|
pico9918-core 1.3.0
TMS9918A / F18A video display processor emulation in C99
|
pico9918-core is the video-chip emulation engine inside the PICO9918, the RP2040/RP2350 drop-in replacement for the TMS9918A family. It is also the library you can put into another emulator: the same bus, renderer, F18A GPU and PICO9918 extensions that run on the boards, in C11 with no third-party runtime dependencies.
It covers the TMS9918A / TMS9928A / TMS9929A, F18A, PICO9918 and PICO9918 PRO personalities. Each VDP instance can choose which chip it answers as; the old vrEmuTms9918 library remains the smaller choice for an emulator that only wants the original hardware.
Rendering is one scanline at a time into a buffer you own, with nothing allocated per line. The bus and renderer never call back into your program: drive pico9918_write_* and pico9918_scan_line and nothing above you runs. That is what lets the same code drive a physical PICO9918 at video rate and sit inside a desktop emulator unchanged.
If you are putting the library into an emulator, start with the emulator integration guide. It begins with the two guest ports and the indexed renderer, then carries the same instance through full-frame scan-out, GPU pacing, chip personalities, the PICO9918 configuration protocol and a debugger. The rest of this page is the shorter tour and build reference.
The four documented modes of the TMS9918A datasheet, plus the F18A superset:
| Base modes | Graphics I, Graphics II, Multicolor, Text (40 column) |
| Base features | sprites with magnification, fifth-sprite reporting, sprite collision, VSYNC interrupt |
| F18A modes | 80-column text, 24 / 30 / 48 / 60 row screens |
| F18A tiles | a second tile layer, per-tile palette select, position-based attributes, tile/sprite priority |
| F18A colour | ECM 1, 2 and 3 (one, two and three bitplanes) on both tiles and sprites, a 64-entry palette |
| F18A scrolling | horizontal and vertical, per-page, with split and cross-page cases |
| F18A layers | a bitmap layer, above or below the tiles |
| F18A GPU | a TMS9900 core running programs out of VRAM |
Locked, it is a TMS9918A: the enhancements are unreachable until a guest runs the F18A unlock sequence.
A complete, buildable version of this is examples/render_frame.c - it sets a mode up, fills the tables, renders 192 lines and writes a PPM:
Built with PICO9918_SINGLE_INSTANCE=1 - what the firmware ships - there is one VDP at a fixed address instead: pico9918_init() replaces pico9918_new() and every call above drops its first argument. The PICO9918_INST macros in pico9918.h spell a call that compiles either way, which is what the examples and the tests use.
The loop above reads palette indexes and colours them itself, which is the smaller surface. pico9918_frame_scanline() is the other one: it composes a whole display line - borders, the picture, the blanking and scanline registers, the line interrupt, the GPU trigger and the palette LUT - into a buffer of PICO9918_PIXEL_T, which is what a board's video layer wants.
The buffer is hVirtualPixels wide, and every mode fills the same window:
| pixels | |
|---|---|
0 .. hBorder-1 | left border, the backdrop colour |
hBorder .. hBorder+511 | the picture, always 512 pixels - a 256-wide mode is doubled into it, and unlocked 80-column text on the 8bpp tier already fills it |
hBorder+512 .. | right border |
where hBorder is (hVirtualPixels - 512) / 2. A host that reads the window at a different offset, or expects only half of it to have been written, gets the border fill through the middle of its picture rather than an error.
A pixel is the board's format: BGR12 in 16 bits, four bits a channel, red's nibble lowest. One policy ships and it is the platform default on target and off, so a host converts to its own surface format rather than asking the library to render into it:
Vertically the frame is a fixed height too, and pico9918_frame_output_line() is the entry that keeps it that way. A host asks for output lines 0 to 479 in every mode and gets back whether the buffer changed:
Everything vertical is behind that call. Normally two output lines share one rendered line, so the second returns false and a host presents its existing conversion again. In double-rows mode there are 480 rendered lines instead of 240 and every call returns true. The CRT-scanlines setting darkens every second output line and is applied in there as well, which is why the return is "did the pixels change" rather than "was this
a repeat" - a dimmed repeat needs converting again.
So a host never sees vPixelScale, double rows, row-30 mode or the scanline setting. pico9918_frame_scanline() below it renders one virtual line and is what the board and interlaced SCART drive instead, a line at a time as their DMA asks for them.
Every PICO9918_* symbol in platform/ is #ifndef-guarded, so a host can replace the policy - but the scanline is laid out in 32-bit words holding two pixels, and the 80-column palette build stages through uint16_t, so a wider pixel is not a supported substitution. The generated pico9918_build_config.h records the width the library was compiled with and the public headers assert against it, so a mismatch is a compile error rather than a wrongly strided buffer.
An F18A's GPU is a TMS9900 that guest software arms by writing VR55, and it has to run somewhere. Give the library a rate and it runs it for you:
That is the whole integration - a host that sets a rate calls no other GPU entry point. The library runs a slice per scanline, re-derived each frame, and also runs one from inside the register write that arms a program. That second part matters: software probing for an F18A writes a two-instruction self-modifying program and reads the result back a few cycles later, so a GPU serviced only once a scanline has not run yet and the probe intermittently reports no F18A at all.
PICO9918_GPU_IPS_CLASSIC, _PRO and _F18A are rough throughputs for the two board tiers and the original hardware. A host with a thread to spare can leave the rate at zero and run pico9918_gpu_loop() on that thread instead, which is what the firmware does.
The integration layer has four callbacks you can register: pico9918_config_set_applied_callback, pico9918_frame_set_config_reload_callback, pico9918_gpu_set_flash_callback and pico9918_gpu_set_config_save_callback. NULL is the default for all four.
Where they fire depends on who paces the GPU. With a core or a thread of its own running pico9918_gpu_loop, as the board has, each fires at most once a frame and never from the scanline body. Where the library paces the GPU itself - pico9918_gpu_set_clock, or any build without the hand-written Thumb core, which is every desktop one - the GPU is serviced from inside pico9918_frame_scanline, and the flash and config-save callbacks are dispatched from that service.
That means those two callbacks can fire while a desktop host is rendering a scanline. A callback that blocks on a file or a socket stalls scan-out for as long as it takes. Do the work elsewhere and answer later: the flash callback has pico9918_gpu_flash_complete for exactly that.
pico9918_debug.h lets a memory pane, register editor and disassembler use the public surface instead of reaching into impl/.
None of it disturbs the machine: no address latch moves, no read-ahead is consumed, no status is cleared. Two things are worth knowing before you use it.
The map is the backing state, not the map a GPU program sees. Every byte appears exactly once, the workspace overflow above 0xFFFF included, and it does not re-lay-out when the chip personality changes - where a running personality mirrors four windows across 4KB each and reads 0 in the holes. pico9918_gpu_mem_size() is its size.
The register write is a store, not the device's write. pico9918_write_register_value is a protocol: on a locked device it folds the number to three bits, so asking it for R30 writes R6, and R55, R56, R50 and R63 each set something in motion. pico9918_debug_reg_write stores the byte where its number says and reconciles only what the instance needs to stay consistent. A span write refuses the register and status windows for the same reason, and returns short at their edge, so a bulk load cannot start a GPU program by accident.
It is in the normal host build. A board turns it off, and a host that has no debugger can do the same with -DPICO9918_DEBUG_API=OFF.
PICO9918_EXAMPLES=ON adds these to the library's own build. Each links pico9918::core and includes only the public headers, so each also builds against an installed package: cmake -S examples -B build-examples -DCMAKE_PREFIX_PATH=<staging>.
render_frame.c | Graphics I from nothing: registers, three tables, 192 lines, a PPM |
host_bus.c | the same screen driven the way a guest machine drives it - two ports, four operations, and a frame interrupt acknowledged by reading the status port |
f18a_modes.c | one name table drawn twice, locked and unlocked: ECM2, attributes by screen position, the second tile layer and both scrolls |
gpu_program.c | a program loaded into VRAM and run on the F18A's TMS9900, on a thread of its own beside a raster paced to 60Hz |
gpu_program.py is the same job the other way round: one thread, alternating bounded slices of program with lines of raster. Both shapes are real, and which one a host wants is the decision those two files are about - a GPU program can wait on the display, so a host that never advances the raster while one runs waits forever. They run the same two programs, and are worth reading together.
bindings/python/ is the same VDP as a module: one class, the host's ports, and the frame read back as palette indices or as RGB.
It needs the default PICO9918_SINGLE_INSTANCE=0 - a class per VDP is the point of it.
It carries the F18A's GPU as well. Unlock the chip, put a TMS9900 program in VRAM and write its entry address to VR54 and VR55 - the low byte last, because writing that one is what starts it:
gpu_step() runs the program to completion, which is fine until the program waits on something. A GPU program can read the scanline being scanned out at >7000, and one that pages a bitmap in the vertical blank polls it until the raster is somewhere safe - so gpu_step() never returns, because the caller that would move the raster is the one blocked inside it. gpu_step_n() is bounded and comes back with the PC kept, which is what lets the one thread do both:
The firmware instead gives the GPU a core of its own. A Python caller has no way to hand it one - the GIL is held across every call into the module, deliberately - so the interleave above is the shape to reach for here.
That is the normal emulator build: multiple instances, runtime chip selection, the PRO line width, the debug API, the 64KB map, enhanced renderer and GPU. A new instance starts as PICO9918 PRO; call pico9918_set_chip() when the emulated machine has something else. It exports the same CMake target whether it is vendored or installed:
Python 3 is needed at build time for the image assets; the library itself has no third-party runtime dependencies. See BUILDING.md for installation, static and shared libraries, smaller host builds, Python and every core-specific CMake setting.
There is no ctest target. tools/ci.sh is the whole desktop gate, one subcommand a job, and it is the same script CI runs:
None of it builds for the RP2040 or RP2350 - the Pico path needs the SDK - so a green badge here means the library is correct and portable, not that the firmware builds.
The PICO9918 is a drop-in replacement for the TMS9918A in a TI-99/4A, ColecoVision, MSX, NABU, CreatiVision or any other machine that used one. This library is its renderer.
HBC-56 uses vrEmuTms9918, the library this one grew out of, to render to an SDL texture.
This repository is generated: the library is developed at core/ in visrealm/pico9918, where a change can be measured against a device, and split out from there. Issues and pull requests belong on that repository - see CONTRIBUTING.md, which also covers the one class of change that is easier to make here.
This code is licensed under the MIT license