Rust library for working with Visual Pinball VPX files. Also available on npm as a WASM package:
@francisdb/vpin-wasm - see
wasm-readme.md for the JavaScript/TypeScript API.
Join #vpxtool on "Virtual Pinball Chat" discord for support and questions.
The library provides several optional features that can be enabled:
parallel(default): Enables parallel processing using rayon for better performancewasm: Enables WebAssembly bindings for browser/Node.js usagescript-audit: Adds script-level checks tovpx::audit(missingOption Explicit, duplicate procedures, unused variables,Executeusage, VPinMAME setup). Pulls in thevbscriptparser, so it is off by default; thewasmfeature includes it
To use only VPX functionality without parallel support:
[dependencies]
vpin = { version = "0.29", default-features = false }To enable specific features:
[dependencies]
vpin = { version = "0.29", default-features = false, features = ["wasm"] }Check the examples folder
The library supports extracting VPX files to an expanded directory format for easier editing and version control.
Primitive mesh data is written as Wavefront OBJ. For glTF/GLB, use the whole table exporter in
vpin::vpx::export::gltf_export.
When extracting VPX files, the library can optionally generate mesh files for game items that don't store explicit mesh
data but are defined by drag points (walls, ramps, rubbers, flashers). Use ExpandOptions to enable this:
use vpin::vpx::expanded::ExpandOptions;
let options = ExpandOptions::new().generate_derived_meshes(true);ExpandOptions::filter takes a predicate over each file's path relative to the expanded directory. Rejected files
are not written and the work to produce them (decoding bitmaps, decompressing meshes) is skipped, which makes
extracting only the JSON files of a large table fast. Index files such as images.json still list every entry, so a
partial directory documents what was left out; it cannot be read back as a table.
use std::path::Path;
use vpin::vpx::expanded::ExpandOptions;
// everything except the image and sound data
let options = ExpandOptions::new().filter(|path: &Path| {
!path.starts_with("images") && !path.starts_with("sounds")
});vpin::vpz reads and writes the VPZ table pack of Visual Pinball X, a table as JSON documents and assets in
their native formats, stored as a directory or as a .vpz zip archive. The format is still marked preliminary
upstream, see VPZ File Format.
let pack = vpin::vpz::read("table.vpz")?;
println!("{} parts", pack.parts.len());
vpin::vpz::write(&pack, "table-folder")?;The manifest and the asset sidecars are typed; the table and scene documents are ordered JSON properties.
vpin::vpz::from_vpx gives the pack vpinball saves from a table, byte for byte for a table vpinball last saved
itself. vpinball upgrades older tables on load (defaults of newer fields, legacy fields converted, invalid references
cleared) before saving them; the conversion keeps the fields of the file, and carries the legacy fields vpinball
converts on load.
vpin::vpz::to_vpx gives the table vpinball saves after loading a pack. Meshes go through meters and back, so their
vertices may move by a float step, as they do in vpinball.
let vpx = vpin::vpx::read(std::path::Path::new("table.vpx"))?;
let pack = vpin::vpz::from_vpx(&vpx, "Mon Oct 5 12:00:00 2026")?;
vpin::vpz::write(&pack, "table.vpz")?;
let table = vpin::vpz::to_vpx(&vpin::vpz::read("table.vpz")?, "Mon Oct 5 12:05:00 2026")?;The library can export a complete table - generated meshes for every part type, materials, and textures - for use in Blender and other 3D tools:
- glTF/GLB (
export::gltf_export::export_gltf) - PBR materials, embedded textures, the three VPinball view cameras, andKHR_lights_punctuallights. Output follows the glTF conventions (Y-up right-handed, meters) so it opens correctly with default import settings. - OBJ + MTL (
export::obj_export::export_obj) - with texture extraction, MTL dedup, and configurable output.
Both exporters take options for the output units (ExportUnits: VPU, mm, cm, m) and the OBJ exporter also for the
axis convention (AxisConvention):
use vpin::vpx::export::obj_export::{export_obj, AxisConvention, ExportUnits, ObjExportOptions};
let options = ObjExportOptions {
units: ExportUnits::M,
// Y-up right-handed opens upright in Blender with default import
// settings; the default (ZDownRightHanded) matches vpinball's own
// OBJ export convention.
axes: AxisConvention::YUpRightHanded,
..ObjExportOptions::default()
};Note that the glTF specification defines meters and Y-up as the only conforming conventions - the unit option exists for pipelines that expect a different scale.
Which items an export includes is an ItemFilter on the options (export::item_filter). Both exporters default to
ItemFilter::everything(), every item type with geometry; ObjExportOptions::vpinball_strict() narrows it to what
vpinball's own File -> Export -> OBJ Mesh writes (ItemFilter::vpinball_obj_export(), declared in
export::vpinball_rules). Either exporter takes either preset, narrowed or widened by type, name, editor layer
visibility or a predicate:
use vpin::vpx::export::gltf_export::GltfExportOptions;
use vpin::vpx::export::item_filter::{ItemFilter, ItemType};
// A GLB with only what vpinball's OBJ export would carry, plus the lights
let options = GltfExportOptions::glb()
.with_filter(ItemFilter::vpinball_obj_export().with_type(ItemType::Light));
// Everything except the items on hidden editor layers
let filter = ItemFilter::everything().skip_editor_hidden(true);See the examples folder for complete export examples, and
wasm-readme.md for the same functionality from JavaScript (export_glb, export_obj).
VPinball uses a left-handed coordinate system:
(0,0)───────────────→ +X (right)
│ ┌───────────┐
│ │ │
│ │ Playfield │
│ │ │
↓ └───────────┘
+Y (towards player)
+Z points up (towards the cabinet top glass)
- Origin: Top-left corner of the playfield (near the back of the cabinet)
- X-axis: Positive to the right (across the playfield)
- Y-axis: Positive towards the player (down the playfield)
- Z-axis: Positive upward (towards the glass)
Due to the Y-axis pointing down, winding order appears reversed compared to standard mathematical conventions:
- A polygon whose vertices go clockwise on screen has a positive orientation determinant
- A polygon whose vertices go counter-clockwise on screen has a negative orientation determinant
VPinball's triangulation algorithm (used for flashers, walls, etc.) normalizes all polygons to counter-clockwise order before processing. This library matches that behavior.
https://github.com/francisdb/vpxtool https://github.com/jsm174/vpx-editor
- Visual Pinball https://github.com/vpinball/vpinball
- VPUniverse https://vpuniverse.com/
- VPForums https://www.vpforums.org/
- Virtual Pinball Chat on Discord https://discord.com/invite/YHcBrtT
We expect a folder ~/vpinball/tables to exist that contains a lot of vpx files. The tests will recursively search
for these files and run the tests on them.
cargo test --release -- --ignored --nocaptureThe corpus tests run tables in parallel across all cores. Every in-flight table
holds several copies of itself in memory, so on a machine with many cores this
can use a lot of RAM. Set RAYON_NUM_THREADS to reduce the number of worker
threads if needed, for example RAYON_NUM_THREADS=8.
# Install the target and wasmtime (do this only once)
rustup target add wasm32-wasip1
# Recommended, installs a prebuilt binary in seconds.
# See https://docs.wasmtime.dev/cli-install.html for other options like your package manager
curl https://wasmtime.dev/install.sh -sSf | bash
# Alternative, builds wasmtime from source which takes over 5 minutes
cargo install wasmtime-cli
# Run tests
cargo test --target wasm32-wasip1 --features wasm# Install the target and wasm-bindgen-test (do this only once)
rustup target add wasm32-unknown-unknown
cargo install wasm-bindgen-cli
# Run tests
cargo test --target wasm32-unknown-unknownThe fuzz/ directory contains cargo-fuzz targets that feed random bytes
into the parsers to find inputs that crash the library. Parse errors on garbage input are expected; panics are bugs.
There are three targets:
vpx_from_bytes- the full VPX read path, including the compound file containergamedata- the BIFF gamedata parser, bypassing the container so the fuzzer spends its time in this library's own code (much higher throughput)gameitem- the game item parser, covering all item types
# Install the tooling (once). Fuzzing requires the nightly toolchain because
# cargo-fuzz relies on unstable compiler flags; the library itself stays on stable.
rustup toolchain install nightly
cargo install cargo-fuzz
# Run a target (stop with ctrl-c, or bound it with e.g. -- -max_total_time=600)
cargo +nightly fuzz run gamedata
# The full-file target works best when seeded with a real table
mkdir -p fuzz/corpus/vpx_from_bytes
cp testdata/completely_blank_table_10_7_4.vpx fuzz/corpus/vpx_from_bytes/
cargo +nightly fuzz run vpx_from_bytesWhen a crashing input is found it is written to fuzz/artifacts/<target>/. Re-run the target with that file as an
argument to reproduce the crash, and use cargo +nightly fuzz tmin <target> <file> to minimize it to the smallest
input that still crashes. The corpus under fuzz/corpus/ grows as the fuzzer discovers new code paths and is worth
keeping between runs.
We use https://github.com/release-plz/release-plz which creates a release pr on every commit to main