From fdf44365e56c3741f47cbd421a57f0d074a5492c Mon Sep 17 00:00:00 2001 From: Miquel Sabaté Solà Date: Thu, 23 Jan 2025 16:14:12 +0100 Subject: fx: Provide an example of PPU bank switching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Miquel Sabaté Solà --- Makefile | 9 +- README.md | 3 +- assets/diskun0.chr | Bin 0 -> 2048 bytes assets/diskun1.chr | Bin 0 -> 2048 bytes config/mmc3.cfg | 93 +++++++++++++ fx/README.md | 14 +- fx/blink.s | 395 +++++++++++++++++++++++++++++++++++++++++++++++++++++ shared/mmc3.s | 23 ++++ 8 files changed, 531 insertions(+), 6 deletions(-) create mode 100644 assets/diskun0.chr create mode 100644 assets/diskun1.chr create mode 100644 config/mmc3.cfg create mode 100644 fx/blink.s create mode 100644 shared/mmc3.s diff --git a/Makefile b/Makefile index 7b89700..461c0b5 100644 --- a/Makefile +++ b/Makefile @@ -18,14 +18,14 @@ clean: @rm -rf out @find . -type f -name "*.o" -delete @find . -type f -name "*.nes" -delete - @mkdir -p out/basics out/scroll out/space + @mkdir -p out/basics out/scroll out/space out/fx .PHONY: deps deps: @which $(CC65) >/dev/null 2>/dev/null || (echo "ERROR: $(CC65) not found." && false) .PHONY: build -build: basics space scroll +build: basics space scroll fx .PHONY: basics basics: @@ -56,3 +56,8 @@ space: scroll: $(E) " CC scroll" $(Q) $(CC65) $(CCOPTS) scroll/level.s -C config/nrom.cfg -o out/scroll/level.nes + +.PHONY: fx +fx: + $(E) " CC fx/blink" + $(Q) $(CC65) $(CCOPTS) fx/blink.s -C config/mmc3.cfg -o out/fx/blink.nes diff --git a/README.md b/README.md index 476e48f..729a8cb 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,8 @@ The examples are distributed like this: but we make it move and perform an action like shooting bullets. - `scroll`: different scrolling tactics. Read the [scroll/README.md](./scroll/README.md) file for more info. -- `fx`: miscellanous effects that can be achieved with this humble machine. +- `fx`: simple graphical effects that can be pulled off. Read the + [fx/README.md](./fx/README.md) file for more info. ## Other projects diff --git a/assets/diskun0.chr b/assets/diskun0.chr new file mode 100644 index 0000000..f009361 Binary files /dev/null and b/assets/diskun0.chr differ diff --git a/assets/diskun1.chr b/assets/diskun1.chr new file mode 100644 index 0000000..10da5ed Binary files /dev/null and b/assets/diskun1.chr differ diff --git a/config/mmc3.cfg b/config/mmc3.cfg new file mode 100644 index 0000000..9dbfa05 --- /dev/null +++ b/config/mmc3.cfg @@ -0,0 +1,93 @@ +MEMORY { + # iNES header + HEADER: file = %O, start = $0000, size = $0010, fill = yes; + + # First 15 switchable banks at $8000. + PRG0_00: start = $8000, size = $2000, file = %O, fill = yes, fillval = $00; + PRG0_01: start = $8000, size = $2000, file = %O, fill = yes, fillval = $01; + PRG0_02: start = $8000, size = $2000, file = %O, fill = yes, fillval = $02; + PRG0_03: start = $8000, size = $2000, file = %O, fill = yes, fillval = $03; + PRG0_04: start = $8000, size = $2000, file = %O, fill = yes, fillval = $04; + PRG0_05: start = $8000, size = $2000, file = %O, fill = yes, fillval = $05; + PRG0_06: start = $8000, size = $2000, file = %O, fill = yes, fillval = $06; + PRG0_07: start = $8000, size = $2000, file = %O, fill = yes, fillval = $07; + PRG0_08: start = $8000, size = $2000, file = %O, fill = yes, fillval = $08; + PRG0_09: start = $8000, size = $2000, file = %O, fill = yes, fillval = $09; + PRG0_0A: start = $8000, size = $2000, file = %O, fill = yes, fillval = $0A; + PRG0_0B: start = $8000, size = $2000, file = %O, fill = yes, fillval = $0B; + PRG0_0C: start = $8000, size = $2000, file = %O, fill = yes, fillval = $0C; + PRG0_0D: start = $8000, size = $2000, file = %O, fill = yes, fillval = $0D; + PRG0_0E: start = $8000, size = $2000, file = %O, fill = yes, fillval = $0E; + + # Last 15 switchable banks at $A000. + PRG1_00: start = $A000, size = $2000, file = %O, fill = yes, fillval = $10; + PRG1_01: start = $A000, size = $2000, file = %O, fill = yes, fillval = $11; + PRG1_02: start = $A000, size = $2000, file = %O, fill = yes, fillval = $12; + PRG1_03: start = $A000, size = $2000, file = %O, fill = yes, fillval = $13; + PRG1_04: start = $A000, size = $2000, file = %O, fill = yes, fillval = $14; + PRG1_05: start = $A000, size = $2000, file = %O, fill = yes, fillval = $15; + PRG1_06: start = $A000, size = $2000, file = %O, fill = yes, fillval = $16; + PRG1_07: start = $A000, size = $2000, file = %O, fill = yes, fillval = $17; + PRG1_08: start = $A000, size = $2000, file = %O, fill = yes, fillval = $18; + PRG1_09: start = $A000, size = $2000, file = %O, fill = yes, fillval = $19; + PRG1_0A: start = $A000, size = $2000, file = %O, fill = yes, fillval = $1A; + PRG1_0B: start = $A000, size = $2000, file = %O, fill = yes, fillval = $1B; + PRG1_0C: start = $A000, size = $2000, file = %O, fill = yes, fillval = $1C; + PRG1_0D: start = $A000, size = $2000, file = %O, fill = yes, fillval = $1D; + PRG1_0E: start = $A000, size = $2000, file = %O, fill = yes, fillval = $1E; + + # Last two fixed banks + vectors. + FIXED: start = $C000, size = $2000, fill = yes, fillval = $FA, define = yes; + TAIL: start = $E000, size = $1FFA, fill = yes, fillval = $FB, define = yes; + ROMV: start = $FFFA, size = $0006, fill = yes, fillval = $FC, define = yes; + + # CHR ROM. Groupped all together as we don't need as much flexibility + # really, but `incbin` statements will suffice. + CHARS: start = $0000, size = $20000, file = %O, fill = no; +} + +SEGMENTS { + HEADER: load = HEADER, type = ro; + + # 15 first switchable banks. + PRG0_00: load = PRG0_00, type = ro; + PRG0_01: load = PRG0_01, type = ro; + PRG0_02: load = PRG0_02, type = ro; + PRG0_03: load = PRG0_03, type = ro; + PRG0_04: load = PRG0_04, type = ro; + PRG0_05: load = PRG0_05, type = ro; + PRG0_06: load = PRG0_06, type = ro; + PRG0_07: load = PRG0_07, type = ro; + PRG0_08: load = PRG0_08, type = ro; + PRG0_09: load = PRG0_09, type = ro; + PRG0_0A: load = PRG0_0A, type = ro; + PRG0_0B: load = PRG0_0B, type = ro; + PRG0_0C: load = PRG0_0C, type = ro; + PRG0_0D: load = PRG0_0D, type = ro; + PRG0_0E: load = PRG0_0E, type = ro; + + # 15 last switchable banks. + PRG1_00: load = PRG1_00, type = ro; + PRG1_01: load = PRG1_01, type = ro; + PRG1_02: load = PRG1_02, type = ro; + PRG1_03: load = PRG1_03, type = ro; + PRG1_04: load = PRG1_04, type = ro; + PRG1_05: load = PRG1_05, type = ro; + PRG1_06: load = PRG1_06, type = ro; + PRG1_07: load = PRG1_07, type = ro; + PRG1_08: load = PRG1_08, type = ro; + PRG1_09: load = PRG1_09, type = ro; + PRG1_0A: load = PRG1_0A, type = ro; + PRG1_0B: load = PRG1_0B, type = ro; + PRG1_0C: load = PRG1_0C, type = ro; + PRG1_0D: load = PRG1_0D, type = ro; + PRG1_0E: load = PRG1_0E, type = ro; + + # Last two fixed banks + vectors. + FIXED: load = FIXED, type = ro; + TAIL: load = TAIL, type = ro; + VECTORS: load = ROMV, type = ro; + + # CHR-ROM is groupped all together. + CHARS: load = CHARS, type = ro; +} 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 diff --git a/shared/mmc3.s b/shared/mmc3.s new file mode 100644 index 0000000..b3f484a --- /dev/null +++ b/shared/mmc3.s @@ -0,0 +1,23 @@ +;;; Useful macros and constants for development on the MMC3 chip. + +.scope MMC3 + BANK_SELECT = $8000 + BANK_DATA = $8001 + MIRRORING = $A000 + RAM_PROTECT = $A001 + IRQ_LATCH = $C000 + IRQ_RELOAD = $C001 + IRQ_DISABLE = $E000 + IRQ_ENABLE = $E001 +.endscope + +.macro BANK_REGISTER_SET REGISTER_ID, REGISTER_VALUE + .if REGISTER_ID < 0 || REGISTER_ID > 7 + .error "bad value for REGISTER_ID when bank switching" + .endif + + lda #REGISTER_ID + sta MMC3::BANK_SELECT + lda #REGISTER_VALUE + sta MMC3::BANK_DATA +.endmacro -- cgit v1.2.3