Contra và Kontora
Tôi lớn lên trong thời đại của Famicom/NES nên suốt quảng thời gian thơ ấu là những ngày tháng ăn nằm với Mario, Contra, Battle City (bắn xe tăng),... Tôi cứ tưởng mình hiểu rõ lắm về những con game ngày đó rồi. Ấy vậy mà không phải.
Gần đây, một người bạn trong Hội game cổ nhờ tôi giải quyết vấn đề kỹ thuật trong game Contra. Số là anh ấy không biết làm cách nào để thêm số ký tự ít ỏi trong Rom để có thể dịch một bản tiếng Việt hoàn chỉnh.
Tạm gác qua chuyện kỹ thuật, khi tôi nghe anh ấy đề nghị thì không khỏi cười thầm. Ông này quái thật, cái game bắn súng đó có chữ nghĩa gì đâu mà phải dịch. Và tôi tin chắc là rất nhiều người khác cũng nghĩ giống tôi. Cái game Contra trên NES là một tượng đài, một biểu trưng lịch sửa của ngành công nghiệp game thật đấy. Nhưng hầu như người ta chỉ nhớ về nó với lối chơi thú vị, độ khó siêu hạng và nhạc nền siêu hay chứ đâu có ai nhớ nội dung của nó như nào đâu. Đại khái là có 2 anh lính cởi trần, mặc quần xanh đỏ bắn súng ầm ầm như Rambo thôi. Ngoài đoạn text sơ sài ở phần demo mở đầu game như ảnh trên thì chả còn chữ nghĩa gì.
Nhưng sau một lúc trò chuyện thì tôi giật mình khi biết mình đã nhầm. Sau một hồi Google thần chưởng thì biết thêm một số điều về Contra như sau.
■ Konami phát hành phiên bản Contra đầu tiên vào năm 1987 cho hệ máy Acarde.
■ Đến năm 1988 thì Konami port bản game này sang hệ máy Famicom/NES của Nintendō. Và sau đó là nhiều hệ máy khác nữa, cũng như bản thân Konami cũng sản xuất thêm nhiều phiên bản Contra khác.
■ Sau chừng đó năm tháng ngày, đến tận bây giờ thì bản port cho Famicom/NES năm 1988 vẫn được coi là bản Contra hay nhất trong lòng người hâm mộ. Bởi vì mọi thứ đều hợp với nhau một cách hoàn hảo.
■ Bản Famicom, người Nhật dùng 3 chữ Hán là "hồn" (kon), "đấu" (to) và la "ra" để phiên âm chữ "Contra". Contra trong bản tiếng Nhật là 魂闘羅 (hồn đấu la/kontora). Từng chữ Hán đều có nghĩa, nhưng ghép lại thì chẳng có nghĩa gì, mà chỉ là để phiên âm. Giống như Tàu dùng 3 chữ Hán "hoa", "thịnh" và "đốn" để phiên âm chữ Washington vậy.
■ Điều quan trọng nhất: có không ít điểm khác biệt giữa bản Kontora cho máy Famicom (FC) với bản Contra cho máy NES. Sự khác biệt này do kiểu mapping của Rom. Bản Famicom, Rom được sử dụng mapper kiểu VRC2/VRC4 trong khi bản NES dùng kiểu mapper UNROM. Mapper VRC2/VRC4 cho phép bối cảnh cây cối trong Kontora có sự chuyển động, còn ảnh nền trong Contra chỉ là ảnh tĩnh. Ngoài ra, ở bản Kontora còn có thêm nhiều cảnh cutscene mà ở Contra không có, một số đoạn text khiến câu chuyện của Kontora có chiều sâu hơn chỉ là một game bắn súng hời hợi như ấn tượng về Contra.
Dưới đây là video Youtube so sánh những điểm dị biệt giữa Kontora (FC) và Contra (NES).
Tuổi thơ của tôi đã bị lừa dối như thế đấy!
Ngoài ra, giữa Kontora và Contra còn có một số điểm dị biệt khác. Tất cả những điểm này đều được liệt kê đầy đủ tại trang này (click).
Vậy là thử nhòm vào bản Kontora này xem thế nào. Game có nội dung thì cần dịch là đúng rồi. Nhưng trước hết phải dump Rom từ băng ra để có thể debug trên giả lập. Cách dump thì như video bên dưới. Dùng giả lập là chuyện hợp pháp, miễn là có sở hữu băng đàng hoàng. Ngày nay thì có thể dễ dàng mua băng Famicom từ các đầu nậu trong nước hay Quốc tế với giá khá rẻ.
Tiếp theo là đọc một chút về kiểu mapping của Kontora.
https://www.nesdev.org/wiki/VRC2_and_VRC4
Theo tài liệu trên thì đặc trưng của kiểu mapping là một PRG bank là 8Kb (0x2000), và các bank được chuyển đổi qua lại trong CPU memory thông qua các register $8000/$A000.
Đầu tiên là tìm hiểu về cách mà game này ghi chữ ra màn hình. Kontora có nhiều phân đoạn hiển thị text khác nhau. Chẳng hạn như đoạn text ở màn hình đầu tiên, đoạn text mở đầu game, text trong game và đoạn text-roll giới thiệu tên tuổi các thành viên trong đội code. Chúng sử dụng những routine khác nhau.
Đầu tiên là tìm hiểu về routine hiện text ở phần mở đầu game. Đây là phần text chính. Ở routine này, text và thành phần đồ họa khác trong game này đều sử dụng chung một routine để ghi ra màn hình. Routine này ở địa chỉ $CB30 thuộc bank $0E.
bank $0E
org $CB30
LDA $23
BEQ +
JMP $CBC0
+
LDY #$00
STY $08
_CB3B:
LDA $08
CMP #$3F
BNE +
STA $2006
LDA #$00
STA $2006
STA $2006
STA $2006
+
LDX $0700,y
BEQ +
LDA $FF
AND #$18
ORA $CB2A,x
STA $2000
INY
LDA $2002
LDA $0700,y
STA $08
STA $2006
INY
LDA $0700,y
STA $2006
INY
CPX #$03
BEQ _CB7C //write graphic string1
CPX #$04
BCS _CBAF //write graphic string2
BNE _CB9E //write single text
_CB7C:
LDA $0700,y
STA $09
-
INY
LDA $0700,y
STA $2007
DEC $09
BNE -
+
LDA #$00
STA $0700
STA $21
LDA $FF
STA $2000
RTS
_CB99:
LDA #$FF
-
STA $2007
_CB9E:
LDA $0700,y
INY
CMP #$FF
BNE -
LDA $0700,y
CMP #$06
BCS _CB99
BCC _CB3B
_CBAF:
LDX $0700,y
INY
LDA $0700,y
INY
-
STA $2007
DEX
BNE -
JMP $CB3B
Text và graphic được ghi lên Nametable chung một routine này, vốn được gọi ra ở mỗi kỳ NMI. Dữ liệu ghi nằm ở Ram $0700 trở đi. Khi nào cần ghi ra màn hình thì $0700 có giá trị khác zero. Nếu $0700 là 1 thì ghi từng byte text ra Nametable, còn nếu giá trị là 0x03 thì sẽ ghi một chuỗi byte graphic, nếu giá trị của $0700 từ 0x04 trở thì cũng sẽ ghi một chuỗi byte graphic nhưng với cấu trúc khác.
Trường hợp ghi text có cấu trúc:
$0700: trigger ghi text
$0701: high byte địa chỉ cần ghi trong PPU
$0702: low byte địa chỉ cần ghi trong PPU
$0703: giá trị text cần ghi
$0704: cố định là 0xFF để báo kết thúc ghi
$0705: khi có giá trị 0x06 trở lên thì sẽ xóa text vừa ghi, thay thế bằng graphic có giá trị 0xFF. Giá trị 0x00 báo thoát khỏi routine này, kết thúc quá trình ghi.
Các tham số như địa chỉ PPU, giá trị text được ghi vào Nametable, giá trị trigger bắt đầu từ $0700. Những giá trị này được ghi vào $0700 tại bank $08.
bank $08
org $993C
TAY
LDX $21
LDA #$01
STA $0700,x
LDA $49
STA $0702,x
LDA $4A
STA $0701,x
TYA
STA $0703,x
LDA #$FF
STA $0704,x
TXA
CLC
ADC #$05
STA $21
INC $49
RTS
Địa chỉ PPU của text xuất phát từ $49 và $4A. Truy ngược lại thì thấy những giá trị này được ghi vào Ram từ địa chỉ Rom qua một routine khác cũng ở bank $08.
bank $08
org $97B6
LDY #$00
STY $47
STY $48
INY
STY $43
LDA $46
ASL
TAY
LDA $97DB,y
STA $00
LDA $97DC,y
STA $01
LDA $48
ASL
TAY
LDA ($00),y
STA $49
INY
LDA ($00),y
STA $4A
RTS
org $97DB
dw $97E1, $97E7, $97F5
dw $2224, $2264, $22A4, $21A4, $21E4, $2224, $2264
Địa chỉ $97DB ở bank $08 là vị trí của các pointer chỉ đến địa chỉ PPU của text, chúng được ghi vào địa chỉ Ram $49 và $4A, rồi từ đó được ghi vào $0701, $0702.
Routine ghi giá trị của text cũng là một địa chỉ ở bank $08.
org $97FD
DEC $43 //text speed
BNE _9844
LDA #$08
STA $43
LDA $46
ASL
TAY
LDA $9848,y
STA $02
LDA $9849,y
STA $03
LDY $47
INC $47
LDA ($02),y
CMP #$FF
BCS _9846
CMP #$FE
BCC _982C
INC $48
JSR $97BF
LDY $47
INC $47
LDA ($02),y
_982C:
STA $08
AND #$7F
BEQ _9839
PHA
LDA #$0E
JSR $F9BC
PLA
_9839:
JSR $993C //write to ram
LDA $08
BPL _9844
LDA #$30
STA $43
_9844:
CLC
RTS
_9846:
SEC
RTS
text_pointer: //$9848
dw $984E, $9886, $98EC
Qua routine trên thì thấy được $43 là địa chỉ điều khiển tốc độ ghi text. Giá trị mặc định là 0x08. tức sau 8 frame thì mới ghi 1 ký tự. Nếu muốn tốc độ chữ chạy nhanh hay chậm hơn thì chỉ cần thay đổi giá trị này.
Thử nhìn vào PPU khi CPU đang chạy routine hiện text thì thấy bộ font chữ của Kontora chỉ gồm 64 ký tự Katakana, có giá trị từ 0x00 đến 0x3F. Số lượng này không đủ để vẽ lại thành bộ chữ cái tiếng Việt với đầy đủ dấu.
Ngoài ra, còn một vấn đề là hầu hết game Famicom/NES đều dùng tile với kích thước 8x8 pixel để hiển thị text. Với kích thước này thì nếu vẽ thêm dấu tiếng Việt vào chung tile với chữ cái như cách truyền thống mà các nhóm dịch game tiếng Việt hay làm thì sẽ rất xấu.
Vậy nên ở đây ta sẽ không vẽ dấu và chữ cái trong cùng một tile, mà sẽ vẽ dấu ở hàng trên, còn chữ cái ở hàng dưới. Cách này tiết kiệm được số tile sử dụng trong bộ font, chỉ cần đủ các chữ cái và đủ các dấu, ta có thể kết hợp một tile dấu với nhiều tile chữ khác nhau. Chẳng hạn, để diễn đạt các chữ a, á, à, ả, ã, ạ, e, é, è, ẻ, ẹ thì đối với cách truyền thống thì trong Rom cần tới 12 tile với 12 giá trị.
00=a
01=á
02=à
03=ả
04=ã
05=ạ
06=e
07=é
08=è
09=ẻ
0A=ẽ
0B=ẹ
Nhưng đối với cách vẽ tách biệt chữ và dấu thành hai hàng khác nhau thì trong Rom chỉ cần 2 tile để diễn đạt 2 chữ cái a và e, và cần 5 tile để diễn đạt các dấu.
00=a
01=e
02=<sắc>
03=<huyền>
04=<hỏi>
05=<ngã>
06=<nặng>
Như vậy, để diễn tả các chữ cái a, á, à, ả, ã, ạ, e, é, è, ẻ, ẹ thì ta chỉ cần 7 giá trị thật trong Rom để diễn tả a và e, còn các chữ cái còn lại đều là giá trị ảo, không tốn "diện tích" trong Rom.
00=a
01=e
02=á
03=à
04=ả
05=ã
06=ạ
07=é
08=è
09=ẻ
0A=ẽ
0B=ẹ
02=<sắc>
03=<huyền>
04=<hỏi>
05=<ngã>
06=<nặng>
Ở đây có thể thấy là vẫn cần tới 12 giá trị để diễn tả các ký tự trên. Nhưng với cách làm truyền thống thì cả 12 giá trị này đều cần chỗ chứa trong Rom, còn với cách vẽ tách riêng hàng cho chữ và dấu thì chỉ cần có 2 chỗ trống trong Rom, tất cả các giá trị còn lại đều là giá trị luận lý (logic), không cần chỗ chứa trong Rom.
Chẳng hạn, nếu trong routine hiện chữ có sẵn gồm các bước:
1/Đọc giá trị của chữ
2/ Ghi giá trị đó ra màn hình
Thì ta chỉ cần thêm một công đoạn nhỏ như sau.
1/Đọc giá trị của chữ
2/Kiểm tra và xử lý giá trị chữ
3/Ghi giá trị vừa xử lý ra màn hình
Tại bước 2, ta kiểm tra giá trị của chữ vừa đọc là bao nhiêu. Nếu giá trị này không quá 1 thì sẽ chuyển sang bước 3. Nếu giá trị này từ 2 trở lên thì sẽ tham chiếu table để đọc giá trị của chữ và dấu của chữ đó, rồi ghi ra màn hình.
TABLE:
00, 02 //chữ a và dấu sắc
00, 03 //chữ a và dấu huyền
00, 04 //chữ a và dấu hỏi
00, 05 //chữ a và dấu ngã
00, 06 //chữ a và dấu nặng
01, 02 //chữ e và dấu sắc
01, 03 //chữ e và dấu huyền
01, 04 //chữ e và dấu hỏi
01, 05 //chữ e và dấu ngã
01, 06 //chữ e và dấu nặng
Với cách vẽ chữ ra màn hình này thì được kết quả như dưới đây.
Trở lại với đoạn code in chữ ra màn hình được đề cập ở trên, ta chỉ cần thêm vào đó một routine check giá trị chữ như ý tưởng vừa trình bày ở trên là được. Tuy nhiên, nếu thêm bớt code trực tiếp vào đây thì sẽ làm lệch địa chỉ của những routine có sẵn, gây crash game khi CPU đọc tới. Do vậy, ta cần tìm chỗ trống trong Rom để chèn code mới vào. May mắn là Kontora có rất nhiều chỗ trống trong Rom, chẳng hạn như ở bank 09. Cách tìm chỗ trống trong Rom là dùng Hex editor để mở nó lên, nếu thấy những vùng nào toàn giá trị FF hoặc 00 thì khả năng cao đó là vùng dữ liệu trống. Hoặc cũng có thể kiểm tra Rom bằng phần mềm chỉnh sửa đồ họa như yy-chr. Nếu thấy vùng đơn nhất, đồng đều thì đó là vùng trống.
bank 0E
org $CB30
lda #$19
sta $a000
jmp new_location
bank $09
org $fee_space
new_location:
LDA $23
BEQ +
JMP $CBC0
+
LDY #$00
STY $08
_CB3B:
LDA $08
CMP #$3F
BNE +
STA $2006
LDA #$00
STA $2006
STA $2006
STA $2006
+
LDX $0700,y
BEQ +
LDA $FF
AND #$18
ORA $CB2A,x
STA $2000
INY
LDA $2002
LDA $0700,y
STA $08
STA $2006
INY
LDA $0700,y
STA $2006
INY
CPX #$03
BEQ _CB7C //write graphic string1
CPX #$04
BCS _CBAF //write graphic string2
BNE _CB9E //write single text
_CB7C:
LDA $0700,y
STA $09
-
INY
LDA $0700,y
STA $2007
DEC $09
BNE -
+
LDA #$00
STA $0700
STA $21
LDA $FF
STA $2000
RTS
_CB99:
LDA #$FF
-
jsr check_accent
STA $2007
_CB9E:
LDA $0700,y
INY
CMP #$FF
BNE -
LDA $0700,y
CMP #$06
BCS _CB99
BCC _CB3B
_CBAF:
LDX $0700,y
INY
LDA $0700,y
INY
-
STA $2007
DEX
BNE -
JMP $CB3B
check_accent:
cmp #$29
bcs +
pha
jsr prepare
lda #$00
sta {counter_table},x
pla
rts
+
cmp #$5f
bcs +
sec
sbc #$29
asl
tax
stx {save_x}
lda accent1,x
sta $2007
lda $0702
sec
sbc #$20
pha
lda $0701
sbc #$00
sta $2006
pla
sta $2006
jsr check_line
bcs write_sec1
ldx {save_x}
lda accent1+1,x
jmp end_accent1
write_sec1:
ldx {save_x}
lda accent4+1,x
end_accent1:
pha
jsr prepare
lda #$00
sta {counter_table},x
pla
rts
+
cmp #$65
bcs +
sec
sbc #$5f
asl
tax
lda accent2,x
sta $2007
lda $0702
clc
adc #$20
pha
lda $0701
adc #$00
sta $2006
pla
sta $2006
lda accent2+1,x
pha
jsr prepare
lda #$ff
sta {counter_table},x
pla
rts
+
sec
sbc #$65
sta {buffer1}
asl
clc
adc {buffer1}
tax
sta {save_x}
lda accent3,x
sta $2007
lda $0702
clc
adc #$20
pha
lda $0701
adc #$00
sta $2006
pla
sta $2006
lda accent3+1,x
sta $2007
lda $0702
sec
sbc #$20
pha
lda $0701
sbc #$00
sta $2006
pla
sta $2006
jsr check_line
bcs +
ldx {save_x}
lda accent3+2,x
jmp end_accent2
+
ldx {save_x}
lda accent5+2,x
end_accent2:
pha
jsr prepare
lda #$ff
sta {counter_table},x
pla
rts
accent1:
db $01, $2a //á
db $01, $2b //à
db $01, $2c //ả
db $01, $2d //ã
db $01, $2f //â
db $01, $30//ấ
db $01, $31 //ầ
db $01, $32 //ẩ
db $01, $33 //ẫ
db $01, $34 //ă
db $01, $35 //ắ
db $01, $36 //ằ
db $01, $37 //ẳ
db $01, $38 //ẵ
db $09, $2a //í
db $09, $2b //ì
db $09, $2c //ỉ
db $09, $2d //ĩ
db $0F, $2a //ó
db $0F, $2b //ò
db $0F, $2c //ỏ
db $0F, $2d //õ
db $0F, $2f //ô
db $0F, $30 //ố
db $0F, $31 //ồ
db $0F, $32 //ổ
db $0F, $33 //ỗ
db $0F, $29 //ơ
db $0F, $39 //ớ
db $0F, $3a //ờ
db $0F, $3b //ở
db $0F, $3c //ỡ
db $05, $2a //é
db $05, $2b //è
db $05, $2c //ẻ
db $05, $2d //ẽ
db $05, $2f //ê
db $05, $30 //ế
db $05, $31 //ề
db $05, $32 //ể
db $05, $33 //ễ
db $15, $2a //ú
db $15, $2b //ù
db $15, $2c //ủ
db $15, $2d //ũ
db $15, $29 //ư
db $15, $39 //ứ
db $15, $3a //ừ
db $15, $3b //ử
db $15, $3c //ữ
db $19, $2a //ý
db $19, $2b //ỳ
db $19, $2c //ỷ
db $19, $2d //ỹ
accent2:
db $01, $2e //ạ
db $05, $2e //ẹ
db $09, $2e //ị
db $0F, $2e //ọ
db $15, $2e //ụ
db $19, $2e //ỵ
accent3:
db $01, $2e, $2f //ậ
db $01, $2e, $34 //ặ
db $0F, $2e, $2f //ộ
db $0F, $2e, $29 //ợ
db $15, $2e, $29 //ự
db $05, $2e, $2f //ệ
accent4:
db $01, $3d //á .
db $01, $3e //à .
db $01, $d0 //ả .
db $01, $c0 //ã .
db $01, $c2 //â .
db $01, $c3 //ấ .
db $01, $c4 //ầ .
db $01, $c5 //ẩ .
db $01, $c6 //ẫ .
db $01, $c7 //ă .
db $01, $c8 //ắ .
db $01, $c9 //ằ .
db $01, $ca //ẳ .
db $01, $cb //ẵ .
db $09, $3d //í .
db $09, $3e //ì .
db $09, $d0 //ỉ .
db $09, $c0 //ĩ .
db $0F, $3d //ó .
db $0F, $3e //ò .
db $0F, $d0 //ỏ .
db $0F, $c0 //õ .
db $0F, $c2 //ô .
db $0F, $c3 //ố .
db $0F, $c4 //ồ .
db $0F, $c5 //ổ .
db $0F, $c6 //ỗ .
db $0F, $c1 //ơ .
db $0F, $cc //ớ .
db $0F, $cd //ờ .
db $0F, $ce //ở .
db $0F, $cf //ỡ .
db $05, $3d //é .
db $05, $3e //è .
db $05, $d0 //ẻ .
db $05, $c0 //ẽ .
db $05, $c2 //ê .
db $05, $c3 //ế .
db $05, $c4 //ề .
db $05, $c5 //ể .
db $05, $c6 //ễ .
db $15, $3d //ú .
db $15, $3e //ù .
db $15, $d0 //ủ .
db $15, $c0 //ũ .
db $15, $c1 //ư .
db $15, $cc //ứ .
db $15, $cd //ừ .
db $15, $ce //ử .
db $15, $cf //ữ .
db $19, $3d //ý .
db $19, $3e //ỳ .
db $19, $d0 //ỷ .
db $19, $c0 //ỹ .
accent5:
db $01, $2e, $c2 //ậ .
db $01, $2e, $c7 //ặ .
db $0F, $2e, $c2 //ộ .
db $0F, $2e, $c1 //ợ .
db $15, $2e, $c1 //ự .
db $05, $2e, $c2 //ệ .
Ý niệm rất đơn giản. Tại nơi bắt đầu routine ghi chữ ra màn hình, ta buộc CPU nhảy đến địa chỉ mới là một chỗ trống nào đó trong bank mới. Tại địa chỉ mới này, ta chỉ việc copy & paste lại toàn bộ routine có sẵn rồi thêm vào đó một routine mới (chữ đỏ). Với cách này thì khi thêm bớt code sẽ không còn bị crash game nữa. Tất cả những routine khác không liên quan tới routine này vẫn giữ đúng địa chỉ vốn có của chúng.
Trước khi chuyển tới vị trí mới thì cần phải đổi bank, buộc CPU đọc địa chỉ từ bank mà ta sắp chuyển sang. Kỹ thuật chuyển bank khác nhau tùy vào mapper của Rom. Với mapper VRC2/VRC4 của Kontora thì kỹ thuật chuyển đổi được mô tả ở đây (click).
Famicom/NES có thể load bất cứ địa chỉ nào trong phạm vi CPU memory của nó, từ 0x00 tới 0xFFFF. PRG bank được load vào địa chỉ $8000 tới $FFFF của CPU memory. Trong đó, địa chỉ từ $8000 tới $BFFF là các PRG bank biến động, có thể chuyển đổi. Còn địa chỉ $C000 tới $FFFF thường là các bank thường trú, cố định không thay đổi. Theo tài liệu trên thì với mapper VRC2/VRC4 thì ta có thể tùy chỉnh, chọn bank thường trú bắt đầu từ địa chỉ $8000 hoặc $C000, tùy vào thiết lập của Register $9002. Với trường hợp của Kontora, khi hiển thị text thì bank thường trú được cố định ở $C000.
Sau khi chuyển tới địa chỉ mới, ta viết thêm routine kiểm tra giá trị của chữ vừa đọc. Trong đoạn code kiểm tra chữ ở trên thì có thể thấy:
Nếu giá trị nhỏ hơn 0x29 thì ghi giá trị vừa đọc lên màn hình. Các giá trị từ 0x28 trở xuống là các ký tự không dấu.
Các giá trị trong khoảng từ 0x29 tới 0x5E là ký tự có dấu phía trên, chẳng hạn như â, á, ứ, ỗ,... Những ký tự này sẽ được tham chiếu table để ghi chữ cái ở hàng dưới và dấu ở hàng trên.
Các giá trị từ 0x5F tới 0x64 là các chữ cái có dấu nặng. Chúng sẽ được tham chiếu table để ghi ký tự ở hàng trên và dấu ở hàng dưới.
Các giá trị từ 0x65 tới 0x6A là các chữ cái vừa có dấu bên trên, vừa có dấu nặng bên dưới: ậ, ậ, ệ, ộ, ợ, ự. Chúng sẽ được tham chiếu table để ghi dấu ở hàng trên và hàng dưới, còn chữ cái được ghi ở hàng giữa.
Tất nhiên là cách bố trí chữ trong table như thế nào là tùy mỗi người, không có một ước thúc, ràng buộc hay quy tắc nào, nhưng chúng cần được sắp xếp hợp lý để tránh rối code. Quy tắc cơ bản của lập trình là sắp xếp dữ liệu một cách khoa học, có tính quy tắc, tránh rối rắm.
Và có lẽ bạn cũng đang ngạc nhiên khi thấy trong file table của tôi lại có những con dấu như "ngã nặng", "mũ hỏi nặng",.... Chúng là cái gì? Trong tiếng Việt đâu có những dấu này. Đây là những con dấu ảo được tạo ra để khắc phục nhược điểm sau dấu nặng của dòng chữ bên trên bị những dấu trên đầu của dòng chữ phía dưới che mất. Chẳng hạn như hình dưới đây.
Trong ví dụ trên, ở dòng đầu tiên vẫn hiện đầy đủ các dấu, nhưng khi dòng text bên dưới được ghi lên màn hình thì các dấu trên đầu của nó sẽ ghi đè lên dấu nặng của dòng trên. Dĩ nhiên là có cách khắc phục như giãn khoảng cách giữa các dòng chữ ra, hoặc thêm khoảng trắng vào vị trí mà ngay trên nó là dấu nặng. Tuy nhiên những cách này trông không được đẹp mắt.
Cách khắc phục tạm ổn là giãn khoảng cách giữa các dòng, nhưng trông không được đẹp mắt và còn làm giảm số lượng dòng chữ có thể hiển thị trong một "trang" màn hình. Do vậy mới cần đến những tile vừa thể hiện cả dấu nặng của dòng text trên, vừa thể hiện cả dấu phía trên của dòng text bên dưới.
Ý niệm ở đây rất đơn giản. Ở routine ghi chữ, ta thêm một đoạn code sao cho khi mỗi ký tự được đọc, chúng sẽ được kiểm tra xem có phải là ký tự có dấu trên đầu hay không. Chẳng hạn như chữ "á". Nếu đúng, sẽ kiểm tra tiếp giá trị ngay phía trên đó có phải là dấu nặng hay không. Nếu đúng thì tại vị trí ký tự vừa đọc (chữ "á") thì CPU sẽ ghi chữ "a" và dấu "sắc nặng" lên màn hình. Trong đó "sắc nặng" là tile thể hiện vừa dấu sắc vừa dấu nặng.
Để kiểm tra vị trí ngay bên trên ký tự cần kiểm tra có phải dấu nặng hay không thì cần phải làm thêm một bước. Trong hình dưới đây, mỗi khi ký tự được đọc thì CPU sẽ ghi vào vùng Ram trống nào đó (trong ví dụ này là địa chỉ $0390 trở đi). Nếu là ký tự có dấu nặng thì CPU sẽ ghi giá trị 0xFF, nếu không phải thì CPU sẽ ghi 0x00 vào Ram. Địa chỉ ghi trong vùng Ram này cũng tăng dần tương ứng với vị trí của ký tự đang được đọc trong chuỗi text.
Sau đó, ngay trước khi được ghi ra màn hình, giá trị text sẽ được kiểm tra xem có phải là ký tự có dấu trên đầu không bằng cách đọc địa chỉ Ram phía trên nó có giá trị 0xFF không. Nếu không đúng thì nó sẽ được ghi dấu vốn có, còn nếu phía trên nó là dấu nặng (địa chỉ Ram tương ứng là 0xFF) thì dấu vốn có sẽ được thay bằng tile thể hiện dấu nặng và dấu vốn có.
Trên đây là toàn bộ khái niệm về việc ghi dấu tiếng Việt ra màn hình ở routine hiện chữ trong phần mở đầu game. Tuy nhiên, do routine này được dùng vào nhiều mục đích khác nhau, như vẽ bối cảnh, vẽ sprite chứ không phải routine chuyên dụng để vẽ chữ nên khi ta thêm vào đó routine check dấu, vẽ dấu thì sẽ khiến những chỗ không phải là text sẽ xuất hiện những thành phần đồ họa không mong muốn. Do vậy, cần phải biến routine check và vẽ dấu tiếng Việt thành routine chuyên dụng cho text. Việc này không phải là khó.
Ngoài ra, phần text sau khi kết thúc mỗi màn chơi cũng được code tương đối giống với phần code hiện chữ ở đoạn mở đầu. Chỉ có một vài điểm khác biệt nhỏ, và đoạn code này nằm ở bank $09 chứ không phải bank $08.
Đối với đoạn text ở màn hình đầu tiên sau một lúc không nhấn nút thì routine hiển thị text có khác với routine thông thường. Kích thước tile ở đây không phải là 8x8 như thông thường, mà là 16x16 để có thể hiển thị chữ Kanji một cách rõ ràng. Tuy nhiên phần khác nhau chỉ nằm ở routine ghi giá trị text vào chuỗi Ram từ $0700, còn cách ghi giá trị từ chuỗi Ram này vào PPU thì không khác. Chỉ có 2 điểm cần lưu ý với routine ghi text này là pallete màu khác với thông thường, và ta cần chuyển đổi CHR bank sang bank chữ cái tiếng Việt 8x8 thay vì chữ Kanji và Kana 16x16.
Trên đây là khái niệm chung về cách hiển thị tiếng Việt ra màn hình. Hoàn toàn có thể áp dụng những ý niệm này vào nhiều routine hiện chữ khác nhau, chẳng hạn như tên màn (BASE1, WATERFALL,...) và những phần hiển thị chữ khác.
Cuối cùng, bạn có thể tải patch tiếng Việt cho Rom Contra bản Nhật cũng như bộ mã nguồn của bản dịch tại đây. Bản Việt ngữ hóa này chạy trên máy Famicom/NES và các phần mềm giả lập hệ máy này.
Điểm đặc biệt trong bản dịch Việt văn này là bạn có thể chuyển đổi các loại đạn thông qua những nút bấm... bí mật. Bấm nút nào để đổi đạn thì bạn hãy tự tìm hiểu.