Tự viết game NES

  • Thread starter Thread starter asm65816
  • Ngày gửi Ngày gửi

asm65816

Mega Man
Tham gia ngày
23/5/09
Bài viết
3,320
Reaction score
4,851
Lập trình game NES

Dẫn nhập

Từ khi cho ra đời bản dịch Fire Emblem Việt ngữ đầu tiên trên hệ máy SNES vào đầu năm 2010 đến nay, có nhiều người đã hỏi tôi rằng “làm thế nào để dịch game ABC, XYZ…”. Mặc dù đã có một bài hướng dẫn về dịch game console (cũng như PC) đăng trên diễn đàn GameVN từ năm 2010, nhưng nhiều hình ảnh minh họa đã không còn nên gây ra sự khó hiểu đối với người đọc. Sau đó, tôi cũng đã viết một số bài hướng dẫn tập trung cụ thể vào những game nhất định như Resident Evil 5 (PC), Fire Emblem V, Final Fantasy IX (PlayStation) nhưng nhìn lại vẫn còn khá khó hiểu với đa số người đọc vì quá chuyên sâu vào một game nhất định và đặt tiền đề là người đọc đã có những nền tảng cơ bản nhất định.

Bí quyết của việc dịch/hack game là ở chỗ nắm rõ cách thức nó được viết ra, hay nói nôm na là ngôn ngữ lập trình của nó. Đối với các thế hệ console trước đây thì người ta không dùng các ngôn ngữ cao cấp như C để viết, mà sử dụng loại ngôn ngữ cấp thấp (ngôn ngữ giao tiếp với máy) gọi là Assembly (viết tắt là Asm) để viết. Dĩ nhiên là từng loại máy sử dụng từng loại Asm khác nhau, chẳng hạn máy NES sử dụng Asm6502, máy SNES sử dụng Asm65816,… nhưng chúng có nhiều ý niệm cơ bản tương đồng nhau. Vì vậy, loạt bài viết này giới thiệu đến bạn đọc từ cái nhìn tổng quát cho tới chuyên sâu vào ngôn ngữ Asm của NES thông qua những chương trình đơn giản (có thể chạy được bằng giả lập NES) để từ đó người đọc nắm bắt được những nét cơ bản, tạo dựng nền tảng và sự hứng thú để học hỏi tiếp ngôn ngữ ở các hệ máy khác như dòng PlayStation, dòng Xbox….
Dự định loạt bài này gồm hơn 20 bài, đi từ những nền tảng cơ bản nhất đến chi tiết với độ khó tăng dần. Những kiến thức trong loạt bài này là kết quả tiếp thu từ nhiều nguồn khác nhau trong nhiều năm tháng qua, và phần lớn kiến thức đều dẫn từ sách “ngôn ngữ Assembly 6502/65C02” của tác giả Leo J. Scanlon, bản tiếng Nhật do Yonamine Keiko dịch.

*Những khái niệm cơ bản như bit, byte, hệ thập phân, nhị phân và thập lục không được đề cập ở đâu. Nếu muốn thì người đọc có thể tự tìm hiểu thêm.


clGZof0.jpg

Bài 1: khái quát về máy NES

Family Computer (viết tắt là FC, hay gọi tắt kiểu Nhật là Famicom) là loại máy chơi game thuộc thế hệ thứ 3 do hãng Nintendō phát triển, bán ra thị trường Nhật Bản vào ngày 15 tháng 7 năm 1983. Các thị trường Mỹ (1985), Gia Nã Đại, Trung Quốc, Hương Cảng (1986), Úc Thái Lợi (1987), Đại Hàn (1989) lần lượt sau đó.
Máy được nhà sản xuất đề nghị giá bán lẻ là 14.800 En và có tên mã là HVC-001. HVC là chữ viết tắt của Home Video Computer. Trong nước Nhật, nó được gọi tắt là Famicom hay FC, nhưng lại được biết đến rộng rãi trên Thế giới với tên gọi NES (Nintendō Entertainment System).

Có thể nói sự thành công của NES là một điểm nhất trong lịch sử video game. Nếu không có NES thì có lẽ đã không có SNES, PlayStation hay Xbox sau này. Nếu NES không thành công vang dội thì có lẽ Thế giới đã không chú ý đến việc phát triển video game, nên đừng nói là ảnh hưởng đến các loại console mà có thể nó còn ảnh hưởng đến cả mảng game cho PC.

Thành phần chính cấu thành nên một máy NES (FC) như sau:

+ Rom Casette (Cartridge): băng chứa nội dung game, cắm vào máy để chơi. Với băng chính hãng thì mỗi băng chỉ chứa một trò, sau phát sinh băng lậu chứa nhiều hơn, từ vài trò đến vài trăm trò trong một băng.
+ CPU: RP2A03 của hãng Rihco.
+ APU: bộ xử lý âm thanh.
+ PPU: bộ xử lý hình ảnh Rihco RP2C02.
+ Working Ram: 2 kb.
+ Video Ram: 2 kb.
+ Độ phân giải: 256 x 240 đường, độ phân giải này thay đổi tùy vào hệ màu của TV.
+ Controller.

NES là hệ máy có các thanh ghi (Register) 8 bit (1 byte) và Address bus (hình dung nó như chiếc xe bus chạy đến các địa chỉ) 16 bit (2 byte) nên nó có thể truy cập $FFFF (65535) địa chỉ trong bộ nhớ. Vì vậy nên địa chỉ NES cơ bản có dạng như $8000, $1A0E,….
Dấu $ đứng đầu con số cho biết nó được thể hiện ở hệ số thập lục.
Dấu % đứng đầu con số cho biết nó ở dạng nhị phân, chẳng hạn %00010111. Còn khi con số đứng độc lập mà không kèm các ký hiệu trên thì có nghĩa nó là số thập phân.
 
có hướng dẫn về các hệ máy như NDS ko hả bác
 
có hướng dẫn về các hệ máy như NDS ko hả bác
Hiện tại chưa ngâm cứu NDS, chỉ mới có NES, SNES và PS1, 2 thôi.



Bài 2: khái quát về cấu trúc NES


Trước khi đi vào cấu trúc của NES, cần nắm rõ những khái niệm cơ bản trong lập trình. Và đối với ngôn ngữ cấp thấp như Asm thì cần phải hiểu biết nhiều về phần cứng. Tất cả mọi ngôn ngữ lập trình đều có chung 3 khái niệm cơ bản là tập lệnh (chỉ thị), biến số và lưu trình điều khiển. Nếu thiếu bất kỳ yếu tố nào trong số này thì đều không thể xem là ngôn ngữ lập trình được. Chẳng hạn, HTML vốn không có lưu trình điều khiển nên không được xem là một ngôn ngữ lập trình.


Tập lệnh: Câu lệnh (chỉ thị) là thành phần nhỏ nhất mà bộ xử lý thực hiện. Các câu lệnh được thực thi lần lượt từng câu một, lần lượt nối tiếp nhau. Trong bộ xử lý của NES chỉ có 56 câu lệnh, trong đó khoảng 10 câu lệnh thường dùng lặp đi lặp lại, chẳng hạn chứa một giá trị vào Register, câu lệnh so sánh một biến số với zero…

Biến số: Biến là một vùng lưu trữ dữ liệu có thể bị thay đổi. Chẳng hạn như số HP của nhân vật, nó có thể giảm xuống khi bị phe địch tấn công, được tăng lên khi dùng Item hồi phục. Biến có thể thay đổi bất kỳ lúc nào trong game khi một event liên quan xảy ra. Tất cả biến số trong mã nguồn đều có tên do người dùng đặt.

Lưu trình điều khiển: Về cơ bản thì các câu lệnh được thực hiện theo trật tự liên tục, một chiều từ trước ra sau. Nhưng đôi khi bạn muốn bộ xử lý thực thi một phần khác trong mớ code, tùy thuộc vào biến số. Lưu trình điều khiển có chức năng thay đổi hướng thực thi của chương trình, chẳng hạn như khi nhân vật đang bị tấn công thì nó sẽ nhảy đến đoạn code kiểm tra xem HP của nhân vật đã về zero hay chưa. Nếu đã về zero thì lại nhảy đến đoạn code xử lý hình ảnh nhân vật ngả xuống và không cho người chơi điều khiển nữa…

Mọi máy tính phổ thông đều có chung cơ cấu gồm một khu vực để chứa dữ liệu, code (ROM), một khu vực chứa các biến số (RAM) và một bộ xử lý (CPU) để thực thi code. Tuy nhiên CPU của NES còn có một thành phần khác gọi là APU (Audio Processing Unit) để xử lý âm thanh. Ngoài ra NES còn có một bộ xử lý khác để tạo ra hình ảnh, gọi là PPU. Những khái niệm ở đây hết sức cơ bản và sẽ lần lượt đề cập chi tiết trong những bài sau.

Hệ thống cấu trúc của NES

ROM: Viết tắt của cụm từ Read Only Memory, bộ nhớ chỉ đọc. Đây là khu vực chứa dữ liệu hình ảnh, âm thanh, mã chương trình… và không thể bị thay đổi trong quá trình thực thi. Đối với máy NES thì ROM được chứa trong con chip bên trong băng cartridge, đối với PlayStation thì ROM được chứa trong đĩa CD, với PlayStation 2 là DVD, PlayStation 3 là Blu-ray hay ổ cứng….

RAM: Viết tắt của cụm từ Random Access Memory, bộ nhớ truy cập ngẫu nhiên. Ram chứa dữ liệu có thể đọc hay ghi đè lên trong quá trình thực thi. Chẳng hạn cùng một địa chỉ Ram quản lý số HP, ban đầu giá trị là 80 (HP) nhưng khi bị địch tấn công, giá trị mới là 79 có thể ghi đè lên giá trị cũ. Khi tắt nguồn điện thì Ram mất hoàn toàn. Đối với máy NES thì có thể dùng pin để “nuôi” không cho Ram chết. Một số game như Final Fantasy, Fire Emblem có thời lượng rất dài, người chơi không thể hoàn thành game trong một thời gian ngắn nên băng cartridge cho những game này đều có pin nuôi dữ liệu (Ram) nên người chơi có thể tiếp tục ở chỗ đã dừng sau khi tắt máy đi ngủ một thời gian. Đây cũng là lý do khiến máy Gameboy mất hết dữ liệu save sau khi thay pin.

PGR: Program Memory, bộ nhớ chương trình. Nói nôm na là phần code của game. Đây là một trong những thành phần chính của ROM.

CHR: Character Memory. Một trong những thành phần chính của ROM. Nó chính là vùng chứa dữ liệu đồ họa của game.

CPU: Cental Processing Unit, chip xử lý chính. Phần này nằm trong máy NES.

PPU: Picture Processing Unit, chip xử lý đồ họa nằm trong máy NES.

APU: Audio Processing Unit, chip âm thanh nằm trong CPU.

Tổng quan về hệ thống

Máy NES bao gồm một CPU 6502, một APU và Controller chung trong một con chip, một APU xử lý đồ họa trong con chip khác. Khi cắm Rom (băng Cartridge) vào máy, người chơi nhấn nút trên Controller, CPU sẽ đọc code trong Rom và thực thi chúng, gửi mệnh lên đến APU và PPU, từ đó phát âm thanh ra loa và hình ảnh ra màn hình.

Sơ đồ dưới đây thể hiện khái quát về cách hoạt động của máy NES

ZZRP3LB.png

Chỉ có 2KB RAM kết nối với CPU để lưu trữ biến số và 2KB RAM kết nối với PPU để giữ 2 màn hình TV đối với hình ảnh nền (bối cảnh, background). Chú ý, KB là viết tắt của chữ KiloByte, 1KB=1024 byte. Nếu viết là Kb (chữ “b” viết thường) thì có nghĩa là KiloBit. 1 byte = 8 bit nên 1Kb=1/8 của 1KB.
Một vài băng Cartridge còn có thêm WRAM (Work RAM), một dạng CPU RAM. Nếu game cần lưu dữ liệu save (như Final Fantasy) thì sẽ có thêm viên pin nuôi WRAM này để dữ liệu không bị mất khi tắt máy, như đã nói ở phần trên. Một vài băng game còn có thêm PPU RAM để giữ 4 màn hình ảnh nền (background) cùng lúc, nhưng số lượng băng này không nhiều.

Mỗi băng Cartridge có ít nhất 3 con chip: chip chứa code chương trình (PRG ROM), chip chứa dữ liệu đồ họa (CHR) và một chip cho bộ khóa. Tuy nhiên dữ liệu hình ảnh có thể là RAM thay vì ROM, tùy vào game. Điều này có nghĩa là code của game sẽ cspy hình ảnh từ PRG ROM sang CHR RAM.

