GameMaker: sprite_add_sprite in Studio / Studio 2

On this fine day[1], we're[2] proud to announce that we're bringing back everyone's[3] favourite GameMaker 8.x function!

That's right, we're bringing back sprite_add_sprite! ๐ŸŽ‰

Now, you might be thinking either "this is not what I've expected" or "this is exactly what I expected [after reading the title] and I only have more questions now". You see, there's not always justification for things happening, sometimes they just do.

Should you actually need this function or if this introduction got you curious, take a seat and let me tell you a story (and some code),


[1]: Actually it's 2AM on a Sunday night as I'm writing this
[2]: Implementation by me and some original research by meseta.
[3]: This post is actually targeted at one specific person and you know who you are.

The thing

sprite_add_sprite was an obscure function in legacy versions of GameMaker.

It probably ranks about 4th on the "Most obscure resource-related GM functions" list, a list undoubtedly led by sprite_set_bbox, a function that evidently existed in some form since at least 2009 before finally being spotted as undocumented/unexposed on a presumably cloudy Scottish afternoon in 2018.

But, back to it, sprite_add_sprite is a function that allows the game to load images from GMSPR format, a GameMaker-specific format so obscure that our brief search revealed seemingly zero mentions of it's presumed structure.

Fortunately, as it turned out, it is but a zlib-compressed chunk of sprite's properties (image count, sizes, collision data) mixed with raw BGRA pixel data. Real GMSPR files are told apart from imposters by an header that's just 1234321 (as a 32-bit integer) followed by size of compressed data.

The code

If you've implemented binary format readers before, everything is incredibly straightforward here. The format is very much just

assume all numbers to be signed 32-bit integers
- decimal 1234321
- size of compressed data in bytes
everything from hereafter is compressed via zlib
- format version (always 800)
- x origin
- y origin
- number of subimages
for each subimage:
  - format version again (to be sure, I guess)
  - width
  - height
  - size of BGRA data in bytes
  - actual BGRA data
- collision mask kind { precise = 0, rectangular, disk, diamond }
- collision mask tolerance (0-255)
- whether to use separate masks per frame (yeah this is int32 too)
- bounding box kind { automatic = 0, full image, user-defined }
- bounding box left
- bounding box right
- bounding box bottom
- bounding box top

If you've not, well, I can reassure you that this will make way more sense once you are familiar with buffer functions.

/// sprite_add_sprite(path)
/// @param path
var gmspr/*:Buffer*/ = buffer_load(argument0);
if (gmspr < 0) return -1;
if (buffer_read(gmspr, buffer_s32) != 1234321) {
    buffer_delete(gmspr);
    return -1;
}
var size = buffer_read(gmspr, buffer_s32);
var compressed/*:Buffer*/ = buffer_create(size, buffer_fixed, 1);
buffer_copy(gmspr, buffer_tell(gmspr), size, compressed, 0);
buffer_delete(gmspr);
var raw/*:Buffer*/ = buffer_decompress(compressed);
buffer_delete(compressed);
if (raw < 0) return -1;
var version = buffer_read(raw, buffer_s32); // we don't really care, it's always 800
var xorig = buffer_read(raw, buffer_s32);
var yorig = buffer_read(raw, buffer_s32);
var count = buffer_read(raw, buffer_s32);
if (count <= 0) {
    buffer_delete(raw);
    return -1;
}
var stripsurf/*:Surface*/ = -1;
var framebuf/*:Buffer*/ = -1;
var framesurf/*:Surface*/ = -1;
var width, height, i;
for (i = 0; i < count; i++) {
    version = buffer_read(raw, buffer_s32); // we still don't really care
    width = buffer_read(raw, buffer_s32);
    height = buffer_read(raw, buffer_s32);
    size = buffer_read(raw, buffer_s32);
    if (i == 0) { // initialize things on first call
        framebuf = buffer_create(size, buffer_fast, 1);
        if (count > 1) { // make the strip surface if we've multiple frames
            stripsurf = surface_create(width * count, height);
            surface_set_target(stripsurf);
            draw_clear_alpha(0, 0);
            surface_reset_target();
        }
        framesurf = surface_create(width, height);
        surface_set_target(framesurf);
        draw_clear_alpha(0, 0);
        surface_reset_target();
    }
    buffer_copy(raw, buffer_tell(raw), size, framebuf, 0);
    buffer_seek(raw, buffer_seek_relative, size);
    // fix R/B channels being swapped:
    for (var k = 0; k < size; k += 4) {
        var v = buffer_peek(framebuf, k, buffer_u8);
        buffer_poke(framebuf, k, buffer_u8, buffer_peek(framebuf, k + 2, buffer_u8));
        buffer_poke(framebuf, k + 2, buffer_u8, v);
    }
    buffer_set_surface(framebuf, framesurf, 0, 0, 0);
    if (count > 1) surface_copy(stripsurf, width * i, 0, framesurf);
}
// create the sprite and add the frames to it:
var spr/*:Sprite*/ = sprite_create_from_surface(
    count > 1 ? stripsurf : framesurf,
    0, 0, width, height,  false, false, xorig, yorig);
for (i = 1; i < count; i++) {
    sprite_add_from_surface(spr, stripsurf, i * width, 0, width, height, false, false);
}
// free up the frame stuff:
if (count > 1) surface_free(stripsurf);
surface_free(framesurf);
buffer_delete(framebuf);
// set up collision mask:
var colKind = buffer_read(raw, buffer_s32);
var colTol = buffer_read(raw, buffer_s32);
var sepMasks = buffer_read(raw, buffer_s32);
var bbMode = buffer_read(raw, buffer_s32);
var bbLeft = buffer_read(raw, buffer_s32);
var bbRight = buffer_read(raw, buffer_s32);
var bbBottom = buffer_read(raw, buffer_s32);
var bbTop = buffer_read(raw, buffer_s32);
sprite_collision_mask(spr, sepMasks, bbMode, bbLeft, bbTop, bbRight, bbBottom,
    colKind, colTol);
// cleanup the data buffer, and done!
buffer_delete(raw);
return spr;

Note: in GameMaker: Studio (non-GMS2), you'll need to take this extension and replace

var raw/*:Buffer*/ = buffer_decompress(compressed);

by

var raw/*:Buffer*/ = buffer_inflate(compressed, 0, size);

If you wanted to squeeze maximum performance out of this, you could rewrite the reader in a C++ extension so that it'd first tell GML how large of a buffer and surface it needs to make, and then would fill up the buffer with RGBA so that you could buffer_set_surface the entire strip at once.


Have fun!

Related posts:

Leave a Reply

Your email address will not be published. Required fields are marked *