Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 119 additions & 0 deletions components/button/button.c
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
57 changes: 57 additions & 0 deletions components/button/button.h
Original file line number Diff line number Diff line change
Expand Up @@ -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);
1 change: 1 addition & 0 deletions components/mutex/mutex.h
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions components/osd/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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/"
Expand Down
30 changes: 30 additions & 0 deletions components/osd/osd_default.c
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
Expand Down Expand Up @@ -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);
Expand All @@ -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");
}
Expand Down Expand Up @@ -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);
Expand Down
Loading