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

Add a Classic mod option to use stable's hit windows #26452

Open
wants to merge 14 commits into
base: master
Choose a base branch
from

Conversation

Detze
Copy link
Contributor

@Detze Detze commented Jan 9, 2024

Supersedes #26419. Resolves #11311.

Creates new classes, LegacyHitWindows and InclusiveLegacyHitWindows, which override methods that perform judgements, to be used by standard and taiko, and mania, respectively.

The theoretical basis for the judgement formulas is explained here.

The taiko miss hit window case is solved by adjusting the miss hit window value to one equivalent to stable's miss hit window. I deemed this much less complex than implementing a mechanism to perform separate checks for different hit results just to handle this case.

Adds an option to Classic mod for osu!, taiko and mania to use legacy (floored and half-integer) hit window values.

TestSceneSliderLateHitJudgement.cs had to be corrected - the test was failing because the hardcoded hit errors assumed too lenient hit windows.

I've also corrected two tests which used generic HitWindows while testing a particular legacy ruleset. Both tests worked one way or the other, so this is a minor change.

While testing mania, I've also noticed that there is another factor contributing to hit windows not matching stable. Mania uses base_ranges in HitWindows for its hit windows. These ranges match stable, except for Perfect:

new DifficultyRange(HitResult.Perfect, 22.4D, 19.4D, 13.9D),

On stable, it would be (16, 16, 16). Apparently this was done intentionally, however I corrected this, for the same reasons that this PR exists in the first place (it is desirable to not devalue or overvalue historical stable scores, and this would be a significant deviation). I think it would also be a good idea to move these ranges into ManiaHitWindows, and set something more intuitive as base_ranges as the default for new rulesets, instead of imposing mania's legacy hit windows onto them. I don't see any reasons not to do that, since base_ranges only seem to be used for mania in this codebase, and custom ruleset developers can always provide their own ranges if they dislike this change, so I've gone ahead and also done that. This broke TestSceneGameplaySampleTriggerSource which used the wrong hit object class and hardcoded a value used in the test. This was also corrected.

Testing has been performed by watching a replay on stable, live lazer and lazer with the change applied. It's best if the replay is of a play from stable, not lazer, because current lazer's hit result counts aren't the desired ones due to hit windows not matching, so it's difficult to get a ground-value judgement counts list to compare with.

Comparison of stable replays result counts

standard and taiko

Two scores are used for standard, one with and without DT, to test speed rate

score/replay link ruleset result counts (stable) lazer (live) lazer (new) notes
score link standard 553/21/3/0 561/14/2/0 553/21/3/0 1.0x speed
score link standard 1269/78/4/4 1275/72/4/4 1269/78/4/4 1.5x speed
score link taiko 491/92/11 493/90/11 491/92/11 taiko

Conclusion: standard and taiko hit windows seem to work as expected in this test.

mania

Mania is trickier to test using stable replays for several reasons:

  • as described here, converts have drastically different hit windows in stable than in lazer; currently the only result count that should the same is the Perfects count,
  • hold note heads and tails give judgements, changing the result counts,
  • as a result of both stable and lazer using inclusive judgements for mania, and replay input data being integers, there should be no difference in judgements for integer hit windows; this means best beatmaps to test would have fractional OD.

Fortunately there exists a ranked and not converted mania map with only short notes and fractional OD: link.

Note that live lazer's Perfect hit windows are different from stable's.

score/replay link stable lazer (live) lazer (new) notes
score link 1161/278/57/11/5/24 1158/297/41/9/7/24 1158/297/41/9/7/24 convert, integer OD, 1.5x speed
score link 1171/283/45/12/4/21 1171/301/28/11/7/18 1171/301/28/11/7/18 convert, integer OD, 1.0x speed
replay download link 1192/554/164/33/4/34 1163/585/163/31/6/33 1194/554/163/31/6/33 fractional OD, 1.5x speed; far closer to stable, but doesn't fully match
replay download link 1092/665/156/35/17/16 634x 1035/722/156/35/18/15 636x 1092/665/156/35/18/15 636x fractional OD, 1.0x speed; almost matches; interestingly, one Miss too few, or perhaps one Meh too many

