Skip to content

Feat/mcxn947 controller - #4

Merged
ramseymcgrath merged 20 commits into
mainfrom
feat/mcxn947-controller
Sep 18, 2026
Merged

ramseymcgrath merged 20 commits into
mainfrom
feat/mcxn947-controller

Conversation

@ramseymcgrath

Copy link
Copy Markdown
Contributor

No description provided.

Two upstreams, because neither is sufficient alone:

1. MCUXpresso SDK 24.12.00 for FRDM-MCXN947 (BSD-3-Clause) -- CMSIS,
   device headers, periph/, system_*.c, the drivers step 1 needs
   (fsl_clock, fsl_spc, fsl_gpio) and the FRDM board package's
   clock_config/board headers.
2. NXP hal_nxp / mcux-sdk classic layout -- for the four files the SDK
   does not ship: MCXN947_cm33_core{0,1}_flash.ld and
   startup_MCXN947_cm33_core{0,1}.S. The SDK ships only the
   MCUXpresso-IDE managed-linker startup (.c, not .S) and no .ld at all.
   Verified rather than assumed: a find over the whole package returns
   no MCXN947 .ld and no .S startup, and devices/MCXN947/ has no gcc/
   directory.

The core1 linker script and startup are imported now but not built, so
step 5 does not have to re-open the provenance question.

Byte-exact, .gitattributes exempts them from the whitespace gate, and
PROVENANCE.md records both upstreams with their real local paths.

The two trees are different SDK vintages, which matters for one thing
and was checked: hal_nxp's vector table against 24.12.00's own startup
is 172 entries in identical order with no slot inserted or removed, and
slot indices agree with IRQn_Type at EDMA_0_CH0 (1), CTIMER2 (34),
LP_FLEXCOMM6 (41) and QDC0_COMPARE (124). Only names diverge, at slots
this design does not use -- hal_nxp says ENC0_*/ENC1_* where 24.12.00
renamed the peripheral to QDC, so anyone wiring a QDC interrupt must
define the ENC* handler name while the NVIC enum reads QDC*.
Step 1 of the ten-step migration in docs/MCXN947_CONTROLLER.md §9, and
nothing beyond it: one image, CPU0 only, no SPI, no USB, no CPU1, no
display. The point of doing it first is to isolate linker script,
startup, retention roots and toolchain before any of them can be
confused with a link problem.

  text 10540  data 112  bss 3448      make check: 20 passed, 0 failed

Opt-in -- the top-level `make verify` is untouched, so a clone without
arm-none-eabi-gcc is unaffected. Build with `make -C firmware/mcxn947`.

THE DESIGN DOC'S RUNG-1 CHECK WOULD NOT HAVE WORKED ON THIS PART.

§7 specified "every --undefined= root is defined and does not resolve to
DefaultISR", an address comparison. But this startup has a TWO-LEVEL
dispatch and three distinct weak shapes:

    CTIMER2_DriverIRQHandler  W  0x4f8   == DefaultISR
    CTIMER2_IRQHandler        W  0x598   weak trampoline, own address
    HardFault_Handler         W  0x500   weak self-loop, own address
    SysTick_Handler           T  0x1830  ours, strong

The vector table names CTIMER2_IRQHandler, which is a weak trampoline
that branches to CTIMER2_DriverIRQHandler; only the *Driver* name is
.set to DefaultISR. So rooting the vector-table name and comparing
addresses gives a PASS against NXP's own trampoline while the handler is
in fact unimplemented -- precisely the silent failure the rung exists to
catch. The checker therefore tests the nm binding letter (weak vs
strong) as well as the DefaultISR address, and that the named owning
object defines it. Negative-tested: a nonexistent symbol, a wrong
symbol/object pairing, and all three weak shapes each fail distinctly.

The retention roots are a backstop today, not load-bearing: with every
object named on the link line, strong-beats-weak already keeps
SysTick_Handler at T with the --undefined flags removed. The *gate* is
what carries the weight. The roots start earning their keep when a
handler arrives via an archive, or when someone drops an object from the
list expecting the vector table to retain it. Establishing the mechanism
at two lines, while there is almost nothing in it, was the point.

Two more findings for §10, recorded in PROVENANCE.md:

- "NonCacheable region in the vendored .ld" -- there is no such region.
  *(NonCacheable.init) and *(NonCacheable) are collected into the
  ordinary .data output section in m_data. The attribute buys nothing as
  the script stands, so step 2's two-DMA-engine coherency story needs an
  MPU region or an explicit placement decision, not this.
- SystemInit() enables LPCAC and disables RAM ECC, so §5's LPCAC
  question starts from "on by default in every image" rather than from
  an unknown.

Deviations, all in PROVENANCE.md: BOARD_PowerMode_OD reimplemented
rather than vendoring board.c (which drags in the debug console and the
camera/codec/accel I2C helpers for ten lines of SPC calls);
--no-warn-rwx-segments, because the vendored script's .data AT(__DATA_ROM)
puts an RX LMA and an RW VMA in one LOAD segment and ld 14.2.1 flags
every link; and --defsym,__use_shmem__=1, which reuses RPMsg's address
reservation (not RPMsg) so m_data ends at 0x2004C000 and the CPU1 window
is protected from step 1 onward. Both .ld files stay byte-exact. The
newlib "_write is not implemented" notes are deliberately NOT suppressed.

Guard polarity is #if defined(MCXN947) with MMIO inside, so a new file
with no guard is portable by default: `make test` compiles platform.c
and heartbeat.c for the host with no -Ivendor, which demonstrates the
split rather than asserting it.
The doc's §10 is its "Open and UNVERIFIED" list. Three entries are now
answered by having actually built the thing, and leaving them marked
unverified is the same defect as an over-stated claim: it makes the
section stop meaning anything.

RESOLVED:

- CTIMER2's IRQ symbol is CTIMER2_IRQHandler at vector slot 34. But the
  root worth declaring is CTIMER2_DriverIRQHandler -- see below.
- There is no NonCacheable region. *(NonCacheable.init) and
  *(NonCacheable) land in the ordinary .data output section in m_data,
  so the attribute buys nothing as the vendored script stands. Step 2's
  two-DMA-engine coherency story needs an MPU region or an explicit
  placement decision.

NARROWED:

- SystemInit() enables LPCAC, so step 6's question starts from "on by
  default in every image" rather than from an unknown. It also disables
  RAM ECC and the aGDET/dGDET chip-reset path.

CORRECTED -- §7's rung 1 described a check that would not work here. It
said "does not resolve to DefaultISR -- one line of nm". This startup has
a two-level dispatch: the vector table names <NAME>_IRQHandler, a weak
trampoline, and only <NAME>_DriverIRQHandler is .set to DefaultISR. An
address comparison against the vector-table name therefore passes against
NXP's own trampoline while the handler is unimplemented.

Two traps added to §7 that cost real time to find:

- The two upstreams are different SDK vintages. Their vector tables agree
  -- 172 entries, identical order, spot-checked against IRQn_Type -- but
  hal_nxp uses ENC0_*/ENC1_* where 24.12.00 renamed the peripheral to
  QDC, so a QDC interrupt needs the ENC* handler name against a QDC* enum.
- Another local MCXN947 project's SDK export declares
  BOARD_NAME "FRDM-MCXN947" while carrying the EVK's LED pinout
  (GPIO3[2:4] active-high). Taking pins from it yields a board that never
  blinks. 24.12.00's FRDM board package is authoritative: P0_10 red,
  P0_27 green, P1_2 blue, ALT0, active LOW.
§7 said "flash with J-Link, already installed". The board disagrees.

The FRDM-MCXN947's on-board MCU-Link runs CMSIS-DAP firmware and
enumerates as NXP 0x1fc9:0x0143, "MCU-LINK FRDM-MCXN947 (r0E7) CMSIS-DAP
V3.128". JLinkExe cannot drive it. Getting to J-Link would mean
reflashing the MCU-Link with SEGGER firmware -- reversible, but it takes
the board away from MCUXpresso IDE and every NXP example in exchange for
nothing we need.

