Internal Card-API code restarts the SSP clock before every RF APDU, resetting it to zero. ISO14443A retains NextTransferTime as an absolute timestamp from the prior clock instance, so each next transmit waits for stale time.
The app died with SIGSEGV before drawing anything, on every entry point
including --help.
The window minimum size was pre-seeded into Kivy's Config, and
WindowBase.__init__ is the only place Kivy reads it - so it was applied from
inside WindowSDL.create_window(), while Window.initialized was still False.
If the window SDL has just made is smaller than that minimum, SDL resizes it
on the spot; the resize comes back through the SDL event filter into
EventLoop.idle(), which runs the clock and re-enters create_window(). Still
not initialised, that call runs setup_window() again and resizes again, until
the C stack gives out.
A scaled HiDPI session is enough to reach it: with sdl2-compat on SDL3 under
Wayland sizes come back in logical points, so the inherited 800x600 default
arrives as a 500x375 window, under the 760x520 minimum. Set the minimum on
Window once it exists instead, where the same resize takes Kivy's cheap
already-initialised path.
That exposed a second fault on the same path. _install_kivy_logging attached a
bare StreamHandler() to Kivy's own logger, and kivy.logger replaces sys.stderr
with a stream that feeds whatever is written to it back in as a warning -
kivy/logger.py cautions about exactly this. Any warning _KivyNoise did not
drop answered itself until the recursion limit stopped it, with stderr as the
broken part, so nothing legible came out. Not theoretical: this machine logs
"MTD: Unable to open device" at startup, which would have killed the app as
soon as the segfault was out of the way. Point the handler at the real stderr,
which Kivy leaves alone.
Behaviour change: a window that opens smaller than 760x520 is now grown to it,
rather than the constraint being imposed while the window is built.
Testing: 257 passed, 2 skipped (was 254/2); --help, a plain launch, --dump on
a generated sample and -v all start and stay up; black clean. Python-only
change, so no client or firmware build matrix applies.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PM3_CMD_DATA_SIZE went 512 -> 624 without a capabilities bump, so a new
client connects to old firmware and every oversized command dies at the
device's length check with no message.
Append max_cmd_data_size, bump to v9. The client now accepts an older
capabilities struct - it only ever grows by appending, so an older layout
is a prefix - and defaults the frame size for pre-v9 firmware.
SendCommandNG bounds by the device value instead of the compile time one.
Also zero init capabilities_t on the device, it leaked stack bytes.
Allocate EF_SOD parsing scratch buffers on the heap so macOS worker threads do not exceed their stack while reading protected travel documents.
Co-Authored-By: Codex <noreply@openai.com>
The FPGA trace loop was the last OLD reply on the device outside the two
the bootrom also serves. It stayed OLD because the DMA double-buffer was
sized to the frame payload and an NG header did not obviously fit in
front of it.
DMA straight into chunk->data of a download_chunk_t instead, so a filled
buffer is already a complete NG payload and needs no copy. Chunking now
follows DOWNLOAD_CHUNK_MAX and scales with PM3_CMD_DATA_SIZE. The
terminator carries download_done_t like the other bulk downloads. No
client change needed, dl_it already had the NG branch.
Two fixes fall out of it:
FPGA_TRACE_SIZE is 3072, an exact multiple of 512 but not of
DOWNLOAD_CHUNK_MAX. Each transfer is now armed for exactly the bytes
still expected - arming a full chunk for the short last one would spin in
FPGA_SSC_DMA_RX_Done() forever. This also drops the stray extra DMA the
old loop left armed.
get_tosend() moved after FpgaDownloadAndGo(). The loader calls
BigBuf_free(), which nulls s_toSend.buf, then reuses that same region for
its decompression ring buffer - the old code captured the pointer before
the free and only worked because the loader was done with it in time.
3072 bytes goes from 6 OLD frames to 7 NG frames at PM3_CMD_DATA_SIZE
512, and would be 5 at 688.
chunked by PM3_CMD_DATA_SIZE. Identical today, but if the NG size moves the
chunk would be built oversized, truncated on the wire, and still announced
at full length in oldarg[1] - the client would copy past the valid bytes and
advance by the wrong stride. Bound the client's OLD download branch by the
same constant.
nkeys was a 6 bit field but the client chunked by what fits in a frame -
123 keys in segment mode. nkeys wrapped to 59 while memcpy copied all 123
and the loop advanced by 123, so 64 of every 123 keys were never tested
and never reported. Full key mode was unaffected, it chunks 30.
Give nkeys its own byte. MIFAREU3P_CHKKEY_HEADER goes 18 -> 19, costing
one byte of payload, and segment mode chunks 123 again
Payload layout changed: client and firmware must be updated together.
Thanks Claude!
The OLD frame size was tied to the NG one, but the bootloader only speaks
OLD - growing PM3_CMD_DATA_SIZE would silently change sizeof(PacketCommandOLD)
and break flashing against every deployed bootrom in both directions.
Pin the OLD structs to their own constant and use it on every OLD path:
reply_old and the OLD receive branch on both sides, the bootrom, and the
flasher's write_block/send_finish_write_cmd, which memcpy into a
PacketCommandOLD using the NG size.
No behaviour change - both constants are 512 and armsrc .text is
byte-identical before and after.
Each helper had its own uint8_t recvbuf[ISO15693_MAX_RESPONSE_LENGTH],
2116 bytes, and the SLIX/AFI helpers nest up to three deep - write_afi
-> set_pass_Slix -> set_pass_SlixRnd put over 6 kB of the 8.4 kB stack
into one chain. No helper reads its response across a nested call, so
one shared buffer serves all of them.
Worst chain from AppMain 6848 -> 4544 bytes, measured with -fstack-usage.
Costs 2120 bytes of bss, so BigBuf goes 35108 -> 32988.
SimTagIso15693 keeps its own buffer, it does not nest.\n Thanks Claude!
SendVersion holds three PM3_CMD_DATA_SIZE sized buffers. Inlined into AppMain they sat in its frame for the whole main loop, not just while CMD_VERSION was handled. Marking it noinline makes the frame transient.
AppMain frame 2160 -> 1160 bytes, measured with -fstack-usage. Thanks Claude!
iso14443a.c called BigBuf_calloc() and wrote to the result without checking it:
uint8_t *real_cmd = BigBuf_calloc(cmd_len + 4);
if (real_cmd == NULL) {
return -1;
}
BigBuf_malloc returns NULL once s_bigbuf_hi - s_trace_len < chunksize. Since clear_trace() is never called anywhere in the SAM path — only from the four standalone 14a entry points — s_trace_len grows monotonically for the life of a session and can eventually starve that allocation. -1 matches the function's existing error convention, and callers already map apdu_len < 2 to PM3_ECARDEXCHANGE.