Conclusion: even after the changes, mania doesn't quite match stable.

On 100% speed rate, the hit windows seem to work well in this test, but not quite ideally. The last score in the table differs slightly from stable, and I'm not quite sure why. One note is judged as a Meh, instead of a Miss. The hit results match when the Meh hit window is made 5 ms stricter (but not 4.5 ms stricter). But I don't see why that would be the right thing to do. The max combo should match on short notes but it does not. This might be a hint. More research is required, possibly by analyzing the replay data.

Non-100% speed rate hit results don't match particularly well. The Perfect counts on the convert don't match. On the third score, new lazer matches stable much more closely than live lazer, but it's still quite far from perfectly matching. Something is not quite right with speed rates, and more research is required. This doesn't appear to be the fault of the change either, as live lazer with just the Perfect hit window change also doesn't result in the same Perfect result count as stable on the third score.

Closing

In general, stable seems more closely matched after this change, especially on standard and taiko.

The tests in the scene from the superseded PR now pass. All other automatic tests also pass. Feedback and more testing welcome. Since the testing is not completely exhaustive, it is not impossible that there exist corner cases that still are not matching. Possible future directions:

  • do more research in order to completely match mania,
  • make the tests verifiable using osu!stable more directly, instead of using hardcoded assertions, to remove any doubt that the changes are correct,
  • testing all possible hit results (for example, taiko and mania miss hit windows),
  • add tests for speed rates, especially mania,
  • add hit window edge tests for a non-legacy ruleset,
  • watch more replays
  • consider whether and how mania converts, as well as other gameplay differences, should be matched,
  • solve the legacy score encoding problem affecting lazer replays.

Copy link
Collaborator

@bdach bdach left a comment

Choose a reason for hiding this comment

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

basic first impressions pass before i even attempt to verify this against ground truth (which will likely take days so i don't want to spend too much time on that before getting the shape of this correct)


/// <summary>
/// Retrieve a valid list of <see cref="DifficultyRange"/>s representing hit windows.
/// Defaults are provided but can be overridden to customise for a ruleset.
/// </summary>
protected virtual DifficultyRange[] GetRanges() => base_ranges;

public class LegacyHitWindows : HitWindows
Copy link
Collaborator

Choose a reason for hiding this comment

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

There's no sense calling things "legacy" if they're just going to exist forevermore.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I don't really agree with this argument. I used this word because standard, taiko, catch and mania are called legacy rulesets in the codebase:

/// Check whether this <see cref="IRulesetInfo"/>'s online ID is within the range that defines it as a legacy ruleset (ie. either osu!, osu!taiko, osu!catch or osu!mania).

And some kind of word is necessary (at least with the new class approach), because these hit windows would not be recommended to be used for new custom rulesets, and we're only keeping them to not diverge from stable. If you can think of a better name, feel free to recommend it (i also used HistoricalHitWindows before commiting).

/// <param name="difficulty">The difficulty parameter.</param>
/// <param name="range">The range of difficulty values.</param>
/// <returns>Value to which the difficulty parameter maps in the specified range.</returns>
public virtual double ValueFor(double difficulty, DifficultyRange range)
Copy link
Collaborator

Choose a reason for hiding this comment

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

  • needs better name (CalculateWindowFor()?)
  • needs to not be public

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Changed the name to a more descriptive one (HitWindowValueFor). I suppose the reason why I left it public is that another class might want to know the value of the "true" hit window (for example, for display when hovering over OD in song select). I'll just make it protected for now though.

/// <param name="timeOffset">The time offset.</param>
/// <param name="result">The <see cref="HitResult"/>.</param>
/// <returns>Whether the time offset is contained within the hit window of the hit result.</returns>
public virtual bool Contains(double timeOffset, HitResult result)
Copy link
Collaborator

Choose a reason for hiding this comment

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

If this is gonna be a separate method, then I'd argue there's a high chance that every single caller of WindowFor() is incorrect at that point, because that method existing may cause someone to wrongly attempt to use WindowFor() to determine if an offset is contained hit result window by locally (and wrongly) reimplementing Contains().

What I'm getting at is that either this should not exist if possible, or that other method should not be public anymore.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I refactored Contains to the separate method to make it a single source of truth (a judgement inequality was used hardcoded in two places, and I almost forgot to change it in the other place, which would've resulted in a bug). Again, possibly in the future another class might want to know the answer to the question Contains answers, but the visibility remark is valid, so I'll just make the method protected for now.

