brdb 0.9.0

A library for reading and writing Brickadia's World files.
Documentation
use serde::{Deserialize, Serialize};

/// Written into `Meta/Bundle.json` as `gameVersion`. A neutral placeholder so
/// generated bundles don't bake in a specific changelist. Migrations are driven
/// by the saved data/schema, not this field.
pub const GAME_VERSION: &str = "CL0";

/// The all-zero GUID the game uses for unset ids (bundle `iD`, author ids of
/// bundles that never stored one).
pub const ZERO_GUID: &str = "00000000-0000-0000-0000-000000000000";

/// One entry of Bundle.json's `authors` array as the game writes it.
#[derive(Debug, Clone, Serialize)]
pub struct BundleAuthor {
    #[serde(rename = "iD")]
    pub id: String,
    pub name: String,
}

impl<'de> Deserialize<'de> for BundleAuthor {
    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
        #[derive(Deserialize)]
        #[serde(untagged)]
        enum Repr {
            Object {
                #[serde(rename = "iD")]
                id: String,
                name: String,
            },
            // Bundles generated by this crate before 0.7 stored bare names.
            Name(String),
        }
        Ok(match Repr::deserialize(d)? {
            Repr::Object { id, name } => BundleAuthor { id, name },
            Repr::Name(name) => BundleAuthor {
                id: ZERO_GUID.to_string(),
                name,
            },
        })
    }
}

/// Bundle.json `color` (RGBA, 0..=1 floats; the game writes whole numbers
/// for white).
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct BundleColor {
    pub r: f64,
    pub g: f64,
    pub b: f64,
    pub a: f64,
}

impl Default for BundleColor {
    fn default() -> Self {
        Self {
            r: 1.0,
            g: 1.0,
            b: 1.0,
            a: 1.0,
        }
    }
}

#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct BundleJson {
    #[serde(rename = "type")]
    pub level_type: String,
    #[serde(rename = "iD")]
    pub id: String,
    pub name: String,
    pub version: String,
    pub tags: Vec<String>,
    pub authors: Vec<BundleAuthor>,
    #[serde(rename = "createdAt")]
    pub created_at: String,
    #[serde(rename = "updatedAt")]
    pub updated_at: String,
    pub description: String,
    /// Absent in bundles written by this crate before 0.7; the game always
    /// writes it.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub color: Option<BundleColor>,
    // Unknown content
    pub dependencies: Vec<serde_json::Value>,
    #[serde(rename = "gameVersion")]
    pub game_version: String,
}

impl Default for BundleJson {
    fn default() -> Self {
        Self {
            level_type: "World".to_string(),
            id: ZERO_GUID.to_string(),
            name: "".to_string(),
            version: "".to_string(),
            tags: vec![],
            authors: vec![],
            created_at: "0001.01.01-00.00.00".to_string(),
            updated_at: "0001.01.01-00.00.00".to_string(),
            description: "A Generated World".to_string(),
            color: None,
            dependencies: vec![],
            game_version: GAME_VERSION.to_string(),
        }
    }
}

#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct WorldJson {
    pub environment: String,
}

impl Default for WorldJson {
    fn default() -> Self {
        Self {
            environment: "Plate".to_string(),
        }
    }
}

/// A float 3-vector as serialized in `Meta/Prefab.json` (pivot centers and
/// half-extents).
#[derive(Debug, Default, Clone, Copy, Deserialize, Serialize)]
pub struct PrefabVec3 {
    pub x: f64,
    pub y: f64,
    pub z: f64,
}

/// An integer 3-vector as serialized in `Meta/Prefab.json`
/// (`addedGlobalGridOffset`).
#[derive(Debug, Default, Clone, Copy, Deserialize, Serialize)]
pub struct PrefabIntVec3 {
    pub x: i32,
    pub y: i32,
    pub z: i32,
}

