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_directDraw32with 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)andUVM2_CMD_PACK,_REG_DATA,_DELAY; - the VIA register and bit names;
- the
#errorthat 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:
| Constant | Value | Meaning |
|---|---|---|
UVM2_SMP_VOICES | 10 | Sample voices |
UVM2_SMP_REG | 10 | The register used: channel C's volume |
UVM2_SMP_HZ | 16000 | The 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:
| File | Holds | Written by |
|---|---|---|
config/uvm2.cfg | The console's beam calibration, shared by every game | The calibration screen, when you save |
config/NAME.cfg | The game's own settings, layered on top | Only 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:
| Flag | Value | What the wizard offers |
|---|---|---|
UVM2_SETTING_HZ | 1 | Refresh 50 / 60 / 0 (free) → hz |
UVM2_SETTING_MENU | 2 | Menu on power-up; with it off, button 4 still forces the menu → start_menu |
UVM2_SETTING_ROTATE | 4 | Drawing rotated 90° for a horizontal arcade game → rotate |
UVM2_SETTING_AUDIO | 8 | Sound 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 callsuvm2_config_wizard()shows both. - The SDK stores, the game acts. The SDK stores the
audiosetting, 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 |
|---|---|---|---|
| 0 | RESET0REF | 12 | PSG_READ |
| 1 | WAIT_RECAL | 13 | READ_AXES |
| 2 | SET_INTENSITY | 14 | READ_BTN_RAW |
| 3 | MOVE | 15 | MOVE_ABS |
| 4 | DRAW_DELTA | 16 | PRINT_TEXT |
| 5 | PSG_WRITE | 21 | PLAY_MUSIC |
| 6 | PSG_SILENCE | 22 | STOP_MUSIC |
| 7 | READ_BUTTONS | 23 | PLAY_SFX |
| 8 | BUS_WRITE | 26 | RASTER_TEXT |
| 10 | SAMPLE_POS | 27 | DRAW_GAPPED |
| 11 | BUS_READ | 28 | MOVE_Q4 |
| 29 | DRAW_DELTA_Q4 | ||
| 30 | CALIBRATE |
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_RECALis 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_READandBUS_WRITEfail because the other core owns the pins. - A
.um2mostly skipssvc. The SDK is inside the image, sosdk_rp2350.ccallsuvm2_draw_*directly. Only sound, music, samples and raster text still go throughsvc.