From 0e943b843f2b413fbcc866d7c9eea1adef1a0870 Mon Sep 17 00:00:00 2001 From: Scott Duensing Date: Tue, 29 Sep 2026 20:23:31 -0500 Subject: [PATCH] Fullscreen exclusive mode. --- .gitignore | 1 + Makefile | 9 +- docs/dvx_system_reference.html | 97 +++- screenshots/86box/fullmode-fm-13h.png | 3 + screenshots/86box/fullmode-fm-desk.png | 3 + screenshots/86box/fullmode-fm-modex.png | 3 + screenshots/86box/fullmode-fm-vbe.png | 3 + screenshots/86box/fullscr-fs-desk.png | 3 + screenshots/86box/fullscr-fs-game.png | 3 + screenshots/dosbox/fullmode-fm-13h.png | 3 + screenshots/dosbox/fullmode-fm-desk.png | 3 + screenshots/dosbox/fullmode-fm-modex.png | 3 + screenshots/dosbox/fullmode-fm-vbe.png | 3 + screenshots/dosbox/fullscr-fs-desk.png | 3 + screenshots/dosbox/fullscr-fs-game.png | 3 + screenshots/masks/fullscr-fs-game.mask | 4 + src/apps/kpunch/dvxbasic/formrt/formrt.c | 84 ++- src/apps/kpunch/progman/userHelp.dhs | 6 + src/include/basic/comm.bas | 21 +- src/installer/mkDist.sh | 18 +- src/libs/kpunch/conn/conn.c | 5 +- src/libs/kpunch/libdvx/apiref.dhs | 82 +++ src/libs/kpunch/libdvx/dvxApp.c | 455 +++++++++++++++-- src/libs/kpunch/libdvx/dvxApp.h | 93 ++++ src/libs/kpunch/libdvx/dvxTypes.h | 2 +- src/libs/kpunch/libdvx/games.dhs | 53 +- src/libs/kpunch/libdvx/platform/dvxPlat.h | 26 +- .../kpunch/libdvx/platform/dvxPlatformDos.c | 385 ++++++++++++-- .../kpunch/libdvx/platform/dvxPlatformHost.c | 47 +- src/libs/kpunch/serial/rs232/rs232.c | 17 +- src/libs/kpunch/serial/serial.dhs | 6 +- src/sdk/README.TXT | 18 +- src/sdk/templates/game/tplName.c | 68 ++- src/sdk/templates/game/tplName.dhs | 4 +- src/test/abi/abiSizes.txt | 5 +- src/test/dos/fixtures/ticker/ticker.dbp | 14 + src/test/dos/fixtures/ticker/ticker.frm | 60 +++ src/test/dos/runDos.sh | 6 +- src/test/dos/script/scriptLang.c | 81 ++- src/test/dos/script/scriptLang.h | 8 +- src/test/dos/script/scriptRun.c | 74 +++ src/test/dos/script/scriptRun.h | 1 + src/test/dos/scripts/fullmode.scr | 33 ++ src/test/dos/scripts/fullscr.scr | 50 ++ src/test/formrt/frtStubs.c | 69 +++ src/test/formrt/testFormRt.c | 23 + src/test/samples/golden/basdemo-debug.app | Bin 23431 -> 23431 bytes src/test/samples/golden/basdemo-release.app | Bin 20182 -> 20182 bytes src/test/samples/golden/helpedit-debug.app | Bin 12571 -> 12571 bytes src/test/samples/golden/helpedit-release.app | Bin 11363 -> 11363 bytes src/test/samples/golden/iconed-debug.app | Bin 17371 -> 17371 bytes src/test/samples/golden/iconed-release.app | Bin 14506 -> 14506 bytes src/test/samples/golden/imgview-debug.app | Bin 9536 -> 9536 bytes src/test/samples/golden/imgview-release.app | Bin 9063 -> 9063 bytes src/test/samples/golden/inc-comm-debug.app | Bin 7341 -> 7418 bytes src/test/samples/golden/inc-comm-release.app | Bin 7259 -> 7336 bytes src/test/samples/golden/inc-commdlg-debug.app | Bin 7132 -> 7132 bytes .../samples/golden/inc-commdlg-release.app | Bin 7068 -> 7068 bytes src/test/samples/golden/inc-fileops-debug.app | Bin 7114 -> 7114 bytes .../samples/golden/inc-fileops-release.app | Bin 7059 -> 7059 bytes src/test/samples/golden/inc-game-debug.app | Bin 7098 -> 7098 bytes src/test/samples/golden/inc-game-release.app | Bin 6980 -> 6980 bytes src/test/samples/golden/inc-help-debug.app | Bin 7046 -> 7046 bytes src/test/samples/golden/inc-help-release.app | Bin 6991 -> 6991 bytes .../samples/golden/inc-joystick-debug.app | Bin 7119 -> 7119 bytes .../samples/golden/inc-joystick-release.app | Bin 7055 -> 7055 bytes src/test/samples/golden/inc-music-debug.app | Bin 7263 -> 7263 bytes src/test/samples/golden/inc-music-release.app | Bin 7202 -> 7202 bytes src/test/samples/golden/inc-net-debug.app | Bin 7191 -> 7191 bytes src/test/samples/golden/inc-net-release.app | Bin 7136 -> 7136 bytes src/test/samples/golden/inc-print-debug.app | Bin 7125 -> 7125 bytes src/test/samples/golden/inc-print-release.app | Bin 7070 -> 7070 bytes .../samples/golden/inc-resource-debug.app | Bin 7151 -> 7151 bytes .../samples/golden/inc-resource-release.app | Bin 7087 -> 7087 bytes src/test/samples/golden/inc-sound-debug.app | Bin 7147 -> 7147 bytes src/test/samples/golden/inc-sound-release.app | Bin 7089 -> 7089 bytes src/test/samples/golden/inc-sql-debug.app | Bin 7194 -> 7194 bytes src/test/samples/golden/inc-sql-release.app | Bin 7139 -> 7139 bytes src/test/samples/golden/inc-xfer-debug.app | Bin 7150 -> 7150 bytes src/test/samples/golden/inc-xfer-release.app | Bin 7071 -> 7071 bytes src/test/samples/golden/notepad-debug.app | Bin 12240 -> 12240 bytes src/test/samples/golden/notepad-release.app | Bin 10909 -> 10909 bytes src/test/samples/golden/resedit-debug.app | Bin 13880 -> 13880 bytes src/test/samples/golden/resedit-release.app | Bin 12204 -> 12204 bytes src/test/samples/golden/widshow-debug.app | Bin 15195 -> 15195 bytes src/test/samples/golden/widshow-release.app | Bin 14185 -> 14185 bytes src/test/script/testScript.c | 27 +- src/test/serial/loopShim.c | 19 + src/test/serial/loopShim.h | 1 + src/test/terminal/testConn.c | 1 + src/test/terminal/testConnProxy.c | 9 + src/test/wm/Makefile | 6 +- src/test/wm/wmExcl.c | 481 ++++++++++++++++++ 93 files changed, 2366 insertions(+), 139 deletions(-) create mode 100644 screenshots/86box/fullmode-fm-13h.png create mode 100644 screenshots/86box/fullmode-fm-desk.png create mode 100644 screenshots/86box/fullmode-fm-modex.png create mode 100644 screenshots/86box/fullmode-fm-vbe.png create mode 100644 screenshots/86box/fullscr-fs-desk.png create mode 100644 screenshots/86box/fullscr-fs-game.png create mode 100644 screenshots/dosbox/fullmode-fm-13h.png create mode 100644 screenshots/dosbox/fullmode-fm-desk.png create mode 100644 screenshots/dosbox/fullmode-fm-modex.png create mode 100644 screenshots/dosbox/fullmode-fm-vbe.png create mode 100644 screenshots/dosbox/fullscr-fs-desk.png create mode 100644 screenshots/dosbox/fullscr-fs-game.png create mode 100644 screenshots/masks/fullscr-fs-game.mask create mode 100644 src/test/dos/fixtures/ticker/ticker.dbp create mode 100644 src/test/dos/fixtures/ticker/ticker.frm create mode 100644 src/test/dos/scripts/fullmode.scr create mode 100644 src/test/dos/scripts/fullscr.scr create mode 100644 src/test/wm/wmExcl.c diff --git a/.gitignore b/.gitignore index 78895914..ba80306d 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ dvx.log claudeDocs/ toolchains/ dist/ +NOTES.md diff --git a/Makefile b/Makefile index 84f4dab9..24d41fbe 100644 --- a/Makefile +++ b/Makefile @@ -41,7 +41,10 @@ CORE_LIBS = libdvx libtasks .PHONY: all clean $(LIBS) loader widgets apps tools installer dist deploy-helpsrc compile-help deploy-sdk test test-quick test-asan test-host test-samples coverage +# Every build ends by making the release media in dist/ (a second or +# two), so dist/ always holds the latest DVX. all: $(LIBS) loader tools widgets apps installer deploy-helpsrc compile-help deploy-sdk + src/installer/mkDist.sh $(or $(FLOPPY),1.44M) $(LIBS): $(MAKE) -C src/libs/kpunch/$@ @@ -67,9 +70,9 @@ installer: # Release media in dist/: the CD image and floppy images (FLOPPY=360K, # 720K, 1.2M, 1.44M (default), 2.88M or a number of bytes per disk). -# See src/installer/mkDist.sh. +# See src/installer/mkDist.sh. `all` makes them; `make dist` is the +# same build under the name people look for. dist: all - src/installer/mkDist.sh $(or $(FLOPPY),1.44M) apps: libdvx libtasks dvxshell tools $(MAKE) -C src/apps/kpunch @@ -241,6 +244,6 @@ clean: $(MAKE) -C src/tools clean $(MAKE) -C src/tools/proxy clean $(MAKE) -C src/installer clean - rm -rf obj bin/host bin/test bin/system dist + rm -rf obj bin/host bin/test bin/system rm -rf bin/config bin/widgets bin/libs bin/sdk rm -f docs/*.html diff --git a/docs/dvx_system_reference.html b/docs/dvx_system_reference.html index 3e56c3df..28357fd2 100644 --- a/docs/dvx_system_reference.html +++ b/docs/dvx_system_reference.html @@ -190,6 +190,7 @@ img { max-width: 100%; }
  • Keyboard, Mouse and Joysticks
  • Timing and Frame Pacing
  • The Screen: Framebuffers and Palettes
  • +
  • The Whole Screen
  • Sound, Music and MIDI
  • Data Files and Saved Games
  • Packaging and Ending
  • @@ -274,6 +275,7 @@ img { max-width: 100%; }
  • accelParse
  • AccelTableT
  • accept
  • +
  • Alt+Enter
  • Alt+Tab
  • AnsiTerm
  • AnsiTermExApiT
  • @@ -530,6 +532,16 @@ img { max-width: 100%; }
  • dvxDrv.h
  • DvxEntropyHookT
  • dvxErrorBox
  • +
  • dvxExclusiveBegin
  • +
  • dvxExclusiveBegin
  • +
  • dvxExclusiveBlitOps
  • +
  • dvxExclusiveDisplay
  • +
  • dvxExclusiveEnd
  • +
  • dvxExclusiveFlush
  • +
  • dvxExclusivePresent8
  • +
  • dvxExclusivePresent8
  • +
  • dvxExclusiveSetPalette
  • +
  • dvxExclusiveWaitRetrace
  • dvxFileDialog
  • dvxFitWindow
  • dvxFitWindowH
  • @@ -660,6 +672,7 @@ img { max-width: 100%; }
  • Event Dispatch
  • Events
  • EWOULDBLOCK
  • +
  • Exclusive mode
  • exit
  • Exit DVX
  • Exit DVX
  • @@ -687,6 +700,9 @@ img { max-width: 100%; }
  • Framebuffer
  • FRAMEWAIT
  • FRAMEWAIT
  • +
  • Full screen
  • +
  • Full screen
  • +
  • Full-screen exclusive mode
  • game
  • Game Input
  • Game library
  • @@ -1438,6 +1454,7 @@ img { max-width: 100%; }
  • wgtVBox
  • wgtVSeparator
  • wgtWrapBox
  • +
  • Whole screen
  • Widget API
  • Widget Base
  • widgetAddChild
  • @@ -1820,6 +1837,8 @@ img { max-width: 100%; }
  • Press Alt+Tab until its window comes to the front, or
  • Press Ctrl+Esc to open the Task Manager, pick the program and click Switch To.
  • +

    Programs That Use the Whole Screen

    +

    Some programs, games especially, can fill the whole screen instead of a window (many use Alt+Enter to switch). Your other programs keep running while they do. Alt+Tab and Ctrl+Esc still work and bring the desktop back; when you return to the program, it fills the screen again.

    Closing a Program

    Close the program's main window, or choose Exit from its File menu. A program that has unsaved work usually asks whether you want to save it first. See Working with Windows for all the ways to close a window.

    Help for Each Program

    @@ -4163,6 +4182,38 @@ void dvxBeep(void); void dvxSetSoundHook(DvxSoundHookT fn);

    The function that plays the sounds, set by the sound library when it loads (NULL: silent). Programs do not call it.

    +

    Full-Screen Exclusive Mode

    +

    A window's program takes the whole screen in a video mode of its own (a game at 320x200, say) while every other program keeps running: windows keep painting into their own buffers, and the desktop is drawn again when the program lets go. The owner's window must have the focus: when it loses it (Alt+Tab, Ctrl+Esc, a dialog, another window coming to the front) the desktop comes back and onChange hears ExclusiveSuspendedE; when the window has the focus again the mode is set again and onChange hears ExclusiveActiveE (draw the whole frame: the screen and the backbuffer start black). While active every key but Alt+Tab, Ctrl+Esc and Alt+F4 goes to the window as in game input mode, the mouse cursor is not drawn, onMouse gets full-screen coordinates, dvxScreenshot saves the full screen, and dvxChangeVideoMode fails. Closing the window, the program ending or being ended, and dvxExclusiveEnd give the screen back. See the Writing Games guide.

    +
    typedef enum {
    +    ExclusiveActiveE,       // the screen is the program's: draw the whole frame
    +    ExclusiveSuspendedE,    // its window lost the focus: the desktop is back until it has the focus again
    +    ExclusiveEndedE         // the mode could not be set again: full screen is over
    +} ExclusiveStateE;
    +
    +typedef void (*ExclusiveFnT)(WindowT *win, ExclusiveStateE state, void *userData);
    +

    dvxExclusiveBegin

    +
    int32_t dvxExclusiveBegin(AppContextT *ctx, WindowT *win, int32_t width, int32_t height, int32_t bpp, ExclusiveFnT onChange, void *userData);
    +

    Take the whole screen for win (raised and focused): the smallest mode of at least width x height, at bpp when there is one (0: any depth). At 8 bpp (or 0) a request that fits 320x200 gets VGA mode 13h, and one that fits 320x240 VGA Mode X (square pixels, two pages flipped at the retrace), unless the card has a VBE mode no larger. onChange (may be NULL) hears ExclusiveActiveE before this returns. A window that already has full screen gets the new size.

    +

    Returns: DVX_EXCL_OK; DVX_EXCL_ERR_ARGS (no such window, or a bad size); DVX_EXCL_ERR_BUSY (another window has full screen); DVX_EXCL_ERR_MODE (no mode could be set; the desktop is back).

    +

    dvxExclusiveEnd

    +
    void dvxExclusiveEnd(AppContextT *ctx, WindowT *win);
    +

    Give the screen back: the desktop's mode, palette and mouse, and a full repaint. onChange is not called. Nothing when win does not have full screen.

    +

    dvxExclusiveDisplay / dvxExclusiveBlitOps

    +
    DisplayT       *dvxExclusiveDisplay(AppContextT *ctx);
    +const BlitOpsT *dvxExclusiveBlitOps(AppContextT *ctx);
    +

    The full-screen display (its size may be larger than asked for) and the span operations for its pixel format while active; NULL while suspended or without full screen. Draw into the display's backBuf, with DVX's draw functions if you like, then dvxExclusiveFlush.

    +

    dvxExclusiveFlush

    +
    void dvxExclusiveFlush(AppContextT *ctx, const RectT *r);
    +

    Copy a rectangle of the backbuffer to the screen (NULL: all of it). Nothing when not active. In Mode X a whole-screen flush goes to the hidden page, which is shown from the next vertical retrace (the call waits for it): no tearing; a smaller rectangle goes to the page on show.

    +

    dvxExclusivePresent8

    +
    bool dvxExclusivePresent8(AppContextT *ctx, const uint8_t *src, int32_t srcW, int32_t srcH, int32_t srcPitch);
    +

    Show an 8-bit picture of palette indexes on the whole screen: scaled by the largest whole factor that fits, centred on black, through the palette in any pixel format, and flushed (the whole screen, so Mode X flips pages). Returns false (and does nothing) when not active, so a caller falls back to drawing in its window.

    +

    dvxExclusiveSetPalette

    +
    void dvxExclusiveSetPalette(AppContextT *ctx, const uint8_t *pal, int32_t first, int32_t count);
    +

    Set palette entries first..first+count-1 (pal is the whole 256-entry table, R, G, B bytes per entry). At 8 bpp they go to the DAC at once, so a picture on screen changes colour; at other depths the next dvxExclusivePresent8 uses them. The palette is kept while suspended and set again on resuming; the desktop's palette comes back whenever the desktop does.

    +

    dvxExclusiveWaitRetrace

    +
    void dvxExclusiveWaitRetrace(void);
    +

    Wait for the start of the next vertical retrace (at most two frames' time), before a flush, so the frame is not torn.

    Accelerators

    dvxCreateAccelTable

    AccelTableT *dvxCreateAccelTable(void);
    @@ -7298,6 +7349,31 @@ for (int32_t i = 0; i < 256; i++) {

    Size the window so its content area is exactly the framebuffer times a whole scale that fits the screen (the template computes the scale from the display size and the chrome constants, then corrects with dvxResizeWindow after adding the menu bar). Copying only what changed matters on slower machines: a full 320x200 frame at 2x on a 16-bit desktop is 256 KB of pixels to convert and composite.

    +
    +

    The Whole Screen

    +

    A game may also take the whole screen in a video mode of its own, as the template does with Alt+Enter. Every other program keeps running while it does: their windows go on painting into their own buffers, their timers fire, their sound plays, and the desktop is drawn again, as it is now, when the game lets go.

    +
    static void fullChanged(WindowT *win, ExclusiveStateE state, void *userData) {
    +    // Active: draw the whole frame.  Suspended: draw in the window again.
    +    // Ended: the mode could not be set again; full screen is over.
    +}
    +
    +if (dvxExclusiveBegin(ctx, win, 320, 200, 8, fullChanged, NULL) == DVX_EXCL_OK) {
    +    dvxExclusiveSetPalette(ctx, gamePal, 0, 256);
    +}
    +
    +// every frame
    +if (!dvxExclusivePresent8(ctx, fb, 320, 200, 320)) {
    +    // not on the whole screen: present into the window as usual
    +}
    + +

    Sound, Music and MIDI

    Games play sound on whatever the computer has: a Sound Blaster family card, an FM synthesizer chip, a MIDI port, the PC speaker, or nothing. Nothing fails because of missing hardware: audioOpen returns NULL (or the device plays tones only), and the game carries on silently. Several programs can play at once, and everything a program opened is stopped and released when it ends.

    @@ -7361,7 +7437,7 @@ End Sub

    Porting a Game Library to DVX

    Portable game libraries for DOS-era machines usually keep everything machine-specific behind a small hardware layer: start and stop, show a frame, set the palette, read input, wait for the next frame, play sound, and find files. A DVX port replaces that layer. The rest of the library, including drawing code, sprite compilers that write x86 code into memory, and asset formats built for DOS, works unchanged, because a DVX program is 32-bit protected-mode x86 code in the same flat memory as a DOS program built with the same compiler.

    The Model: a Window, a Task, a Virtual Frame Rate

    -

    The game becomes a DVX program with its own main loop: appMain calls the game's main() (compile the game with -Dmain=gameMain, say, and call gameMain from appMain). The library's start-up creates a game input window whose content area is the game's screen at a whole scale; its frame wait yields until the next frame on the high-resolution clock. The game shares the screen with the desktop, so there is no mode change, no direct palette hardware and no vertical retrace; the port converts the library's back buffer into the window each frame.

    +

    The game becomes a DVX program with its own main loop: appMain calls the game's main() (compile the game with -Dmain=gameMain, say, and call gameMain from appMain). The library's start-up creates a game input window whose content area is the game's screen at a whole scale; its frame wait yields until the next frame on the high-resolution clock. In a window the game shares the screen with the desktop, so there is no mode change, no direct palette hardware and no vertical retrace; the port converts the library's back buffer into the window each frame. On the whole screen (see The Whole Screen) the game has its own mode, its own palette and the vertical retrace, while the other programs run on.

    Mapping a Hardware Layer to DVX

      Hardware layer                   DVX
       --------------                   ---
    @@ -7369,9 +7445,11 @@ End Sub
    Shut-down dvxDestroyWindow, return from appMain Error text, debug log dvxLog (DVX.LOG) Present the back buffer colour table -> win->contentBuf, dvxInvalidateRect; onPaint - Palette packColor, gamePalNearest, GamePalCacheT + Present, whole screen dvxExclusivePresent8, or dvxExclusiveDisplay + dvxExclusiveFlush + Full screen on and off dvxExclusiveBegin, dvxExclusiveEnd + Palette packColor, gamePalNearest, GamePalCacheT; whole screen: dvxExclusiveSetPalette Palette per scan line one colour table per palette, chosen per row - Wait for vertical blank gameFrameInit, gameFrameWait + Wait for vertical blank gameFrameInit, gameFrameWait; whole screen: dvxExclusiveWaitRetrace Frame counter, frame rate GameFramePacerT.frame, the rate given to gameFrameInit Milliseconds platformMicros() / 1000 Held keys onRawKey, gameKeyIsDown, platformKeyStateGet @@ -7397,8 +7475,8 @@ End Sub
  • Start-up: make the window not resizable, with its content area the game's screen at a whole scale that fits the display; give it onClose and onPaint. audioOpen returning NULL (no sound device) is not an error.
  • Shut-down: close what the port opened and return. Anything left (sounds, music, joystick handles, timers) is released when the program ends.
  • Present: expand the rows or word bands that changed through the colour table into the content buffer, scaled, and invalidate them; onPaint does the whole screen. See The Screen: Framebuffers and Palettes.
  • -
  • Palette: scale 12-bit or 18-bit colour to 0..255 per channel before looking it up. Never touch the VGA palette registers: other programs share the screen.
  • -
  • Vertical blank: there is none in a window. The frame rate is a virtual one: use the rate the library reports (60 or 70 frames a second); truncate the frame counter to the library's width.
  • +
  • Palette: scale 12-bit or 18-bit colour to 0..255 per channel before looking it up. Never touch the VGA palette registers: other programs share the screen, and on the whole screen dvxExclusiveSetPalette does it (and puts the desktop's palette back when the game lets go).
  • +
  • Vertical blank: there is none in a window. The frame rate is a virtual one: use the rate the library reports (60 or 70 frames a second); truncate the frame counter to the library's width. On the whole screen dvxExclusiveWaitRetrace waits for the real one.
  • Keyboard: raw codes are set-1 scancodes with KEY_RAW_EXT for the keys sent behind 0xE0, so a DOS port's scancode table maps directly. Never hook the keyboard interrupt.
  • Mouse: divide content-area pixels by the scale and clamp.
  • Joystick: axes are -32768..32767 after calibration and dead zone; divide down to the library's range, or compare with a threshold for a digital stick. Buttons are bits. Never read the game port directly; calibration lives in the Control Panel.
  • @@ -7413,7 +7491,8 @@ End Sub
  • Shared library or static: built as a DVX library (.lib, loaded when DVX starts) the port is shared by every game, but its global state (the stage, the key table) means one game at a time. Linked into each game's .app (the LIBS variable) every game has its own copy, several can run at once, and nothing is loaded while no game runs.
  • Paths: libraries that open "DATA/..." or write saves to a fixed folder need a run-time root (appDir and configDir) instead of compile-time paths.
  • Speed: every frame costs a conversion and a composite. Present only what changed; a 1x scale on a 486 and larger scales on faster machines.
  • -
  • Tearing: there is no vertical retrace in a window. Games that depend on raster timing need rework.
  • +
  • Tearing: there is no vertical retrace in a window. On the whole screen there is (dvxExclusiveWaitRetrace), but games that change registers mid-frame (raster effects) still need rework.
  • +
  • Whole screen: offer it (Alt+Enter is the custom) and handle ExclusiveSuspendedE by drawing in the window again: the player can Alt+Tab to the desktop at any time.
  • The 256-colour desktop: nearest colours only; say so in the game's help if it needs 15-bit colour or better.
  • Checking a Port

    @@ -8189,7 +8268,7 @@ if (!job) { handshake Flow control mode (RS232_HANDSHAKE_*).

    Returns RS232_SUCCESS or a negative error code. The UART type is probed once here (RS232_ERR_NO_UART when nothing answers at the port address); only a 16550A gets its FIFO enabled and is fed 16 bytes per transmit interrupt, every other UART one byte.

    rs232Close

    -

    Close a COM port and release its ISR resources.

    +

    Close a COM port and release its ISR resources. The modem control lines are left as they are: closing never drops DTR or RTS, so a modem stays connected and the next program to open the port can carry on. To hang up, call rs232SetDtr(com, false) before closing; the line stays down after the close. Opening a port raises DTR and RTS.

    int32_t rs232Close(int32_t com);

    rs232Read

    Non-blocking read from the receive ring buffer.

    @@ -8247,9 +8326,9 @@ if (!job) { rs232SetHandshake(com, handshake) Change flow control mode. Releases any throttle the old mode applied (raises RTS/DTR or sends XON) and starts the transmit side from the live CTS/DSR line. rs232SetIrq(com, irq) Move an open port to another IRQ (for a wrong auto-detection). Interrupt enables and DTR/RTS are kept. rs232SetMcr(com, mcr) Set modem control register directly. - rs232SetDtr(com, dtr) Set DTR line state. + rs232SetDtr(com, dtr) Set DTR line state. The state is kept when the port is closed (dropping DTR is how a modem is hung up). rs232SetBreak(com, on) Hold the transmit line in the break (spacing) state (on = true) or release it. The caller times the break, typically 250-500 ms. - rs232SetRts(com, rts) Set RTS line state. + rs232SetRts(com, rts) Set RTS line state. The state is kept when the port is closed. rs232SetFifoThreshold(com, thr) Set FIFO trigger level (1, 4, 8, or 14 bytes) or 0 to turn the FIFO off. Levels other than 0 return RS232_ERR_INVALID_FIFO unless the UART is a 16550A.

    Buffer Management

      Function                   Description
    diff --git a/screenshots/86box/fullmode-fm-13h.png b/screenshots/86box/fullmode-fm-13h.png
    new file mode 100644
    index 00000000..a4bcfca1
    --- /dev/null
    +++ b/screenshots/86box/fullmode-fm-13h.png
    @@ -0,0 +1,3 @@
    +version https://git-lfs.github.com/spec/v1
    +oid sha256:d6113b57221371c09303db72f2ef4b5a0239d489245a346acdaed16a1c4a1f8d
    +size 3095
    diff --git a/screenshots/86box/fullmode-fm-desk.png b/screenshots/86box/fullmode-fm-desk.png
    new file mode 100644
    index 00000000..2c80adad
    --- /dev/null
    +++ b/screenshots/86box/fullmode-fm-desk.png
    @@ -0,0 +1,3 @@
    +version https://git-lfs.github.com/spec/v1
    +oid sha256:3fb9ab5f33faf4c02ed15a9d1d1280f18df993e40bc1d4bb203aa0bb7b6d3250
    +size 18114
    diff --git a/screenshots/86box/fullmode-fm-modex.png b/screenshots/86box/fullmode-fm-modex.png
    new file mode 100644
    index 00000000..a47e5dc3
    --- /dev/null
    +++ b/screenshots/86box/fullmode-fm-modex.png
    @@ -0,0 +1,3 @@
    +version https://git-lfs.github.com/spec/v1
    +oid sha256:5e9171a09e181e3c11e00b0ece62c33dff8e70d551d4a3737cf7ddb4b074c192
    +size 3718
    diff --git a/screenshots/86box/fullmode-fm-vbe.png b/screenshots/86box/fullmode-fm-vbe.png
    new file mode 100644
    index 00000000..e9949a68
    --- /dev/null
    +++ b/screenshots/86box/fullmode-fm-vbe.png
    @@ -0,0 +1,3 @@
    +version https://git-lfs.github.com/spec/v1
    +oid sha256:a7ef883b6c15df3e26d9fe71bbd2057a05d5f3641dc47664d957e16143996ec3
    +size 12283
    diff --git a/screenshots/86box/fullscr-fs-desk.png b/screenshots/86box/fullscr-fs-desk.png
    new file mode 100644
    index 00000000..c1006a4f
    --- /dev/null
    +++ b/screenshots/86box/fullscr-fs-desk.png
    @@ -0,0 +1,3 @@
    +version https://git-lfs.github.com/spec/v1
    +oid sha256:4fbc9cc1f7ba27a108766d613ab4618ea02b446bf8c2e9cda3fc151bb57e0645
    +size 17353
    diff --git a/screenshots/86box/fullscr-fs-game.png b/screenshots/86box/fullscr-fs-game.png
    new file mode 100644
    index 00000000..bc085884
    --- /dev/null
    +++ b/screenshots/86box/fullscr-fs-game.png
    @@ -0,0 +1,3 @@
    +version https://git-lfs.github.com/spec/v1
    +oid sha256:92e0c86946d954ac748f60ce76e0a3ff93b91e161117cd0f7e1ede748a12c556
    +size 2337
    diff --git a/screenshots/dosbox/fullmode-fm-13h.png b/screenshots/dosbox/fullmode-fm-13h.png
    new file mode 100644
    index 00000000..a4bcfca1
    --- /dev/null
    +++ b/screenshots/dosbox/fullmode-fm-13h.png
    @@ -0,0 +1,3 @@
    +version https://git-lfs.github.com/spec/v1
    +oid sha256:d6113b57221371c09303db72f2ef4b5a0239d489245a346acdaed16a1c4a1f8d
    +size 3095
    diff --git a/screenshots/dosbox/fullmode-fm-desk.png b/screenshots/dosbox/fullmode-fm-desk.png
    new file mode 100644
    index 00000000..2c80adad
    --- /dev/null
    +++ b/screenshots/dosbox/fullmode-fm-desk.png
    @@ -0,0 +1,3 @@
    +version https://git-lfs.github.com/spec/v1
    +oid sha256:3fb9ab5f33faf4c02ed15a9d1d1280f18df993e40bc1d4bb203aa0bb7b6d3250
    +size 18114
    diff --git a/screenshots/dosbox/fullmode-fm-modex.png b/screenshots/dosbox/fullmode-fm-modex.png
    new file mode 100644
    index 00000000..a47e5dc3
    --- /dev/null
    +++ b/screenshots/dosbox/fullmode-fm-modex.png
    @@ -0,0 +1,3 @@
    +version https://git-lfs.github.com/spec/v1
    +oid sha256:5e9171a09e181e3c11e00b0ece62c33dff8e70d551d4a3737cf7ddb4b074c192
    +size 3718
    diff --git a/screenshots/dosbox/fullmode-fm-vbe.png b/screenshots/dosbox/fullmode-fm-vbe.png
    new file mode 100644
    index 00000000..e9949a68
    --- /dev/null
    +++ b/screenshots/dosbox/fullmode-fm-vbe.png
    @@ -0,0 +1,3 @@
    +version https://git-lfs.github.com/spec/v1
    +oid sha256:a7ef883b6c15df3e26d9fe71bbd2057a05d5f3641dc47664d957e16143996ec3
    +size 12283
    diff --git a/screenshots/dosbox/fullscr-fs-desk.png b/screenshots/dosbox/fullscr-fs-desk.png
    new file mode 100644
    index 00000000..c1006a4f
    --- /dev/null
    +++ b/screenshots/dosbox/fullscr-fs-desk.png
    @@ -0,0 +1,3 @@
    +version https://git-lfs.github.com/spec/v1
    +oid sha256:4fbc9cc1f7ba27a108766d613ab4618ea02b446bf8c2e9cda3fc151bb57e0645
    +size 17353
    diff --git a/screenshots/dosbox/fullscr-fs-game.png b/screenshots/dosbox/fullscr-fs-game.png
    new file mode 100644
    index 00000000..c4cbcf43
    --- /dev/null
    +++ b/screenshots/dosbox/fullscr-fs-game.png
    @@ -0,0 +1,3 @@
    +version https://git-lfs.github.com/spec/v1
    +oid sha256:b7615577d7414e1fa85e696ae3d392db685681e4ba3df7876c0c6f5a8775f21b
    +size 2337
    diff --git a/screenshots/masks/fullscr-fs-game.mask b/screenshots/masks/fullscr-fs-game.mask
    new file mode 100644
    index 00000000..87a75716
    --- /dev/null
    +++ b/screenshots/masks/fullscr-fs-game.mask
    @@ -0,0 +1,4 @@
    +# The first coin: its palette entry glows, so its colour depends on the
    +# frame the game was paused in (full screen in mode 13h: game pixels are
    +# screen pixels)
    +184 96 8 8
    diff --git a/src/apps/kpunch/dvxbasic/formrt/formrt.c b/src/apps/kpunch/dvxbasic/formrt/formrt.c
    index af6153c6..495756c0 100644
    --- a/src/apps/kpunch/dvxbasic/formrt/formrt.c
    +++ b/src/apps/kpunch/dvxbasic/formrt/formrt.c
    @@ -135,6 +135,13 @@ typedef struct {
         int     (*getIrq)(int);
         int     (*setBase)(int, int);
         int     (*setIrq)(int, int);
    +    int     (*getCts)(int);
    +    int     (*getDcd)(int);
    +    int     (*getDsr)(int);
    +    int     (*getDtr)(int);
    +    int     (*getRts)(int);
    +    int     (*setDtr)(int, bool);
    +    int     (*setRts)(int, bool);
     } SerApiT;
     
     static SerApiT sSerApi;
    @@ -143,6 +150,7 @@ static bool    sSerApiResolved = false;
     
     // Per-COM raw serial state: open tracking and terminal attach
     #define SER_RECV_BUF    4096
    +#define SER_BASIC_TRUE  (-1)   // BASIC's True, for the Ser functions that return one
     
     typedef struct {
         bool     opened;   // SerOpen succeeded and SerClose has not run
    @@ -626,14 +634,23 @@ void SerClose(int32_t com);
     void SerDetach(int32_t com);
     void SerFlush(int32_t com);
     int32_t SerGetBase(int32_t com);
    +int32_t SerGetCts(int32_t com);
    +int32_t SerGetDcd(int32_t com);
    +int32_t SerGetDsr(int32_t com);
    +int32_t SerGetDtr(int32_t com);
     int32_t SerGetIrq(int32_t com);
    +int32_t SerGetRts(int32_t com);
     int32_t SerGetUart(int32_t com);
     static void serIdlePoll(void *ctx);
    +static int32_t serLineGet(int (*const *get)(int), int32_t com);
    +static void serLineSet(int (*const *set)(int, bool), int32_t com, int32_t on);
     int32_t SerOpen(int32_t com, int32_t baud, int32_t dataBits, const char *parity, int32_t stopBits, int32_t handshake);
     const char *SerRead(int32_t com);
     static bool serResolveApi(void);
     void SerSetBase(int32_t com, int32_t base);
    +void SerSetDtr(int32_t com, int32_t on);
     void SerSetIrq(int32_t com, int32_t irq);
    +void SerSetRts(int32_t com, int32_t on);
     static int32_t serTermRead(void *ctx, uint8_t *buf, int32_t maxLen);
     static int32_t serTermWrite(void *ctx, const uint8_t *data, int32_t len);
     int32_t SerWrite(int32_t com, const char *data);
    @@ -6104,6 +6121,26 @@ int32_t SerGetBase(int32_t com) {
     }
     
     
    +int32_t SerGetCts(int32_t com) {
    +    return serLineGet(&sSerApi.getCts, com);
    +}
    +
    +
    +int32_t SerGetDcd(int32_t com) {
    +    return serLineGet(&sSerApi.getDcd, com);
    +}
    +
    +
    +int32_t SerGetDsr(int32_t com) {
    +    return serLineGet(&sSerApi.getDsr, com);
    +}
    +
    +
    +int32_t SerGetDtr(int32_t com) {
    +    return serLineGet(&sSerApi.getDtr, com);
    +}
    +
    +
     int32_t SerGetIrq(int32_t com) {
         if (!serResolveApi() || !sSerApi.getIrq) {
             return 0;
    @@ -6113,6 +6150,11 @@ int32_t SerGetIrq(int32_t com) {
     }
     
     
    +int32_t SerGetRts(int32_t com) {
    +    return serLineGet(&sSerApi.getRts, com);
    +}
    +
    +
     int32_t SerGetUart(int32_t com) {
         if (!serResolveApi() || !sSerApi.getUartType) {
             return 0;
    @@ -6134,6 +6176,29 @@ static void serIdlePoll(void *ctx) {
     }
     
     
    +// A modem line of an open port as BASIC's True / False (False on an error
    +// or a port that is not open).  get points at the sSerApi field, read
    +// after serResolveApi has filled it.
    +static int32_t serLineGet(int (*const *get)(int), int32_t com) {
    +    if (!serResolveApi() || !*get) {
    +        return 0;
    +    }
    +
    +    return (*get)(com - 1) > 0 ? SER_BASIC_TRUE : 0;
    +}
    +
    +
    +// Raise (on nonzero) or drop a modem line of an open port.  The line keeps
    +// its state when the port is closed.
    +static void serLineSet(int (*const *set)(int, bool), int32_t com, int32_t on) {
    +    if (!serResolveApi() || !*set) {
    +        return;
    +    }
    +
    +    (*set)(com - 1, on != 0);
    +}
    +
    +
     int32_t SerOpen(int32_t com, int32_t baud, int32_t dataBits, const char *parity, int32_t stopBits, int32_t handshake) {
         if (!serResolveApi()) {
             return 0;
    @@ -6153,7 +6218,7 @@ int32_t SerOpen(int32_t com, int32_t baud, int32_t dataBits, const char *parity,
             sSerAttach[idx].opened = true;
         }
     
    -    return -1;
    +    return SER_BASIC_TRUE;
     }
     
     
    @@ -6192,6 +6257,13 @@ static bool serResolveApi(void) {
         sSerApi.getIrq        = dlsym(NULL, DVX_SYM("rs232GetIrq"));
         sSerApi.setBase       = dlsym(NULL, DVX_SYM("rs232SetBase"));
         sSerApi.setIrq        = dlsym(NULL, DVX_SYM("rs232SetIrq"));
    +    sSerApi.getCts        = dlsym(NULL, DVX_SYM("rs232GetCts"));
    +    sSerApi.getDcd        = dlsym(NULL, DVX_SYM("rs232GetDcd"));
    +    sSerApi.getDsr        = dlsym(NULL, DVX_SYM("rs232GetDsr"));
    +    sSerApi.getDtr        = dlsym(NULL, DVX_SYM("rs232GetDtr"));
    +    sSerApi.getRts        = dlsym(NULL, DVX_SYM("rs232GetRts"));
    +    sSerApi.setDtr        = dlsym(NULL, DVX_SYM("rs232SetDtr"));
    +    sSerApi.setRts        = dlsym(NULL, DVX_SYM("rs232SetRts"));
     
         return sSerApi.open != NULL;
     }
    @@ -6206,6 +6278,11 @@ void SerSetBase(int32_t com, int32_t base) {
     }
     
     
    +void SerSetDtr(int32_t com, int32_t on) {
    +    serLineSet(&sSerApi.setDtr, com, on);
    +}
    +
    +
     void SerSetIrq(int32_t com, int32_t irq) {
         if (!serResolveApi() || !sSerApi.setIrq) {
             return;
    @@ -6215,6 +6292,11 @@ void SerSetIrq(int32_t com, int32_t irq) {
     }
     
     
    +void SerSetRts(int32_t com, int32_t on) {
    +    serLineSet(&sSerApi.setRts, com, on);
    +}
    +
    +
     // Raw serial terminal callbacks
     static int32_t serTermRead(void *ctx, uint8_t *buf, int32_t maxLen) {
         SerAttachT *sa = (SerAttachT *)ctx;
    diff --git a/src/apps/kpunch/progman/userHelp.dhs b/src/apps/kpunch/progman/userHelp.dhs
    index 8cdb0d88..2566b208 100644
    --- a/src/apps/kpunch/progman/userHelp.dhs
    +++ b/src/apps/kpunch/progman/userHelp.dhs
    @@ -548,6 +548,8 @@ Pictures are printed in black and white, as large as fits on the page. Printing
     .index Running Programs
     .index Starting Programs
     .index Switching Programs
    +.index Full screen
    +.index Whole screen
     .index Program Help
     .index About
     
    @@ -579,6 +581,10 @@ Most programs can only run once at a time. If you start a program that is alread
     .item Press Ctrl+Esc to open the Task Manager, pick the program and click Switch To.
     .endlist
     
    +.h2 Programs That Use the Whole Screen
    +
    +Some programs, games especially, can fill the whole screen instead of a window (many use Alt+Enter to switch). Your other programs keep running while they do. Alt+Tab and Ctrl+Esc still work and bring the desktop back; when you return to the program, it fills the screen again.
    +
     .h2 Closing a Program
     
     Close the program's main window, or choose Exit from its File menu. A program that has unsaved work usually asks whether you want to save it first. See Working with Windows for all the ways to close a window.
    diff --git a/src/include/basic/comm.bas b/src/include/basic/comm.bas
    index f5e3c9aa..1fbae76c 100644
    --- a/src/include/basic/comm.bas
    +++ b/src/include/basic/comm.bas
    @@ -84,7 +84,7 @@ DECLARE LIBRARY "basrt"
         ' Open a raw serial port. Returns True on success.
         DECLARE FUNCTION SerOpen(BYVAL com AS LONG, BYVAL baud AS LONG, BYVAL dataBits AS LONG, BYVAL parity AS STRING, BYVAL stopBits AS LONG, BYVAL handshake AS LONG) AS LONG
     
    -    ' Close a raw serial port.
    +    ' Close a raw serial port. DTR and RTS keep their state.
         DECLARE SUB SerClose(BYVAL com AS LONG)
     
         ' Write a string to the serial port. Returns True on success.
    @@ -105,6 +105,25 @@ DECLARE LIBRARY "basrt"
         ' Detach a raw serial port from a Terminal widget.
         DECLARE SUB SerDetach(BYVAL com AS LONG)
     
    +    ' =========================================================
    +    ' Modem Control Lines (raw serial port must be open)
    +    ' =========================================================
    +
    +    ' Raise (True) or drop (False) DTR or RTS. SerOpen raises both.
    +    ' SerClose leaves them as they are, so closing never hangs up a
    +    ' modem: to hang up, call SerSetDtr(com, False) before SerClose.
    +    DECLARE SUB SerSetDtr(BYVAL com AS LONG, BYVAL state AS LONG)
    +    DECLARE SUB SerSetRts(BYVAL com AS LONG, BYVAL state AS LONG)
    +
    +    ' True when the line is up. DTR and RTS are ours; CTS (clear to
    +    ' send), DSR (data set ready) and DCD (carrier detect) come from the
    +    ' other end.
    +    DECLARE FUNCTION SerGetDtr(BYVAL com AS LONG) AS LONG
    +    DECLARE FUNCTION SerGetRts(BYVAL com AS LONG) AS LONG
    +    DECLARE FUNCTION SerGetCts(BYVAL com AS LONG) AS LONG
    +    DECLARE FUNCTION SerGetDsr(BYVAL com AS LONG) AS LONG
    +    DECLARE FUNCTION SerGetDcd(BYVAL com AS LONG) AS LONG
    +
         ' =========================================================
         ' Packet Serial (reliable delivery + channel multiplexing)
         ' =========================================================
    diff --git a/src/installer/mkDist.sh b/src/installer/mkDist.sh
    index d62c0602..b6df174f 100755
    --- a/src/installer/mkDist.sh
    +++ b/src/installer/mkDist.sh
    @@ -32,6 +32,8 @@
     #   dist/DVX.ISO            ISO 9660 image of dist/cd
     #   dist/floppy/DISKnn.IMG  floppy images: Disk 1 = SETUP.EXE, README.TXT
     #                           and DVX.P01; Disk n = DVX.P0n
    +#   dist/sdk.zip            the SDK (bin/sdk) in DVX-SDK/, with the
    +#                           technical and BASIC references in docs/
     #
     # FLOPPY: 360K, 720K, 1.2M, 1.44M (default), 2.88M -- images of that
     # format -- or a number of bytes: the room on each disk, and folders
    @@ -41,8 +43,9 @@
     # DVX_DIST_BIN / DVX_DIST_OUT replace bin/ and dist/ (the DOS test
     # harness builds from its bin snapshot into its run folder).
     #
    -# Needs mtools (mformat, mcopy, mdir) and mkisofs on the build machine
    -# (the DOS test harness already needs mtools; mkcd.sh mkisofs).
    +# Needs mtools (mformat, mcopy, mdir), mkisofs and zip on the build
    +# machine (the DOS test harness already needs mtools; mkcd.sh mkisofs;
    +# zip keeps the SDK tools' execute bits).
     
     set -euo pipefail
     
    @@ -82,6 +85,8 @@ format() {
     }
     
     
    +command -v zip > /dev/null || fail "zip is missing (it packs dist/sdk.zip)"
    +
     for f in "${TOOL}" "${SETUP}"; do
         [ -x "${f}" ] || [ -f "${f}" ] || fail "${f} is missing (run make first)"
     done
    @@ -148,6 +153,15 @@ done
     
     rm -rf "${STAGE}"
     
    +# The SDK: bin/sdk and the references (make all writes docs/) in DVX-SDK/
    +SDK_STAGE="${DIST}/sdkstage"
    +mkdir -p "${SDK_STAGE}/DVX-SDK/docs"
    +cp -r "${BIN}/sdk/." "${SDK_STAGE}/DVX-SDK/"
    +cp "${ROOT}/docs/dvx_system_reference.html" "${ROOT}/docs/dvx_basic_reference.html" "${SDK_STAGE}/DVX-SDK/docs/"
    +(cd "${SDK_STAGE}" && zip -q -r -X "${DIST}/sdk.zip" DVX-SDK)
    +rm -rf "${SDK_STAGE}"
    +
     echo "mkDist: SETUP.EXE ${SETUP_SIZE} bytes; package $(stat -c %s "${DIST}/dvx.zip") bytes"
     echo "mkDist: CD: ${DIST}/DVX.ISO (SETUP.EXE $(stat -c %s "${DIST}/cd/SETUP.EXE") bytes with the package)"
     echo "mkDist: floppies (${FLOPPY}): ${DISKS} disk(s) in ${DIST}/floppy, ${ROOM1} bytes of the package on Disk 1"
    +echo "mkDist: SDK: ${DIST}/sdk.zip ($(stat -c %s "${DIST}/sdk.zip") bytes)"
    diff --git a/src/libs/kpunch/conn/conn.c b/src/libs/kpunch/conn/conn.c
    index 3a72ce10..585b7608 100644
    --- a/src/libs/kpunch/conn/conn.c
    +++ b/src/libs/kpunch/conn/conn.c
    @@ -833,7 +833,9 @@ static void secureStart(ConnT *c, uint32_t nowMs) {
     }
     
     
    -// Closing the port drops DTR, which also hangs up a modem.
    +// Ending the connection drops DTR, which also hangs up a modem.  Closing
    +// the port alone would leave DTR as it is; with DTR down when the port
    +// closes, it stays down.
     static void serialClose(ConnT *c) {
         if (!c->portOpen) {
             return;
    @@ -843,6 +845,7 @@ static void serialClose(ConnT *c) {
             rs232SetBreak(c->cfg.comPort, false);
         }
     
    +    rs232SetDtr(c->cfg.comPort, false);
         rs232Close(c->cfg.comPort);
         c->portOpen = false;
     }
    diff --git a/src/libs/kpunch/libdvx/apiref.dhs b/src/libs/kpunch/libdvx/apiref.dhs
    index 52051c54..b9208557 100644
    --- a/src/libs/kpunch/libdvx/apiref.dhs
    +++ b/src/libs/kpunch/libdvx/apiref.dhs
    @@ -2330,6 +2330,15 @@ Install an icon that is already in display pixel format (for example from dvxLoa
     .index dvxResLoadData
     .index dvxTextHash
     .index dvxChangeVideoMode
    +.index dvxExclusiveBegin
    +.index dvxExclusiveEnd
    +.index dvxExclusiveDisplay
    +.index dvxExclusiveBlitOps
    +.index dvxExclusiveFlush
    +.index dvxExclusivePresent8
    +.index dvxExclusiveSetPalette
    +.index dvxExclusiveWaitRetrace
    +.index Full-screen exclusive mode
     
     .h1 dvxApp.h -- Layer 5: Application API
     
    @@ -3131,6 +3140,79 @@ void dvxSetSoundHook(DvxSoundHookT fn);
     
     The function that plays the sounds, set by the sound library when it loads (NULL: silent).  Programs do not call it.
     
    +.h2 Full-Screen Exclusive Mode
    +
    +A window's program takes the whole screen in a video mode of its own (a game at 320x200, say) while every other program keeps running: windows keep painting into their own buffers, and the desktop is drawn again when the program lets go.  The owner's window must have the focus: when it loses it (Alt+Tab, Ctrl+Esc, a dialog, another window coming to the front) the desktop comes back and onChange hears ExclusiveSuspendedE; when the window has the focus again the mode is set again and onChange hears ExclusiveActiveE (draw the whole frame: the screen and the backbuffer start black).  While active every key but Alt+Tab, Ctrl+Esc and Alt+F4 goes to the window as in game input mode, the mouse cursor is not drawn, onMouse gets full-screen coordinates, dvxScreenshot saves the full screen, and dvxChangeVideoMode fails.  Closing the window, the program ending or being ended, and dvxExclusiveEnd give the screen back.  See the Writing Games guide.
    +
    +.code
    +typedef enum {
    +    ExclusiveActiveE,       // the screen is the program's: draw the whole frame
    +    ExclusiveSuspendedE,    // its window lost the focus: the desktop is back until it has the focus again
    +    ExclusiveEndedE         // the mode could not be set again: full screen is over
    +} ExclusiveStateE;
    +
    +typedef void (*ExclusiveFnT)(WindowT *win, ExclusiveStateE state, void *userData);
    +.endcode
    +
    +.h3 dvxExclusiveBegin
    +
    +.code
    +int32_t dvxExclusiveBegin(AppContextT *ctx, WindowT *win, int32_t width, int32_t height, int32_t bpp, ExclusiveFnT onChange, void *userData);
    +.endcode
    +
    +Take the whole screen for win (raised and focused): the smallest mode of at least width x height, at bpp when there is one (0: any depth).  At 8 bpp (or 0) a request that fits 320x200 gets VGA mode 13h, and one that fits 320x240 VGA Mode X (square pixels, two pages flipped at the retrace), unless the card has a VBE mode no larger.  onChange (may be NULL) hears ExclusiveActiveE before this returns.  A window that already has full screen gets the new size.
    +
    +Returns: DVX_EXCL_OK; DVX_EXCL_ERR_ARGS (no such window, or a bad size); DVX_EXCL_ERR_BUSY (another window has full screen); DVX_EXCL_ERR_MODE (no mode could be set; the desktop is back).
    +
    +.h3 dvxExclusiveEnd
    +
    +.code
    +void dvxExclusiveEnd(AppContextT *ctx, WindowT *win);
    +.endcode
    +
    +Give the screen back: the desktop's mode, palette and mouse, and a full repaint.  onChange is not called.  Nothing when win does not have full screen.
    +
    +.h3 dvxExclusiveDisplay / dvxExclusiveBlitOps
    +
    +.code
    +DisplayT       *dvxExclusiveDisplay(AppContextT *ctx);
    +const BlitOpsT *dvxExclusiveBlitOps(AppContextT *ctx);
    +.endcode
    +
    +The full-screen display (its size may be larger than asked for) and the span operations for its pixel format while active; NULL while suspended or without full screen.  Draw into the display's backBuf, with DVX's draw functions if you like, then dvxExclusiveFlush.
    +
    +.h3 dvxExclusiveFlush
    +
    +.code
    +void dvxExclusiveFlush(AppContextT *ctx, const RectT *r);
    +.endcode
    +
    +Copy a rectangle of the backbuffer to the screen (NULL: all of it).  Nothing when not active.  In Mode X a whole-screen flush goes to the hidden page, which is shown from the next vertical retrace (the call waits for it): no tearing; a smaller rectangle goes to the page on show.
    +
    +.h3 dvxExclusivePresent8
    +
    +.code
    +bool dvxExclusivePresent8(AppContextT *ctx, const uint8_t *src, int32_t srcW, int32_t srcH, int32_t srcPitch);
    +.endcode
    +
    +Show an 8-bit picture of palette indexes on the whole screen: scaled by the largest whole factor that fits, centred on black, through the palette in any pixel format, and flushed (the whole screen, so Mode X flips pages).  Returns false (and does nothing) when not active, so a caller falls back to drawing in its window.
    +
    +.h3 dvxExclusiveSetPalette
    +
    +.code
    +void dvxExclusiveSetPalette(AppContextT *ctx, const uint8_t *pal, int32_t first, int32_t count);
    +.endcode
    +
    +Set palette entries first..first+count-1 (pal is the whole 256-entry table, R, G, B bytes per entry).  At 8 bpp they go to the DAC at once, so a picture on screen changes colour; at other depths the next dvxExclusivePresent8 uses them.  The palette is kept while suspended and set again on resuming; the desktop's palette comes back whenever the desktop does.
    +
    +.h3 dvxExclusiveWaitRetrace
    +
    +.code
    +void dvxExclusiveWaitRetrace(void);
    +.endcode
    +
    +Wait for the start of the next vertical retrace (at most two frames' time), before a flush, so the frame is not torn.
    +
     .h2 Accelerators
     
     .h3 dvxCreateAccelTable
    diff --git a/src/libs/kpunch/libdvx/dvxApp.c b/src/libs/kpunch/libdvx/dvxApp.c
    index 6bfde48f..138a77be 100644
    --- a/src/libs/kpunch/libdvx/dvxApp.c
    +++ b/src/libs/kpunch/libdvx/dvxApp.c
    @@ -242,6 +242,12 @@ static bool drawPopupRow(AppContextT *ctx, DisplayT *d, const BlitOpsT *ops, int
     static void drawSysMenu(AppContextT *ctx, DisplayT *d, const BlitOpsT *ops, const RectT *clipTo);
     static void entropyFeed(const uint8_t *event, int32_t len);
     static void enumModeCb(int32_t w, int32_t h, int32_t bpp, void *userData);
    +static bool exclusiveEnter(AppContextT *ctx);
    +static void exclusiveLeave(AppContextT *ctx);
    +static void exclusiveLutSet(ExclusiveStateT *ex, int32_t first, int32_t count);
    +static void exclusiveMouse(AppContextT *ctx);
    +static void exclusiveNotify(AppContextT *ctx, WindowT *win, ExclusiveFnT fn, void *userData, ExclusiveStateE state);
    +static void exclusiveSync(AppContextT *ctx);
     static void executeSysMenuCmd(AppContextT *ctx, int32_t cmd);
     static WindowT *findWindowById(AppContextT *ctx, int32_t id);
     static uint8_t *finishLoadImage(const DisplayT *d, uint8_t *rgb, int32_t imgW, int32_t imgH, int32_t *outW, int32_t *outH, int32_t *outPitch);
    @@ -289,13 +295,15 @@ static void refreshMinimizedIcons(AppContextT *ctx);
     static void releaseKeyPressedButton(AppContextT *ctx);
     static int32_t remapIndexForLower(int32_t index, int32_t lowered);
     static void repositionWindow(AppContextT *ctx, WindowT *win, int32_t x, int32_t y, int32_t w, int32_t h);
    -static int32_t saveImageBusy(AppContextT *ctx, const uint8_t *data, int32_t w, int32_t h, int32_t pitch, const char *path);
    +static int32_t saveImageAs(const DisplayT *fmt, const uint8_t *data, int32_t w, int32_t h, int32_t pitch, const char *path);
    +static int32_t saveImageBusy(AppContextT *ctx, const DisplayT *fmt, const uint8_t *data, int32_t w, int32_t h, int32_t pitch, const char *path);
     static int32_t scrollToShow(int32_t value, int32_t pos, int32_t size, int32_t view);
     static void scrollWidgetIntoView(AppContextT *ctx, WindowT *win, WidgetT *w);
     static void sendMouseToWindow(AppContextT *ctx, WindowT *win, int32_t mx, int32_t my, int32_t buttons);
     static void setWindowScroll(AppContextT *ctx, WindowT *win, ScrollbarT *sb, int32_t value);
     static const char *splitMenuLabel(const char *label, char leftBuf[MAX_MENU_LABEL]);
     static inline void storePixel(uint8_t *row, int32_t x, int32_t bytesPerPx, uint32_t px);
    +static bool takesGameKeys(const AppContextT *ctx, const WindowT *win);
     static int32_t tileSlotPos(int32_t slot, int32_t tile, int32_t screen);
     static void toggleMaximize(AppContextT *ctx, WindowT *win);
     static void updateCursorShape(AppContextT *ctx);
    @@ -1096,6 +1104,14 @@ static void compositeAndFlush(AppContextT *ctx) {
         DirtyListT   *dl  = &ctx->dirty;
         WindowStackT *ws  = &ctx->stack;
     
    +    // A full-screen program has the screen: nothing is drawn (windows keep
    +    // painting into their own buffers), and giving the screen back
    +    // repaints all of it.
    +    if (ctx->exclusive.active) {
    +        dirtyListClear(dl);
    +        return;
    +    }
    +
         // Merge overlapping dirty rects to reduce flush count
         dirtyListMerge(dl);
     
    @@ -1472,6 +1488,10 @@ static void destroyWindowPrep(AppContextT *ctx, WindowT *win) {
             sOpenSoundWinId = 0;
         }
     
    +    // A window going away gives full screen back (its program is told
    +    // nothing: it is closing the window or being ended).
    +    dvxExclusiveEnd(ctx, win);
    +
         // Clear the modal pointer if it was this window, even when deferring
         // -- a hidden pending-destroy modal must not keep eating input.
         if (ctx->modalWindow == win) {
    @@ -1647,6 +1667,12 @@ static void dispatchEvents(AppContextT *ctx) {
             ctx->modalWindow = NULL;
         }
     
    +    // Full screen: the mouse is the program's alone.
    +    if (ctx->exclusive.active) {
    +        exclusiveMouse(ctx);
    +        return;
    +    }
    +
         // Mouse movement always dirties old and new cursor positions
         if (mx != ctx->prevMouseX || my != ctx->prevMouseY) {
             dirtyCursorArea(ctx, ctx->prevMouseX, ctx->prevMouseY);
    @@ -2020,7 +2046,7 @@ static void dispatchKey(AppContextT *ctx, int32_t scancode, int32_t ascii, int32
         // Game input: the three keys above are the only ones DVX keeps; every
         // other key goes straight to the game, unless a menu or the keyboard
         // move/size mode (opened with the mouse) is taking keys.
    -    if (win && win->gameInput && !ctx->popup.active && !ctx->sysMenu.active && ctx->kbMoveResize.mode == KbModeNoneE) {
    +    if (win && takesGameKeys(ctx, win) && !ctx->popup.active && !ctx->sysMenu.active && ctx->kbMoveResize.mode == KbModeNoneE) {
             if (win->onKey) {
                 WIN_CALLBACK(ctx, win, win->onKey(win, key, shiftFlags));
             }
    @@ -2158,7 +2184,7 @@ static void dispatchRawKey(AppContextT *ctx, const RawKeyEventT *evt) {
             WIN_CALLBACK(ctx, win, win->onKeyUp(win, scan | KEY_EXT_FLAG, platformKeyboardGetModifiers()));
         }
     
    -    if (win->gameInput && win->onRawKey && !win->destroyPending) {
    +    if (takesGameKeys(ctx, win) && win->onRawKey && !win->destroyPending) {
             WIN_CALLBACK(ctx, win, win->onRawKey(win, evt));
         }
     }
    @@ -2488,6 +2514,12 @@ void dvxCascadeWindows(AppContextT *ctx) {
     // screen are clamped.  On failure, the old mode is restored.
     
     int32_t dvxChangeVideoMode(AppContextT *ctx, int32_t requestedW, int32_t requestedH, int32_t preferredBpp) {
    +    // Not while a full-screen program has the screen (the desktop mode is
    +    // set again when it lets go).
    +    if (ctx->exclusive.active) {
    +        return -1;
    +    }
    +
         // Save old state for rollback
         DisplayT oldDisplay = ctx->display;
     
    @@ -2801,6 +2833,177 @@ void dvxDestroyWindowNow(AppContextT *ctx, WindowT *win) {
     }
     
     
    +// ============================================================
    +// dvxExclusiveBegin / dvxExclusiveEnd and the full-screen display
    +// ============================================================
    +//
    +// The owner's window must have the focus; exclusiveSync (every dvxUpdate)
    +// suspends and resumes full screen as the focus leaves and comes back.
    +
    +int32_t dvxExclusiveBegin(AppContextT *ctx, WindowT *win, int32_t width, int32_t height, int32_t bpp, ExclusiveFnT onChange, void *userData) {
    +    ExclusiveStateT *ex = &ctx->exclusive;
    +
    +    if (!win || windowIndex(ctx, win) < 0 || win->destroyPending || width <= 0 || height <= 0) {
    +        return DVX_EXCL_ERR_ARGS;
    +    }
    +
    +    if (ex->owner && ex->owner != win) {
    +        return DVX_EXCL_ERR_BUSY;
    +    }
    +
    +    // A new size for the owner: let go of the old mode first.
    +    exclusiveLeave(ctx);
    +
    +    if (!ex->owner) {
    +        dvxGeneratePalette(ex->palette);
    +    }
    +
    +    ex->owner    = win;
    +    ex->reqW     = width;
    +    ex->reqH     = height;
    +    ex->reqBpp   = bpp;
    +    ex->onChange = onChange;
    +    ex->userData = userData;
    +
    +    if (win->minimized) {
    +        dvxRestoreWindow(ctx, win);
    +    }
    +
    +    raiseAndFocus(ctx, windowIndex(ctx, win));
    +
    +    if (!exclusiveEnter(ctx)) {
    +        ex->owner = NULL;
    +        return DVX_EXCL_ERR_MODE;
    +    }
    +
    +    return DVX_EXCL_OK;
    +}
    +
    +
    +const BlitOpsT *dvxExclusiveBlitOps(AppContextT *ctx) {
    +    return ctx->exclusive.active ? &ctx->exclusive.blitOps : NULL;
    +}
    +
    +
    +DisplayT *dvxExclusiveDisplay(AppContextT *ctx) {
    +    return ctx->exclusive.active ? &ctx->exclusive.display : NULL;
    +}
    +
    +
    +void dvxExclusiveEnd(AppContextT *ctx, WindowT *win) {
    +    if (!win || ctx->exclusive.owner != win) {
    +        return;
    +    }
    +
    +    exclusiveLeave(ctx);
    +    ctx->exclusive.owner = NULL;
    +}
    +
    +
    +void dvxExclusiveFlush(AppContextT *ctx, const RectT *r) {
    +    DisplayT *d = &ctx->exclusive.display;
    +
    +    if (!ctx->exclusive.active) {
    +        return;
    +    }
    +
    +    RectT screen = { 0, 0, d->width, d->height };
    +    RectT clip   = screen;
    +
    +    if (r && !rectIntersect(r, &screen, &clip)) {
    +        return;
    +    }
    +
    +    platformVideoExclusiveFlush(d, &clip);
    +}
    +
    +
    +// Whole-factor scaling, one source row expanded once and copied for the
    +// repeated rows.  The backbuffer is cleared (borders black) whenever the
    +// picture's place changes.  The whole screen is flushed, so a
    +// page-flipped mode shows the frame at once and without tearing.
    +bool dvxExclusivePresent8(AppContextT *ctx, const uint8_t *src, int32_t srcW, int32_t srcH, int32_t srcPitch) {
    +    ExclusiveStateT *ex = &ctx->exclusive;
    +    DisplayT        *d  = &ex->display;
    +
    +    if (!ex->active || !src || srcW <= 0 || srcH <= 0) {
    +        return false;
    +    }
    +
    +    int32_t scale = DVX_MAX(1, DVX_MIN(d->width / srcW, d->height / srcH));
    +    int32_t outW  = DVX_MIN(srcW * scale, d->width);
    +    int32_t outH  = DVX_MIN(srcH * scale, d->height);
    +    int32_t bpp   = d->format.bytesPerPixel;
    +    RectT   place = { (d->width - outW) / 2, (d->height - outH) / 2, outW, outH };
    +
    +    if (memcmp(&place, &ex->presentRect, sizeof(place)) != 0) {
    +        memset(d->backBuf, 0, (size_t)d->pitch * (size_t)d->height);
    +        ex->presentRect = place;
    +    }
    +
    +    for (int32_t y = 0; y < outH; y++) {
    +        uint8_t *dst = d->backBuf + (size_t)(place.y + y) * (size_t)d->pitch + (size_t)place.x * (size_t)bpp;
    +
    +        if (y % scale != 0) {
    +            memcpy(dst, dst - d->pitch, (size_t)outW * (size_t)bpp);
    +            continue;
    +        }
    +
    +        const uint8_t *row = src + (size_t)(y / scale) * (size_t)srcPitch;
    +
    +        if (bpp == 1 && scale == 1) {
    +            memcpy(dst, row, (size_t)outW);
    +            continue;
    +        }
    +
    +        int32_t x = 0;
    +
    +        for (int32_t sx = 0; x < outW; sx++) {
    +            uint32_t px = bpp == 1 ? row[sx] : ex->lut[row[sx]];
    +
    +            for (int32_t k = 0; k < scale && x < outW; k++, x++) {
    +                storePixel(dst, x, bpp, px);
    +            }
    +        }
    +    }
    +
    +    dvxExclusiveFlush(ctx, NULL);
    +    return true;
    +}
    +
    +
    +void dvxExclusiveSetPalette(AppContextT *ctx, const uint8_t *pal, int32_t first, int32_t count) {
    +    ExclusiveStateT *ex = &ctx->exclusive;
    +
    +    if (!pal || first < 0 || first >= PAL_ENTRIES) {
    +        return;
    +    }
    +
    +    count = DVX_MIN(count, PAL_ENTRIES - first);
    +
    +    if (count <= 0) {
    +        return;
    +    }
    +
    +    memcpy(ex->palette + first * PAL_CHANNELS, pal + first * PAL_CHANNELS, (size_t)count * PAL_CHANNELS);
    +
    +    if (!ex->active) {
    +        return;
    +    }
    +
    +    if (ex->display.format.bitsPerPixel == PALETTE_BPP) {
    +        platformVideoSetPalette(ex->palette, first, count);
    +    } else {
    +        exclusiveLutSet(ex, first, count);
    +    }
    +}
    +
    +
    +void dvxExclusiveWaitRetrace(void) {
    +    platformVideoWaitRetrace();
    +}
    +
    +
     void dvxFitWindow(AppContextT *ctx, WindowT *win) {
         fitWindow(ctx, win, true, true);
     }
    @@ -3568,28 +3771,11 @@ void dvxRun(AppContextT *ctx) {
     // dvxWindowScreenshot are convenience wrappers around it.
     
     int32_t dvxSaveImage(const AppContextT *ctx, const uint8_t *data, int32_t w, int32_t h, int32_t pitch, const char *path) {
    -    if (!ctx || !data || !path || w <= 0 || h <= 0) {
    +    if (!ctx) {
             return -1;
         }
     
    -    uint8_t *rgb = bufferToRgb(&ctx->display, data, w, h, pitch);
    -
    -    if (!rgb) {
    -        return -1;
    -    }
    -
    -    const char *ext = strrchr(path, '.');
    -    int         ok;
    -
    -    if (ext && strcasecmp(ext, BMP_EXT) == 0) {
    -        ok = stbi_write_bmp(path, w, h, RGB_CHANNELS, rgb);
    -    } else {
    -        ok = stbi_write_png(path, w, h, RGB_CHANNELS, rgb, w * RGB_CHANNELS);
    -    }
    -
    -    free(rgb);
    -
    -    return ok ? 0 : -1;
    +    return saveImageAs(&ctx->display, data, w, h, pitch, path);
     }
     
     
    @@ -3633,9 +3819,26 @@ bool dvxSaveTheme(const AppContextT *ctx, const char *filename) {
     // LFB since we only write to the LFB, never read.
     
     int32_t dvxScreenshot(AppContextT *ctx, const char *path) {
    -    DisplayT *d = &ctx->display;
    +    if (!ctx->exclusive.active) {
    +        const DisplayT *d = &ctx->display;
     
    -    return saveImageBusy(ctx, d->backBuf, d->width, d->height, d->pitch, path);
    +        return saveImageBusy(ctx, d, d->backBuf, d->width, d->height, d->pitch, path);
    +    }
    +
    +    // While a program has full screen the screen is its display: read
    +    // back from video memory where the backbuffer is not a copy of it.
    +    const DisplayT *d      = &ctx->exclusive.display;
    +    uint8_t        *screen = (uint8_t *)malloc((size_t)d->width * (size_t)d->height);
    +    int32_t         rc;
    +
    +    if (screen && platformVideoExclusiveCapture(d, screen)) {
    +        rc = saveImageBusy(ctx, d, screen, d->width, d->height, d->width, path);
    +    } else {
    +        rc = saveImageBusy(ctx, d, d->backBuf, d->width, d->height, d->pitch, path);
    +    }
    +
    +    free(screen);
    +    return rc;
     }
     
     
    @@ -3895,6 +4098,7 @@ void dvxShowWindow(AppContextT *ctx, WindowT *win) {
     
     
     void dvxShutdown(AppContextT *ctx) {
    +    dvxExclusiveEnd(ctx, ctx->exclusive.owner);
         platformTimerShutdown();
         platformKeyHookShutdown();
     
    @@ -4135,6 +4339,7 @@ bool dvxUpdate(AppContextT *ctx) {
         pollMouse(ctx);
         pollKeyboard(ctx);
         dispatchEvents(ctx);
    +    exclusiveSync(ctx);
         updateTooltip(ctx);
         pollWidgets(ctx);
     
    @@ -4209,7 +4414,7 @@ int32_t dvxWindowScreenshot(AppContextT *ctx, WindowT *win, const char *path) {
         ctx->dispatchDepth++;
         flushWindowPaint(ctx, win);
     
    -    int32_t rc = (win->destroyPending || !win->contentBuf) ? -1 : saveImageBusy(ctx, win->contentBuf, win->contentW, win->contentH, win->contentPitch, path);
    +    int32_t rc = (win->destroyPending || !win->contentBuf) ? -1 : saveImageBusy(ctx, &ctx->display, win->contentBuf, win->contentW, win->contentH, win->contentPitch, path);
     
         ctx->dispatchDepth--;
     
    @@ -4267,6 +4472,158 @@ static void enumModeCb(int32_t w, int32_t h, int32_t bpp, void *userData) {
     }
     
     
    +// ============================================================
    +// Full screen: taking and giving back the screen
    +// ============================================================
    +
    +// Set the owner's mode and hand it the screen.  False (the desktop is back
    +// and repainted) when no mode could be set.
    +static bool exclusiveEnter(AppContextT *ctx) {
    +    ExclusiveStateT *ex = &ctx->exclusive;
    +    DisplayT        *d  = &ex->display;
    +
    +    if (platformVideoExclusiveBegin(d, ex->reqW, ex->reqH, ex->reqBpp) != 0) {
    +        dvxLog("Video: no %ldx%ld full-screen mode", (long)ex->reqW, (long)ex->reqH);
    +        platformVideoExclusiveEnd(d, &ctx->display);
    +        dirtyListInit(&ctx->dirty);
    +        dirtyListAdd(&ctx->dirty, 0, 0, ctx->display.width, ctx->display.height);
    +        return false;
    +    }
    +
    +    dvxLog("Video: full screen %ldx%ld at %ld bpp", (long)d->width, (long)d->height, (long)d->format.bitsPerPixel);
    +
    +    // Menus, a drag or a tooltip would be invisible and in the way.
    +    closeWindowTransients(ctx, NULL);
    +    dvxInvalidateTooltip(ctx, NULL);
    +
    +    if (ctx->stack.dragWindow >= 0) {
    +        wmDragEnd(&ctx->stack);
    +    }
    +
    +    if (ctx->stack.resizeWindow >= 0) {
    +        wmResizeEnd(&ctx->stack);
    +    }
    +
    +    d->palette = ex->palette;
    +    drawInit(&ex->blitOps, d);
    +
    +    if (d->format.bitsPerPixel == PALETTE_BPP) {
    +        platformVideoSetPalette(ex->palette, 0, PAL_ENTRIES);
    +    } else {
    +        exclusiveLutSet(ex, 0, PAL_ENTRIES);
    +    }
    +
    +    memset(&ex->presentRect, 0, sizeof(ex->presentRect));
    +
    +    ex->savedMouseX = ctx->mouseX;
    +    ex->savedMouseY = ctx->mouseY;
    +    platformMouseInit(d->width, d->height);
    +    platformMouseWarp(d->width / 2, d->height / 2);
    +    ctx->mouseX     = d->width / 2;
    +    ctx->mouseY     = d->height / 2;
    +    ctx->prevMouseX = ctx->mouseX;
    +    ctx->prevMouseY = ctx->mouseY;
    +
    +    ex->active = true;
    +    exclusiveNotify(ctx, ex->owner, ex->onChange, ex->userData, ExclusiveActiveE);
    +    return true;
    +}
    +
    +
    +// Give the desktop back (the owner keeps full screen, suspended): the
    +// desktop's mode, mouse and a full repaint.  Nothing when not active.
    +static void exclusiveLeave(AppContextT *ctx) {
    +    ExclusiveStateT *ex = &ctx->exclusive;
    +
    +    if (!ex->active) {
    +        return;
    +    }
    +
    +    ex->active = false;
    +
    +    if (platformVideoExclusiveEnd(&ex->display, &ctx->display) != 0) {
    +        dvxLog("Video: the desktop mode could not be set again");
    +    }
    +
    +    platformMouseInit(ctx->display.width, ctx->display.height);
    +    platformMouseWarp(ex->savedMouseX, ex->savedMouseY);
    +    ctx->mouseX     = ex->savedMouseX;
    +    ctx->mouseY     = ex->savedMouseY;
    +    ctx->prevMouseX = ctx->mouseX;
    +    ctx->prevMouseY = ctx->mouseY;
    +
    +    resetClipRect(&ctx->display);
    +    dirtyListInit(&ctx->dirty);
    +    dirtyListAdd(&ctx->dirty, 0, 0, ctx->display.width, ctx->display.height);
    +}
    +
    +
    +// Palette entries first..first+count-1 as pixels of the full-screen format
    +// (above 8 bpp), for dvxExclusivePresent8.
    +static void exclusiveLutSet(ExclusiveStateT *ex, int32_t first, int32_t count) {
    +    for (int32_t i = first; i < first + count; i++) {
    +        const uint8_t *rgb = ex->palette + i * PAL_CHANNELS;
    +
    +        ex->lut[i] = packColor(&ex->display, rgb[0], rgb[1], rgb[2]);
    +    }
    +}
    +
    +
    +// Full screen: any mouse change goes to the owner in screen coordinates.
    +static void exclusiveMouse(AppContextT *ctx) {
    +    WindowT *win = ctx->exclusive.owner;
    +
    +    if (ctx->mouseX == ctx->prevMouseX && ctx->mouseY == ctx->prevMouseY && ctx->mouseButtons == ctx->prevMouseButtons) {
    +        return;
    +    }
    +
    +    if (win->onMouse && !win->destroyPending) {
    +        WIN_CALLBACK(ctx, win, win->onMouse(win, ctx->mouseX, ctx->mouseY, ctx->mouseButtons));
    +    }
    +}
    +
    +
    +// Tell a full-screen owner what happened, under its app id.
    +static void exclusiveNotify(AppContextT *ctx, WindowT *win, ExclusiveFnT fn, void *userData, ExclusiveStateE state) {
    +    if (fn && win && !win->destroyPending) {
    +        WIN_CALLBACK(ctx, win, fn(win, state, userData));
    +    }
    +}
    +
    +
    +// Once a frame: full screen follows the owner's focus.  Losing it (Alt+Tab,
    +// Ctrl+Esc, a dialog, minimizing) suspends; getting it back sets the mode
    +// again, and when that fails full screen is over.
    +static void exclusiveSync(AppContextT *ctx) {
    +    ExclusiveStateT *ex  = &ctx->exclusive;
    +    WindowT         *win = ex->owner;
    +
    +    if (!win) {
    +        return;
    +    }
    +
    +    bool focused = focusedWindow(ctx) == win && win->visible && !win->minimized && !win->destroyPending;
    +
    +    if (focused == ex->active) {
    +        return;
    +    }
    +
    +    if (!focused) {
    +        exclusiveLeave(ctx);
    +        exclusiveNotify(ctx, win, ex->onChange, ex->userData, ExclusiveSuspendedE);
    +        return;
    +    }
    +
    +    if (!exclusiveEnter(ctx)) {
    +        ExclusiveFnT fn       = ex->onChange;
    +        void        *userData = ex->userData;
    +
    +        ex->owner = NULL;
    +        exclusiveNotify(ctx, win, fn, userData, ExclusiveEndedE);
    +    }
    +}
    +
    +
     // ============================================================
     // executeSysMenuCmd
     // ============================================================
    @@ -5178,7 +5535,7 @@ static void interactiveSaveBuffer(AppContextT *ctx, const char *title, const uin
         memcpy(snap, src, size);
     
         if (dvxFileDialog(ctx, title, FD_SAVE, NULL, filters, DVX_ARRAY_LEN(filters), path, sizeof(path))) {
    -        if (saveImageBusy(ctx, snap, w, h, pitch, path) != 0) {
    +        if (saveImageBusy(ctx, &ctx->display, snap, w, h, pitch, path) != 0) {
                 dvxErrorBox(ctx, title, SCREENSHOT_FAIL_MSG);
             }
         }
    @@ -5273,7 +5630,7 @@ static void keyFocusSync(AppContextT *ctx) {
     
         ctx->keyFocusWinId = winId;
     
    -    if (old && old->gameInput && old->onRawKey && !old->destroyPending) {
    +    if (old && takesGameKeys(ctx, old) && old->onRawKey && !old->destroyPending) {
             uint8_t held[KEY_RAW_COUNT];
     
             platformKeyStateGet(held);
    @@ -6069,14 +6426,42 @@ static void repositionWindow(AppContextT *ctx, WindowT *win, int32_t x, int32_t
     }
     
     
    +// dvxSaveImage's body, for pixels in fmt's format (the desktop's, or a
    +// full-screen program's for dvxScreenshot).
    +static int32_t saveImageAs(const DisplayT *fmt, const uint8_t *data, int32_t w, int32_t h, int32_t pitch, const char *path) {
    +    if (!data || !path || w <= 0 || h <= 0) {
    +        return -1;
    +    }
    +
    +    uint8_t *rgb = bufferToRgb(fmt, data, w, h, pitch);
    +
    +    if (!rgb) {
    +        return -1;
    +    }
    +
    +    const char *ext = strrchr(path, '.');
    +    int         ok;
    +
    +    if (ext && strcasecmp(ext, BMP_EXT) == 0) {
    +        ok = stbi_write_bmp(path, w, h, RGB_CHANNELS, rgb);
    +    } else {
    +        ok = stbi_write_png(path, w, h, RGB_CHANNELS, rgb, w * RGB_CHANNELS);
    +    }
    +
    +    free(rgb);
    +
    +    return ok ? 0 : -1;
    +}
    +
    +
     // dvxSaveImage with the busy cursor shown, restoring the caller's busy
     // state afterwards (a caller already busy stays busy).
    -static int32_t saveImageBusy(AppContextT *ctx, const uint8_t *data, int32_t w, int32_t h, int32_t pitch, const char *path) {
    +static int32_t saveImageBusy(AppContextT *ctx, const DisplayT *fmt, const uint8_t *data, int32_t w, int32_t h, int32_t pitch, const char *path) {
         bool wasBusy = ctx->busy;
     
         dvxSetBusy(ctx, true);
     
    -    int32_t result = dvxSaveImage(ctx, data, w, h, pitch, path);
    +    int32_t result = saveImageAs(fmt, data, w, h, pitch, path);
     
         dvxSetBusy(ctx, wasBusy);
         return result;
    @@ -6207,6 +6592,13 @@ static inline void storePixel(uint8_t *row, int32_t x, int32_t bytesPerPx, uint3
     }
     
     
    +// Keys go to win as in game input mode: it asked for them, or it has full
    +// screen (menus there could not be seen).
    +static bool takesGameKeys(const AppContextT *ctx, const WindowT *win) {
    +    return win->gameInput || win == ctx->exclusive.owner;
    +}
    +
    +
     // Position of tile slot along an axis: slot * tile, pulled back so the
     // tile stays on screen when minimum-size tiles overflow it (the last
     // tiles then overlap instead of landing off-screen).
    @@ -6337,6 +6729,11 @@ static void updateTooltip(AppContextT *ctx) {
         int32_t        mx  = ctx->mouseX;
         int32_t        my  = ctx->mouseY;
     
    +    // No tooltips over a full-screen program.
    +    if (ctx->exclusive.active) {
    +        return;
    +    }
    +
         // Mouse moved or button pressed  -- hide tooltip and reset timer
         if (mx != ctx->prevMouseX || my != ctx->prevMouseY || ctx->mouseButtons) {
             dvxInvalidateTooltip(ctx, NULL);
    diff --git a/src/libs/kpunch/libdvx/dvxApp.h b/src/libs/kpunch/libdvx/dvxApp.h
    index d186e653..61e4769f 100644
    --- a/src/libs/kpunch/libdvx/dvxApp.h
    +++ b/src/libs/kpunch/libdvx/dvxApp.h
    @@ -43,10 +43,41 @@
     #include "dvxPlat.h"
     #include "dvxVideo.h"
     #include "dvxDraw.h"
    +#include "dvxPal.h"
     #include "dvxComp.h"
     #include "dvxWm.h"
     
     
    +// ============================================================
    +// Full-screen exclusive mode (state; the API is further down)
    +// ============================================================
    +
    +// What happened to a program's full screen (its ExclusiveFnT is told).
    +typedef enum {
    +    ExclusiveActiveE,       // the screen is the program's: draw the whole frame
    +    ExclusiveSuspendedE,    // its window lost the focus: the desktop is back until it has the focus again
    +    ExclusiveEndedE         // the mode could not be set again: full screen is over
    +} ExclusiveStateE;
    +
    +typedef void (*ExclusiveFnT)(WindowT *win, ExclusiveStateE state, void *userData);
    +
    +typedef struct {
    +    WindowT     *owner;                               // window holding full screen (NULL: none)
    +    bool         active;                              // the screen is the owner's now (false: suspended)
    +    int32_t      reqW;                                // what the owner asked for
    +    int32_t      reqH;
    +    int32_t      reqBpp;
    +    ExclusiveFnT onChange;
    +    void        *userData;
    +    DisplayT     display;                             // the full-screen display while active
    +    BlitOpsT     blitOps;                             // span operations for display's format
    +    uint8_t      palette[PAL_ENTRIES * PAL_CHANNELS]; // the owner's palette (the DAC at 8 bpp)
    +    uint32_t     lut[PAL_ENTRIES];                    // palette index -> pixel, above 8 bpp
    +    RectT        presentRect;                         // where dvxExclusivePresent8 last put the picture
    +    int32_t      savedMouseX;                         // desktop mouse position to go back to
    +    int32_t      savedMouseY;
    +} ExclusiveStateT;
    +
     // ============================================================
     // Application context
     // ============================================================
    @@ -156,6 +187,7 @@ typedef struct AppContextT {
         int32_t         wallpaperPitch;      // bytes per row
         char            wallpaperPath[DVX_MAX_PATH];  // source image path (empty = none)
         WallpaperModeE  wallpaperMode;       // stretch, tile, or center
    +    ExclusiveStateT exclusive;           // full-screen exclusive mode (dvxExclusiveBegin)
     } AppContextT;
     
     // Initialize the entire GUI stack: video mode, input devices, font,
    @@ -223,6 +255,67 @@ void dvxPlaySoundWait(DvxSoundE event);
     // The default beep: dvxPlaySound(SoundDefaultE).
     void dvxBeep(void);
     
    +// ============================================================
    +// Full-screen exclusive mode
    +// ============================================================
    +//
    +// A window's program takes the whole screen in a video mode of its own
    +// (a game at 320x200, say).  Every other program keeps running: windows
    +// keep painting into their own buffers, and the desktop is drawn again
    +// when the program lets go.  The owner's window must have the focus:
    +// when it loses it (Alt+Tab, Ctrl+Esc, a dialog, any other window coming
    +// to the front) the desktop comes back and onChange hears
    +// ExclusiveSuspendedE; when the window has the focus again the mode is set
    +// again and onChange hears ExclusiveActiveE (draw the whole frame: the
    +// screen and the backbuffer start black).  While active, every key but
    +// Alt+Tab, Ctrl+Esc and Alt+F4 goes to the window as in game input mode,
    +// the mouse cursor is not drawn, and onMouse gets full-screen coordinates.
    +// Closing the window, the program ending or being ended, and
    +// dvxExclusiveEnd all give the screen back.
    +
    +#define DVX_EXCL_OK         0
    +#define DVX_EXCL_ERR_MODE   (-1)   // no video mode could be set
    +#define DVX_EXCL_ERR_BUSY   (-2)   // another window has full screen
    +#define DVX_EXCL_ERR_ARGS   (-3)   // no such window, or a bad size
    +
    +// Take the whole screen for win: the smallest mode of at least width x
    +// height, at bpp when there is one (0: any depth); raises and focuses win.
    +// onChange (may be NULL) hears ExclusiveActiveE before this returns.  A
    +// window that already has full screen gets the new size.
    +int32_t dvxExclusiveBegin(AppContextT *ctx, WindowT *win, int32_t width, int32_t height, int32_t bpp, ExclusiveFnT onChange, void *userData);
    +
    +// The span operations for the full-screen display's pixel format (NULL
    +// when not active).
    +const BlitOpsT *dvxExclusiveBlitOps(AppContextT *ctx);
    +
    +// The full-screen display while active, NULL otherwise (suspended or no
    +// full screen).  Draw into its backBuf (DVX's draw functions work on it),
    +// then dvxExclusiveFlush.  Its size may be larger than asked for.
    +DisplayT *dvxExclusiveDisplay(AppContextT *ctx);
    +
    +// Give the screen back (no onChange call).  Nothing when win does not
    +// have full screen.
    +void dvxExclusiveEnd(AppContextT *ctx, WindowT *win);
    +
    +// Copy a rectangle of the backbuffer to the screen (NULL: all of it).
    +void dvxExclusiveFlush(AppContextT *ctx, const RectT *r);
    +
    +// Show an 8-bit picture of palette indexes on the whole screen: scaled by
    +// the largest whole factor that fits, centred on black, through the
    +// palette (dvxExclusiveSetPalette) in any pixel format.  False when not
    +// active.
    +bool dvxExclusivePresent8(AppContextT *ctx, const uint8_t *src, int32_t srcW, int32_t srcH, int32_t srcPitch);
    +
    +// Set palette entries first..first+count-1 (R, G, B bytes per entry, the
    +// whole 256-entry table in pal).  At 8 bpp they go to the DAC at once (a
    +// picture on screen changes colour); at other depths the next
    +// dvxExclusivePresent8 uses them.  Kept across suspending.
    +void dvxExclusiveSetPalette(AppContextT *ctx, const uint8_t *pal, int32_t first, int32_t count);
    +
    +// Wait for the next vertical retrace, before a flush, so the frame is not
    +// torn.
    +void dvxExclusiveWaitRetrace(void);
    +
     // ============================================================
     // Color scheme
     // ============================================================
    diff --git a/src/libs/kpunch/libdvx/dvxTypes.h b/src/libs/kpunch/libdvx/dvxTypes.h
    index f0d49a34..e47db8d5 100644
    --- a/src/libs/kpunch/libdvx/dvxTypes.h
    +++ b/src/libs/kpunch/libdvx/dvxTypes.h
    @@ -65,7 +65,7 @@
     // src/test/abi/abiSizes.txt (make -C src/test/abi update) in the same
     // change; the abi test fails when a public struct changes size without a
     // bump, and when the table was not regenerated after one.
    -#define DVX_ABI_VERSION 3
    +#define DVX_ABI_VERSION 4
     
     // A module's stamp is named , e.g. the
     // stamp of LIBS/KPUNCH/LIBDVX/LIBDVX.LIB is libdvxAbiVersion.
    diff --git a/src/libs/kpunch/libdvx/games.dhs b/src/libs/kpunch/libdvx/games.dhs
    index 7256a658..95ed6af7 100644
    --- a/src/libs/kpunch/libdvx/games.dhs
    +++ b/src/libs/kpunch/libdvx/games.dhs
    @@ -202,6 +202,44 @@ Palette animation (colour cycling, fades) works at every depth: change the game
     
     Size the window so its content area is exactly the framebuffer times a whole scale that fits the screen (the template computes the scale from the display size and the chrome constants, then corrects with dvxResizeWindow after adding the menu bar). Copying only what changed matters on slower machines: a full 320x200 frame at 2x on a 16-bit desktop is 256 KB of pixels to convert and composite.
     
    +.topic games.fullscreen
    +.title The Whole Screen
    +.toc 1 The Whole Screen
    +.index Full screen
    +.index Exclusive mode
    +.index dvxExclusiveBegin
    +.index dvxExclusivePresent8
    +.index Alt+Enter
    +
    +.h1 The Whole Screen
    +
    +A game may also take the whole screen in a video mode of its own, as the template does with Alt+Enter. Every other program keeps running while it does: their windows go on painting into their own buffers, their timers fire, their sound plays, and the desktop is drawn again, as it is now, when the game lets go.
    +
    +.code
    +static void fullChanged(WindowT *win, ExclusiveStateE state, void *userData) {
    +    // Active: draw the whole frame.  Suspended: draw in the window again.
    +    // Ended: the mode could not be set again; full screen is over.
    +}
    +
    +if (dvxExclusiveBegin(ctx, win, 320, 200, 8, fullChanged, NULL) == DVX_EXCL_OK) {
    +    dvxExclusiveSetPalette(ctx, gamePal, 0, 256);
    +}
    +
    +// every frame
    +if (!dvxExclusivePresent8(ctx, fb, 320, 200, 320)) {
    +    // not on the whole screen: present into the window as usual
    +}
    +.endcode
    +
    +.list
    +.item dvxExclusiveBegin sets the smallest mode of at least the size asked for, at the depth asked for when there is one (0: any). At 8 bits per pixel a request that fits 320x200 gets VGA mode 13h, and one that fits 320x240 gets VGA Mode X (square pixels, and two pages: a whole-screen flush is shown from the next retrace, so frames never tear), unless the card has a VBE mode no larger. Either way the game draws into an ordinary linear backbuffer. The display may be larger than asked for; dvxExclusiveDisplay says what it is.
    +.item The game's window must have the focus. When it loses it (Alt+Tab, Ctrl+Esc, a dialog, another program's window coming to the front) the desktop comes back and the callback hears ExclusiveSuspendedE; when the window has the focus again the mode is set again and it hears ExclusiveActiveE. Draw the whole frame then: the screen starts black.
    +.item While the game has the screen, every key but Alt+Tab, Ctrl+Esc and Alt+F4 goes to its window, as in a game input window; the mouse cursor is not drawn; onMouse gets whole-screen coordinates.
    +.item dvxExclusivePresent8 shows a picture of palette numbers on the whole screen, scaled by the largest whole factor that fits and centred on black, in any pixel format. At 8 bits per pixel the palette is the screen's own: dvxExclusiveSetPalette changes colours at once, so palette animation costs nothing, and a hardware model of up to 16 palettes of 16 colours fits in the 256 entries.
    +.item To draw yourself, draw into dvxExclusiveDisplay(ctx)->backBuf (DVX's draw functions work on it with dvxExclusiveBlitOps) and call dvxExclusiveFlush. dvxExclusiveWaitRetrace waits for the vertical retrace before a flush, so the frame is not torn.
    +.item dvxExclusiveEnd gives the screen back. Closing the window, the program ending and End Task do too.
    +.endlist
    +
     .topic games.sound
     .title Sound, Music and MIDI
     .toc 1 Sound, Music and MIDI
    @@ -327,7 +365,7 @@ Portable game libraries for DOS-era machines usually keep everything machine-spe
     
     .h2 The Model: a Window, a Task, a Virtual Frame Rate
     
    -The game becomes a DVX program with its own main loop: appMain calls the game's main() (compile the game with -Dmain=gameMain, say, and call gameMain from appMain). The library's start-up creates a game input window whose content area is the game's screen at a whole scale; its frame wait yields until the next frame on the high-resolution clock. The game shares the screen with the desktop, so there is no mode change, no direct palette hardware and no vertical retrace; the port converts the library's back buffer into the window each frame.
    +The game becomes a DVX program with its own main loop: appMain calls the game's main() (compile the game with -Dmain=gameMain, say, and call gameMain from appMain). The library's start-up creates a game input window whose content area is the game's screen at a whole scale; its frame wait yields until the next frame on the high-resolution clock. In a window the game shares the screen with the desktop, so there is no mode change, no direct palette hardware and no vertical retrace; the port converts the library's back buffer into the window each frame. On the whole screen (see The Whole Screen) the game has its own mode, its own palette and the vertical retrace, while the other programs run on.
     
     .h2 Mapping a Hardware Layer to DVX
     
    @@ -338,9 +376,11 @@ The game becomes a DVX program with its own main loop: appMain calls the game's
       Shut-down                        dvxDestroyWindow, return from appMain
       Error text, debug log            dvxLog (DVX.LOG)
       Present the back buffer          colour table -> win->contentBuf, dvxInvalidateRect; onPaint
    -  Palette                          packColor, gamePalNearest, GamePalCacheT
    +  Present, whole screen            dvxExclusivePresent8, or dvxExclusiveDisplay + dvxExclusiveFlush
    +  Full screen on and off           dvxExclusiveBegin, dvxExclusiveEnd
    +  Palette                          packColor, gamePalNearest, GamePalCacheT; whole screen: dvxExclusiveSetPalette
       Palette per scan line            one colour table per palette, chosen per row
    -  Wait for vertical blank          gameFrameInit, gameFrameWait
    +  Wait for vertical blank          gameFrameInit, gameFrameWait; whole screen: dvxExclusiveWaitRetrace
       Frame counter, frame rate        GameFramePacerT.frame, the rate given to gameFrameInit
       Milliseconds                     platformMicros() / 1000
       Held keys                        onRawKey, gameKeyIsDown, platformKeyStateGet
    @@ -369,8 +409,8 @@ The game becomes a DVX program with its own main loop: appMain calls the game's
     .item Start-up: make the window not resizable, with its content area the game's screen at a whole scale that fits the display; give it onClose and onPaint. audioOpen returning NULL (no sound device) is not an error.
     .item Shut-down: close what the port opened and return. Anything left (sounds, music, joystick handles, timers) is released when the program ends.
     .item Present: expand the rows or word bands that changed through the colour table into the content buffer, scaled, and invalidate them; onPaint does the whole screen. See The Screen: Framebuffers and Palettes.
    -.item Palette: scale 12-bit or 18-bit colour to 0..255 per channel before looking it up. Never touch the VGA palette registers: other programs share the screen.
    -.item Vertical blank: there is none in a window. The frame rate is a virtual one: use the rate the library reports (60 or 70 frames a second); truncate the frame counter to the library's width.
    +.item Palette: scale 12-bit or 18-bit colour to 0..255 per channel before looking it up. Never touch the VGA palette registers: other programs share the screen, and on the whole screen dvxExclusiveSetPalette does it (and puts the desktop's palette back when the game lets go).
    +.item Vertical blank: there is none in a window. The frame rate is a virtual one: use the rate the library reports (60 or 70 frames a second); truncate the frame counter to the library's width. On the whole screen dvxExclusiveWaitRetrace waits for the real one.
     .item Keyboard: raw codes are set-1 scancodes with KEY_RAW_EXT for the keys sent behind 0xE0, so a DOS port's scancode table maps directly. Never hook the keyboard interrupt.
     .item Mouse: divide content-area pixels by the scale and clamp.
     .item Joystick: axes are -32768..32767 after calibration and dead zone; divide down to the library's range, or compare with a threshold for a digital stick. Buttons are bits. Never read the game port directly; calibration lives in the Control Panel.
    @@ -387,7 +427,8 @@ The game becomes a DVX program with its own main loop: appMain calls the game's
     .item Shared library or static: built as a DVX library (.lib, loaded when DVX starts) the port is shared by every game, but its global state (the stage, the key table) means one game at a time. Linked into each game's .app (the LIBS variable) every game has its own copy, several can run at once, and nothing is loaded while no game runs.
     .item Paths: libraries that open "DATA/..." or write saves to a fixed folder need a run-time root (appDir and configDir) instead of compile-time paths.
     .item Speed: every frame costs a conversion and a composite. Present only what changed; a 1x scale on a 486 and larger scales on faster machines.
    -.item Tearing: there is no vertical retrace in a window. Games that depend on raster timing need rework.
    +.item Tearing: there is no vertical retrace in a window. On the whole screen there is (dvxExclusiveWaitRetrace), but games that change registers mid-frame (raster effects) still need rework.
    +.item Whole screen: offer it (Alt+Enter is the custom) and handle ExclusiveSuspendedE by drawing in the window again: the player can Alt+Tab to the desktop at any time.
     .item The 256-colour desktop: nearest colours only; say so in the game's help if it needs 15-bit colour or better.
     .endlist
     
    diff --git a/src/libs/kpunch/libdvx/platform/dvxPlat.h b/src/libs/kpunch/libdvx/platform/dvxPlat.h
    index 64964fbf..03cd067a 100644
    --- a/src/libs/kpunch/libdvx/platform/dvxPlat.h
    +++ b/src/libs/kpunch/libdvx/platform/dvxPlat.h
    @@ -288,6 +288,28 @@ void platformVideoEnumModes(void (*cb)(int32_t w, int32_t h, int32_t bpp, void *
     // firstEntry..firstEntry+count-1 are written, clamped to the table.
     void platformVideoSetPalette(const uint8_t *pal, int32_t firstEntry, int32_t count);
     
    +// VGA mode 13h: the boot splash, and the full-screen fallback
    +#define VGA13_W             320
    +#define VGA13_H             200
    +
    +// Full-screen exclusive video (dvxExclusiveBegin).  Begin sets the
    +// smallest mode of at least requestedW x requestedH (preferredBpp first,
    +// any depth for 0; on DOS VGA mode 13h up to 320x200 and Mode X up to
    +// 320x240 at 8 bpp when no VBE mode is that small) and fills d with its
    +// own linear backbuffer; the desktop's display and buffers are left as
    +// they are.  End frees d's buffers and sets the desktop's mode (and 8-bit
    +// palette) again; call it after a failed Begin too.  Flush puts a
    +// backbuffer rectangle on the screen (Mode X: a whole-screen flush flips
    +// pages at the next retrace and waits for it).  Capture reads what is on
    +// the screen into out (width x height bytes) where the backbuffer is not
    +// a copy of it (Mode X), else returns false.  WaitRetrace waits for the
    +// start of the next vertical retrace.
    +int32_t platformVideoExclusiveBegin(DisplayT *d, int32_t requestedW, int32_t requestedH, int32_t preferredBpp);
    +bool    platformVideoExclusiveCapture(const DisplayT *d, uint8_t *out);
    +int32_t platformVideoExclusiveEnd(DisplayT *d, const DisplayT *desktop);
    +void    platformVideoExclusiveFlush(const DisplayT *d, const RectT *r);
    +void    platformVideoWaitRetrace(void);
    +
     // ============================================================
     // Framebuffer flush
     // ============================================================
    @@ -788,8 +810,8 @@ void platformOutOfMemory(size_t bytes) __attribute__((noreturn));
     // SPLASH.RAW format (written by the bmp2raw tool): SPLASH_PAL_BYTES of
     // palette (R, G, B per entry, 6-bit DAC values), then SPLASH_PIXEL_BYTES
     // of 8-bit pixels, top-to-bottom scanlines.
    -#define SPLASH_W            320
    -#define SPLASH_H            200
    +#define SPLASH_W            VGA13_W
    +#define SPLASH_H            VGA13_H
     #define SPLASH_BPP          8
     #define SPLASH_PAL_ENTRIES  256
     #define SPLASH_PAL_CHANNELS 3
    diff --git a/src/libs/kpunch/libdvx/platform/dvxPlatformDos.c b/src/libs/kpunch/libdvx/platform/dvxPlatformDos.c
    index 37fe584d..48cc5b7b 100644
    --- a/src/libs/kpunch/libdvx/platform/dvxPlatformDos.c
    +++ b/src/libs/kpunch/libdvx/platform/dvxPlatformDos.c
    @@ -300,11 +300,57 @@
     // the handlers are small, a generous span guarantees full coverage.
     #define ISR_LOCK_SIZE          2048
     
    -// Sentinel for sLfbLinearAddr/sLfbMappedSize meaning "no LFB currently mapped".
    +// Sentinel for LfbMapT.linearAddr meaning "no LFB currently mapped".
     #define LFB_NOT_MAPPED  0
     
    -// VGA splash screen (mode 13h) constants
    -#define SPLASH_VGA_ADDR 0xA0000
    +// VGA mode 13h: 320x200 at 8 bpp, linear at A0000h (the boot splash, and
    +// the full-screen fallback when the card has no VBE mode that small)
    +#define VGA13_ADDR      0xA0000
    +
    +// Full-screen mode choice (scoreExclusive): the requested depth first,
    +// then the least wasted area.  AREA_MAX is above any mode's pixel count.
    +#define EXCL_SCORE_AREA_MAX   (1 << 22)
    +#define EXCL_SCORE_BPP_MATCH  (EXCL_SCORE_AREA_MAX * 2)
    +
    +// Mode X: VGA 256 colours unchained, 320x240 with square pixels, pixel
    +// (x, y) in plane x & 3 at byte y * 80 + x / 4.  Two pages of
    +// MODEX_PAGE_BYTES in the 64K window: a whole-screen flush fills the
    +// hidden one and flips to it at the retrace.
    +#define MODEX_W               320
    +#define MODEX_H               240
    +#define MODEX_PLANES          4
    +#define MODEX_PLANE_MASK      (MODEX_PLANES - 1)
    +#define MODEX_ROW_BYTES       (MODEX_W / MODEX_PLANES)
    +#define MODEX_PAGE_BYTES      (MODEX_ROW_BYTES * MODEX_H)
    +#define MODEX_PAGES           2
    +#define MODEX_ALL_PLANES      0x0F
    +#define VGA_WINDOW_BYTES      0x10000
    +#define VGA_SEQ_INDEX         0x3C4
    +#define VGA_GC_INDEX          0x3CE
    +#define VGA_CRTC_INDEX        0x3D4
    +#define VGA_CRTC_DATA         0x3D5
    +#define VGA_MISC_WRITE        0x3C2
    +#define SEQ_RESET             0x00
    +#define SEQ_MAP_MASK          0x02
    +#define SEQ_MEMORY_MODE       0x04
    +#define SEQ_RESET_SYNC        0x01     // synchronous reset (while the clock changes)
    +#define SEQ_RESET_RUN         0x03     // sequencer running
    +#define SEQ_MEM_UNCHAINED     0x06     // chain-4 and odd/even off, 256K
    +#define GC_READ_MAP           0x04
    +#define CRTC_START_HIGH       0x0C
    +#define CRTC_START_LOW        0x0D
    +#define CRTC_VRETRACE_END     0x11
    +#define CRTC_UNPROTECT_MASK   0x7F     // clears the write-protect bit of CRTC 0-7
    +#define MISC_MODEX            0xE3     // 25 MHz dot clock, 480-line sync polarities
    +#define REG_DATA_SHIFT        8        // outportw: index in the low byte, data in the high
    +#define BYTE_MASK             0xFF
    +
    +// Vertical retrace: VGA input status 1, bit 3 is set during retrace.  The
    +// wait gives up after RETRACE_WAIT_MAX_US (two frames at 50 Hz), so a card
    +// without the bit never hangs a game.
    +#define VGA_INPUT_STATUS      0x3DA
    +#define VGA_STATUS_RETRACE    0x08
    +#define RETRACE_WAIT_MAX_US   40000
     
     // Max VBE mode numbers we read from the BIOS mode list
     #define MAX_VBE_MODES   256
    @@ -343,6 +389,19 @@ typedef struct {
         uint8_t  bluePos;
     } VbeModeT;
     
    +// A DPMI physical mapping of a linear framebuffer: the RAW linear address
    +// from __dpmi_physical_address_mapping (NOT d->lfb, which has
    +// __djgpp_conventional_base folded in) and its size, kept so the mapping
    +// can be unlocked and freed.  LFB_NOT_MAPPED means nothing mapped.
    +typedef struct {
    +    uint32_t linearAddr;
    +    uint32_t size;
    +} LfbMapT;
    +
    +// Scores a VBE mode against a request (-1: unusable); findBestMode keeps
    +// the highest.
    +typedef int32_t (*ModeScoreFnT)(const VbeModeT *m, int32_t requestedW, int32_t requestedH, int32_t preferredBpp);
    +
     
     // ============================================================
     // Prototypes
    @@ -352,19 +411,22 @@ static uint8_t                 biosVideoMode(void);
     static bool                    cpuHasTsc(void);
     static uint32_t                estimateClockMhz(void);
     static uint32_t                farPtrToLinear(uint32_t addr);
    -static int32_t                 findBestMode(int32_t requestedW, int32_t requestedH, int32_t preferredBpp, uint16_t *outMode, DisplayT *d);
    +static int32_t                 findBestMode(int32_t requestedW, int32_t requestedH, int32_t preferredBpp, ModeScoreFnT score, uint16_t *outMode, DisplayT *d);
     static bool                    hasCpuid(void);
     static void                    installCritErrorHandler(void);
     static void                    int8Handler(void);
     static void                    int9Handler(void);
     static void                    irqServicesArmAtExit(void);
     static void                    irqServicesAtExit(void);
    -static int32_t                 mapLfb(DisplayT *d, uint32_t physAddr);
    +static int32_t                 mapLfb(DisplayT *d, uint32_t physAddr, LfbMapT *map);
    +static void                    modeXCopy(const DisplayT *d, const RectT *r, uint32_t pageOffset);
    +static void                    modeXSet(void);
     static uint32_t                pagesToKb(unsigned long pages);
     static uint32_t                pitLatchElapsed(void);
     static void                    platformCrashHandler(int sig);
     ISR_INLINE uint64_t            readTsc(void);
     static bool                    regionFromNear(const void *addr, uint32_t size, bool isCode, __dpmi_meminfo *out);
    +static int32_t                 scoreExclusive(const VbeModeT *m, int32_t requestedW, int32_t requestedH, int32_t preferredBpp);
     static int32_t                 scoreMode(const VbeModeT *m, int32_t requestedW, int32_t requestedH, int32_t preferredBpp);
     static dxe_symbol_table        sDxeExportTable[];
     static void                    setBiosMode(uint16_t mode);
    @@ -373,7 +435,7 @@ static void                    sysInfoAppend(const char *fmt, ...) __attribute__
     static void                    sysInfoDrives(void);
     static bool                    timerLock(void);
     static void                    timerUnlock(void);
    -static void                    unmapLfb(DisplayT *d);
    +static void                    unmapLfb(DisplayT *d, LfbMapT *map);
     static uint16_t                vbeGetControllerInfo(void);
     static bool                    vbeQueryMode(uint16_t mode, uint16_t vbeVersion, VbeModeT *out);
     static int32_t                 vbeReadModeList(uint16_t *modes);
    @@ -500,13 +562,34 @@ static jmp_buf       *sPlatformCrashJmp    = NULL;
     static volatile int  *sPlatformCrashSignal = NULL;
     static PlatformLogFnT sPlatformLogFn       = NULL;
     
    -// Linear address and size of the currently mapped physical LFB, saved by
    -// mapLfb so platformVideoFreeBuffers / platformVideoShutdown can unlock and
    -// free the DPMI physical mapping.  This is the RAW linear address returned by
    -// __dpmi_physical_address_mapping, NOT d->lfb (which has
    -// __djgpp_conventional_base folded in).  LFB_NOT_MAPPED means nothing mapped.
    -static uint32_t sLfbLinearAddr = LFB_NOT_MAPPED;
    -static uint32_t sLfbMappedSize = 0;
    +// The desktop's LFB mapping (platformVideoInit .. FreeBuffers) and a
    +// full-screen program's (platformVideoExclusiveBegin .. End).  The desktop
    +// mapping stays while a program has the screen, so returning needs only
    +// the mode set again.
    +static LfbMapT  sDesktopLfb   = { LFB_NOT_MAPPED, 0 };
    +static LfbMapT  sExclusiveLfb = { LFB_NOT_MAPPED, 0 };
    +
    +// The desktop's VBE mode, set again when a full-screen program lets go.
    +static uint16_t sDesktopMode  = 0;
    +
    +// Full screen in Mode X, and the page on show (0 or 1).
    +static bool     sModeX        = false;
    +static int32_t  sModeXShown   = 0;
    +
    +// Mode X's CRTC registers over mode 13h's (index in the low byte, value in
    +// the high): 240 lines of one scan line each, byte addressing.
    +static const uint16_t sModeXCrtc[] = {
    +    0x0D06,     // vertical total
    +    0x3E07,     // overflow
    +    0x4109,     // maximum scan line: one scan line per pixel row doubled
    +    0xEA10,     // vertical retrace start
    +    0xAC11,     // vertical retrace end (write protect off)
    +    0xDF12,     // vertical display end
    +    0x0014,     // underline location: doubleword mode off
    +    0xE715,     // vertical blank start
    +    0x0616,     // vertical blank end
    +    0xE317      // mode control: byte mode
    +};
     
     // True from a successful platformVideoInit until platformVideoShutdown: a
     // DVX screen mode is up (a failed live mode change restores it), so video
    @@ -610,7 +693,7 @@ static uint32_t farPtrToLinear(uint32_t addr) {
     // real-mode interrupts via __dpmi_int(), so all data must pass through
     // the transfer buffer.
     
    -static int32_t findBestMode(int32_t requestedW, int32_t requestedH, int32_t preferredBpp, uint16_t *outMode, DisplayT *d) {
    +static int32_t findBestMode(int32_t requestedW, int32_t requestedH, int32_t preferredBpp, ModeScoreFnT score, uint16_t *outMode, DisplayT *d) {
         uint16_t vbeVersion = vbeGetControllerInfo();
     
         // VBE 2.0+ is required for LFB (Linear Frame Buffer) support.
    @@ -636,10 +719,10 @@ static int32_t findBestMode(int32_t requestedW, int32_t requestedH, int32_t pref
                 continue;
             }
     
    -        int32_t score = scoreMode(&candidate, requestedW, requestedH, preferredBpp);
    +        int32_t s = score(&candidate, requestedW, requestedH, preferredBpp);
     
    -        if (score > bestScore) {
    -            bestScore = score;
    +        if (s > bestScore) {
    +            bestScore = s;
                 bestMode  = modes[i];
                 best      = candidate;
             }
    @@ -823,7 +906,7 @@ static void irqServicesAtExit(void) {
     // is the offset DJGPP applies to convert linear addresses to near
     // pointer addresses (compensating for the DS segment base).
     
    -static int32_t mapLfb(DisplayT *d, uint32_t physAddr) {
    +static int32_t mapLfb(DisplayT *d, uint32_t physAddr, LfbMapT *map) {
         __dpmi_meminfo info;
         uint32_t       fbSize = (uint32_t)d->pitch * (uint32_t)d->height;
     
    @@ -853,8 +936,8 @@ static int32_t mapLfb(DisplayT *d, uint32_t physAddr) {
         }
     
         // Save the RAW linear address (not d->lfb) so we can later unlock/free it.
    -    sLfbLinearAddr = info.address;
    -    sLfbMappedSize = fbSize;
    +    map->linearAddr = info.address;
    +    map->size       = fbSize;
     
         // Convert linear address to near pointer by adding the DS base offset
         d->lfb = (uint8_t *)(info.address + __djgpp_conventional_base);
    @@ -863,6 +946,66 @@ static int32_t mapLfb(DisplayT *d, uint32_t physAddr) {
     }
     
     
    +// Backbuffer rectangle -> Mode X memory at pageOffset, a plane at a time.
    +// Whole rows go four pixels to a doubleword write.
    +static void modeXCopy(const DisplayT *d, const RectT *r, uint32_t pageOffset) {
    +    uint8_t *vram  = (uint8_t *)(VGA13_ADDR + __djgpp_conventional_base) + pageOffset;
    +    int32_t  right = r->x + r->w;
    +    bool     whole = r->x == 0 && r->w == MODEX_W;
    +
    +    for (int32_t plane = 0; plane < MODEX_PLANES; plane++) {
    +        int32_t first = r->x + ((plane - r->x) & MODEX_PLANE_MASK);
    +
    +        if (first >= right) {
    +            continue;
    +        }
    +
    +        outportw(VGA_SEQ_INDEX, (uint16_t)(SEQ_MAP_MASK | ((1 << plane) << REG_DATA_SHIFT)));
    +
    +        for (int32_t y = r->y; y < r->y + r->h; y++) {
    +            const uint8_t *src = d->backBuf + (size_t)y * (size_t)d->pitch;
    +            uint8_t       *dst = vram + y * MODEX_ROW_BYTES;
    +
    +            if (whole) {
    +                uint32_t *dst32 = (uint32_t *)dst;
    +
    +                for (int32_t x = plane; x < MODEX_W; x += MODEX_PLANES * (int32_t)sizeof(uint32_t)) {
    +                    *dst32++ = (uint32_t)src[x] | ((uint32_t)src[x + MODEX_PLANES] << BYTE_BITS) | ((uint32_t)src[x + 2 * MODEX_PLANES] << (2 * BYTE_BITS)) | ((uint32_t)src[x + 3 * MODEX_PLANES] << (3 * BYTE_BITS));
    +                }
    +
    +                continue;
    +            }
    +
    +            for (int32_t x = first; x < right; x += MODEX_PLANES) {
    +                dst[x / MODEX_PLANES] = src[x];
    +            }
    +        }
    +    }
    +}
    +
    +
    +// Mode 13h unchained into Mode X (Abrash's sequence): chain-4 off, the
    +// 25 MHz clock under a sequencer reset, the 240-line CRTC timings, page 0
    +// on show, all of video memory cleared.
    +static void modeXSet(void) {
    +    setBiosMode(BIOS_MODE_VGA13);
    +    outportw(VGA_SEQ_INDEX, (uint16_t)(SEQ_MEMORY_MODE | (SEQ_MEM_UNCHAINED << REG_DATA_SHIFT)));
    +    outportw(VGA_SEQ_INDEX, (uint16_t)(SEQ_RESET | (SEQ_RESET_SYNC << REG_DATA_SHIFT)));
    +    outportb(VGA_MISC_WRITE, MISC_MODEX);
    +    outportw(VGA_SEQ_INDEX, (uint16_t)(SEQ_RESET | (SEQ_RESET_RUN << REG_DATA_SHIFT)));
    +    outportb(VGA_CRTC_INDEX, CRTC_VRETRACE_END);
    +    outportb(VGA_CRTC_DATA, inportb(VGA_CRTC_DATA) & CRTC_UNPROTECT_MASK);
    +
    +    for (size_t i = 0; i < sizeof(sModeXCrtc) / sizeof(sModeXCrtc[0]); i++) {
    +        outportw(VGA_CRTC_INDEX, sModeXCrtc[i]);
    +    }
    +
    +    outportw(VGA_SEQ_INDEX, (uint16_t)(SEQ_MAP_MASK | (MODEX_ALL_PLANES << REG_DATA_SHIFT)));
    +    memset((uint8_t *)(VGA13_ADDR + __djgpp_conventional_base), 0, VGA_WINDOW_BYTES);
    +    sModeXShown = 0;
    +}
    +
    +
     // Convert a DPMI page count to KB, mapping the "unavailable" sentinel to 0.
     static uint32_t pagesToKb(unsigned long pages) {
         if (pages == DPMI_INFO_UNAVAILABLE) {
    @@ -2140,7 +2283,7 @@ void platformSplashFillRect(int32_t x, int32_t y, int32_t w, int32_t h, uint8_t
                 continue;
             }
     
    -        dosmemput(rowBuf, spanW, SPLASH_VGA_ADDR + row * SPLASH_W + x0);
    +        dosmemput(rowBuf, spanW, VGA13_ADDR + row * SPLASH_W + x0);
         }
     }
     
    @@ -2182,7 +2325,7 @@ bool platformSplashLoadRaw(const char *path) {
             }
     
             // One block move per scanline (rep movs) instead of 320 byte pokes
    -        dosmemput(rowBuf, SPLASH_W, SPLASH_VGA_ADDR + y * SPLASH_W);
    +        dosmemput(rowBuf, SPLASH_W, VGA13_ADDR + y * SPLASH_W);
         }
     
         fclose(fp);
    @@ -2441,6 +2584,143 @@ void platformVideoEnumModes(void (*cb)(int32_t w, int32_t h, int32_t bpp, void *
     }
     
     
    +// A program takes the whole screen: the smallest VBE mode of at least the
    +// requested size (the requested depth first, any depth for 0), or a VGA
    +// mode for a request at 8 bpp (or any depth) that fits one when no VBE
    +// mode is that small: mode 13h up to 320x200, Mode X up to 320x240.  d gets its own backbuffer; the desktop's
    +// buffers and LFB mapping are left alone.  On failure the screen may be in
    +// any mode: the caller calls platformVideoExclusiveEnd, as after success.
    +int32_t platformVideoExclusiveBegin(DisplayT *d, int32_t requestedW, int32_t requestedH, int32_t preferredBpp) {
    +    uint16_t mode;
    +    bool     vgaDepth = preferredBpp == 0 || preferredBpp == BPP_8;
    +    bool     fitsVga13 = vgaDepth && requestedW <= VGA13_W && requestedH <= VGA13_H;
    +    bool     fitsModeX = vgaDepth && requestedW <= MODEX_W && requestedH <= MODEX_H;
    +    int32_t  vgaArea   = fitsVga13 ? VGA13_W * VGA13_H : MODEX_W * MODEX_H;
    +    bool     found;
    +
    +    memset(d, 0, sizeof(*d));
    +    sModeX = false;
    +    found  = findBestMode(requestedW, requestedH, preferredBpp, scoreExclusive, &mode, d) == 0;
    +
    +    // A request that fits 320x200 (mode 13h) or 320x240 (Mode X) at 8 bpp
    +    // gets that VGA mode rather than a larger VBE mode: the true low
    +    // resolution and far fewer bytes a frame.
    +    if (found && !(fitsModeX && d->width * d->height > vgaArea)) {
    +        uint32_t physAddr = (uint32_t)(uintptr_t)d->lfb;
    +
    +        d->lfb = NULL;
    +
    +        if (setVesaMode(mode) != 0 || mapLfb(d, physAddr, &sExclusiveLfb) != 0) {
    +            return -1;
    +        }
    +    } else if (fitsVga13) {
    +        memset(d, 0, sizeof(*d));
    +        setBiosMode(BIOS_MODE_VGA13);
    +        d->width                = VGA13_W;
    +        d->height               = VGA13_H;
    +        d->pitch                = VGA13_W;
    +        d->format.bitsPerPixel  = BPP_8;
    +        d->format.bytesPerPixel = 1;
    +        d->lfb                  = (uint8_t *)(VGA13_ADDR + __djgpp_conventional_base);
    +        dvxLog("Video: VGA mode 13h");
    +    } else if (fitsModeX) {
    +        // Planar: no linear framebuffer; platformVideoExclusiveFlush
    +        // writes the planes.
    +        memset(d, 0, sizeof(*d));
    +        modeXSet();
    +        sModeX                  = true;
    +        d->width                = MODEX_W;
    +        d->height               = MODEX_H;
    +        d->pitch                = MODEX_W;
    +        d->format.bitsPerPixel  = BPP_8;
    +        d->format.bytesPerPixel = 1;
    +        dvxLog("Video: VGA Mode X (planar, two pages)");
    +    } else {
    +        return -1;
    +    }
    +
    +    d->backBuf = (uint8_t *)calloc(1, (size_t)d->pitch * (size_t)d->height);
    +
    +    if (!d->backBuf) {
    +        return -1;
    +    }
    +
    +    d->clipW = d->width;
    +    d->clipH = d->height;
    +    return 0;
    +}
    +
    +
    +// Mode X only: the page on show, read back plane by plane, into out
    +// (width x height bytes).  False in a linear mode: the backbuffer is what
    +// is on the screen.
    +bool platformVideoExclusiveCapture(const DisplayT *d, uint8_t *out) {
    +    if (!sModeX) {
    +        return false;
    +    }
    +
    +    const uint8_t *vram = (const uint8_t *)(VGA13_ADDR + __djgpp_conventional_base) + sModeXShown * MODEX_PAGE_BYTES;
    +
    +    for (int32_t plane = 0; plane < MODEX_PLANES; plane++) {
    +        outportw(VGA_GC_INDEX, (uint16_t)(GC_READ_MAP | (plane << REG_DATA_SHIFT)));
    +
    +        for (int32_t y = 0; y < d->height; y++) {
    +            for (int32_t x = plane; x < d->width; x += MODEX_PLANES) {
    +                out[y * d->width + x] = vram[y * MODEX_ROW_BYTES + x / MODEX_PLANES];
    +            }
    +        }
    +    }
    +
    +    return true;
    +}
    +
    +
    +// The full-screen program lets go: its buffers and mapping are freed and
    +// the desktop's mode (and, at 8 bpp, its palette) is set again.  The
    +// desktop's backbuffer is untouched; the caller repaints the screen.
    +int32_t platformVideoExclusiveEnd(DisplayT *d, const DisplayT *desktop) {
    +    free(d->backBuf);
    +    d->backBuf = NULL;
    +    sModeX     = false;
    +    unmapLfb(d, &sExclusiveLfb);
    +
    +    if (setVesaMode(sDesktopMode) != 0) {
    +        return -1;
    +    }
    +
    +    if (desktop->format.bitsPerPixel == BPP_8 && desktop->palette) {
    +        platformVideoSetPalette(desktop->palette, 0, PAL_ENTRIES);
    +    }
    +
    +    return 0;
    +}
    +
    +
    +// A linear mode copies the rectangle.  Mode X: a whole-screen flush fills
    +// the hidden page, makes it the one on show and waits for the retrace that
    +// shows it (no tearing); a smaller one writes the page on show.
    +void platformVideoExclusiveFlush(const DisplayT *d, const RectT *r) {
    +    if (!sModeX) {
    +        platformFlushRect(d, r);
    +        return;
    +    }
    +
    +    if (r->x != 0 || r->y != 0 || r->w != d->width || r->h != d->height) {
    +        modeXCopy(d, r, (uint32_t)(sModeXShown * MODEX_PAGE_BYTES));
    +        return;
    +    }
    +
    +    int32_t  hidden = (sModeXShown + 1) % MODEX_PAGES;
    +    uint32_t offset = (uint32_t)(hidden * MODEX_PAGE_BYTES);
    +
    +    modeXCopy(d, r, offset);
    +    outportw(VGA_CRTC_INDEX, (uint16_t)(CRTC_START_HIGH | (offset & (BYTE_MASK << REG_DATA_SHIFT))));
    +    outportw(VGA_CRTC_INDEX, (uint16_t)(CRTC_START_LOW | ((offset & BYTE_MASK) << REG_DATA_SHIFT)));
    +    platformVideoWaitRetrace();
    +    sModeXShown = hidden;
    +}
    +
    +
     // Tears down the graphics mode and restores standard 80x25 text
     // mode (BIOS mode 3) so the user gets their DOS prompt back.
     // Also frees the backbuffer, palette, and disables near pointers
    @@ -2457,7 +2737,7 @@ void platformVideoFreeBuffers(DisplayT *d) {
             d->palette = NULL;
         }
     
    -    unmapLfb(d);
    +    unmapLfb(d, &sDesktopLfb);
     }
     
     
    @@ -2482,7 +2762,7 @@ int32_t platformVideoInit(DisplayT *d, int32_t requestedW, int32_t requestedH, i
         memset(d, 0, sizeof(*d));
     
         // Find the best VESA mode
    -    if (findBestMode(requestedW, requestedH, preferredBpp, &bestMode, d) != 0) {
    +    if (findBestMode(requestedW, requestedH, preferredBpp, scoreMode, &bestMode, d) != 0) {
             return -1;
         }
     
    @@ -2497,10 +2777,12 @@ int32_t platformVideoInit(DisplayT *d, int32_t requestedW, int32_t requestedH, i
         // A failure past this point leaves the new mode set: at startup
         // videoError puts the machine back in text mode for its message, and a
         // live mode change restores the old mode.
    -    if (mapLfb(d, physAddr) != 0) {
    +    if (mapLfb(d, physAddr, &sDesktopLfb) != 0) {
             return -1;
         }
     
    +    sDesktopMode = bestMode;
    +
         // Allocate backbuffer
         uint32_t fbSize = (uint32_t)d->pitch * (uint32_t)d->height;
         d->backBuf = (uint8_t *)calloc(1, fbSize);
    @@ -2580,6 +2862,20 @@ void platformVideoShutdown(DisplayT *d) {
     }
     
     
    +// Wait for the start of the next vertical retrace (a retrace already under
    +// way is let finish first), so a frame copied now is not torn.  Gives up
    +// after RETRACE_WAIT_MAX_US.
    +void platformVideoWaitRetrace(void) {
    +    uint64_t start = platformMicros();
    +
    +    while ((inportb(VGA_INPUT_STATUS) & VGA_STATUS_RETRACE) && platformMicros() - start < RETRACE_WAIT_MAX_US) {
    +    }
    +
    +    while (!(inportb(VGA_INPUT_STATUS) & VGA_STATUS_RETRACE) && platformMicros() - start < RETRACE_WAIT_MAX_US) {
    +    }
    +}
    +
    +
     // Cooperative yield to the DPMI host. In a multitasking DOS
     // environment (Windows 3.x DOS box or OS/2 VDM),
     // this gives other tasks a chance to run. Under a single-tasking
    @@ -2617,6 +2913,24 @@ static bool regionFromNear(const void *addr, uint32_t size, bool isCode, __dpmi_
     }
     
     
    +// Scores a mode for a full-screen program: the requested depth (any when
    +// preferredBpp is 0) outweighs size; then the smaller the mode (the less
    +// wasted around the program's picture), the better.  -1 below the request.
    +static int32_t scoreExclusive(const VbeModeT *m, int32_t requestedW, int32_t requestedH, int32_t preferredBpp) {
    +    if (m->width < requestedW || m->height < requestedH) {
    +        return -1;
    +    }
    +
    +    int32_t waste = m->width * m->height - requestedW * requestedH;
    +
    +    if (waste >= EXCL_SCORE_AREA_MAX) {
    +        waste = EXCL_SCORE_AREA_MAX - 1;
    +    }
    +
    +    return (m->bpp == preferredBpp ? EXCL_SCORE_BPP_MATCH : 0) + EXCL_SCORE_AREA_MAX - waste;
    +}
    +
    +
     // Scores a supported mode against the requested parameters:
     //
     //   Base score by bpp:  16-bit=100, 15-bit=90, 32-bit=85, 8-bit=70
    @@ -2776,20 +3090,20 @@ static void timerUnlock(void) {
     }
     
     
    -// Unlocks and frees the DPMI physical LFB mapping recorded by mapLfb.
    -// Safe to call when nothing is mapped (sLfbLinearAddr == LFB_NOT_MAPPED)
    +// Unlocks and frees a DPMI physical LFB mapping recorded by mapLfb.
    +// Safe to call when nothing is mapped (map->linearAddr == LFB_NOT_MAPPED)
     // and idempotent: the sentinel is reset so a second call is a no-op.
     // Uses the raw linear address, NOT d->lfb (which has the conventional
     // base folded in).  Clears d->lfb so callers do not keep a stale pointer.
    -static void unmapLfb(DisplayT *d) {
    -    if (sLfbLinearAddr != LFB_NOT_MAPPED) {
    +static void unmapLfb(DisplayT *d, LfbMapT *map) {
    +    if (map->linearAddr != LFB_NOT_MAPPED) {
             __dpmi_meminfo info;
    -        info.address = sLfbLinearAddr;
    -        info.size    = sLfbMappedSize;
    +        info.address = map->linearAddr;
    +        info.size    = map->size;
             __dpmi_unlock_linear_region(&info);
             __dpmi_free_physical_address_mapping(&info);
    -        sLfbLinearAddr = LFB_NOT_MAPPED;
    -        sLfbMappedSize = 0;
    +        map->linearAddr = LFB_NOT_MAPPED;
    +        map->size       = 0;
         }
     
         d->lfb = NULL;
    @@ -3115,10 +3429,15 @@ DXE_EXPORT_TABLE(sDxeExportTable)
         DXE_EXPORT(platformUnlockRegion)
         DXE_EXPORT(platformValidateFilename)
         DXE_EXPORT(platformVideoEnumModes)
    +    DXE_EXPORT(platformVideoExclusiveBegin)
    +    DXE_EXPORT(platformVideoExclusiveCapture)
    +    DXE_EXPORT(platformVideoExclusiveEnd)
    +    DXE_EXPORT(platformVideoExclusiveFlush)
         DXE_EXPORT(platformVideoFreeBuffers)
         DXE_EXPORT(platformVideoInit)
         DXE_EXPORT(platformVideoSetPalette)
         DXE_EXPORT(platformVideoShutdown)
    +    DXE_EXPORT(platformVideoWaitRetrace)
         DXE_EXPORT(platformYield)
     
         // --- DPMI/go32 (needed by rs232 serial driver) ---
    diff --git a/src/libs/kpunch/libdvx/platform/dvxPlatformHost.c b/src/libs/kpunch/libdvx/platform/dvxPlatformHost.c
    index f45e361e..955e9fa3 100644
    --- a/src/libs/kpunch/libdvx/platform/dvxPlatformHost.c
    +++ b/src/libs/kpunch/libdvx/platform/dvxPlatformHost.c
    @@ -150,7 +150,7 @@ static const int32_t sModeList[][2] = {
     static const int32_t sBppList[] = { HOST_BPP_8, HOST_BPP_16, HOST_BPP_32 };
     
     // Most recently initialised display, for hostSavePng.
    -static DisplayT *sDisplay = NULL;
    +static const DisplayT *sDisplay = NULL;
     
     // Palette as last programmed by platformVideoSetPalette (8-bit modes).
     static uint8_t sPalette[PAL_ENTRIES * PAL_CHANNELS];
    @@ -925,6 +925,46 @@ void platformVideoEnumModes(void (*cb)(int32_t w, int32_t h, int32_t bpp, void *
     }
     
     
    +// The host has no modes to set: the full-screen display is a pair of
    +// buffers like the desktop's (bpp 0 = 8), and hostVideoFailNext fails it.
    +// The palette is the caller's, as on DOS.
    +int32_t platformVideoExclusiveBegin(DisplayT *d, int32_t requestedW, int32_t requestedH, int32_t preferredBpp) {
    +    int32_t rc = platformVideoInit(d, requestedW, requestedH, preferredBpp == 0 ? HOST_BPP_8 : preferredBpp);
    +
    +    free(d->palette);
    +    d->palette = NULL;
    +    return rc;
    +}
    +
    +
    +// The host's full-screen display is linear: the backbuffer is the screen.
    +bool platformVideoExclusiveCapture(const DisplayT *d, uint8_t *out) {
    +    (void)d;
    +    (void)out;
    +    return false;
    +}
    +
    +
    +int32_t platformVideoExclusiveEnd(DisplayT *d, const DisplayT *desktop) {
    +    free(d->backBuf);
    +    d->backBuf = NULL;
    +    free(d->lfb);
    +    d->lfb     = NULL;
    +    sDisplay   = desktop;
    +
    +    if (desktop->format.bitsPerPixel == HOST_BPP_8 && desktop->palette) {
    +        platformVideoSetPalette(desktop->palette, 0, PAL_ENTRIES);
    +    }
    +
    +    return 0;
    +}
    +
    +
    +void platformVideoExclusiveFlush(const DisplayT *d, const RectT *r) {
    +    platformFlushRect(d, r);
    +}
    +
    +
     void platformVideoFreeBuffers(DisplayT *d) {
         free(d->backBuf);
         d->backBuf = NULL;
    @@ -1009,6 +1049,11 @@ void platformVideoShutdown(DisplayT *d) {
     }
     
     
    +// No retrace to wait for on the host.
    +void platformVideoWaitRetrace(void) {
    +}
    +
    +
     // Nothing else is running in a test process; the fake clock moves on by
     // the yield step (hostClockSetYieldStep), in real-time mode not at all.
     void platformYield(void) {
    diff --git a/src/libs/kpunch/serial/rs232/rs232.c b/src/libs/kpunch/serial/rs232/rs232.c
    index d7e745df..45e1944b 100644
    --- a/src/libs/kpunch/serial/rs232/rs232.c
    +++ b/src/libs/kpunch/serial/rs232/rs232.c
    @@ -162,6 +162,9 @@ DVX_ABI_STAMP(serial);
     #define UART_READ_LCR(C)        inportb((C)->base + UART_LCR)
     #define UART_READ_LSR(C)        ((C)->lsr = inportb((C)->base + UART_LSR))
     #define UART_READ_MSR(C)        ((C)->msr = inportb((C)->base + UART_MSR))
    +// The live MCR, for the modem lines another program left set before this
    +// port was opened (the cached mcr only knows what this library wrote).
    +#define UART_READ_MCR(C)        inportb((C)->base + UART_MCR)
     #define UART_READ_BPS(C, D) \
         { outportb((C)->base + UART_LCR, inportb((C)->base + UART_LCR) | LCR_DLAB); \
           (D) = (uint16_t)inportw((C)->base + UART_DLW); \
    @@ -256,6 +259,9 @@ DVX_ABI_STAMP(serial);
     #define MCR_OUT2                0x08
     #define MCR_LOOPBACK            0x10
     #define MCR_MASK                (MCR_DTR | MCR_RTS | MCR_OUT1 | MCR_OUT2 | MCR_LOOPBACK)
    +// The modem control lines: never changed by opening or closing a port,
    +// only by rs232SetDtr / rs232SetRts / rs232SetMcr and flow control.
    +#define MCR_LINES               (MCR_DTR | MCR_RTS)
     
     // LSR bits
     #define LSR_DATA_READY          0x01
    @@ -732,8 +738,9 @@ static int32_t findIrq(Rs232StateT *com) {
         int32_t  bit;
         uint32_t flags;
     
    -    // Set up the UART
    -    UART_WRITE_MCR(com, MCR_OUT2);
    +    // Set up the UART (OUT2 gates the interrupt line; DTR and RTS stay as
    +    // they are, so probing never hangs up a modem)
    +    UART_WRITE_MCR(com, (UART_READ_MCR(com) & MCR_LINES) | MCR_OUT2);
         UART_WRITE_FCR(com, 0);
     
         // Wait until TX hold register is empty
    @@ -986,7 +993,11 @@ int32_t rs232Close(int32_t com) {
         flags        = irqSave();
         port->isOpen = 0;
         UART_WRITE_IER(port, 0);
    -    UART_WRITE_MCR(port, 0);
    +    // DTR and RTS are left as they are: closing a port must never hang up
    +    // a modem or signal the other end.  Only OUT2 (the interrupt gate),
    +    // OUT1 and loopback are cleared.  To hang up, drop DTR first
    +    // (rs232SetDtr); the line stays down after the close.
    +    UART_WRITE_MCR(port, port->mcr & MCR_LINES);
         UART_WRITE_FCR(port, 0);
         drainRxFifo(port);
         UART_READ_IIR(port);
    diff --git a/src/libs/kpunch/serial/serial.dhs b/src/libs/kpunch/serial/serial.dhs
    index 2b7b403c..4d204f13 100644
    --- a/src/libs/kpunch/serial/serial.dhs
    +++ b/src/libs/kpunch/serial/serial.dhs
    @@ -177,7 +177,7 @@ Returns RS232_SUCCESS or a negative error code. The UART type is probed once her
     
     .h2 rs232Close
     
    -Close a COM port and release its ISR resources.
    +Close a COM port and release its ISR resources. The modem control lines are left as they are: closing never drops DTR or RTS, so a modem stays connected and the next program to open the port can carry on. To hang up, call rs232SetDtr(com, false) before closing; the line stays down after the close. Opening a port raises DTR and RTS.
     
     .code
     int32_t rs232Close(int32_t com);
    @@ -276,9 +276,9 @@ rs232GetParity returns a lowercase character on success. Callers comparing again
       rs232SetHandshake(com, handshake) Change flow control mode. Releases any throttle the old mode applied (raises RTS/DTR or sends XON) and starts the transmit side from the live CTS/DSR line.
       rs232SetIrq(com, irq)             Move an open port to another IRQ (for a wrong auto-detection). Interrupt enables and DTR/RTS are kept.
       rs232SetMcr(com, mcr)             Set modem control register directly.
    -  rs232SetDtr(com, dtr)             Set DTR line state.
    +  rs232SetDtr(com, dtr)             Set DTR line state. The state is kept when the port is closed (dropping DTR is how a modem is hung up).
       rs232SetBreak(com, on)            Hold the transmit line in the break (spacing) state (on = true) or release it. The caller times the break, typically 250-500 ms.
    -  rs232SetRts(com, rts)             Set RTS line state.
    +  rs232SetRts(com, rts)             Set RTS line state. The state is kept when the port is closed.
       rs232SetFifoThreshold(com, thr)   Set FIFO trigger level (1, 4, 8, or 14 bytes) or 0 to turn the FIFO off. Levels other than 0 return RS232_ERR_INVALID_FIFO unless the UART is a 16550A.
     .endtable
     
    diff --git a/src/sdk/README.TXT b/src/sdk/README.TXT
    index f7656353..4c8e5c16 100644
    --- a/src/sdk/README.TXT
    +++ b/src/sdk/README.TXT
    @@ -27,6 +27,18 @@ packaging.  Technical Reference > Writing Games for DVX
     covers game input, timing, the screen and palettes, sound, music,
     joysticks, data and save files, and porting a game library to DVX.
     
    +Documentation
    +-------------
    +
    +  docs/dvx_system_reference.html  The DVX help as one page: the user guide,
    +                                  the SDK Quick Start, Writing Games for
    +                                  DVX, and the reference for every header,
    +                                  library and widget
    +  docs/dvx_basic_reference.html   DVX BASIC: the language, the IDE, the
    +                                  controls and the include libraries
    +
    +Inside DVX the same text is in the Help program.
    +
     Directory Structure
     -------------------
     
    @@ -68,9 +80,9 @@ Requirements
     
       - The DJGPP cross compiler (i586-pc-msdosdjgpp-gcc) and its dxe3gen.
         Projects find it through DJGPP_PREFIX (environment or make command
    -    line); the default is toolchains/djgpp in the DVX source tree the SDK
    -    was built in (../../toolchains/djgpp from this directory), which
    -    ./installToolchain.sh sets up.
    +    line), e.g. make DJGPP_PREFIX=/opt/djgpp.  Without it the SDK looks in
    +    ../../toolchains/djgpp from this directory: inside a DVX source tree
    +    that is the toolchain ./installToolchain.sh sets up.
       - A Linux host for the tools in bin/.  On DVX itself the same tools are
         SYSTEM/DVXRES.EXE, SYSTEM/DVXHLPC.EXE and SYSTEM/BASCOMP.EXE.
     
    diff --git a/src/sdk/templates/game/tplName.c b/src/sdk/templates/game/tplName.c
    index 2ab14f33..7c8dd8f7 100644
    --- a/src/sdk/templates/game/tplName.c
    +++ b/src/sdk/templates/game/tplName.c
    @@ -54,6 +54,8 @@
     // The game's screen
     #define FB_W                320
     #define FB_H                200
    +#define FULL_BPP            8       // full screen: 256 colours, the game's palette in the DAC
    +#define SCAN_ALT_ENTER      0xA6    // BIOS scancode of Alt with the keypad Enter
     #define SCALE_MAX           3
     #define FRAME_HZ            60
     #define STEPS_MAX           4       // frames made up at once after a stall
    @@ -117,6 +119,7 @@
     #define CMD_GAME_PAUSE      101
     #define CMD_GAME_MUSIC      102
     #define CMD_GAME_EXIT       103
    +#define CMD_GAME_FULL       104
     #define CMD_HELP_CONTENTS   200
     #define CMD_HELP_ABOUT      201
     
    @@ -154,6 +157,7 @@ static int32_t         sScale     = 1;
     static bool            sQuit      = false;
     static bool            sPaused    = false;
     static bool            sMusicOn   = true;
    +static bool            sFull      = false;
     static GameStateT      sGame;
     
     // The game's screen and palette, and the desktop pixel for each colour
    @@ -181,6 +185,8 @@ static void     expandRow(uint8_t *dst, const uint8_t *src, int32_t count, int32
     static void     fbFill(int32_t x, int32_t y, int32_t w, int32_t h, uint8_t color);
     static void     fbFrame(int32_t x, int32_t y, int32_t size, uint8_t fill, uint8_t edge);
     static void     fbSky(int32_t x, int32_t y, int32_t w, int32_t h);
    +static void     fullChanged(WindowT *win, ExclusiveStateE state, void *userData);
    +static void     fullSet(bool on);
     static void     gameNew(void);
     static void     gameUpdate(uint32_t steps);
     static void     loadBest(void);
    @@ -238,6 +244,7 @@ static void coinGlow(void) {
         sGamePal[COL_COIN * PAL_CHANNELS + 0] = glow;
         sGamePal[COL_COIN * PAL_CHANNELS + 1] = glow;
         lutSetRange(COL_COIN, 1);
    +    dvxExclusiveSetPalette(sAc, sGamePal, COL_COIN, 1);
     }
     
     
    @@ -347,6 +354,45 @@ static void fbSky(int32_t x, int32_t y, int32_t w, int32_t h) {
     }
     
     
    +// Full screen came, went away for a while (another window has the focus:
    +// the game draws in its window again) or ended.
    +static void fullChanged(WindowT *win, ExclusiveStateE state, void *userData) {
    +    (void)win;
    +    (void)userData;
    +
    +    if (state == ExclusiveEndedE) {
    +        sFull = false;
    +        wmMenuItemSetChecked(sWin->menuBar, CMD_GAME_FULL, false);
    +    }
    +
    +    if (state == ExclusiveActiveE) {
    +        dvxExclusiveSetPalette(sAc, sGamePal, 0, PAL_ENTRIES);
    +    }
    +
    +    dvxLog("tplName: full screen %s", state == ExclusiveActiveE ? "on" : state == ExclusiveSuspendedE ? "suspended" : "ended");
    +    dirtyAdd(0, 0, FB_W, FB_H);
    +}
    +
    +
    +// Alt+Enter: the whole screen at 320x200 (or the nearest mode DVX has),
    +// or back to the window.  The other programs run on either way.
    +static void fullSet(bool on) {
    +    if (on && dvxExclusiveBegin(sAc, sWin, FB_W, FB_H, FULL_BPP, fullChanged, NULL) != DVX_EXCL_OK) {
    +        dvxLog("tplName: no full-screen mode");
    +        on = false;
    +    }
    +
    +    if (!on && sFull) {
    +        dvxExclusiveEnd(sAc, sWin);
    +        dirtyAdd(0, 0, FB_W, FB_H);
    +        dvxLog("tplName: full screen off");
    +    }
    +
    +    sFull = on;
    +    wmMenuItemSetChecked(sWin->menuBar, CMD_GAME_FULL, on);
    +}
    +
    +
     static void gameNew(void) {
         sGame.playerX    = (FB_W - PLAYER_SIZE) / 2 - FIRST_COIN_DX / 2;
         sGame.playerY    = (FB_H - PLAYER_SIZE) / 2;
    @@ -470,10 +516,15 @@ static void onClose(WindowT *win) {
     
     
     // In a game input window every key but Alt+Tab, Ctrl+Esc and Alt+F4
    -// comes here, F1 included: the game decides what it does.
    +// comes here, F1 included: the game decides what it does.  Alt+Enter
    +// comes as Enter or as a bare Enter scancode (the main or keypad key).
     static void onKey(WindowT *win, int32_t key, int32_t mod) {
         (void)win;
    -    (void)mod;
    +
    +    if ((mod & KEY_MOD_ALT) && (key == KEY_ENTER || key == (KEY_SCAN_ENTER | KEY_EXT_FLAG) || key == (SCAN_ALT_ENTER | KEY_EXT_FLAG))) {
    +        fullSet(!sFull);
    +        return;
    +    }
     
         switch (key) {
             case KEY_F1:
    @@ -513,6 +564,10 @@ static void onMenu(WindowT *win, int32_t menuId) {
                 musicApply();
                 break;
     
    +        case CMD_GAME_FULL:
    +            fullSet(!sFull);
    +            break;
    +
             case CMD_GAME_EXIT:
                 sQuit = true;
                 break;
    @@ -566,8 +621,14 @@ static void paletteInit(void) {
     }
     
     
    -// Copy this frame's changed rectangles to the window and tell DVX.
    +// Copy this frame's changed rectangles to the window and tell DVX.  In
    +// full screen the whole frame goes to the screen instead.
     static void present(void) {
    +    if (sDirtyCount > 0 && dvxExclusivePresent8(sAc, sFb, FB_W, FB_H, FB_W)) {
    +        sDirtyCount = 0;
    +        return;
    +    }
    +
         for (int32_t i = 0; i < sDirtyCount; i++) {
             const GameRectT *r = &sDirty[i];
     
    @@ -799,6 +860,7 @@ static bool windowCreate(void) {
             wmAddMenuItem(gameMenu, "&New Game\tF2", CMD_GAME_NEW);
             wmAddMenuCheckItem(gameMenu, "&Pause\tP", CMD_GAME_PAUSE, false);
             wmAddMenuCheckItem(gameMenu, "&Music", CMD_GAME_MUSIC, true);
    +        wmAddMenuCheckItem(gameMenu, "&Full Screen\tAlt+Enter", CMD_GAME_FULL, false);
             wmAddMenuSeparator(gameMenu);
             wmAddMenuItem(gameMenu, "E&xit", CMD_GAME_EXIT);
         }
    diff --git a/src/sdk/templates/game/tplName.dhs b/src/sdk/templates/game/tplName.dhs
    index bb89d800..a2d2a379 100644
    --- a/src/sdk/templates/game/tplName.dhs
    +++ b/src/sdk/templates/game/tplName.dhs
    @@ -42,7 +42,8 @@ While TplName is the active window it has the keyboard to itself: keys that usua
       P                  Pause, or carry on.
       F1                 Open this help.
       F2                 Start a new round.
    -  Alt+Tab            Switch to another program.
    +  Alt+Enter          Play on the whole screen, or back in the window.
    +  Alt+Tab            Switch to another program (from the whole screen too: TplName comes back to it when you return).
       Alt+F4             Close TplName.
       Ctrl+Esc           Open the Task Manager.
     .endtable
    @@ -61,6 +62,7 @@ While TplName is the active window it has the keyboard to itself: keys that usua
       Game    New Game         Start a new round.
       Game    Pause            Pause the game, or carry on.
       Game    Music            Turn the music on or off.
    +  Game    Full Screen      Play on the whole screen (Alt+Enter), or back in the window.
       Game    Exit             Close TplName.
       Help    Contents         Open this help.
       Help    About TplName    Show the program name.
    diff --git a/src/test/abi/abiSizes.txt b/src/test/abi/abiSizes.txt
    index 2bafc777..be75347f 100644
    --- a/src/test/abi/abiSizes.txt
    +++ b/src/test/abi/abiSizes.txt
    @@ -2,12 +2,12 @@
     # (i386 DJGPP), recorded at the DVX_ABI_VERSION below.  Generated by
     # `make -C src/test/abi update`; read abiCheck.py and DVX_ABI_VERSION in
     # src/libs/kpunch/libdvx/dvxTypes.h before changing it.
    -version 3
    +version 4
     AccelEntryT           12
     AccelTableT           12
     AnsiTermApiT          36
     AnsiTermExApiT        16
    -AppContextT           1460
    +AppContextT           3400
     AppDescriptorT        80
     AudioDriverT          28
     AudioDrvConfigT       16
    @@ -43,6 +43,7 @@ DvxResDirEntryT       48
     DvxResFooterT         16
     DvxResHandleT         268
     DxeAppContextT        2216
    +ExclusiveStateT       1940
     FileFilterT           4
     FileOpsCallbacksT     12
     FileOpsEntryT         20
    diff --git a/src/test/dos/fixtures/ticker/ticker.dbp b/src/test/dos/fixtures/ticker/ticker.dbp
    new file mode 100644
    index 00000000..afa3871d
    --- /dev/null
    +++ b/src/test/dos/fixtures/ticker/ticker.dbp
    @@ -0,0 +1,14 @@
    +[Project]
    +Name = Ticker
    +Author = Scott Duensing
    +Publisher = Kangaroo Punch Studios
    +Version = 1.00
    +Copyright = Copyright 2026 Scott Duensing
    +Description = DOS harness fixture: a timer counting in the caption, to show a program runs on in the background
    +
    +[Forms]
    +File0 = ticker.frm
    +Count = 1
    +
    +[Settings]
    +StartupForm = Ticker
    diff --git a/src/test/dos/fixtures/ticker/ticker.frm b/src/test/dos/fixtures/ticker/ticker.frm
    new file mode 100644
    index 00000000..ed3639a5
    --- /dev/null
    +++ b/src/test/dos/fixtures/ticker/ticker.frm
    @@ -0,0 +1,60 @@
    +VERSION DVX 1.00
    +
    +Begin Form Ticker
    +    Caption   = "Counting 0"
    +    Layout    = VBox
    +    AutoSize  = False
    +    Resizable = False
    +    Width     = 200
    +    Height    = 60
    +
    +    Begin Label lblNote
    +        Caption = "Counts to 100"
    +        Weight  = 1
    +    End
    +
    +    Begin Timer tmrTick
    +        Interval = 100
    +        Enabled  = True
    +    End
    +End
    +
    +' The MIT License (MIT)
    +'
    +' Copyright (C) 2026 Scott Duensing
    +'
    +' Permission is hereby granted, free of charge, to any person obtaining a copy
    +' of this software and associated documentation files (the "Software"), to
    +' deal in the Software without restriction, including without limitation the
    +' rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
    +' sell copies of the Software, and to permit persons to whom the Software is
    +' furnished to do so, subject to the following conditions:
    +'
    +' The above copyright notice and this permission notice shall be included in
    +' all copies or substantial portions of the Software.
    +'
    +' THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    +' IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    +' FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.  IN NO EVENT SHALL THE
    +' AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    +' LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
    +' FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
    +' IN THE SOFTWARE.
    +
    +' ticker -- DOS harness fixture.  Counts to 100, ten a second, in the
    +' window caption, then shows "Counted 100".  The fullscr script starts it,
    +' puts a game on the whole screen and waits for "Counted 100" while the
    +' game still has the screen: the program ran on in the background.
    +
    +Dim Shared ticks As Integer
    +
    +Sub tmrTick_Timer ()
    +    ticks = ticks + 1
    +
    +    If ticks >= 100 Then
    +        tmrTick.Enabled = False
    +        Ticker.Caption = "Counted 100"
    +    Else
    +        Ticker.Caption = "Counting " & LTrim$(Str$(ticks))
    +    End If
    +End Sub
    diff --git a/src/test/dos/runDos.sh b/src/test/dos/runDos.sh
    index f3dd6197..046efede 100755
    --- a/src/test/dos/runDos.sh
    +++ b/src/test/dos/runDos.sh
    @@ -1416,12 +1416,14 @@ for script in "${SCRIPTS[@]}"; do
             SCRIPT_DOSBOX_MACHINE="${DOSBOX_OLD_MACHINE}"
         fi
     
    -    # "# LOG: text": DVX.LOG must contain text (checked after the run).
    +    # "# LOG: text": DVX.LOG must contain text (checked after the run);
    +    # "# LOG(emulator): text" only on that emulator (e.g. a mode one of
    +    # the emulated video cards does not offer).
         LOG_EXPECTS=()
     
         while IFS= read -r expect; do
             LOG_EXPECTS+=("${expect}")
    -    done < <(sed -n 's/^#[[:space:]]*LOG:[[:space:]]*\(.*[^[:space:]]\)[[:space:]]*$/\1/p' "${script}")
    +    done < <(sed -n -e 's/^#[[:space:]]*LOG:[[:space:]]*\(.*[^[:space:]]\)[[:space:]]*$/\1/p' -e "s/^#[[:space:]]*LOG(${EMU}):[[:space:]]*\(.*[^[:space:]]\)[[:space:]]*$/\1/p" "${script}")
     
         # "# DOSBOX-CPU: type": DOSBox Staging's cputype (e.g. pentium, which
         # has a time-stamp counter); 86Box keeps its 486.
    diff --git a/src/test/dos/script/scriptLang.c b/src/test/dos/script/scriptLang.c
    index bdb6885d..ef5fba9a 100644
    --- a/src/test/dos/script/scriptLang.c
    +++ b/src/test/dos/script/scriptLang.c
    @@ -133,6 +133,7 @@ typedef struct {
     
     static bool           addStep(ParseStateT *ps, const ScriptStepT *step);
     static bool           cmdButton(ParseStateT *ps, ScriptStepT *step, const TokenT *tok, int32_t count);
    +static bool           cmdFullScreen(ParseStateT *ps, ScriptStepT *step, const TokenT *tok, int32_t count);
     static bool           cmdKey(ParseStateT *ps, ScriptStepT *step, const TokenT *tok, int32_t count);
     static bool           cmdLaunch(ParseStateT *ps, ScriptStepT *step, const TokenT *tok, int32_t count);
     static bool           cmdLog(ParseStateT *ps, ScriptStepT *step, const TokenT *tok, int32_t count);
    @@ -229,34 +230,42 @@ static const RawKeyNameT sRawKeyNames[] = {
     
     #define RAW_KEY_NAME_COUNT ((int32_t)(sizeof(sRawKeyNames) / sizeof(sRawKeyNames[0])))
     
    +// FULLSCREEN depths
    +#define SCRIPT_BPP_8   8
    +#define SCRIPT_BPP_15  15
    +#define SCRIPT_BPP_16  16
    +#define SCRIPT_BPP_32  32
    +
     static const CommandT sCommands[] = {
    -    { "ASSERTNOWIN", ScriptOpAssertNoWinE, cmdTitle   },
    -    { "ASSERTWIN",   ScriptOpAssertWinE,   cmdTitle   },
    -    { "CLICK",       ScriptOpClickE,       cmdPoint   },
    -    { "CLOCKMARK",   ScriptOpClockMarkE,   cmdNoArgs  },
    -    { "DBLCLICK",    ScriptOpDblClickE,    cmdPoint   },
    -    { "DRAG",        ScriptOpDragE,        cmdPoint   },
    -    { "KEY",         ScriptOpKeyE,         cmdKey     },
    -    { "KEYDOWN",     ScriptOpKeyDownE,     cmdRawKey  },
    -    { "KEYUP",       ScriptOpKeyUpE,       cmdRawKey  },
    -    { "LAUNCH",      ScriptOpLaunchE,      cmdLaunch  },
    -    { "LOG",         ScriptOpLogE,         cmdLog     },
    -    { "LOGWINDOWS",  ScriptOpLogWindowsE,  cmdNoArgs  },
    -    { "MOUSEDOWN",   ScriptOpMouseDownE,   cmdButton  },
    -    { "MOUSEUP",     ScriptOpMouseUpE,     cmdButton  },
    -    { "MOVE",        ScriptOpMoveE,        cmdPoint   },
    -    { "OUTDIR",      ScriptOpLogE,         cmdOutDir  },
    -    { "QUIT",        ScriptOpQuitE,        cmdQuit    },
    -    { "SHOT",        ScriptOpShotE,        cmdShot    },
    -    { "TIMEOUT",     ScriptOpLogE,         cmdTimeout },
    -    { "TYPE",        ScriptOpTypeE,        cmdType    },
    -    { "WAIT",        ScriptOpWaitE,        cmdWait    },
    -    { "WAITEXIT",    ScriptOpWaitExitE,    cmdTitle   },
    -    { "WAITHOST",    ScriptOpWaitHostE,    cmdTitle   },
    -    { "WAITLOG",     ScriptOpWaitLogE,     cmdTitle   },
    -    { "WAITMS",      ScriptOpWaitMsE,      cmdWait    },
    -    { "WAITNOWIN",   ScriptOpWaitNoWinE,   cmdTitle   },
    -    { "WAITWIN",     ScriptOpWaitWinE,     cmdTitle   }
    +    { "ASSERTNOWIN", ScriptOpAssertNoWinE, cmdTitle      },
    +    { "ASSERTWIN",   ScriptOpAssertWinE,   cmdTitle      },
    +    { "CLICK",       ScriptOpClickE,       cmdPoint      },
    +    { "CLOCKMARK",   ScriptOpClockMarkE,   cmdNoArgs     },
    +    { "DBLCLICK",    ScriptOpDblClickE,    cmdPoint      },
    +    { "DRAG",        ScriptOpDragE,        cmdPoint      },
    +    { "FULLSCREEN",  ScriptOpFullScreenE,  cmdFullScreen },
    +    { "KEY",         ScriptOpKeyE,         cmdKey        },
    +    { "KEYDOWN",     ScriptOpKeyDownE,     cmdRawKey     },
    +    { "KEYUP",       ScriptOpKeyUpE,       cmdRawKey     },
    +    { "LAUNCH",      ScriptOpLaunchE,      cmdLaunch     },
    +    { "LOG",         ScriptOpLogE,         cmdLog        },
    +    { "LOGWINDOWS",  ScriptOpLogWindowsE,  cmdNoArgs     },
    +    { "MOUSEDOWN",   ScriptOpMouseDownE,   cmdButton     },
    +    { "MOUSEUP",     ScriptOpMouseUpE,     cmdButton     },
    +    { "MOVE",        ScriptOpMoveE,        cmdPoint      },
    +    { "OUTDIR",      ScriptOpLogE,         cmdOutDir     },
    +    { "QUIT",        ScriptOpQuitE,        cmdQuit       },
    +    { "SHOT",        ScriptOpShotE,        cmdShot       },
    +    { "TIMEOUT",     ScriptOpLogE,         cmdTimeout    },
    +    { "TYPE",        ScriptOpTypeE,        cmdType       },
    +    { "WAIT",        ScriptOpWaitE,        cmdWait       },
    +    { "WAITEXIT",    ScriptOpWaitExitE,    cmdTitle      },
    +    { "WAITHOST",    ScriptOpWaitHostE,    cmdTitle      },
    +    { "WAITLOG",     ScriptOpWaitLogE,     cmdTitle      },
    +    { "WAITMS",      ScriptOpWaitMsE,      cmdWait       },
    +    { "WAITNOWIN",   ScriptOpWaitNoWinE,   cmdTitle      },
    +    { "WAITWIN",     ScriptOpWaitWinE,     cmdTitle      },
    +    { "WINDOWED",    ScriptOpWindowedE,    cmdNoArgs     }
     };
     
     #define COMMAND_COUNT ((int32_t)(sizeof(sCommands) / sizeof(sCommands[0])))
    @@ -298,6 +307,22 @@ static bool cmdButton(ParseStateT *ps, ScriptStepT *step, const TokenT *tok, int
     
     
     // KEY key [key ...]: one step per key
    +// FULLSCREEN width height bpp (bpp 0, 8, 15, 16 or 32)
    +static bool cmdFullScreen(ParseStateT *ps, ScriptStepT *step, const TokenT *tok, int32_t count) {
    +    bool ok = count == 3 && parseInt(tok[0].text, &step->from.x) && parseInt(tok[1].text, &step->from.y) && parseInt(tok[2].text, &step->count);
    +
    +    if (!ok || step->from.x <= 0 || step->from.y <= 0) {
    +        return fail(ps, "expected width, height and bits per pixel");
    +    }
    +
    +    if (step->count != 0 && step->count != SCRIPT_BPP_8 && step->count != SCRIPT_BPP_15 && step->count != SCRIPT_BPP_16 && step->count != SCRIPT_BPP_32) {
    +        return fail(ps, "bits per pixel must be 0, 8, 15, 16 or 32");
    +    }
    +
    +    return addStep(ps, step);
    +}
    +
    +
     static bool cmdKey(ParseStateT *ps, ScriptStepT *step, const TokenT *tok, int32_t count) {
         if (count < 1) {
             return fail(ps, "KEY needs at least one key name");
    @@ -358,6 +383,8 @@ static bool cmdLog(ParseStateT *ps, ScriptStepT *step, const TokenT *tok, int32_
     
     
     // A command without arguments (LOGWINDOWS)
    +
    +
     static bool cmdNoArgs(ParseStateT *ps, ScriptStepT *step, const TokenT *tok, int32_t count) {
         (void)tok;
     
    diff --git a/src/test/dos/script/scriptLang.h b/src/test/dos/script/scriptLang.h
    index 6ee6abd2..8ba21aba 100644
    --- a/src/test/dos/script/scriptLang.h
    +++ b/src/test/dos/script/scriptLang.h
    @@ -60,6 +60,7 @@ typedef enum {
         ScriptOpClockMarkE,     // CLOCKMARK: send "CLOCK " to the host
         ScriptOpDblClickE,      // DBLCLICK pos
         ScriptOpDragE,          // DRAG [button] pos pos
    +    ScriptOpFullScreenE,    // FULLSCREEN w h bpp: the script's own window takes the whole screen with a test pattern
         ScriptOpKeyDownE,       // KEYDOWN key: press it at the keyboard (one step per key)
         ScriptOpKeyE,           // KEY keyspec (one step per key)
         ScriptOpKeyUpE,         // KEYUP key: release it at the keyboard (one step per key)
    @@ -78,7 +79,8 @@ typedef enum {
         ScriptOpWaitLogE,       // WAITLOG "text" [seconds]
         ScriptOpWaitMsE,        // WAITMS milliseconds
         ScriptOpWaitNoWinE,     // WAITNOWIN "title" [seconds]
    -    ScriptOpWaitWinE        // WAITWIN "title" [seconds]
    +    ScriptOpWaitWinE,       // WAITWIN "title" [seconds]
    +    ScriptOpWindowedE       // WINDOWED: FULLSCREEN's window gives the screen back and closes
     } ScriptOpE;
     
     // How a coordinate is measured.  Screen coordinates are always
    @@ -114,10 +116,10 @@ typedef struct {
         char         text[SCRIPT_TEXT_MAX];  // title, path, text or file name
         char         args[SCRIPT_TEXT_MAX];  // LAUNCH arguments
         ScriptKeyT   key;                    // KEY; KEYDOWN / KEYUP: scancode is the raw key code
    -    ScriptPointT from;                   // position; DRAG start
    +    ScriptPointT from;                   // position; DRAG start; FULLSCREEN size (x, y)
         ScriptPointT to;                     // DRAG end
         int32_t      button;                 // MOUSE_LEFT / MOUSE_RIGHT / MOUSE_MIDDLE
    -    int32_t      count;                  // frames, or a time in ms
    +    int32_t      count;                  // frames, or a time in ms; FULLSCREEN bits per pixel
         bool         pass;                   // QUIT status
     } ScriptStepT;
     
    diff --git a/src/test/dos/script/scriptRun.c b/src/test/dos/script/scriptRun.c
    index 91b6e5be..c157363f 100644
    --- a/src/test/dos/script/scriptRun.c
    +++ b/src/test/dos/script/scriptRun.c
    @@ -44,6 +44,12 @@
     #define CLOCK_LINE_MAX    48
     #define CS_PER_SECOND     100    // CLOCKMARK reports hundredths of a second
     #define US_PER_CS         10000
    +#define FULL_TITLE        "Full Screen Test"
    +#define FULL_WIN_W        160
    +#define FULL_WIN_H        120
    +#define FULL_SQUARE_SHIFT 4      // test pattern: 16x16 squares
    +#define FULL_SQUARES_ROW  16     // palette entries per row of squares
    +#define WHITE             255
     
     // Phases of the multi-frame mouse steps
     #define PHASE_MOVE        0
    @@ -71,9 +77,11 @@ typedef enum {
     
     static uint32_t    elapsedMs(PlatformTicksT since);
     static StepResultE execClockMark(ScriptRunT *run, const ScriptStepT *step);
    +static StepResultE execFullScreen(ScriptRunT *run, const ScriptStepT *step);
     static StepResultE execMouse(ScriptRunT *run, const ScriptStepT *step);
     static StepResultE execStep(ScriptRunT *run, const ScriptStepT *step);
     static StepResultE execWait(ScriptRunT *run, const ScriptStepT *step);
    +static void        execWindowed(ScriptRunT *run);
     static StepResultE failStep(ScriptRunT *run, const ScriptStepT *step, const char *fmt, ...) __attribute__((format(printf, 3, 4)));
     static WindowT    *findWindow(const AppContextT *ctx, const char *pattern);
     static void        finishRun(ScriptRunT *run, bool pass, const char *detail);
    @@ -114,6 +122,53 @@ static StepResultE execClockMark(ScriptRunT *run, const ScriptStepT *step) {
     }
     
     
    +// FULLSCREEN: the script's own window takes the whole screen and shows a
    +// test pattern in the DVX palette: 16x16 squares of every entry, and a
    +// white diagonal one pixel wide, so a plane, page or scaling mix-up shows
    +// in a screenshot.
    +static StepResultE execFullScreen(ScriptRunT *run, const ScriptStepT *step) {
    +    AppContextT *ctx = run->env.ctx;
    +    int32_t      w   = step->from.x;
    +    int32_t      h   = step->from.y;
    +    uint8_t      pal[PAL_ENTRIES * PAL_CHANNELS];
    +
    +    if (!run->fullWin) {
    +        run->fullWin = dvxCreateWindow(ctx, FULL_TITLE, 0, 0, FULL_WIN_W, FULL_WIN_H, false);
    +
    +        if (!run->fullWin) {
    +            return failStep(run, step, "no window for FULLSCREEN");
    +        }
    +    }
    +
    +    int32_t rc = dvxExclusiveBegin(ctx, run->fullWin, w, h, step->count, NULL, NULL);
    +
    +    if (rc != DVX_EXCL_OK) {
    +        return failStep(run, step, "FULLSCREEN %" PRId32 "x%" PRId32 " at %" PRId32 " bpp failed (%" PRId32 ")", w, h, step->count, rc);
    +    }
    +
    +    uint8_t *pic = (uint8_t *)malloc((size_t)w * (size_t)h);
    +
    +    if (!pic) {
    +        return failStep(run, step, "out of memory for the FULLSCREEN pattern");
    +    }
    +
    +    dvxGeneratePalette(pal);
    +
    +    uint8_t white = dvxNearestPalEntry(pal, WHITE, WHITE, WHITE);
    +
    +    for (int32_t y = 0; y < h; y++) {
    +        for (int32_t x = 0; x < w; x++) {
    +            pic[y * w + x] = x == y ? white : (uint8_t)((y >> FULL_SQUARE_SHIFT) * FULL_SQUARES_ROW + (x >> FULL_SQUARE_SHIFT));
    +        }
    +    }
    +
    +    dvxExclusiveSetPalette(ctx, pal, 0, PAL_ENTRIES);
    +    dvxExclusivePresent8(ctx, pic, w, h, w);
    +    free(pic);
    +    return StepYieldE;
    +}
    +
    +
     // MOVE, CLICK, DBLCLICK, DRAG, MOUSEDOWN, MOUSEUP.  One mouse state per
     // tick: move first, then each press and release on its own frame.
     static StepResultE execMouse(ScriptRunT *run, const ScriptStepT *step) {
    @@ -219,6 +274,13 @@ static StepResultE execStep(ScriptRunT *run, const ScriptStepT *step) {
             case ScriptOpClockMarkE:
                 return execClockMark(run, step);
     
    +        case ScriptOpFullScreenE:
    +            return execFullScreen(run, step);
    +
    +        case ScriptOpWindowedE:
    +            execWindowed(run);
    +            return StepYieldE;
    +
             case ScriptOpKeyDownE:
             case ScriptOpKeyUpE:
                 if (run->phase == 0) {
    @@ -370,6 +432,18 @@ static StepResultE execWait(ScriptRunT *run, const ScriptStepT *step) {
     }
     
     
    +// WINDOWED: FULLSCREEN's window gives the screen back and closes.
    +static void execWindowed(ScriptRunT *run) {
    +    if (!run->fullWin) {
    +        return;
    +    }
    +
    +    dvxExclusiveEnd(run->env.ctx, run->fullWin);
    +    dvxDestroyWindow(run->env.ctx, run->fullWin);
    +    run->fullWin = NULL;
    +}
    +
    +
     static StepResultE failStep(ScriptRunT *run, const ScriptStepT *step, const char *fmt, ...) {
         char    reason[SCRIPT_DETAIL_MAX];
         char    detail[SCRIPT_DETAIL_MAX];
    diff --git a/src/test/dos/script/scriptRun.h b/src/test/dos/script/scriptRun.h
    index 95f23fef..7b42a472 100644
    --- a/src/test/dos/script/scriptRun.h
    +++ b/src/test/dos/script/scriptRun.h
    @@ -86,6 +86,7 @@ typedef struct {
         int32_t        fromY;
         int32_t        toX;
         int32_t        toY;
    +    WindowT       *fullWin;       // FULLSCREEN's window (NULL: none)
         bool           mouseKnown;    // mouseX/Y seeded from the context
         bool           finished;
         bool           passed;
    diff --git a/src/test/dos/scripts/fullmode.scr b/src/test/dos/scripts/fullmode.scr
    new file mode 100644
    index 00000000..1e9806fc
    --- /dev/null
    +++ b/src/test/dos/scripts/fullmode.scr
    @@ -0,0 +1,33 @@
    +# fullmode.scr -- every kind of full-screen mode shows its picture.
    +#
    +# FULLSCREEN puts the script's own window on the whole screen with a test
    +# pattern (16x16 squares of every palette entry and a white diagonal one
    +# pixel wide).  320x240 at 8 bpp goes to VGA Mode X: planar, two pages,
    +# each frame flipped at the retrace; its screenshot is read back from
    +# video memory, so it shows what the planes hold.  320x200 at 8 bpp goes
    +# to VGA mode 13h; 640x480 at 16 bpp to a VBE mode, the pattern shown
    +# through the palette.  WINDOWED gives the desktop back.  86Box's S3 card
    +# has VBE modes of exactly 320x240 and 320x200 at 8 bpp, which win over
    +# the VGA modes there; DOSBox's has neither, so it runs Mode X and 13h.
    +# LOG(dosbox): Video: VGA Mode X (planar, two pages)
    +# LOG(dosbox): Video: VGA mode 13h
    +# LOG: Video: full screen 320x240 at 8 bpp
    +# LOG: Video: full screen 320x200 at 8 bpp
    +# LOG: Video: full screen 640x480 at 16 bpp
    +TIMEOUT 60
    +WAITWIN "Program Manager"
    +MOVE 639 479
    +FULLSCREEN 320 240 8
    +WAIT 5
    +SHOT fm-modex
    +FULLSCREEN 320 200 8
    +WAIT 5
    +SHOT fm-13h
    +FULLSCREEN 640 480 16
    +WAIT 5
    +SHOT fm-vbe
    +WINDOWED
    +WAIT 10
    +ASSERTNOWIN "Full Screen Test"
    +SHOT fm-desk
    +QUIT
    diff --git a/src/test/dos/scripts/fullscr.scr b/src/test/dos/scripts/fullscr.scr
    new file mode 100644
    index 00000000..4599893c
    --- /dev/null
    +++ b/src/test/dos/scripts/fullscr.scr
    @@ -0,0 +1,50 @@
    +# fullscr.scr -- a game on the whole screen while another program runs on.
    +#
    +# TEST\TICKER.APP (fixtures/ticker) counts to 100 in its caption, ten a
    +# second.  The SDK's C game template starts, pauses (so nothing moves) and
    +# takes the whole screen with Alt+Enter at 320x200 in 256 colours.  While
    +# the game has the screen the ticker must reach "Counted 100" (it ran on in
    +# the background) and the screenshot is the game's own screen (the coin's
    +# glow is masked).  Alt+Tab gives the desktop back (the game hears
    +# "suspended"): the desktop, at 256 colours, must look right again (its
    +# palette restored).  Shift+Alt+Tab brings the game back to the front, and
    +# with it the whole screen; Alt+Enter goes back to the window, and Alt+F4
    +# ends the game.
    +# SDK-GAMES
    +# INI: video bpp 8
    +# LOG: sdkGame: started, 320x200 at 1x on a 8-bit desktop
    +# LOG: sdkGame: full screen on
    +# LOG: sdkGame: full screen suspended
    +# LOG: sdkGame: full screen on
    +# LOG: sdkGame: full screen off
    +# LOG: sdkGame: ended
    +TIMEOUT 120
    +WAITWIN "Program Manager"
    +MOVE 639 479
    +LAUNCH TEST/TICKER.APP
    +WAITWIN "Counting *"
    +LAUNCH apps/sdktest/sdkGame/sdkGame.app
    +WAITLOG "sdkGame: started"
    +WAITWIN "SdkGame - Score 0 - Time *"
    +KEY p
    +WAITWIN "SdkGame - Score 0 - Paused"
    +KEY Alt+Enter
    +WAITLOG "sdkGame: full screen on"
    +ASSERTWIN "Counting *"
    +WAITWIN "Counted 100"
    +SHOT fs-game
    +KEY Alt+Tab
    +WAITLOG "sdkGame: full screen suspended"
    +WAIT 20
    +SHOT fs-desk
    +KEY Shift+Alt+Tab
    +WAITLOG "sdkGame: full screen on"
    +WAIT 10
    +KEY Alt+Enter
    +WAITLOG "sdkGame: full screen off"
    +WAIT 10
    +KEY Alt+F4
    +WAITLOG "sdkGame: ended"
    +WAITNOWIN "SdkGame*"
    +ASSERTWIN "Counted 100"
    +QUIT
    diff --git a/src/test/formrt/frtStubs.c b/src/test/formrt/frtStubs.c
    index 844857a4..bdd11852 100644
    --- a/src/test/formrt/frtStubs.c
    +++ b/src/test/formrt/frtStubs.c
    @@ -48,6 +48,8 @@ typedef struct {
         int32_t  txLen;
         int32_t  base;
         int32_t  irq;
    +    bool     dtr;
    +    bool     rts;
     } StubPortT;
     
     typedef struct {
    @@ -69,13 +71,20 @@ const FrtStubApiT *frtStubApi(void);
     int32_t rs232ClearRxBuffer(int32_t com);
     int32_t rs232Close(int32_t com);
     int32_t rs232GetBase(int32_t com);
    +int32_t rs232GetCts(int32_t com);
    +int32_t rs232GetDcd(int32_t com);
    +int32_t rs232GetDsr(int32_t com);
    +int32_t rs232GetDtr(int32_t com);
     int32_t rs232GetIrq(int32_t com);
    +int32_t rs232GetRts(int32_t com);
     int32_t rs232GetRxBuffered(int32_t com);
     int32_t rs232GetUartType(int32_t com);
     int32_t rs232Open(int32_t com, int32_t bps, int32_t dataBits, char parity, int32_t stopBits, int32_t handshake);
     int32_t rs232Read(int32_t com, char *data, int32_t len);
     int32_t rs232SetBase(int32_t com, int32_t base);
    +int32_t rs232SetDtr(int32_t com, bool dtr);
     int32_t rs232SetIrq(int32_t com, int32_t irq);
    +int32_t rs232SetRts(int32_t com, bool rts);
     int32_t rs232Write(int32_t com, const char *data, int32_t len);
     int32_t rs232WriteBuf(int32_t com, const char *data, int32_t len);
     void secLinkClose(void *link);
    @@ -103,6 +112,7 @@ static const char *stubHlpcLastOutput(void);
     static void stubHlpcRc(int32_t rc);
     static int32_t stubIdleCount(void);
     static void stubIdleRun(void);
    +static int32_t stubLine(int32_t com, bool up);
     static void stubReset(void);
     static void stubSerFail(bool openFails, bool writeFails);
     static bool stubSerIsOpen(int32_t com);
    @@ -203,6 +213,28 @@ int32_t rs232GetBase(int32_t com) {
     }
     
     
    +// The stub modem: CTS and DSR up, no carrier.  Lines of a closed port
    +// read as the rs232 "not open" error.
    +int32_t rs232GetCts(int32_t com) {
    +    return stubLine(com, true);
    +}
    +
    +
    +int32_t rs232GetDcd(int32_t com) {
    +    return stubLine(com, false);
    +}
    +
    +
    +int32_t rs232GetDsr(int32_t com) {
    +    return stubLine(com, true);
    +}
    +
    +
    +int32_t rs232GetDtr(int32_t com) {
    +    return stubLine(com, com >= 0 && com < FRT_STUB_PORTS && sStubPorts[com].dtr);
    +}
    +
    +
     int32_t rs232GetIrq(int32_t com) {
         if (com < 0 || com >= FRT_STUB_PORTS) {
             return 0;
    @@ -212,6 +244,11 @@ int32_t rs232GetIrq(int32_t com) {
     }
     
     
    +int32_t rs232GetRts(int32_t com) {
    +    return stubLine(com, com >= 0 && com < FRT_STUB_PORTS && sStubPorts[com].rts);
    +}
    +
    +
     int32_t rs232GetRxBuffered(int32_t com) {
         if (com < 0 || com >= FRT_STUB_PORTS) {
             return 0;
    @@ -239,6 +276,8 @@ int32_t rs232Open(int32_t com, int32_t bps, int32_t dataBits, char parity, int32
         }
     
         sStubPorts[com].open = true;
    +    sStubPorts[com].dtr  = true;
    +    sStubPorts[com].rts  = true;
         return 0;
     }
     
    @@ -267,6 +306,16 @@ int32_t rs232SetBase(int32_t com, int32_t base) {
     }
     
     
    +int32_t rs232SetDtr(int32_t com, bool dtr) {
    +    if (com < 0 || com >= FRT_STUB_PORTS || !sStubPorts[com].open) {
    +        return -1;
    +    }
    +
    +    sStubPorts[com].dtr = dtr;
    +    return 0;
    +}
    +
    +
     int32_t rs232SetIrq(int32_t com, int32_t irq) {
         if (com >= 0 && com < FRT_STUB_PORTS) {
             sStubPorts[com].irq = irq;
    @@ -276,6 +325,16 @@ int32_t rs232SetIrq(int32_t com, int32_t irq) {
     }
     
     
    +int32_t rs232SetRts(int32_t com, bool rts) {
    +    if (com < 0 || com >= FRT_STUB_PORTS || !sStubPorts[com].open) {
    +        return -1;
    +    }
    +
    +    sStubPorts[com].rts = rts;
    +    return 0;
    +}
    +
    +
     int32_t rs232Write(int32_t com, const char *data, int32_t len) {
         return rs232WriteBuf(com, data, len);
     }
    @@ -546,6 +605,16 @@ static void stubIdleRun(void) {
     }
     
     
    +// A modem line of an open stub port (1 up, 0 down), -1 when not open.
    +static int32_t stubLine(int32_t com, bool up) {
    +    if (com < 0 || com >= FRT_STUB_PORTS || !sStubPorts[com].open) {
    +        return -1;
    +    }
    +
    +    return up ? 1 : 0;
    +}
    +
    +
     static void stubReset(void) {
         memset(sStubPorts, 0, sizeof(sStubPorts));
         memset(sStubLinks, 0, sizeof(sStubLinks));
    diff --git a/src/test/formrt/testFormRt.c b/src/test/formrt/testFormRt.c
    index cdfdeb73..8124d468 100644
    --- a/src/test/formrt/testFormRt.c
    +++ b/src/test/formrt/testFormRt.c
    @@ -152,12 +152,19 @@ void        SerClose(int32_t com);
     void        SerDetach(int32_t com);
     void        SerFlush(int32_t com);
     int32_t     SerGetBase(int32_t com);
    +int32_t     SerGetCts(int32_t com);
    +int32_t     SerGetDcd(int32_t com);
    +int32_t     SerGetDsr(int32_t com);
    +int32_t     SerGetDtr(int32_t com);
     int32_t     SerGetIrq(int32_t com);
    +int32_t     SerGetRts(int32_t com);
     int32_t     SerGetUart(int32_t com);
     int32_t     SerOpen(int32_t com, int32_t baud, int32_t dataBits, const char *parity, int32_t stopBits, int32_t handshake);
     const char *SerRead(int32_t com);
     void        SerSetBase(int32_t com, int32_t base);
    +void        SerSetDtr(int32_t com, int32_t on);
     void        SerSetIrq(int32_t com, int32_t irq);
    +void        SerSetRts(int32_t com, int32_t on);
     int32_t     SerWrite(int32_t com, const char *data);
     int32_t     SQLAffected(int32_t db);
     void        SQLClose(int32_t db);
    @@ -4366,6 +4373,22 @@ static void caseSerialLoopback(void) {
         TEST_EQ(SerGetBase(1), 0x2F8);
         SerSetIrq(1, 3);
         TEST_EQ(SerGetIrq(1), 3);
    +
    +    // Modem lines: DTR/RTS up after the open, settable; CTS/DSR up and no
    +    // carrier on the stub modem; BASIC True is -1.
    +    TEST_EQ(SerGetDtr(1), -1);
    +    TEST_EQ(SerGetRts(1), -1);
    +    TEST_EQ(SerGetCts(1), -1);
    +    TEST_EQ(SerGetDsr(1), -1);
    +    TEST_EQ(SerGetDcd(1), 0);
    +    SerSetDtr(1, 0);
    +    SerSetRts(1, 0);
    +    TEST_EQ(SerGetDtr(1), 0);
    +    TEST_EQ(SerGetRts(1), 0);
    +    SerSetDtr(1, -1);
    +    TEST_EQ(SerGetDtr(1), -1);
    +    TEST_EQ(SerGetCts(4), 0);
    +    SerSetRts(4, -1);
         SerAttach(9, "Term1");
         SerAttach(1, "NoSuchTerm");
     
    diff --git a/src/test/samples/golden/basdemo-debug.app b/src/test/samples/golden/basdemo-debug.app
    index 165319894830138bf78011166a716d29d7d17104..91750c8c67f602bcfef591c80ad20780a5c1b163 100644
    GIT binary patch
    delta 16
    YcmZqQ&e*=4al-^oMwZPJIll!005>xRVE_OC
    
    delta 16
    YcmZqQ&e*=4al-^oM&`{EIll!005>fLU;qFB
    
    diff --git a/src/test/samples/golden/basdemo-release.app b/src/test/samples/golden/basdemo-release.app
    index 828fb96c101c6b41170c5a0b45efcf7644b976f0..e277aaa65359d28adc3aec414f6252cb0196300a 100644
    GIT binary patch
    delta 16
    YcmcaMm+{(M#tjoV8Cf<@
    
    diff --git a/src/test/samples/golden/helpedit-release.app b/src/test/samples/golden/helpedit-release.app
    index 0b46f9d832bc579a895aeb8f31979a53c3ed3ec9..bcb95f31aaee76aceebebc0bb9a6c9f0a59a237b 100644
    GIT binary patch
    delta 14
    WcmaDH@i=0`1Wrbl%@a8al-^oMwZPJIlZg^IyeR9
    
    delta 16
    XcmccJ&Um|>al-^oM&`{EIlZg^Ix+?2
    
    diff --git a/src/test/samples/golden/iconed-release.app b/src/test/samples/golden/iconed-release.app
    index 7918303c21648430b19774279db1f184d4004dcc..a5c92938d5ba4d1dfdb8bef9497f4bce96b2414b 100644
    GIT binary patch
    delta 14
    VcmZ2gxTe4h7`^
    
    delta 14
    WcmaFv_S|j51Wrcg%@aAVNdf>e2nFN-
    
    diff --git a/src/test/samples/golden/inc-comm-debug.app b/src/test/samples/golden/inc-comm-debug.app
    index bf16227b5366dd24d75026094c73930af0913e8a..99294ce7c6b938b191b18554b7321ab841c3dedb 100644
    GIT binary patch
    delta 138
    zcmZ2$`O9*{1Wrbl%@a9a3ot59u9e#F%gGR&S`?gG;!;usV+NHJgPHCKQ6LjZ)EP-Z
    mF+>|kZF0(H8);2O#yykMWVB_}WEdD&7$CqUEFwq?i4Oo6;3cX6
    
    delta 76
    zcmexmxz=*S1Wrcg%@a9a3ouGeu9e!qxl>w`kvE@_0SsJRokHCwFXWU~U|kZF0(HIcZf!iFu3+VBiuK5yTJC^F+?Cf&eE11snhX
    
    delta 14
    VcmZoOZ!_O8fs>JW^F+?Cf&eD`1sebW
    
    diff --git a/src/test/samples/golden/inc-help-release.app b/src/test/samples/golden/inc-help-release.app
    index 13794907b210f5e66a9b58e7d1ec8edd2c4ffe9b..6308c77c0a28fa7e91ce687276ce2bb979ca1edc 100644
    GIT binary patch
    delta 14
    WcmX?acHV5m1Wrbl%@aBI2?78u<^^>C
    
    delta 14
    WcmX?acHV5m1Wrcg%@aBI2?78u;01I5
    
    diff --git a/src/test/samples/golden/inc-joystick-debug.app b/src/test/samples/golden/inc-joystick-debug.app
    index 85876607857f1915052d1a62ce90d1a09fc1cc57..39c2dd2092057fcdcd9c5ee24b80adb29a4b63e7 100644
    GIT binary patch
    delta 14
    VcmX?ae%^e;1Wrbl%@aB8ga9o61o!{|
    
    delta 14
    VcmX?ae%^e;1Wrcg%@aB8ga9o01or>{
    
    diff --git a/src/test/samples/golden/inc-joystick-release.app b/src/test/samples/golden/inc-joystick-release.app
    index 75529b7b7aeca766653618610bd81cfa01d8e27c..dbb357b41716c51dd4c5ec13889b3492870aef98 100644
    GIT binary patch
    delta 14
    VcmeA-?>FBtfs>JC^F+>nf&eG}1vdZy
    
    delta 14
    VcmeA-?>FBtfs>JW^F+>nf&eG@1vUTx
    
    diff --git a/src/test/samples/golden/inc-music-debug.app b/src/test/samples/golden/inc-music-debug.app
    index d7e87c1eaba913dbd0ece3a65fa001012f64e66a..dcae51a597a3c72c19fb768cebe81d9a85e3a352 100644
    GIT binary patch
    delta 14
    Wcmca_ao=LY1Wrbl%@aA#2>}2v*9EEo
    
    delta 14
    Wcmca_ao=LY1Wrcg%@aA#2>}2v(FLgh
    
    diff --git a/src/test/samples/golden/inc-music-release.app b/src/test/samples/golden/inc-music-release.app
    index cdeaa413eda44ea55d9e290c9278dc34f596b6e6..01d50a9fa83b3f45586cb885d9f66faba647abec 100644
    GIT binary patch
    delta 14
    WcmZ2vvB+Y>1Wrbl%@a8%3IPBqD+KcZ
    
    delta 14
    WcmZ2vvB+Y>1Wrcg%@a8%3IPBqB?R&S
    
    diff --git a/src/test/samples/golden/inc-net-debug.app b/src/test/samples/golden/inc-net-debug.app
    index 3f08b9cc5813b636407b40a3e2dff2e4d6c45214..14a0e8a06d8862f84d3a758162317df12345dce1 100644
    GIT binary patch
    delta 14
    VcmbPkG2LRr1Wrbl%@aA>ga9Y^1kwNi
    
    delta 14
    VcmbPkG2LRr1Wrcg%@aA>ga9Y;1knHh
    
    diff --git a/src/test/samples/golden/inc-net-release.app b/src/test/samples/golden/inc-net-release.app
    index c7354ea33adb5bafa63d99ed796bd7c2a5e1a47f..c3f2172bc1762260e6da09207df60b18476caf25 100644
    GIT binary patch
    delta 14
    VcmaE0{=j_01Wrbl%@aBOg#a$w1uFmm
    
    delta 14
    VcmaE0{=j_01Wrcg%@aBOg#a$q1u6gl
    
    diff --git a/src/test/samples/golden/inc-print-debug.app b/src/test/samples/golden/inc-print-debug.app
    index 833142263c3f9f95f83bbee60cb45ead990bd780..617e181aa20548dbe32a30e1017aab47c9a7a774 100644
    GIT binary patch
    delta 14
    Vcmca=e${-#1Wrbl%@a9Yga9q41quKF
    
    delta 14
    Vcmca=e${-#1Wrcg%@a9Yga9p}1qlEE
    
    diff --git a/src/test/samples/golden/inc-print-release.app b/src/test/samples/golden/inc-print-release.app
    index acdeee5cc8a73a3395a302283ddc32a855aac35d..3c158eeea8b5ae2f786aa6867e19fc6728ab93a1 100644
    GIT binary patch
    delta 14
    VcmbPdKF@r^1Wrbl%@a9!g#ag`1Z4mK
    
    delta 14
    VcmbPdKF@r^1Wrcg%@a9!g#ag=1Y`gJ
    
    diff --git a/src/test/samples/golden/inc-resource-debug.app b/src/test/samples/golden/inc-resource-debug.app
    index e7c274e2a269e7a5fa6bfd145e5576c0a8a7cb67..8266be7e48e8fc3eff49e746f28226810486f12d 100644
    GIT binary patch
    delta 14
    VcmaEF{@#4U1Wrbl%@aA}ga9yq1y}$8
    
    delta 14
    VcmaEF{@#4U1Wrcg%@aA}ga9yk1y=w7
    
    diff --git a/src/test/samples/golden/inc-resource-release.app b/src/test/samples/golden/inc-resource-release.app
    index 125d6fa258a7db2b4625230bff6607634894dcbd..f14cdcc4e44bba28f7ec839da0c86e2b162d6573 100644
    GIT binary patch
    delta 14
    VcmZ2)zTSMp1Wrbl%@aB0ga9dj1egE-
    
    delta 14
    VcmZ2)zTSMp1Wrcg%@aB0ga9dd1eX8+
    
    diff --git a/src/test/samples/golden/inc-sound-debug.app b/src/test/samples/golden/inc-sound-debug.app
    index b26010db8f8efd947c5dcb2e6e04c9f5957287bf..547197b1c3ee25abe848ced0abc070dce65eb618 100644
    GIT binary patch
    delta 14
    VcmaED{@Q%Q1Wrbl%@aAJga9xP1xx?{
    
    delta 14
    VcmaED{@Q%Q1Wrcg%@aAJga9xJ1xo+`
    
    diff --git a/src/test/samples/golden/inc-sound-release.app b/src/test/samples/golden/inc-sound-release.app
    index e23e04c133b7e722baea4c18aa81d91ae4c0d131..983fdeccfd3fe548ec573d9d6abda641104f6e26 100644
    GIT binary patch
    delta 14
    VcmdmJzR`Tc1Wrbl%@a8lga9eG1fBo@
    
    delta 14
    VcmdmJzR`Tc1Wrcg%@a8lga9eA1f2i?
    
    diff --git a/src/test/samples/golden/inc-sql-debug.app b/src/test/samples/golden/inc-sql-debug.app
    index b4956cb3aaccb6600957d55ed4d9878407ad93ff..0d42a9778318a1c857eaad4a6fe2fc3f59b8c198 100644
    GIT binary patch
    delta 14
    VcmbPbG0S4Z1Wrbl%@a90g#ai^1ls@r
    
    delta 14
    VcmbPbG0S4Z1Wrcg%@a90g#ai;1lj-q
    
    diff --git a/src/test/samples/golden/inc-sql-release.app b/src/test/samples/golden/inc-sql-release.app
    index 7ff0f02318830e5259110435422b47a542ac3269..99abed096171388b20cee160bb04ff8acfa2e438 100644
    GIT binary patch
    delta 14
    VcmaEC{@8rO1Wrbl%@a9;ga9uu1vCHv
    
    delta 14
    VcmaEC{@8rO1Wrcg%@a9;ga9uo1v3Bu
    
    diff --git a/src/test/samples/golden/inc-xfer-debug.app b/src/test/samples/golden/inc-xfer-debug.app
    index 8c1d153480d9073667feaff81dfa4375a335a011..de2400361ce44b5519631e928619e56e57547ccf 100644
    GIT binary patch
    delta 14
    VcmaE7{?2^E1Wrbl%@a9eg#a*P1yuk5
    
    delta 14
    VcmaE7{?2^E1Wrcg%@a9eg#a*J1yle4
    
    diff --git a/src/test/samples/golden/inc-xfer-release.app b/src/test/samples/golden/inc-xfer-release.app
    index a65103ac0921565735de14bdb58d830d75b4b017..194d5d4f52b97fa440eea7e47c80350119fb00cd 100644
    GIT binary patch
    delta 14
    VcmbPlKHq%91Wrbl%@aBKga9YM1ZV&N
    
    delta 14
    VcmbPlKHq%91Wrcg%@aBKga9YG1ZMyM
    
    diff --git a/src/test/samples/golden/notepad-debug.app b/src/test/samples/golden/notepad-debug.app
    index 14b907a9a68f0828de45c92c77be9f6343d5570a..2e66a186736c3b23d7d59e808f09b6f9d38cb362 100644
    GIT binary patch
    delta 14
    VcmcZ*e<6Ou1Wrbl%@aB8)c`Q>1z!LF
    
    delta 14
    VcmcZ*e<6Ou1Wrcg%@aB8)c`Q*1zrFE
    
    diff --git a/src/test/samples/golden/notepad-release.app b/src/test/samples/golden/notepad-release.app
    index ab56f24d6a7c13a103202ff170dfc8750f09975a..81aaf840c73bdde8d3df494c6f900f59ce97979b 100644
    GIT binary patch
    delta 14
    VcmbOmIyZE~1Wrbl%@a9!6agw`1g!u7
    
    delta 14
    VcmbOmIyZE~1Wrcg%@a9!6agw=1gro6
    
    diff --git a/src/test/samples/golden/resedit-debug.app b/src/test/samples/golden/resedit-debug.app
    index ab46235148a0be2de05d160319ba65092bc4d7e2..330d4321f98c8eff94a0f372bb6a8e77347b19fd 100644
    GIT binary patch
    delta 14
    Wcmdmyvmi_^SjRm&=
    
    delta 14
    Wcmdmyvmi_^ShXu9(
    
    diff --git a/src/test/samples/golden/resedit-release.app b/src/test/samples/golden/resedit-release.app
    index 7dea67aea3ba8958646bf804e93b9cfd15cdcc49..3952edb02295ebdda8b148f71a2fa99ba37e4370 100644
    GIT binary patch
    delta 14
    VcmZ1zzb1ae1Wrbl%@aAL)c`F21oHp@
    
    delta 14
    VcmZ1zzb1ae1Wrcg%@aAL)c`E{1o8j?
    
    diff --git a/src/test/samples/golden/widshow-debug.app b/src/test/samples/golden/widshow-debug.app
    index db7966a9ac954e82173f83b0b6f8c1145b8ffd78..211e4275255583f1310999c732171f5c1f108b18 100644
    GIT binary patch
    delta 14
    WcmcazcDroD1Wrbl%@a9K83F(`Rt7l$
    
    delta 14
    WcmcazcDroD1Wrcg%@a9K83F(`PzE>v
    
    diff --git a/src/test/samples/golden/widshow-release.app b/src/test/samples/golden/widshow-release.app
    index b704bbc59ef94d8f049b0a34aee79cf6880e3bd1..c9752fc71ad241fc1f9dbb53969f6363094b416f 100644
    GIT binary patch
    delta 14
    WcmaEv_cCw81Wrbl%@a9q=mG#X8U{}Q
    
    delta 14
    WcmaEv_cCw81Wrcg%@a9q=mG#X6b4QJ
    
    diff --git a/src/test/script/testScript.c b/src/test/script/testScript.c
    index 92c826e8..e7700ae6 100644
    --- a/src/test/script/testScript.c
    +++ b/src/test/script/testScript.c
    @@ -328,7 +328,11 @@ static void caseParseErrors(void) {
             { "OK\n",                         1 },
             { "WAIT 1\nWAIT 1\nDRAG 1 2 3",   3 },
             { "WAITEXIT \"x\"",               1 },
    -        { "WAITEXIT 1 2",                 1 }
    +        { "WAITEXIT 1 2",                 1 },
    +        { "FULLSCREEN 320 240",           1 },
    +        { "FULLSCREEN 0 240 8",           1 },
    +        { "FULLSCREEN 320 240 24",        1 },
    +        { "WINDOWED 1",                   1 }
         };
     
         tCase("parse errors");
    @@ -353,6 +357,26 @@ static void caseParseErrors(void) {
     }
     
     
    +static void caseParseFullScreen(void) {
    +    ScriptT s;
    +
    +    tCase("parse FULLSCREEN and WINDOWED");
    +
    +    if (!TEST_TRUE(scriptParse(&s, "FULLSCREEN 320 240 8\nFULLSCREEN 640 480 0\nWINDOWED", ""))) {
    +        return;
    +    }
    +
    +    TEST_EQ(s.count, 3);
    +    TEST_EQ(s.steps[0].op, ScriptOpFullScreenE);
    +    TEST_EQ(s.steps[0].from.x, 320);
    +    TEST_EQ(s.steps[0].from.y, 240);
    +    TEST_EQ(s.steps[0].count, 8);
    +    TEST_EQ(s.steps[1].count, 0);
    +    TEST_EQ(s.steps[2].op, ScriptOpWindowedE);
    +    scriptFree(&s);
    +}
    +
    +
     // KEYDOWN / KEYUP names, and the keys going into the keyboard hook's
     // decoder (held-key table); CLOCKMARK sends a CLOCK line to the host.
     static void caseRawKeys(void) {
    @@ -764,6 +788,7 @@ int main(void) {
         caseKeyNames();
         caseParseCommands();
         caseParseErrors();
    +    caseParseFullScreen();
         caseRawKeys();
         caseRunnerFailures();
         caseRunnerInput();
    diff --git a/src/test/serial/loopShim.c b/src/test/serial/loopShim.c
    index 4d9d9dd7..f3637d2e 100644
    --- a/src/test/serial/loopShim.c
    +++ b/src/test/serial/loopShim.c
    @@ -65,6 +65,7 @@ typedef struct {
         bool        open;
         bool        dcd;         // carrier detect as the test set it
         bool        breakOn;     // line held in the break state
    +    bool        dtr;         // DTR as the library set it (kept by rs232Close, like the real driver)
         int32_t     writeCap;    // max bytes accepted per rs232Write (LOOP_CAP_NONE = all)
         uint8_t     rx[RX_QUEUE_SIZE];
         uint32_t    rxHead;
    @@ -156,6 +157,11 @@ bool loopGetBreak(int com) {
     }
     
     
    +bool loopGetDtr(int com) {
    +    return portValid(com) && sPorts[com].dtr;
    +}
    +
    +
     void loopGetStats(int com, LoopStatsT *stats) {
         if (!portValid(com) || !stats) {
             return;
    @@ -349,6 +355,7 @@ int rs232Open(int com, int32_t bps, int dataBits, char parity, int stopBits, int
         }
         sPorts[com].open    = true;
         sPorts[com].breakOn = false;
    +    sPorts[com].dtr     = true;
         sPorts[com].rxHead  = 0;
         sPorts[com].rxTail  = 0;
         sPorts[com].rxCount = 0;
    @@ -406,6 +413,18 @@ int32_t rs232SetBreak(int32_t com, bool on) {
     }
     
     
    +int32_t rs232SetDtr(int32_t com, bool dtr) {
    +    if (!portValid(com)) {
    +        return RS232_ERR_INVALID_PORT;
    +    }
    +    if (!sPorts[com].open) {
    +        return RS232_ERR_NOT_OPEN;
    +    }
    +    sPorts[com].dtr = dtr;
    +    return RS232_SUCCESS;
    +}
    +
    +
     int rs232Write(int com, const char *data, int len) {
         if (!portValid(com)) {
             return RS232_ERR_INVALID_PORT;
    diff --git a/src/test/serial/loopShim.h b/src/test/serial/loopShim.h
    index 9b28583a..35a6e8f0 100644
    --- a/src/test/serial/loopShim.h
    +++ b/src/test/serial/loopShim.h
    @@ -91,6 +91,7 @@ typedef void (*LoopIdleHookT)(void *ctx);
     clock_t loopClock(void);
     void    loopClockAdvance(clock_t ticks);
     bool    loopGetBreak(int com);
    +bool    loopGetDtr(int com);
     void    loopGetStats(int com, LoopStatsT *stats);
     bool    loopIsOpen(int com);
     int     loopPeer(int com);
    diff --git a/src/test/terminal/testConn.c b/src/test/terminal/testConn.c
    index 77eedc0f..f4fafd2c 100644
    --- a/src/test/terminal/testConn.c
    +++ b/src/test/terminal/testConn.c
    @@ -286,6 +286,7 @@ static void caseModem(void) {
         connClose(c);
         TEST_TRUE("modem hung up", strcmp(connStatus(c), "Hung up") == 0);
         TEST_TRUE("modem hangup closes port", !loopIsOpen(LOCAL_COM));
    +    TEST_TRUE("modem hangup drops DTR", !loopGetDtr(LOCAL_COM));
         connDestroy(c);
     }
     
    diff --git a/src/test/terminal/testConnProxy.c b/src/test/terminal/testConnProxy.c
    index 2d44f374..60dbf597 100644
    --- a/src/test/terminal/testConnProxy.c
    +++ b/src/test/terminal/testConnProxy.c
    @@ -104,6 +104,7 @@ int             main(void);
     int32_t rs232GetDcd(int32_t com);
     int32_t rs232GetTxBuffered(int32_t com);
     int32_t rs232SetBreak(int32_t com, bool on);
    +int32_t rs232SetDtr(int32_t com, bool dtr);
     int32_t rs232WriteBuf(int32_t com, const char *data, int32_t len);
     
     
    @@ -520,6 +521,14 @@ int32_t rs232SetBreak(int32_t com, bool on) {
     }
     
     
    +// A socket has no DTR line.
    +int32_t rs232SetDtr(int32_t com, bool dtr) {
    +    (void)com;
    +    (void)dtr;
    +    return RS232_SUCCESS;
    +}
    +
    +
     int32_t rs232WriteBuf(int32_t com, const char *data, int32_t len) {
         return rs232Write(com, data, len);
     }
    diff --git a/src/test/wm/Makefile b/src/test/wm/Makefile
    index bd0fb052..2001daf7 100644
    --- a/src/test/wm/Makefile
    +++ b/src/test/wm/Makefile
    @@ -37,6 +37,7 @@ BINDIR = $(DVXTEST_BINDIR)/wm
     SUITE  = $(BINDIR)/wmSuite
     SUITE2 = $(BINDIR)/wmSuite2
     SUITE3 = $(BINDIR)/wmFixes
    +SUITE4 = $(BINDIR)/wmExcl
     
     .PHONY: all run clean
     
    @@ -47,12 +48,13 @@ $(BINDIR)/%: %.c $(DVX_MAKEFILES) $(DVXTEST_ARCHIVES) | dvxtest-libs
     	@mkdir -p $(BINDIR)
     	$(HOSTCC) $(DVXTEST_CFLAGS) -o $@ $< $(DVXTEST_LINK)
     
    --include $(SUITE).d $(SUITE2).d $(SUITE3).d
    +-include $(SUITE).d $(SUITE2).d $(SUITE3).d $(SUITE4).d
     
    -run: $(SUITE) $(SUITE2) $(SUITE3)
    +run: $(SUITE) $(SUITE2) $(SUITE3) $(SUITE4)
     	cd $(BINDIR) && ASAN_OPTIONS=detect_leaks=1:allocator_may_return_null=1 ./wmSuite
     	cd $(BINDIR) && ASAN_OPTIONS=detect_leaks=1:allocator_may_return_null=1 ./wmSuite2
     	cd $(BINDIR) && ASAN_OPTIONS=detect_leaks=1:allocator_may_return_null=1 ./wmFixes
    +	cd $(BINDIR) && ASAN_OPTIONS=detect_leaks=1:allocator_may_return_null=1 ./wmExcl
     
     clean:
     	rm -rf $(BINDIR)
    diff --git a/src/test/wm/wmExcl.c b/src/test/wm/wmExcl.c
    new file mode 100644
    index 00000000..61d1019c
    --- /dev/null
    +++ b/src/test/wm/wmExcl.c
    @@ -0,0 +1,481 @@
    +// The MIT License (MIT)
    +//
    +// Copyright (C) 2026 Scott Duensing
    +//
    +// Permission is hereby granted, free of charge, to any person obtaining a copy
    +// of this software and associated documentation files (the "Software"), to
    +// deal in the Software without restriction, including without limitation the
    +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
    +// sell copies of the Software, and to permit persons to whom the Software is
    +// furnished to do so, subject to the following conditions:
    +//
    +// The above copyright notice and this permission notice shall be included in
    +// all copies or substantial portions of the Software.
    +//
    +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.  IN NO EVENT SHALL THE
    +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
    +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
    +// IN THE SOFTWARE.
    +
    +// wmExcl.c  -- full-screen exclusive mode (dvxExclusive*)
    +//
    +// Taking and giving back the screen, suspending when the owner loses the
    +// focus and resuming when it gets it back, other windows painting on
    +// while the desktop is not drawn, input going to the owner, the 8-bit
    +// picture scaled through the palette, screenshots of the full screen,
    +// and the failure paths (busy, bad arguments, no mode, no mode again on
    +// resuming, the owner closing).
    +
    +#include "dvxtest.h"
    +#include "dvxPlat.h"
    +#include "dvxPlatHost.h"
    +
    +#include 
    +#include 
    +#include 
    +#include 
    +#include 
    +
    +#define SCREEN_W         640
    +#define SCREEN_H         480
    +#define SCREEN_BPP       32
    +
    +#define WIN_A_X          40
    +#define WIN_A_Y          40
    +#define WIN_B_X          300
    +#define WIN_B_Y          200
    +#define WIN_W            200
    +#define WIN_H            150
    +
    +#define FULL_W           320
    +#define FULL_H           200
    +#define BPP_8            8
    +#define BPP_16           16
    +
    +#define PIC_W            160    // a quarter-size picture: scaled by 2
    +#define PIC_H            100
    +#define SQUARE           100    // a square picture: scaled by 2, centred
    +#define MOUSE_X          50
    +#define MOUSE_Y          60
    +#define PAL_TEST_INDEX   7
    +#define PAL_TEST_R       200
    +#define PAL_TEST_G       100
    +#define PAL_TEST_B       50
    +#define SETTLE_FRAMES    3
    +#define SHOT_FILE        "wmExcl.png"
    +#define SCAN_X           0x2D
    +#define ASCII_X          'x'
    +
    +static int32_t         sActiveCount    = 0;
    +static int32_t         sSuspendedCount = 0;
    +static int32_t         sEndedCount     = 0;
    +static int32_t         sMouseCount     = 0;
    +static int32_t         sMouseX         = -1;
    +static int32_t         sMouseY         = -1;
    +static int32_t         sKeyCount       = 0;
    +static int32_t         sLastKey        = 0;
    +static int32_t         sPaintCount     = 0;
    +
    +static void            caseBeginEnd(void);
    +static void            caseErrors(void);
    +static void            caseInput(void);
    +static void            caseOthersRunOn(void);
    +static void            casePresentScaled(void);
    +static void            caseResumeFails(void);
    +static void            caseSuspendResume(void);
    +static void            countsReset(void);
    +static void            onChange(WindowT *win, ExclusiveStateE state, void *userData);
    +static void            onKey(WindowT *win, int32_t key, int32_t mod);
    +static void            onMouse(WindowT *win, int32_t x, int32_t y, int32_t buttons);
    +static void            onPaintCount(WindowT *win, RectT *dirty);
    +static WindowT        *ownerCreate(void);
    +
    +
    +// Taking the screen: the mode asked for, the desktop left as it was, the
    +// mouse in the middle; giving it back restores both, without a callback.
    +static void caseBeginEnd(void) {
    +    tCase("dvxExclusiveBegin takes the screen, dvxExclusiveEnd gives it back");
    +    tInit(SCREEN_W, SCREEN_H, SCREEN_BPP);
    +    countsReset();
    +
    +    AppContextT *ctx = tCtx();
    +    WindowT     *a   = ownerCreate();
    +
    +    tPump(SETTLE_FRAMES);
    +    tMouseMove(MOUSE_X, MOUSE_Y);
    +    tPump(1);
    +
    +    TEST_EQ(dvxExclusiveBegin(ctx, a, FULL_W, FULL_H, BPP_8, onChange, NULL), DVX_EXCL_OK);
    +    TEST_EQ(sActiveCount, 1);
    +
    +    DisplayT *d = dvxExclusiveDisplay(ctx);
    +
    +    TEST_TRUE(d != NULL);
    +    TEST_TRUE(dvxExclusiveBlitOps(ctx) != NULL);
    +    TEST_EQ(d->width, FULL_W);
    +    TEST_EQ(d->height, FULL_H);
    +    TEST_EQ(d->format.bitsPerPixel, BPP_8);
    +    TEST_EQ(ctx->display.width, SCREEN_W);
    +    TEST_EQ(ctx->display.height, SCREEN_H);
    +    TEST_EQ(ctx->mouseX, FULL_W / 2);
    +    TEST_EQ(ctx->mouseY, FULL_H / 2);
    +    TEST_EQ(dvxChangeVideoMode(ctx, SCREEN_W, SCREEN_H, BPP_16), -1);
    +
    +    // A screenshot is of the full screen.
    +    char path[DVX_MAX_PATH];
    +
    +    snprintf(path, sizeof(path), "%s/%s", tTempDir(), SHOT_FILE);
    +    TEST_EQ(dvxScreenshot(ctx, path), 0);
    +
    +    struct stat st;
    +
    +    TEST_TRUE(stat(path, &st) == 0 && st.st_size > 0);
    +
    +    tPump(SETTLE_FRAMES);
    +    TEST_EQ(sSuspendedCount, 0);
    +
    +    dvxExclusiveEnd(ctx, a);
    +    TEST_TRUE(dvxExclusiveDisplay(ctx) == NULL);
    +    TEST_TRUE(dvxExclusiveBlitOps(ctx) == NULL);
    +    TEST_TRUE(ctx->exclusive.owner == NULL);
    +    TEST_EQ(ctx->mouseX, MOUSE_X);
    +    TEST_EQ(ctx->mouseY, MOUSE_Y);
    +    TEST_EQ(ctx->dirty.count, 1);
    +    TEST_EQ(sSuspendedCount + sEndedCount, 0);
    +    tPump(SETTLE_FRAMES);
    +
    +    tShutdown();
    +}
    +
    +
    +// Busy, bad arguments, no mode; closing the owner gives the screen back.
    +static void caseErrors(void) {
    +    tCase("dvxExclusiveBegin errors, and the owner closing");
    +    tInit(SCREEN_W, SCREEN_H, SCREEN_BPP);
    +    countsReset();
    +
    +    AppContextT *ctx = tCtx();
    +    WindowT     *a   = ownerCreate();
    +    WindowT     *b   = dvxCreateWindow(ctx, "B", WIN_B_X, WIN_B_Y, WIN_W, WIN_H, false);
    +
    +    tPump(SETTLE_FRAMES);
    +
    +    TEST_EQ(dvxExclusiveBegin(ctx, NULL, FULL_W, FULL_H, BPP_8, onChange, NULL), DVX_EXCL_ERR_ARGS);
    +    TEST_EQ(dvxExclusiveBegin(ctx, a, 0, FULL_H, BPP_8, onChange, NULL), DVX_EXCL_ERR_ARGS);
    +
    +    hostVideoFailNext(1);
    +    TEST_EQ(dvxExclusiveBegin(ctx, a, FULL_W, FULL_H, BPP_8, onChange, NULL), DVX_EXCL_ERR_MODE);
    +    TEST_TRUE(ctx->exclusive.owner == NULL);
    +    TEST_EQ(sActiveCount, 0);
    +
    +    TEST_EQ(dvxExclusiveBegin(ctx, a, FULL_W, FULL_H, BPP_8, onChange, NULL), DVX_EXCL_OK);
    +    TEST_EQ(dvxExclusiveBegin(ctx, b, FULL_W, FULL_H, BPP_8, onChange, NULL), DVX_EXCL_ERR_BUSY);
    +
    +    // The owner asking again gets the new size.
    +    TEST_EQ(dvxExclusiveBegin(ctx, a, FULL_W * 2, FULL_H * 2, BPP_16, onChange, NULL), DVX_EXCL_OK);
    +    TEST_EQ(dvxExclusiveDisplay(ctx)->width, FULL_W * 2);
    +    TEST_EQ(dvxExclusiveDisplay(ctx)->format.bitsPerPixel, BPP_16);
    +
    +    dvxExclusiveEnd(ctx, b);
    +    TEST_TRUE(dvxExclusiveDisplay(ctx) != NULL);
    +
    +    dvxDestroyWindow(ctx, a);
    +    tPump(SETTLE_FRAMES);
    +    TEST_TRUE(dvxExclusiveDisplay(ctx) == NULL);
    +    TEST_TRUE(ctx->exclusive.owner == NULL);
    +    TEST_EQ(sSuspendedCount + sEndedCount, 0);
    +
    +    tShutdown();
    +}
    +
    +
    +// Mouse changes reach the owner in full-screen coordinates; keys reach it
    +// as in game input mode although it did not ask for that.
    +static void caseInput(void) {
    +    tCase("full screen: mouse and keys go to the owner");
    +    tInit(SCREEN_W, SCREEN_H, SCREEN_BPP);
    +    countsReset();
    +
    +    AppContextT *ctx = tCtx();
    +    WindowT     *a   = ownerCreate();
    +
    +    tPump(SETTLE_FRAMES);
    +    TEST_EQ(dvxExclusiveBegin(ctx, a, FULL_W, FULL_H, BPP_8, onChange, NULL), DVX_EXCL_OK);
    +    TEST_TRUE(!a->gameInput);
    +
    +    tMouseMove(MOUSE_X, MOUSE_Y);
    +    tPump(1);
    +    TEST_TRUE(sMouseCount > 0);
    +    TEST_EQ(sMouseX, MOUSE_X);
    +    TEST_EQ(sMouseY, MOUSE_Y);
    +
    +    tKey(SCAN_X, ASCII_X, 0);
    +    tPump(1);
    +    TEST_EQ(sKeyCount, 1);
    +    TEST_EQ(sLastKey, ASCII_X);
    +
    +    // F10 would open a menu nobody can see: it goes to the owner too.
    +    tFKey(10);
    +    tPump(1);
    +    TEST_EQ(sKeyCount, 2);
    +    TEST_TRUE(!ctx->popup.active);
    +
    +    dvxExclusiveEnd(ctx, a);
    +    tShutdown();
    +}
    +
    +
    +// Other windows paint on into their own buffers; the desktop's screen is
    +// not drawn until the owner lets go, then all of it is.
    +static void caseOthersRunOn(void) {
    +    tCase("full screen: other windows paint on, the desktop waits");
    +    tInit(SCREEN_W, SCREEN_H, SCREEN_BPP);
    +    countsReset();
    +
    +    AppContextT *ctx = tCtx();
    +    WindowT     *b   = dvxCreateWindow(ctx, "B", WIN_B_X, WIN_B_Y, WIN_W, WIN_H, false);
    +    WindowT     *a   = ownerCreate();
    +
    +    b->onPaint = onPaintCount;
    +    tPump(SETTLE_FRAMES);
    +
    +    size_t         screenBytes = (size_t)ctx->display.pitch * (size_t)ctx->display.height;
    +    const uint8_t *screen      = hostFramebuffer(&ctx->display);
    +    uint8_t       *before      = (uint8_t *)malloc(screenBytes);
    +
    +    TEST_TRUE(before != NULL);
    +    memcpy(before, screen, screenBytes);
    +
    +    TEST_EQ(dvxExclusiveBegin(ctx, a, FULL_W, FULL_H, BPP_8, onChange, NULL), DVX_EXCL_OK);
    +    sPaintCount = 0;
    +    dvxInvalidateWindow(ctx, b);
    +    dvxSetTitle(ctx, b, "B busy");
    +    tPump(SETTLE_FRAMES);
    +    TEST_TRUE(sPaintCount > 0);
    +    TEST_EQ(ctx->dirty.count, 0);
    +    TEST_TRUE(memcmp(before, screen, screenBytes) == 0);
    +
    +    dvxExclusiveEnd(ctx, a);
    +    tPump(1);
    +    TEST_TRUE(memcmp(before, screen, screenBytes) != 0);
    +    TEST_TRUE(memcmp(ctx->display.backBuf, screen, screenBytes) == 0);
    +
    +    free(before);
    +    tShutdown();
    +}
    +
    +
    +// An 8-bit picture: whole-factor scaled and centred on black, through the
    +// palette at 16 bpp, straight at 8 bpp.
    +static void casePresentScaled(void) {
    +    tCase("dvxExclusivePresent8 scales, centres and converts");
    +    tInit(SCREEN_W, SCREEN_H, SCREEN_BPP);
    +    countsReset();
    +
    +    AppContextT *ctx = tCtx();
    +    WindowT     *a   = ownerCreate();
    +    uint8_t      pal[PAL_ENTRIES * PAL_CHANNELS];
    +    uint8_t      pic[PIC_W * PIC_H];
    +
    +    memset(pal, 0, sizeof(pal));
    +    pal[PAL_TEST_INDEX * PAL_CHANNELS]     = PAL_TEST_R;
    +    pal[PAL_TEST_INDEX * PAL_CHANNELS + 1] = PAL_TEST_G;
    +    pal[PAL_TEST_INDEX * PAL_CHANNELS + 2] = PAL_TEST_B;
    +    memset(pic, PAL_TEST_INDEX, sizeof(pic));
    +    tPump(SETTLE_FRAMES);
    +
    +    TEST_TRUE(!dvxExclusivePresent8(ctx, pic, PIC_W, PIC_H, PIC_W));
    +
    +    // 16 bpp: a quarter-size picture fills the screen at twice the size.
    +    TEST_EQ(dvxExclusiveBegin(ctx, a, FULL_W, FULL_H, BPP_16, onChange, NULL), DVX_EXCL_OK);
    +    dvxExclusiveSetPalette(ctx, pal, 0, PAL_ENTRIES);
    +    TEST_TRUE(dvxExclusivePresent8(ctx, pic, PIC_W, PIC_H, PIC_W));
    +
    +    DisplayT *d    = dvxExclusiveDisplay(ctx);
    +    uint16_t  want = (uint16_t)packColor(d, PAL_TEST_R, PAL_TEST_G, PAL_TEST_B);
    +    uint16_t  last = ((const uint16_t *)(d->backBuf + (size_t)(FULL_H - 1) * (size_t)d->pitch))[FULL_W - 1];
    +
    +    TEST_EQ(((const uint16_t *)d->backBuf)[0], want);
    +    TEST_EQ(last, want);
    +    TEST_TRUE(memcmp(d->backBuf, hostFramebuffer(d), (size_t)d->pitch * (size_t)d->height) == 0);
    +
    +    // A square picture: twice the size, centred, black either side.
    +    TEST_TRUE(dvxExclusivePresent8(ctx, pic, SQUARE, SQUARE, PIC_W));
    +
    +    int32_t left = (FULL_W - SQUARE * 2) / 2;
    +
    +    TEST_EQ(((const uint16_t *)d->backBuf)[left - 1], 0);
    +    TEST_EQ(((const uint16_t *)d->backBuf)[left], want);
    +    TEST_EQ(((const uint16_t *)d->backBuf)[left + SQUARE * 2 - 1], want);
    +    TEST_EQ(((const uint16_t *)d->backBuf)[left + SQUARE * 2], 0);
    +
    +    // 8 bpp: the indexes themselves, the palette in the DAC.
    +    TEST_EQ(dvxExclusiveBegin(ctx, a, FULL_W, FULL_H, BPP_8, onChange, NULL), DVX_EXCL_OK);
    +    d = dvxExclusiveDisplay(ctx);
    +    TEST_TRUE(dvxExclusivePresent8(ctx, pic, PIC_W, PIC_H, PIC_W));
    +    TEST_EQ(d->backBuf[0], PAL_TEST_INDEX);
    +    TEST_EQ(d->palette[PAL_TEST_INDEX * PAL_CHANNELS], PAL_TEST_R);
    +
    +    dvxExclusiveEnd(ctx, a);
    +    tShutdown();
    +}
    +
    +
    +// Losing the focus suspends; when the mode cannot be set again on the
    +// way back, full screen ends and the owner hears so.
    +static void caseResumeFails(void) {
    +    tCase("full screen ends when the mode cannot be set again");
    +    tInit(SCREEN_W, SCREEN_H, SCREEN_BPP);
    +    countsReset();
    +
    +    AppContextT *ctx = tCtx();
    +    WindowT     *b   = dvxCreateWindow(ctx, "B", WIN_B_X, WIN_B_Y, WIN_W, WIN_H, false);
    +    WindowT     *a   = ownerCreate();
    +
    +    tPump(SETTLE_FRAMES);
    +    TEST_EQ(dvxExclusiveBegin(ctx, a, FULL_W, FULL_H, BPP_8, onChange, NULL), DVX_EXCL_OK);
    +
    +    dvxRaiseWindow(ctx, b);
    +    tPump(1);
    +    TEST_EQ(sSuspendedCount, 1);
    +
    +    hostVideoFailNext(1);
    +    dvxRaiseWindow(ctx, a);
    +    tPump(1);
    +    TEST_EQ(sEndedCount, 1);
    +    TEST_TRUE(ctx->exclusive.owner == NULL);
    +    TEST_TRUE(dvxExclusiveDisplay(ctx) == NULL);
    +    TEST_EQ(ctx->display.width, SCREEN_W);
    +
    +    tShutdown();
    +}
    +
    +
    +// Alt+Tab away suspends (the desktop is drawn again); coming back sets the
    +// mode again and the owner redraws.
    +static void caseSuspendResume(void) {
    +    tCase("full screen suspends on Alt+Tab and resumes on the focus");
    +    tInit(SCREEN_W, SCREEN_H, SCREEN_BPP);
    +    countsReset();
    +
    +    AppContextT *ctx = tCtx();
    +    WindowT     *b   = dvxCreateWindow(ctx, "B", WIN_B_X, WIN_B_Y, WIN_W, WIN_H, false);
    +    WindowT     *a   = ownerCreate();
    +
    +    tPump(SETTLE_FRAMES);
    +    TEST_EQ(dvxExclusiveBegin(ctx, a, FULL_W, FULL_H, BPP_8, onChange, NULL), DVX_EXCL_OK);
    +
    +    tAltTab();
    +    tPump(1);
    +    TEST_EQ(sSuspendedCount, 1);
    +    TEST_TRUE(dvxExclusiveDisplay(ctx) == NULL);
    +    TEST_TRUE(ctx->exclusive.owner == a);
    +    TEST_TRUE(!ctx->exclusive.active);
    +    TEST_EQ(ctx->dirty.count, 0);
    +    TEST_TRUE(memcmp(ctx->display.backBuf, hostFramebuffer(&ctx->display), (size_t)ctx->display.pitch * (size_t)ctx->display.height) == 0);
    +
    +    // Minimized, it stays suspended even with the focus nominally back.
    +    dvxMinimizeWindow(ctx, a);
    +    tPump(1);
    +    TEST_EQ(sActiveCount, 1);
    +
    +    dvxRestoreWindow(ctx, a);
    +    dvxRaiseWindow(ctx, a);
    +    tPump(1);
    +    TEST_EQ(sActiveCount, 2);
    +    TEST_TRUE(dvxExclusiveDisplay(ctx) != NULL);
    +    TEST_TRUE(b->visible);
    +
    +    dvxExclusiveEnd(ctx, a);
    +    tShutdown();
    +}
    +
    +
    +static void countsReset(void) {
    +    sActiveCount    = 0;
    +    sSuspendedCount = 0;
    +    sEndedCount     = 0;
    +    sMouseCount     = 0;
    +    sMouseX         = -1;
    +    sMouseY         = -1;
    +    sKeyCount       = 0;
    +    sLastKey        = 0;
    +    sPaintCount     = 0;
    +}
    +
    +
    +static void onChange(WindowT *win, ExclusiveStateE state, void *userData) {
    +    (void)win;
    +    (void)userData;
    +
    +    if (state == ExclusiveActiveE) {
    +        sActiveCount++;
    +    } else if (state == ExclusiveSuspendedE) {
    +        sSuspendedCount++;
    +    } else {
    +        sEndedCount++;
    +    }
    +}
    +
    +
    +static void onKey(WindowT *win, int32_t key, int32_t mod) {
    +    (void)win;
    +    (void)mod;
    +    sKeyCount++;
    +    sLastKey = key;
    +}
    +
    +
    +static void onMouse(WindowT *win, int32_t x, int32_t y, int32_t buttons) {
    +    (void)win;
    +    (void)buttons;
    +    sMouseCount++;
    +    sMouseX = x;
    +    sMouseY = y;
    +}
    +
    +
    +static void onPaintCount(WindowT *win, RectT *dirty) {
    +    (void)win;
    +    (void)dirty;
    +    sPaintCount++;
    +}
    +
    +
    +// The window that takes full screen (a menu bar, so F10 has a menu to
    +// open when it is not taken by full screen).
    +static WindowT *ownerCreate(void) {
    +    AppContextT *ctx = tCtx();
    +    WindowT     *win = dvxCreateWindow(ctx, "Game", WIN_A_X, WIN_A_Y, WIN_W, WIN_H, false);
    +    MenuBarT    *bar = wmAddMenuBar(win);
    +    MenuT       *m   = wmAddMenu(bar, "&Game");
    +
    +    wmAddMenuItem(m, "&New", 1);
    +    win->onKey   = onKey;
    +    win->onMouse = onMouse;
    +    return win;
    +}
    +
    +
    +int main(void) {
    +    platformSetLogPath("wmExcl.log");
    +    tSuiteBegin("wm suite (full-screen exclusive)");
    +
    +    int32_t loaded = tProcessInit(DVXTEST_WIDGET_DIR);
    +
    +    printf("widgets loaded: %" PRId32 "\n", loaded);
    +    TEST_TRUE(loaded > 0);
    +
    +    caseBeginEnd();
    +    caseErrors();
    +    caseInput();
    +    caseOthersRunOn();
    +    casePresentScaled();
    +    caseResumeFails();
    +    caseSuspendResume();
    +
    +    return tSuiteEnd();
    +}