A fork of https://github.com/crosspoint-reader/crosspoint-reader
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix: prevent wallpaper clustering with 16-entry recency buffer (#1606)

## Problem

Custom sleep wallpapers feel repetitive — the same image appearing
multiple times in a short session. Only the single most-recently-shown
index was stored (`uint8_t lastSleepImage`), so on collections of 3–5
images, two-pick cycles were common. On larger collections, any image
could reappear within the next few picks.

## Solution

Add a 16-entry circular recency buffer to `CrossPointState` that
excludes recently shown wallpapers from selection.

**Recency buffer** (`recentSleepImages[16]`, `recentSleepPos`,
`recentSleepFill` — 34 bytes DRAM):
- Tracks the last 16 shown wallpaper indices
- On each pick, rerolls up to 20 times if the candidate was recently
shown
- Window auto-shrinks to `numFiles - 1` for small collections
(guarantees a non-repeat is always possible)
- `isRecentSleep()` clamps to `recentSleepFill` to avoid false positives
on unwritten buffer slots
- State persisted to `state.json` so the buffer survives sleep/wake
cycles

**Migration**:
- Binary (`state.bin`): old `lastSleepImage` field seeded into the new
buffer if valid
- JSON (`state.json`): legacy `lastSleepImage` key detected and seeded
into the buffer when upgrading from older firmware

## Index type fix

`randomFileIndex` upgraded from `uint8_t` to `uint16_t` — silently
truncated for collections larger than 255 wallpapers.

## Repeat probability: before vs after

Chance of seeing a recently-shown image on the next pick. After:
`(numFiles - 16) / numFiles` once collection exceeds the window; 0%
while ≤17.

| Collection | Before | After |
|---|---|---|
| 3 | 50% | 0% |
| 5 | 75% | 0% |
| 10 | 89% | 0% |
| 17 | 94% | 0% |
| 18 | 94% | 6% |
| 20 | 95% | 20% |
| 25 | 96% | 36% |
| 30 | 97% | 47% |
| 50 | 98% | 68% |
| 100 | 99% | 84% |

## Memory impact

| Addition | Size |
|---|---|
| `recentSleepImages[16]` | 32 bytes DRAM |
| `recentSleepPos` + `recentSleepFill` | 2 bytes DRAM |
| **Total** | **34 bytes DRAM** |

## Files changed

- `src/CrossPointState.h` — recency buffer fields + `isRecentSleep()` /
`pushRecentSleep()` declarations
- `src/CrossPointState.cpp` — `isRecentSleep()` / `pushRecentSleep()`
implementations + binary migration path
- `src/JsonSettingsIO.cpp` — JSON serialisation of buffer state + JSON
migration
- `src/activities/boot_sleep/SleepActivity.cpp` — retry loop with
recency check

Co-authored-by: Patryk Radtke <patryk@Patryks-MacBook-Pro.local>

authored by

zgredex
Patryk Radtke
and committed by
GitHub
c4f5c8e9 0c5dee3c

+64 -14
+22 -3
src/CrossPointState.cpp
··· 5 5 #include <Logging.h> 6 6 #include <Serialization.h> 7 7 8 + #include <algorithm> 9 + 8 10 namespace { 9 11 constexpr uint8_t STATE_FILE_VERSION = 4; 10 12 constexpr char STATE_FILE_BIN[] = "/.crosspoint/state.bin"; ··· 13 15 } // namespace 14 16 15 17 CrossPointState CrossPointState::instance; 18 + 19 + bool CrossPointState::isRecentSleep(uint16_t idx, uint8_t checkCount) const { 20 + const uint8_t effectiveCount = std::min(checkCount, recentSleepFill); 21 + for (uint8_t i = 0; i < effectiveCount; i++) { 22 + const uint8_t slot = (recentSleepPos + SLEEP_RECENT_COUNT - 1 - i) % SLEEP_RECENT_COUNT; 23 + if (recentSleepImages[slot] == idx) return true; 24 + } 25 + return false; 26 + } 27 + 28 + void CrossPointState::pushRecentSleep(uint16_t idx) { 29 + recentSleepImages[recentSleepPos] = idx; 30 + recentSleepPos = (recentSleepPos + 1) % SLEEP_RECENT_COUNT; 31 + if (recentSleepFill < SLEEP_RECENT_COUNT) recentSleepFill++; 32 + } 16 33 17 34 bool CrossPointState::saveToFile() const { 18 35 Storage.mkdir("/.crosspoint"); ··· 60 77 61 78 serialization::readString(inputFile, openEpubPath); 62 79 if (version >= 2) { 63 - serialization::readPod(inputFile, lastSleepImage); 64 - } else { 65 - lastSleepImage = UINT8_MAX; 80 + uint8_t legacyLastSleep = UINT8_MAX; 81 + serialization::readPod(inputFile, legacyLastSleep); 82 + if (legacyLastSleep != UINT8_MAX) { 83 + pushRecentSleep(static_cast<uint16_t>(legacyLastSleep)); 84 + } 66 85 } 67 86 68 87 if (version >= 3) {
+11 -2
src/CrossPointState.h
··· 1 1 #pragma once 2 2 #include <cstdint> 3 - #include <iosfwd> 4 3 #include <string> 5 4 6 5 class CrossPointState { ··· 8 7 static CrossPointState instance; 9 8 10 9 public: 10 + static constexpr uint8_t SLEEP_RECENT_COUNT = 16; 11 + 11 12 std::string openEpubPath; 12 - uint8_t lastSleepImage = UINT8_MAX; // UINT8_MAX = unset sentinel 13 + uint16_t recentSleepImages[SLEEP_RECENT_COUNT] = {}; // circular buffer of recent wallpaper indices 14 + uint8_t recentSleepPos = 0; // next write slot 15 + uint8_t recentSleepFill = 0; // valid entries (0..SLEEP_RECENT_COUNT) 13 16 uint8_t readerActivityLoadCount = 0; 14 17 bool lastSleepFromReader = false; 18 + 19 + // Returns true if idx was shown within the last checkCount picks. 20 + // Walks backwards from the most recently written slot. 21 + bool isRecentSleep(uint16_t idx, uint8_t checkCount) const; 22 + 23 + void pushRecentSleep(uint16_t idx); 15 24 ~CrossPointState() = default; 16 25 17 26 // Get singleton instance
+22 -3
src/JsonSettingsIO.cpp
··· 69 69 bool JsonSettingsIO::saveState(const CrossPointState& s, const char* path) { 70 70 JsonDocument doc; 71 71 doc["openEpubPath"] = s.openEpubPath; 72 - doc["lastSleepImage"] = s.lastSleepImage; 72 + JsonArray recentArr = doc["recentSleepImages"].to<JsonArray>(); 73 + for (int i = 0; i < CrossPointState::SLEEP_RECENT_COUNT; i++) recentArr.add(s.recentSleepImages[i]); 74 + doc["recentSleepPos"] = s.recentSleepPos; 75 + doc["recentSleepFill"] = s.recentSleepFill; 73 76 doc["readerActivityLoadCount"] = s.readerActivityLoadCount; 74 77 doc["lastSleepFromReader"] = s.lastSleepFromReader; 75 78 ··· 87 90 } 88 91 89 92 s.openEpubPath = doc["openEpubPath"] | std::string(""); 90 - s.lastSleepImage = doc["lastSleepImage"] | (uint8_t)UINT8_MAX; 91 - s.readerActivityLoadCount = doc["readerActivityLoadCount"] | (uint8_t)0; 93 + memset(s.recentSleepImages, 0, sizeof(s.recentSleepImages)); 94 + JsonArrayConst recentArr = doc["recentSleepImages"]; 95 + const int actualCount = recentArr.isNull() ? 0 96 + : std::min(static_cast<int>(recentArr.size()), 97 + static_cast<int>(CrossPointState::SLEEP_RECENT_COUNT)); 98 + for (int i = 0; i < actualCount; i++) s.recentSleepImages[i] = recentArr[i] | static_cast<uint16_t>(0); 99 + s.recentSleepPos = doc["recentSleepPos"] | static_cast<uint8_t>(0); 100 + if (s.recentSleepPos >= CrossPointState::SLEEP_RECENT_COUNT) 101 + s.recentSleepPos = actualCount > 0 ? s.recentSleepPos % CrossPointState::SLEEP_RECENT_COUNT : 0; 102 + s.recentSleepFill = doc["recentSleepFill"] | static_cast<uint8_t>(0); 103 + s.recentSleepFill = static_cast<uint8_t>(std::min(static_cast<int>(s.recentSleepFill), actualCount)); 104 + // Migrate legacy single-image field from old state.json (pre-recency-buffer). 105 + // Only seeds the buffer if the new buffer is empty (fresh migration, not a resave). 106 + if (s.recentSleepFill == 0 && !doc["lastSleepImage"].isNull()) { 107 + const uint8_t legacy = doc["lastSleepImage"] | static_cast<uint8_t>(UINT8_MAX); 108 + if (legacy != UINT8_MAX) s.pushRecentSleep(static_cast<uint16_t>(legacy)); 109 + } 110 + s.readerActivityLoadCount = doc["readerActivityLoadCount"] | static_cast<uint8_t>(0); 92 111 s.lastSleepFromReader = doc["lastSleepFromReader"] | false; 93 112 return true; 94 113 }
+9 -6
src/activities/boot_sleep/SleepActivity.cpp
··· 85 85 } 86 86 const auto numFiles = files.size(); 87 87 if (numFiles > 0) { 88 - // Generate a random number between 1 and numFiles 89 - auto randomFileIndex = random(numFiles); 90 - // If we picked the same image as last time, reroll 91 - while (numFiles > 1 && APP_STATE.lastSleepImage != UINT8_MAX && randomFileIndex == APP_STATE.lastSleepImage) { 92 - randomFileIndex = random(numFiles); 88 + // Pick a random wallpaper, excluding recently shown ones. 89 + // Window: up to SLEEP_RECENT_COUNT entries, capped at numFiles-1. 90 + const uint16_t fileCount = static_cast<uint16_t>(std::min(numFiles, static_cast<size_t>(UINT16_MAX))); 91 + const uint8_t window = 92 + static_cast<uint8_t>(std::min(static_cast<size_t>(APP_STATE.recentSleepFill), numFiles - 1)); 93 + auto randomFileIndex = static_cast<uint16_t>(random(fileCount)); 94 + for (uint8_t attempt = 0; attempt < 20 && APP_STATE.isRecentSleep(randomFileIndex, window); attempt++) { 95 + randomFileIndex = static_cast<uint16_t>(random(fileCount)); 93 96 } 94 - APP_STATE.lastSleepImage = randomFileIndex; 97 + APP_STATE.pushRecentSleep(randomFileIndex); 95 98 APP_STATE.saveToFile(); 96 99 const auto filename = std::string(sleepDir) + "/" + files[randomFileIndex]; 97 100 FsFile file;