All SDK docs

Low-level API

Level 2, the backend-neutral PiTrex/host contract; Level 3, the cartridge runtime (drawing, bus, input, sound, storage, calibration); and the syscall ABI.

Below libvpy there are two more layers. Each layer is written in terms of the one beneath it, and a game can mix them freely.

  • Level 2: the PiTrex/host contract. Write to this and your game stays portable.
  • Level 3: the cartridge SDK. The runtime inside the cartridge image. Use it when you need something the contract doesn't offer, and accept that your game is then tied to the RP2350 cartridges.

Level 2 — the PiTrex/host contract

sdk/pitrex-sim/include/vectrex/vectrexInterface.h is about 20 declarations. Per byte, it is the most important file in the SDK. It is backend-neutral: the same source builds for:

  • the Debug Cart;
  • the UVMC2;
  • a desktop harness and the WASM simulator (pitrex-sim/sdk_host.c);
  • PiTrex, coming soon. The contract keeps PiTrex's own names.
#include <vectrex/vectrexInterface.h>
 
void     vectrexinit(int mode);
void     v_init(void);
void     v_setRefresh(int hz);
void     v_WaitRecal(void);       /* seal the frame, hand it over, open the next */
 
void     v_directDraw32(int32_t x0,int32_t y0,int32_t x1,int32_t y1,uint8_t brightness);
void     v_setColour(uint32_t rgb);   /* 0x00RRGGBB; 0 = the display's default */
 
uint8_t  v_readButtons(void);         /* -> currentButtonState */
void     v_readJoystick1Analog(void); /* -> currentJoy1X / currentJoy1Y */
uint32_t v_millis(void);
 
void     v_writePSG(uint8_t reg,uint8_t val);
void     v_setSoundAY(uint8_t reg,uint8_t val);   /* the same, legacy name */
void     v_playSample(int idx,int voice,int loop);
void     v_stopSample(int voice);
int      v_samplePlaying(int voice);
 
extern uint8_t currentButtonState;    /* bit n-1 = button n */
extern int8_t  currentJoy1X, currentJoy1Y;

Coordinates for v_directDraw32 are VPy units × 127, so about ±16 000.

Calling v_directDraw32 with brightness 0 returns immediately. It is not a blanked move. To reposition without drawing, simply draw the next stroke from where you want it to start, and the SDK inserts the jump.

Cartridge extensions

These are defined in sdk/rp2350-sdk/sdk_rp2350.c, the one place where the game's API becomes SDK calls.

void v_beamNewStroke(void);     /* force a re-zero: a fresh reference per figure */
void v_setIntensity(int b);
void v_directGapped(int32_t x0,int32_t y0,int32_t x1,int32_t y1,uint8_t b,
                    const unsigned char *gaps,int n);
int  v_directSweepSR(int32_t x0,int32_t y0,int32_t x1,int32_t y1,uint8_t b,
                     const unsigned char *pattern,int n,int step);
void v_rasterText(int x,int y,const unsigned char *s,int n);
void v_textBegin(void); void v_textEnd(void);
const void *v_sampleData(int idx);   /* WEAK: the game defines it */

v_beamNewStroke() matters more than its size suggests. A figure drawn as loose strokes builds up position error. The symptom is degradation that grows rightwards along a line of text: the first letters are clean and the last ones ragged and clipped. One re-zero per glyph costs about 211 bus cycles and fixes it. On targets with no integrators it does nothing, so it is safe to call everywhere.

v_directGapped draws one ramp with holes in it. gaps is a list of (start, end) pairs, as fractions 0..255 of the stroke. The SDK switches the beam off and on along the way instead of programming a separate ramp for each lit run. That is what you need to sweep a row of pixels. It is a tool, not a way to run raster graphics.

v_rasterText uses the SDK's own vector font at a fixed scale. Text that the game scales itself comes out misaligned with it.


Level 3 — the cartridge SDK

These headers live in sdk/uvm2-sdk/. They are the runtime that is compiled into every cartridge image.

Drawing — uvm2_draw.h

