Bug fixes: - pic: honor auto-EOI mode (ICW4 bit 1); ISR bit now only set when auto-EOI is disabled (previously set unconditionally) - spec: correct port 0xB0-in polarity (0x00 = hard disk, 0xFF = floppy) to match both the Odin and C implementations - debug: gate cga_regs/cga_vram handlers on the video adapter actually being CGA (returned garbage when EGA/HGC was active) Dead code removal: - unused public APIs: vxt_cpu_flush_prefetch, vxt_bus_set_pic, vxt_uart_registers, vxt_uart_set_error, vxt_disk_bootstrap - write-only CPU instruction-stream fields + MAX_INSTRUCTION_SIZE - unused macros VXT_CPU_USE_PREFETCH, MBOX_TOTAL_SIZE - debug server: g_active_server, single_step, last_segments, SEG_TAG_* - UART iir field, always-false ega_config stub, empty destroy stubs (joystick, UART, mouse), exec_wait/exec_esc folded into exec_nop - frontend: BIOS_ROM_BASE/VXTX_ROM_BASE, mixer HOSTFS_DURATION/ HOSTFS_VOLUME/seek_volume, unreachable clamps, ini_parse return value - misc dead stores/clamps (hostfs, hgc), dangling comment (cpu.c) Makefile: drop 12 redundant pattern rules, replace dead check-circle test with a real compiler check, drop duplicated distclean line, complete .PHONY, collapse flag lists, develop honors LIBRETRO_OUT. STATUS.md: correct stale module test counts (110 -> 119).
21 KiB
VirtualXT -- Specification
Philosophy
VirtualXT shall be a Turbo PC/XT emulator. Its design shall be governed by three principles:
-
Simplicity. Every component shall have one job and one interface. The peripheral system shall have one vtable. The bus shall have one dispatch mechanism. The CPU shall have one decode-execute loop. When a simpler design can do the job, it wins.
-
Minimalism. No external dependencies beyond the libretro API header. No threading. No dynamic plugin loading. No abstraction layers that don't map directly to hardware concepts.
-
Transparency. A developer familiar with PC/XT hardware should be able to read any module and recognize the hardware it models. The emulator's structure shall mirror the machine it emulates.
The implementation language shall be C11. The core emulator shall compile in a
freestanding environment (-ffreestanding) with no standard library
dependency, ensuring portability to bare-metal targets. Optional modules
(networking, debugging) and unit tests may use hosted facilities such as
threads and sockets, but these shall never be required for the core to build
and run.
Design Characteristics
Several choices shall be applied consistently across the codebase:
- Freestanding-first core design
- Flat lookup-table dispatch instead of dynamic discovery chains
- Statically allocated singleton-style system state
- Small, explicit interfaces between layers
- Minimal dependencies and minimal hidden control flow
The intended result is a codebase where major behavior is easy to trace from the frontend entry points down to CPU, bus, and peripheral interactions.
System Overview
The emulator shall be organized as four layers:
┌──────────────────────────────────────────┐
│ Frontend │
│ (libretro callbacks, frame loop) │
├──────────────────────────────────────────┤
│ Machine │
│ (step loop, timer dispatch, reset) │
├────────┬────────────────────────┬────────┤
│ CPU │ Bus │ Timers │
│8088/V20│ memory map · I/O map │ │
├────────┴────────────────────────┴────────┤
│ Peripheral Modules │
│ PIT · PIC · PPI · DMA · ROM · CGA · … │
└──────────────────────────────────────────┘
Frontend
The frontend shall be the boundary between the emulator and the host environment. In normal use this shall be the libretro integration layer.
Its responsibilities shall be:
- Accept host callbacks for video, audio, input, timing, and file access
- Create and initialize the emulated machine
- Apply runtime configuration and machine setup
- Drive frame-by-frame execution
- Present audio/video output back to the host
At a high level, the frontend shall own host integration while the emulator core shall own emulation behavior.
Machine
The machine shall be the central coordinator for the emulated system.
Its responsibilities shall be:
- Own global emulator lifecycle operations such as create, init, reset, and destroy
- Register and initialize peripherals
- Step the CPU forward by a requested cycle budget
- Advance timers alongside CPU execution
- Route configuration requests to the appropriate layer
The machine shall be the point where the frontend's frame-level view meets the core's cycle-level execution model. The machine shall be a singleton: all state shall be module-scoped. There shall be exactly one machine per process.
Core Services
Core services shall be the internal mechanisms shared by the whole emulator:
- CPU instruction execution
- Bus dispatch for memory and I/O
- Timer scheduling and updates
- Interrupt routing
These services shall form the runtime backbone of the emulator. They shall not model a single external device; instead, they shall provide the execution environment in which peripherals and the CPU interact.
Peripheral Modules
Peripheral modules shall represent hardware devices attached to the machine. Examples include memory-mapped devices, I/O devices, video, storage, and support chips.
A module may instantiate multiple peripherals. For example, a VGA module could register both a video peripheral (for the CRTC and attribute controller I/O) and a ROM peripheral (for the VGA BIOS). Each peripheral still implements the same vtable interface independently.
Each peripheral shall follow the same broad model:
- It exposes a common interface to the machine and bus
- It shall register the address ranges or ports it owns
- It shall maintain its own device state
- It shall react to reads, writes, resets, timers, and configuration as needed
- It may read frontend-provided resources via the Frontend API during
install()
This shall keep the system modular without introducing deep abstraction layers.
Runtime Flow
The emulator shall run in a simple top-down flow:
- The frontend shall receive a frame callback from the host
- It shall gather input and determine how many emulated CPU cycles to run
- It shall ask the machine to execute that cycle budget
- The machine shall repeatedly step the CPU and update timers
- CPU memory and I/O operations shall be dispatched through the bus to peripherals
- Peripherals shall update internal state and may raise interrupts or produce output
- The frontend shall submit the resulting audio and video to the host
This structure shall keep host concerns separated from core emulation while still allowing the whole system to advance in a predictable loop.
Bus
The bus shall be two flat arrays that map addresses to peripheral indices.
Memory map. The 8088 has a 20-bit address space (1 MB). The memory map shall divide this into 16-byte pages, yielding 65,536 entries. Each entry shall hold the index of the peripheral responsible for that page. A memory access shall mask the address to 20 bits, shift right by 4 to find the page, look up the peripheral index, and call that peripheral's read or write callback.
I/O map. The 8088 has a 16-bit I/O port space (65,536 ports). The I/O map shall have one entry per port, each holding a peripheral index. Port access shall look up the index directly and call the peripheral's I/O callback.
Default peripheral. Peripheral index 0 shall be special. It shall be created first and claim the entire memory and I/O space. It shall provide 1 MB of RAM and return open-bus values (0xFF) for all I/O reads. Every other peripheral shall overlay its ranges on top by writing its own index into the map entries. This means unregistered regions shall always fall through to sensible defaults without any conditional logic in the dispatch path.
The lookup tables shall be the only dispatch mechanism. There shall be no linked lists, priority chains, or fallback searches.
Peripheral Interface
Every hardware device -- RAM, ROM, timers, interrupt controller, video adapter, disk controller -- shall implement the same interface. This shall be a struct of function pointers (a vtable) with the following callbacks:
Lifecycle:
install-- called once; the peripheral shall register its memory and I/O ranges by writing its index into the bus lookup tables.reset-- shall restore the peripheral to power-on state.destroy-- shall free resources.config-- shall accept a section/key/value triple for runtime configuration. The section corresponds to an INI section name during data-driven setup.name-- shall return a human-readable identifier.
Memory bus:
read(address)-- shall return a byte from the peripheral's address space.write(address, byte)-- shall store a byte.
I/O bus:
io_in(port)-- shall return a byte from an I/O port.io_out(port, byte)-- shall write a byte to an I/O port.
Timer:
timer(id, cycles)-- shall be called when a registered timer fires.
Interrupt controller (PIC only):
pic_irq(line)-- shall assert an interrupt request line.pic_next-- shall return the highest-priority pending interrupt vector, or -1.
DMA controller (DMA only):
dma_read(channel)/dma_write(channel, byte)-- DMA transfer callbacks.
All callbacks shall be nullable. The bus shall check for null and substitute safe defaults: 0xFF for reads, no-op for writes.
Device state shall be attached by embedding the vtable struct as the first field of a larger device-specific struct. Because the vtable is at offset zero, a pointer to either type shall be interchangeable. This gives each device private state with zero indirection cost and no separate allocation.
Class tags. Each peripheral shall carry a bitmask of class flags (generic,
PIC, DMA, PPI, PIT, video, audio, etc.). The machine shall use these to
discover specific peripherals -- for example, locating the PIC for
interrupt routing. Peripherals may also read frontend-provided
configuration via the Frontend API during install().
The system shall support up to 64 peripherals. This is more than sufficient for a PC/XT and shall allow the index to fit in a single byte in the lookup tables.
Frontend API
Modules may occasionally need configuration or resources that cannot be
expressed through the peripheral vtable alone. The Frontend API shall be a set
of global configuration functions that sit alongside the peripheral manager.
Modules shall read these values during install(); the frontend shall set them before
vxt_machine_init().
This shall be intentionally separate from the vtable because it represents module ↔ frontend communication, not module ↔ machine communication. The peripheral vtable shall handle runtime interaction; the Frontend API shall handle setup-time resource binding such as framebuffers, file loaders, and logging.
Global setters shall be called once by the frontend before machine init.
Global getters shall be used by modules during install() to discover
frontend-provided resources. This eliminates the need for type-specific
setup functions or module-specific global pointers in the frontend.
CPU
The CPU shall model an Intel 8088 processor implementing the 8086 instruction set, with optional NEC V20 emulation. It shall follow the decode-dispatch pattern:
- Prepare -- shall clear per-instruction state and set default segment bases.
- Prefix -- shall consume prefix bytes: segment overrides, LOCK, REP/REPNE.
- Decode -- shall read the opcode byte, decode operands (ModR/M, immediates, displacements), and resolve a function pointer for execution.
- Execute -- shall call the resolved function.
- REP -- for string instructions, shall decrement CX and repeat if nonzero (with flag checks for REPE/REPNE variants).
- Interrupt check -- if interrupts are enabled, shall poll the PIC for a pending vector and service it.
- Prefetch -- shall use any remaining bus cycles to speculatively fill the prefetch queue from ahead of IP.
Cycle accounting shall model the 8088's split architecture. The execution unit (EU) and bus interface unit (BIU) shall operate in parallel. Memory and port accesses shall charge bus cycles; arithmetic shall charge execution cycles. The true cost of an instruction shall be the greater of the two.
Cycle lookup tables. Static cycle costs shall be stored in per-CPU-type tables indexed by opcode. A zero entry shall mean dynamic cost computed by the handler.
Prefetch queue. A 4-byte circular buffer shall model the 8088's instruction prefetch. Instruction bytes shall be fetched from the queue when available, falling back to direct memory reads. After execution, idle bus cycles shall fill the queue ahead of the instruction pointer.
Register file. The 8086 registers (AX/BX/CX/DX with 8-bit halves, SP, BP, SI, DI, segment registers, IP, FLAGS) shall be stored in a struct with overlapping 8-bit and 16-bit fields. Flag manipulation shall use a 256-entry parity lookup table and bitmask operations.
ALU. Each arithmetic and logic operation (ADD, SUB, AND, OR, XOR, shifts, rotates, MUL, DIV, BCD adjustments) shall be a standalone function that computes the result and updates the relevant flags. Operations shall be generic over byte and word widths.
Timers
Peripherals shall register timers during installation, specifying an interval in microseconds. The peripheral manager shall maintain a flat list of timers, each tracking accumulated CPU cycles. After every instruction, the machine shall advance all timers by the instruction's cycle count. When a timer's accumulated cycles reach its interval threshold (converted from microseconds using the CPU frequency), the owning peripheral's timer callback shall fire. For non-zero intervals, the accumulator subtracts the threshold, carrying the remainder forward for timing accuracy. For zero intervals (fire every update), the accumulator resets to zero.
An interval of zero shall mean the timer fires on every update, which the PIT shall use for sub-instruction-granularity counting.
Interrupts
Peripherals shall never interrupt the CPU directly. All hardware interrupts shall route through the PIC (Intel 8259):
- A peripheral shall call
interrupt(irq_line)on the bus. - The bus shall forward this to the PIC, which sets the corresponding request bit.
- After each CPU instruction, the CPU shall poll the PIC via
next(). - If the PIC returns a valid vector and interrupts are enabled in FLAGS, the CPU shall push FLAGS, CS, and IP onto the stack, clear the interrupt flag, and jump to the address in the interrupt vector table.
This shall mirror real hardware: the PIC shall arbitrate priority, masks, and end-of-interrupt signaling. The CPU shall be unaware of individual IRQ lines.
Disk Emulation
Unlike most PC/XT peripherals, the disk controller shall not be a hardware-level device model. Instead, it shall emulate the BIOS INT 0x13/0x19 interface directly through two I/O ports. This avoids the need to model a real floppy controller (Intel 8272/NEC 765) while still providing full disk support for DOS and DOS applications.
Architecture. The disk peripheral shall register I/O ports 0xB0 and 0xB1. The VXTX extension ROM (see below) shall hook INT 0x13 and INT 0x19 and translate them into port I/O that the disk peripheral shall handle. The disk controller shall read CPU registers directly from the machine to get the INT 0x13 parameters (drive number, CHS address, sector count, buffer address) and write results back the same way.
I/O ports:
- Port 0xB0 out -- bootstrap. Shall load the first sector of the boot drive into memory at 0x7C00.
- Port 0xB0 in -- shall return 0x00 if the boot drive is a hard disk, 0xFF if it is a floppy.
- Port 0xB1 out -- shall dispatch the INT 0x13 function in AH. Supported functions: reset (0x00), status (0x01), read sectors (0x02), write sectors (0x03), verify/format (0x04-0x07), drive geometry (0x08).
- Port 0xB1 in -- shall return 0x00 if drive DL has a disk mounted, 0xFF otherwise.
Asynchronous completion protocol. Because the libretro VFS interface does not define an asynchronous I/O mechanism, the disk controller shall support an optional guest-visible polling path for frontends where physical device I/O may block:
- When
file_api.read()orfile_api.write()returnsVXT_DISK_NOT_READY(-2), the controller shall record the pending operation and returnAH = 0x80with carry clear. - The VXTX BIOS shall poll port
0xB1:0x80means busy,0x00means complete,0xFFmeans error. On completion it shall read finalAL/AHfrom ports0xB2/0xB3. - The frontend shall complete the blocking call outside the emulation thread and
make the result available on the next
read()/write()call for the same drive. - Frontends that never return
VXT_DISK_NOT_READYshall see no behavioral difference.
Drive management. The controller shall support up to 256 drives (indices 0x00-0xFF). Floppy drives shall use indices 0x00-0x7F; hard disks shall use 0x80-0xFF. On mount, the controller shall auto-detect floppy geometry from well-known image sizes (160K, 360K, 720K, 1.2M, 1.44M) or compute hard disk geometry (63 sectors, 16 heads, cylinders derived from image size). All addressing shall use LBA, computed from the CHS values passed by the BIOS.
File access. The controller shall not access files directly. Instead, it
shall receive a vxt_disk_file_api_t struct of function pointers (open, read,
write, seek, tell, close, size, flush, set_led) from the frontend. The
frontend shall bridge these to the libretro VFS interface.
Configuration. The disk controller shall accept INI configuration keys under
the [disk] section: boot (set boot drive number), auto (mount with
auto-detect), A (mount as floppy 0), C (mount as hard disk 0x80), and
umount (unmount a floppy drive).
VXTX Extension ROM
VXTX shall be a 2 KB extension ROM mapped at address 0xFD800. It shall be the glue between the system BIOS and the emulated disk controller.
Purpose. A real PC/XT uses a hardware floppy controller with its own BIOS extension ROM that hooks INT 0x13. VirtualXT skips the hardware controller entirely. VXTX shall fill the same role in software: it installs INT 0x13 and INT 0x19 handlers that forward disk requests to the emulated disk peripheral via I/O ports.
Initialization. During POST, the system BIOS shall scan the ROM space for expansion ROMs (signature 0x55AA). When it finds VXTX at 0xFD800, it shall call the ROM's init entry point. VXTX shall install its interrupt handlers and update the BIOS Data Area equipment word at 0x40:0x10 to report one floppy drive present.
INT 0x13 handler. Shall perform OUT 0xB1, AL to dispatch the disk request
to the disk peripheral. The peripheral shall read the function number and
parameters directly from CPU registers. After the I/O completes, the handler
shall copy the CPU flags to the interrupt stack frame so the caller sees the
correct carry flag result, then returns via IRET.
INT 0x19 handler. Shall perform OUT 0xB0, AL to trigger the bootstrap
sequence (loading sector 1 to 0x7C00). Before the OUT, it patches the
return address on the stack to 0x0000:0x7C00, so the IRET transfers control
directly to the loaded boot sector.
Build Modes
VirtualXT shall be organized around a freestanding emulator core, with hosted code used only where it is clearly needed.
At a high level:
- The core emulator and normal frontend build shall be freestanding-oriented
- Tests shall run in a hosted environment
- Some development-only tooling shall be enabled only in develop builds
This shall keep the main emulator architecture portable while still allowing richer debugging and test support during development.
Testing
Tests shall be hosted (they may use libc for stdio, malloc, etc.) and shall run
against the same libvxt.a core library that ships in the emulator.
CPU opcode tests. The 8088 Machine Opcode Operation (MOO) test suite
covers the full instruction set. A binary parser shall read .MOO files,
apply initial CPU/memory state, execute to completion, and validate
final state against expected results. It shall be run with make test-cpu (requires
make testdata to fetch the test files).
Module tests. A lightweight test framework (module.h) shall provide
peripheral-level unit tests that instantiate individual modules, wire them
to the bus, and exercise their behavior in isolation. Each test file
(e.g. test_pic.c, test_pit.c) shall compile into a single module-runner
binary. It shall be run with make test-modules.
Both suites shall run together via make test.
Optional Debug Server
Develop builds may include an optional debug server layer.
This shall not be part of the normal emulation path, but an auxiliary development feature that can observe machine-visible events and support debugging without changing the core architectural split between frontend, machine, core services, and peripherals.
Frontend Effects
The frontend may apply optional visual and audio effects during presentation, such as a CRT filter and simulated hardware sounds. These are purely cosmetic and do not affect emulation state.
Bare-Metal Multicore Frontends
On bare-metal targets such as the Raspberry Pi, the frontend may utilize multiple CPU cores. The design shall maintain a strict separation: the emulator core shall remain single-threaded, and host-integration concerns shall be offloaded to dedicated cores.
- Core 0 shall handle initialization, USB input polling, and device hot-plug.
- Core 1 shall run the emulation frame loop.
- Core 2 shall handle video presentation, including optional CRT filtering and scaling.
- Core 3 shall drive audio output from a shared ring buffer.
Synchronization shall be achieved through spinlocks, atomic flags with memory barriers, and trampoline callbacks. The emulation core shall call standard libretro video and audio callbacks; the frontend's callback implementations shall act as trampolines that queue work to the appropriate core via lockless atomics. The emulation core shall be unaware of threading; it shall be driven by a single caller exactly as in any other frontend.