Pissing about with TZX support

This commit is contained in:
2026-05-01 20:02:18 +01:00
parent 8efcf00286
commit b1e7210e95
7 changed files with 277 additions and 23 deletions

View File

@@ -1312,6 +1312,9 @@ namespace Core.Cpu
case 0x4D: // RETI Does not affect IFF1 or IFF2
PC = Pop();
return 14;
case 0x4F: // LD R, A
R = AF.High;
return 9;
case 0x51: // OUT (C), D
// BC.Word goes to the address bus, D (DE.High) goes to the data bus
_simpleIoBus.WritePort(BC.Word, DE.High);

View File

@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using static Core.Io.TurboSpeedBlock;
//using Core.Io;
namespace Core.Io
@@ -20,7 +21,7 @@ namespace Core.Io
private List<TzxBlock> _tzxBlocks = new List<TzxBlock>();
// State Machine Tracking
private enum TapeState { Idle, Pilot, Sync1, Sync2, Data, Pause }
private enum TapeState { Idle, Pilot, Sync1, Sync2, Data, Pause, PureTone, PulseSequence }
private TapeState _state = TapeState.Idle;
public bool EarBit { get; private set; } = false; // The physical audio signal sent to the CPU
@@ -30,6 +31,8 @@ namespace Core.Io
private int _byteIndex = 0;
private int _bitIndex = 0;
private int _pulseCountForCurrentBit = 0;
private ushort[] _sequencePulses = Array.Empty<ushort>();
private int _sequenceIndex = 0;
public void LoadTapData(byte[] fileData)
{
@@ -85,8 +88,12 @@ namespace Core.Io
_currentBlock = _playQueue.Dequeue();
// If it's metadata (like our ArchiveInfoBlock), just skip it and load the next audio block
if (_currentBlock.BlockId == 0x32)
if (_currentBlock is ArchiveInfoBlock ||
_currentBlock is TextDescriptionBlock ||
_currentBlock is GroupStartBlock ||
_currentBlock is GroupEndBlock)
{
// It has no audio data! Discard it and recursively pull the next block.
LoadNextBlock();
return;
}
@@ -121,6 +128,70 @@ namespace Core.Io
_oneLength = turboBlock.OneBitPulseLength;
_pauseTStates = turboBlock.PauseAfterMs * 3500;
}
else if (_currentBlock is PauseBlock pauseBlock)
{
if (pauseBlock.PauseDurationMs == 0)
{
// A duration of 0 literally means "Stop the Tape Deck"
Stop();
return;
}
// Otherwise, just drop straight into a Pause state!
_state = TapeState.Pause;
_pauseTStates = pauseBlock.PauseDurationMs * 3500;
_pulseTimer = _pauseTStates;
return;
}
else if (_currentBlock is PureToneBlock toneBlock)
{
_state = TapeState.PureTone;
_pilotPulsesRemaining = toneBlock.PulseCount;
_pilotLength = toneBlock.PulseLength;
_pulseTimer = _pilotLength; // Start the first pulse
return;
}
else if (_currentBlock is PulseSequenceBlock seqBlock)
{
_state = TapeState.PulseSequence;
_sequencePulses = seqBlock.Pulses;
_sequenceIndex = 0;
// Safety check: only start if the array actually has data
if (_sequencePulses.Length > 0)
{
_pulseTimer = _sequencePulses[_sequenceIndex];
}
else
{
// Empty block? Just skip it.
_state = TapeState.Idle;
LoadNextBlock();
}
return;
}
else if (_currentBlock is PureDataBlock pureDataBlock)
{
_zeroLength = pureDataBlock.ZeroBitPulseLength;
_oneLength = pureDataBlock.OneBitPulseLength;
_pauseTStates = pureDataBlock.PauseAfterMs * 3500;
if (pureDataBlock.Data.Length == 0)
{
_state = TapeState.Pause;
_pulseTimer = _pauseTStates;
return;
}
// Skip the preamble completely and jump straight to Data processing!
_state = TapeState.Data;
_byteIndex = 0;
_bitIndex = 7;
_pulseCountForCurrentBit = 0;
// Prime the very first data pulse into the timer
CalculateNextDataPulse();
return;
}
// Start the very first pilot pulse!
_pulseTimer = _pilotLength;
@@ -164,37 +235,55 @@ namespace Core.Io
break;
case TapeState.Sync2:
// --- THE FIX: Check if the block actually has data! ---
byte[] upcomingData = ExtractDataFromBlock(_currentBlock);
if (upcomingData.Length == 0)
{
// It was just a timing calibration block! Skip Data and go straight to Pause.
_state = TapeState.Pause;
_pulseTimer += _pauseTStates;
}
else
{
// Normal block. Proceed to extract the bits.
_state = TapeState.Data;
CalculateNextDataPulse();
}
break;
case TapeState.Data:
_pulseCountForCurrentBit++;
if (_pulseCountForCurrentBit < 2)
{
// A bit requires 2 pulses (one high, one low)
CalculateNextDataPulse();
}
else
{
// Move to next bit
_pulseCountForCurrentBit = 0;
_bitIndex--;
byte[] dataArray = ExtractDataFromBlock(_currentBlock);
// --- THE SPEEDLOCK FIX: Used Bits In Last Byte ---
byte usedBits = 8;
if (_currentBlock is TurboSpeedBlock t && t.UsedBitsInLastByte > 0) usedBits = t.UsedBitsInLastByte;
if (_currentBlock is PureDataBlock p && p.UsedBitsInLastByte > 0) usedBits = p.UsedBitsInLastByte;
// Stop exactly when we've processed the required bits in the final byte!
if (_byteIndex == dataArray.Length - 1 && _bitIndex < (8 - usedBits))
{
_state = TapeState.Pause;
_pulseTimer += _pauseTStates;
return; // Drop out of AdvanceState immediately
}
if (_bitIndex < 0)
{
// Move to next byte
_bitIndex = 7;
_byteIndex++;
// --- THE FIX ---
// Extract the raw byte array from the generic TzxBlock
byte[] dataArray = ExtractDataFromBlock(_currentBlock);
// Now we can safely check the length!
if (_byteIndex >= dataArray.Length)
{
// Block finished! Enter the Pause state and wait for the dynamic delay
_state = TapeState.Pause;
_pulseTimer += _pauseTStates;
return;
@@ -207,19 +296,52 @@ namespace Core.Io
_state = TapeState.Idle;
LoadNextBlock();
break;
case TapeState.PureTone:
_pilotPulsesRemaining--;
if (_pilotPulsesRemaining > 0)
{
_pulseTimer += _pilotLength;
}
else
{
// The tone is over! Move immediately to the next block.
_state = TapeState.Idle;
LoadNextBlock();
}
break;
case TapeState.PulseSequence:
_sequenceIndex++;
if (_sequenceIndex < _sequencePulses.Length)
{
// Load the length of the next raw pulse
_pulseTimer += _sequencePulses[_sequenceIndex];
}
else
{
// Array is finished! Move immediately to the next block.
_state = TapeState.Idle;
LoadNextBlock();
}
break;
}
}
private void CalculateNextDataPulse()
{
// We need to pull the Data array from the base TzxBlock
byte[] dataArray = Array.Empty<byte>();
if (_currentBlock is StandardSpeedBlock std) dataArray = std.Data;
else if (_currentBlock is TurboSpeedBlock turbo) dataArray = turbo.Data;
// 1. Use the helper method to grab the array, which automatically supports PureData blocks too!
byte[] dataArray = ExtractDataFromBlock(_currentBlock);
// 2. THE BULLETPROOF VEST: Catch zero-length arrays and out-of-bounds indices before they crash the CPU
if (dataArray.Length == 0 || _byteIndex >= dataArray.Length)
{
_state = TapeState.Pause;
_pulseTimer += _pauseTStates;
return;
}
// 3. Extract the bit and load the dynamic pulse length into the timer
bool isBitOne = (dataArray[_byteIndex] & (1 << _bitIndex)) != 0;
// Use the dynamic zero/one lengths!
_pulseTimer += isBitOne ? _oneLength : _zeroLength;
}
@@ -240,8 +362,8 @@ namespace Core.Io
{
if (block is StandardSpeedBlock std) return std.Data;
if (block is TurboSpeedBlock turbo) return turbo.Data;
if (block is PureDataBlock pureData) return pureData.Data; // <-- Added this line!
// If it's metadata or a block type without data, return an empty array
return Array.Empty<byte>();
}

View File

@@ -50,6 +50,66 @@ namespace Core.Io
// This is a 24-bit integer in the file, so we store it in a standard 32-bit int
public int DataLength { get; set; }
public byte[] Data { get; set; } = Array.Empty<byte>();
// ID 0x30: Text Description
public class TextDescriptionBlock : TzxBlock
{
public override int BlockId => 0x30;
public string Description { get; set; } = string.Empty;
}
// ID 0x20: Pause / Stop the Tape
public class PauseBlock : TzxBlock
{
public override int BlockId => 0x20;
public ushort PauseDurationMs { get; set; } // 0 means "Stop the tape"
}
// ID 0x21: Group Start (Used to group blocks in UI tape browsers)
public class GroupStartBlock : TzxBlock
{
public override int BlockId => 0x21;
public string GroupName { get; set; } = string.Empty;
}
// ID 0x22: Group End
public class GroupEndBlock : TzxBlock
{
public override int BlockId => 0x22;
}
// ID 0x12: Pure Tone Block
public class PureToneBlock : TzxBlock
{
public override int BlockId => 0x12;
public ushort PulseLength { get; set; }
public ushort PulseCount { get; set; }
}
// ID 0x13: Pulse Sequence Block
public class PulseSequenceBlock : TzxBlock
{
public override int BlockId => 0x13;
// Number of pulses (1 to 255)
public int PulseCount { get; set; }
// The exact T-State lengths of each pulse
public ushort[] Pulses { get; set; } = Array.Empty<ushort>();
}
// ID 0x14: Pure Data Block
public class PureDataBlock : TzxBlock
{
public override int BlockId => 0x14;
public ushort ZeroBitPulseLength { get; set; }
public ushort OneBitPulseLength { get; set; }
public byte UsedBitsInLastByte { get; set; }
public ushort PauseAfterMs { get; set; }
// The return of the 24-bit integer!
public int DataLength { get; set; }
public byte[] Data { get; set; } = Array.Empty<byte>();
}
}
}

