Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix slider tails sometimes not dimming correctly #27401

Merged
merged 2 commits into from
Feb 28, 2024

Conversation

bdach
Copy link
Collaborator

@bdach bdach commented Feb 27, 2024

Originally noticed during review of another change: #27369 (comment).

DrawableOsuHitObject tries to solve the initial dimming of objects by applying transform to a list of dimmable parts. For plain drawables this is safe, but if one of the parts is a DHO, it is not safe, because drawable transforms can be cleared at will.

In particular, on first use of a drawable slider, UpdateInitialTransforms() would fire via LoadComplete() on the DrawableSlider, but then, also via LoadComplete(), the DrawableSliderTail would update its own state and by doing so inadvertently clear the dim transform just added by the slider.

To fix, ensure dim transforms are applied to DHOs via ApplyCustomUpdateState.

Untitled.mp4

Originally noticed during review of another change:
ppy#27369 (comment).

`DrawableOsuHitObject` tries to solve the initial dimming of objects
by applying transform to a list of dimmable parts. For plain drawables
this is safe, but if one of the parts is a DHO, it is not safe,
because drawable transforms can be cleared at will.

In particular, on first use of a drawable slider,
`UpdateInitialTransforms()` would fire via `LoadComplete()` on the
`DrawableSlider`, but *then*, also via `LoadComplete()`,
the `DrawableSliderTail` would update its own state and by doing so
inadvertently clear the dim transform just added by the slider.

To fix, ensure dim transforms are applied to DHOs
via `ApplyCustomUpdateState`.
// as they may be cleared via the `updateState()` DHO flow,
// so use `ApplyCustomUpdateState` instead. which does not have this pitfall.
if (piece is DrawableHitObject drawableObjectPiece)
drawableObjectPiece.ApplyCustomUpdateState += (dho, _) => applyDim(dho);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, my concern is that UpdateInitialTransforms seems like it can be called more than once, which would bind this each time.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a good spot but I don't necessarily know what the best option forward is here.

It's either this atrocity:

diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuHitObject.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuHitObject.cs
index 35cab6459b..2e2831f9ae 100644
--- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuHitObject.cs
+++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuHitObject.cs
@@ -85,11 +85,16 @@ protected override void UpdateInitialTransforms()
                 // as they may be cleared via the `updateState()` DHO flow,
                 // so use `ApplyCustomUpdateState` instead. which does not have this pitfall.
                 if (piece is DrawableHitObject drawableObjectPiece)
-                    drawableObjectPiece.ApplyCustomUpdateState += (dho, _) => applyDim(dho);
+                {
+                    drawableObjectPiece.ApplyCustomUpdateState -= applyDimToDrawableObject;
+                    drawableObjectPiece.ApplyCustomUpdateState += applyDimToDrawableObject;
+                }
                 else
                     applyDim(piece);
             }
 
