Files
firmware/docs/how-it-works.md
T
Oleksandr ShyshatskyiandClaude Opus 5 91604c2dfb Cut the suite to four gates, and rewrite the Python half as pytest
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>
2026-08-24 13:00:12 +03:00

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 greps ps for firmwareExe five seconds after launch. The wrapper is firmwareExe and stays in the foreground, so the stock watchdog supervises the mod correctly.
  • If the wrapper is ever removed, app_startup.sh restores a firmwareExe from 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.