From 0d79a3f52cfe0554738de6ba60ea195c1b0ff7c4 Mon Sep 17 00:00:00 2001 From: Andrew Kesterson Date: Sun, 2 Aug 2026 16:26:56 -0400 Subject: [PATCH] Decompress a full-screen picture and six frames of video from strings 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) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EwxGB6TdoVvZ11KQQME9cL --- TODO.md | 11 ++ examples/megademo/README.md | 36 +++- examples/megademo/megademo.bas | 216 +++++++++++++++++++++- examples/megademo/vaporwave.py | 327 +++++++++++++++++++++++++++++++++ 4 files changed, 578 insertions(+), 12 deletions(-) create mode 100644 examples/megademo/vaporwave.py diff --git a/TODO.md b/TODO.md index c1f0758..b43062b 100644 --- a/TODO.md +++ b/TODO.md @@ -778,6 +778,17 @@ and that is the argument for the verb. Ordered by how much BASIC each one would the note rather than read from shared state at release. The demo works around it the way 1985 did on one channel: an interleaved kick–bass–arpeggio line (the `TUNE` label), which is authentic and should not be mandatory. +- **`DATA` is too small a pipe for assets, and `SPRSAV`'s type-in form is 24 by 21.** The + pool is 512 items and the demo's stroke font already holds ~350, so a full-screen picture + can never arrive by `DATA` — and the one sprite form that reads from the program is fixed + at the VIC-II's 24x21 monochrome. The demo's scene 5 shows both the ceiling and the way + around it: a 160x120 sixteen-colour image rides in **string literals** instead — RLE, two + characters a run, ~4 KB in eighteen `IM$()` assignments, decoded with `INSTR` and painted + in 5x5 blocks (`examples/megademo/vaporwave.py` generates the block) — while the 63-byte + bird beside it is all `DATA` can actually carry. Strings are the workaround, not the + answer: every program that wants a picture must also carry a decoder. What is wanted is + either a bigger `DATA` pool (it is one constant) or, better, a `SPRSAV`/`BLOAD` form that + accepts packed image data from program text at a stated width and height. - **The only vsync is spinning on `TI#`, and the spin is worse than it looks.** Chapter 13 documents the polling; the demo's first cut had the loop — read `TI#`, branch to yourself until it moves — and it did not just waste CPU, it **starved the `PLAY` queue dry**. The diff --git a/examples/megademo/README.md b/examples/megademo/README.md index 70cd44b..e5c9a27 100644 --- a/examples/megademo/README.md +++ b/examples/megademo/README.md @@ -8,14 +8,15 @@ megagreetz to starfort. ./build-akgl/basic examples/megademo/megademo.bas ``` -Q or ESC quits. Six scenes: a title card, a Lissajous braid, a spinning wireframe +Q or ESC quits. Seven scenes: a title card, a Lissajous braid, a spinning wireframe cube and octahedron — which drift together, ignite a pulsing origin point strobing the next scene's colours, swallow the camera as the perspective scale outgrows the -window, and split that origin into the first two raster bars — then the raster -bars proper, the sprite bank going supernova — the letters fall into the middle, a -fireball throws them at the walls, and a black hole drags everything back into the -centre as the transition out — and a finale that composes all of it. A sawtooth -chiptune in A minor throughout, with a noise drum break, if a SID answers. +window, and split that origin into the first two raster bars — the raster bars +proper, a full-screen vaporwave sunset decompressed out of the listing itself, the +sprite bank going supernova — the letters fall into the middle, a fireball throws +them at the walls, and a black hole drags everything back into the centre as the +transition out — and a finale that composes all of it. A sawtooth chiptune in A +minor throughout, with a noise drum break, if a SID answers. ## What it leans on @@ -44,6 +45,29 @@ This is a tour of the interpreter's edges, on purpose: the window, which the silent clipping permits. The pulsing origin point strobes through the bar ramp table because it *is* the next scene: it splits, rides the Y axis to the band edges, and stretches into the first two raster bars in place. +- **The picture is strings, because `DATA` is a straw** — the pool is 512 items + and the font owns ~350, so the full-screen sunset rides in string literals: + 160x120 cells, sixteen colours, run-length encoded two characters a run and + decoded with `INSTR` into 5x5 blocks — about 2 KB for the whole base frame. + `vaporwave.py` composes the image in palette space — dithered by *row*, not + pixel, so the gradients band like a CRT instead of shattering the RLE — and + owns the generated block between the `PICTURE-BEGIN`/`PICTURE-END` markers. Do + not hand-edit it; rerun the script. The bird gliding over it is the + counterpoint: a genuine 24x21 `SPRSAV` type-in sprite, 63 bytes, which is + everything `DATA` has room to say. The paint is the reveal, and the exit is a + stride-63 column dissolve — 63 is coprime to 160, so the walk hits every + column once and looks random while carrying no state. +- **And then the picture is video.** Six delta frames loop the floor grid toward + you and crawl the sun's slice pattern — full motion video, 1.3 KB total, about + 220 bytes a frame. A delta re-encodes only the rows that changed: an `R` + record jumps the decoder's cursor (two base-36 digits of row), `Q` skips what + stayed, and black *draws*, which is how a moved grid line is erased. The rays + are not in the encoding at all; they are struck live over every frame, which + is the difference between a thirty-run grid row and a five-run one. The loop + is mathematically exact: line k sits at `B*(r^(k+t)-1)` so six frames advance + t by one and land the set on itself, and the slice period divides the frame + count. The generator simulates the decoder and asserts base-plus-deltas + reproduces every frame before it will emit a byte. - **3D with no matrices** — the wireframe scene rotates fourteen pooled vertices through two composed axis rotations and a perspective divide, all of it `SIN`, `COS` and one division, with the float kept on the left of every product because diff --git a/examples/megademo/megademo.bas b/examples/megademo/megademo.bas index db90aca..2e49179 100644 --- a/examples/megademo/megademo.bas +++ b/examples/megademo/megademo.bas @@ -57,9 +57,14 @@ DIM WQX#(14) DIM WQY#(14) DIM WE1#(24) DIM WE2#(24) +DIM PBRD#(63) ALPHA$ = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 :-.!" GC$ = "GAMECUBE" +REM The picture decoder's alphabets: a run is two characters, colour +REM then length, and INSTR turns each back into a number. +CPAL$ = "ABCDEFGHIJKLMNOP" +LTAB$ = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" SEED# = 314159 QF# = 0 RB# = 0 @@ -120,6 +125,7 @@ SEED# = SEED# + TI# GOSUB LOADTABLES GOSUB LOADWIRE GOSUB LOADFONT +GOSUB LOADBIRD IF SND# = 1 THEN GOSUB SIDINIT GOSUB TUNE IF TXT# = 1 THEN CHAR 1, 0, 0, "AKLABS MEGADEMO 85 - PRESS Q TO EXIT" @@ -429,7 +435,117 @@ LABEL BARDONE IF QF# = 1 THEN GOTO BYE REM ===================================================================== -REM SCENE 5 : THE SPRITE BANK IS THE SCROLLTEXT, AND THEN IT EXPLODES. +REM SCENE 5 : THE PICTURE, AND THEN IT MOVES. A full 800x600 +REM vaporwave sunset carried inside the listing, then six delta +REM frames of full motion video in a loop. DATA could never hold any +REM of it -- the pool is 512 items and the font owns most of them +REM (TODO.md section 4) -- so it all rides in string literals: +REM run-length encoded, two characters a run, decoded with INSTR and +REM painted as 5x5 blocks. A video frame re-encodes only the rows +REM that changed ('R' records jump the cursor), 'Q' skips what stayed +REM and black draws over what moved, so a frame of grid-scroll and +REM sun-shimmer is about two hundred bytes and paints in a few +REM presents. The rays are not encoded at all: they are struck live +REM over every frame, which is what keeps the floor rows cheap. The +REM loop is exact -- the grid advances one geometric step over six +REM frames and the slice pattern's period is six. The paint is the +REM reveal; the exit is a stride-63 column dissolve. The bird is the +REM one thing DATA does hold: a real 24x21 SPRSAV sprite, 63 bytes, +REM gliding across its own sunset. vaporwave.py draws the picture, +REM proves base plus deltas reproduce every frame, and owns the +REM generated block. +REM ===================================================================== +GRAPHIC 1, 1 +WIDTH 2 +BS% = W# / 160 +IF BS% < 1 THEN BS% = 1 +REM ---- PICTURE-BEGIN (generated by vaporwave.py; do not +REM ---- hand-edit -- rerun the script to change the picture) +DIM IM$(16) +DIM VA#(6) +DIM VB#(6) +NS# = 9 +VA#(0) = 9 +VB#(0) = 9 +VA#(1) = 10 +VB#(1) = 10 +VA#(2) = 11 +VB#(2) = 12 +VA#(3) = 13 +VB#(3) = 13 +VA#(4) = 14 +VB#(4) = 14 +VA#(5) = 15 +VB#(5) = 15 +IM$(0) = "G9G9G9G9GPGHEHG9GTEHG9GTEHGPGPEHG9GTEHG9GTEHGHGXEHGTBAG8EHG9GTEHG5EHGPEHG5EHGPEHG5EHGAPAG3EHGPEHG5EHGPEHGXGHEHG5EHGPEHG5EHGFBAGIEHGPGPEHGPEHGHEHGPEHGPEHGHEHGPEHGHEHGPEHGPEHGHEHGBPAGMEHGPEHGHEHGPEHGHEHGPEHGPEHGHEHGPEHGPEHGHEHGPEHGHEPGHEHGPEH" +IM$(1) = "GHEPGHEHGPEHGHEPGHEHGHEGBAEHGHEHGPEHGHEPGHEHGPEHGHEHGPEHGHEPGHEHGPEHGHEPGHEHGPECPAEDGHEPGHEHGHEPGHEPGHEHGHEPGHEPGHEHGHEPGHEHGHEPGHEPGHECBAEDGHEPGHEPGHEHGHEPGHEHGHEPGHEPGHEHGHEPGHEPEPGHEPGHE5GHEPGHE5GHEHEXGHEPGHEEPAEZGHEPGHE5GHE5GHEPGHE5GHEP" +IM$(2) = "GHE5GHE9ETGHE9ETGHEXEHGHE9ETGHHAE9ESGHEPEPGBBAGEE9EMHOE9ETGHEHE9E7HUE9E6E9E4H0E9E3E9E3H2E9EHBAETEHKHE9ELH6E9ECKHEPEPKHE9EBH9HAE9EIKHEHEXKHE2H9HCE9EPKHE5KHEPKDH9HEKCEPKHE5KHE5KHEKH9HGEBKHEPKHEXEHKHE5KHEBH9HIEIKHEPKHEPEPKHEPKHEHKAH9HKKHEHKHEP" +IM$(3) = "KHEHKHEPKHEPKHH9HMEGKHEHKHEPKHEHKHEPKHEPH9HMEOKHEHKHEPKHEHKPEHKHEGH9HOKFEPKHEHKPEHKHEHKPEHKGH9HOEFKHEPKHEHKHEPKHEHKPEFH9HQKEEHKHEPKHEHKHEPKHEHKMH9HSKLEHKHEPKHEHKPEHKHEHKEH9HSEDKPEHKPEHKHEHKPEHKHEEH9HSKDEHKPEHKPKPEHKPEHKDH9HUECKHEHKPEHKHKXEH" +IM$(4) = "KPEDH9HUKCEHK5EHK5EHKLH9HUKKEHK5EHK5EHKCH9HWEBKPEHKXKHEHK8I9IWKZEHKPKPEHK0I9IWK7EHKHKXEHKSI9IWK9KFEHK9KOI9IWK9KNK9KOI9IWK9KNK9KOI9IWK9KNKPIHKZI9IYK6IHKHKXIHKSI9IWK9KFIHK5IHKKI9IWK9KNIHK5IHKCI9IYKPIHKXKHIHK5IHKPIHK5IHKPIHKPKPIHK0I9IWKJIHKPIH" +IM$(5) = "KHIHKPIHKPI9IZKBIHKHIHKPIHKHIHKPIHKKI9IWKJIHKHIHKPIHKHIHKPIHKDI9IXKPIHKHIHKHKHIHKHIPKHI9IYKCIHKPIHKHIHKPIHKHIPKHIHKPIHKHIPKHIHKPIHKHIHKPIHKHI9I9IHKHIHKPIHKHIPKHIHKHIEC9CSKDIPKHIPKHIHKHIPKHIHKEC9CSIDKHIPKHIPIPKHIPKHIFC9CQKEIHKHIPKHIHIXKHIPKG" +IM$(6) = "C9COIFKHI5KHI5KHIPKHI5KHIPKHI5KHI5KHIPKHI5KHIPKHIXIHKHI9IDC9CMI4KHIPIPKHI6C9CKI9IDKHIHIXKHIZC9CII9IMKHI9IWC9CGI9IVI9I9I9I9IPI9I9I9I9IPQ9Q9Q9Q9QPE9E9E9E9EPE9E9E9E9EPQ9Q9Q9Q9QPE9E9E9E9EPQ9Q9Q9Q9QPE9E9E9E9EPQ9Q9Q9Q9QPQ9Q9Q9Q9QPQ9Q9Q9Q9QPQ9Q9Q9" +IM$(7) = "Q9QPE9E9E9E9EPQ9Q9Q9Q9QPQ9Q9Q9Q9QPQ9Q9Q9Q9QPQ9Q9Q9Q9QPQ9Q9Q9Q9QPQ9Q9Q9Q9QPE9E9E9E9EPQ9Q9Q9Q9QPQ9Q9Q9Q9QPQ9Q9Q9Q9QPQ9Q9Q9Q9QPQ9Q9Q9Q9QPQ9Q9Q9Q9QPQ9Q9Q9Q9QPQ9Q9Q9Q9QPQ9Q9Q9Q9QPQ9Q9Q9Q9QPE9E9E9E9EPQ9Q9Q9Q9QPQ9Q9Q9Q9QPQ9Q9Q9Q9QPQ9Q9Q9Q9QPQ9Q9Q9" +IM$(8) = "Q9QPQ9Q9Q9Q9QPQ9Q9Q9Q9QPQ9Q9Q9Q9QPQ9Q9Q9Q9QPQ9Q9Q9Q9QPQ9Q9Q9Q9QPQ9Q9Q9Q9QPQ9Q9Q9Q9QPQ9Q9Q9Q9QPQ9Q9Q9Q9QPQ9Q9Q9Q9QP" +IM$(9) = "RBRQ9QOKMQHK5RBSQ9QTI9IRRBXQ9QTKPQHKHQPKERBYQ9QPI9IHRB3Q9QSKAI5KHIJRB5Q9QTC9CMRB9Q9QWI9IGRCBQ9QYC9CCRCIA9A9A9A9APRCJE9E9E9E9EPRCNA9A9A9A9APRCOE9E9E9E9EPRCUA9A9A9A9APRCVE9E9E9E9EPRC5A9A9A9A9APRC7E9E9E9E9EP" +IM$(10) = "RBQQ9QOK9KIQHKFRBRQ9QOI9IQRBWQ9QPKLQHKHQHKPRBXQ9QTI9QLIERB2Q9QRIBKHIPKHIPKCRB4Q9QSC9CORB8Q9QVI3KHIGRCAQ9QXC9CERCJA9A9A9A9APRCKE9E9E9E9EPRCOA9A9A9A9APRCPE9E9E9E9EPRCVA9A9A9A9APRCXE9E9E9E9EPRC7A9A9A9A9APRDAE9E9E9E9EP" +IM$(11) = "RBPQ9QOK9KAQHKNRBQQ9QOI9IWRBVQ9QOKEQHKHQHKPQHKFRBWQ9QPI9IPRB1Q9QQKCIPKHIPKHIDRB3Q9QSC9CORB7Q9QUIWKHIPRB9Q9QWC9CGRCEA9A9A9A9APRCFE9E9E9E9EPRCGA9A9A9A9APRCHE9E9E9E9EPRCPA9A9A9A9APRCQE9E9E9E9EPRCXA9A9A9A9APRCZE9E9E9E9EPRDAA9A9A9A9APRDCE9E9E9E9" +IM$(12) = "EP" +IM$(13) = "RBOQ9QNK3QHKWRBPQ9QOI9IWRBUQ9QTKHQHKPQHKNRBVQ9QOI9IWRB0Q9QQIKKHIPKHIHKDRB1Q9QQC9CSRB2Q9QRC9CQRB6Q9QTIPKHIYRB8Q9QVC9CIRCKA9A9A9A9APRCLE9E9E9E9EPRCQA9A9A9A9APRCRE9E9E9E9EPRCZA9A9A9A9APRC1E9E9E9E9EPRDCA9A9A9A9APRDFE9E9E9E9EP" +IM$(14) = "RBOQ9QNI9IYRBTQ9QOKEQHKPQHKVRBUQ9QTI9IRRBZQ9QTKHQHKPQHKHRB0Q9QQC9CSRB5Q9QTIHKHI5KARB7Q9QUC9CKRCBQ9QYI9ICRCHA9A9A9A9APRCIE9E9E9E9EPRCLA9A9A9A9APRCME9E9E9E9EPRCRA9A9A9A9APRCTE9E9E9E9EPRC1A9A9A9A9APRC3E9E9E9E9EPRDFA9A9A9A9APRDIE9E9E9E9EP" +IM$(15) = "RBSQ9QTKPQHK3RBTQ9QOI9IWRBYQ9QPKDQHKPQHKHRBZQ9QTI9ILRB4Q9QSIAKHI5KHIBRB6Q9QTC9CMRCAQ9QXI9IERCEE9E9E9E9EPRCFA9A9A9A9APRCGE9E9E9E9EPRCMA9A9A9A9APRCNE9E9E9E9EPRCTA9A9A9A9APRCUE9E9E9E9EPRC3A9A9A9A9APRC5E9E9E9E9EPRDIA9A9A9A9AP" +REM ---- PICTURE-END +SS# = 0 +SE# = NS# - 1 +GOSUB DRAWSTREAM +GOSUB RAYS +SPRSAV PBRD#, 1 +SPRITE 1, 1, 1, 0, 1, 1 +MOVSPR 1, 0.16 * W#, 0.14 * H# +IF HL# = 0 THEN MOVSPR 1, 96 # 1 +COLOR 1, 2 +T$ = "THE PICTURE IS STRINGS - THE BIRD IS DATA" +SZ% = 0.0038 * W# +GOSUB CENTERX +TY% = 0.02 * H# +GOSUB DRAWTEXT +IF HL# = 1 THEN GOTO PICDONE +REM Six delta frames in a loop: the grid rolls toward you and the +REM sun's slices crawl. Each frame repaints only the rows that +REM changed, and the rays are struck over the top again. +VI# = 0 +DL# = TI# + 480 +DO WHILE TI# < DL# + SS# = VA#(VI#) + SE# = VB#(VI#) + GOSUB DRAWSTREAM + GOSUB RAYS + VI# = VI# + 1 + IF VI# >= 6 THEN VI# = 0 + IF TI# > MT# THEN GOSUB TUNE + GOSUB POLLKEY + IF QF# = 1 THEN EXIT +LOOP +REM The dissolve: black out the 160 columns in stride-63 order. +COLOR 1, 1 +K2# = 0 +DO WHILE K2# < 160 + CO# = MOD(K2# * 63, 160) + X3% = CO# * BS% + DRAW 1, X3%, 0 TO X3%, H# - 1 + X3% = X3% + 2 + DRAW 1, X3%, 0 TO X3%, H# - 1 + X3% = X3% + 2 + DRAW 1, X3%, 0 TO X3%, H# - 1 + IF MOD(K2#, 5) = 0 THEN GOSUB VSYNC + K2# = K2# + 1 +LOOP +MOVSPR 1, 0 # 0 +SPRITE 1, 0 +LABEL PICDONE +IF QF# = 1 THEN GOTO BYE + +REM ===================================================================== +REM SCENE 6 : THE SPRITE BANK IS THE SCROLLTEXT, AND THEN IT EXPLODES. REM Eight letters, eight sprites: draw each with the stroke font, REM SSHAPE it, SPRSAV it, surf it on a sine. Then the letters fall REM into the middle, a supernova throws them at the walls -- SOLID @@ -573,7 +689,7 @@ LABEL WAVEDONE IF QF# = 1 THEN GOTO BYE REM ===================================================================== -REM SCENE 6 : THE FINALE CARD. Everything at once: stars, bars, the +REM SCENE 7 : THE FINALE CARD. Everything at once: stars, bars, the REM logo, the greetz, and the sprite bank surfing a sine. This is the REM frame a clockless host leaves on the screen. REM ===================================================================== @@ -869,6 +985,7 @@ REM A ball of light: concentric circles three pixels apart, solid at REM WIDTH 2. DCX%/DCY% centre it, DR% sizes it, DCI# colours it -- REM DCI# = 1 is the eraser. LABEL DISC +IF DR% < 1 THEN RETURN COLOR 1, DCI# R3% = DR% DO WHILE R3% > 1 @@ -881,13 +998,11 @@ REM The origin point: erase it where it was, redraw it pulsing on REM SIN(BF#) and strobing through the bar ramp table -- the colours REM of the next scene, because this is where its bars come from. LABEL BALLFX -IF PB% <= 0 THEN GOTO BALLFX2 DCI# = 1 DR% = PB% DCX% = C1X% DCY% = CY% GOSUB DISC -LABEL BALLFX2 DR% = BBR% + ((0.45 * BBR%) * SIN(1.1 * BF#)) DCI# = BARC#(MOD(BF#, 20)) DCX% = C1X% @@ -920,6 +1035,72 @@ IF SND# = 0 THEN RETURN SOUND 3, 4000, 40, 1, 52000, 800 RETURN +REM Decode picture strings IM$(SS#) through IM$(SE#) and paint them +REM as 5x5 blocks. A run is colour-char then length-char. 'Q' misses +REM the colour table, so it advances without drawing -- the skip. 'A' +REM is black and DOES draw, which is how a video delta erases. 'R' +REM opens a row record: two base-36 digits of row, cursor to column +REM zero -- how a delta jumps between the rows that changed. +LABEL DRAWSTREAM +X# = 0 +Y# = 0 +S# = SS# +DO WHILE S# <= SE# + T2$ = IM$(S#) + TL2# = LEN(T2$) + P# = 0 + DO WHILE P# < TL2# + C2$ = MID(T2$, P#, 1) + IF C2$ = "R" THEN GOSUB PICROW ELSE GOSUB PICRUN2 + LOOP + GOSUB POLLKEY + S# = S# + 1 +LOOP +RETURN + +LABEL PICROW +Y# = INSTR(LTAB$, MID(T2$, P# + 1, 1)) * 36 +Y# = Y# + INSTR(LTAB$, MID(T2$, P# + 2, 1)) +X# = 0 +P# = P# + 3 +RETURN + +LABEL PICRUN2 +CI# = INSTR(CPAL$, C2$) + 1 +LN# = INSTR(LTAB$, MID(T2$, P# + 1, 1)) + 1 +IF CI# > 0 THEN GOSUB PICRUN +X# = X# + LN# +IF X# >= 160 THEN X# = 0 +IF X# = 0 THEN Y# = Y# + 1 +P# = P# + 2 +RETURN + +REM One run: LN# cells of colour CI# at cell (X#, Y#), scaled by BS%. +LABEL PICRUN +X3% = X# * BS% +X4% = ((X# + LN#) * BS%) - 1 +Y3% = Y# * BS% +COLOR 1, CI# +DRAW 1, X3%, Y3% TO X4%, Y3% +Y3% = Y3% + 2 +DRAW 1, X3%, Y3% TO X4%, Y3% +Y3% = Y3% + 2 +DRAW 1, X3%, Y3% TO X4%, Y3% +RETURN + +REM The rays live outside the encoding, redrawn over every video +REM frame: nineteen lines instead of thirty runs a grid row, and they +REM are what makes the encoded floor nearly free. +LABEL RAYS +COLOR 1, 4 +RI# = 0 - 9 +DO WHILE RI# < 10 + X3% = (0.5 * W#) + (RI# * 26 * BS%) + DRAW 1, 0.5 * W#, (74 * BS%) + 2 TO X3%, H# + 99 + RI# = RI# + 1 +LOOP +RETURN + REM Park the eight letter sprites on a sine. LABEL WAVE L# = 1 @@ -954,11 +1135,9 @@ RETURN REM One frame of fireball: erase the previous radius, draw the new REM one. PR% carries the radius to erase between calls. LABEL BOOMFX -IF PR% <= 0 THEN GOTO BOOMFX2 BCF# = 1 BR2% = PR% GOSUB BOOMPASS -LABEL BOOMFX2 BCF# = 0 BR2% = ER% GOSUB BOOMPASS @@ -969,6 +1148,7 @@ REM The fireball at radius BR2%: three rings, white heart to orange REM rim, and twelve red spokes thrown past the outer ring. BCF# = 1 REM draws the whole thing in black, which is the eraser. LABEL BOOMPASS +IF BR2% < 1 THEN RETURN R3% = BR2% IF BCF# = 1 THEN COLOR 1, 1 ELSE COLOR 1, 2 CIRCLE 1, CX0%, CY0%, R3%, R3% @@ -1220,6 +1400,15 @@ FOR K# = 1 TO N# * 2 NEXT K# RETURN +REM The bird: SPRSAV's type-in form, sixty-three bytes, three per row, +REM most significant bit leftmost. +LABEL LOADBIRD +FOR I# = 0 TO 62 + READ D# + PBRD#(I#) = D# +NEXT I# +RETURN + REM ===================================================================== REM Trap handlers for the hardware probes. Each marks its device gone REM and lets the probe fall through to the next line. @@ -1367,3 +1556,18 @@ REM full stop DATA 1, 15,16 REM exclamation mark DATA 2, 10,14, 15,16 + +REM The bird, 24 by 21, one bit a pixel: +REM ..XXX...........XXX..... +REM .X...XX.......XX...X.... +REM X.....XXX...XXX.....X... +REM .......XXXXXX........... +REM .........XX............. +REM and sixteen empty rows of sky. +DATA 0, 0, 0, 56, 0, 224, 70, 3, 16 +DATA 131, 142, 8, 1, 248, 0, 0, 96, 0 +DATA 0, 0, 0, 0, 0, 0, 0, 0, 0 +DATA 0, 0, 0, 0, 0, 0, 0, 0, 0 +DATA 0, 0, 0, 0, 0, 0, 0, 0, 0 +DATA 0, 0, 0, 0, 0, 0, 0, 0, 0 +DATA 0, 0, 0, 0, 0, 0, 0, 0, 0 diff --git a/examples/megademo/vaporwave.py b/examples/megademo/vaporwave.py new file mode 100644 index 0000000..8f5b73f --- /dev/null +++ b/examples/megademo/vaporwave.py @@ -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()