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. 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:
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:
And here's a save record:
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:
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: Sega Genesis Programming Part 11: 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:
There were two options:
You already know which I picked.
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