/// A single pivot: a center point and an axis-aligned half-extent, both in
/// grid-local brick units.
#[derive(Debug, Default, Clone, Copy, Deserialize, Serialize)]
pub struct PrefabPivot {
    pub center: PrefabVec3,
    #[serde(rename = "halfExtent")]
    pub half_extent: PrefabVec3,
}

impl PrefabPivot {
    /// Build a pivot from inclusive min/max corners (in brick units).
    pub fn from_bounds(min: crate::wrapper::Position, max: crate::wrapper::Position) -> Self {
        Self {
            center: PrefabVec3 {
                x: (min.x + max.x) as f64 / 2.0,
                y: (min.y + max.y) as f64 / 2.0,
                z: (min.z + max.z) as f64 / 2.0,
            },
            half_extent: PrefabVec3 {
                x: (max.x - min.x) as f64 / 2.0,
                y: (max.y - min.y) as f64 / 2.0,
                z: (max.z - min.z) as f64 / 2.0,
            },
        }
    }
}

/// The `pivots` object of `Meta/Prefab.json`. The game uses these for preview
/// and stud-snapping when placing a prefab.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct PrefabPivots {
    #[serde(rename = "bottomStudsPivot")]
    pub bottom_studs_pivot: PrefabPivot,
    #[serde(rename = "studsExpandedPivot")]
    pub studs_expanded_pivot: PrefabPivot,
    #[serde(rename = "topStudsPivot")]
    pub top_studs_pivot: PrefabPivot,
    #[serde(rename = "boundsPivot")]
    pub bounds_pivot: PrefabPivot,
    #[serde(rename = "bottomStudsDirection")]
    pub bottom_studs_direction: String,
    #[serde(rename = "topStudsDirection")]
    pub top_studs_direction: String,
    #[serde(rename = "bBottomStudsValid")]
    pub bottom_studs_valid: bool,
    #[serde(rename = "bTopStudsValid")]
    pub top_studs_valid: bool,
}

impl Default for PrefabPivots {
    fn default() -> Self {
        Self {
            bottom_studs_pivot: PrefabPivot::default(),
            studs_expanded_pivot: PrefabPivot::default(),
            top_studs_pivot: PrefabPivot::default(),
            bounds_pivot: PrefabPivot::default(),
            bottom_studs_direction: "Z_Negative".to_string(),
            top_studs_direction: "Z_Positive".to_string(),
            bottom_studs_valid: true,
            top_studs_valid: true,
        }
    }
}

/// `Meta/Prefab.json` — present only for `type: "Prefab"` bundles. Describes
/// the prefab's bounds/pivots and grid flags so the game can preview and place
/// it. Build one from a brick bounding box with [`PrefabJson::from_bounds`].
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct PrefabJson {
    pub pivots: PrefabPivots,
    #[serde(rename = "addedGlobalGridOffset")]
    pub added_global_grid_offset: PrefabIntVec3,
    #[serde(rename = "bIsPhysicsGrid")]
    pub is_physics_grid: bool,
    #[serde(rename = "bFreezePhysicsGrid")]
    pub freeze_physics_grid: bool,
    #[serde(rename = "bFreezeGlobalGrid")]
    pub freeze_global_grid: bool,
    #[serde(rename = "bIsMicrochipPrefab")]
    pub is_microchip_prefab: bool,
}

impl Default for PrefabJson {
    fn default() -> Self {
        Self {
            pivots: PrefabPivots::default(),
            added_global_grid_offset: PrefabIntVec3::default(),
            is_physics_grid: false,
            freeze_physics_grid: false,
            freeze_global_grid: false,
            is_microchip_prefab: false,
        }
    }
}

impl PrefabJson {
    /// Build a prefab from an inclusive brick bounding box (in brick units).
    /// All four pivots are set to the bounds box; refine the stud pivots later
    /// if precise snapping is needed.
    pub fn from_bounds(min: crate::wrapper::Position, max: crate::wrapper::Position) -> Self {
        let pivot = PrefabPivot::from_bounds(min, max);
        Self {
            pivots: PrefabPivots {
                bottom_studs_pivot: pivot,
                studs_expanded_pivot: pivot,
                top_studs_pivot: pivot,
                bounds_pivot: pivot,
                ..Default::default()
            },
            ..Default::default()
        }
    }
}

