[C64] EasyFlash overhaul

- Implement the AM29F040B flash device as its own component
- Fix save data for games that use the standard EasyAPI driver
- Fix "Briley Witch Chronicles 2": the flash driver checks AutoSelect register 02 to see if the cartridge is write-protected
- Rom is stored as bytes instead of ints, saves a lot of memory
- EasyFlash now has proper hard reset and clock operations
- SaveRam deltas fixed
This commit is contained in:
saxxonpike 2025-01-03 00:32:02 -06:00 committed by YoshiRulz
parent 83ea4bfb1c
commit 99ca3be22a
7 changed files with 542 additions and 179 deletions

View File

@ -11,6 +11,7 @@ namespace BizHawk.Emulation.Cores.Computers.Commodore64
{
bool diskDriveEnabled = _board.DiskDrive != null;
bool tapeDriveEnabled = _board.TapeDrive != null;
bool cartEnabled = _board.CartPort.IsConnected;
var domains = new List<MemoryDomain>
{
@ -41,6 +42,11 @@ namespace BizHawk.Emulation.Cores.Computers.Commodore64
});
}
if (cartEnabled)
{
domains.AddRange(_board.CartPort.CreateMemoryDomains());
}
_memoryDomains = new MemoryDomainList(domains);
((BasicServiceProvider)ServiceProvider).Register(_memoryDomains);
}

View File

@ -36,6 +36,12 @@ namespace BizHawk.Emulation.Cores.Computers.Commodore64
}
InitMedia(_roms[_currentDisk]);
if (_board.CartPort.SaveRam is { } cartSaveRam)
{
ser.Register<ISaveRam>(cartSaveRam);
}
HardReset();
switch (SyncSettings.VicType)
@ -329,6 +335,9 @@ namespace BizHawk.Emulation.Cores.Computers.Commodore64
if (cart != null)
{
_board.CartPort.Connect(cart);
if (_board.CartPort.SaveRam != null)
{
}
}
break;
case C64Format.TAP:

View File

