Saving and Loading


What this will probably cover

Let me get this out of the way first...

The basic logistics of reading & writing save data is covered very well here: https://plutiedev.com/saving-sram. Go read that, it's quick.

I'll use this time to instead talk about a few things specific to how I added saving & loading to Retail Clerk '89 like:

When this is all done we should have a save option on the game's status screen with a semi-friendly confirmation message like this:

Status screen with save dialog

Also a load screen that looks remarkably similar. Let's get started...

Initializing SRAM

When the Genesis starts up the SRAM is either empty or filled with garbage. It would be bad to try and load a save game from it. When the game starts let's check if the SRAM has been initialized by looking for a value waaay at the end. If that value isn't found we'll write it and also mark the start of each save record with $7FFF to indicate it is empty. I'm spacing the save games out by $2000 which can be adjusted to your liking.


SRAM_START=$200001 ; SRAM start address
SRAM_END=$20FFFF ; SRAM end address
SRAM_LOCK=$A130F1 ; address of lock bit
SRAM_INIT_CHECK=$20FFFE ; address to check if SRAM is initialized
SAVE_SIZE=$2000 ; max size of a save game slot
[...]
InitSRAM:
 move.w #$2700,sr  ; disable interrupts
 move.b #1,(SRAM_LOCK) ; unlock SRAM
 lea (MEM_TEMP_SPACE),a4 ; point a4 to temp space
 clr d6 ; just being paranoid
 move.w #$0004,d6 ; 4 save slots, init check is after the last
 mulu.w #SAVE_SIZE,d6 ; copy by save size
 lea (SRAM_START),a5 ; point a5 to the start of SRAM
 adda.l d6,a5 ; move to offset location
 move.b (a5),(a4) ; write byte to temp space
 addq.l #1,a4 ; advance write byte
 addq.l #2,a5 ; advance read byte
 move.b (a5),(a4) ; write next byte to temp space
 move.l (MEM_TEMP_SPACE),d7 ; copy temp value to d7
 swap d7 ; swap d7
 cmpi.w #$8989,d7 ; test if it matches the expected init value
 beq.s ExitInitSRAM ; branch if it does
 ; write init data
 lea (SRAM_START),a5 ; point a5 to the start of SRAM
 adda.l d6,a5 ; move to offset location
 move.b #$89,(a5) ; first half of word
 addq.l  #2,a5 ; advance write byte
 move.b #$89,(a5) ; second half of word
 ; initialize the four save slots
 move.w #$0003,d7 ; four save slots to loop through
 clr.l d6 ; just being paranoid again
InitSRAMLoop:
 move.w d7,d6 ; copy d7 to d6
 mulu.w #SAVE_SIZE,d6 ; copy by save size
 lea (SRAM_START),a5 ; point a5 to the start of SRAM
 adda.l d6,a5 ; move to offset location
 move.b #$7F,(a5) ; first half of word
 addq.l  #2,a5 ; advance write byte
 move.b #$FF,(a5) ; second half of word
 dbf d7,InitSRAMLoop ; loop
ExitInitSRAM:
 move.b #0,(SRAM_LOCK) ; lock SRAM
 move.w #$2000,sr  ; re-enable interrupts
 rts

Let's fire up a hex editor to verify this worked. Here's the initialization value:

Initialized SRAM

And here's a save record:

Empty save state

To go along with this, we need a subroutine to lookup if a save slot is empty. This takes a parameter in d7 and returns the first value found for that save slot number in d6. Some day I will learn to pass parameters and return values the right way.


SaveGameLookup:
 move.w #$2700,sr ; disable interrupts
 move.b  #1,(SRAM_LOCK) ; unlock SRAM
 lea (MEM_TEMP_SPACE),a4 ; point a4 to temp space
 mulu.w #SAVE_SIZE,d7 ; d7 should have the save slot number
 lea (SRAM_START),a5 ; point a5 to start of SRAM
 adda.l d7,a5 ; move to offset location
 move.b (a5),(a4) ; write byte to temp space
 addq.l #1,a4 ; advance write byte
 addq.l #2,a5 ; advance read byte
 move.b (a5),(a4) ; write next byte to temp space
 move.b  #0,(SRAM_LOCK)   ; lock SRAM
 move.l (MEM_TEMP_SPACE),d6 ; copy temp value to d6
 swap d6 ; swap to low word
 move.w #$2000,sr  ; re-enable interrupts
 rts

Saving Game State

I took a simple path here:

  1. Re-organize the memory map so all the things I'd want to save are grouped together
  2. Point to the first address of this group
  3. Loop until everything is written