LinkServer 24.12.21 was already installed with MCUXpresso IDE, just not
on PATH. It is NXP's tool for NXP's probe on NXP's part and it resolves
the target with no configuration:

  #  Description                                    Device    Board
  1  MCU-LINK FRDM-MCXN947 (r0E7) CMSIS-DAP V3.128  MCXN947   FRDM-MCXN947

`make flash` loads core0.elf rather than the .bin, so load addresses come
from the linker script and there is no --addr to drift out of step with
it. `make probes` lists what is attached. Neither is reachable from CI.

VERIFIED ON HARDWARE: sectors written at 0x0 and 0x400, "Finished
writing Flash successfully", target reset, execution started.

Also closes §10's "JLINK_DEVICE = MCXN947_M33_0 -- plausible, unverified".
The name is correct -- it is in the SDK's own debug configs, with
MCXN947_M33_1 for core1 -- but it is now moot for this flow, and only
matters to someone who reflashes the probe.

The J-Link device list cannot be queried offline, incidentally: JLinkExe
accepts any -device string without a probe attached and only validates
on connect, so "verify the device name" is not a thing that can be done
from a desk.
§9 step 2 said "mcu_ready low -> LPSPI6 + eDMA0 ring -> TX permanently
IDLE", gated on "spi_slots advancing at 8 kHz with every spi_bad_* and
spi_queue_full flat".

That gate is vacuous, and it was measured to be so before writing any
step-2 code. On the bench, against a board running only step 1's blink,
with nothing driving MISO or mcu_ready:

    spi_slots      advancing at 8,049 slots/s
    spi_bad_sof/crc/length/type, spi_queue_full, link_losses    all 0

Both halves of the gate were already satisfied. Two facts in the
gateware explain it:

- spi_link.py:281 increments slot_counter on the fixed cadence boundary,
  under a comment reading "A fixed slot boundary is generated
  independently of transfer state". spi_slots at 8 kHz is the FPGA's own
  cadence and says nothing whatever about the MCU.
- Every bad_* counter is gated on transfer_ready, latched from mcu_ready
  at slot start (spi_link.py:289), e.g.
  `with m.If(transfer_ready & (rx_sof != SOF))` at :433. With mcu_ready
  low the FPGA never validates a returned slot, so no error counter can
  increment and "all flat" carries no information.

So the TCR[BYSW] question, which this step is supposed to resolve "here
and nowhere else", would have stayed open behind a green gate.

Corrected: arm both DMA directions with mcu_ready low, then RAISE it,
and gate on counters that can now actually move -- plus map_active never
leaving 0, which makes the safety argument checked rather than assumed.
Low is the boot discipline, as ch32-cynthion-wiring.md has always said,
not the steady state.

Safety does not come from mcu_ready being low. It comes from TX being
IDLE: the admission predicate is `deliverable <=> SOF == 0x68 and
known_type and length == expected(type) and CRC ok and type != IDLE`, so
an IDLE slot is never deliverable and no map can activate.

THE DOC ALREADY CONTAINED THE CORRECT FACT. §4's ERR051588 recovery says
of driving mcu_ready low: "all four error counters are gated on
transfer_ready, so the garbage slots are *ignored* rather than counted",
and uses that deliberately to suppress counting during recovery, ending
at "6. Raise mcu_ready." A state whose stated purpose is to stop
counting cannot also be the state a gate reads counters in. §9 step 2
was the half that was wrong.
Byte-exact from MCUXpresso SDK 24.12.00 for FRDM-MCXN947, same as the
step-1 subset. fsl_lpspi.c is deliberately NOT vendored -- only its
header. LPSPI_SlaveInit() writes TCR wholesale from four fields, which
clears BYSW, and ends by enabling the module, so using it would mean
enable/disable/rewrite/re-enable for no gain over fifteen ordered
register writes. Recorded in PROVENANCE.md.

Consequence worth stating: fsl_lpspi.c's built-in ERR051588 workaround
lives only inside the transactional slave API this firmware does not
use, so the recovery ladder in design doc section 4 is ours to write.
Step 3 provokes the erratum deliberately to prove it runs.
LPSPI6 as SPI slave on LP_FLEXCOMM6, both eDMA0 rings self-loading, TX
permanently IDLE, mcu_ready raised once both directions are armed.

Gate, measured over 22 s with link_ready = 1 throughout:

    spi_slots        +174497    7,976/s
    spi_bad_sof           +0        0/s
    spi_bad_crc           +0        0/s
    spi_bad_length        +0        0/s
    spi_bad_type          +0        0/s
    spi_queue_full        +0        0/s
    map_active            0 throughout

That is the corrected gate: the error counters were capable of moving
and did not, which is only true because mcu_ready is high.

TCR[BYSW] = 1, which design doc section 10 listed as derived but
unmeasured. It is now measured. With it clear, the eDMA writes each
32-bit word byte-reversed -- an FPGA IDLE slot lands in the RX bank as
`00 00 00 68` -- and symmetrically the TX word for `68 00 00 00` clocks
0x00 out first, which is exactly the rx_sof != 0x68 the FPGA counts.

A WARNING FOR ANYONE DEBUGGING THIS LINK. spi_bad_sof at 1:1 with
spi_slots does NOT identify byte order. Byte order wrong in EITHER
direction saturates that counter at exactly the slot rate, so 1:1 is
what both BYSW settings look like whenever anything else in the chain is
also broken -- and during bring-up something else was: a bad MOSI
connection. BYSW was flipped to 0 on that non-discriminating evidence
and the fault survived the change, which is the only reason the mistake
was caught. The discriminator is the RX BANK CONTENTS, not the counter.

Two more corrections to claims made while debugging, both wrong:

- CH_CSR[DONE] latched does NOT mean a stalled ring. DONE lives in
  CH_CSR, not the TCD, so scatter-gather reload never clears it while
  ERQ stays set. It is sticky status. Both rings were turning the whole
  time; sampling CITER over time shows 1..8, where a frozen ring would
  show one value.
- "No bytes move in either direction" was a single-snapshot artifact.

Retained deliberately, against the usual rule: src/dbg_uart.c and the
register/wire probes in link.c are scaffolding, still marked TEMPORARY.
They stay for step 3, which provokes ERR051588 on purpose and will need
to watch the recovery. The LinkServer gdbserver is NOT an alternative --
it stalls the boot ROM on attach, so every register read that way
describes a halted device rather than running firmware. The UART is the
supported instrument on this board.

Also measured, and it changes how the counters must be read: spi_bad_sof
gains ~15,000 across every reflash, because mcu_ready is a GPIO that
holds its last level while LinkServer halts the core, so the FPGA keeps
validating against a peripheral that has stopped feeding it. Absolute
counter values are meaningless across a flash. Compare rates only.

make check 27/27, make test green.
RX retirement through portable host-tested logic, the ERR051588 ladder
built and actually exercised, and the discovery that one boot in five
comes up with the SPI frame permanently offset.

WHAT WORKS, measured:

- Steady state unchanged by retirement. Over 27.2 s: spi_slots 7,992/s,
  every spi_bad_* and spi_queue_full flat, link_ready=1, map_active=0.
- Retirement is real, against live traffic rather than only tests. The
  FPGA is not just sending keepalives: native_reports runs at 1,000.1/s
  and the MCU decodes those as INJ_TYPE_REPORT_FRAGMENT with live
  sequence bytes. Over 68,000 deliverable frames: 1 gap, 0 duplicates,
  0 stales. bad_crc = 0 across ~550,000 retired slots is the running
  proof that the bank-selection rule holds -- a torn read of a live bank
  fails CRC immediately, and step 2 measured what one looks like.
- ERR051588 recovery ran. Provoked by taking the TX channel's DMA
  request away for 1 s, which latches SR[TEF]; reproduced 4 times.