Bộ khóa

Bên trong máy NES và trong băng Cartridge luôn có 2 con chip khóa. Chip bộ khóa quản lý việc reset máy. Đầu tiên, bộ khóa trong NES gửi đi một xung với tần số nào đó, bộ khóa trong Cartridge ghi nhận con số này. Sau đó cả 2 bộ khóa thực hiện một phương trình phức tạp sử dụng con số đó và gửi kết quả cho nhau. Cả 2 chip đều biết rõ đối phương cần phải gửi dữ liệu gì nên cả 2 đều biết rõ khi có bất thường xảy ra. Khi có bất thường thì hệ thống sẽ đi vào vòng lặp khởi động lại liên tục. Điều này giải thích cho hiện tượng màn hình chớp sáng khi cắm băng bị bẩn vào.
Mục đích của bộ khóa là để ngăn chặn băng game lậu. Những băng lậu sau này đều có bộ bẻ khóa. Nguyên tắc hoạt động của chúng là gửi dòng điện từ băng làm tê liệt bộ khóa trong máy NES, ngăn không cho nó khởi động lại hệ thống. Sau đó, Nintendō cũng dần có đối sách phòng ngừa chuyện này. Khi mở máy NES, kiểm tra bản mạch sẽ thấy dòng NES-CPU- và con số tiếp sau đó. Con số này chính là phiên bản. 11 là phiên bản cuối cùng và hầu như không có game lậu nào chơi được trên máy NES-CPU-11.

Tổng quan về CPU

NES (Famicom) sử dụng CPU 6502 của hãng Motorola với một chút chỉnh sửa. 6502 là CPU 8 bit, nổi tiếng vì nó được sử dụng trong máy NES và máy Apple II, Atari 2600. Đương thời, sức mạnh của NES không thể sánh với một cỗ máy tính bình thường, nhưng cũng là quá đủ để chơi game.
CPU 6502 có Address bus 16 bit nên có thể truy cập vào 64KB bộ nhớ ($00~$FF). Bao gồm trong không gian bộ nhớ đó là 2KB của CPU RAM, các cổng để truy cập vào PPU/APU/Controller, WRAM (trong băng) và 32 KB dành cho PRG ROM. Chẳng hạn, RAM nội bộ bắt đầu từ $0000 ~ $0800. $0800 = 2048, tức 2 Kb. Phần chương trình gói gọn trong 32 KB, nhưng 32 KB là con số khá nhỏ đối với nhiều game nên Memory mapper được dùng để giải quyết vấn đề này.

z5pZpGq.png

Tổng quan về PPU

PPU trên NES là con chip hiển thị đồ họa, nó bao gồm RAM nội bộ chứa sprite (ảnh nhân vật) và palette màu. Trên bảng mạch NES còn có RAM chứa ảnh nền, và hình ảnh thực tế đều được xử lý từ bộ nhớ CHR trong băng Cartridge.
Chương trình chính không chạy trên PPU mà nó chỉ chứa vài tùy chỉnh như màu sắc hay cuốn màn hình. PPU xử lý một đường quét của TV tại một thời điểm. Đầu tiên, dữ liệu sprite được nạp từ CHR trong băng cắm, nếu có nhiều hơn 8 sprite trên một đường quét thì phần còn lại sẽ bị bỏ qua. Vì vậy, ở một số game thì hình ảnh sẽ nhấp nháy khi có nhiều thứ cùng thể hiện trên màn hình. Sau sprite là đến ảnh nền được nạp từ CHR. Khi tất cả các đường quét đều được xử lý xong thì sẽ có một khoảng thời gian không có bất kỳ hình ảnh nào được gửi ra màn hình. Khoảng này gọi là VBlank và là thời gian duy nhất để cập nhật đồ họa. TV hệ PAL có thời gian VBlank dài hơn, cho phép nhiều thời gian cập nhật hơn. Một vài game hệ PAL không chạy được trên TV NTSC vì khác biệt trong thời gian VBlank này. Cả PAL và NTSC đều cho độ phân giải 256x240 điểm ảnh, nhưng hàng trên cùng và dưới cùng của TV NTSC thường bị cắt đi 8 điểm ảnh, nên chỉ còn 256x224.

Tổng quan về đồ họa

Tile: tất cả mọi hình ảnh đều được cấu thành từ những tile 8x8 điểm ảnh. Những hình ảnh lớn hơn, như ảnh nhân vật, được cấu thành từ liên hợp nhiều tile 8x8. Hình ảnh dạng tile cần ít bộ nhớ hơn, nhưng không thể hiện được hình ảnh 3D hay bitmap. Có thể dùng các phần mềm như Tile Molester hay YY-CHR để xem tile bên trong game NES.

Sprite: PPU có đủ bộ nhớ để chứa 64 sprite hay những thứ chuyển động trên màn hình. Trên mỗi đường quét chỉ được phép tối đa là 8 sprite, số nhiều hơn sẽ bị bỏ qua.

Background: chính là ảnh nền, có thể cuộn theo chiều ngang hay dọc. Chẳng hạn trong game Mario, khi nhân vật di chuyển sang phải thì bối cảnh cũng thay đổi theo, tạo cảm giác màn hình đang cuốn sang phải. Bối cảnh gồm các tile 32x30 điểm ảnh. Có 2 màn hình bối cảnh được giữ trong bộ nhớ.

Pattern Table: là vùng chứa dữ liệu hình ảnh thực tế, có thể là trong ROM hay RAM trong băng Cardtridge. Mỗi pattern gồm 256 tile. Một table được dùng cho bối cảnh, một để chứa sprite. Tất cả hình ảnh hiện trên màn hình đều phải nằm trong những table này.
Table thuộc tính: những table này thiết lập thông tin màu sắc trong những khu vực 2x2 tile. Có nghĩa là một vùng 16x16 điểm ảnh chỉ có 4 màu khác nhau được lựa chọn từ palette.

Palette: 2 khu vực này đều giữ thông tin về màu sắc, 1 cho ảnh nền và 1 cho ảnh sprite. Mỗi palette có 16 màu. Để hiển thị 1 tile lên màn hình, con số chỉ định màu sắc của điểm ảnh được đọc từ Pattern table và table thuộc tính, rồi con số này tìm trong palette để lấy màu sắc thực tế.

Dưới đây là hình ảnh sơ đồ PPU.

OTFUC2I.png
 
Bài 3: cái nhìn chung về Asm6502

Từ bài này ta sẽ đi vào phần lập trình của 6502. Tài liệu về 6502 có rất nhiều trên Google, người đọc có thể tìm hiểu thêm nếu thích.
Có 5 phần chính trong ngôn ngữ Assembly như được giới thiệu lần lượt bên dưới. Trong đó có những phần phải được viết theo một vị trí hàng ngang cố định mới có hiệu lực.

Để tạo ra một game NES (file có đuôi .Nes), đầu tiên ta viết chương trình trong một file text có đuôi .Asm rồi chạy với trình compiller. Compiller là trình biên dịch, nó sẽ chuyển các mệnh lệnh trong file text thành dạng số mà máy có thể hiểu được. Nói nôm na, nhiệm vụ của compiller là dịch từ ngôn ngữ mà con người hiểu được thành ngôn ngữ máy. Chẳng hạn, trong file text ta có mệnh lệnh LDA #03 thì trình biên dịch sẽ chuyển thành 03A9.

Click vào đây để tải NESASM.

Có khá nhiều compiller cho NES, trong số đó có compiller dựa trên nền ngôn ngữ C, nhưng ở đây chỉ giới thiệu NESASM, trình biên dịch Assembler để giúp người đọc hiểu rõ. NESASM là Assembler có thể tạo ra file.NES từ mã nguồn. Sau khi có file.NES, có thể dùng giả lập để chơi. Có nhiều loại giả lập NES, nhưng khuyên dùng Fceux vì nó có chức năng Debugger và Dis-assemble, chức năng phân tích ngược lại từ ngôn ngữ máy ra ngôn ngữ Assmbly. Giả lập này có thể tải miễn phí từ Google.

Dưới đây là lưu trình phát triển một game NES.

sEMF8dD.png

Lệnh dẫn hướng: là những lệnh chỉ cho trình Assembler biết cần phải làm gì, chẳng hạn như tìm code trong bộ nhớ. Các lệnh này đều bắt đầu bằng dấu "." (không ngoặc kép) với một khoảng trắng đứng trước. Có người dùng phím tab để tạo khoảng trắng, hay 4 dấu cách, có người dùng 2 dấu cách. Chẳng hạn, lệnh dẫn hướng dưới đây chỉ cho trình Assembler biết phải đặt code vào trong ROM, bắt đầu từ địa chỉ $8000 tron bộ nhớ"

.org $8000

Label: nhãn. Label phải được viết ngay sát lền trái và có dấu ":" (không ngoặc kép) liền sau. Lable là tên gán cho một đoạn code nào đó để tránh mất thời gian viết khi lặp lại nhiều lần, hay để dễ hiểu, tránh nhầm lẫn. Chẳng hạn có label như sau:

.org $8000
Gokuraku:

Khi trình Assembler gặp label, nó sẽ tự động chuyển label thành địa chỉ. Trong ví dụ trên, ta gán nhãn Gokuraku cho .org $8000 thì

STA Gokuraku

sẽ được dịch thành STA .org $8000

Opcode: opcode là mệnh lệnh mà bộ xử lý thi hành. Trong ví dụ sau, JMP là opcode chỉ cho bộ xử lý nhảy tới label GameVN:

.org $8000
GameVN:
JMP GameVN

Tất cả các opcode trong các ngôn ngữ console đều là chữ viết tắt gồm 3 ký tự. JMP là opcode có giá trị $4C.

Operand: là thành phần thông tin bổ trợ cho opcode. Opcode có thể đi kèm từ 1~3 oprand. Trong ví dụ dưới đây thì #$FF chính là operand:

.org $8000
Google:
LDA #$FF
JMP Google

Nói cách khác, operand chính là giá trị đi kèm với opcode. Cả opcode và operand cấu thành nên một câu lệnh hoàn chỉnh, gọi là mnemonic.

Comment: là một kiểu ghi chú bằng "tiếng người" để hiểu được mớ code lằng nhằng đó có ý nghĩa gì. Phần comment sẽ không được trình biên dịch chú ý nên nó không ảnh hưởng gì tới chương trình, chỉ giúp người lập trình dễ dàng theo dõi những gì đang viết mà thôi. Trong ngôn ngữ Assembly, comment được viết sau dấu ; trong khi C hay Java là //, VB là '.

LDA #%00100001 ; nạp giá trị 00100001 vào Register A (% thể hiện hệ nhị phân)
LDX #13 ; nạp giá trị 13 (thập phân) vào Register X
LDY #$0F ; nạp giá trị 0F (thập lục) vào Register Y

Tổng quát về bộ xử lý 6502

6502 là bộ xử lý 8 bit với Address bus 16 bit, có thể truy cập vào 64KB bộ nhớ mà không cần đổi bank. Không gian bộ nhớ này được chia thành RAM, PPU/APU/Controller và ROM.

Phần bộ nhớ của Famicom được chia làm RAM và ROM, gọi là Memory Map. Đại khái như dưới đây.

$0000-$07FF: RAM nội bộ, dung lượng 2KB bên trong NES, user tự do sử dụng
$0800-$1FFF: đối xứng gương của RAM
$2000-$2007: các cổng truy cập vào PPU (I/O Register)
$2008-$3FFF: đối xứng gương của I/O Register
$4000-$401F: các cổng truy cập vào APU và Controller
$4020-$5FFF: ROM mở rộng
$6000-$7FFF: WRAM có thể có hay không bên trong Cartridge (backup RAM)
$8000-$BFFF: ROM bên trong Cartridge
$C000-$FFFF: ROM bên trong Cartridge

Phần bộ nhớ người dùng có thể tùy nghi sử dụng là từ $0000 đến $07FF, dung lượng 2KB.

Tổng quan về Asm 6502

Ngôn ngữ Assembly cho 6502 bắt đầu với code 3 chữ cái như mô tả ở phần trên. Có tất cả 56 lệnh, trong số đó có chừng 10 lệnh hay được dùng nhất. Nhiều lệnh trong đó có giá trị (operand) đi kèm opcode. Phần giá trị (operand) này có thể viết ở 3 dạng: thập lục, thập phân và nhị phân. Dấu $ đứng trước giá trị cho biết nó ở hệ thập lục. Dấu % đứng trước giá trị cho biết nó ở hệ nhị phân, trong khi số thập phân thì đứng độc lập, không đi kèm ký hiệu gì.
Nếu giá trị không có dấu # đi kèm thì nghĩa là giá trị tại địa chỉ đó. Chẳng hạn:

