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.b #1,(SRAM_LOCK) ; unlock SRAM

 lea (SRAM_INIT_CHECK),a5 ; point a5 to the location of init data

 clr.l d7 ; just being paranoid

 move.b (a5),d7 ; copy value at SRAM_INIT_CHECK to d7

 cmpi.b #$89,d7 ; test if it matches the expected init value

 beq.s ExitInitSRAM ; branch if it does

 move.b #$89,(a5) ; write init data

 ; 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.w 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

 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:

 ; compute which save slot to use

 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 (a5),d7 ; copy first byte to high word of d7

 addq.l  #2,a5 ; advance read byte

 move.w (a5),d6 ; copy next byte to high word of d6

 lsr.w #8,d6 ; shift d6 to low word

 add.w d7,d6 ; add d7 and d6

 move.b #0,(SRAM_LOCK) ; lock SRAM

 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

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