Skip to content

Fixes the host tree can prove, their tests, and CI - #28

Open
munzzyy wants to merge 8 commits into
freewili:masterfrom
munzzyy:host-verifiable-fixes
Open

munzzyy wants to merge 8 commits into
freewili:masterfrom
munzzyy:host-verifiable-fixes

Conversation

@munzzyy

@munzzyy munzzyy commented Sep 16, 2026

Copy link
Copy Markdown

I went looking for things the host tree could prove, since I have no FreeWili 2
and no probe here. Everything below is verified with fw test on Linux. Nothing
in it has been near a board, so the hardware-facing parts are reasoned from the
code and I have said so where that matters.

Four fixes, each with the test that catches it:

wMaxPacketSize was taken from the device as-is (bsp/usbhost). For a hub's
status-change endpoint that value becomes the buffer length the SIE may write
into the 64-byte window at INT_EP_BUF_OFFSET, so a descriptor asking for more
than 64 runs past it, and a value too wide for the 10-bit length field lands on
the control bits next to it. Now masked to the size field, capped at the
full-speed maximum, with a zero-length endpoint left unrecorded so the existing
completeness checks reject the device. hcd_int_ep_install caps it too.
usb_parse.c had no host coverage at all, so there is a new test file for it.

Four IR decoders accepted frames longer than the protocol (bsp/ir). NEC,
Samsung32, RCA and Kaseikyo checked a minimum edge count and read only the
entries they needed, so a capture holding a good frame plus a second burst or a
noise glitch decoded as clean. ir_frame drops the terminating space, so the
count is exact and they can compare against it. SIRC and RC5 already did, RC6
self-limits. The NEC repeat frame is matched earlier and still decodes.

A UF2 built for another chip passed check_app_uf2 (tools/fw.py). The
family ID was unpacked and dropped, and the address windows do not separate
chips: RP2040 SRAM also starts at 0x20000000, so an RP2040 image sits inside
the SRAM app window. Blocks that declare a family are now held to the RP2350
Arm-secure one. Blocks without the flag are untouched.

The CIC accumulators were signed (bsp/dsp). The filter wants the
integrators to wrap and the comb stage takes the wrap back out, but wrapping a
signed type is undefined rather than modular. pdm_capture free-runs these for
the session and any real mic bias walks them to the end of their range in
seconds. Under -fsanitize=undefined with a biased PDM stream the first
integrator trips well inside a second of audio. Holding them in uint32_t makes
the wrap defined and the arithmetic identical: same samples over three million
bits, sanitizer quiet.

Also here:

  • An injected touch skipped the clamp a real touch gets, so TCH 60000 50000
    reported a point far off the panel. Nothing currently turns that into a bad
    access, so it is a broken promise rather than a crash. The clamp is shared
    now, and the mapping has the host test the driver comment already described.
  • CI. The host tree needs no SDK and no board, so a stock Linux runner covers
    it on push and PR. I left Windows out rather than add a job I cannot try.
  • fw test was only running part of tools/tests. test_fw and
    test_check_app_repo were outside it, which left the flash-protection and
    install-app guards out of the suite. They run now.
  • test_no_private_refs did not scan skills/. AGENTS.md stays out, a test
    pins it as non-public.
  • The README pinned a test count that had drifted, and a comment in fw2kb
    says five hops where the loop runs six.
  • audio_i2s_duplex_play_stream_loop needs a length that is a non-zero
    multiple of 8192 and at least 16384, or a chunk-sized read points past the
    buffer. Nothing violates it today, and the one caller pads, but the rule
    only existed in a comment at the call site while the sibling play_loop
    documents its own rule in the header. Documented, not guarded, since I
    cannot build the target here to check a guard.

One thing I found and did not fix, because it needs a board to confirm and to
not make worse: fw2_recovery_sd_send calls straight into
uart_write_blocking on a CTS/RTS link, with fw2_app_recovery_task() only
after it returns. The read side deliberately slices into short polls for exactly
this reason. If MAIN stops asserting CTS mid-write, a HOME hold looks like it
should reboot and does not. Happy to open it as an issue instead if that is
easier to track.

fw test: 49/49 green, from a clean tree.

An endpoint's wMaxPacketSize was stored exactly as the device reported it.
For a hub's status-change endpoint that value becomes the buffer length the
SIE is told it may write into the 64-byte DPRAM window at INT_EP_BUF_OFFSET.
A descriptor claiming more than 64 therefore lets the controller run past the
end of that window. A value too large for the 10-bit length field also
collides with the control bits sharing the register.

Keep only the size field. Bits 12:11 are the high-speed transaction count,
not part of the size. Cap the result at the 64-byte full-speed maximum this
controller is wired for, and leave a zero-length endpoint unrecorded so the
existing completeness checks reject the device instead of arming a pipe that
carries nothing. hcd_int_ep_install caps the length as well, so the hardware
boundary does not rely on its caller.

