Lua Scripting

Lua scripts run inside the emulator as coroutines, yielding back to the emulation core between frames. This lets you automate DOS without network round-trips: scripts access memory, inject input, and read the screen directly, all synchronized with frame dispatch.

Limitations

The Lua API cannot:

  • Call DOS interrupts (INT 10h, INT 21h, etc.)
  • Perform port I/O
  • Record or playback mouse input (use the REST API for that)
  • Access the host file system
  • Load or modify DOSBox configuration at runtime

These operations are only available through the REST API or direct emulator configuration.

Getting started

Scripts are loaded and started via the REST API. Send the source code to /api/v1/script/load, then trigger execution with /api/v1/script/start.

# Create a simple script
cat > myscript.lua << 'EOF'
-- Wait for the main menu to appear
dosbox.wait_for_text("MAIN MENU")
-- Press 'Y' and wait for confirmation
dosbox.type("Y")
dosbox.wait_frames(10)
EOF

# Load it
curl -X POST -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: text/plain" \
  --data-binary @myscript.lua \
  "http://localhost:8386/api/v1/script/load?name=mytest&debug=false"

# Start it
curl -X POST -H "Authorization: Bearer $TOKEN" \
  http://localhost:8386/api/v1/script/start

# Check status
curl -H "Authorization: Bearer $TOKEN" \
  http://localhost:8386/api/v1/script/status

See the REST API page for full load and status endpoint documentation.

Coroutine model

Scripts are coroutines: they run a step, yield, and resume the next frame. Yielding functions like type() and wait_for_text() suspend the coroutine until their condition is met. The emulator checks waiting conditions every frame and automatically resumes the coroutine when ready.

-- This script runs over multiple frames:
dosbox.type("C:\\> ")       -- yields; keys injected over next frames
dosbox.wait_frames(20)      -- yields; resumes after 20 frames pass
dosbox.wait_for_text("C:\\> ") -- yields; resumes when prompt appears
dosbox.type("GAME.EXE")     -- yields again

A script that never yields completes in a single frame dispatch.

API reference

All Lua functions live in the dosbox table. Each section below documents the available functions.

Input injection

dosbox.key(name, pressed)

Press or release a single key.

Arguments:

  • name (string): Key name, e.g. "KBD_a", "KBD_enter", "KBD_f1". See the key name table in the REST API input sequence documentation for the full list.
  • pressed (boolean): true to press, false to release.

Does not yield.

dosbox.key("KBD_a", true)    -- press A
dosbox.key("KBD_a", false)   -- release A
dosbox.key("KBD_enter", true) -- press Enter
dosbox.key("KBD_enter", false) -- release Enter

dosbox.type(text)

Type text as a series of paced keystrokes. This yields until all keys have been injected and the guest's keyboard buffer has drained.

Arguments:

  • text (string): Text to type. Supports ASCII printable characters, newlines, and tabs. Non-mappable characters are silently dropped.

Yields until the keyboard buffer empties. Keys are paced to respect the 8-slot i8042 buffer; typing an entire string at once would overflow it. The script must not read screen state immediately after calling type() - use wait_for_text() or wait_frames() to let the guest process the input.

The function uses the US keyboard layout: it injects key positions, and the guest's active DOS layout decides which characters come out. If the config loads another layout (keyboard_layout = de, or auto on a non-US system), typed text arrives garbled: on a German layout : comes out as Ö. Set keyboard_layout = us for any session driven by type(). Shifted symbols (like ! and @) are generated by pressing Shift + the base key.

-- Type a filename and press Enter
dosbox.type("MYFILE.TXT")
dosbox.wait_frames(5)  -- wait for keys to be processed
dosbox.type("\n")      -- newline

If the string contains only unmappable characters, type() returns without yielding (a no-op).

dosbox.mouse_move(dx, dy)

Move the mouse by a relative offset.

Arguments:

  • dx (number): Horizontal pixels to move.
  • dy (number): Vertical pixels to move.

Does not yield.

dosbox.mouse_move(10, -5)   -- move right 10 pixels, up 5 pixels
dosbox.mouse_move(-50, 0)   -- move left 50 pixels

dosbox.mouse_click(button)

Click a mouse button (press and release).

Arguments:

  • button (string): "left", "right", or "middle".

Does not yield.

dosbox.mouse_click("left")   -- left click
dosbox.mouse_click("right")  -- right click

Memory access

dosbox.mem_read(seg, off, len) -> string

Read guest memory and return it as a byte string.

Arguments:

  • seg (integer): Segment number (e.g., 0x1234).
  • off (integer): Offset within the segment.
  • len (integer): Number of bytes to read (1..1048576).

Returns the memory contents as a binary string. Use Lua's string library to inspect bytes.

Does not yield.

