diff --git a/.gitignore b/.gitignore
index 3e759b7..6ada180 100644
--- a/.gitignore
+++ b/.gitignore
@@ -171,6 +171,8 @@ publish/
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
*.publishproj
+Platforms/ZXBox.Blazor/wwwroot/Roms/CURRAH.ROM
+Platforms/ZXBox.Blazor/wwwroot/Roms/SP0256-AL2.BIN
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
diff --git a/Platforms/ZXBox.Blazor/Components/EmulatorComponent.razor b/Platforms/ZXBox.Blazor/Components/EmulatorComponent.razor
index ca111bc..7c62e2d 100644
--- a/Platforms/ZXBox.Blazor/Components/EmulatorComponent.razor
+++ b/Platforms/ZXBox.Blazor/Components/EmulatorComponent.razor
@@ -7,39 +7,339 @@
@inject Toolbelt.Blazor.Gamepad.GamepadList GamePadList;
@inherits EmulatorComponentModel
-@* *@
+
@if (gameLoop.Enabled)
{
-
- Load a game (supported formats: TAP, Z80, and SNA)
-
- Use a gamepad for Kempston joystick
- @((MarkupString)Instructions)
-
LoadGame("ManicMiner.z80","left - q right - w jump - space"))">Load Manic Miner (1983)(Bug-Byte Software)
- @if (tapePlayer.EarValues.Count > 0)
+
+ @if (IsSettingsOpen)
{
- @if (!tapePlayer.IsPlaying)
- {
-
Start tape
- }
- else
- {
-
-
-
- }
+
}
+
+
+
+
}
else
{
-
- Choose the computer you want to run
-
StartZXSpectrum(RomEnum.ZXSpectrum48k))">
-
StartZXSpectrum(RomEnum.ZXSpectrumPlus))">
+
+
+
Choose the computer you want to run
+
+
}
diff --git a/Platforms/ZXBox.Blazor/Components/EmulatorComponent.razor.cs b/Platforms/ZXBox.Blazor/Components/EmulatorComponent.razor.cs
index 442df52..c2a8bd6 100644
--- a/Platforms/ZXBox.Blazor/Components/EmulatorComponent.razor.cs
+++ b/Platforms/ZXBox.Blazor/Components/EmulatorComponent.razor.cs
@@ -8,8 +8,10 @@
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
+using System.Net;
using System.Net.Http;
using System.Runtime.InteropServices;
+using System.Threading;
using System.Threading.Tasks;
using System.Timers;
using ZXBox.Core.Hardware.Input;
@@ -17,12 +19,66 @@
using ZXBox.Hardware.Input.Joystick;
using ZXBox.Hardware.Output;
using ZXBox.Snapshot;
+using ZXBox.Core.Tape;
namespace ZXBox.Blazor.Pages
{
public partial class EmulatorComponentModel : ComponentBase, IAsyncDisposable
{
- private ZXSpectrum speccy;
+ private const float BeeperMixGain = 0.45f;
+ private const float SpeechMixGain = 6.0f;
+ private const float AyMixGain = 0.35f;
+ private const float ConsumerTvCurvature = 0.07f;
+ private const float ConsumerTvScanlineStrength = 0.24f;
+ private const float ConsumerTvVignetteStrength = 0.16f;
+ private const float ConsumerTvSaturation = 0.92f;
+ private const float ConsumerTvBrightness = 1.04f;
+ private const int PrinterDisplayScale = 3;
+ private const uint PrinterPaperColor = 0xFFB8BCC0;
+ private const uint PrinterInkColor = 0xFF1F1F1F;
+ private const string CurrahRomAssetPath = "Roms/CURRAH.ROM";
+ private const string Sp0256RomAssetPath = "Roms/SP0256-AL2.BIN";
+ private const string ConsumerTvShaderSource = @"
+uniform shader content;
+uniform float2 outputSize;
+uniform float2 inputSize;
+uniform float curvature;
+uniform float scanlineStrength;
+uniform float vignetteStrength;
+uniform float saturation;
+uniform float brightness;
+
+half4 main(float2 fragCoord)
+{
+ float2 uv = fragCoord / outputSize;
+ float2 centered = uv * 2.0 - 1.0;
+ float radius2 = dot(centered, centered);
+ centered *= 1.0 + curvature * radius2;
+ float2 warpedUv = centered * 0.5 + 0.5;
+
+ if (warpedUv.x < 0.0 || warpedUv.y < 0.0 || warpedUv.x > 1.0 || warpedUv.y > 1.0)
+ {
+ return half4(0.0, 0.0, 0.0, 1.0);
+ }
+
+ float2 sampleCoord = warpedUv * inputSize;
+ half3 color = content.eval(sampleCoord).rgb;
+
+ float sourceY = warpedUv.y * inputSize.y;
+ float beamPosition = abs(fract(sourceY) - 0.5) * 2.0;
+ float scanline = 1.0 - scanlineStrength * beamPosition * beamPosition;
+ color *= clamp(scanline, 0.0, 1.0);
+
+ float vignette = 1.0 - vignetteStrength * radius2;
+ color *= vignette * brightness;
+
+ float greyscale = dot(color, half3(0.299, 0.587, 0.114));
+ color = mix(half3(greyscale, greyscale, greyscale), color, saturation);
+
+ return half4(color, 1.0);
+}";
+ private static readonly SKSamplingOptions ConsumerTvSampling = new(SKFilterMode.Linear);
+ public ZXSpectrum speccy;
public System.Timers.Timer gameLoop;
int flashcounter = 16;
bool flash = false;
@@ -31,6 +87,14 @@ public partial class EmulatorComponentModel : ComponentBase, IAsyncDisposable
Beeper
beeper;
public TapePlayer tapePlayer;
public SKCanvasView _canvasView;
+ public SKCanvasView _printerCanvasView;
+ private SKBitmap _printerBitmap = new(1, 1);
+ private uint[] _printerPixels = new uint[1];
+ private SKRuntimeEffect _consumerTvEffect;
+ private SKRuntimeShaderBuilder _consumerTvShaderBuilder;
+ private string _consumerTvShaderError = string.Empty;
+ private int _lastPrinterVersion = -1;
+ private int _lastPrinterHeight;
[Inject]
Toolbelt.Blazor.Gamepad.GamepadList GamePadList { get; set; }
@@ -54,42 +118,101 @@ public void StartZXSpectrum(RomEnum rom)
{
speccy = GetZXSpectrum(rom);
speccy.InputHardware.Add(Keyboard);
+ IsSettingsOpen = false;
+ _lastPrinterVersion = -1;
+ _lastPrinterHeight = 0;
kempston = new Kempston();
speccy.InputHardware.Add(kempston);
//48000 samples per second, 50 frames per second (20ms per frame) Mono
- beeper = new Beeper(0, 127, 48000 / 50, 1);
+ beeper = new Beeper(0, 127, 48000 / 50, 1, speccy.FrameTStates);
speccy.OutputHardware.Add(beeper);
tapePlayer = new(beeper);
speccy.InputHardware.Add(tapePlayer);
mono = JSRuntime as WebAssemblyJSRuntime;
+ flashcounter = 16;
+ flash = false;
speccy.Reset();
gameLoop.Start();
+
+ if (ConsumerTvShaderEnabled)
+ {
+ EnsureConsumerTvShader();
+ }
}
+ public bool IsSettingsOpen { get; set; } = true;
+ public bool ConsumerTvShaderEnabled { get; set; }
+ public string ConsumerTvShaderError => _consumerTvShaderError;
+
public string TapeName { get; set; }
+
+ public void OpenSettingsPanel()
+ {
+ IsSettingsOpen = true;
+ }
+
+ public void CloseSettingsPanel()
+ {
+ IsSettingsOpen = false;
+ }
+
public async Task HandleFileSelected(InputFileChangeEventArgs args)
{
- if (args.File.Name.ToLower().EndsWith(".tap"))
+ var file = args.File;
+ var ms = new MemoryStream();
+ await file.OpenReadStream().CopyToAsync(ms);
+ var bytes = ms.ToArray();
+
+ if (TapeFormatFactory.IsSupportedTapeFile(args.File.Name))
{
- //Load the tape
- var file = args.File;
- var ms = new MemoryStream();
- await file.OpenReadStream().CopyToAsync(ms);
- tapePlayer.LoadTape(ms.ToArray());
+ tapePlayer.LoadTape(bytes, args.File.Name);
TapeName = Path.GetFileNameWithoutExtension(args.File.Name);
+ return;
}
- else
+
+ var handler = FileFormatFactory.GetSnapShotHandler(file.Name);
+ handler.LoadSnapshot(bytes, speccy);
+ }
+
+ public async Task ConnectCurrahMicroSpeech()
+ {
+ if (speccy is null)
+ {
+ return;
+ }
+
+ speccy.ConnectCurrahMicroSpeech();
+ await TryLoadCurrahAssetsAsync();
+ }
+
+ public void DisconnectCurrahMicroSpeech()
+ {
+ speccy?.DisconnectCurrahMicroSpeech();
+ }
+
+ public void ConnectZxPrinter()
+ {
+ if (speccy is null)
{
- var file = args.File;
- var ms = new MemoryStream();
+ return;
+ }
- await file.OpenReadStream().CopyToAsync(ms);
+ speccy.ConnectZxPrinter();
+ _lastPrinterVersion = -1;
+ _lastPrinterHeight = speccy.ZxPrinter.PaperHeight;
+ }
- var handler = FileFormatFactory.GetSnapShotHandler(file.Name);
- var bytes = ms.ToArray();
- handler.LoadSnapshot(bytes, speccy);
+ public void DisconnectZxPrinter()
+ {
+ if (speccy is null)
+ {
+ return;
}
+
+ speccy.DisconnectZxPrinter();
+ _lastPrinterVersion = -1;
+ _lastPrinterHeight = 0;
}
[Inject]
HttpClient httpClient { get; set; }
@@ -105,46 +228,140 @@ public async Task LoadGame(string filename, string instructions)
Instructions = instructions;
}
+ private async Task TryLoadCurrahAssetsAsync()
+ {
+ var currahRom = await TryLoadAssetAsync(CurrahRomAssetPath);
+ var speechRom = await TryLoadAssetAsync(Sp0256RomAssetPath);
+
+ if (currahRom is { Length: >= 0x800 })
+ {
+ speccy.LoadCurrahMicroSpeechRom(currahRom);
+ }
+
+ if (speechRom is { Length: >= 0x800 })
+ {
+ speccy.CurrahMicroSpeech.LoadSpeechRom(speechRom);
+ }
+
+ }
+
+ private async Task TryLoadAssetAsync(string assetPath)
+ {
+ using var response = await httpClient.GetAsync(assetPath);
+
+ if (response.StatusCode == HttpStatusCode.NotFound)
+ {
+ return null;
+ }
+
+ response.EnsureSuccessStatusCode();
+ return await response.Content.ReadAsByteArrayAsync();
+ }
+
private async void GameLoop_Elapsed(object sender, ElapsedEventArgs e)
{
+ if (Interlocked.Exchange(ref _frameInProgress, 1) != 0)
+ {
+ return;
+ }
+
Stopwatch sw = new Stopwatch();
- //Get gamepads
- kempston.Gamepads = await GamePadList.GetGamepadsAsync();
- //Run JavaScriptInterop to find the currently pressed buttons
- Keyboard.KeyBuffer = await JSRuntime.InvokeAsync>("getKeyStatus");
- sw.Start();
- speccy.DoIntructions(69888);
+ try
+ {
+ //Get gamepads
+ kempston.Gamepads = await GamePadList.GetGamepadsAsync();
+ //Run JavaScriptInterop to find the currently pressed buttons
+ Keyboard.KeyBuffer = await JSRuntime.InvokeAsync>("getKeyStatus");
+ sw.Start();
+ var frameTStates = speccy.FrameTStates;
+ speccy.DoIntructions(frameTStates);
- beeper.GenerateSound();
- await BufferSound();
+ beeper.GenerateSound(frameTStates);
+ await BufferSound();
- Paint();
- sw.Stop();
- if (tapePlayer != null && tapePlayer.IsPlaying)
- {
- TapeStopped = false;
- PercentLoaded = ((Convert.ToDouble(tapePlayer.CurrentTstate) / Convert.ToDouble(tapePlayer.TotalTstates)) * 100);
- await InvokeAsync(() => StateHasChanged());
+ Paint();
+ sw.Stop();
+ if (tapePlayer != null && tapePlayer.IsPlaying)
+ {
+ TapeStopped = false;
+ PercentLoaded = ((Convert.ToDouble(tapePlayer.CurrentTstate) / Convert.ToDouble(tapePlayer.TotalTstates)) * 100);
+ await InvokeAsync(() => StateHasChanged());
+ }
+ if (!TapeStopped && !tapePlayer.IsPlaying)
+ {
+ TapeStopped = true;
+ await InvokeAsync(() => StateHasChanged());
+ }
}
- if (!TapeStopped && !tapePlayer.IsPlaying)
+ finally
{
- TapeStopped = true;
- await InvokeAsync(() => StateHasChanged());
+ Interlocked.Exchange(ref _frameInProgress, 0);
}
}
bool TapeStopped = false;
+ private int _frameInProgress;
GCHandle gchsound;
IntPtr pinnedsound;
WebAssemblyJSRuntime mono;
- byte[] soundbytes;
+ float[] soundbytes;
protected async Task BufferSound()
{
- soundbytes = beeper.GetSoundBuffer();
+ soundbytes = MixAudioBuffers(
+ ConvertBeeperBuffer(beeper.GetSoundBuffer()),
+ speccy.CurrahMicroSpeech.RenderAudioFrame(48000 / 50, speccy.FrameTStates),
+ speccy.AyChip.RenderAudioFrame(48000 / 50, speccy.FrameTStates),
+ BeeperMixGain,
+ SpeechMixGain,
+ AyMixGain);
mono.InvokeVoid("addAudioBuffer", soundbytes);
}
+ private static float[] ConvertBeeperBuffer(byte[] beeperBuffer)
+ {
+ if (beeperBuffer.Length == 0)
+ {
+ return Array.Empty();
+ }
+
+ var converted = new float[beeperBuffer.Length];
+ var sum = 0f;
+
+ for (var i = 0; i < beeperBuffer.Length; i++)
+ {
+ sum += beeperBuffer[i];
+ }
+
+ var average = sum / beeperBuffer.Length;
+
+ for (var i = 0; i < beeperBuffer.Length; i++)
+ {
+ converted[i] = Math.Clamp((beeperBuffer[i] - average) / 63.5f, -1f, 1f);
+ }
+
+ return converted;
+ }
+
+ private static float[] MixAudioBuffers(float[] primary, float[] secondary, float[] tertiary, float primaryGain, float secondaryGain, float tertiaryGain)
+ {
+ if (primary.Length == 0 && secondary.Length == 0 && tertiary.Length == 0)
+ {
+ return Array.Empty();
+ }
+
+ var mixed = new float[Math.Max(primary.Length, Math.Max(secondary.Length, tertiary.Length))];
+
+ for (var sample = 0; sample < mixed.Length; sample++)
+ {
+ var primarySample = sample < primary.Length ? primary[sample] * primaryGain : 0f;
+ var secondarySample = sample < secondary.Length ? secondary[sample] * secondaryGain : 0f;
+ var tertiarySample = sample < tertiary.Length ? tertiary[sample] * tertiaryGain : 0f;
+ mixed[sample] = Math.Clamp(primarySample + secondarySample + tertiarySample, -1f, 1f);
+ }
+
+ return mixed;
+ }
public double PercentLoaded = 0;
protected async override void OnAfterRender(bool firstRender)
{
@@ -180,6 +397,23 @@ public async void Paint()
//gchscreen.Free();
_canvasView?.Invalidate();
+
+ if (speccy?.ZxPrinter.Connected == true)
+ {
+ var printerVersion = speccy.ZxPrinter.PaperVersion;
+ if (printerVersion != _lastPrinterVersion)
+ {
+ _lastPrinterVersion = printerVersion;
+ _printerCanvasView?.Invalidate();
+
+ var printerHeight = speccy.ZxPrinter.PaperHeight;
+ if (printerHeight != _lastPrinterHeight)
+ {
+ _lastPrinterHeight = printerHeight;
+ _ = InvokeAsync(() => StateHasChanged());
+ }
+ }
+ }
}
SKBitmap bitmap = new SKBitmap(296, 232);
@@ -191,8 +425,9 @@ public async void Paint()
//};
public void OnPaintSurface(SKPaintSurfaceEventArgs e)
{
-
+
var canvas = e.Surface.Canvas;
+ canvas.Clear(SKColors.Black);
unsafe
{
var ptr = (uint*)bitmap.GetPixels().ToPointer();
@@ -203,15 +438,156 @@ public void OnPaintSurface(SKPaintSurfaceEventArgs e)
Buffer.MemoryCopy(srcPtr, ptr, screen.Length * sizeof(uint), screen.Length * sizeof(uint));
}
}
-
- // Draw the bitmap onto the canvas
- canvas.DrawBitmap(bitmap, new SKRect(0, 0, e.Info.Width, e.Info.Height));
+
+ if (ConsumerTvShaderEnabled && EnsureConsumerTvShader())
+ {
+ DrawConsumerTvShader(canvas, e.Info);
+ return;
+ }
+
+ canvas.DrawBitmap(bitmap, new SKRect(0, 0, e.Info.Width, e.Info.Height));
+ }
+
+ private bool EnsureConsumerTvShader()
+ {
+ if (_consumerTvShaderBuilder != null)
+ {
+ return true;
+ }
+
+ if (!string.IsNullOrEmpty(_consumerTvShaderError))
+ {
+ return false;
+ }
+
+ _consumerTvEffect = SKRuntimeEffect.CreateShader(ConsumerTvShaderSource, out var shaderErrors);
+ if (_consumerTvEffect == null)
+ {
+ _consumerTvShaderError = string.IsNullOrWhiteSpace(shaderErrors)
+ ? "Failed to compile the CRT shader."
+ : shaderErrors;
+ _ = InvokeAsync(StateHasChanged);
+ return false;
+ }
+
+ _consumerTvShaderBuilder = new SKRuntimeShaderBuilder(_consumerTvEffect);
+ return true;
+ }
+
+ private void DrawConsumerTvShader(SKCanvas canvas, SKImageInfo info)
+ {
+ using var sourceShader = bitmap.ToShader(SKShaderTileMode.Clamp, SKShaderTileMode.Clamp, ConsumerTvSampling);
+ _consumerTvShaderBuilder.Children["content"] = sourceShader;
+ _consumerTvShaderBuilder.Uniforms["outputSize"] = new SKSize(info.Width, info.Height);
+ _consumerTvShaderBuilder.Uniforms["inputSize"] = new SKSize(bitmap.Width, bitmap.Height);
+ _consumerTvShaderBuilder.Uniforms["curvature"] = ConsumerTvCurvature;
+ _consumerTvShaderBuilder.Uniforms["scanlineStrength"] = ConsumerTvScanlineStrength;
+ _consumerTvShaderBuilder.Uniforms["vignetteStrength"] = ConsumerTvVignetteStrength;
+ _consumerTvShaderBuilder.Uniforms["saturation"] = ConsumerTvSaturation;
+ _consumerTvShaderBuilder.Uniforms["brightness"] = ConsumerTvBrightness;
+ using var crtShader = _consumerTvShaderBuilder.Build();
+ using var paint = new SKPaint
+ {
+ Shader = crtShader,
+ IsAntialias = false
+ };
+
+ canvas.DrawRect(SKRect.Create(info.Width, info.Height), paint);
+ }
+
+ public int PrinterCanvasDisplayHeight
+ {
+ get
+ {
+ var printerHeight = speccy?.ZxPrinter.PaperHeight ?? 0;
+ return Math.Max(printerHeight * PrinterDisplayScale, 96);
+ }
+ }
+
+ public int PrinterCanvasDisplayWidth => ZxPrinter.PaperWidth * PrinterDisplayScale;
+
+ public string PrinterCanvasStyle => $"display:block; width:min(100%, {PrinterCanvasDisplayWidth}px); height:auto;";
+
+ private int PrinterRenderedPaperHeight
+ {
+ get
+ {
+ var printerHeight = speccy?.ZxPrinter.PaperHeight ?? 0;
+ return printerHeight * PrinterDisplayScale;
+ }
+ }
+
+ public void OnPaintPrinterSurface(SKPaintSurfaceEventArgs e)
+ {
+ var canvas = e.Surface.Canvas;
+ canvas.Clear(new SKColor(184, 188, 192));
+
+ if (speccy?.ZxPrinter.Connected != true)
+ {
+ return;
+ }
+
+ var snapshot = speccy.ZxPrinter.GetPaperSnapshot();
+ var bitmapHeight = Math.Max(snapshot.Height, 1);
+ EnsurePrinterBitmap(snapshot.Width, bitmapHeight);
+ Array.Fill(_printerPixels, PrinterPaperColor);
+
+ for (var y = 0; y < snapshot.Height; y++)
+ {
+ var lineOffset = y * snapshot.Width;
+ for (var x = 0; x < snapshot.Width; x++)
+ {
+ if (snapshot.Pixels[lineOffset + x] != 0)
+ {
+ _printerPixels[lineOffset + x] = PrinterInkColor;
+ }
+ }
+ }
+
+ unsafe
+ {
+ var ptr = (uint*)_printerBitmap.GetPixels().ToPointer();
+ fixed (uint* srcPtr = _printerPixels)
+ {
+ Buffer.MemoryCopy(srcPtr, ptr, _printerPixels.Length * sizeof(uint), _printerPixels.Length * sizeof(uint));
+ }
+ }
+
+ using var paint = new SKPaint
+ {
+ FilterQuality = SKFilterQuality.None,
+ IsAntialias = false
+ };
+
+ var renderedHeight = Math.Min(PrinterRenderedPaperHeight, e.Info.Height);
+ if (renderedHeight <= 0)
+ {
+ return;
+ }
+
+ canvas.DrawBitmap(_printerBitmap, new SKRect(0, 0, e.Info.Width, renderedHeight), paint);
+ }
+
+ private void EnsurePrinterBitmap(int width, int height)
+ {
+ if (_printerBitmap.Width == width && _printerBitmap.Height == height && _printerPixels.Length == width * height)
+ {
+ return;
+ }
+
+ _printerBitmap.Dispose();
+ _printerBitmap = new SKBitmap(width, height);
+ _printerPixels = new uint[width * height];
}
public ValueTask DisposeAsync()
{
gameLoop.Stop();
gameLoop.Dispose();
+ _consumerTvShaderBuilder?.Dispose();
+ _consumerTvEffect?.Dispose();
+ _printerBitmap.Dispose();
+ bitmap.Dispose();
return ValueTask.CompletedTask;
}
}
diff --git a/Platforms/ZXBox.Blazor/Shared/MainLayout.razor b/Platforms/ZXBox.Blazor/Shared/MainLayout.razor
index d580e88..684e850 100644
--- a/Platforms/ZXBox.Blazor/Shared/MainLayout.razor
+++ b/Platforms/ZXBox.Blazor/Shared/MainLayout.razor
@@ -4,5 +4,3 @@
@Body
-
-
diff --git a/Platforms/ZXBox.Blazor/ZXBox.Blazor.csproj b/Platforms/ZXBox.Blazor/ZXBox.Blazor.csproj
index fce39a9..7c7a88c 100644
--- a/Platforms/ZXBox.Blazor/ZXBox.Blazor.csproj
+++ b/Platforms/ZXBox.Blazor/ZXBox.Blazor.csproj
@@ -48,6 +48,12 @@
PreserveNewest
+
+ PreserveNewest
+
+
+ PreserveNewest
+
diff --git a/Platforms/ZXBox.Blazor/wwwroot/css/app.css b/Platforms/ZXBox.Blazor/wwwroot/css/app.css
index 845ff5e..e38d1cf 100644
--- a/Platforms/ZXBox.Blazor/wwwroot/css/app.css
+++ b/Platforms/ZXBox.Blazor/wwwroot/css/app.css
@@ -1,10 +1,8 @@
@import url('open-iconic/font/css/open-iconic-bootstrap.min.css');
-#emulatorCanvas {
-
- width: 592px;
- height: 464px;
-}
+:root {
+ --app-nav-height: 59px;
+}
html {
min-height: 100%;
@@ -19,7 +17,7 @@ html {
body {
color: white;
background-color:transparent;
-
+ padding-bottom: 0;
}
.about {
width: 80%;
@@ -28,11 +26,13 @@ body {
padding-top:25px;
}
#emulatorCanvas {
-
padding: 0;
margin: auto;
display: block;
-
+ width: 100%;
+ height: 100%;
+ max-width: 100%;
+ max-height: 100%;
}
@@ -42,21 +42,12 @@ body {
}
-footer {
-
- position: fixed;
- left: 0;
- bottom: 0;
- width: 100%;
- color: white;
- text-align: center;
- background-color:black;
-}
-
-
body {
font-family: 'zx','Helvetica Neue', Helvetica, Arial, sans-serif;
- padding-bottom:150px;
+}
+
+.emulator {
+ min-height: calc(100vh - var(--app-nav-height));
}
diff --git a/Platforms/ZXBox.Blazor/wwwroot/index.html b/Platforms/ZXBox.Blazor/wwwroot/index.html
index ab6ac41..c527a41 100644
--- a/Platforms/ZXBox.Blazor/wwwroot/index.html
+++ b/Platforms/ZXBox.Blazor/wwwroot/index.html
@@ -18,19 +18,18 @@
var buffersize = 960;
// Create audioContext and gain node.
- // Gain is set to 10% as we will add data 256 times larger than the normal max to our audio buffer source node.
var audioContext = new AudioContext();
- var gainNode = new GainNode(audioContext, { gain: 0.1 / 256 });
+ var gainNode = new GainNode(audioContext, { gain: 0.35 });
// Connect the gain node.
gainNode.connect(audioContext.destination);
- function addAudioBuffer(byteData) {
+ function addAudioBuffer(floatData) {
// Create audiobuffer
var audioBuffer = audioContext.createBuffer(1, buffersize, audioContext.sampleRate);
- // Create Float32Array from UInt8Array via constructor.
- var newChannelData = new Float32Array(byteData)
+ // Create Float32Array from normalized float samples.
+ var newChannelData = new Float32Array(floatData)
audioBuffer.copyToChannel(newChannelData, 0);
// Create audio source from audio buffer
@@ -40,7 +39,7 @@
source.connect(gainNode);
// Update nextStartTime and start source node.
- if (nextStartTime == 0) nextStartTime = audioContext.currentTime + (audioBuffer.length / audioBuffer.sampleRate);
+ if (nextStartTime == 0 || nextStartTime < audioContext.currentTime) nextStartTime = audioContext.currentTime + (audioBuffer.length / audioBuffer.sampleRate);
source.start(nextStartTime);
nextStartTime += audioBuffer.length / audioBuffer.sampleRate;
}
diff --git a/README.md b/README.md
index 551e97d..ebcc541 100644
--- a/README.md
+++ b/README.md
@@ -6,6 +6,12 @@ In this repo, you will find:
* ZX Spectrum emulator written in C#.
* Blazor implementation of the emulator
+* 48K and 128K Spectrum support
+* Blazor audio output for the beeper, 128K AY sound, and Currah uSpeech
+* Snapshot support for SNA and Z80
+* Tape loading and playback for TAP and TZX files
+* Kempston joystick support through a gamepad
+* Connectable Currah uSpeech and ZX Printer peripherals
## Sponsors
Thanks you to much to my sponsors!
@@ -37,9 +43,24 @@ I did a talk about Blazor at Microsoft Ignite 2019 where I demoed my ZX Spectrum
You can find the Blazor implementation here http://zxbox.com .
I got a lot of amazing feedback and many wanted to see the code so I decided to share that as well.
-There are still things left to do for example implementing sound (for Blazor) and support for more file formats.
+Current emulator support includes:
-It supports SNA and Z80-formats and you can connect an XBox gamepad to emulate Kempston Joystick.
+* ZX Spectrum 48K and 128K models
+* Beeper audio in Blazor
+* 128K AY-3-8912 sound
+* Currah uSpeech with bundled Currah and SP0256 ROM assets
+* TAP and TZX loading with tape playback
+* SNA and Z80 snapshot loading
+* Kempston joystick through an Xbox-compatible gamepad
+* ZX Printer emulation with a live paper preview in the Blazor UI
+
+There are still things left to do, especially around adding more file formats and improving hardware accuracy even further.
+
+## Currah uSpeech / SP0256 ROM ownership
+
+Currah uSpeech support uses the bundled Currah ROM plus the SP0256-AL2 speech ROM image.
+
+The SP0256-AL2 ROM image (`al2.bin`) is owned by **Microchip Technology Inc.** Microchip retains the intellectual property rights to the algorithms and data contained in that ROM image. Distribution of that ROM image is based on the permission described by Microchip's legal department in the material provided with the ROM.
## Thanks to
diff --git a/ZXBox.Core.Tests/Ay38912ChipTests.cs b/ZXBox.Core.Tests/Ay38912ChipTests.cs
new file mode 100644
index 0000000..61fdd2a
--- /dev/null
+++ b/ZXBox.Core.Tests/Ay38912ChipTests.cs
@@ -0,0 +1,97 @@
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using ZXBox.Hardware.Sound;
+
+namespace ZXBox.Core.Tests;
+
+[TestClass]
+public class Ay38912ChipTests
+{
+ [TestMethod]
+ public void AyPortWritesRoundTripRegisters()
+ {
+ var chip = new Ay38912Chip();
+
+ WriteRegister(chip, 0xC000, 0x8000, 0, 0x34);
+ WriteRegister(chip, 0xFFFD, 0xBFFD, 1, 0x1F);
+ WriteRegister(chip, 0xFFFD, 0xBFFD, 6, 0xFF);
+ WriteRegister(chip, 0xFFFD, 0xBFFD, 8, 0x3F);
+
+ Assert.AreEqual(0x34, ReadRegister(chip, 0));
+ Assert.AreEqual(0x0F, ReadRegister(chip, 1));
+ Assert.AreEqual(0x1F, ReadRegister(chip, 6));
+ Assert.AreEqual(0x1F, ReadRegister(chip, 8));
+ }
+
+ [TestMethod]
+ public void AyToneGenerationProducesExpectedFrequency()
+ {
+ var chip = new Ay38912Chip();
+ ProgramChannelATone(chip, 0x0100, 0x0F, 0);
+
+ var frame = chip.RenderAudioFrame(960, Ay38912Chip.FrameTStates128k);
+ var estimatedFrequency = EstimateFrequency(frame, 48000);
+
+ Assert.IsTrue(frame.Any(sample => Math.Abs(sample) > 0.01f));
+ Assert.IsTrue(estimatedFrequency > 380d && estimatedFrequency < 490d, $"Expected ~433 Hz, got {estimatedFrequency:F2} Hz.");
+ }
+
+ [TestMethod]
+ public void AyMidFrameVolumeChangeSilencesSecondHalf()
+ {
+ var chip = new Ay38912Chip();
+ ProgramChannelATone(chip, 0x0080, 0x0F, 0);
+
+ WriteRegister(chip, 0xFFFD, 0xBFFD, 8, 0x00, Ay38912Chip.FrameTStates128k / 2);
+
+ var frame = chip.RenderAudioFrame(960, Ay38912Chip.FrameTStates128k);
+ var firstQuarter = AverageAbsolute(frame.Take(240));
+ var lastQuarter = AverageAbsolute(frame.Skip(720));
+
+ Assert.IsTrue(firstQuarter > 0.02f, $"Expected audible first quarter, got {firstQuarter:F4}.");
+ Assert.IsTrue(lastQuarter < firstQuarter / 4f, $"Expected quiet last quarter, first={firstQuarter:F4}, last={lastQuarter:F4}.");
+ }
+
+ private static void ProgramChannelATone(Ay38912Chip chip, int period, int volume, int tState)
+ {
+ WriteRegister(chip, 0xFFFD, 0xBFFD, 0, period & 0xFF, tState);
+ WriteRegister(chip, 0xFFFD, 0xBFFD, 1, (period >> 8) & 0x0F, tState);
+ WriteRegister(chip, 0xFFFD, 0xBFFD, 7, 0x3E, tState);
+ WriteRegister(chip, 0xFFFD, 0xBFFD, 8, volume & 0x0F, tState);
+ }
+
+ private static void WriteRegister(Ay38912Chip chip, int selectPort, int dataPort, int register, int value, int tState = 0)
+ {
+ chip.HandlePortWrite(selectPort, register, tState);
+ chip.HandlePortWrite(dataPort, value, tState);
+ }
+
+ private static int ReadRegister(Ay38912Chip chip, int register)
+ {
+ chip.HandlePortWrite(0xFFFD, register, 0);
+ chip.TryReadPort(0xFFFD, 0, out var value);
+ return value;
+ }
+
+ private static double EstimateFrequency(float[] frame, int sampleRate)
+ {
+ var zeroCrossings = 0;
+
+ for (var i = 1; i < frame.Length; i++)
+ {
+ if ((frame[i - 1] <= 0f && frame[i] > 0f) || (frame[i - 1] >= 0f && frame[i] < 0f))
+ {
+ zeroCrossings++;
+ }
+ }
+
+ return zeroCrossings / (2d * frame.Length / sampleRate);
+ }
+
+ private static float AverageAbsolute(IEnumerable
samples)
+ {
+ return samples.Select(sample => Math.Abs(sample)).DefaultIfEmpty().Average();
+ }
+}
diff --git a/ZXBox.Core.Tests/CoreTest.cs b/ZXBox.Core.Tests/CoreTest.cs
index 1077f1a..9e49b09 100644
--- a/ZXBox.Core.Tests/CoreTest.cs
+++ b/ZXBox.Core.Tests/CoreTest.cs
@@ -25,6 +25,7 @@ public static bool TestInstruction(string file)
file = $"{Directory.GetParent(Directory.GetCurrentDirectory()).Parent.Parent.FullName}\\Testfiles\\" + file;
ZXSpectrum z80 = new ZXSpectrum();
z80.Reset();
+ z80.AutoInterruptAtEndOfTimeslice = false;
TestState ts = TestfileHandler.ReadINFile(file);
z80.AF = ts.af;
@@ -79,8 +80,8 @@ public static bool CompareFunction(TestState ts, TestState z80)
Assert.AreEqual (ts.hl , z80.hl,"hl");
Assert.AreEqual (ts.hl_ , z80.hl_,"hl_");
Assert.AreEqual (ts.i , z80.i, "i");
- //Assert.AreEqual (ts.iff1 , z80.iff1, "iff1");
- //Assert.AreEqual (ts.iff2 , z80.iff2, "iff2");
+ Assert.AreEqual (ts.iff1 , z80.iff1, "iff1");
+ Assert.AreEqual (ts.iff2 , z80.iff2, "iff2");
Assert.AreEqual (ts.im , z80.im, "im");
Assert.AreEqual (ts.ix , z80.ix, "ix");
Assert.AreEqual(ts.iy, z80.iy, "iy");
@@ -98,9 +99,9 @@ public static bool CompareFunction(TestState ts, TestState z80)
}
}
Assert.IsTrue(equalMemory,"memory");
- //Assert.AreEqual(ts.pc, z80.pc, "pc");
- //Assert.AreEqual(ts.r, z80.r, "r");
- //Assert.AreEqual(ts.sp, z80.sp, "sp");
+ Assert.AreEqual(ts.pc, z80.pc, "pc");
+ Assert.AreEqual(ts.r, z80.r, "r");
+ Assert.AreEqual(ts.sp, z80.sp, "sp");
return comparetrue;
@@ -205,4 +206,3 @@ public static TestState ExtractState(Zilog.Z80 z80)
}
}
}
-
diff --git a/ZXBox.Core.Tests/CurrahMicroSpeechTests.cs b/ZXBox.Core.Tests/CurrahMicroSpeechTests.cs
new file mode 100644
index 0000000..54b729b
--- /dev/null
+++ b/ZXBox.Core.Tests/CurrahMicroSpeechTests.cs
@@ -0,0 +1,124 @@
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using System;
+using System.Linq;
+using ZXBox.Hardware.Speech;
+
+namespace ZXBox.Core.Tests;
+
+[TestClass]
+public class CurrahMicroSpeechTests
+{
+ [TestMethod]
+ public void CurrahReadAt0038TogglesOverlayAndOpcodeFetchStillWorks()
+ {
+ var baseline = new ZXSpectrum();
+ var baselineValue = baseline.ReadByteFromMemory(0x0001);
+
+ var speccy = new ZXSpectrum();
+ speccy.ConnectCurrahMicroSpeech();
+
+ var currahRom = CreateCurrahRom();
+ speccy.LoadCurrahMicroSpeechRom(currahRom);
+
+ speccy.ReadByteFromMemory(0x0038);
+
+ Assert.IsTrue(speccy.CurrahMicroSpeech.Active);
+ Assert.AreEqual(currahRom[0x0001], speccy.ReadByteFromMemory(0x0801));
+ Assert.AreEqual(0xff, speccy.ReadByteFromMemory(0x2000));
+
+ speccy.PC = 0x0038;
+ speccy.NextOpcode();
+
+ Assert.IsFalse(speccy.CurrahMicroSpeech.Active);
+ Assert.AreEqual(baselineValue, speccy.ReadByteFromMemory(0x0001));
+ }
+
+ [TestMethod]
+ public void CurrahPortReadAt0038StillTogglesOverlay()
+ {
+ var speccy = new ZXSpectrum();
+ speccy.ConnectCurrahMicroSpeech();
+ speccy.LoadCurrahMicroSpeechRom(CreateCurrahRom());
+
+ Assert.IsFalse(speccy.CurrahMicroSpeech.Active);
+
+ speccy.In(0x0038);
+ Assert.IsTrue(speccy.CurrahMicroSpeech.Active);
+
+ speccy.In(0x0038);
+ Assert.IsFalse(speccy.CurrahMicroSpeech.Active);
+ }
+
+ [TestMethod]
+ public void CurrahQueuedAllophoneSetsBusyAndProducesSpeechSamples()
+ {
+ var speccy = new ZXSpectrum();
+ speccy.ConnectCurrahMicroSpeech();
+ speccy.CurrahMicroSpeech.LoadSpeechRom(CreateSyntheticSpeechRom());
+
+ speccy.ReadByteFromMemory(0x0038);
+ speccy.WriteByteToMemory(0x1000, 0x00);
+
+ Assert.AreEqual(1, speccy.ReadByteFromMemory(0x1000) & 0x01);
+
+ var frame = speccy.CurrahMicroSpeech.RenderAudioFrame(48000 / 50, 69888);
+
+ Assert.IsTrue(frame.Any(sample => Math.Abs(sample) > 0.001f));
+ Assert.AreEqual(0, speccy.ReadByteFromMemory(0x1000) & 0x01);
+ }
+
+ [TestMethod]
+ public void CurrahMasksAllophoneWritesToSixBits()
+ {
+ var speccy = new ZXSpectrum();
+ speccy.ConnectCurrahMicroSpeech();
+ speccy.CurrahMicroSpeech.LoadSpeechRom(CreateSyntheticSpeechRom());
+
+ speccy.ReadByteFromMemory(0x0038);
+ speccy.WriteByteToMemory(0x1000, 0x40);
+
+ Assert.AreEqual(1, speccy.ReadByteFromMemory(0x1000) & 0x01);
+
+ var frame = speccy.CurrahMicroSpeech.RenderAudioFrame(48000 / 50, 69888);
+
+ Assert.IsTrue(frame.Any(sample => Math.Abs(sample) > 0.001f));
+ }
+
+ [TestMethod]
+ public void CurrahDisconnectStopsOverlayAndAudio()
+ {
+ var speccy = new ZXSpectrum();
+ speccy.ConnectCurrahMicroSpeech();
+ speccy.CurrahMicroSpeech.LoadSpeechRom(CreateSyntheticSpeechRom());
+ speccy.ReadByteFromMemory(0x0038);
+ speccy.WriteByteToMemory(0x1000, 0x00);
+
+ speccy.DisconnectCurrahMicroSpeech();
+
+ Assert.IsFalse(speccy.CurrahMicroSpeech.Connected);
+ Assert.IsFalse(speccy.CurrahMicroSpeech.Active);
+ Assert.AreEqual(0xff, speccy.In(0x1000));
+ CollectionAssert.AreEqual(new float[16], speccy.CurrahMicroSpeech.RenderAudioFrame(16, 69888));
+ }
+
+ private static byte[] CreateCurrahRom()
+ {
+ var rom = new byte[CurrahMicroSpeech.CurrahRomSize];
+ for (var i = 0; i < rom.Length; i++)
+ {
+ rom[i] = (byte)((i + 0x40) & 0xff);
+ }
+
+ return rom;
+ }
+
+ private static byte[] CreateSyntheticSpeechRom()
+ {
+ var rom = new byte[Sp0256Chip.Sp0256RomSize];
+ rom[0] = 0x87; // immed4=7, opcode=8? actual LOAD_E uses fetched opcode 0x7, repeat 7
+ rom[1] = 0x1f; // amplitude
+ rom[2] = 0x20; // period
+ rom[3] = 0x00; // halt
+ return rom;
+ }
+}
diff --git a/ZXBox.Core.Tests/FileFormats/TapFileFormatTests.cs b/ZXBox.Core.Tests/FileFormats/TapFileFormatTests.cs
index 994e10d..73df5b6 100644
--- a/ZXBox.Core.Tests/FileFormats/TapFileFormatTests.cs
+++ b/ZXBox.Core.Tests/FileFormats/TapFileFormatTests.cs
@@ -1,5 +1,6 @@
-using Microsoft.VisualStudio.TestTools.UnitTesting;
-using System.IO;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using System.Linq;
+using ZXBox.Core.Hardware.Input;
using ZXBox.Core.Tape;
namespace ZXBox.Core.Tests.FileFormats;
@@ -8,30 +9,57 @@ namespace ZXBox.Core.Tests.FileFormats;
public class TapFileFormatTests
{
[TestMethod]
- public void LoadTapFileTest()
+ public void ReadFileParsesLengthPrefixedTapBlocks()
{
- var filename = @"Binaries\froggers.tap";
- var tf = new TapFormat();
- var bytes = File.ReadAllBytes(filename);
- tf.ReadFile(bytes);
+ var tapFormat = new TapFormat();
+ var bytes = new byte[]
+ {
+ 0x03, 0x00, 0x00, 0xAA, 0x55,
+ 0x02, 0x00, 0xFF, 0x10
+ };
- Assert.AreEqual(tf.Blocks.Count, 2);
+ var tapeImage = tapFormat.ReadFile(bytes);
+
+ Assert.AreEqual(2, tapeImage.Blocks.Count);
+ Assert.IsInstanceOfType(tapeImage.Blocks[0]);
+ Assert.IsInstanceOfType(tapeImage.Blocks[1]);
+ CollectionAssert.AreEqual(new byte[] { 0x00, 0xAA, 0x55 }, ((TapeDataBlock)tapeImage.Blocks[0]).Data);
+ CollectionAssert.AreEqual(new byte[] { 0xFF, 0x10 }, ((TapeDataBlock)tapeImage.Blocks[1]).Data);
}
+
[TestMethod]
- public void LoadSentinelTapFileTest()
+ public void ReadFileKeepsShortFinalBlockWhenTapDataIsTruncated()
{
- var filename = @"C:\Users\Jimmy\Downloads\SentinelThe.tap\SENTINEL.TAP";
- var tf = new TapFormat();
- var bytes = File.ReadAllBytes(filename);
- tf.ReadFile(bytes);
+ var tapFormat = new TapFormat();
+ var bytes = new byte[]
+ {
+ 0x04, 0x00, 0x01, 0x02
+ };
- Assert.AreEqual(tf.Blocks.Count, 2);
+ var tapeImage = tapFormat.ReadFile(bytes);
+
+ Assert.AreEqual(1, tapeImage.Blocks.Count);
+ Assert.IsInstanceOfType(tapeImage.Blocks[0]);
+ CollectionAssert.AreEqual(new byte[] { 0x01, 0x02 }, ((TapeDataBlock)tapeImage.Blocks[0]).Data);
}
+
[TestMethod]
- public void DecodeTapFileTest()
+ public void TapePlayerLoadTapeBuildsExpectedPulseSequence()
{
+ var tapePlayer = new TapePlayer(null!);
+ var bytes = new byte[]
+ {
+ 0x02, 0x00, 0x00, 0x80
+ };
- //var tp = new TapePlayer();
+ tapePlayer.LoadTape(bytes);
+ Assert.AreEqual(8063, tapePlayer.EarValues.Count(earValue => earValue.Pulse == PulseTypeEnum.Pilot));
+ Assert.AreEqual(PulseTypeEnum.Sync1, tapePlayer.EarValues[8063].Pulse);
+ Assert.AreEqual(PulseTypeEnum.Sync2, tapePlayer.EarValues[8064].Pulse);
+ Assert.AreEqual(32, tapePlayer.EarValues.Count(earValue => earValue.Pulse == PulseTypeEnum.Data));
+ Assert.AreEqual(PulseTypeEnum.Pause, tapePlayer.EarValues[^3].Pulse);
+ Assert.AreEqual(PulseTypeEnum.Termination, tapePlayer.EarValues[^2].Pulse);
+ Assert.AreEqual(PulseTypeEnum.Stop, tapePlayer.EarValues[^1].Pulse);
}
-}
\ No newline at end of file
+}
diff --git a/ZXBox.Core.Tests/FileFormats/TzxFormatTests.cs b/ZXBox.Core.Tests/FileFormats/TzxFormatTests.cs
new file mode 100644
index 0000000..4d6706c
--- /dev/null
+++ b/ZXBox.Core.Tests/FileFormats/TzxFormatTests.cs
@@ -0,0 +1,123 @@
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using System.Linq;
+using ZXBox.Core.Hardware.Input;
+using ZXBox.Core.Tape;
+
+namespace ZXBox.Core.Tests.FileFormats;
+
+[TestClass]
+public class TzxFormatTests
+{
+ [TestMethod]
+ public void StandardSpeedTzxBlockMatchesTapPulseStream()
+ {
+ var tapBytes = new byte[]
+ {
+ 0x02, 0x00, 0x00, 0xA5
+ };
+ var tzxBytes = CreateTzx(
+ 0x10,
+ 0xE8, 0x03,
+ 0x02, 0x00,
+ 0x00, 0xA5);
+
+ var tapPlayer = new TapePlayer(null);
+ var tzxPlayer = new TapePlayer(null);
+
+ tapPlayer.LoadTape(tapBytes, "demo.tap");
+ tzxPlayer.LoadTape(tzxBytes, "demo.tzx");
+
+ Assert.AreEqual(tapPlayer.EarValues.Count, tzxPlayer.EarValues.Count);
+ for (var index = 0; index < tapPlayer.EarValues.Count; index++)
+ {
+ Assert.AreEqual(tapPlayer.EarValues[index].Pulse, tzxPlayer.EarValues[index].Pulse, $"Pulse mismatch at {index}.");
+ Assert.AreEqual(tapPlayer.EarValues[index].Ear, tzxPlayer.EarValues[index].Ear, $"Signal mismatch at {index}.");
+ Assert.AreEqual(tapPlayer.EarValues[index].TState, tzxPlayer.EarValues[index].TState, $"Timing mismatch at {index}.");
+ }
+ }
+
+ [TestMethod]
+ public void StandardSpeedTzxBlockIsMarkedAsQuickLoadCompatible()
+ {
+ var tzxBytes = CreateTzx(
+ 0x10,
+ 0xE8, 0x03,
+ 0x02, 0x00,
+ 0x00, 0xA5);
+
+ var tapeImage = new TzxFormat().ReadFile(tzxBytes);
+ var block = (TapeDataBlock)tapeImage.Blocks[0];
+
+ Assert.IsTrue(block.IsQuickLoadCandidate);
+ }
+
+ [TestMethod]
+ public void TzxFormatSkipsMetadataAndResolvesLoops()
+ {
+ var tzxBytes = CreateTzx(
+ 0x30, 0x04, (byte)'T', (byte)'E', (byte)'S', (byte)'T',
+ 0x24, 0x02, 0x00,
+ 0x12, 0x64, 0x00, 0x01, 0x00,
+ 0x25);
+
+ var tapeImage = new TzxFormat().ReadFile(tzxBytes);
+
+ Assert.AreEqual(2, tapeImage.Blocks.Count);
+ Assert.IsTrue(tapeImage.Blocks.TrueForAll(block => block is TapePureToneBlock));
+ }
+
+ [TestMethod]
+ public void TurboDataBlockHonorsUsedBitsInLastByte()
+ {
+ var tzxBytes = CreateTzx(
+ 0x11,
+ 0x10, 0x00,
+ 0x20, 0x00,
+ 0x30, 0x00,
+ 0x04, 0x00,
+ 0x08, 0x00,
+ 0x00, 0x00,
+ 0x03,
+ 0x00, 0x00,
+ 0x01, 0x00, 0x00,
+ 0xA0);
+
+ var tapeImage = new TzxFormat().ReadFile(tzxBytes);
+ var block = (TapeDataBlock)tapeImage.Blocks[0];
+ var tapePlayer = new TapePlayer(null);
+ tapePlayer.LoadTape(tapeImage);
+
+ Assert.AreEqual(3, block.UsedBitsInLastByte);
+ Assert.AreEqual(6, tapePlayer.EarValues.Count(earValue => earValue.Pulse == PulseTypeEnum.Data));
+ }
+
+ [TestMethod]
+ public void ZeroLengthPauseCreatesExplicitStopBlock()
+ {
+ var tzxBytes = CreateTzx(
+ 0x20, 0x00, 0x00);
+
+ var tapeImage = new TzxFormat().ReadFile(tzxBytes);
+
+ Assert.AreEqual(1, tapeImage.Blocks.Count);
+ Assert.IsInstanceOfType(tapeImage.Blocks[0]);
+ }
+
+ private static byte[] CreateTzx(params byte[] body)
+ {
+ return
+ [
+ (byte)'Z',
+ (byte)'X',
+ (byte)'T',
+ (byte)'a',
+ (byte)'p',
+ (byte)'e',
+ (byte)'!',
+ 0x1A,
+ 0x01,
+ 0x20,
+ .. body
+ ];
+ }
+}
diff --git a/ZXBox.Core.Tests/GameBoy/GBFileFormatTests.cs b/ZXBox.Core.Tests/GameBoy/GBFileFormatTests.cs
index 96dbc12..6fed6c5 100644
--- a/ZXBox.Core.Tests/GameBoy/GBFileFormatTests.cs
+++ b/ZXBox.Core.Tests/GameBoy/GBFileFormatTests.cs
@@ -1,6 +1,4 @@
-using Microsoft.VisualStudio.TestTools.UnitTesting;
-using System.Collections.Generic;
-using System.IO;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
using ZXBox.Snapshot;
namespace ZXBox.Core.Tests.GameBoy;
@@ -9,27 +7,57 @@ namespace ZXBox.Core.Tests.GameBoy;
public class GBFileFormatTests
{
[TestMethod]
- public void TestLoadGBFile()
+ public void LoadSnapshotCopiesRomBytesIntoSpectrumMemory()
{
- var bytes = File.ReadAllBytes(@"C:\Code\Roms\Test.gb");
- var ff = new GBFileFormat();
- ff.LoadSnapshot(bytes, new ZXSpectrum());
+ var bytes = CreateGameBoyRom();
+ var fileFormat = new GBFileFormat();
+ var spectrum = new ZXSpectrum();
+
+ fileFormat.LoadSnapshot(bytes, spectrum);
+
+ Assert.AreEqual(0xC3, spectrum.ReadByteFromMemory(0x0100));
+ Assert.AreEqual(0x50, spectrum.ReadByteFromMemory(0x0101));
+ Assert.AreEqual(0x01, spectrum.ReadByteFromMemory(0x0102));
+ Assert.AreEqual(0x42, spectrum.ReadByteFromMemory(0x0147));
}
[TestMethod]
- public void LoadTileTest()
+ public void LoadSnapshotCopiesRomBytesIntoGameBoyMemoryMap()
+ {
+ var bytes = CreateGameBoyRom();
+ var fileFormat = new GBFileFormat();
+ var gameboy = new Gameboy();
+
+ fileFormat.LoadSnapshot(bytes, gameboy);
+
+ Assert.AreEqual(0xC3, gameboy.ReadByteFromMemory(0x0100));
+ Assert.AreEqual(0x50, gameboy.ReadByteFromMemory(0x0101));
+ Assert.AreEqual(0x01, gameboy.ReadByteFromMemory(0x0102));
+ Assert.AreEqual((byte)'Z', gameboy.ReadByteFromMemory(0x0134));
+ Assert.AreEqual(0x42, gameboy.ReadByteFromMemory(0x0147));
+ }
+
+ private static byte[] CreateGameBoyRom()
{
- var bytes = File.ReadAllBytes(@"C:\Code\Roms\Test.gb");
- var ff = new GBFileFormat();
- var gb = new Gameboy();
- ff.LoadSnapshot(bytes, gb);
- List tiles = new List();
- //gb.PC = 0x100; //Entry point
- gb.DoIntructions(1000000000);
- for (int b = 0x8000; b <= 0x97FF; b++)
- {
- tiles.Add(gb.ReadByteFromMemory(b));
- }
+ var rom = new byte[0x200];
+ rom[0x0100] = 0xC3;
+ rom[0x0101] = 0x50;
+ rom[0x0102] = 0x01;
+ rom[0x0134] = (byte)'Z';
+ rom[0x0135] = (byte)'X';
+ rom[0x0136] = (byte)'B';
+ rom[0x0137] = (byte)'O';
+ rom[0x0138] = (byte)'X';
+ rom[0x0147] = 0x42;
+ rom[0x0148] = 0x03;
+ rom[0x0149] = 0x02;
+ rom[0x014A] = 0x01;
+ rom[0x014B] = 0x33;
+ rom[0x014C] = 0x07;
+ rom[0x014D] = 0xA5;
+ rom[0x014E] = 0x12;
+ rom[0x014F] = 0x34;
+ return rom;
}
-}
\ No newline at end of file
+}
diff --git a/ZXBox.Core.Tests/Sp0256ChipTests.cs b/ZXBox.Core.Tests/Sp0256ChipTests.cs
new file mode 100644
index 0000000..6c5c537
--- /dev/null
+++ b/ZXBox.Core.Tests/Sp0256ChipTests.cs
@@ -0,0 +1,59 @@
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using System;
+using System.Linq;
+using ZXBox.Hardware.Speech;
+
+namespace ZXBox.Core.Tests;
+
+[TestClass]
+public class Sp0256ChipTests
+{
+ [TestMethod]
+ public void Sp0256RejectsSmallRoms()
+ {
+ var chip = new Sp0256Chip();
+
+ try
+ {
+ chip.LoadRom(new byte[32]);
+ Assert.Fail("Expected LoadRom to reject undersized SP0256 ROMs.");
+ }
+ catch (ArgumentException)
+ {
+ }
+ }
+
+ [TestMethod]
+ public void Sp0256DropsBusyAfterInstructionLatchWhileAudioContinues()
+ {
+ var chip = new Sp0256Chip();
+ chip.LoadRom(CreateSyntheticSpeechRom());
+
+ chip.WriteAllophone(0x00, 0);
+ Assert.IsTrue(chip.ReadBusy(0));
+
+ Assert.IsFalse(chip.ReadBusy(400));
+
+ var frame = chip.RenderFrame(48000 / 50, 69888);
+ Assert.IsTrue(frame.Any(sample => Math.Abs(sample) > 0.001f));
+ }
+
+ [TestMethod]
+ public void Sp0256WithoutRomStaysReadyAndSilent()
+ {
+ var chip = new Sp0256Chip();
+ chip.WriteAllophone(0x00, 0);
+ Assert.IsFalse(chip.ReadBusy(0));
+ CollectionAssert.AreEqual(new float[16], chip.RenderFrame(16, 69888));
+ }
+
+ private static byte[] CreateSyntheticSpeechRom()
+ {
+ var rom = new byte[Sp0256Chip.Sp0256RomSize];
+ rom[0] = 0x87;
+ rom[1] = 0x1f;
+ rom[2] = 0x20;
+ rom[3] = 0x00;
+ return rom;
+ }
+}
diff --git a/ZXBox.Core.Tests/TestfileHandler.cs b/ZXBox.Core.Tests/TestfileHandler.cs
index 33fe5ea..91c9575 100644
--- a/ZXBox.Core.Tests/TestfileHandler.cs
+++ b/ZXBox.Core.Tests/TestfileHandler.cs
@@ -113,7 +113,7 @@ public static TestState ReadOUTFile(string Path,int[] MemoryPreset)
//To get a generic tester I have a copy of the Z80 memory in the teststate
//Fill the memory with data
- ts.Memory = MemoryPreset;
+ ts.Memory = (int[])MemoryPreset.Clone();
for (int a = LineToStarttRead + 2; a < rows.Length; a++)
{
rowdata = rows[a].Split(' ');
diff --git a/ZXBox.Core.Tests/ZXBox.Core.Tests.csproj b/ZXBox.Core.Tests/ZXBox.Core.Tests.csproj
index c76c9d1..8341e14 100644
--- a/ZXBox.Core.Tests/ZXBox.Core.Tests.csproj
+++ b/ZXBox.Core.Tests/ZXBox.Core.Tests.csproj
@@ -20,13 +20,4 @@
-
-
- PreserveNewest
-
-
-
-
-
-
diff --git a/ZXBox.Core.Tests/ZxPrinterTests.cs b/ZXBox.Core.Tests/ZxPrinterTests.cs
new file mode 100644
index 0000000..b8c8f55
--- /dev/null
+++ b/ZXBox.Core.Tests/ZxPrinterTests.cs
@@ -0,0 +1,151 @@
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using ZXBox.Hardware.Output;
+
+namespace ZXBox.Core.Tests;
+
+[TestClass]
+public class ZxPrinterTests
+{
+ [TestMethod]
+ public void PrinterOnlyRespondsWhenConnected()
+ {
+ var printer = new ZxPrinter();
+
+ Assert.IsFalse(printer.TryReadPort(0x00FB, out _));
+ Assert.IsFalse(printer.HandlePortWrite(0x00FB, 0x80));
+
+ printer.Connect();
+
+ Assert.IsTrue(printer.TryReadPort(0x00FB, out var status));
+ Assert.AreEqual(0, status & 0x40);
+ }
+
+ [TestMethod]
+ public void PrinterRespondsOnAnyPortWithA2Low()
+ {
+ var printer = new ZxPrinter();
+ printer.Connect();
+
+ Assert.IsTrue(printer.TryReadPort(0x00FB, out _));
+ Assert.IsTrue(printer.TryReadPort(0x00F3, out _));
+ Assert.IsTrue(printer.TryReadPort(0x00EB, out _));
+ Assert.IsFalse(printer.TryReadPort(0x00FF, out _));
+ }
+
+ [TestMethod]
+ public void PrinterUsesConfiguredFastPixelTiming()
+ {
+ var printer = new ZxPrinter();
+ printer.Connect();
+
+ printer.HandlePortWrite(0x00FB, 0x80);
+ printer.TryReadPort(0x00FB, out var statusAfterWrite);
+ Assert.AreEqual(0, statusAfterWrite & 0x01);
+ Assert.AreEqual(0x80, statusAfterWrite & 0x80);
+
+ printer.AdvanceTStates(ZxPrinter.FastPixelStepTStates - 1);
+ printer.TryReadPort(0x00FB, out var statusBeforeStep);
+ Assert.AreEqual(0, statusBeforeStep & 0x01);
+
+ printer.AdvanceTStates(1);
+ printer.TryReadPort(0x00FB, out var statusAfterStep);
+ Assert.AreEqual(1, statusAfterStep & 0x01);
+
+ var snapshot = printer.GetPaperSnapshot();
+ Assert.AreEqual(1, snapshot.Height);
+ Assert.AreEqual(1, snapshot.Pixels[0]);
+ }
+
+ [TestMethod]
+ public void PrinterPoweringStylusOnRaisesPaperStartLatchWithoutMirroringStylusState()
+ {
+ var printer = new ZxPrinter();
+ printer.Connect();
+
+ printer.HandlePortWrite(0x00FB, 0x80);
+ printer.TryReadPort(0x00FB, out var statusAfterPowerOn);
+ Assert.AreEqual(0x80, statusAfterPowerOn & 0x80);
+
+ printer.HandlePortWrite(0x00FB, 0x80);
+ printer.TryReadPort(0x00FB, out var statusAfterSecondWrite);
+ Assert.AreEqual(0, statusAfterSecondWrite & 0x80);
+ }
+
+ [TestMethod]
+ public void PrinterCompletesScanlineAfter256Pixels()
+ {
+ var printer = new ZxPrinter();
+ printer.Connect();
+ printer.HandlePortWrite(0x00FB, 0x80);
+
+ for (var pixel = 0; pixel < ZxPrinter.PaperWidth; pixel++)
+ {
+ printer.AdvanceTStates(ZxPrinter.FastPixelStepTStates);
+
+ if (pixel < ZxPrinter.PaperWidth - 1)
+ {
+ printer.HandlePortWrite(0x00FB, 0x80);
+ }
+ }
+
+ printer.TryReadPort(0x00FB, out var status);
+ var snapshot = printer.GetPaperSnapshot();
+
+ Assert.AreEqual(0x80, status & 0x80);
+ Assert.AreEqual(1, snapshot.Height);
+ Assert.AreEqual(1, snapshot.Pixels[ZxPrinter.PaperWidth - 1]);
+ }
+
+ [TestMethod]
+ public void PrinterMotorStopFreezesPrinting()
+ {
+ var printer = new ZxPrinter();
+ printer.Connect();
+ printer.HandlePortWrite(0x00FB, 0x84);
+
+ printer.AdvanceTStates(ZxPrinter.FastPixelStepTStates * 4);
+ printer.TryReadPort(0x00FB, out var status);
+ var snapshot = printer.GetPaperSnapshot();
+
+ Assert.AreEqual(0, status & 0x01);
+ Assert.AreEqual(0, snapshot.Height);
+ }
+
+ [TestMethod]
+ public void PrinterSlowModeUsesLongerPixelTiming()
+ {
+ var printer = new ZxPrinter();
+ printer.Connect();
+ printer.HandlePortWrite(0x00FB, 0x82);
+
+ printer.AdvanceTStates(ZxPrinter.SlowPixelStepTStates - 1);
+ printer.TryReadPort(0x00FB, out var statusBeforeStep);
+ Assert.AreEqual(0, statusBeforeStep & 0x01);
+
+ printer.AdvanceTStates(1);
+ printer.TryReadPort(0x00FB, out var statusAfterStep);
+ var snapshot = printer.GetPaperSnapshot();
+
+ Assert.AreEqual(1, statusAfterStep & 0x01);
+ Assert.AreEqual(1, snapshot.Height);
+ Assert.AreEqual(1, snapshot.Pixels[0]);
+ }
+
+ [TestMethod]
+ public void SpectrumRoutesPrinterAliasesThroughDedicatedDevice()
+ {
+ var speccy = new ZXSpectrum();
+ speccy.ConnectZxPrinter();
+
+ speccy.Out(0x00F3, 0x80, 0);
+ speccy.TstateChange(ZxPrinter.FastPixelStepTStates);
+
+ var status = speccy.In(0x00EB);
+ var snapshot = speccy.ZxPrinter.GetPaperSnapshot();
+
+ Assert.AreEqual(0, status & 0x40);
+ Assert.AreEqual(1, status & 0x01);
+ Assert.AreEqual(1, snapshot.Height);
+ Assert.AreEqual(1, snapshot.Pixels[0]);
+ }
+}
diff --git a/ZXBox.Core.Tests/ZxSpectrumAyIntegrationTests.cs b/ZXBox.Core.Tests/ZxSpectrumAyIntegrationTests.cs
new file mode 100644
index 0000000..d6746da
--- /dev/null
+++ b/ZXBox.Core.Tests/ZxSpectrumAyIntegrationTests.cs
@@ -0,0 +1,58 @@
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using System;
+using System.Linq;
+
+namespace ZXBox.Core.Tests;
+
+[TestClass]
+public class ZxSpectrumAyIntegrationTests
+{
+ [TestMethod]
+ public void Spectrum128kRoutesAyPortsWhile48kIgnoresThem()
+ {
+ var plus = new ZXSpectrum(rom: RomEnum.ZXSpectrumPlus);
+ plus.Reset();
+ ProgramChannelATone(plus);
+
+ var plusFrame = plus.AyChip.RenderAudioFrame(960, plus.FrameTStates);
+
+ var classic48 = new ZXSpectrum(rom: RomEnum.ZXSpectrum48k);
+ classic48.Reset();
+ ProgramChannelATone(classic48);
+
+ var classic48Frame = classic48.AyChip.RenderAudioFrame(960, classic48.FrameTStates);
+
+ Assert.IsTrue(plusFrame.Any(sample => Math.Abs(sample) > 0.01f));
+ Assert.IsFalse(classic48Frame.Any(sample => Math.Abs(sample) > 0.001f));
+ }
+
+ [TestMethod]
+ public void Spectrum128kPagingUsesAllThreeBankBitsAndHonorsPagingLock()
+ {
+ var speccy = new ZXSpectrum(rom: RomEnum.ZXSpectrumPlus);
+ speccy.Reset();
+
+ speccy.Out(0x3FFD, 0x27, 0);
+ speccy.WriteByteToMemory(0xC000, 0xA5);
+
+ Assert.AreEqual(0xA5, speccy.Banks[7][0]);
+
+ speccy.Out(0x7FFD, 0x00, 0);
+ speccy.WriteByteToMemory(0xC001, 0x5A);
+
+ Assert.AreEqual(0x00, speccy.Banks[0][1]);
+ Assert.AreEqual(0x5A, speccy.Banks[7][1]);
+ }
+
+ private static void ProgramChannelATone(ZXSpectrum speccy)
+ {
+ speccy.Out(0xFFFD, 0x00, 0);
+ speccy.Out(0xBFFD, 0x40, 0);
+ speccy.Out(0xFFFD, 0x01, 0);
+ speccy.Out(0xBFFD, 0x00, 0);
+ speccy.Out(0xFFFD, 0x07, 0);
+ speccy.Out(0xBFFD, 0x3E, 0);
+ speccy.Out(0xFFFD, 0x08, 0);
+ speccy.Out(0xBFFD, 0x0F, 0);
+ }
+}
diff --git a/ZXBox.Core/Cpus/Z80/Z80.cs b/ZXBox.Core/Cpus/Z80/Z80.cs
index ce17d7a..7b7bfa4 100644
--- a/ZXBox.Core/Cpus/Z80/Z80.cs
+++ b/ZXBox.Core/Cpus/Z80/Z80.cs
@@ -420,6 +420,11 @@ public int StackpopWord()
public abstract int ReadByteFromMemory(int address);
+ protected virtual int ReadOpcodeByteFromMemory(int address)
+ {
+ return ReadByteFromMemory(address);
+ }
+
public int ReadWordFromMemory(int address)
{
return (ReadByteFromMemory(address + 1 & 0xffff) << 8 | ReadByteFromMemory(address & 0xffff)) & 0xffff;
@@ -450,9 +455,11 @@ public int EndTstates2
}
//Interupts and memory
public bool BlockINT = true;
+ public bool AutoInterruptAtEndOfTimeslice { get; set; } = true;
public bool IFF = false;
public bool IFF2 = false;
public int IM = 2;
+ protected int _interruptInhibitInstructionCount = 0;
public int _R7 = 0;
public int _R = 0;
public int R7
@@ -473,9 +480,9 @@ public int R
public void Refresh(int t)
{
- // _R += t;
- _R = (byte)(((_R + 1) & 0x7F) | (_R & 0x80));
- //SubtractNumberOfTStatesLeft( 1;
+ int lowBits = (_R & 0x7F);
+ lowBits = (lowBits + t) & 0x7F;
+ _R = (byte)(lowBits | (_R & 0x80));
}
public void Reset()
@@ -507,6 +514,9 @@ public void Reset()
IFF = false;
IFF2 = false;
IM = 0;
+ AutoInterruptAtEndOfTimeslice = true;
+ BlockINT = false;
+ _interruptInhibitInstructionCount = 0;
_numberOfTStatesLeft = 0;
this.Out(254, 5, 0); //Border Color
@@ -520,7 +530,14 @@ public void Reset()
//System.Text.StringBuilder sb = new StringBuilder();
public void NextOpcode()
{
- opcode = (ReadByteFromMemory(PC) & 0xff);
+ Refresh(1);
+ opcode = (ReadOpcodeByteFromMemory(PC) & 0xff);
+ PC = (PC + 1) & 0xffff;
+ }
+
+ public void NextOpcodeWithoutRefresh()
+ {
+ opcode = (ReadOpcodeByteFromMemory(PC) & 0xff);
PC = (PC + 1) & 0xffff;
}
@@ -553,6 +570,29 @@ public int interrupt()
return 0;
}
+ protected void BeginInterruptInhibit()
+ {
+ BlockINT = true;
+ _interruptInhibitInstructionCount = 2;
+ }
+
+ protected void ClearInterruptInhibit()
+ {
+ BlockINT = false;
+ _interruptInhibitInstructionCount = 0;
+ }
+
+ private void AdvanceInterruptInhibit()
+ {
+ if (_interruptInhibitInstructionCount <= 0)
+ {
+ return;
+ }
+
+ _interruptInhibitInstructionCount--;
+ BlockINT = _interruptInhibitInstructionCount > 0;
+ }
+
public int NumberOfTstates = 0;
public StringBuilder sb = new StringBuilder();
@@ -569,9 +609,21 @@ public virtual void DoIntructions(int numberOfTStates, Func gameSpecif
_EndTstates2 = numberOfTStates;
while (true)
{
+ if (_numberOfTStatesLeft <= 0)
+ {
+ if (!AutoInterruptAtEndOfTimeslice || BlockINT)
+ {
+ break;
+ }
+ }
if (interruptTriggered(_numberOfTStatesLeft))
{
+ if (BlockINT)
+ {
+ break;
+ }
+
//NumberOfTStatesLeft += (NumberOfTStates - interrupt());
SubtractNumberOfTStatesLeft(interrupt());
break;
@@ -592,25 +644,23 @@ public virtual void DoIntructions(int numberOfTStates, Func gameSpecif
DoCBPrefixInstruction();
break;
case 0xDD:
- Refresh(1);
NextOpcode();
DoDDorFDPrefixInstruction(IndexRegistryEnum.IX);
break;
case 0xED:
- Refresh(1);
NextOpcode();
DoEDPrefixInstruction();
break;
case 0xFD:
- Refresh(1);
NextOpcode();
DoDDorFDPrefixInstruction(IndexRegistryEnum.IY);
break;
default:
- Refresh(1);
DoNoPrefixInstruction();
break;
}
+
+ AdvanceInterruptInhibit();
}
}
}
diff --git a/ZXBox.Core/Cpus/Z80/Z80AssemblerInstructions.cs b/ZXBox.Core/Cpus/Z80/Z80AssemblerInstructions.cs
index 26289da..4d6a931 100644
--- a/ZXBox.Core/Cpus/Z80/Z80AssemblerInstructions.cs
+++ b/ZXBox.Core/Cpus/Z80/Z80AssemblerInstructions.cs
@@ -17,7 +17,7 @@ public void Refresh()
public bool interruptTriggered(int tstates)
{
- return (tstates <= 0);
+ return AutoInterruptAtEndOfTimeslice && (tstates <= 0);
}
int pushsp;
@@ -222,7 +222,7 @@ public void Halt()
{
tmphaltsToInterrupt = (((_numberOfTStatesLeft - 1) / 4) + 1);
SubtractNumberOfTStatesLeft((tmphaltsToInterrupt * 4));
- Refresh(tmphaltsToInterrupt - 1);
+ Refresh(tmphaltsToInterrupt);
}
public void RST(int position)
diff --git a/ZXBox.Core/Cpus/Z80/Z80CBPrefixInstructions.cs b/ZXBox.Core/Cpus/Z80/Z80CBPrefixInstructions.cs
index b6a43f1..6e3ad32 100644
--- a/ZXBox.Core/Cpus/Z80/Z80CBPrefixInstructions.cs
+++ b/ZXBox.Core/Cpus/Z80/Z80CBPrefixInstructions.cs
@@ -6,7 +6,6 @@ public void DoCBPrefixInstruction()
{
//TODO: Check i values are read from the right places
- Refresh(1);
switch (opcode)
{
case 0x36: //SLL (HL)*
diff --git a/ZXBox.Core/Cpus/Z80/Z80DDandFDPrefixInstructions.cs b/ZXBox.Core/Cpus/Z80/Z80DDandFDPrefixInstructions.cs
index b5b3bfa..92d69a1 100644
--- a/ZXBox.Core/Cpus/Z80/Z80DDandFDPrefixInstructions.cs
+++ b/ZXBox.Core/Cpus/Z80/Z80DDandFDPrefixInstructions.cs
@@ -21,7 +21,6 @@ public void DoDDorFDPrefixInstruction(IndexRegistryEnum IRindex)
ixd = 0;
index = (int)IRindex;
- Refresh(1);
switch (opcode)
{
case 0x84: //ADD A,IXH*
@@ -402,7 +401,7 @@ public void DoDDorFDPrefixInstruction(IndexRegistryEnum IRindex)
case 0xCB:
dvalue = d;
tmpValue = 0;
- NextOpcode();
+ NextOpcodeWithoutRefresh();
switch (opcode)
{
case 0x00: //LD B,RLC (IX+d)*
diff --git a/ZXBox.Core/Cpus/Z80/Z80EDPrefixInstructions.cs b/ZXBox.Core/Cpus/Z80/Z80EDPrefixInstructions.cs
index 0031729..b1ba260 100644
--- a/ZXBox.Core/Cpus/Z80/Z80EDPrefixInstructions.cs
+++ b/ZXBox.Core/Cpus/Z80/Z80EDPrefixInstructions.cs
@@ -6,7 +6,6 @@ public partial class Z80
{
public void DoEDPrefixInstruction()
{
- Refresh(1);
switch (opcode)
{
case 0x4A: //ADC HL,BC
diff --git a/ZXBox.Core/Cpus/Z80/Z80NOPrefixInstructions.cs b/ZXBox.Core/Cpus/Z80/Z80NOPrefixInstructions.cs
index aa8c401..bc3b81e 100644
--- a/ZXBox.Core/Cpus/Z80/Z80NOPrefixInstructions.cs
+++ b/ZXBox.Core/Cpus/Z80/Z80NOPrefixInstructions.cs
@@ -166,6 +166,7 @@ public void DoNoPrefixInstruction()
break;
case 0xF3: //DI
IFF = IFF2 = false;
+ ClearInterruptInhibit();
SubtractNumberOfTStatesLeft(4);
break;
case 0x10: //DNJZ
@@ -173,6 +174,7 @@ public void DoNoPrefixInstruction()
break;
case 0xFB: //EI
IFF = IFF2 = true;
+ BeginInterruptInhibit();
SubtractNumberOfTStatesLeft(4);
break;
case 0xE3: //EX (SP),HL
diff --git a/ZXBox.Core/Hardware/Input/TapePlayer.cs b/ZXBox.Core/Hardware/Input/TapePlayer.cs
index 109b60c..2093832 100644
--- a/ZXBox.Core/Hardware/Input/TapePlayer.cs
+++ b/ZXBox.Core/Hardware/Input/TapePlayer.cs
@@ -1,4 +1,5 @@
-using System.Collections.Generic;
+using System;
+using System.Collections.Generic;
using System.Linq;
using ZXBox.Core.Tape;
using ZXBox.Hardware.Interfaces;
@@ -6,80 +7,63 @@
namespace ZXBox.Core.Hardware.Input
{
- ///
- ///A Pilot consisting of 8063 (for header blocks) or 3223 (data blocks) pulses, each of which has a duration of 2168 tstates.
- ///A first sync pulse of 667 tstates.
- ///A second sync pulse of 735 tstates.
- ///The block data: a reset bit is encoded as two pulses of 855 tstates each, a set bit as two pulses of 1710 tstates each.The lowest byte in memory is first on tape, with the most significant bit first within each byte.
- ///
public class TapePlayer : IInput
{
- private Beeper _beeper;
+ private const int TStatesPerMillisecond = 3500;
+ private readonly Beeper _beeper;
+
public TapePlayer(Beeper beeper)
{
_beeper = beeper;
}
- public TapFormat tf = new TapFormat();
- public void LoadTape(byte[] data)
+ public TapeImage Tape { get; private set; } = new();
+
+ public void LoadTape(byte[] data, string fileName = null)
{
- tf.ReadFile(data);
- bool ear = false;
- long tstate = 0;
- long b = 0;
- int bitmask;
- bool signal;
- foreach (var block in tf.Blocks)
- {
- for (int pilotcount = 0; pilotcount < (block.Data[0] < 128 ? 8063 : 3223); pilotcount++)
- {
- ear = !ear;
- tstate += 2168;
- EarValues.Add(new EarValue() { Ear = ear, TState = tstate, Pulse = PulseTypeEnum.Pilot });
+ LoadTape(TapeFormatFactory.ReadTape(data, fileName));
+ }
- }
+ public void LoadTape(TapeImage tape)
+ {
+ ResetTapeState();
+ Tape = tape;
- //Add sync1
- ear = !ear;
- tstate += 667;
- EarValues.Add(new EarValue() { Ear = ear, TState = tstate, Pulse = PulseTypeEnum.Sync1 });
+ var ear = false;
+ long tstate = 0;
- //Add sync2
- ear = !ear;
- tstate += 735;
- EarValues.Add(new EarValue() { Ear = ear, TState = tstate, Pulse = PulseTypeEnum.Sync2 });
- b = 0;
- for (; b < block.Data.Length; b++)
+ foreach (var block in tape.Blocks)
+ {
+ switch (block)
{
- for (bitmask = 0x80; bitmask > 0; bitmask = bitmask >> 1)
- {
- signal = (block.Data[b] & bitmask) == bitmask;
-
- //Add two pulses
- ear = !ear;
- tstate += signal ? 1710 : 855;
- EarValues.Add(new() { Ear = ear, TState = tstate, Pulse = PulseTypeEnum.Data });
-
- ear = !ear;
- tstate += signal ? 1710 : 855;
- EarValues.Add(new() { Ear = ear, TState = tstate, Pulse = PulseTypeEnum.Data });
- }
+ case TapeDataBlock dataBlock:
+ AppendDataBlock(dataBlock, ref ear, ref tstate);
+ break;
+ case TapePureToneBlock pureToneBlock:
+ AppendPulseRepeats(pureToneBlock.PulseCount, pureToneBlock.PulseLength, PulseTypeEnum.Data, ref ear, ref tstate);
+ break;
+ case TapePulseSequenceBlock pulseSequenceBlock:
+ AppendPulseSequence(pulseSequenceBlock.PulseLengths, PulseTypeEnum.Data, ref ear, ref tstate);
+ break;
+ case TapePauseBlock pauseBlock:
+ AppendPause(pauseBlock.DurationMilliseconds, ref ear, ref tstate);
+ break;
+ case TapeSetSignalLevelBlock signalLevelBlock:
+ ear = signalLevelBlock.High;
+ break;
+ case TapeStopBlock:
+ EarValues.Add(new EarValue { Ear = false, TState = tstate, Pulse = PulseTypeEnum.Stop });
+ break;
}
- ear = !ear;
- tstate += 3500 * 5; //5ms
- EarValues.Add(new() { Ear = ear, TState = tstate, Pulse = PulseTypeEnum.Pause });
- //Pause
- ear = false;
- tstate += 3500 * 1000; //1second;
- EarValues.Add(new() { Ear = ear, TState = tstate, Pulse = PulseTypeEnum.Pause });
-
}
- //Add Termination
- ear = !ear;
- tstate += 947;
- EarValues.Add(new() { Ear = ear, TState = tstate, Pulse = PulseTypeEnum.Termination });
- EarValues.Add(new() { Ear = false, TState = tstate, Pulse = PulseTypeEnum.Stop });
+ if (EarValues.Count == 0 || EarValues[^1].Pulse != PulseTypeEnum.Stop)
+ {
+ ear = !ear;
+ tstate += 947;
+ EarValues.Add(new EarValue { Ear = ear, TState = tstate, Pulse = PulseTypeEnum.Termination });
+ EarValues.Add(new EarValue { Ear = false, TState = tstate, Pulse = PulseTypeEnum.Stop });
+ }
}
public void AddTStates(int tstates)
@@ -90,41 +74,47 @@ public void AddTStates(int tstates)
}
}
- public List EarValues = new();
+ public List EarValues { get; } = new();
+
public void Play()
{
+ if (EarValues.Count == 0)
+ {
+ return;
+ }
+
TotalTstates = EarValues.Last().TState;
IsPlaying = true;
}
- public bool IsPlaying { get; set; } = false;
- public long CurrentTstate = 0;
- public long TotalTstates = 0;
+ public bool IsPlaying { get; set; }
+
+ public long CurrentTstate;
+ public long TotalTstates;
+
+ private int _returnValue = 0xFF;
+ private EarValue _ear;
+ private bool _firstRead = true;
+ private int _tapePosition;
- private long lastTstate = 0;
- private long diff = 0;
- int returnvalue = 0xff;
- EarValue ear;
- bool firstread = true;
- int tapeposition = 0;
public int Input(int Port, int tact)
{
if (IsPlaying)
{
- returnvalue = 0xff;
+ _returnValue = 0xFF;
if ((Port & 0xff) == 0xfe)
{
- if (firstread)
+ if (_firstRead)
{
CurrentTstate = 0;
- firstread = false;
+ _firstRead = false;
}
- for (; tapeposition < EarValues.Count - 1;)
+ for (; _tapePosition < EarValues.Count - 1;)
{
- if (EarValues[tapeposition + 1].TState < CurrentTstate)
+ if (EarValues[_tapePosition + 1].TState < CurrentTstate)
{
- tapeposition++;
+ _tapePosition++;
}
else
{
@@ -132,35 +122,140 @@ public int Input(int Port, int tact)
}
}
- ear = EarValues[tapeposition];
- _beeper.Output(0xfe, (ear.Ear ? 1 : 0) << 4, tact);
- if (ear != null)
+ _ear = EarValues[_tapePosition];
+ _beeper?.Output(0xfe, (_ear.Ear ? 1 : 0) << 4, tact);
+ if (_ear != null)
{
- if (ear.Pulse == PulseTypeEnum.Stop)
+ if (_ear.Pulse == PulseTypeEnum.Stop)
{
IsPlaying = false;
}
- if (ear.Ear)
- return returnvalue |= 1 << 6;
- else
- return returnvalue &= ~(1 << 6);
+
+ if (_ear.Ear)
+ {
+ return _returnValue |= 1 << 6;
+ }
+
+ return _returnValue &= ~(1 << 6);
}
}
+
if (CurrentTstate > TotalTstates)
{
IsPlaying = false;
}
}
- return returnvalue;
+ return _returnValue;
+ }
+
+ private void AppendDataBlock(TapeDataBlock block, ref bool ear, ref long tstate)
+ {
+ if (block.PilotPulseCount > 0 && block.PilotPulseLength > 0)
+ {
+ AppendPulseRepeats(block.PilotPulseCount, block.PilotPulseLength, PulseTypeEnum.Pilot, ref ear, ref tstate);
+ }
+
+ if (block.SyncFirstPulseLength > 0)
+ {
+ ear = !ear;
+ tstate += block.SyncFirstPulseLength;
+ EarValues.Add(new EarValue { Ear = ear, TState = tstate, Pulse = PulseTypeEnum.Sync1 });
+ }
+
+ if (block.SyncSecondPulseLength > 0)
+ {
+ ear = !ear;
+ tstate += block.SyncSecondPulseLength;
+ EarValues.Add(new EarValue { Ear = ear, TState = tstate, Pulse = PulseTypeEnum.Sync2 });
+ }
+
+ var lastByteBits = block.UsedBitsInLastByte is >= 1 and <= 8 ? block.UsedBitsInLastByte : 8;
+ for (var byteIndex = 0; byteIndex < block.Data.Length; byteIndex++)
+ {
+ var bitsInByte = byteIndex == block.Data.Length - 1 ? lastByteBits : 8;
+ for (var bitIndex = 0; bitIndex < bitsInByte; bitIndex++)
+ {
+ var bitMask = 0x80 >> bitIndex;
+ var signal = (block.Data[byteIndex] & bitMask) == bitMask;
+ var pulseLength = signal ? block.OneBitPulseLength : block.ZeroBitPulseLength;
+
+ ear = !ear;
+ tstate += pulseLength;
+ EarValues.Add(new EarValue { Ear = ear, TState = tstate, Pulse = PulseTypeEnum.Data });
+
+ ear = !ear;
+ tstate += pulseLength;
+ EarValues.Add(new EarValue { Ear = ear, TState = tstate, Pulse = PulseTypeEnum.Data });
+ }
+ }
+
+ AppendPauseOrStop(block.PauseAfterMilliseconds, ref ear, ref tstate);
+ }
+
+ private void AppendPulseRepeats(int count, int pulseLength, PulseTypeEnum pulseType, ref bool ear, ref long tstate)
+ {
+ for (var pulseIndex = 0; pulseIndex < count; pulseIndex++)
+ {
+ ear = !ear;
+ tstate += pulseLength;
+ EarValues.Add(new EarValue { Ear = ear, TState = tstate, Pulse = pulseType });
+ }
+ }
+
+ private void AppendPulseSequence(IReadOnlyList pulseLengths, PulseTypeEnum pulseType, ref bool ear, ref long tstate)
+ {
+ for (var pulseIndex = 0; pulseIndex < pulseLengths.Count; pulseIndex++)
+ {
+ ear = !ear;
+ tstate += pulseLengths[pulseIndex];
+ EarValues.Add(new EarValue { Ear = ear, TState = tstate, Pulse = pulseType });
+ }
+ }
+
+ private void AppendPause(int durationMilliseconds, ref bool ear, ref long tstate)
+ {
+ AppendPauseOrStop(durationMilliseconds, ref ear, ref tstate);
+ }
+
+ private void AppendPauseOrStop(int durationMilliseconds, ref bool ear, ref long tstate)
+ {
+ if (durationMilliseconds <= 0)
+ {
+ EarValues.Add(new EarValue { Ear = false, TState = tstate, Pulse = PulseTypeEnum.Stop });
+ return;
+ }
+
+ ear = !ear;
+ tstate += TStatesPerMillisecond;
+ EarValues.Add(new EarValue { Ear = ear, TState = tstate, Pulse = PulseTypeEnum.Pause });
+
+ ear = false;
+ tstate += Math.Max(durationMilliseconds - 1, 0) * TStatesPerMillisecond;
+ EarValues.Add(new EarValue { Ear = ear, TState = tstate, Pulse = PulseTypeEnum.Pause });
+ }
+
+ private void ResetTapeState()
+ {
+ Tape = new TapeImage();
+ EarValues.Clear();
+ IsPlaying = false;
+ CurrentTstate = 0;
+ TotalTstates = 0;
+ _returnValue = 0xFF;
+ _ear = null;
+ _firstRead = true;
+ _tapePosition = 0;
}
}
+
public class EarValue
{
public long TState { get; set; }
public bool Ear { get; set; }
public PulseTypeEnum Pulse { get; set; }
}
+
public enum PulseTypeEnum
{
Data,
@@ -171,5 +266,4 @@ public enum PulseTypeEnum
Stop,
Pause
}
-
}
diff --git a/ZXBox.Core/Hardware/Output/Beeper.cs b/ZXBox.Core/Hardware/Output/Beeper.cs
index 78d2627..be1f01f 100644
--- a/ZXBox.Core/Hardware/Output/Beeper.cs
+++ b/ZXBox.Core/Hardware/Output/Beeper.cs
@@ -7,7 +7,7 @@ namespace ZXBox.Hardware.Output;
public class Beeper : Interfaces.IOutput where T : IComparable, IComparable, IConvertible, IEquatable
{
- public Beeper(T low, T high, int samplesPerFrame, int channels)
+ public Beeper(T low, T high, int samplesPerFrame, int channels, int tStatesPerFrame = 69888)
{
bufferCount = samplesPerFrame;
highBuffer = Enumerable.Repeat(high, bufferCount).ToArray();
@@ -16,6 +16,7 @@ public Beeper(T low, T high, int samplesPerFrame, int channels)
this.high = high;
this.low = low;
this.channels = channels;
+ this.tStatesPerFrame = tStatesPerFrame;
returnbuffer = new T[samplesPerFrame * channels];
buffer = new T[samplesPerFrame];
}
@@ -33,6 +34,7 @@ public Beeper(T low, T high, int samplesPerFrame, int channels)
private T[] returnbuffer;
private int bufferPosition;
private int channels;
+ private int tStatesPerFrame;
public T[] GetSoundBuffer()
{
@@ -46,8 +48,13 @@ public T[] GetSoundBuffer()
}
}
- public void GenerateSound(int tStates = 69888)
+ public void GenerateSound(int tStates = -1)
{
+ if (tStates <= 0)
+ {
+ tStates = tStatesPerFrame;
+ }
+
if (lastTstate < tStates)
{
if (bufferPosition <= bufferCount)
@@ -95,7 +102,7 @@ public void GenerateSound(int tStates = 69888)
//The output is is not dependent on the way the sound will be outputted but rather all the values the buzzer would have at any given tstate
public void Output(int Port, int ByteValue, int tState)
{
- double buffertstate = Convert.ToDouble(samplesPerFrame) / 69888d;
+ double buffertstate = Convert.ToDouble(samplesPerFrame) / tStatesPerFrame;
if ((Port & 0xff) == 0xfe)
{
diff --git a/ZXBox.Core/Hardware/Output/ZxPrinter.cs b/ZXBox.Core/Hardware/Output/ZxPrinter.cs
new file mode 100644
index 0000000..ed65fb1
--- /dev/null
+++ b/ZXBox.Core/Hardware/Output/ZxPrinter.cs
@@ -0,0 +1,273 @@
+using System;
+using System.Collections.Generic;
+
+namespace ZXBox.Hardware.Output;
+
+public sealed class ZxPrinter
+{
+ public const int PaperWidth = 256;
+ public const int FastPixelStepTStates = 12288;
+ public const int SlowPixelStepTStates = FastPixelStepTStates * 2;
+
+ private readonly object _syncRoot = new();
+ private readonly List _completedLines = new();
+ private readonly byte[] _currentLine = new byte[PaperWidth];
+
+ private bool _connected;
+ private bool _motorRunning;
+ private bool _slowMotor;
+ private bool _stylusPowered;
+ private bool _paperStartLatch;
+ private bool _nextPixelLatch;
+ private int _headColumn = -1;
+ private int _tStateAccumulator;
+ private int _paperVersion;
+
+ public bool Connected
+ {
+ get
+ {
+ lock (_syncRoot)
+ {
+ return _connected;
+ }
+ }
+ }
+
+ public int PaperHeight
+ {
+ get
+ {
+ lock (_syncRoot)
+ {
+ return _completedLines.Count + (_headColumn >= 0 ? 1 : 0);
+ }
+ }
+ }
+
+ public int PaperVersion
+ {
+ get
+ {
+ lock (_syncRoot)
+ {
+ return _paperVersion;
+ }
+ }
+ }
+
+ public void Connect()
+ {
+ lock (_syncRoot)
+ {
+ _connected = true;
+ ResetRuntimeLocked();
+ }
+ }
+
+ public void Disconnect()
+ {
+ lock (_syncRoot)
+ {
+ _connected = false;
+ ResetRuntimeLocked();
+ }
+ }
+
+ public void ResetRuntime()
+ {
+ lock (_syncRoot)
+ {
+ ResetRuntimeLocked();
+ }
+ }
+
+ public void ClearPaper()
+ {
+ lock (_syncRoot)
+ {
+ _completedLines.Clear();
+ Array.Clear(_currentLine);
+ _headColumn = -1;
+ _paperVersion++;
+ }
+ }
+
+ public bool TryReadPort(int port, out int value)
+ {
+ lock (_syncRoot)
+ {
+ value = 0xFF;
+
+ if (!_connected || !ClaimsPort(port))
+ {
+ return false;
+ }
+
+ value = BuildStatusByte();
+ return true;
+ }
+ }
+
+ public bool HandlePortWrite(int port, int value)
+ {
+ lock (_syncRoot)
+ {
+ if (!_connected || !ClaimsPort(port))
+ {
+ return false;
+ }
+
+ var stylusWasPowered = _stylusPowered;
+ _paperStartLatch = false;
+ _nextPixelLatch = false;
+ _stylusPowered = (value & 0x80) != 0;
+ _motorRunning = (value & 0x04) == 0;
+ _slowMotor = (value & 0x02) != 0;
+
+ if (!stylusWasPowered && _stylusPowered)
+ {
+ _paperStartLatch = true;
+ }
+
+ return true;
+ }
+ }
+
+ public void AdvanceTStates(int diff)
+ {
+ if (diff <= 0)
+ {
+ return;
+ }
+
+ lock (_syncRoot)
+ {
+ if (!_connected || !_motorRunning)
+ {
+ return;
+ }
+
+ _tStateAccumulator += diff;
+ var stepSize = _slowMotor ? SlowPixelStepTStates : FastPixelStepTStates;
+
+ while (_tStateAccumulator >= stepSize)
+ {
+ _tStateAccumulator -= stepSize;
+ AdvanceOnePixel();
+ }
+ }
+ }
+
+ public ZxPrinterPaperSnapshot GetPaperSnapshot()
+ {
+ lock (_syncRoot)
+ {
+ var height = _completedLines.Count + (_headColumn >= 0 ? 1 : 0);
+ if (height == 0)
+ {
+ return new ZxPrinterPaperSnapshot(PaperWidth, 0, Array.Empty(), _paperVersion);
+ }
+
+ var pixels = new byte[PaperWidth * height];
+
+ for (var lineIndex = 0; lineIndex < _completedLines.Count; lineIndex++)
+ {
+ Buffer.BlockCopy(_completedLines[lineIndex], 0, pixels, lineIndex * PaperWidth, PaperWidth);
+ }
+
+ if (_headColumn >= 0)
+ {
+ Buffer.BlockCopy(_currentLine, 0, pixels, _completedLines.Count * PaperWidth, PaperWidth);
+ }
+
+ return new ZxPrinterPaperSnapshot(PaperWidth, height, pixels, _paperVersion);
+ }
+ }
+
+ private static bool ClaimsPort(int port)
+ {
+ return (port & 0x0004) == 0;
+ }
+
+ private int BuildStatusByte()
+ {
+ var status = 0x3E;
+
+ if (_paperStartLatch)
+ {
+ status |= 0x80;
+ }
+
+ if (_nextPixelLatch)
+ {
+ status |= 0x01;
+ }
+
+ return status;
+ }
+
+ private void AdvanceOnePixel()
+ {
+ _nextPixelLatch = true;
+
+ if (_headColumn < 0)
+ {
+ _headColumn = 0;
+ }
+
+ if (_stylusPowered)
+ {
+ _currentLine[_headColumn] = 1;
+ }
+
+ if (_headColumn == PaperWidth - 1)
+ {
+ CommitLine();
+ _headColumn = -1;
+ _paperStartLatch = true;
+ }
+ else
+ {
+ _headColumn++;
+ }
+
+ _paperVersion++;
+ }
+
+ private void CommitLine()
+ {
+ _completedLines.Add((byte[])_currentLine.Clone());
+ Array.Clear(_currentLine);
+ }
+
+ private void ResetRuntimeLocked()
+ {
+ _motorRunning = false;
+ _slowMotor = false;
+ _stylusPowered = false;
+ _paperStartLatch = true;
+ _nextPixelLatch = false;
+ _headColumn = -1;
+ _tStateAccumulator = 0;
+ }
+}
+
+public sealed class ZxPrinterPaperSnapshot
+{
+ public ZxPrinterPaperSnapshot(int width, int height, byte[] pixels, int version)
+ {
+ Width = width;
+ Height = height;
+ Pixels = pixels;
+ Version = version;
+ }
+
+ public int Width { get; }
+
+ public int Height { get; }
+
+ public byte[] Pixels { get; }
+
+ public int Version { get; }
+}
diff --git a/ZXBox.Core/Hardware/Sound/Ay38912Chip.cs b/ZXBox.Core/Hardware/Sound/Ay38912Chip.cs
new file mode 100644
index 0000000..b5dbea4
--- /dev/null
+++ b/ZXBox.Core/Hardware/Sound/Ay38912Chip.cs
@@ -0,0 +1,387 @@
+using System;
+using System.Collections.Generic;
+
+namespace ZXBox.Hardware.Sound;
+
+public sealed class Ay38912Chip
+{
+ public const int RegisterCount = 16;
+ public const int FrameTStates128k = 70908;
+
+ private const int InternalSampleRate = 192000;
+ private const int Zx128TStatesPerSecond = 3546900;
+ private const int SelectPortMask = 0xC002;
+ private const int SelectPortValue = 0xC000;
+ private const int DataPortMask = 0xC002;
+ private const int DataPortValue = 0x8000;
+
+ private static readonly float[] VolumeTable = BuildVolumeTable();
+
+ private readonly byte[] _registers = new byte[RegisterCount];
+ private readonly List _frameSamples = new();
+ private readonly double[] _tonePhase = new double[3];
+
+ private long _sampleAccumulator;
+ private int _frameTStateCursor;
+ private int _selectedRegister;
+ private double _noisePhase;
+ private int _noiseShiftRegister = 0x1FFFF;
+ private bool _noiseOutput = true;
+ private double _envelopePhase;
+ private int _envelopeLevel;
+ private int _envelopeDirection = -1;
+ private bool _envelopeContinue;
+ private bool _envelopeAttack;
+ private bool _envelopeAlternate;
+ private bool _envelopeHold;
+ private bool _envelopeHolding = true;
+ private float _highPassInput;
+ private float _highPassOutput;
+
+ public void Reset()
+ {
+ Array.Clear(_registers);
+ Array.Clear(_tonePhase);
+ _frameSamples.Clear();
+ _sampleAccumulator = 0;
+ _frameTStateCursor = 0;
+ _selectedRegister = 0;
+ _noisePhase = 0d;
+ _noiseShiftRegister = 0x1FFFF;
+ _noiseOutput = true;
+ _envelopePhase = 0d;
+ _envelopeLevel = 0;
+ _envelopeDirection = -1;
+ _envelopeContinue = false;
+ _envelopeAttack = false;
+ _envelopeAlternate = false;
+ _envelopeHold = false;
+ _envelopeHolding = true;
+ _highPassInput = 0f;
+ _highPassOutput = 0f;
+ }
+
+ public bool HandlePortWrite(int port, int value, int frameTState)
+ {
+ port &= 0xFFFF;
+ value &= 0xFF;
+
+ if ((port & SelectPortMask) == SelectPortValue)
+ {
+ AdvanceToFrameTState(frameTState);
+
+ if (value < RegisterCount)
+ {
+ _selectedRegister = value;
+ }
+
+ return true;
+ }
+
+ if ((port & DataPortMask) == DataPortValue)
+ {
+ AdvanceToFrameTState(frameTState);
+ WriteSelectedRegister((byte)value);
+ return true;
+ }
+
+ return false;
+ }
+
+ public bool TryReadPort(int port, int frameTState, out int value)
+ {
+ value = 0xFF;
+ port &= 0xFFFF;
+
+ if ((port & SelectPortMask) != SelectPortValue)
+ {
+ return false;
+ }
+
+ AdvanceToFrameTState(frameTState);
+ value = ReadSelectedRegister();
+ return true;
+ }
+
+ public float[] RenderAudioFrame(int outputSampleCount, int frameTStates)
+ {
+ AdvanceToFrameTState(frameTStates);
+
+ var rendered = ResampleFrame(outputSampleCount);
+ _frameSamples.Clear();
+ _frameTStateCursor = 0;
+
+ return rendered;
+ }
+
+ private void AdvanceToFrameTState(int frameTState)
+ {
+ if (frameTState <= _frameTStateCursor)
+ {
+ return;
+ }
+
+ var deltaTStates = frameTState - _frameTStateCursor;
+ _sampleAccumulator += (long)deltaTStates * InternalSampleRate;
+
+ while (_sampleAccumulator >= Zx128TStatesPerSecond)
+ {
+ _sampleAccumulator -= Zx128TStatesPerSecond;
+ _frameSamples.Add(GenerateInternalSample());
+ }
+
+ _frameTStateCursor = frameTState;
+ }
+
+ private float GenerateInternalSample()
+ {
+ AdvanceTonePhases();
+ AdvanceNoise();
+ AdvanceEnvelope();
+
+ var mixer = _registers[7];
+ var envelopeVolume = VolumeTable[_envelopeLevel];
+ var sample = 0f;
+
+ for (var channel = 0; channel < 3; channel++)
+ {
+ var toneDisabled = (mixer & (1 << channel)) != 0;
+ var noiseDisabled = (mixer & (1 << (channel + 3))) != 0;
+ var toneActive = toneDisabled || _tonePhase[channel] < 0.5d;
+ var noiseActive = noiseDisabled || _noiseOutput;
+
+ if (toneActive && noiseActive)
+ {
+ sample += GetChannelVolume(channel, envelopeVolume);
+ }
+ }
+
+ return sample;
+ }
+
+ private void AdvanceTonePhases()
+ {
+ for (var channel = 0; channel < 3; channel++)
+ {
+ var period = GetTonePeriod(channel);
+ var frequency = 1773450d / (16d * period);
+ _tonePhase[channel] += frequency / InternalSampleRate;
+ _tonePhase[channel] -= Math.Floor(_tonePhase[channel]);
+ }
+ }
+
+ private void AdvanceNoise()
+ {
+ var period = Math.Max(_registers[6] & 0x1F, 1);
+ var frequency = 1773450d / (16d * period);
+ _noisePhase += frequency / InternalSampleRate;
+
+ while (_noisePhase >= 1d)
+ {
+ _noisePhase -= 1d;
+ var feedback = ((_noiseShiftRegister ^ (_noiseShiftRegister >> 3)) & 0x01) != 0 ? 1 : 0;
+ _noiseShiftRegister = (_noiseShiftRegister >> 1) | (feedback << 16);
+ _noiseOutput = (_noiseShiftRegister & 0x01) != 0;
+ }
+ }
+
+ private void AdvanceEnvelope()
+ {
+ if (_envelopeHolding)
+ {
+ return;
+ }
+
+ var period = GetEnvelopePeriod();
+ var frequency = 1773450d / (256d * period);
+ _envelopePhase += frequency / InternalSampleRate;
+
+ while (_envelopePhase >= 1d)
+ {
+ _envelopePhase -= 1d;
+ StepEnvelope();
+ }
+ }
+
+ private void StepEnvelope()
+ {
+ _envelopeLevel += _envelopeDirection;
+
+ if (_envelopeLevel >= 0 && _envelopeLevel <= 15)
+ {
+ return;
+ }
+
+ if (!_envelopeContinue)
+ {
+ _envelopeHolding = true;
+ _envelopeLevel = 0;
+ return;
+ }
+
+ if (_envelopeHold)
+ {
+ _envelopeHolding = true;
+ _envelopeLevel = _envelopeAlternate
+ ? (_envelopeAttack ? 0 : 15)
+ : (_envelopeAttack ? 15 : 0);
+ return;
+ }
+
+ if (_envelopeAlternate)
+ {
+ _envelopeDirection = -_envelopeDirection;
+ }
+
+ _envelopeLevel = _envelopeDirection > 0 ? 0 : 15;
+ }
+
+ private float[] ResampleFrame(int outputSampleCount)
+ {
+ if (outputSampleCount <= 0)
+ {
+ return Array.Empty();
+ }
+
+ var output = new float[outputSampleCount];
+
+ if (_frameSamples.Count == 0)
+ {
+ return output;
+ }
+
+ if (_frameSamples.Count == 1)
+ {
+ Array.Fill(output, _frameSamples[0]);
+ return NormalizeFrame(output);
+ }
+
+ var lastSourceIndex = _frameSamples.Count - 1;
+ for (var i = 0; i < output.Length; i++)
+ {
+ var sourcePosition = output.Length == 1
+ ? 0d
+ : (double)i * lastSourceIndex / (output.Length - 1);
+ var sourceIndex = (int)Math.Floor(sourcePosition);
+ var fraction = sourcePosition - sourceIndex;
+ var nextIndex = Math.Min(sourceIndex + 1, lastSourceIndex);
+
+ output[i] = _frameSamples[sourceIndex] +
+ ((_frameSamples[nextIndex] - _frameSamples[sourceIndex]) * (float)fraction);
+ }
+
+ return NormalizeFrame(output);
+ }
+
+ private float[] NormalizeFrame(float[] buffer)
+ {
+ if (buffer.Length == 0)
+ {
+ return buffer;
+ }
+
+ for (var i = 0; i < buffer.Length; i++)
+ {
+ var sample = buffer[i] / 1.5f;
+ var filtered = sample - _highPassInput + (0.995f * _highPassOutput);
+ _highPassInput = sample;
+ _highPassOutput = filtered;
+ buffer[i] = Math.Clamp(filtered, -1f, 1f);
+ }
+
+ return buffer;
+ }
+
+ private void WriteSelectedRegister(byte value)
+ {
+ switch (_selectedRegister)
+ {
+ case 0:
+ case 2:
+ case 4:
+ case 11:
+ case 12:
+ case 14:
+ _registers[_selectedRegister] = value;
+ break;
+ case 1:
+ case 3:
+ case 5:
+ _registers[_selectedRegister] = (byte)(value & 0x0F);
+ break;
+ case 6:
+ _registers[_selectedRegister] = (byte)(value & 0x1F);
+ break;
+ case 7:
+ _registers[_selectedRegister] = value;
+ break;
+ case 8:
+ case 9:
+ case 10:
+ _registers[_selectedRegister] = (byte)(value & 0x1F);
+ break;
+ case 13:
+ _registers[_selectedRegister] = (byte)(value & 0x0F);
+ TriggerEnvelope();
+ break;
+ }
+ }
+
+ private int ReadSelectedRegister()
+ {
+ if (_selectedRegister < 0 || _selectedRegister >= RegisterCount)
+ {
+ return 0xFF;
+ }
+
+ return _registers[_selectedRegister];
+ }
+
+ private void TriggerEnvelope()
+ {
+ _envelopeContinue = (_registers[13] & 0x08) != 0;
+ _envelopeAttack = (_registers[13] & 0x04) != 0;
+ _envelopeAlternate = (_registers[13] & 0x02) != 0;
+ _envelopeHold = (_registers[13] & 0x01) != 0;
+ _envelopeHolding = false;
+ _envelopePhase = 0d;
+ _envelopeDirection = _envelopeAttack ? 1 : -1;
+ _envelopeLevel = _envelopeAttack ? 0 : 15;
+ }
+
+ private int GetTonePeriod(int channel)
+ {
+ var fineRegister = channel * 2;
+ var coarseRegister = fineRegister + 1;
+ var period = _registers[fineRegister] | ((_registers[coarseRegister] & 0x0F) << 8);
+ return Math.Max(period, 1);
+ }
+
+ private int GetEnvelopePeriod()
+ {
+ var period = _registers[11] | (_registers[12] << 8);
+ return Math.Max(period, 1);
+ }
+
+ private float GetChannelVolume(int channel, float envelopeVolume)
+ {
+ var register = _registers[8 + channel];
+ return (register & 0x10) != 0
+ ? envelopeVolume
+ : VolumeTable[register & 0x0F];
+ }
+
+ private static float[] BuildVolumeTable()
+ {
+ var table = new float[16];
+ table[0] = 0f;
+
+ for (var i = 1; i < table.Length; i++)
+ {
+ table[i] = (float)Math.Pow(10d, ((i - 15) * 3d) / 20d);
+ }
+
+ table[15] = 1f;
+ return table;
+ }
+}
diff --git a/ZXBox.Core/Hardware/Speech/CurrahMicroSpeech.cs b/ZXBox.Core/Hardware/Speech/CurrahMicroSpeech.cs
new file mode 100644
index 0000000..5b9d773
--- /dev/null
+++ b/ZXBox.Core/Hardware/Speech/CurrahMicroSpeech.cs
@@ -0,0 +1,309 @@
+#nullable enable
+
+using System;
+
+namespace ZXBox.Hardware.Speech;
+
+public sealed class CurrahMicroSpeech
+{
+ public const int CurrahRomSize = 0x800;
+
+ private const int ToggleAddress = 0x0038;
+ private const int LowerRomEnd = 0x1000;
+ private const int ActiveWindowEnd = 0x4000;
+ private const int PlayAddressMask = 0xF000;
+ private const int PlayAddressValue = 0x1000;
+ private const int IntonationAddressMask = 0xF000;
+ private const int IntonationAddressValue = 0x3000;
+
+ private readonly byte[] _rom = new byte[CurrahRomSize];
+ private readonly Sp0256Chip _speechChip = new();
+
+ public bool Connected { get; private set; }
+
+ public bool Active { get; private set; }
+
+ public bool HasRom { get; private set; }
+
+ public bool HasSpeechRom => _speechChip.HasRom;
+
+ public CurrahMicroSpeechIntonation Intonation { get; private set; } = CurrahMicroSpeechIntonation.Normal;
+
+ public void Connect()
+ {
+ Connected = true;
+ ResetRuntime();
+ }
+
+ public void Disconnect()
+ {
+ Connected = false;
+ ResetRuntime();
+ }
+
+ public void ResetRuntime()
+ {
+ Active = false;
+ Intonation = CurrahMicroSpeechIntonation.Normal;
+ _speechChip.ResetRuntime();
+ _speechChip.SetClock(Sp0256Chip.NormalClockHz, 0);
+ }
+
+ public void LoadRom(byte[] romBytes)
+ {
+ ArgumentNullException.ThrowIfNull(romBytes);
+
+ if (romBytes.Length < CurrahRomSize)
+ {
+ throw new ArgumentException($"Currah ROM must be at least {CurrahRomSize} bytes.", nameof(romBytes));
+ }
+
+ Array.Copy(romBytes, _rom, CurrahRomSize);
+ HasRom = true;
+ }
+
+ public void LoadSpeechRom(byte[] romBytes)
+ {
+ _speechChip.LoadRom(romBytes);
+ _speechChip.SetClock(GetClockHz(Intonation), 0);
+ }
+
+ public void ClearRom()
+ {
+ Array.Clear(_rom);
+ HasRom = false;
+ }
+
+ public void ClearSpeechRom()
+ {
+ _speechChip.ClearRom();
+ }
+
+ public bool TryReadMemory(int address, int frameTState, out int value)
+ {
+ value = 0;
+
+ if (!Connected)
+ {
+ return false;
+ }
+
+ address &= 0xffff;
+ ToggleForReadAccess(address);
+
+ if (!Active)
+ {
+ return false;
+ }
+
+ return TryReadMappedMemory(address, frameTState, out value);
+ }
+
+ public bool TryReadOpcodeFetch(int address, int frameTState, out int value)
+ {
+ value = 0;
+
+ if (!Connected)
+ {
+ return false;
+ }
+
+ address &= 0xffff;
+ ToggleForReadAccess(address);
+
+ if (!Active)
+ {
+ return false;
+ }
+
+ return TryReadMappedMemory(address, frameTState, out value);
+ }
+
+ public bool HandleMemoryWrite(int address, int value, int frameTState)
+ {
+ if (!Connected)
+ {
+ return false;
+ }
+
+ address &= 0xffff;
+ value &= 0xff;
+
+ if (!Active)
+ {
+ return address < ActiveWindowEnd;
+ }
+
+ if (IsPlayAddress(address))
+ {
+ _speechChip.WriteAllophone((byte)(value & 0x3f), frameTState);
+ return true;
+ }
+
+ if (IsIntonationAddress(address))
+ {
+ Intonation = GetIntonation(address);
+ _speechChip.SetClock(GetClockHz(Intonation), frameTState);
+ return true;
+ }
+
+ return address < ActiveWindowEnd;
+ }
+
+ public bool TryReadPort(int port, int frameTState, out int value)
+ {
+ value = 0xff;
+
+ if (!Connected)
+ {
+ return false;
+ }
+
+ port &= 0xffff;
+ if (port == ToggleAddress)
+ {
+ Toggle();
+ }
+
+ if (!Active)
+ {
+ return false;
+ }
+
+ if (IsPlayAddress(port))
+ {
+ value = _speechChip.ReadBusy(frameTState) ? 1 : 0;
+ return true;
+ }
+
+ return false;
+ }
+
+ public bool HandlePortWrite(int port, int value, int frameTState)
+ {
+ if (!Connected)
+ {
+ return false;
+ }
+
+ port &= 0xffff;
+ value &= 0xff;
+
+ if (port == ToggleAddress)
+ {
+ Toggle();
+ return true;
+ }
+
+ if (!Active)
+ {
+ return false;
+ }
+
+ if (IsPlayAddress(port))
+ {
+ _speechChip.WriteAllophone((byte)(value & 0x3f), frameTState);
+ return true;
+ }
+
+ if (IsIntonationAddress(port))
+ {
+ Intonation = GetIntonation(port);
+ _speechChip.SetClock(GetClockHz(Intonation), frameTState);
+ return true;
+ }
+
+ return false;
+ }
+
+ public float[] RenderAudioFrame(int samplesPerFrame, int tStatesPerFrame)
+ {
+ if (!Connected)
+ {
+ return samplesPerFrame <= 0 ? Array.Empty() : new float[samplesPerFrame];
+ }
+
+ return _speechChip.RenderFrame(samplesPerFrame, tStatesPerFrame);
+ }
+
+ private void Toggle()
+ {
+ if (Connected)
+ {
+ Active = !Active;
+ }
+ }
+
+ private void ToggleForReadAccess(int address)
+ {
+ if (address == ToggleAddress)
+ {
+ Toggle();
+ }
+ }
+
+ private bool TryReadMappedMemory(int address, int frameTState, out int value)
+ {
+ value = 0;
+
+ if (!Connected || !Active)
+ {
+ return false;
+ }
+
+ if (IsPlayAddress(address))
+ {
+ value = _speechChip.ReadBusy(frameTState) ? 1 : 0;
+ return true;
+ }
+
+ if (!HasRom)
+ {
+ return false;
+ }
+
+ if (address < LowerRomEnd)
+ {
+ value = _rom[address & 0x07ff];
+ return true;
+ }
+
+ if (address < ActiveWindowEnd)
+ {
+ value = 0xff;
+ return true;
+ }
+
+ return false;
+ }
+
+ private static bool IsPlayAddress(int address)
+ {
+ return (address & PlayAddressMask) == PlayAddressValue;
+ }
+
+ private static bool IsIntonationAddress(int address)
+ {
+ return (address & IntonationAddressMask) == IntonationAddressValue;
+ }
+
+ private static CurrahMicroSpeechIntonation GetIntonation(int address)
+ {
+ return (address & 0x01) == 0
+ ? CurrahMicroSpeechIntonation.Normal
+ : CurrahMicroSpeechIntonation.High;
+ }
+
+ private static int GetClockHz(CurrahMicroSpeechIntonation intonation)
+ {
+ return intonation == CurrahMicroSpeechIntonation.Normal
+ ? Sp0256Chip.NormalClockHz
+ : Sp0256Chip.HighClockHz;
+ }
+}
+
+public enum CurrahMicroSpeechIntonation
+{
+ Normal,
+ High
+}
diff --git a/ZXBox.Core/Hardware/Speech/Sp0256Chip.cs b/ZXBox.Core/Hardware/Speech/Sp0256Chip.cs
new file mode 100644
index 0000000..499f752
--- /dev/null
+++ b/ZXBox.Core/Hardware/Speech/Sp0256Chip.cs
@@ -0,0 +1,856 @@
+// Derived from the BSD-3-Clause SP0256 implementation in MAME.
+// Copyright (c) Joseph Zbiciak, Tim Lindner
+
+#nullable enable
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Security.Cryptography;
+
+namespace ZXBox.Hardware.Speech;
+
+public sealed class Sp0256Chip
+{
+ public const int Sp0256RomSize = 0x800;
+ public const int NormalClockHz = 3_050_000;
+ public const int HighClockHz = 3_260_000;
+
+ private const int ClockDivider = 7 * 6 * 8;
+ private const int PerPause = 64;
+ private const int PerNoise = 64;
+ private const int RomPageOffset = 0x1000;
+ private const int ZxTStatesPerSecond = 69_888 * 50;
+ private const string Al2RomSha1 = "e60fcb5fa16ff3f3b69d36c7a6e955744d3feafc";
+
+ private const int Amplitude = 0;
+ private const int Period = 1;
+ private const int B0 = 2;
+ private const int F0 = 3;
+ private const int B1 = 4;
+ private const int F1 = 5;
+ private const int B2 = 6;
+ private const int F2 = 7;
+ private const int B3 = 8;
+ private const int F3 = 9;
+ private const int B4 = 10;
+ private const int F4 = 11;
+ private const int B5 = 12;
+ private const int F5 = 13;
+ private const int InterpolateAmplitude = 14;
+ private const int InterpolatePeriod = 15;
+
+ private const ushort DeltaMask = 1 << 12;
+ private const ushort FieldMask = 1 << 13;
+ private const ushort ClearFiveMask = 1 << 14;
+ private const ushort ClearAllMask = 1 << 15;
+
+ private static readonly short[] QuantizationTable =
+ {
+ 0, 9, 17, 25, 33, 41, 49, 57,
+ 65, 73, 81, 89, 97, 105, 113, 121,
+ 129, 137, 145, 153, 161, 169, 177, 185,
+ 193, 201, 209, 217, 225, 233, 241, 249,
+ 257, 265, 273, 281, 289, 297, 301, 305,
+ 309, 313, 317, 321, 325, 329, 333, 337,
+ 341, 345, 349, 353, 357, 361, 365, 369,
+ 373, 377, 381, 385, 389, 393, 397, 401,
+ 405, 409, 413, 417, 421, 425, 427, 429,
+ 431, 433, 435, 437, 439, 441, 443, 445,
+ 447, 449, 451, 453, 455, 457, 459, 461,
+ 463, 465, 467, 469, 471, 473, 475, 477,
+ 479, 481, 482, 483, 484, 485, 486, 487,
+ 488, 489, 490, 491, 492, 493, 494, 495,
+ 496, 497, 498, 499, 500, 501, 502, 503,
+ 504, 505, 506, 507, 508, 509, 510, 511
+ };
+
+ private static readonly ushort[] DataFormat =
+ {
+ Encode(0, 0, 0, false, false, false, true),
+
+ Encode(8, 0, Amplitude, false, false, false, true),
+ Encode(8, 0, Period, false, false, false, false),
+ Encode(8, 0, B0, false, false, false, false),
+ Encode(8, 0, F0, false, false, false, false),
+ Encode(8, 0, B1, false, false, false, false),
+ Encode(8, 0, F1, false, false, false, false),
+ Encode(8, 0, B2, false, false, false, false),
+ Encode(8, 0, F2, false, false, false, false),
+ Encode(8, 0, B3, false, false, false, false),
+ Encode(8, 0, F3, false, false, false, false),
+ Encode(8, 0, B4, false, false, false, false),
+ Encode(8, 0, F4, false, false, false, false),
+ Encode(8, 0, B5, false, false, false, false),
+ Encode(8, 0, F5, false, false, false, false),
+ Encode(8, 0, InterpolateAmplitude, false, false, false, false),
+ Encode(8, 0, InterpolatePeriod, false, false, false, false),
+
+ Encode(6, 2, Amplitude, false, false, false, true),
+ Encode(8, 0, Period, false, false, false, false),
+ Encode(4, 3, B3, false, false, false, false),
+ Encode(6, 2, F3, false, false, false, false),
+ Encode(7, 1, B4, false, false, false, false),
+ Encode(6, 2, F4, false, false, false, false),
+ Encode(8, 0, B5, false, false, false, false),
+ Encode(8, 0, F5, false, false, false, false),
+
+ Encode(6, 2, Amplitude, false, false, false, true),
+ Encode(8, 0, Period, false, false, false, false),
+ Encode(6, 1, B3, false, false, false, false),
+ Encode(7, 1, F3, false, false, false, false),
+ Encode(8, 0, B4, false, false, false, false),
+ Encode(8, 0, F4, false, false, false, false),
+ Encode(8, 0, B5, false, false, false, false),
+ Encode(8, 0, F5, false, false, false, false),
+
+ Encode(0, 0, 0, false, false, true, false),
+ Encode(6, 2, Amplitude, false, false, false, false),
+ Encode(6, 2, F3, false, true, false, false),
+ Encode(6, 2, F4, false, true, false, false),
+ Encode(8, 0, F5, false, true, false, false),
+
+ Encode(0, 0, 0, false, false, true, false),
+ Encode(6, 2, Amplitude, false, false, false, false),
+ Encode(7, 1, F3, false, true, false, false),
+ Encode(8, 0, F4, false, true, false, false),
+ Encode(8, 0, F5, false, true, false, false),
+
+ 0,
+ 0,
+
+ Encode(4, 2, Amplitude, true, false, false, false),
+ Encode(5, 0, Period, true, false, false, false),
+ Encode(3, 4, B0, true, false, false, false),
+ Encode(3, 3, F0, true, false, false, false),
+ Encode(3, 4, B1, true, false, false, false),
+ Encode(3, 3, F1, true, false, false, false),
+ Encode(3, 4, B2, true, false, false, false),
+ Encode(3, 3, F2, true, false, false, false),
+ Encode(3, 3, B3, true, false, false, false),
+ Encode(4, 2, F3, true, false, false, false),
+ Encode(4, 1, B4, true, false, false, false),
+ Encode(4, 2, F4, true, false, false, false),
+ Encode(5, 0, B5, true, false, false, false),
+ Encode(5, 0, F5, true, false, false, false),
+
+ Encode(4, 2, Amplitude, true, false, false, false),
+ Encode(5, 0, Period, true, false, false, false),
+ Encode(4, 1, B0, true, false, false, false),
+ Encode(4, 2, F0, true, false, false, false),
+ Encode(4, 1, B1, true, false, false, false),
+ Encode(4, 2, F1, true, false, false, false),
+ Encode(4, 1, B2, true, false, false, false),
+ Encode(4, 2, F2, true, false, false, false),
+ Encode(4, 1, B3, true, false, false, false),
+ Encode(5, 1, F3, true, false, false, false),
+ Encode(5, 0, B4, true, false, false, false),
+ Encode(5, 0, F4, true, false, false, false),
+ Encode(5, 0, B5, true, false, false, false),
+ Encode(5, 0, F5, true, false, false, false),
+
+ Encode(0, 0, 0, false, false, true, false),
+ Encode(6, 2, Amplitude, false, false, false, false),
+ Encode(5, 3, F0, false, true, false, false),
+ Encode(5, 3, F1, false, true, false, false),
+ Encode(5, 3, F2, false, true, false, false),
+
+ Encode(0, 0, 0, false, false, true, false),
+ Encode(6, 2, Amplitude, false, false, false, false),
+ Encode(6, 2, F0, false, true, false, false),
+ Encode(6, 2, F1, false, true, false, false),
+ Encode(6, 2, F2, false, true, false, false),
+
+ Encode(6, 2, Amplitude, false, false, false, true),
+ Encode(8, 0, Period, false, false, false, false),
+ Encode(3, 4, B0, false, false, false, false),
+ Encode(5, 3, F0, false, false, false, false),
+ Encode(3, 4, B1, false, false, false, false),
+ Encode(5, 3, F1, false, false, false, false),
+ Encode(3, 4, B2, false, false, false, false),
+ Encode(5, 3, F2, false, false, false, false),
+ Encode(4, 3, B3, false, false, false, false),
+ Encode(6, 2, F3, false, false, false, false),
+ Encode(7, 1, B4, false, false, false, false),
+ Encode(6, 2, F4, false, false, false, false),
+ Encode(5, 0, InterpolateAmplitude, false, false, false, false),
+ Encode(5, 0, InterpolatePeriod, false, false, false, false),
+
+ Encode(6, 2, Amplitude, false, false, false, true),
+ Encode(8, 0, Period, false, false, false, false),
+ Encode(6, 1, B0, false, false, false, false),
+ Encode(6, 2, F0, false, false, false, false),
+ Encode(6, 1, B1, false, false, false, false),
+ Encode(6, 2, F1, false, false, false, false),
+ Encode(6, 1, B2, false, false, false, false),
+ Encode(6, 2, F2, false, false, false, false),
+ Encode(6, 1, B3, false, false, false, false),
+ Encode(7, 1, F3, false, false, false, false),
+ Encode(8, 0, B4, false, false, false, false),
+ Encode(8, 0, F4, false, false, false, false),
+ Encode(5, 0, InterpolateAmplitude, false, false, false, false),
+ Encode(5, 0, InterpolatePeriod, false, false, false, false),
+
+ Encode(4, 2, Amplitude, true, false, false, false),
+ Encode(5, 0, Period, true, false, false, false),
+ Encode(3, 3, B3, true, false, false, false),
+ Encode(4, 2, F3, true, false, false, false),
+ Encode(4, 1, B4, true, false, false, false),
+ Encode(4, 2, F4, true, false, false, false),
+ Encode(5, 0, B5, true, false, false, false),
+ Encode(5, 0, F5, true, false, false, false),
+
+ Encode(4, 2, Amplitude, true, false, false, false),
+ Encode(5, 0, Period, true, false, false, false),
+ Encode(4, 1, B3, true, false, false, false),
+ Encode(5, 1, F3, true, false, false, false),
+ Encode(5, 0, B4, true, false, false, false),
+ Encode(5, 0, F4, true, false, false, false),
+ Encode(5, 0, B5, true, false, false, false),
+ Encode(5, 0, F5, true, false, false, false),
+
+ Encode(6, 2, Amplitude, false, false, false, false),
+ Encode(8, 0, Period, false, false, false, false),
+
+ Encode(6, 2, Amplitude, false, false, false, true),
+ Encode(8, 0, Period, false, false, false, false),
+ Encode(3, 4, B0, false, false, false, false),
+ Encode(5, 3, F0, false, false, false, false),
+ Encode(3, 4, B1, false, false, false, false),
+ Encode(5, 3, F1, false, false, false, false),
+ Encode(3, 4, B2, false, false, false, false),
+ Encode(5, 3, F2, false, false, false, false),
+ Encode(4, 3, B3, false, false, false, false),
+ Encode(6, 2, F3, false, false, false, false),
+ Encode(7, 1, B4, false, false, false, false),
+ Encode(6, 2, F4, false, false, false, false),
+ Encode(8, 0, B5, false, false, false, false),
+ Encode(8, 0, F5, false, false, false, false),
+ Encode(5, 0, InterpolateAmplitude, false, false, false, false),
+ Encode(5, 0, InterpolatePeriod, false, false, false, false),
+
+ Encode(6, 2, Amplitude, false, false, false, true),
+ Encode(8, 0, Period, false, false, false, false),
+ Encode(6, 1, B0, false, false, false, false),
+ Encode(6, 2, F0, false, false, false, false),
+ Encode(6, 1, B1, false, false, false, false),
+ Encode(6, 2, F1, false, false, false, false),
+ Encode(6, 1, B2, false, false, false, false),
+ Encode(6, 2, F2, false, false, false, false),
+ Encode(6, 1, B3, false, false, false, false),
+ Encode(7, 1, F3, false, false, false, false),
+ Encode(8, 0, B4, false, false, false, false),
+ Encode(8, 0, F4, false, false, false, false),
+ Encode(8, 0, B5, false, false, false, false),
+ Encode(8, 0, F5, false, false, false, false),
+ Encode(5, 0, InterpolateAmplitude, false, false, false, false),
+ Encode(5, 0, InterpolatePeriod, false, false, false, false),
+
+ Encode(0, 0, 0, false, false, true, false),
+ Encode(6, 2, Amplitude, false, false, false, false),
+ Encode(8, 0, Period, false, false, false, false),
+ Encode(5, 3, F0, false, true, false, false),
+ Encode(5, 3, F1, false, true, false, false),
+ Encode(5, 3, F2, false, true, false, false),
+ Encode(5, 0, InterpolateAmplitude, false, false, false, false),
+ Encode(5, 0, InterpolatePeriod, false, false, false, false),
+
+ Encode(0, 0, 0, false, false, true, false),
+ Encode(6, 2, Amplitude, false, false, false, false),
+ Encode(8, 0, Period, false, false, false, false),
+ Encode(6, 2, F0, false, true, false, false),
+ Encode(6, 2, F1, false, true, false, false),
+ Encode(6, 2, F2, false, true, false, false),
+ Encode(5, 0, InterpolateAmplitude, false, false, false, false),
+ Encode(5, 0, InterpolatePeriod, false, false, false, false)
+ };
+
+ private static readonly short[] DataFormatIndex =
+ {
+ -1, -1, -1, -1, -1, -1, -1, -1,
+ -1, -1, -1, -1, -1, -1, -1, -1,
+ 17, 22, 17, 24, 25, 30, 25, 32,
+ 83, 94, 129, 142, 97, 108, 145, 158,
+ 83, 96, 129, 144, 97, 110, 145, 160,
+ 73, 77, 74, 77, 78, 82, 79, 82,
+ 33, 36, 34, 37, 38, 41, 39, 42,
+ 127, 128, 127, 128, 127, 128, 127, 128,
+ 1, 14, 1, 16, 1, 14, 1, 16,
+ 45, 56, 45, 58, 59, 70, 59, 72,
+ 161, 166, 162, 166, 169, 174, 170, 174,
+ 111, 116, 111, 118, 119, 124, 119, 126,
+ 161, 168, 162, 168, 169, 176, 170, 176,
+ -1, -1, -1, -1, -1, -1, -1, -1,
+ -1, -1, -1, -1, -1, -1, -1, -1,
+ 0, 0, 0, 0, 0, 0, 0, 0
+ };
+
+ private readonly byte[] _rom = new byte[0x10000];
+ private readonly List _frameSamples = new();
+ private readonly FilterState _filter = new();
+
+ private bool _silent = true;
+ private bool _lrq = true;
+ private int _ald;
+ private int _pc;
+ private int _stack;
+ private bool _halted = true;
+ private uint _mode;
+ private uint _page = RomPageOffset << 3;
+ private int _clockHz = NormalClockHz;
+ private long _sampleAccumulator;
+ private int _frameTStateCursor;
+
+ public bool HasRom { get; private set; }
+
+ public int ClockHz => _clockHz;
+
+ public void LoadRom(byte[] romBytes)
+ {
+ ArgumentNullException.ThrowIfNull(romBytes);
+
+ if (romBytes.Length < Sp0256RomSize)
+ {
+ throw new ArgumentException($"SP0256 ROM must be at least {Sp0256RomSize} bytes.", nameof(romBytes));
+ }
+
+ var normalizedRom = NormalizeAl2Rom(romBytes);
+
+ Array.Clear(_rom);
+ Array.Copy(normalizedRom, 0, _rom, RomPageOffset, Sp0256RomSize);
+ HasRom = true;
+ ResetRuntime();
+ }
+
+ public void ClearRom()
+ {
+ Array.Clear(_rom);
+ HasRom = false;
+ ResetRuntime();
+ }
+
+ public void ResetRuntime()
+ {
+ _frameSamples.Clear();
+ _sampleAccumulator = 0;
+ _frameTStateCursor = 0;
+ _silent = true;
+ _lrq = true;
+ _ald = 0;
+ _pc = 0;
+ _stack = 0;
+ _halted = true;
+ _mode = 0;
+ _page = RomPageOffset << 3;
+ _filter.Reset();
+ }
+
+ public void SetClock(int clockHz, int frameTState)
+ {
+ if (clockHz <= 0)
+ {
+ throw new ArgumentOutOfRangeException(nameof(clockHz));
+ }
+
+ AdvanceToFrameTState(frameTState);
+ _clockHz = clockHz;
+ }
+
+ public void WriteAllophone(byte allophone, int frameTState)
+ {
+ if (!HasRom)
+ {
+ return;
+ }
+
+ AdvanceToFrameTState(frameTState);
+
+ if (!_lrq)
+ {
+ return;
+ }
+
+ _lrq = false;
+ _ald = allophone << 4;
+ }
+
+ public bool ReadBusy(int frameTState)
+ {
+ if (!HasRom)
+ {
+ return false;
+ }
+
+ AdvanceToFrameTState(frameTState);
+ return !_lrq;
+ }
+
+ public float[] RenderFrame(int outputSampleCount, int tStatesPerFrame)
+ {
+ AdvanceToFrameTState(tStatesPerFrame);
+
+ var rendered = ResampleFrame(outputSampleCount);
+ _frameSamples.Clear();
+ _frameTStateCursor = 0;
+
+ return rendered;
+ }
+
+ private void AdvanceToFrameTState(int frameTState)
+ {
+ if (frameTState <= _frameTStateCursor)
+ {
+ return;
+ }
+
+ if (!HasRom)
+ {
+ _frameTStateCursor = frameTState;
+ return;
+ }
+
+ var deltaTStates = frameTState - _frameTStateCursor;
+ _sampleAccumulator += (long)deltaTStates * _clockHz;
+
+ var threshold = (long)ZxTStatesPerSecond * ClockDivider;
+ while (_sampleAccumulator >= threshold)
+ {
+ _sampleAccumulator -= threshold;
+ _frameSamples.Add(GenerateNativeSample());
+ }
+
+ _frameTStateCursor = frameTState;
+ }
+
+ private short GenerateNativeSample()
+ {
+ if (_filter.Repeat <= 0)
+ {
+ Micro();
+ }
+
+ if (_silent && _filter.Repeat <= 0)
+ {
+ return 0;
+ }
+
+ return _filter.UpdateOneSample();
+ }
+
+ private float[] ResampleFrame(int outputSampleCount)
+ {
+ if (outputSampleCount <= 0)
+ {
+ return Array.Empty();
+ }
+
+ var output = new float[outputSampleCount];
+
+ if (_frameSamples.Count == 0)
+ {
+ return output;
+ }
+
+ if (_frameSamples.Count == 1)
+ {
+ var single = NormalizeSample(_frameSamples[0]);
+ Array.Fill(output, single);
+ return output;
+ }
+
+ var lastSourceIndex = _frameSamples.Count - 1;
+ for (var i = 0; i < output.Length; i++)
+ {
+ var sourcePosition = output.Length == 1
+ ? 0d
+ : (double)i * lastSourceIndex / (output.Length - 1);
+ var sourceIndex = (int)Math.Floor(sourcePosition);
+ var fraction = sourcePosition - sourceIndex;
+ var nextIndex = Math.Min(sourceIndex + 1, lastSourceIndex);
+
+ var sample = _frameSamples[sourceIndex] +
+ ((_frameSamples[nextIndex] - _frameSamples[sourceIndex]) * fraction);
+ output[i] = NormalizeSample(sample);
+ }
+
+ return output;
+ }
+
+ private void Micro()
+ {
+ while (_filter.Repeat <= 0)
+ {
+ if (_halted && !_lrq)
+ {
+ _pc = _ald | (RomPageOffset << 3);
+ _halted = false;
+ _lrq = true;
+ _ald = 0;
+ Array.Clear(_filter.Registers);
+ }
+
+ if (_halted)
+ {
+ _filter.Repeat = 1;
+ _lrq = true;
+ _ald = 0;
+ Array.Clear(_filter.Registers);
+ return;
+ }
+
+ var immediate4 = (byte)GetBits(4);
+ var opcode = (byte)GetBits(4);
+ var repeat = 0;
+ var controlTransfer = false;
+
+ switch (opcode)
+ {
+ case 0x0:
+ if (immediate4 != 0)
+ {
+ _page = BitReverse32(immediate4) >> 13;
+ }
+ else
+ {
+ var branchTarget = _stack;
+ _stack = 0;
+
+ if (branchTarget == 0)
+ {
+ _halted = true;
+ _pc = 0;
+ }
+ else
+ {
+ _pc = branchTarget;
+ }
+
+ controlTransfer = true;
+ }
+ break;
+
+ case 0xE:
+ case 0xD:
+ var target =
+ _page |
+ (BitReverse32(immediate4) >> 17) |
+ (BitReverse32(GetBits(8)) >> 21);
+
+ if (opcode == 0xD)
+ {
+ _stack = (_pc + 7) & ~7;
+ }
+
+ _pc = (int)target;
+ controlTransfer = true;
+ break;
+
+ case 0x1:
+ _mode = (uint)(((immediate4 & 8) >> 2) | (immediate4 & 4) | ((immediate4 & 3) << 4));
+ break;
+
+ default:
+ repeat = immediate4 | (int)(_mode & 0x30);
+ break;
+ }
+
+ if (opcode != 0x1)
+ {
+ _mode &= 0x0f;
+ }
+
+ if (controlTransfer)
+ {
+ continue;
+ }
+
+ if (repeat == 0)
+ {
+ continue;
+ }
+
+ _filter.Repeat = repeat + 1;
+
+ var formatIndex = (opcode << 3) | (int)(_mode & 6);
+ var dataStart = DataFormatIndex[formatIndex];
+ var dataEnd = DataFormatIndex[formatIndex + 1];
+
+ for (var i = dataStart; i <= dataEnd; i++)
+ {
+ var control = DataFormat[i];
+
+ var bitLength = control & 0x0f;
+ var shift = (control >> 4) & 0x0f;
+ var parameter = (control >> 8) & 0x0f;
+ var clearAll = (control & ClearAllMask) != 0;
+ var clearFive = (control & ClearFiveMask) != 0;
+ var isDelta = (control & DeltaMask) != 0;
+ var isFieldReplace = (control & FieldMask) != 0;
+
+ if (clearAll)
+ {
+ Array.Clear(_filter.Registers);
+ _silent = true;
+ }
+
+ if (clearFive)
+ {
+ _filter.Registers[B5] = 0;
+ _filter.Registers[F5] = 0;
+ }
+
+ if (bitLength == 0)
+ {
+ continue;
+ }
+
+ var value = (int)GetBits(bitLength);
+
+ if (isDelta && (value & (1 << (bitLength - 1))) != 0)
+ {
+ value |= -1 << bitLength;
+ }
+
+ if (shift != 0)
+ {
+ value <<= shift;
+ }
+
+ _silent = false;
+
+ if (isFieldReplace)
+ {
+ var preservedMask = shift == 0 ? 0 : (1 << shift) - 1;
+ _filter.Registers[parameter] = unchecked((byte)((_filter.Registers[parameter] & preservedMask) | value));
+ continue;
+ }
+
+ if (isDelta)
+ {
+ _filter.Registers[parameter] = unchecked((byte)(_filter.Registers[parameter] + value));
+ continue;
+ }
+
+ _filter.Registers[parameter] = unchecked((byte)value);
+ }
+
+ if (opcode == 0xF)
+ {
+ _silent = true;
+ _filter.Registers[Period] = PerPause;
+ }
+
+ _filter.DecodeRegisters();
+ break;
+ }
+ }
+
+ private uint GetBits(int length)
+ {
+ var index0 = _pc >> 3;
+ var index1 = (_pc + 8) >> 3;
+ var data0 = _rom[index0 & 0xffff];
+ var data1 = _rom[index1 & 0xffff];
+ var data = ((uint)data1 << 8 | data0) >> (_pc & 7);
+ _pc += length;
+
+ return data & ((1u << length) - 1);
+ }
+
+ private static float NormalizeSample(double sample)
+ {
+ return Math.Clamp((float)(sample / 32768d), -1f, 1f);
+ }
+
+ private static uint BitReverse32(uint value)
+ {
+ value = ((value & 0xffff0000) >> 16) | ((value & 0x0000ffff) << 16);
+ value = ((value & 0xff00ff00) >> 8) | ((value & 0x00ff00ff) << 8);
+ value = ((value & 0xf0f0f0f0) >> 4) | ((value & 0x0f0f0f0f) << 4);
+ value = ((value & 0xcccccccc) >> 2) | ((value & 0x33333333) << 2);
+ value = ((value & 0xaaaaaaaa) >> 1) | ((value & 0x55555555) << 1);
+ return value;
+ }
+
+ private static ushort Encode(int length, int shift, int parameter, bool delta, bool field, bool clearFive, bool clearAll)
+ {
+ return (ushort)(
+ ((length & 0x0f) << 0) |
+ ((shift & 0x0f) << 4) |
+ ((parameter & 0x0f) << 8) |
+ ((delta ? 1 : 0) << 12) |
+ ((field ? 1 : 0) << 13) |
+ ((clearFive ? 1 : 0) << 14) |
+ ((clearAll ? 1 : 0) << 15));
+ }
+
+ private static byte[] NormalizeAl2Rom(byte[] romBytes)
+ {
+ var rom = romBytes.Take(Sp0256RomSize).ToArray();
+ if (ComputeSha1Hex(rom) == Al2RomSha1)
+ {
+ return rom;
+ }
+
+ var reversed = rom.Select(ReverseBits).ToArray();
+ return ComputeSha1Hex(reversed) == Al2RomSha1
+ ? reversed
+ : rom;
+ }
+
+ private static string ComputeSha1Hex(byte[] bytes)
+ {
+ return Convert.ToHexString(SHA1.HashData(bytes)).ToLowerInvariant();
+ }
+
+ private static byte ReverseBits(byte value)
+ {
+ value = (byte)(((value & 0xF0) >> 4) | ((value & 0x0F) << 4));
+ value = (byte)(((value & 0xCC) >> 2) | ((value & 0x33) << 2));
+ value = (byte)(((value & 0xAA) >> 1) | ((value & 0x55) << 1));
+ return value;
+ }
+
+ private sealed class FilterState
+ {
+ public int Repeat = -1;
+ public int Count;
+ public uint Period;
+ public uint Random = 1;
+ public int Amplitude;
+ public readonly short[] ForwardCoefficients = new short[6];
+ public readonly short[] BackwardCoefficients = new short[6];
+ public readonly short[,] Delay = new short[6, 2];
+ public readonly byte[] Registers = new byte[16];
+ public bool Interpolate;
+
+ public void Reset()
+ {
+ Repeat = -1;
+ Count = 0;
+ Period = 0;
+ Random = 1;
+ Amplitude = 0;
+ Interpolate = false;
+ Array.Clear(ForwardCoefficients);
+ Array.Clear(BackwardCoefficients);
+ Array.Clear(Delay);
+ Array.Clear(Registers);
+ }
+
+ public void DecodeRegisters()
+ {
+ Amplitude = DecodeAmplitude(Registers[Sp0256Chip.Amplitude]);
+ Count = 0;
+ Period = Registers[Sp0256Chip.Period];
+
+ for (var i = 0; i < 6; i++)
+ {
+ BackwardCoefficients[i] = DecodeCoefficient(Registers[B0 + (i * 2)]);
+ ForwardCoefficients[i] = DecodeCoefficient(Registers[F0 + (i * 2)]);
+ }
+
+ Interpolate = Registers[InterpolateAmplitude] != 0 || Registers[InterpolatePeriod] != 0;
+ }
+
+ public short UpdateOneSample()
+ {
+ var interpolateNow = false;
+ ushort sampleBits = 0;
+
+ if (Period != 0)
+ {
+ if (Count <= 0)
+ {
+ Count += (int)Period;
+ sampleBits = unchecked((ushort)Amplitude);
+ Repeat--;
+ interpolateNow = Interpolate;
+
+ for (var i = 0; i < 6; i++)
+ {
+ Delay[i, 0] = 0;
+ Delay[i, 1] = 0;
+ }
+ }
+ else
+ {
+ Count--;
+ }
+ }
+ else
+ {
+ if (--Count <= 0)
+ {
+ interpolateNow = Interpolate;
+ Count = PerNoise;
+ Repeat--;
+
+ for (var i = 0; i < 6; i++)
+ {
+ Delay[i, 0] = 0;
+ Delay[i, 1] = 0;
+ }
+ }
+
+ var bit = (Random & 1) != 0;
+ Random = (Random >> 1) ^ (bit ? 0x4001u : 0u);
+ sampleBits = unchecked((ushort)(bit ? Amplitude : -Amplitude));
+ }
+
+ if (interpolateNow)
+ {
+ Registers[Sp0256Chip.Amplitude] = unchecked((byte)(Registers[Sp0256Chip.Amplitude] + Registers[InterpolateAmplitude]));
+ Registers[Sp0256Chip.Period] = unchecked((byte)(Registers[Sp0256Chip.Period] + Registers[InterpolatePeriod]));
+ Amplitude = DecodeAmplitude(Registers[Sp0256Chip.Amplitude]);
+ Period = Registers[Sp0256Chip.Period];
+ }
+
+ if (Repeat <= 0)
+ {
+ return 0;
+ }
+
+ for (var i = 0; i < 6; i++)
+ {
+ var accumulatedSample = (int)sampleBits;
+ accumulatedSample += (BackwardCoefficients[i] * Delay[i, 1]) >> 9;
+ accumulatedSample += (ForwardCoefficients[i] * Delay[i, 0]) >> 8;
+ sampleBits = unchecked((ushort)accumulatedSample);
+ var sample = unchecked((short)sampleBits);
+
+ Delay[i, 1] = Delay[i, 0];
+ Delay[i, 0] = sample;
+ }
+
+ return (short)(Limit(unchecked((short)sampleBits)) << 2);
+ }
+
+ private static int DecodeAmplitude(byte amplitude)
+ {
+ return (amplitude & 0x1f) << ((amplitude & 0xe0) >> 5);
+ }
+
+ private static short DecodeCoefficient(byte value)
+ {
+ return (value & 0x80) != 0
+ ? QuantizationTable[0x7f & -value]
+ : (short)-QuantizationTable[value];
+ }
+
+ private static short Limit(short value)
+ {
+ if (value > 8191)
+ {
+ return 8191;
+ }
+
+ if (value < -8192)
+ {
+ return -8192;
+ }
+
+ return value;
+ }
+ }
+}
diff --git a/ZXBox.Core/Tape/TapFormat.cs b/ZXBox.Core/Tape/TapFormat.cs
index 54cbda4..0827167 100644
--- a/ZXBox.Core/Tape/TapFormat.cs
+++ b/ZXBox.Core/Tape/TapFormat.cs
@@ -1,28 +1,35 @@
-using System.Collections.Generic;
using System.IO;
-namespace ZXBox.Core.Tape
+namespace ZXBox.Core.Tape;
+
+public class TapFormat
{
- public class TapFormat
+ public TapeImage ReadFile(byte[] data)
{
- public void ReadFile(byte[] data)
+ var tapeImage = new TapeImage();
+
+ using MemoryStream ms = new(data);
+ using BinaryReader reader = new(ms);
+
+ while (reader.BaseStream.Position < reader.BaseStream.Length)
{
- using MemoryStream ms = new MemoryStream(data);
- using BinaryReader reader = new BinaryReader(ms);
- while (reader.BaseStream.Position < reader.BaseStream.Length)
+ var length = reader.ReadUInt16();
+ var blockData = reader.ReadBytes(length);
+
+ tapeImage.Blocks.Add(new TapeDataBlock
{
- var lenght = reader.ReadUInt16();
- Blocks.Add(new TapBlock() { Data = reader.ReadBytes(lenght) });
- }
+ Data = blockData,
+ PilotPulseLength = 2168,
+ PilotPulseCount = blockData.Length > 0 && blockData[0] < 0x80 ? 8063 : 3223,
+ SyncFirstPulseLength = 667,
+ SyncSecondPulseLength = 735,
+ ZeroBitPulseLength = 855,
+ OneBitPulseLength = 1710,
+ UsedBitsInLastByte = 8,
+ PauseAfterMilliseconds = 1000
+ });
}
- public List Blocks = new();
+ return tapeImage;
}
-
- public class TapBlock
- {
- public byte[] Data { get; set; }
-
- }
-
}
diff --git a/ZXBox.Core/Tape/TapeFormatFactory.cs b/ZXBox.Core/Tape/TapeFormatFactory.cs
new file mode 100644
index 0000000..c9014a8
--- /dev/null
+++ b/ZXBox.Core/Tape/TapeFormatFactory.cs
@@ -0,0 +1,25 @@
+namespace ZXBox.Core.Tape;
+
+public static class TapeFormatFactory
+{
+ public static TapeImage ReadTape(byte[] data, string fileName = null)
+ {
+ if (TzxFormat.IsTzx(data, fileName))
+ {
+ return new TzxFormat().ReadFile(data);
+ }
+
+ return new TapFormat().ReadFile(data);
+ }
+
+ public static bool IsSupportedTapeFile(string fileName)
+ {
+ if (string.IsNullOrWhiteSpace(fileName))
+ {
+ return false;
+ }
+
+ var extension = System.IO.Path.GetExtension(fileName).ToLowerInvariant();
+ return extension is ".tap" or ".tzx";
+ }
+}
diff --git a/ZXBox.Core/Tape/TapeImage.cs b/ZXBox.Core/Tape/TapeImage.cs
new file mode 100644
index 0000000..89e4be4
--- /dev/null
+++ b/ZXBox.Core/Tape/TapeImage.cs
@@ -0,0 +1,70 @@
+using System;
+using System.Collections.Generic;
+
+namespace ZXBox.Core.Tape;
+
+public sealed class TapeImage
+{
+ public List Blocks { get; } = new();
+}
+
+public abstract class TapeBlock
+{
+}
+
+public sealed class TapeDataBlock : TapeBlock
+{
+ public required byte[] Data { get; init; }
+
+ public required int PilotPulseLength { get; init; }
+
+ public required int PilotPulseCount { get; init; }
+
+ public required int SyncFirstPulseLength { get; init; }
+
+ public required int SyncSecondPulseLength { get; init; }
+
+ public required int ZeroBitPulseLength { get; init; }
+
+ public required int OneBitPulseLength { get; init; }
+
+ public required int UsedBitsInLastByte { get; init; }
+
+ public required int PauseAfterMilliseconds { get; init; }
+
+ public bool IsQuickLoadCandidate =>
+ PilotPulseLength == 2168 &&
+ SyncFirstPulseLength == 667 &&
+ SyncSecondPulseLength == 735 &&
+ ZeroBitPulseLength == 855 &&
+ OneBitPulseLength == 1710 &&
+ UsedBitsInLastByte == 8 &&
+ Data.Length > 0 &&
+ PilotPulseCount == (Data[0] < 0x80 ? 8063 : 3223);
+}
+
+public sealed class TapePureToneBlock : TapeBlock
+{
+ public required int PulseLength { get; init; }
+
+ public required int PulseCount { get; init; }
+}
+
+public sealed class TapePulseSequenceBlock : TapeBlock
+{
+ public required IReadOnlyList PulseLengths { get; init; }
+}
+
+public sealed class TapePauseBlock : TapeBlock
+{
+ public required int DurationMilliseconds { get; init; }
+}
+
+public sealed class TapeStopBlock : TapeBlock
+{
+}
+
+public sealed class TapeSetSignalLevelBlock : TapeBlock
+{
+ public required bool High { get; init; }
+}
diff --git a/ZXBox.Core/Tape/TzxFormat.cs b/ZXBox.Core/Tape/TzxFormat.cs
new file mode 100644
index 0000000..045aeee
--- /dev/null
+++ b/ZXBox.Core/Tape/TzxFormat.cs
@@ -0,0 +1,360 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Text;
+
+namespace ZXBox.Core.Tape;
+
+public class TzxFormat
+{
+ private static readonly byte[] Signature = Encoding.ASCII.GetBytes("ZXTape!");
+
+ public static bool IsTzx(byte[] data, string fileName = null)
+ {
+ if (data.Length >= 8 &&
+ data[0] == (byte)'Z' &&
+ data[1] == (byte)'X' &&
+ data[2] == (byte)'T' &&
+ data[3] == (byte)'a' &&
+ data[4] == (byte)'p' &&
+ data[5] == (byte)'e' &&
+ data[6] == (byte)'!' &&
+ data[7] == 0x1A)
+ {
+ return true;
+ }
+
+ return !string.IsNullOrWhiteSpace(fileName) &&
+ Path.GetExtension(fileName).Equals(".tzx", StringComparison.OrdinalIgnoreCase);
+ }
+
+ public TapeImage ReadFile(byte[] data)
+ {
+ using MemoryStream stream = new(data);
+ using BinaryReader reader = new(stream);
+
+ ValidateHeader(reader);
+
+ var blocks = new List();
+ while (reader.BaseStream.Position < reader.BaseStream.Length)
+ {
+ blocks.Add(ReadBlock(reader));
+ }
+
+ return ResolveBlocks(blocks);
+ }
+
+ private static void ValidateHeader(BinaryReader reader)
+ {
+ var header = reader.ReadBytes(10);
+ if (header.Length < 10)
+ {
+ throw new InvalidDataException("Invalid TZX file header.");
+ }
+
+ for (var i = 0; i < Signature.Length; i++)
+ {
+ if (header[i] != Signature[i])
+ {
+ throw new InvalidDataException("Missing TZX signature.");
+ }
+ }
+
+ if (header[7] != 0x1A)
+ {
+ throw new InvalidDataException("Invalid TZX end-of-text marker.");
+ }
+ }
+
+ private static object ReadBlock(BinaryReader reader)
+ {
+ return reader.ReadByte() switch
+ {
+ 0x10 => ReadStandardSpeedDataBlock(reader),
+ 0x11 => ReadTurboSpeedDataBlock(reader),
+ 0x12 => new TapePureToneBlock
+ {
+ PulseLength = reader.ReadUInt16(),
+ PulseCount = reader.ReadUInt16()
+ },
+ 0x13 => ReadPulseSequenceBlock(reader),
+ 0x14 => ReadPureDataBlock(reader),
+ 0x20 => ReadPauseOrStopBlock(reader),
+ 0x21 => SkipGroupStart(reader),
+ 0x22 => NoOpBlock.Instance,
+ 0x23 => new JumpBlock(reader.ReadInt16()),
+ 0x24 => new LoopStartBlock(reader.ReadUInt16()),
+ 0x25 => LoopEndBlock.Instance,
+ 0x2A => SkipLengthPrefixedDwordBlock(reader),
+ 0x2B => ReadSignalLevelBlock(reader),
+ 0x30 => SkipByteLengthTextBlock(reader),
+ 0x31 => SkipMessageBlock(reader),
+ 0x32 => SkipLengthPrefixedWordBlock(reader),
+ 0x33 => SkipHardwareTypeBlock(reader),
+ 0x35 => SkipCustomInfoBlock(reader),
+ 0x5A => SkipGlueBlock(reader),
+ var blockId => throw new NotSupportedException($"Unsupported TZX block 0x{blockId:X2}.")
+ };
+ }
+
+ private static TapeDataBlock ReadStandardSpeedDataBlock(BinaryReader reader)
+ {
+ var pauseAfterMilliseconds = reader.ReadUInt16();
+ var dataLength = reader.ReadUInt16();
+ var blockData = reader.ReadBytes(dataLength);
+
+ return new TapeDataBlock
+ {
+ Data = blockData,
+ PilotPulseLength = 2168,
+ PilotPulseCount = blockData.Length > 0 && blockData[0] < 0x80 ? 8063 : 3223,
+ SyncFirstPulseLength = 667,
+ SyncSecondPulseLength = 735,
+ ZeroBitPulseLength = 855,
+ OneBitPulseLength = 1710,
+ UsedBitsInLastByte = 8,
+ PauseAfterMilliseconds = pauseAfterMilliseconds
+ };
+ }
+
+ private static TapeDataBlock ReadTurboSpeedDataBlock(BinaryReader reader)
+ {
+ var block = new TapeDataBlock
+ {
+ PilotPulseLength = reader.ReadUInt16(),
+ SyncFirstPulseLength = reader.ReadUInt16(),
+ SyncSecondPulseLength = reader.ReadUInt16(),
+ ZeroBitPulseLength = reader.ReadUInt16(),
+ OneBitPulseLength = reader.ReadUInt16(),
+ PilotPulseCount = reader.ReadUInt16(),
+ UsedBitsInLastByte = reader.ReadByte(),
+ PauseAfterMilliseconds = reader.ReadUInt16(),
+ Data = reader.ReadBytes(ReadUInt24(reader))
+ };
+
+ return block;
+ }
+
+ private static TapePulseSequenceBlock ReadPulseSequenceBlock(BinaryReader reader)
+ {
+ var pulseCount = reader.ReadByte();
+ var pulses = new int[pulseCount];
+
+ for (var pulseIndex = 0; pulseIndex < pulseCount; pulseIndex++)
+ {
+ pulses[pulseIndex] = reader.ReadUInt16();
+ }
+
+ return new TapePulseSequenceBlock
+ {
+ PulseLengths = pulses
+ };
+ }
+
+ private static TapeDataBlock ReadPureDataBlock(BinaryReader reader)
+ {
+ var block = new TapeDataBlock
+ {
+ PilotPulseLength = 0,
+ PilotPulseCount = 0,
+ SyncFirstPulseLength = 0,
+ SyncSecondPulseLength = 0,
+ ZeroBitPulseLength = reader.ReadUInt16(),
+ OneBitPulseLength = reader.ReadUInt16(),
+ UsedBitsInLastByte = reader.ReadByte(),
+ PauseAfterMilliseconds = reader.ReadUInt16(),
+ Data = reader.ReadBytes(ReadUInt24(reader))
+ };
+
+ return block;
+ }
+
+ private static TapeBlock ReadPauseOrStopBlock(BinaryReader reader)
+ {
+ var durationMilliseconds = reader.ReadUInt16();
+ if (durationMilliseconds == 0)
+ {
+ return new TapeStopBlock();
+ }
+
+ return new TapePauseBlock
+ {
+ DurationMilliseconds = durationMilliseconds
+ };
+ }
+
+ private static object SkipGroupStart(BinaryReader reader)
+ {
+ var length = reader.ReadByte();
+ reader.ReadBytes(length);
+ return NoOpBlock.Instance;
+ }
+
+ private static object SkipByteLengthTextBlock(BinaryReader reader)
+ {
+ var length = reader.ReadByte();
+ reader.ReadBytes(length);
+ return NoOpBlock.Instance;
+ }
+
+ private static object SkipMessageBlock(BinaryReader reader)
+ {
+ reader.ReadByte();
+ var length = reader.ReadByte();
+ reader.ReadBytes(length);
+ return NoOpBlock.Instance;
+ }
+
+ private static object SkipLengthPrefixedWordBlock(BinaryReader reader)
+ {
+ var length = reader.ReadUInt16();
+ reader.ReadBytes(length);
+ return NoOpBlock.Instance;
+ }
+
+ private static object SkipLengthPrefixedDwordBlock(BinaryReader reader)
+ {
+ var length = reader.ReadUInt32();
+ reader.ReadBytes((int)length);
+ return NoOpBlock.Instance;
+ }
+
+ private static object SkipHardwareTypeBlock(BinaryReader reader)
+ {
+ var count = reader.ReadByte();
+ reader.ReadBytes(count * 3);
+ return NoOpBlock.Instance;
+ }
+
+ private static object SkipCustomInfoBlock(BinaryReader reader)
+ {
+ reader.ReadBytes(10);
+ var length = reader.ReadUInt32();
+ reader.ReadBytes((int)length);
+ return NoOpBlock.Instance;
+ }
+
+ private static object SkipGlueBlock(BinaryReader reader)
+ {
+ reader.ReadBytes(9);
+ return NoOpBlock.Instance;
+ }
+
+ private static TapeSetSignalLevelBlock ReadSignalLevelBlock(BinaryReader reader)
+ {
+ var length = reader.ReadUInt32();
+ var level = reader.ReadByte();
+ if (length > 1)
+ {
+ reader.ReadBytes((int)length - 1);
+ }
+
+ return new TapeSetSignalLevelBlock
+ {
+ High = level != 0
+ };
+ }
+
+ private static int ReadUInt24(BinaryReader reader)
+ {
+ var b0 = reader.ReadByte();
+ var b1 = reader.ReadByte();
+ var b2 = reader.ReadByte();
+ return b0 | (b1 << 8) | (b2 << 16);
+ }
+
+ private static TapeImage ResolveBlocks(List blocks)
+ {
+ var tapeImage = new TapeImage();
+ var loopStack = new Stack();
+ var blockIndex = 0;
+ var guard = 0;
+
+ while (blockIndex < blocks.Count)
+ {
+ guard++;
+ if (guard > blocks.Count * 1024)
+ {
+ throw new InvalidDataException("TZX control flow exceeded safety limit.");
+ }
+
+ switch (blocks[blockIndex])
+ {
+ case TapeBlock tapeBlock:
+ tapeImage.Blocks.Add(tapeBlock);
+ blockIndex++;
+ break;
+ case JumpBlock jumpBlock:
+ if (jumpBlock.RelativeOffset == 0)
+ {
+ throw new InvalidDataException("TZX jump block cannot jump to itself.");
+ }
+
+ blockIndex += jumpBlock.RelativeOffset;
+ if (blockIndex < 0 || blockIndex > blocks.Count)
+ {
+ throw new InvalidDataException("TZX jump moved outside the tape.");
+ }
+
+ break;
+ case LoopStartBlock loopStartBlock:
+ loopStack.Push(new LoopState(blockIndex + 1, loopStartBlock.Repetitions));
+ blockIndex++;
+ break;
+ case LoopEndBlock:
+ if (loopStack.Count == 0)
+ {
+ throw new InvalidDataException("TZX loop end encountered without a loop start.");
+ }
+
+ var loopState = loopStack.Pop();
+ if (loopState.RemainingRepetitions > 1)
+ {
+ loopState.RemainingRepetitions--;
+ loopStack.Push(loopState);
+ blockIndex = loopState.BlockIndex;
+ }
+ else
+ {
+ blockIndex++;
+ }
+
+ break;
+ case NoOpBlock:
+ blockIndex++;
+ break;
+ default:
+ throw new InvalidDataException($"Unsupported TZX control block type '{blocks[blockIndex].GetType().Name}'.");
+ }
+ }
+
+ return tapeImage;
+ }
+
+ private sealed class JumpBlock(short relativeOffset)
+ {
+ public short RelativeOffset { get; } = relativeOffset;
+ }
+
+ private sealed class LoopStartBlock(ushort repetitions)
+ {
+ public ushort Repetitions { get; } = repetitions;
+ }
+
+ private sealed class LoopEndBlock
+ {
+ public static LoopEndBlock Instance { get; } = new();
+ }
+
+ private sealed class NoOpBlock
+ {
+ public static NoOpBlock Instance { get; } = new();
+ }
+
+ private sealed class LoopState(int blockIndex, int remainingRepetitions)
+ {
+ public int BlockIndex { get; } = blockIndex;
+
+ public int RemainingRepetitions { get; set; } = remainingRepetitions;
+ }
+}
diff --git a/ZXBox.Core/ZxSpectrum.cs b/ZXBox.Core/ZxSpectrum.cs
index bf81694..910b193 100644
--- a/ZXBox.Core/ZxSpectrum.cs
+++ b/ZXBox.Core/ZxSpectrum.cs
@@ -2,13 +2,15 @@
using System.Collections.Generic;
using ZXBox.Hardware.Interfaces;
using ZXBox.Hardware.Output;
+using ZXBox.Hardware.Speech;
+using ZXBox.Hardware.Sound;
namespace ZXBox;
public class ZXSpectrum : Zilog.Z80
{
public List Roms = new List { new byte[0x4000], new byte[0x4000] };
- public List Banks = new List { new byte[0x4000], new byte[0x4000], new byte[0x4000], new byte[0x4000], new byte[0x4000], new byte[0x4000], new byte[0x4000] };
+ public List Banks = new List { new byte[0x4000], new byte[0x4000], new byte[0x4000], new byte[0x4000], new byte[0x4000], new byte[0x4000], new byte[0x4000], new byte[0x4000] };
Screen speccyscreen;
byte[] romBytes;
@@ -27,6 +29,8 @@ public ZXSpectrum(bool renderBorder = true, bool loadRom = true, int borderTop =
//https://tomeko.net/online_tools/file_to_hex.php?lang=en
public void LoadRom(RomEnum rom)
{
+ _machineModel = rom;
+
switch (rom)
{
case RomEnum.ZXSpectrum48k:
@@ -64,13 +68,73 @@ public uint[] GetScreenInUint(bool flash)
public List InputHardware = new List();
public List OutputHardware = new List();
+ public CurrahMicroSpeech CurrahMicroSpeech { get; } = new();
+ public Ay38912Chip AyChip { get; } = new();
+ public ZxPrinter ZxPrinter { get; } = new();
public int bordercolor = 1;
int retvalue = 0xFF;
int i = 0;
+ private int CurrentFrameTState => NumberOfTstates - Math.Abs(_numberOfTStatesLeft);
+ private RomEnum _machineModel = RomEnum.ZXSpectrum48k;
+
+ public bool Is128KModel => _machineModel == RomEnum.ZXSpectrumPlus;
+
+ public int FrameTStates => Is128KModel ? Ay38912Chip.FrameTStates128k : 69888;
+
+ public void ConnectCurrahMicroSpeech()
+ {
+ CurrahMicroSpeech.Connect();
+ }
+
+ public void DisconnectCurrahMicroSpeech()
+ {
+ CurrahMicroSpeech.Disconnect();
+ }
+
+ public void LoadCurrahMicroSpeechRom(byte[] romBytes)
+ {
+ CurrahMicroSpeech.LoadRom(romBytes);
+ }
+
+ public void ConnectZxPrinter()
+ {
+ ZxPrinter.Connect();
+ }
+
+ public void DisconnectZxPrinter()
+ {
+ ZxPrinter.Disconnect();
+ }
+
+ public new void Reset()
+ {
+ base.Reset();
+ ResetPagingState();
+ CurrahMicroSpeech.ResetRuntime();
+ AyChip.Reset();
+ ZxPrinter.ResetRuntime();
+ }
+
public override int In(int port)
{
retvalue = 0xFF;
+
+ if (Is128KModel && AyChip.TryReadPort(port, CurrentFrameTState, out var ayPortValue))
+ {
+ retvalue &= ayPortValue;
+ }
+
+ if (CurrahMicroSpeech.TryReadPort(port, CurrentFrameTState, out var currahPortValue))
+ {
+ retvalue &= currahPortValue;
+ }
+
+ if (ZxPrinter.TryReadPort(port, out var printerPortValue))
+ {
+ return printerPortValue;
+ }
+
for (i = 0; i < InputHardware.Count; i++)
{
retvalue &= InputHardware[i].Input(port, NumberOfTstates - Math.Abs(_numberOfTStatesLeft));
@@ -79,6 +143,7 @@ public override int In(int port)
}
public override void TstateChange(int diff)
{
+ ZxPrinter.AdvanceTStates(diff);
for (i = 0; i < InputHardware.Count; i++)
{
InputHardware[i].AddTStates(diff);
@@ -88,15 +153,19 @@ public override void TstateChange(int diff)
bool disablepaging = false;
public override void Out(int Port, int ByteValue, int tStates)
{
- //128k
- if (Port == 0x7ffd)
+ if (Is128KModel && !disablepaging && Is128kPagingPort(Port))
+ {
+ Apply128kPaging(ByteValue);
+ }
+
+ if (Is128KModel)
{
- bank = ByteValue & 0x03;
- rom = (ByteValue >> 4) & 0x01;
- activescreen = (ByteValue >> 3) & 0x01;
- disablepaging = ((ByteValue >> 5) & 0x01) == 1;
+ AyChip.HandlePortWrite(Port, ByteValue, CurrentFrameTState);
}
+ CurrahMicroSpeech.HandlePortWrite(Port, ByteValue, CurrentFrameTState);
+ ZxPrinter.HandlePortWrite(Port, ByteValue);
+
for (int o = 0; o < OutputHardware.Count; o++)
{
OutputHardware[o].Output(Port, ByteValue, tStates);
@@ -104,8 +173,34 @@ public override void Out(int Port, int ByteValue, int tStates)
}
int bank = 0;
int rom = 0;
+
+ private void ResetPagingState()
+ {
+ bank = 0;
+ rom = 0;
+ activescreen = 0;
+ disablepaging = false;
+ }
+
+ private void Apply128kPaging(int value)
+ {
+ bank = value & 0x07;
+ rom = (value >> 4) & 0x01;
+ activescreen = (value >> 3) & 0x01;
+ disablepaging = ((value >> 5) & 0x01) == 1;
+ }
+
+ private static bool Is128kPagingPort(int port)
+ {
+ return (port & 0x8002) == 0;
+ }
+
public override void WriteByteToMemory(int address, int bytetowrite)
{
+ if (CurrahMicroSpeech.HandleMemoryWrite(address, bytetowrite, CurrentFrameTState))
+ {
+ return;
+ }
if (address < 0x4000) //rom
{
@@ -149,6 +244,28 @@ public override void WriteWordToMemory(int address, int word)
public override int ReadByteFromMemory(int address)
{
+ return ReadByteFromMemoryCore(address, opcodeFetch: false);
+ }
+
+ protected override int ReadOpcodeByteFromMemory(int address)
+ {
+ return ReadByteFromMemoryCore(address, opcodeFetch: true);
+ }
+
+ private int ReadByteFromMemoryCore(int address, bool opcodeFetch)
+ {
+ if (opcodeFetch)
+ {
+ if (CurrahMicroSpeech.TryReadOpcodeFetch(address, CurrentFrameTState, out var currahOpcodeValue))
+ {
+ return currahOpcodeValue;
+ }
+ }
+ else if (CurrahMicroSpeech.TryReadMemory(address, CurrentFrameTState, out var currahMemoryValue))
+ {
+ return currahMemoryValue;
+ }
+
if (address < 0x4000) //rom
{
return Roms[rom][address & 0xffff];
@@ -165,6 +282,7 @@ public override int ReadByteFromMemory(int address)
{
return Banks[bank][address - 0xc000];
}
+
return 0;
}
}