7 Commits

Author SHA1 Message Date
bad8b6b43e build: the pin advances over the bounded calibration
libavr's calibrate() now bounds its measurement loop, starts the pulse
on an observed edge, and re-arms a rejected pulse on the remaining
budget instead of one-strike booting the application. The autobaud
images pay +16..20 B — every slot still fits, the worst now the 1284s'
502 of 512 — and the stock images are byte-identical, kept so by
fitting the loader's flag set per backend: -fno-ivopts stays on the
fixed-baud bodies it shrinks and comes off the autobaud body, where it
duplicated the calibration countdown into a 9-cycle loop against the
contracted seven.

One deployed constant moved and its gate caught it: the calibrate
wait's budget poll re-laid from ten cycles to nine (the exit branches
land where block layout puts them), so pureboot.window.autobaud
measured -10 % until AUTOBAUD_POLL_CYCLES and the README's derived
seconds were re-measured — the default autobaud window is 36 M cycles,
4.5 s at 8 MHz. Full gate green on all 37 chips; the README's autobaud
column carries each chip's rebuilt worst configuration, machine-checked
against the built trees.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 22:49:55 +02:00
5d1b4497d4 build: the libavr pin advances over the drain and delay contracts
The hand-over drains move to the explicit drain_unbounded() — both link
adapters drain only after their own write, so the frame is in flight by
construction and the bounded default's countdown would be dead bytes;
the images stay byte-identical. window_polls() states its arithmetic
through dev::cycles_for with the whole window converted before the
per-poll division — one truncation instead of one per second, same
instructions, only the countdown's immediate moves. Every size in the
matrix is unchanged; the full gate is green on all 37 chips.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 17:31:22 +02:00
a743ea64a3 tool: a size collision across build trees is an error, not a coin toss
sizes.py merged every owned tree's rows and let the last one win, so a
stale reflect tree — last built before the window constants moved —
reported the atmega8's old stock size over the fresh build and failed
the README check with yesterday's number. Generated and reflect must
answer with the same bytes (the identity invariant), so the same target
measuring two sizes is a stale tree or an identity breach; collect()
refuses now, naming both trees. The stale reflect trees are removed —
the reflect sweep rebuilds them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 16:32:35 +02:00
aa66cfccff pureboot: the poll-cost lookup rides the baud parameter
usart_of<0> is an incomplete type on the USART-less chips, and a static
member initializer with only non-dependent operands is checked when the
template is parsed, not when it is instantiated — so the address probe
broke every tiny build without hardware_link ever being named. The
lookup moves into a member function template taking the link's own baud
parameter, the dependence carrier that defers it to instantiation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 16:13:44 +02:00
459d463283 pureboot: the classic megas' idle poll is a bit-skip — 7 cycles, and their stock window goes wide
The window gate's first full sweep caught it on the atmega8: 6.22 s
measured against 8 declared, the exact 7/9 of a poll modeled as an
extended-I/O lds + skip on a chip whose UCSRA sits in bit-addressable
I/O and compiles to a 2-cycle skip. poll_cycles now follows the status
register's home (7 below 0x40, 9 above). At 16 MHz over 7 cycles the
poll count no longer fits uint24_t, so the classic megas' stock windows
take the wide countdown — 8.000 s measured on all three, +4 B of stock
image (m8 362, m16/m32 364), README stock rows updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 16:11:34 +02:00
8e7cc86fb3 pureboot: the activation window gets a behavioral gate, and honest per-poll constants under it
The window's per-poll cycle counts were hand-counted for a uint32_t
countdown, but every default window fits uint24_t, whose decrement chain
is one sbci shorter — so deployed loaders ran 9/10ths of their stated
seconds (a 328P's 8 s was 7.2 s on the wire). No golden-asm pin can hold
this: the loops compile in consumer context. pbwindow.py measures the
behavior instead: it installs a real application beside the loader
through the host tool's own plan_flash (surgery included), starts the
simulator with the line idle, and reads the cycle of the first transmit
— the application's banner, so that cycle is the window. Held at plus or
minus 2 percent per chip (pureboot.window), red at -10.0 percent against
the old constants, green with poll_cycles now counted for the narrow
countdown (hardware 9, software 7; window_polls() solves narrow-first
and adds the wide loop's cycle where the count forces uint32_t — a count
narrow only at the wide cost stays wide, so the choice cannot
oscillate). The autobaud window is its poll budget at the measured ten
cycles a poll, gated the same way (pureboot.window.autobaud), and the
README carries that arithmetic now. No version bump: timing-window
precision is not meaningful behavior, v7 stays.

The gate flushed out two runner gaps. The software bridge accepted any
falling edge as a start bit, so the device's own TX-init glitch decoded
as a stray byte; it re-samples mid-bit now and abandons a false start,
as silicon does. And after avr_reset, the idle-line re-raise was
silently dropped: ioport pin irqs are IRQ_FLAG_FILTERED and the irq's
cached value survives the reset the port latch does not, so the device
read the line stuck low, calibrate() measured reset-to-first-edge as one
wrapping pulse, and the first knock after a reset could boot the
application instead of locking — the intermittent autobaud failure.
bridge_reset forces a real transition (0 then 1, no cycles between).

The README's Autobaud column now carries each chip's worst
configuration — autobaud with OSCCAL baked, on a USART's own pins where
the chip has one (tinies: autobaud + OSCCAL) — the numbers the existing
pureboot_autobaud_osccal[_on_usart0] matrix points already gate;
sizes.py checks the column against exactly those targets. Tool sizes
and window prose updated with it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 16:05:42 +02:00
c8ac61779e tool: survive our own leftovers — drain the fresh port, shorten the identity read
--stay leaves the loader's final prompt in the USB pipeline; a fresh
invocation on a board that resets when its port opens then flushes too
early, trusts the stale prompt, and spends the new activation window on
a 2-second identity read against a device that never heard its knock —
collecting the application's banner as an unknown signature. Three
host-side moves, no device bytes: the line is drained until quiet
(bounded, 250 ms) before the port's first knock — once per port, since a
mid-session re-knock faces no foreign bytes and its own window is
already burning; the identity read_exact drops 2.0 to 0.5 s, dozens of
times the worst real answer, so any false prompt match leaves room for
the retry that already works; and the tool version drifts to 8. The
StaleDTRPort fixture models the whole moment — stale prompt in transit,
reset holding the device off the line, a finite window, the banner —
red against the old tool in exactly the field shape (unknown signature
from banner bytes), green now; LoaderPort answers its prompt to the
knock rather than to a read count, which the drain exposed as a
call-order coupling.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 16:05:16 +02:00
11 changed files with 495 additions and 68 deletions

View File

@@ -202,6 +202,21 @@ if(PROJECT_IS_TOP_LEVEL)
${CMAKE_BINARY_DIR}/pbtest-work) ${CMAKE_BINARY_DIR}/pbtest-work)
set_tests_properties(pureboot.protocol PROPERTIES TIMEOUT 180) set_tests_properties(pureboot.protocol PROPERTIES TIMEOUT 180)
# The activation window as a measured duration: application installed,
# line idle, the first transmit is the application's banner — its
# cycle is the window the source declares, held to ±2 % (one
# mis-counted cycle per poll is a 10 % shift).
add_test(NAME pureboot.window
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/pbwindow.py
--device ${PB_DEVICE} --loader $<TARGET_FILE:pureboot>
--mcu ${PUREBOOT_SIM_MCU} --hz ${_pb_stock_hz}
--base ${PUREBOOT_BASE_HEX} --page ${PUREBOOT_PAGE}
--baud ${_pb_stock_baud} --app $<TARGET_FILE:pbapp>.bin
--seconds ${PUREBOOT_TIMEOUT}
--tool ${CMAKE_CURRENT_SOURCE_DIR}/pureboot/pureboot.py
--workdir ${CMAKE_BINARY_DIR}/pbwindow-work)
set_tests_properties(pureboot.window PROPERTIES TIMEOUT 300)
# The position-independence acceptance test: the identical image, # The position-independence acceptance test: the identical image,
# installed one slot lower, must serve the full command set. # installed one slot lower, must serve the full command set.
add_test(NAME pureboot.reloc add_test(NAME pureboot.reloc
@@ -543,5 +558,19 @@ if(PROJECT_IS_TOP_LEVEL)
1000000 9600 ${CMAKE_CURRENT_SOURCE_DIR}/pureboot/pureboot.py 1000000 9600 ${CMAKE_CURRENT_SOURCE_DIR}/pureboot/pureboot.py
${CMAKE_BINARY_DIR}/pbautobaud-work) ${CMAKE_BINARY_DIR}/pbautobaud-work)
set_tests_properties(pureboot.autobaud PROPERTIES TIMEOUT 240) set_tests_properties(pureboot.autobaud PROPERTIES TIMEOUT 240)
# The autobaud window: the calibration poll budget, at the measured
# 10 cycles a poll (pbwindow.py pins the constant the README's
# seconds arithmetic uses; the budget itself is the clock-free knob).
add_test(NAME pureboot.window.autobaud
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/pbwindow.py
--device ${PB_DEVICE} --loader $<TARGET_FILE:pureboot_autobaud>
--mcu ${PUREBOOT_SIM_MCU} --hz 1000000
--base ${PUREBOOT_BASE_HEX} --page ${PUREBOOT_PAGE}
--baud 9600 --app $<TARGET_FILE:pbapp_autobaud>.bin
--autobaud-polls 4000000 --link sw
--tool ${CMAKE_CURRENT_SOURCE_DIR}/pureboot/pureboot.py
--workdir ${CMAKE_BINARY_DIR}/pbwindow-autobaud-work)
set_tests_properties(pureboot.window.autobaud PROPERTIES TIMEOUT 300)
endif() endif()
endif() endif()