@ -0,0 +1,315 @@
using BizHawk.Common;
using BizHawk.Emulation.Common;
namespace BizHawk.Emulation.Cores.Computers.Commodore64.Cartridge;
/// <summary>
/// AMD flash chip used for EasyFlash emulation.
/// </summary>
public class Am29F040B
{
// Source:
// https://www.mouser.com/datasheet/2/196/spansion_inc_am29f040b_eol_21445e8-3004346.pdf
//
// Flash erase suspend/resume are not implemented.
public const int ImageSize = 1 << 19;
public const int ImageMask = ImageSize - 1;
private const int SectorSize = 1 << 16;
private const int SectorMask = SectorSize - 1;
private const int RegisterMask = (1 << 11) - 1;
private const byte ToggleBit2 = 1 << 2;
private const byte ErrorBit = 1 << 5;
private const byte ToggleBit = 1 << 6;
private const byte PollingBit = 1 << 7;
private const byte EraseBit = 1 << 3;
private const int WriteLatency = 7;
private const int EraseSectorLatency = 1000000;
private const int EraseChipLatency = 8000000;
private const int EraseValue = 0xFF;
private const byte ManufacturerCode = 0x01;
private const byte DeviceCode = 0xA4;
private const byte WriteProtect = 0x00; // can be set to 1 to tell software it is write-protected
private enum Sequence
{
None,
Start,
Complete,
Command
}
private enum Mode
{
Read,
Erase,
AutoSelect,
Write
}
private enum Register
{
Command0 = 0x0555,
Command1 = 0x02AA
}
private enum Signal
{
Command0 = 0xAA,
Command1 = 0x55,
Erase = 0x80,
AutoSelect = 0x90,
Program = 0xA0,
ChipErase = 0x10,
SectorErase = 0x30,
Reset = 0xF0
}
private int _busyTimeRemaining;
private int _status;
private byte[] _data = new byte[ImageSize];
private Mode _mode;
private Sequence _sequence;
private bool _returnStatus;
private int _startAddress;
private int _endAddress;
private bool _errorPending;
private bool _dataDirty;
public MemoryDomain CreateMemoryDomain(string name) =>
new MemoryDomainByteArray(
name: name,
endian: MemoryDomain.Endian.Little,
data: _data,
writable: true,
wordSize: 1
);
public void Clock()
{
if (_busyTimeRemaining <= 0)
return;
_busyTimeRemaining--;
if (_busyTimeRemaining != 0)
return;
_status ^= PollingBit;
if (_errorPending)
{
_errorPending = false;
_status |= ErrorBit;
}
}
/// <summary>
/// Synchronize state.
/// </summary>
/// <param name="ser">
/// State serializer.
/// </param>
/// <param name="withData">
/// True only if the raw data should be synchronized. If false,
/// the caller is responsible for synchronizing deltas.
/// </param>
public void SyncState(Serializer ser, bool withData)
{
ser.Sync("BusyTimeRemaining", ref _busyTimeRemaining);
ser.Sync("Status", ref _status);
ser.SyncEnum("Mode", ref _mode);
ser.SyncEnum("Sequence", ref _sequence);
ser.Sync("ReturnStatus", ref _returnStatus);
ser.Sync("StartAddress", ref _startAddress);
ser.Sync("EndAddress", ref _endAddress);
ser.Sync("ErrorPending", ref _errorPending);
ser.Sync("DataDirty", ref _dataDirty);
if (withData)
ser.Sync("Data", ref _data, false);
}
public void Reset()
{
_busyTimeRemaining = 0;
_status = 0;
_mode = Mode.Read;
_sequence = Sequence.None;
_errorPending = false;
_startAddress = 0;
_endAddress = ImageMask;
}
public Span<byte> Data =>
_data.AsSpan();
public int Peek(int addr) =>
_data[addr & ImageMask] & 0xFF;
public int Poke(int addr, int val)
{
var newData = val & 0xFF;
_dataDirty |= _data[addr & ImageMask] != newData;
return _data[addr & ImageMask] = unchecked((byte)newData);
}
// From the datasheet:
// Address bits A18-A11 = X = Dont Care for all address
// commands except for Program Address (PA), Sector Address (SA), Read
// Address (RA), and AutoSelect sector protect verify.
public int Read(int addr)
{
int data;
if (_busyTimeRemaining > 0)
{
if (addr >= _startAddress && addr <= _endAddress)
_status ^= ToggleBit2;
_status ^= ToggleBit;
return _status;
}
// Some commands allow one read of status before going back to read mode.
// Areas being written or erased will always return status during modification.
if (_returnStatus && addr >= _startAddress && addr <= _endAddress)
{
_returnStatus = false;
return _status;
}
// Read manufacturer registers or memory.
switch (_mode)
{
case Mode.AutoSelect:
{
switch (addr & 0xFF)
{
case 0x00:
data = ManufacturerCode;
break;
case 0x01:
data = DeviceCode;
break;
case 0x02:
data = WriteProtect;
break;
default:
data = 0xFF;
break;
}
break;
}
default:
{
data = _data[addr & ImageMask];
break;
}
}
return data;
}
public void Write(int addr, int data)
{
switch (_mode, _sequence, (Register)(addr & RegisterMask), (Signal)data)
{
case (Mode.Write, _, _, _):
{
_mode = Mode.Read;
_sequence = Sequence.None;
if (_busyTimeRemaining > 0)
break;
var originalData = _data[addr & ImageMask];
var newData = originalData & data & 0xFF;
_dataDirty |= newData != originalData;
_errorPending = data != newData;
_data[addr & ImageMask] = unchecked((byte)newData);
_busyTimeRemaining = WriteLatency; // 7-30us
_status = (data & 0x80) ^ PollingBit;
_returnStatus = true;
_startAddress = _endAddress = addr;
break;
}
case (_, _, Register.Command0, Signal.Command0):
{
_sequence = Sequence.Start;
break;
}
case (_, Sequence.Start, Register.Command1, Signal.Command1):
{
_sequence = Sequence.Complete;
break;
}
case (_, Sequence.Complete, Register.Command0, Signal.Erase):
{
_mode = Mode.Erase;
_sequence = Sequence.None;
break;
}
case (Mode.Erase, Sequence.Complete, Register.Command0, Signal.ChipErase):
{
_mode = Mode.Read;
_sequence = Sequence.None;
if (_busyTimeRemaining > 0)
break;
_busyTimeRemaining = EraseChipLatency; // 8-64sec
_data.AsSpan().Fill(EraseValue);
_dataDirty = true;
_returnStatus = true;
_status = EraseBit; // bit 7 = complete
break;
}
case (Mode.Erase, Sequence.Complete, _, Signal.SectorErase):
{
_mode = Mode.Read;
_sequence = Sequence.None;
if (_busyTimeRemaining > 0)
break;
_busyTimeRemaining = EraseSectorLatency; // ~1sec
_data.AsSpan(addr & ~SectorMask, SectorSize).Fill(0xFF);
_dataDirty = true;
_returnStatus = true;
_status = EraseBit; // bit 7 = complete
break;
}
case (Mode.Read, Sequence.Complete, Register.Command0, Signal.AutoSelect):
{
_mode = Mode.AutoSelect;
_sequence = Sequence.None;
break;
}
case (Mode.Read, Sequence.Complete, Register.Command0, Signal.Program):
{
_mode = Mode.Write;
break;
}
case (_, _, _, Signal.Reset):
{
_mode = Mode.Read;
_sequence = Sequence.None;
break;
}
}
}
public bool IsDataDirty => _dataDirty;
public bool CheckDataDirty()
{
var result = _dataDirty;
_dataDirty = false;
return result;
}
}