LDA #$000A ; tải giá trị 0A vào Register A
LDA $000A ; tải giá trị tại địa chỉ $0A trong bộ nhớ vào Register A

Giải thích về Register

Register là một vùng bên trong bộ xử lý để giữ một giá trị. Từ "register" có nghĩa là lưu giữ. 6502 chỉ có 3 Register cho phép tự do sử dụng, ngoài ra còn có Stack Register và vài Register nữa nhưng tôi không đề cập đến trong bài này. Các Register đó là:


A: Accumulator, dùng để tính toán.
X, Y: Index, chỉ mục. Dùng để truy nhập vào bộ nhớ.

Các Register của 6502 chỉ có 8bit nên chúng chỉ chứa được giá trị từ 00 đến FF (0~255).

Accumulator: Register này được ký hiệu là A, là Register chủ yếu để chứa, tải, so sánh và tính toán trên dữ liệu. Một vài lệnh thường dùng liên quan đến Accumulator:

LDA #$FF ; tải giá trị hex FF (255) vào trong A
STA $0000 ; chứa (ghi) Accumulator vào địa chỉ $0000 trong bộ nhớ, đây là phần RAM nội bộ

Index Register X: thường được dùng để đếm hay truy nhập bộ nhớ. Trong các vòng lặp thì Register này được dùng để theo dõi vòng lặp đã lặp được bao nhiêu lần, trong khi dùng A để xử lý dữ liệu. Vài ví dụ:

LDX $0000 ; tải giá trị bộ nhớ tại địa chỉ $0000 vào X
INX ; tăng X thêm 1 đơn vị, X=X+1

Index Register Y: hoạt động giống X. Tuy nhiên có vài lệnh chỉ hoạt động với X mà không hoạt động với Y. Vài ví dụ:

STY $00BA ; chứa Y vào địa chỉ $00BA trong bộ nhớ
TYA ; chuyển Y vào Accumulator

Status Register: Register trạng thái này giữ flag với thông tin về mệnh lệnh trước. Chẳng hạn như khi đang thực hiện phép trừ, nó cho phép kiểm tra xem kết quả có phải zero không.


Các lệnh 6502 căn bản

Dưới đây là các lệnh căn bản. Một số lệnh phức tạp hơn sẽ được đề cập ở những phần sau.

Opcode liên quan đến nạp/ghi

LDA #$0A ; nạp giá trị 0A vào Accumulator
; phần giá trị có thể là con số hoặc địa chỉ
; nếu giá trị là zero thì zero flag được thiết lập

LDX $0000 ; nạp giá trị tại địa chỉ $0000 vào trong bộ nhớ vào Index Register X
; nếu giá trị là zero thì zero flag được thiết lập

LDY #$FF ; nạp giá trị FF vào trong bộ nhớ vào Index Register Y
; nếu giá trị là zero thì zero flag được thiết lập

STA $2000 ; chứa giá trị của A vào địa chỉ $2000
; phần số phải là địa chỉ, không chấp nhận giá trị

STX $5A12 ; chứa giá trị của X vào địa chỉ $5A12
; phần số phải là địa chỉ, không chấp nhận giá trị

STY $010F ; chứa giá trị của Y vào địa chỉ $010F
; phần số phải là địa chỉ, không chấp nhận giá trị

Đồ hình dưới đây giải thích rõ thêm về chức năng của LDA và STA.

wCZIXY1.gif

TAX ; (transfer A to X) chuyển giá trị từ A vào X
; nếu giá trị là zero thì zero flag được thiết lập

TAY ; (transfer A to Y) chuyển giá trị từ A vào Y
; nếu giá trị là zero thì zero flag được thiết lập

TXA ; chuyển X vào A
; nếu giá trị là zero thì zero flag được thiết lập

TYA ; chuyển Y vào A
; nếu giá trị là zero thì zero flag được thiết lập


Opcode tính toán phổ thông

ADC #$01 ; Add with Carry, cộng với carry
; A= A + $01 +carry
; nếu kết quả là zero thì zero flag được thiết lập

SBC #$80 ; Subtract with Carry, trừ với carry
; A= A - $80 - (1 - carry)
; nếu kết quả là zero thì zero flag được thiết lập

CLC ; Clear Carry, xóa carry flag trong Status Register
; thường được thực hiện trước khi thi hành ADC

SEC ; Set carry, thiết lập carry flag trong Status Register
; thường được thực hiện trước khi thi hành SBC

INC $0100 ; Increment, tăng giá trị tại địa chỉ $0100
; nếu kết quả là zero thì zero flag được thiết lập

DEC $0001 ; Decrement, giảm giá trị tại địa chỉ $0001
; nếu kết quả là zero thì zero flag được thiết lập

INY ; Incrememt Y Register, tăng Y
; nếu kết quả là zero thì zero flag được thiết lập

INX ; Increment X Register, tăng X
; nếu kết quả là zero thì zero flag được thiết lập

DEY ; Decrement Y, giảm Y
; nếu kết quả là zero thì zero flag được thiết lập

DEX ; Decrement X, giảm X
; nếu kết quả là zero thì zero flag được thiết lập

ASL A ; Arithmetic Shift Left, dịch chuyển số học về trái
; dời tất cả bit 1 vị trí về bên trái
; luôn được nhân 2
; nếu kết quả là zero thì zero flag được thiết lập

LSR $6000 ; Logical Shift Right, dịch chuyển số học về phải
; dời tất cả bit 1 vị trí về bên phải

1 byte là tổ hợp 8 bit, thứ tự từ bit 0 đến bit 7. Hình ảnh dưới đây minh họa cho 2 phép dời bit

GxHufEm.png


Opcode liên quan đến lưu trình điều khiển

JMP $8000 ; Jump, nhảy đến địa chỉ $8000 và tiếp tục thực hiện code ở đây

BEQ $FF00 ; Branch if Equal, phân nhánh nếu bằng, tiếp tục chạy code ở đây
; đầu tiên là so sánh (CMP), kết quả sẽ xóa hay thiết lập zero flag
; BEQ sẽ kiểm tra zero flag
; nếu zero flag được lập (kết quả = zero), code nhảy đến $FF00 và chạy từ đây
; nếu zero flag bị xóa (kết quả không = zero) thì không nhảy, tiếp tục chạy code từ vị trí cũ

BNE $FF00 ; Branch if not equal, phân nhánh nếu không bằng
; chức năng ngược lại với BEQ, nhảy đến $FF00 nếu kết quả khác zero.

Cấu trúc NES code

iNES Header: phần header này gồm 16 byte cho trình giả lập biết mọi thông tin về game, bao gồm Mapper, đối xứng đồ họa và kích thước PRG/CHR. Ta nên đưa những thông tin này vào đầu file asm.

.inesprg 1 ; 1x 16KB bank cho code PRG (dùng 1 bank trong số mấy bank của chương trình)
.ineschr 1 ; 1x 8KB bank cho dữ liệu CHR (dùng 1 bank trong số mấy bank của dữ liệu đồ họa)
.inesmap 0 ; mapper 0= NROM, không chuyển đổi bank
.inesmir 1 ; chọn đối xứng gương ngang (0) hay dọc (1) của VRAM đối với ảnh nền. Đang chọn dọc.

Bank: dữ liệu chương trình và đồ họa được phân chia thành các đơn vị gọi là bank. Ở đây đề cập đến 3 bank như bên dưới.

Bank 0 bắt đầu từ $8000, là khu vực chứa chương trình trong ROM.
Bank 1 bắt đầu từ $FFFA, là table ngắt.
Bank 2 bắt đầu từ $0000 trong VRAM, là nơi chứa ảnh sprite và ảnh nền (BG).

NESASM sắp đặt mọi thứ trong các bank 8KB code và 8KB đồ họa. Cần có 2 bank để đầy 16KB PRG. Đối với mỗi bank, cần khai báo thông tin để trình Assembler biết nó phải bắt đầu ở đâu trong bộ nhớ.

.bank 0 ; chọn bank 0
.org $C000 ; bắt đầu từ $C000
; code chương trình bắt đầu từ đây

Có 3 lúc bộ xử lý phải dừng code và nhảy đến vị trí mới. Những vector này nằm trong PRG ROM và cho bộ xử lý biết cần phải nhảy đến đâu khi những tình huống này xảy ra.

NMI Vector: xảy ra một lần cho mỗi khung hình khi được cho phép. PPU cho bộ xử lý biết nó đang bắt đầu thời gian VBlank và có thể cập nhật hình ảnh.
RESET Vector: xảy ra khi NES khởi động hay nút Reset được nhấn.
IRQ Vector: được kích hoạt từ vài con chip mapper hay ngắt audio, không được đề cập đến ở đây.

3 vector này phải được xuất hiện trong file .asm đúng thứ tự. Lệnh .dw được dùng để định nghĩa một Data Word (1 word = 2 byte).

.bank 1 ; đổi sang bank 1
.org $FFFA ; vector đầu tiên bắt đầu từ $FFFA
.dw NMI ; khi NMI xảy ra (1 lần mỗi khung hình), bộ xử lý sẽ nhảy đến label NMI:
.dw RESET ; khi bộ xử lý được bật hay reset, nó sẽ nhảy đến label RESET:
.dw 0 ; ngắt VBlank
.dw Start ; ngắt Reset. Nhảy đến label Start khi khởi động và reset
.dw 0 ; phát sinh do ngắt phần cứng và ngắt phần mềm

Reset code: vector reset được gán cho label RESET, nên khi bộ xử lý khỏi động thì nó sẽ bắt đầu từ RESET bằng cách dùng lệnh .org mà code được viết trong ROM.

.bank 0
.org $C000
RESET:
SEI ; bỏ IRQ
CLD ; bỏ chế độ số thập phân

Ở đây bank 1 viết về table vector bộ ngắt. Ngắt là một chức năng xử lý quan trọng. Ở đây khi nhấn nút Reset thì mọi thứ trở về ban đầu. Nếu dùng chức năng ngắt thì phải ghi địa chỉ routine ngắt vào .dw. Routine ngắt là một chương trình nằm chờ trên bộ nhớ để xử lý, khống chế chức năng ngắt. Khi kết quả tính toán dẫn đến lỗi hay khi có yêu cầu xử lý từ thiết bị ngoại vi gửi đến thì CPU sẽ dừng chương trình đang chạy và gọi chương trình đã đăng ký sẵn ra. Đây gọi là chức năng ngắt. Chẳng hạn, khi đang chạy game, ta nhấn nút Reset trên máy Famicom thì toàn bộ game sẽ dừng và quay trở lại màn hình đầu tiên.

Thêm file binary: chức năng thêm file thường được dùng cho dữ liệu đồ họa và dữ liệu các màn chơi. Dữ liệu này được đưa vào file .nes như sau:

.bank 2 ; đổi sang bank 2
.org $0000 ; bắt đầu từ $0000 (trong VRAM)
.incbin "kage.bkg" ; include file kage.bkg, file ảnh nền (BG)
.incbin "kage.spr" ; include file kage.spr, file ảnh sprite

Theo thứ tự này thì ảnh nền là Pattern table 0, ảnh sprite là Pattern table 1. incbin có chức năng giống #include trong ngôn ngữ C, bao gồm file đối tượng được chỉ định.

Trong VRAM cũng có khái niệm Memory map. Phần dưới là nhắc lại kiến thức ở phần trước đây. Chức năng của từng cái sẽ được giới thiệu ở những bài sau.

$0000-$0FFF: Pattern table 0
$1000-$1FFF: Pattern table 1
$2000-$23BF: Name table
$23C0-$23FF: table thuộc tính
$2400-$27BF: Name table
$27C0-$27FF: table thuộc tính
$2800-$2BBF: Name table
$2BC0-$2BFF: table thuộc tính
$2C00-$2FBF: Name table
$2FC0-$2FFF: table thuộc tính
$3000-$3EFF: đối xứng gương của $2000-$2EFF
$3F00-$3F0F: palette dùng cho BG
$3F10-$3F10: palette dùng cho sprite
$3F20-$3FFF: đối xứng gương của palette
$4000-$FFFF: đối xứng gương của $0000-$3FFF
 
Chỉnh sửa cuối:
Bài 4: PPU