THE BOOT HAZARD, and it is the important part:

One boot in five comes up with the whole 256-bit slot permanently
offset. Measured 20 boots per arm, -DLINK_SKIP_GAP_WAIT building the
control:

    gap wait present   20/20 clean
    gap wait removed   16/20 clean    4 mis-framed (20%)

Fisher exact one-tailed p = 0.053 -- well supported, just short of the
conventional threshold, so it is evidence and not proof. An earlier
7-of-7 against 1-of-2 is deliberately NOT pooled in: those runs came
from a session with the code changing underneath, and combining
non-comparable runs to manufacture significance is exactly how this
repo's old four-row yosys table became worthless.

The offset is invisible to every status register -- no underrun, no
overrun, no DMA error, SR reads as on a healthy link. The only FPGA-side
symptom is spi_bad_sof saturated 1:1 with spi_slots, which step 2
already recorded as the non-discriminating signature of ANY whole-slot
corruption. It became nameable only once retirement made the received
bytes readable.

THE FRAMING MONITOR IS NOT A BACKSTOP FOR IT. An earlier claim in
PROVENANCE.md said it "makes the alignment hypothesis not need to be
right". It does not. All four control failures show the ladder firing
once per ~53 ms monitor pass, ~220 times, never succeeding, with every
slot failing throughout:

    sof=92917  recov=220  framing=220   ms=11691

A mis-framed boot is not recoverable in software; it needs a reset. The
monitor's one observed success repaired a RECOVERY'S OWN bad re-arm,
which is a different fault, and generalising from it was the error.

Consequence worth internalising: every §9 gate accepted on a single boot
-- including step 2's, which I reported as settled -- was one draw from
a distribution with a 20% failure mode. Quote a rate over N boots.

ALSO: fsl_edma_soc.c removed from vendor/, header kept. It defines
nothing but 32 strong EDMA_<n>_CH<m>_DriverIRQHandler wrappers, and
EDMA_HandleIRQ() opens with assert(handle != NULL); with SDK asserts
live and nosys.specs, newlib's failure path hangs rather than resets.
All 31 unused ones were latent hangs behind any eDMA interrupt a later
step enabled. It surfaced only because our retirement ISR needs the same
name and the link failed loudly -- taking the weak trampoline name
instead would have linked silently and left them in place.

§4's ladder needed three additions, all forced by measurement and
recorded in PROVENANCE.md: rung 3 must also drop CR[MEN]; rung 5 must
not return until the TX FIFO holds a whole slot; rung 6 must clear SR
immediately before raising mcu_ready, because the refill window sets
SR[TEF] by construction and a latched TEF makes the monitor recover
forever.

§9 step 3's stated provocation does not work: halting the core starves
nothing, because enableDebugMode=false leaves the eDMA running and the
rings self-loading.

Instrumentation retained deliberately (dbg_uart.c, wire probe, register
dumps, the LINK_SKIP_GAP_WAIT experiment switch) -- the switch is how
the boot experiment gets re-run, and the alignment mechanism is still
open.

make check 29/29, make test 6 suites, check-copies 4 files, repo pytest
542 passed, make lint clean.
Gate 1 -- enumerates at High Speed:

    hurra-adapter  0x1209:0x0001  serial 000000000001
    Speed: Up to 480 Mb/s

pid.codes VID, not an invented one: a random VID squats on a real
vendor's assigned ID and can misbind drivers. Serial is the fixed
placeholder per section 10 -- DIEID is revision/die and identical across
boards, real identity is in the PUF, and no PUF transaction may sit on
the boot path between mcu_ready and the foreground loop.

Gate 2 -- console stats match JTAG. Same 22.4 s window, MCU 178,930
slots against the FPGA's 178,960: 99.983% agreement, 30 slots of drift,
which is the ~4 ms sampling skew between the two reads at 8 slots/ms.
Both report zero errors.

Gate 3 -- the step-2 link gate under sustained bulk console load, which
is the empirical check on section 3's core-placement decision:

    host read 178,520,064 B (6,339.5 KiB/s in), wrote 947,418 B out
    spi_slots 8003.66/s; bad_sof/crc/length/type, queue_full,
    link_losses, rx_invalid all 0.00/s over 27.9 s

6.2 MB/s of HS bulk through the console on CPU0 and the link does not
drop a slot. Section 3 argued CPU0 has a 107.93 us STAGING WINDOW rather
than a latency deadline, because LPSPI6's FIFO is 8x32 bits = exactly
one 32-byte slot. That is now measured rather than argued.

THE BOOT MITIGATION IS NOT COMPLETE, and step 3 overstated it.

All 40 trials behind "20/20 clean with the gap wait" used a
flash-triggered reset. A board running the default build -- gap wait
present -- then came up mis-framed after a VBUS power reset, observed
directly at recovery #959 with the ladder looping and not repairing. The
result does not generalise to power-on. The hazard is reduced by an
unknown amount, not removed, and whatever explains the mechanism has to
explain this too. Recorded in section 10.

Corrections to the design doc, all forced by measurement:

- Section 7's "usb_phy.{c,h}, the only file from middleware/usb/" is not
  achievable. usb_phy.c includes usb.h, which pulls in usb_misc.h,
  usb_spec.h and fsl_os_abstraction.h -- 944 lines of the NXP USB stack
  plus the OSA layer, BOTH excluded by name in the same section. Its
  whole contribution on this part is TRIM_OVERRIDE_EN, two CTRL bits,
  PWD = 0 and the TX trim, so it is hand-written instead, against two
  independent upstreams that state the sequence register for register.
- The ISR is USB1_HS, not USB0. USB0_FS is routed nowhere on this board
  (UM12018: only the HS controller and PHY are used). Root is
  USB1_HS_DriverIRQHandler, verified T 0x3d54 from usb_console.o.
- Section 10's rhport resolves to 1, and re-reading mattered for a
  different reason than the number: ci_hs_mcx.h IGNORES the argument --
  CI_HS_REG(port) discards port and always uses controller 0 -- so a
  wrong rhport would not fail where anyone would look. tusb_init(rhport,
  NULL) likewise ignores its own rhport.
- The CH32 console has no command surface to carry over. usb_cdc_fs.c is
  644 lines of endpoint transport with no parser, and main_v5f.c never
  reads a byte out of its RX ring. The real precedent is step 3's
  link_report() UART line, so stats mirrors its field names.
- Section 7's "~17 lines of glue" was optimistic; 179 guarded lines, a
  third of which is clock/PHY and the UART status line. The number that
  matters is the split: 234 lines of console logic with ZERO guarded
  lines and a host test per command.

Two comments in link.c claimed the framing monitor repairs a mis-framed
boot, one concluding "alignment converges rather than depending on a
lucky boot" -- exactly what step 3's own experiment disproved. Corrected
to the distinction that holds: a RECOVERY whose re-arm lands mis-framed
is repaired in 60 ms; a BOOT that comes up mis-framed is not.

TinyUSB pinned at tag 0.20.0 (3af1bec1), extracted read-only with
git archive -- no checkout, worktree or fetch against the user's clone,
verified still at 2a364ca27 and clean. 26 files, byte-exact. MIT is now
the third licence in the tree.

A measurement trap, recorded because it cost one wrong FAIL: step 3's
ERR051588 demo arms ~20 s after boot and runs two deliberately-broken
windows, so a gate measured at 30 s reads spi_bad_sof 174/s and looks
like a hard failure. Measure at least 70 s after a reflash.

make check 39/39, make test 7 suites, check-copies 4/4.
Step 4's commit recorded a mis-framed boot on the default build (gap
wait present) as evidence that "the 20/20 result does not generalise to
power-on". The event was a mis-plug while J11 was being connected, not a
clean VBUS power-on, so it is not evidence about the mitigation in
either direction.

