aboutsummaryrefslogtreecommitdiff
path: root/scroll/include/buffer.s
diff options
context:
space:
mode:
authorMiquel Sabaté Solà <mikisabate@gmail.com>2025-03-10 22:55:41 +0100
committerMiquel Sabaté Solà <mikisabate@gmail.com>2025-03-10 23:22:22 +0100
commitba0cf9e5293f258c58ced1fcc47f558e7419dae4 (patch)
treeb620422506e5b8a951f7e52756c116bfb1498ea3 /scroll/include/buffer.s
parent1a74e6a1856673072a61abd0861759901bc2d939 (diff)
downloadcode.nes-ba0cf9e5293f258c58ced1fcc47f558e7419dae4.tar.gz
code.nes-ba0cf9e5293f258c58ced1fcc47f558e7419dae4.zip
Provide an example on multiscreen scrolling
Signed-off-by: Miquel Sabaté Solà <mikisabate@gmail.com>
Diffstat (limited to 'scroll/include/buffer.s')
-rw-r--r--scroll/include/buffer.s258
1 files changed, 258 insertions, 0 deletions
diff --git a/scroll/include/buffer.s b/scroll/include/buffer.s
new file mode 100644
index 0000000..fd08c92
--- /dev/null
+++ b/scroll/include/buffer.s
@@ -0,0 +1,258 @@
+;;;
+;; VRAM buffer
+;;
+;; As a general rule, you do *not* modify the PPU memory outside of `nmi` code:
+;; this can incur into graphical glitches because of writing into PPU memory
+;; while the PPU is consuming it. Instead, you append all writes into a VRAM
+;; buffer so the changes are finally committed during VBlank.
+;;
+;; The format of the VRAM buffer being used here is quite straight-forward
+;; and needs three bytes per update: two bytes for the PPU address, and
+;; another for the data to be passed into the PPU. This means that for each
+;; metatile we need 3 bytes per tile update * 4 tiles per metatile = 12
+;; bytes per update on a metatile. This is important when considering the
+;; capacity for this buffer. One idea could be to allow a whole column of
+;; metatiles to fit this buffer, which would require 12 bytes per metatile *
+;; 15 rows = 180 bytes. This does not look like a lot at first, but bear in
+;; mind that flushing this buffer has to be done during VBlank, which has a
+;; very tight time frame. Testing this number I noticed that even with 160
+;; bytes for this buffer (the suggested size on the NesDev wiki:
+;; https://www.nesdev.org/wiki/Sample_RAM_map) the NMI code would be
+;; struggling to fit the time frame.
+;;
+;; Thus, by poking with different values, I ended up with 96 bytes for this
+;; buffer, which allows for updates on 8 metatiles (a little more than half
+;; a screen's column). This looks quite reasonable with all the tests I have
+;; done.
+;;
+;; As a final note, on a real game you'd want to be more "aggressive" than this,
+;; as the format on the VRAM buffer directly influences how fast the code during
+;; VBlank can go. Hence, this buffer might not be suitable for your case.
+.scope Buffer
+ ;; The index of the next element to be evaluated on the VRAM buffer. If this
+ ;; index reaches `VRAM_BUFFER_CAP` then we are sure that we are done
+ ;; flushing updates. It's up for the user to notice when the buffer is full
+ ;; and should stop sending in more updates: the user should stop things as
+ ;; they are and continue on the next frame by picking up this index again.
+ zp_vram_idx = $19
+
+ ;; The VRAM buffer lives at a somewhat dangerous RAM address: $0100. This
+ ;; can be dangerous because we set the stack to start at $01FF and grows in
+ ;; decreasing addresses. Considering that the buffer is capped at $60, it
+ ;; means that if there are no buffer overflows, the memory layout for the
+ ;; $01xx RAM space is:
+ ;;
+ ;; - $0100-$0159: VRAM buffer (96 bytes).
+ ;; - $0160-$01FF: Stack (160 bytes).
+ ;;
+ ;; Crossing fingers there are no leaks on the VRAM buffer, we have 160 bytes
+ ;; for the stack, which realistically should be more than enough for any
+ ;; game out there. That is, unless you are abusing the stack a la
+ ;; Battletoads.
+ m_vram_buffer = $0100
+
+ ;; As explained above, the maximum capacity for the VRAM buffer, which
+ ;; amounts to 96 bytes (allowing for updates on 8 metatiles during VBlank).
+ VRAM_BUFFER_CAP = $60
+
+ ;; A tile in the VRAM buffer spans 3 bytes: 2 for the address, 1 for the
+ ;; sprite identifier. Since a metatile is made up of 4 tiles, then a
+ ;; metatile "bucket" is 3 * 4 = 12 bytes long.
+ METATILE_SIZE = 3 * 4
+
+ ;; Push the given metatile into the graphics and the collision map. How the
+ ;; graphics are updated depends on whether the PPU is enabled or not. If
+ ;; it's enabled then all updates are pushed into the VRAM buffer (given that
+ ;; enough space is available on this buffer, see more on the return value).
+ ;; Otherwise it pushes data into the PPU directly so everything is good
+ ;; after enabling the PPU back. This function expects the following
+ ;; *parameters*:
+ ;;
+ ;; - `a` register: the metatile definition as described in `metatile.s`.
+ ;; More specifically, the second byte on a metatile reference on the
+ ;; screen. Hence, something like `$81` for metatile with ID = 1 and with
+ ;; collision.
+ ;; - `zp_arg0`: the Y metatile coordinate.
+ ;; - `zp_arg1`: the X metatile coordinate.
+ ;; - `zp_arg2`: low byte for the corresponding PPU base address.
+ ;; - `zp_arg3`: high byte for the corresponding PPU base address.
+ ;;
+ ;; For the memory arguments see:
+ ;; `Background::translate_mt_coordinates_to_arguments`, as calling this
+ ;; function will set these values properly.
+ ;;
+ ;; *Returns* 1 if the push was successful, 0 otherwise. A push can only
+ ;; *not* be successful if the VRAM buffer doesn't have enough space for the
+ ;; push.
+ ;;
+ ;; NOTE: the VRAM size check also happens even if the PPU is disabled. That
+ ;; is, whenever you disable the PPU, make sure to mark the VRAM buffer as
+ ;; free as well.
+ .proc push_metatile
+ ;; Do we actually have room for this metatile? If not then just return
+ ;; early.
+ ldx zp_vram_idx
+ cpx #(VRAM_BUFFER_CAP - METATILE_SIZE)
+ bne @has_enough_space
+ lda #0
+ rts
+
+ @has_enough_space:
+ ;; Set the proper collision for the given metatile definition, while
+ ;; also preserving the value from the `a` register.
+ pha
+ jsr Collision::set_background_collision
+ pla
+
+ ;; Set the `y` register to contain the index on the `metatiles` table
+ ;; from `metatile.s`. This index will then be incremented on each
+ ;; iteration to index each tile on a metatile.
+ A_TO_METATILE_INDEX
+ tay
+
+ ;; Is the PPU enabled?
+ lda #%00011000
+ and PPU::zp_mask
+ bne @buffered_push
+
+ ;; The PPU is disabled, we can push directly graphics into it.
+ ldx #$00
+ @direct_push_loop:
+ bit PPU::STATUS
+
+ ;; Write the high byte of the PPU address being used.
+ lda Globals::zp_arg3
+ sta PPU::ADDRESS
+
+ ;; The low byte for the PPU address is the base as given from the
+ ;; parameter plus an offset that we have pre-computed (nothing too
+ ;; misterious, just check what we need to add for each tile of the
+ ;; metatile).
+ lda ppu_offsets, x
+ clc
+ adc Globals::zp_arg2
+ sta PPU::ADDRESS
+
+ ;; Load the tile id for this part of the metatile and increment the `y`
+ ;; register in preparation for the next iteration.
+ lda (Metatile::zp_metatile_ptr), y
+ sta PPU::DATA
+ iny
+
+ inx
+ cpx #4
+ bne @direct_push_loop
+ jmp @end
+
+ @buffered_push:
+ ;; Set on `Globals::zp_tmp0` the index on the metatile table, and on
+ ;; `Globals::zp_tmp1` the loop index.
+ sty Globals::zp_tmp0
+ lda #$00
+ sta Globals::zp_tmp1
+ ldx zp_vram_idx
+
+ @buffered_push_loop:
+ ;; The first element being pushed is the high byte for the PPU address
+ ;; to be used, which can be pushed directly.
+ lda Globals::zp_arg3
+ sta m_vram_buffer, x
+ inx
+
+ ;; The low byte needs some addition as in `@direct_push_loop`, but
+ ;; taking into account that the index is handled in memory instead of
+ ;; registers.
+ ldy Globals::zp_tmp1
+ lda ppu_offsets, y
+ clc
+ adc Globals::zp_arg2
+ sta m_vram_buffer, x
+ inx
+
+ ;; Load on `y` the index on the metatile table and increase it for the
+ ;; next iteration.
+ ldy Globals::zp_tmp0
+ inc Globals::zp_tmp0
+
+ ;; And push the tile id for this part.
+ lda (Metatile::zp_metatile_ptr), y
+ sta m_vram_buffer, x
+ inx
+
+ ;; Should we continue?
+ inc Globals::zp_tmp1
+ lda Globals::zp_tmp1
+ cmp #4
+ bne @buffered_push_loop
+
+ ;; And save the current index for the VRAM buffer.
+ stx zp_vram_idx
+
+ @end:
+ ;; End of the push, set the proper return value.
+ lda #1
+ rts
+ ppu_offsets:
+ .byte $00, $01, $20, $21
+ .endproc
+
+ ;; Flush the current contents of the VRAM buffer into the PPU. This function
+ ;; will also set the "ppu" flag if data was actually pushed.
+ ;;
+ ;; NOTE (NMI): this function should *only* be called during VBlank.
+ .proc flush_vram_buffer
+ ldx #$FF
+
+ @loop:
+ inx
+ cpx zp_vram_idx
+ beq @after_loop
+
+ bit PPU::STATUS
+
+ lda m_vram_buffer, x
+ sta PPU::ADDRESS
+
+ inx
+ lda m_vram_buffer, x
+ sta PPU::ADDRESS
+
+ inx
+ lda m_vram_buffer, x
+ sta PPU::DATA
+
+ jmp @loop
+
+ @after_loop:
+ ;; If no loop cycle was performed then, in theory, there is no data to
+ ;; be buffered into PPU. If this was not the case, and things had to be
+ ;; updated anyways (e.g. the scroll moved regardless of this), then it's
+ ;; up for the caller to decide.
+ lda zp_vram_idx
+ beq @end
+
+ ;; Buffer a write into the PPU control register, so the scroll is also
+ ;; left at a known state after touching the PPU address register.
+ lda #%01000000
+ ora Globals::zp_flags
+ sta Globals::zp_flags
+
+ ;; If there were some bytes actually consumed by this loop, ensure that
+ ;; it's reset.
+ lda #0
+ sta zp_vram_idx
+
+ @end:
+ rts
+ .endproc
+.endscope
+
+;; Flush the VRAM buffer if there are pending updates.
+;;
+;; NOTE (NMI): this macro should *only* be used during VBlank.
+.macro FLUSH_PENDING_VRAM_BUFFER
+ lda Buffer::zp_vram_idx
+ beq :+
+ jsr Buffer::flush_vram_buffer
+:
+.endmacro