git.y1.nz

gbdk-2020

GameBoy Development Kit
download: https://git.y1.nz/archives/gbdk.tar.gz
README | Files | Log | Refs | LICENSE

commit 39ea4bf6a3a4766c77ede89d485161c57d9309da
parent c5332025b001e78dc0bbf69a507e3b94b49ea54d
Author: Toxa <56631470+untoxa@users.noreply.github.com>
Date:   Thu, 16 May 2024 01:08:22 +0300

Merge pull request #669 from michel-iwaniec/nes_deferred_fake_lcd_isr-v4

NES: Add deferred hblank system for fake LCD ISRs
Diffstat:
Mdocs/pages/06b_supported_consoles.md24++++++++++++++++++++----
Mgbdk-lib/examples/cross-platform/scroller/src/text_scroller.c30+++++++++++++++++++++++-------
Mgbdk-lib/include/nes/nes.h4++++
Mgbdk-lib/libc/targets/mos6502/nes/crt0.s271++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
Mgbdk-lib/libc/targets/mos6502/nes/global.s5++++-
5 files changed, 272 insertions(+), 62 deletions(-)

diff --git a/docs/pages/06b_supported_consoles.md b/docs/pages/06b_supported_consoles.md @@ -469,9 +469,11 @@ GBDK provides an API for installing Interrupt Service Routines that execute on s But the base NES system has no suitable scanline interrupts that can provide such functionality. So instead, gbdk-nes API allows *fake* handlers to be installed in the goal of keeping code compatible with other platforms. * An installed VBL handler will be called immediately when calling wait_vbl_done. This handler should only update PPU shadow registers -* An installed LCD handler for a specific scanline will be called after the vblank NMI handler has finished execution, and will then manually run a delay loop to reach that scanline before calling your installed LCD handler. +* An installed LCD handler for a specific scanline will then be called repeatedly until the value of _lcd_scanline is either set to an earlier scanline or >= 240. After each invocation, shadow registers are stored into a buffer. +* After the vblank NMI handler has finished palette updates, OAM DMA, VRAM updates and scroll updates it will then manually run a delay loop to reach the particular scanlines that the installed LCD handler was pre-called for, and use the contents of the buffer to update registers. -Because the LCD "ISR" is actually implemented with a delay loop, it will burn a lot of CPU cycles in the frame - the further down the scanline is the larger the CPU cycle loss. In practice this makes this faked-LCD-ISR functionality only suited for status bars at the top screen, or simple parallax cutscenes where the CPU has little else to do. +Because the LCD "ISR" is actually implemented with a delay loop, it will burn a lot of CPU cycles in the frame - the further down the requested scanline is the larger the CPU cycle loss. +In practice this makes this faked-LCD-ISR functionality mostly suitable for status bars at the top of the screen screen. Or for simple parallax cutscenes where the CPU has little else to do. @note The support for VBL and LCD handlers is currently under consideration and subject to change in newer versions of gbdk-nes. @@ -484,13 +486,27 @@ On the GB, the call to wait_vbl_done is an optional call that serves two purpose On gbdk-nes the second point is no longer true, because writes need to be made to the shadow registers *before* wait_vbl_done is called. -But the wait_vbl_done call serves two other very important purposes: +But the wait_vbl_done call serves three other very important purposes: A. It calls the optional VBL handler, where shadow registers can be written (and will later be picked up by the actual vblank NMI handler) -B. It calls flush_shadow_attributes so that updates to background attributes actually get written to PPU memory +B. It repeatedly calls the optional LCD handler up to MAX_LCD_ISR_CALLS times. After each call, PPU shadow registers are stored into a buffer that will later be used by timed code in the NMI to handle mid-frame changes for screen splits / sprite hiding / etc. +C. It calls flush_shadow_attributes so that updates to background attributes actually get written to PPU memory For these reasons you should always include a call to wait_vbl_done if you expect to see any graphical updates on the screen. +### Caveat: Do all status bar scroll movement in LCD handlers to mitigate glitches + +The fake LCD ISR system is not bullet-proof. In particular, it has a problem where lag frames can cause the shadow register updates in LCD handlers not to be ready in time for when the timed code in the NMI handler would be called. This will effectively cause all those updates to be missing for one frame and glitched scroll updates. + +There is currently no complete work-around for this problem other than avoiding lag frames altogether. But the glitch can be made less distracting by making sure only the status bar glitches rather than the main background. + +maIf you are using LCD handlers to achieve a top-screen stationary status bar, it is recommended that you follow the following guidelines to make sure the background itself has consistent scrolling: +* Use move_bkg either in your main loop or in the VBL handler, to set the level scrolling +* Use move_bkg in the first invocation of the LCD handler, to set the (stationary) status bar scroll position +* Use move_bkg in the second invocation of the LCD handler, to reset the background scrolling + +In short: Ensuring that the last called LCD handler sets the scroll back to the original value means the PPU rendering keeps rendering the background from the same scrolling position even when the NMI handling was missed. + ### Tile Data and Tile Map loading #### Tile and Map Data in 2bpp Game Boy Format diff --git a/gbdk-lib/examples/cross-platform/scroller/src/text_scroller.c b/gbdk-lib/examples/cross-platform/scroller/src/text_scroller.c @@ -7,14 +7,27 @@ const uint8_t * scanline_offsets = scanline_offsets_tbl; #define SCROLL_POS 15 #define SCROLL_POS_PIX_START ((SCROLL_POS + DEVICE_SCREEN_Y_OFFSET) * 8) - 1 -#define SCROLL_POS_PIX_END ((SCROLL_POS + DEVICE_SCREEN_X_OFFSET + 1) * 8) - 1 +#define SCROLL_POS_PIX_END ((SCROLL_POS + DEVICE_SCREEN_Y_OFFSET + 1) * 8) - 1 + +extern uint8_t _lcd_scanline; uint8_t scroller_x = 0; void scanline_isr(void) { #if defined(NINTENDO_NES) - // Write directly to hardware scroll registers (only first write will have an effect) - PPUSCROLL = scroller_x; - PPUSCROLL = 0; // 2nd write (dummy) + switch (_lcd_scanline) { + case 0: + move_bkg(0, 0); + _lcd_scanline = SCROLL_POS_PIX_START; + break; + case SCROLL_POS_PIX_START: + move_bkg(scroller_x, SCROLL_POS_PIX_START); + _lcd_scanline = SCROLL_POS_PIX_END; + break; + case SCROLL_POS_PIX_END: + move_bkg(0, SCROLL_POS_PIX_END); + _lcd_scanline = 0; + break; + } #elif defined(NINTENDO) switch (LYC_REG) { case 0: @@ -49,9 +62,12 @@ const uint8_t * scroller_next_char = scroller_text; uint8_t * scroller_vram_addr; uint8_t * base, * limit; -extern uint8_t _lcd_scanline; - void main(void) { + DISPLAY_OFF; + // Fill the screen background with '*' + fill_bkg_rect(0, 0, DEVICE_SCREEN_WIDTH, DEVICE_SCREEN_HEIGHT, '*' - ' '); + SHOW_BKG; SHOW_SPRITES; + printf(" Scrolling %d chars", sizeof(scroller_text) - 1); CRITICAL { @@ -92,7 +108,7 @@ void main(void) { #ifdef NINTENDO_NES // Normal indirect setting of scroll via shadow registers (written by vblank handler) move_bkg(0,0); - _lcd_scanline = SCROLL_POS; + _lcd_scanline = 0; #endif vsync(); } diff --git a/gbdk-lib/include/nes/nes.h b/gbdk-lib/include/nes/nes.h @@ -248,6 +248,10 @@ void add_VBL(int_handler h) NO_OVERLAY_LOCALS; */ void add_LCD(int_handler h) NO_OVERLAY_LOCALS; +/** The maximum number of times the LCD handler will be called per frame. + */ +#define MAX_LCD_ISR_CALLS 4 + /** Set the current screen mode - one of M_* modes Normally used by internal functions only. diff --git a/gbdk-lib/libc/targets/mos6502/nes/crt0.s b/gbdk-lib/libc/targets/mos6502/nes/crt0.s @@ -67,6 +67,7 @@ _sys_time:: .ds 2 _shadow_PPUCTRL:: .ds 1 _shadow_PPUMASK:: .ds 1 __crt0_spritePageValid: .ds 1 +__crt0_disableNMI: .ds 1 _bkg_scroll_x:: .ds 1 _bkg_scroll_y:: .ds 1 _attribute_row_dirty:: .ds 1 @@ -79,6 +80,12 @@ __SYSTEM:: .ds 1 .area _BSS __crt0_paletteShadow:: .ds 25 .mode:: .ds 1 +__lcd_isr_PPUCTRL: .ds .MAX_LCD_ISR_CALLS +__lcd_isr_PPUMASK: .ds .MAX_LCD_ISR_CALLS +__lcd_isr_scroll_x: .ds .MAX_LCD_ISR_CALLS +__lcd_isr_scroll_y: .ds .MAX_LCD_ISR_CALLS +__lcd_isr_delay_num_scanlines: .ds .MAX_LCD_ISR_CALLS +__lcd_isr_num_calls: .ds 1 .area _CODE @@ -161,36 +168,35 @@ ProcessDrawList: ; ; Delays until specified (non-zero) scanline is reached ; -; First scanline's delay needs adjusting for cycle cost of subroutine execution: -; beq-not-taken -1 -; jsr +6 -; lda #0 +2 -; sta *.acc +3 -; ldy #N +2 -; nop +2 -; nop +2 -; bne-taken +3 -; rts +6 -; -> 25 cycles less -; -> N = 19-25/5 = 19-5 +; First scanline's delay needs adjusting in coordination with .do_lcd_ppu_reg_writes ; .define .acc "___SDCC_m6502_ret4" .delay_to_lcd_scanline:: - lda #0 - sta *.acc - ldy #(19-5) - nop - nop - bne 2$ + jsr .delay_12_cycles + jmp 2$ 1$: - ldy #15 + jsr .delay_28_cycles + jsr .delay_28_cycles + jsr .delay_12_cycles ; -> 28 + 28 + 12 = 68 cycles 2$: - dey - bne 2$ ; -> 2 + 14*5 + 4 = 76 cycles + jsr .delay_fractional ; -> 40.666 NTSC cycles 33.5625 PAL cycles + + dex + bne 1$ ; -> 5 cycles + rts + +.delay_28_cycles: + jsr .delay_12_cycles nop - nop ; -> 4 cycles + nop +.delay_12_cycles: + rts +; +; Takes 40.666 NTSC cycles / 33.5626 PAL cycles +; +.delay_fractional: lda #144 ; Initialize A with PAL fractional cycle count ; +7 cycles for NTSC scanlines bit *__SYSTEM @@ -207,12 +213,13 @@ ProcessDrawList: bcs 4$ 4$: sta *.acc ; -> 13.666 NTSC cycles / 13.5625 PAL cycles - - dex - bne 1$ ; -> 5 cycles - rts + rts ; -> 6 cycles for RTS, 6 cycles for JSR = 12 cycles +__crt0_NMI_earlyout: + rti __crt0_NMI: + bit *__crt0_disableNMI + bmi __crt0_NMI_earlyout pha txa pha @@ -259,6 +266,7 @@ __crt0_NMI: bit *__SYSTEM bvc 2$ nop + nop ldy #5 ldx #(14-7) 3$: @@ -267,11 +275,8 @@ __crt0_NMI: dey bne 3$ 2$: - ; ...then delay for desired number of scanlines - ldx *__lcd_scanline - jsr .delay_to_lcd_scanline - ; Call the handler - jsr .jmp_to_LCD_isr + ; Call the write reg subroutine + jsr .do_lcd_ppu_reg_writes __crt0_NMI_skip: ; Update frame counter @@ -402,14 +407,115 @@ __crt0_clearVRAM_loop: .wait_vbl_done:: _wait_vbl_done:: _vsync:: + + .define .lcd_scanline_previous "REGTEMP" jsr _flush_shadow_attributes jsr .jmp_to_VBL_isr + + ; Set initial scanline value + lda #0xFF + sta *.lcd_scanline_previous + ; + ldy #0 + sty __lcd_isr_num_calls + ; Special-case: LCD at scanline 0 should just directly replace VBL shadow_ values + lda *__lcd_scanline + bne 0$ + jsr .jmp_to_LCD_isr + lda #0 + sta *.lcd_scanline_previous +0$: + ; disable NMI, as we are saving and restoring shadow registers that it may use + sec + ror *__crt0_disableNMI + ; Save shadow registers that LCD isr could change + lda *_shadow_PPUMASK + pha + lda *_shadow_PPUCTRL + pha + lda *_bkg_scroll_x + pha + lda *_bkg_scroll_y + pha + + jmp 2$ +1$: + pla + sta *.lcd_scanline_previous + ; We are done if next scanline is <= the previous one + cmp *__lcd_scanline + bcs _wait_vbl_done_waitForNextFrame +2$: + ; + ldy __lcd_isr_num_calls + lda *__lcd_scanline + ; We are done if next LCD scanline >= SCREENHEIGHT + cmp #.SCREENHEIGHT + bcs _wait_vbl_done_waitForNextFrame + pha + clc ; -1 to compensate for LCD PPU write taking up a scanline on its own + sbc *.lcd_scanline_previous + sta __lcd_isr_delay_num_scanlines,y + ; Call LCD isr + jsr .jmp_to_LCD_isr + ; Copy shadow registers + ldy __lcd_isr_num_calls + lda *_shadow_PPUMASK + sta __lcd_isr_PPUMASK,y + lda *_shadow_PPUCTRL + sta __lcd_isr_PPUCTRL,y + lda *_bkg_scroll_x + sta __lcd_isr_scroll_x,y + lda *_bkg_scroll_y + sta __lcd_isr_scroll_y,y + + iny + sty __lcd_isr_num_calls + cpy #.MAX_LCD_ISR_CALLS + bne 1$ + + ; Clear last-scanline-value from stack + pla + +_wait_vbl_done_waitForNextFrame: + ; Restore shadow registers + pla + sta *_bkg_scroll_y + pla + sta *_bkg_scroll_x + pla + sta *_shadow_PPUCTRL + pla + sta *_shadow_PPUMASK + + asl *__crt0_disableNMI lda *_sys_time _wait_vbl_done_waitForNextFrame_loop: cmp *_sys_time beq _wait_vbl_done_waitForNextFrame_loop rts +.display_off:: +_display_off:: + lda *_shadow_PPUMASK + and #~(PPUMASK_SHOW_BG | PPUMASK_SHOW_SPR) + sta *_shadow_PPUMASK + sta PPUMASK + ; Set forced blanking bit + sec + ror *.crt0_forced_blanking + rts + +.display_on:: +_display_on:: + lda *_shadow_PPUMASK + ora #(PPUMASK_SHOW_BG | PPUMASK_SHOW_SPR) + sta *_shadow_PPUMASK + ; Clear forced blanking bit + clc + ror *.crt0_forced_blanking + rts + __crt0_RESET: ; Disable IRQs sei @@ -482,26 +588,91 @@ __crt0_RESET_bankSwitchValue: __crt0_waitForever: jmp __crt0_waitForever -.display_off:: -_display_off:: - lda *_shadow_PPUMASK - and #~(PPUMASK_SHOW_BG | PPUMASK_SHOW_SPR) - sta *_shadow_PPUMASK - sta PPUMASK - ; Set forced blanking bit - sec - ror *.crt0_forced_blanking - rts +.do_lcd_ppu_reg_writes: + .define .reg_write_index "__crt0_NMITEMP+1" + .define .lda_PPUADDR "__crt0_NMITEMP+2" + .define .ldx_PPUMASK "__crt0_NMITEMP+3" -.display_on:: -_display_on:: - lda *_shadow_PPUMASK - ora #(PPUMASK_SHOW_BG | PPUMASK_SHOW_SPR) - sta *_shadow_PPUMASK - sta PPUMASK - ; Clear forced blanking bit - clc - ror *.crt0_forced_blanking + nop + + ; Skip if empty buffer (no calls were made within frame) + lda __lcd_isr_num_calls + beq 2$ + + ldy #0 + sty *.acc +1$: + sty *.reg_write_index + ldx __lcd_isr_delay_num_scanlines,y + beq 3$ + jsr .delay_to_lcd_scanline +3$: + + ; Pre-write PPUADDR (1st write) and y-scroll + sty PPUADDR + lda __lcd_isr_scroll_y,y + sta PPUSCROLL + and #0xF8 + asl + asl + sta *.lda_PPUADDR + ; A <- PPUADDR (2nd write) + lda __lcd_isr_scroll_x,y + lsr + lsr + lsr + ora *.lda_PPUADDR + sta *.lda_PPUADDR + ; ldx <- PPUMASK + ldx __lcd_isr_PPUMASK,y + stx *.ldx_PPUMASK + ; X <- SCROLLX + ldx __lcd_isr_scroll_x,y + ; Y <- PPUCTRL + lda __lcd_isr_PPUCTRL,y + tay + lda *.lda_PPUADDR + ; + ; Write 4 PPU registers in following order. + ; + ; 1. PPUSCROLL (needs to be written to set fine-x) + ; 2. PPUADDR 2nd write (highest priority as needs to happen before the two-tile pre-fetch) + ; 3. PPUCTRL (PPU pattern table switch can affect two-tile pre-fetch) + ; 4. PPUMASK (emphasis and render on/off are maybe less distracting?) + ; + ; TODO: Self-modifying code could build a non-redundant write sequence in RAM. + ; + stx PPUSCROLL + sta PPUADDR + sty PPUCTRL + ldx *.ldx_PPUMASK + stx PPUMASK + + ; Delay for 40.666 NTSC cycles / 33.5625 PAL cycles + jsr .delay_fractional + ldy *.reg_write_index + + ; Finally, write Y-scroll part of T with original non-LCD shadow values, but + ; *without* triggering an update of V, to mitigate glitches on lag frames. + ; In normal circumstances, NMI will re-write T with the new proper Y-scroll + ; value for start of screen. Or the next iteration of this loop may overwrite + ; it as well. + ; But if our calls to VBL/LCD handlers disable NMI just at the wrong moment in + ; the vsync routine, and cause the scroll update in NMI to be skipped, + ; this mitigation will leave T with a "reasonable" value of the old shadow + ; bkg scroll register for Y at scanline 0. + sty PPUADDR + lda *_bkg_scroll_y + sta PPUSCROLL + + nop + nop + lda *0x00 + + iny + cpy __lcd_isr_num_calls + bne 1$ +2$: rts ; Interrupt / RESET vector table diff --git a/gbdk-lib/libc/targets/mos6502/nes/global.s b/gbdk-lib/libc/targets/mos6502/nes/global.s @@ -1,7 +1,10 @@ + ;; Maximum number of times LCD ISR can be repeatedly called + .MAX_LCD_ISR_CALLS = 4 + ;; Transfer buffer (lower half of hardware stack) __vram_transfer_buffer = 0x100 ;; Number of 8-cycles available each frame for transfer buffer - VRAM_DELAY_CYCLES_X8 = 172 + VRAM_DELAY_CYCLES_X8 = 171 ;; Keypad .UP = 0x08

This webpage is intended to be an accessible preview of this repository. To get a fuller picture, clone it and use the git CLI.