diff --git a/patches/pkgbuild/expat.sh b/patches/pkgbuild/expat.sh
index 7f45f91..d5a760f 100755
--- a/patches/pkgbuild/expat.sh
+++ b/patches/pkgbuild/expat.sh
@@ -1,5 +1,8 @@
#!/usr/bin/env bash
-# expat: bare cmake picks up the Debian multiarch libdir.
+# expat: two defaults decided by the build HOST rather than by the PKGBUILD.
+set -euo pipefail
+
+# 1. The library directory.
#
# The PKGBUILD calls cmake directly -- NOT arch-cmake -- so the devtools
# stand-in that already passes -DCMAKE_INSTALL_LIBDIR=lib never runs. The
@@ -21,13 +24,8 @@
# dependency of cmake, python, gdb and fontconfig, so this does not fail
# loudly here -- it fails later, in whatever links against -lexpat.
#
-# The fix is to state the libdir the stand-in would have stated. Passing it on
-# the command line makes it a cache entry BEFORE GNUInstallDirs runs, and
-# GNUInstallDirs only fills in defaults for variables that are still unset.
-#
-# One configure call, so exactly one insertion: assert the count rather than
-# rewriting every -D.
-set -euo pipefail
+# Passing it on the command line makes it a cache entry BEFORE GNUInstallDirs
+# runs, and GNUInstallDirs only fills in defaults for variables still unset.
python3 - <<'PY'
import io
s = io.open("PKGBUILD", encoding="utf-8").read()
@@ -41,3 +39,63 @@ PY
[ "$(grep -c 'CMAKE_INSTALL_LIBDIR=lib' PKGBUILD)" = 1 ] || {
echo "expat: libdir not pinned exactly once" >&2; exit 1; }
echo "expat: CMAKE_INSTALL_LIBDIR=lib (bare cmake bypasses arch-cmake)"
+
+# 2. EXPAT_BUILD_DOCS: pin it OFF, because its default reads the host's PATH.
+#
+# THE TRAP: this option has no fixed default. CMakeLists.txt line 74 does
+#
+# find_program(DOCBOOK_TO_MAN NAMES docbook2x-man db2x_docbook2man \
+# docbook2man docbook-to-man)
+#
+# and turns docs ON if ANY of those four is found. Nothing in the PKGBUILD
+# asks for docs -- makedepends is (cmake git). So whether expat builds a man
+# page is decided by whatever apt happens to have dragged onto the VM.
+#
+# Not hypothetical, and the timeline is the whole lesson. expat built clean on
+# 2026-08-15 21:25 and its package carries no usr/share/man at all. At
+# 2026-08-16 00:23:31 apt installed docbook-utils -- nobody asked for it; it
+# arrived as an automatic dependency of asciidoc, which install_host_deps
+# needs for pacman. docbook-utils owns /usr/bin/docbook2man, the THIRD name in
+# that list. From that moment find_program succeeded and a target that had
+# never run in this port switched itself on. expat was simply not rebuilt for
+# another twenty-nine hours, so the breakage waited.
+#
+# THE ERROR NAMES THE WRONG CULPRIT, twice over:
+#
+# mv: cannot stat 'XMLWF.1': No such file or directory
+#
+# Nothing is wrong with the mv, and XMLWF.1 is the correct name --
+# doc/xmlwf.xml declares XMLWF, and every
+# docbook man backend derives the filename from it. The real failure is one
+# command earlier and silent: docbook-utils' docbook2man is a two-line shell
+# script, `jw -f docbook -b man "$@"`, i.e. the SGML pipeline. doc/xmlwf.xml
+# is DocBook XML 4.2, whose ISO entity sets use `NNNN;` hex character
+# references that SGML forbids. onsgmls emits `"X2288" is not a function name`
+# two hundred times, gives up -- and jw still EXITS 0, having written only
+# manpage.refs and manpage.links. Those two hundred lines sit ABOVE the
+# failure in the log and read like harmless validation noise.
+#
+# Upstream knows this tool is unusable and rejects it BY NAME in the autotools
+# path: configure.ac:454 greps `${DOCBOOK_TO_MAN} --help` for "sgmlbase" and
+# aborts with "was found to work with SGML rather than XML". Our
+# /usr/bin/docbook2man matches that grep. The CMake path has no such guard.
+#
+# WHY OFF RATHER THAN INSTALLING docbook2x: because OFF is what Arch ships.
+# Arch's core expat 2.8.3 has 29 files and no usr/share/man, which proves
+# Arch's builder has none of those four programs -- any one would have flipped
+# the default. Pinning OFF reproduces Arch exactly; installing docbook2x would
+# make s390x diverge from x86_64 and add one more host tool that silently
+# decides what gets built.
+python3 - <<'PY'
+import io
+s = io.open("PKGBUILD", encoding="utf-8").read()
+assert "EXPAT_BUILD_DOCS" not in s, "expat: docs already pinned, hook ran twice"
+anchor = " -D CMAKE_INSTALL_LIBDIR=lib\n"
+n = s.count(anchor)
+assert n == 1, "expat: expected 1 libdir line, found %d" % n
+s = s.replace(anchor, anchor + " -D EXPAT_BUILD_DOCS=OFF\n", 1)
+io.open("PKGBUILD", "w", encoding="utf-8").write(s)
+PY
+[ "$(grep -c 'EXPAT_BUILD_DOCS=OFF' PKGBUILD)" = 1 ] || {
+ echo "expat: docs not pinned exactly once" >&2; exit 1; }
+echo "expat: EXPAT_BUILD_DOCS=OFF (default follows host PATH, not the PKGBUILD)"
diff --git a/patches/pkgbuild/systemd.sh b/patches/pkgbuild/systemd.sh
index 1704324..17e4bec 100755
--- a/patches/pkgbuild/systemd.sh
+++ b/patches/pkgbuild/systemd.sh
@@ -81,3 +81,48 @@ grep -q -- '-Dvmlinux-h=generated' PKGBUILD || {
grep -q -- '-Dvmlinux-h-path' PKGBUILD && {
echo "systemd: stale vmlinux-h-path left in place" >&2; exit 1; }
echo "systemd: vmlinux.h generated from /sys/kernel/btf/vmlinux"
+
+# 3. -Dsplit-bin=auto: the question is answered by the HOST, not the target.
+#
+# systemd/meson.build:123
+# sbindir = prefixdir / (split_bin ? 'sbin' : 'bin')
+#
+# split-bin is a combo defaulting to 'auto', and 'auto' probes whether
+# /usr/sbin on the BUILD MACHINE is a symlink to bin. On Arch it is, so
+# split_bin is false and everything lands in /usr/bin. Here /usr/sbin is a
+# real directory, and meson-log.txt says so:
+#
+# split bin-sbin : true
+#
+# THE TRAP is that arch-meson ALREADY passes --sbindir bin, and
+# `meson configure` dutifully reports sbindir = bin. It is ignored: systemd
+# computes its own sbindir from split_bin and never reads meson's builtin. The
+# option that looks like the fix is not the fix, and the one that is mentions
+# neither sbin nor a directory in its name.
+#
+# Nothing fails in build(). It fails much later, in package_systemd(), on the
+# first line that names a path:
+#
+# rm: cannot remove '/usr/bin/halt': No such file or directory
+#
+# because halt, init, poweroff, reboot, shutdown and resolvconf all went to
+# /usr/sbin -- along with mount.ddi, mount.mstack and mount.storage, which the
+# PKGBUILD never mentions at all. The rm is only the first victim. Suppress it
+# and the package ships /usr/sbin as a DIRECTORY, which collides on the target
+# with the ["usr/sbin"]="bin" SYMLINK our own filesystem package declares.
+#
+# Measured on a configured copy: with split-bin=false there is no /usr/sbin in
+# the install tree at all, and all six paths are where package() looks.
+#
+# Same species as arch-meson's --libdir -- an Ubuntu default standing in for
+# an Arch one -- but it cannot live in arch-meson: split-bin is systemd's own
+# option, not a meson builtin. --auto-features does not reach it either; it is
+# a combo, not a feature.
+test "$(grep -c -- '-Dcompat-sysv-interfaces=false' PKGBUILD)" -eq 1 || {
+ echo "systemd: expected exactly one _meson_options array" >&2; exit 1; }
+test "$(grep -c -- '-Dsplit-bin' PKGBUILD)" -eq 0 || {
+ echo "systemd: PKGBUILD already sets split-bin, re-read it" >&2; exit 1; }
+sed -i 's/^\( *\)-Dcompat-sysv-interfaces=false/\1-Dsplit-bin=false\n\1-Dcompat-sysv-interfaces=false/' PKGBUILD
+grep -q -- '-Dsplit-bin=false' PKGBUILD || {
+ echo "systemd: split-bin still auto (would install into /usr/sbin)" >&2; exit 1; }
+echo "systemd: split-bin=false (Ubuntu's /usr/sbin is not Arch's)"
diff --git a/scripts/bootstrap-pacman.sh b/scripts/bootstrap-pacman.sh
index 6afd5c1..ed8f7f1 100755
--- a/scripts/bootstrap-pacman.sh
+++ b/scripts/bootstrap-pacman.sh
@@ -82,7 +82,8 @@ install_host_deps() {
libbpf-dev clang libapparmor-dev libfdisk-dev libkmod-dev libdw-dev \
libpwquality-dev libxkbcommon-dev libdbus-1-dev libqrencode-dev \
libfido2-dev libtss2-dev libmicrohttpd-dev libaudit-dev \
- libcryptsetup-dev libgcrypt20-dev libgnutls28-dev libpam0g-dev
+ libcryptsetup-dev libgcrypt20-dev libgnutls28-dev libpam0g-dev \
+ python3-pefile
install_host_shims
}
@@ -254,6 +255,28 @@ build_package() {
# mkdir: build-curl-compat: File exists
# grep, gnupg, pacman and curl all failed this way -- not on the
# port, but on my own driver re-entering a dirty directory.
+ # LC_ALL=C.UTF-8, because build systems parse their tools' output.
+ #
+ # This host runs LANG=fr_FR.UTF-8. util-linux's poman-translate.sh reads
+ # po4a's report and skips what it says it discarded:
+ #
+ # DISCARDED_TRANSLATION=$(echo "$line" | awk '/Discard/ {print $2;}')
+ #
+ # -- an English word matched against a gettext-translated message. In
+ # French po4a prints "Rejet de ar/..." instead, so the skip list came out
+ # empty, asciidoctor was handed 852 files po4a had never written, and the
+ # build died on the first. Note $2 is "de" in French: even a locale-aware
+ # pattern would take the wrong field.
+ #
+ # It went unseen for a second reason -- the script captures po4a in
+ # `output=$(...)`, so none of those 852 lines reach the log at all.
+ #
+ # The locale is a property of THIS host, not of any one package, and any
+ # build that greps English tool output is exposed. So it is pinned here,
+ # once, rather than in a hook per package. C.UTF-8 and not C: the Arabic
+ # and Ukrainian pages must stay valid UTF-8. Arch's own build chroot runs
+ # in this locale, so this makes us match it rather than diverge.
+ LC_ALL=C.UTF-8 \
makepkg --nodeps --ignorearch --skippgpcheck --skipchecksums --nocheck \
-C -f || return 1
# An exit code is not proof. Only the artefact is.