spine_runtime 0.2.2

Unofficial native Rust port of the Spine 4.3 runtime. Renderer-agnostic.
Documentation
// Spine Runtimes License Agreement
// Last updated April 5, 2025. Replaces all prior versions.
//
// Copyright (c) 2013-2025, Esoteric Software LLC
//
// Integration of the Spine Runtimes into software or otherwise creating
// derivative works of the Spine Runtimes is permitted under the terms and
// conditions of Section 2 of the Spine Editor License Agreement:
// http://esotericsoftware.com/spine-editor-license
//
// Otherwise, it is permitted to integrate the Spine Runtimes into software
// or otherwise create derivative works of the Spine Runtimes (collectively,
// "Products"), provided that each user of the Products must obtain their own
// Spine Editor license and redistribution of the Products in any form must
// include this license and copyright notice.
//
// THE SPINE RUNTIMES ARE PROVIDED BY ESOTERIC SOFTWARE LLC "AS IS" AND ANY
// EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
// DISCLAIMED. IN NO EVENT SHALL ESOTERIC SOFTWARE LLC BE LIABLE FOR ANY
// DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES,
// BUSINESS INTERRUPTION, OR LOSS OF USE, DATA, OR PROFITS) HOWEVER CAUSED AND
// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
// THE SPINE RUNTIMES, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

//! Primitive readers for Spine's big-endian binary format.
//!
//! Wire layout, as in `spine-cpp/SkeletonBinary.cpp`:
//!
//! - Integers: big-endian 4-byte `i32`.
//! - Floats: big-endian IEEE 754, the same bits as an integer.
//! - Varints: 1 to 5 bytes, low 7 bits first, high bit means more follow.
//!   Signed values are zigzag-encoded.
//! - Strings: varint length `n`. `n == 0` means `None`; otherwise `n - 1`
//!   bytes of UTF-8 follow, with no trailing NUL.
//! - Colors: 4 bytes RGBA, each `u8 / 255.0`.
//!
//! The reader never panics on malformed input. Errors carry the byte offset
//! where the problem was found.

// Varints return `i32` in both modes, as spine-cpp's `readVarint` does, so
// unsigned bit patterns are reinterpreted as signed.
#![allow(clippy::cast_possible_wrap, clippy::cast_lossless)]

use thiserror::Error;

use crate::load::AttachmentLoaderError;
use crate::math::Color;

/// Errors produced while parsing a `.skel` file. `at` is a 0-based byte
/// offset from the start of the buffer.
#[derive(Debug, Error)]
pub enum BinaryError {
    /// The buffer ended `wanted` bytes short of a value.
    #[error("unexpected end of input at byte {at} (wanted {wanted} more byte(s))")]
    UnexpectedEof { at: usize, wanted: usize },

    /// A varint's fifth byte had its continuation bit set.
    #[error("byte {at}: varint overflowed 32 bits")]
    VarintOverflow { at: usize },

    #[error("byte {at}: string is not valid UTF-8: {source}")]
    InvalidUtf8 {
        at: usize,
        #[source]
        source: std::str::Utf8Error,
    },

    /// A string-table reference past the end of the table. `index` is
    /// 0-based.
    #[error("byte {at}: string-table index {index} out of range ({len} entries)")]
    StringRefOutOfRange { at: usize, index: usize, len: usize },

    /// The export's version does not start with `4.3`.
    #[error("skeleton version mismatch: file reports {found:?}, runtime targets {expected:?}")]
    UnsupportedVersion { found: String, expected: String },

    /// A reference to a bone, slot, constraint, skin or event that does not
    /// exist. `at` is 0 for linked-mesh skins, which are
    /// checked after the whole skin section is read.
    #[error("byte {at}: {entity} index {index} out of range ({len} entries)")]
    IndexOutOfRange {
        at: usize,
        entity: &'static str,
        index: usize,
        len: usize,
    },

    /// An enum or type tag outside the known set, or a constraint
    /// reference to a constraint of the wrong kind (then `value` is its
    /// index).
    #[error("byte {at}: unknown {entity} discriminant {value}")]
    UnknownDiscriminant {
        at: usize,
        entity: &'static str,
        value: u32,
    },

    /// A named attachment missing from its skin and slot: the source of a
    /// linked mesh (`at` is 0) or the target of an attachment timeline.
    #[error("byte {at}: linked-mesh parent {parent:?} not found on skin {skin:?} slot {slot}")]
    LinkedMeshParentMissing {
        at: usize,
        skin: String,
        slot: usize,
        parent: String,
    },

    /// The [`AttachmentLoader`](crate::load::AttachmentLoader) returned an
    /// error.
    #[error("attachment loader failed: {0}")]
    AttachmentLoader(#[from] AttachmentLoaderError),

    /// Not produced by the parser, which reads from memory.
    #[error("io error: {0}")]
    Io(#[from] std::io::Error),
}

/// Cursor-style reader over a `&[u8]` buffer.
pub(crate) struct BinaryReader<'a> {
    buf: &'a [u8],
    pos: usize,
}

impl<'a> BinaryReader<'a> {
    pub fn new(buf: &'a [u8]) -> Self {
        Self { buf, pos: 0 }
    }

