Architecture
EZ2BMS is a Tauri 2 desktop application: a Svelte 5 + PixiJS front end in the
system webview, and a Rust back end for everything that touches audio samples,
processes or the disk. This page is the map; the per-format decisions live in
bmson-dialect.md and the engine contract in
ez2port-compat.md.
The rule: one implementation of each concern
| Concern | Lives in | Why |
|---|---|---|
| Chart model, timing, modes, editing, undo, lint | packages/chart-core (TypeScript) |
the editor edits it at 60+ fps without crossing IPC; Vitest tests it without a webview |
Every byte format: bmson, BMS, EZFF .ez, .ezi, .ini, song.ini, .abm, .gds, .pvi, the cipher, PE key-table extraction |
packages/chart-core |
pure byte work on the model; one implementation, tested against the EZ2PORT oracle |
| The EZ2PORT judge/score/gauge rules, voice rules, publish plan | packages/chart-core |
Play mode and Publish must agree with each other by construction |
Decode, resample, mix, render, cut, encode (.ssf/.ezw/wav) |
crates/ez2bms-audio (Rust) |
real-time and sample-exact work |
Launching ez2play, capturing its log |
crates/ez2bms-launch (Rust) |
process control |
Decoding images, cutting the disc and eyecatch, rendering title plates (the pixels; chart-core has the specs and encodes the .abm) |
crates/ez2bms-media (Rust) |
decoders, fonts and per-pixel work, byte-checked against the port |
| Files, dialogs, settings, IPC glue | src-tauri (Rust) |
the host |
The front end talks to native code only through one interface,
apps/editor/src/bridge/types.ts (Backend). tauri.ts implements it in the
app; web.ts implements it in a plain browser with an in-memory file system and
a silent clock, which is what Playwright and browser development use.
Layout
packages/chart-core/src/
model/ ChartDoc, NoteRec, channels, extras (unknown JSON kept), note index
timing/ tempo map (f32-faithful), ticks (48/beat), measures, snap, rescale
modes/ mode registry, lane kinds, bundled lane orders, file names
ez2data/ .gds, .pvi, .abm, PE + key tables, cipher
io/bmson/ parse / serialize / mode resolution / legacy renumbering
edit/ ops + inverses, transactions, history, selection, clipboard, commands
engine/ EZ2PORT emulation: voices, judge, holds, gauge, score, sessions
publish/ the compiled plan and the package writers (EZFF, ezi, ini, song.ini, plate);
audible.ts: what a chart sounds like, for Classic mode's checks
sound/ chart names -> folder files, BmsTWO's grouping, song-wide usage, renames
lint/ rules, grouped by severity
input/ keys.ini and binding tokens, scancodes, channel routing, the input mapper,
settings.ini's Debounce, latency calibration
apps/editor/src/
bridge/ state/ commands/ input/ render/ skin/ audio/ port/ ui/ theme/
crates/
ez2bms-audio/ sample cache, peaks, mixer with EZ2 voice rules, offline render, cutting
ez2bms-launch/ ez2play discovery, capability probe, isolated temp songs root
ez2bms-media/ song art (disc, eyecatch) and title plates, as the port makes them
ez2bms-input/ game controllers through SDL3 (feature `sdl`), named and timed as the port's
ez2port-oracle/ TEST ONLY: builds third_party/ez2port-core and answers JSON queries
third_party/ez2port-core/ vendored snapshot of EZ2PORT's ez2core (GPL-3.0-or-later)
Two skins, one playfield
The playfield draws with a procedural neon skin, or, when a game folder is set
and holds a panel for the chart's mode and side, with the game's own
.pvi/.abm art: skin/game.ts finds and decodes it, render/gameskin.ts
turns it into textures once, and the renderer draws with it. The game skin
only changes the lane boxes, the judge line and how things look; scrolling,
hit testing and the tools are the same code. What is reproduced from the
port's scene/skin.c, and what is not, is in ez2port-compat.md.
The neon skin's note heads are bars across the lane or orbs, one size in
every lane with holds as capsules (Preferences; the noteSkin setting).
render/geometry.ts's noteBox says where a head sits and how far from it
a click still takes it, so the drawing, hit testing, the draw tool's ghost
and a recorded take agree. The game skin keeps its own art whichever is
chosen: the shape is the editor's, not one of the game's note styles.
Both skins read the game folder through skin/vfs.ts, the port's
any-case, .abm-first resolver. The song manager's wheel preview reads the
song select's masks through it too (skin/select.ts) and draws a Canvas 2D
scene (render/wheel.ts) rather than a second WebGL context beside the
playfield's; where the discs and plates sit, and how they move, is
chart-core's oracle-checked ez2data/selectwheel.ts.
Big files stay on disk
A BGA movie can be hundreds of megabytes, so it never crosses the bridge:
chart-core's media/movie.ts reads its headers through ranged reads
(fs_read_range, a few kilobytes), the package writer copies it by path
(WriteOptions.copies), and the <video> preview loads it through Tauri's
asset protocol, whose scope holds only the files shown.
One compiler for playback and publishing
chart-core/src/publish/plan.ts turns a chart into exactly what the package
writer will put on disk: slices become keysound entries, background notes are
allocated to tracks, STOPs become gaps, pulses become 1/48-beat ticks, tempo is
rounded to f32 the way EZFF stores it. The editor's preview and Play mode play
that plan, through the same voice rules EZ2PORT uses. What you hear in the
editor is what the engine will play.
Edit and Play share one axis
EZ2PORT scrolls by ticks, not seconds: 1.6 px per tick x speed%, so the space
between two beats never changes when the BPM does. The editor's Edit mode uses
the same pulse axis; Play mode only swaps who drives the position (the audio
clock instead of the user) and the zoom. Flipping between them is an animation,
not a different renderer. A chart's scroll changes (M8) rescale Play's zoom
as a whole, as EZ2PORT's live rate does; the axis stays linear.
Drawing while playing
While the chart plays, the playfield draws every frame, so a frame's cost is
what it does and what it throws away: garbage is collected later, in pauses
the page does not choose. What moves with the chart is therefore moved, not
rebuilt (render/pool.ts): notes, grid lines, strip marks and hold ticks
are pooled sprites (a rectangle is Pixi's white texture, scaled and tinted);
a label keeps its object while its words stay the same, since new words are
laid out and measured again; a Graphics layer that looks as it did last frame
(the lanes, the rack's columns, the judge line) is recorded and compared, not
triangulated again; tints are set only when they change. The stem strips
slide (below). What each of these saved is in
perf-log.md (2026-09-28).
Audio
crates/ez2bms-audio plays what chart-core compiles; it makes no timing or
voice decisions of its own.
- Samples. Decoded once (WAV/OGG/FLAC/MP3 through symphonia,
.ssf/.ezwdirectly), resampled to the device rate with the filter delay removed, and cached while the file is unchanged. Files of 20 s and more are also kept decoded on disk (disk.rs,<app cache>/audio, least recently used out past a cap): a hit is the decode bit for bit, so the editor and every publish can use it. Peak mipmaps feed waveform drawing (whole levels for thumbnails, 4096-bucket tiles for the stem strips) and sit on disk beside a long file, as does its onset and tempo analysis (analysis.rs). - Schedule. Events carry song milliseconds from chart-core. A slice gives its fresh hit's time and the next note's time, so its bounds and the event times come from the same frame positions: a chain of slices plays exactly like the uncut sample at any device rate (tested bit for bit).
- Voices. EZ2PORT's rule: a sound on a voice that is already sounding cuts
it and restarts. Every keysound has a voice (backing, autoplay) and every
lane has one (presses); gains are DirectSound's (
10^(dB/2000), pan turns one side down), and the output stage is the port's soft knee and clamp. - Real time. The renderer never allocates, locks or frees (a test counts
allocator calls). The control side swaps whole transports (schedule, play,
stop, seek) with
arc-swapand keeps retired ones until the audio thread has let go; immediate sounds (auditions, test-play presses) go through anrtrbring. Playing from any frame picks up every sound already under way, mid-sample, with voice cuts applied. - Clock. Each buffer publishes frame, host time and output latency through
a seqlock;
heard_frame_at(now)is what the speaker is playing. The page gets a snapshot at once when playing starts, stops or seeks and every 32 ms while it plays, none while stopped (audio_clock_stream), and extrapolates between them at the device rate: each message is a script the webview runs on the page's main thread, between its frames. - Backends.
cpal(feature) for the device,nullfor a real-time clock without one;offlinerenders through the same renderer for previews, bounces and tests. - Publish. Keysounds are cut from 44.1 kHz audio into 16-bit stereo
.ssf, the port's own format; consecutive cuts join exactly into the uncut sample.
Sounds across the song (M2)
Each chart has its own channel list and undo history; what the charts of a
song share is the folder of sound files. The keysound workbench
(apps/editor/src/ui/workbench) shows that folder: every file and every
missing name as a card, grouped by chart-core's port of BmsTWO's
SampleGrouping (the same groups as the background rack), with where each
is used (sound/usage.ts). Only the cards on screen are built, and their
waveforms come from the host in batches (audio/thumbs.ts).
The background rack beside the lanes shows the same groups, a column
each, sub-lanes where their sounds overlap (BmsTWO's Classic BMS view). It
takes the room right of the lanes and strips (at least 40% of the field),
narrows its sub-lanes to half before anything is hidden, and scrolls the
rest (render/geometry.ts): by the bar under the group names, a sideways
swipe, Shift+wheel, or the wheel over the names. Closing the drawers gives
it more room.
Two song-wide changes behave differently on purpose:
- Replace points channels at another file. The notes now play something else, so it is an edit: one undo step in each chart it touches, and the toast undoes them all.
- Rename renames the file on disk and every reference to it, in every
chart, including their undo and redo history (
ChartDoc.renameSoundRefs), so no undo can bring back a name that no longer exists. It is not an edit: charts without unsaved changes are saved at once, and the toast's Undo renames the file back.sound/rename.tsrefuses a name another reference would start to mean.
Classic mode (edit/classic.ts, on per song in ez2bms.song.json)
charts over music already complete in the background: placing keys a note
the background already has at that spot (classicMagnet pulls the pointer
onto one, the picked sound's group first), deleting un-keys it; placing
never adds a note. Every such edit is dry-run
through publish/audible.ts - only for the sound files it touches, against
a cached analysis of the chart - and refused if the music would change.
What that promise covers, and what it cannot, is in ez2port-compat.md.
Stems (M4)
A stem is a sound file a chart slices: a fresh hit and continuation notes, which EZ2PORT plays as consecutive cuts of the file (slicing).
- The model. chart-core
slice/view.tsturns the notes of the channels playing one file into slices (each note's part of the file, and when it plays) and the stretches that sound (audible(), voice cuts applied), cached per file in the document's analysis (edit/analysis.ts, shared with Classic mode) until those notes change.whenHeardmaps a second of the file to every song time it plays at;PlanTimeline(now in chart-core) maps song time to pulses and back, STOPs as gaps. - The edits.
slice/ops.tscuts, moves, heals and keys slices, chops to a grid and places onsets - all as standard notes, all dry-run throughclassicCheckso autoplay never changes. - The strip.
render/striprows.tsworks out, per pixel row, the stretch of the file playing there and its slice;render/strip.tspaints the rows on a canvas shown as one texture in the playfield, again only when something it shows changed. The canvas reaches past the screen (while playing, a screen ahead) and slides with the cursor, so playing paints it once a screen rather than every frame, one strip a frame. Waveform tiles (audio/peaktiles.ts) come from the host as needed, answered meanwhile from the file's coarse whole level; onsets (audio/analysis.ts) once per file. - The state.
state/strips.svelte.ts: which files have strips, the slicing actions with their refusals, hover audition, the panel's plans; the pointer tool's strip gestures and the knife call into it.
Importers (M5)
Everything an import reads is chart-core's, pure and synchronous over bytes (importing); the editor supplies the bytes and writes the result through one host command.
- The game.
ez2data/songdb.ts(the song tables and their cipher),io/ez/ezi.tsandengine/songini.ts(a chart's keysounds and settings, EZ2PORT's parsers transcribed),ez2data/songtext.ts(titles from the port's manifest).io/ez/game.tswalks a data folder through a small async file system (GameFs), matching names in any case as the port does, and gathers one song's files;io/ez/import.tsturns them into bmson, the song file and a list of keysounds to copy. - BMS.
io/bms/decode.ts(encoding),parse.ts(commands and control flow, the random values chosen by the caller),convert.ts(exact positions, lanes by channel map),song.ts(a folder as one song, with per-file choices). - bmson.
io/bmson/v021.tsupgrades 0.21;open.tsis how the editor opens any bmson (upgrade, mode, legacy renumbering) and what it says. - MIDI.
io/midi/smf.tsreads note starts and tempo;slice/midi.tsplans and makes cuts through the M4 slice operations. - Notes. What opening or importing says is
OpenNotes: a chart's go toLintChart.notes, a song's toLintSong.notes, and an import's are kept in the song file (source.notes) so Issues shows them on every open. - Writing. The host's
import_run(ez2bms-audioimport.rs) writes the new folder in a staging folder beside it and renames it into place; an.ssfbecomes a.wavby putting a RIFF header on its unchanged PCM. - The wizard.
state/importer.svelte.tsholds what was found and chosen;ui/import/ImportWizard.svelteshows it. The browser build stages a made-up game folder (?game, chart-coredev/synthgame.ts) for tests.
Exporters (M6)
The bytes are chart-core's, as publishing's are (exporting); the host makes the sounds and writes the files, all or nothing.
- To the game.
publish/chart-plan.tscompiles a chart withtarget: 'cabinet': an imported chart'sx_*members pin its background notes to their tracks (the allocator inpublish/tracks.tskeeps busy intervals and places everything else around them), and its kept records and header go back.publish/cabinet.tsplans a song into one the game has (planCabinet: files, one keysound table, thesong.binedits), names its keysounds against the folder (nameSounds) and makes the bytes (finishCabinet: encrypted with the executable's tables;ez2data/songdb.tspatchSongdbchanges only the edited bytes).lint/cabinet.tsis what the original executable does differently.io/legacy-text.tswrites CP949 and Shift-JIS by inverting the platform's own decoders, with no table committed. - BMS.
io/bms/write.tsis the reader's inverse (convert.ts's maps, turned around);io/bms/export.tsmakes a song a folder. - The host.
ez2bms-audioexport.rsmakes each keysound: a 16-bit PCM file rewrapped untouched (so the game's own sound goes back as it was and is recognised in its folder), anything else cut as a publish cuts it.ez2bms-launchgamepatch.rswrites into a game folder: every file checked against what the plan read, staged, the replaced ones backed up with a manifest, then renamed into place, with a rollback;restoreundoes it. - The dialog.
apps/editor/src/port/{cabinet,bms}.tsrun the steps and build the job;state/exporter.svelte.tsholds the choices;ui/export/ExportDialog.svelteshows the review.state/game.tsis the game folder as both the importer and the exporter read it.
Input and recording (M7)
Every press - keyboard or controller - takes one path, and each step is the port's rule where the port has one (recording, compat):
- Devices.
crates/ez2bms-inputowns SDL3 on a thread of its own (joystick layer only, built from source and linked in). Boards are namedvid:pid[#n]asezpad.cnames them; each event carries SDL's stamp, moved onto the audio engine's host clock by its age (both clocks read back to back per batch). Pads are open only while the editor holds them, and closed for every EZ2PORT test run (DirectInput is exclusive).src-tauriinput.rsstreams batches to the page. - The hub (
apps/editor/src/input/hub.svelte.ts) is the one entry point: keys byKeyboardEvent.code→ SDL scancode, pads from the stream, both on the host clock, EZ2PORT's 200 ms age rule applied once. Bound keys are kept from the editor's shortcuts while something listens. - The mapper (chart-core
input/mapper.ts,ezinput.c/ezpad.ctranscribed): bindings (keys.inigrammar,input/keyconf.tsandbindspec.ts, oracle-exact) turn raw events into channel edges - the OR of alternates, the debounce, hats, the turntable's steps and holds. - Routing (
input/channels.ts): a channel goes to the lane EZ2PORT sends it to in the chart's mode, or, in ScratchMix, strums. - Consumers. Test play (
play/controller.svelte.ts→ the port's judge,engine/strum.tsfor ScratchMix), step input, Record mode (play/recorder.svelte.ts→ chart-coreedit/record.ts: snap, review, apply as one undo step throughChartDoc.group), the latency tests (state/calibrator.svelte.ts→input/calibrate.ts) and the Controls dialog (binding by pressing: the mapper's capture).
Song time for a press is AudioClient.songMsAtHost(hostMs) - the same
clock arithmetic as the cursor - less the input offset. The metronome's
clicks are made in the host (ez2bms-audio click.rs) and scheduled as
engine events beside the chart's.
EZ2-native extras (M8)
- Hold kinds.
engine/holdpreview.tssays, for a hold, where each instalment falls due, how many a clean play is paid and what the note counter counts, from the sameholdStep/holdInstalments/noteCountedthe play session judges with; its test runs the session in autoplay and finds the instalments where the preview put them. The renderer draws a tick at each (cached per chart version) and the Inspector the counts. - Scroll changes.
ChartData.scrollEvents(bmsonx_scroll_events), edited bysetScrollAtin one transaction.timing/scroll.tsholds both halves of what EZ2PORT does:ez2/scroll.c's arithmetic (oracle-checked bit for bit) and the play loop's walk of the records (scrollPoints,multiplierAt, fromreference/play.c).scrollEventsOfadds the records charts imported before M8 kept, so they play and publish too.- Packages get them as type-6 records on track 0, the cabinet on their own tracks with their own second word.
- The renderer multiplies Play's pixels per beat by the multiplier at the cursor, and while playing eases to it a tenth of the gap per 1/60 s.
- Kept records.
io/ez/kept.tsdescribes what an imported game chart kept inx_ez_records; the renderer tags them in the gutter and the Timing panel lists them. A change of resolution moves them with the notes.
Polish and distribution (M9)
- Languages. English, Korean and Japanese, chosen in Preferences or
following the system. Every word the editor shows comes from a catalog
in ICU MessageFormat (
intl-messageformat): the editor's inapps/editor/src/i18n/{en,ko,ja}/(one file per area), chart-core's inpackages/chart-core/src/i18n/. English is the source; a translation is typed against it and a message it lacks falls back to English.t(key, params)reads the language as Svelte state, so switching re-renders every screen; command titles arecmd.<id>keys, and the palette also searches the English ones.- chart-core's findings and notes carry a
said({key, params}) beside their Englishmessage, and the editor shows them withtCore, so a lint finding or an import note stored in a song file is said in the language chosen now, not the one it was made in. A note from an older song file has only its English. - The host sends each error as
{kind, message, params}(src-tauri/src/error.rs); the bridge words it fromhost.<kind>(bridge/hosterror.ts). The OS's own error text is passed through, and the log keeps the English. - Tests: every message parses and each translation takes the English
parameters (
catalogProblems); no screen has words written into its markup (i18n/scan.ts); every command's title is in the catalog; the host's error kinds each have a sentence.?pseudomarks every catalog message (⟦Øpén⟧) to find what is still hard-coded. The words chosen are ini18n-glossary.md.
- The log (
src-tauri/src/diag.rs):tauri-plugin-logwrites a rotatingez2bms.login the app's log folder; a panic hook and the editor's uncaught errors land there. A session marker left behind by a run that did not close is offered at the next start; About copies a report (the version, this run's errors and the log's end). Nothing is sent anywhere. - Files from the system (
opened.rs): launch arguments and a second launch's files (tauri-plugin-single-instance) wait in a queue the editor drains; a bmson opens its song, a BMS file the import wizard. - Updates (
update.rs):tauri-plugin-updater, registered only in a build carrying the update key's public half (added by the release workflow,scripts/release-config.mjs). The editor looks at most once a day, never while playing, and installs only when asked (state/updates.svelte.ts). Seereleasing.md.
The desktop host (src-tauri)
The host does what a browser cannot, and nothing else; chart logic never
crosses the bridge. Its commands, each mirrored by the web mock in
apps/editor/src/bridge/:
| Group | Commands |
|---|---|
| Files | fs_read (raw bytes), fs_read_text, fs_write_text / fs_write_bytes (atomic, optional .bak), fs_list, project_scan, fs_copy_into (import: never overwrites), fs_rename |
| Settings | settings_load, settings_save (a JSON object the front end owns, in the app's config folder) |
| Audio | audio_info, audio_load, audio_peaks, audio_thumbs (a screenful of waveforms in one call), audio_set_events, audio_play / seek / stop, audio_trigger, audio_set_master |
| Clock | audio_clock, audio_now (for offset pings), audio_clock_stream (a snapshot on start, stop and seek, every 32 ms while playing, over a Tauri channel) |
| EZ2PORT | port_locate, port_probe, port_publish (cuts keysounds, writes the package whole), port_test / port_stop (pads closed for the run), port_config_files (where the port keeps keys.ini) |
| Import | import_run (a new song folder, staged and renamed into place) |
| Export | export_probe (which keysounds the game's folder already holds), export_game (into a game folder, with a backup), export_folder, export_backups, export_restore |
| Input | input_devices, input_stream (pad events in batches over a Tauri channel), input_hold (open or close the pads), audio_clicks (the metronome's samples) |
| App | app_info (version, commit, folders, a run that did not close), diag_log, diag_tail, diag_reveal_logs, opened_take, update_check / update_install / update_restart, update_open_releases |
Without an output device the audio engine falls back to a silent real-time
clock, so Play mode still runs. port_test publishes into a private songs
folder in the app cache, streams ez2play's output back line by line, and
removes the folder when the game exits.
Risks and fallbacks
- WebKitGTK WebGL speed - see
perf-log.md. Mitigations: draw only on change in Edit mode, pooled sprites, density view when zoomed out, no Pixi filters or backdrop blur;WEBKIT_DISABLE_DMABUF_RENDERER=1for NVIDIA blank-window issues; the Tauri CEF runtime as a last resort. - The oracle drifting from the live port -
third_party/ez2port-core/PROVENANCE.mdrecords the build; re-vendor from a newer zip or repository when the port changes. - Nothing from the game ships - key tables, lane layouts and art are read from the user's own install at run time, exactly as EZ2PORT does.
The song file
ez2bms.song.json holds what bmson has no place for - the key, category,
plate, disc, eyecatch, preview and BGA - read and written byte-stably by
chart-core song/songfile.ts. Its members are in song-file.md.
This page is synced from docs/architecture.md in the EZ2BMS repo.