Skip to main content

write_fixtures/
write_fixtures.rs

1//! Deterministic fixture generator for the brs-js cross-language test suite.
2//! Writes into crates/brdb/fixtures/: six worlds as raw + zstd-14 .brz,
3//! hashes.json (per-path BLAKE3 of decompressed payloads), and the nine
4//! embedded schemas serialized to binary msgpack.
5use std::collections::{BTreeMap, HashMap};
6use std::fs;
7use std::path::PathBuf;
8
9use brdb::fs::BrFs;
10use brdb::schema::WireVariant;
11use brdb::{
12    assets, schemas, AsBrdbValue, BrFsReader, Brdb, Brick, BrickSize, BrickType, Brz,
13    Collision, Direction, Entity, Guid, IntoReader, Owner, Rotation, SavedBrickColor, World,
14};
15
16const ALICE_UUID: &str = "a1b2c3d4-e5f6-4789-8abc-def012345678";
17const BOB_UUID: &str = "00112233-4455-6677-8899-aabbccddeeff";
18
19fn brick_world() -> World {
20    // Mirror of examples/write_brz.rs — the historical example_brick world.
21    let mut world = World::new();
22    world.meta.bundle.description = "Example World".to_string();
23    world.bricks.push(Brick {
24        position: (0, 0, 6).into(),
25        color: (255, 0, 0).into(),
26        ..Default::default()
27    });
28    world
29}
30
31fn features_world() -> World {
32    // Single chunk (all coords in [0, 2048)): registries, owners, procedural
33    // size run-length grouping (new/extend/reuse), a basic asset, collision
34    // and visibility variants, orientations, material intensity.
35    let mut world = World::new();
36    world.meta.bundle.description = "Feature fixture".to_string();
37
38    let alice = Guid::from_uuid(uuid::Uuid::parse_str(ALICE_UUID).unwrap());
39    let bob = Guid::from_uuid(uuid::Uuid::parse_str(BOB_UUID).unwrap());
40    world.owners.insert(alice, Owner {
41        user_id: alice,
42        user_name: "alice".to_string(),
43        display_name: "Alice".to_string(),
44    });
45    world.owners.insert(bob, Owner {
46        user_id: bob,
47        user_name: "bob".to_string(),
48        display_name: "Bob".to_string(),
49    });
50
51    let tile = |size: (u16, u16, u16)| BrickType::Procedural {
52        asset: assets::bricks::PB_DEFAULT_TILE, // "PB_DefaultTile"
53        size: BrickSize { x: size.0, y: size.1, z: size.2 },
54    };
55
56    // 1: default brick (PB_DefaultBrick 5x5x6, plastic, intensity 5) — new size slot
57    world.bricks.push(Brick {
58        position: (0, 0, 6).into(),
59        color: (255, 0, 0).into(),
60        owner_index: Some(1),
61        ..Default::default()
62    });
63    // 2: tile 10x10x2, metallic, intensity 7, XPositive/Deg90 — new counter entry
64    world.bricks.push(Brick {
65        asset: tile((10, 10, 2)),
66        position: (20, 0, 2).into(),
67        color: (0, 255, 0).into(),
68        owner_index: Some(1),
69        material: assets::materials::METALLIC, // "BMC_Metallic"
70        material_intensity: 7,
71        direction: Direction::XPositive,
72        rotation: Rotation::Deg90,
73        ..Default::default()
74    });
75    // 3: same tile size — size_index_map reuse
76    world.bricks.push(Brick {
77        asset: tile((10, 10, 2)),
78        position: (40, 0, 2).into(),
79        color: (0, 0, 255).into(),
80        owner_index: Some(2),
81        ..Default::default()
82    });
83    // 4: tile 20x20x2 — extends the tail counter (same asset, new size)
84    world.bricks.push(Brick {
85        asset: tile((20, 20, 2)),
86        position: (80, 0, 2).into(),
87        color: (255, 255, 0).into(),
88        owner_index: Some(2),
89        ..Default::default()
90    });
91    // 5: default brick again — reuses slot from brick 1 (map hit after other asset)
92    world.bricks.push(Brick {
93        position: (100, 0, 6).into(),
94        color: (255, 255, 255).into(),
95        owner_index: Some(1),
96        ..Default::default()
97    });
98    // 6: BASIC asset, PUBLIC owner, glow, partial collision, YNegative/Deg180
99    world.bricks.push(Brick {
100        asset: assets::bricks::B_2X2_OVERHANG, // "B_2x2_Overhang"
101        position: (200, 0, 10).into(),
102        color: (128, 64, 32).into(),
103        material: assets::materials::GLOW, // "BMC_Glow"
104        material_intensity: 3,
105        direction: Direction::YNegative,
106        rotation: Rotation::Deg180,
107        collision: Collision { player: false, ..Default::default() },
108        ..Default::default()
109    });
110    // 7: invisible, all-collision-off, tiny proc brick
111    world.bricks.push(Brick {
112        asset: BrickType::Procedural {
113            asset: assets::bricks::PB_DEFAULT_BRICK,
114            size: BrickSize { x: 2, y: 2, z: 2 },
115        },
116        position: (300, 0, 2).into(),
117        color: (10, 20, 30).into(),
118        owner_index: Some(1),
119        visible: false,
120        collision: Collision {
121            player: false,
122            weapon: false,
123            interact: false,
124            physics: false,
125            ..Default::default()
126        },
127        ..Default::default()
128    });
129    world
130}
131
132fn chunks_world() -> World {
133    // Multi-chunk: euclidean chunking with negative coords, plus chunks
134    // 0_0_0 and 1_0_0 carry byte-identical SoA payloads → blob dedup.
135    // NOTE: multi-chunk Rust output has nondeterministic FILE ORDER
136    // (HashMap iteration), so this fixture is hashes-gate only — never
137    // byte-compare its container.
138    let mut world = World::new();
139    world.meta.bundle.description = "Chunk fixture".to_string();
140    for (pos, color) in [
141        ((0, 0, 0), (1, 2, 3)),
142        ((-1, -1, -1), (4, 5, 6)),
143        ((2048, 0, 0), (1, 2, 3)),      // same rel pos + color as brick 1
144        ((-2048, 4096, 10), (10, 11, 12)),
145        ((500, 500, 500), (42, 42, 42)),
146        ((2548, 500, 500), (42, 42, 42)), // same rel pos + color as brick 5
147    ] {
148        world.bricks.push(Brick {
149            position: pos.into(),
150            color: color.into(),
151            ..Default::default()
152        });
153    }
154    world
155}
156
157fn wires_world() -> World {
158    // Mirror of examples/write_wire.rs (single grid, single chunk — fully
159    // deterministic), extended to 3 bricks/2 wires: a boolean NOT gate feeds
160    // one input of an AND gate, whose output feeds a rerouter.
161    let mut world = World::new();
162    world.register_all_components();
163    world.meta.bundle.description = "Wire fixture".to_string();
164
165    let (a, a_id) = Brick {
166        position: (30, 0, 1).into(),
167        color: (255, 0, 0).into(),
168        asset: assets::bricks::B_REROUTE,
169        ..Default::default()
170    }
171    .with_component(assets::components::Rerouter)
172    .with_id_split();
173    let (b, b_id) = Brick {
174        position: (15, 0, 1).into(),
175        color: (0, 255, 0).into(),
176        asset: assets::components::LogicGate::BoolAnd.brick(),
177        ..Default::default()
178    }
179    .with_component(assets::components::LogicGate::BoolAnd.component())
180    .with_id_split();
181    let (c, c_id) = Brick {
182        position: (0, 0, 1).into(),
183        color: (0, 0, 255).into(),
184        asset: assets::components::LogicGate::BoolNot.brick(),
185        ..Default::default()
186    }
187    .with_component(assets::components::LogicGate::BoolNot.component())
188    .with_id_split();
189
190    world.add_bricks([a, b, c]);
191    // Wire 1: NOT.output -> AND.inputA
192    world.add_wire_connection(
193        assets::components::LogicGate::BoolNot.output_of(c_id),
194        assets::components::LogicGate::BoolAnd.input_a_of(b_id),
195    );
196    // Wire 2: AND.output -> Rerouter.input
197    world.add_wire_connection(
198        assets::components::LogicGate::BoolAnd.output_of(b_id),
199        assets::components::Rerouter::input_of(a_id),
200    );
201
202    world
203}
204
205fn components_world() -> World {
206    // Single grid, single chunk (all positions < 2048) — fully deterministic.
207    // register_all_components() embeds the full component catalog so every
208    // type below (light/interact/wiregraph-pseudo/expr) resolves; each
209    // component below carries non-default property values, and #4/#5 exercise
210    // a WireGraphVariant-typed property (BufferTicks' Input/Output, and the
211    // Constant gate's Value).
212    let mut world = World::new();
213    world.register_all_components();
214    world.meta.bundle.description = "Component fixture".to_string();
215
216    // 1: Point light — non-default brightness/radius/color, decoupled from
217    // brick color (bUseBrickColor: false).
218    world.bricks.push(
219        Brick {
220            position: (0, 0, 6).into(),
221            color: (255, 255, 255).into(),
222            ..Default::default()
223        }
224        .with_component(assets::LiteralComponent::new("Component_PointLight").with_data([
225            ("bMatchBrickShape", Box::new(false) as Box<dyn AsBrdbValue>),
226            ("bEnabled", Box::new(true)),
227            ("Brightness", Box::new(500.0f32)),
228            ("Radius", Box::new(800.0f32)),
229            (
230                "Color",
231                Box::new(SavedBrickColor { r: 10, g: 20, b: 30, a: 255 }),
232            ),
233            ("bUseBrickColor", Box::new(false)),
234            ("bCastShadows", Box::new(true)),
235        ])),
236    );
237
238    // 2: Spot light — narrow cone, non-default brightness/color.
239    world.bricks.push(
240        Brick {
241            position: (20, 0, 6).into(),
242            color: (255, 255, 0).into(),
243            ..Default::default()
244        }
245        .with_component(assets::LiteralComponent::new("Component_SpotLight").with_data([
246            ("InnerConeAngle", Box::new(15.0f32) as Box<dyn AsBrdbValue>),
247            ("OuterConeAngle", Box::new(45.0f32)),
248            ("bEnabled", Box::new(true)),
249            ("Brightness", Box::new(300.0f32)),
250            ("Radius", Box::new(600.0f32)),
251            (
252                "Color",
253                Box::new(SavedBrickColor { r: 255, g: 0, b: 0, a: 255 }),
254            ),
255            ("bUseBrickColor", Box::new(false)),
256            ("bCastShadows", Box::new(true)),
257        ])),
258    );
259
260    // 3: Interact — custom prompt text, hidden interaction.
261    world.bricks.push(
262        Brick {
263            position: (40, 0, 6).into(),
264            color: (0, 255, 255).into(),
265            ..Default::default()
266        }
267        .with_component(assets::LiteralComponent::new("Component_Interact").with_data([
268            (
269                "Message",
270                Box::new("You interacted!".to_string()) as Box<dyn AsBrdbValue>,
271            ),
272            ("ConsoleTag", Box::new("fixture_interact".to_string())),
273            ("bAllowNearbyInteraction", Box::new(false)),
274            ("bHiddenInteraction", Box::new(true)),
275            ("PromptCustomLabel", Box::new("Open Door".to_string())),
276        ])),
277    );
278
279    // 4: Buffer (ticks) — WireGraphPseudo component whose Input/Output are
280    // WireGraphVariant; non-default counters plus a Number/Bool variant pair.
281    world.bricks.push(
282        Brick {
283            position: (60, 0, 1).into(),
284            color: (128, 0, 128).into(),
285            asset: assets::components::BufferTicks::default().brick(),
286            ..Default::default()
287        }
288        .with_component(assets::components::BufferTicks {
289            current_ticks: 3,
290            ticks_to_wait: 10,
291            input: WireVariant::Number(2.5),
292            output: WireVariant::Bool(true),
293        }),
294    );
295
296    // 5: Blend gate — WireGraph_Expr_MathBlend's InputA/InputB are each a
297    // WireGraphPrimMathVariant (here an f64 and an i64 tag, showing the
298    // variant is polymorphic per-port); Blend itself is a plain f64.
299    world.bricks.push(
300        Brick {
301            position: (80, 0, 1).into(),
302            color: (0, 128, 0).into(),
303            asset: assets::components::LogicGate::Blend.brick(),
304            ..Default::default()
305        }
306        .with_component(assets::components::LogicGate::Blend.component_with_overrides(
307            HashMap::from([
308                (
309                    "InputA".into(),
310                    Box::new(WireVariant::Number(10.0)) as Box<dyn AsBrdbValue>,
311                ),
312                ("InputB".into(), Box::new(WireVariant::Int(7))),
313                ("Blend".into(), Box::new(0.75f64)),
314            ]),
315        )),
316    );
317
318    world
319}
320
321fn entities_world() -> World {
322    // Mirror of examples/write_entity.rs's floating sub-grid — the first
323    // grid added after the always-present main grid 1 gets persistent index
324    // 2, so it lands at Grids/2 — plus a couple of main-grid (Grids/1)
325    // bricks. Both grids are single-chunk; note the *outer* Grids/1 vs
326    // Grids/2 folder order comes from a HashMap<usize, UnsavedGrid> in the
327    // writer and is NOT guaranteed stable run-to-run (see stability notes).
328    //
329    // register_all_components() is required here (matching
330    // test_write_entity_save, NOT the plain write_entity.rs example): without
331    // it, Entity_DynamicBrickGrid's class name is never registered and the
332    // write fails with `UnknownType("Entity_DynamicBrickGrid")` — confirmed
333    // by running the crate's own unmodified write_entity example.
334    let mut world = World::new();
335    world.register_all_components();
336    world.meta.bundle.description = "Entity fixture".to_string();
337
338    world.bricks.push(Brick {
339        position: (0, 0, 6).into(),
340        color: (200, 50, 50).into(),
341        ..Default::default()
342    });
343    world.bricks.push(Brick {
344        position: (20, 0, 6).into(),
345        color: (50, 200, 50).into(),
346        ..Default::default()
347    });
348
349    world.add_brick_grid(
350        Entity {
351            frozen: true,
352            location: (0.0, 0.0, 40.0).into(),
353            ..Default::default()
354        },
355        [Brick {
356            position: (0, 0, 3).into(),
357            color: (0, 255, 0).into(),
358            ..Default::default()
359        }],
360    );
361
362    world
363}
364
365fn spawner_world() -> World {
366    // Prefab-embedding fixture: an outer prefab whose spawner gate references
367    // an inner single-brick prefab embedded at Prefabs/Uploads/<BLAKE3>.brz.
368    // Single grid, single chunk, one insertion-ordered prefab map entry —
369    // fully deterministic. The inner archive is written raw (no zstd) so the
370    // embedded bytes are cross-language reproducible.
371    let mut inner = World::new();
372    inner.meta.bundle.description = "Inner prefab".to_string();
373    inner.bricks.push(Brick {
374        position: (0, 0, 6).into(),
375        color: (255, 0, 0).into(),
376        ..Default::default()
377    });
378    inner.make_prefab();
379    let inner_bytes = {
380        let pending = inner.to_unsaved().unwrap().to_pending().unwrap();
381        let mut buf = Vec::new();
382        pending
383            .to_brz_data(None)
384            .unwrap()
385            .write(&mut buf, None)
386            .unwrap();
387        buf
388    };
389
390    let mut world = World::new();
391    world.register_all_components();
392    world.meta.bundle.description = "Spawner fixture".to_string();
393    let prefab_path = world.add_prefab(inner_bytes);
394    world.bricks.push(
395        Brick {
396            asset: brdb::BrickType::str("B_1x1_Gate_Exec_PrefabSpawner"),
397            position: (0, 0, 1).into(),
398            ..Default::default()
399        }
400        .with_component(
401            assets::LiteralComponent::new("BrickComponentType_WireGraph_Exec_PrefabSpawner")
402                .with_data([(
403                    "Prefab",
404                    Box::new(prefab_path) as Box<dyn AsBrdbValue>,
405                )]),
406        ),
407    );
408    world.make_prefab();
409    world
410}
411
412fn hash_archive(path: &PathBuf) -> Result<BTreeMap<String, serde_json::Value>, Box<dyn std::error::Error>> {
413    fn collect(fs: &BrFs, prefix: &str, out: &mut Vec<(String, Option<i64>)>) {
414        match fs {
415            BrFs::Root(children) => for (n, c) in children { collect(c, n, out); },
416            BrFs::Folder(_, children) => for (n, c) in children {
417                collect(c, &format!("{prefix}/{n}"), out);
418            },
419            BrFs::File(f) => out.push((prefix.to_string(), f.content_id)),
420        }
421    }
422    let reader = Brz::open(path)?.into_reader();
423    let reader = &*reader;
424    let mut files = Vec::new();
425    collect(&reader.get_fs()?, "", &mut files);
426    let mut out = BTreeMap::new();
427    for (p, content_id) in files {
428        let content = match content_id {
429            Some(id) => reader.find_blob(id)?.read()?,
430            None => Vec::new(),
431        };
432        out.insert(p, serde_json::json!({
433            "blake3": blake3::hash(&content).to_hex().to_string(),
434            "len": content.len(),
435        }));
436    }
437    Ok(out)
438}
439
440fn main() -> Result<(), Box<dyn std::error::Error>> {
441    let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("fixtures");
442    fs::create_dir_all(dir.join("schemas"))?;
443
444    let mut hashes = BTreeMap::new();
445    for (name, world) in [
446        ("brick", brick_world()),
447        ("features", features_world()),
448        ("chunks", chunks_world()),
449        ("wires", wires_world()),
450        ("components", components_world()),
451        ("entities", entities_world()),
452        ("spawner", spawner_world()),
453    ] {
454        let pending = world.to_unsaved()?.to_pending()?;
455        // raw variant: no zstd anywhere — byte-comparable across languages
456        let raw_path = dir.join(format!("{name}_raw.brz"));
457        let mut f = fs::File::create(&raw_path)?;
458        pending.clone().to_brz_data(None)?.write(&mut f, None)?;
459        // compressed variant: zstd level 14 (matches Brz::save)
460        let mut f = fs::File::create(dir.join(format!("{name}.brz")))?;
461        pending.to_brz_data(Some(14))?.write(&mut f, Some(14))?;
462
463        // .brdb variant: content parity only (the .brdb container is not
464        // byte-deterministic across runs).
465        let db_path = dir.join(format!("{name}.brdb"));
466        let _ = fs::remove_file(&db_path);
467        Brdb::create(&db_path)?.save("Fixture", &world)?;
468
469        hashes.insert(name.to_string(), hash_archive(&raw_path)?);
470    }
471    fs::write(dir.join("hashes.json"), serde_json::to_string_pretty(&hashes)?)?;
472
473    // The nine embedded schemas as binary msgpack (the exact bytes the
474    // writer embeds as *.schema files inside archives).
475    for (name, schema) in [
476        ("BRSavedGlobalDataSoA", schemas::global_data_schema()),
477        ("BRSavedOwnerTableSoA", schemas::owners_schema()),
478        ("BRSavedBrickChunkIndexSoA", schemas::bricks_chunk_index_schema()),
479        ("BRSavedBrickChunkSoA", schemas::bricks_chunks_schema()),
480        ("BRSavedWireChunkSoA", schemas::bricks_wires_schema()),
481        ("BRSavedComponentChunkSoA", schemas::bricks_components_schema_min()),
482        ("BRSavedComponentChunkSoA_max", schemas::bricks_components_schema_max()),
483        ("BRSavedEntityChunkIndexSoA", schemas::entities_chunk_index_schema()),
484        ("BRSavedEntityChunkSoA", schemas::entities_chunks_schema()),
485    ] {
486        fs::write(dir.join(format!("schemas/{name}.bin")), schema.to_bytes()?)?;
487    }
488    eprintln!("fixtures written to {}", dir.display());
489    Ok(())
490}