    /// Current byte offset from the start of the buffer.
    pub fn position(&self) -> usize {
        self.pos
    }

    fn need(&self, n: usize) -> Result<(), BinaryError> {
        if self.pos + n > self.buf.len() {
            Err(BinaryError::UnexpectedEof {
                at: self.pos,
                wanted: n,
            })
        } else {
            Ok(())
        }
    }

    pub fn read_byte(&mut self) -> Result<u8, BinaryError> {
        self.need(1)?;
        let b = self.buf[self.pos];
        self.pos += 1;
        Ok(b)
    }

    pub fn read_sbyte(&mut self) -> Result<i8, BinaryError> {
        self.read_byte().map(|b| b as i8)
    }

    pub fn read_bool(&mut self) -> Result<bool, BinaryError> {
        self.read_byte().map(|b| b != 0)
    }

    /// Big-endian signed 32-bit integer.
    pub fn read_int(&mut self) -> Result<i32, BinaryError> {
        self.need(4)?;
        let b0 = self.buf[self.pos];
        let b1 = self.buf[self.pos + 1];
        let b2 = self.buf[self.pos + 2];
        let b3 = self.buf[self.pos + 3];
        self.pos += 4;
        Ok(i32::from_be_bytes([b0, b1, b2, b3]))
    }

    /// Big-endian IEEE 754 single-precision float.
    pub fn read_float(&mut self) -> Result<f32, BinaryError> {
        self.read_int().map(|bits| f32::from_bits(bits as u32))
    }

    /// Variable-length integer. `optimize_positive` reads it as unsigned
    /// (values above `i32::MAX` wrap negative); otherwise it is zigzag-decoded.
    /// Payload bits past 32 are dropped, as in spine-cpp.
    ///
    /// # Errors
    /// [`BinaryError::VarintOverflow`] if the fifth byte has its continuation
    /// bit set. spine-cpp ignores that bit.
    pub fn read_varint(&mut self, optimize_positive: bool) -> Result<i32, BinaryError> {
        let start = self.pos;
        let mut b = self.read_byte()?;
        let mut value = u32::from(b & 0x7F);
        if b & 0x80 != 0 {
            b = self.read_byte()?;
            value |= u32::from(b & 0x7F) << 7;
            if b & 0x80 != 0 {
                b = self.read_byte()?;
                value |= u32::from(b & 0x7F) << 14;
                if b & 0x80 != 0 {
                    b = self.read_byte()?;
                    value |= u32::from(b & 0x7F) << 21;
                    if b & 0x80 != 0 {
                        b = self.read_byte()?;
                        value |= u32::from(b & 0x7F) << 28;
                    }
                }
            }
        }
        if self.pos == start + 5 && (b & 0x80) != 0 {
            return Err(BinaryError::VarintOverflow { at: start });
        }

        Ok(if optimize_positive {
            value as i32
        } else {
            // Zigzag: 0→0, 1→-1, 2→1, 3→-2, 4→2, ...
            let sign_mask = (value & 1).wrapping_neg();
            ((value >> 1) ^ sign_mask) as i32
        })
    }

    /// Unsigned varint as `usize`, for counts and indices.
    pub fn read_uvarint(&mut self) -> Result<usize, BinaryError> {
        let v = self.read_varint(true)?;
        Ok(v as u32 as usize)
    }

    /// Length-prefixed string. A zero length means `None`.
    pub fn read_string(&mut self) -> Result<Option<String>, BinaryError> {
        let len = self.read_uvarint()?;
        if len == 0 {
            return Ok(None);
        }
        let payload_len = len - 1;
        self.need(payload_len)?;
        let start = self.pos;
        let slice = &self.buf[start..start + payload_len];
        self.pos += payload_len;
        std::str::from_utf8(slice)
            .map(|s| Some(s.to_string()))
            .map_err(|source| BinaryError::InvalidUtf8 { at: start, source })
    }

    /// Reference into the file's string table, stored as `index + 1`. `0`
    /// means `None`.
    pub fn read_string_ref(&mut self, strings: &[String]) -> Result<Option<String>, BinaryError> {
        let at = self.pos;
        let index = self.read_uvarint()?;
        if index == 0 {
            return Ok(None);
        }
        let idx = index - 1;
        strings
            .get(idx)
            .cloned()
            .map(Some)
            .ok_or(BinaryError::StringRefOutOfRange {
                at,
                index: idx,
                len: strings.len(),
            })
    }

    /// 4-byte RGBA color (each channel `u8 / 255.0`).
    pub fn read_color(&mut self) -> Result<Color, BinaryError> {
        let r = f32::from(self.read_byte()?) / 255.0;
        let g = f32::from(self.read_byte()?) / 255.0;
        let b = f32::from(self.read_byte()?) / 255.0;
        let a = f32::from(self.read_byte()?) / 255.0;
        Ok(Color::new(r, g, b, a))
    }
}

#[cfg(test)]
#[allow(clippy::float_cmp)]
mod tests {
    use super::*;

