diff --git a/YARG.Core/Song/Entries/Types/GameplayHasher.cs b/YARG.Core/Song/Entries/Types/GameplayHasher.cs new file mode 100644 index 000000000..cc277c75e --- /dev/null +++ b/YARG.Core/Song/Entries/Types/GameplayHasher.cs @@ -0,0 +1,335 @@ +using System; +using System.IO; + +namespace YARG.Core.Song +{ + /// + /// Computes a hash over the *parsed* chart data () rather + /// than raw source-file bytes. + /// + /// This is intentionally kept separate from : + /// - SongEntry.Hash stays a byte-exact hash of the source file(s), used for the + /// song cache, leaderboards, and anywhere exactness matters. + /// - GameplayHash is computed lazily (only when actually needed, e.g. right after + /// a multiplayer session calls LoadChart()) and is only ever used to widen + /// multiplayer song matching to charts that are gameplay-identical but not + /// byte-identical. + /// + /// Neither SongEntry's fields, its cache serialization, nor the existing scan-time + /// hash call sites are touched by this. + /// + /// A chart matches under this hash if it's the same song approximately: same notes, + /// same timing, same difficulty content. It does NOT require byte-for-byte gameplay + /// equivalence - the goal is "close enough that two players don't look out of sync + /// to each other," not exactness. Known tolerances: + /// - MIDI velocity magnitude (engines only check on/off, see MidiFiveFretPreparser) + /// - notes outside an instrument's valid pitch range (never parsed at all) + /// - legacy RB1/RB2 versus-phrase markers (dropped since RB3, unused by YARG) + /// - sustain tails shorter than a fraction of a beat (quantized away below) + /// - non-gameplay tracks (EVENTS/VENUE/track order) - simply never read here + /// - Strum vs. HOPO on guitar/pro guitar (see NormalizeType) - this DOES change + /// hit mechanics, unlike everything else on this list, but not what's rendered + /// on the highway, so it's tolerated deliberately for lenient matching rather + /// than proven invisible to the engine like the rest of this list. + /// + public static class GameplayHasher + { + public const int HASH_VERSION = 26_08_29_00; // bump: strum/HOPO tolerated for lenient matching (see NormalizeType) + // Every chart is rescaled to this PPQ before hashing, so two files authored + // at different MIDI resolutions (e.g. 480 vs 960) still match as long as the + // underlying timing is the same. SongChart.Resolution is read straight from + // the source file and is NOT normalized upstream, so this has to happen here. + private const uint CANONICAL_RESOLUTION = 480; + + // Sustain lengths are bucketed to this many canonical ticks (a 16th note at + // CANONICAL_RESOLUTION) rather than hashed exactly. This isn't an arbitrary + // safety margin - it's sized from real data: comparing a from-scratch CON + // extraction against Onyx's for the same song showed ~90% of held notes had + // their tail trimmed by exactly 60 ticks (a 32nd note at 480 PPQ) by one tool + // and not the other, with zero effect on tap/sustain classification at + // YARG's default SustainCutoffThreshold (resolution / 3). + // + // A grain equal to the trim itself does NOT absorb it: with floor bucketing, + // sustain / GRAIN, a value of exactly one grain (e.g. 120) and one grain minus + // the trim (e.g. 60) fall in adjacent buckets by construction - they can never + // collide. The grain has to be strictly bigger than the trim, and bucketing has + // to round to the nearest multiple (not floor) so each bucket gets roughly + // symmetric tolerance around its center instead of zero tolerance on one edge. + // A 16th note (twice the observed 32nd-note trim) verified clean against real + // CON/Onyx pairs for this song - every previously-mismatched sustain now lands + // in the same bucket - while still telling apart sustains that differ by a + // musically meaningful amount. If a future extractor trims by something bigger + // than this, re-derive the grain the same way: diff real CON/Onyx sustains for + // a song, and pick a grain comfortably larger than the largest systematic skew. + private const uint SUSTAIN_QUANTIZE = CANONICAL_RESOLUTION / 4; + + private static readonly Difficulty[] ORDERED_DIFFICULTIES = + { + Difficulty.Easy, Difficulty.Medium, Difficulty.Hard, Difficulty.Expert, + }; + + // Strum and HOPO render identically on the highway - only the hit mechanic + // differs (strum required vs. hammer-on/pull-off allowed). For lenient + // multiplayer matching that's tolerable: two players on differently-encoded + // rips of the same song still see the same notes go by. Tap is kept distinct - + // it renders differently (diamond notes) and would look out of place if + // collapsed. + // + // This matters more than a rounding error: on a real CON/Onyx pair for this + // song, 98% of expert guitar chords differed in resolved Strum/HOPO status, + // because one rip encoded its force-flags on non-standard MIDI pitches that + // get silently dropped by the parser (see class doc). Collapsing here is what + // makes that pair - and others like it - match at all. + private static byte NormalizeType(Chart.GuitarNoteType type) + { + if (type == Chart.GuitarNoteType.Strum || type == Chart.GuitarNoteType.Hopo) + { + return (byte) Chart.GuitarNoteType.Hopo; + } + + return (byte) type; + } + + public static HashWrapper Hash(Chart.SongChart chart) + { + using var stream = new MemoryStream(); + using var writer = new BinaryWriter(stream); + + double scale = CANONICAL_RESOLUTION / (double) chart.Resolution; + + // Fixed, deterministic order - matches SongChart's own enumeration order, + // not file/track order, so track shuffling between extractors is a no-op. + foreach (var track in chart.FiveFretTracks) + { + WriteInstrumentTrack(writer, track, scale, WriteGuitarNote); + } + + foreach (var track in chart.SixFretTracks) + { + WriteInstrumentTrack(writer, track, scale, WriteGuitarNote); + } + + foreach (var track in chart.VocalsTracks) + { + WriteVocalsTrack(writer, track, scale); + } + + foreach (var track in chart.DrumsTracks) + { + WriteInstrumentTrack(writer, track, scale, WriteDrumsNote); + } + + WriteInstrumentTrack(writer, chart.EliteDrums, scale, WriteEliteDrumsNote); + + foreach (var track in chart.ProGuitarTracks) + { + WriteInstrumentTrack(writer, track, scale, WriteProGuitarNote); + } + + WriteInstrumentTrack(writer, chart.ProKeys, scale, WriteProKeysNote); + + return HashWrapper.Hash(stream.ToArray()); + } + + // Every non-vocals instrument shares the same track shape: an optional + // per-difficulty note list. This is the one place that shape gets walked - + // each instrument only supplies how to write a single note via writeNote. + private static void WriteInstrumentTrack(BinaryWriter writer, Chart.InstrumentTrack track, + double scale, Action writeNote) + where TNote : Chart.Note + { + if (track.IsEmpty) + { + return; + } + + writer.Write((byte) track.Instrument); + + foreach (var difficulty in ORDERED_DIFFICULTIES) + { + if (!track.TryGetDifficulty(difficulty, out var diff) || diff.Notes.Count == 0) + { + continue; + } + + writer.Write((byte) difficulty); + writer.Write(diff.Notes.Count); + + foreach (var note in diff.Notes) + { + writeNote(writer, note, scale); + } + } + } + + // Rounds to the nearest bucket rather than flooring, so tolerance is spread + // symmetrically (+/- half a grain) instead of only below each multiple. + // Integer-only, no floating point: adding half a grain before dividing is the + // standard round-half-up trick and matches Math.Round's default + // MidpointRounding. See SUSTAIN_QUANTIZE for how the grain size was picked. + // Shared by every note type with a meaningful sustain (guitar, vocals, pro + // guitar, pro keys) - drum-family notes don't call this, since their + // TickLength is always 0 by construction. + private static void WriteQuantizedSustain(BinaryWriter writer, uint tickLength, double scale) + { + uint sustain = (uint) Math.Round(tickLength * scale); + writer.Write((sustain + SUSTAIN_QUANTIZE / 2) / SUSTAIN_QUANTIZE); + } + + private static void WriteGuitarNote(BinaryWriter writer, Chart.GuitarNote note, double scale) + { + writer.Write((uint) Math.Round(note.Tick * scale)); + writer.Write((byte) note.NoteMask); + writer.Write(NormalizeType(note.Type)); // strum / hopo / tap - see DIAGNOSTIC_IGNORE_STRUM_HOPO above + WriteQuantizedSustain(writer, note.TickLength, scale); + } + + private static void WriteVocalsTrack(BinaryWriter writer, Chart.VocalsTrack track, double scale) + { + if (track.IsEmpty) + { + return; + } + + writer.Write((byte) track.Instrument); + + // track.Parts is already voice-ordered (solo vocals: 1 part; harmony: + // HARM1/HARM2/HARM3 at indices 0/1/2) - iterating it directly gives a + // fixed, deterministic order without sorting by VocalNote.HarmonyPart. + foreach (var part in track.Parts) + { + // NOTE: deliberately NOT using VocalsPart.IsEmpty here - that check + // ignores StaticLyricPhrases entirely, and this method used to key its + // own skip logic off it too. That meant any part carrying only + // unpitched/static lyric data (no NotePhrases, no OtherPhrases, no + // TextEvents) got silently skipped, contributing zero bytes to the + // hash. For a song whose only content is a lyrics-only vocals chart - + // no five/six-fret tracks either - that leaves the entire stream + // empty, so every such song collapsed to the same hash. Skip only when + // there is truly nothing (of the two phrase lists this method writes) + // to write. + if (part.NotePhrases.Count == 0 && part.StaticLyricPhrases.Count == 0) + { + continue; + } + + writer.Write(part.NotePhrases.Count); + foreach (var phrase in part.NotePhrases) + { + WriteVocalsPhrase(writer, phrase, scale); + } + + // Static lyric phrases (lyrics displayed without pitch tracking) carry + // no gameplay-relevant pitch data, but they're still real chart content + // that differs from song to song - has to be included or two different + // lyrics-only songs hash identically. + writer.Write(part.StaticLyricPhrases.Count); + foreach (var phrase in part.StaticLyricPhrases) + { + WriteVocalsPhrase(writer, phrase, scale); + } + } + } + + private static void WriteVocalsPhrase(BinaryWriter writer, Chart.VocalsPhrase phrase, double scale) + { + var parent = phrase.PhraseParentNote; + + writer.Write((uint) Math.Round(parent.Tick * scale)); + writer.Write(parent.IsPercussionPhrase); + writer.Write(parent.ChildNotes.Count); + + foreach (var note in parent.ChildNotes) + { + writer.Write((uint) Math.Round(note.Tick * scale)); + writer.Write(note.Type == Chart.VocalNoteType.Percussion); + writer.Write(NormalizePitch(note.Pitch)); + WriteQuantizedSustain(writer, note.TickLength, scale); + } + } + + private static void WriteDrumsNote(BinaryWriter writer, Chart.DrumNote note, double scale) + { + writer.Write((uint) Math.Round(note.Tick * scale)); + // Drum "chords" (e.g. snare+hihat hit together) are stored as + // ChildNotes rather than a NoteMask like guitar, since each + // component can carry its own dynamics/kick-lane flags - AllNotes + // walks the parent plus every child in a fixed order. + writer.Write(note.ChildNotes.Count + 1); + + foreach (var n in note.AllNotes) + { + writer.Write((byte) n.Pad); + writer.Write((byte) n.Type); // Neutral / Ghost / Accent + writer.Write(n.IsDoubleKick); + // Only the kick-lane bits are gameplay-relevant here; StarPowerActivator + // is already implied by the phrase-level StarPower flags on other tracks. + writer.Write((byte) (n.DrumFlags & + (Chart.DrumNoteFlags.KickLane | Chart.DrumNoteFlags.KickLaneStart | + Chart.DrumNoteFlags.KickLaneEnd))); + } + } + + private static void WriteEliteDrumsNote(BinaryWriter writer, Chart.EliteDrumNote note, double scale) + { + writer.Write((uint) Math.Round(note.Tick * scale)); + writer.Write(note.ChildNotes.Count + 1); + + foreach (var n in note.AllNotes) + { + writer.Write((byte) n.Pad); + writer.Write((byte) n.Dynamics); + writer.Write((byte) n.HatState); + writer.Write((byte) n.HatPedalType); + writer.Write(n.IsFlam); + writer.Write(n.IsDoubleKick); + } + } + + private static void WriteProGuitarNote(BinaryWriter writer, Chart.ProGuitarNote note, double scale) + { + writer.Write((uint) Math.Round(note.Tick * scale)); + // String+fret pairs are ChildNotes (unlike five-fret's single NoteMask), + // since each string in a chord needs its own fret number. + writer.Write(note.ChildNotes.Count + 1); + + foreach (var n in note.AllNotes) + { + writer.Write((byte) n.String); + writer.Write((byte) n.Fret); + writer.Write(NormalizeProGuitarType(n.Type)); + writer.Write(n.IsMuted); + WriteQuantizedSustain(writer, n.TickLength, scale); + } + } + + private static void WriteProKeysNote(BinaryWriter writer, Chart.ProKeysNote note, double scale) + { + writer.Write((uint) Math.Round(note.Tick * scale)); + writer.Write(note.NoteMask); + writer.Write(note.IsGlissando); + WriteQuantizedSustain(writer, note.TickLength, scale); + } + + // Same tolerance and rationale as NormalizeType, applied to pro guitar's + // parallel enum. + private static byte NormalizeProGuitarType(Chart.ProGuitarNoteType type) + { + if (type == Chart.ProGuitarNoteType.Strum || type == Chart.ProGuitarNoteType.Hopo) + { + return (byte) Chart.ProGuitarNoteType.Hopo; + } + + return (byte) type; + } + + // Pitch is a float, but every real chart stores whole MIDI note numbers (or -1 + // for unpitched/percussion) - round defensively instead of trusting that, then + // narrow to sbyte since MIDI pitches fit in 0-127 and -1 is the only negative. + private static sbyte NormalizePitch(float pitch) + { + if (pitch < 0) return -1; + return (sbyte) Math.Round(pitch); + } + } +}