Two features that are fully designed but not built. They live here rather than in the OS references so those stay descriptions of what the ROMs actually do. Nothing in this document is implemented; it exists so the design work does not have to be re-derived.
$51 (comb sprites) and a new rectangular background blit
(comb backgrounds).$62/$63 (VTEXT/VTEXT_BG), which is why those two slots are
reserved in MAD65_GPU_OS.md's instruction set and must
not be reused.See MAD65_GPU_OS.md for the implemented instruction set and
MAD65_CPU_OS.md for the CPU-side builders and the replay
machinery both features would have to hook into.
Two related ideas, both built on the same trick: render each source row to every second destination row. The drawn thing becomes 2× taller on screen for the same number of source rows, and the skipped rows are simply never written — whatever was underneath shows through. The result is a hard-edged horizontal comb, i.e. a ghost/hologram/scanline look. Neither is implemented; this section records the design and the gotchas so the work does not have to be re-derived.
Expectation-setting on the look: the display is 400×300 logical row-doubled to 800×600, so a skipped logical row is a 2-physical-scanline gap next to a 2-physical-scanline band. This reads as bold venetian blinds, not as a smooth 50 % fade. It is the right tool when you want something to look unstable; it is the wrong tool when you want "faded".
A new opcode ($51 is free, arguments identical to OP_SPRITE) that blits an existing
sprite with a doubled vertical step. Same sprite data, same sprite-table slot, no new
asset format.
$20 is free).
Ghosting is a transient state (hit flash, phasing, spawn), not a property of the
artwork — a type-byte bit would force a duplicate sprite slot per art asset.SPR_BLITTER instantiations into
sixteen. Replace the row-advance adc #50 with adc SPR_VSTEP (a ZP variable
holding 50 or 100). That costs +1 cycle per row — under 1 % — and zero code
growth. adc #100 carries into SPR_VRAM+1 identically, so nothing else in the
blitter moves: load, shift chain, composite and clip emit are all untouched.spr_setup's vertical clip, which must reason in screen
rows while advancing source rows:pixel_Y ≥ 300);pixel_Y + 2·ROWS ≤ 0 — SPR_HEIGHT reaches 255, so the
doubled extent reaches 510 and the test is genuinely 16-bit;src_skip = ceil(top_skip / 2), and the
first visible screen row is 0 or 1 depending on whether top_skip is odd. Get
this wrong and the sprite pops by a pixel as it crosses the top edge;SPR_SRC += src_skip · STRIDE (source rows, not screen rows);ROWCNT = min(ROWS − src_skip, ceil((300 − fr) / 2)). Note the in-code comment
"ROWCNT (≤32)" is already stale (heights grew to 255); with a doubled step an
off-by-one here runs past the image end at $BA98 into the video registers at
$BFE0 — the same hazard class as the lc_vline bottom-clip bug.(screen OR bitmap) AND NOT(overlay). On drawn rows the overlay still punches the
background to black; on skipped rows the background survives untouched, and that
contrast is what reads as see-through. A no-overlay sprite in comb mode is just
additive white stripes — much weaker. Consequence: a comb sprite can never fully
occlude what is behind it.test_sprite64.py,
test_sprite_coords.py). Cover: skipped rows untouched, source→screen mapping,
top-clip parity in both parities, bottom clip at row 299 for both phases, and no
write at or past $BA98.The same stride trick applied to the background layer. The economics are completely different from sprites — see below.
OP_LOAD through PPRAM at 258
bytes/page, PPRAM is ~2 kB ≈ 7 pages/frame, and every background write is replayed
into the second buffer (the two-frame rule) — so net throughput is ≈ 3.5 pages/frame
and a full-screen background from cart takes ~17 frames, roughly 0.3 s. The
GPU-side write cost over those frames is ~10k cycles/frame out of ~238k: noise.OP_LOAD cannot express a comb. It is a linear 256-byte copy to a page-aligned
destination, and a page straddles 5.12 screen rows, so it is structurally
row-agnostic. A background comb needs a new opcode: a byte-aligned rectangular
blit taking dest_row, dest_col_byte, w_bytes, n_rows + data, walking the
destination with stride 100 instead of 50. That op is worth having on its own merits
— today a logo cannot be placed at an arbitrary background position without shipping
full-width, page-aligned rows.RP_TYPE_* record (see
MAD65_CPU_OS.md, and gpu_load_cart_bg in cpu_os.s for
the pattern). This is what makes B a medium job where A is a small one.OP_PIXEL_BG/OP_LINE_BG were retired: no
OR, no mask, no blend, ever. Comb writing is the only way to get partial-coverage
compositing there — not blending but spatial interleaving. Two consequences: a comb
bitmap over existing background art lets the old art show through the gaps; and two
bitmaps combed at rows R and R+1 interleave perfectly, giving two full background
images sharing one rectangle at 50 % each.A is self-contained and adds no new plumbing. B is the bigger unlock but touches the replay records; once B's rectangular background blit exists, its comb variant is nearly free. Naming note: call it comb / ghost, not "semi-transparent" — the latter oversells what is actually on screen.
Support for vertical games: the physical monitor is turned on its side and the game is authored for a 300×400 portrait screen. Nothing is implemented; this section records the design and the decisions so the work does not have to be re-derived.
The single most important finding: a 90° rotation maps the 400×300 framebuffer onto
itself, and every drawing routine in gpu_os.s already works in framebuffer
coordinates, not screen coordinates. SPR_BLITTER walks SPR_VRAM += 50 per row;
line_core is Bresenham-symmetric; op_load is a linear page copy. None of them know
or care which way the monitor is bolted.
So the whole feature is: pre-rotate the assets, and add text opcodes that use a rotated font. There is:
VIDEO_REG, not in hardwareV[0] and is orientation-
agnostic; PPRAM bandwidth is unchanged; 400×300 → 300×400 on a sideways 4:3 monitor
is 3:4 and fills the tube with square pixels.The developer works directly in framebuffer coordinates and thinks "x runs down the
screen". A transform layer in the CPU1 command builders was considered and rejected:
it buys little and it breaks on sprite anchors, because (X,Y) is the top-left corner
in framebuffer coordinates and after rotation that is a different corner on screen —
the builder would have to know each sprite's dimensions.
Note the wire format needs no change: OP_PIXEL already carries X and Y as 16-bit, and
the line family's half-res bytes (0–199 / 0–149) stay in range when swapped.
The monitor is turned 90° clockwise — its top edge goes to the viewer's right. Consequently:
| framebuffer axis | points (viewer) | carries |
|---|---|---|
fb-x + (0–399, 50 byte columns) |
down | text lines, 8 px each → 50 lines, exact fit |
fb-y + (0–299, 50-byte stride) |
left | characters within a line, 8 rows each |
Characters must advance along fb-y — that is what makes vertical text byte-aligned,
and it is not negotiable. Because fb-y + points left under CW, the character index is
the one that runs backwards: character c sits at a decreasing fb-y, so the
inner loop advances by -400 instead of +400. The line index is the identity
(screen line L → byte column L, top to bottom).
Why clockwise. The cost is symmetric — either direction requires reversing exactly
one of the two indices, which is one subtraction at setup and sbc vs adc in the
inner loop, the same cycle count. So it was decided on other grounds:
×8 + offset is computed
anyway, rather than adding a second reversed index elsewhere.History gives no convention to copy. 1980s vertical cabinets used ordinary 4:3
raster CRTs bolted in rotated 90°, with the board designed vertical from the start and
the artwork stored pre-rotated — architecturally the same thing as this design, not a
horizontal game flipped afterwards. But direction varied freely per product (MAME
encodes it per-driver as ROT90 vs ROT270, and both are common; even a single
manufacturer went both ways). There is no "wrong" answer to match.
This decision is cheap to reverse. The direction appears in exactly three places:
the rotate-90 flag in the asset script, the same flag in the font generator, and the
sign of the char-axis advance in VTEXT. Art is authored in screen space and rotated
by the script, so flipping direction later is a rebuild, not a redraw — provided the
direction lives in a single shared ROT_DIR constant and is not hardcoded twice.
| axis | maths | result |
|---|---|---|
lines (fb-x) |
400 / 8 = 50 exactly | lines L = 0–49, byte column L, no slack |
chars (fb-y) |
37 × 8 = 296, 300 − 296 = 4 | chars C = 0–36, 2 px top + 2 px bottom |
37 was chosen over 36: it is the maximum, the 2 px margins are symmetric (4 physical pixels, since a logical pixel is 2×2), and an odd count gives a true centre column (18) for centring strings.
The OFF = 2 margin costs nothing whatever its value, because along fb-y it is a
whole-row adjustment folded into the base address (+ OFF × 50), not a bit shift. If a
fatter border is ever wanted, 36 columns at OFF = 6 is equally free.
VTEXT / VTEXT_BG — proposed $62 / $63#Slots $62/$63 keep them in the $6x text family and leave $70/$71 clear for a
VTILE pair later.
$62 VTEXT L (0-49), C (0-36), scroll, ...string bytes..., $00 → VRAM-image
$63 VTEXT_BG L (0-49), C (0-36), scroll, ...string bytes..., $00 → VRAM-background
Same wire shape as TEXT/TEXT_BG, so the CPU1 command builders are a copy-paste.
Address maths — structurally identical to draw_text_core, with the axes swapped.
Today that routine does text_row_lo/hi[TEXT_Y] (= Y × 400) + TEXT_X + DRAW_BASE_HI
in 25 inline cycles. The vertical version needs a new vtext_col_lo/hi table (37
entries, 74 bytes) holding 400 × c + 50 × OFF, plus the line index L added directly,
plus DRAW_BASE_HI. Same shape, same cost. Per glyph: 8 bytes stored at 50-byte stride,
then the pointer advances by -400 (clockwise).
The vertical opcodes come out simpler than $60. Smooth scroll along the on-screen
horizontal axis is motion along fb-y, i.e. a whole-row offset — free — instead of
a sub-pixel bit shift. That deletes the expensive and awkward parts of draw_text_core:
ROL carry chain, no 150-byte $0200–$0295 scratch buffer, no
font_glyph_lo/hi pointer tables — Phases 1 and 2 simply do not existVTEXT draws every
character it is given.scroll could be widened past 0–7 for free, though keeping 0–7 preserves the wire
formatWhat it does need instead is a char-axis clip — stop when a cell would run past the
fb-y edge — which is cheaper than the guard scheme it replaces.
A second 768-byte table (font_data_v), generated at build time from
fonts.s by a script, in the manner of gen_build_date.py. Glyph
orientation is entirely the generator's problem and costs nothing at runtime — it is
not a design decision, only a flag.
ROM space is not a concern: CODE ends well short of SPRITE0 at $F400 (check
gpu_os.map for the current figure). Add to gpu.cfg:
FONT_V: load=ROM, type=ro, start=$F000; # 768 B at $F000-$F2FF, clear of SPRITE0 ($F400)
Everything else is asset tooling: a rotate-90 script for sprites, tiles and backgrounds, run at build time. Four consequences worth knowing before designing a vertical game:
OP_HDOT_LINE ($49) loses its fast path. Its 10–14× speedup is an $AA
memfill along the byte axis, which is structurally fb-x-only. In vertical mode it
draws an on-screen vertical dotted line; an on-screen horizontal rule (HUD
separators, borders) becomes 1 bit per row at 50-byte stride — slow. There is no
trick that recovers this: the byte packing has a preferred axis and rotation moves
it. This is the only genuine loss.SPR_WBYTES supports
1/2/4/8 bytes = 8/16/32/64 px of framebuffer width, and the 128 px cap likewise —
after rotation these quantise on-screen height. A vertical shooter wants tall,
narrow ships: 24×40 on screen is 40×24 in framebuffer terms, and 40 is not a legal
width, so it rounds up to 64 (wasting data) or becomes a meta-sprite. This is the
wrinkle most likely to bite in practice.SPR_SHIFTS cost applies to
fb-x = on-screen vertical, so vertical motion is the expensive axis and
horizontal dodging is free byte-steps — the mirror of a horizontal game. Not worse,
but it inverts the tuning intuition.madsim needs a rotate-the-window hotkey so the monitor does not have to be
physically turned during development. This is purely a display-side transpose on blit;
madsim is deliberately not told about vertical mode, because there is no mode for
it to be told about. The same applies to the Verilator sim
(sim/mad65_sim.cpp) if wanted there.