MSX — MidStroke eXtension
Format Specification
Tracks the binary format's actual VERSION byte (currently 2) and this repo's current main — kept up to date alongside the code, not written once and left to drift. If something here disagrees with core/msx-binary or core/msx-ast, the code is right; that's a doc bug, flag it.
Overview
MSX is a two-layer vector image format:
1. Source layer — A DixScript .msx file. The human-readable authoring surface. 2. Binary layer — A compact typed-stream encoding of the evaluated scene graph, optionally MBFA-compressed.
The source layer is optional at runtime. A compiled binary .msx file is entirely self-contained and renders to SVG (or PNG, via msx-render-cpu/msx-render-gpu) without the original source.
Why MSX?
SVG is XML. Nobody writes XML by hand for complex or parametric graphics. MSX source files are DixScript — the same .mdix format you already use for configs, now driving vectors. The key insight:
| SVG | MSX Source |
|---|---|
Copy-paste the same <circle> 50 times |
One QuickFunc ~dot(x,y,r,color), call it 50 times |
| No reusable components without JS | QuickFuncs compose freely, evaluated at compile time |
| Gradient/clip IDs must be manually unique | Defs are DixScript objects; IDs are data |
| Verbosity: ~1.2KB per annotated shape | Compact typed binary; MBFA compresses repeating structure |
| Zero compression on SVG | MBFA multi-fold LZ on binary coordinate + opcode streams |
Source Format (DixScript Schema)
An .msx file is a valid DixScript file. The MSX compiler evaluates it and produces a Scene AST.
Section contract
| Section | Required | Purpose |
|---|---|---|
@CONFIG |
No | Format version, metadata |
@ENUMS |
No | Named constants (BlendMode, LineCap, FillRule…) |
@QUICKFUNCS |
No | Parametric shape generators and component libraries |
@DATA |
Yes | Scene definition — canvas, defs, elements |
@DATA top-level keys
@DATA(
// Canvas — required
scene: { width = <float>, height = <float>, background = <color> }
// Optional viewbox
viewbox: { min_x = <float>, min_y = <float>, width = <float>, height = <float> }
// Optional gradient / pattern defs (referenced by elements via url(#id))
defs::
<def_object>...
// Element tree — required
elements::
<element_object>...
)
Element Objects
Every element is a DixScript object literal or a QuickFunc call that returns one. All elements share optional id, transform, and style keys.
rect
{
type = "rect"
x = <float> // top-left x
y = <float> // top-left y
width = <float>
height = <float>
rx = <float>? // corner radius x (omit for sharp corners)
ry = <float>? // corner radius y (defaults to rx if omitted)
id = <string>?
transform = <transform>?
style = <style>
}
circle
{
type = "circle"
cx = <float>
cy = <float>
r = <float>
id = <string>? transform = <transform>? style = <style>
}
ellipse
{
type = "ellipse"
cx = <float> cy = <float>
rx = <float> ry = <float>
id = <string>? transform = <transform>? style = <style>
}
line
{
type = "line"
x1 = <float> y1 = <float>
x2 = <float> y2 = <float>
id = <string>? transform = <transform>? style = <style>
}
polyline
{
type = "polyline"
points = [ [<float>, <float>] ... ] // array of [x, y] pairs
id = <string>? transform = <transform>? style = <style>
}
polygon
Same as polyline but closed. type = "polygon".
path
{
type = "path"
d = <string> // standard SVG path data — M L H V C S Q T A Z (abs + rel)
id = <string>? transform = <transform>? style = <style>
}
Supports interpolated strings for parametric paths:
d = $"M {cx - half} {cy} L {cx + half} {cy} Z"
text
{
type = "text"
x = <float>
y = <float> // baseline y
content = <string>
id = <string>? transform = <transform>? style = <style>
}
group
{
type = "group"
elements = [ <element> ... ] // recursive
id = <string>?
transform = <transform>?
style = <style>? // inheritable styles only; applied to all children
}
use
References a def by id. Used to stamp out gradient-filled or reused shapes.
{
type = "use"
href = "#<id>"
x = <float>?
y = <float>?
transform = <transform>?
}
image
{
type = "image"
source_ref = <string>? // path, resolved relative to the source file — mutually exclusive with `data`
data = <string>? // base64-encoded PNG/JPEG/GIF bytes, embedded inline
x = <float> // anchor point's canvas position — NOT necessarily the
y = <float> // rendered top-left corner; see `anchor` below
width = <float>
height = <float>
anchor = "top_left" | "center" | "top_right" | "bottom_left" | "bottom_right"? // default "top_left"
id = <string>? transform = <transform>? style = <style>
}
Embedded data is format-sniffed at parse time (PNG/JPEG/GIF magic bytes) — base64 that decodes to anything else is a parse error, unlike audio's def below.
sdf
A signed-distance-field shape tree — tree is a recursive node graph (primitives combined with union/subtract/intersect, optionally smoothed, optionally offset), evaluated per-pixel by every renderer rather than tessellated.
{
type = "sdf"
tree = <sdf_node> // see below
fill = <paint>
stroke = <paint>?
stroke_width = <float>?
id = <string>? transform = <transform>?
}
<sdf_node> is one of:
{ type = "circle", cx = <float>, cy = <float>, r = <float> }
{ type = "box", cx = <float>, cy = <float>, hx = <float>, hy = <float>, corner_radius = <float>? }
{ type = "line", x1 = <float>, y1 = <float>, x2 = <float>, y2 = <float>, thickness = <float> }
{ type = "ring", cx = <float>, cy = <float>, r = <float>, thickness = <float> }
{ type = "arc", cx = <float>, cy = <float>, r = <float>, thickness = <float>, start_angle = <float>, sweep_angle = <float> }
{ type = "union", a = <sdf_node>, b = <sdf_node> }
{ type = "smooth_union", a = <sdf_node>, b = <sdf_node>, k = <float> }
{ type = "subtract", a = <sdf_node>, b = <sdf_node> }
{ type = "smooth_subtract", a = <sdf_node>, b = <sdf_node>, k = <float> }
{ type = "intersect", a = <sdf_node>, b = <sdf_node> }
{ type = "smooth_intersect", a = <sdf_node>, b = <sdf_node>, k = <float> }
{ type = "offset", node = <sdf_node>, amount = <float> }
splat
A single 2D Gaussian splat — an elliptical falloff, not a hard-edged shape. Has no transform field (position/rotation are its own explicit fields instead).
{
type = "splat"
x = <float> // center
y = <float>
sigma_x = <float> // standard deviation along the local x axis
sigma_y = <float> // standard deviation along the local y axis
rotation = <float> // radians
color = <color> // peak color at center — the fallback every renderer uses
opacity = <float>
fill = <paint>? // overrides `color` where present; see msx-ast's own doc for how the two interact
id = <string>?
}
layer
An isolated compositing group: children render into their own offscreen buffer (cleared to transparent, not the canvas background) before that buffer composites onto its parent at opacity, through blend_mode, with effects applied first. This is where blend modes, post-processing effects, and clip live — an ordinary group has none of these.
{
type = "layer"
elements = [ <element> ... ]
blend_mode = "normal" | "multiply" | "screen" | "overlay" | "add" | "soft_light"
| "hard_light" | "difference" | "exclusion" | "darken" | "lighten"
| "subtract" | "divide" // default "normal"
opacity = <float>? // default 1.0
clip = <bool>? // clip children to this layer's pixel footprint; default false
z_index = <float>? // paint order among SIBLING top-level Layers only — see note below
effects = [
{ type = "blur", radius = <float> }
{ type = "drop_shadow", offset_x = <float>, offset_y = <float>, blur_radius = <float>, color = <color>, opacity = <float> }
{ type = "inner_shadow", offset_x = <float>, offset_y = <float>, blur_radius = <float>, color = <color>, opacity = <float> }
{ type = "outer_glow", color = <color>, blur_radius = <float>, spread = <float>, opacity = <float> }
{ type = "inner_glow", color = <color>, blur_radius = <float>, opacity = <float> }
...
]
id = <string>? transform = <transform>?
}
Rendering support today: all three renderers (SVG/CPU/GPU) agree on Layer nesting semantics (a nested Layer's own z_index/opacity resolve at its own level first, then the parent participates as one unit in its sibling list) and on shader fills reaching shapes inside a Layer. blend_mode and Effect::Blur are implemented on GPU; DropShadow/InnerShadow/OuterGlow/InnerGlow are not yet — a Layer using one of those four renders today exactly as if that effect weren't present, on GPU specifically (CPU already implements all five).
z_index is purely relative: only compared against other top-level Layers, never against ordinary (non-Layer) elements, which always draw before every Layer regardless of z_index. Ties break by document order (a stable sort, same convention as CSS z-index).
Def Objects
Defined in the defs:: group array. Referenced via "url(#id)" in paint values.
linear_gradient
{
type = "linear_gradient"
id = <string>
x1 = <float> // 0.0..1.0 in gradient space (or px if gradientUnits = "userSpaceOnUse")
y1 = <float>
x2 = <float>
y2 = <float>
stops = [
{ offset = <float>, color = <color>, opacity = <float> }
...
]
}
radial_gradient
{
type = "radial_gradient"
id = <string>
cx = <float> // center x, 0.0..1.0
cy = <float> // center y
r = <float> // radius
fx = <float>? // focal point x (defaults to cx)
fy = <float>?
stops = [ ... ]
}
conic_gradient
Sweeps stops around a center point, starting at angle (radians).
{
type = "conic_gradient"
id = <string>
cx = <float>
cy = <float>
angle = <float>
stops = [ ... ]
}
shader
A Def::Shader — real WGSL, executed only by msx-render-gpu (the gpu Cargo feature). Every other renderer paints fallback_color wherever this shader is referenced as a fill.
{
type = "shader"
id = <string>
source_ref = <string> // path to a .wgsl file, resolved relative to the source file
entry_point = <string> // fragment shader entry point name
fallback_color = <color> // what CPU/SVG paint instead
uniforms = [
{ name = <string>, value = <float> | [<float>,<float>] | [<float>,<float>,<float>] | [<float>,<float>,<float>,<float>] }
...
]
}
audio
A Def::Audio — has no canvas position (it's a resource, not a drawable element). Nothing in this project currently plays audio; see the CLI Reference's extract-media entry for the one way to get the bytes back out today.
{
type = "audio"
id = <string>
source_ref = <string>? // path to a .wav/.ogg/.mp3, mutually exclusive with `data`
data = <string>? // base64-encoded audio bytes, embedded inline
}
Both source_ref and data go through the same MediaSource machinery Element::Image uses (see below) — a def or element is MediaSource::FileRef(path) or MediaSource::Embedded(bytes), never both. Unlike images, audio parsing does not format-sniff the decoded bytes at parse time — there's no render-time failure mode to protect against yet, since nothing renders (or plays) audio.
Style Object
All keys are optional. Unset keys inherit from the parent group or fall back to defaults.
{
fill = <paint> // default "black"
stroke = <paint> // default "none"
stroke_width = <float> // default 1.0
opacity = <float> // 0.0..1.0, default 1.0
fill_opacity = <float>?
stroke_opacity = <float>?
fill_rule = "nonzero" | "evenodd"
stroke_linecap = "butt" | "round" | "square"
stroke_linejoin = "miter" | "round" | "bevel"
stroke_miterlimit = <float>?
stroke_dasharray = [<float> ...]?
stroke_dashoffset = <float>?
font_size = <float>?
font_family = <string>?
font_weight = "normal" | "bold" | <int>?
text_anchor = "start" | "middle" | "end"
dominant_baseline = <string>?
visibility = "visible" | "hidden"
display = "inline" | "none"
}
Paint values:
| Syntax | Meaning |
|---|---|
"none" |
Transparent / no paint |
"#rrggbb" |
Opaque hex color |
"#rrggbbaa" |
Hex color with alpha |
"rgb(r, g, b)" |
Functional RGB |
"rgba(r, g, b, a)" |
Functional RGBA |
"url(#id)" |
Reference to a gradient or pattern def |
"currentColor" |
Inherited color value |
Transform Values
As a string (SVG syntax):
"translate(tx, ty)"
"scale(sx)" | "scale(sx, sy)"
"rotate(deg)" | "rotate(deg, cx, cy)"
"skewX(deg)" | "skewY(deg)"
"matrix(a, b, c, d, e, f)"
Or as a DixScript object:
{ type = "translate", x = <float>, y = <float> }
{ type = "scale", x = <float>, y = <float> }
{ type = "rotate", angle = <float>, cx = <float>?, cy = <float>? }
{ type = "matrix", a = <float>, b = <float>, c = <float>, d = <float>, e = <float>, f = <float> }
{ type = "skew_x", angle = <float> }
{ type = "skew_y", angle = <float> }
Or as an array for chained transforms (applied right-to-left):
transform = [
{ type = "rotate", angle = 45 }
{ type = "translate", x = 100, y = 0 }
]
Path Command String
The d string in path elements follows standard SVG path syntax.
| Command | Absolute | Relative | Arguments |
|---|---|---|---|
| Move to | M |
m |
x y |
| Line to | L |
l |
x y |
| Horizontal line | H |
h |
x |
| Vertical line | V |
v |
y |
| Cubic bezier | C |
c |
cx1 cy1 cx2 cy2 x y |
| Smooth cubic | S |
s |
cx2 cy2 x y |
| Quadratic bezier | Q |
q |
cx cy x y |
| Smooth quadratic | T |
t |
x y |
| Arc | A |
a |
rx ry x-rotation large-arc-flag sweep-flag x y |
| Close path | Z |
z |
— |
MSX source supports DixScript interpolated strings for parametric paths:
@QUICKFUNCS(
~arrow<object>(x1, y1, x2, y2, color) {
let dx = x2 - x1;
let dy = y2 - y1;
return {
type = "path"
d = $"M {x1} {y1} L {x2} {y2} L {x2 - dx * 0.2} {y2 - dy * 0.2 - 8}"
style = { fill = "none", stroke = color, stroke_width = 2.0,
stroke_linecap = "round", opacity = 1.0 }
}
}
)
Binary Format
File Header (32 bytes)
| Offset | Size | Field | Type | Notes |
|---|---|---|---|---|
| 0 | 4 | magic | [u8; 4] |
0x4D 0x53 0x58 0x00 — "MSX\0" |
| 4 | 1 | version | u8 |
2 — bumped when new tags would break an old decoder's assumptions about tag-driven dispatch (last bumped for Sdf/Splat/Layer/ConicGradient). New tags/flags that an old decoder can just never encounter or safely ignore (Image/Audio, the animations flag below) do not bump this — see "Wire-format versioning" below. |
| 5 | 1 | compress | u8 |
0=none 1=mbfa |
| 6 | 1 | flags | u8 |
bit0=has_viewbox bit1=has_metadata bit2=has_defs bit3=has_animations |
| 7 | 1 | reserved | u8 |
zero |
| 8 | 4 | width | f32 LE |
canvas width in user units |
| 12 | 4 | height | f32 LE |
canvas height in user units |
| 16 | 4 | elem_count | u32 LE |
top-level element count |
| 20 | 4 | str_pool_len | u32 LE |
string pool byte length |
| 24 | 4 | def_count | u32 LE |
def count (gradients, shaders, audio) |
| 28 | 4 | reserved | [u8; 4] |
zeros |
Wire-format versioning
New tags/flags are added additively wherever the decoder can either dispatch on the tag itself (a new element/def tag an old decoder simply never emits or reads) or gate a whole optional section behind a flag bit an old decoder never checks. Both of those degrade gracefully on version mismatch rather than crashing — an old decoder either never encounters the new tag at all (the file simply doesn't use the feature it postdates), or, for a flag-gated section like animations, silently doesn't read past where it always stopped, same "silently drops what it doesn't know about" behavior as before that section existed, not a new failure mode. Only changes to tags an old decoder actively dispatches on and would now misinterpret bump version.
Payload Layout (after header; optionally MBFA-compressed as a unit)
Background RGBA 4 bytes [u8 r][u8 g][u8 b][u8 a]
Viewbox 16 bytes [f32 min_x][f32 min_y][f32 w][f32 h] (only if flags.bit0)
String pool variable [u16 count] ([u16 byte_len][utf8 bytes])*
Def section variable def_count × def
Element stream variable elem_count × element, then [u8 0xFF] (TAG_END sentinel)
Animation section variable only if flags.bit3 — see "Animation Section" below
The TAG_END sentinel is always written after the element stream, regardless of whether an animation section follows — decoding always reads exactly elem_count elements (never scans for the sentinel) and then consumes that one byte before deciding, from flags.bit3, whether to read further.
Element Type Tags
| Tag | Element | Since |
|---|---|---|
0x00 |
Rect | v0.1 |
0x01 |
Circle | v0.1 |
0x02 |
Ellipse | v0.1 |
0x03 |
Line | v0.1 |
0x04 |
Polyline | v0.1 |
0x05 |
Polygon | v0.1 |
0x06 |
Path | v0.1 |
0x07 |
Text | v0.1 |
0x08 |
Group | v0.1 |
0x09 |
Use | v0.1 |
0x0A |
LinearGradient (def only) | v0.1 |
0x0B |
RadialGradient (def only) | v0.1 |
0x0C |
ConicGradient (def only) | v0.2 |
0x0D |
Sdf | v0.2 |
0x0E |
Splat | v0.2 |
0x0F |
Layer | v0.2 |
0x10 |
Shader (def only) | v0.3 |
0x11 |
Image | v0.4 |
0x12 |
Audio (def only) | v0.4 |
0xFF |
End sentinel | v0.1 |
("Since" tracks when each tag was added to tags.rs, not the file-header version byte — see "Wire-format versioning" above for why those two numbers don't move together.)
Element Wire Format
[u8 tag]
[u8 id_flags] bit0 = has_id bit1 = has_transform
[u16 id_str_idx] (only if id_flags.bit0)
[transform block] (only if id_flags.bit1)
[geometry fields] (tag-specific — see below)
[style block] (most tags — Sdf and Splat use `fill`/`stroke` paint fields directly instead, Layer has none)
Splat (0x0E) has no transform field on the AST at all, so it skips the shared id_flags header entirely in favor of a simpler one: [u8 has_id] [u16 id_str_idx if has_id].
Geometry fields by tag
| Tag | Fields (all f32 LE unless noted) |
|---|---|
0x00 Rect |
x y width height rx ry |
0x01 Circle |
cx cy r |
0x02 Ellipse |
cx cy rx ry |
0x03 Line |
x1 y1 x2 y2 |
0x04 Polyline |
[u32 count] [f32 x y]* |
0x05 Polygon |
[u32 count] [f32 x y]* |
0x06 Path |
[u32 cmd_count] [path_command]* |
0x07 Text |
[u16 str_idx] [f32 x] [f32 y] |
0x08 Group |
[u32 child_count] [element]* |
0x09 Use |
[u16 href_str_idx] [f32 x] [f32 y] |
0x0A LinearGradient |
[u16 id_str_idx] [f32 x1 y1 x2 y2] [u16 stop_count] [stop]* |
0x0B RadialGradient |
[u16 id_str_idx] [f32 cx cy r fx fy] [u16 stop_count] [stop]* |
0x0C ConicGradient |
[u16 id_str_idx] [f32 cx cy angle] [u16 stop_count] [stop]* |
0x0D Sdf |
[sdf_tree] [paint fill] [u8 has_stroke] ([paint stroke] [f32 stroke_width] if has_stroke) — note: no style block, fill/stroke are direct paint fields |
0x0E Splat |
[f32 x y sigma_x sigma_y rotation] [u8 r g b a] [f32 opacity] [u8 has_fill] ([paint fill] if has_fill) |
0x0F Layer |
[u8 blend_mode] [f32 opacity] [u8 clip] [u16 effect_count] [effect]* [u32 child_count] [element]* [f32 z_index] — no style block |
0x10 Shader |
[u16 id_str_idx source_ref_str_idx entry_point_str_idx] [u8 r g b a fallback_color] [u16 uniform_count] ([u16 name_str_idx] [uniform_value])* |
0x11 Image |
[media_source] [f32 x y width height] [u8 anchor], then the shared style block |
0x12 Audio |
[u16 id_str_idx] [media_source] — no style block, def only |
Gradient stop
[f32 offset] 0.0..1.0
[u8 r g b a]
Shader uniform value
[u8 type]
0x00 Float [f32]
0x01 Vec2 [f32 x y]
0x02 Vec3 [f32 x y z]
0x03 Vec4 [f32 x y z w]
MediaSource encoding
Shared by Image and Audio — one or the other, never both, decoded at read time only from the discriminant byte:
[u8 kind]
0x00 FileRef [u16 path_str_idx] // through the shared string pool
0x01 Embedded [u32 byte_len] [u8 bytes]* // NOT the string pool — see note below
Embedded bytes use their own u32-length-prefixed block rather than the string pool: the pool is u16-length-prefixed per entry (65,535-byte ceiling per string), which is fine for paths/ids but would silently truncate a real embedded image or audio file past that size. base64 decoding happens only in msx-parser (source → bytes, at parse time); base64 re-encoding happens only in msx-render-svg (bytes → data: URI, at SVG-emit time) — the binary format itself and every other renderer only ever handle raw bytes.
SDF tree encoding (sdf_tree)
A recursive node graph — combinators recurse into their own child node(s) inline, no separate length prefix needed since each node's own tag determines how many more nodes follow.
[u8 tag]
0x00 Circle [f32 cx cy r]
0x01 Box [f32 cx cy hx hy] [u8 has_corner_radius] ([f32 corner_radius] if present)
0x02 Line [f32 x1 y1 x2 y2 thickness]
0x03 Ring [f32 cx cy r thickness]
0x04 Arc [f32 cx cy r thickness start_angle sweep_angle]
0x05 Union [sdf_tree a] [sdf_tree b]
0x06 SmoothUnion [sdf_tree a] [sdf_tree b] [f32 k]
0x07 Subtract [sdf_tree a] [sdf_tree b]
0x08 SmoothSubtract [sdf_tree a] [sdf_tree b] [f32 k]
0x09 Intersect [sdf_tree a] [sdf_tree b]
0x0A SmoothIntersect [sdf_tree a] [sdf_tree b] [f32 k]
0x0B Offset [sdf_tree node] [f32 amount]
Effect encoding
[u8 tag]
0x00 Blur [f32 radius]
0x01 DropShadow [f32 offset_x offset_y blur_radius] [u8 r g b a color] [f32 opacity]
0x02 InnerShadow [f32 offset_x offset_y blur_radius] [u8 r g b a color] [f32 opacity]
0x03 OuterGlow [u8 r g b a color] [f32 blur_radius spread opacity]
0x04 InnerGlow [u8 r g b a color] [f32 blur_radius opacity]
BlendMode byte values
Normal is the #[default] (byte 0). This exact numbering is what a Layer's blend_mode field serializes to, and is also what GPU's blend shader receives directly as its mode uniform — no remapping table between the wire format and the shader.
| Byte | Mode | Byte | Mode |
|---|---|---|---|
| 0 | Normal | 7 | Difference |
| 1 | Multiply | 8 | Exclusion |
| 2 | Screen | 9 | Darken |
| 3 | Overlay | 10 | Lighten |
| 4 | Add | 11 | Subtract |
| 5 | SoftLight | 12 | Divide |
| 6 | HardLight |
Animation Section
Only present when header flags.bit3 (has_animations) is set — gated on whether the scene has any non-default animation-related state (duration, loop_mode, or a non-empty animations list), not on whether the scene is currently animated in the rendering sense (a duration/loop_mode explicitly authored alongside zero real keyframe tracks still round-trips through this section — that's a fidelity guarantee, not a rendering one).
[f32 duration]
[u8 loop_mode] 0=Once 1=Loop 2=PingPong (#[default] = Once)
[u16 track_count]
track:
[u16 target_id_str_idx] // through the shared string pool
[u8 property] // AnimatedProperty discriminant
[u16 keyframe_count]
keyframe:
[f32 time]
[f32 value]
[u8 easing] // Easing discriminant
target_id goes through the shared string pool rather than being inlined — several properties on the same element each get their own track, so the same id repeats often enough that pool dedup is worth it, same reasoning as MediaSource::FileRef paths.
Rendering support: msx-anim::resolve_at_time reads this data to drive msx-viewer's live keyframe playback and msx animate's GIF export. It does not drive the GPU shader time uniform — that's a separate clock (see msx animate-gpu in the CLI Reference below), and live GPU playback in the viewer hasn't been started.
Style Block
[u8 present_flags]
bit 0 = fill present
bit 1 = stroke present
bit 2 = opacity present
bit 3 = stroke_width present
bit 4 = fill_rule + linecap + linejoin present
bit 5 = font fields present
bit 6 = dash present
bit 7 = visibility / display present
[paint] if bit 0 (fill)
[paint] if bit 1 (stroke)
[f32] if bit 2 (opacity)
[f32] if bit 3 (stroke_width)
[u8 fill_rule] if bit 4 (0=nonzero 1=evenodd)
[u8 linecap] (0=butt 1=round 2=square)
[u8 linejoin] (0=miter 1=round 2=bevel)
[f32 miterlimit]
[u16 font_size_x100] if bit 5 (stored as integer × 100)
[u16 font_family_str_idx]
[u8 font_weight] (0=normal 1=bold 2..=numeric/100)
[u8 text_anchor] (0=start 1=middle 2=end)
[u16 dash_count] if bit 6
[f32 dash]*
[f32 dashoffset]
[u8 vis_display_flags] if bit 7 bit0=hidden bit1=display_none
Note: Sdf and Layer don't have a style block at all — Sdf has direct fill/stroke paint fields instead (see its geometry-fields row above), and Layer has no fill/stroke concept at all (it's a compositing group, not a drawable shape).
Paint Encoding
[u8 type]
0x00 None (transparent)
0x01 Color [u8 r][u8 g][u8 b][u8 a]
0x02 Gradient reference [u16 str_idx] (points into string pool)
0x03 Pattern reference [u16 str_idx]
Transform Block
[u8 type]
0x00 None (no further bytes)
0x01 Matrix [f32 a b c d e f]
0x02 Translate [f32 tx ty]
0x03 Scale [f32 sx sy]
0x04 Rotate [f32 angle] [u8 has_center] ([f32 cx cy] if has_center)
0x05 SkewX [f32 angle]
0x06 SkewY [f32 angle]
0x07 Multiple [u8 count] [transform_block]*
Path Command Encoding
[u8 cmd_tag]
Absolute commands:
0x00 MoveTo [f32 x y]
0x01 LineTo [f32 x y]
0x02 HLineTo [f32 x]
0x03 VLineTo [f32 y]
0x04 CubicBezier [f32 cx1 cy1 cx2 cy2 x y]
0x05 SmoothCubic [f32 cx2 cy2 x y]
0x06 QuadBezier [f32 cx cy x y]
0x07 SmoothQuad [f32 x y]
0x08 Arc [f32 rx ry x_rotation] [u8 flags: bit0=large_arc bit1=sweep] [f32 x y]
Relative commands (same wire layout, tag = absolute + 0x10):
0x10 rel MoveTo
0x11 rel LineTo
0x12 rel HLineTo
0x13 rel VLineTo
0x14 rel CubicBezier
0x15 rel SmoothCubic
0x16 rel QuadBezier
0x17 rel SmoothQuad
0x18 rel Arc
0xFF ClosePath (no further bytes)
Compression Pipeline
When header.compress == 1, the entire payload (everything after the 32-byte header) is passed through mbfa::compress(&payload, 8) before writing and mbfa::decompress before reading.
Why MBFA exploits MSX binary well:
| Binary structure | MBFA mechanism |
|---|---|
Repeated element tags (0x01 cx cy r for many circles) |
Fold-1 LZ finds back-references across the opcode + geometry prefix |
| Coordinate locality (nearby shapes share high bytes of f32) | LZ window covers full coordinate stream; partial matches still compress |
| Palette reuse (same RGBA repeated across elements) | 4-byte color block → frequent LZ back-ref |
Path command repetition (M L L Z patterns) |
Opcode byte stream compresses like source code |
| Style blocks for uniform elements (many shapes same fill) | LZ matches the entire style block verbatim |
Unlike MPX (which splits by channel), MSX passes the stream as-is because opcode-interleaved data actually benefits from LZ finding cross-type patterns (e.g., a circle tag byte followed by its color is a recurring 5-byte sequence in icon sets).
CLI Reference
msx-cli's commands all accept either DixScript source or a compiled binary for any <file> argument — load_scene/load_scene_bytes sniff the "MSX\0" magic bytes and dispatch to msx-parser or msx-binary accordingly.
msx render <file.msx> Evaluate/decode → SVG
[-o out.svg]
msx compile <source.msx> DixScript → binary .msx
[-o out.msx]
[--no-compress] Skip MBFA on the binary payload
msx rasterize <file.msx> → PNG via msx-render-cpu
[-o out.png]
msx rasterize-gpu <file.msx> → PNG via msx-render-gpu instead —
[-o out.png] the only path that actually executes
[--time <seconds>] `Def::Shader` WGSL rather than painting
`fallback_color`. Only in `--features gpu`
builds; a clear error (not a panic) if no
GPU adapter is available.
msx animate <source.msx> Samples msx-anim's keyframe timeline,
[-o out.gif] exports a looping GIF via msx-render-cpu.
[--fps <n>] Errors if the scene has no animation
tracks — use `rasterize` for a static one.
msx animate-gpu <source.msx> GPU counterpart — samples BOTH clocks
[-o out.gif] that can move at once (msx-anim's
[--fps <n>] keyframe timeline AND a shader-def's
`time` uniform) at the same t per frame.
The right tool whenever a scene uses (or
might use) a shader def and keyframes
together.
msx info <file.msx> Print header + scene stats
msx validate <source.msx> Parse + type-check only; exit code
msx roundtrip <source.msx> source → binary → SVG text comparison,
plus a float-tolerant check of
duration/loop_mode/animations (the SVG
comparison alone can't see those —
static SVG output doesn't depend on them)
msx view <file.msx> Rasterize to a temp PNG and open it in
the system's default image viewer — a
quick one-shot preview, distinct from
the separate `msx-viewer` app below
msx extract-media <file.msx> --id <id> Pull a Def::Audio's raw bytes back out
[-o out.wav] to a real file — `.wav`/`.ogg`/`.mp3`
if the bytes sniff as one, `.bin`
otherwise (sniffing is informational
here, not a gate). The only way to
actually verify embedded/referenced
audio with a real audio-aware tool.
msx-viewer (a separate binary, apps/msx-viewer — not the msx view subcommand above) opens an .msx file in a persistent native window. It plays a scene's keyframe timeline live if it has one (see apps/msx-viewer/src/playback.rs) — the shader time uniform is a separate, GPU-only clock the viewer doesn't drive yet.
Example: Parametric Badge Component
// badge.msx
@CONFIG( version -> "1.0.0" )
@ENUMS(
Variant { Primary = 0, Success = 1, Warning = 2, Danger = 3 }
)
@QUICKFUNCS(
~badge<object>(x, y, label, color) {
return {
type = "group"
elements = [
{ type = "rect", x = x, y = y, width = 90, height = 30, rx = 15,
style = { fill = color, stroke = "none", stroke_width = 0, opacity = 1.0 } }
{ type = "text", x = x + 45, y = y + 20, content = label,
style = { fill = "#ffffff", font_size = 12, text_anchor = "middle",
font_weight = "bold", stroke = "none", stroke_width = 0, opacity = 1.0 } }
]
}
}
~card<object>(x, y, w, h, accent) {
return {
type = "group"
elements = [
{ type = "rect", x = x, y = y, width = w, height = h, rx = 8,
style = { fill = "#ffffff", stroke = "#e0e0e0", stroke_width = 1, opacity = 1.0 } }
{ type = "rect", x = x, y = y, width = w, height = 4, rx = 2,
style = { fill = accent, stroke = "none", stroke_width = 0, opacity = 1.0 } }
]
}
}
)
@DATA(
scene: { width = 700, height = 300, background = "#f4f5f7" }
elements::
badge(40, 30, "primary", "#007bff")
badge(150, 30, "success", "#28a745")
badge(260, 30, "warning", "#ffc107")
badge(370, 30, "danger", "#dc3545")
card(40, 100, 180, 160, "#007bff")
card(250, 100, 180, 160, "#28a745")
card(460, 100, 180, 160, "#dc3545")
)
Scope and Non-goals
MSX is:
- A source format for authoring parametric/reusable vector graphics in DixScript
- A compact binary interchange format for tool-to-tool transfer, including keyframe animation data
- A compression target for MBFA on coordinate + opcode streams
MSX is not:
- A raster image format (that is MPX)
- A browser-native format — SVG is an export target, not the runtime format
- A format with pattern fills, clip-path/mask, or CSS-style timing functions beyond the fixed
Easingset below — none of these exist yet in any renderer
Current feature scope:
| Feature | Status |
|---|---|
| rect, circle, ellipse, line, polygon, polyline | ✅ all three renderers (SVG/CPU/GPU) |
| path (all SVG commands) | ✅ all three renderers |
| text | ✅ all three renderers |
| group + transform | ✅ all three renderers |
| linear, radial, conic gradient | ✅ all three renderers |
| use / def referencing | ✅ all three renderers |
| SDF shape trees (circle/box/line/ring/arc + boolean combinators) | ✅ all three renderers |
| Gaussian splats | ✅ all three renderers |
| Image (file-ref or embedded, PNG/JPEG/GIF) | ✅ all three renderers |
| Audio (file-ref or embedded) as a def | ✅ round-trips losslessly; nothing anywhere plays it — extract-media is the only way to pull it back out and verify with a real tool |
| Layer (isolated compositing group) | ✅ all three renderers agree on nesting/paint-order semantics |
| Layer blend modes (Multiply/Screen/etc.) | ✅ CPU, SVG, GPU |
| Layer effects — Blur | ✅ CPU, SVG, GPU |
| Layer effects — DropShadow/InnerShadow/OuterGlow/InnerGlow | ✅ CPU, SVG · ❌ GPU (flagged, not silent — see msx-render-gpu's effects.rs) |
| Shader defs (real WGSL fills) | ✅ GPU only (--features gpu) — CPU/SVG paint fallback_color instead, by design |
Keyframe animation (animations::, duration, loop_mode) |
✅ parses, compiles to binary, round-trips, bakes to GIF (msx animate/msx animate-gpu), plays live in msx-viewer |
Live GPU playback (shader time uniform driven continuously in the viewer) |
❌ not started — see apps/msx-viewer/src/window.rs's module doc |
| MBFA binary compression | ✅ |
| SVG export | ✅ |
| True per-element paint order within one Run (vector/SDF/splat interleaving) | ❌ still type-batched, not real document order — a real, identified, deliberately deferred gap |
| Pattern fills | ❌ |
clip-path / mask (beyond a Layer's own clip: bool, which only clips to the layer's own pixel footprint) |
❌ |
| Font embedding | ❌ |