Skip to content
Merged
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
28 changes: 28 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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(...)`.
Expand All @@ -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.
Expand Down
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 5 additions & 2 deletions src/SceneGenerator.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down
121 changes: 110 additions & 11 deletions src/serum-decode.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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++) {
Expand All @@ -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],
Expand All @@ -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],
Expand Down Expand Up @@ -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++) {
Expand All @@ -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],
Expand All @@ -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],
Expand Down Expand Up @@ -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;
}

Expand All @@ -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<uint16_t>(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.
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand All @@ -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) ==
Expand All @@ -4612,6 +4689,7 @@ static uint32_t Serum_ColorizeWithMetadatav2Internal(uint8_t* frame,
sceneRepeatCount);
}
sceneFrameCount = 0;
sceneIsLastForegroundFrame = false;
sceneIsLastBackgroundFrame = false;
sceneEndHoldUntilMs = 0;
sceneEndHoldDurationMs = 0;
Expand Down Expand Up @@ -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);
Expand All @@ -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;
}
Expand Down Expand Up @@ -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);
Expand All @@ -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;
}
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading