'W' handed the loader a whole page with no ack inside it and sp_spm handed any
wire byte to SPMCSR, so a dropped byte re-aligned the stream and page data
arrived where commands belong. That is how a page-address byte became
BLBSET|SELFPRGEN on the tempmon board and programmed its lock bits.
The first answer was to refuse that one command. It was the wrong shape twice
over: it forbade a lock-bit write the owner may want, and it left every other
command decided by bytes nobody checked. v9 checks them instead. One header for
every command — opcode, selector, address, count, seal — folded and compared
before the command is decoded, and *answered* before any payload moves: '+'
accepts, 0xd4 (the ack inverted) refuses and nothing happened. An ack cannot do
this job; it reports a command that has already run.
It is smaller than v8 everywhere: 1284P 506→480, m8 498→480, 328P 484→468,
t13A 474→460. The seal costs 14 bytes; bit opcodes in place of the letters pay
for it twice over, since a letter costs a compare and a branch where a bit costs
a skip. Both guards go — the lock-bit refusal because the seal covers it, the
running-slot write guard because what it defended against was a wire fault
naming an address and a wire fault can no longer name one. That one is a real
trade: a host bug aimed at the running slot now lands. It buys a resident copy
that can write its own slot, which is the only self-update route on a chip whose
boot section *is* the slot.
Two things the tests caught, both introduced here. Removing the invalid-opcode
arm made every byte a command, so the knock stopped being harmless against a
loader already in session and ate the five bytes behind it — identify moves to
bit 5, which both 'p' and 'b' carry, so the knock is inert again and version
discovery still works before the version is known. And the SPM value rides the
count field because a data byte would arrive after the seal was checked.
pbselfwrite and pbglitch are the new gates, both red-green: the same erase of
the running page refused unsealed and performed sealed, and every header byte
damaged after sealing refused where the identical damage before sealing is
obeyed. Both judge by the simulator's flash, not the loader's opinion of it.
pbreloc and pbrehome lose their write-guard probes, which is what those two
gates replace. Defeating the seal in the loader turns seven tests red.
37 of 37 chips green with the exhaustive size matrix; README protocol section
and every size row rewritten. pbhw gains an adversarial --seal-rounds sweep for
the bench.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
HALF_DUPLEX deploys a shared line per backend. The hardware USART takes
the library's .half_duplex turn-around — RXD and TXD tied off-chip, each
reply byte held to transmit-complete before the line can be released
(m8 404 B, m328P 440, 1284P 460; the window poll runs through the
outlined release-line call at 18 or 22 cycles a poll, measured off the
built loops and held per chip by pureboot.window.halfduplex). The
software and autobaud links fold onto the RX pin — RX == TX spells the
same — and cost nothing: the frame's direction wrap is what the dropped
second-pin init paid, and the worst image in the space is unchanged at
the 1284s' 502 of 512, now with its one-wire twin proven equal across
the exhaustive matrix. The host gains --one-wire, the echo discard a
shared line requires: the adapter's echo is matched byte for byte and a
reply interleaving a blind write — a loader already in session
re-prompts inside the knock — is held for the reader. The device runner
models the shared line by direction (drives only while the firmware's
DDR reads input, decodes only while the firmware owns it, supplies the
host-side echo), extends the USART pin-ownership model to RXEN's hold
on RXD, and starts the pty USART from the datasheet's zeroed UCSR#B:
simavr's TXEN-set reset plus its clear-UDRE-on-TXEN-drop otherwise
wedges the first transmitter after a receiver-only program, which the
half-duplex window gate caught as a banner that never came. v7 is
tagged at its era's last commit; v8 changes nothing on the wire.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>
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>
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>
The in-file version window now carries the v7 line its own comment
claimed to hold; the GPIOR note counts words, not instructions; the
USART-release cost and citation match the silicon (two bytes on the
classics, §20.6.3); and the 512-byte claim reads as the slot bound it
is. The libavr pin advances over the review pass — images byte-identical.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Four cuts, none touching what the loader can do. The entry stub stops
re-doing the reset logic's own SP write where the datasheet guarantees
RAMEND (stack::hardware — the classic megas keep theirs). The autobaud
unit moves into GPIOR2:GPIOR1 wherever the chip has the pair: one-word
accesses, no RAM object, and the host's measured-clock peek follows it
by version and geometry. 'J' rides the unified decode, carrying a
selector it ignores so its address is the same two reads as every other
command — the tool sends the bare form to older residents. run_app stops
insisting on a body of its own. The fleet lands at 358–410 B stock and
438–474 B autobaud; the tightest image in the space — the 1284s'
autobaud on a USART's own pins with the OSCCAL trim — drops from 510 to
484 of its 512. Every chip's suite is green on the wire that changed,
and the README's table is machine-checked against the built images.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The fixed-baud window counted down a uint32 where almost every window fits
24 bits; the countdown now takes avr::uint24_t when the poll budget allows
(the autobaud budget's own choice), uint32 past 16.7M polls — four bytes
off every fixed-baud image on every chip, the full suites green on the
changed window. The README size table is refreshed — its autobaud column
had also gone stale by the no-assembly pass's measurement-loop win, which
nothing gated: sizes.py check-readme now runs as the gate's final stage,
where every tree is freshly built and the table can actually be held.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pureboot_device and the tsb device, C until now, rewritten in C++23 with
every modeled behavior intact — the PGERS Z-mask and m48-discard ioctl
wraps, the GPIO bridge's timing and pacing, the tiny NVM's write-once
buffer, pin ownership, and the PB_PTY/TSB_PTY lines the harnesses parse.
The one linkage fact worth a comment: simavr's parts headers (uart_pty.h)
carry no C++ guards where its core headers do, so those includes sit in an
extern "C" block. Warning-clean at -Wall -Wextra on the build line; the
full protocol suites on all four sim-driven chips prove the conversion.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
--scan's walk was unwalkable on POSIX: probe rates have no termios
B-constant, so the first off-nominal probe raised out of the loop. The port
speaks termios2 BOTHER now (red-proven on a pty at 9984 Bd), the probe's
open lives inside the walk's error handling, an fd no longer leaks on an
unmakeable rate, and the swallowed unknown-signature reply is named at
timeout instead of reported as silence. CMakePresets.json's generator emits
the submodule toolchain path it had drifted from — a hand edit on a
generated file, exactly the class rule 10 exists for — and presets.generated
gates the pair from here on (the marker CMake rejects at the
presets root stayed out; the check is the guard). The over-slot image guard
the tsb runner gained reaches the pureboot runner too; the GPIO bridge's
delivery comment states the hardware truth (RXC at the stop bit's sampling
point); the hardware suite gains the scan check — the one place the rate
physics is real; and the libavr pin advances over both audit rounds.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Configuration gains the OSCCAL row; Deployment says what the build cannot
see (±10 % factory trim against a frame's ~±4 %, and silence that reads as
wiring); the update section names an OSCCAL bake as a link change in effect,
declared with --staged-baud; the host-tool section documents --scan and the
measured clock --info adds on an autobaud session; the version map gains 6.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The pin the whole history now encodes becomes the primary route: the
submodule default replaces FetchContent and the unpinned forge fallback,
LIBAVR_ROOT stays as the tandem-development override, the presets already
take the toolchain file from the submodule, and the Studio projects anchor
their include path there — correct by construction. The version tags and
the one-command historical build are documented beside the version map.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Writing 0x60-0x61 on an ATtiny13A reliably garbled the link, which looked like a
loader defect. It is the loader's own footing: .noinit lands at exactly 0x60,
size 2, and on an autobaud build that is unit_ — the measured bit period, and the
whole of its static RAM. Overwrite it and the next reply is timed against
garbage, so the symptom is a mangled prompt byte and no error, because nothing
went wrong except the rate both ends had agreed on.
Identical in kind to poking the stack at the top of SRAM, and cleared by a reset.
test/pbautobaud.py already steered its RAM round-trip clear of the bottom of SRAM
for this reason; only the README had not said it. Both regions are named there
now, beside the note that --poke does reach OSCCAL but that a session survives
only a step or two of moving the clock under itself.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The ATtiny13A run left autobaud's low-clock lock looking unreliable: 1/5 at
19200 on a 1.2 MHz RC part. The simulator does not reproduce it. At an exact
clock the calibration is solid down to ~36 cycles a bit and fails outright by
~31 — a sharp edge, not a fraying one — where the real part was already 1 in 5
by ~59. So the effect is the oscillator's own jitter and not backend logic, and
the two floors are different quantities about a factor of two apart.
Both are worth having. pureboot.autobaud gates a tight-bit point, since its two
existing clock points both sat near 100 cycles a bit and would not notice the
floor moving. The README carries the other half: both floors side by side, the
per-clock envelope measured on silicon, and the reason budgeting the logic's ~36
on an RC part is wrong.
It also carries the trap that produced the confusion. On a patched-vector chip an
erased application region walks back up into the loader, so every expired window
opens another and the host's retries eventually catch the pulse — 5/5 where the
same part with an application resident gives 1/5. Measure with an application in
place, or the fixture flatters the backend.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
--update-loader works by entering copies of the *new* image and letting them
rewrite the resident. Those copies speak the link they were built for, but the
host went on knocking with the session's baud and backend — the resident's. Where
the image changed either, the staging copy was installed and then never answered:
resident untouched, and on a 1 KiB tiny the staging slot is the whole application
region, so the application was already gone.
The wire cannot be probed for it. 512 bytes of position-independent code carry no
header saying what rate they were built for, so the operator declares it:
--staged-baud and --staged-autobaud, applied from the jump into the staging copy
onward. Retuning goes through the open port — SetCommState or tcsetattr on the
live handle, never a reopen — because a DTR pulse would reset the copy being
talked to. Undeclared against a changed link it still cannot work, but the error
now names that as the cause instead of reporting the bare activation timeout that
sent the operator looking at wiring.
The README's idempotence claim needed the same qualification: from step 2 a
re-run must reach the new image, and after step 3 word 0 points at the staging
copy, so on a patched-vector part the resident's link reaches nothing at all.
Found on an ATtiny13A, where two controls differing only in the activation window
updated cleanly and so isolated the link as the variable.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
check.sh proves the protocol under simavr on every chip; it cannot prove a
board. Two things live only on silicon — an RC oscillator that is not on its
nominal, and a reset edge that has to come from somewhere — and until now the
scripts that reached them were per-session scratch on the machine holding the
programmer, which is where the ATtiny13A run's findings nearly stayed.
pbrig.py is the primitives, knowing nothing per-board: every deployment fact is
a flag or a PUREBOOT_* variable. Two rig facts are encoded in it because neither
is guessable and each cost a session to learn: an ISP access *is* the reset edge
where the adapter's DTR is unwired, so a session begins with an ISP touch and
knocks immediately after; and avrdude splits -U on colons, so a Windows drive
letter breaks the spec and every file goes as a bare name with avrdude run in
its own directory. Its `rate` subcommand is the one that turns "the loader is
silent, so the wiring must be wrong" into a number, by sweeping the host rate
against a fixed cycles-per-bit transmitter — PUREBOOT_HEARTBEAT makes the
existing fixture into one, software link only, since the hardware-link idle owes
the self-update tests its command loop.
pbhw.py takes every bound from the info block the loader reports, so one run
covers a 1 KiB tiny and a 128 KiB mega alike. Both are exercised on an ATtiny13A:
backup verified against a known-good capture, the clock measured at 9.048 MHz
against a 9.6 MHz nominal, and the suite 11/11.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pins move the image for exactly one reason — a bit-banged link on a USART's own
pins has to release that USART — and the matrix said outright that they were no
axis, so the tightest configuration in the space was one nothing built. Not
subtly, either: the 1284's slot ends at flash end, so that build does not merely
exceed the size test's limit, it fails to link. pureboot_{sw,autobaud}_on_usart
{0,1} are gate points in both matrix modes now, and the exhaustive sweep carries
the pins across its whole cross product. The hand-measured table is the gate's
output: 506 B of 512 for the 1284 autobaud on USART0's pins, 504 on USART1's.
pureboot.mute drives the defect itself — an application hands over with USART0
still enabled and the loader on those pins must still answer. Reaching that
needed the runner to know an enabled USART owns its TxD, which simavr does not
model at all: it wires a USART through IRQs and never takes the pin from the
port. It also brings UCSRnB up with TXEN already set where silicon clears the
register, so the runner restores the reset value for the USART it models — the
mute must come from the application, not from power-on. The fixture stays
silent, since nothing is listening on the USART it brings up.
test_handshake.py, written where no gate could run it, is pureboot.handshake.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
R/r/w/F collapse into G and g over a selector byte naming the space — flash,
EEPROM, data, fuse, SPM — with the flash bank in its high nibble. Four command
bodies, four transfer loops and four argument decodes become one of each, and
W joins the same decode instead of keeping an address form of its own. The
loader shrinks while gaining everything below: on the 1284P the stock build
goes 480 -> 432 B and the software one 496 -> 450.
What the freed space buys:
- Data space. On AVR one pointer spans SRAM, the register file and the whole
I/O space, so G over space 2 reads all three. pureboot keeps zero static
RAM and pushes no register, so at loader entry an application's SRAM is
still what the application left there — this is a post-mortem, not just a
poke hole. As its own command it needed a dispatch arm and a loop; as one
more space it is a single ld/st.
- Host-issued SPM. W fills the page buffer and stops; erase, write and RWW
re-enable are writes to space 4, which reach the same fused store-and-SPM
pair through the transfer's own address and data. Any SPM operation, lock
bits included, is now reachable and the loader carries no page-commit logic.
The four-cycle SPMCSR-to-SPM window is why that primitive stays fused: no
host can hit it across a serial link, and that — not the byte count — is
the floor on how low-level a bootloader's primitives can go.
- Byte addresses everywhere. The bank in the selector retires the
word-addressed wire the >64 KiB parts needed, so the 1284s stop being the
outlier.
SERIAL autobaud is a third backend on the same loader, over libavr's
software_autobaud: no clock, no baud, one binary per chip for every F_CPU and
every rate. Activation counts poll iterations rather than seconds and bounds
every wait, so a stray pulse cannot hold an unattended device.
b answers with the version and signature only; the host derives geometry from
the signature, which is what an autobaud build requires anyway. An update image
is a bare slot with no device to ask, so every image carries a six-byte stamp —
the same bytes b answers with, and the source of both — that the loader never
reads from flash and the host refuses to install a mismatch against. The
running-slot write guard moved onto the SPM commit, which covers erase and
write both where guarding W covered neither directly.
The position-independence lint now proves the property instead of a proxy for
it: the image must come out byte-identical linked at a different base.
-fno-move-loop-invariants left the tuned flag set — it was fitted to a command
loop carrying four transfer bodies and costs bytes now that it carries one.
Verified: the exhaustive matrix on all 37 chips (every clock x every baud x
every backend, non-standard rates included, plus the autobaud build) —
8174 size checks, no failures, tightest fit the 1284s' autobaud at 510 of 512.
Behavioral suites green on every chip class: t13a 10/10, t85 11/11, m8 13/13,
m16a 13/13, m48pa 13/13, 328P 23/23, 644A 17/17, 1284P 17/17. Data-space
round trip through --peek/--poke and the autobaud handshake are both red-green
proven.
The two prototype sources and their findings file go; the README carries the
protocol and dev/done.md in libavr carries the reasoning.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A sticky WDRF diverts every reset past the activation window (deliberate, so
an app can reboot instantly, at the cost of a possible lockout); an EEPROM
address past E2END wraps onto low EEPROM (the host bounds it, not the loader).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The in-page bits of a W address are dropped so the fill always walks from
the page base; the wire contract is one page of data for any address
inside it, on both the byte- and the word-addressed path. +2 B.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Every plausible oscillator against every rate it reaches against every
backend, on one chip per size-bearing class, under --full only. The baud
ladder becomes a reachability predicate the enumeration filters on, so an
unreachable point drops out instead of aborting the configure.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The word-addressed 1284s were the one family deploying in a 1 KiB slot,
because the far-flash machinery (ELPM reads, RAMPZ page commands, a
word-addressed wire) did not fit 512 B. It does now: 478 B stock, 494 B in
the heaviest configuration the build can produce. They take the 644s'
geometry, where the smallest boot section holds the resident slot and its
staging slot together. The loader's version goes to 3; the host tool did
not change, so its own version stays 2 and only the window it speaks
widens.
Most of the saving is one restructure. The info block and a flash read are
the same act, so giving all four streamed commands one address-and-count
path leaves exactly one call site for the flash streamer: it inlines into
the never-returning command loop and its 24-bit cursor stops being saved
and restored around every transmit. Around it, the ack byte moved out of
line, the wire's byte pair is bit_cast into the word it already is, the
fuse loop ends on its count, the info block's in-slot offset is taken as
the one-byte relocation it is, and -fno-expensive-optimizations gives way
to -fno-move-loop-invariants -fno-tree-ter. Every chip shrank 14-18 B.
The size matrix grew the axes it was missing: the USART1 instance across
the whole clock ladder, and the shape a slow baud gives a software UART —
past 255 delay iterations libavr takes the 16-bit delay loop, which the
ladder default never selects and which was 4 B over the 1284's slot the
first time it was built.
The protocol fixture stopped deriving the loader entry from the flash
size; on the 1284s it had been jumping a slot low and reaching the loader
only because erased flash walked it up.
Docs and comments were consolidated across the port in the same pass: the
README carries a per-chip size table instead of prose, and prose that
restated the code is gone — 190 lines, no behaviour with it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The info block's third byte was a protocol version that never moved in the
loader's lifetime. It is the pureboot version now, and this change is
version 2: the one number that says what a deployed loader is. Every loader
already in the field answers 1.
The protocol keeps no number of its own — a pureboot version implies it, and
the host tool is what holds that map. pureboot.py states the loader-version
window it speaks (OLDEST_LOADER/NEWEST_LOADER; a version that changes the
protocol becomes the new floor there), so a loader newer than the tool is
refused by name rather than decoded on the assumption nothing moved, while an
older one is read, identified and installed like any other. The tool carries
its own version, free to drift from the loader's: --version prints it and the
window, --info leads with the device's, --update-loader names the version it
installs.
Tests: the planner unit pins the window — every version in it decodes, one
above it is refused, an older loader's image is still found — and the live
suite pins the built loader against the tool beside it, so a bump that reaches
only one of them fails. The image is byte-identical to the previous build but
for that byte.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The headline numbers described the stock deployments but claimed "every
configured variant of them", which the matrix contradicts: choosing the
software UART where the chip has a USART costs 8-46 B, so the megas reach
460-462 rather than 452, and the 1284s' software-serial build is 546 B —
inside their 1 KiB boot sector, but not inside 512.
Also names the actual tightest chip. The 1284 looks like it at 506, but it
deploys in 1 KiB with 478 B spare; against its own budget the ATmega328P
has the least room, 50 B.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The temporary page buffer is write-once per word, so a page filled over
one an earlier writer left dirty programs the stale words. The same
datasheet clause carries the cure: the buffer auto-erases after a page
write (§26.2.1; §19.2 on the tinies), so the corruption clears itself by
happening, and rewriting the page programs correctly.
The loader therefore clears the buffer nowhere. The tinies' CTPB and the
m48s' RWWSRE discard are gone; the boot-sectioned megas keep only the
trailing RWWSRE they need anyway to re-enable the RWW section for
read-back, which discards the buffer as a side effect and keeps them off
the path entirely. 434 B on the tiny13s, 438-442 on the tiny25/45/85,
430 on the m48s; the megas are unchanged, the 1284s still 506.
The host takes over the guarantee: a flash page that reads back wrong is
rewritten up to RETRIES times before the run stops. Both read-back paths
repair — verify_pages for programming, and write_differing, which is the
loader-update path where a page left wrong is a half-written loader slot.
That one is not hypothetical: deleting the discard made attiny85
pureboot.rehome fail deterministically there, the only flow still
assuming the old contract.
Protocol-visible, so README's W command says it: one W may program the
wrong bytes after a refused page, or after an application that
self-programmed entered without a reset, and a host that programs without
reading back cannot trust it.
Tests: pureboot.dirty drives the case the loader declines to guard — the
fixture application dirties every buffer word and jumps in with no reset
(hardware forbids that on a boot-sectioned mega, but simavr dispatches SPM
from anywhere, which is what makes it constructible) — and asserts a bare
verify sees the corruption, the repairing verify fixes it in one rewrite,
and it stays fixed. pbreloc asserts the same shape after a refusal.
test_planner covers the bound against a fake device: one bad write
repaired in a single rewrite, a page that never comes good stopping after
exactly three.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Clock, baud, serial backend (hardware USART 0/1 or the software UART on
any pins) and the activation window all resolve through one CMake
function, pureboot_add_loader() in pureboot/CMakeLists.txt — the unit a
downstream project consumes. The default baud is the fastest standard
rate within 2.5 % (the same best-divisor search libavr's solver runs),
gated on software builds by the polled receiver's 100-cycles-a-bit
floor; every explicit pick is re-checked by the compile's static asserts.
The size matrix builds each axis that can move the image — backend x
clock ladder x USART instance, per chip — against the slot budget, and
two nondefault deployments run the whole protocol suite live: the 328P
on its shipped 1 MHz fuses over software serial on TX=PB1/RX=PB5
(pureboot.custom), and the 644A over USART1 (pureboot.usart1). The sim
runner takes -l to bridge any link, paces a fully quiet bridge toward
real time (a free-running 8 M-cycle window loses the reset-race knock),
and the fixture application speaks the deployment it is built for.
The loader itself shed bytes on the way: the return-address high byte
spelled through byteswap (the double swap folds to the one-byte pick),
the info-block address composed instead of bit_cast, and libavr's new
polled-UART helpers replacing the port's uart::detail reaches. Every
combination fits: 458-506 B across the megas' whole matrix, 470-484 B
on the tinies, 556-562 B in the 1284s' 1 KiB slot.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The update flow's first step wrote the staging content over whatever the
staging slot held; with a loader running right there (programmed by hand
onto erased flash), that write met the copy's own running-slot guard on
the composed through-word and the tool stopped at its verify — although
the copy is exactly an installed staging copy, able to stream the new
resident like any other. The install is now skipped when the slot holds a
complete loader: its info block where every image carries it, matching
the device's byte for byte, and the slot unchanged since the update began
(the state file's snapshot) — so a resumed half-written install still
differs from its snapshot and takes the install path, which completes it.
pbrehome gains the staging-slot position (an older build at stage
streaming a newer resident in); the README's wrong "cannot re-home from
the staging slot" claim is corrected.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The README's deployment section now says what each build artifact is for:
the .hex is the programmer artifact (self-addressed into the top slot),
the .bin the self-update image — bare slot bytes a programmer would put
at address 0, where a boot-sectioned mega cannot even heal itself (SPM
only runs from the boot section) but a patched-vector chip runs the
position-independent copy and re-homes a build through the ordinary
--update-loader flow: the staging install and the word-0 redirect both
execute outside page 0's slot, so the running-slot guard never blocks it.
pbrehome.py is the acceptance test (misplaced at 0, guard intact,
re-home, app flash over the stale copy, banner); the staging slot is the
one position that cannot re-home itself, documented. The preflight's
wrong-chip refusal and loader_image's handling of padded images (peeled
to the slot content by the embedded base) are documented and the padded
case pinned in the planner.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The chip table becomes family blocks covering all 37 targets. The m48s
are a new deployment class: no boot section, so the tiny profile spoken
over the hardware USART — host-patched reset vector, trampoline
hand-over, a 510-byte budget (474 B built), no fuse preflight — while
their RWWSRE store stays the buffer discard (Atmel-8271 §26.2); the
device keys the patch flag and the CPU-halt waits on the curated
boot-section capability and the discard on the RWWSRE bit itself. The
644s' 64 KiB is exactly the 16-bit byte space: plain LPM, byte wire
addresses, 498 B in a 512-byte slot — and their 1 KiB minimum boot
section holds the resident and staging slots together, so self-update
needs no fuse step (the update test's slot pick now keys word-flash on
base >= 64 KiB; base + slot merely touching the boundary stays
byte-addressed). The 1284 joins the 1284P's word-addressed 1 KiB slot at
558 B. BOOT_FUSE gains every boot-sectioned family's ladder and fuse
byte; the planner exercises them all. The sim scaffolding keys
patch-vector-ness instead of the atmega name prefix, the fixture app
picks its clock by family (the tiny25/45/13 builds surfaced the 16 MHz
fallthrough as garbled banners), and the runner's wrapped flash ioctl
performs the m48 discard simavr's no-RWW cores turn into a stray buffer
fill. Sizes across the fleet: 466-504 B megas, 474 B m48s, 498 B 644s,
488-502 B tinies, 558 B 1284s — every chip passing
size/pi/planner/protocol/reloc/update.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The deployment section claimed the 1284P has no standalone profile, runs
BOOTSZ = 512 words always, and self-updates with no fuse change, reset
landing at 0x1f800 — internally contradictory (a 512-word section starts
at 0x1fc00, and the section holding both 1 KiB slots is 1024 words) and
contradicted by update_preflight, which refuses a self-update unless the
boot section covers two slots. The text described a 512-byte-slot
geometry this chip's loader cannot have. In truth the 328P profile table
maps onto the 1284P doubled: standalone = 512 words (the smallest
section is exactly the 1 KiB slot, reset at the loader base), self-update
= 1024 words with the loader-first reset walking the staging slot.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The far machinery (ELPM reads, RAMPZ page commands, wire-word math)
costs ~46 B over the m328P's 504, and the tsb-calibrated C++-to-asm
gap says no implementation of this feature set reaches 512 on this
chip — a boundary its hardware does not have anyway: the 1284P's
smallest boot sector is 1 KiB. The slot therefore becomes
per-geometry (512 B, or 1 KiB past 64 KiB), which the host derives
from the word-addressing flag; slot arithmetic unifies (the index is
the wire high byte with its low bit dropped in either unit), the
update preflight demands a two-slot boot section in the chip's own
terms, and pbapp's hand-back jumps to the real slot base. libavr's
far primitives split their RAMPZ/Z asm operands (a page never
crosses 64 KiB, so callers keep a byte and a 16-bit cursor — the
32-bit address folds away; flash_load_far's byte form becomes the
out-RAMPZ+elpm pair avr-libc's pgm_read_byte_far rebuilds per call),
and the host splits reads at 64 KiB boundaries. All ten chips pass
the full suite — the 1284P at 558 B including protocol, relocation,
and the power-fail self-update — with pureboot byte-identical across
generated and reflect modes everywhere, and the original three
chips' images unchanged to the byte (488/502/504).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Device: boot-section detection probes SPMCR beside SPMCSR, the link
picks any hardware USART through the instance-aware lookups (URSEL
chips included), WDRF reads MCUSR-or-MCUCSR, and the >64 KiB shape
lands — word-addressed wire flash (info flag bit 1, page byte 0 means
256, base as a word address), far reads through flash_load_far, a
single 32-bit byte-cursor page walk (the 256-byte page wraps its low
byte exactly), and slot arithmetic in words (the return address
already is one). Host: addresses stay bytes internally and scale at
the wire, the boot-fuse decode becomes a per-signature table (byte
index + BOOTSZ ladder — the m168A's lives in EXTENDED), and the
planner tests pin every chip's ladder plus the word-addressed info
decode. Tests: the device runner serves every mega over the USART pty,
pbapp banners over the right link, the update rehearsal synthesizes
its assumed fuses from the tool's own table, and the PI lint tracks
the renamed info symbol. All six classic-mega/168A targets pass the
full suite (size, PI, planner, protocol, reloc, self-update) at
466–504 B; the 1284P builds await a libavr far-path slimming to make
its 512.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The build emits an Intel HEX beside every loader image, but --update-loader
could not consume one: load_image() anchors every image at address zero, and
a loader HEX links at its base, so it decoded to a 32760-byte blob carrying
504 bytes of loader at the end. staging_content() then refused it as "loader
image is 32760 B, the slot holds 512" - an error naming neither the cause nor
the raw .bin the tool wanted instead.
Drop the blank below the base in the update path. The base comes from the
image's own info block rather than the device's, so an image built for
another target survives the slice intact and the preflight still reports it
as another target rather than failing to find an info block at all.
Verified on an ATmega328P: the full self-update flow driven straight from
pureboot_timeout-5s.hex, resident slot byte-for-byte against the image
afterwards, application preserved; both refusal paths unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The host tool was standard-library-only but POSIX-only with it: termios and
select() bound the port layer, and importing termios failed outright on
Windows, so the module could not even load there.
Split Port into PosixPort (unchanged) and a WindowsPort over the Win32 serial
API through ctypes, picked by os.name; every call site keeps the Port name.
kernel32 only, so the standard-library constraint holds.
Windows has no select() for a COM handle, so the read deadlines move into the
driver as COMMTIMEOUTS, re-armed per read: read_available() ends on a gap
longer than a USB-serial latency timer coalesces (16 ms on FTDI parts),
read_exact() on the count or its deadline. Opening asserts DTR and RTS as a
POSIX open does, so a board wiring DTR to reset still pulses it. A failed
configuration closes the handle before raising - a COM handle is exclusive,
and the leak met the next open as "Access is denied". Win32 takes any integer
baud and a driver may accept one its hardware cannot produce (an FT232R
reports back a baud of 3 and keeps the old divisor), so obvious nonsense is
refused where termios' table would have.
Tested against an ATmega328P on COM6: info, fuses, both memories programmed
and verified, session reconnect, hand-over, the loader self-update, and the
write guard on its own slot. test_planner runs on Windows now as well.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The image now runs from any 512-byte slot with every command intact:
control flow stays PC-relative, the write guard keys on the running slot
(the return-address anchor, computed once), the info block is addressed
from that same anchor as a byte pair (no absolute 16-bit address in the
image), and the application jump is an indirect call through a noipa-
laundered pointer to the absolute entry. 'J' — jump to a wire word
address, the one transfer primitive — replaces 'G': the host knows the
application entry from the info block, and moving between loader copies
needs arbitrary targets. The activation window is a compile-time 8 s
(PUREBOOT_TIMEOUT overrides), counted as a single calibrated poll loop.
A refused page no longer poisons the write-once temporary buffer (a real
silicon trap: the next write would program the drained data): every page
write discards the buffer first — CTPB on the tinies, on the mega the same
RWWSRE store that re-enables RWW after programming. The tinies' post-op
busy-waits go with it: their CPU halts through page erase and write.
488 / 502 / 504 B on t13a / t85 / mega — under the tinies' 510-byte budget,
whose last slot word is the host-managed trampoline: the resident's holds
the application entry, a staging copy's the jump through which an abandoned
update still times out into a loader.
The host tool updates the loader with itself: --update-loader installs the
identical image one slot below the resident, jumps into it, lets it rewrite
the resident, and restores the staging region from a state file — each
phase idempotent off the flash state, resumable after any interruption
(t13a: the staging slot carries the reset vector, written last in and
first out; t85: word 0 redirected around the resident rewrite; mega:
fuse-matrix preflight with a hard BOOTSZ gate and --assume-fuses for
simulators). Application flashing recovers by reset from any interruption:
patched page 0 and trampoline first, erase descending, and a walk-region
refusal behind --force on BOOTRST-below-loader megas.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pureboot.py (Python stdlib only): images as raw binary or Intel HEX,
flash and EEPROM programming with read-back verify, erase composites,
fuse and info readout, activation-timeout configuration, and the
tinies' reset-vector surgery — the trampoline word below the loader,
page 0 written last.
The test spawns a simavr device (pureboot_device.c) — the mega's USART
as a pty; on the tinies a cycle-timed GPIO<->pty bridge for the polled
software UART plus the NVM module simavr's tiny cores lack (their SPM
opcode ioctls into a void and silently does nothing) — and drives it
with the real tool: knock from reset (erased-flash walk on the tinies),
program and verify both memories, timeout write, session reconnect, an
external reset through the patched vector, hand-over, and the fixture
application's banner. Results are cross-checked against ground-truth
memory dumps and an independent decode of the surgery's rjmp words,
red-verified against a sabotaged encoder.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
No inline assembly, no global register variables; libavr does the
datasheet work. The device speaks primitives — flash read/page-program,
EEPROM read/write, fuse read, info block, EEPROM-resident activation
timeout, hand-over — and verify, erase, reset-vector surgery, and
timeout configuration live in the host tool. 490 B on the ATtiny13A,
510 B on the ATtiny85, 484 B on the ATmega328P, each linked into the
top 512 bytes of flash; per-chip size tests gate all three.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>