Tools and Python libraries for manipulating Pico-8 game files. http://www.lexaloffle.com/pico-8.php
picotool: Tools and Python libraries for manipulating Pico-8 game files
Pico-8 is a fantasy game console by Lexaloffle Games. The Pico-8 runtime environment runs cartridges (or carts): game files containing code, graphics, sound, and music data. The console includes a built-in editor for writing games. Game cartridge files can be played in a browser, and can be posted to the Lexaloffle bulletin board or exported to any website.
picotoolis a suite of tools and libraries for building and manipulating Pico-8 game cartridge files. The suite is implemented in, and requires, Python 3. The tools can examine and transform cartridges in various ways, and you can implement your own tools to access and modify cartridge data with the Python libraries.
Useful tools include:
p8tool build: assembles cartridges from multiple sources, as part of a game development workflow
p8tool stats: reports statistics on one or many cartridge files
p8tool listlua: prints the Lua code of a cartridge
p8tool luafind: searches the Lua code of a collection of cartridges
p8tool luafmt: formats the Lua code of a cartridge to make it easier to read
There are additional tools that are mostly useful for demonstrating and troubleshooting the library:
writep8,
listtokens,
printast,
luamin. A separate demo,
p8upsidedown, uses picotool to transform the code and data of a game to turn it upsidedown.
picotoolsupports reading and writing both of Pico-8's cartridge file formats: the text-based format
.p8, and the PNG-based binary format
.p8.png.
To install the
picotooltools and libraries:
picotool-master. When cloning the repo, this is just
picotool, or whatever you named it when you cloned it.
python3 -m pip install pypng
To use a tool, you run the
p8toolcommand with the appropriate arguments. Without arguments, it prints a help message. The first argument is the name of the tool to run (such as
stats), followed by the arguments expected by that tool.
For example, to print statistics about a cart named
helloworld.p8.png:
./picotool-master/p8tool stats helloworld.p8.png
The
buildtool creates or updates a cartridge file using other files as sources. It is intended as a part of a game development workflow, producing the final output cartridge.
The tool takes the filename of the output cartridge, with additional arguments describing the build. If the output cartridge does not exist, the build starts with an empty cartridge. Otherwise, it uses the existing cartridge as the default, and overwrites sections of it based on the arguments.
For example, you can create a cartridge in Pico-8, use Pico-8's built-in graphics and sound editors, then use
p8tool buildto replace the Lua code with the contents of a
.luafile:
% ./picotool-master/p8tool build mygame.p8.png --lua mygame.lua
As another example, to create a new cartridge using the spritesheet (
gfx) from one cartridge file, music (
sfx,
music) from another, and Lua code from a
.luafile:
% ./picotool-master/p8tool build mygame.p8.png --gfx mygamegfx.p8 --sfx radsnds.p8.png --music radsnds.p8.png --lua mygame.lua
You can also erase a section of an existing cart with an argument such as
--empty-map.
The available arguments are as follows:
--lua LUA: use Lua code from the given cart or
.luafile
--gfx GFX: use spritesheet from the given cart
--gff GFF: use sprite flags from the given cart
--map MAP: use map from the given cart
--sfx SFX: use sound effects from the given cart
--music MUSIC: use music patterns from the given cart
--empty-lua: use an empty Lua code section
--empty-gfx: use an empty spritesheet
--empty-gff: use empty sprite flags
--empty-map: use an empty map
--empty-sfx: use empty sound effects
--empty-music: use empty music patterns
If the output cart filename ends with
.p8.png, the result a cartridge with a label image. If the file already exists, the cartridge label is reused. If the file does not exist, an empty cartridge label is used. To use a non-empty label, you must open the cart in Pico-8, take a screenshot (press F6 while running), set the title and byline in the first two lines of code (as Lua comments), then save the
.p8.pngfile from Pico-8. Future runs of
p8tool buildwill reuse the label.
p8tool build supports a special feature for organizing your Lua code, called packages. When loading Lua code from a file with the
--lua mygame.luaargument, your program can call a function named
require()to load Lua code from another file. This is similar to the
require()function available in some other Lua environments, with some subtle differences due to how picotool does this at build time instead of at run time.
Consider the following simple example. Say you have a function you like to use in several games in a file called
mylib.lua:
function handyfunc(x, y) return x + y endhandynumber = 3.14
Your main game code is in a file named
mygame.lua. To use the
handyfunc()function within
mygame.lua, call
require()to load it:
require("mylib")result = handyfunc(2, 3) print(result)
r = 5 area = handynumber * r * r
All globals defined in the required file are set as globals in your program when
require()is called. While this is easy enough to understand, this has the disadvantage of polluting the main program's global namespace.
A more typical way to write a Lua package is to put everything intended to be used by other programs in a table:
HandyPackage = { handyfunc = function(x, y) return x + y end, handynumber = 3.14, }
Then in
mygame.lua:
require("mylib")result = HandyPackage.handyfunc(2, 3)
This is cleaner, but still has the disadvantage that the package must be known by the global name
HandyPackagewihtin
mygame.lua. To fix this, Lua packages can return a value with the
returnstatement. This becomes the return value for the
require()call. Furthermore, Lua packages can declare
localvariables that are not accessible to the main program. You can use these features to hide explicit names and return the table for the package:
local HandyPackage = { handyfunc = function(x, y) return x + y end, handynumber = 3.14, }return HandyPackage
The main program uses the return value of
require()to access the package:
HandyPackage = require("mylib")result = HandyPackage.handyfunc(2, 3)
The
require()function only evaluates the package's code once. Subsequent calls to
require()with the same string name do not reevaluate the code. They just return the package's return value. Packages can safely require other packages, and only the first encountered
require()call evaluates the package's code.
The first argument to
require()is a string name. picotool finds the file that goes with the string name using a library lookup path. This is a semicolon-delimited (
;) list of filesystem path patterns, where each pattern uses a question mark (
?) where the string name would go.
The default lookup path is
?;?.lua. With this path,
require("mylib")would check for a file named
mylib, then for a file named
mylib.lua, each in the same directory as the file containing the
require()call. The lookup path can also use absolute filesystem paths (such as
/usr/share/pico8/lib/?.lua). You can customize the lookup path either by passing the
--lua-path=...argument on the command line, or by setting the PICO8LUAPATH environment variable.
For example, with this environment variable set:
PICO8_LUA_PATH=?;?.lua;/home/dan/p8libs/?/?.p8
The require("3dlib") statement would look for these files, in this order, with paths relative to the file containing the require() statement:
3dlib 3dlib.lua /home/dan/p8libs/3dlib/3dlib.p8
To prevent malicious code from accessing arbitrary files on your hard drive (unlikely but it's nice to prevent it), the
require()string cannot refer to files in parent directories with
../. It can refer to child directories, such as
require("math/linear").
As with Lua, packages are remembered by the string name used with
require(). This means it is possible to have two copies of the same package, each known by a different name, if it can be reached two ways with the lookup path. For example, if the file is named
foo.luaand the lookup path is
?;?.lua,
require("foo")and
require("foo.lua")treat the same file as two different packages.
When you write a library of routines for Pico-8, you probably want to write test code for those routines. picotool assumes that this test code would be executed in a Pico-8 game loop, such that the library can be in its own test cart. For this purpose, you can write your library file with
_init(),
_update()or
_update60(), and
_draw()functions that test the library. By default,
require()will strip the game loop functions from the library when including it in your game code so they don't cause conflicts or consume tokens.
For example:
local HandyPackage = { handyfunc = function(x, y) return x + y end, handynumber = 3.14, }function _update() test1 = HandyPackage.handyfunc(2, 3) end function _draw() cls() print('test1 = '..test1) end
return HandyPackage
If you want to keep the game loop functions present in a package, you can request them with a second argument to
require(), like so:
require("mylib", {use_game_loop=true})
Of course, Pico-8 does not actually load packages from disk when it runs the cartridge. Instead, picotool inserts each package into the cartridge code in a special way that replicates the behavior of the Lua
require()feature.
When you run
p8tool buildwith the
--lua=...argument, picotool scans the code for calls to the
require()function. If it sees any, it loads and parses the file associated with the string name, and does so again if the required file also has
require()calls.
Each required library is stored once as a function object in a table inserted at the top of the final cartridge's code. A definition of the
require()function is also inserted that finds and evaluates the package code in the table as needed.
To match Lua's behavior,
require()maintains a table named
package.loadedthat maps string names to return values. As with Lua, you can reset this value to
nilto force a
require()to reevaluate a package.
This feature incurs a small amount of overhead in terms of tokens. Each library uses tokens for its own code, plus a few additional tokens for storing it in the table. The definition for
require()is another 40 tokens or so. Naturally, the inserted code also consumes characters.
You can tell
p8tool buildto format or minify the code in the built output using the
--lua-formator
--lua-minifycommand line arguments, respectively.
% ./picotool-master/p8tool build mycart.p8.png --lua=mygame.lua --lua-format
This is equivalent to building the cart then running
p8tool luafmtor
p8tool luaminon the result.
The
statstool prints statistics about one or more carts. Given one or more cart filenames, it analyzes each cart, then prints information about it.
% ./picotool-master/p8tool stats helloworld.p8.png hello world (helloworld.p8.png) by zep version: 0 lines: 48 chars: 419 tokens: 134
This command accepts an optional
--csvargument. If provided, the command prints the statistics in a CSV format suitable for importing into a spreadsheet. This is useful when tallying statistics about multiple carts for comparative analysis.
% ./picotool-master/p8tool --csv stats mycarts/*.p8* >cartstats.csv
The
listluatool extracts the Lua code from a cart, then prints it exactly as it appears in the cart.
% ./picotool-master/p8tool listlua helloworld.p8.png -- hello world -- by zept = 0
music(0)
function _update() t += 1 end
function _draw() cls()
...
The
luafmttool rewrites the Lua region of a cart to make it easier to read, using regular indentation and spacing. This does not change the token count, but it may increase the character count, depending on the initial state of the code.
The command takes one or more cart filenames as arguments. For each cart with a name like
xxx.p8.png, it writes a new cart with a name like
xxx_fmt.p8.
% ./picotool-master/p8tool luafmt helloworld.p8.png % cat helloworld_fmt.p8 pico-8 cartridge // http://www.pico-8.com version 5 __lua__ -- hello world -- by zept = 0
music(0)
function _update() t += 1 end
function _draw() cls()
for i=1,11 do for j0=0,7 do j = 7-j0 col = 7+j ...
By default, the indentation width is 2 spaces. You can change the desired indentation width by specifying the
--indentwidth=...argument:
% ./picotool-master/p8tool luafmt --indentwidth=4 helloworld.p8.png % cat helloworld_fmt.p8 ... function _update() t += 1 endfunction _draw() cls()
for i=1,11 do for j0=0,7 do j = 7-j0 col = 7+j
...
The current version of
luafmtis simple and mostly just adjusts indentation. It does not adjust spaces between tokens on a line, align elements to brackets, or wrap long lines.
The
luafindtool searches for a string or pattern in the code of one or more carts. The pattern can be a simple string or a regular expression that matches a single line of code.
Unlike common tools like
grep,
luafindcan search code in .p8.png carts as well as .p8 carts. This tool is otherwise not particularly smart: it's slow (it runs every file through the parser), and doesn't support fancier
grep-like features.
% ./picotool-master/p8tool luafind 'boards\[.*\]' *.p8* test_gol.p8.png:11: boards[1][y] = {} test_gol.p8.png:12: boards[2][y] = {} test_gol.p8.png:14: boards[1][y][x] = 0 test_gol.p8.png:15: boards[2][y][x] = 0 test_gol.p8.png:20:boards[1][60][64] = 1 test_gol.p8.png:21:boards[1][60][65] = 1 test_gol.p8.png:22:boards[1][61][63] = 1 test_gol.p8.png:23:boards[1][61][64] = 1 test_gol.p8.png:24:boards[1][62][64] = 1 test_gol.p8.png:30: return boards[bi][y][x] test_gol.p8.png:36: pset(x-1,y-1,boards[board_i][y][x] * alive_color) test_gol.p8.png:54: ((boards[board_i][y][x] == 1) and neighbors == 2)) then test_gol.p8.png:55: boards[other_i][y][x] = 1 test_gol.p8.png:57: boards[other_i][y][x] = 0
You can tell
luafindto just list the names of files containing the pattern without printing the lines using the
--listfilesargument. Here's an example that looks for carts that contain examples of Lua OO programming:
% ./picotool-master/p8tool luafind --listfiles 'self,' *.p8* 11243.p8.png 12029.p8.png 12997.p8.png 13350.p8.png 13375.p8.png 13739.p8.png 15216.p8.png ...
The
writep8tool writes a game's data to a
.p8file. This is mostly useful for converting a
.p8.pngfile to a
.p8file. If the input is a
.p8already, then this just makes a copy of the file. (This can be used to validate that the picotool library can output its input.)
The command takes one or more cart filenames as arguments. For each cart with a name like
xxx.p8.png, it writes a new cart with a name like
xxx_fmt.p8.
% ./picotool-master/p8tool writep8 helloworld.p8.png % cat helloworld_fmt.p8 pico-8 cartridge // http://www.pico-8.com version 5 __lua__ -- hello world -- by zept = 0
music(0)
function _update() t += 1 end
function _draw() cls()
...
The
luamintool rewrites the Lua region of a cart to use as few characters as possible. It does this by discarding comments and extraneous space characters, and renaming variables and functions. This does not change the token count.
The command takes one or more cart filenames as arguments. For each cart with a name like
xxx.p8.png, it writes a new cart with a name like
xxx_fmt.p8.
I don't recommend using this tool when publishing your games. Statistically, you will run out of tokens before you run out of characters, and minifying is unlikely to affect the compressed character count. Carts are more useful to the Pico-8 community if the code in a published cart is readable and well-commented. I only wrote
luaminbecause it's an obvious kind of code transformation to try with the library.
% ./picotool-master/p8tool luamin helloworld.p8.png % cat helloworld_fmt.p8 pico-8 cartridge // http://www.pico-8.com version 5 __lua__ a = 0 music(0) function _update() a += 1 end function _draw() cls() ...
The
listtokenstool is similar to
listlua, but it identifies which characters picotool recognizes as a single token.
% ./picotool-master/p8tool listtokens ./picotool-master/tests/testdata/helloworld.p8.png<0:t>< ><1:=>< ><2:0>
<3:music><4:(><5:0><6:)>
<7:function>< ><8:_update><9:(><10:)> < ><11:t>< ><12:+=>< ><13:1> <14:end>
<15:function>< ><16:_draw><17:(><18:)> < ><19:cls><20:(><21:)>
...
When picotool parses Lua code, it separates out comments, newlines, and spaces, as well as proper Lua tokens. The Lua tokens appear with an ascending number, illustrating how picotool counts the tokens. Non-token elements appear with similar angle brackets but no number. Newlines are rendered as is, without brackets, to make them easy to read.
Note: picotool does not currently count tokens the same way Pico-8 does. One purpose of
listtokensis to help troubleshoot and fix this discrepancy. See "Known issues."
The
printasttool prints a visualization of the abstract syntax tree (AST) determined by the parser. This is a representation of the structure of the Lua code. This is useful for understanding the AST structure when writing a new tool based on the picotool library.
% ./picotool-master/p8tool printast ./picotool-master/tests/testdata/helloworld.p8.pngChunk
...
picotool provides a general purpose library for accessing and manipulating Pico-8 cart data. You can add the
picotooldirectory to your
PYTHONPATHenvironment variable (or append
sys.pathin code), or just copy the
pico8module to the directory that contains your code.
The easiest way to load a cart from a file is with the
Game.from_filename()method, in the
pico8.game.gamemodule:
#!/usr/bin/env python3from pico8.game import game
g = game.Game.from_filename('mycart.p8.png') print('Tokens: {}'.format(g.lua.get_token_count()))
Aspects of the game are accessible as attributes of the
Gameobject:
lua
gfx
gff
map
sfx
music
Lua code is treated as bytestrings throughout the API. This is because Pico-8 uses a custom text encoding equivalent to lower ASCII plus arbitrary high characters for the glyph set. Take care to use b'bytestring literals' when creating or comparing values.
While the library in its current state is featureful enough for building simple tools, it is not yet ready to promise backwards compatibility in future releases. Feel free to mess with it, but please be patient if I change things.
If you want to change the picotool code and run its test suite, you will need the Nose test runner and the coverage tool. You can install these with
pip:
python3 -m pip install nose coverage
To run the test suite:
python3 run_tests.py
By default, this produces an HTML coverage report in the
coversubdirectory. Open
.../picotool-master/cover/index.htmlin a browser to see it.
For more known issues, see the issues list in the Github project: https://github.com/dansanderson/picotool/issues