Table of Contents
Node 22 is required (/usr/bin/node, in the default PATH).
npm install
npm run dev # http://localhost:5175/feuerwerk/dist/ — API calls are proxied to production
npm test # unit tests (core, editor, ui, viewport)
npm run typecheck
npm run lint # ESLint (type-aware for src and tests); lint:fix for the fixable half
npm run format # Prettier over the repo, minus what .prettierignore lists
npm run check # typecheck + lint + format:check + tests, the gate CI runs too
./check.sh # the same, ending in `=== CHECK EXIT n ===`: piped into `tail` npm's exit
# code is lost (tail's is reported), so a red run reads as green
npm run build # writes dist/ and regenerates index.html
./build.sh # the same under `set -o pipefail`, for callers that pipe the output
php tools/make-user.php <name> <password> [admin|user] # an account; every tool signs in (see Accounts)
php tests/php/accounts.test.php # the API's account rules, no server needed (CI's gate job);
# the saves-at-once check needs pcntl, which the CI image
# has and the web server's container has not (SKIP there)
php tests/php/snapshots.test.php # the snapshot store (#231), the same way
php tests/php/releases.test.php # the release store (#266), the same way
php tests/php/files.test.php # uploaded files in a folder per account (#344), the same way
php tests/php/videos.test.php # the video store, its queue and the worker (#402), with the
# real ffmpeg: the CI image and feuerwerk-video have it
php tests/php/limits.test.php # the brake on wrong passwords and the quota (#427)
php tests/php/agents.test.php # agent tokens and the relay's tickets (#429)
php tests/php/rights.test.php # every route as signed out, owner, stranger and admin (#427):
# starts the API under `php -S` on a scratch directory
node tools/seed-e2e.mjs && node tools/e2e.mjs # 2088 editor checks, screenshots in /tmp/gm-e2e-*.png
GM_STEPS=20 node tools/e2e.mjs # or 1-12, or 6,20 — a subset while working on one step
./e2e.sh start [GM_STEPS] # the same run detached, see below
./e2e.sh status # live progress, or the last run's result
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs # 24 checks: the example game, played end to end
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs # 19 checks: the Tetris example, played end to end
node tools/seed-actor.mjs && node tools/smoke-actor.mjs # 21 checks: the walk demo (the seed uploads its own sheet)
node tools/seed-e2e.mjs && node tools/perf-shaders.mjs # what a shader costs per sprite; a measuring tool, not a check
Every tool signs in first. Since #177 the API answers 401 without a session, so the seeds, the
smokes and the e2e run log in as the e2e account (tools/lib/auth.mjs) and send the cookie a
browser would; the e2e browser is handed the same session over CDP. The password comes from
FEUERWERK_E2E_PASSWORD or from the git-ignored data/e2e-password.txt. A machine that has
neither fails with a message saying exactly that — see Accounts.
A run needs a working GPU. An idle editor page keeps rendering a picture that never changes, and
in software that is ruinous: measured here on an idle page over 20 s, software WebGL costs 373% of
a core at 8 fps, the same page on the Intel HD 630 via ANGLE/Vulkan costs 69% at 56 fps. The
full suite takes about four minutes that way. tools/lib/browser.mjs therefore always asks for the GPU and has
no software fallback — on a host without one, chromium should fail loudly rather than melt the
machine down. (--use-angle=gl and --use-gl=egl measure cheaper still, but a headless chromium
gets no WebGL context with them at all, so the app never renders.) Every run also names its renderer
in the log (renderer: …) and stops if that is a software one such as llvmpipe or SwiftShader:
under WSL2 a missing GPU driver does not fail by itself, llvmpipe still offers WebGL 2 there.
The run is at 100 %, one step is not. The harness sets deviceScaleFactor: 1, where a layout
pixel is a device pixel and nothing can sit between two. What a screen scaled to 150 % shows is
therefore invisible to every step but 62 (62-device-pixels.mjs), which switches the scale over
CDP, reloads the page and reads the frame as the screen gets it: __gm.deviceColumn() gives the
device pixels down a column, __gm.edgeContrast() how hard the edges in a rectangle are, and
__gm.pixelGrid() the scale and the width of a hairline. It switches back and reloads again, so it
can stand anywhere in the order. A probe of your own takes the scale as an option:
runChecks({ projectId, baseUrl, deviceScaleFactor: 1.5 }, body).
Leave the full run to CI. Every push runs the whole proof on the runner (see Continuous
integration), so locally take only what the change needs: the gate, plus the step that covers it —
GM_STEPS keeps that to seconds. The whole suite works here too and costs about four minutes and
roughly 0.8 of the four cores of this box (60% renderer, 18% GPU process, load ~1.0), but four
minutes of waiting buys nothing the push does not give.
./e2e.sh start runs the suite detached, in a transient feuerwerk-e2e.service, and returns at
once; output goes to logs/e2e-<date>_<time>.log (gitignored) and ends in === E2E EXIT n ===.
Backgrounding it from a shell instead is not enough — four runs in a row died mid-suite with no
closing line, because the whole process group is killed when the calling shell exits (nohup only
covers SIGHUP, and /proc/vmstat confirmed oom_kill 0, so it was never memory). The unit outlives
its caller. It takes the same GM_STEPS argument, refuses to start a second run over a live one, and
systemctl stop feuerwerk-e2e.service ends it.
./e2e.sh status reports the live run (elapsed, PASS/FAIL so far, the check it is on) or, when
nothing is running, the newest log's result plus its FAIL lines. A log without a closing line is
reported as ABORTED, not as its PASS count: a run that was cut short is not a result.
Prettier owns the formatting (110 columns, single quotes, .prettierrc.json); Markdown is
excluded because it pads table cells to equal width. ESLint therefore only ever means "this is a
mistake", never "this looks different": eslint-config-prettier switches every stylistic rule
off, and what is left is type-aware — unnecessary conditions, floating promises, duplicate
imports, base-to-string. Where a rule fires on something deliberate, the disable comment states
why (the Log node's console.log, a guard against untyped test hooks).
Each script writes to its own project (tools/lib/projects.mjs): the fixture project is
rewritten by every e2e run, the jump-run and tetris projects by their seeders. The
playground project is the author's own and is maintained in the editor — every tool refuses
to touch it.
The e2e run is a list of steps in tools/e2e/ (hierarchy, inspector, play, assets, drawing,
graph, parenting, colliders, containers, text/enabled, menus, textures, frames, overflow, aspect,
container tab, run-in-editor, pause, nested behaviors, key repeat, inline lists and the Variables tab, struct variable,
variable nodes, property nodes, animations, state machines, console, shaders, transforms, fonts,
particles, shared scope, render on change, sounds, load scene, graph groups, nine-slice, plugins, tiles, tilemaps, physics, shapes, queries, documents, sign-in) that build on each other's
scene state and share the CDP driver in tools/lib/harness.mjs — press, undo, play,
object(id), world(), mutate(js) and friends, so a check reads like the interaction it
exercises.
Every load starts from the same baseline. After each navigate() the harness sets the zoom
transition to 0 and switches the magnet of every graph tab off (#391): the graphs snap by default,
but most checks drag a node to an exact spot. What the magnets started at stays readable through
graphSnapAtFirst(), and e2e 06 checks that all eight were on. A step that brings the editor up
some other way (the sign-in in e2e 45) switches them off itself with __gm.graphSnapping(false).
The suite waits for the editor, not for the clock (#93). Every action in the harness — click,
doubleClick, wheel, drag, press, typeText, select, mutate, undo, redo, showTab —
ends in settle(), which waits for two drawn frames (~32 ms) through __gm.afterFrames() rather
than for a deadline somebody estimated. afterFrames wakes the loop itself, because an editor that
draws nothing would never redeem a promise of a frame, and gives up after a timeout instead of
hanging the run; settle() falls back to sleep(150) when the page has no such hook (an old
build), since silently not waiting would turn every later check into a coin toss. What a frame
cannot answer has its own condition: waitUntil(expr), play()/stop() waiting on
__gm.state.mode, and saved() for the autosave.
That replaced about 550 sleep() calls and took the suite from 564 s of step time (88% of it pure
waiting) to 243 s (76%). Three kinds of wait were deliberately kept, and removing them breaks
things:
- Loading takes real time. The 500/600 ms waits behind texture and font changes in
12-texturesand15-aspectare waiting for bytes, not for a picture. Two frames are no answer to that. - Game time is not frame time.
setTimeScale,shaderTime,particleTime, the pause measurements in18-pause, and the live values on graph nodes in18— those are carried by a running graph, so the run must be given a tick, not a repaint. - The scroll measurement in
11-menustimes a real inertia, with its protocol in the comment.
One consequence of the new speed: a step can now finish before the autosave of the step before it
has fired (it runs 1 s after the last model change and paints the status twice). 33-render-on-change
therefore waits for __gm.state.saveStatus to leave dirty/saving before it measures idleness —
it must measure that nothing happens any more, not that it waited long enough.
A step that needs a tab opens it with openTab(t, 'container') from tools/e2e/lib/scaffold.mjs,
not by clicking its name: since the tenth tab the row hides some of them at the test window's width,
and the helper goes through the » menu when it has to. Clicking the label directly took the
whole suite down the day the Particles tab was added.
The same holds for the + menu. A step picks a type with pickAssetType(t, 'Font') from the
same file: it asks __gm.addMenuCategory('Font') which category holds the type, clicks that
category, and picks the type inside the submenu that opens. It keeps a scroll as insurance for a
short window, since an entry below the mask edge is still found by locate while a click on it
lands on nothing (that is what took the font step down when the menu was one long list). A step
that wants to look at the entries itself clicks the category first; from then on
__gm.menuRows(), __gm.menuIcons() and __gm.overlayRect() describe the submenu, and
__gm.menuSubmenus() tells which rows open one. The assets step checks the order of the
categories and the sorted types under each. Order is the whole answer there, and no colour check
can see an order.
An open popup wins over what is beneath it. locate (and with it find, has and
clickText) takes the first text in drawing order, and the editor is drawn before any popup.
Since the asset panel's headings are named like the + menu entries (Behavior, Sprite, Text),
a click meant for an entry would have folded a heading. So locate looks in the popup layer
(ui().overlay) first and only then in the rest; locate(text, true) stays "the topmost". This
matches the screen: below an open menu a click only closes the menu. 04-assets checks it with the
word Behavior on screen twice. The categories (Logics) read like the panel's category headings
in the same way. A submenu lies on the popup layer too, above the menu it hangs off.
The node palette has the same property, and __gm.graph.paletteRows() is its witness: the rows all
look alike, only their sequence says whether the search ranked them (#43).
A symbol is a picture, so a step finds it as one (#214). The reset and apply buttons, the fit
arrows, the kind marks, the tick boxes and the scene marks used to be characters, and locate
found them by that character. They are SVG files now, because the Latin font the editor ships has none of those glyphs.
A file that carries an id names its graphics icon:<id>, and __gm.locateIcons(id) lists every
such picture on screen as { x, y, menu }: menu says whether the button it sits on opens a menu.
In a step, t.findIcon(id, menu) and t.clickIcon(id, menu) do the same as find and clickText.
A menu's tick boxes are read through t.menuTicked() as { label, ticked }. The unit test
tests/ui/fonts.test.ts reads the character map straight out of the font files and fails on any
character in src/ that one of the shipped faces lacks. It also holds the loaded weights to
the fontWeight settings in src/, both ways: a weight the editor sets needs its face, and a face
no one sets fails as well, since the editor waits for every face before it starts (#273).
Many pixels are one read, not many. Every __gm.pixelAt renders the stage into a render
texture of its own and waits for the GPU to hand the pixel back: about 2 ms here, about 7 ms
behind the WSL runner's d3d12 driver. A step that samples a grid or a strip asks
__gm.pixels([{ x, y }, …]) (or __gm.pixelRow(x, y, count)) instead, which renders once around all
the points and answers each exactly as pixelAt would. Measured on both runners: the same colour
at all 10,680 points of six screen rows, three columns and 600 scattered points, and a row of 1280
in 4 to 18 ms instead of 2.4 to 9.9 s. Only whole pixels at a resolution of 1 share the frame;
anything else falls back to one read a point.
What no colour check can see: whether anything was drawn at all. __gm.pixelAt re-renders the
stage on demand, so it answers correctly on a screen that has been frozen for a minute — which is
exactly the failure mode of drawing on change instead of every frame (see Architecture). The
drawn-frame counter in __gm.renderStats() is the witness, and the render-on-change step is the
only place the suite looks at it. Two rules there, both learned the hard way: measure idleness only
after waiting for the trailing window of the last event to run out (otherwise the frames the
previous click is still entitled to look like a leak), and take a step that does its measuring on a
tab whose picture stands still — the scene viewport may be wearing a Time shader, and the particles
and animation previews are moving pictures by definition. Screenshots ask for a frame first
(__gm.wake() in harness.mjs) rather than trusting what the compositor still holds.
Nothing is ever heard, and that is fine. The browser runs with --mute-audio, plus
--autoplay-policy=no-user-gesture-required so a voice still starts — otherwise the sound
checks would be measuring a browser that refuses to play rather than an editor that never asked
it to. __gm.sound.stats() (voices started, stopped, in flight, and the last one with its volume
and loop flag) is the witness, for the reason renderStats is one. The seed's two sounds are
generated WAVs (tools/lib/audio.mjs), not files: unlike the DejaVu typefaces there is no
audio file the host and the CI image are guaranteed to share, and the bytes are a pure function of
the arguments, so storage stays content-addressed and a check can name a file by its exact size.
Nothing is ever spoken into either: --use-fake-device-for-media-stream and
--use-fake-ui-for-media-stream give the page chromium's test tone as its microphone, without the
question whether it may, so e2e 65 can record a take (#342). Each run leaves its take of about
a second and a half in the sound store, as a WAV of its own.
The one step that must clean up after itself twice. 35-load-scene makes a second scene and
plays from it, so it leaves behind both assets and a project field. Its own scene is where all of
its objects live, which keeps the shared fixture scene untouched; the start scene it names is
cleared at the end, because a step that leaves one behind would silently redirect every Play of
every step after it.
A check that demands an exact number must be handed the number, not something it can be derived
from. The particle preview used to hold second 0.5 by computing the clock reading that would mean
0.5 since the field started; the round trip through floating point misses by the last bit about one
time in ten, and floor(0.5 · 20) is then 9, one particle short. It had been quietly flaky for as
long as it had existed and only came out when another change moved where the clock happened to stand.
The panel now hands the field the age itself (ParticleField.hold). Where a check reads a clock
across a round trip of its own — "this happened right after that" — bound it by a multiple, not by
an epsilon, or the driver's own latency is in the measurement.
GM_STEPS=20, GM_STEPS=1-12 or GM_STEPS=6,20 runs a subset — seconds instead of minutes
while one step is being written. Because a step lives off what the earlier ones left behind, a
subset that skips a prerequisite fails on purpose; take 1-N when in doubt.
The example game (tools/seed-jumprun.mjs, project Jump and Run) is a complete little
platformer built only from assets, containers and graphs: a title screen with a clickable Start
button, a level of boxes with ten coins, gravity and jumping, a 30-second clock in the HUD, and
a win / lose screen with "Play again". A coin is a container of its own (sprite plus trigger
collider) placed ten times inside the level container — nested containers resolve through both levels,
and picking one up destroys the container, not just the sprite the collider reported. tools/smoke-jumprun.mjs plays it in a headless browser
(start, jump, collect, win, restart, lose) — it is the regression test for everything screens,
text, mouse and platformer physics need.
The second example (tools/seed-tetris.mjs, project Tetris) is the same idea with data
instead of physics. The board is a list of 200 numbers, one per cell — -1 for empty,
otherwise the shape that came to rest there. A cell is Item at(grid, row · 10 + column), a
landing is Set item, and clearing a full line is an empty row joined in front of everything
above and below it. What the player sees is a pool of objects the graphs address by name —
c0…c199 for the well, p0…p3 for the piece that is falling — so nothing is spawned or
destroyed while playing. Every frame tries exactly one move (turning beats moving beats
falling), which is why the game needs only a single "does it fit?" test instead of one per key.
What is not loose numbers is the piece. piece and the candidate next are structs of
shape/rot/x/y, and the shape table is a list of {x, y} cells — so Next move hands back a
whole candidate for Fits to judge and the master to accept in one write, rather than three
parallel variables copied back and forth, and each strand packs only the field it changes
(mergeStruct keeps the rest). Reading a cell is ((shape · 4 + rot) · 4 + corner) into that
list instead of the same index doubled and read twice.
Where a step needs several variables at once, one Variables node stands for the
lot: the call to Next move has one node at each side of it rather than five, and the master
graph is 44 nodes / 64 edges where the same game in single nodes was 69 / 76. Inside Next move each strand writes its whole answer — the candidate, what kind of move it was and the wish
it used up — in one node rather than three to five, which is 30 nodes against 40. A writing
node reads all its inputs before it writes any, so an input fed from a variable the same node
writes sees the value from before.
It is ten behaviors, not one graph. Tetris owns the state and does none of the work: each
step of a frame is a behavior of its own, called as a node — Spawn piece, Next move, Fits,
Draw piece, Lock piece, Clear lines, Show lines, Draw board, with Piece cell (where
one of the four cells of a piece sits) used by two of them. None of them holds state: what they
need arrives through their inputs and every answer leaves through their outputs, which is what
lets Fits answer for turning, moving and falling alike, and Piece cell serve the fit test
and the drawing instead of being the same twenty nodes twice. What is left in
the master graph is the order the steps run in and which variable each answer lands in — it holds
no loop and no list access of its own, which tools/smoke-tetris.mjs checks. That script plays
the game in a headless browser (fall, move, turn, drop, land, clear a line, fill the well) — it
is the regression test for loops, lists and behaviors used as nodes.
The walk demo (tools/seed-actor.mjs, project Actor demo) is the smallest of the three and the
only one that ships its own picture: it uploads tools/assets/Actor1.png and slices the 576x384
sheet into the 96 cells of a 12x8 grid. That is the usual RPG-Maker layout — eight characters, each
three columns (left foot / standing / right foot) by four rows (down, left, right, up) — and the demo
wears the one in the top left corner. Walking is four ping-pong animations of three frames, which
is exactly the original walk cycle; standing is no animation at all but the middle frame of the row.
The graph picks what the sprite wears the way the editor does: two list variables hold the four
walk animations and the four standing frames, every item chosen with the texture picker, and
facing is the row of the sheet, which indexes both (Item at). No id is spelled anywhere in it,
so the whole graph can be rebuilt by hand. Which way it looks is the key pressed last, corrected whenever only one axis is held — otherwise letting go of one key of a
diagonal would leave it walking backwards. It is the worked example for animations, for a seed that
brings its own texture, and for Axis input, whose vector is already normalized so a diagonal is not
faster than a straight line. tools/smoke-actor.mjs walks it in a headless browser; that the
animation really advances it reads off the drawn pixels, standing at the edge of the camera where
he walks on the spot — a clock that ticks without the picture following would pass otherwise.
Deployment
The workspace folder itself lies behind https://nockal.com/feuerwerk/, so npm run build is the
deployment, and a change to api/ is live the moment it is saved. The same folder answers at
https://feuerwerk.nockal.com/: the Brandmauer puts /feuerwerk in front of every path there.
That is why the build knows no path of its own. Its base is relative, and src/core/site.ts
takes the path of the API and the data from the folder of the open page. nginx hands out only what the
pages load (#427), from a location ^~ /feuerwerk/ block in the shared nginx.conf of the web
stack:
index.htmlandplay.html,dist/,public/brand/;- the uploaded files in
data/textures|fonts|sounds|videos|pixels/, by folder, hash and the endings their stores give them, withContent-Security-Policy: default-src 'none'; sandbox; api/index.php, the only PHP that runs.display_errorsis off andX-Powered-Byis hidden.
Everything else in the workspace (logs/, tmp/, tools/, tests/, wiki/, node_modules/,
the source) answers 404. Over plain HTTP the bare folder is a 301 to HTTPS, the two pages forward
in the browser (the Brandmauer rewrites every other redirect), and the API answers 403.
The pages run under a Content-Security-Policy without inline code and without eval:
vite.config.ts writes it into both built pages as a <meta>, so CI runs the whole suite under
it, and the harness fails a module for anything Chrome refuses. Pixi gets by without eval
through pixi.js/unsafe-eval (imported in src/page.ts); Rapier needs 'wasm-unsafe-eval'. nginx
adds nosniff, frame-ancestors 'self', HSTS and a Referrer-Policy to everything under the path.
A new kind of file the editor loads, or a new place it loads one from, needs both lists: the
policy in vite.config.ts and the location in nginx.conf.
Every PHP script outside api/ stops at once unless it runs on the command line.
The video worker (tools/video/compose.yml) runs without network, without capabilities, with the
checkout read-only and only data/videos/ writable, and with 2 GB of memory and 256 processes at
most: ffmpeg reads what strangers upload.
The MCP relay (#429, see MCP) is a small Node service in the container feuerwerk-mcp
(tools/mcp/compose.yml, docker compose -f tools/mcp/compose.yml up -d). nginx passes the one
path /feuerwerk/mcp on to it, plain requests, event streams and the WebSocket upgrade alike, to
172.16.0.1:8091 on the docker bridge's gateway, where nothing from outside reaches it. It is named
by address, so nginx starts while the relay is down; that path answers 502 then and nothing else
notices. The container sees only tools/mcp/, src/editor/agent/ (for the tool list, which it
reads on every use) and data/mcp-relay.key, read-only, with no capabilities, 256 MB and 64
processes. It asks the API through the web stack's port, with the key, and takes the agent's address
from nginx's X-Real-IP. A change to tools/mcp/ needs docker restart feuerwerk-mcp; new tools
in tools.json need only the build.
Commit messages
A commit message is read in a log viewer, in git log --oneline and in the Gitea history, and
all three cut it at the first line. So the first line is a subject of its own: one sentence,
at most 72 characters, in the present tense, saying what the user gets out of the change, with
the issue number at its end. Then an empty line, then the body — never one long line for
everything.
The body is prose in paragraphs, not one sentence strung together with semicolons, wrapped at 72 columns:
- a first paragraph for the why: what did not work, what it cost, what the change decides;
- then a bullet per building block, decision or deviation from the plan, each one a sentence or two, not a page;
- a paragraph for findings made on the way, if there were any;
- a
Tests:block at the end with the measured numbers (gate, build, e2e, counter-checks).
Write #123 for an issue and a short hash for a commit — Gitea turns both into links. The
footer carries the Co-Authored-By: line.
Draw a picture instead of uploading it (#185)
A texture asset has always been a file on the server, so an asset that
paints its own image had nowhere to put itself.
- The key may now read gen:<assetId>@<revision>, which an uploaded
file can never look like.
- The revision is a string, not a counter, so a generator may
fingerprint its own content.
Tests: check 124 files / 1817 tests, e2e ALL PASSED - 654 checks.
Counter-check: dropping generated assets from resolvePicture fails 5.
Continuous integration
Every push runs the whole proof on the Gitea runner: .gitea/workflows/ci.yml installs, runs
./check.sh and the PHP tests (tests/php/, #179 — this machine has no PHP, so they are not in
check.sh; here they run in the web server's container), builds, then tools/ci/run-e2e.sh (the editor suite) and tools/ci/run-smokes.sh
(the three games). Both scripts source tools/ci/serve.sh, which puts the checkout behind
http://127.0.0.1:8080/feuerwerk/ with php -S, under the same path as on stein2, and writes the empty project files the
seeders need (PUT does not create a project, POST hands out a random id). Every tool takes its
server from FEUERWERK_BASE_URL. The MCP relay (#429) stands in front of the same server on
http://127.0.0.1:8081/feuerwerk/ (FEUERWERK_MCP_BASE_URL): it passes everything but its own
path on, so the editor and the relay share an origin there as they do behind nginx. Only step 71
opens the editor on that port.
The jobs run in the image from tools/ci/Dockerfile: chromium with the Vulkan drivers and
xvfb, php-cli, ffmpeg for the video worker (#402, serve.sh starts it beside the server),
and the host's font packages, because the checks measure text widths to find click points. A
change to the Dockerfile needs the image built again on both runners' hosts. ci.yml asks for the label feuerwerk-wsl (#283): a runner in a WSL2 distro
on a Windows machine with an RTX 4070, so this host no longer carries the suite. The runner on
this host is still registered with the label feuerwerk-ci and the same image; pointing both
runs-on back at that label moves the runs here again.
The GPU is not in this repo. It comes from container.options in each runner's own
config.yaml, which applies to every job on that runner:
- Here:
/dev/dri/renderD128and a 1 GB/dev/shm. Measured in the job container:ANGLE (Intel, Vulkan 1.3.230, HD Graphics 630), WebGL 2.0. - Under WSL2:
/dev/dxg, the Windows driver libraries from/usr/lib/wsland the same/dev/shm, plusFEUERWERK_BROWSER=wslfrom the runner'senvs. There is no DRM render node and no Vulkan there, and a headless chromium gets no WebGL at all. In this modetools/lib/browser.mjsstarts an invisible Xvfb and an X11 chromium on ANGLE/GL, which reaches the GPU through Mesa's d3d12 driver:ANGLE (Microsoft Corporation, D3D12 (NVIDIA GeForce RTX 4070), OpenGL 4.2), WebGL 2.0, 60 frames a second. A Wayland chromium reached the GPU as well, but let the editor's clock stall in long runs. Every GL call there passes through ANGLE, Mesa's d3d12 driver and the Windows driver, and that costs chromium's GPU process far more CPU than Vulkan does here: in aperfsample three quarters of its time sat in the driver stack. Reading pixels back cost three times as much, which the steps now avoid (see Many pixels are one read above). Frames with real work in them (tilemaps, the graph) still take longer than here, and no chromium or Mesa switch changed that:mesa_glthread,--in-process-gpu, software compositing and EGL were each tried and measured.
Without these options chromium falls back to software rendering, and the harness stops at the renderer check.
Using the editor
- Concepts
- Accounts
- Agents (MCP)
- Scenes
- Scene editing
- Behaviors
- Structs
- Colliders
- Physics
- Textures
- Vector art
- Pixel art
- Fonts
- Videos
- Sounds
- Instruments
- Patterns
- Songs
- Animations
- Curves
- Tiles
- Tilemaps
- State machines
- Shaders
- Particles
- Containers
- Compositions
- Keyboard
Working on the code