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. 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.
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 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. 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.
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. 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. 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 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
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 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. 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.
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. Sprite trong game Jetman. 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. 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. 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. Ở đâ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. 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. 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.
Bài 7: điều khiển tay cầm 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 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ý
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. 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.