From 14366f08f651cfc66654539da6c4d725a81a329e Mon Sep 17 00:00:00 2001 From: Wojciech Sobieszek Date: Wed, 2 Sep 2026 19:19:42 +0100 Subject: [PATCH 1/5] Fix Ula+ border color handling --- src/Spectron.Emulation/Devices/Ula.cs | 3 +- src/Spectron.Emulation/Devices/UlaPlus.cs | 10 ++- src/Spectron.Emulation/Devices/UlaTimex.cs | 35 ++++---- src/Spectron.Emulation/Emulator.cs | 11 ++- src/Spectron.Emulation/Screen/ScreenBuffer.cs | 44 +++++++-- .../Screen/ScreenMemoryHandler.cs | 1 + .../Screen/SpectrumPalette.cs | 3 - .../Snapshot/SnaSnapshot.cs | 6 +- .../Snapshot/SzxSnapshot.cs | 6 +- .../Snapshot/Z80Snapshot.cs | 5 +- src/Spectron.Emulation/State/StateSnapshot.cs | 2 +- .../State/StateSnapshotManager.cs | 10 ++- .../ViewModels/TimeMachineViewModel.cs | 4 +- .../Screen/ScreenBufferTests.cs | 90 ++++++++++++++++++- 14 files changed, 178 insertions(+), 52 deletions(-) diff --git a/src/Spectron.Emulation/Devices/Ula.cs b/src/Spectron.Emulation/Devices/Ula.cs index 80a765bc..87b146e6 100644 --- a/src/Spectron.Emulation/Devices/Ula.cs +++ b/src/Spectron.Emulation/Devices/Ula.cs @@ -32,8 +32,7 @@ public virtual void WritePort(Word address, byte value) return; } - var color = SpectrumPalette.GetBorderColor(value); - screenBuffer.UpdateBorder(color, clock.UlaTicks); + screenBuffer.UpdateBorder((byte)(value & 0x07), clock.UlaTicks); } internal virtual bool IsUlaPort(Word address) => (address & 0x01) == 0x00; diff --git a/src/Spectron.Emulation/Devices/UlaPlus.cs b/src/Spectron.Emulation/Devices/UlaPlus.cs index c6d9283a..1a7faf6d 100644 --- a/src/Spectron.Emulation/Devices/UlaPlus.cs +++ b/src/Spectron.Emulation/Devices/UlaPlus.cs @@ -25,12 +25,12 @@ internal bool IsActive set { field = value; - ActiveChanged?.Invoke(EventArgs.Empty); + ActiveChanged?.Invoke(this, EventArgs.Empty); } } - internal delegate void ActiveChangedEvent(EventArgs e); - internal event ActiveChangedEvent? ActiveChanged; + internal event EventHandler? ActiveChanged; + internal event EventHandler? PaletteChanged; public void WritePort(Word address, byte value) { @@ -56,6 +56,7 @@ public void WritePort(Word address, byte value) var color = ColorFromValue(value); PaletteColors[paletteIndex][colorIndex] = color; + PaletteChanged?.Invoke(this, EventArgs.Empty); break; case Register.ModeGroup: @@ -85,6 +86,9 @@ internal Color GetInkColor(byte attribute) return palette[colorIndex]; } + internal Color GetBorderColor(byte borderIndex, bool isHiRes = false) => + PaletteColors[isHiRes ? 1 : 0][(borderIndex & 0x07) | 8]; + internal Color GetPaperColor(byte attribute) { var paletteIndex = attribute >> 6; diff --git a/src/Spectron.Emulation/Devices/UlaTimex.cs b/src/Spectron.Emulation/Devices/UlaTimex.cs index b7ecbba9..853ca0bb 100644 --- a/src/Spectron.Emulation/Devices/UlaTimex.cs +++ b/src/Spectron.Emulation/Devices/UlaTimex.cs @@ -18,6 +18,7 @@ internal sealed class UlaTimex( internal ScreenMode ScreenMode { get; private set; } internal Color Paper { get; private set; } internal Color Ink { get; private set; } + internal byte PaperIndex { get; private set; } = 0x07; internal event EventHandler? ScreenModeChanged; @@ -62,30 +63,32 @@ public override void WritePort(Word address, byte value) Ink = (value & 0b111_000) switch { - 0b000_000 => SpectrumPalette.Black, - 0b001_000 => SpectrumPalette.BrightBlue, - 0b010_000 => SpectrumPalette.BrightRed, - 0b011_000 => SpectrumPalette.BrightMagenta, - 0b100_000 => SpectrumPalette.BrightGreen, - 0b101_000 => SpectrumPalette.BrightCyan, - 0b110_000 => SpectrumPalette.BrightYellow, - 0b111_000 => SpectrumPalette.BrightWhite, + 0b000_000 => SpectrumPalette.Black, // 000 + 0b001_000 => SpectrumPalette.BrightBlue, // 001 + 0b010_000 => SpectrumPalette.BrightRed, // 010 + 0b011_000 => SpectrumPalette.BrightMagenta, // 011 + 0b100_000 => SpectrumPalette.BrightGreen, // 100 + 0b101_000 => SpectrumPalette.BrightCyan, // 101 + 0b110_000 => SpectrumPalette.BrightYellow, // 110 + 0b111_000 => SpectrumPalette.BrightWhite, // 111 _ => throw new ArgumentOutOfRangeException(nameof(value)) }; Paper = (value & 0b111_000) switch { - 0b000_000 => SpectrumPalette.BrightWhite, - 0b001_000 => SpectrumPalette.BrightYellow, - 0b010_000 => SpectrumPalette.BrightCyan, - 0b011_000 => SpectrumPalette.BrightGreen, - 0b100_000 => SpectrumPalette.BrightMagenta, - 0b101_000 => SpectrumPalette.BrightRed, - 0b110_000 => SpectrumPalette.BrightBlue, - 0b111_000 => SpectrumPalette.Black, + 0b000_000 => SpectrumPalette.BrightWhite, // 111 + 0b001_000 => SpectrumPalette.BrightYellow, // 110 + 0b010_000 => SpectrumPalette.BrightCyan, // 101 + 0b011_000 => SpectrumPalette.BrightGreen, // 100 + 0b100_000 => SpectrumPalette.BrightMagenta, // 011 + 0b101_000 => SpectrumPalette.BrightRed, // 010 + 0b110_000 => SpectrumPalette.BrightBlue, // 001 + 0b111_000 => SpectrumPalette.Black, // 000 _ => throw new ArgumentOutOfRangeException(nameof(value)) }; + PaperIndex = (byte)(~(value >> 3) & 0x07); // Inverted to match the paper color index + if (_lastControlValue != value) { ScreenModeChanged?.Invoke(this, EventArgs.Empty); diff --git a/src/Spectron.Emulation/Emulator.cs b/src/Spectron.Emulation/Emulator.cs index cdf8eda3..e8f96e2d 100644 --- a/src/Spectron.Emulation/Emulator.cs +++ b/src/Spectron.Emulation/Emulator.cs @@ -242,7 +242,8 @@ private void AddEventHandlers() { Cpu.Clock.TicksAdded += (_, previousFrameTicks, _) => ScreenBuffer.UpdateScreen(previousFrameTicks / Clock.Multiplier); Cpu.BeforeInstruction += BeforeInstruction; - UlaPlus.ActiveChanged += _ => _invalidateScreen = true; + UlaPlus.ActiveChanged += (_, _) => InvalidateUlaPlus(); + UlaPlus.PaletteChanged += (_, _) => InvalidateUlaPlus(); Beta128.DiskActivity += _ => DiskDriveManager.OnDiskActivity(); if (Ula is UlaTimex ulaTimex) @@ -345,7 +346,15 @@ private void EndFrame() private void ToggleUlaPlus(bool value) { UlaPlus.IsEnabled = value; + + InvalidateUlaPlus(); + } + + private void InvalidateUlaPlus() + { _invalidateScreen = true; + + ScreenBuffer.RefreshBorder(Clock.UlaTicks); } private void BeforeInstruction(Word pc) diff --git a/src/Spectron.Emulation/Screen/ScreenBuffer.cs b/src/Spectron.Emulation/Screen/ScreenBuffer.cs index 6da15c4f..265691b7 100644 --- a/src/Spectron.Emulation/Screen/ScreenBuffer.cs +++ b/src/Spectron.Emulation/Screen/ScreenBuffer.cs @@ -8,14 +8,16 @@ public sealed class ScreenBuffer { private readonly Border _border; private readonly Content _content; + private readonly UlaPlus _ulaPlus; private bool _borderColorChanged = true; private Color? _lockedBorderColor; + private byte _lockedBorderColorIndex; private ScreenMode _screenMode = ScreenMode.Spectrum; public FrameBuffer FrameBuffer { get; } - internal Color LastBorderColor { get; private set; } = SpectrumPalette.White; + internal byte LastBorderColorIndex { get; private set; } = 0x07; public event EventHandler? FrameBufferChanged; @@ -23,6 +25,7 @@ internal ScreenBuffer(HardwareSettings hardware, IEmulatorMemory memory, UlaPlus { FrameBuffer = new FrameBuffer(); + _ulaPlus = ulaPlus; _border = new Border(hardware, FrameBuffer); _content = new Content(hardware, FrameBuffer, memory, ulaPlus); @@ -32,14 +35,15 @@ internal ScreenBuffer(HardwareSettings hardware, IEmulatorMemory memory, UlaPlus } } - internal void ChangeScreenMode(ScreenMode screenMode, Color ink, Color paper, int frameTicks) + internal void ChangeScreenMode(ScreenMode screenMode, Color ink, Color paper, byte paperIndex, int frameTicks) { _lockedBorderColor = screenMode.IsTimexHiRes() ? paper : null; + _lockedBorderColorIndex = paperIndex; _content.ChangeScreenMode(screenMode, ink, paper); _border.ChangeScreenMode(screenMode); - _border.Update(_lockedBorderColor ?? LastBorderColor, frameTicks); + _border.Update(BorderColor, frameTicks); if (_screenMode != screenMode) { @@ -55,13 +59,15 @@ internal void NewFrame() _content.NewFrame(); } - internal void EndFrame(int frameTicks) => _border.Update(_lockedBorderColor ?? LastBorderColor, frameTicks); + internal void EndFrame(int frameTicks) => _border.Update(BorderColor, frameTicks); - internal void UpdateBorder(Color borderColor, int frameTicks = 0) + internal void UpdateBorder(byte borderColor, int frameTicks = 0) { - if (LastBorderColor != borderColor) + borderColor &= 0x07; + + if (LastBorderColorIndex != borderColor) { - LastBorderColor = borderColor; + LastBorderColorIndex = borderColor; _borderColorChanged = true; } @@ -70,11 +76,33 @@ internal void UpdateBorder(Color borderColor, int frameTicks = 0) return; } - _border.Update(_lockedBorderColor ?? borderColor, frameTicks); + _border.Update(BorderColor, frameTicks); _borderColorChanged = false; } + internal void RefreshBorder(int frameTicks = 0) + { + _borderColorChanged = true; + + UpdateBorder(LastBorderColorIndex, frameTicks); + } + + private Color BorderColor + { + get + { + if (_ulaPlus is { IsEnabled: true, IsActive: true }) + { + return _lockedBorderColor != null ? + _ulaPlus.GetBorderColor(_lockedBorderColorIndex, isHiRes: true) : + _ulaPlus.GetBorderColor(LastBorderColorIndex); + } + + return _lockedBorderColor ?? SpectrumPalette.GetBorderColor(LastBorderColorIndex); + } + } + internal void Reset() { _border.Reset(); diff --git a/src/Spectron.Emulation/Screen/ScreenMemoryHandler.cs b/src/Spectron.Emulation/Screen/ScreenMemoryHandler.cs index 1650801c..c92ce6d8 100644 --- a/src/Spectron.Emulation/Screen/ScreenMemoryHandler.cs +++ b/src/Spectron.Emulation/Screen/ScreenMemoryHandler.cs @@ -70,6 +70,7 @@ internal void SetScreenMode(UlaTimex? ulaTimex, int frameTicks = 0) screenMode, ulaTimex?.Ink ?? SpectrumPalette.Black, ulaTimex?.Paper ?? SpectrumPalette.White, + ulaTimex?.PaperIndex ?? 0x07, frameTicks); } diff --git a/src/Spectron.Emulation/Screen/SpectrumPalette.cs b/src/Spectron.Emulation/Screen/SpectrumPalette.cs index 21eb58ac..661c66d5 100644 --- a/src/Spectron.Emulation/Screen/SpectrumPalette.cs +++ b/src/Spectron.Emulation/Screen/SpectrumPalette.cs @@ -35,9 +35,6 @@ public static class SpectrumPalette { 0b0000111, White } }; - internal static readonly Dictionary ReverseBorderColors = - BorderColors.ToDictionary(x => x.Value, x => x.Key); - private static readonly Dictionary PaperColors = new() { { 0b00000000, Black }, diff --git a/src/Spectron.Emulation/Snapshot/SnaSnapshot.cs b/src/Spectron.Emulation/Snapshot/SnaSnapshot.cs index 5b6ae763..e72571f6 100644 --- a/src/Spectron.Emulation/Snapshot/SnaSnapshot.cs +++ b/src/Spectron.Emulation/Snapshot/SnaSnapshot.cs @@ -41,7 +41,7 @@ internal void Save(string fileName, Emulator emulator) InterruptMode = (byte)emulator.Cpu.IM, Interrupt = (byte)((emulator.Cpu.IFF2 ? 0x04 : 0x00) | (emulator.Cpu.IFF1 ? 0x02 : 0x00)), - BorderColor = SpectrumPalette.ReverseBorderColors[emulator.ScreenBuffer.LastBorderColor] + BorderColor = emulator.ScreenBuffer.LastBorderColorIndex } }; @@ -123,10 +123,8 @@ internal static void Update(Emulator emulator, SnaFile snapshot, bool updateBord return; } - var borderColor = SpectrumPalette.GetBorderColor(snapshot.Header.BorderColor); - screenBuffer.Reset(); - screenBuffer.UpdateBorder(borderColor); + screenBuffer.UpdateBorder(snapshot.Header.BorderColor); } private Emulator CreateEmulator(SnaFile snapshot) diff --git a/src/Spectron.Emulation/Snapshot/SzxSnapshot.cs b/src/Spectron.Emulation/Snapshot/SzxSnapshot.cs index bd07c3d7..4e9450e3 100644 --- a/src/Spectron.Emulation/Snapshot/SzxSnapshot.cs +++ b/src/Spectron.Emulation/Snapshot/SzxSnapshot.cs @@ -182,10 +182,8 @@ private static void LoadMemory(IMemory memory, List ramPages, Spec private static void UpdateBorder(ScreenBuffer screenBuffer, SpecRegsBlock specRegs) { - var borderColor = SpectrumPalette.GetBorderColor(specRegs.Border); - screenBuffer.Reset(); - screenBuffer.UpdateBorder(borderColor); + screenBuffer.UpdateBorder(specRegs.Border); } private static void LoadUlaPlus(UlaPlus ulaPlus, PaletteBlock? palette) @@ -335,7 +333,7 @@ private static void SaveCustomRom(IMemory memory, SzxFile snapshot, CompressionL } private static void SaveSpectrumRegisters(ScreenBuffer screenBuffer, SpecRegsBlock specRegs) => - specRegs.Border = SpectrumPalette.ReverseBorderColors[screenBuffer.LastBorderColor]; + specRegs.Border = screenBuffer.LastBorderColorIndex; private static void SaveUlaPlus(UlaPlus ulaPlus, SzxFile snapshot) { diff --git a/src/Spectron.Emulation/Snapshot/Z80Snapshot.cs b/src/Spectron.Emulation/Snapshot/Z80Snapshot.cs index 09bf84fc..dabc50ba 100644 --- a/src/Spectron.Emulation/Snapshot/Z80Snapshot.cs +++ b/src/Spectron.Emulation/Snapshot/Z80Snapshot.cs @@ -42,7 +42,7 @@ internal void Save(string fileName, Emulator emulator) IFF2 = (byte)(emulator.Cpu.IFF2 ? 1 : 0), Flags1 = { - BorderColor = SpectrumPalette.ReverseBorderColors[emulator.ScreenBuffer.LastBorderColor], + BorderColor = emulator.ScreenBuffer.LastBorderColorIndex, }, Flags2 = { @@ -134,8 +134,7 @@ internal static void Update(Emulator emulator, Z80File snapshot, bool updateBord } screenBuffer.Reset(); - var borderColor = SpectrumPalette.GetBorderColor(snapshot.Header.Flags1.BorderColor); - screenBuffer.UpdateBorder(borderColor); + screenBuffer.UpdateBorder(snapshot.Header.Flags1.BorderColor); } private Emulator CreateEmulator(Z80File snapshot) diff --git a/src/Spectron.Emulation/State/StateSnapshot.cs b/src/Spectron.Emulation/State/StateSnapshot.cs index bc0faf07..79bf0d2c 100644 --- a/src/Spectron.Emulation/State/StateSnapshot.cs +++ b/src/Spectron.Emulation/State/StateSnapshot.cs @@ -9,7 +9,7 @@ public sealed partial class StateSnapshot { public ComputerType ComputerType { get; set; } - public Color BorderColor { get; set; } + public byte Border { get; set; } public CpuState Cpu { get; set; } = new(); diff --git a/src/Spectron.Emulation/State/StateSnapshotManager.cs b/src/Spectron.Emulation/State/StateSnapshotManager.cs index 16b0989d..14ad65f4 100644 --- a/src/Spectron.Emulation/State/StateSnapshotManager.cs +++ b/src/Spectron.Emulation/State/StateSnapshotManager.cs @@ -275,8 +275,10 @@ private static void SaveTimex(Ula ula, StateSnapshot stateSnapshot) }; } - private static void SaveOther(Emulator emulator, StateSnapshot stateSnapshot) => - stateSnapshot.BorderColor = emulator.ScreenBuffer.LastBorderColor; + private static void SaveOther(Emulator emulator, StateSnapshot stateSnapshot) + { + stateSnapshot.Border = emulator.ScreenBuffer.LastBorderColorIndex; + } private static void SaveCustomRom(Emulator emulator, StateSnapshot stateSnapshot) { @@ -385,8 +387,10 @@ private static void LoadTape(TapeManager tapeManager, TapeState? tapeState) private static void LoadOther(Emulator emulator, StateSnapshot stateSnapshot) { + var border = stateSnapshot.Border; + emulator.ScreenBuffer.Reset(); - emulator.ScreenBuffer.UpdateBorder(stateSnapshot.BorderColor); + emulator.ScreenBuffer.UpdateBorder(border); } private static void LoadAy(AudioManager audioManager, AyState? ayState) diff --git a/src/Spectron/ViewModels/TimeMachineViewModel.cs b/src/Spectron/ViewModels/TimeMachineViewModel.cs index 19bc7969..cf633126 100644 --- a/src/Spectron/ViewModels/TimeMachineViewModel.cs +++ b/src/Spectron/ViewModels/TimeMachineViewModel.cs @@ -14,6 +14,7 @@ using OldBit.Spectron.Emulation.Devices.Gamepad; using OldBit.Spectron.Emulation.Devices.Joystick; using OldBit.Spectron.Emulation.Extensions; +using OldBit.Spectron.Emulation.Screen; using OldBit.Spectron.Emulation.TimeTravel; using OldBit.Spectron.Messages; @@ -129,7 +130,8 @@ private void UpdatePreview() Marshal.Copy(screenshot, 0, bitmap.Address, screenshot.Length); } - ScreenBorderBrush = new SolidColorBrush(snapshot.BorderColor.Argb); + var borderColor = SpectrumPalette.GetBorderColor(snapshot.Border); + ScreenBorderBrush = new SolidColorBrush(borderColor.Argb); PreviewControl?.InvalidateVisual(); } diff --git a/tests/Spectron.Emulator.Tests/Screen/ScreenBufferTests.cs b/tests/Spectron.Emulator.Tests/Screen/ScreenBufferTests.cs index 9df7f86e..0391ce1f 100644 --- a/tests/Spectron.Emulator.Tests/Screen/ScreenBufferTests.cs +++ b/tests/Spectron.Emulator.Tests/Screen/ScreenBufferTests.cs @@ -7,6 +7,11 @@ namespace OldBit.Spectron.Emulator.Tests.Screen; public class ScreenBufferTests { + private const int RegisterPort = 0xBF3B; + private const int DataPort = 0xFF3B; + + private static readonly Color BrightGreen = new(0x00, 0xFF, 0x00); + [Fact] public void Test() { @@ -17,9 +22,88 @@ public void Test() var screenBuffer = new ScreenBuffer(Hardware.Spectrum48K, memory, ulaPlus); - screenBuffer.UpdateBorder(SpectrumPalette.Cyan, 224); - screenBuffer.UpdateBorder(SpectrumPalette.White, 448); + screenBuffer.UpdateBorder(5, 224); + screenBuffer.UpdateBorder(7, 448); // TODO: Write some tests for the frame buffer, not so easy } -} \ No newline at end of file + + [Fact] + public void UpdateBorder_ShouldUseStandardColor_WhenUlaPlusIsNotActive() + { + var ulaPlus = new UlaPlus { IsEnabled = true }; + var screenBuffer = CreateScreenBuffer(ulaPlus); + + SetPaletteEntry(ulaPlus, entry: 8, color: 0xE0); + + screenBuffer.UpdateBorder(2); + screenBuffer.EndFrame(Hardware.Spectrum48K.TicksPerFrame); + + screenBuffer.FrameBuffer.Pixels[100..5000].ShouldAllBe(color => color == SpectrumPalette.Red); + } + + [Fact] + public void UpdateBorder_ShouldUseUlaPlusPalette_WhenUlaPlusIsActive() + { + var ulaPlus = new UlaPlus { IsEnabled = true }; + var screenBuffer = CreateScreenBuffer(ulaPlus); + + // BORDER 2 uses PAPER 2 of the first CLUT, which is palette entry 10 + SetPaletteEntry(ulaPlus, entry: 10, color: 0xE0); + Activate(ulaPlus); + + screenBuffer.UpdateBorder(2); + screenBuffer.EndFrame(Hardware.Spectrum48K.TicksPerFrame); + + screenBuffer.FrameBuffer.Pixels[100..5000].ShouldAllBe(color => color == BrightGreen); + } + + [Fact] + public void RefreshBorder_ShouldUpdateBorder_WhenUlaPlusPaletteChanges() + { + var ulaPlus = new UlaPlus { IsEnabled = true }; + var screenBuffer = CreateScreenBuffer(ulaPlus); + + Activate(ulaPlus); + screenBuffer.UpdateBorder(2); + + SetPaletteEntry(ulaPlus, entry: 10, color: 0xE0); + screenBuffer.RefreshBorder(); + screenBuffer.EndFrame(Hardware.Spectrum48K.TicksPerFrame); + + screenBuffer.FrameBuffer.Pixels[100..5000].ShouldAllBe(color => color == BrightGreen); + } + + [Fact] + public void UpdateBorder_ShouldUseSecondUlaPlusClut_WhenTimexHiResIsActive() + { + var ulaPlus = new UlaPlus { IsEnabled = true }; + var screenBuffer = CreateScreenBuffer(ulaPlus, Hardware.Timex2048); + + // Hi-res PAPER 5 uses palette entry 13 of the second CLUT + SetPaletteEntry(ulaPlus, entry: 16 + 13, color: 0xE0); + Activate(ulaPlus); + + screenBuffer.ChangeScreenMode( + ScreenMode.TimexHiRes, SpectrumPalette.Black, SpectrumPalette.White, paperIndex: 5, frameTicks: 0); + + screenBuffer.EndFrame(Hardware.Timex2048.TicksPerFrame); + + screenBuffer.FrameBuffer.Pixels[100..5000].ShouldAllBe(color => color == BrightGreen); + } + + private static ScreenBuffer CreateScreenBuffer(UlaPlus ulaPlus, HardwareSettings? hardware = null) => + new(hardware ?? Hardware.Spectrum48K, new Memory48K(new byte[16384]), ulaPlus); + + private static void SetPaletteEntry(UlaPlus ulaPlus, int entry, byte color) + { + ulaPlus.WritePort(RegisterPort, (byte)entry); + ulaPlus.WritePort(DataPort, color); + } + + private static void Activate(UlaPlus ulaPlus) + { + ulaPlus.WritePort(RegisterPort, 0x40); + ulaPlus.WritePort(DataPort, 0x01); + } +} From 5c6651a55f12eec243b4c2fb5f7af2c6239c45cc Mon Sep 17 00:00:00 2001 From: Wojciech Sobieszek Date: Wed, 2 Sep 2026 19:33:37 +0100 Subject: [PATCH 2/5] Fix test files to match border changes --- .../TestFiles/quick-save.spectron | Bin 49274 -> 57588 bytes tests/Spectron.Tests/TestFiles/test.spectron | Bin 49274 -> 57588 bytes 2 files changed, 0 insertions(+), 0 deletions(-) diff --git a/tests/Spectron.Tests/TestFiles/quick-save.spectron b/tests/Spectron.Tests/TestFiles/quick-save.spectron index 64897fd4e40c40f62895c66f858e3e40c1edfd40..8c472da1c904e47bc442b7357fdef207c4a235ce 100644 GIT binary patch literal 57588 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 IhXA1e0Qvk0MF0Q* delta 449 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 E2Wb}x#{d8T delta 448 zcmajcF-yZh6ae6tbVzJdJQPV;93qrbe?sjq zaMQ8Wp&dlroH~fmp+fDVf|H<#4#qo0aPvJLkK?;{aCa}4k&k*LJuU!1zuylhC>Uy& zo}in^5ZtU($#to!6#4qR6*A*MD$ zHsHY4Sq48U7q}ML_8LKmfI~*^V6~*&H5o2rrBK97ez&A`ua>}NZAcwM0vsy zMIvAR#^D%jga8I?N-E$x&(MtKxxPAOWnRsExl-{{>$~Nx{XKujjMrP%2(|F_KXnyd z=*1qqUhL6JsJGEx?A?=D?`t-D>3CvDPql$K5Z9nf4YfmQ^WfrXG7JU&PrM*xAwc*A DTRMJ8 From 635ea006f32ca33e8035cea02aa47ef96a1bed64 Mon Sep 17 00:00:00 2001 From: Wojciech Sobieszek Date: Wed, 2 Sep 2026 19:54:56 +0100 Subject: [PATCH 3/5] Fix time machine preview to show correct border colour if ULA+ is enabled --- .../Extensions/EmulatorStateExtensions.cs | 78 +++++++++++-------- .../ViewModels/TimeMachineViewModel.cs | 3 +- .../EmulatorStateExtensionsTests.cs | 58 ++++++++++++++ 3 files changed, 106 insertions(+), 33 deletions(-) create mode 100644 tests/Spectron.Emulator.Tests/Extensions/EmulatorStateExtensionsTests.cs diff --git a/src/Spectron.Emulation/Extensions/EmulatorStateExtensions.cs b/src/Spectron.Emulation/Extensions/EmulatorStateExtensions.cs index f7bde6df..2be3e720 100644 --- a/src/Spectron.Emulation/Extensions/EmulatorStateExtensions.cs +++ b/src/Spectron.Emulation/Extensions/EmulatorStateExtensions.cs @@ -8,50 +8,63 @@ public static class EmulatorStateExtensions { private static readonly int[] Buffer = new int[ScreenSize.ContentHeight * ScreenSize.ContentWidth]; - public static int[] GetScreenshot(this StateSnapshot snapshot, bool isFlashOnFrame = false) + extension(StateSnapshot snapshot) { - var screenMemory = snapshot.ComputerType == ComputerType.Spectrum128K ? - snapshot.Memory.Banks[5] : - snapshot.Memory.Banks[0]; - - for (var line = 0; line < ScreenSize.ContentHeight; line++) + public int[] GetScreenshot(bool isFlashOnFrame = false) { - for (var column = 0; column < 32; column++) - { - var bitmapAddress = ScreenAddress.Calculate(column, line) - 0x4000; - var attributeAddress = ScreenAddress.CalculateAttribute(column, line) - 0x4000; + var screenMemory = snapshot.ComputerType == ComputerType.Spectrum128K ? + snapshot.Memory.Banks[5] : + snapshot.Memory.Banks[0]; - var bitmap = screenMemory[bitmapAddress]; - var attribute = screenMemory[attributeAddress]; + for (var line = 0; line < ScreenSize.ContentHeight; line++) + { + for (var column = 0; column < 32; column++) + { + var bitmapAddress = ScreenAddress.Calculate(column, line) - 0x4000; + var attributeAddress = ScreenAddress.CalculateAttribute(column, line) - 0x4000; - var attributeData = FastLookup.AttributeData[attribute]; - var isFlashOn = attributeData.IsFlashOn && isFlashOnFrame; + var bitmap = screenMemory[bitmapAddress]; + var attribute = screenMemory[attributeAddress]; - var bufferIndex = 256 * line + 8 * column; + var attributeData = FastLookup.AttributeData[attribute]; + var isFlashOn = attributeData.IsFlashOn && isFlashOnFrame; - for (var bit = 0; bit < FastLookup.BitMasks.Length; bit++) - { - Color color; + var bufferIndex = 256 * line + 8 * column; - if (snapshot.UlaPlus != null && snapshot.UlaPlus.PaletteGroup != 0) + for (var bit = 0; bit < FastLookup.BitMasks.Length; bit++) { - color = (bitmap & FastLookup.BitMasks[bit]) != 0 ? - snapshot.UlaPlus.GetInkColor(attribute) : - snapshot.UlaPlus.GetPaperColor(attribute); - } - else - { - color = (bitmap & FastLookup.BitMasks[bit]) != 0 ^ isFlashOn ? - attributeData.Ink : - attributeData.Paper; - } + Color color; + + if (snapshot.UlaPlus != null && snapshot.UlaPlus.PaletteGroup != 0) + { + color = (bitmap & FastLookup.BitMasks[bit]) != 0 ? + snapshot.UlaPlus.GetInkColor(attribute) : + snapshot.UlaPlus.GetPaperColor(attribute); + } + else + { + color = (bitmap & FastLookup.BitMasks[bit]) != 0 ^ isFlashOn ? + attributeData.Ink : + attributeData.Paper; + } - Buffer[bufferIndex + bit] = (int)color.Abgr; + Buffer[bufferIndex + bit] = (int)color.Abgr; + } } } + + return Buffer; } - return Buffer; + public Color GetBorderColor() + { + if (snapshot.UlaPlus is { IsEnabled: true, IsActive: true } ulaPlus) + { + return ulaPlus.GetBorderColor(snapshot.Border); + } + + return SpectrumPalette.GetBorderColor(snapshot.Border); + } } extension(UlaPlusState ulaPlusState) @@ -71,5 +84,8 @@ private Color GetPaperColor(byte attribute) return ulaPlusState.PaletteColors[paletteIndex][colorIndex]; } + + private Color GetBorderColor(byte borderIndex) => + ulaPlusState.PaletteColors[0][(borderIndex & 0x07) | 8]; } } \ No newline at end of file diff --git a/src/Spectron/ViewModels/TimeMachineViewModel.cs b/src/Spectron/ViewModels/TimeMachineViewModel.cs index cf633126..be0b1d7f 100644 --- a/src/Spectron/ViewModels/TimeMachineViewModel.cs +++ b/src/Spectron/ViewModels/TimeMachineViewModel.cs @@ -14,7 +14,6 @@ using OldBit.Spectron.Emulation.Devices.Gamepad; using OldBit.Spectron.Emulation.Devices.Joystick; using OldBit.Spectron.Emulation.Extensions; -using OldBit.Spectron.Emulation.Screen; using OldBit.Spectron.Emulation.TimeTravel; using OldBit.Spectron.Messages; @@ -130,7 +129,7 @@ private void UpdatePreview() Marshal.Copy(screenshot, 0, bitmap.Address, screenshot.Length); } - var borderColor = SpectrumPalette.GetBorderColor(snapshot.Border); + var borderColor = snapshot.GetBorderColor(); ScreenBorderBrush = new SolidColorBrush(borderColor.Argb); PreviewControl?.InvalidateVisual(); diff --git a/tests/Spectron.Emulator.Tests/Extensions/EmulatorStateExtensionsTests.cs b/tests/Spectron.Emulator.Tests/Extensions/EmulatorStateExtensionsTests.cs new file mode 100644 index 00000000..75a71e10 --- /dev/null +++ b/tests/Spectron.Emulator.Tests/Extensions/EmulatorStateExtensionsTests.cs @@ -0,0 +1,58 @@ +using OldBit.Spectron.Emulation.Extensions; +using OldBit.Spectron.Emulation.Screen; +using OldBit.Spectron.Emulation.State; +using OldBit.Spectron.Emulation.State.Components; + +namespace OldBit.Spectron.Emulator.Tests.Extensions; + +public class EmulatorStateExtensionsTests +{ + [Fact] + public void GetBorderColor_WhenUlaPlusDisabled_ReturnsSpectrumPaletteColor() + { + var snapshot = new StateSnapshot { Border = 2 }; + + snapshot.GetBorderColor().ShouldBe(SpectrumPalette.GetBorderColor(2)); + } + + [Fact] + public void GetBorderColor_WhenUlaPlusEnabledButInactive_ReturnsSpectrumPaletteColor() + { + var snapshot = new StateSnapshot + { + Border = 2, + UlaPlus = new UlaPlusState + { + IsEnabled = true, + IsActive = false, + PaletteColors = CreatePalette(), + }, + }; + + snapshot.GetBorderColor().ShouldBe(SpectrumPalette.GetBorderColor(2)); + } + + [Fact] + public void GetBorderColor_WhenUlaPlusEnabledAndActive_ReturnsUlaPlusPaletteColor() + { + var expected = new Color(0x12, 0x34, 0x56); + var palette = CreatePalette(); + palette[0][(2 & 0x07) | 8] = expected; + + var snapshot = new StateSnapshot + { + Border = 2, + UlaPlus = new UlaPlusState + { + IsEnabled = true, + IsActive = true, + PaletteColors = palette, + }, + }; + + snapshot.GetBorderColor().ShouldBe(expected); + } + + private static Color[][] CreatePalette() => + [new Color[16], new Color[16], new Color[16], new Color[16]]; +} From 8ee89b093c6d45dc18d96946dd63c874cee1c538 Mon Sep 17 00:00:00 2001 From: Wojciech Sobieszek Date: Wed, 2 Sep 2026 20:19:15 +0100 Subject: [PATCH 4/5] Fix ULA+ check in GetScreenshot --- .../Extensions/EmulatorStateExtensions.cs | 8 +-- .../EmulatorStateExtensionsTests.cs | 53 +++++++++++++++++++ 2 files changed, 58 insertions(+), 3 deletions(-) diff --git a/src/Spectron.Emulation/Extensions/EmulatorStateExtensions.cs b/src/Spectron.Emulation/Extensions/EmulatorStateExtensions.cs index 2be3e720..0d6dfaf4 100644 --- a/src/Spectron.Emulation/Extensions/EmulatorStateExtensions.cs +++ b/src/Spectron.Emulation/Extensions/EmulatorStateExtensions.cs @@ -16,6 +16,8 @@ public int[] GetScreenshot(bool isFlashOnFrame = false) snapshot.Memory.Banks[5] : snapshot.Memory.Banks[0]; + var isUlaPlus = snapshot.UlaPlus is { IsEnabled: true, IsActive: true }; + for (var line = 0; line < ScreenSize.ContentHeight; line++) { for (var column = 0; column < 32; column++) @@ -35,11 +37,11 @@ public int[] GetScreenshot(bool isFlashOnFrame = false) { Color color; - if (snapshot.UlaPlus != null && snapshot.UlaPlus.PaletteGroup != 0) + if (isUlaPlus) { color = (bitmap & FastLookup.BitMasks[bit]) != 0 ? - snapshot.UlaPlus.GetInkColor(attribute) : - snapshot.UlaPlus.GetPaperColor(attribute); + snapshot.UlaPlus!.GetInkColor(attribute) : + snapshot.UlaPlus!.GetPaperColor(attribute); } else { diff --git a/tests/Spectron.Emulator.Tests/Extensions/EmulatorStateExtensionsTests.cs b/tests/Spectron.Emulator.Tests/Extensions/EmulatorStateExtensionsTests.cs index 75a71e10..8bf36ffb 100644 --- a/tests/Spectron.Emulator.Tests/Extensions/EmulatorStateExtensionsTests.cs +++ b/tests/Spectron.Emulator.Tests/Extensions/EmulatorStateExtensionsTests.cs @@ -1,3 +1,4 @@ +using OldBit.Spectron.Emulation; using OldBit.Spectron.Emulation.Extensions; using OldBit.Spectron.Emulation.Screen; using OldBit.Spectron.Emulation.State; @@ -53,6 +54,58 @@ public void GetBorderColor_WhenUlaPlusEnabledAndActive_ReturnsUlaPlusPaletteColo snapshot.GetBorderColor().ShouldBe(expected); } + [Fact] + public void GetScreenshot_WhenUlaPlusActive_UsesUlaPlusPaletteRegardlessOfPaletteGroup() + { + // Bitmap all zeros -> every pixel is paper. Attribute 0 -> palette 0, paper colour index 8. + // PaletteGroup is 0 here, proving the renderer keys off IsActive, not PaletteGroup. + var expected = new Color(0x11, 0x22, 0x33); + var palette = CreatePalette(); + palette[0][8] = expected; + + var snapshot = new StateSnapshot + { + ComputerType = ComputerType.Spectrum48K, + UlaPlus = new UlaPlusState + { + IsEnabled = true, + IsActive = true, + PaletteGroup = 0, + PaletteColors = palette, + }, + }; + snapshot.Memory.SetBank(new byte[0xC000], pageNumber: 0); + + var screenshot = snapshot.GetScreenshot(); + + screenshot[0].ShouldBe((int)expected.Abgr); + } + + [Fact] + public void GetScreenshot_WhenUlaPlusInactive_UsesStandardPalette() + { + // Attribute 0b0000_1000 -> blue paper in the standard palette; bitmap all zeros -> all paper. + var memory = new byte[0xC000]; + memory[0x1800] = 0b0000_1000; + + var snapshot = new StateSnapshot + { + ComputerType = ComputerType.Spectrum48K, + UlaPlus = new UlaPlusState + { + IsEnabled = true, + IsActive = false, + PaletteGroup = 0x3F, + PaletteColors = CreatePalette(), + }, + }; + snapshot.Memory.SetBank(memory, pageNumber: 0); + + var screenshot = snapshot.GetScreenshot(); + + screenshot[0].ShouldBe((int)SpectrumPalette.Blue.Abgr); + } + private static Color[][] CreatePalette() => [new Color[16], new Color[16], new Color[16], new Color[16]]; } From 8ffaab93e6c448894f7d95f805dcae1372e8abfb Mon Sep 17 00:00:00 2001 From: Wojciech Sobieszek Date: Wed, 2 Sep 2026 20:31:14 +0100 Subject: [PATCH 5/5] Update version and add more tests --- src/Spectron/Views/AboutView.axaml | 2 +- .../EmulatorStateExtensionsTests.cs | 29 +++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/Spectron/Views/AboutView.axaml b/src/Spectron/Views/AboutView.axaml index b63529a1..d21470f6 100644 --- a/src/Spectron/Views/AboutView.axaml +++ b/src/Spectron/Views/AboutView.axaml @@ -49,7 +49,7 @@ - + diff --git a/tests/Spectron.Emulator.Tests/Extensions/EmulatorStateExtensionsTests.cs b/tests/Spectron.Emulator.Tests/Extensions/EmulatorStateExtensionsTests.cs index 8bf36ffb..de1687c5 100644 --- a/tests/Spectron.Emulator.Tests/Extensions/EmulatorStateExtensionsTests.cs +++ b/tests/Spectron.Emulator.Tests/Extensions/EmulatorStateExtensionsTests.cs @@ -106,6 +106,35 @@ public void GetScreenshot_WhenUlaPlusInactive_UsesStandardPalette() screenshot[0].ShouldBe((int)SpectrumPalette.Blue.Abgr); } + [Fact] + public void GetScreenshot_AfterSerializationRoundTrip_PreservesUlaPlusPalette() + { + // Mirrors the exact Time Machine path: Serialize -> Deserialize -> GetScreenshot. + var expected = new Color(0x11, 0x22, 0x33); + var palette = CreatePalette(); + palette[0][8] = expected; + + var original = new StateSnapshot + { + ComputerType = ComputerType.Spectrum48K, + UlaPlus = new UlaPlusState + { + IsEnabled = true, + IsActive = true, + PaletteGroup = 0x3F, + PaletteColors = palette, + }, + }; + original.Memory.SetBank(new byte[0xC000], pageNumber: 0); + + var snapshot = StateSnapshot.Deserialize(original.Serialize()); + + snapshot.ShouldNotBeNull(); + var screenshot = snapshot.GetScreenshot(); + + screenshot[0].ShouldBe((int)expected.Abgr); + } + private static Color[][] CreatePalette() => [new Color[16], new Color[16], new Color[16], new Color[16]]; }