2
libavr

Submodule libavr updated: 56337f37de...a9fe6bed50

View File

@@ -376,9 +376,18 @@ function(pureboot_add_loader name)
# load-immediate it saves. The set is fitted to the loader's body and has to # load-immediate it saves. The set is fitted to the loader's body and has to
# be re-measured when that body changes: -fno-move-loop-invariants belonged # be re-measured when that body changes: -fno-move-loop-invariants belonged
# here while the command loop carried four transfer bodies and costs bytes # here while the command loop carried four transfer bodies and costs bytes
# now that it carries one. # now that it carries one, and -fno-ivopts is fitted per backend — an
target_compile_options(${name} PRIVATE # autobaud body needs ivopts to keep the calibration countdown a single
-fno-ivopts -fira-algorithm=priority -fno-tree-ter -fno-split-wide-types) # induction variable (without it the counter is duplicated and the
# measurement loop runs 9 cycles instead of its contracted 7), while the
# fixed-baud bodies still measure smaller with it off.
if(PB_SERIAL STREQUAL "autobaud")
target_compile_options(${name} PRIVATE
-fira-algorithm=priority -fno-tree-ter -fno-split-wide-types)
else()
target_compile_options(${name} PRIVATE
-fno-ivopts -fira-algorithm=priority -fno-tree-ter -fno-split-wide-types)
endif()
target_link_options(${name} PRIVATE -nostartfiles -Wl,--section-start=.text=${_base_hex} target_link_options(${name} PRIVATE -nostartfiles -Wl,--section-start=.text=${_base_hex}
-Wl,--defsym=pureboot_app=${_app} ${_wrap}) -Wl,--defsym=pureboot_app=${_app} ${_wrap})
add_custom_command(TARGET ${name} POST_BUILD COMMAND ${CMAKE_SIZE} $<TARGET_FILE:${name}>) add_custom_command(TARGET ${name} POST_BUILD COMMAND ${CMAKE_SIZE} $<TARGET_FILE:${name}>)

View File