void uvm2_draw_init(void);
void uvm2_frame_begin(void);
void uvm2_frame_end(void);
 
void uvm2_draw_intensity(int brightness);      /* 0..127 */
int  uvm2_draw_intensity_current(void);
void uvm2_draw_penup(void);
 
void uvm2_draw_move(int dx,int dy);            /* relative jump, device units */
void uvm2_draw_delta(int dx,int dy);           /* relative lit stroke */
void uvm2_draw_move_abs(int x,int y);          /* absolute jump */
void uvm2_draw_move_q4(int dx_q4,int dy_q4);   /* the same, in 1/16 */
void uvm2_draw_move_abs_q4(int x_q4,int y_q4);
void uvm2_draw_delta_q4(int dx_q4,int dy_q4);
 
void uvm2_draw_reset(void);        /* re-zero the beam now */
void uvm2_draw_invalidate(void);   /* forget the cached VIA state */
void uvm2_draw_prime_holds(void);
void uvm2_draw_rotate(int enable); /* 90 degrees, for horizontal arcade games */
int  uvm2_draw_rotated(void);
 
void uvm2_draw_delta_patterned(int dx,int dy,const unsigned char *gaps,int n);
int  uvm2_draw_sweep_sr(int dx,int dy,const unsigned char *pattern,int n,int step);
 
void     uvm2_set_refresh(unsigned hz);   /* 50, 60, or 0 = free-running */
unsigned uvm2_current_refresh(void);
int      uvm2_refresh_fits(void);         /* did the last frame fit the cap? */
 
uint32_t uvm2_list_cycles(void);          /* bus cycles in the list so far */
uint32_t uvm2_list_commands(void);
uint32_t uvm2_list_room(void);            /* commands the game may still add this frame */
uint32_t uvm2_frame_count(void);
uint32_t uvm2_frame_bus_cycles(void);
void     uvm2_emit_raw(uint32_t reg,uint32_t data,uint32_t gap);   /* one raw command */
 
/* the diagnostics HUD: buttons 1+4 held 2 s, or poke uvm2_hud */
extern volatile uint8_t uvm2_hud;
extern uvm2_hud_stats_t uvm2_hud_stats;
extern char uvm2_hud_text[2][32];
 
/* the last closed frame's command list, to the SD card (core 0 only) */
int  uvm2_dump_list(const char *path);
int  uvm2_dump_list_on_buttons(const char *path,uint8_t mask);
extern uvm2_dump_diag_t uvm2_dump_diag;

A game normally never calls uvm2_frame_begin and uvm2_frame_end itself: v_WaitRecal() does. The model behind these calls (the command list, ramps, re-zeroing, what costs time) is explained in The command list and Drawing. The HUD and the list dump are covered under Debugging.

Runtime knobs. These are all volatile globals, so they can be changed over SWD while a game runs: uvm2_zero_jump, uvm2_zero_every, uvm2_zero_offset, uvm2_pacer_cycles, uvm2_filler_clamp, uvm2_hold_y_min/max, uvm2_hold_z_min/max, uvm2_sweep_t1_max.

The bus — uvm2_bus.h

void     uvm2_cpu_init(void);   /* .bss, vector table, firmware IRQs off — the first C to run */
void     uvm2_bus_pads(void);   /* pads + function select; CLK becomes readable */
void     uvm2_bus_halt(void);   /* park, enable drivers, assert /HALT for good */
void     uvm2_bus_init(void);   /* all three */
 
uint32_t uvm2_exec(const uint8_t *cmds,uint32_t count);  /* replay; returns bus cycles */
void     uvm2_bus_delay(uint32_t cycles);
void     uvm2_via_write(uint32_t reg,uint32_t data);
uint8_t  uvm2_via_read(uint32_t reg);
void     uvm2_measure_e(void);
extern uint32_t uvm2_cycles_per_e_q8;   /* CPU cycles per E period, Q8 */
uint32_t uvm2_now_us(void);             /* microseconds; 0 on a host harness */
 
extern uvm2_stats_t uvm2_stats;

