Generating code
bi gen struct reads a schema and writes the same types in C, C++, Go, Rust, C3 or Lua — with defaults, id constants and the instances themselves, so the schema stays the one source.
The command
A .bischema says what a Weapon is. The game that loads level1.bidata
needs the same thing said in its own language, and typing it twice is how the
two drift apart.
bi gen struct --lang <lang> -o <dir> -i <file>... [-m <file>...] [--pkg <name>] [--force] [-v]
| Flag | Is |
|---|---|
--lang <lang> | c, c++ (or cpp), go, rust, c3, lua — one per run |
-o <dir> | where the files go; created with its parents |
-i <file> | a .bischema or .bidata; repeatable — a data file brings its schema along |
-m <file> | a .bimapping; repeatable, later files winning per key |
--pkg <name> | the namespace, package or module to declare the types in |
--force | overwrite without asking |
-v | say what was decided: the mapping merge, the notes, every file’s fate |
-i and -o are required, flags come in any order, and -- ends them.

Every -i is read before anything is written, and a file that does not parse
stops the run with the same diagnostics the property view shows. Nothing is
written when anything is wrong — half-generated code beside code that compiled
yesterday is worse than no code at all.
cargo
does: bi gen prints the generators, bi help gen the same. See the
command line for the rest of them.What comes out
One file per schema, named after its stem — game.bischema becomes game.rs,
game.h, game.go. One file per data file, named the same way, holding the
instances. And once per output directory, bi_types.<ext> with the support
types the builtins need — colours, curve, gradient, Ref — holding only the
ones your schemas actually use.

Each file opens with a line naming where it came from:
// generated by `bi gen struct` from game.bischema — do not edit
Generated text is a pure function of the inputs: types in schema order,
imports sorted, no timestamp and no bi version in the header. Regenerating an
unchanged schema produces a byte-identical file, which is what lets the output
live in git. A file that already holds exactly that text is reported
unchanged and left alone; one that differs asks before overwriting, unless
--force says not to, and a run with no terminal skips it and exits non-zero.
Types, defaults, ids, instances
Types map to each language’s idiom: an enum becomes a Go string type with
constants, a Rust enum with a NAMES table, a Lua table of names — always
keeping the wire names, because that is what the data stores. list<T> is a
Vec<T>, a []T, a std::vector<T>; optional<T> an Option<T>, a
pointer, a std::optional<T>; ref<S> a typed string wrapper, so a
Ref<Weapon> cannot be handed to a field expecting a Ref<Enemy>.
Defaults travel with the types, because they are what make sparse data
loadable: impl Default in Rust, member initialisers in C++, DefaultWeapon()
in Go, weapon_init() in C, weapon_default() in C3, Weapon.new(t) in Lua.
Ids become constants — WEAPON_RUSTY_SWORD, weapon::RUSTY_SWORD,
WeaponRustySword — so a ref written in code is checked by the compiler
instead of being a string you spelled right. Only the data files on the command
line are scanned, never the directory around them, so the same command in a
Makefile always produces the same output.
Instances come out too, one file per data file: each instance a constant
with its sparse fields filled in from the defaults, plus a table per type and a
find(id) that walks it. The loader becomes a lookup, with no I/O and no
parsing at runtime. ids = false and instances = false in the mapping turn
either off.
| Language | An instance | Its table |
|---|---|---|
| Rust | level1::weapon::rusty_sword() | weapon::all(), weapon::find(id) |
| C | level1_weapon_rusty_sword | level1_weapon_all[], level1_weapon_find |
| C++ | level1::weapon::rusty_sword | weapon::all, weapon::find(id) |
| Go | Level1WeaponRustySword | Level1Weapons, FindLevel1Weapon(id) |
| C3 | level1_weapon_rusty_sword | level1_weapon_all(), level1_weapon_find |
| Lua | M.Weapon.rusty_sword | M.Weapon.all, M.Weapon.find(id) |
Names are cased per language — Go and Rust get Pascal types, Rust and C3 get
snake fields, C gets RARITY_COMMON — and the schema’s own spelling is kept as
the wire name wherever the language can hold it: a serde rename, a json:
tag, a comment in C. A reserved word, a collision after casing, or a name the
language owns is an error naming both sides and the mapping key that fixes
it, rather than a silently numbered name.
The mapping file
A .bimapping is TOML, and it answers two questions: what should not be
generated because the project already has it, and how the generated code is
spelled. Neither changes what the data means.
ids = true # id constants from the data files given
[names] # what a schema name is called in code
"Weapon.type" = "kind" # a field
"Rarity.very rare" = "VeryRare" # an enum value
"Enemy" = "Monster" # a type; the wire name stays "Enemy"
[rust]
imports = ["use serde::Deserialize;"]
derive = ["Debug", "Clone", "Deserialize"]
[rust.types]
Vec2 = "glam::Vec2" # external: not generated, spelled like this
rgb = { as = "palette::Srgb<u8>", import = "use palette::Srgb;", default = "Srgb::new(0, 0, 0)" }
[rust.generics]
list = "SmallVec<[{T}; 4]>" # {T} is the element's spelling
ref = "Handle<{S}>" # {S} is the target struct
[c]
prefix = "gs_" # every type and constant: gs_Weapon, GS_RARITY_COMMON
| Key | Means |
|---|---|
imports | lines placed once at the top of every unit |
types | a schema type or builtin → the spelling to use instead of generating it; the long form adds an import and a default |
generics | list, optional, ref → a template with {T} or {S} |
names | like [names], for one language; wins over the shared one |
header | text after the generated-by line |
prefix | C only: prepended to every type and constant |
derive | Rust only: the derive list; Deserialize turns on serde renames |
A mapping key naming a type no schema uses is a warning, not an error — one mapping is meant to serve a whole project. An unknown key is an error with its line and file.
bi gen sample mapping writes game.bimapping beside the sample with every
key present and commented out, the way bi config init writes the user
config, so the file documents itself and changes nothing until you uncomment a
line.
default in the mapping.