Famicom thể hiện hình ảnh bằng PPU (Picture Processing Unit). Đây là vùng không gian riêng biệt với CPU 6502 và được gọi là VRAM (video Ram), có nhiệm vụ vẽ ảnh nền (BG) và ảnh sprite. PPU vẽ không đồng bộ, độc lập với 6502. Dù 6502 có ghi vào Register vẽ sprite thì không phải sprite sẽ được hiển thị ra màn hình lập tức. Để thao tác PPU từ 6502 thì cần phải thiết lập giá trị Register điều khiển PPU.

Bit flag

Vì 6502 là CPU 8 bit nên nó có thể xử lý cùng lúc 8 hàng giá trị ở hệ nhị phân. Trong các chương trình thông thường thì có một biến số gọi là flag để giữ trạng thái ON hay OFF của cái gì đó. Khi giữ 1 bye ở dạng nhị phân thì có thể sử dụng đến 8 bit từ 0 đến 7 như dưới đây. Đối với người biết ngôn ngữ C thì dù không biết Assembly cũng thấy quen thuộc. Chẳng hạn như trường hợp dưới đây, có thể nói 0 và 5 đang ON.

Giá trị Bit flag 0 0 1 0 0 0 0 1
Hàng số 7 6 5 4 3 2 1 0

Thiết lập PPU

Để sử dụng PPU thì đầu tiên cần định dạng Register điều khiển PPU bằng Bit flag này. Register điều khiển PPU chia làm 2 địa chỉ lần lượt là $2000 và $2001, có nội dung như dưới đây.

$2000: Register điều khiển PPU 1, trong đó
bit 7: thi hành NMI khi VBlank (0: không thi hành, 1: thi hành)
bit 6: chọn PPU Master/Slave (0: chọn Master mode)
bit 5: kích thước sprite (0: 8x8, 1:8x16)
bit 4: địa chỉ Pattern table cho BG (0: $0000, 1: $1000)
bit 3: địa chỉ Pattern table cho sprite (0: $0000, 1: $1000)
bit 2: cộng vào địa chỉ PPU (0: +1, 1:+32)
bit 1-0: số hiệu địa chỉ Name table hiển thị

00=$2000 (VRAM)
01=$2400 (VRAM)
10=$2800 (VRAM)
11=$2C00 (VRAM)

$2001: Register điều khiển PPU 2

bit 7-5: khi bit 0=1 thì chỉnh cường độ màu của BG
000: không
001: lục
010: lam
100: đỏ
(không chấp nhận số khác ngoài 3 số này)

bit 4: hiển thị sprite (0: không hiển thị, 1: hiển thị)
bit 3: hiển thị BG (0: không hiển thị, 1: hiển thị)
bit 2: xén sprite (0: không hiển thị 8 điểm bên trái màn hình, 1: không cắt xén)
bit 1: xén BG (0: không hiển thị 8 điểm bên trái màn hình, 1: không cắt xén)
bit 0: kiểu hiển thị (0: màu, 1: trắng đen)

Ta thử định dạng Register điều khiển PPU về mặc định bằng đoạn code dưới đây

LDA #%00001000
STA $2000
LDA #%00011110
STA $2001

Tức là:

1. Tải giá trị nhị phân %00001000 vào Accumulator. Giá trị này tương đương #$08
2. Chứa giá trị của Accumulator (%00001000) vào địa chỉ $2000
3. $2000 là địa chỉ của Register điều khiểnPPU thứ nhất, có các trạng thái sau:
+ Không thực hiện ngắt NMI khi VBlank (bit 7 =0)
+ Sprit là 8x8 điểm ảnh (bit 5 = 0)
+ Pattern table cho BG bắt đầu từ $0000 (bit 4 =0)
+ Địa chỉ PPU cộng thêm từng 1 đơn vị (bit 2= 0)
+ Số hiệu địa chỉ Name table là $2000 (bit 1,0=00)

4. Tải giá trị nhị phân %00011110 vào Accumulator. Giá trị này tương đương #$1E
5. Chứa giá trị của Accumulator (%00011110) vào địa chỉ $2001
6. $2001 là địa chỉ của Register điều khiểnPPU thứ hai, có các trạng thái sau:
+ Hiển thị ảnh sprite (bit 4=1)
+ Hiển thị ảnh nền (bit 3=1)
+ Không cắt xén sprite (bit 2=1)
+ Không cắt xén BG (bit 1=1)
+ Chế độ hiển thị màu (bit 0=0)

Vậy là ta đã định dạng về mặc định xong 2 Register điều khiển PPU ở $2000 và $2001.
Điểm cần chú ý là khi định dạng VRAM là cho hiển thị sprite và BG bằng giá trị 0, sau khi định dạng thì trả về 1, nếu không thì có nguy cơ VRAM không được định dạng đúng.

Chương trình đơn giản

Dưới đây là một chương trình đơn giản, chỉ hiển thị một màn hình màu.
Thử thay đổi giá trị các bit từ 7 đến 5 của Register $2001 và quan sát sự thay đổi màu sắc trên màn hình.

.inesprg 1 ; 1x 16KB PRG code
.ineschr 1 ; 1x 8KB dữ liệu CHR
.inesmap 0 ; mapper 0 = NROM, không đổi bank
.inesmir 1 ; đối xứng gương ảnh nền

;;;;;;;;;;;;;;;


.bank 0
.org $C000
RESET:
SEI ; vô hiệu hóa IRQ
CLD ; vô hiệu hóa chế độ thập phân
LDX #$40
STX $4017 ; vô hiệu hóa APU frame
LDX #$FF
TXS ; Set up stack
INX ; X = 0
STX $2000
STX $2001
STX $4010

vblankwait1: ; đợi VBlank để bảo đảm PPU đã sẵn sàng
BIT $2002
BPL vblankwait1

clrmem:
LDA #$00
STA $0000, x
STA $0100, x
STA $0200, x
STA $0400, x
STA $0500, x
STA $0600, x
STA $0700, x
LDA #$FE
STA $0300, x
INX
BNE clrmem

vblankwait2: ; đợi VBlank, PPU đã hoàn tất
BIT $2002
BPL vblankwait2


LDA #%10100001 ;định dạng Register điều khiển PPU2
STA $2001

Forever:
JMP Forever ;nhảy về Forever, lặp vĩnh viễn


NMI:
RTI

;;;;;;;;;;;;;;
.bank 1
.org $FFFA ;vector đầu tiên trong số 3 vector bắt đầu tại đây
.dw NMI ; khi NMI xảy ra (1 lần mỗi frame) thì bộ xử lý nhảy tới label NMI
.dw RESET ;khi bộ xử lý được bật hay reset, nó nhảy tới label RESET:
.dw 0 ;ngắt IRQ không được dùng ở đây
;;;;;;;;;;;;;;
.bank 2
.org $0000
.incbin "kage.bkg" ; kèm file ảnh nền kage.bkg

