Docs / Developer / Lumiverse format
Lumiverse format
Lumiverse's character card rides the SillyTavern CCv2/v3 wire shape with its own extensions keys, plus
an optional charx-like ZIP container (lumiverse_modules.json) that carries expression images, alternate
avatars, alternate field text, world-book ids, and archive-embedded regex scripts as separate files
instead of inline data URIs. bunzai reads and writes both containers through one character adapter, plus
standalone codecs for Lumiverse's regex export, persona object, and preset wrapper.
The format is one folder, src/formats/lumiverse/, whose index.ts default-exports four codecs:
lumiverse, kindcharacter, reads PNG, JSON, or the modules ZIP; writes.jsonor.charx.lumiverse-regex, kindregex, reads the versioned standalonelumiverse_regex_scriptsexport file.lumiverse-persona, kindpersona, reads one accountPersonaobject.lumiverse-preset, kindpreset, reads the block-basedlumiverse_presetwrapper and writes a flat
SillyTavern-compatible preset.
There is no dedicated Lumiverse lorebook codec. An embedded data.character_book on a Lumiverse card is
extracted and re-embedded by the same shared bundle layer every CCv2/v3-lineage format uses, SillyTavern
and Risu included (src/convert.ts:40-53; see risu.md, "The embedded character_book"). This
page documents the character adapter and its modules sidecar in full; the regex, persona, and preset codecs are
covered in summary under Quirks. For the canonical field meanings this format maps onto, see
entities/character.md and entities/regex.md. For the
hub-and-spoke, escrow, and detection model, see architecture.md. For the whole
matrix of formats, see FORMAT-SUPPORT.md.
Detection
The registry runs every adapter's detect() and keeps the single highest score above 0.5; a tie is
resolved by adapter registration order, not by any content-based tiebreak (registry.ts:30-41).
Character
Three input shapes, checked in this order (index.ts:82-113):
ZIP (bytes starting PK, index.ts:47-49):
lumiverse_modules.jsonpresent: score1(index.ts:85-86). This is Lumiverse's own container.- No modules sidecar but a
module.risumentry: score0(index.ts:87-89). Lumiverse cedes a bare
charx carrying a Risu module to the Risu adapter rather than claim a card it did not author. - No modules sidecar, no
module.risum, butcard.jsondecodes to a card whoseextensionscarries a
Lumiverse fingerprint (see below): score0.96(index.ts:90-96). A charx-shaped archive with neither
signal scores0.
PNG carrying a chara/ccv3 chunk (getVersion, the same shared PNG reader SillyTavern uses): the
decoded card's extensions is checked for a fingerprint, 0.95 if found, 0 otherwise
(index.ts:102-107).
Plain JSON (V3 spec/data, V2 spec/data, or flat { name, ... }): the same fingerprint check,0.95 or 0 (index.ts:108-112).
A fingerprinted card outbids SillyTavern's generic 0.9 for any recognizable CCv2/v3/flat card, the same
outbidding pattern RoleCall uses against SillyTavern (sillytavern.md, "Detection"). Unlike RoleCall's
unconditional 1.0, Lumiverse only wins when a real Lumiverse signal is present; a plain ST card with no
fingerprint scores 0 here and 0.9 there, so ST reads it.
hasLumiverseFingerprints (modules.ts:317-330) is true when extensions carries any of: anexpressions object, an expression_groups object, an alternate_fields object, an alternate_avatars
array, a lumiverse_image_gen_lora object, a string alternate_character_name, a world_book_ids array,
a databank_ids array, or a defined ttsVoice.
Deterministic tie on a genuine modules ZIP. Risu's own detect() scores 1 for any zip containingcard.json, with no check for lumiverse_modules.json (risu/index.ts:87-95); it does not reciprocate
Lumiverse's cede on module.risum. So a real Lumiverse export (card.json plus lumiverse_modules.json,
no module.risum) scores 1 on both adapters. loadFormats sorts folder names before registration,
so lumiverse registers before risu (loader.ts:20-42). The registry replaces its winner only for a
strictly greater score (registry.ts:30-41), which makes Lumiverse the winner for this tie.
Regex
lumiverse-regex claims only its own self-identifying envelope: type === "lumiverse_regex_scripts" and
an array scripts, score 1 (regex.ts:360-365). A bare scripts array with no envelope, or an
envelope with the wrong type, scores 0. No other adapter reads this shape, so there is no contest.
Persona
lumiverse-persona claims an object carrying name, description, and both subjective_pronoun andobjective_pronoun as strings, score 0.95 (persona.ts:20-38). The same object also satisfies
SillyTavern's flat/V1 character heuristic (name plus description), which bids 0.9; the pronoun pair
is the distinct signal that outbids it, the same pattern SillyTavern's own persona codec uses against its
own character adapter (sillytavern.md, "Persona codec").
Field map
Character
The base card mapping is the shared Tavern data <-> canonical mapping insrc/formats/_shared/tavern-fields.ts, documented in full in sillytavern.md.
Lumiverse cards use the identical dataToBody / applyBodyToData functions, so nothing here repeats that
table. data.assets[] maps through the same shared CCv3 asset mapping (_shared/assets.ts) documented in
risu.md, passing "lumiverse" as the dialect. The only dialect-sensitive
choice, emotion vs expression wire typing, resolves identically to SillyTavern's default (emotion;
only the rolecall dialect differs, assets.ts:147-148).
Everything Lumiverse-specific lives under data.extensions and is native (first-class, not escrow), but
almost none of it is lifted onto a canonical body field. Two things ARE bridged:
extensions field |
Bridge | Notes |
|---|---|---|
alternate_fields (description/personality/scenario rows) and alternate_avatars |
body.variants[] |
Rows sharing a label merge into one CharacterVariant; see Variants bridge. variants-bridge.ts:24-83 |
expressions.mappings, expression_groups.*, alternate_avatars[].image_id/path when resolved to a data URI |
body.media.assets[] (role emotion or other) |
Only entries that resolve to a data: URI become media assets; a bare archive path with no matching file stays extensions-only. modules.ts:104-108,117-127,148-150 |
Everything else on extensions rides through as native data on the original.sillytavern twin, editable
only through the Workbench's Lumiverse native-fields panel or its raw-extensions editor, never lifted to a
canonical field: alternate_character_name, lumiverse_image_gen_lora, ttsVoice, world_book_ids,databank_ids (ui/apps/workbench/platforms/lumiverse.ts:12-76).
Archive-embedded regex is not behaviorRefs. A modules ZIP's regex_scripts[] decodes through the
real typed regex codec (decodeLumiverseModuleRegexScripts, not an opaque blob) and lands atextensions._lumiverse_modules_regex_scripts as RegexRule[], but the character adapter never lifts that
array onto CharacterBody.behaviorRefs. It stays native extensions data, not a canonical link
(coverage.ts:22-27, modules.ts:158-161,276-283).
Variants bridge
altsToVariants (variants-bridge.ts:24-83) groups alternate_fields rows and alternate_avatars
entries by shared label into one CharacterVariant per label: description maps tooverrides.identity, personality/scenario to overrides.persona, the avatar tooverrides.media.portrait. Export (variantsToAlts, variants-bridge.ts:94-126) walks body.variants[]
and emits a row per field the variant actually overrides, keyed back by the variant's id.applyVariantsToExtensions (variants-bridge.ts:136-144) then overwrites alternate_fields andalternate_avatars wholesale from the current body.variants, deleting the key entirely when no variants
remain; it does not merge row by row against what was there before.
Modules sidecar
The modules ZIP is a second container layered on the same card. rehydrateCardData (modules.ts:76-166)
merges lumiverse_modules.json into a clone of data.extensions on import:
| Modules field | Extensions field it merges into | Media asset produced |
|---|---|---|
expressions.{enabled,defaultExpression,mappings} |
expressions |
one emotion asset per mapping entry that resolves to a data URI |
expression_groups.groups (per character name) |
expression_groups |
one emotion asset per resolved label, labeled <charName>:<label> |
alternate_fields |
alternate_fields |
none (feeds the variants bridge, not media) |
alternate_avatars[] |
alternate_avatars |
one other asset per resolved avatar |
world_books |
_lumiverse_modules_world_books |
none |
regex_scripts |
_lumiverse_modules_regex_scripts (decoded RegexRule[]) |
none |
resolveAssetRef (modules.ts:52-58) turns an archive path into a data: URI when the file exists in
the ZIP and is under 64 MiB (MAX_ASSET, modules.ts:18); a missing or oversized file is left as the
bare archive-path string, so it never becomes a media asset and stays visible only as raw extensions text.
Escrow and round-trip
Two escrow buckets carry one Lumiverse character: original.sillytavern (the ST-shaped card twin, same
mechanism sillytavern.md describes) and original.lumiverse (the
modules-container twin), both under the canonical wrapper's original field (canonical.ts:39-51,80)
(index.ts:161-175):
original.sillytavern = {
raw: <the card envelope, with data.extensions already merged with the modules sidecar>,
unmapped: { variant: "v2" | "v3" | "flat" },
sourceMedia: { b64, mime: "image/png" } // only for a PNG, non-ZIP input
}
original.lumiverse = {
raw: {
modules: <the parsed lumiverse_modules.json, or null if the input had none>,
fromZip: true | false,
files: { "<zip path>": "<base64 bytes>", ... } // every ZIP entry, including both JSON files
}
}
The sillytavern twin's raw.data.extensions is the HYDRATED extensions bag: archive paths already
resolved to data URIs where possible, the same object the canonical body's variants and media were
derived from. It is not a byte-identical copy of the input card.json's extensions when the input was a
ZIP. The lumiverse twin carries the unhydrated modules pointers plus every archive entry, includingcard.json and lumiverse_modules.json, base64 and keyed by zip path
(index.ts:150-154,168-174). Repacking overwrites those two structured entries with current data while
the full map preserves other files.
On export, fromCanonical (index.ts:179-258):
- Clones the base card data from
original.sillytavern.raw(its.datafor v2/v3, the object itself
for flat/v1), overlays it withapplyBodyToDataandapplyMediaToTavernData(..., "lumiverse"), and
prepares the current canonical fields (index.ts:186-194). - Merges
extensions: the twin's rawextensionsfirst, then the canonical-derivedextensions
spread on top, current values winning on key collision (index.ts:196-202). - Folds
body.variantsback onto that mergedextensionsviaapplyVariantsToExtensions
(index.ts:204-207), see Variants bridge, then applies the resolved lorebook
bundle. Doing that after the raw-extension merge ensures an empty resolution removes both stalecharacter_bookslots instead of reviving the twin's fallback. - Picks the container: the caller's
requestedExtensionwins if given, otherwise the source container is
preserved, a ZIP twin re-emits.charx, a non-ZIP twin re-emits.json(index.ts:223-227). - For a
.charxemit,packModulesFromExtensions(modules.ts:200-286) rebuildslumiverse_modules.jsonfrom the mergedextensions. Anydata:URI is materialized back to an
archive file under a deterministic per-field path scheme:assets/other/image/expr_<label>.<ext>for
expressions,assets/other/image/exprg_<char>--<label>.<ext>for expression groups,assets/icon/image/<id>.<ext>for avatars (modules.ts:216,235,263,292-314); an existing archive-path
reference is kept as-is.regex_scriptsre-encodes through the typed codec
(encodeLumiverseModuleRegexScripts), reproducing the original wire byte-for-byte when unedited
(modules.ts:276-283). Every original zip entry not referenced by any extensions field still rides
through untouched, since re-packing starts from the twin's ownfilesmap (modules.ts:206). - Emitting
.jsonfrom a ZIP twin can refuse rather than silently drop archive content
(index.ts:243-255): the guard checks whether the twin's ORIGINAL archive carried any file besidescard.jsonand whether the current extensions still pack into a non-empty modules object. It is a
conservative check, not an exact one; it does not confirm that some specific field in the current
payload still points at an un-inlined archive path, so a leftover file from the original archive can
trip the refusal even after every extensions field referencing it was edited away.
A same-format Lumiverse round-trip through the plain-JSON path is field-for-field lossless when unedited:
a bare archive-path reference has no ZIP files to resolve against, so resolveAssetRef returns it
unchanged (modules.ts:52-58). A same-format round-trip through the ZIP path is data-lossless, not
byte-lossless: asset bytes are unchanged for untouched assets, but the archive path each one lives at is
regenerated from the naming scheme above rather than preserved verbatim from the input archive.
A cross-format conversion carries only the canonical body: both the sillytavern and lumiverse twins,
every extensions key with no canonical home, and every archive file stay in escrow and are not copied
into another app's file (architecture.md, "Escrow: lossless round-trips, contained cross-format loss").
Quirks
- The
.lvbakarchive importer accepts regex scopesaccount,character, andpreset.
Character and preset scopes require a usablescope_id. Unknown or incomplete scopes are
reported as failed rows, never widened to account scope; supported sibling rows still import. - Zip decompression bounds. Every Lumiverse ZIP inflate, detection and conversion both, runs through
unzipBoundedwithCARD_ARCHIVE_BOUNDS, the same bound Risu and Backyard's.byafuse: 96 MiB
archive cap, 512 entries max, 64 MiB per entry, 96 MiB aggregate inflated (archive.ts:29-36,index.ts:23,43-45). - Regex codec (
lumiverse-regex). Two real wire shapes converge on the same canonicalRegexRule: the
full accountRegexScriptthe versioned export file carries (targetas an array, plususer_id/script_id/folder/pack_id/preset_id/timestamps, all sealed toextrassince none has a
canonical home) and the reduced shape actually embedded in a character archive'sregex_scripts[](no
id or those account fields,scope_idalwaysnull, andtargeta single string rather than an array)
(regex.ts:1-32,110-145,252-270).placementdecodes to canonicalphases;memoryis a
Lumiverse-only phase among the format family (regex.ts:52-58). Unmappedplacementvalues and an
unrecognizedsubstitute_macrosmode are never fabricated back: unmapped values rideextras.unmappedPlacementsand re-emit verbatim, an unrecognized substitution mode decodes toundefinedand re-encodes as"none"honestly rather than guessing (regex.ts:93-97). - Persona codec (
lumiverse-persona). Reads one account persona object as exported to JSON: name, title (mapped to
canonicalidentity.tagline), description, the pronoun TRIPLET
(subjective_pronoun/objective_pronoun/possessive_pronounmapped toidentity.pronounSet, the only
wire among bunzai's persona codecs with structured pronouns),attached_world_book_id(mapped toknowledgeRefs),avatar_path(mapped topresentation.imageUrl) (persona.ts:40-67). Lumiverse ships
no file import/export UI for personas, so the API object dump is the only standalone form; the whole
object is sealed tooriginal["lumiverse-persona"].rawand export overlays only the edited fields back
onto the twin, sofolder/is_default/is_narrator/metadataround-trip untouched. A from-scratch
persona has no twin, so export materializes the requireddescription,subjective_pronoun, andobjective_pronounstring keys, using empty strings when canonical content or pronouns are absent.
The optionalpossessive_pronounremains absent unless supplied (persona.ts). alternate_character_nameis a prompt/macro override, not the card name. It stays onextensionsand
is separately editable in the Workbench's Lumiverse native-fields panel; canonicalidentity.nameis
the library name and never reads from it (ui/apps/workbench/platforms/lumiverse.ts:42-46).- The Lumiverse PRESET format (a block-based prompt export,
{ type: "lumiverse_preset", ... }) imports
viasrc/formats/lumiverse/preset.ts: blocks convert into the
shared ST preset wire (_shared/st-preset-wire.ts), category blocks become canonical groups,
promptVariables synthesize the guarded defaults prompt, and the wrapper rides whole as the escrow
twin. Export writes an ST flat preset (the wrapper is never reproduced - the spec's stated non-goal).
Source of truth
| Concern | File |
|---|---|
| Character adapter (detect, toCanonical, fromCanonical, container selection) | src/formats/lumiverse/index.ts |
| Modules sidecar rehydrate/pack, fingerprint check | src/formats/lumiverse/modules.ts |
Variants bridge (alternate_fields/alternate_avatars <-> body.variants) |
src/formats/lumiverse/variants-bridge.ts |
| Regex codec (both wire shapes) | src/formats/lumiverse/regex.ts |
| Persona codec | src/formats/lumiverse/persona.ts |
| Preset codec | src/formats/lumiverse/preset.ts |
| Coverage declaration | src/formats/lumiverse/coverage.ts |
Shared Tavern field map (data <-> canonical) |
src/formats/_shared/tavern-fields.ts |
| Shared CCv3 asset <-> media map | src/formats/_shared/assets.ts |
| Shared PNG chunk read | src/formats/_shared/png.ts |
| Shared card-io JSON/PNG readers | src/formats/_shared/card-io.ts |
| Bounded archive inflate | src/core/archive.ts |
Canonical wrapper, escrow (original), id policy |
src/core/canonical.ts |
| Adapter registry, detection tie-break | src/core/registry.ts |
| Samples | samples/lumiverse/ (rich.extensions.card.json) |