+            void applyDimToDrawableObject(DrawableHitObject drawableHitObject, ArmedState _) => applyDim(drawableHitObject);
+
             void applyDim(Drawable piece)
             {
                 piece.FadeColour(new Color4(195, 195, 195, 255));

or splitting the application in two for DHOs and non-DHOs? which also feels pretty crap...

diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuHitObject.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuHitObject.cs
index 35cab6459b..6bdf7ca82b 100644
--- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuHitObject.cs
+++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuHitObject.cs
@@ -75,29 +75,43 @@ protected override void OnFree()
 
         protected virtual IEnumerable<Drawable> DimmablePieces => Enumerable.Empty<Drawable>();
 
-        protected override void UpdateInitialTransforms()
+        protected override void LoadComplete()
         {
-            base.UpdateInitialTransforms();
+            base.LoadComplete();
 
             foreach (var piece in DimmablePieces)
             {
                 // if the specified dimmable piece is a DHO, it is generally not safe to tack transforms onto it directly
                 // as they may be cleared via the `updateState()` DHO flow,
                 // so use `ApplyCustomUpdateState` instead. which does not have this pitfall.
-                if (piece is DrawableHitObject drawableObjectPiece)
-                    drawableObjectPiece.ApplyCustomUpdateState += (dho, _) => applyDim(dho);
-                else
-                    applyDim(piece);
+                if (piece is not DrawableHitObject drawableObjectPiece)
+                    continue;
+
+                drawableObjectPiece.ApplyCustomUpdateState += (dho, _) => applyDim(dho);
             }
+        }
 
-            void applyDim(Drawable piece)
+        protected override void UpdateInitialTransforms()
+        {
+            base.UpdateInitialTransforms();
+
+            foreach (var piece in DimmablePieces)
             {
-                piece.FadeColour(new Color4(195, 195, 195, 255));
-                using (piece.BeginDelayedSequence(InitialLifetimeOffset - OsuHitWindows.MISS_WINDOW))
-                    piece.FadeColour(Color4.White, 100);
+                // see `LoadComplete()`.
+                if (piece is DrawableHitObject)
+                    continue;
+
+                applyDim(piece);
             }
         }
 
+        private void applyDim(Drawable piece)
+        {
+            piece.FadeColour(new Color4(195, 195, 195, 255));
+            using (piece.BeginDelayedSequence(InitialLifetimeOffset - OsuHitWindows.MISS_WINDOW))
+                piece.FadeColour(Color4.White, 100);
+        }
+
         protected sealed override double InitialLifetimeOffset => HitObject.TimePreempt;
 
         private OsuInputManager osuActionInputManager;

Copy link
Member

@frenzibyte frenzibyte Feb 28, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the former diff works it makes most sense to me, I would go with that to keep the ball rolling.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have applied it. I tend to agree it's the less bad option.

@peppy peppy merged commit 4506ad2 into ppy:master Feb 28, 2024
13 of 17 checks passed
@bdach bdach deleted the fix-slider-tail-dim branch February 28, 2024 14:28
bdach added a commit to bdach/osu that referenced this pull request Sep 19, 2024
…allbacks

Should fix ppy#28629.

First of all, to support the claim that this does fix the issue -
reproduction is rather difficult, but I believe I found a way to
maximise the chances of it reproducing by performing the following
steps:

1. Apply the following diff:

diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs
index eacd2b3e75..4c00da031a 100644
--- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs
+++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs
@@ -6,6 +6,7 @@
 using System;
 using System.Collections.Generic;
 using System.Linq;
+using System.Threading;
 using JetBrains.Annotations;
 using osu.Framework.Allocation;
 using osu.Framework.Bindables;
@@ -95,6 +96,8 @@ public DrawableSlider([CanBeNull] Slider s = null)
         [BackgroundDependencyLoader]
         private void load()
         {
+            Thread.Sleep(100);
+
             tailContainer = new Container<DrawableSliderTail> { RelativeSizeAxes = Axes.Both };

             AddRangeInternal(new Drawable[]

2. Download https://osu.ppy.sh/beatmapsets/1470790#osu/3023028 and open
   it in the editor.

3. Select all objects using Ctrl-A. Yes, it'll take a while, especially
   so with the patch above.

4. Rotate the selection by any amount using the right toolbox.

5. Press undo. The game should crash. If it doesn't spam redo and undo
   until it does.

Now to explain what the fix is.

In the issue thread I spent a considerable time hemming and hawing about
which of the dimmable pieces was null, which was a complete miss and a
failure at reading. Let's see the stack trace again:

	2024-06-27 02:15:20 [error]: at osu.Game.Rulesets.Osu.Objects.Drawables.DrawableOsuHitObject.<UpdateInitialTransforms>g__applyDim|15_0(Drawable piece) in /home/runner/work/osu-auth-client/osu-auth-client/osu/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuHitObject.cs:line 101

Line 101, you say? What could be null here?

	https://github.com/ppy/osu/blob/bd8addfb5f71568479d2c037d1b6e811de6e7fe6/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuHitObject.cs#L101

Okay, what's `InitialLifetimeOffset`, then?

	https://github.com/ppy/osu/blob/bd8addfb5f71568479d2c037d1b6e811de6e7fe6/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuHitObject.cs#L108

Yes, that's right. It's `HitObject` that is null here.

Now why does *that* happen? First, let's note that all stacks where this
died went through `UpdateState()`, which means that the problematic
`applyDim()` calls had to be `ApplyCustomUpdateState` event callbacks.
Meaning that the pieces where `HitObject` was null were DHOs themselves.

Recall that parent DHOs and child DHOs are pooled separately. Therefore,
there is no guarantee that any parent and child DHOs will remain
associated with each other for the entire duration of a gameplay
session; it is quite the contrary, and nobody should rely on that.
Unfortunately for us, adding a `applyDimToDrawableHitObject` callback to
a child object's `ApplyCustomUpdateState` *implicitly creates* such an
association, because it ends up allocating a closure that captures
`this` (meaning the parent in this context).

Therefore, this now creates a situation where a child DHO can attempt to
read state from a former parent DHO which can be in an indeterminate
state, and in fact, when this crashes, the former parent DHO is most
likely not even in use - hence the null `HitObject`.

Thus, the fix is to clear the association by unsubscribing from the
event when nested objects are cleared.

My hypothesis why the reproduction scenario is like it is, is that both
the sleep and the increased pressure on the pool (by way of selecting
all objects and therefore forcing the DHOs to be materialised beyond
pool capacity) increases the likelihood of getting a crosslink.
When pool pressure is low, it is much more likely that a parent DHO
*will* get the same child DHO again on re-application, even though
that is not guaranteed.

Just as an additional detail, note that the sentry issue for this lists
the "first seen" version as 2024.312.0, which is the release that
included ppy#27401 which would be directly
responsible for this mess.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants