August 1, 2018

Making MAME appear on multiple monitors...

I have been working on an open source screen saver that runs MAME games. You can read about it here.

The screen saver originally just ran on the primary windows monitor. All the other screens were blank. I decided it would be way cooler if MAME could run on all your monitors.

The first idea for making this happen was to run multiple instances of MAME. One per screen and tell MAME which monitor to run on. This seems like a simple out-of-the box approach. In practice however it turned out that the first instance seems to acquire a named resource (file or mutex) and this kills the second instance. Running two instances of MAME is pretty resource intensive.

The second idea was to run a single instance of MAME. Then grab screen shots of that window and draw them on onto other full screen windows.

The first attempt to grab screen shots and paint them was to use a technique shown at  here. This method uses a timer running triggering approximately 30 times a second.

private void _refreshTimer_Tick(object sender, EventArgs e)
{
   var bitMap = CaptureScreen.GetDesktopImage();
   _blankScreens.ForEach(screen => screen.FrmBackground.BackgroundImage = bitMap);
}

Each time the timer function invoked a number of unmanaged resources are allocated with calls into the GDI, bitmaps are created and a managed bitmap object returned. This bitmap is then attached to the background image of the windows form.

class CaptureScreen
{
public static Bitmap CaptureDesktop()
{
    IntPtr hDC = User32.GetWindowDC(IntPtr.Zero);
    hMemDC = Gdi32.CreateCompatibleDC(hDC),
    hBitmap = Gdi32.CreateCompatibleBitmap(hDC, SystemParams.ScreenWidth, SystemParams.ScreenHeight);

    if (hBitmap == IntPtr.Zero)
        return null;

    var hOld = Gdi32.SelectObject(hMemDC, hBitmap);

    Gdi32.BitBlt(hMemDC, 0, 0, SystemParams.ScreenWidth, SystemParams.ScreenHeight, hDC, 0, 0, CopyPixelOperation.SourceCopy);

    Gdi32.SelectObject(hMemDC, hOld);

    return Bitmap.FromHbitmap(hBitmap);
}

This worked, but was really slow. I was aiming to get around 30fps and this technique couldn’t cope. It also used a lot of CPU. All that resource creation and disposing is not cheap.

The second method was to try and reuse the expensive unmanaged resources instead of rapidly creating and disposing them.

private void _refreshTimer_Tick(object sender, EventArgs e)
{
    _blankScreens.ForEach(screen =>
    {
        Log.Verbose("Cloning to screen {deviceName} {destination}", screen.Screen.DeviceName, screen.Screen.Bounds);
        _captureScreen.CloneTo(screen.HandleDeviceContext, screen.Screen.Bounds);
    });
}
public class CaptureScreen : IDisposable
{
    private Rectangle _sourceRect;
    private IntPtr _sourceHwnd;
    private IntPtr _hDeviceContext;
    private IntPtr _hMemoryContext = IntPtr.Zero;
    private IntPtr _hBitmap = IntPtr.Zero;

    public void Initialise(Rectangle sourceRect)
    {
        _sourceRect = sourceRect;
        _sourceHwnd = GetDesktopWindow();
        _hDeviceContext = GetDC(_sourceHwnd);
    }
    
    public void CloneTo(IntPtr destinationDeviceContext, Rectangle destinationRect)
    {
        StretchBlt(destinationDeviceContext, 0, 0, destinationRect.Width, destinationRect.Height,
            _hDeviceContext, _sourceRect.X, _sourceRect.Y, _sourceRect.Width, _sourceRect.Height, SRCOPY);
    }

The main change here is that the CaptureScreen class is initialised once and grabs the window handle of the desktop on which the MAME game is running on.

The code in the timer then passes in a list of blank screen target windows handles (which are also grabbed once) and performs a StretchBlt. A StretchBlt operation will resize the image to the target size (either larger or smaller).

This code made a massive difference in performance and visual quality. On high (30fps+) framerate games you will sometimes notice skipped frames, but generally the outcome is pretty good and the code is not resource intensive.

The final improvement was to always run MAME on the monitor with the highest resolution. This meant that we were using the image with the most pixels/detail and painting to monitors with fewer pixels. In addition, scaling between screens with different ratios also needs to ensure the images are not distorted.

private Screen GetPrimaryScreen()
{
    if (_settings.MamePrimaryScreen == MamePrimaryScreen.HighestResolution)
    {
        // Find the best primary screen for MAME.
        // As games are largely vertical and screens are wide, select the one with the greatest Y axis
        return Screen.AllScreens.OrderByDescending(screen => screen.Bounds.Height).First();
    }
    return Screen.PrimaryScreen;
}

private void BackgroundForm_Load(object sender, EventArgs e)
{
    HandleDeviceContext = GetDC(BackgroundForm.Handle);

    _xDpi = GetDeviceCaps(HandleDeviceContext, (int)DeviceCap.LOGPIXELSX);
    _yDpi = GetDeviceCaps(HandleDeviceContext, (int)DeviceCap.LOGPIXELSY);

    // 96 is the default dpi for windows 
    // https://docs.microsoft.com/en-us/windows/desktop/directwrite/how-to-ensure-that-your-application-displays-properly-on-high-dpi-displays
    var width = _xDpi * Screen.Bounds.Width / 96f;
    var height = _yDpi * Screen.Bounds.Height / 96f;
    WindowsInterop.SetWinFullScreen(BackgroundForm.Handle, Screen.Bounds.Left, Screen.Bounds.Top, (int) width, (int)height);

    Log.Information("Blank screen resized {device} {bounds} xDpi {xDpi} yDpi {yDpi}", Screen.DeviceName, Screen.Bounds, _xDpi, _yDpi);
}

The code is available  here on my github account.