View File

@@ -2,6 +2,7 @@
using System;
using System.Collections.Generic;
using System.IO;
using static Core.Io.TurboSpeedBlock;
namespace Core.Io
{
@@ -65,6 +66,66 @@ namespace Core.Io
blocks.Add(turboBlock);
break;
case 0x12: // Pure Tone Block
blocks.Add(new PureToneBlock
{
PulseLength = br.ReadUInt16(),
PulseCount = br.ReadUInt16()
});
break;
case 0x13: // Pulse Sequence Block
var seqBlock = new PulseSequenceBlock();
byte rawCount = br.ReadByte();
// TZX Spec: A raw count of 0 means 256 pulses!
seqBlock.PulseCount = (rawCount == 0) ? 256 : rawCount;
seqBlock.Pulses = new ushort[seqBlock.PulseCount];
for (int i = 0; i < seqBlock.PulseCount; i++)
{
seqBlock.Pulses[i] = br.ReadUInt16();
}
blocks.Add(seqBlock);
break;
case 0x14: // Pure Data Block
var pureData = new PureDataBlock
{
ZeroBitPulseLength = br.ReadUInt16(),
OneBitPulseLength = br.ReadUInt16(),
UsedBitsInLastByte = br.ReadByte(),
PauseAfterMs = br.ReadUInt16()
};
// Assemble the 24-bit length
byte pdLen0 = br.ReadByte();
byte pdLen1 = br.ReadByte();
byte pdLen2 = br.ReadByte();
pureData.DataLength = pdLen0 | (pdLen1 << 8) | (pdLen2 << 16);
pureData.Data = br.ReadBytes(pureData.DataLength);
blocks.Add(pureData);
break;
case 0x20: // Pause / Stop Tape Block
blocks.Add(new PauseBlock { PauseDurationMs = br.ReadUInt16() });
break;
case 0x21: // Group Start Block
byte groupNameLength = br.ReadByte();
string groupName = System.Text.Encoding.ASCII.GetString(br.ReadBytes(groupNameLength));
blocks.Add(new GroupStartBlock { GroupName = groupName });
break;
case 0x22: // Group End Block
// This block has no payload data at all! Just the ID.
blocks.Add(new GroupEndBlock());
break;
case 0x30: // Text Description Block
byte textLength30 = br.ReadByte();
string description = System.Text.Encoding.ASCII.GetString(br.ReadBytes(textLength30));
blocks.Add(new TextDescriptionBlock { Description = description });
break;
case 0x32: // Archive Info Block
var archiveBlock = new ArchiveInfoBlock();
@@ -76,10 +137,10 @@ namespace Core.Io
for (int i = 0; i < stringCount; i++)
{
byte textId = br.ReadByte();
byte textLength = br.ReadByte();
byte textLength32 = br.ReadByte();
// Read the raw bytes and convert them to an ASCII string
string text = System.Text.Encoding.ASCII.GetString(br.ReadBytes(textLength));
string text = System.Text.Encoding.ASCII.GetString(br.ReadBytes(textLength32));
archiveBlock.Metadata[textId] = text;
}

View File

@@ -26,6 +26,8 @@
<None Remove="ROMS\TAP\Treasure Island - Dizzy.tap" />
<None Remove="ROMS\TAP\zexall.tap" />
<None Remove="ROMS\TZX\Batman - Release 1.tzx" />
<None Remove="ROMS\TZX\Renegade 2 - Target Renegade - Side 1.tzx" />
<None Remove="ROMS\TZX\Renegade 2 - Target Renegade - Side 2.tzx" />
<None Remove="ROMS\TZX\Split Personalities1.tzx" />
<None Remove="ROMS\TZX\Split Personalities2.tzx" />
</ItemGroup>
@@ -82,6 +84,12 @@
<EmbeddedResource Include="ROMS\TZX\Batman - Release 1.tzx">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</EmbeddedResource>
<EmbeddedResource Include="ROMS\TZX\Renegade 2 - Target Renegade - Side 1.tzx">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</EmbeddedResource>
<EmbeddedResource Include="ROMS\TZX\Renegade 2 - Target Renegade - Side 2.tzx">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</EmbeddedResource>
<EmbeddedResource Include="ROMS\TZX\Split Personalities1.tzx">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</EmbeddedResource>