@@ -19,42 +19,42 @@ come out byte-identical linked at a different base.
## Chips ## Chips
Sizes are the default configuration: the hardware USART0 at 115200 8N1 on a The Stock column is the default configuration: the hardware USART0 at 115200
16 MHz crystal, or the software UART on RX = PB0 / TX = PB1 at 57600 8N1 on 8N1 on a 16 MHz crystal, or the software UART on RX = PB0 / TX = PB1 at
the tinies' RC oscillator (9.6 MHz on the t13s, 8 MHz above). Every axis moves 57600 8N1 on the tinies' RC oscillator (9.6 MHz on the t13s, 8 MHz above).
per build — see *Configuration*. The autobaud column is the clock-free build, Every axis moves per build — see *Configuration*. The Autobaud column is the
which is the largest the space produces and the tightest fit in the matrix; worst configuration the space produces for the chip: the clock-free build —
it carries the calibration machinery and no clock at all. it alone carries the calibration machinery — with the `OSCCAL` trim baked
and, where the chip has a USART, the link deployed on that USART's own pins,
which the loader then has to release (*Pin ownership*). On default pins
without the trim the same loaders run 410 B smaller.
| Chip | Flash | Loader at | Link | Stock | Autobaud | | Chip | Flash | Loader at | Link | Stock | Autobaud |
|---|---|---|---|---|---| |---|---|---|---|---|---|
| ATtiny13, ATtiny13A † | 1 KiB | 0x0200 | software | 384 B | 452 B | | ATtiny13, ATtiny13A † | 1 KiB | 0x0200 | software | 384 B | 474 B |
| ATtiny25 † | 2 KiB | 0x0600 | software | 388 B | 442 B | | ATtiny25 † | 2 KiB | 0x0600 | software | 388 B | 466 B |
| ATtiny45 † | 4 KiB | 0x0e00 | software | 388 B | 442 B | | ATtiny45 † | 4 KiB | 0x0e00 | software | 388 B | 466 B |
| ATtiny85 † | 8 KiB | 0x1e00 | software | 388 B | 442 B | | ATtiny85 † | 8 KiB | 0x1e00 | software | 388 B | 466 B |
| ATmega8, 8A | 8 KiB | 0x1e00 | USART0 | 358 B | 470 B | | ATmega8, 8A | 8 KiB | 0x1e00 | USART0 | 362 B | 494 B |
| ATmega16, 16A | 16 KiB | 0x3e00 | USART0 | 360 B | 474 B | | ATmega16, 16A | 16 KiB | 0x3e00 | USART0 | 364 B | 496 B |
| ATmega32, 32A | 32 KiB | 0x7e00 | USART0 | 360 B | 474 B | | ATmega32, 32A | 32 KiB | 0x7e00 | USART0 | 364 B | 496 B |
| ATmega48, 48A, 48P, 48PA † | 4 KiB | 0x0e00 | USART0 | 378 B | 438 B | | ATmega48, 48A, 48P, 48PA † | 4 KiB | 0x0e00 | USART0 | 378 B | 468 B |
| ATmega88, 88A, 88P, 88PA | 8 KiB | 0x1e00 | USART0 | 388 B | 448 B | | ATmega88, 88A, 88P, 88PA | 8 KiB | 0x1e00 | USART0 | 388 B | 478 B |
| ATmega168, 168A, 168P, 168PA | 16 KiB | 0x3e00 | USART0 | 390 B | 454 B | | ATmega168, 168A, 168P, 168PA | 16 KiB | 0x3e00 | USART0 | 390 B | 480 B |
| ATmega328, 328P | 32 KiB | 0x7e00 | USART0 | 390 B | 454 B | | ATmega328, 328P | 32 KiB | 0x7e00 | USART0 | 390 B | 480 B |
| ATmega164A, 164P, 164PA | 16 KiB | 0x3e00 | USART0 | 390 B | 454 B | | ATmega164A, 164P, 164PA | 16 KiB | 0x3e00 | USART0 | 390 B | 480 B |
| ATmega324A, 324P, 324PA | 32 KiB | 0x7e00 | USART0 | 390 B | 454 B | | ATmega324A, 324P, 324PA | 32 KiB | 0x7e00 | USART0 | 390 B | 480 B |
| ATmega644, 644A, 644P, 644PA | 64 KiB | 0xfe00 | USART0 | 384 B | 448 B | | ATmega644, 644A, 644P, 644PA | 64 KiB | 0xfe00 | USART0 | 384 B | 474 B |
| ATmega1284, 1284P | 128 KiB | 0x1fe00 | USART0 | 410 B | 474 B | | ATmega1284, 1284P | 128 KiB | 0x1fe00 | USART0 | 410 B | 502 B |
† No hardware boot section: the host patches the reset vector, and the budget † No hardware boot section: the host patches the reset vector, and the budget
is 510 bytes, since the slot's last word is the trampoline. is 510 bytes, since the slot's last word is the trampoline.
The tightest fit in the whole space is the 1284s' autobaud build deployed on a The tightest fit in the whole space is therefore the 1284s' 502 of their
USART's own pins with the `OSCCAL` trim baked, 484 of its 512 — they alone 512: they alone carry the far-flash machinery (ELPM reads, RAMPZ page
carry the far-flash machinery (ELPM reads, RAMPZ page commands), autobaud commands) on top of everything the column already stacks. The flash bank
alone carries the calibration loop, a bit-banged link on a USART's pins alone riding in a transfer's selector byte keeps even those chips' addressing the
has to release it (below), and the trim adds its one register write. Without same 16-bit form every other chip uses, which is why they are no longer the
the trim that build is 478; on the default pins, 474. The flash bank riding
in a transfer's selector byte keeps even those chips' addressing the same
16-bit form every other chip uses, which is why they are no longer the
outlier they were. outlier they were.
The software UART enables the RX pull-up; TX idles high. All multi-byte wire The software UART enables the RX pull-up; TX idles high. All multi-byte wire
@@ -100,7 +100,10 @@ where a fixed-baud software build has to be rebuilt per clock and still drifts
out of tolerance. The cost is that it is software-serial only (a hardware USART out of tolerance. The cost is that it is software-serial only (a hardware USART
needs its divisor programmed) and that activation counts poll iterations rather needs its divisor programmed) and that activation counts poll iterations rather
than seconds, since there is no clock to convert them against than seconds, since there is no clock to convert them against
(`PUREBOOT_AUTOBAUD_POLLS`, default 4,000,000). (`PUREBOOT_AUTOBAUD_POLLS`, default 4,000,000). The wait spends nine cycles a
poll (measured, and held by the `pureboot.window.autobaud` gate), so the
default window is 36 M cycles: 4.5 s at 8 MHz, 3.75 s at 9.6 MHz, 36 s at
1 MHz.
**Pick the rate by cycles a bit, and leave the oscillator room.** What the **Pick the rate by cycles a bit, and leave the oscillator room.** What the
calibration can measure is bounded by how many clock cycles one bit lasts, so a calibration can measure is bounded by how many clock cycles one bit lasts, so a

View File

