aboutsummaryrefslogtreecommitdiff
path: root/fx
diff options
context:
space:
mode:
authorMiquel Sabaté Solà <mikisabate@gmail.com>2025-01-23 16:14:12 +0100
committerMiquel Sabaté Solà <mikisabate@gmail.com>2025-02-04 22:00:07 +0100
commitfdf44365e56c3741f47cbd421a57f0d074a5492c (patch)
tree92b55944dd84171f6c42857402b1f90f835abfa2 /fx
parent6c207c192b7ce1a292249afd7b0a3499be70d03c (diff)
downloadcode.nes-fdf44365e56c3741f47cbd421a57f0d074a5492c.tar.gz
code.nes-fdf44365e56c3741f47cbd421a57f0d074a5492c.zip
fx: Provide an example of PPU bank switching
Signed-off-by: Miquel Sabaté Solà <mikisabate@gmail.com>
Diffstat (limited to 'fx')
-rw-r--r--fx/README.md14
-rw-r--r--fx/blink.s395
2 files changed, 406 insertions, 3 deletions
diff --git a/fx/README.md b/fx/README.md
index 746e922..e7194dc 100644
--- a/fx/README.md
+++ b/fx/README.md
@@ -1,4 +1,12 @@
-TBD
+## Effects by bank switching on CHR-ROM
-- Animating through CHR bank switching (a la Megaman 5 with MMC3)
-- Shrinking a sprite rendered through CHR-RAM
+Some games like Megaman 5/6 performed some subtle background effects by
+performing bank switching on the PPU thanks to the capabilities of the MMC3.
+That is, the trick is to devote at least two similar 1KB chunks where one
+contains a subtly changed version of the other. This way, you can simply perform
+a periodic bank switch and the PPU will render subtly different things every
+time, without the CPU having to dedicate any resources on changing any values on
+the data.
+
+A very simple example is provided in [blink.s](./blink.s) where a character
+blinks periodically.
diff --git a/fx/blink.s b/fx/blink.s
new file mode 100644
index 0000000..c4d141d
--- /dev/null
+++ b/fx/blink.s
@@ -0,0 +1,395 @@
+;;;
+;; Make a character blink by performing bank switching on the PPU.
+;;
+;; This is done by using the MMC3 chip. Read more about this mapper here:
+;; https://www.nesdev.org/wiki/MMC3. The MMC3 is a pretty advanced chip, so
+;; first go over the `basics/` directory for a better understanding on easier
+;; topics. Most importantly, take a look at examples like `basics/persist.s` or
+;; `basics/unrom`, which also perform bank switching albeit with simpler
+;; hardware.
+;;
+;; This examples basically makes use of the bank switching capabilities of the
+;; MMC3 chip, so the handling of IRQs is left to other examples.
+;;
+;; On the context of the MMC3 chip, the CPU ROM space is divided into 4 regions
+;; of 8KB each. Two of these regions are swappable, the rest are fixed. This is
+;; configured when performing bank switching itself. The MMC3 chip has 8
+;; registers which hold which bank to go for any given memory address. Because
+;; there's only two swappable banks on the CPU memory space, only two registers
+;; are given for the CPU: R6 and R7. Bank switching will be a matter of setting
+;; which memory bank corresponds to these two registers. The CHR ROM is similar
+;; but in there the space is divided into 6 regions (hence 6 registers): 2 of
+;; 2KB and 4 of 1KB. Which regions are 2KB and which 1KB is also configured when
+;; doing the bank switching itself. Again, for more in-depth views on all of
+;; this, check out the NesDev wiki. This example tries to be detailed whenever
+;; bank switching happens.
+;;
+;; With that being said, some games like Megaman 5/6 performed some background
+;; effects by simply performing bank switching on the CHR ROM space (e.g.
+;; animating leaves from palm trees). This can easily be achieved by simply
+;; performing a bank switch on the PPU space periodically with two similar
+;; segments. On this example the character blinks because we periodically switch
+;; the bank on the first half of the first pattern table between
+;; shared/diskun{0,1}.chr. The only difference between these two files are the
+;; top sprites for the character. Notice that the code doesn't have to manually
+;; specify which sprite ID to look on each iteration: it's the same ID all the
+;; time, it's just that the data underneath each ID subtly changes.
+
+;; Include helpful definitions.
+.include "../shared/mmc3.s"
+
+;; Variables used on this example.
+.scope Vars
+ counter = $00
+ last_bank = $01
+.endscope
+
+.segment "HEADER"
+ .byte 'N', 'E', 'S', $1A
+ .byte $10 ; 16 * 16 PRG-ROM (256KB)
+ .byte $10 ; 16 * 8 CHR-ROM (128KB)
+ .byte $42, $08 ; Mapper 4, battery present, iNES 2.0 header
+ .res 8, 0
+
+.segment "VECTORS"
+ .addr nmi, reset, irq
+
+;;; NOTE: lots of banks, all of them empty since we don't need them :)
+
+.segment "PRG0_00"
+.byte $FF
+.segment "PRG0_01"
+.byte $FF
+.segment "PRG0_02"
+.byte $FF
+.segment "PRG0_03"
+.byte $FF
+.segment "PRG0_04"
+.byte $FF
+.segment "PRG0_05"
+.byte $FF
+.segment "PRG0_06"
+.byte $FF
+.segment "PRG0_07"
+.byte $FF
+.segment "PRG0_08"
+.byte $FF
+.segment "PRG0_09"
+.byte $FF
+.segment "PRG0_0A"
+.byte $FF
+.segment "PRG0_0B"
+.byte $FF
+.segment "PRG0_0C"
+.byte $FF
+.segment "PRG0_0D"
+.byte $FF
+.segment "PRG0_0E"
+.byte $FF
+.segment "PRG1_00"
+.byte $FF
+.segment "PRG1_01"
+.byte $FF
+.segment "PRG1_02"
+.byte $FF
+.segment "PRG1_03"
+.byte $FF
+.segment "PRG1_04"
+.byte $FF
+.segment "PRG1_05"
+.byte $FF
+.segment "PRG1_06"
+.byte $FF
+.segment "PRG1_07"
+.byte $FF
+.segment "PRG1_08"
+.byte $FF
+.segment "PRG1_09"
+.byte $FF
+.segment "PRG1_0A"
+.byte $FF
+.segment "PRG1_0B"
+.byte $FF
+.segment "PRG1_0C"
+.byte $FF
+.segment "PRG1_0D"
+.byte $FF
+.segment "PRG1_0E"
+.byte $FF
+
+;;; NOTE: the first fixed PRG bank will simply contain utilities for moving the
+;;; player around.
+.segment "FIXED"
+.include "../shared/diskun.s"
+
+;;; NOTE: the main bulk of this example. Comments only for the parts which are
+;;; specific to this example.
+.segment "TAIL"
+
+reset:
+ sei
+ cld
+
+ ldx #$40
+ stx $4017
+
+ ldx #$ff
+ txs
+
+ inx
+ stx $2000
+ stx $2001
+ stx $4010
+
+ ;;;
+ ;; NOTE: Setup MMC3
+
+ ;; Just like we saw with the MMC1 chip on `basics/persist.s`, mirroring is
+ ;; configurable (i.e. not soldered in hardware as with the regular NROM).
+ ;; There's a register you can set for this, tied to even addresses between
+ ;; $A000-$BFFE.
+ ;;
+ ;; Set mirroring to vertical (0).
+ lda #$00
+ sta MMC3::MIRRORING
+
+ ;; Interrupts are a whole topic of their own on the MMC3 chip. Here we just
+ ;; disable them and leave it for other examples. Again, this is tied to a
+ ;; register mapped to a region in memory (even addresses on $E000-$FFFE in
+ ;; this case).
+ sta MMC3::IRQ_DISABLE
+
+ ;; PRG RAM can be protected from writes, but this is a feature that many
+ ;; emulators choose to discard because it might conflict with the MMC6 chip
+ ;; support. Long story short, here we just enable PRG RAM and leave it at
+ ;; that.
+ lda #$80
+ sta MMC3::RAM_PROTECT
+
+ ;; PRG banking setup. PRG-ROM is divided into 4 banks of 8KB each. The last
+ ;; two banks are set to be fixed (otherwise the values below should have set
+ ;; bit 7 each to make other regions fixed and others swappable, see the
+ ;; documentation on NesDev). The first two banks are controlled by registers
+ ;; 6 and 7. In this case, we will simply set the first two banks for these
+ ;; two regions.
+ BANK_REGISTER_SET 6, 0
+ BANK_REGISTER_SET 7, 1
+
+ ;; CHR banking setup. The PPU is divided into 8 banks 1KB each. Hence, we
+ ;; have to fill up this 8KB of the usual CHR-ROM space through banking. The
+ ;; first two registers controlling bank switching on the PPU, R0 and R1,
+ ;; actually hold control over 2KB. Hence, the first two registers actually
+ ;; span 4 banks (that is, the first pattern table on the PPU). We have to
+ ;; account for that when writing into the first two registers (and that's
+ ;; why they are set to 0 and 2 respectively). Other than that, R2-R5 hold
+ ;; which bank goes into the other remaining regions which are 1KB each.
+ ;; Because of this, we can simply assign one 1KB bank to each register.
+ ;;
+ ;; To sum things up, on this setup the first pattern table is equally
+ ;; divided by 2 and it's controlled by R0 and R1 respectively. The second
+ ;; pattern table is equally divided by 4 and it's controlled by R2-R5.
+ BANK_REGISTER_SET 0, 0
+ BANK_REGISTER_SET 1, 2
+ BANK_REGISTER_SET 2, 4
+ BANK_REGISTER_SET 3, 5
+ BANK_REGISTER_SET 4, 6
+ BANK_REGISTER_SET 5, 7
+
+ ;; NOTE: and from here on initialization proceeds as usual.
+
+@vblankwait1:
+ bit $2002
+ bpl @vblankwait1
+
+ ldx #0
+ lda #0
+@ram_reset_loop:
+ sta $000, x
+ sta $100, x
+ sta $300, x
+ sta $400, x
+ sta $500, x
+ sta $600, x
+ sta $700, x
+ inx
+ bne @ram_reset_loop
+
+ lda #$ef
+@sprite_reset_loop:
+ sta $200, x
+ inx
+ bne @sprite_reset_loop
+
+ lda #$00
+ sta $2003
+ lda #$02
+ sta $4014
+
+@vblankwait2:
+ bit $2002
+ bpl @vblankwait2
+
+ lda #$3F
+ sta $2006
+ lda #$00
+ sta $2006
+
+ lda #$0F
+ ldx #$20
+@palettes_reset_loop:
+ sta $2007
+ dex
+ bne @palettes_reset_loop
+ jmp main
+
+;;; NOTE: mainly as usual except that a bit of game loop has been added to
+;;; handle the blinking state.
+.proc main
+ jsr Diskun::init_palettes
+ jsr init_sprites
+
+ cli
+ lda #%10010000
+ sta $2000
+ lda #%00011110
+ sta $2001
+
+@main_game_loop:
+ jsr joypad_read
+ jsr Diskun::update
+
+ lda #%10000000
+ ora $20
+ sta $20
+@wait_for_render:
+ bit $20
+ bmi @wait_for_render
+
+ ;; NOTE: let there be a game logic :D
+
+ ;; Is the counter already at the limit? If not just restart the game loop.
+ lda Vars::counter
+ cmp #$20
+ bne @main_game_loop
+
+ ;; Reset the counter
+ lda #0
+ sta Vars::counter
+
+ ;; The whole trick is done on R0. So select it.
+ lda #0
+ sta MMC3::BANK_SELECT
+
+ ;; The value for the register is either 0 or 2 depending on its last value.
+ lda Vars::last_bank
+ beq :+
+ lda #0
+ beq @set
+:
+ lda #2
+@set:
+ ;; Save which is the bank being used both internally and onto the MMC3 chip.
+ sta Vars::last_bank
+ sta MMC3::BANK_DATA
+
+ jmp @main_game_loop
+.endproc
+
+;; NOTE: from here on nothing remarkable in comparison to other examples.
+
+.proc init_sprites
+ NUM_SPRITES = 4
+
+ lda #$40
+ sta Diskun::m_screen_y
+ lda #$46
+ sta Diskun::m_screen_x
+
+ ldx #$00
+@load_sprites_loop:
+ lda initial_sprite_data, x
+ sta $0200, x
+ inx
+ cpx #(4 * NUM_SPRITES)
+ bne @load_sprites_loop
+ rts
+initial_sprite_data:
+ ;; $200-$20F
+ .byte $40, $01, %00000000, $46
+ .byte $40, $01, %01000000, $4E
+ .byte $48, $11, %00000000, $46
+ .byte $48, $11, %01000000, $4E
+.endproc
+
+;;; NOTE: nothing to highlight here other than the counter is increased on each
+;;; NMI.
+nmi:
+ bit $20
+ bpl @next
+
+ pha
+ txa
+ pha
+ tya
+ pha
+
+ ;; Increase the counter for the blinking.
+ inc Vars::counter
+
+ jsr Diskun::nmi_update
+
+ lda #$00
+ sta $2003
+ lda #$02
+ sta $4014
+
+ bit $2002
+ lda #$00
+ sta $2005
+ sta $2005
+
+ lda #%01111111
+ and $20
+ sta $20
+
+ pla
+ tay
+ pla
+ tax
+ pla
+@next:
+ rti
+
+;;; NOTE: IRQ is disabled when setting up the MMC3 chip for this example.
+irq:
+ rti
+
+;;; NOTE: The header for this game advertises 128KB for CHR-ROM. This is wildly
+;;; too much for this example, but it's a reasonable size for an MMC3 game.
+;;; Considering only one pair of pattern tables are available at any given
+;;; moment (8KB), this means that we need space for 128 / 8 = 16 pairs of
+;;; pattern tables here. For this example the first 8KB are realy only used, and
+;;; the rest are left with a default value ($00).
+
+.segment "CHARS"
+;; First 8KB (note that diskun0.chr and diskun1.chr are both 2KB long)
+.incbin "../assets/diskun0.chr" ; First half of the first pattern table has the default diskun character.
+.incbin "../assets/diskun1.chr" ; Second half of the first pattern table simply has the blinking version.
+.res $1000, $00 ; Second pattern table is left with the default value.
+
+;; The 15 other 8KB portions are left empty.
+.res $2000, $00
+.res $2000, $00
+.res $2000, $00
+.res $2000, $00
+.res $2000, $00
+.res $2000, $00
+.res $2000, $00
+.res $2000, $00
+.res $2000, $00
+.res $2000, $00
+.res $2000, $00
+.res $2000, $00
+.res $2000, $00
+.res $2000, $00
+.res $2000, $00