Stage 3 of the rename. The repo now lives at meleki/feuerwerk, the
runners carry feuerwerk labels and the WSL distro on melektaus-atx is
called feuerwerk-ci. The code has to follow, or CI finds no runner and
the suite no longer picks the GPU path.
- ci.yml: workflow name feuerwerk, jobs on feuerwerk-ci (gate, stein2)
and feuerwerk-wsl (suite, melektaus-atx).
- GAME_MAKER_* variables are now FEUERWERK_*. This includes
FEUERWERK_BROWSER=wsl, which the runner config on melektaus-atx sets.
- The video worker runs as container feuerwerk-video with the image
feuerwerk-video and works on /srv/feuerwerk.
- e2e.sh starts the unit feuerwerk-e2e, the CI image keeps its paths
under the new name.
- README and repo links point to git.nockal.com/meleki/feuerwerk. The
old URLs still redirect with 301.
Both runners keep their old labels until the first CI run with this
ci.yml is through. Then they go, together with the image tag
game-maker-ci:latest and GAME_MAKER_BROWSER in the atx config.
Tests:
- Gate on melektaus-atx through the renamed distro: CHECK EXIT 0,
307 files, 4756 tests.
- e2e 60 on melektaus-atx: ALL PASSED, 20 checks. Renderer is the
RTX 4070 through D3D12, so FEUERWERK_BROWSER reaches the GPU path.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The editor gets a name of its own that fits beside Brandmauer,
Glutspeicher and Feuerwehr. This is stage 1, 2 and 4 of #422: the
name, the URL and the stored state in the browser. Repository, CI
runners, containers and the GAME_MAKER_* variables follow in stage 3.
- All paths come from SITE_PATH in src/core/site.ts: the API endpoint,
the data folders, the play page and the base in vite.config.ts. A
later move is one line instead of fifteen.
- The window title, the sign-in screen, the playground and the README
say Feuerwerk. The package, __feuerwerkPlay, the take worklet and the
writing app in Ogg and Matroska follow.
- The session cookie is now feuerwerk_session, so everyone signs in
once more.
- localStorage keys are now feuerwerk.*. moveOldStoredKeys copies the
old game-maker.* keys over on the first start, so settings, panel
sizes and save games survive.
- tools/ci/serve.sh serves the build under /feuerwerk/. The variable
GAME_MAKER_BASE_URL keeps its name until stage 3.
Outside the repository, nockal.com/feuerwerk/ is a second symlink to
the same tree. nginx sends the bare /game-maker/ folder there with a
301. Everything else under /game-maker/ is still served directly, so
shared play links and the API keep working.
Finding: when nginx redirects a path without a trailing slash or with
a query, the Brandmauer answers with its own 307 to the same URL plus
a slash, placed after the query. A play link would lose its token that
way, which is why only the bare folder is redirected.
Tests:
- Gate on melektaus-atx: CHECK EXIT 0, 307 files, 4756 tests
(4 new in site.test.ts).
- Counter-check: when the migration misses the old prefix, 3 of the
4 new tests fail.
- e2e 60 under /feuerwerk/: ALL PASSED, 20 checks.
- From outside: /feuerwerk/ 200, /game-maker/ 301, an old play
link 200.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A video becomes an asset like a sound: uploaded once, converted to one
format by a worker of its own, cut into clips in its tab, and worn by a
sprite through its Texture row. Only Chrome is a target, without alpha.
- Server: POST ?r=videos queues the upload under the hash of its bytes,
so the MP4's name is known at once. The worker (VideoWorker over
VideoFormat) writes H.264/AAC, yuv420p, constant frame rate, a
keyframe every 15 frames, at most 1920x1080, with a poster and the
details beside it. A failed job says why and is tried again on the
next upload.
- An upload may be up to 2 GB. It is streamed from the request to disk
and hashed there, so php-fpm holds none of it in memory. The other
routes keep their 256 MB.
- The worker runs in the container game-maker-video on stein2, a
compose project of its own (tools/video/), and in CI beside the PHP
server. The CI image has ffmpeg for it.
- Model: asset type video with size, length, frame rate and sound, and
clips like a sound's. The clip helpers and commands serve both.
- A sprite's Texture row offers videos and clips after the pictures.
resolvePicture names a playing video by a key, and the texture library
hands it to a VideoLibrary with one player per key.
- The editor shows a placed video as a quiet preview. Play starts it
over at its clip's start and lets it be heard through the master bus,
a pause holds it, Esc makes it quiet again.
- Video tab: the picture, a ruler, the clip strip and a track on the
Sound tab's time axis. A drag spans a clip, a handle moves an edge,
Play plays the picked clip.
- The picker shows a video on its way with its progress and adopts an
upload from it once it is ready. Dropping a video file does the same.
Decided on my own:
- Two sprites wearing the same clip share one player and show the same
picture. Control per instance belongs to the follow-up with nodes.
- A video plays in real time, the game's time scale does not slow it.
- The container may use 3 of the 4 cores of stein2.
Findings:
- The first real upload (279 MB) got 413: the API's own ceiling of
256 MB for every request, not nginx or the Brandmauer.
- Chrome has no picture of a video that never played, and a Pixi
VideoSource drawn before the first frame stays empty for good. The
texture is made in the first frame callback (openVideo).
- A player nobody asks for in 2 s is let go. The inspector's PREVIEW
asked only on an edit, so with the sprite not on screen its texture
was destroyed under it and the batcher threw on every frame. The
preview now redraws every frame while it shows a video, like a Time
shader. A let-go texture keeps an empty source instead of none.
- VideoSource.load() reloads the element and cuts off an earlier
play(). Playing starts after loadeddata.
Tests:
- PHP videos.test.php: 68 checks, with the real ffmpeg
- 300 MB upload through Brandmauer and nginx: 201 in 21 s, php-fpm at
13.7 MB RSS at most
- Unit videos.test.ts: 20 tests
- e2e 66 (stein2, atx was off): ALL PASSED, 18 checks; e2e 04 with the
new asset count green
- Gate (atx): 294 files, 4520 tests. serialize.test.ts used video as
its example of an unknown type and now uses a made-up one.
- Counter-checks: a keyframe gap of 30 fails the PHP check, a key that
keeps the video's end fails 2 unit tests, a clip without its end
fails "plays only its stretch" in e2e 66
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sounds follow the road fonts already paved: the file goes to the server
content-addressed under its sha256, the project keeps a named asset that
points at it, and nothing is ever re-encoded. The format is read from the
file's own signature, not from what the browser claims - the same .m4a
arrives as audio/mp4, audio/x-m4a or as nothing at all.
Playing is two nodes. "Play sound" takes the asset and may raise or lower
its volume; every call is its own voice, so two jumps in a row are two
jumps heard. Its loop input is a choice of three - from the asset, on,
off - because a checkbox has to spend one of its two positions on "not
set" and would lose a case: an asset that loops could then never be
played once.
Below the graph core stays browser-free: NodeContext hands out an asset
id, BehaviorSystem resolves it against the project and folds the asset's
own volume and loop into the call, and only viewport/SoundLibrary knows
about Web Audio. A run without a sink plays nothing instead of throwing.
The CI browser is muted, so the ear is no witness: the debug hook counts
started and stopped voices, and step 34 reads that counter.
An upload sends its file name next to the raw bytes, and the server keeps
it in a sidecar next to the content-addressed file. Both pickers caption
every tile and row with it, and an asset made there is named after the
file instead of "Texture" / "Font". Last upload of the same content wins.
The API's own set_error_handler ignores error_reporting(), so @-silenced
warnings still throw: a missing sidecar took the whole listing down with a
500. Existence is checked instead of suppressed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A font asset holds a file and nothing else, the twin of a texture: the server keeps it
content-addressed under data/fonts/{sha256}.{ext}, with the format read from the file's own
four-byte signature (finfo reports fonts inconsistently across libmagic versions). A text
wears one through its ordinary Font property, so overrides, reset, undo and the Properties
node come for free; no font means the editor's own typeface.
The picker lists everything the server holds and renders each row in the face it offers.
The family name is gm-font-<hash16>, never the name inside the file, so two "Regular" cannot
collide. WorldViewport.syncText is the single place that draws it, which covers scene, game
preview and play at once.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The job image (tools/ci/Dockerfile, label game-maker-ci) carries chromium with the Vulkan
drivers, php-cli and the host's fonts; the render device itself comes from the runner's
container.options, so a job container gets the Intel GPU instead of llvmpipe.
tools/ci/serve.sh puts the checkout behind /game-maker/ with php -S and writes the project
shells the seeders need; every tool now takes its server from GAME_MAKER_BASE_URL rather than
from a project id repeated in shell scripts.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
tools/seed-actor.mjs builds the project Actor demo: it uploads
tools/assets/Actor1.png, slices the 576x384 sheet into the 96 cells of a
12x8 grid (48x48 each, no remainder) and wears the character in the top
left corner. Walking is four ping-pong animations of three frames, which
is the original RPG-Maker walk cycle; standing is the middle frame of the
row, not an animation.
The graph pastes walk- or idle- in front of the direction faced and wears
the result, so eight cases need no branch tree - the List node only holds
numbers. Which way it looks is the key pressed last, corrected whenever
only one axis is still held, or letting go of one key of a diagonal would
walk backwards. Axis input already normalizes its vector.
tools/lib/projects.mjs gains uploadTexture, since data/textures/ is
git-ignored and a seed that ships its picture has to send it. The harness
gains w/a/s/d and keyDown/keyUp halves, because holdKey cannot express two
presses that overlap - which is what a diagonal is.
tools/smoke-actor.mjs plays it in 19 checks and reads the drawn pixels at
the camera edge, where the bounds hold him walking on the spot: three
distinct frames walking, one standing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A Tetris example project needs to draw 200 board cells and scan rows
for a full line. Neither is expressible without iteration, so the node
system gets a Repeat node plus the missing text side: Substring, Find
text and To number. The interpreter drops its data cache once per
round -- otherwise every round would recompute with the first round's
values.
Object by name takes its name as an input now, not just as a setting;
addressing "c" + index is impossible otherwise. An unconnected input
reads the stored setting, so saved projects are untouched.
The board itself is a 200-character text, "." for empty. That makes
both operations of Tetris a single expression instead of a loop, and
each frame attempts exactly one move -- rotate beats sideways beats
fall -- so the fit check exists once in the whole graph.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A run whose chromium died stopped mid-step and exited with code 0, silently
reporting success for checks it never ran: CDP.send() left its promise pending
when the socket closed, and an unsettled promise does not keep node alive.
Waiting on the server had the mirror problem – a bare fetch keeps its socket
open, so a stalled response hung the run forever.
Every wait can now time out, the socket rejects what it can no longer answer,
and reaching process exit without a summary reports RUN DIED with exit 1.
Chromium's stderr and exit signal are kept, so "killed by SIGKILL" is visible
rather than guessed at.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Tooling:
- Prettier as the single formatting authority (110 columns, single quotes,
measured against the existing style); Markdown excluded because table-cell
padding turns the README glossary into 1000-character lines.
- Type-aware ESLint ending in eslint-config-prettier, so a rule always means
"this is a bug", never "this looks different".
- .editorconfig and an `npm run check` that runs typecheck, lint, format and
tests in one go.
Findings fixed on the first run: three more duplicate imports, four
String(unknown) calls (parameter types narrowed instead of casts), two dead
initialisers, two redundant casts, a console.log for a real error. Two
warnings were false here and are disabled with a reason in place.
Tests 132 -> 269:
- node-catalog.test.ts covers every node with a run(), with a guard that fails
when a new node has no row.
- behavior-events.test.ts covers the ten event nodes through a running World,
checking timing rather than existence.
- Input's state machine (press/release/releaseAll) is reachable without DOM
events now; the DOM handlers are thin adapters.
Compare fell back to == without a configured operator although the schema
default is <; aligned.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>