The honest state is narrower and worth stating exactly: all 40 trials
used a flash-triggered reset, so coverage on a power-on reset is
UNTESTED. That is different from "shown to fail", which is what the
previous wording implied. Establishing the power-on rate needs its own
trials.

Recorded rather than deleted so the observation is not re-derived later
as a finding.
Section 9's gate for this step says to run three configurations and stop if any
fails. Configuration (a) failed.

With the core1 region erased, both words of CPU1's vector table read
0xFFFFFFFF. CPU1 faults on its first instruction fetch, escalates to LOCKUP,
and resets the whole part -- CPU0 boot-looped continuously, re-running
link_init() forever, and the link never reached steady state. Section 4 claims
that if CPU1 "was never flashed at all, the FPGA link runs normally": that is
exactly the clause that does not hold on this silicon.

core1_image_valid() is the fix. CPU0 reads the two vector words and releases
CPU1 only if the initial MSP lies in CPU1's RAM 8-byte aligned and the reset
vector lies in CPU1's flash with bit 0 set. It reads two words and waits for
nothing, so section 8's objection to MCMgr does not apply. The lockup-to-reset
path itself is inferred, not confirmed against the RM: what is measured is that
a blank region boot-loops CPU0 and a valid one does not, nothing else changed.

Measured with the fix, FPGA counters over JTAG, each over a ~43 s window:

  (a) core1 erased, blank-checked   7999.97 slots/s  all gated counters +0
  (b) both images flashed           7995.75 slots/s  all gated counters +0
  (c) CPU1 stopped mid-execution    7999.92 slots/s  all gated counters +0

spi_bad_sof, spi_bad_crc, spi_bad_length, spi_bad_type and spi_queue_full were
flat in all three, link_losses did not move, and no counter on the MCU's own
console grew during any window. CPU1 ran at 720 heartbeats/s in (b) and was
frozen in (c).

Configuration (c) is measured with a console command rather than a debugger.
LinkServer's gdbserver attached to cm33_core1 without ever halting it -- the
heartbeat advanced at 720/s straight through a supposed halt, so the first (c)
result was really a second reading of (b) -- while reporting pc = 0x00000000
and reading the shared window correctly over the same connection. cpu1halt has
CPU0 re-assert CPU1's reset: deterministic, no debugger, and harsher than a
halt because reset is asynchronous and can land mid-store. CPU0 already owns
that reset line, so this is not a command to CPU1 and section 3's one-way
data-only IPC rule is intact. cpu1start is the explicit re-release command
section 3 asks for in any case.

Also measured, and each cost real time:

* CPU1 is not the same core. __FPU_PRESENT 0, __DSP_PRESENT 0, __MPU_PRESENT 0
  against core0's 1 and 1. Core1 builds -mcpu=cortex-m33+nodsp -mfloat-abi=soft;
  CMSIS stops the build outright on core0's hard-float ABI, which is how this
  was found. The missing MPU means section 10's NonCacheable problem cannot be
  solved with an MPU region on the core that will own the display's eDMA1.

* CPU1's vendored SystemInit() writes ten chip-wide locations in SYSCON, SPC0,
  GDET0/1 and ITRC0, including an RMW of SPC0->CORELDO_CFG, and would run them
  after mcu_ready is already high. Declined: src/system_core1.c touches only
  SCB->CPACR and SCB->VTOR. Whether replaying NXP's sequence under a live link
  would have been tolerated is untested, not disproven.

* A halted MCU is indistinguishable from the boot-random frame offset from the
  FPGA side: mcu_ready floats high and spi_bad_sof saturates 1:1 with
  spi_slots, which is section 10's signature for that hazard. The debug UART
  tells them apart -- a mis-framed board still prints its periodic report, a
  halted one prints nothing. LinkServer only runs what it wrote when its log
  ends with "restart on reset"; both erase-then-load and load -e leave the part
  stopped. Its default -u check stalled over three minutes, twice; -u none is
  now the Makefile default.

* boot_multicore_slave.c is not "already in the vendor set" and is not
  vendorable: it is gated on __MULTICORE_MASTER and refers to
  __core_m33slave_START__, which the byte-exact core0 script does not define.
  Reimplemented as src/core1_release.c exactly as section 8 prescribes.

New gate rungs, 73 assertions passing: core1's lowest LOAD PhysAddr read from
the ELF, both images placing g_shared_window at the same address, per-image
forbidden-symbol lists rejecting strong definitions only, a disassembly
allowlist proving core1's SystemInit stores only CPACR then VTOR, and rung 6
reconstructing core1_release's CPBOOT/CPUCTRL stores. Rung 6 initially failed a
correct image because GCC emits both MMIO constants as Thumb-2 immediates
rather than pool words; an intermediate draft contorted the firmware with
inline asm to satisfy it, and shipped a silent early-return with it. Both are
gone -- the checker learned about immediates, and its self-test now carries the
real objdump output of the built function verbatim.
Section 5 called cross-core cache coherency "strongly supported, UNVERIFIED"
and asked for a design where being wrong would be cheap and visible. It is now
measured rather than argued.

CPU0 publishes the section 5 seqlock snapshot from the retirement path at
~8 kHz; CPU1 reads it at ~20 Hz, drives the blue LED from bit 11 of
slot_counter, and echoes the slot_counter it actually read back into the shared
window. Measured on hardware over a 43 s window:

  spi_slots 8000.23/s, every gated FPGA counter +0, link_losses 0
  cpu1 echo tracked cpu0's live counter, lagging 105 slots (13.1 ms)
  snapshot_fail = 0 -- not one bounded read gave up
  cpu1 heartbeat 720/s, no console error counter grew

13.1 ms is the 20 Hz sampling interval, not staleness. LPCAC was left enabled
and the window stayed at 0x2004C000, so none of section 5's escalation steps
(__DSB(), disabling LPCAC, moving to SRAMX) were needed. volatile plus __DMB()
is sufficient. That closes the LPCAC item in section 10.

The echo is the reason this is a measurement and not an impression. Section 5
proposed watching slot_counter on the panel and section 9 step 6 proposes the
RGB LED; both need a human looking at the board and yield no number.
cpu1_seen_slot_counter turns the question into two numbers on the console that
either track or do not. It is the same category as the CPU1 heartbeat that
section 3 already endorses -- data CPU0 observes and never waits on -- so the
one-way IPC rule holds. The LED still blinks at ~1.95 Hz; it is simply no
longer the only evidence.

Two more doc discrepancies, both found by building it:

* Section 5's snapshot struct does not add up to the size it claims. The named
  fields total 28 bytes, so the _pad[3] shown gives 28, not 32. link_snapshot.h
  keeps every named field and widens the padding to _pad[7] to realize the
  stated 32-byte ABI rather than silently shipping 28 or hiding it behind an
  alignment attribute. The module is link_snapshot.{c,h}; section 5's
  shared_link_status.c never existed here.

* Step 6 says "the RGB LED" without saying which. CPU0 owns green for its
  foreground heartbeat and section 3 reserves red for a hard link fault, so
  CPU1 gets blue (P1_2) -- which also keeps it off Port 0 entirely at this step.

Recorded for step 7 rather than fixed now: both cores necessarily share the
clock-gate registers, and section 4(a)'s ladder is what makes that race-free,
since CPU0 finishes all its clock setup before rung 8 releases CPU1. That gets
sharper at step 7, where section 2 puts the display's CS, D/C, WR and RD on
Port 0 -- the port CPU0's LED code is still writing at 1 Hz. Section 2's "no J8
pin is on Port 3, so there is zero physical overlap with the link" is true and
is not the same claim as section 4(b)'s "disjoint pins": the pins are disjoint,
the port registers are not. PSOR/PCOR are write-1-to-act and safe for disjoint
bits; PDDR is an RMW and must stay inside the boot ordering.

