2025-181

19:28:11, atom feed.

Today marks my first day working full-time on my own projects. I'm focusing on building a game. Toward learning how everything works end-to-end from first principles, I'm building a game engine. I will likely ship with SDL3, though.

My game will feature tilemaps built from pixel art tiles. I love using Aseprite for my art. At the time of this writing, Aseprite supports building tilemaps but only supports exporting them using an out-of-tool script. This script exports tilemaps and supporting data as JSON. I don't want to write or depend on a JSON parser, though, so I chose to fork and extend the script to output binary.

Aseprite scripts are written in Lua, so I needed to learn Lua. I half-jokingly searched for "learn lua in 10 minutes" and turned up Learn Lua in 15 Minutes. Using this, the official documentation, and asking an LLM questions here and there, I picked up Lua pretty quickly.

Some interesting things I learned are that Lua has a way to allow one Lua file to import another (require) which caches anything that the imported file runs on import so that when additional imports of the same file happen, nothing top-level runs again. There is another mode (dofile) which imports without caching, always running anything exposed at the top-level scope in the imported file. Another thing is that Lua only has a table data structure which doubles as an array. Array indices start from 1, not 0. You can use negative indicies when accessing the command-line arguments to find arguments passed to Lua itself, not to your script.

Anyway, without showing the full glue code, I introduced this binary.lua to the exporter script, added command-line argument parsing to the main script, and allowed the caller to either export JSON or binary. I'll stick with binary and write a simple parser in my game.

When calling a non-graphical script from Aseprite, you do so from the command line. You pass args before specifying the script. This is for at least Aseprite v1.3.14.3.

aseprite -b foo.aseprite --script-param bar=baz --script my_script.lua

The content of binary.lua in its initial form is below.

-- Export an Aseprite tilemap and supporting data to a binary file.
--
-- Output Schema
-- -------------
-- schema_major_ver: uint8_t
-- schema_minor_ver: uint8_t
-- schema_patch_ver: uint8_t
-- canvas_width_px : uint16_t
-- canvas_height_px: uint16_t
-- tilesets        : array<tileset>
-- layers          : array<layer>
--
-- array
-- -----
-- An array `array<T>` is a single `uint64_t` indicating the number of instances
-- of `T` immediately after. The instances of `T` are contiguous in memory.
-- For example, a 2-element array of type `array<uint8_t>` is laid out as
-- follows in memory:
--   uint64_t (num elements)
--   uint8_t  (first element)
--   uint8_t  (second element)
--
-- string
-- ------
-- A string is a single `uint64_t` indicating the number of characters
-- immediately following and then said number of characters. The characters are
-- 8-bit bytes. Each byte is a single ASCII character.
--
-- tileset
-- -------
-- image_pathname: string
-- tile_width_px : uint16_t
-- tile_height_px: uint16_t
--
-- layer
-- -----
-- name        : string
-- tileset_id  : uint16_t
-- width_tiles : uint16_t
-- height_tiles: uint16_t
-- tiles       : array<index_into_tileset>
--
-- index_into_tileset
-- ------------------
-- index: uint16_t
--
-- Notes:
-- - Strings do not support Unicode.
-- - All numbers are encoded as little-endian.
-- - The current schema only supports a single cel per layer.

local binary = { _version_major = 0, _version_minor = 0, _version_patch = 1 }

-- Write select keys out of table `t` to the open file `f` as binary. See
-- the output schema in the file documentation for what will be written out to
-- `f`.
function binary.encode(f, t)
  -- Schema version.
  f:write(string.pack("<I1", binary._version_major))
  f:write(string.pack("<I1", binary._version_minor))
  f:write(string.pack("<I1", binary._version_patch))

  -- Canvas width, height.
  f:write(string.pack("<I2", t.width))
  f:write(string.pack("<I2", t.height))

  -- Tilesets.
  f:write(string.pack("<I8", #t.tilesets))
  for i = 1, #t.tilesets do
    local ts = t.tilesets[i]
    f:write(string.pack("<I8", #ts.image))
    f:write(ts.image)
    f:write(string.pack("<I2", ts.grid.tileSize.width))
    f:write(string.pack("<I2", ts.grid.tileSize.height))
  end

  -- Layers.
  f:write(string.pack("<I8", #t.layers))
  for i = 1, #t.layers do
    local l = t.layers[i]
    f:write(string.pack("<I8", #l.name))
    f:write(l.name)
    f:write(string.pack("<I2", l.tileset))

    if #l.cels > 1 then
      error("Layer " .. i .. " has more than 1 cel")
    end

    local cel = l.cels[1]
    f:write(string.pack("<I2", cel.tilemap.width))
    f:write(string.pack("<I2", cel.tilemap.height))
    f:write(string.pack("<I8", #cel.tilemap.tiles))
    for j = 1, #cel.tilemap.tiles do
      f:write(string.pack("<I2", cel.tilemap.tiles[j]))
    end
  end
end

return binary