1
0
mirror of https://github.com/ppy/osu.git synced 2026-06-05 22:34:48 +08:00

Compare commits

..

35 Commits

  • Implement new score multipliers (#37967)
    - Part of https://github.com/ppy/osu/issues/37818
    
    Updates score multipliers to latest proposals, and bumps replay version
    ready for recalculation.
    
    ---
    
    For anyone following along, **please wait for the incoming news post
    before sharing this publicly**. This is intended for internal
    development use at this point. There's still some time before these go
    live.
    
    ---------
    
    Co-authored-by: Bartłomiej Dach <dach.bartlomiej@gmail.com>
  • Fix edge cases around matchmaking queue notifications (#37942)
    Supersedes / closes https://github.com/ppy/osu/pull/37881
    Fixes https://github.com/ppy/osu/issues/37497
    Fixes https://github.com/ppy/osu/issues/37214
    Fixes https://github.com/ppy/osu/issues/36744
    
    ## Some ranked play matches getting stuck in the database
    
    This is an issue I've been investigating for a while now, and I have
    suspicions that it's related to the following sentry event:
    https://sentry.ppy.sh/organizations/ppy/issues/15519/events/7a0d5a390a2d4894ae382a1fdcfd54cc/
    
    ```
    Unobserved exception occurred via FireAndForget call: Cannot join a multiplayer room while already in one.
    ```
    
    While I haven't repro'd the exact failure case where `ends_at` is
    `NULL`, it does put the game into a weird state:
    
    
    https://github.com/user-attachments/assets/1d97cfbf-ae97-4f78-89bb-6b3f186c7c4e
    
    Should be fixed by 787ef7fe2d
    
    ## Game sometimes deadlocking when accepting queue invitations
    
    I've personally experienced this when waiting for the invitation
    notification to time out.
    
    
    https://github.com/user-attachments/assets/cc191cbd-d72a-4a2e-a300-81f22b3fc993
    
    Should be fixed by b238bf8aea
  • Add beatmap difficulty before mods as context for score multiplier calculations (#37921)
    - Part of https://github.com/ppy/osu/issues/37818
    
    Access to difficulty info is required for the upcoming multiplier
    proposals. All places providing difficulty info intentionally use
    `IBeatmapInfo` as the difficulty info exposed to the calculator should
    _always_ be pre-mods for our usecase.
    
    There's a couple of quirks:
    
    - The usage in `ScoreProcessor` is a bit troubling to me but I can't see
    a way to make it better without refactoring it. Essentially, we don't
    have a beatmap until `ApplyBeatmap` is called, but most usages of
    `ScoreProcessor` are setting `Mods` prior to `ApplyBeatmap` so there is
    a `null` check in the logic for when mods change. Additionally, this
    means a new bindable of the beatmap via `ApplyBeatmap` which also feels
    a bit dirty. Open to suggestions.
    - ~~`BeatmapLeaderboardScore.Tooltip` is using a null-forgiving on the
    `BeatmapInfo`, but there's basically no context available on if this is
    an issue - the only code path which sets the score is `SetContent` which
    has no callers, so it's essentially dead code. Makes sense given it's
    Select V1.~~
    
    ---------
    
    Co-authored-by: Bartłomiej Dach <dach.bartlomiej@gmail.com>
  • Obsolete Mod.ScoreMultiplier and remove all other references to it (#37846)
    - Part of https://github.com/ppy/osu/issues/37818
    - [x] Depends on https://github.com/ppy/osu/pull/37845
    
    Purely mechanical, split away for ease of review / due to size.
    
    ---------
    
    Co-authored-by: Dean Herbert <pe@ppy.sh>
  • Fix client not sending data relevant to replay to spectator server (#37919)
    - Related to https://github.com/ppy/osu/issues/37818, but of no material
    help to it at this point (too late for that)
    
    As noted in
    https://github.com/ppy/osu/pull/37845#discussion_r3297203361.
    
    Upon comparison of replays recorded by the client and by the server the
    affected fields are: total score without mods, and the list of user
    pauses. Additionally, the date of setting the score may differ -
    server-side it seems to be written with UTC+0 while client-side it's
    written using the local timezone offset. Not really interested in fixing
    that last issue at this time.
    
    Also included is an intentionally loud disclaimer in
    `LegacyScoreEncoder` to tread with caution when treating the class. Not
    sure it'll help, and it's a bit late for it as pretty much every single
    versioning primitive has been ravaged to the brink of unusability, but
    maybe it'll help someone in the future.
    
    This also cleans up an unnecessary nullable on `FrameHeader.Mods` (added
    in https://github.com/ppy/osu/pull/30137). This change can be only done
    if users on releases earlier than 2024.1023.0 can no longer connect to
    spectator server. I leave it to reviewers to determine this as I have no
    visibility over current spectator server configuration. Inspecting the
    `osu_builds` table may help confirm this. If it provokes unease, I can
    back this change out.
  • Replace usages of Mod.ScoreMultiplier with new score multiplier API (#37845)
    - Part of https://github.com/ppy/osu/issues/37818
    
    During review, I would like to direct particular attention to the
    following changes:
    
    ## [Migrate song select to new score multiplier
    API](https://github.com/ppy/osu/commit/945fd78539da3ae57d1550a5bbfb0f859d153cc4)
    
    This was a confusing change to write because of the way song selects
    hook their mod overlays up to global bindables. In particular different
    things happen in different circumstances.
    
    - When going through `SongSelect.CreateModOverlay()`, which is called by
    the base `SongSelect`, the mod overlay is automatically bound to global
    bindables via `SongSelect.on{ArrivingAt,Leaving}Screen()`.
    - For multiplayer user mod select overlays, which are bolted on by
    subclasses of `SongSelect`, manual hook-up is required.
    - As for free mod select overlays, they don't show mod multipliers at
    all, and don't have easy access to the ruleset, and thus the hookup is
    skipped entirely as redundant.
    
    ## [Fix score multiplier registrations being shared between
    implementations via superclass static
    fields](https://github.com/ppy/osu/commit/ba0a7ad421e0c84c2d8162b6bbdd3a0683f5a6a6)
    
    Revealed by `ScoreMultiplierCalculatorTest` starting to fail due to
    interference from `OsuScoreMultiplierCalculator`.
    
    It's not ideal from a performance standpoint but it's the simplest
    choice for now. Tricks could be pulled to salvage the static. One is
    
    ```csharp
    public class ScoreMultiplierCalculator<T>
    	where T : ScoreMultiplierCalculator<T>
    {
    }
    ```
    
    This works because of generics internals; static instance members are
    not shared between different specialisations of a generic class. It is
    also very unintuitive, so I would rather not. (It trips a ReSharper
    inspection too, which would have to be silenced.)
    
    From a performance standpoint this is not ideal, but a significant chunk
    of migrated usages already precede the construction of the calculator
    via the known-expensive `RulesetInfo.CreateInstance()`, and the paths
    that actually construct the calculator do not appear to be that hot. If
    need be, this can be handled by actually caching ruleset instances and
    their derivative subcomponents.
    
    ## [Introduce passing of context to score multiplier
    calculator](https://github.com/ppy/osu/pull/37845/changes/9e9242b3221dddacd226f4b3b9c5632d7350e998)
    
    This is required for two reasons:
    
    - The upcoming mod rebalance will require out-of-band supplementary
    information that is not available for reading from the mod instances
    themselves for calculating the multiplier.
    - This context, namely passing of `ScoreInfo`, will be used for
    implementing backwards compatibility with old scores and their score
    multipliers. This is required because it has turned out under inspection
    that all server-side lazer replays recorded until now are missing
    `TotalScoreWithoutMods` due to an omission of not sending it across the
    wire to spectator server.
    
    Because the score import flow uses replays, filtered through
    `LegacyScoreDecoder`, to populate total score in the realm database, it
    is basically impossible to ignore scores that are missing
    `TotalScoreWithoutMods`, because that will result in bug reports that
    the scores do not have the new score multipliers applied.
    
    Thus, passing of `ScoreInfo` will facilitate implementation of
    versioning score multipliers, which should result in less breakage than
    not doing so.
    
    An example of this is added in 341b2d6e55,
    which should handle the case of mania mod multipliers having been
    changed without any attempt to facilitate for it in
    https://github.com/ppy/osu/pull/30506.
    
    ---------
    
    Co-authored-by: Dean Herbert <pe@ppy.sh>
  • Replace new combo button icons with ruleset-specifc ones (#37848)
    - Depends on https://github.com/ppy/osu-resources/pull/425.
    - Closes https://github.com/ppy/osu/issues/37874
    
    This makes the new combo button use the new icons added in
    https://github.com/ppy/osu/pull/37804. Instead of having four separate
    icons per ruleset, the "sparkle" texture is overlaid on top of the
    appropriate icon.
    
    I'm not sure if I've overdone it with how every ruleset copypastes the
    same code for the icon (in `<ruleset>BlueprintContainer`), so that can
    be scaled down if necessary.
    
    | osu | taiko | catch | mania |
    |--------|--------|--------|--------|
    | <img width="200" height="67" alt="image"
    src="https://github.com/user-attachments/assets/88a31611-f200-4da8-8490-39e6803a452c"
    /> | <img width="194" height="69" alt="image"
    src="https://github.com/user-attachments/assets/fbe5c7c0-2a53-4f3f-9c80-67c8769dfb52"
    /> | <img width="194" height="69" alt="image"
    src="https://github.com/user-attachments/assets/dbfbd183-0469-4b57-9059-40351604aa64"
    /> | <img width="190" height="68" alt="image"
    src="https://github.com/user-attachments/assets/708fc2e0-34fb-4983-b696-8c23431f8af4"
    /> |
    
    Co-authored-by: Dean Herbert <pe@ppy.sh>
  • Follow-up fixes for client-side slots implementation (#37868)
    Fell out of full-stack testing with
    https://github.com/ppy/osu-server-spectator/pull/513.
    
    - **Fix missing property copy in multiplayer client**
    Would cause the participant count limit to not update on the multiplayer
    match screen.
      
    
    - **Fix hard crash when user is kicked from a room with slots active**
    The kicked user is unsubscribed from receiving room state updates before
    their slot is vacated, which then would lead this code to attempt to
    look the local, kicked user via the unvacated slot and thus fail because
    `client.Room.Users` does *not* contain the user anymore.
      
    This is a bit of a dicey change but I think it's less dicey than to try
    to wiggle ordering server-side.
  • Fix legacy beatmap export dropping background specification (#37892)
    - Closes https://github.com/ppy/osu/issues/37884
    - Closes https://github.com/ppy/osu/pull/37890
    
    Due to lack of population of `Storyboard.Beatmap` and
    `Storyboard.BeatmapInfo` post-decoding, `LegacyBeatmapExporter` would
    completely drop background specifications on exported beatmap packages.
    This affects both direct legacy export to file (`.osz`) as well as
    beatmap submission.
    
    I will not pretend that the API here is optimal but I do not see very
    easy opportunities to curtail misuse. Storyboards can be treated as
    either parts of a beatmap or standalone entities, and if a requirement
    is added to forcibly provide a beatmap and its info when encoding out a
    storyboard, I also foresee a requirement to bypass this later when
    design mode is implemented, which would be a return to square one.
    
    There is likely room for cleanup around `Storyboard` to maybe make this
    nicer (remove passing of both `Beatmap` and `BeatmapInfo` and just pass
    `Beatmap` instead, maybe shuffle some properties from `Beatmap` to
    `Storyboard` to remove the requirement of having to bolt the beatmap on
    to begin with). I leave voicing opinions on that, and how soon that
    should be done, to reviewers. My primary intent at this time is to
    hotfix a major issue in a released build.
    
    The external editing feature is not involved in this bug and any
    attempts to claim so are misdirections.
  • Fix "Click to see what's new!" notification no longer appearing (#37875)
    - Regressed with https://github.com/ppy/osu/pull/37839.
    - Closes https://github.com/ppy/osu/issues/37870
    
    I feel like `UpdateManager` should remain a background component, so
    moving this notification into a new stable execution path is best to me.
  • Increase minimum size of video/storyboard icons globally (#37866)
    They were shockingly small.
    
    | Before | After |
    | :---: | :---: |
    | <img width="1358" height="601" alt="osu! 2026-05-22 at 08 19 27"
    src="https://github.com/user-attachments/assets/4e3ea756-2970-4b7a-b59d-0e684d5bfe49"
    /> | <img width="1358" height="601" alt="osu! 2026-05-22 at 08 17 23"
    src="https://github.com/user-attachments/assets/f0ce6385-6da8-4cb3-837c-0199f0034529"
    /> |
    | <img width="1358" height="601" alt="osu! 2026-05-22 at 08 19 34"
    src="https://github.com/user-attachments/assets/97bb06ed-4563-4fbc-a897-490abb593151"
    /> | <img width="1358" height="601" alt="osu! 2026-05-22 at 08 17 16"
    src="https://github.com/user-attachments/assets/294a14f9-09b5-480a-a383-15d82bde1aa0"
    /> |
  • Handle background offset when encoding/decoding beatmaps (#37841)
    - Part of https://github.com/ppy/osu/issues/14238 (doesn't close,
    because the property doesn't do anything yet).
    - Supersedes / closes https://github.com/ppy/osu/pull/37467.
  • Add ability to add videos in editor (#37857)
    https://github.com/user-attachments/assets/bc329cca-dfa1-4149-9760-121626cf1cb4
    
    ---
    
    - Closes https://github.com/ppy/osu/issues/36326
    
    Currently lacks the ability to specify custom video offset that isn't
    manual .osu editing (defaults to 0) but I'm starting here and listening
    to requirements.
    
    ---------
    
    Co-authored-by: Dean Herbert <pe@ppy.sh>
  • Make experimental audio the new default (#37856)
    Internal offset adjust is based on [survey
    results](https://docs.google.com/forms/d/1bWdwN9LPB4dJsqh2NO4z0HBiMiod1k8-tJN5oca-1Iw/edit)
    results (mean: 17.95, median: 24.5) with slight skewing based on
    cherry-picking results and personal experiences.
    
    For users which have had the setting disabled:
    
    <img width="1380" height="774" alt="osu! 2026-05-21 at 09 19 06"
    src="https://github.com/user-attachments/assets/0526517a-fa0b-485b-ac1b-b61b2fccd2af"
    />
    
    For users which are already using it:
    
    <img width="1380" height="774" alt="osu! 2026-05-21 at 09 20 24"
    src="https://github.com/user-attachments/assets/1bd77b39-5d75-43e8-8fe1-b324064b25fa"
    />
    
    Note the button is intentionally hidden to avoid any confusion (it's
    inverse now, so some users may mistakenly click it). Assume if a user is
    already on the new engine, they are happy with it.
    
    Test migration dialog in startup game flow using:
    
    ```diff
    diff --git a/osu.Game/OsuGame.cs b/osu.Game/OsuGame.cs
    index 4bd5ab83a3..091af6d428 100644
    --- a/osu.Game/OsuGame.cs
    +++ b/osu.Game/OsuGame.cs
    @@ -1315,6 +1315,8 @@ protected override void LoadComplete()
             /// </remarks>
             private void applyConfigMigrations()
             {
    +            dialogOverlay.Push(new MigrateNewAudioDialog(true));
    +
                 // arrives as 2020.123.0-lazer
                 string rawVersion = LocalConfig.Get<string>(OsuSetting.Version);
     
    
    ```
    
    ---------
    
    Co-authored-by: Bartłomiej Dach <dach.bartlomiej@gmail.com>
  • Add client-side support for slots in multiplayer rooms (#37741)
    - Part of https://github.com/ppy/osu-server-spectator/issues/405
    
    <img width="1624" height="900" alt="Screenshot 2026-05-13 at 12 40 07"
    src="https://github.com/user-attachments/assets/a7f36d54-4cc6-49c9-8e89-ee0d049bb637"
    />
    <img width="1624" height="900" alt="Screenshot 2026-05-13 at 12 31 40"
    src="https://github.com/user-attachments/assets/0c054dfd-addd-4d00-bbca-119b4c3ec3cb"
    />
    
    Will not work until relevant server-side support is in.
    
    ---
    
    I was in two minds whether to PR this all at once or to PR only
    https://github.com/ppy/osu/commit/693e4ef4b095042f7a171672224e10f9c4f47c1c
    to begin with to unblock server-side implementation. In the end I opted
    for one PR because usage informs the model, so I find everything else
    relevant as part of review of the model design. If there are concerns
    about this making it into a release without server-side support and
    therefore things looking broken I will split the commit out on request.
    
    I put in some effort to add relevant logic in test multiplayer client to
    simulate the server side but I may well have missed something.
    
    ---------
    
    Co-authored-by: Dean Herbert <pe@ppy.sh>
  • Move configuration migrations to OsuGame (#37839)
    As we go forward, migrations are going to likely become more complex,
    requiring access to more components and also at a point in time where
    they are ready.
    
    In the upcoming case, `DialogOverlay` and `Audio` are important. Access
    to `Audio` is a killer as migrations were run before the `GameHost` has
    a chance to initialise it.
    
    ---------
    
    Co-authored-by: Bartłomiej Dach <dach.bartlomiej@gmail.com>
  • Fix popup dialogs not appearing if pushed when OverlayActivationMode is wrong (#37838)
    This change is a prerequisite for making a migration dialog which runs
    on startup to let users know that something hs changed.
    
    A few cases this could happen:
    
    - During start (intro still playing)
    - During gameplay
    
    Basically making dialogs get poofed without the user ever seeing them.
    
    Arguably, we should also change the way dialogs are still poofed when
    activation mode becomes not-`All` (deferring for later response rather
    than dismissing?).
  • Allow rulesets to override PlacementReplacesExisting (#37789)
    Custom rulesets may have different conditions for replacement, and may
    not even use the `IHasColumn` interface.
  • Add legacy storyboard encoder (#37790)
    - Closes https://github.com/ppy/osu/issues/37757
    
    Commit-by-commit reading is recommended. Commits will be split to PRs on
    request but I consider this to be the minimal viable functional
    increment.
    
    ## Done
    
    - This adds a first version of a full storyboard encoder
    (a66dc406f498e35d4e0c8f2a462e946a9a1aeccc). I expect there to be hiccups
    due to weird corners of the `.osb` format; this is only intended to be
    somewhat correct as a start to build upon. Storyboarders are asked to
    file issues as necessary.
    - Due to the fact that storyboard definitions can reside both in the
    `.osu` and the `.osb`, b60698a95c4de1bfeb36fbb159fd5a6028920832 adds the
    required storage to be able to tell which storyboard element lives
    where, so that it can be decoded properly later.
    - In c9d3e04a4135886b5b0943c85f3cc6f4fe99c84c, the storyboard decoder is
    weaved into the beatmap decoder to handle the `.osu` part of the
    storyboard, via the
    `LegacyStoryboardEncoder.Encode{General,Events}ToBeatmap()` methods. For
    `.osb`s, `LegacyStoryboardEncoder.EncodeStandaloneStoryboard()` is
    intended, but for now is not used outside tests.
    - Because of the above, dd1c4e43dc51154cd67860f096712f8b4f229661 removes
    `Beatmap.UnhandledEventLines` as no longer required.
    - 26ac417ed98a8937c42e5f52c4e15ef065a48902 adds tests. They are mostly
    handwritten to ensure basic encode-decode roundtripping. Using existing
    storyboards is difficult, see "Known issues" section as to why.
    - 5cc542366db7caac38eb0729260d884905a2c0d5 fixes a bug in the storyboard
    decoder where the trigger group number was not properly negated on
    decode (see inline comment reference to relevant stable code).
    
    ## Known issues
    
    - Any and all variables in the `[Variables]` section are inlined into
    their usages by `LegacyStoryboardDecoder`, and as such
    `LegacyStoryboardEncoder` will end up inlining them and discarding the
    `[Variables]` section. As far as I can tell stable will also do this.
    - `LegacyStoryboardDecoder` splits all `M` (move) commands into
    `MX`/`MY` commands. Therefore, `LegacyStoryboardEncoder` will write out
    things in the same split way. I did not put in effort to attempt to
    reconcile this, for reasons of part laziness, part not wanting to bloat
    this already-large diff.
    - Ordering of storyboard samples on decode may not match the order on
    decode. I'm crossing fingers this doesn't matter.
  • Add custom editor toolbox icons for taiko, mania, and catch (#37804)
    Requires https://github.com/ppy/osu-resources/pull/424.
    
    Replaces the editor toolbox icons for taiko, mania, and catch rulesets
    with new designs, courtesy of [Adarin](https://osu.ppy.sh/users/118360).
    
    | taiko | catch | mania |
    |--------|--------|--------|
    | <img width="369" height="401" alt="image"
    src="https://github.com/user-attachments/assets/25dc6259-02b4-4a23-9a80-529a366c270e"
    /> | <img width="369" height="401" alt="image"
    src="https://github.com/user-attachments/assets/4bf67bf9-8a45-4075-8d33-f0501cdb08cb"
    /> | <img width="369" height="317" alt="image"
    src="https://github.com/user-attachments/assets/d130a97e-c082-4b01-a88a-d3209c05e33b"
    /> |
    
    ---------
    
    Co-authored-by: Dean Herbert <pe@ppy.sh>
  • Add score multiplier calculator API (#37822)
    - Part of https://github.com/ppy/osu/issues/37818
    - Continued from
    https://github.com/ppy/osu/pull/37355#issuecomment-4449248107
    
    ## Overview
    
    This PR introduces an alternative API, `ScoreMultiplierCalculator`, to
    be used going forward for calculating mod multipliers.
    
    The reason for introducing this new API is that it has been requested
    that:
    - For any two given mods, it should be possible to have the combined mod
    multipliers of them in combination be *different* than the product of
    the individual mods' multipliers in isolation, i.e. $mult( \\{ A, B \\}
    ) \neq mult( \\{ A \\} ) \cdot mult( \\{ B \\} )$.
    - For an individual mod, it should be possible to have the mod
    multipliers depend on a quantity that is *not* the presence of another
    mod or the direct value of a setting on the mod.
    
    This capability is being demonstrated in this PR via the
    `osu.Game.Tests.Rulesets.Scoring.ScoreMultiplierCalculatorTest` test
    fixture.
    
    ## Parity with `Mod.ScoreMultiplier`
    
    This PR contains a `ScoreMultiplierCalculator` implementation for each
    of the built-in four rulesets.
    
    The abstract `osu.Game.Tests.Rulesets.RulesetScoreMultiplierTest` and
    its four derived ruleset-specific test fixtures were written to ensure
    that the new implementations do not diverge from the current state of
    affairs.
    
    `Mod.ScoreMultiplier` is not removed in this diff to keep size low. It
    will be removed as a follow-up.
    
    ## Performance
    
    This PR contains a benchmark comparing the current implementation via
    `Mod.ScoreMultiplier` and the new `ScoreMultiplierCalculator` API.
    Results below.
    
    <details>
    
    | Method | Times | Mods | Mean | Error | StdDev | Gen0 | Allocated |
    |---------------------- |------ |---------------------
    |--------------:|------------:|------------:|--------:|----------:|
    | ViaModScoreMultiplier | 1 | mods (...)tings [27] | 121.171 ns | 1.5284
    ns | 1.4297 ns | 0.0782 | 656 B |
    | ViaCalculator | 1 | mods (...)tings [27] | 248.509 ns | 1.9313 ns |
    1.6127 ns | 0.1364 | 1144 B |
    | ViaModScoreMultiplier | 1 | multiple mods | 128.357 ns | 0.4282 ns |
    0.4006 ns | 0.0782 | 656 B |
    | ViaCalculator | 1 | multiple mods | 252.953 ns | 1.2860 ns | 1.2029 ns
    | 0.1364 | 1144 B |
    | ViaModScoreMultiplier | 1 | no mods | 3.007 ns | 0.0345 ns | 0.0288 ns
    | - | - |
    | ViaCalculator | 1 | no mods | 14.802 ns | 0.0616 ns | 0.0576 ns |
    0.0134 | 112 B |
    | ViaModScoreMultiplier | 1 | single mod | 40.271 ns | 0.1238 ns |
    0.1098 ns | 0.0258 | 216 B |
    | ViaCalculator | 1 | single mod | 113.033 ns | 0.3140 ns | 0.2937 ns |
    0.0842 | 704 B |
    | ViaModScoreMultiplier | 1 | single mod 2 | 3.653 ns | 0.0384 ns |
    0.0359 ns | 0.0038 | 32 B |
    | ViaCalculator | 1 | single mod 2 | 78.172 ns | 0.0680 ns | 0.0603 ns |
    0.0621 | 520 B |
    | ViaModScoreMultiplier | 10 | mods (...)tings [27] | 1,169.609 ns |
    4.3058 ns | 4.0276 ns | 0.7839 | 6560 B |
    | ViaCalculator | 10 | mods (...)tings [27] | 2,575.264 ns | 21.2705 ns
    | 19.8964 ns | 1.3657 | 11440 B |
    | ViaModScoreMultiplier | 10 | multiple mods | 1,171.775 ns | 6.2332 ns
    | 5.2050 ns | 0.7839 | 6560 B |
    | ViaCalculator | 10 | multiple mods | 2,579.593 ns | 22.1010 ns |
    20.6733 ns | 1.3657 | 11440 B |
    | ViaModScoreMultiplier | 10 | no mods | 35.943 ns | 0.1665 ns | 0.1476
    ns | - | - |
    | ViaCalculator | 10 | no mods | 154.980 ns | 0.2381 ns | 0.1988 ns |
    0.1338 | 1120 B |
    | ViaModScoreMultiplier | 10 | single mod | 404.185 ns | 1.3190 ns |
    1.2338 ns | 0.2580 | 2160 B |
    | ViaCalculator | 10 | single mod | 1,167.279 ns | 6.1641 ns | 5.7659 ns
    | 0.8411 | 7040 B |
    | ViaModScoreMultiplier | 10 | single mod 2 | 42.128 ns | 0.2878 ns |
    0.2692 ns | 0.0382 | 320 B |
    | ViaCalculator | 10 | single mod 2 | 775.435 ns | 2.3318 ns | 2.1811 ns
    | 0.6208 | 5200 B |
    | ViaModScoreMultiplier | 100 | mods (...)tings [27] | 11,623.346 ns |
    51.7174 ns | 43.1863 ns | 7.8430 | 65600 B |
    | ViaCalculator | 100 | mods (...)tings [27] | 25,252.987 ns | 44.4352
    ns | 39.3906 ns | 13.6719 | 114400 B |
    | ViaModScoreMultiplier | 100 | multiple mods | 11,928.536 ns | 35.2079
    ns | 32.9334 ns | 7.8430 | 65600 B |
    | ViaCalculator | 100 | multiple mods | 25,399.378 ns | 152.4597 ns |
    127.3108 ns | 13.6719 | 114400 B |
    | ViaModScoreMultiplier | 100 | no mods | 328.158 ns | 0.5827 ns |
    0.5165 ns | - | - |
    | ViaCalculator | 100 | no mods | 1,517.485 ns | 10.2304 ns | 9.5695 ns
    | 1.3390 | 11200 B |
    | ViaModScoreMultiplier | 100 | single mod | 3,986.251 ns | 24.2523 ns |
    21.4991 ns | 2.5787 | 21600 B |
    | ViaCalculator | 100 | single mod | 11,479.514 ns | 23.3738 ns |
    20.7203 ns | 8.4076 | 70400 B |
    | ViaModScoreMultiplier | 100 | single mod 2 | 385.679 ns | 3.5190 ns |
    3.2917 ns | 0.3824 | 3200 B |
    | ViaCalculator | 100 | single mod 2 | 7,658.646 ns | 21.8274 ns |
    19.3494 ns | 6.2103 | 52000 B |
    
    </details>
    
    While the calculator is obviously slower, in my view it is not
    egregiously so. The main overheads both time- and memory-wise are
    collection allocations for the dictionary and the set which I consider
    to be directly caused by the requested additional complexity and as such
    I don't really consider them eliminable.
    
    I have tried and applied some micro-optimisations
    (e2469ce338,
    cb33abec17), albeit with negligible
    effect. I have also tried to key the mods by `Acronym` instead of by
    `Type` and the difference was basically nil.
    
    <details>
    <summary>patch for keying by acronym</summary>
    
    ```diff
    diff --git a/osu.Game/Rulesets/Scoring/ScoreMultiplierCalculator.cs b/osu.Game/Rulesets/Scoring/ScoreMultiplierCalculator.cs
    index 772f9d178b..7f5907cbda 100644
    --- a/osu.Game/Rulesets/Scoring/ScoreMultiplierCalculator.cs
    +++ b/osu.Game/Rulesets/Scoring/ScoreMultiplierCalculator.cs
    @@ -13,26 +13,26 @@ namespace osu.Game.Rulesets.Scoring
         /// </summary>
         public class ScoreMultiplierCalculator
         {
    -        private static readonly List<(Type[] mods, Func<Mod[], double> multiplier)> combination_multipliers = [];
    -        private static readonly Dictionary<Type, Func<Mod, ScoreMultiplierCalculator, double>> single_multipliers_with_context = [];
    -        private static readonly Dictionary<Type, Func<Mod, double>> single_multipliers = [];
    +        private static readonly List<(string[] modAcronyms, Func<Mod[], double> multiplier)> combination_multipliers = [];
    +        private static readonly Dictionary<string, Func<Mod, ScoreMultiplierCalculator, double>> single_multipliers_with_context = [];
    +        private static readonly Dictionary<string, Func<Mod, double>> single_multipliers = [];
     
             /// <summary>
             /// Defines a flat, setting-independent score multiplier for the given <typeparamref name="TMod"/>.
             /// </summary>
             public static void Single<TMod>(double hasMultiplier)
    -            where TMod : Mod
    +            where TMod : Mod, new()
             {
    -            single_multipliers[typeof(TMod)] = _ => hasMultiplier;
    +            single_multipliers[new TMod().Acronym] = _ => hasMultiplier;
             }
     
             /// <summary>
             /// Defines a setting-dependent score multiplier for the given <typeparamref name="TMod"/>.
             /// </summary>
             public static void Single<TMod>(Func<TMod, double> hasMultiplier)
    -            where TMod : Mod
    +            where TMod : Mod, new()
             {
    -            single_multipliers[typeof(TMod)] = mod => hasMultiplier.Invoke((TMod)mod);
    +            single_multipliers[new TMod().Acronym] = mod => hasMultiplier.Invoke((TMod)mod);
             }
     
             /// <summary>
    @@ -40,20 +40,20 @@ public static void Single<TMod>(Func<TMod, double> hasMultiplier)
             /// The multiplier calculation is given additional context to calculate the multiplier via the <typeparamref name="TContext"/> type instance.
             /// </summary>
             public static void Single<TMod, TContext>(Func<TMod, TContext, double> hasMultiplier)
    -            where TMod : Mod
    +            where TMod : Mod, new()
                 where TContext : ScoreMultiplierCalculator
             {
    -            single_multipliers_with_context[typeof(TMod)] = (mod, context) => hasMultiplier.Invoke((TMod)mod, (TContext)context);
    +            single_multipliers_with_context[new TMod().Acronym] = (mod, context) => hasMultiplier.Invoke((TMod)mod, (TContext)context);
             }
     
             /// <summary>
             /// Defines a score multiplier specific to when both <typeparamref name="T1"/> and <typeparamref name="T2"/> mods are present.
             /// </summary>
             public static void Combination<T1, T2>(Func<T1, T2, double> hasMultiplier)
    -            where T1 : Mod
    -            where T2 : Mod
    +            where T1 : Mod, new()
    +            where T2 : Mod, new()
             {
    -            combination_multipliers.Add(([typeof(T1), typeof(T2)], mods => hasMultiplier((T1)mods[0], (T2)mods[1])));
    +            combination_multipliers.Add(([new T1().Acronym, new T2().Acronym], mods => hasMultiplier((T1)mods[0], (T2)mods[1])));
             }
     
             /// <summary>
    @@ -61,7 +61,7 @@ public static void Combination<T1, T2>(Func<T1, T2, double> hasMultiplier)
             /// </summary>
             public double CalculateFor(IEnumerable<Mod> mods)
             {
    -            var allModsByType = mods.ToDictionary(m => m.GetType());
    +            var allModsByType = mods.ToDictionary(m => m.Acronym);
     
                 if (allModsByType.Count == 0)
                     return 1;
    @@ -83,7 +83,7 @@ public double CalculateFor(IEnumerable<Mod> mods)
                     }
                 }
     
    -            foreach (var modType in remainingModTypes)
    +            foreach (string modType in remainingModTypes)
                 {
                     if (single_multipliers.TryGetValue(modType, out var multiplier))
                         result *= multiplier(allModsByType[modType]);
    
    ```
    
    </details>
    
    One particular parallel thread that may warrant follow-up is that
    `Mod.UsesDefaultConfiguration` is disproportionately expensive due to
    calling into regexes via Humanizer internals.
    
    <img width="1517" height="517" alt="Screenshot_2026-05-19_at_10 58 30"
    src="https://github.com/user-attachments/assets/68309a8c-74e7-4f96-8ef9-62868eeca337"
    />
  • Add slider velocity control to toolbox (#37746)
    https://github.com/user-attachments/assets/2a511e0d-51f8-4abf-a3ab-de0992618b6b
    
    This implements a rather opinionated UX that's designed to be a middle
    ground between the previous lazer behaviour of unconditionally
    inheriting the last slider's velocity and just a textbox that you'd need
    to manually fiddle with every time.
    
    As to what that means, precisely:
    
    - By default, the control follows the last slider's velocity (updates on
    seeks as well as changes to existing objects).
    - When the slider control in the toolbox is manually adjusted, the
    control decouples from the last slider's velocity and instead uses the
    last manually-specified value.
    - There is a button that allows the user to couple back to the last
    slider's velocity if they consider to have made a mistake in adjusting
    it.
    - Upon successful placement of a slider, the control reverts to
    following the last slider's velocity.
    
    Of note, this control *only interacts with and affects the next placed
    slider*. It is in no way coupled to any selected objects. This may be
    confusing to users but was an intentional choice to limit complexity
    (what if there are multiple selected objects with multiple velocities?)
    
    For adjusting existing objects you can use the green pieces on the
    timeline, which notably do support changing multiple selected objects at
    once.
    
    ---
    
    - Closes https://github.com/ppy/osu/issues/36844
    - Supersedes / closes https://github.com/ppy/osu/pull/33707
  • Add editor hotkeys for beatmap submit and edit externally (#37782)
    Addresses https://github.com/ppy/osu/discussions/37777.
    
    Note: please don't merge until after the imminent release. Would rather
    not add new features at this point.
  • Stop applying UI scale to ranked play (#37779)
    We already [did this for quick
    play](https://github.com/ppy/osu/pull/36025) but it was never carried
    across. Some of the new UI *could* work with UI scale with more
    consideration, but for now, let's disable it globally to fix cases like
    the results screen which people completely unusable at higher scales.
    
    ---
    
    @ppy/team-client would hope to get this into today's build, if priority
    review could be given to it.
    
    Closes https://github.com/ppy/osu/issues/37407.
    
    - Note that this adds a UI scale slider to all `ScreenTestScenes`. In
    some testing, this seems to work just fine.
    - May be worth reading 6c8dd589f7384d46377620340d300e53aec7eed5 commit
    message for one future concern i have.
311 changed files with 5989 additions and 2925 deletions
@@ -19,13 +19,13 @@ namespace osu.Game.Rulesets.EmptyFreeform
{
}
protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills)
protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate)
{
return new DifficultyAttributes(mods, 0);
}
protected override IEnumerable<DifficultyHitObject> CreateDifficultyHitObjects(IBeatmap beatmap, Mod[] mods) => Enumerable.Empty<DifficultyHitObject>();
protected override IEnumerable<DifficultyHitObject> CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) => Enumerable.Empty<DifficultyHitObject>();
protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods) => Array.Empty<Skill>();
protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) => Array.Empty<Skill>();
}
}
@@ -19,13 +19,13 @@ namespace osu.Game.Rulesets.Pippidon
{
}
protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills)
protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate)
{
return new DifficultyAttributes(mods, 0);
}
protected override IEnumerable<DifficultyHitObject> CreateDifficultyHitObjects(IBeatmap beatmap, Mod[] mods) => Enumerable.Empty<DifficultyHitObject>();
protected override IEnumerable<DifficultyHitObject> CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) => Enumerable.Empty<DifficultyHitObject>();
protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods) => Array.Empty<Skill>();
protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) => Array.Empty<Skill>();
}
}
@@ -19,13 +19,13 @@ namespace osu.Game.Rulesets.EmptyScrolling
{
}
protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills)
protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate)
{
return new DifficultyAttributes(mods, 0);
}
protected override IEnumerable<DifficultyHitObject> CreateDifficultyHitObjects(IBeatmap beatmap, Mod[] mods) => Enumerable.Empty<DifficultyHitObject>();
protected override IEnumerable<DifficultyHitObject> CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) => Enumerable.Empty<DifficultyHitObject>();
protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods) => Array.Empty<Skill>();
protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) => Array.Empty<Skill>();
}
}
@@ -19,13 +19,13 @@ namespace osu.Game.Rulesets.Pippidon
{
}
protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills)
protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate)
{
return new DifficultyAttributes(mods, 0);
}
protected override IEnumerable<DifficultyHitObject> CreateDifficultyHitObjects(IBeatmap beatmap, Mod[] mods) => Enumerable.Empty<DifficultyHitObject>();
protected override IEnumerable<DifficultyHitObject> CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) => Enumerable.Empty<DifficultyHitObject>();
protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods) => Array.Empty<Skill>();
protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) => Array.Empty<Skill>();
}
}
+1 -1
View File
@@ -10,7 +10,7 @@
<EmbedAssembliesIntoApk>true</EmbedAssembliesIntoApk>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="ppy.osu.Framework.Android" Version="2026.513.0" />
<PackageReference Include="ppy.osu.Framework.Android" Version="2026.527.0" />
</ItemGroup>
<PropertyGroup>
<!-- Fody does not handle Android build well, and warns when unchanged.
@@ -0,0 +1,67 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Collections.Generic;
using BenchmarkDotNet.Attributes;
using NUnit.Framework;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Osu;
using osu.Game.Rulesets.Osu.Mods;
using osu.Game.Rulesets.Scoring;
namespace osu.Game.Benchmarks
{
public class BenchmarkScoreMultiplierCalculator : BenchmarkTest
{
private ScoreMultiplierCalculator calculator = null!;
[Params(1, 10, 100)]
public int Times { get; set; }
public record ModTestCase(string Description, IEnumerable<Mod> Mods)
{
public override string ToString() => Description;
}
public static IEnumerable<ModTestCase> ValuesForMods =>
[
new ModTestCase("no mods", []),
new ModTestCase("single mod", [new OsuModHardRock()]),
new ModTestCase("single mod 2", [new OsuModEasy()]),
new ModTestCase("multiple mods", [new OsuModHidden(), new OsuModHardRock(), new OsuModDoubleTime()]),
new ModTestCase("mods with adjusted settings", [
new OsuModDoubleTime { SpeedChange = { Value = 2 } },
new OsuModHidden { OnlyFadeApproachCircles = { Value = true } },
new OsuModHardRock()
]),
];
[ParamsSource(nameof(ValuesForMods))]
public ModTestCase Mods { get; set; } = null!;
public override void SetUp()
{
base.SetUp();
calculator = new OsuRuleset().CreateScoreMultiplierCalculator(new ScoreMultiplierContext(new BeatmapDifficulty()));
}
[Benchmark]
public double ViaCalculator()
=> viaCalculator(Times, Mods);
[Test]
public void ViaCalculator([Values(100)] int times, [ValueSource(nameof(ValuesForMods))] ModTestCase mods)
=> viaCalculator(times, mods);
private double viaCalculator(int times, ModTestCase mods)
{
double scoreMultiplier = 1;
for (int i = 0; i < times; ++i)
scoreMultiplier = calculator.CalculateFor(mods.Mods);
return scoreMultiplier;
}
}
}
@@ -14,11 +14,11 @@ namespace osu.Game.Rulesets.Catch.Tests
{
protected override string ResourceAssembly => "osu.Game.Rulesets.Catch.Tests";
[TestCase(4.039861734717169d, 127, "diffcalc-test")]
[TestCase(4.0505463516206195d, 127, "diffcalc-test")]
public void Test(double expectedStarRating, int expectedMaxCombo, string name)
=> base.Test(expectedStarRating, expectedMaxCombo, name);
[TestCase(5.1527173897800873d, 127, "diffcalc-test")]
[TestCase(5.1696411260785498d, 127, "diffcalc-test")]
public void TestClockRateAdjusted(double expectedStarRating, int expectedMaxCombo, string name)
=> Test(expectedStarRating, expectedMaxCombo, name, new CatchModDoubleTime());
@@ -0,0 +1,171 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using NUnit.Framework;
using osu.Framework.Utils;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Catch.Mods;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Scoring;
using osu.Game.Scoring;
using osu.Game.Tests.Rulesets;
namespace osu.Game.Rulesets.Catch.Tests
{
public class CatchScoreMultiplierTest : RulesetScoreMultiplierTest
{
public CatchScoreMultiplierTest()
: base(new CatchRuleset())
{
}
private static readonly object[][] test_cases =
[
#region Difficulty Reduction
[new Mod[] { new CatchModEasy() }, 0.5],
[new Mod[] { new CatchModNoFail() }, 0.5],
[new Mod[] { new CatchModHalfTime { SpeedChange = { Value = 0.50 } } }, 0.1],
[new Mod[] { new CatchModHalfTime { SpeedChange = { Value = 0.55 } } }, 0.1],
[new Mod[] { new CatchModHalfTime { SpeedChange = { Value = 0.60 } } }, 0.2],
[new Mod[] { new CatchModHalfTime { SpeedChange = { Value = 0.65 } } }, 0.2],
[new Mod[] { new CatchModHalfTime { SpeedChange = { Value = 0.70 } } }, 0.3],
[new Mod[] { new CatchModHalfTime { SpeedChange = { Value = 0.75 } } }, 0.3],
[new Mod[] { new CatchModHalfTime { SpeedChange = { Value = 0.80 } } }, 0.4],
[new Mod[] { new CatchModHalfTime { SpeedChange = { Value = 0.85 } } }, 0.4],
[new Mod[] { new CatchModHalfTime { SpeedChange = { Value = 0.90 } } }, 0.5],
[new Mod[] { new CatchModHalfTime { SpeedChange = { Value = 0.95 } } }, 0.5],
[new Mod[] { new CatchModHalfTime { SpeedChange = { Value = 0.99 } } }, 0.5],
[new Mod[] { new CatchModDaycore { SpeedChange = { Value = 0.50 } } }, 0.1],
[new Mod[] { new CatchModDaycore { SpeedChange = { Value = 0.55 } } }, 0.1],
[new Mod[] { new CatchModDaycore { SpeedChange = { Value = 0.60 } } }, 0.2],
[new Mod[] { new CatchModDaycore { SpeedChange = { Value = 0.65 } } }, 0.2],
[new Mod[] { new CatchModDaycore { SpeedChange = { Value = 0.70 } } }, 0.3],
[new Mod[] { new CatchModDaycore { SpeedChange = { Value = 0.75 } } }, 0.3],
[new Mod[] { new CatchModDaycore { SpeedChange = { Value = 0.80 } } }, 0.4],
[new Mod[] { new CatchModDaycore { SpeedChange = { Value = 0.85 } } }, 0.4],
[new Mod[] { new CatchModDaycore { SpeedChange = { Value = 0.90 } } }, 0.5],
[new Mod[] { new CatchModDaycore { SpeedChange = { Value = 0.95 } } }, 0.5],
[new Mod[] { new CatchModDaycore { SpeedChange = { Value = 0.99 } } }, 0.5],
#endregion
#region Difficulty Increase
[new Mod[] { new CatchModHardRock() }, 1.12],
[new Mod[] { new CatchModSuddenDeath() }, 1],
[new Mod[] { new CatchModPerfect() }, 1],
[new Mod[] { new CatchModDoubleTime { SpeedChange = { Value = 1.01 } } }, 1.00],
[new Mod[] { new CatchModDoubleTime { SpeedChange = { Value = 1.05 } } }, 1.00],
[new Mod[] { new CatchModDoubleTime { SpeedChange = { Value = 1.10 } } }, 1.02],
[new Mod[] { new CatchModDoubleTime { SpeedChange = { Value = 1.15 } } }, 1.02],
[new Mod[] { new CatchModDoubleTime { SpeedChange = { Value = 1.20 } } }, 1.04],
[new Mod[] { new CatchModDoubleTime { SpeedChange = { Value = 1.25 } } }, 1.04],
[new Mod[] { new CatchModDoubleTime { SpeedChange = { Value = 1.30 } } }, 1.06],
[new Mod[] { new CatchModDoubleTime { SpeedChange = { Value = 1.35 } } }, 1.06],
[new Mod[] { new CatchModDoubleTime { SpeedChange = { Value = 1.40 } } }, 1.08],
[new Mod[] { new CatchModDoubleTime { SpeedChange = { Value = 1.45 } } }, 1.08],
[new Mod[] { new CatchModDoubleTime { SpeedChange = { Value = 1.50 } } }, 1.10],
[new Mod[] { new CatchModDoubleTime { SpeedChange = { Value = 1.55 } } }, 1.10],
[new Mod[] { new CatchModDoubleTime { SpeedChange = { Value = 1.60 } } }, 1.12],
[new Mod[] { new CatchModDoubleTime { SpeedChange = { Value = 1.65 } } }, 1.12],
[new Mod[] { new CatchModDoubleTime { SpeedChange = { Value = 1.70 } } }, 1.14],
[new Mod[] { new CatchModDoubleTime { SpeedChange = { Value = 1.75 } } }, 1.14],
[new Mod[] { new CatchModDoubleTime { SpeedChange = { Value = 1.80 } } }, 1.16],
[new Mod[] { new CatchModDoubleTime { SpeedChange = { Value = 1.85 } } }, 1.16],
[new Mod[] { new CatchModDoubleTime { SpeedChange = { Value = 1.90 } } }, 1.18],
[new Mod[] { new CatchModDoubleTime { SpeedChange = { Value = 1.95 } } }, 1.18],
[new Mod[] { new CatchModDoubleTime { SpeedChange = { Value = 2.00 } } }, 1.20],
[new Mod[] { new CatchModNightcore { SpeedChange = { Value = 1.01 } } }, 1.00],
[new Mod[] { new CatchModNightcore { SpeedChange = { Value = 1.05 } } }, 1.00],
[new Mod[] { new CatchModNightcore { SpeedChange = { Value = 1.10 } } }, 1.02],
[new Mod[] { new CatchModNightcore { SpeedChange = { Value = 1.15 } } }, 1.02],
[new Mod[] { new CatchModNightcore { SpeedChange = { Value = 1.20 } } }, 1.04],
[new Mod[] { new CatchModNightcore { SpeedChange = { Value = 1.25 } } }, 1.04],
[new Mod[] { new CatchModNightcore { SpeedChange = { Value = 1.30 } } }, 1.06],
[new Mod[] { new CatchModNightcore { SpeedChange = { Value = 1.35 } } }, 1.06],
[new Mod[] { new CatchModNightcore { SpeedChange = { Value = 1.40 } } }, 1.08],
[new Mod[] { new CatchModNightcore { SpeedChange = { Value = 1.45 } } }, 1.08],
[new Mod[] { new CatchModNightcore { SpeedChange = { Value = 1.50 } } }, 1.10],
[new Mod[] { new CatchModNightcore { SpeedChange = { Value = 1.55 } } }, 1.10],
[new Mod[] { new CatchModNightcore { SpeedChange = { Value = 1.60 } } }, 1.12],
[new Mod[] { new CatchModNightcore { SpeedChange = { Value = 1.65 } } }, 1.12],
[new Mod[] { new CatchModNightcore { SpeedChange = { Value = 1.70 } } }, 1.14],
[new Mod[] { new CatchModNightcore { SpeedChange = { Value = 1.75 } } }, 1.14],
[new Mod[] { new CatchModNightcore { SpeedChange = { Value = 1.80 } } }, 1.16],
[new Mod[] { new CatchModNightcore { SpeedChange = { Value = 1.85 } } }, 1.16],
[new Mod[] { new CatchModNightcore { SpeedChange = { Value = 1.90 } } }, 1.18],
[new Mod[] { new CatchModNightcore { SpeedChange = { Value = 1.95 } } }, 1.18],
[new Mod[] { new CatchModNightcore { SpeedChange = { Value = 2.00 } } }, 1.20],
[new Mod[] { new CatchModHidden() }, 1.06],
[new Mod[] { new CatchModFlashlight() }, 1.12],
[new Mod[] { new CatchModFlashlight { ComboBasedSize = { Value = false } } }, 1],
[new Mod[] { new ModAccuracyChallenge() }, 1],
#endregion
#region Conversion
[new Mod[] { new CatchModDifficultyAdjust() }, 0.5],
[new Mod[] { new CatchModClassic() }, 1],
[new Mod[] { new CatchModMirror() }, 1],
#endregion
#region Automation
[new Mod[] { new CatchModAutoplay() }, 1],
[new Mod[] { new CatchModCinema() }, 1],
[new Mod[] { new CatchModRelax() }, 0.1],
#endregion
#region Fun
[new Mod[] { new ModWindUp() }, 0.5],
[new Mod[] { new ModWindDown() }, 0.5],
[new Mod[] { new CatchModFloatingFruits() }, 1],
[new Mod[] { new CatchModMuted() }, 1],
[new Mod[] { new CatchModNoScope() }, 1],
[new Mod[] { new CatchModMovingFast() }, 1],
[new Mod[] { new CatchModSynesthesia() }, 0.8],
#endregion
#region System
[new Mod[] { new ModScoreV2() }, 1],
#endregion
#region Combinations
[new Mod[] { new CatchModHidden(), new CatchModHardRock() }, 1.06 * 1.12]
#endregion
];
[TestCaseSource(nameof(test_cases))]
public void TestMultipliers(Mod[] mods, double expectedMultiplier)
=> TestModCombination(mods, expectedMultiplier);
[TestCase(30000001, 0.96)]
[TestCase(30000009, 0.96)]
[TestCase(30000016, 0.96)]
[TestCase(30000017, 1)]
[TestCase(null, 1)]
public void TestClassicMultiplierVersioning(int? totalScoreVersion, double expectedMultiplier)
{
var scoreInfo = totalScoreVersion != null ? new ScoreInfo { TotalScoreVersion = totalScoreVersion.Value } : null;
var calculator = Ruleset.CreateScoreMultiplierCalculator(new ScoreMultiplierContext(new BeatmapDifficulty(), scoreInfo));
Assert.That(calculator.CalculateFor([new CatchModClassic()]), Is.EqualTo(expectedMultiplier).Within(Precision.DOUBLE_EPSILON));
}
}
}
+2
View File
@@ -169,6 +169,8 @@ namespace osu.Game.Rulesets.Catch
}
}
public override ScoreMultiplierCalculator CreateScoreMultiplierCalculator(ScoreMultiplierContext context) => new CatchScoreMultiplierCalculator(context);
public override string Description => "osu!catch";
public override string ShortName => SHORT_NAME;
@@ -15,7 +15,6 @@ using osu.Game.Rulesets.Difficulty;
using osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Difficulty.Skills;
using osu.Game.Rulesets.Mods;
using osu.Game.Utils;
namespace osu.Game.Rulesets.Catch.Difficulty
{
@@ -23,6 +22,8 @@ namespace osu.Game.Rulesets.Catch.Difficulty
{
private const double difficulty_multiplier = 4.59;
private float halfCatcherWidth;
public override int Version => 20251020;
public CatchDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap)
@@ -30,7 +31,7 @@ namespace osu.Game.Rulesets.Catch.Difficulty
{
}
protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills)
protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate)
{
if (beatmap.HitObjects.Count == 0)
return new CatchDifficultyAttributes { Mods = mods };
@@ -45,19 +46,12 @@ namespace osu.Game.Rulesets.Catch.Difficulty
return attributes;
}
protected override IEnumerable<DifficultyHitObject> CreateDifficultyHitObjects(IBeatmap beatmap, Mod[] mods)
protected override IEnumerable<DifficultyHitObject> CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate)
{
CatchHitObject? lastObject = null;
List<DifficultyHitObject> objects = new List<DifficultyHitObject>();
double clockRate = ModUtils.CalculateRateWithMods(mods);
float halfCatcherWidth = Catcher.CalculateCatchWidth(beatmap.Difficulty) * 0.5f;
// For circle sizes above 5.5, reduce the catcher width further to simulate imperfect gameplay.
halfCatcherWidth *= 1 - (Math.Max(0, beatmap.Difficulty.CircleSize - 5.5f) * 0.0625f);
// In 2B beatmaps, it is possible that a normal Fruit is placed in the middle of a JuiceStream.
foreach (var hitObject in CatchBeatmap.GetPalpableObjects(beatmap.HitObjects))
{
@@ -74,11 +68,16 @@ namespace osu.Game.Rulesets.Catch.Difficulty
return objects;
}
protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods)
protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate)
{
halfCatcherWidth = Catcher.CalculateCatchWidth(beatmap.Difficulty) * 0.5f;
// For circle sizes above 5.5, reduce the catcher width further to simulate imperfect gameplay.
halfCatcherWidth *= 1 - (Math.Max(0, beatmap.Difficulty.CircleSize - 5.5f) * 0.0625f);
return new Skill[]
{
new Movement(mods),
new Movement(mods, halfCatcherWidth, clockRate),
};
}
@@ -11,16 +11,12 @@ namespace osu.Game.Rulesets.Catch.Difficulty.Evaluators
{
private const double direction_change_bonus = 21.0;
public static double EvaluateDifficultyOf(DifficultyHitObject current)
public static double EvaluateDifficultyOf(DifficultyHitObject current, double catcherSpeedMultiplier)
{
var catchCurrent = (CatchDifficultyHitObject)current;
var catchLast = (CatchDifficultyHitObject)current.Previous(0);
var catchLastLast = (CatchDifficultyHitObject)current.Previous(1);
// In catch, clockrate adjustments do not only affect the timings of hitobjects,
// but also the speed of the player's catcher, which has an impact on difficulty
double catcherSpeedMultiplier = current.ClockRate;
double weightedStrainTime = catchCurrent.StrainTime + 13 + (3 / catcherSpeedMultiplier);
double distanceAddition = (Math.Pow(Math.Abs(catchCurrent.DistanceMoved), 1.3) / 510);
@@ -44,30 +40,6 @@ namespace osu.Game.Rulesets.Catch.Difficulty.Evaluators
/ (CatchDifficultyHitObject.NORMALIZED_HALF_CATCHER_WIDTH * 6) / sqrtStrain;
}
// Linear spacing nerf.
double linearSpacingCount = 0;
for (int i = 0; i < Math.Min(current.Index, 10); i++)
{
var catchPrevObj = (CatchDifficultyHitObject)catchCurrent.Previous(i);
// Only same direction movements matter as they do not take any additional inputs.
if (Math.Sign(catchCurrent.DistanceMoved) != Math.Sign(catchPrevObj.DistanceMoved) || catchCurrent.DistanceMoved == 0 || catchPrevObj.DistanceMoved == 0)
break;
double currentSpacing = Math.Abs(catchCurrent.DistanceMoved / catchCurrent.StrainTime);
double prevSpacing = Math.Abs(catchPrevObj.DistanceMoved / catchPrevObj.StrainTime);
double relativeDifference = Math.Abs(currentSpacing / prevSpacing - 1);
if (relativeDifference > 0.05)
break;
linearSpacingCount++;
}
distanceAddition *= Math.Pow(0.7, linearSpacingCount);
// Bonus for edge dashes.
if (catchCurrent.LastObject.DistanceToHyperDash <= 20.0f)
{
@@ -17,14 +17,28 @@ namespace osu.Game.Rulesets.Catch.Difficulty.Skills
protected override int SectionLength => 750;
public Movement(Mod[] mods)
protected readonly float HalfCatcherWidth;
/// <summary>
/// The speed multiplier applied to the player's catcher.
/// </summary>
private readonly double catcherSpeedMultiplier;
public Movement(Mod[] mods, float halfCatcherWidth, double clockRate)
: base(mods)
{
HalfCatcherWidth = halfCatcherWidth;
// In catch, clockrate adjustments do not only affect the timings of hitobjects,
// but also the speed of the player's catcher, which has an impact on difficulty
// TODO: Support variable clockrates caused by mods such as ModTimeRamp
// (perhaps by using IApplicableToRate within the CatchDifficultyHitObject constructor to set a catcher speed for each object before processing)
catcherSpeedMultiplier = clockRate;
}
protected override double StrainValueOf(DifficultyHitObject current)
{
return MovementEvaluator.EvaluateDifficultyOf(current);
return MovementEvaluator.EvaluateDifficultyOf(current, catcherSpeedMultiplier);
}
}
}
@@ -2,9 +2,9 @@
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Graphics;
using osu.Game.Beatmaps;
using osu.Framework.Graphics.Sprites;
using osu.Game.Graphics;
using osu.Game.Rulesets.Catch.Edit.Blueprints;
using osu.Game.Rulesets.Catch.Objects;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Edit.Tools;
@@ -13,11 +13,11 @@ namespace osu.Game.Rulesets.Catch.Edit
public class BananaShowerCompositionTool : CompositionTool
{
public BananaShowerCompositionTool()
: base(nameof(BananaShower))
: base("Banana shower")
{
}
public override Drawable CreateIcon() => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Spinners);
public override Drawable CreateIcon() => new SpriteIcon { Icon = OsuIcon.EditorBananaShower };
public override HitObjectPlacementBlueprint CreatePlacementBlueprint() => new BananaShowerPlacementBlueprint();
}
@@ -3,11 +3,16 @@
using System.Collections.Generic;
using System.Linq;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Input.Events;
using osu.Game.Graphics;
using osu.Game.Rulesets.Catch.Edit.Blueprints;
using osu.Game.Rulesets.Catch.Objects;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Objects;
using osu.Game.Screens.Edit.Components.TernaryButtons;
using osu.Game.Screens.Edit.Compose.Components;
using osuTK;
@@ -22,6 +27,29 @@ namespace osu.Game.Rulesets.Catch.Edit
{
}
protected override Drawable CreateNewComboButton() => new NewComboTernaryButton
{
Current = NewCombo,
CreateIcon = () => new Container
{
Children = new Drawable[]
{
new SpriteIcon
{
Anchor = Anchor.BottomLeft,
Origin = Anchor.BottomLeft,
Icon = OsuIcon.EditorFruit,
Size = new Vector2(15),
},
new SpriteIcon
{
Icon = OsuIcon.EditorNewComboSparkles,
Size = new Vector2(20),
}
},
},
};
protected override SelectionHandler<HitObject> CreateSelectionHandler() => new CatchSelectionHandler();
public override HitObjectSelectionBlueprint? CreateHitObjectBlueprintFor(HitObject hitObject)
@@ -2,7 +2,8 @@
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Graphics;
using osu.Game.Beatmaps;
using osu.Framework.Graphics.Sprites;
using osu.Game.Graphics;
using osu.Game.Rulesets.Catch.Edit.Blueprints;
using osu.Game.Rulesets.Catch.Objects;
using osu.Game.Rulesets.Edit;
@@ -17,7 +18,7 @@ namespace osu.Game.Rulesets.Catch.Edit
{
}
public override Drawable CreateIcon() => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Circles);
public override Drawable CreateIcon() => new SpriteIcon { Icon = OsuIcon.EditorFruit };
public override HitObjectPlacementBlueprint CreatePlacementBlueprint() => new FruitPlacementBlueprint();
}
@@ -2,9 +2,9 @@
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Graphics;
using osu.Game.Beatmaps;
using osu.Framework.Graphics.Sprites;
using osu.Game.Graphics;
using osu.Game.Rulesets.Catch.Edit.Blueprints;
using osu.Game.Rulesets.Catch.Objects;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Edit.Tools;
@@ -13,11 +13,11 @@ namespace osu.Game.Rulesets.Catch.Edit
public class JuiceStreamCompositionTool : CompositionTool
{
public JuiceStreamCompositionTool()
: base(nameof(JuiceStream))
: base("Juice stream")
{
}
public override Drawable CreateIcon() => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Sliders);
public override Drawable CreateIcon() => new SpriteIcon { Icon = OsuIcon.EditorJuiceStream };
public override HitObjectPlacementBlueprint CreatePlacementBlueprint() => new JuiceStreamPlacementBlueprint();
}
@@ -13,8 +13,6 @@ namespace osu.Game.Rulesets.Catch.Mods
{
public partial class CatchModFlashlight : ModFlashlight<CatchHitObject>
{
public override double ScoreMultiplier => UsesDefaultConfiguration ? 1.12 : 1;
public override BindableFloat SizeMultiplier { get; } = new BindableFloat(1)
{
MinValue = 0.5f,
@@ -16,7 +16,6 @@ namespace osu.Game.Rulesets.Catch.Mods
public override string Name => "Floating Fruits";
public override string Acronym => "FF";
public override LocalisableString Description => "The fruits are... floating?";
public override double ScoreMultiplier => 1;
public override IconUsage? Icon => OsuIcon.ModFloatingFruits;
public void ApplyToDrawableRuleset(DrawableRuleset<CatchHitObject> drawableRuleset)
@@ -10,8 +10,6 @@ namespace osu.Game.Rulesets.Catch.Mods
{
public class CatchModHardRock : ModHardRock, IApplicableToBeatmapProcessor
{
public override double ScoreMultiplier => UsesDefaultConfiguration ? 1.12 : 1;
public void ApplyToBeatmapProcessor(IBeatmapProcessor beatmapProcessor)
{
var catchProcessor = (CatchBeatmapProcessor)beatmapProcessor;
@@ -16,7 +16,6 @@ namespace osu.Game.Rulesets.Catch.Mods
public class CatchModHidden : ModHidden, IApplicableToDrawableRuleset<CatchHitObject>
{
public override LocalisableString Description => @"Play with fading fruits.";
public override double ScoreMultiplier => UsesDefaultConfiguration ? 1.06 : 1;
private const double fade_out_offset_multiplier = 0.6;
private const double fade_out_duration_multiplier = 0.44;
@@ -23,7 +23,6 @@ namespace osu.Game.Rulesets.Catch.Mods
public override string Acronym => "MF";
public override LocalisableString Description => "Dashing by default, slow down!";
public override ModType Type => ModType.Fun;
public override double ScoreMultiplier => 1;
public override IconUsage? Icon => OsuIcon.ModMovingFast;
public override Type[] IncompatibleMods => new[] { typeof(ModAutoplay), typeof(ModRelax) };
@@ -0,0 +1,95 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Game.Rulesets.Catch.Mods;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Scoring;
using osu.Game.Scoring;
namespace osu.Game.Rulesets.Catch.Scoring
{
public class CatchScoreMultiplierCalculator : ScoreMultiplierCalculator
{
public CatchScoreMultiplierCalculator(ScoreMultiplierContext context)
: base(context)
{
#region Difficulty Reduction
Single<CatchModEasy>(hasMultiplier: 0.5);
Single<CatchModNoFail>(hasMultiplier: 0.5);
Single<CatchModHalfTime>(hasMultiplier: halfTime => rateAdjustMultiplier(halfTime.SpeedChange.Value));
Single<CatchModDaycore>(hasMultiplier: daycore => rateAdjustMultiplier(daycore.SpeedChange.Value));
#endregion
#region Difficulty Increase
Single<CatchModHardRock>(hasMultiplier: hardRock => hardRock.UsesDefaultConfiguration ? 1.12 : 1);
// Sudden Death
// Perfect
Single<CatchModDoubleTime>(hasMultiplier: doubleTime => rateAdjustMultiplier(doubleTime.SpeedChange.Value));
Single<CatchModNightcore>(hasMultiplier: nightcore => rateAdjustMultiplier(nightcore.SpeedChange.Value));
Single<CatchModHidden>(hasMultiplier: hidden => hidden.UsesDefaultConfiguration ? 1.06 : 1);
Single<CatchModFlashlight>(hasMultiplier: flashlight => flashlight.UsesDefaultConfiguration ? 1.12 : 1);
// Accuracy Challenge
#endregion
#region Conversion
Single<CatchModDifficultyAdjust>(hasMultiplier: 0.5);
Single<CatchModClassic>(hasMultiplier: _ => classicMultiplier(context.Score));
// Mirror
#endregion
#region Automation
// Autoplay
// Cinema
Single<CatchModRelax>(hasMultiplier: 0.1);
#endregion
#region Fun
Single<ModWindUp>(hasMultiplier: 0.5);
Single<ModWindDown>(hasMultiplier: 0.5);
// Floating Fruits
// Muted
// No Scope
// Moving Fast
Single<CatchModSynesthesia>(hasMultiplier: 0.8);
#endregion
#region System
// Score V2
#endregion
}
private static double rateAdjustMultiplier(double speedChange)
{
// Round to the nearest multiple of 0.1.
double value = (int)(speedChange * 10) / 10.0;
// Offset back to 0.
value -= 1;
if (speedChange >= 1)
return 1 + value / 5;
else
return 0.6 + value;
}
private static double classicMultiplier(ScoreInfo? score)
{
if (score != null && score.TotalScoreVersion < 30000017)
return 0.96;
return 1;
}
}
}
@@ -36,8 +36,8 @@ namespace osu.Game.Rulesets.Mania.Tests
var decoded = DecodeFromLegacy(beatmaps_resource_store.GetStream($"Resources/Testing/Beatmaps/{name}.osu"), beatmaps_resource_store, name);
var decodedAfterEncode = DecodeFromLegacy(EncodeToLegacy(decoded), beatmaps_resource_store, name);
Sort(decoded.beatmap);
Sort(decodedAfterEncode.beatmap);
Sort(decoded.Beatmap);
Sort(decodedAfterEncode.Beatmap);
CompareBeatmaps(decoded, decodedAfterEncode);
}
@@ -0,0 +1,236 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using NUnit.Framework;
using osu.Framework.Utils;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Mania.Mods;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Scoring;
using osu.Game.Scoring;
using osu.Game.Tests.Rulesets;
namespace osu.Game.Rulesets.Mania.Tests
{
public class ManiaScoreMultiplierTest : RulesetScoreMultiplierTest
{
public ManiaScoreMultiplierTest()
: base(new ManiaRuleset())
{
}
private static readonly object[][] test_cases =
[
#region Difficulty Reduction
[new Mod[] { new ManiaModEasy() }, 0.5],
[new Mod[] { new ManiaModNoFail() }, 0.5],
[new Mod[] { new ManiaModHalfTime { SpeedChange = { Value = 0.50 } } }, 0.1],
[new Mod[] { new ManiaModHalfTime { SpeedChange = { Value = 0.55 } } }, 0.1],
[new Mod[] { new ManiaModHalfTime { SpeedChange = { Value = 0.60 } } }, 0.2],
[new Mod[] { new ManiaModHalfTime { SpeedChange = { Value = 0.65 } } }, 0.2],
[new Mod[] { new ManiaModHalfTime { SpeedChange = { Value = 0.70 } } }, 0.3],
[new Mod[] { new ManiaModHalfTime { SpeedChange = { Value = 0.75 } } }, 0.3],
[new Mod[] { new ManiaModHalfTime { SpeedChange = { Value = 0.80 } } }, 0.4],
[new Mod[] { new ManiaModHalfTime { SpeedChange = { Value = 0.85 } } }, 0.4],
[new Mod[] { new ManiaModHalfTime { SpeedChange = { Value = 0.90 } } }, 0.5],
[new Mod[] { new ManiaModHalfTime { SpeedChange = { Value = 0.95 } } }, 0.5],
[new Mod[] { new ManiaModHalfTime { SpeedChange = { Value = 0.99 } } }, 0.5],
[new Mod[] { new ManiaModDaycore { SpeedChange = { Value = 0.50 } } }, 0.1],
[new Mod[] { new ManiaModDaycore { SpeedChange = { Value = 0.55 } } }, 0.1],
[new Mod[] { new ManiaModDaycore { SpeedChange = { Value = 0.60 } } }, 0.2],
[new Mod[] { new ManiaModDaycore { SpeedChange = { Value = 0.65 } } }, 0.2],
[new Mod[] { new ManiaModDaycore { SpeedChange = { Value = 0.70 } } }, 0.3],
[new Mod[] { new ManiaModDaycore { SpeedChange = { Value = 0.75 } } }, 0.3],
[new Mod[] { new ManiaModDaycore { SpeedChange = { Value = 0.80 } } }, 0.4],
[new Mod[] { new ManiaModDaycore { SpeedChange = { Value = 0.85 } } }, 0.4],
[new Mod[] { new ManiaModDaycore { SpeedChange = { Value = 0.90 } } }, 0.5],
[new Mod[] { new ManiaModDaycore { SpeedChange = { Value = 0.95 } } }, 0.5],
[new Mod[] { new ManiaModDaycore { SpeedChange = { Value = 0.99 } } }, 0.5],
[new Mod[] { new ManiaModNoRelease() }, 0.9],
#endregion
#region Difficulty Increase
[new Mod[] { new ManiaModHardRock() }, 1],
[new Mod[] { new ManiaModSuddenDeath() }, 1],
[new Mod[] { new ManiaModPerfect() }, 1],
[new Mod[] { new ManiaModDoubleTime { SpeedChange = { Value = 1.01 } } }, 1],
[new Mod[] { new ManiaModDoubleTime { SpeedChange = { Value = 1.05 } } }, 1],
[new Mod[] { new ManiaModDoubleTime { SpeedChange = { Value = 1.10 } } }, 1],
[new Mod[] { new ManiaModDoubleTime { SpeedChange = { Value = 1.15 } } }, 1],
[new Mod[] { new ManiaModDoubleTime { SpeedChange = { Value = 1.20 } } }, 1],
[new Mod[] { new ManiaModDoubleTime { SpeedChange = { Value = 1.25 } } }, 1],
[new Mod[] { new ManiaModDoubleTime { SpeedChange = { Value = 1.30 } } }, 1],
[new Mod[] { new ManiaModDoubleTime { SpeedChange = { Value = 1.35 } } }, 1],
[new Mod[] { new ManiaModDoubleTime { SpeedChange = { Value = 1.40 } } }, 1],
[new Mod[] { new ManiaModDoubleTime { SpeedChange = { Value = 1.45 } } }, 1],
[new Mod[] { new ManiaModDoubleTime { SpeedChange = { Value = 1.50 } } }, 1],
[new Mod[] { new ManiaModDoubleTime { SpeedChange = { Value = 1.55 } } }, 1],
[new Mod[] { new ManiaModDoubleTime { SpeedChange = { Value = 1.60 } } }, 1],
[new Mod[] { new ManiaModDoubleTime { SpeedChange = { Value = 1.65 } } }, 1],
[new Mod[] { new ManiaModDoubleTime { SpeedChange = { Value = 1.70 } } }, 1],
[new Mod[] { new ManiaModDoubleTime { SpeedChange = { Value = 1.75 } } }, 1],
[new Mod[] { new ManiaModDoubleTime { SpeedChange = { Value = 1.80 } } }, 1],
[new Mod[] { new ManiaModDoubleTime { SpeedChange = { Value = 1.85 } } }, 1],
[new Mod[] { new ManiaModDoubleTime { SpeedChange = { Value = 1.90 } } }, 1],
[new Mod[] { new ManiaModDoubleTime { SpeedChange = { Value = 1.95 } } }, 1],
[new Mod[] { new ManiaModDoubleTime { SpeedChange = { Value = 2.00 } } }, 1],
[new Mod[] { new ManiaModNightcore { SpeedChange = { Value = 1.01 } } }, 1],
[new Mod[] { new ManiaModNightcore { SpeedChange = { Value = 1.05 } } }, 1],
[new Mod[] { new ManiaModNightcore { SpeedChange = { Value = 1.10 } } }, 1],
[new Mod[] { new ManiaModNightcore { SpeedChange = { Value = 1.15 } } }, 1],
[new Mod[] { new ManiaModNightcore { SpeedChange = { Value = 1.20 } } }, 1],
[new Mod[] { new ManiaModNightcore { SpeedChange = { Value = 1.25 } } }, 1],
[new Mod[] { new ManiaModNightcore { SpeedChange = { Value = 1.30 } } }, 1],
[new Mod[] { new ManiaModNightcore { SpeedChange = { Value = 1.35 } } }, 1],
[new Mod[] { new ManiaModNightcore { SpeedChange = { Value = 1.40 } } }, 1],
[new Mod[] { new ManiaModNightcore { SpeedChange = { Value = 1.45 } } }, 1],
[new Mod[] { new ManiaModNightcore { SpeedChange = { Value = 1.50 } } }, 1],
[new Mod[] { new ManiaModNightcore { SpeedChange = { Value = 1.55 } } }, 1],
[new Mod[] { new ManiaModNightcore { SpeedChange = { Value = 1.60 } } }, 1],
[new Mod[] { new ManiaModNightcore { SpeedChange = { Value = 1.65 } } }, 1],
[new Mod[] { new ManiaModNightcore { SpeedChange = { Value = 1.70 } } }, 1],
[new Mod[] { new ManiaModNightcore { SpeedChange = { Value = 1.75 } } }, 1],
[new Mod[] { new ManiaModNightcore { SpeedChange = { Value = 1.80 } } }, 1],
[new Mod[] { new ManiaModNightcore { SpeedChange = { Value = 1.85 } } }, 1],
[new Mod[] { new ManiaModNightcore { SpeedChange = { Value = 1.90 } } }, 1],
[new Mod[] { new ManiaModNightcore { SpeedChange = { Value = 1.95 } } }, 1],
[new Mod[] { new ManiaModNightcore { SpeedChange = { Value = 2.00 } } }, 1],
[new Mod[] { new ManiaModFadeIn() }, 1],
[new Mod[] { new ManiaModHidden() }, 1],
[new Mod[] { new ManiaModCover() }, 1],
[new Mod[] { new ManiaModFlashlight() }, 1],
[new Mod[] { new ModAccuracyChallenge() }, 1],
#endregion
#region Conversion
[new Mod[] { new ManiaModRandom() }, 1],
[new Mod[] { new ManiaModDualStages() }, 1],
[new Mod[] { new ManiaModMirror() }, 1],
[new Mod[] { new ManiaModDifficultyAdjust() }, 0.5],
[new Mod[] { new ManiaModClassic() }, 1],
[new Mod[] { new ManiaModInvert() }, 1],
[new Mod[] { new ManiaModConstantSpeed() }, 0.9],
[new Mod[] { new ManiaModHoldOff() }, 0.9],
[new Mod[] { new ManiaModKey1() }, 0.9],
[new Mod[] { new ManiaModKey2() }, 0.9],
[new Mod[] { new ManiaModKey3() }, 0.9],
[new Mod[] { new ManiaModKey4() }, 0.9],
[new Mod[] { new ManiaModKey5() }, 0.9],
[new Mod[] { new ManiaModKey6() }, 0.9],
[new Mod[] { new ManiaModKey7() }, 0.9],
[new Mod[] { new ManiaModKey8() }, 0.9],
[new Mod[] { new ManiaModKey9() }, 0.9],
[new Mod[] { new ManiaModKey10() }, 0.9],
#endregion
#region Automation
[new Mod[] { new ManiaModAutoplay() }, 1],
[new Mod[] { new ManiaModCinema() }, 1],
#endregion
#region Fun
[new Mod[] { new ModWindUp() }, 0.5],
[new Mod[] { new ModWindDown() }, 0.5],
[new Mod[] { new ManiaModMuted() }, 1],
[new Mod[] { new ModAdaptiveSpeed() }, 0.5],
#endregion
#region System
[new Mod[] { new ManiaModScoreV2() }, 1],
#endregion
#region Combinations
[new Mod[] { new ManiaModEasy(), new ManiaModKey4() }, 0.5 * 0.9]
#endregion
];
[TestCaseSource(nameof(test_cases))]
public void TestMultipliers(Mod[] mods, double expectedMultiplier)
=> TestModCombination(mods, expectedMultiplier);
private static readonly object[][] key_mod_multiplier_test_cases =
[
// score end date, client version, expected multiplier
// scores verifiably from old clients.
[new DateTimeOffset(2024, 1, 31, 11, 0, 0, TimeSpan.Zero), "2024.130.2", 1],
[new DateTimeOffset(2024, 12, 9, 11, 0, 0, TimeSpan.Zero), "2024.1208.0", 1],
[new DateTimeOffset(2025, 6, 12, 11, 0, 0, TimeSpan.Zero), "2025.605.3", 1],
[new DateTimeOffset(2025, 6, 28, 11, 0, 0, TimeSpan.Zero), "2025.625.0-tachyon", 1],
[new DateTimeOffset(2025, 7, 11, 11, 0, 0, TimeSpan.Zero), "2025.710.0-lazer", 1],
[new DateTimeOffset(2025, 7, 15, 11, 0, 0, TimeSpan.Zero), "2025.711.0-tachyon", 1],
// scores without explicit client versions, predating the change of multiplier.
// those MUST have used the old multiplier.
[new DateTimeOffset(2024, 1, 31, 11, 0, 0, TimeSpan.Zero), "", 1],
[new DateTimeOffset(2024, 12, 9, 11, 0, 0, TimeSpan.Zero), "", 1],
[new DateTimeOffset(2025, 6, 12, 11, 0, 0, TimeSpan.Zero), "", 1],
[new DateTimeOffset(2025, 6, 28, 11, 0, 0, TimeSpan.Zero), "", 1],
[new DateTimeOffset(2025, 7, 11, 11, 0, 0, TimeSpan.Zero), "", 1],
[new DateTimeOffset(2025, 7, 15, 11, 0, 0, TimeSpan.Zero), "", 1],
// scores without explicit client versions, AFTER the change of multiplier.
// there is NO way of verifying whether these scores use the new or old multiplier, therefore GUESS that it's the new one.
// "thankfully" the window of opportunity for this occurring *should* be slim
// (from client release with new key mod multipliers on July 18, 2025
// until spectator server release which added client version writing to server-side replays on August 1, 2025).
[new DateTimeOffset(2025, 7, 19, 0, 20, 15, 0, TimeSpan.Zero), "", 0.9],
[new DateTimeOffset(2025, 7, 23, 0, 20, 15, 0, TimeSpan.Zero), "", 0.9],
[new DateTimeOffset(2025, 8, 19, 0, 20, 15, 0, TimeSpan.Zero), "", 0.9],
[new DateTimeOffset(2026, 6, 18, 0, 20, 15, 0, TimeSpan.Zero), "", 0.9],
[new DateTimeOffset(2026, 7, 18, 0, 20, 15, 0, TimeSpan.Zero), "", 0.9],
// scores verifiably from new clients.
[new DateTimeOffset(2025, 7, 19, 0, 20, 15, 0, TimeSpan.Zero), "2025.718.0-tachyon", 0.9],
[new DateTimeOffset(2025, 7, 23, 0, 20, 15, 0, TimeSpan.Zero), "2025.721.0-tachyon", 0.9],
[new DateTimeOffset(2025, 8, 19, 0, 20, 15, 0, TimeSpan.Zero), "2025.816.0-lazer", 0.9],
[new DateTimeOffset(2026, 6, 18, 0, 20, 15, 0, TimeSpan.Zero), "2026.518.0-lazer", 0.9],
[new DateTimeOffset(2026, 7, 18, 0, 20, 15, 0, TimeSpan.Zero), "2026.522.1-tachyon", 0.9],
];
[TestCaseSource(nameof(key_mod_multiplier_test_cases))]
public void TestKeyModMultiplierCompatibility(DateTimeOffset endDate, string clientVersion, double expectedMultiplier)
{
var calculator = Ruleset.CreateScoreMultiplierCalculator(new ScoreMultiplierContext(new BeatmapDifficulty(), new ScoreInfo
{
Date = endDate,
ClientVersion = clientVersion
}));
Assert.That(calculator.CalculateFor([new ManiaModKey4()]), Is.EqualTo(expectedMultiplier).Within(Precision.DOUBLE_EPSILON));
}
[TestCase(30000001, 0.96)]
[TestCase(30000009, 0.96)]
[TestCase(30000016, 0.96)]
[TestCase(30000017, 1)]
[TestCase(null, 1)]
public void TestClassicMultiplierVersioning(int? totalScoreVersion, double expectedMultiplier)
{
var scoreInfo = totalScoreVersion != null ? new ScoreInfo { TotalScoreVersion = totalScoreVersion.Value } : null;
var calculator = Ruleset.CreateScoreMultiplierCalculator(new ScoreMultiplierContext(new BeatmapDifficulty(), scoreInfo));
Assert.That(calculator.CalculateFor([new ManiaModClassic()]), Is.EqualTo(expectedMultiplier).Within(Precision.DOUBLE_EPSILON));
}
}
}
@@ -8,8 +8,10 @@ using osu.Game.Beatmaps;
using osu.Game.Rulesets.Mania.Mods;
using osu.Game.Rulesets.Mania.Objects;
using osu.Game.Rulesets.Mania.Replays;
using osu.Game.Rulesets.Mania.Scoring;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Replays;
using osu.Game.Rulesets.Scoring;
using osu.Game.Tests.Visual;
namespace osu.Game.Rulesets.Mania.Tests.Mods
@@ -54,7 +56,7 @@ namespace osu.Game.Rulesets.Mania.Tests.Mods
Mod = doubleTime,
PassCondition = () => Player.ScoreProcessor.JudgedHits > 0
&& Player.ScoreProcessor.Accuracy.Value == 1
&& Player.ScoreProcessor.TotalScore.Value == (long)(1_000_000 * doubleTime.ScoreMultiplier),
&& Player.ScoreProcessor.TotalScore.Value == (long)(1_000_000 * new ManiaScoreMultiplierCalculator(new ScoreMultiplierContext(new BeatmapDifficulty())).CalculateFor([doubleTime])),
Autoplay = false,
CreateBeatmap = () => new Beatmap
{
@@ -19,7 +19,6 @@ using osu.Game.Rulesets.Mania.Scoring;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Scoring;
using osu.Game.Utils;
namespace osu.Game.Rulesets.Mania.Difficulty
{
@@ -37,7 +36,7 @@ namespace osu.Game.Rulesets.Mania.Difficulty
isForCurrentRuleset = beatmap.BeatmapInfo.Ruleset.MatchesOnlineID(ruleset);
}
protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills)
protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate)
{
if (beatmap.HitObjects.Count == 0)
return new ManiaDifficultyAttributes { Mods = mods };
@@ -63,13 +62,11 @@ namespace osu.Game.Rulesets.Mania.Difficulty
return 1;
}
protected override IEnumerable<DifficultyHitObject> CreateDifficultyHitObjects(IBeatmap beatmap, Mod[] mods)
protected override IEnumerable<DifficultyHitObject> CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate)
{
var sortedObjects = beatmap.HitObjects.ToArray();
int totalColumns = ((ManiaBeatmap)beatmap).TotalColumns;
double clockRate = ModUtils.CalculateRateWithMods(mods);
LegacySortHelper<HitObject>.Sort(sortedObjects, Comparer<HitObject>.Create((a, b) => (int)Math.Round(a.StartTime) - (int)Math.Round(b.StartTime)));
List<DifficultyHitObject> objects = new List<DifficultyHitObject>();
@@ -91,7 +88,7 @@ namespace osu.Game.Rulesets.Mania.Difficulty
// Sorting is done in CreateDifficultyHitObjects, since the full list of hitobjects is required.
protected override IEnumerable<DifficultyHitObject> SortObjects(IEnumerable<DifficultyHitObject> input) => input;
protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods) => new Skill[]
protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) => new Skill[]
{
new Strain(mods, ((ManiaBeatmap)Beatmap).TotalColumns)
};
@@ -9,6 +9,8 @@ using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Mania.Objects;
using osu.Game.Rulesets.Mania.Skinning.Default;
using osu.Game.Rulesets.Mania.UI;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.UI.Scrolling;
using osuTK;
using osuTK.Input;
@@ -90,5 +92,8 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints
private float getNoteHeight(Column resultPlayfield) =>
resultPlayfield.ToScreenSpace(new Vector2(DefaultNotePiece.NOTE_HEIGHT)).Y -
resultPlayfield.ToScreenSpace(Vector2.Zero).Y;
public override bool ReplacesExistingObject(HitObject existing)
=> base.ReplacesExistingObject(existing) && HitObject.Column == ((IHasColumn)existing).Column;
}
}
@@ -2,7 +2,8 @@
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Graphics;
using osu.Game.Beatmaps;
using osu.Framework.Graphics.Sprites;
using osu.Game.Graphics;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Edit.Tools;
using osu.Game.Rulesets.Mania.Edit.Blueprints;
@@ -16,7 +17,7 @@ namespace osu.Game.Rulesets.Mania.Edit
{
}
public override Drawable CreateIcon() => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Sliders);
public override Drawable CreateIcon() => new SpriteIcon { Icon = OsuIcon.EditorHoldNote };
public override HitObjectPlacementBlueprint CreatePlacementBlueprint() => new HoldNotePlacementBlueprint();
}
@@ -3,11 +3,16 @@
using System.Collections.Generic;
using System.Linq;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Input.Events;
using osu.Game.Graphics;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Mania.Edit.Blueprints;
using osu.Game.Rulesets.Mania.Objects;
using osu.Game.Rulesets.Objects;
using osu.Game.Screens.Edit.Components.TernaryButtons;
using osu.Game.Screens.Edit.Compose.Components;
using osuTK;
@@ -22,6 +27,29 @@ namespace osu.Game.Rulesets.Mania.Edit
{
}
protected override Drawable CreateNewComboButton() => new NewComboTernaryButton
{
Current = NewCombo,
CreateIcon = () => new Container
{
Children = new Drawable[]
{
new SpriteIcon
{
Anchor = Anchor.BottomLeft,
Origin = Anchor.BottomLeft,
Icon = OsuIcon.EditorNote,
Size = new Vector2(15),
},
new SpriteIcon
{
Icon = OsuIcon.EditorNewComboSparkles,
Size = new Vector2(20),
}
},
},
};
public override HitObjectSelectionBlueprint? CreateHitObjectBlueprintFor(HitObject hitObject)
{
switch (hitObject)
@@ -2,7 +2,8 @@
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Graphics;
using osu.Game.Beatmaps;
using osu.Framework.Graphics.Sprites;
using osu.Game.Graphics;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Edit.Tools;
using osu.Game.Rulesets.Mania.Edit.Blueprints;
@@ -17,7 +18,7 @@ namespace osu.Game.Rulesets.Mania.Edit
{
}
public override Drawable CreateIcon() => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Circles);
public override Drawable CreateIcon() => new SpriteIcon { Icon = OsuIcon.EditorNote };
public override HitObjectPlacementBlueprint CreatePlacementBlueprint() => new NotePlacementBlueprint();
}
+2
View File
@@ -307,6 +307,8 @@ namespace osu.Game.Rulesets.Mania
}
}
public override ScoreMultiplierCalculator CreateScoreMultiplierCalculator(ScoreMultiplierContext context) => new ManiaScoreMultiplierCalculator(context);
public override string Description => "osu!mania";
public override string ShortName => SHORT_NAME;
@@ -14,7 +14,6 @@ namespace osu.Game.Rulesets.Mania.Mods
public override string Acronym => Name;
public abstract int KeyCount { get; }
public override ModType Type => ModType.Conversion;
public override double ScoreMultiplier => 0.9;
public override bool Ranked => UsesDefaultConfiguration;
public void ApplyToBeatmapConverter(IBeatmapConverter beatmapConverter)
@@ -18,8 +18,6 @@ namespace osu.Game.Rulesets.Mania.Mods
public override string Acronym => "CS";
public override double ScoreMultiplier => 0.9;
public override LocalisableString Description => "No more tricky speed changes!";
public override IconUsage? Icon => OsuIcon.ModConstantSpeed;
@@ -20,8 +20,6 @@ namespace osu.Game.Rulesets.Mania.Mods
public override LocalisableString Description => @"Decrease the playfield's viewing area.";
public override double ScoreMultiplier => 1;
protected override CoverExpandDirection ExpandDirection => Direction.Value;
public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[]
@@ -7,9 +7,5 @@ namespace osu.Game.Rulesets.Mania.Mods
{
public class ManiaModDoubleTime : ModDoubleTime, IManiaRateAdjustmentMod
{
// For now, all rate-increasing mods should be given a 1x multiplier in mania because it doesn't always
// make the map harder and is more of a personal preference.
// In the future, we can consider adjusting this by experimenting with not applying the hitwindow leniency.
public override double ScoreMultiplier => 1;
}
}
@@ -17,7 +17,6 @@ namespace osu.Game.Rulesets.Mania.Mods
public override LocalisableString Description => @"Double the stages, double the fun!";
public override IconUsage? Icon => OsuIcon.ModDualStages;
public override ModType Type => ModType.Conversion;
public override double ScoreMultiplier => 1;
private bool isForCurrentRuleset;
@@ -16,7 +16,6 @@ namespace osu.Game.Rulesets.Mania.Mods
public override string Acronym => "FI";
public override IconUsage? Icon => OsuIcon.ModFadeIn;
public override LocalisableString Description => @"Keys appear out of nowhere!";
public override double ScoreMultiplier => 1;
public override bool ValidForFreestyleAsRequiredMod => false;
public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[]
@@ -13,7 +13,6 @@ namespace osu.Game.Rulesets.Mania.Mods
{
public partial class ManiaModFlashlight : ModFlashlight<ManiaHitObject>
{
public override double ScoreMultiplier => 1;
public override Type[] IncompatibleMods => new[] { typeof(ModHidden) };
public override BindableFloat SizeMultiplier { get; } = new BindableFloat(1)
@@ -10,7 +10,6 @@ namespace osu.Game.Rulesets.Mania.Mods
{
public class ManiaModHardRock : ModHardRock, IApplicableToHitObject
{
public override double ScoreMultiplier => 1;
public override bool Ranked => false;
public const double HIT_WINDOW_DIFFICULTY_MULTIPLIER = 1.4;
@@ -30,7 +30,6 @@ namespace osu.Game.Rulesets.Mania.Mods
private const float coverage_increase_per_combo = 0.5f;
public override LocalisableString Description => @"Keys fade out before you hit them!";
public override double ScoreMultiplier => 1;
public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[]
{
@@ -21,8 +21,6 @@ namespace osu.Game.Rulesets.Mania.Mods
public override string Acronym => "HO";
public override double ScoreMultiplier => 0.9;
public override LocalisableString Description => @"Replaces all hold notes with normal notes.";
public override IconUsage? Icon => OsuIcon.ModHoldOff;
@@ -20,7 +20,6 @@ namespace osu.Game.Rulesets.Mania.Mods
public override string Name => "Invert";
public override string Acronym => "IN";
public override double ScoreMultiplier => 1;
public override LocalisableString Description => "Hold the keys. To the beat.";
@@ -8,9 +8,5 @@ namespace osu.Game.Rulesets.Mania.Mods
{
public class ManiaModNightcore : ModNightcore<ManiaHitObject>, IManiaRateAdjustmentMod
{
// For now, all rate-increasing mods should be given a 1x multiplier in mania because it doesn't always
// make the map any harder and is more of a personal preference.
// In the future, we can consider adjusting this by experimenting with not applying the hitwindow leniency.
public override double ScoreMultiplier => 1;
}
}
@@ -26,8 +26,6 @@ namespace osu.Game.Rulesets.Mania.Mods
public override LocalisableString Description => "No more timing the end of hold notes.";
public override double ScoreMultiplier => 0.9;
public override IconUsage? Icon => OsuIcon.ModNoRelease;
public override ModType Type => ModType.DifficultyReduction;
@@ -0,0 +1,154 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using osu.Game.Rulesets.Mania.Mods;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Scoring;
using osu.Game.Scoring;
namespace osu.Game.Rulesets.Mania.Scoring
{
public class ManiaScoreMultiplierCalculator : ScoreMultiplierCalculator
{
public ManiaScoreMultiplierCalculator(ScoreMultiplierContext context)
: base(context)
{
#region Difficulty Reduction
Single<ManiaModEasy>(hasMultiplier: 0.5);
Single<ManiaModNoFail>(hasMultiplier: 0.5);
Single<ManiaModHalfTime>(hasMultiplier: halfTime => rateAdjustMultiplier(halfTime.SpeedChange.Value));
Single<ManiaModDaycore>(hasMultiplier: daycore => rateAdjustMultiplier(daycore.SpeedChange.Value));
Single<ManiaModNoRelease>(hasMultiplier: 0.9);
#endregion
#region Difficulty Increase
// Hard Rock
// Sudden Death
// Perfect
// Double Time
// Nightcore
// Fade In
// Hidden
// Cover
// Flashlight
// Accuracy Challenge
#endregion
#region Conversion
// Random
// Dual Stages
// Mirror
Single<ManiaModDifficultyAdjust>(hasMultiplier: 0.5);
Single<ManiaModClassic>(hasMultiplier: _ => classicMultiplier(Context.Score));
// Invert
Single<ManiaModConstantSpeed>(hasMultiplier: 0.9);
Single<ManiaModHoldOff>(hasMultiplier: 0.9);
Single<ManiaModKey1>(hasMultiplier: keyModMultiplier(Context.Score));
Single<ManiaModKey2>(hasMultiplier: keyModMultiplier(Context.Score));
Single<ManiaModKey3>(hasMultiplier: keyModMultiplier(Context.Score));
Single<ManiaModKey4>(hasMultiplier: keyModMultiplier(Context.Score));
Single<ManiaModKey5>(hasMultiplier: keyModMultiplier(Context.Score));
Single<ManiaModKey6>(hasMultiplier: keyModMultiplier(Context.Score));
Single<ManiaModKey7>(hasMultiplier: keyModMultiplier(Context.Score));
Single<ManiaModKey8>(hasMultiplier: keyModMultiplier(Context.Score));
Single<ManiaModKey9>(hasMultiplier: keyModMultiplier(Context.Score));
Single<ManiaModKey10>(hasMultiplier: keyModMultiplier(Context.Score));
#endregion
#region Automation
// Autoplay
// Cinema
#endregion
#region Fun
Single<ModWindUp>(hasMultiplier: 0.5);
Single<ModWindDown>(hasMultiplier: 0.5);
// Muted
Single<ModAdaptiveSpeed>(hasMultiplier: 0.5);
#endregion
#region System
// Score V2
#endregion
}
private static double rateAdjustMultiplier(double speedChange)
{
// Round to the nearest multiple of 0.1.
double value = (int)(speedChange * 10) / 10.0;
// Offset back to 0.
value -= 1;
if (speedChange >= 1)
return 1 + value / 5;
else
return 0.6 + value;
}
private const double old_key_mod_multiplier = 1;
private const double new_key_mod_multiplier = 0.9;
/// <summary>
/// <para>
/// The mod multiplier was changed from 1.0x to 0.9x in https://github.com/ppy/osu/pull/30506
/// which was included in the https://osu.ppy.sh/home/changelog/tachyon/2025.718.0 release.
/// The replay version was not bumped in the change, meaning that the only usable indicator
/// of the mod multiplier changing is the client version.
/// </para>
/// <para>
/// Unfortunately not even the client version is available on server-side recorded replays
/// recorded prior to https://github.com/ppy/osu-server-spectator/pull/290,
/// which does not appear to have been deployed until August 1
/// (https://github.com/ppy/osu-server-spectator/releases/tag/2025.801.0).
/// </para>
/// </summary>
private double keyModMultiplier(ScoreInfo? scoreInfo)
{
if (scoreInfo == null)
return new_key_mod_multiplier;
string clientVersion = scoreInfo.ClientVersion;
if (!string.IsNullOrEmpty(clientVersion))
{
string[] pieces = clientVersion.Split('.');
if (int.TryParse(pieces[0], out int year) && int.TryParse(pieces[1], out int monthDay))
{
if (year < 2025 || (year == 2025 && monthDay < 718))
return old_key_mod_multiplier;
}
return new_key_mod_multiplier;
}
// Client version not available, fallback to doing the best we can with the score's timestamp.
if (scoreInfo.Date < new DateTimeOffset(2025, 7, 18, 0, 0, 0, TimeSpan.Zero))
return old_key_mod_multiplier;
return new_key_mod_multiplier;
}
private static double classicMultiplier(ScoreInfo? score)
{
if (score != null && score.TotalScoreVersion < 30000017)
return 0.96;
return 1;
}
}
}
@@ -33,7 +33,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
[Test]
public void TestTouchInputPlaceHitCircleDirectly()
{
AddStep("tap circle", () => tap(this.ChildrenOfType<EditorRadioButton>().Single(b => b.Button.Label == "HitCircle")));
AddStep("tap circle", () => tap(this.ChildrenOfType<EditorRadioButton>().Single(b => b.Button.Label == "Hit circle")));
AddStep("tap to place circle", () => tap(this.ChildrenOfType<Playfield>().Single()));
AddAssert("circle placed correctly", () =>
@@ -52,7 +52,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
[Test]
public void TestTouchInputPlaceCircleAfterTouchingComposeArea()
{
AddStep("tap circle", () => tap(this.ChildrenOfType<EditorRadioButton>().Single(b => b.Button.Label == "HitCircle")));
AddStep("tap circle", () => tap(this.ChildrenOfType<EditorRadioButton>().Single(b => b.Button.Label == "Hit circle")));
AddStep("tap playfield", () => tap(this.ChildrenOfType<Playfield>().Single()));
AddAssert("circle placed", () => EditorBeatmap.HitObjects.Single(h => h.StartTime == EditorClock.CurrentTimeAccurate) is HitCircle);
@@ -7,6 +7,9 @@ using osu.Framework.Input;
using osu.Framework.Testing;
using osu.Framework.Utils;
using osu.Game.Beatmaps;
using osu.Game.Graphics.UserInterface;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Osu.Edit;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.UI;
using osu.Game.Screens.Edit;
@@ -130,5 +133,74 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
AddAssert("slider has correct velocity", () => slider!.Velocity, () => Is.EqualTo(velocityBefore));
AddAssert("slider has correct duration", () => slider!.Duration, () => Is.EqualTo(durationBefore));
}
[Test]
public void TestVelocityToolbox()
{
ExpandableSlider<double> velocitySlider = null!;
ExpandableButton useLastSliderButton = null!;
AddStep("enter editor", () => Game.ScreenStack.Push(new EditorLoader()));
AddUntilStep("wait for editor load", () => editor?.ReadyForUse == true);
AddStep("retrieve controls", () =>
{
var toolbox = this.ChildrenOfType<OsuSliderVelocityToolboxGroup>().Single();
velocitySlider = toolbox.ChildrenOfType<ExpandableSlider<double>>().Single();
useLastSliderButton = toolbox.ChildrenOfType<ExpandableButton>().Single();
});
AddAssert("velocity slider at 1x", () => velocitySlider.Current.Value, () => Is.EqualTo(1));
AddStep("expand right toolbox", () => InputManager.MoveMouseTo(this.ChildrenOfType<ExpandingToolboxContainer>().Last()));
AddUntilStep("wait for expand", () => useLastSliderButton.Expanded.Value, () => Is.True);
AddAssert("use last slider button disabled", () => useLastSliderButton.Enabled.Value, () => Is.False);
AddStep("seek to 5000", () => editorClock.Seek(5000));
AddStep("set 2x velocity", () => velocitySlider.Current.Value = 2);
placeSlider();
AddAssert("placed slider has 2x velocity", () => editorBeatmap.HitObjects.OfType<Slider>().Last().SliderVelocityMultiplier, () => Is.EqualTo(2));
AddStep("expand right toolbox", () => InputManager.MoveMouseTo(this.ChildrenOfType<ExpandingToolboxContainer>().Last()));
AddUntilStep("wait for expand", () => useLastSliderButton.Expanded.Value, () => Is.True);
AddAssert("use last slider button enabled", () => useLastSliderButton.Enabled.Value, () => Is.True);
AddStep("seek to 6000", () => editorClock.Seek(6000));
placeSlider();
AddAssert("placed slider has 2x velocity", () => editorBeatmap.HitObjects.OfType<Slider>().Last().SliderVelocityMultiplier, () => Is.EqualTo(2));
AddStep("expand right toolbox", () => InputManager.MoveMouseTo(this.ChildrenOfType<ExpandingToolboxContainer>().Last()));
AddUntilStep("wait for expand", () => useLastSliderButton.Expanded.Value, () => Is.True);
AddAssert("use last slider button enabled", () => useLastSliderButton.Enabled.Value, () => Is.True);
AddStep("seek to 9000", () => editorClock.Seek(9000));
AddStep("set 3x velocity", () => velocitySlider.Current.Value = 3);
placeSlider();
AddAssert("placed slider has 3x velocity", () => editorBeatmap.HitObjects.OfType<Slider>().Last().SliderVelocityMultiplier, () => Is.EqualTo(3));
AddStep("expand right toolbox", () => InputManager.MoveMouseTo(this.ChildrenOfType<ExpandingToolboxContainer>().Last()));
AddUntilStep("wait for expand", () => useLastSliderButton.Expanded.Value, () => Is.True);
AddAssert("use last slider button enabled", () => useLastSliderButton.Enabled.Value, () => Is.True);
AddStep("seek to 10000", () => editorClock.Seek(10000));
AddStep("set 1x velocity", () => velocitySlider.Current.Value = 1);
AddStep("use last slider velocity instead", () => useLastSliderButton.TriggerClick());
placeSlider();
AddAssert("placed slider has 3x velocity", () => editorBeatmap.HitObjects.OfType<Slider>().Last().SliderVelocityMultiplier, () => Is.EqualTo(3));
AddStep("expand right toolbox", () => InputManager.MoveMouseTo(this.ChildrenOfType<ExpandingToolboxContainer>().Last()));
AddUntilStep("wait for expand", () => useLastSliderButton.Expanded.Value, () => Is.True);
AddAssert("use last slider button disabled", () => useLastSliderButton.Enabled.Value, () => Is.False);
AddStep("seek back to 7000", () => editorClock.Seek(7000));
placeSlider();
AddAssert("placed slider has 2x velocity", () => editorBeatmap.HitObjects.OfType<Slider>().ElementAt(2).SliderVelocityMultiplier, () => Is.EqualTo(2));
AddStep("expand right toolbox", () => InputManager.MoveMouseTo(this.ChildrenOfType<ExpandingToolboxContainer>().Last()));
AddUntilStep("wait for expand", () => useLastSliderButton.Expanded.Value, () => Is.True);
AddAssert("use last slider button disabled", () => useLastSliderButton.Enabled.Value, () => Is.False);
void placeSlider()
{
AddStep("enter slider placement mode", () => InputManager.Key(Key.Number3));
AddStep("move mouse to top left", () => InputManager.MoveMouseTo(editor.ChildrenOfType<Playfield>().First().ScreenSpaceDrawQuad.TopLeft + new Vector2(50)));
AddStep("start placement", () => InputManager.Click(MouseButton.Left));
AddStep("move mouse to bottom right", () => InputManager.MoveMouseTo(editor.ChildrenOfType<Playfield>().First().ScreenSpaceDrawQuad.BottomRight - new Vector2(50)));
AddStep("end placement", () => InputManager.Click(MouseButton.Right));
}
}
}
}
@@ -17,6 +17,8 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods
{
public partial class TestSceneOsuModDifficultyAdjust : OsuModTestScene
{
protected override bool AllowFail => true;
[Test]
public void TestNoAdjustment() => CreateModTest(new ModTestData
{
@@ -72,6 +74,88 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods
PassCondition = () => checkSomeHit() && checkObjectsPreempt(450)
});
[Test]
public void TestScoreMultiplierCorrectWithNoAdjustment() => CreateModTest(new ModTestData
{
Mod = new OsuModDifficultyAdjust(),
CreateBeatmap = () => new Beatmap
{
BeatmapInfo = new BeatmapInfo
{
Difficulty = new BeatmapDifficulty
{
CircleSize = 8
}
},
HitObjects = new List<HitObject>
{
new HitCircle { StartTime = 1000 },
new HitCircle { StartTime = 2000 }
}
},
Autoplay = true,
PassCondition = () => Player.ScoreProcessor.TotalScore.Value == 1_000_000,
});
[Test]
public void TestScoreMultiplierCorrectWithSingleAdjustment() => CreateModTest(new ModTestData
{
Mod = new OsuModDifficultyAdjust
{
ApproachRate = { Value = 7.3f }
},
CreateBeatmap = () => new Beatmap
{
BeatmapInfo = new BeatmapInfo
{
Difficulty = new BeatmapDifficulty
{
CircleSize = 8,
ApproachRate = 7,
OverallDifficulty = 6,
DrainRate = 5,
}
},
HitObjects = new List<HitObject>
{
new HitCircle { StartTime = 1000 },
new HitCircle { StartTime = 2000 }
}
},
Autoplay = true,
PassCondition = () => Player.ScoreProcessor.TotalScore.Value == 850_000,
});
[Test]
public void TestScoreMultiplierCorrectWithMultipleAdjustments() => CreateModTest(new ModTestData
{
Mod = new OsuModDifficultyAdjust
{
ApproachRate = { Value = 6.8f },
OverallDifficulty = { Value = 6.6f }
},
CreateBeatmap = () => new Beatmap
{
BeatmapInfo = new BeatmapInfo
{
Difficulty = new BeatmapDifficulty
{
CircleSize = 8,
ApproachRate = 7,
OverallDifficulty = 6,
DrainRate = 5,
}
},
HitObjects = new List<HitObject>
{
new HitCircle { StartTime = 1000 },
new HitCircle { StartTime = 2000 }
}
},
Autoplay = true,
PassCondition = () => Player.ScoreProcessor.TotalScore.Value == 630_000,
});
private bool checkObjectsPreempt(double target)
{
var objects = Player.ChildrenOfType<DrawableHitCircle>();
@@ -15,49 +15,25 @@ namespace osu.Game.Rulesets.Osu.Tests
{
protected override string ResourceAssembly => "osu.Game.Rulesets.Osu.Tests";
[TestCase(6.5243170265483581d, 239, "diffcalc-test")]
[TestCase(1.3280410795791415d, 54, "zero-length-sliders")]
[TestCase(0.40867325147697559d, 4, "very-fast-slider")]
[TestCase(0.87058175794353554d, 6, "nan-slider")]
[TestCase(6.6232533278125061d, 239, "diffcalc-test")]
[TestCase(1.5045783545699611d, 54, "zero-length-sliders")]
[TestCase(0.43333836671191595d, 4, "very-fast-slider")]
[TestCase(0.13841532030395723d, 2, "nan-slider")]
public void Test(double expectedStarRating, int expectedMaxCombo, string name)
=> base.Test(expectedStarRating, expectedMaxCombo, name);
[TestCase(9.4677607900646308d, 239, "diffcalc-test")]
[TestCase(1.6856612715618886d, 54, "zero-length-sliders")]
[TestCase(0.53588473186572561d, 4, "very-fast-slider")]
[TestCase(9.6491691624112761d, 239, "diffcalc-test")]
[TestCase(1.756936832498702d, 54, "zero-length-sliders")]
[TestCase(0.57771197086735004d, 4, "very-fast-slider")]
public void TestClockRateAdjusted(double expectedStarRating, int expectedMaxCombo, string name)
=> Test(expectedStarRating, expectedMaxCombo, name, new OsuModDoubleTime());
[TestCase(6.5243170265483581d, 239, "diffcalc-test")]
[TestCase(1.3280410795791415d, 54, "zero-length-sliders")]
[TestCase(0.40867325147697559d, 4, "very-fast-slider")]
[TestCase(6.6232533278125061d, 239, "diffcalc-test")]
[TestCase(1.5045783545699611d, 54, "zero-length-sliders")]
[TestCase(0.43333836671191595d, 4, "very-fast-slider")]
public void TestClassicMod(double expectedStarRating, int expectedMaxCombo, string name)
=> Test(expectedStarRating, expectedMaxCombo, name, new OsuModClassic());
[TestCase(239, "diffcalc-test")]
[TestCase(54, "zero-length-sliders")]
[TestCase(4, "very-fast-slider")]
public void TestOffsetChanges(int expectedMaxCombo, string name)
{
const double offset_iterations = 400;
var beatmap = GetBeatmap(name);
var attributes = CreateDifficultyCalculator(beatmap).Calculate();
double expectedStarRating = attributes.StarRating;
for (int i = 0; i < offset_iterations; i++)
{
foreach (var beatmapHitObject in beatmap.Beatmap.HitObjects)
beatmapHitObject.StartTime++;
attributes = CreateDifficultyCalculator(beatmap).Calculate();
// Platform-dependent math functions (Pow, Cbrt, Exp, etc) may result in minute differences.
Assert.That(attributes.StarRating, Is.EqualTo(expectedStarRating).Within(0.00001));
Assert.That(attributes.MaxCombo, Is.EqualTo(expectedMaxCombo));
}
}
protected override DifficultyCalculator CreateDifficultyCalculator(IWorkingBeatmap beatmap) => new OsuDifficultyCalculator(new OsuRuleset().RulesetInfo, beatmap);
protected override Ruleset CreateRuleset() => new OsuRuleset();
@@ -0,0 +1,297 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using NUnit.Framework;
using osu.Framework.Utils;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Osu.Mods;
using osu.Game.Rulesets.Scoring;
using osu.Game.Scoring;
using osu.Game.Tests.Rulesets;
namespace osu.Game.Rulesets.Osu.Tests
{
public class OsuScoreMultiplierTest : RulesetScoreMultiplierTest
{
public OsuScoreMultiplierTest()
: base(new OsuRuleset())
{
}
private static readonly object[][] test_cases =
[
#region Difficulty Reduction
[new Mod[] { new OsuModEasy() }, 0.8],
[new Mod[] { new OsuModEasy { Retries = { Value = 1 } } }, 0.8],
[new Mod[] { new OsuModEasy { Retries = { Value = 3 } } }, 0.7],
[new Mod[] { new OsuModEasy { Retries = { Value = 5 } } }, 0.5],
[new Mod[] { new OsuModEasy { Retries = { Value = 8 } } }, 0.4],
[new Mod[] { new OsuModNoFail() }, 0.5],
[new Mod[] { new OsuModHalfTime { SpeedChange = { Value = 0.50 } } }, 0.20],
[new Mod[] { new OsuModHalfTime { SpeedChange = { Value = 0.55 } } }, 0.27],
[new Mod[] { new OsuModHalfTime { SpeedChange = { Value = 0.60 } } }, 0.34],
[new Mod[] { new OsuModHalfTime { SpeedChange = { Value = 0.65 } } }, 0.41],
[new Mod[] { new OsuModHalfTime { SpeedChange = { Value = 0.70 } } }, 0.48],
[new Mod[] { new OsuModHalfTime { SpeedChange = { Value = 0.75 } } }, 0.55],
[new Mod[] { new OsuModHalfTime { SpeedChange = { Value = 0.80 } } }, 0.62],
[new Mod[] { new OsuModHalfTime { SpeedChange = { Value = 0.85 } } }, 0.69],
[new Mod[] { new OsuModHalfTime { SpeedChange = { Value = 0.90 } } }, 0.76],
[new Mod[] { new OsuModHalfTime { SpeedChange = { Value = 0.95 } } }, 0.83],
[new Mod[] { new OsuModHalfTime { SpeedChange = { Value = 0.99 } } }, 0.83],
[new Mod[] { new OsuModDaycore { SpeedChange = { Value = 0.50 } } }, 0.20],
[new Mod[] { new OsuModDaycore { SpeedChange = { Value = 0.55 } } }, 0.27],
[new Mod[] { new OsuModDaycore { SpeedChange = { Value = 0.60 } } }, 0.34],
[new Mod[] { new OsuModDaycore { SpeedChange = { Value = 0.65 } } }, 0.41],
[new Mod[] { new OsuModDaycore { SpeedChange = { Value = 0.70 } } }, 0.48],
[new Mod[] { new OsuModDaycore { SpeedChange = { Value = 0.75 } } }, 0.55],
[new Mod[] { new OsuModDaycore { SpeedChange = { Value = 0.80 } } }, 0.62],
[new Mod[] { new OsuModDaycore { SpeedChange = { Value = 0.85 } } }, 0.69],
[new Mod[] { new OsuModDaycore { SpeedChange = { Value = 0.90 } } }, 0.76],
[new Mod[] { new OsuModDaycore { SpeedChange = { Value = 0.95 } } }, 0.83],
[new Mod[] { new OsuModDaycore { SpeedChange = { Value = 0.99 } } }, 0.83],
#endregion
#region Difficulty Increase
[new Mod[] { new OsuModHardRock() }, 1.09],
[new Mod[] { new OsuModSuddenDeath() }, 1],
[new Mod[] { new OsuModPerfect() }, 1],
[new Mod[] { new OsuModDoubleTime { SpeedChange = { Value = 1.01 } } }, 1.000],
[new Mod[] { new OsuModDoubleTime { SpeedChange = { Value = 1.05 } } }, 1.000],
[new Mod[] { new OsuModDoubleTime { SpeedChange = { Value = 1.10 } } }, 1.036],
[new Mod[] { new OsuModDoubleTime { SpeedChange = { Value = 1.15 } } }, 1.036],
[new Mod[] { new OsuModDoubleTime { SpeedChange = { Value = 1.20 } } }, 1.082],
[new Mod[] { new OsuModDoubleTime { SpeedChange = { Value = 1.25 } } }, 1.082],
[new Mod[] { new OsuModDoubleTime { SpeedChange = { Value = 1.30 } } }, 1.128],
[new Mod[] { new OsuModDoubleTime { SpeedChange = { Value = 1.35 } } }, 1.128],
[new Mod[] { new OsuModDoubleTime { SpeedChange = { Value = 1.40 } } }, 1.174],
[new Mod[] { new OsuModDoubleTime { SpeedChange = { Value = 1.45 } } }, 1.174],
[new Mod[] { new OsuModDoubleTime { SpeedChange = { Value = 1.50 } } }, 1.230],
[new Mod[] { new OsuModDoubleTime { SpeedChange = { Value = 1.55 } } }, 1.230],
[new Mod[] { new OsuModDoubleTime { SpeedChange = { Value = 1.60 } } }, 1.266],
[new Mod[] { new OsuModDoubleTime { SpeedChange = { Value = 1.65 } } }, 1.266],
[new Mod[] { new OsuModDoubleTime { SpeedChange = { Value = 1.70 } } }, 1.312],
[new Mod[] { new OsuModDoubleTime { SpeedChange = { Value = 1.75 } } }, 1.312],
[new Mod[] { new OsuModDoubleTime { SpeedChange = { Value = 1.80 } } }, 1.358],
[new Mod[] { new OsuModDoubleTime { SpeedChange = { Value = 1.85 } } }, 1.358],
[new Mod[] { new OsuModDoubleTime { SpeedChange = { Value = 1.90 } } }, 1.404],
[new Mod[] { new OsuModDoubleTime { SpeedChange = { Value = 1.95 } } }, 1.404],
[new Mod[] { new OsuModDoubleTime { SpeedChange = { Value = 2.00 } } }, 1.450],
[new Mod[] { new OsuModNightcore { SpeedChange = { Value = 1.01 } } }, 1.000],
[new Mod[] { new OsuModNightcore { SpeedChange = { Value = 1.05 } } }, 1.000],
[new Mod[] { new OsuModNightcore { SpeedChange = { Value = 1.10 } } }, 1.036],
[new Mod[] { new OsuModNightcore { SpeedChange = { Value = 1.15 } } }, 1.036],
[new Mod[] { new OsuModNightcore { SpeedChange = { Value = 1.20 } } }, 1.082],
[new Mod[] { new OsuModNightcore { SpeedChange = { Value = 1.25 } } }, 1.082],
[new Mod[] { new OsuModNightcore { SpeedChange = { Value = 1.30 } } }, 1.128],
[new Mod[] { new OsuModNightcore { SpeedChange = { Value = 1.35 } } }, 1.128],
[new Mod[] { new OsuModNightcore { SpeedChange = { Value = 1.40 } } }, 1.174],
[new Mod[] { new OsuModNightcore { SpeedChange = { Value = 1.45 } } }, 1.174],
[new Mod[] { new OsuModNightcore { SpeedChange = { Value = 1.50 } } }, 1.230],
[new Mod[] { new OsuModNightcore { SpeedChange = { Value = 1.55 } } }, 1.230],
[new Mod[] { new OsuModNightcore { SpeedChange = { Value = 1.60 } } }, 1.266],
[new Mod[] { new OsuModNightcore { SpeedChange = { Value = 1.65 } } }, 1.266],
[new Mod[] { new OsuModNightcore { SpeedChange = { Value = 1.70 } } }, 1.312],
[new Mod[] { new OsuModNightcore { SpeedChange = { Value = 1.75 } } }, 1.312],
[new Mod[] { new OsuModNightcore { SpeedChange = { Value = 1.80 } } }, 1.358],
[new Mod[] { new OsuModNightcore { SpeedChange = { Value = 1.85 } } }, 1.358],
[new Mod[] { new OsuModNightcore { SpeedChange = { Value = 1.90 } } }, 1.404],
[new Mod[] { new OsuModNightcore { SpeedChange = { Value = 1.95 } } }, 1.404],
[new Mod[] { new OsuModNightcore { SpeedChange = { Value = 2.00 } } }, 1.450],
[new Mod[] { new OsuModHidden() }, 1.04],
[new Mod[] { new OsuModHidden { OnlyFadeApproachCircles = { Value = true } } }, 1.02],
[new Mod[] { new OsuModTraceable() }, 1.02],
[new Mod[] { new OsuModFlashlight() }, 1.2],
[new Mod[] { new OsuModFlashlight { SizeMultiplier = { Value = 0.5f } } }, 1.2],
[new Mod[] { new OsuModFlashlight { SizeMultiplier = { Value = 0.9f } } }, 1.2],
[new Mod[] { new OsuModFlashlight { SizeMultiplier = { Value = 1.1f } } }, 1.18],
[new Mod[] { new OsuModFlashlight { SizeMultiplier = { Value = 1.5f } } }, 1.1],
[new Mod[] { new OsuModFlashlight { SizeMultiplier = { Value = 1.9f } } }, 1.02],
[new Mod[] { new OsuModFlashlight { SizeMultiplier = { Value = 2f } } }, 1.02],
[new Mod[] { new OsuModFlashlight { ComboBasedSize = { Value = false } } }, 1.04],
[new Mod[] { new OsuModFlashlight { SizeMultiplier = { Value = 1.9f }, ComboBasedSize = { Value = false } } }, 1.004],
[new Mod[] { new OsuModBlinds() }, 1.24],
[new Mod[] { new OsuModStrictTracking() }, 1],
[new Mod[] { new OsuModAccuracyChallenge() }, 1],
#endregion
#region Conversion
[new Mod[] { new OsuModTargetPractice() }, 0.01],
[new Mod[] { new OsuModDifficultyAdjust() }, 1],
[new Mod[] { new OsuModClassic() }, 0.985],
[new Mod[] { new OsuModClassic { ClassicNoteLock = { Value = false } } }, 0.96],
[new Mod[] { new OsuModRandom() }, 0.7],
[new Mod[] { new OsuModMirror() }, 1],
[new Mod[] { new OsuModAlternate() }, 1],
[new Mod[] { new OsuModSingleTap() }, 1],
#endregion
#region Automation
[new Mod[] { new OsuModAutoplay() }, 1],
[new Mod[] { new OsuModCinema() }, 1],
[new Mod[] { new OsuModRelax() }, 0.1],
[new Mod[] { new OsuModAutopilot() }, 0.1],
[new Mod[] { new OsuModSpunOut() }, 0.95],
#endregion
#region Fun
[new Mod[] { new OsuModTransform() }, 1],
[new Mod[] { new OsuModWiggle() }, 1],
[new Mod[] { new OsuModSpinIn() }, 1],
[new Mod[] { new OsuModGrow() }, 1],
[new Mod[] { new OsuModDeflate() }, 1],
[new Mod[] { new OsuModDeflate { StartScale = { Value = 5 } } }, 0.94],
[new Mod[] { new ModWindUp() }, 0.8 * 1 + 0.2 * 1.230],
[new Mod[] { new ModWindUp { InitialRate = { Value = 0.7 }, FinalRate = { Value = 1.2 } } }, 0.8 * 0.48 + 0.2 * 1.082],
[new Mod[] { new ModWindUp { InitialRate = { Value = 0.7 }, FinalRate = { Value = 0.9 } } }, 0.8 * 0.48 + 0.2 * 0.76],
[new Mod[] { new ModWindUp { InitialRate = { Value = 1.1 }, FinalRate = { Value = 1.4 } } }, 0.8 * 1.036 + 0.2 * 1.174],
[new Mod[] { new ModWindDown() }, 0.8 * 0.55 + 0.2 * 1],
[new Mod[] { new ModWindDown { InitialRate = { Value = 1.2 }, FinalRate = { Value = 0.7 } } }, 0.8 * 0.48 + 0.2 * 1.082],
[new Mod[] { new ModWindDown { InitialRate = { Value = 0.9 }, FinalRate = { Value = 0.7 } } }, 0.8 * 0.48 + 0.2 * 0.76],
[new Mod[] { new ModWindDown { InitialRate = { Value = 1.4 }, FinalRate = { Value = 1.1 } } }, 0.8 * 1.036 + 0.2 * 1.174],
[new Mod[] { new OsuModBarrelRoll() }, 1],
[new Mod[] { new OsuModApproachDifferent() }, 0.7],
[new Mod[] { new OsuModMuted() }, 1],
[new Mod[] { new OsuModNoScope() }, 1],
[new Mod[] { new OsuModMagnetised() }, 0.4],
[new Mod[] { new OsuModMagnetised { AttractionStrength = { Value = 0.05f } } }, 0.67],
[new Mod[] { new OsuModMagnetised { AttractionStrength = { Value = 0.2f } } }, 0.58],
[new Mod[] { new OsuModMagnetised { AttractionStrength = { Value = 0.7f } } }, 0.28],
[new Mod[] { new OsuModMagnetised { AttractionStrength = { Value = 1 } } }, 0.1],
[new Mod[] { new OsuModRepel() }, 1],
[new Mod[] { new ModAdaptiveSpeed() }, 0.1],
[new Mod[] { new OsuModFreezeFrame() }, 1],
[new Mod[] { new OsuModBubbles() }, 1],
[new Mod[] { new OsuModSynesthesia() }, 0.99],
[new Mod[] { new OsuModDepth() }, 1],
[new Mod[] { new OsuModBloom() }, 1],
#endregion
#region System
[new Mod[] { new OsuModTouchDevice() }, 1],
[new Mod[] { new ModScoreV2() }, 1],
#endregion
#region Combinations
[new Mod[] { new OsuModHidden(), new OsuModHardRock() }, 1.04 * 1.09],
[new Mod[] { new OsuModHidden(), new OsuModWiggle() }, 1.02],
[new Mod[] { new OsuModHidden(), new OsuModGrow() }, 1.02],
[new Mod[] { new OsuModHidden(), new OsuModDeflate() }, 1.02],
[new Mod[] { new OsuModHidden(), new OsuModDeflate { StartScale = { Value = 4 } } }, 1.02 * 0.96],
[new Mod[] { new OsuModHidden(), new OsuModRepel() }, 1.02],
[new Mod[] { new OsuModHidden { OnlyFadeApproachCircles = { Value = true } }, new OsuModRepel() }, 1],
[new Mod[] { new OsuModHidden(), new OsuModDepth() }, 1.02],
[new Mod[] { new OsuModHidden(), new OsuModDepth(), new OsuModHardRock() }, 1.02 * 1.09],
[new Mod[] { new OsuModHidden(), new OsuModBlinds() }, 1.24],
[new Mod[] { new OsuModHidden(), new OsuModBlinds(), new OsuModHardRock() }, 1.24 * 1.09],
[new Mod[] { new OsuModTraceable(), new OsuModBlinds() }, 1.24],
[new Mod[] { new OsuModTraceable(), new OsuModBlinds(), new OsuModHardRock() }, 1.24 * 1.09],
[new Mod[] { new OsuModFlashlight(), new OsuModFreezeFrame() }, 1.1],
#endregion
];
[TestCaseSource(nameof(test_cases))]
public void TestMultipliers(Mod[] mods, double expectedMultiplier)
=> TestModCombination(mods, expectedMultiplier);
[TestCase(null, null, null, null, 1)]
[TestCase(2.9f, null, null, null, 0.95)]
[TestCase(3.1f, null, null, null, 0.95)]
[TestCase(null, 3.9f, null, null, 0.95)]
[TestCase(null, 4.1f, null, null, 0.95)]
[TestCase(null, null, 4.9f, null, 0.95)]
[TestCase(null, null, 5.1f, null, 0.95)]
[TestCase(null, null, null, 5.9f, 0.95)]
[TestCase(null, null, null, 6.1f, 0.95)]
[TestCase(2.9f, 3.9f, null, null, 0.95 * 0.95)]
[TestCase(2.9f, 3.9f, 4.9f, null, 0.95 * 0.95 * 0.95)]
[TestCase(2.9f, 3.9f, 4.9f, 5.9f, 0.95 * 0.95 * 0.95 * 0.95)]
[TestCase(0.0f, null, null, null, 0.1)]
[TestCase(0.0f, 0.0f, 0.0f, 0.0f, 0.1)]
public void TestDifficultyAdjust(float? cs, float? ar, float? od, float? hp, double expectedMultiplier)
{
var difficulty = new BeatmapDifficulty
{
CircleSize = 3,
ApproachRate = 4,
OverallDifficulty = 5,
DrainRate = 6,
};
var mod = new OsuModDifficultyAdjust
{
CircleSize = { Value = cs },
ApproachRate = { Value = ar },
OverallDifficulty = { Value = od },
DrainRate = { Value = hp },
};
var calculator = Ruleset.CreateScoreMultiplierCalculator(new ScoreMultiplierContext(difficulty));
Assert.That(calculator.CalculateFor([mod]), Is.EqualTo(expectedMultiplier).Within(Precision.FLOAT_EPSILON));
}
[TestCase(30000001, 0.96)]
[TestCase(30000009, 0.96)]
[TestCase(30000016, 0.96)]
[TestCase(30000017, 0.985)]
[TestCase(null, 0.985)]
public void TestClassicMultiplierVersioning(int? totalScoreVersion, double expectedMultiplier)
{
var scoreInfo = totalScoreVersion != null ? new ScoreInfo { TotalScoreVersion = totalScoreVersion.Value } : null;
var calculator = Ruleset.CreateScoreMultiplierCalculator(new ScoreMultiplierContext(new BeatmapDifficulty(), scoreInfo));
Assert.That(calculator.CalculateFor([new OsuModClassic()]), Is.EqualTo(expectedMultiplier).Within(Precision.DOUBLE_EPSILON));
}
[Test]
public void VerySmallModMultiplier()
{
var mods = new Mod[]
{
new OsuModEasy { Retries = { Value = 10 } },
new OsuModNoFail(),
new OsuModHalfTime { SpeedChange = { Value = 0.5 } },
new OsuModTargetPractice(),
new OsuModClassic { ClassicNoteLock = { Value = false } },
new OsuModDeflate { StartScale = { Value = 25 } },
new OsuModMagnetised { AttractionStrength = { Value = 1 } },
new OsuModSynesthesia(),
};
var calculator = Ruleset.CreateScoreMultiplierCalculator(new ScoreMultiplierContext(new BeatmapDifficulty()));
Assert.That(calculator.CalculateFor(mods), Is.GreaterThan(0));
}
}
}
@@ -1 +1 @@
{"Mappings":[{"StartTime":76911.0,"Objects":[{"StartTime":76911.0,"EndTime":76911.0,"X":283.402,"Y":275.402}]},{"StartTime":77053.0,"Objects":[{"StartTime":77053.0,"EndTime":77053.0,"X":287.0515,"Y":279.0515}]},{"StartTime":77196.0,"Objects":[{"StartTime":77196.0,"EndTime":77196.0,"X":290.701019,"Y":282.701019}]},{"StartTime":77339.0,"Objects":[{"StartTime":77339.0,"EndTime":77339.0,"X":294.3505,"Y":286.3505}]},{"StartTime":77497.0,"Objects":[{"StartTime":77497.0,"EndTime":77497.0,"X":298.0,"Y":290.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":77533.0,"EndTime":77533.0,"X":276.162567,"Y":293.0336,"StackOffset":{"X":0.0,"Y":0.0}}]}]}
{"Mappings":[{"StartTime":77497.0,"Objects":[{"StartTime":77497.0,"EndTime":77497.0,"X":298.0,"Y":290.0},{"StartTime":77533.0,"EndTime":77533.0,"X":276.162567,"Y":293.0336}]}]}
@@ -9,15 +9,10 @@ SliderMultiplier:2
SliderTickRate:1
[TimingPoints]
76911,285.7142857142857,4,1,0,100,1,8
77211,-100,4,3,50,70,0,0
77497,8.40402703648439,4,3,51,70,1,8
77497,NaN,4,3,51,70,0,8
77498,285.714285714286,4,3,51,70,1,0
[HitObjects]
298,290,76911,5,0,1:0:0:0:
298,290,77053,1,0,1:0:0:0:
298,290,77196,1,0,1:0:0:0:
298,290,77339,1,0,1:0:0:0:
298,290,77497,6,0,B|234:298|192:279|192:279|180:299|180:299|205:311|238:318|238:318|230:347|217:371|217:371|137:370|80:340|80:340|65:259|73:143|102:68|102:68|149:49|199:34|199:34|213:54|213:54|267:38|324:40|324:40|332:18|332:18|385:20|435:27|435:27|480:93|517:204|521:286|521:286|474:329|396:350|396:350|377:329|363:302|363:302|393:287|415:271|415:271|398:254|398:254|362:282|299:290,1,1723.66345596313,10|0,1:0|3:0,3:0:0:0:
@@ -792,7 +792,7 @@ namespace osu.Game.Rulesets.Osu.Tests
{
AddStep("export beatmap", () =>
{
var beatmapEncoder = new LegacyBeatmapEncoder(playableBeatmap, null);
var beatmapEncoder = new LegacyBeatmapEncoder(playableBeatmap, null, null);
using (var stream = File.Open(Path.Combine(exportLocation, $"{testCaseName}.osu"), FileMode.Create))
{
@@ -1,42 +0,0 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Osu.Difficulty.Preprocessing;
using osu.Game.Rulesets.Osu.Objects;
namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators.Aim
{
public static class AgilityEvaluator
{
private const double distance_cap = OsuDifficultyHitObject.NORMALISED_DIAMETER * 1.2; // 1.2 circles distance between centers
/// <summary>
/// Evaluates the difficulty of fast aiming
/// </summary>
public static double EvaluateDifficultyOf(DifficultyHitObject current)
{
if (current.BaseObject is Spinner)
return 0;
var osuCurrObj = (OsuDifficultyHitObject)current;
var osuPrevObj = current.Index > 0 ? (OsuDifficultyHitObject)current.Previous(0) : null;
double travelDistance = osuPrevObj?.LazyTravelDistance ?? 0;
double distance = travelDistance + osuCurrObj.LazyJumpDistance;
double distanceScaled = Math.Min(distance, distance_cap) / distance_cap;
double agilityDifficulty = distanceScaled * 1000 / osuCurrObj.AdjustedDeltaTime;
agilityDifficulty *= Math.Pow(osuCurrObj.SmallCircleBonus, 1.5);
agilityDifficulty *= highBpmBonus(osuCurrObj.AdjustedDeltaTime);
return agilityDifficulty;
}
private static double highBpmBonus(double ms) => 1 / (1 - Math.Pow(0.2, ms / 1000));
}
}
@@ -1,126 +0,0 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Difficulty.Utils;
using osu.Game.Rulesets.Osu.Difficulty.Preprocessing;
using osu.Game.Rulesets.Osu.Objects;
using osuTK;
namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators.Aim
{
public static class FlowAimEvaluator
{
private const double velocity_change_multiplier = 0.52;
/// <summary>
/// Evaluates difficulty of "flow aim" - aiming pattern where player doesn't stop their cursor on every object and instead "flows" through them.
/// </summary>
public static double EvaluateDifficultyOf(DifficultyHitObject current, bool withSliderTravelDistance)
{
if (current.BaseObject is Spinner || current.Index <= 1 || current.Previous(0).BaseObject is Spinner)
return 0;
var osuCurrObj = (OsuDifficultyHitObject)current;
var osuLastObj = (OsuDifficultyHitObject)current.Previous(0);
var osuLastLastObj = (OsuDifficultyHitObject)current.Previous(1);
double currDistance = withSliderTravelDistance ? osuCurrObj.LazyJumpDistance : osuCurrObj.JumpDistance;
double prevDistance = withSliderTravelDistance ? osuLastObj.LazyJumpDistance : osuLastObj.JumpDistance;
double currVelocity = currDistance / osuCurrObj.AdjustedDeltaTime;
if (osuLastObj.BaseObject is Slider && withSliderTravelDistance)
{
// If the last object is a slider, then we extend the travel velocity through the slider into the current object.
double sliderDistance = osuLastObj.LazyTravelDistance + osuCurrObj.LazyJumpDistance;
currVelocity = Math.Max(currVelocity, sliderDistance / osuCurrObj.AdjustedDeltaTime);
}
double prevVelocity = prevDistance / osuLastObj.AdjustedDeltaTime;
double flowDifficulty = currVelocity;
// Apply high circle size bonus to the base velocity.
// We use reduced CS bonus here because the bonus was made for an evaluator with a different d/t scaling
flowDifficulty *= Math.Sqrt(osuCurrObj.SmallCircleBonus);
// Rhythm changes are harder to flow
flowDifficulty *= 1 + Math.Min(0.25,
Math.Pow((Math.Max(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime) - Math.Min(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime)) / 50, 4));
if (osuCurrObj.Angle != null && osuLastObj.Angle != null)
{
double angleDifference = Math.Abs(osuCurrObj.Angle.Value - osuLastObj.Angle.Value);
double angleDifferenceAdjusted = Math.Sin(angleDifference / 2) * 180.0;
double angularVelocity = angleDifferenceAdjusted / (osuCurrObj.AdjustedDeltaTime * 0.1);
// Low angular velocity flow (angles are consistent) is easier to follow than erratic flow
flowDifficulty *= 0.8 + Math.Sqrt(angularVelocity / 270.0);
}
// If all three notes are overlapping - don't reward bonuses as you don't have to do additional movement
double overlappedNotesWeight = 1;
if (current.Index > 2)
{
double o1 = calculateOverlapFactor(osuCurrObj, osuLastObj);
double o2 = calculateOverlapFactor(osuCurrObj, osuLastLastObj);
double o3 = calculateOverlapFactor(osuLastObj, osuLastLastObj);
overlappedNotesWeight = 1 - o1 * o2 * o3;
}
if (osuCurrObj.Angle != null)
{
// Acute angles are also hard to flow
flowDifficulty += currVelocity *
SnapAimEvaluator.CalcAngleAcuteness(osuCurrObj.Angle.Value) *
overlappedNotesWeight;
}
if (Math.Max(prevVelocity, currVelocity) != 0)
{
if (withSliderTravelDistance)
{
currVelocity = currDistance / osuCurrObj.AdjustedDeltaTime;
}
// Scale with ratio of difference compared to 0.5 * max dist.
double distRatio = DifficultyCalculationUtils.Smoothstep(Math.Abs(prevVelocity - currVelocity) / Math.Max(prevVelocity, currVelocity), 0, 1);
// Reward for % distance up to 125 / strainTime for overlaps where velocity is still changing.
double overlapVelocityBuff = Math.Min(OsuDifficultyHitObject.NORMALISED_DIAMETER * 1.25 / Math.Min(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime),
Math.Abs(prevVelocity - currVelocity));
flowDifficulty += overlapVelocityBuff *
distRatio *
overlappedNotesWeight *
velocity_change_multiplier;
}
if (osuCurrObj.BaseObject is Slider && withSliderTravelDistance)
{
// Include slider velocity to make velocity more consistent with snap
flowDifficulty += osuCurrObj.TravelDistance / osuCurrObj.TravelTime;
}
// Final velocity is being raised to a power because flow difficulty scales harder with both high distance and time, and we want to account for that
flowDifficulty = Math.Pow(flowDifficulty, 1.45);
// Reduce difficulty for low spacing since spacing below radius is always to be flowed
return flowDifficulty * DifficultyCalculationUtils.Smootherstep(currDistance, 0, OsuDifficultyHitObject.NORMALISED_RADIUS);
}
private static double calculateOverlapFactor(OsuDifficultyHitObject first, OsuDifficultyHitObject second)
{
var firstBase = (OsuHitObject)first.BaseObject;
var secondBase = (OsuHitObject)second.BaseObject;
double objectRadius = firstBase.Radius;
double distance = Vector2.Distance(firstBase.StackedPosition, secondBase.StackedPosition);
return Math.Clamp(1 - Math.Pow(Math.Max(distance - objectRadius, 0) / objectRadius, 2), 0, 1);
}
}
}
@@ -1,220 +0,0 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Difficulty.Utils;
using osu.Game.Rulesets.Osu.Difficulty.Preprocessing;
using osu.Game.Rulesets.Osu.Objects;
namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators.Aim
{
public static class SnapAimEvaluator
{
private const double wide_angle_multiplier = 9.67;
private const double acute_angle_multiplier = 2.41;
private const double slider_multiplier = 1.5;
private const double velocity_change_multiplier = 0.9;
private const double wiggle_multiplier = 1.02; // WARNING: Increasing this multiplier beyond 1.02 reduces difficulty as distance increases. Refer to the desmos link above the wiggle bonus calculation
private const double maximum_repetition_nerf = 0.15;
private const double maximum_vector_influence = 0.5;
/// <summary>
/// Evaluates the difficulty of aiming the current object, based on:
/// <list type="bullet">
/// <item><description>cursor velocity to the current object,</description></item>
/// <item><description>angle difficulty,</description></item>
/// <item><description>sharp velocity increases,</description></item>
/// <item><description>and slider difficulty.</description></item>
/// </list>
/// </summary>
public static double EvaluateDifficultyOf(DifficultyHitObject current, bool withSliderTravelDistance)
{
if (current.BaseObject is Spinner || current.Index <= 1 || current.Previous(0).BaseObject is Spinner)
return 0;
var osuCurrObj = (OsuDifficultyHitObject)current;
var osuLastObj = (OsuDifficultyHitObject)current.Previous(0);
var osuLast2Obj = (OsuDifficultyHitObject)current.Previous(2);
const int radius = OsuDifficultyHitObject.NORMALISED_RADIUS;
const int diameter = OsuDifficultyHitObject.NORMALISED_DIAMETER;
// Calculate the velocity to the current hitobject, which starts with a base distance / time assuming the last object is a hitcircle.
double currDistance = withSliderTravelDistance ? osuCurrObj.LazyJumpDistance : osuCurrObj.JumpDistance;
double currVelocity = currDistance / osuCurrObj.AdjustedDeltaTime;
// But if the last object is a slider, then we extend the travel velocity through the slider into the current object.
if (osuLastObj.BaseObject is Slider && withSliderTravelDistance)
{
double sliderDistance = osuLastObj.LazyTravelDistance + osuCurrObj.LazyJumpDistance;
currVelocity = Math.Max(currVelocity, sliderDistance / osuCurrObj.AdjustedDeltaTime);
}
double prevDistance = withSliderTravelDistance ? osuLastObj.LazyJumpDistance : osuLastObj.JumpDistance;
double prevVelocity = prevDistance / osuLastObj.AdjustedDeltaTime;
double snapDifficulty = currVelocity; // Start difficulty with regular velocity.
// Penalize angle repetition.
snapDifficulty *= vectorAngleRepetition(osuCurrObj, osuLastObj);
if (osuCurrObj.Angle != null && osuLastObj.Angle != null)
{
double currAngle = osuCurrObj.Angle.Value;
double lastAngle = osuLastObj.Angle.Value;
// Rewarding angles, take the smaller velocity as base.
double velocityInfluence = Math.Min(currVelocity, prevVelocity);
double acuteAngleBonus = 0;
if (Math.Max(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime) < 1.25 * Math.Min(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime)) // If rhythms are the same.
{
acuteAngleBonus = CalcAngleAcuteness(currAngle);
// Penalize angle repetition. It is important to do it _before_ multiplying by anything because we compare raw acuteness here
acuteAngleBonus *= 0.08 + 0.92 * (1 - Math.Min(acuteAngleBonus, Math.Pow(CalcAngleAcuteness(lastAngle), 3)));
// Apply acute angle bonus for BPM above 300 1/2 and distance more than one diameter
acuteAngleBonus *= velocityInfluence * DifficultyCalculationUtils.Smootherstep(DifficultyCalculationUtils.MillisecondsToBPM(osuCurrObj.AdjustedDeltaTime, 2), 300, 400) *
DifficultyCalculationUtils.Smootherstep(currDistance, 0, diameter * 2);
}
double wideAngleBonus = calcAngleWideness(currAngle);
// Penalize angle repetition. It is important to do it _before_ multiplying by velocity because we compare raw wideness here
wideAngleBonus *= 0.25 + 0.75 * (1 - Math.Min(wideAngleBonus, Math.Pow(calcAngleWideness(lastAngle), 3)));
// Rescaling velocity for the wide angle bonus
const double wide_angle_time_scale = 1.45;
double wideAngleCurrVelocity = currDistance / Math.Pow(osuCurrObj.AdjustedDeltaTime, wide_angle_time_scale);
double wideAnglePrevVelocity = prevDistance / Math.Pow(osuLastObj.AdjustedDeltaTime, wide_angle_time_scale);
if (osuLastObj.BaseObject is Slider && withSliderTravelDistance)
{
double sliderDistance = osuLastObj.LazyTravelDistance + osuCurrObj.LazyJumpDistance;
wideAngleCurrVelocity = Math.Max(wideAngleCurrVelocity, sliderDistance / Math.Pow(osuCurrObj.AdjustedDeltaTime, wide_angle_time_scale));
}
wideAngleBonus *= Math.Min(wideAngleCurrVelocity, wideAnglePrevVelocity);
if (osuLast2Obj != null)
{
// If objects just go back and forth through a middle point - don't give as much wide bonus
// Use Previous(2) and Previous(0) because angles calculation is done prevprev-prev-curr, so any object's angle's center point is always the previous object
var lastBaseObject = (OsuHitObject)osuLastObj.BaseObject;
var last2BaseObject = (OsuHitObject)osuLast2Obj.BaseObject;
float distance = (last2BaseObject.StackedPosition - lastBaseObject.StackedPosition).Length;
if (distance < 1)
{
wideAngleBonus *= 1 - 0.55 * (1 - distance);
}
}
// Add in acute angle bonus or wide angle bonus, whichever is larger.
snapDifficulty += Math.Max(acuteAngleBonus * acute_angle_multiplier, wideAngleBonus * wide_angle_multiplier);
// Apply wiggle bonus for jumps that are [radius, 3*diameter] in distance, with < 110 angle
// https://www.desmos.com/calculator/dp0v0nvowc
double wiggleBonus = velocityInfluence
* DifficultyCalculationUtils.Smootherstep(currDistance, radius, diameter)
* Math.Pow(DifficultyCalculationUtils.ReverseLerp(currDistance, diameter * 3, diameter), 1.8)
* DifficultyCalculationUtils.Smootherstep(currAngle, double.DegreesToRadians(110), double.DegreesToRadians(60))
* DifficultyCalculationUtils.Smootherstep(prevDistance, radius, diameter)
* Math.Pow(DifficultyCalculationUtils.ReverseLerp(prevDistance, diameter * 3, diameter), 1.8)
* DifficultyCalculationUtils.Smootherstep(lastAngle, double.DegreesToRadians(110), double.DegreesToRadians(60));
snapDifficulty += wiggleBonus * wiggle_multiplier;
}
if (Math.Max(prevVelocity, currVelocity) != 0)
{
if (withSliderTravelDistance)
{
// We want to use just the object jump without slider velocity when awarding differences
currVelocity = currDistance / osuCurrObj.AdjustedDeltaTime;
}
// Scale with ratio of difference compared to 0.5 * max dist.
double distRatio = DifficultyCalculationUtils.Smoothstep(Math.Abs(prevVelocity - currVelocity) / Math.Max(prevVelocity, currVelocity), 0, 1);
// Reward for % distance up to 125 / strainTime for overlaps where velocity is still changing.
double overlapVelocityBuff = Math.Min(diameter * 1.25 / Math.Min(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime), Math.Abs(prevVelocity - currVelocity));
double velocityChangeBonus = overlapVelocityBuff * distRatio;
// Penalize for rhythm changes.
velocityChangeBonus *= Math.Pow(Math.Min(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime) / Math.Max(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime), 2);
snapDifficulty += velocityChangeBonus * velocity_change_multiplier;
}
// Reward sliders based on velocity.
if (osuCurrObj.BaseObject is Slider && withSliderTravelDistance)
{
double sliderBonus = osuCurrObj.TravelDistance / osuCurrObj.TravelTime;
snapDifficulty += (sliderBonus < 1 ? sliderBonus : Math.Pow(sliderBonus, 0.75)) * slider_multiplier;
}
// Apply high circle size bonus
snapDifficulty *= osuCurrObj.SmallCircleBonus;
snapDifficulty *= highBpmBonus(osuCurrObj.AdjustedDeltaTime);
return snapDifficulty;
}
private static double highBpmBonus(double ms) => 1 / (1 - Math.Pow(0.03, Math.Pow(ms / 1000, 0.65)));
private static double vectorAngleRepetition(OsuDifficultyHitObject current, OsuDifficultyHitObject previous)
{
if (current.Angle == null || previous.Angle == null)
return 1;
const double note_limit = 6;
double constantAngleCount = 0;
for (int index = 0; index < note_limit; index++)
{
var loopObj = (OsuDifficultyHitObject)current.Previous(index);
if (loopObj.IsNull())
break;
// Only consider vectors in the same jump section, stopping to change rhythm ruins momentum
if (Math.Max(current.AdjustedDeltaTime, loopObj.AdjustedDeltaTime) > 1.1 * Math.Min(current.AdjustedDeltaTime, loopObj.AdjustedDeltaTime))
break;
if (loopObj.NormalisedVectorAngle.IsNotNull() && current.NormalisedVectorAngle.IsNotNull())
{
double angleDifference = Math.Abs(current.NormalisedVectorAngle.Value - loopObj.NormalisedVectorAngle.Value);
// Refer to this desmos for tuning, constants need to be precise so that values stay within the range of 0 and 1.
// https://www.desmos.com/calculator/a8jesv5sv2
constantAngleCount += Math.Cos(8 * Math.Min(double.DegreesToRadians(11.25), angleDifference));
}
}
double vectorRepetition = Math.Pow(Math.Min(0.5 / constantAngleCount, 1), 2);
double stackFactor = DifficultyCalculationUtils.Smootherstep(current.LazyJumpDistance, 0, OsuDifficultyHitObject.NORMALISED_DIAMETER);
double currAngle = current.Angle.Value;
double lastAngle = previous.Angle.Value;
double angleDifferenceAdjusted = Math.Cos(2 * Math.Min(double.DegreesToRadians(45), Math.Abs(currAngle - lastAngle) * stackFactor));
double baseNerf = 1 - maximum_repetition_nerf * CalcAngleAcuteness(lastAngle) * angleDifferenceAdjusted;
return Math.Pow(baseNerf + (1 - baseNerf) * vectorRepetition * maximum_vector_influence * stackFactor, 2);
}
private static double calcAngleWideness(double angle) => DifficultyCalculationUtils.Smoothstep(angle, double.DegreesToRadians(40), double.DegreesToRadians(140));
public static double CalcAngleAcuteness(double angle) => DifficultyCalculationUtils.Smoothstep(angle, double.DegreesToRadians(140), double.DegreesToRadians(40));
}
}
@@ -0,0 +1,172 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Difficulty.Utils;
using osu.Game.Rulesets.Osu.Difficulty.Preprocessing;
using osu.Game.Rulesets.Osu.Objects;
namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
{
public static class AimEvaluator
{
private const double wide_angle_multiplier = 1.5;
private const double acute_angle_multiplier = 2.55;
private const double slider_multiplier = 1.35;
private const double velocity_change_multiplier = 0.75;
private const double wiggle_multiplier = 1.02;
/// <summary>
/// Evaluates the difficulty of aiming the current object, based on:
/// <list type="bullet">
/// <item><description>cursor velocity to the current object,</description></item>
/// <item><description>angle difficulty,</description></item>
/// <item><description>sharp velocity increases,</description></item>
/// <item><description>and slider difficulty.</description></item>
/// </list>
/// </summary>
public static double EvaluateDifficultyOf(DifficultyHitObject current, bool withSliderTravelDistance)
{
if (current.BaseObject is Spinner || current.Index <= 1 || current.Previous(0).BaseObject is Spinner)
return 0;
var osuCurrObj = (OsuDifficultyHitObject)current;
var osuLastObj = (OsuDifficultyHitObject)current.Previous(0);
var osuLastLastObj = (OsuDifficultyHitObject)current.Previous(1);
var osuLast2Obj = (OsuDifficultyHitObject)current.Previous(2);
const int radius = OsuDifficultyHitObject.NORMALISED_RADIUS;
const int diameter = OsuDifficultyHitObject.NORMALISED_DIAMETER;
// Calculate the velocity to the current hitobject, which starts with a base distance / time assuming the last object is a hitcircle.
double currVelocity = osuCurrObj.LazyJumpDistance / osuCurrObj.AdjustedDeltaTime;
// But if the last object is a slider, then we extend the travel velocity through the slider into the current object.
if (osuLastObj.BaseObject is Slider && withSliderTravelDistance)
{
double travelVelocity = osuLastObj.TravelDistance / osuLastObj.TravelTime; // calculate the slider velocity from slider head to slider end.
double movementVelocity = osuCurrObj.MinimumJumpDistance / osuCurrObj.MinimumJumpTime; // calculate the movement velocity from slider end to current object
currVelocity = Math.Max(currVelocity, movementVelocity + travelVelocity); // take the larger total combined velocity.
}
// As above, do the same for the previous hitobject.
double prevVelocity = osuLastObj.LazyJumpDistance / osuLastObj.AdjustedDeltaTime;
if (osuLastLastObj.BaseObject is Slider && withSliderTravelDistance)
{
double travelVelocity = osuLastLastObj.TravelDistance / osuLastLastObj.TravelTime;
double movementVelocity = osuLastObj.MinimumJumpDistance / osuLastObj.MinimumJumpTime;
prevVelocity = Math.Max(prevVelocity, movementVelocity + travelVelocity);
}
double wideAngleBonus = 0;
double acuteAngleBonus = 0;
double sliderBonus = 0;
double velocityChangeBonus = 0;
double wiggleBonus = 0;
double aimStrain = currVelocity; // Start strain with regular velocity.
if (osuCurrObj.Angle != null && osuLastObj.Angle != null)
{
double currAngle = osuCurrObj.Angle.Value;
double lastAngle = osuLastObj.Angle.Value;
// Rewarding angles, take the smaller velocity as base.
double angleBonus = Math.Min(currVelocity, prevVelocity);
if (Math.Max(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime) < 1.25 * Math.Min(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime)) // If rhythms are the same.
{
acuteAngleBonus = calcAcuteAngleBonus(currAngle);
// Penalize angle repetition.
acuteAngleBonus *= 0.08 + 0.92 * (1 - Math.Min(acuteAngleBonus, Math.Pow(calcAcuteAngleBonus(lastAngle), 3)));
// Apply acute angle bonus for BPM above 300 1/2 and distance more than one diameter
acuteAngleBonus *= angleBonus *
DifficultyCalculationUtils.Smootherstep(DifficultyCalculationUtils.MillisecondsToBPM(osuCurrObj.AdjustedDeltaTime, 2), 300, 400) *
DifficultyCalculationUtils.Smootherstep(osuCurrObj.LazyJumpDistance, diameter, diameter * 2);
}
wideAngleBonus = calcWideAngleBonus(currAngle);
// Penalize angle repetition.
wideAngleBonus *= 1 - Math.Min(wideAngleBonus, Math.Pow(calcWideAngleBonus(lastAngle), 3));
// Apply full wide angle bonus for distance more than one diameter
wideAngleBonus *= angleBonus * DifficultyCalculationUtils.Smootherstep(osuCurrObj.LazyJumpDistance, 0, diameter);
// Apply wiggle bonus for jumps that are [radius, 3*diameter] in distance, with < 110 angle
// https://www.desmos.com/calculator/dp0v0nvowc
wiggleBonus = angleBonus
* DifficultyCalculationUtils.Smootherstep(osuCurrObj.LazyJumpDistance, radius, diameter)
* Math.Pow(DifficultyCalculationUtils.ReverseLerp(osuCurrObj.LazyJumpDistance, diameter * 3, diameter), 1.8)
* DifficultyCalculationUtils.Smootherstep(currAngle, double.DegreesToRadians(110), double.DegreesToRadians(60))
* DifficultyCalculationUtils.Smootherstep(osuLastObj.LazyJumpDistance, radius, diameter)
* Math.Pow(DifficultyCalculationUtils.ReverseLerp(osuLastObj.LazyJumpDistance, diameter * 3, diameter), 1.8)
* DifficultyCalculationUtils.Smootherstep(lastAngle, double.DegreesToRadians(110), double.DegreesToRadians(60));
if (osuLast2Obj != null)
{
// If objects just go back and forth through a middle point - don't give as much wide bonus
// Use Previous(2) and Previous(0) because angles calculation is done prevprev-prev-curr, so any object's angle's center point is always the previous object
var lastBaseObject = (OsuHitObject)osuLastObj.BaseObject;
var last2BaseObject = (OsuHitObject)osuLast2Obj.BaseObject;
float distance = (last2BaseObject.StackedPosition - lastBaseObject.StackedPosition).Length;
if (distance < 1)
{
wideAngleBonus *= 1 - 0.35 * (1 - distance);
}
}
}
if (Math.Max(prevVelocity, currVelocity) != 0)
{
// We want to use the average velocity over the whole object when awarding differences, not the individual jump and slider path velocities.
prevVelocity = (osuLastObj.LazyJumpDistance + osuLastLastObj.TravelDistance) / osuLastObj.AdjustedDeltaTime;
currVelocity = (osuCurrObj.LazyJumpDistance + osuLastObj.TravelDistance) / osuCurrObj.AdjustedDeltaTime;
// Scale with ratio of difference compared to 0.5 * max dist.
double distRatio = DifficultyCalculationUtils.Smoothstep(Math.Abs(prevVelocity - currVelocity) / Math.Max(prevVelocity, currVelocity), 0, 1);
// Reward for % distance up to 125 / strainTime for overlaps where velocity is still changing.
double overlapVelocityBuff = Math.Min(diameter * 1.25 / Math.Min(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime), Math.Abs(prevVelocity - currVelocity));
velocityChangeBonus = overlapVelocityBuff * distRatio;
// Penalize for rhythm changes.
velocityChangeBonus *= Math.Pow(Math.Min(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime) / Math.Max(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime), 2);
}
if (osuLastObj.BaseObject is Slider)
{
// Reward sliders based on velocity.
sliderBonus = osuLastObj.TravelDistance / osuLastObj.TravelTime;
}
aimStrain += wiggleBonus * wiggle_multiplier;
aimStrain += velocityChangeBonus * velocity_change_multiplier;
// Add in acute angle bonus or wide angle bonus, whichever is larger.
aimStrain += Math.Max(acuteAngleBonus * acute_angle_multiplier, wideAngleBonus * wide_angle_multiplier);
// Apply high circle size bonus
aimStrain *= osuCurrObj.SmallCircleBonus;
// Add in additional slider velocity bonus.
if (withSliderTravelDistance)
aimStrain += sliderBonus * slider_multiplier;
return aimStrain;
}
private static double calcWideAngleBonus(double angle) => DifficultyCalculationUtils.Smoothstep(angle, double.DegreesToRadians(40), double.DegreesToRadians(140));
private static double calcAcuteAngleBonus(double angle) => DifficultyCalculationUtils.Smoothstep(angle, double.DegreesToRadians(140), double.DegreesToRadians(40));
}
}
@@ -2,12 +2,8 @@
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Linq;
using osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Osu.Difficulty.Preprocessing;
using osu.Game.Rulesets.Osu.Mods;
using osu.Game.Rulesets.Osu.Objects;
namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
@@ -32,7 +28,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
/// <item><description>and whether the hidden mod is enabled.</description></item>
/// </list>
/// </summary>
public static double EvaluateDifficultyOf(DifficultyHitObject current, IReadOnlyList<Mod> mods)
public static double EvaluateDifficultyOf(DifficultyHitObject current, bool hidden)
{
if (current.BaseObject is Spinner)
return 0;
@@ -44,7 +40,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
double smallDistNerf = 1.0;
double cumulativeStrainTime = 0.0;
double flashlightDifficulty = 0.0;
double result = 0.0;
OsuDifficultyHitObject lastObj = osuCurrent;
@@ -70,9 +66,9 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
double stackNerf = Math.Min(1.0, (currentObj.LazyJumpDistance / scalingFactor) / 25.0);
// Bonus based on how visible the object is.
double opacityBonus = 1.0 + max_opacity_bonus * (1.0 - osuCurrent.OpacityAt(currentHitObject.StartTime, mods.OfType<OsuModHidden>().Any(m => !m.OnlyFadeApproachCircles.Value)));
double opacityBonus = 1.0 + max_opacity_bonus * (1.0 - osuCurrent.OpacityAt(currentHitObject.StartTime, hidden));
flashlightDifficulty += stackNerf * opacityBonus * scalingFactor * jumpDistance / cumulativeStrainTime;
result += stackNerf * opacityBonus * scalingFactor * jumpDistance / cumulativeStrainTime;
if (currentObj.Angle != null && osuCurrent.Angle != null)
{
@@ -85,14 +81,14 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
lastObj = currentObj;
}
flashlightDifficulty = Math.Pow(smallDistNerf * flashlightDifficulty, 2.0);
result = Math.Pow(smallDistNerf * result, 2.0);
// Additional bonus for Hidden due to there being no approach circles.
if (mods.OfType<OsuModHidden>().Any())
flashlightDifficulty *= 1.0 + hidden_bonus;
if (hidden)
result *= 1.0 + hidden_bonus;
// Nerf patterns with repeated angles.
flashlightDifficulty *= min_angle_multiplier + (1.0 - min_angle_multiplier) / (angleRepeatCount + 1.0);
result *= min_angle_multiplier + (1.0 - min_angle_multiplier) / (angleRepeatCount + 1.0);
double sliderBonus = 0.0;
@@ -112,9 +108,9 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
sliderBonus /= (osuSlider.RepeatCount + 1);
}
flashlightDifficulty += sliderBonus * slider_multiplier;
result += sliderBonus * slider_multiplier;
return flashlightDifficulty;
return result;
}
}
}
@@ -1,272 +0,0 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Difficulty.Utils;
using osu.Game.Rulesets.Osu.Difficulty.Preprocessing;
using osu.Game.Rulesets.Osu.Objects;
namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
{
public static class ReadingEvaluator
{
private const double reading_window_size = 3000; // 3 seconds
private const double distance_influence_threshold = OsuDifficultyHitObject.NORMALISED_DIAMETER * 1.5; // 1.5 circles distance between centers
private const double hidden_multiplier = 0.28;
private const double density_multiplier = 2.4;
private const double density_difficulty_base = 2.5;
private const double preempt_balancing_factor = 140000;
private const double preempt_starting_point = 500; // AR 9.66 in milliseconds
private const double minimum_angle_relevancy_time = 2000; // 2 seconds
private const double maximum_angle_relevancy_time = 200;
public static double EvaluateDifficultyOf(DifficultyHitObject current, bool hidden)
{
if (current.BaseObject is Spinner || current.Index == 0)
return 0;
var currObj = (OsuDifficultyHitObject)current;
var nextObj = (OsuDifficultyHitObject)current.Next(0);
double velocity = Math.Max(1, currObj.LazyJumpDistance / currObj.AdjustedDeltaTime); // Only allow velocity to buff
double currentVisibleObjectDensity = retrieveCurrentVisibleObjectDensity(currObj);
double pastObjectDifficultyInfluence = getPastObjectDifficultyInfluence(currObj);
double constantAngleNerfFactor = getConstantAngleNerfFactor(currObj);
double noteDensityDifficulty = calculateDensityDifficulty(nextObj, velocity, constantAngleNerfFactor, pastObjectDifficultyInfluence, currentVisibleObjectDensity);
double hiddenDifficulty = hidden
? calculateHiddenDifficulty(currObj, pastObjectDifficultyInfluence, currentVisibleObjectDensity, velocity, constantAngleNerfFactor)
: 0;
double preemptDifficulty = calculatePreemptDifficulty(velocity, constantAngleNerfFactor, currObj.Preempt);
double readingDifficulty = DifficultyCalculationUtils.Norm(1.5, preemptDifficulty, hiddenDifficulty, noteDensityDifficulty);
// Having less time to process information is harder
readingDifficulty *= highBpmBonus(currObj.AdjustedDeltaTime);
return readingDifficulty;
}
/// <summary>
/// Calculates the density difficulty of the current object and how hard it is to aim it because of it based on:
/// <list type="bullet">
/// <item><description>cursor velocity to the current object,</description></item>
/// <item><description>how many times the current object's angle was repeated,</description></item>
/// <item><description>density of objects visible when the current object appears,</description></item>
/// <item><description>density of objects visible when the current object needs to be clicked,</description></item>
/// /// </list>
/// </summary>
private static double calculateDensityDifficulty(OsuDifficultyHitObject? nextObj, double velocity, double constantAngleNerfFactor,
double pastObjectDifficultyInfluence, double currentVisibleObjectDensity)
{
// Consider future densities too because it can make the path the cursor takes less clear
double futureObjectDifficultyInfluence = Math.Sqrt(currentVisibleObjectDensity);
if (nextObj != null)
{
// Reduce difficulty if movement to next object is small
futureObjectDifficultyInfluence *= DifficultyCalculationUtils.Smootherstep(nextObj.LazyJumpDistance, 15, distance_influence_threshold);
}
// Value higher note densities exponentially
double noteDensityDifficulty = Math.Pow(pastObjectDifficultyInfluence + futureObjectDifficultyInfluence, 1.7) * 0.4 * constantAngleNerfFactor * velocity;
// Award only denser than average maps.
noteDensityDifficulty = Math.Max(0, noteDensityDifficulty - density_difficulty_base);
// Apply a soft cap to general density reading to account for partial memorization
noteDensityDifficulty = Math.Pow(noteDensityDifficulty, 0.45) * density_multiplier;
return noteDensityDifficulty;
}
/// <summary>
/// Calculates the difficulty of aiming the current object when the approach rate is very high based on:
/// <list type="bullet">
/// <item><description>cursor velocity to the current object,</description></item>
/// <item><description>how many times the current object's angle was repeated,</description></item>
/// <item><description>how many milliseconds elapse between the approach circle appearing and touching the inner circle</description></item>
/// </list>
/// </summary>
private static double calculatePreemptDifficulty(double velocity, double constantAngleNerfFactor, double preempt)
{
// Arbitrary curve for the base value preempt difficulty should have as approach rate increases.
// https://www.desmos.com/calculator/c175335a71
double preemptDifficulty = Math.Pow((preempt_starting_point - preempt + Math.Abs(preempt - preempt_starting_point)) / 2, 2.5) / preempt_balancing_factor;
preemptDifficulty *= constantAngleNerfFactor * velocity;
return preemptDifficulty;
}
/// <summary>
/// Calculates the difficulty of aiming the current object when the hidden mod is active based on:
/// <list type="bullet">
/// <item><description>cursor velocity to the current object,</description></item>
/// <item><description>time the current object spends invisible,</description></item>
/// <item><description>density of objects visible when the current object appears,</description></item>
/// <item><description>density of objects visible when the current object needs to be clicked,</description></item>
/// <item><description>how many times the current object's angle was repeated,</description></item>
/// <item><description>if the current object is perfectly stacked to the previous one</description></item>
/// </list>
/// </summary>
private static double calculateHiddenDifficulty(OsuDifficultyHitObject currObj, double pastObjectDifficultyInfluence, double currentVisibleObjectDensity, double velocity,
double constantAngleNerfFactor)
{
// Higher preempt means that time spent invisible is higher too, we want to reward that
double preemptFactor = Math.Pow(currObj.Preempt, 2.2) * 0.01;
// Account for both past and current densities
double densityFactor = Math.Pow(currentVisibleObjectDensity + pastObjectDifficultyInfluence, 3.3) * 3;
double hiddenDifficulty = (preemptFactor + densityFactor) * constantAngleNerfFactor * velocity * 0.01;
// Apply a soft cap to general HD reading to account for partial memorization
hiddenDifficulty = Math.Pow(hiddenDifficulty, 0.4) * hidden_multiplier;
var previousObj = (OsuDifficultyHitObject)currObj.Previous(0);
// Buff perfect stacks only if current note is completely invisible at the time you click the previous note.
if (currObj.LazyJumpDistance == 0 && currObj.OpacityAt(previousObj.BaseObject.StartTime, true) == 0 && previousObj.StartTime > currObj.StartTime - currObj.Preempt)
hiddenDifficulty += hidden_multiplier * 2500 / Math.Pow(currObj.AdjustedDeltaTime, 1.5); // Perfect stacks are harder the less time between notes
return hiddenDifficulty;
}
private static double getPastObjectDifficultyInfluence(OsuDifficultyHitObject currObj)
{
double pastObjectDifficultyInfluence = 0;
foreach (var loopObj in retrievePastVisibleObjects(currObj))
{
double loopDifficulty = currObj.OpacityAt(loopObj.BaseObject.StartTime, false);
// When aiming an object small distances mean previous objects may be cheesed, so it doesn't matter whether they were arranged confusingly.
loopDifficulty *= DifficultyCalculationUtils.Smootherstep(loopObj.LazyJumpDistance, 15, distance_influence_threshold);
// Account less for objects close to the max reading window
double timeBetweenCurrAndLoopObj = currObj.StartTime - loopObj.StartTime;
double timeNerfFactor = getTimeNerfFactor(timeBetweenCurrAndLoopObj);
loopDifficulty *= timeNerfFactor;
pastObjectDifficultyInfluence += loopDifficulty;
}
return pastObjectDifficultyInfluence;
}
// Returns a list of objects that are visible on screen at the point in time the current object becomes visible.
private static IEnumerable<OsuDifficultyHitObject> retrievePastVisibleObjects(OsuDifficultyHitObject current)
{
for (int i = 0; i < current.Index; i++)
{
OsuDifficultyHitObject hitObject = (OsuDifficultyHitObject)current.Previous(i);
if (hitObject.IsNull() ||
current.StartTime - hitObject.StartTime > reading_window_size ||
hitObject.StartTime < current.StartTime - current.Preempt) // Current object not visible at the time object needs to be clicked
break;
yield return hitObject;
}
}
// Returns the density of objects visible at the point in time the current object needs to be clicked capped by the reading window.
private static double retrieveCurrentVisibleObjectDensity(OsuDifficultyHitObject current)
{
double visibleObjectCount = 0;
OsuDifficultyHitObject? hitObject = (OsuDifficultyHitObject)current.Next(0);
while (hitObject != null)
{
if (hitObject.StartTime - current.StartTime > reading_window_size ||
current.StartTime < hitObject.StartTime - hitObject.Preempt) // Object not visible at the time current object needs to be clicked.
break;
double timeBetweenCurrAndLoopObj = hitObject.StartTime - current.StartTime;
double timeNerfFactor = getTimeNerfFactor(timeBetweenCurrAndLoopObj);
visibleObjectCount += hitObject.OpacityAt(current.BaseObject.StartTime, false) * timeNerfFactor;
hitObject = (OsuDifficultyHitObject?)hitObject.Next(0);
}
return visibleObjectCount;
}
// Returns a factor of how often the current object's angle has been repeated in a certain time frame.
// It does this by checking the difference in angle between current and past objects and sums them based on a range of similarity.
// https://www.desmos.com/calculator/eb057a4822
private static double getConstantAngleNerfFactor(OsuDifficultyHitObject current)
{
double constantAngleCount = 0;
int index = 0;
double currentTimeGap = 0;
OsuDifficultyHitObject loopObjPrev0 = current;
OsuDifficultyHitObject? loopObjPrev1 = null;
OsuDifficultyHitObject? loopObjPrev2 = null;
while (currentTimeGap < minimum_angle_relevancy_time)
{
var loopObj = (OsuDifficultyHitObject)current.Previous(index);
if (loopObj.IsNull())
break;
// Account less for objects that are close to the time limit.
double longIntervalFactor = 1 - DifficultyCalculationUtils.ReverseLerp(loopObj.AdjustedDeltaTime, maximum_angle_relevancy_time, minimum_angle_relevancy_time);
if (loopObj.Angle.IsNotNull() && current.Angle.IsNotNull())
{
double angleDifference = Math.Abs(current.Angle.Value - loopObj.Angle.Value);
double angleDifferenceAlternating = Math.PI;
if (loopObjPrev0.Angle != null && loopObjPrev1?.Angle != null && loopObjPrev2?.Angle != null)
{
angleDifferenceAlternating = Math.Abs(loopObjPrev1.Angle.Value - loopObj.Angle.Value);
angleDifferenceAlternating += Math.Abs(loopObjPrev2.Angle.Value - loopObjPrev0.Angle.Value);
double weight = 1.0;
// Be sure that one of the angles is very sharp, when other is wide
weight *= DifficultyCalculationUtils.ReverseLerp(Math.Min(loopObj.Angle.Value, loopObjPrev0.Angle.Value) * 180 / Math.PI, 20, 5);
weight *= DifficultyCalculationUtils.ReverseLerp(Math.Max(loopObj.Angle.Value, loopObjPrev0.Angle.Value) * 180 / Math.PI, 60, 120);
// Lerp between max angle difference and rescaled alternating difference, with more harsh scaling compared to normal difference
angleDifferenceAlternating = double.Lerp(Math.PI, 0.1 * angleDifferenceAlternating, weight);
}
double stackFactor = DifficultyCalculationUtils.Smootherstep(loopObj.LazyJumpDistance, 0, OsuDifficultyHitObject.NORMALISED_RADIUS);
constantAngleCount += Math.Cos(3 * Math.Min(double.DegreesToRadians(30), Math.Min(angleDifference, angleDifferenceAlternating) * stackFactor)) * longIntervalFactor;
}
currentTimeGap = current.StartTime - loopObj.StartTime;
index++;
loopObjPrev2 = loopObjPrev1;
loopObjPrev1 = loopObjPrev0;
loopObjPrev0 = loopObj;
}
return Math.Clamp(2 / constantAngleCount, 0.2, 1);
}
// Returns a nerfing factor for when objects are very distant in time, affecting reading less.
private static double getTimeNerfFactor(double deltaTime)
{
return Math.Clamp(2 - deltaTime / (reading_window_size / 2), 0, 1);
}
private static double highBpmBonus(double ms) => 1 / (1 - Math.Pow(0.8, ms / 1000));
}
}
@@ -8,16 +8,15 @@ using osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Difficulty.Utils;
using osu.Game.Rulesets.Osu.Difficulty.Preprocessing;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Scoring;
namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators.Speed
namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
{
public static class RhythmEvaluator
{
private const int history_time_max = 5 * 1000; // 5 seconds
private const int history_objects_max = 32;
private const double rhythm_overall_multiplier = 0.95;
private const double rhythm_ratio_multiplier = 26.0;
private const double rhythm_overall_multiplier = 1.0;
private const double rhythm_ratio_multiplier = 15.0;
/// <summary>
/// Calculates a rhythm multiplier for the difficulty of the tap associated with historic data of the current <see cref="OsuDifficultyHitObject"/>.
@@ -27,9 +26,11 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators.Speed
if (current.BaseObject is Spinner)
return 0;
var currentOsuObject = (OsuDifficultyHitObject)current;
double rhythmComplexitySum = 0;
double deltaDifferenceEpsilon = ((OsuDifficultyHitObject)current).HitWindow(HitResult.Great) * 0.3;
double deltaDifferenceEpsilon = ((OsuDifficultyHitObject)current).HitWindowGreat * 0.3;
var island = new Island(deltaDifferenceEpsilon);
var previousIsland = new Island(deltaDifferenceEpsilon);
@@ -56,8 +57,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators.Speed
for (int i = rhythmStart; i > 0; i--)
{
OsuDifficultyHitObject currObj = (OsuDifficultyHitObject)current.Previous(i - 1);
if (currObj.BaseObject is Spinner)
continue;
// scales note 0 to 1 from history to now
double timeDecay = (history_time_max - (current.StartTime - currObj.StartTime)) / history_time_max;
@@ -65,56 +64,44 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators.Speed
double currHistoricalDecay = Math.Min(noteDecay, timeDecay); // either we're limited by time or limited by object count.
// Use custom cap value to ensure that at this point delta time is actually zero
// Use custom cap value to ensure that that at this point delta time is actually zero
double currDelta = Math.Max(currObj.DeltaTime, 1e-7);
double prevDelta = Math.Max(prevObj.DeltaTime, 1e-7);
double lastDelta = Math.Max(lastObj.DeltaTime, 1e-7);
// Make sure to always have the current island initialised - if we don't do it here it will only initialise on the next rhythm change
if (island.Delta == int.MaxValue)
island = new Island((int)currDelta, deltaDifferenceEpsilon);
// calculate how much current delta difference deserves a rhythm bonus
// this function is meant to reduce rhythm bonus for deltas that are multiples of each other (i.e 100 and 200)
double deltaDifference = Math.Max(prevDelta, currDelta) / Math.Min(prevDelta, currDelta);
// Take only the fractional part of the value since we're only interested in punishing multiples
double deltaDifferenceFraction = deltaDifference - Math.Truncate(deltaDifference);
double currRatio = 1.0 + rhythm_ratio_multiplier * Math.Min(0.5, DifficultyCalculationUtils.SmoothstepBellCurve(deltaDifferenceFraction));
// reduce ratio bonus if delta difference is too big
double differenceMultiplier = Math.Clamp(2.0 - deltaDifference / 8.0, 0.0, 1.0);
double windowPenalty = Math.Min(1, Math.Max(0, Math.Abs(prevDelta - currDelta) - deltaDifferenceEpsilon) / deltaDifferenceEpsilon);
double effectiveRatio = getEffectiveRatio(deltaDifference) * windowPenalty * differenceMultiplier;
// if previous object is a slider it might be easier to tap since you don't have to do a whole tapping motion
// while a full deltatime might end up some weird ratio the "unpress->tap" motion might be simple
// for example a slider-circle-circle pattern should be evaluated as a regular triple and not as a single->double
if (prevObj.BaseObject is Slider)
{
double sliderLazyEndDelta = currObj.MinimumJumpTime;
double sliderLazyDeltaDifference = Math.Max(sliderLazyEndDelta, currDelta) / Math.Min(sliderLazyEndDelta, currDelta);
double sliderRealEndDelta = currObj.LastObjectEndDeltaTime;
double sliderRealDeltaDifference = Math.Max(sliderRealEndDelta, currDelta) / Math.Min(sliderRealEndDelta, currDelta);
double sliderEffectiveRatio = Math.Min(getEffectiveRatio(sliderLazyDeltaDifference), getEffectiveRatio(sliderRealDeltaDifference));
effectiveRatio = Math.Min(sliderEffectiveRatio, effectiveRatio);
}
bool isSpeedingUp = prevDelta > currDelta + deltaDifferenceEpsilon;
if (Math.Abs(prevDelta - currDelta) < deltaDifferenceEpsilon)
{
// island is still progressing
island.AddDelta((int)currDelta);
}
double effectiveRatio = windowPenalty * currRatio * differenceMultiplier;
if (firstDeltaSwitch)
{
if (Math.Abs(prevDelta - currDelta) > deltaDifferenceEpsilon)
if (Math.Abs(prevDelta - currDelta) < deltaDifferenceEpsilon)
{
// island is still progressing
island.AddDelta((int)currDelta);
}
else
{
// bpm change is into slider, this is easy acc window
if (currObj.BaseObject is Slider)
effectiveRatio *= 0.5;
effectiveRatio *= 0.125;
// bpm change was from a slider, this is easier typically than circle -> circle
// unintentional side effect is that bursts with kicksliders at the ends might have lower difficulty than bursts without sliders
if (prevObj.BaseObject is Slider)
effectiveRatio *= 0.3;
// repeated island polarity (2 -> 4, 3 -> 5)
if (island.IsSimilarPolarity(previousIsland))
@@ -129,9 +116,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators.Speed
if (previousIsland.DeltaCount == island.DeltaCount)
effectiveRatio *= 0.5;
if (isSpeedingUp)
effectiveRatio *= 0.65;
var islandCount = islandCounts.FirstOrDefault(x => x.Island.Equals(island));
if (islandCount != default)
@@ -150,10 +134,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators.Speed
}
else
{
if (island.DeltaCount > 0)
{
islandCounts.Add((island, 1));
}
islandCounts.Add((island, 1));
}
// scale down the difficulty if the object is doubletappable
@@ -195,18 +176,10 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators.Speed
prevObj = currObj;
}
// If the current island is long we don't want the sum to have as big of an effect
rhythmComplexitySum *= DifficultyCalculationUtils.ReverseLerp(island.DeltaCount, 22, 3);
double rhythmDifficulty = Math.Sqrt(4 + rhythmComplexitySum * rhythm_overall_multiplier) / 2.0; // produces multiplier that can be applied to strain. range [1, infinity) (not really though)
rhythmDifficulty *= 1 - currentOsuObject.GetDoubletapness((OsuDifficultyHitObject)current.Next(0));
return Math.Sqrt(4 + rhythmComplexitySum * rhythm_overall_multiplier) / 2.0; // produces multiplier that can be applied to strain. range [1, infinity) (not really though);
}
private static double getEffectiveRatio(double deltaDifference)
{
// Take only the fractional part of the value since we're only interested in punishing multiples
double deltaDifferenceFraction = deltaDifference - Math.Truncate(deltaDifference);
return 1.0 + rhythm_ratio_multiplier * Math.Min(0.5, DifficultyCalculationUtils.SmoothstepBellCurve(deltaDifferenceFraction));
return rhythmDifficulty;
}
private class Island : IEquatable<Island>
@@ -238,12 +211,9 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators.Speed
public bool IsSimilarPolarity(Island other)
{
// single delta islands shouldn't be compared
if (DeltaCount <= 1 || other.DeltaCount <= 1)
return false;
return Math.Abs(Delta - other.Delta) < deltaDifferenceEpsilon &&
DeltaCount % 2 == other.DeltaCount % 2;
// TODO: consider islands to be of similar polarity only if they're having the same average delta (we don't want to consider 3 singletaps similar to a triple)
// naively adding delta check here breaks _a lot_ of maps because of the flawed ratio calculation
return DeltaCount % 2 == other.DeltaCount % 2;
}
public bool Equals(Island? other)
@@ -2,39 +2,47 @@
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Linq;
using osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Difficulty.Utils;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Osu.Difficulty.Preprocessing;
using osu.Game.Rulesets.Osu.Mods;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Scoring;
namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators.Speed
namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
{
public static class SpeedEvaluator
{
private const double single_spacing_threshold = OsuDifficultyHitObject.NORMALISED_DIAMETER * 1.25; // 1.25 circles distance between centers
private const double min_speed_bonus = 200; // 200 BPM 1/4th
private const double speed_balancing_factor = 40;
private const double distance_multiplier = 0.8;
/// <summary>
/// Evaluates the difficulty of tapping the current object, based on:
/// <list type="bullet">
/// <item><description>time between pressing the previous and current object,</description></item>
/// <item><description>distance between those objects,</description></item>
/// <item><description>and how easily they can be cheesed.</description></item>
/// </list>
/// </summary>
public static double EvaluateDifficultyOf(DifficultyHitObject current)
public static double EvaluateDifficultyOf(DifficultyHitObject current, IReadOnlyList<Mod> mods)
{
if (current.BaseObject is Spinner)
return 0;
// derive strainTime for calculation
var osuCurrObj = (OsuDifficultyHitObject)current;
var osuPrevObj = current.Index > 0 ? (OsuDifficultyHitObject)current.Previous(0) : null;
double strainTime = osuCurrObj.AdjustedDeltaTime;
double doubletapness = 1.0 - osuCurrObj.GetDoubletapness((OsuDifficultyHitObject?)osuCurrObj.Next(0));
// Cap deltatime to the OD 300 hitwindow.
// 0.93 is derived from making sure 260bpm OD8 streams aren't nerfed harshly, whilst 0.92 limits the effect of the cap.
strainTime /= Math.Clamp((strainTime / osuCurrObj.HitWindow(HitResult.Great)) / 0.93, 0.92, 1);
strainTime /= Math.Clamp((strainTime / osuCurrObj.HitWindowGreat) / 0.93, 0.92, 1);
// speedBonus will be 0.0 for BPM < 200
double speedBonus = 0.0;
@@ -43,15 +51,26 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators.Speed
if (DifficultyCalculationUtils.MillisecondsToBPM(strainTime) > min_speed_bonus)
speedBonus = 0.75 * Math.Pow((DifficultyCalculationUtils.BPMToMilliseconds(min_speed_bonus) - strainTime) / speed_balancing_factor, 2);
// Base difficulty with all bonuses
double speedDifficulty = (1 + speedBonus) * 1000 / strainTime;
double travelDistance = osuPrevObj?.TravelDistance ?? 0;
double distance = travelDistance + osuCurrObj.MinimumJumpDistance;
speedDifficulty *= highBpmBonus(osuCurrObj.AdjustedDeltaTime);
// Cap distance at single_spacing_threshold
distance = Math.Min(distance, single_spacing_threshold);
// Max distance bonus is 1 * `distance_multiplier` at single_spacing_threshold
double distanceBonus = Math.Pow(distance / single_spacing_threshold, 3.95) * distance_multiplier;
// Apply reduced small circle bonus because flow aim difficulty on small circles doesn't scale as hard as jumps
distanceBonus *= Math.Sqrt(osuCurrObj.SmallCircleBonus);
if (mods.OfType<OsuModAutopilot>().Any())
distanceBonus = 0;
// Base difficulty with all bonuses
double difficulty = (1 + speedBonus + distanceBonus) * 1000 / strainTime;
// Apply penalty if there's doubletappable doubles
return speedDifficulty * doubletapness;
return difficulty * doubletapness;
}
private static double highBpmBonus(double ms) => 1 / (1 - Math.Pow(0.3, ms / 1000));
}
}
@@ -45,12 +45,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty
[JsonProperty("flashlight_difficulty")]
public double FlashlightDifficulty { get; set; }
/// <summary>
/// The difficulty corresponding to the reading skill.
/// </summary>
[JsonProperty("reading_difficulty")]
public double ReadingDifficulty { get; set; }
/// <summary>
/// Describes how much of <see cref="AimDifficulty"/> is contributed to by hitcircles or sliders.
/// A value closer to 1.0 indicates most of <see cref="AimDifficulty"/> is contributed by hitcircles.
@@ -81,9 +75,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty
[JsonProperty("speed_difficult_strain_count")]
public double SpeedDifficultStrainCount { get; set; }
[JsonProperty("reading_difficult_note_count")]
public double ReadingDifficultNoteCount { get; set; }
[JsonProperty("nested_score_per_object")]
public double NestedScorePerObject { get; set; }
@@ -93,6 +84,11 @@ namespace osu.Game.Rulesets.Osu.Difficulty
[JsonProperty("maximum_legacy_combo_score")]
public double MaximumLegacyComboScore { get; set; }
/// <summary>
/// The beatmap's drain rate. This doesn't scale with rate-adjusting mods.
/// </summary>
public double DrainRate { get; set; }
/// <summary>
/// The number of hitcircles in the beatmap.
/// </summary>
@@ -115,7 +111,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty
yield return (ATTRIB_ID_AIM, AimDifficulty);
yield return (ATTRIB_ID_SPEED, SpeedDifficulty);
yield return (ATTRIB_ID_READING, ReadingDifficulty);
yield return (ATTRIB_ID_DIFFICULTY, StarRating);
if (ShouldSerializeFlashlightDifficulty())
@@ -132,7 +127,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty
yield return (ATTRIB_ID_NESTED_SCORE_PER_OBJECT, NestedScorePerObject);
yield return (ATTRIB_ID_LEGACY_SCORE_BASE_MULTIPLIER, LegacyScoreBaseMultiplier);
yield return (ATTRIB_ID_MAXIMUM_LEGACY_COMBO_SCORE, MaximumLegacyComboScore);
yield return (ATTRIB_ID_READING_DIFFICULT_NOTE_COUNT, ReadingDifficultNoteCount);
}
public override void FromDatabaseAttributes(IReadOnlyDictionary<int, double> values, IBeatmapOnlineInfo onlineInfo)
@@ -141,7 +135,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty
AimDifficulty = values[ATTRIB_ID_AIM];
SpeedDifficulty = values[ATTRIB_ID_SPEED];
ReadingDifficulty = values[ATTRIB_ID_READING];
StarRating = values[ATTRIB_ID_DIFFICULTY];
FlashlightDifficulty = values.GetValueOrDefault(ATTRIB_ID_FLASHLIGHT);
SliderFactor = values[ATTRIB_ID_SLIDER_FACTOR];
@@ -154,7 +147,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty
NestedScorePerObject = values[ATTRIB_ID_NESTED_SCORE_PER_OBJECT];
LegacyScoreBaseMultiplier = values[ATTRIB_ID_LEGACY_SCORE_BASE_MULTIPLIER];
MaximumLegacyComboScore = values[ATTRIB_ID_MAXIMUM_LEGACY_COMBO_SCORE];
ReadingDifficultNoteCount = values[ATTRIB_ID_READING_DIFFICULT_NOTE_COUNT];
DrainRate = onlineInfo.DrainRate;
HitCircleCount = onlineInfo.CircleCount;
SliderCount = onlineInfo.SliderCount;
SpinnerCount = onlineInfo.SpinnerCount;
@@ -8,19 +8,21 @@ using osu.Game.Beatmaps;
using osu.Game.Rulesets.Difficulty;
using osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Difficulty.Skills;
using osu.Game.Rulesets.Difficulty.Utils;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Osu.Difficulty.Preprocessing;
using osu.Game.Rulesets.Osu.Difficulty.Skills;
using osu.Game.Rulesets.Osu.Difficulty.Utils;
using osu.Game.Rulesets.Osu.Mods;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Utils;
using osu.Game.Rulesets.Osu.Scoring;
using osu.Game.Rulesets.Scoring;
namespace osu.Game.Rulesets.Osu.Difficulty
{
public class OsuDifficultyCalculator : DifficultyCalculator
{
private const double star_rating_multiplier = 0.0265;
public override int Version => 20251020;
public OsuDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap)
@@ -28,7 +30,23 @@ namespace osu.Game.Rulesets.Osu.Difficulty
{
}
protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills)
public static double CalculateRateAdjustedApproachRate(double approachRate, double clockRate)
{
double preempt = IBeatmapDifficultyInfo.DifficultyRange(approachRate, OsuHitObject.PREEMPT_MAX, OsuHitObject.PREEMPT_MID, OsuHitObject.PREEMPT_MIN) / clockRate;
return IBeatmapDifficultyInfo.InverseDifficultyRange(preempt, OsuHitObject.PREEMPT_MAX, OsuHitObject.PREEMPT_MID, OsuHitObject.PREEMPT_MIN);
}
public static double CalculateRateAdjustedOverallDifficulty(double overallDifficulty, double clockRate)
{
HitWindows hitWindows = new OsuHitWindows();
hitWindows.SetDifficulty(overallDifficulty);
double hitWindowGreat = hitWindows.WindowFor(HitResult.Great) / clockRate;
return (79.5 - hitWindowGreat) / 6;
}
protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate)
{
if (beatmap.HitObjects.Count == 0)
return new OsuDifficultyAttributes { Mods = mods };
@@ -37,61 +55,66 @@ namespace osu.Game.Rulesets.Osu.Difficulty
var aimWithoutSliders = skills.OfType<Aim>().Single(a => !a.IncludeSliders);
var speed = skills.OfType<Speed>().Single();
var flashlight = skills.OfType<Flashlight>().SingleOrDefault();
var reading = skills.OfType<Reading>().Single();
double aimDifficultyValue = aim.DifficultyValue();
double aimNoSlidersDifficultyValue = aimWithoutSliders.DifficultyValue();
double speedDifficultyValue = speed.DifficultyValue();
double readingDifficultyValue = reading.DifficultyValue();
double aimDifficultStrainCount = aim.CountTopWeightedStrains(aimDifficultyValue);
double speedDifficultStrainCount = speed.CountTopWeightedObjectDifficulties(speedDifficultyValue);
double readingDifficultNoteCount = reading.CountTopWeightedObjectDifficulties(readingDifficultyValue);
double speedNotes = speed.RelevantNoteCount();
double aimNoSlidersTopWeightedSliderCount = aimWithoutSliders.CountTopWeightedSliders(aimNoSlidersDifficultyValue);
double aimNoSlidersDifficultStrainCount = aimWithoutSliders.CountTopWeightedStrains(aimNoSlidersDifficultyValue);
double aimDifficultStrainCount = aim.CountTopWeightedStrains();
double speedDifficultStrainCount = speed.CountTopWeightedStrains();
double aimNoSlidersTopWeightedSliderCount = aimWithoutSliders.CountTopWeightedSliders();
double aimNoSlidersDifficultStrainCount = aimWithoutSliders.CountTopWeightedStrains();
double aimTopWeightedSliderFactor = aimNoSlidersTopWeightedSliderCount / Math.Max(1, aimNoSlidersDifficultStrainCount - aimNoSlidersTopWeightedSliderCount);
double speedTopWeightedSliderCount = speed.CountTopWeightedSliders(speedDifficultyValue);
double speedTopWeightedSliderCount = speed.CountTopWeightedSliders();
double speedTopWeightedSliderFactor = speedTopWeightedSliderCount / Math.Max(1, speedDifficultStrainCount - speedTopWeightedSliderCount);
double difficultSliders = aim.GetDifficultSliders();
double approachRate = CalculateRateAdjustedApproachRate(beatmap.Difficulty.ApproachRate, clockRate);
double overallDifficulty = CalculateRateAdjustedOverallDifficulty(beatmap.Difficulty.OverallDifficulty, clockRate);
int hitCircleCount = beatmap.HitObjects.Count(h => h is HitCircle);
int sliderCount = beatmap.HitObjects.Count(h => h is Slider);
int spinnerCount = beatmap.HitObjects.Count(h => h is Spinner);
int totalHits = beatmap.HitObjects.Count;
double sliderFactor = aimDifficultyValue > 0
? calculateAimDifficultyRating(aimNoSlidersDifficultyValue) / calculateAimDifficultyRating(aimDifficultyValue)
: 1;
double drainRate = beatmap.Difficulty.DrainRate;
double aimRating = calculateAimDifficultyRating(aimDifficultyValue);
double speedRating = calculateDifficultyRating(speedDifficultyValue);
double readingRating = calculateDifficultyRating(readingDifficultyValue);
double aimDifficultyValue = aim.DifficultyValue();
double aimNoSlidersDifficultyValue = aimWithoutSliders.DifficultyValue();
double speedDifficultyValue = speed.DifficultyValue();
double mechanicalDifficultyRating = calculateMechanicalDifficultyRating(aimDifficultyValue, speedDifficultyValue);
double sliderFactor = aimDifficultyValue > 0 ? OsuRatingCalculator.CalculateDifficultyRating(aimNoSlidersDifficultyValue) / OsuRatingCalculator.CalculateDifficultyRating(aimDifficultyValue) : 1;
var osuRatingCalculator = new OsuRatingCalculator(mods, totalHits, approachRate, overallDifficulty, mechanicalDifficultyRating, sliderFactor);
double aimRating = osuRatingCalculator.ComputeAimRating(aimDifficultyValue);
double speedRating = osuRatingCalculator.ComputeSpeedRating(speedDifficultyValue);
double flashlightRating = 0.0;
if (flashlight is not null)
flashlightRating = calculateDifficultyRating(flashlight.DifficultyValue());
flashlightRating = osuRatingCalculator.ComputeFlashlightRating(flashlight.DifficultyValue());
double sliderNestedScorePerObject = LegacyScoreUtils.CalculateNestedScorePerObject(beatmap, totalHits);
double legacyScoreBaseMultiplier = LegacyScoreUtils.CalculateDifficultyPeppyStars(WorkingBeatmap.Beatmap);
double legacyScoreBaseMultiplier = LegacyScoreUtils.CalculateDifficultyPeppyStars(beatmap);
var simulator = new OsuLegacyScoreSimulator();
var scoreAttributes = simulator.Simulate(WorkingBeatmap, beatmap);
double baseAimPerformance = OsuPerformanceCalculator.DifficultyToPerformance(aimRating);
double baseSpeedPerformance = HarmonicSkill.DifficultyToPerformance(speedRating);
double baseReadingPerformance = HarmonicSkill.DifficultyToPerformance(readingRating);
double baseAimPerformance = OsuStrainSkill.DifficultyToPerformance(aimRating);
double baseSpeedPerformance = OsuStrainSkill.DifficultyToPerformance(speedRating);
double baseFlashlightPerformance = Flashlight.DifficultyToPerformance(flashlightRating);
double baseCognitionPerformance = SumCognitionDifficulty(baseReadingPerformance, baseFlashlightPerformance);
double basePerformance = DifficultyCalculationUtils.Norm(OsuPerformanceCalculator.PERFORMANCE_NORM_EXPONENT, baseAimPerformance, baseSpeedPerformance, baseCognitionPerformance);
double basePerformance =
Math.Pow(
Math.Pow(baseAimPerformance, 1.1) +
Math.Pow(baseSpeedPerformance, 1.1) +
Math.Pow(baseFlashlightPerformance, 1.1), 1.0 / 1.1
);
double starRating = calculateStarRating(basePerformance);
@@ -104,13 +127,12 @@ namespace osu.Game.Rulesets.Osu.Difficulty
SpeedDifficulty = speedRating,
SpeedNoteCount = speedNotes,
FlashlightDifficulty = flashlightRating,
ReadingDifficulty = readingRating,
SliderFactor = sliderFactor,
AimDifficultStrainCount = aimDifficultStrainCount,
SpeedDifficultStrainCount = speedDifficultStrainCount,
ReadingDifficultNoteCount = readingDifficultNoteCount,
AimTopWeightedSliderFactor = aimTopWeightedSliderFactor,
SpeedTopWeightedSliderFactor = speedTopWeightedSliderFactor,
DrainRate = drainRate,
MaxCombo = beatmap.GetMaxCombo(),
HitCircleCount = hitCircleCount,
SliderCount = sliderCount,
@@ -123,33 +145,28 @@ namespace osu.Game.Rulesets.Osu.Difficulty
return attributes;
}
public static double SumCognitionDifficulty(double reading, double flashlight)
private double calculateMechanicalDifficultyRating(double aimDifficultyValue, double speedDifficultyValue)
{
if (reading <= 0)
return flashlight;
double aimValue = OsuStrainSkill.DifficultyToPerformance(OsuRatingCalculator.CalculateDifficultyRating(aimDifficultyValue));
double speedValue = OsuStrainSkill.DifficultyToPerformance(OsuRatingCalculator.CalculateDifficultyRating(speedDifficultyValue));
if (flashlight <= 0)
return reading;
double totalValue = Math.Pow(Math.Pow(aimValue, 1.1) + Math.Pow(speedValue, 1.1), 1 / 1.1);
// Nerf flashlight value in cognition sum when reading is greater than flashlight
return DifficultyCalculationUtils.Norm(OsuPerformanceCalculator.PERFORMANCE_NORM_EXPONENT, reading, flashlight * Math.Clamp(flashlight / reading, 0.25, 1.0));
return calculateStarRating(totalValue);
}
private double calculateAimDifficultyRating(double difficultyValue) => Math.Pow(difficultyValue, 0.63) * 0.02275;
private double calculateDifficultyRating(double difficultyValue) => Math.Sqrt(difficultyValue) * 0.0675;
private double calculateStarRating(double basePerformance)
{
return Math.Cbrt(basePerformance * OsuPerformanceCalculator.PERFORMANCE_BASE_MULTIPLIER);
if (basePerformance <= 0.00001)
return 0;
return Math.Cbrt(OsuPerformanceCalculator.PERFORMANCE_BASE_MULTIPLIER) * star_rating_multiplier * (Math.Cbrt(100000 / Math.Pow(2, 1 / 1.1) * basePerformance) + 4);
}
protected override IEnumerable<DifficultyHitObject> CreateDifficultyHitObjects(IBeatmap beatmap, Mod[] mods)
protected override IEnumerable<DifficultyHitObject> CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate)
{
List<DifficultyHitObject> objects = new List<DifficultyHitObject>();
double clockRate = ModUtils.CalculateRateWithMods(mods);
// The first jump is formed by the first two hitobjects of the map.
// If the map has less than two OsuHitObjects, the enumerator will not return anything.
for (int i = 1; i < beatmap.HitObjects.Count; i++)
@@ -160,18 +177,17 @@ namespace osu.Game.Rulesets.Osu.Difficulty
return objects;
}
protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods)
protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate)
{
var skills = new List<Skill>
{
new Aim(mods, true),
new Aim(mods, false),
new Speed(mods),
new Reading(mods)
new Speed(mods)
};
if (mods.Any(h => h is OsuModFlashlight))
skills.Add(new Flashlight(mods, beatmap.HitObjects.Count));
skills.Add(new Flashlight(mods));
return skills.ToArray();
}
@@ -115,13 +115,9 @@ namespace osu.Game.Rulesets.Osu.Difficulty
double missCount = 0;
// If sliders in the map are hard - it's likely for player to drop sliderends
// If map has easy sliders - it's more likely for player to sliderbreak
double likelyMissedSliderendPortion = 0.04 + 0.06 * Math.Pow(Math.Min(attributes.AimTopWeightedSliderFactor, 1), 2);
// Consider that full combo is maximum combo minus dropped slider tails since they don't contribute to combo but also don't break it
// In classic scores we can't know the amount of dropped sliders so we estimate it
double fullComboThreshold = attributes.MaxCombo - Math.Min(4 + likelyMissedSliderendPortion * attributes.SliderCount, attributes.SliderCount);
// In classic scores we can't know the amount of dropped sliders so we estimate to 10% of all sliders on the map
double fullComboThreshold = attributes.MaxCombo - 0.1 * attributes.SliderCount;
if (score.MaxCombo < fullComboThreshold)
missCount = Math.Pow(fullComboThreshold / Math.Max(1.0, score.MaxCombo), 2.5);
@@ -21,9 +21,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty
[JsonProperty("flashlight")]
public double Flashlight { get; set; }
[JsonProperty("reading")]
public double Reading { get; set; }
[JsonProperty("effective_miss_count")]
public double EffectiveMissCount { get; set; }
@@ -51,7 +48,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty
yield return new PerformanceDisplayAttribute(nameof(Speed), "Speed", Speed);
yield return new PerformanceDisplayAttribute(nameof(Accuracy), "Accuracy", Accuracy);
yield return new PerformanceDisplayAttribute(nameof(Flashlight), "Flashlight Bonus", Flashlight);
yield return new PerformanceDisplayAttribute(nameof(Reading), "Reading", Reading);
}
}
}
@@ -5,15 +5,12 @@ using System;
using System.Collections.Generic;
using System.Linq;
using osu.Framework.Extensions.IEnumerableExtensions;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Difficulty;
using osu.Game.Rulesets.Difficulty.Skills;
using osu.Game.Rulesets.Difficulty.Utils;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Osu.Scoring;
using osu.Game.Rulesets.Difficulty;
using osu.Game.Rulesets.Osu.Difficulty.Skills;
using osu.Game.Rulesets.Osu.Mods;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Osu.Scoring;
using osu.Game.Rulesets.Scoring;
using osu.Game.Scoring;
using osu.Game.Utils;
@@ -22,8 +19,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty
{
public class OsuPerformanceCalculator : PerformanceCalculator
{
public const double PERFORMANCE_BASE_MULTIPLIER = 1.12; // This is being adjusted to keep the final pp value scaled around what it used to be when changing things.
public const double PERFORMANCE_NORM_EXPONENT = 1.1;
public const double PERFORMANCE_BASE_MULTIPLIER = 1.14; // This is being adjusted to keep the final pp value scaled around what it used to be when changing things.
private bool usingClassicSliderAccuracy;
private bool usingScoreV2;
@@ -54,18 +50,14 @@ namespace osu.Game.Rulesets.Osu.Difficulty
private double greatHitWindow;
private double okHitWindow;
private double mehHitWindow;
private double overallDifficulty;
private double approachRate;
private double drainRate;
private double? speedDeviation;
private double aimEstimatedSliderBreaks;
private double speedEstimatedSliderBreaks;
public static double DifficultyToPerformance(double difficulty) => 4.0 * Math.Pow(difficulty, 3.0);
public OsuPerformanceCalculator()
: base(new OsuRuleset())
{
@@ -101,14 +93,13 @@ namespace osu.Game.Rulesets.Osu.Difficulty
okHitWindow = hitWindows.WindowFor(HitResult.Ok) / clockRate;
mehHitWindow = hitWindows.WindowFor(HitResult.Meh) / clockRate;
approachRate = calculateRateAdjustedApproachRate(difficulty.ApproachRate, clockRate);
overallDifficulty = (79.5 - greatHitWindow) / 6;
drainRate = difficulty.DrainRate;
approachRate = OsuDifficultyCalculator.CalculateRateAdjustedApproachRate(difficulty.ApproachRate, clockRate);
overallDifficulty = OsuDifficultyCalculator.CalculateRateAdjustedOverallDifficulty(difficulty.OverallDifficulty, clockRate);
double comboBasedEstimatedMissCount = calculateComboBasedEstimatedMissCount(osuAttributes);
double? scoreBasedEstimatedMissCount = null;
if (usingClassicSliderAccuracy && !usingScoreV2 && score.LegacyTotalScore != null)
if (usingClassicSliderAccuracy && score.LegacyTotalScore != null)
{
var legacyScoreMissCalculator = new OsuLegacyScoreMissCalculator(score, osuAttributes);
scoreBasedEstimatedMissCount = legacyScoreMissCalculator.Calculate();
@@ -124,12 +115,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty
effectiveMissCount = Math.Max(countMiss, effectiveMissCount);
effectiveMissCount = Math.Min(totalHits, effectiveMissCount);
if (effectiveMissCount > 0)
{
aimEstimatedSliderBreaks = calculateEstimatedSliderBreaks(osuAttributes.AimTopWeightedSliderFactor, osuAttributes);
speedEstimatedSliderBreaks = calculateEstimatedSliderBreaks(osuAttributes.SpeedTopWeightedSliderFactor, osuAttributes);
}
double multiplier = PERFORMANCE_BASE_MULTIPLIER;
if (score.Mods.Any(m => m is OsuModNoFail))
@@ -155,12 +140,15 @@ namespace osu.Game.Rulesets.Osu.Difficulty
double aimValue = computeAimValue(score, osuAttributes);
double speedValue = computeSpeedValue(score, osuAttributes);
double accuracyValue = computeAccuracyValue(score, osuAttributes);
double readingValue = computeReadingValue(osuAttributes);
double flashlightValue = computeFlashlightValue(score, osuAttributes);
double cognitionValue = OsuDifficultyCalculator.SumCognitionDifficulty(readingValue, flashlightValue);
double totalValue = DifficultyCalculationUtils.Norm(PERFORMANCE_NORM_EXPONENT, aimValue, speedValue, accuracyValue, cognitionValue) * multiplier;
double totalValue =
Math.Pow(
Math.Pow(aimValue, 1.1) +
Math.Pow(speedValue, 1.1) +
Math.Pow(accuracyValue, 1.1) +
Math.Pow(flashlightValue, 1.1), 1.0 / 1.1
) * multiplier;
return new OsuPerformanceAttributes
{
@@ -168,7 +156,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty
Speed = speedValue,
Accuracy = accuracyValue,
Flashlight = flashlightValue,
Reading = readingValue,
EffectiveMissCount = effectiveMissCount,
ComboBasedEstimatedMissCount = comboBasedEstimatedMissCount,
ScoreBasedEstimatedMissCount = scoreBasedEstimatedMissCount,
@@ -207,14 +194,16 @@ namespace osu.Game.Rulesets.Osu.Difficulty
aimDifficulty *= sliderNerfFactor;
}
double aimValue = DifficultyToPerformance(aimDifficulty);
double aimValue = OsuStrainSkill.DifficultyToPerformance(aimDifficulty);
double lengthBonus = 0.95 + 0.35 * Math.Min(1.0, totalHits / 2000.0) +
double lengthBonus = 0.95 + 0.4 * Math.Min(1.0, totalHits / 2000.0) +
(totalHits > 2000 ? Math.Log10(totalHits / 2000.0) * 0.5 : 0.0);
aimValue *= lengthBonus;
if (effectiveMissCount > 0)
{
aimEstimatedSliderBreaks = calculateEstimatedSliderBreaks(attributes.AimTopWeightedSliderFactor, attributes);
double relevantMissCount = Math.Min(effectiveMissCount + aimEstimatedSliderBreaks, totalImperfectHits + countSliderTickMiss);
aimValue *= calculateMissPenalty(relevantMissCount, attributes.AimDifficultStrainCount);
@@ -222,10 +211,10 @@ namespace osu.Game.Rulesets.Osu.Difficulty
// TC bonuses are excluded when blinds is present as the increased visual difficulty is unimportant when notes cannot be seen.
if (score.Mods.Any(m => m is OsuModBlinds))
aimValue *= 1.3 + (totalHits * (0.0016 / (1 + 2 * effectiveMissCount)) * Math.Pow(accuracy, 16)) * (1 - 0.003 * drainRate * drainRate);
aimValue *= 1.3 + (totalHits * (0.0016 / (1 + 2 * effectiveMissCount)) * Math.Pow(accuracy, 16)) * (1 - 0.003 * attributes.DrainRate * attributes.DrainRate);
else if (score.Mods.Any(m => m is OsuModTraceable))
{
aimValue *= 1.0 + calculateTraceableBonus(attributes.SliderFactor);
aimValue *= 1.0 + OsuRatingCalculator.CalculateVisibilityBonus(score.Mods, approachRate, sliderFactor: attributes.SliderFactor);
}
aimValue *= accuracy;
@@ -238,33 +227,44 @@ namespace osu.Game.Rulesets.Osu.Difficulty
if (score.Mods.Any(h => h is OsuModRelax) || speedDeviation == null)
return 0.0;
double speedValue = HarmonicSkill.DifficultyToPerformance(attributes.SpeedDifficulty);
double speedValue = OsuStrainSkill.DifficultyToPerformance(attributes.SpeedDifficulty);
double lengthBonus = 0.95 + 0.4 * Math.Min(1.0, totalHits / 2000.0) +
(totalHits > 2000 ? Math.Log10(totalHits / 2000.0) * 0.5 : 0.0);
speedValue *= lengthBonus;
if (effectiveMissCount > 0)
{
speedEstimatedSliderBreaks = calculateEstimatedSliderBreaks(attributes.SpeedTopWeightedSliderFactor, attributes);
double relevantMissCount = Math.Min(effectiveMissCount + speedEstimatedSliderBreaks, totalImperfectHits + countSliderTickMiss);
speedValue *= calculateMissPenalty(relevantMissCount, attributes.SpeedDifficultStrainCount);
}
// TC bonuses are excluded when blinds is present as the increased visual difficulty is unimportant when notes cannot be seen.
if (score.Mods.Any(m => m is OsuModBlinds))
{
// Increasing the speed value by object count for Blinds isn't ideal, so the minimum buff is given.
speedValue *= 1.12;
}
else if (score.Mods.Any(m => m is OsuModTraceable))
{
speedValue *= 1.0 + OsuRatingCalculator.CalculateVisibilityBonus(score.Mods, approachRate);
}
double speedHighDeviationMultiplier = calculateSpeedHighDeviationNerf(attributes);
speedValue *= speedHighDeviationMultiplier;
// An effective hit window is created based on the speed SR. The higher the speed difficulty, the shorter the hit window.
// For example, a speed SR of 4.0 leads to an effective hit window of 20ms, which is OD 10.
double effectiveHitWindow = 20 * Math.Pow(4 / attributes.SpeedDifficulty, 0.35);
// Calculate accuracy assuming the worst case scenario
double relevantTotalDiff = Math.Max(0, totalHits - attributes.SpeedNoteCount);
double relevantCountGreat = Math.Max(0, countGreat - relevantTotalDiff);
double relevantCountOk = Math.Max(0, countOk - Math.Max(0, relevantTotalDiff - countGreat));
double relevantCountMeh = Math.Max(0, countMeh - Math.Max(0, relevantTotalDiff - countGreat - countOk));
double relevantAccuracy = attributes.SpeedNoteCount == 0 ? 0 : (relevantCountGreat * 6.0 + relevantCountOk * 2.0 + relevantCountMeh) / (attributes.SpeedNoteCount * 6.0);
// Find the proportion of 300s on speed notes assuming the hit window was the effective hit window.
double effectiveAccuracy = DifficultyCalculationUtils.Erf(effectiveHitWindow / (double)speedDeviation);
// Scale speed value by normalized accuracy.
speedValue *= Math.Pow(effectiveAccuracy, 2);
// Scale the speed value with accuracy and OD.
speedValue *= Math.Pow((accuracy + relevantAccuracy) / 2.0, (14.5 - overallDifficulty) / 2);
return speedValue;
}
@@ -294,19 +294,20 @@ namespace osu.Game.Rulesets.Osu.Difficulty
double accuracyValue = Math.Pow(1.52163, overallDifficulty) * Math.Pow(betterAccuracyPercentage, 24) * 2.83;
// Bonus for many hitcircles - it's harder to keep good accuracy up for longer.
accuracyValue *= amountHitObjectsWithAccuracy < 1000
? Math.Pow(amountHitObjectsWithAccuracy / 1000.0, 0.3)
: Math.Pow(amountHitObjectsWithAccuracy / 1000.0, 0.1);
accuracyValue *= Math.Min(1.15, Math.Pow(amountHitObjectsWithAccuracy / 1000.0, 0.3));
// Increasing the accuracy value by object count for Blinds isn't ideal, so the minimum buff is given.
if (score.Mods.Any(m => m is OsuModBlinds))
accuracyValue *= 1.14;
else if (score.Mods.Any(m => m is OsuModTraceable))
else if (score.Mods.Any(m => m is OsuModHidden || m is OsuModTraceable))
{
// Decrease bonus for AR > 10
accuracyValue *= 1 + 0.08 * DifficultyCalculationUtils.ReverseLerp(approachRate, 11.5, 10);
}
if (score.Mods.Any(m => m is OsuModFlashlight))
accuracyValue *= 1.02;
return accuracyValue;
}
@@ -329,19 +330,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty
return flashlightValue;
}
private double computeReadingValue(OsuDifficultyAttributes attributes)
{
double readingValue = HarmonicSkill.DifficultyToPerformance(attributes.ReadingDifficulty);
if (effectiveMissCount > 0)
readingValue *= calculateMissPenalty(effectiveMissCount + aimEstimatedSliderBreaks, attributes.ReadingDifficultNoteCount);
// Scale the reading value with accuracy _harshly_.
readingValue *= Math.Pow(accuracy, 3);
return readingValue;
}
private double calculateComboBasedEstimatedMissCount(OsuDifficultyAttributes attributes)
{
if (attributes.SliderCount <= 0)
@@ -351,13 +339,9 @@ namespace osu.Game.Rulesets.Osu.Difficulty
if (usingClassicSliderAccuracy)
{
// If sliders in the map are hard - it's likely for player to drop sliderends
// If map has easy sliders - it's more likely for player to sliderbreak
double likelyMissedSliderendPortion = 0.04 + 0.06 * Math.Pow(Math.Min(attributes.AimTopWeightedSliderFactor, 1), 2);
// Consider that full combo is maximum combo minus dropped slider tails since they don't contribute to combo but also don't break it
// In classic scores we can't know the amount of dropped sliders so we estimate it
double fullComboThreshold = attributes.MaxCombo - Math.Min(4 + likelyMissedSliderendPortion * attributes.SliderCount, attributes.SliderCount);
// In classic scores we can't know the amount of dropped sliders so we estimate to 10% of all sliders on the map
double fullComboThreshold = attributes.MaxCombo - 0.1 * attributes.SliderCount;
if (scoreMaxCombo < fullComboThreshold)
missCount = fullComboThreshold / Math.Max(1.0, scoreMaxCombo);
@@ -392,22 +376,19 @@ namespace osu.Game.Rulesets.Osu.Difficulty
private double calculateEstimatedSliderBreaks(double topWeightedSliderFactor, OsuDifficultyAttributes attributes)
{
int nonMissMistakes = countOk + countMeh;
if (!usingClassicSliderAccuracy || nonMissMistakes == 0)
if (!usingClassicSliderAccuracy || countOk == 0)
return 0;
double missedComboPercent = 1.0 - (double)scoreMaxCombo / attributes.MaxCombo;
double estimatedSliderBreaks = Math.Min(nonMissMistakes, effectiveMissCount * topWeightedSliderFactor);
double estimatedSliderBreaks = Math.Min(countOk, effectiveMissCount * topWeightedSliderFactor);
// Scores with more Oks and Mehs are more likely to have slider breaks.
// We add an arbitrary value to both sides of the division to make it more stable on extreme ends.
double nonMissMistakeAdjustment = (nonMissMistakes - estimatedSliderBreaks + 4.5) / (nonMissMistakes + 4);
// Scores with more Oks are more likely to have slider breaks.
double okAdjustment = ((countOk - estimatedSliderBreaks) + 0.5) / countOk;
// There is a low probability of extra slider breaks on effective miss counts close to 1, as score based calculations are good at indicating if only a single break occurred.
estimatedSliderBreaks *= DifficultyCalculationUtils.Smoothstep(effectiveMissCount, 1, 2);
return estimatedSliderBreaks * nonMissMistakeAdjustment * DifficultyCalculationUtils.Logistic(missedComboPercent, 0.33, 15);
return estimatedSliderBreaks * okAdjustment * DifficultyCalculationUtils.Logistic(missedComboPercent, 0.33, 15);
}
/// <summary>
@@ -489,7 +470,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty
if (speedDeviation == null)
return 0;
double speedValue = HarmonicSkill.DifficultyToPerformance(attributes.SpeedDifficulty);
double speedValue = OsuStrainSkill.DifficultyToPerformance(attributes.SpeedDifficulty);
// Decides a point where the PP value achieved compared to the speed deviation is assumed to be tapped improperly. Any PP above this point is considered "excess" speed difficulty.
// This is used to cause PP above the cutoff to scale logarithmically towards the original speed value thus nerfing the value.
@@ -508,42 +489,12 @@ namespace osu.Game.Rulesets.Osu.Difficulty
return adjustedSpeedValue / speedValue;
}
/// <summary>
/// Calculates a visibility bonus that is applicable to Traceable.
/// </summary>
private double calculateTraceableBonus(double sliderFactor = 1)
{
// We want to reward slider aim less, more so at lower AR
double highApproachRateSliderVisibilityFactor = 0.5 + (Math.Pow(sliderFactor, 6) / 2);
double lowApproachRateSliderVisibilityFactor = Math.Pow(sliderFactor, 6);
// Start from normal curve, rewarding lower AR up to AR7
double traceableBonus = 0.0275;
traceableBonus += 0.025 * (12.0 - Math.Max(approachRate, 7)) * highApproachRateSliderVisibilityFactor;
// For AR up to 0 - reduce reward for very low ARs when object is visible
if (approachRate < 7)
traceableBonus += 0.025 * (7.0 - Math.Max(approachRate, 0)) * lowApproachRateSliderVisibilityFactor;
// Starting from AR0 - cap values so they won't grow to infinity
if (approachRate < 0)
traceableBonus += 0.025 * (1 - Math.Pow(1.5, approachRate)) * lowApproachRateSliderVisibilityFactor;
return traceableBonus;
}
// Miss penalty assumes that a player will miss on the hardest parts of a map,
// so we use the amount of relatively difficult sections to adjust miss penalty
// to make it more punishing on maps with lower amount of hard sections.
private double calculateMissPenalty(double missCount, double difficultStrainCount) => 0.93 / (missCount / (4 * Math.Log(difficultStrainCount)) + 1);
private double calculateMissPenalty(double missCount, double difficultStrainCount) => 0.96 / ((missCount / (4 * Math.Pow(Math.Log(difficultStrainCount), 0.94))) + 1);
private double getComboScalingFactor(OsuDifficultyAttributes attributes) => attributes.MaxCombo <= 0 ? 1.0 : Math.Min(Math.Pow(scoreMaxCombo, 0.8) / Math.Pow(attributes.MaxCombo, 0.8), 1.0);
private double calculateRateAdjustedApproachRate(double approachRate, double clockRate)
{
double preempt = IBeatmapDifficultyInfo.DifficultyRange(approachRate, OsuHitObject.PREEMPT_MAX, OsuHitObject.PREEMPT_MID, OsuHitObject.PREEMPT_MIN) / clockRate;
return IBeatmapDifficultyInfo.InverseDifficultyRange(preempt, OsuHitObject.PREEMPT_MAX, OsuHitObject.PREEMPT_MID, OsuHitObject.PREEMPT_MIN);
}
private int totalHits => countGreat + countOk + countMeh + countMiss;
private int totalSuccessfulHits => countGreat + countOk + countMeh;
private int totalImperfectHits => countOk + countMeh + countMiss;
@@ -0,0 +1,213 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Linq;
using osu.Game.Rulesets.Difficulty.Utils;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Osu.Mods;
namespace osu.Game.Rulesets.Osu.Difficulty
{
public class OsuRatingCalculator
{
private const double difficulty_multiplier = 0.0675;
private readonly Mod[] mods;
private readonly int totalHits;
private readonly double approachRate;
private readonly double overallDifficulty;
private readonly double mechanicalDifficultyRating;
private readonly double sliderFactor;
public OsuRatingCalculator(Mod[] mods, int totalHits, double approachRate, double overallDifficulty, double mechanicalDifficultyRating, double sliderFactor)
{
this.mods = mods;
this.totalHits = totalHits;
this.approachRate = approachRate;
this.overallDifficulty = overallDifficulty;
this.mechanicalDifficultyRating = mechanicalDifficultyRating;
this.sliderFactor = sliderFactor;
}
public double ComputeAimRating(double aimDifficultyValue)
{
if (mods.Any(m => m is OsuModAutopilot))
return 0;
double aimRating = CalculateDifficultyRating(aimDifficultyValue);
if (mods.Any(m => m is OsuModTouchDevice))
aimRating = Math.Pow(aimRating, 0.8);
if (mods.Any(m => m is OsuModRelax))
aimRating *= 0.9;
if (mods.Any(m => m is OsuModMagnetised))
{
float magnetisedStrength = mods.OfType<OsuModMagnetised>().First().AttractionStrength.Value;
aimRating *= 1.0 - magnetisedStrength;
}
double ratingMultiplier = 1.0;
double approachRateLengthBonus = 0.95 + 0.4 * Math.Min(1.0, totalHits / 2000.0) +
(totalHits > 2000 ? Math.Log10(totalHits / 2000.0) * 0.5 : 0.0);
double approachRateFactor = 0.0;
if (approachRate > 10.33)
approachRateFactor = 0.3 * (approachRate - 10.33);
else if (approachRate < 8.0)
approachRateFactor = 0.05 * (8.0 - approachRate);
if (mods.Any(h => h is OsuModRelax))
approachRateFactor = 0.0;
ratingMultiplier += approachRateFactor * approachRateLengthBonus; // Buff for longer maps with high AR.
if (mods.Any(m => m is OsuModHidden))
{
double visibilityFactor = calculateAimVisibilityFactor(approachRate);
ratingMultiplier += CalculateVisibilityBonus(mods, approachRate, visibilityFactor, sliderFactor);
}
// It is important to consider accuracy difficulty when scaling with accuracy.
ratingMultiplier *= 0.98 + Math.Pow(Math.Max(0, overallDifficulty), 2) / 2500;
return aimRating * Math.Cbrt(ratingMultiplier);
}
public double ComputeSpeedRating(double speedDifficultyValue)
{
if (mods.Any(m => m is OsuModRelax))
return 0;
double speedRating = CalculateDifficultyRating(speedDifficultyValue);
if (mods.Any(m => m is OsuModAutopilot))
speedRating *= 0.5;
if (mods.Any(m => m is OsuModMagnetised))
{
// reduce speed rating because of the speed distance scaling, with maximum reduction being 0.7x
float magnetisedStrength = mods.OfType<OsuModMagnetised>().First().AttractionStrength.Value;
speedRating *= 1.0 - magnetisedStrength * 0.3;
}
double ratingMultiplier = 1.0;
double approachRateLengthBonus = 0.95 + 0.4 * Math.Min(1.0, totalHits / 2000.0) +
(totalHits > 2000 ? Math.Log10(totalHits / 2000.0) * 0.5 : 0.0);
double approachRateFactor = 0.0;
if (approachRate > 10.33)
approachRateFactor = 0.3 * (approachRate - 10.33);
if (mods.Any(m => m is OsuModAutopilot))
approachRateFactor = 0.0;
ratingMultiplier += approachRateFactor * approachRateLengthBonus; // Buff for longer maps with high AR.
if (mods.Any(m => m is OsuModHidden))
{
double visibilityFactor = calculateSpeedVisibilityFactor(approachRate);
ratingMultiplier += CalculateVisibilityBonus(mods, approachRate, visibilityFactor);
}
ratingMultiplier *= 0.95 + Math.Pow(Math.Max(0, overallDifficulty), 2) / 750;
return speedRating * Math.Cbrt(ratingMultiplier);
}
public double ComputeFlashlightRating(double flashlightDifficultyValue)
{
if (!mods.Any(m => m is OsuModFlashlight))
return 0;
double flashlightRating = CalculateDifficultyRating(flashlightDifficultyValue);
if (mods.Any(m => m is OsuModTouchDevice))
flashlightRating = Math.Pow(flashlightRating, 0.8);
if (mods.Any(m => m is OsuModRelax))
flashlightRating *= 0.7;
else if (mods.Any(m => m is OsuModAutopilot))
flashlightRating *= 0.4;
if (mods.Any(m => m is OsuModMagnetised))
{
float magnetisedStrength = mods.OfType<OsuModMagnetised>().First().AttractionStrength.Value;
flashlightRating *= 1.0 - magnetisedStrength;
}
if (mods.Any(m => m is OsuModDeflate))
{
float deflateInitialScale = mods.OfType<OsuModDeflate>().First().StartScale.Value;
flashlightRating *= Math.Clamp(DifficultyCalculationUtils.ReverseLerp(deflateInitialScale, 11, 1), 0.1, 1);
}
double ratingMultiplier = 1.0;
// Account for shorter maps having a higher ratio of 0 combo/100 combo flashlight radius.
ratingMultiplier *= 0.7 + 0.1 * Math.Min(1.0, totalHits / 200.0) +
(totalHits > 200 ? 0.2 * Math.Min(1.0, (totalHits - 200) / 200.0) : 0.0);
// It is important to consider accuracy difficulty when scaling with accuracy.
ratingMultiplier *= 0.98 + Math.Pow(Math.Max(0, overallDifficulty), 2) / 2500;
return flashlightRating * Math.Sqrt(ratingMultiplier);
}
private double calculateAimVisibilityFactor(double approachRate)
{
const double ar_factor_end_point = 11.5;
double mechanicalDifficultyFactor = DifficultyCalculationUtils.ReverseLerp(mechanicalDifficultyRating, 5, 10);
double arFactorStartingPoint = double.Lerp(9, 10.33, mechanicalDifficultyFactor);
return DifficultyCalculationUtils.ReverseLerp(approachRate, ar_factor_end_point, arFactorStartingPoint);
}
private double calculateSpeedVisibilityFactor(double approachRate)
{
const double ar_factor_end_point = 11.5;
double mechanicalDifficultyFactor = DifficultyCalculationUtils.ReverseLerp(mechanicalDifficultyRating, 5, 10);
double arFactorStartingPoint = double.Lerp(10, 10.33, mechanicalDifficultyFactor);
return DifficultyCalculationUtils.ReverseLerp(approachRate, ar_factor_end_point, arFactorStartingPoint);
}
/// <summary>
/// Calculates a visibility bonus that is applicable to Hidden and Traceable.
/// </summary>
public static double CalculateVisibilityBonus(Mod[] mods, double approachRate, double visibilityFactor = 1, double sliderFactor = 1)
{
// NOTE: TC's effect is only noticeable in performance calculations until lazer mods are accounted for server-side.
bool isAlwaysPartiallyVisible = mods.OfType<OsuModHidden>().Any(m => m.OnlyFadeApproachCircles.Value) || mods.OfType<OsuModTraceable>().Any();
// Start from normal curve, rewarding lower AR up to AR7
// TC forcefully requires a lower reading bonus for now as it's post-applied in PP which makes it multiplicative with the regular AR bonuses
// This means it has an advantage over HD, so we decrease the multiplier to compensate
// This should be removed once we're able to apply TC bonuses in SR (depends on real-time difficulty calculations being possible)
double readingBonus = (isAlwaysPartiallyVisible ? 0.025 : 0.04) * (12.0 - Math.Max(approachRate, 7));
readingBonus *= visibilityFactor;
// We want to reward slideraim on low AR less
double sliderVisibilityFactor = Math.Pow(sliderFactor, 3);
// For AR up to 0 - reduce reward for very low ARs when object is visible
if (approachRate < 7)
readingBonus += (isAlwaysPartiallyVisible ? 0.02 : 0.045) * (7.0 - Math.Max(approachRate, 0)) * sliderVisibilityFactor;
// Starting from AR0 - cap values so they won't grow to infinity
if (approachRate < 0)
readingBonus += (isAlwaysPartiallyVisible ? 0.01 : 0.1) * (1 - Math.Pow(1.5, approachRate)) * sliderVisibilityFactor;
return readingBonus;
}
public static double CalculateDifficultyRating(double difficultyValue) => Math.Sqrt(difficultyValue) * difficulty_multiplier;
}
}
@@ -5,7 +5,6 @@ using System;
using System.Collections.Generic;
using System.Linq;
using osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Difficulty.Utils;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Osu.Mods;
using osu.Game.Rulesets.Osu.Objects;
@@ -36,22 +35,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing
/// </summary>
public readonly double AdjustedDeltaTime;
/// <summary>
/// Amount of time elapsed between lastDifficultyObject's <see cref="DifficultyHitObject.EndTime"/> and <see cref="DifficultyHitObject.StartTime"/> capped to a minimum of <see cref="MIN_DELTA_TIME"/>ms.
/// </summary>
public double LastObjectEndDeltaTime { get; private set; }
/// <summary>
/// Time (in ms) between the object first appearing and the time it needs to be clicked.
/// <see cref="OsuHitObject.TimePreempt"/> adjusted by clock rate.
/// </summary>
public readonly double Preempt;
/// <summary>
/// Normalised distance from the start position of the previous <see cref="OsuDifficultyHitObject"/> to the start position of this <see cref="OsuDifficultyHitObject"/>.
/// </summary>
public double JumpDistance { get; private set; }
/// <summary>
/// Normalised distance from the "lazy" end position of the previous <see cref="OsuDifficultyHitObject"/> to the start position of this <see cref="OsuDifficultyHitObject"/>.
/// <para>
@@ -118,29 +101,15 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing
public double? Angle { get; private set; }
/// <summary>
/// Angle of the vector created between current and current-1
/// normalised to consider symmetrical vectors in any axis to be the same angle.
/// Retrieves the full hit window for a Great <see cref="HitResult"/>.
/// </summary>
public double? NormalisedVectorAngle { get; private set; }
public double HitWindowGreat { get; private set; }
/// <summary>
/// Selective bonus for maps with higher circle size.
/// </summary>
public double SmallCircleBonus { get; private set; }
/// <summary>
/// Object's immediate OverallDifficulty value calculated from the raw hitwindow.
/// </summary>
public double OverallDifficulty
{
get
{
double hitWindowGreat = RawHitWindow(HitResult.Great) / ClockRate;
return (79.5 - hitWindowGreat) / 6;
}
}
private readonly OsuDifficultyHitObject? lastLastDifficultyObject;
private readonly OsuDifficultyHitObject? lastDifficultyObject;
@@ -152,11 +121,17 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing
// Capped to 25ms to prevent difficulty calculation breaking from simultaneous objects.
AdjustedDeltaTime = Math.Max(DeltaTime, MIN_DELTA_TIME);
LastObjectEndDeltaTime = lastDifficultyObject != null ? Math.Max(StartTime - lastDifficultyObject.EndTime, MIN_DELTA_TIME) : AdjustedDeltaTime;
SmallCircleBonus = Math.Max(1.0, 1.0 + (30 - BaseObject.Radius) / 70);
SmallCircleBonus = Math.Max(1.0, 1.0 + (30 - BaseObject.Radius) / 40);
Preempt = BaseObject.TimePreempt / clockRate;
if (BaseObject is Slider sliderObject)
{
HitWindowGreat = 2 * sliderObject.HeadCircle.HitWindows.WindowFor(HitResult.Great) / clockRate;
}
else
{
HitWindowGreat = 2 * BaseObject.HitWindows.WindowFor(HitResult.Great) / clockRate;
}
computeSliderCursorPosition();
setDistances(clockRate);
@@ -173,9 +148,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing
}
double fadeInStartTime = BaseObject.StartTime - BaseObject.TimePreempt;
// Equal to `OsuHitObject.TimeFadeIn` minus any adjustments from the HD mod.
double fadeInDuration = 400 * Math.Min(1, BaseObject.TimePreempt / OsuHitObject.PREEMPT_MIN);
double fadeInDuration = BaseObject.TimeFadeIn;
if (hidden)
{
@@ -202,16 +175,10 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing
{
double currDeltaTime = Math.Max(1, DeltaTime);
double nextDeltaTime = Math.Max(1, osuNextObj.DeltaTime);
double deltaDifference = Math.Abs(nextDeltaTime - currDeltaTime);
double speedRatio = currDeltaTime / Math.Max(currDeltaTime, deltaDifference);
double windowRatio = Math.Pow(Math.Min(1, currDeltaTime / HitWindow(HitResult.Great)), 5);
// Can't doubletap if circles don't intersect
double distanceFactor = Math.Pow(DifficultyCalculationUtils.ReverseLerp(LazyJumpDistance, NORMALISED_DIAMETER, NORMALISED_RADIUS), 2);
return 1.0 - Math.Pow(speedRatio, distanceFactor * (1 - windowRatio));
double windowRatio = Math.Pow(Math.Min(1, currDeltaTime / HitWindowGreat), 2);
return 1.0 - Math.Pow(speedRatio, 1 - windowRatio);
}
return 0;
@@ -222,12 +189,10 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing
if (BaseObject is Slider currentSlider)
{
// Bonus for repeat sliders until a better per nested object strain system can be achieved.
TravelDistance = LazyTravelDistance * Math.Max(1, Math.Pow(currentSlider.RepeatCount, 0.3));
TravelDistance = LazyTravelDistance * Math.Pow(1 + currentSlider.RepeatCount / 2.5, 1.0 / 2.5);
TravelTime = Math.Max(LazyTravelTime / clockRate, MIN_DELTA_TIME);
}
MinimumJumpTime = AdjustedDeltaTime;
// We don't need to calculate either angle or distance when one of the last->curr objects is a spinner
if (BaseObject is Spinner || LastObject is Spinner)
return;
@@ -237,8 +202,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing
Vector2 lastCursorPosition = lastDifficultyObject != null ? getEndCursorPosition(lastDifficultyObject) : LastObject.StackedPosition;
JumpDistance = (LastObject.StackedPosition - BaseObject.StackedPosition).Length * scalingFactor;
LazyJumpDistance = (BaseObject.StackedPosition - lastCursorPosition).Length * scalingFactor;
LazyJumpDistance = (BaseObject.StackedPosition * scalingFactor - lastCursorPosition * scalingFactor).Length;
MinimumJumpTime = AdjustedDeltaTime;
MinimumJumpDistance = LazyJumpDistance;
if (LastObject is Slider lastSlider && lastDifficultyObject != null)
@@ -274,18 +239,15 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing
if (lastLastDifficultyObject != null && lastLastDifficultyObject.BaseObject is not Spinner)
{
if (lastDifficultyObject!.BaseObject is Slider prevSlider && lastDifficultyObject.TravelDistance > 0)
lastCursorPosition = prevSlider.HeadCircle.StackedPosition;
Vector2 lastLastCursorPosition = getEndCursorPosition(lastLastDifficultyObject);
double angle = calculateAngle(BaseObject.StackedPosition, lastCursorPosition, lastLastCursorPosition);
double sliderAngle = calculateSliderAngle(lastDifficultyObject!, lastLastCursorPosition);
Vector2 v1 = lastLastCursorPosition - LastObject.StackedPosition;
Vector2 v2 = BaseObject.StackedPosition - lastCursorPosition;
Vector2 v = BaseObject.StackedPosition - lastCursorPosition;
NormalisedVectorAngle = Math.Atan2(Math.Abs(v.Y), Math.Abs(v.X));
float dot = Vector2.Dot(v1, v2);
float det = v1.X * v2.Y - v1.Y * v2.X;
Angle = Math.Min(angle, sliderAngle);
Angle = Math.Abs(Math.Atan2(det, dot));
}
}
@@ -397,30 +359,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing
}
}
private double calculateSliderAngle(OsuDifficultyHitObject lastDifficultyObject, Vector2 lastLastCursorPosition)
{
Vector2 lastCursorPosition = getEndCursorPosition(lastDifficultyObject);
if (lastDifficultyObject.BaseObject is Slider prevSlider && lastDifficultyObject.TravelDistance > 0)
{
OsuHitObject secondLastNestedObject = (OsuHitObject)prevSlider.NestedHitObjects[^2];
lastLastCursorPosition = secondLastNestedObject.StackedPosition;
}
return calculateAngle(BaseObject.StackedPosition, lastCursorPosition, lastLastCursorPosition);
}
private double calculateAngle(Vector2 currentPosition, Vector2 lastPosition, Vector2 lastLastPosition)
{
Vector2 v1 = lastLastPosition - lastPosition;
Vector2 v2 = currentPosition - lastPosition;
float dot = Vector2.Dot(v1, v2);
float det = v1.X * v2.Y - v1.Y * v2.X;
return Math.Abs(Math.Atan2(det, dot));
}
private Vector2 getEndCursorPosition(OsuDifficultyHitObject difficultyHitObject)
{
return difficultyHitObject.LazyEndPosition ?? difficultyHitObject.BaseObject.StackedPosition;
+10 -194
View File
@@ -4,14 +4,10 @@
using System;
using System.Collections.Generic;
using System.Linq;
using osu.Framework.Utils;
using osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Difficulty.Skills;
using osu.Game.Rulesets.Difficulty.Utils;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Osu.Difficulty.Evaluators.Aim;
using osu.Game.Rulesets.Osu.Difficulty.Preprocessing;
using osu.Game.Rulesets.Osu.Mods;
using osu.Game.Rulesets.Osu.Difficulty.Evaluators;
using osu.Game.Rulesets.Osu.Difficulty.Utils;
using osu.Game.Rulesets.Osu.Objects;
namespace osu.Game.Rulesets.Osu.Difficulty.Skills
@@ -19,7 +15,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills
/// <summary>
/// Represents the skill required to correctly aim at every object in the map with a uniform CircleSize and normalized distances.
/// </summary>
public class Aim : VariableLengthStrainSkill
public class Aim : OsuStrainSkill
{
public readonly bool IncludeSliders;
@@ -31,39 +27,19 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills
private double currentStrain;
private double skillMultiplierSnap => 70.9;
private double skillMultiplierAgility => 2.35;
private double skillMultiplierFlow => 242.0;
private double skillMultiplierTotal => 1.12;
private double combinedSnapNormExponent => 1.2;
/// <summary>
/// The number of sections with the highest strains, which the peak strain reductions will apply to.
/// This is done in order to decrease their impact on the overall difficulty of the map for this skill.
/// </summary>
private int reducedSectionTime => 4000;
/// <summary>
/// The baseline multiplier applied to the section with the biggest strain.
/// </summary>
private double reducedStrainBaseline => 0.727;
private double skillMultiplier => 26;
private double strainDecayBase => 0.15;
private readonly List<double> sliderStrains = new List<double>();
private double strainDecay(double ms) => Math.Pow(0.2, ms / 1000);
private double strainDecay(double ms) => Math.Pow(strainDecayBase, ms / 1000);
protected override double CalculateInitialStrain(double time, DifficultyHitObject current) =>
currentStrain * strainDecay(time - current.Previous(0).StartTime);
protected override double CalculateInitialStrain(double time, DifficultyHitObject current) => currentStrain * strainDecay(time - current.Previous(0).StartTime);
protected override double StrainValueAt(DifficultyHitObject current)
{
if (Mods.Any(m => m is OsuModAutopilot))
return 0;
double decay = strainDecay(((OsuDifficultyHitObject)current).AdjustedDeltaTime);
currentStrain *= decay;
currentStrain += calculateAdjustedDifficulty(current) * (1 - decay);
currentStrain *= strainDecay(current.DeltaTime);
currentStrain += AimEvaluator.EvaluateDifficultyOf(current, IncludeSliders) * skillMultiplier;
if (current.BaseObject is Slider)
sliderStrains.Add(currentStrain);
@@ -71,75 +47,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills
return currentStrain;
}
private double calculateAdjustedDifficulty(DifficultyHitObject current)
{
double snapDifficulty = SnapAimEvaluator.EvaluateDifficultyOf(current, IncludeSliders) * skillMultiplierSnap;
double agilityDifficulty = AgilityEvaluator.EvaluateDifficultyOf(current) * skillMultiplierAgility;
double flowDifficulty = FlowAimEvaluator.EvaluateDifficultyOf(current, IncludeSliders) * skillMultiplierFlow;
double totalDifficulty = calculateTotalValue(snapDifficulty, agilityDifficulty, flowDifficulty);
if (Mods.Any(m => m is OsuModMagnetised))
{
float magnetisedStrength = Mods.OfType<OsuModMagnetised>().First().AttractionStrength.Value;
totalDifficulty *= 1.0 - magnetisedStrength;
}
totalDifficulty *= 0.985 + Math.Pow(Math.Max(0, ((OsuDifficultyHitObject)current).OverallDifficulty), 2) / 4000;
return totalDifficulty;
}
private double calculateTotalValue(double snapDifficulty, double agilityDifficulty, double flowDifficulty)
{
// We compare flow to combined snap and agility because snap by itself doesn't have enough difficulty to be above flow on streams
// Agility on the other hand is supposed to measure the rate of cursor velocity changes while snapping
// So snapping every circle on a stream requires an enormous amount of agility at which point it's easier to flow
double combinedSnapDifficulty = DifficultyCalculationUtils.Norm(combinedSnapNormExponent, snapDifficulty, agilityDifficulty);
double pSnap = calculateSnapFlowProbability(flowDifficulty / combinedSnapDifficulty);
double pFlow = 1 - pSnap;
if (Mods.Any(m => m is OsuModTouchDevice))
{
// we don't adjust agility here since agility represents TD difficulty in a decent enough way
snapDifficulty = Math.Pow(snapDifficulty, 0.89);
combinedSnapDifficulty = DifficultyCalculationUtils.Norm(combinedSnapNormExponent, snapDifficulty, agilityDifficulty);
}
if (Mods.Any(m => m is OsuModRelax))
{
combinedSnapDifficulty *= 0.75;
flowDifficulty *= 0.6;
}
double totalDifficulty = combinedSnapDifficulty * pSnap + flowDifficulty * pFlow;
double totalStrain = totalDifficulty * skillMultiplierTotal;
return totalStrain;
}
// A function that turns the ratio of snap : flow into the probability of snapping/flowing
// It has the constraints:
// P(snap) + P(flow) = 1 (the object is always either snapped or flowed)
// P(snap) = f(snap/flow), P(flow) = f(flow/snap) (ie snap and flow are symmetric and reversible)
// Therefore: f(x) + f(1/x) = 1
// 0 <= f(x) <= 1 (cannot have negative or greater than 100% probability of snapping or flowing)
// This logistic function is a solution, which fits nicely with the general idea of interpolation and provides a tuneable constant
private static double calculateSnapFlowProbability(double ratio)
{
const double k = 7.27;
if (ratio == 0)
return 0;
if (double.IsNaN(ratio))
return 1;
return DifficultyCalculationUtils.Logistic(-k * Math.Log(ratio));
}
public double GetDifficultSliders()
{
if (sliderStrains.Count == 0)
@@ -153,97 +60,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills
return sliderStrains.Sum(strain => 1.0 / (1.0 + Math.Exp(-(strain / maxSliderStrain * 12.0 - 6.0))));
}
public double CountTopWeightedSliders(double difficultyValue)
{
if (sliderStrains.Count == 0)
return 0;
double consistentTopStrain = difficultyValue * (1 - DecayWeight); // What would the top strain be if all strain values were identical
if (consistentTopStrain == 0)
return 0;
// Use a weighted sum of all strains. Constants are arbitrary and give nice values
return sliderStrains.Sum(s => DifficultyCalculationUtils.Logistic(s / consistentTopStrain, 0.88, 10, 1.1));
}
public override double DifficultyValue()
{
double difficulty = 0;
double time = 0;
var strains = getReducedStrainPeaks();
// Difficulty is a continuous weighted sum of the sorted strains
foreach (StrainPeak strain in strains)
{
/* Weighting function can be thought of as:
b
DecayWeight^x dx
a
where a = startTime and b = endTime
Technically, the function below has been slightly modified from the equation above.
The real function would be
double weight = Math.Pow(DecayWeight, startTime) - Math.Pow(DecayWeight, endTime);
...
return difficulty / Math.Log(1 / DecayWeight);
E.g. for a DecayWeight of 0.9, we're multiplying by 10 instead of 9.49122...
This change makes it so that a map composed solely of MaxSectionLength chunks will have the exact same value when summed in this class and StrainSkill.
Doing this ensures the relationship between strain values and difficulty values remains the same between the two classes.
*/
double startTime = time;
double endTime = time + strain.SectionLength / MaxSectionLength;
double weight = Math.Pow(DecayWeight, startTime) - Math.Pow(DecayWeight, endTime);
difficulty += strain.Value * weight;
time = endTime;
}
return difficulty / (1 - DecayWeight);
}
/// <summary>
/// Returns a sorted enumerable of strain peaks with the highest values reduced.
/// </summary>
/// <returns></returns>
private IEnumerable<StrainPeak> getReducedStrainPeaks()
{
// Sections with 0 strain are excluded to avoid worst-case time complexity of the following sort (e.g. /b/2351871).
// These sections will not contribute to the difficulty.
var peaks = GetCurrentStrainPeaks().Where(p => p.Value > 0);
List<StrainPeak> strains = peaks.OrderByDescending(p => p.Value).ToList();
const int chunk_size = 20;
double time = 0;
int strainsToRemove = 0; // All strains are removed at the end for optimization purposes
// We are reducing the highest strains first to account for extreme difficulty spikes
// Strains are split into 20ms chunks to try to mitigate inconsistencies caused by reducing strains
while (strains.Count > strainsToRemove && time < reducedSectionTime)
{
StrainPeak strain = strains[strainsToRemove];
for (double addedTime = 0; addedTime < strain.SectionLength; addedTime += chunk_size)
{
double scale = Math.Log10(Interpolation.Lerp(1, 10, Math.Clamp((time + addedTime) / reducedSectionTime, 0, 1)));
strains.Add(new StrainPeak(
strain.Value * Interpolation.Lerp(reducedStrainBaseline, 1.0, scale),
Math.Min(chunk_size, strain.SectionLength - addedTime)
));
}
time += strain.SectionLength;
strainsToRemove++;
}
strains.RemoveRange(0, strainsToRemove);
return strains.OrderByDescending(p => p.Value);
}
public double CountTopWeightedSliders() => OsuStrainUtils.CountTopWeightedSliders(sliderStrains, DifficultyValue());
}
}
@@ -5,10 +5,8 @@ using System;
using System.Linq;
using osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Difficulty.Skills;
using osu.Game.Rulesets.Difficulty.Utils;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Osu.Difficulty.Evaluators;
using osu.Game.Rulesets.Osu.Difficulty.Preprocessing;
using osu.Game.Rulesets.Osu.Mods;
namespace osu.Game.Rulesets.Osu.Difficulty.Skills
@@ -18,15 +16,15 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills
/// </summary>
public class Flashlight : StrainSkill
{
private readonly int totalObjects;
private readonly bool hasHiddenMod;
public Flashlight(Mod[] mods, int totalObjects)
public Flashlight(Mod[] mods)
: base(mods)
{
this.totalObjects = totalObjects;
hasHiddenMod = mods.Any(m => m is OsuModHidden);
}
private double skillMultiplier => 0.058;
private double skillMultiplier => 0.05512;
private double strainDecayBase => 0.15;
private double currentStrain;
@@ -37,55 +35,13 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills
protected override double StrainValueAt(DifficultyHitObject current)
{
if (!Mods.Any(m => m is OsuModFlashlight))
return 0;
currentStrain *= strainDecay(current.DeltaTime);
currentStrain += calculateAdjustedDifficulty(current) * skillMultiplier;
currentStrain += FlashlightEvaluator.EvaluateDifficultyOf(current, hasHiddenMod) * skillMultiplier;
return currentStrain;
}
private double calculateAdjustedDifficulty(DifficultyHitObject current)
{
double difficulty = FlashlightEvaluator.EvaluateDifficultyOf(current, Mods);
if (Mods.Any(m => m is OsuModTouchDevice))
difficulty = Math.Pow(difficulty, 0.9);
if (Mods.Any(m => m is OsuModMagnetised))
{
float magnetisedStrength = Mods.OfType<OsuModMagnetised>().First().AttractionStrength.Value;
difficulty *= 1.0 - magnetisedStrength;
}
if (Mods.Any(m => m is OsuModDeflate))
{
float deflateInitialScale = Mods.OfType<OsuModDeflate>().First().StartScale.Value;
difficulty *= Math.Clamp(DifficultyCalculationUtils.ReverseLerp(deflateInitialScale, 11, 1), 0.1, 1);
}
if (Mods.Any(m => m is OsuModRelax))
difficulty *= 0.7;
if (Mods.Any(m => m is OsuModAutopilot))
difficulty *= 0.4;
difficulty *= 0.985 + Math.Pow(Math.Max(0, ((OsuDifficultyHitObject)current).OverallDifficulty), 2) / 4000;
return difficulty;
}
public override double DifficultyValue()
{
double sum = GetCurrentStrainPeaks().Sum();
// Account for shorter maps having a higher ratio of 0 combo/100 combo flashlight radius.
sum *= 0.7 + 0.1 * Math.Min(1.0, totalObjects / 200.0) +
(totalObjects > 200 ? 0.2 * Math.Min(1.0, (totalObjects - 200) / 200.0) : 0.0);
return sum;
}
public override double DifficultyValue() => GetCurrentStrainPeaks().Sum();
public static double DifficultyToPerformance(double difficulty) => 25 * Math.Pow(difficulty, 2);
}
@@ -0,0 +1,62 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using osu.Game.Rulesets.Difficulty.Skills;
using osu.Game.Rulesets.Mods;
using System.Linq;
using osu.Framework.Utils;
namespace osu.Game.Rulesets.Osu.Difficulty.Skills
{
public abstract class OsuStrainSkill : StrainSkill
{
/// <summary>
/// The number of sections with the highest strains, which the peak strain reductions will apply to.
/// This is done in order to decrease their impact on the overall difficulty of the map for this skill.
/// </summary>
protected virtual int ReducedSectionCount => 10;
/// <summary>
/// The baseline multiplier applied to the section with the biggest strain.
/// </summary>
protected virtual double ReducedStrainBaseline => 0.75;
protected OsuStrainSkill(Mod[] mods)
: base(mods)
{
}
public override double DifficultyValue()
{
double difficulty = 0;
double weight = 1;
// Sections with 0 strain are excluded to avoid worst-case time complexity of the following sort (e.g. /b/2351871).
// These sections will not contribute to the difficulty.
var peaks = GetCurrentStrainPeaks().Where(p => p > 0);
List<double> strains = peaks.OrderDescending().ToList();
// We are reducing the highest strains first to account for extreme difficulty spikes
for (int i = 0; i < Math.Min(strains.Count, ReducedSectionCount); i++)
{
double scale = Math.Log10(Interpolation.Lerp(1, 10, Math.Clamp((float)i / ReducedSectionCount, 0, 1)));
strains[i] *= Interpolation.Lerp(ReducedStrainBaseline, 1.0, scale);
}
// Difficulty is the weighted sum of the highest strains from every section.
// We're sorting from highest to lowest strain.
foreach (double strain in strains.OrderDescending())
{
difficulty += strain * weight;
weight *= DecayWeight;
}
return difficulty;
}
public static double DifficultyToPerformance(double difficulty) => Math.Pow(5.0 * Math.Max(1.0, difficulty / 0.0675) - 4.0, 3.0) / 100000.0;
}
}
@@ -1,124 +0,0 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Linq;
using osu.Framework.Utils;
using osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Difficulty.Skills;
using osu.Game.Rulesets.Difficulty.Utils;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Osu.Difficulty.Evaluators;
using osu.Game.Rulesets.Osu.Difficulty.Preprocessing;
using osu.Game.Rulesets.Osu.Mods;
namespace osu.Game.Rulesets.Osu.Difficulty.Skills
{
public class Reading : HarmonicSkill
{
private readonly List<DifficultyHitObject> objectList = new List<DifficultyHitObject>();
private readonly bool hasHiddenMod;
public Reading(Mod[] mods)
: base(mods)
{
hasHiddenMod = mods.OfType<OsuModHidden>().Any(m => !m.OnlyFadeApproachCircles.Value);
}
private double currentStrain;
private double skillMultiplier => 2.5;
private double strainDecayBase => 0.8;
private double strainDecay(double ms) => Math.Pow(strainDecayBase, ms / 1000);
protected override double ObjectDifficultyOf(DifficultyHitObject current)
{
objectList.Add(current);
double decay = strainDecay(current.DeltaTime);
currentStrain *= decay;
currentStrain += calculateAdjustedDifficulty(current) * (1 - decay) * skillMultiplier;
return currentStrain;
}
private double calculateAdjustedDifficulty(DifficultyHitObject current)
{
double difficulty = ReadingEvaluator.EvaluateDifficultyOf(current, hasHiddenMod);
if (Mods.Any(m => m is OsuModTouchDevice))
difficulty = Math.Pow(difficulty, 0.89);
if (Mods.Any(m => m is OsuModMagnetised))
{
float magnetisedStrength = Mods.OfType<OsuModMagnetised>().First().AttractionStrength.Value;
difficulty *= 1.0 - magnetisedStrength;
}
if (Mods.Any(m => m is OsuModRelax))
difficulty *= 0.4;
if (Mods.Any(m => m is OsuModAutopilot))
difficulty *= 0.1;
difficulty *= 0.825 + Math.Pow(Math.Max(0, ((OsuDifficultyHitObject)current).OverallDifficulty), 2.2) / 1125.0;
return difficulty;
}
protected override void ApplyDifficultyTransformation(double[] difficulties)
{
const double reduced_difficulty_base_line = 0.0; // Assume the first seconds are completely memorised
int reducedNoteCount = calculateReducedNoteCount();
for (int i = 0; i < Math.Min(difficulties.Length, reducedNoteCount); i++)
{
double scale = Math.Log10(Interpolation.Lerp(1, 10, Math.Clamp((double)i / reducedNoteCount, 0, 1)));
difficulties[i] *= Interpolation.Lerp(reduced_difficulty_base_line, 1.0, scale);
}
}
private int calculateReducedNoteCount()
{
const double reduced_difficulty_duration = 60 * 1000;
if (objectList.Count == 0)
return 0;
double reducedDuration = objectList.First().StartTime + reduced_difficulty_duration;
int reducedNoteCount = 0;
foreach (var hitObject in objectList)
{
if (hitObject.StartTime > reducedDuration)
break;
reducedNoteCount++;
}
return reducedNoteCount;
}
public override double CountTopWeightedObjectDifficulties(double difficultyValue)
{
if (ObjectDifficulties.Count == 0)
return 0.0;
if (NoteWeightSum == 0)
return 0.0;
double consistentTopNote = difficultyValue / NoteWeightSum; // What would the top difficulty be if all object difficulties were identical
if (consistentTopNote == 0)
return 0;
return ObjectDifficulties.Sum(d => DifficultyCalculationUtils.Logistic(d / consistentTopNote, 1.15, 5, 1.1));
}
}
}
@@ -3,33 +3,30 @@
using System;
using System.Collections.Generic;
using System.Linq;
using osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Difficulty.Skills;
using osu.Game.Rulesets.Difficulty.Utils;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Osu.Difficulty.Evaluators.Speed;
using osu.Game.Rulesets.Osu.Difficulty.Evaluators;
using osu.Game.Rulesets.Osu.Difficulty.Preprocessing;
using osu.Game.Rulesets.Osu.Mods;
using osu.Game.Rulesets.Osu.Objects;
using System.Linq;
using osu.Game.Rulesets.Osu.Difficulty.Utils;
namespace osu.Game.Rulesets.Osu.Difficulty.Skills
{
/// <summary>
/// Represents the skill required to press keys with regards to keeping up with the speed at which objects need to be hit.
/// </summary>
public class Speed : HarmonicSkill
public class Speed : OsuStrainSkill
{
private double skillMultiplier => 1.16;
private double skillMultiplier => 1.47;
private double strainDecayBase => 0.3;
private double currentStrain;
private double currentRhythm;
private readonly List<double> sliderStrains = new List<double>();
private double currentStrain;
private double strainDecayBase => 0.3;
protected override double HarmonicScale => 20;
protected override double DecayExponent => 0.9;
protected override int ReducedSectionCount => 5;
public Speed(Mod[] mods)
: base(mods)
@@ -38,17 +35,14 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills
private double strainDecay(double ms) => Math.Pow(strainDecayBase, ms / 1000);
protected override double ObjectDifficultyOf(DifficultyHitObject current)
protected override double CalculateInitialStrain(double time, DifficultyHitObject current) => (currentStrain * currentRhythm) * strainDecay(time - current.Previous(0).StartTime);
protected override double StrainValueAt(DifficultyHitObject current)
{
if (Mods.Any(m => m is OsuModRelax))
return 0;
currentStrain *= strainDecay(((OsuDifficultyHitObject)current).AdjustedDeltaTime);
currentStrain += SpeedEvaluator.EvaluateDifficultyOf(current, Mods) * skillMultiplier;
double decay = strainDecay(((OsuDifficultyHitObject)current).AdjustedDeltaTime);
currentStrain *= decay;
currentStrain += calculateAdjustedDifficulty(current) * (1 - decay) * skillMultiplier;
double currentRhythm = RhythmEvaluator.EvaluateDifficultyOf(current);
currentRhythm = RhythmEvaluator.EvaluateDifficultyOf(current);
double totalStrain = currentStrain * currentRhythm;
@@ -58,44 +52,18 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills
return totalStrain;
}
private double calculateAdjustedDifficulty(DifficultyHitObject current)
{
double difficulty = SpeedEvaluator.EvaluateDifficultyOf(current);
if (Mods.Any(m => m is OsuModAutopilot))
difficulty *= 0.5;
return difficulty;
}
public double RelevantNoteCount()
{
if (ObjectDifficulties.Count == 0)
if (ObjectStrains.Count == 0)
return 0;
double maxStrain = ObjectDifficulties.Max();
double maxStrain = ObjectStrains.Max();
if (maxStrain == 0)
return 0;
return ObjectDifficulties.Sum(strain => 1.0 / (1.0 + Math.Exp(-(strain / maxStrain * 12.0 - 6.0))));
return ObjectStrains.Sum(strain => 1.0 / (1.0 + Math.Exp(-(strain / maxStrain * 12.0 - 6.0))));
}
public double CountTopWeightedSliders(double difficultyValue)
{
if (sliderStrains.Count == 0)
return 0;
if (NoteWeightSum == 0)
return 0.0;
double consistentTopNote = difficultyValue / NoteWeightSum; // What would the top note be if all note values were identical
if (consistentTopNote == 0)
return 0;
// Use a weighted sum of all notes. Constants are arbitrary and give nice values
return sliderStrains.Sum(s => DifficultyCalculationUtils.Logistic(s / consistentTopNote, 0.88, 10, 1.1));
}
public double CountTopWeightedSliders() => OsuStrainUtils.CountTopWeightedSliders(sliderStrains, DifficultyValue());
}
}
@@ -0,0 +1,26 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Collections.Generic;
using System.Linq;
using osu.Game.Rulesets.Difficulty.Utils;
namespace osu.Game.Rulesets.Osu.Difficulty.Utils
{
public static class OsuStrainUtils
{
public static double CountTopWeightedSliders(IReadOnlyCollection<double> sliderStrains, double difficultyValue)
{
if (sliderStrains.Count == 0)
return 0;
double consistentTopStrain = difficultyValue / 10; // What would the top strain be if all strain values were identical
if (consistentTopStrain == 0)
return 0;
// Use a weighted sum of all strains. Constants are arbitrary and give nice values
return sliderStrains.Sum(s => DifficultyCalculationUtils.Logistic(s / consistentTopStrain, 0.88, 10, 1.1));
}
}
}
@@ -54,6 +54,9 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
[Resolved]
private EditorClock? editorClock { get; set; }
[Resolved]
private OsuSliderVelocityToolboxGroup? sliderVelocityToolbox { get; set; }
private Bindable<bool> limitedDistanceSnap { get; set; } = null!;
private readonly IncrementalBSplineBuilder bSplineBuilder = new IncrementalBSplineBuilder { Degree = 4 };
@@ -111,9 +114,6 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
}
}
[Resolved]
private EditorBeatmap editorBeatmap { get; set; } = null!;
public override SnapResult UpdateTimeAndPosition(Vector2 screenSpacePosition, double fallbackTime)
{
var result = composer?.TrySnapToNearbyObjects(screenSpacePosition, fallbackTime);
@@ -129,11 +129,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
case SliderPlacementState.Initial:
BeginPlacement();
double? nearestSliderVelocity = (editorBeatmap
.HitObjects
.LastOrDefault(h => h is Slider && h.GetEndTime() < HitObject.StartTime) as Slider)?.SliderVelocityMultiplier;
HitObject.SliderVelocityMultiplier = nearestSliderVelocity ?? 1;
HitObject.SliderVelocityMultiplier = sliderVelocityToolbox?.SliderVelocity.Value ?? 1;
HitObject.Position = ToLocalSpace(result.ScreenSpacePosition);
// Replacing the DifficultyControlPoint above doesn't trigger any kind of invalidation.
@@ -15,7 +15,7 @@ namespace osu.Game.Rulesets.Osu.Edit
public partial class FreehandSliderToolboxGroup : EditorToolboxGroup
{
public FreehandSliderToolboxGroup()
: base("slider")
: base("freehand")
{
}
@@ -7,14 +7,13 @@ using osu.Game.Graphics;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Edit.Tools;
using osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles;
using osu.Game.Rulesets.Osu.Objects;
namespace osu.Game.Rulesets.Osu.Edit
{
public class HitCircleCompositionTool : CompositionTool
{
public HitCircleCompositionTool()
: base(nameof(HitCircle))
: base("Hit circle")
{
}
@@ -75,6 +75,9 @@ namespace osu.Game.Rulesets.Osu.Edit
[Cached(typeof(IDistanceSnapProvider))]
public readonly OsuDistanceSnapProvider DistanceSnapProvider = new OsuDistanceSnapProvider();
[Cached]
private readonly OsuSliderVelocityToolboxGroup sliderVelocityToolboxGroup = new OsuSliderVelocityToolboxGroup();
[Cached]
protected readonly OsuGridToolboxGroup OsuGridToolboxGroup = new OsuGridToolboxGroup();
@@ -111,6 +114,7 @@ namespace osu.Game.Rulesets.Osu.Edit
RightToolbox.AddRange(new Drawable[]
{
sliderVelocityToolboxGroup,
OsuGridToolboxGroup,
new TransformToolboxGroup
{
@@ -0,0 +1,213 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Caching;
using osu.Framework.Extensions.LocalisationExtensions;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Graphics;
using osu.Framework.Localisation;
using osu.Game.Graphics.UserInterface;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Screens.Edit;
using osuTK;
namespace osu.Game.Rulesets.Osu.Edit
{
public partial class OsuSliderVelocityToolboxGroup : EditorToolboxGroup
{
/// <summary>
/// Whether the last slider's velocity should be used (if available).
/// </summary>
private bool useLastSliderVelocity;
/// <summary>
/// The slider velocity to be used for new object placements.
/// </summary>
public IBindable<double> SliderVelocity => sliderVelocity;
private readonly BindableDouble sliderVelocity = new BindableDouble(1)
{
Precision = 0.01,
MinValue = 0.1,
MaxValue = 10,
};
private ExpandableSlider<double> slider = null!;
private ExpandableButton useLastSliderButton = null!;
[Resolved]
private EditorBeatmap editorBeatmap { get; set; } = null!;
[Resolved]
private EditorClock editorClock { get; set; } = null!;
private bool syncingBindables;
private double lastClockPosition = double.NegativeInfinity;
private readonly Cached<Slider?> sliderVelocitySourceObject = new Cached<Slider?>();
public OsuSliderVelocityToolboxGroup()
: base("velocity")
{
}
[BackgroundDependencyLoader]
private void load()
{
Spacing = new Vector2(5);
Children = new Drawable[]
{
slider = new ExpandableSlider<double>
{
ExpandedLabelText = "Slider velocity",
Current = new BindableDouble(1)
{
Precision = 0.01,
MinValue = 0.1,
MaxValue = 10,
},
KeyboardStep = 0.1f,
},
useLastSliderButton = new ExpandableButton
{
RelativeSizeAxes = Axes.X,
Action = () =>
{
useLastSliderVelocity = true;
sliderVelocitySourceObject.Invalidate();
},
}
};
}
protected override void LoadComplete()
{
base.LoadComplete();
// set unconditionally to true initially.
// if there is no object available to get the slider velocity from, the code in `Update()` will handle that.
useLastSliderVelocity = true;
sliderVelocity.BindValueChanged(_ => updateSliderFromVelocity(), true);
slider.Current.BindValueChanged(_ =>
{
updateVelocityFromSlider();
updateContractedText();
});
updateContractedText();
useLastSliderButton.Expanded.BindValueChanged(_ => sliderVelocitySourceObject.Invalidate());
editorBeatmap.HitObjectAdded += invalidateSliderVelocitySourceObject;
editorBeatmap.HitObjectUpdated += invalidateSliderVelocitySourceObject;
editorBeatmap.HitObjectRemoved += invalidateSliderVelocitySourceObject;
}
private void updateContractedText()
{
slider.ContractedLabelText = LocalisableString.Interpolate($@"SV: {slider.Current.Value.ToLocalisableString("N2")}x");
}
/// <summary>
/// Updates the displayed value of this toolbox's slider from a change to <see cref="SliderVelocity"/>
/// (which is the source-of-truth used for new object placements).
/// This is only relevant when <see cref="useLastSliderVelocity"/> is true,
/// in which case this code is responsible for propagating the velocity from <see cref="sliderVelocitySourceObject"/> to the slider.
/// </summary>
private void updateSliderFromVelocity()
{
if (syncingBindables)
return;
if (!useLastSliderVelocity)
return;
syncingBindables = true;
slider.Current.Value = sliderVelocity.Value;
syncingBindables = false;
}
/// <summary>
/// Updates the value of <see cref="SliderVelocity"/> from a change to the slider's state.
/// This change is assumed to be user-provoked, and therefore <see cref="useLastSliderVelocity"/> is switched unconditionally off
/// as the presumed intent is to override the velocity from <see cref="sliderVelocitySourceObject"/>.
/// </summary>
private void updateVelocityFromSlider()
{
if (syncingBindables)
return;
syncingBindables = true;
useLastSliderVelocity = false;
sliderVelocity.Value = slider.Current.Value;
syncingBindables = false;
sliderVelocitySourceObject.Invalidate();
}
private void invalidateSliderVelocitySourceObject(HitObject _) => sliderVelocitySourceObject.Invalidate();
protected override void Update()
{
base.Update();
if (editorClock.CurrentTime != lastClockPosition)
{
sliderVelocitySourceObject.Invalidate();
lastClockPosition = editorClock.CurrentTime;
}
// Three possible causes of invalidation:
// - The user seeked the clock, which means a different velocity source object needs to be used.
// - Some change to the beatmap was made, which means the previously-used velocity source object may no longer be the most relevant one.
// - The user is interacting with the toolbox in a way that requires a visual state update
// (hovered to expand it, clicked the button to use last slider's velocity, or dragged the manual velocity slider).
// This is a procedural one, because `sliderVelocitySourceObject` will have been pointing at the correct object already,
// but to decrease unnecessary work being done every frame, the invalidation is explicitly re-triggered to update the toolbox state.
if (!sliderVelocitySourceObject.IsValid)
{
var lastSlider = getLastSlider();
sliderVelocitySourceObject.Value = lastSlider;
if (lastSlider == null)
{
useLastSliderButton.Enabled.Value = false;
useLastSliderButton.ExpandedLabelText = "No sliders to get velocity from";
useLastSliderButton.ContractedLabelText = default;
}
else
{
useLastSliderButton.Enabled.Value = useLastSliderButton.Expanded.Value && !useLastSliderVelocity;
useLastSliderButton.ExpandedLabelText = useLastSliderVelocity
? "Using last slider's velocity"
: LocalisableString.Interpolate($@"Use last slider's velocity ({lastSlider.SliderVelocityMultiplier.ToLocalisableString("N2")}x)");
useLastSliderButton.ContractedLabelText = $@"current {lastSlider.SliderVelocityMultiplier.ToLocalisableString("N2")}x";
if (useLastSliderVelocity)
sliderVelocity.Value = lastSlider.SliderVelocityMultiplier;
}
}
}
private Slider? getLastSlider()
{
return editorBeatmap
.HitObjects
.OfType<Slider>()
.LastOrDefault(h => h.StartTime <= editorClock.CurrentTime);
}
protected override void Dispose(bool isDisposing)
{
if (editorBeatmap.IsNotNull())
{
editorBeatmap.HitObjectAdded -= invalidateSliderVelocitySourceObject;
editorBeatmap.HitObjectUpdated -= invalidateSliderVelocitySourceObject;
editorBeatmap.HitObjectRemoved -= invalidateSliderVelocitySourceObject;
}
base.Dispose(isDisposing);
}
}
}
@@ -21,7 +21,6 @@ namespace osu.Game.Rulesets.Osu.Mods
{
public abstract partial class InputBlockingMod : Mod, IApplicableToDrawableRuleset<OsuHitObject>, IUpdatableByPlayfield
{
public override double ScoreMultiplier => 1.0;
public override Type[] IncompatibleMods => new[] { typeof(ModAutoplay), typeof(ModRelax), typeof(OsuModCinema) };
public override ModType Type => ModType.Conversion;
@@ -19,7 +19,6 @@ namespace osu.Game.Rulesets.Osu.Mods
public override string Name => "Approach Different";
public override string Acronym => "AD";
public override LocalisableString Description => "Never trust the approach circles...";
public override double ScoreMultiplier => 1;
public override IconUsage? Icon => OsuIcon.ModApproachDifferent;
public override Type[] IncompatibleMods => new[] { typeof(IHidesApproachCircles), typeof(OsuModFreezeFrame) };
@@ -23,7 +23,6 @@ namespace osu.Game.Rulesets.Osu.Mods
public override IconUsage? Icon => OsuIcon.ModAutopilot;
public override ModType Type => ModType.Automation;
public override LocalisableString Description => @"Automatic cursor movement - just follow the rhythm.";
public override double ScoreMultiplier => 0.1;
public override Type[] IncompatibleMods => new[]
{
@@ -30,7 +30,6 @@ namespace osu.Game.Rulesets.Osu.Mods
public override IconUsage? Icon => OsuIcon.ModBlinds;
public override ModType Type => ModType.DifficultyIncrease;
public override double ScoreMultiplier => UsesDefaultConfiguration ? 1.12 : 1;
public override Type[] IncompatibleMods => new[] { typeof(OsuModFlashlight) };
public override bool Ranked => true;
@@ -26,7 +26,6 @@ namespace osu.Game.Rulesets.Osu.Mods
public override IconUsage? Icon => OsuIcon.ModBloom;
public override ModType Type => ModType.Fun;
public override LocalisableString Description => "The cursor blooms into.. a larger cursor!";
public override double ScoreMultiplier => 1;
protected const float MIN_SIZE = 1;
protected const float TRANSITION_DURATION = 100;
public override Type[] IncompatibleMods => new[] { typeof(OsuModFlashlight), typeof(OsuModNoScope), typeof(ModTouchDevice) };
@@ -34,8 +34,6 @@ namespace osu.Game.Rulesets.Osu.Mods
public override LocalisableString Description => "Don't let their popping distract you!";
public override double ScoreMultiplier => 1;
public override IconUsage? Icon => OsuIcon.ModBubbles;
public override ModType Type => ModType.Fun;
@@ -25,7 +25,6 @@ namespace osu.Game.Rulesets.Osu.Mods
public override IconUsage? Icon => OsuIcon.ModDepth;
public override ModType Type => ModType.Fun;
public override LocalisableString Description => "3D. Almost.";
public override double ScoreMultiplier => 1;
public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(OsuModMagnetised), typeof(OsuModRepel), typeof(OsuModFreezeFrame), typeof(ModWithVisibilityAdjustment) }).ToArray();
private static readonly Vector3 camera_position = new Vector3(OsuPlayfield.BASE_SIZE.X * 0.5f, OsuPlayfield.BASE_SIZE.Y * 0.5f, -200);
@@ -19,7 +19,6 @@ namespace osu.Game.Rulesets.Osu.Mods
{
public partial class OsuModFlashlight : ModFlashlight<OsuHitObject>, IApplicableToDrawableHitObject
{
public override double ScoreMultiplier => UsesDefaultConfiguration ? 1.12 : 1;
public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(OsuModBloom), typeof(OsuModBlinds) }).ToArray();
private const double default_follow_delay = 120;
@@ -23,8 +23,6 @@ namespace osu.Game.Rulesets.Osu.Mods
public override IconUsage? Icon => OsuIcon.ModFreezeFrame;
public override double ScoreMultiplier => 1;
public override LocalisableString Description => "Burn the notes into your memory.";
/// <remarks>
@@ -13,8 +13,6 @@ namespace osu.Game.Rulesets.Osu.Mods
{
public class OsuModHardRock : ModHardRock, IApplicableToHitObject
{
public override double ScoreMultiplier => UsesDefaultConfiguration ? 1.06 : 1;
public override Type[] IncompatibleMods => base.IncompatibleMods.Append(typeof(ModMirror)).ToArray();
public void ApplyToHitObject(HitObject hitObject)
@@ -24,7 +24,6 @@ namespace osu.Game.Rulesets.Osu.Mods
public Bindable<bool> OnlyFadeApproachCircles { get; } = new BindableBool();
public override LocalisableString Description => @"Play with no approach circles and fading circles/sliders.";
public override double ScoreMultiplier => UsesDefaultConfiguration ? 1.06 : 1;
public override Type[] IncompatibleMods => new[] { typeof(IRequiresApproachCircles), typeof(OsuModSpinIn), typeof(OsuModDepth), typeof(OsuModFreezeFrame) };
@@ -27,7 +27,6 @@ namespace osu.Game.Rulesets.Osu.Mods
public override IconUsage? Icon => OsuIcon.ModMagnetised;
public override ModType Type => ModType.Fun;
public override LocalisableString Description => "No need to chase the circles your cursor is a magnet!";
public override double ScoreMultiplier => 0.5;
public override Type[] IncompatibleMods => new[] { typeof(OsuModAutopilot), typeof(OsuModWiggle), typeof(OsuModTransform), typeof(ModAutoplay), typeof(OsuModRelax), typeof(OsuModRepel), typeof(OsuModBubbles), typeof(OsuModDepth) };
[SettingSource("Attraction strength", "How strong the pull is.", 0)]
@@ -19,8 +19,6 @@ namespace osu.Game.Rulesets.Osu.Mods
{
public override ModType Type => ModType.Fun;
public override double ScoreMultiplier => 1;
[SettingSource("Starting Size", "The initial size multiplier applied to all objects.")]
public abstract BindableNumber<float> StartScale { get; }
@@ -28,7 +28,6 @@ namespace osu.Game.Rulesets.Osu.Mods
public override IconUsage? Icon => OsuIcon.ModRepel;
public override ModType Type => ModType.Fun;
public override LocalisableString Description => "Hit objects run away!";
public override double ScoreMultiplier => 1;
public override Type[] IncompatibleMods => new[] { typeof(OsuModAutopilot), typeof(OsuModWiggle), typeof(OsuModTransform), typeof(ModAutoplay), typeof(OsuModMagnetised), typeof(OsuModBubbles), typeof(OsuModDepth) };
[SettingSource("Repulsion strength", "How strong the repulsion is.", 0)]
@@ -21,7 +21,6 @@ namespace osu.Game.Rulesets.Osu.Mods
public override IconUsage? Icon => OsuIcon.ModSpinIn;
public override ModType Type => ModType.Fun;
public override LocalisableString Description => "Circles spin in. No approach circles.";
public override double ScoreMultiplier => 1;
// todo: this mod needs to be incompatible with "hidden" due to forcing the circle to remain opaque,
// further implementation will be required for supporting that.
@@ -20,7 +20,6 @@ namespace osu.Game.Rulesets.Osu.Mods
public override IconUsage? Icon => OsuIcon.ModSpunOut;
public override ModType Type => ModType.Automation;
public override LocalisableString Description => @"Spinners will be automatically completed.";
public override double ScoreMultiplier => 0.9;
public override Type[] IncompatibleMods => new[] { typeof(ModAutoplay), typeof(OsuModAutopilot), typeof(OsuModTargetPractice) };
public override bool Ranked => UsesDefaultConfiguration;
@@ -29,7 +29,6 @@ namespace osu.Game.Rulesets.Osu.Mods
public override IconUsage? Icon => OsuIcon.ModStrictTracking;
public override ModType Type => ModType.DifficultyIncrease;
public override LocalisableString Description => @"Once you start a slider, follow precisely or get a miss.";
public override double ScoreMultiplier => 1.0;
public override Type[] IncompatibleMods => new[] { typeof(ModClassic), typeof(OsuModTargetPractice) };
public void ApplyToDrawableHitObject(DrawableHitObject drawable)
@@ -39,7 +39,6 @@ namespace osu.Game.Rulesets.Osu.Mods
public override ModType Type => ModType.Conversion;
public override IconUsage? Icon => OsuIcon.ModTargetPractice;
public override LocalisableString Description => @"Practice keeping up with the beat of the song.";
public override double ScoreMultiplier => 0.1;
public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[]
{
@@ -23,7 +23,6 @@ namespace osu.Game.Rulesets.Osu.Mods
public override IconUsage? Icon => OsuIcon.ModTraceable;
public override ModType Type => ModType.DifficultyIncrease;
public override LocalisableString Description => "Put your faith in the approach circles...";
public override double ScoreMultiplier => 1;
public override bool Ranked => true;
public override Type[] IncompatibleMods => new[] { typeof(IHidesApproachCircles), typeof(OsuModDepth) };
@@ -22,7 +22,6 @@ namespace osu.Game.Rulesets.Osu.Mods
public override IconUsage? Icon => OsuIcon.ModTransform;
public override ModType Type => ModType.Fun;
public override LocalisableString Description => "Everything rotates. EVERYTHING.";
public override double ScoreMultiplier => 1;
public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(OsuModWiggle), typeof(OsuModMagnetised), typeof(OsuModRepel), typeof(OsuModFreezeFrame), typeof(OsuModDepth) }).ToArray();
private float theta;
@@ -23,7 +23,6 @@ namespace osu.Game.Rulesets.Osu.Mods
public override IconUsage? Icon => OsuIcon.ModWiggle;
public override ModType Type => ModType.Fun;
public override LocalisableString Description => "They just won't stay still...";
public override double ScoreMultiplier => 1;
public override Type[] IncompatibleMods => new[] { typeof(OsuModTransform), typeof(OsuModMagnetised), typeof(OsuModRepel), typeof(OsuModDepth) };
private const int wiggle_duration = 100; // (ms) Higher = fewer wiggles

Some files were not shown because too many files have changed in this diff Show More