pico9918-core 1.3.0
TMS9918A / F18A video display processor emulation in C99
Loading...
Searching...
No Matches
gpu_program.c
1/*
2 * pico9918-core - run a program on the F18A's GPU, beside a running raster
3 *
4 * Copyright (c) 2026 Troy Schrapel
5 *
6 * This code is licensed under the MIT license
7 *
8 * https://github.com/visrealm/pico9918-core
9 *
10 * \example gpu_program.c
11 *
12 * The F18A has a TMS9900 on it. A program sitting in VRAM runs on that core, reaches
13 * the VDP register file through the GPU's >6000 window, and draws by writing VRAM -
14 * so the host's whole job is to load it, point the GPU at it, and let it run.
15 *
16 * Except that "let it run" is where a host has a real decision to make, and it is
17 * what this example is about.
18 *
19 * A GPU program is not a subroutine. It runs beside the display, and it can WAIT on
20 * the display: the scanline being scanned out is readable at >7000, and a program
21 * that pages a bitmap wants to do it in the vertical blank, so it polls that address
22 * until the raster is somewhere safe. If nothing advances the raster while the
23 * program runs, that poll never ends.
24 *
25 * There are two honest shapes for a host, and the library supports both:
26 *
27 * A THREAD FOR THE GPU. What this file does, and what the firmware does - core 0
28 * runs pico9918_gpu_loop() while core 1 renders. The program executes beside the
29 * raster rather than instead of it, so the drawing appears a frame at a time. The
30 * thing to get right is the throttle: the frame loop has to be paced to the display
31 * rate, or the program's waits are answered faster than a display would answer them.
32 *
33 * ONE THREAD, INTERLEAVED. pico9918_gpu_step_n() runs a bounded number of
34 * instructions and returns with the PC kept, so a host with no core to spare
35 * alternates slices of program with lines of raster. gpu_program.py is written that
36 * way, against the same two programs, and is worth reading beside this one.
37 *
38 * pico9918_gpu_step() - unbounded, on the calling thread - is the third option and is
39 * the one that cannot service a wait: the caller that would advance the raster is the
40 * one blocked inside it.
41 *
42 * The default program is **Tursi's** F18A GPU Mandelbrot, from
43 * test/suite/data/gpu-programs/ where it is credited in full. 548 bytes: it sets
44 * VR0-VR7 itself, builds its own name table, and draws 49,152 pixels in Graphics II
45 * over x -2.0..+0.5, y +1.25..-1.25 with 14 iterations of Q13 fixed point before
46 * halting on IDLE. Twenty-three million TMS9900 instructions. It is somebody else's
47 * program, which is the point - nothing in it was written with this library in mind.
48 * It never looks at the raster, so it runs the same under any of the three shapes.
49 *
50 * The other thing a host has to get right: the GPU is an F18A feature, so the library
51 * the chip must be unlocked. A locked TMS9918A has no GPU to run anything on.
52 *
53 * Build it against an installed package:
54 *
55 * cmake -S examples -B build-examples
56 * cmake --build build-examples
57 * ./build-examples/gpu_program mandel.ppm
58 *
59 * A program and the address it was assembled for can be given instead. cube.bin,
60 * beside it, is a solid cube turning on the F18A's bitmap layer, and it is the one
61 * that waits on the raster:
62 *
63 * ./build-examples/gpu_program cube.ppm .../gpu-programs/cube.bin 0x3200
64 *
65 * Its colours come out wrong here, and that is the interesting part: the cube shades
66 * itself by rewriting palette RAM, and this colours the frame from the boot palette
67 * instead. test/suite/view.py --gpu reads the live one and shows it turning.
68 */
69
70#include "pico9918.h"
71#include "pico9918_frame.h"
72#include "pico9918_util.h"
73
74#include "gpu/gpu.h"
75
76#include <stdio.h>
77#include <stdlib.h>
78#include <string.h>
79
80#ifdef _WIN32
81#include <windows.h>
82#else
83#include <pthread.h>
84#include <time.h>
85#endif
86
87/* Where Tursi's program starts. Even, because the GPU refuses an odd address. */
88#define ENTRY 0x1B02
89
90/* The GPU may occupy base VRAM below the F18A's GRAM window. Blanked before the
91 program is loaded, so nothing already in VRAM can end up as part of the picture. */
92#define PROGRAM_SPACE 0x4000
93
94#define ROWS 192
95#define COLS TMS9918_PIXELS_X
96
97/* The host's own display, which is what the raster below is a raster OF. 640x480 at
98 60Hz is the shipping VGA mode; any host's numbers go here instead. */
99#define H_VIRTUAL_PIXELS 640
100#define V_DISPLAY_LINES 480
101#define FRAME_RATE_HZ 60.0f
102#define CORE_TEMP_C 30.0f
103#define FRAME_US (1000000 / 60)
104/* The share of a frame the raster spends outside the picture. A 480-line mode over
105 525 total lines puts it near a sixth, and it is what a program waiting for the
106 vertical blank has to catch. */
107#define BLANK_US (FRAME_US / 6)
108
109/* Long enough that a program which is not waiting on anything we understand is
110 reported rather than hung on. */
111#define GIVE_UP_FRAMES 3600
112
113/* The F18A registers this reaches. The public header names only R0-R7, the ones a
114 TMS9918A has, so the enhanced ones are written by number. */
115#define VR_UNLOCK 0x39 /* 0x1c twice unlocks the F18A */
116#define VR_GPU_HI 0x36 /* GPU start address, high byte */
117#define VR_GPU_LO 0x37 /* low byte - writing it arms the GPU at that address */
118
119#if PICO9918_SINGLE_INSTANCE
120#define INSTANCE
121#else
122static pico9918_t* tms9918;
123#define INSTANCE tms9918
124#endif
125
126static void writeReg(PICO9918_INST_ARG uint8_t reg, uint8_t value)
127{
129}
130
131static void writeVram(PICO9918_INST_ARG uint16_t addr, const uint8_t* data, size_t len)
132{
135}
136
137/* -------------------------------------------------------------------------
138 * The GPU, on a thread of its own.
139 *
140 * Nothing is locked, and that is not an oversight - it is the shape of the machine.
141 * On a board the two cores share VRAM with no lock either. Every access is a byte, so
142 * a reader sees the old value or the new one, and a half-drawn frame is the truth
143 * about a half-drawn picture.
144 * ---------------------------------------------------------------------- */
145static volatile int gpuBusy = 0;
146
147#ifdef _WIN32
148static DWORD WINAPI gpuBody(LPVOID unused)
149{
150 (void)unused;
151 pico9918_gpu_step(INSTANCE);
152 gpuBusy = 0;
153 return 0;
154}
155
156typedef HANDLE thread_t;
157static int threadStart(thread_t* t)
158{
159 *t = CreateThread(NULL, 0, gpuBody, NULL, 0, NULL);
160 return *t != NULL;
161}
162static void threadJoin(thread_t t) { WaitForSingleObject(t, INFINITE); CloseHandle(t); }
163static void sleepUs(unsigned us) { Sleep(us / 1000); }
164static uint64_t nowUs(void)
165{
166 LARGE_INTEGER f, t;
167 QueryPerformanceFrequency(&f);
168 QueryPerformanceCounter(&t);
169 return (uint64_t)t.QuadPart * 1000000u / (uint64_t)f.QuadPart;
170}
171#else
172static void* gpuBody(void* unused)
173{
174 (void)unused;
175 pico9918_gpu_step(INSTANCE);
176 gpuBusy = 0;
177 return NULL;
178}
179
180typedef pthread_t thread_t;
181static int threadStart(thread_t* t) { return pthread_create(t, NULL, gpuBody, NULL) == 0; }
182static void threadJoin(thread_t t) { pthread_join(t, NULL); }
183static void sleepUs(unsigned us)
184{
185 struct timespec ts = {(time_t)(us / 1000000), (long)(us % 1000000) * 1000};
186 nanosleep(&ts, NULL);
187}
188static uint64_t nowUs(void)
189{
190 struct timespec t;
191 clock_gettime(CLOCK_MONOTONIC, &t);
192 return (uint64_t)t.tv_sec * 1000000u + (uint64_t)(t.tv_nsec / 1000);
193}
194#endif
195
196/* A deadline, not a duration. Sleeping a fixed amount at the bottom of a loop drifts
197 by however long the work took, and keeping the display's time is the entire job of
198 the throttle. The last stretch is spun rather than slept because no host's sleep is
199 accurate to a scanline - ask for less than you need, then wait out the rest. */
200static void waitUntil(uint64_t deadlineUs)
201{
202 for (;;)
203 {
204 const uint64_t now = nowUs();
205 if (now >= deadlineUs) return;
206 const uint64_t left = deadlineUs - now;
207 if (left > 2000) sleepUs((unsigned)(left - 2000));
208 }
209}
210
211static uint8_t vPixelScale = 1;
212static uint16_t vVirtualPixels = V_DISPLAY_LINES;
213
214/* One frame, in the order a host's video layer calls it: every visible line, the
215 porch, then the end of frame. This is what moves the scanline register the program
216 reads at >7000 - pico9918_scan_line() renders a line but does not publish one, so a
217 host that only calls that has no raster as far as a GPU program is concerned.
218
219 The frame's time is spent in two places, and that split is the point. Rendering 480
220 lines takes a fraction of a frame, so a loop that renders then sleeps leaves the
221 raster parked whereever it stopped for the rest of the frame - and it stops in the
222 porch, at >FF, which is exactly the value a program waiting for the vertical blank
223 is looking for. Answered instantly, every time, the throttle does nothing. Waiting
224 out most of the frame BEFORE the porch leaves the raster in the picture where it
225 belongs, and the remainder after it is a blank wide enough for a program to catch.
226
227 The geometry a frame runs under is the PREVIOUS frame's: pico9918_frame_end is the
228 only thing that recomputes it, on a device too. */
229static void renderFrame(PICO9918_INST_ARG uint64_t startUs)
230{
231 static PICO9918_PIXEL_T pixels[H_VIRTUAL_PIXELS + 16];
232
233 pico9918_scanline_params_t params = {H_VIRTUAL_PIXELS, vVirtualPixels, false, 0};
234 for (uint16_t y = 0; y < vVirtualPixels; ++y)
235 pico9918_frame_scanline(PICO9918_INST y, &params, pixels);
236
237 waitUntil(startUs + FRAME_US - BLANK_US);
239
240 pico9918_frame_display_t display = {V_DISPLAY_LINES, false, vPixelScale, vVirtualPixels};
241 pico9918_frame_end(PICO9918_INST CORE_TEMP_C, FRAME_RATE_HZ, &display);
242 vPixelScale = display.vPixelScale;
243 vVirtualPixels = display.vVirtualPixels;
244 waitUntil(startUs + FRAME_US);
245}
246
247static int writePpm(const char* path, const uint8_t* rgb)
248{
249 FILE* f = fopen(path, "wb");
250 if (!f) return 0;
251 fprintf(f, "P6\n%d %d\n255\n", COLS, ROWS);
252 fwrite(rgb, 1, (size_t)COLS * ROWS * 3, f);
253 fclose(f);
254 return 1;
255}
256
257int main(int argc, char** argv)
258{
259 const char* out = (argc > 1) ? argv[1] : "gpu.ppm";
260 const char* bin = (argc > 2) ? argv[2] : MANDEL_BIN;
261 const uint16_t entry = (argc > 3) ? (uint16_t)strtoul(argv[3], NULL, 0) : ENTRY;
262
263 if ((entry & 1) || entry >= PROGRAM_SPACE)
264 {
265 fprintf(stderr, "%#06x cannot be a start address: the GPU refuses an odd one, and "
266 "a program lives below %#06x\n", entry, PROGRAM_SPACE);
267 return 1;
268 }
269
270 static uint8_t program[PROGRAM_SPACE];
271 FILE* f = fopen(bin, "rb");
272 if (!f)
273 {
274 fprintf(stderr, "cannot open %s\n", bin);
275 return 1;
276 }
277 const size_t len = fread(program + entry, 1, sizeof program - entry, f);
278 fclose(f);
279 if (!len)
280 {
281 fprintf(stderr, "%s is empty\n", bin);
282 return 1;
283 }
284
285#if PICO9918_SINGLE_INSTANCE
286 pico9918_init();
287#else
288 tms9918 = pico9918_new();
289 if (!tms9918) return 1;
290#endif
293
294 /* Unlocked: two writes of 0x1c to VR57, which is the F18A's own sequence and the
295 only register write that is honoured while locked. */
296 writeReg(PICO9918_INST VR_UNLOCK, 0x1c);
297 writeReg(PICO9918_INST VR_UNLOCK, 0x1c);
298
299 /* The register file is left as pico9918_reset() made it. Do not blank it from here:
300 VR48 is the address auto-increment the data port uses, and zeroing it would make
301 every byte of the load below land on the same address. */
302 writeVram(PICO9918_INST 0, program, sizeof program);
303
304 /* One frame before the program starts, so the raster it reads has a value in it
305 rather than whatever a reset left. */
306 renderFrame(PICO9918_INST nowUs());
307
308 /* Arm and run. Writing the low byte is what latches the address and starts it, so
309 the high byte goes first. */
310 writeReg(PICO9918_INST VR_GPU_HI, (uint8_t)(entry >> 8));
311 writeReg(PICO9918_INST VR_GPU_LO, (uint8_t)(entry & 0xff));
312
314 gpuBusy = 1;
315
316 thread_t gpu;
317 if (!threadStart(&gpu))
318 {
319 fprintf(stderr, "no thread to run the GPU on\n");
320 return 1;
321 }
322
323 /* The frame loop, paced to the display. Pacing is the price of the thread: without
324 it a program that waits a frame is answered in microseconds, and anything it
325 times against the raster runs at whatever speed this host happens to have. */
326 unsigned frames = 0;
327 uint64_t nextUs = nowUs();
328 while (gpuBusy && frames < GIVE_UP_FRAMES)
329 {
330 renderFrame(PICO9918_INST nextUs);
331 nextUs += FRAME_US;
332 ++frames;
333 }
334 if (gpuBusy)
335 {
336 fprintf(stderr, "%s did not finish in %u frames\n", bin, frames);
337 return 1;
338 }
339 threadJoin(gpu);
340 const uint32_t us = pico9918_gpu_time(0);
341
342 /* Wall clock on whatever is emulating it, not the TMS9900's own time - the same
343 accumulator the firmware reports the GPU's share of a frame from. */
344 printf("%s: %u bytes at 0x%04X, %u us of host time over %u frames\n", bin,
345 (unsigned)len, entry, (unsigned)us, frames);
346
347 /* What it drew. The GPU wrote VRAM and the registers; rendering is unchanged from
348 any other frame. Taken a line at a time rather than through the frame loop above
349 because what a PPM wants is palette INDICES, upstream of any host's pixel format. */
350 static uint8_t rgb[(size_t)COLS * ROWS * 3];
351 for (uint16_t y = 0; y < ROWS; ++y)
352 {
354
355 const uint8_t* line = pico9918_line_source(PICO9918_INST_ONLY);
356 for (uint16_t x = 0; x < COLS; ++x)
357 {
358 const uint32_t argb = pico9918_default_palette(line[x] & 0x0f);
359 uint8_t* px = rgb + ((size_t)y * COLS + x) * 3;
360 px[0] = (uint8_t)(((argb >> 8) & 0xf) * 17);
361 px[1] = (uint8_t)(((argb >> 4) & 0xf) * 17);
362 px[2] = (uint8_t)((argb & 0xf) * 17);
363 }
364 }
365
366 if (!writePpm(out, rgb))
367 {
368 fprintf(stderr, "cannot write %s\n", out);
369 return 1;
370 }
371 printf("wrote %s (%dx%d)\n", out, COLS, ROWS);
372
373#if !PICO9918_SINGLE_INSTANCE
374 pico9918_destroy(tms9918);
375#endif
376 return 0;
377}
void pico9918_gpu_reset_time(void)
Reset the internal GPU time accumulator to 0.
Definition gpu.c:421
void pico9918_gpu_init(pico9918_t *tms9918)
Initialize the TMS9900 GPU.
Definition gpu.c:388
void pico9918_gpu_step(pico9918_t *tms9918)
One pass of that loop: run a pending trigger to completion, then dispatch any flash and config-action...
Definition gpu.c:430
uint32_t pico9918_gpu_time(uint32_t totalTime)
Return the GPU's CPU time in microseconds.
Definition gpu.c:412
pico9918-core - GPU Interface
void pico9918_destroy(pico9918_t *tms9918)
destroy a TMS9918
Definition pico9918.c:360
const uint8_t * pico9918_line_source(pico9918_t *tms9918)
where the scanline just generated actually is.
Definition pico9918.c:3529
uint8_t pico9918_scan_line(pico9918_t *tms9918, uint16_t y)
generate a scanline
Definition pico9918.c:3256
void pico9918_reset(pico9918_t *tms9918)
reset the new TMS9918
Definition pico9918.c:318
uint16_t pico9918_default_palette(int index)
a default palette entry, 0xargb
Definition pico9918.c:3536
pico9918-core - core interface
pico9918_t * pico9918_new(void)
create a new TMS9918
#define PICO9918_INST_ARG
declare the instance ahead of other parameters
Definition pico9918.h:71
#define PICO9918_INST_ONLY
pass the instance as the only argument
Definition pico9918.h:74
pico9918_register_t
the eight TMS9918 registers, by number and by what each one holds
Definition pico9918.h:198
#define PICO9918_INST
pass the instance ahead of other arguments
Definition pico9918.h:73
void pico9918_frame_porch(pico9918_t *tms9918)
see the header.
bool pico9918_frame_scanline(pico9918_t *tms9918, uint16_t y, const pico9918_scanline_params_t *params, PICO9918_PIXEL_T *pixels)
see the header.
pico9918_frame_geometry_t pico9918_frame_end(pico9918_t *tms9918, float tempC, float frameRateHz, pico9918_frame_display_t *display)
see the header.
pico9918-core - frame module
pico9918-core - utility / helper functions
static void pico9918_set_address_write(pico9918_t *tms9918, uint16_t addr)
point the VRAM address register at addr for writing, ie.
static void pico9918_write_bytes(pico9918_t *tms9918, const uint8_t *bytes, size_t numBytes)
write a block of bytes to VRAM from the current address
static void pico9918_write_register_value(pico9918_t *tms9918, pico9918_register_t reg, uint8_t value)
write a VDP register as a host would, value byte first
the host's mutable vertical display parameters, as the end-of-frame geometry sees them
uint16_t vVirtualPixels
(in, and out when yScale > 1)
uint8_t vPixelScale
(in, and out when yScale > 1)
the host's per-call display parameters, as the scanline sees them