The memory map then starts with the current objective (more on why later) and continues until the active scene ID is reached. The scene concept is introduced back in: https://huguesjohnson.com/programming/genesis/scenes-dialogs/. I don't need to save all the information about the scene because it can be reloaded based on the ID.


;-------------------------------------------------------------------------------
; game state
;-------------------------------------------------------------------------------
; MEM_OBJECTIVE is first because it's used for save game title 
MEM_OBJECTIVE=$FFFF0026 ; which text to show on objectives
MEM_GAME_STATE=$FFFF0028 ; used to control the main loop flow
[...]
MEM_ACTIVE_SCENE_ID=$FFFF013E ; ID of the active scene
MEM_ACTIVE_SCENE_EXIT_S=$FFFF0140 ; south exit of active scene
[...]
SAVE_GAME_START=MEM_OBJECTIVE ; start location of save game data
SAVE_GAME_END=MEM_ACTIVE_SCENE_EXIT_S ; end location of save game data
SAVE_DATA_LENGTH=SAVE_GAME_END-SAVE_GAME_START

It's just a simple loop then to save the game state.


SaveGame:
 ; compute which save slot to use
 clr.l d7 ; just being paranoid
 move.w (MEM_MENU_SELECTION),d7 ; copy menu selection to d7
 mulu.w #SAVE_SIZE,d7 ; copy by save size
 lea (SRAM_START),a5 ; point a5 to the start of SRAM
 adda.w d7,a5 ; move to offset location
 move.b #1,(SRAM_LOCK) ; unlock SRAM
 lea (SAVE_GAME_START),a4 ; point to start of game state
 move.w #SAVE_DATA_LENGTH,d7 ; size of data to read
SaveLoop:
 move.b (a4),(a5) ; write byte to SRAM
 addq.l  #1,a4 ; advance read byte
 addq.l  #2,a5 ; advance write byte
 dbf d7,SaveLoop ; loop
SaveLoopEnd:
 move.b #0,(SRAM_LOCK) ; lock SRAM
 rts

I got lazy and assumed the address at MEM_MENU_SELECTION would always have a number from 0-3, which it always should. That will be true until I decide to create some kind of zany auto-save feature.

Loading Game State

Loading is the same logic as saving only copying from SRAM to regular memory.


LoadGame:
 ; compute which save slot to use
 clr.l d7 ; just being paranoid
 move.w (MEM_MENU_SELECTION),d7 ; copy menu selection to d7
 mulu.w #SAVE_SIZE,d7 ; copy by save size
 lea (SRAM_START),a5 ; point a5 to the start of SRAM
 adda.w d7,a5 ; move to offset location
 move.b #1,(SRAM_LOCK) ; unlock SRAM
 lea (SAVE_GAME_START),a4 ; point to start of game state
 move.w #SAVE_DATA_LENGTH,d7 ; size of data to read
LoadLoop:
 move.b (a5),(a4) ; read byte from SRAM
 addq.l  #1,a4 ; advance write byte
 addq.l  #2,a5 ; advance read byte
 dbf d7,LoadLoop ; loop
LoadLoopEnd:
 move.b #0,(SRAM_LOCK) ; lock SRAM
 ; the game is saved from the status screen, need to clear that
 move.l (MEM_GAME_STATE),d7 ; copy current game state to d7
 bclr.l #STATE_FLAG_STATUS_SCREEN,d7 ; update game state
 move.l d7,(MEM_GAME_STATE) ; save updated game state
 rts

The last game state update is needed because saving always happens on the status screen. When the game is re-loaded I assume the player doesn't want to land on the status screen so it's cleared.

Title Lookup

Save games need to have a title so players can tell them apart. At the start we have save and load screens with empty text:

Screens with used save slots

There were two options:

  1. Let the player pick a name (a lot of work)
  2. Use some attribute from the game state to build the title (less work)

You already know which I picked.

Screens with used save slots

Look at those beautiful save game titles. They're just a simple lookup based on the player's current objective. On the status screen (the picture on the left) there's some text with a hint that changes throughout the game. The same mechanism that determines the help text can be used for save titles.

There's already a list of 16 objectives, all we need to add is a lookup table and of course the actual text.


OBJECTIVE_D0_O0=$0000
OBJECTIVE_D1_O0=$0001
[...]
OBJECTIVE_D6_O2=$0010
MAX_OBJECTIVE=OBJECTIVE_D6_O2
[...]
SaveGameTitles:
 dc.l SaveGameTitleD0O0
 dc.l SaveGameTitleD1O0
[...]
SaveGameTitleD0O0:
 dc.b "November 19 (Evening)",ETX
SaveGameTitleD1O0:
 dc.b "November 24 (Day)",ETX
[...]

