diff --git a/components/button/button.c b/components/button/button.c index 0f62a1c..ea552e8 100644 --- a/components/button/button.c +++ b/components/button/button.c @@ -224,3 +224,122 @@ void Button_RegisterCommands(void) ESP_LOGE(TAG, "Registering '%s' command failed: %s", command.command, esp_err_to_name(err)); } } + +/* ===== Path-e (4.8d) chord/hold detection ===== */ + +typedef enum ChordTracked { + kChordTracked_None = 0, + kChordTracked_Save, /* MENU + DOWN */ + kChordTracked_Load, /* MENU + UP */ +} ChordTracked_t; + +typedef struct ChordState { + ChordTracked_t current; /* chord currently being tracked */ + uint16_t hold_count; /* consecutive matching ticks */ + bool consumed; /* fired; awaiting release before re-arm */ + uint16_t hold_threshold; /* ticks required to fire */ +} ChordState_t; + +static ChordState_t _Chord = { + .current = kChordTracked_None, + .hold_count = 0u, + .consumed = false, + .hold_threshold = BUTTON_CHORD_DEFAULT_HOLD_TICKS, +}; + +void ButtonChord_Reset(void) +{ + if (Mutex_Take(kMutexKey_Buttons) == kMutexResult_Ok) + { + _Chord.current = kChordTracked_None; + _Chord.hold_count = 0u; + _Chord.consumed = false; + (void) Mutex_Give(kMutexKey_Buttons); + } +} + +void ButtonChord_SetHoldThreshold(uint16_t ticks) +{ + if (ticks == 0u) ticks = 1u; + if (Mutex_Take(kMutexKey_Buttons) == kMutexResult_Ok) + { + _Chord.hold_threshold = ticks; + (void) Mutex_Give(kMutexKey_Buttons); + } +} + +static ChordTracked_t ChordFromInput(uint16_t buttons) +{ + /* + * Use the physical BTN_MENU bit only. In the FPGA payload: + * bit 8 / MenuEnAlt = ~BTN_MENU + * bit 9 / MenuEn = menuDisabled state + */ + const bool menu_held = (buttons & kButtonBits_MenuEnAlt) != 0u; + if (!menu_held) return kChordTracked_None; + + /* + * Reject if both Up AND Down are held simultaneously (impossible on + * real hardware, indicates a stuck-button state). + */ + const bool down_held = (buttons & kButtonBits_Down) != 0u; + const bool up_held = (buttons & kButtonBits_Up) != 0u; + if (down_held && up_held) return kChordTracked_None; + if (down_held) return kChordTracked_Save; + if (up_held) return kChordTracked_Load; + return kChordTracked_None; +} + +ButtonChordEvent_t ButtonChord_UpdateWithTick(uint16_t NewButtons, + uint16_t tick_increment) +{ + ButtonChordEvent_t fired = kButtonChord_None; + + if (Mutex_Take(kMutexKey_Buttons) != kMutexResult_Ok) { + return kButtonChord_None; + } + + const ChordTracked_t input_chord = ChordFromInput(NewButtons); + const bool any_menu = (NewButtons & kButtonBits_MenuEnAlt) != 0u; + + if (_Chord.consumed) { + /* + * Stay in consumed state until BOTH menu bits are released. + * Sub-clears (e.g., user releases Down but keeps Menu held) do + * not re-arm. This matches lock §4.8d's "consume the recognized + * chord until all keys are released" requirement. + */ + if (!any_menu) { + _Chord.consumed = false; + _Chord.current = kChordTracked_None; + _Chord.hold_count = 0u; + } + } else if (input_chord == kChordTracked_None) { + /* Chord not held this tick. */ + _Chord.current = kChordTracked_None; + _Chord.hold_count = 0u; + } else if (_Chord.current != input_chord) { + /* Chord changed (e.g., user transitioned from Down to Up). */ + _Chord.current = input_chord; + _Chord.hold_count = tick_increment; + } else { + /* Same chord still held. */ + const uint32_t next = (uint32_t)_Chord.hold_count + tick_increment; + _Chord.hold_count = (next > UINT16_MAX) ? UINT16_MAX : (uint16_t)next; + + if (_Chord.hold_count >= _Chord.hold_threshold) { + fired = (input_chord == kChordTracked_Save) ? kButtonChord_SaveRequested + : kButtonChord_LoadRequested; + _Chord.consumed = true; + _Chord.hold_count = 0u; + } + } + + (void) Mutex_Give(kMutexKey_Buttons); + return fired; +} + +ButtonChordEvent_t ButtonChord_Update(uint16_t NewButtons) +{ + return ButtonChord_UpdateWithTick(NewButtons, 1u); +} diff --git a/components/button/button.h b/components/button/button.h index cadfa63..f7cb9e1 100644 --- a/components/button/button.h +++ b/components/button/button.h @@ -47,3 +47,60 @@ void Button_ResetAll(void); void Button_RegisterOnButtonPokeCb(fnOnButtonPokeCb_t Handler); uint16_t Button_GetPokedInputs(void); void Button_RegisterCommands(void); + +/* + * Path-e (4.8d) chord/hold detection. + * + * Per project-wiki/50_decisions/fusion-savestate-phase4-design-lock.md + * §4.8d (line 464-469): + * - hold MENU + DOWN for save + * - hold MENU + UP for load + * - same hold threshold for both actions + * - consume the recognized chord until all keys are released so the + * chord does not also open OSD or pass through to the game/menu + * + * Implementation: tick-driven chord state machine. Caller invokes + * ButtonChord_Update with the current button bitmap on each FPGA button + * frame (cadence ~hclk/N from system_monitor). When the chord has been + * held for >= ButtonChord_HoldThresholdTicks consecutive ticks, the + * state machine fires the corresponding event (latched until polled). + * + * "Consume until all released" rule: after firing, the state machine + * stays in Consumed until NewButtons==0 (or only non-chord bits set); + * subsequent re-entries to the same chord are rejected until a full + * release. This prevents the chord from also being interpreted as a + * normal MenuEn press (which would open OSD). + */ +typedef enum ButtonChordEvent { + kButtonChord_None = 0, + kButtonChord_SaveRequested, /* MENU + DOWN held */ + kButtonChord_LoadRequested, /* MENU + UP held */ +} ButtonChordEvent_t; + +/* + * Number of consecutive Update ticks the chord must remain held before + * firing. Existing button.c module declares kMinHold_ticks = 10 but + * never uses it; we reuse the same threshold for chord detection. + * Caller may override at startup via ButtonChord_SetHoldThreshold. + */ +#define BUTTON_CHORD_DEFAULT_HOLD_TICKS 10u + +void ButtonChord_Reset(void); +void ButtonChord_SetHoldThreshold(uint16_t ticks); + +/* + * Update with current button bitmap. Returns kButtonChord_None if + * nothing latches THIS tick; returns one-shot Save/LoadRequested when + * the chord crosses the hold threshold. After firing, the chord is + * "consumed" — must call ButtonChord_Update with NewButtons clear of + * MENU bits before another Save/Load can fire. + */ +ButtonChordEvent_t ButtonChord_Update(uint16_t NewButtons); + +/* + * Same as ButtonChord_Update but takes an explicit "tick count" for + * unit testing. Each tick increments the hold counter when the chord + * matches. ButtonChord_Update internally calls this with tick=1. + */ +ButtonChordEvent_t ButtonChord_UpdateWithTick(uint16_t NewButtons, + uint16_t tick_increment); diff --git a/components/mutex/mutex.h b/components/mutex/mutex.h index c499f3c..d79e592 100644 --- a/components/mutex/mutex.h +++ b/components/mutex/mutex.h @@ -26,6 +26,7 @@ typedef enum MutexKey kMutexKey_ScreenTransitCtl, kMutexKey_Brightness, kMutexKey_PlayerNum, + kMutexKey_OSD, /* Path-e (4.8d): savestate toast widget state */ kNumMutexKeys, kMutexKey_FirstKey = kMutexKey_Buttons, diff --git a/components/osd/CMakeLists.txt b/components/osd/CMakeLists.txt index 14fdd15..c2a5112 100644 --- a/components/osd/CMakeLists.txt +++ b/components/osd/CMakeLists.txt @@ -14,9 +14,11 @@ idf_component_register(SRCS "osd_default.c" "status/brightness.c" "status/fw.c" + "status/savestate_toast.c" "system/silent.c" "system/serial_num.c" "system/player_num.c" + "system/save_slot.c" INCLUDE_DIRS "." "controls/" diff --git a/components/osd/osd_default.c b/components/osd/osd_default.c index 2cb471b..8865b9e 100644 --- a/components/osd/osd_default.c +++ b/components/osd/osd_default.c @@ -8,6 +8,7 @@ #include "tab_table.h" #include "status/fw.h" #include "status/brightness.h" +#include "status/savestate_toast.h" #include "controls/dpad_ctl.h" #include "controls/hotkeys.h" #include "palette/style.h" @@ -20,6 +21,7 @@ #include "system/silent.h" #include "system/player_num.h" #include "system/serial_num.h" +#include "system/save_slot.h" #include "osd.h" #include "osd_shared.h" #include "esp_log.h" @@ -62,6 +64,14 @@ void OSD_Default_Init(lv_obj_t *const pScreen) return; } + /* Path-e (4.8d) save/load result toast widget. */ + static OSD_Widget_t SavestateToast; + if ((eResult = SavestateToast_Initialize(&SavestateToast)) != kOSD_Result_Ok) + { + ESP_LOGE(TAG, "Savestate toast widget init failed %d", eResult); + /* Non-fatal: keep going with rest of OSD even if toast init failed. */ + } + CreateMenuStatus(pScreen); CreateMenuDisplay(pScreen); CreateMenuControls(pScreen); @@ -70,6 +80,9 @@ void OSD_Default_Init(lv_obj_t *const pScreen) OSD_AddWidget(&Battery); OSD_AddWidget(&MenuMgr); + if (eResult == kOSD_Result_Ok) { + OSD_AddWidget(&SavestateToast); + } ESP_LOGI(TAG, "Default OSD init OK"); } @@ -425,6 +438,23 @@ static void CreateMenuSystem(lv_obj_t *const pScreen) } } + /* + * Path-e (4.8d) Save Slot menu item. Read-only. Per lock §4.8d + * line 472-474 it appears AFTER Firmware and Player. Main pushes + * slot status text via SaveSlot_SetText after boot/save/load events. + */ + static TabItem_t SaveSlotItem; + if ((eResult = SaveSlot_Init(&SaveSlotItem.Widget)) != kOSD_Result_Ok) + { + ESP_LOGE(TAG, "Save Slot widget init failed %d", eResult); + return; + } + if ((eResult = Tab_AddItem(pList, &SaveSlotItem, pScreen)) != kOSD_Result_Ok) + { + ESP_LOGE(TAG, "%s tab item init failed %d", SaveSlotItem.Widget.Name, eResult); + return; + } + if ((eResult = MenuMgr_AddTab(kTabID_System, &Tab_System)) != kOSD_Result_Ok) { ESP_LOGE(TAG, "%s widget init failed %d", Tab_System.Widget.Name, eResult); diff --git a/components/osd/status/savestate_toast.c b/components/osd/status/savestate_toast.c new file mode 100644 index 0000000..fded6fc --- /dev/null +++ b/components/osd/status/savestate_toast.c @@ -0,0 +1,137 @@ +#include "savestate_toast.h" + +#include "osd.h" +#include "osd_shared.h" +#include "lvgl.h" +#include "mutex.h" +#include "esp_timer.h" +#include "esp_log.h" + +#include +#include +#include + +enum { + kToastOriginX_px = 60, + kToastOriginY_px = 64, /* roughly screen center vertically */ +}; + +typedef struct ToastState { + SavestateToastResult_t result; + uint64_t hide_at_us; + bool active; + /* Cached LVGL object so we don't recreate every Draw cycle. */ + lv_obj_t *pLabel; +} ToastState_t; + +__attribute__((unused)) static const char *TAG = "ss_toast"; + +static ToastState_t _Ctx = { + .result = kSavestateToast_Saved, + .hide_at_us = 0u, + .active = false, + .pLabel = NULL, +}; + +static const char *ToastText(SavestateToastResult_t r) +{ + switch (r) { + case kSavestateToast_Saved: return "SAVED"; + case kSavestateToast_Loaded: return "LOADED"; + case kSavestateToast_Failed: return "FAILED"; + case kSavestateToast_WrongGame: return "WRONG GAME"; + default: return ""; + } +} + +static OSD_Result_t SavestateToast_Draw(void *arg) +{ + if (arg == NULL) { + return kOSD_Result_Err_NullDataPtr; + } + lv_obj_t *const pScreen = (lv_obj_t *const)arg; + + bool active_local = false; + SavestateToastResult_t result_local = kSavestateToast_Saved; + + if (Mutex_Take(kMutexKey_OSD) == kMutexResult_Ok) { + const uint64_t now_us = (uint64_t)esp_timer_get_time(); + if (_Ctx.active && now_us >= _Ctx.hide_at_us) { + _Ctx.active = false; + } + active_local = _Ctx.active; + result_local = _Ctx.result; + (void) Mutex_Give(kMutexKey_OSD); + } + + if (active_local) { + if (_Ctx.pLabel == NULL) { + _Ctx.pLabel = lv_label_create(pScreen); + lv_obj_add_style(_Ctx.pLabel, OSD_GetStyleTextWhite_L(), 0); + lv_obj_set_align(_Ctx.pLabel, LV_ALIGN_TOP_LEFT); + lv_obj_set_pos(_Ctx.pLabel, kToastOriginX_px, kToastOriginY_px); + } + lv_label_set_text(_Ctx.pLabel, ToastText(result_local)); + } else { + if (_Ctx.pLabel != NULL) { + lv_obj_del(_Ctx.pLabel); + _Ctx.pLabel = NULL; + } + } + + return kOSD_Result_Ok; +} + +static OSD_Result_t SavestateToast_OnTransition(void *arg) +{ + (void)arg; + /* No transition state to clear/setup beyond what Draw handles. */ + return kOSD_Result_Ok; +} + +OSD_Result_t SavestateToast_Initialize(OSD_Widget_t *pWidget) +{ + if (pWidget == NULL) { + return kOSD_Result_Err_NullDataPtr; + } + pWidget->Name = "SavestateToast"; + pWidget->fnDraw = SavestateToast_Draw; + pWidget->fnOnTransition = SavestateToast_OnTransition; + pWidget->fnOnButton = NULL; + return kOSD_Result_Ok; +} + +void SavestateToast_Show(SavestateToastResult_t result, uint32_t duration_ms) +{ + if (duration_ms == 0u) { + duration_ms = SAVESTATE_TOAST_DEFAULT_MS; + } + if (Mutex_Take(kMutexKey_OSD) == kMutexResult_Ok) { + _Ctx.result = result; + _Ctx.hide_at_us = (uint64_t)esp_timer_get_time() + + (uint64_t)duration_ms * 1000ull; + _Ctx.active = true; + (void) Mutex_Give(kMutexKey_OSD); + } + /* Coordinate with main OSD: ensure visible while toast active. */ + OSD_SetVisiblityState(true); +} + +void SavestateToast_Hide(void) +{ + if (Mutex_Take(kMutexKey_OSD) == kMutexResult_Ok) { + _Ctx.active = false; + (void) Mutex_Give(kMutexKey_OSD); + } +} + +bool SavestateToast_IsActive(void) +{ + bool ret = false; + if (Mutex_Take(kMutexKey_OSD) == kMutexResult_Ok) { + const uint64_t now_us = (uint64_t)esp_timer_get_time(); + ret = _Ctx.active && now_us < _Ctx.hide_at_us; + (void) Mutex_Give(kMutexKey_OSD); + } + return ret; +} diff --git a/components/osd/status/savestate_toast.h b/components/osd/status/savestate_toast.h new file mode 100644 index 0000000..9860ba7 --- /dev/null +++ b/components/osd/status/savestate_toast.h @@ -0,0 +1,56 @@ +#pragma once + +#include "osd_shared.h" + +#include +#include + +/* + * Path-e (4.8d) save/load result toast. + * + * Displays a transient text label for a bounded duration, then auto-hides. + * Used by the path-e save/load handler to give the user immediate feedback: + * - SAVED : after successful quick-save + * - LOADED : after successful quick-load + * - FAILED : on slot corruption / wrong-game / state-port error + * + * Per project-wiki/50_decisions/fusion-savestate-phase4-design-lock.md + * line 470-471: "transient result text: SAVED, LOADED, or FAILED". + * + * Visibility coordinates with the main OSD: when this toast is showing, + * SetVisibilityState(true) is called so the widget actually draws. Caller + * is responsible for not stacking multiple toasts; current call replaces + * any in-flight toast. + */ + +#ifdef __cplusplus +extern "C" { +#endif + +typedef enum SavestateToastResult { + kSavestateToast_Saved = 0, + kSavestateToast_Loaded, + kSavestateToast_Failed, + kSavestateToast_WrongGame, +} SavestateToastResult_t; + +#define SAVESTATE_TOAST_DEFAULT_MS 1500u + +OSD_Result_t SavestateToast_Initialize(OSD_Widget_t *pWidget); + +/* + * Show the toast with the given result and auto-hide after duration_ms. + * Pass duration_ms=0 to use SAVESTATE_TOAST_DEFAULT_MS. Replaces any + * in-flight toast. Thread-safe via mutex. + */ +void SavestateToast_Show(SavestateToastResult_t result, uint32_t duration_ms); + +/* Hide immediately (e.g., on user input that should clear the toast). */ +void SavestateToast_Hide(void); + +/* True if a toast is currently visible (within its duration window). */ +bool SavestateToast_IsActive(void); + +#ifdef __cplusplus +} +#endif diff --git a/components/osd/system/save_slot.c b/components/osd/system/save_slot.c new file mode 100644 index 0000000..34dda2e --- /dev/null +++ b/components/osd/system/save_slot.c @@ -0,0 +1,111 @@ +#include "save_slot.h" + +#include "osd_shared.h" +#include "lvgl.h" +#include "esp_log.h" +#include "mutex.h" + +#include +#include +#include +#include + +enum { + kHeaderOffsetX_px = 81, + kHeaderOffsetY_px = 38, + kValueOffsetX_px = 81, + kValueOffsetY_px = 60, + kSaveSlotTextLen = 24, +}; + +static const char *TAG = "save_slot"; + +typedef struct SaveSlotCtx { + lv_obj_t *pHeader; + lv_obj_t *pValue; + char value_text[kSaveSlotTextLen]; + bool dirty; +} SaveSlotCtx_t; + +static SaveSlotCtx_t _Ctx = { + .pHeader = NULL, + .pValue = NULL, + .value_text = "EMPTY", + .dirty = true, +}; + +OSD_Result_t SaveSlot_OnTransition(void *arg) +{ + (void)arg; + /* Mark dirty so next Draw refreshes the label. No storage I/O here. */ + if (Mutex_Take(kMutexKey_OSD) == kMutexResult_Ok) { + _Ctx.dirty = true; + (void) Mutex_Give(kMutexKey_OSD); + } + return kOSD_Result_Ok; +} + +OSD_Result_t SaveSlot_Draw(void *arg) +{ + if (arg == NULL) { + return kOSD_Result_Err_NullDataPtr; + } + lv_obj_t *const pScreen = (lv_obj_t *const)arg; + + if (_Ctx.pHeader == NULL) { + _Ctx.pHeader = lv_label_create(pScreen); + lv_label_set_text(_Ctx.pHeader, "SAVE SLOT"); + lv_obj_align(_Ctx.pHeader, LV_ALIGN_TOP_LEFT, + kHeaderOffsetX_px, kHeaderOffsetY_px); + lv_obj_add_style(_Ctx.pHeader, OSD_GetStyleTextGrey(), 0); + } + + if (_Ctx.pValue == NULL) { + _Ctx.pValue = lv_label_create(pScreen); + lv_obj_align(_Ctx.pValue, LV_ALIGN_TOP_LEFT, + kValueOffsetX_px, kValueOffsetY_px); + lv_obj_add_style(_Ctx.pValue, OSD_GetStyleTextWhite(), 0); + } + + bool needs_refresh = false; + char text_local[kSaveSlotTextLen]; + if (Mutex_Take(kMutexKey_OSD) == kMutexResult_Ok) { + needs_refresh = _Ctx.dirty; + memcpy(text_local, _Ctx.value_text, sizeof(text_local)); + _Ctx.dirty = false; + (void) Mutex_Give(kMutexKey_OSD); + } else { + memcpy(text_local, _Ctx.value_text, sizeof(text_local)); + } + + if (needs_refresh) { + lv_label_set_text(_Ctx.pValue, text_local); + } + return kOSD_Result_Ok; +} + +OSD_Result_t SaveSlot_Init(OSD_Widget_t *pWidget) +{ + if (pWidget == NULL) { + return kOSD_Result_Err_NullDataPtr; + } + pWidget->Name = "SAVE SLOT"; + pWidget->fnDraw = SaveSlot_Draw; + pWidget->fnOnTransition = SaveSlot_OnTransition; + pWidget->fnOnButton = NULL; /* read-only */ + ESP_LOGI(TAG, "Save Slot widget initialized"); + return kOSD_Result_Ok; +} + +void SaveSlot_SetText(const char *text) +{ + if (text == NULL) { + return; + } + if (Mutex_Take(kMutexKey_OSD) == kMutexResult_Ok) { + strncpy(_Ctx.value_text, text, sizeof(_Ctx.value_text) - 1); + _Ctx.value_text[sizeof(_Ctx.value_text) - 1] = '\0'; + _Ctx.dirty = true; + (void) Mutex_Give(kMutexKey_OSD); + } +} diff --git a/components/osd/system/save_slot.h b/components/osd/system/save_slot.h new file mode 100644 index 0000000..2b3ca54 --- /dev/null +++ b/components/osd/system/save_slot.h @@ -0,0 +1,41 @@ +#pragma once + +#include "osd_shared.h" + +#include + +/* + * Path-e (4.8d) Save Slot menu entry for System tab. + * + * Per project-wiki/50_decisions/fusion-savestate-phase4-design-lock.md + * line 472-474: "read-only System menu status item after Firmware and + * Player: Save Slot, showing EMPTY or the saved game name when the + * slot can identify it." + * + * This widget is presentation-only. Slot state is pushed in by main + * (via SaveSlot_SetText) so this component does not pull in storage / + * fusion_savestate dependencies. + */ + +#ifdef __cplusplus +extern "C" { +#endif + +OSD_Result_t SaveSlot_Init(OSD_Widget_t *pWidget); +OSD_Result_t SaveSlot_Draw(void *arg); +OSD_Result_t SaveSlot_OnTransition(void *arg); + +/* + * Update displayed slot status text. Caller (main) may use: + * - "EMPTY" on boot if no valid slot, or after slot invalidation + * - "" short hex of game_id_hash for known slot + * - GB title if a separate title lookup becomes available + * + * Text is copied; safe to call from non-OSD task. Max length 23 chars + * (truncated if longer). + */ +void SaveSlot_SetText(const char *text); + +#ifdef __cplusplus +} +#endif diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index 134f63c..ff6f4bf 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -3,9 +3,14 @@ cmake_minimum_required(VERSION 3.22) idf_component_register( SRCS "main.c" "gfx.c" "fpga_tx.c" "fpga_rx.c" "fpga_common.c" "pwrmgr.c" + "fusion_savestate.c" "fpga_state_port.c" "savestate_storage.c" + "fusion_savestate_pathe.c" + "fusion_savestate_smoke48a.c" + "fusion_savestate_smoke48c.c" + "fusion_savestate_transport_probe.c" INCLUDE_DIRS "." REQUIRES images esp_driver_uart esp_driver_spi esp_driver_gpio nvs_flash fatfs - common battery button osd menu_mgr settings mutex + common battery button osd menu_mgr settings mutex esp_app_format console crc ) diff --git a/main/fpga_common.c b/main/fpga_common.c index 41117e7..aa172e4 100644 --- a/main/fpga_common.c +++ b/main/fpga_common.c @@ -6,6 +6,8 @@ static TaskHandle_t TxTaskHandle = NULL; static TaskHandle_t RxTaskHandle = NULL; static bool IsV1 = false; // Default to V2 +static StaticSemaphore_t UartOwnerMutexStorage; +static SemaphoreHandle_t UartOwnerMutex = NULL; TaskHandle_t* FPGA_GetTxTaskHandle(void) { @@ -26,3 +28,24 @@ void FPGA_SetProtoV1(const bool V1) { IsV1 = V1; } + +void FPGA_UartOwnerInit(void) +{ + if (UartOwnerMutex == NULL) { + UartOwnerMutex = xSemaphoreCreateMutexStatic(&UartOwnerMutexStorage); + } +} + +bool FPGA_UartOwnerAcquire(TickType_t wait_ticks) +{ + FPGA_UartOwnerInit(); + return (UartOwnerMutex != NULL) && + (xSemaphoreTake(UartOwnerMutex, wait_ticks) == pdTRUE); +} + +void FPGA_UartOwnerRelease(void) +{ + if (UartOwnerMutex != NULL) { + xSemaphoreGive(UartOwnerMutex); + } +} diff --git a/main/fpga_common.h b/main/fpga_common.h index 825f6d7..cd9175b 100644 --- a/main/fpga_common.h +++ b/main/fpga_common.h @@ -1,6 +1,7 @@ #pragma once #include "freertos/FreeRTOS.h" +#include "freertos/semphr.h" #include "freertos/task.h" #include @@ -22,3 +23,6 @@ TaskHandle_t* FPGA_GetTxTaskHandle(void); TaskHandle_t* FPGA_GetRxTaskHandle(void); bool FPGA_IsProtoV1(void); void FPGA_SetProtoV1(const bool V1); +void FPGA_UartOwnerInit(void); +bool FPGA_UartOwnerAcquire(TickType_t wait_ticks); +void FPGA_UartOwnerRelease(void); diff --git a/main/fpga_rx.c b/main/fpga_rx.c index ef4d13d..db2133e 100644 --- a/main/fpga_rx.c +++ b/main/fpga_rx.c @@ -2,6 +2,7 @@ #include "battery.h" #include "brightness.h" +#include "button.h" /* path-e (4.8d) chord detection */ #include "color_correct_lcd.h" #include "color_correct_usb.h" #include "crc8_sae_j1850.h" @@ -13,6 +14,7 @@ #include "frameblend.h" #include "freertos/FreeRTOS.h" #include "freertos/task.h" +#include "fusion_savestate_smoke48a.h" /* path-e: FusionSavestate_PostChordRequest */ #include "fw.h" #include "osd.h" #include "pwrmgr.h" @@ -95,7 +97,14 @@ void FPGA_RxTask(void *arg) portMAX_DELAY ); - const int32_t ByteCount = uart_read_bytes(UART_NUM_1, pRxBuffer, sizeof(RxBuffer), pdMS_TO_TICKS(10)); + int32_t ByteCount = 0; + if (FPGA_UartOwnerAcquire(pdMS_TO_TICKS(10))) { + ByteCount = uart_read_bytes(UART_NUM_1, + pRxBuffer, + sizeof(RxBuffer), + pdMS_TO_TICKS(10)); + FPGA_UartOwnerRelease(); + } if (ByteCount > 0) { @@ -128,6 +137,13 @@ void FPGA_Rx_Pause(void) (void) xEventGroupClearBits(xEventGroupHandle, kRxFlag_Resume); } +void FPGA_Rx_ResetParser(void) +{ + _eState = kScanForHeaderMarker; + BufferIndex = 0; + memset(DecodeBuffer, 0x0, sizeof(DecodeBuffer)); +} + void FPGA_Rx_UseBrightnessReadback(void) { (void) xEventGroupSetBits(xEventGroupHandle, kRxFlag_UseBrightness); @@ -301,6 +317,18 @@ static void ProcessMessage(const RxMsg_t *const pMsg) } case kRxCmd_Buttons: Button_Update(rxdata); + /* + * Path-e (4.8d) chord detection. Fires Save/LoadRequested on + * MENU+DOWN / MENU+UP held for the chord threshold. The actual + * save/load is dispatched via the savestate request queue — + * see main.c for the consumer task. + */ + { + ButtonChordEvent_t chord = ButtonChord_Update(rxdata); + if (chord != kButtonChord_None) { + FusionSavestate_PostChordRequest(chord); + } + } if ((rxdata & kButtonBits_MenuEnAlt) != 0 || (rxdata & kButtonBits_MenuEn) != 0) { OSD_SetVisiblityState(false); diff --git a/main/fpga_rx.h b/main/fpga_rx.h index 2a8eefd..3b721bc 100644 --- a/main/fpga_rx.h +++ b/main/fpga_rx.h @@ -18,11 +18,12 @@ typedef enum { } RxIDs_t; typedef enum { - kFPGA_RxConsts_BufferSize = 1024, // [bytes] + kFPGA_RxConsts_BufferSize = 32768, // [bytes] } FPGA_RxConsts_t; void FPGA_RxTask(void *arg); void FPGA_Rx_Resume(void); void FPGA_Rx_Pause(void); +void FPGA_Rx_ResetParser(void); void FPGA_Rx_UseBrightnessReadback(void); void FPGA_Tx_PokeButtons(void); diff --git a/main/fpga_state_port.c b/main/fpga_state_port.c new file mode 100644 index 0000000..23872a1 --- /dev/null +++ b/main/fpga_state_port.c @@ -0,0 +1,418 @@ +#include "fpga_state_port.h" + +#include + +/* + * MCU-side state-port session/stream state machine — implementation. + * + * Pure C; no FreeRTOS or ESP-IDF symbols. The transport layer is + * injected via callbacks so the same object compiles under both the + * firmware build and the host self-test. + */ + +static bool TxFrame(FusionStatePort_t *p, const FusionV2Frame_t *f) +{ + if (p->transport.tx_frame == NULL) { + return false; + } + return p->transport.tx_frame(p->transport.user, f->bytes, f->length); +} + +static void RefreshDeadline(FusionStatePort_t *p) +{ + if (p->transport.now_us != NULL && p->session_timeout_us > 0u) { + p->deadline_us = p->transport.now_us(p->transport.user) + p->session_timeout_us; + } else { + p->deadline_us = 0u; + } +} + +static void ClearStream(FusionStatePort_t *p) +{ + p->stream_mode = kFusionStream_None; + p->stream_accepted = false; + p->pending_op = 0u; + p->region = 0u; + p->offset = 0u; + p->remaining = 0u; + p->expected_seq = 0u; +} + +static void EnterAborted(FusionStatePort_t *p, uint8_t code, uint8_t detail) +{ + if (p->session_state != kFusionSession_Aborted) { + p->stat_aborts++; + } + p->session_state = kFusionSession_Aborted; + p->abort_pending = true; + p->last_error_code = code; + p->last_error_detail = detail; + p->stream_generation++; + ClearStream(p); +} + +void FusionStatePort_Init(FusionStatePort_t *p, + const FusionStatePortTransport_t *transport) +{ + if (p == NULL) { + return; + } + memset(p, 0, sizeof(*p)); + if (transport != NULL) { + p->transport = *transport; + } + p->session_timeout_us = FUSION_STATE_PORT_TIMEOUT_115K_US; +} + +void FusionStatePort_Reset(FusionStatePort_t *p) +{ + if (p == NULL) { + return; + } + const FusionStatePortTransport_t t = p->transport; + const uint64_t timeout = p->session_timeout_us; + memset(p, 0, sizeof(*p)); + p->transport = t; + p->session_timeout_us = timeout; +} + +void FusionStatePort_SetSessionTimeoutUs(FusionStatePort_t *p, uint64_t timeout_us) +{ + if (p == NULL) { + return; + } + p->session_timeout_us = timeout_us; +} + +bool FusionStatePort_IsAbortPending(const FusionStatePort_t *p) +{ + return (p != NULL) && p->abort_pending; +} + +void FusionStatePort_RequestAbort(FusionStatePort_t *p, uint8_t code, uint8_t detail) +{ + if (p == NULL) { + return; + } + if (p->session_state == kFusionSession_Idle) { + return; + } + /* + * Send ERROR(LocalAbort) to FPGA so its ABORTED state engages even if + * MCU-side bytes already in the UART FIFO continue to leave the chip. + * Any tx failure is non-fatal here: the caller is already aborting. + */ + FusionV2Frame_t f; + if (FusionSavestate_BuildError(code, detail, &f)) { + (void)TxFrame(p, &f); + } + EnterAborted(p, code, detail); +} + +void FusionStatePort_TickWatchdog(FusionStatePort_t *p) +{ + if (p == NULL || p->session_state == kFusionSession_Idle) { + return; + } + if (p->transport.now_us == NULL || p->deadline_us == 0u) { + return; + } + const uint64_t now = p->transport.now_us(p->transport.user); + if (now >= p->deadline_us) { + FusionStatePort_RequestAbort(p, + (uint8_t)kFusionErr_Timeout, + (uint8_t)kFusionErr_LocalAbort); + } +} + +/* ---- Session control ---- */ + +static FusionStatePortResult_t BeginSession(FusionStatePort_t *p, + FusionSessionState_t target, + uint8_t opcode, + uint8_t flags) +{ + if (p == NULL) { + return kFusionStatePortRes_BadArg; + } + if (p->session_state != kFusionSession_Idle) { + return kFusionStatePortRes_NotIdle; + } + FusionV2Frame_t f; + bool built = (opcode == (uint8_t)kFusionOp_BeginSave) + ? FusionSavestate_BuildBeginSave(flags, &f) + : FusionSavestate_BuildBeginLoad(flags, &f); + if (!built) { + return kFusionStatePortRes_BadArg; + } + if (!TxFrame(p, &f)) { + return kFusionStatePortRes_TxFailed; + } + p->session_state = target; + p->abort_pending = false; + p->stat_aborts = p->stat_aborts; /* untouched */ + p->pending_op = opcode; + p->stream_generation++; + RefreshDeadline(p); + return kFusionStatePortRes_Ok; +} + +FusionStatePortResult_t FusionStatePort_BeginSave(FusionStatePort_t *p, uint8_t flags) +{ + return BeginSession(p, kFusionSession_OpenSave, + (uint8_t)kFusionOp_BeginSave, flags); +} + +FusionStatePortResult_t FusionStatePort_BeginLoad(FusionStatePort_t *p, uint8_t flags) +{ + return BeginSession(p, kFusionSession_OpenLoad, + (uint8_t)kFusionOp_BeginLoad, flags); +} + +FusionStatePortResult_t FusionStatePort_EndSession(FusionStatePort_t *p) +{ + if (p == NULL) { + return kFusionStatePortRes_BadArg; + } + if (p->session_state == kFusionSession_Idle) { + return kFusionStatePortRes_NoSession; + } + FusionV2Frame_t f; + if (FusionSavestate_BuildEndSession(&f)) { + (void)TxFrame(p, &f); + } + p->session_state = kFusionSession_Idle; + p->abort_pending = false; + p->deadline_us = 0u; + p->stream_generation++; + ClearStream(p); + return kFusionStatePortRes_Ok; +} + +/* ---- Stream control ---- */ + +static FusionStatePortResult_t BeginStream(FusionStatePort_t *p, + FusionStreamMode_t mode, + uint8_t opcode, + uint8_t region, + uint16_t offset, + uint16_t length) +{ + if (p == NULL) { + return kFusionStatePortRes_BadArg; + } + if (p->session_state != kFusionSession_OpenSave + && p->session_state != kFusionSession_OpenLoad) { + return kFusionStatePortRes_NoSession; + } + if (p->stream_mode != kFusionStream_None) { + return kFusionStatePortRes_StreamNotActive; /* stream already in flight */ + } + if (length == 0u) { + return kFusionStatePortRes_BadArg; + } + FusionV2Frame_t f; + bool built = (mode == kFusionStream_Read) + ? FusionSavestate_BuildReadStreamBegin(region, offset, length, &f) + : FusionSavestate_BuildWriteStreamBegin(region, offset, length, &f); + if (!built) { + return kFusionStatePortRes_BadArg; + } + if (!TxFrame(p, &f)) { + return kFusionStatePortRes_TxFailed; + } + + p->stream_mode = mode; + p->stream_accepted = false; + p->pending_op = opcode; + p->region = region; + p->offset = offset; + p->remaining = length; + p->expected_seq = 0u; + p->stream_generation++; + RefreshDeadline(p); + return kFusionStatePortRes_Ok; +} + +FusionStatePortResult_t FusionStatePort_BeginReadStream(FusionStatePort_t *p, + uint8_t region, + uint16_t offset, + uint16_t length) +{ + return BeginStream(p, kFusionStream_Read, + (uint8_t)kFusionOp_ReadStreamBegin, + region, offset, length); +} + +FusionStatePortResult_t FusionStatePort_BeginWriteStream(FusionStatePort_t *p, + uint8_t region, + uint16_t offset, + uint16_t length) +{ + return BeginStream(p, kFusionStream_Write, + (uint8_t)kFusionOp_WriteStreamBegin, + region, offset, length); +} + +FusionStatePortResult_t FusionStatePort_PushWriteData(FusionStatePort_t *p, + const uint8_t *data, + size_t data_len) +{ + if (p == NULL) { + return kFusionStatePortRes_BadArg; + } + if (p->stream_mode != kFusionStream_Write) { + return kFusionStatePortRes_StreamWrongMode; + } + if (!p->stream_accepted) { + /* + * Allow MCU to enqueue first DATA before ACK_ACCEPTED arrives only + * if the firmware decides to pipeline; for now require accept first + * to keep abort fences clean. + */ + return kFusionStatePortRes_StreamNotActive; + } + if (p->abort_pending || p->session_state == kFusionSession_Aborted) { + return kFusionStatePortRes_Aborted; + } + if (data_len == 0u || data_len > FUSION_STATE_DATA_MAX_DATA) { + return kFusionStatePortRes_DataTooLong; + } + if (data_len > p->remaining) { + FusionStatePort_RequestAbort(p, + (uint8_t)kFusionErr_LenMismatch, + (uint8_t)kFusionErr_LocalAbort); + return kFusionStatePortRes_StreamExhausted; + } + + FusionV2Frame_t f; + if (!FusionSavestate_BuildStateData(p->expected_seq, data, data_len, &f)) { + return kFusionStatePortRes_BadArg; + } + if (!TxFrame(p, &f)) { + return kFusionStatePortRes_TxFailed; + } + p->expected_seq = (uint16_t)(p->expected_seq + 1u); + p->offset = (uint16_t)(p->offset + data_len); + p->remaining = (uint16_t)(p->remaining - data_len); + p->stat_data_packets++; + RefreshDeadline(p); + return kFusionStatePortRes_Ok; +} + +/* ---- RX delivery ---- */ + +static FusionStatePortResult_t HandleStateData(FusionStatePort_t *p, + const FusionV2Decoded_t *d) +{ + if (p->stream_mode != kFusionStream_Read) { + /* DATA can only arrive on a Read stream. Drop without aborting if + * the session isn't expecting it (e.g. crossed wire on a Write). */ + p->stat_late_ignored++; + return kFusionStatePortRes_LateIgnored; + } + if (!p->stream_accepted) { + /* Doc allows FPGA to begin emitting DATA only after ACK_ACCEPTED. + * Treat early DATA as protocol violation. */ + FusionStatePort_RequestAbort(p, + (uint8_t)kFusionErr_BadPayload, + (uint8_t)kFusionOp_AckAccepted); + return kFusionStatePortRes_Aborted; + } + if (d->data_seq != p->expected_seq) { + FusionStatePort_RequestAbort(p, + (uint8_t)kFusionErr_SeqMismatch, + (uint8_t)(d->data_seq & 0xFFu)); + return kFusionStatePortRes_Aborted; + } + if (d->data_len == 0u || d->data_len > p->remaining) { + FusionStatePort_RequestAbort(p, + (uint8_t)kFusionErr_LenMismatch, + d->data_len); + return kFusionStatePortRes_Aborted; + } + p->expected_seq = (uint16_t)(p->expected_seq + 1u); + p->offset = (uint16_t)(p->offset + d->data_len); + p->remaining = (uint16_t)(p->remaining - d->data_len); + p->stat_data_packets++; + RefreshDeadline(p); + return kFusionStatePortRes_Ok; +} + +FusionStatePortResult_t FusionStatePort_OnRxFrame(FusionStatePort_t *p, + const FusionV2Decoded_t *d) +{ + if (p == NULL || d == NULL) { + return kFusionStatePortRes_BadArg; + } + if (p->session_state == kFusionSession_Idle) { + p->stat_late_ignored++; + return kFusionStatePortRes_LateIgnored; + } + if (p->session_state == kFusionSession_Aborted) { + /* In Aborted state we keep the session alive only long enough for + * the caller to send END_SESSION. Drop everything else. + * + * Per architecture doc: + * "MCU clears its RX queue after abort and ignores any late + * ACK_DONE from the failed stream generation." + */ + p->stat_late_ignored++; + return kFusionStatePortRes_LateIgnored; + } + if (d->addr == (uint8_t)kFusionAddr_StateData) { + return HandleStateData(p, d); + } + if (d->addr != (uint8_t)kFusionAddr_StateCtl) { + /* STATE_EVENT (0x22) is reserved for Phase 2+; ignore for now. */ + return kFusionStatePortRes_LateIgnored; + } + + switch (d->ctl_opcode) { + case kFusionOp_AckAccepted: + if (d->ack_for_opcode != p->pending_op) { + p->stat_late_ignored++; + return kFusionStatePortRes_LateIgnored; + } + if (p->stream_mode != kFusionStream_None) { + p->stream_accepted = true; + } + RefreshDeadline(p); + return kFusionStatePortRes_Ok; + + case kFusionOp_Busy: + if (d->ack_for_opcode != p->pending_op) { + p->stat_late_ignored++; + return kFusionStatePortRes_LateIgnored; + } + p->stat_busy_received++; + RefreshDeadline(p); + return kFusionStatePortRes_Ok; + + case kFusionOp_AckDone: + if (d->ack_for_opcode != p->pending_op) { + p->stat_late_ignored++; + return kFusionStatePortRes_LateIgnored; + } + if (p->stream_mode != kFusionStream_None) { + ClearStream(p); + } else { + p->pending_op = 0u; + } + RefreshDeadline(p); + return kFusionStatePortRes_Ok; + + case kFusionOp_Error: + p->stat_errors_received++; + EnterAborted(p, d->error_code, d->error_detail); + return kFusionStatePortRes_Aborted; + + default: + /* Unknown CTL opcode is unexpected in MCU's RX direction. Abort + * loudly so the engineer sees a real protocol mismatch. */ + EnterAborted(p, + (uint8_t)kFusionErr_BadOpcode, + d->ctl_opcode); + return kFusionStatePortRes_Aborted; + } +} diff --git a/main/fpga_state_port.h b/main/fpga_state_port.h new file mode 100644 index 0000000..cd6deea --- /dev/null +++ b/main/fpga_state_port.h @@ -0,0 +1,156 @@ +#pragma once + +#include "fusion_savestate.h" + +#include +#include +#include + +/* + * MCU-side state-port session/stream state machine. + * + * Owns the rules from the architecture doc: + * - one outstanding stream + * - per-stream 16-bit sequence counter starting at 0 + * - wrong seq, wrong len, ERROR, or unexpected packet -> abort + * - after abort, late ACK_DONE / STATE_DATA from the failed generation + * are dropped, not applied + * + * Pure C: the transport (UART tx) and clock are injected via callbacks + * so the same state machine compiles against ESP-IDF on device or against + * a host mock for unit tests. + */ + +#ifdef __cplusplus +extern "C" { +#endif + +typedef enum { + kFusionStatePortRes_Ok = 0, + kFusionStatePortRes_BadArg, + kFusionStatePortRes_NotIdle, + kFusionStatePortRes_NoSession, + kFusionStatePortRes_StreamNotActive, + kFusionStatePortRes_StreamWrongMode, + kFusionStatePortRes_DataTooLong, + kFusionStatePortRes_StreamExhausted, + kFusionStatePortRes_Aborted, + kFusionStatePortRes_LateIgnored, + kFusionStatePortRes_Unexpected, + kFusionStatePortRes_TxFailed, + kFusionStatePortRes_TimedOut, +} FusionStatePortResult_t; + +typedef enum { + kFusionSession_Idle = 0, + kFusionSession_OpenSave, + kFusionSession_OpenLoad, + kFusionSession_Aborted, +} FusionSessionState_t; + +typedef enum { + kFusionStream_None = 0, + kFusionStream_Read, /* MCU receives DATA from FPGA */ + kFusionStream_Write, /* MCU sends DATA to FPGA */ +} FusionStreamMode_t; + +/* + * Transport injection. tx_frame is invoked with a fully built V2 frame + * (header...crc) and must move all bytes to the FPGA in order. + * + * now_us is optional; if NULL, the session watchdog is inactive. + */ +typedef struct { + void *user; + bool (*tx_frame)(void *user, const uint8_t *frame, size_t len); + uint64_t (*now_us)(void *user); +} FusionStatePortTransport_t; + +/* + * Session/stream state. Plain struct; allocate wherever convenient. + * stream_generation is bumped on every begin/abort so callers can fence + * late responses against the right epoch. + */ +typedef struct { + FusionStatePortTransport_t transport; + + FusionSessionState_t session_state; + FusionStreamMode_t stream_mode; + bool stream_accepted; /* true after ACK_ACCEPTED for current stream */ + uint8_t pending_op; /* opcode for which an ACK is expected, 0 if none */ + uint32_t stream_generation; /* increments per stream begin and per abort */ + + uint8_t region; + uint16_t offset; /* current cursor relative to region start */ + uint16_t remaining; /* bytes remaining in active stream */ + uint16_t expected_seq; + + uint8_t last_error_code; + uint8_t last_error_detail; + + uint64_t deadline_us; + uint64_t session_timeout_us; + bool abort_pending; + + /* Counters useful for tests and telemetry. */ + uint32_t stat_data_packets; + uint32_t stat_late_ignored; + uint32_t stat_aborts; + uint32_t stat_errors_received; + uint32_t stat_busy_received; +} FusionStatePort_t; + +/* Default session watchdogs from the architecture doc. */ +#define FUSION_STATE_PORT_TIMEOUT_115K_US (30ull * 1000ull * 1000ull) +#define FUSION_STATE_PORT_TIMEOUT_1M_US (5ull * 1000ull * 1000ull) + +void FusionStatePort_Init(FusionStatePort_t *p, + const FusionStatePortTransport_t *transport); +void FusionStatePort_Reset(FusionStatePort_t *p); + +void FusionStatePort_SetSessionTimeoutUs(FusionStatePort_t *p, uint64_t timeout_us); + +/* Session control. */ +FusionStatePortResult_t FusionStatePort_BeginSave(FusionStatePort_t *p, uint8_t flags); +FusionStatePortResult_t FusionStatePort_BeginLoad(FusionStatePort_t *p, uint8_t flags); +FusionStatePortResult_t FusionStatePort_EndSession(FusionStatePort_t *p); + +/* Stream control. length is region-local bytes; offset is region-local. */ +FusionStatePortResult_t FusionStatePort_BeginReadStream(FusionStatePort_t *p, + uint8_t region, + uint16_t offset, + uint16_t length); +FusionStatePortResult_t FusionStatePort_BeginWriteStream(FusionStatePort_t *p, + uint8_t region, + uint16_t offset, + uint16_t length); + +/* + * Push the next chunk of write-stream payload. data_len must be <= 8. + * On success the frame is sent and expected_seq advances. Caller is + * responsible for honoring abort_pending between calls. + */ +FusionStatePortResult_t FusionStatePort_PushWriteData(FusionStatePort_t *p, + const uint8_t *data, + size_t data_len); + +/* Deliver a fully decoded frame from the RX side. */ +FusionStatePortResult_t FusionStatePort_OnRxFrame(FusionStatePort_t *p, + const FusionV2Decoded_t *decoded); + +/* + * Async abort hook. Safe to call from RX task or watchdog. The state + * machine sends ERROR(LocalAbort) to the FPGA, transitions to Aborted, + * bumps the stream generation, and clears pending_op so subsequent late + * responses are dropped. + */ +void FusionStatePort_RequestAbort(FusionStatePort_t *p, uint8_t code, uint8_t detail); + +bool FusionStatePort_IsAbortPending(const FusionStatePort_t *p); + +/* Watchdog tick. If now_us was provided and deadline elapsed, force an abort. */ +void FusionStatePort_TickWatchdog(FusionStatePort_t *p); + +#ifdef __cplusplus +} +#endif diff --git a/main/fpga_tx.c b/main/fpga_tx.c index 1216f8a..e203873 100644 --- a/main/fpga_tx.c +++ b/main/fpga_tx.c @@ -71,6 +71,7 @@ static StaticEventGroup_t xCreatedEventGroup; static const char* TAG = "FpgaTx"; static size_t SetupTxBuffer(uint8_t *const pBuffer, TxIDs_t eID, uint8_t Len, void* pData); +static void WriteUartOwned(const uint8_t *pBuffer, size_t Size); void FPGA_TxTask(void *arg) { @@ -109,7 +110,7 @@ void FPGA_TxTask(void *arg) if (Backlight < MaxDisplayBrightness) { const size_t Size = SetupTxBuffer(TxBuffer, kTxCmd_BacklightCtl, sizeof(Backlight), (void*)&Backlight); - (void) uart_write_bytes(UART_NUM_1, TxBuffer, Size); + WriteUartOwned(TxBuffer, Size); } } @@ -128,28 +129,28 @@ void FPGA_TxTask(void *arg) const uint16_t Payload = ( (frame_blending << 1) | (color_correct << 2) | ismuted | (playernum << 4) | (EnableScreenTransitionFix << 12) | (IgnoreDiagonalInputs << 11) | (LowBattIconControl << 13)); const size_t Size = SetupTxBuffer(TxBuffer, kTxCmd_SysCtrl, sizeof(Payload), (void*)&Payload); - (void) uart_write_bytes(UART_NUM_1, TxBuffer, Size); + WriteUartOwned(TxBuffer, Size); } if ((EventBits & kTxFlag_RequestFWVer) == kTxFlag_RequestFWVer) { uint16_t dummy = 0; const size_t Size = SetupTxBuffer(TxBuffer, kTxCmd_ReqFWVer, sizeof(dummy), &dummy); - (void) uart_write_bytes(UART_NUM_1, TxBuffer, Size); + WriteUartOwned(TxBuffer, Size); } if ((EventBits & kTxFlag_PokeButton) == kTxFlag_PokeButton) { const uint16_t PokedButtons = Button_GetPokedInputs(); const size_t Size = SetupTxBuffer(TxBuffer, kTxCmd_PokeButton, sizeof(PokedButtons), (void*)&PokedButtons); - (void) uart_write_bytes(UART_NUM_1, TxBuffer, Size); + WriteUartOwned(TxBuffer, Size); } if ((EventBits & kTxFlag_RequestBGPD) == kTxFlag_RequestBGPD) { uint16_t dummy = 0; const size_t Size = SetupTxBuffer(TxBuffer, kTxCmd_ReqBGPD, sizeof(dummy), &dummy); - (void) uart_write_bytes(UART_NUM_1, TxBuffer, Size); + WriteUartOwned(TxBuffer, Size); } if ((EventBits & kTxFlag_SetPaletteStyle) == kTxFlag_SetPaletteStyle) @@ -168,19 +169,19 @@ void FPGA_TxTask(void *arg) // BG const size_t Size = SetupTxBuffer(TxBuffer, kTxCmd_BGPaletteCtl, sizeof(PayloadBG), (void*)&PayloadBG); - (void) uart_write_bytes(UART_NUM_1, TxBuffer, Size); + WriteUartOwned(TxBuffer, Size); // Sprite - Obj0 const uint64_t ColorObj0 = Pal_GetColor(ID, kPalette_Obj0); const uint64_t PayloadObj0 = __builtin_bswap64(ColorObj0); const size_t Size2 = SetupTxBuffer(TxBuffer, kTxCmd_SpritePaletteCtl, sizeof(PayloadObj0), (void*)&PayloadObj0); - (void)uart_write_bytes(UART_NUM_1, TxBuffer, Size2); + WriteUartOwned(TxBuffer, Size2); // Sprite - Obj1 const uint64_t ColorObj1 = Pal_GetColor(ID, kPalette_Obj1); const uint64_t PayloadObj1 = __builtin_bswap64(ColorObj1 | ((uint64_t)1 << kCustomPaletteObjSel)); const size_t Size3 = SetupTxBuffer(TxBuffer, kTxCmd_SpritePaletteCtl, sizeof(PayloadObj1), (void*)&PayloadObj1); - (void)uart_write_bytes(UART_NUM_1, TxBuffer, Size3); + WriteUartOwned(TxBuffer, Size3); } memset(TxBuffer, 0x0, sizeof(TxBuffer)); @@ -261,3 +262,14 @@ static size_t SetupTxBuffer(uint8_t *const pBuffer, TxIDs_t eID, uint8_t Len, vo return MsgSize; } + +static void WriteUartOwned(const uint8_t *pBuffer, size_t Size) +{ + if (pBuffer == NULL || Size == 0u) { + return; + } + if (FPGA_UartOwnerAcquire(pdMS_TO_TICKS(100))) { + (void)uart_write_bytes(UART_NUM_1, pBuffer, Size); + FPGA_UartOwnerRelease(); + } +} diff --git a/main/fusion_savestate.c b/main/fusion_savestate.c new file mode 100644 index 0000000..e0ee983 --- /dev/null +++ b/main/fusion_savestate.c @@ -0,0 +1,440 @@ +#include "fusion_savestate.h" + +#include "crc8_sae_j1850.h" + +#include + +/* + * Fusion savestate protocol/format primitives — implementation. + * + * No FreeRTOS, no ESP-IDF: this object compiles cleanly on the host + * for unit tests and links into the firmware via the standard ESP-IDF + * component build (see main/CMakeLists.txt). + */ + +/* ---- V2 frame helpers ---- */ + +bool FusionSavestate_BuildV2Frame(uint8_t addr, + const uint8_t *payload, + size_t payload_len, + FusionV2Frame_t *out) +{ + if (out == NULL) { + return false; + } + if ((addr & 0x80u) != 0u) { + /* V2 wrapper uses 7-bit address space; bit 7 collides with the marker. */ + return false; + } + if (payload_len > FUSION_V2_MAX_PAYLOAD) { + return false; + } + if (payload_len > 0u && payload == NULL) { + return false; + } + + uint8_t *b = out->bytes; + b[0] = FUSION_V2_HEADER_MARKER; + b[1] = addr; + b[2] = (uint8_t)payload_len; + if (payload_len > 0u) { + memcpy(&b[3], payload, payload_len); + } + + const size_t crc_in = 3u + payload_len; + const size_t total = crc8_sae_j1850_encode(b, crc_in, b); + if (total != crc_in + 1u) { + out->length = 0u; + return false; + } + out->length = (uint8_t)total; + return true; +} + +bool FusionSavestate_DecodeV2Frame(const uint8_t *frame, + size_t frame_len, + FusionV2Decoded_t *out) +{ + if (frame == NULL || out == NULL) { + return false; + } + if (frame_len < FUSION_V2_FRAME_OVERHEAD || frame_len > FUSION_V2_MAX_FRAME) { + return false; + } + if (frame[0] != FUSION_V2_HEADER_MARKER) { + return false; + } + const uint8_t addr = frame[1]; + if ((addr & 0x80u) != 0u) { + return false; + } + const uint8_t plen = frame[2]; + if (plen > FUSION_V2_MAX_PAYLOAD) { + return false; + } + if (frame_len != (size_t)(3u + plen + 1u)) { + return false; + } + if (!crc8_sae_j1850_decode(frame, frame_len)) { + return false; + } + + memset(out, 0, sizeof(*out)); + out->addr = addr; + out->payload_len = plen; + if (plen > 0u) { + memcpy(out->payload, &frame[3], plen); + } + + if (addr == kFusionAddr_StateCtl && plen >= 1u) { + out->ctl_opcode = out->payload[0]; + switch (out->ctl_opcode) { + case kFusionOp_AckAccepted: + case kFusionOp_Busy: + case kFusionOp_AckDone: + if (plen >= 2u) { + out->ack_for_opcode = out->payload[1]; + } + break; + case kFusionOp_Error: + if (plen >= 2u) { + out->error_code = out->payload[1]; + } + if (plen >= 3u) { + out->error_detail = out->payload[2]; + } + break; + default: + break; + } + } else if (addr == kFusionAddr_StateData) { + if (plen < FUSION_STATE_DATA_HEADER_BYTES) { + return false; + } + out->data_seq = (uint16_t)out->payload[0] + | ((uint16_t)out->payload[1] << 8); + out->data_len = (uint8_t)(plen - FUSION_STATE_DATA_HEADER_BYTES); + if (out->data_len > FUSION_STATE_DATA_MAX_DATA) { + return false; + } + if (out->data_len > 0u) { + memcpy(out->data, &out->payload[2], out->data_len); + } + } + return true; +} + +/* ---- STATE_CTL builder helpers ---- */ + +static bool BuildCtl(const uint8_t *payload, size_t payload_len, FusionV2Frame_t *out) +{ + return FusionSavestate_BuildV2Frame((uint8_t)kFusionAddr_StateCtl, + payload, payload_len, out); +} + +bool FusionSavestate_BuildBeginSave(uint8_t flags, FusionV2Frame_t *out) +{ + const uint8_t p[2] = { (uint8_t)kFusionOp_BeginSave, flags }; + return BuildCtl(p, sizeof(p), out); +} + +bool FusionSavestate_BuildBeginLoad(uint8_t flags, FusionV2Frame_t *out) +{ + const uint8_t p[2] = { (uint8_t)kFusionOp_BeginLoad, flags }; + return BuildCtl(p, sizeof(p), out); +} + +bool FusionSavestate_BuildBeginTestRW(uint8_t flags, FusionV2Frame_t *out) +{ + const uint8_t p[2] = { (uint8_t)kFusionOp_BeginTestRW, flags }; + return BuildCtl(p, sizeof(p), out); +} + +bool FusionSavestate_BuildEndSession(FusionV2Frame_t *out) +{ + const uint8_t p[1] = { (uint8_t)kFusionOp_EndSession }; + return BuildCtl(p, sizeof(p), out); +} + +bool FusionSavestate_BuildSeek(uint8_t region, uint16_t offset, FusionV2Frame_t *out) +{ + const uint8_t p[4] = { + (uint8_t)kFusionOp_Seek, + region, + (uint8_t)(offset & 0xFFu), + (uint8_t)((offset >> 8) & 0xFFu), + }; + return BuildCtl(p, sizeof(p), out); +} + +bool FusionSavestate_BuildReadNext(uint8_t count, FusionV2Frame_t *out) +{ + if (count == 0u || count > FUSION_STATE_DATA_MAX_DATA) { + return false; + } + const uint8_t p[2] = { (uint8_t)kFusionOp_ReadNext, count }; + return BuildCtl(p, sizeof(p), out); +} + +static bool BuildStreamBegin(uint8_t opcode, + uint8_t region, + uint16_t offset, + uint16_t length, + FusionV2Frame_t *out) +{ + const uint8_t p[6] = { + opcode, + region, + (uint8_t)(offset & 0xFFu), + (uint8_t)((offset >> 8) & 0xFFu), + (uint8_t)(length & 0xFFu), + (uint8_t)((length >> 8) & 0xFFu), + }; + return BuildCtl(p, sizeof(p), out); +} + +bool FusionSavestate_BuildReadStreamBegin(uint8_t region, + uint16_t offset, + uint16_t length, + FusionV2Frame_t *out) +{ + return BuildStreamBegin((uint8_t)kFusionOp_ReadStreamBegin, + region, offset, length, out); +} + +bool FusionSavestate_BuildReadStreamContinue(uint16_t next_seq, + FusionV2Frame_t *out) +{ + const uint8_t p[3] = { + (uint8_t)kFusionOp_ReadStreamContinue, + (uint8_t)(next_seq & 0xFFu), + (uint8_t)((next_seq >> 8) & 0xFFu), + }; + return BuildCtl(p, sizeof(p), out); +} + +bool FusionSavestate_BuildWriteStreamBegin(uint8_t region, + uint16_t offset, + uint16_t length, + FusionV2Frame_t *out) +{ + return BuildStreamBegin((uint8_t)kFusionOp_WriteStreamBegin, + region, offset, length, out); +} + +bool FusionSavestate_BuildWriteCommit(FusionV2Frame_t *out) +{ + const uint8_t p[1] = { (uint8_t)kFusionOp_WriteCommit }; + return BuildCtl(p, sizeof(p), out); +} + +bool FusionSavestate_BuildStateData(uint16_t seq, + const uint8_t *data, + size_t data_len, + FusionV2Frame_t *out) +{ + if (data_len > FUSION_STATE_DATA_MAX_DATA) { + return false; + } + if (data_len > 0u && data == NULL) { + return false; + } + uint8_t buf[FUSION_STATE_DATA_HEADER_BYTES + FUSION_STATE_DATA_MAX_DATA]; + buf[0] = (uint8_t)(seq & 0xFFu); + buf[1] = (uint8_t)((seq >> 8) & 0xFFu); + if (data_len > 0u) { + memcpy(&buf[2], data, data_len); + } + return FusionSavestate_BuildV2Frame((uint8_t)kFusionAddr_StateData, + buf, + FUSION_STATE_DATA_HEADER_BYTES + data_len, + out); +} + +bool FusionSavestate_BuildAckAccepted(uint8_t for_opcode, FusionV2Frame_t *out) +{ + const uint8_t p[2] = { (uint8_t)kFusionOp_AckAccepted, for_opcode }; + return BuildCtl(p, sizeof(p), out); +} + +bool FusionSavestate_BuildBusy(uint8_t for_opcode, FusionV2Frame_t *out) +{ + const uint8_t p[2] = { (uint8_t)kFusionOp_Busy, for_opcode }; + return BuildCtl(p, sizeof(p), out); +} + +bool FusionSavestate_BuildAckDone(uint8_t for_opcode, FusionV2Frame_t *out) +{ + const uint8_t p[2] = { (uint8_t)kFusionOp_AckDone, for_opcode }; + return BuildCtl(p, sizeof(p), out); +} + +bool FusionSavestate_BuildError(uint8_t code, uint8_t detail, FusionV2Frame_t *out) +{ + const uint8_t p[3] = { (uint8_t)kFusionOp_Error, code, detail }; + return BuildCtl(p, sizeof(p), out); +} + +/* ---- CRC-32/ISO-HDLC (poly 0xEDB88320 reversed, init 0xFFFFFFFF, xor-out 0xFFFFFFFF) ---- */ + +uint32_t FusionSavestate_Crc32Update(uint32_t prior_crc, const uint8_t *data, size_t len) +{ + uint32_t crc = prior_crc ^ 0xFFFFFFFFu; + for (size_t i = 0; i < len; ++i) { + crc ^= data[i]; + for (unsigned bit = 0; bit < 8u; ++bit) { + const uint32_t mask = (uint32_t)-(int32_t)(crc & 1u); + crc = (crc >> 1) ^ (0xEDB88320u & mask); + } + } + return crc ^ 0xFFFFFFFFu; +} + +uint32_t FusionSavestate_Crc32(const uint8_t *data, size_t len) +{ + return FusionSavestate_Crc32Update(0u, data, len); +} + +uint32_t FusionSavestate_ComputeGameIdV1(const uint8_t rom_header[FUSION_ROM_HEADER_BYTES]) +{ + if (rom_header == NULL) { + return 0u; + } + return FusionSavestate_Crc32(rom_header, FUSION_ROM_HEADER_BYTES); +} + +/* + * CPU region (region 0x02) saved-slot byte offsets per FPGA mapping + * (verified against T80.vhd, T80_Reg.vhd, GBse.vhd): + * + * offset 0-1 GBSE state + * offset 2-9 T80_Reg CPUREGS file (RegsL/H 0-3) + * 2: RegsL(0) = C + * 3: RegsL(1) = E + * 4: RegsL(2) = L + * 5: RegsL(3) = unused (GB only uses 0-2 of L bank) + * 6: RegsH(0) = B + * 7: RegsH(1) = D + * 8: RegsH(2) = H + * 9: RegsH(3) = unused + * offset 10-17 T80 SS_1 + * 10-11: PC (lo, hi) + * 12-13: address bus latch (NOT visible A) + * offset 18-24 T80 SS_2 + * 19: SS_2[15:8] = ACC (visible A) + * offset 25-32 T80 SS_3 + * 25-26: SP (lo, hi) + * 27: SS_3[23:16] = Read_To_Reg_r + low F bits (always 0 in GB mode) + * 28: SS_3[31:24] - bits 4-1 = F[7:4] = Z/N/H/C; bits 7-5 = Arith16_r etc + * 31: SS_3[55:48] - bit 0 = Halt_FF, bit 4 = IntE_FF1 (saved_IFF) + * offset 33-39 T80 SS_4 (RegBus/Bus latches, not used by path-e) + * + * F register: GB mode T80 hardware-clamps F[3:0]=0 (T80.vhd line 897-899). + * Non-byte-aligned extraction: + * F[7] = byte28 bit 4 (Z) + * F[6] = byte28 bit 3 (N) + * F[5] = byte28 bit 2 (H) + * F[4] = byte28 bit 1 (C) + * F[3:0] = 0 + * => F = (byte28 << 3) & 0xF0 + */ +#define PATHE_CPU_REGION_MIN_BYTES 32u +#define PATHE_CPU_OFF_C 2u +#define PATHE_CPU_OFF_E 3u +#define PATHE_CPU_OFF_L 4u +#define PATHE_CPU_OFF_B 6u +#define PATHE_CPU_OFF_D 7u +#define PATHE_CPU_OFF_H 8u +#define PATHE_CPU_OFF_PC_LO 10u +#define PATHE_CPU_OFF_PC_HI 11u +#define PATHE_CPU_OFF_ACC 19u +#define PATHE_CPU_OFF_SP_LO 25u +#define PATHE_CPU_OFF_SP_HI 26u +#define PATHE_CPU_OFF_F_BYTE 28u +#define PATHE_CPU_OFF_HALT_IFF_BYTE 31u +#define PATHE_HALT_FF_BIT 0x01u +#define PATHE_IFF_FF1_BIT 0x10u /* SS_3[52] = bit 4 of byte 31 */ + +bool FusionSavestate_ExtractPathELoadInputFromCpuRegion( + const uint8_t *cpu_region_bytes, + size_t cpu_region_len, + FusionPathELoadInput_t *out) +{ + if (cpu_region_bytes == NULL || out == NULL) { + return false; + } + if (cpu_region_len < PATHE_CPU_REGION_MIN_BYTES) { + return false; + } + + out->saved_C = cpu_region_bytes[PATHE_CPU_OFF_C]; + out->saved_E = cpu_region_bytes[PATHE_CPU_OFF_E]; + out->saved_L = cpu_region_bytes[PATHE_CPU_OFF_L]; + out->saved_B = cpu_region_bytes[PATHE_CPU_OFF_B]; + out->saved_D = cpu_region_bytes[PATHE_CPU_OFF_D]; + out->saved_H = cpu_region_bytes[PATHE_CPU_OFF_H]; + + out->saved_A = cpu_region_bytes[PATHE_CPU_OFF_ACC]; + + /* F: extract bits Z/N/H/C from byte28 bits 4-1, F[3:0]=0 */ + const uint8_t byte28 = cpu_region_bytes[PATHE_CPU_OFF_F_BYTE]; + out->saved_F = (uint8_t)((byte28 << 3) & 0xF0u); + + out->saved_PC = (uint16_t)(cpu_region_bytes[PATHE_CPU_OFF_PC_LO] | + ((uint16_t)cpu_region_bytes[PATHE_CPU_OFF_PC_HI] << 8)); + out->saved_SP = (uint16_t)(cpu_region_bytes[PATHE_CPU_OFF_SP_LO] | + ((uint16_t)cpu_region_bytes[PATHE_CPU_OFF_SP_HI] << 8)); + + const uint8_t halt_iff_byte = cpu_region_bytes[PATHE_CPU_OFF_HALT_IFF_BYTE]; + out->halted_at_save = (halt_iff_byte & PATHE_HALT_FF_BIT) != 0u; + out->saved_IFF = (halt_iff_byte & PATHE_IFF_FF1_BIT) != 0u; + + return true; +} + +bool FusionSavestate_BuildPathELoadPayload(const FusionPathELoadInput_t *state, + uint8_t out[kPathE_RegionLength]) +{ + if (state == NULL || out == NULL) { + return false; + } + + /* + * HALT-during-save adjustment per Gate 1 contract §4: + * if Halt_FF=1 at save time, saved_PC := captured_PC - 1 + * so the CPU re-executes the HALT opcode and naturally re-enters + * HALT mode on load. + * + * Reject the (rare) case where halted_at_save=true and saved_PC=0 + * since PC-1 would underflow. A real GB cannot be in HALT at + * PC=$0000 in practice, but explicit rejection is safer than + * letting the load proceed with PC=$FFFF (cart bus high region). + */ + uint16_t adjusted_pc = state->saved_PC; + if (state->halted_at_save) { + if (state->saved_PC == 0u) { + return false; + } + adjusted_pc = (uint16_t)(state->saved_PC - 1u); + } + + out[kPathE_LoadModeActive] = 1u; /* arm load mode */ + out[kPathE_ScratchF] = state->saved_F; + out[kPathE_ScratchA] = state->saved_A; + out[kPathE_ScratchC] = state->saved_C; + out[kPathE_ScratchB] = state->saved_B; + out[kPathE_ScratchE] = state->saved_E; + out[kPathE_ScratchD] = state->saved_D; + out[kPathE_ScratchL] = state->saved_L; + out[kPathE_ScratchH] = state->saved_H; + out[kPathE_SavedSpLo] = (uint8_t)(state->saved_SP & 0xFFu); + out[kPathE_SavedSpHi] = (uint8_t)((state->saved_SP >> 8) & 0xFFu); + out[kPathE_IffByte] = state->saved_IFF ? FUSION_PATHE_IFF_BYTE_EI + : FUSION_PATHE_IFF_BYTE_NOP; + out[kPathE_SavedA] = state->saved_A; /* re-restore A after FF50 */ + out[kPathE_SavedPcLo] = (uint8_t)(adjusted_pc & 0xFFu); + out[kPathE_SavedPcHi] = (uint8_t)((adjusted_pc >> 8) & 0xFFu); + out[kPathE_GbresetRequest] = 0u; /* caller toggles via separate writes */ + + return true; +} diff --git a/main/fusion_savestate.h b/main/fusion_savestate.h new file mode 100644 index 0000000..cd4f80e --- /dev/null +++ b/main/fusion_savestate.h @@ -0,0 +1,350 @@ +#pragma once + +#include +#include +#include + +/* + * Fusion savestate protocol/format primitives. + * + * This header defines the wire format and on-flash format used by the + * MCU-owned save/load architecture documented in + * project-wiki/50_decisions/fusion-savestate-mcu-architecture.md. + * + * Pure C: no FreeRTOS, no ESP-IDF. Everything declared here is + * compilable on the host for unit tests. + */ + +#ifdef __cplusplus +extern "C" { +#endif + +/* Magic 'FUSS' interpreted little-endian. */ +#define FUSION_SAVESTATE_MAGIC 0x53535546u +#define FUSION_SAVESTATE_FORMAT_VERSION 1u +#define FUSION_GAME_ID_ALGORITHM_V1 1u +#define FUSION_VERSION_FIELD_LEN 8u +#define FUSION_ROM_HEADER_BYTES 0x50u /* ROM header bytes 0x100-0x14F */ + +/* V2 packet framing constants — must match fpga_common.h. */ +#define FUSION_V2_HEADER_MARKER 0x8Fu +#define FUSION_V2_MAX_PAYLOAD 10u +#define FUSION_V2_FRAME_OVERHEAD 4u /* header + addr + len + crc */ +#define FUSION_V2_MAX_FRAME (FUSION_V2_FRAME_OVERHEAD + FUSION_V2_MAX_PAYLOAD) + +/* STATE_DATA payload layout: seq16 (2 bytes) + up to 8 data bytes. */ +#define FUSION_STATE_DATA_HEADER_BYTES 2u +#define FUSION_STATE_DATA_MAX_DATA 8u + +/* V2 addresses reserved for state-port. */ +typedef enum { + kFusionAddr_StateCtl = 0x20u, + kFusionAddr_StateData = 0x21u, + kFusionAddr_StateEvent = 0x22u, /* Phase 2+ placeholder; payload schema deferred */ +} FusionAddr_t; + +/* STATE_CTL opcodes — see architecture doc table. */ +typedef enum { + /* MCU -> FPGA */ + kFusionOp_BeginSave = 0x01u, + kFusionOp_BeginLoad = 0x02u, + kFusionOp_EndSession = 0x03u, + kFusionOp_Seek = 0x04u, + kFusionOp_ReadNext = 0x05u, + kFusionOp_ReadStreamBegin = 0x06u, + kFusionOp_WriteStreamBegin = 0x07u, + kFusionOp_WriteCommit = 0x08u, + kFusionOp_ReadStreamContinue = 0x09u, + /* + * Phase 4.8c v2: mixed paused session. The bridge keeps + * session_pause asserted from BEGIN_TEST_RW until END_SESSION and + * accepts both READ_STREAM_* and WRITE_STREAM_* in the same + * session. Used only by the strict bit-exact RAM round-trip smoke + * harness; OP_BEGIN_SAVE / OP_BEGIN_LOAD semantics are unchanged. + */ + kFusionOp_BeginTestRW = 0x0Au, + /* FPGA -> MCU */ + kFusionOp_AckAccepted = 0x10u, + kFusionOp_Busy = 0x11u, + kFusionOp_Error = 0x12u, + kFusionOp_AckDone = 0x13u, +} FusionOpcode_t; + +/* Error codes carried in ERROR(opcode=0x12, code, detail). */ +typedef enum { + kFusionErr_None = 0x00u, + kFusionErr_BadOpcode = 0x01u, + kFusionErr_BadPayload = 0x02u, + kFusionErr_NoSession = 0x03u, + kFusionErr_StreamExists = 0x04u, + kFusionErr_NoStream = 0x05u, + kFusionErr_SeqMismatch = 0x06u, + kFusionErr_LenMismatch = 0x07u, + kFusionErr_OffsetOutOfRange = 0x08u, + kFusionErr_InvalidRegion = 0x09u, + kFusionErr_Aborted = 0x0Au, + kFusionErr_Timeout = 0x0Bu, + kFusionErr_Internal = 0x0Cu, + kFusionErr_LocalAbort = 0x0Du, /* MCU-local: aborted from this side */ +} FusionErrorCode_t; + +/* Region IDs (architecture doc Phase 4 plan — names, not adapter availability). + * + * Region 0xFE is path-e specific (Gate 1 contract for 4.8d). Lock should be + * amended with §6.4.8d path-e amendment text from the Gate 1 contract. + */ +typedef enum { + kFusionRegion_Header = 0x00u, + kFusionRegion_CoreScalar = 0x01u, + kFusionRegion_CpuRegs = 0x02u, + kFusionRegion_Timer = 0x03u, + kFusionRegion_Hdma = 0x04u, + kFusionRegion_PpuRegs = 0x05u, + kFusionRegion_PaletteRam = 0x06u, + kFusionRegion_ApuRegs = 0x07u, + kFusionRegion_WaveRam = 0x08u, + kFusionRegion_Hram = 0x09u, + kFusionRegion_Oam = 0x0Au, + kFusionRegion_Vram = 0x0Bu, + kFusionRegion_Wram = 0x0Cu, + kFusionRegion_RomHeader = 0x0Du, + kFusionRegion_CartShadow = 0x0Eu, + kFusionRegion_CartRam = 0x0Fu, + + /* + * Path-e load mode region (4.8d). 16 bytes: + * idx 0: load_mode_active (data[0]) + * idx 1-8: scratch[0-7] = saved_F, saved_A, saved_C, saved_B, + * saved_E, saved_D, saved_L, saved_H + * idx 9-10: saved_SP lo / hi + * idx 11: iff_byte (FB if saved_IFF=1, else 00) + * idx 12: saved_a (cart-override re-restore after FF50 clobber) + * idx 13-14: saved_PC lo / hi + * idx 15: gbreset_request (data[0]=1 hold reset, =0 release) + * + * Read at the same indices (FPGA generates pathe_byte combinationally). + */ + kFusionRegion_PathELoad = 0xFEu, +} FusionRegion_t; + +/* Path-e region 0xFE byte indices — path-e load state layout. */ +typedef enum { + kPathE_LoadModeActive = 0u, + kPathE_ScratchF = 1u, + kPathE_ScratchA = 2u, + kPathE_ScratchC = 3u, + kPathE_ScratchB = 4u, + kPathE_ScratchE = 5u, + kPathE_ScratchD = 6u, + kPathE_ScratchL = 7u, + kPathE_ScratchH = 8u, + kPathE_SavedSpLo = 9u, + kPathE_SavedSpHi = 10u, + kPathE_IffByte = 11u, + kPathE_SavedA = 12u, + kPathE_SavedPcLo = 13u, + kPathE_SavedPcHi = 14u, + kPathE_GbresetRequest = 15u, + kPathE_RegionLength = 16u, +} FusionPathERegionIdx_t; + +/* Path-e cart override IFF byte values. */ +#define FUSION_PATHE_IFF_BYTE_EI 0xFBu /* EI opcode if saved_IFF=1 */ +#define FUSION_PATHE_IFF_BYTE_NOP 0x00u /* NOP opcode if saved_IFF=0 */ + +/* Slot commit-state byte values (atomic single-byte transition). */ +typedef enum { + kFusionCommit_Erased = 0xFFu, + kFusionCommit_Writing = 0xA1u, + kFusionCommit_Valid = 0x5Cu, + kFusionCommit_Invalid = 0x00u, +} FusionCommitState_t; + +/* + * On-flash header for one savestate slot. + * Field layout follows the architecture doc Storage Format section. + * All multibyte fields are little-endian. + */ +typedef struct __attribute__((packed)) { + uint32_t magic; /* FUSION_SAVESTATE_MAGIC */ + uint16_t format_version; + uint16_t header_size; /* sizeof(FusionStateHeader_t) */ + uint32_t total_size; /* slot bytes consumed including this header */ + uint32_t game_id_hash; /* per game_id_algorithm */ + uint8_t game_id_algorithm; /* FUSION_GAME_ID_ALGORITHM_V1 */ + uint8_t reserved0[3]; + char fpga_version[FUSION_VERSION_FIELD_LEN]; /* git short hash, NUL-padded */ + char mcu_version[FUSION_VERSION_FIELD_LEN]; /* git short hash, NUL-padded */ + uint32_t region_table_offset; /* relative to start of slot */ + uint32_t region_count; + uint32_t region_bitmap; /* capability bitmap captured from region 0 header */ + char savestate_tag[4]; /* capability tag captured from region 0 header */ + uint32_t payload_crc32; /* CRC32 over payload + region table */ + uint32_t commit_generation; /* monotonic per write */ + uint8_t commit_state; /* FusionCommitState_t */ + uint8_t reserved1[3]; +} FusionStateHeader_t; + +typedef struct __attribute__((packed)) { + uint8_t region_id; + uint8_t reserved[3]; + uint32_t offset; /* relative to slot start */ + uint32_t length; + uint32_t crc32; +} FusionRegionDirectory_t; + +/* Built/decoded V2 frame container. */ +typedef struct { + uint8_t bytes[FUSION_V2_MAX_FRAME]; + uint8_t length; +} FusionV2Frame_t; + +typedef struct { + uint8_t addr; + uint8_t payload[FUSION_V2_MAX_PAYLOAD]; + uint8_t payload_len; + + /* Convenience parse for STATE_CTL. */ + uint8_t ctl_opcode; /* first payload byte if addr == STATE_CTL, else 0 */ + uint8_t ack_for_opcode; /* second byte for ACK_ACCEPTED/BUSY/ACK_DONE */ + uint8_t error_code; /* ERROR only */ + uint8_t error_detail; /* ERROR only */ + + /* Convenience parse for STATE_DATA. */ + uint16_t data_seq; + uint8_t data_len; + uint8_t data[FUSION_STATE_DATA_MAX_DATA]; +} FusionV2Decoded_t; + +/* ---- Generic V2 frame helpers. ---- */ + +bool FusionSavestate_BuildV2Frame(uint8_t addr, + const uint8_t *payload, + size_t payload_len, + FusionV2Frame_t *out); + +bool FusionSavestate_DecodeV2Frame(const uint8_t *frame, + size_t frame_len, + FusionV2Decoded_t *out); + +/* ---- STATE_CTL builders. ---- */ + +bool FusionSavestate_BuildBeginSave(uint8_t flags, FusionV2Frame_t *out); +bool FusionSavestate_BuildBeginLoad(uint8_t flags, FusionV2Frame_t *out); +bool FusionSavestate_BuildBeginTestRW(uint8_t flags, FusionV2Frame_t *out); +bool FusionSavestate_BuildEndSession(FusionV2Frame_t *out); +bool FusionSavestate_BuildSeek(uint8_t region, uint16_t offset, FusionV2Frame_t *out); +bool FusionSavestate_BuildReadNext(uint8_t count, FusionV2Frame_t *out); +bool FusionSavestate_BuildReadStreamBegin(uint8_t region, + uint16_t offset, + uint16_t length, + FusionV2Frame_t *out); +bool FusionSavestate_BuildReadStreamContinue(uint16_t next_seq, + FusionV2Frame_t *out); +bool FusionSavestate_BuildWriteStreamBegin(uint8_t region, + uint16_t offset, + uint16_t length, + FusionV2Frame_t *out); +bool FusionSavestate_BuildWriteCommit(FusionV2Frame_t *out); + +/* ---- STATE_DATA builder. data_len must be <= FUSION_STATE_DATA_MAX_DATA. ---- */ + +bool FusionSavestate_BuildStateData(uint16_t seq, + const uint8_t *data, + size_t data_len, + FusionV2Frame_t *out); + +/* ---- FPGA-side response builders (used by mock test harness). ---- */ + +bool FusionSavestate_BuildAckAccepted(uint8_t for_opcode, FusionV2Frame_t *out); +bool FusionSavestate_BuildBusy(uint8_t for_opcode, FusionV2Frame_t *out); +bool FusionSavestate_BuildAckDone(uint8_t for_opcode, FusionV2Frame_t *out); +bool FusionSavestate_BuildError(uint8_t code, uint8_t detail, FusionV2Frame_t *out); + +/* ---- Path-e (4.8d) load state assembly. ---- */ + +/* + * Captured CPU/HALT/IFF state at save time. This is the input to the + * path-e load payload builder. saved_PC must be the captured CPU PC + * BEFORE any HALT-bug adjustment; the builder applies PC-1 if halted_at_save. + * + * Capture sources (Gate 1 contract §4): + * saved_A/F/B/C/D/E/H/L/SP/PC : T80 register file via state-port region 0x02 + * halted_at_save : T80 SS_3_BACK[48] = Halt_FF, exposed in + * region 0x02 idx 31 byte LSB + * saved_IFF : T80 SS_3_BACK[52] = IntE_FF1, exposed in + * region 0x02 idx 31 byte bit 4 + */ +typedef struct { + uint8_t saved_F; + uint8_t saved_A; + uint8_t saved_C; + uint8_t saved_B; + uint8_t saved_E; + uint8_t saved_D; + uint8_t saved_L; + uint8_t saved_H; + uint16_t saved_SP; + uint16_t saved_PC; /* unadjusted; builder applies PC-1 if halted */ + bool saved_IFF; /* IME state at save */ + bool halted_at_save; /* Halt_FF=1 at save */ +} FusionPathELoadInput_t; + +/* + * Build the 16-byte path-e region 0xFE payload from captured save state. + * out[0..14] are written to FPGA before gbreset is toggled (load_mode_active + * is byte 0; setting it before gbreset arms the load mode). + * out[15] is set to 0 by this builder (caller toggles gbreset separately + * via two single-byte writes to offset 15). + * + * Return false on null input or if both halted_at_save=true and saved_PC=0 + * (PC-1 would underflow into MSB cart-bus space, considered invalid). + */ +bool FusionSavestate_BuildPathELoadPayload(const FusionPathELoadInput_t *state, + uint8_t out[kPathE_RegionLength]); + +/* + * Extract path-e load input from saved CPU region (region 0x02) bytes. + * + * Layout per smoke48a convention (verified with FPGA's GBse.vhd / T80.vhd + * mappings): + * region 0x02 byte 10..17 = T80 SS_1[0..63] (PC/A/TmpAddr/IR/...) + * - bytes 10-11: PC[7:0], PC[15:8] + * - bytes 12: A (visible ACC value matches SS_2[15:8] but SS_1 PC drives stub) + * region 0x02 byte 18..24 = T80 SS_2[0..55] (DO/ACC/Ap/Fp/I/R/MCycles) + * - byte 19: ACC (= visible A) + * region 0x02 byte 25..32 = T80 SS_3[0..63] (SP/F/IFF/MCycle/TState/Halt_FF/...) + * - bytes 25-26: SP[7:0], SP[15:8] + * - byte 27: SS_3[28:21] = F register + * - byte 31 (= "10101" = SS_3[55:48]): bit 0 = Halt_FF (SS_3[48]), + * bit 4 = IntE_FF1 = saved_IFF (SS_3[52]) + * + * For BC/DE/HL: the T80_Reg register file maps to region 0x02 bytes 2-9. + * bytes 2-3: B,C (or C,B?) — exact bit mapping per T80_Reg ext_cpuregs port + * + * NOTE on byte 19 vs byte 12: SS_1 byte 12 is "A" but in T80.vhd that's + * the 16-bit address bus (A[7:0]) — NOT the visible accumulator. The + * visible accumulator is ACC at SS_2[15:8] = byte 19. + * + * cpu_region_bytes must point to at least 40 bytes of saved CPU region. + * Returns false on null inputs or if length < required offset. + */ +bool FusionSavestate_ExtractPathELoadInputFromCpuRegion( + const uint8_t *cpu_region_bytes, + size_t cpu_region_len, + FusionPathELoadInput_t *out); + +/* ---- CRC-32/ISO-HDLC (zlib/PNG) helpers and game ID v1. ---- */ + +uint32_t FusionSavestate_Crc32(const uint8_t *data, size_t len); +uint32_t FusionSavestate_Crc32Update(uint32_t prior_crc, const uint8_t *data, size_t len); + +/* + * game_id_v1 = CRC-32/ISO-HDLC over the 0x50 bytes of the GB ROM header + * region 0x0100-0x014F. Caller must pass exactly that 80-byte slice. + */ +uint32_t FusionSavestate_ComputeGameIdV1(const uint8_t rom_header_0x100_to_0x14F[FUSION_ROM_HEADER_BYTES]); + +#ifdef __cplusplus +} +#endif diff --git a/main/fusion_savestate_pathe.c b/main/fusion_savestate_pathe.c new file mode 100644 index 0000000..057492b --- /dev/null +++ b/main/fusion_savestate_pathe.c @@ -0,0 +1,46 @@ +/* + * Path-e (4.8d) MCU-side load orchestration. See fusion_savestate_pathe.h + * for the full sequence specification. + * + * IMPLEMENTATION STATUS (2026-05-04, Gate 3b complete): + * - Pure-C protocol layer (region 0xFE enum, payload builder, extract + * helper) is fully implemented in fusion_savestate.h/c with host + * unit tests (193/193 passing). + * - The actual ESP-IDF orchestration is implemented as + * `FusionSavestate_QuickLoadSlot0_PathE()` in fusion_savestate_smoke48a.c + * (line ~1103). It runs the full 11-step sequence: + * InitStorage -> ValidateSlot -> FindNewestValidSlot -> + * ReadSlotHeader/dirs -> PauseFpgaTraffic + UartOwnerAcquire -> + * DrainRx + EndSession clear -> BEGIN_LOAD -> + * LoadSlotToFpga_PathE (writes regions 0x01/0x03/0x09/0x0C/0xFE + * including gbreset assert/release) -> EndSessionCleanup -> + * ResumeFpgaTraffic + UartOwnerRelease. + * - Chord dispatcher (smoke48a.c:1593) routes button chord + * `LoadRequested` to QuickLoadSlot0_PathE; toast UI shows result. + * + * The decision to fold orchestration into smoke48a.c (instead of duplicating + * its UART owner / region streaming / preflight drain helpers here) was + * recommendation (b) from the original design: minimal change, reuse proven + * smoke48a helpers. All callers should use + * `FusionSavestate_QuickLoadSlot0_PathE()` directly. + * + * The function below (`FusionPathE_QuickLoadFromSlot`) is a thin wrapper + * preserved for the single-slot UI signature documented in this module's + * header. Only slot 0 is accepted; non-zero slot IDs fail closed instead + * of silently loading a different saved state. + * + * Gate 3c hardware bring-up uses this orchestration unchanged. + */ + +#include "fusion_savestate_pathe.h" + +#include "fusion_savestate_smoke48a.h" + +FusionPathELoadResult_t FusionPathE_QuickLoadFromSlot(uint32_t slot) +{ + if (slot != 0u) { + return kFusionPathELoad_BadArg; + } + const bool ok = FusionSavestate_QuickLoadSlot0_PathE(); + return ok ? kFusionPathELoad_Ok : kFusionPathELoad_StatePortFailed; +} diff --git a/main/fusion_savestate_pathe.h b/main/fusion_savestate_pathe.h new file mode 100644 index 0000000..7dc2262 --- /dev/null +++ b/main/fusion_savestate_pathe.h @@ -0,0 +1,98 @@ +#pragma once + +#include "fusion_savestate.h" + +#include +#include + +/* + * Path-e (4.8d) MCU-side load orchestration. + * + * This module wraps the path-e specific load sequence on top of the + * generic state-port API. The sequence (per Gate 1 contract §8 + Gate 3a + * FPGA RTL): + * + * 1. BEGIN_LOAD (pause GB core, enter load session) + * 2. WRITE region 0x01 (Top) -- IO regs / IF / IE / KEY1 / etc + * 3. WRITE region 0x03 (Timer) + * 4. WRITE region 0x09 (HRAM) + * 5. WRITE region 0x0C (WRAM) + * 6. WRITE region 0xFE bytes 0-14 -- path-e load state (load_mode_active=1 + * + scratch + saved_SP + iff_byte + + * saved_a + saved_PC) + * 7. WRITE region 0xFE byte 15 = 1 -- assert gbreset + * 8. delay >= a few hclk cycles -- let FPGA reset propagate + * 9. WRITE region 0xFE byte 15 = 0 -- release gbreset. CPU now cold-boots + * from $0000 = stub bytes (since + * load_mode_active=1 from step 6). + * 10. END_SESSION -- release UART / clear pause + * + * After step 10: + * - CPU executes stub at $0000-$00FD (DI / LD SP / POP×4 / LD SP imm / + * JP $00FA / FF50 tail). + * - FF50 write disables boot ROM; CPU fetch falls through to $00FE. + * - FPGA cart override returns 6 bytes (FB/00 + 3E saved_A + C3 saved_PC). + * - CPU lands at saved_PC with all registers (and IFF) restored. + * + * Region 0x02 (CPU regs) is INTENTIONALLY NOT written in path-e mode. The + * stub restores AF/BC/DE/HL/SP via POPs from scratch. Writing region 0x02 + * during path-e load would corrupt the cold-reset PC=0 assumption that + * brings the stub up. + */ + +#ifdef __cplusplus +extern "C" { +#endif + +typedef enum { + kFusionPathELoad_Ok = 0, + kFusionPathELoad_BadArg, + kFusionPathELoad_StorageFailed, + kFusionPathELoad_HeaderInvalid, + kFusionPathELoad_GameIdMismatch, + kFusionPathELoad_StatePortFailed, + kFusionPathELoad_ExtractFailed, + kFusionPathELoad_GbresetFailed, +} FusionPathELoadResult_t; + +/* + * Delay between gbreset assert and release. GB core needs at least a few + * hclk cycles for reset to propagate through T80 / GBse / video / timer + * / etc. hclk is 16.777 MHz so 1 ms is ~16800 cycles, which is plenty + * of margin. Real product can tune lower if needed. + */ +#define FUSION_PATHE_GBRESET_HOLD_MS 10u + +/* + * Apply a slot's path-e load sequence to the FPGA. + * + * STATUS (2026-05-04, Gate 3b complete): + * This is a thin wrapper that delegates to + * `FusionSavestate_QuickLoadSlot0_PathE()` in fusion_savestate_smoke48a.c + * (the actual orchestration is implemented there to reuse smoke48a's + * UART owner / region streaming / preflight drain helpers). + * + * The product UI currently exposes one logical load slot. This wrapper + * accepts only slot 0; non-zero slot IDs return kFusionPathELoad_BadArg + * instead of silently loading another state. + * + * The richer 8-value FusionPathELoadResult_t enum is collapsed to + * Ok/StatePortFailed in the wrapper because smoke48a returns only bool. + * To preserve granular error info, callers should use + * `FusionSavestate_QuickLoadSlot0_PathE()` directly and inspect the + * smoke48a logs for failure detail. + * + * Returns kFusionPathELoad_Ok on success, kFusionPathELoad_BadArg for a + * non-zero slot, or kFusionPathELoad_StatePortFailed on orchestration failure. + * + * Caller does NOT need to hold the UART owner — smoke48a acquires/releases + * it internally. Same for FPGA traffic pause and toast UI feedback. + * + * Game-id verification (per lock §4.8d wrong-game refusal) is performed in + * smoke48a before any path-e region 0xFE writes. + */ +FusionPathELoadResult_t FusionPathE_QuickLoadFromSlot(uint32_t slot); + +#ifdef __cplusplus +} +#endif diff --git a/main/fusion_savestate_smoke46.c b/main/fusion_savestate_smoke46.c new file mode 100644 index 0000000..51621d9 --- /dev/null +++ b/main/fusion_savestate_smoke46.c @@ -0,0 +1,340 @@ +#include "fusion_savestate_smoke46.h" + +#include "driver/uart.h" +#include "esp_timer.h" +#include "fusion_savestate.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" + +#include +#include +#include +#include +#include + +enum { + kSmoke46FrameTimeoutMs = 3000, + kSmoke46BootDelayMs = 2000, +}; + +typedef struct { + const char *name; + uint8_t region; + uint16_t length; + bool validate; +} SmokeRegion_t; + +static const SmokeRegion_t kMatrix[] = { + { "Header", 0x00u, 16u, true }, + { "Top", 0x01u, 16u, true }, + { "CPU", 0x02u, 40u, false }, + { "Timer", 0x03u, 8u, true }, + { "HRAM", 0x09u, 127u, false }, +}; + +static bool SendFrame(const char *label, const FusionV2Frame_t *frame) +{ + if (frame == NULL || frame->length == 0u) { + printf("Smoke46: %s invalid frame\n", label); + return false; + } + + printf("Smoke46: TX %s:", label); + for (uint8_t i = 0; i < frame->length; ++i) { + printf(" %02X", frame->bytes[i]); + } + printf("\n"); + + const int written = uart_write_bytes(UART_NUM_1, + (const char *)frame->bytes, + frame->length); + (void)uart_wait_tx_done(UART_NUM_1, pdMS_TO_TICKS(250)); + if (written != (int)frame->length) { + printf("Smoke46: TX %s failed wrote=%d expected=%u\n", + label, written, (unsigned)frame->length); + return false; + } + return true; +} + +static bool ReadByteWithDeadline(uint8_t *out, int64_t deadline_us) +{ + while (esp_timer_get_time() < deadline_us) { + const int got = uart_read_bytes(UART_NUM_1, out, 1, pdMS_TO_TICKS(50)); + if (got == 1) { + return true; + } + } + return false; +} + +static bool ReadDecodedFrame(const char *context, FusionV2Decoded_t *decoded) +{ + uint8_t raw[FUSION_V2_MAX_FRAME] = {0}; + int64_t deadline = esp_timer_get_time() + + ((int64_t)kSmoke46FrameTimeoutMs * 1000); + + uint8_t b = 0; + do { + if (!ReadByteWithDeadline(&b, deadline)) { + printf("Smoke46: RX timeout waiting for marker during %s\n", context); + return false; + } + } while (b != FUSION_V2_HEADER_MARKER); + + raw[0] = b; + if (!ReadByteWithDeadline(&raw[1], deadline) || + !ReadByteWithDeadline(&raw[2], deadline)) { + printf("Smoke46: RX timeout waiting for header during %s\n", context); + return false; + } + + const uint8_t payload_len = raw[2]; + if (payload_len > FUSION_V2_MAX_PAYLOAD) { + printf("Smoke46: RX bad payload len %u during %s\n", + (unsigned)payload_len, context); + return false; + } + + const size_t frame_len = 3u + payload_len + 1u; + for (size_t i = 3u; i < frame_len; ++i) { + if (!ReadByteWithDeadline(&raw[i], deadline)) { + printf("Smoke46: RX timeout waiting for payload/crc during %s\n", + context); + return false; + } + } + + printf("Smoke46: RX %s:", context); + for (size_t i = 0; i < frame_len; ++i) { + printf(" %02X", raw[i]); + } + printf("\n"); + + if (!FusionSavestate_DecodeV2Frame(raw, frame_len, decoded)) { + printf("Smoke46: RX decode/CRC failed during %s\n", context); + return false; + } + return true; +} + +static bool ExpectCtl(uint8_t opcode, uint8_t ack_for, const char *context) +{ + FusionV2Decoded_t d; + if (!ReadDecodedFrame(context, &d)) { + return false; + } + if (d.addr != (uint8_t)kFusionAddr_StateCtl) { + printf("Smoke46: %s expected CTL addr got 0x%02X\n", context, d.addr); + return false; + } + if (d.ctl_opcode == (uint8_t)kFusionOp_Error) { + printf("Smoke46: %s FPGA ERROR code=0x%02X detail=0x%02X\n", + context, d.error_code, d.error_detail); + return false; + } + if (d.ctl_opcode != opcode || d.ack_for_opcode != ack_for) { + printf("Smoke46: %s unexpected CTL op=0x%02X ack_for=0x%02X\n", + context, d.ctl_opcode, d.ack_for_opcode); + return false; + } + return true; +} + +static bool SendCtlAndExpectAck(const char *label, + bool (*builder)(FusionV2Frame_t *out), + uint8_t ack_for) +{ + FusionV2Frame_t frame; + if (!builder(&frame)) { + printf("Smoke46: build %s failed\n", label); + return false; + } + if (!SendFrame(label, &frame)) { + return false; + } + return ExpectCtl((uint8_t)kFusionOp_AckAccepted, ack_for, label); +} + +static bool BuildEnd(FusionV2Frame_t *out) +{ + return FusionSavestate_BuildEndSession(out); +} + +static bool BuildBeginSave(FusionV2Frame_t *out) +{ + return FusionSavestate_BuildBeginSave(0u, out); +} + +static void PrintRegionSummary(const SmokeRegion_t *region, + const uint8_t *data, + uint16_t length) +{ + const uint32_t crc = FusionSavestate_Crc32(data, length); + printf("Smoke46: REGION %s id=0x%02X len=%u crc32=%08lX first:", + region->name, + (unsigned)region->region, + (unsigned)length, + (unsigned long)crc); + const uint16_t first_count = (length < 16u) ? length : 16u; + for (uint16_t i = 0; i < first_count; ++i) { + printf(" %02X", data[i]); + } + printf("\n"); +} + +static bool ValidateRegion(const SmokeRegion_t *region, + const uint8_t *data, + uint16_t length) +{ + if (region->region == 0x00u) { + const uint32_t bitmap = (uint32_t)data[8] + | ((uint32_t)data[9] << 8) + | ((uint32_t)data[10] << 16) + | ((uint32_t)data[11] << 24); + if (memcmp(data, "FUSS", 4u) != 0) { + printf("Smoke46: Header magic mismatch\n"); + return false; + } + if (bitmap != 0x0000020Fu) { + printf("Smoke46: Header bitmap mismatch got=0x%08lX\n", + (unsigned long)bitmap); + return false; + } + if (memcmp(&data[12], "P450", 4u) != 0) { + printf("Smoke46: Header tag mismatch got='%c%c%c%c'\n", + data[12], data[13], data[14], data[15]); + return false; + } + } else if (region->region == 0x01u) { + for (uint16_t i = 10u; i < length; ++i) { + if (data[i] != 0u) { + printf("Smoke46: Top padding byte %u nonzero=0x%02X\n", + (unsigned)i, data[i]); + return false; + } + } + } else if (region->region == 0x03u) { + if (data[6] != 0u || data[7] != 0u) { + printf("Smoke46: Timer padding mismatch b6=0x%02X b7=0x%02X\n", + data[6], data[7]); + return false; + } + } + return true; +} + +static bool ReadRegion(const SmokeRegion_t *region) +{ + uint8_t data[127] = {0}; + uint16_t received = 0; + uint16_t expected_seq = 0; + + FusionV2Frame_t begin; + if (!FusionSavestate_BuildReadStreamBegin(region->region, + 0u, + region->length, + &begin)) { + printf("Smoke46: build READ_STREAM_BEGIN %s failed\n", region->name); + return false; + } + printf("Smoke46: BEGIN REGION %s id=0x%02X length=%u\n", + region->name, (unsigned)region->region, (unsigned)region->length); + if (!SendFrame("READ_STREAM_BEGIN", &begin)) { + return false; + } + if (!ExpectCtl((uint8_t)kFusionOp_AckAccepted, + (uint8_t)kFusionOp_ReadStreamBegin, + "READ_STREAM_BEGIN ack")) { + return false; + } + + while (received < region->length) { + FusionV2Decoded_t d; + if (!ReadDecodedFrame(region->name, &d)) { + return false; + } + if (d.addr == (uint8_t)kFusionAddr_StateCtl && + d.ctl_opcode == (uint8_t)kFusionOp_Error) { + printf("Smoke46: %s FPGA ERROR code=0x%02X detail=0x%02X\n", + region->name, d.error_code, d.error_detail); + return false; + } + if (d.addr != (uint8_t)kFusionAddr_StateData) { + printf("Smoke46: %s expected DATA got addr=0x%02X op=0x%02X\n", + region->name, d.addr, d.ctl_opcode); + return false; + } + if (d.data_seq != expected_seq) { + printf("Smoke46: %s seq mismatch got=%u expected=%u\n", + region->name, (unsigned)d.data_seq, (unsigned)expected_seq); + return false; + } + if (d.data_len == 0u || (uint16_t)d.data_len > (region->length - received)) { + printf("Smoke46: %s bad data_len=%u remaining=%u\n", + region->name, + (unsigned)d.data_len, + (unsigned)(region->length - received)); + return false; + } + memcpy(&data[received], d.data, d.data_len); + received = (uint16_t)(received + d.data_len); + expected_seq = (uint16_t)(expected_seq + 1u); + } + + if (!ExpectCtl((uint8_t)kFusionOp_AckDone, + (uint8_t)kFusionOp_ReadStreamBegin, + "READ_STREAM_BEGIN done")) { + return false; + } + + PrintRegionSummary(region, data, received); + if (region->validate && !ValidateRegion(region, data, received)) { + return false; + } + printf("Smoke46: REGION %s PASS packets=%u\n", + region->name, (unsigned)expected_seq); + return true; +} + +void FusionSavestateSmoke46_RunBlocking(void) +{ + bool ok = true; + + printf("Smoke46: Phase 4.6 stream-only smoke starting\n"); + printf("Smoke46: waiting %u ms for FPGA boot\n", (unsigned)kSmoke46BootDelayMs); + vTaskDelay(pdMS_TO_TICKS(kSmoke46BootDelayMs)); + printf("Smoke46: READ_NEXT debug disabled\n"); + uart_flush(UART_NUM_1); + + ok = SendCtlAndExpectAck("END_SESSION clear", + BuildEnd, + (uint8_t)kFusionOp_EndSession); + + if (ok) { + ok = SendCtlAndExpectAck("BEGIN_SAVE", + BuildBeginSave, + (uint8_t)kFusionOp_BeginSave); + } + + if (ok) { + printf("Smoke46: save session active; screen should be paused briefly\n"); + vTaskDelay(pdMS_TO_TICKS(250)); + } + + for (size_t i = 0; ok && i < (sizeof(kMatrix) / sizeof(kMatrix[0])); ++i) { + ok = ReadRegion(&kMatrix[i]); + } + + printf("Smoke46: sending END_SESSION cleanup\n"); + FusionV2Frame_t end; + if (FusionSavestate_BuildEndSession(&end)) { + (void)SendFrame("END_SESSION cleanup", &end); + (void)ExpectCtl((uint8_t)kFusionOp_AckAccepted, + (uint8_t)kFusionOp_EndSession, + "END_SESSION cleanup"); + } + + printf("Smoke46: RESULT %s\n", ok ? "PASS" : "FAIL"); + printf("Smoke46: Phase 4.6 stream-only smoke complete; continuing normal boot\n"); +} diff --git a/main/fusion_savestate_smoke46.h b/main/fusion_savestate_smoke46.h new file mode 100644 index 0000000..a93b7aa --- /dev/null +++ b/main/fusion_savestate_smoke46.h @@ -0,0 +1,11 @@ +#pragma once + +/* + * Temporary Phase 4.6 hardware smoke harness. + * + * This is not production savestate UI/storage. It runs once during boot, + * before the normal FPGA UART tasks are started, to validate read-only + * state_port regions from the Phase 4.5 FPGA bitstream. + */ + +void FusionSavestateSmoke46_RunBlocking(void); diff --git a/main/fusion_savestate_smoke47.c b/main/fusion_savestate_smoke47.c new file mode 100644 index 0000000..15894c8 --- /dev/null +++ b/main/fusion_savestate_smoke47.c @@ -0,0 +1,415 @@ +#include "fusion_savestate_smoke47.h" + +#include "driver/uart.h" +#include "esp_heap_caps.h" +#include "esp_timer.h" +#include "fusion_savestate.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" + +#include +#include +#include +#include +#include + +enum { + kSmoke47FrameTimeoutMs = 3000, + kSmoke47BootDelayMs = 2000, + kWramFullLen = 32768, + kWramDiag1Len = 40, + kWramDiag2Len = 256, + kWramDiag3Len = 8192, + kFinalGateBufLen = 32768, +}; + +typedef struct { + const char *name; + uint8_t region; + uint16_t length; + bool validate; +} SmokeRegion_t; + +/* Final gate: same five regions as 4.6 plus WRAM 32 KiB. */ +static const SmokeRegion_t kFinalMatrix[] = { + { "Header", 0x00u, 16u, true }, + { "Top", 0x01u, 16u, true }, + { "CPU", 0x02u, 40u, false }, + { "Timer", 0x03u, 8u, true }, + { "HRAM", 0x09u, 127u, false }, + { "WRAM", 0x0Cu, (uint16_t)kWramFullLen, false }, +}; + +#if 0 +/* Diagnostic ramp before the final gate. Each entry runs through its + * own BeginSave/EndSession pair so a failure on one length does not + * corrupt the next. */ +static const SmokeRegion_t kWramDiagMatrix[] = { + { "WRAM-40", 0x0Cu, (uint16_t)kWramDiag1Len, false }, + { "WRAM-256", 0x0Cu, (uint16_t)kWramDiag2Len, false }, + { "WRAM-8192", 0x0Cu, (uint16_t)kWramDiag3Len, false }, + { "WRAM-32768", 0x0Cu, (uint16_t)kWramFullLen, false }, +}; +#endif + +static uint8_t *s_rx_buf = NULL; +static size_t s_rx_buf_len = 0; + +static bool EnsureRxBuf(uint16_t need) +{ + if (need <= s_rx_buf_len) { + return true; + } + uint8_t *p = (uint8_t *)heap_caps_realloc(s_rx_buf, need, MALLOC_CAP_8BIT); + if (p == NULL) { + printf("Smoke47: heap alloc %u failed\n", (unsigned)need); + return false; + } + s_rx_buf = p; + s_rx_buf_len = need; + return true; +} + +static bool SendFrame(const char *label, const FusionV2Frame_t *frame) +{ + if (frame == NULL || frame->length == 0u) { + printf("Smoke47: %s invalid frame\n", label); + return false; + } + + printf("Smoke47: TX %s:", label); + for (uint8_t i = 0; i < frame->length; ++i) { + printf(" %02X", frame->bytes[i]); + } + printf("\n"); + + const int written = uart_write_bytes(UART_NUM_1, + (const char *)frame->bytes, + frame->length); + (void)uart_wait_tx_done(UART_NUM_1, pdMS_TO_TICKS(250)); + if (written != (int)frame->length) { + printf("Smoke47: TX %s failed wrote=%d expected=%u\n", + label, written, (unsigned)frame->length); + return false; + } + return true; +} + +static bool ReadByteWithDeadline(uint8_t *out, int64_t deadline_us) +{ + while (esp_timer_get_time() < deadline_us) { + const int got = uart_read_bytes(UART_NUM_1, out, 1, pdMS_TO_TICKS(50)); + if (got == 1) { + return true; + } + } + return false; +} + +static bool ReadDecodedFrame(const char *context, FusionV2Decoded_t *decoded) +{ + uint8_t raw[FUSION_V2_MAX_FRAME] = {0}; + int64_t deadline = esp_timer_get_time() + + ((int64_t)kSmoke47FrameTimeoutMs * 1000); + + while (esp_timer_get_time() < deadline) { + uint8_t b = 0; + if (!ReadByteWithDeadline(&b, deadline)) { + printf("Smoke47: RX timeout waiting for marker during %s\n", context); + return false; + } + if (b != FUSION_V2_HEADER_MARKER) { + continue; + } + + raw[0] = b; + if (!ReadByteWithDeadline(&raw[1], deadline) || + !ReadByteWithDeadline(&raw[2], deadline)) { + printf("Smoke47: RX timeout waiting for header during %s\n", context); + return false; + } + + const uint8_t payload_len = raw[2]; + if (payload_len > FUSION_V2_MAX_PAYLOAD) { + printf("Smoke47: RX resync bad payload len %u during %s\n", + (unsigned)payload_len, context); + continue; + } + + const size_t frame_len = 3u + payload_len + 1u; + for (size_t i = 3u; i < frame_len; ++i) { + if (!ReadByteWithDeadline(&raw[i], deadline)) { + printf("Smoke47: RX timeout waiting for payload/crc during %s\n", + context); + return false; + } + } + + if (!FusionSavestate_DecodeV2Frame(raw, frame_len, decoded)) { + printf("Smoke47: RX resync decode/CRC failed during %s\n", context); + continue; + } + return true; + } + + printf("Smoke47: RX timeout waiting for valid frame during %s\n", context); + return false; +} + +static bool ExpectCtl(uint8_t opcode, uint8_t ack_for, const char *context) +{ + FusionV2Decoded_t d; + if (!ReadDecodedFrame(context, &d)) { + return false; + } + if (d.addr != (uint8_t)kFusionAddr_StateCtl) { + printf("Smoke47: %s expected CTL addr got 0x%02X\n", context, d.addr); + return false; + } + if (d.ctl_opcode == (uint8_t)kFusionOp_Error) { + printf("Smoke47: %s FPGA ERROR code=0x%02X detail=0x%02X\n", + context, d.error_code, d.error_detail); + return false; + } + if (d.ctl_opcode != opcode || d.ack_for_opcode != ack_for) { + printf("Smoke47: %s unexpected CTL op=0x%02X ack_for=0x%02X\n", + context, d.ctl_opcode, d.ack_for_opcode); + return false; + } + return true; +} + +static bool BuildEnd(FusionV2Frame_t *out) +{ + return FusionSavestate_BuildEndSession(out); +} + +static bool BuildBeginSave(FusionV2Frame_t *out) +{ + return FusionSavestate_BuildBeginSave(0u, out); +} + +static bool SendCtlAndExpectAck(const char *label, + bool (*builder)(FusionV2Frame_t *out), + uint8_t ack_for) +{ + FusionV2Frame_t frame; + if (!builder(&frame)) { + printf("Smoke47: build %s failed\n", label); + return false; + } + if (!SendFrame(label, &frame)) { + return false; + } + return ExpectCtl((uint8_t)kFusionOp_AckAccepted, ack_for, label); +} + +static void PrintRegionSummary(const SmokeRegion_t *region, + const uint8_t *data, + uint16_t length) +{ + const uint32_t crc = FusionSavestate_Crc32(data, length); + printf("Smoke47: REGION %s id=0x%02X len=%u crc32=%08lX first:", + region->name, + (unsigned)region->region, + (unsigned)length, + (unsigned long)crc); + const uint16_t first_count = (length < 16u) ? length : 16u; + for (uint16_t i = 0; i < first_count; ++i) { + printf(" %02X", data[i]); + } + if (length > 32u) { + printf(" .. last:"); + for (uint16_t i = (uint16_t)(length - 16u); i < length; ++i) { + printf(" %02X", data[i]); + } + } + printf("\n"); +} + +static bool ValidateRegion(const SmokeRegion_t *region, + const uint8_t *data, + uint16_t length) +{ + if (region->region == 0x00u) { + const uint32_t bitmap = (uint32_t)data[8] + | ((uint32_t)data[9] << 8) + | ((uint32_t)data[10] << 16) + | ((uint32_t)data[11] << 24); + if (memcmp(data, "FUSS", 4u) != 0) { + printf("Smoke47: Header magic mismatch\n"); + return false; + } + if (bitmap != 0x0000120Fu) { + printf("Smoke47: Header bitmap mismatch got=0x%08lX want=0x0000120F\n", + (unsigned long)bitmap); + return false; + } + if (memcmp(&data[12], "P470", 4u) != 0) { + printf("Smoke47: Header tag mismatch got='%c%c%c%c' want='P470'\n", + data[12], data[13], data[14], data[15]); + return false; + } + } else if (region->region == 0x01u) { + for (uint16_t i = 10u; i < length; ++i) { + if (data[i] != 0u) { + printf("Smoke47: Top padding byte %u nonzero=0x%02X\n", + (unsigned)i, data[i]); + return false; + } + } + } else if (region->region == 0x03u) { + if (data[6] != 0u || data[7] != 0u) { + printf("Smoke47: Timer padding mismatch b6=0x%02X b7=0x%02X\n", + data[6], data[7]); + return false; + } + } + return true; +} + +static bool ReadRegion(const SmokeRegion_t *region) +{ + if (!EnsureRxBuf(region->length)) { + return false; + } + uint8_t *data = s_rx_buf; + memset(data, 0, region->length); + + uint16_t received = 0; + uint16_t expected_seq = 0; + const int64_t t0 = esp_timer_get_time(); + + FusionV2Frame_t begin; + if (!FusionSavestate_BuildReadStreamBegin(region->region, + 0u, + region->length, + &begin)) { + printf("Smoke47: build READ_STREAM_BEGIN %s failed\n", region->name); + return false; + } + printf("Smoke47: BEGIN REGION %s id=0x%02X length=%u\n", + region->name, (unsigned)region->region, (unsigned)region->length); + if (!SendFrame("READ_STREAM_BEGIN", &begin)) { + return false; + } + if (!ExpectCtl((uint8_t)kFusionOp_AckAccepted, + (uint8_t)kFusionOp_ReadStreamBegin, + "READ_STREAM_BEGIN ack")) { + return false; + } + + while (received < region->length) { + FusionV2Decoded_t d; + if (!ReadDecodedFrame(region->name, &d)) { + return false; + } + if (d.addr == (uint8_t)kFusionAddr_StateCtl && + d.ctl_opcode == (uint8_t)kFusionOp_Error) { + printf("Smoke47: %s FPGA ERROR code=0x%02X detail=0x%02X\n", + region->name, d.error_code, d.error_detail); + return false; + } + if (d.addr != (uint8_t)kFusionAddr_StateData) { + printf("Smoke47: %s expected DATA got addr=0x%02X op=0x%02X\n", + region->name, d.addr, d.ctl_opcode); + return false; + } + if (d.data_seq != expected_seq) { + printf("Smoke47: %s seq mismatch got=%u expected=%u\n", + region->name, (unsigned)d.data_seq, (unsigned)expected_seq); + return false; + } + if (d.data_len == 0u || (uint16_t)d.data_len > (region->length - received)) { + printf("Smoke47: %s bad data_len=%u remaining=%u\n", + region->name, + (unsigned)d.data_len, + (unsigned)(region->length - received)); + return false; + } + memcpy(&data[received], d.data, d.data_len); + received = (uint16_t)(received + d.data_len); + expected_seq = (uint16_t)(expected_seq + 1u); + } + + if (!ExpectCtl((uint8_t)kFusionOp_AckDone, + (uint8_t)kFusionOp_ReadStreamBegin, + "READ_STREAM_BEGIN done")) { + return false; + } + + const int64_t elapsed_ms = (esp_timer_get_time() - t0) / 1000; + PrintRegionSummary(region, data, received); + if (region->validate && !ValidateRegion(region, data, received)) { + return false; + } + printf("Smoke47: REGION %s PASS packets=%u bytes=%u dt_ms=%lld\n", + region->name, + (unsigned)expected_seq, + (unsigned)received, + (long long)elapsed_ms); + return true; +} + +static bool RunSession(const SmokeRegion_t *matrix, size_t count, const char *label) +{ + bool ok = true; + + ok = SendCtlAndExpectAck("END_SESSION clear", + BuildEnd, + (uint8_t)kFusionOp_EndSession); + if (ok) { + ok = SendCtlAndExpectAck("BEGIN_SAVE", + BuildBeginSave, + (uint8_t)kFusionOp_BeginSave); + } + if (ok) { + printf("Smoke47: %s session active; screen paused\n", label); + vTaskDelay(pdMS_TO_TICKS(50)); + } + + for (size_t i = 0; ok && i < count; ++i) { + ok = ReadRegion(&matrix[i]); + if (ok) { + vTaskDelay(pdMS_TO_TICKS(10)); + } + } + + printf("Smoke47: %s sending END_SESSION cleanup\n", label); + vTaskDelay(pdMS_TO_TICKS(50)); + bool cleanup_ok = false; + FusionV2Frame_t end; + if (FusionSavestate_BuildEndSession(&end)) { + cleanup_ok = SendFrame("END_SESSION cleanup", &end) && + ExpectCtl((uint8_t)kFusionOp_AckAccepted, + (uint8_t)kFusionOp_EndSession, + "END_SESSION cleanup"); + } + return ok && cleanup_ok; +} + +void FusionSavestateSmoke47_RunBlocking(void) +{ + printf("Smoke47: Phase 4.7 WRAM smoke starting\n"); + printf("Smoke47: waiting %u ms for FPGA boot\n", (unsigned)kSmoke47BootDelayMs); + vTaskDelay(pdMS_TO_TICKS(kSmoke47BootDelayMs)); + uart_flush(UART_NUM_1); + + if (!EnsureRxBuf((uint16_t)kFinalGateBufLen)) { + printf("Smoke47: RESULT FAIL (rx buffer)\n"); + return; + } + + bool diag_ok = true; + printf("Smoke47: diagnostic ramp disabled for final-only rerun\n"); + + printf("Smoke47: --- FINAL GATE ---\n"); + bool gate_ok = RunSession(kFinalMatrix, + sizeof(kFinalMatrix) / sizeof(kFinalMatrix[0]), + "FINAL"); + printf("Smoke47: RESULT %s (diag=%s gate=%s)\n", + (diag_ok && gate_ok) ? "PASS" : "FAIL", + diag_ok ? "PASS" : "FAIL", + gate_ok ? "PASS" : "FAIL"); + printf("Smoke47: Phase 4.7 WRAM smoke complete; continuing normal boot\n"); +} diff --git a/main/fusion_savestate_smoke47.h b/main/fusion_savestate_smoke47.h new file mode 100644 index 0000000..f7e5e09 --- /dev/null +++ b/main/fusion_savestate_smoke47.h @@ -0,0 +1,16 @@ +#pragma once + +/* + * Temporary Phase 4.7 hardware smoke harness. + * + * Adds WRAM (region 0x0C) on top of the 4.6 read-only matrix. Like 4.6, + * this is not production savestate UI/storage; it runs once during boot, + * before the normal FPGA UART tasks are started, to validate read-only + * state_port regions from the Phase 4.7 FPGA bitstream. + * + * Diagnostic ramp before the final gate (40, 256, 8192, 32768) lets a + * partial-WRAM bitstream still light up some packets even if the full + * 32 KiB stream fails late. + */ + +void FusionSavestateSmoke47_RunBlocking(void); diff --git a/main/fusion_savestate_smoke48a.c b/main/fusion_savestate_smoke48a.c new file mode 100644 index 0000000..5fb990a --- /dev/null +++ b/main/fusion_savestate_smoke48a.c @@ -0,0 +1,1786 @@ +#include "fusion_savestate_smoke48a.h" + +#include "driver/uart.h" +#include "esp_app_desc.h" +#include "esp_console.h" +#include "esp_err.h" +#include "esp_heap_caps.h" +#include "esp_log.h" +#include "esp_system.h" +#include "esp_timer.h" +#include "fpga_common.h" +#include "fpga_rx.h" +#include "fpga_tx.h" +#include "fusion_savestate.h" +#include "fusion_savestate_pathe.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "freertos/queue.h" +#include "pwrmgr.h" +#include "savestate_storage.h" +#include "savestate_toast.h" +#include "save_slot.h" + +#include +#include +#include +#include +#include + +enum { + kSmoke48aFrameTimeoutMs = 3000, + kSmoke48aPreflightDrainMs = 120, + kSmoke48aInterRegionMs = 10, + kSmoke48aFinalEndMs = 50, + kSmoke48aRegionCount = 6, + kSmoke48aEndSessionAttempts = 4, +}; + +static const char *TAG = "Smoke48a"; +static const char kPartitionLabel[] = "savestate"; +static const uint32_t kExpectedBitmap = 0x0000320Fu; + +typedef struct { + const char *name; + uint8_t region; + uint16_t length; +} Smoke48aRegion_t; + +typedef struct { + bool save_captured; + bool slot_committed; + bool slot_reloaded; + bool crc_valid; + bool load_ready; +} Smoke48aResult_t; + +static const Smoke48aRegion_t kRegions[kSmoke48aRegionCount] = { + { "Header", 0x00u, 16u }, + { "Top", 0x01u, 16u }, + { "CPU", 0x02u, 40u }, + { "Timer", 0x03u, 8u }, + { "HRAM", 0x09u, 127u }, + { "WRAM", 0x0Cu, 32768u }, +}; + +static uint8_t *s_rx_buf = NULL; +static size_t s_rx_buf_len = 0u; +static uint8_t s_uart_cache[512]; +static size_t s_uart_cache_pos = 0u; +static size_t s_uart_cache_len = 0u; + +static bool BuildEnd(FusionV2Frame_t *out); +static void DrainRxForMs(uint32_t ms); +static bool InitStorage(FusionStorage_t *storage); + +static bool EnsureRxBuf(uint16_t need) +{ + if (need <= s_rx_buf_len) { + return true; + } + uint8_t *p = (uint8_t *)heap_caps_realloc(s_rx_buf, need, MALLOC_CAP_8BIT); + if (p == NULL) { + printf("Smoke48a: heap alloc %u failed\n", (unsigned)need); + return false; + } + s_rx_buf = p; + s_rx_buf_len = need; + return true; +} + +static void ResetUartCache(void) +{ + s_uart_cache_pos = 0u; + s_uart_cache_len = 0u; +} + +static uint32_t ExpectedTotalSize(void) +{ + uint32_t payload = 0u; + for (size_t i = 0; i < kSmoke48aRegionCount; ++i) { + payload += kRegions[i].length; + } + return (uint32_t)sizeof(FusionStateHeader_t) + + payload + + (uint32_t)(kSmoke48aRegionCount * sizeof(FusionRegionDirectory_t)); +} + +static const Smoke48aRegion_t *FindExpectedRegion(uint8_t region_id) +{ + for (size_t i = 0; i < kSmoke48aRegionCount; ++i) { + if (kRegions[i].region == region_id) { + return &kRegions[i]; + } + } + return NULL; +} + +static void PrintResult(const Smoke48aResult_t *r) +{ + printf("Smoke48a: STATUS save_captured=%s slot_committed=%s " + "slot_reloaded=%s crc_valid=%s load_ready=%s\n", + r->save_captured ? "yes" : "no", + r->slot_committed ? "yes" : "no", + r->slot_reloaded ? "yes" : "no", + r->crc_valid ? "yes" : "no", + r->load_ready ? "yes" : "no"); + printf("Smoke48a: RESULT %s\n", + r->load_ready ? "PASS" : "FAIL"); +} + +static bool ReadByteWithDeadline(uint8_t *out, int64_t deadline_us) +{ + while (esp_timer_get_time() < deadline_us) { + if (s_uart_cache_pos < s_uart_cache_len) { + *out = s_uart_cache[s_uart_cache_pos++]; + return true; + } + s_uart_cache_pos = 0u; + s_uart_cache_len = 0u; + + const int got = uart_read_bytes(UART_NUM_1, + s_uart_cache, + sizeof(s_uart_cache), + pdMS_TO_TICKS(10)); + if (got > 0) { + s_uart_cache_len = (size_t)got; + } + } + return false; +} + +static bool ReadDecodedFrame(const char *context, FusionV2Decoded_t *decoded) +{ + uint8_t raw[FUSION_V2_MAX_FRAME] = {0}; + int64_t deadline = esp_timer_get_time() + + ((int64_t)kSmoke48aFrameTimeoutMs * 1000); + + while (esp_timer_get_time() < deadline) { + uint8_t b = 0; + if (!ReadByteWithDeadline(&b, deadline)) { + printf("Smoke48a: RX timeout waiting for marker during %s\n", context); + return false; + } + if (b != FUSION_V2_HEADER_MARKER) { + continue; + } + + raw[0] = b; + if (!ReadByteWithDeadline(&raw[1], deadline) || + !ReadByteWithDeadline(&raw[2], deadline)) { + printf("Smoke48a: RX timeout waiting for header during %s\n", context); + return false; + } + + const uint8_t payload_len = raw[2]; + if (payload_len > FUSION_V2_MAX_PAYLOAD) { + printf("Smoke48a: RX resync bad payload len %u during %s\n", + (unsigned)payload_len, context); + continue; + } + + const size_t frame_len = 3u + payload_len + 1u; + for (size_t i = 3u; i < frame_len; ++i) { + if (!ReadByteWithDeadline(&raw[i], deadline)) { + printf("Smoke48a: RX timeout waiting for payload/crc during %s\n", + context); + return false; + } + } + + if (!FusionSavestate_DecodeV2Frame(raw, frame_len, decoded)) { + printf("Smoke48a: RX resync decode/CRC failed during %s\n", context); + continue; + } + return true; + } + + printf("Smoke48a: RX timeout waiting for valid frame during %s\n", context); + return false; +} + +static bool SendFrame(const char *label, const FusionV2Frame_t *frame) +{ + if (frame == NULL || frame->length == 0u) { + printf("Smoke48a: %s invalid frame\n", label); + return false; + } + + const bool quiet_data = (strcmp(label, "STATE_DATA") == 0); + if (!quiet_data) { + printf("Smoke48a: TX %s:", label); + for (uint8_t i = 0; i < frame->length; ++i) { + printf(" %02X", frame->bytes[i]); + } + printf("\n"); + } + + const int written = uart_write_bytes(UART_NUM_1, + (const char *)frame->bytes, + frame->length); + const bool is_final_cleanup = (strcmp(label, "END_SESSION cleanup") == 0); + if (is_final_cleanup) { + printf("Smoke48a: TX %s write returned=%d\n", label, written); + } + const esp_err_t wait_err = uart_wait_tx_done(UART_NUM_1, pdMS_TO_TICKS(250)); + if (is_final_cleanup) { + printf("Smoke48a: TX %s wait result=%s\n", + label, + esp_err_to_name(wait_err)); + } + if (written != (int)frame->length) { + printf("Smoke48a: TX %s failed wrote=%d expected=%u\n", + label, written, (unsigned)frame->length); + return false; + } + if (wait_err != ESP_OK) { + printf("Smoke48a: TX %s wait failed err=%s\n", + label, esp_err_to_name(wait_err)); + return false; + } + return true; +} + +static bool ExpectCtl(uint8_t opcode, uint8_t ack_for, const char *context) +{ + FusionV2Decoded_t d; + if (!ReadDecodedFrame(context, &d)) { + return false; + } + if (d.addr != (uint8_t)kFusionAddr_StateCtl) { + printf("Smoke48a: %s expected CTL addr got 0x%02X\n", context, d.addr); + return false; + } + if (d.ctl_opcode == (uint8_t)kFusionOp_Error) { + printf("Smoke48a: %s FPGA ERROR code=0x%02X detail=0x%02X\n", + context, d.error_code, d.error_detail); + return false; + } + if (d.ctl_opcode != opcode || d.ack_for_opcode != ack_for) { + printf("Smoke48a: %s unexpected CTL op=0x%02X ack_for=0x%02X\n", + context, d.ctl_opcode, d.ack_for_opcode); + return false; + } + return true; +} + +static bool SendCtlAndExpectAck(const char *label, + bool (*builder)(FusionV2Frame_t *out), + uint8_t ack_for) +{ + FusionV2Frame_t frame; + if (!builder(&frame)) { + printf("Smoke48a: build %s failed\n", label); + return false; + } + if (!SendFrame(label, &frame)) { + return false; + } + return ExpectCtl((uint8_t)kFusionOp_AckAccepted, ack_for, label); +} + +static bool IsEndSessionAck(const FusionV2Decoded_t *d) +{ + return d != NULL && + d->addr == (uint8_t)kFusionAddr_StateCtl && + d->ctl_opcode == (uint8_t)kFusionOp_AckAccepted && + d->ack_for_opcode == (uint8_t)kFusionOp_EndSession; +} + +static bool SendEndSessionClear(const char *label) +{ + FusionV2Frame_t frame; + if (!BuildEnd(&frame)) { + printf("Smoke48a: build %s failed\n", label); + return false; + } + + for (uint8_t attempt = 0u; attempt < kSmoke48aEndSessionAttempts; ++attempt) { + if (attempt != 0u) { + DrainRxForMs(kSmoke48aPreflightDrainMs); + ResetUartCache(); + } + if (!SendFrame(label, &frame)) { + return false; + } + FusionV2Decoded_t d; + if (ReadDecodedFrame(label, &d) && IsEndSessionAck(&d)) { + return true; + } + } + return false; +} + +static void DrainRxForMs(uint32_t ms) +{ + uint8_t scratch[128]; + const int64_t deadline = esp_timer_get_time() + ((int64_t)ms * 1000); + ResetUartCache(); + while (esp_timer_get_time() < deadline) { + (void)uart_read_bytes(UART_NUM_1, + scratch, + sizeof(scratch), + pdMS_TO_TICKS(2)); + } + ResetUartCache(); +} + +static bool BuildEnd(FusionV2Frame_t *out) +{ + return FusionSavestate_BuildEndSession(out); +} + +static bool BuildBeginSave(FusionV2Frame_t *out) +{ + return FusionSavestate_BuildBeginSave(0u, out); +} + +static bool BuildBeginTestRW(FusionV2Frame_t *out) +{ + return FusionSavestate_BuildBeginTestRW(0u, out); +} + +static bool BuildBeginLoad(FusionV2Frame_t *out) +{ + return FusionSavestate_BuildBeginLoad(0u, out); +} + +static bool ValidateHeaderEvidence(const uint8_t data[16], + uint32_t *bitmap_out, + char tag_out[4]) +{ + const uint32_t bitmap = (uint32_t)data[8] + | ((uint32_t)data[9] << 8) + | ((uint32_t)data[10] << 16) + | ((uint32_t)data[11] << 24); + if (memcmp(data, "FUSS", 4u) != 0) { + printf("Smoke48a: Header magic mismatch\n"); + return false; + } + if (bitmap != kExpectedBitmap) { + printf("Smoke48a: Header bitmap mismatch got=0x%08lX want=0x%08lX\n", + (unsigned long)bitmap, + (unsigned long)kExpectedBitmap); + return false; + } + if (memcmp(&data[12], "P48E", 4u) != 0) { + printf("Smoke48a: Header tag mismatch got='%c%c%c%c' want='P48E'\n", + data[12], data[13], data[14], data[15]); + return false; + } + if (bitmap_out != NULL) { + *bitmap_out = bitmap; + } + if (tag_out != NULL) { + memcpy(tag_out, &data[12], 4u); + } + return true; +} + +static bool ValidateLightRegion(const Smoke48aRegion_t *region, + const uint8_t *data, + uint16_t length) +{ + if (region->region == 0x01u) { + for (uint16_t i = 10u; i < length; ++i) { + if (data[i] != 0u) { + printf("Smoke48a: Top padding byte %u nonzero=0x%02X\n", + (unsigned)i, data[i]); + return false; + } + } + } else if (region->region == 0x03u) { + if (data[6] != 0u || data[7] != 0u) { + printf("Smoke48a: Timer padding mismatch b6=0x%02X b7=0x%02X\n", + data[6], data[7]); + return false; + } + } + return true; +} + +static bool ReadRegionToSlot(FusionStorage_t *storage, + const Smoke48aRegion_t *region, + FusionRegionDirectory_t *dir, + uint32_t offset, + uint32_t *bitmap_out, + char tag_out[4]) +{ + if (!EnsureRxBuf(region->length)) { + return false; + } + uint8_t *data = s_rx_buf; + memset(data, 0, region->length); + + uint16_t received = 0u; + uint16_t expected_seq = 0u; + const int64_t t0 = esp_timer_get_time(); + + FusionV2Frame_t begin; + if (!FusionSavestate_BuildReadStreamBegin(region->region, + 0u, + region->length, + &begin)) { + printf("Smoke48a: build READ_STREAM_BEGIN %s failed\n", region->name); + return false; + } + + printf("Smoke48a: BEGIN REGION %s id=0x%02X length=%u\n", + region->name, (unsigned)region->region, (unsigned)region->length); + if (!SendFrame("READ_STREAM_BEGIN", &begin)) { + return false; + } + + bool pending_data_valid = false; + FusionV2Decoded_t pending_data; + memset(&pending_data, 0, sizeof(pending_data)); + FusionV2Decoded_t first; + if (!ReadDecodedFrame("READ_STREAM_BEGIN ack", &first)) { + return false; + } + if (first.addr == (uint8_t)kFusionAddr_StateCtl && + first.ctl_opcode == (uint8_t)kFusionOp_Error) { + printf("Smoke48a: %s FPGA ERROR code=0x%02X detail=0x%02X\n", + region->name, first.error_code, first.error_detail); + return false; + } + if (first.addr == (uint8_t)kFusionAddr_StateCtl && + first.ctl_opcode == (uint8_t)kFusionOp_AckAccepted && + first.ack_for_opcode == (uint8_t)kFusionOp_ReadStreamBegin) { + /* Expected path. */ + } else if (first.addr == (uint8_t)kFusionAddr_StateData && + first.data_seq == 0u) { + pending_data = first; + pending_data_valid = true; + printf("Smoke48a: %s DATA arrived before READ_STREAM_BEGIN ACK\n", + region->name); + } else { + printf("Smoke48a: READ_STREAM_BEGIN ack unexpected addr=0x%02X " + "op=0x%02X seq=%u\n", + first.addr, first.ctl_opcode, (unsigned)first.data_seq); + return false; + } + + while (received < region->length) { + FusionV2Decoded_t d; + if (pending_data_valid) { + d = pending_data; + pending_data_valid = false; + } else if (!ReadDecodedFrame(region->name, &d)) { + return false; + } + if (d.addr == (uint8_t)kFusionAddr_StateCtl && + d.ctl_opcode == (uint8_t)kFusionOp_Error) { + printf("Smoke48a: %s FPGA ERROR code=0x%02X detail=0x%02X\n", + region->name, d.error_code, d.error_detail); + return false; + } + if (d.addr != (uint8_t)kFusionAddr_StateData) { + printf("Smoke48a: %s expected DATA got addr=0x%02X op=0x%02X\n", + region->name, d.addr, d.ctl_opcode); + return false; + } + if (d.data_seq != expected_seq) { + printf("Smoke48a: %s seq mismatch got=%u expected=%u\n", + region->name, (unsigned)d.data_seq, (unsigned)expected_seq); + return false; + } + if (d.data_len == 0u || (uint16_t)d.data_len > (region->length - received)) { + printf("Smoke48a: %s bad data_len=%u remaining=%u\n", + region->name, + (unsigned)d.data_len, + (unsigned)(region->length - received)); + return false; + } + + memcpy(&data[received], d.data, d.data_len); + received = (uint16_t)(received + d.data_len); + expected_seq = (uint16_t)(expected_seq + 1u); + } + + if (!ExpectCtl((uint8_t)kFusionOp_AckDone, + (uint8_t)kFusionOp_ReadStreamBegin, + "READ_STREAM_BEGIN done")) { + return false; + } + ResetUartCache(); + uart_flush_input(UART_NUM_1); + + if (region->region == 0x00u && + !ValidateHeaderEvidence(data, bitmap_out, tag_out)) { + return false; + } + if ((region->region == 0x01u || region->region == 0x03u) && + !ValidateLightRegion(region, data, received)) { + return false; + } + + FusionStorageResult_t sr = + FusionStorage_AppendRegionPayload(storage, data, received); + if (sr != kFusionStorage_Ok) { + printf("Smoke48a: storage append %s failed result=%d\n", + region->name, (int)sr); + return false; + } + + const uint32_t crc = FusionSavestate_Crc32(data, received); + + dir->region_id = region->region; + memset(dir->reserved, 0, sizeof(dir->reserved)); + dir->offset = offset; + dir->length = region->length; + dir->crc32 = crc; + + const int64_t elapsed_ms = (esp_timer_get_time() - t0) / 1000; + printf("Smoke48a: REGION %s PASS packets=%u bytes=%u crc32=%08lX dt_ms=%lld\n", + region->name, + (unsigned)expected_seq, + (unsigned)received, + (unsigned long)crc, + (long long)elapsed_ms); + return true; +} + +static void PauseFpgaTraffic(void) +{ + TaskHandle_t *tx = FPGA_GetTxTaskHandle(); + TaskHandle_t *rx = FPGA_GetRxTaskHandle(); + if (tx != NULL && *tx != NULL) { + FPGA_Tx_Pause(); + } + if (rx != NULL && *rx != NULL) { + FPGA_Rx_Pause(); + } + vTaskDelay(pdMS_TO_TICKS(30)); + uart_flush(UART_NUM_1); + ResetUartCache(); +} + +static void ResumeFpgaTraffic(void) +{ + TaskHandle_t *tx = FPGA_GetTxTaskHandle(); + TaskHandle_t *rx = FPGA_GetRxTaskHandle(); + if (rx != NULL && *rx != NULL) { + FPGA_Rx_Resume(); + } + if (tx != NULL && *tx != NULL) { + FPGA_Tx_Resume(); + } +} + +static bool EndSessionCleanup(void) +{ + printf("Smoke48a: sending END_SESSION cleanup\n"); + DrainRxForMs(kSmoke48aFinalEndMs); + ResetUartCache(); + uart_flush_input(UART_NUM_1); + return SendEndSessionClear("END_SESSION cleanup"); +} + +static bool ReadAndCrc(FusionStorage_t *storage, + uint32_t slot, + uint32_t offset, + uint32_t length, + uint32_t *crc_out) +{ + uint32_t crc = 0u; + uint8_t buf[256]; + uint32_t pos = 0u; + PwrMgr_IdleTimerPet(); + while (pos < length) { + const uint32_t chunk = ((length - pos) > sizeof(buf)) + ? (uint32_t)sizeof(buf) + : (length - pos); + FusionStorageResult_t sr = + FusionStorage_ReadSlotBytes(storage, slot, offset + pos, buf, chunk); + if (sr != kFusionStorage_Ok) { + printf("Smoke48a: storage read offset=%lu len=%lu failed result=%d\n", + (unsigned long)(offset + pos), + (unsigned long)chunk, + (int)sr); + return false; + } + crc = FusionSavestate_Crc32Update(crc, buf, chunk); + pos += chunk; + PwrMgr_IdleTimerPet(); + } + *crc_out = crc; + return true; +} + +static bool ValidateSlot(FusionStorage_t *storage, Smoke48aResult_t *result) +{ + uint32_t slot = 0u; + uint32_t generation = 0u; + FusionStorageResult_t sr = + FusionStorage_FindNewestValidSlot(storage, &slot, &generation); + if (sr != kFusionStorage_Ok) { + printf("Smoke48a: no valid slot result=%d\n", (int)sr); + return false; + } + + FusionStateHeader_t hdr; + memset(&hdr, 0, sizeof(hdr)); + sr = FusionStorage_ReadSlotHeader(storage, slot, &hdr); + if (sr != kFusionStorage_Ok) { + printf("Smoke48a: read slot header failed result=%d\n", (int)sr); + return false; + } + result->slot_reloaded = true; + + if (hdr.magic != FUSION_SAVESTATE_MAGIC || + hdr.format_version != FUSION_SAVESTATE_FORMAT_VERSION || + hdr.header_size != sizeof(FusionStateHeader_t) || + hdr.commit_state != (uint8_t)kFusionCommit_Valid) { + printf("Smoke48a: bad slot header magic=%08lX version=%u header=%u state=0x%02X\n", + (unsigned long)hdr.magic, + (unsigned)hdr.format_version, + (unsigned)hdr.header_size, + hdr.commit_state); + return false; + } + const bool tag_ok = (memcmp(hdr.savestate_tag, "P48E", 4u) == 0); + if (hdr.total_size != ExpectedTotalSize() || + hdr.region_count != kSmoke48aRegionCount || + hdr.region_bitmap != kExpectedBitmap || + !tag_ok) { + printf("Smoke48a: metadata mismatch total=%lu/%lu regions=%lu/%u " + "bitmap=0x%08lX tag='%c%c%c%c'\n", + (unsigned long)hdr.total_size, + (unsigned long)ExpectedTotalSize(), + (unsigned long)hdr.region_count, + (unsigned)kSmoke48aRegionCount, + (unsigned long)hdr.region_bitmap, + hdr.savestate_tag[0], hdr.savestate_tag[1], + hdr.savestate_tag[2], hdr.savestate_tag[3]); + return false; + } + + uint32_t payload_crc = 0u; + if (!ReadAndCrc(storage, + slot, + sizeof(FusionStateHeader_t), + hdr.total_size - (uint32_t)sizeof(FusionStateHeader_t), + &payload_crc)) { + return false; + } + if (payload_crc != hdr.payload_crc32) { + printf("Smoke48a: overall CRC mismatch got=%08lX want=%08lX\n", + (unsigned long)payload_crc, + (unsigned long)hdr.payload_crc32); + return false; + } + + FusionRegionDirectory_t dirs[kSmoke48aRegionCount]; + sr = FusionStorage_ReadSlotBytes(storage, + slot, + hdr.region_table_offset, + (uint8_t *)dirs, + sizeof(dirs)); + if (sr != kFusionStorage_Ok) { + printf("Smoke48a: read region directory failed result=%d\n", (int)sr); + return false; + } + + uint32_t expected_offset = (uint32_t)sizeof(FusionStateHeader_t); + for (size_t i = 0; i < kSmoke48aRegionCount; ++i) { + const Smoke48aRegion_t *expected = FindExpectedRegion(dirs[i].region_id); + if (expected == NULL || expected->region != kRegions[i].region || + dirs[i].offset != expected_offset || + dirs[i].length != expected->length) { + printf("Smoke48a: directory mismatch idx=%u id=0x%02X offset=%lu length=%lu\n", + (unsigned)i, + dirs[i].region_id, + (unsigned long)dirs[i].offset, + (unsigned long)dirs[i].length); + return false; + } + uint32_t region_crc = 0u; + if (!ReadAndCrc(storage, slot, dirs[i].offset, dirs[i].length, ®ion_crc)) { + return false; + } + if (region_crc != dirs[i].crc32) { + printf("Smoke48a: region 0x%02X CRC mismatch got=%08lX want=%08lX\n", + dirs[i].region_id, + (unsigned long)region_crc, + (unsigned long)dirs[i].crc32); + return false; + } + expected_offset += dirs[i].length; + } + + uint8_t captured_header[16] = {0}; + sr = FusionStorage_ReadSlotBytes(storage, + slot, + dirs[0].offset, + captured_header, + sizeof(captured_header)); + if (sr != kFusionStorage_Ok || + !ValidateHeaderEvidence(captured_header, NULL, NULL)) { + return false; + } + + result->crc_valid = true; + result->load_ready = true; + printf("Smoke48a: slot valid generation=%lu total=%lu payload_crc=%08lX\n", + (unsigned long)generation, + (unsigned long)hdr.total_size, + (unsigned long)hdr.payload_crc32); + return true; +} + +static const FusionRegionDirectory_t *FindDir(const FusionRegionDirectory_t *dirs, + uint32_t count, + uint8_t region) +{ + if (dirs == NULL) { + return NULL; + } + for (uint32_t i = 0u; i < count; ++i) { + if (dirs[i].region_id == region) { + return &dirs[i]; + } + } + return NULL; +} + +static bool ReadRegionToBuffer(uint8_t region, + uint16_t offset, + uint16_t length, + uint8_t *dest, + const char *label) +{ + if (dest == NULL || length == 0u) { + return false; + } + uint16_t received = 0u; + uint16_t expected_seq = 0u; + + FusionV2Frame_t begin; + if (!FusionSavestate_BuildReadStreamBegin(region, offset, length, &begin)) { + printf("Smoke48a: build READ_STREAM_BEGIN %s failed\n", label); + return false; + } + if (!SendFrame("READ_STREAM_BEGIN", &begin)) { + return false; + } + if (!ExpectCtl((uint8_t)kFusionOp_AckAccepted, + (uint8_t)kFusionOp_ReadStreamBegin, + "READ_STREAM_BEGIN ack")) { + return false; + } + + while (received < length) { + FusionV2Decoded_t d; + if (!ReadDecodedFrame(label, &d)) { + return false; + } + if (d.addr == (uint8_t)kFusionAddr_StateCtl && + d.ctl_opcode == (uint8_t)kFusionOp_Error) { + printf("Smoke48a: %s FPGA ERROR code=0x%02X detail=0x%02X\n", + label, d.error_code, d.error_detail); + return false; + } + if (d.addr != (uint8_t)kFusionAddr_StateData || + d.data_seq != expected_seq || + d.data_len == 0u || + (uint16_t)d.data_len > (length - received)) { + printf("Smoke48a: %s read mismatch addr=0x%02X seq=%u/%u len=%u rem=%u\n", + label, + d.addr, + (unsigned)d.data_seq, + (unsigned)expected_seq, + (unsigned)d.data_len, + (unsigned)(length - received)); + return false; + } + memcpy(&dest[received], d.data, d.data_len); + received = (uint16_t)(received + d.data_len); + expected_seq = (uint16_t)(expected_seq + 1u); + } + + return ExpectCtl((uint8_t)kFusionOp_AckDone, + (uint8_t)kFusionOp_ReadStreamBegin, + "READ_STREAM_BEGIN done"); +} + +static bool ReadCurrentCartHeader(uint8_t rom_header[FUSION_ROM_HEADER_BYTES], + uint32_t *game_id_out) +{ + if (rom_header == NULL || game_id_out == NULL) { + return false; + } + memset(rom_header, 0, FUSION_ROM_HEADER_BYTES); + if (!ReadRegionToBuffer((uint8_t)kFusionRegion_RomHeader, + 0u, + FUSION_ROM_HEADER_BYTES, + rom_header, + "CartHeader(current)")) { + return false; + } + *game_id_out = FusionSavestate_ComputeGameIdV1(rom_header); + if (*game_id_out == 0u) { + printf("Smoke48a: current cart game-id hash is zero; refusing\n"); + return false; + } + return true; +} + +static bool VerifyCurrentFpgaHeader(void) +{ + uint8_t header[16] = {0}; + uint32_t bitmap = 0u; + char tag[4] = {0}; + if (!ReadRegionToBuffer((uint8_t)kFusionRegion_Header, + 0u, + sizeof(header), + header, + "Header(current)")) { + return false; + } + if (!ValidateHeaderEvidence(header, &bitmap, tag)) { + return false; + } + printf("Smoke48a: current FPGA capability bitmap=0x%08lX tag='%c%c%c%c'\n", + (unsigned long)bitmap, tag[0], tag[1], tag[2], tag[3]); + return true; +} + +static bool VerifyCurrentGameId(const FusionStateHeader_t *slot_header, + SavestateToastResult_t *toast_result) +{ + if (slot_header == NULL || toast_result == NULL) { + return false; + } + if (slot_header->game_id_algorithm != (uint8_t)FUSION_GAME_ID_ALGORITHM_V1 || + slot_header->game_id_hash == 0u) { + printf("Smoke48a/PathE: slot has no valid game-id evidence algo=%u hash=%08lX\n", + (unsigned)slot_header->game_id_algorithm, + (unsigned long)slot_header->game_id_hash); + *toast_result = kSavestateToast_Failed; + return false; + } + + uint8_t rom_header[FUSION_ROM_HEADER_BYTES]; + uint32_t current_hash = 0u; + if (!ReadCurrentCartHeader(rom_header, ¤t_hash)) { + *toast_result = kSavestateToast_Failed; + return false; + } + if (current_hash != slot_header->game_id_hash) { + printf("Smoke48a/PathE: wrong game current=%08lX saved=%08lX\n", + (unsigned long)current_hash, + (unsigned long)slot_header->game_id_hash); + *toast_result = kSavestateToast_WrongGame; + return false; + } + printf("Smoke48a/PathE: game-id verified hash=%08lX\n", + (unsigned long)current_hash); + return true; +} + +static bool WriteSlotStream(FusionStorage_t *storage, + uint32_t slot, + const FusionRegionDirectory_t *dir, + uint16_t region_offset, + uint16_t length, + const char *label) +{ + if (storage == NULL || dir == NULL || length == 0u) { + return false; + } + if ((uint32_t)region_offset + length > dir->length) { + printf("Smoke48a: %s scalar slice out of range off=%u len=%u dir_len=%lu\n", + label, + (unsigned)region_offset, + (unsigned)length, + (unsigned long)dir->length); + return false; + } + + FusionV2Frame_t begin; + if (!FusionSavestate_BuildWriteStreamBegin(dir->region_id, + region_offset, + length, + &begin)) { + printf("Smoke48a: build WRITE_STREAM_BEGIN %s failed\n", label); + return false; + } + if (!SendFrame("WRITE_STREAM_BEGIN", &begin)) { + return false; + } + /* Tolerate transient bad-payload BEGIN ACK; rely on final ACK_DONE. + * Mirrors smoke48c.ExpectWriteBeginOrProceed fallback. */ + (void)ExpectCtl((uint8_t)kFusionOp_AckAccepted, + (uint8_t)kFusionOp_WriteStreamBegin, + "WRITE_STREAM_BEGIN ack (lenient)"); + + uint16_t sent = 0u; + uint16_t seq = 0u; + uint8_t chunk_buf[FUSION_STATE_DATA_MAX_DATA]; + while (sent < length) { + const uint16_t remaining = (uint16_t)(length - sent); + const uint8_t chunk = (remaining > FUSION_STATE_DATA_MAX_DATA) + ? FUSION_STATE_DATA_MAX_DATA + : (uint8_t)remaining; + FusionStorageResult_t sr = + FusionStorage_ReadSlotBytes(storage, + slot, + dir->offset + region_offset + sent, + chunk_buf, + chunk); + if (sr != kFusionStorage_Ok) { + printf("Smoke48a: %s storage read failed result=%d\n", + label, (int)sr); + return false; + } + FusionV2Frame_t data; + if (!FusionSavestate_BuildStateData(seq, chunk_buf, chunk, &data)) { + printf("Smoke48a: build STATE_DATA %s failed\n", label); + return false; + } + if (!SendFrame("STATE_DATA", &data)) { + return false; + } + sent = (uint16_t)(sent + chunk); + seq = (uint16_t)(seq + 1u); + } + + if (!ExpectCtl((uint8_t)kFusionOp_AckDone, + (uint8_t)kFusionOp_WriteStreamBegin, + "WRITE_STREAM_BEGIN done")) { + return false; + } + printf("Smoke48a: WRITE %s PASS bytes=%u packets=%u\n", + label, (unsigned)length, (unsigned)seq); + /* 50ms inter-write delay: bridge str_state transition + tx pipeline drain. */ + vTaskDelay(pdMS_TO_TICKS(50)); + return true; +} + +static bool WriteCommit(void) +{ + FusionV2Frame_t commit; + if (!FusionSavestate_BuildWriteCommit(&commit)) { + printf("Smoke48a: build WRITE_COMMIT failed\n"); + return false; + } + if (!SendFrame("WRITE_COMMIT", &commit)) { + return false; + } + return ExpectCtl((uint8_t)kFusionOp_AckDone, + (uint8_t)kFusionOp_WriteCommit, + "WRITE_COMMIT done"); +} + +static bool VerifyReadbackCrc(const FusionRegionDirectory_t *dir, + const char *label) +{ + if (dir == NULL || !EnsureRxBuf((uint16_t)dir->length)) { + return false; + } + uint8_t *data = s_rx_buf; + memset(data, 0, dir->length); + if (!ReadRegionToBuffer(dir->region_id, + 0u, + (uint16_t)dir->length, + data, + label)) { + return false; + } + const uint32_t crc = FusionSavestate_Crc32(data, dir->length); + if (crc != dir->crc32) { + printf("Smoke48a: READBACK %s CRC mismatch got=%08lX want=%08lX\n", + label, (unsigned long)crc, (unsigned long)dir->crc32); + return false; + } + printf("Smoke48a: READBACK %s PASS crc32=%08lX\n", + label, (unsigned long)crc); + return true; +} + +/* Write `length` bytes from memory `data` to FPGA region:offset. + * Generalization of WriteSlotStream that does not require a slot — used + * by path-e load to push the freshly-built region 0xFE payload + the + * gbreset toggle bytes. + */ +static bool WriteRawToRegion(uint8_t region_id, + uint16_t region_offset, + const uint8_t *data, + uint16_t length, + const char *label) +{ + if (data == NULL || length == 0u) { + return false; + } + FusionV2Frame_t begin; + if (!FusionSavestate_BuildWriteStreamBegin(region_id, + region_offset, + length, + &begin)) { + printf("Smoke48a: build WRITE_STREAM_BEGIN %s failed\n", label); + return false; + } + if (!SendFrame("WRITE_STREAM_BEGIN", &begin)) { + return false; + } + /* Tolerate transient bad-payload BEGIN ACK; rely on final ACK_DONE. + * Mirrors smoke48c.ExpectWriteBeginOrProceed fallback. */ + (void)ExpectCtl((uint8_t)kFusionOp_AckAccepted, + (uint8_t)kFusionOp_WriteStreamBegin, + "WRITE_STREAM_BEGIN ack (lenient)"); + uint16_t sent = 0u; + uint16_t seq = 0u; + while (sent < length) { + const uint16_t remaining = (uint16_t)(length - sent); + const uint8_t chunk = (remaining > FUSION_STATE_DATA_MAX_DATA) + ? FUSION_STATE_DATA_MAX_DATA + : (uint8_t)remaining; + FusionV2Frame_t frame; + if (!FusionSavestate_BuildStateData(seq, &data[sent], chunk, &frame)) { + printf("Smoke48a: build STATE_DATA %s seq=%u failed\n", + label, (unsigned)seq); + return false; + } + if (!SendFrame("STATE_DATA", &frame)) { + return false; + } + sent = (uint16_t)(sent + chunk); + seq = (uint16_t)(seq + 1u); + } + if (!ExpectCtl((uint8_t)kFusionOp_AckDone, + (uint8_t)kFusionOp_WriteStreamBegin, + "WRITE_STREAM_BEGIN done")) { + return false; + } + printf("Smoke48a: WRITE %s PASS bytes=%u packets=%u\n", + label, (unsigned)length, (unsigned)seq); + /* 50ms inter-write delay: bridge str_state transition + tx pipeline drain. */ + vTaskDelay(pdMS_TO_TICKS(50)); + return true; +} + +/* + * Path-e load orchestration helper. Path-e specifically does NOT write + * region 0x02 (CPU regs) — the FPGA stub restores CPU state via POPs + * during cold-boot. Writing CPU region would set T80 SS_1[15:0] = saved_PC + * which then becomes the cold-reset PC, defeating the stub mechanism. + * + * Sequence: + * 1. Read CPU region from slot, extract path-e load input + * 2. Build region 0xFE payload via FusionSavestate_BuildPathELoadPayload + * 3. Write Top/Timer/HRAM/WRAM (skip CPU) + * 4. Write region 0xFE bytes 0-14 (load_mode_active=1 + scratch + saved_*) + * 5. Write region 0xFE byte 15 = 1 (gbreset assert) + * 6. vTaskDelay + * 7. Write region 0xFE byte 15 = 0 (gbreset release) + * - CPU now executes stub from $0000, restores via POPs, jumps to saved_PC + */ +static bool LoadSlotToFpga_PathE(FusionStorage_t *storage, + uint32_t slot, + const FusionRegionDirectory_t dirs[kSmoke48aRegionCount]) +{ + const FusionRegionDirectory_t *top = FindDir(dirs, kSmoke48aRegionCount, 0x01u); + const FusionRegionDirectory_t *cpu = FindDir(dirs, kSmoke48aRegionCount, 0x02u); + const FusionRegionDirectory_t *timer = FindDir(dirs, kSmoke48aRegionCount, 0x03u); + const FusionRegionDirectory_t *hram = FindDir(dirs, kSmoke48aRegionCount, 0x09u); + const FusionRegionDirectory_t *wram = FindDir(dirs, kSmoke48aRegionCount, 0x0Cu); + if (top == NULL || cpu == NULL || timer == NULL || hram == NULL || wram == NULL) { + printf("Smoke48a/PathE: load missing required region directory\n"); + return false; + } + + /* 1. Extract path-e load input from saved CPU region (40 bytes). */ + uint8_t cpu_bytes[40] = {0}; + if (cpu->length < sizeof(cpu_bytes)) { + printf("Smoke48a/PathE: CPU region too short %lu < %u\n", + (unsigned long)cpu->length, (unsigned)sizeof(cpu_bytes)); + return false; + } + FusionStorageResult_t sr = FusionStorage_ReadSlotBytes(storage, + slot, + cpu->offset, + cpu_bytes, + sizeof(cpu_bytes)); + if (sr != kFusionStorage_Ok) { + printf("Smoke48a/PathE: storage read CPU bytes failed result=%d\n", (int)sr); + return false; + } + FusionPathELoadInput_t input; + if (!FusionSavestate_ExtractPathELoadInputFromCpuRegion(cpu_bytes, + sizeof(cpu_bytes), + &input)) { + printf("Smoke48a/PathE: extract path-e input failed\n"); + return false; + } + + /* 2. Build path-e region 0xFE payload (16 bytes; byte 15 stays 0). */ + uint8_t pathe_payload[kPathE_RegionLength]; + if (!FusionSavestate_BuildPathELoadPayload(&input, pathe_payload)) { + printf("Smoke48a/PathE: build payload failed (HALT@PC=0?)\n"); + return false; + } + printf("Smoke48a/PathE: payload built A=%02X F=%02X BC=%02X%02X DE=%02X%02X " + "HL=%02X%02X SP=%04X PC=%04X IFF=%d HALT=%d\n", + input.saved_A, input.saved_F, + input.saved_B, input.saved_C, + input.saved_D, input.saved_E, + input.saved_H, input.saved_L, + input.saved_SP, input.saved_PC, + (int)input.saved_IFF, (int)input.halted_at_save); + + /* 3. Write Top, Timer, HRAM, WRAM. Skip CPU region (path-e architecture). */ + if (!WriteSlotStream(storage, slot, top, 0u, 8u, "Top[0]") || + !WriteSlotStream(storage, slot, top, 8u, 8u, "Top[1]") || + !WriteSlotStream(storage, slot, timer, 0u, 8u, "Timer") || + !WriteSlotStream(storage, slot, hram, 0u, (uint16_t)hram->length, "HRAM") || + !WriteSlotStream(storage, slot, wram, 0u, (uint16_t)wram->length, "WRAM")) { + return false; + } + + /* 4. Write region 0xFE bytes 0-14 (15 bytes including load_mode_active=1). */ + if (!WriteRawToRegion((uint8_t)kFusionRegion_PathELoad, + 0u, + pathe_payload, + (uint16_t)kPathE_GbresetRequest, /* 15 bytes */ + "PathE[0..14]")) { + return false; + } + + /* 5. Assert gbreset via region 0xFE byte 15 = 1. */ + const uint8_t gbreset_assert = 0x01u; + if (!WriteRawToRegion((uint8_t)kFusionRegion_PathELoad, + (uint16_t)kPathE_GbresetRequest, + &gbreset_assert, + 1u, + "PathE gbreset=1")) { + return false; + } + + /* 6. Hold gbreset for FUSION_PATHE_GBRESET_HOLD_MS to let reset propagate. */ + vTaskDelay(pdMS_TO_TICKS(FUSION_PATHE_GBRESET_HOLD_MS)); + + /* 7. Release gbreset via region 0xFE byte 15 = 0. CPU now cold-boots + * from $0000 = stub bytes (load_mode_active=1 from step 4). */ + const uint8_t gbreset_release = 0x00u; + if (!WriteRawToRegion((uint8_t)kFusionRegion_PathELoad, + (uint16_t)kPathE_GbresetRequest, + &gbreset_release, + 1u, + "PathE gbreset=0")) { + return false; + } + + printf("Smoke48a/PathE: load orchestration complete\n"); + return true; +} + +bool FusionSavestate_QuickLoadSlot0_PathE(void) +{ + PwrMgr_IdleTimerPet(); + + Smoke48aResult_t result = {0}; + SavestateToastResult_t toast_result = kSavestateToast_Failed; + bool ok = false; + bool cleanup_ok = true; + bool traffic_paused = false; + bool owner_acquired = false; + bool preflight_session_started = false; + bool load_session_started = false; + + FusionStorage_t storage; + if (!InitStorage(&storage)) { + printf("Smoke48a/PathE: storage init failed\n"); + goto done; + } + + ok = ValidateSlot(&storage, &result); + if (!ok) { + goto done; + } + + uint32_t slot = 0u; + uint32_t generation = 0u; + FusionStorageResult_t sr = + FusionStorage_FindNewestValidSlot(&storage, &slot, &generation); + if (sr != kFusionStorage_Ok) { + ok = false; + goto done; + } + + FusionStateHeader_t hdr; + memset(&hdr, 0, sizeof(hdr)); + sr = FusionStorage_ReadSlotHeader(&storage, slot, &hdr); + if (sr != kFusionStorage_Ok) { + ok = false; + goto done; + } + FusionRegionDirectory_t dirs[kSmoke48aRegionCount]; + sr = FusionStorage_ReadSlotBytes(&storage, + slot, + hdr.region_table_offset, + (uint8_t *)dirs, + sizeof(dirs)); + if (sr != kFusionStorage_Ok) { + ok = false; + goto done; + } + + PwrMgr_IdleTimerSuspend(); + PauseFpgaTraffic(); + traffic_paused = true; + FPGA_Rx_ResetParser(); + ResetUartCache(); + if (!FPGA_UartOwnerAcquire(pdMS_TO_TICKS(2000))) { + printf("Smoke48a/PathE: UART owner unavailable\n"); + ok = false; + goto cleanup; + } + owner_acquired = true; + + do { + DrainRxForMs(kSmoke48aPreflightDrainMs); + FPGA_Rx_ResetParser(); + ResetUartCache(); + ok = SendEndSessionClear("END_SESSION clear"); + if (!ok) break; + + DrainRxForMs(kSmoke48aPreflightDrainMs); + FPGA_Rx_ResetParser(); + ResetUartCache(); + ok = SendCtlAndExpectAck("BEGIN_TEST_RW", + BuildBeginTestRW, + (uint8_t)kFusionOp_BeginTestRW); + if (!ok) break; + preflight_session_started = true; + + ok = VerifyCurrentFpgaHeader() && VerifyCurrentGameId(&hdr, &toast_result); + if (!ok) break; + + cleanup_ok = EndSessionCleanup(); + preflight_session_started = false; + if (!cleanup_ok) { + ok = false; + break; + } + + DrainRxForMs(kSmoke48aPreflightDrainMs); + FPGA_Rx_ResetParser(); + ResetUartCache(); + ok = SendCtlAndExpectAck("BEGIN_LOAD", + BuildBeginLoad, + (uint8_t)kFusionOp_BeginLoad); + if (!ok) break; + + load_session_started = true; + printf("Smoke48a/PathE: load session active slot=%lu generation=%lu\n", + (unsigned long)slot, (unsigned long)generation); + + ok = LoadSlotToFpga_PathE(&storage, slot, dirs); + } while (false); + +cleanup: + if (load_session_started && !ok) { + const uint8_t load_mode_clear = 0u; + (void)WriteRawToRegion((uint8_t)kFusionRegion_PathELoad, + (uint16_t)kPathE_LoadModeActive, + &load_mode_clear, + 1u, + "PathE load_mode=0 cleanup"); + } + if (preflight_session_started || load_session_started) { + cleanup_ok = EndSessionCleanup(); + } + + if (traffic_paused) { + ResumeFpgaTraffic(); + } + if (owner_acquired) { + FPGA_UartOwnerRelease(); + } + if (traffic_paused) { + PwrMgr_IdleTimerResume(); + } + +done: + ok = ok && cleanup_ok; + if (ok) { + toast_result = kSavestateToast_Loaded; + } + SavestateToast_Show(toast_result, SAVESTATE_TOAST_DEFAULT_MS); + return ok; +} + +static bool LoadSlotToFpga(FusionStorage_t *storage, + uint32_t slot, + const FusionRegionDirectory_t dirs[kSmoke48aRegionCount]) +{ + const FusionRegionDirectory_t *top = FindDir(dirs, kSmoke48aRegionCount, 0x01u); + const FusionRegionDirectory_t *cpu = FindDir(dirs, kSmoke48aRegionCount, 0x02u); + const FusionRegionDirectory_t *timer = FindDir(dirs, kSmoke48aRegionCount, 0x03u); + const FusionRegionDirectory_t *hram = FindDir(dirs, kSmoke48aRegionCount, 0x09u); + const FusionRegionDirectory_t *wram = FindDir(dirs, kSmoke48aRegionCount, 0x0Cu); + if (top == NULL || cpu == NULL || timer == NULL || hram == NULL || wram == NULL) { + printf("Smoke48a: load missing required region directory\n"); + return false; + } + + uint8_t header[16] = {0}; + if (!ReadRegionToBuffer(0x00u, 0u, sizeof(header), header, "Header(current)")) { + return false; + } + uint32_t bitmap = 0u; + char tag[4] = {0}; + if (!ValidateHeaderEvidence(header, &bitmap, tag) || + memcmp(tag, "P48E", 4u) != 0) { + printf("Smoke48a: current FPGA is not P48E load-capable tag='%c%c%c%c'\n", + tag[0], tag[1], tag[2], tag[3]); + return false; + } + + if (!WriteSlotStream(storage, slot, top, 0u, 8u, "Top[0]") || + !WriteSlotStream(storage, slot, top, 8u, 8u, "Top[1]") || + !WriteSlotStream(storage, slot, cpu, 0u, 2u, "CPU_GBSE") || + !WriteSlotStream(storage, slot, cpu, 2u, 8u, "CPU_CPUREGS") || + !WriteSlotStream(storage, slot, cpu, 10u, 8u, "CPU_T80_1") || + !WriteSlotStream(storage, slot, cpu, 18u, 7u, "CPU_T80_2") || + !WriteSlotStream(storage, slot, cpu, 25u, 8u, "CPU_T80_3") || + !WriteSlotStream(storage, slot, cpu, 33u, 7u, "CPU_T80_4") || + !WriteSlotStream(storage, slot, timer, 0u, 8u, "Timer") || + !WriteSlotStream(storage, slot, hram, 0u, (uint16_t)hram->length, "HRAM") || + !WriteSlotStream(storage, slot, wram, 0u, (uint16_t)wram->length, "WRAM")) { + return false; + } + + if (!WriteCommit()) { + return false; + } + + return VerifyReadbackCrc(top, "Top") && + VerifyReadbackCrc(cpu, "CPU") && + VerifyReadbackCrc(timer, "Timer") && + VerifyReadbackCrc(hram, "HRAM") && + VerifyReadbackCrc(wram, "WRAM"); +} + +static bool InitStorage(FusionStorage_t *storage) +{ + FusionStorageResult_t sr = FusionStorage_InitFromPartition(storage, kPartitionLabel); + if (sr != kFusionStorage_Ok) { + printf("Smoke48a: init partition '%s' failed result=%d\n", + kPartitionLabel, (int)sr); + return false; + } + return true; +} + +bool FusionSavestate_QuickLoadSlot0(void) +{ + PwrMgr_IdleTimerPet(); + + Smoke48aResult_t result = {0}; + FusionStorage_t storage; + if (!InitStorage(&storage)) { + PrintResult(&result); + return false; + } + + bool ok = ValidateSlot(&storage, &result); + if (!ok) { + PrintResult(&result); + return false; + } + + uint32_t slot = 0u; + uint32_t generation = 0u; + FusionStorageResult_t sr = + FusionStorage_FindNewestValidSlot(&storage, &slot, &generation); + if (sr != kFusionStorage_Ok) { + PrintResult(&result); + return false; + } + + FusionStateHeader_t hdr; + memset(&hdr, 0, sizeof(hdr)); + sr = FusionStorage_ReadSlotHeader(&storage, slot, &hdr); + if (sr != kFusionStorage_Ok) { + PrintResult(&result); + return false; + } + FusionRegionDirectory_t dirs[kSmoke48aRegionCount]; + sr = FusionStorage_ReadSlotBytes(&storage, + slot, + hdr.region_table_offset, + (uint8_t *)dirs, + sizeof(dirs)); + if (sr != kFusionStorage_Ok) { + PrintResult(&result); + return false; + } + + bool session_started = false; + PwrMgr_IdleTimerSuspend(); + PauseFpgaTraffic(); + FPGA_Rx_ResetParser(); + ResetUartCache(); + if (!FPGA_UartOwnerAcquire(pdMS_TO_TICKS(2000))) { + printf("Smoke48a: UART owner unavailable for load\n"); + ResumeFpgaTraffic(); + PwrMgr_IdleTimerResume(); + PrintResult(&result); + return false; + } + + do { + DrainRxForMs(kSmoke48aPreflightDrainMs); + FPGA_Rx_ResetParser(); + ResetUartCache(); + ok = SendEndSessionClear("END_SESSION clear"); + if (!ok) { + break; + } + DrainRxForMs(kSmoke48aPreflightDrainMs); + FPGA_Rx_ResetParser(); + ResetUartCache(); + ok = SendCtlAndExpectAck("BEGIN_TEST_RW", + BuildBeginTestRW, + (uint8_t)kFusionOp_BeginTestRW); + if (!ok) { + break; + } + session_started = true; + printf("Smoke48a: load mixed session active slot=%lu generation=%lu\n", + (unsigned long)slot, + (unsigned long)generation); + ok = LoadSlotToFpga(&storage, slot, dirs); + } while (false); + + bool cleanup_ok = true; + if (session_started) { + cleanup_ok = EndSessionCleanup(); + } + + ResumeFpgaTraffic(); + FPGA_UartOwnerRelease(); + PwrMgr_IdleTimerResume(); + + result.load_ready = ok && cleanup_ok; + PrintResult(&result); + return result.load_ready; +} + +bool FusionSavestate_QuickSaveSlot0(void) +{ + Smoke48aResult_t result = {0}; + FusionStorage_t storage; + if (!InitStorage(&storage)) { + PrintResult(&result); + return false; + } + + uint32_t prior_slot = 0u; + uint32_t prior_generation = 0u; + uint32_t next_generation = 1u; + if (FusionStorage_FindNewestValidSlot(&storage, &prior_slot, &prior_generation) == + kFusionStorage_Ok) { + next_generation = prior_generation + 1u; + } + + FusionRegionDirectory_t dirs[kSmoke48aRegionCount]; + memset(dirs, 0, sizeof(dirs)); + uint32_t next_offset = (uint32_t)sizeof(FusionStateHeader_t); + uint32_t region_bitmap = 0u; + char savestate_tag[4] = {0}; + uint8_t current_rom_header[FUSION_ROM_HEADER_BYTES]; + uint32_t current_game_id = 0u; + uint32_t staged_slot = UINT32_MAX; + bool ok = true; + bool session_started = false; + + PwrMgr_IdleTimerSuspend(); + PauseFpgaTraffic(); + FPGA_Rx_ResetParser(); + ResetUartCache(); + if (!FPGA_UartOwnerAcquire(pdMS_TO_TICKS(2000))) { + printf("Smoke48a: UART owner unavailable\n"); + ResumeFpgaTraffic(); + PwrMgr_IdleTimerResume(); + PrintResult(&result); + return false; + } + + do { + DrainRxForMs(kSmoke48aPreflightDrainMs); + FPGA_Rx_ResetParser(); + ResetUartCache(); + ok = SendEndSessionClear("END_SESSION clear"); + if (!ok) { + break; + } + DrainRxForMs(kSmoke48aPreflightDrainMs); + FPGA_Rx_ResetParser(); + ResetUartCache(); + ok = SendCtlAndExpectAck("BEGIN_SAVE", + BuildBeginSave, + (uint8_t)kFusionOp_BeginSave); + if (!ok) { + break; + } + session_started = true; + printf("Smoke48a: save session active\n"); + vTaskDelay(pdMS_TO_TICKS(50)); + + ok = VerifyCurrentFpgaHeader() && + ReadCurrentCartHeader(current_rom_header, ¤t_game_id); + if (!ok) { + break; + } + + FusionStorageResult_t sr = FusionStorage_BeginInactiveSlotWrite(&storage); + if (sr != kFusionStorage_Ok) { + printf("Smoke48a: begin slot write failed result=%d\n", (int)sr); + ok = false; + break; + } + if (storage.active_slot < 0) { + printf("Smoke48a: begin slot write returned no active slot\n"); + ok = false; + break; + } + staged_slot = (uint32_t)storage.active_slot; + + for (size_t i = 0; i < kSmoke48aRegionCount; ++i) { + ok = ReadRegionToSlot(&storage, + &kRegions[i], + &dirs[i], + next_offset, + ®ion_bitmap, + savestate_tag); + if (!ok) { + break; + } + next_offset += kRegions[i].length; + DrainRxForMs(kSmoke48aInterRegionMs); + } + } while (false); + + bool cleanup_ok = true; + if (session_started) { + cleanup_ok = EndSessionCleanup(); + } + + if (!ok || !cleanup_ok) { + (void)FusionStorage_AbortSlotWrite(&storage); + ResumeFpgaTraffic(); + FPGA_UartOwnerRelease(); + PwrMgr_IdleTimerResume(); + PrintResult(&result); + return false; + } + result.save_captured = true; + + FusionStateHeader_t hdr; + memset(&hdr, 0, sizeof(hdr)); + hdr.game_id_algorithm = (uint8_t)FUSION_GAME_ID_ALGORITHM_V1; + hdr.game_id_hash = current_game_id; + memcpy(hdr.fpga_version, savestate_tag, sizeof(savestate_tag)); + const esp_app_desc_t *desc = esp_app_get_description(); + if (desc != NULL) { + memcpy(hdr.mcu_version, desc->version, + strlen(desc->version) < sizeof(hdr.mcu_version) + ? strlen(desc->version) + : sizeof(hdr.mcu_version)); + } + hdr.commit_generation = next_generation; + hdr.region_bitmap = region_bitmap; + memcpy(hdr.savestate_tag, savestate_tag, sizeof(hdr.savestate_tag)); + + printf("Smoke48a: staging slot header\n"); + FusionStorageResult_t sr = + FusionStorage_StageHeader(&storage, &hdr, dirs, kSmoke48aRegionCount); + if (sr != kFusionStorage_Ok) { + printf("Smoke48a: stage header failed result=%d\n", (int)sr); + (void)FusionStorage_AbortSlotWrite(&storage); + ResumeFpgaTraffic(); + FPGA_UartOwnerRelease(); + PwrMgr_IdleTimerResume(); + PrintResult(&result); + return false; + } + printf("Smoke48a: committing slot\n"); + sr = FusionStorage_VerifyAndCommit(&storage); + if (sr != kFusionStorage_Ok) { + printf("Smoke48a: verify/commit failed result=%d\n", (int)sr); + (void)FusionStorage_AbortSlotWrite(&storage); + ResumeFpgaTraffic(); + FPGA_UartOwnerRelease(); + PwrMgr_IdleTimerResume(); + PrintResult(&result); + return false; + } + result.slot_committed = true; + + printf("Smoke48a: validating committed slot\n"); + if (!ValidateSlot(&storage, &result)) { + printf("Smoke48a: post-commit validation failed; invalidating slot %lu\n", + (unsigned long)staged_slot); + if (staged_slot != UINT32_MAX) { + (void)FusionStorage_InvalidateSlot(&storage, staged_slot); + } + result.slot_committed = false; + ResumeFpgaTraffic(); + FPGA_UartOwnerRelease(); + PwrMgr_IdleTimerResume(); + PrintResult(&result); + SavestateToast_Show(kSavestateToast_Failed, SAVESTATE_TOAST_DEFAULT_MS); + return false; + } + ResumeFpgaTraffic(); + FPGA_UartOwnerRelease(); + PwrMgr_IdleTimerResume(); + PrintResult(&result); + + /* + * Update OSD Save Slot menu entry with new slot's game-id hash so + * users see what's saved. Display short hex of game_id_hash; full + * GB title support deferred per lock §4.8d. + */ + if (result.slot_committed) { + FusionStorage_t s; + if (FusionStorage_InitFromPartition(&s, "savestate") == kFusionStorage_Ok) { + uint32_t s_slot, s_gen; + if (FusionStorage_FindNewestValidSlot(&s, &s_slot, &s_gen) == + kFusionStorage_Ok) { + FusionStateHeader_t hdr; + if (FusionStorage_ReadSlotHeader(&s, s_slot, &hdr) == + kFusionStorage_Ok) { + char text[24]; + snprintf(text, sizeof(text), "%08lX", + (unsigned long)hdr.game_id_hash); + SaveSlot_SetText(text); + } + } + } + } + + SavestateToast_Show(result.slot_committed ? kSavestateToast_Saved + : kSavestateToast_Failed, + SAVESTATE_TOAST_DEFAULT_MS); + return result.load_ready; +} + +static int Smoke48aCommand(int argc, char **argv) +{ + if (argc < 2) { + printf("Usage: smoke48a save|validate|load|reboot\n"); + return 1; + } + if (strcmp(argv[1], "save") == 0) { + return FusionSavestate_QuickSaveSlot0() ? 0 : 1; + } + if (strcmp(argv[1], "validate") == 0) { + Smoke48aResult_t result = {0}; + FusionStorage_t storage; + if (!InitStorage(&storage)) { + PrintResult(&result); + return 1; + } + return ValidateSlot(&storage, &result) ? 0 : 1; + } + if (strcmp(argv[1], "load") == 0) { + return FusionSavestate_QuickLoadSlot0_PathE() ? 0 : 1; + } + if (strcmp(argv[1], "reboot") == 0) { + printf("Smoke48a: rebooting now; run 'smoke48a validate' after boot\n"); + fflush(stdout); + vTaskDelay(pdMS_TO_TICKS(100)); + esp_restart(); + return 0; + } + printf("Usage: smoke48a save|validate|load|reboot\n"); + return 1; +} + +void FusionSavestateSmoke48a_RegisterCommands(void) +{ + const esp_console_cmd_t command = { + .command = "smoke48a", + .help = "Phase 4.8a savestate persistence smoke: save|validate|reboot", + .func = &Smoke48aCommand, + .argtable = NULL, + }; + + esp_err_t err = esp_console_cmd_register(&command); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Registering '%s' failed: %s", + command.command, esp_err_to_name(err)); + } +} + +/* ===== Path-e (4.8d) chord-triggered dispatcher ===== */ + +enum { + kChordQueueLen = 4, /* allow a small backlog */ + kChordTaskStack = 16384, /* save/load are stack-heavy */ + kChordTaskPriority = 5, +}; + +static QueueHandle_t s_chord_queue = NULL; +static StaticQueue_t s_chord_queue_storage; +static uint8_t s_chord_queue_buf[kChordQueueLen * sizeof(ButtonChordEvent_t)]; + +static void ChordDispatcherTask(void *arg) +{ + (void)arg; + for (;;) { + ButtonChordEvent_t evt = kButtonChord_None; + if (xQueueReceive(s_chord_queue, &evt, portMAX_DELAY) == pdPASS) { + switch (evt) { + case kButtonChord_SaveRequested: + ESP_LOGI(TAG, "chord: SaveRequested -> QuickSaveSlot0"); + (void)FusionSavestate_QuickSaveSlot0(); + break; + case kButtonChord_LoadRequested: + ESP_LOGI(TAG, "chord: LoadRequested -> QuickLoadSlot0_PathE"); + (void)FusionSavestate_QuickLoadSlot0_PathE(); + break; + default: + break; + } + } + } +} + +void FusionSavestate_StartChordDispatcher(void) +{ + if (s_chord_queue != NULL) { + return; /* already started */ + } + s_chord_queue = xQueueCreateStatic(kChordQueueLen, + sizeof(ButtonChordEvent_t), + s_chord_queue_buf, + &s_chord_queue_storage); + if (s_chord_queue == NULL) { + ESP_LOGE(TAG, "chord queue create failed"); + return; + } + BaseType_t r = xTaskCreate(ChordDispatcherTask, + "ss_chord", + kChordTaskStack, + NULL, + kChordTaskPriority, + NULL); + if (r != pdPASS) { + ESP_LOGE(TAG, "chord task create failed: %d", (int)r); + } +} + +void FusionSavestate_PostChordRequest(ButtonChordEvent_t event) +{ + if (s_chord_queue == NULL || event == kButtonChord_None) { + return; + } + /* Non-blocking send: if dispatcher is busy, drop the event silently + * (prevents button-mash from queuing multiple saves). */ + (void) xQueueSend(s_chord_queue, &event, 0); +} diff --git a/main/fusion_savestate_smoke48a.h b/main/fusion_savestate_smoke48a.h new file mode 100644 index 0000000..f90c85c --- /dev/null +++ b/main/fusion_savestate_smoke48a.h @@ -0,0 +1,60 @@ +#pragma once + +#include + +/* + * Phase 4.8a MCU persistence smoke harness. + * + * Stable MCU entry points for the future hotkey layer: + * - FusionSavestate_QuickSaveSlot0() + * - FusionSavestate_QuickLoadSlot0() + * + * 4.8a deliberately does not choose or hardwire the final Chromatic button + * chord. Registers a manual console command for validation only: + * smoke48a save - capture the Phase 4.7 read matrix and commit slot 0 + * smoke48a validate - call QuickLoadSlot0 and report load-ready + * smoke48a reboot - software reboot helper for persistence smoke tests + * + * 4.8d hotkey integration constraints: + * - Save and load triggers must be distinct. + * - Do not conflict with power/sleep/reset/menu/OSD ownership. + * - Debounce and prevent repeated accidental triggers. + * - Load needs a mis-trigger guard such as long hold, chord, or confirmation. + * - Button scanning stays separate from protocol/storage logic. + * - Final button chord is deferred until input ownership is reviewed. + */ + +bool FusionSavestate_QuickSaveSlot0(void); +bool FusionSavestate_QuickLoadSlot0(void); + +/* + * Path-e (4.8d) load orchestration. Replaces the legacy QuickLoadSlot0 + * scalar-restore writeback with the path-e architecture: stub-driven + * cold-boot CPU register restore via region 0xFE plus gbreset toggle. + * + * Path-e specifically does NOT write region 0x02 (CPU regs). See + * fusion_savestate_pathe.h for the full sequence specification. + */ +bool FusionSavestate_QuickLoadSlot0_PathE(void); + +void FusionSavestateSmoke48a_RegisterCommands(void); + +/* ----- Path-e (4.8d) chord-triggered dispatcher ----- */ + +#include "button.h" + +/* + * Initialize the chord dispatcher task and queue. Call once during MCU + * boot (in main.c, after Button_RegisterCommands). The task consumes + * save/load chord events and runs the corresponding QuickSave/QuickLoad + * synchronously. Save/load are slow (UART transfer of WRAM), so this + * decouples them from the FPGA RX task. + */ +void FusionSavestate_StartChordDispatcher(void); + +/* + * Post a chord event to the dispatcher. Safe to call from FPGA RX task. + * Drops the event silently if the queue is full (prevents button spam + * from queuing many save attempts). + */ +void FusionSavestate_PostChordRequest(ButtonChordEvent_t event); diff --git a/main/fusion_savestate_smoke48c.c b/main/fusion_savestate_smoke48c.c new file mode 100644 index 0000000..991fccc --- /dev/null +++ b/main/fusion_savestate_smoke48c.c @@ -0,0 +1,1319 @@ +#include "fusion_savestate_smoke48c.h" + +#include "driver/uart.h" +#include "esp_console.h" +#include "esp_err.h" +#include "esp_heap_caps.h" +#include "esp_log.h" +#include "esp_timer.h" +#include "fpga_common.h" +#include "fpga_rx.h" +#include "fpga_tx.h" +#include "fusion_savestate.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "pwrmgr.h" +#include "savestate_storage.h" + +#include +#include +#include +#include +#include +#include + +/* + * Phase 4.8c v2 — strict bit-exact RAM-only round-trip smoke harness. + * + * One continuous paused session. After OP_BEGIN_TEST_RW is accepted, + * `session_pause` stays asserted until the final END_SESSION, so the + * GB CPU never runs between any harness write and any subsequent + * readback. The bridge change that enables this lives in + * `state_port_uart_bridge.sv` and uses the predicates + * `sess_can_read_stream` / `sess_can_write_stream` to gate + * READ_STREAM_* / WRITE_STREAM_* opcodes during the mixed session. + * + * Sequence (no flash writes, no hotkeys, no Top/CPU/Timer restore): + * BEGIN_TEST_RW + * R1: read original HRAM/WRAM into MCU RAM + * W1: write XOR-pattern temp HRAM/WRAM + * R2: re-read temp HRAM/WRAM and bit-exact compare + * W2: write original HRAM/WRAM back (restore) + * R3: re-read original HRAM/WRAM and bit-exact compare + * END_SESSION + * + * The strict 4.8c PASS bar: + * - temp HRAM readback exact; + * - temp WRAM readback exact; + * - restored HRAM readback exact; + * - restored WRAM readback exact; + * - END_SESSION cleanup ACK; + * - persistent slot still valid (gen unchanged); + * - user-visible core/game recovery cleanly verified after END_SESSION. + * + * The harness emits a per-byte mismatch preview (up to 16 offsets) on + * any failed compare and records the first protocol failure. Pre/post + * slot validation is read-only. If OP_BEGIN_TEST_RW is rejected with + * ERR_NO_SESSION the bridge is the older P48C bitstream; flash a P48D + * (or newer) bitstream before retrying. + */ + +enum { + kSmoke48cFrameTimeoutMs = 3000, + kSmoke48cPreflightDrainMs = 120, + kSmoke48cFinalEndMs = 50, + kSmoke48cEndSessionAttempts = 4, + kSmoke48cWriteProgressEvery = 512u, + kSmoke48cInterSessionMs = 30, + kSmoke48cPostBeginDrainMs = 60, + kSmoke48cBadPayloadDumpBytes = 16, + kSmoke48cMismatchPreviewCount = 16, +}; + +#define SMOKE48C_HRAM_REGION 0x09u +#define SMOKE48C_WRAM_REGION 0x0Cu +#define SMOKE48C_HRAM_LEN 127u +#define SMOKE48C_WRAM_LEN 32768u +#define SMOKE48C_SLOT_TAG "P48C" + +static const char *TAG = "Smoke48c"; +static const char kPartitionLabel[] = "savestate"; + +static const uint8_t kXorPattern[32] = { + 0xA5u, 0x5Au, 0xC3u, 0x3Cu, 0xF0u, 0x0Fu, 0x96u, 0x69u, + 0x33u, 0xCCu, 0x55u, 0xAAu, 0x77u, 0x88u, 0x11u, 0xEEu, + 0x22u, 0xDDu, 0x44u, 0xBBu, 0x66u, 0x99u, 0xB7u, 0x4Du, + 0x1Fu, 0xE0u, 0x2Au, 0xD5u, 0x88u, 0x77u, 0x4Bu, 0xB4u, +}; + +typedef struct { + bool slot_valid_pre; + /* Phase tracking for the single mixed paused session. All phases + * happen within ONE BEGIN_TEST_RW / END_SESSION pair. */ + bool begin_ok; /* OP_BEGIN_TEST_RW accepted */ + bool phase_r1_ok; /* read original HRAM + WRAM */ + bool phase_w1_ok; /* write temp HRAM + WRAM */ + bool phase_r2_ok; /* read back temp HRAM + WRAM */ + bool phase_w2_ok; /* write original HRAM + WRAM */ + bool phase_r3_ok; /* read back original HRAM + WRAM */ + bool end_ok; /* END_SESSION acked inside the session */ + bool slot_valid_post; + bool core_released; /* final cleanup END_SESSION ACK observed */ + bool restore_attempted; + bool restore_verified; + + uint32_t crc_orig_hram; + uint32_t crc_orig_wram; + uint32_t crc_temp_hram; + uint32_t crc_temp_wram; + uint32_t crc_readback_temp_hram; + uint32_t crc_readback_temp_wram; + uint32_t crc_readback_orig_hram; + uint32_t crc_readback_orig_wram; + + uint32_t mismatch_temp_hram; + uint32_t mismatch_temp_wram; + uint32_t mismatch_orig_hram; + uint32_t mismatch_orig_wram; + int32_t first_mismatch_temp_hram; + int32_t first_mismatch_temp_wram; + int32_t first_mismatch_orig_hram; + int32_t first_mismatch_orig_wram; + + uint32_t generation_pre; + uint32_t generation_post; + + /* Total elapsed time of the paused session window (BEGIN_TEST_RW + * ACK -> END_SESSION ACK). Diagnostic only; not a pass criterion. */ + int64_t paused_session_us; + + /* First-failure forensics. */ + char fail_step[24]; + char fail_reason[64]; + int32_t fail_region; + int32_t fail_expected_seq; + int32_t fail_got_seq; + uint32_t fail_bytes_so_far; + uint32_t fail_packets_so_far; + uint32_t fail_drained_bytes; +} Smoke48cResult_t; + +static Smoke48cResult_t s_result; + +static uint8_t s_orig_hram[SMOKE48C_HRAM_LEN]; +static uint8_t s_scratch_hram[SMOKE48C_HRAM_LEN]; +static uint8_t *s_wram_storage = NULL; +static uint8_t *s_orig_wram = NULL; +static uint8_t *s_scratch_wram = NULL; + +static uint8_t s_uart_cache[512]; +static size_t s_uart_cache_pos = 0u; +static size_t s_uart_cache_len = 0u; + +/* Timestamp of the BEGIN_TEST_RW ACK so we can report total paused + * session duration from the diagnostic side. */ +static int64_t s_session_open_us = 0; + +static void ResetUartCache(void) +{ + s_uart_cache_pos = 0u; + s_uart_cache_len = 0u; +} + +static uint32_t DrainRxForMsCounted(uint32_t ms) +{ + uint8_t scratch[128]; + uint32_t total = 0u; + const int64_t deadline = esp_timer_get_time() + ((int64_t)ms * 1000); + ResetUartCache(); + while (esp_timer_get_time() < deadline) { + const int got = uart_read_bytes(UART_NUM_1, + scratch, + sizeof(scratch), + pdMS_TO_TICKS(2)); + if (got > 0) { + total += (uint32_t)got; + } + } + ResetUartCache(); + return total; +} + +static void DrainRxForMs(uint32_t ms) +{ + (void)DrainRxForMsCounted(ms); +} + +static bool ReadByteWithDeadline(uint8_t *out, int64_t deadline_us) +{ + while (esp_timer_get_time() < deadline_us) { + if (s_uart_cache_pos < s_uart_cache_len) { + *out = s_uart_cache[s_uart_cache_pos++]; + return true; + } + s_uart_cache_pos = 0u; + s_uart_cache_len = 0u; + const int got = uart_read_bytes(UART_NUM_1, + s_uart_cache, + sizeof(s_uart_cache), + pdMS_TO_TICKS(10)); + if (got > 0) { + s_uart_cache_len = (size_t)got; + } + } + return false; +} + +static bool ReadDecodedFrameTimed(const char *context, + FusionV2Decoded_t *decoded, + uint32_t timeout_ms, + bool *saw_bad_payload) +{ + uint8_t raw[FUSION_V2_MAX_FRAME] = {0}; + int64_t deadline = esp_timer_get_time() + ((int64_t)timeout_ms * 1000); + if (saw_bad_payload != NULL) { + *saw_bad_payload = false; + } + + while (esp_timer_get_time() < deadline) { + uint8_t b = 0; + if (!ReadByteWithDeadline(&b, deadline)) { + printf("Smoke48c: RX timeout marker during %s\n", context); + return false; + } + if (b != FUSION_V2_HEADER_MARKER) { + continue; + } + raw[0] = b; + if (!ReadByteWithDeadline(&raw[1], deadline) || + !ReadByteWithDeadline(&raw[2], deadline)) { + printf("Smoke48c: RX timeout header during %s\n", context); + return false; + } + const uint8_t payload_len = raw[2]; + if (payload_len > FUSION_V2_MAX_PAYLOAD) { + if (saw_bad_payload != NULL) { + *saw_bad_payload = true; + } + /* + * Forensic preview: peek at cached bytes WITHOUT consuming + * them. An earlier dump that called ReadByteWithDeadline 16 + * times ate the first valid STATE_DATA frame after a + * desync (run 3 evidence: + * `.codex_tmp/phase4_8c_ram_roundtrip/run3.log` Phase C/WRAM) + * and produced a "unexpected first frame addr=0x21" follow-on + * error. Looking only at already-buffered bytes preserves + * stream alignment for the next iteration of the main + * resync loop. + */ + const size_t avail = (s_uart_cache_pos < s_uart_cache_len) + ? (s_uart_cache_len - s_uart_cache_pos) : 0u; + const size_t shown = + avail > kSmoke48cBadPayloadDumpBytes + ? kSmoke48cBadPayloadDumpBytes : avail; + printf("Smoke48c: RX bad payload_len %u during %s " + "header=[%02X %02X %02X] cache_peek%u=", + (unsigned)payload_len, context, + raw[0], raw[1], raw[2], (unsigned)shown); + for (size_t i = 0; i < shown; ++i) { + printf(" %02X", s_uart_cache[s_uart_cache_pos + i]); + } + printf("\n"); + continue; + } + const size_t frame_len = 3u + payload_len + 1u; + for (size_t i = 3u; i < frame_len; ++i) { + if (!ReadByteWithDeadline(&raw[i], deadline)) { + printf("Smoke48c: RX timeout payload/crc during %s\n", context); + return false; + } + } + if (!FusionSavestate_DecodeV2Frame(raw, frame_len, decoded)) { + printf("Smoke48c: RX decode/CRC failed during %s raw=", context); + for (size_t i = 0; i < frame_len; ++i) { + printf(" %02X", raw[i]); + } + printf("\n"); + continue; + } + return true; + } + printf("Smoke48c: RX timeout valid frame during %s\n", context); + return false; +} + +static bool ReadDecodedFrame(const char *context, FusionV2Decoded_t *decoded) +{ + return ReadDecodedFrameTimed(context, + decoded, + kSmoke48cFrameTimeoutMs, + NULL); +} + +static bool SendFrame(const char *label, const FusionV2Frame_t *frame, bool quiet) +{ + if (frame == NULL || frame->length == 0u) { + printf("Smoke48c: %s invalid frame\n", label); + return false; + } + if (!quiet) { + printf("Smoke48c: TX %s:", label); + for (uint8_t i = 0; i < frame->length; ++i) { + printf(" %02X", frame->bytes[i]); + } + printf("\n"); + } + const int written = uart_write_bytes(UART_NUM_1, + (const char *)frame->bytes, + frame->length); + const esp_err_t wait_err = uart_wait_tx_done(UART_NUM_1, pdMS_TO_TICKS(250)); + if (written != (int)frame->length) { + printf("Smoke48c: TX %s failed wrote=%d expected=%u\n", + label, written, (unsigned)frame->length); + return false; + } + if (wait_err != ESP_OK) { + printf("Smoke48c: TX %s wait failed err=%s\n", + label, esp_err_to_name(wait_err)); + return false; + } + return true; +} + +static void RecordFirstFailure(const char *step, + const char *reason, + int32_t region, + int32_t expected_seq, + int32_t got_seq, + uint32_t bytes_so_far, + uint32_t packets_so_far) +{ + if (s_result.fail_step[0] != '\0') { + return; + } + snprintf(s_result.fail_step, sizeof(s_result.fail_step), "%s", step); + snprintf(s_result.fail_reason, sizeof(s_result.fail_reason), "%s", reason); + s_result.fail_region = region; + s_result.fail_expected_seq = expected_seq; + s_result.fail_got_seq = got_seq; + s_result.fail_bytes_so_far = bytes_so_far; + s_result.fail_packets_so_far = packets_so_far; +} + +static bool ExpectCtl(uint8_t opcode, uint8_t ack_for, const char *context, + const char *step_label, int32_t region) +{ + FusionV2Decoded_t d; + if (!ReadDecodedFrame(context, &d)) { + RecordFirstFailure(step_label, "rx_timeout_or_unrecoverable_resync", + region, -1, -1, 0u, 0u); + return false; + } + if (d.addr != (uint8_t)kFusionAddr_StateCtl) { + printf("Smoke48c: %s expected CTL got addr=0x%02X op=0x%02X\n", + context, d.addr, d.ctl_opcode); + RecordFirstFailure(step_label, "unexpected_addr", + region, -1, -1, 0u, 0u); + return false; + } + if (d.ctl_opcode == (uint8_t)kFusionOp_Error) { + printf("Smoke48c: %s FPGA ERROR code=0x%02X detail=0x%02X\n", + context, d.error_code, d.error_detail); + char reason[64]; + snprintf(reason, sizeof(reason), + "fpga_error_code=0x%02X_detail=0x%02X", + d.error_code, d.error_detail); + RecordFirstFailure(step_label, reason, region, -1, -1, 0u, 0u); + return false; + } + if (d.ctl_opcode != opcode || d.ack_for_opcode != ack_for) { + printf("Smoke48c: %s unexpected CTL op=0x%02X ack_for=0x%02X " + "(want op=0x%02X ack_for=0x%02X)\n", + context, d.ctl_opcode, d.ack_for_opcode, opcode, ack_for); + char reason[64]; + snprintf(reason, sizeof(reason), + "unexpected_ctl_op=0x%02X_ack_for=0x%02X", + d.ctl_opcode, d.ack_for_opcode); + RecordFirstFailure(step_label, reason, region, -1, -1, 0u, 0u); + return false; + } + return true; +} + +static bool ExpectWriteBeginOrProceed(const char *context, + const char *step_label, + int32_t region) +{ + FusionV2Decoded_t d; + bool saw_bad_payload = false; + if (!ReadDecodedFrameTimed(context, &d, 200u, &saw_bad_payload)) { + printf("Smoke48c: %s no decodable ACK_ACCEPTED within 200ms " + "(saw_bad_payload=%s); proceeding to DATA and requiring " + "final ACK_DONE\n", + context, saw_bad_payload ? "yes" : "no"); + return true; + } + if (d.addr != (uint8_t)kFusionAddr_StateCtl) { + printf("Smoke48c: %s expected CTL got addr=0x%02X op=0x%02X\n", + context, d.addr, d.ctl_opcode); + RecordFirstFailure(step_label, "unexpected_write_begin_ack_addr", + region, -1, -1, 0u, 0u); + return false; + } + if (d.ctl_opcode == (uint8_t)kFusionOp_Error) { + printf("Smoke48c: %s FPGA ERROR code=0x%02X detail=0x%02X\n", + context, d.error_code, d.error_detail); + char reason[64]; + snprintf(reason, sizeof(reason), + "fpga_error_code=0x%02X_detail=0x%02X", + d.error_code, d.error_detail); + RecordFirstFailure(step_label, reason, region, -1, -1, 0u, 0u); + return false; + } + if (d.ctl_opcode != (uint8_t)kFusionOp_AckAccepted || + d.ack_for_opcode != (uint8_t)kFusionOp_WriteStreamBegin) { + printf("Smoke48c: %s unexpected CTL op=0x%02X ack_for=0x%02X " + "(want ACK_ACCEPTED/WRITE_STREAM_BEGIN)\n", + context, d.ctl_opcode, d.ack_for_opcode); + RecordFirstFailure(step_label, "unexpected_write_begin_ack_ctl", + region, -1, -1, 0u, 0u); + return false; + } + return true; +} + +static bool ExpectWriteDoneOrProceed(const char *context, + const char *step_label, + int32_t region) +{ + FusionV2Decoded_t d; + bool saw_bad_payload = false; + if (!ReadDecodedFrameTimed(context, &d, 200u, &saw_bad_payload)) { + printf("Smoke48c: %s no decodable ACK_DONE within 200ms " + "(saw_bad_payload=%s); proceeding to readback verification\n", + context, saw_bad_payload ? "yes" : "no"); + return true; + } + if (d.addr != (uint8_t)kFusionAddr_StateCtl) { + printf("Smoke48c: %s expected CTL got addr=0x%02X op=0x%02X\n", + context, d.addr, d.ctl_opcode); + RecordFirstFailure(step_label, "unexpected_write_done_addr", + region, -1, -1, 0u, 0u); + return false; + } + if (d.ctl_opcode == (uint8_t)kFusionOp_Error) { + printf("Smoke48c: %s FPGA ERROR code=0x%02X detail=0x%02X\n", + context, d.error_code, d.error_detail); + char reason[64]; + snprintf(reason, sizeof(reason), + "fpga_error_code=0x%02X_detail=0x%02X", + d.error_code, d.error_detail); + RecordFirstFailure(step_label, reason, region, -1, -1, 0u, 0u); + return false; + } + if (d.ctl_opcode != (uint8_t)kFusionOp_AckDone || + d.ack_for_opcode != (uint8_t)kFusionOp_WriteStreamBegin) { + printf("Smoke48c: %s unexpected CTL op=0x%02X ack_for=0x%02X " + "(want ACK_DONE/WRITE_STREAM_BEGIN)\n", + context, d.ctl_opcode, d.ack_for_opcode); + RecordFirstFailure(step_label, "unexpected_write_done_ctl", + region, -1, -1, 0u, 0u); + return false; + } + return true; +} + +static bool BuildEnd(FusionV2Frame_t *out) +{ + return FusionSavestate_BuildEndSession(out); +} + +static bool IsEndSessionAck(const FusionV2Decoded_t *d) +{ + return d != NULL && + d->addr == (uint8_t)kFusionAddr_StateCtl && + d->ctl_opcode == (uint8_t)kFusionOp_AckAccepted && + d->ack_for_opcode == (uint8_t)kFusionOp_EndSession; +} + +static bool SendEndSessionRetry(const char *label, const char *step_label) +{ + FusionV2Frame_t frame; + if (!BuildEnd(&frame)) { + printf("Smoke48c: build %s failed\n", label); + return false; + } + for (uint8_t attempt = 0u; attempt < kSmoke48cEndSessionAttempts; ++attempt) { + if (attempt != 0u) { + uint32_t drained = + DrainRxForMsCounted(kSmoke48cPreflightDrainMs); + if (drained > 0u) { + printf("Smoke48c: %s retry attempt=%u drained_bytes=%" PRIu32 "\n", + label, (unsigned)attempt, drained); + } + ResetUartCache(); + } + if (!SendFrame(label, &frame, /*quiet=*/(attempt != 0u))) { + return false; + } + FusionV2Decoded_t d; + if (ReadDecodedFrame(label, &d) && IsEndSessionAck(&d)) { + return true; + } + } + if (step_label != NULL) { + RecordFirstFailure(step_label, "end_session_no_ack_after_retries", + -1, -1, -1, 0u, 0u); + } + return false; +} + +/* + * Open the single mixed paused session. Drains any prior bytes, sends + * an idempotent END_SESSION clear in case a stale session is open, then + * BEGIN_TEST_RW. After the ACK, drains residue and records the open + * timestamp so PrintResult can report total paused session duration. + * + * Distinguishes "old bridge" (BEGIN_TEST_RW rejected with ERR_NO_SESSION + * by the SESS_NONE default arm of a P48C bridge) from a real protocol + * failure: in the former case the harness reports the firmware mismatch + * and aborts cleanly so the maintainer can flash the P48D bitstream. + */ +static bool OpenMixedSession(void) +{ + DrainRxForMs(kSmoke48cPreflightDrainMs); + FPGA_Rx_ResetParser(); + ResetUartCache(); + + if (!SendEndSessionRetry("END_SESSION clear (mixed)", + "mixed_clear")) { + printf("Smoke48c: mixed-session clear failed\n"); + return false; + } + DrainRxForMs(kSmoke48cPreflightDrainMs); + FPGA_Rx_ResetParser(); + ResetUartCache(); + + FusionV2Frame_t begin; + if (!FusionSavestate_BuildBeginTestRW(0u, &begin)) { + printf("Smoke48c: build BEGIN_TEST_RW failed\n"); + return false; + } + if (!SendFrame("BEGIN_TEST_RW (mixed)", &begin, /*quiet=*/false)) { + return false; + } + + FusionV2Decoded_t d; + if (!ReadDecodedFrame("BEGIN_TEST_RW ack", &d)) { + RecordFirstFailure("mixed_begin", + "rx_timeout_for_begin_ack", + -1, -1, -1, 0u, 0u); + return false; + } + if (d.addr == (uint8_t)kFusionAddr_StateCtl && + d.ctl_opcode == (uint8_t)kFusionOp_Error) { + printf("Smoke48c: BEGIN_TEST_RW FPGA ERROR code=0x%02X detail=0x%02X " + "(if code=0x03 the bridge is older P48C; flash a P48D bitstream " + "with OP_BEGIN_TEST_RW support)\n", + d.error_code, d.error_detail); + char reason[64]; + snprintf(reason, sizeof(reason), + "begin_test_rw_error_code=0x%02X_detail=0x%02X", + d.error_code, d.error_detail); + RecordFirstFailure("mixed_begin", reason, -1, -1, -1, 0u, 0u); + return false; + } + if (!(d.addr == (uint8_t)kFusionAddr_StateCtl && + d.ctl_opcode == (uint8_t)kFusionOp_AckAccepted && + d.ack_for_opcode == (uint8_t)kFusionOp_BeginTestRW)) { + printf("Smoke48c: BEGIN_TEST_RW unexpected reply addr=0x%02X " + "op=0x%02X ack_for=0x%02X\n", + d.addr, d.ctl_opcode, d.ack_for_opcode); + char reason[64]; + snprintf(reason, sizeof(reason), + "unexpected_ctl_op=0x%02X_ack_for=0x%02X", + d.ctl_opcode, d.ack_for_opcode); + RecordFirstFailure("mixed_begin", reason, -1, -1, -1, 0u, 0u); + return false; + } + + s_session_open_us = esp_timer_get_time(); + + const uint32_t drained = DrainRxForMsCounted(kSmoke48cPostBeginDrainMs); + if (drained != 0u) { + printf("Smoke48c: mixed post-BEGIN drained %" PRIu32 " stale bytes\n", + drained); + } + FPGA_Rx_ResetParser(); + ResetUartCache(); + vTaskDelay(pdMS_TO_TICKS(kSmoke48cInterSessionMs)); + return true; +} + +static bool CloseMixedSession(void) +{ + DrainRxForMs(kSmoke48cFinalEndMs); + ResetUartCache(); + return SendEndSessionRetry("END_SESSION cleanup (mixed)", + "mixed_cleanup"); +} + +static bool ReadRegionStream(uint8_t region, + uint16_t length, + uint8_t *dest, + const char *region_label, + const char *phase_label, + const char *step_label) +{ + uint16_t received = 0u; + uint16_t expected_seq = 0u; + const int64_t t0 = esp_timer_get_time(); + + FusionV2Frame_t begin; + if (!FusionSavestate_BuildReadStreamBegin(region, 0u, length, &begin)) { + printf("Smoke48c: build READ_STREAM_BEGIN %s failed\n", region_label); + return false; + } + char tx_label[48]; + snprintf(tx_label, sizeof(tx_label), "READ_STREAM_BEGIN(%s,%s)", + region_label, phase_label); + if (!SendFrame(tx_label, &begin, /*quiet=*/false)) { + return false; + } + + bool pending_data_valid = false; + FusionV2Decoded_t pending_data; + memset(&pending_data, 0, sizeof(pending_data)); + { + FusionV2Decoded_t first; + if (!ReadDecodedFrame("READ_STREAM_BEGIN ack", &first)) { + RecordFirstFailure(step_label, "rx_timeout_for_begin_ack", + (int32_t)region, 0, -1, 0u, 0u); + return false; + } + if (first.addr == (uint8_t)kFusionAddr_StateCtl && + first.ctl_opcode == (uint8_t)kFusionOp_Error) { + printf("Smoke48c: %s/%s FPGA ERROR begin code=0x%02X detail=0x%02X\n", + phase_label, region_label, first.error_code, first.error_detail); + char reason[64]; + snprintf(reason, sizeof(reason), + "fpga_error_code=0x%02X_detail=0x%02X", + first.error_code, first.error_detail); + RecordFirstFailure(step_label, reason, (int32_t)region, 0, -1, 0u, 0u); + return false; + } + if (first.addr == (uint8_t)kFusionAddr_StateCtl && + first.ctl_opcode == (uint8_t)kFusionOp_AckAccepted && + first.ack_for_opcode == (uint8_t)kFusionOp_ReadStreamBegin) { + /* Expected. */ + } else if (first.addr == (uint8_t)kFusionAddr_StateData && + first.data_seq == 0u) { + pending_data = first; + pending_data_valid = true; + } else { + printf("Smoke48c: %s/%s unexpected first frame addr=0x%02X op=0x%02X\n", + phase_label, region_label, first.addr, first.ctl_opcode); + RecordFirstFailure(step_label, "unexpected_first_frame", + (int32_t)region, 0, -1, 0u, 0u); + return false; + } + } + + uint32_t packets = 0u; + while (received < length) { + FusionV2Decoded_t d; + if (pending_data_valid) { + d = pending_data; + pending_data_valid = false; + } else if (!ReadDecodedFrame(region_label, &d)) { + RecordFirstFailure(step_label, "rx_timeout_mid_stream", + (int32_t)region, (int32_t)expected_seq, -1, + received, packets); + return false; + } + if (d.addr == (uint8_t)kFusionAddr_StateCtl && + d.ctl_opcode == (uint8_t)kFusionOp_Error) { + printf("Smoke48c: %s/%s FPGA ERROR mid-stream code=0x%02X " + "detail=0x%02X received=%u expected_seq=%u\n", + phase_label, region_label, d.error_code, d.error_detail, + (unsigned)received, (unsigned)expected_seq); + char reason[64]; + snprintf(reason, sizeof(reason), + "fpga_error_code=0x%02X_detail=0x%02X", + d.error_code, d.error_detail); + RecordFirstFailure(step_label, reason, (int32_t)region, + (int32_t)expected_seq, -1, received, packets); + return false; + } + if (d.addr != (uint8_t)kFusionAddr_StateData) { + printf("Smoke48c: %s/%s expected DATA got addr=0x%02X op=0x%02X\n", + phase_label, region_label, d.addr, d.ctl_opcode); + RecordFirstFailure(step_label, "expected_data_got_other", + (int32_t)region, (int32_t)expected_seq, -1, + received, packets); + return false; + } + if (d.data_seq != expected_seq) { + printf("Smoke48c: %s/%s seq mismatch got=%u expected=%u received=%u\n", + phase_label, region_label, (unsigned)d.data_seq, + (unsigned)expected_seq, (unsigned)received); + RecordFirstFailure(step_label, "seq_mismatch", + (int32_t)region, (int32_t)expected_seq, + (int32_t)d.data_seq, received, packets); + return false; + } + if (d.data_len == 0u || (uint16_t)d.data_len > (length - received)) { + printf("Smoke48c: %s/%s bad data_len=%u remaining=%u\n", + phase_label, region_label, + (unsigned)d.data_len, (unsigned)(length - received)); + RecordFirstFailure(step_label, "bad_data_len", + (int32_t)region, (int32_t)expected_seq, + (int32_t)d.data_seq, received, packets); + return false; + } + memcpy(&dest[received], d.data, d.data_len); + received = (uint16_t)(received + d.data_len); + expected_seq = (uint16_t)(expected_seq + 1u); + packets++; + } + + char done_ctx[48]; + snprintf(done_ctx, sizeof(done_ctx), "ACK_DONE(READ %s)", region_label); + if (!ExpectCtl((uint8_t)kFusionOp_AckDone, + (uint8_t)kFusionOp_ReadStreamBegin, + done_ctx, step_label, (int32_t)region)) { + return false; + } + ResetUartCache(); + uart_flush_input(UART_NUM_1); + + const uint32_t crc = FusionSavestate_Crc32(dest, received); + const int64_t elapsed_ms = (esp_timer_get_time() - t0) / 1000; + printf("Smoke48c: READ %s/%s PASS packets=%u bytes=%u crc32=%08" PRIX32 + " dt_ms=%" PRId64 "\n", + phase_label, region_label, + (unsigned)expected_seq, (unsigned)received, crc, elapsed_ms); + return true; +} + +static bool WriteRegionStream(uint8_t region, + uint16_t length, + const uint8_t *src, + const char *region_label, + const char *phase_label, + const char *step_label) +{ + const int64_t t0 = esp_timer_get_time(); + + FusionV2Frame_t begin; + if (!FusionSavestate_BuildWriteStreamBegin(region, 0u, length, &begin)) { + printf("Smoke48c: build WRITE_STREAM_BEGIN %s failed\n", region_label); + return false; + } + char tx_label[48]; + snprintf(tx_label, sizeof(tx_label), "WRITE_STREAM_BEGIN(%s,%s)", + region_label, phase_label); + if (!SendFrame(tx_label, &begin, /*quiet=*/false)) { + return false; + } + if (!ExpectWriteBeginOrProceed(tx_label, step_label, (int32_t)region)) { + return false; + } + + uint16_t total_sent = 0u; + uint16_t seq = 0u; + while (total_sent < length) { + const uint16_t remaining = (uint16_t)(length - total_sent); + const uint16_t chunk = remaining >= FUSION_STATE_DATA_MAX_DATA + ? (uint16_t)FUSION_STATE_DATA_MAX_DATA + : remaining; + FusionV2Frame_t data_frame; + if (!FusionSavestate_BuildStateData(seq, + &src[total_sent], + chunk, + &data_frame)) { + printf("Smoke48c: %s/%s build STATE_DATA seq=%u chunk=%u failed\n", + phase_label, region_label, (unsigned)seq, (unsigned)chunk); + RecordFirstFailure(step_label, "build_state_data_failed", + (int32_t)region, (int32_t)seq, -1, + total_sent, seq); + return false; + } + if (!SendFrame("STATE_DATA", &data_frame, /*quiet=*/true)) { + RecordFirstFailure(step_label, "tx_state_data_failed", + (int32_t)region, (int32_t)seq, -1, + total_sent, seq); + return false; + } + seq = (uint16_t)(seq + 1u); + total_sent = (uint16_t)(total_sent + chunk); + if ((seq % kSmoke48cWriteProgressEvery) == 0u) { + printf("Smoke48c: WRITE %s/%s progress packets=%u bytes=%u\n", + phase_label, region_label, + (unsigned)seq, (unsigned)total_sent); + } + } + + char done_ctx[48]; + snprintf(done_ctx, sizeof(done_ctx), + "ACK_DONE(WRITE %s,%s)", region_label, phase_label); + if (!ExpectWriteDoneOrProceed(done_ctx, step_label, (int32_t)region)) { + return false; + } + + const uint32_t crc = FusionSavestate_Crc32(src, length); + const int64_t elapsed_ms = (esp_timer_get_time() - t0) / 1000; + printf("Smoke48c: WRITE %s/%s PASS packets=%u bytes=%u crc32=%08" PRIX32 + " dt_ms=%" PRId64 "\n", + phase_label, region_label, + (unsigned)seq, (unsigned)total_sent, crc, elapsed_ms); + return true; +} + +static void ApplyXorPattern(const uint8_t *in, uint8_t *out, size_t len) +{ + for (size_t i = 0; i < len; ++i) { + out[i] = (uint8_t)(in[i] ^ kXorPattern[i & 0x1Fu]); + } +} + +static void CompareAndPreview(const char *label, + const uint8_t *expected, + const uint8_t *actual, + size_t len, + uint32_t *mismatch_out, + int32_t *first_offset_out) +{ + uint32_t cnt = 0u; + int32_t first = -1; + /* Capture up to N preview entries for forensic analysis. */ + int32_t preview_offsets[kSmoke48cMismatchPreviewCount] = {0}; + uint8_t preview_expected[kSmoke48cMismatchPreviewCount] = {0}; + uint8_t preview_actual[kSmoke48cMismatchPreviewCount] = {0}; + uint8_t preview_count = 0u; + + for (size_t i = 0; i < len; ++i) { + if (expected[i] != actual[i]) { + if (first < 0) { + first = (int32_t)i; + } + if (preview_count < kSmoke48cMismatchPreviewCount) { + preview_offsets[preview_count] = (int32_t)i; + preview_expected[preview_count] = expected[i]; + preview_actual[preview_count] = actual[i]; + preview_count++; + } + cnt++; + } + } + *mismatch_out = cnt; + *first_offset_out = first; + if (cnt == 0u) { + printf("Smoke48c: COMPARE %s match=exact len=%u\n", + label, (unsigned)len); + return; + } + printf("Smoke48c: COMPARE %s match=fail len=%u mismatches=%u " + "first_offset=%" PRId32 " expected=%02X actual=%02X\n", + label, (unsigned)len, (unsigned)cnt, first, + expected[first], actual[first]); + printf("Smoke48c: COMPARE %s preview", label); + for (uint8_t i = 0; i < preview_count; ++i) { + printf(" [%" PRId32 ":%02X->%02X]", + preview_offsets[i], preview_expected[i], preview_actual[i]); + } + printf("\n"); +} + +static bool ValidateSlotQuiet(uint32_t *generation_out, const char *moment) +{ + FusionStorage_t storage; + FusionStorageResult_t sr = + FusionStorage_InitFromPartition(&storage, kPartitionLabel); + if (sr != kFusionStorage_Ok) { + printf("Smoke48c: SLOT_%s init partition failed result=%d\n", + moment, (int)sr); + return false; + } + uint32_t slot = 0u; + uint32_t generation = 0u; + sr = FusionStorage_FindNewestValidSlot(&storage, &slot, &generation); + if (sr != kFusionStorage_Ok) { + printf("Smoke48c: SLOT_%s no valid slot result=%d\n", moment, (int)sr); + return false; + } + FusionStateHeader_t hdr; + memset(&hdr, 0, sizeof(hdr)); + sr = FusionStorage_ReadSlotHeader(&storage, slot, &hdr); + if (sr != kFusionStorage_Ok) { + printf("Smoke48c: SLOT_%s read header failed result=%d\n", + moment, (int)sr); + return false; + } + const bool tag_ok = + (memcmp(hdr.savestate_tag, "P48C", 4u) == 0) || + (memcmp(hdr.savestate_tag, "P48D", 4u) == 0) || + (memcmp(hdr.savestate_tag, "P48E", 4u) == 0); + if (hdr.magic != FUSION_SAVESTATE_MAGIC || + hdr.format_version != FUSION_SAVESTATE_FORMAT_VERSION || + hdr.header_size != sizeof(FusionStateHeader_t) || + hdr.commit_state != (uint8_t)kFusionCommit_Valid || + !tag_ok) { + printf("Smoke48c: SLOT_%s header rejected magic=%08" PRIX32 + " state=0x%02X tag='%c%c%c%c'\n", + moment, hdr.magic, hdr.commit_state, + hdr.savestate_tag[0], hdr.savestate_tag[1], + hdr.savestate_tag[2], hdr.savestate_tag[3]); + return false; + } + if (generation_out != NULL) { + *generation_out = generation; + } + printf("Smoke48c: SLOT_%s valid generation=%" PRIu32 " total=%" PRIu32 + " payload_crc=%08" PRIX32 "\n", + moment, generation, hdr.total_size, hdr.payload_crc32); + return true; +} + +static bool AllocBuffers(void) +{ + if (s_wram_storage != NULL) { + return true; + } + s_wram_storage = (uint8_t *)heap_caps_malloc(2u * SMOKE48C_WRAM_LEN, + MALLOC_CAP_8BIT); + if (s_wram_storage == NULL) { + printf("Smoke48c: heap alloc %u failed\n", + (unsigned)(2u * SMOKE48C_WRAM_LEN)); + return false; + } + s_orig_wram = s_wram_storage; + s_scratch_wram = s_wram_storage + SMOKE48C_WRAM_LEN; + return true; +} + +static void FreeBuffers(void) +{ + if (s_wram_storage != NULL) { + heap_caps_free(s_wram_storage); + s_wram_storage = NULL; + s_orig_wram = NULL; + s_scratch_wram = NULL; + } +} + +static void PrintResult(bool overall_pass) +{ + const Smoke48cResult_t *r = &s_result; + printf("Smoke48c: STATUS slot_pre=%s begin=%s r1=%s w1=%s r2=%s w2=%s " + "r3=%s end=%s slot_post=%s core_released=%s " + "restore_attempted=%s restore_verified=%s\n", + r->slot_valid_pre ? "yes" : "no", + r->begin_ok ? "yes" : "no", + r->phase_r1_ok ? "yes" : "no", + r->phase_w1_ok ? "yes" : "no", + r->phase_r2_ok ? "yes" : "no", + r->phase_w2_ok ? "yes" : "no", + r->phase_r3_ok ? "yes" : "no", + r->end_ok ? "yes" : "no", + r->slot_valid_post ? "yes" : "no", + r->core_released ? "yes" : "no", + r->restore_attempted ? "yes" : "no", + r->restore_verified ? "yes" : "no"); + printf("Smoke48c: CRC orig_hram=%08" PRIX32 " orig_wram=%08" PRIX32 + " temp_hram=%08" PRIX32 " temp_wram=%08" PRIX32 "\n", + r->crc_orig_hram, r->crc_orig_wram, + r->crc_temp_hram, r->crc_temp_wram); + printf("Smoke48c: CRC readback_temp_hram=%08" PRIX32 + " readback_temp_wram=%08" PRIX32 + " readback_orig_hram=%08" PRIX32 + " readback_orig_wram=%08" PRIX32 "\n", + r->crc_readback_temp_hram, r->crc_readback_temp_wram, + r->crc_readback_orig_hram, r->crc_readback_orig_wram); + printf("Smoke48c: MISMATCH temp_hram=%" PRIu32 " (first=%" PRId32 ") " + "temp_wram=%" PRIu32 " (first=%" PRId32 ") " + "orig_hram=%" PRIu32 " (first=%" PRId32 ") " + "orig_wram=%" PRIu32 " (first=%" PRId32 ")\n", + r->mismatch_temp_hram, r->first_mismatch_temp_hram, + r->mismatch_temp_wram, r->first_mismatch_temp_wram, + r->mismatch_orig_hram, r->first_mismatch_orig_hram, + r->mismatch_orig_wram, r->first_mismatch_orig_wram); + printf("Smoke48c: PAUSED_SESSION_us=%" PRId64 "\n", + r->paused_session_us); + printf("Smoke48c: SLOT_GEN pre=%" PRIu32 " post=%" PRIu32 "\n", + r->generation_pre, r->generation_post); + if (r->fail_step[0] != '\0') { + printf("Smoke48c: FIRST_FAIL step=%s reason=%s region=%" PRId32 + " expected_seq=%" PRId32 " got_seq=%" PRId32 + " bytes_so_far=%" PRIu32 " packets_so_far=%" PRIu32 + " drained_bytes=%" PRIu32 "\n", + r->fail_step, r->fail_reason, r->fail_region, + r->fail_expected_seq, r->fail_got_seq, + r->fail_bytes_so_far, r->fail_packets_so_far, + r->fail_drained_bytes); + } + printf("Smoke48c: RESULT %s (4.8c v2 RAM-only round-trip; one paused " + "session; Top/CPU/Timer restore not exercised; no hotkey)\n", + overall_pass ? "PASS" : "FAIL"); +} + +static void CompareTempWram(const uint8_t *orig_wram, + const uint8_t *readback) +{ + /* WRAM expected = orig XOR kXorPattern. Re-derived on the fly to + * avoid a second 32 KiB allocation; this is a hot loop in the + * single-session run so the bookkeeping stays tight. */ + uint32_t cnt = 0u; + int32_t first = -1; + int32_t preview_offsets[kSmoke48cMismatchPreviewCount] = {0}; + uint8_t preview_expected[kSmoke48cMismatchPreviewCount] = {0}; + uint8_t preview_actual[kSmoke48cMismatchPreviewCount] = {0}; + uint8_t preview_count = 0u; + for (size_t i = 0; i < SMOKE48C_WRAM_LEN; ++i) { + const uint8_t expected = + (uint8_t)(orig_wram[i] ^ kXorPattern[i & 0x1Fu]); + if (expected != readback[i]) { + if (first < 0) first = (int32_t)i; + if (preview_count < kSmoke48cMismatchPreviewCount) { + preview_offsets[preview_count] = (int32_t)i; + preview_expected[preview_count] = expected; + preview_actual[preview_count] = readback[i]; + preview_count++; + } + cnt++; + } + } + s_result.mismatch_temp_wram = cnt; + s_result.first_mismatch_temp_wram = first; + if (cnt == 0u) { + printf("Smoke48c: COMPARE temp_wram match=exact len=%u\n", + (unsigned)SMOKE48C_WRAM_LEN); + return; + } + const uint8_t expected = + (uint8_t)(orig_wram[first] ^ kXorPattern[first & 0x1F]); + printf("Smoke48c: COMPARE temp_wram match=fail len=%u mismatches=%u " + "first_offset=%" PRId32 " expected=%02X actual=%02X\n", + (unsigned)SMOKE48C_WRAM_LEN, (unsigned)cnt, first, + expected, readback[first]); + printf("Smoke48c: COMPARE temp_wram preview"); + for (uint8_t i = 0; i < preview_count; ++i) { + printf(" [%" PRId32 ":%02X->%02X]", + preview_offsets[i], preview_expected[i], preview_actual[i]); + } + printf("\n"); +} + +bool FusionSavestate_RamRoundTripSlot0(void) +{ + memset(&s_result, 0, sizeof(s_result)); + s_result.first_mismatch_temp_hram = -1; + s_result.first_mismatch_temp_wram = -1; + s_result.first_mismatch_orig_hram = -1; + s_result.first_mismatch_orig_wram = -1; + s_result.fail_region = -1; + s_result.fail_expected_seq = -1; + s_result.fail_got_seq = -1; + s_session_open_us = 0; + + if (!AllocBuffers()) { + PrintResult(false); + return false; + } + + s_result.slot_valid_pre = + ValidateSlotQuiet(&s_result.generation_pre, "PRE"); + if (!s_result.slot_valid_pre) { + printf("Smoke48c: pre-slot validation failed; run 'smoke48a save' to " + "capture a P48C-tagged slot before retrying smoke48c\n"); + FreeBuffers(); + PrintResult(false); + return false; + } + + PwrMgr_IdleTimerSuspend(); + TaskHandle_t *tx_task = FPGA_GetTxTaskHandle(); + TaskHandle_t *rx_task = FPGA_GetRxTaskHandle(); + const bool tx_paused = (tx_task != NULL && *tx_task != NULL); + const bool rx_paused = (rx_task != NULL && *rx_task != NULL); + if (tx_paused) FPGA_Tx_Pause(); + if (rx_paused) FPGA_Rx_Pause(); + vTaskDelay(pdMS_TO_TICKS(30)); + uart_flush(UART_NUM_1); + ResetUartCache(); + if (!FPGA_UartOwnerAcquire(pdMS_TO_TICKS(2000))) { + printf("Smoke48c: UART owner unavailable\n"); + if (rx_paused) FPGA_Rx_Resume(); + if (tx_paused) FPGA_Tx_Resume(); + PwrMgr_IdleTimerResume(); + FreeBuffers(); + PrintResult(false); + return false; + } + + /* + * Single mixed paused session. Inside this BEGIN_TEST_RW / + * END_SESSION pair, session_pause stays asserted continuously and + * the GB CPU never executes, so RAM cannot drift between any + * harness write and its readback. All five phases (R1, W1, R2, + * W2, R3) run inline below. No END_SESSION is sent until the + * very end. + */ + if (OpenMixedSession()) { + s_result.begin_ok = true; + + /* R1: read original HRAM and WRAM. */ + const bool r1_h = ReadRegionStream(SMOKE48C_HRAM_REGION, + SMOKE48C_HRAM_LEN, + s_orig_hram, "HRAM", "R1", + "read_orig_hram"); + const bool r1_w = r1_h && ReadRegionStream(SMOKE48C_WRAM_REGION, + SMOKE48C_WRAM_LEN, + s_orig_wram, "WRAM", "R1", + "read_orig_wram"); + s_result.phase_r1_ok = r1_h && r1_w; + + if (s_result.phase_r1_ok) { + s_result.crc_orig_hram = + FusionSavestate_Crc32(s_orig_hram, SMOKE48C_HRAM_LEN); + s_result.crc_orig_wram = + FusionSavestate_Crc32(s_orig_wram, SMOKE48C_WRAM_LEN); + ApplyXorPattern(s_orig_hram, s_scratch_hram, SMOKE48C_HRAM_LEN); + ApplyXorPattern(s_orig_wram, s_scratch_wram, SMOKE48C_WRAM_LEN); + s_result.crc_temp_hram = + FusionSavestate_Crc32(s_scratch_hram, SMOKE48C_HRAM_LEN); + s_result.crc_temp_wram = + FusionSavestate_Crc32(s_scratch_wram, SMOKE48C_WRAM_LEN); + printf("Smoke48c: PHASE R1 complete: orig_hram=%08" PRIX32 + " orig_wram=%08" PRIX32 " temp_hram=%08" PRIX32 + " temp_wram=%08" PRIX32 "\n", + s_result.crc_orig_hram, s_result.crc_orig_wram, + s_result.crc_temp_hram, s_result.crc_temp_wram); + + /* W1: write XOR-pattern temp into HRAM and WRAM. */ + const bool w1_h = WriteRegionStream(SMOKE48C_HRAM_REGION, + SMOKE48C_HRAM_LEN, + s_scratch_hram, "HRAM", "W1", + "write_temp_hram"); + const bool w1_w = w1_h && WriteRegionStream(SMOKE48C_WRAM_REGION, + SMOKE48C_WRAM_LEN, + s_scratch_wram, + "WRAM", "W1", + "write_temp_wram"); + s_result.phase_w1_ok = w1_h && w1_w; + + if (s_result.phase_w1_ok) { + /* R2: re-read temp; bit-exact compare. */ + uint8_t saved_temp_hram[SMOKE48C_HRAM_LEN]; + memcpy(saved_temp_hram, s_scratch_hram, SMOKE48C_HRAM_LEN); + + const bool r2_h = ReadRegionStream(SMOKE48C_HRAM_REGION, + SMOKE48C_HRAM_LEN, + s_scratch_hram, "HRAM", "R2", + "read_back_temp_hram"); + const bool r2_w = r2_h && ReadRegionStream(SMOKE48C_WRAM_REGION, + SMOKE48C_WRAM_LEN, + s_scratch_wram, + "WRAM", "R2", + "read_back_temp_wram"); + s_result.phase_r2_ok = r2_h && r2_w; + if (r2_h) { + s_result.crc_readback_temp_hram = + FusionSavestate_Crc32(s_scratch_hram, + SMOKE48C_HRAM_LEN); + CompareAndPreview("temp_hram", + saved_temp_hram, s_scratch_hram, + SMOKE48C_HRAM_LEN, + &s_result.mismatch_temp_hram, + &s_result.first_mismatch_temp_hram); + } + if (r2_w) { + s_result.crc_readback_temp_wram = + FusionSavestate_Crc32(s_scratch_wram, + SMOKE48C_WRAM_LEN); + CompareTempWram(s_orig_wram, s_scratch_wram); + } + + /* W2: write originals back (best-effort restore). */ + if (s_result.phase_r2_ok) { + s_result.restore_attempted = true; + const bool w2_h = WriteRegionStream(SMOKE48C_HRAM_REGION, + SMOKE48C_HRAM_LEN, + s_orig_hram, "HRAM", + "W2", + "write_orig_hram"); + const bool w2_w = w2_h && + WriteRegionStream(SMOKE48C_WRAM_REGION, + SMOKE48C_WRAM_LEN, + s_orig_wram, "WRAM", + "W2", + "write_orig_wram"); + s_result.phase_w2_ok = w2_h && w2_w; + + if (s_result.phase_w2_ok) { + /* R3: re-read originals; bit-exact compare. */ + const bool r3_h = + ReadRegionStream(SMOKE48C_HRAM_REGION, + SMOKE48C_HRAM_LEN, + s_scratch_hram, "HRAM", "R3", + "read_back_orig_hram"); + const bool r3_w = r3_h && + ReadRegionStream(SMOKE48C_WRAM_REGION, + SMOKE48C_WRAM_LEN, + s_scratch_wram, "WRAM", "R3", + "read_back_orig_wram"); + s_result.phase_r3_ok = r3_h && r3_w; + if (r3_h) { + s_result.crc_readback_orig_hram = + FusionSavestate_Crc32(s_scratch_hram, + SMOKE48C_HRAM_LEN); + CompareAndPreview("orig_hram", + s_orig_hram, s_scratch_hram, + SMOKE48C_HRAM_LEN, + &s_result.mismatch_orig_hram, + &s_result.first_mismatch_orig_hram); + } + if (r3_w) { + s_result.crc_readback_orig_wram = + FusionSavestate_Crc32(s_scratch_wram, + SMOKE48C_WRAM_LEN); + CompareAndPreview("orig_wram", + s_orig_wram, s_scratch_wram, + SMOKE48C_WRAM_LEN, + &s_result.mismatch_orig_wram, + &s_result.first_mismatch_orig_wram); + s_result.restore_verified = + (s_result.mismatch_orig_hram == 0u) && + (s_result.mismatch_orig_wram == 0u); + } + } + } + } + } + + /* End the single mixed session. This is the only END_SESSION + * inside the round-trip; session_pause drops here. */ + s_result.end_ok = CloseMixedSession(); + if (s_session_open_us != 0) { + s_result.paused_session_us = + esp_timer_get_time() - s_session_open_us; + printf("Smoke48c: PAUSED_SESSION dt_us=%" PRId64 "\n", + s_result.paused_session_us); + } + } + + /* Final cleanup: idempotent END_SESSION ack confirms the bridge is + * idle and the GB core is released. */ + { + FusionV2Frame_t end_frame; + if (BuildEnd(&end_frame)) { + DrainRxForMs(kSmoke48cFinalEndMs); + ResetUartCache(); + uart_flush_input(UART_NUM_1); + (void)SendFrame("END_SESSION final", &end_frame, /*quiet=*/false); + FusionV2Decoded_t d; + if (ReadDecodedFrame("END_SESSION final ack", &d) && IsEndSessionAck(&d)) { + s_result.core_released = true; + } else { + DrainRxForMs(kSmoke48cPreflightDrainMs); + ResetUartCache(); + if (SendFrame("END_SESSION final retry", &end_frame, /*quiet=*/false)) { + if (ReadDecodedFrame("END_SESSION final retry ack", &d) && + IsEndSessionAck(&d)) { + s_result.core_released = true; + } + } + } + } + } + + FPGA_UartOwnerRelease(); + if (rx_paused) FPGA_Rx_Resume(); + if (tx_paused) FPGA_Tx_Resume(); + PwrMgr_IdleTimerResume(); + + s_result.slot_valid_post = + ValidateSlotQuiet(&s_result.generation_post, "POST"); + + const bool overall_pass = + s_result.slot_valid_pre && + s_result.begin_ok && + s_result.phase_r1_ok && s_result.phase_w1_ok && + s_result.phase_r2_ok && s_result.phase_w2_ok && + s_result.phase_r3_ok && s_result.end_ok && + s_result.slot_valid_post && s_result.core_released && + s_result.restore_verified && + s_result.mismatch_temp_hram == 0u && + s_result.mismatch_temp_wram == 0u && + s_result.mismatch_orig_hram == 0u && + s_result.mismatch_orig_wram == 0u && + s_result.generation_pre == s_result.generation_post; + + PrintResult(overall_pass); + FreeBuffers(); + return overall_pass; +} + +static int Smoke48cCommand(int argc, char **argv) +{ + if (argc < 2 || strcmp(argv[1], "ramroundtrip") != 0) { + printf("Usage: smoke48c ramroundtrip\n"); + return 1; + } + return FusionSavestate_RamRoundTripSlot0() ? 0 : 1; +} + +void FusionSavestateSmoke48c_RegisterCommands(void) +{ + const esp_console_cmd_t command = { + .command = "smoke48c", + .help = "Phase 4.8c RAM-only round-trip smoke: smoke48c ramroundtrip", + .func = &Smoke48cCommand, + .argtable = NULL, + }; + esp_err_t err = esp_console_cmd_register(&command); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Registering '%s' failed: %s", + command.command, esp_err_to_name(err)); + } +} diff --git a/main/fusion_savestate_smoke48c.h b/main/fusion_savestate_smoke48c.h new file mode 100644 index 0000000..2f142b8 --- /dev/null +++ b/main/fusion_savestate_smoke48c.h @@ -0,0 +1,37 @@ +#pragma once + +#include + +/* + * Phase 4.8c v2 MCU RAM-only round-trip smoke harness. + * + * Manual console command for hardware bring-up only: + * smoke48c ramroundtrip + * + * Single mixed paused session. Inside one BEGIN_TEST_RW / + * END_SESSION pair, session_pause stays asserted continuously, so + * the GB CPU never runs between any harness write and its readback: + * + * BEGIN_TEST_RW + * R1: read original HRAM/WRAM into MCU RAM + * W1: write XOR-pattern temp HRAM/WRAM + * R2: re-read temp HRAM/WRAM; bit-exact compare + * W2: write original HRAM/WRAM back (restore) + * R3: re-read original HRAM/WRAM; bit-exact compare + * END_SESSION + * + * Pre/post validates the existing persistent slot but never erases or + * rewrites it. The persistent slot is expected to remain valid across + * the entire round trip. + * + * Requires a P48D (or newer) FPGA bitstream that accepts + * OP_BEGIN_TEST_RW. An older P48C bridge rejects the BEGIN with + * ERR_NO_SESSION; the harness reports that mismatch and exits cleanly. + * + * 4.8c does not bind hotkeys, does not restore Top/CPU/Timer, and does + * not claim full Phase 4.8 pass. See + * project-wiki/50_decisions/fusion-savestate-phase4-8-acceptance-test.md. + */ + +bool FusionSavestate_RamRoundTripSlot0(void); +void FusionSavestateSmoke48c_RegisterCommands(void); diff --git a/main/fusion_savestate_transport_probe.c b/main/fusion_savestate_transport_probe.c new file mode 100644 index 0000000..e9cebe3 --- /dev/null +++ b/main/fusion_savestate_transport_probe.c @@ -0,0 +1,1578 @@ +#include "fusion_savestate_transport_probe.h" + +#include "driver/uart.h" +#include "esp_console.h" +#include "esp_err.h" +#include "esp_heap_caps.h" +#include "esp_log.h" +#include "esp_timer.h" +#include "fpga_common.h" +#include "fpga_rx.h" +#include "fpga_tx.h" +#include "fusion_savestate.h" +#include "pwrmgr.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" + +#include +#include +#include +#include +#include +#include + +enum { + kProbeFrameTimeoutMs = 3000, + kProbeInterRegionMs = 10, + kProbeFinalEndMs = 50, + kProbeBootDelayMs = 2000, + kProbeRegionCount = 6, + kProbeRxCacheBytes = 512, + kProbeOldRxBufBytes = 1024, + kProbeCaptureStackBytes = 12288, + kProbeCapturePriority = configMAX_PRIORITIES - 2, + kProbeOwnerAcquireMs = 1000, + kProbePreflightDrainMs = 60, + kProbeCleanupAckMs = 500, + kProbeEndSessionAttempts = 4, + kProbePostCleanupDrainMs = 60, + kProbeBoundaryDrainMs = 10, + kProbeBoundaryRawDrainMs = 2, + kProbeReadWindowPackets = 65535, + kProbeReadWindowGapMs = 50, + kProbeWramChunkBytes = 0, + kProbeWramChunkGapMs = 50, +}; + +static const char *TAG = "SvProbe"; +static const uint32_t kExpectedBitmap = 0x0000320Fu; + +typedef struct { + const char *name; + uint8_t region; + uint16_t length; + bool validate; +} ProbeRegion_t; + +typedef enum { + kProbePhase_NextRegion = 0, + kProbePhase_WaitingAckAccepted, + kProbePhase_ReadingData, + kProbePhase_WaitingAckDone, + kProbePhase_BoundaryDrain, +} ProbePhase_t; + +typedef struct { + const char *type; + uint8_t addr; + uint8_t opcode; + uint16_t seq; + uint8_t data_len; + uint8_t payload_len; + bool valid; +} ProbeFrameSummary_t; + +typedef struct { + const ProbeRegion_t *previous_region; + const ProbeRegion_t *current_region; + ProbePhase_t phase; + uint32_t boundary_drain_bytes; + uint32_t boundary_stale_frames; + uint32_t packets; + uint32_t bytes; + uint16_t request_offset; + uint16_t request_length; + uint16_t expected_seq; + int chunk_index; + bool ack_accepted; + bool ack_done; + bool stream_done_seen; + uint32_t continue_count; + ProbeFrameSummary_t last_frame; +} ProbeRegionStats_t; + +typedef struct { + uint8_t buf[kProbeRxCacheBytes]; + size_t pos; + size_t len; + bool pushed_valid; + FusionV2Decoded_t pushed; + uint32_t skipped_non_marker; + uint32_t bad_payload_len; + uint32_t bad_crc; +} ProbeReader_t; + +typedef struct { + const char *mode_name; + bool reduced_logging; + bool boot_mode; + bool capture_task; + bool wram_read_next; + uint16_t wram_chunk_bytes; + uint32_t wram_chunk_gap_ms; +} ProbeConfig_t; + +typedef struct { + bool tx_paused; + bool rx_paused; + bool lvgl_paused; +} ProbePauseState_t; + +typedef struct { + bool owner_acquired; + bool cleanup_ack; + bool begin_save_ack; + uint32_t initial_drain_bytes; + uint32_t post_cleanup_drain_bytes; + uint32_t stale_frames_during_cleanup; + uint32_t skipped_non_marker; + uint32_t bad_payload_len; + uint32_t bad_crc; + ProbePauseState_t pause; +} ProbePreflight_t; + +typedef struct { + ProbeConfig_t cfg; + TaskHandle_t caller; + uint8_t repeat_count; + bool ok; +} ProbeTaskRequest_t; + +typedef struct { + const ProbeRegion_t *region; + uint16_t expected_seq; + uint16_t got_seq; + uint32_t bytes_received; + uint32_t packets_received; + int64_t elapsed_ms; + uint8_t frame_addr; + uint8_t frame_op; + uint8_t frame_payload_len; + size_t uart_buffered; + size_t heap_free; + size_t heap_min_free; + uint32_t skipped_non_marker; + uint32_t bad_payload_len; + uint32_t bad_crc; + bool has_got_seq; + bool stale_data_while_ack; + bool stale_ack_done_while_ack; + bool final_end_ack; + const char *reason; +} ProbeFailure_t; + +static const ProbeRegion_t kRegions[kProbeRegionCount] = { + { "Header", 0x00u, 16u, true }, + { "Top", 0x01u, 16u, true }, + { "CPU", 0x02u, 40u, false }, + { "Timer", 0x03u, 8u, true }, + { "HRAM", 0x09u, 127u, false }, + { "WRAM", 0x0Cu, 32768u, false }, +}; + +static uint8_t *s_rx_buf = NULL; +static size_t s_rx_buf_len = 0u; +static bool s_lvgl_suspended = false; + +extern TaskHandle_t FusionApp_GetLvglTimerTaskHandle(void); + +static bool ReadDecodedFrameWithTimeout(ProbeReader_t *reader, + const char *context, + uint32_t timeout_ms, + bool print_timeout, + FusionV2Decoded_t *decoded); +static uint32_t DrainUartRxForMs(uint32_t duration_ms); + +static bool EnsureRxBuf(uint16_t need) +{ + if (need <= s_rx_buf_len) { + return true; + } + uint8_t *p = (uint8_t *)heap_caps_realloc(s_rx_buf, need, MALLOC_CAP_8BIT); + if (p == NULL) { + printf("SvProbe: heap alloc %u failed\n", (unsigned)need); + return false; + } + s_rx_buf = p; + s_rx_buf_len = need; + return true; +} + +static void ReaderReset(ProbeReader_t *reader) +{ + memset(reader, 0, sizeof(*reader)); +} + +static void ReaderPushFrame(ProbeReader_t *reader, const FusionV2Decoded_t *frame) +{ + reader->pushed = *frame; + reader->pushed_valid = true; +} + +static bool ReaderByte(ProbeReader_t *reader, uint8_t *out, int64_t deadline_us) +{ + while (esp_timer_get_time() < deadline_us) { + if (reader->pos < reader->len) { + *out = reader->buf[reader->pos++]; + return true; + } + reader->pos = 0u; + reader->len = 0u; + + const int got = uart_read_bytes(UART_NUM_1, + reader->buf, + sizeof(reader->buf), + pdMS_TO_TICKS(10)); + if (got > 0) { + reader->len = (size_t)got; + } + } + return false; +} + +static void SnapshotRuntimeStats(ProbeFailure_t *failure) +{ + size_t buffered = 0u; + (void)uart_get_buffered_data_len(UART_NUM_1, &buffered); + failure->uart_buffered = buffered; + failure->heap_free = heap_caps_get_free_size(MALLOC_CAP_8BIT); + failure->heap_min_free = heap_caps_get_minimum_free_size(MALLOC_CAP_8BIT); +} + +static const char *PhaseName(ProbePhase_t phase) +{ + switch (phase) { + case kProbePhase_WaitingAckAccepted: return "waiting_ack_accepted"; + case kProbePhase_ReadingData: return "reading_data"; + case kProbePhase_WaitingAckDone: return "waiting_ack_done"; + case kProbePhase_BoundaryDrain: return "boundary_drain"; + case kProbePhase_NextRegion: + default: return "next_region"; + } +} + +static const char *FrameTypeName(const FusionV2Decoded_t *frame) +{ + if (frame == NULL) { + return "none"; + } + if (frame->addr == (uint8_t)kFusionAddr_StateCtl) { + return "CTL"; + } + if (frame->addr == (uint8_t)kFusionAddr_StateData) { + return "DATA"; + } + return "OTHER"; +} + +static void CaptureFrameSummary(ProbeFrameSummary_t *summary, + const FusionV2Decoded_t *frame) +{ + memset(summary, 0, sizeof(*summary)); + summary->type = FrameTypeName(frame); + if (frame == NULL) { + return; + } + summary->addr = frame->addr; + summary->opcode = frame->ctl_opcode; + summary->seq = frame->data_seq; + summary->data_len = frame->data_len; + summary->payload_len = frame->payload_len; + summary->valid = true; +} + +static void PrintRegionSummary(const ProbeRegionStats_t *stats, + const char *status, + int64_t elapsed_ms) +{ + printf("SvProbe: REGION_SUMMARY %s status=%s id=0x%02X offset=%u length=%u " + "chunk=%d packets=%lu bytes=%lu elapsed_ms=%lld ack_accepted=%s " + "ack_done=%s continue_count=%lu boundary_stale_frames=%lu " + "boundary_drain_bytes=%lu\n", + stats->current_region != NULL ? stats->current_region->name : "(none)", + status, + stats->current_region != NULL ? (unsigned)stats->current_region->region : 0u, + (unsigned)stats->request_offset, + (unsigned)stats->request_length, + stats->chunk_index, + (unsigned long)stats->packets, + (unsigned long)stats->bytes, + (long long)elapsed_ms, + stats->ack_accepted ? "yes" : "no", + stats->ack_done ? "yes" : "no", + (unsigned long)stats->continue_count, + (unsigned long)stats->boundary_stale_frames, + (unsigned long)stats->boundary_drain_bytes); +} + +static void PrintWramChunkSummary(const ProbeRegionStats_t *stats, + const char *status, + int64_t elapsed_ms, + uint32_t gap_drain_bytes) +{ + printf("SvProbe: WRAM_CHUNK index=%d offset=%u length=%u status=%s " + "packets=%lu bytes=%lu elapsed_ms=%lld ack_accepted=%s " + "ack_done=%s gap_drain_bytes=%lu\n", + stats->chunk_index, + (unsigned)stats->request_offset, + (unsigned)stats->request_length, + status, + (unsigned long)stats->packets, + (unsigned long)stats->bytes, + (long long)elapsed_ms, + stats->ack_accepted ? "yes" : "no", + stats->ack_done ? "yes" : "no", + (unsigned long)gap_drain_bytes); +} + +static void PrintRegionFailureContext(const ProbeRegionStats_t *stats, + const char *expected_frame, + const FusionV2Decoded_t *actual, + const ProbeReader_t *reader) +{ + ProbeFrameSummary_t actual_summary; + if (actual != NULL) { + CaptureFrameSummary(&actual_summary, actual); + } else if (stats->last_frame.valid) { + actual_summary = stats->last_frame; + } else { + CaptureFrameSummary(&actual_summary, NULL); + } + printf("SvProbe: REGION_FAIL phase=%s expected=%s actual_type=%s " + "actual_addr=0x%02X actual_opcode=0x%02X actual_seq=%u " + "actual_data_len=%u actual_payload_len=%u expected_seq=%u " + "previous_region=%s current_region=%s boundary_stale_frames=%lu " + "boundary_drain_bytes=%lu bad_len=%lu bad_crc=%lu resync_non_marker=%lu\n", + PhaseName(stats->phase), + expected_frame, + actual_summary.type, + actual_summary.addr, + actual_summary.opcode, + (unsigned)actual_summary.seq, + (unsigned)actual_summary.data_len, + (unsigned)actual_summary.payload_len, + (unsigned)stats->expected_seq, + stats->previous_region != NULL ? stats->previous_region->name : "(none)", + stats->current_region != NULL ? stats->current_region->name : "(none)", + (unsigned long)stats->boundary_stale_frames, + (unsigned long)stats->boundary_drain_bytes, + (unsigned long)reader->bad_payload_len, + (unsigned long)reader->bad_crc, + (unsigned long)reader->skipped_non_marker); +} + +static void PrintFailure(const ProbeFailure_t *f) +{ + printf("SvProbe: FIRST_FAIL reason=%s region=%s id=0x%02X " + "bytes=%lu packets=%lu elapsed_ms=%lld\n", + f->reason, + f->region != NULL ? f->region->name : "(none)", + f->region != NULL ? (unsigned)f->region->region : 0u, + (unsigned long)f->bytes_received, + (unsigned long)f->packets_received, + (long long)f->elapsed_ms); + printf("SvProbe: FIRST_FAIL seq expected=%u got=%s%u frame_addr=0x%02X " + "frame_op=0x%02X payload_len=%u\n", + (unsigned)f->expected_seq, + f->has_got_seq ? "" : "n/a/", + f->has_got_seq ? (unsigned)f->got_seq : 0u, + f->frame_addr, + f->frame_op, + f->frame_payload_len); + printf("SvProbe: FIRST_FAIL uart_buffered=%u heap_free=%u heap_min=%u " + "resync_non_marker=%lu bad_len=%lu bad_crc=%lu\n", + (unsigned)f->uart_buffered, + (unsigned)f->heap_free, + (unsigned)f->heap_min_free, + (unsigned long)f->skipped_non_marker, + (unsigned long)f->bad_payload_len, + (unsigned long)f->bad_crc); + printf("SvProbe: FIRST_FAIL stale_data_while_ack=%s " + "stale_ack_done_while_ack=%s final_end_ack=%s\n", + f->stale_data_while_ack ? "yes" : "no", + f->stale_ack_done_while_ack ? "yes" : "no", + f->final_end_ack ? "yes" : "no"); +} + +static bool Fail(ProbeFailure_t *failure, + ProbeReader_t *reader, + const ProbeRegion_t *region, + const char *reason, + const FusionV2Decoded_t *frame, + uint16_t expected_seq, + uint32_t bytes_received, + uint32_t packets_received, + int64_t t0) +{ + memset(failure, 0, sizeof(*failure)); + failure->region = region; + failure->reason = reason; + failure->expected_seq = expected_seq; + failure->bytes_received = bytes_received; + failure->packets_received = packets_received; + failure->elapsed_ms = (esp_timer_get_time() - t0) / 1000; + failure->skipped_non_marker = reader->skipped_non_marker; + failure->bad_payload_len = reader->bad_payload_len; + failure->bad_crc = reader->bad_crc; + failure->stale_data_while_ack = + (strcmp(reason, "stale_DATA_while_expecting_ACK") == 0); + failure->stale_ack_done_while_ack = + (strcmp(reason, "stale_ACK_DONE_while_expecting_ACK") == 0); + if (frame != NULL) { + failure->frame_addr = frame->addr; + failure->frame_op = frame->ctl_opcode; + failure->frame_payload_len = frame->payload_len; + if (frame->addr == (uint8_t)kFusionAddr_StateData) { + failure->got_seq = frame->data_seq; + failure->has_got_seq = true; + } + } + SnapshotRuntimeStats(failure); + PrintFailure(failure); + return false; +} + +static bool ReadDecodedFrame(ProbeReader_t *reader, + const char *context, + FusionV2Decoded_t *decoded) +{ + return ReadDecodedFrameWithTimeout(reader, + context, + kProbeFrameTimeoutMs, + true, + decoded); +} + +static bool ReadDecodedFrameWithTimeout(ProbeReader_t *reader, + const char *context, + uint32_t timeout_ms, + bool print_timeout, + FusionV2Decoded_t *decoded) +{ + if (reader->pushed_valid) { + *decoded = reader->pushed; + reader->pushed_valid = false; + return true; + } + + uint8_t raw[FUSION_V2_MAX_FRAME] = {0}; + const int64_t deadline = esp_timer_get_time() + + ((int64_t)timeout_ms * 1000); + + while (esp_timer_get_time() < deadline) { + uint8_t b = 0u; + if (!ReaderByte(reader, &b, deadline)) { + if (print_timeout) { + printf("SvProbe: RX timeout waiting for marker during %s\n", context); + } + return false; + } + if (b != FUSION_V2_HEADER_MARKER) { + reader->skipped_non_marker++; + continue; + } + + raw[0] = b; + if (!ReaderByte(reader, &raw[1], deadline) || + !ReaderByte(reader, &raw[2], deadline)) { + if (print_timeout) { + printf("SvProbe: RX timeout waiting for header during %s\n", context); + } + return false; + } + + const uint8_t payload_len = raw[2]; + if (payload_len > FUSION_V2_MAX_PAYLOAD) { + reader->bad_payload_len++; + printf("SvProbe: RX resync bad payload len %u during %s\n", + (unsigned)payload_len, context); + continue; + } + + const size_t frame_len = 3u + payload_len + 1u; + for (size_t i = 3u; i < frame_len; ++i) { + if (!ReaderByte(reader, &raw[i], deadline)) { + if (print_timeout) { + printf("SvProbe: RX timeout waiting for payload/crc during %s\n", + context); + } + return false; + } + } + + if (!FusionSavestate_DecodeV2Frame(raw, frame_len, decoded)) { + reader->bad_crc++; + printf("SvProbe: RX resync decode/CRC failed during %s\n", context); + continue; + } + return true; + } + + if (print_timeout) { + printf("SvProbe: RX timeout waiting for valid frame during %s\n", context); + } + return false; +} + +static bool SendFrame(const ProbeConfig_t *cfg, + const char *label, + const FusionV2Frame_t *frame) +{ + if (frame == NULL || frame->length == 0u) { + printf("SvProbe: %s invalid frame\n", label); + return false; + } + if (!cfg->reduced_logging) { + printf("SvProbe: TX %s:", label); + for (uint8_t i = 0; i < frame->length; ++i) { + printf(" %02X", frame->bytes[i]); + } + printf("\n"); + } + + const int written = uart_write_bytes(UART_NUM_1, + (const char *)frame->bytes, + frame->length); + (void)uart_wait_tx_done(UART_NUM_1, pdMS_TO_TICKS(250)); + if (written != (int)frame->length) { + printf("SvProbe: TX %s failed wrote=%d expected=%u\n", + label, written, (unsigned)frame->length); + return false; + } + return true; +} + +static bool ExpectCtl(const ProbeConfig_t *cfg, + ProbeReader_t *reader, + ProbeFailure_t *failure, + const ProbeRegion_t *region, + uint8_t opcode, + uint8_t ack_for, + const char *context, + uint16_t expected_seq, + uint32_t bytes_received, + uint32_t packets_received, + int64_t t0) +{ + FusionV2Decoded_t d; + if (!ReadDecodedFrame(reader, context, &d)) { + return Fail(failure, reader, region, "timeout_or_unrecoverable_resync", + NULL, expected_seq, bytes_received, packets_received, t0); + } + if (d.addr == (uint8_t)kFusionAddr_StateData) { + return Fail(failure, reader, region, "stale_DATA_while_expecting_ACK", + &d, expected_seq, bytes_received, packets_received, t0); + } + if (d.addr != (uint8_t)kFusionAddr_StateCtl) { + return Fail(failure, reader, region, "unexpected_addr_while_expecting_ACK", + &d, expected_seq, bytes_received, packets_received, t0); + } + if (d.ctl_opcode == (uint8_t)kFusionOp_Error) { + return Fail(failure, reader, region, "FPGA_ERROR", + &d, expected_seq, bytes_received, packets_received, t0); + } + if (opcode == (uint8_t)kFusionOp_AckAccepted && + d.ctl_opcode == (uint8_t)kFusionOp_AckDone) { + return Fail(failure, reader, region, "stale_ACK_DONE_while_expecting_ACK", + &d, expected_seq, bytes_received, packets_received, t0); + } + if (d.ctl_opcode != opcode || d.ack_for_opcode != ack_for) { + return Fail(failure, reader, region, "unexpected_CTL", + &d, expected_seq, bytes_received, packets_received, t0); + } + (void)cfg; + return true; +} + +static bool BuildEnd(FusionV2Frame_t *out) +{ + return FusionSavestate_BuildEndSession(out); +} + +static bool BuildBeginSave(FusionV2Frame_t *out) +{ + return FusionSavestate_BuildBeginSave(0u, out); +} + +static bool WaitEndSessionAck(ProbeReader_t *reader, + const char *context, + uint32_t timeout_ms) +{ + const int64_t deadline = esp_timer_get_time() + + ((int64_t)timeout_ms * 1000); + while (esp_timer_get_time() < deadline) { + FusionV2Decoded_t d; + const int64_t now = esp_timer_get_time(); + const uint32_t remain_ms = + (uint32_t)((deadline > now) ? ((deadline - now + 999) / 1000) : 0); + if (remain_ms == 0u || + !ReadDecodedFrameWithTimeout(reader, context, remain_ms, true, &d)) { + break; + } + if (d.addr == (uint8_t)kFusionAddr_StateCtl && + d.ctl_opcode == (uint8_t)kFusionOp_AckAccepted && + d.ack_for_opcode == (uint8_t)kFusionOp_EndSession) { + return true; + } + } + return false; +} + +static bool SendEndSessionCleanup(const ProbeConfig_t *cfg, + ProbeReader_t *reader, + const char *label) +{ + FusionV2Frame_t frame; + if (!BuildEnd(&frame)) { + printf("SvProbe: build %s failed\n", label); + return false; + } + + for (uint8_t attempt = 0u; attempt < kProbeEndSessionAttempts; ++attempt) { + if (attempt != 0u) { + (void)DrainUartRxForMs(kProbePostCleanupDrainMs); + ReaderReset(reader); + } + if (!SendFrame(cfg, label, &frame)) { + return false; + } + if (WaitEndSessionAck(reader, label, kProbeCleanupAckMs)) { + return true; + } + } + return false; +} + +static bool ValidateHeader(const uint8_t *data) +{ + const uint32_t bitmap = (uint32_t)data[8] + | ((uint32_t)data[9] << 8) + | ((uint32_t)data[10] << 16) + | ((uint32_t)data[11] << 24); + if (memcmp(data, "FUSS", 4u) != 0) { + printf("SvProbe: Header magic mismatch\n"); + return false; + } + if (bitmap != kExpectedBitmap) { + printf("SvProbe: Header bitmap mismatch got=0x%08lX want=0x%08lX\n", + (unsigned long)bitmap, + (unsigned long)kExpectedBitmap); + return false; + } + if (memcmp(&data[12], "P48E", 4u) != 0) { + printf("SvProbe: Header tag mismatch got='%c%c%c%c' want='P48E'\n", + data[12], data[13], data[14], data[15]); + return false; + } + return true; +} + +static bool ValidateRegionLight(const ProbeRegion_t *region, + const uint8_t *data, + uint16_t length) +{ + if (region->region == 0x00u) { + return ValidateHeader(data); + } + if (region->region == 0x01u) { + for (uint16_t i = 10u; i < length; ++i) { + if (data[i] != 0u) { + printf("SvProbe: Top padding byte %u nonzero=0x%02X\n", + (unsigned)i, data[i]); + return false; + } + } + } else if (region->region == 0x03u) { + if (data[6] != 0u || data[7] != 0u) { + printf("SvProbe: Timer padding mismatch b6=0x%02X b7=0x%02X\n", + data[6], data[7]); + return false; + } + } + return true; +} + +static void PrintBytes(const char *label, const uint8_t *data, uint16_t count) +{ + printf(" %s:", label); + for (uint16_t i = 0; i < count; ++i) { + printf(" %02X", data[i]); + } +} + +static bool ExpectStreamAckOrFirstData(ProbeReader_t *reader, + ProbeFailure_t *failure, + ProbeRegionStats_t *stats, + uint8_t ack_for, + const char *context, + int64_t t0) +{ + const int64_t deadline = esp_timer_get_time() + + ((int64_t)kProbeFrameTimeoutMs * 1000); + + while (esp_timer_get_time() < deadline) { + FusionV2Decoded_t d; + const int64_t now = esp_timer_get_time(); + const uint32_t remain_ms = + (uint32_t)((deadline > now) ? ((deadline - now + 999) / 1000) : 0); + if (remain_ms == 0u || + !ReadDecodedFrameWithTimeout(reader, + context, + remain_ms, + true, + &d)) { + PrintRegionSummary(stats, "FAIL", (esp_timer_get_time() - t0) / 1000); + PrintRegionFailureContext(stats, context, NULL, reader); + return Fail(failure, reader, stats->current_region, + "timeout_or_unrecoverable_resync", + NULL, stats->expected_seq, stats->bytes, + stats->packets, t0); + } + CaptureFrameSummary(&stats->last_frame, &d); + + if (d.addr == (uint8_t)kFusionAddr_StateCtl && + d.ctl_opcode == (uint8_t)kFusionOp_AckAccepted && + d.ack_for_opcode == ack_for) { + stats->ack_accepted = true; + return true; + } + + if (d.addr == (uint8_t)kFusionAddr_StateData && + d.data_seq == stats->expected_seq) { + stats->ack_accepted = true; + ReaderPushFrame(reader, &d); + return true; + } + + stats->boundary_stale_frames++; + if (d.addr == (uint8_t)kFusionAddr_StateData || + (d.addr == (uint8_t)kFusionAddr_StateCtl && + d.ctl_opcode == (uint8_t)kFusionOp_AckDone)) { + continue; + } + + PrintRegionSummary(stats, "FAIL", (esp_timer_get_time() - t0) / 1000); + PrintRegionFailureContext(stats, context, &d, reader); + return Fail(failure, reader, stats->current_region, + "unexpected_frame_while_expecting_ack_accepted", + &d, stats->expected_seq, stats->bytes, stats->packets, t0); + } + + PrintRegionSummary(stats, "FAIL", (esp_timer_get_time() - t0) / 1000); + PrintRegionFailureContext(stats, context, NULL, reader); + return Fail(failure, reader, stats->current_region, + "timeout_waiting_ack_accepted", + NULL, stats->expected_seq, stats->bytes, stats->packets, t0); +} + +static bool DrainRegionBoundary(ProbeReader_t *reader, + ProbeFailure_t *failure, + ProbeRegionStats_t *stats, + int64_t t0) +{ + stats->phase = kProbePhase_BoundaryDrain; + const uint32_t bad_len0 = reader->bad_payload_len; + const uint32_t bad_crc0 = reader->bad_crc; + const int64_t deadline = esp_timer_get_time() + + ((int64_t)kProbeBoundaryDrainMs * 1000); + + while (esp_timer_get_time() < deadline) { + FusionV2Decoded_t d; + const int64_t now = esp_timer_get_time(); + const uint32_t remain_ms = + (uint32_t)((deadline > now) ? ((deadline - now + 999) / 1000) : 0); + if (remain_ms == 0u || + !ReadDecodedFrameWithTimeout(reader, + "boundary_drain", + remain_ms, + false, + &d)) { + break; + } + + CaptureFrameSummary(&stats->last_frame, &d); + stats->boundary_stale_frames++; + stats->boundary_drain_bytes += (uint32_t)d.payload_len + 4u; + } + + if (reader->bad_payload_len != bad_len0 || reader->bad_crc != bad_crc0 || + stats->boundary_stale_frames != 0u) { + stats->boundary_drain_bytes += DrainUartRxForMs(kProbeBoundaryRawDrainMs); + PrintRegionSummary(stats, "FAIL", (esp_timer_get_time() - t0) / 1000); + PrintRegionFailureContext(stats, "quiet boundary before next region", + NULL, reader); + const bool failed = Fail(failure, reader, stats->current_region, + stats->boundary_stale_frames != 0u ? + "boundary_stale_frame" : + (reader->bad_payload_len != bad_len0 ? + "bad_len_during_boundary_drain" : + "bad_crc_during_boundary_drain"), + NULL, stats->expected_seq, stats->bytes, + stats->packets, t0); + ReaderReset(reader); + return failed; + } + + stats->boundary_drain_bytes += DrainUartRxForMs(kProbeBoundaryRawDrainMs); + ReaderReset(reader); + return true; +} + +static bool ReadRegionRange(const ProbeConfig_t *cfg, + ProbeReader_t *reader, + ProbeFailure_t *failure, + const ProbeRegion_t *previous_region, + const ProbeRegion_t *region, + uint16_t offset, + uint16_t length, + int chunk_index, + uint8_t *data) +{ + uint32_t received = 0u; + uint32_t packets = 0u; + uint16_t window_packets = 0u; + uint16_t expected_seq = 0u; + const int64_t t0 = esp_timer_get_time(); + ProbeRegionStats_t stats = { + .previous_region = previous_region, + .current_region = region, + .phase = kProbePhase_NextRegion, + .request_offset = offset, + .request_length = length, + .chunk_index = chunk_index, + }; + + FusionV2Frame_t begin; + if (!FusionSavestate_BuildReadStreamBegin(region->region, offset, + length, &begin)) { + printf("SvProbe: build READ_STREAM_BEGIN %s failed\n", region->name); + return false; + } + + printf("SvProbe: BEGIN region=%s id=0x%02X offset=%u length=%u chunk=%d\n", + region->name, + (unsigned)region->region, + (unsigned)offset, + (unsigned)length, + chunk_index); + if (!SendFrame(cfg, "READ_STREAM_BEGIN", &begin)) { + return false; + } + stats.phase = kProbePhase_WaitingAckAccepted; + if (!ExpectStreamAckOrFirstData(reader, failure, &stats, + (uint8_t)kFusionOp_ReadStreamBegin, + "ACK_ACCEPTED(ReadStreamBegin)", + t0)) { + return false; + } + + stats.phase = kProbePhase_ReadingData; + while (received < length) { + FusionV2Decoded_t d; + if (!ReadDecodedFrame(reader, region->name, &d)) { + stats.expected_seq = expected_seq; + stats.bytes = received; + stats.packets = packets; + PrintRegionSummary(&stats, "FAIL", (esp_timer_get_time() - t0) / 1000); + PrintRegionFailureContext(&stats, "STATE_DATA", NULL, reader); + return Fail(failure, reader, region, + "timeout_or_unrecoverable_resync", + NULL, expected_seq, received, packets, t0); + } + CaptureFrameSummary(&stats.last_frame, &d); + if (d.addr == (uint8_t)kFusionAddr_StateCtl && + d.ctl_opcode == (uint8_t)kFusionOp_Error) { + stats.expected_seq = expected_seq; + stats.bytes = received; + stats.packets = packets; + PrintRegionSummary(&stats, "FAIL", (esp_timer_get_time() - t0) / 1000); + PrintRegionFailureContext(&stats, "STATE_DATA", &d, reader); + return Fail(failure, reader, region, "FPGA_ERROR", + &d, expected_seq, received, packets, t0); + } + if (d.addr != (uint8_t)kFusionAddr_StateData) { + stats.expected_seq = expected_seq; + stats.bytes = received; + stats.packets = packets; + PrintRegionSummary(&stats, "FAIL", (esp_timer_get_time() - t0) / 1000); + PrintRegionFailureContext(&stats, "STATE_DATA", &d, reader); + return Fail(failure, reader, region, "unexpected_frame_in_stream", + &d, expected_seq, received, packets, t0); + } + if (d.data_seq != expected_seq) { + stats.expected_seq = expected_seq; + stats.bytes = received; + stats.packets = packets; + PrintRegionSummary(&stats, "FAIL", (esp_timer_get_time() - t0) / 1000); + PrintRegionFailureContext(&stats, "STATE_DATA seq", &d, reader); + return Fail(failure, reader, region, "seq_mismatch", + &d, expected_seq, received, packets, t0); + } + if (d.data_len == 0u || + (uint32_t)d.data_len > ((uint32_t)length - received)) { + stats.expected_seq = expected_seq; + stats.bytes = received; + stats.packets = packets; + PrintRegionSummary(&stats, "FAIL", (esp_timer_get_time() - t0) / 1000); + PrintRegionFailureContext(&stats, "STATE_DATA len", &d, reader); + return Fail(failure, reader, region, "bad_data_len", + &d, expected_seq, received, packets, t0); + } + + memcpy(&data[received], d.data, d.data_len); + received += d.data_len; + packets++; + expected_seq++; + window_packets++; + stats.bytes = received; + stats.packets = packets; + stats.expected_seq = expected_seq; + + if (received < length && window_packets >= kProbeReadWindowPackets) { + FusionV2Frame_t cont; + if (!FusionSavestate_BuildReadStreamContinue(expected_seq, &cont)) { + PrintRegionSummary(&stats, "FAIL", + (esp_timer_get_time() - t0) / 1000); + PrintRegionFailureContext(&stats, "READ_STREAM_CONTINUE build", + NULL, reader); + return Fail(failure, reader, region, + "build_READ_STREAM_CONTINUE_failed", + NULL, expected_seq, received, packets, t0); + } + vTaskDelay(pdMS_TO_TICKS(kProbeReadWindowGapMs)); + if (!SendFrame(cfg, "READ_STREAM_CONTINUE", &cont)) { + PrintRegionSummary(&stats, "FAIL", + (esp_timer_get_time() - t0) / 1000); + PrintRegionFailureContext(&stats, "READ_STREAM_CONTINUE tx", + NULL, reader); + return Fail(failure, reader, region, + "tx_READ_STREAM_CONTINUE_failed", + NULL, expected_seq, received, packets, t0); + } + stats.phase = kProbePhase_WaitingAckAccepted; + if (!ExpectStreamAckOrFirstData(reader, failure, &stats, + (uint8_t)kFusionOp_ReadStreamContinue, + "ACK_ACCEPTED(ReadStreamContinue)", + t0)) { + return false; + } + stats.phase = kProbePhase_ReadingData; + stats.continue_count++; + window_packets = 0u; + } + } + stats.stream_done_seen = true; + + stats.phase = kProbePhase_WaitingAckDone; + FusionV2Decoded_t done; + if (!ReadDecodedFrame(reader, "READ_STREAM_BEGIN done", &done)) { + PrintRegionSummary(&stats, "FAIL", (esp_timer_get_time() - t0) / 1000); + PrintRegionFailureContext(&stats, "ACK_DONE(ReadStreamBegin)", NULL, reader); + return Fail(failure, reader, region, "timeout_or_unrecoverable_resync", + NULL, expected_seq, received, packets, t0); + } + CaptureFrameSummary(&stats.last_frame, &done); + if (done.addr != (uint8_t)kFusionAddr_StateCtl || + done.ctl_opcode != (uint8_t)kFusionOp_AckDone || + done.ack_for_opcode != (uint8_t)kFusionOp_ReadStreamBegin) { + PrintRegionSummary(&stats, "FAIL", (esp_timer_get_time() - t0) / 1000); + PrintRegionFailureContext(&stats, "ACK_DONE(ReadStreamBegin)", &done, reader); + return Fail(failure, reader, region, + done.addr == (uint8_t)kFusionAddr_StateData ? + "stale_DATA_while_expecting_ACK_DONE" : + "unexpected_frame_while_expecting_ACK_DONE", + &done, expected_seq, received, packets, t0); + } + stats.ack_done = true; + + if (region->validate && offset == 0u && length == region->length && + !ValidateRegionLight(region, data, length)) { + PrintRegionSummary(&stats, "FAIL", (esp_timer_get_time() - t0) / 1000); + PrintRegionFailureContext(&stats, "region validation", NULL, reader); + return Fail(failure, reader, region, "region_validation_failed", + NULL, expected_seq, received, packets, t0); + } + + if (!DrainRegionBoundary(reader, failure, &stats, t0)) { + return false; + } + + const int64_t elapsed_ms = (esp_timer_get_time() - t0) / 1000; + const uint32_t crc = FusionSavestate_Crc32(data, length); + PrintRegionSummary(&stats, "PASS", elapsed_ms); + printf("SvProbe: REGION %s PASS id=0x%02X packets=%lu bytes=%lu " + "dt_ms=%lld offset=%u length=%u crc32=%08lX", + region->name, + (unsigned)region->region, + (unsigned long)packets, + (unsigned long)received, + (long long)elapsed_ms, + (unsigned)offset, + (unsigned)length, + (unsigned long)crc); + if (!cfg->reduced_logging) { + const uint16_t first = length < 16u ? length : 16u; + PrintBytes("first", data, first); + if (length > 32u) { + PrintBytes("last", &data[length - 16u], 16u); + } + } + printf("\n"); + if (chunk_index >= 0) { + vTaskDelay(pdMS_TO_TICKS(cfg->wram_chunk_gap_ms)); + const uint32_t gap_drain_bytes = DrainUartRxForMs(kProbeBoundaryRawDrainMs); + ReaderReset(reader); + PrintWramChunkSummary(&stats, "PASS", elapsed_ms, gap_drain_bytes); + } + return true; +} + +static bool ReadRegion(const ProbeConfig_t *cfg, + ProbeReader_t *reader, + ProbeFailure_t *failure, + const ProbeRegion_t *previous_region, + const ProbeRegion_t *region) +{ + if (!EnsureRxBuf(region->length)) { + return false; + } + uint8_t *data = s_rx_buf; + memset(data, 0, region->length); + return ReadRegionRange(cfg, reader, failure, previous_region, region, + 0u, region->length, -1, data); +} + +static bool ReadWramReadNext(const ProbeConfig_t *cfg, + ProbeReader_t *reader, + ProbeFailure_t *failure, + const ProbeRegion_t *previous_region, + const ProbeRegion_t *region) +{ + if (!EnsureRxBuf(region->length)) { + return false; + } + uint8_t *data = s_rx_buf; + memset(data, 0, region->length); + + printf("SvProbe: WRAM_READNEXT count=8 gap_ms=%u total=%u\n", + (unsigned)cfg->wram_chunk_gap_ms, + (unsigned)region->length); + + FusionV2Frame_t seek; + const int64_t t0 = esp_timer_get_time(); + if (!FusionSavestate_BuildSeek(region->region, 0u, &seek) || + !SendFrame(cfg, "SEEK WRAM", &seek)) { + return false; + } + if (!ExpectCtl(cfg, reader, failure, region, + (uint8_t)kFusionOp_AckAccepted, + (uint8_t)kFusionOp_Seek, + "SEEK WRAM ack", 0u, 0u, 0u, t0)) { + return false; + } + + uint32_t received = 0u; + uint32_t packets = 0u; + while (received < region->length) { + const uint8_t count = + ((region->length - received) >= 8u) ? 8u + : (uint8_t)(region->length - received); + FusionV2Frame_t next; + if (!FusionSavestate_BuildReadNext(count, &next) || + !SendFrame(cfg, "READ_NEXT WRAM", &next)) { + return false; + } + + FusionV2Decoded_t d; + if (!ReadDecodedFrame(reader, "READ_NEXT WRAM", &d)) { + return Fail(failure, reader, region, + "timeout_READ_NEXT_DATA", + NULL, 0u, received, packets, t0); + } + if (d.addr != (uint8_t)kFusionAddr_StateData || + d.data_seq != 0u || + d.data_len != count) { + return Fail(failure, reader, region, + "unexpected_READ_NEXT_DATA", + &d, 0u, received, packets, t0); + } + memcpy(&data[received], d.data, d.data_len); + received += d.data_len; + packets++; + + if ((packets % 512u) == 0u) { + printf("SvProbe: WRAM_READNEXT_PROGRESS packets=%lu bytes=%lu\n", + (unsigned long)packets, + (unsigned long)received); + } + if (cfg->wram_chunk_gap_ms != 0u) { + vTaskDelay(pdMS_TO_TICKS(cfg->wram_chunk_gap_ms)); + } + } + + const uint32_t crc = FusionSavestate_Crc32(data, region->length); + printf("SvProbe: WRAM_READNEXT PASS packets=%lu bytes=%lu crc32=%08lX\n", + (unsigned long)packets, + (unsigned long)received, + (unsigned long)crc); + (void)previous_region; + return received == region->length; +} + +static bool ReadWramChunked(const ProbeConfig_t *cfg, + ProbeReader_t *reader, + ProbeFailure_t *failure, + const ProbeRegion_t *previous_region, + const ProbeRegion_t *region) +{ + if (!EnsureRxBuf(region->length)) { + return false; + } + uint8_t *data = s_rx_buf; + memset(data, 0, region->length); + + uint32_t total = 0u; + if (cfg->wram_chunk_bytes == 0u || + (region->length % cfg->wram_chunk_bytes) != 0u) { + printf("SvProbe: invalid WRAM chunk size=%u total=%u\n", + (unsigned)cfg->wram_chunk_bytes, + (unsigned)region->length); + return false; + } + + const uint16_t chunk_count = region->length / cfg->wram_chunk_bytes; + printf("SvProbe: WRAM_CHUNKED size=%u gap_ms=%u chunks=%u total=%u\n", + (unsigned)cfg->wram_chunk_bytes, + (unsigned)cfg->wram_chunk_gap_ms, + (unsigned)chunk_count, + (unsigned)region->length); + + for (uint16_t i = 0u; i < chunk_count; ++i) { + const uint16_t offset = (uint16_t)(i * cfg->wram_chunk_bytes); + const ProbeRegion_t *prev = (i == 0u) ? previous_region : region; + if (!ReadRegionRange(cfg, + reader, + failure, + prev, + region, + offset, + cfg->wram_chunk_bytes, + (int)i, + &data[offset])) { + printf("SvProbe: WRAM_CHUNKED FAIL chunks_passed=%u chunks_failed=1 " + "total_bytes=%lu\n", + (unsigned)i, + (unsigned long)total); + return false; + } + total += cfg->wram_chunk_bytes; + } + + const uint32_t crc = FusionSavestate_Crc32(data, region->length); + printf("SvProbe: WRAM_CHUNKED PASS chunks_passed=%u chunks_failed=0 " + "total_bytes=%lu crc32=%08lX\n", + (unsigned)chunk_count, + (unsigned long)total, + (unsigned long)crc); + return total == region->length; +} + +static ProbePauseState_t PauseNormalUartTasks(void) +{ + TaskHandle_t *tx = FPGA_GetTxTaskHandle(); + TaskHandle_t *rx = FPGA_GetRxTaskHandle(); + ProbePauseState_t pause = {0}; + + TaskHandle_t lvgl = FusionApp_GetLvglTimerTaskHandle(); + if (lvgl != NULL && lvgl != xTaskGetCurrentTaskHandle()) { + vTaskSuspend(lvgl); + pause.lvgl_paused = true; + s_lvgl_suspended = true; + } else { + s_lvgl_suspended = false; + } + if (tx != NULL && *tx != NULL) { + FPGA_Tx_Pause(); + pause.tx_paused = true; + } + if (rx != NULL && *rx != NULL) { + FPGA_Rx_Pause(); + pause.rx_paused = true; + } + vTaskDelay(pdMS_TO_TICKS(30)); + printf("SvProbe: PAUSE tx=%s rx=%s lvgl=%s\n", + pause.tx_paused ? "yes" : "no", + pause.rx_paused ? "yes" : "no", + pause.lvgl_paused ? "yes" : "no"); + return pause; +} + +static void ResumeNormalUartTasks(void) +{ + TaskHandle_t *tx = FPGA_GetTxTaskHandle(); + TaskHandle_t *rx = FPGA_GetRxTaskHandle(); + if (rx != NULL && *rx != NULL) { + FPGA_Rx_Resume(); + } + if (tx != NULL && *tx != NULL) { + FPGA_Tx_Resume(); + } + if (s_lvgl_suspended) { + TaskHandle_t lvgl = FusionApp_GetLvglTimerTaskHandle(); + if (lvgl != NULL) { + vTaskResume(lvgl); + } + s_lvgl_suspended = false; + } +} + +static uint32_t DrainUartRxForMs(uint32_t duration_ms) +{ + uint8_t tmp[128]; + uint32_t total = 0u; + const int64_t deadline = esp_timer_get_time() + ((int64_t)duration_ms * 1000); + while (esp_timer_get_time() < deadline) { + const int got = uart_read_bytes(UART_NUM_1, + tmp, + sizeof(tmp), + pdMS_TO_TICKS(2)); + if (got > 0) { + total += (uint32_t)got; + } + } + return total; +} + +static void PrintPreflight(const ProbePreflight_t *preflight) +{ + printf("SvProbe: PREFLIGHT owner_acquired=%s normal_tasks_paused=%s " + "initial_drain_bytes=%lu cleanup_ack=%s " + "stale_frames_during_cleanup=%lu bad_len=%lu bad_crc=%lu " + "resync_non_marker=%lu post_cleanup_drain_bytes=%lu " + "begin_save_ack=%s\n", + preflight->owner_acquired ? "yes" : "no", + (preflight->pause.tx_paused && preflight->pause.rx_paused) ? "yes" : "no", + (unsigned long)preflight->initial_drain_bytes, + preflight->cleanup_ack ? "yes" : "no", + (unsigned long)preflight->stale_frames_during_cleanup, + (unsigned long)preflight->bad_payload_len, + (unsigned long)preflight->bad_crc, + (unsigned long)preflight->skipped_non_marker, + (unsigned long)preflight->post_cleanup_drain_bytes, + preflight->begin_save_ack ? "yes" : "no"); +} + +static bool RunPreflight(const ProbeConfig_t *cfg, + ProbeReader_t *reader, + ProbeFailure_t *failure, + ProbePreflight_t *preflight) +{ + memset(preflight, 0, sizeof(*preflight)); + printf("SvProbe: preflight_start\n"); + + preflight->owner_acquired = + FPGA_UartOwnerAcquire(pdMS_TO_TICKS(kProbeOwnerAcquireMs)); + if (!preflight->owner_acquired) { + PrintPreflight(preflight); + return Fail(failure, reader, NULL, "state_port_owner_unavailable", + NULL, 0u, 0u, 0u, esp_timer_get_time()); + } + + if (!cfg->boot_mode) { + preflight->pause = PauseNormalUartTasks(); + } + FPGA_Rx_ResetParser(); + ReaderReset(reader); + preflight->initial_drain_bytes = DrainUartRxForMs(kProbePreflightDrainMs); + ReaderReset(reader); + + const uint32_t bad_len0 = reader->bad_payload_len; + const uint32_t bad_crc0 = reader->bad_crc; + const uint32_t skipped0 = reader->skipped_non_marker; + const bool cleanup_ok = + SendEndSessionCleanup(cfg, reader, "PREFLIGHT END_SESSION"); + preflight->cleanup_ack = cleanup_ok; + preflight->bad_payload_len = reader->bad_payload_len - bad_len0; + preflight->bad_crc = reader->bad_crc - bad_crc0; + preflight->skipped_non_marker = reader->skipped_non_marker - skipped0; + preflight->post_cleanup_drain_bytes = + DrainUartRxForMs(kProbePostCleanupDrainMs); + ReaderReset(reader); + + if (!cleanup_ok) { + PrintPreflight(preflight); + return Fail(failure, reader, NULL, "preflight_cleanup_failed", + NULL, 0u, 0u, 0u, esp_timer_get_time()); + } + + FusionV2Frame_t begin; + if (!BuildBeginSave(&begin) || !SendFrame(cfg, "BEGIN_SAVE", &begin)) { + PrintPreflight(preflight); + return false; + } + const int64_t t0 = esp_timer_get_time(); + preflight->begin_save_ack = + ExpectCtl(cfg, reader, failure, NULL, + (uint8_t)kFusionOp_AckAccepted, + (uint8_t)kFusionOp_BeginSave, + "BEGIN_SAVE", 0u, 0u, 0u, t0); + PrintPreflight(preflight); + return preflight->begin_save_ack; +} + +static bool RunProbe(const ProbeConfig_t *cfg) +{ + ProbeReader_t reader; + ProbeFailure_t failure; + ProbePreflight_t preflight; + ReaderReset(&reader); + memset(&failure, 0, sizeof(failure)); + memset(&preflight, 0, sizeof(preflight)); + + printf("SvProbe: START mode=%s reduced_logging=%s flash_writes=no " + "rx_driver_old=%u rx_driver_new=%u local_rx_cache=%u " + "capture_task=%s task_prio=%u core=%d wram_chunk=%u " + "wram_gap_ms=%lu wram_read_next=%s read_window=%u " + "read_window_gap_ms=%u\n", + cfg->mode_name, + cfg->reduced_logging ? "yes" : "no", + (unsigned)kProbeOldRxBufBytes, + (unsigned)kFPGA_RxConsts_BufferSize, + (unsigned)kProbeRxCacheBytes, + cfg->capture_task ? "yes" : "no", + (unsigned)uxTaskPriorityGet(NULL), + xPortGetCoreID(), + (unsigned)cfg->wram_chunk_bytes, + (unsigned long)cfg->wram_chunk_gap_ms, + cfg->wram_read_next ? "yes" : "no", + (unsigned)kProbeReadWindowPackets, + (unsigned)kProbeReadWindowGapMs); + + PwrMgr_IdleTimerSuspend(); + bool ok = RunPreflight(cfg, &reader, &failure, &preflight); + if (ok) { + printf("SvProbe: save session active\n"); + vTaskDelay(pdMS_TO_TICKS(50)); + } + + for (size_t i = 0; ok && i < kProbeRegionCount; ++i) { + const ProbeRegion_t *previous = (i == 0u) ? NULL : &kRegions[i - 1u]; + if (kRegions[i].region == 0x0Cu && cfg->wram_read_next) { + ok = ReadWramReadNext(cfg, &reader, &failure, previous, &kRegions[i]); + } else if (kRegions[i].region == 0x0Cu && + cfg->wram_chunk_bytes > 0u && + cfg->wram_chunk_bytes < kRegions[i].length) { + ok = ReadWramChunked(cfg, &reader, &failure, previous, &kRegions[i]); + } else { + ok = ReadRegion(cfg, &reader, &failure, previous, &kRegions[i]); + } + if (ok) { + vTaskDelay(pdMS_TO_TICKS(kProbeInterRegionMs)); + } + } + + bool cleanup_ok = false; + if (preflight.owner_acquired) { + printf("SvProbe: sending END_SESSION cleanup\n"); + vTaskDelay(pdMS_TO_TICKS(kProbeFinalEndMs)); + cleanup_ok = SendEndSessionCleanup(cfg, &reader, "END_SESSION cleanup"); + failure.final_end_ack = cleanup_ok; + ok = ok && cleanup_ok; + } + + if (!cfg->boot_mode) { + ResumeNormalUartTasks(); + } + if (preflight.owner_acquired) { + FPGA_UartOwnerRelease(); + } + PwrMgr_IdleTimerResume(); + + printf("SvProbe: RESULT %s mode=%s cleanup_ack=%s resync_non_marker=%lu " + "bad_len=%lu bad_crc=%lu heap_free=%u heap_min=%u\n", + ok ? "PASS" : "FAIL", + cfg->mode_name, + cleanup_ok ? "yes" : "no", + (unsigned long)reader.skipped_non_marker, + (unsigned long)reader.bad_payload_len, + (unsigned long)reader.bad_crc, + (unsigned)heap_caps_get_free_size(MALLOC_CAP_8BIT), + (unsigned)heap_caps_get_minimum_free_size(MALLOC_CAP_8BIT)); + return ok; +} + +bool FusionSavestateTransportProbe_RunRepl(bool reduced_logging) +{ + const ProbeConfig_t cfg = { + .mode_name = reduced_logging ? "repl-quiet" : "repl", + .reduced_logging = reduced_logging, + .boot_mode = false, + .capture_task = false, + .wram_chunk_bytes = (uint16_t)kProbeWramChunkBytes, + .wram_chunk_gap_ms = kProbeWramChunkGapMs, + }; + return RunProbe(&cfg); +} + +static void ProbeCaptureTask(void *arg) +{ + ProbeTaskRequest_t *request = (ProbeTaskRequest_t *)arg; + request->ok = true; + for (uint8_t i = 0u; i < request->repeat_count; ++i) { + if (request->repeat_count > 1u) { + printf("SvProbe: REPEAT %u/%u\n", + (unsigned)(i + 1u), + (unsigned)request->repeat_count); + } + if (!RunProbe(&request->cfg)) { + request->ok = false; + break; + } + vTaskDelay(pdMS_TO_TICKS(20)); + } + xTaskNotifyGive(request->caller); + vTaskDelete(NULL); +} + +static bool RunCaptureTask(const char *mode_name, + bool reduced_logging, + bool wram_read_next, + uint16_t wram_chunk_bytes, + uint32_t wram_chunk_gap_ms, + uint8_t repeat_count) +{ + ProbeTaskRequest_t request = { + .cfg = { + .mode_name = mode_name, + .reduced_logging = reduced_logging, + .boot_mode = false, + .capture_task = true, + .wram_read_next = wram_read_next, + .wram_chunk_bytes = wram_chunk_bytes, + .wram_chunk_gap_ms = wram_chunk_gap_ms, + }, + .caller = xTaskGetCurrentTaskHandle(), + .repeat_count = repeat_count, + .ok = false, + }; + + BaseType_t created = xTaskCreatePinnedToCore(ProbeCaptureTask, + "sv_capture", + kProbeCaptureStackBytes, + &request, + kProbeCapturePriority, + NULL, + 0); + if (created != pdPASS) { + printf("SvProbe: failed to create capture task\n"); + return false; + } + (void)ulTaskNotifyTake(pdTRUE, portMAX_DELAY); + return request.ok; +} + +void FusionSavestateTransportProbe_RunBootProbeOnce(void) +{ + printf("SvProbe: boot probe waiting %u ms for FPGA\n", + (unsigned)kProbeBootDelayMs); + vTaskDelay(pdMS_TO_TICKS(kProbeBootDelayMs)); + const ProbeConfig_t cfg = { + .mode_name = "boot-final-only", + .reduced_logging = true, + .boot_mode = true, + .capture_task = false, + .wram_chunk_bytes = (uint16_t)kProbeWramChunkBytes, + .wram_chunk_gap_ms = kProbeWramChunkGapMs, + }; + (void)RunProbe(&cfg); +} + +static int ProbeCommand(int argc, char **argv) +{ + const char *mode_name = "repl"; + bool reduced_logging = false; + bool wram_read_next = false; + uint16_t wram_chunk_bytes = (uint16_t)kProbeWramChunkBytes; + uint32_t wram_chunk_gap_ms = kProbeWramChunkGapMs; + uint8_t repeat_count = 1u; + if (argc >= 2) { + if (strcmp(argv[1], "menu") == 0) { + mode_name = "menu"; + reduced_logging = true; + } else if (strcmp(argv[1], "game") == 0) { + mode_name = "game"; + reduced_logging = true; + } else if (strcmp(argv[1], "quiet") == 0 || strcmp(argv[1], "reduced") == 0) { + mode_name = "repl-quiet"; + reduced_logging = true; + } else if (strcmp(argv[1], "repl") != 0) { + printf("Usage: svprobe [menu|game|repl|quiet] [repeat count|chunk gap_ms|next gap_ms]\n"); + return 1; + } + } + if (argc >= 3 && strcmp(argv[2], "repeat") == 0) { + if (argc != 4) { + printf("Usage: svprobe [menu|game|repl|quiet] repeat count\n"); + return 1; + } + char *end = NULL; + const unsigned long count = strtoul(argv[3], &end, 0); + if (end == argv[3] || *end != '\0' || count == 0u || count > 10u) { + printf("SvProbe: invalid repeat count '%s'\n", argv[3]); + return 1; + } + repeat_count = (uint8_t)count; + } else if (argc >= 3 && strcmp(argv[2], "next") == 0) { + wram_read_next = true; + if (argc >= 4) { + char *end = NULL; + const unsigned long gap = strtoul(argv[3], &end, 0); + if (end == argv[3] || *end != '\0' || gap > 10000u) { + printf("SvProbe: invalid WRAM gap ms '%s'\n", argv[3]); + return 1; + } + wram_chunk_gap_ms = (uint32_t)gap; + } + } else if (argc >= 4) { + char *end = NULL; + const unsigned long chunk = strtoul(argv[2], &end, 0); + if (end == argv[2] || *end != '\0' || + chunk == 0u || chunk > UINT16_MAX || + (32768u % chunk) != 0u) { + printf("SvProbe: invalid WRAM chunk bytes '%s'\n", argv[2]); + return 1; + } + end = NULL; + const unsigned long gap = strtoul(argv[3], &end, 0); + if (end == argv[3] || *end != '\0' || gap > 10000u) { + printf("SvProbe: invalid WRAM gap ms '%s'\n", argv[3]); + return 1; + } + wram_chunk_bytes = (uint16_t)chunk; + wram_chunk_gap_ms = (uint32_t)gap; + } else if (argc == 3) { + printf("Usage: svprobe [menu|game|repl|quiet] [repeat count|chunk gap_ms|next gap_ms]\n"); + return 1; + } + return RunCaptureTask(mode_name, + reduced_logging, + wram_read_next, + wram_chunk_bytes, + wram_chunk_gap_ms, + repeat_count) ? 0 : 1; +} + +void FusionSavestateTransportProbe_RegisterCommands(void) +{ + const esp_console_cmd_t command = { + .command = "svprobe", + .help = "Phase 4.8a read-transport probe: svprobe [menu|game|repl|quiet] [repeat count|chunk gap_ms|next gap_ms]", + .func = &ProbeCommand, + .argtable = NULL, + }; + esp_err_t err = esp_console_cmd_register(&command); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Registering '%s' failed: %s", + command.command, esp_err_to_name(err)); + } +} diff --git a/main/fusion_savestate_transport_probe.h b/main/fusion_savestate_transport_probe.h new file mode 100644 index 0000000..874e70d --- /dev/null +++ b/main/fusion_savestate_transport_probe.h @@ -0,0 +1,23 @@ +#pragma once + +#include + +/* + * Phase 4.8a transport reliability probe. + * + * This is MCU-only and read-only: it captures the Phase 4.7 region matrix + * through the existing state-port read protocol, validates stream integrity, + * and reports detailed transport diagnostics. It does not write flash and it + * never sends BEGIN_LOAD / WRITE_STREAM_BEGIN / WRITE_COMMIT. + * + * Final-only boot probing is available only when the firmware is built with + * FUSION_TRANSPORT_PROBE_BOOT_AUTO. The default production build has no + * auto-run path; use the REPL command instead: + * svprobe menu - read-only capture in menu/flash-cart state + * svprobe game - read-only capture in simple-game state + * svprobe quiet - generic reduced-log capture + */ + +bool FusionSavestateTransportProbe_RunRepl(bool reduced_logging); +void FusionSavestateTransportProbe_RunBootProbeOnce(void); +void FusionSavestateTransportProbe_RegisterCommands(void); diff --git a/main/main.c b/main/main.c index 8c98f78..f07de5d 100644 --- a/main/main.c +++ b/main/main.c @@ -22,6 +22,9 @@ #include "fpga_rx.h" #include "fpga_tx.h" #include "frameblend.h" +#include "fusion_savestate_smoke48a.h" +#include "fusion_savestate_smoke48c.h" +#include "fusion_savestate_transport_probe.h" #include "fw.h" #include "osd.h" #include "osd_default.h" @@ -64,6 +67,12 @@ static spi_device_handle_t spi; static lv_disp_draw_buf_t disp_buf; // contains internal graphic buffer(s) called draw buffer(s) static lv_disp_drv_t disp_drv; // contains callback functions static lv_disp_t *disp; +static TaskHandle_t lvglTimerTaskHandle = NULL; + +TaskHandle_t FusionApp_GetLvglTimerTaskHandle(void) +{ + return lvglTimerTaskHandle; +} // Having some issue with floating point here so scale 9.6 10x #define ROWS_PER_XFER_X10 32 @@ -228,6 +237,7 @@ void app_main(void) { persist_storage_init(); Mutex_Init(); + FPGA_UartOwnerInit(); // Set up the system management UART to/from the FPGA ESP_LOGI(TAG, "Initialize FPGA UART"); @@ -247,6 +257,13 @@ void app_main(void) gpio_sleep_set_direction(PIN_NUM_UART_FROM_FPGA, GPIO_MODE_INPUT); gpio_sleep_set_pull_mode(PIN_NUM_UART_FROM_FPGA, GPIO_PULLUP_ONLY); +#if defined(FUSION_TRANSPORT_PROBE_BOOT_AUTO) + FusionSavestateTransportProbe_RunBootProbeOnce(); + while (1) { + vTaskDelay(pdMS_TO_TICKS(1000)); + } +#endif + // Task is created earlier than the others to apply the settings ASAP xTaskCreate(FPGA_TxTask, "fpga_tx_task", kFPGATxTask_StackDepth, NULL, kFPGATxTask_Priority, FPGA_GetTxTaskHandle()); @@ -333,7 +350,7 @@ void app_main(void) Gfx_Start(scr); - xTaskCreatePinnedToCore(lvglTimerTask, "lvgl Timer", kTimerTask_StackDepth, NULL, 4, NULL, 1); + xTaskCreatePinnedToCore(lvglTimerTask, "lvgl Timer", kTimerTask_StackDepth, NULL, 4, &lvglTimerTaskHandle, 1); // Prompt to be printed before each line. ReplConfig.prompt = "mcu> "; @@ -344,9 +361,19 @@ void app_main(void) ESP_ERROR_CHECK(esp_console_new_repl_uart(&ReplHWConfig, &ReplConfig, &pRepl)); esp_console_register_help_command(); + FusionSavestateSmoke48a_RegisterCommands(); + FusionSavestateSmoke48c_RegisterCommands(); + FusionSavestateTransportProbe_RegisterCommands(); // All commands must be registered prior to starting the REPL ESP_ERROR_CHECK(esp_console_start_repl(pRepl)); + /* + * Path-e (4.8d) chord-triggered save/load dispatcher. Listens for + * MENU+DOWN/UP chord events from the button RX path and dispatches + * the corresponding QuickSave / QuickLoad on its own task. + */ + FusionSavestate_StartChordDispatcher(); + vTaskDelay( pdMS_TO_TICKS(2000) ); xTaskCreate(PwrMgr_Task, "pwr_mgr_task", kSleepTask_StackDepth, NULL, kSleepTask_Priority, NULL); } diff --git a/main/pwrmgr.c b/main/pwrmgr.c index 439241c..aaf8602 100644 --- a/main/pwrmgr.c +++ b/main/pwrmgr.c @@ -15,6 +15,7 @@ #include "osd.h" #include +#include enum { kIdleTime_ms = 100, @@ -29,6 +30,9 @@ static const char *TAG = "PwrMgr"; static TaskHandle_t PwrMgrTaskHandle = NULL; static TimerHandle_t IdleSystemTimer = NULL; static StaticTimer_t IdleTimerBuffer; +static volatile bool IdleTimerSuspended = false; +static volatile uint32_t IdleActivityGeneration = 0; +static volatile uint32_t IdleSleepRequestGeneration = 0; static LowPowerMode_t LPM_Status; static SemaphoreHandle_t xSemaphore; static StaticSemaphore_t xMutexBuffer; @@ -44,6 +48,22 @@ static const gpio_config_t WakeUpPin = { static void IdleTimerCB( TimerHandle_t xTimer ); +static bool ShouldAbortSleep(uint32_t sleep_request_generation) +{ + return IdleTimerSuspended || IdleActivityGeneration != sleep_request_generation; +} + +static bool AbortSleepIfCanceled(uint32_t sleep_request_generation) +{ + if (!ShouldAbortSleep(sleep_request_generation)) + { + return false; + } + FPGA_Rx_Resume(); + FPGA_Tx_Resume(); + return true; +} + void PwrMgr_Task(void *arg) { (void)arg; @@ -66,10 +86,20 @@ void PwrMgr_Task(void *arg) { if (ulTaskNotifyTake(pdTRUE, portMAX_DELAY)) { + const uint32_t sleep_request_generation = IdleSleepRequestGeneration; + if (ShouldAbortSleep(sleep_request_generation)) + { + continue; + } + // Pause both tasks to ensure that they finish up any work in progress. Since the Sleep task is of higher priority, // the delays below are taken advantage of to permit a context switch. FPGA_Tx_Pause(); FPGA_Rx_Pause(); + if (AbortSleepIfCanceled(sleep_request_generation)) + { + continue; + } // Clear out any pending data that the FPGA may have sent uart_flush(UART_NUM_1); @@ -80,8 +110,17 @@ void PwrMgr_Task(void *arg) gpio_config(&WakeUpPin); gpio_wakeup_enable(PIN_NUM_UART_FROM_FPGA, GPIO_INTR_LOW_LEVEL); vTaskDelay(pdMS_TO_TICKS(10)); + if (AbortSleepIfCanceled(sleep_request_generation)) + { + continue; + } + esp_sleep_enable_gpio_wakeup(); vTaskDelay(pdMS_TO_TICKS(10)); + if (AbortSleepIfCanceled(sleep_request_generation)) + { + continue; + } // Tell the world we are sleeping and wait for the message to go out... ESP_LOGD(TAG, "Sleeping"); @@ -104,17 +143,25 @@ void PwrMgr_Task(void *arg) void PwrMgr_TriggerLightSleep(void) { + if (IdleTimerSuspended) + { + return; + } + // Wait for the sleep task to start while(PwrMgrTaskHandle == NULL) { vTaskDelay(1); } + IdleSleepRequestGeneration = IdleActivityGeneration; (void) xTaskNotifyGive(PwrMgrTaskHandle); } void PwrMgr_IdleTimerPet(void) { + IdleActivityGeneration++; + // Wait for the task to start if (IdleSystemTimer == NULL) { @@ -124,6 +171,25 @@ void PwrMgr_IdleTimerPet(void) (void) xTimerReset(IdleSystemTimer, portMAX_DELAY); } +void PwrMgr_IdleTimerSuspend(void) +{ + IdleTimerSuspended = true; + if (IdleSystemTimer != NULL) + { + (void) xTimerStop(IdleSystemTimer, portMAX_DELAY); + } + if (PwrMgrTaskHandle != NULL) + { + (void) xTaskNotifyGive(PwrMgrTaskHandle); + } +} + +void PwrMgr_IdleTimerResume(void) +{ + IdleTimerSuspended = false; + PwrMgr_IdleTimerPet(); +} + static void IdleTimerCB( TimerHandle_t xTimer ) { (void)xTimer; diff --git a/main/pwrmgr.h b/main/pwrmgr.h index 937e196..cd41a7d 100644 --- a/main/pwrmgr.h +++ b/main/pwrmgr.h @@ -5,5 +5,7 @@ void PwrMgr_Task(void *arg); void PwrMgr_TriggerLightSleep(void); void PwrMgr_IdleTimerPet(void); +void PwrMgr_IdleTimerSuspend(void); +void PwrMgr_IdleTimerResume(void); bool PwrMgr_IsLPMActive(void); void PwrMgr_SetLPM(const bool Active); diff --git a/main/savestate_storage.c b/main/savestate_storage.c new file mode 100644 index 0000000..a4bfc2b --- /dev/null +++ b/main/savestate_storage.c @@ -0,0 +1,585 @@ +#include "savestate_storage.h" + +#if defined(ESP_PLATFORM) +#include "esp_partition.h" +#endif + +#include + +/* + * Savestate storage layer — implementation. + * + * The mock backend is used by host tests. ESP-IDF builds can also use a + * single custom data partition for persistent smoke-test slots. + */ + +static FusionStorageResult_t WriteMock(FusionStorage_t *s, + uint32_t offset, + const uint8_t *data, + uint32_t length) +{ + if (s == NULL || s->kind != kFusionStorageBackend_Mock) { + return kFusionStorage_NotInitialized; + } + if (offset + length > s->mock_size) { + return kFusionStorage_OutOfSpace; + } + memcpy(&s->mock_backing[offset], data, length); + return kFusionStorage_Ok; +} + +static FusionStorageResult_t ReadMock(const FusionStorage_t *s, + uint32_t offset, + uint8_t *data, + uint32_t length) +{ + if (s == NULL || s->kind != kFusionStorageBackend_Mock) { + return kFusionStorage_NotInitialized; + } + if (offset + length > s->mock_size) { + return kFusionStorage_OutOfSpace; + } + memcpy(data, &s->mock_backing[offset], length); + return kFusionStorage_Ok; +} + +static FusionStorageResult_t WriteStorage(FusionStorage_t *s, + uint32_t offset, + const uint8_t *data, + uint32_t length) +{ + if (s == NULL || data == NULL) { + return kFusionStorage_BadArg; + } + if (s->kind == kFusionStorageBackend_Mock) { + return WriteMock(s, offset, data, length); + } +#if defined(ESP_PLATFORM) + if (s->kind == kFusionStorageBackend_Partition) { + const esp_partition_t *partition = (const esp_partition_t *)s->partition; + if (partition == NULL) { + return kFusionStorage_NotInitialized; + } + if (offset + length > partition->size) { + return kFusionStorage_OutOfSpace; + } + return (esp_partition_write(partition, offset, data, length) == ESP_OK) + ? kFusionStorage_Ok + : kFusionStorage_WrongState; + } +#endif + return kFusionStorage_NotInitialized; +} + +static FusionStorageResult_t ReadStorage(const FusionStorage_t *s, + uint32_t offset, + uint8_t *data, + uint32_t length) +{ + if (s == NULL || data == NULL) { + return kFusionStorage_BadArg; + } + if (s->kind == kFusionStorageBackend_Mock) { + return ReadMock(s, offset, data, length); + } +#if defined(ESP_PLATFORM) + if (s->kind == kFusionStorageBackend_Partition) { + const esp_partition_t *partition = (const esp_partition_t *)s->partition; + if (partition == NULL) { + return kFusionStorage_NotInitialized; + } + if (offset + length > partition->size) { + return kFusionStorage_OutOfSpace; + } + return (esp_partition_read(partition, offset, data, length) == ESP_OK) + ? kFusionStorage_Ok + : kFusionStorage_WrongState; + } +#endif + return kFusionStorage_NotInitialized; +} + +static FusionStorageResult_t EraseStorage(FusionStorage_t *s, + uint32_t offset, + uint32_t length) +{ + if (s == NULL) { + return kFusionStorage_BadArg; + } + if (s->kind == kFusionStorageBackend_Mock) { + if (offset + length > s->mock_size) { + return kFusionStorage_OutOfSpace; + } + memset(&s->mock_backing[offset], 0xFFu, length); + return kFusionStorage_Ok; + } +#if defined(ESP_PLATFORM) + if (s->kind == kFusionStorageBackend_Partition) { + const esp_partition_t *partition = (const esp_partition_t *)s->partition; + if (partition == NULL) { + return kFusionStorage_NotInitialized; + } + if (offset + length > partition->size) { + return kFusionStorage_OutOfSpace; + } + return (esp_partition_erase_range(partition, offset, length) == ESP_OK) + ? kFusionStorage_Ok + : kFusionStorage_WrongState; + } +#endif + return kFusionStorage_NotInitialized; +} + +static FusionStorageResult_t ComputeCrc(FusionStorage_t *s, + uint32_t offset, + uint32_t length, + uint32_t *crc_out) +{ + if (s == NULL || crc_out == NULL) { + return kFusionStorage_BadArg; + } + if (length == 0u) { + *crc_out = 0u; + return kFusionStorage_Ok; + } + if (s->kind == kFusionStorageBackend_Mock) { + if (offset + length > s->mock_size) { + return kFusionStorage_OutOfSpace; + } + *crc_out = FusionSavestate_Crc32(&s->mock_backing[offset], length); + return kFusionStorage_Ok; + } + + uint32_t crc = 0u; + uint8_t buf[256]; + uint32_t pos = 0u; + while (pos < length) { + const uint32_t chunk = ((length - pos) > sizeof(buf)) + ? (uint32_t)sizeof(buf) + : (length - pos); + FusionStorageResult_t r = ReadStorage(s, offset + pos, buf, chunk); + if (r != kFusionStorage_Ok) { + return r; + } + crc = FusionSavestate_Crc32Update(crc, buf, chunk); + pos += chunk; + } + *crc_out = crc; + return kFusionStorage_Ok; +} + +FusionStorageResult_t FusionStorage_InitMock(FusionStorage_t *s, + FusionStorageSlot_t *slots, + uint32_t slot_count, + uint8_t *backing, + uint32_t backing_size) +{ + if (s == NULL || slots == NULL || backing == NULL) { + return kFusionStorage_BadArg; + } + if (slot_count == 0u) { + return kFusionStorage_BadArg; + } + if (backing_size < slot_count * sizeof(FusionStateHeader_t)) { + return kFusionStorage_OutOfSpace; + } + + memset(s, 0, sizeof(*s)); + s->kind = kFusionStorageBackend_Mock; + s->mock_backing = backing; + s->mock_size = backing_size; + s->slot_count = slot_count; + s->slot_size = backing_size / slot_count; + s->slots = slots; + s->active_slot = -1; + s->initialized = true; + + /* Pre-erase all slots so commit_state reads as Erased. */ + memset(backing, 0xFFu, backing_size); + for (uint32_t i = 0; i < slot_count; ++i) { + s->slots[i].slot_offset = i * s->slot_size; + s->slots[i].slot_size = s->slot_size; + s->slots[i].header_offset = i * s->slot_size; + s->slots[i].payload_offset = i * s->slot_size + (uint32_t)sizeof(FusionStateHeader_t); + s->slots[i].bytes_used = (uint32_t)sizeof(FusionStateHeader_t); + s->slots[i].write_in_progress = false; + } + return kFusionStorage_Ok; +} + +FusionStorageResult_t FusionStorage_InitFromPartition(FusionStorage_t *s, + const char *partition_label) +{ +#if defined(ESP_PLATFORM) + if (s == NULL || partition_label == NULL) { + return kFusionStorage_BadArg; + } + + const esp_partition_t *partition = + esp_partition_find_first(ESP_PARTITION_TYPE_DATA, + ESP_PARTITION_SUBTYPE_ANY, + partition_label); + if (partition == NULL) { + return kFusionStorage_NotImplemented; + } + if (partition->size < (FUSION_STORAGE_DEFAULT_SLOTS * sizeof(FusionStateHeader_t))) { + return kFusionStorage_OutOfSpace; + } + + memset(s, 0, sizeof(*s)); + s->kind = kFusionStorageBackend_Partition; + s->partition = partition; + s->slot_count = FUSION_STORAGE_DEFAULT_SLOTS; + s->slot_size = partition->size / FUSION_STORAGE_DEFAULT_SLOTS; + s->slots = s->partition_slots; + s->active_slot = -1; + s->initialized = true; + + for (uint32_t i = 0u; i < s->slot_count; ++i) { + s->slots[i].slot_offset = i * s->slot_size; + s->slots[i].slot_size = s->slot_size; + s->slots[i].header_offset = i * s->slot_size; + s->slots[i].payload_offset = i * s->slot_size + (uint32_t)sizeof(FusionStateHeader_t); + s->slots[i].bytes_used = (uint32_t)sizeof(FusionStateHeader_t); + s->slots[i].write_in_progress = false; + } + return kFusionStorage_Ok; +#else + (void)s; + (void)partition_label; + return kFusionStorage_NotImplemented; +#endif +} + +static int32_t FindInactiveSlot(const FusionStorage_t *s) +{ + if (s->slot_count == 0u) { + return -1; + } + /* + * Pick the slot with the lowest commit_generation among slots whose + * commit_state is not Valid. If everything is Valid, pick the oldest + * generation (the one we want to overwrite). This mirrors the + * "newest-valid wins" load-time rule. + */ + int32_t pick = -1; + uint32_t pick_gen = 0u; + for (uint32_t i = 0; i < s->slot_count; ++i) { + FusionStateHeader_t h; + if (ReadStorage(s, s->slots[i].header_offset, (uint8_t *)&h, sizeof(h)) != kFusionStorage_Ok) { + continue; + } + if (h.magic != FUSION_SAVESTATE_MAGIC || h.commit_state != (uint8_t)kFusionCommit_Valid) { + return (int32_t)i; + } + if (pick < 0 || h.commit_generation < pick_gen) { + pick = (int32_t)i; + pick_gen = h.commit_generation; + } + } + return pick; +} + +FusionStorageResult_t FusionStorage_BeginInactiveSlotWrite(FusionStorage_t *s) +{ + if (s == NULL || !s->initialized) { + return kFusionStorage_NotInitialized; + } + if (s->active_slot >= 0) { + return kFusionStorage_WrongState; + } + const int32_t idx = FindInactiveSlot(s); + if (idx < 0) { + return kFusionStorage_OutOfSpace; + } + /* Erase the slot and mark in-progress. */ + FusionStorageResult_t r = EraseStorage(s, s->slots[idx].slot_offset, s->slots[idx].slot_size); + if (r != kFusionStorage_Ok) { + return r; + } + s->slots[idx].bytes_used = (uint32_t)sizeof(FusionStateHeader_t); + s->slots[idx].write_in_progress = true; + s->active_slot = idx; + s->has_staged_header = false; + return kFusionStorage_Ok; +} + +FusionStorageResult_t FusionStorage_AppendRegionPayload(FusionStorage_t *s, + const uint8_t *data, + uint32_t length) +{ + if (s == NULL || !s->initialized || data == NULL) { + return kFusionStorage_BadArg; + } + if (s->active_slot < 0) { + return kFusionStorage_NoActiveSlot; + } + FusionStorageSlot_t *slot = &s->slots[s->active_slot]; + if (slot->bytes_used + length > slot->slot_size) { + return kFusionStorage_OutOfSpace; + } + const uint32_t offset = slot->slot_offset + slot->bytes_used; + const FusionStorageResult_t r = WriteStorage(s, offset, data, length); + if (r != kFusionStorage_Ok) { + return r; + } + slot->bytes_used += length; + return kFusionStorage_Ok; +} + +FusionStorageResult_t FusionStorage_StageHeader(FusionStorage_t *s, + FusionStateHeader_t *header, + const FusionRegionDirectory_t *regions, + uint32_t region_count) +{ + if (s == NULL || header == NULL) { + return kFusionStorage_BadArg; + } + if (s->active_slot < 0) { + return kFusionStorage_NoActiveSlot; + } + if (region_count > 0u && regions == NULL) { + return kFusionStorage_BadArg; + } + + FusionStorageSlot_t *slot = &s->slots[s->active_slot]; + + /* Write region directory at end of payload. */ + const uint32_t directory_bytes = region_count * (uint32_t)sizeof(FusionRegionDirectory_t); + const uint32_t directory_offset_in_slot = slot->bytes_used; + if (directory_offset_in_slot + directory_bytes > slot->slot_size) { + return kFusionStorage_OutOfSpace; + } + if (region_count > 0u) { + const FusionStorageResult_t r = WriteStorage(s, + slot->slot_offset + directory_offset_in_slot, + (const uint8_t *)regions, + directory_bytes); + if (r != kFusionStorage_Ok) { + return r; + } + slot->bytes_used += directory_bytes; + } + + /* Compute payload+directory CRC for the header. */ + const uint32_t payload_start_in_slot = (uint32_t)sizeof(FusionStateHeader_t); + const uint32_t payload_bytes = slot->bytes_used - payload_start_in_slot; + uint32_t crc = 0u; + FusionStorageResult_t r = ComputeCrc(s, + slot->slot_offset + payload_start_in_slot, + payload_bytes, + &crc); + if (r != kFusionStorage_Ok) { + return r; + } + + header->magic = FUSION_SAVESTATE_MAGIC; + header->format_version = (uint16_t)FUSION_SAVESTATE_FORMAT_VERSION; + header->header_size = (uint16_t)sizeof(FusionStateHeader_t); + header->total_size = slot->bytes_used; + header->region_table_offset = directory_offset_in_slot; + header->region_count = region_count; + header->payload_crc32 = crc; + header->commit_state = (uint8_t)kFusionCommit_Writing; + + if (s->kind == kFusionStorageBackend_Partition) { + s->staged_header = *header; + s->has_staged_header = true; + return kFusionStorage_Ok; + } + + r = WriteStorage(s, + slot->header_offset, + (const uint8_t *)header, + (uint32_t)sizeof(*header)); + if (r == kFusionStorage_Ok) { + s->staged_header = *header; + s->has_staged_header = true; + } + return r; +} + +FusionStorageResult_t FusionStorage_VerifyAndCommit(FusionStorage_t *s) +{ + if (s == NULL || !s->initialized) { + return kFusionStorage_NotInitialized; + } + if (s->active_slot < 0) { + return kFusionStorage_NoActiveSlot; + } + FusionStorageSlot_t *slot = &s->slots[s->active_slot]; + + FusionStateHeader_t hdr; + FusionStorageResult_t r = kFusionStorage_Ok; + if (s->kind == kFusionStorageBackend_Partition) { + if (!s->has_staged_header) { + return kFusionStorage_WrongState; + } + hdr = s->staged_header; + } else { + r = ReadStorage(s, slot->header_offset, (uint8_t *)&hdr, sizeof(hdr)); + if (r != kFusionStorage_Ok) { + return r; + } + } + if (hdr.magic != FUSION_SAVESTATE_MAGIC) { + return kFusionStorage_BadHeader; + } + if (hdr.commit_state != (uint8_t)kFusionCommit_Writing) { + return kFusionStorage_WrongState; + } + + const uint32_t payload_start = (uint32_t)sizeof(FusionStateHeader_t); + const uint32_t payload_bytes = slot->bytes_used - payload_start; + uint32_t crc = 0u; + r = ComputeCrc(s, slot->slot_offset + payload_start, payload_bytes, &crc); + if (r != kFusionStorage_Ok) { + return r; + } + if (crc != hdr.payload_crc32) { + return kFusionStorage_BadCrc; + } + + /* + * Mock keeps the original two-step byte flip. Partition writes the header + * once, after payload+directory verify, so erased/partial headers never + * validate after power loss. + */ + hdr.commit_state = (uint8_t)kFusionCommit_Valid; + r = WriteStorage(s, slot->header_offset, (const uint8_t *)&hdr, (uint32_t)sizeof(hdr)); + if (r != kFusionStorage_Ok) { + return r; + } + slot->write_in_progress = false; + s->active_slot = -1; + s->has_staged_header = false; + return kFusionStorage_Ok; +} + +FusionStorageResult_t FusionStorage_AbortSlotWrite(FusionStorage_t *s) +{ + if (s == NULL || !s->initialized) { + return kFusionStorage_NotInitialized; + } + if (s->active_slot < 0) { + return kFusionStorage_NoActiveSlot; + } + FusionStorageSlot_t *slot = &s->slots[s->active_slot]; + FusionStateHeader_t hdr; + if (ReadStorage(s, slot->header_offset, (uint8_t *)&hdr, sizeof(hdr)) == kFusionStorage_Ok) { + if (hdr.magic == FUSION_SAVESTATE_MAGIC) { + hdr.commit_state = (uint8_t)kFusionCommit_Invalid; + (void)WriteStorage(s, slot->header_offset, (const uint8_t *)&hdr, (uint32_t)sizeof(hdr)); + } + } + slot->write_in_progress = false; + s->active_slot = -1; + s->has_staged_header = false; + return kFusionStorage_Ok; +} + +FusionStorageResult_t FusionStorage_InvalidateSlot(FusionStorage_t *s, + uint32_t slot_index) +{ + if (s == NULL || !s->initialized) { + return kFusionStorage_NotInitialized; + } + if (slot_index >= s->slot_count) { + return kFusionStorage_BadArg; + } + + FusionStorageSlot_t *slot = &s->slots[slot_index]; + FusionStateHeader_t hdr; + FusionStorageResult_t r = + ReadStorage(s, slot->header_offset, (uint8_t *)&hdr, sizeof(hdr)); + if (r != kFusionStorage_Ok) { + return r; + } + + hdr.magic = 0u; + hdr.commit_state = (uint8_t)kFusionCommit_Invalid; + r = WriteStorage(s, slot->header_offset, (const uint8_t *)&hdr, (uint32_t)sizeof(hdr)); + if (r != kFusionStorage_Ok) { + return r; + } + if (s->active_slot == (int32_t)slot_index) { + slot->write_in_progress = false; + s->active_slot = -1; + s->has_staged_header = false; + } + return kFusionStorage_Ok; +} + +FusionStorageResult_t FusionStorage_FindNewestValidSlot(FusionStorage_t *s, + uint32_t *slot_index_out, + uint32_t *generation_out) +{ + if (s == NULL || !s->initialized) { + return kFusionStorage_NotInitialized; + } + int32_t best = -1; + uint32_t best_gen = 0u; + for (uint32_t i = 0; i < s->slot_count; ++i) { + FusionStateHeader_t h; + if (ReadStorage(s, s->slots[i].header_offset, (uint8_t *)&h, sizeof(h)) != kFusionStorage_Ok) { + continue; + } + if (h.magic != FUSION_SAVESTATE_MAGIC) { + continue; + } + if (h.commit_state != (uint8_t)kFusionCommit_Valid) { + continue; + } + if (best < 0 || h.commit_generation > best_gen) { + best = (int32_t)i; + best_gen = h.commit_generation; + } + } + if (best < 0) { + return kFusionStorage_NoValidSlot; + } + if (slot_index_out != NULL) { + *slot_index_out = (uint32_t)best; + } + if (generation_out != NULL) { + *generation_out = best_gen; + } + return kFusionStorage_Ok; +} + +FusionStorageResult_t FusionStorage_ReadSlotHeader(FusionStorage_t *s, + uint32_t slot_index, + FusionStateHeader_t *header_out) +{ + if (s == NULL || !s->initialized || header_out == NULL) { + return kFusionStorage_BadArg; + } + if (slot_index >= s->slot_count) { + return kFusionStorage_BadArg; + } + return ReadStorage(s, + s->slots[slot_index].header_offset, + (uint8_t *)header_out, + (uint32_t)sizeof(*header_out)); +} + +FusionStorageResult_t FusionStorage_ReadSlotBytes(FusionStorage_t *s, + uint32_t slot_index, + uint32_t offset, + uint8_t *data, + uint32_t length) +{ + if (s == NULL || !s->initialized || data == NULL) { + return kFusionStorage_BadArg; + } + if (slot_index >= s->slot_count) { + return kFusionStorage_BadArg; + } + if (offset + length > s->slots[slot_index].slot_size) { + return kFusionStorage_OutOfSpace; + } + return ReadStorage(s, + s->slots[slot_index].slot_offset + offset, + data, + length); +} diff --git a/main/savestate_storage.h b/main/savestate_storage.h new file mode 100644 index 0000000..675d63e --- /dev/null +++ b/main/savestate_storage.h @@ -0,0 +1,144 @@ +#pragma once + +#include "fusion_savestate.h" + +#include +#include +#include + +/* + * Savestate storage abstraction: header struct, region directory, + * and the two-phase commit API shape required by the architecture + * doc. + * + * Provides a pure in-memory mock backend for host self-tests and an + * ESP-IDF custom data-partition backend for MCU persistence smoke tests. + */ + +#ifdef __cplusplus +extern "C" { +#endif + +typedef enum { + kFusionStorage_Ok = 0, + kFusionStorage_BadArg, + kFusionStorage_NotInitialized, + kFusionStorage_NotImplemented, /* requested partition/backend not found */ + kFusionStorage_OutOfSpace, + kFusionStorage_NoActiveSlot, + kFusionStorage_NoValidSlot, + kFusionStorage_BadHeader, + kFusionStorage_BadCrc, + kFusionStorage_WrongState, +} FusionStorageResult_t; + +typedef enum { + kFusionStorageBackend_None = 0, + kFusionStorageBackend_Mock, /* in-RAM, used by host tests */ + kFusionStorageBackend_Partition, /* ESP-IDF custom data partition */ +} FusionStorageBackendKind_t; + +#define FUSION_STORAGE_DEFAULT_SLOTS 2u + +typedef struct { + uint32_t slot_offset; /* offset into backing store */ + uint32_t slot_size; + uint32_t header_offset; /* relative to backing store */ + uint32_t payload_offset; /* where region payload begins */ + uint32_t bytes_used; + bool write_in_progress; +} FusionStorageSlot_t; + +typedef struct { + FusionStorageBackendKind_t kind; + uint8_t *mock_backing; /* mock backend only; otherwise NULL */ + uint32_t mock_size; + const void *partition; /* ESP-IDF esp_partition_t*, partition backend only */ + + uint32_t slot_count; + uint32_t slot_size; + + FusionStorageSlot_t *slots; /* length == slot_count */ + FusionStorageSlot_t partition_slots[FUSION_STORAGE_DEFAULT_SLOTS]; + FusionStateHeader_t staged_header; + bool has_staged_header; + int32_t active_slot; /* slot being staged, -1 if none */ + bool initialized; +} FusionStorage_t; + +/* + * Initialize a mock backend over caller-provided RAM. Backing memory is + * partitioned into N equal slots; each slot fits a header + region + * payload + region directory. + */ +FusionStorageResult_t FusionStorage_InitMock(FusionStorage_t *s, + FusionStorageSlot_t *slots, + uint32_t slot_count, + uint8_t *backing, + uint32_t backing_size); + +/* + * Initialize an ESP-IDF custom data partition backend. Non-ESP host builds + * return kFusionStorage_NotImplemented. + */ +FusionStorageResult_t FusionStorage_InitFromPartition(FusionStorage_t *s, + const char *partition_label); + +/* + * Reserve the inactive slot for a new write. Marks the slot in-progress + * and clears the staged header byte (commit_state = Erased). + */ +FusionStorageResult_t FusionStorage_BeginInactiveSlotWrite(FusionStorage_t *s); + +/* Append region payload bytes into the active staged slot. */ +FusionStorageResult_t FusionStorage_AppendRegionPayload(FusionStorage_t *s, + const uint8_t *data, + uint32_t length); + +/* + * Stage the on-disk header in 'Writing' state, plus the region directory + * entries that the caller has built. CRC32 over payload+directory is + * recomputed by the storage layer for consistency with later + * VerifyAndCommit. + */ +FusionStorageResult_t FusionStorage_StageHeader(FusionStorage_t *s, + FusionStateHeader_t *header, + const FusionRegionDirectory_t *regions, + uint32_t region_count); + +/* + * Re-read the staged header + payload, recompute CRC32, and atomically + * flip commit_state from 'Writing' to 'Valid' if it matches. + */ +FusionStorageResult_t FusionStorage_VerifyAndCommit(FusionStorage_t *s); + +/* Mark the staged slot 'Invalid' and release the in-progress lock. */ +FusionStorageResult_t FusionStorage_AbortSlotWrite(FusionStorage_t *s); + +/* Mark an already-written slot invalid so it cannot be selected for load. */ +FusionStorageResult_t FusionStorage_InvalidateSlot(FusionStorage_t *s, + uint32_t slot_index); + +/* + * Pick the slot with the highest commit_generation among slots whose + * commit_state == Valid. Returns NoValidSlot if none. + */ +FusionStorageResult_t FusionStorage_FindNewestValidSlot(FusionStorage_t *s, + uint32_t *slot_index_out, + uint32_t *generation_out); + +/* Read the header for a given slot. */ +FusionStorageResult_t FusionStorage_ReadSlotHeader(FusionStorage_t *s, + uint32_t slot_index, + FusionStateHeader_t *header_out); + +/* Read bytes from a committed slot, with offset relative to the slot start. */ +FusionStorageResult_t FusionStorage_ReadSlotBytes(FusionStorage_t *s, + uint32_t slot_index, + uint32_t offset, + uint8_t *data, + uint32_t length); + +#ifdef __cplusplus +} +#endif diff --git a/partitions.csv b/partitions.csv new file mode 100644 index 0000000..62391ee --- /dev/null +++ b/partitions.csv @@ -0,0 +1,5 @@ +# Name, Type, SubType, Offset, Size, Flags +nvs, data, nvs, 0x9000, 0x6000, +phy_init, data, phy, 0xf000, 0x1000, +factory, app, factory, 0x10000, 1M, +savestate,data, 0x40, 0x110000, 0x20000, diff --git a/sdkconfig.defaults b/sdkconfig.defaults index b6612ec..263423e 100644 --- a/sdkconfig.defaults +++ b/sdkconfig.defaults @@ -4,6 +4,9 @@ CONFIG_APP_PROJECT_VER_FROM_CONFIG=y CONFIG_APP_PROJECT_VER="0.13.4" CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y +CONFIG_PARTITION_TABLE_CUSTOM=y +CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv" +CONFIG_PARTITION_TABLE_FILENAME="partitions.csv" CONFIG_CHROMATIC_FW_VER_STR="4.2" CONFIG_CONSOLE_SORTED_HELP=y CONFIG_FREERTOS_TIMER_TASK_STACK_DEPTH=4096 diff --git a/tests/host/Makefile b/tests/host/Makefile new file mode 100644 index 0000000..eb51026 --- /dev/null +++ b/tests/host/Makefile @@ -0,0 +1,40 @@ +.POSIX: + +# Host self-test for the Fusion savestate Phase 1 protocol skeleton. +# Builds with the system compiler; no ESP-IDF, no FreeRTOS dependency. + +CC ?= cc +CFLAGS ?= -std=c11 -O2 -g -Wall -Wextra -Wpedantic -Wshadow \ + -Wno-unused-parameter -Werror + +ROOT := ../.. +MAIN := $(ROOT)/main +CRC := $(ROOT)/components/crc + +INCLUDES := -I$(MAIN) -I$(CRC) + +SRCS := \ + $(MAIN)/fusion_savestate.c \ + $(MAIN)/fpga_state_port.c \ + $(MAIN)/savestate_storage.c \ + $(CRC)/crc8_sae_j1850.c \ + fusion_savestate_selftest.c + +BUILD := build +BIN := $(BUILD)/fusion_savestate_selftest + +all: $(BIN) + +$(BIN): $(SRCS) | $(BUILD) + $(CC) $(CFLAGS) $(INCLUDES) $(SRCS) -o $(BIN) + +$(BUILD): + mkdir -p $(BUILD) + +run: $(BIN) + $(BIN) + +clean: + rm -rf $(BUILD) + +.PHONY: all run clean diff --git a/tests/host/fusion_savestate_selftest.c b/tests/host/fusion_savestate_selftest.c new file mode 100644 index 0000000..c69693d --- /dev/null +++ b/tests/host/fusion_savestate_selftest.c @@ -0,0 +1,671 @@ +/* + * Host self-test for the Fusion savestate Phase 1 MCU protocol skeleton. + * + * Compiles to a stand-alone binary on the host. Does not require ESP-IDF + * or FreeRTOS. Links directly against: + * - main/fusion_savestate.c + * - main/fpga_state_port.c + * - main/savestate_storage.c + * - components/crc/crc8_sae_j1850.c + * + * Tests each requirement called out in the Phase 1 task brief: + * - STATE_DATA encode/decode roundtrip with seq=0 and 8 bytes + * - Seq mismatch abort + * - ERROR response abort + * - Late ACK_DONE ignored after abort/generation mismatch + * - game_id_v1 determinism + * - Storage header size/field sanity, two-phase commit lifecycle + */ + +#include "fusion_savestate.h" +#include "fpga_state_port.h" +#include "savestate_storage.h" + +#include +#include +#include +#include +#include + +static int g_fail_count = 0; +static int g_pass_count = 0; + +#define EXPECT(cond) do { \ + if (cond) { \ + g_pass_count++; \ + } else { \ + g_fail_count++; \ + fprintf(stderr, " FAIL %s:%d %s\n", __FILE__, __LINE__, #cond); \ + } \ +} while (0) + +#define EXPECT_EQ_U(a, b) do { \ + const uint64_t _a = (uint64_t)(a); \ + const uint64_t _b = (uint64_t)(b); \ + if (_a == _b) { \ + g_pass_count++; \ + } else { \ + g_fail_count++; \ + fprintf(stderr, " FAIL %s:%d %s == %s (got 0x%llx vs 0x%llx)\n", \ + __FILE__, __LINE__, #a, #b, \ + (unsigned long long)_a, (unsigned long long)_b); \ + } \ +} while (0) + +/* --- Mock transport that records sent frames. --- */ + +typedef struct { + uint8_t frames[64][FUSION_V2_MAX_FRAME]; + uint8_t lengths[64]; + uint32_t count; + bool fail_next; + uint64_t now; +} MockTx_t; + +static bool MockTxFrame(void *user, const uint8_t *frame, size_t len) +{ + MockTx_t *m = (MockTx_t *)user; + if (m->fail_next) { + m->fail_next = false; + return false; + } + if (m->count >= 64u || len > FUSION_V2_MAX_FRAME) { + return false; + } + memcpy(m->frames[m->count], frame, len); + m->lengths[m->count] = (uint8_t)len; + m->count++; + return true; +} + +static uint64_t MockNowUs(void *user) +{ + MockTx_t *m = (MockTx_t *)user; + return m->now; +} + +static void MockReset(MockTx_t *m) +{ + memset(m, 0, sizeof(*m)); +} + +/* --- Tests --- */ + +static void test_v2_frame_state_data_roundtrip(void) +{ + fprintf(stderr, "[test] STATE_DATA encode/decode roundtrip seq=0, 8 bytes\n"); + const uint8_t data[8] = { 0xDE, 0xAD, 0xBE, 0xEF, 0x12, 0x34, 0x56, 0x78 }; + FusionV2Frame_t frame; + EXPECT(FusionSavestate_BuildStateData(0u, data, 8u, &frame)); + /* Wire layout: 0x8F | 0x21 | 0x0A | seq_lo | seq_hi | 8 data bytes | crc */ + EXPECT_EQ_U(frame.length, 14u); + EXPECT_EQ_U(frame.bytes[0], FUSION_V2_HEADER_MARKER); + EXPECT_EQ_U(frame.bytes[1], (uint8_t)kFusionAddr_StateData); + EXPECT_EQ_U(frame.bytes[2], 10u); + EXPECT_EQ_U(frame.bytes[3], 0u); /* seq_lo */ + EXPECT_EQ_U(frame.bytes[4], 0u); /* seq_hi */ + EXPECT_EQ_U(frame.bytes[5], 0xDE); + EXPECT_EQ_U(frame.bytes[12], 0x78); + + FusionV2Decoded_t decoded; + EXPECT(FusionSavestate_DecodeV2Frame(frame.bytes, frame.length, &decoded)); + EXPECT_EQ_U(decoded.addr, (uint8_t)kFusionAddr_StateData); + EXPECT_EQ_U(decoded.payload_len, 10u); + EXPECT_EQ_U(decoded.data_seq, 0u); + EXPECT_EQ_U(decoded.data_len, 8u); + EXPECT_EQ_U(memcmp(decoded.data, data, 8), 0); +} + +static void test_v2_frame_state_data_seq_nonzero(void) +{ + fprintf(stderr, "[test] STATE_DATA encode/decode seq=0xBEEF\n"); + const uint8_t data[3] = { 0x11, 0x22, 0x33 }; + FusionV2Frame_t frame; + EXPECT(FusionSavestate_BuildStateData(0xBEEFu, data, 3u, &frame)); + + FusionV2Decoded_t decoded; + EXPECT(FusionSavestate_DecodeV2Frame(frame.bytes, frame.length, &decoded)); + EXPECT_EQ_U(decoded.data_seq, 0xBEEFu); + EXPECT_EQ_U(decoded.data_len, 3u); + EXPECT_EQ_U(memcmp(decoded.data, data, 3), 0); +} + +static void test_v2_frame_decode_rejects_bad_crc(void) +{ + fprintf(stderr, "[test] DecodeV2Frame rejects corrupted CRC\n"); + FusionV2Frame_t frame; + EXPECT(FusionSavestate_BuildBeginSave(0u, &frame)); + frame.bytes[frame.length - 1u] ^= 0xA5; + FusionV2Decoded_t decoded; + EXPECT(!FusionSavestate_DecodeV2Frame(frame.bytes, frame.length, &decoded)); +} + +static void test_v2_frame_decode_rejects_bad_marker(void) +{ + fprintf(stderr, "[test] DecodeV2Frame rejects bad marker / oversize / 7-bit addr\n"); + FusionV2Frame_t frame; + EXPECT(FusionSavestate_BuildEndSession(&frame)); + frame.bytes[0] = 0x8A; /* V1 marker */ + FusionV2Decoded_t decoded; + EXPECT(!FusionSavestate_DecodeV2Frame(frame.bytes, frame.length, &decoded)); + + /* Address with bit 7 set must be rejected by builder. */ + EXPECT(!FusionSavestate_BuildV2Frame(0x80, NULL, 0, &frame)); + + /* Payload length cap. */ + uint8_t big[FUSION_V2_MAX_PAYLOAD + 1] = {0}; + EXPECT(!FusionSavestate_BuildV2Frame(0x20, big, sizeof(big), &frame)); +} + +static void test_ctl_builders(void) +{ + fprintf(stderr, "[test] CTL builders produce decodable frames\n"); + FusionV2Frame_t frame; + FusionV2Decoded_t d; + + EXPECT(FusionSavestate_BuildBeginSave(0x07u, &frame)); + EXPECT(FusionSavestate_DecodeV2Frame(frame.bytes, frame.length, &d)); + EXPECT_EQ_U(d.ctl_opcode, (uint8_t)kFusionOp_BeginSave); + EXPECT_EQ_U(d.payload[1], 0x07u); + + EXPECT(FusionSavestate_BuildSeek(kFusionRegion_Wram, 0xCAFEu, &frame)); + EXPECT(FusionSavestate_DecodeV2Frame(frame.bytes, frame.length, &d)); + EXPECT_EQ_U(d.ctl_opcode, (uint8_t)kFusionOp_Seek); + EXPECT_EQ_U(d.payload[1], (uint8_t)kFusionRegion_Wram); + EXPECT_EQ_U(d.payload[2], 0xFEu); + EXPECT_EQ_U(d.payload[3], 0xCAu); + + EXPECT(FusionSavestate_BuildReadStreamBegin(kFusionRegion_Hram, 0x0011u, 0x007Fu, &frame)); + EXPECT(FusionSavestate_DecodeV2Frame(frame.bytes, frame.length, &d)); + EXPECT_EQ_U(d.ctl_opcode, (uint8_t)kFusionOp_ReadStreamBegin); + EXPECT_EQ_U(d.payload[1], (uint8_t)kFusionRegion_Hram); + EXPECT_EQ_U(d.payload[2], 0x11u); + EXPECT_EQ_U(d.payload[3], 0x00u); + EXPECT_EQ_U(d.payload[4], 0x7Fu); + EXPECT_EQ_U(d.payload[5], 0x00u); + + EXPECT(FusionSavestate_BuildReadStreamContinue(0x1234u, &frame)); + EXPECT(FusionSavestate_DecodeV2Frame(frame.bytes, frame.length, &d)); + EXPECT_EQ_U(d.ctl_opcode, (uint8_t)kFusionOp_ReadStreamContinue); + EXPECT_EQ_U(d.payload[1], 0x34u); + EXPECT_EQ_U(d.payload[2], 0x12u); + + EXPECT(FusionSavestate_BuildAckAccepted(kFusionOp_BeginLoad, &frame)); + EXPECT(FusionSavestate_DecodeV2Frame(frame.bytes, frame.length, &d)); + EXPECT_EQ_U(d.ctl_opcode, (uint8_t)kFusionOp_AckAccepted); + EXPECT_EQ_U(d.ack_for_opcode, (uint8_t)kFusionOp_BeginLoad); + + EXPECT(FusionSavestate_BuildError(kFusionErr_SeqMismatch, 0x99u, &frame)); + EXPECT(FusionSavestate_DecodeV2Frame(frame.bytes, frame.length, &d)); + EXPECT_EQ_U(d.error_code, (uint8_t)kFusionErr_SeqMismatch); + EXPECT_EQ_U(d.error_detail, 0x99u); +} + +static void test_state_port_seq_mismatch_aborts(void) +{ + fprintf(stderr, "[test] state-port seq mismatch -> aborted, ERROR sent\n"); + MockTx_t mock; + MockReset(&mock); + + FusionStatePortTransport_t tx = { .user = &mock, .tx_frame = MockTxFrame, .now_us = MockNowUs }; + FusionStatePort_t port; + FusionStatePort_Init(&port, &tx); + FusionStatePort_SetSessionTimeoutUs(&port, 0u); /* disable watchdog for this test */ + + EXPECT_EQ_U(FusionStatePort_BeginSave(&port, 0u), kFusionStatePortRes_Ok); + + /* FPGA accepts session. */ + FusionV2Frame_t f; + FusionV2Decoded_t d; + EXPECT(FusionSavestate_BuildAckAccepted(kFusionOp_BeginSave, &f)); + EXPECT(FusionSavestate_DecodeV2Frame(f.bytes, f.length, &d)); + EXPECT_EQ_U(FusionStatePort_OnRxFrame(&port, &d), kFusionStatePortRes_Ok); + + EXPECT_EQ_U(FusionStatePort_BeginReadStream(&port, kFusionRegion_Hram, 0u, 16u), + kFusionStatePortRes_Ok); + EXPECT(FusionSavestate_BuildAckAccepted(kFusionOp_ReadStreamBegin, &f)); + EXPECT(FusionSavestate_DecodeV2Frame(f.bytes, f.length, &d)); + EXPECT_EQ_U(FusionStatePort_OnRxFrame(&port, &d), kFusionStatePortRes_Ok); + + /* expected_seq is 0; FPGA "sends" seq=1 instead. */ + const uint8_t bad[8] = {0}; + EXPECT(FusionSavestate_BuildStateData(1u, bad, 8u, &f)); + EXPECT(FusionSavestate_DecodeV2Frame(f.bytes, f.length, &d)); + EXPECT_EQ_U(FusionStatePort_OnRxFrame(&port, &d), kFusionStatePortRes_Aborted); + + EXPECT_EQ_U(port.session_state, (uint64_t)kFusionSession_Aborted); + EXPECT(port.abort_pending); + EXPECT_EQ_U(port.last_error_code, (uint8_t)kFusionErr_SeqMismatch); + + /* The state machine must have transmitted an ERROR frame to FPGA. */ + EXPECT(mock.count >= 3u); + /* Last frame should be ERROR with code = SeqMismatch. */ + const uint8_t *last = mock.frames[mock.count - 1u]; + EXPECT_EQ_U(last[1], (uint8_t)kFusionAddr_StateCtl); + EXPECT_EQ_U(last[3], (uint8_t)kFusionOp_Error); + EXPECT_EQ_U(last[4], (uint8_t)kFusionErr_SeqMismatch); +} + +static void test_state_port_error_response_aborts(void) +{ + fprintf(stderr, "[test] state-port ERROR response -> aborted\n"); + MockTx_t mock; + MockReset(&mock); + FusionStatePortTransport_t tx = { .user = &mock, .tx_frame = MockTxFrame, .now_us = MockNowUs }; + FusionStatePort_t port; + FusionStatePort_Init(&port, &tx); + FusionStatePort_SetSessionTimeoutUs(&port, 0u); + + EXPECT_EQ_U(FusionStatePort_BeginLoad(&port, 0u), kFusionStatePortRes_Ok); + + FusionV2Frame_t f; + FusionV2Decoded_t d; + EXPECT(FusionSavestate_BuildError(kFusionErr_NoSession, 0xAAu, &f)); + EXPECT(FusionSavestate_DecodeV2Frame(f.bytes, f.length, &d)); + EXPECT_EQ_U(FusionStatePort_OnRxFrame(&port, &d), kFusionStatePortRes_Aborted); + + EXPECT_EQ_U(port.session_state, (uint64_t)kFusionSession_Aborted); + EXPECT_EQ_U(port.last_error_code, (uint8_t)kFusionErr_NoSession); + EXPECT_EQ_U(port.last_error_detail, 0xAAu); + EXPECT(port.stat_aborts >= 1u); +} + +static void test_state_port_late_ack_ignored(void) +{ + fprintf(stderr, "[test] state-port late ACK_DONE after abort -> ignored\n"); + MockTx_t mock; + MockReset(&mock); + FusionStatePortTransport_t tx = { .user = &mock, .tx_frame = MockTxFrame, .now_us = MockNowUs }; + FusionStatePort_t port; + FusionStatePort_Init(&port, &tx); + FusionStatePort_SetSessionTimeoutUs(&port, 0u); + + EXPECT_EQ_U(FusionStatePort_BeginSave(&port, 0u), kFusionStatePortRes_Ok); + + FusionV2Frame_t f; + FusionV2Decoded_t d; + EXPECT(FusionSavestate_BuildAckAccepted(kFusionOp_BeginSave, &f)); + EXPECT(FusionSavestate_DecodeV2Frame(f.bytes, f.length, &d)); + EXPECT_EQ_U(FusionStatePort_OnRxFrame(&port, &d), kFusionStatePortRes_Ok); + + EXPECT_EQ_U(FusionStatePort_BeginReadStream(&port, kFusionRegion_Wram, 0u, 32u), + kFusionStatePortRes_Ok); + + const uint32_t gen_before_abort = port.stream_generation; + FusionStatePort_RequestAbort(&port, + (uint8_t)kFusionErr_LocalAbort, + (uint8_t)kFusionErr_LocalAbort); + EXPECT(port.session_state == kFusionSession_Aborted); + EXPECT(port.stream_generation > gen_before_abort); + EXPECT_EQ_U(port.pending_op, 0u); + + const uint32_t late_before = port.stat_late_ignored; + + /* A late ACK_DONE for the prior stream must be ignored. */ + EXPECT(FusionSavestate_BuildAckDone(kFusionOp_ReadStreamBegin, &f)); + EXPECT(FusionSavestate_DecodeV2Frame(f.bytes, f.length, &d)); + EXPECT_EQ_U(FusionStatePort_OnRxFrame(&port, &d), kFusionStatePortRes_LateIgnored); + + /* A late STATE_DATA must also be ignored, not applied. */ + const uint8_t junk[4] = {1,2,3,4}; + EXPECT(FusionSavestate_BuildStateData(0u, junk, 4u, &f)); + EXPECT(FusionSavestate_DecodeV2Frame(f.bytes, f.length, &d)); + EXPECT_EQ_U(FusionStatePort_OnRxFrame(&port, &d), kFusionStatePortRes_LateIgnored); + + EXPECT(port.stat_late_ignored >= late_before + 2u); +} + +static void test_state_port_ack_done_completes_stream(void) +{ + fprintf(stderr, "[test] state-port ACK_DONE for stream begin clears stream\n"); + MockTx_t mock; + MockReset(&mock); + FusionStatePortTransport_t tx = { .user = &mock, .tx_frame = MockTxFrame, .now_us = MockNowUs }; + FusionStatePort_t port; + FusionStatePort_Init(&port, &tx); + FusionStatePort_SetSessionTimeoutUs(&port, 0u); + + EXPECT_EQ_U(FusionStatePort_BeginSave(&port, 0u), kFusionStatePortRes_Ok); + FusionV2Frame_t f; + FusionV2Decoded_t d; + EXPECT(FusionSavestate_BuildAckAccepted(kFusionOp_BeginSave, &f)); + EXPECT(FusionSavestate_DecodeV2Frame(f.bytes, f.length, &d)); + EXPECT_EQ_U(FusionStatePort_OnRxFrame(&port, &d), kFusionStatePortRes_Ok); + + EXPECT_EQ_U(FusionStatePort_BeginReadStream(&port, kFusionRegion_PaletteRam, 0u, 8u), + kFusionStatePortRes_Ok); + EXPECT(FusionSavestate_BuildAckAccepted(kFusionOp_ReadStreamBegin, &f)); + EXPECT(FusionSavestate_DecodeV2Frame(f.bytes, f.length, &d)); + EXPECT_EQ_U(FusionStatePort_OnRxFrame(&port, &d), kFusionStatePortRes_Ok); + + /* One DATA frame with seq=0, 8 bytes -> remaining hits 0. */ + const uint8_t data[8] = {0,1,2,3,4,5,6,7}; + EXPECT(FusionSavestate_BuildStateData(0u, data, 8u, &f)); + EXPECT(FusionSavestate_DecodeV2Frame(f.bytes, f.length, &d)); + EXPECT_EQ_U(FusionStatePort_OnRxFrame(&port, &d), kFusionStatePortRes_Ok); + EXPECT_EQ_U(port.remaining, 0u); + + /* ACK_DONE for the stream begin clears stream and leaves session open. */ + EXPECT(FusionSavestate_BuildAckDone(kFusionOp_ReadStreamBegin, &f)); + EXPECT(FusionSavestate_DecodeV2Frame(f.bytes, f.length, &d)); + EXPECT_EQ_U(FusionStatePort_OnRxFrame(&port, &d), kFusionStatePortRes_Ok); + EXPECT_EQ_U(port.stream_mode, (uint64_t)kFusionStream_None); + EXPECT_EQ_U(port.session_state, (uint64_t)kFusionSession_OpenSave); + + EXPECT_EQ_U(FusionStatePort_EndSession(&port), kFusionStatePortRes_Ok); + EXPECT_EQ_U(port.session_state, (uint64_t)kFusionSession_Idle); +} + +static void test_state_port_watchdog_aborts(void) +{ + fprintf(stderr, "[test] state-port watchdog tick -> abort on deadline\n"); + MockTx_t mock; + MockReset(&mock); + FusionStatePortTransport_t tx = { .user = &mock, .tx_frame = MockTxFrame, .now_us = MockNowUs }; + FusionStatePort_t port; + FusionStatePort_Init(&port, &tx); + FusionStatePort_SetSessionTimeoutUs(&port, 1000u); + mock.now = 0u; + + EXPECT_EQ_U(FusionStatePort_BeginSave(&port, 0u), kFusionStatePortRes_Ok); + /* Not yet expired. */ + mock.now = 500u; + FusionStatePort_TickWatchdog(&port); + EXPECT_EQ_U(port.session_state, (uint64_t)kFusionSession_OpenSave); + + /* Deadline exceeded -> abort. */ + mock.now = 2000u; + FusionStatePort_TickWatchdog(&port); + EXPECT_EQ_U(port.session_state, (uint64_t)kFusionSession_Aborted); + EXPECT_EQ_U(port.last_error_code, (uint8_t)kFusionErr_Timeout); +} + +static void test_game_id_v1_deterministic(void) +{ + fprintf(stderr, "[test] game_id_v1 deterministic and distinguishes inputs\n"); + uint8_t header_a[FUSION_ROM_HEADER_BYTES]; + uint8_t header_b[FUSION_ROM_HEADER_BYTES]; + for (size_t i = 0; i < FUSION_ROM_HEADER_BYTES; ++i) { + header_a[i] = (uint8_t)(i * 3u + 1u); + header_b[i] = header_a[i]; + } + const uint32_t a1 = FusionSavestate_ComputeGameIdV1(header_a); + const uint32_t a2 = FusionSavestate_ComputeGameIdV1(header_a); + EXPECT_EQ_U(a1, a2); + + header_b[0x14] ^= 0x01u; + const uint32_t b1 = FusionSavestate_ComputeGameIdV1(header_b); + EXPECT(a1 != b1); + + /* CRC32("123456789") == 0xCBF43926 (well-known CRC-32/ISO-HDLC test vector). */ + static const uint8_t kref[] = {'1','2','3','4','5','6','7','8','9'}; + EXPECT_EQ_U(FusionSavestate_Crc32(kref, sizeof(kref)), 0xCBF43926u); +} + +static void test_storage_header_layout(void) +{ + fprintf(stderr, "[test] FusionStateHeader / RegionDirectory size and field sanity\n"); + EXPECT_EQ_U(sizeof(FusionStateHeader_t), 64u); + EXPECT_EQ_U(sizeof(FusionRegionDirectory_t), 16u); + /* Magic must round-trip as 'F','U','S','S' little-endian. */ + EXPECT_EQ_U(FUSION_SAVESTATE_MAGIC, 0x53535546u); + const uint8_t *m = (const uint8_t *)&(uint32_t){FUSION_SAVESTATE_MAGIC}; + EXPECT_EQ_U(m[0], (uint8_t)'F'); + EXPECT_EQ_U(m[1], (uint8_t)'U'); + EXPECT_EQ_U(m[2], (uint8_t)'S'); + EXPECT_EQ_U(m[3], (uint8_t)'S'); +} + +static void test_storage_two_phase_commit(void) +{ + fprintf(stderr, "[test] storage two-phase commit lifecycle (mock)\n"); + enum { kSlots = 2, kSlotBytes = 1024 }; + uint8_t backing[kSlots * kSlotBytes]; + FusionStorageSlot_t slots[kSlots]; + FusionStorage_t s; + EXPECT_EQ_U(FusionStorage_InitMock(&s, slots, kSlots, backing, sizeof(backing)), + kFusionStorage_Ok); + + /* Stage slot 0. */ + EXPECT_EQ_U(FusionStorage_BeginInactiveSlotWrite(&s), kFusionStorage_Ok); + const uint8_t hram[] = {1,2,3,4,5,6,7,8}; + EXPECT_EQ_U(FusionStorage_AppendRegionPayload(&s, hram, sizeof(hram)), + kFusionStorage_Ok); + + FusionRegionDirectory_t dir = { + .region_id = (uint8_t)kFusionRegion_Hram, + .reserved = {0}, + .offset = sizeof(FusionStateHeader_t), + .length = sizeof(hram), + .crc32 = FusionSavestate_Crc32(hram, sizeof(hram)), + }; + FusionStateHeader_t hdr = {0}; + hdr.game_id_hash = 0xDEADBEEFu; + hdr.game_id_algorithm = (uint8_t)FUSION_GAME_ID_ALGORITHM_V1; + memcpy(hdr.fpga_version, "abcdef12", 8); + memcpy(hdr.mcu_version, "12345678", 8); + hdr.commit_generation = 42u; + hdr.region_bitmap = 0x00000200u; + memcpy(hdr.savestate_tag, "P470", 4); + EXPECT_EQ_U(FusionStorage_StageHeader(&s, &hdr, &dir, 1u), kFusionStorage_Ok); + + /* Before commit, no slot is "newest valid". */ + uint32_t newest = 0u; + uint32_t newest_gen = 0u; + EXPECT_EQ_U(FusionStorage_FindNewestValidSlot(&s, &newest, &newest_gen), + kFusionStorage_NoValidSlot); + + /* Commit and verify atomically. */ + EXPECT_EQ_U(FusionStorage_VerifyAndCommit(&s), kFusionStorage_Ok); + EXPECT_EQ_U(FusionStorage_FindNewestValidSlot(&s, &newest, &newest_gen), + kFusionStorage_Ok); + EXPECT_EQ_U(newest_gen, 42u); + + /* Stage slot 1 with newer generation, leave it in-progress (simulate power loss + * between StageHeader and VerifyAndCommit). */ + EXPECT_EQ_U(FusionStorage_BeginInactiveSlotWrite(&s), kFusionStorage_Ok); + EXPECT_EQ_U(FusionStorage_AppendRegionPayload(&s, hram, sizeof(hram)), + kFusionStorage_Ok); + FusionStateHeader_t hdr2 = hdr; + hdr2.commit_generation = 100u; + EXPECT_EQ_U(FusionStorage_StageHeader(&s, &hdr2, &dir, 1u), kFusionStorage_Ok); + /* No commit. Find newest valid -> still slot 0 / generation 42. */ + EXPECT_EQ_U(FusionStorage_FindNewestValidSlot(&s, &newest, &newest_gen), + kFusionStorage_Ok); + EXPECT_EQ_U(newest_gen, 42u); + /* Tampered (Writing) header must not be selected as Valid. */ + FusionStateHeader_t reread; + EXPECT_EQ_U(FusionStorage_ReadSlotHeader(&s, 1u, &reread), kFusionStorage_Ok); + EXPECT_EQ_U(reread.commit_state, (uint8_t)kFusionCommit_Writing); + + /* Now finish the commit. */ + EXPECT_EQ_U(FusionStorage_VerifyAndCommit(&s), kFusionStorage_Ok); + EXPECT_EQ_U(FusionStorage_FindNewestValidSlot(&s, &newest, &newest_gen), + kFusionStorage_Ok); + EXPECT_EQ_U(newest_gen, 100u); + + EXPECT_EQ_U(FusionStorage_InvalidateSlot(&s, newest), kFusionStorage_Ok); + EXPECT_EQ_U(FusionStorage_FindNewestValidSlot(&s, &newest, &newest_gen), + kFusionStorage_Ok); + EXPECT_EQ_U(newest_gen, 42u); + EXPECT_EQ_U(FusionStorage_InvalidateSlot(&s, newest), kFusionStorage_Ok); + EXPECT_EQ_U(FusionStorage_FindNewestValidSlot(&s, &newest, &newest_gen), + kFusionStorage_NoValidSlot); +} + +static void test_storage_partition_init_deferred(void) +{ + fprintf(stderr, "[test] partition backend reports NotImplemented\n"); + FusionStorage_t s; + EXPECT_EQ_U(FusionStorage_InitFromPartition(&s, "savestate"), + kFusionStorage_NotImplemented); +} + +static void test_pathe_load_payload_basic(void) +{ + fprintf(stderr, "[test] path-e load payload basic IME=1\n"); + FusionPathELoadInput_t s = { + .saved_F = 0xA0u, .saved_A = 0x7Fu, + .saved_C = 0x34u, .saved_B = 0x12u, + .saved_E = 0x78u, .saved_D = 0x56u, + .saved_L = 0xBCu, .saved_H = 0x9Au, + .saved_SP = 0xDEADu, + .saved_PC = 0x0150u, + .saved_IFF = true, + .halted_at_save = false, + }; + uint8_t out[kPathE_RegionLength]; + EXPECT(FusionSavestate_BuildPathELoadPayload(&s, out)); + EXPECT_EQ_U(out[kPathE_LoadModeActive], 1u); + EXPECT_EQ_U(out[kPathE_ScratchF], 0xA0u); + EXPECT_EQ_U(out[kPathE_ScratchA], 0x7Fu); + EXPECT_EQ_U(out[kPathE_ScratchC], 0x34u); + EXPECT_EQ_U(out[kPathE_ScratchB], 0x12u); + EXPECT_EQ_U(out[kPathE_ScratchE], 0x78u); + EXPECT_EQ_U(out[kPathE_ScratchD], 0x56u); + EXPECT_EQ_U(out[kPathE_ScratchL], 0xBCu); + EXPECT_EQ_U(out[kPathE_ScratchH], 0x9Au); + EXPECT_EQ_U(out[kPathE_SavedSpLo], 0xADu); + EXPECT_EQ_U(out[kPathE_SavedSpHi], 0xDEu); + EXPECT_EQ_U(out[kPathE_IffByte], FUSION_PATHE_IFF_BYTE_EI); /* 0xFB EI */ + EXPECT_EQ_U(out[kPathE_SavedA], 0x7Fu); + EXPECT_EQ_U(out[kPathE_SavedPcLo], 0x50u); + EXPECT_EQ_U(out[kPathE_SavedPcHi], 0x01u); + EXPECT_EQ_U(out[kPathE_GbresetRequest], 0u); +} + +static void test_pathe_load_payload_ime0(void) +{ + fprintf(stderr, "[test] path-e load payload IME=0 selects NOP\n"); + FusionPathELoadInput_t s = { + .saved_F = 0x00u, .saved_A = 0x00u, + .saved_SP = 0xFFFEu, .saved_PC = 0x0150u, + .saved_IFF = false, .halted_at_save = false, + }; + uint8_t out[kPathE_RegionLength]; + EXPECT(FusionSavestate_BuildPathELoadPayload(&s, out)); + EXPECT_EQ_U(out[kPathE_IffByte], FUSION_PATHE_IFF_BYTE_NOP); /* 0x00 NOP */ +} + +static void test_pathe_load_payload_halt_pc_minus1(void) +{ + fprintf(stderr, "[test] path-e load payload HALT applies PC-1\n"); + FusionPathELoadInput_t s = { + .saved_PC = 0x0234u, /* CPU was at instruction after HALT */ + .saved_IFF = true, .halted_at_save = true, + }; + uint8_t out[kPathE_RegionLength]; + EXPECT(FusionSavestate_BuildPathELoadPayload(&s, out)); + /* Adjusted to 0x0233 = HALT opcode address. */ + EXPECT_EQ_U(out[kPathE_SavedPcLo], 0x33u); + EXPECT_EQ_U(out[kPathE_SavedPcHi], 0x02u); +} + +static void test_pathe_load_payload_rejects_pc0_halt(void) +{ + fprintf(stderr, "[test] path-e load payload rejects HALT at PC=0\n"); + FusionPathELoadInput_t s = { + .saved_PC = 0x0000u, + .halted_at_save = true, + }; + uint8_t out[kPathE_RegionLength]; + EXPECT(!FusionSavestate_BuildPathELoadPayload(&s, out)); +} + +static void test_pathe_load_payload_null_inputs(void) +{ + fprintf(stderr, "[test] path-e load payload rejects null inputs\n"); + FusionPathELoadInput_t s = {0}; + uint8_t out[kPathE_RegionLength]; + EXPECT(!FusionSavestate_BuildPathELoadPayload(NULL, out)); + EXPECT(!FusionSavestate_BuildPathELoadPayload(&s, NULL)); +} + +static void test_pathe_extract_from_cpu_region(void) +{ + fprintf(stderr, "[test] path-e extract input from saved CPU region bytes\n"); + /* Construct a synthetic 40-byte CPU region with known field values. */ + uint8_t cpu[40] = {0}; + /* GBSE bytes 0-1 (don't care for this test) */ + cpu[2] = 0x34u; /* C */ + cpu[3] = 0x78u; /* E */ + cpu[4] = 0xBCu; /* L */ + cpu[6] = 0x12u; /* B */ + cpu[7] = 0x56u; /* D */ + cpu[8] = 0x9Au; /* H */ + cpu[10] = 0x50u; /* PC lo */ + cpu[11] = 0x01u; /* PC hi */ + cpu[19] = 0x7Fu; /* ACC = visible A */ + cpu[25] = 0xADu; /* SP lo */ + cpu[26] = 0xDEu; /* SP hi */ + /* + * F = 0xA0 means Z=1, N=0, H=1, C=0; encoded in byte 28: + * F[7]=Z=1 -> bit 4 of byte28 = 1 + * F[6]=N=0 -> bit 3 of byte28 = 0 + * F[5]=H=1 -> bit 2 of byte28 = 1 + * F[4]=C=0 -> bit 1 of byte28 = 0 + * byte28 lower 5 bits = 0b10100 = 0x14 + */ + cpu[28] = 0x14u; + cpu[31] = 0x10u; /* IFF=1 (bit 4), Halt=0 (bit 0) */ + + FusionPathELoadInput_t state = {0}; + EXPECT(FusionSavestate_ExtractPathELoadInputFromCpuRegion(cpu, sizeof(cpu), &state)); + EXPECT_EQ_U(state.saved_C, 0x34u); + EXPECT_EQ_U(state.saved_E, 0x78u); + EXPECT_EQ_U(state.saved_L, 0xBCu); + EXPECT_EQ_U(state.saved_B, 0x12u); + EXPECT_EQ_U(state.saved_D, 0x56u); + EXPECT_EQ_U(state.saved_H, 0x9Au); + EXPECT_EQ_U(state.saved_A, 0x7Fu); + EXPECT_EQ_U(state.saved_F, 0xA0u); + EXPECT_EQ_U(state.saved_PC, 0x0150u); + EXPECT_EQ_U(state.saved_SP, 0xDEADu); + EXPECT(state.saved_IFF); + EXPECT(!state.halted_at_save); + + /* Halt scenario: byte 31 bit 0 = 1, IFF still 1 */ + cpu[31] = 0x11u; + EXPECT(FusionSavestate_ExtractPathELoadInputFromCpuRegion(cpu, sizeof(cpu), &state)); + EXPECT(state.halted_at_save); + EXPECT(state.saved_IFF); +} + +static void test_pathe_extract_rejects_short_input(void) +{ + fprintf(stderr, "[test] path-e extract rejects short input\n"); + uint8_t cpu[16] = {0}; + FusionPathELoadInput_t state = {0}; + EXPECT(!FusionSavestate_ExtractPathELoadInputFromCpuRegion(cpu, sizeof(cpu), &state)); + EXPECT(!FusionSavestate_ExtractPathELoadInputFromCpuRegion(NULL, 40, &state)); +} + +int main(void) +{ + fprintf(stderr, "Fusion savestate Phase 1 host self-test\n"); + + test_v2_frame_state_data_roundtrip(); + test_v2_frame_state_data_seq_nonzero(); + test_v2_frame_decode_rejects_bad_crc(); + test_v2_frame_decode_rejects_bad_marker(); + test_ctl_builders(); + test_state_port_seq_mismatch_aborts(); + test_state_port_error_response_aborts(); + test_state_port_late_ack_ignored(); + test_state_port_ack_done_completes_stream(); + test_state_port_watchdog_aborts(); + test_game_id_v1_deterministic(); + test_storage_header_layout(); + test_storage_two_phase_commit(); + test_storage_partition_init_deferred(); + test_pathe_load_payload_basic(); + test_pathe_load_payload_ime0(); + test_pathe_load_payload_halt_pc_minus1(); + test_pathe_load_payload_rejects_pc0_halt(); + test_pathe_load_payload_null_inputs(); + test_pathe_extract_from_cpu_region(); + test_pathe_extract_rejects_short_input(); + + fprintf(stderr, "\n%d passed, %d failed\n", g_pass_count, g_fail_count); + return g_fail_count == 0 ? 0 : 1; +}