@@ -0,0 +1,892 @@
import type { CrystalAssets } from "../rom/assets" ;
import { SAVED_WRAM , SAVE_BLOCK , SRAM , SYMBOLS , NUM_ENGINE_FLAGS , NUM_FRUIT_TREES , ENGINE_FLAGS } from "../rom/profile" ;
import { NUM_TMS_HMS } from "../rom/pokemon" ;
import { MN , PK , type PartyMon } from "../party/pokemon" ;
import type { BoxMon , PokemonStorage } from "../party/storage" ;
import type { Bag , ItemSlot } from "../party/bag" ;
import type { Mail } from "../party/mail" ;
import type { HallOfFameTeam } from "../party/hall-of-fame" ;
import type { DayCareState } from "../party/day-care" ;
import type { RoamMon } from "../battle/battle" ;
import { CHALLENGE , type BattleTowerState } from "../scripts/battle-tower" ;
import { RTC_DAYS , rtcDay , type DayTimer } from "./day-timers" ;
import { DECORATION_SLOTS , type Decorations } from "./decorations" ;
import { DIRECTION_ORDER } from "./types" ;
import { OW } from "./constants" ;
import type { NativeSave } from "./world" ;
/** PRET constants a cartridge save's layout depends on, keyed by their
* names so that `npm run check:reference` verifies each value. */
export const SAV = {
SAVE_CHECK_VALUE_1 : 99 ,
SAVE_CHECK_VALUE_2 : 127 ,
NUM_EVENTS : 0x800 ,
NAME_LENGTH : 11 ,
PLAYER_NAME_LENGTH : 8 ,
BOX_NAME_LENGTH : 9 ,
MON_NAME_LENGTH : 11 ,
MAIL_STRUCT_LENGTH : 0x2f ,
MAIL_LINE_LENGTH : 0x10 ,
MAILBOX_CAPACITY : 10 ,
MONS_PER_BOX : 20 ,
MAX_ITEMS : 20 ,
MAX_BALLS : 12 ,
MAX_KEY_ITEMS : 25 ,
MAX_PC_ITEMS : 50 ,
CONTACT_LIST_SIZE : 10 ,
NUM_OBJECTS : 16 ,
NUM_OBJECT_STRUCTS : 13 ,
DST_F : 7 ,
PLAYERGENDER_FEMALE_F : 0 ,
GAME_TIME_CAPPED : 0 ,
STATUSFLAGS_FLASH_F : 2 ,
STATUSFLAGS_NO_WILD_ENCOUNTERS_F : 5 ,
STATUSFLAGS_HALL_OF_FAME_F : 6 ,
DAYCAREMAN_MONS_COMPATIBLE_F : 5 ,
DAYCAREMAN_ACTIVE_F : 7 ,
DAYCARELADY_ACTIVE_F : 7 ,
RTC_RESET : 0x80 ,
SPRITEMOVEDATA_WANDER : 0x02 ,
SPRITEMOVEDATA_WALK_UP_DOWN : 0x04 ,
SPRITEMOVEDATA_WALK_LEFT_RIGHT : 0x05 ,
SPRITEMOVEDATA_SWIM_WANDER : 0x24 ,
} as const ;
/** Struct layouts from macros/ram.asm and constants/map_object_constants.asm
* (rb fields the reference check cannot read; tests compare them with the
* symbols of the pinned build). */
export const LAYOUT = {
/** box_struct (BOXMON_STRUCT_LENGTH) and party_struct. */
boxMon : 0x20 ,
partyMon : 0x30 ,
/** box: count, species list, the mons, their OTs, their nicknames. */
box : 0x450 ,
/** hall_of_fame (HOF_LENGTH) and hof_mon (HOF_MON_LENGTH). */
hallOfFame : 0x62 ,
hallOfFameMon : 0x10 ,
/** NUM_HOF_TEAMS */
hallOfFameTeams : 30 ,
/** map_object (MAPOBJECT_LENGTH) and its fields. */
mapObject : 0x10 ,
mapObjectStruct : 0 ,
mapObjectY : 2 ,
mapObjectX : 3 ,
/** object_struct (OBJECT_LENGTH) and its fields. */
object : 0x28 ,
objectDirection : 0x08 ,
objectMapX : 0x10 ,
objectMapY : 0x11 ,
/** roam_struct */
roamMon : 7 ,
} as const ;
/** The emulator footers that append the MBC3 clock to a 32 KiB .sav:
* VBA-M, mGBA, BGB and SameBoy write the five clock registers, their
* latched copies (each a 32-bit little-endian word) and a 64-bit Unix
* timestamp of the write; older VBA builds a 32-bit one. The 3DS Virtual
* Console's sav.dat has 16 bytes after the RAM, whose clock format is not
* documented, so they are not read. */
const SRAM_SIZE = 0x8000 ;
const FOOTERS = { rtc48 : 48 , rtc44 : 44 , virtualConsole : 16 } as const ;
/** The two-character strings for the naming screen's <PK> and <MN>. */
const NAME_CHARACTERS : Readonly < Record < number , string > > = {
0x72 : "“" ,
0x73 : "”" ,
0x75 : "…" ,
0x7f : " " ,
0x9a : "(" ,
0x9b : ")" ,
0x9c : ":" ,
0x9d : ";" ,
0x9e : "[" ,
0x9f : "]" ,
0xe0 : "'" ,
0xe1 : PK ,
0xe2 : MN ,
0xe3 : "-" ,
0xe6 : "?" ,
0xe7 : "!" ,
0xe8 : "." ,
0xe9 : "&" ,
0xea : "é" ,
0xef : "♂" ,
0xf0 : "¥" ,
0xf1 : "× " ,
0xf2 : "." ,
0xf3 : "/" ,
0xf4 : "," ,
0xf5 : "♀" ,
} ;
/** A cartridge save the port refuses, with the reason shown to the player. */
export class CartridgeSaveError extends Error { }
/** What a cartridge save becomes, and how. */
export interface CartridgeConversion {
save : NativeSave ;
/** The copy the cartridge itself would load: the main game data, or its
* backup when the main copy fails its checksum (TryLoadSaveFile). */
copy : "main" | "backup" ;
/** Where the game clock came from: the emulator's clock footer, or this
* device (the file holds no usable clock). */
clock : "rtc" | "device" ;
/** What could not be carried over, one sentence each. */
notes : string [ ] ;
}
/** A file whose size a cartridge save can have. */
export function isCartridgeSaveSize ( size : number ) : boolean {
return (
size === SRAM_SIZE ||
Object . values ( FOOTERS ) . some ( ( footer ) = > size === SRAM_SIZE + footer )
) ;
}
/** Reads an English Crystal (revision 1.1 layout) cartridge save and
* converts it to a native save, as Continue would load it: the checked
* copy of the game data, the current box from its own bank, the mail
* backups and the clock. Throws CartridgeSaveError when the file cannot
* become a native save; the result still has to pass GameWorld.restore. */
export function convertCartridgeSave (
assets : CrystalAssets ,
file : Uint8Array ,
now : Date ,
) : CartridgeConversion {
if ( ! isCartridgeSaveSize ( file . length ) )
throw new CartridgeSaveError (
"This file is not a Game Boy Color cartridge save: Crystal's are 32 KiB, sometimes followed by an emulator's clock." ,
) ;
const sram = file . subarray ( 0 , SRAM_SIZE ) ;
const notes : string [ ] = [ ] ;
const copy = pickCopy ( sram ) ;
const base = copy === "main" ? SRAM.sGameData : SRAM.sBackupGameData ;
if ( copy === "backup" )
notes . push (
"The main copy of the game failed its checksum, so its backup was imported, as the cartridge would load it." ,
) ;
/** A byte of the saved block at a work RAM address. */
const at = ( address : number ) = > {
if ( address < SAVE_BLOCK . wPlayerData || address >= SAVE_BLOCK . wPokemonDataEnd )
throw new Error ( ` $ ${ address . toString ( 16 ) } is not in the saved game. ` ) ;
return base + address - SAVE_BLOCK . wPlayerData ;
} ;
const byte = ( address : number ) = > sram [ at ( address ) ] ;
const big = ( offset : number , length : number ) = > {
let value = 0 ;
for ( let i = 0 ; i < length ; i ++ ) value = value * 256 + sram [ offset + i ] ;
return value ;
} ;
const word = ( address : number ) = > big ( at ( address ) , 2 ) ;
const bit = ( address : number , index : number ) = >
Boolean ( byte ( address + ( index >> 3 ) ) & ( 1 << ( index & 7 ) ) ) ;
const name = ( offset : number , length : number , what : string ) = >
decodeName ( sram , offset , length , what ) ;
const party = readParty ( assets , sram , at ) ;
const flags : number [ ] = [ ] ;
for ( let index = 0 ; index < NUM_ENGINE_FLAGS ; index ++ ) {
const entry = SYMBOLS . engineFlags + index * 3 ;
const address = assets . reader . word ( entry ) ,
mask = assets . reader . byte ( entry + 2 ) ;
// Three flags live outside the saved block: ENGINE_PLAYER_IS_FEMALE
// (the port's `avatar`) and two that are not saved and start clear.
if ( address < SAVE_BLOCK . wPlayerData || address >= SAVE_BLOCK . wPokemonDataEnd )
continue ;
if ( sram [ at ( address ) ] & mask ) flags . push ( index ) ;
}
if ( flags . includes ( ENGINE_FLAGS . bugContestTimer ) )
throw new CartridgeSaveError (
"This save was made during the Bug-Catching Contest. Finish the contest in the original game, save again, then import." ,
) ;
const events : number [ ] = [ ] ;
for ( let index = 0 ; index < SAV . NUM_EVENTS ; index ++ )
if ( bit ( SAVE_BLOCK . wEventFlags , index ) ) events . push ( index ) ;
const options = SRAM . sOptions + ( copy === "backup" ? SRAM . sBackupOptions - SRAM.sOptions : 0 ) ;
const clock = convertClock ( sram , file . subarray ( SRAM_SIZE ) , byte , now , notes ) ;
const scenes : Record < string , number > = { } ;
for ( let p = SYMBOLS . mapScenes ; assets . reader . byte ( p ) !== 0xff ; p += 4 ) {
const map = ` ${ assets . reader . byte ( p ) } : ${ assets . reader . byte ( p + 1 ) } ` ;
const value = sram [ at ( assets . reader . word ( p + 2 ) ) ] ;
const owner = assets . sceneOwner ( map ) ;
if ( owner && value ) scenes [ owner ] = value ;
}
const variables : Record < number , number > = { } ;
for ( let i = 0 ; i < 0x100 - OW . SPRITE_VARS ; i ++ ) {
const sprite = byte ( SAVE_BLOCK . wVariableSprites + i ) ;
if ( sprite ) variables [ i ] = sprite ;
}
const phone : number [ ] = [ ] ;
for ( let i = 0 ; i < SAV . CONTACT_LIST_SIZE ; i ++ ) {
const contact = byte ( SAVE_BLOCK . wPhoneList + i ) ;
if ( ! contact ) break ;
phone . push ( contact ) ;
}
const memory : NativeSave [ "memory" ] = { } ;
for ( const [ label , address ] of Object . entries ( SAVED_WRAM ) as [ keyof typeof SAVED_WRAM , number ] [ ] ) {
const value = byte ( address ) ;
if ( value ) memory [ label ] = value ;
}
const map = ( group : number , number : number ) = >
group && group !== 0xff ? ` ${ group } : ${ number } ` : null ;
const warp = ( address : number ) = > {
const number = byte ( address ) ,
target = map ( byte ( address + 1 ) , byte ( address + 2 ) ) ;
return number && target ? { map : target , warp : number } : null ;
} ;
const status = byte ( SAVE_BLOCK . wStatusFlags ) ;
const mapId = map ( byte ( SAVE_BLOCK . wMapGroup ) , byte ( SAVE_BLOCK . wMapGroup + 1 ) ) ;
if ( ! mapId ) throw new CartridgeSaveError ( "The saved position is damaged." ) ;
if ( ! assets . isPorted ( mapId ) ) {
let place = "a map" ;
try {
place = assets . map ( mapId ) . location ;
} catch {
throw new CartridgeSaveError ( "The saved position is damaged." ) ;
}
throw new CartridgeSaveError (
` This save is in ${ place } , which the port cannot open yet. Save elsewhere in Johto in the original game, then import. ` ,
) ;
}
const unownDex : number [ ] = [ ] ;
for ( let i = 0 ; i < 26 ; i ++ ) {
const letter = byte ( SAVE_BLOCK . wUnownDex + i ) ;
if ( ! letter ) break ;
unownDex . push ( letter ) ;
}
const caught : number [ ] = [ ] ,
seen : number [ ] = [ ] ;
for ( let species = 1 ; species <= 251 ; species ++ ) {
if ( bit ( SAVE_BLOCK . wPokedexCaught , species - 1 ) ) caught . push ( species ) ;
if ( bit ( SAVE_BLOCK . wPokedexSeen , species - 1 ) ) seen . push ( species ) ;
}
const fruitTrees : number [ ] = [ ] ;
for ( let tree = 1 ; tree <= NUM_FRUIT_TREES ; tree ++ )
if ( bit ( SAVE_BLOCK . wFruitTreeFlags , tree - 1 ) ) fruitTrees . push ( tree ) ;
const decorations = Object . fromEntries (
DECORATION_SLOTS . map ( ( slot ) = > [ slot , byte ( DECORATION_ADDRESSES [ slot ] ) ] ) ,
) as Decorations ;
// Timers count days of the cartridge's wCurDay; the port counts them on
// its own day number, so each stored day moves by the same amount.
const shift = ( day : number ) = > rtcDay ( day + clock . dayShift ) ;
const timer = ( address : number ) : DayTimer = > ( {
remaining : byte ( address ) ,
day : byte ( address + 1 ) ,
} ) ;
const lucky = timer ( SAVE_BLOCK . wLuckyNumberDayTimer ) ;
const luckyDay = sram [ SRAM . sLuckyNumberDay ] ;
// CheckDailyResetTimer: a countdown of one day. Once it has run out (or
// was never started), the next check clears the daily flags.
const daily = timer ( SAVE_BLOCK . wDailyResetTimer ) ;
const dailyDue = daily . remaining <= rtcDay ( clock . today - daily . day ) ;
const challenge = sram [ SRAM . sBattleTowerChallengeState ] ;
if ( challenge === CHALLENGE . savedAndLeft || challenge === CHALLENGE . inProgress )
throw new CartridgeSaveError (
"This save was made during a Battle Tower challenge. Finish or leave the challenge in the original game, save again, then import." ,
) ;
const battleTower : BattleTowerState = {
// LoadPlayerData turns a received reward back into a won challenge.
challenge : challenge === CHALLENGE . receivedReward ? CHALLENGE.won : challenge ,
beaten : sram [ SRAM . sNrOfBeatenBattleTowerTrainers ] ,
trainers : [ . . . sram . subarray ( SRAM . sBTTrainers , SRAM . sBattleTowerSaveFileFlags ) ] ,
prevMons : [ . . . sram . subarray ( SRAM . sBTMonOfTrainers , SRAM . sBTMonOfTrainers + 3 ) ] ,
prevPrevMons : [ . . . sram . subarray ( SRAM . sBTMonOfTrainers + 3 , SRAM . sBTMonOfTrainers + 6 ) ] ,
savedLevelGroup : sram [ SRAM . sBTChoiceOfLevelGroup ] ,
reward : sram [ SRAM . sBattleTowerReward ] ,
fileFlags : sram [ SRAM . sBattleTowerSaveFileFlags ] ,
// s5_aa8d is in an SRAM bank 32 KiB cartridges do not have.
aa8d : 0 ,
levelGroup : 0 ,
beatenNow : 0 ,
textIndex : 0 ,
} ;
const roamMons : RoamMon [ ] = [ 0 , 1 , 2 ] . map ( ( index ) = > {
const p = SAVE_BLOCK . wRoamMon1 + index * LAYOUT . roamMon ;
return {
species : byte ( p ) ,
level : byte ( p + 1 ) ,
mapId : map ( byte ( p + 2 ) , byte ( p + 3 ) ) ,
hp : byte ( p + 4 ) ,
dvs : word ( p + 5 ) ,
} ;
} ) ;
if ( sram [ SRAM . sMysteryGiftItem ] || sram [ SRAM . sBackupMysteryGiftItem ] )
notes . push (
"A Mystery Gift item waiting at the Goldenrod department store is not carried over." ,
) ;
if ( sram . subarray ( SRAM . sLinkBattleStats , SRAM . sLinkBattleStatsEnd ) . some ( Boolean ) )
notes . push ( "The link battle record is not carried over: the port has no Link yet." ) ;
notes . push ( "The tiles walked count starts from zero: cartridges do not record it." ) ;
const save : NativeSave = {
format : "crystal-native" ,
version : 3 ,
map : mapId ,
x : byte ( SAVE_BLOCK . wXCoord ) ,
y : byte ( SAVE_BLOCK . wYCoord ) ,
direction : direction ( byte ( SAVE_BLOCK . wPlayerDirection ) ) ,
name : name ( at ( SAVE_BLOCK . wPlayerName ) , SAV . NAME_LENGTH , "The player's name" ) ,
avatar :
sram [ SRAM . sCrystalData + SAVE_BLOCK . wPlayerGender - SAVE_BLOCK . wCrystalData ] &
( 1 << SAV . PLAYERGENDER_FEMALE_F )
? "kris"
: "chris" ,
tilesWalked : 0 ,
gameTime : {
hours : word ( SAVE_BLOCK . wGameTimeHours ) ,
minutes : byte ( SAVE_BLOCK . wGameTimeMinutes ) ,
seconds : byte ( SAVE_BLOCK . wGameTimeSeconds ) ,
frames : byte ( SAVE_BLOCK . wGameTimeFrames ) ,
capped : Boolean ( byte ( SAVE_BLOCK . wGameTimeCap ) & ( 1 << SAV . GAME_TIME_CAPPED ) ) ,
} ,
events ,
flags ,
variables ,
scenes ,
phone ,
specialPhoneCall : byte ( SAVE_BLOCK . wSpecialPhoneCallID ) ,
rivalName : name ( at ( SAVE_BLOCK . wRivalName ) , SAV . NAME_LENGTH , "The rival's name" ) ,
clock : {
minuteOffset : clock.minuteOffset ,
weekdayOffset : clock.weekdayOffset ,
dst : Boolean ( byte ( SAVE_BLOCK . wDST ) & ( 1 << SAV . DST_F ) ) ,
} ,
intro : false ,
objects : readObjects ( assets , mapId , byte , events ) ,
playerId : word ( SAVE_BLOCK . wPlayerID ) ,
party ,
bag : readBag ( sram , at ) ,
storage : readStorage ( sram , at , byte , name ) ,
pcItems : readItems ( sram , at ( SAVE_BLOCK . wNumPCItems ) , SAV . MAX_PC_ITEMS , "The PC's items" ) ,
pokedex : { seen , caught } ,
money : big ( at ( SAVE_BLOCK . wMoney ) , 3 ) ,
momsMoney : big ( at ( SAVE_BLOCK . wMomsMoney ) , 3 ) ,
momItem : byte ( SAVE_BLOCK . wWhichMomItem ) ,
momItemTriggerBalance : big ( at ( SAVE_BLOCK . wMomItemTriggerBalance ) , 3 ) ,
decorations ,
fruitTrees ,
dailyReset : dailyDue ? null : clock . nativeDay ,
lastSpawnMap : map ( byte ( SAVE_BLOCK . wLastSpawnMapGroup ) , byte ( SAVE_BLOCK . wLastSpawnMapGroup + 1 ) ) ,
playerState : byte ( SAVE_BLOCK . wPlayerState ) ,
backupWarp : warp ( SAVE_BLOCK . wBackupWarpNumber ) ,
digWarp : warp ( SAVE_BLOCK . wDigWarpNumber ) ,
flash : Boolean ( status & ( 1 << SAV . STATUSFLAGS_FLASH_F ) ) ,
noWildEncounters : Boolean ( status & ( 1 << SAV . STATUSFLAGS_NO_WILD_ENCOUNTERS_F ) ) ,
hallOfFame : Boolean ( status & ( 1 << SAV . STATUSFLAGS_HALL_OF_FAME_F ) ) ,
hallOfFameCount : byte ( SAVE_BLOCK . wHallOfFameCount ) ,
hallOfFameTeams : readHallOfFame ( sram , name ) ,
registeredItem : {
which : byte ( SAVE_BLOCK . wWhichRegisteredItem ) ,
item : byte ( SAVE_BLOCK . wRegisteredItem ) ,
} ,
mailbox : readMailbox ( sram ) ,
repel : byte ( SAVE_BLOCK . wRepelEffect ) ,
stepCount : byte ( SAVE_BLOCK . wStepCount ) ,
poisonStepCount : byte ( SAVE_BLOCK . wPoisonStepCount ) ,
happinessStepCount : byte ( SAVE_BLOCK . wHappinessStepCount ) ,
bikeStep : word ( SAVE_BLOCK . wBikeStep ) ,
coins : word ( SAVE_BLOCK . wCoins ) ,
blueCardBalance : byte ( SAVE_BLOCK . wBlueCardBalance ) ,
tradeFlags : byte ( SAVE_BLOCK . wTradeFlags ) ,
memory ,
unownDex ,
firstUnownSeen : byte ( SAVE_BLOCK . wFirstUnownSeen ) ,
// A countdown never started is all zeros, which the port leaves out.
timers :
lucky.remaining || lucky . day
? { luckyNumber : { remaining : lucky.remaining , day : shift ( lucky . day ) } }
: { } ,
luckyNumber : {
id : big ( SRAM . sLuckyIDNumber , 2 ) ,
day : luckyDay ? shift ( luckyDay - 1 ) + 1 : 0 ,
} ,
magikarpRecord : {
feet : byte ( SAVE_BLOCK . wBestMagikarpLengthFeet ) ,
inches : byte ( SAVE_BLOCK . wBestMagikarpLengthFeet + 1 ) ,
holder : name ( at ( SAVE_BLOCK . wMagikarpRecordHoldersName ) , SAV . NAME_LENGTH , "The Magikarp record holder's name" ) ,
} ,
battleTower ,
roamMons ,
roamMonsMap : map ( byte ( SAVE_BLOCK . wRoamMons_CurMapNumber + 1 ) , byte ( SAVE_BLOCK . wRoamMons_CurMapNumber ) ) ,
options : {
options : sram [ options ] ,
textboxFrame : sram [ options + SAVE_BLOCK . wTextboxFrame - SAVE_BLOCK . wOptions ] ,
printerBrightness : sram [ options + SAVE_BLOCK . wGBPrinterBrightness - SAVE_BLOCK . wOptions ] ,
options2 : sram [ options + SAVE_BLOCK . wOptions2 - SAVE_BLOCK . wOptions ] ,
} ,
dayCare : readDayCare ( sram , at , byte , flags , name ) ,
} ;
return { save , copy , clock : clock.source , notes } ;
}
const DECORATION_ADDRESSES : Record < ( typeof DECORATION_SLOTS ) [ number ] , number > = {
bed : SAVE_BLOCK.wDecoBed ,
carpet : SAVE_BLOCK.wDecoCarpet ,
plant : SAVE_BLOCK.wDecoPlant ,
poster : SAVE_BLOCK.wDecoPoster ,
console : SAVE_BLOCK.wDecoConsole ,
bigDoll : SAVE_BLOCK.wDecoBigDoll ,
left : SAVE_BLOCK.wDecoLeftOrnament ,
right : SAVE_BLOCK.wDecoRightOrnament ,
} ;
/** OBJECT_DIRECTION's facing bits (DOWN, UP, LEFT, RIGHT). */
const direction = ( value : number ) = > DIRECTION_ORDER [ ( value >> 2 ) & 3 ] ;
/** The checksum Checksum computes: the 16-bit sum of the block's bytes. */
function checksum ( sram : Uint8Array , start : number , end : number ) {
let sum = 0 ;
for ( let p = start ; p < end ; p ++ ) sum = ( sum + sram [ p ] ) & 0xffff ;
return sum ;
}
/** TryLoadSaveFile's choice: the main copy if it is marked and its
* checksum (little-endian, as SaveChecksum stores e then d) matches, else
* the backup under the same test. */
function pickCopy ( sram : Uint8Array ) : "main" | "backup" {
const length = SRAM . sGameDataEnd - SRAM . sGameData ;
const intact = ( data : number , sum : number , first : number , second : number ) = >
sram [ first ] === SAV . SAVE_CHECK_VALUE_1 &&
sram [ second ] === SAV . SAVE_CHECK_VALUE_2 &&
checksum ( sram , data , data + length ) === ( sram [ sum ] | ( sram [ sum + 1 ] << 8 ) ) ;
if ( intact ( SRAM . sGameData , SRAM . sChecksum , SRAM . sCheckValue1 , SRAM . sCheckValue2 ) )
return "main" ;
if (
intact (
SRAM . sBackupGameData ,
SRAM . sBackupChecksum ,
SRAM . sBackupCheckValue1 ,
SRAM . sBackupCheckValue2 ,
)
)
return "backup" ;
const marked =
( sram [ SRAM . sCheckValue1 ] === SAV . SAVE_CHECK_VALUE_1 &&
sram [ SRAM . sCheckValue2 ] === SAV . SAVE_CHECK_VALUE_2 ) ||
( sram [ SRAM . sBackupCheckValue1 ] === SAV . SAVE_CHECK_VALUE_1 &&
sram [ SRAM . sBackupCheckValue2 ] === SAV . SAVE_CHECK_VALUE_2 ) ;
throw new CartridgeSaveError (
marked
? "Both copies of this save fail their checksums: the file is damaged."
: "This file holds no saved game of the English Pokémon Crystal." ,
) ;
}
/** A name or nickname: charmap codes up to "@" or `length` bytes. */
function decodeName (
sram : Uint8Array ,
offset : number ,
length : number ,
what : string ,
) : string {
let text = "" ;
for ( let p = offset ; p < offset + length ; p ++ ) {
const code = sram [ p ] ;
if ( code === 0x50 ) return text ;
text += character ( code , what ) ;
}
return text ;
}
function character ( code : number , what : string ) : string {
if ( code >= 0x80 && code <= 0x99 ) return String . fromCharCode ( 65 + code - 0x80 ) ;
if ( code >= 0xa0 && code <= 0xb9 ) return String . fromCharCode ( 97 + code - 0xa0 ) ;
if ( code >= 0xf6 ) return String ( code - 0xf6 ) ;
const value = NAME_CHARACTERS [ code ] ;
if ( value === undefined )
throw new CartridgeSaveError (
` ${ what } uses a character the port cannot show yet (code $ ${ code . toString ( 16 ) . padStart ( 2 , "0" ) } ). ` ,
) ;
return value ;
}
/** A box_struct, without the egg mark or the names. */
function boxFields ( sram : Uint8Array , p : number ) {
const word = ( q : number ) = > ( sram [ q ] << 8 ) | sram [ q + 1 ] ;
return {
species : sram [ p ] ,
item : sram [ p + 1 ] ,
moves : [ sram [ p + 2 ] , sram [ p + 3 ] , sram [ p + 4 ] , sram [ p + 5 ] ] as PartyMon [ "moves" ] ,
otId : word ( p + 6 ) ,
exp : ( sram [ p + 8 ] << 16 ) | ( sram [ p + 9 ] << 8 ) | sram [ p + 10 ] ,
statExp : [ 0 , 1 , 2 , 3 , 4 ] . map ( ( i ) = > word ( p + 11 + i * 2 ) ) as PartyMon [ "statExp" ] ,
dvs : word ( p + 21 ) ,
pp : [ sram [ p + 23 ] , sram [ p + 24 ] , sram [ p + 25 ] , sram [ p + 26 ] ] as PartyMon [ "pp" ] ,
happiness : sram [ p + 27 ] ,
pokerus : sram [ p + 28 ] ,
caughtData : word ( p + 29 ) ,
level : sram [ p + 31 ] ,
} ;
}
/** The stored Pokémon of a species list, structs, OTs and nicknames, as
* the party and the boxes keep them. */
function storedMons (
sram : Uint8Array ,
count : number ,
capacity : number ,
species : number ,
mons : number ,
structLength : number ,
ots : number ,
nicknames : number ,
what : string ,
) : ( BoxMon & { slot : number } ) [ ] {
if ( count > capacity ) throw new CartridgeSaveError ( ` ${ what } is damaged. ` ) ;
return Array . from ( { length : count } , ( _ , slot ) = > {
const listed = sram [ species + slot ] ,
p = mons + slot * structLength ;
const mon : BoxMon = {
. . . boxFields ( sram , p ) ,
ot : decodeName ( sram , ots + slot * SAV . NAME_LENGTH , SAV . NAME_LENGTH , ` ${ what } 's Pokémon ${ slot + 1 } ` ) ,
nickname : decodeName ( sram , nicknames + slot * SAV . MON_NAME_LENGTH , SAV . MON_NAME_LENGTH , ` ${ what } 's Pokémon ${ slot + 1 } ` ) ,
} ;
if ( listed === OW . EGG ) mon . egg = true ;
else if ( listed !== mon . species ) throw new CartridgeSaveError ( ` ${ what } is damaged. ` ) ;
return { . . . mon , slot } ;
} ) ;
}
function readParty (
assets : CrystalAssets ,
sram : Uint8Array ,
at : ( address : number ) = > number ,
) : PartyMon [ ] {
return storedMons (
sram ,
sram [ at ( SAVE_BLOCK . wPartyCount ) ] ,
6 ,
at ( SAVE_BLOCK . wPartySpecies ) ,
at ( SAVE_BLOCK . wPartyMons ) ,
LAYOUT . partyMon ,
at ( SAVE_BLOCK . wPartyMonOTs ) ,
at ( SAVE_BLOCK . wPartyMonNicknames ) ,
"The party" ,
) . map ( ( { slot , . . . box } ) = > {
const p = at ( SAVE_BLOCK . wPartyMons ) + slot * LAYOUT . partyMon ;
const word = ( q : number ) = > ( sram [ q ] << 8 ) | sram [ q + 1 ] ;
const mon : PartyMon = {
. . . box ,
status : sram [ p + 32 ] ,
hp : word ( p + 34 ) ,
stats : [ 0 , 1 , 2 , 3 , 4 , 5 ] . map ( ( i ) = > word ( p + 36 + i * 2 ) ) as PartyMon [ "stats" ] ,
} ;
// sPartyMail, restored from its backup as RestorePartyMonMail does.
if ( assets . pokemon . itemIsMail ( mon . item ) )
mon . mail = readMail ( sram , SRAM . sPartyMailBackup + slot * SAV . MAIL_STRUCT_LENGTH ) ;
return mon ;
} ) ;
}
function readStorage (
sram : Uint8Array ,
at : ( address : number ) = > number ,
byte : ( address : number ) = > number ,
name : ( offset : number , length : number , what : string ) = > string ,
) : PokemonStorage {
const boxes = Array . from ( { length : 14 } , ( _ , index ) = > {
// LoadBox copies wCurBox's bank into sBox, so each numbered box holds
// its own Pokémon; sBox itself may be ahead of the last save.
const p = ( SRAM as Record < string , number > ) [ ` sBox ${ index + 1 } ` ] ;
return storedMons (
sram ,
sram [ p ] ,
SAV . MONS_PER_BOX ,
p + 1 ,
p + 1 + SAV . MONS_PER_BOX + 1 ,
LAYOUT . boxMon ,
p + 1 + SAV . MONS_PER_BOX + 1 + SAV . MONS_PER_BOX * LAYOUT . boxMon ,
p + 1 + SAV . MONS_PER_BOX + 1 + SAV . MONS_PER_BOX * ( LAYOUT . boxMon + SAV . NAME_LENGTH ) ,
` Box ${ index + 1 } ` ,
) . map ( ( { slot : _slot , . . . mon } ) = > mon ) ;
} ) ;
const names = boxes . map ( ( _ , index ) = > {
const boxName = name (
at ( SAVE_BLOCK . wBoxNames ) + index * SAV . BOX_NAME_LENGTH ,
SAV . BOX_NAME_LENGTH ,
` Box ${ index + 1 } 's name ` ,
) ;
// SetDefaultBoxNames' "BOX1" to "BOX14" are the port's null names.
return boxName === ` BOX ${ index + 1 } ` ? null : boxName ;
} ) ;
const current = byte ( SAVE_BLOCK . wCurBox ) ;
// GetBoxAddress resets an out-of-range wCurBox to the first box.
return { current : current < 14 ? current : 0 , names , boxes } ;
}
/** An item list: a count, item and quantity pairs, then -1. */
function readItems (
sram : Uint8Array ,
p : number ,
capacity : number ,
what : string ,
) : ItemSlot [ ] {
const count = sram [ p ] ;
if ( count > capacity || sram [ p + 1 + count * 2 ] !== 0xff )
throw new CartridgeSaveError ( ` ${ what } are damaged. ` ) ;
return Array . from ( { length : count } , ( _ , i ) = > ( {
item : sram [ p + 1 + i * 2 ] ,
quantity : sram [ p + 2 + i * 2 ] ,
} ) ) ;
}
function readBag ( sram : Uint8Array , at : ( address : number ) = > number ) : Bag {
const keys = at ( SAVE_BLOCK . wNumKeyItems ) ,
count = sram [ keys ] ;
if ( count > SAV . MAX_KEY_ITEMS || sram [ keys + 1 + count ] !== 0xff )
throw new CartridgeSaveError ( "The key items are damaged." ) ;
return {
items : readItems ( sram , at ( SAVE_BLOCK . wNumItems ) , SAV . MAX_ITEMS , "The items" ) ,
balls : readItems ( sram , at ( SAVE_BLOCK . wNumBalls ) , SAV . MAX_BALLS , "The balls" ) ,
keyItems : [ . . . sram . subarray ( keys + 1 , keys + 1 + count ) ] ,
tmsHms : [ . . . sram . subarray ( at ( SAVE_BLOCK . wTMsHMs ) , at ( SAVE_BLOCK . wTMsHMs ) + NUM_TMS_HMS ) ] ,
} ;
}
/** A mailmsg: two lines split by <NEXT>, the author, the author's ID, the
* species that held it and the MAIL item. */
function readMail ( sram : Uint8Array , p : number ) : Mail {
let message = "" ;
for ( let q = p ; q < p + 2 * SAV . MAIL_LINE_LENGTH + 1 ; q ++ ) {
const code = sram [ q ] ;
if ( code === 0x50 ) break ;
message += code === 0x4e ? "\n" : character ( code , "A MAIL's message" ) ;
}
const author = p + 2 * SAV . MAIL_LINE_LENGTH + 1 ;
const id = author + SAV . PLAYER_NAME_LENGTH + 2 ;
return {
message ,
author : decodeName ( sram , author , SAV . PLAYER_NAME_LENGTH , "A MAIL's author" ) ,
authorId : ( sram [ id ] << 8 ) | sram [ id + 1 ] ,
species : sram [ id + 2 ] ,
type : sram [ id + 3 ] ,
} ;
}
/** sMailboxes, from their backup as RestorePartyMonMail loads them. */
function readMailbox ( sram : Uint8Array ) : Mail [ ] {
const count = sram [ SRAM . sMailboxCountBackup ] ;
if ( count > SAV . MAILBOX_CAPACITY )
throw new CartridgeSaveError ( "The PC's MAIL is damaged." ) ;
return Array . from ( { length : count } , ( _ , i ) = >
readMail ( sram , SRAM . sMailboxesBackup + i * SAV . MAIL_STRUCT_LENGTH ) ,
) ;
}
/** sHallOfFame, newest first; an entry without a win count ends it. */
function readHallOfFame (
sram : Uint8Array ,
name : ( offset : number , length : number , what : string ) = > string ,
) : HallOfFameTeam [ ] {
const teams : HallOfFameTeam [ ] = [ ] ;
for ( let index = 0 ; index < LAYOUT . hallOfFameTeams ; index ++ ) {
const p = SRAM . sHallOfFame + index * LAYOUT . hallOfFame ,
win = sram [ p ] ;
if ( ! win ) break ;
const mons = [ ] ;
for ( let slot = 0 ; slot < 6 ; slot ++ ) {
const q = p + 1 + slot * LAYOUT . hallOfFameMon ;
if ( sram [ q ] === 0xff || ! sram [ q ] ) break ;
mons . push ( {
species : sram [ q ] ,
otId : ( sram [ q + 1 ] << 8 ) | sram [ q + 2 ] ,
dvs : ( sram [ q + 3 ] << 8 ) | sram [ q + 4 ] ,
level : sram [ q + 5 ] ,
nickname : name ( q + 6 , SAV . MON_NAME_LENGTH - 1 , "A Hall of Fame Pokémon" ) ,
} ) ;
}
teams . push ( { win , mons } ) ;
}
return teams ;
}
function readDayCare (
sram : Uint8Array ,
at : ( address : number ) = > number ,
byte : ( address : number ) = > number ,
flags : number [ ] ,
name : ( offset : number , length : number , what : string ) = > string ,
) : DayCareState {
const mon = ( struct : number , nickname : number , ot : number , what : string ) : BoxMon = > ( {
. . . boxFields ( sram , at ( struct ) ) ,
nickname : name ( at ( nickname ) , SAV . MON_NAME_LENGTH , what ) ,
ot : name ( at ( ot ) , SAV . NAME_LENGTH , what ) ,
} ) ;
const man = byte ( SAVE_BLOCK . wDayCareMan ) ,
lady = byte ( SAVE_BLOCK . wDayCareLady ) ;
return {
manActive : Boolean ( man & ( 1 << SAV . DAYCAREMAN_ACTIVE_F ) ) ,
ladyActive : Boolean ( lady & ( 1 << SAV . DAYCARELADY_ACTIVE_F ) ) ,
compatible : Boolean ( man & ( 1 << SAV . DAYCAREMAN_MONS_COMPATIBLE_F ) ) ,
stepsToEgg : byte ( SAVE_BLOCK . wStepsToEgg ) ,
motherOrNonDitto : byte ( SAVE_BLOCK . wBreedMotherOrNonDitto ) ,
mon1 : flags.includes ( ENGINE_FLAGS . dayCareManHasMon )
? mon ( SAVE_BLOCK . wBreedMon1 , SAVE_BLOCK . wBreedMon1Nickname , SAVE_BLOCK . wBreedMon1OT , "The Day-Care man's Pokémon" )
: null ,
mon2 : flags.includes ( ENGINE_FLAGS . dayCareLadyHasMon )
? mon ( SAVE_BLOCK . wBreedMon2 , SAVE_BLOCK . wBreedMon2Nickname , SAVE_BLOCK . wBreedMon2OT , "The Day-Care lady's Pokémon" )
: null ,
egg : byte ( SAVE_BLOCK . wEggMon )
? {
. . . mon ( SAVE_BLOCK . wEggMon , SAVE_BLOCK . wEggMonNickname , SAVE_BLOCK . wEggMonOT , "The Day-Care's Egg" ) ,
egg : true ,
}
: null ,
} ;
}
/** The current map's objects as Continue finds them (MapSetupScript_Continue
* keeps wMapObjects, wObjectMasks and the object structs): objects a script
* moved or placed elsewhere, and objects whose mask differs from what
* their event flag says. Wandering objects stray from home on their own
* and keep the map's position, as the port saves them. The masks of
* objects with time windows follow the clock again on import. */
function readObjects (
assets : CrystalAssets ,
mapId : string ,
byte : ( address : number ) = > number ,
events : number [ ] ,
) : NativeSave [ "objects" ] {
const set = new Set ( events ) ;
const wandering = new Set < number > ( [
SAV . SPRITEMOVEDATA_WANDER ,
SAV . SPRITEMOVEDATA_WALK_UP_DOWN ,
SAV . SPRITEMOVEDATA_WALK_LEFT_RIGHT ,
SAV . SPRITEMOVEDATA_SWIM_WANDER ,
] ) ;
const objects : NativeSave [ "objects" ] = [ ] ;
for ( const object of assets . map ( mapId ) . objects ) {
// Script object IDs count from 2; wMapObjects[1] is the first object.
const index = object . id - 1 ;
if ( index >= SAV . NUM_OBJECTS ) break ;
const p = SAVE_BLOCK . wMapObjects + index * LAYOUT . mapObject ;
let x = byte ( p + LAYOUT . mapObjectX ) - 4 ,
y = byte ( p + LAYOUT . mapObjectY ) - 4 ;
let facing : ( typeof DIRECTION_ORDER ) [ number ] | undefined ;
const struct = byte ( p + LAYOUT . mapObjectStruct ) ;
if ( struct > 0 && struct < SAV . NUM_OBJECT_STRUCTS ) {
const q = SAVE_BLOCK . wPlayerStruct + struct * LAYOUT . object ;
if ( ! wandering . has ( object . movement ) || x !== object . x || y !== object . y ) {
x = byte ( q + LAYOUT . objectMapX ) - 4 ;
y = byte ( q + LAYOUT . objectMapY ) - 4 ;
}
facing = direction ( byte ( q + LAYOUT . objectDirection ) ) ;
}
const moved = x !== object . x || y !== object . y ;
const always =
( object . hour1 === 0xff && object . hour2 === 0xff ) ||
( object . hour1 !== 0xff && object . hour1 === object . hour2 ) ;
const masked = byte ( SAVE_BLOCK . wObjectMasks + index ) === 0xff ,
flagged = object . eventFlag !== 0xffff && set . has ( object . eventFlag ) ;
const hidden = always && masked && ! flagged ,
shown = always && ! masked && flagged ;
if ( ! moved && ! hidden && ! shown ) continue ;
objects . push ( {
id : object.id ,
x ,
y ,
// An object without a struct faces as LoadMapObjects spawns it.
direction : facing ? ? DIRECTION_ORDER [ object . movement - 6 ] ? ? "down" ,
. . . ( hidden && { hidden : true as const } ) ,
. . . ( shown && { shown : true as const } ) ,
} ) ;
}
return objects ;
}
/** The game clock now, from the emulator's clock footer when the file has
* one, and how the port's clock offsets reproduce it. */
function convertClock (
sram : Uint8Array ,
footer : Uint8Array ,
byte : ( address : number ) = > number ,
now : Date ,
notes : string [ ] ,
) {
const start = [ 0 , 1 , 2 , 3 ] . map ( ( i ) = > byte ( SAVE_BLOCK . wStartDay + i ) ) ;
let today = byte ( SAVE_BLOCK . wCurDay ) ,
minutes : number | undefined ;
let source : "rtc" | "device" = "device" ;
const rtc = readFooter ( footer ) ;
if ( rtc && ! ( sram [ SRAM . sRTCStatusFlags ] & SAV . RTC_RESET ) ) {
// The registers when the file was written, plus the time since,
// unless the clock was halted.
let seconds =
rtc . seconds + rtc . minutes * 60 + rtc . hours * 3600 + rtc . days * 86400 ;
if ( ! rtc . halted )
seconds += Math . max ( 0 , Math . floor ( now . getTime ( ) / 1000 ) - rtc . timestamp ) ;
// The day counter has nine bits (with a carry FixDays ignores), which
// FixDays then takes modulo 140.
const days = Math . floor ( seconds / 86400 ) % 512 ;
const clock = seconds % 86400 ;
// FixTime adds wStartDay, wStartHour, wStartMinute and wStartSecond.
const second = ( clock % 60 ) + start [ 3 ] ,
minute = ( Math . floor ( clock / 60 ) % 60 ) + start [ 2 ] + Math . floor ( second / 60 ) ,
hour = Math . floor ( clock / 3600 ) + start [ 1 ] + Math . floor ( minute / 60 ) ;
today = ( days % RTC_DAYS ) + start [ 0 ] + Math . floor ( hour / 24 ) ;
minutes = ( hour % 24 ) * 60 + ( minute % 60 ) ;
source = "rtc" ;
} else if ( rtc ) {
notes . push (
"The cartridge's clock had been reset, so the game clock follows this device and its day continues from the save." ,
) ;
} else {
notes . push (
"The file has no clock data, so the game clock follows this device and its day continues from the save." ,
) ;
}
// The port's clock is this device's plus a minute offset (within 12
// hours) and a weekday offset; GetWeekday is wCurDay modulo 7.
const device = now . getHours ( ) * 60 + now . getMinutes ( ) ;
let minuteOffset = minutes === undefined ? 0 : ( minutes - device + 1440 ) % 1440 ;
if ( minuteOffset >= 720 ) minuteOffset -= 1440 ;
const game = new Date ( now . getTime ( ) + minuteOffset * 60 _000 ) ;
const weekdayOffset = ( ( ( today % 7 ) - game . getDay ( ) ) % 7 + 7 ) % 7 ;
const nativeDay =
Math . floor ( Date . UTC ( game . getFullYear ( ) , game . getMonth ( ) , game . getDate ( ) ) / 86 _400_000 ) +
weekdayOffset ;
return {
source ,
minuteOffset ,
weekdayOffset ,
nativeDay ,
today : rtcDay ( today ) ,
/** Adds to a wCurDay to give the port's RTC day (see GameWorld). */
dayShift : rtcDay ( nativeDay ) - rtcDay ( today ) ,
} ;
}
/** The MBC3 registers of an emulator footer, or undefined without one.
* Throws when the footer's values are impossible. */
function readFooter ( footer : Uint8Array ) {
if ( footer . length !== FOOTERS . rtc48 && footer . length !== FOOTERS . rtc44 ) return undefined ;
const view = new DataView ( footer . buffer , footer . byteOffset , footer . byteLength ) ;
const [ seconds , minutes , hours , low , high ] = [ 0 , 1 , 2 , 3 , 4 ] . map ( ( i ) = >
view . getUint32 ( i * 4 , true ) ,
) ;
const timestamp =
footer . length === FOOTERS . rtc48
? Number ( view . getBigUint64 ( 40 , true ) )
: view . getUint32 ( 40 , true ) ;
if (
seconds > 59 ||
minutes > 59 ||
hours > 23 ||
low > 0xff ||
high > 0xff ||
! Number . isSafeInteger ( timestamp )
)
throw new CartridgeSaveError ( "The file's clock data is damaged." ) ;
return {
seconds ,
minutes ,
hours ,
days : low | ( ( high & 1 ) << 8 ) ,
halted : Boolean ( high & 0x40 ) ,
timestamp ,
} ;
}