View File

@ -0,0 +1,8 @@
namespace BizHawk.Emulation.Cores.Computers.Commodore64.Cartridge;
public class CartridgeChip
{
public int Address;
public int Bank;
public int[] Data;
}

View File

@ -106,7 +106,7 @@ namespace BizHawk.Emulation.Cores.Computers.Commodore64.Cartridge
result = new Mapper0013(chipAddress, chipBank, chipData);
break;
case 0x0020: // EasyFlash
result = new Mapper0020(chipAddress, chipBank, chipData);
result = new Mapper0020(BuildChipList(chipAddress, chipBank, chipData));
break;
case 0x002B: // Prophet 64
result = new Mapper002B(chipAddress, chipBank, chipData);
@ -119,6 +119,16 @@ namespace BizHawk.Emulation.Cores.Computers.Commodore64.Cartridge
return result;
}
private static List<CartridgeChip> BuildChipList(IList<int> addresses, IList<int> banks, IList<int[]> data) =>
Enumerable.Range(0, addresses.Count)
.Select(i => new CartridgeChip
{
Address = addresses[i],
Bank = banks[i],
Data = data[i]
})
.ToList();
private static int ReadCRTShort(BinaryReader reader)
{
return (reader.ReadByte() << 8) |
@ -256,6 +266,9 @@ namespace BizHawk.Emulation.Cores.Computers.Commodore64.Cartridge
{
}
public virtual IEnumerable<MemoryDomain> CreateMemoryDomains() =>
Array.Empty<MemoryDomain>();
private bool _driveLightEnabled;
private bool _driveLightOn;

View File

@ -1,3 +1,4 @@
using System.Collections.Generic;
using BizHawk.Common;
using BizHawk.Emulation.Common;
@ -133,6 +134,15 @@ namespace BizHawk.Emulation.Cores.Computers.Commodore64.Cartridge
ser.EndSection();
}
public ISaveRam SaveRam => _cartridgeDevice as ISaveRam;
public IEnumerable<MemoryDomain> CreateMemoryDomains()
{
if (_connected)
return _cartridgeDevice.CreateMemoryDomains();
return Array.Empty<MemoryDomain>();
}
public bool DriveLightEnabled => _connected && _cartridgeDevice.DriveLightEnabled;
public bool DriveLightOn => _connected && _cartridgeDevice.DriveLightOn;

View File