#[derive(Debug, Default, Clone, Deserialize, Serialize)]
pub struct WorldMeta {
    /// Meta/Bundle.json
    pub bundle: BundleJson,
    /// Meta/Screenshot.jpg
    pub screenshot: Option<Vec<u8>>,
    /// Meta/Thumbnail.png
    pub thumbnail: Option<Vec<u8>>,
    /// Meta/World.json
    pub world: WorldJson,
    /// Meta/Prefab.json — written only when this is a prefab bundle.
    /// When `Some`, the write path emits a prefab (Bundle.json + Prefab.json
    /// plus the optional Screenshot/Thumbnail; no World.json).
    pub prefab: Option<PrefabJson>,
}

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

    // Bundle.json in the exact shape the game writes (observed on CL0
    // prefab and world bundles; identifying values replaced).
    const GAME_BUNDLE: &str = r#"{
        "type": "Prefab",
        "iD": "00000000-0000-0000-0000-000000000000",
        "name": "1x1f spawner",
        "version": "",
        "tags": [],
        "authors": [{ "iD": "12345678-9abc-4def-8123-456789abcdef", "name": "author" }],
        "createdAt": "2026.07.07-17.38.40",
        "updatedAt": "2026.07.07-17.38.40",
        "description": "",
        "color": { "r": 1, "g": 1, "b": 1, "a": 1 },
        "dependencies": [],
        "gameVersion": "CL0"
    }"#;

    #[test]
    fn parses_game_bundle_json() {
        let b: BundleJson = serde_json::from_str(GAME_BUNDLE).unwrap();
        assert_eq!(b.level_type, "Prefab");
        assert_eq!(b.authors.len(), 1);
        assert_eq!(b.authors[0].id, "12345678-9abc-4def-8123-456789abcdef");
        assert_eq!(b.authors[0].name, "author");
        let c = b.color.unwrap();
        assert_eq!((c.r, c.g, c.b, c.a), (1.0, 1.0, 1.0, 1.0));
    }

    #[test]
    fn parses_legacy_string_authors_and_missing_color() {
        // Older crate-generated bundles: authors were plain strings, no color.
        let b: BundleJson = serde_json::from_str(
            r#"{"type":"World","iD":"00000000-0000-0000-0000-000000000000",
                "name":"x","version":"","tags":[],"authors":["author"],
                "createdAt":"0001.01.01-00.00.00","updatedAt":"0001.01.01-00.00.00",
                "description":"","dependencies":[],"gameVersion":"CL0"}"#,
        )
        .unwrap();
        assert_eq!(b.authors[0].name, "author");
        assert_eq!(b.authors[0].id, ZERO_GUID);
        assert!(b.color.is_none());
    }

    #[test]
    fn default_serialization_is_unchanged() {
        // Fixture-byte stability: default bundles keep authors:[] and omit color.
        let json = serde_json::to_string(&BundleJson::default()).unwrap();
        assert!(json.contains(r#""authors":[]"#));
        assert!(!json.contains("color"));
    }

    #[test]
    fn author_round_trip() {
        let b = BundleJson {
            authors: vec![BundleAuthor {
                id: "12345678-9abc-4def-8123-456789abcdef".into(),
                name: "author".into(),
            }],
            color: Some(BundleColor::default()),
            ..Default::default()
        };
        let json = serde_json::to_string(&b).unwrap();
        assert!(json.contains(
            r#""authors":[{"iD":"12345678-9abc-4def-8123-456789abcdef","name":"author"}]"#
        ));
        let back: BundleJson = serde_json::from_str(&json).unwrap();
        assert_eq!(back.authors[0].name, "author");
        assert!(back.color.is_some());
    }
}