diff --git a/BlazorBoy.sln b/BlazorBoy.sln index c70bd8f..cf4385f 100644 --- a/BlazorBoy.sln +++ b/BlazorBoy.sln @@ -13,6 +13,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Gameboy.Player.Cli", "Gameb EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Gameboy.Player.Blazor", "Gameboy.Player.Blazor\BlazorBoy.Player.csproj", "{C05C9750-5DA4-467F-AE0D-B14330885D87}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Gameboy.Database", "Gameboy.Database\Gameboy.Database.csproj", "{D85FB21E-22FE-4C1D-B8D3-2104E2F7CF44}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -42,5 +44,9 @@ Global {C05C9750-5DA4-467F-AE0D-B14330885D87}.Debug|Any CPU.Build.0 = Debug|Any CPU {C05C9750-5DA4-467F-AE0D-B14330885D87}.Release|Any CPU.ActiveCfg = Release|Any CPU {C05C9750-5DA4-467F-AE0D-B14330885D87}.Release|Any CPU.Build.0 = Release|Any CPU + {D85FB21E-22FE-4C1D-B8D3-2104E2F7CF44}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D85FB21E-22FE-4C1D-B8D3-2104E2F7CF44}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D85FB21E-22FE-4C1D-B8D3-2104E2F7CF44}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D85FB21E-22FE-4C1D-B8D3-2104E2F7CF44}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal diff --git a/Gameboy.Database/GameDatabase.cs b/Gameboy.Database/GameDatabase.cs new file mode 100644 index 0000000..91078ab --- /dev/null +++ b/Gameboy.Database/GameDatabase.cs @@ -0,0 +1,60 @@ +using System.Collections; +using System.Reflection; +using System.Text.Json.Serialization; + +namespace Qkmaxware.Emulators.Gameboy; + +public class GameInfo { + [JsonPropertyName("title")] + public string? CartTitle {get; set;} + [JsonPropertyName("name")] + public string? Name {get; set;} + [JsonPropertyName("boxart")] + public string? BoxArtUrl {get; set;} + [JsonPropertyName("description")] + public string? Description {get; set;} + [JsonPropertyName("genres")] + public string[]? Genres {get; set;} + [JsonPropertyName("released")] + public int ReleaseYear {get; set;} + [JsonPropertyName("developer")] + public string? DeveloperName {get; set;} + [JsonPropertyName("publisher")] + public string? PublisherName {get; set;} +} + +public class GameDatabase : IEnumerable { + + private List all = new List(); + + private GameDatabase() { + var assembly = typeof(GameDatabase).GetTypeInfo().Assembly; + foreach (var name in assembly.GetManifestResourceNames()) { + if (name == ("Gameboy.Database.database.json")) { + Stream? resource = assembly.GetManifestResourceStream(name); + if (resource is null) + continue; + + var records = System.Text.Json.JsonSerializer.Deserialize>(resource); + if (records is null) + break; + this.all = records; + } + } + } + + private static GameDatabase? instance; + public static GameDatabase Instance() { + if (instance is null) + instance = new GameDatabase(); + return instance; + } + + public IEnumerator GetEnumerator() { + return all.GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() { + return all.GetEnumerator(); + } +} diff --git a/Gameboy.Database/Gameboy.Database.csproj b/Gameboy.Database/Gameboy.Database.csproj new file mode 100644 index 0000000..890d906 --- /dev/null +++ b/Gameboy.Database/Gameboy.Database.csproj @@ -0,0 +1,13 @@ + + + + net6.0 + enable + enable + + + + + + + diff --git a/Gameboy.Database/database.json b/Gameboy.Database/database.json new file mode 100644 index 0000000..706d5fc --- /dev/null +++ b/Gameboy.Database/database.json @@ -0,0 +1,28 @@ +[ + { + "title": "TETRIS", + "name": "Tetris", + "boxart": "https://upload.wikimedia.org/wikipedia/en/thumb/4/4a/Tetris_Boxshot.jpg/220px-Tetris_Boxshot.jpg", + "description": "A portable version of Alexey Pajitnov's original tetris for the Nintendo Game Boy.", + "genres": [ + "Puzzle" + ], + "released": 1989, + "developer": "Nintendo R&D1", + "publisher": "Nintendo" + }, + { + "title": "POKEMON BLUE", + "name": "Pokémon Blue", + "boxart": "https://archives.bulbagarden.net/media/upload/thumb/5/5a/Blue_EN_boxart.png/250px-Blue_EN_boxart.png", + "description": "Along with Pokémon Red, Pokémon Blue was one of the first installments of the Pokémon series.", + "genres": [ + "Role-playing", + "Adventure", + "Turn-based strategy" + ], + "released": 1996, + "developer": "Game Freak", + "publisher": "Nintendo" + } +] \ No newline at end of file diff --git a/Gameboy.Player.Blazor/BlazorBoy.Player.csproj b/Gameboy.Player.Blazor/BlazorBoy.Player.csproj index 4b7c932..6b5c70b 100644 --- a/Gameboy.Player.Blazor/BlazorBoy.Player.csproj +++ b/Gameboy.Player.Blazor/BlazorBoy.Player.csproj @@ -13,6 +13,7 @@ + diff --git a/Gameboy.Player.Blazor/Shared/CartridgeInfo.razor b/Gameboy.Player.Blazor/Shared/CartridgeInfo.razor index 22de8d7..c553035 100644 --- a/Gameboy.Player.Blazor/Shared/CartridgeInfo.razor +++ b/Gameboy.Player.Blazor/Shared/CartridgeInfo.razor @@ -2,21 +2,32 @@ @using Qkmaxware.Emulators.Gameboy.Hardware @if (Header is not null) { +
- + +
Title @Header.title@(dbInfo?.Name ?? Header.title)
+ @if (dbInfo?.BoxArtUrl is not null) { + @dbInfo.BoxArtUrl + } + + @if (dbInfo is not null) { - + - + - + + + } + + @@ -29,10 +40,28 @@
Region @Header.regionDeveloper @dbInfo.DeveloperName
Manufacturer @Header.manufacturerCodePublisher @dbInfo.PublisherName
Licencee @Header.licenceeReleased @dbInfo.ReleaseYear
Region @Header.region
Cart Type @Header.cartType.MBC
+
} @code { - [Parameter] public Cartridge? Cart {get; set;} + + private Cartridge? cart; + private GameInfo? dbInfo; + #pragma warning disable BL0007 + [Parameter] public Cartridge? Cart{ + get => cart; + set { + if (value != cart) { + this.cart = value; + if (value is not null) { + this.dbInfo = GameDatabase.Instance().Where(x => x.CartTitle == value.Info.title).FirstOrDefault(); + } else { + this.dbInfo = null; + } + } + } + } + #pragma warning restore BL0007 public CartridgeHeader? Header => Cart?.Info; } \ No newline at end of file diff --git a/Gameboy.Player.Blazor/Shared/CartridgeInfo.razor.css b/Gameboy.Player.Blazor/Shared/CartridgeInfo.razor.css index 708410c..953e918 100644 --- a/Gameboy.Player.Blazor/Shared/CartridgeInfo.razor.css +++ b/Gameboy.Player.Blazor/Shared/CartridgeInfo.razor.css @@ -1,11 +1,20 @@ -table { +div { background-color: rgba(0, 0, 0, 0.5); color: white; padding: 12px; border-radius: 12px; width: 100%; } +table { + width: 100%; +} table th { text-align: left; +} + +img { + width: 100%; + max-height: 240px; + object-fit: contain; } \ No newline at end of file diff --git a/Gameboy.Player.Godot/FpsCounter.cs b/Gameboy.Player.Godot/FpsCounter.cs new file mode 100644 index 0000000..b47d436 --- /dev/null +++ b/Gameboy.Player.Godot/FpsCounter.cs @@ -0,0 +1,23 @@ +using Godot; +using System; + +namespace Qkmaxware.Emulators.Gameboy.Player; + +public partial class FpsCounter : Label { + [Export] public TextureRenderer Renderer {get; set;} + private int fps; + + // Called every frame. 'delta' is the elapsed time since the previous frame. + public override void _Process(double delta) { + if (Renderer is not null && Renderer.IsPlaying) { + var fps = (int)Math.Round(Godot.Engine.GetFramesPerSecond()); + if (this.fps != fps) { + // New string only if the FPS changed + this.Text = "FPS: " + fps; + this.fps = fps; + } + } else { + this.Text = string.Empty; + } + } +} diff --git a/Gameboy.Player.Godot/Gameboy.tscn b/Gameboy.Player.Godot/Gameboy.tscn index c095b53..1b5a888 100644 --- a/Gameboy.Player.Godot/Gameboy.tscn +++ b/Gameboy.Player.Godot/Gameboy.tscn @@ -1,10 +1,11 @@ -[gd_scene load_steps=7 format=3 uid="uid://bytv8v3jd4m0l"] +[gd_scene load_steps=8 format=3 uid="uid://bytv8v3jd4m0l"] [ext_resource type="Script" path="res://TextureRenderer.cs" id="1_hfp5s"] [ext_resource type="Texture2D" uid="uid://dfkwdfgk0dp8n" path="res://icon.svg" id="1_ptiq5"] [ext_resource type="Texture2D" uid="uid://cip0keh15srys" path="res://cart.png" id="3_gvrte"] [ext_resource type="Texture2D" uid="uid://byxxvldruom6j" path="res://play.png" id="4_lwet3"] [ext_resource type="Texture2D" uid="uid://cm332so38crqs" path="res://pause.png" id="5_5clxm"] +[ext_resource type="Script" path="res://FpsCounter.cs" id="5_rtd4o"] [ext_resource type="Texture2D" uid="uid://d0dpxbrvr2bo7" path="res://stop.png" id="6_cecoo"] [node name="Gameboy" type="Control"] @@ -61,6 +62,24 @@ focus_mode = 0 icon = ExtResource("4_lwet3") expand_icon = true +[node name="FPS" type="Label" parent="Top Right/Play" node_paths=PackedStringArray("Renderer")] +layout_mode = 1 +anchors_preset = 7 +anchor_left = 0.5 +anchor_top = 1.0 +anchor_right = 0.5 +anchor_bottom = 1.0 +offset_left = -22.5 +offset_top = -3.0 +offset_right = 22.5 +offset_bottom = 20.0 +grow_horizontal = 2 +grow_vertical = 0 +text = "FPS: 00" +horizontal_alignment = 1 +script = ExtResource("5_rtd4o") +Renderer = NodePath("../../../LCD") + [node name="Pause" type="Button" parent="Top Right"] layout_mode = 2 size_flags_horizontal = 3 diff --git a/Gameboy.Player.Godot/TextureRenderer.cs b/Gameboy.Player.Godot/TextureRenderer.cs index a059901..18818b7 100644 --- a/Gameboy.Player.Godot/TextureRenderer.cs +++ b/Gameboy.Player.Godot/TextureRenderer.cs @@ -6,12 +6,15 @@ using System.Linq; using System.IO; +namespace Qkmaxware.Emulators.Gameboy.Player; + public partial class TextureRenderer : TextureRect { public enum RendererState { Stopped, Paused, Playing } private RendererState State {get; set;} = RendererState.Stopped; + public bool IsPlaying => State == RendererState.Playing; [Export] [ExportGroup("Colour Pallet/Background")] @@ -47,11 +50,13 @@ public enum RendererState { private Image pixels; private ImageTexture texture; + private LcdBitmap intro; private LcdBitmap white; // Called when the node enters the scene tree for the first time. public override void _Ready() { var intro = new LcdBitmap(Gpu.LCD_WIDTH, Gpu.LCD_HEIGHT); + this.intro = intro; intro.Fill(ColourPallet.BackgroundWhite); white = new LcdBitmap(Gpu.LCD_WIDTH, Gpu.LCD_HEIGHT); @@ -153,8 +158,8 @@ public void Stop() { if (this.gb is not null && this.gb.IsCartridgeLoaded()) { this.State = RendererState.Stopped; this.gb.Reset(); - if (white is not null) { - Redraw(white); + if (intro is not null) { + Redraw(intro); } } } diff --git a/Gameboy.Player.Godot/icon.svg.import b/Gameboy.Player.Godot/icon.svg.import index eba893f..6ba6a52 100644 --- a/Gameboy.Player.Godot/icon.svg.import +++ b/Gameboy.Player.Godot/icon.svg.import @@ -32,6 +32,6 @@ process/hdr_as_srgb=false process/hdr_clamp_exposure=false process/size_limit=0 detect_3d/compress_to=1 -svg/scale=2.0 +svg/scale=8.0 editor/scale_with_editor_scale=false editor/convert_colors_with_editor_theme=false diff --git a/Gameboy.Player.Godot/project.godot b/Gameboy.Player.Godot/project.godot index 6ecc993..c01f6be 100644 --- a/Gameboy.Player.Godot/project.godot +++ b/Gameboy.Player.Godot/project.godot @@ -13,6 +13,7 @@ config_version=5 config/name="Gameboy.Player.Godot" run/main_scene="res://Gameboy.tscn" config/features=PackedStringArray("4.2", "C#", "GL Compatibility") +run/max_fps=60 boot_splash/bg_color=Color(0.141176, 0.141176, 0.141176, 1) config/icon="res://icon.svg" diff --git a/Gameboy.Test/DatabaseTest.cs b/Gameboy.Test/DatabaseTest.cs new file mode 100644 index 0000000..c07c8f0 --- /dev/null +++ b/Gameboy.Test/DatabaseTest.cs @@ -0,0 +1,12 @@ +using System.Reflection; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Qkmaxware.Emulators.Gameboy.Test; + +[TestClass] +public class GameDatabaseTest { + [TestMethod] + public void TestGameDatabaseNonEmpty() { + Assert.AreEqual(true, GameDatabase.Instance().Any()); + } +} \ No newline at end of file diff --git a/Gameboy.Test/Gameboy.Test.csproj b/Gameboy.Test/Gameboy.Test.csproj index 18b4106..5dc1e80 100644 --- a/Gameboy.Test/Gameboy.Test.csproj +++ b/Gameboy.Test/Gameboy.Test.csproj @@ -17,9 +17,10 @@ + - + diff --git a/Gameboy/GameBoy.cs b/Gameboy/GameBoy.cs index 236d245..3daac26 100644 --- a/Gameboy/GameBoy.cs +++ b/Gameboy/GameBoy.cs @@ -6,7 +6,7 @@ namespace Qkmaxware.Emulators.Gameboy; public class Gameboy { public Cpu CPU {get; init;} - public Gpu GPU {get; init;} + public IPpu GPU {get; init;} private MemoryMap mmu {get; init;} public Input Input {get; init;} private Hardware.Timer timer {get; init;} @@ -63,7 +63,7 @@ public void Reset(){ } public Cartridge? GetCartridge() { - return this.cart.LoadedCart(); + return this.cart.LoadedCart; } public void LoadCartridge(Cartridge cart){ @@ -98,4 +98,49 @@ public void DispatchUntilBufferFlush() { } } } + + public ConsoleState GetState() { + ConsoleState state = new ConsoleState(); + + CpuState cpu = new CpuState(); + state.Cpu = cpu; + cpu.A = this.CPU.Registry.a(); + cpu.B = this.CPU.Registry.b(); + cpu.C = this.CPU.Registry.c(); + cpu.D = this.CPU.Registry.d(); + cpu.E = this.CPU.Registry.e(); + cpu.F = this.CPU.Registry.f(); + cpu.Hi = this.CPU.Registry.h(); + cpu.Lo = this.CPU.Registry.l(); + cpu.Sp = this.CPU.Registry.sp(); + cpu.Pc = this.CPU.Registry.pc(); + cpu.Ime = this.CPU.Registry.ime(); + + CartState cart = new CartState(); + state.Cart = cart; + cart.RamBanks = this.cart?.ActiveController?.GetRamBanks()?.Select(bytes => Convert.ToBase64String(bytes))?.ToArray() ?? new string[0]; + + return state; + } + + public void SetState(ConsoleState state) { + if (state.Cpu is not null) { + this.CPU.Registry.a(state.Cpu.A); + this.CPU.Registry.b(state.Cpu.B); + this.CPU.Registry.c(state.Cpu.C); + this.CPU.Registry.d(state.Cpu.D); + this.CPU.Registry.e(state.Cpu.E); + this.CPU.Registry.f(state.Cpu.F); + this.CPU.Registry.h(state.Cpu.Hi); + this.CPU.Registry.l(state.Cpu.Lo); + this.CPU.Registry.sp(state.Cpu.Sp); + this.CPU.Registry.pc(state.Cpu.Pc); + this.CPU.Registry.ime(state.Cpu.Ime); + } + + if (state.Cart is not null) { + if (state.Cart.RamBanks is not null) + this.cart?.ActiveController?.UpdateRamBanks(state.Cart.RamBanks.Select(b64 => Convert.FromBase64String(b64))); + } + } } \ No newline at end of file diff --git a/Gameboy/State.cs b/Gameboy/State.cs new file mode 100644 index 0000000..8048116 --- /dev/null +++ b/Gameboy/State.cs @@ -0,0 +1,24 @@ +namespace Qkmaxware.Emulators.Gameboy; + +public class CpuState { + public int A {get; set;} + public int B {get; set;} + public int C {get; set;} + public int D {get; set;} + public int E {get; set;} + public int F {get; set;} + public int Hi {get; set;} + public int Lo {get; set;} + public int Sp {get; set;} + public int Pc {get; set;} + public int Ime {get; set;} +} + +public class CartState { + public string[]? RamBanks; +} + +public class ConsoleState { + public CpuState? Cpu {get; set;} + public CartState? Cart {get; set;} +} \ No newline at end of file diff --git a/Gameboy/hardware/game/Cartidge.cs b/Gameboy/hardware/game/Cartidge.cs index 4ce38fa..bfaec49 100644 --- a/Gameboy/hardware/game/Cartidge.cs +++ b/Gameboy/hardware/game/Cartidge.cs @@ -25,6 +25,10 @@ public bool HasRam(){ } public int read(int addr){ - return rom[addr]; + if (addr >= 0 && addr < rom.Length) { + return rom[addr]; + } else { + return 0; + } } } \ No newline at end of file diff --git a/Gameboy/hardware/game/CartridgeAdapter.cs b/Gameboy/hardware/game/CartridgeAdapter.cs index 7588d48..aab80da 100644 --- a/Gameboy/hardware/game/CartridgeAdapter.cs +++ b/Gameboy/hardware/game/CartridgeAdapter.cs @@ -4,25 +4,34 @@ public class CartridgeAdapter : IMemorySegment { private Cartridge? cart; private IMbc? controller; - public CartridgeAdapter() { + public CartridgeAdapter() { } - } - - public Cartridge? LoadedCart() => cart; + public Cartridge? LoadedCart => cart; + public IMbc? ActiveController => controller; public bool HasCart() => cart is not null && controller is not null; public void Reset() { - if(this.controller != null){ + if(this.controller is not null){ this.controller.Reset(); } } + /// + /// Load a cartridge into the console. + /// + /// cart to load + /// true if cart is loaded successfully, false otherwise + public bool LoadCart(Cartridge cart){ + this.cart = null; + this.controller = null; - public void LoadCart(Cartridge cart){ - this.cart = cart; - + bool recognized = true; + IMbc? controller = null; switch(cart.Info.cartType.MBC){ + case CartType.MBCtype.ROM: + controller = (IMbc) new NoMbc(cart); + break; case CartType.MBCtype.MBC1: controller = (IMbc) new Mbc1(cart); break; @@ -35,10 +44,19 @@ public void LoadCart(Cartridge cart){ case CartType.MBCtype.MBC5: controller = (IMbc) new Mbc5(cart); break; + case CartType.MBCtype.Unknown: default: - controller = (IMbc) new RomOnlyMbc(cart); + recognized = false; break; } + + if (recognized) { + this.cart = cart; + this.controller = controller; + return true; + } else { + return false; + } } public void LoadRam(params byte[][] banks) { @@ -76,13 +94,4 @@ public bool SupportsCGB(){ public void SetMMU(MemoryMap mmu) { } - public int ReadShort(int address) - { - throw new NotImplementedException(); - } - - public void WriteShort(int address, int value) - { - throw new NotImplementedException(); - } } \ No newline at end of file diff --git a/Gameboy/hardware/game/controller/BaseMbc.cs b/Gameboy/hardware/game/controller/BaseMbc.cs new file mode 100644 index 0000000..c552aaf --- /dev/null +++ b/Gameboy/hardware/game/controller/BaseMbc.cs @@ -0,0 +1,46 @@ +using Qkmaxware.Vm.LR35902; + +namespace Qkmaxware.Emulators.Gameboy.Hardware; + + +/// +/// Base class for MBC controllers +/// +public abstract class BaseMbc : IMbc { + + protected static readonly DataSize KiB_16 = DataSize.Kibibytes(16); + protected static readonly DataSize KiB_8 = DataSize.Kibibytes(8); + + protected static readonly int LO = 0; + protected static readonly int HI = 0xFF; + + protected Cartridge Cart {get; private set;} + + public BaseMbc(Cartridge cart) { + this.Cart = cart; + } + protected static bool between(int x, int lower, int upper) { + return lower <= x && x <= upper; + } + + public abstract void Reset(); + + public abstract byte[] GetActiveRam(); + public abstract IEnumerable GetRamBanks(); + public virtual void UpdateRamBanks(IEnumerable banks) { + // Reset the banks + foreach (var bank in this.GetRamBanks()) { + Array.Fill(bank, (byte)0); + } + + // For each bank with a cooresponding update bank provided, copy bytes from the update into the original + foreach (var pair in this.GetRamBanks().Zip(banks)) { + Array.Copy(pair.Second, pair.First, Math.Min(pair.First.Length, pair.Second.Length)); + } + } + + + public abstract int ReadByte(int addr); + public abstract void WriteByte(int addr, int value); + +} diff --git a/Gameboy/hardware/game/controller/IMbc.cs b/Gameboy/hardware/game/controller/IMbc.cs index 24027ce..77df5a0 100644 --- a/Gameboy/hardware/game/controller/IMbc.cs +++ b/Gameboy/hardware/game/controller/IMbc.cs @@ -3,36 +3,35 @@ namespace Qkmaxware.Emulators.Gameboy.Hardware; public interface IMbc : IResetable { - /** - * Get the offset value to use for ram access - * @return - */ - public int GetRamOffset(); - - /** - * Get the offset value to use for rom access - * @return - */ - public int GetRomOffset(); - - /** - * Get a reference to the ram array for this controller - * @return - */ + /// + /// Get a reference to the ram array for this controller + /// + /// active ram array public byte[] GetActiveRam(); + + /// + /// Get all RAM banks managed by this controller + /// + /// banks of RAM public IEnumerable GetRamBanks(); + + /// + /// Update the value of each ram bank to those provided + /// + /// banks of RAM + public void UpdateRamBanks(IEnumerable banks); - /** - * Read a byte from this controller - * @param addr - * @return - */ + /// + /// Read a byte from this controller + /// + /// address + /// byte public int ReadByte(int addr); - /** - * Write a value to this controller (triggers side-effects) - * @param addr - * @param value - */ + /// + /// Write a value to this controller (triggers side-effects) + /// + /// address to write to + /// byte to write public void WriteByte(int addr, int value); } \ No newline at end of file diff --git a/Gameboy/hardware/game/controller/Mbc1.cs b/Gameboy/hardware/game/controller/Mbc1.cs index 3a4e103..24502c6 100644 --- a/Gameboy/hardware/game/controller/Mbc1.cs +++ b/Gameboy/hardware/game/controller/Mbc1.cs @@ -1,129 +1,129 @@ +using Qkmaxware.Vm.LR35902; + namespace Qkmaxware.Emulators.Gameboy.Hardware; -public class Mbc1 : IMbc { - public static readonly int ERAM_SIZE = 32768; //32KB eram - private byte[] eram = new byte[ERAM_SIZE]; //External Cartridge RAM - - private bool ramEnabled = false; - private bool ramSelected = true; - - private int rambank = 0; - private int rombank = 1; - - private Cartridge cart; - - public Mbc1(Cartridge cart){ - this.cart = cart; - } +/// +/// MBC1 controller +/// https://gbdev.io/pandocs/MBC1.html +/// This is the first MBC chip for the Game Boy +/// +public class Mbc1 : BaseMbc { - public void Reset(){ - Array.Fill(eram, (byte)0); - - ramEnabled = false; - ramSelected = true; - - rambank = 0; - rombank = 1; - } - - private static bool between(int x, int lower, int upper) { - return lower <= x && x <= upper; - } + private List eram; + + #region Registers + private bool ramEnable; + private int lowerRomBankNumber = 1; + private int higherRomBankNumber = 0; + private int romBankIndex => (higherRomBankNumber << 5) + lowerRomBankNumber; + private int ramBankIndex; + private BankingModeSelect bankingModeSelect; + #endregion - public bool IsRamEnabled(){ - return this.ramEnabled; + enum BankingModeSelect { + Simple = 0, Advanced = 1 } - public int GetRamOffset() => rambank * 0x2000; + public Mbc1(Cartridge cart) : base(cart) { + this.eram = new List(); + for (var i = 0; i < Math.Max(1, cart.Info.eramClass.BankCount); i++) { + this.eram.Add(new byte[KiB_8.ByteCount]); + } + } - public int GetRomOffset() => rombank * 0x4000; + public override void Reset() { + ramEnable = false; + lowerRomBankNumber = 1; + higherRomBankNumber = 0; + ramBankIndex = 0; + bankingModeSelect = BankingModeSelect.Simple; + foreach (var bank in eram) { + Array.Fill(bank, (byte)0); + } + } - public byte[] GetActiveRam() => this.eram; + public override byte[] GetActiveRam() { + return this.eram[ramBankIndex]; + } - public IEnumerable GetRamBanks() { - yield return GetActiveRam(); + public override IEnumerable GetRamBanks() { + foreach (var bank in this.eram) + yield return bank; } - public int ReadByte(int addr) { - //Create the appropriate offsets if required - int romoff = GetRomOffset(); //Rom bank 1 - int ramoff = GetRamOffset(); - - if(between(addr, 0, 0x3FFF)){ - //Cartridge ROM (fixed) (rom bank 0) - if(cart == null) - return 0; - return cart.read(addr); + public override int ReadByte(int addr) { + // 0000–3FFF — ROM Bank X0 (Read Only) + if (between(addr, 0x0000, 0x3FFF)) { + // This area normally contains the first 16 KiB (bank 00) of the cartridge ROM. + if (Cart is null) + return LO; + return Cart.read(addr); } - else if(between(addr, 0x4000, 0x7FFF)){ - //Cartridge ROM (switchable) (rom bank 1) - if(cart == null) - return 0; - return cart.read((romoff + (addr - 0x4000))); + + // 4000–7FFF — ROM Bank 01-7F + if (between(addr, 0x4000, 0x7FFF)) { + if (Cart is null) + return LO; + var offset = romBankIndex * KiB_16.ByteCount; + return Cart.read(offset + (addr - 0x4000)); } - else if(between(addr, 0xA000, 0xBFFF)){ - //External cartridge RAM - if(!ramSelected){ - return eram[addr - 0xA000]; - }else{ - return eram[(addr - 0xA000) + ramoff]; + + // A000–BFFF — RAM Bank 00–03 + if (between(addr, 0xA000, 0xBFFF)) { + if (!ramEnable) { + return 0xFF; // Otherwise reads return open bus values (often $FF, but not guaranteed) } + return eram[ramBankIndex][addr - 0xA000]; } + return 0; } - public void WriteByte(int addr, int value) { - //Create the appropriate offsets if required - int romoff = GetRomOffset(); //Rom bank 1 - int ramoff = GetRamOffset(); - - this.HasOccurredWrite(addr, value); - - if(between(addr, 0xA000, 0xBFFF)){ - //External cartridge RAM - if(!ramSelected){ - eram[addr - 0xA000] = (byte)value; - }else{ - eram[(addr - 0xA000) + ramoff] = (byte)value; + public override void WriteByte(int addr, int value) + { + // 0000–1FFF — RAM Enable (Write Only) + if (between(addr, 0x0000, 0x1FFF)) { + this.ramEnable = (value & 0xF) == 0xA; + } + // 2000–3FFF — ROM Bank Number (Write Only) + if (between(addr, 0x2000, 0x3FFF)) { + var desiredBank = value & 0b11111; // Last 5 bits only + if (desiredBank == 0) + desiredBank = 1; // cannot duplicate bank $00 into both the 0000–3FFF and 4000–7FFF + // If the ROM Bank Number is set to a higher value than the number of banks in the cart, + // the bank number is masked to the required number of bits + // TODO + this.lowerRomBankNumber = desiredBank; + } + // 4000–5FFF — RAM Bank Number (Write Only) + if (between(addr, 0x4000, 0x5FFF)) { + if (this.bankingModeSelect == BankingModeSelect.Simple) { + // This second 2-bit register can be used to select a RAM Bank in range from $00–$03 + this.ramBankIndex = value & 0b11; + } else { + // or to specify the upper two bits (bits 5-6) of the ROM Bank number + this.higherRomBankNumber = (value & 0b110000) >> 5; } + } + // 6000–7FFF — Banking Mode Select (Write Only) + if (between(addr, 0x6000, 0x7FFF)) { + /* + This 1-bit register selects between the two MBC1 banking modes, + controlling the behaviour of the secondary 2-bit banking register (above). + If the cart is not large enough to use the 2-bit register (≤ 8 KiB RAM and ≤ 512 KiB ROM) + this mode select has no observable effect. + The program may freely switch between the two modes at any time. + */ + this.bankingModeSelect = (BankingModeSelect)(value & 0b1); } - } - public void HasOccurredWrite(int addr, int value){ - if(addr >= 0x0000 && addr <= 0x1FFF){ - //Enable RAM. Any Value with 0x0AH in the lower 4 bits enables ram, other values disable ram - ramEnabled = (value & 0x0F) == 0x0A; - }else if(addr >= 0x2000 && addr <= 0x3FFF){ - if(!ramSelected){ - //If rammode not selected, this represents the lower 5 bits, preserve the upper 5 bits - rombank = (value & 0x1F) | ((rombank >> 5) << 5); - }else{ - rombank = value & 0x1F; - } - - //Never select 0th rombank - if(rombank == 0x00 || rombank == 0x20 || rombank == 0x40 || rombank == 0x60){ - rombank++; - } - - rombank &= (cart.Info.romClass.BankCount - 1); - - }else if(addr >= 0x4000 && addr <= 0x5FFF){ - //This 2 bit register can be used to select a ram bank in the range 00-03 or specify the upper 2 bits of the bank number - //This behavior depends on the ROM/RAM mode select - if(!ramSelected){ - rombank = (rombank & 0x1F) | ((value & 3) << 5); //Set upper 2 bits - rombank &= (cart.Info.romClass.BankCount - 1); - - }else{ - rambank = value & 3; //Set rambank number - rambank &= (cart.Info.eramClass.BankCount - 1); + // A000–BFFF — RAM Bank 00–03 + if (between(addr, 0xA000, 0xBFFF)) { + if (!ramEnable) { + eram[0][addr - 0xA000] = (byte)value; } + eram[ramBankIndex][addr - 0xA000] = (byte)value; } - else if(addr >= 6000 && addr <= 0x7FFF){ - //This one bit register selects whether the two bits above should be used as the upper two bits of the rom bank - //or as the ram bank number - ramSelected = (value & 0x1) != 0; //Ram banking mode, else Rom banking mode - } + } } \ No newline at end of file diff --git a/Gameboy/hardware/game/controller/NoMbc.cs b/Gameboy/hardware/game/controller/NoMbc.cs new file mode 100644 index 0000000..43b9925 --- /dev/null +++ b/Gameboy/hardware/game/controller/NoMbc.cs @@ -0,0 +1,66 @@ +using Qkmaxware.Vm.LR35902; + +namespace Qkmaxware.Emulators.Gameboy.Hardware; + +/// +/// No MBC controller (ROM Only) +/// https://gbdev.io/pandocs/nombc.html +/// Small games of no more than 32KiB ROM do not require an MBC chip. +/// +public class NoMbc : BaseMbc { + + private static readonly DataSize MaxRamSize = DataSize.Kibibytes(8); + private byte[] eram = new byte[MaxRamSize.ByteCount]; + private bool usedOptionalRam = false; + + public NoMbc(Cartridge cart) : base(cart) {} + + public override void Reset() { + // Copy from the cart, to the optional RAM + for (var i = 0xA000; i < 0xBFFF; i++) { + eram[i - 0xA000] = (byte)Cart.read(i); + } + usedOptionalRam = false; + } + + public override byte[] GetActiveRam() => eram; + public override IEnumerable GetRamBanks() { + yield return GetActiveRam(); + } + + public override int ReadByte(int addr) { + // The ROM is directly mapped to memory at $0000-7FFF + if(between(addr, 0, 0x9FFF)){ + if(Cart is null) + return LO; + return Cart.read(addr); + } + // Optionally up to 8 KiB of RAM could be connected at $A000-BFFF + else if (between(addr, 0xA000, 0xBFFF)) { + return eram[addr - 0xA000]; // is a duplicate of the cart due to the reset procedure + } + // The ROM is directly mapped to memory at $0000-7FFF + else if (between(addr, 0xC000, 0x7FFF)) { + if(Cart is null) + return LO; + return Cart.read(addr); + } + return 0; + } + + public override void WriteByte(int addr, int value) { + // The ROM is directly mapped to memory at $0000-7FFF + if(between(addr, 0, 0x9FFF)){ + // Read only + } + // Optionally up to 8 KiB of RAM could be connected at $A000-BFFF + else if (between(addr, 0xA000, 0xBFFF)) { + usedOptionalRam = true; + eram[addr - 0xA000] = (byte)value; + } + // The ROM is directly mapped to memory at $0000-7FFF + else if (between(addr, 0xC000, 0x7FFF)) { + // Read only + } + } +} \ No newline at end of file diff --git a/Gameboy/hardware/game/controller/RomOnly.cs b/Gameboy/hardware/game/controller/RomOnly.cs deleted file mode 100644 index e37468e..0000000 --- a/Gameboy/hardware/game/controller/RomOnly.cs +++ /dev/null @@ -1,66 +0,0 @@ -using Qkmaxware.Vm.LR35902; - -namespace Qkmaxware.Emulators.Gameboy.Hardware; - -public class RomOnlyMbc : IMbc { - - public static readonly int ERAM_SIZE = 32768; //32KB eram - private byte[] eram = new byte[ERAM_SIZE]; //External Cartridge RAM - - private Cartridge cart; - - public RomOnlyMbc(Cartridge cart){ - this.cart = cart; - } - - public void Reset() { - Array.Fill(eram, (byte)0); - } - - public byte[] GetActiveRam() => eram; - public IEnumerable GetRamBanks() { - yield return GetActiveRam(); - } - - public int GetRamOffset() => 0; - - public int GetRomOffset() => 0x4000; - - private static bool between(int x, int lower, int upper) { - return lower <= x && x <= upper; - } - - public int ReadByte(int addr) { - int romoff = GetRomOffset(); //Rom bank 1 - int ramoff = GetRamOffset(); - - if(between(addr, 0, 0x3FFF)){ - //Cartridge ROM (fixed) (rom bank 0) - if(cart == null) - return 0; - return cart.read(addr); - } - else if(between(addr, 0x4000, 0x7FFF)){ - //Cartridge ROM (switchable) (rom bank 1) - if(cart == null) - return 0; - return cart.read((romoff + (addr&0x3FFF))); - } - else if(between(addr, 0xA000, 0xBFFF)){ - //External cartridge RAM - return eram[ramoff + (addr&0x1FFF)]; //eram[ramoffs+(addr&0x1FFF)]; - } - return 0; - } - - public void WriteByte(int addr, int value) { - //Create the appropriate offsets if required - int romoff = GetRomOffset(); //Rom bank 1 - int ramoff = GetRamOffset(); - - if(between(addr, 0xA000, 0xBFFF)){ - //External cartridge RAM - eram[ramoff + (addr&0x1FFF)] = (byte)value; //eram[ramoffs+(addr&0x1FFF)]; - } - } -} \ No newline at end of file diff --git a/Gameboy/hardware/gpu/Bitmap.cs b/Gameboy/hardware/gpu/Bitmap.cs index d3f36d4..299a463 100644 --- a/Gameboy/hardware/gpu/Bitmap.cs +++ b/Gameboy/hardware/gpu/Bitmap.cs @@ -24,16 +24,34 @@ public Bitmap(ColourPallet[,] pixels) { } public ColourPallet this[int x, int y] { - get => (ColourPallet)pixels[y * this.Width + x]; - set => pixels[y * this.Width + x] = (byte)value; + get { + if (!IsValidCoordinate(x, y)) { + return ColourPallet.BackgroundDark; + } + return (ColourPallet)pixels[y * this.Width + x]; + } + set { + if (!IsValidCoordinate(x, y)) { + return; + } + pixels[y * this.Width + x] = (byte)value; + } } public void Fill(ColourPallet colour) { Array.Fill(this.pixels, (byte)colour); } + public bool IsValidRow(int row) { + return row >= 0 && row < this.Height; + } + + public bool IsValidColumn(int column) { + return column >= 0 && column < this.Width; + } + public bool IsValidCoordinate(int x, int y) { - return x >= 0 && x < this.Width && y >= 0 && y < this.Height; + return IsValidColumn(x) && IsValidRow(y); } public void DrawRect(int xOrig, int yOrig, int width, int height, ColourPallet colour) { diff --git a/Gameboy/hardware/gpu/Gpu.cs b/Gameboy/hardware/gpu/Gpu.cs index 65eb167..2857547 100644 --- a/Gameboy/hardware/gpu/Gpu.cs +++ b/Gameboy/hardware/gpu/Gpu.cs @@ -5,7 +5,7 @@ namespace Qkmaxware.Emulators.Gameboy.Hardware; /// /// Conversion of https://github.com/qkmaxware/GBemu/blob/master/src/gameboy/gpu/Gpu.java /// -public class Gpu : IMemorySegment { +public class Gpu : IPpu { public static readonly int VRAM_SIZE = 8192; public static readonly int OAM_COUNT = 40; @@ -475,6 +475,7 @@ public void flushBuffer(){ var temp = this.buffer; this.buffer = Canvas; this.Canvas = temp; + this.buffer.Fill(ColourPallet.BackgroundWhite); // Swap is faster than actually copying pixels /*for(int x = 0; x < Canvas.Width; x++){ for(int y = 0; y < Canvas.Height; y++){ diff --git a/Gameboy/hardware/gpu/IPpu.cs b/Gameboy/hardware/gpu/IPpu.cs new file mode 100644 index 0000000..5a3535d --- /dev/null +++ b/Gameboy/hardware/gpu/IPpu.cs @@ -0,0 +1,24 @@ +namespace Qkmaxware.Emulators.Gameboy.Hardware; + +/// +/// Picture processing unit interface +/// +public interface IPpu : IMemorySegment { + /// + /// Canvas containing the rendered image + /// + public Bitmap Canvas {get;} + + #region Flags + /// + /// Test if the PPU's buffer has flushed last step + /// + public bool HasBufferJustFlushed {get;} + #endregion + + /// + /// Perform a single GPU step + /// + /// cpu cycles + public void Step(int step); +} \ No newline at end of file