Now let's update the main game loop to react to the load menu being selected:


MainGameLoop:
[...]
 ; test if load screen is showing
 btst.l #STATE_FLAG_LOAD_MENU,d7 ; test game state
 bne.w ProcessLoadScreen
[...]
 move.w #$0000,(MEM_MENU_SELECTION) ; clear menu selection
 bset.l #STATE_FLAG_LOAD_MENU,d7 ; set debug menu flag
 move.l d7,(MEM_GAME_STATE) ; save updated game state
 lea SceneLoad,a6 ; address of the scene for the debug menu
 bsr.w LoadScene ; branch to LoadScene subroutine
 bsr.w LoadPlayerSprite ; load the player sprite (also loads sprite 0)
 bsr.w BuildLoadScreen ; builds the text on the screen
 bsr.w FadeIn ; fade in to the new scene
 bra.w MainGameLoop ; return to start of game loop

To draw the names of the save games, we need a constant to track the write location:


;-------------------------------------------------------------------------------
; load screen settings
;-------------------------------------------------------------------------------
LOAD_SCREEN_SAVE0_VDP=VDP_VRAM_WRITE_B+$07800000+$00140000

Now when we build the load screen, we need to look at all four save slots and determine if they have a valid game. That's where the $7FFF initialization value comes into play. It needs to be something larger than MAX_OBJECTIVE and I will never make a game long enough to have 32,767 objectives. Once we have a valid save game we then lookup the text and draw it.



BuildLoadScreen:
 move.w #$2700,sr  ; disable interrupts
 move.w #$0003,d5 ; three save slots
BuildLoadScreenLoop:
 move.w d5,d7 ; d7 gets modified by SaveGameLookup
 bsr.w SaveGameLookup ; get save title into d6
 cmpi.w #MAX_OBJECTIVE,d6 ; is this > MAX_OBJECTIVE?
 bgt .empty
 ; lookup text
 mulu.w #LWORD_SIZE,d6 ; multiply by LWORD_SIZE to get offset
 lea SaveGameTitles,a5 ; point a5 to the lookup table
 adda.l d6,a5 ; move to offset
 move.l (a5),a0 ; point a0 to the first character
 bra.s .buildtext
.empty
 lea StatusScreenEmptySave,a0 ; use text for empty save slot
.buildtext
 move.l #LOAD_SCREEN_SAVE0_VDP,d7 ; point d7 to first line
 clr d6 ; lazy way to prevent various bugs
 move.w #$0080,d6 ; using d6 to compute row
 mulu.w d5,d6 ; multiply by current save slot
 swap d6 ; move to high word
 add.l d6,d7 ; add offset to d7
 move.l d7,(MEM_DIALOG_VDP)
DrawSaveTitleLoop:
 clr d6 ; lazy way to prevent various bugs
 move.b (a0)+,d6 ; copy current character to d6
 cmpi.b #ETX,d6 ; is this the end of the text?
 beq.s DrawSaveTitleLoopExit ; end of text, move on
 ; update d6 to point to the tile ID
 sub.w #$20,d6 ; subtract 32 to get the character index
 add.w #DIALOG_BASE_TILE_LOW+%1000000000000000,d6 ; add the base tile
 move.l (MEM_DIALOG_VDP),(VDP_CONTROL) ; set VDP address
 move.w  d6,(VDP_DATA) ; copy the character to VDP
 ; draw the next character
 add.l #$00020000,(MEM_DIALOG_VDP) ; move to the next column
 bra.w DrawSaveTitleLoop ; loop until ETX
DrawSaveTitleLoopExit:
 dbf d5,BuildLoadScreenLoop ; loop
ExitBuildLoadScreen:
 move.w #$2000,sr ; re-enable interrupts
 rts

This last part is handling the selection cursor while the load menu is showing and re-loading the scene when a save game is chosen.



ProcessLoadScreen:
 ; process b press
 move.b (MEM_CONTROL_PRESSED),d6 ; copy pressed buttons to d6
 andi.b #BUTTON_B_PRESSED,d6 ; test if the start button was pressed
 beq.s ProcessLoadScreenTestStart ; b not pressed, branch
 move.w #$0000,(MEM_MENU_SELECTION) ; prevents a bug on the title screen
 bra.w NewGame ; kick-off new game out of laziness