Gate: 78 static assertions, 12 host test binaries, including a seqlock test
that interleaves the reader at every writer store offset via hooks that compile
out of both images.
CPU1 drives the LCD-PAR-S035 on J8 over FlexIO0 8080 16-bit + eDMA1 + DBI +
ST7796S, composing a text page from the step-6 snapshot at ~20 Hz. Section 9's
gate has two halves and both are measured:

  repaint running, 45 s   spi_slots 8000.82/s  all gated counters +0  18.3 fps
  5(c) mid-render, 40 s   spi_slots 8026.10/s  all gated counters +0  halted

link_losses was 0 in both and no counter on the MCU's own console grew. cpu1halt
during a blit stops CPU1 with an eDMA1 transfer potentially in flight to the
panel, and the link is untouched. snapshot_seq kept advancing at 15,376/s
through that window with no reader at all, which is section 5's "the writer
never waits on CPU1" shown rather than argued. cpu1start restored rendering at
20.5 fps with boot=2.

The repaint rate is a counter, not an impression. Section 9 asks for "20 Hz
repaint", which is a rate, and a rate cannot be established by looking at a
panel. CPU1 publishes cpu1_frames and a panel= state into the shared window and
stats prints both, so both halves of the gate are one measurement. panel=
reports ABSENT / ok / ok-with-FAULT, because CPU1 deliberately keeps its
heartbeat, echo and LED alive when display_init() fails -- correct, since those
are how CPU0 observes CPU1 at all, but it leaves a blank screen with a live
heartbeat otherwise indistinguishable from a panel that was never detected.
This is the same move step 6 made for the LPCAC question.

No full framebuffer, per section 2: an 8x16 character/attribute grid with
dirty-cell diffing coalesced into row runs, rasterised into two ping-ponged
480x16 row buffers. text_grid and glyphs are portable and host-tested -- the
diffing tests are the interesting ones: unchanged text produces no runs,
adjacent changes coalesce into one, a clean cell between two changes produces
two. core1 RAM lands at 37 KB against section 2's ~33 KB estimate.

Two design-doc discrepancies, both recorded rather than quietly worked around:

* Section 2's "No J8 pin is on Port 3, so there is zero physical overlap with
  the link" is true and is NOT section 4(b)'s "disjoint pins". CS (P0_12),
  D/C (P0_7), WR (P0_9) and RD (P0_8) are all on Port 0 -- the port CPU0's
  heartbeat LED code is still writing at 1 Hz. The pins are disjoint, the port
  registers are not. PSOR/PCOR are write-1-to-act and safe for disjoint bits;
  PDDR and the PCRs are RMW, so CPU1 may only touch them during its own init,
  which is safe solely because section 4(a) releases CPU1 at rung 8 after CPU0
  has finished its own pin and clock setup. Not "fixed" with a lock: a lock
  between the cores is what section 5 rejects outright.

* baudRateDiv = 6 is shown sufficient, not shown correct. Section 10 derives it
  from an ILI9341 tWC of 66 ns and the LCD-PAR-S035 datasheet is still not on
  disk. The real panel initialises and holds ~20 Hz at that divider, which is
  not the same as knowing the ST7796S margin -- a marginal write cycle presents
  exactly as a working panel. DISPLAY_FLEXIO_BAUD_DIV is one constant carrying
  that provenance, and section 10 now says "narrowed, not closed".

Rung 5's forbidden-symbol lists stop being vacuous here: core0 is now asserted
to contain no strong FlexIO / eDMA1 / ST7796S / DBI / display / text_grid
symbol, and those symbols finally exist in the other image.

What this does NOT establish is the pixels. Every number above concerns the
transport running at rate and costing the link nothing; whether the composed
page is legible and correct needs someone looking at the panel.

Gate: 95 static assertions, 16 host test binaries.
…FIED

The panel came up black at step 7 with every counter healthy: panel=ok, blits
running at ~537/s, zero rejections, DMA completing. An 8080 bus has no
handshake, so a controller that receives nothing looks identical to one that
receives everything.

A solid-red fill pushed through the identical SelectArea + WritePixels path
never appeared either, which rules out the renderer -- text grid, glyphs,
dirty-run coalescing and colours are all downstream of a bus that was not
working. That fill is kept as display_fill_screen(): "blits succeed and the
screen is black" is not a diagnosis, and separating a dead bus from a renderer
that draws nothing is worth one obvious look at the board.

Checked against NXP's own lvgl_support.c and pin_mux.c for this exact panel on
this exact board, and MATCHING, so none of these were the bug: pin assignments
(CS P0_12, D/C P0_7, RST P4_7, WR P0_9, RD P0_8, data D16..D31), mux alternates
(ALT6 FlexIO, ALT0 GPIO), shifter and timer indices, the baud divider, and the
setCSPin/setRSPin callback wiring.

Three real differences found, all fixed here:

1. PORT_SetPinConfig, not PORT_SetPinMux. PORT_SetPinMux writes ONLY the PCR
   MUX field, leaving PCR[IBE] -- input buffer enable, bit 12 -- at its reset
   value. NXP sets kPORT_InputBufferEnable on every one of these pins. With IBE
   clear the shifters still drain and every transfer still completes, which is
   exactly the symptom observed.

2. The reset pulse was 1 us. NXP holds RST low for 1000 us, then waits 5 ms.
   Three orders of magnitude short, and a controller that never reset ignores
   the init sequence while acknowledging nothing.

3. Transport before reset, not after. FLEXIO_MCULCD_Init is what puts WR, RD
   and the data lines into a defined state; releasing the ST7796S from reset
   before that lets it sample a floating bus as it comes up. NXP brings up
   FlexIO and the DBI handle first and only then pulses RST.

Also adds cpu1_blits / cpu1_blit_rejects to the shared window and `stats`, so
"is anything reaching the glass" is a number rather than an inference. frames
alone cannot answer it: a frame with no dirty runs completes successfully
having sent nothing.

NONE OF THIS IS VERIFIED ON HARDWARE. The board became unreachable during this
work and has not been recovered: the debug port answers (DpID 6BA02477) but
only AP 2, the debug mailbox, is present -- there is no AHB-AP, so CpuID reads
0 and LinkServer reports "check target power" (which means CDBGPWRUPACK did not
assert, not that the board is unpowered; its power LED and the panel backlight
were on throughout). The mailbox itself returns AHB_OR_ERR/DBG_OR_ERR and will
not grant a debug session, and it is the boot ROM that services it.

Recovery attempts that all failed are recorded so nobody repeats them: power
cycle, panel unplugged, Cynthion ribbon unplugged, SW3/ISP held across RESET,
probe-driven ISP on all four ISP_CTRL lines, romstalldelay to 8000, JTAG,
gdbserver, blhost on every VCOM, flash resurrect, and a custom .scp doing a
500 ms reset then START_DM_AP before DEBUG_SESSION_REQ.

Whether change 1 is implicated in the lockout is NOT established. It alters the
electrical behaviour of 16 pins wired to an external panel, which makes it the
obvious suspect, but the board stayed dead with the panel unplugged. It should
be reintroduced on its own, with the panel and the Cynthion ribbon
disconnected, on a board known to be healthy.

The steps 5-7 gate results committed earlier are unaffected: they were measured
before any of this.
…struction

The panel now paints: a full-screen fill clears the power-on GRAM and the text
grid renders. Two independent defects, each invisible to every counter the
firmware has, because an 8080 write is unacknowledged.

1. CPU1 could not drive GPIO at all.

GPIO is TrustZone-aware and carries a per-pin secure filter, PCNS (offset 0x10),
which resets to 0 -- every pin secure-access-only. CPU1 has no SAU and can only
issue non-secure transactions: AHBSC->MASTER_SEC_LEVEL resets to 0x80000000,
CPU1's field [3:2] = 00b. So RGPIO discarded CPU1's writes and returned zero on
its reads, with no bus fault.

