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:

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.