Files
akbasic/examples/megademo/README.md

131 lines
8.5 KiB
Markdown
Raw Normal View History

# AKLABS MEGADEMO '85
A demoscene production for akbasic, written as if it were 1985 and this dialect were
the machine under the tree. Code and FX by Tachikoma, dedicated to GameCube,
megagreetz to starfort.
```sh
./build-akgl/basic examples/megademo/megademo.bas
```
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 — 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
This is a tour of the interpreter's edges, on purpose:
- **No `RND`** — it carries Chapter 17's LCG, seeded from `TI#`.
- **No palette writes** — the "colour cycle" on the logo is an honest redraw of the
same strokes in the next colour, and the raster bars are erased and redrawn two
scanlines at a time. The palette is sixteen fixed colours and the demo says so on
screen.
- **Eight sprites** — so the lettering *is* the sprite bank. Each letter of GAMECUBE
is drawn with the stroke font from `examples/breakout`, captured with `SSHAPE`,
installed with `SPRSAV`, and tinted with `SPRITE`.
- **`SOLID` walls and `COLLISION 2`** — the supernova registers four rectangles and
reverses a bearing per `RCOLLISION(n, 7)`. The interpreter does the physics;
BASIC does one subtraction. The letters leave the blast on `MOVSPR`'s continuous
form, eight bearings fanned evenly around the compass.
- **The black hole is `RSPPOS` plus one rotation** — read each letter's position
back, rotate the offset from centre, shrink it by 0.86, put it there. Thirty-four
frames later everything is in the middle. The screen goes with it: an iris of
collapsing black `CIRCLE`s, which works because the drawing verbs clip silently
at the window edge — a fireball and an iris can both start bigger than the
screen.
- **The camera is a multiplier** — the wireframe zoom-through is nothing but the
perspective scale growing fourteen percent a frame until the solids' edges leave
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
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
column once and looks random while carrying no state. The strings come in
64-character chunks because a source line is 80 columns, like the machines
this pretends to be — the decoder carries its cursor from one `IM$` entry to
the next, and the generator refuses to cut inside a run.
- **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
the left operand decides the arithmetic. Erase is the raster bars' trick: redraw
the previous frame's twenty-four edges in black.
- **`PLAY` is one queue** — every note holds it for its length and the whole batch
plays on one instrument, so three `V` tracks come out end to end, not layered. The
soundtrack is therefore beeper music, the way single-channel tracker tunes were
written: sixteenths that take turns being the kick (an `O1` root), the pumping bass
(`O2`/`O3` octaves) and the rolling arpeggio (`O4` and up), two alternating
eight-beat batches — Am F C G, then Am F Dm E with the G sharp — and every third
batch a bar of noise drums. Noise is real: `ENVELOPE`'s sixth argument is a
waveform, 0 to 3, which Chapter 7 forgot to mention. Voice 1 carries the tune so
the collision blips (voice 2) and scene sweeps (voice 3) never steal its channel.
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
A batch is four `PLAY` statements, one bar each — the 80-column line limit will
not hold four bars in one string, and it does not need to: the parser's voice,
envelope and duration state persists across statements and every `PLAY` appends
to the same queue, so four bars queue exactly as one line would.
It also probes for its hardware like a proper boot loader, with one `TRAP` per
device: no graphics refuses by name and exits, no audio mutes the soundtrack, no
text grid drops the status row, and a host that never sets the clock — which is
exactly what `tools/screenshot.c` is — renders each scene as a still and leaves the
finale as the picture. The same file runs everywhere; nothing hangs.
## The ones that bit
**A scalar first assigned inside a `GOSUB` dies with that call's scope.** The first
cut computed text centring in a subroutine and read the result after `RETURN`; every
line of text rendered flush left, because the variable read back as a fresh zero.
Reads climb the scope chain, so a nested subroutine can *use* your globals — but a
write to a name that exists nowhere creates it in the current scope, and the scope
is not coming back. That is why Chapter 18's listings predeclare.
**Then the predeclaring bit back.** The obvious lesson — predeclare everything — ran
the 128-slot variable pool dry the moment the wireframes arrived: `Maximum runtime
variables reached`. A predeclared scratch variable occupies a slot forever; a scoped
one gives its slot back at `RETURN`. So the rule is narrower than it first looks:
predeclare a subroutine's *outputs* and nothing else. This program predeclares four.
**And the raster wait strangled the SID.** The `VSYNC` label used to spin on `TI#`
until the jiffy turned — romantic, and fatal. The host releases at most one queued
`PLAY` note per frame, and a frame of 256 *executed* spin statements is slow enough
to drop the release rate below the tune's fifteen notes a second. The queue never
reclaims slots until it drains completely, so the backlog compounded quietly for two
scenes and then `PLAY queue is full` killed the show. `VSYNC` now sleeps a frame —
`SLEEP` holds at no per-step cost — and `TUNE` traps the overflow on hosts slower
than this one, dropping a bar instead of the whole demo. `TODO.md` §4 carries the
measurement.
## What it wanted and could not have
Recorded properly in `TODO.md` §4, "What a demoscene program wanted and could not
have": `RND` as a real function, palette writes, `GSHAPE` XOR stamping, a way to ask
whether `PLAY` has drained, per-voice `PLAY` queues so three voices can actually
sound together, and a frame-edge wait that is not a busy loop. None of them blocked
the demo; all of them left a routine behind that every future game will carry too.