Two changes that turned out to be one.
MOVED, NOT DELETED. `test/ffsim/` and `test/integration/printer/` are
`tools/replica/`. They were never tests: bin/patch.sh calls
bin/build-payload.py, which starts a replica and assembles the payload by
running the PRINTER'S OWN opkg against / inside it -- so the "test harness"
was on the build path, and deleting it as asked would have broken `make
build`. qa/lib/replica.py drives the same directory unmodified. No content
changed; what changed is qa/lib/replica.py's build directory, three launcher
sys.path lines, the Makefile targets and the CI shellcheck glob.
tools/replica/README.md says who drives it and why it is not under test/.
CUT TO 28. The five surviving modules were 136 tests; they are 28, chosen on
one question: would this failure be SILENT? What is kept is the mistakes that
run perfectly and are wrong --
test_startup 8 no stamp unless the values are verifiably saved (a
stamp written early is a printer that has lost its
factory calibration for good), and the handover order
test_tool_transform 7 every one is a move to the wrong place: a sign, a
frame left applied while probing, a stale gcode_move
cache, a config a calibrated printer cannot parse
test_ffscreen 5 the four ways it can do harm rather than look wrong
test_chamber 4 the chamber mistakes that cost a print before anyone
sees them, plus the parse gate they rest on
test_gcode 4 a renamed macro, and the safe file being cold
What went: the panel narration, the geometry probe and parse_geometry, the
z_adjust and per-job-Z bookkeeping, the config layout checks, the G-code
bounds checks. Each is either a wrong number shown to a person, or a failure
the printer reports loudly the first time -- and qa/replica/test_boot_screen.py
already renders the real frames on the real machine.
The rewrite is not a delete: assertions were folded so a kept test asks its
question in every direction it can be got wrong (both models, both rotations,
all four ways the migration can fail), and each file's header now says what it
dropped and why, where the next person will open it.
Also gone: conftest's `rootfs` fixture, orphaned two commits ago. Nothing under
test/ needs the firmware, docker, or a network any more.
28 host-side tests pass in 0.15s. qa/static unchanged at 218. `make rootfs`
re-verified end to end from its new path.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UzLebCrvc8ssWR5JuEsha4
21 KiB
Building your own packages
You need a stock FlashForge update package for your model — this builds from it, it does not replace it. Everything else is Docker.
cp config.env.example config.env # what to build, and what ships
$EDITOR config.env # point it at your stock package
make build # the firmware
The package lands in work/out/. make release builds both models into
dist/.
Requirements
Docker. That is the whole list — the build image carries openssl, tar,
xz, unzip, python3, binutils, squashfs-tools and qemu-user-static,
and every target runs inside it. make shell drops you into it.
LOCAL=1 make <target> runs on the host instead.
The test targets get the docker socket mounted through, so they can start the
replica as a sibling container — and so does make shell, which shares their
run flags. A build cannot reach the docker daemon.
The pipeline
fetch-assets.sh download Mainsail + HelixScreen + Moonraker into vendor/
unpack.sh decrypt the stock .tgz, open the software component
patch.sh apply the mods to work/software/, and install the .ipk feed
into work/modpayload-root/ to make the payload
pack.sh regenerate md5sum.list, tar, encrypt → work/out/<Model>-anvil-<date>.tgz
verify.sh simulate every check the printer performs, against the built file
make build runs the first four. It needs the .ipk feed to exist first --
patch.sh assembles the payload by installing it -- so make packages
comes before a cold make build; patch.sh says so if it is missing.
verify.sh is NOT one of them -- run
make verify after it, as the hardware checklist does. Each is idempotent and
safe to re-run.
Packages carry only the software component by default. The stock installer
skips any component that is absent, so the kernel, the rootfs image and the
MCU/board firmware are left untouched — MCU flashing is the riskiest thing in
a package and there is no reason to run it for a userspace mod. FULL=1 make <target> carries all four.
One build
There is one package: the forked Klipper with toolchanger support,
Mainsail/Moonraker, ssh, and HelixScreen as the UI. bin/common.sh defaults
the BUILD_* flags to exactly that, and config.env can override any of
them if you want a piece left out.
There used to be a profiles/ directory choosing between builds — five of
them at one point (ssh, web, full, helix) climbing one feature at a
time, then two (probe, default). The intermediate rungs each cost their
own flash-and-verify cycle while the recovery story stayed the same at every
one of them (flash the stock package), so they bought caution nobody was
spending; the last of them, probe, changed nothing on the printer and only
wrote a diagnostic report to the USB stick, which was a bring-up aid rather
than something to keep shipping. With one build left, the selection layer went
with them.
make build # the firmware
make release # both models, into dist/
MODEL=Creator5 make build # just the non-Pro
Cutting a release
Releases are built by .github/workflows/release.yml, on a pushed tag:
git tag v20260824-nova-kakhovka
git push origin v20260824-nova-kakhovka
The tag is v<YYYYMMDD>-<city>: the release date, and a Ukrainian city under
occupation. Write docs/releases/<tag>.md before you tag -- a short paragraph
on that city -- and the workflow appends it to the release notes; without one
it publishes a bare line saying releases are named this way, and warns in the
job log. Only the date reaches the package — FlashForge's installer reads
the version field as a number and compares it against what is on the machine,
so anvil-20260824 is what the filename says and the codename lives in the
tag and the release title. A workflow_dispatch run has no tag; it builds and
uploads the packages as a workflow artefact and publishes nothing.
Nothing about the release job is privileged. The stock FlashForge packages and
the factory image it tests against are large but public (ghzserg/FF), pinned
by sha256 in the workflow exactly as ci.yml pins them. It reads one optional
secret, ROOT_PW_HASH, and deliberately has none set: released packages fall
back to a random root password written to anvil-password.txt on the stick,
which is the same thing a local build does and means no crackable hash ships
inside a public package.
The job publishes only if the whole brick-safety suite passes on the replica,
and then re-runs the end-to-end install against the exact dist/*.tgz files
that get attached — not against a package built from the same tree, but those
files. Releases are marked pre-release.
Two kinds of flag
This distinction is the one to keep straight:
-
BUILD_*decides what goes into a package. Read at build time only, defaulted inbin/common.sh, never present on the printer. (BUILD_TOOLCHANGE,BUILD_MAINSAIL,BUILD_MOONRAKER,BUILD_HELIX.) -
MOD_*are runtime tunables. They are written into/usr/data/anvil/anvil.conf, which the printer re-reads at every boot, so they can be changed over ssh afterwards and survive a mod update. (MOD_SPLASH,MOD_STARTUP,MOD_IMPORT,MOD_S6, theMOD_CAM_*camera settings and theNICE_*priorities.)There used to be five more —
MOD_WEB,MOD_CAM,MOD_UI,MOD_SSH,MOD_WIFI— and they are gone. Each was an on/off switch that defaulted to on, and what each described was a half-installed printer: Mainsail with no moonraker behind it, a screen dark on purpose, a root password deliberately not set on a machine whose recovery story is ssh. The web stack, the camera, the screen and wifi now run because they are installed. To leave a piece out, leave it out — that is what theBUILD_*flags are for — oropkg removeit on the printer.
Third-party pieces are downloaded, not vendored
Mainsail, the HelixScreen build for this printer and Moonraker are large
third-party trees, so the repo does not carry them — and never has, so there is
nothing in the git history either. versions.env pins each one by version and
sha256:
MAINSAIL_VERSION="v2.18.2"
HELIX_VERSION="v0.99.115-creator5.1"
MOONRAKER_VERSION="v0.9.3"
Moonraker is here because the printer's own is a 2022 build that predates the
webcam enabled flag Mainsail filters on, so the camera panel never appears.
BUILD_MOONRAKER=0 leaves the stock server alone.
bin/fetch-assets.sh downloads them into vendor/ (gitignored) and refuses
anything whose sha256 does not match the pin. A cached file with the right
hash is never re-downloaded, so it is a no-op on every build after the first.
bin/build.sh calls it for you; make vendor pre-fetches everything.
To bump a version: edit versions.env — for HelixScreen that means both
HELIX_VERSION and HELIX_FILE, which embeds the version in the filename —
set the matching sha256 to SKIP, and run make vendor. It downloads the new
file and prints its hash for you to paste back over the SKIP. With a stale
hash left in place make vendor fails instead of printing anything.
Setting MAINSAIL_ZIP, HELIX_TGZ or MOONRAKER_TGZ in config.env points
the build at your own local copy. It is still checksummed against versions.env, and on a
mismatch fetch-assets.sh overwrites your file with the pinned release —
SKIP re-downloads too. To build against your own file, put its sha256 in
versions.env as well; only an exact match is left alone.
If the build asks for Mainsail, HelixScreen or Moonraker and the file is not there, it fails. It used to skip silently, which shipped a package with an empty web root and no way to notice.
The root password
ROOT_PW_HASH in config.env is written straight into the shipped shadow
file at build time — make passwd prompts for one and prints the hash, using
the build image's python so the result is the same on every host. Leave it
empty and the installer picks a random password on the printer instead, on
the first install only, and writes it to anvil-password.txt on the USB
stick it was flashed from. An update finds the printer already has a password
— the random one, or one set by hand with passwd — and keeps it: the
pre-block records the live hash before the stock installer replaces the
shadow file, and the post-block puts it back.
The reason it works that way: a package is one file that many people flash, so any password baked into it would be the same on every machine. Generating it on the device is what makes it per-printer, and the stick is the one channel back to the person standing at the machine.
If the stick cannot be written, no password is set — a password nobody can read is no better than no access, and leaving a guessable one behind would be worse than saying so.
bin/patch.sh decides which of the two applies and sets MOD_PW_AUTO in the
injected install block; installer/run-append.sh does the on-device half with
the printer's own mkpasswd.
Two config files
config.env |
The build config: where your stock package and source trees are, which model, the root password hash. Some of it ships. |
test.env |
The replica config: the factory image, the partition sizes, an optional prebuilt printer image. None of it ships, ever. |
Both are gitignored; copy the .example of each. CONFIG_ENV=<path> and
TEST_ENV=<path> override the locations.
Layout
The repo has two lanes, and the directory a file sits in says which one it belongs to. Nothing from the test lane can reach a printer.
Ships — everything that ends up inside the .tgz and runs on the machine:
Files that run on the printer live with the RECIPE THAT OWNS THEM, and the
directory inside that recipe says how they get there. Three names, no fourth
(qa/static/test_recipe_layout.py holds this):
pkgs/<recipe>/payload/ staged into the .ipk, laid out as it lands under
$MODDIR: payload/etc/s6-rc/source/nginx installs as
$MODDIR/etc/s6-rc/source/nginx and no recipe says so
pkgs/<recipe>/payload/prog/
package files the printer reads from somebody else's
filesystem: anvil-link-prog.sh symlinks them to the
absolute paths under /usr/prog that FlashForge's own
scripts open
pkgs/<recipe>/seed/ templated or seeded user state: not a package member,
because a member is overwritten on every upgrade
pkgs/<recipe>/control/ maintainer scripts, copied verbatim into the .ipk's
CONTROL/ -- metadata, not content
installer/ run-pre.sh, run-append.sh -- never files on the
printer at all: patch.sh splices them into the stock
run.sh
Which is to say:
pkgs/anvil-core/ what makes the machine ours, and nothing else
payload/ anvil-env.sh PATH/LD_LIBRARY_PATH/FF_PYTHON -- sourced
anvil-service.sh the start/stop/status/liveness shape services share
init.d/ S40s6, S50wifi, S60nginx, S62moonraker, S65camera,
S70klipper, S80ui
bin/ ff-startup.py, ff_mcu_bringup.py, ffscreen.py,
wifi-action.sh
etc/s6/ the s6 scandir, one directory per service
nginx/nginx.conf
bin/anvil-link-prog.sh links the two below, and the configs, into
the absolute paths the stock scripts read
prog/firmwareExe the wrapper that replaces the stock binary
prog/start.sh replaces the stock Klipper launcher
seed/ anvil.conf.in runtime switches, preserved across mod updates
pkgs/klipper/
payload/ klipper/klippy/extras/ ff_*.py
pkgs/klipper-config/ every Klipper config the mod owns
payload/ config/ff-*.cfg the toolchanger's includes
config/printer.base.cfg the hub the rest hang off
config/chamber/Creator5.cfg , Creator5Pro.cfg
both ship; anvil-link-prog.sh symlinks the one
app_startup.sh's MACHINE names
pkgs/moonraker/
payload/ config/moonraker.conf the server's own config, with the server
seed/ moonraker-custom.conf the user's own Moonraker settings
pkgs/helixscreen/
payload/ helixscreen/config/printer_database.d/
the entry that makes it a toolchanger
The init scripts stay with anvil-core and do NOT move to the components
they start, which looks inconsistent and is not. Those services are gated at
RUNTIME by flags in anvil.conf; the packages that would own the scripts are
gated at BUILD time by BUILD_HELIX. A
BUILD_HELIX=0 tarball has no anvil-helixscreen package, so it would carry
no S80ui, and nothing would decide about the screen at all. The script that
reads a flag must not itself sit behind a second, different flag.
Builds it — host-side, never installed:
bin/ fetch-assets -> unpack -> patch -> pack, plus verify.
build-packages.sh is the packaging lane below, and the four
steps need it to have run: patch.sh checks for the feed and
refuses without one
versions.env pinned Mainsail / HelixScreen / Moonraker versions + sha256
vendor/ where fetch-assets.sh caches them (gitignored), plus the
opkg-utils checkout -- the one entry pinned by git commit
rather than sha256, because it has no release tarball
config.env your paths, the root password hash, the model
docker/ Dockerfile.build -- the container every target runs in
pkgs/ package recipes: one directory per component, each a
build.sh producing a $MODDIR-relative tree and a pkg.conf
naming it, plus whatever files of ours that component ships.
`make packages` builds them into .ipk files in work/packages/
with a feed index, using upstream's opkg-build. The release
path depends on this: recipes resolve their build
dependencies out of the feed, so `make packages` comes first.
See docs/notes/85-packaging.md.
3rdparty/ the recipes that build a pinned tarball and carry no files
of this repo -- thirty-four of the thirty-eight. They are
one level down so that `ls pkgs/` is the four components a
person edits rather than those four buried in the rest.
The line is mechanical, not editorial: a recipe belongs up
a level when it grows a payload/, prog/ or seed/, and
qa/static/test_recipe_layout.py enforces it in both
directions.
lib.sh the part of a cross-build every recipe shares: the
toolchains, the compiler wrappers and their ABI self-test,
configure/make/install, the build cache. A recipe that
needs something this cannot express should grow it --
qa/static/test_ipk.py fails a recipe that goes around it.
libsodium/ bin/patch.sh section 5d's build, moved. patch.sh runs it,
so the payload's copy and the packaged copy are one build.
opkg/ the package manager itself: static musl, with zlib and
libarchive as build-only dependencies linked into it. This
is what installs packages on the printer.
Tests it — never ships, and never touched by a build:
qa/ THE SUITE. static/ needs nothing but a checkout;
replica/ needs docker and the firmware. `make qa`
test/ 28 host-side unit tests over our own Python, and the two
fixtures they share. `make test-py`
conftest.py the merged config tree the config gates read
ffcfg.py Klipper config parsing, as klippy's own parser sees it
integration/ test_startup.py, test_tool_transform.py, test_ffscreen.py,
test_chamber.py, test_gcode.py -- see docs/testing.md
test-chelper.py in neither suite: bin/patch.sh and bin/verify.sh call it
tools/replica/ THE REPLICA, which is a build tool as much as a test one --
bin/patch.sh assembles the payload inside it
printer/ the machine: binfmt, the mount layout, its two Dockerfiles
and the entrypoint that runs inside it on the printer's own
binaries -- SHELL, because the printer's busybox ash is the
only interpreter that matters there
ffsim/ the host half: config loading and the docker plumbing
extract-rootfs.py pulls the real rootfs out of the stock package
sim-boot-screen.py renders the boot frames, via the docker socket
build-printer-image.sh bakes a prebuilt replica image
test.env replica settings only -- factory image, partition sizes
docs/ the documentation
Nothing left under test/ needs the firmware, or docker, or a network: the
config gates need python3 and jinja2, and the rest exercise our own Python
directly. It briefly had a directory of its own, test/unit, for exactly that
distinction; with one maintainer who always has the firmware, that was a
boundary kept in sync for nobody. Everything that needs a real machine is in
qa/, and the machine itself is in tools/replica/.
The line between Python and shell here is not taste. Everything that runs on YOUR machine is Python; everything executed by the printer's own busybox under qemu stays shell, because the fact that it survives that is a large part of what the suite proves. See testing.md for why the host half moved.
Two things keep the boundary from eroding: the only files of ours that
patch.sh copies into a package are the ones under a recipe's payload/,
prog/ or seed/, and make verify fails if a built package contains any
file byte-identical to one in bin/, test/ or docker/.
Rebuilding chelper
klippy/chelper/c_helper.so must be MIPS32r2 / nan2008 / o32 or klippy dies on
import — pkgs/klipper refuses to seal a package with anything else, and
test-install checks the copy that lands on the machine. Debian's
cross-compilers cannot produce one (big-endian or legacy-nan); the Ingenic
gcc 7.2.0 / glibc 2.29 toolchain for the X2000 can, and it is pinned by
sha256 in versions.env and fetched into vendor/ like every other asset.
You do not rebuild it by hand any more. pkgs/klipper compiles the .so from
the chelper sources of the very tree it is about to ship — the fork tarball
pinned in versions.env, which since the recipe landed is the only source
there is. Two gates then run inside the recipe, so make packages enforces
them as well as a firmware build: the ELF-flag check above, and
test/test-chelper.py, which checks every function the klippy tree cdefs against the .so's dynamic
symbol table. The symbol gate exists because cffi resolves lazily: a stale
.so imports cleanly and dies only at Internal error during connect on the
printer, with a traceback that names cffi and not the build. Both a stale
prebuilt .so (v0.12 binary under a v0.13 tree) and a package that skipped
the fork entirely (v20260824-nova-kakhovka) have shipped; the compile-fresh
rule plus these gates are what make the third time structurally hard.
The docs site
These pages are the site at
reforge.readthedocs.io. Read the Docs
builds mkdocs.yml out of this repo on every push to master and once per
tag, so a page and the code it describes move in the same commit and the same
review, and an owner running an older package can read that package's docs
from the version switcher rather than master's.
To preview locally:
python3 -m venv .venv && .venv/bin/pip install -r docs/requirements.txt
.venv/bin/mkdocs serve # http://127.0.0.1:8000
.venv/bin/mkdocs build --strict # what CI and RTD run
Three things about it are worth knowing before you add a page.
The nav is split by audience, and the split is the point. mkdocs.yml has
a Using Reforge section for someone who owns the printer and a For
contributors section for someone building or testing the firmware, with the
docs/notes/ internals nested inside the second. A new page that is not in
that nav is built but unreachable. Put it in the section its reader is
standing in.
Links stay repo-relative. Write ../payload/klipper/extras/ff_tool.py and
notes/40-offsets.md exactly as you would for someone reading the page on
GitHub; hooks/mkdocs_readme.py turns the ones that leave docs/ into GitHub
URLs at build time. Anchors use GitHub's slug rules, so #runout--clog for
## Runout / clog works in both places. The same hook generates
docs/index.md from README.md on every build — edit the README, never that
file, which is gitignored.
A dead link fails the build. CI's docs job and RTD both build with
--strict, so renaming a file in payload/ breaks the build rather than
leaving a doc pointing at nothing. It cannot check that the prose is still
true; only a reader can.