Measured from CPU1: GPIO0->PDDR read 0 while CPU0 visibly drove P0_27, and all
four views behaved the same -- non-secure, ALIAS1, both secure aliases. Yet
PORT0->PCR[7] read 0x1000, PORT2->PCR[8] read 0x1600, FLEXIO0->CTRL read
0xC0000005 and AHBCLKCTRL0 read 0x04DBE7FF with the GPIO gates set. PORT and
FLEXIO have no per-pin filter; that asymmetry is the whole story.

CS (P0_12), D/C (P0_7) and RST (P4_7) are GPIO, so none of them were ever
driven. The data lines and WR strobe were correct the entire time, feeding a
panel that was never selected and never reset.

core1_grant_gpio_nonsecure() is the handover, called from main_core0.c strictly
before core1_release(). It grants only the pins CPU1 owns -- P0_27 stays secure,
so CPU1 still reads that bit as 0, which is correct. PCNS is writable only by a
secure-privileged master, so CPU1 could never have done this itself.

The cheap diagnostic is the blue LED: CPU1 drives P1_2 via GPIO1, so blue dark
while stats reports CPU1 alive means CPU1's GPIO is dead.

2. Every command parameter was silently dropped.

Core1 was built -DMCUX_DBI_LEGACY=0, which sends parameters via
FLEXIO_MCULCD_WriteDataArrayBlocking(). On a 16-bit bus that does `size /= 2U`
and reinterprets the parameter BYTE array as uint16_t, so a one-byte parameter
becomes ZERO bus beats. COLMOD, MADCTL, TEON and the CSCON unlock keys were
never transmitted; CASET/RASET sent 2 beats instead of 4 and lost both end
addresses. Only param_len == 0 commands survived, which is exactly why SLPOUT
and DISPON worked while nothing else did.

The panel therefore woke and lit but was never configured, showing the random
power-on GRAM that ST7796S §9.2.22 says no reset clears. That "frozen static"
survived every bus-rate, DMA-path and pin-level change because none of them
touched parameter framing.

The default path widens each parameter byte into its own beat and is what NXP's
own working frdmmcxn947 examples use, so this moves to the legacy DBI API:
dbi_flexio_edma_xfer_handle_t, DBI_FLEXIO_EDMA_CreateXferHandle(), the four-arg
ST7796S_Init(), and ST7796S_SetMemoryDoneCallback() on the panel handle.
fsl_dbi.c implements only the non-legacy helpers, so it leaves CORE1_OBJECTS --
left in, --gc-sections discards it whole and make check rejects that.

Also closes the board.h P0_10 vs P0_7 D/C question PROVENANCE left open for
whoever wrote step 7: P0_7 is correct, per Zephyr's frdm_mcxn947.dtsi and
UM12018 Table 21. Our pin assignments match NXP's pin_mux.c field for field,
PCR values included. Pin assignment is not a display-fault candidate.

What did NOT matter, all measured rather than argued: bus rate (tested at
12.5 MHz and 1.875 MHz), eDMA versus blocking transfers, FLEXIO_MCULCD_DATA_BUS_WIDTH
(correctly 16), posted-write ordering on D/C and CS, the addressable window, the
backlight (hardwired), and FlexIO visibility from core1. The three bring-up
fixes in 0bd48bc are on the board and were not the cause either, though the IBE
half of that change does match NXP.

Known and not addressed: ~7 fps at 25 blits/frame, under section 9's 20 Hz
target, because legacy framing costs ~10 CS-framed transfers per SelectArea.
The display is non-load-bearing and the link is unaffected.

Verified on hardware: fill then readable text, link clean (sof/crc/len/typ all
zero, framing 0), panel=ok, blitrej=0. 94 image gates and 14 host tests pass.
The panel now says something useful at a glance. Three changes plus a
documentation correction that the LED work forced.

Two-column page, split at column 30. Left is for a person: link state, faults,
reports, rate, USB frame phase, descriptor and map generations, and liveness --
decimal, decoded, and colour-coded. Right is the raw register image in hex, now
complete rather than the partial set it was. Neither side is derived from the
other, so the left can be read without trusting the decode and the right stays
the wire contract verbatim. The page previously used 13 of 20 rows and about a
third of the width, and never showed native_report_count, usb_frame,
usb_subframe, descriptor_generation or map_generation at all.

The report rate is derived from the slot counter, not a clock. CPU1 has no time
base, but the link delivers one slot every 125 us, so slot_counter IS a clock at
8 kHz and a ratio against it converts to real time. Note that "slots per second"
derived the same way would be tautologically 8000 -- a fake number -- so link
health is reported as ALIVE or STALLED instead. display_report_rate() is pure
and host-tested directly, including the divide-by-zero and the 64-bit
intermediate that keeps a large delta from overflowing before it divides.

SHARED_DISPLAY_FLAG_FAULT now means what shared_window.h already documented it
to mean. display_start_frame() returns false both for a transport failure and
for a repaint tick arriving while the previous frame is still in flight; the
caller treated both as faults, so the first overlap latched the sticky flag
forever and panel=ok/FAULT stopped carrying information. Only a genuine
transport failure sets it now. Overlap is normal pacing and needs no flag --
cpu1_frames already shows the achieved rate.

The boot fill no longer holds for three seconds. The fill itself stays and is
load-bearing: ST7796S GRAM is random at power-on and no reset clears it (§9.2.22),
so without the fill the noise would persist anywhere the text grid never paints.
The hold existed only so a human could catch the fill during bring-up, and the
delay loop stretched its nominal 3 s to roughly 15 s of CPU1 spinning at every
boot.

The blue status LED is now a two-beat cardiac pulse rather than a square wave:
2.048 s cycle, 256 ms beat, 192 ms gap, 192 ms beat, 1408 ms rest. A brightness
fade was the obvious idea and is not achievable here -- the LED is written from
inside the snapshot branch at CORE1_SNAPSHOT_HEARTBEATS = 36, so roughly 20 Hz,
which is one bit of brightness at 50 ms granularity and far below flicker fusion.
Every segment is at least 1536 slots so the shortest gets three samples at the
real call rate and the beats can neither merge nor vanish. The policy stays a
pure function of slot_counter, so a stalled link freezes the animation rather
than blinking on past the data, and the 21.9% duty parks a stall dark rather
than at a misleading steady-on.

That last point invalidated a bring-up diagnostic added two commits ago, and
PROVENANCE.md is corrected accordingly: "a dark blue LED means CPU1's GPIO is
dead" was true of a 50% square wave and is wrong now, because the LED is
deliberately dark most of the time. The test is that it never ANIMATES -- watch
a full 2.048 s cycle for the double-beat. Two other stale references to the old
bit-11 / ~1.95 Hz blink are fixed in the same pass.

Cost, stated plainly: the richer page raised blits per frame from 25 to about 49,
so the repaint rate fell to roughly 10 fps. The display is non-load-bearing and
the link is unaffected.

Verified on hardware: page renders and is readable, LED shows the double-beat,
panel=ok with the flag no longer latching, link clean. 94 image gates and the
host suite pass, with display and status_led tests extended rather than merely
adjusted -- the LED test asserts the sampling margin from every phase offset at
six sampling rates, and was mutation-checked against the old waveform.
…ld was the only one still paying

PROVENANCE.md measures the eDMA retirement ISR at ~10 us of each 125 us slot,
"nearly all of it the bitwise CRC-16" over 30 bytes, 8,000 times a second. Two
changes, both portable C, no MMIO and no netlist:

1. spi_frame_crc16() becomes the table-free bytewise reduction: the eight
   shift/xor steps per byte collapse into three shifted xors of one
   intermediate. ~68 instructions per byte becomes ~10.

   This only had to be done because of a toolchain asymmetry nobody had
   noticed. tests/test_wire_cross_language.py already records that GCC 15
   rewrites the bitwise loop into a 256-entry table -- and the CH32/RISC-V
   build gets GCC 15, so it did. The ARM toolchain is GCC 14.2, which has no
   CRC idiom recognition, so objdump on build/core0.elf shows the MCXN947
   image shipping the loop verbatim. Same source, same contract, one target
   silently paying 7x.

   The new form's shift counts ARE the polynomial: 12, 5 and 0 spell
   x^16 + x^12 + x^5 + 1. INJ_CRC16_POLY is generated from
   protocol/report_injection_wire.json, so a _Static_assert pins it -- changing
   the poly there must fail the build rather than leave these shifts quietly
   disagreeing with every other implementation of the contract.

