All SDK docs

Drawing

The beam model: how a stroke becomes hardware, sub-unit geometry, re-zeroing, and what actually costs time.

The three layers

your game            vpy_draw_line(x0,y0,x1,y1, brightness)    ±127 logical units
  |                  or v_directDraw32(...)                    ×127, so ~±16000
  v
sdk_rp2350.c         uvm2_draw_intensity(z)
                     uvm2_draw_move_abs_q4(x, y)               1/16 of a device unit
                     uvm2_draw_delta_q4(dx, dy)
  v
uvm2_draw.c          splits, re-zeroes, applies calibration
  |                  calls the beam model for each ramp
  v
vectrex-draw (Rust)  ramp_params(dx, dy) -> (vx, vy, t1)
  v
the command list     PORT_A, PORT_B, T1CL, T1CH, T1LL, SR, PCR writes + delays

Every stroke is three calls: intensity, absolute jump, delta. There is no intensity cache, no stroke reordering, no collinear merging, no simplification and no clipping. All of those were tried at some point and each was eventually measured as a shimmer, a displaced stroke or missing geometry. What the game asks for is what gets drawn.


How a stroke becomes hardware

The Vectrex draws by running two integrators for a fixed time:

  1. PORT_A = the rate for one axis (a signed 8-bit DAC value);
  2. PORT_B routes it into the X or Y integrator through the mux;
  3. T1 counts down the duration;
  4. SR carries brightness, PCR carries /BLANK.

So a delta (dx, dy) has to be split between how fast and for how long:

distance ≈ rate × t1 / DRAW_SCALE

ramp_params(dx, dy) in sdk/vectrex-draw/src/ramp.rs does that split. It is the single implementation of the beam model, and the same crate is linked by the Debug Cart's firmware, so a discovery made on one board does not have to be rediscovered on the other.

The constants

ConstantDefaultWhy
DRAW_SCALE160The divisor. Larger = shorter strokes. Calibrated per console as SCALE.
MIN_T18The dwell floor. Without it, short strokes get ramps of 3 to 7 cycles and the geometry breaks.
MIN_T1_START8The floor for ramps that start from rest (a jump, and the first stroke after one). A separate knob so it can be raised without charging every interior stroke; the SDK sets it equal to MIN_T1. A game raises it with its own measurement.
DAC_CAP127The DAC is 8-bit signed, so a long stroke is bounded by duration, not speed.
T1_TRANSPORT160The timer ceiling for blanked jumps, which may be faster than lit strokes.
T1_EXTRA_Q8per consoleThe ramp's real length in 1/256 of a count. The 6522 counts t1 + 1.5 on a one-shot. Calibrated as TAIL.

Sub-unit geometry

Everything travels in 1/16 of a device unit end to end: v_directDraw32 converts once, and the SDK splits, re-zeroes and calibrates in that unit. A 2.5-unit stroke stays 2.5 units.

The debt

Splitting a real distance into (rate, t1) loses a fraction. ramp_params_chain carries that remainder forward as a debt and pays it into the next stroke, so errors do not accumulate along a chain. On coarse input it is what keeps a long chain straight.


Re-zeroing

The integrators drift. Two safety nets in uvm2_draw.c:

  • uvm2_zero_jump (24 device units): a blanked jump longer than this forces a re-zero before the next stroke. This is the one that normally fires.
  • uvm2_zero_every (off by default): re-zero after N ramps regardless. A scene of short chained strokes can otherwise run a long way without one; uvm2_list_count reports the worst run with no re-centring, which is the number to look at if the picture is sliding.

A re-zero costs commands, so it is a trade, not a free win.

The first jump of every frame always re-zeroes if the clamp is still on. Otherwise, a frame whose first stroke starts near the centre would draw its first chain with both integrators held at the origin: lines radiating out of (0,0), an "asterisk". uvm2_stats.ramps_clamped counts any ramp started with the clamp on and must stay 0.

uvm2_zero_offset, the value primed into the zero reference, belongs to the console, not the game. A wrong value adds the same velocity to every ramp and tilts rows of text into diagonals. See Calibrating a console.


What actually costs time

From real frames:

  • a chained stroke is 6 commands: 32 bus cycles at minimum, and its length only adds to the ramp's wait;
  • a stroke that needs a blanked jump first is 14–21 commands, and 2.4–3× the cycles;
  • a brightness change is three more writes.

Averaged over a real frame, with its jumps and brightness changes, that comes to about 13 commands per segment. The measured curves are in How the engine works.

So, in order of impact:

  1. Chain your strokes. A stroke starting where the last ended pays no jump.
  2. Fewer, longer strokes. Length is free; count is not.
  3. Do not re-set an intensity you have not changed.
  4. Only then worry about geometry.

Reordering strokes to reduce jumps sounds attractive and mostly does not work: naive nearest-neighbour ordering made one scene worse (709 to 882 jumps), because it breaks up segments that were the continuation of a chain. The number of jumps is set by how many disjoint polylines the game draws. Reordering can shorten jumps, not remove them.


Things that are not what they look like

  • Shift register writes are not brightness changes. A game that never changes intensity still writes SR hundreds of times a frame: that is the beam being blanked and unblanked around each jump.
  • A photograph of a CRT is not brightness data. Geometry from a photo is evidence; brightness is phosphor.
  • One game is not evidence. Render constants tuned on a single game are overfitted. Check changes on short, dense vectors: long strokes cannot fail, so they prove nothing.