Scripting in Octo
I recently participated in the second Nokia 3310 Jam using Octo. Over the course of a hectic four-day development cycle I managed to cobble together a commodity trading strategy game that was essentially a menu-based text adventure with the look and feel of the Nokia 3310 OS: Business is Contagious. Over the course of development I borrowed and iterated upon many ideas from my previous ambitious Octo title, An Evening to Die For. In this writeup, I'd like to delve into the details of a few programming techniques invented for these games.
The XO-Chip extended instruction set augments the CHIP-8 12-bit addressing space (of which 3.5kb is usable for your game) with four more bits via the i := long XXXX instruction. At the cost of a four-byte instruction, you can point the index register anywhere in a spacious 64kb of RAM. While XO-Chip eschews the painful bank-switching approach employed by many 8- and 16-bit game consoles, the 64kb address is not all created equally. It's still only possible to call subroutines or jump to addresses in the low 3.5kb. Thus, I often describe this low region as "code ram" and the upper 60kb as "data ram". Technically one could place the entrypoint to a subroutine in the bottom edge of the low 4k and have execution proceed into "data" space, but this limits the kind of code which can go in such a subroutine.
In An Evening to Die For, I introduced a pair of macros which make it possible to interleave building up these two regions of a ROM:
:calc CODE_POS { 0x200 } :calc DATA_POS { 0x1000 } :macro to-code { :calc DATA_POS { HERE } :org { CODE_POS } } :macro to-data { :calc CODE_POS { HERE } :org { DATA_POS } }
Octo can resolve forward-referenced labels, but declaring before use is necessary if you want to perform compile-time calculations with an address- a necessity for more sophisticated metaprogramming. Consider these routines for laying down pointers and constructing self-modifiable i := long XXXX instructions:
:macro indirect LABEL { # compiles into i := long 0x0000 # with a label in the middle! 0xF0 0x00 : LABEL 0x00 0x00 } :macro unpack16 ADDR { # stash a 16-bit address in a pair of # registers (in code ram), # ready to write into an "indirect" slot :calc hi { 0xFF & ADDR >> 8 } v0 := hi :calc lo { 0xFF & ADDR } v1 := lo } :macro pointer ADDR { # stash a 16-bit address as bytes in data ram :byte { 0xFF & ADDR >> 8 } :byte { 0xFF & ADDR } }
With these tools in hand, I can not only ensure that none of my graphics or lookup tables eat into precious code ram, but easily perform pointer indirection. I can build data structures, and pass addresses into subroutines!
I used the same features extensively in Business is Contagious, but that was only the beginning. This new game had far more game logic and mechanics to squeeze into that dwindling 3.5kb. The solution: write an interpreter, and stuff as much of that logic as possible in data ram!
Interpreted code would necessarily be at least an order of magnitude slower, but for many applications this was irrelevant. For example, the logic that sequences together a series of screens of text and graphics, with pauses for player interaction. Logic like this has a very sterotypical, repetitive structure that can be compressed as a series of simple bytecode instructions. The selection of instructions in my interpreter were chosen to suit the things that happened frequently in my game, giving the whole process the feel of Huffman encoding.
Below is a cleaned up and streamlined iteration on the interpreter I developed for Business is Contagious, minus the game-specific features:
# v0-v2 are used as temporary working registers :alias SCRIPT_A v4 :alias SCRIPT_PC v5 : script-exec # entrypoint; take a pointer in v0-v1 i := script-base save v0 - v1 SCRIPT_PC := 0 : script-step indirect script-base i += SCRIPT_PC SCRIPT_PC += 1 load v0 - v2 jump0 script-dispatch : script-dispatch : do-op-end return : do-op-clear clear jump script-step : do-op-pause # a debounced wait-for-key routine: vf := OCTO_KEY_E loop if vf key then again # wait for release, loop if vf -key then again # wait for press, loop if vf key then again # wait for release... jump script-step : do-op-const SCRIPT_A := v1 SCRIPT_PC += 1 jump script-step : do-op-random i := random-target save v1 - v1 SCRIPT_PC += 1 :next random-target SCRIPT_A := random 0x00 jump script-step : do-op-inc SCRIPT_A += 1 if SCRIPT_A == 0 then SCRIPT_A := 255 # saturating inc jump script-step : do-op-dec SCRIPT_A += -1 if SCRIPT_A == 255 then SCRIPT_A := 0 # saturating dec jump script-step : do-op-load script-addr load SCRIPT_A - SCRIPT_A jump script-step : do-op-save script-addr save SCRIPT_A - SCRIPT_A jump script-step : do-op-table script-addr i += SCRIPT_A load v0 - v1 jump script-exec : do-op-jumpif if SCRIPT_A == 0 then SCRIPT_PC += 2 if SCRIPT_A == 0 then jump script-step # otherwise, fall through... : do-op-jump script-addr v0 := v1 v1 := v2 jump script-exec : script-addr i := script-addr-slot save v1 - v2 SCRIPT_PC += 2 indirect script-addr-slot ;
And then to compose a script, we can write a series of macros for gluing together opcodes and arguments:
:macro opcode OP { :byte { OP - script-dispatch } } :macro s-end { opcode do-op-end } :macro s-clear { opcode do-op-clear } :macro s-pause { opcode do-op-pause } :macro s-const NN { opcode do-op-const :byte NN } :macro s-random NN { opcode do-op-const :byte NN } :macro s-inc-a { opcode do-op-inc } :macro s-dec-a { opcode do-op-dec } :macro s-load ADDR { opcode do-op-load pointer ADDR } :macro s-save ADDR { opcode do-op-save pointer ADDR } :macro s-table ADDR { opcode do-op-tab pointer ADDR } :macro s-jumpif ADDR { opcode do-op-jumpif pointer ADDR } :macro s-jump ADDR { opcode do-op-jump pointer ADDR } :macro script ADDR { unpack16 ADDR script-exec }
Given all this machinery, a trivial script and its call site might look something like the following:
to-data : temp-1 0xAA : temp-2 0x00 : demo-script s-clear s-load temp-1 s-inc-a s-save temp-2 s-end to-code : main script demo-script i := 0xABC :monitor temp-1 2 :breakpoint done
Invoking a script only costs 6 bytes (4 to stash a 16-bit pointer in a pair of v registers, and one call to script-exec), and then the only cost is data ram. The interpreter above can carry out complex control flow between script fragments (each fragment limited to a 256 byte "page", which is plenty for a basic block), and could drive whole conversation trees with the addition of, say, some opcodes for initializing and displaying menus. The whole system described above can be tried out in Octo itself here:
http://octo-ide.com/?key=CHxsqsx7
If you're interested, try contrasting this interpreter with the less general one in Business is Contagious. You can also read a more game-design-oriented devlog on the community forum for the jam. Have any thoughts? What strategies have you used to make the best use of XO-Chip ram?
Get Octo
Octo
A Chip8 IDE
Status | In development |
Category | Tool |
Author | Internet Janitor |
Tags | 8-Bit, chip8, fantasy-console, Pixel Art, Retro |
More posts
- 2021 EnhancementsSep 14, 2021
- Announcing OctodeDec 10, 2020
- 2020 EnhancementsAug 05, 2020
- String ModesJul 29, 2020
- Adaptive Touch InputNov 17, 2019
Leave a comment
Log in with itch.io to leave a comment.