Files
akbasic/examples/megademo/vaporwave.py

368 lines
13 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
"""Generate the megademo's vaporwave picture, and its six frames of video.
The picture is composed directly in akbasic's 16-colour palette space at
160x120 -- one cell is a 5x5 pixel block on the 800x600 window -- then
run-length encoded into string literals the demo decodes with INSTR and
draws with three WIDTH-2 lines per run.
The video is delta frames. Six phases of a perfect loop -- the floor
grid advancing on a geometric progression parameterised so phase 6 lands
exactly on phase 0, and the sun's slice pattern crawling on a period-six
cycle -- and each frame encodes only the rows that differ from the one
before it: an 'R' record carrying the row in two base-36 digits, then
ordinary runs, with unreachable colour 'Q' as a skip. The rays are not
in the encoding at all; the demo overdraws them live, which is what
turns a thirty-run grid row back into a five-run one.
Why strings and not DATA: the interpreter's DATA pool is 512 items and
the stroke font already holds ~350 of them. A string literal carries one
RLE run in two characters. TODO.md section 4 records the gap.
Why the dithering is by row and not by pixel: per-pixel ordered dither
shatters every gradient into one-cell runs, which is death for RLE.
Row-phase dither keeps the runs long, and horizontal banding is what a
CRT did to a gradient anyway.
Usage:
python3 vaporwave.py --preview OUT.png write a x5 preview of
phase 0, rays included
python3 vaporwave.py --splice MEGADEMO.BAS rewrite the generated
block between the
PICTURE-BEGIN/END markers
"""
import argparse
import struct
import sys
import zlib
W, H = 160, 120
PHASES = 6
# src/graphics_tables.c, Pepto's PAL measurement. Index 0 unused.
PALETTE = [
(0x00, 0x00, 0x00), (0x00, 0x00, 0x00), (0xff, 0xff, 0xff),
(0x88, 0x39, 0x32), (0x67, 0xb6, 0xbd), (0x8b, 0x3f, 0x96),
(0x55, 0xa0, 0x49), (0x40, 0x31, 0x8d), (0xbf, 0xce, 0x72),
(0x8b, 0x54, 0x29), (0x57, 0x42, 0x00), (0xb8, 0x69, 0x62),
(0x50, 0x50, 0x50), (0x78, 0x78, 0x78), (0x94, 0xe0, 0x89),
(0x78, 0x69, 0xc4), (0x9f, 0x9f, 0x9f),
]
# Run encoding alphabets. Position decides meaning, so overlap is fine.
# 'Q' is a skip (INSTR misses the colour table and the decoder's guard
# draws nothing) and 'R' opens a row record.
COLORCH = "ABCDEFGHIJKLMNOP"
SKIP = "Q"
ROWREC = "R"
LENCH = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
MAXRUN = len(LENCH)
Rework the megademo to fit the 80-column source line limit AKBASIC_MAX_LINE_LENGTH's cut from 256 to 80 left seventeen lines of examples/megademo unloadable: the sixteen IM$() picture strings (up to 252 characters) and TUNEA/TUNEB's four-bar PLAY strings (174 and 175). The real ceiling is 78 characters, not 80 -- stdio_readline() refuses a read that fills the 80-byte buffer without a newline, so content plus its terminator must fit in 79. The picture: vaporwave.py's PAYLOAD drops from 240 to 64, so every emitted IM$(NN) = "..." line fits under the ceiling. chop() no longer slices blind; it walks the stream a record at a time -- two characters for a run, three for an R row record -- and never cuts inside one, because the decoder reads a record's tail with MID on the string it is walking and a record straddling two IM$ entries decodes as garbage. The old blind slice at 240 only happened to be safe. verify() now simulates the CHOPPED strings with the cursor threaded across the boundaries exactly the way DRAWSTREAM executes them, so a bad cut is an assertion failure instead of a corrupted screen, and emit_block() asserts every emitted line fits. The picture is 56 strings where it was 16; the decoder needed no changes at all, since it already carries X#/Y# from one IM$ entry to the next. The music: TUNEA and TUNEB each become four PLAY statements, one bar apiece. play.c keeps voice, envelope, level and duration state on the runtime across statements and every PLAY appends to the same queue, so four bars queue exactly as one long string did. Each bar restates the V1T3U9S prefix so a bar dropped by QFULL cannot leave the next batch playing on the drum kit's envelope. Everything still clears the shrunken pools with room to spare: 1625 source lines of 2048, ~704 array slots of 2048, identifiers within the 24-character symtab key. Verified end to end against this branch's build: the demo loads, the offscreen host renders every scene, and the scene-5 still is pixel-identical to vaporwave.py's own preview. The test suite fails the same seventeen cases with and without this commit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ACffnV6F7sxQuG3Y8a1L3s
2026-08-03 22:57:57 -04:00
# The interpreter reads source through an 80-byte line buffer and refuses
# any line that fills it (AKBASIC_MAX_LINE_LENGTH, sink_stdio.c), so a
# stored line is at most 78 characters plus its newline. 'IM$(NN) = "' and
# the closing quote spend 12 of those; 64 keeps the emitted lines under
# the ceiling with margin to spare while the index stays two digits.
PAYLOAD = 64
MAXLINE = 78
SUN_CX, SUN_CY, SUN_R = 80, 50, 30
HORIZON = 74
GRID_R = 1.55
RAY_SPREAD = 26
def band_mix(y, x, frac, a, b):
threshold = (((y * 5) + ((x // 8) * 3)) % 8) / 8.0
return b if frac > threshold else a
def sky_color(y, x):
bands = [(7, 5), (5, 11), (11, 9)]
seg = HORIZON / len(bands)
i = min(int(y / seg), len(bands) - 1)
frac = (y - (i * seg)) / seg
a, b = bands[i]
return band_mix(y, x, frac, a, b)
def sun_color(y):
frac = (y - (SUN_CY - SUN_R)) / (2.0 * SUN_R)
if frac < 0.4:
return 8
if frac < 0.7:
return 9
return 3
def sun_sliced(y, phase):
"""Period-six cuts below the sun's midline, crawling with the phase."""
if y < SUN_CY:
return False
return ((y + phase) % 6) < (1 + min(1, (y - SUN_CY) // 14))
def grid_rows(t):
"""Horizontal grid lines at loop parameter t in [0,1): row k sits at
HORIZON+1 + B*(r^(k+t)-1), so t=1 reproduces t=0 shifted one line."""
rows, k = [], 0
while True:
y = HORIZON + 1 + 2.2 * (GRID_R ** (k + t) - 1.0)
if y >= H:
return rows
rows.append(int(y))
k += 1
def compose(phase, rays=False):
img = [[1] * W for _ in range(H)]
for y in range(HORIZON):
for x in range(W):
img[y][x] = sky_color(y, x)
for i, (sx, sy) in enumerate([(9, 5), (31, 11), (52, 3), (74, 8),
(99, 14), (126, 6), (147, 12), (18, 21),
(139, 24), (61, 17)]):
img[sy][sx] = 2 if i % 3 else 16
for y in range(SUN_CY - SUN_R, SUN_CY + SUN_R + 1):
if y < 0 or y >= HORIZON or sun_sliced(y, phase):
continue
dy = y - SUN_CY
half = int((SUN_R * SUN_R - dy * dy) ** 0.5)
for x in range(SUN_CX - half, SUN_CX + half + 1):
img[y][x] = sun_color(y)
for r in grid_rows(phase / float(PHASES)):
for x in range(W):
img[r][x] = 5
if rays:
for i in range(-9, 10):
x0, y0 = SUN_CX, HORIZON
x1, y1 = SUN_CX + (i * RAY_SPREAD), H + 20
steps = max(abs(x1 - x0), abs(y1 - y0))
for s in range(steps + 1):
x = x0 + ((x1 - x0) * s) // steps
y = y0 + ((y1 - y0) * s) // steps
if 0 <= x < W and HORIZON < y < H:
img[y][x] = 4
return img
def encode_run(color, n):
return COLORCH[color - 1] + LENCH[n - 1]
def encode_skip(n):
out = ""
while n > 0:
step = min(n, MAXRUN)
out += SKIP + LENCH[step - 1]
n -= step
return out
def encode_base(img):
"""The full raster, top to bottom; rows advance automatically.
Black spans become skips -- GRAPHIC's clear already painted them --
which is what keeps the floor nearly free. A skip draws nothing and
a black run draws black: the base wants the former, a delta erasing
a moved grid line needs the latter."""
out = ""
for y in range(H):
x = 0
while x < W:
c = img[y][x]
n = 1
while x + n < W and img[y][x + n] == c and n < MAXRUN:
n += 1
out += encode_skip(n) if c == 1 else encode_run(c, n)
x += n
return out
def encode_delta(prev, cur):
"""Row records for every row that differs, runs spanning the changed
extent. An unchanged span of eight or more becomes a skip; anything