home

posts




2026

April

The Skyland LISP Modding System

Skyland, a GBA homebrew RTS I've been working on for years, has a fairly extensive modding system. I'd like to demonstrate the process by extracting scripts from the ROM, editing one, and rebuilding the game-all you need is a copy of the ROM and a Python script. Let's start by picking a level scenario: I've chosen a simple one where the player encounters an angry pirate character called Redbeard!

Once the opening level dialog finishes, the game presents you with a few choices:

When you choose the option accusing Redbeard of bluffing, he angrily fires a broadside off of your bow:

Suppose we wanted to change what the game does when you accuse Redbeard of lying, where would we start? You could download the entire source code and set up a C++ cross compiler, but actually, for editing scripts, all you need is a copy of the game's ROM file and this unpack_rom.py script! This setup allows you to modify the game on any computer with an installation of python, no other tools needed! Place your copy of the game and the unpacking script in the same directory:


 modding % ls
Skyland.gba	unpack_rom.py

Next, you'll need to run the unpacking script. You can run it with the python command:

modding % python3 unpack_rom.py

If you inspect the current directory after running the script, you should see quite a few files. The unpack_rom.py script has extracted the engine code into SkylandEngine.gba, and unpacked all of the other resource files into various directories. The scripts/ folder contains all of the level scenarios, so we'll want to start there.


 modding % ls
boot.ini		lisp_symtab.dat		SkylandEngine.gba
fs_hash.dat		packages		strings
fs.extracted.bin	readme.txt		tools
help			repack.sh		unpack_rom.py
licenses		scripts
lisp_constant_tab.dat	Skyland.gba
    

You can find any of the scripts by searching the game's dialog in the scripts/ directory:


 modding % grep -r "You're bluffing" scripts
scripts/event/neutral/0/1_0.lisp:                                          "You're bluffing!"
scripts/event/neutral/0/1_0.lisp:                                          "You're bluffing!"

Huh, looks like the game's scripts are all written in LISP :)

It's a real LISP dialect with a full numerical tower, support for async programming, and an optimizing bytecode compiler! Later in this post, I'll show the onscreen debugger!

When opening scripts/event/neutral/0/1_0.lisp, we see a function called handle-bluff, which implements Redbeard's reaction when accused of being deceitful.


(defn/temp handle-bluff ()
  ;; You accused redbeard of bluffing. He fires a broadside off your bow.
  ;; He fires three projectiles, with pauses in between.
  (sleep 400)
  (emit (opponent) 0 12 (terrain (player)) 0)
  (sleep 200)
  (emit (opponent) 0 13 (terrain (player)) 0)
  (sleep 200)
  (emit (opponent) 0 14 (terrain (player)) 0)
  (sleep 1200)
  ;; Set up a dialog prompt, and await user input:
  (let ((sel (await (dialog-choice* (tr (s+ "Yaargh!! I'm just a simple marauder, "
                                            "trying to earn a decent living here! "
                                            "[via petty extortion, how else?]  "
                                            "So what's it gonna be? Last chance..."))
                                    (tr '("Pay 600@."
                                          "Fight back."))))))
    (case sel
      (0 (on-dialog-accepted))
      (1 (on-dialog-declined)))))

Some things here warrant some explanation. defn/temp is a macro that declares a temporary function, to be cleaned up at the end of the current level. The await keyword waits on a promise, many functions accepting input from the player will return a promise, allowing scripts to suspend execution and resume later. The tr function translates text, but also doubles as a tag used by the localization system when extracting strings from the game's scripts.

Let's edit the script to fire way more projectiles. Maybe something like this:


(defn/temp handle-bluff ()
  ;; Opening salvo, same as before
  (sleep 400)
  (emit (opponent) 0 12 (terrain (player)) 0)
  (sleep 200)
  (emit (opponent) 0 13 (terrain (player)) 0)
  (sleep 200)
  (emit (opponent) 0 14 (terrain (player)) 0)
  ;; A long pause. The player thinks it's over.
  (sleep 1500)
  ;; It is not over.
  (let ((shots 30))
    (foreach (lambda (i)
               (emit (opponent) 0 (+ 12 (mod i 3)) (terrain (player)) 0)
               (sleep (+ 70 i)))
             (range 0 shots)))
  (sleep 1200)
  (let ((sel (await (dialog-choice* (tr (s+ "Yaargh!! I'm just a simple marauder, "
                                            "trying to earn a decent living here! "
                                            "[via petty extortion, how else?]  "
                                            "So what's it gonna be? Last chance..."))
                                    (tr '("Pay 600@."
                                          "Fight back."))))))
    (case sel
      (0 (on-dialog-accepted))
      (1 (on-dialog-declined)))))        
    

Now we've edited the script and want to test it. All we need to do is run the repack.sh script, which we previously extracted from the rom file:


 modding % chmod +x repack.sh 
 modding % ./repack.sh       
creating fs.bin...
encoding 708 files...
symbol tab count: 815
constant tab count: 29
encoded 20677776 bytes
done!

Now, let's run the Skyland.gba ROM and see how it looks:

That's quite dramatic! :D

Now, suppose you ran into an issue when editing the script, and wanted to see what was going wrong. The game actually includes an onscreen script debugger! You can either use the breakpoint-register function to declare a breakpoint by specifying the symbol name of a function, or you can simply call the breakpoint function anywhere in a script.


(defn/temp handle-bluff ()
  (breakpoint)        
  ...)
    

If we rebuild and run the game again, we'll enter the debugger when hitting the breakpoint expression! Using the left and right dpad buttons, you can inspect variables, and look at the contents of the call stack, function arguments, etc.

Finally, I briefly mentioned the localization system when talking about the tr tags in the scripts. If you wanted to translate the game into a new language, you would just need to create new files in the strings directory (follow the Spanish translation as an example), and use the repack.sh script to zip the ROM back up! For more details about Skyland LISP, you can find a much more detailed description in the help/ folder that we unpacked from the ROM earlier! Have fun scripting!