diff --git a/AGENTS.md b/AGENTS.md index cd474c4..66bf968 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -351,6 +351,17 @@ Background placeholder policy: - When true, frame-level background images are treated as placeholders and existing output pixel is kept in masked background areas. - This is used when a background scene is active so the scene background can continue while foreground content changes. +Mixed-resolution background scenes: +- `applySceneBackground` caches the already-rendered scene background together + with its source dimensions. +- If a background scene and the current foreground are rendered on different + planes because fallback selected the original-resolution foreground, the + cached scene background is sampled into the target plane instead of being + reused by raw flat-buffer index. +- This preserves correct background-scene composition for `64p` scene / + `32p` fallback foreground and the inverse `32p` scene / `64p` fallback + foreground case. + Critical-trigger fast rejection: - While a non-interruptable scene (or its end-hold) is active, normal incoming frames are not always sent through full `Identify_Frame(...)`. @@ -370,12 +381,29 @@ Critical-trigger fast rejection: Scene data comes from CSV (`SceneGenerator`). Flags (from `serum.h`): +- Scene finish behavior is selected by the low finish-mode bits: `0`, `1`, or + `2`. Other scene flags are orthogonal and may be combined with that finish + mode. +- `0` (default): keep the last scene frame visible when the scene finishes + until a new normal frame is identified - `1`: black when scene finished - `2`: show previous frame when scene finished - `4`: run scene as background - `8`: only dynamic content in foreground over background scene - `16`: resume interrupted scene if retriggered within 8s +Finished-scene default behavior: +- Foreground scenes with flag `0` leave the last rendered scene frame visible + until normal-frame identification resumes with a newly matched normal frame. +- If that next newly matched normal frame would immediately retrigger the same + scene, libserum does not restart the scene and keeps the preserved last scene + frame visible. +- Background scenes with flag `0` keep the last scene frame composited as + background until a newly identified normal frame stops that background + state. +- Same-trigger background-scene continuation does not clear that preserved + background frame; it follows the historical seamless continuation path. + `startImmediately` behavior: - `startImmediately` is honored for foreground scenes. - Background scenes do not use foreground-style immediate takeover semantics. diff --git a/README.md b/README.md index 22af2f5..5575b66 100644 --- a/README.md +++ b/README.md @@ -124,9 +124,18 @@ Format of a PUP scene line: if scene flag 0 is used for a non-interruptable scene, this value is used as end-hold duration in seconds instead 10: scene flags + the finish behavior is selected by the low bits: 0, 1 or 2 + 0 - default: keep the last scene frame visible when the scene finishes + until a new normal frame is identified + if that next identified normal frame would immediately retrigger the + same scene, the scene is not restarted and the preserved last scene + frame remains visible 1 - black screen when scene finished 2 - show last frame before scene started when scene finished 4 - run scene as background + after a background scene finishes with flag 0, its last scene frame + remains visible in the background until a newly identified normal frame + stops that background state; same-trigger continuation does not clear it 8 - replace static content with background scene, only dynamic zones, sprites and shadows stay in the foreground 16 - continue scene at previous frame when interrupted for less than 8s diff --git a/src/SceneGenerator.cpp b/src/SceneGenerator.cpp index 8b3f736..c6f1595 100644 --- a/src/SceneGenerator.cpp +++ b/src/SceneGenerator.cpp @@ -120,7 +120,8 @@ bool SceneGenerator::parseCSV(const std::string &csv_filename) { if (row.size() >= 10) data.sceneOptions = (uint8_t)std::stoi(row[9]); const bool useAutoStartAsEndHold = - (autoStartRaw > 0) && !data.interruptable && (data.sceneOptions == 0); + (autoStartRaw > 0) && !data.interruptable && + ((data.sceneOptions & FLAG_SCENE_FINISH_MODE_MASK) == 0); if (data.autoStart > 0 && !useAutoStartAsEndHold) { m_autoStartTimer = data.autoStart; m_autoStartSceneId = data.sceneId; @@ -295,7 +296,9 @@ bool SceneGenerator::getSceneEndHoldDurationMs(uint16_t sceneId, return false; } - if (!it->interruptable && it->sceneOptions == 0 && it->autoStart > 0) { + if (!it->interruptable && + ((it->sceneOptions & FLAG_SCENE_FINISH_MODE_MASK) == 0) && + it->autoStart > 0) { durationMs = (uint32_t)it->autoStart * 1000; return true; } diff --git a/src/serum-decode.cpp b/src/serum-decode.cpp index a319894..672dea4 100644 --- a/src/serum-decode.cpp +++ b/src/serum-decode.cpp @@ -153,6 +153,7 @@ uint16_t sceneCurrentFrame = 0; uint16_t sceneDurationPerFrame = 0; bool sceneInterruptable = false; bool sceneStartImmediately = false; +bool sceneIsLastForegroundFrame = false; bool sceneIsLastBackgroundFrame = false; uint8_t sceneRepeatCount = 0; uint8_t sceneOptionFlags = 0; @@ -163,6 +164,8 @@ uint8_t sceneFrame[256 * 64] = {0}; uint8_t lastFrame[256 * 64] = {0}; uint32_t lastFrameId = 0; // last frame ID identified uint16_t sceneBackgroundFrame[256 * 64] = {0}; +uint16_t sceneBackgroundWidth = 0; +uint16_t sceneBackgroundHeight = 0; bool monochromeMode = false; bool monochromePaletteMode = false; bool showStatusMessages = false; @@ -1223,6 +1226,9 @@ void Serum_free(void) { sceneEndHoldUntilMs = 0; sceneEndHoldDurationMs = 0; sceneNextFrameAtMs = 0; + sceneIsLastForegroundFrame = false; + sceneBackgroundWidth = 0; + sceneBackgroundHeight = 0; monochromeMode = false; monochromePaletteMode = false; monochromePaletteV2Length = 0; @@ -1495,6 +1501,26 @@ static uint32_t BuildCurrentFrameChangedFlags(void) { return changedFlags; } +static inline uint16_t GetSceneBackgroundPixel(uint16_t x, uint16_t y, + uint16_t targetWidth, + uint16_t targetHeight) { + if (sceneBackgroundWidth == 0 || sceneBackgroundHeight == 0 || + targetWidth == 0 || targetHeight == 0) { + return 0; + } + + if (sceneBackgroundWidth == targetWidth && + sceneBackgroundHeight == targetHeight) { + return sceneBackgroundFrame[(uint32_t)y * targetWidth + x]; + } + + const uint32_t sourceX = + (uint32_t)x * sceneBackgroundWidth / targetWidth; + const uint32_t sourceY = + (uint32_t)y * sceneBackgroundHeight / targetHeight; + return sceneBackgroundFrame[sourceY * sceneBackgroundWidth + sourceX]; +} + uint32_t max(uint32_t v1, uint32_t v2) { if (v1 > v2) return v1; return v2; @@ -3700,6 +3726,10 @@ void Colorize_Framev2(uint8_t* frame, uint32_t IDfound, if (applySceneBackground) memcpy(sceneBackgroundFrame, pSceneBackgroundFrame, g_serumData.fwidth * g_serumData.fheight * sizeof(uint16_t)); + if (applySceneBackground) { + sceneBackgroundWidth = g_serumData.fwidth; + sceneBackgroundHeight = g_serumData.fheight; + } memset(isdynapix, 0, g_serumData.fheight * g_serumData.fwidth); for (tj = 0; tj < g_serumData.fheight; tj++) { for (ti = 0; ti < g_serumData.fwidth; ti++) { @@ -3708,7 +3738,8 @@ void Colorize_Framev2(uint8_t* frame, uint32_t IDfound, (frameBackgroundMask[tk] > 0)) { if (isdynapix[tk] == 0) { if (applySceneBackground) { - pfr[tk] = sceneBackgroundFrame[tk]; + pfr[tk] = GetSceneBackgroundPixel(ti, tj, g_serumData.fwidth, + g_serumData.fheight); } else if (!suppressFrameBackgroundImage) { pfr[tk] = frameBackground[tk]; if (ColorInRotation(IDfound, pfr[tk], &prot[tk * 2], @@ -3726,7 +3757,8 @@ void Colorize_Framev2(uint8_t* frame, uint32_t IDfound, if (isdynapix[tk] == 0) { if (blackOutStaticContent && hasBackground && (frame[tk] > 0) && (frameBackgroundMask[tk] > 0)) { - pfr[tk] = sceneBackgroundFrame[tk]; + pfr[tk] = GetSceneBackgroundPixel(ti, tj, g_serumData.fwidth, + g_serumData.fheight); } else { pfr[tk] = frameColors[tk]; if (ColorInRotation(IDfound, pfr[tk], &prot[tk * 2], @@ -3811,6 +3843,10 @@ void Colorize_Framev2(uint8_t* frame, uint32_t IDfound, memcpy(sceneBackgroundFrame, pSceneBackgroundFrame, g_serumData.fwidth_extra * g_serumData.fheight_extra * sizeof(uint16_t)); + if (applySceneBackground) { + sceneBackgroundWidth = g_serumData.fwidth_extra; + sceneBackgroundHeight = g_serumData.fheight_extra; + } memset(isdynapix, 0, g_serumData.fheight_extra * g_serumData.fwidth_extra); for (tj = 0; tj < g_serumData.fheight_extra; tj++) { for (ti = 0; ti < g_serumData.fwidth_extra; ti++) { @@ -3825,7 +3861,9 @@ void Colorize_Framev2(uint8_t* frame, uint32_t IDfound, (frameBackgroundMaskExtra[tk] > 0)) { if (isdynapix[tk] == 0) { if (applySceneBackground) { - pfr[tk] = sceneBackgroundFrame[tk]; + pfr[tk] = GetSceneBackgroundPixel(ti, tj, + g_serumData.fwidth_extra, + g_serumData.fheight_extra); } else if (!suppressFrameBackgroundImage) { pfr[tk] = frameBackgroundExtra[tk]; if (ColorInRotation(IDfound, pfr[tk], &prot[tk * 2], @@ -3844,7 +3882,9 @@ void Colorize_Framev2(uint8_t* frame, uint32_t IDfound, if (isdynapix[tk] == 0) { if (blackOutStaticContent && hasBackground && (frame[tl] > 0) && (frameBackgroundMaskExtra[tk] > 0)) { - pfr[tk] = sceneBackgroundFrame[tk]; + pfr[tk] = GetSceneBackgroundPixel(ti, tj, + g_serumData.fwidth_extra, + g_serumData.fheight_extra); } else { pfr[tk] = frameColorsExtra[tk]; if (ColorInRotation(IDfound, pfr[tk], &prot[tk * 2], @@ -4375,7 +4415,8 @@ static void ConfigureSceneEndHold(uint16_t sceneId, bool interruptable, uint8_t sceneOptions) { sceneEndHoldUntilMs = 0; sceneEndHoldDurationMs = 0; - if (sceneOptions != 0 || interruptable || !g_serumData.sceneGenerator) { + if ((sceneOptions & FLAG_SCENE_FINISH_MODE_MASK) != 0 || interruptable || + !g_serumData.sceneGenerator) { return; } @@ -4386,6 +4427,29 @@ static void ConfigureSceneEndHold(uint16_t sceneId, bool interruptable, } } +static bool ShouldSuppressFinishedSceneRetrigger(uint32_t triggerId) { + if (!g_serumData.sceneGenerator || !g_serumData.sceneGenerator->isActive() || + triggerId > 0xffff) { + return false; + } + + uint16_t frameCount = 0; + uint16_t durationPerFrame = 0; + bool interruptable = false; + bool startImmediately = false; + uint8_t repeat = 0; + uint8_t sceneOptions = 0; + if (!g_serumData.sceneGenerator->getSceneInfo( + static_cast(triggerId), frameCount, durationPerFrame, + interruptable, startImmediately, repeat, sceneOptions)) { + return false; + } + + const bool sceneIsBackground = + (sceneOptions & FLAG_SCENE_AS_BACKGROUND) == FLAG_SCENE_AS_BACKGROUND; + return startImmediately || sceneIsBackground; +} + static void ForceNormalFrameRefreshAfterSceneEnd(void) { // Force Identify_Frame(normal) to emit a concrete frame ID once after // scene teardown, even when the underlying DMD frame did not change. @@ -4509,6 +4573,7 @@ static uint32_t Serum_ColorizeWithMetadatav2Internal(uint8_t* frame, sceneFrameCount, sceneDurationPerFrame, sceneOptionFlags, sceneInterruptable, sceneStartImmediately, sceneRepeatCount); sceneFrameCount = 0; + sceneIsLastForegroundFrame = false; sceneIsLastBackgroundFrame = false; sceneEndHoldUntilMs = 0; sceneEndHoldDurationMs = 0; @@ -4574,12 +4639,13 @@ static uint32_t Serum_ColorizeWithMetadatav2Internal(uint8_t* frame, if (!sceneFrameRequested) { memcpy(lastFrame, frame, g_serumData.fwidth * g_serumData.fheight); lastFrameId = frameID; + const uint32_t matchedTriggerId = g_serumData.triggerIDs[lastfound][0]; if (sceneFrameCount > 0 && (sceneOptionFlags & FLAG_SCENE_AS_BACKGROUND) == FLAG_SCENE_AS_BACKGROUND && lastTriggerID < MONOCHROME_TRIGGER_ID && - g_serumData.triggerIDs[lastfound][0] == lastTriggerID) { + matchedTriggerId == lastTriggerID) { // New frame has the same Trigger ID, continuing an already running // seamless looped scene. // Wait for the next rotation to have a smooth transition. @@ -4592,8 +4658,19 @@ static uint32_t Serum_ColorizeWithMetadatav2Internal(uint8_t* frame, (sceneOptionFlags & FLAG_SCENE_AS_BACKGROUND) == FLAG_SCENE_AS_BACKGROUND && lastTriggerID < MONOCHROME_TRIGGER_ID && - g_serumData.triggerIDs[lastfound][0] == lastTriggerID) { + matchedTriggerId == lastTriggerID) { // New frame has the same Trigger ID, continuing an already running. + } else if (sceneIsLastForegroundFrame && + lastTriggerID < MONOCHROME_TRIGGER_ID && + matchedTriggerId == lastTriggerID && + ShouldSuppressFinishedSceneRetrigger(matchedTriggerId)) { + // Keep the last visible scene frame instead of immediately + // retriggering the same scene on the next matching normal frame. + if (g_profileDynamicHotPaths) { + ++g_profileSameFrameReturns; + } + MaybeLogDynamicHotPathProfileWindow(sceneFrameRequested); + return IDENTIFY_SAME_FRAME; } else { if (sceneFrameCount > 0 && (sceneOptionFlags & FLAG_SCENE_RESUME_IF_RETRIGGERED) == @@ -4612,6 +4689,7 @@ static uint32_t Serum_ColorizeWithMetadatav2Internal(uint8_t* frame, sceneRepeatCount); } sceneFrameCount = 0; + sceneIsLastForegroundFrame = false; sceneIsLastBackgroundFrame = false; sceneEndHoldUntilMs = 0; sceneEndHoldDurationMs = 0; @@ -5094,15 +5172,21 @@ uint32_t Serum_RenderScene(void) { sceneFrameCount = 0; mySerum.rotationtimer = 0; ForceNormalFrameRefreshAfterSceneEnd(); + const uint8_t sceneFinishMode = + sceneOptionFlags & FLAG_SCENE_FINISH_MODE_MASK; - switch (sceneOptionFlags) { + switch (sceneFinishMode) { case FLAG_SCENE_BLACK_WHEN_FINISHED: + sceneIsLastForegroundFrame = false; + sceneIsLastBackgroundFrame = false; if (mySerum.frame32) memset(mySerum.frame32, 0, 32 * mySerum.width32); if (mySerum.frame64) memset(mySerum.frame64, 0, 64 * mySerum.width64); FinishProfileRenderedFrameOperationMaybe(); break; case FLAG_SCENE_SHOW_PREVIOUS_FRAME_WHEN_FINISHED: + sceneIsLastForegroundFrame = false; + sceneIsLastBackgroundFrame = false; if (lastfound < MAX_NUMBER_FRAMES && FrameHasRenderableContent(lastfound)) { Serum_ColorizeWithMetadatav2(lastFrame); @@ -5123,6 +5207,10 @@ uint32_t Serum_RenderScene(void) { } if (sceneOptionFlags & FLAG_SCENE_AS_BACKGROUND) { sceneIsLastBackgroundFrame = true; + sceneIsLastForegroundFrame = false; + } else { + sceneIsLastForegroundFrame = true; + sceneIsLastBackgroundFrame = false; } break; } @@ -5234,14 +5322,20 @@ uint32_t Serum_RenderScene(void) { mySerum.rotationtimer = 0; sceneNextFrameAtMs = 0; ForceNormalFrameRefreshAfterSceneEnd(); + const uint8_t sceneFinishMode = + sceneOptionFlags & FLAG_SCENE_FINISH_MODE_MASK; - switch (sceneOptionFlags) { + switch (sceneFinishMode) { case FLAG_SCENE_BLACK_WHEN_FINISHED: + sceneIsLastForegroundFrame = false; + sceneIsLastBackgroundFrame = false; if (mySerum.frame32) memset(mySerum.frame32, 0, 32 * mySerum.width32); if (mySerum.frame64) memset(mySerum.frame64, 0, 64 * mySerum.width64); break; case FLAG_SCENE_SHOW_PREVIOUS_FRAME_WHEN_FINISHED: + sceneIsLastForegroundFrame = false; + sceneIsLastBackgroundFrame = false; if (lastfound < MAX_NUMBER_FRAMES && FrameHasRenderableContent(lastfound)) { Serum_ColorizeWithMetadatav2(lastFrame); @@ -5261,6 +5355,10 @@ uint32_t Serum_RenderScene(void) { } if (sceneOptionFlags & FLAG_SCENE_AS_BACKGROUND) { sceneIsLastBackgroundFrame = true; + sceneIsLastForegroundFrame = false; + } else { + sceneIsLastForegroundFrame = true; + sceneIsLastBackgroundFrame = false; } break; } @@ -5294,7 +5392,7 @@ uint32_t Serum_ApplyRotationsv2(void) { uint32_t sizeframe; uint32_t now = GetMonotonicTimeMs(); - if (mySerum.frame32) { + if (mySerum.frame32 && (mySerum.flags & FLAG_RETURNED_32P_FRAME_OK)) { sizeframe = 32 * mySerum.width32; if (mySerum.modifiedelements32) memset(mySerum.modifiedelements32, 0, sizeframe); @@ -5327,7 +5425,7 @@ uint32_t Serum_ApplyRotationsv2(void) { } } } - if (mySerum.frame64) { + if (mySerum.frame64 && (mySerum.flags & FLAG_RETURNED_64P_FRAME_OK)) { sizeframe = 64 * mySerum.width64; if (mySerum.modifiedelements64) memset(mySerum.modifiedelements64, 0, sizeframe); @@ -5493,6 +5591,7 @@ SERUM_API uint32_t Serum_Scene_Trigger(uint16_t sceneId) { StopV2ColorRotations(); } ConfigureSceneEndHold(sceneId, sceneInterruptable, sceneOptionFlags); + sceneIsLastForegroundFrame = false; sceneIsLastBackgroundFrame = false; sceneCurrentFrame = 0; sceneNextFrameAtMs = 0; diff --git a/src/serum.h b/src/serum.h index aeefe60..ca9d2ad 100644 --- a/src/serum.h +++ b/src/serum.h @@ -13,10 +13,12 @@ typedef void(SERUM_CALLBACK* Serum_LogCallback)(const char* format, va_list args, const void* userData); +// mask for the mutually exclusive scene-finish behavior bits +#define FLAG_SCENE_FINISH_MODE_MASK 3 + enum { // default: when scene is finished, show last frame of the scene until a // new frame is matched. - // black screen after scene is finished FLAG_SCENE_BLACK_WHEN_FINISHED = 1, // show last frame before scene started