diff --git a/README.md b/README.md index c46abdd..e3e6961 100644 --- a/README.md +++ b/README.md @@ -43,11 +43,13 @@ Pokéwalker. [How step counting works](stick/docs/motion.md) explains the detail The original 96×64 screen appears at twice its size on the Stick's display. You can rotate it and choose a light or dark appearance. The speaker plays the original tone sequences. Your Pokémon, route and progress stay saved across -restarts and firmware updates. +restarts. Return your Pokémon to HGSS before updating pre-release firmware; +updates can require resetting game data. Walking continues while the screen is off. Hold M for half a second to wake it. -With the default controls, M moves left, R moves right, and M+R together -confirms. L opens Stick Settings. See [controls and settings](stick/docs/controls.md) +With the default controls, L moves left, M confirms, and R moves right. +The screen starts left-side-down. Hold L+R for one second to open Stick Settings. +See [controls and settings](stick/docs/controls.md) for the other button layouts. ## Help and contribute diff --git a/config/lint-suppressions.txt b/config/lint-suppressions.txt index b9b62e7..f323ac5 100644 --- a/config/lint-suppressions.txt +++ b/config/lint-suppressions.txt @@ -3,5 +3,5 @@ // The first expression adds a page to an EEPROM address. The battle subtraction // is unsigned and its subtrahend is capped at the current watt balance. // See docs/build.md. -integerOverflow:src/startup/h8_resetprg.c:302 +integerOverflow:src/startup/h8_resetprg.c:371 integerOverflowCond:src/application/pw_battle.c:443 diff --git a/docs/adr/0002-m-only-dark-wake.md b/docs/adr/0002-m-only-dark-wake.md index 503a9c9..901a46d 100644 --- a/docs/adr/0002-m-only-dark-wake.md +++ b/docs/adr/0002-m-only-dark-wake.md @@ -1,6 +1,9 @@ # Use M to wake the screen -Holding physical M for 500 ms wakes the Stick in every layout and rotation. +Holding physical M through native Center qualification wakes the Stick in every +layout and rotation, roughly half a second. The native firmware also retains +its social-offer opportunity; the adapter no longer starts interactive mode +from a separate 500 ms wall-clock threshold. L and R resume their configured roles after wake. Releasing M early cancels panel preparation. A held L or R still allows M to wake the display. @@ -10,8 +13,8 @@ and waits for all buttons to be released. A successful PMIC read is required to establish that L has been released. When the game or Settings is visible, the front buttons can wake the CPU and -sleep is capped at 100 ms for L polling. Awake polls run every 5 ms. Walking -and interactive game work normally supply a 62.5 ms deadline. +required button polls supply a 5 ms deadline. Walking and interactive game work +retain their native timing instead of accelerating to consume queued input. ## GPIO restoration @@ -22,7 +25,8 @@ keep the CPU awake until a scheduled retry. ## Checks -Host tests cover the adapter branches and sleep API calls. The +Production-firmware simulation covers timeout, canceled and qualified M holds, +held-input suppression, RTC timer/EXT1 wake and digital GPIO restoration. The [device test records](../../stick/docs/history/power-and-wake.md) record the button, panel and boot trials. USB keeps the CPU awake during bench commands; test physical sleep and wake on battery. diff --git a/docs/build.md b/docs/build.md index cdb3e61..ed1d73d 100644 --- a/docs/build.md +++ b/docs/build.md @@ -154,7 +154,7 @@ This requires the exact retail size and SHA-256 and writes `verified.json` on success. Run it under **both 6.02.01 and 6.02.02** for accepted matching-source changes. The build records also check that the image came from the current source, assets and compiler files. They catch stale objects even when timestamps -have been preserved. Host tests and formatting complement this check. +have been preserved. Firmware E2E checks and formatting complement this check. ## Clean, recover and work offline @@ -211,8 +211,9 @@ Cppcheck 2.21.0 separately, then run: uv run --group dev -m tools.check ``` -This checks C formatting, Python formatting and lint, Cppcheck with the H8 target -model, and the utility tests. To apply formatting: +This checks C formatting, Python formatting and lint, and Cppcheck with the H8 +target model. Run `uv run python stick/check.py` separately for the production +firmware workflows. To apply formatting: ```sh uv run --group dev -m tools.check --format @@ -221,11 +222,7 @@ uv run --group dev -m tools.check --format Formatting includes nested source and header directories. The accepted HEW device header at `include/startup/iodefine.h` keeps its original formatting. Cppcheck has two narrow suppressions for arithmetic whose evaluation follows -the target's 16-bit rules. To run only the utility tests: - -```sh -uv run -m unittest discover -s tests -``` +the target's 16-bit rules. `uv` is a convenience for a reproducible host environment. An existing Python environment with Ninja and Pillow can also run `python configure.py` and `ninja`; diff --git a/include/stick_wire_endian.h b/include/stick_wire_endian.h index 0e7cd6e..cb80fdf 100644 --- a/include/stick_wire_endian.h +++ b/include/stick_wire_endian.h @@ -7,8 +7,8 @@ * documented with a Le suffix remain raw forwarded bytes instead. */ static inline u32 StickReadBe32(const u8 *bytes) { - return ((u32)bytes[0] << 24) | ((u32)bytes[1] << 16) | - ((u32)bytes[2] << 8) | bytes[3]; + return ((u32)bytes[0] << 24) | ((u32)bytes[1] << 16) | ((u32)bytes[2] << 8) | + bytes[3]; } static inline u16 StickReadBe16(const u8 *bytes) diff --git a/src/application/pw_buzzer.c b/src/application/pw_buzzer.c index 0c26db7..0cb3a30 100644 --- a/src/application/pw_buzzer.c +++ b/src/application/pw_buzzer.c @@ -6,6 +6,7 @@ #include "application/pw_builtin.h" #include "application/pw_eeprom_m95512.h" #ifdef PW_STICK_S3 +#include "stick/native_runtime.h" #include "stick/sound_bridge.h" #endif @@ -54,6 +55,8 @@ void BeepInit(void) g_ui.durationDivisor = 0x78; g_ui.outputMode = 0; #ifdef PW_STICK_S3 + StickNativeTimerWDisable(); + StickNativeTimerWSetCompare(0); StickSoundInit(); #else IO.PCR8 |= 0x0c; @@ -92,7 +95,9 @@ void BeepLoadScore(u8 sequenceId) if (g_ui.outputMode == 0) { return; } -#ifndef PW_STICK_S3 +#ifdef PW_STICK_S3 + StickNativeTimerWInterruptEnable(0); +#else TW.TIERW.BIT.IMIEA = 0; #endif id = sequenceId; @@ -126,7 +131,9 @@ void BeepLoadScore(u8 sequenceId) g_ui.separatorPeriodsRemaining = 0; } } -#ifndef PW_STICK_S3 +#ifdef PW_STICK_S3 + StickNativeTimerWInterruptEnable(1); +#else TW.TIERW.BIT.IMIEA = 1; #endif } @@ -143,6 +150,7 @@ void BeepSelectScore(const Note *score) void BeepEnableTimer(void) { #ifdef PW_STICK_S3 + StickNativeTimerWEnable(); StickSoundEnable(); #else CKSTPR2.BYTE |= 0x40; @@ -161,6 +169,7 @@ void BeepEnableTimer(void) void BeepDisableTimer(void) { #ifdef PW_STICK_S3 + StickNativeTimerWDisable(); StickSoundDisable(); #else TW.TIERW.BIT.IMIEA = 0; @@ -190,6 +199,7 @@ void BeepSetOutputMode(u8 mode) void BeepSetPeriod(u8 compareValue) { #ifdef PW_STICK_S3 + StickNativeTimerWSetCompare(compareValue); StickSoundPeriod(compareValue, g_ui.outputMode); #else switch (g_ui.outputMode) { @@ -245,6 +255,7 @@ void BeepAdvance(void) goto playNote; } else { #ifdef PW_STICK_S3 + StickNativeTimerWSetCompare(BEEPER_SEPARATOR_COMPARE); StickSoundSilencePeriod(BEEPER_SEPARATOR_COMPARE); #else TW.GRA = BEEPER_SEPARATOR_COMPARE; diff --git a/src/application/pw_friend.c b/src/application/pw_friend.c index 709cf35..022e5b8 100644 --- a/src/application/pw_friend.c +++ b/src/application/pw_friend.c @@ -96,8 +96,10 @@ void PeerAwardGift(void) PeerInfo *peer; Item *items; u32 giftScore; +#ifdef PW_STICK_S3 u32 peerDailySteps; u16 peerHourSteps; +#endif u8 freeSlot; ScratchReset(); @@ -111,9 +113,6 @@ void PeerAwardGift(void) #ifdef PW_STICK_S3 peerDailySteps = StickReadBe32((const u8 *)&peer->dailySteps); peerHourSteps = StickReadBe16((const u8 *)&peer->hourSteps); -#else - peerDailySteps = peer->dailySteps; - peerHourSteps = peer->hourSteps; #endif items = ScratchAlloc(sizeof(Item) * 10); EepromRead(PW_EEPROM_MEMBER_ADDRESS(EEPROM_WALK, WalkData, friendItems), @@ -123,8 +122,8 @@ void PeerAwardGift(void) giftScore = g_state.dailySteps + peerDailySteps + (u16)((g_state.hourSteps + peerHourSteps) * 10u); #else - giftScore = g_state.dailySteps + peerDailySteps + - ((g_state.hourSteps + peerHourSteps) * 10); + giftScore = g_state.dailySteps + peer->dailySteps + + ((g_state.hourSteps + peer->hourSteps) * 10); #endif if (giftScore > 20000ul) { giftScore = 20000ul; @@ -151,7 +150,11 @@ void PeerAwardGift(void) if (g_ui.view.peer.wattsAwarded != 0) { return; } +#ifdef PW_STICK_S3 if (g_state.dailySteps > peerDailySteps) { +#else + if (g_state.dailySteps > peer->dailySteps) { +#endif g_ui.view.peer.itemIndex = 0; } else { g_ui.view.peer.itemIndex = 1; @@ -164,7 +167,11 @@ void PeerAwardGift(void) if (g_ui.view.peer.wattsAwarded != 0) { return; } +#ifdef PW_STICK_S3 if (g_state.dailySteps > peerDailySteps) { +#else + if (g_state.dailySteps > peer->dailySteps) { +#endif g_ui.view.peer.itemIndex = 2; } else { g_ui.view.peer.itemIndex = 3; @@ -177,7 +184,11 @@ void PeerAwardGift(void) if (g_ui.view.peer.wattsAwarded != 0) { return; } +#ifdef PW_STICK_S3 if (g_state.dailySteps > peerDailySteps) { +#else + if (g_state.dailySteps > peer->dailySteps) { +#endif g_ui.view.peer.itemIndex = 4; } else { g_ui.view.peer.itemIndex = 5; @@ -190,7 +201,11 @@ void PeerAwardGift(void) if (g_ui.view.peer.wattsAwarded != 0) { return; } +#ifdef PW_STICK_S3 if (g_state.dailySteps > peerDailySteps) { +#else + if (g_state.dailySteps > peer->dailySteps) { +#endif g_ui.view.peer.itemIndex = 6; } else { g_ui.view.peer.itemIndex = 7; @@ -203,7 +218,11 @@ void PeerAwardGift(void) if (g_ui.view.peer.wattsAwarded != 0) { return; } +#ifdef PW_STICK_S3 if (g_state.dailySteps > peerDailySteps) { +#else + if (g_state.dailySteps > peer->dailySteps) { +#endif g_ui.view.peer.itemIndex = 8; } else { g_ui.view.peer.itemIndex = 9; diff --git a/src/application/pw_home.c b/src/application/pw_home.c index 64be801..66b4b24 100644 --- a/src/application/pw_home.c +++ b/src/application/pw_home.c @@ -15,9 +15,6 @@ #include "application/pw_player_input.h" #include "support/lib_common.h" #include "support/scratch.h" -#ifdef PW_STICK_S3 -#include "stick/display_panel.h" -#endif #define HOME_DISPLAY_HEIGHT_PIXELS 0x40 #define HOME_INITIAL_X 0x20 @@ -59,12 +56,6 @@ void TryBeginIr(void) RenderBattery(0, 0); } DisplayToggleBank(); -#ifdef PW_STICK_S3 - /* The NT7508 bank switch is immediate on the original panel. The Stick's - * virtual display needs one physical transfer before IR takes ownership; - * presenting in the receive loop would delay packet replies. */ - StickDisplayPresent(); -#endif set_ccr(0x80); IrBegin(); InstallTask(IrProtocolTick); diff --git a/src/application/pw_main.c b/src/application/pw_main.c index 6829efd..a312d3f 100644 --- a/src/application/pw_main.c +++ b/src/application/pw_main.c @@ -3,6 +3,7 @@ #include "types.h" #ifdef PW_STICK_S3 #include "stick/motion_sample.h" +#include "stick/native_runtime.h" #endif #include "eeprom_address.h" #include "startup/hardware.h" @@ -579,7 +580,9 @@ void MainTick(void) CaptureSample(); IENR1.BIT.IENRTC = 1; #else + g_state.events.bits.lowPowerClock = 0; CaptureSample(); + StickNativeService(); #endif InputScan(); if ((g_state.flags.byte & SYSTEM_MODE_MASK) == SYSTEM_MODE_INACTIVE) { @@ -623,13 +626,9 @@ void MainTick(void) g_state.centerHoldScans = 0; } } else { -#ifndef PW_STICK_S3 set_ccr(0x80); -#endif BatteryUpdate(); -#ifndef PW_STICK_S3 set_ccr(0); -#endif MotionSessionIdleCheck(); } } @@ -658,6 +657,8 @@ void BeepTick(void) if (TW.GRA != 0) { sleep(); } +#else + g_state.events.bits.lowPowerClock = 1; #endif WatchdogService(); InputScan(); diff --git a/src/application/pw_nt7508.c b/src/application/pw_nt7508.c index fbc3716..6d72a17 100644 --- a/src/application/pw_nt7508.c +++ b/src/application/pw_nt7508.c @@ -184,7 +184,7 @@ void DisplayAddress(u8 x, u8 page) while (SSU.SSSR.BIT.TDRE == 0) { } LCD_TX((((x / DISPLAY_COL_MSB_DIVISOR) & DISPLAY_COL_MSB_MASK) + - DISPLAY_COL_MSB_COMMAND)); + DISPLAY_COL_MSB_COMMAND)); while (SSU.SSSR.BIT.TDRE == 0) { } LCD_TX((x & DISPLAY_COL_LSB_MASK)); @@ -194,7 +194,7 @@ void DisplayAddress(u8 x, u8 page) while (SSU.SSSR.BIT.TDRE == 0) { } LCD_TX((g_displayBank.bytes.index * DISPLAY_BANK_PAGES + page + - DISPLAY_PAGE_COMMAND_BASE)); + DISPLAY_PAGE_COMMAND_BASE)); while (SSU.SSSR.BIT.TEND == 0) { } } @@ -274,7 +274,7 @@ void DisplayFill(u8 pixelValue) while (SSU.SSSR.BIT.TDRE == 0) { } LCD_TX((g_displayBank.bytes.index * DISPLAY_BANK_PAGES + y + - DISPLAY_PAGE_COMMAND_BASE)); + DISPLAY_PAGE_COMMAND_BASE)); while (SSU.SSSR.BIT.TEND == 0) { } IO.PDR1.BIT.B1 = 1; @@ -330,7 +330,7 @@ void DisplayFill(u8 pixelValue) do { \ while (SSU.SSSR.BIT.TDRE == 0) { \ } \ - LCD_TX((value)); \ + LCD_TX((value)); \ } while (0) #define LCD_END() \ do { \ @@ -467,7 +467,7 @@ void DisplayClear(u8 height) while (SSU.SSSR.BIT.TDRE == 0) { } LCD_TX((g_displayBank.bytes.index * DISPLAY_BANK_PAGES + y + - DISPLAY_PAGE_COMMAND_BASE)); + DISPLAY_PAGE_COMMAND_BASE)); while (SSU.SSSR.BIT.TEND == 0) { } IO.PDR1.BIT.B1 = 1; @@ -524,7 +524,7 @@ void DisplayBlit(u8 x, u8 y, u8 width, u8 height, const u8 *raster) while (SSU.SSSR.BIT.TDRE == 0) { } LCD_TX((((left / DISPLAY_COL_MSB_DIVISOR) & DISPLAY_COL_MSB_MASK) + - DISPLAY_COL_MSB_COMMAND)); + DISPLAY_COL_MSB_COMMAND)); while (SSU.SSSR.BIT.TDRE == 0) { } LCD_TX((left & DISPLAY_COL_LSB_MASK)); @@ -534,7 +534,7 @@ void DisplayBlit(u8 x, u8 y, u8 width, u8 height, const u8 *raster) while (SSU.SSSR.BIT.TDRE == 0) { } LCD_TX((g_displayBank.bytes.index * DISPLAY_BANK_PAGES + row + - DISPLAY_PAGE_COMMAND_BASE)); + DISPLAY_PAGE_COMMAND_BASE)); while (SSU.SSSR.BIT.TEND == 0) { } IO.PDR1.BIT.B1 = 1; @@ -955,7 +955,7 @@ void DisplayMessageRule(void) while (SSU.SSSR.BIT.TDRE == 0) { } LCD_TX((g_displayBank.bytes.index * DISPLAY_BANK_PAGES + - DISPLAY_PAGE_COMMAND_BASE + DISPLAY_RULE_PAGE_OFFSET)); + DISPLAY_PAGE_COMMAND_BASE + DISPLAY_RULE_PAGE_OFFSET)); while (SSU.SSSR.BIT.TEND == 0) { } IO.PDR1.BIT.B1 = 1; diff --git a/src/application/pw_power.c b/src/application/pw_power.c index b21c91c..1089567 100644 --- a/src/application/pw_power.c +++ b/src/application/pw_power.c @@ -4,17 +4,24 @@ #include "application/pw_buzzer.h" #include "application/pw_nt7508.h" #include "application/pw_power.h" +#ifdef PW_STICK_S3 +#include "stick/native_runtime.h" +#endif /* Timer B1 resumes regular sampling while the display stays asleep. * The one-second RTC interrupt maintains the motion timeout. */ void MotionSessionWake(void) { -#ifndef PW_STICK_S3 +#ifdef PW_STICK_S3 + StickNativeMotionClock(1); +#else CKSTPR1.BYTE |= 4; #endif g_state.flags.byte = ((g_state.flags.byte & SYSTEM_MODE_CLEAR) | SYSTEM_MODE_MOTION); -#ifndef PW_STICK_S3 +#ifdef PW_STICK_S3 + StickNativeRefreshInterrupt(1); +#else RTC.RTCCR2.BYTE |= 1; #endif g_state.idleSeconds[IDLE_MOTION] = ACTIVITY_MOTION_SECONDS; @@ -57,7 +64,9 @@ void MotionSessionStart(void) } g_state.flags.byte = ((g_state.flags.byte & SYSTEM_MODE_CLEAR) | SYSTEM_MODE_INTERACTIVE); -#ifndef PW_STICK_S3 +#ifdef PW_STICK_S3 + StickNativeRefreshInterrupt(1); +#else RTC.RTCCR2.BYTE |= 1; #endif DisplayExitPowerSave(); @@ -70,7 +79,10 @@ void MotionSessionStart(void) void MotionSessionEnd(void) { BeepDisableTimer(); -#ifndef PW_STICK_S3 +#ifdef PW_STICK_S3 + StickNativeMotionClock(0); + StickNativeRefreshInterrupt(0); +#else CKSTPR1.BYTE &= 0xfb; RTC.RTCCR2.BYTE &= 0xfe; #endif diff --git a/src/application/pw_rtc.c b/src/application/pw_rtc.c index 6325a32..80b720a 100644 --- a/src/application/pw_rtc.c +++ b/src/application/pw_rtc.c @@ -8,6 +8,7 @@ #include "support/scratch.h" #ifdef PW_STICK_S3 #include "stick_wire_endian.h" +#include "stick/native_runtime.h" #endif void PokemonMinuteTick(void); @@ -157,6 +158,10 @@ void RtcSetTime(u32 seconds) u8 hourBcd; u8 component; +#ifdef PW_STICK_S3 + StickNativeRtcSet(seconds); +#endif + component = (seconds % sixty); secondBcd = BinaryToBcd(component); seconds = seconds / sixty; @@ -191,9 +196,7 @@ void RtcSetTime(u32 seconds) void RtcReadStable(u8 *secondOut, u8 *minuteOut, u8 *hourOut) { #ifdef PW_STICK_S3 - *secondOut = g_state.time.secondBcd; - *minuteOut = g_state.time.minuteBcd; - *hourOut = g_state.time.hourBcd24h; + StickNativeRtcRead(secondOut, minuteOut, hourOut); #else u8 snapshot[6]; u8 sample; @@ -245,14 +248,11 @@ void RtcSecondInterrupt(void) { u16 socialSeconds; #ifdef PW_STICK_S3 - u32 next = g_state.save.rtcSeconds + 1; - g_state.save.rtcSeconds = next; - RtcSetTime(next); - if (next % SECONDS_PER_MINUTE == 0) { - RtcMinuteInterrupt(); - if (next % RTC_ONE_HOUR_SECONDS == 0) - RtcHourInterrupt(); - } + u8 second, minute, hour; + + StickNativeRtcRead(&second, &minute, &hour); + g_state.time.secondBcd = second; + g_state.save.rtcSeconds++; #else u8 second; @@ -281,7 +281,12 @@ void RtcSecondInterrupt(void) #pragma interrupt(RtcMinuteInterrupt(vect = 26)) void RtcMinuteInterrupt(void) { -#ifndef PW_STICK_S3 +#ifdef PW_STICK_S3 + u8 second, minute, hour; + + StickNativeRtcRead(&second, &minute, &hour); + g_state.time.minuteBcd = minute; +#else u8 minute; minute = RTC.RMINDR.BYTE; @@ -298,7 +303,12 @@ void RtcMinuteInterrupt(void) #pragma interrupt(RtcHourInterrupt(vect = 27)) void RtcHourInterrupt(void) { -#ifndef PW_STICK_S3 +#ifdef PW_STICK_S3 + u8 second, minute, hour; + + StickNativeRtcRead(&second, &minute, &hour); + g_state.time.hourBcd24h = hour; +#else u8 hour; hour = RTC.RHRDR.BYTE; diff --git a/src/startup/h8_resetprg.c b/src/startup/h8_resetprg.c index e0e17e7..c00487e 100644 --- a/src/startup/h8_resetprg.c +++ b/src/startup/h8_resetprg.c @@ -325,7 +325,11 @@ void StatusApplyTime(void) #endif if (seconds != 0) { g_state.save.rtcSeconds = seconds; +#ifdef PW_STICK_S3 RtcSetTime(seconds); +#else + RtcSetTime(g_work.irc.statusA.status.rtcSeconds); +#endif } } diff --git a/src/support/ir.c b/src/support/ir.c index 8032a19..5dc2a09 100644 --- a/src/support/ir.c +++ b/src/support/ir.c @@ -34,9 +34,12 @@ uint PacketChecksum(u8 *bytes, u32 length) } #ifdef PW_STICK_S3 +#include "application/pw_home.h" #include "stick_wire_endian.h" #include "stick/eeprom_backend.h" +#include "stick/board_runtime.h" #include "stick/ir_transport.h" +static u8 stickEepromSessionReady; #define IR_TIMER_NOW() StickIrTicks() #ifdef PW_STICK_BENCH_CONTROL void StickPortTrace(const char *text); @@ -110,6 +113,12 @@ void SendPacket(u8 payloadLength, u8 command, u8 argument) u16 transmitEndTicks; #endif +#ifdef PW_STICK_S3 + if (!StickEepromHealthy()) { + g_state.irResult = IR_RESULT_CONNECTION_ERROR; + return; + } +#endif header = (IrcHeader *)g_work.irc.packet; header->command = command; header->argument = argument; @@ -197,7 +206,6 @@ void IrHardwareStart(void) { #ifdef PW_STICK_S3 IrConfigure(); - StickEepromDefer(1); StickIrStart(); #else u8 ssr3; @@ -245,17 +253,20 @@ static u8 StickCompressedPageWellFormed(const u8 *packed, u8 length) u8 flags; u8 bit; - if (length < 5 || packed[1] != EEPROM_PAGE_BYTES) return 0; + if (length < 5 || packed[1] != EEPROM_PAGE_BYTES) + return 0; input = 4; output = 0; while (output < EEPROM_PAGE_BYTES) { - if (input >= length) return 0; + if (input >= length) + return 0; flags = packed[input++]; for (bit = 0; bit < 8 && output < EEPROM_PAGE_BYTES; bit++) { if (flags & 0x80) { u16 count; u16 behind; - if (input + 1 >= length) return 0; + if (input + 1 >= length) + return 0; count = (packed[input] >> 4) + 3; behind = packed[input + 1] + 1; input += 2; @@ -263,7 +274,8 @@ static u8 StickCompressedPageWellFormed(const u8 *packed, u8 length) return 0; output += count; } else { - if (input >= length) return 0; + if (input >= length) + return 0; input++; output++; } @@ -287,6 +299,15 @@ typedef char IrcHeaderMatchesPayloadOffset[sizeof(IrcHeader) == IR_HEADER_BYTES * and send the one-byte connection probe. */ void IrBegin(void) { +#ifdef PW_STICK_S3 + StickPlatformPrepareIr(); + stickEepromSessionReady = StickEepromSessionBegin(); + if (!stickEepromSessionReady) { + g_state.irResult = IR_RESULT_CONNECTION_ERROR; + g_work.irc.work.completionAction = IR_ACTION_NONE; + return; + } +#endif IrHardwareStart(); g_state.irResult = IR_RESULT_NONE; g_work.irc.work.localHandshakeToken = g_state.randomState; @@ -408,7 +429,7 @@ void IrProtocolTick(void) WatchdogService(); #ifdef PW_STICK_S3 - if (StickIrFailed()) { + if (!StickEepromHealthy() || StickIrFailed()) { g_state.irResult = IR_RESULT_CONNECTION_ERROR; IrFinish(); return; @@ -416,8 +437,8 @@ void IrProtocolTick(void) { u8 length; u16 lastObservationTick; - if (StickIrTakeBurst(g_work.irc.packet, PW_IR_RX_WINDOW_CAPACITY, - &length, &lastObservationTick)) { + if (StickIrTakeBurst(g_work.irc.packet, PW_IR_RX_WINDOW_CAPACITY, &length, + &lastObservationTick)) { u16 index; g_state.irReceivedBytes = length; for (index = 0; index < length; index++) @@ -465,6 +486,9 @@ void IrProtocolTick(void) while (IR_TIMER_ELAPSED(g_state.irTimerReference) < retryDelayTicks) { } g_work.irc.work.handshakePhase = IR_PHASE_PROBING; +#ifdef PW_STICK_S3 + StickPlatformPresentIr(); +#endif IrTransmitByte(IR_CONNECT); timerSample = IR_TIMER_NOW(); /* Timer W bit 14 selects the raster bank. */ @@ -507,7 +531,7 @@ void IrProtocolTick(void) if (receivedChecksum != computedChecksum) { #ifdef PW_STICK_BENCH_CONTROL StickIrTraceChecksumFailure(g_work.irc.packet, g_state.irReceivedBytes, - receivedChecksum, computedChecksum); + receivedChecksum, computedChecksum); #endif g_state.irReceivedBytes = 0; g_work.irc.work.checksumFailureCount++; @@ -540,6 +564,12 @@ void IrProtocolTick(void) goto packetDone; } } +#ifdef PW_STICK_S3 + if (IR_TIMER_ELAPSED(g_state.irTimerReference) < + PW_IR_INACTIVITY_TICKS - 0x100) { + StickPlatformPresentIr(); + } +#endif switch (command) { case IR_RESPONSE: if ((argument == IR_CONSOLE_PARAMETER) || @@ -752,8 +782,18 @@ void IrProtocolTick(void) #endif EepromMirrorWrite(EEPROM_SAVE_PRIMARY, EEPROM_SAVE_BACKUP, (u8 *)&g_state.save, sizeof(SaveData)); +#ifdef PW_STICK_S3 + { + u8 watts[2]; + + StickWriteBe16(watts, g_state.save.watts); + EepromWrite(PW_EEPROM_MEMBER_ADDRESS(EEPROM_WALK, WalkData, watts), watts, + sizeof(watts)); + } +#else EepromWrite(PW_EEPROM_MEMBER_ADDRESS(EEPROM_WALK, WalkData, watts), &g_state.save.watts, 2); +#endif g_work.irc.statusB.status = *payloadStatus; SendPacket(sizeof(DeviceStatus), IR_CMD_STATUS_REPLY, IR_WALKER_PARAMETER); break; @@ -1276,7 +1316,14 @@ void IrProtocolTick(void) void IrFinish(void) { #ifdef PW_STICK_S3 - StickIrStop(); + if (stickEepromSessionReady) + StickIrStop(); + stickEepromSessionReady = 0; + if (!StickEepromSessionEnd()) { + g_state.irResult = IR_RESULT_CONNECTION_ERROR; + if (!StickEepromHealthy()) + g_work.irc.work.completionAction = IR_ACTION_NONE; + } #ifdef PW_STICK_BENCH_CONTROL StickPortTrace("PW_STICK_AFTER_IR_STOP"); #endif @@ -1292,9 +1339,12 @@ void IrFinish(void) #endif IrComplete(); #ifdef PW_STICK_S3 - /* WalkStartCommit copies the staged course and updates many records. - * Persist its complete result as one atomic EEPROM-image generation. */ - StickEepromDefer(0); + if (!StickEepromHealthy()) { + g_state.events.bits.eepromError = 1; + g_state.irResult = IR_RESULT_CONNECTION_ERROR; + SetView(VIEW_IR_RESULT); + g_ui.view.presentation.frame = 0; + } #ifdef PW_STICK_BENCH_CONTROL StickPortTrace("PW_STICK_AFTER_EEPROM_COMMIT"); #endif diff --git a/src/support/lib_common.c b/src/support/lib_common.c index d4e89d2..0eaaf0c 100644 --- a/src/support/lib_common.c +++ b/src/support/lib_common.c @@ -619,8 +619,10 @@ void *ScratchAlloc(u16 byteCount) #ifdef PW_STICK_S3 u8 *base = g_work.motion.scratch.layout.scratch; u8 *old = portScratchCursor; - if (old == 0) old = base; - if ((size_t)(old - base) + byteCount > 0x400u) __builtin_trap(); + if (old == 0) + old = base; + if ((size_t)(old - base) + byteCount > 0x400u) + __builtin_trap(); portScratchCursor = old + byteCount; return old; #else diff --git a/stick/README.md b/stick/README.md index 173b7b9..4e36f2a 100644 --- a/stick/README.md +++ b/stick/README.md @@ -1,6 +1,6 @@ # Developing Stickwalker -The [build guide](docs/build.md) covers toolchain setup, host tests and compiling +The [build guide](docs/build.md) covers toolchain setup, firmware checks and compiling firmware. For installing a release on your Stick, use the [installation guide](docs/install.md). @@ -11,16 +11,19 @@ and the infrared protocol. The adapters here connect that application to M5Stick | Change | Start here | | --- | --- | -| Game scheduling and the Settings overlay | [board_runtime.cpp](board_runtime.cpp) | +| Native timing and foreground scheduling | [native_runtime.cpp](native_runtime.cpp), [foreground_bridge.c](foreground_bridge.c) | +| Board coordination and Settings overlay | [board_runtime.cpp](board_runtime.cpp), [board_settings.cpp](board_settings.cpp) | | Button acquisition and native control mapping | [input_bridge.cpp](input_bridge.cpp), [controls.cpp](controls.cpp) | | Panel, palette, rotation and backlight | [display_panel.cpp](display_panel.cpp), [display_bus.cpp](display_bus.cpp) | | Optical capture, receive decoding and transmission | [ir_transport.cpp](ir_transport.cpp), [ir_rx_core.cpp](ir_rx_core.cpp), [ir_gate.h](ir_gate.h) | | Motion samples and sensor scaling | [motion_sample.cpp](motion_sample.cpp), [accel_bridge.cpp](accel_bridge.cpp) | -| Audio scheduling and codec supply | [sound_bridge.cpp](sound_bridge.cpp) | +| Physical audio readiness and codec supply | [sound_bridge.cpp](sound_bridge.cpp) | | Game-data persistence and recovery | [eeprom_backend.cpp](eeprom_backend.cpp), [device_identity.c](device_identity.c) | | Board supplies, PMIC setup and processor sleep | [board_hal.cpp](board_hal.cpp), [power_sleep.cpp](power_sleep.cpp) | The [player guide](docs/playing.md) describes the complete walk and transfer flow. +The [native platform guide](docs/native-platform.md) describes ownership, +readiness boundaries, input cancellation and storage recovery. The [motion guide](docs/motion.md) explains the sensor adaptation. The [source guide](../docs/source.md) follows the native application across modules. [CONTEXT.md](../CONTEXT.md) defines motion, display and power terms. @@ -57,6 +60,7 @@ boot. Repair changes the identity fields and their checksums; the game data stays intact. `CommitStagedWalk` also copies the current ID into the trainer record, including when boot finishes a transfer interrupted on older firmware. -The identity and peer tests cover save preservation, interrupted checkpoints, -journal replay, repeat encounters and day rollover. They run with -`uv run python stick/check.py`. +The production workflows exercise journal recovery, stale-checkpoint records, +optical status/page exchange and persistence across reset. Run them with +`uv run python stick/check.py`. Repeat encounters, day rollover and complete +retail transfers still need the device checks in the release guide. diff --git a/stick/battery_bridge.cpp b/stick/battery_bridge.cpp index 97835ee..be3e10e 100644 --- a/stick/battery_bridge.cpp +++ b/stick/battery_bridge.cpp @@ -12,3 +12,12 @@ extern "C" u8 StickBatteryLow(void) { // Use M5Unified's <5% threshold. Zero indicates a failed voltage read. return voltage != 0 && voltage < 3340; } + +extern "C" int StickBatteryPercentEstimate(u16 millivolts) { + if (!millivolts) return -1; + // M5Unified uses this voltage-only scale for M5PM1. It is an estimate, + // particularly while charging or under a changing load. + if (millivolts <= 3300) return 0; + if (millivolts >= 4100) return 100; + return (millivolts - 3300) * 100 / 800; +} diff --git a/stick/battery_bridge.h b/stick/battery_bridge.h index cdfde7d..ae226b9 100644 --- a/stick/battery_bridge.h +++ b/stick/battery_bridge.h @@ -8,6 +8,7 @@ extern "C" { #endif u16 StickBatteryMillivolts(void); +int StickBatteryPercentEstimate(u16 millivolts); u8 StickBatteryLow(void); #ifdef __cplusplus diff --git a/stick/board_diagnostics.cpp b/stick/board_diagnostics.cpp new file mode 100644 index 0000000..77e2a15 --- /dev/null +++ b/stick/board_diagnostics.cpp @@ -0,0 +1,539 @@ +#include "board_diagnostics.h" + +#ifdef PW_STICK_BENCH_CONTROL +#include "board_runtime.h" +#include "board_settings.h" +#include "display_panel.h" +#include "display_bus.h" +#include "board_hal.h" +#include "battery_bridge.h" +#include "eeprom_backend.h" +#include "foreground_bridge.h" +#include "input_bridge.h" +#include "ir_transport.h" +#include "sound_bridge.h" +#include "power_sleep.h" +#include "backlight.h" +#include "native_runtime.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +extern "C" void StickSoundBenchCapture(bool microphone, bool calibration, bool muted); +extern "C" void StickPortTrace(const char *message) { + if (StickIrBenchTraceEnabled()) Serial.println(message); +} + +namespace { +unsigned sample_bclk_edges() { + // Observe the existing I2S output pad without taking over its matrix route. + const uint32_t saved = REG_READ(GPIO_PIN_MUX_REG[17]); + gpio_input_enable(GPIO_NUM_17); + unsigned edges = 0; + int previous = gpio_get_level(GPIO_NUM_17); + const int64_t end = esp_timer_get_time() + 400; + while (esp_timer_get_time() < end) { + const int level = gpio_get_level(GPIO_NUM_17); + edges += level != previous; + previous = level; + } + REG_WRITE(GPIO_PIN_MUX_REG[17], saved); + return edges; +} + +bool passive_capture = false; +uint64_t passive_deadline_us = 0; +bool receive_trial = false; +uint64_t receive_deadline_us = 0; +uint64_t timing_started_us = 0; +uint64_t main_ticks = 0, beep_ticks = 0, input_polls = 0; +uint64_t main_us = 0, beep_us = 0, input_us = 0, present_us = 0; +uint64_t sound_us = 0, sound_max_us = 0, loop_gap_max_us = 0; +uint64_t last_loop_entry_us = 0; + +bool previous_ir = false; + +uint64_t battery_trial_end_us = 0; +bool battery_trial_armed = false; +RTC_NOINIT_ATTR struct { + uint32_t magic; + unsigned start_reads, end_reads, start_seconds, end_seconds; + unsigned failures, completed; + uint64_t start_us, end_us; +} retained_trial; +constexpr uint32_t kTrialMagic = 0x50575431; + +void begin_battery_trial(uint64_t now) { + unsigned successes, failures; + StickBoardAccelDiagnostic(&retained_trial.start_reads, &successes, &failures); + unsigned view, updates, frames, flags, idle, selection, pressed; + StickForegroundUiDiagnostic(&view, &updates, &frames, + &retained_trial.start_seconds, &flags, &idle, &selection, &pressed); + retained_trial.magic = kTrialMagic; + retained_trial.completed = 0; + retained_trial.start_us = now; + battery_trial_end_us = now + 5000000; + battery_trial_armed = false; +} + +} + +void StickDiagnosticsInit(void) { + if (retained_trial.magic == kTrialMagic) + Serial.printf("PW_STICK_POWER_TRIAL_PREVIOUS completed=%u start_us=%llu " + "end_us=%llu start_reads=%u end_reads=%u failures=%u " + "start_seconds=%u end_seconds=%u\n", + retained_trial.completed, + (unsigned long long)retained_trial.start_us, + (unsigned long long)retained_trial.end_us, + retained_trial.start_reads, retained_trial.end_reads, + retained_trial.failures, retained_trial.start_seconds, + retained_trial.end_seconds); + retained_trial = {}; + timing_started_us = uint64_t(esp_timer_get_time()); + Serial.println("PW_STICK_READY"); +} + +int StickDiagnosticsService(void) { + const uint64_t now = uint64_t(esp_timer_get_time()); + if (StickForegroundIsIr()) previous_ir = true; + else if (previous_ir) { + previous_ir = false; + if (StickIrBenchTraceEnabled()) + Serial.printf("PW_STICK_IR_DONE result=%u\n", StickForegroundIrResult()); + StickIrBenchTrace(0); + } + if (battery_trial_end_us && now >= battery_trial_end_us) { + battery_trial_end_us = 0; + // USB CDC may remain unavailable until the host resets/reconnects it. + // Retain the completed trial across that CPU reset without save writes. + unsigned successes; + StickBoardAccelDiagnostic(&retained_trial.end_reads, &successes, + &retained_trial.failures); + unsigned view, updates, frames, flags, idle, selection, pressed; + StickForegroundUiDiagnostic(&view, &updates, &frames, + &retained_trial.end_seconds, &flags, &idle, &selection, &pressed); + retained_trial.end_us = now; + retained_trial.completed = 1; + } + if (last_loop_entry_us && now - last_loop_entry_us > loop_gap_max_us) + loop_gap_max_us = now - last_loop_entry_us; + last_loop_entry_us = now; + if (passive_capture) { + if (now >= passive_deadline_us) { + StickIrStop(); + StickIrPassiveDiagnostic(0); + passive_capture = false; + Serial.println("PW_STICK_PASSIVE_DONE"); + StickIrBenchTrace(0); + } else { + ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(10)); + } + return 1; + } + if (receive_trial) { + u8 wire[136], length; + u16 tick; + while (StickIrTakeBurst(wire, sizeof(wire), &length, &tick)) { + Serial.printf("PW_STICK_TRIAL_FRAME length=%u tick=%u wire=", length, + tick); + for (unsigned i = 0; i < length; ++i) Serial.printf("%02x", wire[i]); + Serial.println(); + } + StickIrResumeReception(); + if (now >= receive_deadline_us || StickIrFailed()) { + StickIrStop(); + receive_trial = false; + Serial.println("PW_STICK_TRIAL_DONE"); + StickIrBenchTrace(0); + } else { + ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(1)); + } + return 1; + } + while (Serial.available()) { + const int command = Serial.read(); + if (command == 'c' && !StickForegroundIsIr()) { + StickIrBenchTrace(1); + StickForegroundRequestIr(); + Serial.println("PW_STICK_BENCH_CONNECT"); + if (!StickForegroundIsIr()) StickIrBenchTrace(0); + } + if (command == 'd' && !StickForegroundIsIr()) { + // Capture a normal menu-initiated session without starting IR from the + // host. The session completion path clears this one-shot trace flag. + StickIrBenchTrace(1); + Serial.println("PW_STICK_TRACE_ARMED"); + } + if (command == 'p' && !StickForegroundIsIr()) { + StickIrBenchTrace(1); + StickIrConfigure(); + StickIrPassiveDiagnostic(1); + StickIrStart(); + passive_capture = !StickIrFailed(); + passive_deadline_us = uint64_t(esp_timer_get_time()) + 200000; + Serial.println(passive_capture ? "PW_STICK_PASSIVE_START" : + "PW_STICK_PASSIVE_ERROR"); + if (!passive_capture) { + StickIrStop(); + StickIrPassiveDiagnostic(0); + StickIrBenchTrace(0); + } + return passive_capture; + } + if (command == 'r' && !StickForegroundIsIr()) { + StickIrBenchTrace(1); + StickIrConfigure(); + StickIrPassiveDiagnostic(0); + StickIrStart(); + receive_trial = !StickIrFailed(); + receive_deadline_us = uint64_t(esp_timer_get_time()) + 2000000; + Serial.println(receive_trial ? "PW_STICK_TRIAL_START" : + "PW_STICK_TRIAL_ERROR"); + if (!receive_trial) { + StickIrStop(); + StickIrBenchTrace(0); + } + return receive_trial; + } + if (command == 'e' && !StickForegroundIsIr()) { + const unsigned char *snapshot = StickEepromSnapshot(); + if (!snapshot) { + Serial.println("PW_STICK_EEPROM_UNAVAILABLE"); + } else { + static constexpr char hex[] = "0123456789abcdef"; + char row[513]; + Serial.println("PW_STICK_EEPROM_BEGIN bytes=65536"); + for (unsigned offset = 0; offset < 65536; offset += 256) { + for (unsigned i = 0; i < 256; ++i) { + const unsigned char value = snapshot[offset + i]; + row[2 * i] = hex[value >> 4]; + row[2 * i + 1] = hex[value & 15]; + } + row[512] = 0; + Serial.printf("PW_STICK_EEPROM_DATA offset=%u %s\n", offset, row); + } + Serial.println("PW_STICK_EEPROM_END"); + } + } + if (command == 'f' && !StickForegroundIsIr()) { + static uint8_t pixels[96 * 64]; + static constexpr char hex[] = "0123456789abcdef"; + char row[97]; + StickDisplayFrame(pixels, sizeof(pixels)); + Serial.println("PW_STICK_FRAME_BEGIN width=96 height=64"); + for (unsigned y = 0; y < 64; ++y) { + for (unsigned x = 0; x < 96; x += 2) + row[x / 2] = hex[(pixels[y * 96 + x] << 2) | + pixels[y * 96 + x + 1]]; + row[48] = 0; + Serial.printf("PW_STICK_FRAME_ROW y=%u %s\n", y, row); + } + Serial.println("PW_STICK_FRAME_END"); + } + if (command == 'j' && !StickForegroundIsIr()) { + // Switch both buttons to RTC mode, then exercise the sleep-return GPIO + // restoration while USB remains connected. + const esp_err_t first = rtc_gpio_init(GPIO_NUM_11); + const esp_err_t second = rtc_gpio_init(GPIO_NUM_12); + const unsigned before = + (REG_GET_BIT(RTC_IO_TOUCH_PAD11_REG, RTC_IO_TOUCH_PAD11_MUX_SEL) ? 1u : 0u) | + (REG_GET_BIT(RTC_IO_TOUCH_PAD12_REG, RTC_IO_TOUCH_PAD12_MUX_SEL) ? 2u : 0u); + const bool restored = StickSleepBegin(); + const unsigned after = + (REG_GET_BIT(RTC_IO_TOUCH_PAD11_REG, RTC_IO_TOUCH_PAD11_MUX_SEL) ? 1u : 0u) | + (REG_GET_BIT(RTC_IO_TOUCH_PAD12_REG, RTC_IO_TOUCH_PAD12_MUX_SEL) ? 2u : 0u); + Serial.printf("PW_STICK_BUTTON_MUX first=%d second=%d before=%u restored=%u after=%u\n", + first, second, before, unsigned(restored), after); + } + if (command == 'i' && !StickForegroundIsIr()) { + uint8_t pmic = 0; + uint8_t reset_cfg = 0, off_cfg = 0; + const bool pmic_ok = m5::In_I2C.readRegister(0x6e, 0x48, &pmic, 1, + 100000); + const bool config_ok = m5::In_I2C.readRegister(0x6e, 0x49, + &reset_cfg, 1, 100000) && + m5::In_I2C.readRegister(0x6e, 0x4a, + &off_cfg, 1, 100000); + unsigned m_edges, r_edges, l_edges, l_events, pmic_errors, raw, logical, power_ready; + StickInputDiagnostic(&m_edges, &r_edges, &l_edges, &l_events, &pmic_errors, + &raw, &logical, &power_ready); + Serial.printf("PW_STICK_INPUT_RAW main=%u side=%u power=%u pmic_ok=%u pmic=%02x config_ok=%u reset_cfg=%02x off_cfg=%02x\n", + unsigned(digitalRead(11) == LOW), + unsigned(digitalRead(12) == LOW), + unsigned(pmic_ok && (pmic & 1)), unsigned(pmic_ok), pmic, + unsigned(config_ok), reset_cfg, off_cfg); + Serial.printf("PW_STICK_INPUT_EVENTS main=%u side=%u power=%u power_events=%u pmic_errors=%u raw=%u logical=%u power_ready=%u\n", + m_edges, r_edges, l_edges, l_events, pmic_errors, raw, logical, + power_ready); + unsigned stable, gesture, desired, delivered, queued, wait_release; + unsigned emitted, consumed, overflow, left, right, center, expired, cancelled; + StickInputPathDiagnostic(&stable, &gesture, &desired, &delivered, + &queued, &wait_release, &emitted, &consumed, + &overflow, &left, &right, ¢er, &expired, &cancelled); + Serial.printf("PW_STICK_INPUT_PATH stable=%u gesture=%u desired=%u delivered=%u queued=%u wait=%u emitted=%u consumed=%u overflow=%u left=%u right=%u center=%u expired=%u cancelled=%u\n", + stable, gesture, desired, delivered, queued, wait_release, + emitted, consumed, overflow, left, right, center, expired, cancelled); + } + if ((command == 'K' || command == 'U' || command == 'Q' || command == 'O') && !StickForegroundIsIr()) { + StickSoundBenchCapture(command != 'U', command == 'Q', command == 'O'); + StickNativeService(); + } + if (command == 'H' && !StickForegroundIsIr()) { + const int started = StickForegroundBenchMoveScore(); + const unsigned early = sample_bclk_edges(); + delay(50); + const unsigned late = sample_bclk_edges(); + StickNativeService(); + Serial.printf("PW_STICK_CODEC_CLOCK started=%u early_edges=%u late_edges=%u registers=", + started, early, late); + for (const unsigned reg : {0x00,0x01,0x02,0x03,0x04,0x05,0x06,0x07, + 0x08,0x09,0x0a,0x0b,0x0c,0x0d,0x0e,0x10,0x11,0x12, + 0x13,0x31,0x32,0x37,0x44}) { + uint8_t value = 0; + const bool ok = m5::In_I2C.readRegister(0x18, reg, &value, 1, 100000); + Serial.printf("%02x:%02x:%u,", reg, value, ok); + } + Serial.println(); + } + if (command == 'G' && !StickForegroundIsIr()) { + // Delay score servicing to reproduce foreground rendering stalls. + const int started = StickForegroundBenchMoveScore(); + if (started) delay(80); + StickNativeService(); + Serial.printf("PW_STICK_SOUND_STALL started=%u stall_ms=80\n", started); + } + if (command == 's' && !StickForegroundIsIr()) { + unsigned active, speaker_ready, codec, mode, compare, begins; + unsigned begin_errors, power_errors, tones, tone_errors, playing; + StickSoundDiagnostic(&active, &speaker_ready, &codec, &mode, &compare, + &begins, &begin_errors, &power_errors, &tones, + &tone_errors, &playing); + Serial.printf("PW_STICK_SOUND active=%u ready=%u codec=%u mode=%u compare=%u begins=%u begin_errors=%u power_errors=%u tones=%u tone_errors=%u playing=%u\n", + active, speaker_ready, codec, mode, compare, begins, + begin_errors, power_errors, tones, tone_errors, playing); + unsigned measured, under20, under50, shortest; + StickSoundTimingDiagnostic(&measured, &under20, &under50, &shortest); + Serial.printf("PW_STICK_SOUND_TIMING measured=%u under20=%u under50=%u shortest_us=%u\n", + measured, under20, under50, shortest); + uint8_t gpio_out = 0, gpio_in = 0, gpio_mode = 0; + uint8_t gpio_func = 0, pwr_cfg = 0, codec_reg = 0; + const bool pmic_ok = + m5::In_I2C.readRegister(0x6e, 0x11, &gpio_out, 1, 100000) && + m5::In_I2C.readRegister(0x6e, 0x12, &gpio_in, 1, 100000) && + m5::In_I2C.readRegister(0x6e, 0x10, &gpio_mode, 1, 100000) && + m5::In_I2C.readRegister(0x6e, 0x16, &gpio_func, 1, 100000) && + m5::In_I2C.readRegister(0x6e, 0x06, &pwr_cfg, 1, 100000); + const bool codec_ok = m5::In_I2C.readRegister( + 0x18, 0x00, &codec_reg, 1, 100000); + unsigned m_edges, r_edges, l_edges, l_events, pmic_errors, raw, logical, power_ready; + StickInputDiagnostic(&m_edges, &r_edges, &l_edges, &l_events, &pmic_errors, + &raw, &logical, &power_ready); + Serial.printf("PW_STICK_SOUND_HW pmic_ok=%u gpio_out=%02x gpio_in=%02x gpio_mode=%02x gpio_func=%02x pwr_cfg=%02x codec_ok=%u codec_00=%02x input_raw=%u power_edges=%u\n", + unsigned(pmic_ok), gpio_out, gpio_in, gpio_mode, + gpio_func, pwr_cfg, unsigned(codec_ok), codec_reg, + raw, l_edges); + } + if (command == 'a' && !StickForegroundIsIr()) + Serial.printf("PW_STICK_SOUND_BENCH_TONE started=%u duration_ms=8000\n", + unsigned(StickSoundBenchTone())); + if (command == 'b' && !StickForegroundIsIr()) { + auto &power = StickBoardPower(); + Serial.printf("PW_STICK_POWER battery_mv=%u vbus_mv=%u source=%u " + "ext_5v=%u inactive=%u sample_period_us=%llu\n", + unsigned(StickBatteryMillivolts()), + unsigned(power.getVBUSVoltage()), + unsigned(power.getPowerSource()), + unsigned(power.getExtOutput()), + unsigned(StickForegroundIsInactive()), + (unsigned long long)(StickForegroundIsInactive() ? UINT64_C(1000000) : UINT64_C(62500))); + uint64_t count, sleep_us, timer_wakes, gpio_wakes, errors, rejections; + int last_sleep_error; + unsigned sleep_gpio, sleep_duration; + StickSleepDiagnostic(&count, &sleep_us, &timer_wakes, &gpio_wakes, + &errors, &rejections, &last_sleep_error, &sleep_gpio, + &sleep_duration); + Serial.printf("PW_STICK_SLEEP count=%llu sleep_us=%llu timer=%llu " + "gpio=%llu errors=%llu last_error=%d last_gpio=%u " + "duration=%u rejections=%llu\n", + (unsigned long long)count, + (unsigned long long)sleep_us, + (unsigned long long)timer_wakes, + (unsigned long long)gpio_wakes, + (unsigned long long)errors, + last_sleep_error, + sleep_gpio, sleep_duration, + (unsigned long long)rejections); + unsigned accel_reads, accel_successes, accel_failures; + StickBoardAccelDiagnostic(&accel_reads, &accel_successes, + &accel_failures); + Serial.printf("PW_STICK_ACCEL reads=%u successes=%u failures=%u\n", + accel_reads, accel_successes, accel_failures); + } + if (command == 'F' && !StickForegroundIsIr() && + StickDisplayPanelIsReady()) { + // Exercise cold panel wake and finish with the current game image + // displayed. + auto *screen = StickBoardScreen(); + auto *panel = screen->getPanel(); + screen->startWrite(); screen->writeCommand(0x28); screen->endWrite(); + const unsigned off = panel->readCommand(0x0a, 0, 1); + screen->writeCommand(0x29); // Reproduce the former unselected write. + panel->waitDMA(); + const unsigned unselected = panel->readCommand(0x0a, 0, 1); + screen->startWrite(); screen->writeCommand(0x29); screen->endWrite(); + const unsigned selected = panel->readCommand(0x0a, 0, 1); + Serial.printf("PW_STICK_LCD_CS off=%02x unselected=%02x selected=%02x\n", + off, unselected, selected); + } + if (command == 'D' && !StickForegroundIsIr()) { + auto *screen = StickBoardScreen(); + if (!screen || !StickDisplayPanelIsReady()) { + Serial.println("PW_STICK_LCD_REG ready=0"); + } else { + auto *panel = screen->getPanel(); + Serial.printf("PW_STICK_LCD_REG ready=1 power=%02x madctl=%02x pixel=%02x\n", + unsigned(panel->readCommand(0x0a, 0, 1)), + unsigned(panel->readCommand(0x0b, 0, 1)), + unsigned(panel->readCommand(0x0c, 0, 1))); + } + } + if (command == 'P' && !StickForegroundIsIr()) { + uint8_t gpio_out = 0, pwr_cfg = 0, accel_conf = 0; + uint8_t imu_power = 0, imu_power_conf = 0; + const bool pmic_ok = + m5::In_I2C.readRegister(0x6e, 0x11, &gpio_out, 1, 100000) && + m5::In_I2C.readRegister(0x6e, 0x06, &pwr_cfg, 1, 100000); + const bool imu_ok = + m5::In_I2C.readRegister(0x68, 0x40, &accel_conf, 1, 100000) && + m5::In_I2C.readRegister(0x68, 0x7d, &imu_power, 1, 100000) && + m5::In_I2C.readRegister(0x68, 0x7c, &imu_power_conf, 1, 100000); + Serial.printf("PW_STICK_POWER_HW cpu_mhz=%u panel_awake=%u " + "pwm_ready=%u sound_busy=%u pmic_ok=%u gpio_out=%02x " + "pwr_cfg=%02x imu_ok=%u accel_conf=%02x imu_power=%02x " + "imu_power_conf=%02x\n", + unsigned(getCpuFrequencyMhz()), StickDisplayPanelIsReady(), + StickBacklightSleepReady(), StickSoundIsBusy(), + unsigned(pmic_ok), gpio_out, pwr_cfg, unsigned(imu_ok), + accel_conf, imu_power, imu_power_conf); + } + if (command == 'o' && !StickForegroundIsIr()) { + const u16 millivolts = StickBatteryMillivolts(); + Serial.printf("PW_STICK_DEVICE_MENU open=%u row=%u profile=%u orientation=%u chord_ms=%u battery_mv=%u battery_pct=%d usb=%u layout=%u dark=%u\n", + StickSettingsIsOpen(), StickSettingsSelection(), + StickInputProfile(), StickInputOrientation(), + 80 + StickInputChordWindowIndex() * 40, + unsigned(millivolts), StickBatteryPercentEstimate(millivolts), + unsigned(StickBoardPower().getVBUSVoltage() >= 4000), + StickInputLayout(), StickDisplayIsDark()); + } + if (command == 't' && !StickForegroundIsIr()) { + const uint64_t end = uint64_t(esp_timer_get_time()); + const uint64_t deadline = StickNativeNextDeadline(); + unsigned view, updates, frames, seconds, flags, idle, selection, pressed; + StickForegroundUiDiagnostic(&view, &updates, &frames, &seconds, + &flags, &idle, &selection, &pressed); + Serial.printf("PW_STICK_TIMING span_us=%llu main=%llu main_us=%llu beep=%llu beep_us=%llu input=%llu input_us=%llu present_us=%llu lag_us=%lld\n", + (unsigned long long)(end - timing_started_us), + (unsigned long long)main_ticks, + (unsigned long long)main_us, + (unsigned long long)beep_ticks, + (unsigned long long)beep_us, + (unsigned long long)input_polls, + (unsigned long long)input_us, + (unsigned long long)present_us, + (long long)(deadline == UINT64_MAX ? 0 : int64_t(end) - int64_t(deadline))); + Serial.printf("PW_STICK_TIMING_EXTRA sound_us=%llu sound_max_us=%llu loop_gap_max_us=%llu\n", + (unsigned long long)sound_us, + (unsigned long long)sound_max_us, + (unsigned long long)loop_gap_max_us); + Serial.printf("PW_STICK_UI view=%u updates=%u frames=%u seconds=%u flags=%02x idle=%u selection=%u pressed=%u\n", + view, updates, frames, seconds, flags, idle, selection, + pressed); + timing_started_us = end; + main_ticks = beep_ticks = input_polls = 0; + main_us = beep_us = input_us = present_us = 0; + sound_us = sound_max_us = loop_gap_max_us = 0; + } + if (command >= '1' && command <= '3' && !StickForegroundIsIr()) { + const u8 button = command == '1' ? 4 : command == '2' ? 2 : 8; + StickInputBenchInject(button); + Serial.printf("PW_STICK_BUTTON_INJECTED value=%u\n", button); + } + if (command == 'z' && !StickForegroundIsIr()) { + StickForegroundBenchSleep(); + StickPresentGame(1); + Serial.println("PW_STICK_BENCH_SLEEP"); + } + if (command == 'Y' && !StickForegroundIsIr()) { + battery_trial_armed = true; + retained_trial = {}; + Serial.println("PW_STICK_BATTERY_TRIAL_ARMED duration_ms=5000"); + } + if (command == 'n' && !StickForegroundIsIr()) { + StickForegroundBenchInactive(); + StickPresentGame(1); + Serial.println("PW_STICK_BENCH_INACTIVE"); + } + if (command == 'w' && !StickForegroundIsIr()) { + StickInputBenchHold(2, 10); + Serial.println("PW_STICK_BENCH_CENTER_HOLD"); + } + if (command == 'v' && !StickForegroundIsIr()) { + StickInputBenchMainHold(650); + Serial.println("PW_STICK_BENCH_MAIN_HOLD"); + } + if (command == 'V' && !StickForegroundIsIr()) { + StickInputBenchMainHold(40); // Abandon preparation before wake acceptance. + Serial.println("PW_STICK_BENCH_MAIN_TAP"); + } + if (command == 'l' && !StickForegroundIsIr()) { + StickInputBenchPowerEvent(); + Serial.println("PW_STICK_BENCH_POWER_EVENT"); + } + if (command == 'x' && !StickForegroundIsIr()) { + const bool erased = StickEepromBenchErase(); + Serial.println(erased ? "PW_STICK_BENCH_ERASED" : + "PW_STICK_BENCH_ERASE_FAILED"); + Serial.flush(); + if (erased) ESP.restart(); + } + } + if (StickForegroundIsIr()) previous_ir = true; + return 0; +} + +void StickDiagnosticsForeground(unsigned task, uint64_t elapsed) { + if (task == 1) { + ++main_ticks; + main_us += elapsed; + } else if (task == 2) { + ++beep_ticks; + beep_us += elapsed; + } +} + +void StickDiagnosticsInput(uint64_t elapsed) { + ++input_polls; + input_us += elapsed; +} + +void StickDiagnosticsSound(uint64_t elapsed) { + sound_us += elapsed; + if (elapsed > sound_max_us) sound_max_us = elapsed; +} + +void StickDiagnosticsPresent(uint64_t elapsed) { present_us += elapsed; } + +void StickDiagnosticsBeforeSleep(int usb_present) { + if (!usb_present && battery_trial_armed) + begin_battery_trial(uint64_t(esp_timer_get_time())); +} +#endif diff --git a/stick/board_diagnostics.h b/stick/board_diagnostics.h new file mode 100644 index 0000000..b9da7a2 --- /dev/null +++ b/stick/board_diagnostics.h @@ -0,0 +1,21 @@ +#pragma once + +#include + +#ifdef PW_STICK_BENCH_CONTROL +void StickDiagnosticsInit(void); +int StickDiagnosticsService(void); +void StickDiagnosticsForeground(unsigned task, uint64_t elapsed); +void StickDiagnosticsInput(uint64_t elapsed); +void StickDiagnosticsSound(uint64_t elapsed); +void StickDiagnosticsPresent(uint64_t elapsed); +void StickDiagnosticsBeforeSleep(int usb_present); +#else +inline void StickDiagnosticsInit(void) {} +inline int StickDiagnosticsService(void) { return 0; } +inline void StickDiagnosticsForeground(unsigned, uint64_t) {} +inline void StickDiagnosticsInput(uint64_t) {} +inline void StickDiagnosticsSound(uint64_t) {} +inline void StickDiagnosticsPresent(uint64_t) {} +inline void StickDiagnosticsBeforeSleep(int) {} +#endif diff --git a/stick/board_runtime.cpp b/stick/board_runtime.cpp index c26c208..db8e1c3 100644 --- a/stick/board_runtime.cpp +++ b/stick/board_runtime.cpp @@ -1,126 +1,33 @@ -#include "display_panel.h" -#include "display_bus.h" +#include "board_runtime.h" +#include "board_diagnostics.h" +#include "board_settings.h" #include "board_hal.h" -#include "battery_bridge.h" +#include "display_bus.h" +#include "display_panel.h" #include "eeprom_backend.h" #include "foreground_bridge.h" #include "input_bridge.h" #include "ir_transport.h" +#include "native_runtime.h" #include "sound_bridge.h" #include "power_sleep.h" #include "backlight.h" #include "peripheral_power.h" -#include "controls.h" -#include "display_palette.h" #include #include #include -#ifdef PW_STICK_BENCH_CONTROL -#include -#include -#include -#include -#include -#include -extern "C" void StickSoundBenchCapture(bool microphone, bool calibration, bool muted); -#endif extern "C" void StickPortBoot(void); -#ifdef PW_STICK_BENCH_CONTROL -extern "C" void StickPortTrace(const char *message) { - if (StickIrBenchTraceEnabled()) Serial.println(message); -} -#endif namespace { - -#ifdef PW_STICK_BENCH_CONTROL -unsigned sample_bclk_edges() { - // Observe the existing I2S output pad without taking over its matrix route. - const uint32_t saved = REG_READ(GPIO_PIN_MUX_REG[17]); - gpio_input_enable(GPIO_NUM_17); - unsigned edges = 0; - int previous = gpio_get_level(GPIO_NUM_17); - const int64_t end = esp_timer_get_time() + 400; - while (esp_timer_get_time() < end) { - const int level = gpio_get_level(GPIO_NUM_17); - edges += level != previous; - previous = level; - } - REG_WRITE(GPIO_PIN_MUX_REG[17], saved); - return edges; -} -#endif - bool ready = false; -bool device_menu_open = false; -bool device_menu_draw_pending = false; -u8 device_menu_row = 0; -bool previous_ir = false; -unsigned last_present_frame = ~0u; -int last_present_power = -1; -#ifdef PW_STICK_BENCH_CONTROL -bool passive_capture = false; -uint64_t passive_deadline_us = 0; -bool receive_trial = false; -uint64_t receive_deadline_us = 0; -uint64_t timing_started_us = 0; -uint64_t main_ticks = 0, beep_ticks = 0, input_polls = 0; -uint64_t main_us = 0, beep_us = 0, input_us = 0, present_us = 0; -uint64_t sound_us = 0, sound_max_us = 0, loop_gap_max_us = 0; -uint64_t last_loop_entry_us = 0; -#endif -uint64_t next_sample_us = 0; -uint64_t next_quarter_us = 0; -uint64_t next_second_us = 0; -uint64_t next_input_us = 0; -uint64_t next_beep_us = 0; -uint64_t next_vbus_check_us = 0; -uint64_t last_battery_draw_us = 0; -uint64_t last_menu_input_us = 0; -constexpr uint64_t kDeviceMenuIdleUs = 90000000; -constexpr unsigned kDeviceMenuRows = 5; bool usb_present = true; -void refresh_usb_power() { - uint16_t millivolts; - // Keep the CPU awake after a failed power read so USB stays available. - // Retry at the next scheduled check. - usb_present = !StickBoardVbusVoltage(&millivolts) || millivolts >= 4000; -} -#ifdef PW_STICK_BENCH_CONTROL -uint64_t battery_trial_end_us = 0; -bool battery_trial_armed = false; -RTC_NOINIT_ATTR struct { - uint32_t magic; - unsigned start_reads, end_reads, start_seconds, end_seconds; - unsigned failures, completed; - uint64_t start_us, end_us; -} retained_trial; -constexpr uint32_t kTrialMagic = 0x50575431; -#endif - -uint64_t sample_period_us() { - // The H8 disables regular 16 Hz sampling after its motion timeout and - // collects one activity sample on each RTC second instead. - return StickForegroundIsInactive() && !StickInputWakeScanActive() ? - 1000000 : 62500; -} - -#ifdef PW_STICK_BENCH_CONTROL -void begin_battery_trial(uint64_t now) { - unsigned successes, failures; - StickBoardAccelDiagnostic(&retained_trial.start_reads, &successes, &failures); - unsigned view, updates, frames, flags, idle, selection, pressed; - StickForegroundUiDiagnostic(&view, &updates, &frames, - &retained_trial.start_seconds, &flags, &idle, &selection, &pressed); - retained_trial.magic = kTrialMagic; - retained_trial.completed = 0; - retained_trial.start_us = now; - battery_trial_end_us = now + 5000000; - battery_trial_armed = false; -} -#endif +bool previous_ir = false; +uint64_t next_vbus_check = 0; +uint64_t next_input = 0; +uint64_t presented_revision = UINT64_MAX; +StickDisplaySnapshot snapshot; void fatal(const char *reason) { Serial.printf("PW_STICK_FATAL %s\n", reason); @@ -132,97 +39,44 @@ void fatal(const char *reason) { } } -int battery_percent_estimate(u16 millivolts) { - if (!millivolts) return -1; - // M5Unified uses this voltage-only scale for M5PM1. It is an estimate, - // particularly while charging or under a changing load. - if (millivolts <= 3300) return 0; - if (millivolts >= 4100) return 100; - return (millivolts - 3300) * 100 / 800; +void refresh_usb() { + uint16_t millivolts; + usb_present = !StickBoardVbusVoltage(&millivolts) || millivolts >= 4000; + next_vbus_check = uint64_t(esp_timer_get_time()) + 1000000; } -void draw_battery_readout() { - if (!StickDisplayPanelIsReady()) return; - auto *screen = StickBoardScreen(); - if (!screen) return; - const u16 millivolts = StickBatteryMillivolts(); - const int percent = battery_percent_estimate(millivolts); - const bool on_usb = StickBoardPower().getVBUSVoltage() >= 4000; - screen->fillRect(8, 27, 224, 12, StickDisplayBackground()); - screen->setTextColor(StickDisplayForeground(), StickDisplayBackground()); - screen->setTextSize(1); - screen->setCursor(10, 28); - if (percent < 0) { - screen->print("Battery reading unavailable"); - } else { - screen->printf("Battery %u.%02u V ~%d%% %s", unsigned(millivolts / 1000), - unsigned((millivolts % 1000) / 10), percent, - on_usb ? "USB" : ""); - } - last_battery_draw_us = uint64_t(esp_timer_get_time()); +uint64_t next_deadline() { + uint64_t deadline = StickNativeNextDeadline(); + for (const uint64_t candidate : {next_input, next_vbus_check, + StickDisplayNextDeadline(), StickSoundNextDeadline(), + StickPeripheralNextDeadline(), StickSleepNextDeadline(), + StickSettingsNextDeadline()}) + if (candidate < deadline) deadline = candidate; + return deadline; } - -void draw_device_menu() { - auto *screen = StickBoardScreen(); - if (!screen) return; - StickDisplayPanelSetBacklight(1); - device_menu_draw_pending = !StickDisplayPanelIsReady(); - if (device_menu_draw_pending) return; - screen->fillScreen(StickDisplayBackground()); - screen->setTextColor(StickDisplayForeground(), StickDisplayBackground()); - screen->setTextSize(2); - screen->setCursor(8, 8); - screen->print("STICK SETTINGS"); - draw_battery_readout(); - screen->setTextSize(1); - screen->setCursor(10, 42); - screen->printf("%c Input: %s", device_menu_row == 0 ? '>' : ' ', - pw_stick::layout_name(pw_stick::Layout(StickInputLayout()))); - screen->setCursor(10, 55); - screen->printf("%c Rotation: %s", device_menu_row == 1 ? '>' : ' ', - StickInputOrientation() ? "Right side down" : "Left side down"); - screen->setCursor(10, 68); - screen->printf("%c Appearance: %s", device_menu_row == 2 ? '>' : ' ', - StickDisplayIsDark() ? "Dark" : "Light"); - for (unsigned shade = 0; shade < 4; ++shade) - screen->fillRect(176 + shade * 12, 68, 10, 8, - pw_stick::palette_color(StickDisplayIsDark(), shade)); - screen->setCursor(10, 81); - if (StickInputProfile()) - screen->printf(" Center: M"); - else - screen->printf("%c M+R window: %u ms", device_menu_row == 3 ? '>' : ' ', - 80 + StickInputChordWindowIndex() * 40); - screen->setCursor(10, 94); - screen->printf("%c Test speaker", device_menu_row == 4 ? '>' : ' '); - screen->drawFastHLine(8, 107, 224, StickDisplayForeground()); - screen->setCursor(10, 115); - screen->print("M next R change L close"); } -void present_game_if_changed(bool force = false) { - if (device_menu_open) return; - const unsigned frame = StickForegroundUiFrame(); - const int power = StickDisplayIsPowered(); - if (!force && last_present_frame == frame && last_present_power == power) - return; - if (!StickDisplayPresent()) return; - last_present_frame = frame; - last_present_power = power; +void StickPresentGame(int force) { + if (StickSettingsIsOpen()) return; + if (force) StickDisplayInvalidate(); + const uint64_t revision = StickDisplayRevision(); + if (revision == presented_revision && !StickDisplayPresentationPending()) return; + StickDisplayCapture(&snapshot); + const uint64_t started = uint64_t(esp_timer_get_time()); + if (StickDisplayPresent(&snapshot)) presented_revision = snapshot.revision; + StickDiagnosticsPresent(uint64_t(esp_timer_get_time()) - started); + StickNativeService(); } -void close_device_menu(bool wake_game) { - device_menu_open = false; - device_menu_draw_pending = false; - StickInputMenuMode(0); - if (wake_game) StickForegroundDeviceMenuClosed(); - if (StickDisplayPanelIsReady()) - StickBoardScreen()->fillScreen(StickDisplayBackground()); - StickDisplayInvalidate(); - present_game_if_changed(true); +extern "C" void StickPlatformPrepareIr(void) { + StickInputRequireRelease(); + StickSoundQuiesceForIr(); + StickPresentGame(0); + while (!StickSettingsIsOpen() && StickDisplayPanelIsReady() && + StickDisplayPresentationPending()) StickPresentGame(0); } -} // namespace +extern "C" void StickPlatformPresentIr(void) { StickPresentGame(0); } extern "C" void StickPortSetup(void) { Serial.begin(115200); @@ -230,634 +84,76 @@ extern "C" void StickPortSetup(void) { if (!StickBoardBegin()) { fatal("Board startup failed"); return; } if (!StickDisplayPanelInit()) { fatal("Display startup failed"); return; } if (!StickEepromMount()) { fatal("Game storage unavailable"); return; } + StickNativeInit(); StickEepromDefer(1); StickPortBoot(); StickEepromDefer(0); + if (!StickEepromHealthy()) { fatal("Game storage checkpoint failed"); return; } StickDisplayPanelSetOrientation(StickInputOrientation()); - present_game_if_changed(true); - // The board's infrared hardware uses the external 5 V rail. - // StickIrConfigure restores it at the start of a session. + StickSoundPrepare(); + StickPresentGame(1); StickBoardPower().setExtOutput(false); - if (!StickSleepBegin()) - Serial.printf("PW_STICK_SLEEP_SETUP_FAILED retry_ms=1000\n"); -#ifdef PW_STICK_BENCH_CONTROL - if (retained_trial.magic == kTrialMagic) - Serial.printf("PW_STICK_POWER_TRIAL_PREVIOUS completed=%u start_us=%llu " - "end_us=%llu start_reads=%u end_reads=%u failures=%u " - "start_seconds=%u end_seconds=%u\n", - retained_trial.completed, - (unsigned long long)retained_trial.start_us, - (unsigned long long)retained_trial.end_us, - retained_trial.start_reads, retained_trial.end_reads, - retained_trial.failures, retained_trial.start_seconds, - retained_trial.end_seconds); - retained_trial = {}; -#endif - const uint64_t now = uint64_t(esp_timer_get_time()); - refresh_usb_power(); - next_vbus_check_us = now + 1000000; - next_sample_us = now + sample_period_us(); - next_quarter_us = now + 250000; - next_second_us = now + 1000000; - next_input_us = now + 5000; - next_beep_us = now + 8000; - // Keep APB/SPI/I2S at their normal clock while reducing CPU work cost. - // The physical receiver restores 240 MHz before any session setup. - if (!setCpuFrequencyMhz(80)) - Serial.printf("PW_STICK_IDLE_CLOCK_FAILED\n"); -#ifdef PW_STICK_BENCH_CONTROL - timing_started_us = now; -#endif + if (!StickSleepBegin()) Serial.printf("PW_STICK_SLEEP_SETUP_FAILED retry_ms=1000\n"); + refresh_usb(); + next_input = uint64_t(esp_timer_get_time()); + if (!setCpuFrequencyMhz(80)) Serial.printf("PW_STICK_IDLE_CLOCK_FAILED\n"); + StickDiagnosticsInit(); + StickNativeStart(); ready = true; -#ifdef PW_STICK_BENCH_CONTROL - Serial.println("PW_STICK_READY"); -#endif } extern "C" void StickPortLoop(void) { if (!ready) { delay(50); return; } -#ifndef PW_STICK_BENCH_CONTROL - // Limit the serial-input sleep check to builds that consume diagnostic - // commands. - for (unsigned discarded = 0; discarded < 64 && Serial.available(); ++discarded) - Serial.read(); -#endif - const uint64_t now = uint64_t(esp_timer_get_time()); -#ifdef PW_STICK_BENCH_CONTROL - if (battery_trial_end_us && now >= battery_trial_end_us) { - battery_trial_end_us = 0; - // USB CDC may remain unavailable until the host resets/reconnects it. - // Retain the completed trial across that CPU reset without save writes. - unsigned successes; - StickBoardAccelDiagnostic(&retained_trial.end_reads, &successes, - &retained_trial.failures); - unsigned view, updates, frames, flags, idle, selection, pressed; - StickForegroundUiDiagnostic(&view, &updates, &frames, - &retained_trial.end_seconds, &flags, &idle, &selection, &pressed); - retained_trial.end_us = now; - retained_trial.completed = 1; - } - if (last_loop_entry_us && now - last_loop_entry_us > loop_gap_max_us) - loop_gap_max_us = now - last_loop_entry_us; - last_loop_entry_us = now; - if (passive_capture) { - if (now >= passive_deadline_us) { - StickIrStop(); - StickIrPassiveDiagnostic(0); - passive_capture = false; - Serial.println("PW_STICK_PASSIVE_DONE"); - StickIrBenchTrace(0); - } else { - ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(10)); - } - return; - } - if (receive_trial) { - u8 wire[136], length; - u16 tick; - while (StickIrTakeBurst(wire, sizeof(wire), &length, &tick)) { - Serial.printf("PW_STICK_TRIAL_FRAME length=%u tick=%u wire=", length, - tick); - for (unsigned i = 0; i < length; ++i) Serial.printf("%02x", wire[i]); - Serial.println(); - } - if (now >= receive_deadline_us || StickIrFailed()) { - StickIrStop(); - receive_trial = false; - Serial.println("PW_STICK_TRIAL_DONE"); - StickIrBenchTrace(0); - } else { - ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(1)); - } - return; - } - while (Serial.available()) { - const int command = Serial.read(); - if (command == 'c' && !StickForegroundIsIr()) { - StickIrBenchTrace(1); - StickForegroundRequestIr(); - Serial.println("PW_STICK_BENCH_CONNECT"); - if (!StickForegroundIsIr()) StickIrBenchTrace(0); - } - if (command == 'd' && !StickForegroundIsIr()) { - // Capture a normal menu-initiated session without starting IR from the - // host. The session completion path clears this one-shot trace flag. - StickIrBenchTrace(1); - Serial.println("PW_STICK_TRACE_ARMED"); - } - if (command == 'p' && !StickForegroundIsIr()) { - StickIrBenchTrace(1); - StickIrConfigure(); - StickIrPassiveDiagnostic(1); - StickIrStart(); - passive_capture = !StickIrFailed(); - passive_deadline_us = uint64_t(esp_timer_get_time()) + 200000; - Serial.println(passive_capture ? "PW_STICK_PASSIVE_START" : - "PW_STICK_PASSIVE_ERROR"); - if (!passive_capture) { - StickIrPassiveDiagnostic(0); - StickIrBenchTrace(0); - } - } - if (command == 'r' && !StickForegroundIsIr()) { - StickIrBenchTrace(1); - StickIrConfigure(); - StickIrPassiveDiagnostic(0); - StickIrStart(); - receive_trial = !StickIrFailed(); - receive_deadline_us = uint64_t(esp_timer_get_time()) + 2000000; - Serial.println(receive_trial ? "PW_STICK_TRIAL_START" : - "PW_STICK_TRIAL_ERROR"); - if (!receive_trial) StickIrBenchTrace(0); - } - if (command == 'e' && !StickForegroundIsIr()) { - const unsigned char *snapshot = StickEepromSnapshot(); - if (!snapshot) { - Serial.println("PW_STICK_EEPROM_UNAVAILABLE"); - } else { - static constexpr char hex[] = "0123456789abcdef"; - char row[513]; - Serial.println("PW_STICK_EEPROM_BEGIN bytes=65536"); - for (unsigned offset = 0; offset < 65536; offset += 256) { - for (unsigned i = 0; i < 256; ++i) { - const unsigned char value = snapshot[offset + i]; - row[2 * i] = hex[value >> 4]; - row[2 * i + 1] = hex[value & 15]; - } - row[512] = 0; - Serial.printf("PW_STICK_EEPROM_DATA offset=%u %s\n", offset, row); - } - Serial.println("PW_STICK_EEPROM_END"); - } - } - if (command == 'f' && !StickForegroundIsIr()) { - static uint8_t pixels[96 * 64]; - static constexpr char hex[] = "0123456789abcdef"; - char row[97]; - StickDisplayFrame(pixels, sizeof(pixels)); - Serial.println("PW_STICK_FRAME_BEGIN width=96 height=64"); - for (unsigned y = 0; y < 64; ++y) { - for (unsigned x = 0; x < 96; x += 2) - row[x / 2] = hex[(pixels[y * 96 + x] << 2) | - pixels[y * 96 + x + 1]]; - row[48] = 0; - Serial.printf("PW_STICK_FRAME_ROW y=%u %s\n", y, row); - } - Serial.println("PW_STICK_FRAME_END"); - } - if (command == 'j' && !StickForegroundIsIr()) { - // Switch both buttons to RTC mode, then exercise the sleep-return GPIO - // restoration while USB remains connected. - const esp_err_t first = rtc_gpio_init(GPIO_NUM_11); - const esp_err_t second = rtc_gpio_init(GPIO_NUM_12); - const unsigned before = - (REG_GET_BIT(RTC_IO_TOUCH_PAD11_REG, RTC_IO_TOUCH_PAD11_MUX_SEL) ? 1u : 0u) | - (REG_GET_BIT(RTC_IO_TOUCH_PAD12_REG, RTC_IO_TOUCH_PAD12_MUX_SEL) ? 2u : 0u); - const bool restored = StickSleepBegin(); - const unsigned after = - (REG_GET_BIT(RTC_IO_TOUCH_PAD11_REG, RTC_IO_TOUCH_PAD11_MUX_SEL) ? 1u : 0u) | - (REG_GET_BIT(RTC_IO_TOUCH_PAD12_REG, RTC_IO_TOUCH_PAD12_MUX_SEL) ? 2u : 0u); - Serial.printf("PW_STICK_BUTTON_MUX first=%d second=%d before=%u restored=%u after=%u\n", - first, second, before, unsigned(restored), after); - } - if (command == 'i' && !StickForegroundIsIr()) { - uint8_t pmic = 0; - uint8_t reset_cfg = 0, off_cfg = 0; - const bool pmic_ok = m5::In_I2C.readRegister(0x6e, 0x48, &pmic, 1, - 100000); - const bool config_ok = m5::In_I2C.readRegister(0x6e, 0x49, - &reset_cfg, 1, 100000) && - m5::In_I2C.readRegister(0x6e, 0x4a, - &off_cfg, 1, 100000); - unsigned m_edges, r_edges, l_edges, l_events, pmic_errors, raw, logical, power_ready; - StickInputDiagnostic(&m_edges, &r_edges, &l_edges, &l_events, &pmic_errors, - &raw, &logical, &power_ready); - Serial.printf("PW_STICK_INPUT_RAW main=%u side=%u power=%u pmic_ok=%u pmic=%02x config_ok=%u reset_cfg=%02x off_cfg=%02x\n", - unsigned(digitalRead(11) == LOW), - unsigned(digitalRead(12) == LOW), - unsigned(pmic_ok && (pmic & 1)), unsigned(pmic_ok), pmic, - unsigned(config_ok), reset_cfg, off_cfg); - Serial.printf("PW_STICK_INPUT_EVENTS main=%u side=%u power=%u power_events=%u pmic_errors=%u raw=%u logical=%u power_ready=%u\n", - m_edges, r_edges, l_edges, l_events, pmic_errors, raw, logical, - power_ready); - unsigned stable, gesture, desired, delivered, queued, wait_release; - unsigned emitted, consumed, overflow, left, right, center; - StickInputPathDiagnostic(&stable, &gesture, &desired, &delivered, - &queued, &wait_release, &emitted, &consumed, - &overflow, &left, &right, ¢er); - Serial.printf("PW_STICK_INPUT_PATH stable=%u gesture=%u desired=%u delivered=%u queued=%u wait=%u emitted=%u consumed=%u overflow=%u left=%u right=%u center=%u\n", - stable, gesture, desired, delivered, queued, wait_release, - emitted, consumed, overflow, left, right, center); - } - if ((command == 'K' || command == 'U' || command == 'Q' || command == 'O') && !StickForegroundIsIr()) { - StickSoundBenchCapture(command != 'U', command == 'Q', command == 'O'); - } - if (command == 'H' && !StickForegroundIsIr()) { - const int started = StickForegroundBenchMoveScore(); - const unsigned early = sample_bclk_edges(); - delay(50); - const unsigned late = sample_bclk_edges(); - Serial.printf("PW_STICK_CODEC_CLOCK started=%u early_edges=%u late_edges=%u registers=", - started, early, late); - for (const unsigned reg : {0x00,0x01,0x02,0x03,0x04,0x05,0x06,0x07, - 0x08,0x09,0x0a,0x0b,0x0c,0x0d,0x0e,0x10,0x11,0x12, - 0x13,0x31,0x32,0x37,0x44}) { - uint8_t value = 0; - const bool ok = m5::In_I2C.readRegister(0x18, reg, &value, 1, 100000); - Serial.printf("%02x:%02x:%u,", reg, value, ok); - } - Serial.println(); - } - if (command == 'G' && !StickForegroundIsIr()) { - // Delay score servicing to reproduce foreground rendering stalls. - const int started = StickForegroundBenchMoveScore(); - if (started) delay(80); - Serial.printf("PW_STICK_SOUND_STALL started=%u stall_ms=80\n", started); - } - if (command == 's' && !StickForegroundIsIr()) { - unsigned active, speaker_ready, codec, mode, compare, begins; - unsigned begin_errors, power_errors, tones, tone_errors, playing; - StickSoundDiagnostic(&active, &speaker_ready, &codec, &mode, &compare, - &begins, &begin_errors, &power_errors, &tones, - &tone_errors, &playing); - Serial.printf("PW_STICK_SOUND active=%u ready=%u codec=%u mode=%u compare=%u begins=%u begin_errors=%u power_errors=%u tones=%u tone_errors=%u playing=%u\n", - active, speaker_ready, codec, mode, compare, begins, - begin_errors, power_errors, tones, tone_errors, playing); - unsigned measured, under20, under50, shortest; - StickSoundTimingDiagnostic(&measured, &under20, &under50, &shortest); - Serial.printf("PW_STICK_SOUND_TIMING measured=%u under20=%u under50=%u shortest_us=%u\n", - measured, under20, under50, shortest); - uint8_t gpio_out = 0, gpio_in = 0, gpio_mode = 0; - uint8_t gpio_func = 0, pwr_cfg = 0, codec_reg = 0; - const bool pmic_ok = - m5::In_I2C.readRegister(0x6e, 0x11, &gpio_out, 1, 100000) && - m5::In_I2C.readRegister(0x6e, 0x12, &gpio_in, 1, 100000) && - m5::In_I2C.readRegister(0x6e, 0x10, &gpio_mode, 1, 100000) && - m5::In_I2C.readRegister(0x6e, 0x16, &gpio_func, 1, 100000) && - m5::In_I2C.readRegister(0x6e, 0x06, &pwr_cfg, 1, 100000); - const bool codec_ok = m5::In_I2C.readRegister( - 0x18, 0x00, &codec_reg, 1, 100000); - unsigned m_edges, r_edges, l_edges, l_events, pmic_errors, raw, logical, power_ready; - StickInputDiagnostic(&m_edges, &r_edges, &l_edges, &l_events, &pmic_errors, - &raw, &logical, &power_ready); - Serial.printf("PW_STICK_SOUND_HW pmic_ok=%u gpio_out=%02x gpio_in=%02x gpio_mode=%02x gpio_func=%02x pwr_cfg=%02x codec_ok=%u codec_00=%02x input_raw=%u power_edges=%u\n", - unsigned(pmic_ok), gpio_out, gpio_in, gpio_mode, - gpio_func, pwr_cfg, unsigned(codec_ok), codec_reg, - raw, l_edges); - } - if (command == 'a' && !StickForegroundIsIr()) - Serial.printf("PW_STICK_SOUND_BENCH_TONE started=%u duration_ms=8000\n", - unsigned(StickSoundBenchTone())); - if (command == 'b' && !StickForegroundIsIr()) { - auto &power = StickBoardPower(); - Serial.printf("PW_STICK_POWER battery_mv=%u vbus_mv=%u source=%u " - "ext_5v=%u inactive=%u sample_period_us=%llu\n", - unsigned(StickBatteryMillivolts()), - unsigned(power.getVBUSVoltage()), - unsigned(power.getPowerSource()), - unsigned(power.getExtOutput()), - unsigned(StickForegroundIsInactive()), - (unsigned long long)sample_period_us()); - uint64_t count, sleep_us, timer_wakes, gpio_wakes, errors, rejections; - int last_sleep_error; - unsigned sleep_gpio, sleep_duration; - StickSleepDiagnostic(&count, &sleep_us, &timer_wakes, &gpio_wakes, - &errors, &rejections, &last_sleep_error, &sleep_gpio, - &sleep_duration); - Serial.printf("PW_STICK_SLEEP count=%llu sleep_us=%llu timer=%llu " - "gpio=%llu errors=%llu last_error=%d last_gpio=%u " - "duration=%u rejections=%llu\n", - (unsigned long long)count, - (unsigned long long)sleep_us, - (unsigned long long)timer_wakes, - (unsigned long long)gpio_wakes, - (unsigned long long)errors, - last_sleep_error, - sleep_gpio, sleep_duration, - (unsigned long long)rejections); - unsigned accel_reads, accel_successes, accel_failures; - StickBoardAccelDiagnostic(&accel_reads, &accel_successes, - &accel_failures); - Serial.printf("PW_STICK_ACCEL reads=%u successes=%u failures=%u\n", - accel_reads, accel_successes, accel_failures); - } - if (command == 'F' && !StickForegroundIsIr() && - StickDisplayPanelIsReady()) { - // Exercise cold panel wake and finish with the current game image - // displayed. - auto *screen = StickBoardScreen(); - auto *panel = screen->getPanel(); - screen->startWrite(); screen->writeCommand(0x28); screen->endWrite(); - const unsigned off = panel->readCommand(0x0a, 0, 1); - screen->writeCommand(0x29); // Reproduce the former unselected write. - panel->waitDMA(); - const unsigned unselected = panel->readCommand(0x0a, 0, 1); - screen->startWrite(); screen->writeCommand(0x29); screen->endWrite(); - const unsigned selected = panel->readCommand(0x0a, 0, 1); - Serial.printf("PW_STICK_LCD_CS off=%02x unselected=%02x selected=%02x\n", - off, unselected, selected); - } - if (command == 'D' && !StickForegroundIsIr()) { - auto *screen = StickBoardScreen(); - if (!screen || !StickDisplayPanelIsReady()) { - Serial.println("PW_STICK_LCD_REG ready=0"); - } else { - auto *panel = screen->getPanel(); - Serial.printf("PW_STICK_LCD_REG ready=1 power=%02x madctl=%02x pixel=%02x\n", - unsigned(panel->readCommand(0x0a, 0, 1)), - unsigned(panel->readCommand(0x0b, 0, 1)), - unsigned(panel->readCommand(0x0c, 0, 1))); - } - } - if (command == 'P' && !StickForegroundIsIr()) { - uint8_t gpio_out = 0, pwr_cfg = 0, accel_conf = 0; - uint8_t imu_power = 0, imu_power_conf = 0; - const bool pmic_ok = - m5::In_I2C.readRegister(0x6e, 0x11, &gpio_out, 1, 100000) && - m5::In_I2C.readRegister(0x6e, 0x06, &pwr_cfg, 1, 100000); - const bool imu_ok = - m5::In_I2C.readRegister(0x68, 0x40, &accel_conf, 1, 100000) && - m5::In_I2C.readRegister(0x68, 0x7d, &imu_power, 1, 100000) && - m5::In_I2C.readRegister(0x68, 0x7c, &imu_power_conf, 1, 100000); - Serial.printf("PW_STICK_POWER_HW cpu_mhz=%u panel_awake=%u " - "pwm_ready=%u sound_busy=%u pmic_ok=%u gpio_out=%02x " - "pwr_cfg=%02x imu_ok=%u accel_conf=%02x imu_power=%02x " - "imu_power_conf=%02x\n", - unsigned(getCpuFrequencyMhz()), StickDisplayPanelIsReady(), - StickBacklightSleepReady(), StickSoundIsBusy(), - unsigned(pmic_ok), gpio_out, pwr_cfg, unsigned(imu_ok), - accel_conf, imu_power, imu_power_conf); - } - if (command == 'o' && !StickForegroundIsIr()) { - const u16 millivolts = StickBatteryMillivolts(); - Serial.printf("PW_STICK_DEVICE_MENU open=%u row=%u profile=%u orientation=%u chord_ms=%u battery_mv=%u battery_pct=%d usb=%u layout=%u dark=%u\n", - device_menu_open, device_menu_row, - StickInputProfile(), StickInputOrientation(), - 80 + StickInputChordWindowIndex() * 40, - unsigned(millivolts), battery_percent_estimate(millivolts), - unsigned(StickBoardPower().getVBUSVoltage() >= 4000), - StickInputLayout(), StickDisplayIsDark()); - } - if (command == 't' && !StickForegroundIsIr()) { - const uint64_t end = uint64_t(esp_timer_get_time()); - unsigned view, updates, frames, seconds, flags, idle, selection, pressed; - StickForegroundUiDiagnostic(&view, &updates, &frames, &seconds, - &flags, &idle, &selection, &pressed); - Serial.printf("PW_STICK_TIMING span_us=%llu main=%llu main_us=%llu beep=%llu beep_us=%llu input=%llu input_us=%llu present_us=%llu lag_us=%lld\n", - (unsigned long long)(end - timing_started_us), - (unsigned long long)main_ticks, - (unsigned long long)main_us, - (unsigned long long)beep_ticks, - (unsigned long long)beep_us, - (unsigned long long)input_polls, - (unsigned long long)input_us, - (unsigned long long)present_us, - (long long)(int64_t(end) - int64_t(next_sample_us))); - Serial.printf("PW_STICK_TIMING_EXTRA sound_us=%llu sound_max_us=%llu loop_gap_max_us=%llu\n", - (unsigned long long)sound_us, - (unsigned long long)sound_max_us, - (unsigned long long)loop_gap_max_us); - Serial.printf("PW_STICK_UI view=%u updates=%u frames=%u seconds=%u flags=%02x idle=%u selection=%u pressed=%u\n", - view, updates, frames, seconds, flags, idle, selection, - pressed); - timing_started_us = end; - main_ticks = beep_ticks = input_polls = 0; - main_us = beep_us = input_us = present_us = 0; - sound_us = sound_max_us = loop_gap_max_us = 0; - } - if (command >= '1' && command <= '3' && !StickForegroundIsIr()) { - const u8 button = command == '1' ? 4 : command == '2' ? 2 : 8; - StickInputBenchInject(button); - Serial.printf("PW_STICK_BUTTON_INJECTED value=%u\n", button); - } - if (command == 'z' && !StickForegroundIsIr()) { - StickForegroundBenchSleep(); - present_game_if_changed(true); - Serial.println("PW_STICK_BENCH_SLEEP"); - } - if (command == 'Y' && !StickForegroundIsIr()) { - battery_trial_armed = true; - retained_trial = {}; - Serial.println("PW_STICK_BATTERY_TRIAL_ARMED duration_ms=5000"); - } - if (command == 'n' && !StickForegroundIsIr()) { - StickForegroundBenchInactive(); - present_game_if_changed(true); - Serial.println("PW_STICK_BENCH_INACTIVE"); - } - if (command == 'w' && !StickForegroundIsIr()) { - StickInputBenchHold(2, 10); - Serial.println("PW_STICK_BENCH_CENTER_HOLD"); - } - if (command == 'v' && !StickForegroundIsIr()) { - StickInputBenchMainHold(650); - Serial.println("PW_STICK_BENCH_MAIN_HOLD"); - } - if (command == 'V' && !StickForegroundIsIr()) { - StickInputBenchMainHold(40); // Abandon preparation before wake acceptance. - Serial.println("PW_STICK_BENCH_MAIN_TAP"); - } - if (command == 'l' && !StickForegroundIsIr()) { - StickInputBenchPowerEvent(); - Serial.println("PW_STICK_BENCH_POWER_EVENT"); - } - if (command == 'x' && !StickForegroundIsIr()) { - const bool erased = StickEepromBenchErase(); - Serial.println(erased ? "PW_STICK_BENCH_ERASED" : - "PW_STICK_BENCH_ERASE_FAILED"); - Serial.flush(); - if (erased) ESP.restart(); - } - } -#endif - if (now >= next_quarter_us) { - next_quarter_us += 250000; - if (!StickForegroundIsInactive()) StickForegroundQuarterSecond(); - } - if (now >= next_second_us) { - next_second_us += 1000000; - StickForegroundSecond(); - } - - if (device_menu_open && now - last_battery_draw_us >= 5000000) - draw_battery_readout(); - + if (StickDiagnosticsService()) return; + StickNativeService(); if (StickForegroundIsIr()) { previous_ir = true; - StickForegroundRun(); - if (StickForegroundIsIr()) - ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(10)); + StickNativeRun(); + if (StickForegroundIsIr() && !StickForegroundIrHasInput()) + StickIrResumeReception(); + if (StickForegroundIsIr()) ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(1)); return; } if (previous_ir) { previous_ir = false; - // IR can occupy the foreground for several seconds. Resume sampling from - // the current time when it finishes. - next_sample_us = now + sample_period_us(); - next_beep_us = now + 8000; -#ifdef PW_STICK_BENCH_CONTROL - if (StickIrBenchTraceEnabled()) - Serial.printf("PW_STICK_IR_DONE result=%u\n", StickForegroundIrResult()); - StickIrBenchTrace(0); -#endif + next_input = uint64_t(esp_timer_get_time()); + if (StickDisplayIsPowered()) StickSoundPrepare(); } + uint64_t now = uint64_t(esp_timer_get_time()); + if (next_input == UINT64_MAX && (usb_present || StickSoundIsBusy())) + next_input = now; + if (now >= next_input) { + StickInputPoll(millis()); + StickDiagnosticsInput(uint64_t(esp_timer_get_time()) - now); + next_input = StickInputNextDeadline(); + } + StickNativeService(); + StickSettingsService(); + now = uint64_t(esp_timer_get_time()); + if (StickDisplayIsPowered() || StickSettingsIsOpen()) StickSoundPrepare(); + else if (!StickInputWakeScanActive()) StickSoundSuspend(); + StickSoundService(); + StickDiagnosticsSound(uint64_t(esp_timer_get_time()) - now); StickDisplayPowerService(); StickPeripheralPowerService(); - // The board overlay uses the native settings' inactivity interval. - if (device_menu_open && now - last_menu_input_us >= kDeviceMenuIdleUs) - close_device_menu(false); - if (device_menu_open && device_menu_draw_pending) draw_device_menu(); - -#ifdef PW_STICK_BENCH_CONTROL - const uint64_t sound_begin = uint64_t(esp_timer_get_time()); -#endif - StickSoundService(); -#ifdef PW_STICK_BENCH_CONTROL - const uint64_t sound_elapsed = uint64_t(esp_timer_get_time()) - sound_begin; - sound_us += sound_elapsed; - if (sound_elapsed > sound_max_us) sound_max_us = sound_elapsed; -#endif - // After a long foreground task, schedule the next sample from the current - // time. - const uint64_t sample_period = sample_period_us(); - if (now > next_sample_us + sample_period) next_sample_us = now; - // Recalculate the deadline when a gesture or foreground callback shortens - // the sample period. - if (StickInputWakeScanActive() && next_sample_us > now + sample_period) - next_sample_us = now + sample_period; - if (now > next_input_us + 5000) next_input_us = now; - if (now > next_beep_us + 8000) next_beep_us = now; - if (now >= next_input_us) { - next_input_us += 5000; -#ifdef PW_STICK_BENCH_CONTROL - const uint64_t begin = uint64_t(esp_timer_get_time()); -#endif - StickInputPoll(millis()); - // The H8's center IRQ resumes its fast sampling timer immediately. On - // this board the comfort chord is resolved by polling, so hold that - // cadence while a physical or queued gesture needs native input scans. - if (sample_period_us() < sample_period && - next_sample_us > now + sample_period_us()) - next_sample_us = now + sample_period_us(); -#ifdef PW_STICK_BENCH_CONTROL - ++input_polls; - input_us += uint64_t(esp_timer_get_time()) - begin; -#endif - if (!device_menu_open && StickMenuRequested()) { - device_menu_open = true; - last_menu_input_us = now; - device_menu_row = 0; - StickInputMenuMode(1); - draw_device_menu(); - } - if (device_menu_open) { - const u8 buttons = StickInputTakeMenuButtons(); - if (buttons) last_menu_input_us = now; - if (buttons & 4u) { - // The game occupies only the centered 192x128 region. Erase the - // settings text in the border before returning to that framebuffer. - close_device_menu(true); - } else if (buttons & 1u) { - device_menu_row = (device_menu_row + 1u) % kDeviceMenuRows; - if (device_menu_row == 3 && StickInputProfile()) ++device_menu_row; - draw_device_menu(); - } else if (buttons & 2u) { - const u8 layout = StickInputLayout(); - const u8 orientation = StickInputOrientation(); - const u8 chord = StickInputChordWindowIndex(); - const u8 next_layout = device_menu_row == 0 ? (layout + 1u) % 4u : layout; - const u8 next_orientation = device_menu_row == 1 ? orientation ^ 1u : orientation; - const u8 next_chord = device_menu_row == 3 ? (chord + 1u) % 3u : chord; - if (device_menu_row == 4) { - StickSoundTestTone(); - } else if (device_menu_row == 2) { - if (StickDisplaySetDark(!StickDisplayIsDark())) draw_device_menu(); - } else if (StickInputConfigure(next_layout, next_orientation, - next_chord)) { - if (next_orientation != orientation) - StickDisplayPanelSetOrientation(next_orientation); - draw_device_menu(); - } - } - } - } - if (StickForegroundIsMain() && now >= next_sample_us) { - next_sample_us += sample_period; -#ifdef PW_STICK_BENCH_CONTROL - const uint64_t begin = uint64_t(esp_timer_get_time()); -#endif - StickForegroundRun(); - if (sample_period_us() != sample_period) - next_sample_us = now + sample_period_us(); -#ifdef PW_STICK_BENCH_CONTROL - ++main_ticks; - main_us += uint64_t(esp_timer_get_time()) - begin; -#endif - if (!StickForegroundIsIr() && !device_menu_open) { -#ifdef PW_STICK_BENCH_CONTROL - const uint64_t present_begin = uint64_t(esp_timer_get_time()); -#endif - present_game_if_changed(); -#ifdef PW_STICK_BENCH_CONTROL - present_us += uint64_t(esp_timer_get_time()) - present_begin; -#endif - } - return; - } - if (StickForegroundIsBeep() && now >= next_beep_us) { - next_beep_us += 8000; -#ifdef PW_STICK_BENCH_CONTROL - const uint64_t begin = uint64_t(esp_timer_get_time()); -#endif - StickForegroundRun(); -#ifdef PW_STICK_BENCH_CONTROL - ++beep_ticks; - beep_us += uint64_t(esp_timer_get_time()) - begin; - const uint64_t present_begin = uint64_t(esp_timer_get_time()); -#endif - if (!device_menu_open) present_game_if_changed(); - if (StickForegroundIsMain()) next_sample_us = now + sample_period_us(); -#ifdef PW_STICK_BENCH_CONTROL - present_us += uint64_t(esp_timer_get_time()) - present_begin; -#endif - return; - } - if (StickForegroundIsMain() && !StickSoundIsBusy() && - !StickInputWakeScanActive() && - ((!StickDisplayIsPowered() && !device_menu_open) || - StickBacklightSleepReady()) -#ifdef PW_STICK_BENCH_CONTROL - && !Serial.available() -#endif - ) { - // Keep the CPU awake while USB is connected; Light-sleep disconnects USB - // Serial/JTAG. An armed battery trial starts after unplugging. - if (now >= next_vbus_check_us) { - refresh_usb_power(); - next_vbus_check_us = now + 1000000; - } -#ifdef PW_STICK_BENCH_CONTROL - if (!usb_present && battery_trial_armed) begin_battery_trial(now); -#endif - const bool usb_sleep_allowed = !usb_present; - if (usb_sleep_allowed) { - uint64_t deadline = next_sample_us; - const uint64_t display_deadline = StickDisplayNextDeadline(); - if (display_deadline < deadline) deadline = display_deadline; - if (device_menu_open && last_menu_input_us + kDeviceMenuIdleUs < deadline) - deadline = last_menu_input_us + kDeviceMenuIdleUs; - if (next_second_us < deadline) deadline = next_second_us; - if (!StickForegroundIsInactive() && next_quarter_us < deadline) - deadline = next_quarter_us; - if (StickSleepUntil(deadline)) { - // A switch can wake the chip between scheduled 5 ms polls. Poll it in - // the next loop before returning to sleep or delivering a native scan. - next_input_us = uint64_t(esp_timer_get_time()); - return; - } - } + now = uint64_t(esp_timer_get_time()); + const unsigned task = StickNativeRun(); + StickDiagnosticsForeground(task, uint64_t(esp_timer_get_time()) - now); + if (StickForegroundIsIr()) return; + StickPresentGame(0); + now = uint64_t(esp_timer_get_time()); + if (now >= next_vbus_check) refresh_usb(); + const uint64_t deadline = next_deadline(); + if (!usb_present && !StickSoundIsBusy() && + ((!StickDisplayIsPowered() && !StickSettingsIsOpen()) || StickBacklightSleepReady())) { + StickDiagnosticsBeforeSleep(usb_present); + if (StickSleepUntil(deadline)) { + next_input = uint64_t(esp_timer_get_time()); + return; + } + if (next_input == UINT64_MAX) next_input = now + 5000; } - delay(1); + if (deadline > now + 1000) delay(1); } diff --git a/stick/board_runtime.h b/stick/board_runtime.h new file mode 100644 index 0000000..170ef45 --- /dev/null +++ b/stick/board_runtime.h @@ -0,0 +1,12 @@ +#pragma once + +void StickPresentGame(int force); + +#ifdef __cplusplus +extern "C" { +#endif +void StickPlatformPrepareIr(void); +void StickPlatformPresentIr(void); +#ifdef __cplusplus +} +#endif diff --git a/stick/board_settings.cpp b/stick/board_settings.cpp new file mode 100644 index 0000000..c81bcfb --- /dev/null +++ b/stick/board_settings.cpp @@ -0,0 +1,134 @@ +#include "board_settings.h" + +#include "battery_bridge.h" +#include "board_hal.h" +#include "controls.h" +#include "display_bus.h" +#include "display_palette.h" +#include "display_panel.h" +#include "foreground_bridge.h" +#include "input_bridge.h" +#include "native_runtime.h" +#include "sound_bridge.h" +#include +#include + +namespace { +bool opened = false; +bool draw_pending = false; +unsigned row = 0; +uint64_t last_input = 0; +uint64_t last_battery = 0; +constexpr uint64_t kIdleUs = 90000000; + +void battery() { + if (!StickDisplayPanelIsReady()) return; + auto *screen = StickBoardScreen(); + const unsigned millivolts = StickBatteryMillivolts(); + const int percent = StickBatteryPercentEstimate(millivolts); + screen->fillRect(8, 27, 224, 12, StickDisplayBackground()); + screen->setTextColor(StickDisplayForeground(), StickDisplayBackground()); + screen->setTextSize(1); + screen->setCursor(10, 28); + if (percent < 0) screen->print("Battery reading unavailable"); + else screen->printf("Battery %u.%02u V ~%d%% %s", millivolts / 1000, + (millivolts % 1000) / 10, percent, + StickBoardPower().getVBUSVoltage() >= 4000 ? "USB" : ""); + last_battery = uint64_t(esp_timer_get_time()); +} + +void draw() { + StickDisplayPanelSetBacklight(1); + draw_pending = !StickDisplayPanelIsReady(); + if (draw_pending) return; + auto *screen = StickBoardScreen(); + screen->fillScreen(StickDisplayBackground()); + StickNativeService(); + screen->setTextColor(StickDisplayForeground(), StickDisplayBackground()); + screen->setTextSize(2); + screen->setCursor(8, 8); + screen->print("STICK SETTINGS"); + battery(); + screen->setTextSize(1); + screen->setCursor(10, 42); + screen->printf("%c Input: %s", row == 0 ? '>' : ' ', + pw_stick::layout_name(pw_stick::Layout(StickInputLayout()))); + screen->setCursor(10, 55); + screen->printf("%c Rotation: %s", row == 1 ? '>' : ' ', + StickInputOrientation() ? "Right side down" : "Left side down"); + screen->setCursor(10, 68); + screen->printf("%c Appearance: %s", row == 2 ? '>' : ' ', + StickDisplayIsDark() ? "Dark" : "Light"); + for (unsigned shade = 0; shade < 4; ++shade) + screen->fillRect(176 + shade * 12, 68, 10, 8, + pw_stick::palette_color(StickDisplayIsDark(), shade)); + screen->setCursor(10, 81); + if (StickInputProfile()) screen->print(" Center: M"); + else screen->printf("%c M+R window: %u ms", row == 3 ? '>' : ' ', + 80 + StickInputChordWindowIndex() * 40); + screen->setCursor(10, 94); + screen->printf("%c Test speaker", row == 4 ? '>' : ' '); + screen->drawFastHLine(8, 107, 224, StickDisplayForeground()); + screen->setCursor(10, 115); + screen->print("M next R change L close"); + StickNativeService(); +} + +void close(bool wake) { + opened = draw_pending = false; + StickInputMenuMode(0); + if (wake) StickForegroundDeviceMenuClosed(); + if (StickDisplayPanelIsReady()) StickBoardScreen()->fillScreen(StickDisplayBackground()); + StickDisplayInvalidate(); +} +} + +void StickSettingsService(void) { + const uint64_t now = uint64_t(esp_timer_get_time()); + if (!opened && StickMenuRequested()) { + opened = true; + row = 0; + last_input = now; + StickInputMenuMode(1); + draw(); + } + if (!opened) return; + if (now - last_input >= kIdleUs) { close(false); return; } + if (draw_pending) draw(); + if (now - last_battery >= 5000000) battery(); + const u8 buttons = StickInputTakeMenuButtons(); + if (!buttons) return; + last_input = now; + if (buttons & 4u) { close(true); return; } + if (buttons & 1u) { + row = (row + 1) % 5; + if (row == 3 && StickInputProfile()) ++row; + draw(); + return; + } + if (!(buttons & 2u)) return; + if (row == 4) { StickSoundTestTone(); return; } + if (row == 2) { + if (StickDisplaySetDark(!StickDisplayIsDark())) draw(); + return; + } + const u8 layout = StickInputLayout(); + const u8 orientation = StickInputOrientation(); + const u8 chord = StickInputChordWindowIndex(); + const u8 next_orientation = row == 1 ? orientation ^ 1u : orientation; + if (StickInputConfigure(row == 0 ? (layout + 1u) % 4u : layout, + next_orientation, row == 3 ? (chord + 1u) % 3u : chord)) { + if (next_orientation != orientation) + StickDisplayPanelSetOrientation(next_orientation); + draw(); + } +} + +int StickSettingsIsOpen(void) { return opened; } +unsigned StickSettingsSelection(void) { return row; } +uint64_t StickSettingsNextDeadline(void) { + if (!opened) return UINT64_MAX; + const uint64_t battery_deadline = last_battery + 5000000; + const uint64_t idle_deadline = last_input + kIdleUs; + return battery_deadline < idle_deadline ? battery_deadline : idle_deadline; +} diff --git a/stick/board_settings.h b/stick/board_settings.h new file mode 100644 index 0000000..c80e79f --- /dev/null +++ b/stick/board_settings.h @@ -0,0 +1,8 @@ +#pragma once + +#include + +void StickSettingsService(void); +int StickSettingsIsOpen(void); +unsigned StickSettingsSelection(void); +uint64_t StickSettingsNextDeadline(void); diff --git a/stick/check.py b/stick/check.py index 4d808be..a9dbfd1 100644 --- a/stick/check.py +++ b/stick/check.py @@ -7,10 +7,14 @@ import hashlib import json import os +import shutil import statistics +import struct import subprocess import sys import tempfile +import zlib +from array import array from pathlib import Path from PIL import Image @@ -18,6 +22,24 @@ ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT / "stick/simulation")) from bootstrap import REVISION, prepare # noqa: E402 +from fixture import course, storage # noqa: E402 + +SCENARIOS = ( + "settings", + "connect", + "controls", + "layouts", + "wake", + "battery", + "recovery-stale", + "recovery-journal", + "recovery-truncated", + "peer", + "peer-storage", + "peer-transfer", + "peer-transfer-compressed", + "peer-play", +) def digest(path: Path) -> str: @@ -98,6 +120,120 @@ def optical_frames(path: Path) -> list[bytes]: return decoded +def screen_region(path: Path, box: tuple[int, int, int, int]) -> bytes: + return Image.open(path).rotate(-90, expand=True).crop(box).tobytes() + + +def expect_cursor(path: Path, x: int, y: int) -> None: + image = Image.open(path).rotate(-90, expand=True).convert("L") + for row in range(8): + for column in range(8): + lit = image.getpixel((24 + (x + column) * 2, 3 + (y + row) * 2)) >= 128 + if lit != (2 <= row < 6): + raise AssertionError(f"{path}: native editor cursor is not at ({x}, {y})") + + +def expect_tones(path: Path, count: int) -> list[dict]: + samples = array("h", path.read_bytes()) + if sys.byteorder != "little": + samples.byteswap() + rate = int(path.with_name(path.name + ".rate").read_text()) + active = [i for i, value in enumerate(samples) if abs(value) > 100] + spans: list[list[int]] = [] + for index in active: + if not spans or index - spans[-1][1] > rate * 0.005: + spans.append([index, index]) + else: + spans[-1][1] = index + if len(spans) != count: + raise AssertionError(f"{path}: expected {count} distinct synthetic notes, got {len(spans)}") + observations = [] + expected_frequency = 32768 / 78 + expected_duration = (int((81920 * 6 / 120 - 320) / 77) - 1) * 78 / 32768 + for start, end in spans: + duration = (end - start + 1) / rate + crossings = [i for i in range(start + 1, end + 1) if samples[i - 1] <= 0 < samples[i]] + if len(crossings) < 2: + raise AssertionError(f"{path}: note has no complete measured period") + cycle_samples = crossings[-1] - crossings[0] + frequency = (len(crossings) - 1) * rate / cycle_samples + duration_limit = 2 * 4 * 64 / rate + 0.001 + 2 / rate + if abs(duration - expected_duration) > duration_limit: + raise AssertionError( + f"{path}: note duration {duration:.4f}s differs from native {expected_duration:.4f}s" + ) + expected_samples = (len(crossings) - 1) * rate / expected_frequency + if abs(cycle_samples - expected_samples) > 2: + raise AssertionError( + f"{path}: note frequency {frequency:.2f} differs from native {expected_frequency:.2f} Hz" + ) + observations.append({"seconds": duration, "frequency_hz": frequency}) + return observations + + +def saved_eeprom(folder: Path, offset: int, size: int) -> bytes: + raw = (folder / "flash.bin").read_bytes()[offset : offset + size] + checkpoints = [] + for base in (0, 0x11000): + magic, generation, sequence, length, crc, header_crc, marker = struct.unpack_from( + "<7I", raw, base + ) + if marker != 0x54494D43: + continue + payload = raw[base + 28 : base + 28 + length] + if ( + magic != 0x53574531 + or length != 65536 + or zlib.crc32(raw[base : base + 20]) != header_crc + or zlib.crc32(payload) != crc + ): + raise AssertionError("Invalid committed EEPROM checkpoint") + checkpoints.append((generation, sequence, payload)) + if not checkpoints: + raise AssertionError("No committed EEPROM checkpoint") + generation, sequence, data = max(checkpoints) + payload = bytearray(data) + cursor = 0x22000 + while cursor < 0x62000: + magic, version, serial, length, crc, header_crc, marker = struct.unpack_from( + "<7I", raw, cursor + ) + if marker != 0x54494D43: + break + append = raw[cursor + 28 : cursor + 28 + length] + if ( + magic != 0x53574A31 + or not 0 < length <= 4096 + or zlib.crc32(raw[cursor : cursor + 20]) != header_crc + or zlib.crc32(append) != crc + ): + raise AssertionError("Invalid committed EEPROM journal record") + if serial > sequence: + if version != generation or serial != sequence + 1: + raise AssertionError("EEPROM journal skipped a sequence") + position = 0 + while position < len(append): + address, count = struct.unpack_from(" 65536 or position + count > len(append): + raise AssertionError("EEPROM journal patch exceeds bounds") + payload[address : address + count] = append[position : position + count] + position += count + sequence = serial + cursor += (28 + length + 255) & ~255 + return bytes(payload) + + +def expect_saved_settings(folder: Path, offset: int, size: int) -> None: + payload = saved_eeprom(folder, offset, size) + for address in (0x156, 0x256): + record = payload[address : address + 25] + if record[24] != (1 + sum(record[:24])) & 255 or record[23] != (5 << 3): + raise AssertionError( + "Native volume=0/contrast=5 did not survive save and reset in both mirrors" + ) + + def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--build-dir", type=Path, default=ROOT / "stick/.build/e2e-firmware") @@ -106,6 +242,7 @@ def main() -> None: ) parser.add_argument("--arduino-cli", default="arduino-cli") parser.add_argument("--config-file", type=Path) + parser.add_argument("--scenario", action="append", choices=SCENARIOS) parser.add_argument("--output-dir", type=Path, default=ROOT / "stick/.build/e2e-results") args = parser.parse_args() os.chdir(ROOT) @@ -135,12 +272,19 @@ def main() -> None: destination = args.output_dir.resolve() destination.mkdir(parents=True, exist_ok=True) out = Path(tempfile.mkdtemp(prefix="run-", dir=destination)) + firmware = out / "firmware.merged.bin" + shutil.copyfile(build / "PwStick.ino.merged.bin", firmware) + if digest(firmware) != manifest["outputs"]["PwStick.ino.merged.bin"]: + raise RuntimeError("Firmware changed while retaining the run's immutable image") + (out / "build-manifest.json").write_text(json.dumps(manifest, indent=2) + "\n") evidence = { - "application_sha256": digest(build / "PwStick.ino.bin"), - "merged_sha256": digest(build / "PwStick.ino.merged.bin"), + "application_sha256": manifest["outputs"]["PwStick.ino.bin"], + "merged_sha256": digest(firmware), + "firmware_sources_sha256": manifest["source_sha256"], "simulator_revision": REVISION, "simulator_patch_sha256": digest(ROOT / "stick/simulation/soc.patch"), "scenarios": {}, + "complete": False, } evidence["verification_sources_sha256"] = { name: digest(ROOT / name) @@ -152,26 +296,91 @@ def main() -> None: "stick/simulation/Cargo.lock", "stick/simulation/settings.actions", "stick/simulation/connect.actions", + "stick/simulation/controls.actions", + "stick/simulation/layouts.actions", + "stick/simulation/wake.actions", + "stick/simulation/recovery.actions", + "stick/simulation/peer.actions", + "stick/simulation/peer-storage.actions", + "stick/simulation/peer-transfer.actions", + "stick/simulation/peer-transfer-compressed.actions", + "stick/simulation/peer-play.actions", + "stick/simulation/peer.rs", + "stick/simulation/fixture.py", ) } - for scenario in ("settings", "connect"): + scenarios = args.scenario or SCENARIOS + fixture = None + if any(name not in ("settings", "connect") for name in scenarios): + fixture = storage(build / "PwStick.ino.partitions.bin", out) + evidence["fixture_sha256"] = digest(out / "fixture.eeprom") + (out / "results.json").write_text(json.dumps(evidence, indent=2) + "\n") + for scenario in scenarios: folder = out / scenario folder.mkdir(exist_ok=True) + scenario_fixture = fixture + if scenario.startswith("recovery-") or scenario == "peer-play": + scenario_fixture = storage(build / "PwStick.ino.partitions.bin", folder, scenario) + action = "recovery" if scenario.startswith("recovery-") else scenario + script = ROOT / f"stick/simulation/{action}.actions" + if scenario in ("peer-transfer", "peer-transfer-compressed", "peer-play"): + script = folder / f"{scenario}.actions" + actions = (ROOT / f"stick/simulation/{scenario}.actions").read_text() + if scenario.startswith("peer-transfer"): + source = folder / "course.bin" + source.write_bytes(course()) + else: + source = folder / "fixture.eeprom" + script.write_text(actions.replace("PUBLIC_FIXTURE", str(source))) + if scenario == "battery": + script = folder / "battery.actions" + actions = (ROOT / "stick/simulation/wake.actions").read_text() + actions = actions.replace( + "screen asleep.png", "run-until-timer-wake 1000\nscreen asleep.png" + ) + script.write_text("vbus 0\n" + actions) + # Exercise the unsaved three-key default, then explicitly select the + # two-key layout used by the established navigation/rollover scripts. + actions = script.read_text() + script = folder / "configured.actions" + script.write_text( + "run 8000\npress L\npress R\nrun 1200\nrelease L\nrelease R\n" + "run 200\nscreen defaults.png\n" + "press R\nrun 70\nrelease R\nrun 300\n" + "press R\nrun 70\nrelease R\nrun 300\n" + "press L\nrun 70\nrelease L\nrun 300\n" + actions + ) with (folder / "run.log").open("w") as log: subprocess.run( [ str(binary), str(rom), - str(build / "PwStick.ino.merged.bin"), - str(ROOT / f"stick/simulation/{scenario}.actions"), + str(firmware), + str(script), str(folder), - ], + ] + + ( + [str(scenario_fixture[0]), str(scenario_fixture[1])] + if scenario not in ("settings", "connect") + else [] + ), stdout=log, stderr=log, check=True, - timeout=180, + timeout=360, + env={ + **os.environ, + **( + {"STICK_E2E_OPCODE_TIMING": "1", "STICK_E2E_FLASH_TIMING": "300,20000"} + if scenario in ("peer-transfer", "peer-transfer-compressed", "peer-play") + else {} + ), + }, ) if scenario == "settings": + expect_screen( + folder / "defaults.png", "STICK SETTINGS", "3-key", "Left side down" + ) expect_screen(folder / "settings.png", "STICK SETTINGS", "Left side down", "Dark") expect_appearance(folder / "settings.png", light=False) for name in ("changed.png", "retained.png"): @@ -180,9 +389,9 @@ def main() -> None: ) expect_appearance(folder / name, light=True) evidence["scenarios"][scenario] = ( - "Buttons change rotation and appearance; reset retains both." + "Unsaved controls use L/M/R and left-side-down; buttons change rotation and appearance, and reset retains both." ) - else: + elif scenario == "connect": expect_screen(folder / "settings-after.png", "STICK SETTINGS") expect_screen(folder / "settings-retry.png", "STICK SETTINGS") first = optical_frames(folder / "first.optical") @@ -193,7 +402,221 @@ def main() -> None: "result": "Receiver held low and held high: Connect finishes, retries transmit, Settings remains usable.", "logical_frames": [frame.hex() for frame in retry], } + elif scenario == "controls": + for name, label in ( + ("settings-menu", "SETTINGS"), + ("inventory", "INVENTORY"), + ("trainer", "TRAINER"), + ("inventory-return", "INVENTORY"), + ("settings-return", "SETTINGS"), + ("rollover", "SETTINGS"), + ("native-settings", "SETTINGS"), + ): + expect_screen(folder / f"{name}.png", label) + for name in ("overlay-held", "overlay-released", "overlay-fresh"): + expect_screen(folder / f"{name}.png", "STICK SETTINGS") + region = (10, 42, 165, 52) + held = screen_region(folder / "overlay-held.png", region) + if held != screen_region(folder / "overlay-released.png", region): + raise AssertionError("Held R leaked through overlay capture before release") + if held == screen_region(folder / "overlay-fresh.png", region): + raise AssertionError("Fresh R after release did not change the layout") + evidence["scenarios"][scenario] = { + "result": "Short repeated and alternating taps navigate; rollover does not become Center; overlay captures held inputs until release.", + "tones": expect_tones(folder / "navigation.pcm", 5), + } + elif scenario == "layouts": + labels = [] + for index in range(4): + before, after = ( + folder / f"{prefix}{index}.png" for prefix in ("layout", "retained") + ) + expect_screen(before, "STICK SETTINGS") + expect_screen(after, "STICK SETTINGS") + label = screen_region(before, (10, 42, 165, 52)) + if label != screen_region(after, (10, 42, 165, 52)): + raise AssertionError(f"Layout {index} did not survive reset") + labels.append(label) + if len(set(labels)) != 4 or labels[0] != screen_region( + folder / "wrapped0.png", (10, 42, 165, 52) + ): + raise AssertionError("Layout selection did not cycle through four distinct choices") + if labels[2] != screen_region(folder / "defaults.png", (10, 42, 165, 52)): + raise AssertionError("Unsaved controls did not select the three-key L/R layout") + expect_screen(folder / "two-key-native.png", "SETTINGS") + expect_screen(folder / "two-key-reopened.png", "STICK SETTINGS") + if labels[0] != screen_region(folder / "two-key-reopened.png", (10, 42, 165, 52)): + raise AssertionError("Dedicated L did not reopen the retained two-key layout") + for mapping, index in (("LR", 2), ("RL", 3)): + direction = folder / f"direction-{mapping}.png" + pending = folder / f"pending-{mapping}.png" + expect_screen(direction, "SETTINGS") + if screen_region(direction, (24, 24, 184, 72)) == screen_region( + pending, (24, 24, 184, 72) + ): + raise AssertionError("Joining the Settings hold withheld gameplay Right") + if "STICK SETTINGS" in read_screen(pending, -90): + raise AssertionError("Settings opened before its one-second hold") + expect_cursor(folder / f"two-choice-{mapping}.png", 48, 20) + for prefix in ("held", "partial"): + path = folder / f"{prefix}-{mapping}.png" + expect_screen(path, "STICK SETTINGS") + if labels[index] != screen_region(path, (10, 42, 165, 52)): + raise AssertionError("Opening hold operated the Settings overlay") + for name, x, y in ( + ("volume", 64, 44), + ("volume-changed", 0, 44), + ("contrast", 40, 32), + ("contrast-changed", 48, 32), + ): + expect_cursor(folder / f"{name}.png", x, y) + expect_saved_settings(folder, fixture[0], fixture[1].stat().st_size) + evidence["scenarios"][scenario] = ( + "All four layouts persist; L+R delivers gameplay before opening Settings, consumes held buttons through release, and preserves the native two-choice selector and mirrored saves." + ) + elif scenario in ("wake", "battery"): + for name in ("asleep", "ignored", "cancelled"): + state = (folder / f"{name}.txt").read_text() + if "sleeping=true" not in state or "peripheral_supply=false" not in state: + raise AssertionError( + f"{name}: expected suspended panel and released shared supply; {state}" + ) + if "sleeping=false" not in (folder / "held.txt").read_text(): + raise AssertionError("Qualified M hold did not wake the panel") + if scenario == "battery": + asleep = dict( + line.split("=", 1) + for line in (folder / "asleep.txt").read_text().splitlines()[1:] + ) + held = dict( + line.split("=", 1) + for line in (folder / "held.txt").read_text().splitlines()[1:] + ) + if int(asleep["rtc_wakeup"]) & 8 == 0 or int(asleep["ext1_mask"]) != 1 << 11: + raise AssertionError( + "Battery idle must reach RTC timer sleep with M-only EXT1 wake" + ) + if ( + int(held["rtc_wakeup"]) & 2 == 0 + or held["main_rtc_mux"] != "0" + or held["side_rtc_mux"] != "0" + ): + raise AssertionError( + "M must wake via EXT1 and both front buttons must return to digital GPIO" + ) + if screen_region(folder / "held.png", (24, 3, 216, 131)) != screen_region( + folder / "released.png", (24, 3, 216, 131) + ): + raise AssertionError("Wake hold/release leaked a native navigation action") + expect_screen(folder / "first-action.png", "SETTINGS") + expect_screen(folder / "repeated-action.png", "INVENTORY") + expect_screen(folder / "settings.png", "STICK SETTINGS") + evidence["scenarios"][scenario] = { + "result": "Native timeout sleeps; R/L do not wake; short M cancels; qualified M wakes and is consumed through release.", + "vbus_mv": 0 if scenario == "battery" else 5000, + "cold": expect_tones(folder / "cold.pcm", 1), + "warm": expect_tones(folder / "warm.pcm", 1), + } + elif scenario.startswith("recovery-"): + for name in ("before-reset", "after-reset"): + expect_screen(folder / f"{name}.png", "SETTINGS") + payload = saved_eeprom(folder, scenario_fixture[0], scenario_fixture[1].stat().st_size) + watts = 100 if scenario == "recovery-journal" else 200 + for address in (0x156, 0x256): + record = payload[address : address + 25] + if ( + record[24] != (1 + sum(record[:24])) & 255 + or int.from_bytes(record[14:16], "big") != watts + ): + raise AssertionError( + f"{scenario}: both recovered save mirrors must retain {watts} Watts" + ) + evidence["scenarios"][scenario] = { + "watts": watts, + "fixture_files_sha256": { + path.name: digest(path) for path in (folder / "fixture.store",) + }, + } + elif scenario in ("peer-transfer", "peer-transfer-compressed", "peer-play"): + report = (folder / "peer.txt").read_text() + if "complete=true error=None" not in report: + raise AssertionError(f"{scenario} failed:\n{report[:1500]}") + if "flash_erases_during_reply_window=0" not in report: + raise AssertionError("Flash erase occurred while a peer reply was pending") + payload = saved_eeprom(folder, scenario_fixture[0], scenario_fixture[1].stat().st_size) + if scenario.startswith("peer-transfer"): + if payload[0x8F00 : 0x8F00 + 10430] != course(): + raise AssertionError("DS put course differs after native commit/reset") + identity = (out / "fixture.eeprom").read_bytes()[0x83 : 0x83 + 40] + if payload[0xCC08:0xCC30] != identity: + raise AssertionError("DS trainer record did not survive native commit/reset") + if "received_frames=135" not in report or "watts=0064" not in report: + raise AssertionError( + "DS put did not complete course/record writes and PHC/distribution reads" + ) + else: + identity = b"PUBLIC OPTICAL WALKER".ljust(40, b"\0") + if payload[0xDC00 + 548 + 8 : 0xDC00 + 548 + 48] != identity: + raise AssertionError("Native peer completion did not persist peer history") + local = (folder / "fixture.eeprom").read_bytes() + for source, destination, length in ((0x91BE, 0xF400, 384), (0x993E, 0xF580, 320)): + expected = bytes(byte ^ 0x5A for byte in local[source : source + length]) + if payload[destination : destination + length] != expected: + raise AssertionError("Peer animation/name did not persist") + expect_screen(folder / "settings-after.png", "STICK SETTINGS") + evidence["scenarios"][scenario] = { + "result": "DS put/commit or native Walker peer exchange completes with retail baud, transmitter echo and instruction-cost timing; persisted state survives reset.", + "timing": "one-instruction quanta with opcode/branch/call/load-use costs; 300 us program and 20 ms sector erase busy periods; no analog/cache timing claim", + } + elif scenario == "peer": + report = (folder / "peer.txt").read_text() + if ( + "complete=true" not in report + or "error=none" not in report + or "watts=0064" not in report + ): + raise AssertionError(f"Functional DS exchange failed:\n{report}") + frames = optical_frames(folder / "peer.optical") + replies = [ + frame for frame in frames if len(frame) == 10 and frame[:2] == bytes((0x0E, 2)) + ] + if len(replies) != 1 or replies[0][8:] != bytes((0, 100)): + raise AssertionError( + "Status/read exchange must emit 100 Watts as 00 64 on the optical wire" + ) + expect_screen(folder / "settings-after.png", "STICK SETTINGS") + evidence["scenarios"][scenario] = { + "result": "Ideal optical DS status/read session emits big-endian Watts and returns control to Settings.", + "logical_frames": [frame.hex() for frame in frames], + "peer": report, + } + elif scenario == "peer-storage": + for name in ("written", "restored"): + report = (folder / f"{name}.txt").read_text() + if any( + field not in report + for field in ("complete=true", "error=none", "page_verified=true", "watts=0064") + ): + raise AssertionError(f"Optical page write/reboot/read failed:\n{report}") + frames = optical_frames(folder / "peer.optical") + replies = [ + frame[8:] for frame in frames if len(frame) == 136 and frame[:2] == bytes((0x0E, 2)) + ] + expected = bytes(index ^ 0x5A for index in range(128)) + if replies != [expected, expected]: + raise AssertionError("Optical page payload must survive a reset and second session") + expect_screen(folder / "settings-after.png", "STICK SETTINGS") + payload = saved_eeprom(folder, fixture[0], fixture[1].stat().st_size) + if payload[0xFF80:] != expected: + raise AssertionError( + "Acknowledged optical page is absent from the durable EEPROM checkpoint" + ) + evidence["scenarios"][scenario] = ( + "An optical EEPROM page write reads back immediately and after reboot; its durable checkpoint contains the same public pattern." + ) + (out / "results.json").write_text(json.dumps(evidence, indent=2) + "\n") print(f"{scenario}: PASS", flush=True) + evidence["complete"] = True (out / "results.json").write_text(json.dumps(evidence, indent=2) + "\n") print(f"Firmware E2E checks passed. Screens and signal recordings: {out}") diff --git a/stick/compat/machine.h b/stick/compat/machine.h index 454dd80..fabdbe4 100644 --- a/stick/compat/machine.h +++ b/stick/compat/machine.h @@ -1,11 +1,11 @@ #ifndef PW_STICK_MACHINE_H #define PW_STICK_MACHINE_H -/* H8 CPU intrinsics are no-ops here. board_runtime.cpp and power_sleep.cpp - * own scheduling, processor sleep and interrupt policy on the Stick. */ +#include "stick/native_runtime.h" + #define nop() ((void)0) -#define sleep() ((void)0) -#define set_ccr(value) ((void)(value)) -#define get_ccr() 0 +#define sleep() StickNativeService() +#define set_ccr(value) StickNativeSetCcr((uint8_t)(value)) +#define get_ccr() StickNativeGetCcr() #endif diff --git a/stick/control_settings.h b/stick/control_settings.h index 9510a47..5ff280d 100644 --- a/stick/control_settings.h +++ b/stick/control_settings.h @@ -4,7 +4,7 @@ namespace pw_stick { struct ControlSettings { - Layout layout = Layout::TwoKeyMR; + Layout layout = Layout::ThreeKeyLR; uint8_t orientation = 0; uint8_t chord_window = 0; }; @@ -19,13 +19,4 @@ constexpr ControlSettings decode_controls(uint8_t value) { uint8_t((value >> 3) & 3u)}; } -// Preserve the user's actual directions when migrating the old coupled layout. -constexpr ControlSettings migrate_controls(uint8_t value) { - const bool three = value & 1u; - const uint8_t orientation = (value >> 1) & 1u; - const uint8_t chord = (value >> 2) & 3u; - return {three ? (orientation ? Layout::ThreeKeyLR : Layout::ThreeKeyRL) - : (orientation ? Layout::TwoKeyRM : Layout::TwoKeyMR), - orientation, uint8_t(chord > 2 ? 0 : chord)}; -} } // namespace pw_stick diff --git a/stick/controls.cpp b/stick/controls.cpp index 965520b..7b7f537 100644 --- a/stick/controls.cpp +++ b/stick/controls.cpp @@ -5,6 +5,7 @@ namespace pw_stick { namespace { // Require sustained overlap so alternating direction taps do not emit Center. constexpr uint32_t kChordHoldMs = 20; +constexpr uint32_t kSettingsHoldMs = 1000; } void Controls::Debouncer::update(uint32_t now_ms, bool value) { @@ -16,15 +17,25 @@ void Controls::Debouncer::update(uint32_t now_ms, bool value) { } void Controls::emit(uint8_t value) { - if (value == desired_) return; - desired_ = value; - if (count_ < sizeof(queue_)) { - queue_[(head_ + count_) % sizeof(queue_)] = value; - ++count_; - ++emitted_; - } else { + if (faulted_ || value == desired_) return; + if (count_ && queue_[(head_ + count_ - 1) % kCapacity].value == 0 && value) { + const uint8_t preceding = count_ > 1 ? + queue_[(head_ + count_ - 2) % kCapacity].value : delivered_; + if (!(preceding & value)) { + --count_; + ++consumed_; + } + } + if (count_ == kCapacity) { ++overflow_; + require_release(); + faulted_ = true; + return; } + desired_ = value; + queue_[(head_ + count_) % kCapacity] = {now_ms_, value}; + ++count_; + ++emitted_; } uint8_t Controls::direction(bool main_button) const { @@ -45,28 +56,64 @@ void Controls::set_chord_window(uint16_t milliseconds) { void Controls::require_release() { gesture_ = Gesture::Suppressed; + cancelled_ += count_; head_ = count_ = 0; desired_ = delivered_ = 0; - menu_ = false; - power_was_down_ = power_.stable; + center_notified_ = false; wait_release_ = true; + settings_hold_tracking_ = settings_hold_requested_ = false; +} + +void Controls::expire(uint32_t now_ms) { + if (count_ && uint32_t(now_ms - queue_[head_].milliseconds) > + kMaximumPendingAgeMs) { + ++expired_; + require_release(); + faulted_ = true; + } } void Controls::sample(uint32_t now_ms, bool main_pressed, bool side_pressed, - bool power_pressed) { + bool power_pressed, bool captured, + bool power_press_latched) { + now_ms_ = now_ms; + expire(now_ms); + const uint8_t previous = stable_buttons(); main_.update(now_ms, main_pressed); side_.update(now_ms, side_pressed); power_.update(now_ms, power_pressed); + if (power_press_latched) { + power_.stable = power_.candidate = true; + power_.since_ms = now_ms; + } + pressed_ = stable_buttons() & ~previous; const bool m = main_.stable, r = side_.stable, l = power_.stable; + if (captured) { + settings_hold_tracking_ = false; + return; + } if (wait_release_) { - if (!m && !r && !l) { + if (!m && !r && !l && !main_pressed && !side_pressed && !power_pressed) { wait_release_ = false; + faulted_ = false; gesture_ = Gesture::Idle; } return; } if (three_key(layout_)) { + // Observe the physical L+R hold alongside normal gameplay delivery. + // Capture starts only after the caller opens the Settings overlay. + if (l && r && power_pressed && side_pressed) { + if (!settings_hold_tracking_) { + settings_hold_tracking_ = true; + settings_hold_since_ms_ = now_ms; + } else if (uint32_t(now_ms - settings_hold_since_ms_) >= kSettingsHoldMs) { + settings_hold_requested_ = true; + } + } else { + settings_hold_tracking_ = false; + } uint8_t value = m ? kCenter : 0; if (layout_ == Layout::ThreeKeyLR) value |= (l ? kLeft : 0) | (r ? kRight : 0); @@ -76,8 +123,6 @@ void Controls::sample(uint32_t now_ms, bool main_pressed, bool side_pressed, return; } - if (l && !power_was_down_) menu_ = true; - power_was_down_ = l; switch (gesture_) { case Gesture::Idle: if (m && !r) { gesture_ = Gesture::PendingMain; first_down_ms_ = now_ms; } @@ -155,32 +200,56 @@ void Controls::sample(uint32_t now_ms, bool main_pressed, bool side_pressed, } } -uint8_t Controls::next_scan() { +uint8_t Controls::next_scan(uint32_t now_ms) { + expire(now_ms); if (count_) { - delivered_ = queue_[head_]; - head_ = (head_ + 1) % sizeof(queue_); + delivered_ = queue_[head_].value; + head_ = (head_ + 1) % kCapacity; --count_; ++consumed_; } else { delivered_ = desired_; } + if (!(delivered_ & kCenter)) center_notified_ = false; return delivered_; } -bool Controls::idle() const { return !main_.stable && !side_.stable && !power_.stable && !count_; } +bool Controls::idle() const { + return !stable_buttons() && !main_.candidate && !side_.candidate && + !power_.candidate && !count_; +} + +uint8_t Controls::stable_buttons() const { + return uint8_t((main_.stable ? 1u : 0u) | (side_.stable ? 2u : 0u) | + (power_.stable ? 4u : 0u)); +} + +uint8_t Controls::pressed_buttons() const { return pressed_; } + +bool Controls::take_settings_hold() { + const bool requested = settings_hold_requested_; + settings_hold_requested_ = false; + return requested; +} Controls::Diagnostic Controls::diagnostic() const { return {uint8_t(gesture_), - uint8_t((main_.stable ? 1u : 0u) | (side_.stable ? 2u : 0u) | - (power_.stable ? 4u : 0u)), + stable_buttons(), desired_, delivered_, count_, uint8_t(wait_release_), - emitted_, consumed_, overflow_}; + emitted_, consumed_, overflow_, expired_, cancelled_}; } -bool Controls::menu_requested() { - bool requested = menu_; - menu_ = false; - return requested; +bool Controls::take_center_press() { + return take_center_press(now_ms_); +} + +bool Controls::take_center_press(uint32_t now_ms) { + expire(now_ms); + const uint8_t available = count_ ? queue_[head_].value : desired_; + if (!(available & kCenter) || (delivered_ & kCenter) || center_notified_) + return false; + center_notified_ = true; + return true; } } // namespace pw_stick diff --git a/stick/controls.h b/stick/controls.h index b7ce977..1c3aa1d 100644 --- a/stick/controls.h +++ b/stick/controls.h @@ -24,21 +24,26 @@ constexpr const char *layout_name(Layout layout) { return "2-key M/R"; } -// Converts physical M/R/L levels into native Pokewalker button levels. Each -// queued transition is held for one native input scan, including short taps. class Controls { public: + static constexpr uint32_t kMaximumPendingAgeMs = 250; + static constexpr uint8_t kCapacity = 32; struct Diagnostic { uint8_t gesture, stable, desired, delivered, queued, wait_release; - uint32_t emitted, consumed, overflow; + uint32_t emitted, consumed, overflow, expired, cancelled; }; void configure(Layout layout); void set_chord_window(uint16_t milliseconds); void sample(uint32_t now_ms, bool main_pressed, bool side_pressed, - bool power_pressed); - uint8_t next_scan(); + bool power_pressed, bool captured = false, + bool power_press_latched = false); + uint8_t next_scan(uint32_t now_ms); bool idle() const; - bool menu_requested(); + uint8_t stable_buttons() const; + uint8_t pressed_buttons() const; + bool take_center_press(); + bool take_center_press(uint32_t now_ms); + bool take_settings_hold(); void require_release(); Diagnostic diagnostic() const; @@ -54,6 +59,7 @@ class Controls { void update(uint32_t now_ms, bool value); }; void emit(uint8_t value); + void expire(uint32_t now_ms); uint8_t direction(bool main_button) const; Layout layout_ = Layout::TwoKeyMR; @@ -65,13 +71,23 @@ class Controls { uint8_t chord_first_ = 0; uint8_t desired_ = 0; uint8_t delivered_ = 0; - uint8_t queue_[32] = {}; + struct Transition { + uint32_t milliseconds; + uint8_t value; + }; + Transition queue_[kCapacity] = {}; uint8_t head_ = 0; uint8_t count_ = 0; - bool menu_ = false; - bool power_was_down_ = false; + uint32_t now_ms_ = 0; + uint8_t pressed_ = 0; + bool center_notified_ = false; + bool faulted_ = false; bool wait_release_ = true; + bool settings_hold_tracking_ = false; + bool settings_hold_requested_ = false; + uint32_t settings_hold_since_ms_ = 0; uint32_t emitted_ = 0, consumed_ = 0, overflow_ = 0; + uint32_t expired_ = 0, cancelled_ = 0; }; } // namespace pw_stick diff --git a/stick/display_bus.cpp b/stick/display_bus.cpp index 644d5e1..1ee6cbb 100644 --- a/stick/display_bus.cpp +++ b/stick/display_bus.cpp @@ -1,4 +1,5 @@ #include "display_bus.h" +#include "native_runtime.h" #include @@ -19,10 +20,13 @@ uint8_t start_line = 0; bool start_line_parameter = false; bool contrast_parameter = false; bool screen_enabled = true; +uint64_t revision = 0; void command(uint8_t value) { if (start_line_parameter) { - start_line = value & 0x7f; + const uint8_t next = value & 0x7f; + if (start_line != next) ++revision; + start_line = next; start_line_parameter = false; return; } @@ -44,14 +48,28 @@ void command(uint8_t value) { column = uint8_t((column & 0xf0) | value); plane = 0; } else if (value == 0xa9) { + if (screen_enabled) ++revision; screen_enabled = false; } else if (value == 0xe1 || value == 0xaf) { + if (!screen_enabled) ++revision; screen_enabled = true; } } void data(uint8_t value) { - if (page < kPages && column < kColumns) ram[page][column][plane] = value; + if (page < kPages && column < kColumns) { + const uint8_t changed = ram[page][column][plane] ^ value; + ram[page][column][plane] = value; + if (screen_enabled && changed) { + for (unsigned bit = 0; bit < 8; ++bit) { + if ((changed & (1u << bit)) && + ((page * 8u + bit - start_line) & 127u) < 64) { + ++revision; + break; + } + } + } + } plane ^= 1; if (!plane) ++column; } @@ -67,11 +85,21 @@ extern "C" void StickDisplayBusInit(void) { page = column = plane = start_line = 0; start_line_parameter = contrast_parameter = false; screen_enabled = true; + ++revision; } extern "C" int StickDisplayIsPowered(void) { return screen_enabled; } +extern "C" uint64_t StickDisplayRevision(void) { return revision; } + +extern "C" void StickDisplayCapture(StickDisplaySnapshot *snapshot) { + if (!snapshot) return; + snapshot->revision = revision; + snapshot->powered = screen_enabled; + StickDisplayFrame(snapshot->pixels, sizeof(snapshot->pixels)); +} extern "C" void StickDisplayWrite(u8 value) { + StickNativeService(); if (IO.PDR1.BIT.B1) data(value); else command(value); } diff --git a/stick/display_bus.h b/stick/display_bus.h index 4234f48..2658566 100644 --- a/stick/display_bus.h +++ b/stick/display_bus.h @@ -2,6 +2,7 @@ #define PW_STICK_DISPLAY_BUS_H #include "types.h" +#include #ifdef __cplusplus extern "C" { @@ -24,6 +25,13 @@ extern StickDisplaySsu SSU; void StickDisplayWrite(u8 value); void StickDisplayBusInit(void); int StickDisplayIsPowered(void); +typedef struct { + uint64_t revision; + u8 powered; + u8 pixels[96 * 64]; +} StickDisplaySnapshot; +uint64_t StickDisplayRevision(void); +void StickDisplayCapture(StickDisplaySnapshot *snapshot); /* Read the panel's selected 96x64 view as four-shade palette indexes. */ void StickDisplayFrame(u8 *pixels, u16 byteCount); diff --git a/stick/display_panel.cpp b/stick/display_panel.cpp index 7ce8f4c..02e4754 100644 --- a/stick/display_panel.cpp +++ b/stick/display_panel.cpp @@ -19,21 +19,21 @@ constexpr unsigned kNativeWidth = 96; constexpr unsigned kNativeHeight = 64; constexpr unsigned kScale = 2; constexpr unsigned kPanelWidth = kNativeWidth * kScale; -constexpr unsigned kPanelHeight = kNativeHeight * kScale; +constexpr unsigned kTransferRows = 8; bool dark_appearance = true; -uint8_t native_pixels[kNativeWidth * kNativeHeight]; uint8_t presented_pixels[kNativeWidth * kNativeHeight]; uint16_t *panel_pixels = nullptr; bool backlight_on = true; uint8_t backlight_level = 68; bool panel_awake = true; -bool image_valid = false; +uint64_t valid_rows = 0; +bool presentation_pending = true; unsigned panel_generation = 0; unsigned orientation = 3; -int64_t can_sleep_us = 0; unsigned wake_stage = 0; int64_t wake_deadline_us = 0; +int64_t wake_retry_us = 0; int64_t prewarm_until_us = 0; bool reset_required = false; @@ -56,11 +56,11 @@ extern "C" int StickDisplayPanelInit(void) { screen->fillScreen(StickDisplayBackground()); panel_generation = StickPeripheralGeneration(); panel_awake = true; - image_valid = false; + StickDisplayInvalidate(); StickDisplayBusInit(); if (!panel_pixels) { panel_pixels = static_cast(heap_caps_malloc( - sizeof(uint16_t) * kPanelWidth * kPanelHeight, + sizeof(uint16_t) * kPanelWidth * kTransferRows * kScale, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT)); } return panel_pixels != nullptr; @@ -74,7 +74,7 @@ extern "C" void StickDisplayPanelSetOrientation(unsigned right_side_down) { screen->setRotation(orientation); screen->fillScreen(StickDisplayBackground()); } - image_valid = false; + StickDisplayInvalidate(); } extern "C" void StickDisplayPanelSetBacklight(unsigned enabled) { @@ -96,12 +96,15 @@ extern "C" void StickDisplayPrepareWake(void) { if (!screen) return; prewarm_until_us = esp_timer_get_time() + 750000; if (panel_awake || wake_stage) return; + if (esp_timer_get_time() < wake_retry_us) return; if (!StickPeripheralAcquire(StickPeripheral::Display)) { // Acquire records the supply request before writing the PMIC. Release // that request if panel preparation fails. StickPeripheralRelease(StickPeripheral::Display); + wake_retry_us = esp_timer_get_time() + 1000000; return; } + wake_retry_us = 0; if (reset_required || panel_generation != StickPeripheralGeneration()) { reset_required = true; StickBoardDisplayReset(false); @@ -109,7 +112,13 @@ extern "C" void StickDisplayPrepareWake(void) { wake_deadline_us = esp_timer_get_time() + 8000; } else { screen->getPanel()->initBus(); - StickBacklightInit(0); + if (!StickBacklightInit(0)) { + screen->getPanel()->releaseBus(); + StickBacklightSuspend(); + StickPeripheralRelease(StickPeripheral::Display); + wake_retry_us = esp_timer_get_time() + 1000000; + return; + } screen->wakeup(); wake_stage = 3; wake_deadline_us = esp_timer_get_time() + 130000; @@ -119,6 +128,7 @@ extern "C" void StickDisplayPrepareWake(void) { extern "C" void StickDisplayCancelPreparedWake(void) { if (backlight_on) return; prewarm_until_us = 0; + wake_retry_us = 0; if (wake_stage == 1 || wake_stage == 2) { // Reset preparation has not issued Sleep-out. Abort now, and remember // that reset lost controller state even if audio keeps the rail powered. @@ -140,6 +150,11 @@ extern "C" void StickDisplayPowerService(void) { auto *screen = StickBoardScreen(); if (!screen) return; const int64_t now = esp_timer_get_time(); + if (!panel_awake && !wake_stage && wake_retry_us && + now >= wake_retry_us) { + if (backlight_on || now < prewarm_until_us) StickDisplayPrepareWake(); + else wake_retry_us = 0; + } if (wake_stage && now >= wake_deadline_us) { if (wake_stage == 1) { StickBoardDisplayReset(true); @@ -148,13 +163,16 @@ extern "C" void StickDisplayPowerService(void) { } else if (wake_stage == 2) { if (!StickBoardDisplayInitRegisters()) { wake_stage = 0; + screen->getPanel()->releaseBus(); + StickBacklightSuspend(); StickPeripheralRelease(StickPeripheral::Display); + wake_retry_us = esp_timer_get_time() + 1000000; return; } screen->wakeup(); wake_stage = 3; wake_deadline_us = esp_timer_get_time() + 130000; - } else { + } else if (wake_stage == 3) { if (!backlight_on && now >= prewarm_until_us) { wake_stage = 0; panel_awake = true; // Sleep-out completed; Sleep-in is now legal. @@ -165,30 +183,34 @@ extern "C" void StickDisplayPowerService(void) { screen->writeCommand(0x38); // IDMOFF screen->writeCommand(0x29); // DISPON screen->endWrite(); + screen->setRotation(orientation); if (reset_required || panel_generation != StickPeripheralGeneration()) { screen->setSwapBytes(true); screen->invertDisplay(screen->getPanel()->getInvert()); screen->setColorDepth(16); - screen->setRotation(orientation); screen->fillScreen(StickDisplayBackground()); panel_generation = StickPeripheralGeneration(); reset_required = false; - image_valid = false; + StickDisplayInvalidate(); } wake_stage = 0; panel_awake = true; screen->setBrightness(backlight_on ? backlight_level : 0); } + } else { + screen->getPanel()->releaseBus(); + StickBacklightSuspend(); + wake_stage = 0; + StickPeripheralRelease(StickPeripheral::Display); + if (backlight_on || now < prewarm_until_us) StickDisplayPrepareWake(); } } if (!wake_stage && !backlight_on && panel_awake && - now >= can_sleep_us && now >= prewarm_until_us) { + now >= prewarm_until_us) { screen->sleep(); - delay(5); - screen->getPanel()->releaseBus(); - StickBacklightSuspend(); panel_awake = false; - StickPeripheralRelease(StickPeripheral::Display); + wake_stage = 4; + wake_deadline_us = esp_timer_get_time() + 5000; } } @@ -196,15 +218,20 @@ extern "C" int StickDisplayPanelIsReady(void) { return panel_awake; } extern "C" uint64_t StickDisplayNextDeadline(void) { if (wake_stage) return uint64_t(wake_deadline_us); - if (!backlight_on && panel_awake) { - const int64_t deadline = can_sleep_us > prewarm_until_us ? - can_sleep_us : prewarm_until_us; - return uint64_t(deadline); - } + if (!backlight_on && panel_awake) return uint64_t(prewarm_until_us); + if (wake_retry_us && (backlight_on || + esp_timer_get_time() < prewarm_until_us)) return uint64_t(wake_retry_us); return UINT64_MAX; } -extern "C" void StickDisplayInvalidate(void) { image_valid = false; } +extern "C" void StickDisplayInvalidate(void) { + valid_rows = 0; + presentation_pending = true; +} + +extern "C" int StickDisplayPresentationPending(void) { + return presentation_pending; +} extern "C" unsigned StickDisplayIsDark(void) { return dark_appearance; } extern "C" uint16_t StickDisplayBackground(void) { @@ -222,7 +249,7 @@ extern "C" int StickDisplaySetDark(unsigned dark) { preferences.end(); if (written != 1) return 0; dark_appearance = dark; - image_valid = false; + StickDisplayInvalidate(); if (panel_awake) StickBoardScreen()->fillScreen(StickDisplayBackground()); return 1; } @@ -232,29 +259,38 @@ extern "C" void StickDisplayPanelSetContrastDelta(unsigned delta) { if (delta > 9) delta = 9; // Map the game's Contrast setting to backlight brightness. backlight_level = uint8_t(24 + 11 * delta); - if (screen && backlight_on) screen->setBrightness(backlight_level); + if (screen && backlight_on && panel_awake) + screen->setBrightness(backlight_level); } -extern "C" int StickDisplayPresent(void) { - if (!panel_pixels) return 0; - if (!StickDisplayIsPowered()) { +extern "C" int StickDisplayPresent(const StickDisplaySnapshot *snapshot) { + if (!panel_pixels || !snapshot) return 0; + if (!snapshot->powered) { StickDisplayPanelSetBacklight(0); + presentation_pending = false; return 1; } StickDisplayPanelSetBacklight(1); + presentation_pending = true; if (!panel_awake) return 0; - StickDisplayFrame(native_pixels, sizeof(native_pixels)); + const uint8_t *native_pixels = snapshot->pixels; unsigned x0 = kNativeWidth, y0 = kNativeHeight, x1 = 0, y1 = 0; for (unsigned y = 0; y < kNativeHeight; ++y) for (unsigned x = 0; x < kNativeWidth; ++x) - if (!image_valid || native_pixels[y * kNativeWidth + x] != - presented_pixels[y * kNativeWidth + x]) { + if (!(valid_rows & (UINT64_C(1) << y)) || + native_pixels[y * kNativeWidth + x] != + presented_pixels[y * kNativeWidth + x]) { if (x < x0) x0 = x; if (y < y0) y0 = y; if (x + 1 > x1) x1 = x + 1; if (y + 1 > y1) y1 = y + 1; } - if (x0 == kNativeWidth) return 1; + if (x0 == kNativeWidth) { + presentation_pending = false; + return 1; + } + const unsigned end = y1; + if (y1 > y0 + kTransferRows) y1 = y0 + kTransferRows; const unsigned width = (x1 - x0) * kScale; for (unsigned y = y0; y < y1; ++y) { for (unsigned x = x0; x < x1; ++x) { @@ -267,7 +303,11 @@ extern "C" int StickDisplayPresent(void) { } StickBoardScreen()->pushImage(24 + x0 * kScale, 3 + y0 * kScale, width, (y1 - y0) * kScale, panel_pixels); - std::memcpy(presented_pixels, native_pixels, sizeof(native_pixels)); - image_valid = true; - return 1; + for (unsigned y = y0; y < y1; ++y) { + std::memcpy(presented_pixels + y * kNativeWidth + x0, + native_pixels + y * kNativeWidth + x0, x1 - x0); + valid_rows |= UINT64_C(1) << y; + } + presentation_pending = y1 < end; + return !presentation_pending; } diff --git a/stick/display_panel.h b/stick/display_panel.h index 3af8f94..eac319e 100644 --- a/stick/display_panel.h +++ b/stick/display_panel.h @@ -2,6 +2,7 @@ #define PW_STICK_DISPLAY_PANEL_H #include +#include "display_bus.h" #ifdef __cplusplus extern "C" { @@ -13,8 +14,8 @@ int StickDisplayPanelInit(void); void StickDisplayPanelSetOrientation(unsigned right_side_down); void StickDisplayPanelSetBacklight(unsigned enabled); void StickDisplayPanelSetContrastDelta(unsigned delta); -/* False until asynchronous physical wake is ready; keep the image pending. */ -int StickDisplayPresent(void); +int StickDisplayPresent(const StickDisplaySnapshot *snapshot); +int StickDisplayPresentationPending(void); void StickDisplayPowerService(void); void StickDisplayPrepareWake(void); /* Cancel pending wake preparation when the user releases M early. */ diff --git a/stick/docs/controls.md b/stick/docs/controls.md index 9952cd1..e58c4ef 100644 --- a/stick/docs/controls.md +++ b/stick/docs/controls.md @@ -1,25 +1,30 @@ # Controls and settings Use the Stick's buttons to navigate the Pokéwalker screens. The default layout -uses M for Left and R for Right. For Center, press M and R together. +uses L for Left, M for Center and R for Right, with the screen left-side-down. -If Center is hard to trigger, increase the M+R window in Stick Settings. This +In a two-key layout, if Center is hard to trigger, increase the M+R window in Stick Settings. This gives you more time to press the second button. Keep both pressed together for at least 20 ms. | Layout | Left | Center | Right | Open Stick Settings | | --- | --- | --- | --- | --- | -| 2-key M/R, default | M | M+R | R | L | +| 2-key M/R | M | M+R | R | L | | 2-key R/M | R | M+R | M | L | -| 3-key L/R | L | M | R | Hold L for 1.2 s | -| 3-key R/L | R | M | L | Hold L for 1.2 s | +| 3-key L/R, default | L | M | R | Hold L+R for one second | +| 3-key R/L | R | M | L | Hold L+R for one second | ## Wake the screen -With the screen off, hold M for half a second in any layout or rotation. +With the screen off, hold M until the screen wakes, roughly half a second in any layout or rotation. Release all buttons before navigating. L and R work again once the screen is awake. +Wake uses native Center-hold qualification and its social-offer opportunity, +rather than a separate wall-clock shortcut. An abandoned hold cancels +preparation. IR entry also consumes its gesture through release, preventing +held input from being replayed into the result screen. + ## Change settings In Stick Settings, M selects the next row, R changes its value, and L closes @@ -34,5 +39,15 @@ Your layout, rotation and appearance are remembered across restarts, separately from your Pokémon and game progress. Settings also shows battery voltage and an estimated charge percentage. +In three-key layouts, hold physical L and R together for one second to open +Stick Settings. Gameplay receives both directions while the hold is pending. +Release all buttons after the overlay opens before using its controls. Native +Volume and Contrast retain their original two-choice selector. Native time and +background work continue while Stick Settings captures buttons. + +Rapid taps are buffered, but stale input is not played into later screens. If +pending transitions exceed 250 ms or fill the bounded queue, the gesture is +canceled until release. Diagnostic builds report these cancellations. + Unplug USB to carry the Stick. It sleeps between game tasks while continuing to track walking; hold M for half a second when you want to check your Pokémon. diff --git a/stick/docs/device-settings-and-display.md b/stick/docs/device-settings-and-display.md index 385cab2..0c8e472 100644 --- a/stick/docs/device-settings-and-display.md +++ b/stick/docs/device-settings-and-display.md @@ -5,12 +5,18 @@ covers persistence and pixel presentation for changes to the adapters. ## Persist settings -`pw-controls/layout-v2` stores layout, rotation and the two-key window. Migration -maps each legacy configuration to its directional behavior, including the -reversed three-key mapping. The display adapter applies rotation. Button mapping -uses the selected layout. +`pw-controls/layout-v2` stores layout, rotation and the two-key window. +The display adapter applies rotation. Button mapping uses the selected layout. +Older configuration keys are ignored. +Without a saved setting, the layout is three-key L/M/R and rotation is left-side-down. Game progress is stored in the EEPROM image. +Three-key Settings access observes a one-second physical L+R hold in `Controls`; +normal directional transitions continue until recognition. The input bridge +then requests the overlay, clears pending Center activity and requires release +at entry and closure. The native Settings renderer keeps its two original +choices. + `pw-display/dark` stores appearance. Unsaved installations default to Dark. A failed persistence write leaves the previous appearance active. A successful change repaints the overlay immediately and invalidates the game image so it @@ -43,7 +49,12 @@ to backlight brightness. ## Check changes -`uv run python stick/check.py` covers layout migration, input gestures, palette -ordering/reversal, persistence failure and full repaint after the overlay. +The presenter consumes an immutable native LCD snapshot and revision. A call +transfers at most eight native rows and finishes with SPI idle, retaining no +caller pointer. Remaining strips are scheduled independently of native refresh; +appearance, rotation and overlay changes also invalidate presentation. + +`uv run python stick/check.py` exercises production input gestures, layout and +appearance persistence, and returning from Settings to the native screen. After a display change, check the four shades and Settings on the board in both appearances and after wake. diff --git a/stick/docs/history/audio.md b/stick/docs/history/audio.md index bb2850a..743a9be 100644 --- a/stick/docs/history/audio.md +++ b/stick/docs/history/audio.md @@ -4,6 +4,11 @@ These tests measured score timing and acoustic output. Raw artifacts remain in `stick/.build/sound-scheduling-2026-09-26/` and `stick/.build/codec-startup-2026-09-27/`. +These are historical results, not qualification of the current +[native-platform redesign](../native-platform.md). It removes score reanchoring +and the fixed cold-start delay, preparing physical output separately from native +score timing. Its acoustic onset and settling need fresh device measurements. + ## Score scheduling The native score walker and Stick adapter initially failed 102 of 176 host diff --git a/stick/docs/history/ir-readiness-regression.md b/stick/docs/history/ir-readiness-regression.md new file mode 100644 index 0000000..0943eb7 --- /dev/null +++ b/stick/docs/history/ir-readiness-regression.md @@ -0,0 +1,146 @@ +# IR readiness and DS reply regression + +PR #5 moved receive handoff and durable EEPROM work into the compiled Stick +port. The user subsequently reported both immediate DS connection failures and +failures after the transfer sound began. Hardware was unavailable for this +investigation. + +The references are the matching [Pokéwalker firmware](https://github.com/lumirth/pw/tree/6dc7bc09950078fa3fe0dffa4dae34e9549a99da) +and the user's HGSS `src/phc/irc.c`, `phc_lib.c` and PHC record definitions. +The pre-PR comparison is `b3ef7a2`; its existing Watts byte-order defect was +accepted only by the diagnostic comparison peer, so it could exercise the +same acquisition and transfer sequence. + +## Reproduction + +The original ideal, short status/read exchange passed. A longer synthetic +uncompressed course transfer at the captured retail-card baud, with local +transmitter echo and separate CPU instruction-cost scheduling, failed on the +PR firmware. The pre-PR firmware completed the same exchange. One retained +failure changed a data bit in a 136-byte page, causing the native checksum to +reject it and the DS peer to reach its unchanged 100 ms timeout. + +The course-only comparisons used the same 10,430-byte public payload +(`b5dcc0c82d28b8f9270aa4add67523011952a0c5aeb63253126188ee5574afa8`), +115760 baud, 5 ms peer turnaround and live local transmitter echo: + +| Firmware | Result | +| --- | --- | +| Pre-PR `b3ef7a2` | All 82 course pages complete; legacy Watts accepted only for comparison | +| PR's original IR implementation at `68ae1fe` | Corrupted page; timeout after eight native transmissions | +| Sampling/handoff repair alone | Valid pages, then a late reply after 27 native transmissions | +| Sampling, bounded readback and exact decoder optimization | All 82 course pages complete | + +The producer had acquired additional request/ack fences and handoff bookkeeping +inside its timing-sensitive gate function. The worker now stops at a complete +burst and performs notification and release waiting outside that function. +Readiness observes fresh progress in the existing published sample counter; +it still waits for physical samples before initial transmission and replies. +Receive-to-reply storage remains protected by a consumed-event ticket. + +After removing the acquisition failure, the broader payload exposed late +replies during journal growth. The write path retains checked open, size, write, +sync and close operations. Its independent readback uses a fresh POSIX +descriptor to read only the appended header and payload, avoiding buffered +seek/read prefix work. Reader closure is checked too. + +The decoder's complete candidate set, framing-first/phase-second ranking, +original tie order, baud range, phase range, strong/weak thresholds and recovery +rules are retained. It visits likely baud candidates first and rejects a +candidate only when its already-observed framing errors and nonnegative phase +sum prove it cannot win. Winning data-cell occupancy is built once. A +sanitizer-backed comparison against the original decoder matched all output +bytes, geometry, cached-baud behavior and recovery diagnostics on 2,048 +generated waveforms. This creates reply-time headroom without accepting a +packet the previous search would reject. + +## Permanent coverage + +`peer-transfer` follows HGSS's uncompressed put choreography with public data: +status/Watts, 82 course pages, five trainer-record pages, PHC and distribution +reads, the checksum/ping exchange, commit and one-way shutdown. It checks the +native committed course and record independently after reset. + +`peer-play` uses a synthetic eligible Walker and exchanges status, animation, +name, trainer-house records, peer information and start. It checks both bulk +transfer directions and the received resources and native peer-history entry +after reset. This exercises one handshake role, not all collision/backoff cases. + +Run the exact production build through: + +```sh +uv run python stick/check.py --scenario peer-transfer --scenario peer-play +``` + +These workflows execute the ROM, bootloader, FreeRTOS, SDK and application and +use optical/button/bus inputs. They do not inject native game state. Their +instruction-cost mode schedules the CPUs independently in one-instruction +quanta using the simulator's opcode, branch, call and load-use costs. It is a +software timing regression test; analog acquisition, cache contention, flash +part latency and retail hardware interoperability remain outside its model. +The frozen machine-byte timing reference remains a separate diagnostic. + +## Initial software candidate (`22769ce`) + +All 13 production workflows pass on application +`05ed911a060fca5c3e023a7994e351ed553974e68789a5caff5e6283f31730e5`. +The full put completes 135 native transmissions, then independently verified +course/record persistence after reset. Its maximum modeled DS send-to-complete- +response interval is 91.99 ms against the unchanged 100 ms deadline. The same +exchange also completes on the fresh bench build. Walker resources and native +peer history survive reset. + +Both builds' input and output hashes match their manifests. Static checks pass, +and Renesas 6.02.01 and 6.02.02 each reproduce the canonical 49,152-byte image +(`f9e210a3b74afbbd12c5a66a51cc05cb9fbac986805ff0a3bfb4be6074d15607`). +The frozen machine-byte comparison differs for all three tracked functions; +its reference has not been updated. These new images were not flashed. + +## Retail put failure and flash busy timing + +The initial local-artwork candidate was installed and app/non-app flash verified. +The user then reported successful return from stroll, failed start of stroll, and +occasional black screens. A single subsequent diagnostic attempt decoded all five +incoming frames with no checksum or malformed-page rejection. They were response, +status, put request, and compressed course pages at `0xD700` and `0xD780`. +The last acknowledgment began 108.039 ms after the peer's final pulse; the journal +append maximum was 98.022 ms. The earlier production capture retained a 103.471 ms +append. No crash was captured; the user could not reproduce that symptom. + +HGSS `compress`/`sendData` independently compress 128-byte course and trainer pages +using Nintendo LZ10, falling back to raw pages when compression does not help. +The last partial page is sent in a padded 128-byte form. The original full-put +workflow covered only uncompressed writes and therefore omitted this path. + +The SPI flash model also completed programming and erases instantly. Optional WIP +busy periods now exercise the real SDK's polling: 300 microseconds per program +command and 20 milliseconds per sector erase are explicit stress parameters, not +claimed measurements of the installed flash chip. With those periods and the +compressed put sequence, the installed filesystem-backed implementation fails +unattended: a 116.078 ms append produces a 132.544 ms acknowledgment and the peer's +unchanged 100 ms timeout. + +The backend now uses a single flash-record format in the existing game-data +partition. Two checkpoints cover a bounded log. Headers and bodies are programmed +and independently read back before a final publication word. Log space is erased +before acquisition; append operations cannot erase during an IR session. Checkpoint +replacement and journal clearing run after publication outside that session. +The filesystem and old save/configuration conversions are removed. + +The first raw-log build completes the full uncompressed put and Walker exchange +with the same flash-busy profile; its maximum modeled DS response is 39.04 ms. +Recovery checks cover current records, stale records and an unpublished truncated +append. Permanent compressed-put coverage and a bus-observed zero-erase invariant +protect the timing window. Unsupported filesystem data and a corrupt published +checkpoint are rejected without conversion or erasure. + +This storage format requires initializing the game-data region. Return the Pokémon +to HGSS before pre-release updates; see the [install guide](../install.md). + +The final public and local-artwork production images each pass all 14 workflows, +including compressed put and the zero-erase reply-window check. Public application +SHA-256: `a8aa2659834cfed7deeb800a95bca5a57ff360ee415897d78377dd4aaa01591b`. +Local application SHA-256: `a5b890cad28ec7f800d98944243e12cb35c987aad983046717734faff57c7ab4`. +Independent negative boots also reject unsupported data, a corrupt published +checkpoint, and a corrupt newer checkpoint even when an older valid save exists; +all leave the data partition unchanged. Source/output hashes and static checks pass. diff --git a/stick/docs/images/three-key-settings.png b/stick/docs/images/three-key-settings.png new file mode 100644 index 0000000..2590f0d Binary files /dev/null and b/stick/docs/images/three-key-settings.png differ diff --git a/stick/docs/install.md b/stick/docs/install.md index 270e95a..8a662ed 100644 --- a/stick/docs/install.md +++ b/stick/docs/install.md @@ -30,36 +30,42 @@ settings. Check that it is exactly 8,388,608 bytes before proceeding. If the board won't connect, enter its USB download mode using M5Stack's [StickS3 instructions](https://docs.m5stack.com/en/core/StickS3). -## Update an existing Stickwalker installation +## Update a pre-release installation -An app-only update keeps your Pokémon, registration, steps, Watts, items and -settings. The October 10 update also repairs missing device IDs on first boot. +Return your Pokémon to HGSS before updating. Pre-release builds do not promise +save compatibility. Keep the flash backup, then initialize the game-data region +for the new firmware. This resets registration, steps, Watts, items and other +walker progress; the Pokémon is already safe in your game. Device settings in +NVS and resident artwork are separate from this region. -Check that the installed partition layout matches the release before updating: +Check that the installed partition layout matches the new build: ```sh uvx --from esptool==5.1.0 esptool --chip esp32s3 --port PORT read-flash 0x8000 0xC00 /path/to/installed-partitions.bin ``` -Compare those bytes with `PwStick.ino.partitions.bin`, for example with `cmp` -on macOS/Linux or `fc /b` on Windows. Proceed with the app-only -update only if they match and the existing installation runs from app0: +Compare those bytes with `PwStick.ino.partitions.bin`, using `cmp` on macOS/Linux +or `fc /b` on Windows. For the matching app0 layout used by this guide: ```sh +uvx --from esptool==5.1.0 esptool --chip esp32s3 --port PORT --after no-reset erase-region 0x670000 0x180000 uvx --from esptool==5.1.0 esptool --chip esp32s3 --port PORT write-flash 0x10000 PwStick.ino.bin uvx --from esptool==5.1.0 esptool --chip esp32s3 --port PORT verify-flash 0x10000 PwStick.ino.bin ``` -Use these esptool commands for updates. Arduino's Upload command also rewrites -the bootloader, partition table and boot selection. If the board previously ran -OTA firmware from app1, inspect its boot selection before using the app0 address. -Stickwalker installations made with this guide use app0. +The backend supports only the current flash-record format. It starts a new +walker on blank storage; unsupported or damaged storage reports a fault rather +than converting an old save. After installation, connect HGSS to start a stroll. + +Use these commands for updates. Arduino's Upload command also rewrites the +bootloader, partition table and boot selection. If the installed layout differs +or app1 is active, inspect that installation before choosing an application +address. These instructions use app0 at `0x10000`. ## First installation After making a backup, initialize the region where Stickwalker will store its -game data, then install the firmware. This starts a new walker; use the app-only -update above to keep an existing Stickwalker save. +game data, then install the firmware. This starts a new walker. ```sh uvx --from esptool==5.1.0 esptool --chip esp32s3 --port PORT erase-region 0x670000 0x180000 @@ -72,13 +78,14 @@ uvx --from esptool==5.1.0 esptool --chip esp32s3 --port PORT write-flash 0x0 PwS ``` The selected layout has app0 at `0x10000` (size `0x330000`), app1 at `0x340000`, -LittleFS at `0x670000` (size `0x180000`), and coredump at `0x7F0000`. NVS starts -at `0x9000`. On first boot, Stickwalker creates its filesystem in the erased -game-data region. - -After boot, check the display and controls, open Settings and test sound. For an -update, confirm the previous registration, Pokémon and settings remain. Unplug -USB, let the screen go dark, and verify a 500 ms M hold wakes it. You can now +game-data storage at `0x670000` (size `0x180000`), and coredump at `0x7F0000`. +NVS starts at `0x9000`. On first boot, Stickwalker publishes its initial +checkpoint in the erased game-data region. + +After boot, check the display and controls, open Settings and test sound. +Confirm the device settings, then connect HGSS to register the new walker. +Unplug USB, let the screen go dark, and hold M until it wakes, roughly half a +second. You can now [connect to your game and take a Pokémon for a walk](playing.md). The [controls guide](controls.md) covers button layouts and device settings. diff --git a/stick/docs/native-platform.md b/stick/docs/native-platform.md new file mode 100644 index 0000000..5ef3a9c --- /dev/null +++ b/stick/docs/native-platform.md @@ -0,0 +1,142 @@ +# Native execution and Stick services + +The behavioral reference is the [matching Pokéwalker reconstruction](https://github.com/lumirth/pw/tree/6dc7bc09950078fa3fe0dffa4dae34e9549a99da). +Both supported H8 compiler suites must continue producing the reference image. +Stick-specific branches adapt hardware and interaction; they do not replace +native game rules, rendering, score interpretation, or motion estimation. +DS-side byte order and completion behavior were also checked against locally +supplied HGSS source. That source and its artwork are not distributed here. + +## Execution ownership + +`native_runtime.cpp` owns Timer B1, Timer W, RTC events, Center IRQ, interrupt +masks, and foreground wake eligibility. Timer W deadlines retain 32768 Hz +watch-clock precision. The native score interpreter runs from compare events, +not the physical speaker service or the former fixed 8 ms BeepTick scheduler. + +RTC time-of-day advances independently of interrupt delivery and the native +saved-seconds counter. Pending RTC sources coalesce while masked; flag state is +separate from interrupt enable. Interrupt effects delivered during foreground +execution do not themselves request another foreground run. Timer B1 follows +native motion transitions, not an input queue's desire for faster consumption. + +The application task owns native state. Interrupt service occurs at runtime and +adapter boundaries, including LCD writes; output workers do not call game +updates or mutate the motion/sound/IR workspace. This is a source-level native +platform implementation, not instruction-accurate H8 CPU emulation. + +`board_runtime.cpp` composes services and chooses the next deadline. Settings +owns its overlay in `board_settings.cpp`; diagnostic commands live in +`board_diagnostics.cpp` and compile out of production. Native renderers still +advance only when the firmware consumes a refresh request. + +## Inputs and interaction + +Physical acquisition and Settings share debounce state. PMIC L supplies a +current level and retained press; configuration/read failures are retried. +The four current layout encodings remain unchanged. Older configuration keys are ignored. + +The delivery queue timestamps transitions. Disjoint buttons may omit a redundant +neutral state, but repeated presses of one button require a delivered release. +Center IRQ becomes eligible only at the delivery front, so an older direction +cannot merge with a later confirmation. Pending transitions older than 250 ms +or a full queue cancel the gesture and require physical release. Diagnostic +counters expose both cases; native scans are not manufactured to drain a queue. + +While dark, M presents native Center activity. Native hold qualification starts +interactive mode and requests the original social-offer check. Panel and audio +preparation start earlier without claiming the native score workspace. Wake, +overlay, and IR transitions consume their physical gesture through release. +Opening Settings cancels an undelivered Center latch. Closing it resumes sampling +and visibility without pretending another native Center wake occurred. + +Two-key layouts retain dedicated L access to Stick Settings. Three-key layouts +observe a one-second physical L+R hold alongside normal directional delivery. +Recognition opens the overlay and consumes its gesture through release; pending +holds reserve no input. The native Settings selector retains Volume and Contrast. +Settings captures buttons, but native time, motion and eligible foreground work +continue; it is not a game pause. + +## Output and power + +The LCD bus publishes a revision for visible RAM, start-line and power changes. +The presenter consumes an immutable 96-by-64 snapshot, not `uiFrame` or live game +state. Each call transfers at most eight native rows, returns with SPI idle, and +retains no caller pointer. A newer snapshot can supersede remaining strips. +Appearance, rotation and overlay invalidation also require presentation even +without a new native LCD revision. + +Codec/I2S preparation is independent of Timer W. Output remains prepared during +interactive use and is suspended for screen-off carry and IR. Readiness means +configuration succeeded, not that acoustic settling was measured. The existing +single-voice waveform, codec readback, rail-generation handling and teardown +protections remain. Digital PCM checks do not establish analog onset or power. + +Sleep deadlines include native events, required input polling, Settings +inactivity, and display/audio/supply recovery. Quiet dark M-only input uses GPIO +wake; when CPU sleep is unavailable, polling still detects M. Digital GPIO +ownership is restored after every sleep attempt. Powered I2S is an actual sleep +constraint, distinct from native score ownership. + +## Infrared handoff + +Native framing, tokens, checksums, commands and completion actions are retained. +Optical acquisition retains its dedicated implementation and 240 MHz requirement. +Audio is quiesced and the prepared Connect image presented before acquisition. + +A completed non-self burst hands execution to the protocol with the receiver +core's interrupts restored. The worker waits while decoding and bounded storage +work complete. Consumed-event tickets prevent lost or premature release. Startup +and transmission wait for fresh acquisition progress before emitting a bit; +that preparation is not tagged as self-echo. The gate producer ends at a complete +non-self burst. Notification and release waiting run in the worker with +interrupts restored, outside the gate function; readiness uses its existing +published sample count. Ignored or rejected input releases +reception when no native packet remains. Stop wakes a waiting receiver and waits +for completion and suspension before deleting its task. + +Native-requested bank changes can be presented in bounded strips in the +receive-to-reply window. Acquisition coordination changed and must be physically +requalified: simulator success is not proof of unchanged optical timing. + +## Persistence + +The native 64 KiB EEPROM address space, mixed-endian records, mirrors and staging +regions remain intact. The status path explicitly writes big-endian Watts, as +required by the DS reader. + +The backend has one flash-record format, with two alternating 64 KiB +checkpoints and a bounded append log in the existing game-data partition. +Generation and sequence numbers prevent a covered journal from overriding a +published checkpoint. Native records retain their mixed byte order and mirrors. + +A record programs its body and checked header into pre-erased space, reads both +back independently, and programs its publication word last. Recovery applies only +published records with valid CRCs and consecutive sequences. An unpublished tail +is ignored. Published corruption fails closed. Inactive checkpoints are retired +before replacement; journal erasure follows publication of the covering checkpoint. + +Journal capacity is prepared before acquisition. During IR, append operations +perform only bounded programming and readback; flash erasure is prohibited by the +storage module. Checkpoint replacement and clearing dirty journal sectors happen +outside the session. The decoder's exact search pruning retains reply-time +headroom; see the [IR regression investigation](history/ir-readiness-regression.md). + +The filesystem, legacy save readers, migration and old control-layout conversion +are removed. Unsupported game data is not interpreted or erased automatically. +Pre-release upgrades require returning the Pokémon to HGSS, backing up, and +initializing the game-data partition as described in the [install guide](install.md). +Board settings remain separately stored in NVS. + +The DS's Back and present-collection paths do not wait for a final deletion +acknowledgment. No new handshake or claim of two-device atomicity is introduced. +Native staging and completion order retain their existing recovery limits. + +## Qualification + +Run the [production firmware workflows](../simulation/README.md), native-source +formatting/static analysis, and exact H8 matching under both supported compilers. +Public EEPROM fixtures exercise normal startup and persisted flash, not game +memory hooks. Physical controls, acoustics, walking comparisons, current draw, +flash interruption, and real peer/Game Card exchanges need hardware qualification +on the exact candidate. diff --git a/stick/docs/playing.md b/stick/docs/playing.md index 625d49f..10a065e 100644 --- a/stick/docs/playing.md +++ b/stick/docs/playing.md @@ -14,8 +14,8 @@ If a Pokémon from this save is out on another walker, return it first. 1. Start the game and choose **Connect to Pokéwalker** from the main menu, then **Go for a Stroll**. 2. Choose a Pokémon from your PC Boxes and a route. -3. On the Stick, choose **Connect**. With the default buttons, M moves left, - R moves right, and M+R together confirms. +3. On the Stick, choose **Connect**. With the default buttons, L moves left, + R moves right, and M confirms. 4. Aim the Stick's infrared transmitter and photodiode at the Game Card's infrared window. Hold them still until both sides finish. @@ -60,6 +60,6 @@ adjust alignment and distance. Keep both the Stick's transmitter and its photodiode pointed at the other device throughout the transfer. If an older Stickwalker says "cannot connect again" with a walker it has never -met, [update its firmware](install.md#update-an-existing-stickwalker-installation). -The October 10 update repairs the missing device ID on first boot and keeps -your Pokémon and progress. Update both Sticks if both run older firmware. +met, return its Pokémon to HGSS, then +[update its firmware and initialize game data](install.md#update-a-pre-release-installation). +Update both Sticks if both run older firmware. diff --git a/stick/docs/power.md b/stick/docs/power.md index e6df775..840dbaa 100644 --- a/stick/docs/power.md +++ b/stick/docs/power.md @@ -12,11 +12,11 @@ sampling while they use the shared working memory; menus and games continue to sample. The [motion scheduling decision](../../docs/adr/0001-preserve-native-motion-ownership.md) explains why. -Awake button polls run every 5 ms. While the game or Settings is visible, sleep -is capped at 100 ms so L's PMIC press latch can be read. The 62.5 ms game deadline -usually wakes the CPU sooner. +Required awake button polls run every 5 ms. While the game or Settings is visible, +that acquisition deadline participates in CPU sleep decisions, including L's +retained PMIC press. Delivery does not change the native sampling clock. -With the screen off, holding M for 500 ms wakes it. Releasing M early cancels +With the screen off, holding M through native Center qualification wakes it. Releasing M early cancels panel preparation. After wake, the adapter clears L's retained press and waits for all buttons to be released before navigation resumes. See the [wake policy](../../docs/adr/0002-m-only-dark-wake.md) and diff --git a/stick/docs/release.md b/stick/docs/release.md index 29a5aa2..4283605 100644 --- a/stick/docs/release.md +++ b/stick/docs/release.md @@ -24,6 +24,12 @@ The timing check compares `measure_ir_gate`, `aligned_next` and `run_gate_segmen with the production reference. The report records the new ELF hash and whether each function's instruction bytes match. +The native-platform redesign changes the receiver handoff and its gate-loop +machine code. The frozen reference is intentionally unchanged. A differing +report is a physical requalification requirement, not permission to replace the +reference with the candidate's hashes. Simulator protocol checks alone cannot +qualify this candidate for a stable release. + ## Assemble the runtime kit Use the source archives and `sources.json` from the previous release's runtime @@ -50,13 +56,18 @@ to use it with a modified Arduino runtime. Commit the final source and documentation, then run from that clean checkout: ```sh -uv run python stick/package_release.py --build-dir stick/.build/candidate --runtime-dir stick/.build/runtime --output-dir stick/.build/release +uv run python stick/package_release.py --build-dir stick/.build/candidate --runtime-dir stick/.build/runtime --output-dir stick/.build/release --allow-ir-timing-change ``` Packaging verifies the source inventory, firmware hashes, production mode, placeholder artwork, absence of diagnostic markers, infrared timing report and runtime kit. It produces three archives and `SHA256SUMS`: +`--allow-ir-timing-change` permits a prerelease with changed IR instruction bytes; +the original timing report is bundled and the manifest remains unqualified. +Omit the flag to require the frozen machine-byte reference. Neither mode replaces +physical qualification for a stable release. + - The firmware ZIP contains the binaries, symbols, installation and player guides, manifests, timing result and dependency notices. - The source archive contains the committed code and documentation. @@ -73,9 +84,10 @@ Run these checks on the firmware you intend to publish. Record the setup, observations and failures. 1. [Back up and install](install.md) the candidate. Read back the app and compare - its hash. For an app-only update, confirm registration, - Pokémon, settings and valid EEPROM mirrors survive. -2. Disconnect USB. Exercise the 500 ms M wake in every layout and rotation, + its hash. Return the Pokémon to HGSS before pre-release updates, initialize + game data, and confirm device settings, new registration and valid EEPROM + mirrors. +2. Disconnect USB. Exercise native M-hold wake in every layout and rotation, ignored dark L/R, abandoned short M taps, navigation after release, Settings, display restoration, short sounds and clock progression. 3. Repeat retail walk-start and return transfers on that image. Record @@ -86,10 +98,7 @@ observations and failures. USB disconnected. Record the time spent walking, idle and using the screen. 5. Interrupt EEPROM journal/image writes on hardware and verify recovery without losing the last completed save. -6. Update a paired installation from the September 30 firmware with a Pokémon - still on it. Confirm identity repair preserves its Pokémon, steps, Watts, - items and registration, then return the Pokémon to its Game Card. -7. With the [photodiode connected](install.md#infrared-receiver), peer-connect +6. With the [photodiode connected](install.md#infrared-receiver), peer-connect with an original Pokéwalker and with another Stickwalker. Check first contact, same-day repeat rejection, a different peer and reconnection after day rollover. Record each device's firmware and game language. diff --git a/stick/docs/relink.md b/stick/docs/relink.md index 69da6e4..75e6f9b 100644 --- a/stick/docs/relink.md +++ b/stick/docs/relink.md @@ -57,7 +57,7 @@ The SDK identifies ESP-IDF **v5.5.4**, commit `735507283d5b2f9fb363a1901172dbd9e847945d`. Its source archive includes the pinned recursive submodules; `sources.json` records each revision. The Arduino library builder source and SDK's `versions.txt`, `sdkconfig`, include files and flags are -also included. Linked managed components are LittleFS **1.22.1** and -ESP Diagnostics **1.2.1**. Their component source archives retain their own +also included. The SDK supplies managed components LittleFS **1.22.1** and +ESP Diagnostics **1.2.1**; Stickwalker's EEPROM backend does not use LittleFS. Their component source archives retain their own notices and build files. Espressif's radio libraries are supplied as upstream binary archives under their included licenses. diff --git a/stick/eeprom_backend.cpp b/stick/eeprom_backend.cpp index a0cb5fd..f1da31e 100644 --- a/stick/eeprom_backend.cpp +++ b/stick/eeprom_backend.cpp @@ -1,10 +1,11 @@ #include "eeprom_backend.h" #include "device_identity.h" -#include -#include #include #include +#include +#include +#include #include #include @@ -15,84 +16,204 @@ namespace { constexpr unsigned kImageBytes = 65536; constexpr unsigned kPageBytes = 128; -constexpr uint32_t kMagic = 0x50574531; // PWE1 -constexpr const char *kSlots[2] = {"/pw-a.bin", "/pw-b.bin"}; -constexpr const char *kTemp = "/pw-next.tmp"; -constexpr const char *kJournal = "/pw-journal.bin"; -constexpr uint32_t kJournalMagic = 0x50574a31; // PWJ1 +constexpr size_t kSectorBytes = 4096; +constexpr size_t kCheckpointSpan = 0x11000; +constexpr size_t kJournalOffset = 2 * kCheckpointSpan; constexpr size_t kJournalRecordMax = 4096; -constexpr size_t kJournalLimit = 65536; +constexpr size_t kJournalLimit = 262144; +constexpr size_t kRecordAlignment = 256; +constexpr uint32_t kMagic = 0x53574531; +constexpr uint32_t kJournalMagic = 0x53574a31; +constexpr uint32_t kCommitted = 0x54494d43; +constexpr int64_t kServiceDeadlineUs = 97700; struct Header { uint32_t magic; uint32_t generation; - uint32_t crc; -}; - -struct JournalHeader { - uint32_t magic; + uint32_t sequence; uint32_t length; uint32_t crc; + uint32_t header_crc; }; +static_assert(sizeof(Header) == 24, "EEPROM header layout"); +constexpr size_t kPayloadOffset = sizeof(Header) + sizeof(kCommitted); +static_assert(kPayloadOffset + kImageBytes <= kCheckpointSpan, + "Checkpoint fits its flash slot"); +const esp_partition_t *store = nullptr; uint8_t image[kImageBytes]; uint32_t generation = 0; +uint32_t sequence = 0; int active_slot = -1; bool mounted = false; bool dirty = false; bool deferred = false; +bool session = false; +bool failed = false; +bool timing_failed = false; unsigned batch_depth = 0; uint8_t journal_record[kJournalRecordMax]; size_t journal_record_length = 0; size_t journal_size = 0; bool journal_overflow = false; -bool journal_poisoned = false; +uint32_t append_count = 0; +int64_t max_append_us = 0; +uint32_t deadline_misses = 0; -uint32_t crc32(const uint8_t *bytes, size_t count) { - uint32_t crc = 0xffffffff; +uint32_t crc_update(uint32_t crc, const uint8_t *bytes, size_t count) { for (size_t i = 0; i < count; ++i) { crc ^= bytes[i]; for (unsigned bit = 0; bit < 8; ++bit) crc = (crc >> 1) ^ (0xedb88320u & (0u - (crc & 1u))); } - return ~crc; + return crc; } - -bool partition_blank() { - const esp_partition_t *part = esp_partition_find_first( - ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_DATA_SPIFFS, nullptr); - if (!part) return false; +uint32_t crc32(const void *bytes, size_t count) { + return ~crc_update(0xffffffff, static_cast(bytes), count); +} +bool valid_header(const Header &header, uint32_t magic) { + return header.magic == magic && header.generation && header.sequence && + header.header_crc == crc32(&header, offsetof(Header, header_crc)); +} +Header make_header(uint32_t magic, uint32_t version, uint32_t serial, + const uint8_t *bytes, size_t length) { + Header header = {magic, version, serial, uint32_t(length), + crc32(bytes, length), 0}; + header.header_crc = crc32(&header, offsetof(Header, header_crc)); + return header; +} +size_t record_span(size_t length) { + return (kPayloadOffset + length + kRecordAlignment - 1) & + ~(kRecordAlignment - 1); +} +bool flash_read(size_t offset, void *bytes, size_t length) { + return esp_partition_read(store, offset, bytes, length) == ESP_OK; +} +bool region_blank(size_t offset, size_t length) { uint8_t block[256]; - for (size_t offset = 0; offset < part->size; offset += sizeof(block)) { - const size_t count = - part->size - offset < sizeof(block) ? part->size - offset : sizeof(block); - if (esp_partition_read(part, offset, block, count) != ESP_OK) return false; - for (size_t i = 0; i < count; ++i) - if (block[i] != 0xff) return false; + while (length) { + const size_t count = length < sizeof(block) ? length : sizeof(block); + if (!flash_read(offset, block, count)) return false; + for (size_t i = 0; i < count; ++i) if (block[i] != 0xff) return false; + offset += count; + length -= count; } return true; } - -bool read_slot(unsigned slot, uint32_t &version) { - fs::File file = LittleFS.open(kSlots[slot], "r"); - if (!file || file.size() != kImageBytes + sizeof(Header)) return false; - Header header; - if (file.read(reinterpret_cast(&header), sizeof(header)) != - sizeof(header)) - return false; - if (header.magic != kMagic || - file.read(image, sizeof(image)) != sizeof(image) || - crc32(image, sizeof(image)) != header.crc) - return false; - version = header.generation; +bool clear_region(size_t offset, size_t length) { + if (session || offset % kSectorBytes || length % kSectorBytes) return false; + for (size_t end = offset + length; offset < end; offset += kSectorBytes) { + if (!region_blank(offset, kSectorBytes) && + esp_partition_erase_range(store, offset, kSectorBytes) != ESP_OK) + return false; + if (!region_blank(offset, kSectorBytes)) return false; + } return true; } - +bool clear_journal() { + if (!clear_region(kJournalOffset, kJournalLimit)) return false; + journal_size = 0; + return true; +} +bool write_committed(size_t offset, const Header &header, const uint8_t *bytes) { + // The SDK waits for NOR programming to complete. Header and body are checked + // independently before the final publication word; replies never erase flash. + if (esp_partition_write(store, offset + kPayloadOffset, bytes, + header.length) != ESP_OK || + esp_partition_write(store, offset, &header, sizeof(header)) != ESP_OK) + return false; + Header observed; + if (!flash_read(offset, &observed, sizeof(observed)) || + memcmp(&observed, &header, sizeof(header))) return false; + uint8_t block[256]; + for (size_t position = 0; position < header.length;) { + const size_t count = header.length - position < sizeof(block) ? + header.length - position : sizeof(block); + if (!flash_read(offset + kPayloadOffset + position, block, count) || + memcmp(block, bytes + position, count)) return false; + position += count; + } + uint32_t marker = kCommitted; + return esp_partition_write(store, offset + sizeof(Header), &marker, + sizeof(marker)) == ESP_OK && + flash_read(offset + sizeof(Header), &marker, sizeof(marker)) && + marker == kCommitted; +} +bool read_payload(size_t offset, uint32_t expected_crc, bool load) { + uint8_t block[256]; + uint32_t crc = 0xffffffff; + for (size_t position = 0; position < sizeof(image); position += sizeof(block)) { + if (!flash_read(offset + kPayloadOffset + position, block, sizeof(block))) + return false; + crc = crc_update(crc, block, sizeof(block)); + if (load) memcpy(image + position, block, sizeof(block)); + } + return ~crc == expected_crc; +} +bool read_checkpoint(unsigned slot, Header &header, bool load) { + const size_t offset = size_t(slot) * kCheckpointSpan; + uint32_t marker; + return flash_read(offset, &header, sizeof(header)) && + flash_read(offset + sizeof(Header), &marker, sizeof(marker)) && + marker == kCommitted && valid_header(header, kMagic) && + header.length == sizeof(image) && read_payload(offset, header.crc, load); +} bool range_ok(uint16_t address, uint16_t length) { return uint32_t(address) + length <= kImageBytes; } - -void storage_error() { StickEepromSetError(1); } +void storage_error() { + if (!failed) + esp_log_write(ESP_LOG_ERROR, "pw-eeprom", "PW_EEPROM_FAILURE restart_required=1\n"); + failed = true; + StickEepromSetError(1); +} +bool apply_patches(size_t length) { + size_t offset = 0; + while (offset < length) { + if (length - offset < 4) return false; + const unsigned address = unsigned(journal_record[offset]) | + (unsigned(journal_record[offset + 1]) << 8); + const unsigned count = unsigned(journal_record[offset + 2]) | + (unsigned(journal_record[offset + 3]) << 8); + offset += 4; + if (address + count > kImageBytes || length - offset < count) return false; + memcpy(image + address, journal_record + offset, count); + offset += count; + } + return true; +} +bool replay_journal() { + uint32_t previous = 0; + uint32_t journal_generation = 0; + while (journal_size < kJournalLimit) { + const size_t offset = kJournalOffset + journal_size; + Header header; + uint32_t marker; + if (!flash_read(offset, &header, sizeof(header)) || + !flash_read(offset + sizeof(Header), &marker, sizeof(marker))) return false; + if (marker != kCommitted) break; + if (!valid_header(header, kJournalMagic) || !header.length || + header.length > sizeof(journal_record) || + record_span(header.length) > kJournalLimit - journal_size) return false; + if (previous && (previous == UINT32_MAX || header.sequence != previous + 1 || + header.generation != journal_generation)) return false; + previous = header.sequence; + journal_generation = header.generation; + if (header.generation > generation || + !flash_read(offset + kPayloadOffset, journal_record, header.length) || + crc32(journal_record, header.length) != header.crc) return false; + journal_size += record_span(header.length); + if (header.sequence <= sequence) { + if (header.generation >= generation) return false; + continue; + } + if (header.generation != generation || sequence == UINT32_MAX || + header.sequence != sequence + 1 || !apply_patches(header.length)) return false; + sequence = header.sequence; + dirty = true; + } + return true; +} bool append_patch(uint16_t address, uint16_t length) { if (journal_record_length + 4u + length > sizeof(journal_record)) { @@ -109,164 +230,123 @@ bool append_patch(uint16_t address, uint16_t length) { return true; } -bool append_journal() { - if (journal_poisoned || journal_overflow || - journal_size + sizeof(JournalHeader) + journal_record_length > - kJournalLimit) - return StickEepromCommit(); +bool append_journal_record() { + if (failed || sequence == UINT32_MAX) return false; + if (journal_overflow || journal_size + record_span(journal_record_length) > + kJournalLimit) + return !session && StickEepromCommit(); if (!journal_record_length) return true; - const JournalHeader header = {kJournalMagic, - uint32_t(journal_record_length), - crc32(journal_record, journal_record_length)}; - fs::File file = LittleFS.open(kJournal, "a"); - if (!file) return false; - const size_t before = file.size(); - const bool written = - file.write(reinterpret_cast(&header), - sizeof(header)) == sizeof(header) && - file.write(journal_record, journal_record_length) == - journal_record_length; - file.flush(); - file.close(); - if (!written) { journal_poisoned = true; return false; } - // Read the completed record back before reporting a source EEPROM write. - fs::File verify = LittleFS.open(kJournal, "r"); - JournalHeader observed; - const bool valid_header = verify && - verify.size() == before + sizeof(header) + journal_record_length && - verify.seek(before) && - verify.read(reinterpret_cast(&observed), - sizeof(observed)) == sizeof(observed) && - observed.magic == header.magic && observed.length == header.length && - observed.crc == header.crc; - uint8_t block[256]; - uint32_t check = 0xffffffff; - size_t remaining = journal_record_length; - while (valid_header && remaining) { - const size_t count = remaining < sizeof(block) ? remaining : sizeof(block); - if (verify.read(block, count) != count) break; - for (size_t i = 0; i < count; ++i) { - check ^= block[i]; - for (unsigned bit = 0; bit < 8; ++bit) - check = (check >> 1) ^ (0xedb88320u & (0u - (check & 1u))); - } - remaining -= count; - } - verify.close(); - if (!valid_header || remaining || ~check != header.crc) { - journal_poisoned = true; - return false; - } - journal_size = before + sizeof(header) + journal_record_length; + const Header header = make_header(kJournalMagic, generation, sequence + 1, + journal_record, journal_record_length); + const size_t span = record_span(header.length); + if (journal_size + span > kJournalLimit) return !session && StickEepromCommit(); + const size_t offset = kJournalOffset + journal_size; + if (!region_blank(offset, span) || + !write_committed(offset, header, journal_record)) return false; + sequence = header.sequence; + journal_size += span; journal_record_length = 0; return true; } -bool replay_journal() { - if (!LittleFS.exists(kJournal)) return true; - fs::File file = LittleFS.open(kJournal, "r"); - if (!file) return false; - journal_size = file.size(); - dirty = true; - while (file.position() < journal_size) { - JournalHeader header; - const size_t remaining = journal_size - file.position(); - if (remaining < sizeof(header)) break; // Interrupted append. - if (file.read(reinterpret_cast(&header), sizeof(header)) != - sizeof(header)) return false; - if (header.magic != kJournalMagic || - header.length > sizeof(journal_record)) return false; - if (journal_size - file.position() < header.length) break; - if (file.read(journal_record, header.length) != header.length || - crc32(journal_record, header.length) != header.crc) return false; - size_t offset = 0; - while (offset < header.length) { - if (header.length - offset < 4) return false; - const unsigned address = unsigned(journal_record[offset]) | - (unsigned(journal_record[offset + 1]) << 8); - const unsigned length = unsigned(journal_record[offset + 2]) | - (unsigned(journal_record[offset + 3]) << 8); - offset += 4; - if (address + length > kImageBytes || - header.length - offset < length) return false; - memcpy(image + address, journal_record + offset, length); - offset += length; +bool append_journal() { + if (!journal_record_length && !journal_overflow) return !failed; + const int64_t start = esp_timer_get_time(); + const bool result = append_journal_record(); + if (session) { + const int64_t elapsed = esp_timer_get_time() - start; + ++append_count; + if (elapsed > max_append_us) max_append_us = elapsed; + if (elapsed >= kServiceDeadlineUs) { + ++deadline_misses; + timing_failed = true; + StickEepromSetError(1); } - dirty = true; } - file.close(); - // Checkpoint the replayed journal and remove its incomplete tail before - // accepting new writes. - return StickEepromCommit(); + return result; } bool write_span(uint16_t address, const void *source, uint16_t length) { - if (!mounted || !source || !range_ok(address, length)) { + if (!mounted || failed || !source || !range_ok(address, length)) { storage_error(); return false; } - if (memcmp(image + address, source, length)) { - memcpy(image + address, source, length); - dirty = true; - if (!deferred) { - if (!batch_depth) journal_record_length = 0; - append_patch(address, length); + if (!memcmp(image + address, source, length)) return true; + memcpy(image + address, source, length); + dirty = true; + if (!deferred) { + if (!batch_depth) { + journal_record_length = 0; + journal_overflow = false; + } + append_patch(address, length); + if (!batch_depth && !append_journal()) { + storage_error(); + return false; } - } else { - return true; } - if (!deferred && !batch_depth && !append_journal()) { - storage_error(); - return false; + return true; +} + +bool load_image() { + Header first = {}, second = {}; + const bool a = read_checkpoint(0, first, false); + const bool b = read_checkpoint(1, second, false); + uint32_t first_marker, second_marker; + if (!flash_read(sizeof(Header), &first_marker, sizeof(first_marker)) || + !flash_read(kCheckpointSpan + sizeof(Header), &second_marker, + sizeof(second_marker)) || + (first_marker == kCommitted && !a) || + (second_marker == kCommitted && !b)) return false; + if (a || b) { + if (a && b && + ((first.generation == second.generation && + memcmp(&first, &second, sizeof(first))) || + (first.generation > second.generation && first.sequence <= second.sequence) || + (second.generation > first.generation && second.sequence <= first.sequence))) + return false; + active_slot = a && (!b || first.generation > second.generation) ? 0 : 1; + Header selected; + if (!read_checkpoint(active_slot, selected, true)) return false; + generation = selected.generation; + sequence = selected.sequence; + return replay_journal(); } + // An unsupported or damaged store needs explicit initialization. Only blank + // flash creates a new walker; no save readers or conversions are retained. + if (!region_blank(0, store->size)) return false; + memset(image, 0xff, sizeof(image)); + dirty = true; return true; } -} // namespace +} extern "C" int StickEepromMount(void) { - if (mounted) return 1; - if (!LittleFS.begin(false)) { - if (!partition_blank() || !LittleFS.begin(true)) return 0; - } - uint32_t first_version = 0, second_version = 0; - const bool first = read_slot(0, first_version); - const bool second = read_slot(1, second_version); - if (first && (!second || int32_t(first_version - second_version) > 0)) { - read_slot(0, generation); - active_slot = 0; - } else if (second) { - read_slot(1, generation); - active_slot = 1; - } else { - // Existing save files with invalid headers or checksums need recovery. - // Initialize a fresh image when all save files are absent. - if (LittleFS.exists(kSlots[0]) || LittleFS.exists(kSlots[1]) || - LittleFS.exists(kJournal)) return 0; - memset(image, 0xff, sizeof(image)); - dirty = true; - } - mounted = true; - if (dirty && !StickEepromCommit()) return 0; - if (!replay_journal()) return 0; + if (mounted) return !failed; + if (failed) return 0; + store = esp_partition_find_first(ESP_PARTITION_TYPE_DATA, + ESP_PARTITION_SUBTYPE_DATA_SPIFFS, nullptr); + if (!store || store->size < kJournalOffset + kJournalLimit) return 0; + if (!load_image()) { storage_error(); return 0; } uint8_t factory_mac[6]; if (esp_efuse_mac_get_default(factory_mac) != ESP_OK) return 0; - if (StickIdentityRepair(image, factory_mac)) { - dirty = true; - // Save all repaired identity copies in one checkpoint before boot. On - // failure, the previous slot remains available for recovery. - if (!StickEepromCommit()) return 0; + dirty |= StickIdentityRepair(image, factory_mac) != 0; + mounted = true; + const bool existing_checkpoint = !dirty; + if (!StickEepromCommit() || (existing_checkpoint && !clear_journal())) { + storage_error(); mounted = false; return 0; } return 1; } #ifdef PW_STICK_BENCH_CONTROL extern "C" const unsigned char *StickEepromSnapshot(void) { - return mounted && !deferred ? image : nullptr; + return mounted && !deferred && !session && !failed ? image : nullptr; } extern "C" int StickEepromBenchErase(void) { - if (!mounted || deferred) return 0; + if (!mounted || deferred || session || failed) return 0; memset(image, 0xff, sizeof(image)); dirty = true; return StickEepromCommit(); @@ -274,9 +354,38 @@ extern "C" int StickEepromBenchErase(void) { #endif extern "C" void StickEepromDefer(int enabled) { + if (session || batch_depth) { storage_error(); return; } deferred = enabled != 0; - if (!deferred && !batch_depth && dirty && !StickEepromCommit()) + if (!deferred && !StickEepromCommit()) storage_error(); +} + +extern "C" int StickEepromSessionBegin(void) { + if (!mounted || failed || session || deferred || batch_depth || + !StickEepromCommit()) { storage_error(); + return 0; + } + session = true; + timing_failed = false; + append_count = 0; + max_append_us = 0; + deadline_misses = 0; + return 1; +} + +extern "C" int StickEepromSessionEnd(void) { + session = false; + esp_log_write(failed || timing_failed ? ESP_LOG_ERROR : ESP_LOG_INFO, + "pw-eeprom", + "PW_EEPROM_SESSION appends=%lu max_us=%lld deadline_misses=%lu failed=%d\n", + static_cast(append_count), + static_cast(max_append_us), + static_cast(deadline_misses), int(failed)); + return !failed && !timing_failed; +} + +extern "C" int StickEepromHealthy(void) { + return mounted && !failed; } extern "C" void StickEepromBatchBegin(void) { @@ -287,67 +396,58 @@ extern "C" void StickEepromBatchBegin(void) { extern "C" int StickEepromBatchEnd(void) { if (!batch_depth) { storage_error(); return 0; } --batch_depth; - if (!batch_depth && !deferred && !append_journal()) { + if (failed || (!batch_depth && !deferred && !append_journal())) { storage_error(); return 0; } return 1; } -extern "C" int StickEepromCommit(void) { - if (!mounted) return 0; +static int checkpoint_image() { + if (!mounted || failed || session) return 0; if (!dirty) return 1; + if (generation == UINT32_MAX || sequence == UINT32_MAX) return 0; const int next = active_slot == 0 ? 1 : 0; - const Header header = {kMagic, generation + 1, crc32(image, sizeof(image))}; - LittleFS.remove(kTemp); - fs::File file = LittleFS.open(kTemp, "w"); - if (!file) return 0; - const bool written = - file.write(reinterpret_cast(&header), - sizeof(header)) == sizeof(header) && - file.write(image, sizeof(image)) == sizeof(image); - file.flush(); - file.close(); - if (!written) return 0; - // Validate the temporary image independently before replacing a slot. - fs::File verify = LittleFS.open(kTemp, "r"); - Header observed; - bool valid = verify && - verify.read(reinterpret_cast(&observed), sizeof(observed)) == - sizeof(observed) && - observed.magic == kMagic && observed.generation == header.generation && - observed.crc == header.crc; - uint32_t check = 0xffffffff; - uint8_t block[256]; - size_t remaining = kImageBytes; - while (valid && remaining) { - const size_t count = remaining < sizeof(block) ? remaining : sizeof(block); - if (verify.read(block, count) != count) { valid = false; break; } - for (size_t i = 0; i < count; ++i) { - check ^= block[i]; - for (unsigned bit = 0; bit < 8; ++bit) - check = (check >> 1) ^ (0xedb88320u & (0u - (check & 1u))); - } - remaining -= count; + const size_t offset = size_t(next) * kCheckpointSpan; + const Header header = make_header(kMagic, generation + 1, sequence + 1, + image, sizeof(image)); + // Retire the inactive slot before erasing it. A reset during replacement + // leaves the active checkpoint usable instead of a published torn body. + uint32_t marker; + if (!flash_read(offset + sizeof(Header), &marker, sizeof(marker))) return 0; + if (marker == kCommitted) { + marker = 0; + if (esp_partition_write(store, offset + sizeof(Header), &marker, + sizeof(marker)) != ESP_OK || + !flash_read(offset + sizeof(Header), &marker, sizeof(marker)) || marker) + return 0; } - verify.close(); - if (!valid || ~check != header.crc) return 0; - LittleFS.remove(kSlots[next]); - if (!LittleFS.rename(kTemp, kSlots[next])) return 0; + if (!clear_region(offset, kCheckpointSpan) || + !write_committed(offset, header, image)) return 0; + Header observed; + if (!read_checkpoint(next, observed, false) || + memcmp(&observed, &header, sizeof(header))) return 0; + // Publication precedes erasure. A reset here replays/skips old records using + // the published generation/sequence, then clears every dirty journal sector. active_slot = next; generation = header.generation; + sequence = header.sequence; + if (!clear_journal()) return 0; dirty = false; - if (LittleFS.exists(kJournal) && !LittleFS.remove(kJournal)) return 0; - journal_size = 0; - journal_poisoned = false; journal_overflow = false; journal_record_length = 0; return 1; } +extern "C" int StickEepromCommit(void) { + if (checkpoint_image()) return 1; + storage_error(); + return 0; +} + extern "C" void EepromRead(uint16_t address, void *destination, uint16_t length) { - StickEepromSetError(0); + StickEepromSetError(failed || timing_failed); if (!mounted || !destination || !range_ok(address, length)) { storage_error(); if (destination) memset(destination, 0xff, length); @@ -357,7 +457,7 @@ extern "C" void EepromRead(uint16_t address, void *destination, } extern "C" void EepromWrite(uint16_t address, void *source, uint16_t length) { - StickEepromSetError(0); + StickEepromSetError(failed || timing_failed); write_span(address, source, length); } @@ -379,23 +479,20 @@ extern "C" void EepromFillPage(uint16_t address, uint8_t value) { } extern "C" void EepromFill(uint16_t address, uint16_t count, uint8_t value) { - StickEepromSetError(0); - if (!mounted || !range_ok(address, count)) { + StickEepromSetError(failed || timing_failed); + if (!mounted || failed || !range_ok(address, count)) { storage_error(); return; } - bool changed = false; - for (unsigned i = 0; i < count; ++i) { - changed |= image[address + i] != value; - image[address + i] = value; - } - dirty |= changed; - if (changed && !deferred) { - if (!batch_depth) journal_record_length = 0; - append_patch(address, count); + uint8_t bytes[kPageBytes]; + memset(bytes, value, sizeof(bytes)); + StickEepromBatchBegin(); + for (unsigned offset = 0; offset < count; offset += sizeof(bytes)) { + const uint16_t length = count - offset < sizeof(bytes) + ? count - offset : sizeof(bytes); + if (!write_span(address + offset, bytes, length)) break; } - if (changed && !deferred && !batch_depth && !append_journal()) - storage_error(); + StickEepromBatchEnd(); } extern "C" void EepromWritePage(uint16_t address, uint8_t *source) { diff --git a/stick/eeprom_backend.h b/stick/eeprom_backend.h index 48b2535..1016880 100644 --- a/stick/eeprom_backend.h +++ b/stick/eeprom_backend.h @@ -5,13 +5,12 @@ extern "C" { #endif -/* Load the newest valid image, replay its journal and repair the device - * identity. Format LittleFS when the partition is blank. */ int StickEepromMount(void); -/* Buffer EEPROM writes in RAM during an infrared session. */ void StickEepromDefer(int enabled); int StickEepromCommit(void); -/* Commit the writes in an EEPROM mirror update together. */ +int StickEepromSessionBegin(void); +int StickEepromSessionEnd(void); +int StickEepromHealthy(void); void StickEepromBatchBegin(void); int StickEepromBatchEnd(void); #ifdef PW_STICK_BENCH_CONTROL diff --git a/stick/foreground_bridge.c b/stick/foreground_bridge.c index 32fc896..5801932 100644 --- a/stick/foreground_bridge.c +++ b/stick/foreground_bridge.c @@ -1,4 +1,5 @@ #include "foreground_bridge.h" +#include "native_runtime.h" #include "project.h" #include "flags.h" @@ -9,20 +10,15 @@ #include "application/pw_buzzer.h" #include "support/ir.h" -void RtcQuarterSecondInterrupt(void); -void RtcSecondInterrupt(void); void BeepTick(void); void MotionSessionEnd(void); -void StickForegroundQuarterSecond(void) { RtcQuarterSecondInterrupt(); } -void StickForegroundSecond(void) { RtcSecondInterrupt(); } int StickForegroundIsIr(void) { return g_task == IrProtocolTick; } int StickForegroundIsMain(void) { return g_task == MainTick; } int StickForegroundIsBeep(void) { return g_task == BeepTick; } int StickForegroundIsInactive(void) { return (g_state.flags.byte & SYSTEM_MODE_MASK) == SYSTEM_MODE_INACTIVE; } -unsigned StickForegroundUiFrame(void) { return g_state.uiFrame; } void StickForegroundRun(void) { if (g_task) g_task(); #ifdef PW_STICK_BENCH_CONTROL @@ -39,14 +35,25 @@ void StickForegroundRequestIr(void) { g_state.events.byte |= EVENT_IR_REQUEST; #endif } -void StickForegroundWakeDisplay(void) { MotionSessionStart(); } -void StickForegroundDeviceMenuClosed(void) { StickForegroundWakeDisplay(); } +void StickForegroundDeviceMenuClosed(void) { + StickNativeMotionClock(1); + MotionSessionStart(); +} void StickForegroundCenterWake(void) { + StickNativeCenterInterrupt(); + StickNativeService(); +} +void StickForegroundAcceptCenterInterrupt(void) { /* The original center switch also raises IRQ0. The GPIO adapter must * supply that latch when its virtual Center edge is first delivered. */ g_state.events.byte |= EVENT_CENTER_PRESS; g_state.buttonWake[0] = 1; } +void StickForegroundDiscardCenterInput(void) { + StickNativeCancelCenterInterrupt(); + g_state.events.byte &= EVENT_CLEAR(EVENT_CENTER_PRESS); +} +int StickForegroundIrHasInput(void) { return g_state.irReceivedBytes != 0; } #ifdef PW_STICK_BENCH_CONTROL void StickForegroundBenchSleep(void) { DisplayEnterPowerSave(); @@ -74,12 +81,6 @@ int StickForegroundBenchMoveScore(void) { } #endif unsigned StickForegroundIrResult(void) { return g_state.irResult; } -void StickForegroundIrDiagnostic(unsigned *phase, unsigned *received, - unsigned *reference) { - *phase = g_work.irc.work.handshakePhase; - *received = g_state.irReceivedBytes; - *reference = g_state.irTimerReference; -} #ifdef PW_STICK_BENCH_CONTROL void StickForegroundUiDiagnostic(unsigned *view, unsigned *updates, unsigned *frames, unsigned *seconds, diff --git a/stick/foreground_bridge.h b/stick/foreground_bridge.h index e6b77d1..652142e 100644 --- a/stick/foreground_bridge.h +++ b/stick/foreground_bridge.h @@ -5,26 +5,23 @@ extern "C" { #endif -void StickForegroundQuarterSecond(void); -void StickForegroundSecond(void); int StickForegroundIsIr(void); int StickForegroundIsMain(void); int StickForegroundIsBeep(void); int StickForegroundIsInactive(void); -unsigned StickForegroundUiFrame(void); void StickForegroundRun(void); void StickForegroundRequestIr(void); void StickForegroundDeviceMenuClosed(void); -void StickForegroundWakeDisplay(void); void StickForegroundCenterWake(void); +void StickForegroundAcceptCenterInterrupt(void); +void StickForegroundDiscardCenterInput(void); +int StickForegroundIrHasInput(void); #ifdef PW_STICK_BENCH_CONTROL void StickForegroundBenchSleep(void); void StickForegroundBenchInactive(void); int StickForegroundBenchMoveScore(void); #endif unsigned StickForegroundIrResult(void); -void StickForegroundIrDiagnostic(unsigned *phase, unsigned *received, - unsigned *reference); #ifdef PW_STICK_BENCH_CONTROL void StickForegroundUiDiagnostic(unsigned *view, unsigned *updates, unsigned *frames, unsigned *seconds, diff --git a/stick/input_bridge.cpp b/stick/input_bridge.cpp index cc9aa8d..4eef2ad 100644 --- a/stick/input_bridge.cpp +++ b/stick/input_bridge.cpp @@ -5,36 +5,34 @@ #include "display_bus.h" #include "display_panel.h" #include "foreground_bridge.h" -#ifdef PW_STICK_BENCH_CONTROL #include "sound_bridge.h" -#endif #include #include #include +#include namespace { pw_stick::Controls controls; bool power_button_ready = false; -u8 input_profile = 0; -u8 input_layout = 0; -u8 input_orientation = 0; -u8 chord_window_index = 0; +pw_stick::ControlSettings settings; bool menu_active = false; bool was_m_only_wake = false; -bool discard_power_event = false; +bool discard_power_event = true; u8 sampled_raw = 0; -u8 menu_candidate = 0, menu_stable = 0, menu_pending = 0; -uint32_t menu_since_ms = 0, three_menu_since_ms = 0; -bool three_menu_started = false, three_menu_sent = false; -bool three_menu_requested = false; -bool power_event_requested = false; +u8 menu_pending = 0; +bool menu_wait_release = true; +bool menu_requested = false; bool power_action_armed = false; +bool power_release_tracking = false; uint32_t power_released_since_ms = 0; -uint32_t front_wake_since_ms = 0; +uint32_t power_retry_ms = 0; +bool wake_center = false; +bool wake_started = false; bool front_wake_consuming = false; u8 previous_levels = 0; +uint64_t last_poll_us = 0; #ifdef PW_STICK_BENCH_CONTROL u8 injected_button = 0; u8 held_button = 0; @@ -58,8 +56,6 @@ bool pm1_write(uint8_t reg, uint8_t value) { } bool enable_power_button_input() { - // Set download-mode recovery to a four-second hold so it remains separate - // from the 1.2-second Settings gesture. uint8_t single, twice, single_after, twice_after; if (!pm1_read(0x49, single) || !pm1_read(0x4a, twice)) return false; const uint8_t button_config = uint8_t((single & ~0x18u) | 0x18u | 1u); @@ -76,70 +72,80 @@ extern "C" void StickInputInit(void) { pinMode(11, INPUT_PULLUP); pinMode(12, INPUT_PULLUP); power_button_ready = enable_power_button_input(); + power_retry_ms = millis() + 1000; + last_poll_us = esp_timer_get_time(); previous_levels = 0; Preferences preferences; if (preferences.begin("pw-controls", true)) { - const u8 stored = preferences.getUChar("layout-v2", 0xff); - const auto settings = stored == 0xff ? - pw_stick::migrate_controls(preferences.getUChar("layout", 0)) : - pw_stick::decode_controls(stored); + settings = pw_stick::decode_controls(preferences.getUChar( + "layout-v2", pw_stick::encode_controls({}))); preferences.end(); - input_layout = u8(settings.layout); - input_orientation = settings.orientation; - chord_window_index = settings.chord_window; } - input_profile = pw_stick::three_key(pw_stick::Layout(input_layout)); - controls.configure(pw_stick::Layout(input_layout)); - controls.set_chord_window(80 + chord_window_index * 40); + controls.configure(settings.layout); + controls.set_chord_window(80 + settings.chord_window * 40); } extern "C" void StickInputPoll(unsigned long milliseconds) { + const uint32_t now_ms = uint32_t(milliseconds); + last_poll_us = esp_timer_get_time(); const bool main_pressed = digitalRead(11) == LOW #ifdef PW_STICK_BENCH_CONTROL - || uint32_t(bench_main_until_ms - milliseconds) < - 0x80000000u + || (bench_main_until_ms && + int32_t(bench_main_until_ms - now_ms) > 0) #endif ; if (StickInputMOnlyWake()) { if (!was_m_only_wake) { controls.require_release(); previous_levels = menu_pending = 0; - three_menu_started = three_menu_sent = three_menu_requested = false; - power_event_requested = power_action_armed = false; - power_released_since_ms = front_wake_since_ms = 0; + menu_requested = power_action_armed = power_release_tracking = false; + wake_center = wake_started = false; front_wake_consuming = false; discard_power_event = true; was_m_only_wake = true; } - // Use M for dark-screen wake. The wake path clears the retained L event - // before navigation resumes. sampled_raw = main_pressed ? 1u : 0u; + controls.sample(now_ms, main_pressed, false, false, true); #ifdef PW_STICK_BENCH_CONTROL main_edges += main_pressed && !(last_raw & 1u); last_raw = sampled_raw; bench_power_event = false; #endif if (main_pressed) { + wake_started = true; StickDisplayPrepareWake(); - if (!front_wake_since_ms) front_wake_since_ms = uint32_t(milliseconds); - else if (uint32_t(milliseconds - front_wake_since_ms) >= 500) { - StickForegroundWakeDisplay(); - controls.require_release(); - front_wake_consuming = true; - front_wake_since_ms = 0; - } + StickSoundPrepare(); } else { - front_wake_since_ms = 0; StickDisplayCancelPreparedWake(); } + const bool center = (controls.stable_buttons() & 1u) != 0; + if (center && !wake_center) StickForegroundCenterWake(); + wake_center = center; return; } - was_m_only_wake = false; + if (was_m_only_wake) { + was_m_only_wake = false; + front_wake_consuming = wake_started; + wake_center = false; + controls.require_release(); + } + if (!power_button_ready && int32_t(now_ms - power_retry_ms) >= 0) { + power_button_ready = enable_power_button_input(); + power_retry_ms = now_ms + 1000; + discard_power_event = true; + } uint8_t power_state = 0; const bool power_read = power_button_ready && pm1_read(0x48, power_state); + if (power_button_ready && !power_read) { +#ifdef PW_STICK_BENCH_CONTROL + ++pmic_errors; +#endif + power_button_ready = false; + power_retry_ms = now_ms + 1000; + power_action_armed = false; + controls.require_release(); + } const bool power_pressed = power_read && (power_state & 1u); - // BTN_Status bit 7 records a press until the register is read. A short - // power-key tap can end between polls even when its event was captured. const bool power_event = !discard_power_event && ((power_read && (power_state & 0x80u)) #ifdef PW_STICK_BENCH_CONTROL || bench_power_event @@ -149,13 +155,12 @@ extern "C" void StickInputPoll(unsigned long milliseconds) { #ifdef PW_STICK_BENCH_CONTROL bench_power_event = false; #endif - // The PMIC can report several changes for one tap. Wait for a quiet release - // before accepting another Settings gesture. if (!power_read || power_pressed || power_event) { - power_released_since_ms = 0; - } else if (!power_released_since_ms) { - power_released_since_ms = uint32_t(milliseconds); - } else if (uint32_t(milliseconds - power_released_since_ms) >= 50) { + power_release_tracking = false; + } else if (!power_release_tracking) { + power_release_tracking = true; + power_released_since_ms = now_ms; + } else if (uint32_t(now_ms - power_released_since_ms) >= 50) { power_action_armed = true; } const bool power_action = power_action_armed && @@ -170,63 +175,46 @@ extern "C" void StickInputPoll(unsigned long milliseconds) { side_edges += (raw & 2u) && !(last_raw & 2u); power_edges += (raw & 4u) && !(last_raw & 4u); power_events += power_event; - pmic_errors += power_button_ready && !power_read; last_raw = raw; - // Wait for release after the sound test before resuming game input. if (StickSoundBenchToneActive()) bench_suppress_until_release = true; if (bench_suppress_until_release) { if (!sampled_raw) bench_suppress_until_release = false; controls.require_release(); - three_menu_started = false; - three_menu_sent = false; - three_menu_requested = false; - power_event_requested = false; + controls.sample(now_ms, main_pressed, side_pressed, power_pressed, true); + menu_requested = false; return; } #endif - if (menu_active) { - if (power_action) menu_pending |= 4u; - if (sampled_raw != menu_candidate) { - menu_candidate = sampled_raw; - menu_since_ms = uint32_t(milliseconds); - } - if (menu_stable != menu_candidate && - uint32_t(milliseconds - menu_since_ms) >= 5) { - const u8 new_edges = menu_candidate & ~menu_stable; - menu_stable = menu_candidate; - menu_pending |= new_edges & 3u; - } - return; - } + controls.sample(now_ms, main_pressed, side_pressed, power_pressed, + menu_active || front_wake_consuming, power_action); if (front_wake_consuming) { - if (!main_pressed && !side_pressed && !power_pressed && - (!power_button_ready || power_read)) + if (!sampled_raw && !controls.stable_buttons() && power_read) { front_wake_consuming = false; - // Feed quiet levels to release the debouncers after the wake gesture. - if (!front_wake_consuming) - controls.sample(uint32_t(milliseconds), false, false, false); + controls.require_release(); + } return; } - if (!input_profile && power_action) power_event_requested = true; - if (input_profile && power_pressed) { - if (!three_menu_started) { - three_menu_started = true; - three_menu_since_ms = uint32_t(milliseconds); - } else if (!three_menu_sent && - uint32_t(milliseconds - three_menu_since_ms) >= 1200) { - three_menu_sent = true; - three_menu_requested = true; + if (menu_active) { + if (menu_wait_release) { + if (!sampled_raw && !controls.stable_buttons() && power_read) + menu_wait_release = false; + } else { + menu_pending |= controls.pressed_buttons() & 3u; + if (power_action) menu_pending |= 4u; } - } else { - three_menu_started = false; - three_menu_sent = false; + return; + } + if (controls.take_settings_hold() || + (!pw_stick::three_key(settings.layout) && power_action)) { + menu_requested = true; + controls.require_release(); } - controls.sample(uint32_t(milliseconds), main_pressed, side_pressed, - input_profile ? power_pressed : false); + if (controls.take_center_press()) StickForegroundCenterWake(); } extern "C" u8 StickInputLevels(void) { - if (menu_active) { + if (menu_active || menu_requested || front_wake_consuming || + (was_m_only_wake && !StickInputMOnlyWake())) { previous_levels = 0; return 0; } @@ -234,27 +222,24 @@ extern "C" u8 StickInputLevels(void) { if (held_scans) { --held_scans; const u8 value = held_button; - if ((value & pw_stick::kCenter) && - !(previous_levels & pw_stick::kCenter)) - StickForegroundCenterWake(); previous_levels = value; return value; } if (injected_button) { const u8 value = injected_button; injected_button = 0; - if ((value & pw_stick::kCenter) && - !(previous_levels & pw_stick::kCenter)) - StickForegroundCenterWake(); previous_levels = value; return value; } #endif - if (StickInputMOnlyWake()) { previous_levels = 0; return 0; } - const u8 value = controls.next_scan(); - if ((value & pw_stick::kCenter) && - !(previous_levels & pw_stick::kCenter)) - StickForegroundCenterWake(); + u8 value; + if (StickInputMOnlyWake()) { + value = wake_center ? pw_stick::kCenter : 0; + } else { + const uint32_t now_ms = millis(); + if (controls.take_center_press(now_ms)) StickForegroundCenterWake(); + value = controls.next_scan(now_ms); + } #ifdef PW_STICK_BENCH_CONTROL left_edges += (value & pw_stick::kLeft) && !(previous_levels & pw_stick::kLeft); @@ -284,8 +269,17 @@ extern "C" int StickInputWakeScanActive(void) { return 0; } +extern "C" uint64_t StickInputNextDeadline(void) { + if (StickInputMOnlyWake() && !sampled_raw && + !(controls.stable_buttons() & 1u)) return UINT64_MAX; + return last_poll_us + 5000; +} + #ifdef PW_STICK_BENCH_CONTROL -extern "C" void StickInputBenchInject(u8 button) { injected_button = button; } +extern "C" void StickInputBenchInject(u8 button) { + injected_button = button; + if (button & pw_stick::kCenter) StickForegroundCenterWake(); +} extern "C" void StickInputBenchMainHold(unsigned milliseconds) { bench_main_until_ms = millis() + milliseconds; } @@ -293,6 +287,7 @@ extern "C" void StickInputBenchPowerEvent(void) { bench_power_event = true; } extern "C" void StickInputBenchHold(u8 button, unsigned scans) { held_button = button; held_scans = scans; + if (scans && (button & pw_stick::kCenter)) StickForegroundCenterWake(); } extern "C" void StickInputDiagnostic(unsigned *main_count, unsigned *side_count, @@ -314,7 +309,8 @@ extern "C" void StickInputPathDiagnostic( unsigned *stable, unsigned *gesture, unsigned *desired, unsigned *delivered, unsigned *queued, unsigned *wait_release, unsigned *emitted, unsigned *consumed, unsigned *overflow, - unsigned *left_count, unsigned *right_count, unsigned *center_count) { + unsigned *left_count, unsigned *right_count, unsigned *center_count, + unsigned *expired, unsigned *cancelled) { const auto d = controls.diagnostic(); *stable = d.stable; *gesture = d.gesture; @@ -328,24 +324,31 @@ extern "C" void StickInputPathDiagnostic( *left_count = left_edges; *right_count = right_edges; *center_count = center_edges; + *expired = d.expired; + *cancelled = d.cancelled; } #endif extern "C" int StickMenuRequested(void) { - const bool requested = controls.menu_requested() || three_menu_requested || - power_event_requested; - three_menu_requested = false; - power_event_requested = false; + const bool requested = menu_requested; + menu_requested = false; return requested ? 1 : 0; } extern "C" void StickInputMenuMode(int enabled) { + StickForegroundDiscardCenterInput(); menu_active = enabled != 0; - menu_candidate = menu_stable = sampled_raw; menu_pending = 0; - menu_since_ms = millis(); + menu_wait_release = true; + controls.require_release(); + previous_levels = 0; +} + +extern "C" void StickInputRequireRelease(void) { controls.require_release(); + StickForegroundDiscardCenterInput(); previous_levels = 0; + front_wake_consuming = true; } extern "C" u8 StickInputTakeMenuButtons(void) { @@ -354,10 +357,12 @@ extern "C" u8 StickInputTakeMenuButtons(void) { return value; } -extern "C" u8 StickInputProfile(void) { return input_profile; } -extern "C" u8 StickInputLayout(void) { return input_layout; } -extern "C" u8 StickInputOrientation(void) { return input_orientation; } -extern "C" u8 StickInputChordWindowIndex(void) { return chord_window_index; } +extern "C" u8 StickInputProfile(void) { + return pw_stick::three_key(settings.layout); +} +extern "C" u8 StickInputLayout(void) { return u8(settings.layout); } +extern "C" u8 StickInputOrientation(void) { return settings.orientation; } +extern "C" u8 StickInputChordWindowIndex(void) { return settings.chord_window; } extern "C" int StickInputConfigure(u8 layout, u8 orientation, u8 window_index) { @@ -369,11 +374,8 @@ extern "C" int StickInputConfigure(u8 layout, u8 orientation, {pw_stick::Layout(layout), orientation, window_index})); preferences.end(); if (written != 1) return 0; - input_layout = layout; - input_profile = pw_stick::three_key(pw_stick::Layout(layout)); - input_orientation = orientation; - chord_window_index = window_index; - controls.configure(pw_stick::Layout(layout)); - controls.set_chord_window(80 + window_index * 40); + settings = {pw_stick::Layout(layout), orientation, window_index}; + controls.configure(settings.layout); + controls.set_chord_window(80 + settings.chord_window * 40); return 1; } diff --git a/stick/input_bridge.h b/stick/input_bridge.h index 34e743e..049f037 100644 --- a/stick/input_bridge.h +++ b/stick/input_bridge.h @@ -2,6 +2,7 @@ #define PW_STICK_INPUT_BRIDGE_H #include "types.h" +#include #ifdef __cplusplus extern "C" { @@ -12,12 +13,13 @@ void StickInputInit(void); void StickInputPoll(unsigned long milliseconds); /* Consume one native Pokewalker input scan, returning BUTTON_* bits. */ u8 StickInputLevels(void); -/* True while a physical press or a queued gesture needs prompt native scans. */ int StickInputWakeScanActive(void); +uint64_t StickInputNextDeadline(void); /* True when the game screen is off and Settings is closed; M starts the wake * gesture. */ int StickInputMOnlyWake(void); int StickMenuRequested(void); +void StickInputRequireRelease(void); /* Device overlay consumes physical M/R/L without sending game input. */ void StickInputMenuMode(int enabled); u8 StickInputTakeMenuButtons(void); @@ -42,7 +44,8 @@ void StickInputPathDiagnostic(unsigned *stable, unsigned *gesture, unsigned *queued, unsigned *wait_release, unsigned *emitted, unsigned *consumed, unsigned *overflow, unsigned *left_edges, - unsigned *right_edges, unsigned *center_edges); + unsigned *right_edges, unsigned *center_edges, + unsigned *expired, unsigned *cancelled); #endif #ifdef __cplusplus diff --git a/stick/ir_rx_core.cpp b/stick/ir_rx_core.cpp index 6225878..aa9ebd6 100644 --- a/stick/ir_rx_core.cpp +++ b/stick/ir_rx_core.cpp @@ -37,27 +37,41 @@ void map_cells(const uint16_t *strong, unsigned count, Geometry &geometry) { } } -uint64_t phase_error_for(const uint16_t *strong, unsigned count, - const Geometry &geometry) { - uint64_t phase_error = 0; +bool score_geometry(const uint16_t *strong, unsigned count, + const Geometry &geometry, unsigned best_bad, + uint64_t best_phase, bool earlier, unsigned &bad, + uint64_t &phase) { + // Sorted gates map monotonically to cells. Count each occupied start/stop + // once, and reject as soon as stop errors alone exceed the current best. + // With perfect framing already found, nonnegative phase-error terms also + // let us reject a candidate once its partial sum cannot beat the winner. + // Neither bound changes the framing-first, phase-second search ordering. + unsigned starts = 0, stops = 0; + int previous = -1; + phase = 0; for (unsigned i = 0; i < count; ++i) { const int32_t position_q16 = - (int32_t(strong[i]) * 2 - geometry.origin_twice) * - geometry.inverse_q15; + (int32_t(strong[i]) * 2 - geometry.origin_twice) * geometry.inverse_q15; const int cell = (position_q16 + (1 << 15)) >> 16; const int32_t offset = position_q16 - cell * 65536; - phase_error += uint32_t(offset * offset); - } - return phase_error; -} - -unsigned framing_errors(const Geometry &geometry) { - unsigned bad = 0; - for (unsigned byte = 0; byte < geometry.bytes; ++byte) { - bad += !geometry.occupied[byte * 10]; - bad += geometry.occupied[byte * 10 + 9]; + phase += uint32_t(offset * offset); + if (cell >= 0 && cell < int(geometry.bytes * 10) && cell != previous) { + previous = cell; + const unsigned bit = unsigned(cell) % 10; + starts += bit == 0; + stops += bit == 9; + } + // A later gate cannot fill a missing start in this already-observed prefix. + const unsigned prefix = cell < 0 ? 0 : + (unsigned(cell) / 10 + 1 < geometry.bytes ? + unsigned(cell) / 10 + 1 : geometry.bytes); + const unsigned lower_bound = prefix - starts + stops; + if (lower_bound > best_bad || + (lower_bound == best_bad && + (phase > best_phase || (phase == best_phase && !earlier)))) return false; } - return bad; + bad = geometry.bytes - starts + stops; + return bad <= best_bad; } bool fit_geometry(const uint16_t *strong, unsigned count, size_t gate_count, @@ -86,7 +100,17 @@ bool fit_geometry(const uint16_t *strong, unsigned count, size_t gate_count, (anchor - radius < 29700 ? 29700 : anchor - radius); const int last_step = full_search ? 30120 : (anchor + radius > 30120 ? 30120 : anchor + radius); - for (int step = first_step; step <= last_step; step += 5) { + if (first_step > last_step) continue; + const int steps = (last_step - first_step) / 5 + 1; + int center = (anchor - first_step + 2) / 5; + if (center < 0) center = 0; + if (center >= steps) center = steps - 1; + // Visit the likely baud first so the same exact search gets a useful + // rejection bound early. Explicit ties preserve its original ordering. + for (int trial = 0; trial < steps * 2; ++trial) { + const int index = center + ((trial + 1) / 2) * (trial & 1 ? -1 : 1); + if (index < 0 || index >= steps) continue; + const int step = first_step + index * 5; for (int half = -10; half <= 8; ++half) { // Keep the geometry workspace in static storage to fit the // foreground task's 8 KiB stack. @@ -101,11 +125,14 @@ bool fit_geometry(const uint16_t *strong, unsigned count, size_t gate_count, // a start bit from the first data-zero pulse. const int opening_cell = nearest_cell(strong[0], candidate); if (opening_cell < 0 || opening_cell > 1) continue; - map_cells(strong, count, candidate); - const unsigned bad = framing_errors(candidate); - if (bad > best_bad) continue; - const uint64_t phase = phase_error_for(strong, count, candidate); - if (bad < best_bad || (bad == best_bad && phase < best_phase)) { + unsigned bad; + uint64_t phase; + const bool earlier = candidate.fit_pass < best.fit_pass || + (candidate.fit_pass == best.fit_pass && step < best.step_10000); + if (!score_geometry(strong, count, candidate, best_bad, best_phase, + earlier, bad, phase)) continue; + if (bad < best_bad || (bad == best_bad && + (phase < best_phase || (phase == best_phase && earlier)))) { best = candidate; best_bad = bad; best_phase = phase; @@ -185,6 +212,7 @@ bool decode_wire_burst(const uint8_t *bins, size_t gate_count, WireBurst &out, static Geometry geometry; if (!fit_geometry(strong, strong_count, gate_count, geometry)) return false; + map_cells(strong, strong_count, geometry); remove_recovery_tails(bins, strong, strong_count, geometry, out); for (unsigned byte = 0; byte < geometry.bytes; ++byte) { diff --git a/stick/ir_transport.cpp b/stick/ir_transport.cpp index ff3fe20..9d961eb 100644 --- a/stick/ir_transport.cpp +++ b/stick/ir_transport.cpp @@ -42,10 +42,12 @@ OpticalEvent events[kEventSlots]; volatile uint32_t produced = 0; volatile uint32_t event_write = 0; volatile uint32_t event_read = 0; +volatile uint32_t released_events = 0; volatile bool running = false; volatile bool stop_requested = false; volatile bool worker_ready = false; volatile bool worker_done = false; +volatile bool tx_pending = false; volatile bool tx_busy = false; volatile bool failed = false; volatile bool passive_diagnostic = false; @@ -134,7 +136,7 @@ uint32_t IRAM_ATTR __attribute__((noinline)) run_gate_segment( uint8_t *bins, uint32_t first, uint32_t limit, pw_stick::GateHealth &health, uint32_t input_mask, bool detect_events, bool wake_events, bool session_idle_abort, - uint16_t align_us, TaskHandle_t caller) { + uint16_t align_us) { uint32_t next = aligned_next(align_us), fraction = 0; uint32_t first_low = 0, last_low = 0, last_service_gate = first; bool in_packet = false, self_echo = false; @@ -144,7 +146,8 @@ uint32_t IRAM_ATTR __attribute__((noinline)) run_gate_segment( !__atomic_load_n(&stop_requested, __ATOMIC_ACQUIRE); ++gate) { if (session_idle_abort && !in_packet && - gate - last_service_gate >= kGateHz / 10) break; + gate - last_service_gate >= kGateHz / 10 && + !__atomic_load_n(&tx_pending, __ATOMIC_ACQUIRE)) break; if (in_packet && gate - first_low >= pw_stick::kMaxBurstGates) { // Continuous light can keep a burst open. Reject it at the capture // limit so both cores resume their interrupts before the watchdog. @@ -189,15 +192,10 @@ uint32_t IRAM_ATTR __attribute__((noinline)) run_gate_segment( in_packet = false; core1_tick(true); if (wake_events && !self_echo) { - __atomic_store_n(&produced, gate + 1, __ATOMIC_RELEASE); - portENABLE_INTERRUPTS(); - if (caller) xTaskNotifyGive(caller); - // The peer waits for our reply here, leaving time to service the - // idle task and watchdog. - vTaskDelay(1); - portDISABLE_INTERRUPTS(); - next = aligned_next(align_us); - last_service_gate = gate; + // End at a complete burst. The worker owns notification and the + // storage handoff outside this timing-sensitive producer. + ++gate; + break; } } } @@ -263,16 +261,24 @@ void sampler(void *) { } else { while (!__atomic_load_n(&stop_requested, __ATOMIC_ACQUIRE)) { gate = run_gate_segment(ring, gate, gate + kSegmentGates, health, - input_mask, true, true, true, 250, - protocol_handle); + input_mask, true, true, true, 250); portENABLE_INTERRUPTS(); + const uint32_t handoff = __atomic_load_n(&event_write, __ATOMIC_ACQUIRE); #ifdef PW_STICK_BENCH_CONTROL if (trace_enabled() && gate <= kSegmentGates) Serial.printf("PW_STICK_SEGMENT_END gate=%lu\n", (unsigned long)gate); #endif if (protocol_handle) xTaskNotifyGive(protocol_handle); - vTaskDelay(1); + if (int32_t(__atomic_load_n(&released_events, __ATOMIC_ACQUIRE) - + handoff) < 0) { + while (int32_t(__atomic_load_n(&released_events, __ATOMIC_ACQUIRE) - + handoff) < 0 && + !__atomic_load_n(&stop_requested, __ATOMIC_ACQUIRE)) + ulTaskNotifyTake(pdTRUE, portMAX_DELAY); + } else { + vTaskDelay(1); + } portDISABLE_INTERRUPTS(); } } @@ -295,6 +301,52 @@ uint16_t ticks_from_us(int64_t us) { return uint16_t((uint64_t(us) * 32768u) / 1000000u); } +bool resume_reception(bool transmitting) { + if (__atomic_load_n(&passive_diagnostic, __ATOMIC_ACQUIRE)) return true; + if (!worker_handle || !__atomic_load_n(&running, __ATOMIC_ACQUIRE) || + __atomic_load_n(&failed, __ATOMIC_ACQUIRE) || + __atomic_load_n(&stop_requested, __ATOMIC_ACQUIRE)) return false; + const uint32_t release = __atomic_load_n( + transmitting ? &event_write : &event_read, __ATOMIC_ACQUIRE); + const uint32_t previous = __atomic_load_n(&released_events, __ATOMIC_ACQUIRE); + const bool advances = int32_t(release - previous) > 0; + if (!transmitting && !advances) return true; + // Observe fresh physical samples through the existing producer publication. + // Extra request/ack fences in the gate loop exceed its 65-cycle release + // margin with real branch/call costs and can turn light pulses into ones. + uint32_t sampled = __atomic_load_n(&produced, __ATOMIC_ACQUIRE); + uint32_t released = previous; + if (advances) { + __atomic_store_n(&released_events, release, __ATOMIC_RELEASE); + xTaskNotifyGive(worker_handle); + released = release; + } + const int64_t deadline = esp_timer_get_time() + 10000; + while (!__atomic_load_n(&failed, __ATOMIC_ACQUIRE) && + !__atomic_load_n(&stop_requested, __ATOMIC_ACQUIRE) && + !__atomic_load_n(&worker_done, __ATOMIC_ACQUIRE) && + esp_timer_get_time() < deadline) { + if (transmitting) { + const uint32_t newest = __atomic_load_n(&event_write, __ATOMIC_ACQUIRE); + if (int32_t(newest - released) > 0) { + sampled = __atomic_load_n(&produced, __ATOMIC_ACQUIRE); + __atomic_store_n(&released_events, newest, __ATOMIC_RELEASE); + xTaskNotifyGive(worker_handle); + released = newest; + } + } + if (uint32_t(__atomic_load_n(&produced, __ATOMIC_ACQUIRE) - sampled) >= 32) break; + delayMicroseconds(5); + } + const bool ready = + uint32_t(__atomic_load_n(&produced, __ATOMIC_ACQUIRE) - sampled) >= 32 && + !__atomic_load_n(&failed, __ATOMIC_ACQUIRE) && + !__atomic_load_n(&stop_requested, __ATOMIC_ACQUIRE) && + !__atomic_load_n(&worker_done, __ATOMIC_ACQUIRE); + if (!ready) __atomic_store_n(&failed, true, __ATOMIC_RELEASE); + return ready; +} + } // namespace #ifdef PW_STICK_BENCH_CONTROL @@ -371,6 +423,9 @@ extern "C" void StickIrStart(void) { #endif __atomic_store_n(&event_write, 0, __ATOMIC_RELEASE); __atomic_store_n(&event_read, 0, __ATOMIC_RELEASE); + __atomic_store_n(&released_events, 0, __ATOMIC_RELEASE); + __atomic_store_n(&tx_pending, false, __ATOMIC_RELEASE); + __atomic_store_n(&tx_busy, false, __ATOMIC_RELEASE); __atomic_store_n(&stop_requested, false, __ATOMIC_RELEASE); __atomic_store_n(&worker_ready, false, __ATOMIC_RELEASE); __atomic_store_n(&worker_done, false, __ATOMIC_RELEASE); @@ -396,11 +451,15 @@ extern "C" void StickIrStart(void) { return; } core1_tick(true); + __atomic_store_n(&tx_pending, true, __ATOMIC_RELEASE); __atomic_store_n(&running, true, __ATOMIC_RELEASE); + resume_reception(true); + __atomic_store_n(&tx_pending, false, __ATOMIC_RELEASE); } extern "C" void StickIrStop(void) { __atomic_store_n(&stop_requested, true, __ATOMIC_RELEASE); + if (worker_handle) xTaskNotifyGive(worker_handle); const int64_t deadline = esp_timer_get_time() + 500000; while (!worker_parked() && esp_timer_get_time() < deadline) delay(1); @@ -534,7 +593,17 @@ extern "C" void StickIrDelayTicks(u16 ticks) { while (u16(StickIrTicks() - start) < ticks) {} } +extern "C" void StickIrResumeReception(void) { + resume_reception(false); +} + extern "C" void StickIrSendFrame(const u8 *logical, u8 length) { + __atomic_store_n(&tx_pending, true, __ATOMIC_RELEASE); + if (!resume_reception(true)) { + __atomic_store_n(&tx_pending, false, __ATOMIC_RELEASE); + __atomic_store_n(&failed, true, __ATOMIC_RELEASE); + return; + } __atomic_store_n(&tx_busy, true, __ATOMIC_RELEASE); #ifdef PW_STICK_BENCH_CONTROL const int64_t started_us = esp_timer_get_time(); @@ -553,6 +622,7 @@ extern "C" void StickIrSendFrame(const u8 *logical, u8 length) { } #endif __atomic_store_n(&tx_busy, false, __ATOMIC_RELEASE); + __atomic_store_n(&tx_pending, false, __ATOMIC_RELEASE); } extern "C" void StickIrSendByte(u8 logical) { diff --git a/stick/ir_transport.h b/stick/ir_transport.h index 9c93257..d33b20f 100644 --- a/stick/ir_transport.h +++ b/stick/ir_transport.h @@ -18,6 +18,7 @@ void StickIrConfigure(void); void StickIrStart(void); void StickIrPassiveDiagnostic(int enabled); void StickIrStop(void); +void StickIrResumeReception(void); u16 StickIrTicks(void); void StickIrDelayTicks(u16 ticks); void StickIrSendByte(u8 logical); diff --git a/stick/native_runtime.cpp b/stick/native_runtime.cpp new file mode 100644 index 0000000..760fbd9 --- /dev/null +++ b/stick/native_runtime.cpp @@ -0,0 +1,269 @@ +#include "native_runtime.h" + +#include "foreground_bridge.h" +#include + +extern "C" { +void RtcQuarterSecondInterrupt(void); +void RtcSecondInterrupt(void); +void RtcMinuteInterrupt(void); +void RtcHourInterrupt(void); +void BeepAdvance(void); +} + +namespace { +constexpr uint64_t kWatchHz = 32768; +constexpr uint64_t kSampleUs = 62500; +bool initialized = false; +bool servicing = false; +bool foreground = false; +uint8_t ccr = 0x80; +bool motion_clock = true; +bool sample_interrupt = false; +bool refresh_interrupt = true; +bool sample_pending = false; +bool refresh_pending = false; +bool second_pending = false; +bool minute_pending = false; +bool hour_pending = false; +bool center_pending = false; +bool wake_pending = false; +uint64_t sample_deadline = 0; +uint64_t sample_remaining = kSampleUs; +uint64_t rtc_anchor = 0; +uint32_t rtc_base = 0; +uint64_t rtc_seconds = 0; +uint64_t rtc_quarters = 0; +bool timer_w_enabled = false; +bool timer_w_interrupt = true; +bool timer_w_pending = false; +bool timer_w_dispatch = false; +uint16_t timer_w_compare = 0; +uint64_t timer_w_deadline = 0; +uint64_t timer_w_event = 0; +unsigned timer_w_revision = 0; + +uint64_t now_us() { return uint64_t(esp_timer_get_time()); } +uint64_t timer_period() { return uint64_t(timer_w_compare + 1) * 1000000; } +uint8_t bcd(unsigned value) { return uint8_t((value / 10) * 16 + value % 10); } + +void capture(uint64_t now) { + const uint64_t elapsed = now - rtc_anchor; + const uint64_t seconds = elapsed / 1000000; + const uint64_t quarters = elapsed / 250000; + if (seconds != rtc_seconds) { + const uint64_t before = uint64_t(rtc_base) + rtc_seconds; + const uint64_t after = uint64_t(rtc_base) + seconds; + second_pending = true; + minute_pending |= before / 60 != after / 60; + hour_pending |= before / 3600 != after / 3600; + rtc_seconds = seconds; + } + if (quarters != rtc_quarters) { + refresh_pending = true; + rtc_quarters = quarters; + } + if (motion_clock && now >= sample_deadline) { + sample_pending = true; + sample_deadline += ((now - sample_deadline) / kSampleUs + 1) * kSampleUs; + } +} +} + +extern "C" void StickNativeInit(void) { + initialized = true; + servicing = foreground = false; + ccr = 0x80; + motion_clock = refresh_interrupt = true; + sample_interrupt = false; + sample_pending = refresh_pending = second_pending = minute_pending = false; + hour_pending = center_pending = wake_pending = false; + rtc_anchor = now_us(); + rtc_base = 0; + rtc_seconds = rtc_quarters = 0; + sample_deadline = rtc_anchor + kSampleUs; + sample_remaining = kSampleUs; + timer_w_enabled = timer_w_pending = timer_w_dispatch = false; + timer_w_interrupt = true; + timer_w_compare = 0; + timer_w_deadline = timer_w_event = 0; + timer_w_revision = 0; +} + +extern "C" void StickNativeStart(void) { + sample_deadline = now_us() + kSampleUs; + sample_remaining = kSampleUs; + sample_pending = false; + motion_clock = true; + sample_interrupt = true; + ccr = 0; + StickNativeService(); +} + +extern "C" void StickNativeService(void) { + if (!initialized || servicing) return; + servicing = true; + const uint64_t now = now_us(); + const uint64_t watch_now = now * kWatchHz; + capture(now); + if (ccr & 0x80) { + if (timer_w_enabled && watch_now >= timer_w_deadline) { + timer_w_pending = true; + timer_w_deadline += ((watch_now - timer_w_deadline) / timer_period() + 1) * timer_period(); + } + servicing = false; + return; + } + if (center_pending) { + center_pending = false; + StickForegroundAcceptCenterInterrupt(); + StickNativeMotionClock(1); + wake_pending |= !foreground; + } + if (refresh_pending && refresh_interrupt) { + refresh_pending = false; + RtcQuarterSecondInterrupt(); + wake_pending |= !foreground; + } + if (second_pending) { + second_pending = false; + RtcSecondInterrupt(); + wake_pending |= !foreground; + } + if (minute_pending) { + minute_pending = false; + RtcMinuteInterrupt(); + wake_pending |= !foreground; + } + if (hour_pending) { + hour_pending = false; + RtcHourInterrupt(); + wake_pending |= !foreground; + } + if (sample_pending && sample_interrupt) { + sample_pending = false; + wake_pending |= !foreground; + } + if (timer_w_enabled && !timer_w_interrupt && watch_now >= timer_w_deadline) { + timer_w_pending = true; + timer_w_deadline += ((watch_now - timer_w_deadline) / timer_period() + 1) * timer_period(); + } + if (timer_w_enabled && timer_w_interrupt && timer_w_pending) { + timer_w_pending = false; + timer_w_event = watch_now; + timer_w_dispatch = true; + BeepAdvance(); + timer_w_dispatch = false; + wake_pending |= !foreground; + } + while (timer_w_enabled && timer_w_interrupt && timer_w_deadline <= watch_now) { + timer_w_event = timer_w_deadline; + const unsigned revision = timer_w_revision; + timer_w_dispatch = true; + BeepAdvance(); + timer_w_dispatch = false; + if (revision == timer_w_revision) timer_w_deadline += timer_period(); + wake_pending |= !foreground; + } + servicing = false; +} + +extern "C" uint64_t StickNativeNextDeadline(void) { + if (!initialized) return UINT64_MAX; + if (!(ccr & 0x80) && (wake_pending || + (StickForegroundIsBeep() && timer_w_compare == 0))) return now_us(); + uint64_t next = rtc_anchor + (rtc_seconds + 1) * 1000000; + if (refresh_interrupt) { + const uint64_t quarter = rtc_anchor + (rtc_quarters + 1) * 250000; + if (quarter < next) next = quarter; + } + if (motion_clock && sample_interrupt && sample_deadline < next) next = sample_deadline; + if (timer_w_enabled && timer_w_interrupt) { + const uint64_t timer = (timer_w_deadline + kWatchHz - 1) / kWatchHz; + if (timer < next) next = timer; + } + return next; +} + +extern "C" int StickNativeRun(void) { + StickNativeService(); + if (foreground) return 0; + const int task = StickForegroundIsIr() ? 3 : StickForegroundIsBeep() ? 2 : 1; + if (task != 3 && ((ccr & 0x80) || (!wake_pending && !(task == 2 && timer_w_compare == 0)))) + return 0; + wake_pending = false; + sample_interrupt = false; + foreground = true; + StickForegroundRun(); + sample_interrupt = StickForegroundIsMain(); + StickNativeService(); + foreground = false; + return task; +} + +extern "C" void StickNativeSetCcr(uint8_t value) { + StickNativeService(); + ccr = value; + StickNativeService(); +} + +extern "C" uint8_t StickNativeGetCcr(void) { return ccr; } + +extern "C" void StickNativeCenterInterrupt(void) { + center_pending = true; +} + +extern "C" void StickNativeCancelCenterInterrupt(void) { center_pending = false; } + +extern "C" void StickNativeMotionClock(int enabled) { + if (motion_clock == bool(enabled)) return; + const uint64_t now = now_us(); + capture(now); + if (enabled) sample_deadline = now + sample_remaining; + else sample_remaining = sample_deadline > now ? sample_deadline - now : kSampleUs; + motion_clock = enabled; +} + +extern "C" void StickNativeRefreshInterrupt(int enabled) { + capture(now_us()); + refresh_interrupt = enabled; +} + +extern "C" void StickNativeTimerWEnable(void) { + timer_w_enabled = timer_w_interrupt = true; + timer_w_pending = false; + timer_w_deadline = now_us() * kWatchHz + timer_period(); + ++timer_w_revision; +} + +extern "C" void StickNativeTimerWDisable(void) { + timer_w_enabled = timer_w_pending = false; + ++timer_w_revision; +} + +extern "C" void StickNativeTimerWSetCompare(uint16_t compare) { + timer_w_compare = compare; + timer_w_deadline = (timer_w_dispatch ? timer_w_event : now_us() * kWatchHz) + timer_period(); + ++timer_w_revision; +} + +extern "C" void StickNativeTimerWInterruptEnable(int enabled) { + StickNativeService(); + timer_w_interrupt = enabled; + StickNativeService(); +} + +extern "C" void StickNativeRtcSet(uint32_t seconds) { + rtc_base = seconds; + rtc_anchor = now_us(); + rtc_seconds = rtc_quarters = 0; + refresh_interrupt = true; + refresh_pending = second_pending = minute_pending = hour_pending = false; +} + +extern "C" void StickNativeRtcRead(uint8_t *second, uint8_t *minute, uint8_t *hour) { + const uint64_t seconds = uint64_t(rtc_base) + (now_us() - rtc_anchor) / 1000000; + *second = bcd(seconds % 60); + *minute = bcd((seconds / 60) % 60); + *hour = bcd((seconds / 3600) % 24); +} diff --git a/stick/native_runtime.h b/stick/native_runtime.h new file mode 100644 index 0000000..6e9d1a4 --- /dev/null +++ b/stick/native_runtime.h @@ -0,0 +1,29 @@ +#pragma once + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +void StickNativeInit(void); +void StickNativeStart(void); +void StickNativeService(void); +uint64_t StickNativeNextDeadline(void); +int StickNativeRun(void); +void StickNativeSetCcr(uint8_t value); +uint8_t StickNativeGetCcr(void); +void StickNativeCenterInterrupt(void); +void StickNativeCancelCenterInterrupt(void); +void StickNativeMotionClock(int enabled); +void StickNativeRefreshInterrupt(int enabled); +void StickNativeTimerWEnable(void); +void StickNativeTimerWDisable(void); +void StickNativeTimerWSetCompare(uint16_t compare); +void StickNativeTimerWInterruptEnable(int enabled); +void StickNativeRtcSet(uint32_t seconds); +void StickNativeRtcRead(uint8_t *second, uint8_t *minute, uint8_t *hour); + +#ifdef __cplusplus +} +#endif diff --git a/stick/package_release.py b/stick/package_release.py index 4b0d983..6720b22 100644 --- a/stick/package_release.py +++ b/stick/package_release.py @@ -69,7 +69,7 @@ def validate_runtime(folder: Path, build: dict) -> dict: return runtime -def validate_build(folder: Path) -> dict: +def validate_build(folder: Path, allow_ir_timing_change: bool = False) -> dict: manifest = read_json(folder / "build-manifest.json") if manifest.get("bench_control") is not False or manifest.get("artwork") != "placeholders": raise InputError("Release firmware must be production mode with placeholder artwork.") @@ -101,13 +101,21 @@ def validate_build(folder: Path) -> dict: timing.get("elf_sha256") != manifest["outputs"]["PwStick.ino.elf"] or timing.get("reference_app_sha256") != baseline["reference_app_sha256"] or set(timing.get("functions", {})) != set(expected_functions) - or not timing.get("passed") ): raise InputError("Missing or stale IR timing verification.") for name, expected in expected_functions.items(): row = timing["functions"][name] + if not isinstance(row.get("bytes"), int) or row["bytes"] <= 0 or not re.fullmatch( + r"[0-9a-f]{64}", row.get("sha256", "") + ): + raise InputError(f"Incomplete IR timing record: {name}.") if row.get("bytes") != expected["bytes"] or row.get("sha256") != expected["sha256"]: - raise InputError(f"IR timing changed: {name}; physical requalification is required.") + if not allow_ir_timing_change: + raise InputError( + f"IR timing changed: {name}; use --allow-ir-timing-change for an unqualified prerelease." + ) + if not allow_ir_timing_change and not timing.get("passed"): + raise InputError("IR timing verification did not pass.") return manifest @@ -118,7 +126,7 @@ def git(*parts): if git("status", "--porcelain", "--untracked-files=normal").strip(): raise InputError("Commit the intended source and remove untracked release inputs first.") commit = git("rev-parse", "HEAD").decode().strip() - manifest = validate_build(args.build_dir.resolve()) + manifest = validate_build(args.build_dir.resolve(), args.allow_ir_timing_change) runtime_folder = args.runtime_dir.resolve() validate_runtime(runtime_folder, manifest) output = args.output_dir.resolve() @@ -152,6 +160,7 @@ def git(*parts): { "git_commit": commit, "hardware_qualified": False, + "ir_baseline_identical": read_json(args.build_dir / "ir-timing.json")["passed"], "qualification": "See RELEASE.md; qualify these exact output hashes.", "runtime_archive": runtime.name, "runtime_sha256": digest(runtime), @@ -228,6 +237,7 @@ def main() -> int: parser.add_argument("--build-dir", type=Path, required=True) parser.add_argument("--runtime-dir", type=Path, required=True) parser.add_argument("--output-dir", type=Path, required=True) + parser.add_argument("--allow-ir-timing-change", action="store_true") args = parser.parse_args() try: package(args) diff --git a/stick/peripheral_power.cpp b/stick/peripheral_power.cpp index d9491a9..a8230b8 100644 --- a/stick/peripheral_power.cpp +++ b/stick/peripheral_power.cpp @@ -47,3 +47,7 @@ void StickPeripheralPowerService(void) { } unsigned StickPeripheralGeneration(void) { return generation; } + +uint64_t StickPeripheralNextDeadline(void) { + return retry_us ? uint64_t(retry_us) : UINT64_MAX; +} diff --git a/stick/peripheral_power.h b/stick/peripheral_power.h index d67047b..d060e25 100644 --- a/stick/peripheral_power.h +++ b/stick/peripheral_power.h @@ -11,3 +11,4 @@ bool StickPeripheralAcquire(StickPeripheral owner); void StickPeripheralRelease(StickPeripheral owner); void StickPeripheralPowerService(void); unsigned StickPeripheralGeneration(void); +uint64_t StickPeripheralNextDeadline(void); diff --git a/stick/power_sleep.cpp b/stick/power_sleep.cpp index bf9e62e..c4b8ffe 100644 --- a/stick/power_sleep.cpp +++ b/stick/power_sleep.cpp @@ -119,6 +119,10 @@ bool StickSleepBegin(void) { return true; } +uint64_t StickSleepNextDeadline(void) { + return ready ? UINT64_MAX : retry_begin_us; +} + bool StickSleepUntil(uint64_t deadline_us) { const uint64_t now = uint64_t(esp_timer_get_time()); if (!ready && (now < retry_begin_us || !StickSleepBegin())) return false; @@ -129,10 +133,7 @@ bool StickSleepUntil(uint64_t deadline_us) { return false; } const bool m_only = StickInputMOnlyWake(); - // Cap visible-screen sleep for L polling. With the screen off, wake for M - // or the next game deadline. - uint64_t duration = deadline_us - now - 1000; - if (!m_only && duration > 100000) duration = 100000; + const uint64_t duration = deadline_us - now - 1000; if (digitalRead(11) == LOW || (!m_only && digitalRead(12) == LOW)) return false; const esp_err_t timer_result = esp_sleep_enable_timer_wakeup(duration); if (timer_result != ESP_OK) { diff --git a/stick/power_sleep.h b/stick/power_sleep.h index 1ba36c0..a87a22d 100644 --- a/stick/power_sleep.h +++ b/stick/power_sleep.h @@ -6,6 +6,7 @@ // Suspend the CPU in Light-sleep until the caller's next work deadline. bool StickSleepBegin(void); bool StickSleepUntil(uint64_t deadline_us); +uint64_t StickSleepNextDeadline(void); #ifdef PW_STICK_BENCH_CONTROL void StickSleepDiagnostic(uint64_t *count, uint64_t *sleep_us, diff --git a/stick/simulation/README.md b/stick/simulation/README.md index 186aa3b..5541ce9 100644 --- a/stick/simulation/README.md +++ b/stick/simulation/README.md @@ -3,7 +3,7 @@ Run the production Stickwalker image on a simulated Stick S3, press its buttons, and check the screen and infrared output. The ESP32-S3 ROM, bootloader, FreeRTOS, SDK drivers and application execute together. Flash writes use the -firmware's NVS and LittleFS code. +firmware's NVS and flash-record code. ## Run the checks @@ -32,6 +32,10 @@ The runner checks that its source and binary hashes match the build record. `--arduino-cli` and `--config-file` select a separate Arduino installation when building through `check.py`. +Repeat `--scenario` to run selected workflows, for example +`--scenario controls --scenario wake`. Fixtures are generated directly in the +current flash-record format; no filesystem packer is needed. + ## Workflows and expectations `settings.actions` opens Settings with L, uses M and R to change rotation and @@ -48,15 +52,140 @@ attempts. These expectations come from [Dmitry's captured Pokéwalker protocol]( sampling, interrupt masking, watchdog service, session cleanup and reuse through the real firmware. +`controls.actions` boots a public, synthetic registered walker. Thirty-millisecond +contacts navigate Settings, Inventory and Trainer with repeated and alternating +direction taps. A ten-millisecond overlap must remain two directions rather than +become Center. A real M+R chord enters native Settings. Opening the board overlay +while holding R must not change its layout until R is released and pressed again. +The navigation expectations follow `HomeUpdate`, `MainMenuUpdate` and the documented +Stick control mapping, not an injected foreground callback. + +`layouts.actions` selects every layout, resets the chip and reopens Settings to +verify persistence. Both three-key mappings deliver physical L+R to gameplay +before a one-second hold opens the board overlay. The pending-hold capture must +show the native menu; the overlay's input layout must remain unchanged while +both keys, then R alone, remain held. A fresh R changes the layout only after +release. Native Volume and Contrast remain the only selector choices: a second +Right press stays on Contrast. The workflow changes volume 2 to 0 and contrast +4 to 5, saves, resets, and checks both checksummed EEPROM mirrors in the +resulting flash records. Editor cursor positions are checked independently. +After cycling from three-key back to two-key, a fresh dedicated L press must +reopen the overlay with the same two-key layout. + +`wake.actions` waits for the native 60-second display timeout with USB present. +R and L cannot wake the sleeping panel. A short M press cancels preparation and +releases the shared supply; a qualified M hold wakes the screen and must be consumed +through release. The following two presses navigate normally. This exercises native +eight-scan Center acceptance, not a diagnostic wake function. It does **not** exercise +CPU Light-sleep: USB keeps the CPU awake throughout this workflow. + +`battery` runs the same contacts with VBUS at zero. It additionally requires a +retained RTC timer-wake cause, an M-only EXT1 mask while dark, an EXT1 wake for +the accepted M hold, and both front-button pads restored to digital GPIO. These +are hardware-register observations from the model, not firmware diagnostic hooks. +The timer cause is observed at wake rather than an arbitrary later instant: the +RTC model clears that status when the CPU enters its next sleep. + +The controls and wake workflows capture actual I2S DMA output from the simulator. +Their generated score contains tempo 120, a six-unit note at pitch index 20 and an +end marker. The matching firmware's compare table gives 77, so its fundamental is +32768/78 Hz. Native duration arithmetic and final-period muting give 48 periods, +or 114.26 ms. Frequency is measured between the first and last complete positive +zero crossing, with two samples of endpoint quantization tolerance. Counting +crossings over the entire amplitude envelope would bias a short note's estimate +downward. Duration tolerance conservatively budgets both edges of the configured +four-by-64-frame DMA queue, one 1 ms service tick and two PCM samples (24.31 ms at +22050 Hz). M5Unified's stop request is consumed by its mixer; it does not discard +already queued samples. This is an acceptance tolerance, not a hardware latency +guarantee. Five navigation notes and the +first cold-wake note plus a warm repeat must all be present separately. These are +digital-output checks, not measurements of acoustic onset, loudness or codec analog +settling. + +### Public registered fixture + +`fixture.py` generates all 64 KiB from public record layouts and original synthetic +labels rendered with Pillow's default font. It supplies the `nintendo` signature, +valid mirrored identity/status/save records, a registered flag, 100 Watts, no +companion, volume 2 and contrast 4. Its score and simple cursor rasters are generated +locally. It imports no private saves, Nintendo artwork, DS assets or retail scores. + +The runner wraps this image in the current committed checkpoint format and +loads it into the game-data partition before boot. Later reads, writes, record +publication and NVS changes go through the real firmware. It never patches game +RAM or replaces native callbacks. Fixture hashes are retained with the evidence. + +Three recovery cases reuse `recovery.actions`, navigate the native menu and reset +again. `recovery-stale` starts from 200 Watts with an older published journal +containing 100; the checkpoint must win. `recovery-journal` publishes a valid next +record changing both save mirrors from 200 to 100; replay must survive reset. +`recovery-truncated` leaves an incomplete, unpublished append after a 200-Watt +checkpoint; it must not partially apply. Both native mirrors must retain their +expected big-endian Watts and checksums. These are current-format recovery states, +not compatibility fixtures or exhaustive physical power-loss coverage. + +`peer.actions` uses a minimal DS-side protocol peer, not a second copy of the +firmware. It observes GPIO46 SIR, validates checksums and session tokens, sends +status/read requests through GPIO5, and requires the 100-Watt value as `00 64`. +`check.py` separately decodes that reply from the transmitted optical waveform. +`peer-storage.actions` additionally writes one public 128-byte pattern at `0xFF80`, +reads it back, resets the chip and reads it again in a new session. The final +committed checkpoint must contain the same pattern. Both workflows finish by +opening Settings through physical inputs. The peer's ideal charge/discharge +model drives the compiled photodiode receiver; it does not replace decoding or +native protocol callbacks. It is a functional status/page exchange, not a complete +retail DS transfer or an analog interoperability qualification. + +`peer-transfer.actions` follows the supplied HGSS put sequence with synthetic +uncompressed data: status/Watts, 82 course pages, five trainer-record pages, +PHC/distribution reads, checksum/ping, commit and one-way shutdown. After reset, +independent flash extraction checks the committed course and trainer identity. + +`peer-transfer-compressed.actions` follows the same sequence using independently +encoded Nintendo LZ10 pages, as HGSS does for starting a stroll. Pages that grow +are sent raw, and the final partial course/record page uses HGSS's padded +128-byte form. Native commit and reset must retain the same expected course and +trainer record. + +`peer-play.actions` uses an eligible public Walker fixture and exchanges status, +animation, name, trainer-house records, peer information and start. Both bulk +transfer directions are checked. After reset, the native peer-history entry +and received animation/name must remain in flash. It covers one handshake role; +it does not yet cover all opening collisions or both native role branches. + +All three full exchanges enable local transmitter echo and use 115760 baud, matching +the recorded retail-card clock. `STICK_E2E_OPCODE_TIMING=1` uses independently +scheduled CPUs in one-instruction quanta with opcode/branch/call/load-use costs. +Full exchanges also hold the NOR WIP bit for 300 microseconds per program +command and 20 milliseconds per sector erase. These are explicit regression +stress parameters, not a claim about the installed flash chip. An independent +bus observer requires zero flash erases while a protocol reply is pending. +The default short workflows retain their original timing mode. This is a +software timing sensitivity check, not an analog or cache-accurate hardware +model. The [regression investigation](../docs/history/ir-readiness-regression.md) +records the before/after evidence and decoder-equivalence check. + Each run creates a fresh directory under `stick/.build/e2e-results/` with screen images, OCR text, console output, optical waveforms and flash. `results.json` records the completed workflows and hashes of the application, merged image and simulator patch. CI retains these files for review, including failed runs. +The suite does not establish retail hardware transfers, exhaustive power-loss +recovery, walking accuracy, social-offer acceptance, battery life or +physical optical timing. The stationary fixture has no eligible social offer; +observing the wake alone does not prove the offer consequence. An exploratory +battery-only run (`vbus 0`) originally reached an RTC-watchdog reset with cause16 +on both unchanged main and the redesign. Guest stacks established that the pinned +RTC model left SLEEP_EN set without generating wake/reject status, trapping the SDK +in `rtc_sleep_start`. The local simulator patch now supplies timer/EXT1 wake, +sleep rejection and watchdog pause during modeled sleep. This enables the battery +workflow but does not measure current consumption or establish physical battery +life. + ## Board model and simulator `board.rs` implements the Stick's I2C1 devices on GPIO47/48, the M5PM1 button -latch, stationary BMI270 samples and the ST7789 display on SPI3. Its visible +latch, selectable input voltage, stationary BMI270 samples and the ST7789 display on SPI3. Its visible window crops the controller's GRAM to the Stick's 135-by-240 panel. Chip resets retain the external devices; their reset commands reset their own state. @@ -78,9 +207,29 @@ number of milliseconds. Every run stops on an unexpected chip reset or panic. | Command | Action | | --- | --- | | `run 100` | Run firmware for 100 ms | +| `run-until-timer-wake 1000` | Run until an RTC timer wake is observed, failing after the given maximum milliseconds | | `press M`, `release M` | Change a physical M, R or L contact | | `gpio 5 0` | Hold a GPIO input low or high | +| `serial d` | Send USB serial bytes; used for bench-image investigation, never by production acceptance workflows | | `screen name.png` | Capture the visible display | | `optical name.optical` | Save emitted GPIO46 light pulses with durations in CPU cycles | | `flash name.bin` | Save the flash image | +| `vbus 0` | Set the PMIC input-voltage register; 5000 restores simulated USB power | +| `board name.txt` | Record panel sleep/on state and the shared-supply output latch | +| `audio-clear` | Clear only the host's captured PCM samples, without changing firmware or I2S registers | +| `audio name.pcm` | Save mono signed little-endian 16-bit digital PCM and its `.rate` sample-rate file | +| `peer start` | Enable the ideal optical DS status/Watts peer | +| `peer write-page 65408` | Also write/read a page containing byte `index XOR 0x5A` at the given address | +| `peer read-page 65408` | Also read and verify that page without writing it | +| `peer report name.txt` | Save peer phase, packet trace, observed Watts and any protocol error | +| `peer put path baud turnaround_us` | Run the HGSS put sequence with a public 10430-byte course | +| `peer put-compressed path baud turnaround_us` | Run HGSS put with per-page LZ10 compression | +| `peer walker path` | Run a Walker exchange using a public 64 KiB fixture | +| `peer echo on` | Illuminate the receiver with live local RMT pulses during transmission | +| `run-until-peer 15000` | Advance until peer completion/error, bounded by the given milliseconds | +| `peer stop` | Disconnect the test peer before restarting the board | | `reset` | Restart the chip while retaining flash and board devices | + +The simulator executable also accepts an optional initial flash offset and image +after its normal arguments. `check.py` uses this only for the generated flash-record +fixture, before the ROM boots. diff --git a/stick/simulation/board.rs b/stick/simulation/board.rs index 80eb5a8..f9939d5 100644 --- a/stick/simulation/board.rs +++ b/stick/simulation/board.rs @@ -1,6 +1,6 @@ use esp_periph::i2c::I2cDevice; use esp_soc::{ - board::{BoardModel, SpiPins}, + board::{BoardEdge, BoardModel, SpiPins}, devices::dcs_panel::DcsPanel, }; use std::sync::{Arc, Mutex}; @@ -11,6 +11,10 @@ pub struct Inputs { pub left_latched: bool, pub optical: Vec>, pub cycle: u64, + pub vbus_mv: Option, + pub peer: Option, + pub local_echo: bool, + pub reply_window_erases: u64, } enum Device { @@ -104,6 +108,20 @@ impl I2cDevice for Registers { fn read(&mut self) -> u8 { let reg = self.pointer as usize; let byte = match self.device { + Device::Pmic if reg == 4 => { + if self.inputs.lock().unwrap().vbus_mv.unwrap_or(5000) >= 4000 { + 1 + } else { + 4 + } + } + Device::Pmic if reg == 0x24 || reg == 0x25 => self + .inputs + .lock() + .unwrap() + .vbus_mv + .unwrap_or(5000) + .to_le_bytes()[reg - 0x24], Device::Pmic if reg == 0x48 => { let mut state = self.inputs.lock().unwrap(); let byte = u8::from(state.left) | if state.left_latched { 0x80 } else { 0 }; @@ -204,6 +222,12 @@ impl BoardModel for StickS3 { } self.panel.dc = self.levels & (1 << 45) != 0; } + fn gpio_output_at(&mut self, cycle: u64, changes: &[(u8, bool)], enabled: u64, output: u64) { + self.gpio_changes(changes); + if let Some(peer) = self.inputs.lock().unwrap().peer.as_mut() { + peer.gpio_output(cycle, enabled, output); + } + } fn i2c_devices(&mut self) -> Vec<(u8, u8, Box)> { self.devices .iter() @@ -269,24 +293,72 @@ impl BoardModel for StickS3 { _ => None, } } + fn flash_erase_at(&mut self, _cycle: u64, _offset: usize, _length: usize) { + let mut state = self.inputs.lock().unwrap(); + if state.peer.as_ref().is_some_and(|peer| peer.reply_pending()) { + state.reply_window_erases += 1; + } + } + fn rmt_symbol_at(&mut self, cycle: u64, pin: u8, pulses: &[(u64, bool)]) { + if pin != 46 || self.devices[0].1.lock().unwrap().regs[6] & 8 == 0 { + return; + } + let mut state = self.inputs.lock().unwrap(); + if state.local_echo { + if let Some(peer) = state.peer.as_mut() { + peer.local_light(cycle, pulses); + } + } + } fn rmt_waveform(&mut self, pin: u8, pulses: &[(u64, bool)]) { if pin == 46 { // The onboard transmitter draws from the PMIC's external 5 V rail. let powered = self.devices[0].1.lock().unwrap().regs[6] & 8 != 0; - let light = pulses + let light: Vec<(u64, bool)> = pulses .iter() .map(|&(cycles, high)| (cycles, high && powered)) .collect(); - self.inputs.lock().unwrap().optical.push(light); + let mut state = self.inputs.lock().unwrap(); + let cycle = state.cycle; + if let Some(peer) = state.peer.as_mut() { + peer.observe_tx(cycle, &light); + } + state.optical.push(light); } } + fn next_deadline(&self) -> Option { + self.inputs + .lock() + .unwrap() + .peer + .as_ref() + .and_then(|peer| peer.next_deadline()) + } + fn uses_gpio_waveform(&self) -> bool { + self.inputs.lock().unwrap().peer.is_some() + } fn advance_to(&mut self, cycle: u64) { - self.inputs.lock().unwrap().cycle = cycle; + let mut state = self.inputs.lock().unwrap(); + state.cycle = cycle; + if let Some(peer) = state.peer.as_mut() { + peer.advance_to(cycle); + } + } + fn take_edges(&mut self) -> Vec { + self.inputs + .lock() + .unwrap() + .peer + .as_mut() + .map_or_else(Vec::new, |peer| peer.take_edges()) } fn report(&self) -> String { format!( - "ST7789: {} pixels; on={}, sleeping={}", - self.panel.pixels_written, self.panel.on, self.panel.sleeping + "ST7789: {} pixels; on={}, sleeping={}; peripheral_supply={}", + self.panel.pixels_written, + self.panel.on, + self.panel.sleeping, + self.devices[0].1.lock().unwrap().regs[0x11] & 4 != 0 ) } } diff --git a/stick/simulation/controls.actions b/stick/simulation/controls.actions new file mode 100644 index 0000000..d463d5d --- /dev/null +++ b/stick/simulation/controls.actions @@ -0,0 +1,59 @@ +run 4000 +screen home.png +audio-clear +press M +run 30 +release M +run 220 +screen settings-menu.png +press M +run 30 +release M +run 220 +screen inventory.png +press M +run 30 +release M +run 220 +screen trainer.png +press R +run 30 +release R +run 220 +screen inventory-return.png +press R +run 30 +release R +run 220 +screen settings-return.png +audio navigation.pcm +press M +run 40 +press R +run 10 +release M +run 30 +release R +run 400 +screen rollover.png +press M +press R +run 100 +release M +release R +run 400 +screen native-settings.png +press L +press R +run 200 +release L +run 200 +screen overlay-held.png +release R +run 200 +screen overlay-released.png +press R +run 30 +release R +run 220 +screen overlay-fresh.png diff --git a/stick/simulation/fixture.py b/stick/simulation/fixture.py new file mode 100644 index 0000000..e4e4838 --- /dev/null +++ b/stick/simulation/fixture.py @@ -0,0 +1,136 @@ +"""Public synthetic EEPROM: native record layouts, original raster labels and score. + +Addresses and checksums follow include/eeprom_map.h, resources.h, records.h, +save.h, score.h and the matching firmware's EEPROM/score loaders. No save or +uploaded game artwork is imported. Every fixture uses the current flash-record format. +""" + +import struct +import zlib +from pathlib import Path + +from PIL import Image, ImageDraw, ImageFont + + +def raster(text: str, width: int, height: int = 16) -> bytes: + image = Image.new("1", (width, height)) + draw = ImageDraw.Draw(image) + font = ImageFont.load_default(size=9) + draw.text((1, 2), text, font=font, fill=1) + data = bytearray() + for page in range(0, height, 8): + for x in range(width): + column = sum(bool(image.getpixel((x, page + bit))) << bit for bit in range(8)) + data.extend((column, column)) + return bytes(data) + + +def course() -> bytes: + data = bytearray(10430) + struct.pack_into(" bytes: + image = bytearray(65536) + image[:8] = b"nintendo" + + def mirror(primary: int, backup: int, payload: bytes) -> None: + record = payload + bytes([(1 + sum(payload)) & 255]) + for address in (primary, backup): + image[address : address + len(record)] = record + + identity = b"PUBLIC SIMULATOR FIXTURE".ljust(40, b"\0") + mirror(0x83, 0x183, identity) + mirror(0x80, 0x180, bytes(2)) + mirror(0xAC, 0x1AC, bytes(64)) + mirror(0x16F, 0x26F, bytes(1)) + status = bytearray(104) + status[16:56] = identity + status[91] = 3 if companion else 1 + status[95] = 2 + mirror(0xED, 0x1ED, status) + save = bytearray(24) + struct.pack_into(">I", save, 8, 0x0D2B0B80) + struct.pack_into(">H", save, 14, watts) + save[23] = (2 << 1) | (4 << 3) + mirror(0x156, 0x256, save) + + def ui(offset: int, payload: bytes) -> None: + image[0x280 + offset : 0x280 + offset + len(payload)] = payload + + for index, digit in enumerate("0123456789"): + ui(index * 32, raster(digit, 8)) + for index, label in enumerate( + ("RADAR", "DOWSING", "CONNECT", "TRAINER", "INVENTORY", "SETTINGS") + ): + ui(1680 + index * 320, raster(label, 80)) + ui(3600 + index * 64, raster(str(index + 1), 16)) + ui(5136, raster("VOL", 40)) + ui(5296, raster("LCD", 40)) + for index, label in enumerate(("OFF", "LOW", "MAX")): + ui(5456 + index * 96, raster(label, 24)) + ui(5744, raster("X", 8)) + for direction in range(4): + for variant in range(3): + ui(632 + (direction * 3 + variant) * 16, bytes([0x3C, 0x3C] * 8)) + for index in range(68): + ui(8880 + index * 384, raster(f"MESSAGE {index}", 96)) + score = bytes((120, 0x7B, 6, 20, 0, 0x7F)) + for index in range(16): + struct.pack_into(" tuple[int, Path]: + table = partitions.read_bytes() + partition = None + for position in range(0, len(table), 32): + magic, kind, subtype, offset, size, label, flags = struct.unpack_from( + " bytes: + header = struct.pack("<5I", magic, generation, sequence, len(data), zlib.crc32(data)) + return header + struct.pack(" = std::env::args().collect(); assert!( - args.len() == 5, - "usage: stickwalker-e2e ROM.elf MERGED.bin SCRIPT OUTPUT_DIR" + args.len() == 5 || args.len() == 7, + "usage: stickwalker-e2e ROM.elf MERGED.bin SCRIPT OUTPUT_DIR [FLASH_OFFSET FLASH_IMAGE]" ); let out = PathBuf::from(&args[4]); std::fs::create_dir_all(&out).unwrap(); @@ -22,6 +23,25 @@ fn main() { machine .write_flash(0, &std::fs::read(&args[2]).unwrap()) .unwrap(); + if args.len() == 7 { + machine + .write_flash(args[5].parse().unwrap(), &std::fs::read(&args[6]).unwrap()) + .unwrap(); + } + if std::env::var_os("STICK_E2E_OPCODE_TIMING").is_some() { + machine.set_approximate_jit_timing(1, 1).unwrap(); + machine.set_approximate_jit_frontiers(true).unwrap(); + for core in &mut machine.cores { + core.price_control = true; + } + } + if let Ok(value) = std::env::var("STICK_E2E_FLASH_TIMING") { + let (program, erase) = value + .split_once(',') + .expect("flash timing: program_us,sector_erase_us"); + machine.bus.periph.spi1.program_cycles = program.parse::().unwrap() * 240; + machine.bus.periph.spi1.sector_erase_cycles = erase.parse::().unwrap() * 240; + } machine.console.capture = true; machine.boot_rom(); for line in std::fs::read_to_string(&args[3]).unwrap().lines() { @@ -30,26 +50,66 @@ fn main() { continue; } match fields.as_slice() { - ["run", ms] => { - let target = machine.bus.cycles + ms.parse::().unwrap() * 240_000; - machine.max_cycles = target; - let stop = machine.run(u64::MAX); - std::fs::write(out.join("console.txt"), &machine.console.all).unwrap(); - let console = String::from_utf8_lossy(&machine.console.all); - if !matches!(stop, Stop::Halted) - || machine.bus.cycles < target - || console.contains("PW_STICK_FATAL") - || console.contains("Guru Meditation") - { - machine - .write_tft_png(out.join("failure.png").to_str().unwrap(), 1) - .unwrap(); - std::fs::write(out.join("flash.bin"), &machine.bus.flash).unwrap(); - panic!( - "firmware stopped: {stop:?}; reset cause={}\n{console}", - machine.bus.reset_cause() - ); + ["run", ms] | ["run-until-timer-wake", ms] | ["run-until-peer", ms] => { + let deadline = machine.bus.cycles + ms.parse::().unwrap() * 240_000; + let timer_wake = fields[0] == "run-until-timer-wake"; + let peer_done = fields[0] == "run-until-peer"; + loop { + if peer_done + && inputs + .lock() + .unwrap() + .peer + .as_ref() + .is_some_and(|peer| peer.complete() || peer.error().is_some()) + { + break; + } + if timer_wake && machine.bus.periph.rtc.read(0x130) & 8 != 0 { + break; + } + if machine.bus.cycles >= deadline { + assert!(!timer_wake, "RTC timer did not wake the CPU within {ms} ms"); + break; + } + let target = if timer_wake || peer_done { + (machine.bus.cycles + 240).min(deadline) + } else { + deadline + }; + machine.max_cycles = target; + let stop = machine.run(u64::MAX); + let console = String::from_utf8_lossy(&machine.console.all); + if !matches!(stop, Stop::Halted) + || machine.bus.cycles < target + || console.contains("PW_STICK_FATAL") + || console.contains("Guru Meditation") + { + std::fs::write(out.join("console.txt"), &machine.console.all).unwrap(); + machine + .write_tft_png(out.join("failure.png").to_str().unwrap(), 1) + .unwrap(); + std::fs::write(out.join("flash.bin"), &machine.bus.flash).unwrap(); + let mut registers = format!( + "action={line}\ncycles={}\n{}", + machine.bus.cycles, + machine.dump_regs() + ); + for offset in [0x0, 0x18, 0x1c, 0x44, 0x98, 0x9c, 0xa0, 0xa4, 0xa8] { + registers += &format!( + "RTC+{offset:03x}={:08x}\n", + machine.bus.periph.rtc.read(offset) + ); + } + std::fs::write(out.join("failure-registers.txt"), registers).unwrap(); + std::fs::write(out.join("failure-sram.bin"), &machine.bus.sram).unwrap(); + panic!( + "firmware stopped: {stop:?}; reset cause={}\n{console}", + machine.bus.reset_cause() + ); + } } + std::fs::write(out.join("console.txt"), &machine.console.all).unwrap(); } ["press", key] | ["release", key] => { let down = fields[0] == "press"; @@ -78,6 +138,99 @@ fn main() { ["gpio", pin, level] => machine .bus .gpio_set_input(pin.parse().unwrap(), *level == "1"), + ["serial", text] => machine.bus.serial_input(text.as_bytes()), + ["vbus", millivolts] => { + inputs.lock().unwrap().vbus_mv = Some(millivolts.parse().unwrap()); + } + ["board", name] => { + let rtc = &mut machine.bus.periph.rtc; + let state = format!( + "{}\nrtc_wakeup={}\next1_mask={}\nmain_rtc_mux={}\nside_rtc_mux={}\n", + machine.bus.board.report(), + rtc.read(0x130), + rtc.read(0xe0) & 0x3fffff, + (rtc.read(0x484 + 11 * 4) >> 19) & 1, + (rtc.read(0x484 + 12 * 4) >> 19) & 1 + ); + std::fs::write(out.join(name), state).unwrap(); + } + ["audio-clear"] => { + machine.bus.periph.i2s0.pcm.clear(); + machine.bus.periph.i2s1.pcm.clear(); + } + ["audio", name] => { + let (samples, rate) = machine.bus.audio(); + let bytes: Vec = samples + .iter() + .flat_map(|sample| sample.to_le_bytes()) + .collect(); + std::fs::write(out.join(name), bytes).unwrap(); + std::fs::write(out.join(format!("{name}.rate")), rate.to_string()).unwrap(); + } + ["peer", "start"] => { + inputs.lock().unwrap().peer = Some(peer::OpticalPeer::new()); + } + ["peer", "echo", enabled] => { + inputs.lock().unwrap().local_echo = *enabled == "on"; + } + ["peer", "start", baud, turnaround] => { + inputs.lock().unwrap().peer = Some( + peer::OpticalPeer::new() + .timing(baud.parse().unwrap(), turnaround.parse().unwrap()), + ); + } + ["peer", "put", path] => { + inputs.lock().unwrap().peer = + Some(peer::OpticalPeer::with_put(std::fs::read(path).unwrap())); + } + ["peer", "put", path, baud, turnaround] => { + inputs.lock().unwrap().peer = Some( + peer::OpticalPeer::with_put(std::fs::read(path).unwrap()) + .timing(baud.parse().unwrap(), turnaround.parse().unwrap()), + ); + } + ["peer", "put-compressed", path, baud, turnaround] => { + inputs.lock().unwrap().peer = Some( + peer::OpticalPeer::with_compressed_put(std::fs::read(path).unwrap()) + .timing(baud.parse().unwrap(), turnaround.parse().unwrap()), + ); + } + ["peer", "walker", path] => { + inputs.lock().unwrap().peer = Some( + peer::OpticalPeer::with_walker(std::fs::read(path).unwrap()) + .timing(115760, 5000), + ); + } + ["peer", mode @ ("write-page" | "read-page"), address] => { + let data = (0..128).map(|index| (index as u8) ^ 0x5a).collect(); + inputs.lock().unwrap().peer = Some( + peer::OpticalPeer::with_page( + address.parse().unwrap(), + data, + *mode == "write-page", + ) + .unwrap(), + ); + } + ["peer", "stop"] => { + inputs.lock().unwrap().peer = None; + machine.bus.gpio_set_input(5, true); + } + ["peer", "report", name] => { + let state = inputs.lock().unwrap(); + let peer = state.peer.as_ref().expect("peer has not been started"); + std::fs::write( + out.join(name), + format!( + "complete={} error={:?}\nflash_erases_during_reply_window={}\n{}", + peer.complete(), + peer.error(), + state.reply_window_erases, + peer.report() + ), + ) + .unwrap(); + } ["screen", name] => machine .write_tft_png(out.join(name).to_str().unwrap(), 1) .unwrap(), diff --git a/stick/simulation/peer-play.actions b/stick/simulation/peer-play.actions new file mode 100644 index 0000000..0e36ec4 --- /dev/null +++ b/stick/simulation/peer-play.actions @@ -0,0 +1,25 @@ +run 4000 +peer walker PUBLIC_FIXTURE +peer echo on +press M +press R +run 100 +release M +release R +run 300 +press M +press R +run 100 +release M +release R +run-until-peer 15000 +peer report peer.txt +run 1000 +peer stop +reset +run 4000 +press L +run 100 +release L +run 200 +screen settings-after.png diff --git a/stick/simulation/peer-storage.actions b/stick/simulation/peer-storage.actions new file mode 100644 index 0000000..f703258 --- /dev/null +++ b/stick/simulation/peer-storage.actions @@ -0,0 +1,38 @@ +run 4000 +peer write-page 65408 +press M +press R +run 100 +release M +release R +run 300 +press M +press R +run 100 +release M +release R +run 4000 +peer report written.txt +peer stop +reset +run 4000 +peer read-page 65408 +press M +press R +run 100 +release M +release R +run 300 +press M +press R +run 100 +release M +release R +run 4000 +peer report restored.txt +optical peer.optical +press L +run 100 +release L +run 200 +screen settings-after.png diff --git a/stick/simulation/peer-transfer-compressed.actions b/stick/simulation/peer-transfer-compressed.actions new file mode 100644 index 0000000..4a9344c --- /dev/null +++ b/stick/simulation/peer-transfer-compressed.actions @@ -0,0 +1,25 @@ +run 4000 +peer put-compressed PUBLIC_FIXTURE 115760 5000 +peer echo on +press M +press R +run 100 +release M +release R +run 300 +press M +press R +run 100 +release M +release R +run-until-peer 15000 +peer report peer.txt +run 1000 +peer stop +reset +run 4000 +press L +run 100 +release L +run 200 +screen settings-after.png diff --git a/stick/simulation/peer-transfer.actions b/stick/simulation/peer-transfer.actions new file mode 100644 index 0000000..d882bf1 --- /dev/null +++ b/stick/simulation/peer-transfer.actions @@ -0,0 +1,25 @@ +run 4000 +peer put PUBLIC_FIXTURE 115760 5000 +peer echo on +press M +press R +run 100 +release M +release R +run 300 +press M +press R +run 100 +release M +release R +run-until-peer 15000 +peer report peer.txt +run 1000 +peer stop +reset +run 4000 +press L +run 100 +release L +run 200 +screen settings-after.png diff --git a/stick/simulation/peer.actions b/stick/simulation/peer.actions new file mode 100644 index 0000000..0346d93 --- /dev/null +++ b/stick/simulation/peer.actions @@ -0,0 +1,22 @@ +run 4000 +peer start +press M +press R +run 100 +release M +release R +run 300 +press M +press R +run 100 +release M +release R +run 4000 +peer report peer.txt +optical peer.optical +screen completed.png +press L +run 100 +release L +run 200 +screen settings-after.png diff --git a/stick/simulation/peer.rs b/stick/simulation/peer.rs new file mode 100644 index 0000000..f4b97af --- /dev/null +++ b/stick/simulation/peer.rs @@ -0,0 +1,780 @@ +use esp_soc::board::BoardEdge; +use std::collections::VecDeque; + +const CPU_HZ: u64 = 240_000_000; +const BAUD: u64 = 115_200; +const TURNAROUND: u64 = CPU_HZ / 1000; +const TIMEOUT: u64 = CPU_HZ / 10; +const FIXTURE_WATTS: [u8; 2] = [0, 100]; +const TOKEN: [u8; 4] = [0x13, 0x57, 0x9b, 0xdf]; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum Phase { + Probe, + Acknowledgment, + Status, + Watts, + PutAccepted, + PutPage, + PutRead, + PutPing, + PutCommitted, + WalkerStatus, + WalkerWrite, + WalkerRead, + WalkerInfo, + WalkerStart, + PageWritten, + PageRead, + Shutdown, + Complete, + Failed, +} + +#[derive(Clone, Copy)] +struct LightPulse { + start: u64, + end: u64, +} + +struct Page { + address: u16, + bytes: Vec, + write: bool, +} + +struct Put { + compressed: bool, + bytes: Vec, + offset: usize, + records: Vec, + record_offset: usize, + last_address: u16, + read_segment: usize, + read_offset: usize, + read_count: usize, +} + +// The matching firmware exchanges animation, name, trainer-house records and +// PeerInfo in this order. Addresses and sizes come from its public record map. +const WALKER_SEGMENTS: [(usize, usize, usize); 3] = [ + (0x91be, 0xf400, 384), + (0x993e, 0xf580, 320), + (0xcc00, 0xdc00, 548), +]; + +struct Walker { + image: Vec, + expected: Vec, + segment: usize, + offset: usize, + chunk: usize, +} + +pub struct OpticalPeer { + phase: Phase, + now: u64, + session: [u8; 4], + timeout: Option, + transmit_end: u64, + pulses: VecDeque, + output_enabled: bool, + output_high: bool, + charged: bool, + edges: Vec, + failure: Option, + trace: String, + watts: Option<[u8; 2]>, + page: Option, + page_verified: bool, + received_frames: usize, + transmitted_frames: usize, + charge_count: u64, + discharge_count: u64, + baud: u64, + turnaround: u64, + echoes: VecDeque, + put: Option, + status: Vec, + walker: Option, +} + +impl OpticalPeer { + pub fn new() -> Self { + Self { + phase: Phase::Probe, + now: 0, + session: [0; 4], + timeout: None, + transmit_end: 0, + pulses: VecDeque::new(), + output_enabled: false, + output_high: false, + charged: false, + edges: Vec::new(), + failure: None, + trace: String::new(), + watts: None, + page: None, + page_verified: false, + received_frames: 0, + transmitted_frames: 0, + charge_count: 0, + discharge_count: 0, + baud: BAUD, + turnaround: TURNAROUND, + echoes: VecDeque::new(), + put: None, + status: Vec::new(), + walker: None, + } + } + + pub fn timing(mut self, baud: u64, turnaround_us: u64) -> Self { + assert!((110_000..=120_000).contains(&baud)); + self.baud = baud; + self.turnaround = turnaround_us * (CPU_HZ / 1_000_000); + self + } + + pub fn with_put(bytes: Vec) -> Self { + assert_eq!(bytes.len(), 10_430); + let mut peer = Self::new(); + peer.put = Some(Put { + compressed: false, + bytes, + offset: 0, + records: vec![0; 548], + record_offset: 0, + last_address: 0, + read_segment: 0, + read_offset: 0, + read_count: 0, + }); + peer + } + + pub fn with_compressed_put(bytes: Vec) -> Self { + let mut peer = Self::with_put(bytes); + peer.put.as_mut().unwrap().compressed = true; + peer + } + + pub fn with_walker(mut image: Vec) -> Self { + assert_eq!(image.len(), 65536); + let expected = image.clone(); + let mut identity = [0; 40]; + identity[..21].copy_from_slice(b"PUBLIC OPTICAL WALKER"); + image[0xed + 16..0xed + 56].copy_from_slice(&identity); + image[0xcc08..0xcc30].copy_from_slice(&identity); + for &(source, _, length) in &WALKER_SEGMENTS[..2] { + for byte in &mut image[source..source + length] { + *byte ^= 0x5a; + } + } + let mut peer = Self::new(); + peer.walker = Some(Walker { + image, + expected, + segment: 0, + offset: 0, + chunk: 0, + }); + peer + } + + fn walker_write(&mut self) { + let walker = self.walker.as_mut().unwrap(); + if walker.segment == WALKER_SEGMENTS.len() { + walker.segment = 0; + walker.offset = 0; + self.walker_read(); + return; + } + let (source, destination, length) = WALKER_SEGMENTS[walker.segment]; + walker.chunk = (length - walker.offset).min(128); + let bytes = + walker.image[source + walker.offset..source + walker.offset + walker.chunk].to_vec(); + let address = destination + walker.offset; + self.phase = Phase::WalkerWrite; + self.send( + 2 | (address as u8 & 0x80), + (address >> 8) as u8, + self.session, + &bytes, + ); + } + + fn walker_read(&mut self) { + let walker = self.walker.as_mut().unwrap(); + if walker.segment == WALKER_SEGMENTS.len() { + let mut info = vec![0; 56]; + info[..4].copy_from_slice(&1000u32.to_be_bytes()); + info[4..6].copy_from_slice(&10u16.to_be_bytes()); + info[14..16].copy_from_slice(&25u16.to_le_bytes()); + self.phase = Phase::WalkerInfo; + self.send(0x14, 2, self.session, &info); + return; + } + let (source, _, length) = WALKER_SEGMENTS[walker.segment]; + walker.chunk = (length - walker.offset).min(128); + let address = (source + walker.offset) as u16; + let count = walker.chunk as u8; + self.phase = Phase::WalkerRead; + self.send( + 0x0c, + 2, + self.session, + &[(address >> 8) as u8, address as u8, count], + ); + } + + fn walker_advance(&mut self) { + let walker = self.walker.as_mut().unwrap(); + walker.offset += walker.chunk; + if walker.offset == WALKER_SEGMENTS[walker.segment].2 { + walker.segment += 1; + walker.offset = 0; + } + } + + fn put_page(&mut self) { + let put = self.put.as_mut().unwrap(); + if put.offset == put.bytes.len() && put.record_offset == put.records.len() { + self.put_read(); + } else { + let (address, bytes) = if put.offset < put.bytes.len() { + let address = 0xd700 + put.offset; + let end = (put.offset + 128).min(put.bytes.len()); + let bytes = put.bytes[put.offset..end].to_vec(); + put.offset = end; + (address, bytes) + } else { + let address = 0xd480 + put.record_offset; + let end = (put.record_offset + 128).min(put.records.len()); + let bytes = put.records[put.record_offset..end].to_vec(); + put.record_offset = end; + (address, bytes) + }; + put.last_address = address as u16; + let mut command = 0x02 | (address as u8 & 0x80); + let mut bytes = bytes; + if put.compressed { + // HGSS compresses complete pages independently. Its size table + // marks the final partial page as an uncompressed 128-byte page. + let complete_page = bytes.len() == 128; + bytes.resize(128, 0); + let packed = compress_lz_page(&bytes); + if complete_page && packed.len() < 128 { + command &= 0x80; + bytes = packed; + } + } + self.phase = Phase::PutPage; + self.send(command, (address >> 8) as u8, self.session, &bytes); + } + } + + fn put_read(&mut self) { + // HGSS phc_lib.c: course, trainer record, PHCData, distribution data, + // checksum/ping, put commit and one-way shutdown. + const READS: [(u16, usize); 2] = [(0xce80, 3404), (0xb800, 1736)]; + let put = self.put.as_mut().unwrap(); + if put.read_segment == READS.len() { + self.phase = Phase::PutPing; + self.send(0x24, 1, self.session, &[]); + } else { + let (base, length) = READS[put.read_segment]; + let address = base + put.read_offset as u16; + put.read_count = (length - put.read_offset).min(128); + let count = put.read_count as u8; + self.phase = Phase::PutRead; + self.send( + 0x0c, + 2, + self.session, + &[(address >> 8) as u8, address as u8, count], + ); + } + } + + pub fn with_page(address: u16, bytes: Vec, write: bool) -> Result { + if address & 0x7f != 0 || bytes.len() != 128 { + return Err("peer page requires an aligned address and exactly 128 bytes".into()); + } + let mut peer = Self::new(); + peer.page = Some(Page { + address, + bytes, + write, + }); + Ok(peer) + } + + fn fail(&mut self, reason: String) { + if self.failure.is_none() { + self.failure = Some(format!( + "cycle={} phase={:?}: {reason}", + self.now, self.phase + )); + } + self.phase = Phase::Failed; + self.timeout = None; + self.pulses.clear(); + } + + fn edge(&mut self, cycle: u64, level: bool) { + if self.charged != level { + self.charged = level; + self.edges.push(BoardEdge { + cycle, + pin: 5, + level, + }); + if !level { + self.discharge_count += 1; + } + } + } + + fn discharge_deadline(&self) -> Option { + if self.output_enabled || !self.charged { + return None; + } + [self.pulses.front(), self.echoes.front()] + .into_iter() + .flatten() + .map(|pulse| pulse.start.max(self.now)) + .min() + } + + pub fn reply_pending(&self) -> bool { + !matches!( + self.phase, + Phase::Probe | Phase::Shutdown | Phase::Complete | Phase::Failed + ) + } + + pub fn local_light(&mut self, cycle: u64, pulses: &[(u64, bool)]) { + let mut start = cycle; + for &(duration, high) in pulses { + if high && duration != 0 { + self.echoes.push_back(LightPulse { + start, + end: start + duration, + }); + } + start += duration; + } + } + + pub fn gpio_output(&mut self, cycle: u64, enabled: u64, output: u64) { + self.advance_to(cycle); + let enabled = enabled & (1 << 5) != 0; + let high = output & (1 << 5) != 0; + if enabled && high && (!self.output_enabled || !self.output_high) { + self.charge_count += 1; + } + self.output_enabled = enabled; + self.output_high = high; + if enabled { + self.edge(cycle, high); + } else if self.charged + && [self.pulses.front(), self.echoes.front()] + .into_iter() + .flatten() + .any(|pulse| pulse.start <= cycle && pulse.end > cycle) + { + self.edge(cycle, false); + } + } + + pub fn advance_to(&mut self, cycle: u64) { + if cycle < self.now { + self.fail("board clock reset without resetting peer session".into()); + return; + } + if let Some(discharge) = self.discharge_deadline() { + if discharge <= cycle { + self.edge(discharge, false); + } + } + self.now = cycle; + while self.pulses.front().is_some_and(|pulse| pulse.end <= cycle) { + self.pulses.pop_front(); + } + while self.echoes.front().is_some_and(|pulse| pulse.end <= cycle) { + self.echoes.pop_front(); + } + if self.phase == Phase::Shutdown && cycle >= self.transmit_end { + self.phase = Phase::Complete; + self.timeout = None; + } + if self.timeout.is_some_and(|deadline| cycle >= deadline) { + self.fail("DS-side 100 ms response timeout".into()); + } + } + + pub fn next_deadline(&self) -> Option { + [ + self.discharge_deadline(), + self.timeout, + (self.phase == Phase::Shutdown).then_some(self.transmit_end), + ] + .into_iter() + .flatten() + .filter(|deadline| *deadline > self.now) + .min() + } + + pub fn take_edges(&mut self) -> Vec { + std::mem::take(&mut self.edges) + } + + fn send(&mut self, command: u8, argument: u8, token: [u8; 4], payload: &[u8]) { + if self.now < self.transmit_end { + self.fail("peer reply overlaps an unfinished transmission".into()); + return; + } + let mut packet = vec![command, argument, 0, 0]; + packet.extend_from_slice(&token); + packet.extend_from_slice(payload); + let checksum = checksum(&packet).to_le_bytes(); + packet[2..4].copy_from_slice(&checksum); + self.trace += &format!( + "{} {} {}\n", + self.now, + if self.walker.is_some() { "PEER" } else { "DS" }, + hex(&packet) + ); + let start = self.now + self.turnaround; + self.pulses.clear(); + for (index, byte) in packet.iter().enumerate() { + let wire = byte ^ 0xaa; + for bit in 0..10 { + let zero = bit == 0 || (bit < 9 && wire & (1 << (bit - 1)) == 0); + if zero { + let start = start + (index as u64 * 10 + bit) * CPU_HZ / self.baud; + self.pulses.push_back(LightPulse { + start, + end: start + CPU_HZ * 3 / (self.baud * 16), + }); + } + } + } + self.transmit_end = start + packet.len() as u64 * 10 * CPU_HZ / self.baud; + self.timeout = Some(self.now + TIMEOUT); + self.transmitted_frames += 1; + } + + fn read_page(&mut self) { + let address = self.page.as_ref().unwrap().address.to_be_bytes(); + self.phase = Phase::PageRead; + self.send(0x0c, 2, self.session, &[address[0], address[1], 128]); + } + + fn shutdown(&mut self) { + self.phase = Phase::Shutdown; + self.send(0xf4, 1, self.session, &[]); + } + + pub fn observe_tx(&mut self, cycle: u64, pulses: &[(u64, bool)]) { + self.advance_to(cycle); + if self.phase == Phase::Failed { + return; + } + let packet = match decode(pulses) { + Ok(packet) => packet, + Err(error) => { + self.fail(error); + return; + } + }; + self.received_frames += 1; + self.trace += &format!("{} WALKER {}\n", cycle, hex(&packet)); + if self.now < self.transmit_end { + self.fail("Walker transmission collides with DS transmission".into()); + return; + } + if packet == [0xfc] && self.phase == Phase::Probe { + self.phase = Phase::Acknowledgment; + self.send(0xfa, if self.walker.is_some() { 2 } else { 1 }, TOKEN, &[]); + return; + } + if packet.len() < 8 { + self.fail(format!("expected packet header, received {}", hex(&packet))); + return; + } + let received = u16::from_le_bytes([packet[2], packet[3]]); + let mut checked = packet.clone(); + checked[2..4].fill(0); + if checksum(&checked) != received { + self.fail("invalid Walker packet checksum".into()); + return; + } + let command = packet[0]; + let argument = packet[1]; + let token: [u8; 4] = packet[4..8].try_into().unwrap(); + let payload = &packet[8..]; + if self.phase != Phase::Acknowledgment && token != self.session { + self.fail("reply has a different session token".into()); + return; + } + match self.phase { + Phase::Acknowledgment if command == 0xf8 && argument == 2 && payload.is_empty() => { + for index in 0..4 { + self.session[index] = TOKEN[index] ^ token[index]; + } + if let Some(walker) = &self.walker { + let status = walker.image[0xed..0xed + 104].to_vec(); + self.phase = Phase::WalkerStatus; + self.send(0x10, 2, self.session, &status); + } else { + self.phase = Phase::Status; + self.send(0x20, 1, self.session, &[]); + } + } + Phase::Status if command == 0x22 && argument == 2 && payload.len() == 104 => { + self.status = payload.to_vec(); + if let Some(put) = self.put.as_mut() { + put.records[8..48].copy_from_slice(&payload[16..56]); + } + self.phase = Phase::Watts; + self.send(0x0c, 2, self.session, &[0xce, 0x8a, 2]); + } + Phase::Watts if command == 0x0e && argument == 2 && payload.len() == 2 => { + self.watts = Some(payload.try_into().unwrap()); + if payload != FIXTURE_WATTS { + self.fail(format!( + "expected fixture Watts 0064, received {}", + hex(payload) + )); + return; + } + if self.put.is_some() { + self.phase = Phase::PutAccepted; + self.send(0x52, 1, self.session, &self.status.clone()); + } else if let Some(page) = &self.page { + if page.write { + let address = page.address; + let bytes = page.bytes.clone(); + self.phase = Phase::PageWritten; + self.send( + 0x02 | (address as u8 & 0x80), + (address >> 8) as u8, + self.session, + &bytes, + ); + } else { + self.read_page(); + } + } else { + self.shutdown(); + } + } + Phase::PageWritten + if command == 4 + && payload.is_empty() + && argument == (self.page.as_ref().unwrap().address >> 8) as u8 => + { + self.read_page(); + } + Phase::PageRead if command == 0x0e && argument == 2 && payload.len() == 128 => { + if payload != self.page.as_ref().unwrap().bytes { + self.fail(format!("page readback differs: {}", hex(payload))); + return; + } + self.page_verified = true; + self.shutdown(); + } + Phase::PutAccepted if command == 0x54 && argument == 2 && payload.is_empty() => { + self.put_page(); + } + Phase::PutPage if command == 4 && payload.is_empty() => { + if argument != (self.put.as_ref().unwrap().last_address >> 8) as u8 { + self.fail("put page acknowledgment has the wrong address".into()); + return; + } + self.put_page(); + } + Phase::PutCommitted if command == 0x5a && argument == 2 && payload.is_empty() => { + self.shutdown(); + } + Phase::PutRead if command == 0x0e && argument == 2 => { + let put = self.put.as_mut().unwrap(); + if payload.len() != put.read_count { + self.fail("DS PHC/distribution read has the wrong length".into()); + return; + } + if put.read_segment == 0 && put.read_offset == 0 && payload[10..12] != FIXTURE_WATTS + { + self.fail("DS PHCData contains incorrectly ordered Watts".into()); + return; + } + put.read_offset += put.read_count; + let length = if put.read_segment == 0 { 3404 } else { 1736 }; + if put.read_offset == length { + put.read_segment += 1; + put.read_offset = 0; + } + self.put_read(); + } + Phase::PutPing if command == 0x26 && argument == 2 && payload.is_empty() => { + self.phase = Phase::PutCommitted; + self.send(0x5a, 1, self.session, &[]); + } + Phase::WalkerStatus if command == 0x12 && argument == 2 && payload.len() == 104 => { + if payload[91] & 3 != 3 { + self.fail("Walker peer play requires registration and a companion".into()); + return; + } + self.walker_write(); + } + Phase::WalkerWrite if command == 4 && payload.is_empty() => { + let walker = self.walker.as_ref().unwrap(); + let address = WALKER_SEGMENTS[walker.segment].1 + walker.offset; + if argument != (address >> 8) as u8 { + self.fail("peer page acknowledgment has the wrong address".into()); + return; + } + self.walker_advance(); + self.walker_write(); + } + Phase::WalkerRead if command == 0x0e && argument == 2 => { + let walker = self.walker.as_ref().unwrap(); + let source = WALKER_SEGMENTS[walker.segment].0 + walker.offset; + if payload != &walker.expected[source..source + walker.chunk] { + self.fail("peer animation/name/records differ from the public fixture".into()); + return; + } + self.walker_advance(); + self.walker_read(); + } + Phase::WalkerInfo if command == 0x14 && argument == 2 && payload.len() == 56 => { + self.phase = Phase::WalkerStart; + self.send(0x16, 2, self.session, &[]); + } + Phase::WalkerStart if command == 0x16 && argument == 2 && payload.is_empty() => { + self.phase = Phase::Complete; + self.timeout = None; + } + _ => self.fail(format!( + "unexpected command {command:02x}, argument {argument:02x}, payload length {}", + payload.len() + )), + } + } + + pub fn complete(&self) -> bool { + self.phase == Phase::Complete + } + + pub fn error(&self) -> Option<&str> { + self.failure.as_deref() + } + + pub fn report(&self) -> String { + format!( + "model=ideal-sir-charge-discharge\nphase={:?}\ncomplete={}\nerror={}\nwatts={}\npage_verified={}\nreceived_frames={}\ntransmitted_frames={}\ncharges={}\ndischarges={}\n{}", + self.phase, self.complete(), self.error().unwrap_or("none"), + self.watts.as_ref().map_or_else(|| "none".into(), |bytes| hex(bytes)), + self.page_verified, self.received_frames, self.transmitted_frames, + self.charge_count, self.discharge_count, self.trace, + ) + } +} + +fn checksum(bytes: &[u8]) -> u16 { + let mut sum = 2u32; + for (index, byte) in bytes.iter().enumerate() { + sum += u32::from(*byte) << if index & 1 == 0 { 8 } else { 0 }; + } + sum = (sum & 0xffff) + (sum >> 16); + sum += sum >> 16; + sum as u16 +} + +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|byte| format!("{byte:02x}")).collect() +} + +fn decode(pulses: &[(u64, bool)]) -> Result, String> { + let duration: u64 = pulses.iter().map(|(cycles, _)| cycles).sum(); + let cells = (duration * BAUD + CPU_HZ / 2) / CPU_HZ; + if cells == 0 || cells % 10 != 0 || cells > 136 * 10 { + return Err(format!("invalid Walker UART duration: {duration} cycles")); + } + let mut bits = vec![true; cells as usize]; + let mut elapsed = 0; + for &(cycles, high) in pulses { + if high { + let index = (elapsed * BAUD + CPU_HZ / 2) / CPU_HZ; + let phase_error = (i128::from(elapsed) * i128::from(BAUD) + - i128::from(index) * i128::from(CPU_HZ)) + .abs(); + if index >= cells + || phase_error > i128::from(CPU_HZ / 20) + || cycles * BAUD < CPU_HZ / 10 + || cycles * BAUD > CPU_HZ * 3 / 10 + { + return Err(format!("invalid Walker SIR light pulse at cycle {elapsed}")); + } + bits[index as usize] = false; + } + elapsed += cycles; + } + let mut result = Vec::new(); + for byte in bits.chunks_exact(10) { + if byte[0] || !byte[9] { + return Err("invalid Walker UART start/stop bits".into()); + } + let wire = byte[1..9] + .iter() + .enumerate() + .fold(0, |value, (bit, high)| value | (u8::from(*high) << bit)); + result.push(wire ^ 0xaa); + } + Ok(result) +} + +// Nintendo LZ10: an advertised 128-byte page, MSB-first flags, and 3..18-byte +// phrases from earlier output. The peer chooses raw transfer when it grows. +fn compress_lz_page(bytes: &[u8]) -> Vec { + assert_eq!(bytes.len(), 128); + let mut packed = vec![0x10, 128, 0, 0]; + let mut cursor = 0; + while cursor < bytes.len() { + let flags = packed.len(); + packed.push(0); + for bit in 0..8 { + if cursor == bytes.len() { + break; + } + let mut best_length = 0; + let mut best_distance = 0; + for distance in 1..=cursor { + let mut length = 0; + while length < 18 + && cursor + length < bytes.len() + && bytes[cursor + length] == bytes[cursor + length - distance] + { + length += 1; + } + if length > best_length { + best_length = length; + best_distance = distance; + } + } + if best_length >= 3 { + packed[flags] |= 0x80 >> bit; + packed.push(((best_length - 3) << 4) as u8); + packed.push((best_distance - 1) as u8); + cursor += best_length; + } else { + packed.push(bytes[cursor]); + cursor += 1; + } + } + } + packed +} diff --git a/stick/simulation/recovery.actions b/stick/simulation/recovery.actions new file mode 100644 index 0000000..ca87ee9 --- /dev/null +++ b/stick/simulation/recovery.actions @@ -0,0 +1,13 @@ +run 4000 +press M +run 30 +release M +run 300 +screen before-reset.png +reset +run 4000 +press M +run 30 +release M +run 300 +screen after-reset.png diff --git a/stick/simulation/soc.patch b/stick/simulation/soc.patch index f2c8dad..4596f62 100644 --- a/stick/simulation/soc.patch +++ b/stick/simulation/soc.patch @@ -11,7 +11,7 @@ index 67738ae..e1f8c73 100644 fn read16(&mut self, addr: u32) -> Result; fn read32(&mut self, addr: u32) -> Result; diff --git a/esp-periph/src/rmt.rs b/esp-periph/src/rmt.rs -index ad89f33..dee114f 100644 +index ad89f33..b44ab63 100644 --- a/esp-periph/src/rmt.rs +++ b/esp-periph/src/rmt.rs @@ -14,6 +14,7 @@ pub struct RmtTxCh { @@ -22,21 +22,22 @@ index ad89f33..dee114f 100644 } pub struct Rmt { pub ch: [RmtTxCh; 4], -@@ -23,11 +24,12 @@ pub struct Rmt { +@@ -23,11 +24,13 @@ pub struct Rmt { /// completed transmissions: (channel, bits) pub done: Vec<(usize, Vec)>, pub tx_count: u64, + pub waveforms: Vec<(usize, Vec<(u64, bool)>)>, ++ pub symbols: Vec<(usize, i64, [(u64, bool); 2])>, cpu_per_apb: i64, running: u8, } impl Rmt { - pub fn new(cpu_hz: u64) -> Self { Rmt { running: 0, cpu_per_apb: (cpu_hz / crate::APB_HZ) as i64, ch: Default::default(), mem: [0; RMT_MEM_WORDS * 8], int_raw: 0, int_ena: 0, sys_conf: 0, ram: RegRam::new(), done: Vec::new(), tx_count: 0 } } -+ pub fn new(cpu_hz: u64) -> Self { Rmt { running: 0, cpu_per_apb: (cpu_hz / crate::APB_HZ) as i64, ch: Default::default(), mem: [0; RMT_MEM_WORDS * 8], int_raw: 0, int_ena: 0, sys_conf: 0, ram: RegRam::new(), done: Vec::new(), tx_count: 0, waveforms: Vec::new() } } ++ pub fn new(cpu_hz: u64) -> Self { Rmt { running: 0, cpu_per_apb: (cpu_hz / crate::APB_HZ) as i64, ch: Default::default(), mem: [0; RMT_MEM_WORDS * 8], int_raw: 0, int_ena: 0, sys_conf: 0, ram: RegRam::new(), done: Vec::new(), tx_count: 0, symbols: Vec::new(), waveforms: Vec::new() } } pub fn is_running(&self) -> bool { self.running != 0 } pub fn irq(&self) -> bool { self.int_raw & self.int_ena != 0 } pub fn read(&self, off: u32) -> u32 { -@@ -52,7 +54,7 @@ impl Rmt { +@@ -52,7 +55,7 @@ impl Rmt { c.conf0 = v & !(1 << 7); // TX_STOP is a command, not state: a read-modify-write after a stop must not stop the next start if v & (1 << 2) != 0 { c.wr = 0; } // APB_MEM_RST if v & (1 << 1) != 0 { c.rd = 0; c.mem_empty = false; c.end_pending = false; } // MEM_RD_RST @@ -45,7 +46,7 @@ index ad89f33..dee114f 100644 if v & (1 << 7) != 0 { c.running = false; } // TX_STOP self.running = (self.running & !(1 << n)) | ((c.running as u8) << n); } -@@ -84,7 +86,7 @@ impl Rmt { +@@ -84,7 +87,7 @@ impl Rmt { let progressed = c.rd != 0; // Continuous output is not a completed WS2812 frame. Retain only // the current lap, so a buzzer cannot accumulate host memory. @@ -54,7 +55,7 @@ index ad89f33..dee114f 100644 c.rd = 0; if c.tx_lim & (1 << 19) != 0 { c.loop_count += 1; -@@ -101,6 +103,7 @@ impl Rmt { +@@ -101,6 +104,7 @@ impl Rmt { self.int_raw |= 1 << n; self.tx_count += 1; self.done.push((n, std::mem::take(&mut c.bits))); @@ -62,7 +63,7 @@ index ad89f33..dee114f 100644 break; } // No-wrap exhaustion is an empty-memory error, not an end marker -@@ -112,7 +115,7 @@ impl Rmt { +@@ -112,7 +116,7 @@ impl Rmt { self.int_raw |= 1 << (4 + n); break; } @@ -71,16 +72,240 @@ index ad89f33..dee114f 100644 let sym = self.mem[base + (c.rd % mem_words)]; let (d0, l0, d1, l1) = ((sym & 0x7fff) as i64, sym & 0x8000 != 0, ((sym >> 16) & 0x7fff) as i64, sym & 0x8000_0000 != 0); if d0 == 0 { // end marker -@@ -123,6 +126,9 @@ impl Rmt { +@@ -123,6 +127,10 @@ impl Rmt { let high = if l0 { d0 } else { 0 } + if l1 { d1 } else { 0 }; let low = if !l0 { d0 } else { 0 } + if !l1 { d1 } else { 0 }; c.bits.push(high > low); + c.pulses.push(((d0 * cycles_per_tick) as u64, l0)); + if d1 != 0 { c.pulses.push(((d1 * cycles_per_tick) as u64, l1)); } + ++ self.symbols.push((n, c.acc_cycles, [((d0 * cycles_per_tick) as u64, l0), ((d1 * cycles_per_tick) as u64, l1)])); c.acc_cycles -= (d0 + d1) * cycles_per_tick; c.rd += 1; c.since_thr += 1; +diff --git a/esp-periph/src/rtc_cntl.rs b/esp-periph/src/rtc_cntl.rs +index 5c625c1..16e17a0 100644 +--- a/esp-periph/src/rtc_cntl.rs ++++ b/esp-periph/src/rtc_cntl.rs +@@ -15,6 +15,7 @@ pub fn reset_cause_name(c: u32) -> &'static str { + /// WDTCONFIG0..WDTWPROTECT live at 0x98..0xb0 on S3 and 0x90..0xa8 on C3. + /// `esp_restart()` on ESP-IDF 5.x arms this watchdog and spins until it resets the chip. + pub struct RtcCntl { pub ram: RegRam, pub slow_ticks: u64, pub time_latch: u64, pub sw_reset: bool, pub reset_cause: u32, ++ pub sleeping: bool, pub gpio_input: u32, alarm_armed: bool, + wdt_base: u32, wdt_count: u64, wdt_stage: usize, wdt_unlocked: bool, + /// S3 only: the SENS block at +0x800 converts on SAR_MEAS1_START (see `sens_meas`); the chip refreshes + /// `now_cycles` before each access so a waveform source is sampled at the right emulated time. +@@ -26,6 +27,7 @@ impl RtcCntl { + pub fn wdt_tick(&mut self, ticks: u64) { + let conf0 = self.ram.read(self.wdt_base); + if conf0 & (1 << 31) == 0 { return; } ++ if self.sleeping && conf0 & (1 << 9) != 0 { return; } + self.wdt_count += ticks; + while self.wdt_stage < 4 { + let timeout = self.ram.read(self.wdt_base + 4 + 4 * self.wdt_stage as u32) as u64; +@@ -48,6 +50,7 @@ impl RtcCntl { + pub fn new_c3() -> Self { Self::with_wdt_base(0x90) } + fn with_wdt_base(wdt_base: u32) -> Self { + let mut r = RtcCntl { ram: RegRam::new(), slow_ticks: 0, time_latch: 0, sw_reset: false, reset_cause: RST_POWERON, wdt_base, wdt_count: 0, wdt_stage: 0, wdt_unlocked: false, ++ sleeping: false, gpio_input: 0x3fffff, alarm_armed: false, + sens_adc: false, analog: crate::analog::AnalogInputs::new(240_000_000), now_cycles: 0 }; + r.ram.write(0x38, 1 | (1 << 6)); // RESET_STATE: reset cause POWERON for both CPUs + r.ram.write(0x74, 0); // CLK_CONF +@@ -55,6 +58,9 @@ impl RtcCntl { + } + pub fn read(&mut self, off: u32) -> u32 { + match off { ++ 0x48 => self.ram.read(0x44) & self.ram.read(0x40), ++ 0x4c => 0, ++ 0x424 if self.sens_adc => (self.gpio_input & 0x3fffff) << 10, + 0x10 => self.time_latch as u32, 0x14 => (self.time_latch >> 32) as u32, + 0xc => self.ram.read(off) | (1 << 30), // TIME_UPDATE: valid + 0x1fc => 0x2007270, +@@ -66,7 +72,36 @@ impl RtcCntl { + let wdt = self.wdt_base; + match off { + 0x0 => { if v & (1 << 31) != 0 { self.request_reset(RST_SW_SYS); } else if v & (1 << 5) != 0 { self.request_reset(RST_SW_CPU); } self.ram.write(off, v & !((1 << 31) | (1 << 5))); } // OPTIONS0.SW_SYS_RST / SW_PROCPU_RST ++ 0x8 if self.sens_adc => { ++ self.ram.write(off, v & 0xffff); ++ if v & (1 << 16) != 0 { self.alarm_armed = true; self.update_alarm(); } ++ } + 0xc => { if v & (1 << 31) != 0 { self.time_latch = self.slow_ticks; } self.ram.write(off, v); } ++ 0x18 if self.sens_adc => { ++ if v & 2 != 0 { self.ram.write(0x128, 0); } ++ self.ram.write(off, v & !2); ++ if v & (1 << 31) != 0 && !self.sleeping && self.ram.read(0x90) & (1 << 31) == 0 { ++ self.update_alarm(); ++ let sources = self.wake_sources(); ++ let reject_conf = self.ram.read(0x68); ++ let rejected = sources & ((reject_conf >> 12) & 0x3ffff); ++ if reject_conf & (1 << 30) != 0 && rejected != 0 { ++ self.ram.write(0x128, rejected); ++ self.ram.write(0x44, self.ram.read(0x44) | 2); ++ self.ram.write(0x18, self.ram.read(0x18) & !(1 << 31)); ++ } else { ++ self.sleeping = true; ++ self.ram.write(0x130, 0); ++ self.check_wakeup(); ++ } ++ } ++ } ++ 0x44 | 0x48 => {} ++ 0x4c => self.ram.write(0x44, self.ram.read(0x44) & !v), ++ 0xe0 if self.sens_adc => { ++ if v & (1 << 22) != 0 { self.ram.write(0xe4, 0); } ++ self.ram.write(off, v & !(1 << 22)); ++ } + 0x80c | 0x830 if self.sens_adc => self.sens_meas(off, v), + _ if off == wdt + 0x18 => { self.wdt_unlocked = v == 0x50D8_3AA1; self.ram.write(off, v); } + _ if (wdt..=wdt + 0x10).contains(&off) => { if self.wdt_unlocked { if off == wdt && (v ^ self.ram.read(wdt)) & (1 << 31) != 0 { self.wdt_count = 0; self.wdt_stage = 0; } self.ram.write(off, v); } } +@@ -74,6 +109,44 @@ impl RtcCntl { + _ => self.ram.write(off, v), + } + } ++ ++ fn alarm_target(&self) -> u64 { ++ u64::from(self.ram.read(0x4)) | (u64::from(self.ram.read(0x8) & 0xffff) << 32) ++ } ++ ++ fn update_alarm(&mut self) { ++ if self.alarm_armed && self.slow_ticks >= self.alarm_target() { ++ self.alarm_armed = false; ++ self.ram.write(0x44, self.ram.read(0x44) | (1 << 10)); ++ } ++ } ++ ++ fn ext1_inputs(&self) -> u32 { ++ let selected = self.ram.read(0xe0) & 0x3fffff; ++ let levels = if self.ram.read(0x64) & (1 << 31) != 0 { self.gpio_input } else { !self.gpio_input }; ++ let mut active = selected & levels; ++ for pin in 0..22 { ++ if self.ram.read(0x484 + pin * 4) & ((1 << 19) | (1 << 13)) != ((1 << 19) | (1 << 13)) { ++ active &= !(1 << pin); ++ } ++ } ++ active ++ } ++ ++ fn wake_sources(&self) -> u32 { ++ u32::from(self.ext1_inputs() != 0) << 1 | u32::from(self.ram.read(0x44) & (1 << 10) != 0) << 3 ++ } ++ ++ pub fn check_wakeup(&mut self) { ++ if !self.sleeping { return; } ++ let sources = self.wake_sources() & ((self.ram.read(0x3c) >> 15) & 0x1ffff); ++ if sources == 0 { return; } ++ self.sleeping = false; ++ self.ram.write(0x130, sources); ++ if sources & 2 != 0 { self.ram.write(0xe4, self.ext1_inputs()); } ++ self.ram.write(0x44, self.ram.read(0x44) | 1); ++ self.ram.write(0x18, self.ram.read(0x18) & !(1 << 31)); ++ } + } + + impl RtcCntl { +@@ -120,5 +193,19 @@ impl Device for RtcCntl { + fn read(&mut self, off: u32) -> u32 { RtcCntl::read(self, off) } + fn write(&mut self, off: u32, v: u32) -> WriteEffect { RtcCntl::write(self, off, v); WriteEffect::NONE } + fn clock(&self) -> Option { Some(ClockDomain::RtcSlow) } +- fn tick(&mut self, ticks: u64) { self.slow_ticks += ticks; self.wdt_tick(ticks); } ++ fn tick(&mut self, ticks: u64) { ++ self.wdt_tick(ticks); ++ self.slow_ticks += ticks; ++ self.update_alarm(); ++ self.check_wakeup(); ++ } ++ fn has_deadline(&self) -> bool { true } ++ fn next_deadline(&self) -> Option { ++ let alarm = self.alarm_armed.then(|| self.alarm_target().saturating_sub(self.slow_ticks).max(1)); ++ let conf0 = self.ram.read(self.wdt_base); ++ if conf0 & (1 << 31) == 0 || (self.sleeping && conf0 & (1 << 9) != 0) { return alarm; } ++ let timeout = u64::from(self.ram.read(self.wdt_base + 4 + 4 * self.wdt_stage as u32)); ++ let wdt = timeout.saturating_sub(self.wdt_count).max(1); ++ Some(alarm.map_or(wdt, |ticks| ticks.min(wdt))) ++ } + } +diff --git a/esp-periph/src/spi_mem.rs b/esp-periph/src/spi_mem.rs +index 8e41481..5df95b1 100644 +--- a/esp-periph/src/spi_mem.rs ++++ b/esp-periph/src/spi_mem.rs +@@ -1,5 +1,6 @@ + use crate::device::{Device, WriteEffect}; + use crate::regram::RegRam; ++use emu_core::ClockDomain; + + /// Which array a SPI command wrote (the SoC maps this to its own buffer ids). + #[derive(Clone, Copy, PartialEq, Eq, Debug)] +@@ -20,11 +21,15 @@ pub struct SpiMem { + pub jedec: [u8; 3], + pub is_spi1: bool, + pub log: bool, ++ /// Optional NOR busy timing, in CPU-clock ticks. Zero retains ideal timing. ++ pub program_cycles: u64, ++ pub sector_erase_cycles: u64, ++ busy_cycles: u64, + /// octal PSRAM (APS6408-like) mode registers MR0..MR8, device on CS1 (SPI1 only) + pub psram_mr: [u8; 9], + } + impl SpiMem { +- pub fn new(is_spi1: bool) -> Self { SpiMem { has_psram: true, regs: RegRam::new(), dirty: Vec::new(), w: [0; 16], pending_cmd: 0, status: 0x200, jedec: [0x20, 0x40, 0x17], is_spi1, log: false , psram_mr: [0x09, 0x0d, 0x8b, 0x00, 0x20, 0, 0, 0, 0x03] } } ++ pub fn new(is_spi1: bool) -> Self { SpiMem { has_psram: true, regs: RegRam::new(), dirty: Vec::new(), w: [0; 16], pending_cmd: 0, status: 0x200, jedec: [0x20, 0x40, 0x17], is_spi1, log: false, program_cycles: 0, sector_erase_cycles: 0, busy_cycles: 0, psram_mr: [0x09, 0x0d, 0x8b, 0x00, 0x20, 0, 0, 0, 0x03] } } + pub fn read(&self, off: u32) -> u32 { + match off { + 0x0 => 0, // CMD: always idle after execution +@@ -46,6 +51,14 @@ impl SpiMem { + } + false + } ++ fn busy(&mut self, ticks: u64) { ++ self.busy_cycles = ticks; ++ if ticks != 0 { self.status |= 1; } ++ } ++ fn advance_busy(&mut self, ticks: u64) { ++ self.busy_cycles = self.busy_cycles.saturating_sub(ticks); ++ if self.busy_cycles == 0 { self.status &= !1; } ++ } + fn w_bytes(&self, n: usize) -> Vec { let mut o = Vec::with_capacity(n); for i in 0..n { o.push((self.w[(i / 4) & 15] >> ((i % 4) * 8)) as u8); } o } + fn set_w_bytes(&mut self, data: &[u8]) { for w in self.w.iter_mut() { *w = 0; } for (i, b) in data.iter().enumerate().take(64) { self.w[i / 4] |= (*b as u32) << ((i % 4) * 8); } } + +@@ -106,10 +119,10 @@ impl SpiMem { + 0x04 => self.status &= !0x02, // WRDI + 0x01 | 0x31 | 0x11 => self.status &= !0x02, // WRSR*: latch consumed (keep QE set) + 0x15 => self.set_w_bytes(&[0x00]), +- 0x02 | 0x32 | 0x38 => { let d = self.w_bytes(mosi_bytes); for (i, b) in d.iter().enumerate() { let x = addr as usize + i; if x < fsize { flash[x] &= *b; } } self.dirty.push((DirtyMem::Flash, addr as usize, d.len())); self.status &= !0x02; } +- 0x20 => { let a = (addr as usize) & !0xfff; for b in flash.iter_mut().take((a + 0x1000).min(fsize)).skip(a) { *b = 0xff; } self.dirty.push((DirtyMem::Flash, a, 0x1000)); self.status &= !0x02; } +- 0x52 => { let a = (addr as usize) & !0x7fff; for b in flash.iter_mut().take((a + 0x8000).min(fsize)).skip(a) { *b = 0xff; } self.dirty.push((DirtyMem::Flash, a, 0x8000)); self.status &= !0x02; } +- 0xd8 => { let a = (addr as usize) & !0xffff; for b in flash.iter_mut().take((a + 0x10000).min(fsize)).skip(a) { *b = 0xff; } self.dirty.push((DirtyMem::Flash, a, 0x10000)); self.status &= !0x02; } ++ 0x02 | 0x32 | 0x38 => { let d = self.w_bytes(mosi_bytes); for (i, b) in d.iter().enumerate() { let x = addr as usize + i; if x < fsize { flash[x] &= *b; } } self.dirty.push((DirtyMem::Flash, addr as usize, d.len())); self.status &= !0x02; self.busy(self.program_cycles); } ++ 0x20 => { let a = (addr as usize) & !0xfff; for b in flash.iter_mut().take((a + 0x1000).min(fsize)).skip(a) { *b = 0xff; } self.dirty.push((DirtyMem::Flash, a, 0x1000)); self.status &= !0x02; self.busy(self.sector_erase_cycles * 1); } ++ 0x52 => { let a = (addr as usize) & !0x7fff; for b in flash.iter_mut().take((a + 0x8000).min(fsize)).skip(a) { *b = 0xff; } self.dirty.push((DirtyMem::Flash, a, 0x8000)); self.status &= !0x02; self.busy(self.sector_erase_cycles * 8); } ++ 0xd8 => { let a = (addr as usize) & !0xffff; for b in flash.iter_mut().take((a + 0x10000).min(fsize)).skip(a) { *b = 0xff; } self.dirty.push((DirtyMem::Flash, a, 0x10000)); self.status &= !0x02; self.busy(self.sector_erase_cycles * 16); } + 0xc7 | 0x60 => { for b in flash.iter_mut() { *b = 0xff; } self.dirty.push((DirtyMem::Flash, 0, fsize)); self.status &= !0x02; } + _ => { if has_miso { self.set_w_bytes(&vec![0u8; miso_bytes]); } } + } +@@ -127,9 +140,10 @@ impl SpiMem { + for (i, b) in d.iter().enumerate() { let x = (addr & 0xff_ffff) as usize + i; if x < fsize { flash[x] &= *b; } } + self.dirty.push((DirtyMem::Flash, (addr & 0xff_ffff) as usize, d.len())); + self.status &= !0x02; ++ self.busy(self.program_cycles); + } +- if cmd & (1 << 24) != 0 { let a = (addr as usize) & !0xfff; for b in flash.iter_mut().take((a + 0x1000).min(fsize)).skip(a) { *b = 0xff; } self.dirty.push((DirtyMem::Flash, a, 0x1000)); self.status &= !0x02; } // SE +- if cmd & (1 << 23) != 0 { let a = (addr as usize) & !0xffff; for b in flash.iter_mut().take((a + 0x10000).min(fsize)).skip(a) { *b = 0xff; } self.dirty.push((DirtyMem::Flash, a, 0x10000)); } // BE ++ if cmd & (1 << 24) != 0 { let a = (addr as usize) & !0xfff; for b in flash.iter_mut().take((a + 0x1000).min(fsize)).skip(a) { *b = 0xff; } self.dirty.push((DirtyMem::Flash, a, 0x1000)); self.status &= !0x02; self.busy(self.sector_erase_cycles * 1); } // SE ++ if cmd & (1 << 23) != 0 { let a = (addr as usize) & !0xffff; for b in flash.iter_mut().take((a + 0x10000).min(fsize)).skip(a) { *b = 0xff; } self.dirty.push((DirtyMem::Flash, a, 0x10000)); self.busy(self.sector_erase_cycles * 16); } // BE + if cmd & (1 << 22) != 0 { for b in flash.iter_mut() { *b = 0xff; } self.dirty.push((DirtyMem::Flash, 0, fsize)); } // CE + } + } +@@ -138,6 +152,10 @@ impl Device for SpiMem { + fn read(&mut self, off: u32) -> u32 { SpiMem::read(self, off) } + /// Only SPI1 is the command engine; SPI0 is the cache path and its command bit is ignored. + fn write(&mut self, off: u32, v: u32) -> WriteEffect { if SpiMem::write(self, off, v) && self.is_spi1 { WriteEffect::SPI_EXEC } else { WriteEffect::NONE } } ++ fn clock(&self) -> Option { Some(ClockDomain::Cpu) } ++ fn tick(&mut self, ticks: u64) { self.advance_busy(ticks); } ++ fn has_deadline(&self) -> bool { true } ++ fn next_deadline(&self) -> Option { (self.busy_cycles != 0).then_some(self.busy_cycles) } + fn debug(&mut self, on: bool) { self.log = on; } + } + diff --git a/esp-periph/src/timg.rs b/esp-periph/src/timg.rs index b9cc33c..71f21d2 100644 --- a/esp-periph/src/timg.rs @@ -153,19 +378,56 @@ index b9cc33c..71f21d2 100644 if t.config & (1 << 31) == 0 || t.config & (1 << 10) == 0 { continue; } let div = ((t.config >> 13) & 0xffff) as u64; diff --git a/esp-soc/src/board.rs b/esp-soc/src/board.rs -index 03995a7..781ce61 100644 +index 03995a7..d93b113 100644 --- a/esp-soc/src/board.rs +++ b/esp-soc/src/board.rs -@@ -37,6 +37,7 @@ pub trait BoardModel { +@@ -37,6 +37,10 @@ pub trait BoardModel { /// GPIO matrix has that channel routed to. Drivers that take a fresh channel per refresh /// (the Arduino NeoPixel one does) make the channel meaningless; the pin names the strip. fn rmt_frame(&mut self, _pin: u8, _bits: &[bool]) {} ++ /// A symbol at its start cycle, before the completed-frame callback. ++ fn rmt_symbol_at(&mut self, _cycle: VirtualCycle, _pin: u8, _pulses: &[(u64, bool)]) {} + fn rmt_waveform(&mut self, _pin: u8, _pulses: &[(u64, bool)]) {} ++ fn flash_erase_at(&mut self, _cycle: VirtualCycle, _offset: usize, _length: usize) {} /// Bytes a GP-SPI master (`host` = 2 or 3) shifted out on MOSI. fn spi_tx(&mut self, _host: u8, _data: &[u8]) {} /// One complete GP-SPI transaction. The default preserves transmit-only boards and models an +diff --git a/esp-soc/src/machine.rs b/esp-soc/src/machine.rs +index 59c4dc3..bace58c 100644 +--- a/esp-soc/src/machine.rs ++++ b/esp-soc/src/machine.rs +@@ -493,9 +493,9 @@ impl Machine { + self.web_poll_input(); + self.refresh_irq(); + if self.bus.board_ref().uses_gpio_waveform() { +- if self.has_timing_model() { return Stop::CostModelLifecycle { kind: LifecycleKind::Attach, reason: GPIO_WAVEFORM_TIMING_ERROR.into() }; } ++ if self.cost.is_some() || self.approximate_jit_timing.is_some_and(|timing| timing != (1, 1)) { return Stop::CostModelLifecycle { kind: LifecycleKind::Attach, reason: GPIO_WAVEFORM_TIMING_ERROR.into() }; } + let quantum = std::mem::replace(&mut self.quantum, 1); +- let stop = self.run_unmodeled::(max_insns); ++ let stop = if self.approximate_jit_frontiers { self.run_approximate_jit_frontiers(max_insns) } else if self.approximate_jit_timing.is_some() { self.run_unmodeled::(max_insns) } else { self.run_unmodeled::(max_insns) }; + self.quantum = quantum; + return stop; + } +@@ -568,7 +568,7 @@ impl Machine { + self.refresh_irq(); + if self.bus.sw_reset() { self.drain_console(); return Stop::SwReset; } + if self.bus.cycles() >= self.max_cycles { self.drain_console(); return Stop::Halted; } +- for (i, state) in on.iter_mut().enumerate().take(S::CORES).skip(1) { ++ for (i, state) in on.iter_mut().enumerate().take(S::CORES) { + *state = match S::core_state(&self.bus, i) { + CoreState::Reset => { self.core_held[i] = true; false } + CoreState::Held => false, +@@ -855,7 +855,7 @@ impl Machine { + // batch-s1: the accesses may have moved a device deadline or a core's run state. Re-bound + // from this round's start (core 0 already counted it in `n`); the loop top would re-plan. + open = false; +- if (1..S::CORES).any(|i| on[i] != (S::core_state(&self.bus, i) == CoreState::Running)) { break; } ++ if (0..S::CORES).any(|i| on[i] != (S::core_state(&self.bus, i) == CoreState::Running)) { break; } + k = self.bb_quanta(k * q, *n - q); + self.bus.set_defer(true); + } diff --git a/esp32s3/src/bus.rs b/esp32s3/src/bus.rs -index 394df15..f98ec68 100644 +index 394df15..52b1122 100644 --- a/esp32s3/src/bus.rs +++ b/esp32s3/src/bus.rs @@ -57,10 +57,11 @@ pub struct SocBus { @@ -267,7 +529,19 @@ index 394df15..f98ec68 100644 // Resolved output changes can raise edge or level input interrupts. if spi { self.irq_dirty |= before != sources(&self.periph); -@@ -677,6 +683,24 @@ impl SocBus { +@@ -522,7 +528,10 @@ impl SocBus { + if self.periph.spi_exec { + self.periph.spi_exec = false; + self.periph.spi1.execute(&mut self.flash, &mut self.psram); +- for (m, off, len) in std::mem::take(&mut self.periph.spi1.dirty) { self.note_written(match m { crate::periph::DirtyMem::Flash => SRC_FLASH, crate::periph::DirtyMem::Psram => SRC_PSRAM }, off, len); } ++ for (m, off, len) in std::mem::take(&mut self.periph.spi1.dirty) { ++ if m == crate::periph::DirtyMem::Flash && len >= 4096 { self.board.flash_erase_at(self.cycles, off, len); } ++ self.note_written(match m { crate::periph::DirtyMem::Flash => SRC_FLASH, crate::periph::DirtyMem::Psram => SRC_PSRAM }, off, len); ++ } + } + } + +@@ -677,6 +686,24 @@ impl SocBus { } impl Bus for SocBus { @@ -292,7 +566,7 @@ index 394df15..f98ec68 100644 #[inline] fn read8(&mut self, addr: u32) -> Result { self.read8_access::(addr) } fn read8_unpriced(&mut self, addr: u32) -> Result { self.read8_access::(addr) } -@@ -782,6 +806,7 @@ impl SocBus { +@@ -782,6 +809,7 @@ impl SocBus { || p.wifi.link.ap().is_some() || p.wifi.link.net().is_some() || !p.wifi.tx_pending.is_empty() || p.aes.dma_pending || p.sha.dma_pending || p.spi2.has_pending_transfer() || p.spi2.dma_tx_pending.is_some() @@ -300,7 +574,7 @@ index 394df15..f98ec68 100644 || p.rmt.ch.iter().any(|c| c.running) || !p.rmt.done.is_empty() || !p.gpio.changes.is_empty() || !p.gpio.input_changes.is_empty() || p.rtc.ram.read(0x98) & (1 << 31) != 0 -@@ -793,7 +818,7 @@ impl SocBus { +@@ -793,7 +821,7 @@ impl SocBus { pub fn refresh_tick_budget(&mut self) { let cap = if self.cadence_active() { MAX_TICK_DEFER } else { QUIET_TICK_DEFER }; let mut budget = self.periph.cycles_until_timer().clamp(1, cap); @@ -309,7 +583,19 @@ index 394df15..f98ec68 100644 let until = u64::from(self.tick_pending).saturating_add(deadline.saturating_sub(self.cycles)); budget = budget.min(until.clamp(1, u64::from(MAX_TICK_DEFER)) as u32); } -@@ -825,8 +850,8 @@ impl SocBus { +@@ -818,6 +846,11 @@ impl SocBus { + // Reads may flush before the periodic backstop. Refresh for either edge + // of a clocked source, without breaking every block that polls MMIO. + self.irq_dirty |= self.periph.tick(cycles as u64); ++ for (ch, behind, pulses) in std::mem::take(&mut self.periph.rmt.symbols) { ++ let pin = self.periph.gpio.pin_for_signal(RMT_SIG_OUT0 + ch as u32).unwrap_or(u8::MAX); ++ self.board.rmt_symbol_at(self.cycles.saturating_sub(behind as u64), pin, &pulses); ++ } ++ + if self.uart_pins { + for input in self.board.uart_rx(self.cycles) { + self.periph.uart_pin_input(&input); +@@ -825,8 +858,8 @@ impl SocBus { } } if self.board_edges { self.irq_dirty |= esp_soc::gpio::deliver_board_inputs(&mut *self.board, &mut self.periph.gpio, &mut self.gpio_events, self.cycles); } @@ -320,7 +606,7 @@ index 394df15..f98ec68 100644 self.dma_i2s_step(cycles as u64); self.dma_cam_step(cycles as u64); self.dma_lcd_step(cycles as u64); -@@ -844,7 +869,11 @@ impl SocBus { +@@ -844,7 +877,11 @@ impl SocBus { self.board.gpio_output_at(self.cycles, ch, self.periph.gpio.enable, self.periph.gpio.out); self.periph.gpio.changes.clear(); } @@ -480,7 +766,7 @@ index 7639ba8..27deeb9 100644 + } } diff --git a/esp32s3/src/periph.rs b/esp32s3/src/periph.rs -index d699cca..e712180 100644 +index d699cca..0eeed0d 100644 --- a/esp32s3/src/periph.rs +++ b/esp32s3/src/periph.rs @@ -17,6 +17,7 @@ pub const SRC_UART0: usize = 27; @@ -518,7 +804,20 @@ index d699cca..e712180 100644 0x17 "PCNT" (pcnt) => [SRC_PCNT]; 0x33 "WIFI_MAC" (wifi) => [SRC_WIFI_MAC]; 0x34 "WIFI_MAC" alias (wifi) delta 0x1000 => []; -@@ -502,12 +505,13 @@ impl DeviceSet for Peripherals { +@@ -494,7 +497,11 @@ impl DeviceSet for Peripherals { + match block { + 0xc2 if !write => self.intmatrix.status = self.source_status(), // INTERRUPT_*_STATUS reads the live sources + 0x35 => self.wifi.now_cycles = self.clock.cycles(), // TSF timestamps +- 0x08 => self.rtc.now_cycles = self.clock.cycles(), // SENS ADC samples analog sources at this time ++ 0x08 => { ++ self.rtc.now_cycles = self.clock.cycles(); ++ self.rtc.gpio_input = self.gpio.input as u32; ++ self.rtc.check_wakeup(); ++ } + 0x06 => self.fe.done = self.wifi.link.ap().is_some(), // IQ estimation completes once there is an AP + _ => {} + } +@@ -502,12 +509,13 @@ impl DeviceSet for Peripherals { } impl Peripherals { @@ -533,10 +832,46 @@ index d699cca..e712180 100644 aes: Aes::new(), rsa: Rsa::new(), sha: Sha::new(), wdev: Wdev::new(), i2c_mst: I2cMst::new(), gdma: Gdma::new(), i2s0: I2s::new(CPU_HZ), i2s1: I2s::new(CPU_HZ), rmt: Rmt::new(CPU_HZ), io_mux: RegRam::new(), misc: Misc::new(), fake_reads: std::env::var("ESP_EMU_FAKE_READ").ok().map(|v| v.split(',').filter_map(|e| { let mut p = e.split(':'); let a = u32::from_str_radix(p.next()?.trim_start_matches("0x"), 16).ok()?; let o = u32::from_str_radix(p.next().unwrap_or("0").trim_start_matches("0x"), 16).ok()?; let m = u32::from_str_radix(p.next().unwrap_or("ffffffff").trim_start_matches("0x"), 16).ok()?; Some((a, (o, m))) }).collect()).unwrap_or_default(), clock: Self::new_clock(), -@@ -601,6 +605,10 @@ impl Peripherals { +@@ -551,7 +559,10 @@ impl Peripherals { + pub fn write32(&mut self, addr: u32, v: u32) { + // IDF v5.5.4 components/soc/esp32s3/register/soc/io_mux_reg.h:42-50 (FUN_PD/PU). + if (0x60009004..=0x600090c4).contains(&addr) { +- self.gpio.set_pad(((addr - 0x60009004) / 4) as u8, v); ++ let pin = ((addr - 0x60009004) / 4) as u8; ++ if pin >= 22 || self.rtc.ram.read(0x484 + u32::from(pin) * 4) & (1 << 19) == 0 { ++ self.gpio.set_pad(pin, v); ++ } + } + if matches!(addr, 0x6001_3004 | 0x6002_7004) && v & (1 << 5) != 0 { + let bus = usize::from(addr == 0x6002_7004); +@@ -566,6 +577,13 @@ impl Peripherals { + for (group, bit) in [17, 20].iter().enumerate() { if v & (1 << bit) != 0 { self.mcpwm[group] = Mcpwm::new(160 + group as u32 * 6, 9); } } + } + let fx = mmio::write32(self, addr, v); ++ if (0x60008484..=0x600084d8).contains(&addr) { ++ let pin = ((addr - 0x60008484) / 4) as u8; ++ if v & (1 << 19) != 0 { self.gpio.set_pulls(pin, v & (1 << 27) != 0, v & (1 << 28) != 0); } ++ else { self.gpio.set_pad(pin, self.io_mux.read(4 + u32::from(pin) * 4)); } ++ self.rtc.gpio_input = self.gpio.input as u32; ++ self.rtc.check_wakeup(); ++ } + if fx.contains(WriteEffect::SPI_EXEC) { self.spi_exec = true; } + if fx.contains(WriteEffect::INTMAP) { self.intmatrix_dirty = true; } + // ESP-IDF v5.5.4 soc/system_reg.h: PERIP_CLK_EN1/RST_EN1 LCD_CAM bit 8. +@@ -600,7 +618,19 @@ impl Peripherals { + /// Advance device time by `cycles` CPU cycles (every clocked device gets its own clock's /// ticks), then route GPIO input edges to the pulse counters. pub fn tick(&mut self, cycles: u64) -> bool { - let mut irq_changed = Dispatch::tick(self, cycles); +- let mut irq_changed = Dispatch::tick(self, cycles); ++ self.rtc.gpio_input = self.gpio.input as u32; ++ self.rtc.check_wakeup(); ++ let mut irq_changed = if self.rtc.sleeping { ++ let rtc = &mut self.rtc; ++ self.clock.advance(&Self::CLOCKS, cycles, |domain, ticks| { ++ if domain == ClockDomain::RtcSlow { Device::tick(rtc, ticks); } ++ }); ++ false ++ } else { Dispatch::tick(self, cycles) }; + for (group, timer) in self.timg.iter().enumerate() { + if timer.wdt_reset { self.rtc.sw_reset = true; self.rtc.reset_cause = if group == 0 { 7 } else { 8 }; } + } @@ -544,6 +879,36 @@ index d699cca..e712180 100644 if !self.gpio.input_changes.is_empty() { let before = self.pcnt.irq(); let gpio = &mut self.gpio; +@@ -626,7 +656,13 @@ impl Peripherals { + + /// Cycles until the next systimer or timer-group alarm can fire — the only device events + /// firmware times precisely. Slightly conservative (one device tick early), never late. +- pub fn cycles_until_timer(&self) -> u32 { Dispatch::cycles_until_deadline(self) } ++ pub fn cycles_until_timer(&self) -> u32 { ++ if self.rtc.sleeping { ++ return Device::next_deadline(&self.rtc).map_or(u32::MAX, ++ |ticks| (ticks.saturating_sub(1) * (CPU_HZ / RTC_SLOW_HZ)).min(u64::from(u32::MAX)) as u32); ++ } ++ Dispatch::cycles_until_deadline(self) ++ } + pub fn lines_dirty(&mut self) -> bool { let st = self.source_status(); if st != self.last_status { self.last_status = st; true } else { false } } + /// Interrupt lines for both cores in one pass over the sources that are active. + pub fn cpu_lines_both(&self) -> (u32, u32) { +diff --git a/esp32s3/src/soc.rs b/esp32s3/src/soc.rs +index 803a374..65de009 100644 +--- a/esp32s3/src/soc.rs ++++ b/esp32s3/src/soc.rs +@@ -40,9 +40,8 @@ impl Soc for S3 { + c.set_ar(0, 0); + } + fn irqs(bus: &SocBus, out: &mut [u32]) { let (l0, l1) = bus.periph.cpu_lines_both(); out[0] = l0; out[1] = l1; } +- /// Core 0 is always running. SYSTEM_CORE_1_CONTROL_0 controls core 1's clock gate, reset, +- /// and run-stall state. + fn core_state(bus: &SocBus, core: usize) -> CoreState { ++ if bus.periph.rtc.sleeping { return CoreState::Held; } + if core == 0 { return CoreState::Running; } + let (clk, reset, stall) = bus.periph.core1_control(); + if reset { CoreState::Reset } else if clk && !stall { CoreState::Running } else { CoreState::Held } diff --git a/xtensa-lx7/src/pie.rs b/xtensa-lx7/src/pie.rs index 66e7beb..227f73b 100644 --- a/xtensa-lx7/src/pie.rs diff --git a/stick/simulation/wake.actions b/stick/simulation/wake.actions new file mode 100644 index 0000000..a53a358 --- /dev/null +++ b/stick/simulation/wake.actions @@ -0,0 +1,45 @@ +run 4000 +board awake.txt +run 62000 +screen asleep.png +board asleep.txt +press R +run 600 +release R +press L +run 100 +release L +run 500 +board ignored.txt +press M +run 40 +release M +run 1000 +screen cancelled.png +board cancelled.txt +press M +run 900 +screen held.png +board held.txt +release M +run 500 +screen released.png +audio-clear +press M +run 30 +release M +run 300 +screen first-action.png +audio cold.pcm +audio-clear +press M +run 30 +release M +run 300 +screen repeated-action.png +audio warm.pcm +press L +run 100 +release L +run 200 +screen settings.png diff --git a/stick/sound_bridge.cpp b/stick/sound_bridge.cpp index 3b8088e..a597f46 100644 --- a/stick/sound_bridge.cpp +++ b/stick/sound_bridge.cpp @@ -4,19 +4,18 @@ #include "peripheral_power.h" #include +#include #include -extern "C" void BeepAdvance(void); - namespace { bool enabled = false; +bool prepare_requested = false; bool speaker_ready = false; bool codec_on = false; u16 compare_value = 0; u8 output_mode = 0; -int64_t next_period_us = 0; int64_t test_tone_end_us = 0; -int64_t amplifier_hold_end_us = 0; +int64_t prepare_retry_us = 0; int64_t shutdown_retry_us = 0; bool codec_configured = false; bool codec_image_valid = false; @@ -31,11 +30,6 @@ uint8_t codec_image[sizeof(kSuspendRegisters)] = {}; constexpr uint8_t kPiezoCycle[16] = { 255, 255, 255, 255, 255, 255, 255, 255, 0, 0, 0, 0, 0, 0, 0, 0}; -// Prepare cold output for 200 ms before starting a score. Acoustic checks -// captured the full 52 ms cue with this interval; ADC startup obscured it at -// 65 ms. Warm playback skips preparation. -constexpr int64_t kOutputStartupUs = 200000; -constexpr int64_t kAmplifierHoldUs = 500000; #ifdef PW_STICK_BENCH_CONTROL unsigned begin_count = 0, begin_failures = 0, power_failures = 0; unsigned tone_count = 0, tone_failures = 0; @@ -53,18 +47,6 @@ void finish_tone_measurement() { } #endif -int64_t period_us() { - return (int64_t(compare_value + 1) * 1000000 + 32767) / 32768; -} - -void anchor_output_period() { - // Start each note's timer at its output transition. Carrying overdue - // drawing time into this timer can end a short note before the speaker task - // plays it. - const int64_t now = esp_timer_get_time(); - if (next_period_us < now) next_period_us = now; -} - bool speaker_power(void *, bool on) { using P = m5::M5PM1_Class; if (!StickBoardPower().setGPIOOutput(P::gpio3, on)) { @@ -148,7 +130,6 @@ void stop_tone() { void stop_output() { stop_tone(); test_tone_end_us = 0; - amplifier_hold_end_us = 0; const bool off = speaker_power(nullptr, false); codec_on = false; if (speaker_ready) M5.Speaker.end(); @@ -156,7 +137,10 @@ void stop_output() { if (off) { shutdown_retry_us = 0; StickPeripheralRelease(StickPeripheral::Sound); - } else shutdown_retry_us = esp_timer_get_time() + 1000000; + } else { + shutdown_retry_us = esp_timer_get_time() + 1000000; + ESP_LOGE("StickSound", "Codec shutdown failed; retaining sound supply"); + } } bool start_output() { @@ -177,6 +161,7 @@ bool start_output() { if (speaker_ready && !codec_on) codec_on = speaker_power(nullptr, true); if (!speaker_ready || !codec_on) { stop_output(); return false; } shutdown_retry_us = 0; + prepare_retry_us = 0; return true; } } // namespace @@ -203,10 +188,10 @@ extern "C" void StickSoundInit(void) { config.use_dac = false; config.dac_zero_level = 0; M5.Speaker.config(config); - // Start I2S with the first score. Even an idle I2S task on the sampler core - // changes optical timing. speaker_ready = false; enabled = false; + prepare_requested = false; + prepare_retry_us = 0; compare_value = 0; output_mode = 0; // Quiet the powered codec even when sound is disabled in the native save @@ -215,18 +200,59 @@ extern "C" void StickSoundInit(void) { codec_image_valid = false; codec_configured = StickPeripheralAcquire(StickPeripheral::Sound); stop_output(); + StickSoundPrepare(); +} + +extern "C" int StickSoundPrepare(void) { + prepare_requested = true; + if (StickSoundIsReady()) return 1; + if (shutdown_retry_us || + (prepare_retry_us && esp_timer_get_time() < prepare_retry_us)) return 0; + if (!start_output()) { + prepare_retry_us = esp_timer_get_time() + 1000000; + ESP_LOGE("StickSound", "Output preparation failed"); + return 0; + } + prepare_retry_us = 0; + return 1; +} + +extern "C" int StickSoundIsReady(void) { + return speaker_ready && codec_on && !shutdown_retry_us; +} + +extern "C" void StickSoundSuspend(void) { + prepare_requested = false; + prepare_retry_us = 0; + if (speaker_ready || codec_on || + (codec_configured && !shutdown_retry_us) || + (shutdown_retry_us && esp_timer_get_time() >= shutdown_retry_us)) + stop_output(); +} + +extern "C" uint64_t StickSoundNextDeadline(void) { + uint64_t deadline = UINT64_MAX; + if (test_tone_end_us) deadline = uint64_t(test_tone_end_us); + if (shutdown_retry_us && uint64_t(shutdown_retry_us) < deadline) + deadline = uint64_t(shutdown_retry_us); + if (!shutdown_retry_us && prepare_retry_us && + uint64_t(prepare_retry_us) < deadline) + deadline = uint64_t(prepare_retry_us); + return deadline; } extern "C" void StickSoundEnable(void) { #ifndef PW_STICK_BENCH_CONTROL - if (test_tone_end_us) stop_output(); + if (test_tone_end_us) { + stop_tone(); + test_tone_end_us = 0; + } #endif - const bool cold_amp = !codec_on; - start_output(); - amplifier_hold_end_us = 0; enabled = true; - next_period_us = esp_timer_get_time() + - (cold_amp && codec_on ? kOutputStartupUs : 0); + if (!StickSoundIsReady()) { + ESP_LOGW("StickSound", "Score enabled before output preparation"); + StickSoundPrepare(); + } } extern "C" void StickSoundDisable(void) { @@ -235,10 +261,6 @@ extern "C" void StickSoundDisable(void) { if (test_tone_end_us) return; #endif stop_tone(); - // A short game cue is often followed by another button press. Leave the - // amplifier ready briefly, then switch it off without a blocking delay. - amplifier_hold_end_us = codec_on ? esp_timer_get_time() + - kAmplifierHoldUs : 0; } extern "C" void StickSoundPeriod(u16 compare, u8 mode) { @@ -252,14 +274,13 @@ extern "C" void StickSoundPeriod(u16 compare, u8 mode) { #endif if (!enabled || !compare || !output_mode) { stop_tone(); - anchor_output_period(); return; } // H8 output modes select progressively stronger drive. The M5 speaker's // default master level is only 64/255 and made level 2 effectively silent. M5.Speaker.setVolume(output_mode == 1 ? 100 : output_mode == 2 ? 170 : 235); - if (speaker_ready && codec_on) { + if (StickSoundIsReady()) { const bool started = M5.Speaker.tone( // Timer W is one voice. Automatic channel allocation would leave // preceding pitches playing on other channels until the score ends. @@ -269,11 +290,14 @@ extern "C" void StickSoundPeriod(u16 compare, u8 mode) { ++tone_count; tone_failures += !started; if (started) last_tone_us = uint64_t(esp_timer_get_time()); -#else - (void)started; #endif + if (!started) ESP_LOGE("StickSound", "Tone submission failed"); + } else { +#ifdef PW_STICK_BENCH_CONTROL + ++tone_failures; +#endif + ESP_LOGE("StickSound", "Tone requested while output is not ready"); } - anchor_output_period(); } extern "C" void StickSoundSilencePeriod(u16 compare) { @@ -282,7 +306,6 @@ extern "C" void StickSoundSilencePeriod(u16 compare) { if (test_tone_end_us) return; #endif stop_tone(); - anchor_output_period(); } extern "C" void StickSoundMute(void) { @@ -293,56 +316,63 @@ extern "C" void StickSoundMute(void) { } extern "C" void StickSoundQuiesceForIr(void) { - stop_output(); + StickSoundSuspend(); } extern "C" int StickSoundIsBusy(void) { - return enabled || test_tone_end_us || speaker_ready; + return speaker_ready || codec_on || codec_configured || shutdown_retry_us; } extern "C" void StickSoundService(void) { - if (!enabled && shutdown_retry_us && - esp_timer_get_time() >= shutdown_retry_us) stop_output(); - if (test_tone_end_us && esp_timer_get_time() >= test_tone_end_us) - stop_output(); - if (!enabled && amplifier_hold_end_us && - esp_timer_get_time() >= amplifier_hold_end_us) - stop_output(); - if (!enabled) return; const int64_t now = esp_timer_get_time(); - unsigned serviced = 0; - while (next_period_us <= now && serviced < 256) { - BeepAdvance(); - next_period_us += period_us(); - ++serviced; + if (shutdown_retry_us && now >= shutdown_retry_us) + stop_output(); + if (prepare_requested && !StickSoundIsReady() && !shutdown_retry_us && + (!prepare_retry_us || now >= prepare_retry_us)) + StickSoundPrepare(); + if (test_tone_end_us && now >= test_tone_end_us) { + if (prepare_requested) { + stop_tone(); + test_tone_end_us = 0; + } else stop_output(); } - if (serviced == 256 && next_period_us <= now) - next_period_us = now + period_us(); } extern "C" int StickSoundTestTone(void) { if (enabled) return 0; - if (!start_output()) return 0; + if (shutdown_retry_us || !start_output()) { + ESP_LOGE("StickSound", "Speaker test preparation failed"); + return 0; + } M5.Speaker.setVolume(170); - const bool started = M5.Speaker.tone(880.0f, 1000); + const bool started = M5.Speaker.tone(880.0f, 1000, 0, true); #ifdef PW_STICK_BENCH_CONTROL ++tone_count; tone_failures += !started; #endif if (started) test_tone_end_us = esp_timer_get_time() + 1000000; - else stop_output(); + else { + ESP_LOGE("StickSound", "Speaker test tone submission failed"); + stop_output(); + } return started ? 1 : 0; } #ifdef PW_STICK_BENCH_CONTROL extern "C" int StickSoundBenchTone(void) { - if (!start_output()) return 0; + if (shutdown_retry_us || !start_output()) { + ESP_LOGE("StickSound", "Bench tone preparation failed"); + return 0; + } M5.Speaker.setVolume(170); - const bool started = M5.Speaker.tone(880.0f, 8000); + const bool started = M5.Speaker.tone(880.0f, 8000, 0, true); ++tone_count; tone_failures += !started; if (started) test_tone_end_us = esp_timer_get_time() + 8000000; - else stop_output(); + else { + ESP_LOGE("StickSound", "Bench tone submission failed"); + stop_output(); + } return started ? 1 : 0; } diff --git a/stick/sound_bridge.h b/stick/sound_bridge.h index e7b932b..5d85078 100644 --- a/stick/sound_bridge.h +++ b/stick/sound_bridge.h @@ -2,12 +2,17 @@ #define PW_STICK_SOUND_BRIDGE_H #include "types.h" +#include #ifdef __cplusplus extern "C" { #endif void StickSoundInit(void); +int StickSoundPrepare(void); +int StickSoundIsReady(void); +void StickSoundSuspend(void); +uint64_t StickSoundNextDeadline(void); void StickSoundEnable(void); void StickSoundDisable(void); void StickSoundPeriod(u16 compare, u8 outputMode); @@ -15,8 +20,6 @@ void StickSoundSilencePeriod(u16 compare); void StickSoundMute(void); void StickSoundQuiesceForIr(void); void StickSoundService(void); -/* Includes the amplifier warm hold; the I2S clock must run until this returns - * false. */ int StickSoundIsBusy(void); int StickSoundTestTone(void); #ifdef PW_STICK_BENCH_CONTROL diff --git a/stick/sound_probe.cpp b/stick/sound_probe.cpp index bd7d324..0fa8e29 100644 --- a/stick/sound_probe.cpp +++ b/stick/sound_probe.cpp @@ -1,6 +1,7 @@ #ifdef PW_STICK_BENCH_CONTROL // The self-test reports aggregate energy measurements. #include "foreground_bridge.h" +#include "native_runtime.h" #include "sound_bridge.h" #include "board_hal.h" #include @@ -122,6 +123,7 @@ extern "C" void StickSoundBenchCapture(bool microphone, bool calibration, bool m const int64_t start = esp_timer_get_time(); int16_t samples[256]; while (ok && esp_timer_get_time() - start < bin_count * 10000) { + StickNativeService(); StickSoundService(); size_t bytes = 0; const auto read = i2s_channel_read(rx, samples, sizeof(samples), &bytes, 1); diff --git a/tools/check.py b/tools/check.py index 1396e2f..2daf45a 100644 --- a/tools/check.py +++ b/tools/check.py @@ -1,4 +1,4 @@ -"""Check source formatting, target-aware static analysis, and utility tests.""" +"""Check source formatting and target-aware static analysis.""" from __future__ import annotations @@ -64,8 +64,7 @@ def main() -> int: ], check=True, ) - subprocess.run([sys.executable, "-m", "unittest", "discover", "-s", "tests"], check=True) - print("Formatting, static analysis, and utility tests passed.") + print("Formatting and static analysis passed. Run stick/check.py for firmware E2E checks.") except (InputError, OSError, subprocess.SubprocessError) as error: print(f"Error: {error}", file=sys.stderr) return 1