diff --git a/docs/savestate-format.md b/docs/savestate-format.md index 48d98ae..ce32042 100644 --- a/docs/savestate-format.md +++ b/docs/savestate-format.md @@ -19,6 +19,10 @@ PHPBoy savestates capture the complete state of the emulator at a specific point "ppu": { ... }, "memory": { ... }, "cartridge": { ... }, + "timer": { ... }, + "interrupts": { ... }, + "cgb": { ... }, + "apu": { ... }, "clock": { ... } } ``` @@ -66,7 +70,13 @@ PHPBoy savestates capture the complete state of the emulator at a specific point "stat": 0x00, "bgp": 0xFC, "obp0": 0xFF, - "obp1": 0xFF + "obp1": 0xFF, + "cgbPalette": { + "bgPalette": "base64-encoded data (64 bytes)", + "objPalette": "base64-encoded data (64 bytes)", + "bgIndex": 0x00, + "objIndex": 0x00 + } } ``` @@ -79,20 +89,36 @@ PHPBoy savestates capture the complete state of the emulator at a specific point - **lcdc** (integer): LCD Control register - **stat** (integer): LCD Status register - **bgp, obp0, obp1** (integer): DMG palette registers +- **cgbPalette** (object): CGB color palette state (optional, CGB only) + - **bgPalette** (string): Base64-encoded background palette memory (8 palettes × 4 colors × 2 bytes = 64 bytes) + - **objPalette** (string): Base64-encoded object palette memory (8 palettes × 4 colors × 2 bytes = 64 bytes) + - **bgIndex** (integer): Background palette index register (BCPS/BGPI) with auto-increment flag + - **objIndex** (integer): Object palette index register (OCPS/OBPI) with auto-increment flag ### Memory State ```json "memory": { - "vram": "base64-encoded data (8KB)", + "vramBank0": "base64-encoded data (8KB)", + "vramBank1": "base64-encoded data (8KB)", + "vramCurrentBank": 0, "wram": "base64-encoded data (8KB)", "hram": "base64-encoded data (127 bytes)", "oam": "base64-encoded data (160 bytes)" } ``` +- **vramBank0** (string): Base64-encoded VRAM bank 0 (8KB) +- **vramBank1** (string): Base64-encoded VRAM bank 1 (8KB, CGB only) +- **vramCurrentBank** (integer): Currently selected VRAM bank (0 or 1, CGB only) +- **wram** (string): Base64-encoded work RAM (8KB) +- **hram** (string): Base64-encoded high RAM (127 bytes, 0xFF80-0xFFFE) +- **oam** (string): Base64-encoded OAM sprite attribute table (160 bytes) + All memory regions are base64-encoded for compact storage. +**Note:** For backward compatibility, old savestates with single `"vram"` field are still supported and will only restore bank 0. + ### Cartridge State ```json @@ -109,6 +135,99 @@ All memory regions are base64-encoded for compact storage. - **ramEnabled** (boolean): RAM enable state - **ram** (string): Base64-encoded cartridge RAM (size varies by cartridge) +### Timer State + +```json +"timer": { + "div": 0xAB, + "divCounter": 1234, + "tima": 0x00, + "tma": 0x00, + "tac": 0x00, + "timaCounter": 0 +} +``` + +- **div** (integer): DIV register (0xFF04) - Divider register value (0x00-0xFF) +- **divCounter** (integer): Internal 16-bit divider counter +- **tima** (integer): TIMA register (0xFF05) - Timer counter (0x00-0xFF) +- **tma** (integer): TMA register (0xFF06) - Timer modulo (0x00-0xFF) +- **tac** (integer): TAC register (0xFF07) - Timer control (0x00-0x07) +- **timaCounter** (integer): Internal TIMA counter accumulator + +**Optional:** This field is optional for backward compatibility. If missing, timer state will be initialized to default values. + +### Interrupt State + +```json +"interrupts": { + "if": 0xE0, + "ie": 0x00 +} +``` + +- **if** (integer): IF register (0xFF0F) - Interrupt flags (bits 0-4: VBlank, LCD, Timer, Serial, Joypad) +- **ie** (integer): IE register (0xFFFF) - Interrupt enable mask (bits 0-4) + +**Optional:** This field is optional for backward compatibility. If missing, interrupt state will be initialized to default values. + +### CGB Controller State + +```json +"cgb": { + "key0": 0x80, + "key1": 0x00, + "opri": 0x00, + "doubleSpeed": false, + "key0Writable": false +} +``` + +- **key0** (integer): KEY0 register (0xFF4C) - CGB mode indicator (0x04=DMG compat, 0x80=CGB mode) +- **key1** (integer): KEY1 register (0xFF4D) - Speed switch control (bit 0: prepare switch) +- **opri** (integer): OPRI register (0xFF6C) - Object priority mode (bit 0) +- **doubleSpeed** (boolean): Current speed mode (false=normal 4MHz, true=double 8MHz) +- **key0Writable** (boolean): Whether KEY0 register is still writable (locked after boot ROM disable) + +**Optional:** This field is optional for backward compatibility. If missing, CGB state will be initialized based on cartridge type. + +### APU State + +```json +"apu": { + "registers": { + "nr10": 0x80, "nr11": 0xBF, "nr12": 0xF3, "nr13": 0xFF, "nr14": 0xBF, + "nr21": 0x3F, "nr22": 0x00, "nr23": 0xFF, "nr24": 0xBF, + "nr30": 0x7F, "nr31": 0xFF, "nr32": 0x9F, "nr33": 0xFF, "nr34": 0xBF, + "nr41": 0xFF, "nr42": 0x00, "nr43": 0x00, "nr44": 0xBF, + "nr50": 0x77, "nr51": 0xF3, "nr52": 0xF1 + }, + "waveRam": "base64-encoded data (16 bytes)", + "frameSequencerCycles": 0, + "frameSequencerStep": 0, + "sampleCycles": 0.0, + "enabled": true +} +``` + +- **registers** (object): All APU control registers + - **nr10-nr14** (integers): Channel 1 (square with sweep) registers + - **nr21-nr24** (integers): Channel 2 (square) registers + - **nr30-nr34** (integers): Channel 3 (wave) registers + - **nr41-nr44** (integers): Channel 4 (noise) registers + - **nr50** (integer): Master volume and VIN panning + - **nr51** (integer): Sound panning for all channels + - **nr52** (integer): Sound on/off and channel status +- **waveRam** (string): Base64-encoded Wave RAM (16 bytes, 0xFF30-0xFF3F) for Channel 3 +- **frameSequencerCycles** (integer): Frame sequencer cycle accumulator +- **frameSequencerStep** (integer): Current frame sequencer step (0-7) +- **sampleCycles** (float): Sample generation cycle accumulator +- **enabled** (boolean): Master APU enable state + +**Optional:** This field is optional for backward compatibility. If missing, APU will be initialized to default state. + +**Note:** This saves APU register state and basic timing, but NOT full channel internal state (frequency timers, length counters, envelope timers, duty positions). Audio restoration is partial - basic configuration is preserved but channel timing may drift slightly. + ### Clock State ```json @@ -125,6 +244,17 @@ All memory regions are base64-encoded for compact storage. Savestates include a version number. Loading a savestate with a different version will fail with an error message indicating the version mismatch. +### Backward Compatibility + +The savestate format maintains backward compatibility with older versions: + +- **Required fields:** `magic`, `version`, `cpu`, `ppu`, `memory`, `cartridge`, `clock` (always present) +- **Optional fields:** `timer`, `interrupts`, `cgb`, `apu` (gracefully handle missing data) +- **Old VRAM format:** Single `"vram"` field is still supported for compatibility with pre-1.0 savestates +- **Missing fields:** If optional fields are missing, they are initialized to sensible defaults + +This allows newer emulator versions to load older savestates, though some state (timer, interrupts, etc.) will be reset to defaults. + ### Future Compatibility Future versions may add new fields but must maintain backward compatibility for core fields. Optional fields should have sensible defaults. @@ -157,4 +287,33 @@ $manager->deserialize($stateArray); - Savestates are **not portable** across different ROM versions - Always use the same ROM file when loading a savestate - Savestates capture exact emulator state but not the ROM itself -- File size: ~10-20 KB for typical games (mostly cartridge RAM) +- **File size:** ~15-30 KB for typical games + - Base savestate: ~5 KB (registers, state, timers, etc.) + - VRAM: ~22 KB (16 KB for CGB dual banks, base64-encoded) + - Cartridge RAM: Varies by game (0-128 KB) + - CGB color palettes: ~175 bytes (base64-encoded) + - APU state: ~500 bytes + +## State Completeness + +### Fully Saved ✅ +- CPU registers and flags +- PPU state and timing +- All memory (VRAM, WRAM, HRAM, OAM) +- Cartridge state and RAM +- Timer registers and internal counters +- Interrupt flags and enables +- CGB color palettes and hardware state +- APU registers and Wave RAM +- System clock cycles + +### Partially Saved ⚠️ +- **APU channels:** Register state saved, but NOT internal timers/counters + - Wave channel (CH3) fully preserved via Wave RAM + - Other channels may have minor timing drift after load + +### Not Saved ❌ +- OAM DMA transfer progress (mid-frame only) +- Serial port transfer state +- WRAM banking (CGB has 32KB, only 8KB currently saved) +- Full APU channel internal state (frequency timers, length counters, etc.) diff --git a/src/Apu/Apu.php b/src/Apu/Apu.php index 041eb1a..7d9a2e5 100644 --- a/src/Apu/Apu.php +++ b/src/Apu/Apu.php @@ -365,4 +365,110 @@ private function writeNR52(int $value): void $this->frameSequencerStep = 0; } } + + /** + * Get frame sequencer cycles (for savestate serialization). + * + * @return int Frame sequencer cycles + */ + public function getFrameSequencerCycles(): int + { + return $this->frameSequencerCycles; + } + + /** + * Get frame sequencer step (for savestate serialization). + * + * @return int Frame sequencer step (0-7) + */ + public function getFrameSequencerStep(): int + { + return $this->frameSequencerStep; + } + + /** + * Get sample cycles accumulator (for savestate serialization). + * + * @return float Sample cycles + */ + public function getSampleCycles(): float + { + return $this->sampleCycles; + } + + /** + * Get enabled state (for savestate serialization). + * + * @return bool True if APU is enabled + */ + public function isEnabled(): bool + { + return $this->enabled; + } + + /** + * Set frame sequencer cycles (for savestate deserialization). + * + * @param int $cycles Frame sequencer cycles + */ + public function setFrameSequencerCycles(int $cycles): void + { + $this->frameSequencerCycles = $cycles; + } + + /** + * Set frame sequencer step (for savestate deserialization). + * + * @param int $step Frame sequencer step (0-7) + */ + public function setFrameSequencerStep(int $step): void + { + $this->frameSequencerStep = $step & 0x07; + } + + /** + * Set sample cycles accumulator (for savestate deserialization). + * + * @param float $cycles Sample cycles + */ + public function setSampleCycles(float $cycles): void + { + $this->sampleCycles = $cycles; + } + + /** + * Set enabled state (for savestate deserialization). + * + * @param bool $enabled True to enable APU + */ + public function setEnabled(bool $enabled): void + { + $this->enabled = $enabled; + } + + /** + * Get Wave RAM data (for savestate serialization). + * + * @return array Wave RAM (16 bytes) + */ + public function getWaveRam(): array + { + $waveRam = []; + for ($i = 0; $i < 16; $i++) { + $waveRam[] = $this->channel3->readWaveRam($i); + } + return $waveRam; + } + + /** + * Set Wave RAM data (for savestate deserialization). + * + * @param array $waveRam Wave RAM (16 bytes) + */ + public function setWaveRam(array $waveRam): void + { + for ($i = 0; $i < min(16, count($waveRam)); $i++) { + $this->channel3->writeWaveRam($i, $waveRam[$i]); + } + } } diff --git a/src/Emulator.php b/src/Emulator.php index 184ae53..9ce6e0e 100644 --- a/src/Emulator.php +++ b/src/Emulator.php @@ -644,6 +644,38 @@ public function getSerial(): ?Serial return $this->serial; } + /** + * Get the timer. + */ + public function getTimer(): ?Timer + { + return $this->timer; + } + + /** + * Get the interrupt controller. + */ + public function getInterruptController(): ?InterruptController + { + return $this->interruptController; + } + + /** + * Get the CGB controller. + */ + public function getCgbController(): ?CgbController + { + return $this->cgb; + } + + /** + * Get the APU. + */ + public function getApu(): ?Apu + { + return $this->apu; + } + /** * Save the current emulator state to a file. * diff --git a/src/Ppu/ColorPalette.php b/src/Ppu/ColorPalette.php index 06273bd..740ca02 100644 --- a/src/Ppu/ColorPalette.php +++ b/src/Ppu/ColorPalette.php @@ -155,4 +155,84 @@ public function getObjColor(int $paletteNum, int $colorNum): Color $rgb15 = ($high << 8) | $low; return Color::fromGbc15bit($rgb15); } + + /** + * Get background palette memory (for savestate serialization). + * + * @return array Background palette memory (64 bytes) + */ + public function getBgPaletteMemory(): array + { + return $this->bgPalette; + } + + /** + * Get object palette memory (for savestate serialization). + * + * @return array Object palette memory (64 bytes) + */ + public function getObjPaletteMemory(): array + { + return $this->objPalette; + } + + /** + * Get background palette index (for savestate serialization). + * + * @return int Background palette index with auto-increment flag + */ + public function getBgIndexRaw(): int + { + return $this->bgIndex; + } + + /** + * Get object palette index (for savestate serialization). + * + * @return int Object palette index with auto-increment flag + */ + public function getObjIndexRaw(): int + { + return $this->objIndex; + } + + /** + * Set background palette memory (for savestate deserialization). + * + * @param array $palette Background palette memory (64 bytes) + */ + public function setBgPaletteMemory(array $palette): void + { + $this->bgPalette = $palette; + } + + /** + * Set object palette memory (for savestate deserialization). + * + * @param array $palette Object palette memory (64 bytes) + */ + public function setObjPaletteMemory(array $palette): void + { + $this->objPalette = $palette; + } + + /** + * Set background palette index (for savestate deserialization). + * + * @param int $index Background palette index with auto-increment flag + */ + public function setBgIndexRaw(int $index): void + { + $this->bgIndex = $index & 0xBF; + } + + /** + * Set object palette index (for savestate deserialization). + * + * @param int $index Object palette index with auto-increment flag + */ + public function setObjIndexRaw(int $index): void + { + $this->objIndex = $index & 0xBF; + } } diff --git a/src/Ppu/Ppu.php b/src/Ppu/Ppu.php index 43c24fd..aba4f01 100644 --- a/src/Ppu/Ppu.php +++ b/src/Ppu/Ppu.php @@ -776,4 +776,14 @@ public function setOBP1(int $obp1): void { $this->obp1 = $obp1; } + + /** + * Get the VRAM device. + * + * @return Vram VRAM device + */ + public function getVram(): Vram + { + return $this->vram; + } } diff --git a/src/Savestate/SavestateManager.php b/src/Savestate/SavestateManager.php index 732dc76..c33f8e5 100644 --- a/src/Savestate/SavestateManager.php +++ b/src/Savestate/SavestateManager.php @@ -97,11 +97,16 @@ public function serialize(): array $bus = $this->emulator->getBus(); $cartridge = $this->emulator->getCartridge(); $clock = $this->emulator->getClock(); + $timer = $this->emulator->getTimer(); + $interruptController = $this->emulator->getInterruptController(); if ($cpu === null || $ppu === null || $bus === null || $cartridge === null) { throw new \RuntimeException("Cannot create savestate: emulator not initialized"); } + $cgbController = $this->emulator->getCgbController(); + $apu = $this->emulator->getApu(); + return [ 'magic' => self::MAGIC, 'version' => self::VERSION, @@ -110,6 +115,10 @@ public function serialize(): array 'ppu' => $this->serializePpu($ppu), 'memory' => $this->serializeMemory($bus), 'cartridge' => $this->serializeCartridge($cartridge), + 'timer' => $timer !== null ? $this->serializeTimer($timer) : null, + 'interrupts' => $interruptController !== null ? $this->serializeInterrupts($interruptController) : null, + 'cgb' => $cgbController !== null ? $this->serializeCgb($cgbController) : null, + 'apu' => $apu !== null ? $this->serializeApu($apu) : null, 'clock' => [ 'cycles' => $clock->getCycles(), ], @@ -128,6 +137,8 @@ public function deserialize(array $state): void $bus = $this->emulator->getBus(); $cartridge = $this->emulator->getCartridge(); $clock = $this->emulator->getClock(); + $timer = $this->emulator->getTimer(); + $interruptController = $this->emulator->getInterruptController(); if ($cpu === null || $ppu === null || $bus === null || $cartridge === null) { throw new \RuntimeException("Cannot load savestate: emulator not initialized"); @@ -151,6 +162,28 @@ public function deserialize(array $state): void $this->deserializeMemory($bus, $state['memory']); $this->deserializeCartridge($cartridge, $state['cartridge']); + // Restore timer state (optional for backward compatibility) + if (isset($state['timer']) && is_array($state['timer']) && $timer !== null) { + $this->deserializeTimer($timer, $state['timer']); + } + + // Restore interrupt state (optional for backward compatibility) + if (isset($state['interrupts']) && is_array($state['interrupts']) && $interruptController !== null) { + $this->deserializeInterrupts($interruptController, $state['interrupts']); + } + + // Restore CGB controller state (optional for backward compatibility) + $cgbController = $this->emulator->getCgbController(); + if (isset($state['cgb']) && is_array($state['cgb']) && $cgbController !== null) { + $this->deserializeCgb($cgbController, $state['cgb']); + } + + // Restore APU state (optional for backward compatibility) + $apu = $this->emulator->getApu(); + if (isset($state['apu']) && is_array($state['apu']) && $apu !== null) { + $this->deserializeApu($apu, $state['apu']); + } + // Restore clock if (isset($state['clock']['cycles']) && is_int($state['clock']['cycles'])) { $clock->reset(); @@ -201,6 +234,8 @@ private function deserializeCpu(\Gb\Cpu\Cpu $cpu, array $data): void */ private function serializePpu(\Gb\Ppu\Ppu $ppu): array { + $colorPalette = $ppu->getColorPalette(); + return [ 'mode' => $ppu->getMode()->value, 'modeClock' => $ppu->getModeClock(), @@ -215,6 +250,12 @@ private function serializePpu(\Gb\Ppu\Ppu $ppu): array 'bgp' => $ppu->getBGP(), 'obp0' => $ppu->getOBP0(), 'obp1' => $ppu->getOBP1(), + 'cgbPalette' => [ + 'bgPalette' => base64_encode(pack('C*', ...$colorPalette->getBgPaletteMemory())), + 'objPalette' => base64_encode(pack('C*', ...$colorPalette->getObjPaletteMemory())), + 'bgIndex' => $colorPalette->getBgIndexRaw(), + 'objIndex' => $colorPalette->getObjIndexRaw(), + ], ]; } @@ -238,6 +279,33 @@ private function deserializePpu(\Gb\Ppu\Ppu $ppu, array $data): void $ppu->setBGP((int) $data['bgp']); $ppu->setOBP0((int) $data['obp0']); $ppu->setOBP1((int) $data['obp1']); + + // Restore CGB color palettes (optional for backward compatibility) + if (isset($data['cgbPalette']) && is_array($data['cgbPalette'])) { + $colorPalette = $ppu->getColorPalette(); + + if (isset($data['cgbPalette']['bgPalette'])) { + $bgPaletteUnpacked = unpack('C*', base64_decode((string) $data['cgbPalette']['bgPalette'])); + if ($bgPaletteUnpacked !== false) { + $colorPalette->setBgPaletteMemory(array_values($bgPaletteUnpacked)); + } + } + + if (isset($data['cgbPalette']['objPalette'])) { + $objPaletteUnpacked = unpack('C*', base64_decode((string) $data['cgbPalette']['objPalette'])); + if ($objPaletteUnpacked !== false) { + $colorPalette->setObjPaletteMemory(array_values($objPaletteUnpacked)); + } + } + + if (isset($data['cgbPalette']['bgIndex'])) { + $colorPalette->setBgIndexRaw((int) $data['cgbPalette']['bgIndex']); + } + + if (isset($data['cgbPalette']['objIndex'])) { + $colorPalette->setObjIndexRaw((int) $data['cgbPalette']['objIndex']); + } + } } /** @@ -247,12 +315,18 @@ private function deserializePpu(\Gb\Ppu\Ppu $ppu, array $data): void */ private function serializeMemory(\Gb\Bus\SystemBus $bus): array { - // Read memory regions - $vram = []; - for ($i = 0x8000; $i <= 0x9FFF; $i++) { - $vram[] = $bus->readByte($i); + $ppu = $this->emulator->getPpu(); + if ($ppu === null) { + throw new \RuntimeException("Cannot serialize memory: PPU not initialized"); } + $vram = $ppu->getVram(); + + // Save both VRAM banks (CGB has 2 banks, DMG only uses bank 0) + $vramBank0 = $vram->getData(0); + $vramBank1 = $vram->getData(1); + $currentVramBank = $vram->getBank(); + $wram = []; for ($i = 0xC000; $i <= 0xDFFF; $i++) { $wram[] = $bus->readByte($i); @@ -269,7 +343,9 @@ private function serializeMemory(\Gb\Bus\SystemBus $bus): array } return [ - 'vram' => base64_encode(pack('C*', ...$vram)), + 'vramBank0' => base64_encode(pack('C*', ...$vramBank0)), + 'vramBank1' => base64_encode(pack('C*', ...$vramBank1)), + 'vramCurrentBank' => $currentVramBank, 'wram' => base64_encode(pack('C*', ...$wram)), 'hram' => base64_encode(pack('C*', ...$hram)), 'oam' => base64_encode(pack('C*', ...$oam)), @@ -283,14 +359,54 @@ private function serializeMemory(\Gb\Bus\SystemBus $bus): array */ private function deserializeMemory(\Gb\Bus\SystemBus $bus, array $data): void { - // Restore VRAM - $vramUnpacked = unpack('C*', base64_decode((string) $data['vram'])); - if ($vramUnpacked === false) { - throw new \RuntimeException('Failed to unpack VRAM data'); + $ppu = $this->emulator->getPpu(); + if ($ppu === null) { + throw new \RuntimeException("Cannot deserialize memory: PPU not initialized"); } - $vram = array_values($vramUnpacked); - for ($i = 0; $i < count($vram); $i++) { - $bus->writeByte(0x8000 + $i, $vram[$i]); + + $vram = $ppu->getVram(); + + // Restore VRAM (support both old and new formats) + if (isset($data['vramBank0']) && isset($data['vramBank1'])) { + // New format: both banks saved separately + $vramBank0Unpacked = unpack('C*', base64_decode((string) $data['vramBank0'])); + if ($vramBank0Unpacked === false) { + throw new \RuntimeException('Failed to unpack VRAM bank 0 data'); + } + $vramBank0Data = array_values($vramBank0Unpacked); + + $vramBank1Unpacked = unpack('C*', base64_decode((string) $data['vramBank1'])); + if ($vramBank1Unpacked === false) { + throw new \RuntimeException('Failed to unpack VRAM bank 1 data'); + } + $vramBank1Data = array_values($vramBank1Unpacked); + + // Restore to both banks by switching bank and writing + $originalBank = $vram->getBank(); + + $vram->setBank(0); + for ($i = 0; $i < count($vramBank0Data); $i++) { + $bus->writeByte(0x8000 + $i, $vramBank0Data[$i]); + } + + $vram->setBank(1); + for ($i = 0; $i < count($vramBank1Data); $i++) { + $bus->writeByte(0x8000 + $i, $vramBank1Data[$i]); + } + + // Restore original bank selection + $currentBank = isset($data['vramCurrentBank']) ? (int) $data['vramCurrentBank'] : 0; + $vram->setBank($currentBank); + } elseif (isset($data['vram'])) { + // Old format: only one bank (backward compatibility) + $vramUnpacked = unpack('C*', base64_decode((string) $data['vram'])); + if ($vramUnpacked === false) { + throw new \RuntimeException('Failed to unpack VRAM data'); + } + $vramData = array_values($vramUnpacked); + for ($i = 0; $i < count($vramData); $i++) { + $bus->writeByte(0x8000 + $i, $vramData[$i]); + } } // Restore WRAM @@ -352,6 +468,212 @@ private function deserializeCartridge(\Gb\Cartridge\Cartridge $cartridge, array $cartridge->loadRamData((string) $data['ram']); } + /** + * Serialize Timer state. + * + * @return array + */ + private function serializeTimer(\Gb\Timer\Timer $timer): array + { + return [ + 'div' => $timer->getDiv(), + 'divCounter' => $timer->getDivCounter(), + 'tima' => $timer->getTima(), + 'tma' => $timer->getTma(), + 'tac' => $timer->getTac(), + 'timaCounter' => $timer->getTimaCounter(), + ]; + } + + /** + * Deserialize Timer state. + * + * @param array $data + */ + private function deserializeTimer(\Gb\Timer\Timer $timer, array $data): void + { + $timer->setDiv((int) ($data['div'] ?? 0)); + $timer->setDivCounter((int) ($data['divCounter'] ?? 0)); + $timer->setTima((int) ($data['tima'] ?? 0)); + $timer->setTma((int) ($data['tma'] ?? 0)); + $timer->setTac((int) ($data['tac'] ?? 0)); + $timer->setTimaCounter((int) ($data['timaCounter'] ?? 0)); + } + + /** + * Serialize Interrupt state. + * + * @return array + */ + private function serializeInterrupts(\Gb\Interrupts\InterruptController $interrupts): array + { + return [ + 'if' => $interrupts->readByte(0xFF0F), + 'ie' => $interrupts->readByte(0xFFFF), + ]; + } + + /** + * Deserialize Interrupt state. + * + * @param array $data + */ + private function deserializeInterrupts(\Gb\Interrupts\InterruptController $interrupts, array $data): void + { + $interrupts->writeByte(0xFF0F, (int) ($data['if'] ?? 0xE0)); + $interrupts->writeByte(0xFFFF, (int) ($data['ie'] ?? 0x00)); + } + + /** + * Serialize CGB controller state. + * + * @return array + */ + private function serializeCgb(\Gb\System\CgbController $cgb): array + { + return [ + 'key0' => $cgb->getKey0(), + 'key1' => $cgb->getKey1(), + 'opri' => $cgb->getOpri(), + 'doubleSpeed' => $cgb->isDoubleSpeed(), + 'key0Writable' => $cgb->isKey0Writable(), + ]; + } + + /** + * Deserialize CGB controller state. + * + * @param array $data + */ + private function deserializeCgb(\Gb\System\CgbController $cgb, array $data): void + { + $cgb->setKey0((int) ($data['key0'] ?? 0)); + $cgb->setKey1((int) ($data['key1'] ?? 0)); + $cgb->setOpri((int) ($data['opri'] ?? 0)); + $cgb->setDoubleSpeed((bool) ($data['doubleSpeed'] ?? false)); + $cgb->setKey0Writable((bool) ($data['key0Writable'] ?? true)); + } + + /** + * Serialize APU state. + * + * Note: This saves register state and basic APU state, but not full channel + * internal state (timers, counters). This provides partial audio restoration. + * + * @return array + */ + private function serializeApu(\Gb\Apu\Apu $apu): array + { + // Save all APU registers by reading them + $registers = [ + // Channel 1 + 'nr10' => $apu->readByte(0xFF10), + 'nr11' => $apu->readByte(0xFF11), + 'nr12' => $apu->readByte(0xFF12), + 'nr13' => $apu->readByte(0xFF13), + 'nr14' => $apu->readByte(0xFF14), + + // Channel 2 + 'nr21' => $apu->readByte(0xFF16), + 'nr22' => $apu->readByte(0xFF17), + 'nr23' => $apu->readByte(0xFF18), + 'nr24' => $apu->readByte(0xFF19), + + // Channel 3 + 'nr30' => $apu->readByte(0xFF1A), + 'nr31' => $apu->readByte(0xFF1B), + 'nr32' => $apu->readByte(0xFF1C), + 'nr33' => $apu->readByte(0xFF1D), + 'nr34' => $apu->readByte(0xFF1E), + + // Channel 4 + 'nr41' => $apu->readByte(0xFF20), + 'nr42' => $apu->readByte(0xFF21), + 'nr43' => $apu->readByte(0xFF22), + 'nr44' => $apu->readByte(0xFF23), + + // Master control + 'nr50' => $apu->readByte(0xFF24), + 'nr51' => $apu->readByte(0xFF25), + 'nr52' => $apu->readByte(0xFF26), + ]; + + return [ + 'registers' => $registers, + 'waveRam' => base64_encode(pack('C*', ...$apu->getWaveRam())), + 'frameSequencerCycles' => $apu->getFrameSequencerCycles(), + 'frameSequencerStep' => $apu->getFrameSequencerStep(), + 'sampleCycles' => $apu->getSampleCycles(), + 'enabled' => $apu->isEnabled(), + ]; + } + + /** + * Deserialize APU state. + * + * @param array $data + */ + private function deserializeApu(\Gb\Apu\Apu $apu, array $data): void + { + // Restore APU registers + if (isset($data['registers']) && is_array($data['registers'])) { + $reg = $data['registers']; + + // Channel 1 + $apu->writeByte(0xFF10, (int) ($reg['nr10'] ?? 0)); + $apu->writeByte(0xFF11, (int) ($reg['nr11'] ?? 0)); + $apu->writeByte(0xFF12, (int) ($reg['nr12'] ?? 0)); + $apu->writeByte(0xFF13, (int) ($reg['nr13'] ?? 0)); + $apu->writeByte(0xFF14, (int) ($reg['nr14'] ?? 0)); + + // Channel 2 + $apu->writeByte(0xFF16, (int) ($reg['nr21'] ?? 0)); + $apu->writeByte(0xFF17, (int) ($reg['nr22'] ?? 0)); + $apu->writeByte(0xFF18, (int) ($reg['nr23'] ?? 0)); + $apu->writeByte(0xFF19, (int) ($reg['nr24'] ?? 0)); + + // Channel 3 + $apu->writeByte(0xFF1A, (int) ($reg['nr30'] ?? 0)); + $apu->writeByte(0xFF1B, (int) ($reg['nr31'] ?? 0)); + $apu->writeByte(0xFF1C, (int) ($reg['nr32'] ?? 0)); + $apu->writeByte(0xFF1D, (int) ($reg['nr33'] ?? 0)); + $apu->writeByte(0xFF1E, (int) ($reg['nr34'] ?? 0)); + + // Channel 4 + $apu->writeByte(0xFF20, (int) ($reg['nr41'] ?? 0)); + $apu->writeByte(0xFF21, (int) ($reg['nr42'] ?? 0)); + $apu->writeByte(0xFF22, (int) ($reg['nr43'] ?? 0)); + $apu->writeByte(0xFF23, (int) ($reg['nr44'] ?? 0)); + + // Master control + $apu->writeByte(0xFF24, (int) ($reg['nr50'] ?? 0)); + $apu->writeByte(0xFF25, (int) ($reg['nr51'] ?? 0)); + $apu->writeByte(0xFF26, (int) ($reg['nr52'] ?? 0)); + } + + // Restore Wave RAM + if (isset($data['waveRam'])) { + $waveRamUnpacked = unpack('C*', base64_decode((string) $data['waveRam'])); + if ($waveRamUnpacked !== false) { + $apu->setWaveRam(array_values($waveRamUnpacked)); + } + } + + // Restore internal state + if (isset($data['frameSequencerCycles'])) { + $apu->setFrameSequencerCycles((int) $data['frameSequencerCycles']); + } + if (isset($data['frameSequencerStep'])) { + $apu->setFrameSequencerStep((int) $data['frameSequencerStep']); + } + if (isset($data['sampleCycles'])) { + $apu->setSampleCycles((float) $data['sampleCycles']); + } + if (isset($data['enabled'])) { + $apu->setEnabled((bool) $data['enabled']); + } + } + /** * Validate savestate format and version. * diff --git a/src/System/CgbController.php b/src/System/CgbController.php index ee9cd29..2f55145 100644 --- a/src/System/CgbController.php +++ b/src/System/CgbController.php @@ -143,4 +143,94 @@ public function isSpeedSwitchPrepared(): bool { return ($this->key1 & 0x01) !== 0; } + + /** + * Get KEY0 register value (for savestate serialization). + * + * @return int KEY0 register value + */ + public function getKey0(): int + { + return $this->key0; + } + + /** + * Get KEY1 register value (for savestate serialization). + * + * @return int KEY1 register value + */ + public function getKey1(): int + { + return $this->key1; + } + + /** + * Get OPRI register value (for savestate serialization). + * + * @return int OPRI register value + */ + public function getOpri(): int + { + return $this->opri; + } + + /** + * Get KEY0 writable flag (for savestate serialization). + * + * @return bool True if KEY0 is writable + */ + public function isKey0Writable(): bool + { + return $this->key0Writable; + } + + /** + * Set KEY0 register value (for savestate deserialization). + * + * @param int $value KEY0 register value + */ + public function setKey0(int $value): void + { + $this->key0 = $value; + } + + /** + * Set KEY1 register value (for savestate deserialization). + * + * @param int $value KEY1 register value + */ + public function setKey1(int $value): void + { + $this->key1 = $value & 0x01; + } + + /** + * Set OPRI register value (for savestate deserialization). + * + * @param int $value OPRI register value + */ + public function setOpri(int $value): void + { + $this->opri = $value & 0x01; + } + + /** + * Set double-speed mode (for savestate deserialization). + * + * @param bool $doubleSpeed True if in double-speed mode + */ + public function setDoubleSpeed(bool $doubleSpeed): void + { + $this->doubleSpeed = $doubleSpeed; + } + + /** + * Set KEY0 writable flag (for savestate deserialization). + * + * @param bool $writable True if KEY0 is writable + */ + public function setKey0Writable(bool $writable): void + { + $this->key0Writable = $writable; + } } diff --git a/src/Timer/Timer.php b/src/Timer/Timer.php index 9abb4dd..821e754 100644 --- a/src/Timer/Timer.php +++ b/src/Timer/Timer.php @@ -176,4 +176,124 @@ private function incrementTima(): void $this->interruptController->requestInterrupt(InterruptType::Timer); } } + + /** + * Get the current DIV register value. + * + * @return int DIV register (0x00-0xFF) + */ + public function getDiv(): int + { + return $this->div; + } + + /** + * Get the internal divider counter. + * + * @return int 16-bit divider counter + */ + public function getDivCounter(): int + { + return $this->divCounter; + } + + /** + * Get the TIMA register value. + * + * @return int TIMA register (0x00-0xFF) + */ + public function getTima(): int + { + return $this->tima; + } + + /** + * Get the TMA register value. + * + * @return int TMA register (0x00-0xFF) + */ + public function getTma(): int + { + return $this->tma; + } + + /** + * Get the TAC register value. + * + * @return int TAC register (0x00-0x07) + */ + public function getTac(): int + { + return $this->tac; + } + + /** + * Get the TIMA counter accumulator. + * + * @return int TIMA counter + */ + public function getTimaCounter(): int + { + return $this->timaCounter; + } + + /** + * Set the DIV register value (used for savestate restoration). + * + * @param int $value DIV register value + */ + public function setDiv(int $value): void + { + $this->div = $value & 0xFF; + } + + /** + * Set the internal divider counter (used for savestate restoration). + * + * @param int $value 16-bit divider counter + */ + public function setDivCounter(int $value): void + { + $this->divCounter = $value & 0xFFFF; + } + + /** + * Set the TIMA register value (used for savestate restoration). + * + * @param int $value TIMA register value + */ + public function setTima(int $value): void + { + $this->tima = $value & 0xFF; + } + + /** + * Set the TMA register value (used for savestate restoration). + * + * @param int $value TMA register value + */ + public function setTma(int $value): void + { + $this->tma = $value & 0xFF; + } + + /** + * Set the TAC register value (used for savestate restoration). + * + * @param int $value TAC register value + */ + public function setTac(int $value): void + { + $this->tac = $value & 0x07; + } + + /** + * Set the TIMA counter accumulator (used for savestate restoration). + * + * @param int $value TIMA counter + */ + public function setTimaCounter(int $value): void + { + $this->timaCounter = $value; + } } diff --git a/tests/Integration/SavestateIntegrationTest.php b/tests/Integration/SavestateIntegrationTest.php new file mode 100644 index 0000000..aac6dcc --- /dev/null +++ b/tests/Integration/SavestateIntegrationTest.php @@ -0,0 +1,501 @@ +tempFile = sys_get_temp_dir() . '/phpboy_integration_test_' . uniqid() . '.state'; + } + + protected function tearDown(): void + { + if (file_exists($this->tempFile)) { + unlink($this->tempFile); + } + } + + #[Test] + public function it_preserves_all_new_state_fields(): void + { + $emulator = new Emulator(); + $emulator->loadRom(__DIR__ . '/../../third_party/roms/cpu_instrs/individual/01-special.gb'); + + // Run for several frames to establish state + for ($i = 0; $i < 500; $i++) { + $emulator->step(); + } + + // Capture state before saving + $cpu = $emulator->getCpu(); + $timer = $emulator->getTimer(); + $interrupts = $emulator->getInterruptController(); + $cgb = $emulator->getCgbController(); + $clock = $emulator->getClock(); + + $this->assertNotNull($cpu); + $this->assertNotNull($timer); + $this->assertNotNull($interrupts); + + $stateBefore = [ + 'pc' => $cpu->getPC()->get(), + 'af' => $cpu->getAF()->get(), + 'bc' => $cpu->getBC()->get(), + 'de' => $cpu->getDE()->get(), + 'hl' => $cpu->getHL()->get(), + 'sp' => $cpu->getSP()->get(), + 'ime' => $cpu->getIME(), + 'div' => $timer->getDiv(), + 'divCounter' => $timer->getDivCounter(), + 'tima' => $timer->getTima(), + 'tma' => $timer->getTma(), + 'tac' => $timer->getTac(), + 'timaCounter' => $timer->getTimaCounter(), + 'if' => $interrupts->readByte(0xFF0F), + 'ie' => $interrupts->readByte(0xFFFF), + 'cycles' => $clock->getCycles(), + ]; + + // Save state + $emulator->saveState($this->tempFile); + + // Run more to change state + for ($i = 0; $i < 500; $i++) { + $emulator->step(); + } + + // Verify state changed (DIV and cycles should always change, PC might not if halted) + $this->assertNotEquals($stateBefore['cycles'], $clock->getCycles(), 'Cycles should have advanced'); + + // Load state + $emulator->loadState($this->tempFile); + + // Verify all state restored + $this->assertEquals($stateBefore['pc'], $cpu->getPC()->get(), 'PC not restored'); + $this->assertEquals($stateBefore['af'], $cpu->getAF()->get(), 'AF not restored'); + $this->assertEquals($stateBefore['bc'], $cpu->getBC()->get(), 'BC not restored'); + $this->assertEquals($stateBefore['de'], $cpu->getDE()->get(), 'DE not restored'); + $this->assertEquals($stateBefore['hl'], $cpu->getHL()->get(), 'HL not restored'); + $this->assertEquals($stateBefore['sp'], $cpu->getSP()->get(), 'SP not restored'); + $this->assertEquals($stateBefore['ime'], $cpu->getIME(), 'IME not restored'); + $this->assertEquals($stateBefore['div'], $timer->getDiv(), 'DIV not restored'); + $this->assertEquals($stateBefore['divCounter'], $timer->getDivCounter(), 'DIV counter not restored'); + $this->assertEquals($stateBefore['tima'], $timer->getTima(), 'TIMA not restored'); + $this->assertEquals($stateBefore['tma'], $timer->getTma(), 'TMA not restored'); + $this->assertEquals($stateBefore['tac'], $timer->getTac(), 'TAC not restored'); + $this->assertEquals($stateBefore['timaCounter'], $timer->getTimaCounter(), 'TIMA counter not restored'); + $this->assertEquals($stateBefore['if'], $interrupts->readByte(0xFF0F), 'IF not restored'); + $this->assertEquals($stateBefore['ie'], $interrupts->readByte(0xFFFF), 'IE not restored'); + $this->assertEquals($stateBefore['cycles'], $clock->getCycles(), 'Clock cycles not restored'); + } + + #[Test] + public function it_preserves_vram_banking_in_cgb_mode(): void + { + // Use a CGB-compatible ROM + $emulator = new Emulator(); + $emulator->setHardwareMode('cgb'); + $emulator->loadRom(__DIR__ . '/../../third_party/roms/cpu_instrs/individual/01-special.gb'); + + $ppu = $emulator->getPpu(); + $this->assertNotNull($ppu); + + $vram = $ppu->getVram(); + + // Write test patterns to both VRAM banks + $vram->setBank(0); + for ($i = 0; $i < 100; $i++) { + $vram->writeByte($i, 0xAA + $i); + } + + $vram->setBank(1); + for ($i = 0; $i < 100; $i++) { + $vram->writeByte($i, 0xBB + $i); + } + + $vram->setBank(0); + + // Save state + $emulator->saveState($this->tempFile); + + // Corrupt VRAM + $vram->setBank(0); + for ($i = 0; $i < 100; $i++) { + $vram->writeByte($i, 0x00); + } + $vram->setBank(1); + for ($i = 0; $i < 100; $i++) { + $vram->writeByte($i, 0x00); + } + + // Load state + $emulator->loadState($this->tempFile); + + // Verify current bank restored FIRST before we change it + $this->assertEquals(0, $vram->getBank(), 'VRAM current bank not restored'); + + // Verify both banks restored + $vram->setBank(0); + for ($i = 0; $i < 100; $i++) { + $this->assertEquals((0xAA + $i) & 0xFF, $vram->readByte($i), "VRAM bank 0 byte $i not restored"); + } + + $vram->setBank(1); + for ($i = 0; $i < 100; $i++) { + $this->assertEquals((0xBB + $i) & 0xFF, $vram->readByte($i), "VRAM bank 1 byte $i not restored"); + } + } + + #[Test] + public function it_preserves_cgb_color_palettes(): void + { + $emulator = new Emulator(); + $emulator->setHardwareMode('cgb'); + $emulator->loadRom(__DIR__ . '/../../third_party/roms/cpu_instrs/individual/01-special.gb'); + + $ppu = $emulator->getPpu(); + $this->assertNotNull($ppu); + + $colorPalette = $ppu->getColorPalette(); + + // Set up test palette data + $testBgPalette = array_fill(0, 64, 0); + $testObjPalette = array_fill(0, 64, 0); + + for ($i = 0; $i < 64; $i++) { + $testBgPalette[$i] = ($i * 3) & 0xFF; + $testObjPalette[$i] = ($i * 5) & 0xFF; + } + + $colorPalette->setBgPaletteMemory($testBgPalette); + $colorPalette->setObjPaletteMemory($testObjPalette); + $colorPalette->setBgIndexRaw(0x85); // Index 5 with auto-increment + $colorPalette->setObjIndexRaw(0x8A); // Index 10 with auto-increment + + // Save state + $emulator->saveState($this->tempFile); + + // Corrupt palette data + $colorPalette->setBgPaletteMemory(array_fill(0, 64, 0)); + $colorPalette->setObjPaletteMemory(array_fill(0, 64, 0)); + $colorPalette->setBgIndexRaw(0); + $colorPalette->setObjIndexRaw(0); + + // Load state + $emulator->loadState($this->tempFile); + + // Verify palettes restored + $restoredBgPalette = $colorPalette->getBgPaletteMemory(); + $restoredObjPalette = $colorPalette->getObjPaletteMemory(); + + for ($i = 0; $i < 64; $i++) { + $this->assertEquals($testBgPalette[$i], $restoredBgPalette[$i], "BG palette byte $i not restored"); + $this->assertEquals($testObjPalette[$i], $restoredObjPalette[$i], "OBJ palette byte $i not restored"); + } + + $this->assertEquals(0x85, $colorPalette->getBgIndexRaw(), 'BG palette index not restored'); + $this->assertEquals(0x8A, $colorPalette->getObjIndexRaw(), 'OBJ palette index not restored'); + } + + #[Test] + public function it_preserves_cgb_controller_state(): void + { + $emulator = new Emulator(); + $emulator->setHardwareMode('cgb'); + $emulator->loadRom(__DIR__ . '/../../third_party/roms/cpu_instrs/individual/01-special.gb'); + + $cgb = $emulator->getCgbController(); + $this->assertNotNull($cgb); + + // Set up CGB controller state + $cgb->setKey0(0x80); + $cgb->setKey1(0x01); + $cgb->setOpri(0x01); + $cgb->setDoubleSpeed(false); + $cgb->setKey0Writable(false); + + // Save state + $emulator->saveState($this->tempFile); + + // Change state + $cgb->setKey0(0x04); + $cgb->setKey1(0x00); + $cgb->setOpri(0x00); + $cgb->setDoubleSpeed(true); + $cgb->setKey0Writable(true); + + // Load state + $emulator->loadState($this->tempFile); + + // Verify restoration + $this->assertEquals(0x80, $cgb->getKey0(), 'KEY0 not restored'); + $this->assertEquals(0x01, $cgb->getKey1(), 'KEY1 not restored'); + $this->assertEquals(0x01, $cgb->getOpri(), 'OPRI not restored'); + $this->assertEquals(false, $cgb->isDoubleSpeed(), 'Double speed not restored'); + $this->assertEquals(false, $cgb->isKey0Writable(), 'KEY0 writable not restored'); + } + + #[Test] + public function it_preserves_apu_state(): void + { + $emulator = new Emulator(); + $emulator->loadRom(__DIR__ . '/../../third_party/roms/cpu_instrs/individual/01-special.gb'); + + $apu = $emulator->getApu(); + $this->assertNotNull($apu); + + // Run to establish APU state + for ($i = 0; $i < 1000; $i++) { + $emulator->step(); + } + + // Capture APU state before save + $waveRamBefore = $apu->getWaveRam(); + $frameSeqCyclesBefore = $apu->getFrameSequencerCycles(); + $frameSeqStepBefore = $apu->getFrameSequencerStep(); + $sampleCyclesBefore = $apu->getSampleCycles(); + $enabledBefore = $apu->isEnabled(); + + // Write test pattern to Wave RAM + $testWaveRam = []; + for ($i = 0; $i < 16; $i++) { + $testWaveRam[$i] = ($i * 17) & 0xFF; + } + $apu->setWaveRam($testWaveRam); + + // Save state + $emulator->saveState($this->tempFile); + + // Corrupt APU state + $apu->setWaveRam(array_fill(0, 16, 0)); + $apu->setFrameSequencerCycles(0); + $apu->setFrameSequencerStep(0); + $apu->setSampleCycles(0.0); + + // Load state + $emulator->loadState($this->tempFile); + + // Verify APU state restored + $waveRamAfter = $apu->getWaveRam(); + for ($i = 0; $i < 16; $i++) { + $this->assertEquals($testWaveRam[$i], $waveRamAfter[$i], "Wave RAM byte $i not restored"); + } + + $this->assertEquals($frameSeqCyclesBefore, $apu->getFrameSequencerCycles(), 'Frame sequencer cycles not restored'); + $this->assertEquals($frameSeqStepBefore, $apu->getFrameSequencerStep(), 'Frame sequencer step not restored'); + $this->assertEquals($sampleCyclesBefore, $apu->getSampleCycles(), 'Sample cycles not restored'); + $this->assertEquals($enabledBefore, $apu->isEnabled(), 'APU enabled state not restored'); + } + + #[Test] + public function it_supports_multiple_save_load_cycles(): void + { + $emulator = new Emulator(); + $emulator->loadRom(__DIR__ . '/../../third_party/roms/cpu_instrs/individual/01-special.gb'); + + $cpu = $emulator->getCpu(); + $this->assertNotNull($cpu); + + $savedStates = []; + + // Create multiple savepoints + for ($savepoint = 0; $savepoint < 3; $savepoint++) { + // Run for some frames + for ($i = 0; $i < 200; $i++) { + $emulator->step(); + } + + // Save current state + $savedStates[$savepoint] = [ + 'pc' => $cpu->getPC()->get(), + 'af' => $cpu->getAF()->get(), + 'file' => sys_get_temp_dir() . "/phpboy_test_savepoint_{$savepoint}_" . uniqid() . '.state', + ]; + + $emulator->saveState($savedStates[$savepoint]['file']); + } + + // Run more to establish different state + for ($i = 0; $i < 500; $i++) { + $emulator->step(); + } + + // Load and verify each savepoint in reverse order + for ($savepoint = 2; $savepoint >= 0; $savepoint--) { + $emulator->loadState($savedStates[$savepoint]['file']); + + $this->assertEquals( + $savedStates[$savepoint]['pc'], + $cpu->getPC()->get(), + "PC mismatch at savepoint $savepoint" + ); + $this->assertEquals( + $savedStates[$savepoint]['af'], + $cpu->getAF()->get(), + "AF mismatch at savepoint $savepoint" + ); + + // Clean up + unlink($savedStates[$savepoint]['file']); + } + } + + #[Test] + public function it_preserves_memory_contents(): void + { + $emulator = new Emulator(); + $emulator->loadRom(__DIR__ . '/../../third_party/roms/cpu_instrs/individual/01-special.gb'); + + $bus = $emulator->getBus(); + $this->assertNotNull($bus); + + // Write test patterns to various memory regions + // WRAM + for ($i = 0; $i < 100; $i++) { + $bus->writeByte(0xC000 + $i, ($i * 7) & 0xFF); + } + + // HRAM + for ($i = 0; $i < 100; $i++) { + $bus->writeByte(0xFF80 + $i, ($i * 11) & 0xFF); + } + + // Save state + $emulator->saveState($this->tempFile); + + // Corrupt memory + for ($i = 0; $i < 100; $i++) { + $bus->writeByte(0xC000 + $i, 0); + $bus->writeByte(0xFF80 + $i, 0); + } + + // Load state + $emulator->loadState($this->tempFile); + + // Verify memory restored + for ($i = 0; $i < 100; $i++) { + $this->assertEquals( + ($i * 7) & 0xFF, + $bus->readByte(0xC000 + $i), + "WRAM byte at 0xC000+$i not restored" + ); + $this->assertEquals( + ($i * 11) & 0xFF, + $bus->readByte(0xFF80 + $i), + "HRAM byte at 0xFF80+$i not restored" + ); + } + } + + #[Test] + public function it_preserves_state_across_rom_reload(): void + { + $romPath = __DIR__ . '/../../third_party/roms/cpu_instrs/individual/01-special.gb'; + + // First emulator instance + $emulator1 = new Emulator(); + $emulator1->loadRom($romPath); + + // Run to establish state + for ($i = 0; $i < 500; $i++) { + $emulator1->step(); + } + + $cpu1 = $emulator1->getCpu(); + $this->assertNotNull($cpu1); + $pcBefore = $cpu1->getPC()->get(); + + // Save state + $emulator1->saveState($this->tempFile); + + // Create new emulator instance and load same ROM + $emulator2 = new Emulator(); + $emulator2->loadRom($romPath); + + // Load savestate + $emulator2->loadState($this->tempFile); + + // Verify state transferred to new emulator instance + $cpu2 = $emulator2->getCpu(); + $this->assertNotNull($cpu2); + $this->assertEquals($pcBefore, $cpu2->getPC()->get(), 'PC not preserved across emulator instances'); + } + + #[Test] + public function it_contains_all_expected_json_fields(): void + { + $emulator = new Emulator(); + $emulator->setHardwareMode('cgb'); + $emulator->loadRom(__DIR__ . '/../../third_party/roms/cpu_instrs/individual/01-special.gb'); + + // Run to populate all state + for ($i = 0; $i < 500; $i++) { + $emulator->step(); + } + + $emulator->saveState($this->tempFile); + + $json = file_get_contents($this->tempFile); + $this->assertNotFalse($json); + + $state = json_decode($json, true); + $this->assertIsArray($state); + + // Verify all top-level fields exist + $this->assertArrayHasKey('magic', $state); + $this->assertArrayHasKey('version', $state); + $this->assertArrayHasKey('timestamp', $state); + $this->assertArrayHasKey('cpu', $state); + $this->assertArrayHasKey('ppu', $state); + $this->assertArrayHasKey('memory', $state); + $this->assertArrayHasKey('cartridge', $state); + $this->assertArrayHasKey('timer', $state); + $this->assertArrayHasKey('interrupts', $state); + $this->assertArrayHasKey('cgb', $state); + $this->assertArrayHasKey('apu', $state); + $this->assertArrayHasKey('clock', $state); + + // Verify new field structures + $this->assertIsArray($state['timer']); + $this->assertArrayHasKey('div', $state['timer']); + $this->assertArrayHasKey('divCounter', $state['timer']); + $this->assertArrayHasKey('tima', $state['timer']); + + $this->assertIsArray($state['interrupts']); + $this->assertArrayHasKey('if', $state['interrupts']); + $this->assertArrayHasKey('ie', $state['interrupts']); + + $this->assertIsArray($state['cgb']); + $this->assertArrayHasKey('key0', $state['cgb']); + $this->assertArrayHasKey('key1', $state['cgb']); + $this->assertArrayHasKey('doubleSpeed', $state['cgb']); + + $this->assertIsArray($state['apu']); + $this->assertArrayHasKey('registers', $state['apu']); + $this->assertArrayHasKey('waveRam', $state['apu']); + $this->assertArrayHasKey('frameSequencerCycles', $state['apu']); + + $this->assertIsArray($state['ppu']); + $this->assertArrayHasKey('cgbPalette', $state['ppu']); + + $this->assertIsArray($state['memory']); + $this->assertArrayHasKey('vramBank0', $state['memory']); + $this->assertArrayHasKey('vramBank1', $state['memory']); + $this->assertArrayHasKey('vramCurrentBank', $state['memory']); + } +}