- 566
- Posts
- 5
- Years
- Seen Apr 29, 2025
A while ago, I was able to implement a 60 fps overworld option in my rom hack Shin Pokemon. It is based on an older commit of pokered disassembly from 2018, but most things are still in the same place. Anyway, I've been wanting to write down how I did this for others to follow. With any luck this will be picked up by search engine crawlers, and some poor sap years from now won't have to waste hours on Google sifting through dead links and vague hints.
Alright! Let's jump right in.
Go to home/overworld.asm and scroll down to OverworldLoop and OverworldLoopLessDelay. Here's the key. Notice that DelayFrame is called in OverworldLoop then you fall through to OverworldLoopLessDelay and call DelayFrame again. This is what causes the overworld to run at 30 fps. The gameboy is locked to 60 fps, but the overworld is updating every 2 frames.
Comment-out that first delay. Congrats, you now have a 60 FPS overworld. Recompile and fire up the game.
What's that? All the movement and animation speeds for overworld objects are messed up and twice as fast? Well, I didn't expect you to be so picky. Fine then. Let's continue on and do things properly.
Go to wram.asm and scroll down to wSpriteStateData2. This is a data structure used by overworld objects. That's tuff that can move around on a map. The 'x' in each address refers to the number of the object on the loaded map. The player character is always object 'x' = 0, so each map can have 15 other objects (1 through F) on it at a time. The really important thing here is address C2xA. That's an unused part of the structure, so comment that as being used for 60FPS. We're going to snag it for our purposes. Specifically, it is going to toggle between 0 and 1 to indicate if object data needs to update or be skipped. This is because, at 60 fps, many things need to only update every other frame.
The 60FPS setting needs to be able to be turned on and off. All that's needed is 1 bit in an unused byte of wram. I used bit 4 of the wram address titled wUnusedD721 out of convenience. 60FPS mode will be active when this bit = 1. Jot this down in your notes. Create the Check60fps function at the bottom of the home/overworld.asm file to easily check for this, and you can now add this toggle to OverworldLoop like so. We'll be doing stuff like this a lot.
Now let's do one of the easier bits. Time to fix jumping ledges. Go to engine/overworld/player_animations.asm and scroll down to the end. You're going to add a new function called Ledge60fps. This function takes the jumping player's incremented Y-coord index as an input in the A register. Running at double framerate means this should only increment every other frame.
Now scroll up to the function _HandleMidJump and call Ledge60fps just after A gets incremented at the beginning.
Go back to home/overworld.asm because there is more stuff to do. Time to fix basic player movement.
Scroll down to OverworldLoopLessDelay.noCollision and notice the counter value of $08.
Modify this so that 60FPS mode doubles this to $10.
Scroll down to AdvancePlayerSprite.afterUpdateMapCoords and notice the counter value of $07.
Modify this so that 60FPS mode doubles this to $0F.
Scroll down to AdvancePlayerSprite.scrollBackgroundAndSprites and notice how some X and Y delta values are shifted left in order to double them.
Modify this so that 60FPS mode does not double them.
Doing great so far!
Walking animations currently run at double-speed. Let's fix that.
Open up engine/overworld/movement.asm and scroll down to the bottom to create a new function called sprite60fps.
No go to UpdatePlayerSprite.moving where you will call the sprite60fps function at a specific spot.
NPCs update their movement twice as fast, so this needs to be corrected.
Go to UpdateSpriteMovementDelay and modify it as such.
UpdateSpriteInWalkingAnimation also needs to have several things update every other frame. Modify it as such.
Finally the last part. DoScriptedNPCMovement needs updates for the rare instances where the player character does scripted movement following behind a NPC. And example of this is when you follow Oak into his lab at the start of the game.
And finish things off with some minor updates to AdvanceScriptedNPCAnimFrameCounter.
And there you go! Setting bit 4 of wUnusedD721 will put the game into 60FPS mode. I'll leave in-game activation to you.
Alright! Let's jump right in.
Go to home/overworld.asm and scroll down to OverworldLoop and OverworldLoopLessDelay. Here's the key. Notice that DelayFrame is called in OverworldLoop then you fall through to OverworldLoopLessDelay and call DelayFrame again. This is what causes the overworld to run at 30 fps. The gameboy is locked to 60 fps, but the overworld is updating every 2 frames.
OverworldLoop::
call DelayFrame
OverworldLoopLessDelay::
call DelayFrame
call LoadGBPal
Comment-out that first delay. Congrats, you now have a 60 FPS overworld. Recompile and fire up the game.
OverworldLoop::
;call DelayFrame ;60fps
OverworldLoopLessDelay::
call DelayFrame
call LoadGBPal
What's that? All the movement and animation speeds for overworld objects are messed up and twice as fast? Well, I didn't expect you to be so picky. Fine then. Let's continue on and do things properly.
Go to wram.asm and scroll down to wSpriteStateData2. This is a data structure used by overworld objects. That's tuff that can move around on a map. The 'x' in each address refers to the number of the object on the loaded map. The player character is always object 'x' = 0, so each map can have 15 other objects (1 through F) on it at a time. The really important thing here is address C2xA. That's an unused part of the structure, so comment that as being used for 60FPS. We're going to snag it for our purposes. Specifically, it is going to toggle between 0 and 1 to indicate if object data needs to update or be skipped. This is because, at 60 fps, many things need to only update every other frame.
wSpriteStateData2:: ; c200
; more data for all sprites on the current map
; holds info for 16 sprites with $10 bytes each
; player sprite is always sprite 0
; C2x0: walk animation counter (counting from $10 backwards when moving)
; C2x1:
; C2x2: Y displacement (initialized at 8, supposed to keep moving sprites from moving too far)
; C2x3: X displacement (initialized at 8, supposed to keep moving sprites from moving too far)
; C2x4: Y position (in 2x2 tile grid steps, topmost 2x2 tile has value 4)
; C2x5: X position (in 2x2 tile grid steps, leftmost 2x2 tile has value 4)
; C2x6: movement byte 1 (determines whether a sprite can move, $ff:not moving, $fe:random movements, others unknown)
; C2x7: (?) (set to $80 when in grass, else $0; may be used to draw grass above the sprite)
; C2x8: delay until next movement (counted downwards, status (c1x1) is set to ready if reached 0)
; C2x9: backup storage of facing direction (0: down, 4: up, 8: left, $c: right)
; C2xA 60fps
; C2xB
; C2xC
; C2xD: picture ID
; C2xE: sprite image base offset (in video ram, player always has value 1, used to compute c1x2)
; C2xF
The 60FPS setting needs to be able to be turned on and off. All that's needed is 1 bit in an unused byte of wram. I used bit 4 of the wram address titled wUnusedD721 out of convenience. 60FPS mode will be active when this bit = 1. Jot this down in your notes. Create the Check60fps function at the bottom of the home/overworld.asm file to easily check for this, and you can now add this toggle to OverworldLoop like so. We'll be doing stuff like this a lot.
OverworldLoop::
;call DelayFrame ;60fps
call Check60fps
call z, DelayFrame
OverworldLoopLessDelay::
call DelayFrame
call LoadGBPal
Check60fps:
ld a, [wUnusedD721]
bit 4, a
ret
Now let's do one of the easier bits. Time to fix jumping ledges. Go to engine/overworld/player_animations.asm and scroll down to the end. You're going to add a new function called Ledge60fps. This function takes the jumping player's incremented Y-coord index as an input in the A register. Running at double framerate means this should only increment every other frame.
Ledge60fps:
push hl
ld h, $c2
ld l, $0a ;point HL to C20A, the address of the player object 60FPS byte, which holds either 0 or 1
sub [hl] ;subtract the value in C20A from the incremented Y index in A, which undoes the increment every other frame
pop hl
ret
Now scroll up to the function _HandleMidJump and call Ledge60fps just after A gets incremented at the beginning.
_HandleMidJump:
ld a, [wPlayerJumpingYScreenCoordsIndex]
ld c, a
inc a
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;60fps - only update every other tick
call Ledge60fps
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
Go back to home/overworld.asm because there is more stuff to do. Time to fix basic player movement.
Scroll down to OverworldLoopLessDelay.noCollision and notice the counter value of $08.
.noCollision
ld a, $08
ld [wWalkCounter], a
jr .moveAhead2
Modify this so that 60FPS mode doubles this to $10.
.noCollision
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;60fps - counter is doubled
call Check60fps
ld a, $8
jr z, .pc60fpsCounter
ld a, $10
.pc60fpsCounter
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
ld [wWalkCounter], a
jr .moveAhead2
Scroll down to AdvancePlayerSprite.afterUpdateMapCoords and notice the counter value of $07.
.afterUpdateMapCoords
ld a, [wWalkCounter] ; walking animation counter
cp $07
jp nz, .scrollBackgroundAndSprites
Modify this so that 60FPS mode doubles this to $0F.
.afterUpdateMapCoords
ld a, [wWalkCounter] ; walking animation counter
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;60fps - counter is doubled
push bc
ld b, a
call Check60fps
ld a, b
ld b, $07
jr z, .pc60fpsCounterComp
ld b, $0F
.pc60fpsCounterComp
cp b
pop bc
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
jp nz, .scrollBackgroundAndSprites
Scroll down to AdvancePlayerSprite.scrollBackgroundAndSprites and notice how some X and Y delta values are shifted left in order to double them.
.scrollBackgroundAndSprites
ld a, [wSpriteStateData1 + 3] ; delta Y
ld b, a
ld a, [wSpriteStateData1 + 5] ; delta X
ld c, a
sla b
sla c
ld a, [hSCY]
Modify this so that 60FPS mode does not double them.
.scrollBackgroundAndSprites
ld a, [wSpriteStateData1 + 3] ; delta Y
ld b, a
ld a, [wSpriteStateData1 + 5] ; delta X
ld c, a
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;60fps - halve the x & y deltas
call Check60fps
jr nz, .xy60fpsEnd
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
sla b
sla c
.xy60fpsEnd
ld a, [hSCY]
Doing great so far!
Walking animations currently run at double-speed. Let's fix that.
Open up engine/overworld/movement.asm and scroll down to the bottom to create a new function called sprite60fps.
What this will do is toggle the value in the C2xA address for the x'th object being referenced every frame. If 60FPS mode is inactive, it will keep this value as 0. If it is active, it will be a value of 1 every other frame. This value is also returned in the B register.sprite60fps:
push hl
push af
ld h, $c2
ld l, $0a
ld a, [H_CURRENTSPRITEOFFSET]
add l
ld l, a ;HL now points to C2xA
ld a, [wUnusedD721]
bit 4, a
ld a, [hl] ;load into A the object 60FPS byte, which holds either 0 or 1
jr nz, .is60fps
xor a ;Xor A with itself to clear it to zero if 60FPS mode is inactive
jr .end
.is60fps
xor $01 ;Xor A with $01 to toggle it between 0 or 1 if 60FPS mode is active
.end
ld [hl], a ;update C2xA with the new toggled value in A
ld b, a ;Store the new toggled value in B
pop af
pop hl
ret
No go to UpdatePlayerSprite.moving where you will call the sprite60fps function at a specific spot.
ld a, [hl] followed by inc a is updating the animation counter. You only want to do this every other frame. Calling sprite60fps and subtracting B from A does this for you by undoing the last increment of A every other frame..moving
ld a, [wd736]
bit 7, a ; is the player sprite spinning due to a spin tile?
jr nz, .skipSpriteAnim
ld a, [H_CURRENTSPRITEOFFSET]
add $7
ld l, a
ld a, [hl]
inc a
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;60fps - only update every other tick
call sprite60fps
sub b
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
ld [hl], a
cp 4
jr nz, .calcImageIndex
NPCs update their movement twice as fast, so this needs to be corrected.
Go to UpdateSpriteMovementDelay and modify it as such.
UpdateSpriteMovementDelay:
ld h, $c2
ld a, [H_CURRENTSPRITEOFFSET]
add $6
ld l, a
ld a, [hl] ; c2x6: movement byte 1
inc l
inc l
cp $fe
jr nc, .tickMoveCounter ; values $fe or $ff
ld [hl], $0
jr .moving
.tickMoveCounter
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;60fps - make the delay counter update every other tick
ld a, [hl]
call sprite60fps
add b
ld [hl], a
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
dec [hl] ; c2x8: frame counter until next movement
jr nz, notYetMoving
.moving
UpdateSpriteInWalkingAnimation also needs to have several things update every other frame. Modify it as such.
UpdateSpriteInWalkingAnimation:
ld a, [H_CURRENTSPRITEOFFSET]
add $7
ld l, a
ld a, [hl] ; c1x7 (counter until next walk animation frame)
inc a
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;60fps - updated xy every other tick
call sprite60fps
push bc
sub b
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
ld [hl], a ; c1x7 += 1
cp $4
jr nz, .noNextAnimationFrame
xor a
ld [hl], a ; c1x7 = 0
inc l
ld a, [hl] ; c1x8 (walk animation frame)
inc a
and $3
ld [hl], a ; advance to next animation frame every 4 ticks (16 ticks total for one step)
.noNextAnimationFrame
ld a, [H_CURRENTSPRITEOFFSET]
add $3
ld l, a
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;60fps - updated xy every other tick
pop bc
push bc
ld a, b
and a
jr nz, .xydone
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
ld a, [hli] ; c1x3 (movement Y delta)
ld b, a
ld a, [hl] ; c1x4 (screen Y position)
add b
ld [hli], a ; update screen Y position
ld a, [hli] ; c1x5 (movement X delta)
ld b, a
ld a, [hl] ; c1x6 (screen X position)
add b
ld [hl], a ; update screen X position
.xydone
ld a, [H_CURRENTSPRITEOFFSET]
ld l, a
inc h
ld a, [hl] ; c2x0 (walk animation counter)
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;60fps - make the delay decounter update every other tick
pop bc
add b ;60fps
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
dec a
ld [hl], a ; update walk animation counter
ret nz
Finally the last part. DoScriptedNPCMovement needs updates for the rare instances where the player character does scripted movement following behind a NPC. And example of this is when you follow Oak into his lab at the start of the game.
DoScriptedNPCMovement:
; This is an alternative method of scripting an NPC's movement and is only used
; a few times in the game. It is used when the NPC and player must walk together
; in sync, such as when the player is following the NPC somewhere. An NPC can't
; be moved in sync with the player using the other method.
ld a, [wd730]
bit 7, a
ret z
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;60fps - update animations every other frame and halve movement
ld de, $00
ld a, [wUnusedD721]
bit 4, a
jr z, .not60fps
call sprite60fps
ld e, b
ld d, $01
.not60fps
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
ld hl, wd72e
bit 7, [hl]
set 7, [hl]
jp z, InitScriptedNPCMovement
ld hl, wNPCMovementDirections2
ld a, [wNPCMovementDirections2Index]
add l
ld l, a
jr nc, .noCarry
inc h
.noCarry
ld a, [hl]
; check if moving up
cp NPC_MOVEMENT_UP
jr nz, .checkIfMovingDown
call GetSpriteScreenYPointer
ld c, SPRITE_FACING_UP
ld a, -2
add d ;60fps
jr .move
.checkIfMovingDown
cp NPC_MOVEMENT_DOWN
jr nz, .checkIfMovingLeft
call GetSpriteScreenYPointer
ld c, SPRITE_FACING_DOWN
ld a, 2
sub d ;60fps
jr .move
.checkIfMovingLeft
cp NPC_MOVEMENT_LEFT
jr nz, .checkIfMovingRight
call GetSpriteScreenXPointer
ld c, SPRITE_FACING_LEFT
ld a, -2
add d ;60fps
jr .move
.checkIfMovingRight
cp NPC_MOVEMENT_RIGHT
jr nz, .noMatch
call GetSpriteScreenXPointer
ld c, SPRITE_FACING_RIGHT
ld a, 2
sub d ;60fps
jr .move
.noMatch
cp $ff
ret
.move
ld b, a
ld a, [hl]
add b
ld [hl], a
ld a, [H_CURRENTSPRITEOFFSET]
add $9
ld l, a
ld a, c
ld [hl], a ; facing direction
call AnimScriptedNPCMovement
ld hl, wScriptedNPCWalkCounter
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;60fps - every other frame, do not decrement walk counter
ld a, [hl]
add e
ld [hl], a
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
dec [hl]
ret nz
ld a, 8
ld [wScriptedNPCWalkCounter], a
ld hl, wNPCMovementDirections2Index
inc [hl]
ret
And finish things off with some minor updates to AdvanceScriptedNPCAnimFrameCounter.
AdvanceScriptedNPCAnimFrameCounter:
ld a, [H_CURRENTSPRITEOFFSET]
add $7
ld l, a
ld a, [hl] ; intra-animation frame counter
inc a
sub e ;60fps
ld [hl], a
cp 4
ret nz
xor a
ld [hl], a ; reset intra-animation frame counter
inc l
ld a, [hl] ; animation frame counter
inc a
and $3
ld [hl], a
ld [hSpriteAnimFrameCounter], a
ret
And there you go! Setting bit 4 of wUnusedD721 will put the game into 60FPS mode. I'll leave in-game activation to you.