Skip to content

fix(player): an override reaches a frame that did not move - #314

Merged
Naruto merged 2 commits into
developfrom
fix/an-override-reaches-a-frame-that-did-not-move
Sep 13, 2026
Merged

fix(player): an override reaches a frame that did not move#314
Naruto merged 2 commits into
developfrom
fix/an-override-reaches-a-frame-that-did-not-move

Conversation

@Naruto

@Naruto Naruto commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator

What was wrong

The override layer lives in the runtime, and an override changes what the current frame draws without changing which frame it is. Every redraw path dedups on exactly that:

// SsInternalPlayer::update
float draw_frame = _sub_frame_enabled ? frame_no : floorf(frame_no);
if (previous_frame_no == draw_frame && !_needs_continuous_update()) return;

So the call was accepted, the runtime held it, and nothing drew. Four ways to meet it:

  • advance(0.0), or any advance() whose delta does not carry the integer frame over — a host driving its own playback.
  • A frame held at a section end.
  • A paused player: still is_playing to the runtime, but its frame never moves again on its own.
  • A stopped or finished player, which takes the !ss_runtime_is_playing early-return above the dedup and never reaches a redraw at all.

ANIMATION_PROCESS_MANUAL is the mode where this is most visible, because there the node ticks the player only when the host calls advance() — and advance() is playback, not the override layer.

set_frame_no() always redraws (_seek_and_redraw has no early-out), which is why the API looked inconsistent from the outside: moving the frame to the value it already had worked, and stepping it by zero did not.

The change

One flag, in the shape _inherited_mask_dirty already had.

_overrides_dirty is set by every override mutator — they all return through _override_applied(bool), so a call the runtime refused marks nothing — and cleared by _drawAnimation, which both the owner's seek and the parent's child walk go through.

It is read in three places:

  • update()'s dedup&& !_overrides_dirty, the same way a held-frame effect defeats it.
  • update()'s stopped / finished early-return — that path never reaches the dedup, so it takes its own redraw_pending_overrides(), which draws the frame the runtime is already holding and is a no-op when nothing is waiting.
  • _redraw_child_if_frame_changed — beside _inherited_mask_dirty, so an override on an Instance child lands the same way.

SpriteStudioPlayer2D calls redraw_pending_overrides() from its idle notification under MANUAL. That mode already keeps the notification for fire-and-forget audio and the coverage scale; this is the third thing it is for. While that branch was being written, _push_coverage_screen_scale() moved out of the IDLE arm to cover both — which is what set_animation_process_mode's comment already claims happens ("the node has to keep reporting its on-screen scale for the mask coverage pass") and did not.

What has not changed is that an override lands on the next tick rather than on the call. test_an_override_lands_on_the_next_update still states it, and the class reference still says so. What lands is the half after it: the tick does not have to move the frame.

Verification

  • scripts/build-extension.sh — clean.
  • scripts/run-tests.sh38 cases, 108 assertions, 0 failed, 0 skipped (was 35 / 101). Three cases added to test_overrides: a frame that did not move, a paused player, a stopped one. is_part_hidden() reads _part_hidden, which only _drawAnimation fills, so it is the headless observable for "the override reached the draw" rather than "the runtime accepted it".
  • Measured on screen as well, since the suite cannot see pixels: a player holding frame 1 of a converted cutscene, hiding one part and clearing it again, under MANUAL, stopped and paused. Before, all three shots were byte-identical; after, each pair differs and clearing restores the first.
  • scripts/build-docs.sh — both locales, --strict, no issues.

Also in this branch

A second commit, documentation only: ROADMAP.md gains a pure mask inside an Instance part. _drawAnimation renders coverage only when !_parent_driven and _bubble_child_clip_writers carries clipping writers and nothing else, so a pure mask written inside a sub-animation is dropped — the same pack clips correctly played directly and draws unclipped through an Instance part, which is measured. 40_mask.md §2-7 has a pure mask closing within the sub-animation, so it is a gap rather than the design, and it is the one thing blocking the only way SpriteStudio can express more than one independent clipping group. It came out of the same investigation; it is filed rather than fixed here.

An override changes what the current frame draws without changing which
frame it is, and every redraw path dedups on exactly that. So a host that
set one and did not happen to step the animation saw nothing: `advance(0.0)`,
a frame held at a section end, a paused player, a stopped one — and in
`ANIMATION_PROCESS_MANUAL` the node never ticks the player at all.

`_overrides_dirty` is set by every override mutator and cleared by
`_drawAnimation`, which both the owner's seek and the parent's child walk go
through. It defeats the dedup in `update()`, takes the stopped / finished
early-return down its own redraw, and joins `_inherited_mask_dirty` in
`_redraw_child_if_frame_changed`. MANUAL picks it up from the idle
notification, which that mode already keeps: `advance()` is playback and an
override is not.

`_push_coverage_screen_scale()` moves out of the IDLE branch while it is
there, which is what `set_animation_process_mode` already says happens.
`_drawAnimation` renders mask coverage only when `!_parent_driven`, and
`_bubble_child_clip_writers` carries clipping writers up and nothing else, so
a pure mask written inside a sub-animation is neither rasterised nor carried:
the pack clips correctly played on its own and draws unclipped through an
Instance part. `40_mask.md` §2-7 has a pure mask closing within the
sub-animation, so this is a gap rather than the design.

It earns a slot because a sub-animation is the only way SpriteStudio can
express more than one independent clipping group — scope is draw priority and
nothing else — and Adobe Animate's `Clpb` has no such limit, which is what
holds the conversion on the SSProjectGenerator side.
@Naruto
Naruto merged commit 74ea33d into develop Sep 13, 2026
0 of 2 checks passed
@Naruto
Naruto deleted the fix/an-override-reaches-a-frame-that-did-not-move branch September 13, 2026 14:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant