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 + delaysEvery 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:
PORT_A= the rate for one axis (a signed 8-bit DAC value);PORT_Broutes it into the X or Y integrator through the mux;T1counts down the duration;SRcarries brightness,PCRcarries/BLANK.
So a delta (dx, dy) has to be split between how fast and for how long:
distance ≈ rate × t1 / DRAW_SCALEramp_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
| Constant | Default | Why |
|---|---|---|
DRAW_SCALE | 160 | The divisor. Larger = shorter strokes. Calibrated per console as SCALE. |
MIN_T1 | 8 | The dwell floor. Without it, short strokes get ramps of 3 to 7 cycles and the geometry breaks. |
MIN_T1_START | 8 | The 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_CAP | 127 | The DAC is 8-bit signed, so a long stroke is bounded by duration, not speed. |
T1_TRANSPORT | 160 | The timer ceiling for blanked jumps, which may be faster than lit strokes. |
T1_EXTRA_Q8 | per console | The 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_countreports 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:
- Chain your strokes. A stroke starting where the last ended pays no jump.
- Fewer, longer strokes. Length is free; count is not.
- Do not re-set an intensity you have not changed.
- 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
SRhundreds 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.