uvm2_bus.h also contains:

  • the command encoding: UVM2_CMD(reg,data,delay) and UVM2_CMD_PACK, _REG_DATA, _DELAY;
  • the VIA register and bit names;
  • the #error that refuses to build without dual core and the PIO stream.

uvm2_stats_t is described field by field under Debugging.

Input — uvm2_input.h

uint8_t  uvm2_read_buttons(void);      /* RAW: active-low, J1 in bits 0-3, J2 in 4-7 */
uint32_t uvm2_read_axes(void);         /* four packed int8: j1x, j1y, j2x, j2y */
void     uvm2_input_set_analog(int enable);   /* 0 = -127/0/+127 (default), 1 = centred analog */
void     uvm2_psg_write(uint32_t reg,uint32_t value);
uint8_t  uvm2_psg_read(uint32_t reg);
 
void uvm2_mem_write(uint32_t addr,uint32_t data);   /* any bus address; core 1, between frames */
extern volatile uint32_t uvm2_reset_seen;           /* presses of the reset button noticed */
extern volatile uint32_t uvm2_reset_held_us;        /* how long the current one has lasted */
extern volatile uint32_t uvm2_restart_refused;      /* restarts refused: .data over 4 KB */

The console's reset button works like this: held under 1 s does nothing; held 1–3 s and released restarts the game; held 3 s returns to the cartridge's menu. See Input.

Sound — uvm2_audio.h, uvm2_smp.h, uvm2_jack.h

void uvm2_play_music(const uint8_t *vmus);
void uvm2_stop_music(void);
void uvm2_play_sfx(const uint8_t *vsfx);
void uvm2_audio_tick(void);            /* one sequencer step; core 1 calls it */
 
void        uvm2_smp_play(const void *vsmp,unsigned voice,int loop);
void        uvm2_smp_stop(unsigned voice);          /* >= UVM2_SMP_VOICES = all */
int         uvm2_smp_playing(unsigned voice);
unsigned    uvm2_smp_pos(unsigned voice,unsigned fps);
int         uvm2_smp_bundle_load(const char *path); /* read a .vsm off the SD */
int         uvm2_smp_bundle_set(const void *base,uint32_t bytes);
const void *uvm2_smp_bundle_entry(unsigned idx);
uint32_t    uvm2_smp_bundle_count(void);

Digitised samples play through the sound chip's volume register, and are written in the gaps of the draw list:

ConstantValueMeaning
UVM2_SMP_VOICES10Sample voices
UVM2_SMP_REG10The register used: channel C's volume
UVM2_SMP_HZ16000The rate the emitter aims at, not the file's rate. Costs about 6% more bus cycles.

UVMC2 only: the 16-bit jack. The UVMC2 has a PT8211 stereo DAC on a jack (GPIO43–45, driven by PIO2 and one DMA channel). It is independent of the Vectrex's sound chip and costs the drawing nothing:

#define UVM2_JACK_RATE 32000
int  uvm2_jack_init(void);                      /* 1 = running, 0 = no jack on this board */
int  uvm2_jack_space(void);                     /* samples to write now to stay ahead */
void uvm2_jack_write(const int16_t *s,int n);   /* mono: the same sample in both halves */
void uvm2_jack_write_lr(const int16_t *l,const int16_t *r,int n);   /* stereo, same ring */

It is a driver, not a sound engine: it has no voices and no mixer. On the Debug Cart, uvm2_jack_init() returns 0. See Sound for how to mix voices into it.

Text, LED and clock — uvm2_text.h, uvm2_led.h

void uvm2_print_text(int x,int y,const char *str,int scale,int intensity);
void uvm2_print_text_chained(int x,int y,const char *str,int scale,int intensity); /* no per-glyph re-zero */
 
void uvm2_led_init(void);
void uvm2_led_rgb(uint8_t r,uint8_t g,uint8_t b);
void uvm2_led_status(uvm2_status_t code);
/*   OFF, BOOT (blue), NO_CLOCK (red), HALTING (amber), RUNNING (green), OVERRUN (orange) */
uint32_t uvm2_clock_calibrate(void);   /* cycles per us, or 0 = no CLK edge arrived */
uint32_t uvm2_cycles_per_us(void);

