Tuesday, September 8, 2020

More NES Roms (and languages!): Writing Handwave in co2

Another NES game! This time, I wanted to make a cooperative music game in a similar vein to Rock Band. Handwave is a game for up to eight or sixteen players (sharing 4 controllers). As notes scroll across the screen, you push your button to sound your note, like in a handbell choir. No scoring; you know you're "winning" when the song sounds good.

Ding

For this game, I changed up my tools just a bit: I changed the emulator I tested against and changed the language and tooling I used to write the game. This post talks about those changes; subsequent posts will talk a bit about the difference between the new language and nbasic, break down the code, go into detail about the mini-language I wrote to make the music, and talk a bit about the challenges I faced trying to make a multiplayer music game work over a network.

FCEUX emulator

FCEUX is an emulator that is popular in the TAS (tool-assisted speedrun) crowd. It sports several features over the one I previously used, Nestopia: the main ones I care about are the ability to reload the ROM completely without closing and re-opening it (ctrl-F1) and a memory watcher / debugger / editor, which proved useful for tracking down some hard-to-see bugs. It's also the basis code for the NES emulator that runs in-browser on the Kosmi website, which is where we play NES games.

One issue I ran into is that the link one gets from the FCEUX main site isn't great; it's hosting a many-years-old version that has some known bugs (the one that bit me is that it mis-maps controller inputs 3 and 4). The best source for working copies of the emulator is nightlies hosted on GitHub.

co2 language

co2 is a racket-derived language and build tool that generates asm6-compatible assembly. It's in the LISP family of languages, which makes it quite a bit more distant from raw assembly than the nbasic I've used previously. That having been said, I really enjoyed it and can give it a strong recommendation, even in its current slightly-buggy state. Here's what I encountered that I'm most happy about.

Pros

  1. clean function abstraction: co2 offers more abstractions atop the machine code than I found in nbasic (or, obviously, in rolling raw 6502 assembly). The language relies heavily on subroutines (created with (defsub)), which can take (and return) multiple arguments. This is a significant departure from the nbasic way, which was arguments passed by developer's choice of standard.
  2. local variables: co2 offers local variables via a system of intelligently auto-assigning free memory in such a way that no call hierarchy "stomps on" the memory used by any caller in the call chain. It pulls this trick off via a method called "compiled stack," which precludes recursive calls but otherwise works (and operates statically; each subroutine knows at compile time what RAM is safe to use for local variables, so you're not paying the overhead of stack manipulation or some kind of heap manager on a system with meager CPU cycles to devote to such a thing). For me, this feature was killer; I didn't have to manually keep track of the bytes used by individual functions and worry about whether they'd clobber each other.
  3. standard library support: Relative to nbasic, the set of basic NES PPU and RAM manipulation features in co2 is fairly robust. There are utility methods for setting pieces of sprite data (set-sprite-id!, set-sprite-y!, etc.), RAM memset and memcpy utilities, complex racket-derived flow control constructs (like cond and while), and even the ability to do some limited 16-bit manipulations (mostly supported at compile-time). 
  4. racket: I'm sure some people will put this in the "cons" category, but I'm very comfortable with LISP-family languages, and racket adds some great features atop the basic LISP model that make it very attractive for writing domain-specific languages. I was able to follow the pattern of co2's design to derive my own micro-compiler for a music mini-language that simplified programming the game's music.

Cons

  1. bugs: I tripped over a few, enumerated later.
  2. documentation: It's incomplete. The team is extremely receptive to donations of more docs (and if I get time, I plan to add a few more). But I did have to learn enough racket to read the source code so I could understand what some functions and standard library routines did. I have some unanswered questions as a result (such as how to use defbuffer and whether it can be used with poke!).
  3. runtime: While it didn't bother me, the environment co2 imposes on your game does create a runtime cost; certain memory locations are reserved for co2's own use. This could introduce challenges if you find yourself needing to squeeze out every CPU cycle, or if you try and copy-paste assembly code that makes assumptions about memory layout. Personally, I think the benefits far outweigh the cost of having someone dictate my memory layout.

Bugs

There are a few bugs in co2's implementation. Note: these are all observed as of this commit and might be fixed in future versions).
  1. Some constructs should work but don't, for example, 
    (peek animations (+ 1 (<< cur-anim-frame 1)))
    fails to compile with "ERROR arg->str: (<< cur-anim-frame 1)", which appears to be a sub-error due to the compiler failing to stringify the (<< etc) list.
  2. The compiler can emit code the assembler can't handle (for example, "branch out of range" failure for large if statements). The solution to that one is to bundle the too-large expression into a subroutine.
  3. Attempting to (<< constant variable) is an error, but the error is "assert failed: variable #<procedure:number?>". This one makes sense in the sense that in the underlying 6502 assembly, left- or right-shifting by a variable isn't a supported feature (i.e. both operators only take a literal for number of bits to shift), but it's a bit surprising that the language doesn't abstract that detail. I went ahead and wrote a variable-amount bit-shifter in the game to compensate.
  4. Mixing multi-return with a subroutine call doesn't work (i.e.
    (set! bar 1)
    (return foo bar)
    is fine, but
    (return foo (calculate-bar args))
    gets dicey)
  5. (loop n a b body) accepts literals and variables as a or b but not expressions that evaluate to a value. If you store the result of the expression evaluation to a temporary variable, you're fine.

Future development

Handwave isn't done. Some features I'd like to add in the future:
  • Support for all five sound channels on the NES (currently, it only uses the two square waves)
  • The ability to "put down and pick up" a "wave" (i.e. change voicing rules for one of the notes mid-song)
  • Letting a wave "ring" vs. "damping" (i.e. holding a button down to keep a note sounding; makes the most sense for sawtooth sounds, which tend to run continuously)
  • Some sort of scoring
  • Improvements to the "netcode" for multiplayer over an environment with latency

Conclusions

I really enjoyed putting this game together, and the excuse to play around in a very unique LISP-family language. If you'd like to play around with (or play) the game, give it a try! The ROM and source are available on GitHub.

Have fun!

No comments:

Post a Comment