Tải file này (https://www.dropbox.com/s/1eob4pjbjrxc2wa/background.zip?dl=0) về, giải nén được background.asm và kage.bkg.
background.asm là file chương trình ở trên, còn kage.bkg là file ảnh nền, tuy chưa cần đến trong chương trình này nhưng ta cũng đưa vào.
Đặt 2 file này chung thư mục với NESASM, rồi lập file .bat với nội dung như sau

NESASM background.asm
pause

Click đôi vào file .bat vừa tạo để NESASM thi hành các lệnh trong background.asm. Cũng có thể thi hành từ cửa sổ CMD, gõ đường dẫn tới thư mục chứa NESASM. Khi chạy NESASM, ta thấy cửa sổ như sau

6GluicM.png


Như vậy, NESASM sẽ tạo ra file có tên background.nes. Có thể dùng các loại giả lập NES để mở file này, quan sát kết quả.
 
Bài 5: lập palette màu

Trong bài trước, ta đã viết một chương trình đơn giản hiển thị màu lên màn hình. Bài này sẽ hướng dẫn cách đưa hình ảnh lên màn hình. Nhưng trước đó cần phải chọn màu palette. Game NES sử dụng 2 palette, 1 cho ảnh nền (BG) và 1 cho ảnh sprite. Mỗi palette là 16 byte, tương ứng với 16 màu sử dụng đồng thời trong số 64 màu mà NES có thể hiển thị.
Dưới đây là 16 màu cho BG và 16 màu cho ảnh sprite.

SlLPmYs.png

Trên Google có nhiều phần mềm cho phép tạo file palette màu cho NES. Quy tắc màu trong palette là 4 màu liên tiếp nhau tạo thành một cụm, chẳng hạn cụm 0-1-2-3, cụm 4-5-6-7, cụm 8-9-10-11 và cụm 12-13-14-15. Trong số này thì các màu 0, 4, 8, 12 là màu ảnh nền.

Đọc palette

Dữ liệu màu palette được đọc vào các địa chỉ từ $3F00 và $3F10 trở đi trong VRAM (PPU). Để đọc vào địa chỉ này thì cần phải ghi địa chỉ này ($3F00) vào Register quản lý địa chỉ trong VRAM ở địa chỉ $2006.
Tuy nhiên Register của NES chỉ có 8 bit, trong khi địa chỉ cần ghi vào ($3F00) lại là 16 bit. Vậy cần phải ghi làm 2 lần như sau

LDA $2002 ; đọc trạng thái của PPU
LDA #$3F ; tải giá trị 3F (byte đầu của $3F10) vào Accumulator
STA $2006 ; ghi giá trị đang chứa trong Accumulator vào $2006
LDA #$10 ; tải giá trị 00 (byte cuối của $3F10) vào Accumulator
STA $2006 ; ghi giá trị đang chứa trong Accumulator vào $2006

Sau đó ta sẽ ghi giá trị palette (1byte) (nạp vào địa chỉ được chỉ định) vào Register quản lý địa chỉ thứ hai trong VRAM ở $2007. Mỗi lần ghi thì đối tượng sẽ tự động di chuyển đến những địa chỉ tiếp theo ($3F11, $3F12, $3F13) nên chỉ cần lặp lại STA cần thiết là được.

LDA #$32 ; nạp code màu xanh vào A
STA $2007 ; ghi giá trị màu xanh từ A vào PPU $3F10
LDA #$14 ; nạp code màu hồng vào A
STA $2007 ; ghi màu hồng vào PPU $3F11
LDA #$2A ; nạp code màu lục vào A
STA $2007 ; ghi màu lục vào PPU $3F12
LDA #$16 ; nạp màu đỏ vào A
STA $2007 ; ghi màu đỏ vào PPU $3F13

Ta có thể ghi lần lượt các giá trị màu vào PPU theo cách trên, nhưng mất nhiều thời gian. Dưới đây là cách thực hiện ngắn gọn hơn.

Truy cập Memory bằng Index Register

X và Y là các Index Register, có thể sử dụng chúng như dưới đây.

; giả định giá trị của X đang là 6
LDA $2002, X ; tải địa chỉ ($2002 + 6) vào A. Tức tải $2008 vào A.

; giả định giá trị của Y đang là 9
LDA $2000, Y ; tải địa chỉ ($2000 + 9) vào A. Tức $2009.

Nhờ chức năng của Index Register này mà ta có thể đọc dữ liệu từ palette lúc nãy theo thứ tự.

pallabel: .incbin "kage.pal" ; include file palette tên kage.pal mà ta đã tạo
LDA pallabel, X ; tải vào A giá trị (pallabel + X)

Trường hợp nếu không dùng phần mềm tạo palette, ta có thể dùng biến .db như sau:

pallabel:
.db $0F, $31, $32, $33, $0F, $35, $36, $37, $0F, $39, $3A, $3B, $0F, $3D, $3E, $0F ; BG
.db $0F, $1C, $15, $14, $0F, $02, $38, $3C, $0F, $1C, $15, $14, $0F, $02, $38, $3C ; sprite

Như vậy, pallabel có thể thay cho file kage.pal.
Rồi sau đó dùng vòng lặp để copy những byte màu này vào PPU. Index Register X được dùng để đếm vòng lặp được bao nhiêu lần rồi, cụ thể như dưới đây.

Code đọc palette

Dưới đây là chương trình tải palette.

; Chỉ định địa chỉ tải palette $3F00 vào Register $2006 trong VRAM
LDA #$3F
STA $2006
LDA #$00
STA $2006

LDX #$00 ; tải giá trị 0 vào X. Vòng lặp bắt đầu từ 0.
loadpal:
LDA pallabel, X ; tải địa chỉ (pallabel + X) vào A
STA $2007 ; ghi giá trị palette vào cổng $2007 trong VRAM
INX ; tăng giá trị của Register X lên 1
CPX #32 ; so sánh X với 32 (thập phân, là tổng số màu cho BG và sprite)
BNE loadpal ; nếu kết quả so sánh trên khác 32 thì sẽ nhảy đến label loadpal, tức lặp lại routine này
; nếu kết quả so sánh trên bằng 32 thì kết thúc

Giải thích: ta có label tên pallabel chứa toàn bộ 32 byte dữ liệu màu, 16 cho BG và 16 cho sprite. Có thể tạo ra file này bằng phần mềm chuyên dụng hay dùng biến .db như đề cập ở trên.
Mục tiêu của ta là ghi lần lượt các giá trị màu vào các địa chỉ $3F00 và từ $3F10 trở về sau. Nhưng đầu tiên cần chỉ định các địa chỉ này qua Register quản lý địa chỉ trong VRAM ở $2006 và $2007. Việc này thực hiện đơn giản bằng LDA và STA. Còn việc tải dữ liệu pallabel vào $3F10, $3F11, $3F12.... được thực hiện bằng vòng lặp dùng X Register và chức năng so sánh.
 
mod theo dõi topic làm cái tổng hợp lại nhé :D
bài viết hay đấy :P
 
Bài 6: tạo sprite

Sprite là những hình ảnh tách biệt so với ảnh nền (BG). Khu vực của sprite trong VRAM chiếm 4KB, và có thể đăng ký 256 sprite với kích thước 8x8 điểm ảnh. Sprite là thành phần cấu thành nên các vật thể, nhân vật trong game. Chẳng hạn, ảnh nhân vật Mario được tạo thành từ nhiều sprite 8x8. Tuy đăng ký được tới 256 sprite nhưng bộ nhớ PPU chỉ hiển thị đồng thời được 64 sprite.
Thực chất, từng đơn vị 8x8 điểm ảnh này được gọi là "tile", gồm 4 màu. Phần dữ liệu sprite này gọi chung là CHR. Khi máy NES tải CHR vào PPU thì nó sẽ tải chúng vào 8 bank, mỗi bank $2000 byte, bao gồm 512 tile.

uAg3iFp.png

Sprite trong game Jetman.​

PwJr3lG.png

Sprite trong game Kunio.​

Có nhiều phần mềm cho phép tạo ảnh sprite cho NES và cả SNES, ở đây khuyên dùng YY-CHR. Tải YY-CHR tại đây. Mở một game NES bất kỳ bằng YY-CHR, ở góc trái bên dưới, chọn 2BPP NES và kéo thanh trượt xuống phía dưới để thấy sprite. Có thể copy từ những game có sẵn, hoặc tạo mới bằng YY-CHR. Sau khi tạo sprite, nên lưu với định dạng .chr để dễ phân biệt.


Có thể tải file này, giải nén và được các file: kage.pal (palette), kage.spr (sprite), kage.bkg (BG) và kage.asm (code chương trình). Có thể mở và chỉnh sửa những file này bằng các phần mềm đã biết.

G550dZQ.gif

Ta có thể chỉ định 4 màu từ palette đối với mỗi sprite. Trong trường hợp bên dưới, nếu chọn palette 0 cho sprite (palette của sprite ở bên dưới) thì màu ảnh nền là màu xám, mắt màu đen, miệng màu đỏ và mặt màu trắng. Nếu chọn palette số 1 thì có thể sử dụng các màu 4-5-6-7 và mặt có màu xanh lá. Hãy ghi nhớ quy tắc chỉ định màu này (cụm 4 màu) vì nó còn được dùng cho cả BG.

Y7hhrQE.gif

Bố trí đồ họa vào bank 2

Trong bài 4, ở phần định dạng PPU, ta đã thiết lập Pattern table cho BG bắt đầu từ $0000 và Pattern table cho sprite bắt đầu từ $1000 qua Register điều khiển PPU ở địa chỉ $2000. $1000 hệ số thập lục tương đương với 4096 byte, tức nó chứa được 4KB dữ liệu. Ta cần phải trình bày BG --> sprite theo trật tự như dưới đây.

.bank 2
.org $0000
.incbin "kage.bkg" ; include file BG
.incbin "kage.spr" ; include file sprite

Ghi vào sprite Ram

Để ghi dữ liệu sprite vào sprite RAM thì đầu tiên cần chỉ định địa chỉ Pattern table (dài 8bit) của sprite cần ghi vào sprite RAM Register $2003.

LDA #$00 ; tải giá trị $00 (địa chỉ sprite RAM 8 bit) vào A
STA $2003 ; chứa địa chỉ sprite RAm trong A vào $2003


Để chỉ định thông tin sprite, ta sử dụng sprite RAM Register $2004. Để vẽ sprite lên màn hình thì cần chỉ định 4 thông tin lần lượt như dưới đây.

+ Byte đầu tiên: tọa độ Y
+ Byte thứ 2: số hiệu Tile Index
+ Byte thứ 3: bit flag 8 bit chỉ định thuộc tính của sprite.
* Bit 7: xoay ngược vuông góc (1 là xoay ngược)
* Bit 6: xoay ngược theo chiều ngang (1 là xoay ngược)
* Bit 5: trật tự ưu tiên BG (0: phía trước, 1: đằng sau)
* Bit 0-1: 2 bit đầu của palette
* Các bit khác: 0
+ Byte thứ 4: tọa độ X

2 bit đầu của palette tức là, trong số palette của BG từ 0~15, nếu dùng palette 0~3 thì đó là 00 trong 0 (%0000), nếu dùng palette 4~7 thì là 01 trong 4 (%0100), nếu dùng palette 8~11 thì đó là 10 trong 8 (%1000), nếu dùng palette 12~15 thì chỉ định 11 trong 12 (%1100). Ở đây chọn palette 0. Nếu muốn hiển thị sprite số 0 ở tọa độ X=30, Y=40 thì viết như sau

LDA #40 ; load 40 vào A
STA $2004 ; chứa tọa độ Y vào Register
LDA #00 ; load giá trị 0 (sprite 0) vào A
STA $2004 ; chứa 0 vào Register, chỉ định sprite 0
STA $2004 ; lại chứa 0 vào vì ta không cần xoay ngược hay trật tự ưu tiên
LDA #30 ; load giá trị thập phân 30 vào A
STA $2004 ; chứa tọa độ X vào Register

Sprite 0 là sprite đầu tiên trong số pattern table, tức kho dữ liệu sprite.

jou60ou.png

Ở đây nhắc lại, màn hình Famicom có độ phân giải 246x240 điểm ảnh.


VBlank

Trong bài trước, ta đã biết về khái niệm VBlank. Ở phần này sẽ đi sâu hơn.
Các loại TV ở Nhật, Mỹ đều chuẩn theo quy cách NTSC. Đường ngang chạy dọc màn hình TV trong 1/60 giây từ phía trên bên trái xuống phía dưới bên góc phải để vẽ nên hình ảnh. Khi nó chạy xuống góc phải bên dưới thì sẽ trở lại góc trái bên trên, và khoảng thời gian nó quay trở lại này được gọi là VBlank.

rfwj708.png

Nếu cập nhật VRAM trong khi đang vẽ hình ảnh lên màn hình sau khi VBlank kết thúc thì hình ảnh sẽ bị vỡ. Vì vậy đầu tiên cần đợi phát sinh VBlank xong mới tiến hành xử lý vẽ hình ảnh trong thời gian VBlank. Và nhất định cần phải xử lý xong tất cả mọi thứ cho đến khi bắt đầu VBlank tiếp theo. Nói cách khác, Famicom (NES) có thể xử lý 60 vòng lặp trong 1 giây.

Start:
LDA $2002 ; khi VBlank phát sinh thì bit 7 của $2002 sẽ là 1
BPL Start ; trong khi bit 7 = 0 thì nhảy đến label Start, đợi vòng lặp

BPL là mệnh lệnh phân nhánh sang địa chỉ được chỉ định nếu N flag (Negative flag) của Status Register là 0. Vì trong N flag đã được set bit 7 của A Register nên có thể đợi bằng LDA và BPL.

Hoàn tất chương trình hiển thị sprite


; Ví dụ chương trình hiển thị sprit

; INES header
.inesprg 1 ; - chọn bank nào trong program. Giờ chọn 1
.ineschr 1 ; - chọn bank nào trong dữ liệu đồ họa. Giờ chọn 1
.inesmir 0 ; - Đối xứng gương theo chiều ngang
.inesmap 0 ; -Mapper 0

.bank 1 ; bank 1
.org $FFFA ; bắt đầu từ $FFFA

.dw 0 ; ngắt VBlank
.dw Start ; ngắt reset. Nhảy tới label Start khi khởi động và reset
.dw 0 ; phát sinh khi ngắt phần cứng và ngắt phần mềm

.bank 0 ; bank 0
.org $8000 ; $bắt đầu từ 8000

; Code chương trình bắt đầu từ đây

Start:
lda $2002 ; Khi VBlank phát sinh, bit 7 của $2002 thành 1
bpl Start ; trong khi bit 7 =0, nhảy tới Start

; Định dạng Register quản lý PPU
lda #%00001000
sta $2000
lda #%00000110 ; tắt hiển thị sprite và BG trong khi định dạng
sta $2001

ldx #$00 ; cho X Register về 0

; Chỉ định địa chỉ load palette $3F00 trong Register địa chỉ $2006 trong VRAM
lda #$3F
sta $2006
lda #$00
sta $2006

loadPal: ; label
lda tilepal, x ; load palette của địa chỉ (pal + X) vào A

sta $2007 ; ghi giá trị palette vào $2007

inx ; gia tăng X 1 đơn vị

cpx #32 ; so sánh X với 32 (tổng palette của BG và sprite)X
bne loadPal ; nếu không bằng thì nhảy sang loadpal
; nếu X=32 thì kết thúc load palette

; vẽ sprite
lda #$00 ; load $00 (địa chỉ sprite RAM 8 bit) vào A
sta $2003 ; chứa địa chỉ sprite RAM trong A

lda #50 ; load 50 vào A
sta $2004 ; chứa tọa độ Y vào Register
lda #00 ; load sprite 0 vào A
sta $2004 ; chứa 0, chỉ định sprite 0
sta $2004 ; lại chứa $00 vì không cần ưu tiên hay xoay ngược
lda #20 ; load 20 vào A
sta $2004 ; chứa tọa độ X vào Register

; Định dạng Register 2 điều khiển PPU
lda #%00011110 ; bật hiển thị BG và sprite
sta $2001

infinityLoop:
jmp infinityLoop ; lặp vô hạn

tilepal: .incbin "kage.pal" ; include file palette

.bank 2 ; bank 2
.org $0000 ; bắt đầu từ $0000

.incbin "kage.bkg" ; include file ảnh nền BG
.incbin "kage.spr" ; include file ảnh sprite

Đến đây tôi đã giải thích xong mọi thứ cần thiết để tạo một chương trình hiển thị sprite. Tải file cung cấp ở đầu bài, cho kage.pal, kage.spr, kage.bkg và kage.asm vào chung thư mục chứa NESASM, viết file lệnh .bat như lần trước

NESASM kage.asm
@Pause

Chạy file .bat vừa tạo, nếu thành công thì sẽ xuất hiện màn hình như dưới đây.

qJ2FX6F.png

Trong chương trình trên, có thể thay
tilepal: .incbin "kage.pal" bằng đoạn code dưới đây nếu bạn không muốn mất công tạo file palette bằng phần mềm chuyên dụng.

tilepal:
.db $0F, $31, $32, $33, $0F, $35, $36, $37, $0F, $39, $3A, $3B, $0F, $3D, $3E, $0F cho BG
.db $0F, $1C, $15, $14, $0F, $02, $38, $3C, $0F, $1C, $15, $14, $0F, $02, $38, $3C cho sprite

Thay đổi các giá trị màu để cho ra màu sắc khác nhau.
Sau khi chạy NESASM, file kage.nes được tạo ra, chạy file này bằng giả lập thì ta thấy sprite đã hiển thị trên màn hình. Hãy thử thay đổi code trong file kage.asm và quan sát thay đổi để hiểu rõ hơn nội dung bài này.

9hd2Er4.png
 
Bài 7: điều khiển tay cầm


OSafPrz.jpg

Tất cả chúng ta đều biết, máy Famicom (NES) có 2 tay cầm (Controller) gọi là "máy chính" (Controller 1) và "máy phụ" (Controller 2). Tín hiệu từ tay cầm được truyền đến địa chỉ $4016 (máy chính) và $4017 (máy phụ) trong bộ nhớ. Mọi ví dụ dưới đây đều tập trung vào máy chính. Máy phụ thì tương tự, chỉ cần đổi sang địa chỉ $4017 là được.
Tín hiệu được truyền qua bit 0 của byte tại các địa chỉ này, nếu nút bị nhấn thì giá trị của bit này là 1, nếu không bị nhấn là 0. Khi đã nhấn một lần thì bit 0 giữ nguyên giá trị 1, do vậy mỗi lần cần phải định dạng lại. Để định dạng, cần viết giá trị 1, 0 theo thứ tự vào I/O Register (I/O :Input, Output) như sau.

LDA #$01 ; load giá trị 01 vào A
STA $4016 ; ghi 01 từ A vào $4016
LDA #$00
STA $4016

Tín hiệu nhập từ tay cầm được truyền đi theo thứ tự: nút A, nút B, nút Select, nút Start, nút lên, nút xuống, nút trái, nút phải. Do đó chỉ cần lặp lại 8 lần LDA là được.


Phân nhánh có điều kiện

Assembly có các mệnh lệnh phân nhánh với điều kiện, trong đó có lệnh BNE ta đã biết ở bài 5. BNE là viết tắt của cụm từ "Branch if Not Equal" (phân nhánh nếu không bằng), tức kết quả phép toán trước đó sẽ làm thay đổi flag của Status Register, từ đó nhảy tới label được chỉ định. Dưới đây là ví dụ.

; giả sử có label tên Kage ở đâu đó
BNE Kage ; kết quả so sánh trước đó có bằng không, nếu kết quả phép toán không bằng zero thì nhảy tới label Kage

Ngoài ra còn lệnh BEQ, nhảy đến label nếu kết quả so sánh không bằng.

CPX $12 ; so sánh giá trị của X với 12 (thập lục)
BEQ Kage ; nếu giá trị X khác 12 thì nhảy tới Kage

Lệnh BEQ, BNE phân nhánh dựa trên kết quả Z flag (Zero flag) của Status Register. Ngoài ra còn nhiều lệnh phân nhánh khác, nhưng tạm thời chỉ giới thiệu 2 lệnh này.


Phép diễn toán AND

Thông tin tình trạng nút được nhấn hay không chỉ gửi qua bit 0 của cổng $4016, $4017 nên ta cần xóa hết 7 bit còn lại. Có thể làm điều này với phép diễn toán AND. Mỗi trong số 8 bit này được AND với các bit từ giá trị khác. Phép diễn toán AND có luận lý như dưới đây.

0 AND 0 = 0
0 AND 1 = 0
1 AND 0 = 0
1 AND 1 = 1

Chẳng hạn ta có

+ Giá trị 1: 01011011
AND
+ Giá trị 2: 10101101
+ Kết quả: 00001001


Chương trình đọc tay cầm

Dưới đây là chương trình đọc trạng thái của tay cầm, chỉ quan tâm tới bit 0.

; chuẩn bị I/O Register
LDA #$01
STA $4016
LDA #$00
STA $4016

LDA $4016 ; đầu tiên đọc nút A
AND #%00000001 ; Accumulator AND 1
BNE Apressed ; nếu kết quả trên khác zero (nút A bị nhấn) thì nhảy tới label Apressed

LDA $4016 ; tiếp theo đọc nút B
AND #%00000001 ; Accumulator AND 1
BNE Bpressed ; nếu kết quả trên khác zero thì nhảy tới label Bpressed

LDA $4016 ; đọc nút Select, bỏ qua
LDA $4016 ; đọc nút Start
AND #%00000001 ; Accumulator AND 1
BNE Startpressed ; nếu kết quả trên khác zero thì nhảy tới label Startpress

(Tương tự, dùng LDA và AND để viết code đọc các nút lên, xuống, trái, phải)

JMP Unpressed ; nhảy tới label Unpressed khi không có nút nào được nhấn

Apressed:
; code xử lý khi nút A được nhấn

Bpressed:
; code xử lý khi nút B được nhấn

Startpressed:
; code xử lý khi nút Start được nhấn

Uppressed:
; code xử lý khi nút lên được nhấn

Downpressed:
; code xử lý khi nút xuống được nhấn

Unpressed:
; code xử lý khi không có nút nào được nhấn
 
Bài 8: Zero page

Ta có thể truy cập vào RAM bằng cách đặt tên như biến số trong ngôn ngữ C, hay có thể định dạng ROM. Trong những trường hợp như vậy, ta dùng .db để mô tả định số như ví dụ dưới đây.

.BANK 0
.ORG $0000 ; bắt đầu từ $0000
label1: .db 0 ; xem địa chỉ $0000 của RAM như label1, không thể mang giá trị ban đầu
byte1: .db 0 ; xem địa chỉ $0001 của RAM như byte 1, không thể mang giá trị ban đầu

.ORG $8000
Start: ; từ đây trở đi là ROM chương trình, viết giá trị ban đầu và bản thân chương trình

label1Init: .db 10 ; đặt giá trị ban đầu của label1 vào ROM chương trình
byte1Init: .db 20 ; đặt giá trị ban đầu của byte1 vào ROM chương trình

Việc định dạng ROM cũng giống như việc định dạng table ngắt .dw (2 byte, word) trong Bank 1 ở bài 3. Ta không thể định dạng dải địa chỉ từ $0000 đến $07FF trong RAM bằng cách viết giá trị vào đó mà phải tải/chứa từ ROM. Và ta cũng không thể chứa giá trị và cập nhật chúng trong ROM.

Nếu như chỉ định label thì có thể định dạng như bên dưới đây

LDA label1 ; load giá trị của label1 vào A
LDX label1 ; load giá trị của label1 vào X
LDY label1 ; load giá trị của label1 vào Y
LDA byte1 ; load giá trị của byte1 vào A

Ta cũng có thể cập nhật giá trị vào RAM, tức có thể sử dụng giống như biến số trong các ngôn ngữ khác Assembly.

STA label1 ; chứa giá trị của A vào label1 ($0000)
STX label1 ; chứa giá trị của X vào label1 ($0000)
STY label1 ; chứa giá trị của Y vào label1 ($0000)
STX byte1 ; chứa giá trị của X vào byte1 ($0001)

Zero page

Từ trước nay ta đã biết, các phép toán đều tiến hành qua các Register. Nhưng 6502 còn có thể diễn toán trực tiếp trong RAM. Ví dụ dưới đây cho thấy có thể tăng, giảm giá trị trong RAM mà không nhất nhất phải thông qua Register.

INC label1 ; tăng giá trị của label1 lên 1
DEC label ; giảm giá trị của label1 xuống 1

Và dải địa chỉ từ $0000 tới $00FF được gọi là Zero page. Ta có thể chỉ định 1 byte truy cập vào phần RAM này như dưới đây.

LDA <$00 ; load giá trị của $0000 vào A
INC <$00 ; tăng giá trị của $0000 lên 1

Ký hiệu < cho biết đây là Zero page, không cần phải viết đủ $0000.
Nhưng nhưng thế này thì khó hiểu hơn label lúc nãy, và có thể viết giống như label.

label1 = $00 ; định nghĩa tên địa chỉ Zero page
LDA <label1 ; load giá trị của $0000 vào A
INC <label1 ; tăng giá trị của $0000 lên 1

Dải địa chỉ từ $0100 đến $01FF là khu vực Stack có thể sử dụng được, nhưng không đề cập đến ở đây.
 
Bài 9: di chuyển sprite

SSUg885.png

Trong bài 6, ta đã viết chương trình hiển thị sprite lên màn hình. Sprite là phần hình ảnh phía trước phông nền, có thể di chuyển, chẳng hạn như nhân vật, vật thể trong game. Trong bài này sẽ đề cập đến việc làm thế nào để di chuyển sprite bằng tay cầm.
Trước hết, ở đây xem lại 3 mệnh lệnh so sánh. Đối tượng so sánh có thể là giá trị trực tiếp hoặc giá trị tại một địa chỉ.

CMP ; so sánh với A
CPX ; so sánh với X
CPY ; so sánh với Y

Chẳng hạn như

CMP #$10 ; so sánh A với số thập lục $10
CPX #16 ; so sánh X với số thập phân 16
CPY $2002 ; so sánh Y với giá trị tại địa chỉ $2002

Khi tiến hành so sánh thì các flag N (Negative), Z (Zero) và C (carry) của Status Register sẽ biến đổi, tùy thuộc vào kết quả. Sau khi so sánh, nó sẽ nhảy đến label cần thiết bằng lệnh phân nhánh có điều kiện. Nếu kếu quả so sánh trước đó là 0 thì Zero flag được lập nên có thể lược bỏ CMP #0 khi sử dụng chung với BEQ và BNE.

BEQ ifEqual ; nếu kết quả bằng thì nhảy tới label ifEqual
BNE ifNotEqual ; nếu kết quả không bằng thì nhảy tới label ifNotEqual


Chương trình di chuyển sprite

Trong các bài trước, ta đã viết được chương trình hiển thị code và điều khiển tay cầm. Kết hợp với bài này để viết chương trình di chuyển sprite thông qua các nút lên, xuống, trái, phải của tay cầm.
Tải file này, giải nén và được kage9.asm, kage.spr, kage.bkg và kage.pal. Đặt chung tất cả vào thư mục NESASM, thực hiện file lệnh và được KAGE9.NES.

; Chương trình di chuyển sprite

; INES header
.inesprg 1 ; - Chọn bank 1 trong program
.ineschr 1 ; - chọn bank 1 trong graphic
.inesmir 0 ; - đối xứng gương theo đường ngang
.inesmap 0 ; - Mapper số 0

.bank 1 ; bank 1
.org $FFFA ; bắt đầu từ $FFFA

.dw 0 ; ngắt VBlank
.dw Start ; ngắt reset. Khi khởi động và khi Reset nhảy tới Start
.dw 0 ; phát sinh do ngắt phần cứng và ngắt phần mềm

.bank 0 ; bank 0
.org $0000 ; bắt đầu từ $0000
X_Pos .db 0 ; biến số tọa độ X của sprite ($0000)
Y_Pos .db 0 ; biến số tọa độ Y của sprite ($0001)

.org $8000 ; bắt đầu từ $8000
Start:
lda $2002 ; khi phát sinh VBlank thì bit 7 của $2002 là 1
bpl Start ; trong khi bit 7 là 0 thì nhảy đến vị trí label Start, đợi vòng lặp

; Định dạng Register điều khiển PPU
lda #%00001000
sta $2000
lda #%00000110 ; tắt sprite và BG khi đang định dạng
sta $2001

ldx #$00 ; cho X về 0

; chỉ định địa chỉ load palette $3F00 vào Register địa chỉ $2006 trong VRAM
lda #$3F
sta $2006
lda #$00
sta $2006

loadPal:
lda tilepal, x

sta $2007 ; đọc giá trị palette vào $2007

inx ; tăng giá trị X lên 1

cpx #32 ; so sánh X với 32
bne loadPal ; nếu kết quả so sánh trên không bằng thì nhảy tới loadpal

; định dạng tọa độ sprite
lda X_Pos_Init
sta X_Pos
lda Y_Pos_Init
sta Y_Pos

; Định dạng Register 2 điều khiển PPU
lda #%00011110 ; bật sprite và BG
sta $2001

mainLoop: ; vòng lặp chính
lda $2002 ; khi phát sinh VBlan thì bit 7 của $2002 là 1
bpl mainLoop ; trong khi bit7 là 0 thì nhảy tới mainLoop, đợi lặp

; vẽ sprite
lda #$00
sta $2003 ; chứa địa chỉ RAM của sprite

lda Y_Pos ; load tọa độ Y
sta $2004 ; chứa tọa độ Y vào Register

lda #00
sta $2004 ; chứa 0, chỉ định sprite 0
sta $2004 ; không xoay ngược sprite

lda X_Pos ; load giá trị tọa độ X
sta $2004 ; chứa tọa độ X vào Register

; chuẩn bị I/O Register
lda #$01
sta $4016
lda #$00
sta $4016

; kiểm tra nút có bị nhấn không
lda $4016 ; bỏ qua nút A
lda $4016 ; bỏ qua nút B
lda $4016 ; bỏ qua Select
lda $4016 ; bỏ qua Start
lda $4016 ; nút lên
and #1 ; AND #1
bne UPKEYdown ; nếu khác 0 tức nút bị nhấn, nhảy tới UPKeydown

lda $4016 ; nút xuống
and #1 ; AND #1
bne DOWNKEYdown ; nếu khác 0 tức nút bị nhấn, nhảy tới DOWNKeydown

lda $4016 ; nút trái
and #1 ; AND #1
bne LEFTKEYdown ; nếu khác 0 tức nút bị nhấn, nhảy tới LEFTKeydown

lda $4016 ; nút phải
and #1 ; AND #1
bne RIGHTKEYdown ; nếu khác 0 tức nút bị nhấn, nhảy tới RIGHTKeydown
jmp NOTHINGdown ; nếu không có nút nào bị nhấn thì nhảy tới NOTHINGdown

UPKEYdown:
dec Y_Pos ; giảm 1 tọa độ Y. Vì là Zero page nên có thể rút ngắn mệnh lệnh
; lda Y_Pos ; load tọa độ Y
; sbc #1 ; giảm #1
; sta Y_Pos ; chứa tọa độ Y

jmp NOTHINGdown

DOWNKEYdown:
inc Y_Pos ; tăng 1 trong giá trị tọa độ Y
jmp NOTHINGdown

LEFTKEYdown:
dec X_Pos ; giảm 1 tọa độ X
jmp NOTHINGdown

RIGHTKEYdown:
inc X_Pos ; tăng 1 tọa độ X
; sau đây là NOTHINGdown nên không cần Jump

NOTHINGdown:
jmp mainLoop ; trở lại ban đầu mainLoop

; dữ liệu ban đầu
X_Pos_Init .db 20 ; giá trị ban đầu của tọa độ X
Y_Pos_Init .db 40 ; giá trị ban đầu của tọa độ Y

tilepal: .incbin "kage.pal" ; include palette

.bank 2 ; bank 2
.org $0000 ; bắt đầu từ $0000

.incbin "kage.bkg" ; include BG
.incbin "kage.spr" ; include spr


Dùng giả lập FCEU để chạy KAGE9.NES vừa tạo ra, có thể vào Menu Debug-->Hex editor để quan sát tọa độ X, Y biến đổi khi di chuyển sprite tại $0000 (tọa độ X) và $0001 (tọa độ Y) trong Memory.
 
Tưởng đùa hóa ra thật :|
Mã:
http://arstechnica.com/gaming/2013/02/retro-city-rampage-creator-makes-a-real-playable-nes-port/
 
Bài 10: hiển thị nhiều sprite

Ta đã biết, sprite là những hình ảnh 8x8 pixel phía trước phông nền. Nhân vật trong game, chẳng hạn như Mario, được cấu thành từ nhiều sprite và cả khối nhân vật được gọi là meta-sprite.
Trong bài 6, ta đã biết cách hiển thị sprite qua các Register $2003 và $2004, nhưng khi số lượng sprite tăng lên thì việc chuyển từng sprite vào RAM sẽ rất phiền. Tuy nhiên, Famicom cho phép chuẩn bị sẵn dữ liệu các sprite và đồng thời chuyển hết chúng vào RAM. Phương pháp đó gọi là sprite DMA (Direct Memory Access).
Trong bài 6, ta cũng đã biết 1 dữ liệu sprite trong RAM được cấu thành từ 4 byte: tọa độ Y, tile Index, thuộc tính của sprite, tọa độ X. Máy Famicom có thể hiển thị tối đa 64 sprite, tức sẽ cần 4x64 = 256 ($100) byte. Do vậy, để chuyển sprite RAM bằng DMA thì cần phải bảo đảm được $100 RAM trống. Vậy làm cách nào để bảo đảm được $100 byte trống này?

Trong những bài đầu đã biết, ta có thể tự do sử dụng từ $0000~$07FF trong RAM. Trong số đó thì $0000~$00FF là Zero page, nên sẽ không động đến. Còn $0100~$01FF là khu vực của Stack. Vì vậy ta sẽ dùng khu vực từ $0200 trở đi làm vùng đệm cho sprite.


Sprite RAM Register

Đầu tiên cần bảo đảm không gian trống trong RAM để chứa sprite. Trong ví dụ dưới đây, ta chọn bắt đầu từ $0300.

.bank 0
.org $0300 ; bắt đầu từ $0300, bố trí dữ liệu sprite DMA
Sprite1_Y: .db 0 ; sprite #1, tọa độ Y
Sprite1_T: .db 0 ; sprite #1, số ID
Sprite1_S: .db 0 ; sprite #1, thuộc tính
Sprite1_X: .db 0 ; sprite #1, tọa độ X
Sprite2_Y: .db 0 ; sprite #2, tọa độ Y
Sprite2_T: .db 0 ; sprite #2, số ID
Sprite2_S: .db 0 ; sprite #2, thuộc tính
Sprite2_X: .db 0 ; sprite #2, tọa độ X

.org $8000 ; bắt đầu từ $8000
Start:
; phần chính của chương trình bắt đầu từ đây

Và ta sẽ cập nhật dữ liệu sprite dự định chuyển DMA. Sau đó ta sẽ ghi địa chỉ RAM muốn chuyển vào Register $4014 khi muốn cập nhật sprite.

; vẽ sprite (lợi dụng DMA)
LDA #$03 ; dữ liệu sprite ở địa chỉ $300
STA $4014 ; chứa nội dung trong A vào trong Register chuyển DMA

Trong ví dụ ở bài trước, ta đã viế chương trình di chuyển sprite. Bài này sử dụng lại ví dụ đó nhưng sửa lại phần hiển thị sprite lợi dụng DMA để thể hiện 2 sprite dính liền nhau.

Mã:
   ; Ví dụ về sprite DMA

   ; INES header
   .inesprg 1 ;  - chọn bank 1
   .ineschr 1 ;  -chọn bank graphic 1
   .inesmir 0 ;  - đối xứng gương ngang
   .inesmap 0 ;  - mapper 0

   .bank 1  ; bank 1
   .org $FFFA  ; bắt đầu từ $FFFA

   .dw 0  ; ngắt VBlank
   .dw Start  ; ngắt reset, nhảy đến Stary khi reset và khi khởi động
   .dw 0  ; phát sinh do ngắt phần cứng và ngắt phần mềm

   .bank 0        ; bank 0
   .org $0300    ; bắt đầu từ $0300, bố trí sprite DMA
Sprite1_Y:  .db  0  ; sprite#1 tọa độ Y
Sprite1_T:  .db  0  ; sprite#1 number
Sprite1_S:  .db  0  ; sprite#1 thuộc tính
Sprite1_X:  .db  0  ; sprite#1 tọa độ X
Sprite2_Y:  .db  0  ; sprite#2 tọa độ Y
Sprite2_T:  .db  0  ; sprite#2 number
Sprite2_S:  .db  0  ; sprite#2 thuộc tính
Sprite2_X:  .db  0  ; sprite#2 tọa độ X

   .org $8000    ; bắt đầu từ $8000, phần chính của chương trình
Start:
   lda $2002  ; khi phát sinh VBlank thì bit 7 của $2002 là 1
   bpl Start  ; khi bit7 là 0 thì nhảy tới Start, đợi lặp

   ; Định dạng Register điều khiển PPU
   lda #%00001000
   sta $2000
   lda #%00000110     ; tắt BG và sprite khi đang định dạng
   sta $2001

   ldx #$00  ; xóa nội dung của X Register

   ; chỉ định địa chỉ $3F00 tải palette vào Register $2006 của VRAM
   lda #$3F  ;  bảo $2006 chỉ định
   sta $2006  ; $2007 để bắt đầu
   lda #$00  ; tại $3F00 (pallete).
   sta $2006

loadPal:     
   lda tilepal, x ;

   sta $2007 ; đọc giá trị vào $2007

   inx ; tăng X lên 1

   cpx #32 ; so sánh X với 32
   bne loadPal ;   không bằng thì nhảy tới loadPal
   
   ; định dạng tọa độ của sprite #1
   lda X_Pos_Init
   sta Sprite1_X
   lda Y_Pos_Init
   sta Sprite1_Y
   ; định dạng tọa độ của sprite #2
   lda X_Pos_Init
   adc #7      ; lệch sang phải 7 dot
   sta Sprite2_X
   lda Y_Pos_Init
   sta Sprite2_Y
   ; xoay ngược sprite theo chiều dọc
   lda #%01000000
   sta Sprite2_S

   ; định dạng Register 2 điều khiển PPU
   lda #%00011110   ; bật BG và sprite
   sta $2001

mainLoop:           ; vòng lặp cính
   lda $2002  ; khi VBlank phát sinh thì bit 7 của $2002 là 1
   bpl mainLoop  ; ki bit7=0 thì nhảy tới mainLoop

   ; vẽ sprite (lợi dụng DMA)
   LDA #$03   ; dữ liệu sprite ở địa chỉ $300
   STA $4014   ; chứa nội dung trong A vào trong Register chuyển DMA
   
   ; chuẩn bị I/O Register
   lda #$01
   sta $4016
   lda #$00
   sta $4016

   ; kiểm tra nhấn tay cầm
   lda $4016  ; bỏ qua nút A
   lda $4016  ; bỏ qua nút B
   lda $4016  ; bỏ qua nútSelect
   lda $4016  ; bỏ qua nútStart
   lda $4016  ; nút lên
   and #1  ; AND #1
   bne UPKEYdown  ; nếu khác 0 thì nhảy tới UPKeydown
   
   lda $4016  ; nút xuống
   and #1  ; AND #1
   bne DOWNKEYdown ; nếu khác 0 thì nhảy tới DOWNKeydown

   lda $4016  ; nút trái
   and #1  ; AND #1
   bne LEFTKEYdown ;  nếu khác 0 thì nhảy tới LEFTKeydown

   lda $4016  ; nút phải
   and #1  ; AND #1
   bne RIGHTKEYdown ; nếu khác 0 thì nhảy tới RIGHTKeydown
   jmp NOTHINGdown  ; không nhấn gì thì nhảy tới NOTHINGdown

UPKEYdown:
   dec Sprite1_Y   ; giảm tọa độ Y 1 pixel
   jmp NOTHINGdown

DOWNKEYdown:
   inc Sprite1_Y ; tăng tọa độ Y 1 pixel
   jmp NOTHINGdown

LEFTKEYdown:
   dec Sprite1_X   ; giảm tọa độ X 1 pixel
   jmp NOTHINGdown

RIGHTKEYdown:
   inc Sprite1_X   ; tăng tọa độ Y 1 pixel
   
NOTHINGdown:
   ; cập nhật tọa độ sprite 2
   lda Sprite1_X
   adc #8      ; lệch 8 dot về bên phải
   sta Sprite2_X
   lda Sprite1_Y
   sta Sprite2_Y

   jmp mainLoop         ; trở lại mainLoop

   ; dữ liệu ban đầu
X_Pos_Init  .db 20  ; tọa độ X ban đầu
Y_Pos_Init  .db 40  ; tọa độ Y ban đầu

tilepal: .incbin "giko2.pal" ;  include palette

   .bank 2  ; bank 2
   .org $0000  ; bắt đầu từ $0000

   .incbin "kage.bkg"  
   .incbin "kage2.spr"

Để chung file ảnh nền kage.bkg và file sprite kage2.spr vào cùng thư mục với NESASM, chạy script và xác nhận kết quả bằng giả lập.
 
Bài 11: ngắt VBlank

Khi viết chương trình dài, có những lúc ta cần dùng lại cùng chức năng đã sử dụng trước đó nên sẽ nảy sinh nhu cần cần phải tập hợp lại những chức năng xử lý giống nhau về cùng một chỗ để có thể gọi từ một nơi khác. Trong ngôn ngữ C thì đó là quan số, trong VB là Function.
Còn trong Assembly là JSR (Jump To Subroutine) và RTS (Return from Subroutine).
Trong đoạn mã ở bài trước, từ hàng 62 trở đi có mục "định dạng tọa độ sprite 2" và từ hàng 125 có "cập nhật tọa độ sprite 2" tương đương như nhau. Nếu gom về một mối thì sẽ như thế này.

setSprite2:
; subroutine cập nhật tọa độ sprite #2
LDA Sprite1_X
ADC #8 ; lệch 8 dot sang phải
STA Sprite2_X
LDA Sprite1_Y
STA Sprite2_Y
RTS

Ta gọi khối mã xử lý chung như thế này là Subroutine.
Trước đây ta tiến hành cập nhật sprite 2 ở 2 chỗ thì giờ đổi thành

; gọi Subroutine cập nhật tọa độ sprite 2
JSR setSprite 2

Viết lại đoạn chương trình ở bài trước, sử dụng Subroutine.

Mã:
    .inesprg 1 
    .ineschr 1 
    .inesmir 0 
    .inesmap 0 

    .bank 1      
    .org $FFFA  

    .dw 0  
    .dw Start    
    .dw 0      

    .bank 0   
    .org $0300
Sprite1_Y:     .db  0 
Sprite1_T:     .db  0
Sprite1_S:     .db  0  
Sprite1_X:     .db  0  
Sprite2_Y:     .db  0  
Sprite2_T:     .db  0 
Sprite2_S:     .db  0  
Sprite2_X:     .db  0  

    .org $8000
Start:
    lda $2002
    bpl Start  


    lda #%00001000 
    sta $2000
    lda #%00000110   
    sta $2001

    ldx #$00  


    lda #$3F  
    sta $2006  
    lda #$00  
    sta $2006

loadPal:       
    lda tilepal, x 

    sta $2007 

    inx 

    cpx #32 
    bne loadPal



    lda X_Pos_Init
    sta Sprite1_X
    lda Y_Pos_Init
    sta Sprite1_Y

    jsr setSprite2

    lda #%01000000
    sta Sprite2_S

   
    lda #%00011110
    sta $2001

mainLoop:               
    lda $2002 
    bpl mainLoop  


    lda #$3
    sta $4014
   

    lda #$01
    sta $4016
    lda #$00 
    sta $4016


    lda $4016  
    lda $4016 
    lda $4016 
    lda $4016 
    lda $4016  
    and #1  
    bne UPKEYdown  
   
    lda $4016  
    and #1 
    bne DOWNKEYdown

    lda $4016
    and #1  
    bne LEFTKEYdown

    lda $4016
    and #1  
    bne RIGHTKEYdown 
    jmp NOTHINGdown 

UPKEYdown:
    dec Sprite1_Y
    jmp NOTHINGdown

DOWNKEYdown:
    inc Sprite1_Y 
    jmp NOTHINGdown

LEFTKEYdown:
    dec Sprite1_X   
    jmp NOTHINGdown 

RIGHTKEYdown:
    inc Sprite1_X   


NOTHINGdown:

    jsr setSprite2

    jmp mainLoop           

setSprite2:

    lda Sprite1_X
    adc #8    
    sta Sprite2_X
    lda Sprite1_Y
    sta Sprite2_Y
    rts


X_Pos_Init   .db 20      
Y_Pos_Init   .db 40      

tilepal: .incbin "giko2.pal"

    .bank 2      
    .org $0000    

    .incbin "giko.bkg"  
    .incbin "giko2.spr"

JSR là nhảy đến Subroutine, và trở về bằng chức năng RTS, tiếp tục thực thi hàng code tiếp theo sau JSR.


Ngắt VBlank

Trong những bài trước đã giải thích về VBlank. Thực tế có rất nhiều game Famicom sử dụng ngắt VBlank, đợi đồng bộ mỗi 1/60 giây rồi mới xử lý.
Ta có thể làm phát sinh ngắt NMI qua thời điểm VBlank của Famicom. Chỉ cần đăng ký địa chỉ vòng lặp chính vào bộ ngắt NMI thì có thể xử lý mỗi 1/60 giây.
Ta có thể sửa đổi lại đoạn code bên trên như thế này.

.bank 1 ; đổi sang bank 1
.org $FFFA ; bắt đầu từ $FFFA

.dw mainLoop ; bộ ngắt VBlank (cứ mỗi 1/60 giây thì mainLoop được gọi ra)
.dw Start ; ngắt Reset. Khi khởi động và khi reset thì nhảy tới Start
.dw 0 ; phát sinh do ngắt phần cứng và ngắt phần mềm

Đầu tiên, cấm ngắt NMI ở Start bởi vì muốn tránh không cho mainLoop được thực hiện khi đang định dạng ban đầu. Việc đợi VBlank cũng như trước giờ, và sau khi định dạng xong thì cho phép ngắt NMI, bit 7 của $2000 thành 1.

; set Flag cho phép ngắt Register điều khiển PPU 1
LDA #%11001000
STA $2000

Lưu ý là $2000 là Register chuyên dùng cho việc ghi.
Sau đó là đợi vòng lặp vô hạn. Cứ mỗi 1/60 giây thì phát sinh ngắt và vòng lặp chính được gọi ra.

infinityLoop: ; vòng lặp vô hạn chỉ để đợi phát sinh ngắt VBlank
JMP infinityLoop

Cuối cùng là ghi lệnh phục hồi sau ngắt vào đoạn cuối của mainLoop. Khi xử lý xong mainLoop thì sẽ trở lại infinityLoop bên trên.

NOTHINGdown:
; gọi Subroutine cập nhật tọa độ sprite 2
JSR setSprite2
RTI ; trở về từ sau khi ngắt

Lưu đồ

Dưới đây là lưu đồ giải thích rõ hơn về trình tự xử lý

QRSRqQK.png
 
Bài 12: BG scroll

Vậy là đã kết thúc xong phần sprite, từ giờ ta sẽ tập trung vào BG. BG là dữ liệu ảnh nền (bối cảnh) cấu thành nên toàn bộ màn hình. BG không bị hạn chế như sprite là chỉ hiển thị được 8 sprite theo chiều ngang. Một trong những điểm đánh giá tài năng của lập trình viên là xem việc xử lý BG có khéo hay không. Trong số những game Famicom được đánh giá cao thì phần lớn đều là những game vận dụng, xử lý BG rất khéo. Vì không bị giới hạn như sprite nên BG có thể dùng để thể hiện những nhân vật khổng lồ hay vô số vật thể, nhân vật nhỏ li ti. 99,9% text (chữ) trong game cũng là BG.


Name table


Trong bài 6, ta đã biết Pattern table của BG bắt đầu từ địa chỉ $0000 trong VRAM. Cũng giống như sprite, BG là những tile có kích thước 8x8, gồm 256 chủng loại và có thể chứa 4KB. Name table là khu vực tập trung 256 chủng loại tile này lại và chỉ định mỗi loại bằng một byte, từ 00 đến FF.

Nhìn vào Memory trong bài 3, ta thấy có 4 khu vực Name table nhưng bình thường chỉ sử dụng 2 khu vực, còn lại là phần đối xứng gương. Tuy nhiên cũng có game sử dụng cả 4 khu vực này.
Lần này ta sẽ ghi 960 ($3BF) byte vào địa chỉ $2000 (bắt đầu của Name table 0). Con số 960 là tổng số tile của BG, ngang 32 x dọc 30 =960 vì màn hình Famicom có độ phân giải 256x240.
Số tile này được phủ kín màn hình theo thứ tự từ góc trái bên trên cho đến góc phải bên dưới.

Dưới đây là ví dụ trong trường hợp dữ liệu BG số 9 là tile màu đen, số 1 và số 2 là tile có hình ảnh ngôi sao. Star_Tbl là table số tile 0 màu đen ở khoảng cách giữa các ngôi sao. Điều ta làm là ghi 60 lần tile số 0 (màu đen), tiếp theo là ghi ngôi sao #1, tiếp theo là ghi 45 lần màu đen #0, rồi lại ghi ngôi sao #2... Trong trường hợp này sẽ xử lý như bên dưới


; tạo Name table $2000
lda #$20
sta $2006
lda #$00
sta $2006

lda #$00 ; #0 (nền đen)
ldy #$00 ; định dạng Register Y
loadNametable1:
ldx Star_Tbl, y ; đọc giá trị X vào Star Table
loadNametable2:
sta $2007 ; đọc giá trị thuộc tính vào $2007
dex ; giảm X
bne loadNametable2 ; nếu khác 0 thì lặp lại, cho ra màu đen
; lấy giao thoa tile #1 hay #2 từ giá trị của Y
tya ; Y→A
and #1 ; A AND 1
adc #1 ; tính thêm 1 vào A, ghi vào #1 hoặc #2
sta $2007 ; đọc giá trị thuộc tính vào $2007
lda #$00 ; #0 (màu đen)
iny ; tăng Y
cpy #20 ; lặp lại 20 lần (số Star table)
bne loadNametable1

; dữ liệu start table (20 cái)
Star_Tbl .db 60,45,35,60,90,65,45,20,90,10,30,40,65,25,65,35,50,35,40,35


Table thuộc tính

Table thuộc tính là dữ liệu palette của Name table ngay trước đó. Tuy nhiên, tổng số tile trên màn hình là 960 nhưng khu vực Table thuộc tính chỉ có 64 byte. Đó là vì Famicom dùng 1 byte để chỉ định cho một nhóm 4 tile có kích thước 2x2, vậy là tổng 8x8=64. BG có kích thước 32x30 tile nên bề dọc vẫn còn thừa. Vậy còn 1 byte chỉ định palette thì sao? Đối với byte này thì bit 0~1 chỉ định số hiệu palette của nhóm ở trên bên trái, bit 2~3 cho ở trên bên phải, bit 4~5 là ở dưới bên trái, bit 6~7 là ở dưới bên phải.

Điểm cần lưu ý ở đây là chỉ có thể chỉ định 2 bit đầu (xét theo hệ nhị phân). Trong palette BG từ 0~15, nếu dùng palette 0~3 thì đó là 0 trong 00 (%0000), nếu là palette 4_7 thì là 01 trong 4 (%0100), nếu là palette 8~10 thì là 10 trong 8 (%1000), nếu là palette 12~15 thì là 11 trong 12 (%1100). Tức là sử dụng 1 trong 4 chủng loại (16/6=4) palette.

Hình bên dưới là ví dụ. Chỉ định palette trong table thuộc tính là set 2x2, theo thứ tự góc trên bên trái, trên bên phải, dưới bên trái, dưới bên phải. Địa chỉ tile của Name table được chỉ định lót kín màn hình theo phương ngang từ góc trên bên trái đến góc dưới bên phải.


JXgChep.gif


Trong số 0~15 của palette thì 0, 4, 8, 12 là màu trong suốt, dù có chỉ định màu gì đi nữa cũng không hiển thị được nên thực chất ta chỉ dùng được 3 màu. Khi cần thể hiện hiệu ứng fade in, fade out cho màn hình thì cần phải có kỹ thuật thay đổi palette.
Điểm hạn chế này cũng giống như palette cho sprite đã giải thích ở bài 6. Đối với sprite thì ta chỉ định bằng bit 0~1 trong 3 số 3 trong số 4 byte thông tin của sprite, và BG cũng giống vậy.
Dưới đây là đoạn code ví dụ có sử dụn lệnh EOR, rất tiện lợi khi muốn thay đổi giá trị một cách giao thoa 0→1→0→1 khi thực hiện lệnh diễn toán XOR. Trong ví dụ này sẽ làm cho palette BG có dạng hoa văn sọc.

; load vào table thuộc tính của $23C0
lda #$23
sta $2006
lda #$C0
sta $2006

ldx #$00 ; clear X Register
lda #%00000000 ; chọn palette 0
; #0 hay #1
loadAttrib
eor #%01010101 ; chọn 0 hay 1 giao thoa nhau cách 1 dot khi diễn toán XO
sta $2007 ; đọc giá trị thuộc tính ($0 hay $55) vào $2007
; lặp lại 64 lần (tất cả các tile)
inx
cpx #64
bne loadAttrib



Cuốn BG

Dưới đây là giải thích về chức năng cuốn BG. Ta thử cho cuốn 1 dot cứ mỗi 1/60 giây.

; cuốn BG (giả định có biến số Scroll_X, Scroll_Y)
lda $2002 ; clear giá trị cuốn
lda Scroll_X ; load giá trị cuốn X
sta $2005 ; cuốn theo hướng X
lda Scroll_Y ; load giá trị cuốn Y
sta $2005 ; Y cuốn theo hướng Y

inc Scroll_X ; tăng giá trị cuốn X
inc Scroll_Y ; tăng giá trị cuốn Y


Chương trình cuốn BG chỉ đơn giản là vậy. Tuy nhiên đây chỉ là đoạn code đơn giản lặp lại cùng một màn hình, còn trong game bình thường thì khi cuốn, bối cảnh khác sẽ xuất hiện nên cần thường xuyên cập nhật màn hình. Để làm được việc này thì phải vẽ lại màn hình, cách làm sẽ đề cập ở chương sau.


Chương trình cuốn BG theo chiều ngang

Tải đoạn chương trình này về, giải nén và để các file chung với thư mục NESASM, chạy chương trình và và được một game cuốn màn hình đơn giản.

x0ZNwyr.png
 
Back
Top