Bounded waits and validated arguments in run.sh and ui-drive.sh, a seeded
settings directory and root-only staged rootfs permissions with their own
tests, qmp.py and imgtools.py hardening, the fetch scripts checking what they
download, and ASCII typography throughout. Each fix carries its test under
qemu/tests or tests/.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N3G6m9Aw5RyVY4ZowtKzEj
U-Boot draws the boot splash from resource.img by name, and now draws a second
image by name when the splash is tapped: the same logo with its bottom-right
mark reading VERBOSE instead of GRAPHIC. Without --logo-verbose that image is
absent from the FIT and a tap changes nothing on screen, even though the boot
itself would still go verbose.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013aHKWzT5EF86RFKRMtAv9n
The probe piped the candidate binary into grep to look for -B support.
mkimage with no arguments prints usage and exits non-zero, and this script
runs under `set -euo pipefail`, so the pipeline reported failure even when
grep matched. Every candidate was rejected and the script failed closed with
"no mkimage on PATH supports -B" while a capable mkimage 2025.01 sat first
on PATH, making it impossible to build a boot.img at all.
Neutralise the probed command's exit status before the pipe. Sentinel
comments now bracket the probe so the new regression test extracts and
exercises the shipping code rather than a copy of it.
Closes#17
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T2D2KtdgwbhbF6Mo64eUrn
054790d claimed the vendor aligns FIT sub-images to 0x800 and made that a build
gate. Both were wrong, and the reasoning was thin: two payloads from one build,
generalised into an invariant.
What U-Boot actually requires. IMAGE_ALIGN_SIZE is 512 (include/image.h:955-958)
and the read is blk_off = (FIT_ALIGN(fdt_totalsize) + offset) / blksz
(arch/arm/mach-rockchip/fit.c:331), a truncating divide by the 512-byte eMMC
block. So the metadata size must be a multiple of 512 -- otherwise FIT_ALIGN
rounds it up and EVERY payload is read late, including ones whose own position
is perfectly aligned -- and each data-position must be a multiple of 512.
Nothing in the FIT or RESC path references 2048.
Why the 2048 gate was actively harmful: of the eight boot.img files on this
machine, four are 512-aligned but sit at data-position % 2048 = 1536, including
vendor RELEASE_TEST builds that boot. The gate would have rejected images the
vendor shipped. 0x800 is the vendor's -p value -- the file position of the FIRST
payload -- not an alignment; 054790d moved it into the -B slot.
And it broke the build inside the SDK. mk-bootimg.sh resolves mkimage with a
bare `command -v`, project/build.sh:64 prepends the SDK tool directory to PATH,
and the SDK vendors mkimage 2017.09, which has no -B and exits 255 with
"invalid option -- 'B'" under set -euo pipefail. The 6.18 image built only
because it was made from a normal shell that found host mkimage 2025.01. The
flag is now feature-detected; the vendor packer 512-aligns natively, so omitting
it there is correct rather than a fallback.
9387cff's -B 0x200 was right, and better derived than 054790d credited: the
proven image's resource sits at 8748544, a multiple of 512 but not of 1024 or
2048, which pins the alignment at exactly 512 rather than bounding it.
The gate now asserts what U-Boot enforces -- metadata % 512 and every
data-position % 512 -- and the embedded-data guard drops from 65536 to >= 4096
to match FIT_FDT_MAX_SIZE (SZ_4K, fit.c:24). At 65536 a 5000-byte header passed
the build and returned "No fit blob" on a panel.
The already-flashed 6.18 boot.img is unaffected: 2048 is a multiple of 512, so
it satisfies the real requirement, which is why it booted.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T2D2KtdgwbhbF6Mo64eUrn
mkimage -p only places the FIRST payload. The rest are packed contiguously, and
U-Boot reads each by dividing its data-position by the 512-byte block size with a
truncating divide -- so a payload that is not block-aligned is read from the
wrong offset. Measured: without -B this script emitted the kernel at 0x9A94,
data-position % 512 = 148, which is exactly the failing shape.
That failure is on record. flare-edge 9387cff (2026-09-01) hit it on a boot_b
FIT: "unaligned sub-images fail the RESC loader's sha256 and the logo silently
falls back to 'No resource file'". It landed on -B 0x200, inferred from noticing
the proven image was "512-aligned" without checking for a larger factor.
The proven images are aligned to 0x800, not merely to 512. Measured on the pair
that boots this board today: boot.img fdt/kernel/resource at 0x800 / 0x12800 /
0x3A7800, recovery.img at 0x800 / 0x12800 / 0x3F8800 -- every one a multiple of
2048. So 0x800 satisfies everything 0x200 does, since every 0x800 multiple is a
0x200 multiple, and additionally reproduces the vendor layout exactly. The cost
is at most 2 KiB of padding. Where a remembered rule and a booting image
disagree, match the image.
The alignment is now also asserted at build time rather than trusted. It runs on
every build because the failure it catches is silent -- a wrong offset does not
fail the build, it fails on a panel, and only sometimes visibly.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T2D2KtdgwbhbF6Mo64eUrn
The firmware builds against sysdrv/source/buildroot/buildroot-2025.02.8 in the
vendor SDK. That tree is not in the vendor checkout -- the SDK ships 2023.02.6 --
it was not in this manifest, and nothing anywhere recorded its origin. A clean
rebuild on another machine silently fell back to the vendor's older buildroot
and produced a different userspace, which is flare-edge#135.
fetch-buildroot-tarball.sh follows fetch-kernel-tarball.sh exactly: pinned URL,
pinned sha256, fails closed on a missing pin. Buildroot signs releases with GPG
rather than publishing a .sha256, so the pin was computed from the tarball and
is what the script verifies against.
The manifest now says out loud that two of the inputs are tarballs rather than
git trees, so "which buildroot" has an answer in the same place as "which LVGL".
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T2D2KtdgwbhbF6Mo64eUrn
The vendor 5.10 kernel is built without CONFIG_BRIDGE, and that absence has
shaped the firmware: the panel's access point has to be its own subnet with its
own DHCP pool instead of joining the LAN, and warden_router_set_zone() treats a
second LAN port as impossible. Both carry a code comment saying "this kernel has
no bridge".
Turning it on is what lets a Client-mode panel run an access point the way an
ordinary ceiling AP does -- wlan0 and the wired uplink in one broadcast domain,
addresses handed out by whatever router is already upstream.
Verified on the bench panel: before, `ip link add type bridge` returned
"Operation not supported"; on this kernel the bridge is created and eth0
enslaves into it (/sys/class/net/br0/brif/eth0). WireGuard, iptables and VLAN
still work.
Also fixes a warden-modbus path in architecture.md left stale by the apps/ move.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T2D2KtdgwbhbF6Mo64eUrn
LVGL and the vendor RV1106 SDK are what this platform is built against, and
nothing in this repo said WHICH ones. The two live outside every worktree as
shared checkouts, so "the version we build against" was whatever happened to be
on the machine.
They are pinned here now, by exact commit, with one script that obtains and
verifies them. Not copied: between them they are ~21 GB, the vendor SDK bakes
absolute paths into its host tools so it has to sit at one stable path anyway,
and a 21 GB git repo would be unusable while still not making anything
reproducible. What makes a build reproducible is knowing exactly which tree was
used, which is a commit id -- the same reasoning build/fetch-kernel-tarball.sh
already applies to the kernel, where a sha256 pin stands in for vendoring the
tarball.
Two behaviours worth stating, because both were wrong in the first draft:
- A checkout is detected by `.git` EXISTING, not by it being a directory. LVGL
is checked out as a worktree here, where `.git` is a file.
- Local modifications are reported and are NOT a failure. The vendor SDK is
supposed to carry them -- tools/build-firmware.sh applies our sdk-patches
series into that tree on every build -- so a pristine checkout is the
unusual state. Only a MISSING or DRIFTED tree fails.
Nothing is ever reset automatically: a tree off its pin is reported, because a
local change to a vendor tree is usually someone mid-debug, not something to
throw away on their behalf.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T2D2KtdgwbhbF6Mo64eUrn
Two gaps between "this SDK builds a kernel" and "this SDK can replace the
vendor SDK for the device".
BOOT IMAGE. build-kernel.sh emitted a zImage and a dtb and stopped, so
producing something the board's U-Boot would actually boot still meant going
through the vendor tree. mk-bootimg.sh packages the pair the way M2 bring-up
established (kernel/docs/m2-boot-on-c8a3.md): an EXTERNAL-DATA FIT
(mkimage -E -p 0x800), a mandatory `resource` multi sub-image carrying
rk-kernel.dtb plus any logos, and the sysmem sentinel load addresses. Each of
those was learned from a specific failure -- an embedded-data FIT is "No fit
blob", a missing resource image is "Failed to load DTB, ret=-19", real load
addresses collide -- so the script also ASSERTS the metadata stayed small,
because an embedded-data FIT looks perfectly fine until a panel will not come
back.
NETWORKING. Diffing this defconfig's expansion against the kernel actually
shipping on the panel found three whole subsystems missing, none of which fail
at build time and none of which are visible until the unit is in the field:
- WIREGUARD + NET_UDP_TUNNEL: flared's mesh to FLARE. Without it wg0 never
comes up.
- VLAN_8021Q: the MikroTik app configures tagged ports the panel terminates.
- NETFILTER and legacy iptables: every rule in S35iptables, and NAT for
router mode.
The netfilter half carried a trap worth naming. 6.18 split the legacy tables
out behind NETFILTER_XTABLES_LEGACY and IP_NF_IPTABLES_LEGACY, symbols that do
not exist in 5.10 -- so copying the vendor kernel's symbol list verbatim gives
a kernel where IP_NF_FILTER and IP_NF_NAT silently stay off and `iptables` has
no filter or nat table at all. Our userspace drives legacy iptables, not nft.
The result now shows ZERO regressions against the shipping 5.10 kernel across
mesh, gadget, firewall, VLAN, storage, net core, display/input, RGA/NPU,
wifi/BT and watchdog.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T2D2KtdgwbhbF6Mo64eUrn
Replace every em dash with real punctuation (rewrites, not hyphen swaps)
in docs, code comments, scripts, configs, and the port records; convert
en dashes, curly quotes, ellipsis glyphs, arrows, and section signs to
ASCII; drop machine-writing tell phrases from living docs. ADR titles
now use a colon. The M2 bring-up DTS model string carried an em dash
into the patch series and its record echoes; fixed at both, and the full
series re-verified to apply cleanly onto pristine 6.18.46. One comment
in freshness.h deliberately names the em dash glyph the UI renders as
the unknown mark; that is data, kept as prose naming it.
Verified: cargo tests (sim, config-lint, rs485-bridge), shellcheck,
both driver MC/DC harnesses, patches-apply.
The hermetic build only ever made zImage+dtb — no 6.18 .ko set existed,
so the panel's only aic8800_btlpm.ko was the stale 5.10 build, which
fails the struct-module ABI check at load. New WARDEN_MODULES_COLLECT
env runs the full `make modules` (required: zImage alone emits no
Module.symvers, so per-directory M= builds cannot link) and collects the
listed dirs' modules fail-closed; CI's kernel-build collects
drivers/net/wireless/aic8800 and ships the .ko files in the artifact.
Verified: all three aic8800 modules (bsp 326K, fdrv 1.5M, btlpm 36K)
compile clean against 6.18, and bsp+btlpm INSMOD WITH RC=0 on the 6.18
kernel in the qemu/ device sim — no linkonce/this_module ABI error;
btlpm's rfkill init runs and only hardware power-up fails (no AIC silicon
on virt, expected). On-panel BT bring-up remains bench-gated (c8a3
currently dark).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018HUayid7W5w7jBdb9Rrj1K
- ci-cd.md phrasing made time-invariant (true before and after the
visibility flip; the prior wording asserted a state not yet real).
- ui-shot: liveness check before the tap QMP call too — the commit-message
claim of "before every QMP call" is now actually true.
- busybox fetcher checks its pin before downloading, mirroring the
kernel-tarball fetcher's ordering.
- Fragment disable-arm: absence of a deps-gated symbol is a valid disable
outcome; only "still set" is fatal (the iteration-2 symmetrization
overcorrected). Negative-tested both directions under set -e.
Iteration 3 produced four findings, none blocking, all applied — the
recursive review is converged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018HUayid7W5w7jBdb9Rrj1K
- build-kernel.sh fragment assertion: survives a missing trailing newline
(the read footgun, reproduced) and treats an absent symbol on a disable
line as FATAL, symmetric with the enable arm.
- fetch-kernel-tarball.sh checks the pin before downloading; both fetchers
add --retry-connrefused.
- mkimage rejects '.'/'..' state keys.
- ui-shot: VM liveness checked before every QMP call, console.log preserved
as evidence on every failure path, repaint deadline widened to 90s with
the contended-runner rationale documented.
- rs485-bridge: overflow discards back off one gap and rate-limit their log
line, mirroring the accept-loop fix; clippy nit fixed.
- .gitignore ignores *.elf/*.map so the untracked artifacts cannot silently
return; CI shellcheck glob now covers build/ and the rootfs boot scripts
(directives added for the deliberate in-guest source paths).
- Docs: NPU parity row matches its sibling verification docs; line-pinned
audit cross-references unpinned; CROSS_COMPILE documented in the build
header; payload README lists warden-ui; ci-cd tense settled.
Verified: guards negative-tested (bad state keys, no-newline fragment);
boot smoke, portal scenario, ui-shot all PASS; 53 tests green; shellcheck
clean across the widened glob; clippy zero.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018HUayid7W5w7jBdb9Rrj1K
CI/pipeline:
- KERNEL_TARBALL passed as a YAML env literal '~' was never tilde-expanded
and would have failed every hosted kernel-build dispatch; the path is now
exported from the shell. Verified reproducible before the fix.
- Every job gets timeout-minutes; boot smoke uses timeout -k so a wedged
qemu is SIGKILLed instead of holding the job.
- Tarball fetch + fail-closed sha256 verification deduplicated into
build/fetch-kernel-tarball.sh (with curl retries), used by build-kernel.sh
and both CI jobs. busybox fetch gains retries too.
- ccache layer for kernel-build (cache keyed on defconfig+patches) recovers
the incremental-compile speed the ephemeral-runner move cost.
- build-kernel.sh now asserts every fragment option survived olddefconfig —
merge_config -m pastes text and Kconfig silently drops unmet symbols.
rs485-bridge:
- pending-buffer cap (2x max RTU ADU) instead of unbounded growth;
explicit accept-loop error handling with backoff instead of .flatten();
per-arm inline bounds instead of the string-keyed lookup whose default
would have mis-bounded a future get-input; control-socket cleanup errors
surfaced; flag-shaped values rejected in arg parsing; doc example uses a
private mktemp dir. Test timing margins widened for contended runners
(gap 25->120ms, 60x margin on the split-frame test).
VM harness:
- stage-1/stage-2 boot scripts share one validated slot parser and one
by-name populator (qemu/rootfs/etc/warden-lib.sh) — the duplicated
parser had already diverged on validation; userdata/oem mount failures
now fail fast with a greppable sentinel; udhcpc fallback keys off the
interface actually having an address; switch_root applet guarded.
- boot-smoke delegates the qemu invocation to run.sh (machine shape lives
in ONE place); run.sh port 0 disables a hostfwd.
- mkimage: unknown partition names fail at build time; DISK_END is a max,
not last-entry; --state keys validated as filenames.
- portal-scenario: mock readiness is asserted (no silent fall-through),
hostfwd port collisions retried, mount-failure sentinel fails fast.
- ui-shot: fixed sleeps replaced with bounded screendump polling; the
repaint assertion is real and documented as such. qmp.py loses its
module-global and gains argv validation.
Docs/scrub: bench-host paths and the site AP name removed from six more
port docs and two evidence tables; path-bearing build artifacts (.elf,
.map) untracked (the 154-byte firmware .bin is path-free and stays);
ADR-0003 marked visibility-superseded by ADR-0007; stale section
cross-reference fixed; flare-edge noted as private for outside readers;
stale root-level review report removed per the new workspace rule.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018HUayid7W5w7jBdb9Rrj1K
The third simulator (deliberately not named "sim"): a QEMU -M virt VM that
boots the real 6.18.46 kernel and enters at -kernel zImage — everything below
(BootROM/idblock/U-Boot/real BCB A/B selection) is closed blobs + mask ROM
and is explicitly out of scope.
- qemu/mkinitramfs.sh: pinned static busybox (sha256 fail-closed) + rootfs/
- qemu/mkimage.sh: unprivileged sparse disk image with the device's canonical
12-partition blkdevparts A/B layout (vda == mmcblk0 mapping)
- qemu/rootfs/: stage-1 init (by-name symlinks from PARTNAME uevents,
whole-token warden.slot= parse, switch_root) + stage-2 init (userdata/oem
mounts, slirp networking, payload daemon start)
- qemu/run.sh: runner with --slot/--rtc/--watchdog/--rs485/--qmp/--display
- qemu/configs/virt.fragment + WARDEN_KCONFIG_FRAGMENT hook in
build/build-kernel.sh (canonical RV1106 build untouched when unset):
adds PCI, pci-serial, i6300esb watchdog, WireGuard, virtio-gpu/input
- qemu/tests/boot-smoke.sh: sentinel-asserting boot test
Verified on QEMU 10.0.11: canonical zImage boots -M virt unmodified (the
feared DEBUG_UNCOMPRESS decompressor hang does not exist in 6.18); full
stack boots both slots; 12 by-name symlinks; userdata persists across
reboot; -rtc base=2021-01-01 reproduces the no-RTC wrong-clock class.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018HUayid7W5w7jBdb9Rrj1K
The kernel-build job set WORK inside the repo checkout, so build-kernel.sh's
`git apply` ran from a subdirectory of the warden-sdk git repo — where git apply
silently ignores out-of-subdir paths and exits 0 without applying anything. The
build then failed at the (never-created) rv1106-warden.dtb target, and the
unconditional `echo "applied"` had masked it.
- ci.yml: WORK -> ${{ runner.temp }}/kbuild-out (outside the checkout); artifact
paths follow.
- build-kernel.sh: the patch loop now fails loudly instead of echoing on silent
failure, and a post-apply sentinel assert (rv1106-warden.dts must exist) makes a
silent no-op impossible to ship — the regression guard for this class.
Refs #1.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017wB8KB3MMQztRDXCMCkPrf
Make the self-hosted kernel-build runnable with zero manual toolchain/python setup
on the runner host (docs/ci-cd.md steps 3-4 move into the workflow):
- build/build-kernel.sh: honor a caller-provided CROSS_COMPILE (default stays the
Luckfox uclibc prefix). The kernel is freestanding, so a generic arm cross
compiler links it.
- kernel-build job: set CROSS_COMPILE=arm-linux-gnueabihf- (Debian
gcc-arm-linux-gnueabihf, already on 0640) instead of depending on the ephemeral
SDK checkout path; symlink python->python3 into $RUNNER_TEMP/bin on $GITHUB_PATH
for the bare-`python` SDK quirk.
- docs/ci-cd.md: only steps 1-2 (systemd service + cgroup cap) still need 0640 sudo.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017wB8KB3MMQztRDXCMCkPrf
First recursive code-review-harness pass over the authored SDK code (sim/,
config-lint, flowgen, drivers/{relays,freshness}, build/, ci.yml). Four parallel
dimension reviewers; all findings at every severity corrected per workspace rule.
Correctness / reliability:
- freshness.c min_budget_ms: use a `seen` flag, not `best==0`, as the empty
sentinel — a zero-tolerance (max_stale_ms==0) binding was silently widened to a
looser neighbour's budget. Regression test added; still 66/66 MC/DC.
- config-lint parse_reserved_ranges: match `reg` as a whole property token (ident
boundary before, `=` after) so `reg-names` / a `region-*` label no longer
mis-parses into a bogus reserved range.
- config-lint loader check: fail closed — flag any loader with a LOAD_ADDR that is
not a known-safe boot component, instead of only known MCU names, so a future
coprocessor ("Rtos"/"Bl32") can't slip past the 0x40000-brick gate.
- build-kernel.sh: sha256 verification is now mandatory (refuse to build if the pin
is missing) and the mktemp scratch tree is removed on exit (trap), while a
caller-provided WORK is left intact for CI artifact upload.
Test quality:
- freshness: added the age==max_stale boundary case and a clock-wraparound
(now < last_ok) fail-safe-to-UNKNOWN test.
- relays: unsetenv(WARDEN_GPIO_ROOT) at main() so the NULL-env arm is hermetic.
Security / CI:
- ci.yml: top-level `permissions: contents: read` (badges overrides to write);
pin taiki-e/install-action to commit SHA (v2.86.7).
Maintainability / docs:
- drivers/enforce-mcdc.sh: one shared, name-derived gate replaces the two
copy-pasted per-driver scripts; Makefiles call ../../enforce-mcdc.sh.
- docs/architecture.md: §3/§4/§6/§7 rewritten to match reality — NPU/RGA models,
config-lint, and the relays+freshness MC/DC harnesses are done; kernel §6 now
reflects the 5.10->6.18.46 forward-port (ADR-0001), not the superseded plan44/6.6.
- README: status blurb + layout table corrected (kernel/, .github/; stale ci/ and
patches/ descriptions fixed). hpmcu "8 tests" -> 7 in docs.
- freshness.{c,h}: ADR reference points at flare-edge ADR-0004 (warden-sdk's
ADR-0004 is the CI runner — number collision).
- normalize rustfmt drift across sim/ + config-lint.
All green: sim 37 tests, config-lint 8 tests, both drivers 100% MC/DC (relays
40/40, freshness 66/66), clippy clean under -D warnings, gitleaks clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017wB8KB3MMQztRDXCMCkPrf
- patches/: the pristine-6.18.46 -> WardenOS delta as a 13-patch, subsystem-split
series (223 files, ~136K lines: clk, pinctrl, DTs/mach, usb-phy, VOP/panel/rgb,
mailbox, pvtm, rknpu, rga, aic8800 wifi, audio codec, thermal/rtc/adc/gmac/touch).
Verified: every patch applies cleanly onto pristine (git apply --check), the full
series reproduces the hardware-verified tree, and the applied source configures +
builds the warden dtb + rockchip DRM drivers (rc=0).
- build/build-kernel.sh: fetch+verify pristine (sha256-pinned) -> apply series ->
warden_defconfig -> zImage + rv1106-warden.dtb. build/warden_defconfig captured.
- CI: `patches-apply` (GitHub-hosted, cached tarball) enforces the series applies;
`kernel-build` (self-hosted warden-sdk runner, dispatch-gated until registered)
runs the full build and uploads the image.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017wB8KB3MMQztRDXCMCkPrf