agat-snake

Русский · English

«ПИТОНЧИК» / SNAKE — Agat-7, 1988

TL;DR

My 1988 simple game written in assembly for Agat-7. Analyzed from a single binary unattended by Claude Code, Opus 5 model in ‘Auto Mode’. Below is the description in Claude’s own words.

My only prompts were:

title screen the animals a game in progress


From the one who took it apart. I am Claude (Opus 5). The folder started with a single file in it and nothing else. Working out that the container is a DOS 3.3 B-file, that one 1024-byte table is both the font and the sprite sheet, that the board keeps a direction in every cell the snake occupies, that the game has no random number generator at all — that is my work, done from the bytes. Where I am guessing, I say so.


What is in this folder

path what it is
SNAKE.FIL the archived file — the only input
bin/SNAKE.bin the game itself, $2000–$2F21, 3874 bytes
docs/SNAKE.disasm.txt disassembly, code and data separated
docs/SNAKE.annotated.txt the same with names put back in
docs/tables.md every lookup table, decoded
docs/text.txt every string, laid out on the 32×32 grid
screens/screen-*.png the four screens the game draws, and one it played
screens/font.png all 128 glyphs
screens/tiles.png the sixteen snake glyphs, named
screens/prizes.png the nine animals, with what each is worth
tools/emu.py a 6502 and just enough Agat to run the binary
tools/check.py runs it and compares the result with the pictures
tools/play.py plays it, and photographs the screen
web/ the browser version — the same binary, a 6502 in JavaScript
convert.py reproduces the whole folder: python3 convert.py
manifest.txt what came from where

The container

SNAKE.FIL is one file lifted out of a DOS 3.3 catalogue and wrapped: 30 bytes of name padded with $A0, nine spare, then the DOS file type at offset 39 — $04, a B-file. The B-file’s own four-byte header says it loads at $2000 and is $0F22 = 3874 bytes long. The rest of the container is the padding to whole sectors. The same layout as the loose .FIL files in the Rise Out archive.

Nothing is damaged. Eleven bytes in the file happen to be $BD — the value a lost sector is filled with — but all eleven are ordinary data in the middle of tables, not a hole.


The machine, as this program sees it

The Agat-7 screen here is 256×256, one bit a pixel, 32 bytes a row, high bit leftmost, and there are two pages: $4000 is where a screen is put together and $6000 is what you see. A glyph is 8×8, so the screen is a 32×32 grid of them and an address is just page | row for the high byte and column for the low, stepping 32 bytes a scan line. That is PUTCH at $237E, twenty-one instructions long, and it is the only drawing routine in the game.

Rise Out used its two pages the same way round — $4000 a clean copy, $6000 what you see — but for a different reason: there it is so sprites can be erased by copying the background back. Here $4000 is only a staging area, and SHUTTER ($2670) moves it to $6000 one bit at a time:

    LSR $4000,X     ; page 1 is the high byte
    ROR $6000,X     ; page 2 the low byte of a 16-bit shift right

Run over all 8192 byte positions eight times, that shifts the composed image across into the visible page while emptying the staging one. On screen every 8-pixel cell fills in from the left at once — the game’s only transition, and it is used between all three menu screens and again before the board appears.


One table for everything

$26A5 holds 128 glyphs of eight bytes — 1024 bytes, a whole character generator carried inside the game, which it has to be: everything happens in the 256×256 graphics mode, where there is no hardware text to lean on. $20–$7F is KOI-7 N2, so $30 is 0 and the score routine indexes straight into the digits. $00–$0F are not characters at all: they are the snake.

the snake glyphs

Four heads, four tails, two straight body pieces and four bends — and every one of them is a dither of alternating pixels, which is what gives the snake its scaly grey against the black field. The wall is a denser dither of the same kind. Rise Out does the same thing in its library (SETSPRITE points at $D000 and “character code N and sprite N are the same eight bytes”); here there is no library, just the one table and the one routine.

The nine animals are the exception — they live in a table of their own at $2EDA, and get drawn by pointing PUTCH at that table instead:

the nine prizes


The board

$3C00–$3FFF is a byte a cell, 32×32, sitting just under the video pages. The value is not “occupied” — it is the direction the snake was travelling when it was in that cell: 1 up, 2 down, 4 left, 8 right, $70 wall, $80 prize, $00 empty.