If uvm2_clock_calibrate() returns 0, that is the diagnosis: the console is off, or the cartridge is not seated.

Storage — uvm2_sd.h, uvm2_psram.h

int      uvm2_sd_init(void);
uint32_t uvm2_sd_read(const char *path,unsigned char *dst,uint32_t max);
uint32_t uvm2_sd_read_from(const char *path,unsigned char *dst,uint32_t max,uint32_t from);
int      uvm2_sd_create(const char *path,const unsigned char *data,uint32_t n);
int      uvm2_sd_overwrite(const char *path,const unsigned char *data,uint32_t n);
int      uvm2_sd_write(const char *path,const unsigned char *data,uint32_t n);    /* any size */
int      uvm2_sd_open(const char *path,uvm2_sd_file *f);                         /* stream a large file */
uint32_t uvm2_sd_next(uvm2_sd_file *f,unsigned char *dst,uint32_t max);          /* 0 = end */
void     uvm2_sd_close(uvm2_sd_file *f);                                         /* optional */
extern int uvm2_sd_error;   /* OK / NO_CARD / NO_INIT / NO_FAT / MISSING / TOO_BIG / IO_ERROR */
extern struct uvm2_sd_diag uvm2_sd_diag;   /* what the mount understood about the disk */
 
int  uvm2_psram_init(void);        /* reset, check ID, arm the XIP window -> 1 if present */
void uvm2_psram_enable_xip(void);
int  uvm2_psram_probe(void);       /* diagnostic only; may leave the chip in QPI */
#define UVM2_PSRAM_NO_CACHE 0x15000000u   /* verify through THIS, not 0x11000000 */

The card can be FAT12/16/32 or exFAT; FatFs does the work underneath. Paths are relative to the root, at any depth, with long names allowed, and are matched case-insensitively.

uvm2_sd_read treats "does not fit" as a failure; uvm2_sd_read_from does not. For large files, use uvm2_sd_open and uvm2_sd_next. Calling uvm2_sd_read_from in a loop seeks from the start of the file on every call. See SD card & storage.

Calibration and game settings — uvm2_config.h

Beam calibration belongs to each console, and it is read from and written to the SD card:

struct uvm2_config {
    int32_t scale;        /* DRAW_SCALE: larger = shorter strokes */
    int32_t t1_tail_q8;   /* the ramp's real length, in 1/256 of a count */
    int32_t zero;         /* the value primed into the zero reference */
    int32_t bright;       /* default Z, 0..127 */
    int32_t hold_y_min, hold_y_max;   /* Y sample-and-hold, in E cycles */
    int32_t neg_rate_x, neg_rate_y;   /* negative-rate DAC trim, 1/256 */
    int32_t drift_x, drift_y;         /* per-jump drift, 1/256 */
    int32_t hz;           /* 50, 60, or 0 = free */
    int32_t start_menu;   /* 1 = menu on power-up */
    int32_t rotate;       /* 1 = drawing rotated 90 degrees */
    int32_t audio;        /* 0 = the jack, 1 = the console's chip */
    int32_t aspect_q8;    /* the screen's shape: x against y, 256 = 1:1 */
    int32_t win_x, win_y; /* what the tube shows, half extents in deflection units */
};
int  uvm2_config_load(void);
int  uvm2_config_save(void);
void uvm2_config_current(struct uvm2_config *c);
void uvm2_config_apply(const struct uvm2_config *c);
int  uvm2_config_wizard(void);
void uvm2_config_boot_combo(void);          /* buttons 2+3 held at launch -> wizard */
extern volatile int32_t uvm2_boot_combo;    /* -1 unchecked, 0 not held, 1 ran, 2 could not check */
void uvm2_config_game(const char *name,unsigned settings);
extern volatile int uvm2_have_calibration;

These values belong to the machine the cartridge is plugged into, not to the game. What each field fixes is explained in Calibrating a console.

