diff --git a/doc/mcp.md b/doc/mcp.md index 205bbce335..235620bb8a 100644 --- a/doc/mcp.md +++ b/doc/mcp.md @@ -28,6 +28,28 @@ changing the BIOS mode record (for example, 256x224 unchained graphics). Pixel and scanline doubling are presentation concerns, not duplicate pixels in the returned framebuffer. GUI graphics retain 4:3 aspect correction. +VGA input status ports follow the emulated clock and programmed beam timing, +independently of framebuffer rendering. The status reader and frame scheduler +share one beam epoch and register-derived model: 25.175/28.322 MHz oscillators, +8/9-dot character clocks, `HorizontalTotal + 5`, and `VerticalTotalValue + 2`. +The sequencer half-dot flag doubles the horizontal period; the CRTC vertical +divide flag doubles physical vertical intervals. Blanking and retrace have +separate wrapped counters; frame publication occurs at display +end, not at retrace start. Timing changes invalidate old frame callbacks on the +video scheduler's next callback; repeated or non-timing writes preserve phase. +Late device scheduler pumps preserve periodic event deadlines rather than +slowing hardware clocks. +Real-time clock reads are fresh, and CPU throttling uses sub-millisecond slices +so guest polling loops can observe short retrace pulses. + +MCP mouse tools and GUI mouse input use the same emulator-thread input queue, +feeding both mouse hardware and the DOS mouse driver. +The emulation loop drains input before each instruction. Mouse samples use the +emulated clock and schedule deferred delivery, accumulating movement and +button transition masks instead of discarding events inside a sample interval. +Scheduler catch-up processes at most 1024 callbacks per pump and retains +remaining deadlines for subsequent pumps. + | Endpoint | Description | |----------|-------------| | `http://localhost:/mcp` | MCP protocol endpoint (JSON-RPC over HTTP) | diff --git a/src/Spice86.Core/Emulator/Devices/Input/Mouse/Mouse.cs b/src/Spice86.Core/Emulator/Devices/Input/Mouse/Mouse.cs index 385910a6bc..8c74918964 100644 --- a/src/Spice86.Core/Emulator/Devices/Input/Mouse/Mouse.cs +++ b/src/Spice86.Core/Emulator/Devices/Input/Mouse/Mouse.cs @@ -6,11 +6,11 @@ namespace Spice86.Core.Emulator.Devices.Input.Mouse; using Spice86.Core.Emulator.Devices.ExternalInput; using Spice86.Core.Emulator.InterruptHandlers.Input.Mouse; using Spice86.Core.Emulator.IOPorts; +using Spice86.Core.Emulator.VM.Clock; +using Spice86.Core.Emulator.VM.DeviceScheduler; using Spice86.Shared.Emulator.Mouse; using Spice86.Shared.Interfaces; -using System.Diagnostics; - /// /// Basic implementation of a mouse /// @@ -18,14 +18,17 @@ public class Mouse : DefaultIOPortHandler, IMouseDevice { private const int IrqNumber = 12; private readonly IGuiMouseEvents? _gui; private readonly ILogger _logger; - private long _lastUpdateTimestamp; + private readonly IEmulatedClock _clock; + private readonly DeviceScheduler _scheduler; + private double _lastUpdateTimeMs = double.NegativeInfinity; + private bool _updateScheduled; + private MouseEventMask _pendingTrigger; private bool _previousIsLeftButtonDown; private bool _previousIsMiddleButtonDown; private bool _previousIsRightButtonDown; private double _previousMouseXRelative; private double _previousMouseYRelative; private int _sampleRate = 100; - private long _sampleRateTicks; private readonly DualPic _dualPic; private readonly SharedMouseData _sharedMouseData; @@ -38,10 +41,12 @@ public class Mouse : DefaultIOPortHandler, IMouseDevice { /// The type of mouse to emulate. /// The logger service implementation. /// Whether we throw an exception when an I/O wasn't handled. + /// Clock used to pace mouse samples. + /// Emulator-thread scheduler for deferred mouse samples. /// The GUI mouse events interface, or null if not using a GUI. public Mouse(State state, SharedMouseData sharedMouseData, DualPic dualPic, MouseType mouseType, - ILogger loggerService, bool failOnUnhandledPort, + ILogger loggerService, bool failOnUnhandledPort, IEmulatedClock clock, DeviceScheduler scheduler, IGuiMouseEvents? gui = null) : base(state, failOnUnhandledPort, loggerService) { _gui = gui; @@ -49,7 +54,8 @@ public Mouse(State state, SharedMouseData sharedMouseData, _dualPic = dualPic; MouseType = mouseType; _logger = loggerService; - _sampleRateTicks = TimeSpan.TicksPerSecond / _sampleRate; + _clock = clock; + _scheduler = scheduler; Initialize(); _dualPic.SetIrqMask(IrqNumber, false); } @@ -76,8 +82,8 @@ public Mouse(State state, SharedMouseData sharedMouseData, public int SampleRate { get => _sampleRate; set { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(value); _sampleRate = value; - _sampleRateTicks = TimeSpan.TicksPerSecond / _sampleRate; } } @@ -145,18 +151,12 @@ private void OnMouseClick(object? sender, MouseButtonEventArgs eventArgs) { } private void UpdateMouse() { - long timestamp = Stopwatch.GetTimestamp(); - // Check the sample rate to see if we need to send an update yet. - long ticksElapsed = timestamp - _lastUpdateTimestamp; - if (ticksElapsed < _sampleRateTicks) { - return; - } - _lastUpdateTimestamp = timestamp; - MouseEventMask trigger = 0; - DeltaX = MouseXRelative - _previousMouseXRelative; - DeltaY = MouseYRelative - _previousMouseYRelative; - if (Math.Abs(DeltaX) > double.Epsilon || Math.Abs(DeltaY) > double.Epsilon) { + double deltaX = MouseXRelative - _previousMouseXRelative; + double deltaY = MouseYRelative - _previousMouseYRelative; + DeltaX += deltaX; + DeltaY += deltaY; + if (Math.Abs(deltaX) > double.Epsilon || Math.Abs(deltaY) > double.Epsilon) { trigger |= MouseEventMask.Movement; } _previousMouseXRelative = MouseXRelative; @@ -177,10 +177,36 @@ private void UpdateMouse() { } _previousIsMiddleButtonDown = IsMiddleButtonDown; - LastTrigger = trigger; + _pendingTrigger |= trigger; + if (_pendingTrigger == 0) { + return; + } + + double now = _clock.ElapsedTimeMs; + double delay = _lastUpdateTimeMs + 1000.0 / _sampleRate - now; + if (delay > 0) { + if (!_updateScheduled) { + _scheduler.AddEvent(DeliverPendingUpdate, delay); + _updateScheduled = true; + } + return; + } + + if (_updateScheduled) { + _scheduler.RemoveEvents(DeliverPendingUpdate); + _updateScheduled = false; + } + _lastUpdateTimeMs = now; + LastTrigger = _pendingTrigger; + _pendingTrigger = 0; TriggerInterruptRequest(); } + private void DeliverPendingUpdate(uint _) { + _updateScheduled = false; + UpdateMouse(); + } + private void TriggerInterruptRequest() { _dualPic.ProcessInterruptRequest(IrqNumber); } diff --git a/src/Spice86.Core/Emulator/Devices/Video/IVideoState.cs b/src/Spice86.Core/Emulator/Devices/Video/IVideoState.cs index b160fa72f5..cdfc37c0a0 100644 --- a/src/Spice86.Core/Emulator/Devices/Video/IVideoState.cs +++ b/src/Spice86.Core/Emulator/Devices/Video/IVideoState.cs @@ -6,6 +6,9 @@ namespace Spice86.Core.Emulator.Devices.Video; /// Represents the state of the video card. /// public interface IVideoState { + /// Shared programmed video timing and beam phase. + public VgaBeamTiming BeamTiming { get; } + /// /// Contains the DAC registers. /// diff --git a/src/Spice86.Core/Emulator/Devices/Video/Renderer.cs b/src/Spice86.Core/Emulator/Devices/Video/Renderer.cs index 6beedfb02a..bb6d201b40 100644 --- a/src/Spice86.Core/Emulator/Devices/Video/Renderer.cs +++ b/src/Spice86.Core/Emulator/Devices/Video/Renderer.cs @@ -316,7 +316,7 @@ public void RenderScanline() { } /// - /// Called by at vertical retrace on the emulation thread. + /// Called by at the end of visible display on the emulation thread. /// Publishes the completed back buffer by swapping it with the front buffer. /// public void CompleteFrame() { diff --git a/src/Spice86.Core/Emulator/Devices/Video/VgaBeamTiming.cs b/src/Spice86.Core/Emulator/Devices/Video/VgaBeamTiming.cs new file mode 100644 index 0000000000..6296369246 --- /dev/null +++ b/src/Spice86.Core/Emulator/Devices/Video/VgaBeamTiming.cs @@ -0,0 +1,90 @@ +namespace Spice86.Core.Emulator.Devices.Video; + +using System.Threading; + +/// +/// Shared programmed beam timing and frame epoch for rendering and status reads. +/// +public sealed class VgaBeamTiming { + private readonly IVideoState _state; + private readonly object _lock = new(); + private Snapshot _snapshot; + + /// Creates a beam model from the initial register state. + public VgaBeamTiming(IVideoState state) { + _state = state; + VgaTimingRegisters registers = VgaTimingRegisters.Read(state); + _snapshot = new(registers, VgaTimingParameters.Calculate(registers), 0); + } + + /// Refreshes changed timing registers and starts their new frame epoch. + public VgaTimingParameters Refresh(double elapsedTimeMs) { + VgaTimingRegisters registers = VgaTimingRegisters.Read(_state); + Snapshot snapshot = Volatile.Read(ref _snapshot); + if (registers == snapshot.Registers) { + return snapshot.Parameters; + } + lock (_lock) { + snapshot = _snapshot; + registers = VgaTimingRegisters.Read(_state); + if (registers != snapshot.Registers) { + VgaTimingParameters parameters = VgaTimingParameters.Calculate(registers); + double frameStartMs = elapsedTimeMs; + if (parameters == snapshot.Parameters) { + parameters = snapshot.Parameters; + frameStartMs = snapshot.FrameStartMs; + } + snapshot = new(registers, parameters, frameStartMs); + Volatile.Write(ref _snapshot, snapshot); + } + return snapshot.Parameters; + } + } + + /// Sets the frame epoch at a scheduled frame boundary. + public VgaTimingParameters StartFrame(double frameStartMs) { + Refresh(frameStartMs); + lock (_lock) { + Snapshot snapshot = _snapshot; + Volatile.Write(ref _snapshot, snapshot with { FrameStartMs = frameStartMs }); + return snapshot.Parameters; + } + } + + /// Reads input status without depending on renderer progress or modifying registers. + public byte ReadStatus(double elapsedTimeMs) { + Snapshot snapshot = Volatile.Read(ref _snapshot); + VgaTimingParameters timing = snapshot.Parameters; + double framePosition = PositiveRemainder(elapsedTimeMs - snapshot.FrameStartMs, timing.FrameDurationMs); + double linePosition = framePosition % timing.LineDurationMs; + const byte DisplayDisabledBit = 1; + const byte VerticalRetraceBit = 8; + byte status = (byte)(_state.GeneralRegisters.InputStatusRegister1.Value & ~(DisplayDisabledBit | VerticalRetraceBit)); + if (IsInInterval(framePosition, timing.VerticalRetraceStartMs, timing.VerticalRetraceEndMs, timing.FrameDurationMs)) { + status |= VerticalRetraceBit; + } + if (framePosition >= timing.DisplayDurationMs + || IsInInterval(framePosition, timing.VerticalBlankingStartMs, timing.VerticalBlankingEndMs, timing.FrameDurationMs) + || IsInInterval(linePosition, timing.HorizontalBlankingStartMs, timing.HorizontalBlankingEndMs, timing.LineDurationMs)) { + status |= DisplayDisabledBit; + } + return status; + } + + private static double PositiveRemainder(double value, double period) { + double remainder = value % period; + if (remainder < 0) { + remainder += period; + } + return remainder; + } + + private static bool IsInInterval(double position, double start, double end, double period) { + if (start >= period || end <= start) { + return false; + } + return (position >= start && position < end) || (end > period && position < end - period); + } + + private sealed record Snapshot(VgaTimingRegisters Registers, VgaTimingParameters Parameters, double FrameStartMs); +} diff --git a/src/Spice86.Core/Emulator/Devices/Video/VgaIoPortHandler.cs b/src/Spice86.Core/Emulator/Devices/Video/VgaIoPortHandler.cs index b011f07acc..2b62ae4aad 100644 --- a/src/Spice86.Core/Emulator/Devices/Video/VgaIoPortHandler.cs +++ b/src/Spice86.Core/Emulator/Devices/Video/VgaIoPortHandler.cs @@ -6,6 +6,7 @@ namespace Spice86.Core.Emulator.Devices.Video; using Spice86.Core.Emulator.Devices.Video.Registers; using Spice86.Core.Emulator.Devices.Video.Registers.Enums; using Spice86.Core.Emulator.IOPorts; +using Spice86.Core.Emulator.VM.Clock; using Spice86.Shared.Interfaces; /// @@ -19,6 +20,7 @@ public class VgaIoPortHandler : DefaultIOPortHandler { private readonly GraphicsControllerRegisters _graphicsRegisters; private readonly SequencerRegisters _sequencerRegisters; private readonly IVideoState _videoState; + private readonly IEmulatedClock _clock; private bool _attributeDataMode; /// @@ -28,9 +30,10 @@ public class VgaIoPortHandler : DefaultIOPortHandler { /// The class that is responsible for dispatching ports reads and writes to classes that respond to them. /// The logger service implementation. /// Represents the state of the video card. + /// The emulated clock driving the beam independently of rendering. /// Whether we throw an exception when an I/O port wasn't handled. public VgaIoPortHandler(State state, IOPortDispatcher ioPortDispatcher, ILogger loggerService, - IVideoState videoState, bool failOnUnhandledPort) : base(state, failOnUnhandledPort, loggerService) { + IVideoState videoState, IEmulatedClock clock, bool failOnUnhandledPort) : base(state, failOnUnhandledPort, loggerService) { // Initialize registers. _attributeRegisters = videoState.AttributeControllerRegisters; @@ -40,6 +43,8 @@ public VgaIoPortHandler(State state, IOPortDispatcher ioPortDispatcher, ILogger _generalRegisters = videoState.GeneralRegisters; _dacRegisters = videoState.DacRegisters; _videoState = videoState; + _clock = clock; + _videoState.BeamTiming.Refresh(0); InitPortHandlers(ioPortDispatcher); } @@ -160,7 +165,7 @@ public override byte ReadByte(ushort port) { break; case Ports.InputStatus1Read or Ports.InputStatus1ReadAlt: _attributeDataMode = false; // Reset the attribute data mode for port 0x03C0 to "Index" - value = _generalRegisters.InputStatusRegister1.Value; + value = ReadBeamStatus(); if (_loggerService.IsEnabled(LogLevel.Trace)) { _loggerService.LogTrace("[{Port:X4}] Read byte from port InputStatus1Read: {Value:X2} {Binary}", port, value, Convert.ToString(value, 2).PadLeft(8, '0')); } @@ -191,6 +196,10 @@ public override byte ReadByte(ushort port) { return value; } + private byte ReadBeamStatus() { + return _videoState.BeamTiming.ReadStatus(_clock.ElapsedTimeMs); + } + /// public override ushort ReadWord(ushort port) { byte value = ReadByte(port); @@ -283,6 +292,9 @@ public override void WriteByte(ushort port, byte value) { _loggerService.LogDebug("[{Port:X4}] Write to Sequencer register {Register}: {Value:X2} {Explained}", port, _sequencerRegisters.Address, value, _sequencerRegisters.Address.Explain(value)); } _sequencerRegisters.WriteRegister(_sequencerRegisters.Address, value); + if (_sequencerRegisters.Address == SequencerRegister.ClockingMode) { + _videoState.BeamTiming.Refresh(_clock.ElapsedTimeMs); + } break; case Ports.AttributeAddress: @@ -325,10 +337,11 @@ public override void WriteByte(ushort port, byte value) { _loggerService.LogTrace("Setting Video display to 0x{Value:X4}", (_graphicsRegisters.MiscellaneousGraphicsRegister.BaseAddress + _crtRegisters.ScreenStartAddress) >> 4); } _crtRegisters.WriteRegister(_crtRegisters.AddressRegister, value); - + _videoState.BeamTiming.Refresh(_clock.ElapsedTimeMs); break; case Ports.MiscOutputWrite: _generalRegisters.MiscellaneousOutput.Value = value; + _videoState.BeamTiming.Refresh(_clock.ElapsedTimeMs); if (_loggerService.IsEnabled(LogLevel.Debug)) { _loggerService.LogDebug("[{Port:X4}] Write to MiscOutputWrite: {Value:X2} {@Explained}", port, value, _generalRegisters.MiscellaneousOutput); } diff --git a/src/Spice86.Core/Emulator/Devices/Video/VgaTimingEngine.cs b/src/Spice86.Core/Emulator/Devices/Video/VgaTimingEngine.cs index 4254082d32..30596c3309 100644 --- a/src/Spice86.Core/Emulator/Devices/Video/VgaTimingEngine.cs +++ b/src/Spice86.Core/Emulator/Devices/Video/VgaTimingEngine.cs @@ -11,7 +11,6 @@ namespace Spice86.Core.Emulator.Devices.Video; /// public class VgaTimingEngine { private const double BlinkPeriodMs = 500.0; - private const double HorizontalLineDurationMs = 1000.0 / 31469.0; private readonly IVideoState _state; private readonly DeviceScheduler _scheduler; @@ -19,7 +18,7 @@ public class VgaTimingEngine { private readonly Renderer _renderer; private readonly VgaBlinkState _blinkState; - private int _totalScanlines; + private VgaTimingParameters _timing; private int _visibleScanlines; private double _lastFrameStartMs; private double _activeDisplayMs; @@ -45,69 +44,92 @@ public VgaTimingEngine(IVideoState state, DeviceScheduler scheduler, _clock = clock; _renderer = renderer; _blinkState = blinkState; + _timing = state.BeamTiming.Refresh(0); _scheduler.AddEvent(OnFrameStart, 0); _scheduler.AddEvent(OnBlinkToggle, BlinkPeriodMs); } - private void RecomputeTimingParameters() { - int totalHeight = _state.CrtControllerRegisters.VerticalTotalValue + 2; - int visibleScanlines = _state.CrtControllerRegisters.VerticalDisplayEndValue + 1; - bool verticalTimingHalved = _state.CrtControllerRegisters.CrtModeControlRegister.VerticalTimingHalved; - - _totalScanlines = totalHeight; - _visibleScanlines = visibleScanlines; - - if (verticalTimingHalved) { - _totalScanlines = totalHeight * 2; - _visibleScanlines = visibleScanlines * 2; - } - - int horizontalDisplayEnd = _state.CrtControllerRegisters.HorizontalDisplayEnd + 1; - int skew = _state.CrtControllerRegisters.HorizontalBlankingEndRegister.DisplayEnableSkew; - int totalWidth = _state.CrtControllerRegisters.HorizontalTotal + 3; - double characterClockDurationMs = HorizontalLineDurationMs / totalWidth; - _activeDisplayMs = (horizontalDisplayEnd + skew) * characterClockDurationMs; - _hblankDurationMs = HorizontalLineDurationMs - _activeDisplayMs; - } - private void OnFrameStart(uint value) { - RecomputeTimingParameters(); - _lastFrameStartMs = _clock.ElapsedTimeMs; - - _state.GeneralRegisters.InputStatusRegister1.VerticalRetrace = false; - _state.GeneralRegisters.InputStatusRegister1.DisplayDisabled = false; - + if (RestartIfTimingChanged()) { + return; + } + _lastFrameStartMs = _scheduler.EventTimeMs; + _timing = _state.BeamTiming.StartFrame(_lastFrameStartMs); + _visibleScanlines = Math.Min(_timing.VisibleScanlines, _timing.TotalScanlines); + _activeDisplayMs = _timing.HorizontalDisplayEndMs; + _hblankDurationMs = _timing.LineDurationMs - _activeDisplayMs; + UpdateBeamStatus(); _renderer.BeginFrame(); - - _scheduler.AddEvent(OnScanlineActive, 0, 0); + _scheduler.AddEvent(OnDisplayEnd, _visibleScanlines * _timing.LineDurationMs); + _scheduler.AddEvent(OnFrameStart, _timing.FrameDurationMs); + if (_timing.VerticalRetraceStartMs < _timing.FrameDurationMs) { + if (_timing.VerticalRetraceStartMs > 0) { + _scheduler.AddEvent(OnBeamStatusChange, _timing.VerticalRetraceStartMs); + } + double retraceEndMs = _timing.VerticalRetraceEndMs % _timing.FrameDurationMs; + if (retraceEndMs > 0) { + _scheduler.AddEvent(OnBeamStatusChange, retraceEndMs); + } + } + OnScanlineActive(0); } private void OnScanlineActive(uint scanline) { - _state.GeneralRegisters.InputStatusRegister1.DisplayDisabled = false; + if (RestartIfTimingChanged()) { + return; + } + UpdateBeamStatus(); _renderer.RenderScanline(); _scheduler.AddEvent(OnScanlineHBlank, _activeDisplayMs, scanline); } private void OnScanlineHBlank(uint scanline) { - _state.GeneralRegisters.InputStatusRegister1.DisplayDisabled = true; - + if (RestartIfTimingChanged()) { + return; + } + UpdateBeamStatus(); if (scanline + 1 < (uint)_visibleScanlines) { - _scheduler.AddEvent(OnScanlineActive, _hblankDurationMs, scanline + 1); - } else { - _scheduler.AddEvent(OnVerticalRetraceStart, _hblankDurationMs); + if (_hblankDurationMs <= 0) { + OnScanlineActive(scanline + 1); + } else { + _scheduler.AddEvent(OnScanlineActive, _hblankDurationMs, scanline + 1); + } } } - private void OnVerticalRetraceStart(uint value) { - _state.GeneralRegisters.InputStatusRegister1.VerticalRetrace = true; - _state.GeneralRegisters.InputStatusRegister1.DisplayDisabled = true; - LastFrameDuration = TimeSpan.FromMilliseconds(_clock.ElapsedTimeMs - _lastFrameStartMs); - + private void OnDisplayEnd(uint value) { + if (RestartIfTimingChanged()) { + return; + } + UpdateBeamStatus(); + LastFrameDuration = TimeSpan.FromMilliseconds(_scheduler.EventTimeMs - _lastFrameStartMs); _renderer.CompleteFrame(); + } + + private void OnBeamStatusChange(uint value) { + if (!RestartIfTimingChanged()) { + UpdateBeamStatus(); + } + } + + private void UpdateBeamStatus() { + _state.GeneralRegisters.InputStatusRegister1.Value = _state.BeamTiming.ReadStatus(_scheduler.EventTimeMs); + } - int blankingLines = _totalScanlines - _visibleScanlines; - double vBlankDurationMs = blankingLines * HorizontalLineDurationMs; - _scheduler.AddEvent(OnFrameStart, vBlankDurationMs); + private bool RestartIfTimingChanged() { + VgaTimingParameters timing = _state.BeamTiming.Refresh(_clock.ElapsedTimeMs); + if (ReferenceEquals(timing, _timing)) { + return false; + } + _scheduler.RemoveEvents(OnFrameStart); + _scheduler.RemoveEvents(OnScanlineActive); + _scheduler.RemoveEvents(OnScanlineHBlank); + _scheduler.RemoveEvents(OnDisplayEnd); + _scheduler.RemoveEvents(OnBeamStatusChange); + _timing = timing; + _state.IsRenderingDirty = true; + _scheduler.AddEvent(OnFrameStart, 0); + return true; } private void OnBlinkToggle(uint value) { diff --git a/src/Spice86.Core/Emulator/Devices/Video/VgaTimingParameters.cs b/src/Spice86.Core/Emulator/Devices/Video/VgaTimingParameters.cs new file mode 100644 index 0000000000..b1a901448e --- /dev/null +++ b/src/Spice86.Core/Emulator/Devices/Video/VgaTimingParameters.cs @@ -0,0 +1,100 @@ +namespace Spice86.Core.Emulator.Devices.Video; + +using Spice86.Core.Emulator.Devices.Video.Registers.General; + +/// +/// Programmed VGA beam intervals, derived from the dot clock and CRTC counters. +/// +public sealed record VgaTimingParameters { + /// Selected oscillator frequency in hertz. + public required int PixelClockHz { get; init; } + /// Character clocks per physical horizontal period. + public required int HorizontalTotal { get; init; } + /// Duration of one physical scanline in milliseconds. + public required double LineDurationMs { get; init; } + /// Horizontal display end in milliseconds from line start. + public required double HorizontalDisplayEndMs { get; init; } + /// Horizontal blanking start in milliseconds from line start. + public required double HorizontalBlankingStartMs { get; init; } + /// Horizontal blanking end, including counter wrap, in milliseconds. + public required double HorizontalBlankingEndMs { get; init; } + /// Horizontal retrace start in milliseconds from line start. + public required double HorizontalRetraceStartMs { get; init; } + /// Horizontal retrace end, including counter wrap, in milliseconds. + public required double HorizontalRetraceEndMs { get; init; } + /// Physical scanlines per frame. + public required int TotalScanlines { get; init; } + /// Physical scanlines in the display interval. + public required int VisibleScanlines { get; init; } + /// Vertical blanking start in milliseconds from frame start. + public required double VerticalBlankingStartMs { get; init; } + /// Vertical blanking end, including counter wrap, in milliseconds. + public required double VerticalBlankingEndMs { get; init; } + /// Vertical retrace start in milliseconds from frame start. + public required double VerticalRetraceStartMs { get; init; } + /// Vertical retrace end, including counter wrap, in milliseconds. + public required double VerticalRetraceEndMs { get; init; } + /// Programmed frame period in milliseconds. + public double FrameDurationMs => TotalScanlines * LineDurationMs; + /// End of the physical display interval in milliseconds. + public double DisplayDurationMs => VisibleScanlines * LineDurationMs; + /// Programmed refresh frequency in hertz. + public double RefreshRateHz => 1000.0 / FrameDurationMs; + + /// Calculates the timing model without modifying registers. + public static VgaTimingParameters Calculate(IVideoState state) { + return Calculate(VgaTimingRegisters.Read(state)); + } + + internal static VgaTimingParameters Calculate(VgaTimingRegisters registers) { + const int PixelClock640Hz = 25175000; + const int PixelClock720Hz = 28322000; + int pixelClock = PixelClock720Hz; + if (registers.ClockSelect == MiscellaneousOutput.ClockSelectValue.Use25175Khz) { + pixelClock = PixelClock640Hz; + } + int characterClock = pixelClock / registers.DotsPerClock; + double characterDurationMs = 1000.0 / characterClock; + int horizontalTotal = registers.HorizontalTotal + 5; + if (registers.HalfDotClock) { + horizontalTotal *= 2; + } + int verticalScale = registers.VerticalTimingHalved ? 2 : 1; + double lineDurationMs = horizontalTotal * characterDurationMs; + double verticalCounterDurationMs = verticalScale * lineDurationMs; + int blankingStart = registers.VerticalBlankingStart; + int blankingEnd = registers.VerticalBlankingEnd & 0x7F; + if (blankingStart != 0) { + blankingStart++; + blankingEnd = WrappedEnd(blankingStart, blankingEnd, 0x7F); + } + blankingEnd++; + + return new VgaTimingParameters { + PixelClockHz = pixelClock, + HorizontalTotal = horizontalTotal, + LineDurationMs = lineDurationMs, + HorizontalDisplayEndMs = Math.Min(registers.HorizontalDisplayEnd + 1, horizontalTotal) * characterDurationMs, + HorizontalBlankingStartMs = registers.HorizontalBlankingStart * characterDurationMs, + HorizontalBlankingEndMs = (registers.HorizontalBlankingStart + + ((registers.HorizontalBlankingEnd - registers.HorizontalBlankingStart) & 0x3F)) * characterDurationMs, + HorizontalRetraceStartMs = registers.HorizontalSyncStart * characterDurationMs, + HorizontalRetraceEndMs = WrappedEnd(registers.HorizontalSyncStart, registers.HorizontalSyncEnd, 0x1F) * characterDurationMs, + TotalScanlines = (registers.VerticalTotal + 2) * verticalScale, + VisibleScanlines = (registers.VerticalDisplayEnd + 1) * verticalScale, + VerticalBlankingStartMs = blankingStart * verticalCounterDurationMs, + VerticalBlankingEndMs = blankingEnd * verticalCounterDurationMs, + VerticalRetraceStartMs = registers.VerticalSyncStart * verticalCounterDurationMs, + VerticalRetraceEndMs = WrappedEnd(registers.VerticalSyncStart, registers.VerticalSyncEnd, 0x0F) + * verticalCounterDurationMs + }; + } + + private static int WrappedEnd(int start, int lowEnd, int mask) { + int length = (lowEnd - start) & mask; + if (length == 0) { + length = mask + 1; + } + return start + length; + } +} diff --git a/src/Spice86.Core/Emulator/Devices/Video/VgaTimingRegisters.cs b/src/Spice86.Core/Emulator/Devices/Video/VgaTimingRegisters.cs new file mode 100644 index 0000000000..30c89d3bbb --- /dev/null +++ b/src/Spice86.Core/Emulator/Devices/Video/VgaTimingRegisters.cs @@ -0,0 +1,20 @@ +namespace Spice86.Core.Emulator.Devices.Video; + +using Spice86.Core.Emulator.Devices.Video.Registers; +using Spice86.Core.Emulator.Devices.Video.Registers.General; + +internal readonly record struct VgaTimingRegisters( + MiscellaneousOutput.ClockSelectValue ClockSelect, int DotsPerClock, bool HalfDotClock, + byte HorizontalTotal, byte HorizontalDisplayEnd, byte HorizontalBlankingStart, byte HorizontalBlankingEnd, + byte HorizontalSyncStart, byte HorizontalSyncEnd, int VerticalTotal, int VerticalDisplayEnd, + int VerticalBlankingStart, byte VerticalBlankingEnd, int VerticalSyncStart, byte VerticalSyncEnd, bool VerticalTimingHalved) { + internal static VgaTimingRegisters Read(IVideoState state) { + CrtControllerRegisters crt = state.CrtControllerRegisters; + return new(state.GeneralRegisters.MiscellaneousOutput.ClockSelect, + state.SequencerRegisters.ClockingModeRegister.DotsPerClock, state.SequencerRegisters.ClockingModeRegister.HalfDotClock, + crt.HorizontalTotal, crt.HorizontalDisplayEnd, crt.HorizontalBlankingStart, crt.HorizontalBlankingEndValue, + crt.HorizontalSyncStart, crt.HorizontalSyncEndRegister.HorizontalSyncEnd, crt.VerticalTotalValue, + crt.VerticalDisplayEndValue, crt.VerticalBlankingStartValue, crt.VerticalBlankingEnd, + crt.VerticalSyncStartValue, crt.VerticalSyncEndRegister.VerticalSyncEnd, crt.CrtModeControlRegister.VerticalTimingHalved); + } +} diff --git a/src/Spice86.Core/Emulator/Devices/Video/VideoState.cs b/src/Spice86.Core/Emulator/Devices/Video/VideoState.cs index 07513ff4e7..5a6297c19a 100644 --- a/src/Spice86.Core/Emulator/Devices/Video/VideoState.cs +++ b/src/Spice86.Core/Emulator/Devices/Video/VideoState.cs @@ -14,6 +14,7 @@ public VideoState() { CrtControllerRegisters = new CrtControllerRegisters(); GraphicsControllerRegisters = new GraphicsControllerRegisters(); AttributeControllerRegisters = new AttributeControllerRegisters(); + BeamTiming = new(this); } /// @@ -36,4 +37,7 @@ public VideoState() { /// public bool IsRenderingDirty { get; set; } + + /// + public VgaBeamTiming BeamTiming { get; } } \ No newline at end of file diff --git a/src/Spice86.Core/Emulator/VM/Clock/EmulatedClock.cs b/src/Spice86.Core/Emulator/VM/Clock/EmulatedClock.cs index d78c08b932..5241912d77 100644 --- a/src/Spice86.Core/Emulator/VM/Clock/EmulatedClock.cs +++ b/src/Spice86.Core/Emulator/VM/Clock/EmulatedClock.cs @@ -6,7 +6,6 @@ /// A real-time clock based on the system's Stopwatch, independent of CPU cycles for its progression. /// public class EmulatedClock : ClockBase { - private int _ticks; private readonly Stopwatch _stopwatch = new(); public EmulatedClock(int? jitterSeed, DateTimeOffset startTime) @@ -15,16 +14,7 @@ public EmulatedClock(int? jitterSeed, DateTimeOffset startTime) } /// - public override double ElapsedTimeMs { - get { - // Stopwatch.GetTimestamp can be slow, so we only query it periodically. - if (_ticks++ % 100 != 0) { - return field; - } - field = _stopwatch.Elapsed.TotalMilliseconds + _jitter.Advance(); - return field; - } - } + public override double ElapsedTimeMs => _stopwatch.Elapsed.TotalMilliseconds + _jitter.Advance(); /// protected override void OnPauseCore() => _stopwatch.Stop(); diff --git a/src/Spice86.Core/Emulator/VM/CpuSpeedLimit/CpuCycleLimiter.cs b/src/Spice86.Core/Emulator/VM/CpuSpeedLimit/CpuCycleLimiter.cs index 143fb61229..11374b9a35 100644 --- a/src/Spice86.Core/Emulator/VM/CpuSpeedLimit/CpuCycleLimiter.cs +++ b/src/Spice86.Core/Emulator/VM/CpuSpeedLimit/CpuCycleLimiter.cs @@ -6,7 +6,7 @@ /// /// Throttles CPU execution to maintain a target number of cycles per millisecond, -/// using a budget-based approach similar to DOSBox. +/// using short budget-based slices to keep hardware polling responsive. /// public class CpuCycleLimiter : ICyclesLimiter { private readonly State _state; @@ -16,6 +16,7 @@ public class CpuCycleLimiter : ICyclesLimiter { private long _targetCyclesForPause; private static readonly long TicksPerMs = Stopwatch.Frequency / 1000; + private const int TimeSlicesPerMs = 40; private const int CyclesUp = 1000; private const int CyclesDown = 1000; @@ -56,7 +57,10 @@ public void RegulateCycles() { } // We've reached our target, time to regulate speed - long targetTicks = _lastTicks + TicksPerMs; + // Keep throttle gaps shorter than VGA retrace pulses so polling loops can observe them. + int targetCyclesPerMs = TargetCpuCyclesPerMs; + int cycleBudget = Math.Max(1, targetCyclesPerMs / TimeSlicesPerMs); + long targetTicks = _lastTicks + TicksPerMs * cycleBudget / targetCyclesPerMs; while (_state.IsRunning && _stopwatch.ElapsedTicks < targetTicks) { _spinner.SpinOnce(-1); @@ -64,13 +68,16 @@ public void RegulateCycles() { _lastTicks = _stopwatch.ElapsedTicks; - _targetCyclesForPause = _state.Cycles + TargetCpuCyclesPerMs; + _targetCyclesForPause = _state.Cycles + cycleBudget; _spinner.Reset(); } /// - public int TargetCpuCyclesPerMs { get; set; } + public int TargetCpuCyclesPerMs { + get; + set => field = Math.Clamp(value, MinCyclesPerMs, MaxCyclesPerMs); + } /// public void IncreaseCycles() { diff --git a/src/Spice86.Core/Emulator/VM/DeviceScheduler/DeviceScheduler.cs b/src/Spice86.Core/Emulator/VM/DeviceScheduler/DeviceScheduler.cs index 03b70c1697..dd109e7e8e 100644 --- a/src/Spice86.Core/Emulator/VM/DeviceScheduler/DeviceScheduler.cs +++ b/src/Spice86.Core/Emulator/VM/DeviceScheduler/DeviceScheduler.cs @@ -18,6 +18,7 @@ namespace Spice86.Core.Emulator.VM.DeviceScheduler; /// public class DeviceScheduler { public const int MaxQueueSize = 8192; + internal const int MaxEventsPerPump = 1024; private readonly IEmulatedClock _clock; private readonly ILogger _logger; @@ -31,6 +32,9 @@ public class DeviceScheduler { private double _activeEventScheduledTime; private double _loopEntryTime; + /// Deadline of the active callback, or current clock time outside event service. + internal double EventTimeMs => _isServicingEvents ? _activeEventScheduledTime : _clock.ElapsedTimeMs; + /// /// Gets the scheduled time of the next queued event, or if the queue is empty. /// @@ -63,19 +67,19 @@ public void AddEvent(EventHandler handler, double delay, uint val = 0) { return; } - // Compute absolute scheduled time taking into account we're currently servicing events. - // Use the active event's scheduled time + delay when that yields a time in the future - // relative to the loop entry. Otherwise schedule just after the loop entry to avoid - // putting events in the past (prevents infinite re-queuing on slow machines). + // Positive delays preserve device cadence even when the host processes events late. + // Overdue non-positive delays yield instead of re-queuing indefinitely in the past. const double MinFutureOffsetMs = 0.0001; double absoluteScheduledTime; if (!_isServicingEvents) { absoluteScheduledTime = _clock.ElapsedTimeMs + delay; } else { double candidate = _activeEventScheduledTime + delay; - absoluteScheduledTime = candidate >= _loopEntryTime - ? candidate - : _loopEntryTime + MinFutureOffsetMs; + if (delay > 0 || candidate >= _loopEntryTime) { + absoluteScheduledTime = candidate; + } else { + absoluteScheduledTime = _loopEntryTime + MinFutureOffsetMs; + } } ScheduledEntry entry = GetEntry(handler, val); @@ -113,7 +117,7 @@ public void RemoveEvents(EventHandler handler) { } /// - /// Executes all events that are due as of the current time provided by the clock. + /// Executes a bounded batch of due events, preserving remaining deadlines for the next pump. /// public void ProcessEvents() { if (_queue.Count == 0) { @@ -124,20 +128,24 @@ public void ProcessEvents() { double currentTime = _clock.ElapsedTimeMs; _loopEntryTime = currentTime; - while (_queue.Count > 0 && _queue.First.Priority <= currentTime) { - ScheduledEntry entry = _queue.Dequeue(); - _monitor?.OnEventExecuted(entry.Priority, currentTime, _queue.Count); - - // Important for events that re-schedules another event, to ensure it will fire at the correct time not taking into account lag - _activeEventScheduledTime = entry.Priority; - try { - entry.Handler.Invoke(entry.Value); - } finally { - ReturnEntry(entry); + try { + int executions = 0; + while (_queue.Count > 0 && _queue.First.Priority <= currentTime && executions < MaxEventsPerPump) { + ScheduledEntry entry = _queue.Dequeue(); + _monitor?.OnEventExecuted(entry.Priority, currentTime, _queue.Count); + + // Important for events that re-schedules another event, to ensure it will fire at the correct time not taking into account lag + _activeEventScheduledTime = entry.Priority; + try { + entry.Handler.Invoke(entry.Value); + } finally { + ReturnEntry(entry); + } + executions++; } + } finally { + _isServicingEvents = false; } - - _isServicingEvents = false; } private ScheduledEntry GetEntry(EventHandler handler, uint value) { diff --git a/src/Spice86.Core/Emulator/VM/EmulationLoop.cs b/src/Spice86.Core/Emulator/VM/EmulationLoop.cs index b61c44a7d9..845830c758 100644 --- a/src/Spice86.Core/Emulator/VM/EmulationLoop.cs +++ b/src/Spice86.Core/Emulator/VM/EmulationLoop.cs @@ -26,7 +26,6 @@ public class EmulationLoop { private readonly ICyclesLimiter _cyclesLimiter; private readonly InputEventHub _inputEventQueue; private readonly DeviceScheduler.DeviceScheduler _emulationLoopScheduler; - private readonly DeviceScheduler.EventHandler _inputEventsHandler; /// /// Gets or sets whether the emulation is paused. @@ -67,13 +66,6 @@ public EmulationLoop(FunctionHandler functionHandler, CfgCpu cpu, State cpuState _pauseHandler = pauseHandler; _inputEventQueue = inputEventQueue; _cyclesLimiter = cyclesLimiter; - _inputEventsHandler = new DeviceScheduler.EventHandler(InputEventsHandler); - _emulationLoopScheduler.AddEvent(_inputEventsHandler, 100); - } - - private void InputEventsHandler(uint _) { - _inputEventQueue.ProcessAllPendingInputEvents(); - _emulationLoopScheduler.AddEvent(_inputEventsHandler, 100); } /// @@ -130,6 +122,7 @@ private void RunLoop() { _cpu.SignalEntry(); while (_cpuState.IsRunning) { _pauseHandler.WaitIfPaused(); + _inputEventQueue.ProcessAllPendingInputEvents(); _emulationLoopScheduler.ProcessEvents(); _cpu.ExecuteNext(); _cyclesLimiter.RegulateCycles(); diff --git a/src/Spice86.Core/Emulator/VM/InputEventQueue.cs b/src/Spice86.Core/Emulator/VM/InputEventQueue.cs index 567361cc17..f574bcf26f 100644 --- a/src/Spice86.Core/Emulator/VM/InputEventQueue.cs +++ b/src/Spice86.Core/Emulator/VM/InputEventQueue.cs @@ -7,6 +7,7 @@ using Spice86.Shared.Interfaces; using System; +using System.Collections.Concurrent; /// /// Represents a queue for handling and processing keyboard and mouse events.
@@ -20,9 +21,8 @@ /// The also exposes properties and methods for interacting with mouse coordinates and /// cursor visibility, delegating these operations to the underlying implementation, if available. public class InputEventHub : IGuiKeyboardEvents, IGuiMouseEvents { - private readonly Queue _eventQueue = new(); - // a thread-safe queue, accessed by both UI thread and emulation thread, requires a lock. - private readonly object _lock = new(); + // A thread-safe queue shared by UI producers and the emulation thread. + private readonly ConcurrentQueue _eventQueue = new(); private readonly IGuiMouseEvents? _mouseEvents; private readonly IGuiKeyboardEvents? _keyboardEvents; @@ -48,14 +48,12 @@ public InputEventHub(IGuiKeyboardEvents? keyboardEvents = null, } private void Enqueue(Action action) { - lock (_lock) { - _eventQueue.Enqueue(action); - } + _eventQueue.Enqueue(action); } /// /// Thread-safe hook for non-UI producers (e.g. the MCP server) that need to - /// mutate emulator input state. The action is enqueued under the same lock + /// mutate emulator input state. The action joins the same queue /// as UI events and will run on the emulator thread the next time /// is pumped. /// @@ -108,13 +106,8 @@ private void OnKeyDown(object? sender, KeyboardEventArgs e) => Enqueue(() => KeyDown?.Invoke(sender, e)); internal void ProcessAllPendingInputEvents() { - if (_eventQueue.Count == 0) { - return; - } - lock (_lock) { - while (_eventQueue.TryDequeue(out Action? action)) { - action.Invoke(); - } + while (_eventQueue.TryDequeue(out Action? action)) { + action.Invoke(); } } diff --git a/src/Spice86/Spice86DependencyInjection.cs b/src/Spice86/Spice86DependencyInjection.cs index f2a73fbd55..64784bbc8a 100644 --- a/src/Spice86/Spice86DependencyInjection.cs +++ b/src/Spice86/Spice86DependencyInjection.cs @@ -348,7 +348,7 @@ internal Spice86DependencyInjection(Configuration configuration, MainWindow? mai VideoState videoState = new(); VgaIoPortHandler vgaIoPortHandler = new(state, ioPortDispatcher, - loggerService, videoState, configuration.FailOnUnhandledPort); + loggerService, videoState, _emulatedClock, configuration.FailOnUnhandledPort); VgaBlinkState vgaBlinkState = new(); IVgaRenderer256Color renderer256Color = VgaRenderer256ColorSimd.CreateBestRenderer(); Renderer vgaRenderer = new(memory, videoState, vgaBlinkState, loggerService, renderer256Color); @@ -566,7 +566,7 @@ internal Spice86DependencyInjection(Configuration configuration, MainWindow? mai stack, state, cfgCpu, dualPic, systemBiosInt15Handler, intel8042Controller, biosKeyboardBuffer, loggerService); Mouse mouse = new(state, sharedMouseData, dualPic, - configuration.Mouse, loggerService, configuration.FailOnUnhandledPort, + configuration.Mouse, loggerService, configuration.FailOnUnhandledPort, _emulatedClock, emulationLoopScheduler, inputEventHub); MouseDriver mouseDriver = new(state, sharedMouseData, memory, mouse, vgaFunctionality, loggerService, diff --git a/tests/Spice86.Tests/ClockTests.cs b/tests/Spice86.Tests/ClockTests.cs index 808a522256..599a528033 100644 --- a/tests/Spice86.Tests/ClockTests.cs +++ b/tests/Spice86.Tests/ClockTests.cs @@ -1,6 +1,8 @@ namespace Spice86.Tests; using FluentAssertions; +using System.Diagnostics; +using System.Threading; using Spice86.Core.CLI; using Spice86.Core.Emulator.CPU; using Spice86.Core.Emulator.Devices.Sound; @@ -11,6 +13,21 @@ namespace Spice86.Tests; /// Tests for clock synchronization architecture. ///
public class ClockTests { + [Fact] + public void EmulatedClock_ReadsCurrentTimeWithoutWaitingForMoreClockReads() { + // Arrange + using EmulatedClock clock = new(null, DateTimeOffset.UnixEpoch); + double before = clock.ElapsedTimeMs; + Stopwatch elapsed = Stopwatch.StartNew(); + + // Act + SpinWait.SpinUntil(() => elapsed.ElapsedMilliseconds >= 2); + double after = clock.ElapsedTimeMs; + + // Assert + after.Should().BeGreaterThanOrEqualTo(before + 1); + } + /// /// Tests that CyclesClock correctly calculates CurrentDateTime from StartTime and cycles. /// @@ -175,7 +192,6 @@ public void CyclesClock_WithDifferentSeeds_JitterIsBounded() { public void EmulatedClock_WithSeed_ElapsedTimeMsIsNonNegative() { EmulatedClock clock = new EmulatedClock(99, DateTimeOffset.UnixEpoch); - // Force multiple cache refreshes to exercise the jitter code path. for (int i = 0; i < 300; i++) { clock.ElapsedTimeMs.Should().BeGreaterThanOrEqualTo(0.0); } diff --git a/tests/Spice86.Tests/CpuCycleLimiterTests.cs b/tests/Spice86.Tests/CpuCycleLimiterTests.cs new file mode 100644 index 0000000000..df02226b1c --- /dev/null +++ b/tests/Spice86.Tests/CpuCycleLimiterTests.cs @@ -0,0 +1,47 @@ +namespace Spice86.Tests; + +using FluentAssertions; + +using Spice86.Core.Emulator.CPU; +using Spice86.Core.Emulator.VM.CpuSpeedLimit; + +using Xunit; + +public sealed class CpuCycleLimiterTests { + [Theory] + [InlineData(0, 3000)] + [InlineData(-1, 3000)] + [InlineData(1, 100)] + [InlineData(3000, 3000)] + [InlineData(70000, 60000)] + public void ConstructorPreservesDefaultRateAndSupportedBounds(int requestedRate, int expectedRate) { + // Arrange + State state = new(CpuModel.INTEL_80286); + + // Act + CpuCycleLimiter limiter = new(state, requestedRate); + + // Assert + limiter.TargetCpuCyclesPerMs.Should().Be(expectedRate); + } + + [Theory] + [InlineData(0, 100)] + [InlineData(-1, 100)] + [InlineData(1, 100)] + [InlineData(3000, 3000)] + [InlineData(70000, 60000)] + public void RateChangesRemainWithinSupportedBounds(int requestedRate, int expectedRate) { + // Arrange + State state = new(CpuModel.INTEL_80286); + CpuCycleLimiter limiter = new(state, 3000); + + // Act + limiter.TargetCpuCyclesPerMs = requestedRate; + + // Assert + limiter.TargetCpuCyclesPerMs.Should().Be(expectedRate); + Action regulate = limiter.RegulateCycles; + regulate.Should().NotThrow(); + } +} diff --git a/tests/Spice86.Tests/Emulator/Devices/ExternalInput/DeviceSchedulerTests.cs b/tests/Spice86.Tests/Emulator/Devices/ExternalInput/DeviceSchedulerTests.cs index 408c96f227..fa77a1dec4 100644 --- a/tests/Spice86.Tests/Emulator/Devices/ExternalInput/DeviceSchedulerTests.cs +++ b/tests/Spice86.Tests/Emulator/Devices/ExternalInput/DeviceSchedulerTests.cs @@ -77,6 +77,81 @@ public void ProcessEvents_ShouldHandleEmptyQueue() { _scheduler.ProcessEvents(); } + [Fact] + public void PeriodicEventsCatchUpWithoutShiftingTheirDeadline() { + // Arrange + int executions = 0; + void Reschedule(uint _) { + executions++; + _scheduler.AddEvent(Reschedule, 5); + } + _scheduler.AddEvent(Reschedule, 5); + AdvanceCycles(16); + + // Act + _scheduler.ProcessEvents(); + + // Assert + executions.Should().Be(3); + _scheduler.NextEventTime.Should().Be(20); + } + + [Fact] + public void OverdueZeroDelayRecurringEventYieldsUntilNextPump() { + // Arrange + int executions = 0; + void Reschedule(uint _) { + executions++; + _scheduler.AddEvent(Reschedule, 0); + } + _scheduler.AddEvent(Reschedule, 1); + AdvanceCycles(5); + + // Act + _scheduler.ProcessEvents(); + + // Assert + executions.Should().Be(1); + _scheduler.NextEventTime.Should().BeGreaterThan(5); + } + + [Fact] + public void LargePeriodicBacklogYieldsWithoutDroppingDeadlines() { + // Arrange + int executions = 0; + void Reschedule(uint _) { + executions++; + _scheduler.AddEvent(Reschedule, 1); + } + _scheduler.AddEvent(Reschedule, 1); + AdvanceCycles(DeviceScheduler.MaxEventsPerPump + 1); + + // Act + _scheduler.ProcessEvents(); + + // Assert + executions.Should().Be(DeviceScheduler.MaxEventsPerPump); + _scheduler.NextEventTime.Should().Be(DeviceScheduler.MaxEventsPerPump + 1); + _scheduler.ProcessEvents(); + executions.Should().Be(DeviceScheduler.MaxEventsPerPump + 1); + _scheduler.NextEventTime.Should().Be(DeviceScheduler.MaxEventsPerPump + 2); + } + + [Fact] + public void CallbackFailureDoesNotLeaveSchedulerInEventService() { + // Arrange + _scheduler.AddEvent(_ => throw new InvalidOperationException("Device failed"), 1); + AdvanceCycles(5); + + // Act + Action process = _scheduler.ProcessEvents; + process.Should().Throw(); + _scheduler.AddEvent(_ => { }, 1); + + // Assert + _scheduler.NextEventTime.Should().Be(6); + } + [Fact] public void RemoveEvents_ShouldCancelEvents() { bool invoked = false; @@ -177,7 +252,9 @@ public void AddEvent_WhenQueueFull_ShouldIgnoreEventButExecuteExisting() { // Advance time enough to process all potential events AdvanceCycles(100 + eventsCount); - _scheduler.ProcessEvents(); + for (int remaining = maxQueueSize; remaining > 0; remaining -= DeviceScheduler.MaxEventsPerPump) { + _scheduler.ProcessEvents(); + } // Verify that only the first maxQueueSize events were executed executedEvents.Count.Should().Be(maxQueueSize); diff --git a/tests/Spice86.Tests/Emulator/Devices/ExternalInput/Pit8254Tests.cs b/tests/Spice86.Tests/Emulator/Devices/ExternalInput/Pit8254Tests.cs index 5da7c85489..6d5bd9420d 100644 --- a/tests/Spice86.Tests/Emulator/Devices/ExternalInput/Pit8254Tests.cs +++ b/tests/Spice86.Tests/Emulator/Devices/ExternalInput/Pit8254Tests.cs @@ -31,9 +31,9 @@ public Pit8254Tests() { _speaker = Substitute.For(); State state = new(CpuModel.INTEL_80286); _ioPortDispatcher = new IOPortDispatcher(new AddressReadWriteBreakpoints(), state, logger, false); - var pic = new DualPic(_ioPortDispatcher, state, logger, false); - EmulatedClock emulatedClock = new EmulatedClock(null, DateTimeOffset.UnixEpoch); - var emulationLoopScheduler = new DeviceScheduler(emulatedClock, logger, "Emulation loop"); + DualPic pic = new(_ioPortDispatcher, state, logger, false); + CyclesClock emulatedClock = new(state, 1000, null, DateTimeOffset.UnixEpoch); + DeviceScheduler emulationLoopScheduler = new(emulatedClock, logger, "Emulation loop"); _pit = new PitTimer(_ioPortDispatcher, state, pic, _speaker, emulationLoopScheduler, emulatedClock, logger, false); } diff --git a/tests/Spice86.Tests/Emulator/Devices/ExternalInput/PitModeTests.cs b/tests/Spice86.Tests/Emulator/Devices/ExternalInput/PitModeTests.cs index 6b47b511d4..d74b9ed435 100644 --- a/tests/Spice86.Tests/Emulator/Devices/ExternalInput/PitModeTests.cs +++ b/tests/Spice86.Tests/Emulator/Devices/ExternalInput/PitModeTests.cs @@ -31,8 +31,8 @@ public PitModeTests() { _speaker = Substitute.For(); State state = new(CpuModel.INTEL_80286); _ioPortDispatcher = new IOPortDispatcher(new AddressReadWriteBreakpoints(), state, logger, false); - _clock = new EmulatedClock(null, DateTimeOffset.UnixEpoch); - var emulationLoopScheduler = new DeviceScheduler(_clock, logger, "Emulation loop"); + _clock = new CyclesClock(state, 1000, null, DateTimeOffset.UnixEpoch); + DeviceScheduler emulationLoopScheduler = new(_clock, logger, "Emulation loop"); _pic = new DualPic(_ioPortDispatcher, state, logger, false); _pit = new PitTimer(_ioPortDispatcher, state, _pic, _speaker, emulationLoopScheduler, _clock, logger, false); } diff --git a/tests/Spice86.Tests/McpServerToolStateTests.cs b/tests/Spice86.Tests/McpServerToolStateTests.cs index ef08ae8a8b..100ea74c33 100644 --- a/tests/Spice86.Tests/McpServerToolStateTests.cs +++ b/tests/Spice86.Tests/McpServerToolStateTests.cs @@ -23,6 +23,24 @@ namespace Spice86.Tests; public class McpServerToolStateTests { private const string TestProgramName = "add"; + [Fact] + public async Task QueuedMouseInputIsDeliveredBeforeShortProgramExitsAsync() { + // Arrange + await using McpIntegrationContext context = await McpIntegrationContext.CreateAsync(TestProgramName); + await context.InitializeAsync(); + using JsonDocument response = await context.CallToolAsync("send_mouse_button", new Dictionary { + ["button"] = "Left", + ["isPressed"] = true + }); + + // Act + context.Spice86.ProgramExecutor.Run(); + + // Assert + McpJsonRpcAssertions.GetJsonRpcResult(response).TryGetProperty("isError", out JsonElement _).Should().BeFalse(); + context.Spice86.Machine.MouseDriver.GetButtonPressCount(MouseButton.Left).Should().Be(1); + } + [Theory] [InlineData(true, 1)] [InlineData(false, 0)] diff --git a/tests/Spice86.Tests/MouseDriverTests.cs b/tests/Spice86.Tests/MouseDriverTests.cs index fca7a8fcd1..485da82b26 100644 --- a/tests/Spice86.Tests/MouseDriverTests.cs +++ b/tests/Spice86.Tests/MouseDriverTests.cs @@ -17,6 +17,8 @@ namespace Spice86.Tests; using Spice86.Core.Emulator.IOPorts; using Spice86.Core.Emulator.Memory; using Spice86.Core.Emulator.Memory.Mmu; +using Spice86.Core.Emulator.VM.Clock; +using Spice86.Core.Emulator.VM.DeviceScheduler; using Xunit; @@ -30,7 +32,9 @@ public MouseDriverTests() { Memory memory = new(new(), new Ram(A20Gate.EndOfHighMemoryArea), new A20Gate(false), new RealModeMmu386(), false); IOPortDispatcher ports = new(new(), _state, NullLogger.Instance, false); DualPic pic = new(ports, _state, NullLogger.Instance, false); - Mouse mouse = new(_state, _sharedMouseData, pic, MouseType.Ps2, NullLogger.Instance, false); + CyclesClock clock = new(_state, 1000, null, DateTimeOffset.UnixEpoch); + DeviceScheduler scheduler = new(clock, NullLogger.Instance, "Mouse driver test"); + Mouse mouse = new(_state, _sharedMouseData, pic, MouseType.Ps2, NullLogger.Instance, false, clock, scheduler); IVgaFunctionality video = Substitute.For(); video.GetCurrentMode().Returns(new VgaMode(MemoryModel.Packed, 320, 200, 8, 8, 8, 0xA000, 1.0)); _driver = new MouseDriver(_state, _sharedMouseData, memory, mouse, video, NullLogger.Instance); diff --git a/tests/Spice86.Tests/MouseInputTests.cs b/tests/Spice86.Tests/MouseInputTests.cs new file mode 100644 index 0000000000..aa2a4265e8 --- /dev/null +++ b/tests/Spice86.Tests/MouseInputTests.cs @@ -0,0 +1,112 @@ +namespace Spice86.Tests; + +using FluentAssertions; + +using Microsoft.Extensions.Logging.Abstractions; + +using NSubstitute; + +using Spice86.Core.Emulator.CPU; +using Spice86.Core.Emulator.Devices.ExternalInput; +using Spice86.Core.Emulator.Devices.Input.Mouse; +using Spice86.Core.Emulator.InterruptHandlers.Input.Mouse; +using Spice86.Core.Emulator.IOPorts; +using Spice86.Core.Emulator.VM; +using Spice86.Core.Emulator.VM.Clock; +using Spice86.Core.Emulator.VM.DeviceScheduler; +using Spice86.Shared.Emulator.Mouse; + +using Xunit; + +public sealed class MouseInputTests { + private readonly InputEventHub _hub = new(); + private readonly IEmulatedClock _clock = Substitute.For(); + private readonly DeviceScheduler _scheduler; + private readonly Mouse _mouse; + private readonly DualPic _pic; + + public MouseInputTests() { + State state = new(CpuModel.INTEL_80286); + IOPortDispatcher ports = new(new(), state, NullLogger.Instance, false); + _pic = new(ports, state, NullLogger.Instance, false); + _scheduler = new(_clock, NullLogger.Instance, "Mouse input test"); + _mouse = new(state, new SharedMouseData(), _pic, MouseType.Ps2, NullLogger.Instance, false, _clock, _scheduler, _hub); + _mouse.SampleRate = 1; + } + + [Fact] + public void FinalBatchedMoveIsDeliveredWithoutAnotherHostEvent() { + // Arrange + PostMove(0.25, 0.25); + ConsumeMouseIrq(); + PostMove(0.75, 0.5); + + // Act + _clock.ElapsedTimeMs.Returns(1000.0); + _scheduler.ProcessEvents(); + + // Assert + _mouse.DeltaX.Should().Be(0.75); + _mouse.DeltaY.Should().Be(0.5); + _mouse.LastTrigger.Should().Be(MouseEventMask.Movement); + _pic.IrqCheck.Should().BeTrue(); + _scheduler.NextEventTime.Should().BeNull(); + } + + [Fact] + public void BatchedPressAndReleasePreserveBothTransitionMasks() { + // Arrange + PostMove(0.25, 0.25); + ConsumeMouseIrq(); + _hub.PostMouseButtonEvent(new(MouseButton.Left, true)); + _hub.PostMouseButtonEvent(new(MouseButton.Left, false)); + _hub.ProcessAllPendingInputEvents(); + + // Act + _clock.ElapsedTimeMs.Returns(1000.0); + _scheduler.ProcessEvents(); + + // Assert + _mouse.LastTrigger.Should().Be(MouseEventMask.LeftButtonDown | MouseEventMask.LeftButtonUp); + _mouse.IsLeftButtonDown.Should().BeFalse(); + _pic.IrqCheck.Should().BeTrue(); + } + + [Fact] + public void DeferredMouseDeliveryWaitsUntilSampleDeadline() { + // Arrange + PostMove(0.25, 0.25); + ConsumeMouseIrq(); + PostMove(0.5, 0.5); + _clock.ElapsedTimeMs.Returns(999.0); + + // Act + _scheduler.ProcessEvents(); + + // Assert + _pic.IrqCheck.Should().BeFalse(); + _scheduler.NextEventTime.Should().Be(1000); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void InvalidSampleRateIsRejected(int sampleRate) { + // Arrange + Action setRate = () => _mouse.SampleRate = sampleRate; + + // Act / Assert + setRate.Should().Throw(); + _mouse.SampleRate.Should().Be(1); + } + + private void PostMove(double x, double y) { + _hub.PostMouseMoveEvent(new(x, y)); + _hub.ProcessAllPendingInputEvents(); + } + + private void ConsumeMouseIrq() { + _pic.ComputeVectorNumber().Should().Be(0x74); + _pic.AcknowledgeInterrupt(12); + } +} diff --git a/tests/Spice86.Tests/Video/CgaIntegrationTests.cs b/tests/Spice86.Tests/Video/CgaIntegrationTests.cs index 892ef7e70d..550111d9b9 100644 --- a/tests/Spice86.Tests/Video/CgaIntegrationTests.cs +++ b/tests/Spice86.Tests/Video/CgaIntegrationTests.cs @@ -13,6 +13,7 @@ namespace Spice86.Tests.Video; using Spice86.Core.Emulator.IOPorts; using Spice86.Core.Emulator.Memory; using Spice86.Core.Emulator.Memory.Mmu; +using Spice86.Core.Emulator.VM.Clock; using Xunit; @@ -242,13 +243,32 @@ public void ModeChanges_RestoreCgaDefaultsAfterOtherGraphicsModes() { video.BiosDataArea.VideoMsr.Should().Be(0x2A); } + [Theory] + [InlineData(0x03, 28322000, 0.0317774258251326, 70.08659)] + [InlineData(0x12, 25175000, 0.031777557100297914, 59.94048)] + [InlineData(0x13, 25175000, 0.031777557100297914, 70.08630)] + public void BiosProgrammedTimingsMatchOscillatorAndPhysicalTotals( + int mode, int pixelClockHz, double lineDurationMs, double refreshRateHz) { + // Arrange + VideoHarness harness = new(mode); + + // Act + VgaTimingParameters timing = VgaTimingParameters.Calculate(harness.State); + + // Assert + timing.PixelClockHz.Should().Be(pixelClockHz); + timing.LineDurationMs.Should().BeApproximately(lineDurationMs, 1e-12); + timing.RefreshRateHz.Should().BeApproximately(refreshRateHz, 0.00001); + } + private sealed class VideoHarness { public VideoHarness(int mode) { Memory = new Memory(new(), new Ram(0x100000), new A20Gate(), new RealModeMmu386(), false); State cpuState = new(CpuModel.ZET_86); Ports = new IOPortDispatcher(new(), cpuState, NullLogger.Instance, true); State = new VideoState(); - _ = new VgaIoPortHandler(cpuState, Ports, NullLogger.Instance, State, true); + _ = new VgaIoPortHandler(cpuState, Ports, NullLogger.Instance, State, + new CyclesClock(cpuState, 1000, null, DateTimeOffset.UnixEpoch), true); Renderer = new Renderer(Memory, State, new VgaBlinkState(), NullLogger.Instance, new VgaRenderer256ColorScalar()); VgaRom rom = new(NullLogger.Instance); Memory.RegisterMapping(VgaRom.Segment << 4, rom.Size, rom); diff --git a/tests/Spice86.Tests/Video/VgaIoPortTimingTests.cs b/tests/Spice86.Tests/Video/VgaIoPortTimingTests.cs new file mode 100644 index 0000000000..fdc463ffce --- /dev/null +++ b/tests/Spice86.Tests/Video/VgaIoPortTimingTests.cs @@ -0,0 +1,245 @@ +namespace Spice86.Tests.Video; + +using FluentAssertions; + +using Microsoft.Extensions.Logging.Abstractions; + +using NSubstitute; + +using Spice86.Core.Emulator.CPU; +using Spice86.Core.Emulator.Devices.Video; +using Spice86.Core.Emulator.Devices.Video.Registers.General; +using Spice86.Core.Emulator.IOPorts; +using Spice86.Core.Emulator.VM.Clock; + +using Xunit; + +public sealed class VgaIoPortTimingTests { + [Theory] + [InlineData(0x3DA, 399.0, 0)] + [InlineData(0x3DA, 400.25, 1)] + [InlineData(0x3DA, 411.75, 1)] + [InlineData(0x3DA, 412.25, 9)] + [InlineData(0x3BA, 413.0, 9)] + [InlineData(0x3DA, 414.25, 1)] + [InlineData(0x3DA, 449.0, 0)] + [InlineData(0x3DA, 0.81, 1)] + [InlineData(0x3DA, 0.79, 0)] + [InlineData(0x3DA, 0.99, 0)] + public void InputStatusTracksBeamWithoutPumpingRenderer(int port, double scanline, byte expectedStatus) { + // Arrange + State cpuState = new(CpuModel.INTEL_80286); + IOPortDispatcher ports = new(new(), cpuState, NullLogger.Instance, true); + VideoState state = CreateVideoState(); + state.GeneralRegisters.InputStatusRegister1.Value = 0xA9; + IEmulatedClock clock = Substitute.For(); + clock.ElapsedTimeMs.Returns(scanline * 100 * 1000.0 / (25175000 / 8)); + _ = new VgaIoPortHandler(cpuState, ports, NullLogger.Instance, state, clock, true); + + // Act + byte status = ports.ReadByte((ushort)port); + + // Assert + (status & 9).Should().Be(expectedStatus); + (status & ~9).Should().Be(0xA0); + state.GeneralRegisters.InputStatusRegister1.Value.Should().Be(0xA9); + } + + [Theory] + [InlineData(448.25, 9)] + [InlineData(449.25, 8)] + [InlineData(450.25, 0)] + public void VerticalRetraceCanWrapAcrossFrameBoundary(double scanline, byte expectedStatus) { + // Arrange + State cpuState = new(CpuModel.INTEL_80286); + IOPortDispatcher ports = new(new(), cpuState, NullLogger.Instance, true); + VideoState state = CreateVideoState(); + state.CrtControllerRegisters.VerticalSyncStart = 192; + state.CrtControllerRegisters.VerticalSyncEndRegister.Value = 0x82; + IEmulatedClock clock = Substitute.For(); + clock.ElapsedTimeMs.Returns(scanline * 100 * 1000.0 / (25175000 / 8)); + _ = new VgaIoPortHandler(cpuState, ports, NullLogger.Instance, state, clock, true); + + // Act + byte status = ports.ReadByte(0x3DA); + + // Assert + (status & 9).Should().Be(expectedStatus); + } + + [Theory] + [InlineData(799.5, 0)] + [InlineData(800.5, 1)] + [InlineData(824.5, 9)] + [InlineData(828.5, 1)] + public void HalvedVerticalTimingDoublesPhysicalScanlineIntervals(double scanline, byte expectedStatus) { + // Arrange + State cpuState = new(CpuModel.INTEL_80286); + IOPortDispatcher ports = new(new(), cpuState, NullLogger.Instance, true); + VideoState state = CreateVideoState(); + state.CrtControllerRegisters.CrtModeControlRegister.Value = 0xE7; + IEmulatedClock clock = Substitute.For(); + clock.ElapsedTimeMs.Returns(scanline * 100 * 1000.0 / (25175000 / 8)); + _ = new VgaIoPortHandler(cpuState, ports, NullLogger.Instance, state, clock, true); + + // Act + byte status = ports.ReadByte(0x3DA); + + // Assert + (status & 9).Should().Be(expectedStatus); + } + + [Theory] + [InlineData(MiscellaneousOutput.ClockSelectValue.Use25175Khz, 8, false, 95, 0.031777557100297914)] + [InlineData(MiscellaneousOutput.ClockSelectValue.Use28322Khz, 8, false, 95, 0.02824659328295928)] + [InlineData(MiscellaneousOutput.ClockSelectValue.Use28322Khz, 9, false, 95, 0.0317774258251326)] + [InlineData(MiscellaneousOutput.ClockSelectValue.Use25175Khz, 8, true, 95, 0.06355511420059583)] + [InlineData(MiscellaneousOutput.ClockSelectValue.Use25175Khz, 8, false, 115, 0.03813306852035749)] + [InlineData(MiscellaneousOutput.ClockSelectValue.External, 8, false, 95, 0.02824659328295928)] + [InlineData(MiscellaneousOutput.ClockSelectValue.Reserved, 8, false, 95, 0.02824659328295928)] + public void ProgrammedClocksAndHorizontalTotalDriveRetrace( + MiscellaneousOutput.ClockSelectValue clockSelect, int dotsPerClock, bool halfDotClock, + byte horizontalTotal, double lineDurationMs) { + // Arrange + State cpuState = new(CpuModel.INTEL_80286); + IOPortDispatcher ports = new(new(), cpuState, NullLogger.Instance, true); + VideoState state = CreateVideoState(); + state.GeneralRegisters.MiscellaneousOutput.ClockSelect = clockSelect; + state.SequencerRegisters.ClockingModeRegister.DotsPerClock = dotsPerClock; + state.SequencerRegisters.ClockingModeRegister.HalfDotClock = halfDotClock; + state.CrtControllerRegisters.HorizontalTotal = horizontalTotal; + IEmulatedClock clock = Substitute.For(); + _ = new VgaIoPortHandler(cpuState, ports, NullLogger.Instance, state, clock, true); + clock.ElapsedTimeMs.Returns(412.5 * lineDurationMs); + + // Act + byte status = ports.ReadByte(0x3DA); + + // Assert + (status & 8).Should().Be(8); + } + + [Fact] + public void ClockWriteStartsNewBeamEpochWithoutPumpingRenderer() { + // Arrange + State cpuState = new(CpuModel.INTEL_80286); + IOPortDispatcher ports = new(new(), cpuState, NullLogger.Instance, true); + VideoState state = CreateVideoState(); + IEmulatedClock clock = Substitute.For(); + _ = new VgaIoPortHandler(cpuState, ports, NullLogger.Instance, state, clock, true); + clock.ElapsedTimeMs.Returns(100.0); + + // Act + ports.WriteByte(0x3C2, 4); + clock.ElapsedTimeMs.Returns(100 + 412.5 * 800000.0 / 28322000); + byte status = ports.ReadByte(0x3DA); + + // Assert + (status & 8).Should().Be(8); + VgaTimingParameters.Calculate(state).PixelClockHz.Should().Be(28322000); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void ScreenOffAndRepeatedTimingWritesPreserveSyncPhase(bool screenOff) { + // Arrange + State cpuState = new(CpuModel.INTEL_80286); + IOPortDispatcher ports = new(new(), cpuState, NullLogger.Instance, true); + VideoState state = CreateVideoState(); + IEmulatedClock clock = Substitute.For(); + _ = new VgaIoPortHandler(cpuState, ports, NullLogger.Instance, state, clock, true); + clock.ElapsedTimeMs.Returns(412.5 * 800000.0 / 25175000); + + // Act + ports.WriteByte(0x3C4, 1); + ports.WriteByte(0x3C5, screenOff ? (byte)0x21 : (byte)1); + byte status = ports.ReadByte(0x3DA); + + // Assert + (status & 8).Should().Be(8); + } + + [Theory] + [InlineData(0.75, 0)] + [InlineData(0.85, 1)] + [InlineData(1.01, 1)] + [InlineData(1.05, 0)] + public void HorizontalBlankingCanWrapAcrossLineBoundary(double scanline, byte expectedStatus) { + // Arrange + State cpuState = new(CpuModel.INTEL_80286); + IOPortDispatcher ports = new(new(), cpuState, NullLogger.Instance, true); + VideoState state = CreateVideoState(); + state.CrtControllerRegisters.HorizontalBlankingEndRegister.HorizontalBlankingEnd = 8; + IEmulatedClock clock = Substitute.For(); + _ = new VgaIoPortHandler(cpuState, ports, NullLogger.Instance, state, clock, true); + clock.ElapsedTimeMs.Returns(scanline * 800000.0 / 25175000); + + // Act + byte status = ports.ReadByte(0x3DA); + + // Assert + (status & 1).Should().Be(expectedStatus); + } + + [Fact] + public void EqualHorizontalBlankingCountersDoNotInventSixtyFourClocksOfBlanking() { + // Arrange + State cpuState = new(CpuModel.INTEL_80286); + IOPortDispatcher ports = new(new(), cpuState, NullLogger.Instance, true); + VideoState state = CreateVideoState(); + state.CrtControllerRegisters.HorizontalBlankingEndRegister.HorizontalBlankingEnd = 16; + state.CrtControllerRegisters.HorizontalSyncEndRegister.HorizontalBlankingEnd5 = 0; + IEmulatedClock clock = Substitute.For(); + _ = new VgaIoPortHandler(cpuState, ports, NullLogger.Instance, state, clock, true); + clock.ElapsedTimeMs.Returns(0.85 * 800000.0 / 25175000); + + // Act + byte status = ports.ReadByte(0x3DA); + + // Assert + (status & 1).Should().Be(0); + } + + [Theory] + [InlineData(199.5, 0)] + [InlineData(200.5, 1)] + [InlineData(210.5, 1)] + [InlineData(211.5, 0)] + public void VerticalBlankingHasItsOwnIntervalWithinTheDisplayCounters(double scanline, byte expectedStatus) { + // Arrange + State cpuState = new(CpuModel.INTEL_80286); + IOPortDispatcher ports = new(new(), cpuState, NullLogger.Instance, true); + VideoState state = CreateVideoState(); + state.CrtControllerRegisters.OverflowRegister.Value &= 0xF7; + state.CrtControllerRegisters.VerticalBlankingStart = 199; + state.CrtControllerRegisters.VerticalBlankingEnd = 210; + IEmulatedClock clock = Substitute.For(); + _ = new VgaIoPortHandler(cpuState, ports, NullLogger.Instance, state, clock, true); + clock.ElapsedTimeMs.Returns(scanline * 800000.0 / 25175000); + + // Act + byte status = ports.ReadByte(0x3DA); + + // Assert + (status & 1).Should().Be(expectedStatus); + } + + private static VideoState CreateVideoState() { + VideoState state = new(); + state.SequencerRegisters.ClockingModeRegister.DotsPerClock = 8; + state.CrtControllerRegisters.HorizontalTotal = 95; + state.CrtControllerRegisters.HorizontalDisplayEnd = 79; + state.CrtControllerRegisters.HorizontalBlankingStart = 80; + state.CrtControllerRegisters.HorizontalBlankingEndRegister.Value = 0x82; + state.CrtControllerRegisters.HorizontalSyncEndRegister.Value = 0x80; + state.CrtControllerRegisters.VerticalTotal = 191; + state.CrtControllerRegisters.OverflowRegister.Value = 0x1F; + state.CrtControllerRegisters.VerticalDisplayEnd = 143; + state.CrtControllerRegisters.VerticalSyncStart = 156; + state.CrtControllerRegisters.VerticalSyncEndRegister.Value = 0x8E; + state.CrtControllerRegisters.VerticalBlankingStart = 143; + state.CrtControllerRegisters.VerticalBlankingEnd = 192; + return state; + } +} diff --git a/tests/Spice86.Tests/Video/VgaTimingParametersTests.cs b/tests/Spice86.Tests/Video/VgaTimingParametersTests.cs new file mode 100644 index 0000000000..244e065ef4 --- /dev/null +++ b/tests/Spice86.Tests/Video/VgaTimingParametersTests.cs @@ -0,0 +1,121 @@ +namespace Spice86.Tests.Video; + +using FluentAssertions; + +using Spice86.Core.Emulator.Devices.Video; + +using Xunit; + +public sealed class VgaTimingParametersTests { + [Theory] + [InlineData(false, 449, 400)] + [InlineData(true, 898, 800)] + public void VerticalClockDividerDoublesPhysicalFrameIntervals( + bool verticalDivider, int totalLines, int displayLines) { + // Arrange + VideoState state = CreateState(); + state.CrtControllerRegisters.CrtModeControlRegister.VerticalTimingHalved = verticalDivider; + + // Act + VgaTimingParameters timing = VgaTimingParameters.Calculate(state); + + // Assert + timing.TotalScanlines.Should().Be(totalLines); + timing.VisibleScanlines.Should().Be(displayLines); + timing.LineDurationMs.Should().BeApproximately(800000.0 / 25175000, 1e-12); + timing.FrameDurationMs.Should().BeApproximately(totalLines * 800000.0 / 25175000, 1e-12); + } + + [Fact] + public void ZeroRetraceDistancesMeanFullCounterPeriodsButZeroHorizontalBlankingDoesNot() { + // Arrange + VideoState state = CreateState(); + state.CrtControllerRegisters.HorizontalBlankingStart = 80; + state.CrtControllerRegisters.HorizontalBlankingEndRegister.HorizontalBlankingEnd = 16; + state.CrtControllerRegisters.HorizontalSyncStart = 84; + state.CrtControllerRegisters.HorizontalSyncEndRegister.HorizontalSyncEnd = 20; + state.CrtControllerRegisters.VerticalSyncStart = 156; + state.CrtControllerRegisters.VerticalSyncEndRegister.VerticalSyncEnd = 12; + state.CrtControllerRegisters.OverflowRegister.Value |= 4; + + // Act + VgaTimingParameters timing = VgaTimingParameters.Calculate(state); + + // Assert + timing.HorizontalBlankingEndMs.Should().Be(timing.HorizontalBlankingStartMs); + (timing.HorizontalRetraceEndMs - timing.HorizontalRetraceStartMs) + .Should().BeApproximately(32 * 8000.0 / 25175000, 1e-12); + (timing.VerticalRetraceEndMs - timing.VerticalRetraceStartMs) + .Should().BeApproximately(16 * 800000.0 / 25175000, 1e-12); + } + + [Theory] + [InlineData(0, 0, 0, 1)] + [InlineData(0, 10, 0, 11)] + [InlineData(143, 64, 400, 449)] + [InlineData(143, 16, 400, 529)] + public void VerticalBlankingUsesItsOwnSevenBitEndCounter( + byte start, byte end, int expectedStart, int expectedEnd) { + // Arrange + VideoState state = CreateState(); + if (start == 0) { + state.CrtControllerRegisters.OverflowRegister.Value &= 0xF7; + } + state.CrtControllerRegisters.VerticalBlankingStart = start; + state.CrtControllerRegisters.VerticalBlankingEnd = end; + + // Act + VgaTimingParameters timing = VgaTimingParameters.Calculate(state); + + // Assert + timing.VerticalBlankingStartMs.Should().BeApproximately(expectedStart * 800000.0 / 25175000, 1e-12); + timing.VerticalBlankingEndMs.Should().BeApproximately(expectedEnd * 800000.0 / 25175000, 1e-12); + } + + [Fact] + public void NonTimingWritesKeepTheSameParametersAndFrameEpoch() { + // Arrange + VideoState state = CreateState(); + VgaTimingParameters before = state.BeamTiming.StartFrame(100); + state.CrtControllerRegisters.ScreenStartAddress = 1234; + state.CrtControllerRegisters.VerticalSyncEndRegister.Value |= 0x80; + state.SequencerRegisters.ClockingModeRegister.ScreenOff = true; + + // Act + VgaTimingParameters after = state.BeamTiming.Refresh(101); + byte status = state.BeamTiming.ReadStatus(100 + 412.5 * 800000.0 / 25175000); + + // Assert + after.Should().BeSameAs(before); + (status & 8).Should().Be(8); + } + + [Fact] + public void TimingCalculationUsesOnlyTheCapturedRegisters() { + // Arrange + VideoState state = CreateState(); + VgaTimingRegisters registers = VgaTimingRegisters.Read(state); + state.CrtControllerRegisters.HorizontalTotal = 115; + state.SequencerRegisters.ClockingModeRegister.HalfDotClock = true; + + // Act + VgaTimingParameters timing = VgaTimingParameters.Calculate(registers); + + // Assert + timing.HorizontalTotal.Should().Be(100); + timing.LineDurationMs.Should().BeApproximately(800000.0 / 25175000, 1e-12); + } + + private static VideoState CreateState() { + VideoState state = new(); + state.SequencerRegisters.ClockingModeRegister.DotsPerClock = 8; + state.CrtControllerRegisters.HorizontalTotal = 95; + state.CrtControllerRegisters.HorizontalDisplayEnd = 79; + state.CrtControllerRegisters.VerticalTotal = 191; + state.CrtControllerRegisters.OverflowRegister.Value = 0x1F; + state.CrtControllerRegisters.VerticalDisplayEnd = 143; + state.CrtControllerRegisters.VerticalSyncStart = 156; + state.CrtControllerRegisters.VerticalSyncEndRegister.VerticalSyncEnd = 14; + return state; + } +} diff --git a/tests/Spice86.Tests/Video/VideoFramePublicationTests.cs b/tests/Spice86.Tests/Video/VideoFramePublicationTests.cs index 7653a22bd5..e30d1a5ebb 100644 --- a/tests/Spice86.Tests/Video/VideoFramePublicationTests.cs +++ b/tests/Spice86.Tests/Video/VideoFramePublicationTests.cs @@ -15,6 +15,113 @@ namespace Spice86.Tests.Video; using Xunit; public sealed class VideoFramePublicationTests { + [Fact] + public void LatePumpsShareFrameEpochAndPreserveTheFinalRenderedRow() { + // Arrange + using AsmFixtureRun run = AsmFixtureRunner.RunEmulated(AsmFixtureCatalog.FlashbackVideo(true), JitMode.InterpretedOnly); + uint[] expected = run.Result.CaptureVideoFrame(); + IEmulatedClock clock = Substitute.For(); + clock.ElapsedTimeMs.Returns(100.0); + DeviceScheduler scheduler = new(clock, Substitute.For(), "Shared video beam test"); + _ = new VgaTimingEngine(run.Result.VideoState, scheduler, clock, run.Result.VideoRenderer, new VgaBlinkState()); + run.Result.VideoState.IsRenderingDirty = true; + VgaTimingParameters parameters = VgaTimingParameters.Calculate(run.Result.VideoState); + scheduler.ProcessEvents(); + + // Act + for (int millisecond = 101; millisecond < 100 + parameters.DisplayDurationMs; millisecond++) { + clock.ElapsedTimeMs.Returns((double)millisecond); + scheduler.ProcessEvents(); + } + clock.ElapsedTimeMs.Returns(100 + parameters.DisplayDurationMs); + scheduler.ProcessEvents(); + uint[] pixels = new uint[expected.Length]; + run.Result.VideoRenderer.CopyLastFrame(pixels); + clock.ElapsedTimeMs.Returns(100 + parameters.VerticalRetraceStartMs + parameters.LineDurationMs / 2); + scheduler.ProcessEvents(); + + // Assert + pixels.Should().Equal(expected); + (run.Result.VideoState.BeamTiming.ReadStatus(clock.ElapsedTimeMs) & 8).Should().Be(8); + } + + [Fact] + public void MidFrameTimingChangeCancelsTheOldFrameChain() { + // Arrange + using AsmFixtureRun run = AsmFixtureRunner.RunEmulated(AsmFixtureCatalog.FlashbackVideo(true), JitMode.InterpretedOnly); + IEmulatedClock clock = Substitute.For(); + DeviceScheduler scheduler = new(clock, Substitute.For(), "Video reconfiguration test"); + VgaTimingEngine engine = new(run.Result.VideoState, scheduler, clock, run.Result.VideoRenderer, new VgaBlinkState()); + scheduler.ProcessEvents(); + VgaTimingParameters before = VgaTimingParameters.Calculate(run.Result.VideoState); + run.Result.VideoState.SequencerRegisters.ClockingModeRegister.HalfDotClock = true; + clock.ElapsedTimeMs.Returns(1.0); + + // Act + scheduler.ProcessEvents(); + VgaTimingParameters after = VgaTimingParameters.Calculate(run.Result.VideoState); + for (int millisecond = 2; millisecond <= Math.Ceiling(before.DisplayDurationMs) + 1; millisecond++) { + clock.ElapsedTimeMs.Returns((double)millisecond); + scheduler.ProcessEvents(); + } + TimeSpan durationAtOldDisplayEnd = engine.LastFrameDuration; + for (int millisecond = (int)Math.Ceiling(before.DisplayDurationMs) + 2; millisecond <= 40; millisecond++) { + clock.ElapsedTimeMs.Returns((double)millisecond); + scheduler.ProcessEvents(); + } + + // Assert + after.FrameDurationMs.Should().BeApproximately(before.FrameDurationMs * 2, 1e-12); + durationAtOldDisplayEnd.Should().Be(TimeSpan.Zero); + engine.LastFrameDuration.TotalMilliseconds.Should().BeApproximately(after.DisplayDurationMs, 0.0001); + } + + [Fact] + public void TimingChangeDuringBlankingDoesNotReuseAnOverdueFrameEpoch() { + // Arrange + using AsmFixtureRun run = AsmFixtureRunner.RunEmulated(AsmFixtureCatalog.FlashbackVideo(true), JitMode.InterpretedOnly); + IEmulatedClock clock = Substitute.For(); + DeviceScheduler scheduler = new(clock, Substitute.For(), "Blanking reconfiguration test"); + _ = new VgaTimingEngine(run.Result.VideoState, scheduler, clock, run.Result.VideoRenderer, new VgaBlinkState()); + scheduler.ProcessEvents(); + VgaTimingParameters before = VgaTimingParameters.Calculate(run.Result.VideoState); + clock.ElapsedTimeMs.Returns(before.DisplayDurationMs); + scheduler.ProcessEvents(); + run.Result.VideoState.SequencerRegisters.ClockingModeRegister.HalfDotClock = true; + clock.ElapsedTimeMs.Returns(100.0); + + // Act + scheduler.ProcessEvents(); + VgaTimingParameters after = VgaTimingParameters.Calculate(run.Result.VideoState); + clock.ElapsedTimeMs.Returns(100 + after.VerticalRetraceStartMs + after.LineDurationMs / 2); + scheduler.ProcessEvents(); + byte status = run.Result.VideoState.BeamTiming.ReadStatus(clock.ElapsedTimeMs); + + // Assert + (status & 8).Should().Be(8); + } + + [Fact] + public void MillisecondSchedulerPumpsPreserveScanlineTiming() { + // Arrange + using AsmFixtureRun run = AsmFixtureRunner.RunEmulated(AsmFixtureCatalog.FlashbackVideo(true), JitMode.InterpretedOnly); + IEmulatedClock clock = Substitute.For(); + DeviceScheduler scheduler = new(clock, Substitute.For(), "Late video timing test"); + VgaTimingEngine timing = new(run.Result.VideoState, scheduler, clock, run.Result.VideoRenderer, new VgaBlinkState()); + int visibleScanlines = run.Result.VideoState.CrtControllerRegisters.VerticalDisplayEndValue + 1; + + // Act + for (int millisecond = 0; millisecond <= 100; millisecond++) { + clock.ElapsedTimeMs.Returns((double)millisecond); + scheduler.ProcessEvents(); + } + + // Assert + timing.LastFrameDuration.TotalMilliseconds.Should().BeGreaterThan(0); + timing.LastFrameDuration.TotalMilliseconds.Should().BeApproximately( + visibleScanlines * VgaTimingParameters.Calculate(run.Result.VideoState).LineDurationMs, 0.0001); + } + [Fact] public void DisabledOutputUsesNewFrameWidthAfterResolutionReduction() { // Arrange @@ -65,11 +172,11 @@ public void SchedulerPublishesFinalVisiblePhysicalScanline( } // Act - for (int eventIndex = 0; eventIndex < visibleScanlines * 2 + 2; eventIndex++) { + for (int eventIndex = 0; eventIndex < visibleScanlines * 2 + 8; eventIndex++) { double nextTime = scheduler.NextEventTime ?? throw new InvalidOperationException("Missing VGA timing event."); clock.ElapsedTimeMs.Returns(nextTime); scheduler.ProcessEvents(); - if (state.GeneralRegisters.InputStatusRegister1.VerticalRetrace) { + if (timing.LastFrameDuration > TimeSpan.Zero) { break; } } @@ -77,16 +184,17 @@ public void SchedulerPublishesFinalVisiblePhysicalScanline( run.Result.VideoRenderer.CopyLastFrame(pixels); // Assert - state.GeneralRegisters.InputStatusRegister1.VerticalRetrace.Should().BeTrue(); + timing.LastFrameDuration.Should().BeGreaterThan(TimeSpan.Zero); run.Result.VideoRenderer.Height.Should().Be(expectedHeight); pixels[(expectedHeight - 1) * 256].Should().Be(state.DacRegisters.PaletteMap[lastPaletteIndex]); - timing.LastFrameDuration.TotalMilliseconds.Should().BeApproximately(visibleScanlines * 1000.0 / 31469.0, 0.0001); + VgaTimingParameters parameters = VgaTimingParameters.Calculate(state); + timing.LastFrameDuration.TotalMilliseconds.Should().BeApproximately(visibleScanlines * parameters.LineDurationMs, 0.0001); int totalScanlines = state.CrtControllerRegisters.VerticalTotalValue + 2; if (verticalTimingHalved) { totalScanlines *= 2; } - double nextFrameTime = scheduler.NextEventTime ?? throw new InvalidOperationException("Missing next frame event."); - nextFrameTime.Should().BeApproximately(totalScanlines * 1000.0 / 31469.0, 0.0001); + scheduler.NextEventTime.Should().NotBeNull(); + parameters.FrameDurationMs.Should().BeApproximately(totalScanlines * parameters.LineDurationMs, 0.0001); } [Fact]