-- Read 100 bytes at segment 0x0040, offset 0x0000
local data = dosbox.mem_read(0x0040, 0x0000, 100)
print("Read " .. #data .. " bytes")

-- Check a specific byte
local byte_val = string.byte(data, 1)

dosbox.mem_read_byte(seg, off) -> integer

Read a single byte from guest memory.

Arguments:

  • seg (integer): Segment number.
  • off (integer): Offset within the segment.

Returns the byte value (0..255).

Does not yield.

local byte = dosbox.mem_read_byte(0x0040, 0x0000)
print("Byte: " .. byte)

dosbox.mem_read_word(seg, off) -> integer

Read a 16-bit word (little-endian) from guest memory.

Arguments:

  • seg (integer): Segment number.
  • off (integer): Offset within the segment.

Returns the word value (0..65535).

Does not yield.

local word = dosbox.mem_read_word(0x0040, 0x0000)
print("Word: " .. word)

dosbox.mem_write(seg, off, data)

Write guest memory.

Arguments:

  • seg (integer): Segment number.
  • off (integer): Offset within the segment.
  • data (string): Binary data to write (1..1048576 bytes).

Does not yield.

-- Write a 4-byte signature
dosbox.mem_write(0x1000, 0x0000, "TEST")

-- Write a NULL-terminated string
dosbox.mem_write(0x1000, 0x0000, "HELLO\0")

Screen and video

dosbox.screen_text() -> string

Read the current text-mode screen buffer. Returns an empty string if not in text mode.

Each row is a separate line in the returned string, with rows separated by newlines.

Does not yield.

local screen = dosbox.screen_text()
if screen:find("ERROR") then
    dosbox.log("Error detected on screen")
end

dosbox.screen_match(pattern [, opts]) -> boolean

Check if the screen text contains a pattern.

Arguments:

  • pattern (string): Lua pattern to match (supports Lua's string.find syntax).
  • opts (table, optional): Options table. Set {ignorecase=true} for case-insensitive matching.

Returns true if the pattern was found, false otherwise.

Does not yield.

local found = dosbox.screen_match("READY")
if found then
    dosbox.log("DOS is ready")
end

-- Case-insensitive match
if dosbox.screen_match("ERROR", {ignorecase=true}) then
    dosbox.abort("Error on screen")
end

dosbox.is_text_mode() -> boolean

Check if the emulator is currently in text mode.

Returns true for text modes (80x25, 80x43, etc.), false for graphics modes.

Does not yield.

if not dosbox.is_text_mode() then
    dosbox.log("In graphics mode")
end

dosbox.wait_for_text(pattern, timeout_frames [, opts]) -> boolean

Wait for text to appear on screen. Yields until the pattern is found or the timeout expires.

Arguments:

  • pattern (string): Lua pattern to match.
  • timeout_frames (integer): Maximum number of frames to wait (non-negative).
  • opts (table, optional): Options table. Set {ignorecase=true} for case-insensitive matching.

Returns true if the pattern was found, false if the timeout expired.

Yields until the condition is met or timeout expires.

-- Wait up to 300 frames (5 seconds at 60 FPS) for the DOS prompt
if dosbox.wait_for_text("C:\\>", 300) then
    dosbox.log("Got prompt")
else
    dosbox.abort("Timeout waiting for prompt")
end

-- Case-insensitive wait
dosbox.wait_for_text("ERROR", 150, {ignorecase=true})

The function also accepts a wall-clock timeout. If a single frame dispatch takes longer than the wall-clock limit, the wait aborts immediately with false to prevent indefinite hangs.

Frame timing

dosbox.wait_frames(n)

Wait for n frames to pass.

Arguments:

  • n (integer): Number of frames to wait (non-negative).

Yields until n frames have been dispatched.

-- Wait 10 frames (roughly 167 ms at 60 FPS)
dosbox.wait_frames(10)

-- Type and then wait for the keystroke to register
dosbox.type("Y")
dosbox.wait_frames(5)

dosbox.frame() -> integer

Get the current frame number.

Returns the current emulator frame number as an integer.

Does not yield.

local frame = dosbox.frame()
dosbox.log("Frame: " .. frame)

Drive and mount management

dosbox.mount_lock()

Lock the mount configuration to prevent further changes. Typically called near the end of an installation to ensure the mounted drives cannot be swapped.

Does not yield.

-- Complete the installation, then lock the config
dosbox.mount_lock()

OSD overlays

dosbox.osd(text [, opts])

Show an on-screen display overlay with text. Multiple overlays stack vertically.

Arguments:

  • text (string): Text to display.
  • opts (table, optional): Options table with these fields:
    • color (string): "white" (default), "green", "yellow", "red", or "cyan".
    • size (string): "medium" (default), "small", or "large".
    • x, y (integer): Custom position. If set, x and y work together for custom placement.
    • y (string): Shorthand position: "top" or "bottom" (default).
    • duration (integer): Frames to display before the overlay expires. Default is indefinite (stays until cleared).

Does not yield.

dosbox.osd("Installing game...")
dosbox.osd("Step 1 of 3", {color="green", size="small"})
dosbox.osd("Error!", {color="red", duration=3000})

dosbox.osd_clear()

Clear all OSD overlays with the tag lua-osd.

Does not yield.

dosbox.osd("Status update")
dosbox.wait_frames(60)
dosbox.osd_clear()

Video capture

dosbox.capture_start()

Start ZMBV video recording. This captures the emulator's output to a .avi file in the recordings directory.

Does not yield.

dosbox.capture_start()
dosbox.wait_frames(100)
dosbox.capture_stop()

dosbox.capture_stop()

Stop the current video recording.

Does not yield.

Logging

dosbox.log(message)

Write a message to the DOSBox log.

Arguments:

  • message (string): Message to log. Automatically prefixed with "LUA: ".

Does not yield.

dosbox.log("Script starting")
dosbox.log("Detected DOS version: " .. dos_version)

dosbox.debugmsg(message)

Write a message to the Lua debug log (if debug mode was enabled when the script was loaded). This is separate from the main DOSBox log.

Arguments:

  • message (string): Message to log.

Does not yield.

dosbox.debugmsg("Detailed trace info")

dosbox.abort(message)

Stop the script with an error message.

Arguments:

  • message (string): Error message.

This function never returns - it terminates the script immediately.

if not dosbox.screen_match("READY") then
    dosbox.abort("DOS did not boot correctly")
end

Script output

dosbox.output

A table where you can store values to return to the harness. The harness retrieves these values via the /api/v1/script/status endpoint under the output field (as JSON).

-- Set output values
dosbox.output["status"] = "complete"
dosbox.output["version"] = "1.0"
dosbox.output["test_passed"] = true

These values must be Lua primitives (strings, numbers, booleans) or simple tables of primitives. Returned to the harness as JSON.

curl -H "Authorization: Bearer $TOKEN" \
  http://localhost:8386/api/v1/script/status

# Response:
# {
#   "state": "completed",
#   "output": {
#     "status": "complete",
#     "version": "1.0",
#     "test_passed": true
#   }
# }

Sandbox restrictions

Scripts run in a sandboxed Lua environment with the following protections:

Blocked functions: dofile, loadfile, load, require, getmetatable, setmetatable, collectgarbage, rawset, rawget, rawlen, rawequal. These are set to nil to prevent code loading, meta-table manipulation, and bytecode exfiltration.

No string.dump: The string.dump function is removed to prevent function serialization.

Pattern complexity limits: Lua's pattern matching (used in screen_match, string.find, etc.) is bounded to prevent exponential backtracking. The guard counts unbounded quantifiers (*, +, -) in the pattern and multiplies by the bit-length of the subject string. If the cost exceeds the threshold, the pattern is rejected before execution. Subjects are capped at 64 KiB and patterns at 1024 bytes. Escaped quantifiers (%*) and quantifiers inside character sets ([*+-]) are treated as literals and not counted.

Instruction limit: Scripts have a configurable instruction count limit (default: per your build). Hitting the limit terminates the script with an error.

Wall-clock timeout: Functions like wait_for_text() and type() have a wall-clock safety ceiling (e.g., 30 seconds). If a single operation takes longer, it aborts to prevent permanent hangs.

Available libraries: Only the safe subset of Lua's standard library is available: _G (base), table, string, math, coroutine, and utf8. The os, io, debug, and package libraries are not available.

Patterns and tips

Waiting for prompts

-- Wait for the DOS prompt with a 10-second timeout
if not dosbox.wait_for_text("C:\\>", 600) then
    dosbox.abort("DOS did not return to prompt")
end

Checking mode changes

-- Loop until we enter text mode
local max_tries = 100
for i = 1, max_tries do
    if dosbox.is_text_mode() then
        dosbox.log("Entered text mode at frame " .. dosbox.frame())
        break
    end
    dosbox.wait_frames(1)
end

Disk swap coordination

Set a value in dosbox.output to signal the harness, then wait for an acknowledgement:

-- Signal that disk 2 is needed (convention: swap_N = disc number)
dosbox.output["swap_1"] = 2
-- Wait long enough for the harness to poll, swap the image,
-- and write back an acknowledgement
dosbox.wait_frames(150)
-- The harness polls /api/v1/script/status, sees swap_1=2,
-- calls /api/v1/drive/swap, then continues

Reading text with pattern matching

Lua patterns support basic wildcards. Use .* to match "any characters":

-- Match "Press any key" anywhere on screen
dosbox.wait_for_text("Press.*key", 300)

-- Match case-insensitively
dosbox.wait_for_text("error", 300, {ignorecase=true})

See the Lua 5.4 pattern matching documentation for the full syntax.

Memory inspection

-- Read and log the first 16 bytes of the BIOS data area
local data = dosbox.mem_read(0x0040, 0x0000, 16)
for i = 1, math.min(#data, 16) do
    local byte = string.byte(data, i)
    dosbox.log(string.format("Byte %d: 0x%02x", i, byte))
end