diff --git a/.gitattributes b/.gitattributes
index d54ff9f6e4..72e57d211c 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -8,3 +8,11 @@ assets/test_gcodes/** linguist-vendored
# Needs the driver from `make setup`; unregistered, git falls back to a text
# merge and these files conflict whenever two branches both cited a lesson.
.claude-recall/*.json merge=recall-stats
+
+# This fork's README is its own document, not a patched copy of upstream's.
+# Upstream touches README.md every couple of days, so without this every merge
+# of main into develop conflicts on a file whose two sides were never meant to
+# be reconciled. `ours` keeps this fork's version untouched and drops upstream's
+# change. Needs the driver from `make setup`; unregistered, git falls back to a
+# text merge and the conflicts come back.
+README.md merge=ours
diff --git a/.github/workflows/snapmaker-u1.yml b/.github/workflows/snapmaker-u1.yml
new file mode 100644
index 0000000000..2c08489dd9
--- /dev/null
+++ b/.github/workflows/snapmaker-u1.yml
@@ -0,0 +1,306 @@
+# Snapmaker U1 — cross-compile, package, and (on a u1-v* tag) publish.
+#
+# Why this exists next to release.yml: release.yml already knows how to build
+# this platform, but it fires only on a `v*` tag and its downstream jobs need
+# secrets a fork does not have (R2_*, Android signing, WEBSITE_DISPATCH_TOKEN).
+# This workflow is the same toolchain + build + package steps with nothing that
+# can fail for lack of a secret, so a fork can produce an installable U1 build
+# from a branch push or a button press.
+#
+# Deliberately NOT triggered by `v*`: that is upstream's release tag, and a fork
+# pushing one would start release.yml too, which then fails on the missing
+# secrets. Fork releases use `u1-v` (e.g. u1-v0.99.114).
+name: Snapmaker U1
+
+on:
+ push:
+ branches:
+ - main
+ - 'feat/**'
+ - 'fix/**'
+ # A `u1-v*` tag additionally publishes a GitHub Release on this repo (see
+ # the publish job).
+ tags:
+ - 'u1-v*'
+ # No paths-ignore here on purpose: GitHub applies path filters to TAG pushes
+ # too, so a release tag landing on a docs-only commit would silently not
+ # build. Skipping a few docs-push builds is not worth a release that
+ # quietly does nothing. The PR trigger below keeps the filter.
+ pull_request:
+ branches: [ main ]
+ paths-ignore:
+ - '**.md'
+ - 'docs/**'
+ workflow_dispatch:
+
+concurrency:
+ group: snapmaker-u1-${{ github.ref }}
+ # A tag build ends in a published release — never cancel one of those.
+ cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }}
+
+jobs:
+ build:
+ name: Build (Snapmaker U1)
+ runs-on: ubuntu-22.04
+ timeout-minutes: 120
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v6
+ with:
+ submodules: false
+
+ - name: Initialize submodules
+ uses: ./.github/actions/init-submodules
+
+ # The runner ships nearly full of preinstalled SDKs we never use, and the
+ # cross-compile writes assembler temps + ccache to the root partition.
+ - name: Free up disk space
+ run: |
+ df -h /
+ sudo rm -rf /usr/local/lib/android /usr/share/dotnet /opt/ghc /usr/local/.ghcup /opt/hostedtoolcache/CodeQL
+ df -h /
+
+ # The U1 links with -flto, so the final ld is one large process; release.yml
+ # learned the hard way (v0.99.110, exit 137) that the runner's RAM is not
+ # reliably enough for a peak like that. Swap makes it page instead of dying.
+ - name: Add swap for the link peak
+ run: |
+ free -h
+ sudo fallocate -l 16G /swapfile-helix || sudo dd if=/dev/zero of=/swapfile-helix bs=1M count=16384
+ sudo chmod 600 /swapfile-helix
+ sudo mkswap /swapfile-helix
+ sudo swapon /swapfile-helix
+ free -h
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v4
+
+ # Unlike release.yml (which runs on a tag ref, whose cache scope is always
+ # empty and must not save), this runs on a branch — so it can both restore
+ # and save, and the second run on a branch is substantially faster.
+ - name: ccache
+ uses: actions/cache@v5
+ with:
+ path: /tmp/.ccache-snapmaker-u1
+ key: ccache-snapmaker-u1-${{ github.ref_name }}-${{ github.sha }}
+ restore-keys: |
+ ccache-snapmaker-u1-${{ github.ref_name }}-
+ ccache-snapmaker-u1-
+
+ - name: Configure ccache
+ run: |
+ mkdir -p /tmp/.ccache-snapmaker-u1
+ printf 'max_size = 2G\ncompression = true\n' > /tmp/.ccache-snapmaker-u1/ccache.conf
+
+ # Debian Trixie + apt crossbuild-essential-arm64. No tarball fetch, no
+ # private registry, no secret — which is why this platform is the one a
+ # fork can build unaided.
+ - name: Build toolchain image
+ run: |
+ docker buildx build \
+ --cache-from type=gha,scope=toolchain-snapmaker-u1 \
+ --cache-to type=gha,mode=max,scope=toolchain-snapmaker-u1 \
+ --load \
+ -t helixscreen/toolchain-snapmaker-u1 \
+ -f docker/Dockerfile.snapmaker-u1 \
+ docker/
+
+ - name: Cross-compile for Snapmaker U1
+ env:
+ # A cross build carries the helixctl server by default, and
+ # release-snapmaker-u1 refuses to package a binary that does -- it
+ # reads the remote_control= stamp mk/rules.mk writes at link time, so
+ # the flag has to be set HERE, at the build, not at the packaging
+ # step. HELIX_PACKAGING=1 is what turns the server off.
+ #
+ # Only on a u1-v* tag. Branch builds keep the server on purpose: that
+ # is what makes a dev cross build drivable on the device.
+ PACKAGING: ${{ startsWith(github.ref, 'refs/tags/u1-v') && 'HELIX_PACKAGING=1' || '' }}
+ run: |
+ docker run --rm \
+ -v "${{ github.workspace }}":/src \
+ -v /tmp/.ccache-snapmaker-u1:/root/.cache/ccache \
+ -w /src \
+ helixscreen/toolchain-snapmaker-u1 \
+ make PLATFORM_TARGET=snapmaker-u1 SKIP_OPTIONAL_DEPS=1 $PACKAGING -j$(nproc)
+
+ - name: Show ccache stats
+ run: |
+ docker run --rm \
+ -v /tmp/.ccache-snapmaker-u1:/root/.cache/ccache \
+ helixscreen/toolchain-snapmaker-u1 \
+ ccache -s
+
+ # The container runs as root; everything after this is host-side.
+ - name: Fix build directory ownership
+ run: sudo chown -R $(id -u):$(id -g) build/
+
+ # The device's own CA bundle is not guaranteed to be current, so the local
+ # `make snapmaker-u1-docker` target extracts the image's. release.yml has
+ # no equivalent, so CI tarballs have been shipping without certs/ — and
+ # release-snapmaker-u1 only includes the file if it is already there.
+ - name: Extract CA certificates for HTTPS on device
+ run: |
+ mkdir -p build/snapmaker-u1/certs
+ docker run --rm helixscreen/toolchain-snapmaker-u1 \
+ cat /etc/ssl/certs/ca-certificates.crt > build/snapmaker-u1/certs/ca-certificates.crt
+ test -s build/snapmaker-u1/certs/ca-certificates.crt
+
+ - name: Verify the binary is a U1 binary
+ run: |
+ set -e
+ bin=build/snapmaker-u1/bin/helix-screen
+ test -f "$bin"
+ file "$bin"
+ # aarch64, and dynamically linked against glibc (the U1 target is
+ # hybrid: static libstdc++/libgcc, dynamic libc/libdrm).
+ file "$bin" | grep -q 'ARM aarch64'
+ ls -lh "$bin"
+
+ - name: Generate pre-rendered images
+ run: make venv-setup && make gen-all-images
+
+ - name: Package release
+ run: make release-snapmaker-u1
+
+ # The zip is the important one: its name is version-less by design
+ # (helixscreen-snapmaker-u1.zip), which is what the installer looks for
+ # first at every transport and what Moonraker's update manager needs.
+ - name: Show packaged contents
+ run: |
+ ls -lh releases/
+ unzip -l releases/helixscreen-snapmaker-u1.zip | head -30
+
+ - name: Upload U1 build
+ uses: actions/upload-artifact@v7
+ with:
+ name: helixscreen-snapmaker-u1
+ path: |
+ releases/helixscreen-snapmaker-u1.zip
+ releases/helixscreen-snapmaker-u1-*.tar.gz
+ if-no-files-found: error
+ retention-days: 30
+
+ - name: Upload symbol map
+ uses: actions/upload-artifact@v7
+ with:
+ name: symbols-snapmaker-u1
+ path: |
+ build/snapmaker-u1/bin/helix-screen.sym
+ build/snapmaker-u1/bin/helix-screen.debug
+ if-no-files-found: warn
+ retention-days: 30
+
+ # ==========================================================================
+ # Publish a GitHub Release on THIS repo, so scripts/install-fork.sh (and the
+ # printer's Moonraker update manager) have something to download.
+ #
+ # Only on a `u1-v*` tag. Uses the automatic GITHUB_TOKEN — no configured
+ # secret — so it works on a fork out of the box.
+ # ==========================================================================
+ publish:
+ name: Publish release
+ needs: build
+ if: startsWith(github.ref, 'refs/tags/u1-v')
+ runs-on: ubuntu-22.04
+ permissions:
+ contents: write
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v6
+ with:
+ submodules: false
+
+ - name: Download build
+ uses: actions/download-artifact@v8
+ with:
+ name: helixscreen-snapmaker-u1
+ path: release-files
+
+ # The installer resolves a version to
+ # .../releases/download//helixscreen-snapmaker-u1-.tar.gz
+ # (and the version-less .zip, which it tries FIRST and which therefore
+ # works whatever the tag is called). `make release-snapmaker-u1` names the
+ # tarball from VERSION.txt, so unless the tag matches that exactly, the
+ # tar URL 404s and only the zip resolves. Add a correctly-named copy so
+ # both do — belt and braces, and it costs one hardlink.
+ - name: Name the tarball after the tag
+ run: |
+ set -eu
+ cd release-files
+ tag="${GITHUB_REF_NAME}"
+ want="helixscreen-snapmaker-u1-${tag}.tar.gz"
+ have=$(ls helixscreen-snapmaker-u1-*.tar.gz | head -1)
+ if [ "$have" != "$want" ]; then
+ cp "$have" "$want"
+ echo "Added $want (built as $have)"
+ fi
+ ls -lh
+
+ # Ship BOTH installers with the build, exactly as upstream ships install.sh
+ # with its releases. install-fork.sh fetches install.sh from
+ # releases/latest/download/ first, so the installer that runs is the one
+ # that built the binary it installs — and it exists exactly when there is
+ # something to install, unlike a branch's raw file.
+ - name: Stage the installers
+ run: cp scripts/install-fork.sh scripts/install.sh release-files/
+
+ - name: Checksums
+ run: |
+ cd release-files
+ sha256sum * > SHA256SUMS
+ cat SHA256SUMS
+
+ - name: Write release notes
+ run: |
+ set -eu
+ tag="${GITHUB_REF_NAME}"
+ cat > release-notes.md < 21 GB), because GCC's
+ # per-type-unit overhead outweighed the dedup at this TU count. Net loss for
+ # the thing being fixed, so it is not used.
+ #
+ # Probed, not assumed: this block also feeds the cross toolchains (pi, ad5m,
+ # cc1), where an unsupported flag would break the build outright.
+ DEBUG_SIZE_FLAGS := $(shell printf 'int main(){return 0;}' | \
+ $(CXX) -x c++ -gz -c -o /dev/null - >/dev/null 2>&1 && echo -gz)
+ CFLAGS := -std=c11 -Wall -Wextra -O$(OPT) -g $(DEBUG_SIZE_FLAGS) -D_GNU_SOURCE -fno-omit-frame-pointer -fstack-protector-strong
+ CXXFLAGS := -std=c++17 -Wall -Wextra -O$(OPT) -g $(DEBUG_SIZE_FLAGS) -fno-omit-frame-pointer -fstack-protector-strong
ifneq ($(OPT),0)
CFLAGS += -D_FORTIFY_SOURCE=2
CXXFLAGS += -D_FORTIFY_SOURCE=2
@@ -619,6 +640,8 @@ else
LIBHV_LIBS := $(LIBHV_LIB)
endif
+
+
# libhv generates include/hv headers during libhv-build. Track json.hpp so a
# stale archive cannot be reused when generated headers are missing.
ifneq ($(LIBHV_LIB),)
@@ -796,6 +819,12 @@ ifeq ($(YOCTO_BUILD),yes)
LDFLAGS += -lssl -lcrypto
endif
LDFLAGS += $(TARGET_LDFLAGS)
+ # No -gz here on purpose. DEBUG_SIZE_FLAGS is defined only on the non-Yocto
+ # branch, and there it reaches the linker through CXXFLAGS (mk/rules.mk
+ # links both binaries with $(CXX) $(CXXFLAGS) ... $(LDFLAGS)), so the debug
+ # sections stay compressed in the linked output without a separate LDFLAGS
+ # entry. On this Yocto branch nothing is -gz-compressed to begin with.
+
PLATFORM := Linux-yocto
# No submodule wpa_client to depend on — wpa-supplicant recipe installs libwpa_client.
WPA_DEPS :=
@@ -952,6 +981,22 @@ endif
CXXFLAGS += $(ALSA_CXXFLAGS)
LDFLAGS += $(ALSA_LIBS)
+# Link with mold when available. GNU ld spends ~97s on helix-screen and longer
+# on helix-tests, and that link IS the cost of an edit-one-file iteration —
+# compiling the changed TU takes about a second. mold is a drop-in: same inputs,
+# same output, gdb reads it identically.
+#
+# MUST live down here for the same reason ASAN's flags do — the per-platform
+# `LDFLAGS :=` composition above clobbers anything appended earlier. Probed by
+# actually linking rather than `which mold`, because the cross toolchains (pi,
+# ad5m, cc1) use a different g++ that may not accept -fuse-ld=mold, and a link
+# failure there is worse than a slow link.
+HELIX_USE_MOLD := $(shell printf 'int main(){return 0;}' | \
+ $(CXX) -x c++ -fuse-ld=mold -o /dev/null - >/dev/null 2>&1 && echo yes)
+ifeq ($(HELIX_USE_MOLD),yes)
+ LDFLAGS += -fuse-ld=mold
+endif
+
# Re-apply AddressSanitizer linker flags AFTER the per-platform LDFLAGS
# composition above (lines 580-694) — those use `LDFLAGS :=` which clobbers
# anything cross.mk's SANITIZE block injected. Must come last so platform
diff --git a/README.md b/README.md
index a92defb01a..52ae9c5e67 100644
--- a/README.md
+++ b/README.md
@@ -1,300 +1,106 @@
A fork of HelixScreen that supports the U1's toolchanger and multiple ACE units
-
-
+
+
-
-
-
-
-Your printer can do way more than your current touchscreen lets you. Bed mesh visualization, input shaper graphs, multi-material management, print history — it's all trapped in a browser tab. HelixScreen puts it at your fingertips.
-
-Fast, beautiful, and frugal enough to run on hardware you already own — your printer's onboard SoC, a Raspberry Pi from a drawer, or anything newer.
-
-Run it right on your printer, or on a separate device — a spare Pi, a mini PC, even your desktop — as a remote screen pointed at your printer's Moonraker over the network. Great for a floor-standing printer with a screen up on your desk.
-
----
-
-**Quick Links:** [Website](https://helixscreen.org) · [Features](#features) · [Screenshots](#screenshots) · [Installation](#installation) · [User Guide](docs/user/USER_GUIDE.md) · [FAQ](#faq) · [Contributing](CONTRIBUTING.md) · [Changelog](CHANGELOG.md) · [Discord](https://discord.gg/RZCT2StKhr)
+> **This is a fork of [prestonbrown/helixscreen](https://github.com/prestonbrown/helixscreen).**
+> Everything upstream HelixScreen does, this does — see [upstream's README](https://github.com/prestonbrown/helixscreen#readme)
+> for the full feature tour. What follows is only what this fork adds on top, for
+> the Snapmaker U1.
+>
+> Upstream is merged in regularly, and U1 work goes back upstream where it fits.
---
-## Why HelixScreen?
-
-- **Customizable dashboard** — Multi-page grid with drag-to-reposition, edge resize, and 30+ widgets including temperature graphs, fan arcs, and power toggles
-- **Every feature at your fingertips** — 30+ panels, 20+ overlays, 20+ modals, 300+ XML layouts
-- **~15MB RAM on embedded targets, ~75MB disk** — sips memory on a Creality K1 or Flashforge AD5M; a few times more on 64-bit Pi, still well under what other touchscreen UIs need. Your printer's onboard SoC or an older Pi is plenty — no need to buy new hardware.
-- **80+ printers in the database** — Auto-detects your hardware and configures itself
-- **Multi-material ready** — AFC, Happy Hare, ACE, AD5X IFS, CFS, Snapmaker U1, tool changers (incl. MedusaHC hotend changers), Spoolman
-- **Exclude objects** — Tap-to-exclude overhead map view with object outlines during prints
-- **Looks great** — Light/dark themes with 17 presets, responsive layouts, GPU-accelerated blur
-- **First-run wizard** — Guided setup discovers your printer's capabilities
-- **9 languages** — English, German, Spanish, French, Italian, Japanese, Portuguese, Russian, and Chinese
-
-
-Technical comparison
-
-| Feature | HelixScreen | GuppyScreen | KlipperScreen |
-|---------|-------------|-------------|---------------|
-| UI Framework | LVGL 9 XML | LVGL 8 C | GTK 3 (Python) |
-| Declarative UI | Full XML with reactive bindings | C only | Python only |
-| RAM Usage | ~15MB (32-bit) | ~15-20MB | ~50MB |
-| Disk Size | ~75-115MB | ~60-80MB | ~50MB |
-| Multi-Material | 7 backends | Limited | Basic |
-| Printer Database | 80+ models | — | Manual config |
-| Display Layouts | Auto-detecting (480x320 to 1024x600, plus ultrawide and portrait) | Fixed | Configurable |
-| Internationalization | 9 languages | — | 40+ languages |
-| Status | Pre-1.0, actively developed | Inactive | Mature (maintenance) |
-| Language | C++17 | C | Python 3 |
-
-
-
-## Screenshots
-
-### Home Panel
-
-
-### Print File Browser
-
-
-### Bed Mesh Visualization
-
-
-
-More screenshots
-
-### Controls Panel
-
-
-### Motion Controls
-
-
-### AMS / Filament Management
-
+## What this fork adds
-### Input Shaper Results
-
+### Multiple ACE units alongside the U1's own toolheads
-### PID Tuning
-
+Stock HelixScreen models the U1 as a four-head parallel toolchanger. This fork adds
+**multiACE**: one or more Snapmaker ACE units feeding the same machine, shown as
+separate units with their own slot counts, and a filament path that traces which
+spool actually feeds which tool.
-### Settings
-
+
-### First-Run Wizard
-
+Per-unit drying comes with it — live temperature and humidity, material presets, and
+auto-dry with its own thresholds:
-
+
-See [docs/devel/GALLERY.md](docs/devel/GALLERY.md) for the full gallery.
+### LAN pairing for Snapmaker Orca and the Snapmaker App
-## Features
+The U1's firmware brokers pairing itself and delegates one step to the printer's
+screen: the approval tap. Replacing the stock screen means nothing answers, so
+Orca and the phone app hang at *"requesting connection"* until they time out.
-**Dashboard** — Customizable multi-page grid with drag-to-reposition, edge resize, and a catalog of 30+ widgets. Temperature graphs, fan arcs, power toggles, camera feeds, active spool, favorite macros — add what matters, hide what doesn't. Per-breakpoint layout persistence.
+This fork answers it:
-**Printer Control** — Print management with G-code preview, motion controls, temperature presets with per-material overrides, multi-fan control, Z-offset, speed/flow tuning, live filament consumption tracking, power device management.
+
-**Multi-Material** — 7 filament system backends: AFC (Box Turtle, ViViD), Happy Hare (ERCF, 3MS, Tradrack, Night Owl), ACE (Anycubic ACE Pro), AD5X IFS, Creality CFS, Snapmaker U1 (with RFID spool recognition), and tool changers — including MedusaHC hotend changers, whose dock sensors and filament feeder are driven on top of klipper-toolchanger. Multi-unit and multi-backend support. Full Spoolman integration with spool creation wizard.
+Deny goes on the wire too, so a refused client fails immediately instead of waiting.
+Details in [`docs/devel/LAN_CLIENT_AUTHORIZATION.md`](docs/devel/LAN_CLIENT_AUTHORIZATION.md).
-**Visualization** — 3D G-code layer preview with memory-aware geometry budgets, 3D bed mesh with async rendering, print thumbnails, frequency response charts, unified temperature graph.
+### Installable U1 builds
-**Calibration** — Input shaper with frequency response charts, PID tuning with live graph, MPC calibration (Kalico), belt tension tuning, bed mesh, screws tilt adjust, Z-offset, firmware retraction, probe management.
+Upstream's release pipeline needs secrets a fork does not have. This fork ships its
+own U1 workflow that cross-compiles, packages and publishes a working build from a
+`u1-v*` tag — plus a fork-aware installer, so the printer is offered *this* repo's
+releases as updates rather than upstream's.
-**Integrations** — HelixPrint plugin, power devices with quick-toggle, print history, timelapse (Moonraker plugin), exclude objects with tap-to-exclude map view, LED control (5 backends), sound alerts (SDL/PWM/M300), Bluetooth label printing (Brother QL/PT, Niimbot, MakeID).
-
-**Display** — Auto-detecting layout system (480x320 through 1024x600, plus ultrawide and portrait — see below), display rotation (0/90/180/270) with auto-detection, light/dark themes with 17 presets and live theme editor, GPU-accelerated backdrop blur, screensavers.
-
-**System** — First-run wizard with guided hardware discovery, 80+ printer models with auto-detection, 9 languages, opt-in crash reporting with debug bundles, KIAUH installer, versioned config migration.
-
-## Supported Platforms
-
-| Platform | Architecture | Status |
-|----------|-------------|--------|
-| Raspberry Pi 3/4/5, CM4, Zero 2 W (64-bit) | aarch64 | Tested |
-| Raspberry Pi 3/4 (32-bit) | armhf | Tested |
-| BTT Pad / CB1 / CB2 / Manta | aarch64 | Tested |
-| Creality K1 / K1C / K1 Max | MIPS32 | Tested |
-| Creality K2 Pro / K2 Plus / K2 SE | ARM (musl) | Tested |
-| Creality Sonic Pad | armhf | Tested |
-| Flashforge AD5M / AD5M Pro | armv7-a | Tested |
-| Flashforge AD5X | MIPS32 | Tested |
-| Snapmaker U1 (SnapSwap toolchanger) | aarch64 | Tested³ |
-| QIDI Q2, Max 4 | aarch64 | Supported¹ |
-| Sovol SV06 / SV08 | Pi build | Tested |
-| Elegoo Centauri Carbon | armv7-a | Tested² |
-| x86 Mini PC (Debian) | x86_64 | Tested |
-| macOS / Linux desktop | x86_64 / ARM64 | Development / CI |
-| Android phone / tablet | arm64 / x86_64 | Experimental⁴ |
-
-¹ QIDI models with Linux framebuffer displays (Q2, Max 4) only. Stock firmware runs standard Moonraker and works directly; community firmware like [FreeDi](https://github.com/Phil1988/FreeDi), [53Aries/Q2-Firmware](https://github.com/53Aries/Q2-Firmware), or [FreeQIDI](https://github.com/Phil1988/FreeQIDI) is optional. Older models (X-Smart 3, X-Plus 3, X-Max 3, Q1 Pro, Plus 4) ship with QIDI's MKS PI smart-panel (a TJC serial HMI that *is* the UI; TJC is the Chinese OEM that Nextion licenses globally) and are **not compatible for on-device install** without a screen replacement — see [QIDI_SUPPORT.md → Display Compatibility](docs/devel/printers/QIDI_SUPPORT.md#display-compatibility) for why. Remote-control mode works on all six QIDI models regardless.
-
-² Elegoo Centauri Carbon requires the community [OpenCentauri COSMOS](https://github.com/OpenCentauri/cosmos) firmware ([docs](https://docs.opencentauri.cc/klipper-conversion/cosmos/cosmos/); stock Elegoo firmware has no SSH, Klipper, or Moonraker). Ships with factory white-balance calibration for the 4.3" panel.
-
-³ Snapmaker U1 needs SSH access. Stock firmware (1.2+) provides it via the **Root access** option in printer settings; the community [PAXX Extended Firmware](https://github.com/paxx12-snapmaker-u1/SnapmakerU1-Extended-Firmware) enables SSH by default and is the easiest path. Tested on PAXX 1.3.x/1.4.x; the stock-firmware path is unverified on a real stock device. Reinstall HelixScreen after any firmware update — it resets system files and the stock screen returns until you reinstall.
-
-⁴ Android is a **remote** client only. It monitors and controls a printer over your network and does not replace a printer's own panel. Needs Android 9.0 or newer, and runs in landscape. Not on Google Play yet, so you install the APK yourself from a [GitHub release](https://github.com/prestonbrown/helixscreen/releases/latest). See [Android app](docs/user/INSTALL.md#android-app-experimental).
+---
-## Installation
+## Install
-> **Run these commands on whatever machine will drive the display.**
-> For an on-printer screen, that's your printer's host — SSH into your Raspberry Pi, BTT board, or (for all-in-one printers like Creality K1/K2, Flashforge AD5M/Pro) directly into the printer.
-> For a **remote screen** on a separate device (a spare Pi, mini PC, etc.), run them there instead, then point it at your printer's Moonraker (IP + port `7125`) in the setup wizard. See [Remote screen setup](docs/user/INSTALL.md#remote-screen-setup-run-on-a-separate-device).
+On the printer, over SSH:
-**One-line install:**
-```bash
-curl -sSL https://raw.githubusercontent.com/prestonbrown/helixscreen/main/scripts/install.sh | sh
+```sh
+curl -fsSL https://github.com/physicsG/helixscreen/releases/latest/download/install-fork.sh | sh
```
-The installer auto-detects your platform, downloads the correct binary, sets up the service, and launches the first-run wizard. To update:
-```bash
-curl -sSL https://raw.githubusercontent.com/prestonbrown/helixscreen/main/scripts/install.sh | sh -s -- --update
-```
+That is a thin front-end for upstream's installer — platform detection, service
+setup, backup/rollback and SHA256 verification are all the same code path. It only
+pins the repo to this fork and skips upstream's CDN, which would otherwise serve
+upstream's binary.
-To install or roll back to a specific release (e.g. a last-known-good version), pass `--version` with the tag:
-```bash
-curl -sSL https://raw.githubusercontent.com/prestonbrown/helixscreen/main/scripts/install.sh | sh -s -- --version v0.99.111
+```sh
+sh install-fork.sh --update # update in place
+sh install-fork.sh --version u1-v0.99.116 # pin a specific build
+sh install-fork.sh --local helixscreen-snapmaker-u1.zip # from a downloaded archive
```
-Add `--clean` to wipe HelixScreen's settings and start fresh (it asks for confirmation first; your Klipper/Moonraker config and G-code are untouched). Combine the two to reinstall a specific version with default settings: `--clean --version v0.99.111`.
-
-Also available through [KIAUH](https://github.com/dw-0/kiauh) as an extension.
-
-**Flashforge AD5M/Pro:** We provide a [ready-made firmware image](https://github.com/prestonbrown/ff5m) (Forge-X fork with HelixScreen pre-configured) — just flash from a USB drive. Or install manually on an existing Forge-X/Klipper Mod setup.
-
-**Android (experimental):** There is an Android build for watching and controlling a printer from a phone or tablet. It is not on Google Play yet, so download the APK from the [latest release](https://github.com/prestonbrown/helixscreen/releases/latest) and install it. `helixscreen-android-arm64-v.apk` covers essentially any modern phone or tablet. Nothing gets installed on the printer; the app just needs to reach Moonraker on your network. See [Android app](docs/user/INSTALL.md#android-app-experimental).
-
-See the [Installation Guide](docs/user/INSTALL.md) for detailed instructions, display configuration, and troubleshooting.
-
-## Development
-
-**Want to contribute? Start at [CONTRIBUTING.md](CONTRIBUTING.md)** — it routes you by what you want to do. New contributors follow a marked path: onboarding (environment + build + a 15-minute mental model) → an annotated first contribution → the per-subsystem architecture guide.
+SSH on the U1: `Settings > Maintenance > Root Access`, or the `firmware-config` web
+page. Credentials are `root` / `snapmaker`.
-The short version, if you just want to see it run:
-
-```bash
-# Check/install dependencies
-make check-deps && make install-deps
-
-# Build
-make -j
-
-# Run with mock printer (no hardware needed) — 'S' takes a screenshot;
-# -v (INFO), -vv (DEBUG), -vvv (TRACE) for logging
-./build/bin/helix-screen --test -vv
-
-# Run tests
-make test-run
-```
-
-XML layouts hot-reload by default on native builds — edit `ui_xml/*.xml`, save, watch the running UI update live.
-
-**Test suite:** 5,000+ test cases across 600+ test files covering printer state, UI components, XML parsing, multi-material, and more.
-
-For the daily-workflow reference (run flags, logging, config, IDE setup), see [docs/devel/DEVELOPMENT.md](docs/devel/DEVELOPMENT.md).
-
-## FAQ
-
-**How is this different from GuppyScreen/KlipperScreen?**
-More features, far lower RAM use (~15MB on embedded targets vs ~50MB for KlipperScreen), and actively developed. The lighter footprint means the printer you have or a Pi you've owned for years is plenty — no need to chase new SBC hardware. See the [comparison table](#why-helixscreen).
-
-**Can I run HelixScreen on a separate device instead of on my printer?**
-Yes. Install it on any supported Linux device — a spare Pi, a mini PC, even your desktop — and enter your printer's IP in the wizard's Moonraker step. This is ideal when the printer is on the floor and you want the screen at your desk. Point it at Moonraker (port `7125`), not Mainsail/Fluidd. See [Remote screen setup](docs/user/INSTALL.md#remote-screen-setup-run-on-a-separate-device).
-
-**Which printers are supported?**
-Any Klipper + Moonraker printer. 80+ models in the auto-detection database spanning Voron, Creality, QIDI, Anycubic, Flashforge, Sovol, RatRig, FLSUN, Elegoo, Prusa, Snapmaker, and more. The wizard auto-discovers your printer's capabilities even if it's not in the database.
-
-**What screen sizes are supported?**
-800x480 and 1024x600 are the well-tested landscape sizes; 480x320 runs but is tight in places. Display rotation (0/90/180/270) with auto-detection.
-
-**Ultrawide (e.g. 1920x440) and portrait (e.g. 480x800) both work.** The layout engine detects either orientation, sizes the grid from the screen, and gives each its own home dashboard layout. Portrait also has dedicated layouts for the app shell, navigation bar, Print Status, Print Tune, Motion, Bed Mesh and the temperature graph; ultrawide has nothing beyond the dashboard. Panels without a dedicated layout use the adaptive fallback, which is why landscape is still the most polished of the three. Both keep gaining per-panel work, and both are open for contributions that need only XML, not C++ — see the [UI Contributor Guide](docs/devel/UI_CONTRIBUTOR_GUIDE.md).
-
-**What multi-material systems work?**
-AFC (Box Turtle, ViViD), Happy Hare (ERCF, 3MS, Tradrack, Night Owl), ACE (Anycubic ACE Pro), AD5X IFS, Creality CFS, Snapmaker U1 (with RFID spool recognition), and tool changers (viesturz/klipper-toolchanger, including MedusaHC hotend changers). Full Spoolman integration for spool management.
-
-See [docs/user/FAQ.md](docs/user/FAQ.md) for the full FAQ.
-
-## Troubleshooting
-
-| Issue | Solution |
-|-------|----------|
-| SDL2 or build tools missing | `make install-deps` |
-| Submodule empty | `git submodule update --init --recursive` |
-| Can't connect to Moonraker | Check IP/port in settings.json |
-| Wizard not showing | Delete settings.json to trigger it |
-| Display upside down | Set rotation in settings or check `panel_orientation` in `/proc/cmdline` |
-
-See [docs/user/TROUBLESHOOTING.md](docs/user/TROUBLESHOOTING.md) for more solutions, or open a [GitHub issue](https://github.com/prestonbrown/helixscreen/issues).
-
-## Documentation
-
-### User Guides
-| Guide | Description |
-|-------|-------------|
-| [Installation](docs/user/INSTALL.md) | Setup for Pi, Sonic Pad, K1, K2, AD5M, AD5X, QIDI |
-| [User Guide](docs/user/USER_GUIDE.md) | Using HelixScreen — panels, overlays, settings |
-| [Configuration](docs/user/CONFIGURATION.md) | All settings with examples |
-| [Upgrading](docs/user/UPGRADING.md) | Version upgrade instructions |
-| [FAQ](docs/user/FAQ.md) | Common questions |
-| [Troubleshooting](docs/user/TROUBLESHOOTING.md) | Problem solutions |
-| [Telemetry & Privacy](docs/user/TELEMETRY.md) | What data is collected (opt-in) |
-
-### Developer Guides
-| Guide | Description |
-|-------|-------------|
-| [Development](docs/devel/DEVELOPMENT.md) | Daily workflow: run flags, logging, config, IDE setup |
-| [Architecture](docs/devel/ARCHITECTURE.md) | Whole-app model + guide to the 15 architecture chapters |
-| [LVGL9 XML Guide](docs/devel/LVGL9_XML_GUIDE.md) | XML syntax reference |
-| [UI Contributor Guide](docs/devel/UI_CONTRIBUTOR_GUIDE.md) | Breakpoints, tokens, colors, widgets |
-| [Changelog](CHANGELOG.md) | Release history |
-| [Roadmap](https://github.com/prestonbrown/helixscreen/issues) | Feature timeline (labeled issues) |
-
-## Community
-
-**[Join the HelixScreen Discord](https://discord.gg/RZCT2StKhr)** — Get help, share your setup, request features, and follow development.
-
-**Also discussed in:**
-- [FuriousForging Discord](https://discord.gg/Cg4yas4V) — #mods-and-projects ([jump to HelixScreen topic](https://discord.com/channels/1323351124069191691/1444485365376352276))
-- [VORONDesign Discord](https://discord.gg/voron) — #voc_works ([jump to HelixScreen topic](https://discord.com/channels/460117602945990666/1468467369407156346))
-
-### Co-Maintainers Wanted
-
-We're looking for co-maintainers to help grow HelixScreen! You can contribute broadly across the project or own a specific area that interests you:
-
-- **Printer support** — Maintain builds and testing for specific platforms (Creality, QIDI, Flashforge, etc.)
-- **Multi-material backends** — Own a filament system integration (AFC, Happy Hare, ACE, CFS, etc.)
-- **UI/UX** — Help design and implement panels, overlays, and responsive layouts
-- **Localization** — Maintain translations for your language
-- **Documentation** — Keep guides accurate and help new users get started
-- **Testing & CI** — Expand the test suite and maintain build infrastructure
-
-If you're interested, join the [Discord](https://discord.gg/RZCT2StKhr) and introduce yourself, or open a [GitHub Discussion](https://github.com/prestonbrown/helixscreen/discussions).
-
-**Bug Reports & Feature Requests:** [GitHub Issues](https://github.com/prestonbrown/helixscreen/issues) — please include your printer model and logs (`helix-screen -vv`) when reporting bugs.
+---
-## License
+## Branches
-GPL v3 — See [LICENSE](LICENSE) for details. Third-party components and their licenses are listed
-in [COPYRIGHT](COPYRIGHT).
+| Branch | What it is |
+|---|---|
+| `develop/snapmaker-multiace` | **Default.** The U1 + multiACE work. Releases are cut from here |
+| `main` | A clean mirror of upstream `main` — never committed to directly, so upstream merges stay conflict-free |
-One exception: **[`lib/helix-xml/`](lib/helix-xml/) is MIT**, not GPL. It is a permanent fork of the
-declarative XML UI engine that shipped inside LVGL core until v9.5 removed it and moved it to the
-commercial LVGL Pro. We forked from the last MIT commit (`a15dcbeb5`, 2026-01-26) and keep our own
-contributions to it under MIT so the engine stays usable as a standalone library. See
-[`lib/helix-xml/README.md`](lib/helix-xml/README.md).
+Release tags are `u1-v` (e.g. `u1-v0.99.116`), deliberately not `v*`:
+upstream's tag would start a release pipeline this fork cannot complete.
-## Acknowledgments
+---
-**Inspired by:** [GuppyScreen](https://github.com/ballaswag/guppyscreen) (general architecture, LVGL-based approach), [KlipperScreen](https://github.com/KlipperScreen/KlipperScreen) (feature inspiration)
+## Everything else
-**Built with:** [LVGL 9.5](https://lvgl.io/), [Klipper](https://www.klipper3d.org/), [Moonraker](https://github.com/Arksine/moonraker), [libhv](https://github.com/ithewei/libhv), [spdlog](https://github.com/gabime/spdlog), [SDL2](https://www.libsdl.org/), and `helix-xml` (our MIT fork of LVGL's XML engine)
+Features, supported printers, configuration, the user guide and troubleshooting are
+all upstream's and unchanged — start at
+**[upstream's README](https://github.com/prestonbrown/helixscreen#readme)** and the
+[User Guide](docs/user/USER_GUIDE.md).
-**AI-Assisted Development:** Built with [Claude Code](https://github.com/anthropics/claude-code) by [Anthropic](https://www.anthropic.com/)
+Bugs in U1 or multiACE behaviour belong [here](https://github.com/physicsG/helixscreen/issues).
+Anything that reproduces without a U1 is better reported
+[upstream](https://github.com/prestonbrown/helixscreen/issues).
diff --git a/docs/devel/HELIXCTL.md b/docs/devel/HELIXCTL.md
index f8ecfc484f..8694761a34 100644
--- a/docs/devel/HELIXCTL.md
+++ b/docs/devel/HELIXCTL.md
@@ -754,7 +754,7 @@ frame doesn't change again a moment later. It combines three things:
`unfreeze`. `freeze` is a transient test-mode toggle; a `--remote` dev
instance killed or crashed between the two would otherwise leave a real
user's config with animations permanently disabled — automated tests never
- see this because `--test` uses `settings-test.json`. The handler instead
+ see this because `--test` uses `config/settings-test.json`. The handler instead
reads and writes `DisplaySettingsManager::subject_animations_enabled()`
directly (an accessor already public and already used by several widgets to
observe this setting), and remembers the real pre-freeze value so `unfreeze`
diff --git a/docs/devel/plans/2026-08-09-multiace-mockup.html b/docs/devel/plans/2026-08-09-multiace-mockup.html
new file mode 100644
index 0000000000..f89fd040a8
--- /dev/null
+++ b/docs/devel/plans/2026-08-09-multiace-mockup.html
@@ -0,0 +1,2037 @@
+HelixScreen — Snapmaker U1 + multiACE mockups
+
+
+
+
+
+
+
HelixScreen · design record · 2026-08-09 · verified on hardware
+
+
Four toolheads, not one merger
+
The Snapmaker U1 has four physically independent toolheads. HelixScreen
+ was drawing them fanned into a single merger box feeding one nozzle. Here is why, the
+ fix, and what multiACE adds on top.
+
Every mockup here is drawn at 480 × 320,
+ 1:1 — the U1's actual panel. There is no wider frame on this page on purpose: a
+ layout that only works at 800 px is not a layout for this machine. Each screen is
+ measured in your browser on load and labelled fits or
+ overflows.
+
Everything below was read from the U1 at
+ 192.168.2.242 — Klipper 1.5.2.13, multiACE 0.99.6.1b. Device
+ screens are drawn at true pixel size in HelixScreen's shipped theme.
A working prototype of the panel at the U1's real size. Tap a head to change its
+ source; the swap runs with its step bar and the head updates. Every action prints the
+ G-code HelixScreen would actually send, so this reviews as a protocol proposal,
+ not just a picture.
+
Swaps are time-compressed — a real
+ ACE_SWAP_HEAD takes up to three minutes.
+
+
+
+
+
+
+
+
+
tap a head to change its source
+
+
+
+
+ Hardware
+
+
+
+
+
+
+
+
+ Layout — the open question
+
+
+
+
+
+
+
+ Screen
+
+
+
+
+
+
+
+ Command log
+
+
+
+
+
+
+
+
+
+
01
The merger, and the fix
+
+
+
+
+ before
+
+
+
+
‹Multi-Filament: AFC
+
+
+
+
+
+
PathTopology::HUB — four heads swallowed
+
+
+
+ after
+
+
+
+
‹Multi-Filament: Snapmaker U1
+
+
+
+
+
+
PathTopology::PARALLEL — the machine as built
+
+
+
480 × 320, 1:1 — real state at capture: heads 0–2 empty
+ (wait_insert), head 3 loaded PETG Kingroon #83AFFF
+
+
+
+
Why it happened
+
+
Your U1 runs a community AFC compatibility shim,
+ extended/klipper/afc.cfg, which declares the four toolheads as four
+ AFC_lanes so AFC-aware UIs can see them. HelixScreen therefore detects
+ AFC, not Snapmaker.
+
The AFC backend infers per-unit topology from three inputs: the unit's
+ extruders[], its hubs[], and each lane's hub
+ field. On the shim all three are blank — it declares no
+ [AFC_extruder] sections, no hubs, and publishes no per-lane
+ hub key.
+
With nothing to count, every arm of the chain at
+ ams_backend_afc.cpp:2903 fails and control reaches the trailing
+ else { topology = PathTopology::HUB; }.
+
HUB selects render_linear_hub() — lanes fan into a hub box
+ and out to one nozzle. The merger.
+
+
+
+ Fix
+
The answer was in the payload the whole time: every lane names its own extruder
+ (E0→extruder, E1→extruder1, …), and the backend
+ already parsed it for other purposes. When a unit reports no extruders of its
+ own, derive them from its lanes — four distinct extruders means four
+ independent toolheads. Guarded three ways so a real Box Turtle is untouched: it fires
+ only when the unit's own list is empty, only when every lane's extruder is known (so a
+ partial Moonraker delta can't trigger it), and only when they are distinct.
Your printer has three filament-management stacks installed at once. All three publish
+ Klipper objects. HelixScreen's detection picks exactly one — and which one it picks is
+ decided by object names, so changing what is installed silently changes which
+ backend you get.
The truth. Per-head type/vendor/colour, channel_state,
+ RFID, and the 32→4 extruder_map_table.
+
+
+
AFC shimselected
+
AFC AFC_unit U1 AFC_lane E0…E3
+
A hand-written adapter: 4 lanes → extruder…extruder3,
+ each with its own toolhead sensor, plus macros forwarding colour/material edits to
+ the native API. No extruder sections, no hubs, no steppers.
No — not yet. It is currently load-bearing, and removing it makes things worse:
+
+
+
+
+
If you…
HelixScreen picks
Result
+
+
+
change nothing
AFC
+
works Four heads, drawn correctly after the §01 fix.
+
+
+
delete afc.cfg
ACE
+
broken The bare name ace
+ matches the Anycubic backend, which needs a top-level
+ slots[]. multiACE publishes
+ aces[].slots[] instead, so it falls through to a REST
+ bridge multiACE doesn't serve, 404s, and gives up. Empty panel.
+
+
+
delete both
SNAPMAKER
+
correct, but the native 4-head backend — and no
+ multiACE.
+
+
+
+
+
+
+
+
The ACE misdetection is a real latent bug for every multiACE user who doesn't
+ have the shim. Next step is to detect multiACE on shape, not name —
+ an ace object carrying aces[] and device_count is
+ multiACE; one carrying slots[] is Anycubic. Then the shim becomes optional
+ and you can delete it.
+
+
+
+
+
+
+
03
The pixel budget
+
+
+
The U1's panel is 480 × 320 — HelixScreen's TINY tier and the
+ smallest resolution it supports. Before any of this work, the
+ 480×320 audit already lists the filament panel
+ as having cards pushed off-screen. Every layout decision downstream is a consequence of
+ these numbers.
+
+
+
+
+ Panel width
+
+ 480 px
+
+
+ − nav rail
+
+ 42 px
+
+
+ − panel padding
+
+ 16 px
+
+
+ Usable width
+
+ 422 px
+
+
+ Per head, 4 across
+
+ 103 px
+
+
+ Usable height
+
+ 279 px
+
+
+
+
+
+
What 103 px buys
+
About six characters of body text, or one 28 px spool ring with a two-line caption
+ under it. A four-deep source stack with readable labels does not fit — which is the
+ single constraint driving the design in §05.
+
+
+
So turn the grid
+
103 px is enough for one head with one source (§04). It is not enough for a head with
+ four. The fix is not to page like the stock UI does — it is to swap the
+ axis: one row per head gives each 406 px instead of 103, and four rows still
+ leave 60 px of vertical slack (§05). Same data, same panel, no truncation.
+
+
+
+
+
+
+
04
Your rig, drawn honestly
+
+
+
Not a hypothetical three-ACE setup — what the printer actually reports:
+ mode: head, one ACE Pro 2, heads 0–2 on stock feeders,
+ head 3 fed from ACE 1 slot 1. Three of the four columns have a source stack of
+ exactly one, and the layout has to look deliberate when that is true, not like
+ a table with empty cells.
+
+
+
+ scale
+
+
+
+
+
+
+
+
+
+
+
‹Multi-Filament
+
+
+
ACE 132° · 33%
+
head mode
+
+
+
+ T0
+
+
+ Empty
+ feeder
+
+
+
insert
+
+
+ T1
+
+
+ Empty
+ feeder
+
+
+
insert
+
+
+ T2
+
+
+ Empty
+ feeder
+
+
+
insert
+
+
+ T3
+
+
+ PETG
+ A1·1
+
+
+
+3 slots
+
+
+
+
+
+
+
480 × 320 · live state · three feeder heads, one ACE-fed head
+
+
+
+
+
Feeder heads get a one-line source row that reads as a statement
+ (“feeder”), not a truncated list. Only the ACE-fed head shows a count chip,
+ because only it has somewhere to go. The asymmetry is the information.
+
+
+
+
+
+
+
05
When a head has more than one source
+
+
+
Add ACE units, or switch to multi mode, and a head gains a
+ stack of candidates — up to four ACE slots plus the stock feeder plus a hand-fed bypass.
+ Four columns of 103 px cannot show that. Turn the grid ninety degrees:
+ one row per head gives each one the full 406 px of usable width, which is enough for
+ the live source, its state, and a count chip — no truncation, no tap required to see
+ what is loaded.
+
+
+
+ scale
+
+
+
+
+
+
+
+
+
+
+
+ ‹
+ Multi-Filament
+ 3 ACE · multi
+
+
+
+
+ T0
+
+ PLA BasicGreen · feeder
+ +2
+ ›
+
+
+
+ T1
+
+ PLA Magentafeeder
+ +1
+ ›
+
+
+
+ T2
+
+ PLA Cyan→ A3·3 Silk Violet
+ 1:47
+ ›
+
+
+
+ T3
+
+ PETGKingroon · ACE 1·1
+ +3
+ ›
+
+
+
+
HOME
+
TIP
+
RETRACT
+
FEED
+
PURGE
+
+
+
+
+
+
+
480 × 320 · row view · a swap in flight on T2
+
+
+
+
+
Two layouts, one panel: columns (§04) keep the spatial mapping to
+ the four physical heads and are right when each head has a single source — your rig
+ today. Rows earn their keep the moment a head has somewhere else to go.
+ Both fit 480 × 320; the backend picks, or it becomes a setting.
+
+
+
+
+
+
+
06
Choosing a source
+
+
+
One head, every spool that can physically reach it, and what each choice costs. A swap
+ is up to three minutes; the sheet says so before you commit. The chosen row
+ emits one command.
A job can use more colours than there are heads, so the number that decides whether you
+ press Start is how many swaps and how long they add. Underneath, two native U1
+ commands: SET_PRINT_EXTRUDER_MAP routes logical tools onto physical heads,
+ then SET_PRINT_USED_EXTRUDERS stops the slicer's baked prestart block
+ auto-feeding heads the job never touches — which also fixes a standing false-runout on a
+ bare U1.
+
+
+
+
+
+
+
+
+ ‹
+ Filament plan
+ lamp-shade_6c
+
+
+
+
+ 6 colours · 4 heads
+ 2 swaps · +5 min
+
+
+
+
T0
+ H0 feeder PLA Green
+ seated
+
T1
+ H1 feeder PLA Magenta
+ seated
+
T2
+ H2 feeder PLA Cyan
+ seated
+
T3
+ H3 ACE 1·1 PETG
+ seated
+
T4
+ H3 ACE 1·2 Copper
+ L84
+ 2:40
+
T5
+ H3 ACE 1·3 Silver
+ L140
+ 2:40
+
+
+
+ Edit mapping
+ Start print
+
+
+
+
+
+
+
480 × 320 · swaps land only on ACE-fed heads — a feeder head
+ cannot change filament mid-print
+
+
+
+
+
+
08
Driving it
+
+
+
Against your real printer, with an isolated socket and config dir so it cannot collide
+ with another instance:
+
+
+
export HELIX_SOCK=/tmp/helix-u1.sock HELIX_CONFIG_DIR=/tmp/helix-config-u1
+mkdir -p "$HELIX_CONFIG_DIR"
+./build/bin/helix-screen --moonraker 192.168.2.242:7125 -vv --remote-socket "$HELIX_SOCK" &
+./build/bin/helix-screen ctl -s "$HELIX_SOCK" navigate ams
+./build/bin/helix-screen ctl -s "$HELIX_SOCK" screenshot /tmp/u1-ams.png
+
+# or with no printer at all — mock modes
+HELIX_MOCK_AMS=u1 ./build/bin/helix-screen --test -vv # exists today
+HELIX_MOCK_AMS=multiace HELIX_MOCK_ACE_COUNT=1 \
+ HELIX_MOCK_ACE_MODE=head HELIX_MOCK_ACE_FEEDER=0,1,2 # your rig (to build)
+
+
+
+
No rebuild for layout
+
Everything visual lives in ui_xml/*.xml, loaded at runtime. Set
+ HELIX_HOT_RELOAD=1, save the file, and the running panel rebuilds within
+ about half a second. Change spacing, wording, colour without a compiler.
+
+
+
Still open
+
+
Do project colours map to heads or to (ACE, slot)? Orca has the
+ same question open; both surfaces should answer it the same way.
+
Four columns at 480×320, or page them like the stock UI does? Decide after
+ seeing it on the real panel.
+
Keep the AFC shim long-term, or retire it once multiACE is detected natively?
+
+
+
+
+
+
+
+
09
The "Currently Loaded" card
+
+
+
The card has three facts available to it and currently renders only one of them.
+ Mounted (which head is on the carriage) comes from the extruder pins.
+ Present (filament is in this head) comes from two independent sensors
+ that agree. Fed (it reached the nozzle) comes from the
+ channel_state latch — the only one of the three that can be wrong, because
+ it is derived from a transition HelixScreen has to witness.
+
Below: what the card should say in each real state, and the signals behind it.
+
+
+
+
+
+
+ nothing mounted
+
+ Currently Loaded
+
No tool mounted
+ Idle
+
LoadUnload
+
+
mounted — · present — · fed —
+
+
+
+ mounted, empty
+
+ Currently Loaded
+
+
+ EmptyT2 mounted
+
+ Idle
+
LoadUnload
+
+
mounted T2 · present no · fed no
+
+
+
+ today's T3
+
+ Currently Loaded
+
+
+ PETGKingroon · T3
+
+ In toolhead · heat to extrude
+
LoadUnload
+
+
mounted T3 · present yes · fed latch lost
+
+
+
+ loaded & hot
+
+ Currently Loaded
+
+
+ PETGKingroon · T3
+
+ Ready · 245 °C
+
LoadUnload
+
+
mounted T3 · present yes · fed yes
+
+
+
+ loading
+
+ Currently Loaded
+
+
+ PLA BasicGreen · T0
+
+ Feeding… 2 of 5
+
LoadUnload
+
+
mounted T0 · present yes · fed in progress
+
+
+
+
the sidebar card at panel scale · buttons shown enabled/disabled as they should gate
+
+
+
+
+
The load-bearing change is the third case. Present — not
+ fed — should decide whether the card names a filament and whether
+ Unload is offered, because presence is what two sensors actually measure. The latch then
+ only refines the state line: "In toolhead · heat to extrude" versus "Ready".
+ That way a latch HelixScreen never witnessed downgrades the wording, not your ability to
+ get the filament out.
+
+
+
+
+
+
+
+
+
diff --git a/docs/devel/plans/2026-08-09-snapmaker-u1-multiace-plan.md b/docs/devel/plans/2026-08-09-snapmaker-u1-multiace-plan.md
new file mode 100644
index 0000000000..395827cfa5
--- /dev/null
+++ b/docs/devel/plans/2026-08-09-snapmaker-u1-multiace-plan.md
@@ -0,0 +1,899 @@
+# Snapmaker U1 + multiACE — support plan
+
+> ## ▶ START HERE (new session, 2026-08-11)
+>
+> **Branch:** `feat/snapmaker-multiace`, **31 commits**. It IS pushed —
+> `origin/feat/snapmaker-multiace` is at `5053dd26b`, with the last 6 local only. The plan's
+> older "nothing pushed" note is obsolete.
+>
+> **State: Phases 1-2 are done and hardware-verified, and the ACE dryer works.** The U1 draws
+> four independent toolheads; multiACE is detected as its own `AmsType`; the ACE appears as
+> unit 1 ("ACE 2 Pro") with live temp/RH; ACE-fed heads load and unload through
+> `ACE_LOAD_HEAD`/`ACE_UNLOAD_HEAD`; drying runs from the stock environment panel.
+>
+> **Read §2's ⚠️ banner and §3 before trusting any "implemented" claim in this doc.** Three
+> were wrong and are corrected in place: §2's AFC topology fix was never built, §3's
+> shape-based discriminator is impossible, and the fixture it cited was never committed (a
+> real one now lives at `tests/fixtures/snapmaker_u1/u1-multiace-head-mode-idle.json`).
+>
+> ### Open, in the order I would take them
+>
+> 1. **Auto-dry toggle — ✅ unblocked and built (2026-08-11).** The parameter names are no
+> longer unknown; see § "Auto-dry" below for the full surface. No hardware probe was
+> needed and none should be run.
+> 2. **§10.2 stuck unload — fixed but never re-observed.** The dispatch is unit-tested against
+> the captured payload; nobody has run an actual unload on T3 since. Confirm before closing.
+> 3. **Stale spool duplicates in Moonraker's DB.** `5122216e5` stops an empty MMU bay claiming
+> a tool's spool, but assignments are cached in Moonraker's database as well as
+> `tool_spools.json` — deleting the local file restores the old values. Existing duplicates
+> (tool 0+1 both spool 3, tool 2+3 both spool 6) outlive the fix and need clearing.
+> 4. **Phase 3's richer half.** The duplicate nozzle is gone and each unit only draws lines to
+> heads it feeds, but the mockup's per-head *source stack* in the seat ("PETG · ACE bay 1")
+> and the rows-vs-columns switch of §6 are not built.
+> Mockup: `2026-08-10-multiace-ui-improvements-mockup.html`.
+> 5. **Phase 1's AFC generalisation** (§2 banner) — never built, still open. Your U1 is fine
+> either way; another AFC-shim-over-toolchanger machine still draws one merger.
+>
+> **Decided against:** replacing the dryer panel's preset dropdown with pills. That panel
+> (`ams_environment_overlay`) is **upstream stock**, added by Preston in `211596fba`
+> (2026-03-25), and is shared with the QIDI Box and CFS dryers; its presets are material-based
+> ("PLA 45 °C/4h"), which carries more than bare temperatures. Per Gordian: keep it as stock as
+> possible. This branch's only change there is a 10-line per-unit humidity fix.
+> Mockup (with pills, NOT built): `2026-08-10-multiace-dryer-mockup.html`.
+>
+> ### Traps this session cost hours to find — read before debugging anything
+>
+> - **Moonraker sends DELTAS: absent ≠ cleared.** Treating a missing `head_source` as "no
+> longer seated" wiped the ACE→head binding a second after it arrived, so bays lost their
+> tool badges and the unit detail fell back to hub-only. Every parse must leave untouched
+> what the frame does not mention. Invisible to any test that feeds one full frame.
+> - **`` loses to a later imperative `lv_obj_clear_flag()`.** Three buttons in
+> the slot menu are shown that way and needed a C++ gate as well as the binding.
+> - **`ACED__DRY_START_n` is NOT the dryer API.** Those are multiACE's parameterless Fluidd
+> macro buttons, and reading only the README's macro table produced a wrong conclusion that
+> temp/duration need a config edit plus a Klipper restart. The real commands take parameters:
+> `ACE_DRY ACE=n [TEMP=] [DURATION=]`, `ACE_STOP_DRYING [ACE=n]`. Check `gcode/help` on the
+> machine, and OrcaSlicer's `resources/web/multiace/index.html`, before believing a doc.
+> - **`ctl click` cannot reach the filament canvas's hit regions** — it sends a widget event
+> with no coordinates. Use `ctl press ` / `ctl release`; nozzles sit at
+> `canvas.y + canvas.h * 0.55`.
+> - **Run `"[ams]"`, never `"[snapmaker]"`.** Half the relevant tests carry neither tag pair.
+> - **A slot's colour is drawn by FILL.** `display_fill_level()` is 0 when not present, and
+> every spool visual sizes its coloured ring by fill — so an assigned-but-empty lane renders
+> as bare grey chrome unless the fill is floored (`SPOOL_ASSIGNED_MIN_FILL_PCT`).
+>
+> **Test state:** `"[ams]"` = 1681/1681. Full suite = 2780 cases with **1 pre-existing
+> failure**: `test_clock_widget.cpp:157` segfaults in the unfiltered run, passes under
+> `"[clock_widget]"` alone, and **reproduces with this branch stashed** — inherited from
+> `main`, do not chase it here. `scripts/quality-checks.sh` also fails pre-existing on
+> "Missing icon codepoints" (needs `./scripts/regen_mdi_fonts.sh`); every gate this branch can
+> affect is clean, with the imperative-UI ratchet held at its 384 baseline.
+>
+> **Formatting — the old note here was wrong twice.** CI pins **clang-format 18.1.8**, not 14
+> (`requirements.txt`), and the CI check is **non-blocking** — `scripts/quality-checks.sh` has
+> `EXIT_CODE=1` commented out under "Don't fail CI for formatting". So drift will not reject a
+> PR; it just gets reflowed by the next machine whose pre-commit hook has the binary.
+>
+> The binary is now available (2026-08-11): `python3 -m venv .venv && .venv/bin/pip install -r
+> requirements.txt`, which needed `sudo apt install python3.14-venv` first. `.venv` is
+> gitignored. **The branch has real drift in 18 of its touched files** — 31 commits were made
+> with no formatter present. Fix it as its OWN commit and add that SHA to
+> `.git-blame-ignore-revs`, the way `54650149f` (the tree-wide 18.1.8 reflow, an ancestor of
+> this branch) is recorded. The baseline itself is clean; `include/filament_database.h` is the
+> one exception and is long string literals clang-format cannot break.
+>
+> **Driving the real printer from a dev box** (no deploy, no SSH — this is the whole loop):
+> ```bash
+> export HELIX_SOCK=/tmp/helix-u1.sock HELIX_CONFIG_DIR=/tmp/helix-cfg-u1
+> mkdir -p "$HELIX_CONFIG_DIR" && cp /tmp/helix-seed/settings.json "$HELIX_CONFIG_DIR/"
+> ./build/bin/helix-screen --moonraker 192.168.2.242:7125 -s tiny -vv \
+> --log-dest file --log-file /tmp/helix-app.log --remote-socket "$HELIX_SOCK" &
+> ./build/bin/helix-screen ctl -s "$HELIX_SOCK" click tour_skip_btn
+> ./build/bin/helix-screen ctl -s "$HELIX_SOCK" navigate filament
+> ./build/bin/helix-screen ctl -s "$HELIX_SOCK" click ams_bars_container # -> ams_panel
+> ./build/bin/helix-screen ctl -s "$HELIX_SOCK" screenshot /tmp/x.png
+> ```
+> `-s tiny` **is** the U1's 480x320. `--log-dest file` is mandatory — the default routes to
+> journal and stdout looks silent. `navigate ams` does not exist. **It is read-write against
+> the live machine**: navigate and screenshot freely, but any Load/Unload really moves
+> filament.
+>
+> **Seed config caveat:** `/tmp/helix-seed/settings.json` is a completed-wizard config and
+> lives in `/tmp` — it will not survive a reboot. Without it every run hits the first-run
+> wizard plus an 8-step tour. If it is gone, run once without it, click through the wizard by
+> hand, then copy the resulting `settings.json` back to `/tmp/helix-seed/`.
+
+
+> **v2, 2026-08-09 — verified against live hardware** (U1 at `192.168.2.242`, Klipper
+> `1.5.2.13_20260722102206`, multiACE `0.99.6.1b`). v1 of this plan was written from source
+> reading alone and guessed the wrong root cause for the merged-toolhead drawing; §2 is the
+> real one, read off the machine. Mockups: `2026-08-09-multiace-mockup.html`.
+> ~~Captured payload: `tests/fixtures/snapmaker_u1/u1-afc-shim-and-multiace-idle.json`.~~
+> **That file does not exist** — never committed, and not on disk anywhere (checked
+> 2026-08-10). The live values quoted in §1 are the only surviving record of the capture.
+> Re-capture from the machine if a fixture is needed:
+> `curl -s '192.168.2.242:7125/printer/objects/query?ace&print_task_config&filament_feed%20left&filament_feed%20right'`.
+
+---
+
+## 0. TL;DR
+
+1. **The merger is a topology-inference fallthrough, and it is fixed.** Your U1 runs a
+ community *AFC compatibility shim* (`extended/klipper/afc.cfg`) that declares its four
+ toolheads as four `AFC_lane`s. HelixScreen detects AFC, tries to infer whether the unit is
+ a hub or four independent heads, finds nothing to count, and falls through to `HUB`. Four
+ independent toolheads get drawn merged into one merger box. Fix in §2.
+2. **Do not delete the shim.** It is the only reason your heads appear at all, and it bridges
+ colour/material edits to the U1's native API. Removing it lands you on a *worse* path (§3).
+3. **Three filament stacks coexist on your machine** — native Snapmaker, the AFC shim, and
+ multiACE — and HelixScreen can only pick one. That is the real architectural problem, and
+ it is what the phase plan is about.
+4. **Your rig is head-mode with one ACE**, not multi-mode with several. That changes what the
+ default view should optimise for (§4).
+
+---
+
+## 1. What is actually running on your printer
+
+Three filament-management stacks are installed simultaneously. All three publish Klipper
+objects; HelixScreen's detection picks exactly one.
+
+| Stack | Objects | What it really knows |
+|---|---|---|
+| **Native Snapmaker** | `filament_detect`, `filament_feed left`/`right`, `print_task_config`, `extruder`…`extruder3` | The truth. Per-head filament type/vendor/colour, `channel_state`, RFID, the 32→4 `extruder_map_table`. |
+| **AFC shim** (`extended/klipper/afc.cfg`) | `AFC`, `AFC_unit U1`, `AFC_lane E0…E3` | A hand-written adapter: 4 lanes → `extruder`/`extruder1`/`extruder2`/`extruder3`, each with its own `toolhead_sensor`, plus `SET_COLOR`/`SET_MATERIAL`/`SET_VENDOR` macros that forward to the native `SET_PRINT_FILAMENT_CONFIG`. No `[AFC_extruder]` sections, no hubs, no steppers. |
+| **multiACE** | `ace`, `ace_bg_swap`, `ace_tipform` | 1 ACE Pro 2 (`protocol: v2`), `mode: head`, per-head source/feeder/manual maps, Spoolman binding. |
+
+**Live state when captured:**
+
+```
+ace.mode = "head" ace.device_count = 1
+ace.head_feeder = {0:true, 1:true, 2:true, 3:false} ← heads 0-2 on stock feeders
+ace.head_ace = {0:0, 1:1, 2:2, 3:0} ace.ace_heads = [3] ← only head 3 is ACE-fed
+ace.head_source = {0:null, 1:null, 2:null, 3:{ace_index:0, slot:0}}
+ace.aces[0] = connected, 32 °C, 33 % RH, gate_status [1,0,0,0]
+
+print_task_config.filament_type = ["NONE","NONE","NONE","PETG"]
+print_task_config.filament_vendor= ["NONE","NONE","NONE","Kingroon"]
+filament_feed = e0/e1/e2 "wait_insert", e3 "load_finish"
+```
+
+So: four independent heads, three on direct stock feeders, one fed from a single ACE Pro 2 —
+and at capture time only head 3 was loaded (PETG Kingroon `#83AFFF`).
+
+---
+
+## 2. Why the four toolheads were drawn as one merger — root cause
+
+`AmsBackendAfc::parse_afc_unit_object()` infers per-unit topology. The documented rule is
+"1 extruder → HUB, N extruders (N == lane count) → PARALLEL". Three inputs feed it, and on
+this machine **all three are blank**:
+
+| Input | Real AFC hardware | Your shim |
+|---|---|---|
+| `AFC_unit U1.extruders[]` | one entry per `[AFC_extruder]` section | `[]` — the shim declares none |
+| `AFC_unit U1.hubs[]` | one entry per `[AFC_hub]` | `[]` |
+| `AFC_lane E*.hub` | `"direct"` or a hub name | **absent** — the key is not published |
+
+With no lane carrying a `hub` field, `lane_hub_routing_` has no entry for any lane, so both
+`has_direct` and `has_hub_routed` stay false. Every arm of the chain in
+`ams_backend_afc.cpp:2903-2921` then fails in turn, and control reaches:
+
+```cpp
+} else {
+ unit_info.topology = PathTopology::HUB; // default
+}
+```
+
+`PathTopology::HUB` makes `render_linear_hub()` draw entry lanes fanning into a hub box and
+out to **one** nozzle. That is the merger you saw. Nothing about it was Snapmaker-specific —
+any AFC shim over a real toolchanger hits it.
+
+**The information was there the whole time.** Each lane publishes its own extruder:
+
+```
+AFC_lane E0.extruder = "extruder" map = "T0"
+AFC_lane E1.extruder = "extruder1" map = "T1"
+AFC_lane E2.extruder = "extruder2" map = "T2"
+AFC_lane E3.extruder = "extruder3" map = "T3"
+```
+
+and the backend already parses it (`slot.extruder_name`, `ams_backend_afc.cpp:2513`) — it
+just never fed topology inference.
+
+### The second half: the canvas never saw the per-unit answer
+
+Deriving the unit's extruders was necessary but **not sufficient**. Verified on hardware:
+the log said `→ Parallel (Tool Changer)` while the panel still drew the merger.
+
+`AmsState` publishes `path_topology_` — the subject the canvas observes — from the
+**system-wide** `backend->get_topology()` (`ams_state.cpp:1385`), not from
+`get_unit_topology()`. And `AmsBackendAfc::get_topology()` was a hardcoded
+`return PathTopology::HUB;`. So the per-unit inference could be perfectly correct and
+never reach the drawing.
+
+That constant also fed `AmsBackend::slot_has_independent_path()`, which decides
+load-vs-swap: every lane looked shared, forcing an unload-before-load that a machine with
+four independent toolheads never needs. So this was a behavioural bug, not only a visual one.
+
+The intended answer — also **not built**, see the banner below — was for `get_topology()` to
+derive from the parsed units: their common value, `MIXED` when they disagree, `HUB` before any
+unit has been seen (unchanged for a Box Turtle). Because `get_unit_topology()` calls it *while
+holding* `mutex_`, the logic would live in a `topology_locked()` helper with `get_topology()`
+as the locking wrapper; calling the public form from inside the lock would self-deadlock.
+
+### ⚠️ The fix below was NEVER IMPLEMENTED (verified 2026-08-10)
+
+Everything in this subsection describes an approach that was designed and then **not built**.
+Verified against the tree, not inferred: `AmsBackendAfc::get_topology()` still returns a
+hardcoded `PathTopology::HUB` (`ams_backend_afc.cpp:555-558`), `lane_extruder_` and
+`topology_locked()` do not exist, `tests/unit/test_afc_shim_unit_topology.cpp` does not
+exist, and `git diff main...HEAD` touches no `*afc*` file at all.
+
+**What actually fixed the merger** was `a1a6c33da` (§3): detection stopped claiming the bare
+`ace` object, so the U1 falls through to the **native Snapmaker backend**, which draws its
+four heads correctly — AFC is never selected on this machine, so its HUB constant no longer
+matters *here*. The render half was `01f77654c`'s `compute_slot_render_states()` change in
+`ui_filament_path_topology.cpp`.
+
+**So this is still open, and Phase 1's stated generalisation is not delivered:** any OTHER
+AFC-shim-over-toolchanger machine — one without the U1's `filament_detect` signature to fall
+through on — still hits the `HUB` fallthrough and still draws four heads as one merger. The
+analysis above is sound and worth building; treat it as a design, not a record.
+
+The unbuilt design was: `parse_afc_unit_object()` falls back to the unit's own lanes when the
+unit reports no extruders — if every lane's extruder is known and they are **distinct**, that
+count becomes the unit's extruder set, and the existing `extruders.size() > 1 → PARALLEL` arm
+answers correctly. Four distinct extruders → four independent toolheads.
+
+Deliberately conservative in three ways, because guessing PARALLEL on a genuine hub unit
+de-merges a merger that physically exists:
+
+- fires **only** when `unit.extruders` is empty — a real AFC install is untouched;
+- requires **every** lane's extruder to be known, so a partial Moonraker delta cannot
+ trigger it (the same hazard the hub-routing parse already guards against, #1229 defect 4);
+- requires the extruders to be **distinct** — four lanes into one extruder stays a hub.
+
+The files it *would* touch (none of these changes exist — this is the build list, not a
+changelog):
+
+| File | Change |
+|---|---|
+| `include/ams_backend_afc.h` | new `lane_extruder_` map (mirrors `lane_hub_routing_`); `topology_locked()` decl |
+| `src/printer/ams_backend_afc.cpp` | populate `lane_extruder_` in the lane parse; derive-from-lanes fallback in `parse_afc_unit_object()`; `get_topology()` derives from units instead of returning a constant |
+| `tests/unit/test_afc_shim_unit_topology.cpp` | 6 cases: U1 shim frame → PARALLEL (per-unit **and** system-wide); partial delta → unchanged; same-extruder lanes → HUB (both); unit-declared extruders → unchanged |
+
+**What WAS verified on the live printer** (2026-08-09, `192.168.2.242`, run locally at
+`-s tiny`): the panel draws four independent toolheads with T3 highlighted holding its PETG,
+instead of four lanes fanning into a hub box and one nozzle. That is the native Snapmaker
+backend doing it after the §3 fall-through — not AFC, and not the design above.
+
+---
+
+## 3. Detection precedence — why deleting the shim makes it worse
+
+Three stacks, one winner. `printer_discovery.h:543` runs `if (has_mmu_) … else if
+(has_snapmaker_) …`, and `has_mmu_` is set by the first MMU-ish object name seen.
+
+| Scenario | HelixScreen picks | Result |
+|---|---|---|
+| Today (AFC + ace + native) | **AFC** | Four heads visible. Merged before the §2 fix; correct after it. |
+| Delete `afc.cfg` | **ACE** — `printer_discovery.h:311` matches the bare name `ace` | **Broken.** `AmsBackendAce` requires a top-level `slots[]` array (`ams_backend_ace.cpp:972`). multiACE publishes `aces[].slots[]` instead — verified live, there is no top-level `slots` key. It falls through to a `/server/ace/*` REST bridge multiACE does not serve, 404s, and gives up. Empty panel. |
+| Delete `afc.cfg` **and** multiACE | **Snapmaker** | The native 4-head backend, which is correct — but you have thrown away multiACE. |
+
+So the shim is currently load-bearing, and the ACE misdetection is a real latent bug for
+every multiACE user who does *not* have the shim installed. Both need fixing in HelixScreen;
+neither is fixed by changing your printer.
+
+**Fix for the ACE collision — ✅ shipped in `a1a6c33da`, but NOT by the mechanism proposed
+here.** Disambiguating on shape is impossible at this point: detection runs off
+`objects.list`, which carries **names only**, so no payload exists to inspect. Do not try to
+rebuild it this way.
+
+What shipped is name-based, in `finalize_ams_detection()` once the whole object list is
+visible (`printer_discovery.h:556-580`). Either signal is sufficient:
+
+```
+ace + (ace_bg_swap | ace_tipform) → multiACE (the extras multiACE always ships)
+ace + filament_detect → multiACE (U1 firmware signature; no Anycubic has it)
+ace, neither marker → AmsType::ACE (Anycubic, unchanged)
+```
+
+On a multiACE match it **leaves the object unclaimed** and falls through to the native
+Snapmaker backend. `AmsType::MULTIACE = 9` exists in `ams_types.h` with its string, but
+**nothing ever assigns it** — deliberately, "until `AmsBackendMultiAce` exists". Regression
+tests both directions: `tests/unit/test_multiace_vs_anycubic_detection.cpp` (121 lines).
+
+So Phase 2 step 1 is half done: the misdetection is fixed and tested; the affirmative
+`MULTIACE` claim is the line to flip when the backend lands.
+
+---
+
+## 4. Phase plan
+
+### Phase 1 — draw the U1 as four toolheads ✅ done for the U1 (§2, §3)
+
+The immediate ask, delivered — but by routing the U1 to its native backend, **not** by the
+§2 AFC fix, which was never built. So the stated generalisation to "any AFC-shim-over-
+toolchanger machine" is **not** delivered: a shim machine without the U1 signature still
+draws one merger. Needs no printer-side change either way.
+
+### Phase 2 — multiACE detection and backend
+
+1. **Detect it** (§3) — ✅ **done 2026-08-10.** `AmsType::MULTIACE` is now claimed outright.
+ **Claiming the type is only half the wiring**, and the missing half is invisible to a
+ type-only assertion: `has_mmu_` skips the `has_snapmaker_` fallback that populated
+ `detected_ams_systems_`, and `AmsState` builds backends by iterating *that list*. With no
+ MULTIACE arm the list came back empty, no backend was built, and the panel logged
+ `navigate_to_ams_panel called with no backend` on a U1 that had worked seconds earlier.
+ Caught on live hardware, now pinned by a test. Four more sites needed the type too:
+ `is_tool_changer()`, `is_filament_system()`, the `AmsBackend::create` factory, and the
+ discovery sequence's subscription block (which is where `ace` gets subscribed at all).
+2. ✅ **done 2026-08-10.** `AmsBackendMultiAce` derives from `AmsBackendSnapmaker` as planned;
+ the §7 risk was real but small — `NUM_TOOLS` and `validate_slot_index()` had to move from
+ `private` to `protected`, nothing more. Three payload facts that no doc states and that
+ cost a live debugging round each:
+ - `head_ace` carries an index for **every** head (`{0:0,1:1,2:2,3:0}` while only head 3 is
+ ACE-fed), so it cannot decide *whether* a head is ACE-fed. `head_feeder`/`head_manual`
+ are the authority.
+ - The per-head maps are keyed by **string** (`"0"`..`"3"`). An int-keyed lookup finds
+ nothing and every head silently reads feeder-fed.
+ - `slots[].color` is an `[r,g,b]` **array**, not a hex string.
+
+ And one that cost the most: `handle_status_update` must unwrap `params[0]` exactly as the
+ base does. Reaching for a `"status"` key instead compiles, passes every unit test that
+ feeds the bare object, logs nothing, and leaves the backend behaving exactly like the plain
+ Snapmaker one. There is now a test using the real `notify_status_update` wrapper.
+
+ *Original plan text follows.* The U1's four heads stay
+ unit 0 with all the hard-won native behaviour intact (the `channel_state` load latch,
+ `is_stuck_motion_sensor_runout`, `prepare_for_resume`, the 5-step load model,
+ `print_task_config` parsing). The subclass adds the `ace` subscription, units 1..N for the
+ ACE hardware, `head_source[h]` → which unit+slot is seated at head *h*, and
+ `ACE_SWAP_HEAD` dispatch for ACE-fed heads with fall-through to the inherited `FEED_AUTO`
+ path for feeder heads. Not a fork — CLAUDE.md's "extend the near-fit helper" applies hard
+ here, because the U1 half is subtle.
+3. **Retire the shim's role.** Once the native backend covers everything, the AFC shim
+ becomes redundant and you can delete it — but only then, and detection must prefer
+ MULTIACE over AFC so the order stops mattering.
+
+**Everything needed is on the WebSocket.** `MultiAce.get_status()` publishes `mode`,
+`device_count`, `active_device`, `head_ace`, `head_feeder`, `head_manual`, `head_source`,
+`swap_phase`, and full per-unit inventory. No dependency on multiACE's optional FastAPI
+service, no second HTTP client, no auth story.
+
+Control surface, all plain gcode: `ACE_SWAP_HEAD HEAD=h ACE=a [SLOT=s]`, `ACE_LOAD_HEAD` /
+`ACE_UNLOAD_HEAD`, `ACE_SWITCH TARGET=n`, `ACE_SET_HEAD_ACE|FEEDER|MANUAL`, `ACE_BG_SWAP`,
+`ACED__Dry_Start_0..3` / `ACED__Dry_Stop`.
+
+### Auto-dry — `ACE_SET_AUTO_DRY`, the full surface
+
+Read off **multiACE's own web UI on the machine**, not guessed and not probed:
+`http://192.168.2.242/multiace/app.js` (`setAutoDry`, `_AUTO_DRY_RANGE`, `autoDryPairInvalid`)
+plus the template in `.../multiace/`. That UI is the FastAPI service nginx mounts at
+`/multiace/`; the local `multiACE/` checkout is **older than the installed firmware** and has
+no auto-dry at all, so do not read it for this.
+
+```
+ACE_SET_AUTO_DRY ACE= [ENABLE=0|1] [TEMP=35..80] [RH_START=5..95]
+ [RH_END=1..94] [MASTER=] [ADD_TIME=0..600]
+```
+
+- **Every field is independent.** The web UI sends one per edit. Arming must therefore send
+ `ENABLE` *alone* — restating a threshold would silently overwrite one set elsewhere, and the
+ setting persists.
+- **`RH_END` must be strictly below `RH_START`.** The firmware rejects the pair otherwise
+ (`autoDryPairInvalid` is `end >= start`), and the UI refuses to send it.
+- **`TEMP` is not auto-dry-only** — it is the unit's one drying temperature, shared with the
+ manual `ACE_DRY`. multiACE's own UI puts it in the manual row for that reason.
+- **v2 vs v1 is the real split.** Only the ACE 2 Pro (`protocol: v2`) has a humidity sensor, so
+ only it evaluates thresholds. A v1 unit FOLLOWS a v2 unit's cycle instead: `MASTER` +
+ `ADD_TIME`, and it cannot be armed until a master is picked. `ace.auto_dry_masters` (live
+ `[0]`) lists the units eligible to be one.
+
+Live shape, confirmed against the machine and present in the committed fixture:
+`ace.aces[i].auto_dry = {enabled, rh_start, rh_end, temp, master, add_time}`, plus the
+**sibling** `ace.aces[i].auto_dry_running` — a separate key, not a field inside the block, so
+it needs its own parse or a frame carrying only it is dropped.
+
+Built 2026-08-11 as `AutoDryInfo` + `get_auto_dry_info()` / `set_auto_dry_enabled()` on
+`AmsBackend`, overridden in `AmsBackendMultiAce`, with a `compact_toggle_row` in the stock
+`ams_environment_overlay` gated on its own visibility subject. Deliberately a **toggle only** —
+thresholds are displayed, not editable, keeping that upstream-stock panel close to stock.
+
+**Closed, not open: how auto-dry and Start/Stop interact.** Per Gordian (2026-08-11), the
+current behaviour is fine and is not to be changed. For the record, so nobody "fixes" it
+again: while the rule is armed and humidity is above `rh_start` it will restart a cycle that
+Stop has just ended; the panel's Temp box is a local value while the rule uses its own
+persisted `auto_dry.temp`; and the countdown is drawn from `duration`/`remain_time`, which do
+not govern a humidity-ended cycle (`auto_dry_running` is what distinguishes one). All known,
+all accepted.
+
+### Binding a spool used to pin a material forever (2026-08-12)
+
+The SnapSwap panel showed materials the printer disagreed with — a head reporting PLA read
+PETG, an empty head read PLA — and it survived every restart. Four links, each of which had to
+be broken:
+
+1. Binding a Spoolman spool routes through `apply_spool_to_slot()`, which writes the **spool's**
+ material into `SlotInfo`.
+2. `set_slot_info(persist=true)` then stamped `user_locked_material = !material.empty()`, so
+ **linking a spool manufactured a material lock** the user never asked for.
+3. That override persists to **Moonraker's `lane_data` namespace on the printer**, not just to
+ the local config dir.
+4. `apply_overrides()` applied material unconditionally, never consulting the lock, so it
+ replayed over `print_task_config` on every parse.
+
+**A clean local config dir does NOT clear this** — link 3 is why. A "clean config" run that
+still shows the wrong material is not evidence the override store is innocent; check
+`curl ':7125/server/database/item?namespace=lane_data'` before concluding anything.
+That mistake cost several rounds here.
+
+Fixed by making `apply_overrides()` lock-aware (firmware's material wins unless the user
+genuinely locked one — ACE bays are unaffected because firmware states nothing for them), and
+by comparing against `last_firmware_material_` when stamping the lock, so a bind that agrees
+with firmware locks nothing. Mirrors AD5X's `last_firmware_color_` guard (#965).
+
+**How it was found:** four temporary `spdlog::info` probes — one at each writer of
+`slot->material` (ptc / RFID / override) and one where `AmsState` publishes the subject, each
+logging *old → new*. One run named the overwriter outright. Worth repeating for any
+"where does this value come from" question; inference had been wrong twice by then.
+
+### A bay's spool identity is in the TABLE, not in `slots[]` (2026-08-11)
+
+`aces[].slots[]` carries `material`, `brand`, `color` — and on real hardware they are all
+**empty**, because those fields are filled from RFID only. Everything a user types into
+multiACE's web UI lands in a spool table instead:
+
+```
+ace.spool_binding = {"0_0": "15", "0_1": "10", "0_3": "16"} # "_" -> spool id
+ace.spools = { "15": {material, vendor, color, label, weight_g, sku, spoolman_id}, ... }
+```
+
+Reading only `slots[]` meant the ACE panel showed none of it, falling back to HelixScreen's
+own override store — so the panel disagreed with the machine entirely. Four traps, each
+pinned by a test in `[spools]`:
+
+- **`spoolman_id` is a STRING here**, an int everywhere else in this codebase.
+- **`color` is bare hex with no `#`**, while `slots[].color` is an `[r,g,b]` array.
+- **Unbinding DELETES the key** rather than nulling it, so `spool_binding` must be replaced
+ wholesale — merging strands a removed spool forever.
+- **The table and the bindings are INDEPENDENT deltas.** Moving a spool between bays sends
+ `spool_binding` with no `spools`, so they must be cached separately; resolving them in one
+ pass threw every description away on the next binding change.
+
+multiACE's table deliberately **outranks** the local override store for a bay it has a binding
+for — the same doctrine `slot_identity_owner_unit()` states — or a stale local edit keeps
+masking what the user typed. And `SlotInfo` objects are reused across rebuilds (only a change
+in unit COUNT reallocates them), so `spool_name`/`brand`/`spoolman_id`/`remaining_weight_g`
+must be cleared each pass or a bay keeps the name of the spool taken out of it.
+
+**There are THREE identity layers, and only two are on the WebSocket.** In precedence order,
+lowest first:
+
+| Layer | Where | Covers |
+|---|---|---|
+| `aces[].slots[]` | `ace` object | RFID only — **empty** for every hand-entered spool |
+| `spools` + `spool_binding` | `ace` object | bays with a spool bound; carries `spoolman_id` |
+| `slot_overrides.json` | **a FILE** | material/brand/subtype/colour, incl. bays with NO spool bound |
+
+multiACE's own web UI resolves from the top one — every bay it reports comes back
+`source: "override"`. The file is **not published in the `ace` object at all**, which is why a
+bay carrying a material and colour but no spool binding read as empty here.
+
+It is reachable through Moonraker's file API at
+`config/extended/multiace/slot_overrides.json`, so this needs nothing beyond multiACE — the
+optional FastAPI service stays unnecessary, as § 4 intended. Nothing in the `ace` object can
+say the file changed, so the fetch is triggered off `event_seq` (multiACE's
+bump-on-any-state-change counter) with the first frame fetching it at all.
+
+**Colour is encoded three different ways across these layers** — `"#RRGGBB"` in the override
+file, bare `"RRGGBB"` in the spool table, `[r,g,b]` in `slots[]`. All three are parsed.
+
+**Weights come from SPOOLMAN, not multiACE.** Its `weight_g` is a local copy, so taking
+remaining from it while total came from Spoolman computed a percentage across two sources.
+Only `spoolman_id` is taken from the table; `tracks_weight_locally()` stays false so
+SpoolmanManager fills remaining AND total as one pair. Both are cleared each rebuild —
+clearing only remaining left a stale total behind a wrong percentage.
+
+**Still open — the SnapSwap side.** `helix-screen/tool_spool_assignments` in Moonraker's DB
+holds assignments for all four heads (0=Red/23, 1=SIlver/15, 2=Black/3, 3=Gray/10) while
+`print_task_config.filament_exist` reads `[true,false,false,true]`, so T1/T2 display filament
+that is not there — and spool 15 is claimed by both tool 1 and ACE bay 0. This is § 10's open
+item 3. Two separable questions: clearing the stale rows (a write to the user's DB), and
+whether an assignment should display at all once the head reports empty — note
+`slot_has_retained_identity()` is a deliberate feature, so today's behaviour may be intended.
+
+### Spool numbering — the label is not the index (2026-08-11)
+
+The ACE's bays were badged **5-8** for a rig with seven spools. Global slot indices are dense
+over every *addressable* slot — the U1's four heads take 0-3, so the ACE starts at 4 — while
+the badge should count *spools*, and the ACE-fed head and the bay behind it are one spool
+counted twice.
+
+`owned_spool_slots()` already existed and already answered this (`[0,1,2,4,5,6,7]`); nothing
+labelled from it. Added `AmsBackend::spool_display_number()` plus
+`slot_identity_owner_slot()` — the companion to the existing `slot_identity_owner_unit()`,
+resolving a viewing slot to the slot that actually holds the spool. The U1's heads now read
+1,2,3 and the ACE's bays 4,5,6,7; T3 shares **4** with the bay feeding it rather than
+consuming a number.
+
+**Do not "fix" this by changing `first_slot_global_index`.** The index is the addressing key —
+subjects, `get_slot_info()`, load/unload dispatch and the active/target comparison all use it,
+and two slots cannot share one. Presentation and addressing are separate on purpose. Backends
+whose slots all own their spools are unaffected: `owned_spool_slots()` is then every slot in
+order and the number is `slot_index + 1` exactly.
+
+### Numeric keyboard — the default one existed and was dead (2026-08-11)
+
+`keyboard_hint="numeric"` routed to the `?123` symbol page. The intended numpad
+(`kb_map_num_improved`) was registered against LVGL's `LV_KEYBOARD_MODE_NUMBER` and **never
+displayed** — nothing calls `lv_keyboard_set_mode()` with that mode, because KeyboardManager
+drives the button matrix directly. Two things had therefore never been exercised:
+
+- **`LV_KEYBOARD_CTRL_BUTTON_FLAGS` does not include `CUSTOM_1`**, which is this codebase's
+ non-printing marker. Every action key on that map would have inserted its raw icon bytes.
+- **Four keys had no handler at all** (`+/-`, `ICON_CHECK`, both chevrons).
+
+Now lives in `keyboard_layout_provider.cpp` as `KEYBOARD_LAYOUT_NUMERIC` with both fixed.
+
+**Trap worth keeping:** `LV_BUTTONMATRIX_WIDTH_MASK` is `0x000F` — a key width above **15**
+overflows into the flag bits and silently drops keys from the rendered row rather than failing.
+A first attempt used widths of 16 and 20 and lost a whole row. Pinned by a test.
+
+### Phase 3 — head-major layout
+
+With Phase 1, four heads draw as four columns. With multiACE, each head also has a **stack of
+candidate sources** behind it (up to 4 ACE slots + stock feeder + manual). Group every slot in
+the system by `mapped_tool` and render the stack under its head. Pure regroup of data the
+model already holds — no new topology enum — and it also fixes the existing
+`HELIX_MOCK_AMS=mixed` scenario (12 slots → 6 toolheads, drawn today as three disconnected
+unit cards).
+
+Your rig makes the *asymmetric* case the default: three feeder heads with exactly one source
+each, one ACE-fed head with four. The layout has to look right when most columns have a stack
+of one — see the mockup.
+
+### Phase 4 — logical tools and >4 colours
+
+`extruder_map_table[32]` maps logical `T0..T31` → physical `0..3`. Full API in
+`SNAPMAKER_U1_PRINT_TASK_CONFIG.md`. HelixScreen should show the plan and the swap bill before
+the print, then send `SET_PRINT_EXTRUDER_MAP` per remap and `SET_PRINT_USED_EXTRUDERS
+EXTRUDERS=` before start — the latter also fixes the standing empty-head false-runout on
+a bare U1. Leave `ACE_SWAP_HEAD` emission to multiACE's post-processor; display and validate,
+don't rewrite gcode.
+
+**Open question:** map project colours to **heads** or directly to **(ACE, slot)**? Orca has
+the same question open; both surfaces should answer it the same way.
+
+### Phase 5 — the rest
+
+Per-ACE dryer (`get_dryer_info(unit)` already takes a unit index), humidity/temp per unit,
+mode switching with a confirmation step, saved loadouts, `swap_phase`/`last_swap_result`
+routed through `classify_error()` so a failed swap becomes an actionable card, and Spoolman
+binding (your `ace.spool_binding` is already populated).
+
+---
+
+## 5. Testing locally
+
+`HELIX_MOCK_AMS=u1` already exists (undocumented — `ams_backend.cpp:177`). Phase 2 adds
+`HELIX_MOCK_AMS=multiace` parameterised to reproduce your rig and the ones you don't have:
+
+```bash
+HELIX_MOCK_AMS=multiace HELIX_MOCK_ACE_COUNT=1 HELIX_MOCK_ACE_MODE=head \
+ HELIX_MOCK_ACE_FEEDER=0,1,2 ./build/bin/helix-screen --test -vv # your machine
+HELIX_MOCK_ACE_COUNT=3 HELIX_MOCK_ACE_MODE=multi # the 3-ACE case
+HELIX_MOCK_ACE_SWAP=3 # mid-swap, step bar live
+```
+
+Against the real printer, with an isolated socket and config dir so it cannot collide with
+another instance:
+
+```bash
+export HELIX_SOCK=/tmp/helix-u1.sock HELIX_CONFIG_DIR=/tmp/helix-config-u1
+mkdir -p "$HELIX_CONFIG_DIR"
+./build/bin/helix-screen --moonraker 192.168.2.242:7125 -vv --remote-socket "$HELIX_SOCK" &
+./build/bin/helix-screen ctl -s "$HELIX_SOCK" navigate ams
+./build/bin/helix-screen ctl -s "$HELIX_SOCK" screenshot /tmp/u1-ams.png
+```
+
+XML is loaded at runtime, so layout iteration needs no rebuild: set `HELIX_HOT_RELOAD=1`, edit
+`ui_xml/*.xml`, and the running panel rebuilds within ~500 ms.
+
+---
+
+## 6. Display budget — 480 × 320
+
+The U1's panel is 480×320 (3.5", TLSC6x touch, DRM/KMS), HelixScreen's **TINY** tier and the
+smallest resolution it supports. `docs/devel/480x320_UI_AUDIT.md` already lists the filament
+panel as having cards pushed off-screen at this size, before any of this work.
+
+Real budget, measured from `ams_panel.xml` and the tokens:
+
+| Region | Cost |
+|---|---|
+| Nav rail | 42–56 px of the 480 |
+| Panel header | 34 px of the 320 |
+| Usable content | **~424 × 278 px** |
+| Per head, 4 across | **~103 px wide** |
+
+103 px is about six characters of body text — enough for one head with one source, not enough
+for a head with four.
+
+**The resolution: two layouts, both fitting 480×320, chosen by the backend.**
+
+| Layout | Per head | Right when |
+|---|---|---|
+| **Columns** (4 across) | 103 px | Each head has a single source — the stock U1, and your rig today. Keeps the spatial mapping to the four physical heads. |
+| **Rows** (4 stacked) | 406 px | A head has somewhere else to go. Fits the live source, its state and a count chip with no truncation; four rows still leave ~60 px of vertical slack. |
+
+Swapping the axis is a better answer than paging (which is how the U1's own stock UI dodges
+the problem) because it keeps all four heads on screen at once — the thing the merger bug was
+hiding in the first place. Both forms are mocked up at 1:1 in the companion HTML, which
+measures each screen in the browser on load and labels it `fits` or `overflows` rather than
+asking you to take the numbers on trust.
+
+---
+
+## 7. Open risks
+
+- **The shim's `map` field is `T0..T3`.** The AFC docs warn that `map` is a *virtual* tool
+ number, not a physical one, and must not be used to count nozzles. Here it happens to agree
+ with the physical head. Do not build on that agreement — use topology, as the fix does.
+- **`mode: head` vs `multi` changes the slot→head mapping.** In multi mode slot *s* of every
+ ACE feeds head *s*; in head mode an ACE binds to one head and all four of its slots feed
+ *that* head — a hub, not a parallel fan. So per-unit topology genuinely differs by mode and
+ `get_unit_topology()` has to answer dynamically. Your machine is in head mode, so this is
+ the shape to build first.
+- **Three stacks, one winner** stays true until Phase 2 lands. Any change to what is installed
+ on the printer silently changes which backend HelixScreen picks.
+- **`AmsBackendMultiAce : AmsBackendSnapmaker`** means the U1 backend gains a subclass its
+ `protected` surface was not designed for. Expect a small refactor and re-run the U1
+ regression tests hard.
+
+---
+
+## 10. Handoff — open items (2026-08-10)
+
+Everything below is on `feat/snapmaker-multiace`, nothing pushed.
+
+### 10.1 Failing unit tests — ✅ fixed (2026-08-10)
+
+**It was 6 failures, not 3.** `[snapmaker]` showed 3; the other three live in
+`test_ams_realtime_filament_state.cpp` under tags that do not include `[snapmaker]`, so only
+`"[ams]"` sees the whole family. Now `[ams]` = **1656 cases, 0 failed** (was 1653 / 6).
+
+| Test | Line |
+|---|---|
+| `can_unload_from_toolhead offers unload for every loaded toolhead` | `test_ams_backend_snapmaker.cpp:497` |
+| `motion-sensor runout path is independent of the loaded latch` | `test_ams_backend_snapmaker.cpp:1067` |
+| `overrides slot LIVE accessors from sensor + LOADED status` | `test_ams_realtime_filament_state.cpp:113` |
+| `AmsState publishes per-slot LIVE subjects on sync` | `test_ams_realtime_filament_state.cpp:186` |
+| `Active-loaded subject is the single highlight source on unload` | `test_ams_realtime_filament_state.cpp:314` |
+| `AMS clears filament_loaded after unload completes` | `test_ams_realtime_filament_state.cpp:483` |
+
+The diagnosis held: all six encode "mounted + spool present ⇒ LOADED", a weaker and more
+defensible claim than the mounted+empty conflation `70ce3345b` actually removed.
+
+**The candidate fix recorded here was wrong** — it would have fixed one of the six. Four of
+them assert `get_slot_info(i).status`, a **stored** field that `filament_present_at_tool_locked()`
+does not feed; adding a term to that predicate leaves the status untouched. The real fix is
+three parts:
+
+1. **The third presence term** (as proposed) in `filament_present_at_tool_locked()`. Needed
+ because `port_sensor_filament_present_` starts false and stays false until a
+ `filament_feed` frame names the tool, so a machine publishing only `print_task_config`
+ answers "nothing loaded" on the two sensor terms alone.
+2. **Recompute the mounted slot's `status` per frame**, next to the `filament_loaded`
+ recompute. It was written *only* by the election, which fires on `active != current_tool`,
+ so a load completing while the tool stayed mounted — the normal case — left the slot
+ reading `AVAILABLE` indefinitely. Same staleness `5174f0f91` fixed for `filament_loaded`,
+ left behind on the status.
+3. **Fold the motion-sensor runout clear into that recompute.** It was its own block *above*
+ the recompute, which then put `filament_loaded` straight back on the strength of the loaded
+ latch. Latent since `5174f0f91` and invisible because the test that covers it aborted on an
+ earlier `REQUIRE`. The runout gates `filament_loaded` only, never
+ `filament_present_at_tool_locked()` — after a runout the canvas must break the line to the
+ nozzle while Unload stays offered.
+
+Three regression tests added, each verified by mutation (revert the fix → the test goes red):
+mounted-but-EMPTY is not loaded (the `70ce3345b` case, which shipped with no test at all),
+a load completing without a tool change promotes the slot, and runout clears `filament_loaded`
+while keeping Unload.
+
+### 10.2 ACE-fed head: unload never terminates in the UI — ✅ addressed 2026-08-10
+
+The hypothesis below was right and is now implemented: `AmsBackendMultiAce::do_unload_filament`
+sends `ACE_UNLOAD_HEAD HEAD=n` for a head the ACE feeds, and leaves feeder heads on the
+inherited native path. **Not yet re-observed on hardware** — the fix is unit-tested against the
+live payload, but nobody has run an unload on T3 since. Confirm before closing.
+
+The second defect (the 5-step LOAD model rendered during an unload) is untouched and still
+open; it lives in the step-model selection, not the dispatch.
+
+*Original analysis:*
+
+Live repro on T3 (the only ACE-fed head): Unload from the multi-filament panel **succeeds on
+the printer** — `channel_state` reaches `preload_finish`, the toolhead motion sensor drops to
+false, `print_stats` stays `standby` — but the panel stays on "Unloading" forever.
+
+Two defects, probably one cause:
+
+1. The step list rendered during the unload is the **5-step LOAD model**
+ (Home/Select/Heat nozzle/Feed filament/Purge), not the 4-step unload model.
+2. The operation never terminates, even though `preload_finish` is marked
+ `is_terminal` in the channel-state table.
+
+Hypothesis: HelixScreen dispatches the U1's native unload and waits for `unload_finish`, but
+an ACE-fed head terminates at `preload_finish` because the ACE performs the retract. This is
+the first concrete case of the Phase 2 rule — **an ACE-fed head must be driven with
+`ACE_UNLOAD_HEAD HEAD=n`, not the native path** — and it will not be fixed properly until the
+backend knows which heads the ACE feeds (`ace.head_ace` / `ace.head_feeder`).
+
+### 10.3 Toolhead context menu — ✅ built and hardware-verified (2026-08-10)
+
+Built as designed: a second canvas callback (`ui_filament_path_canvas_set_toolhead_callback`)
+plus `AmsToolheadMenu`, modelled on `AmsSelectorMenu`. Unregistered, both canvas regions still
+go to `slot_callback`, so no other panel changes behaviour.
+
+- **Select** — `select_slot()`, which already emits `T{n}` on this backend. No new gcode.
+- **Park** — new `AmsBackend::park_toolhead()` + `supports_toolhead_park()`, Snapmaker sends
+ `PARK_EXTRUDER`. **Confirmed on the live machine**, since it is absent from `gcode/help`:
+ `configfile.settings['gcode_macro print_end']` calls it bare and parameterless, right after
+ `SM_PRINT_END_AUTO_UNLOAD_FILAMENT`. It is a carriage op — a docked head keeps its filament,
+ so Park must never unload.
+- **Load / Unload** — mutually exclusive on `can_unload_from_toolhead()`.
+
+The rule is a pure function (`toolhead_menu_model()`), tested without LVGL in
+`test_ams_toolhead_menu_model.cpp`. Entry visibility is published as subjects and bound with
+``; hiding from C++ would have pushed the imperative-UI ratchet above its 384
+baseline. A head with no applicable action shows no menu rather than an empty card.
+
+**Verified against the U1** (T3 mounted, T0–T2 parked): tapping T3 offered **Park + Load**,
+tapping T0 offered **Select T0** alone. Load rather than Unload on T3 is correct and worth
+keeping in mind — `filament_exist[3]` is true but `channel_state` is `preload_finish`, so the
+PETG is staged in the channel and not at the nozzle. `retraction_seen_` catches exactly that,
+and the sidebar agrees ("Currently Loaded: ---", Unload greyed).
+
+The canvas hit-test only reaches this on PARALLEL topology, and `ctl click` cannot reach it at
+all — it sends a widget event with no coordinates. Drive it with the synthetic pointer
+(`ctl press ` / `ctl release`); nozzles sit at `canvas.y + canvas.h * 0.55`.
+
+### 10.4 Misleading wording in existing tests
+
+Several Snapmaker tests and comments say filament is retracted "to the buffer". The U1 has no
+buffer — that term is borrowed from AFC's TurtleNeck. The U1's own vocabulary is **preload**
+(`preloading` / `preload_finish`). Worth a comment-only pass so the next reader is not sent
+looking for hardware that does not exist.
+
+### 10.5 From the code review (2026-08-14) — A/B done, C/D/E open
+
+A four-angle review (reuse / simplification / efficiency / altitude) of the whole branch. The
+mechanical findings are fixed in `e0f22f471`; the items below were held back because they change
+architecture rather than tidy it. **The two latent bugs (A, B) are fixed in `23a0cc95c`
+(2026-08-15). C, D and E remain open** — they alter behaviour rather than close a hole, so they
+want doing deliberately rather than folded into a cleanup.
+
+**A. `park_toolhead()` was outside the NVI gate — ✅ fixed 2026-08-15 (`23a0cc95c`).**
+`Park` joined `FilamentOp`; `park_toolhead()` is `final` on `AmsSubscriptionBackend` and routes
+through `run_filament_op()`; backends implement only the protected `do_park_toolhead()` hook,
+whose default refuses. Snapmaker's hand-written gate is deleted. Mutating the gate away
+reproduces the original bug and `[park]` catches it, asserting no gcode leaks on refusal.
+Original diagnosis retained below.
+
+**A (original).** `park_toolhead()` is outside the NVI gate — latent, ordered first.
+`load_filament` / `unload_filament` / `select_slot` / `change_tool` are `final` on
+`AmsSubscriptionBackend` precisely so a backend *cannot* forget the print-active gate; that
+class's own comment records that opt-in gating already shipped one backend with no gate at all
+(`329e731e9` added it to seven and missed the eighth). `park_toolhead()` (`ams_backend.h`) is a
+plain virtual whose only enforcement is a `@warning` telling each implementer to hand-write
+`check_preconditions(true)`, and it skips the `FilamentOpClaim` test-and-set, so a park can
+dispatch while a load is in flight. Latent only because `supports_toolhead_park()` is true on
+exactly one backend today — the second one to implement it docks the head mid-print.
+*Fix:* add `Park` to `FilamentOp`, make `park_toolhead()` `final` on `AmsSubscriptionBackend`
+dispatching to a protected `do_park_toolhead()`. Snapmaker then drops its hand-written gate.
+
+**B. The toolhead menu keyed slot-indexed APIs with a VIRTUAL tool number — ✅ fixed 2026-08-15
+(`23a0cc95c`).** Resolved once via `mapped_tool` in both `show_at()` and the shared dispatch,
+falling back to the raw index for a backend publishing no mapping. Worse than first described:
+`can_unload_from_toolhead(int slot_index)` is slot-indexed *despite its name*, and the dispatch
+was passing the tool number to three further slot-indexed calls — so every backend call in the
+menu took a slot while receiving a tool. `[tool_index]` pins it with a deliberately remapped
+machine alongside the U1's identity case. Original diagnosis retained below.
+
+**B (original).** The toolhead menu keys slot-indexed APIs with a VIRTUAL tool number — latent.
+`ui_system_path_canvas.h` documents the callback argument as "the VIRTUAL tool number shown on
+the badge, not the physical column". `AmsToolheadMenu::show_at()` passes it unconverted to
+`get_slot_info()`, `can_unload_from_toolhead()`, `select_slot()`, `load_filament()` and
+`unload_filament()` — all slot-indexed. It works only because tool == slot on the U1;
+`ams_backend.h` records that toolchanger tool numbers diverge from slots under `ASSIGN_TOOL`
+remapping, and the overview registers this callback for every backend. (The missing bounds
+check is already restored in `e0f22f471`; the index is still the wrong *kind* of index.)
+*Fix:* resolve tool → slot once via `tool_layout.virtual_to_physical` + `mapped_tool` at the
+top of `show_at()`.
+
+**C. OPEN — the toolhead menu bypasses `plan_load()`.**
+The sidebar, filament panel, runout handler and print-status widget all funnel through
+`plan_load()` / `BackendCaps`. This menu calls `load_filament()` / `unload_filament()` /
+`select_slot()` directly, so it gets none of `requires_slot_selection_for_load`,
+`needs_unload_before_load`, the already-mounted refusal, the preheat flow, the step bar — or
+`change_tool_completes_load`, the capability this very branch added to that planner. Related:
+its print-blocks gate is a fifth copy of the same preamble and omits the `is_busy()` term the
+per-slot menu carries.
+
+**D. OPEN — `change_tool_completes_load()` should be derived, not declared.**
+The planner arm it guards substitutes `change_tool(mapped_tool)` for "load slot N", which is
+valid exactly when the tool number *identifies* the slot. On an ACE in head mode four bays share
+one `mapped_tool`, so it is ambiguous — and that is visible in the `AmsSystemInfo` `plan_load()`
+already holds. Deriving it ("take this arm only when `target_slot` is the only slot with that
+`mapped_tool`") deletes the virtual, the `BackendCaps` field and its three call sites, keeps
+AFC/HH/CFS behaviour (they map lanes 1:1), and protects the next many-to-one backend for free.
+
+**E. OPEN — also flagged, smaller.** `detect_step_operation()`'s guard fixes the UNLOAD direction only —
+the same mid-operation transient resets the bar during a LOAD. `slot_identity_owner_unit()` and
+`slot_identity_owner_slot()` are one concept split in two with an unenforced invariant. Bars and
+badge text in `ui_ams_slot.cpp` are read once at widget construction, so they go stale on any
+inventory change that does not alter the slot count. `override_refetch_wanted_` is armed on every
+`event_seq` bump — confirm multiACE does not bump it for telemetry, or that is a continuous HTTP
+loop.
+
+---
+
+---
+
+## 11. Session log — 2026-08-10/11
+
+25 commits. Grouped by what they were for, since the order they landed in is not the order
+they make sense in.
+
+### Phase 2 — the backend
+
+| Commit | What |
+|---|---|
+| `2c568b983` | `AmsBackendMultiAce`, deriving from `AmsBackendSnapmaker`. Unit 0 stays the U1; units 1..N are the ACE. ACE-fed heads dispatch `ACE_LOAD_HEAD`/`ACE_UNLOAD_HEAD`. |
+| `29c8e5a46` | Stop rebuilding the ACE units wholesale each frame — it discarded user edits and reset the view. |
+| `910400da2` | **A partial `ace` frame no longer wipes head sources.** The delta bug; see the traps list. |
+| `5053dd26b` | The dryer: `ACE_DRY` / `ACE_STOP_DRYING` + `get_dryer_info()`. |
+
+Three payload facts no document states, all found by reading the live machine:
+
+- `head_ace` carries an index for **every** head (`{0:0,1:1,2:2,3:0}` while only head 3 is
+ ACE-fed), so it cannot decide *whether* a head is ACE-fed. `head_feeder`/`head_manual` can.
+- The per-head maps are keyed by **string** (`"0"`..`"3"`). An int key silently finds nothing.
+- `slots[].color` is an `[r,g,b]` **array**, not a hex string.
+
+And one that cost the most: `handle_status_update` must unwrap `params[0]` exactly as the base
+does. Reaching for a `"status"` key compiles, passes every unit test that feeds a bare object,
+logs nothing, and leaves the backend behaving like the plain Snapmaker one.
+
+### Detection
+
+`AmsType::MULTIACE` needed **five** sites, not one. `has_mmu_` skips the `has_snapmaker_`
+fallback that populates `detected_ams_systems_` — the list `AmsState` iterates to build
+backends — so claiming the type alone produced NO backend and an empty panel. The others:
+`is_tool_changer()`, `is_filament_system()`, the `AmsBackend::create` factory, and the
+subscription block (which is where `ace` gets subscribed at all).
+
+### UI
+
+| Commit | What |
+|---|---|
+| `6f8fcbac0` | Recompute the mounted slot's load state every frame (§10.1's real fix). |
+| `024ac964f`, `1c5a1180d` | Per-toolhead context menu, on both the detail canvas and the overview's nozzle row. |
+| `b83cf6bbd` | An ACE-fed head's spool identity belongs to the ACE: the slot menu drops Edit/Spoolman/Clear and offers "Open in ACE". |
+| `6366ee716`, `e74823a48` | One nozzle per head, and a unit draws lines only to heads it feeds. |
+| `7c0cb0b0e`, `4925782d1` | Count spools rather than slots (7, not 8); the home widget's bar cap is per row. |
+| `64dc47738`, `d6e0aa7d7` | No hub box on a shared toolhead; dim heads that are not active; outline an externally-fed slot. |
+| `5fc8c6024` | A unit feeding one known head draws through to that toolhead. |
+| `65ee32bf9`, `a02bd6fab`, `5122216e5` | Spool assignment on ACE bays; assigned-but-empty lanes show their colour; an empty bay cannot claim a tool's spool. |
+| `be61ed894` | The environment overlay reads the unit it is showing (10 lines; upstream's bug). |
+
+### What was verified how
+
+Everything above was checked against the live U1 at `192.168.2.242`, not just unit tests —
+mostly by driving the running app with `ctl` and reading screenshots. Two findings came from
+**sampling rendered pixels** rather than eyeballing: the assigned-but-empty lane really drew
+zero coloured pixels (not a dim tint), and the fix produced 70. When a screenshot and a theory
+disagree, decode the PNG.
+
diff --git a/docs/devel/plans/2026-08-10-multiace-dryer-mockup.html b/docs/devel/plans/2026-08-10-multiace-dryer-mockup.html
new file mode 100644
index 0000000000..f806b1cb68
--- /dev/null
+++ b/docs/devel/plans/2026-08-10-multiace-dryer-mockup.html
@@ -0,0 +1,406 @@
+HelixScreen — ACE dryer in the environment panel
+
+
+
+
+
+
HelixScreen already has the whole dryer UI — readout, progress bar,
+ temperature and duration inputs, start/stop — in ams_environment_overlay.xml.
+ It never appeared on this printer because no Snapmaker backend reported a dryer, so
+ get_dryer_info() answered supported = false and the panel showed
+ its "no dryer" branch. The ACE has one, reports it live, and takes its settings as command
+ parameters.
+
Screens are 1:1 at 480 × 320 and self-measure on load.
+ Live values from the ACE 2 Pro at 192.168.2.242.
+
+
+
+
✕
Correction to the first version
+
+
+ The first version of this memo was wrong
+
It claimed temperature and duration cannot be set without editing
+ multiACE's config and restarting Klipper, and mocked them read-only. That came from
+ reading only multiACE's README macro table, where ACED__DRY_START_0..3 are
+ listed as "uses config settings".
+
Those macros are the parameterless Fluidd buttons. The actual commands take parameters,
+ and OrcaSlicer's own multiACE page uses them. Nothing needs a restart.
+
+
+
+
Command
What it does
+
ACE_DRY ACE=n [TEMP=] [DURATION=]
Start drying, temperature in °C and duration in minutes
+
ACE_STOP_DRYING [ACE=n]
Stop
+
ACE_SET_AUTO_DRY
Humidity-controlled drying, "live + persist"
+
+
+
All three confirmed in the live printer's gcode/help. The consequence for this
+ design is simple: the panel's temperature and duration controls are real, and
+ auto-dry can be a toggle rather than a readout.
+
+
+
+
A
Idle, and drying
+
Presets as pills for the common cases — the same three OrcaSlicer offers — with an
+ editable value beside them for anything else. The field is the truth and the
+ pills are shortcuts into it: tapping a pill fills the field, typing a value the pills do not
+ offer just deselects them. Both write into ACE_DRY's TEMP= and
+ DURATION=, and duration is entered in minutes, which is the unit
+ the command takes — no conversion between what you type and what is sent.
+
Live state comes from dryer_status, so the panel reflects a cycle started
+ anywhere, including from Fluidd or OrcaSlicer.
+
+
+
+ idle — your rig now
+
+
+
+
‹ACE 2 Pro — environment
+
+
+
+
Chamber
31 °C
+
Humidity
35 %
+ Not drying
+
+
+
+ Dry at
+
+ temp
+ 45°55°65°
+ 55°C
+
+
+ for
+ 2 h4 h6 h
+ 240min
+
+
Start drying
+
+
+
+ Auto-dry
+
+
+
Start at 45 % RH, stop at 35 %.
+
+
+
+
+
Two taps and Start. 70 °C is multiACE's own safety cap, so the
+ pill set stops below it rather than letting a number through that the firmware would
+ clamp anyway.
+
+
+
+ drying
+
+
+
+
‹ACE 2 Pro — environment
+
+
+
+
Chamber
48 °C
+
Humidity
22 %
+ Drying
+
+
+
+
+ Drying to 55 °C
+ 3 h 12 m left
+
+
+
Stop drying
+
+
+
+ Auto-dry
+
+
+
Started automatically at 45 % RH. Stops at 35 %.
+
+
+
+
+
The pills are replaced by what is actually running, so the card never
+ shows a setting that is not the one in effect. Progress and remaining come from
+ remain_time / duration, which
+ DryerInfo::get_progress_pct() already computes.
+
+
+
+ typing an exact value
+
+
+
+
‹ACE 2 Pro — dry at
+
+
+
+ Temperature
+ 58°C
+
+
35–70 °C. Above 70 is refused by multiACE.
+
+
+ 123
+ 456
+ 789
+ ⌫0✓
+
+
+
+
+
Tapping the value field opens the numeric keypad the text_input widget
+ already raises. Typing a value the pills do not offer simply deselects them — the
+ field is the truth, the pills are shortcuts into it.
+
+
+
+
+
+
B
Several ACEs
+
Up to four. The panel is per unit, so chips select which one and everything below follows.
+ ACE_STOP_DRYING takes an explicit ACE=, so selecting a unit does not
+ have to make it the active one — the stop always names its target.
+
+
+
+ 3 ACEs, the middle one drying
+
+
+
+
‹ACE drying
+
+
+ ACE 1ACE 2ACE 3
+
+
+
+
Chamber
48 °C
+
Humidity
22 %
+ Drying
+
+
+
+
+ Drying to 55 °C
+ 3 h 12 m left
+
+
+
Stop ACE 2
+
+
+
+
+
The stop button names its unit. With four ACEs on one printer, a bare
+ "Stop" that acts on whichever is current is the kind of button that dries the wrong
+ spools.
+
+
+
+
+
+
?
What is still open
+
+
+
Auto-dry's parameter names
+
ACE_SET_AUTO_DRY exists and persists, so the toggle above is buildable — but
+ neither multiACE's bundled docs nor OrcaSlicer's page call it, so its arguments are unknown.
+ The live object shows the shape (enabled, rh_start,
+ rh_end, temp, master, add_time). Running
+ it bare in a console would print the usage; probing it blind from here would change machine
+ state, which is why it is not wired yet.
+
+
Preset values
+
45/55/65 °C and 2/4/6 h follow OrcaSlicer, and are three numbers in one place
+ if your filaments want different ones. Nothing is lost either way now that any value can be
+ typed.
+
+
+
Already built:
+ get_dryer_info() maps aces[].dryer_status onto the existing
+ DryerInfo; start_drying() emits ACE_DRY and
+ stop_drying() emits ACE_STOP_DRYING. Confirmed working on the
+ machine. What remains is this panel's presentation.
The multiACE backend landed and the data is right: four U1 heads as unit 0,
+ the ACE 2 Pro as unit 1, head_source[3] pointing at bay 1. What is
+ left is entirely presentation — the aggregate canvas still draws units side by side, which is
+ how T3 ends up on screen twice. These four changes are the ask, in the order given.
+
Every screen below is 1:1 at 480 × 320 — the U1's real panel.
+ Measured in your browser on load and labelled fits or
+ overflows; nothing here is taken on trust.
+ Companion to 2026-08-09-multiace-mockup.html.
+
+
+
+
+
+
1
T3 appears once
+
In head mode one ACE binds to one head, so all four of its bays map to T3. The aggregate
+ canvas lays units out left to right and gives each its own nozzle row — so T3 is drawn twice
+ and the panel claims five toolheads on a four-head machine. The fix is to group every slot in
+ the system by mapped_tool and render the ACE bays as a stack behind the
+ head they feed, which is the head-major layout the plan already calls Phase 3.
+
+
+
+ now
+
+
+
+
‹Multi-Filament Overview
+
+
+
+
SnapSwap
4 slots
+
ACE 2 Pro
31°C 35%
+
+
+
T0
+
T1
+
T2
+
T3
+
T3
+
+
+
+ Currently loaded
+
———
+
Unload
+
Reset
+
+
+
+
+
Five nozzles, T3 twice. The second one is the ACE unit's own row —
+ correct data, drawn as if it were a fifth physical head.
+
+
+
+ proposed
+
+
+
+
‹Multi-Filament Overview
+
+
+
+
+
T0
+
feeder
+
+
+
+
T1
+
feeder
+
+
+
+
T2
+
feeder
+
+
+
+
T3
+
PETGACE bay 1
+
+
+
+
+
+ Currently loaded
+
PETG T3 · ACE bay 1
+
Unload
+
Reset
+
+
+
+
+
Four heads, four nozzles. The ACE is not a peer of the SnapSwap —
+ it is the source behind T3, and says so in the seat.
+
+
+
+
+
+
+
+
2
Toolheads carry the context menu here too
+
The per-toolhead menu — Select / Park / Load / Unload — is
+ already built and hardware-verified, but it is wired only to the detail panel's canvas. The
+ overview is where you actually land when there is more than one unit, so the same nozzle tap
+ has to open the same menu. Entries stay computed from the backend, so a parked empty head
+ offers only Select, and during a print the menu does not open at all.
+
+
+
+ proposed — tap T1's nozzle
+
+
+
+
‹Multi-Filament Overview
+
+
+
+
T0
+
feeder
+
+
T1
+
feeder
+
+
T2
+
feeder
+
+
T3
+
PETGACE bay 1
+
+
+
+
+ Currently loaded
+
PETG T3 · ACE bay 1
+
Unload
+
Reset
+
+
+
+ Toolhead T1
+ ↔Select T1
+
+
+
+
T1 is parked and its channel is empty, so the only honest offer is
+ Select. No Park (not mounted), no Load/Unload (nothing to move).
+
+
+
+ proposed — tap T3's nozzle
+
+
+
+
‹Multi-Filament Overview
+
+
+
+
T0
+
feeder
+
+
T1
+
feeder
+
+
T2
+
feeder
+
+
T3
+
PETGACE bay 1
+
+
+
+
+ Currently loaded
+
PETG T3 · ACE bay 1
+
Unload
+
Reset
+
+
+
+ Toolhead T3
+ ⌂Park
+ ↑Unload
+
+
+
+
T3 is the mounted head holding filament: Park and Unload. Unload
+ dispatches ACE_UNLOAD_HEAD here, not the native path.
+
+
+
+
+
+
+
+
3
The ACE-fed spool is not edited from SnapSwap
+
T3's slot in the SnapSwap unit shows filament that the ACE owns. Editing material, colour or
+ spool there would write to print_task_config and be silently overwritten the next
+ time the ACE reports its inventory — two sources of truth for one spool. So the seat stops
+ opening the edit sheet and explains where the real control lives, with a one-tap route to it.
+ Load and Unload stay available — those act on the head, which is still the U1's
+ job; only the spool's identity belongs to the ACE.
+
+
+
+ proposed — tap T3's spool
+
+
+
+
‹Multi-Filament: SnapSwap
+
+
+
+
T0
+
empty
+
T1
+
empty
+
T2
+
empty
+
T3
+
PETG🔒 ACE
+
+
+
+ Currently loaded
+
PETG T3
+
Unload
+
+
+
+
+
T3 is fed by the ACE 2 Pro
+
This spool's material, colour and Spoolman link live on the ACE, in bay 1.
+ Editing them here would be overwritten the next time the ACE reports in.
+
Loading and unloading T3 still works from this panel.
+
CloseOpen ACE 2 Pro
+
+
+
+
A padlock on the seat marks it at a glance; the sheet says why and
+ offers the one useful action rather than just refusing.
+
+
+
+ the quieter alternative
+
+
+
+
‹Multi-Filament: SnapSwap
+
+
+
+
T0
+
empty
+
T1
+
empty
+
T2
+
empty
+
T3
+
PETG🔒 ACE
+
+
+
+ Currently loaded
+
PETG T3
+
Unload
+
+
+
+ T3 · fed by ACE
+ ↗Open in ACE
+ ↓Load
+ ↑Unload
+
+
+
+
No modal at all: the existing slot menu simply drops Edit and Spoolman
+ for an ACE-fed slot and gains "Open in ACE". Fewer taps, less to dismiss.
+
+
+
+
Recommendation: ship the quieter one. The padlock already carries the
+ message, and a full-screen sheet to say "not here" is a lot of ceremony for a slot you tapped
+ by accident. Keep the sheet's wording for the menu's header row.
+
+
+
+
+
+
4
The ACE view is a combiner
+
In head mode all four ACE bays feed one head — that is a hub, not a parallel fan, and the
+ renderer already knows how to draw it: render_linear_hub() is the combiner view
+ the AFC unit used, lanes converging through a box to a single nozzle. The backend already
+ reports PathTopology::HUB for an ACE unit in head mode, so this is a matter of
+ routing the unit detail to that renderer rather than the parallel one.
+
+
+
+ proposed — ACE unit detail, head mode
+
+
+
+
‹ACE 2 Pro — feeding T3
+
+
+
+
+
1 · PETG
+
2
+
3
+
4
+
+
+
COMBINER
+
+
+
T3
+
+
+
+
+ Unit
+
31°C · 35% RH ready
+
Dry
+
Bind head…
+
+
+
+
+
Four bays, one combiner, one nozzle — the shape the hardware actually
+ has in head mode. The live lane is the only one drawn in filament colour.
+
+
+
+ the same unit in multi mode
+
+
+
+
‹ACE 2 Pro — one bay per head
+
+
+
+
bay 1
+
PETG
+
T0
+
bay 2
+
empty
+
T1
+
bay 3
+
empty
+
T2
+
bay 4
+
empty
+
T3
+
+
+
+ Unit
+
31°C · 35% RH ready
+
Dry
+
Mode…
+
+
+
+
+
Not your rig, but the same code path has to answer for it: in multi
+ mode bay s feeds head s, so the unit is a parallel fan and must not
+ draw a combiner.
+
+
+
+
+
Topology is per unit, and already correct
+
The backend sets HUB for head mode and PARALLEL for multi
+ mode on each ACE unit. The unit detail has to read get_unit_topology(), not the
+ system-wide answer — reading the system-wide one is the exact mistake that drew the U1's
+ four heads as a merger in the first place.
+
+
Nothing here needs new backend data
+
All four changes are renderer and menu wiring. mapped_tool,
+ head_source, per-unit topology and the ACE inventory are already parsed and
+ unit-tested against the captured payload.
+
+
+
+
+
+
diff --git a/docs/devel/printers/SNAPMAKER_U1_SUPPORT.md b/docs/devel/printers/SNAPMAKER_U1_SUPPORT.md
index d3e00fb0b6..5d9179918d 100644
--- a/docs/devel/printers/SNAPMAKER_U1_SUPPORT.md
+++ b/docs/devel/printers/SNAPMAKER_U1_SUPPORT.md
@@ -106,6 +106,36 @@ Manual packaging is also available:
make package-snapmaker-u1
```
+### Building the U1 on a fork (`.github/workflows/snapmaker-u1.yml`)
+
+`release.yml` can build this platform, but only on a `v*` tag, and its later jobs
+need secrets a fork does not have (`R2_*`, Android signing, `WEBSITE_DISPATCH_TOKEN`).
+`snapmaker-u1.yml` is the same toolchain + build + package steps with nothing
+secret in them, so a fork can produce an installable build on its own:
+
+| Trigger | Result |
+|---|---|
+| push to `main`, `feat/**`, `fix/**` | builds, uploads `helixscreen-snapmaker-u1` artifact (30 days) |
+| **Run workflow** button (`workflow_dispatch`) | same |
+| pull request to `main` | same |
+| push a **`u1-v*`** tag | the above, plus a GitHub Release on that repo with the `.zip`, `.tar.gz`, `install-fork.sh` and `SHA256SUMS` |
+
+The U1 is the one platform a fork can build unaided: `docker/Dockerfile.snapmaker-u1`
+is Debian Trixie plus `crossbuild-essential-arm64` from apt, with no tarball fetch
+and no private registry (`ad5x`, by contrast, pulls its toolchain from upstream's
+own release assets).
+
+Tag prefix is `u1-v`, **not** `v` — a `v*` tag would also start `release.yml`,
+which then fails on the missing secrets:
+
+```bash
+git tag u1-v0.99.114 && git push origin u1-v0.99.114
+```
+
+This workflow also extracts the toolchain image's CA bundle into
+`build/snapmaker-u1/certs/`, which `release-snapmaker-u1` then packages — a step
+`release.yml` does not perform, so upstream CI tarballs ship without `certs/`.
+
## Installation
### Prerequisites
@@ -130,6 +160,59 @@ curl -sSL https://releases.helixscreen.org/install.sh | sh
The installer auto-detects the U1 platform, downloads the correct aarch64 binary from the release CDN, deploys platform hooks, and starts HelixScreen. Re-run to upgrade.
+### Installing a fork's build
+
+`scripts/install-fork.sh` is a thin front-end for the same installer — it sets
+the two things a fork install needs and hands over, so the install path itself
+(platform detection, service setup, backup/rollback, SHA256 verification,
+`--update` / `--uninstall` / `--local`) is not duplicated:
+
+```sh
+GITHUB_REPO=/helixscreen # which repo to install from
+HELIX_GITHUB_ONLY=1 # that repo's GitHub releases ONLY
+```
+
+The second is the load-bearing one. The installer normally tries the upstream
+CDN (`releases.helixscreen.org`) and HTTP mirror *before* GitHub, and those
+serve upstream's artifacts — so setting `GITHUB_REPO` alone would resolve
+upstream's version number and install upstream's binary under it.
+
+```sh
+# From the fork's latest u1-v* release (install-fork.sh is attached to each)
+curl -fsSL https://github.com//helixscreen/releases/latest/download/install-fork.sh | sh
+
+# From a branch that carries fork support, before any release exists
+HELIX_FORK_REF= sh -c "$(curl -fsSL https://raw.githubusercontent.com//helixscreen//scripts/install-fork.sh)"
+
+# Offline: scp the archive over, unpack, and run the copy shipped inside it
+sh install-fork.sh --local helixscreen-snapmaker-u1.zip
+```
+
+Flags pass straight through to the installer. `GITHUB_REPO` overrides the
+default fork; `HELIX_FORK_REF` picks the branch/tag the installer is fetched
+from when no release provides one.
+
+Where the installer itself comes from, in order: `install.sh` beside the
+script (an unpacked archive), then the fork's **latest release**
+(`releases/latest/download/install.sh` — the installer that built the binary it
+will install), then `HELIX_FORK_REF`. Whichever it finds is **refused unless it
+knows about fork installs** (`HELIX_GITHUB_ONLY`): an older `install.sh`
+hard-assigns `GITHUB_REPO` and would take the environment this script set,
+ignore it, and quietly install upstream's binary from upstream's CDN. That is
+why the release is preferred over a branch, and why `HELIX_FORK_REF=main` fails
+loudly until the fork's `main` carries the new installer.
+
+Two consequences for the fork's releases: they are published as full releases,
+not prereleases (`/releases/latest` — the API the installer discovers versions
+through, and the download URL above — resolves to the newest *non*-prerelease,
+so a prerelease-only repo has no "latest"), and a `u1-v*` tag passes the
+installer's version normalisation untouched (only bare `0.99.x` gets a `v`
+prefixed), so `--version u1-v0.99.114` and the release URLs agree.
+
+Both the Moonraker `[update_manager helixscreen]` block and `release_info.json`
+are written with whichever repo the install came from, so the printer offers
+*that* repo's releases as updates rather than upstream's.
+
### Build
```bash
diff --git a/docs/images/u1/ams-overview.png b/docs/images/u1/ams-overview.png
new file mode 100644
index 0000000000..a86dda2e65
Binary files /dev/null and b/docs/images/u1/ams-overview.png differ
diff --git a/docs/images/u1/filament-environment.png b/docs/images/u1/filament-environment.png
new file mode 100644
index 0000000000..ead27d73c7
Binary files /dev/null and b/docs/images/u1/filament-environment.png differ
diff --git a/docs/images/u1/pairing-prompt.png b/docs/images/u1/pairing-prompt.png
new file mode 100644
index 0000000000..fbd900e87a
Binary files /dev/null and b/docs/images/u1/pairing-prompt.png differ
diff --git a/firmware/helixscreen-esp32/components/helixapp/app_srcs.txt b/firmware/helixscreen-esp32/components/helixapp/app_srcs.txt
index 4a75ab05e2..7725c7a9fd 100644
--- a/firmware/helixscreen-esp32/components/helixapp/app_srcs.txt
+++ b/firmware/helixscreen-esp32/components/helixapp/app_srcs.txt
@@ -125,6 +125,7 @@ src/printer/ams_backend_ad5x_ifs.cpp
src/printer/ams_backend_afc.cpp
src/printer/ams_backend_cfs.cpp
src/printer/ams_backend_happy_hare.cpp
+src/printer/ams_backend_multiace.cpp
src/printer/ams_bypass_policy.cpp
src/printer/ams_backend_qidi.cpp
src/printer/ams_backend_snapmaker.cpp
@@ -330,6 +331,7 @@ src/ui/ui_ams_selector_menu.cpp
src/ui/ui_afc_fault_path.cpp
src/ui/ui_ams_sidebar.cpp
src/ui/ui_ams_slot.cpp
+src/ui/ui_ams_toolhead_menu.cpp
src/ui/ui_buffer_meter.cpp
src/ui/ui_busy_overlay.cpp
src/ui/ui_button.cpp
diff --git a/include/ams_backend.h b/include/ams_backend.h
index 325711d6e6..d8e9a18745 100644
--- a/include/ams_backend.h
+++ b/include/ams_backend.h
@@ -776,6 +776,37 @@ class AmsBackend {
return false;
}
+ /**
+ * @brief Can a filament op dispatched by this backend emit a G28 that
+ * HelixScreen itself sends?
+ *
+ * The inverse question to filament_ops_self_home(), and a different one:
+ * that flag is about a home buried inside FIRMWARE where we cannot see it,
+ * this one is about the home WE send, in
+ * AmsSubscriptionBackend::ensure_homed_then().
+ *
+ * Exists so a UI surface can decide whether to ask "home printer first?"
+ * before it starts a preheat. The prompt is worth moving that early only
+ * when the op it precedes can actually home; asking on a backend that never
+ * emits G28 requests consent for something that will not happen, and a
+ * decline cancels the load outright (Snapmaker U1: do_load_filament()
+ * dispatches `AUTO_FEEDING ... LOAD=1` straight to firmware, which feeds
+ * without moving the toolhead at all).
+ *
+ * False here because a plain AmsBackend has no ensure_homed_then() to route
+ * through — the machinery lives on AmsSubscriptionBackend, which answers
+ * true, and the two subclasses that bypass it answer false again. Every
+ * answer is therefore derivable from what that class's dispatch actually
+ * does, rather than being a fact to remember.
+ *
+ * This does NOT gate the G28 itself. ensure_homed_then() still decides that
+ * from toolhead.homed_axes, and still asks its own confirmation — a backend
+ * that answers false simply never reaches it.
+ */
+ [[nodiscard]] virtual bool filament_ops_may_home() const {
+ return false;
+ }
+
/**
* @brief Does the printer-side system arrange its own homing for filament
* load/unload ops, so HelixScreen should neither prompt nor send G28?
@@ -795,6 +826,31 @@ class AmsBackend {
return false;
}
+ /**
+ * @brief Does changing to slot @p slot_index's mapped tool COMPLETE its load?
+ *
+ * plan_load()'s swap arm turns "load slot N" into `change_tool(N's mapped
+ * tool)` and stops there, on the strength of that being the whole operation:
+ * ACE's change_tool() is literally `return load_filament(...)`, AFC's is
+ * `CHANGE_TOOL LANE={n}`, QIDI's load prepends its own unload. Nothing
+ * chains a second command, so a backend where the tool change is only HALF
+ * the job silently performs half.
+ *
+ * multiACE is that backend. Its ACE bays report mapped_tool = the head they
+ * feed, so tapping Load on a bay planned `T3` -- which mounts head 3, moves
+ * the carriage, and feeds nothing. The bay still has to be named:
+ * `ACE_LOAD_HEAD HEAD=h ACE=a SLOT=s`.
+ *
+ * Per-slot rather than per-backend because one backend can be both: on
+ * multiACE the U1's own heads keep their filament at the head, so a tool
+ * change really is the whole load there, while a bay four slots later is
+ * not. Default true -- the behaviour every backend had before this existed.
+ */
+ [[nodiscard]] virtual bool change_tool_completes_load(int slot_index) const {
+ (void)slot_index;
+ return true;
+ }
+
/**
* @brief Record that the user has already agreed to a pre-operation home for
* the NEXT dispatch, so ensure_homed_then() does not ask a second
@@ -1285,6 +1341,236 @@ class AmsBackend {
return loaded_hint;
}
+ /**
+ * @brief Which unit owns this slot's filament IDENTITY, when not this one.
+ *
+ * On multiACE a U1 head is fed from an ACE bay, so the head's material,
+ * colour and Spoolman link are the ACE's to state. Editing them on the head
+ * would write `print_task_config` and be overwritten the moment the ACE
+ * reports its inventory again — two sources of truth for one spool. The
+ * slot menu uses this to drop its edit actions and offer a route to the
+ * owning unit instead.
+ *
+ * Identity only. Loading and unloading still act on the head and stay
+ * available; this says who describes the filament, not who moves it.
+ *
+ * @param slot_index Global slot index.
+ * @return Owning unit index, or nullopt when the slot describes itself
+ * (every backend except multiACE, always).
+ */
+ [[nodiscard]] virtual std::optional slot_identity_owner_unit(int slot_index) const {
+ (void)slot_index;
+ return std::nullopt;
+ }
+
+ /**
+ * @brief Global indices of the slots that hold a spool of their OWN, in order.
+ *
+ * A slot fed from another unit is not a spool position — it is a view of one.
+ * On multiACE, the U1's ACE-fed head and the ACE bay behind it are a single
+ * physical spool, so counting both double-counts it: a 4-head U1 with one
+ * 4-bay ACE has 7 spool positions, not 8.
+ *
+ * This is what user-facing COUNTS and per-spool rows should iterate.
+ * `total_slots` remains the indexing bound and is unchanged — every slot
+ * here is still addressable, still loadable, still unloadable.
+ *
+ * Derived from slot_identity_owner_unit(), so a backend that answers that
+ * question needs nothing further.
+ */
+ [[nodiscard]] std::vector owned_spool_slots() const {
+ return owned_spool_slots(get_system_info());
+ }
+
+ /// Overload for callers that already hold the system info.
+ ///
+ /// get_system_info() returns BY VALUE under the backend mutex — every unit,
+ /// every SlotInfo, every std::string in them. These helpers are called per
+ /// slot and per frame, so re-fetching it inside each one was the dominant
+ /// cost of drawing a badge. Callers with `info` in hand should pass it.
+ [[nodiscard]] std::vector owned_spool_slots(const AmsSystemInfo& info) const {
+ std::vector out;
+ out.reserve(static_cast(info.total_slots));
+ for (int i = 0; i < info.total_slots; ++i) {
+ if (!slot_identity_owner_unit(i).has_value()) {
+ out.push_back(i);
+ }
+ }
+ return out;
+ }
+
+ /**
+ * @brief Which slot holds the spool that @p slot_index is showing.
+ *
+ * The companion to slot_identity_owner_unit(): that says WHICH UNIT
+ * describes the filament, this says which slot of it. An ACE-fed head and
+ * the ACE bay behind it are one spool, so the head resolves to the bay.
+ *
+ * @param slot_index Global slot index.
+ * @return Global index of the owning slot, or nullopt when the slot holds
+ * its own spool (every backend except multiACE, always).
+ */
+ [[nodiscard]] virtual std::optional slot_identity_owner_slot(int slot_index) const {
+ (void)slot_index;
+ return std::nullopt;
+ }
+
+ /**
+ * @brief The 1-based number a slot should be LABELLED with.
+ *
+ * Not the same as the global index, and deliberately so. Global indices are
+ * the addressing key and must stay dense over every addressable slot; the
+ * label counts SPOOLS. On a U1 with one ACE those disagree: the U1's four
+ * heads take global 0-3, so the ACE's bays start at global 4 and used to be
+ * labelled 5-8 — eight numbers for seven spools, because the ACE-fed head
+ * and the bay feeding it are the same spool counted twice.
+ *
+ * A slot that only views another slot's spool resolves to it, so the head
+ * and its bay share one number instead of consuming two.
+ *
+ * Backends whose slots all own their spools are unaffected: owned_spool_slots()
+ * is then every slot in order, and this returns slot_index + 1 exactly.
+ */
+ [[nodiscard]] int spool_display_number(int slot_index) const {
+ return spool_number_in(owned_spool_slots(), slot_index);
+ }
+
+ /// Overload for callers that already hold the system info — see
+ /// owned_spool_slots(const AmsSystemInfo&).
+ [[nodiscard]] int spool_display_number(int slot_index, const AmsSystemInfo& info) const {
+ return spool_number_in(owned_spool_slots(info), slot_index);
+ }
+
+ /// The badge number for @p slot_index given a PRE-BUILT owned-slot list.
+ ///
+ /// Split out so spool_display_label() can build that list once instead of
+ /// once per candidate slot — it used to call spool_display_number() inside
+ /// its loop, and each call deep-copied the whole AmsSystemInfo and took the
+ /// backend mutex once per slot.
+ [[nodiscard]] int spool_number_in(const std::vector& owned, int slot_index) const {
+ const int target = slot_identity_owner_slot(slot_index).value_or(slot_index);
+ for (size_t i = 0; i < owned.size(); ++i) {
+ if (owned[i] == target) {
+ return static_cast(i) + 1;
+ }
+ }
+ // Not a spool position and nothing owns it — fall back to the raw index
+ // rather than showing nothing.
+ return slot_index + 1;
+ }
+
+ /**
+ * @brief What a slot's badge should READ — a number, or a range.
+ *
+ * A slot holding its own spool is simply its number ("3").
+ *
+ * A slot that only views another unit's spools is not one spool but a
+ * position any of them can reach, so it reads as the range ("4-7"). On a U1
+ * in head mode all four ACE bays feed the one ACE-fed head; naming only the
+ * seated bay would be a number that changes under the user every time the
+ * ACE swaps, and would hide the other three entirely. In multi mode exactly
+ * one bay feeds each head, so the range collapses back to a single number
+ * with no special-casing.
+ *
+ * Unchanged for backends whose slots all own their spools.
+ */
+ [[nodiscard]] std::string spool_display_label(int slot_index) const {
+ // ONE system fetch and ONE owned-slot build for the whole function. This
+ // used to fetch per call and again per loop iteration — roughly five deep
+ // copies of AmsSystemInfo and fifty mutex acquisitions to render one badge.
+ const AmsSystemInfo info = get_system_info();
+ const std::vector owned = owned_spool_slots(info);
+
+ const auto owner_unit = slot_identity_owner_unit(slot_index);
+ if (!owner_unit.has_value()) {
+ return std::to_string(spool_number_in(owned, slot_index));
+ }
+ if (*owner_unit < 0 || *owner_unit >= static_cast(info.units.size())) {
+ return std::to_string(spool_number_in(owned, slot_index));
+ }
+ // Which of the owner's slots can feed this position: the ones mapped to
+ // the same tool.
+ const int tool = get_slot_info(slot_index).mapped_tool;
+ const auto& unit = info.units[static_cast(*owner_unit)];
+ int lo = -1;
+ int hi = -1;
+ for (int s = 0; s < static_cast(unit.slots.size()); ++s) {
+ if (unit.slots[static_cast(s)].mapped_tool != tool) {
+ continue;
+ }
+ const int n = spool_number_in(owned, unit.first_slot_global_index + s);
+ if (lo < 0 || n < lo) {
+ lo = n;
+ }
+ if (hi < 0 || n > hi) {
+ hi = n;
+ }
+ }
+ if (lo < 0) {
+ // Owned by a unit that maps none of its slots here — say what we can.
+ return std::to_string(spool_number_in(owned, slot_index));
+ }
+ if (lo == hi) {
+ return std::to_string(lo);
+ }
+ return std::to_string(lo) + "-" + std::to_string(hi);
+ }
+
+ /**
+ * @brief How many slots on @p unit_index hold a spool of their own.
+ * @see owned_spool_slots()
+ */
+ [[nodiscard]] int unit_spool_slot_count(int unit_index) const {
+ return unit_spool_slot_count(unit_index, get_system_info());
+ }
+
+ /// Overload for callers that already hold the system info — the overview
+ /// builds one card per unit and had been re-fetching for each.
+ [[nodiscard]] int unit_spool_slot_count(int unit_index, const AmsSystemInfo& info) const {
+ if (unit_index < 0 || unit_index >= static_cast(info.units.size())) {
+ return 0;
+ }
+ const auto& unit = info.units[unit_index];
+ int n = 0;
+ for (int s = 0; s < unit.slot_count; ++s) {
+ if (!slot_identity_owner_unit(unit.first_slot_global_index + s).has_value()) {
+ ++n;
+ }
+ }
+ return n;
+ }
+
+ /**
+ * @brief Whether the backend can dock the mounted toolhead without unloading.
+ * @return true if park_toolhead() is implemented (toolchangers only).
+ */
+ [[nodiscard]] virtual bool supports_toolhead_park() const {
+ return false;
+ }
+
+ /**
+ * @brief Return the mounted toolhead to its dock, leaving filament alone.
+ *
+ * Parking is a carriage operation, not a filament one: on a toolchanger a
+ * head is routinely docked with filament still threaded to its nozzle, and
+ * the next pick-up finds it exactly as it was. So this must NOT unload —
+ * the two are separate actions with separate menu entries.
+ *
+ * Only meaningful while a head is actually mounted; with an empty carriage
+ * there is nothing to dock. Callers gate on MountState/mounted_tool.
+ *
+ * Gated like every other toolhead-motion op: AmsSubscriptionBackend makes
+ * this `final` and routes it through run_filament_op(), so an implementer
+ * writes only the protected do_park_toolhead() hook and cannot forget the
+ * print refusal or the single-op-in-flight claim. It used to be a plain
+ * virtual carrying a warning to hand-write check_preconditions(true).
+ *
+ * @return AmsError indicating success or failure.
+ */
+ virtual AmsError park_toolhead() {
+ return AmsErrorHelper::not_supported("Toolhead park");
+ }
+
/**
* @brief Whether the backend can position the selector at a gate without loading.
* @return true if select_gate() is implemented (selector-based systems only).
@@ -1677,6 +1963,41 @@ class AmsBackend {
return get_default_drying_presets();
}
+ /**
+ * @brief Get humidity-controlled ("auto") drying state for a unit
+ *
+ * A dryer is not necessarily an auto-dryer: this is a separate capability
+ * from get_dryer_info() and a backend may support one without the other.
+ * Check AutoDryInfo::supported before showing any auto-dry UI.
+ *
+ * @param unit AMS unit index (0-based)
+ * @return AutoDryInfo struct (supported=false if the unit has no auto-dry)
+ */
+ [[nodiscard]] virtual AutoDryInfo get_auto_dry_info(int unit = 0) const {
+ (void)unit;
+ return AutoDryInfo{};
+ }
+
+ /**
+ * @brief Arm or disarm humidity-controlled drying
+ *
+ * Arming does not start a cycle — it hands the dryer to the humidity rule,
+ * which starts one when the reading crosses AutoDryInfo::rh_start_pct.
+ * Backends are expected to persist the setting across reboots.
+ *
+ * Refuse rather than send when AutoDryInfo::can_enable() is false; a
+ * follower with no master is a state the firmware rejects anyway.
+ *
+ * @param enabled true to arm the rule, false to disarm it
+ * @param unit AMS unit index (0-based)
+ * @return AmsError with SUCCESS result on success, or error with reason
+ */
+ virtual AmsError set_auto_dry_enabled(bool enabled, int unit = 0) {
+ (void)enabled;
+ (void)unit;
+ return AmsErrorHelper::not_supported("Auto-dry");
+ }
+
// ========================================================================
// Endless Spool Control
// ========================================================================
@@ -2330,6 +2651,25 @@ class AmsBackend {
///@}
public:
+ /**
+ * @brief Whether firmware states what filament is in each slot
+ *
+ * A DIFFERENT question from has_firmware_spool_persistence(), and conflating
+ * the two is a real bug: the Snapmaker U1 publishes filament_type and
+ * filament_vendor per head in `print_task_config` while keeping no Spoolman
+ * id at all, so it answers false there and true here.
+ *
+ * When true, ToolState's persisted assignments must not be pushed back onto
+ * slots — the firmware already knows, and a cached assignment that disagrees
+ * is stale by definition. Without this, a head the printer reported as PLA
+ * displayed PETG, resolved from a spool that was physically in an ACE bay.
+ *
+ * @return true if slot material/vendor come from firmware
+ */
+ [[nodiscard]] virtual bool has_firmware_filament_identity() const {
+ return false;
+ }
+
/**
* @brief Whether this backend unloads the toolhead automatically after a print
*
diff --git a/include/ams_backend_ace.h b/include/ams_backend_ace.h
index 064fdaad94..b872bec5ea 100644
--- a/include/ams_backend_ace.h
+++ b/include/ams_backend_ace.h
@@ -116,6 +116,12 @@ class AmsBackendAce : public AmsSubscriptionBackend {
}
public:
+ /// See AmsBackend::filament_ops_may_home(). The ACE dispatches its own
+ /// `ACE_*` macros directly rather than through ensure_homed_then(), so
+ /// HelixScreen never emits a G28 for one of its filament ops.
+ [[nodiscard]] bool filament_ops_may_home() const override {
+ return false;
+ }
// ========================================================================
// Recovery Operations
// ========================================================================
diff --git a/include/ams_backend_mock.h b/include/ams_backend_mock.h
index 16e8a04995..f9da84f1f9 100644
--- a/include/ams_backend_mock.h
+++ b/include/ams_backend_mock.h
@@ -84,6 +84,15 @@ class AmsBackendMock : public AmsBackend {
*
* Scoped to tool-changer mode: the mock also stands in for lane-based systems,
* where the inherited LOADED-status rule is the correct one.
+ *
+ * Snapmaker/multiACE answer separately, and are not tool_changer_mode_: only
+ * a HEAD can be unloaded from the toolhead, exactly as AmsBackendSnapmaker
+ * answers (false for every index >= NUM_TOOLS). Multiace mode is PARALLEL, so
+ * under the base rule every filled ACE bay read "already at the toolhead" —
+ * the slot menu greyed Load on all four bays of an ACE and offered Unload on
+ * all four. Real hardware does neither, so the one scenario the mock exists
+ * to exercise — swapping bays on an ACE-fed head — could not be reached in
+ * `--test` at all.
*/
[[nodiscard]] bool can_unload_from_toolhead(int slot_index) const override;
@@ -598,6 +607,41 @@ class AmsBackendMock : public AmsBackend {
*/
void set_snapmaker_mode(bool enabled);
+ /// Simulate a U1 with TWO ACE units, one bound to each of the first two
+ /// heads. The shape the real rig cannot show on one machine: heads 0 and 1
+ /// ACE-fed (so each draws its feeder's four bays), heads 2 and 3 on their
+ /// stock feeders. Exercises the presentation an ACE-fed position gets —
+ /// bars, a spool-number range, no tool badge, no Load — without a printer.
+ void set_multiace_mode(bool enabled);
+
+ /// In multiace mode heads 0 and 1 are fed by ACE 1 and ACE 2, so their
+ /// spool identity belongs to that unit — exactly as AmsBackendMultiAce
+ /// reports for the real thing. Every other mode answers nullopt, which is
+ /// the base behaviour.
+ [[nodiscard]] std::optional slot_identity_owner_unit(int slot_index) const override;
+ [[nodiscard]] std::optional slot_identity_owner_slot(int slot_index) const override;
+
+ /// Mounting the head an ACE bay feeds moves the carriage and feeds nothing,
+ /// so `T{n}` is not that bay's load — exactly as AmsBackendMultiAce answers.
+ ///
+ /// Without this the mock kept the base's blanket `true`, and plan_load()'s
+ /// swap arm substituted `change_tool(mapped_tool)` for the bay's load. Every
+ /// bay of ACE 1 maps to tool 0, so a bay swap dispatched a tool change the
+ /// mock then refused as out of range — reachable only once the bay gating
+ /// above was corrected, which is how it stayed hidden.
+ [[nodiscard]] bool change_tool_completes_load(int slot_index) const override;
+
+ /// Mirror the real backends' step models so the step bar under test is the
+ /// one that ships.
+ ///
+ /// The mock never overrode this, so every mocked backend fell through to the
+ /// sidebar's legacy coarse bar (Heat → [tip] → Feed/Retract). An unload on a
+ /// mocked U1 rendered two steps where the real one renders four, and the
+ /// firmware-phase bar — including the multiACE swap model this exists to
+ /// show — had no mock representation whatsoever.
+ [[nodiscard]] OperationStepModel get_operation_step_model(StepOperationType op) const override;
+ [[nodiscard]] lv_subject_t* get_operation_step_index_subject(StepOperationType op) override;
+
/**
* @brief Configure (or clear) the simulated Snapmaker print task.
*
@@ -778,6 +822,15 @@ class AmsBackendMock : public AmsBackend {
*/
void set_action(AmsAction action, const std::string& detail);
+ /// Publish a U1 firmware phase id (AmsBackendSnapmaker::*_PHASE_BASE + n)
+ /// while a mocked Snapmaker/multiACE operation runs. No-op in every other
+ /// mock mode, which has no phase model and uses the coarse AmsAction bar.
+ void set_operation_phase(int phase);
+
+ /// Walk the unload half + the ACE-side fetch of a multiACE bay swap.
+ /// Returns false if the operation was cancelled partway.
+ bool run_multiace_swap_prologue(InterruptibleSleep interruptible_sleep);
+
/**
* @brief Execute load operation with optional multi-phase sequence
* @param slot_index Slot being loaded from
@@ -890,6 +943,20 @@ class AmsBackendMock : public AmsBackend {
bool htlf_toolchanger_mode_ = false; ///< Simulate HTLF + Toolchanger mixed topology
bool torture_mode_ = false; ///< Simulate 5 units / 16 lanes / 4 shared extruders
bool snapmaker_mode_ = false; ///< Simulate Snapmaker U1 (4 slots, PARALLEL, non-editable)
+ bool multiace_mode_ = false; ///< Simulate a U1 with two ACE units (see set_multiace_mode)
+ /// Which ACE bay is seated at each U1 head in multiace mode, -1 = none.
+ /// Drives slot_identity_owner_slot(), so the head and its bay share one
+ /// spool number instead of each consuming one.
+ std::array multiace_seated_{{-1, -1, -1, -1}};
+
+ /// The head the last multiace load/unload targeted, or -1.
+ ///
+ /// Mirrors AmsBackendMultiAce::op_target_head_ and exists for the same
+ /// reason: get_operation_step_model() takes no slot, and `current_slot` is
+ /// not the head — after a bay load the mock sets it to the BAY's global
+ /// index, so keying the swap model off it picked the plain load model for
+ /// every swap after the first.
+ int multiace_op_head_ = -1;
/// Declared remap route outside Snapmaker mode. Native by default: every
/// non-Snapmaker mode stands in for a table-owning backend.
RemapStrategy remap_strategy_ = RemapStrategy::Native;
diff --git a/include/ams_backend_multiace.h b/include/ams_backend_multiace.h
new file mode 100644
index 0000000000..6602cbd516
--- /dev/null
+++ b/include/ams_backend_multiace.h
@@ -0,0 +1,491 @@
+// Copyright (C) 2025-2026 356C LLC
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#pragma once
+
+#include "ams_backend_snapmaker.h"
+
+#include
+#include