That one decision is the whole design. Moving the snake is:

No list of segments, no length counter, nothing to shuffle. The body draws itself too: the bend glyph comes from a lookup on (new direction << 4) | old, so a turn picks its own corner piece.

The playing field is the 30×29 interior; row 31 is the status line — high score at columns 5–7, speed at 14–16, score at 23–25, three BCD digits each.

The score is the snake’s length. It starts at 3, with a 3-cell snake, and goes up by exactly one for every step the tail does not move. Eating a rabbit worth 5 does not add 5 to the score: it adds 5 to a counter, and the next five steps grow the snake and score a point each.


No random numbers

There is no random generator anywhere in the file, and nothing that reads a clock. What appears, and where, comes out of the player’s own timing:

Both are seeded from bytes stored in the binary ($14, $19, $04), so the first prize of a fresh boot is always the same: a tsetse fly, worth 1, at (21, 25). After that it is anybody’s guess, which is exactly the point.

animal   points
ТУЗЕМЕЦ native 11
ЛЯГУШКА (АНФАС) frog, front view 2
КЕНГУРУ kangaroo 12
МУХА ЦЕ-ЦЕ tsetse fly 1
ТУЗЕМНЫЙ ПЕСИК native dog 6
МЫШКА mouse 3
ЯЙЦО С ЦЫПЛЕНКОМ egg with a chick 4
КРОЛИК rabbit 5
БАРАН (СЗАДИ) ram, from behind 13

Controls

From the game’s own dispatch table at $2B43 — a key code, a handler address, $80 ends it — and matching the help screen exactly:

key code what it does
arrows $19 $1A $08 $15 up, down, left, right
+ or ; $2B $3B faster
- $2D slower
РЕД $1B sound on / off
УПР+C $03 stop — hold until any key
R $52 reboot

The first arrow you press is also what starts the game. A turn into your own neck is refused by a table of opposites, not by arithmetic.

The speed number on the status line is 256 − delay, and it is worked out by counting up in BCD that many times, because the code has no binary-to-decimal routine and does not need one anywhere else.

Sound off is the neatest thing in the file. The click is LDA ($06),Y with $06/$07 pointing at $C030, the speaker. Turning sound off does:

    LDA $07
    EOR #$FF
    STA $07

$C0 becomes $3F, so the click now reads $3F30 — a harmless byte of RAM. One instruction to mute, one to unmute, and the timing does not change.

Five tunes, all played by the same eight-line routine walking pairs of (cycles, half-period): game start, prize eaten, collision, sound toggled, reset. They are listed with their notes in docs/tables.md.


Starting up, and ⟨RESET⟩

The first thing the program does is hunt upward from $C200 for a 256-byte page that reads all-negative. That is the Agat’s ROM/RAM expansion board, and I know it is because Rise Out does the identical search under the author’s own comment — «НАХОЖДЕНИЕ АДРЕСА ПЛАТЫ ПЗУ И ОЗУ», “finding the address of the ROM and RAM board” — with the same two magic offsets: $80 unprotects, $A0 protects again. Between the two, SNAKE writes its own address into the processor’s reset vector at $FFFC:

    LDY #$80        ; unprotect
    STA (ROM),Y
    LDA #$23 : STA $FFFD
    LDA #$0F : STA $FFFC     ; -> $230F, back into the game
    LDY #$A0        ; protect
    STA (ROM),Y

So ⟨RESET⟩ does not drop you into the monitor: it plays a twelve-note phrase and puts you back at the title. R goes the other way — unprotect, zero the power-up byte at $03F4, JMP ($FFFC) — which is character for character the reboot Rise Out does on the same key.


The four screens

all five

Three menu screens, each of them the same title block plus one string, then the board. The text is the author’s, with his own spelling: letters that have a Latin lookalike (А В Е К М Н О Р С Т Х) are typed as Latin and the rest as KOI-7 codes, so a word like CТPAНИЧКA is half of each table and reads correctly only because the glyphs are identical. convert.py corrects nothing.

МАЛЬЦЕВ АНДРЕЙ ПРИГЛАШАЕТ ВАС ПОИГРАТЬ В ПИ-ТОНЧИКА. ЭТА ИГРА ПОМОЖЕТ ВАМ В МИНУТЫ СКУКИ. ИГРАЙТЕ ВСЕ!!!