@ -1,125 +1,154 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using BizHawk.Common;
using BizHawk.Emulation.Common;
namespace BizHawk.Emulation.Cores.Computers.Commodore64.Cartridge
{
// EasyFlash cartridge
// No official games came on one of these but there
// are a few dumps from GameBase64 that use this mapper
// There are 64 banks total, DE00 is bank select.
// Selecing a bank will select both Lo and Hi ROM.
// DE02 will switch exrom/game bits: bit 0=game,
// bit 1=exrom, bit 2=for our cases, always set true.
// These two registers are write only.
// This cartridge always starts up in Ultimax mode,
// with Game set high and ExRom set low.
// There is also 256 bytes RAM at DF00-DFFF.
// We emulate having the AM29F040 chip.
internal sealed class Mapper0020 : CartridgeDevice
/// <summary>
/// Implements the EasyFlash cartridge format.
///
/// The most common EasyFlash implementation uses 2x AM29F040 programmable ROMs
/// and a 256-byte memory.
///
/// The address bus is 19 bits wide. Bits 18-13 are set by the "bank"
/// register (implemented as a separate bank of flip-flops on the board) and
/// bits 12-0 are set from the system bus. "RomH" and "RomL" are directly
/// tied to the respective chip-enable signals for each flash ROM, which means
/// that address range $8000-$9FFF will correspond to one flash ROM, and $A000-$BFFF
/// (or $E000-$FFFF in UltiMax configuration) will correspond to the other.
///
/// Control registers are mapped to $DE00 and $DE02. The 256-byte RAM is mapped to $DF00-$DFFF.
/// </summary>
/// <remarks>
/// Two registers can be accessed:
///
/// $DE00 - bank register (bits: 00BBBBBB)
/// B = bank ($00-$3F)
///
/// $DE02 - control register (bits: L0000MXG)
/// L = light control
/// M = Game pin control; 1=software controlled, 0=onboard jumper controlled
/// X = ExRom pin level; 1=low, 0=high
/// G = Game pin level; 1=low, 0=high
/// </remarks>
internal sealed class Mapper0020 : CartridgeDevice, ISaveRam
{
private int _bankOffset = 63 << 13;
private readonly byte[] _originalMediaA; // 8000
private readonly byte[] _originalMediaB; // A000
private int[] _banksA = new int[64 << 13]; // 8000
private int[] _banksB = new int[64 << 13]; // A000
private byte[] _deltaA; // 8000
private byte[] _deltaB; // A000
private readonly int[] _originalMediaA; // 8000
private readonly int[] _originalMediaB; // A000
private readonly Am29F040B _chipA = new();
private readonly Am29F040B _chipB = new();
private bool _saveRamDirty;
private bool _boardLed;
private bool _jumper;
private int _stateBits;
private int[] _ram = new int[256];
private byte[] _ram = new byte[256];
private int _bankNumber;
private bool _commandLatch55;
private bool _commandLatchAa;
private int _internalRomState;
public Mapper0020(IList<int> newAddresses, IList<int> newBanks, IList<int[]> newData)
public Mapper0020(IReadOnlyList<CartridgeChip> chips)
{
DriveLightEnabled = true;
var count = newAddresses.Count;
var count = chips.Count;
// force ultimax mode (the cart SHOULD set this
// otherwise on load, according to the docs)
pinGame = false;
pinExRom = true;
// for safety, initialize all banks to dummy
for (var i = 0; i < 64 * 0x2000; i++)
{
_banksA[i] = 0xFF;
_banksB[i] = 0xFF;
}
// load in all banks
for (var i = 0; i < count; i++)
foreach (var chip in chips)
{
switch (newAddresses[i])
switch (chip.Address)
{
case 0x8000:
Array.Copy(newData[i], 0, _banksA, newBanks[i] * 0x2000, 0x2000);
chip.Data.Select(b => unchecked((byte)b))
.ToArray()
.CopyTo(_chipA.Data.Slice(chip.Bank * 0x2000, 0x2000));
break;
case 0xA000:
case 0xE000:
Array.Copy(newData[i], 0, _banksB, newBanks[i] * 0x2000, 0x2000);
chip.Data.Select(b => unchecked((byte)b))
.ToArray()
.CopyTo(_chipB.Data.Slice(chip.Bank * 0x2000, 0x2000));
break;
}
}
// default to bank 0
BankSet(0);
// internal operation settings
_commandLatch55 = false;
_commandLatchAa = false;
_internalRomState = 0;
_bankNumber = 0;
// back up original media
_originalMediaA = _banksA.Select(d => d).ToArray();
_originalMediaB = _banksB.Select(d => d).ToArray();
_originalMediaA = _chipA.Data.ToArray();
_originalMediaB = _chipB.Data.ToArray();
}
public override void HardReset()
{
_chipA.Reset();
_chipB.Reset();
base.HardReset();
}
private void FlushSaveRam()
{
if (_chipA.CheckDataDirty() || _deltaA == null)
_deltaA = DeltaSerializer.GetDelta<byte>(_originalMediaA, _chipA.Data).ToArray();
if (_chipB.CheckDataDirty() || _deltaB == null)
_deltaB = DeltaSerializer.GetDelta<byte>(_originalMediaB, _chipB.Data).ToArray();
_saveRamDirty = false;
}
protected override void SyncStateInternal(Serializer ser)
{
ser.Sync("BankOffset", ref _bankOffset);
if (!ser.IsReader)
FlushSaveRam();
ser.Sync("BankNumber", ref _bankNumber);
ser.Sync("BoardLed", ref _boardLed);
ser.Sync("Jumper", ref _jumper);
ser.Sync("StateBits", ref _stateBits);
ser.Sync("RAM", ref _ram, useNull: false);
ser.Sync("CommandLatch55", ref _commandLatchAa);
ser.Sync("CommandLatchAA", ref _commandLatchAa);
ser.Sync("InternalROMState", ref _internalRomState);
ser.SyncDelta("MediaStateA", _originalMediaA, _banksA);
ser.SyncDelta("MediaStateB", _originalMediaB, _banksB);
ser.Sync("MediaStateA", ref _deltaA, useNull: false);
ser.Sync("MediaStateB", ref _deltaB, useNull: false);
ser.BeginSection("FlashA");
_chipA.SyncState(ser, withData: false);
ser.EndSection();
ser.BeginSection("FlashB");
_chipB.SyncState(ser, withData: false);
ser.EndSection();
if (ser.IsReader)
{
if (_deltaA != null)
DeltaSerializer.ApplyDelta(_originalMediaA, _chipA.Data, _deltaA);
if (_deltaB != null)
DeltaSerializer.ApplyDelta(_originalMediaB, _chipB.Data, _deltaB);
}
DriveLightOn = _boardLed;
}
private void BankSet(int index)
{
_bankOffset = (index & 0x3F) << 13;
}
private int CalculateBankOffset(int addr) =>
(addr & 0x1FFF) | (_bankNumber << 13);
public override int Peek8000(int addr) =>
_chipA.Peek(CalculateBankOffset(addr));
public override int Peek8000(int addr)
{
addr &= 0x1FFF;
return _banksA[addr | _bankOffset];
}
public override int PeekA000(int addr)
{
addr &= 0x1FFF;
return _banksB[addr | _bankOffset];
}
public override int PeekA000(int addr) =>
_chipB.Peek(CalculateBankOffset(addr));
public override int PeekDE00(int addr)
{
@ -127,9 +156,15 @@ namespace BizHawk.Emulation.Cores.Computers.Commodore64.Cartridge
// but Peek is provided here for debug reasons
// and may not stay around
addr &= 0x02;
return addr == 0x00 ? _bankOffset >> 13 : _stateBits;
return addr == 0x00 ? _bankNumber : _stateBits;
}
public override void Poke8000(int addr, int val) =>
_chipA.Poke(addr, val);
public override void PokeA000(int addr, int val) =>
_chipB.Poke(addr, val);
public override int PeekDF00(int addr)
{
addr &= 0xFF;
@ -141,7 +176,7 @@ namespace BizHawk.Emulation.Cores.Computers.Commodore64.Cartridge
addr &= 0x02;
if (addr == 0x00)
{
BankSet(val);
_bankNumber = val & 0x3F;
}
else
{
@ -152,18 +187,14 @@ namespace BizHawk.Emulation.Cores.Computers.Commodore64.Cartridge
public override void PokeDF00(int addr, int val)
{
addr &= 0xFF;
_ram[addr] = val & 0xFF;
_ram[addr] = unchecked((byte)val);
}
public override int Read8000(int addr)
{
return ReadInternal(addr & 0x1FFF, _banksA);
}
public override int Read8000(int addr) =>
_chipA.Read(CalculateBankOffset(addr));
public override int ReadA000(int addr)
{
return ReadInternal(addr & 0x1FFF, _banksB);
}
public override int ReadA000(int addr) =>
_chipB.Read(CalculateBankOffset(addr));
public override int ReadDF00(int addr)
{
@ -171,33 +202,6 @@ namespace BizHawk.Emulation.Cores.Computers.Commodore64.Cartridge
return _ram[addr];
}
private int ReadInternal(int addr, int[] bank)
{
switch (_internalRomState)
{
case 0x80:
break;
case 0x90:
switch (addr & 0x1FFF)
{
case 0x0000:
return 0x01;
case 0x0001:
return 0xA4;
case 0x0002:
return 0x00;
}
break;
case 0xA0:
break;
case 0xF0:
break;
}
return bank[addr | _bankOffset];
}
private void StateSet(int val)
{
_stateBits = val &= 0x87;
@ -212,83 +216,23 @@ namespace BizHawk.Emulation.Cores.Computers.Commodore64.Cartridge
pinExRom = (val & 0x02) == 0;
_boardLed = (val & 0x80) != 0;
_internalRomState = 0;
DriveLightOn = _boardLed;
}
public override void Write8000(int addr, int val)
{
WriteInternal(addr, val);
if (pinGame || !pinExRom)
return;
_chipA.Write(CalculateBankOffset(addr), val);
}
public override void WriteA000(int addr, int val)
{
WriteInternal(addr | 0x2000, val);
}
private void WriteInternal(int addr, int val)
{
if (pinGame || !pinExRom)
{
return;
}
if (val == 0xF0) // any address, resets flash
{
_internalRomState = 0;
_commandLatch55 = false;
_commandLatchAa = false;
}
else if (_internalRomState != 0x00 && _internalRomState != 0xF0)
{
switch (_internalRomState)
{
case 0xA0:
if ((addr & 0x2000) == 0)
{
addr &= 0x1FFF;
_banksA[addr | _bankOffset] = val & 0xFF;
}
else
{
addr &= 0x1FFF;
_banksB[addr | _bankOffset] = val & 0xFF;
}
break;
}
}
else if (addr == 0x0555) // $8555
{
if (!_commandLatchAa)
{
if (val == 0xAA)
{
_commandLatch55 = true;
}
}
else
{
// process EZF command
_internalRomState = val;
}
}
else if (addr == 0x02AA) // $82AA
{
if (_commandLatch55 && val == 0x55)
{
_commandLatchAa = true;
}
else
{
_commandLatch55 = false;
}
}
else
{
_commandLatch55 = false;
_commandLatchAa = false;
}
_chipB.Write(CalculateBankOffset(addr), val);
}
public override void WriteDE00(int addr, int val)
@ -296,7 +240,7 @@ namespace BizHawk.Emulation.Cores.Computers.Commodore64.Cartridge
addr &= 0x02;
if (addr == 0x00)
{
BankSet(val);
_bankNumber = val & 0x3F;
}
else
{
@ -306,7 +250,65 @@ namespace BizHawk.Emulation.Cores.Computers.Commodore64.Cartridge
public override void WriteDF00(int addr, int val)
{
_ram[addr] = val & 0xFF;
_ram[addr] = unchecked((byte)val);
}
public override void ExecutePhase()
{
_chipA.Clock();
_chipB.Clock();
_saveRamDirty |= _chipA.IsDataDirty | _chipB.IsDataDirty;
}
public override IEnumerable<MemoryDomain> CreateMemoryDomains()
{
yield return _chipA.CreateMemoryDomain("EF LoROM");
yield return _chipB.CreateMemoryDomain("EF HiROM");
yield return new MemoryDomainByteArray(
name: "EF RAM",
endian: MemoryDomain.Endian.Little,
data: _ram,
writable: true,
wordSize: 1
);
}
public byte[] CloneSaveRam()
{
FlushSaveRam();
using var result = new MemoryStream();
using var writer = new BinaryWriter(result);
writer.Write(_deltaA.Length);
writer.Write(_deltaA);
writer.Write(_deltaB.Length);
writer.Write(_deltaB);
writer.Flush();
_saveRamDirty = false;
return result.ToArray();
}
/// <summary>
/// Applies a SaveRam block to the flash memory.
/// </summary>
public void StoreSaveRam(byte[] data)
{
using var stream = new MemoryStream(data);
using var reader = new BinaryReader(stream);
var deltaASize = reader.ReadInt32();
_deltaA = reader.ReadBytes(deltaASize);
var deltaBSize = reader.ReadInt32();
_deltaB = reader.ReadBytes(deltaBSize);
DeltaSerializer.ApplyDelta(_originalMediaA, _chipA.Data, _deltaA);
DeltaSerializer.ApplyDelta(_originalMediaB, _chipB.Data, _deltaB);
_saveRamDirty = false;
}
public bool SaveRamModified => _saveRamDirty;
}
}