@bdach
Copy link
Collaborator

bdach commented Jan 9, 2024

I generally do not enjoy the complexity incurred by this solution. If possible, I would like to just adjust the edges of each hitwindow for each ruleset (by rounding, subtracting 0.5ms, adding 0.5ms) to match stable and remove the weird quirks of the subclassing and the overrides and the rounding. Have you investigated if that is feasible?

I know you posted this wall of text but I got lost halfway through reading that, somewhere around

Not sure if I'm understanding legacy score encoding correctly, but from looking at the linked code it seems to me that it's currently not working properly

I'm not sure what the issue is there and how it would be "resolved". The explanation is rather dense and appears to have a lot of mental shortcuts so it's difficult for me to follow it properly. I would appreciate an extended explanation of what that has to do with this PR.

@Detze
Copy link
Contributor Author

Detze commented Jan 9, 2024

Thank you for the review. I've answered all the comments.

The reason why I am not changing the default Contains formula is twofold:

  • new rulesets do not want to floor hit windows, as it is a pointless reduction of OD scale's precision,
  • I find the default method of comparing using <= more intuitive than <, especially when "true" hit windows are integral (after all, it is the equality sign currently used, despite being different from stable's), so this is what I'd keep as the default for new rulesets.

It is not possible to match hit windows by just changing the ranges's numbers while keeping the default Contains formula, because of fractional ODs. Therefore it seems there have to be some overrides for legacy hit windows.

Not sure if I'm understanding legacy score encoding correctly, but from looking at the linked code it seems to me that it's currently not working properly

I'm not sure what the issue is there and how it would be "resolved". The explanation is rather dense and appears to have a lot of mental shortcuts so it's difficult for me to follow it properly. I would appreciate an extended explanation of what that has to do with this PR.

TL;DR of that part:

  • unless I'm misunderstanding the legacy score encoding code (not impossible), it's currently downgrading to int wrongly (this is a possible cause of that issue of getting different result counts after rewatching a mania play),
  • after this change, it can be fixed in every scenario, it's downgrading correctly (except exactly half-integer hit error),
  • ...but we need to know the hit error of the object that was hit, not just the replay frame time; with just the replay frame time, I think it's not even possible to fix. (this only applies to half-integer hit errors)

Edit:

I'm not sure what the issue is there and how it would be "resolved".

I see now that by this, you might have been referring to the sentence "After this issue is resolved (...)" in my issue comment. "This issue" refers to the OP there, not the legacy score encoding being incorrect.

@bdach
Copy link
Collaborator

bdach commented Jan 11, 2024

  • I find the default method of comparing using <= more intuitive than <, especially when "true" hit windows are integral (after all, it is the equality sign currently used, despite being different from stable's), so this is what I'd keep as the default for new rulesets.

Realistically does this matter in any way? If the timeOffset is a double, there is only one case where <= and < can differ, and that is precisely at the edge of the hitwindow. I would like to just not care about that if we can. The chance of hitting that is rather small (possibly a little higher than maybe thinking naively considering frame pacing etc., but still at most a difference of a frame, i.e. insignificant IMO).

It is not possible to match hit windows by just changing the ranges's numbers while keeping the default Contains formula, because of fractional ODs.

Are you referring to the flooring of the hitwindow ranges in HitWindowValueFor()? That's fine to stay, but isn't what that method does basically "changing the ranges' numbers"? Why does Contains() have to also exist?

I'm really looking to simplify here if possible and so far I'd say that I see no clear and convincing argument for Contains() to remain existing.

@Detze
Copy link
Contributor Author

Detze commented Jan 11, 2024

You're right that the equality only makes a difference at the very edge, but when the ranges numbers are the round ones from the stable OD formula, then that edge is an integer, just like legacy replays, which is an important case of the judgement differing. If you change the range values to remedy that, then you're not flooring properly. I don't see an alternative solution you have in mind that doesn't have either of these issues, and I also don't see what's wrong with subtracting in the place where we also floor, which we have to.

I explained why I separated Contains already, and that's the only reason why I think it's better that way. I don't see any issues with it when it's protected, but maybe I'm missing something.

@bdach
Copy link
Collaborator

bdach commented Jan 11, 2024

And I explained what my problem is with Contains() is: namely that it invalidates methods like GetAllAvailableWindows() / WindowFor(), as they should not be used for anything anymore, maybe except for display, but that is also questionable given the entire rounding debacle. But they're still there, still public, and still ripe to misuse in local equality / containment checks. The only way to avoid this is to have HitWindows be an "oracle"-style class: you give it a hit offset, and it decides in its own ways which hit result fits.

I dunno, seems like this is an impasse as the conversation is going in circles and I might have to approach this with a fresh mind from scratch myself.

@Detze
Copy link
Contributor Author

Detze commented Jan 11, 2024

Would making Contains private and not virtual and using an optional bool is_inclusive in HitWindows constructor to change Contains inequality sign resolve your concern? I am merely not a fan of hard coding a function in two separate places, surely there's an issue-free solution here to not have to do that.

@bdach
Copy link
Collaborator

bdach commented Jan 11, 2024

That's still not solving the issue. The issue is that the numerical hitwindow ranges are publicly exposed - despite the fact that they are used by rulesets differently - and are free to be compared wrong by anyone. If implementations are going to differ in how comparisons against the hitwindow ranges are done, the raw values should not be exposed except if explicitly signposted that they are display values only and should not be used for... well anything really at this point probably?

In other words: GetAllAvailableWindows() / WindowFor() should either be deleted, or be private/protected. Fiddling with Contains() further changes nothing in this respect.

@Detze
Copy link
Contributor Author

Detze commented Jan 12, 2024

I see now. There's definitely a problem to resolve here.

There's already code that hard-assumes that containment in a hit window is always judged using <=. For example:

if (timeOffset < -Head.HitObject.HitWindows.WindowFor(HitResult.Miss))

I'd argue that the hard-coding of this assumption is the real problem, WindowFor being public could be a problem even in the pre-PR codebase, and my varying of the Contains function merely made it even easier to write faulty code in a caller. There is nothing stopping anyone from writing timeOffset < WindowFor(result) locally in a caller right now. Even if WindowFor and GetAllAvailableWindows were made not public, a sufficiently insane caller could always use IBeatmapDifficultyInfo.DifficultyRange directly. Not exposing hit window values seems hard too, since as you note, callers might want them for other things, such as display, and a mere written warning will not stop a developer who didn't read it. I'm not sure there is anything that can be done then to prevent such mistakes though. It might be desirable to rethink the entire class's design and rewrite the callers at some point?

I'm thinking back to the idea of making Contains always use <= now. I might have been too quick to discard it. Suppose that a hit in a standard OD 10 legacy replay was 20 ms off. That needs to be out of the Great hit window to match stable. The difficulty range value is 20 (can't be anything else - we can't add/subtract non-integers, as that breaks proper flooring). I forgot that while timeOffset <= 20 would deem that hit in, timeOffset <= floor(20) - 0.5 would correctly deem it out. In fact, abs(hit_error) <= floor(hit_window) - 0.5 is equivalent to abs(hit_error) < floor(hit_window) - 0.5 everywhere except when hit_error is a half-integer. Fortunately, that will never happen on a score from stable, because both object time and hit time there are integers. I believe speed rate doesn't turn them into half-integers either.

Let's just try keeping the current <= Contains formula and see if it works well. Worth noting for legacy score encoding that this makes half-integer hit errors now always in.

Annoyingly, while trying to verify by watching replays from the OP (particularly, the standard DT one), I hit the issue of judgement counts not matching between rewatches. I presume it might be due to the fact that I seeked to the end (if I remember correctly, the result counts from the OP were noted after watching replays without seeking), and there seem to be open issues noting that already. I'll note that:

  • I am pretty sure replays I watched while AFKing always worked normally (edit: it is possible for result counts to be different between rewatches without seeking or alt-tabbing, although it seems it's less likely),
  • alt-tabbing without seeking also made a difference once,
  • interestingly, the hits distribution graphs don't differ:
Screenshots

osu!_5P90K6pfgY
osu!_wTij9D5uM0

  • seeking on mania or taiko never made a difference; could be a standard-only issue (slider ticks or tail issue?)

@Detze
Copy link
Contributor Author

Detze commented Jan 25, 2024

I've performed fairly extensive testing aiming to verify the matching of the proposed hit windows against the ground-truth. With this PR, all legacy "true" hit window values are half-integers, so matching integer hit errors in legacy replays is sufficient to prove fractional hit errors also match. A utility was created that automatically sends inputs no earlier than a given hit error in stable, and used to observe the behavior around the hit window edges on stable and then lazer. For example, I've verified that on a OD 10 osu!mania map, the behavior of a 121 ms (rounded) early hit (Meh) and a 122 ms early hit (Miss) matches on both stable and this PR:

mania: early Meh

stable-mania-meh-early
lazer-mania-meh-early

The matching of other hit windows across the rulesets is verified similarly:

mania: early Great

stable-mania-great-early
lazer-mania-great-early

mania: late Great

stable-mania-great-late
lazer-mania-great-late

mania: early Miss

stable-mania-miss-early-1
lazer-mania-miss-early-1
stable-mania-miss-early-2
lazer-mania-miss-early-2

osu!: early Great

stable-osu-great-early
lazer-osu-great-early

osu!: late Great

stable-osu-great-late
lazer-osu-great-late

osu!: early Meh

stable-osu-meh-early
lazer-osu-meh-early

osu: late Meh

stable-osu-meh-late
lazer-osu-meh-late

osu: early Miss

stable-osu-miss-early
lazer-osu-miss-early

taiko: early Great

stable-taiko-great-early
lazer-taiko-great-early

taiko: late Great

stable-taiko-great-late
lazer-taiko-great-late

taiko: early Miss

stable-taiko-miss-early
lazer-taiko-miss-early

taiko: early Ok

stable-taiko-ok-early
lazer-taiko-ok-early

taiko: late Ok

stable-taiko-ok-late
lazer-taiko-ok-late

One disparity was found: in osu!mania on stable, late 50s are impossible, resulting in a miss; there is no such special case on lazer. This isn't mentioned in the lazer gameplay differences article nor Walavouchey's table in the post on the issue, however it is documented in this article on the wiki since this commit, which is said to be sourced from stable:
ppy/osu-wiki@3795d6d#diff-182fdf1bbbc79c215c413a619f2aacd28cb4f82e32cfa813c5e3d0eb7d101171R39

If a replay of such a play from stable is played on lazer, the late hit would result in a Meh, not a Miss:

mania: late Meh

stable-mania-meh-late
lazer-mania-meh-late

This is likely the cause of the nomod mania replay in the OP having one extra Meh instead of a Miss in lazer, as there is one late Meh hit in the replay (it's not around the biggest combo of the play though, so it doesn't explain the max combo disparity). Unsure whether reimplementing this behavior is desirable or not, it seems unintuitive to me and I wasn't aware of it before. If not, then mania will be slightly easier on lazer than stable, and thus stable's scores's accuracy might be slightly worse unless replayed on lazer. Unsure whether this is acceptable.

I still don't know how to correctly match mania speed rate hit windows, which I now believe is the only remaining case of hit windows not matching not intentionally, and which is incorrect also on live lazer. Even by hardcoding parameters in various places, I've not been able to obtain the same result counts as stable on the linked replays, and I believe the mania hit window formulas currently in the code are correct anyway. The mania map's "true" hit windows on stable are x.(3) ms for Perfect and Great, and x.(6) ms for Good, Ok and Meh, so together with the results in the OP, this might suggest that lazer is somehow too lenient with the former and too strict with the latter. However, I've tested a DT play with similar hit window values in standard and there were no issues. Perhaps it's a floating point number precision issue? Deeper digging is needed.

Edit: Actually, it seems the DT play's hit result counts discrepancy might also be caused by the late Meh hit window. Investigation ongoing.

Edit 2: Interestingly, the mania late 100 hit window on stable is stricter than the other late hit windows, I presume the late 100 special case comparison for some reason uses an exclusive comparison instead of inclusive like all the other hit windows.

mania: late Ok

stable-mania-meh-late-96
lazer-mania-meh-late-96

@Detze
Copy link
Contributor Author

Detze commented Jan 31, 2024

The remaining mania replay hit result count mismatches are due to two disparities not related to legacy hit window values:

Disparity 1

1-lazer-frame_before

Consider the third column from the right. One frame after the screenshot, a tap is made in it.

On stable, the tap is registered on the lower note as a 200. On lazer, the tap is registered on the higher note as a Perfect, and the lower note is left behind to become a Miss.

This gameplay change between stable and lazer was made intentionally as part of this discussion, added with this PR, and has been called 'mania note lock'.

Disparity 2

2-stable-frame_before

Consider the second column from the right. Before the screenshot was taken, the gray pink note was hit 220 ms early (1.5x less than that in real-time), and resulted in a Miss in both lazer and stable, as this value is well within the miss hit window.

A tap has been made in the column around the time of taking the screenshot, 218 ms earlier than the start time of the pink note above the gray pink note.

On stable, the early hit is forgiven, and a future third hit is allowed to successfully hit the note. On lazer, the pink note is considered a Miss, and the future third hit is redundant.

Not 100% sure what the cause of this disparity is. I believe the 218 ms early hit is still before the gray pink note's time, so possibly the gray pink note ate the input for this reason, even though the note was already a Miss?

The hit times likely mean that the cause of this disparity is not related to hit window values.

Thus, they're outside the scope of this PR.

After the above testing, I'm fairly confident legacy hit windows match correctly.

There are two ways in which the legacy hit window formula might be used: ruleset-wide, or only as an option in Classic mod. The Classic-only approach seems preferable (more intuitive hit window values and formula, increased precision of the OD scale, preserves result counts of scores set on lazer). However, it would require fixing legacy score encoding. Currently, lazer is encoding hit times very wrongly around the hit window edges, resulting in a mismatch in hit result counts between lazer gameplay and replays.

Regardless, I've changed the implementation of legacy hit windows in HitWindows and made them only apply with a Classic mod toggle. The other mismatches and issues would be resolved in follow-up PRs.

@Detze Detze force-pushed the hit-window-edges-adjustment branch from 8702f58 to af98eb7 Compare January 31, 2024 01:52
@@ -301,7 +301,7 @@ public bool OnPressed(KeyBindingPressEvent<ManiaAction> e)
// The tail has a lenience applied to it which is factored into the miss window (i.e. the miss judgement will be delayed).
// But the hold cannot ever be started within the late-lenience window, so we should skip trying to begin the hold during that time.
// Note: Unlike below, we use the tail's start time to determine the time offset.
if (Time.Current > Tail.HitObject.StartTime && !Tail.HitObject.HitWindows.CanBeHit(Time.Current - Tail.HitObject.StartTime))
if (Time.Current > Tail.HitObject.StartTime && !Tail.HitObject.HitWindows.CanEverBeHit(Time.Current - Tail.HitObject.StartTime))
Copy link
Collaborator

Choose a reason for hiding this comment

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

what is happening here? why "can ever be hit"? as opposed to what, can "sometimes" be hit? what is the meaning of this?

you talk about opening "follow up PRs", yet i am very uncomfortable with the complexity already incurred by this change as is, and think there's something going deeply wrong if things are headed this way. i do not believe it is necessary complexity.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is just a rename, the behavior of this method does not change with this PR. I found the name CanBeHit confusing, as when I first saw it, I thought it would be

Math.Abs(timeOffset) <= WindowFor(LowestSuccessfulHitResult()) (timeOffset contained within the hit window range),

but it's supposed to be

timeOffset <= WindowFor(LowestSuccessfulHitResult()) (always true for negative timeOffset).

The phrase "can ever be hit" is taken from pre-PR XML docs.

@Detze Detze changed the title Adjust hit windows to match osu!stable Add a Classic mod option to use stable's hit windows Feb 24, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging this pull request may close these issues.

HitResults are not calculated properly at the hit-window edges compared to osu!stable.
2 participants