    #[test]
    fn read_byte_sbyte_bool() {
        let mut r = BinaryReader::new(&[0x00, 0xFF, 0x7F, 0x80]);
        assert_eq!(r.read_byte().unwrap(), 0x00);
        assert!(r.read_bool().unwrap());
        assert_eq!(r.read_sbyte().unwrap(), 0x7F);
        assert_eq!(r.read_sbyte().unwrap(), -128);
    }

    #[test]
    fn read_int_is_big_endian() {
        let mut r = BinaryReader::new(&[0x00, 0x00, 0x00, 0x2A]);
        assert_eq!(r.read_int().unwrap(), 42);

        let mut r = BinaryReader::new(&[0xFF, 0xFF, 0xFF, 0xFF]);
        assert_eq!(r.read_int().unwrap(), -1);

        let mut r = BinaryReader::new(&[0x12, 0x34, 0x56, 0x78]);
        assert_eq!(r.read_int().unwrap(), 0x1234_5678);
    }

    #[test]
    fn read_float_reinterprets_int_bits() {
        // 1.0f32 = 0x3F800000 in IEEE 754.
        let mut r = BinaryReader::new(&[0x3F, 0x80, 0x00, 0x00]);
        assert_eq!(r.read_float().unwrap(), 1.0);
    }

    #[test]
    fn read_varint_positive_single_byte() {
        let mut r = BinaryReader::new(&[0x2A]);
        assert_eq!(r.read_varint(true).unwrap(), 42);
    }

    #[test]
    fn read_varint_positive_multi_byte() {
        // 300 = 0xAC 0x02 in Spine's little-endian-by-group scheme:
        // low 7 bits = 0x2C (0b0101100) with continuation → 0xAC, then 0x02 (high bits).
        let mut r = BinaryReader::new(&[0xAC, 0x02]);
        assert_eq!(r.read_varint(true).unwrap(), 300);
    }

    #[test]
    fn read_varint_zigzag_negative() {
        // -1 zigzag-encodes to 1 = 0x01. 1 encodes to 2 = 0x02. -2 encodes to 3 = 0x03.
        let mut r = BinaryReader::new(&[0x01, 0x02, 0x03]);
        assert_eq!(r.read_varint(false).unwrap(), -1);
        assert_eq!(r.read_varint(false).unwrap(), 1);
        assert_eq!(r.read_varint(false).unwrap(), -2);
    }

    #[test]
    fn read_string_none_on_zero_length() {
        let mut r = BinaryReader::new(&[0x00]);
        assert_eq!(r.read_string().unwrap(), None);
    }

    #[test]
    fn read_string_payload_is_length_minus_one_bytes() {
        // "hi" is 2 bytes of payload. Length prefix on the wire is 3.
        let mut r = BinaryReader::new(&[0x03, b'h', b'i']);
        assert_eq!(r.read_string().unwrap().as_deref(), Some("hi"));
    }

    #[test]
    fn read_string_ref_resolves_to_table() {
        let strings = vec!["alpha".to_string(), "beta".to_string()];
        let mut r = BinaryReader::new(&[0x00, 0x01, 0x02]);
        assert_eq!(r.read_string_ref(&strings).unwrap(), None);
        assert_eq!(
            r.read_string_ref(&strings).unwrap().as_deref(),
            Some("alpha")
        );
        assert_eq!(
            r.read_string_ref(&strings).unwrap().as_deref(),
            Some("beta")
        );
    }

    #[test]
    fn read_string_ref_out_of_range_is_error() {
        let strings = vec!["alpha".to_string()];
        let mut r = BinaryReader::new(&[0x03]);
        match r.read_string_ref(&strings) {
            Err(BinaryError::StringRefOutOfRange { index, len, .. }) => {
                assert_eq!(index, 2);
                assert_eq!(len, 1);
            }
            other => panic!("expected StringRefOutOfRange, got {other:?}"),
        }
    }

    #[test]
    fn read_color_rgba_normalized() {
        let mut r = BinaryReader::new(&[0xFF, 0x00, 0x80, 0x40]);
        let c = r.read_color().unwrap();
        assert_eq!(c.r, 1.0);
        assert_eq!(c.g, 0.0);
        assert!((c.b - 0x80 as f32 / 255.0).abs() < 1e-6);
        assert!((c.a - 0x40 as f32 / 255.0).abs() < 1e-6);
    }

    #[test]
    fn eof_surfaces_as_error() {
        let mut r = BinaryReader::new(&[0x00]);
        assert!(matches!(
            r.read_int(),
            Err(BinaryError::UnexpectedEof { .. })
        ));
    }

    #[test]
    fn position_tracks_cursor() {
        let mut r = BinaryReader::new(&[0x01, 0x02, 0x03, 0x04, 0x05]);
        assert_eq!(r.position(), 0);
        r.read_byte().unwrap();
        assert_eq!(r.position(), 1);
        r.read_int().unwrap();
        assert_eq!(r.position(), 5);
    }
}