Decompress a full-screen picture and six frames of video from strings
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
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
This commit is contained in:
327
examples/megademo/vaporwave.py
Normal file
327
examples/megademo/vaporwave.py
Normal file
@@ -0,0 +1,327 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user