2. link_retire_slot() short-circuits an exact keepalive. PROVENANCE records
   about 1 slot in 8 carrying a real REPORT_FRAGMENT at ~1 kHz, so roughly 87%
   of all CRC work in the system was validating a byte-constant keepalive.

   The compare is EXACT, not an approximation: a slot byte-identical to the
   canonical keepalive necessarily carries the right SOF, a known type, the
   right length and a matching CRC, so spi_frame_unpack() could only have
   returned SPI_FRAME_IDLE. Anything differing falls through untouched.

   The constant is built by spi_frame_pack() in link_retire_reset() rather
   than written out as literal bytes, so it cannot drift from the wire
   contract. Before that reset it is all zero, whose byte 0 is not
   INJ_FRAME_SOF, so a missed reset costs speed and never correctness.

test_a_corrupted_keepalive_is_not_counted_as_idle() pins the reason the compare
must stay a FULL compare. Weaken it to "the type byte says IDLE, so skip the
CRC" and a frame whose type was corrupted to IDLE is counted healthy, blinding
bad_crc -- the counter whose flatness is the running proof the link is clean and
which the ERR051588 ladder reads. Verified by deliberately building that weak
version: the test fails on it and passes on this one.

The speedup is derived from instruction counts, NOT measured on hardware. What
is verified: 541 passed / 1 skipped, both firmware trees green, check-copies
byte-exact across both trees, and the RISC-V codegen test now compiles the real
source under GCC 15.2 and finds no table -- the reduction gives even the
table-izing compiler no loop to recognise. Its skip message was updated to say
so rather than still claiming a bitwise loop.
The gateware side of report injection was complete and tested; the MCU only
ever staged IDLE, so nothing was ever injected. This adds the controller half.

- inj_command.{c,h}: frame builders for MAP_BEGIN/ENTRY/COMMIT and RELATIVE,
  plus a table-free CRC-32 for the map's entries_crc32. The builders fill the
  generated, offset- and size-asserted packed payload structs and hand them to
  spi_frame_pack(), so the wire layout has one source of truth; the CRC-32
  constants come from the generated contract macros so they cannot drift.

- inj_session.{c,h}: the upload/inject state machine. It learns the target
  report's addressing (interface/endpoint/report_id/length) and descriptor
  generation from an observed REPORT_FRAGMENT rather than hardcoding them,
  uploads a fixed boot-mouse field map (X at byte 1, Y at byte 2), waits for
  the MAP_STATUS commit, then emits RELATIVE motion. This order is mandatory:
  the FPGA's command_fresh gate only acts on a command once a map of the cited
  generation is active. On re-enumeration the descriptor generation changes and
  the session re-learns and re-uploads.

- link.c: the RX retirement ISR now drains link_retire_receive() into the
  session (REPORT_FRAGMENT and MAP_STATUS) and refills the TX bank the DMA does
  not own with the session's next frame. Both are gated on mcu_ready, which the
  FPGA also uses to invalidate its map and RX sequence window, so an ERR051588
  recovery tears down and re-establishes cleanly rather than contending.

Injected motion is a fixed gentle drift (X/Y = -2 at ~40/s); at the 8 kHz slot
rate anything larger flings the cursor. It is static by design -- the milestone
was reaching live traffic, not servoing.

Verified: mcxn947 host tests (inj_command, inj_session) and make check 96/96;
a new end-to-end test drives the map + RELATIVE through the real gateware sim
and asserts the commit is accepted and a live report is mutated additively.
Confirmed on hardware: a mouse's reports drift diagonally from the injected
delta, and hot-plugging a different mouse re-learns and keeps injecting.
The instrument carried a free-running 0..255 counter in the report's Y axis as
the motion delta, for drop/reorder detection. As a relative delta at the 8 kHz
microframe rate that moved the cursor at roughly half a million counts/second --
far too fast to see anything injected into it.

This traces a slow circle instead: sinf/cosf against a float position, emitting
the rounded integer delta each report so sub-count motion accumulates rather
than truncating to zero. At the default radius and period the deltas are 0 or
+/-1, so the motion is smooth and gentle and injected drift is obvious on top
of it. Radius and period are constants in main.c.

The payload sequence counter, and thus drop/reorder detection, is dropped for
this build. The 8 kHz report rate is unchanged, so link-speed-by-measurement
still works; the README is updated to match.
The README had drifted: it asserted an unconfirmed ECP5 speed grade as fact,
recommended a yosys version that does not close timing on this design, cited a
placer seed that had since changed, repeated a timing claim that cannot happen
in the production flow, and linked three docs that were deleted long ago. It is
rewritten and corrected, trimmed to the practical build/load/use/debug path,
and made current on the MCU (a Report injection section covering the MCXN947 and
CH32H417 over the shared wire contract).

CLAUDE.md and docs/ are now gitignored -- kept locally (Claude Code still reads
CLAUDE.md, the engineering notes stay on disk) but out of the published tree.
The RP2350 controller design record, a path evaluated and not taken, is removed
outright. The firmware PROVENANCE.md files stay tracked: they are vendored-code
attribution, not notes.
Copilot AI lite review requested due to automatic review settings September 18, 2026 07:15
@ramseymcgrath
ramseymcgrath merged commit 0c06326 into main Sep 18, 2026
29 of 31 checks passed
@ramseymcgrath
ramseymcgrath deleted the feat/mcxn947-controller branch September 18, 2026 07:19

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

TinyUSB’s ci_hs_regs_t uses repeated TU_RESERVED member names in a struct, which is invalid C and will break compilation.

Pull request overview

This PR introduces the MCXN947-based controller firmware stack (including vendored TinyUSB + MCUX SDK subsets and host-side tests) and updates the existing Teensy HS “mouse instrument” to produce a slow, visually legible motion pattern. It also updates cross-language CRC test expectations to match the new table-free CRC implementation.

Changes:

  • Add MCXN947 controller firmware modules (platform bring-up, SPI framing/CRC, CPU0→CPU1 snapshot IPC, CPU1 display/LED plumbing) plus host tests and a core0/core1 image merge tool.
  • Vendor subsets of TinyUSB and the NXP MCUX SDK required for MCXN947 USB HS + peripherals, with licensing/provenance support.
  • Update the Teensy HS mouse demo to trace a slow circle and adjust CRC codegen test skip messaging for the table-free CRC reduction.