ProcessLoadScreenTestStart:
 ; process start press
 move.b (MEM_CONTROL_PRESSED),d6 ; copy pressed buttons to d6
 andi.b #BUTTON_START_PRESSED,d6 ; test if the start button was pressed
 beq.s ProcessLoadScreenTestDpad ; start not pressed, branch
 ; make sure this is a valid save game
 move.w (MEM_MENU_SELECTION),d7 ; lookup if this is a valid save slot
 bsr.w SaveGameLookup ; get save title into d6
 cmpi.w #MAX_OBJECTIVE,d6 ; is this > MAX_OBJECTIVE?
 bgt.w ExitProcessProcessLoadScreen ; exit if bad save game
 ;--------------------------------------------------------------------------- 
 ; load game 
 ;--------------------------------------------------------------------------- 
 bsr.w LoadGame ; load the game from SRAM
 ; reload scene
 clr.l d7 ; just being paranoid
 move.w (MEM_ACTIVE_SCENE_ID),d7 ; copy scene ID to load to d7
 mulu.w #$4,d7 ; multiply by 4 to get offset in scene definition table
 lea SceneDefinitionTable,a6 ; point a6 to the scene definition table
 adda.l d7,a6 ; add offset
 move.l (a6),a6 ; have a6 point to the value at a6
 bsr.w LoadScene ; branch to LoadScene subroutine
 bsr.w LoadPlayerSprite ; load the player sprite
 bsr.w FixSprites ; move player sprite and reset sprite links
 bsr.w FadeIn ; fade in to the new scene
 bra.w MainGameLoop ; return to start of game loop 
ProcessLoadScreenTestDpad:
 ; process dpad press
 move.b (MEM_CONTROL_PRESSED),d6 ; copy pressed buttons to d6
 andi.b #BUTTON_UP_PRESSED,d6 ; test if the up button was pressed
 bne.s ProcessLoadScreenDecrementSelector ; up pressed, branch
 move.b (MEM_CONTROL_PRESSED),d6 ; copy pressed buttons to d6
 andi.b #BUTTON_DOWN_PRESSED,d6 ; test if the down button was pressed
 bne.s ProcessLoadScreenIncrementSelector ; down pressed, branch
 move.b (MEM_CONTROL_PRESSED),d6 ; copy pressed buttons to d6
 andi.b #BUTTON_LEFT_PRESSED,d6 ; test if the left button was pressed
 bne.s ProcessLoadScreenDecrementSelector ; left pressed, branch
 move.b (MEM_CONTROL_PRESSED),d6 ; copy pressed buttons to d6
 andi.b #BUTTON_RIGHT_PRESSED,d6 ; test if the right button was pressed
 bne.s ProcessLoadScreenIncrementSelector ; right pressed, branch
 bra.w ProcessLoadScreenMoveSelector
ProcessLoadScreenIncrementSelector:
 cmpi.w #$003,(MEM_MENU_SELECTION)
 bne.s .1
 move.w #$0000,(MEM_MENU_SELECTION)
 bra.w ProcessLoadScreenMoveSelector
.1
 add.w #$0001,(MEM_MENU_SELECTION)
 bra.w ProcessLoadScreenMoveSelector
ProcessLoadScreenDecrementSelector:
 cmpi.w #$0000,(MEM_MENU_SELECTION)
 bne.s .1
 move.w #$003,(MEM_MENU_SELECTION)
 bra.w ProcessLoadScreenMoveSelector
.1
 sub.w #$0001,(MEM_MENU_SELECTION)
 bra.w ProcessLoadScreenMoveSelector
ProcessLoadScreenMoveSelector:
 ; move selector sprite based on menu selection
 move.w (MEM_MENU_SELECTION),d7 ; copy current value to d7
 mulu.w #$0008,d7 ; rows are 8 apart
 add.w #$00F8,d7 ; first row is F8
 move.l #VDP_VRAM_WRITE_SPRITE,d6 ; add to sprite table address
 move.l d6,(VDP_CONTROL) ; set write location in VDP
 move.w d7,(VDP_DATA) ; copy the new y-coordinate
 add.l #$00060000,d6 ; move to x-coordinate
 move.l d6,(VDP_CONTROL) ; set write location in VDP
 move.w #$00C8,(VDP_DATA) ; copy the new x-coordinate
ExitProcessProcessLoadScreen:
 bra.w MainGameLoop

The status screen with the save game box uses the same logic.

This took me a whopping two evenings to code because 99% of it is stuff I've already done (lookup tables, drawing text based on objective, moving a selector through a menu). The only new stuff was the SRAM interaction and that was simple, not really different than copying data from one memory location to another.

What's Next?

I think it would be kind of neat to get to 20 Genesis programming articles but this will be the last one for a while. I've said that at the end of the last 2-3 also. I'm basically out of things to add to Retail Clerk '89. I'd like to release a "final" demo around Black Friday 2019 and then try something new. Between now and then I mostly want to expand the story a little. So until next time, thanks for reading these articles.

Download

Download the latest source code on GitHub




Related