usb_parse.c had no host coverage. tests/test_usb_parse.c adds it: descriptor
walking for the mass-storage and hub paths, alternate settings, truncated and
malformed TLVs, the packet-size bounds above, and CBW/CSW round-trips.
ir_decode_nec, ir_decode_samsung32, ir_decode_rca and ir_decode_kaseikyo
checked a minimum edge count and then read only the entries their protocol
uses. A capture carrying a good frame plus extra edges, which is what a
second burst, an echo or a noise glitch inside the same capture window
looks like, decoded as a clean result and the extra edges were never
examined.

The framer drops the terminating space, so a captured frame has exactly the
edge count its protocol defines: 67 for NEC and Samsung32, 51 for RCA, 99
for Kaseikyo. Compare against that count instead. ir_decode_sirc and
ir_decode_rc5 already did, and RC6 self-limits through its half-bit
accounting, so this brings the remaining four in line. The NEC repeat frame
is matched before the data-frame check and is unaffected.

tests/test_ir_strict_length.c covers all four: a valid frame still decodes,
one extra edge or a spurious appended burst does not, a frame missing its
stop mark does not, and the NEC repeat still decodes while a padded repeat
does not. The dispatcher and ir_decode_all are checked too, since a padded
frame previously produced a confident match there.
check_app_uf2 unpacked the family ID out of every block header and then
discarded it, so the only thing standing between a foreign image and the SD
card was the address windows. Those do not separate chips: RP2040 SRAM also
starts at 0x20000000, so an RP2040 build lands inside the SRAM app window and
passed both the POST_BUILD gate and the pre-mount check in fw install-app.

make_app_uf2.py stamps every block it emits with the family-ID flag and the
RP2350 Arm-secure family, so when a block declares a family it can be held to
that one. Blocks that do not set the flag are left alone: the field is not a
family there, so it carries no claim to check.

Covered in tools/tests/test_fw.py: an RP2040 image inside the SRAM window is
rejected, an RP2350 one is accepted, and a block without the flag still parses
as before.
A CIC integrator is supposed to wrap. The comb stage subtracts the wrapped
values back out, which is why the filter stays correct across the boundary.
The accumulators were int32_t, so that wrap was signed overflow, and signed
overflow is undefined rather than modular.

This is not a corner case on this board. pdm_capture starts the filters once
and free-runs them for the life of the session, and any real microphone
signal carries enough bias to walk the integrators to the end of their range
within seconds. Built with -fsanitize=undefined and fed a biased PDM stream,
the first integrator trips the sanitizer well under a second of audio.

Holding the accumulators in uint32_t makes the wrap defined and leaves the
arithmetic identical: the same stream over three million bits produces the
same samples before and after, and the sanitizer stays quiet.
Nothing ran the tests automatically, so a regression in the host tree only
surfaced when someone remembered to run fw test locally. The tree needs no
Pico SDK and no board, so a stock Linux runner covers all of it.

Two gaps in what fw test itself reached, fixed here so the workflow and a
local run cover the same ground:

tools/tests was only partly wired in. test_uf2_info and test_make_app_uf2
ran; test_fw and test_check_app_repo did not, which left the fw CLI's
flash-protection and install-app guards out of the suite. Those guards decide
whether a UF2 may reach the card at all, so fw test should be the thing that
says they still hold. They now run as python_tools alongside
test_no_private_refs.

test_no_private_refs also skipped skills/. Those files ship in the repo and
are written for agents to follow, which is the same reason the scanner covers
docs/. AGENTS.md stays out: a test already pins it as a non-public file.
The README pinned the host tree at 26 green binaries, which stopped being
true as tests were added. It now points at fw test rather than carrying a
number that has to be maintained by hand.

fw2kb_chord_for's comment says five hops where the loop runs six. The bound
is fine either way, since every append is guarded against FW2KB_CHORD_MAX,
but the comment is what a reader checks that headroom against.
ft6336_poll promises screen coordinates inside 480x320, and a real touch gets
them because ft6336_map_point clamps on the way through. An injected point
skipped that: agentio parses TCH into a uint16_t and handed it straight to
ft6336_inject_set, so a command naming 60000 50000 reported a touch two
orders of magnitude off the panel to whatever was polling.

No current consumer turns that into a bad access. st7796_fill_rect clips its
own rectangle and retrochat range-checks before indexing, so this is a broken
promise rather than a live crash. It is the kind a later consumer inherits
silently, though, since the header says the range is already bounded.

The clamp moves into ft6336_clamp_point so both paths share it, and injection
now goes through it. tests/test_ft6336_map.c is new: the mapping had no host
test despite the driver comment describing it as host-tested logic. It covers
the rotation, both clamped edges, and the widest value a TCH command can
carry. The injection call itself is by inspection, since ft6336.c needs the
Pico SDK and does not build on the host.
audio_i2s_duplex_play_stream_loop requires a length that is a non-zero
multiple of 8192 and at least 16384. The second DMA channel is armed one
chunk into the buffer and the refill advances by whole chunks modulo the
length, so a shorter or unaligned buffer points a chunk-sized read past the
end of it, and a zero length divides by zero in the interrupt.

Nothing violates that today: the one caller pads up to a multiple of 8192 and
says so in a comment at the call site. The requirement was only recorded
there, though, while the sibling play_loop documents its own alignment rule in
this header. Stating it here is where the next caller will look.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant