Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions doc/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,20 @@ 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. 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:<port>/mcp` | MCP protocol endpoint (JSON-RPC over HTTP) |
Expand Down
64 changes: 45 additions & 19 deletions src/Spice86.Core/Emulator/Devices/Input/Mouse/Mouse.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,26 +6,29 @@ 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;

/// <summary>
/// Basic implementation of a mouse
/// </summary>
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;

Expand All @@ -38,18 +41,21 @@ public class Mouse : DefaultIOPortHandler, IMouseDevice {
/// <param name="mouseType">The type of mouse to emulate.</param>
/// <param name="loggerService">The logger service implementation.</param>
/// <param name="failOnUnhandledPort">Whether we throw an exception when an I/O wasn't handled.</param>
/// <param name="clock">Clock used to pace mouse samples.</param>
/// <param name="scheduler">Emulator-thread scheduler for deferred mouse samples.</param>
/// <param name="gui">The GUI mouse events interface, or null if not using a GUI.</param>
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;
_sharedMouseData = sharedMouseData;
_dualPic = dualPic;
MouseType = mouseType;
_logger = loggerService;
_sampleRateTicks = TimeSpan.TicksPerSecond / _sampleRate;
_clock = clock;
_scheduler = scheduler;
Initialize();
_dualPic.SetIrqMask(IrqNumber, false);
}
Expand All @@ -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;
}
}

Expand Down Expand Up @@ -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;
Expand All @@ -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);
}
Expand Down
50 changes: 48 additions & 2 deletions src/Spice86.Core/Emulator/Devices/Video/VgaIoPortHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/// <summary>
Expand All @@ -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;

/// <summary>
Expand All @@ -28,9 +30,10 @@ public class VgaIoPortHandler : DefaultIOPortHandler {
/// <param name="ioPortDispatcher">The class that is responsible for dispatching ports reads and writes to classes that respond to them.</param>
/// <param name="loggerService">The logger service implementation.</param>
/// <param name="videoState">Represents the state of the video card.</param>
/// <param name="clock">The emulated clock driving the beam independently of rendering.</param>
/// <param name="failOnUnhandledPort">Whether we throw an exception when an I/O port wasn't handled.</param>
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;
Expand All @@ -40,6 +43,7 @@ public VgaIoPortHandler(State state, IOPortDispatcher ioPortDispatcher, ILogger
_generalRegisters = videoState.GeneralRegisters;
_dacRegisters = videoState.DacRegisters;
_videoState = videoState;
_clock = clock;
InitPortHandlers(ioPortDispatcher);
}

Expand Down Expand Up @@ -160,7 +164,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'));
}
Expand Down Expand Up @@ -191,6 +195,48 @@ public override byte ReadByte(ushort port) {
return value;
}

private byte ReadBeamStatus() {
const int HorizontalBlankingMask = 0x3F;
const int VerticalRetraceEndMask = 0x0F;
const byte DisplayDisabledBit = 0x01;
const byte VerticalRetraceBit = 0x08;
int verticalScale = _crtRegisters.CrtModeControlRegister.VerticalTimingHalved ? 2 : 1;
int totalScanlines = _crtRegisters.VerticalTotalValue + 2;
double lineDuration = VgaTimingEngine.HorizontalLineDurationMs;
double elapsed = _clock.ElapsedTimeMs;
double verticalPosition = elapsed / (lineDuration * verticalScale) % totalScanlines;
int totalCharacterClocks = _crtRegisters.HorizontalTotal + 5;
double horizontalPosition = elapsed % lineDuration / lineDuration * totalCharacterClocks;

int retraceStart = _crtRegisters.VerticalSyncStartValue;
int retraceLength = (_crtRegisters.VerticalSyncEndRegister.VerticalSyncEnd - retraceStart) & VerticalRetraceEndMask;
if (retraceLength == 0) {
retraceLength = VerticalRetraceEndMask + 1;
}
int blankingStart = _crtRegisters.HorizontalBlankingStart;
int blankingEnd = (blankingStart & ~HorizontalBlankingMask) | _crtRegisters.HorizontalBlankingEndValue;
if (blankingEnd <= blankingStart) {
blankingEnd += HorizontalBlankingMask + 1;
}

byte status = (byte)(_generalRegisters.InputStatusRegister1.Value & ~(DisplayDisabledBit | VerticalRetraceBit));
if (IsInBeamInterval(verticalPosition, retraceStart, retraceStart + retraceLength, totalScanlines)) {
status |= VerticalRetraceBit;
}
if (verticalPosition >= _crtRegisters.VerticalDisplayEndValue + 1
|| IsInBeamInterval(horizontalPosition, blankingStart, blankingEnd, totalCharacterClocks)) {
status |= DisplayDisabledBit;
}
return status;
}

private static bool IsInBeamInterval(double position, int start, int end, int period) {
if (position >= start && position < end) {
return true;
}
return end > period && position < end - period;
}

/// <inheritdoc />
public override ushort ReadWord(ushort port) {
byte value = ReadByte(port);
Expand Down
2 changes: 1 addition & 1 deletion src/Spice86.Core/Emulator/Devices/Video/VgaTimingEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ namespace Spice86.Core.Emulator.Devices.Video;
/// </summary>
public class VgaTimingEngine {
private const double BlinkPeriodMs = 500.0;
private const double HorizontalLineDurationMs = 1000.0 / 31469.0;
internal const double HorizontalLineDurationMs = 1000.0 / 31469.0;

private readonly IVideoState _state;
private readonly DeviceScheduler _scheduler;
Expand Down
12 changes: 1 addition & 11 deletions src/Spice86.Core/Emulator/VM/Clock/EmulatedClock.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
/// A real-time clock based on the system's Stopwatch, independent of CPU cycles for its progression.
/// </summary>
public class EmulatedClock : ClockBase {
private int _ticks;
private readonly Stopwatch _stopwatch = new();

public EmulatedClock(int? jitterSeed, DateTimeOffset startTime)
Expand All @@ -15,16 +14,7 @@ public EmulatedClock(int? jitterSeed, DateTimeOffset startTime)
}

/// <inheritdoc/>
public override double ElapsedTimeMs {
Comment thread
maximilien-noal marked this conversation as resolved.
Resolved
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();

/// <inheritdoc/>
protected override void OnPauseCore() => _stopwatch.Stop();
Expand Down
15 changes: 11 additions & 4 deletions src/Spice86.Core/Emulator/VM/CpuSpeedLimit/CpuCycleLimiter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

/// <summary>
/// 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.
/// </summary>
public class CpuCycleLimiter : ICyclesLimiter {
private readonly State _state;
Expand All @@ -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;
Expand Down Expand Up @@ -56,21 +57,27 @@ 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);
}

_lastTicks = _stopwatch.ElapsedTicks;

_targetCyclesForPause = _state.Cycles + TargetCpuCyclesPerMs;
_targetCyclesForPause = _state.Cycles + cycleBudget;

_spinner.Reset();
}

/// <inheritdoc />
public int TargetCpuCyclesPerMs { get; set; }
public int TargetCpuCyclesPerMs {
get;
set => field = Math.Clamp(value, MinCyclesPerMs, MaxCyclesPerMs);
}

/// <inheritdoc/>
public void IncreaseCycles() {
Expand Down
Loading
Loading