fix(player): an override reaches a frame that did not move - #314
Merged
Naruto merged 2 commits intoSep 13, 2026
Conversation
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
deleted the
fix/an-override-reaches-a-frame-that-did-not-move
branch
September 13, 2026 14:14
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
So the call was accepted, the runtime held it, and nothing drew. Four ways to meet it:
advance(0.0), or anyadvance()whose delta does not carry the integer frame over — a host driving its own playback.is_playingto the runtime, but its frame never moves again on its own.!ss_runtime_is_playingearly-return above the dedup and never reaches a redraw at all.ANIMATION_PROCESS_MANUALis the mode where this is most visible, because there the node ticks the player only when the host callsadvance()— andadvance()is playback, not the override layer.set_frame_no()always redraws (_seek_and_redrawhas 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_dirtyalready had._overrides_dirtyis 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 ownredraw_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.SpriteStudioPlayer2Dcallsredraw_pending_overrides()from its idle notification underMANUAL. 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 theIDLEarm to cover both — which is whatset_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_updatestill 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.sh—38 cases, 108 assertions, 0 failed, 0 skipped(was 35 / 101). Three cases added totest_overrides: a frame that did not move, a paused player, a stopped one.is_part_hidden()reads_part_hidden, which only_drawAnimationfills, so it is the headless observable for "the override reached the draw" rather than "the runtime accepted it".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.mdgains a pure mask inside an Instance part._drawAnimationrenders coverage only when!_parent_drivenand_bubble_child_clip_writerscarries 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.