@@ -10,6 +10,8 @@
// is what makes a copy one slot below able to rewrite the resident one, and // is what makes a copy one slot below able to rewrite the resident one, and
// every change here has to keep it (test/check_pi.py). // every change here has to keep it (test/check_pi.py).
#include <chrono>
#include <libavr/libavr.hpp> #include <libavr/libavr.hpp>
using namespace avr::literals; using namespace avr::literals;
@@ -171,9 +173,21 @@ template <avr::hertz_t C, avr::baud_t B>
struct hardware_link { struct hardware_link {
using uart = avr::uart::usart<usart_unit, C, {.baud = B, .max_baud_error = 2.5_pct}>; using uart = avr::uart::usart<usart_unit, C, {.baud = B, .max_baud_error = 2.5_pct}>;
// The compiled idle poll: lds UCSR0A (2), sbrc skipping the exit (2), // The compiled idle poll around the window's narrow (uint24_t) countdown:
// sbiw + sbci + sbci + brne (6). // the RXC test, then sbiw + sbci + brne (5). The test's cost follows the
static constexpr std::uint8_t poll_cycles = 10; // status register's home — a 2-cycle bit-skip where UCSRnA sits in
// bit-addressable I/O (the classic megas), lds + skip (4) in extended
// I/O. A uint32_t countdown pays one more sbci — window_polls() adds it
// where the count forces the wide type. Held by the pureboot.window gate.
// The lookup rides the baud parameter so it stays dependent: the trait is
// an incomplete type on the USART-less chips, which parse this template
// without ever instantiating it.
template <avr::baud_t Baud, typename U = avr::hw::usart_of<usart_unit>>
static consteval std::uint8_t poll_cost()
{
return U::ucsra::addr < 0x40 ? 7 : 9;
}
static constexpr std::uint8_t poll_cycles = poll_cost<B>();
static void init() static void init()
{ {
@@ -197,7 +211,10 @@ struct hardware_link {
static void drain() static void drain()
{ {
uart::drain(); // A drain here always follows this link's own write — the frame is
// in flight by construction, so the completion the wait needs is
// guaranteed and the bounded default's countdown would be dead bytes.
uart::drain_unbounded();
} }
}; };
@@ -206,9 +223,11 @@ struct software_link {
using rx_t = avr::uart::software_rx_polled<C, avr::PUREBOOT_RX, B>; using rx_t = avr::uart::software_rx_polled<C, avr::PUREBOOT_RX, B>;
using tx_t = avr::uart::software_tx<C, avr::PUREBOOT_TX, B>; using tx_t = avr::uart::software_tx<C, avr::PUREBOOT_TX, B>;
// The compiled idle poll: sbis skipping the exit (2), sbiw + sbci + // The compiled idle poll around the window's narrow (uint24_t) countdown:
// sbci + brne (6). // sbis skipping the exit (2), sbiw + sbci + brne (5). A uint32_t
static constexpr std::uint8_t poll_cycles = 8; // countdown pays one more sbci — window_polls() adds it where the count
// forces the wide type. Held by the pureboot.window gate.
static constexpr std::uint8_t poll_cycles = 7;
static void init() static void init()
{ {
@@ -263,7 +282,10 @@ struct autobaud_link {
static void drain() static void drain()
{ {
uart::drain(); // A drain here always follows this link's own write — the frame is
// in flight by construction, so the completion the wait needs is
// guaranteed and the bounded default's countdown would be dead bytes.
uart::drain_unbounded();
} }
}; };
@@ -317,17 +339,35 @@ void await_host()
} }
} }
#else #else
// The window as one 32-bit countdown, divided by the backend's counted // The window as one countdown, divided by the backend's counted poll-loop
// poll-loop cycles. Whole seconds is all it promises. // cycles. Whole seconds is all it promises. The per-poll cost depends on the
// countdown's own width (a uint32_t decrement chain is one sbci longer), and
// the width depends on the poll count — solved narrow-first: a count that
// fits 24 bits at the narrow cost keeps the narrow loop, anything else takes
// the wide loop at its own cost. A count fitting 24 bits only at the wide
// cost stays wide, so the choice cannot oscillate on the boundary.
consteval std::uint32_t polls_at(std::uint32_t per_poll)
{
// Whole-window cycles first, then the per-poll division: one truncation
// instead of one per second. Same instructions either way — only the
// countdown's immediate moves.
return static_cast<std::uint32_t>(dev::cycles_for<std::chrono::seconds{timeout_seconds}>() / per_poll);
}
consteval bool narrow_window()
{
return polls_at(link::poll_cycles) <= 0xffffff;
}
consteval std::uint32_t window_polls() consteval std::uint32_t window_polls()
{ {
return timeout_seconds * static_cast<std::uint32_t>(dev::clock.hz / link::poll_cycles); return polls_at(narrow_window() ? link::poll_cycles : link::poll_cycles + 1u);
} }
// The countdown in the narrowest type that holds it: a fourth byte would // The countdown in the narrowest type that holds it: a fourth byte would
// cost a wider decrement chain at every poll for range most windows never // cost a wider decrement chain at every poll for range most windows never
// use (the autobaud budget makes the same choice). // use (the autobaud budget makes the same choice).
using window_t = std::conditional_t<window_polls() <= 0xffffff, avr::uint24_t, std::uint32_t>; using window_t = std::conditional_t<narrow_window(), avr::uint24_t, std::uint32_t>;
bool pending_before_deadline() bool pending_before_deadline()
{ {

View File

@@ -26,7 +26,7 @@ else:
import termios import termios
PROMPT = b"+" PROMPT = b"+"
VERSION = 7 # this tool's own version — free to drift from a loader's VERSION = 8 # this tool's own version — free to drift from a loader's
# The loader versions this tool can drive. A pureboot version implies its wire # The loader versions this tool can drive. A pureboot version implies its wire
# protocol, which carries no number of its own, so this window is where that # protocol, which carries no number of its own, so this window is where that
# map lives: the tool keeps a decoder for every generation in it (14 speak # map lives: the tool keeps a decoder for every generation in it (14 speak
@@ -546,6 +546,11 @@ class Loader:
# Set once a session is established over an autobaud link, so a # Set once a session is established over an autobaud link, so a
# re-entry after 'J' repeats the handshake that worked. # re-entry after 'J' repeats the handshake that worked.
self.autobaud = False self.autobaud = False
# The pre-knock drain runs once per port: the bytes it exists for are
# leftovers from before this process opened the port. Re-knocks later
# in the same session must not pay it — a fresh activation window is
# already burning while they wait.
self._line_drained = False
# The link this session is speaking. It moves when the host follows a # The link this session is speaking. It moves when the host follows a
# staging copy built for another one (enter_copy). # staging copy built for another one (enter_copy).
self.baud = getattr(port, "baud", None) self.baud = getattr(port, "baud", None)
@@ -555,10 +560,16 @@ class Loader:
"""The 'b' reply, in either of the two layouts a loader may send. """The 'b' reply, in either of the two layouts a loader may send.
pureboot 5 answers with its version and the signature; older loaders pureboot 5 answers with its version and the signature; older loaders
answer with a 12-byte block. The version byte cannot be mistaken for answer with a 12-byte block. The version byte cannot be mistaken for
the older block's 'P', so four bytes are enough to tell them apart.""" the older block's 'P', so four bytes are enough to tell them apart.
head = self.port.read_exact(4, 2.0)
The timeout is short on purpose: a real answer follows the prompt
within a frame time or two, so half a second is dozens of times the
worst case — while a *false* prompt match (a stale byte, reset
garbage) makes this read collect noise, and every second spent on it
comes out of the activation window the retry needs."""
head = self.port.read_exact(4, 0.5)
if head[0:2] == b"PB": if head[0:2] == b"PB":
return Info(head + self.port.read_exact(8, 2.0)) return Info(head + self.port.read_exact(8, 0.5))
return Info.from_identity(head) return Info.from_identity(head)
def _handshake(self, wait, knock, what): def _handshake(self, wait, knock, what):
@@ -569,8 +580,23 @@ class Loader:
into a fresh window, where a command without its knock is discarded. into a fresh window, where a command without its knock is discarded.
Each attempt is therefore the whole handshake. This also converges into Each attempt is therefore the whole handshake. This also converges into
an already-live session: the knock bytes are ignored there and the an already-live session: the knock bytes are ignored there and the
drain absorbs whatever they produced.""" drain absorbs whatever they produced.
Before the port's first knock ever, the line is drained until quiet: a
prompt from a previous session (`--stay`) can still be in the USB
pipeline when the port opens, where a flush cannot clear what has not
arrived yet — and on a board that resets when its port opens, trusting
that stale byte would spend the fresh activation window reading noise
from a device that never heard the knock. Once only, and bounded:
later re-knocks in this session face no foreign leftovers, and their
own window is already burning."""
deadline = time.monotonic() + wait deadline = time.monotonic() + wait
if not self._line_drained:
self._line_drained = True
drain = time.monotonic() + 0.25
while self.port.read_available(0.05):
if time.monotonic() > drain:
break
knocks = 0 knocks = 0
refusal = None refusal = None
while True: while True:

View File

@@ -8,10 +8,13 @@ import subprocess
class Device: class Device:
def __init__(self, binary, elf, mcu, hz, base_hex, page, baud, dump, reset_hex=None, resume=None, link=None): def __init__(self, binary, elf, mcu, hz, base_hex, page, baud, dump, reset_hex=None, resume=None, link=None,
window=False):
cmd = [binary] cmd = [binary]
if link: if link:
cmd += ["-l", link] cmd += ["-l", link]
if window:
cmd.append("-w") # report the first-transmit cycle, free-run idle
cmd += [elf, mcu, hz, base_hex, str(page), str(baud), dump] cmd += [elf, mcu, hz, base_hex, str(page), str(baud), dump]
if reset_hex is not None or resume is not None: if reset_hex is not None or resume is not None:
# Chips without a hardware boot section — the tinies and the # Chips without a hardware boot section — the tinies and the

137
test/pbwindow.py Normal file
View File

@@ -0,0 +1,137 @@
#!/usr/bin/env python3
"""The activation window as a behavioral duration gate.
The loader's window is a counted poll loop whose per-poll cost is hand-counted
in the source (`link::poll_cycles`) — but the loop compiles in consumer
context, so only the running image can prove the count. This test installs a
real application beside the loader (the host tool's own `plan_flash` supplies
the reset-vector surgery), starts the simulator with the line idle, and reads
the cycle of the first transmit activity: nothing talks until the window
closes and the application banners, so that cycle *is* the window, give or
take a banner lead measured in microseconds. Asserted at ±2 % — one
mis-counted cycle per poll shifts a window by 10 % and more.
Fixed-baud loaders declare their window in seconds (--seconds, the build's
TIMEOUT). The autobaud loader's window is its calibration poll budget
(--autobaud-polls); the seconds it amounts to are budget × 10 / f_cpu, the
measured cost of the calibrate() wait loop this gate pins.
"""
import argparse
import importlib.util
import pathlib
import select
import sys
import time
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
from pbsim import Device
# The calibrate() budget loop's cycles per poll in the built image — what the
# README's window arithmetic rests on, verified here. A measured fact, not a
# design constant: the wait's exit branches land where the compiler's block
# layout puts them, and the bounded-calibration rework moved the loop from
# ten cycles to nine.
AUTOBAUD_POLL_CYCLES = 9
def load_tool(path):
spec = importlib.util.spec_from_file_location("pureboot", path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def compose_flash(pb, loader_bytes, app_bytes, mcu, base, page):
"""The flash image a completed programming session leaves: application
(with the tinies' vector surgery), loader at base — built through the
host tool's own planner so the surgery is the shipped one, not a copy."""
flash_size = base + pb.SLOT
patch = not mcu.startswith("atmega") or mcu.startswith("atmega48")
word_flash = flash_size > 0x10000
wire_base = base // 2 if word_flash else base
flags = (1 if patch else 0) | (2 if word_flash else 0)
raw = bytes((ord("P"), ord("B"), 5, 0, 0, 0, page & 0xFF,
wire_base & 0xFF, wire_base >> 8, 0, 0, flags))
info = pb.Info(raw)
flash = bytearray(b"\xff" * flash_size)
for address, content in pb.plan_flash(app_bytes, info).items():
flash[address:address + len(content)] = content
flash[base:base + len(loader_bytes)] = loader_bytes
return bytes(flash)
def first_tx_cycle(device, deadline):
"""The PB_WINDOW_TX report, or None. The runner prints it once."""
stream = device.proc.stdout
while True:
remaining = deadline - time.monotonic()
if remaining <= 0:
return None
ready, _, _ = select.select([stream], [], [], remaining)
if not ready:
return None
line = stream.readline()
if not line:
return None
if line.startswith("PB_WINDOW_TX"):
return int(line.split()[1])
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--device", required=True)
parser.add_argument("--loader", required=True)
parser.add_argument("--mcu", required=True)
parser.add_argument("--hz", type=int, required=True)
parser.add_argument("--base", required=True)
parser.add_argument("--page", type=int, required=True)
parser.add_argument("--baud", type=int, required=True)
parser.add_argument("--app", required=True)
parser.add_argument("--tool", required=True)
parser.add_argument("--workdir", required=True)
parser.add_argument("--link", default=None)
parser.add_argument("--seconds", type=float, default=None)
parser.add_argument("--autobaud-polls", type=int, default=None)
args = parser.parse_args()
if (args.seconds is None) == (args.autobaud_polls is None):
parser.error("exactly one of --seconds / --autobaud-polls")
pb = load_tool(args.tool)
base = int(args.base, 0)
expected = (args.seconds if args.seconds is not None
else args.autobaud_polls * AUTOBAUD_POLL_CYCLES / args.hz)
work = pathlib.Path(args.workdir)
work.mkdir(parents=True, exist_ok=True)
# Every loader target objcopies its slot content beside the ELF (.bin).
loader_bytes = pathlib.Path(args.loader + ".bin").read_bytes()
app_bytes = pathlib.Path(args.app).read_bytes()
flash_file = work / "window-flash.bin"
flash_file.write_bytes(compose_flash(pb, loader_bytes, app_bytes, args.mcu, base, args.page))
device = Device(args.device, args.loader, args.mcu, str(args.hz), args.base, args.page,
args.baud, str(work / "window-dump.bin"), resume=str(flash_file),
link=args.link, window=True)
try:
# Simulation speed is machine-dependent; a few hundred thousand
# cycles per wall second is the pessimistic floor.
budget = max(60.0, expected * args.hz / 300000)
cycle = first_tx_cycle(device, time.monotonic() + budget)
finally:
device.stop()
if cycle is None:
print(f" [FAIL] no transmit activity within {budget:.0f} s wall "
f"(expected a {expected:.2f} s window)")
return 1
measured = cycle / args.hz
error = (measured - expected) / expected
ok = abs(error) <= 0.02
print(f" [{'PASS' if ok else 'FAIL'}] window {measured:.3f} s vs declared "
f"{expected:.3f} s ({error:+.1%}, gate ±2%)")
return 0 if ok else 1
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -62,6 +62,29 @@ const char *dump_path;
std::uint32_t reset_pc; std::uint32_t reset_pc;
volatile std::sig_atomic_t reset_requested; volatile std::sig_atomic_t reset_requested;
// -w: report the cycle of the first transmit activity, once. What the
// activation-window gate reads — with an idle line and an application
// installed, the first thing that ever talks is the application's banner,
// so this cycle *is* the loader's window plus a banner lead measured in
// microseconds. Idle pacing is skipped in this mode: there is no real-time
// host in the loop, and a paced multi-second window would take hours.
bool window_report;
bool window_tx_seen;
void window_first_tx()
{
if (!window_report || window_tx_seen)
return;
window_tx_seen = true;
std::println("PB_WINDOW_TX {}", avr->cycle);
std::fflush(stdout);
}
void window_uart_hook(avr_irq_t *, std::uint32_t, void *)
{
window_first_tx();
}
int parse_link(std::string_view spec) int parse_link(std::string_view spec)
{ {
if (spec == "usart0" || spec == "usart1") { if (spec == "usart0" || spec == "usart1") {
@@ -195,6 +218,20 @@ std::uint8_t tx_shift;
avr_cycle_count_t tx_sample(avr_t *, avr_cycle_count_t when, void *) avr_cycle_count_t tx_sample(avr_t *, avr_cycle_count_t when, void *)
{ {
if (tx_bit < 0) {
// Half a bit into the start bit: a real receiver re-samples here and
// abandons a false start. The device's own init produces one — DDR
// drives the pin low for the instructions until the idle level is
// written — and without this check that glitch decodes as a stray
// byte (and would read as first transmit activity under -w).
if (tx_level) {
tx_active = 0;
return 0;
}
window_first_tx();
tx_bit = 0;
return when + bit_cycles;
}
if (tx_bit < 8) { if (tx_bit < 8) {
tx_shift = static_cast<std::uint8_t>((tx_shift >> 1) | (tx_level ? 0x80 : 0)); tx_shift = static_cast<std::uint8_t>((tx_shift >> 1) | (tx_level ? 0x80 : 0));
if (++tx_bit < 8) if (++tx_bit < 8)
@@ -256,10 +293,10 @@ void tx_hook(avr_irq_t *, std::uint32_t value, void *)
return; return;
} }
int level = value & 1; int level = value & 1;
if (!tx_active && tx_level == 1 && level == 0) { // start edge if (!tx_active && tx_level == 1 && level == 0) { // start edge, confirmed mid-bit
tx_active = 1; tx_active = 1;
tx_bit = 0; tx_bit = -1;
avr_cycle_timer_register(avr, bit_cycles + bit_cycles / 2, tx_sample, nullptr); avr_cycle_timer_register(avr, bit_cycles / 2, tx_sample, nullptr);
} }
tx_level = level; tx_level = level;
} }
@@ -315,7 +352,16 @@ void bridge_reset()
rx_active = 0; rx_active = 0;
tx_active = 0; tx_active = 0;
tx_level = 1; tx_level = 1;
avr_raise_irq(rx_pin, 1); // idle line // Re-drive the idle line through a forced transition: ioport pin irqs are
// IRQ_FLAG_FILTERED, and avr_reset zeroes the port latch while the irq
// keeps its pre-reset cached value — so a plain raise(1) against a cached
// 1 is dropped and the device reads the line stuck low. A loader entering
// calibration on that line measures reset-to-first-edge as one giant
// pulse and mis-locks or boots the application on the first real knock.
// No cycles run between the two raises, so the device only ever sees the
// final idle-high.
avr_raise_irq(rx_pin, 0);
avr_raise_irq(rx_pin, 1);
} }
void poll_pty() void poll_pty()
@@ -364,7 +410,11 @@ void poll_pty()
int main(int argc, char *argv[]) int main(int argc, char *argv[])
{ {
bool link_given = false; bool link_given = false;
for (int opt; (opt = getopt(argc, argv, "l:")) != -1;) { for (int opt; (opt = getopt(argc, argv, "l:w")) != -1;) {
if (opt == 'w') {
window_report = true;
continue;
}
if (opt != 'l' || parse_link(optarg) != 0) { if (opt != 'l' || parse_link(optarg) != 0) {
std::println(stderr, "device: bad link spec (usart0, usart1, sw, or sw:B0,B1 as RX,TX)"); std::println(stderr, "device: bad link spec (usart0, usart1, sw, or sw:B0,B1 as RX,TX)");
return 2; return 2;
@@ -374,10 +424,12 @@ int main(int argc, char *argv[])
int args = argc - optind; int args = argc - optind;
if (args < 7 || args > 9) { if (args < 7 || args > 9) {
std::print(stderr, std::print(stderr,
"usage: {} [-l link] <pureboot.elf> <mcu> <hz> <base_hex> <page> <baud> <flash_dump>" "usage: {} [-l link] [-w] <pureboot.elf> <mcu> <hz> <base_hex> <page> <baud> <flash_dump>"
" [reset_hex] [resume_flash]\n" " [reset_hex] [resume_flash]\n"
" -l link: usart0 | usart1 | sw[:B0,B1[@0]] (RX,TX, then the USART owning\n" " -l link: usart0 | usart1 | sw[:B0,B1[@0]] (RX,TX, then the USART owning\n"
" them); default: the chip's own\n" " them); default: the chip's own\n"
" -w: print PB_WINDOW_TX <cycle> at the first transmit activity and\n"
" free-run idle time (window measurement mode)\n"
" reset_hex: reset vector (default: base with a boot section, else 0)\n" " reset_hex: reset vector (default: base with a boot section, else 0)\n"
" resume_flash: raw full-flash image loaded instead of the ELF — a prior\n" " resume_flash: raw full-flash image loaded instead of the ELF — a prior\n"
" run's dump, for power-fail resume tests\n", " run's dump, for power-fail resume tests\n",
@@ -468,6 +520,9 @@ int main(int argc, char *argv[])
avr_ioctl(avr, AVR_IOCTL_UART_SET_FLAGS(uart_digit), &flags); avr_ioctl(avr, AVR_IOCTL_UART_SET_FLAGS(uart_digit), &flags);
uart_pty_init(avr, &uart_pty); uart_pty_init(avr, &uart_pty);
uart_pty_connect(&uart_pty, uart_digit); uart_pty_connect(&uart_pty, uart_digit);
if (window_report)
avr_irq_register_notify(avr_io_getirq(avr, AVR_IOCTL_UART_GETIRQ(uart_digit), UART_IRQ_OUTPUT),
window_uart_hook, nullptr);
std::println("PB_PTY {}", uart_pty.pty.slavename); std::println("PB_PTY {}", uart_pty.pty.slavename);
} else { } else {
bit_cycles = (avr->frequency + baud / 2) / baud; // matches uart.hpp's own rounding exactly bit_cycles = (avr->frequency + baud / 2) / baud; // matches uart.hpp's own rounding exactly
@@ -524,7 +579,7 @@ int main(int argc, char *argv[])
// entirely. Pace the simulation only while the bridge is fully // entirely. Pace the simulation only while the bridge is fully
// quiet (nothing decoding, nothing queued); transfers keep full // quiet (nothing decoding, nothing queued); transfers keep full
// speed, and a quiet window stretches toward real time. // speed, and a quiet window stretches toward real time.
if (!rx_active && !tx_active && rx_head == rx_tail) if (!window_report && !rx_active && !tx_active && rx_head == rx_tail)
usleep(200); usleep(200);
} }
} }

View File

@@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Host-tool activation handshake: it must not hang on a flooding target. """Host-tool activation handshake: bounded against a line that misbehaves.
`_handshake` drains the line after it sees a prompt, to absorb a real loader's `_handshake` drains the line after it sees a prompt, to absorb a real loader's
trailing bytes before it asks for the identity. That drain must be bounded: a trailing bytes before it asks for the identity. That drain must be bounded: a
@@ -8,6 +8,13 @@ this, ~60 reboots/s of UART-reset garbage in which a stray 0x2b reads as a
prompt — otherwise spins the tool forever. Regression for that hang, plus a prompt — otherwise spins the tool forever. Regression for that hang, plus a
control that a well-behaved loader still connects. control that a well-behaved loader still connects.
The handshake must also survive its own leftovers: after `--stay` the loader's
final prompt can still be in the USB pipeline when the next invocation opens
the port, and on a board wired to reset on open, that opening starts a fresh
activation window the stale prompt then betrays — the tool commits to an
identity read against a device that never heard its knock, and what it finally
collects is the application's banner. StaleDTRPort is that moment as a port.
Stdlib only, no device: host-tool logic, so it runs on every chip's preset Stdlib only, no device: host-tool logic, so it runs on every chip's preset
beside pureboot.planner. beside pureboot.planner.
""" """
@@ -49,27 +56,117 @@ class FloodPort:
class LoaderPort: class LoaderPort:
"""A well-behaved pureboot 5: one prompt to the knock, then quiet, then the """A well-behaved pureboot 5: a prompt to the knock, then quiet, then the
slim identity (version 5 + m328p signature) and a closing prompt.""" slim identity (version 5 + m328p signature) and a closing prompt."""
def __init__(self): def __init__(self):
self.reads = self.exacts = 0 self.pending = b""
self.exacts = 0
def flush_input(self): def flush_input(self):
pass self.pending = b""
def write(self, data): def write(self, data):
pass if b"p" in data:
self.pending = b"+" # the prompt answers the knock, nothing else
def read_available(self, wait): def read_available(self, wait):
self.reads += 1 data, self.pending = self.pending, b""
return b"+" if self.reads == 1 else b"" # prompt once, then settle quiet return data
def read_exact(self, count, timeout): def read_exact(self, count, timeout):
self.exacts += 1 self.exacts += 1
return b"\x05\x1e\x95\x0f" if self.exacts == 1 else b"+" # identity, then prompt return b"\x05\x1e\x95\x0f" if self.exacts == 1 else b"+" # identity, then prompt
class StaleDTRPort:
"""`--stay`, then a fresh invocation on a board that resets when its port
opens. Three facts of that moment, all timed from the open: the previous
session's final prompt is still in transit and lands only after the
opening flush has already run; the reset holds the device off the line
at first, eating anything written before it completes; and the fresh
window is finite — once it expires the application boots and prints a
banner whose bytes are what a pending identity read collects. A
handshake that trusts the stale prompt spends the whole window waiting
on a device that never heard its knock; one that drains the line first
knocks into the real window and connects."""
STALE_AT = 0.02 # the leftover prompt becomes visible (post-flush)
READY_AT = 0.05 # reset complete, activation window opens
WINDOW = 1.0 # window length; expiry boots the application
def __init__(self):
self.t0 = time.monotonic()
# (visible-from, bytes): the line as a timed queue.
self.queue = [(self.t0 + self.STALE_AT, b"+")]
self.armed = False # a 'p' heard inside the window arms 'b'
self.booted = False
def _boot_check(self):
if not self.booted and time.monotonic() > self.t0 + self.READY_AT + self.WINDOW:
self.booted = True
self.queue.append((self.t0 + self.READY_AT + self.WINDOW,
b"W r libavr tempmon\r\n"))
def _visible(self):
self._boot_check()
now = time.monotonic()
return b"".join(d for t, d in self.queue if t <= now)
def _consume(self, n):
now = time.monotonic()
left = []
for t, d in self.queue:
if t <= now and n:
take = min(n, len(d))
d = d[take:]
n -= take
if d:
left.append((t, d))
self.queue = left
def flush_input(self):
self._consume(len(self._visible()))
def write(self, data):
self._boot_check()
now = time.monotonic()
if now < self.t0 + self.READY_AT or self.booted:
return # still in reset, or the application owns the line
if b"p" in data:
self.armed = True
self.queue.append((now + 0.01, b"+"))
if b"b" in data and self.armed:
# The slim identity (version 5 + m328p signature) and a prompt.
self.queue.append((now + 0.01, b"\x05\x1e\x95\x0f+"))
def read_available(self, wait):
deadline = time.monotonic() + wait
while True:
data = self._visible()
if data:
self._consume(len(data))
return data
if time.monotonic() >= deadline:
return b""
time.sleep(0.005)
def read_exact(self, count, timeout):
deadline = time.monotonic() + timeout
data = b""
while len(data) < count:
visible = self._visible()
if visible:
take = visible[:count - len(data)]
self._consume(len(take))
data += take
elif time.monotonic() >= deadline:
raise pb.Error(f"timeout: got {len(data)} of {count} bytes")
else:
time.sleep(0.005)
return data
def terminates(port, wait, budget): def terminates(port, wait, budget):
"""Run connect_autobaud in a thread; True if it returns/raises within """Run connect_autobaud in a thread; True if it returns/raises within
`budget` seconds rather than hanging.""" `budget` seconds rather than hanging."""
@@ -97,6 +194,16 @@ def main():
info = pb.Loader(LoaderPort()).connect_autobaud(2.0) info = pb.Loader(LoaderPort()).connect_autobaud(2.0)
check("well-behaved loader still connects (version 5)", info.version == 5) check("well-behaved loader still connects (version 5)", info.version == 5)
# the stale prompt: a --stay leftover plus reset-on-open must not burn the
# fresh window — the pre-knock drain absorbs it and the first real knock
# lands inside the window.
try:
stale_ok = pb.Loader(StaleDTRPort()).connect(2.5).version == 5
except pb.Error as failed:
print(f" ({failed})")
stale_ok = False
check("stale --stay prompt + reset-on-open: connects in the fresh window", stale_ok)
print(f"\n {P} passed, {F} failed") print(f"\n {P} passed, {F} failed")
return 1 if F else 0 return 1 if F else 0

View File

@@ -84,6 +84,20 @@ def collect() -> dict[str, list[tuple[str, int, int]]]:
for match in SIZE_TEST.finditer((tree / "CTestTestfile.cmake").read_text()): for match in SIZE_TEST.finditer((tree / "CTestTestfile.cmake").read_text()):
found.setdefault(chip, []).append((match["name"], match["elf"], int(match["limit"]))) found.setdefault(chip, []).append((match["name"], match["elf"], int(match["limit"])))
sizes = measure([elf for rows in found.values() for _, elf, _ in rows], tool) sizes = measure([elf for rows in found.values() for _, elf, _ in rows], tool)
# A chip's generated and reflect trees must answer with the same bytes
# (the identity invariant), so the same target measuring two sizes means
# a stale tree — or an identity breach. Either is a finding; picking one
# silently is how a gate reports another build's numbers as today's.
for chip, rows in found.items():
seen: dict[str, tuple[int, str]] = {}
for name, elf, _ in rows:
if elf not in sizes:
continue
if name in seen and seen[name][0] != sizes[elf]:
sys.exit(f"{chip} {name}: {seen[name][0]} B in {seen[name][1]} but "
f"{sizes[elf]} B in {elf} — a stale tree (rebuild or remove it) "
f"or a cross-mode identity breach")
seen.setdefault(name, (sizes[elf], elf))
measured = { measured = {
chip: sorted(((name, sizes[elf], limit) for name, elf, limit in rows if elf in sizes), chip: sorted(((name, sizes[elf], limit) for name, elf, limit in rows if elf in sizes),
key=lambda row: -row[1]) key=lambda row: -row[1])
@@ -120,7 +134,9 @@ def cmd_max(args) -> int:
def cmd_check_readme(args) -> int: def cmd_check_readme(args) -> int:
"""The README's per-chip table, against the stock and autobaud builds.""" """The README's per-chip table, against the stock build and the worst
autobaud configuration (OSCCAL baked, plus the USART-pin release where
the chip has a USART) — the config the Autobaud column documents."""
readme = (ROOT / "pureboot" / "README.md").read_text() readme = (ROOT / "pureboot" / "README.md").read_text()
measured = collect() measured = collect()
rows = re.findall(r"^\|\s*(AT\w+[^|]*?)\s*\|[^|]*\|[^|]*\|[^|]*\|\s*(\d+) B\s*\|\s*(\d+) B\s*\|$", rows = re.findall(r"^\|\s*(AT\w+[^|]*?)\s*\|[^|]*\|[^|]*\|[^|]*\|\s*(\d+) B\s*\|\s*(\d+) B\s*\|$",
@@ -132,7 +148,9 @@ def cmd_check_readme(args) -> int:
# "ATmega48, 48A, 48P, 48PA †" — the first name is the family's base. # "ATmega48, 48A, 48P, 48PA †" — the first name is the family's base.
chip = re.sub(r"[^a-z0-9]", "", chips.split(",")[0].strip().lower()) chip = re.sub(r"[^a-z0-9]", "", chips.split(",")[0].strip().lower())
built = {name: text for name, text, _ in measured.get(chip, [])} built = {name: text for name, text, _ in measured.get(chip, [])}
for target, documented in (("pureboot", stock_doc), ("pureboot_autobaud", auto_doc)): worst = ("pureboot_autobaud_osccal_on_usart0"
if "pureboot_autobaud_osccal_on_usart0" in built else "pureboot_autobaud_osccal")
for target, documented in (("pureboot", stock_doc), (worst, auto_doc)):
if target not in built: if target not in built:
skipped += 1 skipped += 1
continue continue