File summaries
File Description
tests/test_wire_cross_language.py Updates skip messaging for CRC-table expectations.
firmware/teensy_hs_mouse/src/main.c Changes synthetic mouse motion to a slow circle; adds math usage.
firmware/teensy_hs_mouse/README.md Updates instrument description to match slow-circle behavior.
firmware/mcxn947/vendor/tinyusb/src/tusb.h Adds TinyUSB public header (vendored).
firmware/mcxn947/vendor/tinyusb/src/portable/chipidea/ci_hs/ci_hs_type.h Adds ChipIdea HS register/type definitions (vendored).
firmware/mcxn947/vendor/tinyusb/src/portable/chipidea/ci_hs/ci_hs_mcx.h Adds MCX N9 ChipIdea HS glue (vendored).
firmware/mcxn947/vendor/tinyusb/src/osal/osal.h Adds TinyUSB OSAL abstraction header (vendored).
firmware/mcxn947/vendor/tinyusb/src/device/usbd_pvt.h Adds TinyUSB internal USBD definitions (vendored).
firmware/mcxn947/vendor/tinyusb/src/common/tusb_verify.h Adds TinyUSB verify/assert macro utilities (vendored).
firmware/mcxn947/vendor/tinyusb/src/common/tusb_debug.h Adds TinyUSB debug/logging utilities (vendored).
firmware/mcxn947/vendor/tinyusb/LICENSE Adds TinyUSB license file.
firmware/mcxn947/vendor/mcux-sdk/devices/MCXN947/system_MCXN947_cm33_core1.h Adds MCXN947 core1 system header (vendored).
firmware/mcxn947/vendor/mcux-sdk/devices/MCXN947/system_MCXN947_cm33_core1.c Adds MCXN947 core1 system implementation (vendored).
firmware/mcxn947/vendor/mcux-sdk/devices/MCXN947/system_MCXN947_cm33_core0.h Adds MCXN947 core0 system header (vendored).
firmware/mcxn947/vendor/mcux-sdk/devices/MCXN947/system_MCXN947_cm33_core0.c Adds MCXN947 core0 system implementation (vendored).
firmware/mcxn947/vendor/mcux-sdk/devices/MCXN947/MCXN947_cm33_core1.h Adds MCXN947 core1 CMSIS peripheral header (vendored).
firmware/mcxn947/vendor/mcux-sdk/devices/MCXN947/MCXN947_cm33_core0.h Adds MCXN947 core0 CMSIS peripheral header (vendored).
firmware/mcxn947/vendor/mcux-sdk/devices/MCXN947/fsl_device_registers.h Adds MCUX device-register dispatch header (vendored).
firmware/mcxn947/vendor/mcux-sdk/devices/MCXN947/drivers/fsl_reset.c Adds MCUX reset driver implementation (vendored).
firmware/mcxn947/vendor/mcux-sdk/devices/MCXN947/drivers/fsl_lpflexcomm.h Adds MCUX LP_FLEXCOMM driver header (vendored).
firmware/mcxn947/vendor/mcux-sdk/devices/MCXN947/drivers/fsl_flexio_mculcd_edma.h Adds MCUX FlexIO MCULCD eDMA header (vendored).
firmware/mcxn947/vendor/mcux-sdk/devices/MCXN947/drivers/fsl_edma_soc.h Adds MCUX eDMA SoC configuration header (vendored).
firmware/mcxn947/vendor/mcux-sdk/COPYING-BSD-3 Adds BSD-3-Clause license text for SDK subset.
firmware/mcxn947/vendor/mcux-sdk/components/video/display/dbi/fsl_dbi.c Adds MCUX DBI display helper implementation (vendored).
firmware/mcxn947/vendor/mcux-sdk/CMSIS/Core/Include/tz_context.h Adds CMSIS TrustZone context header (vendored).
firmware/mcxn947/vendor/mcux-sdk/CMSIS/Core/Include/cmsis_version.h Adds CMSIS version header (vendored).
firmware/mcxn947/vendor/mcux-sdk/CMSIS/Core/Include/CMSIS_Include_core_cm.cmake Adds CMSIS include CMake fragment (vendored).
firmware/mcxn947/vendor/mcux-sdk/boards/frdmmcxn947/project_template/clock_config.h Adds board clock config header (vendored).
firmware/mcxn947/tools/merge_mcxn947_images.py Adds tool to merge core0/core1 images into a bounded flash blob.
firmware/mcxn947/test/text_grid_test.c Adds host test for text-grid dirty-run behavior.
firmware/mcxn947/test/shared_window_test.c Adds host test for shared-window reset semantics/ABI size.
firmware/mcxn947/test/platform_test.c Adds host test for SysTick reload calculation edge cases.
firmware/mcxn947/test/merge_mcxn947_images_test.py Adds tests for the image merge tool.
firmware/mcxn947/test/link_test.c Adds host test for link idle-slot inertness and ring geometry.
firmware/mcxn947/test/link_snapshot_test.c Adds host test for seqlock snapshot publish/read invariants.
firmware/mcxn947/test/inj_command_test.c Adds host test for CRC32 and command-frame builders.
firmware/mcxn947/test/heartbeat_test.c Adds host test for heartbeat cadence logic.
firmware/mcxn947/test/glyphs_test.c Adds host test for glyph table lookup behavior.
firmware/mcxn947/test/display_test.c Adds host test for display composition and rasterization behavior.
firmware/mcxn947/test/display_panel_test.c Adds host test for rectangle validation geometry.
firmware/mcxn947/test/core1_release_test.c Adds host test for CPU1 release/arithmetic validity checks.
firmware/mcxn947/src/usb_console.h Declares the guarded USB CDC console hardware half.
firmware/mcxn947/src/tusb_config.h Adds TinyUSB configuration for MCXN947 CDC console.
firmware/mcxn947/src/text_grid.h Adds portable text-grid model API.
firmware/mcxn947/src/text_grid.c Implements portable text-grid and dirty-run extraction.
firmware/mcxn947/src/system_core1.c Adds minimal core1 SystemInit/SystemCoreClock definition.
firmware/mcxn947/src/status_led.h Adds CPU1 status LED policy + HW interface.
firmware/mcxn947/src/status_led.c Implements status LED rhythm and (guarded) GPIO writes.
firmware/mcxn947/src/spi_frame.h Adds SPI frame format + sequencing API for injection wire.
firmware/mcxn947/src/spi_frame.c Implements table-free CRC16, pack/unpack, sequence classification.
firmware/mcxn947/src/shared_window.h Defines CPU0→CPU1 shared SRAM window ABI.
firmware/mcxn947/src/shared_window.c Implements shared window storage + reset semantics.
firmware/mcxn947/src/platform.h Declares CPU0 platform init + SysTick utility API.
firmware/mcxn947/src/platform.c Implements portable tick base and guarded clock/SysTick bring-up.
firmware/mcxn947/src/main_core1.c Adds CPU1 main loop for presentation (display/LED/snapshot echo).
firmware/mcxn947/src/main_core0.c Adds CPU0 main boot ladder (platform/link/shared window/USB/CPU1 release).
firmware/mcxn947/src/link_snapshot.h Defines portable seqlock snapshot ABI and API.
firmware/mcxn947/src/link_snapshot.c Implements snapshot publish/read with target vs host barrier selection.
firmware/mcxn947/src/link_recovery.h Defines ERR051588 recovery fault snapshot + ladder API.
firmware/mcxn947/src/link_recovery.c Implements recovery predicate and ordered ladder runner.
firmware/mcxn947/src/inj_session.h Introduces injection-session policy state machine (header).
firmware/mcxn947/src/inj_command.h Declares frame builders + CRC32 for injection commands.
firmware/mcxn947/src/inj_command.c Implements CRC32 and command frame builders via spi_frame_pack().
firmware/mcxn947/src/heartbeat.h Declares portable heartbeat + target GPIO/ISR hooks.
firmware/mcxn947/src/heartbeat.c Implements heartbeat logic and (guarded) SysTick handler + GPIO writes.
firmware/mcxn947/src/glyphs.h Declares fixed 8x16 glyph table API.
firmware/mcxn947/src/display.h Declares CPU1 page composition + incremental renderer API.
firmware/mcxn947/src/display_panel.h Declares panel transport API + portable rectangle validation.
firmware/mcxn947/src/dbg_uart.h Declares bring-up UART API for early diagnostics.
firmware/mcxn947/src/dbg_uart.c Implements guarded diagnostic UART TX over LPUART4.
firmware/mcxn947/src/core1_release.h Declares CPU1 release logic + safety predicates and commands.
firmware/mcxn947/src/console.h Declares CDC console portable half, stats model, and flood generator.
firmware/ch32h417/src/spi_frame.c Updates CH32 SPI CRC16 implementation to the table-free reduction.
docs/hardware/ch32-cynthion-wiring.md Removes CH32↔Cynthion wiring doc.
.gitignore Ignores local AI/engineering notes and docs directory.
.gitattributes Marks MCXN947 vendor subtree as whitespace-preserved import.
Review details
  • Files reviewed: 73/244 changed files
  • Comments generated: 0
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants