Skip to content

Refactor difficulty calculation flow to move control from DifficultyCalculator to Skills - #38529

Draft
stanriders wants to merge 2 commits into
ppy:pp-devfrom
stanriders:skill-processing-flow
Draft

Refactor difficulty calculation flow to move control from DifficultyCalculator to Skills#38529
stanriders wants to merge 2 commits into
ppy:pp-devfrom
stanriders:skill-processing-flow

Conversation

@stanriders

Copy link
Copy Markdown
Member

RFC.

Currently difficulty calculation is completely controlled by the DifficultyCalculator and Skills exist only to store state while the DifficultyCalculator goes through every DifficultyHitObject. It then can call DifficultyValue and other methods of Skills to get the final aggregated difficulty values, which it usually does in CreateDifficultyAttributes.

Current flow looks pretty much like this:

DifficultyCalculator.Calculate 
{
    DifficultyCalculator.CreateSkills
                       |
    DifficultyCalculator.CreateDifficultyHitObjects
                       |
    foreach DifficultyHitObject - Skill.Process(object)
                       |
    DifficultyCalculator.CreateDifficultyAttributes 
    {
        foreach Skill - DifficultyValue, ...
    }
}

This is pretty convoluted so this change is proposing flow that looks roughly like this:

DifficultyCalculator.Calculate 
{
    DifficultyCalculator.CreateSkills
                       |
    DifficultyCalculator.CreateDifficultyHitObjects
                       |
    foreach Skill - Skill.Process(allObjects)
                       |
    DifficultyCalculator.CreateDifficultyAttributes(skillAttributes) 
}

As you can see it makes all object iteration and aggregation move to skills instead of being in the calculator itself. CreateDifficultyAttributes then gets a final result of skill processing in a form of ISkillAttributes and only does final difficulty attributes creation instead of doing parts of the difficulty calculation.

Benefits of this approach apart from untangling the flow is allowing skills to define how exactly they want to process the map instead of forcing sequential per-object approach (which for example would greatly benefit potential mania changes since mania often has multiple objects at the same time which should be evaluated together) and allowing skills to expose more information to diffcalc consumers like osu-tools and potentially clientside stuff like map difficulty graphs.

Consider this PR to be sort of an PoC - I've made sure it works for the most part but don't expect it to be merge-ready in any way. I'm interested in having more eyes on the new flow itself before ironing out stuff like tests or timed calculation inconsistencies. Would appreciate hearing feedback sooner than later so that we can have all the major refactoring work done before any other changes.

Benchmarks:

Old:

Method Mean Error StdDev Median Gen0 Gen1 Allocated
CalculateDifficultyOsu 6.519 ms 0.0558 ms 0.0466 ms 6.526 ms 101.5625 93.7500 5.19 MB
CalculateDifficultyTaiko 3.970 ms 0.0898 ms 0.2591 ms 3.872 ms 109.3750 58.5938 5.35 MB
CalculateDifficultyCatch 4.195 ms 0.0946 ms 0.2590 ms 4.092 ms 117.1875 109.3750 5.71 MB
CalculateDifficultyMania 3.158 ms 0.0946 ms 0.2791 ms 3.004 ms 121.0938 89.8438 5.87 MB
CalculateTimedDifficultyOsu 214.785 ms 0.2508 ms 0.2095 ms 214.748 ms 1000.0000 - 86.26 MB
CalculateTimedDifficultyTaiko 398.270 ms 7.8961 ms 8.7765 ms 392.736 ms 4000.0000 1000.0000 204.72 MB
CalculateTimedDifficultyCatch 12.567 ms 0.2342 ms 0.4101 ms 12.431 ms 156.2500 140.6250 7.72 MB
CalculateTimedDifficultyMania 61.085 ms 0.8203 ms 0.6850 ms 61.097 ms 375.0000 250.0000 22.78 MB
CalculateDifficultyOsuHundredTimes 671.977 ms 13.2834 ms 36.5864 ms 656.992 ms 10000.0000 9000.0000 519.34 MB

New:

Method Mean Error StdDev Median Gen0 Gen1 Gen2 Allocated
CalculateDifficultyOsu 7.333 ms 0.1736 ms 0.5010 ms 7.246 ms 93.7500 62.5000 - 5.2 MB
CalculateDifficultyTaiko 4.288 ms 0.0920 ms 0.2698 ms 4.207 ms 109.3750 54.6875 - 5.37 MB
CalculateDifficultyCatch 4.819 ms 0.1094 ms 0.3156 ms 4.771 ms 117.1875 109.3750 - 5.72 MB
CalculateDifficultyMania 3.533 ms 0.0763 ms 0.2251 ms 3.493 ms 121.0938 89.8438 - 5.9 MB
CalculateTimedDifficultyOsu 306.607 ms 7.2740 ms 21.4476 ms 298.829 ms 2000.0000 1000.0000 - 115.19 MB
CalculateTimedDifficultyTaiko 668.392 ms 13.3201 ms 28.6729 ms 659.166 ms 7000.0000 3000.0000 1000.0000 314.6 MB
CalculateTimedDifficultyCatch 40.643 ms 0.8562 ms 2.4839 ms 39.694 ms 166.6667 83.3333 - 11.53 MB
CalculateTimedDifficultyMania 118.512 ms 2.2563 ms 5.4491 ms 118.378 ms 600.0000 400.0000 - 28.72 MB
CalculateDifficultyOsuHundredTimes 715.118 ms 16.6561 ms 48.0566 ms 697.160 ms 10000.0000 8000.0000 - 520.11 MB

@stanriders
stanriders requested review from a team August 5, 2026 18:20
@stanriders stanriders added code quality Fixes code quality. Not visible to the end user. area:difficulty labels Aug 5, 2026

@tsunyoku tsunyoku left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think I prefer this flow overall - in that skills processing the entire map in whichever way it decides definitely has its benefits for mania at the very least. I also like that this means skills can take control of attributes, rather than having to maintain state for its entire existence and expose methods to fetch related data.

That said, some of the details around how Process and the attributes don't feel ideal. I feel like I'd want something with stronger typing but I'm not sure how that'd look like immediately. Maybe my specific review comments will inspire.

Comment on lines +19 to +31
public class AimAttributes : VariableLengthStrainSkillAttributes
{
public required bool WithSliders { get; init; }
public required double DifficultSlidersCount { get; init; }
public required double TopWeightedSlidersCount { get; init; }

public AimAttributes(VariableLengthStrainSkillAttributes baseAttributes)
{
Difficulty = baseAttributes.Difficulty;
ObjectDifficulties = baseAttributes.ObjectDifficulties;
TopWeightedStrainsCount = baseAttributes.TopWeightedStrainsCount;
}
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I'm not sure I like this idea of a class inheriting from T whilst also having a constructor that takes in an object of type T. I suppose your intention here was not to have to assign every attribute in the base skill attributes class but I'm not sure.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

My intention here was to guarantee that base class properties don't get lost on inherited classes initialisation. Maybe I'm trying to be too smart here and we can just leave the responsibility of doing that on the inheritor, I'm not sure

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

As long as every property has a required on it then it should be fine shouldn't it?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

It somehow completely slipped my mind. Changed in bf7cbfe

Comment on lines +147 to +157
public override ISkillAttributes Process()
{
var baseAttributes = (VariableLengthStrainSkillAttributes)base.Process();

return new AimAttributes(baseAttributes)
{
WithSliders = IncludeSliders,
DifficultSlidersCount = getDifficultSliders(),
TopWeightedSlidersCount = countTopWeightedSliders(baseAttributes.Difficulty)
};
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Hmm. I'm not sure. I don't love having to cast base.Process, which may be fixable with some generics on the skill class, but I feel like I'd want to go yet another step further and use interfaces to achieve this instead?

I'm not 100% sure what design I have in my head but it feels weird to have every skill forcefully override Process, call the base Process, cast the result and then return it's own attributes.

@stanriders stanriders Aug 5, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I have tried making ISkill have a generic param for skill attributes, but gave up on it pretty early. If I remember correctly it didn't really solve the issue of multiple layers of inheritence having to do some nasty casts

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:difficulty code quality Fixes code quality. Not visible to the end user. size/XXL

Projects

Status: In Progress

Development

Successfully merging this pull request may close these issues.

2 participants