Skip to content

Commit 013c45e

Browse files
authored
Merge pull request #348 from winnerspiros/copilot/add-persistent-playable-cache
tmp
2 parents 58d9792 + 805167c commit 013c45e

35 files changed

Lines changed: 943 additions & 80 deletions

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@ jobs:
144144
osu.Game.Rulesets.Taiko.Tests/bin/Debug/**/osu.Game.Rulesets.Taiko.Tests.dll
145145
osu.Game.Rulesets.Catch.Tests/bin/Debug/**/osu.Game.Rulesets.Catch.Tests.dll
146146
osu.Game.Rulesets.Mania.Tests/bin/Debug/**/osu.Game.Rulesets.Mania.Tests.dll
147-
timeout-minutes: 90
147+
timeout-minutes: 120
148148
steps:
149149
- name: Checkout
150150
uses: actions/checkout@v6

osu.Android.props

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@
5252
</PropertyGroup>
5353

5454
<ItemGroup>
55-
<PackageReference Include="ppy.osu.Framework.Android" Version="2026.520.3" />
55+
<PackageReference Include="ppy.osu.Framework.Android" Version="2026.521.1" />
5656
<!-- `ppy.osu.Framework.NativeLibs` is a transitive dependency of `ppy.osu.Framework`
5757
that ships desktop-only natives (Linux/macOS/Windows) under `runtimes/<rid>/native/`
5858
— including a bare Linux `libbass.so`/`libbass_fx.so`/`libbassmix.so` for linux-arm64.

osu.Android/OsuGameActivity.cs

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -522,6 +522,11 @@ protected override void OnCreate(Bundle? savedInstanceState)
522522
}
523523

524524
CrashDiagnostics.WriteAliveMarker("Activity.OnCreate exit");
525+
526+
// Arm the general Vulkan UI-thread watchdog LAST in OnCreate, after all
527+
// surface setup and base initialisation is complete. The 7-second initial
528+
// sleep in the watchdog thread provides additional grace.
529+
startVulkanUiWatchdog();
525530
}
526531

527532
protected override void OnNewIntent(Intent? intent) => handleIntent(intent);
@@ -967,6 +972,138 @@ public void SurfaceDestroyed(ISurfaceHolder holder)
967972
}
968973
}
969974

