Some checks failed
akbasic CI Build / cmake_build (push) Failing after 3m23s
akbasic CI Build / sanitizers (push) Failing after 4m33s
akbasic CI Build / coverage (push) Failing after 3m45s
akbasic CI Build / akgl_build (push) Failing after 24s
akbasic CI Build / mutation_test (push) Failing after 3m30s
The megademo gains a scene: an 800x600 vaporwave sunset carried inside the listing, then six looping delta frames of full motion video -- the floor grid rolling forward and the sun's slices crawling, about 220 bytes a frame. DATA cannot carry an image (512 items, ~350 owned by the stroke font), so the picture rides in RLE-encoded string literals decoded with INSTR: 2 KB of base frame, 1.3 KB of video. Row records jump the decoder between changed rows, an unreachable colour is the skip, black draws so a delta can erase, and the rays are struck live over every frame rather than encoded. The 63-byte bird gliding over it is the one image DATA does have room for, via SPRSAV's type-in form. examples/megademo/vaporwave.py composes the image in palette space, dithers by row so the gradients band instead of shattering the RLE, proves base-plus-deltas reproduces every frame against a simulation of the decoder, and owns the generated block between the PICTURE markers. TODO.md section 4 records the DATA ceiling and the wanted verb. Co-Authored-By: Claude Code (Fable 5) <noreply@anthropic.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EwxGB6TdoVvZ11KQQME9cL
328 lines
11 KiB
Python
328 lines
11 KiB
Python
#!/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)
|
|
PAYLOAD = 240
|
|
|
|
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
|
|
shorter is simply repainted, which merges into its neighbours' runs.
|
|
Black runs cost nothing either way -- the decoder draws nothing for
|
|
colour 1 and just advances."""
|
|
out = ""
|
|
for y in range(H):
|
|
diffs = [x for x in range(W) if prev[y][x] != cur[y][x]]
|
|
if not diffs:
|
|
continue
|
|
lo, hi = diffs[0], diffs[-1]
|
|
out += ROWREC + LENCH[y // 36] + LENCH[y % 36]
|
|
if lo:
|
|
out += encode_skip(lo)
|
|
x = lo
|
|
while x <= hi:
|
|
n = 0
|
|
while x + n <= hi and prev[y][x + n] == cur[y][x + n]:
|
|
n += 1
|
|
if n >= 8:
|
|
out += encode_skip(n)
|
|
x += n
|
|
continue
|
|
c = cur[y][x]
|
|
n = 1
|
|
while x + n <= hi and cur[y][x + n] == c and n < MAXRUN:
|
|
n += 1
|
|
out += encode_run(c, n)
|
|
x += n
|
|
return out
|
|
|
|
|
|
def chop(blob):
|
|
return [blob[i:i + PAYLOAD] for i in range(0, len(blob), PAYLOAD)]
|
|
|
|
|
|
def simulate(raster, blob):
|
|
"""Apply one encoded stream to a raster exactly the way the BASIC
|
|
decoder does, skips-draw-nothing and all."""
|
|
x = y = p = 0
|
|
while p < len(blob):
|
|
c = blob[p]
|
|
if c == ROWREC:
|
|
y = (LENCH.index(blob[p + 1]) * 36) + LENCH.index(blob[p + 2])
|
|
x = 0
|
|
p += 3
|
|
continue
|
|
n = LENCH.index(blob[p + 1]) + 1
|
|
ci = COLORCH.find(c) + 1
|
|
if ci >= 1:
|
|
for i in range(n):
|
|
raster[y][x + i] = ci
|
|
x += n
|
|
if x >= W:
|
|
x = 0
|
|
y += 1
|
|
p += 2
|
|
return raster
|
|
|
|
|
|
def verify(frames, base_blob, delta_blobs):
|
|
"""The base must reproduce frame 0 exactly, and each delta must
|
|
carry the raster exactly to the next frame. A skip leaves the cell
|
|
the encoder promised was already right, so equality is total and
|
|
any difference at all is an encoder bug."""
|
|
raster = [[1] * W for _ in range(H)]
|
|
raster = simulate(raster, base_blob)
|
|
assert raster == frames[0], "base stream does not reproduce frame 0"
|
|
for i, blob in enumerate(delta_blobs):
|
|
want = frames[(i + 1) % PHASES]
|
|
raster = simulate(raster, blob)
|
|
assert raster == want, "delta %d does not reproduce its frame" % i
|
|
|
|
|
|
def emit_block(base_lines, delta_ranges, all_lines):
|
|
out = []
|
|
out.append("REM ---- PICTURE-BEGIN (generated by vaporwave.py; do not")
|
|
out.append("REM ---- hand-edit -- rerun the script to change the picture)")
|
|
out.append("DIM IM$(%d)" % len(all_lines))
|
|
out.append("DIM VA#(%d)" % PHASES)
|
|
out.append("DIM VB#(%d)" % PHASES)
|
|
out.append("NS# = %d" % len(base_lines))
|
|
for i, (a, b) in enumerate(delta_ranges):
|
|
out.append("VA#(%d) = %d" % (i, a))
|
|
out.append("VB#(%d) = %d" % (i, b))
|
|
for i, s in enumerate(all_lines):
|
|
out.append('IM$(%d) = "%s"' % (i, s))
|
|
out.append("REM ---- PICTURE-END")
|
|
return out
|
|
|
|
|
|
def write_png(path, img, scale=5):
|
|
w, h = W * scale, H * scale
|
|
raw = bytearray()
|
|
for y in range(h):
|
|
raw.append(0)
|
|
row = img[y // scale]
|
|
for x in range(w):
|
|
raw.extend(PALETTE[row[x // scale]])
|
|
def chunk(tag, data):
|
|
c = struct.pack(">I", len(data)) + tag + data
|
|
return c + struct.pack(">I", zlib.crc32(tag + data) & 0xffffffff)
|
|
with open(path, "wb") as f:
|
|
f.write(b"\x89PNG\r\n\x1a\n")
|
|
f.write(chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0)))
|
|
f.write(chunk(b"IDAT", zlib.compress(bytes(raw), 9)))
|
|
f.write(chunk(b"IEND", b""))
|
|
|
|
|
|
def splice(path, block):
|
|
with open(path) as f:
|
|
text = f.read().splitlines()
|
|
begin = next(i for i, l in enumerate(text) if "PICTURE-BEGIN" in l)
|
|
end = next(i for i, l in enumerate(text) if "PICTURE-END" in l)
|
|
text[begin:end + 1] = block
|
|
with open(path, "w") as f:
|
|
f.write("\n".join(text) + "\n")
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--preview")
|
|
ap.add_argument("--splice")
|
|
args = ap.parse_args()
|
|
|
|
frames = [compose(p) for p in range(PHASES)]
|
|
base_blob = encode_base(frames[0])
|
|
base_lines = chop(base_blob)
|
|
all_lines = list(base_lines)
|
|
delta_ranges = []
|
|
delta_blobs = []
|
|
for i in range(PHASES):
|
|
blob = encode_delta(frames[i], frames[(i + 1) % PHASES])
|
|
delta_blobs.append(blob)
|
|
lines = chop(blob)
|
|
delta_ranges.append((len(all_lines), len(all_lines) + len(lines) - 1))
|
|
all_lines.extend(lines)
|
|
verify(frames, base_blob, delta_blobs)
|
|
dbytes = sum(len(b) for b in delta_blobs)
|
|
print("base %d bytes in %d strings; video %d bytes in %d strings; "
|
|
"%d strings total" %
|
|
(sum(len(s) for s in base_lines), len(base_lines), dbytes,
|
|
len(all_lines) - len(base_lines), len(all_lines)),
|
|
file=sys.stderr)
|
|
if args.preview:
|
|
write_png(args.preview, compose(0, rays=True))
|
|
if args.splice:
|
|
splice(args.splice, emit_block(base_lines, delta_ranges, all_lines))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|