Twelve gates, most of them the weaker copy of a gate that survives. What is
left: pytest for the Klipper config, and three replica gates -- the end-to-end
install, MCU bring-up, and stock recovery.
Fix first, because the cut depends on it: a skip counted as a pass. Every
launcher exits 0 when it decides it cannot run, and sub() reported that as
`ok`. That is how test-abi.sh sat in the suite printing green while checking
nothing -- run-tests.sh deleted work/modpayload immediately before it ran and
CI set KLIPPER_FORK="", so it had no targets on any run, ever. Skips are now
counted apart, the summary says so, and the suite exits non-zero unless
ALLOW_SKIP=1 says the gap is deliberate. With four gates instead of twelve, a
gate that silently does not run is the failure mode that matters most.
Dropped, each because something else already does the job properly:
test-abi.sh bin/patch.sh refuses to build a non-nan2008
c_helper.so, bin/verify.sh re-checks the packaged
one, and case-install.sh reads the ELF header of
the file that actually landed on the machine.
test-ash-conformance.sh case-install.sh runs `sh -n` over every installed
script with the same qemu'd busybox and then greps
the boot log for "Syntax error".
test-model-gate.sh verify.sh SS8b/SS9 checks the same invariant and
pack.sh exits on MODEL MISMATCH before a package
exists. Its header claimed it proved the two models
ship different files; no such check was in it.
test-macros.py It used jinja2.Environment() -- default '{{ }}'.
The configs have no '{{' at all, only single-brace
expressions, so it checked '{% %}' block structure
and never looked inside an expression. Folded into
the chamber tests, which had the delimiters right.
test-base-cfg.py It read work/software/klipper/config/printer.base.cfg
as its stock reference -- the file bin/patch.sh
overwrites with ours. It was diffing our file
against itself: green on a cold tree, red on a
second run, blind to real drift either way. The
comparison moves to bin/unpack.sh, where a pristine
tree exists, as a warning rather than a gate.
lint-danger.sh Its structural greps were satisfiable by comments:
firmwareExe:44 and S80ui:16 *mention* helix and
SAFE-MODE, so they passed with the logic deleted.
case-ui.sh Goes with the helix-only decision.
applets.allow Ten entries, none of which the extractor had asked
about in a long time.
The command-word half of test-applets went too. It extracted only the first
word of a simple unprefixed command, so `if timeout 5 foo; then` yielded
['if','echo','fi'] -- the one failure its docstring named was invisible to it.
The absolute-path half survives: S50wifi's binaries sit behind a
[ -d /sys/class/net/wlan0 ] guard no simulation ever reaches, so nothing else
checks they exist.
The Python half is pytest now. Real assertions instead of hand-rolled
PASS/FAIL counters, underscored filenames so the modules import normally
(ffcfg keeps only the parser; the dash in test-macros.py was why one test used
to reach the other's parser through importlib), and a `rootfs` marker that
splits the tests needing the proprietary package from those that do not --
deselected in the PR lane, run in the replica half, so neither lane reports
skips it did not expect. test_default_delimiters_are_blind pins the jinja2 bug
so it cannot come back.
The config gate also moves out of the replica half into the half that runs on
a plain pull request. It needs python3, jinja2 and files in the repo, and it
is the only safety-relevant check in the suite -- a chamber heater declared on
a Creator 5 that has no element for it -- so it had no business being gated
behind the proprietary firmware.
Verified: pytest 21 passed / 5 rootfs-marked; the fixture pipeline
(unpack, patch, pack, verify) passes end to end; injecting a broken
single-brace expression fails test_every_macro_parses with the exact line, and
parses clean under the old defaults; the skip path reports SKIP and exits 1,
and ALLOW_SKIP=1 accepts it.
Also drops the CI `abi` job, which ran on a bare checkout with no binaries and
so was permanently green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
5.0 KiB
How it works
Reusing FlashForge's own updater
We reuse FlashForge's updater rather than writing a parallel one. The stock
chain, recovered from app_startup.sh, the unTar binary and the rootfs
image:
busybox init → /etc/init.d/rcS → S99factory_test_shell
→ /usr/prog/app_startup.sh
├─ mounts /dev/sda* on /mnt, globs Creator5Pro-*.tgz
├─ /usr/prog/bin/unTar (des3 decrypt → /usr/data/update)
└─ runFirmwareExe.sh Creator5Pro 0029
└─ per component: extract → md5 gate → run.sh
→ firmwareExe ← the UI, and what starts Klipper
The mod's entire integration point is one file: it replaces
/usr/prog/PROGRAM/software/firmwareExe with a shell script that runs
HelixScreen. Because everything funnels through that binary, app_startup.sh,
rcS and the whole init chain stay completely stock and unpatched. The
genuine binary is not kept anywhere — the stock installer wipes the software
directory before run.sh runs, so nothing on the printer could ever be a
reliable backup of it. Flashing the stock FlashForge package, which still
ships it, is the uninstall.
Two properties come for free from keeping that name and that place:
app_startup.sh's own watchdog grepspsforfirmwareExefive seconds after launch. The wrapper isfirmwareExeand stays in the foreground, so the stock watchdog supervises the mod correctly.- If the wrapper is ever removed,
app_startup.shrestores afirmwareExefrom the newest version directory — the stock recovery path still works.
Services
Services are ordinary init.d-style scripts in /usr/data/anvil/init.d/, run in
filename order and individually restartable over ssh:
S50wifi wlan0 + wpa_supplicant + udhcpc
S60web nginx (Mainsail) + moonraker
S65camera mjpg-streamer on :8080 (nginx proxies it at /webcam/)
S70klipper Klipper
S80ui decides whether the UI runs; owns SAFE-MODE
Everything the mod installs lives under /usr/data/anvil on the data
partition, which a FlashForge OTA cannot delete. The software component itself
goes to /usr/prog on the firmware partition, which only holds two versions
and cannot fit ~100MB of web UI — that is why Mainsail and HelixScreen ride in
the outer package instead.
Five things the stock firmware does that will surprise you
Each of these silently breaks a package or a printer, and each is enforced by
a check in make test:
| Reality | |
|---|---|
*.tar.xz is not xz |
The components are plain tar archives carrying an .xz name. The installer runs a bare tar -xvf, so a genuinely xz-compressed file does not install. |
.tgz is not gzip |
DES3-encrypted plain tar. The key is hardcoded in the printer's own unTar binary. Modern OpenSSL needs -md md5 to match its OpenSSL 1.0.2. |
| Nothing in init starts Klipper | firmwareExe runs /usr/prog/klipper/start.sh. Replace the UI without accounting for this and the printer boots to a working screen that cannot move or heat. |
| The installer wipes the software dir first | ls | grep -v temp | xargs rm -rf runs before run.sh, so the previous firmwareExe is already gone. Uninstalling means flashing the stock package, which still ships the genuine binary; nothing can back one up. |
| Binaries must be nan2008 | The Ingenic X2000 kernel returns ENOEXEC for legacy-NaN MIPS. Debian's gcc-mipsel-linux-gnu cannot produce a usable binary — its libc is legacy-NaN. |
And one pleasant surprise, found by unpacking rootfs.squashfs out of the
kernel component: ssh needs no work at all. The stock rootfs already ships
/usr/sbin/dropbear and an enabled /etc/init.d/S50dropbear, so port 22 is
already open on a stock printer. There is simply no published root password.
Setting ROOT_PW_HASH is the whole feature.
Two models, two packages
Creator 5 and Creator 5 Pro are not interchangeable. Each stock package
carries a MACHINE=/PID= gate that refuses to install on the other model,
and — the part that actually matters — they ship different firmwareExe
binaries. A package built for one model would hand the other the wrong
firmware, so each must be built from its own stock package.
| Model | MACHINE | PID | USB filename the printer looks for |
|---|---|---|---|
| Creator 5 | Creator5 |
0028 |
/mnt/Creator5-*.tgz |
| Creator 5 Pro | Creator5Pro |
0029 |
/mnt/Creator5Pro-*.tgz |
The filename prefix and the gate inside must agree: app_startup.sh globs for
the prefix, and runFirmwareExe.sh checks the gate. A mismatch means the
printer picks the file up and then refuses it. make verify and
bin/verify.sh both check this.
Recovery
Recovery is the stock FlashForge package for your model. Flashing it restores
every file the mod touches; the payload under /usr/data/anvil survives but is
inert. make test-recovery proves it, by doing exactly that inside the
replica and diffing the result against stock.