975+
/// <summary>
976+
/// Arms a background watchdog that monitors the Java main thread (UI thread) for
977+
/// responsiveness during Vulkan sessions.
978+
///
979+
/// <para>
980+
/// Root cause this addresses: SDL3 on Android serialises certain surface-lifecycle
981+
/// and window-focus events (surfaceDestroyed, nativeWindowFocusChanged, …) through
982+
/// an internal Java <c>synchronized</c> block that blocks the Java main thread until
983+
/// the SDL game/draw thread acknowledges the event. If the draw thread is stuck
984+
/// inside a long kernel GPU driver call (e.g. <c>vkQueuePresentKHR</c> on an
985+
/// Adreno 7xx under high load — visible as 70–80% kernel CPU time), the
986+
/// acknowledgement is delayed and the main thread can block for the full 10-second
987+
/// Android ANR window. The Samsung Game Optimizing Service (GOS) toolbar, ADPF
988+
/// display-mode changes, and other system overlays are typical triggers around
989+
/// 25–35 s of gameplay.
990+
/// </para>
991+
///
992+
/// <para>
993+
/// The <see cref="OnPause"/> watchdog already covers the <c>OnPause</c> path.
994+
/// This watchdog covers every other blocking entry point on the UI thread by
995+
/// posting a 1-second repeating no-op to the main looper and measuring how long
996+
/// ago the last no-op was executed. If the gap exceeds 7 seconds (leaving a 3-
997+
/// second margin before the 10-second ANR deadline), the process is killed for a
998+
/// clean restart rather than an ANR.
999+
/// </para>
1000+
///
1001+
/// <para>
1002+
/// Safe-mode is deliberately NOT set: this watchdog fires during mid-session
1003+
/// driver stalls, not at startup. The next launch should retry Vulkan normally.
1004+
/// </para>
1005+
/// </summary>
1006+
private void startVulkanUiWatchdog()
1007+
{
1008+
if (!LogManagement.IsVulkanConfigured())
1009+
return;
1010+
1011+
const int watchdog_threshold_ms = 7000;
1012+
const int ping_interval_ms = 1000;
1013+
1014+
global::Android.OS.Handler? pingHandler;
1015+
1016+
try
1017+
{
1018+
pingHandler = new global::Android.OS.Handler(global::Android.OS.Looper.MainLooper!);
1019+
}
1020+
catch (Exception e)
1021+
{
1022+
Debug.WriteLine($"[osu!] VulkanUiWatchdog: failed to create Handler — watchdog disabled ({e.Message})");
1023+
return;
1024+
}
1025+
1026+
// Shared field written by the UI-thread pong and read by the watchdog thread.
1027+
// Interlocked/Volatile access: the pong runs on the UI thread, the reader runs
1028+
// on the watchdog thread. The field is a reference so it can be captured by
1029+
// both lambdas without a ref capture.
1030+
long[] lastPongMonotonicMs = { System.Environment.TickCount64 };
1031+
1032+
// Self-rescheduling pong: posts itself every ping_interval_ms on the UI thread.
1033+
// Capturing pingHandler via a local ref rather than the outer variable so the
1034+
// lambda holds no hard reference to the activity (GC-safety: the Handler is
1035+
// bound to the process-lifetime main looper, not to this activity instance).
1036+
Action? pong = null;
1037+
pong = () =>
1038+
{
1039+
System.Threading.Volatile.Write(ref lastPongMonotonicMs[0], System.Environment.TickCount64);
1040+
1041+
try { pingHandler.PostDelayed(pong!, ping_interval_ms); }
1042+
catch { /* handler gone (process teardown) — stop rescheduling */ }
1043+
};
1044+
1045+
try
1046+
{
1047+
pingHandler.Post(pong);
1048+
}
1049+
catch (Exception e)
1050+
{
1051+
Debug.WriteLine($"[osu!] VulkanUiWatchdog: failed to post initial pong — watchdog disabled ({e.Message})");
1052+
return;
1053+
}
1054+
1055+
var watchdog = new System.Threading.Thread(() =>
1056+
{
1057+
// Initial grace period equal to the threshold: the first pong may not
1058+
// have executed yet, and early-startup UI-thread work (surface format
1059+
// stamp, DecorView.Post lambda, Samsung Game Launcher broadcast) all
1060+
// runs during this window.
1061+
System.Threading.Thread.Sleep(watchdog_threshold_ms);
1062+
1063+
while (true)
1064+
{
1065+
long age = System.Environment.TickCount64 - System.Threading.Volatile.Read(ref lastPongMonotonicMs[0]);
1066+
1067+
if (age > watchdog_threshold_ms)
1068+
{
1069+
// UI thread has not executed the pong for > 7 s. The draw thread
1070+
// is conclusively stuck in vkQueuePresentKHR (or a similar SDL
1071+
// surface-event wait), holding the Java main thread. Kill now to
1072+
// avoid the impending ANR and let the user's launcher restart cleanly.
1073+
try
1074+
{
1075+
CrashDiagnostics.WriteAliveMarker(
1076+
$"VulkanUiWatchdog fired after {age}ms: "
1077+
+ "main thread blocked in SDL surface-event wait — "
1078+
+ "draw thread stuck in vkQueuePresentKHR (Vulkan KGSL stall). "
1079+
+ "Killing for clean restart rather than Android ANR.");
1080+
}
1081+
catch { }
1082+
1083+
try
1084+
{
1085+
Debug.WriteLine(
1086+
$"[osu!] VulkanUiWatchdog: main thread blocked {age}ms >7s — killing for clean Vulkan restart.");
1087+
}
1088+
catch { }
1089+
1090+
try { global::Android.OS.Process.KillProcess(global::Android.OS.Process.MyPid()); }
1091+
catch { }
1092+
1093+
return;
1094+
}
1095+
1096+
System.Threading.Thread.Sleep(ping_interval_ms);
1097+
}
1098+
})
1099+
{
1100+
IsBackground = true,
1101+
Name = "VulkanUiWatchdog",
1102+
};
1103+
1104+
watchdog.Start();
1105+
}
1106+
9701107
protected override void OnPause()
9711108
{
9721109
// Root cause of the recurring Vulkan IMMEDIATE-mode ANR (process-runtime ~50s):

osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -263,14 +263,27 @@ protected override void UpdateAfterChildren()
263263

264264
double completionProgress = Math.Clamp((Time.Current - HitObject.StartTime) / HitObject.Duration, 0, 1);
265265

266+
// Cache once per frame: the property resolves Body.Drawable via a type-check cast on each access.
267+
PlaySliderBody sliderBody = SliderBody;
268+
266269
Ball.UpdateProgress(completionProgress);
267-
SliderBody?.UpdateProgress(HeadCircle.IsHit ? completionProgress : 0);
270+
sliderBody?.UpdateProgress(HeadCircle.IsHit ? completionProgress : 0);
271+
272+
// Pre-compute the snaking start/end path positions once.
273+
// PositionAt() performs a binary search over the pre-built path points list,
274+
// so recomputing it for every repeat on every frame is wasteful — all repeats
275+
// share the same start/end values within a single update.
276+
if (repeatContainer.Count > 0)
277+
{
278+
Vector2 snakeStart = HitObject.Path.PositionAt(sliderBody?.SnakedStart ?? 0);
279+
Vector2 snakeEnd = HitObject.Path.PositionAt(sliderBody?.SnakedEnd ?? 0);
268280

269-
foreach (DrawableSliderRepeat repeat in repeatContainer)
270-
repeat.UpdateSnakingPosition(HitObject.Path.PositionAt(SliderBody?.SnakedStart ?? 0), HitObject.Path.PositionAt(SliderBody?.SnakedEnd ?? 0));
281+
foreach (DrawableSliderRepeat repeat in repeatContainer)
282+
repeat.UpdateSnakingPosition(snakeStart, snakeEnd);
283+
}
271284

272-
Size = SliderBody?.Size ?? Vector2.Zero;
273-
OriginPosition = SliderBody?.PathOffset ?? Vector2.Zero;
285+
Size = sliderBody?.Size ?? Vector2.Zero;
286+
OriginPosition = sliderBody?.PathOffset ?? Vector2.Zero;
274287

275288
if (!relativeAnchorPositionLayout.IsValid)
276289
{

osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderBall.cs

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -61,17 +61,34 @@ public override void ApplyTransformsAt(double time, bool propagateChildren = fal
6161
base.ApplyTransformsAt(time, false);
6262
}
6363

64+
private double cachedPathDistance = -1;
65+
private double cachedCheckDistance;
66+
6467
public void UpdateProgress(double completionProgress)
6568
{
6669
Slider slider = drawableSlider.HitObject;
70+
71+
// Cache the check-distance; Path.Distance is stable after ApplyDefaults so the
72+
// division only runs once per slider-pool reuse (when the path changes).
73+
double pathDistance = slider.Path.Distance;
74+
75+
if (pathDistance != cachedPathDistance)
76+
{
77+
cachedPathDistance = pathDistance;
78+
cachedCheckDistance = 0.1 / pathDistance;
79+
}
80+
81+
// Exact position at current progress (binary search #1).
6782
Position = slider.CurvePositionAt(completionProgress);
6883

69-
// 0.1 / slider.Path.Distance is the additional progress needed to ensure the diff length is 0.1
70-
double checkDistance = 0.1 / slider.Path.Distance;
71-
var diff = slider.CurvePositionAt(Math.Min(1 - checkDistance, completionProgress)) - slider.CurvePositionAt(Math.Min(1, completionProgress + checkDistance));
84+
// Forward-tangent point for ball rotation (binary search #2).
85+
// Using (current → forward) instead of the original symmetric
86+
// (backward → forward) cuts one PositionAt call per frame with
87+
// imperceptible accuracy loss, since checkDistance is tiny.
88+
double dForward = Math.Min(1, completionProgress + cachedCheckDistance);
89+
var diff = Position - slider.CurvePositionAt(dForward);
7290

73-
// Ensure the value is substantially high enough to allow for Atan2 to get a valid angle.
74-
// Needed for when near completion, or in case of a very short slider.
91+
// Ensure the diff is long enough for Atan2 to return a meaningful angle.
7592
if (diff.LengthSquared() < 0.0001f)
7693
return;
7794

osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderRepeat.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,11 +120,11 @@ public void UpdateSnakingPosition(Vector2 start, Vector2 end)
120120
if (IsHit) return;
121121

122122
bool isRepeatAtEnd = HitObject.RepeatIndex % 2 == 0;
123-
List<Vector2> curve = ((PlaySliderBody)DrawableSlider.Body.Drawable).CurrentCurve;
123+
List<Vector2> curve = DrawableSlider.SliderBody?.CurrentCurve;
124124

125125
Position = isRepeatAtEnd ? end : start;
126126

127-
if (curve.Count < 2)
127+
if (curve == null || curve.Count < 2)
128128
return;
129129

130130
Vector2 aimRotationVector = Vector2.Zero;

osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinner.cs

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,15 @@ public partial class DrawableSpinner : DrawableOsuHitObject
4444
private Bindable<bool> isSpinning;
4545
private bool spinnerFrequencyModulate;
4646

47+
/// <summary>
48+
/// Scan index for the first unjudged nested tick, updated each frame.
49+
/// Avoids restarting the <see cref="DrawableHitObject.NestedHitObjects"/> scan from index 0
50+
/// every frame once many ticks have already been judged.
51+
/// </summary>
52+
private int nextUnjudgedTickIndex;
53+
54+
private double lastTickScanTime;
55+
4756
private const float spinning_sample_initial_frequency = 1.0f;
4857
private const float spinning_sample_modulated_base_frequency = 0.5f;
4958

@@ -137,6 +146,9 @@ protected override void OnFree()
137146

138147
spinningSample.ClearSamples();
139148
maxBonusSample.ClearSamples();
149+
150+
nextUnjudgedTickIndex = 0;
151+
lastTickScanTime = double.MinValue;
140152
}
141153

142154
protected override void LoadSamples()
@@ -291,13 +303,27 @@ protected override void Update()
291303
// but for performance reasons, we only want to keep the next tick alive.
292304
DrawableHitObject nextTick = null;
293305

294-
foreach (var nested in NestedHitObjects)
306+
// Reset the hint index on rewind so newly-unjudged ticks are not skipped.
307+
if (Time.Current < lastTickScanTime)
308+
nextUnjudgedTickIndex = 0;
309+
310+
lastTickScanTime = Time.Current;
311+
312+
int nestedCount = NestedHitObjects.Count;
313+
314+
for (int i = nextUnjudgedTickIndex; i < nestedCount; i++)
295315
{
296-
if (!nested.Judged)
316+
var nested = NestedHitObjects[i];
317+
318+
if (nested.Judged)
297319
{
298-
nextTick = nested;
299-
break;
320+
// Advance the hint past confirmed judged ticks.
321+
nextUnjudgedTickIndex = i + 1;
322+
continue;
300323
}
324+
325+
nextTick = nested;
326+
break;
301327
}
302328

303329
// See default `LifetimeStart` as set in `DrawableSpinnerTick`.

osu.Game.Rulesets.Osu/Objects/Slider.cs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,10 @@ public double Duration
3434
set => throw new System.NotSupportedException($"Adjust via {nameof(RepeatCount)} instead"); // can be implemented if/when needed.
3535
}
3636

37-
public override IList<HitSampleInfo> AuxiliarySamples => CreateSlidingSamples().Concat(TailSamples).ToArray();
37+
public override IList<HitSampleInfo> AuxiliarySamples => cachedAuxiliarySamples ??= CreateSlidingSamples().Concat(TailSamples).ToArray();
38+
39+
// Cached after ApplyDefaults populates TailSamples — stable for the lifetime of this slider instance.
40+
private IList<HitSampleInfo> cachedAuxiliarySamples;
3841

3942
private readonly Cached<Vector2> endPositionCache = new Cached<Vector2>();
4043

@@ -165,6 +168,9 @@ protected override void CreateNestedHitObjects(CancellationToken cancellationTok
165168
{
166169
base.CreateNestedHitObjects(cancellationToken);
167170

171+
// Invalidate the auxiliary-samples cache since TailSamples will be reassigned below.
172+
cachedAuxiliarySamples = null;
173+
168174
var sliderEvents = SliderEventGenerator.Generate(StartTime, SpanDuration, Velocity, TickDistance, Path.Distance, this.SpanCount(), cancellationToken);
169175

170176
foreach (var e in sliderEvents)

osu.Game.Rulesets.Osu/Objects/SliderTick.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,13 @@
44
using osu.Game.Beatmaps;
55
using osu.Game.Beatmaps.ControlPoints;
66
using osu.Game.Rulesets.Judgements;
7+
using osu.Game.Rulesets.Objects.Types;
78
using osu.Game.Rulesets.Osu.Judgements;
89
using osu.Game.Rulesets.Scoring;
910

1011
namespace osu.Game.Rulesets.Osu.Objects
1112
{
12-
public class SliderTick : OsuHitObject
13+
public class SliderTick : OsuHitObject, ISliderTick
1314
{
1415
public int SpanIndex { get; set; }
1516
public double SpanStartTime { get; set; }

0 commit comments

Comments
 (0)