A web-based Trackmania 2020 map editor: three.js viewport, searchable block
palette, layers with per-layer grid settings and transforms, and .Map.Gbx
import through a bundled GBX.NET converter. Export produces placement JSON
for the separate gbxbuild toolchain.
The repo ships no game assets — block meshes and textures are Nadeo's
content, so you extract them once from your own TM2020 install. You need:
Trackmania 2020, Openplanet, Node 22+ (there's a
mise.toml if you use mise), and the
.NET 8 SDK (for the converter).
npm install
npm run dev # http://localhost:5199
Open the editor and follow the Get started dialog — it walks you through the one-time asset setup:
- Point it at your
OpenplanetNextfolder (auto-detected on the default install). It installs the bundled Trackedit Extract Openplanet plugin for you (fromtools/TrackeditExtract). - Launch TM2020 with Openplanet and set plugin signature mode to
Developer (F3 → Openplanet menu → Developer → Signature mode) so the
local plugin loads, then click Trackedit Extract in the Openplanet
menu. It extracts the ~18k files the editor needs to
OpenplanetNext/Extract(a few minutes; the dialog watches the folder). - Back in the editor, hit Import models & textures — meshdump converts
the extraction into
public/meshes/(OBJs + PNG textures, gitignored).
The dialog can't be skipped — real geometry is a prerequisite, so it stays until all three steps are done (it's one-time; it never shows again once the import exists). Afterwards the box icon at the bottom of the tool rail reopens it in manage mode: change the folder, re-import after a game update, or remove the imported assets. Extracted assets stay on your machine — never commit or redistribute them.
With the dev server and Trackmania running on the same machine:
- Open Live editor and click Install / pair game plugin. This uses the Openplanet folder configured in asset setup.
- Load/reload Trackedit Live in Openplanet with Developer signature mode. It requires Editor++; Map Together is not required.
- Open a map in the game's editor, outside testing/driving, and close in-game dialogs.
- Click Connect and load game map in trackedit. The current browser map is saved first, and the game map opens as a separate browser document.
- Close the dialog and edit in either editor. Block placements, deletions, moves/rotations, ground/ghost/free modes, variants, colors and waypoint fields sync automatically. Disconnect auto sync stops syncing and keeps the browser document.
The bridge polls snapshots approximately twice per second while connected and sends batches of block changes with game revision checks and placement readback. It preserves identical duplicate blocks, includes hidden layers in sync, and bakes layer transforms into free blocks. Edits to different blocks merge; conflicting edits to the same block pause sync and preserve the browser edits. Connecting again saves that browser document and loads the game's current state.
Game items are loaded on a locked, read-only layer and update from the game. Editing items in the browser pauses block sync. Skinned blocks are visible but protected from edits through this bridge. Terrain, generated clips, embedded asset transfer, and map settings are outside this block-sync protocol. Unknown meshes use placeholders. This is not a complete archival map import.
Browser undo/redo generates inverse block changes. Incoming game changes that alter the browser document clear its undo stack to avoid undoing stale remote placements. Each outgoing batch creates a game undo point. A rejected/partial batch stops sync; reconnect to inspect actual game state instead of blindly retrying. Large edits are limited to 2000 additions/removals per batch. Game map switches, browser map switches and dropped connections require reconnecting. Only one browser tab may sync with the game at a time.
Reinstall/pair and reload Trackedit Live after restarting the dev server (its
pairing token changes). The local bridge is available through npm run dev, not
in the static production build. Plugin source: tools/TrackeditLive.
.Map.Gbx --(meshdump map)--> map.json --Import--> edit --Export--> placements.json --(tracko gbxbuild)--> .Map.Gbx
- TMX import uses the
mapcommand intools/meshdump, built on GBX.NET. The dev server builds it on first import using the .NET 8 SDK (or a newer SDK with the .NET 8 runtime) and runs its managed DLL on Linux, Windows, or macOS. First build needs NuGet access unless packages are already cached. No local configuration is needed. ExistingTRACKEDIT_GBXDUMP/.trackedit.local.jsongbxdumpsettings override the bundled converter with an external executable. - To convert a local map manually:
dotnet run --project tools/meshdump -c Release -- map input.Map.Gbx output.json. Run converter contract checks withdotnet run --project tools/meshdump.tests -c Release. - Writing
.Map.Gbxstill requiresgbxbuildfrom the companion tracko toolchain; this repo does not include that writer. - The converter reads placed blocks and items, including grid/free transforms,
flags, variants, colors, waypoint fields, skins, item pivots and scales.
Extra placement fields ride along in
placement.metaand export unchanged. This is not a full archival dump: arbitrary GBX chunks, replay data, and all game-specific metadata are not preserved. Capturing pivot/scale metadata does not add support for those transforms to the renderer. - Layers with a non-identity transform (translate/rotate) export as free blocks — the game grid can't represent them, but the game renders them fine.
npx tsx tools/export_cli.ts <map.json> <out.placements.json>runs the same import/export code headless.
Strict layering — lower layers never import from higher ones:
core/ pure domain: document, layers, placements, commands (undo/redo),
catalog, math. No DOM, no three.js. Everything mutates through
Command objects so plugin edits are undoable too.
io/ trackoJson import/export (the gbxdump/gbxbuild JSON dialect)
render/ three.js adapters: SceneView (canvas/camera/lights), CameraRig
(Blender-style orbit/pan + RMB fly), DocumentRenderer (mirrors the
document, one Group per layer), geometry providers
tools/ editor tools (place/select/erase) driven by ToolManager
plugins/ the EditorContext API; built-in features register through it —
anything we can do internally, a third-party plugin can do
ui/ framework-free DOM panels (palette, layers, toolbar, statusbar)
main.ts composition root — the only file that knows every concrete class
window.trackedit exposes the full EditorContext (+ io) in the console.
render/GeometryProvider.ts is the seam for block visuals:
PlaceholderProvider— footprint-sized boxes, always works.MeshProvider— serves real meshes frompublic/meshes/(see below), lazy-loads OBJs, hot-swaps them in as they arrive, falls back to placeholders for anything missing.
TM2020's ~4000 block meshes live in Packs/Stadium.pak, encrypted with keys
only the running game has, so extraction must run inside the game via
Openplanet. The Get started dialog (see
Getting started above) automates the whole flow through the dev server's
/api/setup bridge; the moving parts, for doing it by hand:
-
tools/TrackeditExtract— the Openplanet plugin (copy the folder intoOpenplanetNext/Plugins/). Itsextract_list.txtis the complete set of file paths trackedit needs; the plugin extracts them by exact path via the Fids API (which also resolves the hashed pak names Pack Explorer can't) intoOpenplanetNext/Extract/. -
tools/meshdump— the converter (C#, GBX.NET):cd tools/meshdump dotnet run -c Release -- blocks "%USERPROFILE%\OpenplanetNext\Extract\GameData\Stadium" ..\..\public\meshes dotnet run -c Release -- items "%USERPROFILE%\OpenplanetNext\Extract\GameData\Stadium" ..\..\public\meshesThe optional filter is a name substring, or
@names.txtfor a file of exact block names (re-export a hand-picked set in seconds).python tools/reimport_selection.pydoes that for whatever is selected in the editor tab (Shift+click a set) and reloads the tab — the loop for iterating on a few problem blocks without the ~40 min library pass.Other meshdump commands:
missing <root> <out.txt>(after a game update, regenerate the list of newly-referenced files to append toextract_list.txt),refs/matinfo/iteminfo <file>(debug single files),nodeinfo <file>(every parsed property, one level into the variants and mobils — where the geometry transforms show up),unitinfo <BlockInfo.Gbx>(which clip attaches to which unit face),clipinfo <root> <ClipId>(a clip's raw, unplaced geometry bounds per mobil row),clipobj <root> <ClipId> <air|ground> <row> <out.obj>(that raw geometry as OBJ),geomxf <root>(every mobil carrying a geometry transform),icons <root> <outDir>(each block's editor icon as WebP — the game's own render, the ground truth for reviewing exports). SetMESHDUMP_TRACE_MATERIAL=<name>to print which block and reference first registered a material — the tool for "why does X have the wrong texture".
A few game prefabs (the RoadTech finish, the *v2 ramps, the podium) use
a collision-surface chunk newer than the latest GBX.NET understands, so the
whole prefab fails to load and the block used to export as clips only. The
exporter then salvages the prefab (tools/meshdump/Salvage.cs): it reads
the reference table by hand, re-wraps every inline mesh as its own Gbx so
the same material references resolve, and finds entities that use external
sub-prefabs (index + rotation + position) to place them exactly. Inline
meshes are assumed at the prefab origin. meshdump prefabfail <root> lists
every block whose body prefab GBX.NET refused; items rebuilt this way are
marked salvaged in index.json, and version-suffixed items with nothing
to salvage fall back to the previous version's mesh (aliasOf).
This writes one OBJ per block variant and item, diffuse textures
(DDS → PNG ≤512px) in public/meshes/textures/, index.json (footprints
included) and materials.json. The editor picks everything up on reload.
How the materials are resolved (the parts that go wrong when skipped):
- Terrain modifiers. Variant blocks (
PlatformDirt*,PlatformPlastic*, every*Special<Effect>*, the gameplay gates) reuse one base mesh and swap materials through the block'sMaterialModifier/MaterialModifier2references. Each points at a game-skin (GameSkin/*.GameSkin.gbx, the list of swappable slots and the material each normally uses) plus a folder underMedia/Modifier/holding replacements named after those slots. meshdump applies both, second wins, and exports the result as<Folder>.<Slot>(e.g.Fragile.Decal,PlatformDirt.PlatformTech). Without this every special platform shows Turbo chevrons and every dirt platform the tech surface. A replacement with no shader and no textures is a "null" material: the game draws nothing for it, so those faces are dropped. GBX.NET can't read some skins (TrackWallToDecoCliff, the obstacle and penalty ones); their slot lists are recovered from the decompressed bytes. An unknown skin swaps nothing — never guess a slot list, the one-slotTrackWallToDecoCliffskin is what every plain tech platform references. - Sign / chrono panels (
*_DispInshaders) put their content — turbo arrows, checkpoint digits — in theMulInsideslot; the base color is just the LED-cell backdrop. That slot is preferred as the exported texture, so the backdrop-only look means an old texture cache. - Decals (
DecalGeomshaders) are flaggeddecalinmaterials.json. They sit exactly on the surface they mark; the renderer draws them with a depth bias and alpha blending so they don't z-fight the base surface (the "half the top face is grey" symptom).TAddglow strips are flaggedblend: "add". - Projected textures (
Py*/Pxz*slots) ignore mesh UVs; UVs are synthesised from world position with the bitmap'sDefaultTexCoordScale(1/32 = one tile per grid cell; plastic and canopy tile at 1/8). - Each
materials.jsonentry records itssourceimage, so a re-import regenerates a PNG whenever the chosen slot changes rather than trusting a file of the same name. - The UV vertical convention needs no flip: on every vertical face carrying
the upright TRACKMANIA logo, V grows with world up, which matches
three.js's default
flipY. - Water and glass. Water surfaces ship only a normal map
(
Water_SxSySz), glass walls an alpha texture (*_T); they are flaggedwater/translucentinmaterials.jsonand drawn translucent instead of as opaque grey lids.
How the geometry is assembled (meshdump nodeinfo shows the fields):
- Mobil geometry transforms. A block's mobil can carry
GeomRotation/GeomTranslation(270 do): the game rotates the referenced prefab before placing it. The wall checkpoints reuse the flat ground checkpoint prefab stood up against the wall this way ((-90, 0, 180)+(32, 32, 32)for "Down"); most bottom caps carry a plain +8 lift. Rotation is (x, y, z) degrees applied in that order. Ignoring it left the checkpoint arches lying on the floor. - Vertical clip rows. A vertical clip (
*VFC) is a table of wall segments, not one mesh: air rows are Middle, Top, Bottom, TopBottom, then merged Middle×2/3/4/8/16/32; ground rows are Bottom and TopBottom. The segment a unit shows depends on whether the same wall continues on the unit above/below within the block (a lone 8u wall is TopBottom; a 32u platform wall is Bottom, Middle, Middle, Top). The names in the prefab files confirm the order; they are matched by name with the index as fallback. - Ground bodies carry the same terrain-blending skirts as ground clips
and are trimmed to the block's cells the same way.
Extracted game assets are Nadeo's copyrighted content — keep them local,
never commit or redistribute them (
public/meshes/is gitignored).
The mood skyboxes in public/sky/ are CC0 sky photographs from
Poly Haven, baked to 2k equirects by
tools/fetch_skies.py.
Opening a map from TMX draws the driving line of a ghost under the map's
layer: green at the start, red at the finish, following the layer transform
like every placement. The map's own validation ghost is used when the
author left one in (ChallengeParameters.RaceValidateGhost); otherwise the
dev server lists the map's TMX replays and downloads the one whose time is
closest to the author medal (/recordgbx/<ReplayId>), the most
representative clean line. File ▸ Ghost path from TMX fetches one for
the active layer of an already-open TMX map. The path is stored on the
layer (ghost in the map record) so it survives reloads. The tube's
thickness is a render setting (Render settings ▸ Ghost line thickness).
Extraction is meshdump ghost <Map.Gbx|Replay.Gbx> [out.json], ported from
tracko's ghostdump: TM2020 ghosts keep their samples in CPlugEntRecordData
(107-byte vehicle states, position at byte 47); positions are game world
metres and get the same vertical-origin lift as items on import.
Open Controls in the top menu to choose Trackedit (the existing default), Blender, or Plasticity. Changes apply immediately and are remembered in this browser across maps and reloads. The dialog shows the active shortcuts.
| Action | Trackedit | Blender | Plasticity |
|---|---|---|---|
| Orbit | MMB drag | MMB drag | MMB drag |
| Pan | Shift+MMB | Shift+MMB | RMB or Shift+MMB |
| Zoom | Alt+wheel (dolly) | Wheel or Ctrl+MMB | Wheel or Ctrl+MMB |
| Build height | Wheel | Alt+wheel | Alt+wheel |
| Move / rotate | T / R | G / R | G / R |
| Frame selection | Frame button | Numpad . | / |
| Fly mode | F or hold RMB | Shift+backtick | Shift+backtick |
| Confirm transform | Enter / left click | Enter / left click | Enter / left or right click |
| Cancel transform | Esc / right click | Esc / right click | Esc |
Blender and Plasticity use WASD + Space/C only in fly mode. Esc, the fly shortcut, or a click exits toggled flight. P (grid constrained ↔ unconstrained placement), E (select), Delete, and Ctrl/Cmd+Z / Ctrl/Cmd+Shift+Z remain editor shortcuts. X also deletes in the select tool with either modeling preset. Presets cover navigation and supported transforms; scaling and mesh-editing commands are not implemented. Reference mappings: Blender navigation and Plasticity keymap.
The following describes the default Trackedit scheme:
- Left click — active tool, P — toggle grid constrained ↔ unconstrained placement (a setting, it never changes the active tool; it also governs the T/R transforms in select mode: grid constrained snaps moves to the layer grid and rotations to the layer's rotation step (a layer setting, 90° by default, shared by the layer itself and everything in it), unconstrained moves freely and rotates in whole degrees), E — select mode; picking a block in the palette is what starts placing
- Selection box handles — drag an axis tag (X, Y, Z) to move the selection along that axis; drag the ring beside it to rotate: the X ring turns in the XY plane (about Z), the Y ring in XZ (about Y), the Z ring in ZY (about X). Same snapping rules as the key sequences
- Click-drag in grid place — paints blocks across cells (one undo step)
- X/Y/Z while placing — constrain placement to that axis from where the ghost was (Y slides vertically); click places & releases, right-click/Esc releases without placing
- R — rotate armed block (place tool), scroll — build level up/down on the active plane; while placing, a green plumb line shows the height and green outlines mark everything at the same level
- Del — delete selection, Ctrl+Z / Ctrl+Y — undo/redo
- WASD + Space/C — fly movement is always on (steady pace, Ctrl sneak,
Shift sprint); F toggles mouselook (F or any click lands you), or
hold RMB.
Sis never bound to anything — it belongs to flying. - Sequence shortcuts (HUD shows the sequence bottom-left; left
click/Enter commits, right click/Esc cancels — see
docs/SPEC-sequence-shortcuts.md):
cx|y|z/clx|y|z— slide the camera on a global / layer axist— translate selection (free); addx|y|zto constrain to an axis, two axes for a plane; snaps to the piece's layer gridr[x|y|z] — rotate selection (personal frame); grid blocks snap to quarter turns on Y — pitch/roll (or a typed non-90° yaw) converts the block to a free block, exactly in placelinsidet/r— retarget the layer itself (move/spin the whole plane); with nothing selected,t/rtargets the active layer directly- type digits for exact values (
tx-99→ −99 m), Backspace edits, Enter confirms
- MMB drag — orbit, Shift+MMB — pan, Alt+scroll — slide the camera along its view direction, Ctrl+scroll — raise/lower the camera straight up/down (world axis, ignores layer tilt)
- Hold RMB — fly: mouselook + WASD, Space/C up/down, steady pace with Ctrl sneak and Shift sprint
public/catalog.json (3,769 official blocks + 763 Nadeo items with usage
counts) is generated by tools/build_catalog.py from a corpus of parsed maps.
Regenerate with python tools/build_catalog.py <parsed-maps-dir>.
- Blender-style sequence shortcuts / modal operators (
c y,t l x -99, numeric input, HUD) — see docs/SPEC-sequence-shortcuts.md - meshdump: all variants + ground/air selection by placement, icons, textures (DDS → PNG/KTX2), materials
- Vertex/edge snap modes, align-to-plane across selections
- Multi-select, grouping, gizmo translate/rotate
- Custom item/block support (embedded assets)
- Instanced rendering for very large maps
Select a block and click Frame in the status bar to move the camera around its bounds. Click Isolate beside it to show only the selected blocks, then Show all to restore the other blocks. Isolation only affects the current view; it does not change saved layer visibility. The placement ID beside these buttons can be copied for repeatable debug views. With the editor tab open, the development server also provides:
/api/debug/command?action=inspect&uid=p_n_etm1: world bounds, mesh vertex/UV counts, and material texture URLs, sidedness, and vertical-flip settings./api/debug/command?action=reload: reload the editor tab (after a mesh re-import; edits autosave, so nothing is lost)./api/debug/command?action=tmx&uid=<MapId>: open a TMX map through the same flow as the dialog (download, import, ghost line)./api/debug/command?action=focus&uid=p_n_etm1&yaw=112&pitch=-5&distance=42: frame that placement. Angles are degrees; negative pitch looks down. Omit distance to fit the block to the viewport, including narrow viewports./api/debug/screenshot?uid=p_n_etm1&yaw=112&pitch=-5&distance=42&isolate=1: return a PNG with other placements temporarily hidden. Camera and visibility are restored after capture. Omitisolate=1to include surrounding blocks./api/debug/screenshot?target=view: capture the current viewport.
Omit uid to use the selection. If multiple editor tabs are open, pass
client=<client ID from /api/debug/state> to target the tab that posted that
snapshot. Missing selections or placement IDs fail rather than capturing an
unrelated view. These endpoints are for the Vite development server.
After changing clip attachment transforms, regenerate the block meshes and run
python tools/test_curve_clips.py. This checks the open ends of flat and
banked quarter curves against exported clip geometry, and verifies that their
undersides remain present. Banked cap heights are also checked at both ends
against the road, catching caps that sit on the right face but face backwards.
python tools/diagnose_clips.py air RoadTech provides
a broader, heuristic attachment report. GBX east attaches at a unit's x=0
face and west at x=32; reversing these can put braces across a curved wall
while still passing a whole-block bounding-box overlap check.
The clip meshes themselves follow one authoring convention (meshdump clipinfo shows it for any clip): a panel in the plane z=32 spanning
x 0..32, looking into the adjoining cell. Every side-face clip — face caps
and one-piece wall panels alike — is therefore placed the same way: yaw to
the face, then turn inward about the face centre. Asymmetric panels (the
48-wide loop-start walls overhang one end) are the tell-tale: without the
inward turn they hang outside the block. Top and bottom caps carry no
direction either; they are tried at all four yaws and keep the one that
sits on the body — per cell of the cap's footprint, the body's height must
meet the cap (a coping strip turned 90° along a quarter pipe floats above
the curve; a thin wall's strip turned 90° covers empty cell). A cap the
game attaches to several units of one block (chicanes, diagonals) is one
plate split into point-symmetric halves, so each placement is also scored
on staying inside the block and off the copies already placed. Caps, and
every clip of a ground variant, are trimmed to the block's own cells: the
game's ground undersides and wall rows carry terrain-blending skirts that
spread several cells into the neighbours. A clip is kept only if it seats
against the block body; on blocks with no body of their own (deco cliffs,
stage supports are pure clip assemblies) it must reach the block's cells
instead — earlier the first wall merged rejected every wall on the far
faces.
Two more checks for a whole-library pass:
python tools/check_clips.pyscans every exported block for clip geometry outside its footprint and lists the worst offenders — compare the counts before and after a meshdump change.python tools/block_sheets.py --reload(editor tab open) captures every placement of the open map in isolation from three angles and writes one contact sheet per block tosheets/, for reviewing a whole map at once.python tools/catalog_sample_map.py --per 21 --spread --openbuilds a review map with blocks from every catalog category laid out in rows (evenly spread through each family, or the most-used ones without--spread), saves it through/api/mapsand opens it in the editor tab.- Reviewing by hand: in the select tool, Shift+click adds to or removes from
the selection.
/api/debug/statelists the selected placements with their block names, so a hand-picked set can be read back by a script and fed toblock_sheets.py --list names.txt(one exact name per line). - Ground truth:
meshdump icons <root> sheets/iconsdumps the game's own editor icon for every block. The WebP data is stored bottom-up (flip it vertically before comparing — unflipped, side faces appear on the far edges). Flipped, the icons match the sheets' first view (yaw 315, pitch -30; checked on chiral blocks — the wall checkpoints, the loop starts, the expandable gates), so an icon pasted beside a sheet is a direct A/B. Blocks with a non-zeroIconQuarterRotationYare turned by quarter turns in their icon.python tools/icon_match.py sheets/catalog sheets/iconsscores every sheet against its icon by silhouette; the bottom of that list is a coarse "look here first" (blank/wireframe icons are skipped). python tools/sheet_diff.py sheets_before sheets_afterranks blocks by how much their sheet changed between two captures — after a library-wide importer change, eyeball the top of that list instead of all 5000 blocks.