Game settings — uvm2_config_game

The settings are split across two files, by whose settings they are:

FileHoldsWritten by
config/uvm2.cfgThe console's beam calibration, shared by every gameThe calibration screen, when you save
config/NAME.cfgThe game's own settings, layered on topOnly the settings the game declared

name is the game's 8.3 base name with no extension, for example "TACSCAN". settings is an OR of these flags:

FlagValueWhat the wizard offers
UVM2_SETTING_HZ1Refresh 50 / 60 / 0 (free) → hz
UVM2_SETTING_MENU2Menu on power-up; with it off, button 4 still forces the menu → start_menu
UVM2_SETTING_ROTATE4Drawing rotated 90° for a horizontal arcade game → rotate
UVM2_SETTING_AUDIO8Sound from the jack or from the console's chip → audio
uvm2_config_game("MYGAME", UVM2_SETTING_HZ | UVM2_SETTING_MENU);
uvm2_config_load();   /* the console's file is already loaded; this layers the game's on top */

Some rules:

  • Declare only what means something in your game. A vertical game should not offer ROTATE.
  • Not calling it gives you one file and no game settings in the wizard.
  • The buttons 2+3 wizard opens before main, so it only shows the console's fields. A game menu that calls uvm2_config_wizard() shows both.
  • The SDK stores, the game acts. The SDK stores the audio setting, but it doesn't route sound itself: the game reads the setting and acts on it.

The PIO stream — uvm2_bus_stream.h

You should not need this, because uvm2_draw.c drives it. It is listed because reading it is how the bus timing makes sense. See Dual core, PIO & DMA.

void     vbus_install(uint32_t out_base,uint32_t out_count,uint32_t out_dirs,uint32_t park);
uint32_t vbus_word(uint32_t bus_word);   /* a write, with its sentinel */
uint32_t vbus_repeat(uint32_t n);        /* park for n periods, in ONE word */
uint32_t vbus_silence(void);             /* one period of silence */
void     vbus_push(uint32_t word);
void     vbus_flush(void); void vbus_drain(void);
void     vbus_list_begin/end/wait/fire(void);   /* a frame as a single DMA */
uint32_t vbus_stat(uint32_t idx);
void     uvm2_stream_start(void);

The syscall layer

Between Levels 2 and 3 there is a Thumb svc ABI (uvm2_svc.c). It lets the same game binary run either against an SDK compiled into its own image (a UVMC2 .um2) or against one that lives in another cartridge's BIOS (the Debug Cart). Arguments go in r0–r3, following AAPCS.

#Name#Name
0RESET0REF12PSG_READ
1WAIT_RECAL13READ_AXES
2SET_INTENSITY14READ_BTN_RAW
3MOVE15MOVE_ABS
4DRAW_DELTA16PRINT_TEXT
5PSG_WRITE21PLAY_MUSIC
6PSG_SILENCE22STOP_MUSIC
7READ_BUTTONS23PLAY_SFX
8BUS_WRITE26RASTER_TEXT
10SAMPLE_POS27DRAW_GAPPED
11BUS_READ28MOVE_Q4
29DRAW_DELTA_Q4
30CALIBRATE

Numbers 9, 17–20, 24 and 25 are unassigned here; the Debug Cart's BIOS uses some of them for its own calls.

CALIBRATE (30) runs the calibration screen and returns 1 if it saved. It is VPy's CALIBRATE().

Don't confuse 23 and 26. Raster text once went out as 23, which is PLAY_SFX. The SFX player took the text pointer for a music track and hung the core.

Some behaviour to know:

  • Drawing syscalls only record. WAIT_RECAL is what makes a frame happen.
  • Input is cached. It is sampled once per frame inside WAIT_RECAL, so reading it costs nothing and never fights the beam.
  • Raw bus access is refused under dual core. BUS_READ and BUS_WRITE fail because the other core owns the pins.
  • A .um2 mostly skips svc. The SDK is inside the image, so sdk_rp2350.c calls uvm2_draw_* directly. Only sound, music, samples and raster text still go through svc.