(“ANDREY MALTSEV INVITES YOU TO PLAY PI-TONCHIK. THIS GAME WILL HELP YOU IN IDLE MOMENTS. EVERYBODY PLAY!!!”)

The animal list is headed «СТРАНИЧКА ДЛЯ НЕДОГАДЛИВЫХ» — “a page for the slow-witted”. There is no game-over screen: you get three buzzes and the title screen comes back after three presses of SPACE.


Curiosities


How this was checked

Reading a format out of machine code is guesswork until something proves it. tools/emu.py is a 6502 with a keyboard, a speaker that is ignored, and one page of $FF where the ROM board should be — enough to run SNAKE.bin unmodified. tools/check.py boots it, presses SPACE three times, and compares the video page against the pictures convert.py draws from the tables:

title     ok (0 pixels differ of 65536)
controls  ok (0 pixels differ of 65536)
prizes    ok (0 pixels differ of 65536)
game      ok (0 pixels differ of 65536)

The JavaScript 6502 in web/ is timed off the same cycle table, generated from the same source, and the two agree exactly: 1,504,857 cycles from cold reset to the first title screen, on both.


The picture that was played

a played game

screens/screen-play.png is the only picture here that was not drawn. It is the game — the actual 3874 bytes — running on the 6502 in tools/emu.py, with something at the keyboard.

That something is tools/play.py. It boots the machine, taps SPACE three times to get through the title screens, and then, once a move:

It never writes into the machine. The only thing that crosses from the driver into the game is the same four key codes a person would press, arriving at $C000 exactly as the hardware delivered them, and the game cannot tell the difference between this and a patient player.

The first chaser was too keen. It did the obvious thing — work out which way the prize is, go that way — and walled itself in after a dozen moves. It failed in a way I enjoyed: the traceback came out of $2376, which is two instructions inside GETKEY, the blocking wait the game drops into after a collision. My driver had run off the end of its own plan and straight into the game’s «нажмите клавишу».

So the chaser got a survival instinct instead of an appetite. Before each move it flood-fills the free cells from each of the three legal squares and takes whichever leaves the most board still reachable, using distance to the prize only to break ties. That is enough to sweep a 30×29 board in long switchbacks without sealing itself in — and it is what gives the picture its shape. The snake in it is not wandering; it is packing.

The run photographed is 624 moves to a length of 96. On an Agat that is 210,908,752 cycles — about three and a half minutes at the speed the game ships with. It takes a few seconds here, because emu.py spots DELAY ($23C0) on entry, charges the clock precisely what the loop would have cost and skips it:

    7 + 11n + 5n(n+1)/2       # n = the delay byte, $CD by default

That is a triangular loop counted by hand and then checked against running the real thing — it agrees to the cycle for every value the game uses. Up to 108,000 cycles a call, three calls a move, and nothing to show for them but time. Everything else executes.

The same chaser exists in JavaScript for the browser side — node web/tools/check.js play 96 prints the board as ASCII — though it queues keys slightly differently, so the two do not play out move for move.


The browser version

web/index.html is playable, needs no build step and works from file://. It is not a reimplementation: it loads bin/SNAKE.bin and runs it on a 6502 written in JavaScript, with a keyboard at $C000, a one-bit speaker at $C030 and $6000 on the canvas. web/tools/check.js boots that machine under Node and compares its video pages with the same references — so three things now agree on those four screens: the table-driven renderer, the Python 6502 and the JavaScript one.

The sound is worth a paragraph. The audio card is the clock: each block of 1024 samples is produced by running the processor for exactly as many cycles as the block is long, and every sample is the speaker’s one bit at that moment. There is no synthesiser and no note table — the clicks between moves and the sweep at the start are the game’s own $C030 accesses. Details, and the one number I had to choose (the 1 MHz clock), are in web/README.md.


What is not here

The game is complete and self-contained: it needs no library, no separate character generator, no data file. What the archive does not say is which disk it came off, whether there was ever a source, or whether 1988 on the title screen is when it was written or when that copy was made. Rise Out’s own header is dated 1-СЕН-88 … 9-МАР-89, so the two were being worked on at about the same time — but SNAKE, with everything inline and no library at all, reads like the earlier of the two.