commit 0f0431de5a247d13ed46e5bfcdf48f3e109ae672 Author: ProjectQuint Date: Sun Sep 6 17:11:40 2026 +0900 프로젝트 소스 공개 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..68c1cbe --- /dev/null +++ b/.gitignore @@ -0,0 +1,55 @@ +/[Ll]ibrary/ +/[Tt]emp/ +/[Oo]bj/ +/[Bb]uild/ +/[Bb]uilds/ +/[Ll]ogs/ +/[Uu]ser[Ss]ettings/ +/[Mm]emoryCaptures/ +/[Rr]ecordings/ + +.DS_Store + +*.csproj +*.sln +*.slnx +*.user +*.userprefs + +# Local validation output, caches, and working notes. +/_workspace/ +/tmp/ +/TestResults/ +/mono_crash.* +/plan.md +/.release.local.json +.env +.env.local +__pycache__/ +*.py[cod] + +# User-supplied song libraries are never part of the source distribution. +*.[bB][mM][sS] +*.[bB][mM][eE] +*.[bB][mM][lL] +*.[pP][mM][sS] +*.[bB][mM][sS][oO][nN] +*.[wW][aA][vV] +*.[oO][gG][gG] +*.[mM][pP]3 +*.[fF][lL][aA][cC] +*.[mM]4[aA] +*.[aA][iI][fF] +*.[aA][iI][fF][fF] +*.[bB][mM][sS].meta +*.[bB][mM][eE].meta +*.[bB][mM][lL].meta +*.[pP][mM][sS].meta +*.[bB][mM][sS][oO][nN].meta +*.[wW][aA][vV].meta +*.[oO][gG][gG].meta +*.[mM][pP]3.meta +*.[fF][lL][aA][cC].meta +*.[mM]4[aA].meta +*.[aA][iI][fF].meta +*.[aA][iI][fF][fF].meta diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..ddb6ff8 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,5 @@ +{ + "recommendations": [ + "visualstudiotoolsforunity.vstuc" + ] +} diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..da60e25 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,10 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Attach to Unity", + "type": "vstuc", + "request": "attach" + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..e39cdc8 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,71 @@ +{ + "files.exclude": { + "**/.DS_Store": true, + "**/.git": true, + "**/.vs": true, + "**/.gitmodules": true, + "**/.vsconfig": true, + "**/*.booproj": true, + "**/*.pidb": true, + "**/*.suo": true, + "**/*.user": true, + "**/*.userprefs": true, + "**/*.unityproj": true, + "**/*.dll": true, + "**/*.exe": true, + "**/*.pdf": true, + "**/*.mid": true, + "**/*.midi": true, + "**/*.wav": true, + "**/*.gif": true, + "**/*.ico": true, + "**/*.jpg": true, + "**/*.jpeg": true, + "**/*.png": true, + "**/*.psd": true, + "**/*.tga": true, + "**/*.tif": true, + "**/*.tiff": true, + "**/*.3ds": true, + "**/*.3DS": true, + "**/*.fbx": true, + "**/*.FBX": true, + "**/*.lxo": true, + "**/*.LXO": true, + "**/*.ma": true, + "**/*.MA": true, + "**/*.obj": true, + "**/*.OBJ": true, + "**/*.asset": true, + "**/*.cubemap": true, + "**/*.flare": true, + "**/*.mat": true, + "**/*.meta": true, + "**/*.prefab": true, + "**/*.unity": true, + "build/": true, + "Build/": true, + "Library/": true, + "library/": true, + "obj/": true, + "Obj/": true, + "Logs/": true, + "logs/": true, + "ProjectSettings/": true, + "UserSettings/": true, + "temp/": true, + "Temp/": true + }, + "files.associations": { + "*.asset": "yaml", + "*.meta": "yaml", + "*.prefab": "yaml", + "*.unity": "yaml" + }, + "explorer.fileNesting.enabled": true, + "explorer.fileNesting.patterns": { + "*.sln": "*.csproj", + "*.slnx": "*.csproj" + }, + "dotnet.defaultSolution": "ProjectQuint.slnx" +} \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..bbaa49b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,23 @@ +# ProjectQuint Repository Guide + +## What + +- ProjectQuint is a macOS-first 4/5/6/7-key BMS rhythm-game client with optional left scratch, built with Unity 6.3 LTS. +- `Assets/ProjectQuint/Runtime/Core` is pure C# chart/timing/judgement code; Unity input, audio and rendering stay under `Runtime/Unity`. +- BMS packs are external user libraries. Never copy song media into the repository, `Assets`, `StreamingAssets`, or a player build. + +## Why + +- Timing and judgement must remain deterministic and independent from render FPS. +- Scratch is a fixed left lane and is preserved in notes, audio, score, combo and autoplay. MIRROR/RANDOM only transform key lanes. +- Long notes auto-complete while held at the tail. Their scoring ticks use musical 1/16-note boundaries, the default early-release grace is 100 ms, and an early break cannot be recovered by re-pressing. +- `TempoMap` owns beat↔DSP-song-time conversion while `ScrollMap` owns visual position. BPM/STOP handling must not be reintroduced as renderer-local time arithmetic. +- BMS packs must be registered by the user as folders or ZIP files. Keep song indexes isolated by pack ID, but keep difficulty tables in the global table registry and cache them by table ID so the same provider can match charts from any pack. + +## How + +- Unity version: `6000.3.20f1`. +- External editor control uses Unity's official MCP from `com.unity.ai.assistant`; verify the live editor with the `Unity_GetConsoleLogs` tool. +- Run tests headlessly with Unity Test Runner and do not append `-quit` to `-runTests`; Unity 6 exits after the run and `-quit` can terminate before tests start. +- Read editor errors through the official MCP console tool or the explicit Unity `-logFile` used by the current validation run. +- Use `AudioSettings.dspTime` and Input System event timestamps for gameplay clocks; never use frame count as the judgement clock. diff --git a/Assets/DefaultVolumeProfile.asset b/Assets/DefaultVolumeProfile.asset new file mode 100644 index 0000000..8cea4d2 --- /dev/null +++ b/Assets/DefaultVolumeProfile.asset @@ -0,0 +1,798 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &-8186968272690591729 +MonoBehaviour: + m_ObjectHideFlags: 3 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 3eb4b772797da9440885e8bd939e9560, type: 3} + m_Name: ColorCurves + m_EditorClassIdentifier: + active: 1 + master: + m_OverrideState: 1 + m_Value: + k__BackingField: 2 + m_Loop: 0 + m_ZeroValue: 0 + m_Range: 1 + m_Curve: + serializedVersion: 2 + m_Curve: + - serializedVersion: 3 + time: 0 + value: 0 + inSlope: 1 + outSlope: 1 + tangentMode: 0 + weightedMode: 0 + inWeight: 0 + outWeight: 0 + - serializedVersion: 3 + time: 1 + value: 1 + inSlope: 1 + outSlope: 1 + tangentMode: 0 + weightedMode: 0 + inWeight: 0 + outWeight: 0 + m_PreInfinity: 2 + m_PostInfinity: 2 + m_RotationOrder: 4 + red: + m_OverrideState: 1 + m_Value: + k__BackingField: 2 + m_Loop: 0 + m_ZeroValue: 0 + m_Range: 1 + m_Curve: + serializedVersion: 2 + m_Curve: + - serializedVersion: 3 + time: 0 + value: 0 + inSlope: 1 + outSlope: 1 + tangentMode: 0 + weightedMode: 0 + inWeight: 0 + outWeight: 0 + - serializedVersion: 3 + time: 1 + value: 1 + inSlope: 1 + outSlope: 1 + tangentMode: 0 + weightedMode: 0 + inWeight: 0 + outWeight: 0 + m_PreInfinity: 2 + m_PostInfinity: 2 + m_RotationOrder: 4 + green: + m_OverrideState: 1 + m_Value: + k__BackingField: 2 + m_Loop: 0 + m_ZeroValue: 0 + m_Range: 1 + m_Curve: + serializedVersion: 2 + m_Curve: + - serializedVersion: 3 + time: 0 + value: 0 + inSlope: 1 + outSlope: 1 + tangentMode: 0 + weightedMode: 0 + inWeight: 0 + outWeight: 0 + - serializedVersion: 3 + time: 1 + value: 1 + inSlope: 1 + outSlope: 1 + tangentMode: 0 + weightedMode: 0 + inWeight: 0 + outWeight: 0 + m_PreInfinity: 2 + m_PostInfinity: 2 + m_RotationOrder: 4 + blue: + m_OverrideState: 1 + m_Value: + k__BackingField: 2 + m_Loop: 0 + m_ZeroValue: 0 + m_Range: 1 + m_Curve: + serializedVersion: 2 + m_Curve: + - serializedVersion: 3 + time: 0 + value: 0 + inSlope: 1 + outSlope: 1 + tangentMode: 0 + weightedMode: 0 + inWeight: 0 + outWeight: 0 + - serializedVersion: 3 + time: 1 + value: 1 + inSlope: 1 + outSlope: 1 + tangentMode: 0 + weightedMode: 0 + inWeight: 0 + outWeight: 0 + m_PreInfinity: 2 + m_PostInfinity: 2 + m_RotationOrder: 4 + hueVsHue: + m_OverrideState: 1 + m_Value: + k__BackingField: 0 + m_Loop: 1 + m_ZeroValue: 0.5 + m_Range: 1 + m_Curve: + serializedVersion: 2 + m_Curve: [] + m_PreInfinity: 2 + m_PostInfinity: 2 + m_RotationOrder: 4 + hueVsSat: + m_OverrideState: 1 + m_Value: + k__BackingField: 0 + m_Loop: 1 + m_ZeroValue: 0.5 + m_Range: 1 + m_Curve: + serializedVersion: 2 + m_Curve: [] + m_PreInfinity: 2 + m_PostInfinity: 2 + m_RotationOrder: 4 + satVsSat: + m_OverrideState: 1 + m_Value: + k__BackingField: 0 + m_Loop: 0 + m_ZeroValue: 0.5 + m_Range: 1 + m_Curve: + serializedVersion: 2 + m_Curve: [] + m_PreInfinity: 2 + m_PostInfinity: 2 + m_RotationOrder: 4 + lumVsSat: + m_OverrideState: 1 + m_Value: + k__BackingField: 0 + m_Loop: 0 + m_ZeroValue: 0.5 + m_Range: 1 + m_Curve: + serializedVersion: 2 + m_Curve: [] + m_PreInfinity: 2 + m_PostInfinity: 2 + m_RotationOrder: 4 +--- !u!114 &-7645455725216806467 +MonoBehaviour: + m_ObjectHideFlags: 3 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 29fa0085f50d5e54f8144f766051a691, type: 3} + m_Name: FilmGrain + m_EditorClassIdentifier: + active: 1 + type: + m_OverrideState: 1 + m_Value: 0 + intensity: + m_OverrideState: 1 + m_Value: 0 + response: + m_OverrideState: 1 + m_Value: 0.8 + texture: + m_OverrideState: 1 + m_Value: {fileID: 0} +--- !u!114 &-6951754464447674219 +MonoBehaviour: + m_ObjectHideFlags: 3 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 558a8e2b6826cf840aae193990ba9f2e, type: 3} + m_Name: ShadowsMidtonesHighlights + m_EditorClassIdentifier: + active: 1 + shadows: + m_OverrideState: 1 + m_Value: {x: 1, y: 1, z: 1, w: 0} + midtones: + m_OverrideState: 1 + m_Value: {x: 1, y: 1, z: 1, w: 0} + highlights: + m_OverrideState: 1 + m_Value: {x: 1, y: 1, z: 1, w: 0} + shadowsStart: + m_OverrideState: 1 + m_Value: 0 + shadowsEnd: + m_OverrideState: 1 + m_Value: 0.3 + highlightsStart: + m_OverrideState: 1 + m_Value: 0.55 + highlightsEnd: + m_OverrideState: 1 + m_Value: 1 +--- !u!114 &-4210620904635426390 +MonoBehaviour: + m_ObjectHideFlags: 3 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: cdfbdbb87d3286943a057f7791b43141, type: 3} + m_Name: ChannelMixer + m_EditorClassIdentifier: + active: 1 + redOutRedIn: + m_OverrideState: 1 + m_Value: 100 + redOutGreenIn: + m_OverrideState: 1 + m_Value: 0 + redOutBlueIn: + m_OverrideState: 1 + m_Value: 0 + greenOutRedIn: + m_OverrideState: 1 + m_Value: 0 + greenOutGreenIn: + m_OverrideState: 1 + m_Value: 100 + greenOutBlueIn: + m_OverrideState: 1 + m_Value: 0 + blueOutRedIn: + m_OverrideState: 1 + m_Value: 0 + blueOutGreenIn: + m_OverrideState: 1 + m_Value: 0 + blueOutBlueIn: + m_OverrideState: 1 + m_Value: 100 +--- !u!114 &-3978695733392391637 +MonoBehaviour: + m_ObjectHideFlags: 3 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 0b2db86121404754db890f4c8dfe81b2, type: 3} + m_Name: Bloom + m_EditorClassIdentifier: + active: 1 + skipIterations: + m_OverrideState: 1 + m_Value: 1 + threshold: + m_OverrideState: 1 + m_Value: 0.9 + intensity: + m_OverrideState: 1 + m_Value: 0 + scatter: + m_OverrideState: 1 + m_Value: 0.7 + clamp: + m_OverrideState: 1 + m_Value: 65472 + tint: + m_OverrideState: 1 + m_Value: {r: 1, g: 1, b: 1, a: 1} + highQualityFiltering: + m_OverrideState: 1 + m_Value: 0 + filter: + m_OverrideState: 1 + m_Value: 0 + downscale: + m_OverrideState: 1 + m_Value: 0 + maxIterations: + m_OverrideState: 1 + m_Value: 6 + dirtTexture: + m_OverrideState: 1 + m_Value: {fileID: 0} + dimension: 1 + dirtIntensity: + m_OverrideState: 1 + m_Value: 0 +--- !u!114 &-3757877592200609216 +MonoBehaviour: + m_ObjectHideFlags: 3 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 70afe9e12c7a7ed47911bb608a23a8ff, type: 3} + m_Name: SplitToning + m_EditorClassIdentifier: + active: 1 + shadows: + m_OverrideState: 1 + m_Value: {r: 0.5, g: 0.5, b: 0.5, a: 1} + highlights: + m_OverrideState: 1 + m_Value: {r: 0.5, g: 0.5, b: 0.5, a: 1} + balance: + m_OverrideState: 1 + m_Value: 0 +--- !u!114 &-3386560400091697801 +MonoBehaviour: + m_ObjectHideFlags: 3 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: c01700fd266d6914ababb731e09af2eb, type: 3} + m_Name: DepthOfField + m_EditorClassIdentifier: + active: 1 + mode: + m_OverrideState: 1 + m_Value: 0 + gaussianStart: + m_OverrideState: 1 + m_Value: 10 + gaussianEnd: + m_OverrideState: 1 + m_Value: 30 + gaussianMaxRadius: + m_OverrideState: 1 + m_Value: 1 + highQualitySampling: + m_OverrideState: 1 + m_Value: 0 + focusDistance: + m_OverrideState: 1 + m_Value: 10 + aperture: + m_OverrideState: 1 + m_Value: 5.6 + focalLength: + m_OverrideState: 1 + m_Value: 50 + bladeCount: + m_OverrideState: 1 + m_Value: 5 + bladeCurvature: + m_OverrideState: 1 + m_Value: 1 + bladeRotation: + m_OverrideState: 1 + m_Value: 0 +--- !u!114 &-1083910812058648046 +MonoBehaviour: + m_ObjectHideFlags: 3 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 6bd486065ce11414fa40e631affc4900, type: 3} + m_Name: ProbeVolumesOptions + m_EditorClassIdentifier: + active: 1 + normalBias: + m_OverrideState: 1 + m_Value: 0.05 + viewBias: + m_OverrideState: 1 + m_Value: 0.1 + scaleBiasWithMinProbeDistance: + m_OverrideState: 1 + m_Value: 0 + samplingNoise: + m_OverrideState: 1 + m_Value: 0.1 + animateSamplingNoise: + m_OverrideState: 1 + m_Value: 1 + leakReductionMode: + m_OverrideState: 1 + m_Value: 2 + minValidDotProductValue: + m_OverrideState: 1 + m_Value: 0.1 + occlusionOnlyReflectionNormalization: + m_OverrideState: 1 + m_Value: 1 + intensityMultiplier: + m_OverrideState: 1 + m_Value: 1 + skyOcclusionIntensityMultiplier: + m_OverrideState: 1 + m_Value: 1 + worldOffset: + m_OverrideState: 1 + m_Value: {x: 0, y: 0, z: 0} +--- !u!114 &-277880470225475626 +MonoBehaviour: + m_ObjectHideFlags: 3 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: ccf1aba9553839d41ae37dd52e9ebcce, type: 3} + m_Name: MotionBlur + m_EditorClassIdentifier: + active: 1 + mode: + m_OverrideState: 1 + m_Value: 0 + quality: + m_OverrideState: 1 + m_Value: 0 + intensity: + m_OverrideState: 1 + m_Value: 0 + clamp: + m_OverrideState: 1 + m_Value: 0.05 +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: d7fd9488000d3734a9e00ee676215985, type: 3} + m_Name: DefaultVolumeProfile + m_EditorClassIdentifier: + components: + - {fileID: 1247868777620691934} + - {fileID: -277880470225475626} + - {fileID: 2338690907228448291} + - {fileID: 2335868966179733374} + - {fileID: 3460444317416099510} + - {fileID: 7933203426823181213} + - {fileID: 5762530381802164056} + - {fileID: -3386560400091697801} + - {fileID: 156332030701414761} + - {fileID: 9099548931283147545} + - {fileID: -3757877592200609216} + - {fileID: 2332282131777942337} + - {fileID: -4210620904635426390} + - {fileID: -6951754464447674219} + - {fileID: 8549655192814258499} + - {fileID: -7645455725216806467} + - {fileID: -8186968272690591729} + - {fileID: -3978695733392391637} + - {fileID: -1083910812058648046} +--- !u!114 &156332030701414761 +MonoBehaviour: + m_ObjectHideFlags: 3 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 5485954d14dfb9a4c8ead8edb0ded5b1, type: 3} + m_Name: LiftGammaGain + m_EditorClassIdentifier: + active: 1 + lift: + m_OverrideState: 1 + m_Value: {x: 1, y: 1, z: 1, w: 0} + gamma: + m_OverrideState: 1 + m_Value: {x: 1, y: 1, z: 1, w: 0} + gain: + m_OverrideState: 1 + m_Value: {x: 1, y: 1, z: 1, w: 0} +--- !u!114 &1247868777620691934 +MonoBehaviour: + m_ObjectHideFlags: 3 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 97c23e3b12dc18c42a140437e53d3951, type: 3} + m_Name: Tonemapping + m_EditorClassIdentifier: + active: 1 + mode: + m_OverrideState: 1 + m_Value: 0 + neutralHDRRangeReductionMode: + m_OverrideState: 1 + m_Value: 2 + acesPreset: + m_OverrideState: 1 + m_Value: 3 + hueShiftAmount: + m_OverrideState: 1 + m_Value: 0 + detectPaperWhite: + m_OverrideState: 1 + m_Value: 0 + paperWhite: + m_OverrideState: 1 + m_Value: 300 + detectBrightnessLimits: + m_OverrideState: 1 + m_Value: 1 + minNits: + m_OverrideState: 1 + m_Value: 0.005 + maxNits: + m_OverrideState: 1 + m_Value: 1000 +--- !u!114 &2332282131777942337 +MonoBehaviour: + m_ObjectHideFlags: 3 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: e021b4c809a781e468c2988c016ebbea, type: 3} + m_Name: ColorLookup + m_EditorClassIdentifier: + active: 1 + texture: + m_OverrideState: 1 + m_Value: {fileID: 0} + dimension: 1 + contribution: + m_OverrideState: 1 + m_Value: 0 +--- !u!114 &2335868966179733374 +MonoBehaviour: + m_ObjectHideFlags: 3 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: c5e1dc532bcb41949b58bc4f2abfbb7e, type: 3} + m_Name: LensDistortion + m_EditorClassIdentifier: + active: 1 + intensity: + m_OverrideState: 1 + m_Value: 0 + xMultiplier: + m_OverrideState: 1 + m_Value: 1 + yMultiplier: + m_OverrideState: 1 + m_Value: 1 + center: + m_OverrideState: 1 + m_Value: {x: 0.5, y: 0.5} + scale: + m_OverrideState: 1 + m_Value: 1 +--- !u!114 &2338690907228448291 +MonoBehaviour: + m_ObjectHideFlags: 3 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 221518ef91623a7438a71fef23660601, type: 3} + m_Name: WhiteBalance + m_EditorClassIdentifier: + active: 1 + temperature: + m_OverrideState: 1 + m_Value: 0 + tint: + m_OverrideState: 1 + m_Value: 0 +--- !u!114 &3460444317416099510 +MonoBehaviour: + m_ObjectHideFlags: 3 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 81180773991d8724ab7f2d216912b564, type: 3} + m_Name: ChromaticAberration + m_EditorClassIdentifier: + active: 1 + intensity: + m_OverrideState: 1 + m_Value: 0 +--- !u!114 &5762530381802164056 +MonoBehaviour: + m_ObjectHideFlags: 3 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 06437c1ff663d574d9447842ba0a72e4, type: 3} + m_Name: ScreenSpaceLensFlare + m_EditorClassIdentifier: + active: 1 + intensity: + m_OverrideState: 1 + m_Value: 0 + tintColor: + m_OverrideState: 1 + m_Value: {r: 1, g: 1, b: 1, a: 1} + bloomMip: + m_OverrideState: 1 + m_Value: 1 + firstFlareIntensity: + m_OverrideState: 1 + m_Value: 1 + secondaryFlareIntensity: + m_OverrideState: 1 + m_Value: 1 + warpedFlareIntensity: + m_OverrideState: 1 + m_Value: 1 + warpedFlareScale: + m_OverrideState: 1 + m_Value: {x: 1, y: 1} + samples: + m_OverrideState: 1 + m_Value: 1 + sampleDimmer: + m_OverrideState: 1 + m_Value: 0.5 + vignetteEffect: + m_OverrideState: 1 + m_Value: 1 + startingPosition: + m_OverrideState: 1 + m_Value: 1.25 + scale: + m_OverrideState: 1 + m_Value: 1.5 + streaksIntensity: + m_OverrideState: 1 + m_Value: 0 + streaksLength: + m_OverrideState: 1 + m_Value: 0.5 + streaksOrientation: + m_OverrideState: 1 + m_Value: 0 + streaksThreshold: + m_OverrideState: 1 + m_Value: 0.25 + resolution: + m_OverrideState: 1 + m_Value: 4 + chromaticAbberationIntensity: + m_OverrideState: 1 + m_Value: 0.5 +--- !u!114 &7933203426823181213 +MonoBehaviour: + m_ObjectHideFlags: 3 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 66f335fb1ffd8684294ad653bf1c7564, type: 3} + m_Name: ColorAdjustments + m_EditorClassIdentifier: + active: 1 + postExposure: + m_OverrideState: 1 + m_Value: 0 + contrast: + m_OverrideState: 1 + m_Value: 0 + colorFilter: + m_OverrideState: 1 + m_Value: {r: 1, g: 1, b: 1, a: 1} + hueShift: + m_OverrideState: 1 + m_Value: 0 + saturation: + m_OverrideState: 1 + m_Value: 0 +--- !u!114 &8549655192814258499 +MonoBehaviour: + m_ObjectHideFlags: 3 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 899c54efeace73346a0a16faa3afe726, type: 3} + m_Name: Vignette + m_EditorClassIdentifier: + active: 1 + color: + m_OverrideState: 1 + m_Value: {r: 0, g: 0, b: 0, a: 1} + center: + m_OverrideState: 1 + m_Value: {x: 0.5, y: 0.5} + intensity: + m_OverrideState: 1 + m_Value: 0 + smoothness: + m_OverrideState: 1 + m_Value: 0.2 + rounded: + m_OverrideState: 1 + m_Value: 0 +--- !u!114 &9099548931283147545 +MonoBehaviour: + m_ObjectHideFlags: 3 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: fb60a22f311433c4c962b888d1393f88, type: 3} + m_Name: PaniniProjection + m_EditorClassIdentifier: + active: 1 + distance: + m_OverrideState: 1 + m_Value: 0 + cropToFit: + m_OverrideState: 1 + m_Value: 1 diff --git a/Assets/DefaultVolumeProfile.asset.meta b/Assets/DefaultVolumeProfile.asset.meta new file mode 100644 index 0000000..5383e5d --- /dev/null +++ b/Assets/DefaultVolumeProfile.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 3f9215ea0144899419cfbc0957140d3f +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/InputSystem_Actions.inputactions b/Assets/InputSystem_Actions.inputactions new file mode 100644 index 0000000..1a12cb9 --- /dev/null +++ b/Assets/InputSystem_Actions.inputactions @@ -0,0 +1,1057 @@ +{ + "name": "InputSystem_Actions", + "maps": [ + { + "name": "Player", + "id": "df70fa95-8a34-4494-b137-73ab6b9c7d37", + "actions": [ + { + "name": "Move", + "type": "Value", + "id": "351f2ccd-1f9f-44bf-9bec-d62ac5c5f408", + "expectedControlType": "Vector2", + "processors": "", + "interactions": "", + "initialStateCheck": true + }, + { + "name": "Look", + "type": "Value", + "id": "6b444451-8a00-4d00-a97e-f47457f736a8", + "expectedControlType": "Vector2", + "processors": "", + "interactions": "", + "initialStateCheck": true + }, + { + "name": "Attack", + "type": "Button", + "id": "6c2ab1b8-8984-453a-af3d-a3c78ae1679a", + "expectedControlType": "Button", + "processors": "", + "interactions": "", + "initialStateCheck": false + }, + { + "name": "Interact", + "type": "Button", + "id": "852140f2-7766-474d-8707-702459ba45f3", + "expectedControlType": "Button", + "processors": "", + "interactions": "Hold", + "initialStateCheck": false + }, + { + "name": "Crouch", + "type": "Button", + "id": "27c5f898-bc57-4ee1-8800-db469aca5fe3", + "expectedControlType": "Button", + "processors": "", + "interactions": "", + "initialStateCheck": false + }, + { + "name": "Jump", + "type": "Button", + "id": "f1ba0d36-48eb-4cd5-b651-1c94a6531f70", + "expectedControlType": "Button", + "processors": "", + "interactions": "", + "initialStateCheck": false + }, + { + "name": "Previous", + "type": "Button", + "id": "2776c80d-3c14-4091-8c56-d04ced07a2b0", + "expectedControlType": "Button", + "processors": "", + "interactions": "", + "initialStateCheck": false + }, + { + "name": "Next", + "type": "Button", + "id": "b7230bb6-fc9b-4f52-8b25-f5e19cb2c2ba", + "expectedControlType": "Button", + "processors": "", + "interactions": "", + "initialStateCheck": false + }, + { + "name": "Sprint", + "type": "Button", + "id": "641cd816-40e6-41b4-8c3d-04687c349290", + "expectedControlType": "Button", + "processors": "", + "interactions": "", + "initialStateCheck": false + } + ], + "bindings": [ + { + "name": "", + "id": "978bfe49-cc26-4a3d-ab7b-7d7a29327403", + "path": "/leftStick", + "interactions": "", + "processors": "", + "groups": ";Gamepad", + "action": "Move", + "isComposite": false, + "isPartOfComposite": false + }, + { + "name": "WASD", + "id": "00ca640b-d935-4593-8157-c05846ea39b3", + "path": "Dpad", + "interactions": "", + "processors": "", + "groups": "", + "action": "Move", + "isComposite": true, + "isPartOfComposite": false + }, + { + "name": "up", + "id": "e2062cb9-1b15-46a2-838c-2f8d72a0bdd9", + "path": "/w", + "interactions": "", + "processors": "", + "groups": ";Keyboard&Mouse", + "action": "Move", + "isComposite": false, + "isPartOfComposite": true + }, + { + "name": "up", + "id": "8180e8bd-4097-4f4e-ab88-4523101a6ce9", + "path": "/upArrow", + "interactions": "", + "processors": "", + "groups": ";Keyboard&Mouse", + "action": "Move", + "isComposite": false, + "isPartOfComposite": true + }, + { + "name": "down", + "id": "320bffee-a40b-4347-ac70-c210eb8bc73a", + "path": "/s", + "interactions": "", + "processors": "", + "groups": ";Keyboard&Mouse", + "action": "Move", + "isComposite": false, + "isPartOfComposite": true + }, + { + "name": "down", + "id": "1c5327b5-f71c-4f60-99c7-4e737386f1d1", + "path": "/downArrow", + "interactions": "", + "processors": "", + "groups": ";Keyboard&Mouse", + "action": "Move", + "isComposite": false, + "isPartOfComposite": true + }, + { + "name": "left", + "id": "d2581a9b-1d11-4566-b27d-b92aff5fabbc", + "path": "/a", + "interactions": "", + "processors": "", + "groups": ";Keyboard&Mouse", + "action": "Move", + "isComposite": false, + "isPartOfComposite": true + }, + { + "name": "left", + "id": "2e46982e-44cc-431b-9f0b-c11910bf467a", + "path": "/leftArrow", + "interactions": "", + "processors": "", + "groups": ";Keyboard&Mouse", + "action": "Move", + "isComposite": false, + "isPartOfComposite": true + }, + { + "name": "right", + "id": "fcfe95b8-67b9-4526-84b5-5d0bc98d6400", + "path": "/d", + "interactions": "", + "processors": "", + "groups": ";Keyboard&Mouse", + "action": "Move", + "isComposite": false, + "isPartOfComposite": true + }, + { + "name": "right", + "id": "77bff152-3580-4b21-b6de-dcd0c7e41164", + "path": "/rightArrow", + "interactions": "", + "processors": "", + "groups": ";Keyboard&Mouse", + "action": "Move", + "isComposite": false, + "isPartOfComposite": true + }, + { + "name": "", + "id": "1635d3fe-58b6-4ba9-a4e2-f4b964f6b5c8", + "path": "/{Primary2DAxis}", + "interactions": "", + "processors": "", + "groups": "XR", + "action": "Move", + "isComposite": false, + "isPartOfComposite": false + }, + { + "name": "", + "id": "3ea4d645-4504-4529-b061-ab81934c3752", + "path": "/stick", + "interactions": "", + "processors": "", + "groups": "Joystick", + "action": "Move", + "isComposite": false, + "isPartOfComposite": false + }, + { + "name": "", + "id": "c1f7a91b-d0fd-4a62-997e-7fb9b69bf235", + "path": "/rightStick", + "interactions": "", + "processors": "", + "groups": ";Gamepad", + "action": "Look", + "isComposite": false, + "isPartOfComposite": false + }, + { + "name": "", + "id": "8c8e490b-c610-4785-884f-f04217b23ca4", + "path": "/delta", + "interactions": "", + "processors": "", + "groups": ";Keyboard&Mouse;Touch", + "action": "Look", + "isComposite": false, + "isPartOfComposite": false + }, + { + "name": "", + "id": "3e5f5442-8668-4b27-a940-df99bad7e831", + "path": "/{Hatswitch}", + "interactions": "", + "processors": "", + "groups": "Joystick", + "action": "Look", + "isComposite": false, + "isPartOfComposite": false + }, + { + "name": "", + "id": "143bb1cd-cc10-4eca-a2f0-a3664166fe91", + "path": "/buttonWest", + "interactions": "", + "processors": "", + "groups": ";Gamepad", + "action": "Attack", + "isComposite": false, + "isPartOfComposite": false + }, + { + "name": "", + "id": "05f6913d-c316-48b2-a6bb-e225f14c7960", + "path": "/leftButton", + "interactions": "", + "processors": "", + "groups": ";Keyboard&Mouse", + "action": "Attack", + "isComposite": false, + "isPartOfComposite": false + }, + { + "name": "", + "id": "886e731e-7071-4ae4-95c0-e61739dad6fd", + "path": "/primaryTouch/tap", + "interactions": "", + "processors": "", + "groups": ";Touch", + "action": "Attack", + "isComposite": false, + "isPartOfComposite": false + }, + { + "name": "", + "id": "ee3d0cd2-254e-47a7-a8cb-bc94d9658c54", + "path": "/trigger", + "interactions": "", + "processors": "", + "groups": "Joystick", + "action": "Attack", + "isComposite": false, + "isPartOfComposite": false + }, + { + "name": "", + "id": "8255d333-5683-4943-a58a-ccb207ff1dce", + "path": "/{PrimaryAction}", + "interactions": "", + "processors": "", + "groups": "XR", + "action": "Attack", + "isComposite": false, + "isPartOfComposite": false + }, + { + "name": "", + "id": "b3c1c7f0-bd20-4ee7-a0f1-899b24bca6d7", + "path": "/enter", + "interactions": "", + "processors": "", + "groups": "Keyboard&Mouse", + "action": "Attack", + "isComposite": false, + "isPartOfComposite": false + }, + { + "name": "", + "id": "cbac6039-9c09-46a1-b5f2-4e5124ccb5ed", + "path": "/2", + "interactions": "", + "processors": "", + "groups": "Keyboard&Mouse", + "action": "Next", + "isComposite": false, + "isPartOfComposite": false + }, + { + "name": "", + "id": "e15ca19d-e649-4852-97d5-7fe8ccc44e94", + "path": "/dpad/right", + "interactions": "", + "processors": "", + "groups": "Gamepad", + "action": "Next", + "isComposite": false, + "isPartOfComposite": false + }, + { + "name": "", + "id": "f2e9ba44-c423-42a7-ad56-f20975884794", + "path": "/leftShift", + "interactions": "", + "processors": "", + "groups": "Keyboard&Mouse", + "action": "Sprint", + "isComposite": false, + "isPartOfComposite": false + }, + { + "name": "", + "id": "8cbb2f4b-a784-49cc-8d5e-c010b8c7f4e6", + "path": "/leftStickPress", + "interactions": "", + "processors": "", + "groups": "Gamepad", + "action": "Sprint", + "isComposite": false, + "isPartOfComposite": false + }, + { + "name": "", + "id": "d8bf24bf-3f2f-4160-a97c-38ec1eb520ba", + "path": "/trigger", + "interactions": "", + "processors": "", + "groups": "XR", + "action": "Sprint", + "isComposite": false, + "isPartOfComposite": false + }, + { + "name": "", + "id": "eb40bb66-4559-4dfa-9a2f-820438abb426", + "path": "/space", + "interactions": "", + "processors": "", + "groups": "Keyboard&Mouse", + "action": "Jump", + "isComposite": false, + "isPartOfComposite": false + }, + { + "name": "", + "id": "daba33a1-ad0c-4742-a909-43ad1cdfbeb6", + "path": "/buttonSouth", + "interactions": "", + "processors": "", + "groups": "Gamepad", + "action": "Jump", + "isComposite": false, + "isPartOfComposite": false + }, + { + "name": "", + "id": "603f3daf-40bd-4854-8724-93e8017f59e3", + "path": "/secondaryButton", + "interactions": "", + "processors": "", + "groups": "XR", + "action": "Jump", + "isComposite": false, + "isPartOfComposite": false + }, + { + "name": "", + "id": "1534dc16-a6aa-499d-9c3a-22b47347b52a", + "path": "/1", + "interactions": "", + "processors": "", + "groups": "Keyboard&Mouse", + "action": "Previous", + "isComposite": false, + "isPartOfComposite": false + }, + { + "name": "", + "id": "25060bbd-a3a6-476e-8fba-45ae484aad05", + "path": "/dpad/left", + "interactions": "", + "processors": "", + "groups": "Gamepad", + "action": "Previous", + "isComposite": false, + "isPartOfComposite": false + }, + { + "name": "", + "id": "1c04ea5f-b012-41d1-a6f7-02e963b52893", + "path": "/e", + "interactions": "", + "processors": "", + "groups": "Keyboard&Mouse", + "action": "Interact", + "isComposite": false, + "isPartOfComposite": false + }, + { + "name": "", + "id": "b3f66d0b-7751-423f-908b-a11c5bd95930", + "path": "/buttonNorth", + "interactions": "", + "processors": "", + "groups": "Gamepad", + "action": "Interact", + "isComposite": false, + "isPartOfComposite": false + }, + { + "name": "", + "id": "4f4649ac-64a8-4a73-af11-b3faef356a4d", + "path": "/buttonEast", + "interactions": "", + "processors": "", + "groups": "Gamepad", + "action": "Crouch", + "isComposite": false, + "isPartOfComposite": false + }, + { + "name": "", + "id": "36e52cba-0905-478e-a818-f4bfcb9f3b9a", + "path": "/c", + "interactions": "", + "processors": "", + "groups": "Keyboard&Mouse", + "action": "Crouch", + "isComposite": false, + "isPartOfComposite": false + } + ] + }, + { + "name": "UI", + "id": "272f6d14-89ba-496f-b7ff-215263d3219f", + "actions": [ + { + "name": "Navigate", + "type": "PassThrough", + "id": "c95b2375-e6d9-4b88-9c4c-c5e76515df4b", + "expectedControlType": "Vector2", + "processors": "", + "interactions": "", + "initialStateCheck": false + }, + { + "name": "Submit", + "type": "Button", + "id": "7607c7b6-cd76-4816-beef-bd0341cfe950", + "expectedControlType": "Button", + "processors": "", + "interactions": "", + "initialStateCheck": false + }, + { + "name": "Cancel", + "type": "Button", + "id": "15cef263-9014-4fd5-94d9-4e4a6234a6ef", + "expectedControlType": "Button", + "processors": "", + "interactions": "", + "initialStateCheck": false + }, + { + "name": "Point", + "type": "PassThrough", + "id": "32b35790-4ed0-4e9a-aa41-69ac6d629449", + "expectedControlType": "Vector2", + "processors": "", + "interactions": "", + "initialStateCheck": true + }, + { + "name": "Click", + "type": "PassThrough", + "id": "3c7022bf-7922-4f7c-a998-c437916075ad", + "expectedControlType": "Button", + "processors": "", + "interactions": "", + "initialStateCheck": true + }, + { + "name": "RightClick", + "type": "PassThrough", + "id": "44b200b1-1557-4083-816c-b22cbdf77ddf", + "expectedControlType": "Button", + "processors": "", + "interactions": "", + "initialStateCheck": false + }, + { + "name": "MiddleClick", + "type": "PassThrough", + "id": "dad70c86-b58c-4b17-88ad-f5e53adf419e", + "expectedControlType": "Button", + "processors": "", + "interactions": "", + "initialStateCheck": false + }, + { + "name": "ScrollWheel", + "type": "PassThrough", + "id": "0489e84a-4833-4c40-bfae-cea84b696689", + "expectedControlType": "Vector2", + "processors": "", + "interactions": "", + "initialStateCheck": false + }, + { + "name": "TrackedDevicePosition", + "type": "PassThrough", + "id": "24908448-c609-4bc3-a128-ea258674378a", + "expectedControlType": "Vector3", + "processors": "", + "interactions": "", + "initialStateCheck": false + }, + { + "name": "TrackedDeviceOrientation", + "type": "PassThrough", + "id": "9caa3d8a-6b2f-4e8e-8bad-6ede561bd9be", + "expectedControlType": "Quaternion", + "processors": "", + "interactions": "", + "initialStateCheck": false + } + ], + "bindings": [ + { + "name": "Gamepad", + "id": "809f371f-c5e2-4e7a-83a1-d867598f40dd", + "path": "2DVector", + "interactions": "", + "processors": "", + "groups": "", + "action": "Navigate", + "isComposite": true, + "isPartOfComposite": false + }, + { + "name": "up", + "id": "14a5d6e8-4aaf-4119-a9ef-34b8c2c548bf", + "path": "/leftStick/up", + "interactions": "", + "processors": "", + "groups": ";Gamepad", + "action": "Navigate", + "isComposite": false, + "isPartOfComposite": true + }, + { + "name": "up", + "id": "9144cbe6-05e1-4687-a6d7-24f99d23dd81", + "path": "/rightStick/up", + "interactions": "", + "processors": "", + "groups": ";Gamepad", + "action": "Navigate", + "isComposite": false, + "isPartOfComposite": true + }, + { + "name": "down", + "id": "2db08d65-c5fb-421b-983f-c71163608d67", + "path": "/leftStick/down", + "interactions": "", + "processors": "", + "groups": ";Gamepad", + "action": "Navigate", + "isComposite": false, + "isPartOfComposite": true + }, + { + "name": "down", + "id": "58748904-2ea9-4a80-8579-b500e6a76df8", + "path": "/rightStick/down", + "interactions": "", + "processors": "", + "groups": ";Gamepad", + "action": "Navigate", + "isComposite": false, + "isPartOfComposite": true + }, + { + "name": "left", + "id": "8ba04515-75aa-45de-966d-393d9bbd1c14", + "path": "/leftStick/left", + "interactions": "", + "processors": "", + "groups": ";Gamepad", + "action": "Navigate", + "isComposite": false, + "isPartOfComposite": true + }, + { + "name": "left", + "id": "712e721c-bdfb-4b23-a86c-a0d9fcfea921", + "path": "/rightStick/left", + "interactions": "", + "processors": "", + "groups": ";Gamepad", + "action": "Navigate", + "isComposite": false, + "isPartOfComposite": true + }, + { + "name": "right", + "id": "fcd248ae-a788-4676-a12e-f4d81205600b", + "path": "/leftStick/right", + "interactions": "", + "processors": "", + "groups": ";Gamepad", + "action": "Navigate", + "isComposite": false, + "isPartOfComposite": true + }, + { + "name": "right", + "id": "1f04d9bc-c50b-41a1-bfcc-afb75475ec20", + "path": "/rightStick/right", + "interactions": "", + "processors": "", + "groups": ";Gamepad", + "action": "Navigate", + "isComposite": false, + "isPartOfComposite": true + }, + { + "name": "", + "id": "fb8277d4-c5cd-4663-9dc7-ee3f0b506d90", + "path": "/dpad", + "interactions": "", + "processors": "", + "groups": ";Gamepad", + "action": "Navigate", + "isComposite": false, + "isPartOfComposite": false + }, + { + "name": "Joystick", + "id": "e25d9774-381c-4a61-b47c-7b6b299ad9f9", + "path": "2DVector", + "interactions": "", + "processors": "", + "groups": "", + "action": "Navigate", + "isComposite": true, + "isPartOfComposite": false + }, + { + "name": "up", + "id": "3db53b26-6601-41be-9887-63ac74e79d19", + "path": "/stick/up", + "interactions": "", + "processors": "", + "groups": "Joystick", + "action": "Navigate", + "isComposite": false, + "isPartOfComposite": true + }, + { + "name": "down", + "id": "0cb3e13e-3d90-4178-8ae6-d9c5501d653f", + "path": "/stick/down", + "interactions": "", + "processors": "", + "groups": "Joystick", + "action": "Navigate", + "isComposite": false, + "isPartOfComposite": true + }, + { + "name": "left", + "id": "0392d399-f6dd-4c82-8062-c1e9c0d34835", + "path": "/stick/left", + "interactions": "", + "processors": "", + "groups": "Joystick", + "action": "Navigate", + "isComposite": false, + "isPartOfComposite": true + }, + { + "name": "right", + "id": "942a66d9-d42f-43d6-8d70-ecb4ba5363bc", + "path": "/stick/right", + "interactions": "", + "processors": "", + "groups": "Joystick", + "action": "Navigate", + "isComposite": false, + "isPartOfComposite": true + }, + { + "name": "Keyboard", + "id": "ff527021-f211-4c02-933e-5976594c46ed", + "path": "2DVector", + "interactions": "", + "processors": "", + "groups": "", + "action": "Navigate", + "isComposite": true, + "isPartOfComposite": false + }, + { + "name": "up", + "id": "563fbfdd-0f09-408d-aa75-8642c4f08ef0", + "path": "/w", + "interactions": "", + "processors": "", + "groups": "Keyboard&Mouse", + "action": "Navigate", + "isComposite": false, + "isPartOfComposite": true + }, + { + "name": "up", + "id": "eb480147-c587-4a33-85ed-eb0ab9942c43", + "path": "/upArrow", + "interactions": "", + "processors": "", + "groups": "Keyboard&Mouse", + "action": "Navigate", + "isComposite": false, + "isPartOfComposite": true + }, + { + "name": "down", + "id": "2bf42165-60bc-42ca-8072-8c13ab40239b", + "path": "/s", + "interactions": "", + "processors": "", + "groups": "Keyboard&Mouse", + "action": "Navigate", + "isComposite": false, + "isPartOfComposite": true + }, + { + "name": "down", + "id": "85d264ad-e0a0-4565-b7ff-1a37edde51ac", + "path": "/downArrow", + "interactions": "", + "processors": "", + "groups": "Keyboard&Mouse", + "action": "Navigate", + "isComposite": false, + "isPartOfComposite": true + }, + { + "name": "left", + "id": "74214943-c580-44e4-98eb-ad7eebe17902", + "path": "/a", + "interactions": "", + "processors": "", + "groups": "Keyboard&Mouse", + "action": "Navigate", + "isComposite": false, + "isPartOfComposite": true + }, + { + "name": "left", + "id": "cea9b045-a000-445b-95b8-0c171af70a3b", + "path": "/leftArrow", + "interactions": "", + "processors": "", + "groups": "Keyboard&Mouse", + "action": "Navigate", + "isComposite": false, + "isPartOfComposite": true + }, + { + "name": "right", + "id": "8607c725-d935-4808-84b1-8354e29bab63", + "path": "/d", + "interactions": "", + "processors": "", + "groups": "Keyboard&Mouse", + "action": "Navigate", + "isComposite": false, + "isPartOfComposite": true + }, + { + "name": "right", + "id": "4cda81dc-9edd-4e03-9d7c-a71a14345d0b", + "path": "/rightArrow", + "interactions": "", + "processors": "", + "groups": "Keyboard&Mouse", + "action": "Navigate", + "isComposite": false, + "isPartOfComposite": true + }, + { + "name": "", + "id": "9e92bb26-7e3b-4ec4-b06b-3c8f8e498ddc", + "path": "*/{Submit}", + "interactions": "", + "processors": "", + "groups": "Keyboard&Mouse;Gamepad;Touch;Joystick;XR", + "action": "Submit", + "isComposite": false, + "isPartOfComposite": false + }, + { + "name": "", + "id": "82627dcc-3b13-4ba9-841d-e4b746d6553e", + "path": "*/{Cancel}", + "interactions": "", + "processors": "", + "groups": "Keyboard&Mouse;Gamepad;Touch;Joystick;XR", + "action": "Cancel", + "isComposite": false, + "isPartOfComposite": false + }, + { + "name": "", + "id": "c52c8e0b-8179-41d3-b8a1-d149033bbe86", + "path": "/position", + "interactions": "", + "processors": "", + "groups": "Keyboard&Mouse", + "action": "Point", + "isComposite": false, + "isPartOfComposite": false + }, + { + "name": "", + "id": "e1394cbc-336e-44ce-9ea8-6007ed6193f7", + "path": "/position", + "interactions": "", + "processors": "", + "groups": "Keyboard&Mouse", + "action": "Point", + "isComposite": false, + "isPartOfComposite": false + }, + { + "name": "", + "id": "5693e57a-238a-46ed-b5ae-e64e6e574302", + "path": "/touch*/position", + "interactions": "", + "processors": "", + "groups": "Touch", + "action": "Point", + "isComposite": false, + "isPartOfComposite": false + }, + { + "name": "", + "id": "4faf7dc9-b979-4210-aa8c-e808e1ef89f5", + "path": "/leftButton", + "interactions": "", + "processors": "", + "groups": ";Keyboard&Mouse", + "action": "Click", + "isComposite": false, + "isPartOfComposite": false + }, + { + "name": "", + "id": "8d66d5ba-88d7-48e6-b1cd-198bbfef7ace", + "path": "/tip", + "interactions": "", + "processors": "", + "groups": ";Keyboard&Mouse", + "action": "Click", + "isComposite": false, + "isPartOfComposite": false + }, + { + "name": "", + "id": "47c2a644-3ebc-4dae-a106-589b7ca75b59", + "path": "/touch*/press", + "interactions": "", + "processors": "", + "groups": "Touch", + "action": "Click", + "isComposite": false, + "isPartOfComposite": false + }, + { + "name": "", + "id": "bb9e6b34-44bf-4381-ac63-5aa15d19f677", + "path": "/trigger", + "interactions": "", + "processors": "", + "groups": "XR", + "action": "Click", + "isComposite": false, + "isPartOfComposite": false + }, + { + "name": "", + "id": "38c99815-14ea-4617-8627-164d27641299", + "path": "/scroll", + "interactions": "", + "processors": "", + "groups": ";Keyboard&Mouse", + "action": "ScrollWheel", + "isComposite": false, + "isPartOfComposite": false + }, + { + "name": "", + "id": "4c191405-5738-4d4b-a523-c6a301dbf754", + "path": "/rightButton", + "interactions": "", + "processors": "", + "groups": "Keyboard&Mouse", + "action": "RightClick", + "isComposite": false, + "isPartOfComposite": false + }, + { + "name": "", + "id": "24066f69-da47-44f3-a07e-0015fb02eb2e", + "path": "/middleButton", + "interactions": "", + "processors": "", + "groups": "Keyboard&Mouse", + "action": "MiddleClick", + "isComposite": false, + "isPartOfComposite": false + }, + { + "name": "", + "id": "7236c0d9-6ca3-47cf-a6ee-a97f5b59ea77", + "path": "/devicePosition", + "interactions": "", + "processors": "", + "groups": "XR", + "action": "TrackedDevicePosition", + "isComposite": false, + "isPartOfComposite": false + }, + { + "name": "", + "id": "23e01e3a-f935-4948-8d8b-9bcac77714fb", + "path": "/deviceRotation", + "interactions": "", + "processors": "", + "groups": "XR", + "action": "TrackedDeviceOrientation", + "isComposite": false, + "isPartOfComposite": false + } + ] + } + ], + "controlSchemes": [ + { + "name": "Keyboard&Mouse", + "bindingGroup": "Keyboard&Mouse", + "devices": [ + { + "devicePath": "", + "isOptional": false, + "isOR": false + }, + { + "devicePath": "", + "isOptional": false, + "isOR": false + } + ] + }, + { + "name": "Gamepad", + "bindingGroup": "Gamepad", + "devices": [ + { + "devicePath": "", + "isOptional": false, + "isOR": false + } + ] + }, + { + "name": "Touch", + "bindingGroup": "Touch", + "devices": [ + { + "devicePath": "", + "isOptional": false, + "isOR": false + } + ] + }, + { + "name": "Joystick", + "bindingGroup": "Joystick", + "devices": [ + { + "devicePath": "", + "isOptional": false, + "isOR": false + } + ] + }, + { + "name": "XR", + "bindingGroup": "XR", + "devices": [ + { + "devicePath": "", + "isOptional": false, + "isOR": false + } + ] + } + ] +} \ No newline at end of file diff --git a/Assets/InputSystem_Actions.inputactions.meta b/Assets/InputSystem_Actions.inputactions.meta new file mode 100644 index 0000000..e786d3e --- /dev/null +++ b/Assets/InputSystem_Actions.inputactions.meta @@ -0,0 +1,14 @@ +fileFormatVersion: 2 +guid: 2bcd2660ca9b64942af0de543d8d7100 +ScriptedImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 2 + userData: + assetBundleName: + assetBundleVariant: + script: {fileID: 11500000, guid: 8404be70184654265930450def6a9037, type: 3} + generateWrapperCode: 0 + wrapperCodePath: + wrapperClassName: + wrapperCodeNamespace: diff --git a/Assets/Plugins.meta b/Assets/Plugins.meta new file mode 100644 index 0000000..253803e --- /dev/null +++ b/Assets/Plugins.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 2520ca8182cfd49cabc3a8e7a80706b6 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Plugins/FMOD.meta b/Assets/Plugins/FMOD.meta new file mode 100644 index 0000000..aab6de5 --- /dev/null +++ b/Assets/Plugins/FMOD.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 2cac0cde16bcd41db91aae47d021c3cf +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Plugins/FMOD/Cache.meta b/Assets/Plugins/FMOD/Cache.meta new file mode 100644 index 0000000..11def91 --- /dev/null +++ b/Assets/Plugins/FMOD/Cache.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 501e3597be9954100bdbb5e7f8a574e2 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Plugins/FMOD/Cache/Editor.meta b/Assets/Plugins/FMOD/Cache/Editor.meta new file mode 100644 index 0000000..f1debb5 --- /dev/null +++ b/Assets/Plugins/FMOD/Cache/Editor.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: cfff49ae6cc144318ba88c227fc3e88b +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Plugins/FMOD/Cache/Editor/FMODStudioCache.asset b/Assets/Plugins/FMOD/Cache/Editor/FMODStudioCache.asset new file mode 100644 index 0000000..34c3903 --- /dev/null +++ b/Assets/Plugins/FMOD/Cache/Editor/FMODStudioCache.asset @@ -0,0 +1,22 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: d32cf7c32a3ed8347bac48ef5ed56d82, type: 3} + m_Name: FMODStudioCache + m_EditorClassIdentifier: FMODUnityEditor::FMODUnity.EventCache + EditorBanks: [] + EditorEvents: [] + EditorParameters: [] + MasterBanks: [] + StringsBanks: [] + cacheVersion: 131860 + cacheTime: 0 + SerializableEventsDict: [] diff --git a/Assets/Plugins/FMOD/Cache/Editor/FMODStudioCache.asset.meta b/Assets/Plugins/FMOD/Cache/Editor/FMODStudioCache.asset.meta new file mode 100644 index 0000000..52517ab --- /dev/null +++ b/Assets/Plugins/FMOD/Cache/Editor/FMODStudioCache.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: b688e6cf157394eb994baa18eb318210 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Plugins/FMOD/Resources.meta b/Assets/Plugins/FMOD/Resources.meta new file mode 100644 index 0000000..5531f67 --- /dev/null +++ b/Assets/Plugins/FMOD/Resources.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 10238152fc3374e21808394d5539c5e9 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Plugins/FMOD/Resources/FMODStudioSettings.asset b/Assets/Plugins/FMOD/Resources/FMODStudioSettings.asset new file mode 100644 index 0000000..b1ee800 --- /dev/null +++ b/Assets/Plugins/FMOD/Resources/FMODStudioSettings.asset @@ -0,0 +1,956 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &-8876659224941116795 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: d92d0baf34dae0e4ea032a42bd6107c0, type: 3} + m_Name: FMODStudioSettingsPlatform + m_EditorClassIdentifier: FMODUnity::FMODUnity.PlatformMac + identifier: 52eb9df5db46521439908db3a29a1bbb + parentIdentifier: default + active: 0 + Properties: + LiveUpdate: + Value: 0 + HasValue: 0 + LiveUpdatePort: + Value: 0 + HasValue: 0 + Overlay: + Value: 0 + HasValue: 0 + OverlayPosition: + Value: 0 + HasValue: 0 + OverlayFontSize: + Value: 0 + HasValue: 0 + Logging: + Value: 0 + HasValue: 0 + SampleRate: + Value: 0 + HasValue: 0 + BuildDirectory: + Value: + HasValue: 0 + SpeakerMode: + Value: 0 + HasValue: 0 + VirtualChannelCount: + Value: 0 + HasValue: 0 + RealChannelCount: + Value: 0 + HasValue: 0 + DSPBufferLength: + Value: 0 + HasValue: 0 + DSPBufferCount: + Value: 0 + HasValue: 0 + Plugins: + Value: [] + HasValue: 0 + StaticPlugins: + Value: [] + HasValue: 0 + CallbackHandler: + Value: {fileID: 0} + HasValue: 0 + OutputTypeName: + threadAffinities: + Value: [] + HasValue: 0 + displaySortOrder: 0 + childIdentifiers: [] + codecChannels: + Value: [] + HasValue: 0 +--- !u!114 &-6797349823870411825 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 97ba6cc2660c0ca498540d254701057a, type: 3} + m_Name: FMODStudioSettingsPlatform + m_EditorClassIdentifier: FMODUnity::FMODUnity.PlatformAndroid + identifier: 2fea114e74ecf3c4f920e1d5cc1c4c40 + parentIdentifier: default + active: 0 + Properties: + LiveUpdate: + Value: 0 + HasValue: 0 + LiveUpdatePort: + Value: 0 + HasValue: 0 + Overlay: + Value: 0 + HasValue: 0 + OverlayPosition: + Value: 0 + HasValue: 0 + OverlayFontSize: + Value: 0 + HasValue: 0 + Logging: + Value: 0 + HasValue: 0 + SampleRate: + Value: 0 + HasValue: 0 + BuildDirectory: + Value: + HasValue: 0 + SpeakerMode: + Value: 0 + HasValue: 0 + VirtualChannelCount: + Value: 0 + HasValue: 0 + RealChannelCount: + Value: 0 + HasValue: 0 + DSPBufferLength: + Value: 0 + HasValue: 0 + DSPBufferCount: + Value: 0 + HasValue: 0 + Plugins: + Value: [] + HasValue: 0 + StaticPlugins: + Value: [] + HasValue: 0 + CallbackHandler: + Value: {fileID: 0} + HasValue: 0 + OutputTypeName: + threadAffinities: + Value: [] + HasValue: 0 + displaySortOrder: 0 + childIdentifiers: [] + codecChannels: + Value: [] + HasValue: 0 +--- !u!114 &-3793632861967256216 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: bc1f51bc35d549941904cf062bae93a3, type: 3} + m_Name: FMODStudioSettingsPlatform + m_EditorClassIdentifier: FMODUnity::FMODUnity.PlatformIOS + identifier: 0f8eb3f400726694eb47beb1a9f94ce8 + parentIdentifier: default + active: 0 + Properties: + LiveUpdate: + Value: 0 + HasValue: 0 + LiveUpdatePort: + Value: 0 + HasValue: 0 + Overlay: + Value: 0 + HasValue: 0 + OverlayPosition: + Value: 0 + HasValue: 0 + OverlayFontSize: + Value: 0 + HasValue: 0 + Logging: + Value: 0 + HasValue: 0 + SampleRate: + Value: 0 + HasValue: 0 + BuildDirectory: + Value: + HasValue: 0 + SpeakerMode: + Value: 0 + HasValue: 0 + VirtualChannelCount: + Value: 0 + HasValue: 0 + RealChannelCount: + Value: 0 + HasValue: 0 + DSPBufferLength: + Value: 0 + HasValue: 0 + DSPBufferCount: + Value: 0 + HasValue: 0 + Plugins: + Value: [] + HasValue: 0 + StaticPlugins: + Value: [] + HasValue: 0 + CallbackHandler: + Value: {fileID: 0} + HasValue: 0 + OutputTypeName: + threadAffinities: + Value: [] + HasValue: 0 + displaySortOrder: 0 + childIdentifiers: [] + codecChannels: + Value: [] + HasValue: 0 +--- !u!114 &-2900218176781211718 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 17eea195bdfbf014e91ba7620ee491f8, type: 3} + m_Name: FMODStudioSettingsPlatform + m_EditorClassIdentifier: FMODUnity::FMODUnity.PlatformAppleTV + identifier: e7a046c753c3c3d4aacc91f6597f310d + parentIdentifier: default + active: 0 + Properties: + LiveUpdate: + Value: 0 + HasValue: 0 + LiveUpdatePort: + Value: 0 + HasValue: 0 + Overlay: + Value: 0 + HasValue: 0 + OverlayPosition: + Value: 0 + HasValue: 0 + OverlayFontSize: + Value: 0 + HasValue: 0 + Logging: + Value: 0 + HasValue: 0 + SampleRate: + Value: 0 + HasValue: 0 + BuildDirectory: + Value: + HasValue: 0 + SpeakerMode: + Value: 0 + HasValue: 0 + VirtualChannelCount: + Value: 0 + HasValue: 0 + RealChannelCount: + Value: 0 + HasValue: 0 + DSPBufferLength: + Value: 0 + HasValue: 0 + DSPBufferCount: + Value: 0 + HasValue: 0 + Plugins: + Value: [] + HasValue: 0 + StaticPlugins: + Value: [] + HasValue: 0 + CallbackHandler: + Value: {fileID: 0} + HasValue: 0 + OutputTypeName: + threadAffinities: + Value: [] + HasValue: 0 + displaySortOrder: 0 + childIdentifiers: [] + codecChannels: + Value: [] + HasValue: 0 +--- !u!114 &-2541522425307482493 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 335f0a4b26fb46942858ea029e030d2a, type: 3} + m_Name: FMODStudioSettingsPlatform + m_EditorClassIdentifier: FMODUnity::FMODUnity.PlatformPlayInEditor + identifier: playInEditor + parentIdentifier: + active: 1 + Properties: + LiveUpdate: + Value: 1 + HasValue: 1 + LiveUpdatePort: + Value: 0 + HasValue: 0 + Overlay: + Value: 1 + HasValue: 1 + OverlayPosition: + Value: 0 + HasValue: 0 + OverlayFontSize: + Value: 0 + HasValue: 0 + Logging: + Value: 0 + HasValue: 0 + SampleRate: + Value: 48000 + HasValue: 1 + BuildDirectory: + Value: + HasValue: 0 + SpeakerMode: + Value: 0 + HasValue: 0 + VirtualChannelCount: + Value: 1024 + HasValue: 1 + RealChannelCount: + Value: 256 + HasValue: 1 + DSPBufferLength: + Value: 0 + HasValue: 0 + DSPBufferCount: + Value: 0 + HasValue: 0 + Plugins: + Value: [] + HasValue: 0 + StaticPlugins: + Value: [] + HasValue: 0 + CallbackHandler: + Value: {fileID: 0} + HasValue: 0 + OutputTypeName: + threadAffinities: + Value: [] + HasValue: 0 + displaySortOrder: 0 + childIdentifiers: [] + codecChannels: + Value: [] + HasValue: 0 +--- !u!114 &-2094696130006404903 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 73549a74f689f0849a8271d9e908c514, type: 3} + m_Name: FMODStudioSettingsPlatform + m_EditorClassIdentifier: FMODUnity::FMODUnity.PlatformLinux + identifier: b7716510a1f36934c87976f3a81dbf3d + parentIdentifier: default + active: 0 + Properties: + LiveUpdate: + Value: 0 + HasValue: 0 + LiveUpdatePort: + Value: 0 + HasValue: 0 + Overlay: + Value: 0 + HasValue: 0 + OverlayPosition: + Value: 0 + HasValue: 0 + OverlayFontSize: + Value: 0 + HasValue: 0 + Logging: + Value: 0 + HasValue: 0 + SampleRate: + Value: 0 + HasValue: 0 + BuildDirectory: + Value: + HasValue: 0 + SpeakerMode: + Value: 0 + HasValue: 0 + VirtualChannelCount: + Value: 0 + HasValue: 0 + RealChannelCount: + Value: 0 + HasValue: 0 + DSPBufferLength: + Value: 0 + HasValue: 0 + DSPBufferCount: + Value: 0 + HasValue: 0 + Plugins: + Value: [] + HasValue: 0 + StaticPlugins: + Value: [] + HasValue: 0 + CallbackHandler: + Value: {fileID: 0} + HasValue: 0 + OutputTypeName: + threadAffinities: + Value: [] + HasValue: 0 + displaySortOrder: 0 + childIdentifiers: [] + codecChannels: + Value: [] + HasValue: 0 +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: eef8d824ea7b63742966aaa0e94ac383, type: 3} + m_Name: FMODStudioSettings + m_EditorClassIdentifier: FMODUnity::FMODUnity.Settings + switchSettingsMigration: 1 + HasSourceProject: 1 + HasPlatforms: 1 + sourceProjectPath: + sourceBankPath: + sourceBankPathUnformatted: + BankRefreshCooldown: 5 + ShowBankRefreshWindow: 1 + AutomaticEventLoading: 1 + BankLoadType: 0 + AutomaticSampleLoading: 0 + EncryptionKey: + ImportType: 0 + TargetAssetPath: FMODBanks + TargetBankFolder: + EventLinkage: 0 + SerializeGUIDsOnly: 0 + LoggingLevel: 2 + SpeakerModeSettings: [] + SampleRateSettings: [] + LiveUpdateSettings: [] + OverlaySettings: [] + BankDirectorySettings: [] + VirtualChannelSettings: [] + RealChannelSettings: [] + Plugins: [] + MasterBanks: [] + Banks: [] + BanksToLoad: [] + LiveUpdatePort: 9264 + EnableMemoryTracking: 0 + AndroidUseOBB: 0 + AndroidPatchBuild: 0 + MeterChannelOrdering: 0 + StopEventsOutsideMaxDistance: 0 + BoltUnitOptionsBuildPending: 0 + EnableErrorCallback: 0 + SharedLibraryUpdateStage: 0 + SharedLibraryTimeSinceStart: 0 + CurrentVersion: 131860 + HideSetupWizard: 0 + LastEventReferenceScanVersion: 131860 + Platforms: + - {fileID: -2094696130006404903} + - {fileID: 6584450883818306987} + - {fileID: 3944497114675524469} + - {fileID: -3793632861967256216} + - {fileID: -8876659224941116795} + - {fileID: 4116145689230507682} + - {fileID: -2900218176781211718} + - {fileID: 2006094704713410788} + - {fileID: 3754044641852712671} + - {fileID: -6797349823870411825} + - {fileID: 5131407680144949201} + - {fileID: -2541522425307482493} + MigratedPlatforms: 0a00000000000000150000000b00000009000000050000001200000008000000060000000c0000000200000001000000 +--- !u!114 &2006094704713410788 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 9660e62d6232af242877f0cc2b90c63d, type: 3} + m_Name: FMODStudioSettingsPlatform + m_EditorClassIdentifier: FMODUnity::FMODUnity.PlatformWindows + identifier: 2c5177b11d81d824dbb064f9ac8527da + parentIdentifier: default + active: 0 + Properties: + LiveUpdate: + Value: 0 + HasValue: 0 + LiveUpdatePort: + Value: 0 + HasValue: 0 + Overlay: + Value: 0 + HasValue: 0 + OverlayPosition: + Value: 0 + HasValue: 0 + OverlayFontSize: + Value: 0 + HasValue: 0 + Logging: + Value: 0 + HasValue: 0 + SampleRate: + Value: 0 + HasValue: 0 + BuildDirectory: + Value: + HasValue: 0 + SpeakerMode: + Value: 0 + HasValue: 0 + VirtualChannelCount: + Value: 0 + HasValue: 0 + RealChannelCount: + Value: 0 + HasValue: 0 + DSPBufferLength: + Value: 0 + HasValue: 0 + DSPBufferCount: + Value: 0 + HasValue: 0 + Plugins: + Value: [] + HasValue: 0 + StaticPlugins: + Value: [] + HasValue: 0 + CallbackHandler: + Value: {fileID: 0} + HasValue: 0 + OutputTypeName: + threadAffinities: + Value: [] + HasValue: 0 + displaySortOrder: 0 + childIdentifiers: [] + codecChannels: + Value: [] + HasValue: 0 +--- !u!114 &3754044641852712671 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 3abeb1429547a134480f4f2f1efc7e21, type: 3} + m_Name: FMODStudioSettingsPlatform + m_EditorClassIdentifier: FMODUnity::FMODUnity.PlatformMobileLow + identifier: c88d16e5272a4e241b0ef0ac2e53b73d + parentIdentifier: default + active: 0 + Properties: + LiveUpdate: + Value: 0 + HasValue: 0 + LiveUpdatePort: + Value: 0 + HasValue: 0 + Overlay: + Value: 0 + HasValue: 0 + OverlayPosition: + Value: 0 + HasValue: 0 + OverlayFontSize: + Value: 0 + HasValue: 0 + Logging: + Value: 0 + HasValue: 0 + SampleRate: + Value: 0 + HasValue: 0 + BuildDirectory: + Value: + HasValue: 0 + SpeakerMode: + Value: 0 + HasValue: 0 + VirtualChannelCount: + Value: 0 + HasValue: 0 + RealChannelCount: + Value: 0 + HasValue: 0 + DSPBufferLength: + Value: 0 + HasValue: 0 + DSPBufferCount: + Value: 0 + HasValue: 0 + Plugins: + Value: [] + HasValue: 0 + StaticPlugins: + Value: [] + HasValue: 0 + CallbackHandler: + Value: {fileID: 0} + HasValue: 0 + OutputTypeName: + threadAffinities: + Value: [] + HasValue: 0 + displaySortOrder: 0 + childIdentifiers: [] + codecChannels: + Value: [] + HasValue: 0 +--- !u!114 &3944497114675524469 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: b87314b32fbe18943af496e4b47136c6, type: 3} + m_Name: FMODStudioSettingsPlatform + m_EditorClassIdentifier: FMODUnity::FMODUnity.PlatformWebGL + identifier: 46fbfdf3fc43db0458918377fd40293e + parentIdentifier: default + active: 0 + Properties: + LiveUpdate: + Value: 0 + HasValue: 0 + LiveUpdatePort: + Value: 0 + HasValue: 0 + Overlay: + Value: 0 + HasValue: 0 + OverlayPosition: + Value: 0 + HasValue: 0 + OverlayFontSize: + Value: 0 + HasValue: 0 + Logging: + Value: 0 + HasValue: 0 + SampleRate: + Value: 0 + HasValue: 0 + BuildDirectory: + Value: + HasValue: 0 + SpeakerMode: + Value: 0 + HasValue: 0 + VirtualChannelCount: + Value: 0 + HasValue: 0 + RealChannelCount: + Value: 0 + HasValue: 0 + DSPBufferLength: + Value: 0 + HasValue: 0 + DSPBufferCount: + Value: 0 + HasValue: 0 + Plugins: + Value: [] + HasValue: 0 + StaticPlugins: + Value: [] + HasValue: 0 + CallbackHandler: + Value: {fileID: 0} + HasValue: 0 + OutputTypeName: + threadAffinities: + Value: [] + HasValue: 0 + displaySortOrder: 0 + childIdentifiers: [] + codecChannels: + Value: [] + HasValue: 0 +--- !u!114 &4116145689230507682 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 93a382382f106584e8f8f62412fee177, type: 3} + m_Name: FMODStudioSettingsPlatform + m_EditorClassIdentifier: FMODUnity::FMODUnity.PlatformMobileHigh + identifier: fd7c55dab0fce234b8c25f6ffca523c1 + parentIdentifier: default + active: 0 + Properties: + LiveUpdate: + Value: 0 + HasValue: 0 + LiveUpdatePort: + Value: 0 + HasValue: 0 + Overlay: + Value: 0 + HasValue: 0 + OverlayPosition: + Value: 0 + HasValue: 0 + OverlayFontSize: + Value: 0 + HasValue: 0 + Logging: + Value: 0 + HasValue: 0 + SampleRate: + Value: 0 + HasValue: 0 + BuildDirectory: + Value: + HasValue: 0 + SpeakerMode: + Value: 0 + HasValue: 0 + VirtualChannelCount: + Value: 0 + HasValue: 0 + RealChannelCount: + Value: 0 + HasValue: 0 + DSPBufferLength: + Value: 0 + HasValue: 0 + DSPBufferCount: + Value: 0 + HasValue: 0 + Plugins: + Value: [] + HasValue: 0 + StaticPlugins: + Value: [] + HasValue: 0 + CallbackHandler: + Value: {fileID: 0} + HasValue: 0 + OutputTypeName: + threadAffinities: + Value: [] + HasValue: 0 + displaySortOrder: 0 + childIdentifiers: [] + codecChannels: + Value: [] + HasValue: 0 +--- !u!114 &5131407680144949201 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 9587379c972e4a54da1949613f11e1c6, type: 3} + m_Name: FMODStudioSettingsPlatform + m_EditorClassIdentifier: FMODUnity::FMODUnity.PlatformDefault + identifier: default + parentIdentifier: + active: 1 + Properties: + LiveUpdate: + Value: 0 + HasValue: 0 + LiveUpdatePort: + Value: 9264 + HasValue: 1 + Overlay: + Value: 0 + HasValue: 0 + OverlayPosition: + Value: 0 + HasValue: 0 + OverlayFontSize: + Value: 0 + HasValue: 0 + Logging: + Value: 0 + HasValue: 0 + SampleRate: + Value: 0 + HasValue: 0 + BuildDirectory: + Value: + HasValue: 0 + SpeakerMode: + Value: 0 + HasValue: 0 + VirtualChannelCount: + Value: 0 + HasValue: 0 + RealChannelCount: + Value: 0 + HasValue: 0 + DSPBufferLength: + Value: 0 + HasValue: 0 + DSPBufferCount: + Value: 0 + HasValue: 0 + Plugins: + Value: [] + HasValue: 1 + StaticPlugins: + Value: [] + HasValue: 1 + CallbackHandler: + Value: {fileID: 0} + HasValue: 0 + OutputTypeName: + threadAffinities: + Value: [] + HasValue: 0 + displaySortOrder: 0 + childIdentifiers: + - b7716510a1f36934c87976f3a81dbf3d + - de700ef3f37a49b58a57ae3addf01ad9 + - 46fbfdf3fc43db0458918377fd40293e + - 0f8eb3f400726694eb47beb1a9f94ce8 + - 52eb9df5db46521439908db3a29a1bbb + - fd7c55dab0fce234b8c25f6ffca523c1 + - e7a046c753c3c3d4aacc91f6597f310d + - 2c5177b11d81d824dbb064f9ac8527da + - c88d16e5272a4e241b0ef0ac2e53b73d + - 2fea114e74ecf3c4f920e1d5cc1c4c40 + codecChannels: + Value: [] + HasValue: 0 +--- !u!114 &6584450883818306987 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 8f591a193113347409efedcc618e83d6, type: 3} + m_Name: FMODStudioSettingsPlatform + m_EditorClassIdentifier: FMODUnity::FMODUnity.PlatformVisionOS + identifier: de700ef3f37a49b58a57ae3addf01ad9 + parentIdentifier: default + active: 0 + Properties: + LiveUpdate: + Value: 0 + HasValue: 0 + LiveUpdatePort: + Value: 0 + HasValue: 0 + Overlay: + Value: 0 + HasValue: 0 + OverlayPosition: + Value: 0 + HasValue: 0 + OverlayFontSize: + Value: 0 + HasValue: 0 + Logging: + Value: 0 + HasValue: 0 + SampleRate: + Value: 0 + HasValue: 0 + BuildDirectory: + Value: + HasValue: 0 + SpeakerMode: + Value: 0 + HasValue: 0 + VirtualChannelCount: + Value: 0 + HasValue: 0 + RealChannelCount: + Value: 0 + HasValue: 0 + DSPBufferLength: + Value: 0 + HasValue: 0 + DSPBufferCount: + Value: 0 + HasValue: 0 + Plugins: + Value: [] + HasValue: 0 + StaticPlugins: + Value: [] + HasValue: 0 + CallbackHandler: + Value: {fileID: 0} + HasValue: 0 + OutputTypeName: + threadAffinities: + Value: [] + HasValue: 0 + displaySortOrder: 0 + childIdentifiers: [] + codecChannels: + Value: [] + HasValue: 0 diff --git a/Assets/Plugins/FMOD/Resources/FMODStudioSettings.asset.meta b/Assets/Plugins/FMOD/Resources/FMODStudioSettings.asset.meta new file mode 100644 index 0000000..6456d48 --- /dev/null +++ b/Assets/Plugins/FMOD/Resources/FMODStudioSettings.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: a51d0bf46d0fd4e0a8224569e29b7cb3 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/ProjectQuint.meta b/Assets/ProjectQuint.meta new file mode 100644 index 0000000..3c384c9 --- /dev/null +++ b/Assets/ProjectQuint.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: b9fcfc305c32f47c6894393884afbe27 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/ProjectQuint/Art.meta b/Assets/ProjectQuint/Art.meta new file mode 100644 index 0000000..e3335fb --- /dev/null +++ b/Assets/ProjectQuint/Art.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: dbee217e0a68d43ec9fcf523aa9b915d +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/ProjectQuint/Art/AppIcon.meta b/Assets/ProjectQuint/Art/AppIcon.meta new file mode 100644 index 0000000..47c0891 --- /dev/null +++ b/Assets/ProjectQuint/Art/AppIcon.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: cc3b5549db61d4e829d9608adfb38452 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/ProjectQuint/Art/AppIcon/ProjectQuintAppIcon.png b/Assets/ProjectQuint/Art/AppIcon/ProjectQuintAppIcon.png new file mode 100644 index 0000000..a957540 Binary files /dev/null and b/Assets/ProjectQuint/Art/AppIcon/ProjectQuintAppIcon.png differ diff --git a/Assets/ProjectQuint/Art/AppIcon/ProjectQuintAppIcon.png.meta b/Assets/ProjectQuint/Art/AppIcon/ProjectQuintAppIcon.png.meta new file mode 100644 index 0000000..f881269 --- /dev/null +++ b/Assets/ProjectQuint/Art/AppIcon/ProjectQuintAppIcon.png.meta @@ -0,0 +1,143 @@ +fileFormatVersion: 2 +guid: aa998a7af36b743fdb4ab07c8a19741a +TextureImporter: + internalIDToNameTable: + - first: + 213: -4050758962521307463 + second: ProjectQuintAppIcon_0 + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 0 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 1 + aniso: 1 + mipBias: 0 + wrapU: 1 + wrapV: 1 + wrapW: 1 + nPOTScale: 0 + lightmap: 0 + compressionQuality: 50 + spriteMode: 2 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 100 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 1 + spriteTessellationDetail: -1 + textureType: 8 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: + - serializedVersion: 2 + name: ProjectQuintAppIcon_0 + rect: + serializedVersion: 2 + x: 0 + y: 0 + width: 1024 + height: 1024 + alignment: 0 + pivot: {x: 0, y: 0} + border: {x: 0, y: 0, z: 0, w: 0} + customData: + outline: [] + physicsShape: [] + tessellationDetail: -1 + bones: [] + spriteID: 9b600387f20d8c7c0800000000000000 + internalID: -4050758962521307463 + vertices: [] + indices: + edges: [] + weights: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: + ProjectQuintAppIcon_0: -4050758962521307463 + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/ProjectQuint/Editor.meta b/Assets/ProjectQuint/Editor.meta new file mode 100644 index 0000000..1642376 --- /dev/null +++ b/Assets/ProjectQuint/Editor.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 39aa599a9465344fd9bbf8714fe9e66d +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/ProjectQuint/Editor/ChartFeatureCalibrationReport.cs b/Assets/ProjectQuint/Editor/ChartFeatureCalibrationReport.cs new file mode 100644 index 0000000..c1d0ac0 --- /dev/null +++ b/Assets/ProjectQuint/Editor/ChartFeatureCalibrationReport.cs @@ -0,0 +1,90 @@ +#if UNITY_EDITOR +using System; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading; +using ProjectQuint.Library; +using UnityEngine; + +namespace ProjectQuint.Editor +{ + public static class ChartFeatureCalibrationReport + { + private const string PackPathVariable = "PROJECTQUINT_FEATURE_PACK_PATH"; + private const string ReportPathVariable = "PROJECTQUINT_FEATURE_REPORT_PATH"; + private const string CachePathVariable = "PROJECTQUINT_FEATURE_CACHE_PATH"; + + public static void Generate() + { + var packPath = Environment.GetEnvironmentVariable(PackPathVariable); + var reportPath = Environment.GetEnvironmentVariable(ReportPathVariable); + var cachePath = Environment.GetEnvironmentVariable(CachePathVariable); + if (string.IsNullOrWhiteSpace(packPath) || !Directory.Exists(packPath)) + throw new DirectoryNotFoundException($"Set {PackPathVariable} to a BMS pack folder."); + if (string.IsNullOrWhiteSpace(reportPath)) + throw new InvalidOperationException($"Set {ReportPathVariable} to the output TSV path."); + if (string.IsNullOrWhiteSpace(cachePath)) + throw new InvalidOperationException($"Set {CachePathVariable} to a disposable index cache path."); + + var stopwatch = Stopwatch.StartNew(); + var scanner = new SongLibraryScanner(cachePath, packId: "feature-calibration"); + var songs = scanner.Scan(Path.GetFullPath(packPath), CancellationToken.None); + stopwatch.Stop(); + + var builder = new StringBuilder(); + builder.AppendLine( + "chart_path\tmd5\tkey_count\thas_scratch\tdifficulty\tnotes_raw\tpeak_raw\tchord_raw\tlong_raw\tsoflan_raw\ttech_raw\tnotes\tpeak\tchord\tlong\tsoflan\ttech\tanomalies\terror"); + foreach (var song in songs) + { + var feature = song.Features; + builder.Append(Escape(song.ChartPath)).Append('\t') + .Append(Escape(song.Metadata?.Md5)).Append('\t') + .Append(song.Metadata?.KeyCount ?? 0).Append('\t') + .Append(song.Metadata?.HasScratch == true ? 1 : 0).Append('\t') + .Append(Escape(song.DifficultyLabel)).Append('\t'); + if (feature != null) + { + Append(builder, feature.NotesRaw).Append('\t'); + Append(builder, feature.PeakRaw).Append('\t'); + Append(builder, feature.ChordRaw).Append('\t'); + Append(builder, feature.LongRaw).Append('\t'); + Append(builder, feature.SofLanRaw).Append('\t'); + Append(builder, feature.TechRaw).Append('\t'); + Append(builder, feature.Notes).Append('\t'); + Append(builder, feature.Peak).Append('\t'); + Append(builder, feature.Chord).Append('\t'); + Append(builder, feature.Long).Append('\t'); + Append(builder, feature.SofLan).Append('\t'); + Append(builder, feature.Tech).Append('\t'); + builder.Append(feature.AnomalyCount).Append('\t'); + } + else + { + builder.Append("\t\t\t\t\t\t\t\t\t\t\t\t\t"); + } + builder.AppendLine(Escape(song.FeatureAnalysisError)); + } + + var directory = Path.GetDirectoryName(Path.GetFullPath(reportPath)); + if (!string.IsNullOrWhiteSpace(directory)) + Directory.CreateDirectory(directory); + File.WriteAllText(reportPath, builder.ToString(), new UTF8Encoding(false)); + + var available = songs.Count(song => song.Features != null); + UnityEngine.Debug.Log( + $"[ProjectQuint Features] {available:N0}/{songs.Count:N0} chart profiles written to {reportPath} " + + $"in {stopwatch.Elapsed.TotalSeconds:0.00}s; cache {scanner.CacheHits:N0} hits / " + + $"{scanner.CacheMisses:N0} refreshes; failures {scanner.Failed:N0}."); + } + + private static StringBuilder Append(StringBuilder builder, double value) => + builder.Append(value.ToString("0.######", CultureInfo.InvariantCulture)); + + private static string Escape(string value) => + (value ?? string.Empty).Replace('\t', ' ').Replace('\r', ' ').Replace('\n', ' '); + } +} +#endif diff --git a/Assets/ProjectQuint/Editor/ChartFeatureCalibrationReport.cs.meta b/Assets/ProjectQuint/Editor/ChartFeatureCalibrationReport.cs.meta new file mode 100644 index 0000000..c0e7d41 --- /dev/null +++ b/Assets/ProjectQuint/Editor/ChartFeatureCalibrationReport.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 608820f0ced354ca8984048c7444040b \ No newline at end of file diff --git a/Assets/ProjectQuint/Editor/ProjectQuintBuild.cs b/Assets/ProjectQuint/Editor/ProjectQuintBuild.cs new file mode 100644 index 0000000..815423d --- /dev/null +++ b/Assets/ProjectQuint/Editor/ProjectQuintBuild.cs @@ -0,0 +1,758 @@ +#if UNITY_EDITOR +using System; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Xml; +using ProjectQuint.Core.Release; +using UnityEditor; +using UnityEditor.Callbacks; +using UnityEditor.Build.Reporting; +using UnityEngine; + +namespace ProjectQuint.Editor +{ + public static class ProjectQuintBuild + { + private const string BuildPathEnvironmentVariable = "PROJECTQUINT_BUILD_PATH"; + private const string CompanyName = "Team Denia"; + private const string ProductName = "ProjectQuint"; + private const string BundleIdentifier = "com.teamdenia.projectquint"; + private const string Copyright = "© 2026 Team Denia"; + private const string CopyrightXml = "© 2026 Team Denia"; + + public static void BuildMacStable() + { + PlayerSettings.resizableWindow = true; + ApplyReleaseVersion(); + RequireMetalDisplayLink(); + RequireDisabledUnityTelemetry(); + RequireProductIdentity(); + RequireMacNativeInputPlugin(); + RequireMacSparkleUpdater(); + ProjectQuintIconConfigurator.ApplyMacAppIcon(); + + var outputPath = Environment.GetEnvironmentVariable(BuildPathEnvironmentVariable); + if (string.IsNullOrWhiteSpace(outputPath)) + { + throw new InvalidOperationException( + $"Set {BuildPathEnvironmentVariable} to the exact .app output path before building."); + } + + outputPath = Path.GetFullPath(outputPath); + if (!outputPath.EndsWith(".app", StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException($"macOS build output must end in .app: {outputPath}"); + + var directory = Path.GetDirectoryName(outputPath); + if (string.IsNullOrWhiteSpace(directory)) + throw new InvalidOperationException($"Build output has no parent directory: {outputPath}"); + Directory.CreateDirectory(directory); + + var scenes = EditorBuildSettings.scenes + .Where(scene => scene.enabled) + .Select(scene => scene.path) + .ToArray(); + if (scenes.Length == 0) + throw new InvalidOperationException("No enabled scenes are configured in Build Settings."); + + var report = BuildPipeline.BuildPlayer(new BuildPlayerOptions + { + scenes = scenes, + locationPathName = outputPath, + target = BuildTarget.StandaloneOSX, + options = BuildOptions.None, + }); + + var summary = report.summary; + Debug.Log( + $"[ProjectQuint Build] {summary.result}: {summary.totalErrors} errors, " + + $"{summary.totalWarnings} warnings, {summary.totalSize} bytes, {summary.totalTime}."); + if (summary.result != BuildResult.Succeeded || summary.totalErrors > 0) + throw new InvalidOperationException( + $"ProjectQuint macOS build failed: {summary.result}, {summary.totalErrors} errors."); + + ValidateBuiltMacNativeInputPlugin(outputPath); + ValidateBuiltMacSparkleUpdater(outputPath); + WriteAndValidateMacBundleIdentity(outputPath); + WriteAndValidateSparkleConfiguration(outputPath); + ApplyAndValidateMacPrivacyPolicy(outputPath); + ValidateReleaseDependencySurface(outputPath); + AdHocSignAndVerify(outputPath); + } + + private static void ApplyReleaseVersion() + { + PlayerSettings.bundleVersion = ProjectQuintReleaseVersion.ProductVersion; + PlayerSettings.macOS.buildNumber = ProjectQuintReleaseVersion.BuildNumberText; + Debug.Log( + $"[ProjectQuint Build] Release version: " + + $"{ProjectQuintReleaseVersion.DisplayVersionWithBuild}"); + } + + private static void RequireMacNativeInputPlugin() + { + var projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); + var sourceRoot = Path.Combine(projectRoot, "Native", "ProjectQuintInput"); + var bundleRoot = Path.Combine( + Application.dataPath, + "ProjectQuint", + "Plugins", + "macOS", + "ProjectQuintInput.bundle"); + var binary = Path.Combine(bundleRoot, "Contents", "MacOS", "ProjectQuintInput"); + RequireFile(Path.Combine(sourceRoot, "project_quint_input.cpp"), "macOS native input source"); + RequireFile(Path.Combine(sourceRoot, "build-macos.sh"), "macOS native input build script"); + RequireFile(Path.Combine(bundleRoot, "Contents", "Info.plist"), "macOS native input Info.plist"); + RequireFile(binary, "macOS native input Universal binary"); + RunTool( + "/usr/bin/lipo", + new[] { binary, "-verify_arch", "arm64", "x86_64" }, + "macOS native input architecture verification"); + } + + private static void ValidateBuiltMacNativeInputPlugin(string appPath) + { + var bundles = Directory.GetDirectories( + appPath, + "ProjectQuintInput.bundle", + SearchOption.AllDirectories); + if (bundles.Length != 1) + throw new InvalidDataException( + $"Expected exactly one ProjectQuintInput.bundle in the Player, found {bundles.Length}."); + var binary = Path.Combine(bundles[0], "Contents", "MacOS", "ProjectQuintInput"); + RequireFile(binary, "built macOS native input binary"); + RunTool( + "/usr/bin/lipo", + new[] { binary, "-verify_arch", "arm64", "x86_64" }, + "built macOS native input architecture verification"); + Debug.Log($"[ProjectQuint Build] Included Universal native input polling backend: {bundles[0]}"); + } + + private static void RequireMacSparkleUpdater() + { + var projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); + var sourceRoot = Path.Combine(projectRoot, "Native", "ProjectQuintUpdater"); + var bundleRoot = Path.Combine( + Application.dataPath, + "ProjectQuint", + "Plugins", + "macOS", + "ProjectQuintUpdater.bundle"); + var binary = Path.Combine(bundleRoot, "Contents", "MacOS", "ProjectQuintUpdater"); + var sparkleRoot = Path.Combine(projectRoot, "ThirdParty", "Sparkle", "2.9.4"); + var sparkleBinary = Path.Combine( + sparkleRoot, + "Sparkle.framework", + "Versions", + "B", + "Sparkle"); + + RequireFile(Path.Combine(sourceRoot, "project_quint_updater.mm"), "Sparkle updater source"); + RequireFile(Path.Combine(sourceRoot, "build-macos.sh"), "Sparkle updater build script"); + RequireFile(Path.Combine(bundleRoot, "Contents", "Info.plist"), "Sparkle updater Info.plist"); + RequireFile(binary, "Sparkle updater Universal binary"); + RequireFile(sparkleBinary, "Sparkle 2.9.4 framework binary"); + RequireFile(Path.Combine(sparkleRoot, "LICENSE"), "Sparkle license"); + RunTool( + "/usr/bin/lipo", + new[] { binary, "-verify_arch", "arm64", "x86_64" }, + "Sparkle updater architecture verification"); + RunTool( + "/usr/bin/lipo", + new[] { sparkleBinary, "-verify_arch", "arm64", "x86_64" }, + "Sparkle framework architecture verification"); + var linkedLibraries = RunToolCapture( + "/usr/bin/otool", + new[] { "-L", binary }, + "Sparkle updater linkage verification"); + if (!linkedLibraries.Contains("@rpath/Sparkle.framework/Versions/B/Sparkle")) + throw new InvalidDataException("ProjectQuintUpdater is not linked to Sparkle.framework."); + var loadCommands = RunToolCapture( + "/usr/bin/otool", + new[] { "-l", binary }, + "Sparkle updater rpath verification"); + if (!loadCommands.Contains("@loader_path/../../../../Frameworks")) + throw new InvalidDataException("ProjectQuintUpdater has no app Frameworks rpath."); + } + + private static void ValidateBuiltMacSparkleUpdater(string appPath) + { + var bundles = Directory.GetDirectories( + appPath, + "ProjectQuintUpdater.bundle", + SearchOption.AllDirectories); + if (bundles.Length != 1) + throw new InvalidDataException( + $"Expected exactly one ProjectQuintUpdater.bundle in the Player, found {bundles.Length}."); + var binary = Path.Combine(bundles[0], "Contents", "MacOS", "ProjectQuintUpdater"); + RequireFile(binary, "built Sparkle updater binary"); + RunTool( + "/usr/bin/lipo", + new[] { binary, "-verify_arch", "arm64", "x86_64" }, + "built Sparkle updater architecture verification"); + Debug.Log($"[ProjectQuint Build] Included Universal Sparkle bridge: {bundles[0]}"); + } + + private static void RequireProductIdentity() + { + var identifier = PlayerSettings.GetApplicationIdentifier(BuildTargetGroup.Standalone); + if (!string.Equals(PlayerSettings.companyName, CompanyName, StringComparison.Ordinal) || + !string.Equals(PlayerSettings.productName, ProductName, StringComparison.Ordinal) || + !string.Equals( + PlayerSettings.bundleVersion, + ProjectQuintReleaseVersion.ProductVersion, + StringComparison.Ordinal) || + !string.Equals( + PlayerSettings.macOS.buildNumber, + ProjectQuintReleaseVersion.BuildNumberText, + StringComparison.Ordinal) || + !string.Equals(identifier, BundleIdentifier, StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "ProjectQuint product identity is incomplete. Expected " + + $"{CompanyName}/{ProductName}/{BundleIdentifier}/" + + $"{ProjectQuintReleaseVersion.DisplayVersionWithBuild}, got " + + $"{PlayerSettings.companyName}/{PlayerSettings.productName}/{identifier}/" + + $"{PlayerSettings.bundleVersion} ({PlayerSettings.macOS.buildNumber})."); + } + } + + private static void WriteAndValidateMacBundleIdentity(string appPath) + { + var infoPlist = Path.Combine(appPath, "Contents", "Info.plist"); + RequireFile(infoPlist, "macOS application Info.plist"); + RunTool( + "/usr/bin/plutil", + new[] { "-replace", "CFBundleDisplayName", "-string", ProductName, infoPlist }, + "macOS display-name metadata"); + RunTool( + "/usr/bin/plutil", + new[] { "-replace", "NSHumanReadableCopyright", "-xml", CopyrightXml, infoPlist }, + "macOS copyright metadata"); + + RequirePlistValue(infoPlist, "CFBundleIdentifier", BundleIdentifier); + RequirePlistValue(infoPlist, "CFBundleName", ProductName); + RequirePlistValue(infoPlist, "CFBundleDisplayName", ProductName); + RequirePlistValue( + infoPlist, + "CFBundleShortVersionString", + ProjectQuintReleaseVersion.ProductVersion); + RequirePlistValue( + infoPlist, + "CFBundleVersion", + ProjectQuintReleaseVersion.BuildNumberText); + RequireXmlPlistStringValue(infoPlist, "NSHumanReadableCopyright", Copyright); + Debug.Log( + $"[ProjectQuint Build] Product identity verified: {BundleIdentifier} " + + $"{ProjectQuintReleaseVersion.DisplayVersionWithBuild}, {Copyright}"); + } + + private static void WriteAndValidateSparkleConfiguration(string appPath) + { + var infoPlist = Path.Combine(appPath, "Contents", "Info.plist"); + RequireFile(infoPlist, "macOS application Info.plist"); + SetPlistValue( + infoPlist, + "SUFeedURL", + "string", + ProjectQuintReleaseVersion.SparkleFeedUrl); + SetPlistValue( + infoPlist, + "SUPublicEDKey", + "string", + ProjectQuintReleaseVersion.SparklePublicEdKey); + SetPlistValue(infoPlist, "SUEnableAutomaticChecks", "bool", "false"); + SetPlistValue(infoPlist, "SUAutomaticallyUpdate", "bool", "false"); + SetPlistValue(infoPlist, "SUAllowsAutomaticUpdates", "bool", "false"); + SetPlistValue(infoPlist, "SUVerifyUpdateBeforeExtraction", "bool", "true"); + SetPlistValue(infoPlist, "SUShowReleaseNotes", "bool", "true"); + + RequirePlistValue( + infoPlist, + "SUFeedURL", + ProjectQuintReleaseVersion.SparkleFeedUrl); + RequirePlistValue( + infoPlist, + "SUPublicEDKey", + ProjectQuintReleaseVersion.SparklePublicEdKey); + RequirePlistValue(infoPlist, "SUEnableAutomaticChecks", "false"); + RequirePlistValue(infoPlist, "SUAutomaticallyUpdate", "false"); + RequirePlistValue(infoPlist, "SUAllowsAutomaticUpdates", "false"); + RequirePlistValue(infoPlist, "SUVerifyUpdateBeforeExtraction", "true"); + RequirePlistValue(infoPlist, "SUShowReleaseNotes", "true"); + Debug.Log( + $"[ProjectQuint Build] Sparkle feed and Ed25519 verification configured: " + + ProjectQuintReleaseVersion.SparkleFeedUrl); + } + + private static void SetPlistValue( + string plistPath, + string key, + string valueType, + string value) + { + var document = new XmlDocument(); + document.Load(plistPath); + var operation = FindXmlPlistValueNode(document, key) == null ? "-insert" : "-replace"; + RunTool( + "/usr/bin/plutil", + new[] { operation, key, "-" + valueType, value, plistPath }, + $"macOS {key} configuration"); + } + + private static void RequirePlistValue(string plistPath, string key, string expected) + { + var actual = RunToolCapture( + "/usr/bin/plutil", + new[] { "-extract", key, "raw", "-o", "-", plistPath }, + $"macOS {key} verification").Trim(); + if (!string.Equals(actual, expected, StringComparison.Ordinal)) + throw new InvalidDataException( + $"macOS {key} mismatch. Expected '{expected}', got '{actual}'."); + } + + private static void RequireXmlPlistStringValue(string plistPath, string key, string expected) + { + var document = new XmlDocument(); + document.Load(plistPath); + var keys = document.SelectNodes("/plist/dict/key"); + if (keys != null) + { + foreach (XmlNode keyNode in keys) + { + if (!string.Equals(keyNode.InnerText, key, StringComparison.Ordinal)) + continue; + var valueNode = keyNode.NextSibling; + while (valueNode != null && valueNode.NodeType != XmlNodeType.Element) + valueNode = valueNode.NextSibling; + var actual = valueNode?.InnerText ?? string.Empty; + if (string.Equals(actual, expected, StringComparison.Ordinal)) + return; + throw new InvalidDataException( + $"macOS {key} mismatch. Expected '{expected}', got '{actual}'."); + } + } + throw new InvalidDataException($"macOS Info.plist is missing {key}."); + } + + private static void AdHocSignAndVerify(string appPath) + { + RunTool( + "/usr/bin/codesign", + new[] { "--force", "--deep", "--sign", "-", appPath }, + "ad-hoc code signing"); + RunTool( + "/usr/bin/codesign", + new[] { "--verify", "--deep", "--strict", appPath }, + "code signature verification"); + RunTool( + "/usr/bin/codesign", + new[] + { + "--verify", + "--deep", + "--strict", + Path.Combine(appPath, "Contents", "Frameworks", "Sparkle.framework"), + }, + "Sparkle framework signature verification"); + var updaterBundles = Directory.GetDirectories( + appPath, + "ProjectQuintUpdater.bundle", + SearchOption.AllDirectories); + if (updaterBundles.Length != 1) + throw new InvalidDataException( + $"Expected one signed ProjectQuintUpdater.bundle, found {updaterBundles.Length}."); + RunTool( + "/usr/bin/codesign", + new[] { "--verify", "--strict", updaterBundles[0] }, + "Sparkle bridge signature verification"); + Debug.Log($"[ProjectQuint Build] Ad-hoc signature verified: {appPath}"); + } + + private static void RunTool(string executable, string[] arguments, string operation) + { + var startInfo = new System.Diagnostics.ProcessStartInfo + { + FileName = executable, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + Arguments = string.Join(" ", arguments.Select(QuoteProcessArgument)), + }; + + using var process = System.Diagnostics.Process.Start(startInfo) ?? + throw new InvalidOperationException($"Could not start {operation}."); + var standardOutput = process.StandardOutput.ReadToEnd(); + var standardError = process.StandardError.ReadToEnd(); + process.WaitForExit(); + if (process.ExitCode != 0) + { + throw new InvalidOperationException( + $"ProjectQuint {operation} failed ({process.ExitCode}): " + + $"{standardError}\n{standardOutput}"); + } + } + + private static string QuoteProcessArgument(string argument) => + "\"" + (argument ?? string.Empty).Replace("\\", "\\\\").Replace("\"", "\\\"") + "\""; + + private static void RequireMetalDisplayLink() + { + var projectSettingsPath = Path.GetFullPath( + Path.Combine(Application.dataPath, "..", "ProjectSettings", "ProjectSettings.asset")); + var serializedSettings = File.ReadAllText(projectSettingsPath); + if (!serializedSettings.Contains("metalUseMetalDisplayLink: 1")) + { + throw new InvalidOperationException( + "Use MetalDisplayLink (Mac Player only) must be enabled for stable macOS frame pacing."); + } + } + + private static void RequireDisabledUnityTelemetry() + { + var projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); + var projectSettings = File.ReadAllText( + Path.Combine(projectRoot, "ProjectSettings", "ProjectSettings.asset")); + var connectSettings = File.ReadAllText( + Path.Combine(projectRoot, "ProjectSettings", "UnityConnectSettings.asset")); + if (!projectSettings.Contains("submitAnalytics: 0") || + !connectSettings.Contains("UnityConnectSettings:\n m_ObjectHideFlags: 0\n" + + " serializedVersion: 1\n m_Enabled: 0") || + !connectSettings.Contains("m_EngineDiagnosticsEnabled: 0") || + !connectSettings.Contains("m_EnableCloudDiagnosticsReporting: 0") || + !connectSettings.Contains("UnityAnalyticsSettings:\n m_Enabled: 0") || + !connectSettings.Contains("PerformanceReportingSettings:\n m_Enabled: 0")) + { + throw new InvalidOperationException( + "Unity telemetry and diagnostics must remain disabled for a ProjectQuint release."); + } + } + + private static void ApplyAndValidateMacPrivacyPolicy(string appPath) + { + var infoPlist = Path.Combine(appPath, "Contents", "Info.plist"); + RunTool( + "/usr/bin/plutil", + new[] { "-remove", "NSAppTransportSecurity", infoPlist }, + "macOS App Transport Security hardening"); + RequireXmlPlistMissingKey(infoPlist, "NSAppTransportSecurity"); + + var privacyManifest = Path.Combine( + appPath, + "Contents", + "Resources", + "PrivacyInfo.xcprivacy"); + RequireFile(privacyManifest, "macOS privacy manifest"); + var document = new XmlDocument(); + document.Load(privacyManifest); + var collectedData = FindXmlPlistValueNode(document, "NSPrivacyCollectedDataTypes"); + if (collectedData != null && + (collectedData.Name != "array" || + collectedData.ChildNodes.Cast().Any( + node => node.NodeType == XmlNodeType.Element))) + { + throw new InvalidDataException( + "ProjectQuint declares no collected data, but the built privacy manifest is not empty."); + } + + Debug.Log( + "[ProjectQuint Build] Privacy policy verified: no collected data and macOS ATS enforced."); + } + + private static void ValidateReleaseDependencySurface(string appPath) + { + var managedDirectory = Path.Combine( + appPath, + "Contents", + "Resources", + "Data", + "Managed"); + if (!Directory.Exists(managedDirectory)) + throw new DirectoryNotFoundException( + $"Built macOS Managed directory not found: {managedDirectory}"); + + var forbiddenAssemblyNames = new[] + { + "Unity.AI.MCP.Runtime.dll", + "Unity.AI.Tracing.dll", + "Unity.Multiplayer.Center.Common.dll", + "Unity.VisualScripting.Antlr3.Runtime.dll", + "Unity.VisualScripting.Core.dll", + "Unity.VisualScripting.DocCodeExamples.dll", + "Unity.VisualScripting.Flow.dll", + "Unity.VisualScripting.State.dll", + "Unity.2D.Animation.Runtime.dll", + "Unity.2D.Common.Runtime.dll", + "Unity.2D.IK.Runtime.dll", + "Unity.2D.SpriteShape.Runtime.dll", + "Unity.2D.Tilemap.Extras.dll", + "FMODUnityHaptics.dll", + "FMODUnityResonance.dll", + }; + var includedForbiddenAssemblies = forbiddenAssemblyNames + .Where(name => File.Exists(Path.Combine(managedDirectory, name))) + .ToArray(); + + var pluginDirectory = Path.Combine(appPath, "Contents", "PlugIns"); + var forbiddenPluginNames = new[] + { + "fmod_haptics.bundle", + "resonanceaudio.bundle", + }; + var includedForbiddenPlugins = Directory.Exists(pluginDirectory) + ? forbiddenPluginNames + .Where(name => Directory.Exists(Path.Combine(pluginDirectory, name))) + .ToArray() + : Array.Empty(); + + var debugArtifacts = Directory + .EnumerateFiles(appPath, "*", SearchOption.AllDirectories) + .Where(path => + path.EndsWith(".pdb", StringComparison.OrdinalIgnoreCase) || + path.EndsWith(".mdb", StringComparison.OrdinalIgnoreCase) || + path.EndsWith(".debug", StringComparison.OrdinalIgnoreCase)) + .Select(Path.GetFileName) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(name => name, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + var unexpected = includedForbiddenAssemblies + .Concat(includedForbiddenPlugins) + .Concat(debugArtifacts) + .ToArray(); + if (unexpected.Length > 0) + { + throw new InvalidDataException( + "Release build contains development-only or unused runtime files: " + + string.Join(", ", unexpected) + + ". Build through Tools/build-mac-stable-release.sh."); + } + + RequireFile( + Path.Combine(managedDirectory, "FMODUnity.dll"), + "FMOD runtime assembly"); + RequireFile( + Path.Combine(pluginDirectory, "fmodstudio.bundle", "Contents", "MacOS", "fmodstudio"), + "FMOD Studio macOS runtime"); + RequireFile( + Path.Combine( + appPath, + "Contents", + "Frameworks", + "Sparkle.framework", + "Versions", + "B", + "Sparkle"), + "Sparkle updater framework"); + Debug.Log( + "[ProjectQuint Build] Release dependency surface verified: " + + "no AI/MCP, Visual Scripting, unused 2D, multiplayer, " + + "FMOD haptics/resonance or debug artifacts."); + } + + private static void RequireXmlPlistMissingKey(string plistPath, string key) + { + var document = new XmlDocument(); + document.Load(plistPath); + if (FindXmlPlistValueNode(document, key) != null) + throw new InvalidDataException($"macOS Info.plist must not contain {key}."); + } + + private static XmlNode FindXmlPlistValueNode(XmlDocument document, string key) + { + var keys = document.SelectNodes("/plist/dict/key"); + if (keys == null) + return null; + foreach (XmlNode keyNode in keys) + { + if (!string.Equals(keyNode.InnerText, key, StringComparison.Ordinal)) + continue; + var valueNode = keyNode.NextSibling; + while (valueNode != null && valueNode.NodeType != XmlNodeType.Element) + valueNode = valueNode.NextSibling; + return valueNode; + } + return null; + } + + [PostProcessBuild(100)] + public static void IncludeLegacyEncodingProvider(BuildTarget target, string outputPath) + { + if (target != BuildTarget.StandaloneOSX) + return; + + var sourcePath = Path.Combine( + EditorApplication.applicationContentsPath, + "Resources", + "Scripting", + "NetStandard", + "EditorExtensions", + "System.Text.Encoding.CodePages.dll"); + if (!File.Exists(sourcePath)) + throw new FileNotFoundException( + "Unity's Shift-JIS encoding provider was not found; refusing to create a broken macOS build.", + sourcePath); + + var managedDirectory = Path.Combine(outputPath, "Contents", "Resources", "Data", "Managed"); + if (!Directory.Exists(managedDirectory)) + throw new DirectoryNotFoundException($"macOS Managed directory not found: {managedDirectory}"); + + var destinationPath = Path.Combine(managedDirectory, "System.Text.Encoding.CodePages.dll"); + File.Copy(sourcePath, destinationPath, true); + Debug.Log($"[ProjectQuint Build] Included Shift-JIS encoding provider: {destinationPath}"); + } + + [PostProcessBuild(110)] + public static void IncludeBundledBgaConverter(BuildTarget target, string outputPath) + { + if (target != BuildTarget.StandaloneOSX) + return; + + var projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); + var thirdPartyRoot = Path.Combine(projectRoot, "ThirdParty", "FFmpeg"); + var sourceBinary = Path.Combine(thirdPartyRoot, "macOS", "ffmpeg"); + var sourceArchive = Path.Combine(thirdPartyRoot, "Source", "ffmpeg-8.1.2.tar.xz"); + var expectedSourceHash = + "464beb5e7bf0c311e68b45ae2f04e9cc2af88851abb4082231742a74d97b524c"; + RequireFile(sourceBinary, "Bundled macOS FFmpeg converter"); + RequireFile(sourceArchive, "Corresponding FFmpeg source archive"); + RequireFile(Path.Combine(thirdPartyRoot, "COPYING.LGPLv2.1"), "FFmpeg LGPL license"); + RequireFile(Path.Combine(thirdPartyRoot, "NOTICE.txt"), "FFmpeg attribution notice"); + RequireFile(Path.Combine(projectRoot, "Tools", "build-ffmpeg-macos.sh"), "FFmpeg build script"); + + var actualSourceHash = ComputeSha256(sourceArchive); + if (!string.Equals(actualSourceHash, expectedSourceHash, StringComparison.OrdinalIgnoreCase)) + throw new InvalidDataException( + $"FFmpeg source archive hash mismatch. Expected {expectedSourceHash}, got {actualSourceHash}."); + + RunTool( + "/usr/bin/lipo", + new[] { sourceBinary, "-verify_arch", "arm64", "x86_64" }, + "bundled FFmpeg architecture verification"); + var version = RunToolCapture(sourceBinary, new[] { "-version" }, "bundled FFmpeg version check"); + if (!version.Contains("ffmpeg version 8.1.2") || + !version.Contains("--enable-encoder=h264_videotoolbox") || + version.Contains("--enable-gpl") || version.Contains("--enable-nonfree") || + version.Contains("libx264")) + throw new InvalidDataException( + "Bundled FFmpeg must be the pinned LGPL 8.1.2 VideoToolbox build without GPL/nonfree components."); + + var toolDirectory = Path.Combine(outputPath, "Contents", "Resources", "ProjectQuint", "Tools"); + Directory.CreateDirectory(toolDirectory); + var destinationBinary = Path.Combine(toolDirectory, "ffmpeg"); + File.Copy(sourceBinary, destinationBinary, true); + RunTool("/bin/chmod", new[] { "755", destinationBinary }, "bundled FFmpeg permission setup"); + + var noticeDirectory = Path.Combine(outputPath, "Contents", "Resources", "ThirdParty", "FFmpeg"); + Directory.CreateDirectory(noticeDirectory); + CopyRequired(thirdPartyRoot, noticeDirectory, "README.md"); + CopyRequired(thirdPartyRoot, noticeDirectory, "NOTICE.txt"); + CopyRequired(thirdPartyRoot, noticeDirectory, "COPYING.LGPLv2.1"); + File.Copy(sourceArchive, Path.Combine(noticeDirectory, Path.GetFileName(sourceArchive)), true); + File.Copy( + Path.Combine(projectRoot, "Tools", "build-ffmpeg-macos.sh"), + Path.Combine(noticeDirectory, "build-ffmpeg-macos.sh"), + true); + Debug.Log( + $"[ProjectQuint Build] Included Universal LGPL FFmpeg BGA converter and corresponding source: " + + destinationBinary); + } + + [PostProcessBuild(120)] + public static void IncludeThirdPartyNotices(BuildTarget target, string outputPath) + { + if (target != BuildTarget.StandaloneOSX) + return; + + var projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); + var sourceDirectory = Path.Combine(projectRoot, "ThirdParty", "Licenses"); + var destinationDirectory = Path.Combine( + outputPath, + "Contents", + "Resources", + "ThirdParty", + "Licenses"); + Directory.CreateDirectory(destinationDirectory); + + CopyRequired(sourceDirectory, destinationDirectory, "THIRD-PARTY-NOTICES.txt"); + CopyRequired(sourceDirectory, destinationDirectory, "FMOD-NOTICE.txt"); + CopyRequired(sourceDirectory, destinationDirectory, "Pretendard-OFL-1.1.txt"); + CopyRequired(sourceDirectory, destinationDirectory, "Rajdhani-OFL-1.1.txt"); + CopyRequired(sourceDirectory, destinationDirectory, "Sparkle-MIT.txt"); + Debug.Log( + $"[ProjectQuint Build] Included third-party notices and font licenses: " + + destinationDirectory); + } + + [PostProcessBuild(130)] + public static void IncludeSparkleFramework(BuildTarget target, string outputPath) + { + if (target != BuildTarget.StandaloneOSX) + return; + + var projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); + var sparkleRoot = Path.Combine(projectRoot, "ThirdParty", "Sparkle", "2.9.4"); + var sourceFramework = Path.Combine(sparkleRoot, "Sparkle.framework"); + var destinationDirectory = Path.Combine(outputPath, "Contents", "Frameworks"); + var destinationFramework = Path.Combine(destinationDirectory, "Sparkle.framework"); + if (!Directory.Exists(sourceFramework)) + throw new DirectoryNotFoundException( + $"Sparkle framework was not found: {sourceFramework}"); + Directory.CreateDirectory(destinationDirectory); + if (Directory.Exists(destinationFramework)) + Directory.Delete(destinationFramework, true); + RunTool( + "/usr/bin/ditto", + new[] { sourceFramework, destinationFramework }, + "Sparkle framework copy"); + RequireFile( + Path.Combine(destinationFramework, "Versions", "B", "Sparkle"), + "copied Sparkle framework binary"); + Debug.Log($"[ProjectQuint Build] Included Sparkle 2.9.4: {destinationFramework}"); + } + + private static void RequireFile(string path, string label) + { + if (!File.Exists(path)) + throw new FileNotFoundException($"{label} was not found; refusing to create an incomplete build.", path); + } + + private static void CopyRequired(string sourceDirectory, string destinationDirectory, string fileName) + { + var source = Path.Combine(sourceDirectory, fileName); + RequireFile(source, fileName); + File.Copy(source, Path.Combine(destinationDirectory, fileName), true); + } + + private static string ComputeSha256(string path) + { + using var stream = File.OpenRead(path); + using var sha256 = SHA256.Create(); + return string.Concat(sha256.ComputeHash(stream).Select(value => value.ToString("x2"))); + } + + private static string RunToolCapture(string executable, string[] arguments, string operation) + { + var startInfo = new System.Diagnostics.ProcessStartInfo + { + FileName = executable, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + Arguments = string.Join(" ", arguments.Select(QuoteProcessArgument)), + }; + using var process = System.Diagnostics.Process.Start(startInfo) ?? + throw new InvalidOperationException($"Could not start {operation}."); + var standardOutput = process.StandardOutput.ReadToEnd(); + var standardError = process.StandardError.ReadToEnd(); + process.WaitForExit(); + if (process.ExitCode != 0) + throw new InvalidOperationException( + $"ProjectQuint {operation} failed ({process.ExitCode}): {standardError}\n{standardOutput}"); + return standardOutput + "\n" + standardError; + } + } +} +#endif diff --git a/Assets/ProjectQuint/Editor/ProjectQuintBuild.cs.meta b/Assets/ProjectQuint/Editor/ProjectQuintBuild.cs.meta new file mode 100644 index 0000000..46f7c20 --- /dev/null +++ b/Assets/ProjectQuint/Editor/ProjectQuintBuild.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: b0d7464bb03b24dd4b47b33d5bcddcbf \ No newline at end of file diff --git a/Assets/ProjectQuint/Editor/ProjectQuintIconConfigurator.cs b/Assets/ProjectQuint/Editor/ProjectQuintIconConfigurator.cs new file mode 100644 index 0000000..8bf4ba4 --- /dev/null +++ b/Assets/ProjectQuint/Editor/ProjectQuintIconConfigurator.cs @@ -0,0 +1,37 @@ +#if UNITY_EDITOR +using System; +using System.Linq; +using UnityEditor; +using UnityEditor.Build; +using UnityEngine; + +namespace ProjectQuint.Editor +{ + internal static class ProjectQuintIconConfigurator + { + internal const string IconAssetPath = + "Assets/ProjectQuint/Art/AppIcon/ProjectQuintAppIcon.png"; + + [MenuItem("ProjectQuint/Apply macOS App Icon")] + public static void ApplyMacAppIcon() + { + AssetDatabase.ImportAsset(IconAssetPath, ImportAssetOptions.ForceUpdate); + var icon = AssetDatabase.LoadAssetAtPath(IconAssetPath); + if (icon == null) + throw new InvalidOperationException($"ProjectQuint app icon is missing: {IconAssetPath}"); + + var target = NamedBuildTarget.Standalone; + var iconCount = PlayerSettings.GetIconSizes(target, IconKind.Application).Length; + if (iconCount <= 0) + throw new InvalidOperationException("Unity reported no Standalone application icon slots."); + + PlayerSettings.SetIcons( + target, + Enumerable.Repeat(icon, iconCount).ToArray(), + IconKind.Application); + AssetDatabase.SaveAssets(); + Debug.Log($"[ProjectQuint Icon] Applied {IconAssetPath} to {iconCount} Standalone icon slots."); + } + } +} +#endif diff --git a/Assets/ProjectQuint/Editor/ProjectQuintIconConfigurator.cs.meta b/Assets/ProjectQuint/Editor/ProjectQuintIconConfigurator.cs.meta new file mode 100644 index 0000000..1c71de1 --- /dev/null +++ b/Assets/ProjectQuint/Editor/ProjectQuintIconConfigurator.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 262f464501a9e4e74b58ab78ccd699d7 \ No newline at end of file diff --git a/Assets/ProjectQuint/Editor/ProjectQuintVisualQa.cs b/Assets/ProjectQuint/Editor/ProjectQuintVisualQa.cs new file mode 100644 index 0000000..8d42b89 --- /dev/null +++ b/Assets/ProjectQuint/Editor/ProjectQuintVisualQa.cs @@ -0,0 +1,1149 @@ +using System.IO; +using System; +using System.Reflection; +using ProjectQuint.Bootstrap; +using ProjectQuint.Features.Flow; +using UnityEditor; +using UnityEngine; + +namespace ProjectQuint.Editor +{ + [InitializeOnLoad] + internal static class ProjectQuintVisualQa + { + private const string ResultsQaOutputArgument = "-resultsQaOutput"; + private const string MusicSelectQaOutputArgument = "-musicSelectQaOutput"; + private const string MusicSelectQaChartArgument = "-musicSelectQaChart"; + private const string MusicSelectQaHoldStepsArgument = "-musicSelectQaHoldSteps"; + private const string MusicSelectQaModalArgument = "-musicSelectQaModal"; + private const string MusicSelectQaPackRootArgument = "-musicSelectQaPackRoot"; + private const string MusicSelectQaCachePathArgument = "-musicSelectQaCachePath"; + private const double MusicSelectQaHoldIntervalSeconds = 0.055; + private const string QuickMenuQaOutputArgument = "-quickMenuQaOutput"; + private const string OptionsQaOutputArgument = "-optionsQaOutput"; + private const string OptionsQaCategoryArgument = "-optionsQaCategory"; + private const string OptionsQaFromCategoryArgument = "-optionsQaFromCategory"; + private const string OptionsQaKeyCountArgument = "-optionsQaKeyCount"; + private const string OptionsQaAudioSyncArgument = "-optionsQaAudioSync"; + private const string DaylightGalleryOutputArgument = "-daylightGalleryOutput"; + private const string ResultsQaSessionActiveKey = + "ProjectQuint.VisualQa.ResultsCaptureActive"; + private const string ResultsQaSessionOutputKey = + "ProjectQuint.VisualQa.ResultsCaptureOutput"; + private const string MusicSelectQaSessionActiveKey = + "ProjectQuint.VisualQa.MusicSelectCaptureActive"; + private const string MusicSelectQaSessionOutputKey = + "ProjectQuint.VisualQa.MusicSelectCaptureOutput"; + private const string QuickMenuQaSessionActiveKey = + "ProjectQuint.VisualQa.QuickMenuCaptureActive"; + private const string QuickMenuQaSessionOutputKey = + "ProjectQuint.VisualQa.QuickMenuCaptureOutput"; + private const string OptionsQaSessionActiveKey = + "ProjectQuint.VisualQa.OptionsCaptureActive"; + private const string OptionsQaSessionOutputKey = + "ProjectQuint.VisualQa.OptionsCaptureOutput"; + private const string OptionsQaSessionCategoryKey = + "ProjectQuint.VisualQa.OptionsCaptureCategory"; + private const string OptionsQaSessionFromCategoryKey = + "ProjectQuint.VisualQa.OptionsCaptureFromCategory"; + private const string DaylightGallerySessionActiveKey = + "ProjectQuint.VisualQa.DaylightGalleryActive"; + private const string DaylightGallerySessionOutputKey = + "ProjectQuint.VisualQa.DaylightGalleryOutput"; + private const string ChartFeaturePreviewMenu = + "Project Quint/QA/Open Chart Feature Radar Preview"; + private const string CapturePlayViewMenu = + "Project Quint/QA/Capture Play View"; + private const string CapturePlayView4KMenu = + "Project Quint/QA/Capture Play View 4K"; + private const string AudioOptionsPreviewMenu = + "Project Quint/QA/Open Audio Options Preview"; + private const string CaptureAudioOptionsMenu = + "Project Quint/QA/Capture Audio Options"; + private const string ResultsPreviewMenu = + "Project Quint/QA/Open Results Preview"; + private const string CaptureResultsPreviewMenu = + "Project Quint/QA/Capture Results Preview"; + private static string s_CommandLineResultsOutputPath; + private static double s_CommandLineResultsDeadline; + private static double s_CommandLineResultsCaptureAt; + private static bool s_CommandLineResultsPreviewReady; + private static bool s_CommandLineResultsCaptureRequested; + private static string s_CommandLineMusicSelectOutputPath; + private static double s_CommandLineMusicSelectDeadline; + private static double s_CommandLineMusicSelectCaptureAt; + private static double s_CommandLineMusicSelectNextHoldStepAt; + private static int s_CommandLineMusicSelectHoldSteps; + private static bool s_CommandLineMusicSelectPreviewReady; + private static bool s_CommandLineMusicSelectCaptureRequested; + private static string s_CommandLineQuickMenuOutputPath; + private static double s_CommandLineQuickMenuDeadline; + private static double s_CommandLineQuickMenuCaptureAt; + private static bool s_CommandLineQuickMenuPreviewReady; + private static bool s_CommandLineQuickMenuCaptureRequested; + private static string s_CommandLineOptionsOutputPath; + private static string s_CommandLineOptionsCategory; + private static string s_CommandLineOptionsFromCategory; + private static double s_CommandLineOptionsDeadline; + private static double s_CommandLineOptionsCaptureAt; + private static double s_CommandLineOptionsTransitionAt; + private static int s_CommandLineOptionsBurstFrame = -1; + private static bool s_CommandLineOptionsPreviewReady; + private static bool s_CommandLineOptionsCaptureRequested; + private static bool s_CommandLineOptionsPreviewPressed; + private static readonly string[] DaylightGallerySurfaces = + { + "startup-attribution", + "team-denia-branding", + "update", + "music-select", + "sort", + "filter", + "quick-menu", + "options", + "audio-sync", + "pack", + "pack-remove", + "tables", + "course", + "profile-overview", + "profile-rating", + "pack-cache", + "pack-cache-confirm", + "loading", + "pause", + "game-over", + "results", + "course-results", + "error", + }; + private static string s_DaylightGalleryOutputDirectory; + private static double s_DaylightGalleryDeadline; + private static double s_DaylightGalleryCaptureAt; + private static int s_DaylightGalleryIndex; + private static bool s_DaylightGallerySurfaceReady; + private static bool s_DaylightGalleryCaptureRequested; + + static ProjectQuintVisualQa() + { + if (SessionState.GetBool(ResultsQaSessionActiveKey, false)) + { + s_CommandLineResultsOutputPath = + SessionState.GetString(ResultsQaSessionOutputKey, string.Empty); + s_CommandLineResultsDeadline = EditorApplication.timeSinceStartup + 30.0; + EditorApplication.update -= UpdateCommandLineResultsCapture; + EditorApplication.update += UpdateCommandLineResultsCapture; + } + + if (SessionState.GetBool(MusicSelectQaSessionActiveKey, false)) + { + s_CommandLineMusicSelectOutputPath = + SessionState.GetString(MusicSelectQaSessionOutputKey, string.Empty); + s_CommandLineMusicSelectDeadline = EditorApplication.timeSinceStartup + 45.0; + EditorApplication.update -= UpdateCommandLineMusicSelectCapture; + EditorApplication.update += UpdateCommandLineMusicSelectCapture; + } + + if (SessionState.GetBool(QuickMenuQaSessionActiveKey, false)) + { + s_CommandLineQuickMenuOutputPath = + SessionState.GetString(QuickMenuQaSessionOutputKey, string.Empty); + s_CommandLineQuickMenuDeadline = EditorApplication.timeSinceStartup + 45.0; + EditorApplication.update -= UpdateCommandLineQuickMenuCapture; + EditorApplication.update += UpdateCommandLineQuickMenuCapture; + } + + if (SessionState.GetBool(OptionsQaSessionActiveKey, false)) + { + s_CommandLineOptionsOutputPath = + SessionState.GetString(OptionsQaSessionOutputKey, string.Empty); + s_CommandLineOptionsCategory = + SessionState.GetString(OptionsQaSessionCategoryKey, "gameplay"); + s_CommandLineOptionsFromCategory = + SessionState.GetString(OptionsQaSessionFromCategoryKey, string.Empty); + s_CommandLineOptionsDeadline = EditorApplication.timeSinceStartup + 30.0; + EditorApplication.update -= UpdateCommandLineOptionsCapture; + EditorApplication.update += UpdateCommandLineOptionsCapture; + } + + if (SessionState.GetBool(DaylightGallerySessionActiveKey, false)) + { + s_DaylightGalleryOutputDirectory = + SessionState.GetString(DaylightGallerySessionOutputKey, string.Empty); + s_DaylightGalleryDeadline = EditorApplication.timeSinceStartup + 180.0; + EditorApplication.update -= UpdateCommandLineDaylightGalleryCapture; + EditorApplication.update += UpdateCommandLineDaylightGalleryCapture; + } + } + + [MenuItem(ChartFeaturePreviewMenu)] + private static void OpenChartFeatureRadarPreview() + { + if (!EditorApplication.isPlaying) + { + Debug.LogError("[ProjectQuint Visual QA] Enter Play Mode before opening the preview."); + return; + } + + var app = UnityEngine.Object.FindFirstObjectByType(); + if (app == null) + { + Debug.LogError("[ProjectQuint Visual QA] ProjectQuintApp is not running."); + return; + } + + var projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); + var developmentRoot = Path.GetFullPath(Path.Combine(projectRoot, "..")); + var packRoot = Path.Combine(developmentRoot, "AERY-FULL-PACK"); + var cachePath = Path.Combine(projectRoot, "_workspace", "aery-feature-index-v4.json"); + if (!Directory.Exists(packRoot) || !File.Exists(cachePath)) + { + Debug.LogError( + $"[ProjectQuint Visual QA] Preview fixture unavailable. Pack: {packRoot}; cache: {cachePath}"); + return; + } + + app.EditorOpenChartFeaturePreview(packRoot, cachePath, "_opslimone_5a.bms"); + MaximizeGameView(1920, 1080); + Debug.Log("[ProjectQuint Visual QA] Chart feature radar preview opened."); + } + + [MenuItem(AudioOptionsPreviewMenu)] + private static void OpenAudioOptionsPreview() + { + if (!TryFindRunningApp(out var app)) + return; + app.EditorOpenAudioOptionsPreview(); + MaximizeGameView(1920, 1080); + Debug.Log("[ProjectQuint Visual QA] Audio options preview opened."); + } + + [MenuItem(CaptureAudioOptionsMenu)] + private static void CaptureAudioOptions() => + CapturePlayView(1920, 1080, "audio-dsp-options.png"); + + [MenuItem(ResultsPreviewMenu)] + private static void OpenResultsPreview() + { + if (!TryFindRunningApp(out var app)) + return; + app.EditorOpenResultsPreview(); + MaximizeGameView(1920, 1080); + Debug.Log("[ProjectQuint Visual QA] Results preview opened."); + } + + [MenuItem(CaptureResultsPreviewMenu)] + private static void CaptureResultsPreview() + { + if (!TryFindRunningApp(out var app)) + return; + app.EditorOpenResultsPreview(); + MaximizeGameView(1920, 1080); + var captureAt = EditorApplication.timeSinceStartup + 0.5; + void CaptureWhenReady() + { + if (EditorApplication.timeSinceStartup < captureAt) + return; + EditorApplication.update -= CaptureWhenReady; + CapturePlayView(1920, 1080, "results-v4-runtime-qa.png"); + } + EditorApplication.update += CaptureWhenReady; + } + + /// + /// Opens Play Mode, switches directly to the deterministic Results preview, + /// captures it, and exits. Intended for CLI-driven visual QA without playing + /// through an entire chart. + /// + public static void CaptureResultsPreviewFromCommandLine() + { + var projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); + s_CommandLineResultsOutputPath = ReadCommandLineValue(ResultsQaOutputArgument); + if (string.IsNullOrWhiteSpace(s_CommandLineResultsOutputPath)) + { + s_CommandLineResultsOutputPath = Path.Combine( + projectRoot, + "_workspace", + "results-v4-runtime-qa.png"); + } + + s_CommandLineResultsOutputPath = Path.GetFullPath(s_CommandLineResultsOutputPath); + Directory.CreateDirectory(Path.GetDirectoryName(s_CommandLineResultsOutputPath)); + if (File.Exists(s_CommandLineResultsOutputPath)) + File.Delete(s_CommandLineResultsOutputPath); + + s_CommandLineResultsDeadline = EditorApplication.timeSinceStartup + 30.0; + s_CommandLineResultsCaptureAt = 0.0; + s_CommandLineResultsPreviewReady = false; + s_CommandLineResultsCaptureRequested = false; + SessionState.SetBool(ResultsQaSessionActiveKey, true); + SessionState.SetString(ResultsQaSessionOutputKey, s_CommandLineResultsOutputPath); + EditorApplication.update -= UpdateCommandLineResultsCapture; + EditorApplication.update += UpdateCommandLineResultsCapture; + EditorApplication.EnterPlaymode(); + Debug.Log( + $"[ProjectQuint Visual QA] Starting immediate Results capture: {s_CommandLineResultsOutputPath}"); + } + + /// + /// Opens Play Mode, switches directly to a real indexed chart in Music + /// Select, captures it, and exits. This avoids waiting for gameplay to end. + /// + public static void CaptureMusicSelectPreviewFromCommandLine() + { + var projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); + s_CommandLineMusicSelectOutputPath = + ReadCommandLineValue(MusicSelectQaOutputArgument); + if (string.IsNullOrWhiteSpace(s_CommandLineMusicSelectOutputPath)) + { + s_CommandLineMusicSelectOutputPath = Path.Combine( + projectRoot, + "_workspace", + "music-select-a-v2-runtime-qa.png"); + } + + s_CommandLineMusicSelectOutputPath = + Path.GetFullPath(s_CommandLineMusicSelectOutputPath); + Directory.CreateDirectory(Path.GetDirectoryName(s_CommandLineMusicSelectOutputPath)); + if (File.Exists(s_CommandLineMusicSelectOutputPath)) + File.Delete(s_CommandLineMusicSelectOutputPath); + + s_CommandLineMusicSelectDeadline = EditorApplication.timeSinceStartup + 45.0; + s_CommandLineMusicSelectCaptureAt = 0.0; + s_CommandLineMusicSelectNextHoldStepAt = 0.0; + s_CommandLineMusicSelectHoldSteps = 0; + s_CommandLineMusicSelectPreviewReady = false; + s_CommandLineMusicSelectCaptureRequested = false; + SessionState.SetBool(MusicSelectQaSessionActiveKey, true); + SessionState.SetString( + MusicSelectQaSessionOutputKey, + s_CommandLineMusicSelectOutputPath); + EditorApplication.update -= UpdateCommandLineMusicSelectCapture; + EditorApplication.update += UpdateCommandLineMusicSelectCapture; + EditorApplication.EnterPlaymode(); + Debug.Log( + $"[ProjectQuint Visual QA] Starting immediate Music Select capture: {s_CommandLineMusicSelectOutputPath}"); + } + + /// + /// Opens the same real Music Select fixture, overlays Quick Menu directly, + /// captures it, and exits without requiring input or gameplay. + /// + public static void CaptureQuickMenuPreviewFromCommandLine() + { + var projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); + s_CommandLineQuickMenuOutputPath = + ReadCommandLineValue(QuickMenuQaOutputArgument); + if (string.IsNullOrWhiteSpace(s_CommandLineQuickMenuOutputPath)) + { + s_CommandLineQuickMenuOutputPath = Path.Combine( + projectRoot, + "_workspace", + "quick-menu-v2-runtime-qa.png"); + } + + s_CommandLineQuickMenuOutputPath = + Path.GetFullPath(s_CommandLineQuickMenuOutputPath); + Directory.CreateDirectory(Path.GetDirectoryName(s_CommandLineQuickMenuOutputPath)); + if (File.Exists(s_CommandLineQuickMenuOutputPath)) + File.Delete(s_CommandLineQuickMenuOutputPath); + + s_CommandLineQuickMenuDeadline = EditorApplication.timeSinceStartup + 45.0; + s_CommandLineQuickMenuCaptureAt = 0.0; + s_CommandLineQuickMenuPreviewReady = false; + s_CommandLineQuickMenuCaptureRequested = false; + SessionState.SetBool(QuickMenuQaSessionActiveKey, true); + SessionState.SetString( + QuickMenuQaSessionOutputKey, + s_CommandLineQuickMenuOutputPath); + EditorApplication.update -= UpdateCommandLineQuickMenuCapture; + EditorApplication.update += UpdateCommandLineQuickMenuCapture; + EditorApplication.EnterPlaymode(); + Debug.Log( + $"[ProjectQuint Visual QA] Starting immediate Quick Menu capture: {s_CommandLineQuickMenuOutputPath}"); + } + + /// + /// Opens Play Mode, switches directly to the requested Options category, + /// captures it, and exits without requiring menu navigation. + /// + public static void CaptureOptionsPreviewFromCommandLine() + { + var projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); + s_CommandLineOptionsOutputPath = ReadCommandLineValue(OptionsQaOutputArgument); + if (string.IsNullOrWhiteSpace(s_CommandLineOptionsOutputPath)) + { + s_CommandLineOptionsOutputPath = Path.Combine( + projectRoot, + "_workspace", + "options-module-gameplay-runtime-qa.png"); + } + + s_CommandLineOptionsCategory = + ReadCommandLineValue(OptionsQaCategoryArgument).Trim().ToLowerInvariant(); + if (!Enum.TryParse( + s_CommandLineOptionsCategory, + true, + out _)) + s_CommandLineOptionsCategory = "gameplay"; + s_CommandLineOptionsFromCategory = + ReadCommandLineValue(OptionsQaFromCategoryArgument).Trim().ToLowerInvariant(); + if (!Enum.TryParse( + s_CommandLineOptionsFromCategory, + true, + out _)) + s_CommandLineOptionsFromCategory = string.Empty; + + s_CommandLineOptionsOutputPath = + Path.GetFullPath(s_CommandLineOptionsOutputPath); + Directory.CreateDirectory(Path.GetDirectoryName(s_CommandLineOptionsOutputPath)); + if (File.Exists(s_CommandLineOptionsOutputPath)) + File.Delete(s_CommandLineOptionsOutputPath); + + s_CommandLineOptionsDeadline = EditorApplication.timeSinceStartup + 30.0; + s_CommandLineOptionsCaptureAt = 0.0; + s_CommandLineOptionsTransitionAt = 0.0; + s_CommandLineOptionsBurstFrame = -1; + s_CommandLineOptionsPreviewReady = false; + s_CommandLineOptionsCaptureRequested = false; + s_CommandLineOptionsPreviewPressed = false; + SessionState.SetBool(OptionsQaSessionActiveKey, true); + SessionState.SetString( + OptionsQaSessionOutputKey, + s_CommandLineOptionsOutputPath); + SessionState.SetString( + OptionsQaSessionCategoryKey, + s_CommandLineOptionsCategory); + SessionState.SetString( + OptionsQaSessionFromCategoryKey, + s_CommandLineOptionsFromCategory); + EditorApplication.update -= UpdateCommandLineOptionsCapture; + EditorApplication.update += UpdateCommandLineOptionsCapture; + EditorApplication.EnterPlaymode(); + Debug.Log( + $"[ProjectQuint Visual QA] Starting immediate Options ({s_CommandLineOptionsCategory}) capture: {s_CommandLineOptionsOutputPath}"); + } + + /// + /// Captures every retained Daylight Prism screen and overlay in one Play Mode + /// session. Each surface is opened through an editor-only app preview path, so + /// visual QA never waits for chart completion or triggers persistence. + /// + public static void CaptureDaylightGalleryFromCommandLine() + { + var projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); + s_DaylightGalleryOutputDirectory = + ReadCommandLineValue(DaylightGalleryOutputArgument); + if (string.IsNullOrWhiteSpace(s_DaylightGalleryOutputDirectory)) + { + s_DaylightGalleryOutputDirectory = Path.Combine( + projectRoot, + "_workspace", + "qa-captures", + "daylight-header-runtime"); + } + s_DaylightGalleryOutputDirectory = + Path.GetFullPath(s_DaylightGalleryOutputDirectory); + Directory.CreateDirectory(s_DaylightGalleryOutputDirectory); + foreach (var surface in DaylightGallerySurfaces) + { + var path = DaylightGalleryPath(surface); + if (File.Exists(path)) + File.Delete(path); + } + + s_DaylightGalleryDeadline = EditorApplication.timeSinceStartup + 180.0; + s_DaylightGalleryCaptureAt = 0.0; + s_DaylightGalleryIndex = 0; + s_DaylightGallerySurfaceReady = false; + s_DaylightGalleryCaptureRequested = false; + SessionState.SetBool(DaylightGallerySessionActiveKey, true); + SessionState.SetString( + DaylightGallerySessionOutputKey, + s_DaylightGalleryOutputDirectory); + EditorApplication.update -= UpdateCommandLineDaylightGalleryCapture; + EditorApplication.update += UpdateCommandLineDaylightGalleryCapture; + EditorApplication.EnterPlaymode(); + Debug.Log( + $"[ProjectQuint Visual QA] Starting Daylight gallery capture: {s_DaylightGalleryOutputDirectory}"); + } + + private static void UpdateCommandLineResultsCapture() + { + if (EditorApplication.timeSinceStartup >= s_CommandLineResultsDeadline) + { + FinishCommandLineResultsCapture( + 1, + "[ProjectQuint Visual QA] Timed out while capturing the Results preview."); + return; + } + + if (!EditorApplication.isPlaying) + return; + + if (!s_CommandLineResultsPreviewReady) + { + var app = UnityEngine.Object.FindFirstObjectByType(); + if (app == null) + return; + app.EditorOpenResultsPreview(); + MaximizeGameView(1920, 1080); + s_CommandLineResultsPreviewReady = true; + s_CommandLineResultsCaptureAt = EditorApplication.timeSinceStartup + 0.75; + return; + } + + if (!s_CommandLineResultsCaptureRequested && + EditorApplication.timeSinceStartup >= s_CommandLineResultsCaptureAt) + { + ScreenCapture.CaptureScreenshot(s_CommandLineResultsOutputPath, 1); + s_CommandLineResultsCaptureRequested = true; + return; + } + + if (s_CommandLineResultsCaptureRequested && + File.Exists(s_CommandLineResultsOutputPath) && + new FileInfo(s_CommandLineResultsOutputPath).Length > 0) + { + FinishCommandLineResultsCapture( + 0, + $"[ProjectQuint Visual QA] Results capture completed: {s_CommandLineResultsOutputPath}"); + } + } + + private static void FinishCommandLineResultsCapture(int exitCode, string message) + { + EditorApplication.update -= UpdateCommandLineResultsCapture; + SessionState.EraseBool(ResultsQaSessionActiveKey); + SessionState.EraseString(ResultsQaSessionOutputKey); + if (exitCode == 0) + Debug.Log(message); + else + Debug.LogError(message); + EditorApplication.Exit(exitCode); + } + + private static void UpdateCommandLineMusicSelectCapture() + { + if (EditorApplication.timeSinceStartup >= s_CommandLineMusicSelectDeadline) + { + FinishCommandLineMusicSelectCapture( + 1, + "[ProjectQuint Visual QA] Timed out while capturing Music Select."); + return; + } + + if (!EditorApplication.isPlaying) + return; + + if (!s_CommandLineMusicSelectPreviewReady) + { + var app = UnityEngine.Object.FindFirstObjectByType(); + if (app == null) + return; + + var projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); + var developmentRoot = Path.GetFullPath(Path.Combine(projectRoot, "..")); + var packRoot = ReadCommandLineValue(MusicSelectQaPackRootArgument); + if (string.IsNullOrWhiteSpace(packRoot)) + packRoot = Path.Combine(developmentRoot, "AERY-FULL-PACK"); + else + packRoot = Path.GetFullPath(packRoot); + var cachePath = ReadCommandLineValue(MusicSelectQaCachePathArgument); + if (string.IsNullOrWhiteSpace(cachePath)) + { + cachePath = Path.Combine( + projectRoot, + "_workspace", + "aery-feature-index-v4.json"); + } + else + { + cachePath = Path.GetFullPath(cachePath); + } + if (!Directory.Exists(packRoot)) + { + FinishCommandLineMusicSelectCapture( + 1, + $"[ProjectQuint Visual QA] Music Select fixture unavailable. Pack: {packRoot}"); + return; + } + + var preferredChart = ReadCommandLineValue(MusicSelectQaChartArgument); + if (string.IsNullOrWhiteSpace(preferredChart)) + preferredChart = "_opslimone_5a.bms"; + else if (string.Equals( + preferredChart, + "RANDOM", + StringComparison.OrdinalIgnoreCase)) + preferredChart = string.Empty; + app.EditorOpenChartFeaturePreview(packRoot, cachePath, preferredChart); + var modal = ReadCommandLineValue(MusicSelectQaModalArgument); + if (string.Equals(modal, "sort", StringComparison.OrdinalIgnoreCase)) + app.EditorOpenLibraryControlPreview(false); + else if (string.Equals(modal, "filter", StringComparison.OrdinalIgnoreCase)) + app.EditorOpenLibraryControlPreview(true); + MaximizeGameView(1920, 1080); + var holdStepsText = ReadCommandLineValue(MusicSelectQaHoldStepsArgument); + if (!int.TryParse(holdStepsText, out s_CommandLineMusicSelectHoldSteps)) + s_CommandLineMusicSelectHoldSteps = 0; + s_CommandLineMusicSelectHoldSteps = + Math.Max(0, Math.Min(256, s_CommandLineMusicSelectHoldSteps)); + s_CommandLineMusicSelectPreviewReady = true; + if (s_CommandLineMusicSelectHoldSteps > 0) + { + s_CommandLineMusicSelectNextHoldStepAt = + EditorApplication.timeSinceStartup + 1.0; + s_CommandLineMusicSelectCaptureAt = double.PositiveInfinity; + } + else + { + s_CommandLineMusicSelectCaptureAt = + EditorApplication.timeSinceStartup + 1.25; + } + return; + } + + if (s_CommandLineMusicSelectHoldSteps > 0 && + EditorApplication.timeSinceStartup >= + s_CommandLineMusicSelectNextHoldStepAt) + { + var app = UnityEngine.Object.FindFirstObjectByType(); + if (app == null) + return; + + app.EditorMoveLibrarySelection(1); + s_CommandLineMusicSelectHoldSteps--; + s_CommandLineMusicSelectNextHoldStepAt = + EditorApplication.timeSinceStartup + + MusicSelectQaHoldIntervalSeconds; + if (s_CommandLineMusicSelectHoldSteps == 0) + { + // Capture before the removed 80 ms correction could have run. + s_CommandLineMusicSelectCaptureAt = + EditorApplication.timeSinceStartup + 0.02; + } + return; + } + + if (!s_CommandLineMusicSelectCaptureRequested && + EditorApplication.timeSinceStartup >= s_CommandLineMusicSelectCaptureAt) + { + ScreenCapture.CaptureScreenshot(s_CommandLineMusicSelectOutputPath, 1); + s_CommandLineMusicSelectCaptureRequested = true; + return; + } + + if (s_CommandLineMusicSelectCaptureRequested && + File.Exists(s_CommandLineMusicSelectOutputPath) && + new FileInfo(s_CommandLineMusicSelectOutputPath).Length > 0) + { + FinishCommandLineMusicSelectCapture( + 0, + $"[ProjectQuint Visual QA] Music Select capture completed: {s_CommandLineMusicSelectOutputPath}"); + } + } + + private static void FinishCommandLineMusicSelectCapture(int exitCode, string message) + { + EditorApplication.update -= UpdateCommandLineMusicSelectCapture; + SessionState.EraseBool(MusicSelectQaSessionActiveKey); + SessionState.EraseString(MusicSelectQaSessionOutputKey); + if (exitCode == 0) + Debug.Log(message); + else + Debug.LogError(message); + EditorApplication.Exit(exitCode); + } + + private static void UpdateCommandLineQuickMenuCapture() + { + if (EditorApplication.timeSinceStartup >= s_CommandLineQuickMenuDeadline) + { + FinishCommandLineQuickMenuCapture( + 1, + "[ProjectQuint Visual QA] Timed out while capturing Quick Menu."); + return; + } + + if (!EditorApplication.isPlaying) + return; + + if (!s_CommandLineQuickMenuPreviewReady) + { + var app = UnityEngine.Object.FindFirstObjectByType(); + if (app == null) + return; + + var projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); + var developmentRoot = Path.GetFullPath(Path.Combine(projectRoot, "..")); + var packRoot = Path.Combine(developmentRoot, "AERY-FULL-PACK"); + var cachePath = Path.Combine( + projectRoot, + "_workspace", + "aery-feature-index-v4.json"); + if (!Directory.Exists(packRoot) || !File.Exists(cachePath)) + { + FinishCommandLineQuickMenuCapture( + 1, + $"[ProjectQuint Visual QA] Quick Menu fixture unavailable. Pack: {packRoot}; cache: {cachePath}"); + return; + } + + app.EditorOpenChartFeaturePreview(packRoot, cachePath, "_opslimone_5a.bms"); + app.EditorOpenQuickOptionsPreview(); + MaximizeGameView(1920, 1080); + s_CommandLineQuickMenuPreviewReady = true; + s_CommandLineQuickMenuCaptureAt = + EditorApplication.timeSinceStartup + 1.25; + return; + } + + if (!s_CommandLineQuickMenuCaptureRequested && + EditorApplication.timeSinceStartup >= s_CommandLineQuickMenuCaptureAt) + { + ScreenCapture.CaptureScreenshot(s_CommandLineQuickMenuOutputPath, 1); + s_CommandLineQuickMenuCaptureRequested = true; + return; + } + + if (s_CommandLineQuickMenuCaptureRequested && + File.Exists(s_CommandLineQuickMenuOutputPath) && + new FileInfo(s_CommandLineQuickMenuOutputPath).Length > 0) + { + FinishCommandLineQuickMenuCapture( + 0, + $"[ProjectQuint Visual QA] Quick Menu capture completed: {s_CommandLineQuickMenuOutputPath}"); + } + } + + private static void FinishCommandLineQuickMenuCapture(int exitCode, string message) + { + EditorApplication.update -= UpdateCommandLineQuickMenuCapture; + SessionState.EraseBool(QuickMenuQaSessionActiveKey); + SessionState.EraseString(QuickMenuQaSessionOutputKey); + if (exitCode == 0) + Debug.Log(message); + else + Debug.LogError(message); + EditorApplication.Exit(exitCode); + } + + private static void UpdateCommandLineOptionsCapture() + { + if (EditorApplication.timeSinceStartup >= s_CommandLineOptionsDeadline) + { + FinishCommandLineOptionsCapture( + 1, + "[ProjectQuint Visual QA] Timed out while capturing Options."); + return; + } + + if (!EditorApplication.isPlaying) + return; + + if (!s_CommandLineOptionsPreviewReady) + { + var app = UnityEngine.Object.FindFirstObjectByType(); + if (app == null) + return; + + var initialCategory = string.IsNullOrWhiteSpace(s_CommandLineOptionsFromCategory) + ? s_CommandLineOptionsCategory + : s_CommandLineOptionsFromCategory; + OpenOptionsCategory(app, initialCategory); + var audioSyncMode = ReadCommandLineValue(OptionsQaAudioSyncArgument); + if (string.Equals(audioSyncMode, "run", StringComparison.OrdinalIgnoreCase)) + app.EditorStartAudioSyncCalibrationPreview(); + else if (string.Equals(audioSyncMode, "1", StringComparison.Ordinal)) + app.EditorOpenAudioSyncCalibrationPreview(); + + MaximizeGameView(1920, 1080); + s_CommandLineOptionsPreviewReady = true; + if (!string.Equals( + initialCategory, + s_CommandLineOptionsCategory, + StringComparison.Ordinal)) + { + s_CommandLineOptionsTransitionAt = + EditorApplication.timeSinceStartup + 0.5; + s_CommandLineOptionsCaptureAt = double.PositiveInfinity; + } + else + { + s_CommandLineOptionsCaptureAt = + EditorApplication.timeSinceStartup + 1.0; + } + return; + } + + if (s_CommandLineOptionsTransitionAt > 0.0 && + EditorApplication.timeSinceStartup >= s_CommandLineOptionsTransitionAt) + { + var app = UnityEngine.Object.FindFirstObjectByType(); + if (app == null) + return; + OpenOptionsCategory(app, s_CommandLineOptionsCategory); + s_CommandLineOptionsTransitionAt = 0.0; + s_CommandLineOptionsBurstFrame = 0; + s_CommandLineOptionsCaptureAt = + EditorApplication.timeSinceStartup + 0.5; + return; + } + + if (s_CommandLineOptionsBurstFrame >= 0 && + s_CommandLineOptionsBurstFrame < 6) + { + ScreenCapture.CaptureScreenshot( + OptionsBurstPath( + s_CommandLineOptionsOutputPath, + s_CommandLineOptionsBurstFrame), + 1); + s_CommandLineOptionsBurstFrame++; + return; + } + + if (!s_CommandLineOptionsCaptureRequested && + EditorApplication.timeSinceStartup >= s_CommandLineOptionsCaptureAt) + { + ScreenCapture.CaptureScreenshot(s_CommandLineOptionsOutputPath, 1); + s_CommandLineOptionsCaptureRequested = true; + return; + } + + if (!s_CommandLineOptionsPreviewPressed && + string.Equals( + s_CommandLineOptionsCategory, + "gameplay", + StringComparison.Ordinal) && + EditorApplication.timeSinceStartup >= s_CommandLineOptionsCaptureAt - 0.12) + { + var app = UnityEngine.Object.FindFirstObjectByType(); + if (app != null) + { + app.EditorPressOptionsPreviewLane(3); + s_CommandLineOptionsPreviewPressed = true; + } + return; + } + + if (s_CommandLineOptionsCaptureRequested && + File.Exists(s_CommandLineOptionsOutputPath) && + new FileInfo(s_CommandLineOptionsOutputPath).Length > 0) + { + FinishCommandLineOptionsCapture( + 0, + $"[ProjectQuint Visual QA] Options capture completed: {s_CommandLineOptionsOutputPath}"); + } + } + + private static void OpenOptionsCategory(ProjectQuintApp app, string categoryName) + { + var keyCountText = ReadCommandLineValue(OptionsQaKeyCountArgument); + var hasRequestedKeyCount = + int.TryParse(keyCountText, out var requestedKeyCount) && + requestedKeyCount >= 4 && + requestedKeyCount <= 7; + if (string.Equals(categoryName, "audio", StringComparison.Ordinal)) + { + app.EditorOpenAudioOptionsPreview(); + return; + } + if (string.Equals(categoryName, "gameplay", StringComparison.Ordinal)) + { + app.EditorOpenGameplayOptionsPreview( + hasRequestedKeyCount ? requestedKeyCount : 0); + return; + } + if (Enum.TryParse(categoryName, true, out var category)) + { + app.EditorOpenOptionsPreview( + category, + hasRequestedKeyCount ? requestedKeyCount : 0); + } + } + + private static string OptionsBurstPath(string outputPath, int frame) + { + var directory = Path.GetDirectoryName(outputPath); + var fileName = Path.GetFileNameWithoutExtension(outputPath); + return Path.Combine(directory ?? string.Empty, $"{fileName}-frame-{frame:00}.png"); + } + + private static void FinishCommandLineOptionsCapture(int exitCode, string message) + { + EditorApplication.update -= UpdateCommandLineOptionsCapture; + SessionState.EraseBool(OptionsQaSessionActiveKey); + SessionState.EraseString(OptionsQaSessionOutputKey); + SessionState.EraseString(OptionsQaSessionCategoryKey); + SessionState.EraseString(OptionsQaSessionFromCategoryKey); + if (exitCode == 0) + Debug.Log(message); + else + Debug.LogError(message); + EditorApplication.Exit(exitCode); + } + + private static void UpdateCommandLineDaylightGalleryCapture() + { + if (EditorApplication.timeSinceStartup >= s_DaylightGalleryDeadline) + { + FinishCommandLineDaylightGalleryCapture( + 1, + "[ProjectQuint Visual QA] Timed out while capturing the Daylight gallery."); + return; + } + + if (!EditorApplication.isPlaying) + return; + if (s_DaylightGalleryIndex >= DaylightGallerySurfaces.Length) + { + FinishCommandLineDaylightGalleryCapture( + 0, + $"[ProjectQuint Visual QA] Daylight gallery completed: {s_DaylightGalleryOutputDirectory}"); + return; + } + + var app = UnityEngine.Object.FindFirstObjectByType(); + if (app == null) + return; + var surface = DaylightGallerySurfaces[s_DaylightGalleryIndex]; + var outputPath = DaylightGalleryPath(surface); + if (!s_DaylightGallerySurfaceReady) + { + OpenDaylightGallerySurface(app, surface); + MaximizeGameView(1920, 1080); + s_DaylightGallerySurfaceReady = true; + s_DaylightGalleryCaptureRequested = false; + s_DaylightGalleryCaptureAt = EditorApplication.timeSinceStartup + 0.8; + return; + } + + if (!s_DaylightGalleryCaptureRequested && + EditorApplication.timeSinceStartup >= s_DaylightGalleryCaptureAt) + { + ScreenCapture.CaptureScreenshot(outputPath, 1); + s_DaylightGalleryCaptureRequested = true; + return; + } + + if (!s_DaylightGalleryCaptureRequested || + !File.Exists(outputPath) || + new FileInfo(outputPath).Length <= 0) + return; + + Debug.Log($"[ProjectQuint Visual QA] Captured {surface}: {outputPath}"); + s_DaylightGalleryIndex++; + s_DaylightGallerySurfaceReady = false; + s_DaylightGalleryCaptureRequested = false; + } + + private static void OpenDaylightGallerySurface(ProjectQuintApp app, string surface) + { + var projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); + var developmentRoot = Path.GetFullPath(Path.Combine(projectRoot, "..")); + var packRoot = Path.Combine(developmentRoot, "AERY-FULL-PACK"); + var cachePath = Path.Combine( + projectRoot, + "_workspace", + "aery-feature-index-v4.json"); + var needsLibrary = + surface == "music-select" || + surface == "sort" || + surface == "filter" || + surface == "quick-menu" || + surface.StartsWith("profile", StringComparison.Ordinal) || + surface == "loading" || + surface == "pause"; + if (needsLibrary) + { + if (!Directory.Exists(packRoot)) + { + throw new DirectoryNotFoundException( + $"Daylight gallery fixture is unavailable: {packRoot}"); + } + app.EditorOpenChartFeaturePreview(packRoot, cachePath, "_opslimone_5a.bms"); + } + + switch (surface) + { + case "music-select": + return; + case "sort": + app.EditorOpenLibraryControlPreview(false); + return; + case "filter": + app.EditorOpenLibraryControlPreview(true); + return; + case "quick-menu": + app.EditorOpenQuickOptionsPreview(); + return; + case "options": + app.EditorOpenOptionsPreview(OptionsCategory.Gameplay); + return; + case "results": + app.EditorOpenResultsPreview(); + return; + default: + app.EditorOpenVisualQaSurface(surface); + return; + } + } + + private static string DaylightGalleryPath(string surface) => + Path.Combine(s_DaylightGalleryOutputDirectory, $"{surface}.png"); + + private static void FinishCommandLineDaylightGalleryCapture( + int exitCode, + string message) + { + EditorApplication.update -= UpdateCommandLineDaylightGalleryCapture; + SessionState.EraseBool(DaylightGallerySessionActiveKey); + SessionState.EraseString(DaylightGallerySessionOutputKey); + if (exitCode == 0) + Debug.Log(message); + else + Debug.LogError(message); + EditorApplication.Exit(exitCode); + } + + private static string ReadCommandLineValue(string argumentName) + { + var arguments = Environment.GetCommandLineArgs(); + for (var index = 0; index + 1 < arguments.Length; index++) + { + if (string.Equals(arguments[index], argumentName, StringComparison.Ordinal)) + return arguments[index + 1]; + } + return string.Empty; + } + + [MenuItem(CapturePlayViewMenu)] + private static void CapturePlayView() + { + CapturePlayView(1920, 1080, "chart-feature-radar-song-select.png"); + } + + [MenuItem(CapturePlayView4KMenu)] + private static void CapturePlayView4K() + { + CapturePlayView(3840, 2160, "chart-feature-radar-song-select-4k.png"); + } + + private static void CapturePlayView(int width, int height, string fileName) + { + if (!EditorApplication.isPlaying) + { + Debug.LogError("[ProjectQuint Visual QA] Enter Play Mode before capturing the play view."); + return; + } + + var projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); + var outputPath = Path.Combine( + projectRoot, + "_workspace", + fileName); + Directory.CreateDirectory(Path.GetDirectoryName(outputPath)); + MaximizeGameView(width, height); + ScreenCapture.CaptureScreenshot(outputPath, 1); + Debug.Log($"[ProjectQuint Visual QA] Play view capture requested: {outputPath}"); + } + + private static bool TryFindRunningApp(out ProjectQuintApp app) + { + app = null; + if (!EditorApplication.isPlaying) + { + Debug.LogError("[ProjectQuint Visual QA] Enter Play Mode before opening the preview."); + return false; + } + app = UnityEngine.Object.FindFirstObjectByType(); + if (app != null) + return true; + Debug.LogError("[ProjectQuint Visual QA] ProjectQuintApp is not running."); + return false; + } + + private static void MaximizeGameView(int width, int height) + { + var editorAssembly = typeof(EditorWindow).Assembly; + var gameViewType = editorAssembly.GetType("UnityEditor.GameView"); + if (gameViewType == null) + throw new InvalidOperationException("Unity GameView type is unavailable."); + var gameView = EditorWindow.GetWindow(gameViewType); + SelectFixedGameViewSize(editorAssembly, gameView, width, height); + gameView.maximized = true; + gameView.Repaint(); + } + + private static void SelectFixedGameViewSize( + Assembly editorAssembly, + EditorWindow gameView, + int width, + int height) + { + var sizesType = editorAssembly.GetType("UnityEditor.GameViewSizes"); + var groupType = editorAssembly.GetType("UnityEditor.GameViewSizeGroupType"); + var sizeType = editorAssembly.GetType("UnityEditor.GameViewSize"); + var kindType = editorAssembly.GetType("UnityEditor.GameViewSizeType"); + if (sizesType == null || groupType == null || sizeType == null || kindType == null) + throw new InvalidOperationException("Unity GameView size API is unavailable."); + + var singletonType = typeof(ScriptableSingleton<>).MakeGenericType(sizesType); + var instance = singletonType + .GetProperty("instance", BindingFlags.Public | BindingFlags.Static) + ?.GetValue(null); + var standalone = Enum.Parse(groupType, "Standalone"); + var group = sizesType + .GetMethod("GetGroup", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) + ?.Invoke(instance, new[] { standalone }); + if (group == null) + throw new InvalidOperationException("Unity standalone GameView size group is unavailable."); + + var groupRuntimeType = group.GetType(); + var getTotalCount = groupRuntimeType.GetMethod( + "GetTotalCount", + BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance); + var getSize = groupRuntimeType.GetMethod( + "GetGameViewSize", + BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance); + var count = (int)getTotalCount.Invoke(group, null); + var selectedIndex = -1; + for (var index = 0; index < count; index++) + { + var size = getSize.Invoke(group, new object[] { index }); + var runtimeType = size.GetType(); + var candidateWidth = (int)runtimeType + .GetProperty("width", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) + .GetValue(size); + var candidateHeight = (int)runtimeType + .GetProperty("height", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) + .GetValue(size); + if (candidateWidth == width && candidateHeight == height) + { + selectedIndex = index; + break; + } + } + + if (selectedIndex < 0) + { + var fixedResolution = Enum.Parse(kindType, "FixedResolution"); + var size = Activator.CreateInstance( + sizeType, + BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance, + null, + new object[] { fixedResolution, width, height, "Project Quint Visual QA" }, + null); + groupRuntimeType + .GetMethod("AddCustomSize", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) + .Invoke(group, new[] { size }); + selectedIndex = (int)getTotalCount.Invoke(group, null) - 1; + } + + gameView.GetType() + .GetProperty("selectedSizeIndex", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) + .SetValue(gameView, selectedIndex); + } + } +} diff --git a/Assets/ProjectQuint/Editor/ProjectQuintVisualQa.cs.meta b/Assets/ProjectQuint/Editor/ProjectQuintVisualQa.cs.meta new file mode 100644 index 0000000..8753f8b --- /dev/null +++ b/Assets/ProjectQuint/Editor/ProjectQuintVisualQa.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9d3df3121b0094dfc8d2b7b52f6be9b9 \ No newline at end of file diff --git a/Assets/ProjectQuint/Plugins.meta b/Assets/ProjectQuint/Plugins.meta new file mode 100644 index 0000000..6e77f00 --- /dev/null +++ b/Assets/ProjectQuint/Plugins.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 6248c2c2485984703b8814051c952fe3 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/ProjectQuint/Plugins/macOS.meta b/Assets/ProjectQuint/Plugins/macOS.meta new file mode 100644 index 0000000..503d755 --- /dev/null +++ b/Assets/ProjectQuint/Plugins/macOS.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 407742959808e452d95dc237db8e178b +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/ProjectQuint/Plugins/macOS/ProjectQuintInput.bundle.meta b/Assets/ProjectQuint/Plugins/macOS/ProjectQuintInput.bundle.meta new file mode 100644 index 0000000..46ed7d5 --- /dev/null +++ b/Assets/ProjectQuint/Plugins/macOS/ProjectQuintInput.bundle.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 529df0bdea17b4c449d335536cf75642 \ No newline at end of file diff --git a/Assets/ProjectQuint/Plugins/macOS/ProjectQuintInput.bundle/Contents/Info.plist b/Assets/ProjectQuint/Plugins/macOS/ProjectQuintInput.bundle/Contents/Info.plist new file mode 100644 index 0000000..47620a9 --- /dev/null +++ b/Assets/ProjectQuint/Plugins/macOS/ProjectQuintInput.bundle/Contents/Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + English + CFBundleExecutable + ProjectQuintInput + CFBundleIdentifier + com.teamdenia.projectquint.input + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + ProjectQuintInput + CFBundlePackageType + BNDL + CFBundleShortVersionString + 1.0.0 + CFBundleVersion + 1 + + diff --git a/Assets/ProjectQuint/Plugins/macOS/ProjectQuintInput.bundle/Contents/MacOS/ProjectQuintInput b/Assets/ProjectQuint/Plugins/macOS/ProjectQuintInput.bundle/Contents/MacOS/ProjectQuintInput new file mode 100755 index 0000000..3eccb67 Binary files /dev/null and b/Assets/ProjectQuint/Plugins/macOS/ProjectQuintInput.bundle/Contents/MacOS/ProjectQuintInput differ diff --git a/Assets/ProjectQuint/Plugins/macOS/ProjectQuintInput.bundle/Contents/_CodeSignature/CodeResources b/Assets/ProjectQuint/Plugins/macOS/ProjectQuintInput.bundle/Contents/_CodeSignature/CodeResources new file mode 100644 index 0000000..d5d0fd7 --- /dev/null +++ b/Assets/ProjectQuint/Plugins/macOS/ProjectQuintInput.bundle/Contents/_CodeSignature/CodeResources @@ -0,0 +1,115 @@ + + + + + files + + files2 + + rules + + ^Resources/ + + ^Resources/.*\.lproj/ + + optional + + weight + 1000 + + ^Resources/.*\.lproj/locversion.plist$ + + omit + + weight + 1100 + + ^Resources/Base\.lproj/ + + weight + 1010 + + ^version.plist$ + + + rules2 + + .*\.dSYM($|/) + + weight + 11 + + ^(.*/)?\.DS_Store$ + + omit + + weight + 2000 + + ^(Frameworks|SharedFrameworks|PlugIns|Plug-ins|XPCServices|Helpers|MacOS|Library/(Automator|Spotlight|LoginItems))/ + + nested + + weight + 10 + + ^.* + + ^Info\.plist$ + + omit + + weight + 20 + + ^PkgInfo$ + + omit + + weight + 20 + + ^Resources/ + + weight + 20 + + ^Resources/.*\.lproj/ + + optional + + weight + 1000 + + ^Resources/.*\.lproj/locversion.plist$ + + omit + + weight + 1100 + + ^Resources/Base\.lproj/ + + weight + 1010 + + ^[^/]+$ + + nested + + weight + 10 + + ^embedded\.provisionprofile$ + + weight + 20 + + ^version\.plist$ + + weight + 20 + + + + diff --git a/Assets/ProjectQuint/Plugins/macOS/ProjectQuintUpdater.bundle.meta b/Assets/ProjectQuint/Plugins/macOS/ProjectQuintUpdater.bundle.meta new file mode 100644 index 0000000..7cd9848 --- /dev/null +++ b/Assets/ProjectQuint/Plugins/macOS/ProjectQuintUpdater.bundle.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c361e5c9916a44c1b72e671552512b9c diff --git a/Assets/ProjectQuint/Plugins/macOS/ProjectQuintUpdater.bundle/Contents/Info.plist b/Assets/ProjectQuint/Plugins/macOS/ProjectQuintUpdater.bundle/Contents/Info.plist new file mode 100644 index 0000000..6069fca --- /dev/null +++ b/Assets/ProjectQuint/Plugins/macOS/ProjectQuintUpdater.bundle/Contents/Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + English + CFBundleExecutable + ProjectQuintUpdater + CFBundleIdentifier + com.teamdenia.projectquint.updater + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + ProjectQuintUpdater + CFBundlePackageType + BNDL + CFBundleShortVersionString + 1.0.0 + CFBundleVersion + 1 + + diff --git a/Assets/ProjectQuint/Plugins/macOS/ProjectQuintUpdater.bundle/Contents/MacOS/ProjectQuintUpdater b/Assets/ProjectQuint/Plugins/macOS/ProjectQuintUpdater.bundle/Contents/MacOS/ProjectQuintUpdater new file mode 100755 index 0000000..7dfb7a7 Binary files /dev/null and b/Assets/ProjectQuint/Plugins/macOS/ProjectQuintUpdater.bundle/Contents/MacOS/ProjectQuintUpdater differ diff --git a/Assets/ProjectQuint/Plugins/macOS/ProjectQuintUpdater.bundle/Contents/_CodeSignature/CodeResources b/Assets/ProjectQuint/Plugins/macOS/ProjectQuintUpdater.bundle/Contents/_CodeSignature/CodeResources new file mode 100644 index 0000000..d5d0fd7 --- /dev/null +++ b/Assets/ProjectQuint/Plugins/macOS/ProjectQuintUpdater.bundle/Contents/_CodeSignature/CodeResources @@ -0,0 +1,115 @@ + + + + + files + + files2 + + rules + + ^Resources/ + + ^Resources/.*\.lproj/ + + optional + + weight + 1000 + + ^Resources/.*\.lproj/locversion.plist$ + + omit + + weight + 1100 + + ^Resources/Base\.lproj/ + + weight + 1010 + + ^version.plist$ + + + rules2 + + .*\.dSYM($|/) + + weight + 11 + + ^(.*/)?\.DS_Store$ + + omit + + weight + 2000 + + ^(Frameworks|SharedFrameworks|PlugIns|Plug-ins|XPCServices|Helpers|MacOS|Library/(Automator|Spotlight|LoginItems))/ + + nested + + weight + 10 + + ^.* + + ^Info\.plist$ + + omit + + weight + 20 + + ^PkgInfo$ + + omit + + weight + 20 + + ^Resources/ + + weight + 20 + + ^Resources/.*\.lproj/ + + optional + + weight + 1000 + + ^Resources/.*\.lproj/locversion.plist$ + + omit + + weight + 1100 + + ^Resources/Base\.lproj/ + + weight + 1010 + + ^[^/]+$ + + nested + + weight + 10 + + ^embedded\.provisionprofile$ + + weight + 20 + + ^version\.plist$ + + weight + 20 + + + + diff --git a/Assets/ProjectQuint/Runtime.meta b/Assets/ProjectQuint/Runtime.meta new file mode 100644 index 0000000..0e0ca24 --- /dev/null +++ b/Assets/ProjectQuint/Runtime.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 77996964903b549a3ba6124b7f2e91ac +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/ProjectQuint/Runtime/Core.meta b/Assets/ProjectQuint/Runtime/Core.meta new file mode 100644 index 0000000..69db8a8 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Core.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 17adf60357a0142419c85a863df17c9b +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/ProjectQuint/Runtime/Core/Analysis.meta b/Assets/ProjectQuint/Runtime/Core/Analysis.meta new file mode 100644 index 0000000..54a0f19 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Core/Analysis.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: fa19f129aed7947eba7998908f182719 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/ProjectQuint/Runtime/Core/Analysis/ChartFeatureAnalysis.cs b/Assets/ProjectQuint/Runtime/Core/Analysis/ChartFeatureAnalysis.cs new file mode 100644 index 0000000..4e879f0 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Core/Analysis/ChartFeatureAnalysis.cs @@ -0,0 +1,741 @@ +using System; +using System.Collections.Generic; +using ProjectQuint.Core.Charts; + +namespace ProjectQuint.Core.Analysis +{ + public enum ChartFeatureAxis + { + Notes, + Peak, + Chord, + Long, + SofLan, + Tech, + } + + [Serializable] + public sealed class ChartFeatureVector + { + public const int CurrentAnalyzerVersion = 3; + public const int CurrentCalibrationVersion = 4; + public const float ReferenceValue = 100f; + public const float MaximumValue = 200f; + + public int AnalyzerVersion = CurrentAnalyzerVersion; + public int CalibrationVersion = CurrentCalibrationVersion; + public int KeyCount; + public int TotalLaneCount; + public int InputCount; + public int AnomalyCount; + public double ActiveDurationSeconds; + public double NotesRaw; + public double PeakRaw; + public double ChordRaw; + public double LongRaw; + public double SofLanRaw; + public double TechRaw; + public float Notes; + public float Peak; + public float Chord; + public float Long; + public float SofLan; + public float Tech; + + public bool IsCurrent => + AnalyzerVersion == CurrentAnalyzerVersion && + CalibrationVersion == CurrentCalibrationVersion; + + public float Value(ChartFeatureAxis axis) => axis switch + { + ChartFeatureAxis.Notes => Notes, + ChartFeatureAxis.Peak => Peak, + ChartFeatureAxis.Chord => Chord, + ChartFeatureAxis.Long => Long, + ChartFeatureAxis.SofLan => SofLan, + ChartFeatureAxis.Tech => Tech, + _ => 0f, + }; + } + + public static class ChartFeatureCalibration + { + public static ChartFeatureVector Apply(ChartFeatureVector vector) + { + if (vector == null) + throw new ArgumentNullException(nameof(vector)); + if (vector.AnalyzerVersion != ChartFeatureVector.CurrentAnalyzerVersion) + throw new InvalidOperationException("Raw chart features were produced by an incompatible analyzer."); + + vector.Notes = Normalize(ChartFeatureAxis.Notes, vector.KeyCount, vector.NotesRaw); + vector.Peak = Normalize(ChartFeatureAxis.Peak, vector.KeyCount, vector.PeakRaw); + vector.Chord = Normalize(ChartFeatureAxis.Chord, vector.KeyCount, vector.ChordRaw); + vector.Long = Normalize(ChartFeatureAxis.Long, vector.KeyCount, vector.LongRaw); + vector.SofLan = Normalize(ChartFeatureAxis.SofLan, vector.KeyCount, vector.SofLanRaw); + vector.Tech = Normalize(ChartFeatureAxis.Tech, vector.KeyCount, vector.TechRaw); + vector.CalibrationVersion = ChartFeatureVector.CurrentCalibrationVersion; + return vector; + } + + public static float Normalize(ChartFeatureAxis axis, int keyCount, double rawValue) + { + if (!(rawValue > 0) || double.IsNaN(rawValue) || double.IsInfinity(rawValue)) + return 0f; + + keyCount = Math.Max(4, Math.Min(7, keyCount)); + var laneScale = keyCount / 5.0; + var notesScale = keyCount switch + { + 4 => 6.0 / 7.0, + 6 => 8.0 / 7.0, + 7 => 9.0 / 7.0, + _ => 1.0, + }; + var peakScale = 1.0 + 0.125 * (keyCount - 5); + double first; + double second; + double third; + double fourth; + double fifth; + double sixth; + switch (axis) + { + case ChartFeatureAxis.Notes: + (first, second, third, fourth, fifth, sixth) = + Scale((300.0, 600.0, 865.0, 1065.0, 1260.0, 1900.0), notesScale); + break; + case ChartFeatureAxis.Peak: + (first, second, third, fourth, fifth, sixth) = + Scale((9.5, 17.1, 23.9, 29.3, 34.8, 48.0), peakScale); + break; + case ChartFeatureAxis.Chord: + (first, second, third, fourth, fifth, sixth) = + Scale((36.0, 153.0, 317.0, 461.0, 579.0, 1500.0), laneScale); + break; + case ChartFeatureAxis.Long: + (first, second, third, fourth, fifth, sixth) = + (0.005, 0.03, 0.10, 0.20, 0.30, 0.60); + break; + case ChartFeatureAxis.SofLan: + (first, second, third, fourth, fifth, sixth) = + (0.15, 1.0, 6.0, 15.0, 30.0, 150.0); + break; + default: + (first, second, third, fourth, fifth, sixth) = + Scale((8.0, 21.0, 36.0, 67.0, 111.0, 200.0), laneScale); + break; + } + + return (float)Piecewise(rawValue, first, second, third, fourth, fifth, sixth); + } + + private static (double, double, double, double, double, double) Scale( + (double, double, double, double, double, double) values, + double scale) => + (values.Item1 * scale, + values.Item2 * scale, + values.Item3 * scale, + values.Item4 * scale, + values.Item5 * scale, + values.Item6 * scale); + + private static double Piecewise( + double value, + double first, + double second, + double third, + double fourth, + double fifth, + double sixth) + { + const double step = ChartFeatureVector.MaximumValue / 6.0; + if (value <= first) + return step * value / first; + if (value <= second) + return step + step * (value - first) / (second - first); + if (value <= third) + return step * 2.0 + step * (value - second) / (third - second); + if (value <= fourth) + return step * 3.0 + step * (value - third) / (fourth - third); + if (value <= fifth) + return step * 4.0 + step * (value - fourth) / (fifth - fourth); + if (value <= sixth) + return step * 5.0 + step * (value - fifth) / (sixth - fifth); + return ChartFeatureVector.MaximumValue; + } + } + + public static class ChartFeatureAnalyzer + { + private const double SimultaneousEpsilonSeconds = 0.0005; + private const double MinimumActiveDurationSeconds = 15.0; + private const double PeakSampleStepSeconds = 0.050; + private const double SofLanEventBeatEpsilon = 0.000000001; + private const double SofLanEffectiveSpeedTolerance = 0.01; + + public static ChartFeatureVector Analyze(PlayableChart chart) + { + if (chart == null) + throw new ArgumentNullException(nameof(chart)); + + var keyCount = Math.Max(4, Math.Min(7, chart.Metadata?.KeyCount ?? 5)); + var totalLaneCount = Math.Max(1, chart.Metadata?.TotalLaneCount ?? keyCount); + var groups = BuildOnsetGroups(chart.Notes, totalLaneCount, out var uniqueNotes, out var anomalies); + if (groups.Count == 0) + { + return new ChartFeatureVector + { + KeyCount = keyCount, + TotalLaneCount = totalLaneCount, + AnomalyCount = anomalies, + ActiveDurationSeconds = MinimumActiveDurationSeconds, + }; + } + + var activeStart = groups[0].Time; + var activeEnd = activeStart; + foreach (var note in uniqueNotes) + activeEnd = Math.Max(activeEnd, note.IsLong ? note.EndTime : note.StartTime); + var activeDuration = Math.Max(MinimumActiveDurationSeconds, activeEnd - activeStart); + activeEnd = activeStart + activeDuration; + var inputCount = 0; + foreach (var group in groups) + inputCount += group.Count; + + var notesRaw = 60.0 * inputCount / activeDuration; + var peakRaw = CalculatePeak(groups, activeStart, activeEnd); + var chordRaw = CalculateChord(groups, activeDuration, totalLaneCount); + var longRaw = CalculateLong(uniqueNotes, groups, activeStart, activeEnd, totalLaneCount); + var sofLanRaw = CalculateSofLan(chart, groups, activeDuration, inputCount); + var techRaw = CalculateTech( + uniqueNotes, + groups, + activeDuration, + keyCount, + chart.Metadata?.HasScratch == true); + + return ChartFeatureCalibration.Apply(new ChartFeatureVector + { + KeyCount = keyCount, + TotalLaneCount = totalLaneCount, + InputCount = inputCount, + AnomalyCount = anomalies, + ActiveDurationSeconds = activeDuration, + NotesRaw = notesRaw, + PeakRaw = peakRaw, + ChordRaw = chordRaw, + LongRaw = longRaw, + SofLanRaw = sofLanRaw, + TechRaw = techRaw, + }); + } + + private static List BuildOnsetGroups( + IReadOnlyList source, + int totalLaneCount, + out List uniqueNotes, + out int anomalyCount) + { + var sorted = new List(); + if (source != null) + { + foreach (var note in source) + { + if (note == null || !IsFinite(note.StartTime)) + continue; + sorted.Add(note); + } + } + sorted.Sort((left, right) => + { + var time = left.StartTime.CompareTo(right.StartTime); + return time != 0 ? time : left.Lane.CompareTo(right.Lane); + }); + + uniqueNotes = new List(sorted.Count); + var groups = new List(); + anomalyCount = 0; + OnsetGroup current = null; + foreach (var note in sorted) + { + if (note.Lane < 0 || note.Lane >= totalLaneCount) + { + anomalyCount++; + continue; + } + if (current == null || note.StartTime - current.Time > SimultaneousEpsilonSeconds) + { + current = new OnsetGroup(note.StartTime); + groups.Add(current); + } + + var laneBit = 1 << note.Lane; + if ((current.LaneMask & laneBit) != 0) + { + anomalyCount++; + continue; + } + + current.LaneMask |= laneBit; + current.Count++; + if (note.IsScratch) + { + current.HasScratch = true; + } + else + { + current.KeyLaneMask |= laneBit; + current.KeyCount++; + } + uniqueNotes.Add(note); + } + return groups; + } + + private static double CalculatePeak( + IReadOnlyList groups, + double activeStart, + double activeEnd) + { + var sustained = RollingNpsQuantile(groups, activeStart, activeEnd, 2.0, 0.98); + var burst = RollingNpsQuantile(groups, activeStart, activeEnd, 0.5, 0.99); + return sustained * 0.60 + burst * 0.40; + } + + private static double RollingNpsQuantile( + IReadOnlyList groups, + double activeStart, + double activeEnd, + double windowSeconds, + double quantile) + { + var sampleCount = Math.Max(1, + (int)Math.Ceiling((activeEnd - activeStart) / PeakSampleStepSeconds)); + var values = new double[sampleCount]; + var left = 0; + var right = 0; + var count = 0; + for (var sample = 0; sample < sampleCount; sample++) + { + var windowStart = activeStart + sample * PeakSampleStepSeconds; + var windowEnd = windowStart + windowSeconds; + while (left < groups.Count && groups[left].Time < windowStart - SimultaneousEpsilonSeconds) + count -= groups[left++].Count; + while (right < groups.Count && groups[right].Time < windowEnd - SimultaneousEpsilonSeconds) + count += groups[right++].Count; + values[sample] = count / windowSeconds; + } + + Array.Sort(values); + var index = Math.Max(0, Math.Min(values.Length - 1, + (int)Math.Ceiling(values.Length * quantile) - 1)); + return values[index]; + } + + private static double CalculateChord( + IReadOnlyList groups, + double activeDuration, + int laneCount) + { + var chordLoad = 0.0; + var transitionLoad = 0.0; + OnsetGroup previousChord = null; + foreach (var group in groups) + { + if (group.Count < 2) + continue; + chordLoad += Math.Pow(group.Count - 1, 1.35); + if (previousChord != null) + { + var interval = group.Time - previousChord.Time; + if (interval >= 0 && interval <= 0.6) + { + var changedRatio = PopCount(group.LaneMask ^ previousChord.LaneMask) / + (double)Math.Max(1, laneCount); + transitionLoad += changedRatio * Math.Exp(-interval / 0.180); + } + } + previousChord = group; + } + + var perMinute = 60.0 / activeDuration; + return 0.75 * chordLoad * perMinute + 0.25 * transitionLoad * perMinute; + } + + private static double CalculateLong( + IReadOnlyList notes, + IReadOnlyList groups, + double activeStart, + double activeEnd, + int laneCount) + { + var byLane = new List[laneCount]; + for (var lane = 0; lane < laneCount; lane++) + byLane[lane] = new List(); + foreach (var note in notes) + { + if (!note.IsLong || note.Lane < 0 || note.Lane >= laneCount || !IsFinite(note.EndTime)) + continue; + var start = Math.Max(activeStart, note.StartTime); + var end = Math.Min(activeEnd, note.EndTime); + if (end > start + SimultaneousEpsilonSeconds) + byLane[note.Lane].Add(new HoldInterval(start, end)); + } + + var merged = new List(); + var heldLaneSeconds = 0.0; + foreach (var intervals in byLane) + { + intervals.Sort((left, right) => left.Start.CompareTo(right.Start)); + for (var index = 0; index < intervals.Count; index++) + { + var start = intervals[index].Start; + var end = intervals[index].End; + while (index + 1 < intervals.Count && + intervals[index + 1].Start <= end + SimultaneousEpsilonSeconds) + { + index++; + end = Math.Max(end, intervals[index].End); + } + var interval = new HoldInterval(start, end); + merged.Add(interval); + heldLaneSeconds += end - start; + } + } + if (merged.Count == 0) + return 0; + + var events = new List(merged.Count * 2); + foreach (var interval in merged) + { + events.Add(new HoldSweepEvent(interval.Start, 1)); + events.Add(new HoldSweepEvent(interval.End, -1)); + } + events.Sort((left, right) => + { + var time = left.Time.CompareTo(right.Time); + return time != 0 ? time : left.Delta.CompareTo(right.Delta); + }); + + var held = 0; + var previousTime = activeStart; + var extraHeldSeconds = 0.0; + foreach (var item in events) + { + var time = Math.Max(activeStart, Math.Min(activeEnd, item.Time)); + if (time > previousTime) + extraHeldSeconds += Math.Max(0, held - 1) * (time - previousTime); + held += item.Delta; + previousTime = time; + } + + var overlayGroups = 0; + held = 0; + var eventIndex = 0; + foreach (var group in groups) + { + while (eventIndex < events.Count && + events[eventIndex].Time < group.Time - SimultaneousEpsilonSeconds) + held += events[eventIndex++].Delta; + if (held > 0) + overlayGroups++; + while (eventIndex < events.Count && + events[eventIndex].Time <= group.Time + SimultaneousEpsilonSeconds) + held += events[eventIndex++].Delta; + } + + var duration = activeEnd - activeStart; + var occupancy = heldLaneSeconds / (duration * Math.Max(1, laneCount)); + var concurrency = extraHeldSeconds / + (duration * Math.Max(1, laneCount - 1)); + var overlay = overlayGroups / (double)Math.Max(1, groups.Count); + return 0.45 * Clamp01(occupancy) + + 0.25 * Clamp01(concurrency) + + 0.30 * Clamp01(overlay); + } + + private static double CalculateSofLan( + PlayableChart chart, + IReadOnlyList groups, + double activeDuration, + int inputCount) + { + var nodes = chart.TempoMap?.Nodes; + if (nodes == null || nodes.Count == 0 || inputCount <= 0) + return 0; + + var meanNps = inputCount / activeDuration; + var burden = 0.0; + for (var index = 0; index < nodes.Count; index++) + { + var node = nodes[index]; + if (node.StopDuration > 0) + { + var localNps = CountInputs( + groups, + node.StopEndTime, + node.StopEndTime + 2.0) / 2.0; + burden += Math.Min(2.0, node.StopDuration) * Exposure(localNps, meanNps); + } + } + + var scrollNodes = chart.ScrollMap?.Nodes; + var currentBpm = nodes[0].Bpm; + var currentScroll = scrollNodes != null && scrollNodes.Count > 0 + ? scrollNodes[0].Multiplier + : 1.0; + var tempoIndex = 1; + var scrollIndex = scrollNodes != null && scrollNodes.Count > 0 ? 1 : 0; + // BPM and BMS #SCROLL can change at the same beat to deliberately + // preserve visual speed. Merge those events before measuring the + // transition so an inverse pair is not counted twice. + while (tempoIndex < nodes.Count || + (scrollNodes != null && scrollIndex < scrollNodes.Count)) + { + var tempoBeat = tempoIndex < nodes.Count + ? nodes[tempoIndex].Beat + : double.PositiveInfinity; + var scrollBeat = scrollNodes != null && scrollIndex < scrollNodes.Count + ? scrollNodes[scrollIndex].Beat + : double.PositiveInfinity; + var eventBeat = Math.Min(tempoBeat, scrollBeat); + var previousBpm = currentBpm; + var previousScroll = currentScroll; + + while (tempoIndex < nodes.Count && + Math.Abs(nodes[tempoIndex].Beat - eventBeat) <= SofLanEventBeatEpsilon) + { + currentBpm = nodes[tempoIndex].Bpm; + tempoIndex++; + } + while (scrollNodes != null && + scrollIndex < scrollNodes.Count && + Math.Abs(scrollNodes[scrollIndex].Beat - eventBeat) <= + SofLanEventBeatEpsilon) + { + currentScroll = scrollNodes[scrollIndex].Multiplier; + scrollIndex++; + } + + var change = EffectiveScrollSpeedChange( + previousBpm, + previousScroll, + currentBpm, + currentScroll); + if (change > 0) + { + var eventTime = chart.TempoMap.BeatToTime(eventBeat); + var localNps = CountInputs(groups, eventTime - 1.0, eventTime + 2.0) / 3.0; + burden += Math.Min(4.0, change) * Exposure(localNps, meanNps); + } + } + return burden * 60.0 / activeDuration; + } + + private static double EffectiveScrollSpeedChange( + double previousBpm, + double previousScroll, + double currentBpm, + double currentScroll) + { + const double zeroEpsilon = 0.000001; + var previousSpeed = previousBpm * previousScroll; + var currentSpeed = currentBpm * currentScroll; + var previousIsZero = Math.Abs(previousSpeed) <= zeroEpsilon; + var currentIsZero = Math.Abs(currentSpeed) <= zeroEpsilon; + + if (previousIsZero && currentIsZero) + return 0; + if (previousIsZero || currentIsZero) + return 2.5; + if (Math.Sign(previousSpeed) != Math.Sign(currentSpeed)) + return 4.0; + + var ratio = Math.Abs(currentSpeed / previousSpeed); + if (!(ratio > 0) || double.IsNaN(ratio) || double.IsInfinity(ratio)) + return 4.0; + if (ratio >= 1.0 / (1.0 + SofLanEffectiveSpeedTolerance) && + ratio <= 1.0 + SofLanEffectiveSpeedTolerance) + return 0; + return Math.Abs(Log2(ratio)); + } + + private static double CalculateTech( + IReadOnlyList notes, + IReadOnlyList groups, + double activeDuration, + int keyCount, + bool hasScratch) + { + var laneTimes = new Dictionary(); + var jack = 0.0; + foreach (var note in notes) + { + if (laneTimes.TryGetValue(note.Lane, out var previous)) + { + var interval = note.StartTime - previous; + if (interval >= 0 && interval <= 0.200) + jack += 1.0 - interval / 0.200; + } + laneTimes[note.Lane] = note.StartTime; + } + + var trill = 0.0; + for (var index = 3; index < groups.Count; index++) + { + var a = groups[index - 3]; + var b = groups[index - 2]; + var c = groups[index - 1]; + var d = groups[index]; + if (a.Count != 1 || b.Count != 1 || c.Count != 1 || d.Count != 1 || + a.HasScratch || b.HasScratch || c.HasScratch || d.HasScratch || + a.LaneMask != c.LaneMask || b.LaneMask != d.LaneMask || a.LaneMask == b.LaneMask) + continue; + var first = b.Time - a.Time; + var second = c.Time - b.Time; + var third = d.Time - c.Time; + if (first <= 0 || second <= 0 || third <= 0 || + first > 0.250 || second > 0.250 || third > 0.250) + continue; + trill += Math.Exp(-((first + second + third) / 3.0) / 0.150); + } + + var movement = 0.0; + OnsetGroup previousKeys = null; + foreach (var group in groups) + { + if (group.KeyCount <= 0) + continue; + if (previousKeys != null) + { + var interval = group.Time - previousKeys.Time; + if (interval >= 0 && interval <= 0.300) + { + var distance = Math.Abs( + KeyCenter(group.KeyLaneMask, keyCount, hasScratch) - + KeyCenter(previousKeys.KeyLaneMask, keyCount, hasScratch)); + movement += distance * Math.Exp(-interval / 0.180); + } + } + previousKeys = group; + } + + var scratchCross = 0.0; + for (var index = 0; index < groups.Count; index++) + { + if (!groups[index].HasScratch) + continue; + var maximumNearbyKeys = 0; + for (var other = index - 1; other >= 0; other--) + { + if (groups[index].Time - groups[other].Time > 0.150) + break; + maximumNearbyKeys = Math.Max(maximumNearbyKeys, groups[other].KeyCount); + } + for (var other = index + 1; other < groups.Count; other++) + { + if (groups[other].Time - groups[index].Time > 0.150) + break; + maximumNearbyKeys = Math.Max(maximumNearbyKeys, groups[other].KeyCount); + } + maximumNearbyKeys = Math.Max(maximumNearbyKeys, groups[index].KeyCount); + scratchCross += maximumNearbyKeys; + } + + var perMinute = 60.0 / activeDuration; + return 0.35 * jack * perMinute + + 0.30 * trill * perMinute + + 0.20 * movement * perMinute + + 0.15 * scratchCross * perMinute; + } + + private static double KeyCenter(int laneMask, int keyCount, bool hasScratch) + { + var offset = hasScratch ? 1 : 0; + var total = 0.0; + var count = 0; + for (var key = 0; key < keyCount; key++) + { + if ((laneMask & (1 << (key + offset))) == 0) + continue; + total += key / (double)Math.Max(1, keyCount - 1); + count++; + } + return count > 0 ? total / count : 0.5; + } + + private static int CountInputs(IReadOnlyList groups, double start, double end) + { + var count = 0; + foreach (var group in groups) + { + if (group.Time < start) + continue; + if (group.Time >= end) + break; + count += group.Count; + } + return count; + } + + private static double Exposure(double localNps, double meanNps) + { + if (!(meanNps > 0)) + return 0.25; + var ratio = Math.Max(0, Math.Min(3.0, localNps / meanNps)); + return 0.25 + 0.75 * ratio; + } + + private static int PopCount(int value) + { + var count = 0; + var bits = (uint)value; + while (bits != 0) + { + bits &= bits - 1; + count++; + } + return count; + } + + private static double Clamp01(double value) => Math.Max(0, Math.Min(1, value)); + private static double Log2(double value) => Math.Log(value) / Math.Log(2.0); + private static bool IsFinite(double value) => !double.IsNaN(value) && !double.IsInfinity(value); + + private sealed class OnsetGroup + { + public OnsetGroup(double time) => Time = time; + + public double Time; + public int LaneMask; + public int KeyLaneMask; + public int Count; + public int KeyCount; + public bool HasScratch; + } + + private readonly struct HoldInterval + { + public HoldInterval(double start, double end) + { + Start = start; + End = end; + } + + public double Start { get; } + public double End { get; } + } + + private readonly struct HoldSweepEvent + { + public HoldSweepEvent(double time, int delta) + { + Time = time; + Delta = delta; + } + + public double Time { get; } + public int Delta { get; } + } + } +} diff --git a/Assets/ProjectQuint/Runtime/Core/Analysis/ChartFeatureAnalysis.cs.meta b/Assets/ProjectQuint/Runtime/Core/Analysis/ChartFeatureAnalysis.cs.meta new file mode 100644 index 0000000..5f74070 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Core/Analysis/ChartFeatureAnalysis.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: b0f6f0d468cb04ff397643fcc59325e7 \ No newline at end of file diff --git a/Assets/ProjectQuint/Runtime/Core/Bms.meta b/Assets/ProjectQuint/Runtime/Core/Bms.meta new file mode 100644 index 0000000..6354d55 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Core/Bms.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 6196c448b624e48dbbc87bf93d7c2273 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/ProjectQuint/Runtime/Core/Bms/BmsModels.cs b/Assets/ProjectQuint/Runtime/Core/Bms/BmsModels.cs new file mode 100644 index 0000000..ac8d278 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Core/Bms/BmsModels.cs @@ -0,0 +1,175 @@ +using System; +using System.Collections.Generic; +using ProjectQuint.Core.Timing; + +namespace ProjectQuint.Core.Bms +{ + public readonly struct BmsFraction : IEquatable, IComparable + { + public BmsFraction(long numerator, long denominator) + { + if (denominator <= 0) + throw new ArgumentOutOfRangeException(nameof(denominator)); + + var divisor = GreatestCommonDivisor(Math.Abs(numerator), denominator); + Numerator = numerator / divisor; + Denominator = denominator / divisor; + } + + public long Numerator { get; } + public long Denominator { get; } + public double Value => (double)Numerator / Denominator; + + public int CompareTo(BmsFraction other) + { + var left = (decimal)Numerator * other.Denominator; + var right = (decimal)other.Numerator * Denominator; + return left.CompareTo(right); + } + + public bool Equals(BmsFraction other) => + Numerator == other.Numerator && Denominator == other.Denominator; + + public override bool Equals(object obj) => obj is BmsFraction other && Equals(other); + public override int GetHashCode() => HashCode.Combine(Numerator, Denominator); + + private static long GreatestCommonDivisor(long left, long right) + { + while (right != 0) + { + var remainder = left % right; + left = right; + right = remainder; + } + + return left == 0 ? 1 : left; + } + } + + public readonly struct BmsPosition : IEquatable, IComparable + { + public BmsPosition(int measure, BmsFraction fraction) + { + Measure = measure; + Fraction = fraction; + } + + public int Measure { get; } + public BmsFraction Fraction { get; } + + public int CompareTo(BmsPosition other) + { + var measureComparison = Measure.CompareTo(other.Measure); + return measureComparison != 0 ? measureComparison : Fraction.CompareTo(other.Fraction); + } + + public bool Equals(BmsPosition other) => Measure == other.Measure && Fraction.Equals(other.Fraction); + public override bool Equals(object obj) => obj is BmsPosition other && Equals(other); + public override int GetHashCode() => HashCode.Combine(Measure, Fraction); + } + + public sealed class BmsMetadata + { + public string ChartPath { get; set; } = string.Empty; + public string Title { get; set; } = "Untitled"; + public string Subtitle { get; set; } = string.Empty; + public string Artist { get; set; } = "Unknown Artist"; + public string SubArtist { get; set; } = string.Empty; + public string Genre { get; set; } = string.Empty; + public string StageFile { get; set; } = string.Empty; + public string BackBmp { get; set; } = string.Empty; + public string Md5 { get; set; } = string.Empty; + public string Sha256 { get; set; } = string.Empty; + public double InitialBpm { get; set; } = 120.0; + public double MinimumBpm { get; set; } + public double MaximumBpm { get; set; } + public double Total { get; set; } + public int PlayLevel { get; set; } + public int Difficulty { get; set; } + public int Rank { get; set; } = 3; + public int LnType { get; set; } = 1; + public string LnObj { get; set; } = string.Empty; + public int ObjectBase { get; set; } = 36; + public int KeyCount { get; set; } = 5; + public bool HasScratch { get; set; } + public int UsedKeyChannelMask { get; set; } + } + + public sealed class BmsRawEvent + { + public BmsPosition Position { get; set; } + public string Channel { get; set; } = string.Empty; + public string ObjectId { get; set; } = string.Empty; + public int Sequence { get; set; } + } + + public sealed class BmsDocument + { + public BmsMetadata Metadata { get; } = new BmsMetadata(); + public Dictionary WavDefinitions { get; } = new Dictionary(StringComparer.Ordinal); + public Dictionary BmpDefinitions { get; } = new Dictionary(StringComparer.Ordinal); + public Dictionary ExtendedBpms { get; } = new Dictionary(StringComparer.Ordinal); + public Dictionary Stops { get; } = new Dictionary(StringComparer.Ordinal); + public Dictionary Scrolls { get; } = new Dictionary(StringComparer.Ordinal); + public Dictionary MeasureLengths { get; } = new Dictionary(); + public List Events { get; } = new List(); + public List Warnings { get; } = new List(); + } + + public sealed class BmsNote + { + public int Id { get; set; } + public int Lane { get; set; } + public double StartTime { get; set; } + public double EndTime { get; set; } + public double StartBeat { get; set; } = double.NaN; + public double EndBeat { get; set; } = double.NaN; + public string SoundId { get; set; } = string.Empty; + public string TailSoundId { get; set; } = string.Empty; + public bool IsScratch { get; set; } + public bool IsLong => EndTime > StartTime; + } + + public sealed class BmsAudioEvent + { + public double Time { get; set; } + public string SoundId { get; set; } = string.Empty; + } + + public sealed class BmsLaneSoundEvent + { + public int Lane { get; set; } + public double Time { get; set; } + public string SoundId { get; set; } = string.Empty; + } + + public enum BmsBgaLayer + { + Base, + Poor, + Layer, + } + + public sealed class BmsBgaEvent + { + public double Time { get; set; } + public string ResourceId { get; set; } = string.Empty; + public BmsBgaLayer Layer { get; set; } + } + + public sealed class BmsChart + { + public BmsMetadata Metadata { get; set; } = new BmsMetadata(); + public TempoMap TempoMap { get; set; } + public ScrollMap ScrollMap { get; set; } + public Dictionary WavDefinitions { get; set; } = new Dictionary(StringComparer.Ordinal); + public Dictionary BmpDefinitions { get; set; } = new Dictionary(StringComparer.Ordinal); + public List Notes { get; } = new List(); + public List BackgroundSounds { get; } = new List(); + public List LaneSoundEvents { get; } = new List(); + public List BgaEvents { get; } = new List(); + public List Warnings { get; } = new List(); + public int ScratchObjectCount { get; set; } + public double Duration { get; set; } + } +} diff --git a/Assets/ProjectQuint/Runtime/Core/Bms/BmsModels.cs.meta b/Assets/ProjectQuint/Runtime/Core/Bms/BmsModels.cs.meta new file mode 100644 index 0000000..3952827 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Core/Bms/BmsModels.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 8b835aeba53ee44eca669ba79b1f4fad \ No newline at end of file diff --git a/Assets/ProjectQuint/Runtime/Core/Bms/BmsParser.cs b/Assets/ProjectQuint/Runtime/Core/Bms/BmsParser.cs new file mode 100644 index 0000000..a9599b3 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Core/Bms/BmsParser.cs @@ -0,0 +1,588 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; + +namespace ProjectQuint.Core.Bms +{ + public static class BmsParser + { + private static readonly Encoding ShiftJisEncoding = CreateShiftJisEncoding(); + + public static BmsDocument ParseFile(string chartPath, int randomSeed = 1, int forcedKeyCount = 0) + { + if (string.IsNullOrWhiteSpace(chartPath)) + throw new ArgumentException("Chart path is required.", nameof(chartPath)); + + var bytes = File.ReadAllBytes(chartPath); + var document = ParseText(chartPath, Decode(bytes), randomSeed, true, forcedKeyCount); + return document; + } + + public static BmsMetadata ReadMetadataFile(string chartPath, int randomSeed = 1, int forcedKeyCount = 0) + { + var bytes = File.ReadAllBytes(chartPath); + return ParseText(chartPath, Decode(bytes), randomSeed, false, forcedKeyCount).Metadata; + } + + public static BmsDocument ParseText(string chartPath, string text, int randomSeed = 1) + { + return ParseText(chartPath, text, randomSeed, true, 0); + } + + private static BmsDocument ParseText( + string chartPath, + string text, + int randomSeed, + bool includeEvents, + int forcedKeyCount) + { + var document = new BmsDocument(); + document.Metadata.ChartPath = chartPath ?? string.Empty; + + var processor = new ConditionalProcessor(randomSeed); + var sequence = 0; + var lines = (text ?? string.Empty) + .Replace("\r\n", "\n") + .Replace('\r', '\n') + .Split('\n'); + document.Metadata.ObjectBase = ResolveObjectBase(lines, randomSeed); + + foreach (var originalLine in lines) + { + var line = originalLine.Trim(); + if (line.Length == 0 || line[0] != '#') + continue; + + if (processor.TryHandle(line)) + continue; + + if (!processor.IsActive) + continue; + + if (TryParseMeasureLine(line, document, includeEvents, ref sequence)) + continue; + + ParseHeaderLine(line, document); + } + + if (processor.HasOpenCondition) + AddWarning(document, "Conditional block was not closed before EOF."); + + FinalizeKeyLayout(document.Metadata, forcedKeyCount); + + return document; + } + + private static bool TryParseMeasureLine(string line, BmsDocument document, bool includeEvents, ref int sequence) + { + if (line.Length < 7 || line[6] != ':' || + !char.IsDigit(line[1]) || !char.IsDigit(line[2]) || !char.IsDigit(line[3])) + return false; + + if (!int.TryParse(line.Substring(1, 3), NumberStyles.None, CultureInfo.InvariantCulture, out var measure)) + return false; + + var channel = line.Substring(4, 2).ToUpperInvariant(); + var payload = line.Substring(7).Replace(" ", string.Empty).Replace("\t", string.Empty); + + ObservePlayableChannel(document.Metadata, channel, payload); + + if (channel == "02") + { + if (double.TryParse(payload, NumberStyles.Float, CultureInfo.InvariantCulture, out var length) && length > 0) + document.MeasureLengths[measure] = length; + else + AddWarning(document, $"Invalid measure length at #{measure:000}02."); + return true; + } + + if (!includeEvents) + return true; + + if ((payload.Length & 1) != 0) + { + AddWarning(document, $"Odd object data length at #{measure:000}{channel}."); + return true; + } + + var resolution = payload.Length / 2; + if (resolution == 0) + return true; + + for (var index = 0; index < resolution; index++) + { + var rawObjectId = payload.Substring(index * 2, 2); + if (!TryNormalizeObjectId(rawObjectId, document.Metadata.ObjectBase, out var objectId)) + { + AddWarning( + document, + $"Invalid base-{document.Metadata.ObjectBase} object id '{rawObjectId}' at " + + $"#{measure:000}{channel}."); + continue; + } + if (objectId == "00") + continue; + + document.Events.Add(new BmsRawEvent + { + Position = new BmsPosition(measure, new BmsFraction(index, resolution)), + Channel = channel, + ObjectId = objectId, + Sequence = sequence++, + }); + } + + return true; + } + + private static void ParseHeaderLine(string line, BmsDocument document) + { + var separator = FindWhitespace(line); + var rawCommand = separator < 0 ? line.Substring(1) : line.Substring(1, separator - 1); + var command = rawCommand.ToUpperInvariant(); + var value = separator < 0 ? string.Empty : line.Substring(separator).Trim(); + + if (command.StartsWith("WAV", StringComparison.Ordinal) && command.Length == 5) + { + AddObjectDefinition( + document, + document.WavDefinitions, + rawCommand.Substring(3, 2), + value, + "WAV"); + return; + } + + if (command.StartsWith("BMP", StringComparison.Ordinal) && command.Length == 5) + { + AddObjectDefinition( + document, + document.BmpDefinitions, + rawCommand.Substring(3, 2), + value, + "BMP"); + return; + } + + if (command.StartsWith("BPM", StringComparison.Ordinal) && command.Length == 5) + { + if (!TryNormalizeObjectId( + rawCommand.Substring(3, 2), + document.Metadata.ObjectBase, + out var objectId)) + AddWarning(document, $"Invalid base-{document.Metadata.ObjectBase} BPM object id: {rawCommand}"); + else if (TryParsePositiveDouble(value, out var bpm)) + document.ExtendedBpms[objectId] = bpm; + else + AddWarning(document, $"Invalid extended BPM: {rawCommand} {value}"); + return; + } + + if (command.StartsWith("STOP", StringComparison.Ordinal) && command.Length == 6) + { + if (!TryNormalizeObjectId( + rawCommand.Substring(4, 2), + document.Metadata.ObjectBase, + out var objectId)) + AddWarning(document, $"Invalid base-{document.Metadata.ObjectBase} STOP object id: {rawCommand}"); + else if (double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var stop) && stop >= 0) + document.Stops[objectId] = stop; + else + AddWarning(document, $"Invalid STOP definition: {rawCommand} {value}"); + return; + } + + if (command.StartsWith("SCROLL", StringComparison.Ordinal) && command.Length == 8) + { + if (!TryNormalizeObjectId( + rawCommand.Substring(6, 2), + document.Metadata.ObjectBase, + out var objectId)) + AddWarning(document, $"Invalid base-{document.Metadata.ObjectBase} SCROLL object id: {rawCommand}"); + else if (double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var scroll) && + !double.IsNaN(scroll) && !double.IsInfinity(scroll)) + document.Scrolls[objectId] = scroll; + else + AddWarning(document, $"Invalid SCROLL definition: {rawCommand} {value}"); + return; + } + + switch (command) + { + case "TITLE": document.Metadata.Title = EmptyFallback(value, "Untitled"); break; + case "SUBTITLE": document.Metadata.Subtitle = value; break; + case "ARTIST": document.Metadata.Artist = EmptyFallback(value, "Unknown Artist"); break; + case "SUBARTIST": document.Metadata.SubArtist = value; break; + case "GENRE": document.Metadata.Genre = value; break; + case "STAGEFILE": document.Metadata.StageFile = value; break; + case "BACKBMP": document.Metadata.BackBmp = value; break; + case "LNOBJ": + if (TryNormalizeObjectId(value, document.Metadata.ObjectBase, out var lnObject)) + document.Metadata.LnObj = lnObject; + else + AddWarning(document, $"Invalid base-{document.Metadata.ObjectBase} LNOBJ id: {value}"); + break; + case "BPM": + if (TryParsePositiveDouble(value, out var bpm)) document.Metadata.InitialBpm = bpm; + else AddWarning(document, $"Invalid initial BPM: {value}"); + break; + case "TOTAL": + if (TryParsePositiveDouble(value, out var total)) document.Metadata.Total = total; + else AddWarning(document, $"Invalid TOTAL: {value}"); + break; + case "PLAYLEVEL": document.Metadata.PlayLevel = ParseLeadingInteger(value); break; + case "DIFFICULTY": document.Metadata.Difficulty = ParseLeadingInteger(value); break; + case "RANK": document.Metadata.Rank = ParseLeadingInteger(value); break; + case "LNTYPE": + document.Metadata.LnType = ParseLeadingInteger(value); + EnsureSupportedLongNoteNotation(document.Metadata.LnType); + break; + case "SCROLL": AddWarning(document, "SCROLL requires a two-character object id."); break; + case "BASE": break; + } + } + + public static void EnsureSupportedLongNoteNotation(int lnType) + { + if (lnType == 2) + throw new FormatException( + "Unsupported BMS #LNTYPE 2 (obsolete MGQ long-note notation). " + + "ProjectQuint supports #LNTYPE 1 and #LNOBJ charts."); + } + + private static int ResolveObjectBase(IReadOnlyList lines, int randomSeed) + { + var objectBase = 36; + var processor = new ConditionalProcessor(randomSeed); + foreach (var originalLine in lines) + { + var line = (originalLine ?? string.Empty).Trim(); + if (line.Length == 0 || line[0] != '#') + continue; + if (processor.TryHandle(line) || !processor.IsActive) + continue; + + var separator = FindWhitespace(line); + var command = (separator < 0 + ? line.Substring(1) + : line.Substring(1, separator - 1)) + .ToUpperInvariant(); + if (command != "BASE") + continue; + + var value = separator < 0 ? string.Empty : line.Substring(separator).Trim(); + if (!int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out objectBase) || + (objectBase != 36 && objectBase != 62)) + throw new FormatException( + $"Unsupported BMS #BASE '{value}'. ProjectQuint supports only #BASE 36 and #BASE 62."); + } + return objectBase; + } + + private static void AddObjectDefinition( + BmsDocument document, + IDictionary definitions, + string rawObjectId, + string value, + string definitionType) + { + if (TryNormalizeObjectId( + rawObjectId, + document.Metadata.ObjectBase, + out var objectId)) + definitions[objectId] = value; + else + AddWarning( + document, + $"Invalid base-{document.Metadata.ObjectBase} {definitionType} object id: {rawObjectId}"); + } + + private static bool TryNormalizeObjectId(string rawObjectId, int objectBase, out string objectId) + { + objectId = string.Empty; + if (string.IsNullOrEmpty(rawObjectId) || rawObjectId.Length != 2) + return false; + + for (var index = 0; index < rawObjectId.Length; index++) + { + var character = rawObjectId[index]; + if (!(character >= '0' && character <= '9') && + !(character >= 'A' && character <= 'Z') && + !(character >= 'a' && character <= 'z')) + return false; + } + + objectId = objectBase == 36 ? rawObjectId.ToUpperInvariant() : rawObjectId; + return objectBase == 36 || objectBase == 62; + } + + private static string Decode(byte[] bytes) + { + if (bytes.Length >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF) + return Encoding.UTF8.GetString(bytes, 3, bytes.Length - 3); + if (bytes.Length >= 2 && bytes[0] == 0xFF && bytes[1] == 0xFE) + return Encoding.Unicode.GetString(bytes, 2, bytes.Length - 2); + if (bytes.Length >= 2 && bytes[0] == 0xFE && bytes[1] == 0xFF) + return Encoding.BigEndianUnicode.GetString(bytes, 2, bytes.Length - 2); + + try + { + return new UTF8Encoding(false, true).GetString(bytes); + } + catch (DecoderFallbackException) + { + try + { + return ShiftJisEncoding.GetString(bytes); + } + catch + { + return Encoding.Default.GetString(bytes); + } + } + } + + private static Encoding CreateShiftJisEncoding() + { + try + { + var providerType = Type.GetType( + "System.Text.CodePagesEncodingProvider, System.Text.Encoding.CodePages", + throwOnError: false); + var provider = providerType?.GetProperty("Instance")?.GetValue(null) as EncodingProvider; + if (provider != null) + Encoding.RegisterProvider(provider); + } + catch + { + // Unity's Mono runtime already exposes legacy code pages directly. Registration + // is only required by runtimes that split them into System.Text.Encoding.CodePages. + } + return Encoding.GetEncoding( + 932, + EncoderFallback.ExceptionFallback, + DecoderFallback.ExceptionFallback); + } + + private static int FindWhitespace(string line) + { + for (var index = 1; index < line.Length; index++) + if (char.IsWhiteSpace(line[index])) + return index; + return -1; + } + + private static bool TryParsePositiveDouble(string value, out double result) + { + return double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out result) && result > 0; + } + + private static int ParseLeadingInteger(string value) + { + if (string.IsNullOrWhiteSpace(value)) + return 0; + + var digits = new string(value.Trim().TakeWhile(char.IsDigit).ToArray()); + return int.TryParse(digits, NumberStyles.None, CultureInfo.InvariantCulture, out var result) ? result : 0; + } + + private static string EmptyFallback(string value, string fallback) => + string.IsNullOrWhiteSpace(value) ? fallback : value; + + private static void AddWarning(BmsDocument document, string warning) + { + if (!document.Warnings.Contains(warning)) + document.Warnings.Add(warning); + } + + private static void ObservePlayableChannel(BmsMetadata metadata, string channel, string payload) + { + if (metadata == null || string.IsNullOrWhiteSpace(payload) || !ContainsPlayableObject(payload)) + return; + + var keyIndex = BmsKeyLayout.ResolveStandardKeyIndex(channel); + if (keyIndex >= 0) + metadata.UsedKeyChannelMask |= 1 << keyIndex; + if (channel == "16" || channel == "26" || channel == "56" || channel == "66") + metadata.HasScratch = true; + } + + private static bool ContainsPlayableObject(string payload) + { + for (var index = 0; index + 1 < payload.Length; index += 2) + if (payload[index] != '0' || payload[index + 1] != '0') + return true; + return false; + } + + private static void FinalizeKeyLayout(BmsMetadata metadata, int forcedKeyCount) + { + if (forcedKeyCount >= 4 && forcedKeyCount <= 7) + { + metadata.KeyCount = forcedKeyCount; + return; + } + + if (metadata.UsedKeyChannelMask == BmsKeyLayout.SixKeyChannelMask) + metadata.KeyCount = 6; + else if ((metadata.UsedKeyChannelMask & (1 << 6)) != 0) + metadata.KeyCount = 7; + else if ((metadata.UsedKeyChannelMask & (1 << 5)) != 0) + metadata.KeyCount = 6; + else if (metadata.UsedKeyChannelMask == BmsKeyLayout.FourKeyChannelMask) + metadata.KeyCount = 4; + else + metadata.KeyCount = 5; + } + + private static int CountBits(int value) + { + var count = 0; + while (value != 0) + { + count += value & 1; + value >>= 1; + } + return count; + } + + private sealed class ConditionalProcessor + { + private readonly Random _random; + private readonly Stack _randomValues = new Stack(); + private readonly Stack _conditions = new Stack(); + + public ConditionalProcessor(int seed) + { + _random = new Random(seed); + } + + public bool IsActive => _conditions.Count == 0 || _conditions.Peek().CurrentActive; + public bool HasOpenCondition => _conditions.Count > 0; + + public bool TryHandle(string line) + { + var separator = FindWhitespace(line); + var command = (separator < 0 ? line.Substring(1) : line.Substring(1, separator - 1)).ToUpperInvariant(); + var valueText = separator < 0 ? string.Empty : line.Substring(separator).Trim(); + int.TryParse(valueText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value); + + switch (command) + { + case "RANDOM": + _randomValues.Push(value > 0 ? _random.Next(1, value + 1) : 1); + return true; + case "SETRANDOM": + if (_randomValues.Count > 0) _randomValues.Pop(); + _randomValues.Push(value); + return true; + case "ENDRANDOM": + if (_randomValues.Count > 0) _randomValues.Pop(); + return true; + case "IF": + { + var parentActive = IsActive; + var matched = (_randomValues.Count > 0 ? _randomValues.Peek() : 1) == value; + _conditions.Push(new ConditionalFrame(parentActive, matched, parentActive && matched)); + return true; + } + case "ELSEIF": + { + if (_conditions.Count == 0) return true; + var frame = _conditions.Pop(); + var matched = !frame.BranchMatched && (_randomValues.Count > 0 ? _randomValues.Peek() : 1) == value; + frame.CurrentActive = frame.ParentActive && matched; + frame.BranchMatched |= matched; + _conditions.Push(frame); + return true; + } + case "ELSE": + { + if (_conditions.Count == 0) return true; + var frame = _conditions.Pop(); + var matched = !frame.BranchMatched; + frame.CurrentActive = frame.ParentActive && matched; + frame.BranchMatched = true; + _conditions.Push(frame); + return true; + } + case "ENDIF": + if (_conditions.Count > 0) _conditions.Pop(); + return true; + default: + return false; + } + } + + private struct ConditionalFrame + { + public ConditionalFrame(bool parentActive, bool branchMatched, bool currentActive) + { + ParentActive = parentActive; + BranchMatched = branchMatched; + CurrentActive = currentActive; + } + + public bool ParentActive; + public bool BranchMatched; + public bool CurrentActive; + } + } + } + + public static class BmsKeyLayout + { + public static readonly string[] StandardKeyChannels = + { "11", "12", "13", "14", "15", "18", "19" }; + public const int FourKeyChannelMask = + (1 << 0) | (1 << 1) | (1 << 3) | (1 << 4); + // Community 6K charts use the seven-key channel grid with the center + // channel (14) empty: 11/12/13/[14]/15/18/19. + public const int SixKeyChannelMask = + (1 << 0) | (1 << 1) | (1 << 2) | (1 << 4) | (1 << 5) | (1 << 6); + + public static int ResolveStandardKeyIndex(string channel) + { + if (string.IsNullOrWhiteSpace(channel) || channel.Length != 2) + return -1; + + var normalized = channel[0] == '5' + ? "1" + channel.Substring(1, 1) + : channel; + return Array.IndexOf(StandardKeyChannels, normalized); + } + + public static string[] ResolveKeyChannels(BmsMetadata metadata) + { + metadata ??= new BmsMetadata(); + var keyCount = Math.Max(4, Math.Min(7, metadata.KeyCount)); + var channels = new List(keyCount); + + if (keyCount == 4 || keyCount == 6) + { + // Canonical 4K uses 11/12/14/15, while canonical 6K uses + // 11/12/13/15/18/19. Authoritative pack/table contracts must preserve + // these non-contiguous layouts even when a chart contains stray objects + // in an otherwise unused center channel. + var canonicalMask = keyCount == 4 + ? FourKeyChannelMask + : SixKeyChannelMask; + var preferredMask = keyCount == 6 || + (metadata.UsedKeyChannelMask & canonicalMask) == canonicalMask + ? canonicalMask + : metadata.UsedKeyChannelMask; + for (var index = 0; index < StandardKeyChannels.Length && channels.Count < keyCount; index++) + if ((preferredMask & (1 << index)) != 0) + channels.Add(StandardKeyChannels[index]); + } + + for (var index = 0; index < StandardKeyChannels.Length && channels.Count < keyCount; index++) + if (!channels.Contains(StandardKeyChannels[index])) + channels.Add(StandardKeyChannels[index]); + return channels.ToArray(); + } + } +} diff --git a/Assets/ProjectQuint/Runtime/Core/Bms/BmsParser.cs.meta b/Assets/ProjectQuint/Runtime/Core/Bms/BmsParser.cs.meta new file mode 100644 index 0000000..8825724 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Core/Bms/BmsParser.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 995f0998f5d77462eb5d93d5ddd9c47e \ No newline at end of file diff --git a/Assets/ProjectQuint/Runtime/Core/Bms/BmsPlayableChartConverter.cs b/Assets/ProjectQuint/Runtime/Core/Bms/BmsPlayableChartConverter.cs new file mode 100644 index 0000000..c6bb4d6 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Core/Bms/BmsPlayableChartConverter.cs @@ -0,0 +1,170 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using ProjectQuint.Core.Charts; +using ProjectQuint.Core.Timing; + +namespace ProjectQuint.Core.Bms +{ + public readonly struct BmsPlayableChartConversion + { + public BmsPlayableChartConversion(PlayableChart chart, int[] laneMap) + { + Chart = chart ?? throw new ArgumentNullException(nameof(chart)); + LaneMap = laneMap ?? throw new ArgumentNullException(nameof(laneMap)); + } + + public PlayableChart Chart { get; } + public int[] LaneMap { get; } + } + + /// + /// The only boundary that turns compiled BMS data into runtime gameplay data. + /// It preserves scratch as a fixed left lane, applies modifiers to key lanes only, + /// and leaves the source chart untouched. + /// + public static class BmsPlayableChartConverter + { + public static BmsPlayableChartConversion Convert( + BmsChart source, + LaneModifier laneModifier = LaneModifier.Off, + int randomSeed = 0) + { + if (source == null) + throw new ArgumentNullException(nameof(source)); + + var keyCount = Math.Max(4, Math.Min(7, source.Metadata?.KeyCount ?? 5)); + var hasScratch = source.Metadata?.HasScratch == true; + var laneMap = LanePermutation.Create(laneModifier, keyCount, hasScratch, randomSeed); + var chart = new PlayableChart + { + Metadata = ConvertMetadata(source.Metadata, source.TempoMap), + TempoMap = source.TempoMap ?? TempoMap.Constant(source.Metadata?.InitialBpm > 0 + ? source.Metadata.InitialBpm + : 120.0), + AudioResources = new Dictionary( + source.WavDefinitions, + StringComparer.Ordinal), + VisualResources = new Dictionary( + source.BmpDefinitions, + StringComparer.Ordinal), + ScratchObjectCount = source.ScratchObjectCount, + }; + chart.ScrollMap = source.ScrollMap ?? new ScrollMap(chart.TempoMap); + chart.Warnings.AddRange(source.Warnings); + + foreach (var note in source.Notes) + { + if (note.Lane < 0 || note.Lane >= laneMap.Length) + continue; + + chart.Notes.Add(new PlayableNote + { + Id = note.Id, + Lane = laneMap[note.Lane], + StartTime = note.StartTime, + EndTime = note.EndTime, + StartBeat = note.StartBeat, + EndBeat = note.EndBeat, + SoundId = note.SoundId, + TailSoundId = note.TailSoundId, + IsScratch = note.IsScratch, + }); + } + + foreach (var item in source.BackgroundSounds) + { + chart.BackgroundAudioEvents.Add(new PlayableAudioEvent + { + Time = item.Time, + SoundId = item.SoundId, + }); + } + + foreach (var item in source.LaneSoundEvents) + { + if (item.Lane < 0 || item.Lane >= laneMap.Length) + continue; + chart.LaneSoundEvents.Add(new PlayableLaneSoundEvent + { + Lane = laneMap[item.Lane], + Time = item.Time, + SoundId = item.SoundId, + }); + } + + foreach (var item in source.BgaEvents) + { + chart.VisualEvents.Add(new PlayableVisualEvent + { + Time = item.Time, + ResourceId = item.ResourceId, + Layer = ConvertLayer(item.Layer), + }); + } + + chart.Notes.Sort((left, right) => + { + var time = left.StartTime.CompareTo(right.StartTime); + return time != 0 ? time : left.Lane.CompareTo(right.Lane); + }); + chart.BackgroundAudioEvents.Sort((left, right) => left.Time.CompareTo(right.Time)); + chart.LaneSoundEvents.Sort((left, right) => + { + var time = left.Time.CompareTo(right.Time); + return time != 0 ? time : left.Lane.CompareTo(right.Lane); + }); + chart.VisualEvents.Sort((left, right) => left.Time.CompareTo(right.Time)); + var lastNote = chart.Notes.Count == 0 + ? 0.0 + : chart.Notes.Max(note => note.IsLong ? note.EndTime : note.StartTime); + var lastBackgroundAudio = chart.BackgroundAudioEvents.Count == 0 + ? 0.0 + : chart.BackgroundAudioEvents.Max(item => item.Time); + chart.Duration = Math.Max(lastNote, lastBackgroundAudio) + 2.0; + return new BmsPlayableChartConversion(chart, laneMap); + } + + private static PlayableChartMetadata ConvertMetadata( + BmsMetadata source, + TempoMap tempoMap) + { + source ??= new BmsMetadata(); + var minimumTempo = source.MinimumBpm; + var maximumTempo = source.MaximumBpm; + if (tempoMap?.Nodes != null && tempoMap.Nodes.Count > 0) + { + minimumTempo = tempoMap.Nodes.Min(node => node.Bpm); + maximumTempo = tempoMap.Nodes.Max(node => node.Bpm); + } + return new PlayableChartMetadata + { + SourcePath = source.ChartPath, + Title = source.Title, + Subtitle = source.Subtitle, + Artist = source.Artist, + SubArtist = source.SubArtist, + Genre = source.Genre, + StageImage = source.StageFile, + BackgroundImage = source.BackBmp, + SourceHash = source.Md5, + InitialTempo = source.InitialBpm, + MinimumTempo = minimumTempo, + MaximumTempo = maximumTempo, + GaugeTotal = source.Total, + PlayLevel = source.PlayLevel, + Difficulty = source.Difficulty, + KeyCount = Math.Max(4, Math.Min(7, source.KeyCount)), + HasScratch = source.HasScratch, + }; + } + + private static PlayableVisualLayer ConvertLayer(BmsBgaLayer layer) => layer switch + { + BmsBgaLayer.Base => PlayableVisualLayer.Base, + BmsBgaLayer.Poor => PlayableVisualLayer.Poor, + BmsBgaLayer.Layer => PlayableVisualLayer.Overlay, + _ => throw new ArgumentOutOfRangeException(nameof(layer), layer, null), + }; + } +} diff --git a/Assets/ProjectQuint/Runtime/Core/Bms/BmsPlayableChartConverter.cs.meta b/Assets/ProjectQuint/Runtime/Core/Bms/BmsPlayableChartConverter.cs.meta new file mode 100644 index 0000000..24fea0b --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Core/Bms/BmsPlayableChartConverter.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 61f617e6ec3b74517a372d950d8a7b95 \ No newline at end of file diff --git a/Assets/ProjectQuint/Runtime/Core/Bms/BmsTimelineCompiler.cs b/Assets/ProjectQuint/Runtime/Core/Bms/BmsTimelineCompiler.cs new file mode 100644 index 0000000..f90ff8f --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Core/Bms/BmsTimelineCompiler.cs @@ -0,0 +1,493 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using ProjectQuint.Core.Timing; + +namespace ProjectQuint.Core.Bms +{ + public static class BmsTimelineCompiler + { + public static BmsChart Compile(BmsDocument document) + { + if (document == null) + throw new ArgumentNullException(nameof(document)); + + var chart = new BmsChart + { + Metadata = document.Metadata, + WavDefinitions = new Dictionary(document.WavDefinitions, StringComparer.Ordinal), + BmpDefinitions = new Dictionary(document.BmpDefinitions, StringComparer.Ordinal), + }; + chart.Warnings.AddRange(document.Warnings); + + var effectiveEvents = MergeDuplicateChannelRows(document.Events); + var maxMeasure = Math.Max( + effectiveEvents.Count == 0 ? 0 : effectiveEvents.Max(item => item.Position.Measure), + document.MeasureLengths.Count == 0 ? 0 : document.MeasureLengths.Keys.Max()); + var measureStarts = BuildMeasureStarts(document.MeasureLengths, maxMeasure + 1); + + var timedEvents = BuildTimedEvents( + document, + effectiveEvents, + measureStarts, + chart.Warnings, + out var tempoMap); + chart.TempoMap = tempoMap; + chart.Metadata.MinimumBpm = tempoMap.Nodes.Min(node => node.Bpm); + chart.Metadata.MaximumBpm = tempoMap.Nodes.Max(node => node.Bpm); + chart.ScrollMap = CompileScrollMap(document, timedEvents, tempoMap, chart.Warnings); + CompileBackgroundSounds(timedEvents, chart); + CompileBgaEvents(timedEvents, chart); + CompileVisibleNotes(timedEvents, document.Metadata.LnObj, chart); + CompileLongNoteChannels(timedEvents, chart); + CompileScratchObjects(timedEvents, document.Metadata.LnObj, chart); + CompileInvisibleKeySounds(timedEvents, chart); + + chart.ScratchObjectCount = timedEvents.Count(item => + item.Raw.Channel == "16" || item.Raw.Channel == "56" || + item.Raw.Channel == "26" || item.Raw.Channel == "66"); + + chart.Notes.Sort((left, right) => + { + var time = left.StartTime.CompareTo(right.StartTime); + return time != 0 ? time : left.Lane.CompareTo(right.Lane); + }); + for (var index = 0; index < chart.Notes.Count; index++) + chart.Notes[index].Id = index; + + chart.BackgroundSounds.Sort((left, right) => left.Time.CompareTo(right.Time)); + chart.LaneSoundEvents.Sort((left, right) => + { + var time = left.Time.CompareTo(right.Time); + return time != 0 ? time : left.Lane.CompareTo(right.Lane); + }); + chart.BgaEvents.Sort((left, right) => left.Time.CompareTo(right.Time)); + var lastNote = chart.Notes.Count == 0 ? 0.0 : chart.Notes.Max(note => note.IsLong ? note.EndTime : note.StartTime); + var lastBgm = chart.BackgroundSounds.Count == 0 ? 0.0 : chart.BackgroundSounds.Max(item => item.Time); + chart.Duration = Math.Max(lastNote, lastBgm) + 2.0; + return chart; + } + + private static List MergeDuplicateChannelRows(IEnumerable events) + { + var backgroundEvents = new List(); + var merged = new Dictionary(); + + foreach (var item in events.OrderBy(item => item.Sequence)) + { + if (item.Channel == "01") + { + backgroundEvents.Add(item); + continue; + } + + merged[new MergeKey(item.Position, item.Channel)] = item; + } + + backgroundEvents.AddRange(merged.Values); + backgroundEvents.Sort(CompareRawEvents); + return backgroundEvents; + } + + private static double[] BuildMeasureStarts(IReadOnlyDictionary measureLengths, int measureCount) + { + var starts = new double[measureCount + 1]; + for (var measure = 0; measure < measureCount; measure++) + { + var ratio = measureLengths.TryGetValue(measure, out var customLength) ? customLength : 1.0; + starts[measure + 1] = starts[measure] + 4.0 * ratio; + } + + return starts; + } + + private static List BuildTimedEvents( + BmsDocument document, + List events, + double[] measureStarts, + List warnings, + out TempoMap tempoMap) + { + var result = new List(events.Count); + var bpm = document.Metadata.InitialBpm > 0 ? document.Metadata.InitialBpm : 120.0; + var currentBeat = 0.0; + var currentTime = 0.0; + var tempoNodes = new List { new TempoMapNode(0, 0, bpm) }; + var index = 0; + + while (index < events.Count) + { + var position = events[index].Position; + var beat = measureStarts[position.Measure] + + 4.0 * GetMeasureRatio(document.MeasureLengths, position.Measure) * position.Fraction.Value; + var eventTime = currentTime + Math.Max(0.0, beat - currentBeat) * 60.0 / bpm; + currentBeat = beat; + + var groupStart = index; + while (index < events.Count && events[index].Position.Equals(position)) + { + result.Add(new TimedEvent(events[index], eventTime, beat)); + index++; + } + + var hasTempoChange = false; + for (var eventIndex = groupStart; eventIndex < index; eventIndex++) + { + var item = events[eventIndex]; + if (item.Channel == "03" && int.TryParse(item.ObjectId, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var directBpm) && directBpm > 0) + { + bpm = directBpm; + hasTempoChange = true; + } + else if (item.Channel == "08" && document.ExtendedBpms.TryGetValue(item.ObjectId, out var extendedBpm) && extendedBpm > 0) + { + bpm = extendedBpm; + hasTempoChange = true; + } + } + + var stopDuration = 0.0; + for (var eventIndex = groupStart; eventIndex < index; eventIndex++) + { + var item = events[eventIndex]; + if (item.Channel != "09") + continue; + + if (document.Stops.TryGetValue(item.ObjectId, out var stopUnits)) + stopDuration += stopUnits * 60.0 / bpm / 48.0; + else + AddWarning(warnings, $"Undefined STOP object {item.ObjectId}."); + } + + if (hasTempoChange || stopDuration > 0) + { + var node = new TempoMapNode(beat, eventTime, bpm, stopDuration); + if (Math.Abs(tempoNodes[tempoNodes.Count - 1].Beat - beat) <= 0.000000001) + tempoNodes[tempoNodes.Count - 1] = node; + else + tempoNodes.Add(node); + } + currentTime = eventTime + stopDuration; + } + + tempoMap = new TempoMap(tempoNodes); + return result; + } + + private static ScrollMap CompileScrollMap( + BmsDocument document, + IEnumerable events, + TempoMap tempoMap, + List warnings) + { + var nodes = new List(); + foreach (var item in events) + { + if (item.Raw.Channel != "SC") + continue; + + if (!document.Scrolls.TryGetValue(item.Raw.ObjectId, out var multiplier)) + { + AddWarning(warnings, $"Undefined SCROLL object {item.Raw.ObjectId}."); + continue; + } + + nodes.Add(new ScrollMapNode(item.Beat, multiplier)); + } + return new ScrollMap(tempoMap, nodes); + } + + private static void CompileBackgroundSounds(IEnumerable events, BmsChart chart) + { + foreach (var item in events) + { + if (item.Raw.Channel != "01") + continue; + + chart.BackgroundSounds.Add(new BmsAudioEvent + { + Time = item.Time, + SoundId = item.Raw.ObjectId, + }); + } + } + + private static void CompileBgaEvents(IEnumerable events, BmsChart chart) + { + foreach (var item in events) + { + BmsBgaLayer layer; + switch (item.Raw.Channel) + { + case "04": layer = BmsBgaLayer.Base; break; + case "06": layer = BmsBgaLayer.Poor; break; + case "07": layer = BmsBgaLayer.Layer; break; + default: continue; + } + + chart.BgaEvents.Add(new BmsBgaEvent + { + Time = item.Time, + ResourceId = item.Raw.ObjectId, + Layer = layer, + }); + } + } + + private static void CompileVisibleNotes(List events, string lnObject, BmsChart chart) + { + var channels = BmsKeyLayout.ResolveKeyChannels(chart.Metadata); + var keyOffset = chart.Metadata.HasScratch ? 1 : 0; + for (var key = 0; key < channels.Length; key++) + { + var lane = key + keyOffset; + var channel = channels[key]; + var laneEvents = events + .Where(item => item.Raw.Channel == channel) + .OrderBy(item => item.Time) + .ThenBy(item => item.Raw.Sequence) + .ToList(); + + var laneNotes = new List(); + foreach (var item in laneEvents) + { + if (!string.IsNullOrEmpty(lnObject) && string.Equals(item.Raw.ObjectId, lnObject, StringComparison.Ordinal)) + { + var head = laneNotes.LastOrDefault(note => !note.IsLong && note.StartTime < item.Time); + if (head == null) + { + AddWarning(chart.Warnings, $"LNOBJ tail without a head in lane {lane + 1}."); + continue; + } + + head.EndTime = item.Time; + head.EndBeat = item.Beat; + head.TailSoundId = item.Raw.ObjectId; + continue; + } + + laneNotes.Add(new BmsNote + { + Lane = lane, + StartTime = item.Time, + EndTime = item.Time, + StartBeat = item.Beat, + EndBeat = item.Beat, + SoundId = item.Raw.ObjectId, + }); + } + + chart.Notes.AddRange(laneNotes); + } + } + + private static void CompileLongNoteChannels(List events, BmsChart chart) + { + var channels = BmsKeyLayout.ResolveKeyChannels(chart.Metadata); + var keyOffset = chart.Metadata.HasScratch ? 1 : 0; + for (var key = 0; key < channels.Length; key++) + { + var lane = key + keyOffset; + var channel = "5" + channels[key].Substring(1, 1); + var laneEvents = events + .Where(item => item.Raw.Channel == channel) + .OrderBy(item => item.Time) + .ThenBy(item => item.Raw.Sequence) + .ToList(); + + for (var index = 0; index + 1 < laneEvents.Count; index += 2) + { + var head = laneEvents[index]; + var tail = laneEvents[index + 1]; + chart.Notes.Add(new BmsNote + { + Lane = lane, + StartTime = head.Time, + EndTime = tail.Time, + StartBeat = head.Beat, + EndBeat = tail.Beat, + SoundId = head.Raw.ObjectId, + TailSoundId = tail.Raw.ObjectId, + }); + } + + if ((laneEvents.Count & 1) != 0) + AddWarning(chart.Warnings, $"Unpaired long-note object in lane {lane + 1}."); + } + } + + private static void CompileScratchObjects(List events, string lnObject, BmsChart chart) + { + if (!chart.Metadata.HasScratch) + return; + + CompileVisibleChannel(events, "16", 0, lnObject, chart, isScratch: true); + CompileVisibleChannel(events, "26", 0, lnObject, chart, isScratch: true); + CompileLongNoteChannel(events, "56", 0, chart, isScratch: true); + CompileLongNoteChannel(events, "66", 0, chart, isScratch: true); + } + + private static void CompileInvisibleKeySounds(List events, BmsChart chart) + { + var channels = BmsKeyLayout.ResolveKeyChannels(chart.Metadata); + var keyOffset = chart.Metadata.HasScratch ? 1 : 0; + for (var key = 0; key < channels.Length; key++) + { + var visibleChannel = channels[key]; + if (visibleChannel.Length != 2) + continue; + CompileInvisibleChannel( + events, + "3" + visibleChannel.Substring(1, 1), + key + keyOffset, + chart); + } + + if (chart.Metadata.HasScratch) + { + CompileInvisibleChannel(events, "36", 0, chart); + CompileInvisibleChannel(events, "46", 0, chart); + } + } + + private static void CompileInvisibleChannel( + IEnumerable events, + string channel, + int lane, + BmsChart chart) + { + foreach (var item in events) + { + if (item.Raw.Channel != channel || string.IsNullOrWhiteSpace(item.Raw.ObjectId)) + continue; + chart.LaneSoundEvents.Add(new BmsLaneSoundEvent + { + Lane = lane, + Time = item.Time, + SoundId = item.Raw.ObjectId, + }); + } + } + + private static void CompileVisibleChannel( + List events, + string channel, + int lane, + string lnObject, + BmsChart chart, + bool isScratch = false) + { + var channelEvents = events + .Where(item => item.Raw.Channel == channel) + .OrderBy(item => item.Time) + .ThenBy(item => item.Raw.Sequence) + .ToList(); + var notes = new List(); + foreach (var item in channelEvents) + { + if (!string.IsNullOrEmpty(lnObject) && + string.Equals(item.Raw.ObjectId, lnObject, StringComparison.Ordinal)) + { + var head = notes.LastOrDefault(note => !note.IsLong && note.StartTime < item.Time); + if (head != null) + { + head.EndTime = item.Time; + head.EndBeat = item.Beat; + head.TailSoundId = item.Raw.ObjectId; + } + continue; + } + + notes.Add(new BmsNote + { + Lane = lane, + StartTime = item.Time, + EndTime = item.Time, + StartBeat = item.Beat, + EndBeat = item.Beat, + SoundId = item.Raw.ObjectId, + IsScratch = isScratch, + }); + } + chart.Notes.AddRange(notes); + } + + private static void CompileLongNoteChannel( + List events, + string channel, + int lane, + BmsChart chart, + bool isScratch = false) + { + var channelEvents = events + .Where(item => item.Raw.Channel == channel) + .OrderBy(item => item.Time) + .ThenBy(item => item.Raw.Sequence) + .ToList(); + for (var index = 0; index + 1 < channelEvents.Count; index += 2) + { + var head = channelEvents[index]; + var tail = channelEvents[index + 1]; + chart.Notes.Add(new BmsNote + { + Lane = lane, + StartTime = head.Time, + EndTime = tail.Time, + StartBeat = head.Beat, + EndBeat = tail.Beat, + SoundId = head.Raw.ObjectId, + TailSoundId = tail.Raw.ObjectId, + IsScratch = isScratch, + }); + } + } + + private static double GetMeasureRatio(IReadOnlyDictionary measureLengths, int measure) + { + return measureLengths.TryGetValue(measure, out var ratio) ? ratio : 1.0; + } + + private static int CompareRawEvents(BmsRawEvent left, BmsRawEvent right) + { + var position = left.Position.CompareTo(right.Position); + return position != 0 ? position : left.Sequence.CompareTo(right.Sequence); + } + + private static void AddWarning(ICollection warnings, string warning) + { + if (!warnings.Contains(warning)) + warnings.Add(warning); + } + + private readonly struct MergeKey : IEquatable + { + public MergeKey(BmsPosition position, string channel) + { + Position = position; + Channel = channel; + } + + private BmsPosition Position { get; } + private string Channel { get; } + + public bool Equals(MergeKey other) => Position.Equals(other.Position) && Channel == other.Channel; + public override bool Equals(object obj) => obj is MergeKey other && Equals(other); + public override int GetHashCode() => HashCode.Combine(Position, Channel); + } + + private sealed class TimedEvent + { + public TimedEvent(BmsRawEvent raw, double time, double beat) + { + Raw = raw; + Time = time; + Beat = beat; + } + + public BmsRawEvent Raw { get; } + public double Time { get; } + public double Beat { get; } + } + } +} diff --git a/Assets/ProjectQuint/Runtime/Core/Bms/BmsTimelineCompiler.cs.meta b/Assets/ProjectQuint/Runtime/Core/Bms/BmsTimelineCompiler.cs.meta new file mode 100644 index 0000000..16f782f --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Core/Bms/BmsTimelineCompiler.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: baa77492ad43445629ed6490886a40e3 \ No newline at end of file diff --git a/Assets/ProjectQuint/Runtime/Core/Charts.meta b/Assets/ProjectQuint/Runtime/Core/Charts.meta new file mode 100644 index 0000000..fa43b35 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Core/Charts.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: d2bc03fb2c08c47b1b20db316c595ca2 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/ProjectQuint/Runtime/Core/Charts/PlayableChart.cs b/Assets/ProjectQuint/Runtime/Core/Charts/PlayableChart.cs new file mode 100644 index 0000000..5919f0e --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Core/Charts/PlayableChart.cs @@ -0,0 +1,158 @@ +using System; +using System.Collections.Generic; +using ProjectQuint.Core.Timing; + +namespace ProjectQuint.Core.Charts +{ + public enum LaneModifier + { + Off, + Mirror, + Random, + } + + public enum PlayableVisualLayer + { + Base, + Poor, + Overlay, + } + + public static class ChartKeyMode + { + public static string FormatLabel(int keyCount, bool hasScratch) + { + var normalized = Math.Max(4, Math.Min(7, keyCount)); + return hasScratch ? $"1+{normalized}K" : $"{normalized}K"; + } + } + + public sealed class PlayableChartMetadata + { + public string SourcePath { get; set; } = string.Empty; + public string Title { get; set; } = "Untitled"; + public string Subtitle { get; set; } = string.Empty; + public string Artist { get; set; } = "Unknown Artist"; + public string SubArtist { get; set; } = string.Empty; + public string Genre { get; set; } = string.Empty; + public string StageImage { get; set; } = string.Empty; + public string BackgroundImage { get; set; } = string.Empty; + public string SourceHash { get; set; } = string.Empty; + public string DifficultyLabel { get; set; } = string.Empty; + public double InitialTempo { get; set; } = 120.0; + public double MinimumTempo { get; set; } + public double MaximumTempo { get; set; } + public double GaugeTotal { get; set; } + public int PlayLevel { get; set; } + public int Difficulty { get; set; } + public int KeyCount { get; set; } = 5; + public bool HasScratch { get; set; } + public int TotalLaneCount => Math.Max(1, KeyCount) + (HasScratch ? 1 : 0); + } + + public sealed class PlayableNote + { + public int Id { get; set; } + public int Lane { get; set; } + public double StartTime { get; set; } + public double EndTime { get; set; } + public double StartBeat { get; set; } = double.NaN; + public double EndBeat { get; set; } = double.NaN; + public string SoundId { get; set; } = string.Empty; + public string TailSoundId { get; set; } = string.Empty; + public bool IsScratch { get; set; } + public bool IsLong => EndTime > StartTime; + } + + public sealed class PlayableAudioEvent + { + public double Time { get; set; } + public string SoundId { get; set; } = string.Empty; + } + + public sealed class PlayableLaneSoundEvent + { + public int Lane { get; set; } + public double Time { get; set; } + public string SoundId { get; set; } = string.Empty; + } + + public sealed class PlayableVisualEvent + { + public double Time { get; set; } + public string ResourceId { get; set; } = string.Empty; + public PlayableVisualLayer Layer { get; set; } + } + + /// + /// Format-neutral data consumed by gameplay, audio, BGA and result presentation. + /// Source-format parsers must convert into this model before runtime systems see a chart. + /// + public sealed class PlayableChart + { + private static readonly TempoMap DefaultTempoMap = TempoMap.Constant(120.0); + + public PlayableChartMetadata Metadata { get; set; } = new PlayableChartMetadata(); + public TempoMap TempoMap { get; set; } = DefaultTempoMap; + public ScrollMap ScrollMap { get; set; } = new ScrollMap(DefaultTempoMap); + public Dictionary AudioResources { get; set; } = + new Dictionary(StringComparer.Ordinal); + public Dictionary VisualResources { get; set; } = + new Dictionary(StringComparer.Ordinal); + public List Notes { get; } = new List(); + public List BackgroundAudioEvents { get; } = new List(); + public List LaneSoundEvents { get; } = new List(); + public List VisualEvents { get; } = new List(); + public List Warnings { get; } = new List(); + public int ScratchObjectCount { get; set; } + public double Duration { get; set; } + } + + public static class LanePermutation + { + public static int[] Create( + LaneModifier modifier, + int keyCount = 5, + bool hasScratch = false, + int randomSeed = 0) + { + if (keyCount < 4 || keyCount > 7) + throw new ArgumentOutOfRangeException(nameof(keyCount), "BMS key count must be between 4 and 7."); + + var keyOffset = hasScratch ? 1 : 0; + var laneMap = new int[keyCount + keyOffset]; + if (hasScratch) + laneMap[0] = 0; + for (var key = 0; key < keyCount; key++) + laneMap[key + keyOffset] = key + keyOffset; + + switch (modifier) + { + case LaneModifier.Off: + return laneMap; + case LaneModifier.Mirror: + Array.Reverse(laneMap, keyOffset, keyCount); + return laneMap; + case LaneModifier.Random: + { + var random = new Random(randomSeed); + for (var index = laneMap.Length - 1; index > keyOffset; index--) + { + var swapIndex = random.Next(keyOffset, index + 1); + (laneMap[index], laneMap[swapIndex]) = (laneMap[swapIndex], laneMap[index]); + } + + var unchanged = true; + for (var lane = keyOffset; lane < laneMap.Length; lane++) + unchanged &= laneMap[lane] == lane; + if (unchanged) + (laneMap[keyOffset], laneMap[keyOffset + 1]) = + (laneMap[keyOffset + 1], laneMap[keyOffset]); + return laneMap; + } + default: + throw new ArgumentOutOfRangeException(nameof(modifier), modifier, null); + } + } + } +} diff --git a/Assets/ProjectQuint/Runtime/Core/Charts/PlayableChart.cs.meta b/Assets/ProjectQuint/Runtime/Core/Charts/PlayableChart.cs.meta new file mode 100644 index 0000000..a76d1d4 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Core/Charts/PlayableChart.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 06a0a05da456f45cf80b873aee380ba0 \ No newline at end of file diff --git a/Assets/ProjectQuint/Runtime/Core/Course.meta b/Assets/ProjectQuint/Runtime/Core/Course.meta new file mode 100644 index 0000000..4b69cb5 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Core/Course.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: c4d67179ecaf44a5937315f956dfa7a9 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/ProjectQuint/Runtime/Core/Course/CourseLaneModifierPolicy.cs b/Assets/ProjectQuint/Runtime/Core/Course/CourseLaneModifierPolicy.cs new file mode 100644 index 0000000..4889078 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Core/Course/CourseLaneModifierPolicy.cs @@ -0,0 +1,26 @@ +using ProjectQuint.Core.Charts; + +namespace ProjectQuint.Core.Course +{ + /// + /// Competitive course runs permit only the authored lane order or its mirror. + /// RANDOM is normalized to the authored order before any stage is transformed. + /// + public static class CourseLaneModifierPolicy + { + public static bool IsAllowed(LaneModifier modifier) + { + return modifier == LaneModifier.Off || modifier == LaneModifier.Mirror; + } + + public static LaneModifier Normalize(LaneModifier modifier) + { + return modifier == LaneModifier.Mirror ? LaneModifier.Mirror : LaneModifier.Off; + } + + public static LaneModifier Normalize(LaneModifier modifier, bool mirrorAllowed) + { + return mirrorAllowed ? Normalize(modifier) : LaneModifier.Off; + } + } +} diff --git a/Assets/ProjectQuint/Runtime/Core/Course/CourseLaneModifierPolicy.cs.meta b/Assets/ProjectQuint/Runtime/Core/Course/CourseLaneModifierPolicy.cs.meta new file mode 100644 index 0000000..6b3b1de --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Core/Course/CourseLaneModifierPolicy.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d88302233d278417d9827be5358f8de2 \ No newline at end of file diff --git a/Assets/ProjectQuint/Runtime/Core/Course/CourseProgress.cs b/Assets/ProjectQuint/Runtime/Core/Course/CourseProgress.cs new file mode 100644 index 0000000..92c892e --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Core/Course/CourseProgress.cs @@ -0,0 +1,131 @@ +using System; +using System.Collections.Generic; +using ProjectQuint.Core.Judgement; + +namespace ProjectQuint.Core.Course +{ + public readonly struct CourseStageResult + { + public CourseStageResult(RhythmJudgementEngine judgement, bool cleared) + { + if (judgement == null) + throw new ArgumentNullException(nameof(judgement)); + + Cleared = cleared; + Score = judgement.Score; + AccuracyPercent = judgement.AccuracyPercent; + ExScore = judgement.KoolCount * 2 + judgement.CoolCount; + ExScoreRate = judgement.JudgedNoteCount <= 0 + ? 0.0 + : ExScore * 100.0 / (judgement.JudgedNoteCount * 2.0); + BreakPointCount = judgement.MissCount; + MaxCombo = judgement.MaxCombo; + JudgedNotes = judgement.JudgedNoteCount; + TotalScoringUnits = judgement.TotalScoringUnitCount; + KoolCount = judgement.KoolCount; + CoolCount = judgement.CoolCount; + GoodCount = judgement.GoodCount; + MissCount = judgement.MissCount; + PurePerfectCount = judgement.PurePerfectCount; + } + + public bool Cleared { get; } + public int Score { get; } + public double AccuracyPercent { get; } + public int ExScore { get; } + public double ExScoreRate { get; } + public int BreakPointCount { get; } + public int MaxCombo { get; } + public int JudgedNotes { get; } + public int TotalScoringUnits { get; } + public int KoolCount { get; } + public int CoolCount { get; } + public int GoodCount { get; } + public int MissCount { get; } + public int PurePerfectCount { get; } + } + + public sealed class CourseProgress + { + public const int ScoreFormatVersion = 2; + + private long _accuracyValueTotal; + private int _stageScoreTotal; + private readonly List _stageResults = + new List(4); + + public int StagesPlayed { get; private set; } + public int ClearedStages { get; private set; } + public int JudgedNotes { get; private set; } + public int KoolCount { get; private set; } + public int CoolCount { get; private set; } + public int GoodCount { get; private set; } + public int MissCount { get; private set; } + public int FastCount { get; private set; } + public int LateCount { get; private set; } + public int PurePerfectCount { get; private set; } + public int CurrentCombo { get; private set; } + public int MaxCombo { get; private set; } + public IReadOnlyList StageResults => _stageResults; + + public int ExScore => KoolCount * 2 + CoolCount; + public int BreakPointCount => MissCount; + public int Score => _stageScoreTotal; + public double AccuracyPercent => JudgedNotes <= 0 + ? 0.0 + : _accuracyValueTotal * 100.0 / (JudgedNotes * 1000.0); + public double ExScoreRate => JudgedNotes <= 0 + ? 0.0 + : ExScore * 100.0 / (JudgedNotes * 2.0); + public double MissRate => JudgedNotes <= 0 + ? 100.0 + : BreakPointCount * 100.0 / JudgedNotes; + + public int CombinedScore(RhythmJudgementEngine currentStage) + { + return _stageScoreTotal + (currentStage?.Score ?? 0); + } + + public double CombinedAccuracyPercent(RhythmJudgementEngine currentStage) + { + var currentJudgedNotes = currentStage?.JudgedNoteCount ?? 0; + var combinedJudgedNotes = JudgedNotes + currentJudgedNotes; + if (combinedJudgedNotes <= 0) + return 100.0; + + var currentAccuracyValue = + (currentStage?.KoolCount ?? 0) * 1000L + + (currentStage?.CoolCount ?? 0) * 700L + + (currentStage?.GoodCount ?? 0) * 200L; + return (_accuracyValueTotal + currentAccuracyValue) * 100.0 / + (combinedJudgedNotes * 1000.0); + } + + public void AddStage(RhythmJudgementEngine judgement, bool cleared) + { + if (judgement == null) + throw new ArgumentNullException(nameof(judgement)); + + var stageResult = new CourseStageResult(judgement, cleared); + _stageResults.Add(stageResult); + StagesPlayed++; + if (cleared) + ClearedStages++; + _stageScoreTotal += stageResult.Score; + JudgedNotes += judgement.JudgedNoteCount; + KoolCount += judgement.KoolCount; + CoolCount += judgement.CoolCount; + GoodCount += judgement.GoodCount; + MissCount += judgement.MissCount; + FastCount += judgement.FastCount; + LateCount += judgement.LateCount; + PurePerfectCount += judgement.PurePerfectCount; + CurrentCombo = judgement.Combo; + MaxCombo = Math.Max(MaxCombo, judgement.MaxCombo); + _accuracyValueTotal += + judgement.KoolCount * 1000L + + judgement.CoolCount * 700L + + judgement.GoodCount * 200L; + } + } +} diff --git a/Assets/ProjectQuint/Runtime/Core/Course/CourseProgress.cs.meta b/Assets/ProjectQuint/Runtime/Core/Course/CourseProgress.cs.meta new file mode 100644 index 0000000..52651aa --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Core/Course/CourseProgress.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 50b6be1327764ee4ac0141e729418c72 diff --git a/Assets/ProjectQuint/Runtime/Core/Gauge.meta b/Assets/ProjectQuint/Runtime/Core/Gauge.meta new file mode 100644 index 0000000..5ed0f24 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Core/Gauge.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 5538736f60e3447cb97e3a501798db0c +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/ProjectQuint/Runtime/Core/Gauge/GaugeFallbackChain.cs b/Assets/ProjectQuint/Runtime/Core/Gauge/GaugeFallbackChain.cs new file mode 100644 index 0000000..6df2fd3 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Core/Gauge/GaugeFallbackChain.cs @@ -0,0 +1,120 @@ +using System; +using ProjectQuint.Core.Judgement; + +namespace ProjectQuint.Core.Gauge +{ + /// + /// Tracks survival-gauge fallbacks from the beginning of a song so that a + /// transition uses the exact state the destination gauge would have had. + /// All gauges are allocated up front; applying judgements does not allocate. + /// + public sealed class GaugeFallbackChain + { + private readonly PlayGauge[] _gauges; + private int _activeIndex; + + public GaugeFallbackChain( + GaugeMode initialMode, + double declaredTotal, + int totalNotes, + bool fallbackEnabled, + double? restoredValue = null, + CourseGaugeProfile courseGaugeProfile = CourseGaugeProfile.Lr2) + { + var canFallback = + fallbackEnabled && + !restoredValue.HasValue && + initialMode != GaugeMode.Class; + var modes = canFallback + ? ResolveModes(initialMode) + : new[] { initialMode }; + + _gauges = new PlayGauge[modes.Length]; + for (var index = 0; index < modes.Length; index++) + { + _gauges[index] = new PlayGauge( + modes[index], + declaredTotal, + totalNotes, + index == 0 ? restoredValue : null, + courseGaugeProfile); + } + } + + public PlayGauge ActiveGauge => _gauges[_activeIndex]; + public int GaugeCount => _gauges.Length; + public bool HasFallback => _activeIndex + 1 < _gauges.Length; + + public GaugeTransition Apply(JudgementResult result) + { + for (var index = 0; index < _gauges.Length; index++) + _gauges[index].Apply(result); + return AdvancePastFailedGauges(); + } + + public GaugeTransition Apply(JudgementGrade grade) + { + for (var index = 0; index < _gauges.Length; index++) + _gauges[index].Apply(grade); + return AdvancePastFailedGauges(); + } + + public GaugeTransition ApplyEmptyInputPenalty() + { + for (var index = 0; index < _gauges.Length; index++) + _gauges[index].ApplyEmptyInputPenalty(); + return AdvancePastFailedGauges(); + } + + internal PlayGauge GaugeAt(int index) + { + if (index < 0 || index >= _gauges.Length) + throw new ArgumentOutOfRangeException(nameof(index)); + return _gauges[index]; + } + + private GaugeTransition AdvancePastFailedGauges() + { + if (!ActiveGauge.IsFailed || !HasFallback) + return GaugeTransition.None; + + var previousMode = ActiveGauge.Mode; + do + { + _activeIndex++; + } + while (ActiveGauge.IsFailed && HasFallback); + + return new GaugeTransition(previousMode, ActiveGauge.Mode); + } + + private static GaugeMode[] ResolveModes(GaugeMode initialMode) + { + switch (initialMode) + { + case GaugeMode.ExHard: + return new[] { GaugeMode.ExHard, GaugeMode.Hard, GaugeMode.Normal }; + case GaugeMode.Hard: + return new[] { GaugeMode.Hard, GaugeMode.Normal }; + default: + return new[] { initialMode }; + } + } + } + + public readonly struct GaugeTransition + { + public static readonly GaugeTransition None = default; + + public GaugeTransition(GaugeMode from, GaugeMode to) + { + From = from; + To = to; + Changed = from != to; + } + + public GaugeMode From { get; } + public GaugeMode To { get; } + public bool Changed { get; } + } +} diff --git a/Assets/ProjectQuint/Runtime/Core/Gauge/GaugeFallbackChain.cs.meta b/Assets/ProjectQuint/Runtime/Core/Gauge/GaugeFallbackChain.cs.meta new file mode 100644 index 0000000..49af50f --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Core/Gauge/GaugeFallbackChain.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 8f9710e41db44ce3a2317d8ef1f95ed7 diff --git a/Assets/ProjectQuint/Runtime/Core/Gauge/PlayGauge.cs b/Assets/ProjectQuint/Runtime/Core/Gauge/PlayGauge.cs new file mode 100644 index 0000000..d9537df --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Core/Gauge/PlayGauge.cs @@ -0,0 +1,263 @@ +using System; +using ProjectQuint.Core.Judgement; + +namespace ProjectQuint.Core.Gauge +{ + public enum GaugeMode + { + Normal = 0, + Hard = 1, + Class = 2, + ExHard = 3, + } + + public enum CourseGaugeProfile + { + Lr2, + FiveKey, + SevenKey, + } + + /// + /// Owns ProjectQuint's normal, hard, ex-hard, and course life-gauge state. + /// Regular survival play uses one consistent ProjectQuint profile across + /// every supported key mode. Source-specific course rules remain isolated + /// in the course profile. + /// + public sealed class PlayGauge + { + public const double NormalInitialValue = 20.0; + public const double NormalMinimumValue = 2.0; + public const double NormalClearBorder = 75.0; + public const double HardInitialValue = 100.0; + public const double ClassInitialValue = 100.0; + public const double MaximumValue = 100.0; + + private readonly double _normalFullHitIncrement; + private readonly double _standardHardRecoveryScale; + private readonly CourseGaugeProfile _courseGaugeProfile; + + public PlayGauge( + GaugeMode mode, + double declaredTotal, + int totalNotes, + double? restoredValue = null, + CourseGaugeProfile courseGaugeProfile = CourseGaugeProfile.Lr2) + { + Mode = mode; + TotalNotes = Math.Max(0, totalNotes); + Total = declaredTotal > 0 ? declaredTotal : CalculateDefaultTotal(TotalNotes); + _courseGaugeProfile = courseGaugeProfile; + _normalFullHitIncrement = TotalNotes > 0 ? Total / TotalNotes : 0; + _standardHardRecoveryScale = CalculateStandardHardRecoveryScale(Total, TotalNotes); + Reset(); + if (restoredValue.HasValue) + Restore(restoredValue.Value); + } + + public GaugeMode Mode { get; } + public int TotalNotes { get; } + public double Total { get; } + public double Value { get; private set; } + public double MinimumValue => Mode == GaugeMode.Normal ? NormalMinimumValue : 0.0; + public double ClearBorder => Mode == GaugeMode.Normal ? NormalClearBorder : 0.0; + public bool IsFailed => Mode != GaugeMode.Normal && Value <= 0.0; + public bool IsCleared => Mode == GaugeMode.Normal + ? Value >= NormalClearBorder + : !IsFailed; + + public void Reset() + { + Value = Mode switch + { + GaugeMode.Normal => NormalInitialValue, + GaugeMode.Class => ClassInitialValue, + _ => HardInitialValue, + }; + } + + public void Restore(double value) + { + Value = Math.Max(MinimumValue, Math.Min(MaximumValue, value)); + } + + public void Apply(JudgementResult result) + { + Apply(result.Grade); + } + + public void Apply(JudgementGrade grade) + { + if (IsFailed || grade == JudgementGrade.None) + return; + + var delta = Mode switch + { + GaugeMode.Normal => ResolveNormalDelta(grade), + GaugeMode.Class => ResolveCourseDelta(grade, Value, _courseGaugeProfile), + GaugeMode.ExHard => ResolveExHardDelta(grade), + _ => ResolveHardDelta(grade), + }; + Value = Math.Max(MinimumValue, Math.Min(MaximumValue, Value + delta)); + } + + public void ApplyEmptyInputPenalty() + { + if (IsFailed) + return; + + var delta = Mode switch + { + GaugeMode.Normal => -2.0, + GaugeMode.Class => ResolveCourseEmptyInputDelta(Value, _courseGaugeProfile), + GaugeMode.ExHard => -8.0, + _ => ApplyStandardHardDamageReduction(-5.0, Value), + }; + Value = Math.Max(MinimumValue, Math.Min(MaximumValue, Value + delta)); + } + + public static double CalculateDefaultTotal(int totalNotes) + { + var notes = Math.Max(0, totalNotes); + return Math.Max(260.0, 7.605 * notes / (0.01 * notes + 6.5)); + } + + private double ResolveNormalDelta(JudgementGrade grade) + { + switch (grade) + { + case JudgementGrade.Kool: + case JudgementGrade.Cool: + return _normalFullHitIncrement; + case JudgementGrade.Good: + return _normalFullHitIncrement * 0.5; + case JudgementGrade.Miss: + return -6.0; + default: + return 0.0; + } + } + + private double ResolveHardDelta(JudgementGrade grade) + { + switch (grade) + { + case JudgementGrade.Kool: + return 0.15 * _standardHardRecoveryScale; + case JudgementGrade.Cool: + return 0.12 * _standardHardRecoveryScale; + case JudgementGrade.Good: + return 0.03 * _standardHardRecoveryScale; + case JudgementGrade.Miss: + return ApplyStandardHardDamageReduction(-10.0, Value); + default: + return 0.0; + } + } + + private double ResolveExHardDelta(JudgementGrade grade) + { + switch (grade) + { + case JudgementGrade.Kool: + return 0.15 * _standardHardRecoveryScale; + case JudgementGrade.Cool: + return 0.06 * _standardHardRecoveryScale; + case JudgementGrade.Good: + return 0.0; + case JudgementGrade.Miss: + return -16.0; + default: + return 0.0; + } + } + + private static double ResolveCourseDelta( + JudgementGrade grade, + double currentValue, + CourseGaugeProfile profile) + { + var delta = profile switch + { + CourseGaugeProfile.FiveKey => grade switch + { + JudgementGrade.Kool => 0.01, + JudgementGrade.Cool => 0.01, + JudgementGrade.Good => 0.0, + JudgementGrade.Miss => -1.0, + _ => 0.0, + }, + CourseGaugeProfile.SevenKey => grade switch + { + JudgementGrade.Kool => 0.15, + JudgementGrade.Cool => 0.12, + JudgementGrade.Good => 0.06, + JudgementGrade.Miss => -3.0, + _ => 0.0, + }, + _ => grade switch + { + JudgementGrade.Kool => 0.10, + JudgementGrade.Cool => 0.10, + JudgementGrade.Good => 0.05, + JudgementGrade.Miss => -3.0, + _ => 0.0, + }, + }; + return ApplyCourseDamageReduction(delta, currentValue, profile); + } + + private static double ResolveCourseEmptyInputDelta( + double currentValue, + CourseGaugeProfile profile) + { + var delta = profile switch + { + CourseGaugeProfile.FiveKey => -0.5, + CourseGaugeProfile.SevenKey => -1.5, + _ => -2.0, + }; + return ApplyCourseDamageReduction(delta, currentValue, profile); + } + + private static double ApplyCourseDamageReduction( + double delta, + double currentValue, + CourseGaugeProfile profile) + { + if (delta >= 0.0 || profile == CourseGaugeProfile.FiveKey) + return delta; + if (profile == CourseGaugeProfile.Lr2) + return currentValue < 30.0 ? delta * 0.6 : delta; + if (currentValue < 5.0) return delta * 0.4; + if (currentValue < 10.0) return delta * 0.5; + if (currentValue < 15.0) return delta * 0.6; + if (currentValue < 20.0) return delta * 0.7; + if (currentValue < 25.0) return delta * 0.8; + return delta; + } + + private static double CalculateStandardHardRecoveryScale(double total, int totalNotes) + { + if (totalNotes <= 0) + return 0.0; + + var fullHitRecovery = Math.Max( + 0.0, + Math.Min(0.15, (2.0 * total - 320.0) / totalNotes)); + return fullHitRecovery / 0.15; + } + + private static double ApplyStandardHardDamageReduction(double delta, double currentValue) + { + if (delta >= 0.0) + return delta; + if (currentValue < 10.0) return delta * 0.4; + if (currentValue < 20.0) return delta * 0.5; + if (currentValue < 30.0) return delta * 0.6; + if (currentValue < 40.0) return delta * 0.7; + if (currentValue < 50.0) return delta * 0.8; + return delta; + } + } +} diff --git a/Assets/ProjectQuint/Runtime/Core/Gauge/PlayGauge.cs.meta b/Assets/ProjectQuint/Runtime/Core/Gauge/PlayGauge.cs.meta new file mode 100644 index 0000000..c8f5431 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Core/Gauge/PlayGauge.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f17a55945f92a41b8b11cafbae0b788a diff --git a/Assets/ProjectQuint/Runtime/Core/Identity.meta b/Assets/ProjectQuint/Runtime/Core/Identity.meta new file mode 100644 index 0000000..e57c9d6 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Core/Identity.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: ce17824f818ae4ce59c3510373fb2848 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/ProjectQuint/Runtime/Core/Identity/ChartId.cs b/Assets/ProjectQuint/Runtime/Core/Identity/ChartId.cs new file mode 100644 index 0000000..7a627df --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Core/Identity/ChartId.cs @@ -0,0 +1,46 @@ +using System; + +namespace ProjectQuint.Core.Identity +{ + public readonly struct ChartId : IEquatable + { + private ChartId(string value) + { + Value = value ?? string.Empty; + } + + public string Value { get; } + public bool IsValid => !string.IsNullOrWhiteSpace(Value); + + public static ChartId FromMd5(string md5) => + Create("md5", md5); + + public static ChartId FromSha256(string sha256) => + Create("sha256", sha256); + + public static ChartId FromPathFallback(string pathHash) => + Create("path-sha256", pathHash); + + public bool Equals(ChartId other) => + string.Equals(Value, other.Value, StringComparison.Ordinal); + + public override bool Equals(object obj) => + obj is ChartId other && Equals(other); + + public override int GetHashCode() => + StringComparer.Ordinal.GetHashCode(Value ?? string.Empty); + + public override string ToString() => Value ?? string.Empty; + + public static bool operator ==(ChartId left, ChartId right) => left.Equals(right); + public static bool operator !=(ChartId left, ChartId right) => !left.Equals(right); + + private static ChartId Create(string algorithm, string digest) + { + var normalized = (digest ?? string.Empty).Trim().ToLowerInvariant(); + return string.IsNullOrEmpty(normalized) + ? default + : new ChartId($"{algorithm}:{normalized}"); + } + } +} diff --git a/Assets/ProjectQuint/Runtime/Core/Identity/ChartId.cs.meta b/Assets/ProjectQuint/Runtime/Core/Identity/ChartId.cs.meta new file mode 100644 index 0000000..fa66246 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Core/Identity/ChartId.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 8a50d6052e42b438f902e8410ddb3682 \ No newline at end of file diff --git a/Assets/ProjectQuint/Runtime/Core/Judgement.meta b/Assets/ProjectQuint/Runtime/Core/Judgement.meta new file mode 100644 index 0000000..56e65d6 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Core/Judgement.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 831a80b86d35b4af99c56b93f4ad623d +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/ProjectQuint/Runtime/Core/Judgement/LongNotePolicies.cs b/Assets/ProjectQuint/Runtime/Core/Judgement/LongNotePolicies.cs new file mode 100644 index 0000000..19a25be --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Core/Judgement/LongNotePolicies.cs @@ -0,0 +1,79 @@ +using System; +using System.Collections.Generic; +using ProjectQuint.Core.Charts; +using ProjectQuint.Core.Timing; + +namespace ProjectQuint.Core.Judgement +{ + public sealed class LongNoteReleasePolicy + { + public static LongNoteReleasePolicy DjmaxLike { get; } = new LongNoteReleasePolicy(0.100); + + public LongNoteReleasePolicy(double earlyReleaseGraceSeconds) + { + if (earlyReleaseGraceSeconds < 0 || + double.IsNaN(earlyReleaseGraceSeconds) || + double.IsInfinity(earlyReleaseGraceSeconds)) + throw new ArgumentOutOfRangeException(nameof(earlyReleaseGraceSeconds)); + EarlyReleaseGraceSeconds = earlyReleaseGraceSeconds; + } + + public double EarlyReleaseGraceSeconds { get; } + + public bool Accepts(double releaseTime, double tailTime) => + releaseTime + EarlyReleaseGraceSeconds + 0.000001 >= tailTime; + } + + public sealed class LongNoteTickPolicy + { + public const double SixteenthNoteBeats = 0.25; + public static LongNoteTickPolicy MusicalSixteenths { get; } = + new LongNoteTickPolicy(SixteenthNoteBeats); + + public LongNoteTickPolicy(double beatInterval) + { + if (!(beatInterval > 0) || double.IsNaN(beatInterval) || double.IsInfinity(beatInterval)) + throw new ArgumentOutOfRangeException(nameof(beatInterval)); + BeatInterval = beatInterval; + } + + public double BeatInterval { get; } + + public double[] CreateTickTimes(PlayableNote note, TempoMap tempoMap) + { + if (note?.IsLong != true) + return Array.Empty(); + if (tempoMap == null) + throw new ArgumentNullException(nameof(tempoMap)); + + var hasMusicalPosition = IsFinite(note.StartBeat) && + IsFinite(note.EndBeat) && + note.EndBeat > note.StartBeat; + var startBeat = hasMusicalPosition + ? note.StartBeat + : tempoMap.TimeToBeat(note.StartTime); + var endBeat = hasMusicalPosition + ? note.EndBeat + : tempoMap.TimeToBeat(note.EndTime); + if (endBeat <= startBeat) + return Array.Empty(); + + var tickCount = (int)Math.Floor((endBeat - startBeat + 0.000000001) / BeatInterval); + if (tickCount <= 0) + return Array.Empty(); + + var result = new List(tickCount); + for (var tick = 1; tick <= tickCount; tick++) + { + var beat = startBeat + tick * BeatInterval; + if (beat > endBeat + 0.000000001) + break; + result.Add(Math.Min(note.EndTime, tempoMap.BeatToTime(beat))); + } + return result.ToArray(); + } + + private static bool IsFinite(double value) => + !double.IsNaN(value) && !double.IsInfinity(value); + } +} diff --git a/Assets/ProjectQuint/Runtime/Core/Judgement/LongNotePolicies.cs.meta b/Assets/ProjectQuint/Runtime/Core/Judgement/LongNotePolicies.cs.meta new file mode 100644 index 0000000..37e37e1 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Core/Judgement/LongNotePolicies.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a557f3222278452785f524b4dd244e27 diff --git a/Assets/ProjectQuint/Runtime/Core/Judgement/RhythmJudgementEngine.cs b/Assets/ProjectQuint/Runtime/Core/Judgement/RhythmJudgementEngine.cs new file mode 100644 index 0000000..ce109cb --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Core/Judgement/RhythmJudgementEngine.cs @@ -0,0 +1,575 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using ProjectQuint.Core.Charts; +using ProjectQuint.Core.Timing; + +namespace ProjectQuint.Core.Judgement +{ + public enum JudgementGrade + { + None, + Kool, + Cool, + Good, + Miss, + } + + public enum RuntimeNoteState + { + Pending, + Holding, + BrokenHolding, + Completed, + Failed, + } + + public readonly struct JudgementResult + { + public JudgementResult( + int noteId, + JudgementGrade grade, + double offsetSeconds, + bool longNoteBreak = false, + bool isHoldTick = false, + bool suppressGameplayPenalty = false) + { + NoteId = noteId; + Grade = grade; + OffsetSeconds = offsetSeconds; + LongNoteBreak = longNoteBreak; + IsHoldTick = isHoldTick; + SuppressGameplayPenalty = suppressGameplayPenalty; + } + + public int NoteId { get; } + public JudgementGrade Grade { get; } + public double OffsetSeconds { get; } + public bool LongNoteBreak { get; } + public bool IsHoldTick { get; } + public bool SuppressGameplayPenalty { get; } + public bool IsEarly => OffsetSeconds < 0; + } + + public readonly struct TimingSample + { + public TimingSample(double songTime, double offsetSeconds) + { + SongTime = songTime; + OffsetSeconds = offsetSeconds; + } + + public double SongTime { get; } + public double OffsetSeconds { get; } + } + + public sealed class JudgementProfile + { + public static JudgementProfile ProjectQuint { get; } = new JudgementProfile( + koolSeconds: 0.042, + coolSeconds: 0.083, + goodSeconds: 0.125); + + public JudgementProfile(double koolSeconds, double coolSeconds, double goodSeconds) + { + if (!(koolSeconds > 0 && koolSeconds <= coolSeconds && coolSeconds <= goodSeconds)) + throw new ArgumentException("Judgement windows must be positive and ascending."); + + KoolSeconds = koolSeconds; + CoolSeconds = coolSeconds; + GoodSeconds = goodSeconds; + } + + public double KoolSeconds { get; } + public double CoolSeconds { get; } + public double GoodSeconds { get; } + + public JudgementGrade Grade(double absoluteOffset) + { + if (absoluteOffset <= KoolSeconds) return JudgementGrade.Kool; + if (absoluteOffset <= CoolSeconds) return JudgementGrade.Cool; + if (absoluteOffset <= GoodSeconds) return JudgementGrade.Good; + return JudgementGrade.Miss; + } + } + + public sealed class RhythmJudgementEngine + { + private const int MaxTimingSamples = 24; + public const int TimingHistogramBinCount = 25; + public const double PerfectTimingNeutralSeconds = 0.022; + public const double LongNoteReleaseGraceSeconds = 0.100; + private readonly JudgementProfile _profile; + private readonly LongNoteReleasePolicy _longNoteReleasePolicy; + private readonly Func _suppressMissPenalty; + private readonly List _notes; + private readonly List[] _notesByLane; + private readonly Dictionary _notesById; + private readonly List _recentTimingSamples = new List(MaxTimingSamples); + private readonly int[] _timingHistogram = new int[TimingHistogramBinCount]; + private readonly int[] _lanePendingCursors; + private readonly int[] _judgementCounts = new int[Enum.GetValues(typeof(JudgementGrade)).Length]; + private readonly RuntimeNote[] _activeLongNotes; + private readonly List _failedLongNotes; + private readonly int _totalScoringUnitCount; + private int _missCursor; + private int _unfinishedNoteCount; + private int _accuracyJudgedNotes; + private long _accuracyValueTotal; + private int _timingSampleCount; + private int _suppressedMissCount; + private double _timingOffsetTotalSeconds; + + public event Action Judged; + + public RhythmJudgementEngine( + IEnumerable notes, + JudgementProfile profile = null, + int laneCount = 0, + TempoMap tempoMap = null, + LongNoteTickPolicy longNoteTickPolicy = null, + LongNoteReleasePolicy longNoteReleasePolicy = null, + Func suppressMissPenalty = null) + { + _profile = profile ?? JudgementProfile.ProjectQuint; + var effectiveTempoMap = tempoMap ?? TempoMap.Constant(150.0); + var effectiveTickPolicy = longNoteTickPolicy ?? LongNoteTickPolicy.MusicalSixteenths; + _longNoteReleasePolicy = longNoteReleasePolicy ?? LongNoteReleasePolicy.DjmaxLike; + _suppressMissPenalty = suppressMissPenalty; + _notes = (notes ?? Array.Empty()) + .Select(note => new RuntimeNote( + note, + effectiveTickPolicy.CreateTickTimes(note, effectiveTempoMap))) + .OrderBy(note => note.Source.StartTime) + .ThenBy(note => note.Source.Lane) + .ToList(); + _notesById = _notes.ToDictionary(note => note.Source.Id); + var inferredLaneCount = _notes.Count == 0 + ? 5 + : _notes.Max(note => note.Source.Lane) + 1; + laneCount = laneCount > 0 ? laneCount : inferredLaneCount; + if (laneCount < 1 || laneCount > 8) + throw new ArgumentOutOfRangeException(nameof(laneCount), "Gameplay lane count must be between 1 and 8."); + _notesByLane = new List[laneCount]; + _lanePendingCursors = new int[laneCount]; + _activeLongNotes = new RuntimeNote[laneCount]; + _failedLongNotes = new List(laneCount); + for (var lane = 0; lane < _notesByLane.Length; lane++) + _notesByLane[lane] = new List(); + foreach (var note in _notes) + { + if (note.Source.Lane >= 0 && note.Source.Lane < _notesByLane.Length) + _notesByLane[note.Source.Lane].Add(note); + } + _totalScoringUnitCount = _notes.Sum(note => 1 + note.TotalHoldTickCount); + _unfinishedNoteCount = _notes.Count; + } + + public int Combo { get; private set; } + public int MaxCombo { get; private set; } + public int Score => _totalScoringUnitCount == 0 + ? 0 + : (int)Math.Round( + _accuracyValueTotal * 1_000_000.0 / (_totalScoringUnitCount * 1000.0), + MidpointRounding.AwayFromZero) + PurePerfectCount; + public int TotalScoringUnitCount => _totalScoringUnitCount; + public int PurePerfectCount { get; private set; } + public int KoolCount => GetJudgementCount(JudgementGrade.Kool); + public int CoolCount => GetJudgementCount(JudgementGrade.Cool); + public int GoodCount => GetJudgementCount(JudgementGrade.Good); + public int MissCount => GetJudgementCount(JudgementGrade.Miss); + public int VisibleMissCount => Math.Max(0, MissCount - _suppressedMissCount); + public int FastCount { get; private set; } + public int LateCount { get; private set; } + public JudgementResult? LastResult { get; private set; } + public double LastResultTime { get; private set; } = double.NegativeInfinity; + public int ResultSequence { get; private set; } + public IReadOnlyList RecentTimingSamples => _recentTimingSamples; + public IReadOnlyList TimingHistogram => _timingHistogram; + public double PerfectTimingWindowSeconds => _profile.KoolSeconds; + public double TimingWindowSeconds => _profile.GoodSeconds; + public double AverageTimingOffsetMilliseconds => _timingSampleCount == 0 + ? 0 + : _timingOffsetTotalSeconds * 1000.0 / _timingSampleCount; + public int JudgedNoteCount => _accuracyJudgedNotes; + public double AccuracyPercent => _accuracyJudgedNotes == 0 + ? 100.0 + : _accuracyValueTotal * 100.0 / (_accuracyJudgedNotes * 1000.0); + public bool IsFinished => _unfinishedNoteCount == 0; + + public void RestoreCourseCombo(int combo, int maxCombo) + { + Combo = Math.Max(0, combo); + MaxCombo = Math.Max(Combo, maxCombo); + } + + public static bool IsPerfectTimingNeutral(double offsetSeconds) => + Math.Abs(offsetSeconds) <= PerfectTimingNeutralSeconds + 0.000000001; + + public RuntimeNoteState GetState(int noteId) + { + return _notesById.TryGetValue(noteId, out var note) ? note.State : RuntimeNoteState.Completed; + } + + public int GetJudgementCount(JudgementGrade grade) + { + var index = (int)grade; + return index > (int)JudgementGrade.None && index < _judgementCounts.Length + ? _judgementCounts[index] + : 0; + } + + public JudgementResult? Press(int lane, double songTime, double timingOffsetSeconds = 0) + { + if (lane < 0 || lane >= _notesByLane.Length) + return null; + + var judgementTime = songTime + timingOffsetSeconds; + ProcessPendingMisses(judgementTime, songTime); + ProcessLongNoteTail(lane, songTime); + AdvanceLaneCursor(lane); + + RuntimeNote candidate = null; + var candidateOffset = 0.0; + var bestAbsoluteOffset = double.PositiveInfinity; + var laneNotes = _notesByLane[lane]; + for (var index = _lanePendingCursors[lane]; index < laneNotes.Count; index++) + { + var note = laneNotes[index]; + if (note.Source.StartTime > judgementTime + _profile.GoodSeconds) + break; + if (note.State != RuntimeNoteState.Pending) + continue; + + var offset = judgementTime - note.Source.StartTime; + var absoluteOffset = Math.Abs(offset); + if (absoluteOffset > _profile.GoodSeconds || absoluteOffset >= bestAbsoluteOffset) + continue; + + candidate = note; + candidateOffset = offset; + bestAbsoluteOffset = absoluteOffset; + } + + if (candidate == null) + return null; + + var grade = _profile.Grade(bestAbsoluteOffset); + if (candidate.Source.IsLong) + { + candidate.State = RuntimeNoteState.Holding; + _activeLongNotes[lane] = candidate; + } + else + { + candidate.State = RuntimeNoteState.Completed; + FinishNote(candidate); + } + ApplyHitScore(grade); + RecordAccuracy(candidate, grade); + if (grade == JudgementGrade.Kool && IsPerfectTimingNeutral(candidateOffset)) + PurePerfectCount++; + RecordTiming(candidateOffset, songTime); + AdvanceLaneCursor(lane); + + var result = new JudgementResult(candidate.Source.Id, grade, candidateOffset); + SetLastResult(result, songTime); + return result; + } + + public JudgementResult? Release(int lane, double songTime) + { + if (lane < 0 || lane >= _activeLongNotes.Length) + return null; + + var held = _activeLongNotes[lane]; + if (held == null || held.State != RuntimeNoteState.Holding) + return null; + + AwardHoldComboTicks(held, Math.Min(songTime, held.Source.EndTime)); + if (!_longNoteReleasePolicy.Accepts(songTime, held.Source.EndTime)) + { + held.State = RuntimeNoteState.BrokenHolding; + Combo = 0; + FinalizeUnawardedHoldTicks(held); + var failure = new JudgementResult( + held.Source.Id, + JudgementGrade.Miss, + songTime - held.Source.EndTime, + longNoteBreak: true); + SetLastResult(failure, songTime); + return failure; + } + + AwardHoldComboTicks(held, held.Source.EndTime); + held.State = RuntimeNoteState.Completed; + _activeLongNotes[lane] = null; + FinishNote(held); + return null; + } + + public void Update(double songTime, double timingOffsetSeconds = 0) + { + var judgementTime = songTime + timingOffsetSeconds; + ProcessPendingMisses(judgementTime, songTime); + ProcessFailedLongNoteTails(songTime); + for (var lane = 0; lane < _activeLongNotes.Length; lane++) + ProcessLongNoteTail(lane, songTime); + } + + private void ProcessFailedLongNoteTails(double songTime) + { + for (var index = _failedLongNotes.Count - 1; index >= 0; index--) + { + var note = _failedLongNotes[index]; + if (songTime < note.Source.EndTime) + continue; + + note.State = RuntimeNoteState.Failed; + FinishNote(note); + _failedLongNotes.RemoveAt(index); + } + } + + private void ProcessLongNoteTail(int lane, double judgementTime) + { + var note = _activeLongNotes[lane]; + if (note == null) + return; + + if (note.State == RuntimeNoteState.Holding) + AwardHoldComboTicks(note, Math.Min(judgementTime, note.Source.EndTime)); + if (judgementTime < note.Source.EndTime) + return; + + note.State = note.State == RuntimeNoteState.Holding + ? RuntimeNoteState.Completed + : RuntimeNoteState.Failed; + _activeLongNotes[lane] = null; + FinishNote(note); + } + + private void AwardHoldComboTicks(RuntimeNote note, double heldUntilTime) + { + if (note.State != RuntimeNoteState.Holding) + return; + + heldUntilTime = Math.Min(heldUntilTime, note.Source.EndTime); + var earnedTickCount = note.AwardedHoldTickCount; + while (earnedTickCount < note.HoldTickTimes.Length && + note.HoldTickTimes[earnedTickCount] <= heldUntilTime + 0.000001) + earnedTickCount++; + var newTickCount = earnedTickCount - note.AwardedHoldTickCount; + if (newTickCount <= 0) + return; + + var firstNewTick = note.AwardedHoldTickCount + 1; + note.AwardedHoldTickCount = earnedTickCount; + Combo += newTickCount; + MaxCombo = Math.Max(MaxCombo, Combo); + + // A COOL-or-better head confirms that the player entered the long note + // cleanly. Every subsequently held tick is therefore an exact KOOL/Pure + // unit. Lower head grades stay lower so a late/early entry cannot be + // repaired into a perfect long note merely by continuing to hold it. + var headWasCoolOrBetter = note.FinalGrade == JudgementGrade.Kool || + note.FinalGrade == JudgementGrade.Cool; + var tickGrade = headWasCoolOrBetter + ? JudgementGrade.Kool + : note.FinalGrade; + RecordScoringUnits(tickGrade, newTickCount); + if (headWasCoolOrBetter) + PurePerfectCount += newTickCount; + + // Ticks are real score/combo judgements and must retrigger the HUD. They + // are marked separately so presentation can react while the gauge does + // not receive recovery or damage a second time for the same long note. + for (var tick = firstNewTick; tick <= earnedTickCount; tick++) + { + var tickSongTime = note.HoldTickTimes[tick - 1]; + SetLastResult( + new JudgementResult( + note.Source.Id, + tickGrade, + offsetSeconds: 0, + isHoldTick: true), + tickSongTime); + } + } + + private void ProcessPendingMisses(double judgementTime, double songTime) + { + while (_missCursor < _notes.Count) + { + var note = _notes[_missCursor]; + if (judgementTime <= note.Source.StartTime + _profile.GoodSeconds) + break; + + _missCursor++; + if (note.State != RuntimeNoteState.Pending) + continue; + + var deadLongNote = note.Source.IsLong && songTime < note.Source.EndTime; + note.State = deadLongNote + ? RuntimeNoteState.BrokenHolding + : RuntimeNoteState.Failed; + if (deadLongNote) + _failedLongNotes.Add(note); + else + FinishNote(note); + var suppressGameplayPenalty = + _suppressMissPenalty?.Invoke(note.Source) == true; + if (!suppressGameplayPenalty) + Combo = 0; + var remainingHoldTicks = Math.Max( + 0, + note.TotalHoldTickCount - note.AwardedHoldTickCount); + RecordAccuracy(note, JudgementGrade.Miss); + FinalizeUnawardedHoldTicks(note); + if (suppressGameplayPenalty) + { + // Assisted scratch misses remain part of the final score and + // result breakdown, but must be completely silent during play: + // no MISS judgement event, no combo-break pulse, and no live + // MISS counter increment. + _suppressedMissCount += 1 + remainingHoldTicks; + if (note.Source.Lane >= 0 && note.Source.Lane < _notesByLane.Length) + AdvanceLaneCursor(note.Source.Lane); + continue; + } + SetLastResult( + new JudgementResult( + note.Source.Id, + JudgementGrade.Miss, + judgementTime - note.Source.StartTime), + songTime); + if (note.Source.Lane >= 0 && note.Source.Lane < _notesByLane.Length) + AdvanceLaneCursor(note.Source.Lane); + } + } + + private void AdvanceLaneCursor(int lane) + { + var notes = _notesByLane[lane]; + var cursor = _lanePendingCursors[lane]; + while (cursor < notes.Count && notes[cursor].State != RuntimeNoteState.Pending) + cursor++; + _lanePendingCursors[lane] = cursor; + } + + private void FinishNote(RuntimeNote note) + { + if (note.IsFinished) + return; + + note.IsFinished = true; + _unfinishedNoteCount--; + } + + private void ApplyHitScore(JudgementGrade grade) + { + Combo++; + MaxCombo = Math.Max(MaxCombo, Combo); + } + + private void RecordAccuracy(RuntimeNote note, JudgementGrade grade) + { + if (note.AccuracyValue.HasValue) + { + _accuracyValueTotal -= note.AccuracyValue.Value; + _judgementCounts[(int)note.FinalGrade]--; + } + else + { + _accuracyJudgedNotes++; + } + + var value = GradeValue(grade); + note.FinalGrade = grade; + note.AccuracyValue = value; + _accuracyValueTotal += value; + _judgementCounts[(int)grade]++; + } + + private void FinalizeUnawardedHoldTicks(RuntimeNote note) + { + if (note == null || note.HoldTicksFinalized) + return; + + note.HoldTicksFinalized = true; + var remaining = note.TotalHoldTickCount - note.AwardedHoldTickCount; + if (remaining > 0) + RecordScoringUnits(JudgementGrade.Miss, remaining); + } + + private void RecordScoringUnits(JudgementGrade grade, int count) + { + if (count <= 0 || grade == JudgementGrade.None) + return; + + _accuracyJudgedNotes += count; + _accuracyValueTotal += (long)GradeValue(grade) * count; + _judgementCounts[(int)grade] += count; + } + + private void SetLastResult(JudgementResult result, double songTime) + { + LastResult = result; + LastResultTime = songTime; + ResultSequence++; + Judged?.Invoke(result); + } + + private void RecordTiming(double offsetSeconds, double songTime) + { + _timingSampleCount++; + _timingOffsetTotalSeconds += offsetSeconds; + var normalizedOffset = Math.Max(-1.0, Math.Min(1.0, offsetSeconds / _profile.GoodSeconds)); + var histogramIndex = (int)Math.Round( + (normalizedOffset + 1.0) * 0.5 * (TimingHistogramBinCount - 1), + MidpointRounding.AwayFromZero); + _timingHistogram[Math.Max(0, Math.Min(TimingHistogramBinCount - 1, histogramIndex))]++; + if (IsPerfectTimingNeutral(offsetSeconds)) + return; + + if (offsetSeconds < 0) + FastCount++; + else if (offsetSeconds > 0) + LateCount++; + + if (_recentTimingSamples.Count == MaxTimingSamples) + _recentTimingSamples.RemoveAt(0); + _recentTimingSamples.Add(new TimingSample(songTime, offsetSeconds)); + } + + private static int GradeValue(JudgementGrade grade) + { + switch (grade) + { + case JudgementGrade.Kool: return 1000; + case JudgementGrade.Cool: return 700; + case JudgementGrade.Good: return 200; + default: return 0; + } + } + + private sealed class RuntimeNote + { + public RuntimeNote(PlayableNote source, double[] holdTickTimes) + { + Source = source; + HoldTickTimes = holdTickTimes ?? Array.Empty(); + } + + public PlayableNote Source { get; } + public RuntimeNoteState State { get; set; } + public JudgementGrade FinalGrade { get; set; } + public int? AccuracyValue { get; set; } + public bool IsFinished { get; set; } + public int AwardedHoldTickCount { get; set; } + public double[] HoldTickTimes { get; } + public int TotalHoldTickCount => HoldTickTimes.Length; + public bool HoldTicksFinalized { get; set; } + } + } +} diff --git a/Assets/ProjectQuint/Runtime/Core/Judgement/RhythmJudgementEngine.cs.meta b/Assets/ProjectQuint/Runtime/Core/Judgement/RhythmJudgementEngine.cs.meta new file mode 100644 index 0000000..aea883f --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Core/Judgement/RhythmJudgementEngine.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 97c17f28bde3e452bb4ad0bf51210d12 \ No newline at end of file diff --git a/Assets/ProjectQuint/Runtime/Core/Progression.meta b/Assets/ProjectQuint/Runtime/Core/Progression.meta new file mode 100644 index 0000000..c44875a --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Core/Progression.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: dc48a4e4f54c34f28870b1b3532ca1f0 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/ProjectQuint/Runtime/Core/Progression/ProfileCollection.cs b/Assets/ProjectQuint/Runtime/Core/Progression/ProfileCollection.cs new file mode 100644 index 0000000..2600d86 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Core/Progression/ProfileCollection.cs @@ -0,0 +1,198 @@ +using System; +using System.Collections.Generic; + +namespace ProjectQuint.Core.Progression +{ + public readonly struct CollectionGroupSummary + { + public CollectionGroupSummary( + string id, + string displayName, + int played, + int normalClear, + int hardClear, + int failed, + int fullCombo, + int allPure) + { + Id = id ?? string.Empty; + DisplayName = string.IsNullOrWhiteSpace(displayName) ? "UNKNOWN" : displayName; + Played = Math.Max(0, played); + NormalClear = Math.Max(0, normalClear); + HardClear = Math.Max(0, hardClear); + Failed = Math.Max(0, failed); + FullCombo = Math.Max(0, fullCombo); + AllPure = Math.Max(0, allPure); + } + + public string Id { get; } + public string DisplayName { get; } + public int Played { get; } + public int NormalClear { get; } + public int HardClear { get; } + public int Failed { get; } + public int FullCombo { get; } + public int AllPure { get; } + public int Cleared => NormalClear + HardClear; + public double ClearRate => Played <= 0 ? 0.0 : Cleared * 100.0 / Played; + } + + public sealed class ProfileCollectionSnapshot + { + public static readonly ProfileCollectionSnapshot Empty = new ProfileCollectionSnapshot( + default, + Array.Empty(), + Array.Empty(), + Array.Empty()); + + public ProfileCollectionSnapshot( + CollectionGroupSummary total, + IReadOnlyList packs, + IReadOnlyList keyModes, + IReadOnlyList difficulties) + { + Total = total; + Packs = packs ?? Array.Empty(); + KeyModes = keyModes ?? Array.Empty(); + Difficulties = difficulties ?? Array.Empty(); + } + + public CollectionGroupSummary Total { get; } + public IReadOnlyList Packs { get; } + public IReadOnlyList KeyModes { get; } + public IReadOnlyList Difficulties { get; } + } + + public readonly struct CourseCollectionEntry + { + public CourseCollectionEntry( + string courseId, + string providerDisplayName, + string courseName, + bool cleared, + int playCount, + int clearCount, + int bestStageReached, + double bestAccuracyPercent, + int bestTrophyRank, + string bestTrophyName) + { + CourseId = courseId ?? string.Empty; + ProviderDisplayName = providerDisplayName ?? string.Empty; + CourseName = string.IsNullOrWhiteSpace(courseName) ? "UNKNOWN COURSE" : courseName; + Cleared = cleared; + PlayCount = Math.Max(0, playCount); + ClearCount = Math.Max(0, clearCount); + BestStageReached = Math.Max(0, bestStageReached); + BestAccuracyPercent = Math.Max(0.0, Math.Min(100.0, bestAccuracyPercent)); + BestTrophyRank = Math.Max(0, bestTrophyRank); + BestTrophyName = bestTrophyName ?? string.Empty; + } + + public string CourseId { get; } + public string ProviderDisplayName { get; } + public string CourseName { get; } + public bool Cleared { get; } + public int PlayCount { get; } + public int ClearCount { get; } + public int BestStageReached { get; } + public double BestAccuracyPercent { get; } + public int BestTrophyRank { get; } + public string BestTrophyName { get; } + } + + public readonly struct CourseCertificationBadge + { + public CourseCertificationBadge(CourseCollectionEntry course) + { + CourseId = course.CourseId; + ProviderDisplayName = course.ProviderDisplayName; + CourseName = course.CourseName; + BestAccuracyPercent = course.BestAccuracyPercent; + TrophyRank = course.BestTrophyRank; + TrophyName = course.BestTrophyName; + } + + public string CourseId { get; } + public string ProviderDisplayName { get; } + public string CourseName { get; } + public double BestAccuracyPercent { get; } + public int TrophyRank { get; } + public string TrophyName { get; } + public string CertificationLabel => string.IsNullOrWhiteSpace(ProviderDisplayName) + ? CourseName + : $"{ProviderDisplayName} // {CourseName}"; + } + + public sealed class ProfileCourseSnapshot + { + public static readonly ProfileCourseSnapshot Empty = new ProfileCourseSnapshot( + 0, + 0, + 0, + Array.Empty()); + + public ProfileCourseSnapshot( + int recordedCourses, + int clearedCourses, + int totalPlayCount, + IReadOnlyList entries, + string featuredBadgeCourseId = null) + { + RecordedCourses = Math.Max(0, recordedCourses); + ClearedCourses = Math.Max(0, clearedCourses); + TotalPlayCount = Math.Max(0, totalPlayCount); + Entries = entries ?? Array.Empty(); + CertificationBadges = BuildCertificationBadges(Entries); + FeaturedBadgeCourseId = ResolveFeaturedBadgeCourseId( + CertificationBadges, + featuredBadgeCourseId); + } + + public int RecordedCourses { get; } + public int ClearedCourses { get; } + public int TotalPlayCount { get; } + public IReadOnlyList Entries { get; } + public IReadOnlyList CertificationBadges { get; } + public string FeaturedBadgeCourseId { get; } + public int BadgeCount => CertificationBadges.Count; + public bool HasFeaturedBadge => BadgeCount > 0; + public CourseCertificationBadge FeaturedBadge + { + get + { + foreach (var badge in CertificationBadges) + { + if (string.Equals(badge.CourseId, FeaturedBadgeCourseId, StringComparison.OrdinalIgnoreCase)) + return badge; + } + return default; + } + } + + private static IReadOnlyList BuildCertificationBadges( + IReadOnlyList entries) + { + var badges = new List(); + foreach (var entry in entries ?? Array.Empty()) + { + if (entry.Cleared) + badges.Add(new CourseCertificationBadge(entry)); + } + return badges; + } + + private static string ResolveFeaturedBadgeCourseId( + IReadOnlyList badges, + string requestedCourseId) + { + var requested = (requestedCourseId ?? string.Empty).Trim(); + foreach (var badge in badges ?? Array.Empty()) + { + if (string.Equals(badge.CourseId, requested, StringComparison.OrdinalIgnoreCase)) + return badge.CourseId; + } + return badges != null && badges.Count > 0 ? badges[0].CourseId : string.Empty; + } + } +} diff --git a/Assets/ProjectQuint/Runtime/Core/Progression/ProfileCollection.cs.meta b/Assets/ProjectQuint/Runtime/Core/Progression/ProfileCollection.cs.meta new file mode 100644 index 0000000..649bb9d --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Core/Progression/ProfileCollection.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: abc0e008a277e4267aad53209773e6bb \ No newline at end of file diff --git a/Assets/ProjectQuint/Runtime/Core/Progression/ProfileProgression.cs b/Assets/ProjectQuint/Runtime/Core/Progression/ProfileProgression.cs new file mode 100644 index 0000000..8491b20 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Core/Progression/ProfileProgression.cs @@ -0,0 +1,390 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using ProjectQuint.Core.Charts; + +namespace ProjectQuint.Core.Progression +{ + public readonly struct RatingContribution + { + public RatingContribution(string chartId, double playRating) + { + ChartId = chartId ?? string.Empty; + PlayRating = Math.Max(0.0, playRating); + } + + public string ChartId { get; } + public double PlayRating { get; } + } + + public readonly struct RatingChartEntry + { + public RatingChartEntry( + string chartId, + string title, + string artist, + string difficultyLabel, + double accuracyPercent, + bool hardClear, + double playRating) + { + ChartId = chartId ?? string.Empty; + Title = string.IsNullOrWhiteSpace(title) ? "UNKNOWN CHART" : title; + Artist = artist ?? string.Empty; + DifficultyLabel = difficultyLabel ?? string.Empty; + AccuracyPercent = Math.Max(0.0, Math.Min(100.0, accuracyPercent)); + HardClear = hardClear; + PlayRating = Math.Max(0.0, playRating); + } + + public string ChartId { get; } + public string Title { get; } + public string Artist { get; } + public string DifficultyLabel { get; } + public double AccuracyPercent { get; } + public bool HardClear { get; } + public double PlayRating { get; } + } + + public readonly struct RatingSummary + { + public RatingSummary( + string providerId, + string providerDisplayName, + int keyCount, + bool hasScratch, + double value, + int filledSlots, + IReadOnlyList bestPlays = null, + int activityPlayCount = 0) + { + ProviderId = (providerId ?? string.Empty).Trim(); + ProviderDisplayName = string.IsNullOrWhiteSpace(providerDisplayName) + ? ProviderId + : providerDisplayName.Trim(); + KeyCount = Math.Max(0, keyCount); + HasScratch = hasScratch; + Value = Math.Max(0.0, value); + FilledSlots = Math.Max(0, Math.Min(RatingRules.BestCount, filledSlots)); + BestPlays = bestPlays ?? Array.Empty(); + ActivityPlayCount = Math.Max(0, activityPlayCount); + } + + public string ProviderId { get; } + public string ProviderDisplayName { get; } + public int KeyCount { get; } + public bool HasScratch { get; } + public double Value { get; } + public int FilledSlots { get; } + public IReadOnlyList BestPlays { get; } + public int ActivityPlayCount { get; } + public bool HasContext => + !string.IsNullOrWhiteSpace(ProviderId) && + KeyCount >= 4 && KeyCount <= 7; + + public string ContextLabel => !HasContext + ? "RATING" + : $"{ProviderDisplayName.ToUpperInvariant()} {RatingRules.KeyModeLabel(KeyCount, HasScratch)} RATING"; + } + + public readonly struct RatingUpdate + { + public RatingUpdate( + bool eligible, + double playRating, + RatingSummary previous, + RatingSummary current) + { + Eligible = eligible; + PlayRating = Math.Max(0.0, playRating); + Previous = previous; + Current = current; + } + + public bool Eligible { get; } + public double PlayRating { get; } + public RatingSummary Previous { get; } + public RatingSummary Current { get; } + public double Delta => Current.Value - Previous.Value; + } + + public readonly struct ProfileProgressSnapshot + { + public ProfileProgressSnapshot( + string displayName, + long totalXp, + int validPlayCount, + double activePlaySeconds, + string lastRatingProviderId, + string lastRatingProviderDisplayName, + int lastRatingKeyCount, + bool lastRatingHasScratch) + { + DisplayName = string.IsNullOrWhiteSpace(displayName) ? "PLAYER" : displayName; + TotalXp = Math.Max(0, totalXp); + Level = ProfileLevelRules.LevelForTotalXp(TotalXp); + LevelStartXp = ProfileLevelRules.TotalXpForLevel(Level); + NextLevelXp = ProfileLevelRules.TotalXpForLevel(Level + 1); + ValidPlayCount = Math.Max(0, validPlayCount); + ActivePlaySeconds = Math.Max(0.0, activePlaySeconds); + LastRatingProviderId = (lastRatingProviderId ?? string.Empty).Trim(); + LastRatingProviderDisplayName = string.IsNullOrWhiteSpace(lastRatingProviderDisplayName) + ? LastRatingProviderId + : lastRatingProviderDisplayName.Trim(); + LastRatingKeyCount = Math.Max(0, lastRatingKeyCount); + LastRatingHasScratch = lastRatingHasScratch; + } + + public string DisplayName { get; } + public long TotalXp { get; } + public int Level { get; } + public long LevelStartXp { get; } + public long NextLevelXp { get; } + public int ValidPlayCount { get; } + public double ActivePlaySeconds { get; } + public string LastRatingProviderId { get; } + public string LastRatingProviderDisplayName { get; } + public int LastRatingKeyCount { get; } + public bool LastRatingHasScratch { get; } + public long XpIntoLevel => TotalXp - LevelStartXp; + public long XpForLevel => Math.Max(1, NextLevelXp - LevelStartXp); + public double LevelProgress => Math.Max(0.0, Math.Min(1.0, XpIntoLevel / (double)XpForLevel)); + } + + public readonly struct ProfileProgressUpdate + { + public ProfileProgressUpdate( + bool applied, + int xpAwarded, + ProfileProgressSnapshot previous, + ProfileProgressSnapshot current, + bool firstPlay, + bool firstNormalClear, + bool firstHardClear, + bool firstFullCombo, + bool firstAllPure) + { + Applied = applied; + XpAwarded = Math.Max(0, xpAwarded); + Previous = previous; + Current = current; + FirstPlay = firstPlay; + FirstNormalClear = firstNormalClear; + FirstHardClear = firstHardClear; + FirstFullCombo = firstFullCombo; + FirstAllPure = firstAllPure; + } + + public bool Applied { get; } + public int XpAwarded { get; } + public ProfileProgressSnapshot Previous { get; } + public ProfileProgressSnapshot Current { get; } + public bool FirstPlay { get; } + public bool FirstNormalClear { get; } + public bool FirstHardClear { get; } + public bool FirstFullCombo { get; } + public bool FirstAllPure { get; } + public bool LeveledUp => Current.Level > Previous.Level; + } + + public static class RatingRules + { + public const int RulesVersion = 5; + public const int BestCount = 30; + public const double HardClearBonus = 0.50; + public const double MinimumChartConstant = 0.50; + + private static readonly double[] AccuracyAnchors = + { + 0.0, 80.0, 90.0, 95.0, 97.0, 98.0, 99.0, 99.5, 100.0, + }; + + private static readonly double[] FactorAnchors = + { + 0.0, 0.30, 0.60, 0.80, 0.88, 0.93, 0.97, 0.985, 1.0, + }; + + public static double AccuracyFactor(double accuracyPercent) + { + var accuracy = Math.Max(0.0, Math.Min(100.0, accuracyPercent)); + for (var index = 1; index < AccuracyAnchors.Length; index++) + { + if (accuracy > AccuracyAnchors[index]) + continue; + + var start = AccuracyAnchors[index - 1]; + var end = AccuracyAnchors[index]; + var progress = end <= start ? 0.0 : (accuracy - start) / (end - start); + return FactorAnchors[index - 1] + + (FactorAnchors[index] - FactorAnchors[index - 1]) * progress; + } + return 1.0; + } + + public static double CalculatePlayRating( + double chartConstant, + double accuracyPercent, + bool hardClear) + { + if (chartConstant <= 0.0) + return 0.0; + return chartConstant * AccuracyFactor(accuracyPercent) + + (hardClear ? HardClearBonus : 0.0); + } + + public static RatingSummary CalculateBest30( + string providerId, + string providerDisplayName, + int keyCount, + bool hasScratch, + IEnumerable contributions) + { + var bestByChart = new Dictionary(StringComparer.Ordinal); + foreach (var item in contributions ?? Array.Empty()) + { + if (string.IsNullOrWhiteSpace(item.ChartId) || item.PlayRating <= 0.0) + continue; + if (!bestByChart.TryGetValue(item.ChartId, out var current) || item.PlayRating > current) + bestByChart[item.ChartId] = item.PlayRating; + } + + var best = bestByChart.Values + .OrderByDescending(value => value) + .Take(BestCount) + .ToArray(); + return new RatingSummary( + providerId, + providerDisplayName, + keyCount, + hasScratch, + best.Sum() / BestCount, + best.Length); + } + + public static string KeyModeLabel(int keyCount, bool hasScratch) => + ChartKeyMode.FormatLabel(keyCount, hasScratch); + + public static bool TryResolveChartConstant( + string difficultyValue, + string difficultyLabel, + out double chartConstant) + { + if (!string.IsNullOrWhiteSpace(difficultyValue)) + return TryParseChartConstant(difficultyValue, out chartConstant); + return TryParseChartConstant(difficultyLabel, out chartConstant); + } + + public static bool TryParseChartConstant(string difficultyText, out double chartConstant) + { + chartConstant = 0.0; + var label = (difficultyText ?? string.Empty).Trim(); + if (label.Length == 0) + return false; + + var upper = label.ToUpperInvariant(); + if (upper.Contains("GIMMICK") || upper.Contains("SPECIAL") || + upper.Contains("DUMMY") || upper.Contains("OLD")) + return false; + + var numericEnd = label.Length; + var plus = false; + if (numericEnd > 0 && label[numericEnd - 1] == '+') + { + plus = true; + numericEnd--; + while (numericEnd > 0 && char.IsWhiteSpace(label[numericEnd - 1])) + numericEnd--; + } + + var start = numericEnd; + var dotSeen = false; + var digitSeen = false; + while (start > 0) + { + var character = label[start - 1]; + if (char.IsDigit(character)) + { + digitSeen = true; + start--; + continue; + } + if (character == '.' && !dotSeen) + { + dotSeen = true; + start--; + continue; + } + break; + } + if (!digitSeen) + return false; + if (start > 0 && (label[start - 1] == '-' || label[start - 1] == '.')) + return false; + + if (!double.TryParse( + label.Substring(start, numericEnd - start), + NumberStyles.AllowDecimalPoint, + CultureInfo.InvariantCulture, + out chartConstant) || + chartConstant < 0.0 || + double.IsNaN(chartConstant) || + double.IsInfinity(chartConstant)) + { + chartConstant = 0.0; + return false; + } + + // Some established tables start at level 0 (for example Satellite + // sl0). It is a real lowest tier, not a missing difficulty value. + // A positive half-step keeps it below level 1 while allowing it to + // participate in the same rating pipeline. + if (chartConstant <= 0.0) + chartConstant = MinimumChartConstant; + + if (plus && + Math.Abs(chartConstant - Math.Round(chartConstant)) < 0.000001) + chartConstant += 0.5; + return true; + } + } + + public static class ProfileLevelRules + { + public const int XpRulesVersion = 1; + + public static long TotalXpForLevel(int level) + { + var normalized = Math.Max(1, level) - 1L; + return 20L * normalized * normalized + 50L * normalized; + } + + public static int NextLevelXp(int level) => checked(40 * Math.Max(1, level) + 30); + + public static int LevelForTotalXp(long totalXp) + { + var xp = Math.Max(0L, totalXp); + var low = 1; + var high = 2; + while (TotalXpForLevel(high) <= xp && high < 1_000_000) + high *= 2; + while (low + 1 < high) + { + var middle = low + (high - low) / 2; + if (TotalXpForLevel(middle) <= xp) + low = middle; + else + high = middle; + } + return low; + } + + public static int CalculateBaseXp(double activePlaySeconds, int judgedUnits) + { + var value = 20 + + (int)Math.Floor(Math.Max(0.0, activePlaySeconds) / 10.0) * 4 + + Math.Max(0, judgedUnits) / 100 * 2; + return Math.Max(20, Math.Min(100, value)); + } + } +} diff --git a/Assets/ProjectQuint/Runtime/Core/Progression/ProfileProgression.cs.meta b/Assets/ProjectQuint/Runtime/Core/Progression/ProfileProgression.cs.meta new file mode 100644 index 0000000..285907c --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Core/Progression/ProfileProgression.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 459ea391724314c1397e5b3531e642d3 \ No newline at end of file diff --git a/Assets/ProjectQuint/Runtime/Core/ProjectQuint.Core.asmdef b/Assets/ProjectQuint/Runtime/Core/ProjectQuint.Core.asmdef new file mode 100644 index 0000000..f5c1673 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Core/ProjectQuint.Core.asmdef @@ -0,0 +1,14 @@ +{ + "name": "ProjectQuint.Core", + "rootNamespace": "ProjectQuint.Core", + "references": [], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": true +} diff --git a/Assets/ProjectQuint/Runtime/Core/ProjectQuint.Core.asmdef.meta b/Assets/ProjectQuint/Runtime/Core/ProjectQuint.Core.asmdef.meta new file mode 100644 index 0000000..b373a48 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Core/ProjectQuint.Core.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 3b5f07eda49ea4e18b3acb164cc392c1 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/ProjectQuint/Runtime/Core/Release.meta b/Assets/ProjectQuint/Runtime/Core/Release.meta new file mode 100644 index 0000000..06b7913 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Core/Release.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 823010ad9f0f4b81b0b9bbee2ff34369 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/ProjectQuint/Runtime/Core/Release/ProjectQuintReleaseVersion.cs b/Assets/ProjectQuint/Runtime/Core/Release/ProjectQuintReleaseVersion.cs new file mode 100644 index 0000000..e9359e5 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Core/Release/ProjectQuintReleaseVersion.cs @@ -0,0 +1,29 @@ +using System.Globalization; + +namespace ProjectQuint.Core.Release +{ + /// + /// Public product version metadata shared by the runtime, release tooling, and update contract. + /// Persistent-data schema versions remain independent from this product version. + /// + public static class ProjectQuintReleaseVersion + { + public const string ProductVersion = "0.1.0"; + public const string ReleaseChannel = "alpha"; + public const int ChannelRevision = 4; + public const int BuildNumber = 4; + public const string SparkleFeedUrl = + "https://projectquint.duckdns.org/updates/appcast.xml"; + public const string SparklePublicEdKey = + "w94qM58k8MAhIxYYQiR1ivyR5CyMW1tlRV68lQoLehA="; + + public static string DisplayVersion => + $"{ProductVersion}-{ReleaseChannel}.{ChannelRevision.ToString(CultureInfo.InvariantCulture)}"; + + public static string BuildNumberText => + BuildNumber.ToString(CultureInfo.InvariantCulture); + + public static string DisplayVersionWithBuild => + $"{DisplayVersion} (build {BuildNumberText})"; + } +} diff --git a/Assets/ProjectQuint/Runtime/Core/Release/ProjectQuintReleaseVersion.cs.meta b/Assets/ProjectQuint/Runtime/Core/Release/ProjectQuintReleaseVersion.cs.meta new file mode 100644 index 0000000..5e2b8a0 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Core/Release/ProjectQuintReleaseVersion.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e73b647666ee4452a7f5d0ed2735461d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/ProjectQuint/Runtime/Core/Timing.meta b/Assets/ProjectQuint/Runtime/Core/Timing.meta new file mode 100644 index 0000000..99412dc --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Core/Timing.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 3efd064767d294d688e7a4fafe935965 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/ProjectQuint/Runtime/Core/Timing/AudioSyncCalibrationEstimator.cs b/Assets/ProjectQuint/Runtime/Core/Timing/AudioSyncCalibrationEstimator.cs new file mode 100644 index 0000000..e8c2bee --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Core/Timing/AudioSyncCalibrationEstimator.cs @@ -0,0 +1,137 @@ +using System; +using System.Collections.Generic; + +namespace ProjectQuint.Core.Timing +{ + public enum AudioSyncStability + { + Insufficient, + Good, + Fair, + Unstable, + OutOfRange, + } + + public readonly struct AudioSyncCalibrationResult + { + public AudioSyncCalibrationResult( + int recommendedLatencyMilliseconds, + double medianMilliseconds, + double medianAbsoluteDeviationMilliseconds, + int acceptedSampleCount, + int rejectedSampleCount, + AudioSyncStability stability) + { + RecommendedLatencyMilliseconds = recommendedLatencyMilliseconds; + MedianMilliseconds = medianMilliseconds; + MedianAbsoluteDeviationMilliseconds = medianAbsoluteDeviationMilliseconds; + AcceptedSampleCount = acceptedSampleCount; + RejectedSampleCount = rejectedSampleCount; + Stability = stability; + } + + public int RecommendedLatencyMilliseconds { get; } + public double MedianMilliseconds { get; } + public double MedianAbsoluteDeviationMilliseconds { get; } + public int AcceptedSampleCount { get; } + public int RejectedSampleCount { get; } + public AudioSyncStability Stability { get; } + public bool CanApply => AcceptedSampleCount >= AudioSyncCalibrationEstimator.MinimumAcceptedSamples && + Stability != AudioSyncStability.Unstable && + Stability != AudioSyncStability.OutOfRange; + } + + /// + /// Robustly estimates audible output latency from tap-minus-scheduled-click samples. + /// The estimator is deterministic and deliberately independent from Unity and FMOD. + /// + public static class AudioSyncCalibrationEstimator + { + public const int PracticeClickCount = 4; + public const int MeasurementClickCount = 24; + public const int MinimumAcceptedSamples = 18; + public const int MaximumLatencyMilliseconds = 500; + private const double MinimumOutlierThresholdMilliseconds = 30.0; + + public static AudioSyncCalibrationResult Estimate(IReadOnlyList samplesMilliseconds) + { + if (samplesMilliseconds == null) + throw new ArgumentNullException(nameof(samplesMilliseconds)); + if (samplesMilliseconds.Count == 0) + return new AudioSyncCalibrationResult( + 0, 0, 0, 0, 0, AudioSyncStability.Insufficient); + + var sorted = CopyFinite(samplesMilliseconds); + if (sorted.Count == 0) + return new AudioSyncCalibrationResult( + 0, 0, 0, 0, samplesMilliseconds.Count, AudioSyncStability.Insufficient); + + sorted.Sort(); + var initialMedian = Median(sorted); + var deviations = new List(sorted.Count); + for (var index = 0; index < sorted.Count; index++) + deviations.Add(Math.Abs(sorted[index] - initialMedian)); + deviations.Sort(); + var initialMad = Median(deviations); + var threshold = Math.Max(MinimumOutlierThresholdMilliseconds, initialMad * 3.0); + var accepted = new List(sorted.Count); + for (var index = 0; index < sorted.Count; index++) + { + if (Math.Abs(sorted[index] - initialMedian) <= threshold) + accepted.Add(sorted[index]); + } + + if (accepted.Count == 0) + return new AudioSyncCalibrationResult( + 0, initialMedian, initialMad, 0, samplesMilliseconds.Count, + AudioSyncStability.Insufficient); + + accepted.Sort(); + var median = Median(accepted); + deviations.Clear(); + for (var index = 0; index < accepted.Count; index++) + deviations.Add(Math.Abs(accepted[index] - median)); + deviations.Sort(); + var mad = Median(deviations); + var stability = median > MaximumLatencyMilliseconds + ? AudioSyncStability.OutOfRange + : accepted.Count < MinimumAcceptedSamples + ? AudioSyncStability.Insufficient + : mad <= 12.0 + ? AudioSyncStability.Good + : mad <= 22.0 + ? AudioSyncStability.Fair + : AudioSyncStability.Unstable; + var recommendation = (int)Math.Round( + Math.Max(0.0, Math.Min(MaximumLatencyMilliseconds, median)), + MidpointRounding.AwayFromZero); + return new AudioSyncCalibrationResult( + recommendation, + median, + mad, + accepted.Count, + samplesMilliseconds.Count - accepted.Count, + stability); + } + + private static List CopyFinite(IReadOnlyList values) + { + var result = new List(values.Count); + for (var index = 0; index < values.Count; index++) + { + var value = values[index]; + if (!double.IsNaN(value) && !double.IsInfinity(value)) + result.Add(value); + } + return result; + } + + private static double Median(IReadOnlyList sorted) + { + var middle = sorted.Count / 2; + return sorted.Count % 2 == 0 + ? (sorted[middle - 1] + sorted[middle]) * 0.5 + : sorted[middle]; + } + } +} diff --git a/Assets/ProjectQuint/Runtime/Core/Timing/AudioSyncCalibrationEstimator.cs.meta b/Assets/ProjectQuint/Runtime/Core/Timing/AudioSyncCalibrationEstimator.cs.meta new file mode 100644 index 0000000..ebf5c1d --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Core/Timing/AudioSyncCalibrationEstimator.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f9ad2638954b4c7682a159ec2ad15d3d diff --git a/Assets/ProjectQuint/Runtime/Core/Timing/AudioSyncTapMatcher.cs b/Assets/ProjectQuint/Runtime/Core/Timing/AudioSyncTapMatcher.cs new file mode 100644 index 0000000..eef6726 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Core/Timing/AudioSyncTapMatcher.cs @@ -0,0 +1,43 @@ +using System; +using System.Collections.Generic; + +namespace ProjectQuint.Core.Timing +{ + /// + /// Matches a tap only against the next expected click. This deliberately avoids + /// nearest-neighbour matching, which can fold a large output latency onto the + /// following metronome click and report a false value near zero. + /// + public static class AudioSyncTapMatcher + { + public const double MaximumOffsetSeconds = 0.650; + + public static bool TryMatchExpected( + IReadOnlyList clickTimes, + int expectedClickIndex, + double tapTime, + out double offsetSeconds) + { + if (clickTimes == null) + throw new ArgumentNullException(nameof(clickTimes)); + offsetSeconds = 0.0; + if (expectedClickIndex < 0 || expectedClickIndex >= clickTimes.Count) + return false; + + offsetSeconds = tapTime - clickTimes[expectedClickIndex]; + return offsetSeconds >= 0.0 && offsetSeconds <= MaximumOffsetSeconds; + } + + public static bool HasExpired( + IReadOnlyList clickTimes, + int expectedClickIndex, + double currentTime) + { + if (clickTimes == null) + throw new ArgumentNullException(nameof(clickTimes)); + return expectedClickIndex >= 0 && + expectedClickIndex < clickTimes.Count && + currentTime > clickTimes[expectedClickIndex] + MaximumOffsetSeconds; + } + } +} diff --git a/Assets/ProjectQuint/Runtime/Core/Timing/AudioSyncTapMatcher.cs.meta b/Assets/ProjectQuint/Runtime/Core/Timing/AudioSyncTapMatcher.cs.meta new file mode 100644 index 0000000..8bbf493 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Core/Timing/AudioSyncTapMatcher.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 37c9a6416f6248c39db4c32d614aa5d3 diff --git a/Assets/ProjectQuint/Runtime/Core/Timing/MusicalTimingMaps.cs b/Assets/ProjectQuint/Runtime/Core/Timing/MusicalTimingMaps.cs new file mode 100644 index 0000000..b403f61 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Core/Timing/MusicalTimingMaps.cs @@ -0,0 +1,321 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace ProjectQuint.Core.Timing +{ + /// + /// A deterministic musical-time anchor. Time is the instant at which Beat is + /// judged, before an optional STOP at that same beat. Bpm is the tempo used + /// after that STOP and until the next node. + /// + public readonly struct TempoMapNode + { + public TempoMapNode(double beat, double time, double bpm, double stopDuration = 0) + { + if (double.IsNaN(beat) || double.IsInfinity(beat)) + throw new ArgumentOutOfRangeException(nameof(beat)); + if (double.IsNaN(time) || double.IsInfinity(time)) + throw new ArgumentOutOfRangeException(nameof(time)); + if (!(bpm > 0) || double.IsNaN(bpm) || double.IsInfinity(bpm)) + throw new ArgumentOutOfRangeException(nameof(bpm)); + if (stopDuration < 0 || double.IsNaN(stopDuration) || double.IsInfinity(stopDuration)) + throw new ArgumentOutOfRangeException(nameof(stopDuration)); + + Beat = beat; + Time = time; + Bpm = bpm; + StopDuration = stopDuration; + } + + public double Beat { get; } + public double Time { get; } + public double Bpm { get; } + public double StopDuration { get; } + public double StopEndTime => Time + StopDuration; + } + + /// + /// Converts quarter-note beat positions and DSP song seconds in both directions. + /// BeatToTime returns the pre-STOP judgement instant for an exact STOP position; + /// TimeToBeat holds that beat for the entire STOP interval. + /// + public sealed class TempoMap + { + private const double Epsilon = 0.000000001; + private readonly TempoMapNode[] _nodes; + + public TempoMap(IEnumerable nodes) + { + _nodes = (nodes ?? throw new ArgumentNullException(nameof(nodes))).ToArray(); + if (_nodes.Length == 0) + throw new ArgumentException("A tempo map requires at least one node.", nameof(nodes)); + + for (var index = 1; index < _nodes.Length; index++) + { + if (_nodes[index].Beat <= _nodes[index - 1].Beat + Epsilon) + throw new ArgumentException("Tempo-map beats must be strictly increasing.", nameof(nodes)); + if (_nodes[index].Time + Epsilon < _nodes[index - 1].StopEndTime) + throw new ArgumentException("Tempo-map times must not overlap the previous STOP.", nameof(nodes)); + } + } + + public IReadOnlyList Nodes => _nodes; + public double InitialBpm => _nodes[0].Bpm; + + public static TempoMap Constant(double bpm) => + new TempoMap(new[] { new TempoMapNode(0, 0, bpm) }); + + public double BeatToTime(double beat) + { + var index = FindNodeAtOrBeforeBeat(beat); + var node = _nodes[index]; + if (Math.Abs(beat - node.Beat) <= Epsilon) + return node.Time; + if (beat < node.Beat) + return node.Time + (beat - node.Beat) * 60.0 / node.Bpm; + return node.StopEndTime + (beat - node.Beat) * 60.0 / node.Bpm; + } + + public double TimeToBeat(double time) + { + var index = FindNodeAtOrBeforeTime(time); + var node = _nodes[index]; + if (time < node.Time - Epsilon) + return node.Beat + (time - node.Time) * node.Bpm / 60.0; + if (time <= node.StopEndTime + Epsilon && time >= node.Time - Epsilon) + return node.Beat; + return node.Beat + (time - node.StopEndTime) * node.Bpm / 60.0; + } + + public bool IsStopped(double time) + { + var node = _nodes[FindNodeAtOrBeforeTime(time)]; + return node.StopDuration > 0 && + time >= node.Time - Epsilon && + time < node.StopEndTime - Epsilon; + } + + private int FindNodeAtOrBeforeBeat(double beat) + { + if (beat < _nodes[0].Beat) + return 0; + + var low = 0; + var high = _nodes.Length - 1; + while (low <= high) + { + var middle = low + ((high - low) >> 1); + if (_nodes[middle].Beat <= beat + Epsilon) + low = middle + 1; + else + high = middle - 1; + } + return Math.Max(0, high); + } + + private int FindNodeAtOrBeforeTime(double time) + { + if (time < _nodes[0].Time) + return 0; + + var low = 0; + var high = _nodes.Length - 1; + while (low <= high) + { + var middle = low + ((high - low) >> 1); + if (_nodes[middle].Time <= time + Epsilon) + low = middle + 1; + else + high = middle - 1; + } + return Math.Max(0, high); + } + } + + public readonly struct ScrollMapNode + { + public ScrollMapNode(double beat, double multiplier) + { + if (beat < 0 || double.IsNaN(beat) || double.IsInfinity(beat)) + throw new ArgumentOutOfRangeException(nameof(beat)); + if (double.IsNaN(multiplier) || double.IsInfinity(multiplier)) + throw new ArgumentOutOfRangeException(nameof(multiplier)); + + Beat = beat; + Multiplier = multiplier; + } + + public double Beat { get; } + public double Multiplier { get; } + } + + /// + /// Visual scroll is deliberately separate from judgement time. Its coordinate + /// integrates the active BMS #SCROLL multiplier over musical beat, so BPM and + /// STOP still come from TempoMap while scroll gimmicks never alter judgement. + /// + public sealed class ScrollMap + { + private const double Epsilon = 0.000000001; + private readonly ScrollMapNode[] _nodes; + private readonly double[] _positions; + + public ScrollMap(TempoMap tempoMap) + : this(tempoMap, Array.Empty()) + { + } + + public ScrollMap(TempoMap tempoMap, IEnumerable nodes) + { + TempoMap = tempoMap ?? throw new ArgumentNullException(nameof(tempoMap)); + + var ordered = (nodes ?? throw new ArgumentNullException(nameof(nodes))) + .OrderBy(node => node.Beat) + .ToArray(); + var normalized = new List(ordered.Length + 1); + if (ordered.Length == 0 || ordered[0].Beat > Epsilon) + normalized.Add(new ScrollMapNode(0, 1)); + + foreach (var node in ordered) + { + if (normalized.Count > 0 && + Math.Abs(normalized[normalized.Count - 1].Beat - node.Beat) <= Epsilon) + normalized[normalized.Count - 1] = node; + else + normalized.Add(node); + } + + if (normalized.Count == 0) + normalized.Add(new ScrollMapNode(0, 1)); + + _nodes = normalized.ToArray(); + _positions = new double[_nodes.Length]; + for (var index = 1; index < _nodes.Length; index++) + { + _positions[index] = _positions[index - 1] + + (_nodes[index].Beat - _nodes[index - 1].Beat) * + _nodes[index - 1].Multiplier; + } + } + + public TempoMap TempoMap { get; } + public IReadOnlyList Nodes => _nodes; + public bool HasVariableScroll => + _nodes.Count(node => Math.Abs(node.Multiplier - 1.0) > Epsilon) > 0; + public bool HasReverseScroll => _nodes.Any(node => node.Multiplier < -Epsilon); + + public double PositionAtTime(double songTime) => PositionAtBeat(TempoMap.TimeToBeat(songTime)); + + public double PositionAtBeat(double beat) + { + // BMS scroll objects cannot precede measure zero. Keep the countdown + // preroll at the normal 1x rate even if a chart changes scroll at beat 0. + if (beat < 0) + return beat; + + var index = FindNodeAtOrBeforeBeat(beat); + return _positions[index] + (beat - _nodes[index].Beat) * _nodes[index].Multiplier; + } + + public double TimeAtPosition(double position) + { + if (position < 0) + return TempoMap.BeatToTime(position); + + if (TryGetTimeAtForwardDistance(TempoMap.BeatToTime(0), position, out var time)) + return time; + return double.PositiveInfinity; + } + + /// + /// Finds the first future judgement time whose visual position is the requested + /// distance ahead. Zero and reverse segments are traversed instead of being + /// treated as elapsed song time, which keeps note-window culling correct. + /// + public bool TryGetTimeAtForwardDistance(double songTime, double distance, out double time) + { + if (distance < 0 || double.IsNaN(distance) || double.IsInfinity(distance)) + throw new ArgumentOutOfRangeException(nameof(distance)); + + var beat = TempoMap.TimeToBeat(songTime); + var position = PositionAtBeat(beat); + var target = position + distance; + if (distance <= Epsilon) + { + time = songTime; + return true; + } + + if (beat < 0) + { + if (CrossesForward(position, 0, target, 1, out var fraction)) + { + time = TempoMap.BeatToTime(beat + (0 - beat) * fraction); + return true; + } + beat = 0; + position = 0; + } + + var index = FindNodeAtOrBeforeBeat(beat); + while (index + 1 < _nodes.Length) + { + var nextBeat = _nodes[index + 1].Beat; + var nextPosition = position + (nextBeat - beat) * _nodes[index].Multiplier; + if (CrossesForward(position, nextPosition, target, _nodes[index].Multiplier, out var fraction)) + { + time = TempoMap.BeatToTime(beat + (nextBeat - beat) * fraction); + return true; + } + + beat = nextBeat; + position = nextPosition; + index++; + } + + var multiplier = _nodes[index].Multiplier; + if (multiplier > Epsilon && target >= position - Epsilon) + { + var targetBeat = beat + Math.Max(0, target - position) / multiplier; + time = TempoMap.BeatToTime(targetBeat); + return true; + } + + time = double.PositiveInfinity; + return false; + } + + private static bool CrossesForward( + double start, + double end, + double target, + double multiplier, + out double fraction) + { + if (multiplier > Epsilon && target >= start - Epsilon && target <= end + Epsilon) + { + fraction = Math.Abs(end - start) <= Epsilon ? 0 : (target - start) / (end - start); + return true; + } + + fraction = 0; + return false; + } + + private int FindNodeAtOrBeforeBeat(double beat) + { + var low = 0; + var high = _nodes.Length - 1; + while (low <= high) + { + var middle = low + ((high - low) >> 1); + if (_nodes[middle].Beat <= beat + Epsilon) + low = middle + 1; + else + high = middle - 1; + } + return Math.Max(0, high); + } + } +} diff --git a/Assets/ProjectQuint/Runtime/Core/Timing/MusicalTimingMaps.cs.meta b/Assets/ProjectQuint/Runtime/Core/Timing/MusicalTimingMaps.cs.meta new file mode 100644 index 0000000..3b3fcc6 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Core/Timing/MusicalTimingMaps.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 287be2f5502b49849f5a30773b68e98a diff --git a/Assets/ProjectQuint/Runtime/Core/Timing/ResilientSongClock.cs b/Assets/ProjectQuint/Runtime/Core/Timing/ResilientSongClock.cs new file mode 100644 index 0000000..68dfd8d --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Core/Timing/ResilientSongClock.cs @@ -0,0 +1,95 @@ +using System; + +namespace ProjectQuint.Core.Timing +{ + public sealed class ResilientSongClock + { + public const double DspStallThresholdSeconds = 0.250; + private const double DspAdvanceEpsilonSeconds = 0.000001; + private const double RecoveryToleranceSeconds = 0.005; + + private bool _initialized; + private double _currentSongTime; + private double _lastDspTime; + private double _lastDspSongTime; + private double _lastDspAdvanceRealtime; + private double _lastSampleRealtime; + + public bool IsFallbackActive { get; private set; } + public double CurrentSongTime => _currentSongTime; + + public void Reset(double currentDspTime, double startDspTime, double currentRealtime) + { + _initialized = true; + _currentSongTime = currentDspTime - startDspTime; + _lastDspTime = currentDspTime; + _lastDspSongTime = _currentSongTime; + _lastDspAdvanceRealtime = currentRealtime; + _lastSampleRealtime = currentRealtime; + IsFallbackActive = false; + } + + public void Reanchor( + double songTime, + double currentDspTime, + double currentRealtime) + { + _initialized = true; + _currentSongTime = songTime; + _lastDspTime = currentDspTime; + _lastDspSongTime = songTime; + _lastDspAdvanceRealtime = currentRealtime; + _lastSampleRealtime = currentRealtime; + IsFallbackActive = false; + } + + public double Sample(double currentDspTime, double startDspTime, double currentRealtime) + { + if (!_initialized) + Reset(currentDspTime, startDspTime, currentRealtime); + + var realtimeDelta = Math.Max(0, currentRealtime - _lastSampleRealtime); + _lastSampleRealtime = currentRealtime; + var dspSongTime = currentDspTime - startDspTime; + var dspAdvanced = currentDspTime > _lastDspTime + DspAdvanceEpsilonSeconds; + + if (IsFallbackActive) + { + _currentSongTime += realtimeDelta; + if (dspAdvanced && dspSongTime >= _currentSongTime - RecoveryToleranceSeconds) + { + _currentSongTime = Math.Max(_currentSongTime, dspSongTime); + IsFallbackActive = false; + } + } + else if (dspAdvanced) + { + _currentSongTime = Math.Max(_currentSongTime, dspSongTime); + } + else + { + var stalledFor = Math.Max(0, currentRealtime - _lastDspAdvanceRealtime); + if (stalledFor >= DspStallThresholdSeconds) + { + _currentSongTime = Math.Max(_currentSongTime, _lastDspSongTime + stalledFor); + IsFallbackActive = true; + } + } + + if (dspAdvanced) + { + _lastDspTime = currentDspTime; + _lastDspSongTime = dspSongTime; + _lastDspAdvanceRealtime = currentRealtime; + } + + return _currentSongTime; + } + + public static double ResolveInputEventSongTime( + double inputEventTime, + double currentInputTime, + double currentSongTime) => + currentSongTime + inputEventTime - currentInputTime; + } +} diff --git a/Assets/ProjectQuint/Runtime/Core/Timing/ResilientSongClock.cs.meta b/Assets/ProjectQuint/Runtime/Core/Timing/ResilientSongClock.cs.meta new file mode 100644 index 0000000..ca32264 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Core/Timing/ResilientSongClock.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: fd5a9a33c48fb42df9852b599f5a3eeb \ No newline at end of file diff --git a/Assets/ProjectQuint/Runtime/Unity.meta b/Assets/ProjectQuint/Runtime/Unity.meta new file mode 100644 index 0000000..f727b63 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: f6ab15a3928614b1dae5cecabd46a518 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/ProjectQuint/Runtime/Unity/AssemblyInfo.cs b/Assets/ProjectQuint/Runtime/Unity/AssemblyInfo.cs new file mode 100644 index 0000000..8fd40f2 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/AssemblyInfo.cs @@ -0,0 +1,9 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("ProjectQuint.Application")] +[assembly: InternalsVisibleTo("ProjectQuint.Bootstrap")] +[assembly: InternalsVisibleTo("ProjectQuint.Media.Audio")] +[assembly: InternalsVisibleTo("ProjectQuint.Media.Images")] +[assembly: InternalsVisibleTo("ProjectQuint.Media.Video")] +[assembly: InternalsVisibleTo("ProjectQuint.Presentation")] +[assembly: InternalsVisibleTo("ProjectQuint.Tests.EditMode")] diff --git a/Assets/ProjectQuint/Runtime/Unity/AssemblyInfo.cs.meta b/Assets/ProjectQuint/Runtime/Unity/AssemblyInfo.cs.meta new file mode 100644 index 0000000..fa5bfcd --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/AssemblyInfo.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 2af160e05b69349aeb7ac43baa29c62f \ No newline at end of file diff --git a/Assets/ProjectQuint/Runtime/Unity/Audio.meta b/Assets/ProjectQuint/Runtime/Unity/Audio.meta new file mode 100644 index 0000000..34a3d2f --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Audio.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: a8a18e0ff9fe24ad6a98a191cf6c35dd +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/ProjectQuint/Runtime/Unity/Audio/AssemblyInfo.cs b/Assets/ProjectQuint/Runtime/Unity/Audio/AssemblyInfo.cs new file mode 100644 index 0000000..6bfc724 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Audio/AssemblyInfo.cs @@ -0,0 +1,5 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("ProjectQuint.Application")] +[assembly: InternalsVisibleTo("ProjectQuint.Bootstrap")] +[assembly: InternalsVisibleTo("ProjectQuint.Tests.EditMode")] diff --git a/Assets/ProjectQuint/Runtime/Unity/Audio/AssemblyInfo.cs.meta b/Assets/ProjectQuint/Runtime/Unity/Audio/AssemblyInfo.cs.meta new file mode 100644 index 0000000..1f8ad2a --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Audio/AssemblyInfo.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 2fb26024c41234d0798636f56c78e9f8 \ No newline at end of file diff --git a/Assets/ProjectQuint/Runtime/Unity/Audio/AudioAssetStore.cs b/Assets/ProjectQuint/Runtime/Unity/Audio/AudioAssetStore.cs new file mode 100644 index 0000000..7a092d5 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Audio/AudioAssetStore.cs @@ -0,0 +1,738 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using ProjectQuint.Core.Charts; +using ProjectQuint.Library; +using UnityEngine; +using UnityEngine.Networking; + +namespace ProjectQuint.Audio +{ + internal enum AudioAssetStoreState + { + Empty, + Loading, + Ready, + Cancelled, + Failed, + Disposed, + } + + internal enum AudioResourceLoadStatus + { + Loaded, + SilentPlaceholder, + Missing, + DecodeFailed, + TooLarge, + Cancelled, + } + + internal readonly struct AudioResourceLoadResult + { + public AudioResourceLoadResult( + AudioResourceLoadStatus status, + AudioClip clip = null, + string detail = null) + { + Status = status; + Clip = clip; + Detail = detail ?? string.Empty; + } + + public AudioResourceLoadStatus Status { get; } + public AudioClip Clip { get; } + public string Detail { get; } + } + + /// + /// Owns the decoded Unity clips for one prepared song. File resolution, asynchronous + /// decoding and the per-song PCM budget live here so both playback modes share exactly + /// the same assets and failure state. + /// + internal sealed class AudioAssetStore : IDisposable + { + private static readonly string[] AudioExtensionFallbacks = { ".wav", ".ogg", ".mp3" }; + internal const long DefaultDecodedPcmBudgetBytes = 1536L * 1024L * 1024L; + + private readonly Dictionary _clips = + new Dictionary(StringComparer.Ordinal); + private readonly long _decodedPcmBudgetBytes; + + public AudioAssetStore(long decodedPcmBudgetBytes = DefaultDecodedPcmBudgetBytes) + { + if (decodedPcmBudgetBytes <= 0) + throw new ArgumentOutOfRangeException(nameof(decodedPcmBudgetBytes)); + _decodedPcmBudgetBytes = decodedPcmBudgetBytes; + } + + public AudioAssetStoreState State { get; private set; } = AudioAssetStoreState.Empty; + public int LoadedClipCount => _clips.Count; + public int MissingClipCount { get; private set; } + public int DecodeFailedClipCount { get; private set; } + public int SilentPlaceholderCount { get; private set; } + public long EstimatedDecodedPcmBytes { get; private set; } + public long DecodedPcmBudgetBytes => _decodedPcmBudgetBytes; + public string LoadError { get; private set; } = string.Empty; + + public IEnumerator Load( + PlayableChart chart, + Action progress, + Func cancellationRequested = null) + { + if (chart == null) + throw new ArgumentNullException(nameof(chart)); + ThrowIfDisposed(); + + ClearAssets(); + State = AudioAssetStoreState.Loading; + MissingClipCount = 0; + DecodeFailedClipCount = 0; + SilentPlaceholderCount = 0; + LoadError = string.Empty; + + if (IsCancelled(cancellationRequested)) + { + State = AudioAssetStoreState.Cancelled; + yield break; + } + + var soundIds = new HashSet(StringComparer.Ordinal); + foreach (var note in chart.Notes) + if (!string.IsNullOrWhiteSpace(note.SoundId)) soundIds.Add(note.SoundId); + foreach (var item in chart.BackgroundAudioEvents) + if (!string.IsNullOrWhiteSpace(item.SoundId)) soundIds.Add(item.SoundId); + foreach (var item in chart.LaneSoundEvents) + if (!string.IsNullOrWhiteSpace(item.SoundId)) soundIds.Add(item.SoundId); + + var orderedIds = soundIds.OrderBy(item => item, StringComparer.Ordinal).ToList(); + for (var index = 0; index < orderedIds.Count; index++) + { + if (CancelLoad(cancellationRequested)) + yield break; + + var soundId = orderedIds[index]; + progress?.Invoke(index, orderedIds.Count, soundId); + + if (!chart.AudioResources.TryGetValue(soundId, out var declaredPath)) + { + MissingClipCount++; + continue; + } + + var resolvedPath = ResolveReferencedAudioPath(chart.Metadata.SourcePath, declaredPath); + if (string.IsNullOrEmpty(resolvedPath)) + { + MissingClipCount++; + continue; + } + + if (!TryClassifyWaveResource( + resolvedPath, + soundId, + out var resourceStatus, + out var resourceError)) + { + DecodeFailedClipCount++; + Debug.LogWarning( + $"[ProjectQuint] Skipped keysound {soundId} '{resolvedPath}': {resourceError}"); + continue; + } + if (resourceStatus == AudioResourceLoadStatus.SilentPlaceholder) + { + SilentPlaceholderCount++; + continue; + } + + AudioClip clip = null; + var loadError = string.Empty; + if (string.Equals(Path.GetExtension(resolvedPath), ".wav", StringComparison.OrdinalIgnoreCase) && + WaveAudioDecoder.IsMicrosoftAdpcm(resolvedPath)) + { + // The format probe reads only the small RIFF header. The expensive full + // file read and ADPCM decode run off the Unity main thread. + var decodeTask = Task.Run(() => DecodeMicrosoftAdpcm(resolvedPath)); + while (!decodeTask.IsCompleted) + { + if (CancelLoad(cancellationRequested)) + { + ObserveFaultAfterCancellation(decodeTask); + yield break; + } + yield return null; + } + + if (decodeTask.IsFaulted) + { + loadError = decodeTask.Exception?.GetBaseException().Message ?? "WAV decode probe failed."; + } + else + { + var decode = decodeTask.Result; + loadError = decode.Error; + if (decode.Audio != null) + clip = CreateDecodedClip(soundId, decode.Audio, out loadError); + } + } + + if (clip == null && string.IsNullOrEmpty(loadError)) + { + using var request = UnityWebRequestMultimedia.GetAudioClip( + BuildLocalFileUri(resolvedPath), + GetAudioType(resolvedPath)); + if (request.downloadHandler is DownloadHandlerAudioClip audioHandler) + { + audioHandler.streamAudio = false; + audioHandler.compressed = false; + } + + var operation = request.SendWebRequest(); + while (!operation.isDone) + { + if (IsCancelled(cancellationRequested)) + { + request.Abort(); + CancelLoad(cancellationRequested); + yield break; + } + yield return null; + } + + if (request.result == UnityWebRequest.Result.Success) + { + try + { + clip = DownloadHandlerAudioClip.GetContent(request); + } + catch (Exception exception) + { + loadError = exception.Message; + } + } + else + { + loadError = request.error; + } + } + + if (CancelLoad(cancellationRequested)) + { + DestroyUnityObject(clip); + yield break; + } + + if (clip == null) + { + DecodeFailedClipCount++; + Debug.LogWarning($"[ProjectQuint] Skipped keysound {soundId} '{resolvedPath}': {loadError}"); + continue; + } + + var clipBytes = CalculateDecodedPcmBytes(clip.samples, clip.channels); + if (clipBytes > _decodedPcmBudgetBytes - EstimatedDecodedPcmBytes) + { + DestroyUnityObject(clip); + Fail( + $"Decoded keysounds exceed the per-song audio budget " + + $"({FormatBytes(_decodedPcmBudgetBytes)}). Last asset: {soundId}."); + yield break; + } + + clip.name = $"BMS {soundId}"; + _clips[soundId] = clip; + EstimatedDecodedPcmBytes += clipBytes; + } + + if (orderedIds.Count > 0 && + _clips.Count == 0 && + MissingClipCount + DecodeFailedClipCount > 0) + { + Fail( + $"No playable keysounds could be loaded " + + $"({MissingClipCount} missing, {DecodeFailedClipCount} decode failed)."); + yield break; + } + + State = AudioAssetStoreState.Ready; + progress?.Invoke(orderedIds.Count, orderedIds.Count, string.Empty); + } + + internal void PrepareForSequentialMix() + { + ThrowIfDisposed(); + ClearAssets(); + MissingClipCount = 0; + DecodeFailedClipCount = 0; + SilentPlaceholderCount = 0; + LoadError = string.Empty; + State = AudioAssetStoreState.Ready; + } + + internal IEnumerator LoadTransientClip( + PlayableChart chart, + string soundId, + Action completed, + Func cancellationRequested = null) + { + if (chart == null) + throw new ArgumentNullException(nameof(chart)); + + if (!chart.AudioResources.TryGetValue(soundId ?? string.Empty, out var declaredPath)) + { + completed?.Invoke(new AudioResourceLoadResult( + AudioResourceLoadStatus.Missing, + detail: $"Undefined keysound {soundId}.")); + yield break; + } + + var resolvedPath = ResolveReferencedAudioPath(chart.Metadata.SourcePath, declaredPath); + if (string.IsNullOrEmpty(resolvedPath)) + { + completed?.Invoke(new AudioResourceLoadResult( + AudioResourceLoadStatus.Missing, + detail: $"Keysound file not found: {declaredPath}")); + yield break; + } + + if (!TryClassifyWaveResource( + resolvedPath, + soundId, + out var resourceStatus, + out var resourceError)) + { + completed?.Invoke(new AudioResourceLoadResult( + AudioResourceLoadStatus.DecodeFailed, + detail: resourceError)); + yield break; + } + if (resourceStatus == AudioResourceLoadStatus.SilentPlaceholder) + { + completed?.Invoke(new AudioResourceLoadResult( + AudioResourceLoadStatus.SilentPlaceholder)); + yield break; + } + + AudioClip clip = null; + var loadError = string.Empty; + if (string.Equals(Path.GetExtension(resolvedPath), ".wav", StringComparison.OrdinalIgnoreCase) && + WaveAudioDecoder.IsMicrosoftAdpcm(resolvedPath)) + { + var decodeTask = Task.Run(() => DecodeMicrosoftAdpcm(resolvedPath)); + while (!decodeTask.IsCompleted) + { + if (IsCancelled(cancellationRequested)) + { + ObserveFaultAfterCancellation(decodeTask); + completed?.Invoke(new AudioResourceLoadResult( + AudioResourceLoadStatus.Cancelled, + detail: "Audio loading cancelled.")); + yield break; + } + yield return null; + } + + if (decodeTask.IsFaulted) + loadError = decodeTask.Exception?.GetBaseException().Message ?? "WAV decode failed."; + else + { + var decode = decodeTask.Result; + loadError = decode.Error; + if (decode.Audio != null) + clip = CreateDecodedClip(soundId, decode.Audio, out loadError); + } + } + + if (clip == null && string.IsNullOrEmpty(loadError)) + { + using var request = UnityWebRequestMultimedia.GetAudioClip( + BuildLocalFileUri(resolvedPath), + GetAudioType(resolvedPath)); + if (request.downloadHandler is DownloadHandlerAudioClip audioHandler) + { + audioHandler.streamAudio = false; + audioHandler.compressed = false; + } + + var operation = request.SendWebRequest(); + while (!operation.isDone) + { + if (IsCancelled(cancellationRequested)) + { + request.Abort(); + completed?.Invoke(new AudioResourceLoadResult( + AudioResourceLoadStatus.Cancelled, + detail: "Audio loading cancelled.")); + yield break; + } + yield return null; + } + + if (request.result == UnityWebRequest.Result.Success) + { + try + { + clip = DownloadHandlerAudioClip.GetContent(request); + } + catch (Exception exception) + { + loadError = exception.Message; + } + } + else + loadError = request.error; + } + + if (IsCancelled(cancellationRequested)) + { + DestroyUnityObject(clip); + completed?.Invoke(new AudioResourceLoadResult( + AudioResourceLoadStatus.Cancelled, + detail: "Audio loading cancelled.")); + yield break; + } + completed?.Invoke(clip != null + ? new AudioResourceLoadResult(AudioResourceLoadStatus.Loaded, clip) + : new AudioResourceLoadResult( + AudioResourceLoadStatus.DecodeFailed, + detail: string.IsNullOrWhiteSpace(loadError) + ? $"Keysound {soundId} could not be decoded." + : loadError)); + } + + internal void RecordTransientOutcome( + string soundId, + AudioResourceLoadStatus status, + string detail) + { + switch (status) + { + case AudioResourceLoadStatus.SilentPlaceholder: + SilentPlaceholderCount++; + return; + case AudioResourceLoadStatus.Missing: + MissingClipCount++; + break; + case AudioResourceLoadStatus.DecodeFailed: + DecodeFailedClipCount++; + break; + default: + return; + } + + Debug.LogWarning( + $"[ProjectQuint] Skipped keysound {soundId}: " + + $"{(string.IsNullOrWhiteSpace(detail) ? status.ToString() : detail)}"); + } + + internal static bool TryReadClipPcm( + AudioClip clip, + string soundId, + long decodedPcmBudgetBytes, + out PcmAudioData pcm, + out AudioResourceLoadStatus failureStatus, + out string error) + { + pcm = null; + failureStatus = AudioResourceLoadStatus.DecodeFailed; + error = string.Empty; + if (clip == null) + return true; + if (clip.loadState != AudioDataLoadState.Loaded && !clip.LoadAudioData()) + { + error = $"Could not load PCM data for keysound {soundId}."; + return false; + } + + var sampleCount64 = checked((long)clip.samples * clip.channels); + if (sampleCount64 <= 0) + { + error = $"Keysound {soundId} has an invalid PCM size."; + return false; + } + if (sampleCount64 > int.MaxValue || + sampleCount64 > decodedPcmBudgetBytes / sizeof(float)) + { + failureStatus = AudioResourceLoadStatus.TooLarge; + error = + $"Decoded keysound {soundId} exceeds the per-resource audio budget " + + $"({FormatBytes(decodedPcmBudgetBytes)})."; + return false; + } + + var samples = new float[(int)sampleCount64]; + if (!clip.GetData(samples, 0)) + { + error = $"Keysound {soundId} cannot be decoded to PCM. Re-encode the source audio as WAV or OGG."; + return false; + } + pcm = new PcmAudioData(samples, clip.channels, clip.frequency); + failureStatus = AudioResourceLoadStatus.Loaded; + return true; + } + + internal static void ReleaseTransientClip(AudioClip clip) => DestroyUnityObject(clip); + + public bool TryGetClip(string soundId, out AudioClip clip) => + _clips.TryGetValue(soundId ?? string.Empty, out clip) && clip != null; + + public Dictionary GetClipDurations() + { + var durations = new Dictionary(_clips.Count, StringComparer.Ordinal); + foreach (var item in _clips) + if (item.Value != null) durations[item.Key] = Math.Max(item.Value.length, 0.01f); + return durations; + } + + public bool TryReadPcm(string soundId, out PcmAudioData pcm, out string error) + { + if (!TryGetClip(soundId, out var clip)) + { + pcm = null; + error = string.Empty; + return true; + } + return TryReadClipPcm( + clip, + soundId, + _decodedPcmBudgetBytes, + out pcm, + out _, + out error); + } + + public void Clear() + { + ThrowIfDisposed(); + ClearAssets(); + MissingClipCount = 0; + DecodeFailedClipCount = 0; + SilentPlaceholderCount = 0; + LoadError = string.Empty; + State = AudioAssetStoreState.Empty; + } + + public void Dispose() + { + if (State == AudioAssetStoreState.Disposed) + return; + ClearAssets(); + State = AudioAssetStoreState.Disposed; + } + + internal static long CalculateDecodedPcmBytes(int frameCount, int channels) + { + if (frameCount < 0) + throw new ArgumentOutOfRangeException(nameof(frameCount)); + if (channels <= 0) + throw new ArgumentOutOfRangeException(nameof(channels)); + return checked((long)frameCount * channels * sizeof(float)); + } + + internal static string ResolveReferencedAudioPath(string chartPath, string declaredPath) + { + var resolved = BmsResourceResolver.ResolveAudio(chartPath, declaredPath); + if (!string.IsNullOrEmpty(resolved)) + return resolved; + if (string.IsNullOrWhiteSpace(chartPath) || string.IsNullOrWhiteSpace(declaredPath)) + return string.Empty; + + var chartDirectory = Path.GetDirectoryName(chartPath); + if (string.IsNullOrEmpty(chartDirectory)) + return string.Empty; + + var normalizedRelativePath = declaredPath + .Trim() + .Trim('"') + .Replace('\\', Path.DirectorySeparatorChar) + .Replace('/', Path.DirectorySeparatorChar); + var declaredCandidate = Path.IsPathRooted(normalizedRelativePath) + ? normalizedRelativePath + : Path.Combine(chartDirectory, normalizedRelativePath); + var candidateDirectory = Path.GetDirectoryName(declaredCandidate); + var candidateStem = Path.GetFileNameWithoutExtension(declaredCandidate); + if (string.IsNullOrEmpty(candidateDirectory) || string.IsNullOrEmpty(candidateStem) || + !Directory.Exists(candidateDirectory)) + return string.Empty; + + foreach (var extension in AudioExtensionFallbacks) + { + var candidate = Path.Combine(candidateDirectory, candidateStem + extension); + if (File.Exists(candidate)) + return candidate; + } + + try + { + foreach (var candidate in Directory.EnumerateFiles(candidateDirectory)) + { + if (!string.Equals(Path.GetFileNameWithoutExtension(candidate), candidateStem, + StringComparison.OrdinalIgnoreCase)) + continue; + if (AudioExtensionFallbacks.Any(extension => + string.Equals(Path.GetExtension(candidate), extension, StringComparison.OrdinalIgnoreCase))) + return candidate; + } + } + catch (IOException) + { + return string.Empty; + } + catch (UnauthorizedAccessException) + { + return string.Empty; + } + + return string.Empty; + } + + internal static string BuildLocalFileUri(string filePath) + { + if (string.IsNullOrWhiteSpace(filePath)) + throw new ArgumentException("A local audio path is required.", nameof(filePath)); + + // System.Uri treats a valid-looking %xx sequence as an already escaped URL byte. + // BMS packs can legally contain a literal '%' in a directory or file name, so escape + // it before constructing the file URI or UnityWebRequest will open a different path. + var literalPath = Path.GetFullPath(filePath); + return "file://" + string.Join( + "/", + literalPath + .Split(new[] { Path.DirectorySeparatorChar }, StringSplitOptions.None) + .Select(Uri.EscapeDataString)); + } + + private bool CancelLoad(Func cancellationRequested) + { + if (!IsCancelled(cancellationRequested)) + return false; + ClearAssets(); + State = AudioAssetStoreState.Cancelled; + return true; + } + + private void Fail(string error) + { + ClearAssets(); + LoadError = error ?? "Audio asset loading failed."; + State = AudioAssetStoreState.Failed; + } + + private void ClearAssets() + { + foreach (var clip in _clips.Values) + DestroyUnityObject(clip); + _clips.Clear(); + EstimatedDecodedPcmBytes = 0; + } + + private void ThrowIfDisposed() + { + if (State == AudioAssetStoreState.Disposed) + throw new ObjectDisposedException(nameof(AudioAssetStore)); + } + + private static bool IsCancelled(Func cancellationRequested) => + cancellationRequested?.Invoke() == true; + + internal static bool TryClassifyWaveResource( + string resolvedPath, + string soundId, + out AudioResourceLoadStatus status, + out string error) + { + status = AudioResourceLoadStatus.Loaded; + error = string.Empty; + if (!string.Equals( + Path.GetExtension(resolvedPath), + ".wav", + StringComparison.OrdinalIgnoreCase)) + return true; + + var inspection = WaveAudioDecoder.InspectContainer(resolvedPath); + switch (inspection.Status) + { + case WaveContainerStatus.AudioData: + return true; + case WaveContainerStatus.SilentPlaceholder: + status = AudioResourceLoadStatus.SilentPlaceholder; + return true; + default: + status = AudioResourceLoadStatus.DecodeFailed; + error = + $"Keysound {soundId} is not a valid WAV container: " + + inspection.Detail; + return false; + } + } + + private static BackgroundWaveDecodeResult DecodeMicrosoftAdpcm(string path) + { + return WaveAudioDecoder.TryDecodeFile(path, out var audio, out var error) + ? new BackgroundWaveDecodeResult(audio, string.Empty) + : new BackgroundWaveDecodeResult(null, error); + } + + private static AudioClip CreateDecodedClip( + string soundId, + DecodedWaveAudio decoded, + out string error) + { + error = string.Empty; + var clip = AudioClip.Create( + $"BMS {soundId}", + decoded.FrameCount, + decoded.Channels, + decoded.SampleRate, + false); + if (clip != null && clip.SetData(decoded.Samples, 0)) + return clip; + DestroyUnityObject(clip); + error = "AudioClip.SetData rejected the decoded samples."; + return null; + } + + private static AudioType GetAudioType(string path) + { + switch (Path.GetExtension(path).ToLowerInvariant()) + { + case ".ogg": return AudioType.OGGVORBIS; + case ".mp3": return AudioType.MPEG; + default: return AudioType.WAV; + } + } + + private static void ObserveFaultAfterCancellation(Task task) + { + task.ContinueWith( + completed => { _ = completed.Exception; }, + TaskContinuationOptions.OnlyOnFaulted); + } + + private static void DestroyUnityObject(UnityEngine.Object target) + { + if (target == null) + return; + if (Application.isPlaying) + UnityEngine.Object.Destroy(target); + else + UnityEngine.Object.DestroyImmediate(target); + } + + private static string FormatBytes(long bytes) => $"{bytes / (1024d * 1024d):0.#} MiB"; + + private readonly struct BackgroundWaveDecodeResult + { + public BackgroundWaveDecodeResult(DecodedWaveAudio audio, string error) + { + Audio = audio; + Error = error ?? string.Empty; + } + + public DecodedWaveAudio Audio { get; } + public string Error { get; } + } + } +} diff --git a/Assets/ProjectQuint/Runtime/Unity/Audio/AudioAssetStore.cs.meta b/Assets/ProjectQuint/Runtime/Unity/Audio/AudioAssetStore.cs.meta new file mode 100644 index 0000000..cf0079b --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Audio/AudioAssetStore.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: dd4f69d881f884738aed3561ef698e14 \ No newline at end of file diff --git a/Assets/ProjectQuint/Runtime/Unity/Audio/AudioBackendContracts.cs b/Assets/ProjectQuint/Runtime/Unity/Audio/AudioBackendContracts.cs new file mode 100644 index 0000000..c525b07 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Audio/AudioBackendContracts.cs @@ -0,0 +1,178 @@ +using System; +using System.Collections; +using ProjectQuint.Core.Charts; +using UnityEngine; + +namespace ProjectQuint.Audio +{ + public readonly struct AudioOutputDeviceInfo + { + public AudioOutputDeviceInfo( + string persistentId, + string name, + int sampleRate, + int channels, + bool isSystemDefault = false) + { + PersistentId = persistentId ?? string.Empty; + Name = string.IsNullOrWhiteSpace(name) ? "UNKNOWN DEVICE" : name.Trim(); + SampleRate = Math.Max(0, sampleRate); + Channels = Math.Max(0, channels); + IsSystemDefault = isSystemDefault; + } + + public string PersistentId { get; } + public string Name { get; } + public int SampleRate { get; } + public int Channels { get; } + public bool IsSystemDefault { get; } + public string DetailLabel => IsSystemDefault + ? "FOLLOW macOS SYSTEM OUTPUT" + : SampleRate > 0 + ? $"{SampleRate:N0} Hz // {Channels} CH" + : "DEVICE FORMAT UNAVAILABLE"; + + public static AudioOutputDeviceInfo SystemDefault { get; } = + new AudioOutputDeviceInfo(string.Empty, "SYSTEM DEFAULT", 0, 0, true); + } + + public readonly struct AudioOutputConfigurationSnapshot + { + public AudioOutputConfigurationSnapshot( + string backendId, + string outputType, + string deviceName, + int bufferLength, + int bufferCount, + int sampleRate, + string devicePersistentId = "") + { + BackendId = backendId ?? string.Empty; + OutputType = outputType ?? string.Empty; + DeviceName = deviceName ?? string.Empty; + DevicePersistentId = devicePersistentId ?? string.Empty; + BufferLength = Math.Max(0, bufferLength); + BufferCount = Math.Max(0, bufferCount); + SampleRate = Math.Max(0, sampleRate); + } + + public string BackendId { get; } + public string OutputType { get; } + public string DeviceName { get; } + public string DevicePersistentId { get; } + public int BufferLength { get; } + public int BufferCount { get; } + public int SampleRate { get; } + public double EstimatedMixerLatencyMilliseconds => + BufferLength > 0 && BufferCount > 0 && SampleRate > 0 + ? BufferLength * (double)BufferCount * 1000.0 / SampleRate + : 0; + public string AppliedLabel => BufferLength > 0 && BufferCount > 0 && SampleRate > 0 + ? $"{BackendId} // {OutputType} // {DeviceName} // " + + $"{BufferLength} x {BufferCount} @ {SampleRate} Hz // " + + $"EST {EstimatedMixerLatencyMilliseconds:0.0} ms" + : $"{BackendId} // APPLIED CONFIGURATION UNAVAILABLE"; + } + + /// + /// Monotonic clock owned by the selected audio backend. Gameplay consumes seconds only and + /// never reaches into Unity or a future native backend for its clock directly. + /// + public interface IAudioClock + { + string ClockId { get; } + double CurrentTime { get; } + int OutputSampleRate { get; } + } + + /// + /// Per-song audio session contract shared by loading, gameplay and diagnostics. Backend + /// implementations own their native clips, channels, scheduling clock and pause semantics. + /// + public interface ISongAudioBackend : IDisposable + { + string BackendId { get; } + IAudioClock Clock { get; } + double StartDspTime { get; } + int LoadedClipCount { get; } + int MissingClipCount { get; } + int DecodeFailedClipCount { get; } + int SilentPlaceholderCount { get; } + int ScheduledSourceCount { get; } + int PendingScheduledEventCount { get; } + int DroppedLateEventCount { get; } + bool SmoothMixReady { get; } + string SmoothMixError { get; } + bool SmoothMixCacheHit { get; } + string SmoothMixCacheStatus { get; } + string LoadError { get; } + long EstimatedDecodedPcmBytes { get; } + long EstimatedPremixedPcmBytes { get; } + long EstimatedTotalPcmBytes { get; } + + IEnumerator Load( + PlayableChart chart, + Action progress, + Func cancellationRequested = null, + bool reactiveKeySoundsEnabled = true); + + void PrepareReactivePlayback(double leadSeconds = 0.35); + + IEnumerator PrepareSmoothPlaybackMix( + Action progress, + Func cancellationRequested = null); + + void BeginPlayback(bool reactiveKeySoundsEnabled, double leadSeconds = 0.35); + void Update(); + void PlayKeySound(string soundId, int lane); + void SetMasterVolume(float normalizedVolume); + void Pause(); + void Resume(); + void Stop(); + } + + /// + /// Composition boundary used by SongLoadCoordinator. A future FMOD implementation can be + /// selected here without introducing FMOD types into loading or gameplay assemblies. + /// + public interface ISongAudioBackendFactory + { + string BackendId { get; } + IAudioClock Clock { get; } + AudioOutputConfigurationSnapshot OutputConfiguration { get; } + ISongAudioBackend Create(GameObject resourceOwner); + } + + public sealed class UnityDspAudioClock : IAudioClock + { + private UnityDspAudioClock() { } + + public static UnityDspAudioClock Shared { get; } = new UnityDspAudioClock(); + public string ClockId => "UNITY_DSP"; + public double CurrentTime => AudioSettings.dspTime; + public int OutputSampleRate => AudioSettings.outputSampleRate; + } + + public sealed class UnitySongAudioBackendFactory : ISongAudioBackendFactory + { + public string BackendId => BmsAudioSystem.UnityBackendId; + public IAudioClock Clock => UnityDspAudioClock.Shared; + public AudioOutputConfigurationSnapshot OutputConfiguration + { + get + { + AudioSettings.GetDSPBufferSize(out var bufferLength, out var bufferCount); + return new AudioOutputConfigurationSnapshot( + BackendId, + "UNITY AUDIO", + "SYSTEM DEFAULT", + bufferLength, + bufferCount, + AudioSettings.outputSampleRate); + } + } + + public ISongAudioBackend Create(GameObject resourceOwner) => + new BmsAudioSystem(resourceOwner, Clock); + } +} diff --git a/Assets/ProjectQuint/Runtime/Unity/Audio/AudioBackendContracts.cs.meta b/Assets/ProjectQuint/Runtime/Unity/Audio/AudioBackendContracts.cs.meta new file mode 100644 index 0000000..a3d919d --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Audio/AudioBackendContracts.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 2d3afc7f7a5fe4515ba7b2723c0b6d3e \ No newline at end of file diff --git a/Assets/ProjectQuint/Runtime/Unity/Audio/AudioDspBufferController.cs b/Assets/ProjectQuint/Runtime/Unity/Audio/AudioDspBufferController.cs new file mode 100644 index 0000000..f7d40b8 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Audio/AudioDspBufferController.cs @@ -0,0 +1,217 @@ +using System; +using ProjectQuint.Settings; +using UnityEngine; + +namespace ProjectQuint.Audio +{ + internal readonly struct AudioDspConfigurationSnapshot + { + public AudioDspConfigurationSnapshot( + AudioDspBufferProfile requestedProfile, + int requestedBufferSamples, + int bufferLength, + int bufferCount, + int sampleRate) + { + RequestedProfile = requestedProfile; + RequestedBufferSamples = Math.Max(1, requestedBufferSamples); + BufferLength = Math.Max(0, bufferLength); + BufferCount = Math.Max(0, bufferCount); + SampleRate = Math.Max(0, sampleRate); + } + + public AudioDspBufferProfile RequestedProfile { get; } + public int RequestedBufferSamples { get; } + public int BufferLength { get; } + public int BufferCount { get; } + public int SampleRate { get; } + public bool IsExactBufferMatch => BufferLength == RequestedBufferSamples; + public double EstimatedMixerLatencyMilliseconds => CalculateMixerLatencyMilliseconds( + BufferLength, + BufferCount, + SampleRate); + + public string AppliedLabel => BufferLength > 0 && BufferCount > 0 && SampleRate > 0 + ? $"APPLIED {BufferLength} x {BufferCount} @ {SampleRate} Hz // EST {EstimatedMixerLatencyMilliseconds:0.0} ms" + : "APPLIED CONFIGURATION UNAVAILABLE"; + + public static double CalculateMixerLatencyMilliseconds( + int bufferLength, + int bufferCount, + int sampleRate) + { + if (bufferLength <= 0 || bufferCount <= 0 || sampleRate <= 0) + return 0; + return bufferLength * (double)bufferCount * 1000.0 / sampleRate; + } + } + + internal interface IAudioConfigurationBackend : IDisposable + { + event Action ConfigurationChanged; + AudioConfiguration GetConfiguration(); + void GetDspBufferSize(out int bufferLength, out int bufferCount); + bool Reset(AudioConfiguration configuration); + } + + internal sealed class UnityAudioConfigurationBackend : IAudioConfigurationBackend + { + public UnityAudioConfigurationBackend() => + AudioSettings.OnAudioConfigurationChanged += OnAudioConfigurationChanged; + + public event Action ConfigurationChanged; + + public AudioConfiguration GetConfiguration() => AudioSettings.GetConfiguration(); + + public void GetDspBufferSize(out int bufferLength, out int bufferCount) => + AudioSettings.GetDSPBufferSize(out bufferLength, out bufferCount); + + public bool Reset(AudioConfiguration configuration) => AudioSettings.Reset(configuration); + + public void Dispose() => + AudioSettings.OnAudioConfigurationChanged -= OnAudioConfigurationChanged; + + private void OnAudioConfigurationChanged(bool deviceWasChanged) => + ConfigurationChanged?.Invoke(deviceWasChanged); + } + + /// + /// Owns runtime DSP-buffer reconfiguration. Callers must release every generated AudioClip + /// in prepareForReset before this controller invokes AudioSettings.Reset. + /// + internal sealed class AudioDspBufferController : IDisposable + { + private readonly IAudioConfigurationBackend _backend; + private bool _externalDeviceChangePending; + private bool _disposed; + + public AudioDspBufferController( + AudioDspBufferProfile requestedProfile, + IAudioConfigurationBackend backend = null) + { + RequestedProfile = Enum.IsDefined(typeof(AudioDspBufferProfile), requestedProfile) + ? requestedProfile + : GameSettingsDefaults.AudioDspBuffer; + _backend = backend ?? new UnityAudioConfigurationBackend(); + _backend.ConfigurationChanged += OnConfigurationChanged; + Snapshot = CaptureSnapshot(); + } + + public AudioDspBufferProfile RequestedProfile { get; private set; } + public AudioDspConfigurationSnapshot Snapshot { get; private set; } + public bool IsResetting { get; private set; } + public string LastError { get; private set; } = string.Empty; + + public bool TryApply( + AudioDspBufferProfile profile, + Func prepareForReset, + out AudioDspConfigurationSnapshot snapshot) + { + ThrowIfDisposed(); + profile = Enum.IsDefined(typeof(AudioDspBufferProfile), profile) + ? profile + : GameSettingsDefaults.AudioDspBuffer; + var requestedSamples = GameSettings.RequestedDspBufferSamples(profile); + RefreshSnapshot(); + if (Snapshot.BufferLength == requestedSamples) + { + RequestedProfile = profile; + Snapshot = CaptureSnapshot(); + LastError = string.Empty; + snapshot = Snapshot; + return true; + } + + if (prepareForReset == null) + { + LastError = "Audio reset preparation callback is missing."; + snapshot = Snapshot; + return false; + } + + IsResetting = true; + try + { + if (!prepareForReset()) + { + LastError = "Audio reset was blocked because song resources are still active."; + snapshot = Snapshot; + return false; + } + + var configuration = _backend.GetConfiguration(); + configuration.dspBufferSize = requestedSamples; + if (!_backend.Reset(configuration)) + { + LastError = $"The audio device rejected the {requestedSamples}-sample DSP buffer request."; + RefreshSnapshot(); + snapshot = Snapshot; + return false; + } + + RequestedProfile = profile; + RefreshSnapshot(); + LastError = string.Empty; + snapshot = Snapshot; + return true; + } + catch (Exception exception) + { + LastError = $"Audio reset failed: {exception.Message}"; + RefreshSnapshot(); + snapshot = Snapshot; + return false; + } + finally + { + IsResetting = false; + } + } + + public bool ConsumeExternalDeviceChange() + { + var pending = _externalDeviceChangePending; + _externalDeviceChangePending = false; + if (pending) + RefreshSnapshot(); + return pending; + } + + public void RefreshSnapshot() => Snapshot = CaptureSnapshot(); + + public void Dispose() + { + if (_disposed) + return; + _backend.ConfigurationChanged -= OnConfigurationChanged; + _backend.Dispose(); + _disposed = true; + } + + private AudioDspConfigurationSnapshot CaptureSnapshot() + { + var configuration = _backend.GetConfiguration(); + _backend.GetDspBufferSize(out var bufferLength, out var bufferCount); + return new AudioDspConfigurationSnapshot( + RequestedProfile, + GameSettings.RequestedDspBufferSamples(RequestedProfile), + bufferLength, + bufferCount, + configuration.sampleRate); + } + + private void OnConfigurationChanged(bool deviceWasChanged) + { + if (deviceWasChanged) + _externalDeviceChangePending = true; + if (!IsResetting) + RefreshSnapshot(); + } + + private void ThrowIfDisposed() + { + if (_disposed) + throw new ObjectDisposedException(nameof(AudioDspBufferController)); + } + } +} diff --git a/Assets/ProjectQuint/Runtime/Unity/Audio/AudioDspBufferController.cs.meta b/Assets/ProjectQuint/Runtime/Unity/Audio/AudioDspBufferController.cs.meta new file mode 100644 index 0000000..1528bad --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Audio/AudioDspBufferController.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 42d5b3d7137874d02b42b2787892fe5b \ No newline at end of file diff --git a/Assets/ProjectQuint/Runtime/Unity/Audio/BmsAudioSystem.cs b/Assets/ProjectQuint/Runtime/Unity/Audio/BmsAudioSystem.cs new file mode 100644 index 0000000..2ca7af6 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Audio/BmsAudioSystem.cs @@ -0,0 +1,186 @@ +using System; +using System.Collections; +using ProjectQuint.Core.Charts; +using UnityEngine; + +namespace ProjectQuint.Audio +{ + /// + /// Compatibility facade for one song's audio session. Asset loading, offline rendering and + /// DSP playback are separate owners so gameplay never performs decode or mix work. + /// + public sealed class BmsAudioSystem : ISongAudioBackend + { + public const string UnityBackendId = "UNITY_AUDIO"; + private readonly AudioAssetStore _assets; + private readonly PremixedSongRenderer _premix; + private readonly SongAudioPlaybackEngine _playback; + private PlayableChart _chart; + private bool _disposed; + + public BmsAudioSystem(GameObject owner) + : this(owner, UnityDspAudioClock.Shared) + { + } + + internal BmsAudioSystem(GameObject owner, IAudioClock clock) + { + if (owner == null) + throw new ArgumentNullException(nameof(owner)); + Clock = clock ?? throw new ArgumentNullException(nameof(clock)); + _assets = new AudioAssetStore(); + _premix = new PremixedSongRenderer(); + _playback = new SongAudioPlaybackEngine(owner, Clock); + } + + internal const double SchedulingHorizon = SongAudioPlaybackEngine.SchedulingHorizon; + internal const double LateEventDropThresholdSeconds = + SongAudioPlaybackEngine.LateEventDropThresholdSeconds; + internal const int ScheduledSourceHeadroom = SongAudioPlaybackEngine.ScheduledSourceHeadroom; + + public string BackendId => UnityBackendId; + public IAudioClock Clock { get; } + public double StartDspTime => _playback.StartDspTime; + public int LoadedClipCount => _assets.LoadedClipCount; + public int MissingClipCount => _assets.MissingClipCount; + public int DecodeFailedClipCount => _assets.DecodeFailedClipCount; + public int SilentPlaceholderCount => _assets.SilentPlaceholderCount; + public int ScheduledSourceCount => _playback.ScheduledSourceCount; + public int PendingScheduledEventCount => _playback.PendingScheduledEventCount; + public int DroppedLateEventCount => _playback.DroppedLateEventCount; + public bool SmoothMixReady => _premix.IsReady; + public string SmoothMixError => _premix.Error; + public bool SmoothMixCacheHit => _premix.CacheHit; + public string SmoothMixCacheStatus => _premix.CacheStatus; + public string LoadError => _assets.LoadError; + public long EstimatedDecodedPcmBytes => _assets.EstimatedDecodedPcmBytes; + public long EstimatedPremixedPcmBytes => _premix.EstimatedPcmBytes; + public long EstimatedTotalPcmBytes => checked(EstimatedDecodedPcmBytes + EstimatedPremixedPcmBytes); + + internal AudioAssetStoreState AssetState => _assets.State; + internal PremixedSongState PremixState => _premix.State; + + public IEnumerator Load( + PlayableChart chart, + Action progress, + Func cancellationRequested = null, + bool reactiveKeySoundsEnabled = true) + { + if (chart == null) + throw new ArgumentNullException(nameof(chart)); + ThrowIfDisposed(); + ResetLoadedResources(); + _chart = chart; + if (!reactiveKeySoundsEnabled) + { + _assets.PrepareForSequentialMix(); + progress?.Invoke(0, 0, "Sequential smooth mix selected"); + yield break; + } + yield return _assets.Load(chart, progress, cancellationRequested); + } + + public void PrepareReactivePlayback(double leadSeconds = 0.35) + { + ThrowIfDisposed(); + EnsureAssetsReady(); + _playback.PrepareReactivePlayback(_chart, _assets, leadSeconds); + } + + public IEnumerator PrepareSmoothPlaybackMix( + Action progress, + Func cancellationRequested = null) + { + ThrowIfDisposed(); + EnsureAssetsReady(); + yield return _premix.Prepare(_chart, _assets, progress, cancellationRequested); + } + + public void BeginPlayback(bool reactiveKeySoundsEnabled, double leadSeconds = 0.35) + { + ThrowIfDisposed(); + EnsureAssetsReady(); + _playback.BeginPlayback( + _chart, + _assets, + _premix.Clip, + reactiveKeySoundsEnabled, + leadSeconds); + } + + public void Update() => _playback.Update(); + + public void PlayKeySound(string soundId, int lane) => _playback.PlayKeySound(soundId, lane); + + public void SetMasterVolume(float normalizedVolume) => + AudioListener.volume = Mathf.Clamp01(normalizedVolume); + + public void Pause() + { + ThrowIfDisposed(); + AudioListener.pause = true; + } + + public void Resume() + { + ThrowIfDisposed(); + AudioListener.pause = false; + } + + public void Stop() => _playback.Stop(); + + /// + /// Clears song-owned clips while keeping the source graph reusable. PreparedSong.Dispose + /// uses Dispose instead; this method remains for compatibility with older call sites. + /// + public void DisposeClips() + { + if (_disposed) + return; + ResetLoadedResources(); + } + + public void Dispose() + { + if (_disposed) + return; + _playback.Stop(); + _premix.Dispose(); + _assets.Dispose(); + _playback.Dispose(); + _chart = null; + _disposed = true; + } + + internal static string ResolveReferencedAudioPath(string chartPath, string declaredPath) => + AudioAssetStore.ResolveReferencedAudioPath(chartPath, declaredPath); + + internal static bool ShouldDropLateScheduledEvent(double scheduledDspTime, double currentDspTime) => + SongAudioPlaybackEngine.ShouldDropLateScheduledEvent(scheduledDspTime, currentDspTime); + + private void ResetLoadedResources() + { + _playback.Stop(); + _premix.Clear(); + _assets.Clear(); + _chart = null; + } + + private void EnsureAssetsReady() + { + if (_chart == null || _assets.State != AudioAssetStoreState.Ready) + { + var detail = string.IsNullOrWhiteSpace(_assets.LoadError) + ? $"Audio assets are not ready (state: {_assets.State})." + : _assets.LoadError; + throw new InvalidOperationException(detail); + } + } + + private void ThrowIfDisposed() + { + if (_disposed) + throw new ObjectDisposedException(nameof(BmsAudioSystem)); + } + } +} diff --git a/Assets/ProjectQuint/Runtime/Unity/Audio/BmsAudioSystem.cs.meta b/Assets/ProjectQuint/Runtime/Unity/Audio/BmsAudioSystem.cs.meta new file mode 100644 index 0000000..d9a2287 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Audio/BmsAudioSystem.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: ab2ab8839d3064ce8a8a9a4ca2fa73e3 \ No newline at end of file diff --git a/Assets/ProjectQuint/Runtime/Unity/Audio/FmodAudioSyncMetronome.cs b/Assets/ProjectQuint/Runtime/Unity/Audio/FmodAudioSyncMetronome.cs new file mode 100644 index 0000000..27b5ea3 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Audio/FmodAudioSyncMetronome.cs @@ -0,0 +1,143 @@ +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using FMOD; +using FmodChannel = FMOD.Channel; +using FmodSound = FMOD.Sound; + +namespace ProjectQuint.Audio +{ + /// + /// A short procedural click scheduled on the same FMOD DSP clock used by gameplay. + /// + internal sealed class FmodAudioSyncMetronome : IDisposable + { + private const double ClickDurationSeconds = 0.035; + private readonly FmodCoreAudioContext _context; + private readonly List _channels = new List(); + private FmodSound _click; + private bool _disposed; + + internal FmodAudioSyncMetronome(FmodCoreAudioContext context, float volume) + { + _context = context ?? throw new ArgumentNullException(nameof(context)); + var wav = CreateClickWav(context.OutputSampleRate); + var exInfo = new CREATESOUNDEXINFO + { + cbsize = Marshal.SizeOf(), + length = checked((uint)wav.Length), + }; + FmodCoreAudioContext.Check( + context.System.createSound( + wav, + MODE.OPENMEMORY | MODE.CREATESAMPLE | MODE.LOOP_OFF | MODE._2D, + ref exInfo, + out _click), + "create audio-sync click"); + Volume = Math.Max(0f, Math.Min(1f, volume)); + } + + internal float Volume { get; } + + internal double[] ScheduleClicks(int count, double intervalSeconds, double leadSeconds) + { + ThrowIfDisposed(); + if (count <= 0) + throw new ArgumentOutOfRangeException(nameof(count)); + if (intervalSeconds <= 0) + throw new ArgumentOutOfRangeException(nameof(intervalSeconds)); + + StopChannels(); + var start = _context.ScheduleAfter(leadSeconds); + var intervalSamples = (ulong)Math.Round(intervalSeconds * _context.OutputSampleRate); + var logicalTimes = new double[count]; + for (var index = 0; index < count; index++) + { + var rawClock = checked(start + intervalSamples * (ulong)index); + var channel = _context.PlayScheduledAt(_click, rawClock); + FmodCoreAudioContext.Check(channel.setVolume(Volume), "set audio-sync click volume"); + _channels.Add(channel); + logicalTimes[index] = _context.LogicalTimeForRawClock(rawClock); + } + return logicalTimes; + } + + internal void StopChannels() + { + for (var index = 0; index < _channels.Count; index++) + { + var channel = _channels[index]; + if (channel.hasHandle()) + channel.stop(); + channel.clearHandle(); + } + _channels.Clear(); + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + StopChannels(); + if (_click.hasHandle()) + _click.release(); + _click.clearHandle(); + } + + private static byte[] CreateClickWav(int sampleRate) + { + var sampleCount = Math.Max(1, (int)Math.Round(sampleRate * ClickDurationSeconds)); + var pcmBytes = checked(sampleCount * 2); + var data = new byte[44 + pcmBytes]; + WriteAscii(data, 0, "RIFF"); + WriteInt32(data, 4, 36 + pcmBytes); + WriteAscii(data, 8, "WAVE"); + WriteAscii(data, 12, "fmt "); + WriteInt32(data, 16, 16); + WriteInt16(data, 20, 1); + WriteInt16(data, 22, 1); + WriteInt32(data, 24, sampleRate); + WriteInt32(data, 28, sampleRate * 2); + WriteInt16(data, 32, 2); + WriteInt16(data, 34, 16); + WriteAscii(data, 36, "data"); + WriteInt32(data, 40, pcmBytes); + for (var index = 0; index < sampleCount; index++) + { + var t = index / (double)sampleRate; + var envelope = Math.Exp(-t * 95.0); + var waveform = Math.Sin(2.0 * Math.PI * 1450.0 * t) * envelope; + var sample = (short)Math.Round(Math.Max(-1.0, Math.Min(1.0, waveform)) * 24500.0); + WriteInt16(data, 44 + index * 2, sample); + } + return data; + } + + private static void WriteAscii(byte[] target, int offset, string value) + { + for (var index = 0; index < value.Length; index++) + target[offset + index] = (byte)value[index]; + } + + private static void WriteInt16(byte[] target, int offset, int value) + { + target[offset] = (byte)value; + target[offset + 1] = (byte)(value >> 8); + } + + private static void WriteInt32(byte[] target, int offset, int value) + { + target[offset] = (byte)value; + target[offset + 1] = (byte)(value >> 8); + target[offset + 2] = (byte)(value >> 16); + target[offset + 3] = (byte)(value >> 24); + } + + private void ThrowIfDisposed() + { + if (_disposed) + throw new ObjectDisposedException(nameof(FmodAudioSyncMetronome)); + } + } +} diff --git a/Assets/ProjectQuint/Runtime/Unity/Audio/FmodAudioSyncMetronome.cs.meta b/Assets/ProjectQuint/Runtime/Unity/Audio/FmodAudioSyncMetronome.cs.meta new file mode 100644 index 0000000..7a35c78 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Audio/FmodAudioSyncMetronome.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 44dad9469e144cd1817cce6f6a8299e7 diff --git a/Assets/ProjectQuint/Runtime/Unity/Audio/FmodCoreAudioBackend.cs b/Assets/ProjectQuint/Runtime/Unity/Audio/FmodCoreAudioBackend.cs new file mode 100644 index 0000000..86e024e --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Audio/FmodCoreAudioBackend.cs @@ -0,0 +1,1013 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Threading; +using ProjectQuint.Core.Charts; +using UnityEngine; +using FmodChannel = FMOD.Channel; +using FmodChannelGroup = FMOD.ChannelGroup; +using FmodSound = FMOD.Sound; +using FmodSystem = FMOD.System; + +namespace ProjectQuint.Audio +{ + public enum SongAudioBackendKind + { + Unity, + FmodCore, + } + + internal static class AudioBackendSelection + { + internal const string BackendArgument = "-projectquint-audio-backend"; + + public static bool TryParse( + string[] arguments, + out SongAudioBackendKind backend, + out string error) + { + backend = SongAudioBackendKind.FmodCore; + error = string.Empty; + if (arguments == null) + return true; + + for (var index = 0; index < arguments.Length; index++) + { + if (!string.Equals( + arguments[index], + BackendArgument, + StringComparison.OrdinalIgnoreCase)) + continue; + if (++index >= arguments.Length || string.IsNullOrWhiteSpace(arguments[index])) + { + error = $"{BackendArgument} requires 'unity' or 'fmod-core'."; + return false; + } + + var value = arguments[index]; + if (string.Equals(value, "unity", StringComparison.OrdinalIgnoreCase) || + string.Equals(value, "unity-audio", StringComparison.OrdinalIgnoreCase)) + { + backend = SongAudioBackendKind.Unity; + continue; + } + if (string.Equals(value, "fmod", StringComparison.OrdinalIgnoreCase) || + string.Equals(value, "fmod-core", StringComparison.OrdinalIgnoreCase)) + { + backend = SongAudioBackendKind.FmodCore; + continue; + } + + error = $"Unsupported audio backend '{value}'. Expected 'unity' or 'fmod-core'."; + return false; + } + + return true; + } + } + + /// + /// One FMOD Core system shared by every prepared song. Its public clock removes time spent + /// paused, while scheduling continues to use the unmodified native DSP sample clock. + /// + internal sealed class FmodCoreAudioContext : IAudioClock, IDisposable + { + private const int MaximumVirtualChannels = 512; + private FmodSystem _system; + private FmodChannelGroup _masterGroup; + private readonly FMOD.SYSTEM_CALLBACK _outputCallback; + private readonly int _outputSampleRate; + private readonly bool _noSoundNrt; + private AudioOutputDeviceInfo[] _outputDevices = { AudioOutputDeviceInfo.SystemDefault }; + private int _deviceReinitializeCount; + private int _reportedDeviceReinitializeCount; + private int _outputUnderrunCount; + private int _reportedOutputUnderrunCount; + private ulong _accumulatedPausedSamples; + private ulong _pauseStartedRawClock; + private double _pausedLogicalTime; + private bool _clockPaused; + private bool _disposed; + + public FmodCoreAudioContext( + uint dspBufferLength = 0, + bool noSoundNrt = false, + string preferredOutputDeviceId = "") + { + _noSoundNrt = noSoundNrt; + RequestedOutputDeviceId = preferredOutputDeviceId?.Trim() ?? string.Empty; + try + { + Check(FMOD.Factory.System_Create(out _system), "create Core system"); + if (noSoundNrt) + Check(_system.setOutput(FMOD.OUTPUTTYPE.NOSOUND_NRT), "select NRT test output"); + Check(_system.setSoftwareChannels(MaximumVirtualChannels), "configure virtual channels"); + if (dspBufferLength > 0) + Check(_system.setDSPBufferSize(dspBufferLength, 4), "configure DSP buffer"); + if (!noSoundNrt && !string.IsNullOrWhiteSpace(RequestedOutputDeviceId)) + SelectPreferredOutputDevice(RequestedOutputDeviceId); + Check(_system.init(MaximumVirtualChannels, FMOD.INITFLAGS.NORMAL, IntPtr.Zero), "initialize Core system"); + _outputCallback = OnOutputEvent; + Check( + _system.setCallback( + _outputCallback, + FMOD.SYSTEM_CALLBACK_TYPE.DEVICEREINITIALIZE | + FMOD.SYSTEM_CALLBACK_TYPE.OUTPUTUNDERRUN), + "register output-device diagnostics"); + Check(_system.getVersion(out var runtimeVersion), "query runtime version"); + if (runtimeVersion != FMOD.VERSION.number) + { + throw new InvalidOperationException( + $"FMOD header/runtime version mismatch: header 0x{FMOD.VERSION.number:X8}, " + + $"runtime 0x{runtimeVersion:X8}."); + } + Check( + _system.getSoftwareFormat(out _outputSampleRate, out _, out _), + "query output format"); + if (_outputSampleRate <= 0) + throw new InvalidOperationException("FMOD reported an invalid output sample rate."); + Check(_system.getMasterChannelGroup(out _masterGroup), "query master channel group"); + RefreshOutputDevices(); + CaptureSelectedOutputDevice(); + if (DeviceSelectionFallback) + { + Debug.LogWarning( + $"[ProjectQuint Audio] Saved output device '{RequestedOutputDeviceId}' is unavailable; " + + "using the macOS system default for this session."); + } + Debug.Log($"[ProjectQuint Audio] {DescribeOutputDevice()}."); + } + catch + { + Dispose(); + throw; + } + } + + public string ClockId => "FMOD_DSP"; + public int OutputSampleRate => _outputSampleRate; + internal string RequestedOutputDeviceId { get; } + internal string SelectedOutputDeviceId { get; private set; } = string.Empty; + internal string ActiveOutputDeviceId { get; private set; } = string.Empty; + internal string ActiveOutputDeviceName { get; private set; } = string.Empty; + internal bool DeviceSelectionFallback { get; private set; } + internal IReadOnlyList OutputDevices => _outputDevices; + internal AudioOutputConfigurationSnapshot OutputConfiguration => CaptureOutputConfiguration(); + public double CurrentTime + { + get + { + ThrowIfDisposed(); + if (_clockPaused) + return _pausedLogicalTime; + return LogicalSeconds(GetRawDspClock(), _accumulatedPausedSamples, _outputSampleRate); + } + } + + internal FmodSystem System + { + get + { + ThrowIfDisposed(); + return _system; + } + } + + internal FmodChannelGroup MasterGroup + { + get + { + ThrowIfDisposed(); + return _masterGroup; + } + } + + internal ulong ScheduleAfter(double leadSeconds) + { + ThrowIfDisposed(); + var leadSamples = (ulong)Math.Ceiling(Math.Max(0.0, leadSeconds) * _outputSampleRate); + return checked(GetRawDspClock() + leadSamples); + } + + internal double LogicalTimeForRawClock(ulong rawClock) => + _clockPaused + ? _pausedLogicalTime + : LogicalSeconds(rawClock, _accumulatedPausedSamples, _outputSampleRate); + + internal ulong RawClockForLogicalTime(double logicalTime) + { + ThrowIfDisposed(); + var activeSamples = (ulong)Math.Ceiling(Math.Max(0.0, logicalTime) * _outputSampleRate); + return checked(activeSamples + _accumulatedPausedSamples); + } + + internal void PauseClock() + { + ThrowIfDisposed(); + if (_clockPaused) + return; + _pauseStartedRawClock = GetRawDspClock(); + _pausedLogicalTime = LogicalSeconds( + _pauseStartedRawClock, + _accumulatedPausedSamples, + _outputSampleRate); + _clockPaused = true; + } + + internal void ResumeClock() + { + ThrowIfDisposed(); + if (!_clockPaused) + return; + var resumedAt = GetRawDspClock(); + if (resumedAt >= _pauseStartedRawClock) + _accumulatedPausedSamples = checked( + _accumulatedPausedSamples + resumedAt - _pauseStartedRawClock); + _clockPaused = false; + } + + internal void Update() + { + Check(_system.update(), "update Core system"); + ReportOutputEvents(); + } + + internal void SetMasterVolume(float normalizedVolume) => + Check(_masterGroup.setVolume(Mathf.Clamp01(normalizedVolume)), "set master volume"); + + internal FmodSound CreateFileStream(string path) + { + ThrowIfDisposed(); + if (string.IsNullOrWhiteSpace(path)) + throw new ArgumentException("FMOD stream path is empty.", nameof(path)); + if (!global::System.IO.File.Exists(path)) + throw new global::System.IO.FileNotFoundException("FMOD stream file was not found.", path); + var mode = FMOD.MODE.CREATESTREAM | + FMOD.MODE.NONBLOCKING | + FMOD.MODE.LOOP_OFF | + FMOD.MODE._2D | + FMOD.MODE.ACCURATETIME; + Check(_system.createSound(path, mode, out var sound), $"open nonblocking stream '{path}'"); + return sound; + } + + internal FMOD.OPENSTATE GetOpenState(FmodSound sound, out uint bufferedPercent) + { + ThrowIfDisposed(); + if (!sound.hasHandle()) + throw new InvalidOperationException("FMOD stream sound is not prepared."); + var result = sound.getOpenState(out var state, out bufferedPercent, out _, out _); + if (result != FMOD.RESULT.OK && result != FMOD.RESULT.ERR_NOTREADY) + Check(result, "query nonblocking stream state"); + return state; + } + + internal FmodSound CreateFileSample( + string path, + out long decodedPcmBytes, + out double durationSeconds) + { + ThrowIfDisposed(); + if (string.IsNullOrWhiteSpace(path)) + throw new ArgumentException("FMOD sample path is empty.", nameof(path)); + if (!global::System.IO.File.Exists(path)) + throw new global::System.IO.FileNotFoundException("FMOD sample file was not found.", path); + + var mode = FMOD.MODE.CREATESAMPLE | + FMOD.MODE.LOOP_OFF | + FMOD.MODE._2D | + FMOD.MODE.ACCURATETIME; + Check(_system.createSound(path, mode, out var sound), $"create sample '{path}'"); + try + { + Check(sound.getLength(out var pcmBytes, FMOD.TIMEUNIT.PCMBYTES), "query sample PCM size"); + Check(sound.getLength(out var durationMilliseconds, FMOD.TIMEUNIT.MS), "query sample duration"); + decodedPcmBytes = pcmBytes; + durationSeconds = Math.Max(0.01, durationMilliseconds / 1000.0); + return sound; + } + catch + { + sound.release(); + sound.clearHandle(); + throw; + } + } + + internal FmodChannel PlayScheduled(FmodSound sound, double leadSeconds, out ulong startRawClock) + { + ThrowIfDisposed(); + if (!sound.hasHandle()) + throw new InvalidOperationException("FMOD sound is not prepared."); + Check(_system.playSound(sound, _masterGroup, true, out var channel), "allocate playback channel"); + try + { + startRawClock = ScheduleAfter(leadSeconds); + Check(channel.setDelay(startRawClock, 0, false), "schedule playback channel"); + Check(channel.setPaused(false), "start playback channel"); + return channel; + } + catch + { + channel.stop(); + channel.clearHandle(); + throw; + } + } + + internal FmodChannel PlayScheduledAt(FmodSound sound, ulong startRawClock) + { + ThrowIfDisposed(); + if (!sound.hasHandle()) + throw new InvalidOperationException("FMOD sound is not prepared."); + Check(_system.playSound(sound, _masterGroup, true, out var channel), "allocate scheduled channel"); + try + { + Check(channel.setDelay(startRawClock, 0, false), "schedule playback channel"); + Check(channel.setPaused(false), "start scheduled playback channel"); + return channel; + } + catch + { + channel.stop(); + channel.clearHandle(); + throw; + } + } + + internal FmodChannel PlayImmediate(FmodSound sound) + { + ThrowIfDisposed(); + if (!sound.hasHandle()) + throw new InvalidOperationException("FMOD sound is not prepared."); + Check(_system.playSound(sound, _masterGroup, false, out var channel), "play reactive keysound"); + return channel; + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + if (_system.hasHandle()) + { + _system.setCallback(null, 0); + _system.close(); + _system.release(); + _system.clearHandle(); + } + _masterGroup.clearHandle(); + } + + internal static double LogicalSeconds( + ulong rawClock, + ulong pausedSamples, + int sampleRate) + { + if (sampleRate <= 0) + throw new ArgumentOutOfRangeException(nameof(sampleRate)); + var activeSamples = rawClock >= pausedSamples ? rawClock - pausedSamples : 0; + return activeSamples / (double)sampleRate; + } + + internal static void Check(FMOD.RESULT result, string operation) + { + if (result == FMOD.RESULT.OK) + return; + throw new InvalidOperationException( + $"FMOD could not {operation}: {result} ({FMOD.Error.String(result)})"); + } + + private ulong GetRawDspClock() + { + Check(_masterGroup.getDSPClock(out var dspClock, out _), "read DSP clock"); + return dspClock; + } + + private FMOD.RESULT OnOutputEvent( + IntPtr system, + FMOD.SYSTEM_CALLBACK_TYPE type, + IntPtr commandData1, + IntPtr commandData2, + IntPtr userData) + { + // OUTPUTUNDERRUN may arrive on the mixer thread. Keep this callback lock-free and + // defer Unity logging and native driver queries to the next main-thread Update. + if ((type & FMOD.SYSTEM_CALLBACK_TYPE.DEVICEREINITIALIZE) != 0) + Interlocked.Increment(ref _deviceReinitializeCount); + if ((type & FMOD.SYSTEM_CALLBACK_TYPE.OUTPUTUNDERRUN) != 0) + Interlocked.Increment(ref _outputUnderrunCount); + return FMOD.RESULT.OK; + } + + private void ReportOutputEvents() + { + var reinitializations = Volatile.Read(ref _deviceReinitializeCount); + if (reinitializations != _reportedDeviceReinitializeCount) + { + var delta = reinitializations - _reportedDeviceReinitializeCount; + _reportedDeviceReinitializeCount = reinitializations; + RefreshOutputDevices(); + CaptureSelectedOutputDevice(); + Debug.LogWarning( + $"[ProjectQuint Audio] FMOD output device reinitialized {delta} time(s); " + + $"automatic device switching remains enabled. {DescribeOutputDevice()}."); + } + + var underruns = Volatile.Read(ref _outputUnderrunCount); + if (underruns != _reportedOutputUnderrunCount) + { + var delta = underruns - _reportedOutputUnderrunCount; + _reportedOutputUnderrunCount = underruns; + Debug.LogWarning( + $"[ProjectQuint Audio] FMOD output underrun detected {delta} time(s), " + + $"{underruns} total. Increase DSP BUFFER if this repeats."); + } + } + + internal void RefreshOutputDevices() + { + ThrowIfDisposed(); + if (_noSoundNrt) + { + _outputDevices = new[] { AudioOutputDeviceInfo.SystemDefault }; + return; + } + + Check(_system.getNumDrivers(out var driverCount), "enumerate output devices"); + var devices = new List(Math.Max(1, driverCount + 1)) + { + AudioOutputDeviceInfo.SystemDefault, + }; + for (var driverIndex = 0; driverIndex < driverCount; driverIndex++) + { + var result = _system.getDriverInfo( + driverIndex, + out var name, + 512, + out Guid guid, + out var sampleRate, + out _, + out var channels); + if (result != FMOD.RESULT.OK) + { + Debug.LogWarning( + $"[ProjectQuint Audio] Could not inspect output device {driverIndex}: " + + $"{result} ({FMOD.Error.String(result)})."); + continue; + } + + devices.Add(new AudioOutputDeviceInfo( + guid.ToString("D"), + name, + sampleRate, + channels)); + } + _outputDevices = devices.ToArray(); + } + + private void SelectPreferredOutputDevice(string preferredOutputDeviceId) + { + Check(_system.getNumDrivers(out var driverCount), "enumerate output devices before initialization"); + for (var driverIndex = 0; driverIndex < driverCount; driverIndex++) + { + var result = _system.getDriverInfo( + driverIndex, + out Guid guid, + out _, + out _, + out _); + if (result != FMOD.RESULT.OK || + !string.Equals( + guid.ToString("D"), + preferredOutputDeviceId, + StringComparison.OrdinalIgnoreCase)) + continue; + + Check(_system.setDriver(driverIndex), $"select output device {preferredOutputDeviceId}"); + SelectedOutputDeviceId = guid.ToString("D"); + return; + } + + DeviceSelectionFallback = true; + SelectedOutputDeviceId = string.Empty; + } + + private void CaptureSelectedOutputDevice() + { + if (_noSoundNrt) + { + SelectedOutputDeviceId = string.Empty; + ActiveOutputDeviceId = string.Empty; + ActiveOutputDeviceName = "NO SOUND"; + return; + } + + if (_system.getDriver(out var driverIndex) != FMOD.RESULT.OK || + _system.getDriverInfo( + driverIndex, + out var activeName, + 512, + out Guid guid, + out _, + out _, + out _) != FMOD.RESULT.OK) + { + SelectedOutputDeviceId = string.Empty; + ActiveOutputDeviceId = string.Empty; + ActiveOutputDeviceName = string.Empty; + DeviceSelectionFallback = true; + return; + } + + ActiveOutputDeviceId = guid.ToString("D"); + ActiveOutputDeviceName = string.IsNullOrWhiteSpace(activeName) + ? "UNKNOWN DEVICE" + : activeName.Trim(); + SelectedOutputDeviceId = + DeviceSelectionFallback || string.IsNullOrWhiteSpace(RequestedOutputDeviceId) + ? string.Empty + : ActiveOutputDeviceId; + DeviceSelectionFallback = !string.Equals( + ActiveOutputDeviceId, + RequestedOutputDeviceId, + StringComparison.OrdinalIgnoreCase) && + !string.IsNullOrWhiteSpace(RequestedOutputDeviceId); + } + + private string DescribeOutputDevice() + { + var snapshot = CaptureOutputConfiguration(); + return $"FMOD {snapshot.OutputType} output '{snapshot.DeviceName}', " + + $"mix {snapshot.SampleRate:N0} Hz, DSP {snapshot.BufferLength} x {snapshot.BufferCount}"; + } + + private AudioOutputConfigurationSnapshot CaptureOutputConfiguration() + { + Check(_system.getOutput(out var outputType), "query output type"); + Check(_system.getDSPBufferSize(out var bufferLength, out var bufferCount), "query FMOD DSP buffer"); + var driverName = "default device"; + if (_system.getDriver(out var driverIndex) == FMOD.RESULT.OK && + _system.getDriverInfo( + driverIndex, + out var detectedName, + 256, + out _, + out _, + out _, + out _) == FMOD.RESULT.OK) + { + if (!string.IsNullOrWhiteSpace(detectedName)) + driverName = detectedName; + } + + return new AudioOutputConfigurationSnapshot( + FmodCoreSongAudioBackendFactory.FmodBackendId, + outputType.ToString(), + driverName, + checked((int)bufferLength), + bufferCount, + _outputSampleRate, + ActiveOutputDeviceId); + } + + private void ThrowIfDisposed() + { + if (_disposed) + throw new ObjectDisposedException(nameof(FmodCoreAudioContext)); + } + } + + public sealed class FmodCoreSongAudioBackendFactory : ISongAudioBackendFactory, IDisposable + { + public const string FmodBackendId = "FMOD_CORE"; + private readonly FmodCoreAudioContext _context; + private bool _disposed; + + public FmodCoreSongAudioBackendFactory( + uint dspBufferLength = 0, + string preferredOutputDeviceId = "") + : this(dspBufferLength, false, preferredOutputDeviceId) + { + } + + internal FmodCoreSongAudioBackendFactory(uint dspBufferLength, bool noSoundNrt) => + _context = new FmodCoreAudioContext(dspBufferLength, noSoundNrt); + + private FmodCoreSongAudioBackendFactory( + uint dspBufferLength, + bool noSoundNrt, + string preferredOutputDeviceId) => + _context = new FmodCoreAudioContext( + dspBufferLength, + noSoundNrt, + preferredOutputDeviceId); + + public string BackendId => FmodBackendId; + public IAudioClock Clock => _context; + public AudioOutputConfigurationSnapshot OutputConfiguration => _context.OutputConfiguration; + public IReadOnlyList OutputDevices => _context.OutputDevices; + public string SelectedOutputDeviceId => _context.SelectedOutputDeviceId; + public string ActiveOutputDeviceId => _context.ActiveOutputDeviceId; + public string ActiveOutputDeviceName => _context.ActiveOutputDeviceName; + public bool DeviceSelectionFallback => _context.DeviceSelectionFallback; + + internal FmodAudioSyncMetronome CreateAudioSyncMetronome(float volume) => + new FmodAudioSyncMetronome(_context, volume); + + internal void UpdateAudioSystem() => _context.Update(); + + public void RefreshOutputDevices() + { + if (_disposed) + throw new ObjectDisposedException(nameof(FmodCoreSongAudioBackendFactory)); + _context.RefreshOutputDevices(); + } + + public ISongAudioBackend Create(GameObject resourceOwner) + { + if (_disposed) + throw new ObjectDisposedException(nameof(FmodCoreSongAudioBackendFactory)); + if (resourceOwner == null) + throw new ArgumentNullException(nameof(resourceOwner)); + return new FmodCoreSongAudioBackend(_context); + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + _context.Dispose(); + } + } + + internal sealed class FmodCoreSongAudioBackend : ISongAudioBackend + { + private readonly FmodCoreAudioContext _context; + private readonly AudioAssetStore _assets = new AudioAssetStore(); + private readonly PremixedSongRenderer _premix; + private readonly FmodReactiveSoundBank _reactiveBank; + private readonly FmodReactivePlaybackEngine _reactivePlayback; + private PlayableChart _chart; + private FmodSound _sound; + private FmodChannel _channel; + private long _premixedStreamBytes; + private string _loadError = string.Empty; + private string _fmodError = string.Empty; + private bool _paused; + private bool _loadCompleted; + private bool _reactiveLoaded; + private bool _activeReactivePlayback; + private bool _disposed; + private bool _runtimeErrorReported; + + public FmodCoreSongAudioBackend( + FmodCoreAudioContext context, + PremixedSongCache premixCache = null) + { + _context = context ?? throw new ArgumentNullException(nameof(context)); + _premix = new PremixedSongRenderer(premixCache); + _reactiveBank = new FmodReactiveSoundBank(_context); + _reactivePlayback = new FmodReactivePlaybackEngine(_context); + } + + public string BackendId => FmodCoreSongAudioBackendFactory.FmodBackendId; + public IAudioClock Clock => _context; + public double StartDspTime { get; private set; } + public int LoadedClipCount => _reactiveLoaded + ? _reactiveBank.LoadedSoundIdCount + : _sound.hasHandle() ? 1 : 0; + public int MissingClipCount => _reactiveLoaded + ? _reactiveBank.MissingSoundCount + : _assets.MissingClipCount; + public int DecodeFailedClipCount => _reactiveLoaded + ? _reactiveBank.DecodeFailedSoundCount + : _assets.DecodeFailedClipCount; + public int SilentPlaceholderCount => _reactiveLoaded + ? _reactiveBank.SilentPlaceholderCount + : _assets.SilentPlaceholderCount; + public int ScheduledSourceCount => _activeReactivePlayback + ? _reactivePlayback.ActiveVoiceCount + : _channel.hasHandle() ? 1 : 0; + public int PendingScheduledEventCount => _activeReactivePlayback + ? _reactivePlayback.PendingEventCount + : 0; + public int DroppedLateEventCount => _activeReactivePlayback + ? _reactivePlayback.DroppedLateEventCount + : 0; + public bool SmoothMixReady => _sound.hasHandle() && string.IsNullOrEmpty(_fmodError); + public string SmoothMixError => string.IsNullOrWhiteSpace(_fmodError) ? _premix.Error : _fmodError; + public bool SmoothMixCacheHit => _premix.CacheHit; + public string SmoothMixCacheStatus => _premix.CacheStatus; + public string LoadError => _loadError; + public long EstimatedDecodedPcmBytes => _reactiveLoaded + ? _reactiveBank.EstimatedDecodedPcmBytes + : _assets.EstimatedDecodedPcmBytes; + // CREATESTREAM keeps the decoded song out of managed memory. This value intentionally + // reports zero resident premixed PCM; encoded file size is exposed separately in logs. + public long EstimatedPremixedPcmBytes => 0; + public long EncodedPremixedStreamBytes => _premixedStreamBytes; + public long EstimatedTotalPcmBytes => checked(EstimatedDecodedPcmBytes + EstimatedPremixedPcmBytes); + + public IEnumerator Load( + PlayableChart chart, + Action progress, + Func cancellationRequested = null, + bool reactiveKeySoundsEnabled = true) + { + if (chart == null) + throw new ArgumentNullException(nameof(chart)); + ThrowIfDisposed(); + ResetLoadedResources(); + _chart = chart; + if (reactiveKeySoundsEnabled) + { + yield return _reactiveBank.Load(chart, progress, cancellationRequested); + if (cancellationRequested?.Invoke() == true) + yield break; + if (!string.IsNullOrWhiteSpace(_reactiveBank.Error)) + { + _loadError = _reactiveBank.Error; + yield break; + } + _reactiveLoaded = true; + _loadCompleted = true; + progress?.Invoke( + _reactiveBank.LoadedSoundIdCount, + _reactiveBank.LoadedSoundIdCount, + "FMOD Core native keysound bank ready"); + yield break; + } + + _assets.PrepareForSequentialMix(); + _loadCompleted = true; + progress?.Invoke(0, 0, "FMOD Core sequential smooth mix selected"); + } + + public void PrepareReactivePlayback(double leadSeconds = 0.35) + { + ThrowIfDisposed(); + EnsureChartReady(); + if (!_reactiveLoaded) + throw new InvalidOperationException("FMOD reactive keysounds were not loaded for this song."); + _reactivePlayback.Prepare(_chart, _reactiveBank); + } + + public IEnumerator PrepareSmoothPlaybackMix( + Action progress, + Func cancellationRequested = null) + { + ThrowIfDisposed(); + EnsureChartReady(); + yield return _premix.Prepare( + _chart, + _assets, + progress, + cancellationRequested, + PremixedSongOutput.FmodStreamFile, + _context.OutputSampleRate); + if (!_premix.IsReady || cancellationRequested?.Invoke() == true) + yield break; + + var streamEntry = _premix.TakeStreamEntry(); + if (!streamEntry.IsValid) + { + _fmodError = "Smooth playback FLAC stream was not produced."; + yield break; + } + if (!TryCreatePremixStream(streamEntry.Path)) + yield break; + + while (true) + { + if (cancellationRequested?.Invoke() == true) + { + ReleaseSound(); + yield break; + } + if (!TryUpdatePremixOpenState(out var state, out var bufferedPercent)) + yield break; + progress?.Invoke((int)bufferedPercent, 100, "Opening FMOD FLAC stream"); + if (state == FMOD.OPENSTATE.READY) + break; + if (state == FMOD.OPENSTATE.ERROR) + { + ReleaseSound(); + _fmodError = "FMOD could not open the FLAC premix stream."; + yield break; + } + yield return null; + } + _premixedStreamBytes = streamEntry.EncodedBytes; + progress?.Invoke(100, 100, "FMOD Core FLAC stream ready"); + Debug.Log( + $"[ProjectQuint Audio] FMOD CREATESTREAM | NONBLOCKING ready: " + + $"{streamEntry.FrameCount:N0} frames @ {streamEntry.SampleRate:N0} Hz, " + + $"{streamEntry.EncodedBytes / (1024d * 1024d):0.0} MiB FLAC, " + + $"cache {_premix.CacheStatus}; managed premixed PCM: 0 bytes"); + streamEntry = default; + } + + private bool TryCreatePremixStream(string path) + { + try + { + _sound = _context.CreateFileStream(path); + return true; + } + catch (Exception exception) + { + ReleaseSound(); + _fmodError = exception.Message; + return false; + } + } + + private bool TryUpdatePremixOpenState(out FMOD.OPENSTATE state, out uint bufferedPercent) + { + state = FMOD.OPENSTATE.ERROR; + bufferedPercent = 0; + try + { + _context.Update(); + state = _context.GetOpenState(_sound, out bufferedPercent); + return true; + } + catch (Exception exception) + { + ReleaseSound(); + _fmodError = exception.Message; + return false; + } + } + + public void BeginPlayback(bool reactiveKeySoundsEnabled, double leadSeconds = 0.35) + { + ThrowIfDisposed(); + EnsureChartReady(); + if (reactiveKeySoundsEnabled) + { + if (!_reactiveLoaded) + throw new InvalidOperationException("FMOD reactive keysounds were not loaded for this song."); + StopChannel(); + _context.Update(); + _reactivePlayback.Begin(leadSeconds); + _activeReactivePlayback = true; + StartDspTime = _reactivePlayback.StartDspTime; + return; + } + if (!_sound.hasHandle()) + throw new InvalidOperationException( + string.IsNullOrWhiteSpace(_fmodError) + ? "FMOD smooth playback sound is not prepared." + : _fmodError); + + StopChannel(); + _context.Update(); + _channel = _context.PlayScheduled(_sound, leadSeconds, out var startRawClock); + _activeReactivePlayback = false; + StartDspTime = _context.LogicalTimeForRawClock(startRawClock); + } + + public void Update() + { + if (_disposed) + return; + try + { + _context.Update(); + if (_activeReactivePlayback) + _reactivePlayback.Update(); + } + catch (Exception exception) + { + _fmodError = exception.Message; + if (_runtimeErrorReported) + return; + _runtimeErrorReported = true; + Debug.LogError($"[ProjectQuint Audio] FMOD Core update failed: {_fmodError}"); + } + } + + public void PlayKeySound(string soundId, int lane) + { + if (_activeReactivePlayback) + _reactivePlayback.PlayKeySound(soundId, lane); + } + + public void SetMasterVolume(float normalizedVolume) + { + ThrowIfDisposed(); + _context.SetMasterVolume(normalizedVolume); + } + + public void Pause() + { + ThrowIfDisposed(); + if (_paused) + return; + if (_activeReactivePlayback) + _reactivePlayback.Pause(); + else if (_channel.hasHandle()) + FmodCoreAudioContext.Check(_channel.setPaused(true), "pause playback channel"); + _context.PauseClock(); + _paused = true; + } + + public void Resume() + { + ThrowIfDisposed(); + if (!_paused) + return; + _context.ResumeClock(); + if (_activeReactivePlayback) + _reactivePlayback.Resume(); + else if (_channel.hasHandle()) + FmodCoreAudioContext.Check(_channel.setPaused(false), "resume playback channel"); + _paused = false; + } + + public void Stop() + { + if (_disposed) + return; + if (_paused) + { + _context.ResumeClock(); + _paused = false; + } + _reactivePlayback.StopPlayback(); + _activeReactivePlayback = false; + StopChannel(); + StartDspTime = 0; + } + + public void Dispose() + { + if (_disposed) + return; + Stop(); + ReleaseSound(); + _reactivePlayback.Clear(); + _reactiveBank.Dispose(); + _premix.Dispose(); + _assets.Dispose(); + _chart = null; + _disposed = true; + } + + private void ResetLoadedResources() + { + Stop(); + ReleaseSound(); + _reactivePlayback.Clear(); + _reactiveBank.Clear(); + _premix.Clear(); + _assets.Clear(); + _chart = null; + _premixedStreamBytes = 0; + _loadError = string.Empty; + _fmodError = string.Empty; + _runtimeErrorReported = false; + _loadCompleted = false; + _reactiveLoaded = false; + _activeReactivePlayback = false; + } + + private void StopChannel() + { + if (!_channel.hasHandle()) + return; + _channel.stop(); + _channel.clearHandle(); + } + + private void ReleaseSound() + { + if (!_sound.hasHandle()) + return; + _sound.release(); + _sound.clearHandle(); + _premixedStreamBytes = 0; + } + + private void EnsureChartReady() + { + if (_chart == null || !_loadCompleted) + { + throw new InvalidOperationException( + string.IsNullOrWhiteSpace(_loadError) + ? "FMOD audio assets are not ready." + : _loadError); + } + } + + private void ThrowIfDisposed() + { + if (_disposed) + throw new ObjectDisposedException(nameof(FmodCoreSongAudioBackend)); + } + + } +} diff --git a/Assets/ProjectQuint/Runtime/Unity/Audio/FmodCoreAudioBackend.cs.meta b/Assets/ProjectQuint/Runtime/Unity/Audio/FmodCoreAudioBackend.cs.meta new file mode 100644 index 0000000..8877ceb --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Audio/FmodCoreAudioBackend.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: de5e1984f3a2a43ab89ab705fff95575 \ No newline at end of file diff --git a/Assets/ProjectQuint/Runtime/Unity/Audio/FmodCoreReactivePlayback.cs b/Assets/ProjectQuint/Runtime/Unity/Audio/FmodCoreReactivePlayback.cs new file mode 100644 index 0000000..05c9457 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Audio/FmodCoreReactivePlayback.cs @@ -0,0 +1,434 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using ProjectQuint.Core.Charts; +using UnityEngine; +using FmodChannel = FMOD.Channel; +using FmodSound = FMOD.Sound; + +namespace ProjectQuint.Audio +{ + internal readonly struct FmodReactiveSoundAsset + { + public FmodReactiveSoundAsset(FmodSound sound, long decodedPcmBytes, double durationSeconds) + { + Sound = sound; + DecodedPcmBytes = decodedPcmBytes; + DurationSeconds = Math.Max(0.01, durationSeconds); + } + + public FmodSound Sound { get; } + public long DecodedPcmBytes { get; } + public double DurationSeconds { get; } + } + + /// + /// Per-song native sample bank for reactive mode. FMOD decodes source files directly, so + /// keysound ON does not create a second Unity AudioClip graph before native playback. + /// + internal sealed class FmodReactiveSoundBank : IDisposable + { + private readonly FmodCoreAudioContext _context; + private readonly Dictionary _soundsById = + new Dictionary(StringComparer.Ordinal); + private readonly Dictionary _soundsByPath = + new Dictionary(StringComparer.OrdinalIgnoreCase); + private bool _disposed; + + public FmodReactiveSoundBank(FmodCoreAudioContext context) => + _context = context ?? throw new ArgumentNullException(nameof(context)); + + public int LoadedSoundIdCount => _soundsById.Count; + public int MissingSoundCount { get; private set; } + public int DecodeFailedSoundCount { get; private set; } + public int SilentPlaceholderCount { get; private set; } + public long EstimatedDecodedPcmBytes { get; private set; } + public string Error { get; private set; } = string.Empty; + public bool IsReady { get; private set; } + + public IEnumerator Load( + PlayableChart chart, + Action progress, + Func cancellationRequested = null) + { + if (chart == null) + throw new ArgumentNullException(nameof(chart)); + ThrowIfDisposed(); + Clear(); + + var soundIds = new HashSet(StringComparer.Ordinal); + foreach (var note in chart.Notes) + if (!string.IsNullOrWhiteSpace(note.SoundId)) soundIds.Add(note.SoundId); + foreach (var item in chart.BackgroundAudioEvents) + if (!string.IsNullOrWhiteSpace(item.SoundId)) soundIds.Add(item.SoundId); + foreach (var item in chart.LaneSoundEvents) + if (!string.IsNullOrWhiteSpace(item.SoundId)) soundIds.Add(item.SoundId); + var orderedIds = soundIds.OrderBy(value => value, StringComparer.Ordinal).ToList(); + + for (var index = 0; index < orderedIds.Count; index++) + { + if (cancellationRequested?.Invoke() == true) + { + ReleaseSounds(); + yield break; + } + + var soundId = orderedIds[index]; + progress?.Invoke(index, orderedIds.Count, soundId); + if (!chart.AudioResources.TryGetValue(soundId, out var declaredPath)) + { + MissingSoundCount++; + continue; + } + + var resolvedPath = AudioAssetStore.ResolveReferencedAudioPath( + chart.Metadata.SourcePath, + declaredPath); + if (string.IsNullOrWhiteSpace(resolvedPath)) + { + MissingSoundCount++; + continue; + } + + resolvedPath = Path.GetFullPath(resolvedPath); + if (!AudioAssetStore.TryClassifyWaveResource( + resolvedPath, + soundId, + out var resourceStatus, + out var resourceError)) + { + DecodeFailedSoundCount++; + Debug.LogWarning( + $"[ProjectQuint Audio] FMOD skipped keysound {soundId} '{resolvedPath}': " + + resourceError); + continue; + } + if (resourceStatus == AudioResourceLoadStatus.SilentPlaceholder) + { + SilentPlaceholderCount++; + continue; + } + + if (_soundsByPath.TryGetValue(resolvedPath, out var sharedAsset)) + { + _soundsById[soundId] = sharedAsset; + continue; + } + + FmodSound sound = default; + try + { + sound = _context.CreateFileSample( + resolvedPath, + out var decodedPcmBytes, + out var durationSeconds); + if (decodedPcmBytes > AudioAssetStore.DefaultDecodedPcmBudgetBytes - EstimatedDecodedPcmBytes) + { + sound.release(); + sound.clearHandle(); + Error = + $"Decoded FMOD keysounds exceed the per-song audio budget " + + $"({FormatBytes(AudioAssetStore.DefaultDecodedPcmBudgetBytes)}). Last asset: {soundId}."; + ReleaseSounds(); + yield break; + } + + var asset = new FmodReactiveSoundAsset(sound, decodedPcmBytes, durationSeconds); + _soundsByPath[resolvedPath] = asset; + _soundsById[soundId] = asset; + EstimatedDecodedPcmBytes = checked(EstimatedDecodedPcmBytes + decodedPcmBytes); + } + catch (Exception exception) + { + if (sound.hasHandle()) + sound.release(); + DecodeFailedSoundCount++; + Debug.LogWarning( + $"[ProjectQuint Audio] FMOD skipped keysound {soundId} '{resolvedPath}': " + + exception.Message); + } + + _context.Update(); + yield return null; + } + + if (cancellationRequested?.Invoke() == true) + { + ReleaseSounds(); + yield break; + } + + if (orderedIds.Count > 0 && + _soundsById.Count == 0 && + MissingSoundCount + DecodeFailedSoundCount > 0) + { + Error = + $"No playable FMOD keysounds could be loaded " + + $"({MissingSoundCount} missing, {DecodeFailedSoundCount} decode failed)."; + ReleaseSounds(); + yield break; + } + + IsReady = true; + progress?.Invoke(orderedIds.Count, orderedIds.Count, string.Empty); + } + + public bool TryGet(string soundId, out FmodReactiveSoundAsset asset) => + _soundsById.TryGetValue(soundId ?? string.Empty, out asset) && asset.Sound.hasHandle(); + + public void Clear() + { + ThrowIfDisposed(); + ReleaseSounds(); + MissingSoundCount = 0; + DecodeFailedSoundCount = 0; + SilentPlaceholderCount = 0; + EstimatedDecodedPcmBytes = 0; + Error = string.Empty; + IsReady = false; + } + + public void Dispose() + { + if (_disposed) + return; + ReleaseSounds(); + _disposed = true; + } + + private void ReleaseSounds() + { + foreach (var asset in _soundsByPath.Values) + if (asset.Sound.hasHandle()) asset.Sound.release(); + _soundsByPath.Clear(); + _soundsById.Clear(); + EstimatedDecodedPcmBytes = 0; + IsReady = false; + } + + private static string FormatBytes(long bytes) => + bytes >= 1024L * 1024L + ? $"{bytes / (1024d * 1024d):0.#} MiB" + : $"{bytes / 1024d:0.#} KiB"; + + private void ThrowIfDisposed() + { + if (_disposed) + throw new ObjectDisposedException(nameof(FmodReactiveSoundBank)); + } + } + + /// + /// Schedules BGM objects against the FMOD DSP clock while note keysounds remain immediate. + /// Only a short look-ahead is allocated, and future reservations are rebuilt after Pause. + /// + internal sealed class FmodReactivePlaybackEngine + { + internal const double SchedulingHorizonSeconds = 1.0; + internal const double LateEventDropThresholdSeconds = 0.100; + private const double MinimumScheduleLeadSeconds = 0.005; + + private readonly FmodCoreAudioContext _context; + private readonly List _backgroundEvents = new List(); + private readonly List _voices = new List(); + private FmodReactiveSoundBank _bank; + private int _nextBackgroundEvent; + private bool _prepared; + private bool _running; + private bool _paused; + + public FmodReactivePlaybackEngine(FmodCoreAudioContext context) => + _context = context ?? throw new ArgumentNullException(nameof(context)); + + public double StartDspTime { get; private set; } + public int ActiveVoiceCount => _voices.Count; + public int PendingEventCount => Math.Max(0, _backgroundEvents.Count - _nextBackgroundEvent); + public int DroppedLateEventCount { get; private set; } + + public void Prepare(PlayableChart chart, FmodReactiveSoundBank bank) + { + if (chart == null) + throw new ArgumentNullException(nameof(chart)); + if (bank == null || !bank.IsReady) + throw new InvalidOperationException("FMOD reactive sound bank is not ready."); + + Clear(); + _bank = bank; + _backgroundEvents.AddRange(AudioPlaybackTimeline.Build(chart, reactiveKeySoundsEnabled: true)); + _prepared = true; + } + + public void Begin(double leadSeconds) + { + if (!_prepared || _bank == null || !_bank.IsReady) + throw new InvalidOperationException("FMOD reactive playback was not prepared."); + + StopVoices(); + _nextBackgroundEvent = 0; + DroppedLateEventCount = 0; + _paused = false; + _running = true; + var startRawClock = _context.ScheduleAfter(leadSeconds); + StartDspTime = _context.LogicalTimeForRawClock(startRawClock); + ScheduleBackgroundEvents(); + } + + public void Update() + { + PruneFinishedVoices(); + if (_running && !_paused) + ScheduleBackgroundEvents(); + } + + public void PlayKeySound(string soundId, int lane) + { + if (!_running || _paused || lane < 0 || lane >= 8 || string.IsNullOrWhiteSpace(soundId) || + _bank == null || !_bank.TryGet(soundId, out var asset)) + return; + + var channel = _context.PlayImmediate(asset.Sound); + _voices.Add(new Voice(channel, reactive: true, eventIndex: -1, scheduledSongTime: double.NaN)); + } + + public void Pause() + { + if (!_running || _paused) + return; + + var songTime = _context.CurrentTime - StartDspTime; + var rewindIndex = _nextBackgroundEvent; + for (var index = _voices.Count - 1; index >= 0; index--) + { + var voice = _voices[index]; + if (!voice.Reactive && voice.ScheduledSongTime > songTime) + { + StopVoice(voice.Channel); + rewindIndex = Math.Min(rewindIndex, voice.EventIndex); + _voices.RemoveAt(index); + continue; + } + if (!TrySetPaused(voice.Channel, true)) + _voices.RemoveAt(index); + } + _nextBackgroundEvent = rewindIndex; + _paused = true; + } + + public void Resume() + { + if (!_running || !_paused) + return; + for (var index = _voices.Count - 1; index >= 0; index--) + if (!TrySetPaused(_voices[index].Channel, false)) _voices.RemoveAt(index); + _paused = false; + ScheduleBackgroundEvents(); + } + + public void StopPlayback() + { + StopVoices(); + _nextBackgroundEvent = 0; + DroppedLateEventCount = 0; + StartDspTime = 0; + _running = false; + _paused = false; + } + + public void Clear() + { + StopPlayback(); + _backgroundEvents.Clear(); + _bank = null; + _prepared = false; + } + + private void ScheduleBackgroundEvents() + { + var currentLogicalTime = _context.CurrentTime; + var currentSongTime = currentLogicalTime - StartDspTime; + var horizonSongTime = currentSongTime + SchedulingHorizonSeconds; + while (_nextBackgroundEvent < _backgroundEvents.Count) + { + var eventIndex = _nextBackgroundEvent; + var item = _backgroundEvents[eventIndex]; + if (item.Time > horizonSongTime) + break; + _nextBackgroundEvent++; + + if (!_bank.TryGet(item.SoundId, out var asset)) + continue; + var scheduledLogicalTime = StartDspTime + item.Time; + if (scheduledLogicalTime < currentLogicalTime - LateEventDropThresholdSeconds) + { + DroppedLateEventCount++; + continue; + } + + var rawClock = scheduledLogicalTime <= currentLogicalTime + MinimumScheduleLeadSeconds + ? _context.ScheduleAfter(MinimumScheduleLeadSeconds) + : _context.RawClockForLogicalTime(scheduledLogicalTime); + var channel = _context.PlayScheduledAt(asset.Sound, rawClock); + _voices.Add(new Voice(channel, reactive: false, eventIndex, item.Time)); + } + } + + private void PruneFinishedVoices() + { + for (var index = _voices.Count - 1; index >= 0; index--) + { + var result = _voices[index].Channel.isPlaying(out var playing); + if (result == FMOD.RESULT.OK && playing) + continue; + if (result != FMOD.RESULT.OK && !IsExpiredChannel(result)) + FmodCoreAudioContext.Check(result, "query reactive playback channel"); + _voices.RemoveAt(index); + } + } + + private void StopVoices() + { + foreach (var voice in _voices) + StopVoice(voice.Channel); + _voices.Clear(); + } + + private static void StopVoice(FmodChannel channel) + { + var result = channel.stop(); + if (result != FMOD.RESULT.OK && !IsExpiredChannel(result)) + FmodCoreAudioContext.Check(result, "stop reactive playback channel"); + } + + private static bool TrySetPaused(FmodChannel channel, bool paused) + { + var result = channel.setPaused(paused); + if (result == FMOD.RESULT.OK) + return true; + if (IsExpiredChannel(result)) + return false; + FmodCoreAudioContext.Check(result, paused ? "pause reactive playback channel" : "resume reactive playback channel"); + return false; + } + + private static bool IsExpiredChannel(FMOD.RESULT result) => + result == FMOD.RESULT.ERR_INVALID_HANDLE || result == FMOD.RESULT.ERR_CHANNEL_STOLEN; + + private readonly struct Voice + { + public Voice(FmodChannel channel, bool reactive, int eventIndex, double scheduledSongTime) + { + Channel = channel; + Reactive = reactive; + EventIndex = eventIndex; + ScheduledSongTime = scheduledSongTime; + } + + public FmodChannel Channel { get; } + public bool Reactive { get; } + public int EventIndex { get; } + public double ScheduledSongTime { get; } + } + } +} diff --git a/Assets/ProjectQuint/Runtime/Unity/Audio/FmodCoreReactivePlayback.cs.meta b/Assets/ProjectQuint/Runtime/Unity/Audio/FmodCoreReactivePlayback.cs.meta new file mode 100644 index 0000000..0285fd1 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Audio/FmodCoreReactivePlayback.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: ad2e4562a96c24a5abd4d789faebe347 \ No newline at end of file diff --git a/Assets/ProjectQuint/Runtime/Unity/Audio/PremixedSongCache.cs b/Assets/ProjectQuint/Runtime/Unity/Audio/PremixedSongCache.cs new file mode 100644 index 0000000..86c4dfa --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Audio/PremixedSongCache.cs @@ -0,0 +1,721 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using ProjectQuint.Core.Charts; +using ProjectQuint.Persistence; + +namespace ProjectQuint.Audio +{ + internal enum PremixedSongCacheTier + { + Automatic, + Pinned, + } + + internal readonly struct PremixedSongCacheEntry + { + public PremixedSongCacheEntry( + string path, + int sampleRate, + int channels, + long frameCount, + long encodedBytes, + PremixedSongCacheTier tier) + { + Path = path ?? string.Empty; + SampleRate = sampleRate; + Channels = channels; + FrameCount = frameCount; + EncodedBytes = Math.Max(0, encodedBytes); + Tier = tier; + } + + public string Path { get; } + public int SampleRate { get; } + public int Channels { get; } + public long FrameCount { get; } + public long EncodedBytes { get; } + public PremixedSongCacheTier Tier { get; } + public bool IsValid => !string.IsNullOrWhiteSpace(Path) && File.Exists(Path) && + SampleRate > 0 && Channels > 0 && FrameCount > 0; + } + + internal readonly struct PremixedSongCacheSnapshot + { + public PremixedSongCacheSnapshot( + int automaticEntryCount, + long automaticBytes, + long automaticMaximumBytes, + int pinnedEntryCount, + long pinnedBytes) + { + AutomaticEntryCount = Math.Max(0, automaticEntryCount); + AutomaticBytes = Math.Max(0, automaticBytes); + AutomaticMaximumBytes = Math.Max(1, automaticMaximumBytes); + PinnedEntryCount = Math.Max(0, pinnedEntryCount); + PinnedBytes = Math.Max(0, pinnedBytes); + } + + public int AutomaticEntryCount { get; } + public long AutomaticBytes { get; } + public long AutomaticMaximumBytes { get; } + public int PinnedEntryCount { get; } + public long PinnedBytes { get; } + public int EntryCount => AutomaticEntryCount + PinnedEntryCount; + public long TotalBytes => AutomaticBytes + PinnedBytes; + public long MaximumBytes => AutomaticMaximumBytes; + public string Label => + $"AUTO {AutomaticEntryCount:N0} {FormatBytes(AutomaticBytes)} / {FormatBytes(AutomaticMaximumBytes)}" + + $" // PINNED {PinnedEntryCount:N0} {FormatBytes(PinnedBytes)}"; + + internal static string FormatBytes(long bytes) => bytes >= 1024L * 1024L * 1024L + ? $"{bytes / (1024d * 1024d * 1024d):0.00} GiB" + : $"{bytes / (1024d * 1024d):0.0} MiB"; + } + + internal readonly struct PremixedSongCacheReadResult + { + public PremixedSongCacheReadResult(bool hit, PremixedSongCacheEntry entry, string status) + { + Hit = hit; + Entry = entry; + Status = status ?? string.Empty; + } + + public bool Hit { get; } + public PremixedSongCacheEntry Entry { get; } + public string Status { get; } + } + + /// + /// Content-addressed FLAC cache for the keysound-off timeline. Automatic objects are governed + /// by a 4 GiB LRU; approved pack objects are stored in an unbounded pinned tier. Both tiers use + /// the same content key so identical arrangements can hit across unrelated BMS packs. + /// + internal sealed class PremixedSongCache + { + private const string AudioExtension = ".flac"; + private const string MetadataExtension = ".pqmeta"; + private const string LegacyExtension = ".pqmix"; + private const string MetadataMagic = "PQFLAC02"; + private const int SchemaVersion = 2; + private const int MixAlgorithmVersion = 3; + private const int HashBytes = 32; + private const int IoBufferBytes = 1024 * 1024; + private const string AfconvertPath = "/usr/bin/afconvert"; + + public const long DefaultMaximumBytes = 4L * 1024L * 1024L * 1024L; + + private static readonly Lazy SharedInstance = + new Lazy(() => new PremixedSongCache( + PersistencePaths.PremixCache, + DefaultMaximumBytes, + PersistencePaths.PinnedPremixCache)); + private static readonly object FingerprintGate = new object(); + private static readonly Dictionary Fingerprints = + new Dictionary(StringComparer.OrdinalIgnoreCase); + + private readonly string _automaticRootPath; + private readonly string _pinnedRootPath; + private readonly long _maximumBytes; + private bool _legacyCleaned; + + public PremixedSongCache( + string rootPath, + long maximumBytes = DefaultMaximumBytes, + string pinnedRootPath = null) + { + if (string.IsNullOrWhiteSpace(rootPath)) + throw new ArgumentException("A premix cache directory is required.", nameof(rootPath)); + if (maximumBytes <= 0) + throw new ArgumentOutOfRangeException(nameof(maximumBytes)); + _automaticRootPath = Path.GetFullPath(rootPath); + _pinnedRootPath = Path.GetFullPath(string.IsNullOrWhiteSpace(pinnedRootPath) + ? rootPath + "-pinned" + : pinnedRootPath); + _maximumBytes = maximumBytes; + } + + public static PremixedSongCache Shared => SharedInstance.Value; + public string RootPath => _automaticRootPath; + public string PinnedRootPath => _pinnedRootPath; + public long MaximumBytes => _maximumBytes; + + public string BuildKey( + PlayableChart chart, + int outputSampleRate, + CancellationToken cancellationToken = default) + { + if (chart == null) + throw new ArgumentNullException(nameof(chart)); + if (outputSampleRate <= 0) + throw new ArgumentOutOfRangeException(nameof(outputSampleRate)); + + var identity = new StringBuilder(4096); + Append(identity, "schema", SchemaVersion.ToString(CultureInfo.InvariantCulture)); + Append(identity, "mixer", MixAlgorithmVersion.ToString(CultureInfo.InvariantCulture)); + Append(identity, "rate", outputSampleRate.ToString(CultureInfo.InvariantCulture)); + Append(identity, "channels", SmoothAudioMixer.OutputChannels.ToString(CultureInfo.InvariantCulture)); + Append(identity, "duration", chart.Duration.ToString("R", CultureInfo.InvariantCulture)); + + var contentHashes = new Dictionary(StringComparer.Ordinal); + var timeline = AudioPlaybackTimeline.Build(chart, reactiveKeySoundsEnabled: false); + foreach (var item in timeline) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!contentHashes.TryGetValue(item.SoundId ?? string.Empty, out var contentHash)) + { + contentHash = ResolveAudioContentHash(chart, item.SoundId, cancellationToken); + contentHashes[item.SoundId ?? string.Empty] = contentHash; + } + Append(identity, "event", item.Time.ToString("R", CultureInfo.InvariantCulture)); + Append(identity, "audio", contentHash); + } + + return ComputeHexHash(Encoding.UTF8.GetBytes(identity.ToString())); + } + + public PremixedSongCacheReadResult TryRead( + string key, + int expectedSampleRate, + CancellationToken cancellationToken = default) + { + ValidateKey(key); + CleanupLegacyFilesOnce(); + var pinned = TryReadTier(key, expectedSampleRate, PremixedSongCacheTier.Pinned, cancellationToken); + if (pinned.Hit) + return pinned; + var automatic = TryReadTier(key, expectedSampleRate, PremixedSongCacheTier.Automatic, cancellationToken); + return automatic.Hit + ? automatic + : string.Equals(automatic.Status, "MISS", StringComparison.OrdinalIgnoreCase) + ? new PremixedSongCacheReadResult(false, default, "MISS") + : automatic; + } + + public string Write( + string key, + SmoothAudioMixResult mix, + CancellationToken cancellationToken = default) => + Write(key, mix, PremixedSongCacheTier.Automatic, cancellationToken).Status; + + public PremixedSongCacheWriteResult Write( + string key, + SmoothAudioMixResult mix, + PremixedSongCacheTier tier, + CancellationToken cancellationToken = default) + { + ValidateKey(key); + if (mix.Samples == null || mix.Samples.Length == 0 || mix.SampleRate <= 0 || mix.FrameCount <= 0) + return new PremixedSongCacheWriteResult(false, default, "STORE SKIPPED // EMPTY MIX"); + + CleanupLegacyFilesOnce(); + var root = RootFor(tier); + Directory.CreateDirectory(root); + var audioPath = EntryPath(key, tier); + var metadataPath = MetadataPath(key, tier); + var token = Guid.NewGuid().ToString("N"); + var wavePath = Path.Combine(root, $"{key}.tmp-{token}.wav"); + var flacPath = Path.Combine(root, $"{key}.tmp-{token}.flac"); + var metadataTemporaryPath = metadataPath + ".tmp-" + token; + try + { + WritePcm24Wave(wavePath, mix, cancellationToken); + EncodeFlac(wavePath, flacPath, cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + var file = new FileInfo(flacPath); + if (file.Length <= 4 || !HasFlacSignature(flacPath)) + throw new InvalidDataException("FLAC ENCODER PRODUCED AN INVALID FILE"); + var encodedBytes = file.Length; + if (tier == PremixedSongCacheTier.Automatic && encodedBytes > _maximumBytes) + return new PremixedSongCacheWriteResult( + false, + default, + "STORE SKIPPED // TRACK EXCEEDS AUTOMATIC CACHE BUDGET"); + + var hash = ComputeFileHash(flacPath, cancellationToken); + WriteMetadata( + metadataTemporaryPath, + mix.SampleRate, + mix.Channels, + mix.FrameCount, + encodedBytes, + hash); + ReplaceFile(flacPath, audioPath); + ReplaceFile(metadataTemporaryPath, metadataPath); + Touch(audioPath, metadataPath); + if (tier == PremixedSongCacheTier.Automatic) + PruneToBudget(audioPath); + var entry = new PremixedSongCacheEntry( + audioPath, + mix.SampleRate, + mix.Channels, + mix.FrameCount, + encodedBytes, + tier); + return new PremixedSongCacheWriteResult( + true, + entry, + tier == PremixedSongCacheTier.Pinned ? "PINNED // STORED" : "MISS // STORED FLAC"); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception exception) + { + return new PremixedSongCacheWriteResult(false, default, $"STORE FAILED // {exception.Message}"); + } + finally + { + DeleteIfPresent(wavePath); + DeleteIfPresent(flacPath); + DeleteIfPresent(metadataTemporaryPath); + } + } + + public bool PromoteToPinned(string key, int expectedSampleRate, out string status) + { + ValidateKey(key); + var pinned = TryReadTier(key, expectedSampleRate, PremixedSongCacheTier.Pinned, default); + if (pinned.Hit) + { + status = "PINNED // HIT"; + return true; + } + var automatic = TryReadTier(key, expectedSampleRate, PremixedSongCacheTier.Automatic, default); + if (!automatic.Hit) + { + status = "PINNED // SOURCE MISS"; + return false; + } + + try + { + Directory.CreateDirectory(_pinnedRootPath); + File.Copy(automatic.Entry.Path, EntryPath(key, PremixedSongCacheTier.Pinned), true); + File.Copy( + MetadataPath(key, PremixedSongCacheTier.Automatic), + MetadataPath(key, PremixedSongCacheTier.Pinned), + true); + status = "PINNED // PROMOTED"; + return true; + } + catch (Exception exception) + { + status = $"PIN FAILED // {exception.Message}"; + return false; + } + } + + public PremixedSongCacheSnapshot GetSnapshot() + { + CleanupLegacyFilesOnce(); + var automatic = EnumerateEntries(PremixedSongCacheTier.Automatic).ToArray(); + var pinned = EnumerateEntries(PremixedSongCacheTier.Pinned).ToArray(); + return new PremixedSongCacheSnapshot( + automatic.Length, + automatic.Sum(file => file.Length), + _maximumBytes, + pinned.Length, + pinned.Sum(file => file.Length)); + } + + /// Clears only the bounded automatic tier. Approved pinned packs remain intact. + public void Clear() + { + if (!Directory.Exists(_automaticRootPath)) + return; + foreach (var path in Directory.EnumerateFiles(_automaticRootPath, "*", SearchOption.TopDirectoryOnly)) + DeleteIfPresent(path); + } + + private PremixedSongCacheReadResult TryReadTier( + string key, + int expectedSampleRate, + PremixedSongCacheTier tier, + CancellationToken cancellationToken) + { + var audioPath = EntryPath(key, tier); + var metadataPath = MetadataPath(key, tier); + if (!File.Exists(audioPath) || !File.Exists(metadataPath)) + return new PremixedSongCacheReadResult(false, default, "MISS"); + try + { + cancellationToken.ThrowIfCancellationRequested(); + var metadata = ReadMetadata(metadataPath); + var length = new FileInfo(audioPath).Length; + if (metadata.SampleRate != expectedSampleRate || + metadata.Channels != SmoothAudioMixer.OutputChannels || + metadata.FrameCount <= 0 || metadata.EncodedBytes != length || + !HasFlacSignature(audioPath)) + throw new InvalidDataException("INVALID FLAC CACHE METADATA"); + if (!FixedTimeEquals(metadata.Hash, ComputeFileHash(audioPath, cancellationToken))) + throw new InvalidDataException("FLAC CACHE HASH MISMATCH"); + + Touch(audioPath, metadataPath); + var entry = new PremixedSongCacheEntry( + audioPath, + metadata.SampleRate, + metadata.Channels, + metadata.FrameCount, + length, + tier); + return new PremixedSongCacheReadResult( + true, + entry, + tier == PremixedSongCacheTier.Pinned ? "PINNED HIT" : "AUTO HIT"); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception exception) + { + DeleteIfPresent(audioPath); + DeleteIfPresent(metadataPath); + return new PremixedSongCacheReadResult( + false, + default, + exception is InvalidDataException ? exception.Message : "INVALIDATED"); + } + } + + private void PruneToBudget(string protectedAudioPath) + { + var files = EnumerateEntries(PremixedSongCacheTier.Automatic) + .OrderBy(file => file.LastWriteTimeUtc) + .ThenBy(file => file.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); + var total = files.Sum(file => file.Length); + foreach (var file in files) + { + if (total <= _maximumBytes) + break; + if (string.Equals(file.FullName, protectedAudioPath, StringComparison.OrdinalIgnoreCase)) + continue; + total -= file.Length; + DeleteIfPresent(file.FullName); + DeleteIfPresent(Path.ChangeExtension(file.FullName, MetadataExtension)); + } + } + + private IEnumerable EnumerateEntries(PremixedSongCacheTier tier) + { + var root = RootFor(tier); + if (!Directory.Exists(root)) + return Enumerable.Empty(); + return Directory.EnumerateFiles(root, "*" + AudioExtension, SearchOption.TopDirectoryOnly) + .Where(path => !Path.GetFileName(path).Contains(".tmp-")) + .Select(path => new FileInfo(path)); + } + + private string ResolveAudioContentHash( + PlayableChart chart, + string soundId, + CancellationToken cancellationToken) + { + chart.AudioResources.TryGetValue(soundId ?? string.Empty, out var declaredPath); + var path = AudioAssetStore.ResolveReferencedAudioPath(chart.Metadata?.SourcePath, declaredPath); + if (string.IsNullOrWhiteSpace(path) || !File.Exists(path)) + return "missing"; + var file = new FileInfo(path); + var cacheKey = file.FullName; + lock (FingerprintGate) + { + if (Fingerprints.TryGetValue(cacheKey, out var cached) && + cached.Length == file.Length && cached.LastWriteTicks == file.LastWriteTimeUtc.Ticks) + return cached.Hash; + } + + var hash = ComputeFileHash(path, cancellationToken); + var value = ToHex(hash); + lock (FingerprintGate) + Fingerprints[cacheKey] = new FileFingerprint(file.Length, file.LastWriteTimeUtc.Ticks, value); + return value; + } + + private void CleanupLegacyFilesOnce() + { + if (_legacyCleaned) + return; + _legacyCleaned = true; + if (!Directory.Exists(_automaticRootPath)) + return; + foreach (var path in Directory.EnumerateFiles(_automaticRootPath, "*", SearchOption.TopDirectoryOnly)) + { + var name = Path.GetFileName(path); + if (name.EndsWith(LegacyExtension, StringComparison.OrdinalIgnoreCase) || + name.Contains(LegacyExtension + ".tmp-") || + name.Contains(".tmp-") && + (name.EndsWith(".wav", StringComparison.OrdinalIgnoreCase) || + name.EndsWith(AudioExtension, StringComparison.OrdinalIgnoreCase))) + DeleteIfPresent(path); + } + } + + private static void WritePcm24Wave( + string path, + SmoothAudioMixResult mix, + CancellationToken cancellationToken) + { + // macOS afconvert emits an invalid 42-byte FLAC for sub-block streams. Padding only + // pathological sub-170 ms fixtures with silence keeps the production path native and + // leaves all real song timing unchanged. + var minimumSampleCount = checked(8192 * mix.Channels); + var encodedSampleCount = Math.Max(mix.Samples.Length, minimumSampleCount); + var dataBytes = checked((long)encodedSampleCount * 3L); + if (dataBytes > uint.MaxValue - 36) + throw new InvalidOperationException("Premix is too large for the WAV encoder."); + using var stream = new FileStream(path, FileMode.CreateNew, FileAccess.Write, FileShare.None, IoBufferBytes); + using var writer = new BinaryWriter(stream, Encoding.ASCII, true); + writer.Write(Encoding.ASCII.GetBytes("RIFF")); + writer.Write(checked((uint)(36L + dataBytes))); + writer.Write(Encoding.ASCII.GetBytes("WAVEfmt ")); + writer.Write(16u); + writer.Write((ushort)1); + writer.Write((ushort)mix.Channels); + writer.Write((uint)mix.SampleRate); + writer.Write(checked((uint)(mix.SampleRate * mix.Channels * 3))); + writer.Write(checked((ushort)(mix.Channels * 3))); + writer.Write((ushort)24); + writer.Write(Encoding.ASCII.GetBytes("data")); + writer.Write((uint)dataBytes); + var bytes = new byte[IoBufferBytes - IoBufferBytes % 3]; + var byteIndex = 0; + for (var index = 0; index < encodedSampleCount; index++) + { + if ((index & 0x3fff) == 0) + cancellationToken.ThrowIfCancellationRequested(); + var sample = index < mix.Samples.Length + ? Math.Max(-1f, Math.Min(1f, mix.Samples[index])) + : 0f; + var pcm = sample >= 0f + ? (int)Math.Round(sample * 8388607f) + : (int)Math.Round(sample * 8388608f); + bytes[byteIndex++] = (byte)pcm; + bytes[byteIndex++] = (byte)(pcm >> 8); + bytes[byteIndex++] = (byte)(pcm >> 16); + if (byteIndex > bytes.Length - 3) + { + stream.Write(bytes, 0, byteIndex); + byteIndex = 0; + } + } + if (byteIndex > 0) + stream.Write(bytes, 0, byteIndex); + stream.Flush(true); + } + + private static void EncodeFlac(string wavePath, string flacPath, CancellationToken cancellationToken) + { + if (!File.Exists(AfconvertPath)) + throw new PlatformNotSupportedException("macOS afconvert is required to create the FLAC premix cache."); + var startInfo = new ProcessStartInfo + { + FileName = AfconvertPath, + Arguments = $"{Quote(wavePath)} -o {Quote(flacPath)} -f flac -d flac", + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardError = true, + RedirectStandardOutput = true, + }; + using var process = Process.Start(startInfo) ?? + throw new InvalidOperationException("Could not start the FLAC encoder."); + using var registration = cancellationToken.Register(() => + { + try + { + if (!process.HasExited) + process.Kill(); + } + catch + { + // Cancellation cleanup is best effort. + } + }); + var error = process.StandardError.ReadToEnd().Trim(); + process.WaitForExit(); + cancellationToken.ThrowIfCancellationRequested(); + if (process.ExitCode != 0) + throw new InvalidOperationException( + string.IsNullOrWhiteSpace(error) ? "FLAC encoding failed." : error); + } + + private static void WriteMetadata( + string path, + int sampleRate, + int channels, + long frameCount, + long encodedBytes, + byte[] hash) + { + using var stream = new FileStream(path, FileMode.CreateNew, FileAccess.Write, FileShare.None); + using var writer = new BinaryWriter(stream, Encoding.ASCII, true); + writer.Write(Encoding.ASCII.GetBytes(MetadataMagic)); + writer.Write(SchemaVersion); + writer.Write(sampleRate); + writer.Write(channels); + writer.Write(frameCount); + writer.Write(encodedBytes); + writer.Write(hash); + stream.Flush(true); + } + + private static FlacMetadata ReadMetadata(string path) + { + using var stream = File.OpenRead(path); + using var reader = new BinaryReader(stream, Encoding.ASCII, true); + var magic = Encoding.ASCII.GetString(reader.ReadBytes(8)); + var schema = reader.ReadInt32(); + var sampleRate = reader.ReadInt32(); + var channels = reader.ReadInt32(); + var frameCount = reader.ReadInt64(); + var encodedBytes = reader.ReadInt64(); + var hash = reader.ReadBytes(HashBytes); + if (magic != MetadataMagic || schema != SchemaVersion || hash.Length != HashBytes) + throw new InvalidDataException("INVALID FLAC CACHE SIDECAR"); + return new FlacMetadata(sampleRate, channels, frameCount, encodedBytes, hash); + } + + private string RootFor(PremixedSongCacheTier tier) => + tier == PremixedSongCacheTier.Pinned ? _pinnedRootPath : _automaticRootPath; + + private string EntryPath(string key, PremixedSongCacheTier tier) => + Path.Combine(RootFor(tier), key.ToLowerInvariant() + AudioExtension); + + private string MetadataPath(string key, PremixedSongCacheTier tier) => + Path.Combine(RootFor(tier), key.ToLowerInvariant() + MetadataExtension); + + private static void ValidateKey(string key) + { + if (string.IsNullOrWhiteSpace(key) || key.Length != 64 || + key.Any(character => !Uri.IsHexDigit(character))) + throw new ArgumentException("A 64-character hexadecimal premix cache key is required.", nameof(key)); + } + + private static bool HasFlacSignature(string path) + { + using var stream = File.OpenRead(path); + return stream.ReadByte() == 'f' && stream.ReadByte() == 'L' && + stream.ReadByte() == 'a' && stream.ReadByte() == 'C'; + } + + private static void Touch(string audioPath, string metadataPath) + { + var now = DateTime.UtcNow; + File.SetLastWriteTimeUtc(audioPath, now); + File.SetLastWriteTimeUtc(metadataPath, now); + } + + private static void ReplaceFile(string source, string destination) + { + DeleteIfPresent(destination); + File.Move(source, destination); + } + + private static byte[] ComputeFileHash(string path, CancellationToken cancellationToken) + { + using var stream = new FileStream( + path, FileMode.Open, FileAccess.Read, FileShare.Read, IoBufferBytes, FileOptions.SequentialScan); + using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); + var buffer = new byte[IoBufferBytes]; + int read; + while ((read = stream.Read(buffer, 0, buffer.Length)) > 0) + { + cancellationToken.ThrowIfCancellationRequested(); + hash.AppendData(buffer, 0, read); + } + return hash.GetHashAndReset(); + } + + private static string ComputeHexHash(byte[] bytes) + { + using var sha = SHA256.Create(); + return ToHex(sha.ComputeHash(bytes)); + } + + private static void Append(StringBuilder builder, string name, string value) => + builder.Append(name).Append('=').Append(value ?? string.Empty).Append('\n'); + + private static string ToHex(byte[] bytes) => + string.Concat(bytes.Select(value => value.ToString("x2", CultureInfo.InvariantCulture))); + + private static bool FixedTimeEquals(byte[] left, byte[] right) + { + if (left == null || right == null || left.Length != right.Length) + return false; + var difference = 0; + for (var index = 0; index < left.Length; index++) + difference |= left[index] ^ right[index]; + return difference == 0; + } + + private static string Quote(string value) => "\"" + value.Replace("\"", "\\\"") + "\""; + + private static void DeleteIfPresent(string path) + { + try + { + if (File.Exists(path)) + File.Delete(path); + } + catch + { + // Cache cleanup must not replace playable source audio with a fatal error. + } + } + + private readonly struct FileFingerprint + { + public FileFingerprint(long length, long lastWriteTicks, string hash) + { + Length = length; + LastWriteTicks = lastWriteTicks; + Hash = hash; + } + + public long Length { get; } + public long LastWriteTicks { get; } + public string Hash { get; } + } + + private readonly struct FlacMetadata + { + public FlacMetadata(int sampleRate, int channels, long frameCount, long encodedBytes, byte[] hash) + { + SampleRate = sampleRate; + Channels = channels; + FrameCount = frameCount; + EncodedBytes = encodedBytes; + Hash = hash; + } + + public int SampleRate { get; } + public int Channels { get; } + public long FrameCount { get; } + public long EncodedBytes { get; } + public byte[] Hash { get; } + } + } + + internal readonly struct PremixedSongCacheWriteResult + { + public PremixedSongCacheWriteResult(bool success, PremixedSongCacheEntry entry, string status) + { + Success = success; + Entry = entry; + Status = status ?? string.Empty; + } + + public bool Success { get; } + public PremixedSongCacheEntry Entry { get; } + public string Status { get; } + } +} diff --git a/Assets/ProjectQuint/Runtime/Unity/Audio/PremixedSongCache.cs.meta b/Assets/ProjectQuint/Runtime/Unity/Audio/PremixedSongCache.cs.meta new file mode 100644 index 0000000..30e71c5 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Audio/PremixedSongCache.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 83f59debbe01642dabbe6a1d6b32907a \ No newline at end of file diff --git a/Assets/ProjectQuint/Runtime/Unity/Audio/PremixedSongRenderer.cs b/Assets/ProjectQuint/Runtime/Unity/Audio/PremixedSongRenderer.cs new file mode 100644 index 0000000..e90d285 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Audio/PremixedSongRenderer.cs @@ -0,0 +1,441 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using ProjectQuint.Core.Charts; +using UnityEngine; + +namespace ProjectQuint.Audio +{ + internal enum PremixedSongState + { + Empty, + Preparing, + Ready, + Cancelled, + Failed, + Disposed, + } + + internal enum PremixedSongOutput + { + UnityAudioClip, + FmodStreamFile, + } + + /// + /// Renders the shared audio timeline used by keysound-off mode. Unity playback requests an + /// AudioClip, while FMOD receives a content-addressed FLAC path and streams it from disk. + /// PCM exists only while a cache miss is being mixed and encoded on the loading screen. + /// + internal sealed class PremixedSongRenderer : IDisposable + { + private readonly PremixedSongCache _cache; + + public PremixedSongRenderer(PremixedSongCache cache = null) => + _cache = cache ?? PremixedSongCache.Shared; + + public PremixedSongState State { get; private set; } = PremixedSongState.Empty; + public AudioClip Clip { get; private set; } + private PremixedSongCacheEntry _streamEntry; + public bool IsReady => State == PremixedSongState.Ready && + (Clip != null || _streamEntry.IsValid); + public string Error { get; private set; } = string.Empty; + public bool CacheHit { get; private set; } + public string CacheStatus { get; private set; } = string.Empty; + public long EstimatedPcmBytes => Clip != null + ? AudioAssetStore.CalculateDecodedPcmBytes(Clip.samples, Clip.channels) + : 0; + public long StreamFileBytes => _streamEntry.EncodedBytes; + internal bool HasStreamFile => _streamEntry.IsValid; + + internal AudioClip TakeClip() + { + ThrowIfDisposed(); + var clip = Clip; + Clip = null; + return clip; + } + + internal PremixedSongCacheEntry TakeStreamEntry() + { + ThrowIfDisposed(); + var entry = _streamEntry; + _streamEntry = default; + return entry; + } + + public IEnumerator Prepare( + PlayableChart chart, + AudioAssetStore assets, + Action progress, + Func cancellationRequested = null, + PremixedSongOutput output = PremixedSongOutput.UnityAudioClip, + int outputSampleRateOverride = 0, + PremixedSongCacheTier cacheTier = PremixedSongCacheTier.Automatic) + { + if (chart == null) + throw new ArgumentNullException(nameof(chart)); + if (assets == null) + throw new ArgumentNullException(nameof(assets)); + ThrowIfDisposed(); + ClearOutput(); + Error = string.Empty; + CacheHit = false; + CacheStatus = string.Empty; + State = PremixedSongState.Preparing; + + var timeline = AudioPlaybackTimeline.Build(chart, reactiveKeySoundsEnabled: false); + var eventsBySoundId = timeline + .GroupBy(item => item.SoundId, StringComparer.Ordinal) + .ToDictionary( + group => group.Key, + group => (IReadOnlyList)group.ToList(), + StringComparer.Ordinal); + var orderedIds = eventsBySoundId.Keys.OrderBy( + item => item, + StringComparer.Ordinal).ToList(); + var outputSampleRate = outputSampleRateOverride > 0 + ? outputSampleRateOverride + : AudioSettings.outputSampleRate; + if (outputSampleRate <= 0) + { + Fail("The selected audio backend reported an invalid output sample rate."); + yield break; + } + + using var cancellation = new CancellationTokenSource(); + var cacheKey = string.Empty; + progress?.Invoke(0, orderedIds.Count + 1, "Fingerprinting smooth playback content"); + var keyTask = Task.Run( + () => _cache.BuildKey(chart, outputSampleRate, cancellation.Token), + cancellation.Token); + while (!keyTask.IsCompleted) + { + if (IsCancelled(cancellationRequested)) + cancellation.Cancel(); + yield return null; + } + if (keyTask.IsCanceled) + { + State = PremixedSongState.Cancelled; + yield break; + } + if (keyTask.IsFaulted) + { + CacheStatus = "KEY FAILED"; + Debug.LogWarning( + $"[ProjectQuint Audio] Premix cache key failed: " + + $"{keyTask.Exception?.GetBaseException().Message}"); + } + else + cacheKey = keyTask.Result; + + if (output == PremixedSongOutput.FmodStreamFile && !string.IsNullOrEmpty(cacheKey)) + { + progress?.Invoke(0, orderedIds.Count + 1, "Checking smooth playback cache"); + var readTask = Task.Run( + () => _cache.TryRead(cacheKey, outputSampleRate, cancellation.Token), + cancellation.Token); + while (!readTask.IsCompleted) + { + if (IsCancelled(cancellationRequested)) + cancellation.Cancel(); + yield return null; + } + if (readTask.IsCanceled) + { + State = PremixedSongState.Cancelled; + yield break; + } + if (!readTask.IsFaulted) + { + var cached = readTask.Result; + CacheStatus = cached.Status; + if (cached.Hit) + { + _streamEntry = cached.Entry; + CacheHit = true; + State = PremixedSongState.Ready; + progress?.Invoke(orderedIds.Count + 1, orderedIds.Count + 1, "Smooth playback cache hit"); + yield return null; + GC.Collect(); + yield return null; + yield break; + } + if (!string.Equals(cached.Status, "MISS", StringComparison.OrdinalIgnoreCase)) + Debug.LogWarning($"[ProjectQuint Audio] Premix cache {cached.Status}; rendering source audio again."); + } + else + { + CacheStatus = "READ FAILED"; + Debug.LogWarning( + $"[ProjectQuint Audio] Premix cache read failed: " + + $"{readTask.Exception?.GetBaseException().Message}"); + } + } + + SmoothAudioMixAccumulator accumulator; + try + { + accumulator = new SmoothAudioMixAccumulator(outputSampleRate, chart.Duration); + } + catch (Exception exception) + { + Fail($"Could not allocate the smooth playback mix: {exception.Message}"); + yield break; + } + + var mixedResourceCount = 0; + for (var index = 0; index < orderedIds.Count; index++) + { + if (IsCancelled(cancellationRequested)) + { + State = PremixedSongState.Cancelled; + yield break; + } + + var soundId = orderedIds[index]; + progress?.Invoke(index, orderedIds.Count + 1, $"Decoding and mixing {soundId}"); + var loadResult = new AudioResourceLoadResult( + AudioResourceLoadStatus.DecodeFailed, + detail: $"Keysound {soundId} did not return a load result."); + yield return assets.LoadTransientClip( + chart, + soundId, + result => loadResult = result, + cancellationRequested); + if (loadResult.Status == AudioResourceLoadStatus.Cancelled || + IsCancelled(cancellationRequested)) + { + AudioAssetStore.ReleaseTransientClip(loadResult.Clip); + State = PremixedSongState.Cancelled; + yield break; + } + if (loadResult.Status != AudioResourceLoadStatus.Loaded || + loadResult.Clip == null) + { + assets.RecordTransientOutcome( + soundId, + loadResult.Status, + loadResult.Detail); + continue; + } + + if (!AudioAssetStore.TryReadClipPcm( + loadResult.Clip, + soundId, + assets.DecodedPcmBudgetBytes, + out var pcm, + out var pcmFailureStatus, + out var pcmError)) + { + AudioAssetStore.ReleaseTransientClip(loadResult.Clip); + if (pcmFailureStatus == AudioResourceLoadStatus.TooLarge) + { + Fail(pcmError); + yield break; + } + assets.RecordTransientOutcome(soundId, pcmFailureStatus, pcmError); + continue; + } + AudioAssetStore.ReleaseTransientClip(loadResult.Clip); + + var soundEvents = eventsBySoundId[soundId]; + var mixTask = Task.Run( + () => accumulator.Mix(soundEvents, pcm, cancellation.Token), + cancellation.Token); + while (!mixTask.IsCompleted) + { + if (IsCancelled(cancellationRequested)) + cancellation.Cancel(); + yield return null; + } + pcm = null; + if (mixTask.IsCanceled) + { + State = PremixedSongState.Cancelled; + yield break; + } + if (mixTask.IsFaulted) + { + Fail(mixTask.Exception?.GetBaseException().Message ?? "Unknown incremental mix failure."); + yield break; + } + mixedResourceCount++; + yield return null; + } + + if (orderedIds.Count > 0 && + mixedResourceCount == 0 && + assets.MissingClipCount + assets.DecodeFailedClipCount > 0) + { + Fail( + $"No playable keysounds could be mixed " + + $"({assets.MissingClipCount} missing, " + + $"{assets.DecodeFailedClipCount} decode failed)."); + yield break; + } + + progress?.Invoke(orderedIds.Count, orderedIds.Count + 1, "Finalizing smooth playback track"); + var finalizeTask = Task.Run( + () => accumulator.Complete(cancellation.Token), + cancellation.Token); + while (!finalizeTask.IsCompleted) + { + if (IsCancelled(cancellationRequested)) + cancellation.Cancel(); + yield return null; + } + if (finalizeTask.IsCanceled) + { + State = PremixedSongState.Cancelled; + yield break; + } + if (finalizeTask.IsFaulted) + { + Fail(finalizeTask.Exception?.GetBaseException().Message ?? "Unknown offline mix failure."); + yield break; + } + + var mixResult = finalizeTask.Result; + if (output == PremixedSongOutput.UnityAudioClip && !TryCreateUnityOutput(chart, mixResult)) + yield break; + + if (!string.IsNullOrEmpty(cacheKey)) + { + progress?.Invoke(orderedIds.Count, orderedIds.Count + 1, "Encoding smooth playback FLAC cache"); + var writeTask = Task.Run( + () => _cache.Write(cacheKey, mixResult, cacheTier, cancellation.Token), + cancellation.Token); + while (!writeTask.IsCompleted) + { + if (IsCancelled(cancellationRequested)) + cancellation.Cancel(); + yield return null; + } + if (writeTask.IsCanceled) + { + ClearOutput(); + State = PremixedSongState.Cancelled; + yield break; + } + var write = writeTask.IsFaulted + ? new PremixedSongCacheWriteResult( + false, + default, + $"STORE FAILED // {writeTask.Exception?.GetBaseException().Message}") + : writeTask.Result; + CacheStatus = write.Status; + if (output == PremixedSongOutput.FmodStreamFile) + { + if (!write.Success || !write.Entry.IsValid) + { + Fail(CacheStatus); + yield break; + } + _streamEntry = write.Entry; + } + if (CacheStatus.StartsWith("STORE FAILED", StringComparison.OrdinalIgnoreCase)) + Debug.LogWarning($"[ProjectQuint Audio] Premix cache {CacheStatus}"); + } + else if (output == PremixedSongOutput.FmodStreamFile) + { + Fail("Could not build a content key for the FMOD FLAC stream."); + yield break; + } + + State = PremixedSongState.Ready; + progress?.Invoke(orderedIds.Count + 1, orderedIds.Count + 1, "Smooth playback ready"); + + // Drop all loading-only managed PCM references before gameplay begins. This + // collection is intentionally paid on the loading screen instead of a play frame. + mixResult = default; + finalizeTask = null; + accumulator = null; + eventsBySoundId.Clear(); + eventsBySoundId = null; + timeline = null; + yield return null; + GC.Collect(); + yield return null; + } + + public void Clear() + { + ThrowIfDisposed(); + ClearOutput(); + Error = string.Empty; + CacheHit = false; + CacheStatus = string.Empty; + State = PremixedSongState.Empty; + } + + public void Dispose() + { + if (State == PremixedSongState.Disposed) + return; + ClearOutput(); + State = PremixedSongState.Disposed; + } + + private void Fail(string error) + { + ClearOutput(); + Error = string.IsNullOrWhiteSpace(error) ? "Offline audio mix failed." : error; + State = PremixedSongState.Failed; + } + + private bool TryCreateUnityOutput(PlayableChart chart, SmoothAudioMixResult mix) + { + if (mix.Samples == null || mix.Samples.Length == 0 || mix.FrameCount <= 0) + { + Fail("Offline audio mix produced no PCM samples."); + return false; + } + var mixedClip = AudioClip.Create( + $"ProjectQuint Smooth Mix - {chart.Metadata.Title}", + mix.FrameCount, + mix.Channels, + mix.SampleRate, + false); + if (mixedClip == null || !mixedClip.SetData(mix.Samples, 0)) + { + DestroyUnityObject(mixedClip); + Fail("Unity could not create the smooth playback AudioClip."); + return false; + } + + Clip = mixedClip; + return true; + } + + private void ClearOutput() + { + DestroyUnityObject(Clip); + Clip = null; + _streamEntry = default; + } + + private void ThrowIfDisposed() + { + if (State == PremixedSongState.Disposed) + throw new ObjectDisposedException(nameof(PremixedSongRenderer)); + } + + private static bool IsCancelled(Func cancellationRequested) => + cancellationRequested?.Invoke() == true; + + private static void DestroyUnityObject(UnityEngine.Object target) + { + if (target == null) + return; + if (Application.isPlaying) + UnityEngine.Object.Destroy(target); + else + UnityEngine.Object.DestroyImmediate(target); + } + } +} diff --git a/Assets/ProjectQuint/Runtime/Unity/Audio/PremixedSongRenderer.cs.meta b/Assets/ProjectQuint/Runtime/Unity/Audio/PremixedSongRenderer.cs.meta new file mode 100644 index 0000000..0c99e48 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Audio/PremixedSongRenderer.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 26a6a22f76eb24294a947cb7ba7cde5d \ No newline at end of file diff --git a/Assets/ProjectQuint/Runtime/Unity/Audio/ProjectQuint.Media.Audio.asmdef b/Assets/ProjectQuint/Runtime/Unity/Audio/ProjectQuint.Media.Audio.asmdef new file mode 100644 index 0000000..b7b773f --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Audio/ProjectQuint.Media.Audio.asmdef @@ -0,0 +1,18 @@ +{ + "name": "ProjectQuint.Media.Audio", + "rootNamespace": "ProjectQuint.Audio", + "references": [ + "ProjectQuint.Core", + "ProjectQuint.Infrastructure", + "FMODUnity" + ], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/Assets/ProjectQuint/Runtime/Unity/Audio/ProjectQuint.Media.Audio.asmdef.meta b/Assets/ProjectQuint/Runtime/Unity/Audio/ProjectQuint.Media.Audio.asmdef.meta new file mode 100644 index 0000000..d9cccee --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Audio/ProjectQuint.Media.Audio.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 360789961504e497f8124c485249ac60 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/ProjectQuint/Runtime/Unity/Audio/SmoothAudioMixer.cs b/Assets/ProjectQuint/Runtime/Unity/Audio/SmoothAudioMixer.cs new file mode 100644 index 0000000..7db0889 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Audio/SmoothAudioMixer.cs @@ -0,0 +1,261 @@ +using System; +using System.Collections.Generic; +using System.Threading; + +namespace ProjectQuint.Audio +{ + internal sealed class PcmAudioData + { + public PcmAudioData(float[] samples, int channels, int sampleRate) + { + Samples = samples ?? throw new ArgumentNullException(nameof(samples)); + if (channels <= 0) + throw new ArgumentOutOfRangeException(nameof(channels)); + if (sampleRate <= 0) + throw new ArgumentOutOfRangeException(nameof(sampleRate)); + if (samples.Length % channels != 0) + throw new ArgumentException("The PCM sample count must be divisible by its channel count.", nameof(samples)); + + Channels = channels; + SampleRate = sampleRate; + } + + public float[] Samples { get; } + public int Channels { get; } + public int SampleRate { get; } + public int FrameCount => Samples.Length / Channels; + } + + internal readonly struct SmoothAudioMixResult + { + public SmoothAudioMixResult(float[] samples, int sampleRate) + { + Samples = samples ?? throw new ArgumentNullException(nameof(samples)); + SampleRate = sampleRate; + } + + public float[] Samples { get; } + public int SampleRate { get; } + public int Channels => SmoothAudioMixer.OutputChannels; + public int FrameCount => Samples.Length / Channels; + } + + internal static class SmoothAudioMixer + { + internal const int OutputChannels = 2; + internal const double MaximumDurationSeconds = 20.0 * 60.0; + private const float LimiterKnee = 0.90f; + + public static SmoothAudioMixResult Mix( + IReadOnlyList events, + IReadOnlyDictionary clips, + int outputSampleRate, + double minimumDurationSeconds, + CancellationToken cancellationToken = default) + { + if (events == null) + throw new ArgumentNullException(nameof(events)); + if (clips == null) + throw new ArgumentNullException(nameof(clips)); + if (outputSampleRate <= 0) + throw new ArgumentOutOfRangeException(nameof(outputSampleRate)); + if (minimumDurationSeconds < 0 || minimumDurationSeconds > MaximumDurationSeconds) + throw new ArgumentOutOfRangeException(nameof(minimumDurationSeconds)); + + var referencedIds = new List(); + var eventsBySoundId = new Dictionary>( + StringComparer.Ordinal); + for (var index = 0; index < events.Count; index++) + { + cancellationToken.ThrowIfCancellationRequested(); + var item = events[index]; + if (string.IsNullOrWhiteSpace(item.SoundId) || !clips.TryGetValue(item.SoundId, out var clip)) + continue; + + if (!eventsBySoundId.TryGetValue(item.SoundId, out var soundEvents)) + { + soundEvents = new List(); + eventsBySoundId[item.SoundId] = soundEvents; + referencedIds.Add(item.SoundId); + } + soundEvents.Add(item); + } + + var accumulator = new SmoothAudioMixAccumulator(outputSampleRate, minimumDurationSeconds); + for (var soundIndex = 0; soundIndex < referencedIds.Count; soundIndex++) + { + cancellationToken.ThrowIfCancellationRequested(); + var soundId = referencedIds[soundIndex]; + accumulator.Mix(eventsBySoundId[soundId], clips[soundId], cancellationToken); + } + return accumulator.Complete(cancellationToken); + } + + internal static float SoftLimit(float sample) + { + if (float.IsNaN(sample) || float.IsInfinity(sample)) + return 0f; + + var magnitude = Math.Abs(sample); + if (magnitude <= LimiterKnee) + return sample; + + var headroom = 1f - LimiterKnee; + var limitedMagnitude = LimiterKnee + + headroom * (float)Math.Tanh((magnitude - LimiterKnee) / headroom); + return Math.Sign(sample) * limitedMagnitude; + } + + internal static float[] ResampleToStereo( + PcmAudioData source, + int outputSampleRate, + CancellationToken cancellationToken) + { + if (source.FrameCount <= 0) + return Array.Empty(); + + var outputFrameCount64 = checked((long)Math.Ceiling( + source.FrameCount * (double)outputSampleRate / source.SampleRate)); + if (outputFrameCount64 > int.MaxValue / OutputChannels) + throw new InvalidOperationException("A keysound is too large to resample."); + + var outputFrameCount = (int)outputFrameCount64; + var output = new float[checked(outputFrameCount * OutputChannels)]; + var inputFramesPerOutputFrame = source.SampleRate / (double)outputSampleRate; + + for (var outputFrame = 0; outputFrame < outputFrameCount; outputFrame++) + { + if ((outputFrame & 0x3fff) == 0) + cancellationToken.ThrowIfCancellationRequested(); + + var sourcePosition = outputFrame * inputFramesPerOutputFrame; + var sourceFrame0 = Math.Min((int)sourcePosition, source.FrameCount - 1); + var sourceFrame1 = Math.Min(sourceFrame0 + 1, source.FrameCount - 1); + var interpolation = (float)(sourcePosition - sourceFrame0); + var outputSample = outputFrame * OutputChannels; + + output[outputSample] = InterpolateChannel(source, sourceFrame0, sourceFrame1, 0, interpolation); + output[outputSample + 1] = InterpolateChannel( + source, + sourceFrame0, + sourceFrame1, + source.Channels > 1 ? 1 : 0, + interpolation); + } + + return output; + } + + private static float InterpolateChannel( + PcmAudioData source, + int frame0, + int frame1, + int channel, + float interpolation) + { + var sample0 = source.Samples[frame0 * source.Channels + channel]; + var sample1 = source.Samples[frame1 * source.Channels + channel]; + return sample0 + (sample1 - sample0) * interpolation; + } + } + + /// + /// Keeps only the final song buffer and one transient resampled source. This allows + /// very large BMS keysound sets to be decoded and mixed sequentially instead of + /// retaining every decoded clip at the same time. + /// + internal sealed class SmoothAudioMixAccumulator + { + private readonly int _sampleRate; + private float[] _mixed; + private bool _completed; + + public SmoothAudioMixAccumulator(int outputSampleRate, double minimumDurationSeconds) + { + if (outputSampleRate <= 0) + throw new ArgumentOutOfRangeException(nameof(outputSampleRate)); + if (minimumDurationSeconds < 0 || minimumDurationSeconds > SmoothAudioMixer.MaximumDurationSeconds) + throw new ArgumentOutOfRangeException(nameof(minimumDurationSeconds)); + _sampleRate = outputSampleRate; + _mixed = new float[CalculateSampleCount(minimumDurationSeconds)]; + } + + public void Mix( + IReadOnlyList events, + PcmAudioData clip, + CancellationToken cancellationToken = default) + { + if (_completed) + throw new InvalidOperationException("The smooth mix is already complete."); + if (events == null) + throw new ArgumentNullException(nameof(events)); + if (clip == null) + return; + + var source = SmoothAudioMixer.ResampleToStereo(clip, _sampleRate, cancellationToken); + var sourceFrameCount = source.Length / SmoothAudioMixer.OutputChannels; + for (var eventIndex = 0; eventIndex < events.Count; eventIndex++) + { + if ((eventIndex & 31) == 0) + cancellationToken.ThrowIfCancellationRequested(); + + var destinationFrame = (long)Math.Round(events[eventIndex].Time * _sampleRate); + var sourceFrame = 0L; + if (destinationFrame < 0) + { + sourceFrame = -destinationFrame; + destinationFrame = 0; + } + var copyFrameCount = sourceFrameCount - sourceFrame; + if (copyFrameCount <= 0) + continue; + EnsureFrameCapacity(destinationFrame + copyFrameCount); + + var sourceSample = checked((int)(sourceFrame * SmoothAudioMixer.OutputChannels)); + var destinationSample = checked((int)(destinationFrame * SmoothAudioMixer.OutputChannels)); + var copySampleCount = checked((int)(copyFrameCount * SmoothAudioMixer.OutputChannels)); + for (var sampleIndex = 0; sampleIndex < copySampleCount; sampleIndex++) + _mixed[destinationSample + sampleIndex] += source[sourceSample + sampleIndex]; + } + } + + public SmoothAudioMixResult Complete(CancellationToken cancellationToken = default) + { + if (_completed) + throw new InvalidOperationException("The smooth mix is already complete."); + _completed = true; + for (var index = 0; index < _mixed.Length; index++) + { + if ((index & 0x3ffff) == 0) + cancellationToken.ThrowIfCancellationRequested(); + _mixed[index] = SmoothAudioMixer.SoftLimit(_mixed[index]); + } + return new SmoothAudioMixResult(_mixed, _sampleRate); + } + + private void EnsureFrameCapacity(long requiredFrameCount) + { + if (requiredFrameCount <= _mixed.LongLength / SmoothAudioMixer.OutputChannels) + return; + var maximumFrames = checked((long)Math.Ceiling( + SmoothAudioMixer.MaximumDurationSeconds * _sampleRate)); + if (requiredFrameCount > maximumFrames || + requiredFrameCount > int.MaxValue / SmoothAudioMixer.OutputChannels) + { + throw new InvalidOperationException( + $"Smooth playback mix exceeds the {SmoothAudioMixer.MaximumDurationSeconds / 60:0}-minute safety limit."); + } + Array.Resize( + ref _mixed, + checked((int)requiredFrameCount * SmoothAudioMixer.OutputChannels)); + } + + private int CalculateSampleCount(double durationSeconds) + { + var frameCount = Math.Max(1L, checked((long)Math.Ceiling(durationSeconds * _sampleRate))); + if (frameCount > int.MaxValue / SmoothAudioMixer.OutputChannels) + throw new InvalidOperationException("Smooth playback mix is too large for a Unity AudioClip."); + return checked((int)frameCount * SmoothAudioMixer.OutputChannels); + } + } +} diff --git a/Assets/ProjectQuint/Runtime/Unity/Audio/SmoothAudioMixer.cs.meta b/Assets/ProjectQuint/Runtime/Unity/Audio/SmoothAudioMixer.cs.meta new file mode 100644 index 0000000..5c6a1da --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Audio/SmoothAudioMixer.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c4271fd5fdd16467ebe22fe5ded1e031 \ No newline at end of file diff --git a/Assets/ProjectQuint/Runtime/Unity/Audio/SongAudioPlaybackEngine.cs b/Assets/ProjectQuint/Runtime/Unity/Audio/SongAudioPlaybackEngine.cs new file mode 100644 index 0000000..1c6d2bf --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Audio/SongAudioPlaybackEngine.cs @@ -0,0 +1,368 @@ +using System; +using System.Collections.Generic; +using ProjectQuint.Core.Charts; +using Unity.Profiling; +using UnityEngine; + +namespace ProjectQuint.Audio +{ + /// + /// Owns the Unity AudioSources and DSP reservations for one prepared song. It never + /// decodes or mixes audio and therefore performs no PCM work during gameplay. + /// + internal sealed class SongAudioPlaybackEngine : IDisposable + { + internal const double SchedulingHorizon = 1.0; + internal const double LateEventDropThresholdSeconds = 0.100; + internal const int ScheduledSourceHeadroom = 8; + private const int KeySoundPriority = 32; + private const int ScheduledSoundPriority = 96; + private static readonly ProfilerMarker ScheduleAudioMarker = + new ProfilerMarker("ProjectQuint.Audio.Schedule"); + + private readonly GameObject _owner; + private readonly IAudioClock _clock; + private readonly List _scheduledSlots = new List(); + private readonly DspReservationPool _scheduledReservations = new DspReservationPool(); + private readonly List _scheduledEvents = new List(); + private readonly AudioSource[] _laneSources = new AudioSource[8]; + private readonly AudioSource _premixSource; + private PlayableChart _chart; + private AudioAssetStore _assets; + private int _nextScheduledEvent; + private bool _usingPremix; + private bool _disposed; + + public SongAudioPlaybackEngine(GameObject owner, IAudioClock clock) + { + _owner = owner ?? throw new ArgumentNullException(nameof(owner)); + _clock = clock ?? throw new ArgumentNullException(nameof(clock)); + for (var lane = 0; lane < _laneSources.Length; lane++) + _laneSources[lane] = CreateSource(KeySoundPriority); + _premixSource = CreateSource(ScheduledSoundPriority); + } + + public double StartDspTime { get; private set; } + public int ScheduledSourceCount => _usingPremix ? 1 : _scheduledSlots.Count; + public int PendingScheduledEventCount => Math.Max(0, _scheduledEvents.Count - _nextScheduledEvent); + public int DroppedLateEventCount { get; private set; } + + public void PrepareReactivePlayback(PlayableChart chart, AudioAssetStore assets, double leadSeconds = 0.35) + { + ThrowIfDisposed(); + if (chart == null) + throw new ArgumentNullException(nameof(chart)); + if (assets == null) + throw new ArgumentNullException(nameof(assets)); + + Stop(); + _chart = chart; + _assets = assets; + _scheduledEvents.AddRange(AudioPlaybackTimeline.Build(chart, reactiveKeySoundsEnabled: true)); + PrewarmScheduledSources(assets, leadSeconds); + } + + public void BeginPlayback( + PlayableChart chart, + AudioAssetStore assets, + AudioClip premixedClip, + bool reactiveKeySoundsEnabled, + double leadSeconds = 0.35) + { + ThrowIfDisposed(); + if (chart == null) + throw new ArgumentNullException(nameof(chart)); + if (assets == null) + throw new ArgumentNullException(nameof(assets)); + + var alreadyPreparedReactive = reactiveKeySoundsEnabled && + ReferenceEquals(_chart, chart) && + ReferenceEquals(_assets, assets) && + _scheduledEvents.Count > 0; + if (!alreadyPreparedReactive) + Stop(); + else + { + foreach (var source in _laneSources) source.Stop(); + _premixSource.Stop(); + foreach (var slot in _scheduledSlots) slot.Source.Stop(); + _scheduledReservations.Reset(); + _nextScheduledEvent = 0; + StartDspTime = 0; + _usingPremix = false; + } + + DroppedLateEventCount = 0; + _chart = chart; + _assets = assets; + StartDspTime = _clock.CurrentTime + leadSeconds; + + if (!reactiveKeySoundsEnabled) + { + if (premixedClip == null) + throw new InvalidOperationException("Smooth playback was requested before its single-track mix was prepared."); + _scheduledEvents.Clear(); + _usingPremix = true; + _premixSource.clip = premixedClip; + _premixSource.PlayScheduled(StartDspTime); + return; + } + + if (!alreadyPreparedReactive) + { + _scheduledEvents.AddRange(AudioPlaybackTimeline.Build(chart, reactiveKeySoundsEnabled: true)); + PrewarmScheduledSources(assets, leadSeconds); + } + ScheduleAudio(_clock.CurrentTime); + } + + public void Update() + { + if (!_usingPremix && _chart != null && _assets != null && StartDspTime > 0) + ScheduleAudio(_clock.CurrentTime); + } + + public void PlayKeySound(string soundId, int lane) + { + if (lane < 0 || lane >= _laneSources.Length || string.IsNullOrWhiteSpace(soundId) || _assets == null) + return; + if (_assets.TryGetClip(soundId, out var clip)) + _laneSources[lane].PlayOneShot(clip); + } + + public void Stop() + { + if (_disposed) + return; + foreach (var source in _laneSources) source.Stop(); + _premixSource.Stop(); + _premixSource.clip = null; + foreach (var slot in _scheduledSlots) + { + slot.Source.Stop(); + slot.Source.clip = null; + } + _scheduledReservations.Reset(); + _scheduledEvents.Clear(); + _nextScheduledEvent = 0; + StartDspTime = 0; + _usingPremix = false; + _chart = null; + _assets = null; + } + + public void Dispose() + { + if (_disposed) + return; + Stop(); + _disposed = true; + foreach (var source in _laneSources) + DestroyUnityObject(source); + DestroyUnityObject(_premixSource); + foreach (var slot in _scheduledSlots) + DestroyUnityObject(slot.Source); + _scheduledSlots.Clear(); + } + + internal static bool ShouldDropLateScheduledEvent(double scheduledDspTime, double currentDspTime) => + scheduledDspTime < currentDspTime - LateEventDropThresholdSeconds; + + private void ScheduleAudio(double currentDspTime) + { + using var profilerScope = ScheduleAudioMarker.Auto(); + var horizon = currentDspTime + SchedulingHorizon; + while (_nextScheduledEvent < _scheduledEvents.Count) + { + var item = _scheduledEvents[_nextScheduledEvent]; + var scheduledTime = StartDspTime + item.Time; + if (scheduledTime > horizon) + break; + + _nextScheduledEvent++; + if (!_assets.TryGetClip(item.SoundId, out var clip)) + continue; + if (ShouldDropLateScheduledEvent(scheduledTime, currentDspTime)) + { + DroppedLateEventCount++; + continue; + } + + scheduledTime = Math.Max(scheduledTime, currentDspTime + 0.005); + var slotIndex = _scheduledReservations.Reserve( + currentDspTime, + scheduledTime, + Math.Max(clip.length, 0.01f)); + if (slotIndex == _scheduledSlots.Count) + _scheduledSlots.Add(new AudioSlot(CreateSource(ScheduledSoundPriority))); + + var slot = _scheduledSlots[slotIndex]; + slot.Source.clip = clip; + slot.Source.PlayScheduled(scheduledTime); + } + } + + private void PrewarmScheduledSources(AudioAssetStore assets, double leadSeconds) + { + var requiredSlotCount = ScheduledAudioSourcePlanner.CalculateRequiredSlotCount( + _scheduledEvents, + assets.GetClipDurations(), + SchedulingHorizon, + Math.Max(leadSeconds, 0)); + if (requiredSlotCount > 0) + requiredSlotCount += ScheduledSourceHeadroom; + while (_scheduledSlots.Count < requiredSlotCount) + _scheduledSlots.Add(new AudioSlot(CreateSource(ScheduledSoundPriority))); + } + + private AudioSource CreateSource(int priority) + { + var source = _owner.AddComponent(); + source.playOnAwake = false; + source.spatialBlend = 0; + source.priority = priority; + source.bypassEffects = true; + source.bypassListenerEffects = true; + source.bypassReverbZones = true; + return source; + } + + private void ThrowIfDisposed() + { + if (_disposed) + throw new ObjectDisposedException(nameof(SongAudioPlaybackEngine)); + } + + private static void DestroyUnityObject(UnityEngine.Object target) + { + if (target == null) + return; + if (Application.isPlaying) + UnityEngine.Object.Destroy(target); + else + UnityEngine.Object.DestroyImmediate(target); + } + + private sealed class AudioSlot + { + public AudioSlot(AudioSource source) + { + Source = source; + } + + public AudioSource Source { get; } + } + } + + internal readonly struct ScheduledAudioEvent + { + public ScheduledAudioEvent(double time, string soundId) + { + Time = time; + SoundId = soundId; + } + + public double Time { get; } + public string SoundId { get; } + } + + internal static class AudioPlaybackTimeline + { + public static List Build(PlayableChart chart, bool reactiveKeySoundsEnabled) + { + if (chart == null) + throw new ArgumentNullException(nameof(chart)); + + var events = new List( + chart.BackgroundAudioEvents.Count + (reactiveKeySoundsEnabled ? 0 : chart.Notes.Count)); + foreach (var backgroundSound in chart.BackgroundAudioEvents) + { + if (!string.IsNullOrWhiteSpace(backgroundSound.SoundId)) + events.Add(new ScheduledAudioEvent(backgroundSound.Time, backgroundSound.SoundId)); + } + + if (!reactiveKeySoundsEnabled) + { + foreach (var note in chart.Notes) + { + if (!string.IsNullOrWhiteSpace(note.SoundId)) + events.Add(new ScheduledAudioEvent(note.StartTime, note.SoundId)); + } + } + + events.Sort((left, right) => left.Time.CompareTo(right.Time)); + return events; + } + } + + internal sealed class DspReservationPool + { + internal const double RecycleGuardSeconds = 0.02; + private readonly List _reservedUntil = new List(); + + public int Reserve(double currentDspTime, double scheduledDspTime, double durationSeconds) + { + var slotIndex = -1; + for (var index = 0; index < _reservedUntil.Count; index++) + { + if (_reservedUntil[index] > currentDspTime) + continue; + slotIndex = index; + break; + } + if (slotIndex < 0) + { + slotIndex = _reservedUntil.Count; + _reservedUntil.Add(0); + } + + _reservedUntil[slotIndex] = scheduledDspTime + durationSeconds + RecycleGuardSeconds; + return slotIndex; + } + + public void Reset() => _reservedUntil.Clear(); + } + + internal static class ScheduledAudioSourcePlanner + { + public static int CalculateRequiredSlotCount( + IReadOnlyList events, + IReadOnlyDictionary clipDurations, + double schedulingHorizon, + double playbackLeadSeconds) + { + if (events == null) + throw new ArgumentNullException(nameof(events)); + if (clipDurations == null) + throw new ArgumentNullException(nameof(clipDurations)); + + var reservedUntil = new List(); + for (var eventIndex = 0; eventIndex < events.Count; eventIndex++) + { + var item = events[eventIndex]; + if (!clipDurations.TryGetValue(item.SoundId, out var durationSeconds)) + continue; + + var schedulerSongTime = Math.Max(-playbackLeadSeconds, item.Time - schedulingHorizon); + var slotIndex = -1; + for (var index = 0; index < reservedUntil.Count; index++) + { + if (reservedUntil[index] > schedulerSongTime) + continue; + slotIndex = index; + break; + } + if (slotIndex < 0) + { + slotIndex = reservedUntil.Count; + reservedUntil.Add(0); + } + + reservedUntil[slotIndex] = item.Time + Math.Max(durationSeconds, 0.01) + + DspReservationPool.RecycleGuardSeconds; + } + + return reservedUntil.Count; + } + } +} diff --git a/Assets/ProjectQuint/Runtime/Unity/Audio/SongAudioPlaybackEngine.cs.meta b/Assets/ProjectQuint/Runtime/Unity/Audio/SongAudioPlaybackEngine.cs.meta new file mode 100644 index 0000000..0147c9c --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Audio/SongAudioPlaybackEngine.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e035d78e984f745748ac545806648d50 \ No newline at end of file diff --git a/Assets/ProjectQuint/Runtime/Unity/Audio/WaveAudioDecoder.cs b/Assets/ProjectQuint/Runtime/Unity/Audio/WaveAudioDecoder.cs new file mode 100644 index 0000000..00a3d9f --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Audio/WaveAudioDecoder.cs @@ -0,0 +1,447 @@ +using System; +using System.Collections.Generic; +using System.IO; + +namespace ProjectQuint.Audio +{ + internal enum WaveContainerStatus + { + AudioData, + SilentPlaceholder, + Invalid, + } + + internal readonly struct WaveContainerInspection + { + public WaveContainerInspection(WaveContainerStatus status, string detail) + { + Status = status; + Detail = detail ?? string.Empty; + } + + public WaveContainerStatus Status { get; } + public string Detail { get; } + } + + public sealed class DecodedWaveAudio + { + public DecodedWaveAudio(float[] samples, int channels, int sampleRate) + { + Samples = samples; + Channels = channels; + SampleRate = sampleRate; + } + + public float[] Samples { get; } + public int Channels { get; } + public int SampleRate { get; } + public int FrameCount => Samples.Length / Channels; + } + + public static class WaveAudioDecoder + { + private const ushort MicrosoftAdpcmFormat = 0x0002; + private const uint RiffId = 0x46464952; + private const uint WaveId = 0x45564157; + private const uint FormatId = 0x20746D66; + private const uint DataId = 0x61746164; + + private static readonly int[] AdaptationTable = + { + 230, 230, 230, 230, 307, 409, 512, 614, + 768, 614, 512, 409, 307, 230, 230, 230, + }; + + public static bool IsMicrosoftAdpcm(string filePath) + { + try + { + using var stream = File.OpenRead(filePath); + using var reader = new BinaryReader(stream); + if (stream.Length < 12 || reader.ReadUInt32() != RiffId) + return false; + stream.Position = 8; + if (reader.ReadUInt32() != WaveId) + return false; + + while (stream.Position + 8 <= stream.Length) + { + var chunkId = reader.ReadUInt32(); + var chunkSize = reader.ReadUInt32(); + var chunkStart = stream.Position; + if (chunkId == FormatId && chunkSize >= 2) + return reader.ReadUInt16() == MicrosoftAdpcmFormat; + + var nextChunk = chunkStart + chunkSize + (chunkSize & 1); + if (nextChunk <= chunkStart || nextChunk > stream.Length) + return false; + stream.Position = nextChunk; + } + } + catch (Exception) + { + return false; + } + + return false; + } + + /// + /// Validates the RIFF/WAVE container without decoding its payload. A zero-length data + /// chunk is a legitimate BMS silent placeholder only when the surrounding RIFF and fmt + /// chunks are structurally valid. Truncated or malformed WAV files remain decode errors. + /// + internal static WaveContainerInspection InspectContainer(string filePath) + { + try + { + using var stream = File.Open(filePath, FileMode.Open, FileAccess.Read, FileShare.Read); + using var reader = new BinaryReader(stream); + if (stream.Length < 12) + return InvalidContainer("The WAV file is truncated."); + if (reader.ReadUInt32() != RiffId) + return InvalidContainer("The file is not a RIFF stream."); + + var riffSize = reader.ReadUInt32(); + if (riffSize < 4) + return InvalidContainer("The RIFF size is invalid."); + var riffEnd = checked(8L + riffSize); + if (riffEnd > stream.Length) + return InvalidContainer("The RIFF payload is truncated."); + if (reader.ReadUInt32() != WaveId) + return InvalidContainer("The RIFF stream is not WAVE audio."); + + var formatFound = false; + var dataFound = false; + var hasAudioData = false; + while (stream.Position < riffEnd) + { + if (riffEnd - stream.Position < 8) + return InvalidContainer("The WAV chunk header is truncated."); + + var chunkId = reader.ReadUInt32(); + var chunkSize = reader.ReadUInt32(); + var chunkStart = stream.Position; + var chunkEnd = checked(chunkStart + chunkSize); + if (chunkEnd > stream.Length) + return InvalidContainer("A WAV chunk extends beyond the physical file."); + + if (chunkId == FormatId) + { + if (chunkSize < 16) + return InvalidContainer("The WAV fmt chunk is truncated."); + + var formatTag = reader.ReadUInt16(); + var channels = reader.ReadUInt16(); + var sampleRate = reader.ReadUInt32(); + var byteRate = reader.ReadUInt32(); + var blockAlign = reader.ReadUInt16(); + var bitsPerSample = reader.ReadUInt16(); + if (formatTag == 0 || channels == 0 || sampleRate == 0 || + byteRate == 0 || blockAlign == 0 || bitsPerSample == 0) + { + return InvalidContainer("The WAV fmt chunk contains invalid audio parameters."); + } + formatFound = true; + } + else if (chunkId == DataId) + { + dataFound = true; + hasAudioData |= chunkSize > 0; + } + + var paddedEnd = checked(chunkEnd + (chunkSize & 1)); + if (paddedEnd > stream.Length) + return InvalidContainer("The WAV chunk padding is truncated."); + stream.Position = paddedEnd; + } + + if (!formatFound) + return InvalidContainer("The WAV fmt chunk is missing."); + if (!dataFound) + return InvalidContainer("The WAV data chunk is missing."); + return hasAudioData + ? new WaveContainerInspection(WaveContainerStatus.AudioData, string.Empty) + : new WaveContainerInspection( + WaveContainerStatus.SilentPlaceholder, + "The WAV contains a valid zero-length data chunk."); + } + catch (Exception exception) + { + return InvalidContainer(exception.Message); + } + } + + public static bool TryDecodeFile(string filePath, out DecodedWaveAudio audio, out string error) + { + try + { + return TryDecode(File.ReadAllBytes(filePath), out audio, out error); + } + catch (Exception exception) + { + audio = null; + error = exception.Message; + return false; + } + } + + public static bool TryDecode(byte[] bytes, out DecodedWaveAudio audio, out string error) + { + audio = null; + error = string.Empty; + try + { + if (bytes == null || bytes.Length < 12) + return Fail("The WAV file is truncated.", out error); + + using var stream = new MemoryStream(bytes, false); + using var reader = new BinaryReader(stream); + if (reader.ReadUInt32() != RiffId || reader.ReadUInt32() + 8 > stream.Length || reader.ReadUInt32() != WaveId) + return Fail("The file is not a valid RIFF/WAVE stream.", out error); + + WaveFormat format = null; + var dataOffset = -1; + var dataLength = 0; + while (stream.Position + 8 <= stream.Length) + { + var chunkId = reader.ReadUInt32(); + var declaredSize = reader.ReadUInt32(); + var chunkStart = stream.Position; + var availableSize = Math.Min((long)declaredSize, stream.Length - chunkStart); + + if (chunkId == FormatId) + { + if (!TryReadFormat(reader, availableSize, out format, out error)) + return false; + } + else if (chunkId == DataId) + { + dataOffset = checked((int)chunkStart); + dataLength = checked((int)availableSize); + } + + var nextChunk = chunkStart + declaredSize + (declaredSize & 1); + if (nextChunk <= chunkStart || nextChunk > stream.Length) + break; + stream.Position = nextChunk; + } + + if (format == null) + return Fail("The WAV fmt chunk is missing.", out error); + if (dataOffset < 0 || dataLength == 0) + return Fail("The WAV data chunk is missing or empty.", out error); + if (format.FormatTag != MicrosoftAdpcmFormat) + return Fail($"WAV format 0x{format.FormatTag:X4} is not Microsoft ADPCM.", out error); + if (format.Channels < 1 || format.Channels > 2) + return Fail($"Microsoft ADPCM with {format.Channels} channels is not supported.", out error); + if (format.BlockAlign <= format.Channels * 7 || format.SamplesPerBlock < 2) + return Fail("The Microsoft ADPCM block layout is invalid.", out error); + + var samples = DecodeMicrosoftAdpcm(bytes, dataOffset, dataLength, format); + if (samples.Count == 0) + return Fail("The Microsoft ADPCM stream contains no decodable samples.", out error); + + audio = new DecodedWaveAudio(samples.ToArray(), format.Channels, format.SampleRate); + return true; + } + catch (Exception exception) + { + audio = null; + error = exception.Message; + return false; + } + } + + private static bool TryReadFormat( + BinaryReader reader, + long chunkSize, + out WaveFormat format, + out string error) + { + format = null; + error = string.Empty; + if (chunkSize < 22) + return Fail("The Microsoft ADPCM fmt chunk is truncated.", out error); + + var start = reader.BaseStream.Position; + var formatTag = reader.ReadUInt16(); + var channels = reader.ReadUInt16(); + var sampleRate = checked((int)reader.ReadUInt32()); + reader.ReadUInt32(); + var blockAlign = reader.ReadUInt16(); + reader.ReadUInt16(); + reader.ReadUInt16(); + var samplesPerBlock = reader.ReadUInt16(); + var coefficientCount = reader.ReadUInt16(); + + if (coefficientCount == 0 || start + 22L + coefficientCount * 4L > start + chunkSize) + return Fail("The Microsoft ADPCM coefficient table is invalid.", out error); + + var coefficient1 = new short[coefficientCount]; + var coefficient2 = new short[coefficientCount]; + for (var index = 0; index < coefficientCount; index++) + { + coefficient1[index] = reader.ReadInt16(); + coefficient2[index] = reader.ReadInt16(); + } + + format = new WaveFormat( + formatTag, + channels, + sampleRate, + blockAlign, + samplesPerBlock, + coefficient1, + coefficient2); + return true; + } + + private static List DecodeMicrosoftAdpcm( + byte[] bytes, + int dataOffset, + int dataLength, + WaveFormat format) + { + var estimatedFrames = (dataLength / format.BlockAlign + 1) * format.SamplesPerBlock; + var samples = new List(Math.Max(estimatedFrames * format.Channels, 0)); + var end = dataOffset + dataLength; + for (var blockOffset = dataOffset; blockOffset < end; blockOffset += format.BlockAlign) + { + var blockLength = Math.Min(format.BlockAlign, end - blockOffset); + var headerLength = format.Channels * 7; + if (blockLength < headerLength) + break; + + var states = new ChannelState[format.Channels]; + var cursor = blockOffset; + for (var channel = 0; channel < format.Channels; channel++) + states[channel].Predictor = bytes[cursor++]; + for (var channel = 0; channel < format.Channels; channel++) + { + states[channel].Delta = ReadInt16(bytes, cursor); + cursor += 2; + } + for (var channel = 0; channel < format.Channels; channel++) + { + states[channel].Sample1 = ReadInt16(bytes, cursor); + cursor += 2; + } + for (var channel = 0; channel < format.Channels; channel++) + { + states[channel].Sample2 = ReadInt16(bytes, cursor); + cursor += 2; + if (states[channel].Predictor >= format.Coefficient1.Length) + throw new InvalidDataException("A Microsoft ADPCM block uses an unknown predictor."); + states[channel].Delta = Math.Max(states[channel].Delta, 16); + } + + AppendInitialFrames(samples, states); + var decodedFrames = 2; + var blockEnd = blockOffset + blockLength; + if (format.Channels == 1) + { + while (cursor < blockEnd && decodedFrames < format.SamplesPerBlock) + { + var packed = bytes[cursor++]; + AppendDecodedSample(samples, ref states[0], packed >> 4, format); + decodedFrames++; + if (decodedFrames >= format.SamplesPerBlock) + break; + AppendDecodedSample(samples, ref states[0], packed & 0x0F, format); + decodedFrames++; + } + } + else + { + while (cursor < blockEnd && decodedFrames < format.SamplesPerBlock) + { + var packed = bytes[cursor++]; + AppendDecodedSample(samples, ref states[0], packed >> 4, format); + AppendDecodedSample(samples, ref states[1], packed & 0x0F, format); + decodedFrames++; + } + } + } + + return samples; + } + + private static void AppendInitialFrames(List samples, ChannelState[] states) + { + foreach (var state in states) + samples.Add(ToFloat(state.Sample2)); + foreach (var state in states) + samples.Add(ToFloat(state.Sample1)); + } + + private static void AppendDecodedSample( + List samples, + ref ChannelState state, + int nibble, + WaveFormat format) + { + var signedNibble = nibble >= 8 ? nibble - 16 : nibble; + var predicted = (state.Sample1 * format.Coefficient1[state.Predictor] + + state.Sample2 * format.Coefficient2[state.Predictor]) / 256; + predicted += signedNibble * state.Delta; + predicted = Math.Max(short.MinValue, Math.Min(short.MaxValue, predicted)); + + state.Sample2 = state.Sample1; + state.Sample1 = predicted; + state.Delta = Math.Max(16, AdaptationTable[nibble] * state.Delta / 256); + samples.Add(ToFloat(predicted)); + } + + private static short ReadInt16(byte[] bytes, int offset) => + unchecked((short)(bytes[offset] | bytes[offset + 1] << 8)); + + private static float ToFloat(int sample) => sample / 32768f; + + private static bool Fail(string message, out string error) + { + error = message; + return false; + } + + private static WaveContainerInspection InvalidContainer(string detail) => + new WaveContainerInspection(WaveContainerStatus.Invalid, detail); + + private sealed class WaveFormat + { + public WaveFormat( + ushort formatTag, + int channels, + int sampleRate, + int blockAlign, + int samplesPerBlock, + short[] coefficient1, + short[] coefficient2) + { + FormatTag = formatTag; + Channels = channels; + SampleRate = sampleRate; + BlockAlign = blockAlign; + SamplesPerBlock = samplesPerBlock; + Coefficient1 = coefficient1; + Coefficient2 = coefficient2; + } + + public ushort FormatTag { get; } + public int Channels { get; } + public int SampleRate { get; } + public int BlockAlign { get; } + public int SamplesPerBlock { get; } + public short[] Coefficient1 { get; } + public short[] Coefficient2 { get; } + } + + private struct ChannelState + { + public int Predictor; + public int Delta; + public int Sample1; + public int Sample2; + } + } +} diff --git a/Assets/ProjectQuint/Runtime/Unity/Audio/WaveAudioDecoder.cs.meta b/Assets/ProjectQuint/Runtime/Unity/Audio/WaveAudioDecoder.cs.meta new file mode 100644 index 0000000..0cd462e --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Audio/WaveAudioDecoder.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 1234f513bc93d48daaef279c67e00820 \ No newline at end of file diff --git a/Assets/ProjectQuint/Runtime/Unity/Bootstrap.meta b/Assets/ProjectQuint/Runtime/Unity/Bootstrap.meta new file mode 100644 index 0000000..7882528 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Bootstrap.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 8ac866a5c17ce403e9b745726534f447 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/ProjectQuint/Runtime/Unity/Bootstrap/AssemblyInfo.cs b/Assets/ProjectQuint/Runtime/Unity/Bootstrap/AssemblyInfo.cs new file mode 100644 index 0000000..2ab96b6 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Bootstrap/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("ProjectQuint.Tests.EditMode")] diff --git a/Assets/ProjectQuint/Runtime/Unity/Bootstrap/AssemblyInfo.cs.meta b/Assets/ProjectQuint/Runtime/Unity/Bootstrap/AssemblyInfo.cs.meta new file mode 100644 index 0000000..38c895b --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Bootstrap/AssemblyInfo.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: db766f0a4143249338cae181a3130071 \ No newline at end of file diff --git a/Assets/ProjectQuint/Runtime/Unity/Bootstrap/PinnedPackPremixCacheService.cs b/Assets/ProjectQuint/Runtime/Unity/Bootstrap/PinnedPackPremixCacheService.cs new file mode 100644 index 0000000..3febfae --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Bootstrap/PinnedPackPremixCacheService.cs @@ -0,0 +1,508 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using ProjectQuint.Audio; +using ProjectQuint.Core.Charts; +using ProjectQuint.Features.Loading; +using ProjectQuint.Library; +using ProjectQuint.Settings; +using UnityEngine; + +namespace ProjectQuint.Bootstrap +{ + internal readonly struct PinnedPackCacheProgress + { + public PinnedPackCacheProgress(int current, int total, string detail) + { + Current = Math.Max(0, current); + Total = Math.Max(0, total); + Detail = detail ?? string.Empty; + } + + public int Current { get; } + public int Total { get; } + public string Detail { get; } + } + + internal sealed class PinnedPackCachePlan + { + public BmsPackDefinition Pack { get; set; } + public List Items { get; set; } = new List(); + public int SourceChartCount { get; set; } + public int ExistingPinnedCount { get; set; } + public int AutomaticPromotionCount { get; set; } + public int RenderCount { get; set; } + public long EstimatedAdditionalBytes { get; set; } + public long AvailableBytes { get; set; } + public TimeSpan EstimatedDuration { get; set; } + public int OutputSampleRate { get; set; } + + public string Summary => + $"{SourceChartCount:N0} CHARTS // {Items.Count:N0} UNIQUE MIXES\n" + + $"{ExistingPinnedCount:N0} PINNED HITS // {AutomaticPromotionCount:N0} AUTO HITS // {RenderCount:N0} TO ENCODE\n" + + $"ESTIMATED ADDITIONAL {PremixedSongCacheSnapshot.FormatBytes(EstimatedAdditionalBytes)}\n" + + $"FREE SPACE {PremixedSongCacheSnapshot.FormatBytes(AvailableBytes)} // " + + $"ESTIMATED TIME {FormatDuration(EstimatedDuration)}"; + + private static string FormatDuration(TimeSpan value) => value.TotalHours >= 1 + ? $"{(int)value.TotalHours}:{value.Minutes:00}:{value.Seconds:00}" + : $"{(int)value.TotalMinutes}:{value.Seconds:00}"; + } + + internal sealed class PinnedPackCacheItem + { + public SongLibraryEntry Entry { get; set; } + public string Key { get; set; } = string.Empty; + public double DurationSeconds { get; set; } + public PremixedSongCacheTier ExistingTier { get; set; } = PremixedSongCacheTier.Automatic; + public bool Exists { get; set; } + public long ExistingBytes { get; set; } + } + + internal readonly struct PinnedPackCacheStatus + { + public PinnedPackCacheStatus( + BmsPackDefinition pack, + bool isPinned, + int objectCount, + int sharedObjectCount, + int missingObjectCount, + long referencedBytes, + long exclusiveBytes, + DateTime createdUtc) + { + Pack = pack; + IsPinned = isPinned; + ObjectCount = Math.Max(0, objectCount); + SharedObjectCount = Math.Max(0, sharedObjectCount); + MissingObjectCount = Math.Max(0, missingObjectCount); + ReferencedBytes = Math.Max(0, referencedBytes); + ExclusiveBytes = Math.Max(0, exclusiveBytes); + CreatedUtc = createdUtc; + } + + public BmsPackDefinition Pack { get; } + public bool IsPinned { get; } + public int ObjectCount { get; } + public int SharedObjectCount { get; } + public int MissingObjectCount { get; } + public long ReferencedBytes { get; } + public long ExclusiveBytes { get; } + public DateTime CreatedUtc { get; } + } + + internal readonly struct PinnedPackCacheDeleteResult + { + public PinnedPackCacheDeleteResult(bool success, int deletedObjects, long freedBytes, string status) + { + Success = success; + DeletedObjects = Math.Max(0, deletedObjects); + FreedBytes = Math.Max(0, freedBytes); + Status = status ?? string.Empty; + } + + public bool Success { get; } + public int DeletedObjects { get; } + public long FreedBytes { get; } + public string Status { get; } + } + + /// + /// Plans and creates an explicitly approved, unbounded pack cache. Objects remain globally + /// content-addressed; the pack manifest only records ownership, so identical charts across + /// packs share the same pinned FLAC instead of being encoded repeatedly. + /// + internal sealed class PinnedPackPremixCacheService + { + private const double EstimatedFlacRatioForPcm24 = 0.55; + private const double EstimatedRenderTimePerAudioSecond = 0.20; + private readonly PremixedSongCache _cache; + private readonly string _manifestRoot; + + public PinnedPackPremixCacheService(PremixedSongCache cache = null) + { + _cache = cache ?? PremixedSongCache.Shared; + _manifestRoot = Path.Combine(_cache.PinnedRootPath, "manifests"); + } + + public Task AnalyzeAsync( + BmsPackDefinition pack, + IReadOnlyList entries, + int outputSampleRate, + CancellationToken cancellationToken) => Task.Run(() => + { + if (pack == null) + throw new ArgumentNullException(nameof(pack)); + if (entries == null) + throw new ArgumentNullException(nameof(entries)); + if (outputSampleRate <= 0) + throw new ArgumentOutOfRangeException(nameof(outputSampleRate)); + + var unique = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var entry in entries) + { + cancellationToken.ThrowIfCancellationRequested(); + var prepared = SongLoadCoordinator.ParseAndTransform(entry, LaneModifier.Off, 0); + var key = _cache.BuildKey(prepared.Chart, outputSampleRate, cancellationToken); + if (unique.ContainsKey(key)) + continue; + var read = _cache.TryRead(key, outputSampleRate, cancellationToken); + unique[key] = new PinnedPackCacheItem + { + Entry = entry, + Key = key, + DurationSeconds = Math.Max(0.01, prepared.Chart.Duration), + Exists = read.Hit, + ExistingTier = read.Hit ? read.Entry.Tier : PremixedSongCacheTier.Automatic, + ExistingBytes = read.Hit ? read.Entry.EncodedBytes : 0, + }; + } + + var items = unique.Values.ToList(); + var additionalBytes = items.Sum(item => item.Exists + ? item.ExistingTier == PremixedSongCacheTier.Automatic ? item.ExistingBytes : 0 + : EstimateFlacBytes(item.DurationSeconds, outputSampleRate)); + var renderSeconds = items.Where(item => !item.Exists).Sum(item => + 0.35 + item.DurationSeconds * EstimatedRenderTimePerAudioSecond); + return new PinnedPackCachePlan + { + Pack = pack, + Items = items, + SourceChartCount = entries.Count, + ExistingPinnedCount = items.Count(item => item.Exists && + item.ExistingTier == PremixedSongCacheTier.Pinned), + AutomaticPromotionCount = items.Count(item => item.Exists && + item.ExistingTier == PremixedSongCacheTier.Automatic), + RenderCount = items.Count(item => !item.Exists), + EstimatedAdditionalBytes = additionalBytes, + AvailableBytes = GetAvailableBytes(_cache.PinnedRootPath), + EstimatedDuration = TimeSpan.FromSeconds(Math.Max(1.0, renderSeconds)), + OutputSampleRate = outputSampleRate, + }; + }, cancellationToken); + + public IReadOnlyList GetPackStatuses( + IReadOnlyList packs) + { + var manifests = LoadManifests(); + var referenceCounts = BuildReferenceCounts(manifests); + var result = new List(packs?.Count ?? 0); + foreach (var pack in packs ?? Array.Empty()) + { + var manifest = manifests.FirstOrDefault(item => + string.Equals(item.PackId, pack.Id, StringComparison.OrdinalIgnoreCase)); + if (manifest == null) + { + result.Add(new PinnedPackCacheStatus(pack, false, 0, 0, 0, 0, 0, default)); + continue; + } + + long referencedBytes = 0; + long exclusiveBytes = 0; + var sharedCount = 0; + var missingCount = 0; + var keys = manifest.Keys + .Where(IsValidKey) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + foreach (var key in keys) + { + var audioPath = PinnedAudioPath(key); + if (!File.Exists(audioPath)) + { + missingCount++; + continue; + } + var bytes = new FileInfo(audioPath).Length; + referencedBytes += bytes; + if (referenceCounts.TryGetValue(key, out var references) && references > 1) + sharedCount++; + else + exclusiveBytes += bytes; + } + var created = manifest.CreatedUtcTicks > 0 + ? new DateTime(manifest.CreatedUtcTicks, DateTimeKind.Utc) + : default; + result.Add(new PinnedPackCacheStatus( + pack, + true, + keys.Length, + sharedCount, + missingCount, + referencedBytes, + exclusiveBytes, + created)); + } + return result; + } + + public PinnedPackCacheDeleteResult DeletePackCache(string packId) + { + if (string.IsNullOrWhiteSpace(packId)) + return new PinnedPackCacheDeleteResult(false, 0, 0, "PACK ID IS EMPTY"); + try + { + var manifests = LoadManifests(); + var target = manifests.FirstOrDefault(item => + string.Equals(item.PackId, packId, StringComparison.OrdinalIgnoreCase)); + if (target == null) + return new PinnedPackCacheDeleteResult(false, 0, 0, "PACK CACHE NOT FOUND"); + DeleteIfPresent(target.SourcePath); + var collection = CollectOrphanedObjects(); + return new PinnedPackCacheDeleteResult( + true, + collection.DeletedObjects, + collection.FreedBytes, + collection.Status.StartsWith("ORPHAN CLEANUP DEFERRED", StringComparison.OrdinalIgnoreCase) + ? $"PACK CACHE REMOVED // {collection.Status}" + : $"PACK CACHE REMOVED // {collection.DeletedObjects:N0} ORPHANS // " + + $"{PremixedSongCacheSnapshot.FormatBytes(collection.FreedBytes)} FREED"); + } + catch (Exception exception) + { + return new PinnedPackCacheDeleteResult(false, 0, 0, $"DELETE FAILED // {exception.Message}"); + } + } + + public IEnumerator Build( + PinnedPackCachePlan plan, + Action progress, + Func cancellationRequested, + Action completed) + { + if (plan?.Pack == null) + throw new ArgumentNullException(nameof(plan)); + var completedKeys = new List(plan.Items.Count); + var hadManifest = HasManifest(plan.Pack.Id); + for (var index = 0; index < plan.Items.Count; index++) + { + if (cancellationRequested?.Invoke() == true) + { + if (!hadManifest && completedKeys.Count > 0) + WriteManifest(plan.Pack, completedKeys); + completed?.Invoke("PINNED PACK CACHE CANCELLED"); + yield break; + } + + var item = plan.Items[index]; + progress?.Invoke(new PinnedPackCacheProgress( + index, + plan.Items.Count, + $"{item.Entry.DisplayTitle} // CHECKING CONTENT CACHE")); + var current = _cache.TryRead(item.Key, plan.OutputSampleRate); + if (current.Hit && current.Entry.Tier == PremixedSongCacheTier.Pinned) + { + completedKeys.Add(item.Key); + continue; + } + if (current.Hit && _cache.PromoteToPinned(item.Key, plan.OutputSampleRate, out _)) + { + completedKeys.Add(item.Key); + continue; + } + + SongLoadCoordinator.PreparedChart prepared; + try + { + prepared = SongLoadCoordinator.ParseAndTransform(item.Entry, LaneModifier.Off, 0); + } + catch (Exception exception) + { + Debug.LogWarning($"[ProjectQuint Audio] Pinned cache skipped {item.Entry.ChartPath}: {exception.Message}"); + continue; + } + + using var assets = new AudioAssetStore(); + using var renderer = new PremixedSongRenderer(_cache); + yield return renderer.Prepare( + prepared.Chart, + assets, + (currentItem, totalItems, detail) => progress?.Invoke(new PinnedPackCacheProgress( + index, + plan.Items.Count, + $"{item.Entry.DisplayTitle} // {detail} ({currentItem}/{totalItems})")), + cancellationRequested, + PremixedSongOutput.FmodStreamFile, + plan.OutputSampleRate, + PremixedSongCacheTier.Pinned); + if (renderer.State == PremixedSongState.Cancelled) + { + if (!hadManifest && completedKeys.Count > 0) + WriteManifest(plan.Pack, completedKeys); + completed?.Invoke("PINNED PACK CACHE CANCELLED"); + yield break; + } + if (!renderer.IsReady) + { + Debug.LogWarning( + $"[ProjectQuint Audio] Pinned cache skipped {item.Entry.ChartPath}: {renderer.Error}"); + continue; + } + completedKeys.Add(item.Key); + } + + WriteManifest(plan.Pack, completedKeys); + CollectOrphanedObjects(); + progress?.Invoke(new PinnedPackCacheProgress(plan.Items.Count, plan.Items.Count, "PACK CACHE READY")); + completed?.Invoke( + $"PINNED {completedKeys.Count:N0}/{plan.Items.Count:N0} UNIQUE MIXES // " + + $"{plan.Pack.DisplayName}"); + } + + private static long EstimateFlacBytes(double durationSeconds, int outputSampleRate) => + checked((long)Math.Ceiling( + Math.Max(0.01, durationSeconds) * outputSampleRate * + SmoothAudioMixer.OutputChannels * 3d * EstimatedFlacRatioForPcm24)); + + private static long GetAvailableBytes(string targetPath) + { + try + { + var root = Path.GetPathRoot(Path.GetFullPath(targetPath)); + return string.IsNullOrWhiteSpace(root) ? 0 : new DriveInfo(root).AvailableFreeSpace; + } + catch + { + return 0; + } + } + + private bool HasManifest(string packId) => LoadManifests().Any(item => + string.Equals(item.PackId, packId, StringComparison.OrdinalIgnoreCase)); + + private void WriteManifest(BmsPackDefinition pack, List keys) + { + Directory.CreateDirectory(_manifestRoot); + var document = new PinnedPackCacheManifest + { + PackId = pack.Id ?? string.Empty, + PackName = pack.DisplayName ?? string.Empty, + CreatedUtcTicks = DateTime.UtcNow.Ticks, + Keys = keys.Distinct(StringComparer.OrdinalIgnoreCase).OrderBy(key => key).ToList(), + }; + var path = Path.Combine( + _manifestRoot, + SanitizeFileName(document.PackId) + ".json"); + var temporaryPath = path + ".tmp"; + File.WriteAllText(temporaryPath, JsonUtility.ToJson(document, true)); + if (File.Exists(path)) + File.Delete(path); + File.Move(temporaryPath, path); + } + + private static string SanitizeFileName(string value) + { + var builder = new System.Text.StringBuilder(); + foreach (var character in value ?? string.Empty) + builder.Append(char.IsLetterOrDigit(character) || character == '-' || character == '_' + ? character + : '_'); + return builder.Length == 0 ? "pack" : builder.ToString(); + } + + private List LoadManifests() => LoadManifests(out _); + + private List LoadManifests(out bool invalidManifestDetected) + { + invalidManifestDetected = false; + var result = new List(); + if (!Directory.Exists(_manifestRoot)) + return result; + foreach (var path in Directory.EnumerateFiles( + _manifestRoot, + "*.json", + SearchOption.TopDirectoryOnly)) + { + try + { + var document = JsonUtility.FromJson(File.ReadAllText(path)); + if (document == null || document.SchemaVersion != 1 || + string.IsNullOrWhiteSpace(document.PackId) || document.Keys == null) + { + invalidManifestDetected = true; + continue; + } + document.SourcePath = path; + result.Add(document); + } + catch (Exception exception) + { + invalidManifestDetected = true; + Debug.LogWarning($"[ProjectQuint Audio] Ignored invalid pack cache manifest '{path}': {exception.Message}"); + } + } + return result; + } + + private static Dictionary BuildReferenceCounts( + IEnumerable manifests) + { + var counts = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var manifest in manifests) + foreach (var key in manifest.Keys.Where(IsValidKey).Distinct(StringComparer.OrdinalIgnoreCase)) + counts[key] = counts.TryGetValue(key, out var count) ? count + 1 : 1; + return counts; + } + + private PinnedPackCacheDeleteResult CollectOrphanedObjects() + { + var manifests = LoadManifests(out var invalidManifestDetected); + if (invalidManifestDetected) + return new PinnedPackCacheDeleteResult( + true, + 0, + 0, + "ORPHAN CLEANUP DEFERRED // INVALID MANIFEST REQUIRES REBUILD"); + var referenced = new HashSet( + manifests.SelectMany(manifest => manifest.Keys).Where(IsValidKey), + StringComparer.OrdinalIgnoreCase); + if (!Directory.Exists(_cache.PinnedRootPath)) + return new PinnedPackCacheDeleteResult(true, 0, 0, "NO ORPHANS"); + var deleted = 0; + long freed = 0; + foreach (var audioPath in Directory.EnumerateFiles( + _cache.PinnedRootPath, + "*.flac", + SearchOption.TopDirectoryOnly)) + { + var key = Path.GetFileNameWithoutExtension(audioPath); + if (referenced.Contains(key)) + continue; + var bytes = new FileInfo(audioPath).Length; + File.Delete(audioPath); + DeleteIfPresent(Path.ChangeExtension(audioPath, ".pqmeta")); + deleted++; + freed += bytes; + } + return new PinnedPackCacheDeleteResult(true, deleted, freed, "ORPHAN COLLECTION COMPLETE"); + } + + private string PinnedAudioPath(string key) => + Path.Combine(_cache.PinnedRootPath, key.ToLowerInvariant() + ".flac"); + + private static bool IsValidKey(string key) => !string.IsNullOrWhiteSpace(key) && + key.Length == 64 && + key.All(Uri.IsHexDigit); + + private static void DeleteIfPresent(string path) + { + if (!string.IsNullOrWhiteSpace(path) && File.Exists(path)) + File.Delete(path); + } + + [Serializable] + private sealed class PinnedPackCacheManifest + { + public int SchemaVersion = 1; + public string PackId = string.Empty; + public string PackName = string.Empty; + public long CreatedUtcTicks; + public List Keys = new List(); + [NonSerialized] public string SourcePath = string.Empty; + } + } +} diff --git a/Assets/ProjectQuint/Runtime/Unity/Bootstrap/PinnedPackPremixCacheService.cs.meta b/Assets/ProjectQuint/Runtime/Unity/Bootstrap/PinnedPackPremixCacheService.cs.meta new file mode 100644 index 0000000..bf1b7a6 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Bootstrap/PinnedPackPremixCacheService.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d334e055c8b5349daa533ef3a1eb3d96 \ No newline at end of file diff --git a/Assets/ProjectQuint/Runtime/Unity/Bootstrap/ProjectQuint.Bootstrap.asmdef b/Assets/ProjectQuint/Runtime/Unity/Bootstrap/ProjectQuint.Bootstrap.asmdef new file mode 100644 index 0000000..c5c0817 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Bootstrap/ProjectQuint.Bootstrap.asmdef @@ -0,0 +1,23 @@ +{ + "name": "ProjectQuint.Bootstrap", + "rootNamespace": "ProjectQuint.Bootstrap", + "references": [ + "ProjectQuint.Application", + "ProjectQuint.Core", + "ProjectQuint.Infrastructure", + "ProjectQuint.Media.Audio", + "ProjectQuint.Media.Images", + "ProjectQuint.Media.Video", + "ProjectQuint.Presentation", + "Unity.InputSystem" + ], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/Assets/ProjectQuint/Runtime/Unity/Bootstrap/ProjectQuint.Bootstrap.asmdef.meta b/Assets/ProjectQuint/Runtime/Unity/Bootstrap/ProjectQuint.Bootstrap.asmdef.meta new file mode 100644 index 0000000..9618654 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Bootstrap/ProjectQuint.Bootstrap.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 178f6069f8ef347608b519f555a40a11 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/ProjectQuint/Runtime/Unity/Bootstrap/ProjectQuintApp.cs b/Assets/ProjectQuint/Runtime/Unity/Bootstrap/ProjectQuintApp.cs new file mode 100644 index 0000000..fef8199 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Bootstrap/ProjectQuintApp.cs @@ -0,0 +1,7251 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using ProjectQuint.Features.Course; +using ProjectQuint.Features.Flow; +using ProjectQuint.Features.Gameplay; +using ProjectQuint.Features.Library; +using ProjectQuint.Features.Loading; +using ProjectQuint.Features.Results; +using ProjectQuint.Features.Settings; +using ProjectQuint.Audio; +using ProjectQuint.Core.Bms; +using ProjectQuint.Core.Charts; +using ProjectQuint.Core.Course; +using ProjectQuint.Core.Gauge; +using ProjectQuint.Core.Judgement; +using ProjectQuint.Core.Progression; +using ProjectQuint.Core.Release; +using ProjectQuint.Library; +using ProjectQuint.Persistence; +using ProjectQuint.Infrastructure.Images; +using ProjectQuint.Settings; +using ProjectQuint.UI; +using ProjectQuint.Video; +using UnityEngine; +using UnityEngine.InputSystem; +using UnityEngine.InputSystem.LowLevel; +using Unity.Profiling; + +namespace ProjectQuint.Bootstrap +{ + public sealed class ProjectQuintApp : MonoBehaviour + { + private static readonly int[] QuickOptionsVisualOrder = + { + 0, 12, 2, 3, 4, 5, + 1, 6, 7, 8, + 9, 10, 11, + }; + private static readonly int[] PackKeyModeChoices = { 0, 4, 5, 6, 7 }; + private const double LibraryViewStateSaveDelaySeconds = 0.75; + private static readonly ProfilerMarker GameplayUpdateMarker = + new ProfilerMarker("ProjectQuint.Gameplay.Update"); + private static readonly ProfilerMarker JudgementUpdateMarker = + new ProfilerMarker("ProjectQuint.Gameplay.Judgement"); + private static readonly ProfilerMarker BgaUpdateMarker = + new ProfilerMarker("ProjectQuint.Gameplay.BGA"); + private static readonly ProfilerMarker GameplayRenderMarker = + new ProfilerMarker("ProjectQuint.Gameplay.Render"); + + private static bool s_Created; + private readonly string[] _laneKeyLabels = new string[GameSettings.MaximumKeyCount]; + private readonly GameplayNoteWindow _gameplayNoteWindow = new GameplayNoteWindow(); + private readonly GameplayFramePacingMonitor _framePacingMonitor = new GameplayFramePacingMonitor(); + private readonly UnityFrameTimingMonitor _unityFrameTimingMonitor = new UnityFrameTimingMonitor(); + private readonly GameplayWorkloadMonitor _workloadMonitor = new GameplayWorkloadMonitor(); + private readonly HighRateInputMonitor _inputMonitor = new HighRateInputMonitor(); + private readonly DirectionalKeyRepeat _optionsValueKeyRepeat = new DirectionalKeyRepeat(); + private bool _continuousOptionsDirty; + private MacNativeKeyboardPoller _nativeInputPoller; + private string _inputPollingStatus = string.Empty; + private StartupBrandingPresenter _startupBrandingPresenter; + private HomeScreenPresenter _homeScreenPresenter; + private UpdateNoticePresenter _updateNoticePresenter; + private ProjectQuintSparkleUpdater _sparkleUpdater; + private PackMusicScreenPresenter _packMusicScreenPresenter; + private OptionsProfileCoursePresenter _optionsProfileCoursePresenter; + private SessionScreensPresenter _sessionScreensPresenter; + private FpsCounterPresenter _fpsCounterPresenter; + private GameplayPlayfieldPresenter _gameplayPlayfieldPresenter; + private GameplayHudPresenter _gameplayHudPresenter; + private PreparedSong _preparedSong; + private ISongAudioBackend _audio; + private ISongAudioBackendFactory _audioBackendFactory; + private SongAudioBackendKind _audioBackendKind = SongAudioBackendKind.FmodCore; + private IAudioClock _audioClock; + private AudioSyncCalibrationSession _audioSyncCalibration; + private InputAction _audioSyncTapAction; + private string _audioSyncCalibrationError = string.Empty; + private string _audioBackendInitializationError = string.Empty; + private AudioDspBufferController _audioDspBufferController; + private bool _audioBackendResetting; + private string _audioDspStatus = string.Empty; + private string _premixCacheStatus = string.Empty; + private bool _premixCacheClearing; + private PinnedPackPremixCacheService _pinnedPackCacheService; + private CancellationTokenSource _pinnedPackCacheCancellation; + private PinnedPackCachePlan _pendingPinnedPackCachePlan; + private string _pinnedPackCacheStatus = "ESTIMATE DISK SPACE AND TIME BEFORE APPROVAL"; + private bool _pinnedPackCacheConfirmationPending; + private bool _pinnedPackCacheWorking; + private bool _packCacheManagerOpen; + private int _packCacheSelection; + private IReadOnlyList _packCacheStatuses = + Array.Empty(); + private IReadOnlyList _packCacheUiEntries = + Array.Empty(); + private BmsBgaSystem _bga; + private GameplaySessionController _gameplaySession; + private SongLibraryScanner _scanner; + private CancellationTokenSource _scanCancellation; + private CancellationTokenSource _packImportCancellation; + private BmsPackRegistry _packRegistry; + private BmsDifficultyTableRegistry _difficultyTableRegistry; + private BmsPackDefinition _activePack; + private bool _packKeyModeModalOpen; + private int _packKeyModeModalSelection; + private string _pendingPackKeyModeId = string.Empty; + private bool _packRemovalConfirmationPending; + private bool _packRemovalInProgress; + private string _pendingPackRemovalId = string.Empty; + private string _pendingPackRemovalName = string.Empty; + private BmsDifficultyTableDefinition _activeDifficultyTable; + private int _tableSetupSelection; + private string _tableSetupPackId = string.Empty; + private string _tableUrlDraft = string.Empty; + private string _tableSetupStatus = string.Empty; + private bool _tableUrlFocusRequested; + private bool _tableUrlReleaseRequested; + private bool _tableUrlFocused; + private InputAction[] _laneActions; + private GameSettings _settings; + private GameSettingsRepository _settingsRepository; + private FavoriteChartRepository _favoriteRepository; + private LibraryController _libraryController; + private SongLoadCoordinator _songLoadCoordinator; + private AppFlowController _flow; + private CourseSessionController _courseSession; + private ResultSessionController _resultSession; + private PlayerProgressionRepository _progressionRepository; + private PlayResultCommitCoordinator _playCommitCoordinator; + private ProfileArchiveService _profileArchiveService; + private ProfileImageStore _profileImageStore; + private Texture2D _profileImageTexture; + private string _profileImageStatus = string.Empty; + private string _profileTransferStatus = "PROFILE ARCHIVE READY"; + private string _updateStatus = "NOT CHECKED"; + private ProjectQuintUpdateManifest _availableUpdate; + private bool _automaticUpdateCheckStarted; + private bool _updateCheckRunning; + private bool _updateOfferPending; + private bool _updateModalOpen; + private bool _updateDismissedThisRun; + private int _updateModalSelection; + private bool _profileImportConfirmationPending; + private ProfileProgressSnapshot _profileSnapshot; + private RatingSummary _homeRatingSummary; + private RatingSummary _profileRatingSummary; + private IReadOnlyList _ratingContexts = Array.Empty(); + private int _profileRatingContextIndex; + private IReadOnlyList _profileRatingThumbnailSongs = + Array.Empty(); + private int _profileRatingThumbnailSongsRevision; + private long _profileRatingThumbnailSourceSignature = long.MinValue; + private ProfileCollectionSnapshot _profileCollection = ProfileCollectionSnapshot.Empty; + private ProfileCourseSnapshot _profileCourses = ProfileCourseSnapshot.Empty; + private bool _profileModalOpen; + private int _profileModalTab; + private int _profileBadgeSelection; + private int _profileCollectionColumn; + private readonly int[] _profileCollectionSelections = new int[3]; + private string _libraryRoot = string.Empty; + private string _libraryLoadError = string.Empty; + private bool _libraryReady; + private bool _libraryScanRunning; + private bool _openLibraryWhenScanCompletes; + private bool _libraryViewStateDirty; + private double _libraryViewStateSaveDeadline; + private SongLibraryEntry _loadingEntry; + private SongLibraryEntry _playingEntry; + private AeryCourseIndex _courseIndex; + private AeryTableIndex _aeryCatalog; + private IChartCatalog _activeCatalog; + private SearchAliasIndex _searchAliases = SearchAliasIndex.Empty; + private int _aeryMatchedCharts; + private int _catalogMatchedCharts; + private int _libraryScanGeneration; + private Texture2D _libraryThumbnail; + private Texture2D _stageTexture; + private string _thumbnailChartPath = string.Empty; + private int _thumbnailLoadVersion; + + private Texture CurrentArtworkTexture => + _libraryThumbnail != null + ? _libraryThumbnail + : _stageTexture != null + ? _stageTexture + : DefaultJacketProvider.Texture; + private double _startupAttributionEnteredRealtime; + private double _teamDeniaBrandingEnteredRealtime; + private int _songLoadGeneration; + private int _fpsSampleFrames; + private float _fpsSampleElapsed; + private float _displayedFps; + private int _effectiveTargetFrameRate = GameSettingsDefaults.FrameLimit == FrameLimitMode.Unlimited + ? -1 + : GameSettings.TargetFrameRate(GameSettingsDefaults.FrameLimit); + private double _appliedDisplayRefreshRate = double.NaN; + private double _nextDisplayRefreshCheckRealtime; + private bool _autoPlayEnabled; + private bool _autoScratchEnabled; + private bool _gameplayDiagnosticsEnabled; + + // Compatibility accessors keep this first extraction behavior-neutral. Feature controllers + // can receive GameSettings directly as they are split out in later refactoring stages. + private Key[] _laneKeys => _settings.LaneKeys; + private Key _centerAuxKey => _settings.CenterAuxKey; + private bool _reactiveKeySoundsEnabled { get => _settings.ReactiveKeySoundsEnabled; set => _settings.ReactiveKeySoundsEnabled = value; } + private float _noteSpeed { get => _settings.NoteSpeed; set => _settings.NoteSpeed = value; } + private GameplayGearPosition _gearPosition { get => _settings.GearPosition; set => _settings.GearPosition = value; } + private LaneModifier _laneModifier { get => _settings.LaneModifier; set => _settings.LaneModifier = value; } + private GaugeMode _gaugeType { get => _settings.GaugeType; set => _settings.GaugeType = value; } + private bool _gaugeFallbackEnabled + { + get => _settings.GaugeFallbackEnabled; + set => _settings.GaugeFallbackEnabled = value; + } + private GameplaySkin _gameplaySkin { get => _settings.GameplaySkin; set => _settings.GameplaySkin = value; } + private GameplayNoteSkin _noteSkin { get => _settings.NoteSkin; set => _settings.NoteSkin = value; } + private LongNoteVisualStyle _longNoteStyle { get => _settings.LongNoteStyle; set => _settings.LongNoteStyle = value; } + private FrameLimitMode _frameLimitMode { get => _settings.FrameLimitMode; set => _settings.FrameLimitMode = value; } + private DisplayResolutionMode _displayResolutionMode { get => _settings.DisplayResolutionMode; set => _settings.DisplayResolutionMode = value; } + private WindowMode _windowMode { get => _settings.WindowMode; set => _settings.WindowMode = value; } + private int _judgementOffsetMilliseconds { get => _settings.JudgementOffsetMilliseconds; set => _settings.JudgementOffsetMilliseconds = value; } + private int _audioOutputLatencyMilliseconds { get => _settings.AudioOutputLatencyMilliseconds; set => _settings.AudioOutputLatencyMilliseconds = value; } + private int _masterVolumePercent { get => _settings.MasterVolumePercent; set => _settings.MasterVolumePercent = value; } + private AudioDspBufferProfile _audioDspBufferProfile + { + get => _settings.AudioDspBufferProfile; + set => _settings.AudioDspBufferProfile = value; + } + private AudioOutputDeviceInfo CurrentAudioOutputDevice + { + get + { + if (string.IsNullOrWhiteSpace(_settings?.AudioOutputDeviceId) || + _audioBackendFactory is not FmodCoreSongAudioBackendFactory fmodFactory) + return AudioOutputDeviceInfo.SystemDefault; + + foreach (var device in fmodFactory.OutputDevices) + { + if (string.Equals( + device.PersistentId, + _settings.AudioOutputDeviceId, + StringComparison.OrdinalIgnoreCase)) + return device; + } + return AudioOutputDeviceInfo.SystemDefault; + } + } + private bool _showFpsCounter { get => _settings.ShowFpsCounter; set => _settings.ShowFpsCounter = value; } + private InputPollingRate _inputPollingRate + { + get => _settings.InputPollingRate; + set => _settings.InputPollingRate = value; + } + private BgaPlaybackMode _bgaPlaybackMode + { + get => _settings.BgaPlayback; + set => _settings.BgaPlayback = value; + } + private bool DynamicBgaEnabled => _bgaPlaybackMode == BgaPlaybackMode.Full; + private bool StageBackgroundEnabled => _bgaPlaybackMode != BgaPlaybackMode.Black; + private bool IsAudioSystemResetting => + _audioBackendResetting || _audioDspBufferController?.IsResetting == true; + private BgaDisplayMode _bgaDisplayMode { get => _settings.BgaDisplayMode; set => _settings.BgaDisplayMode = value; } + private bool _vSyncEnabled { get => _settings.VSyncEnabled; set => _settings.VSyncEnabled = value; } + private bool _laneSeparatorsEnabled { get => _settings.LaneSeparatorsEnabled; set => _settings.LaneSeparatorsEnabled = value; } + private bool _timingMeterEnabled { get => _settings.TimingMeterEnabled; set => _settings.TimingMeterEnabled = value; } + private int _backgroundDimPercent { get => _settings.BackgroundDimPercent; set => _settings.BackgroundDimPercent = value; } + private AppMode _mode { get => _flow.Mode; set => _flow.TransitionTo(value); } + private string _status { get => _flow.Status; set => _flow.Status = value; } + private string _errorTitle => _flow.ErrorTitle; + private string _errorDetail => _flow.ErrorDetail; + private string _loadingTitle => _flow.LoadingTitle; + private string _loadingDetail => _flow.LoadingDetail; + private int _loadingCurrent => _flow.LoadingCurrent; + private int _loadingTotal => _flow.LoadingTotal; + private double _loadingEnteredRealtime => _flow.LoadingEnteredRealtime; + private int _homeSelection { get => _flow.HomeSelection; set => _flow.HomeSelection = value; } + private int _packSelection { get => _flow.PackSelection; set => _flow.PackSelection = value; } + private OptionsCategory _optionsCategory { get => _flow.OptionsCategory; set => _flow.OptionsCategory = value; } + private int _optionsSelectedRow { get => _flow.OptionsSelectedRow; set => _flow.OptionsSelectedRow = value; } + private int _optionsInputKeyCount { get => _flow.OptionsInputKeyCount; set => _flow.OptionsInputKeyCount = value; } + private int _quickOptionsSelectedRow { get => _flow.QuickOptionsSelectedRow; set => _flow.QuickOptionsSelectedRow = value; } + private int _pauseSelection { get => _flow.PauseSelection; set => _flow.PauseSelection = value; } + private int _gameOverSelection { get => _flow.GameOverSelection; set => _flow.GameOverSelection = value; } + private int _rebindLane { get => _flow.RebindLane; set => _flow.RebindLane = value; } + private AeryCourseDefinition _activeCourse => _courseSession.ActiveCourse; + private CourseProgress _courseProgress => _courseSession.Progress; + private AeryCourseTrophy _courseTrophy => _courseSession.Trophy; + private double _courseGaugeValue => _courseSession.GaugeValue; + private int _courseSelectedIndex => _courseSession.SelectedIndex; + private int _courseStageIndex => _courseSession.StageIndex; + private int _courseResultSelection { get => _courseSession.ResultSelection; set => _courseSession.ResultSelection = value; } + private AppMode _optionsReturnMode = AppMode.Home; + private bool _coursePassed => _courseSession.Passed; + private bool _courseBadgeEarned => _courseSession.CertificationBadgeEarned; + private string _courseStatus { get => _courseSession.Status; set => _courseSession.Status = value; } + private bool _songCleared => _resultSession.SongCleared; + private SongPlayRecord _resultPreviousRecord => _resultSession.PreviousRecord; + private double _resultEnteredRealtime => _resultSession.EnteredRealtime; + private int _resultsSelection { get => _resultSession.Selection; set => _resultSession.Selection = value; } + private PlayableChart _chart => _gameplaySession?.Chart; + private RhythmJudgementEngine _judgement => _gameplaySession?.Judgement; + private PlayGauge _gauge => _gameplaySession?.Gauge; + private bool[] _laneHeld => _gameplaySession?.LaneHeld; + private double[] _laneFlashTimes => _gameplaySession?.LaneFlashTimes; + private double _frameSongTime => _gameplaySession?.FrameSongTime ?? 0; + + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)] + private static void Create() + { + if (s_Created) + return; + + s_Created = true; + var gameObject = new GameObject("ProjectQuint App"); + DontDestroyOnLoad(gameObject); + gameObject.AddComponent(); + } + + private void Awake() + { + Application.runInBackground = true; + AudioListener.pause = false; + Debug.Log( + $"[ProjectQuint] Starting {ProjectQuintReleaseVersion.DisplayVersionWithBuild}."); + _pinnedPackCacheService = new PinnedPackPremixCacheService(); + _flow = new AppFlowController(); + _courseSession = new CourseSessionController(); + _resultSession = new ResultSessionController(); + _startupBrandingPresenter = new StartupBrandingPresenter(gameObject); + _homeScreenPresenter = new HomeScreenPresenter( + gameObject, + HandleHomePresenterAction); + _updateNoticePresenter = new UpdateNoticePresenter( + gameObject, + HandleUpdateNoticeAction); + _sparkleUpdater = new ProjectQuintSparkleUpdater(); + if (_sparkleUpdater.TryInitialize(out var sparkleError)) + Debug.Log("[ProjectQuint] Sparkle automatic updater is ready."); + else if (!Application.isEditor) + Debug.LogWarning( + $"[ProjectQuint] Sparkle unavailable; manual download fallback remains active: " + + sparkleError); + _packMusicScreenPresenter = new PackMusicScreenPresenter( + gameObject, + HandlePackSelectUi, + HandleDifficultyTableUi, + HandleLibraryUi); + _optionsProfileCoursePresenter = new OptionsProfileCoursePresenter( + gameObject, + HandleOptionsUi, + HandleProfileModalUi, + HandleCourseSelectUi); + _sessionScreensPresenter = new SessionScreensPresenter( + gameObject, + new SessionScreensCallbacks( + HandleRetainedLoadingCancel, + selection => _quickOptionsSelectedRow = + SessionScreensPresenter.NormalizeSelection( + selection, + ProjectQuintPresentation.QuickOptionRowCount), + HandleQuickOptionsUi, + selection => _pauseSelection = + SessionScreensPresenter.NormalizeSelection(selection, 3), + ActivatePauseAction, + selection => _gameOverSelection = + SessionScreensPresenter.NormalizeSelection(selection, 2), + ActivateGameOverAction, + selection => _resultsSelection = + SessionScreensPresenter.NormalizeSelection(selection, 2), + ActivateResultsAction, + selection => _courseResultSelection = + SessionScreensPresenter.NormalizeSelection(selection, 2), + ActivateCourseResultAction, + HandleRetainedErrorRetry, + () => _mode = AppMode.Home)); + _fpsCounterPresenter = new FpsCounterPresenter(gameObject); + _gameplayPlayfieldPresenter = new GameplayPlayfieldPresenter(gameObject); + _gameplayHudPresenter = new GameplayHudPresenter(gameObject); + ProductIdentityStorageMigrationResult productStorageMigration; + try + { + productStorageMigration = ProductIdentityStorageMigrator.MigrateLegacyDefaultCompany(); + } + catch (Exception exception) + { + _flow.ShowError( + "DATA MIGRATION FAILED", + "ProjectQuint could not move the existing DefaultCompany data into Team Denia storage.\n\n" + + exception.Message); + Debug.LogException(exception); + return; + } + if (productStorageMigration.Status == ProductIdentityStorageMigrationStatus.DestinationConflict) + { + _flow.ShowError( + "DATA MIGRATION NEEDS ATTENTION", + "Both the legacy and Team Denia data locations contain files. Nothing was overwritten.\n\n" + + $"LEGACY: {productStorageMigration.SourcePath}\n" + + $"CURRENT: {productStorageMigration.DestinationPath}"); + Debug.LogError( + "[ProjectQuint Persistence] Product identity migration stopped because both roots contain data. " + + $"Legacy='{productStorageMigration.SourcePath}', Current='{productStorageMigration.DestinationPath}'."); + return; + } + if (productStorageMigration.Status == ProductIdentityStorageMigrationStatus.Migrated) + { + Debug.Log( + "[ProjectQuint Persistence] Moved the complete legacy product data root from " + + $"'{productStorageMigration.SourcePath}' to '{productStorageMigration.DestinationPath}'."); + } + var profileStorageMigration = ProfileStorageMigrator.MigrateDefaultProfile(); + if (profileStorageMigration.CopiedFileCount > 0) + { + Debug.Log( + $"[ProjectQuint Persistence] Migrated {profileStorageMigration.CopiedFileCount} " + + $"legacy profile files into '{PersistencePaths.DefaultProfileRoot}'. " + + "The original global files were preserved."); + } + _progressionRepository = PlayerProgressionRepository.Shared; + _profileArchiveService = new ProfileArchiveService(); + _profileImageStore = new ProfileImageStore(); + LoadProfileImage(); + _playCommitCoordinator = new PlayResultCommitCoordinator( + progression: _progressionRepository); + RefreshProfileSummary(); + _gameplaySession = new GameplaySessionController(); + _gameplaySession.Judged += OnJudged; + _gameplaySession.AutoPlayNotePressed += OnAutoPlayNotePressed; + _settingsRepository = new GameSettingsRepository(); + _favoriteRepository = new FavoriteChartRepository(); + _settings = _settingsRepository.Load(); + _nativeInputPoller = new MacNativeKeyboardPoller(); + ApplyInputPollingRate(_inputPollingRate, startup: true); + _audioDspBufferController = new AudioDspBufferController(_audioDspBufferProfile); + ApplyAudioDspBufferProfile(_audioDspBufferProfile, startup: true); + ConfigureAudioBackend(Environment.GetCommandLineArgs()); + RestoreActiveDeviceAudioCalibration(); + RefreshPremixCacheStatus(); + _packRegistry = new BmsPackRegistry(); + _packRegistry.MigrateLegacyFolder(_settings.BmsLibraryRoot); + _activePack = _packRegistry.ActivePack; + _difficultyTableRegistry = new BmsDifficultyTableRegistry(); + var migratedTableCount = _difficultyTableRegistry.MigrateLegacyPackTables( + _packRegistry.Packs, + _packRegistry.ActivePackId); + var promotedPackAssignmentCount = + _packRegistry.PromoteLegacyDifficultyTableAssignments(_difficultyTableRegistry); + var migratedGlobalAssignmentCount = 0; + if (_activePack != null && + string.IsNullOrWhiteSpace(_activePack.DifficultyTableId) && + _difficultyTableRegistry.ActiveTable != null) + { + _packRegistry.AssignDifficultyTable( + _activePack.Id, + _difficultyTableRegistry.ActiveTable.Id); + migratedGlobalAssignmentCount = 1; + } + // ActiveTableId was the schema-v1 global selection. Pack-scoped assignments are + // now authoritative, so clear the legacy singleton after its one-time promotion. + _difficultyTableRegistry.ClearSelection(); + var legacyProviderMap = _difficultyTableRegistry.BuildLegacyProviderMap(); + var migratedSongProviderCount = SongRecordRepository.Shared + .MigrateDifficultyTableProviders(legacyProviderMap); + var migratedCourseProviderCount = CourseRecordRepository.Shared + .MigrateDifficultyTableProviders(legacyProviderMap); + var clearedLegacyTableCount = _packRegistry.ClearLegacyDifficultyTableUrls(); + _activeDifficultyTable = ResolvePackDifficultyTable(_activePack); + if (migratedSongProviderCount > 0 || migratedCourseProviderCount > 0) + RefreshProfileSummary(); + if (migratedTableCount > 0 || promotedPackAssignmentCount > 0 || + migratedGlobalAssignmentCount > 0 || clearedLegacyTableCount > 0 || + migratedSongProviderCount > 0 || migratedCourseProviderCount > 0) + { + Debug.Log( + $"[ProjectQuint Library] Promoted {migratedTableCount:N0} legacy pack table URLs " + + $"into the global registry, assigned {promotedPackAssignmentCount + migratedGlobalAssignmentCount:N0} " + + $"pack table references, migrated {migratedSongProviderCount:N0} song records and " + + $"{migratedCourseProviderCount:N0} course records, then cleared " + + $"{clearedLegacyTableCount:N0} pack links."); + } + _libraryController = new LibraryController(); + PersistSettings(); + ApplyMasterVolume(); + ApplyFrameLimit(); +#if UNITY_STANDALONE_OSX && !UNITY_EDITOR + ApplyResolution(); + StartCoroutine(ConfirmStandaloneDisplaySettings()); +#endif + RefreshLaneKeyLabels(); + _startupAttributionEnteredRealtime = Time.realtimeSinceStartupAsDouble; + _mode = AppMode.StartupAttribution; + StartCoroutine(CompleteStartupAttribution()); + _status = !string.IsNullOrWhiteSpace(_audioBackendInitializationError) + ? $"AUDIO BACKEND UNAVAILABLE // {_audioBackendInitializationError}" + : _packRegistry.Packs.Count == 0 + ? "NO BMS PACKS // OPEN PLAY TO ADD ONE" + : $"{_packRegistry.Packs.Count:N0} BMS PACKS REGISTERED"; + + if (!StandaloneProfileOptions.TryParse( + Environment.GetCommandLineArgs(), + out var profileOptions, + out var profileError)) + { + Debug.LogError($"[ProjectQuint Profile Automation] {profileError}"); + Application.Quit(64); + } + else if (profileOptions.Enabled) + { + _gameplayDiagnosticsEnabled = true; + StartCoroutine(RunStandaloneProfile(profileOptions)); + } + } + + private IEnumerator CompleteStartupAttribution() + { + while (!StartupAttributionTimeline.IsComplete( + Time.realtimeSinceStartupAsDouble - _startupAttributionEnteredRealtime)) + { + yield return null; + } + + if (_mode != AppMode.StartupAttribution) + yield break; + + _teamDeniaBrandingEnteredRealtime = Time.realtimeSinceStartupAsDouble; + _mode = AppMode.TeamDeniaBranding; + while (!TeamDeniaBrandingTimeline.IsComplete( + Time.realtimeSinceStartupAsDouble - _teamDeniaBrandingEnteredRealtime)) + { + yield return null; + } + + if (_mode == AppMode.TeamDeniaBranding) + _mode = AppMode.Home; + } + + private IEnumerator RunStandaloneProfile(StandaloneProfileOptions options) + { + while (_mode == AppMode.StartupAttribution || _mode == AppMode.TeamDeniaBranding) + yield return null; + + string chartPath; + try + { + chartPath = Path.GetFullPath(options.ChartPath); + } + catch (Exception exception) + { + Debug.LogError($"[ProjectQuint Profile Automation] Invalid chart path: {exception.Message}"); + Application.Quit(66); + yield break; + } + + if (!File.Exists(chartPath)) + { + Debug.LogError($"[ProjectQuint Profile Automation] Chart not found: {chartPath}"); + Application.Quit(66); + yield break; + } + + var chartDirectory = Path.GetDirectoryName(chartPath); + var scanner = new SongLibraryScanner(packId: "standalone-profile"); + var scanTask = Task.Run(() => scanner.Scan(chartDirectory, CancellationToken.None)); + while (!scanTask.IsCompleted) + yield return null; + + if (scanTask.IsFaulted) + { + Debug.LogError( + $"[ProjectQuint Profile Automation] Chart scan failed: " + + $"{scanTask.Exception?.GetBaseException().Message}"); + Application.Quit(70); + yield break; + } + + var entry = scanTask.Result.FirstOrDefault(candidate => string.Equals( + Path.GetFullPath(candidate.ChartPath), + chartPath, + StringComparison.OrdinalIgnoreCase)); + if (entry == null) + { + Debug.LogError($"[ProjectQuint Profile Automation] Chart was not indexed: {chartPath}"); + Application.Quit(65); + yield break; + } + + _courseSession.ClearRun(); + _mode = AppMode.Library; + var originalGaugeType = _gaugeType; + var originalVSyncEnabled = _vSyncEnabled; + var originalBgaPlaybackMode = _bgaPlaybackMode; + var originalWindowMode = _windowMode; + _gaugeType = GaugeMode.Normal; + if (options.VSyncEnabled.HasValue) + _vSyncEnabled = options.VSyncEnabled.Value; + if (options.BgaPlayback.HasValue) + _bgaPlaybackMode = options.BgaPlayback.Value; + if (options.WindowMode.HasValue) + _windowMode = options.WindowMode.Value; + ApplyFrameLimit(); + ApplyResolution(); + yield return null; + Debug.Log( + $"[ProjectQuint Profile Automation] Loading '{entry.DisplayTitle}' for " + + $"{options.DurationSeconds:0.###} seconds with Normal gauge, " + + $"VSync {(_vSyncEnabled ? "ON" : "OFF")}, BGA {_bgaPlaybackMode}, " + + $"window {_windowMode} from {chartPath}"); + yield return LoadSong(entry); + if (_mode != AppMode.Gameplay) + { + _gaugeType = originalGaugeType; + _vSyncEnabled = originalVSyncEnabled; + _bgaPlaybackMode = originalBgaPlaybackMode; + _windowMode = originalWindowMode; + ApplyFrameLimit(); + ApplyResolution(); + Debug.LogError( + $"[ProjectQuint Profile Automation] Gameplay did not start; final mode {_mode}."); + Application.Quit(70); + yield break; + } + + var startedAt = Time.realtimeSinceStartupAsDouble; + while (_mode == AppMode.Gameplay && + Time.realtimeSinceStartupAsDouble - startedAt < options.DurationSeconds) + yield return null; + + var elapsed = Time.realtimeSinceStartupAsDouble - startedAt; + Debug.Log( + $"[ProjectQuint Profile Automation] Completed {elapsed:0.000} seconds; " + + $"song time {_frameSongTime:0.000}s, final mode {_mode}. Exiting without saving a play record."); + DisableLaneInput(); + _audio?.Stop(); + _bga?.Stop(); + _gaugeType = originalGaugeType; + _vSyncEnabled = originalVSyncEnabled; + _bgaPlaybackMode = originalBgaPlaybackMode; + _windowMode = originalWindowMode; + ApplyFrameLimit(); + ApplyResolution(); + yield return null; + Application.Quit(0); + } + +#if UNITY_EDITOR + /// + /// Opens the real song-select presentation with an indexed pack for deterministic visual QA. + /// This helper is excluded from standalone builds and never changes the user's pack registry. + /// + public void EditorOpenChartFeaturePreview( + string libraryRoot, + string cachePath, + string preferredChartFileName) + { + var scanner = new SongLibraryScanner(cachePath); + var songs = scanner.Scan(libraryRoot, CancellationToken.None); + _favoriteRepository.Apply(songs); + foreach (var song in songs) + { + song.ClearLamp = SongClearLampStore.Load(song); + song.BestRecord = SongPlayRecordStore.Load(song); + } + _libraryController.ReplaceSongs(songs); + var selectedIndex = songs.FindIndex(song => string.Equals( + Path.GetFileName(song.ChartPath), + preferredChartFileName, + StringComparison.OrdinalIgnoreCase)); + // Music Select reserves visual row zero for RANDOM SELECT. + _libraryController.State.SetSelectedIndex(Math.Max(0, selectedIndex + 1)); + _libraryRoot = libraryRoot; + _scanner = scanner; + _libraryReady = true; + _libraryScanRunning = false; + _status = $"VISUAL QA // {scanner.CacheHits:N0} CACHED // {scanner.Failed:N0} FAILED"; + _mode = AppMode.Library; + RefreshLibraryThumbnail(); + } + + public void EditorMoveLibrarySelection(int delta) + { + if (_mode != AppMode.Library || + _libraryController?.State == null || + !_libraryController.State.MoveSelection(delta)) + return; + RefreshLibraryThumbnail(); + } + + public void EditorOpenLibraryControlPreview(bool filter) + { + if (_mode != AppMode.Library || _libraryController?.State == null) + return; + _libraryController.State.CloseSortPanel(); + _libraryController.State.CloseLevelFilterPanel(); + if (filter) + _libraryController.State.ToggleLevelFilterPanel(); + else + _libraryController.State.ToggleSortPanel(); + } + + public void EditorOpenQuickOptionsPreview() + { + _flow.BeginQuickOptions(); + _flow.QuickOptionsSelectedRow = 2; + } + + public void EditorOpenAudioOptionsPreview() + { + _audioDspBufferController?.RefreshSnapshot(); + RefreshAudioOutputDevices(); + RefreshPremixCacheStatus(); + _flow.BeginOptions(); + _optionsCategory = OptionsCategory.Audio; + _optionsSelectedRow = 1; + } + + public void EditorOpenOptionsPreview(OptionsCategory category, int keyCount = 0) + { + _flow.BeginOptions(); + if (keyCount > 0) + { + _optionsInputKeyCount = GameSettings.NormalizeInputKeyCount(keyCount); + RefreshLaneKeyLabels(); + } + _optionsCategory = ProjectQuintPresentation.CycleOptionsCategory(category, 0); + _optionsSelectedRow = 0; + } + + public void EditorOpenAudioSyncCalibrationPreview() + { + EditorOpenOptionsPreview(OptionsCategory.Latency); + _optionsSelectedRow = 3; + OpenAudioSyncCalibration(); + } + + public void EditorStartAudioSyncCalibrationPreview() + { + EditorOpenAudioSyncCalibrationPreview(); + StartAudioSyncCalibration(); + } + + public void EditorOpenGameplayOptionsPreview(int keyCount = 0) + { + EditorOpenOptionsPreview(OptionsCategory.Gameplay); + if (keyCount > 0) + _optionsInputKeyCount = GameSettings.NormalizeInputKeyCount(keyCount); + } + + public void EditorPressOptionsPreviewLane(int lane) + { + _optionsProfileCoursePresenter?.PressOptionsPreviewLane(lane); + } + + /// + /// Opens the real retained Results presenter with an editor-only deterministic + /// judgement fixture. It performs no persistence and is excluded from players. + /// + public void EditorOpenResultsPreview() + { + var chart = new PlayableChart + { + Metadata = new PlayableChartMetadata + { + Title = "Apocalypse Nemesis -Meteor Noir-", + Artist = "kanone", + DifficultyLabel = "4UE20", + InitialTempo = 256, + MinimumTempo = 64, + MaximumTempo = 256, + KeyCount = 4, + }, + }; + for (var index = 0; index < 180; index++) + { + chart.Notes.Add(new PlayableNote + { + Id = index + 1, + Lane = index % 4, + StartTime = 1.0 + index * 0.08, + }); + } + + _gameplaySession.LoadChart(chart); + _gameplaySession.Start( + new GameplaySessionStartRequest( + GaugeMode.Normal, + restoredGaugeValue: 100.0), + new GameplayClockSample( + currentDspTime: 0.0, + startDspTime: 0.0, + currentRealtime: 0.0, + audioOutputLatencyMilliseconds: 0)); + var judgement = _gameplaySession.Judgement; + for (var index = 0; index < chart.Notes.Count; index++) + { + var note = chart.Notes[index]; + if (index % 41 == 0) + continue; + var offset = index % 31 == 0 + ? -0.080 + : index % 23 == 0 + ? -0.060 + : index % 17 == 0 + ? -0.040 + : index % 29 == 0 + ? 0.075 + : index % 13 == 0 + ? 0.052 + : index % 7 == 0 + ? 0.031 + : index % 2 == 0 ? -0.012 : 0.006; + judgement.Press(note.Lane, note.StartTime + offset); + } + judgement.Update(chart.Notes[chart.Notes.Count - 1].StartTime + 1.0); + + var previousRecord = new SongPlayRecord + { + HasRecord = true, + BestScore = 940_152, + BestAccuracyPercent = 94.20, + BestMaxCombo = 800, + HasBestScoreJudgementBreakdown = true, + BestScoreKoolCount = 142, + BestScoreCoolCount = 24, + BestScoreGoodCount = 9, + BestScoreMissCount = 5, + BestScorePurePerfectCount = 128, + PlayCount = 9, + }; + var previousProfile = new ProfileProgressSnapshot( + "PLAYER", 12_540, 24, 100, "4ue", "4UE", 4, false); + var currentProfile = new ProfileProgressSnapshot( + "PLAYER", 12_720, 25, 280, "4ue", "4UE", 4, false); + var profileUpdate = new ProfileProgressUpdate( + true, + 180, + previousProfile, + currentProfile, + firstPlay: false, + firstNormalClear: false, + firstHardClear: false, + firstFullCombo: false, + firstAllPure: false); + var previousRating = new RatingSummary("4ue", "4UE", 4, false, 14.485, 29); + var currentRating = new RatingSummary("4ue", "4UE", 4, false, 15.054, 30); + var ratingUpdate = new RatingUpdate(true, 17.061, previousRating, currentRating); + + _libraryThumbnail = null; + _stageTexture = null; + _profileSnapshot = currentProfile; + _resultSession.BeginPlayback(); + _resultSession.SetCommitOutcome(new PlayCommitOutcome(profileUpdate, ratingUpdate)); + _resultSession.BeginResults( + previousRecord, + songCleared: true, + Time.realtimeSinceStartupAsDouble - 2.0); + _mode = AppMode.Results; + } + + /// + /// Opens retained surfaces directly for deterministic visual QA. This changes + /// only the editor Play Mode session and never persists profile or pack state. + /// + public void EditorOpenVisualQaSurface(string surface) + { + var key = (surface ?? string.Empty).Trim().ToLowerInvariant(); + var selectedSong = _libraryController?.State?.SelectedSong; + _updateModalOpen = false; + _updateOfferPending = false; + _profileModalOpen = false; + _packCacheManagerOpen = false; + _pinnedPackCacheConfirmationPending = false; + _packRemovalConfirmationPending = false; + CloseAudioSyncCalibration(); + _libraryController?.State?.CloseSortPanel(); + _libraryController?.State?.CloseLevelFilterPanel(); + switch (key) + { + case "startup-attribution": + _startupAttributionEnteredRealtime = + Time.realtimeSinceStartupAsDouble - StartupAttributionTimeline.FadeInSeconds; + _mode = AppMode.StartupAttribution; + break; + case "team-denia-branding": + _teamDeniaBrandingEnteredRealtime = + Time.realtimeSinceStartupAsDouble - TeamDeniaBrandingTimeline.FadeInSeconds; + _mode = AppMode.TeamDeniaBranding; + break; + case "update": + _availableUpdate = new ProjectQuintUpdateManifest + { + schemaVersion = 1, + channel = ProjectQuintReleaseVersion.ReleaseChannel, + available = true, + version = "0.1.0-alpha.3", + buildNumber = 3, + publishedAt = DateTimeOffset.UtcNow.ToString("O"), + downloadUrl = + "https://projectquint.duckdns.org/downloads/0.1.0-alpha.3/" + + "ProjectQuint-0.1.0-alpha.3-macOS-universal.dmg", + notesUrl = "/changelog/", + sha256 = new string('a', 64), + sizeBytes = 78_000_000, + }; + _updateModalSelection = 0; + _updateOfferPending = false; + _updateModalOpen = true; + _mode = AppMode.Home; + break; + case "pack": + _packSelection = ResolveActivePackSelection(); + _mode = AppMode.PackSelect; + break; + case "pack-remove": + _packSelection = ResolveActivePackSelection(); + _mode = AppMode.PackSelect; + RequestPackRemoval(); + break; + case "tables": + _packSelection = ResolveActivePackSelection(); + _mode = AppMode.PackSelect; + OpenDifficultyTableSetup(); + break; + case "course": + ConfigureEditorCourseVisualQa(selectedSong); + OpenCourseSelect(); + break; + case "profile": + case "profile-overview": + _mode = AppMode.Library; + OpenProfileModal(0); + break; + case "profile-rating": + _mode = AppMode.Library; + OpenProfileModal(1); + break; + case "pack-cache": + EditorOpenOptionsPreview(OptionsCategory.Data); + OpenPackCacheManager(); + break; + case "pack-cache-confirm": + EditorOpenOptionsPreview(OptionsCategory.Data); + OpenPackCacheManager(); + _pinnedPackCacheConfirmationPending = true; + break; + case "audio-sync": + EditorOpenAudioSyncCalibrationPreview(); + break; + case "loading": + _loadingEntry = selectedSong; + _flow.BeginLoading( + "DECODING AUDIO", + "MIXING KEY-SOUND BANK", + Math.Max(0.0, Time.realtimeSinceStartupAsDouble - 1.0)); + _flow.ReportLoading("MIXING KEY-SOUND BANK", 386, 1234); + break; + case "pause": + _playingEntry = selectedSong; + _flow.BeginPause(); + break; + case "game-over": + _flow.BeginGameOver(); + break; + case "course-results": + ConfigureEditorCourseResultsVisualQa(selectedSong); + _mode = AppMode.CourseResults; + break; + case "error": + _flow.ShowError( + "AUDIO MIX FAILED", + "The selected chart contains an audio stream that could not be prepared."); + break; + default: + throw new ArgumentOutOfRangeException( + nameof(surface), + surface, + "Unknown editor visual QA surface."); + } + } + + private void ConfigureEditorCourseVisualQa(SongLibraryEntry selectedSong) + { + if (selectedSong == null) + return; + + var bundledCourseHeader = Resources.Load("ProjectQuint/Data/AeryHeader"); + var preview = AeryCourseIndex.Parse( + "aery", + "AERY", + bundledCourseHeader != null ? bundledCourseHeader.text : string.Empty); + if (preview?.Courses == null || preview.Courses.Count == 0) + return; + + var previewSongs = (_libraryController?.State?.AllSongs ?? + Array.Empty()) + .Where(song => song != null) + .GroupBy( + song => song.DisplayTitle ?? string.Empty, + StringComparer.OrdinalIgnoreCase) + .Select(group => group.First()) + .Take(12) + .ToArray(); + if (previewSongs.Length == 0) + previewSongs = new[] { selectedSong }; + + for (var index = 0; index < preview.Courses.Count; index++) + { + var course = preview.Courses[index]; + course.Stages = Enumerable.Range(0, course.StageCount) + .Select(stage => previewSongs[(index + stage) % previewSongs.Length]) + .ToArray(); + course.ResolvedKeyCount = 5; + course.ResolvedHasScratch = + course.Stages.Any(stage => stage?.Metadata?.HasScratch == true); + course.StageCompatibilityError = string.Empty; + course.Record = index switch + { + 3 => new AeryCourseRecord + { + HasRecord = true, + BestStageReached = 3, + BestScore = 2_845_320, + BestExScoreRate = 76.84, + BestMissRate = 4.12, + PlayCount = 2, + }, + 5 => new AeryCourseRecord + { + HasRecord = true, + Cleared = true, + BestStageReached = course.StageCount, + BestScore = 3_721_480, + BestExScoreRate = 92.37, + BestMissRate = 1.24, + BestTrophyName = "SILVER", + PlayCount = 5, + }, + _ => new AeryCourseRecord(), + }; + } + + _courseIndex = preview; + _courseSession.Select(5, preview.Courses.Count); + _courseStatus = + "UP / DOWN SELECT // ENTER START // M MIRROR // 1 / 2 SPEED"; + } + + private void ConfigureEditorCourseResultsVisualQa(SongLibraryEntry selectedSong) + { + ConfigureEditorCourseVisualQa(selectedSong); + var course = _courseIndex?.Courses? + .FirstOrDefault(candidate => candidate?.IsPlayable == true && + candidate.StageCount == 4) ?? + _courseIndex?.Courses? + .FirstOrDefault(candidate => candidate?.IsPlayable == true); + if (course == null) + return; + + // Build a deterministic completed run without loading audio or waiting for four + // charts. This is editor-only visual QA data and never reaches persistence. + _courseSession.Begin(course); + const int notesPerStage = 64; + for (var stageIndex = 0; stageIndex < course.StageCount; stageIndex++) + { + if (!_courseSession.TryBeginStage(stageIndex, out _)) + break; + + var notes = Enumerable.Range(0, notesPerStage) + .Select(index => new PlayableNote + { + Id = stageIndex * notesPerStage + index + 1, + Lane = index % 5, + StartTime = 1.0 + index * 0.25, + }) + .ToArray(); + var judgement = new RhythmJudgementEngine(notes, laneCount: 5); + for (var index = 0; index < notesPerStage - 1; index++) + { + var offset = index >= notesPerStage - 3 + ? (index % 2 == 0 ? -0.100 : 0.100) + : index >= notesPerStage - 8 + ? (index % 2 == 0 ? -0.050 : 0.050) + : 0.0; + judgement.Press( + notes[index].Lane, + notes[index].StartTime + offset); + } + judgement.Update(notes[notesPerStage - 1].StartTime + 0.2); + _courseSession.TryAccumulateStage(judgement, cleared: true); + } + + _courseSession.CaptureGauge(63.4); + _courseSession.Complete( + passed: true, + modifier: LaneModifier.Off.ToString().ToUpperInvariant()); + } +#endif + +#if UNITY_STANDALONE_OSX && !UNITY_EDITOR + private IEnumerator ConfirmStandaloneDisplaySettings() + { + // macOS can restore the previous player window state during startup. + // Apply the requested borderless mode once more after that restore pass. + yield return null; + ApplyResolution(); + } +#endif + + private void Update() + { + ReapplyFrameLimitWhenDisplayRefreshRateChanges(); + UpdateFpsSample(); + HandleExternalAudioConfigurationChange(); + TryStartAutomaticUpdateCheck(); + TryPresentPendingUpdate(); + if (_updateModalOpen) + { + UpdateUpdateNoticeInput(); + FlushPendingLibraryViewState(force: false); + return; + } + switch (_mode) + { + case AppMode.StartupAttribution: + // Required middleware attribution is intentionally not skippable. + break; + case AppMode.TeamDeniaBranding: + // Studio branding follows FMOD and remains intentionally input-free. + break; + case AppMode.Home: + UpdateHomeInput(); + break; + case AppMode.PackSelect: + UpdatePackSelectInput(); + break; + case AppMode.PackImport: + UpdatePackImportInput(); + break; + case AppMode.DifficultyTableSetup: + UpdateDifficultyTableSetupInput(); + break; + case AppMode.Options: + UpdateAudioSyncCalibration(); + UpdateOptionsInput(); + break; + case AppMode.Library: + UpdateLibraryInput(); + break; + case AppMode.CourseSelect: + UpdateCourseSelectInput(); + break; + case AppMode.QuickOptions: + UpdateQuickOptionsInput(); + break; + case AppMode.Loading: + UpdateLoadingInput(); + break; + case AppMode.Gameplay: + UpdateGameplay(); + break; + case AppMode.Paused: + UpdatePauseInput(); + break; + case AppMode.GameOver: + UpdateGameOverInput(); + break; + case AppMode.Results: + UpdateResultsInput(); + break; + case AppMode.CourseResults: + UpdateCourseResultsInput(); + break; + case AppMode.Error: + if (Keyboard.current?.f5Key.wasPressedThisFrame == true) + StartCoroutine(InitializeLibrary(true)); + else if (Keyboard.current?.escapeKey.wasPressedThisFrame == true) + _mode = AppMode.Home; + break; + } + FlushPendingLibraryViewState(force: false); + } + + private void LateUpdate() + { + var startupElapsed = _mode == AppMode.StartupAttribution + ? Time.realtimeSinceStartupAsDouble - _startupAttributionEnteredRealtime + : Time.realtimeSinceStartupAsDouble - _teamDeniaBrandingEnteredRealtime; + _startupBrandingPresenter?.Update(_mode, startupElapsed); + + var homeVisible = _mode == AppMode.Home && !_profileModalOpen; + _homeScreenPresenter?.SetVisible(homeVisible); + _updateNoticePresenter?.SetVisible(_updateModalOpen); + if (_updateModalOpen && _availableUpdate != null) + { + _updateNoticePresenter?.Update( + _availableUpdate.version, + _availableUpdate.buildNumber, + _updateModalSelection, + _sparkleUpdater?.IsReady == true); + } + + UpdatePackMusicPresentation(); + UpdateOptionsProfileCoursePresentation(); + + var sessionVisible = + _mode == AppMode.PackImport || + _mode == AppMode.Loading || + _mode == AppMode.QuickOptions || + _mode == AppMode.Paused || + _mode == AppMode.GameOver || + _mode == AppMode.Results || + _mode == AppMode.CourseResults || + _mode == AppMode.Error; + _sessionScreensPresenter?.SetVisible(sessionVisible); + if (sessionVisible) + { + switch (_mode) + { + case AppMode.PackImport: + case AppMode.Loading: + _sessionScreensPresenter.UpdateLoading( + _loadingTitle, + _loadingDetail, + _loadingCurrent, + _loadingTotal, + Time.realtimeSinceStartupAsDouble - _loadingEnteredRealtime, + _loadingEntry ?? _playingEntry, + CurrentArtworkTexture); + break; + case AppMode.QuickOptions: + _sessionScreensPresenter.UpdateQuickOptions( + _autoPlayEnabled, + _autoScratchEnabled, + _gearPosition, + _noteSpeed, + _gaugeType, + _gaugeFallbackEnabled, + _laneModifier, + _gameplaySkin, + _noteSkin, + _longNoteStyle, + _bgaDisplayMode, + _laneSeparatorsEnabled, + _timingMeterEnabled, + _quickOptionsSelectedRow); + break; + case AppMode.Paused: + _sessionScreensPresenter.UpdatePause( + _pauseSelection, + _playingEntry, + CurrentArtworkTexture, + _frameSongTime, + _playingEntry?.DurationSeconds ?? _chart?.Duration ?? 0.0, + _noteSpeed, + _audioOutputLatencyMilliseconds); + break; + case AppMode.GameOver: + _sessionScreensPresenter.UpdateGameOver(_gameOverSelection, _gaugeType); + break; + case AppMode.Results: + _sessionScreensPresenter.UpdateResults( + _chart, + _judgement, + _gauge, + _songCleared, + _gameplaySession?.IsAutoPlay == true, + CurrentArtworkTexture, + _resultPreviousRecord, + _resultSession.ProfileUpdate, + _resultSession.RatingUpdate, + _resultSession.RecordSaveError, + _resultSession.PlayedUtcTicks, + _profileSnapshot, + _resultsSelection, + Math.Max(0.0, Time.realtimeSinceStartupAsDouble - _resultEnteredRealtime), + _gameplaySession?.IsAutoScratch == true); + break; + case AppMode.CourseResults: + _sessionScreensPresenter.UpdateCourseResults( + _activeCourse, + _courseProgress, + _courseGaugeValue, + _coursePassed, + _courseTrophy, + _courseBadgeEarned, + _profileSnapshot, + _courseResultSelection); + break; + case AppMode.Error: + _sessionScreensPresenter.UpdateError(_errorTitle, _errorDetail); + break; + } + } + + var gameplayVisible = + _mode == AppMode.Gameplay || + _mode == AppMode.Paused || + _mode == AppMode.GameOver; + _gameplayPlayfieldPresenter?.SetVisible(gameplayVisible); + _gameplayHudPresenter?.SetVisible(gameplayVisible); + if (gameplayVisible) + UpdateGameplayPresentation( + _mode == AppMode.Gameplay && !_resultSession.GameplayOutroActive); + + _fpsCounterPresenter?.Update( + _mode != AppMode.Home && + AppFlowController.ShouldShowFpsCounter(_mode, _showFpsCounter), + _displayedFps); + } + + private void UpdatePackMusicPresentation() + { + if (_packMusicScreenPresenter == null) + return; + + var screen = _mode switch + { + AppMode.PackSelect => PackMusicScreen.PackSelect, + AppMode.DifficultyTableSetup => PackMusicScreen.DifficultyTableSetup, + AppMode.Scanning => PackMusicScreen.Scanning, + AppMode.Library => PackMusicScreen.Library, + AppMode.QuickOptions => PackMusicScreen.Library, + _ => PackMusicScreen.None, + }; + _packMusicScreenPresenter.SetScreen(screen); + + switch (screen) + { + case PackMusicScreen.PackSelect: + { + var packs = _packRegistry?.Packs; + var selectedPack = packs != null && + _packSelection >= 0 && + _packSelection < packs.Count + ? packs[_packSelection] + : null; + var activePackId = _activePack?.Id ?? + _packRegistry?.ActivePack?.Id ?? + string.Empty; + var selectedIsActive = selectedPack != null && + string.Equals( + selectedPack.Id, + activePackId, + StringComparison.OrdinalIgnoreCase); + var selectedTableId = selectedPack?.DifficultyTableId ?? + string.Empty; + var selectedLibraryReady = selectedIsActive && _libraryReady; + var selectedTableDataAvailable = + selectedLibraryReady && + _activeCatalog != null && + string.Equals( + _activeCatalog.ProviderId, + selectedTableId, + StringComparison.OrdinalIgnoreCase); + _packMusicScreenPresenter.UpdatePackSelect( + packs, + _difficultyTableRegistry?.Tables, + selectedTableId, + _packSelection, + selectedLibraryReady + ? _libraryController?.State?.AllSongs?.Count ?? 0 + : -1, + selectedLibraryReady + ? _courseIndex?.Courses?.Count ?? 0 + : -1, + selectedLibraryReady && !string.IsNullOrWhiteSpace(selectedTableId) + ? _catalogMatchedCharts + : -1, + selectedTableDataAvailable, + _profileSnapshot, + _status, + _packKeyModeModalOpen, + _packKeyModeModalSelection, + _packRemovalConfirmationPending, + _pendingPackRemovalName); + break; + } + case PackMusicScreen.DifficultyTableSetup: + if (_tableUrlReleaseRequested) + { + _packMusicScreenPresenter.ReleaseTableUrlFocus(); + _tableUrlReleaseRequested = false; + } + _packMusicScreenPresenter.UpdateDifficultyTableSetup( + _difficultyTableRegistry?.Tables, + _tableSetupSelection, + ResolveDifficultyTableSetupPack()?.DifficultyTableId, + _tableUrlDraft, + _tableUrlFocusRequested, + _tableSetupStatus); + _tableUrlFocusRequested = false; + break; + case PackMusicScreen.Scanning: + case PackMusicScreen.Library: + { + var library = _libraryController?.State; + if (library == null) + break; + + var focusSearchRequested = + _mode == AppMode.Library && + _libraryController.ConsumeFocusSearchRequest(); + if (_libraryController.ConsumeReleaseSearchFocusRequest()) + _packMusicScreenPresenter.ReleaseSearchFocus(); + _packMusicScreenPresenter.UpdateLibrary( + library.VisibleSongs, + library.SelectedIndex, + _libraryRoot, + _packRegistry?.ActivePack?.DisplayName ?? _activePack?.DisplayName, + _status, + _scanner?.Completed ?? 0, + _scanner?.Total ?? 0, + _scanner?.Failed ?? 0, + library.AllSongs.Count, + _mode == AppMode.Scanning, + CurrentArtworkTexture, + library.SearchQuery, + focusSearchRequested, + library.LevelFilters, + library.SelectedLevelFilters, + library.LevelFilterPanelOpen, + library.LevelFilterCursorIndex, + library.SortPanelOpen, + library.SortCursorIndex, + library.SortMode, + library.FavoritesOnly, + SongLibraryView.SortLabel(library.SortMode), + _gearPosition, + _noteSpeed, + _gaugeType, + _laneModifier, + _gameplaySkin, + _bgaPlaybackMode, + _reactiveKeySoundsEnabled, + _profileSnapshot, + library.ViewRevision); + break; + } + } + } + + private void UpdateOptionsProfileCoursePresentation() + { + if (_optionsProfileCoursePresenter == null) + return; + + var optionsVisible = _mode == AppMode.Options; + var profileVisible = + _profileModalOpen && + (_mode == AppMode.Home || _mode == AppMode.Library); + var courseVisible = _mode == AppMode.CourseSelect; + _optionsProfileCoursePresenter.SetOptionsVisible(optionsVisible); + _optionsProfileCoursePresenter.SetProfileVisible(profileVisible); + _optionsProfileCoursePresenter.SetCourseVisible(courseVisible); + + if (optionsVisible) + { + _optionsProfileCoursePresenter.UpdateOptions( + _noteSpeed, + _judgementOffsetMilliseconds, + _audioOutputLatencyMilliseconds, + _masterVolumePercent, + _reactiveKeySoundsEnabled, + _audioDspBufferProfile, + CurrentAudioOutputDevice.Name, + CurrentAudioOutputDevice.DetailLabel, + _audioBackendFactory?.OutputConfiguration.AppliedLabel ?? string.Empty, + _audioDspStatus, + IsAudioSystemResetting, + _premixCacheStatus, + _premixCacheClearing, + _pinnedPackCacheStatus, + _pinnedPackCacheConfirmationPending, + _pinnedPackCacheWorking, + _packCacheManagerOpen, + _packCacheUiEntries, + _packCacheSelection, + _frameLimitMode, + _displayResolutionMode, + _windowMode, + _showFpsCounter, + _bgaPlaybackMode, + _bgaDisplayMode, + _vSyncEnabled, + _backgroundDimPercent, + _gameplaySkin, + _noteSkin, + _longNoteStyle, + _laneSeparatorsEnabled, + _optionsCategory, + _optionsSelectedRow, + _optionsInputKeyCount, + _inputPollingRate, + _inputPollingStatus, + _laneKeyLabels, + GameSettings.FormatLaneKeyLabel(_centerAuxKey), + GameSettings.FormatLaneKeyLabel(_settings.ScratchKey), + _rebindLane, + _profileTransferStatus, + _updateStatus, + _profileImportConfirmationPending, + _profileSnapshot, + BuildAudioSyncCalibrationView()); + } + + if (profileVisible) + { + _optionsProfileCoursePresenter.UpdateProfile( + _profileSnapshot, + _profileRatingSummary, + _profileCollection, + _profileCourses, + _profileModalTab, + _profileRatingContextIndex, + _ratingContexts.Count, + _profileBadgeSelection, + _profileCollectionColumn, + _profileCollectionSelections, + _profileRatingThumbnailSongs, + _profileRatingThumbnailSongsRevision); + } + + if (courseVisible) + { + _optionsProfileCoursePresenter.UpdateCourseSelect( + _courseIndex?.Courses, + _courseIndex?.ProviderDisplayName, + _courseSelectedIndex, + EffectiveCourseModifier, + _noteSpeed, + _courseStatus, + _profileSnapshot); + } + } + + private IEnumerator InitializeLibrary(bool openLibraryWhenReady) + { + _openLibraryWhenScanCompletes |= openLibraryWhenReady; + if (_libraryScanRunning) + { + if (openLibraryWhenReady) _mode = AppMode.Scanning; + yield break; + } + + _libraryScanRunning = true; + _libraryReady = false; + _libraryLoadError = string.Empty; + if (openLibraryWhenReady) _mode = AppMode.Scanning; + _libraryController.Reset(); + ClearLibraryThumbnail(); + _scanner = null; + var scanGeneration = ++_libraryScanGeneration; + _activePack = _packRegistry?.ActivePack; + _activeDifficultyTable = ResolvePackDifficultyTable(_activePack); + if (_activePack == null) + { + _libraryScanRunning = false; + _openLibraryWhenScanCompletes = false; + _status = "NO BMS PACK SELECTED"; + _mode = AppMode.PackSelect; + yield break; + } + + _libraryRoot = _activePack.ContentRoot; + _status = $"Opening {_activePack.DisplayName}..."; + if (!_activePack.IsAvailable) + { + FailLibraryScan( + "BMS PACK SOURCE MISSING", + $"The registered pack folder is unavailable:\n{_libraryRoot}\n\nReturn to pack selection and add the pack again."); + yield break; + } + + var isAeryTable = IsAeryTable(_activeDifficultyTable?.HeaderUrl); + var cachePath = _activeDifficultyTable == null + ? string.Empty + : _difficultyTableRegistry.DataCachePathFor(_activeDifficultyTable); + var courseCachePath = _activeDifficultyTable == null + ? string.Empty + : _difficultyTableRegistry.HeaderCachePathFor(_activeDifficultyTable); + var bundledTable = Resources.Load("ProjectQuint/Data/AeryData"); + var bundledCourseHeader = Resources.Load("ProjectQuint/Data/AeryHeader"); + ExternalDataOrigin catalogOrigin; + string catalogStatus; + string courseLocalStatus; + if (isAeryTable) + { + var tableResult = AeryTableIndex.LoadLocal( + cachePath, + bundledTable != null ? bundledTable.text : string.Empty); + _aeryCatalog = tableResult.Data; + _activeCatalog = _aeryCatalog; + catalogOrigin = tableResult.Origin; + catalogStatus = tableResult.Status; + + var courseResult = AeryCourseIndex.LoadLocal( + courseCachePath, + bundledCourseHeader != null ? bundledCourseHeader.text : string.Empty); + _courseIndex = courseResult.Data; + _courseStatus = courseResult.Status; + courseLocalStatus = courseResult.Status; + } + else + { + _aeryCatalog = null; + var localTable = _activeDifficultyTable == null + ? null + : BmsDifficultyTableIndex.LoadLocal( + _activeDifficultyTable.Id, + courseCachePath, + cachePath); + _activeCatalog = localTable?.Data; + catalogOrigin = localTable?.Origin ?? ExternalDataOrigin.Unavailable; + catalogStatus = localTable?.Status ?? "No difficulty table configured"; + var localCourses = _activeDifficultyTable == null + ? null + : AeryCourseIndex.LoadTableLocal( + _activeDifficultyTable.Id, + localTable?.Data?.DisplayName ?? _activeDifficultyTable.DisplayName, + courseCachePath); + _courseIndex = localCourses?.Data; + _courseStatus = localCourses?.Status ?? "NO BMSTABLE COURSE DATA CONFIGURED"; + courseLocalStatus = _courseStatus; + } + var bundledSearchAliases = Resources.Load("ProjectQuint/Data/SearchAliases"); + _searchAliases = SearchAliasIndex.Load( + bundledSearchAliases != null ? bundledSearchAliases.text : string.Empty, + PersistencePaths.SearchAliases); + + var libraryIndexCachePath = _packRegistry.CachePathFor(_activePack); + var legacyLibraryIndexCachePath = Path.Combine( + Application.persistentDataPath, + "LibraryIndex", + "song-index-v2.json"); + _scanner = new SongLibraryScanner( + libraryIndexCachePath, + legacyLibraryIndexCachePath, + _activePack.Id, + ResolvePackScanKeyCount(_activePack, _activeDifficultyTable)); + _scanCancellation?.Cancel(); + _scanCancellation?.Dispose(); + _scanCancellation = new CancellationTokenSource(); + var token = _scanCancellation.Token; + var scanTask = Task.Run(() => _scanner.Scan(_libraryRoot, token), token); + + while (!scanTask.IsCompleted) + { + _status = $"Scanning {_activePack.DisplayName} // {(_activeCatalog?.Count ?? 0):N0} table entries // " + + $"{_scanner.CacheHits:N0} cached // {_scanner.CacheMisses:N0} refreshed"; + yield return null; + } + + if (scanTask.IsCanceled) + { + _libraryScanRunning = false; + _libraryReady = false; + _openLibraryWhenScanCompletes = false; + _status = "LIBRARY SCAN CANCELLED"; + if (_mode == AppMode.Scanning) + _mode = AppMode.PackSelect; + yield break; + } + if (scanTask.IsFaulted) + { + FailLibraryScan("LIBRARY SCAN FAILED", scanTask.Exception?.GetBaseException().Message ?? "Unknown scan error"); + yield break; + } + + var scannedSongs = scanTask.Result; + _catalogMatchedCharts = ChartCatalogAnnotator.Apply(scannedSongs, _activeCatalog); + _aeryMatchedCharts = isAeryTable ? _catalogMatchedCharts : 0; + SongSearchIndexer.Rebuild(scannedSongs, _searchAliases); + _favoriteRepository.Apply(scannedSongs); + foreach (var song in scannedSongs) + { + song.ClearLamp = SongClearLampStore.Load(song); + song.BestRecord = SongPlayRecordStore.Load(song); + } + SongRecordRepository.Shared.EnrichChartMetadata(scannedSongs, _activePack?.DisplayName); + if (_playCommitCoordinator.TryResume(scannedSongs, out _)) + Debug.Log("[ProjectQuint Progression] Recovered the pending play result commit."); + RefreshProfileSummary(); + _courseIndex?.ResolveSongs(scannedSongs); + RefreshProfileSummary(); + if (_courseIndex != null) + { + var availableCourses = _courseIndex.Courses.Count(course => course.IsPlayable); + _courseStatus = $"{courseLocalStatus} // {_courseIndex.TableName} {_courseIndex.LastUpdate} // " + + $"{availableCourses}/{_courseIndex.Courses.Count} COURSES READY"; + } + if (scannedSongs.Count == 0) + { + FailLibraryScan("NO PLAYABLE CHARTS", $"No BMS/BME/BML files were indexed under:\n{_libraryRoot}"); + yield break; + } + + _libraryController.ReplaceSongs(scannedSongs); + _libraryController.RestoreViewState( + _activePack.SelectedLevelFilters, + (SongSortMode)_activePack.LibrarySortMode, + _activePack.LibraryFavoritesOnly, + _activePack.LastSelectedChartMd5); + var catalogName = _activeCatalog?.DisplayName ?? "BMS PLAYLEVEL"; + _status = $"{_activePack.DisplayName} // BMS {scannedSongs.Count:N0} // {catalogName} {_catalogMatchedCharts:N0} matched ({catalogOrigin}) // " + + $"{_scanner.CacheHits:N0} cached // {_scanner.CacheMisses:N0} refreshed"; + _libraryReady = true; + _libraryScanRunning = false; + RefreshLibraryThumbnail(); + if (_openLibraryWhenScanCompletes) _mode = AppMode.Library; + _openLibraryWhenScanCompletes = false; + if (isAeryTable) + { + StartCoroutine(RefreshAeryIntegration( + scanGeneration, + cachePath, + bundledTable != null ? bundledTable.text : string.Empty, + courseCachePath, + bundledCourseHeader != null ? bundledCourseHeader.text : string.Empty)); + } + else if (_activeDifficultyTable != null) + { + StartCoroutine(RefreshGenericDifficultyTable(scanGeneration, _activeDifficultyTable, _activePack)); + } + Debug.Log($"[ProjectQuint] Indexed {scannedSongs.Count} charts from {_libraryRoot}. Failed: {_scanner.Failed}. " + + $"Index cache: {_scanner.CacheStatus}. " + + $"Difficulty table: {catalogStatus}; {_catalogMatchedCharts} matched from {catalogOrigin}. " + + $"Default view: {SongLibraryView.AllLevelsFilter} " + + $"({_libraryController.State.VisibleSongs.Count} charts)."); + } + + private IEnumerator RefreshAeryIntegration( + int scanGeneration, + string tableCachePath, + string bundledTableJson, + string courseCachePath, + string bundledCourseJson) + { + ExternalDataResult tableResult = null; + yield return AeryTableIndex.LoadOnline( + tableCachePath, + bundledTableJson, + result => tableResult = result); + if (scanGeneration != _libraryScanGeneration || !_libraryReady) + yield break; + + if (tableResult?.Data != null && tableResult.Data.Count > 0) + { + _aeryCatalog = tableResult.Data; + _activeCatalog = _aeryCatalog; + _catalogMatchedCharts = ChartCatalogAnnotator.Apply( + _libraryController.State.AllSongs, + _aeryCatalog); + _aeryMatchedCharts = _catalogMatchedCharts; + SongSearchIndexer.Rebuild(_libraryController.State.AllSongs, _searchAliases); + _libraryController.RefreshCatalogMetadata(); + if (_mode == AppMode.Library || _mode == AppMode.QuickOptions) + RefreshLibraryThumbnail(); + } + + ExternalDataResult courseResult = null; + yield return AeryCourseIndex.LoadOnline( + courseCachePath, + bundledCourseJson, + result => courseResult = result); + if (scanGeneration != _libraryScanGeneration || !_libraryReady) + yield break; + + if (courseResult?.Data != null && courseResult.Data.Courses.Count > 0) + { + _courseIndex = courseResult.Data; + _courseIndex.ResolveSongs(_libraryController.State.AllSongs); + } + + var availableCourses = _courseIndex?.Courses.Count(course => course.IsPlayable) ?? 0; + var totalCourses = _courseIndex?.Courses.Count ?? 0; + _courseStatus = $"{courseResult?.Status ?? "AERY courses: unavailable"} // " + + $"{availableCourses}/{totalCourses} COURSES READY"; + _status = $"BMS {_libraryController.State.AllSongs.Count:N0} // " + + $"AERY {_aeryMatchedCharts:N0} matched ({_aeryCatalog?.Origin ?? ExternalDataOrigin.Unavailable}) // " + + $"{(_scanner?.CacheHits ?? 0):N0} cached // {(_scanner?.CacheMisses ?? 0):N0} refreshed"; + Debug.Log( + $"[ProjectQuint] Optional AERY refresh complete: " + + $"{_aeryMatchedCharts} chart matches from {tableResult?.Origin ?? ExternalDataOrigin.Unavailable}; " + + $"{availableCourses}/{totalCourses} courses from {courseResult?.Origin ?? ExternalDataOrigin.Unavailable}."); + } + + private IEnumerator RefreshGenericDifficultyTable( + int scanGeneration, + BmsDifficultyTableDefinition table, + BmsPackDefinition sourcePack) + { + ExternalDataResult result = null; + yield return BmsDifficultyTableIndex.LoadOnline( + table.Id, + table.HeaderUrl, + _difficultyTableRegistry.HeaderCachePathFor(table), + _difficultyTableRegistry.DataCachePathFor(table), + loaded => result = loaded); + if (scanGeneration != _libraryScanGeneration || !_libraryReady || + _activePack == null || !string.Equals(_activePack.Id, sourcePack.Id, StringComparison.OrdinalIgnoreCase) || + !string.Equals( + _activePack.DifficultyTableId, + table.Id, + StringComparison.OrdinalIgnoreCase)) + yield break; + + if (result?.Data != null && result.Data.Count > 0) + { + _activeCatalog = result.Data; + _difficultyTableRegistry.UpdateDisplayName(table.Id, result.Data.DisplayName); + _activeDifficultyTable = ResolvePackDifficultyTable(_activePack); + _catalogMatchedCharts = ChartCatalogAnnotator.Apply( + _libraryController.State.AllSongs, + _activeCatalog); + SongSearchIndexer.Rebuild(_libraryController.State.AllSongs, _searchAliases); + _libraryController.RefreshCatalogMetadata(); + if (_mode == AppMode.Library || _mode == AppMode.QuickOptions) + RefreshLibraryThumbnail(); + } + + var courseResult = AeryCourseIndex.LoadTableLocal( + table.Id, + result?.Data?.DisplayName ?? table.DisplayName, + _difficultyTableRegistry.HeaderCachePathFor(table)); + if (courseResult.Data != null) + { + _courseIndex = courseResult.Data; + _courseIndex.ResolveSongs(_libraryController.State.AllSongs); + } + var availableCourses = _courseIndex?.Courses.Count(course => course.IsPlayable) ?? 0; + var totalCourses = _courseIndex?.Courses.Count ?? 0; + _courseStatus = $"{courseResult.Status} // {availableCourses}/{totalCourses} COURSES READY"; + + _status = $"{sourcePack.DisplayName} // BMS {_libraryController.State.AllSongs.Count:N0} // " + + $"{_activeCatalog?.DisplayName ?? "TABLE"} {_catalogMatchedCharts:N0} matched " + + $"({result?.Origin ?? ExternalDataOrigin.Unavailable}) // " + + $"{(_scanner?.CacheHits ?? 0):N0} cached // {(_scanner?.CacheMisses ?? 0):N0} refreshed"; + Debug.Log($"[ProjectQuint] Difficulty table refresh: {result?.Status}; {_catalogMatchedCharts} chart matches; " + + $"{availableCourses}/{totalCourses} courses from {courseResult.Origin}."); + } + + private static bool IsAeryTable(string url) => + !string.IsNullOrWhiteSpace(url) && + url.IndexOf("/aery/", StringComparison.OrdinalIgnoreCase) >= 0; + + private void UpdateHomeInput() + { + var keyboard = Keyboard.current; + if (keyboard == null) + return; + + if (HandleProfileModalKeyboard(keyboard, true)) + return; + + _homeSelection = (int)HomeMenuAction.Play; + if (keyboard.escapeKey.wasPressedThisFrame) + { + ActivateHomeAction(HomeMenuAction.Quit); + return; + } + if (keyboard.enterKey.wasPressedThisFrame || keyboard.numpadEnterKey.wasPressedThisFrame) + ActivateHomeAction(HomeMenuAction.Play); + } + + private void UpdateOptionsInput() + { + var keyboard = Keyboard.current; + if (keyboard == null) + return; + + if (_audioSyncCalibration != null || + !string.IsNullOrWhiteSpace(_audioSyncCalibrationError)) + { + if (keyboard.escapeKey.wasPressedThisFrame) + { + CloseAudioSyncCalibration(); + return; + } + if ((keyboard.enterKey.wasPressedThisFrame || + keyboard.numpadEnterKey.wasPressedThisFrame) && + _audioSyncCalibration?.Phase != AudioSyncCalibrationPhase.Running) + { + if (_audioSyncCalibration?.Phase == AudioSyncCalibrationPhase.Result && + _audioSyncCalibration.Result.CanApply) + ApplyAudioSyncCalibration(); + else + StartAudioSyncCalibration(); + } + return; + } + + if (_pinnedPackCacheConfirmationPending) + { + if (keyboard.enterKey.wasPressedThisFrame || keyboard.numpadEnterKey.wasPressedThisFrame) + ConfirmPinnedPackCache(); + else if (keyboard.escapeKey.wasPressedThisFrame) + CancelPinnedPackCache(); + return; + } + if (_pinnedPackCacheWorking && keyboard.escapeKey.wasPressedThisFrame) + { + CancelPinnedPackCache(); + return; + } + + if (_packCacheManagerOpen) + { + var packCount = _packRegistry?.Packs.Count ?? 0; + if (keyboard.escapeKey.wasPressedThisFrame) + { + ClosePackCacheManager(); + return; + } + if (packCount > 0 && keyboard.upArrowKey.wasPressedThisFrame) + SetPackCacheSelection(ProjectQuintPresentation.CyclePackCacheSelection( + _packCacheSelection, -1, packCount)); + else if (packCount > 0 && keyboard.downArrowKey.wasPressedThisFrame) + SetPackCacheSelection(ProjectQuintPresentation.CyclePackCacheSelection( + _packCacheSelection, 1, packCount)); + else if (keyboard.enterKey.wasPressedThisFrame || keyboard.numpadEnterKey.wasPressedThisFrame || + keyboard.rKey.wasPressedThisFrame) + RequestPinnedPackCacheEstimate(); + else if (keyboard.deleteKey.wasPressedThisFrame || keyboard.backspaceKey.wasPressedThisFrame) + RequestDeleteSelectedPackCache(); + return; + } + + if (_rebindLane >= 0) + { + if (keyboard.escapeKey.wasPressedThisFrame) + { + _rebindLane = -1; + return; + } + + foreach (var keyControl in keyboard.allKeys) + { + if (!keyControl.wasPressedThisFrame || !GameSettings.IsAssignableLaneKey(keyControl.keyCode)) + continue; + + AssignInputBinding(_rebindLane, keyControl.keyCode); + _rebindLane = -1; + return; + } + return; + } + + if (_optionsCategory == OptionsCategory.Gameplay && + HandleOptionsPreviewLaneInput(keyboard)) + return; + + var categoryShortcut = PressedOptionsCategoryShortcut(keyboard); + if (categoryShortcut >= 0) + { + CommitContinuousOptionsIfNeeded(); + SetOptionsCategory((OptionsCategory)categoryShortcut); + return; + } + var categoryDelta = keyboard.leftShiftKey.wasPressedThisFrame ? -1 : + keyboard.rightShiftKey.wasPressedThisFrame ? 1 : 0; + if (categoryDelta != 0) + { + CommitContinuousOptionsIfNeeded(); + CycleOptionsCategory(categoryDelta); + return; + } + var selectionDelta = keyboard.upArrowKey.wasPressedThisFrame ? -1 : + keyboard.downArrowKey.wasPressedThisFrame ? 1 : 0; + if (selectionDelta != 0) + { + CommitContinuousOptionsIfNeeded(); + _optionsValueKeyRepeat.Reset(); + _optionsSelectedRow = ProjectQuintPresentation.CycleOptionsRow( + _optionsCategory, + _optionsSelectedRow, + selectionDelta, + _optionsInputKeyCount); + return; + } + var leftHeld = keyboard.leftArrowKey.isPressed; + var rightHeld = keyboard.rightArrowKey.isPressed; + var valueDirection = leftHeld == rightHeld ? 0 : leftHeld ? -1 : 1; + var valuePressedThisFrame = keyboard.leftArrowKey.wasPressedThisFrame || + keyboard.rightArrowKey.wasPressedThisFrame; + var continuousOption = IsSelectedOptionContinuous(); + var valueDelta = continuousOption + ? _optionsValueKeyRepeat.Update( + valueDirection, + valuePressedThisFrame, + Time.realtimeSinceStartupAsDouble) + : valuePressedThisFrame ? valueDirection : 0; + if (!continuousOption) + _optionsValueKeyRepeat.Reset(); + if (valueDirection == 0) + CommitContinuousOptionsIfNeeded(); + if (valueDelta != 0) + { + if (continuousOption) + ChangeSelectedContinuousOption(valueDelta); + else + ChangeSelectedOption(valueDelta); + return; + } + if (keyboard.enterKey.wasPressedThisFrame || keyboard.numpadEnterKey.wasPressedThisFrame) + { + CommitContinuousOptionsIfNeeded(); + ActivateSelectedOption(); + return; + } + if (keyboard.escapeKey.wasPressedThisFrame) + { + CommitContinuousOptionsIfNeeded(); + if (_profileImportConfirmationPending) + { + _profileImportConfirmationPending = false; + _profileTransferStatus = "PROFILE IMPORT CANCELLED"; + return; + } + _rebindLane = -1; + CloseOptions(); + } + } + + private static int PressedOptionsCategoryShortcut(Keyboard keyboard) + { + if (keyboard.digit1Key.wasPressedThisFrame || keyboard.numpad1Key.wasPressedThisFrame) + return 0; + if (keyboard.digit2Key.wasPressedThisFrame || keyboard.numpad2Key.wasPressedThisFrame) + return 1; + if (keyboard.digit3Key.wasPressedThisFrame || keyboard.numpad3Key.wasPressedThisFrame) + return 2; + if (keyboard.digit4Key.wasPressedThisFrame || keyboard.numpad4Key.wasPressedThisFrame) + return 3; + if (keyboard.digit5Key.wasPressedThisFrame || keyboard.numpad5Key.wasPressedThisFrame) + return 4; + if (keyboard.digit6Key.wasPressedThisFrame || keyboard.numpad6Key.wasPressedThisFrame) + return 5; + return -1; + } + + private bool HandleOptionsPreviewLaneInput(Keyboard keyboard) + { + var keys = _settings.KeysFor(_optionsInputKeyCount); + var pressed = false; + for (var lane = 0; lane < keys.Length; lane++) + { + var key = keys[lane]; + if (!GameSettings.IsAssignableLaneKey(key)) + continue; + var control = keyboard[key]; + if (control == null) + continue; + if (control.wasPressedThisFrame) + { + _optionsProfileCoursePresenter?.PressOptionsPreviewLane(lane); + pressed = true; + } + if (control.wasReleasedThisFrame) + { + _optionsProfileCoursePresenter?.ReleaseOptionsPreviewLane(lane); + pressed = true; + } + } + return pressed; + } + + private void OpenOptions(AppMode returnMode) + { + _optionsReturnMode = returnMode == AppMode.Library ? AppMode.Library : AppMode.Home; + RefreshAudioOutputDevices(); + RefreshPremixCacheStatus(); + _flow.BeginOptions(); + } + + private void CloseOptions() + { + CloseAudioSyncCalibration(); + _rebindLane = -1; + _profileImportConfirmationPending = false; + _mode = _optionsReturnMode; + _optionsReturnMode = AppMode.Home; + } + + private void HandleOptionsUi(OptionsUiResult result) + { + if (result.SelectedCategory >= 0) + SetOptionsCategory((OptionsCategory)result.SelectedCategory); + if (Math.Abs(result.SpeedDelta) > 0.001f) + ChangeNoteSpeed(result.SpeedDelta); + if (result.JudgementOffsetDeltaMilliseconds != 0) + ChangeJudgementOffset(result.JudgementOffsetDeltaMilliseconds); + if (result.AudioOutputLatencyDeltaMilliseconds != 0) + ChangeAudioOutputLatency(result.AudioOutputLatencyDeltaMilliseconds); + if (result.AudioOutputDeviceDelta != 0) + CycleAudioOutputDevice(result.AudioOutputDeviceDelta); + if (result.AudioDspProfileDelta != 0) + CycleAudioDspBufferProfile(result.AudioDspProfileDelta); + if (result.MasterVolumePercent >= 0) + ChangeMasterVolume(result.MasterVolumePercent); + if (result.ToggleKeySound) + ToggleReactiveKeySounds(); + if (result.ClearPremixCache) + RequestClearPremixCache(); + if (result.OpenPackCacheManager) + OpenPackCacheManager(); + if (result.SelectedPackCacheIndex >= 0) + SetPackCacheSelection(result.SelectedPackCacheIndex); + if (result.BuildSelectedPackCache) + RequestPinnedPackCacheEstimate(); + if (result.DeleteSelectedPackCache) + RequestDeleteSelectedPackCache(); + if (result.ClosePackCacheManager) + ClosePackCacheManager(); + if (result.ConfirmPinnedPackCache) + ConfirmPinnedPackCache(); + if (result.CancelPinnedPackCache) + CancelPinnedPackCache(); + if (result.InputProfileDelta != 0) + CycleInputProfile(result.InputProfileDelta); + if (result.InputPollingRateDelta != 0) + CycleInputPollingRate(result.InputPollingRateDelta); + if (result.FrameLimitDelta != 0) + CycleFrameLimit(result.FrameLimitDelta); + if (result.ResolutionDelta != 0) + CycleDisplayResolution(result.ResolutionDelta); + if (result.WindowModeDelta != 0) + CycleWindowMode(result.WindowModeDelta); + if (result.ToggleFpsCounter) + ToggleFpsCounter(); + if (result.BgaPlaybackModeDelta != 0) + CycleBgaPlaybackMode(result.BgaPlaybackModeDelta); + if (result.BgaDisplayModeDelta != 0) + CycleBgaDisplayMode(result.BgaDisplayModeDelta); + if (result.ToggleVSync) + ToggleVSync(); + if (result.BackgroundDimDeltaPercent != 0) + ChangeBackgroundDim(result.BackgroundDimDeltaPercent); + if (result.ExportProfile) + ExportProfileArchive(); + if (result.ImportProfile) + RequestOrImportProfileArchive(); + if (result.CheckForUpdates) + RequestUpdateCheck(manual: true); + if (result.OpenAudioSyncCalibration) + OpenAudioSyncCalibration(); + if (result.StartAudioSyncCalibration) + StartAudioSyncCalibration(); + if (result.RetryAudioSyncCalibration) + StartAudioSyncCalibration(); + if (result.ApplyAudioSyncCalibration) + ApplyAudioSyncCalibration(); + if (result.CancelAudioSyncCalibration) + CloseAudioSyncCalibration(); + if (result.RebindLane >= 0) + { + _optionsSelectedRow = ProjectQuintPresentation.InputRowForBinding( + result.RebindLane, + _optionsInputKeyCount); + _rebindLane = result.RebindLane; + } + if (!result.Back) + return; + + _rebindLane = -1; + _profileImportConfirmationPending = false; + CloseOptions(); + } + + private void ActivateHomeAction(HomeMenuAction action) + { + switch (action) + { + case HomeMenuAction.Play: + _packSelection = Mathf.Clamp( + ResolveActivePackSelection(), + 0, + Math.Max(0, (_packRegistry?.Packs.Count ?? 0) + 1)); + _status = _packRegistry?.Packs.Count > 0 + ? $"{_packRegistry.Packs.Count:N0} BMS PACKS REGISTERED" + : "SELECT A FOLDER OR ZIP TO BEGIN"; + _mode = AppMode.PackSelect; + break; + case HomeMenuAction.Options: + OpenOptions(AppMode.Home); + break; + case HomeMenuAction.Quit: + PersistSettings(); +#if UNITY_EDITOR + UnityEditor.EditorApplication.isPlaying = false; +#else + Application.Quit(); +#endif + break; + case HomeMenuAction.Profile: + OpenProfileModal(0); + break; + } + } + + private void HandleHomePresenterAction(HomeMenuAction action) + { + if (action == HomeMenuAction.Play) + _homeSelection = (int)HomeMenuAction.Play; + ActivateHomeAction(action); + } + + private int ResolveActivePackSelection() + { + var packs = _packRegistry?.Packs; + if (packs == null || _activePack == null) + return 0; + for (var index = 0; index < packs.Count; index++) + if (string.Equals(packs[index].Id, _activePack.Id, StringComparison.OrdinalIgnoreCase)) + return index; + return 0; + } + + private void UpdatePackSelectInput() + { + var keyboard = Keyboard.current; + if (keyboard == null) + return; + + if (_packKeyModeModalOpen) + { + if (keyboard.escapeKey.wasPressedThisFrame) + { + ClosePackKeyModeModal(); + return; + } + if (keyboard.downArrowKey.wasPressedThisFrame) + MovePackKeyModeModalSelection(1); + if (keyboard.upArrowKey.wasPressedThisFrame) + MovePackKeyModeModalSelection(-1); + if (keyboard.enterKey.wasPressedThisFrame || + keyboard.numpadEnterKey.wasPressedThisFrame) + ApplySelectedPackKeyMode( + PackKeyModeChoices[_packKeyModeModalSelection]); + return; + } + + if (_packRemovalConfirmationPending) + { + if (keyboard.escapeKey.wasPressedThisFrame) + { + CancelPackRemoval(); + return; + } + if (keyboard.enterKey.wasPressedThisFrame || keyboard.numpadEnterKey.wasPressedThisFrame) + ConfirmPackRemoval(); + return; + } + + var optionCount = (_packRegistry?.Packs.Count ?? 0) + 2; + if (keyboard.downArrowKey.wasPressedThisFrame) + _packSelection = (_packSelection + 1) % optionCount; + if (keyboard.upArrowKey.wasPressedThisFrame) + _packSelection = (_packSelection + optionCount - 1) % optionCount; + if (keyboard.escapeKey.wasPressedThisFrame) + { + _mode = AppMode.Home; + return; + } + if (IsPackDifficultyTableShortcut( + keyboard.tKey.wasPressedThisFrame, + keyboard.digit5Key.wasPressedThisFrame, + keyboard.numpad5Key.wasPressedThisFrame)) + { + OpenDifficultyTableSetup(); + return; + } + if (keyboard.digit6Key.wasPressedThisFrame || + keyboard.numpad6Key.wasPressedThisFrame) + { + ApplyRecommendedDifficultyTable(); + return; + } + if (keyboard.lKey.wasPressedThisFrame) + { + RelinkSelectedPack(); + return; + } + if (keyboard.nKey.wasPressedThisFrame) + { + RenameSelectedPack(); + return; + } + if (keyboard.f5Key.wasPressedThisFrame) + { + RescanSelectedPack(); + return; + } + if (keyboard.deleteKey.wasPressedThisFrame || keyboard.backspaceKey.wasPressedThisFrame) + { + RequestPackRemoval(); + return; + } + if (keyboard.enterKey.wasPressedThisFrame || keyboard.numpadEnterKey.wasPressedThisFrame) + ActivatePackSelection(_packSelection); + } + + internal static bool IsPackDifficultyTableShortcut( + bool tableKeyPressed, + bool digit5Pressed, + bool numpad5Pressed) => + tableKeyPressed || digit5Pressed || numpad5Pressed; + + private void HandlePackSelectUi(PackSelectUiResult result) + { + if (result.CancelKeyMode) + { + ClosePackKeyModeModal(); + return; + } + if (result.KeyModeChoice >= 0) + { + ApplySelectedPackKeyMode(result.KeyModeChoice); + return; + } + if (_packKeyModeModalOpen) + return; + if (result.CancelRemove) + { + CancelPackRemoval(); + return; + } + if (result.ConfirmRemove) + { + ConfirmPackRemoval(); + return; + } + if (_packRemovalConfirmationPending) + return; + if (result.SelectedIndex >= 0) + _packSelection = result.SelectedIndex; + if (result.RequestRemove) + { + RequestPackRemoval(); + return; + } + if (result.Relink) + { + RelinkSelectedPack(); + return; + } + if (result.Rename) + { + RenameSelectedPack(); + return; + } + if (result.Rescan) + { + RescanSelectedPack(); + return; + } + if (result.OpenKeyMode) + { + OpenPackKeyModeModal(); + return; + } + if (result.Back) + { + _mode = AppMode.Home; + return; + } + if (result.ConfigureTable) + { + OpenDifficultyTableSetup(); + return; + } + if (result.UseRecommendedTable) + { + ApplyRecommendedDifficultyTable(); + return; + } + if (result.Confirm) + ActivatePackSelection(_packSelection); + } + + private void OpenPackKeyModeModal() + { + var selected = SelectedRegisteredPack(); + if (selected == null || _packRegistry == null) + { + _status = "SELECT A REGISTERED PACK BEFORE CHANGING KEY MODE"; + return; + } + + _pendingPackKeyModeId = selected.Id; + _packKeyModeModalSelection = + Math.Max(0, PackKeyModeChoiceIndex(selected.ForcedKeyCount)); + _packKeyModeModalOpen = true; + _status = $"SELECT KEY MODE // {selected.DisplayName}"; + } + + private void ClosePackKeyModeModal() + { + _packKeyModeModalOpen = false; + _pendingPackKeyModeId = string.Empty; + _status = "KEY MODE CHANGE CANCELLED"; + } + + private void MovePackKeyModeModalSelection(int delta) + { + var count = PackKeyModeChoices.Length; + _packKeyModeModalSelection = + ((_packKeyModeModalSelection + delta) % count + count) % count; + } + + private void ApplySelectedPackKeyMode(int keyCount) + { + if (PackKeyModeChoiceIndex(keyCount) < 0 || + _packRegistry == null || + string.IsNullOrWhiteSpace(_pendingPackKeyModeId)) + { + _status = "KEY MODE CHANGE FAILED // INVALID SELECTION"; + _packKeyModeModalOpen = false; + _pendingPackKeyModeId = string.Empty; + return; + } + + var selected = _packRegistry.Find(_pendingPackKeyModeId); + if (selected == null) + { + _status = "KEY MODE CHANGE FAILED // PACK NOT FOUND"; + _packKeyModeModalOpen = false; + _pendingPackKeyModeId = string.Empty; + return; + } + + var isActive = _activePack != null && + string.Equals( + _activePack.Id, + selected.Id, + StringComparison.OrdinalIgnoreCase); + if (selected.ForcedKeyCount == keyCount) + { + _packKeyModeModalOpen = false; + _pendingPackKeyModeId = string.Empty; + _status = $"KEY MODE UNCHANGED // {selected.DisplayName} // " + + $"{selected.KeyModeLabel}"; + return; + } + try + { + var updated = _packRegistry.SetForcedKeyCount(selected.Id, keyCount); + _packKeyModeModalOpen = false; + _pendingPackKeyModeId = string.Empty; + _status = $"KEY MODE // {updated.DisplayName} // {updated.KeyModeLabel}"; + if (!isActive) + return; + + ClearActiveLibraryState(); + _activePack = updated; + StartCoroutine(InitializeLibrary(false)); + } + catch (Exception exception) + { + _packKeyModeModalOpen = false; + _pendingPackKeyModeId = string.Empty; + _status = $"KEY MODE CHANGE FAILED // {exception.Message}"; + Debug.LogException(exception); + } + } + + internal static int PackKeyModeChoiceIndex(int keyCount) + { + for (var index = 0; index < PackKeyModeChoices.Length; index++) + if (PackKeyModeChoices[index] == keyCount) + return index; + return -1; + } + + internal static int ResolvePackScanKeyCount( + BmsPackDefinition pack, + BmsDifficultyTableDefinition table) + { + if (pack?.ForcedKeyCount >= 4 && pack.ForcedKeyCount <= 7) + return pack.ForcedKeyCount; + var preset = BmsDifficultyTableIndex.FindPreset(table?.Id, table?.HeaderUrl); + return preset.HasValue && + preset.Value.DefaultKeyCount >= 4 && + preset.Value.DefaultKeyCount <= 7 + ? preset.Value.DefaultKeyCount + : 0; + } + + private void RequestPackRemoval() + { + var packs = _packRegistry?.Packs; + if (packs == null || _packSelection < 0 || _packSelection >= packs.Count) + return; + + var pack = packs[_packSelection]; + _pendingPackRemovalId = pack.Id; + _pendingPackRemovalName = pack.DisplayName; + _packRemovalConfirmationPending = true; + _status = $"CONFIRM REMOVE // {pack.DisplayName}"; + } + + private void CancelPackRemoval() + { + _packRemovalConfirmationPending = false; + _pendingPackRemovalId = string.Empty; + _pendingPackRemovalName = string.Empty; + _status = "PACK REMOVAL CANCELLED"; + } + + private void ConfirmPackRemoval() + { + var pack = _packRegistry?.Find(_pendingPackRemovalId); + if (pack == null) + { + CancelPackRemoval(); + _status = "PACK IS NO LONGER REGISTERED"; + return; + } + + if (_packRemovalInProgress) + return; + StartCoroutine(RemovePack(pack)); + } + + private IEnumerator RemovePack(BmsPackDefinition pack) + { + var removedPackName = pack.DisplayName; + var removedManagedContent = pack.SourceKind == BmsPackSourceKind.ExtractedZip; + var removedSelection = _packSelection; + var removedActivePack = _activePack != null && + string.Equals(_activePack.Id, pack.Id, StringComparison.OrdinalIgnoreCase); + _packRemovalInProgress = true; + _packRemovalConfirmationPending = false; + _flow.BeginLoading( + "REMOVING BMS PACK", + removedPackName, + Time.realtimeSinceStartupAsDouble); + _mode = AppMode.PackImport; + + Task removalTask = null; + Exception removalError = null; + try + { + if (PathsEqual(_settings.BmsLibraryRoot, pack.ContentRoot)) + { + _settings.BmsLibraryRoot = string.Empty; + PersistSettings(); + } + + removalTask = Task.Run(() => _packRegistry.Unregister(pack.Id)); + } + catch (Exception exception) + { + removalError = exception; + } + + while (removalError == null && removalTask != null && !removalTask.IsCompleted) + { + _flow.ReportLoading( + removedManagedContent + ? "DELETING IMPORTED SONG FILES" + : "REMOVING REGISTRATION AND DERIVED INDEX", + 0, + 0); + yield return null; + } + + if (removalError == null && removalTask != null) + { + if (removalTask.IsFaulted) + removalError = removalTask.Exception?.GetBaseException() ?? + new InvalidOperationException("Unknown pack removal error."); + else if (!removalTask.Result) + removalError = + new InvalidOperationException("The selected pack is no longer registered."); + } + + try + { + if (removalError != null) + throw removalError; + if (removedActivePack) + ClearActiveLibraryState(); + + _activePack = _packRegistry.ActivePack; + var packCount = _packRegistry.Packs.Count; + _packSelection = packCount == 0 + ? 0 + : Mathf.Clamp(removedSelection, 0, packCount - 1); + _packRemovalConfirmationPending = false; + _pendingPackRemovalId = string.Empty; + _pendingPackRemovalName = string.Empty; + _mode = AppMode.PackSelect; + _status = !string.IsNullOrWhiteSpace(_packRegistry.LastRemovalWarning) + ? $"REMOVED WITH STORAGE WARNING // {_packRegistry.LastRemovalWarning}" + : removedManagedContent + ? $"REMOVED // {removedPackName} // IMPORTED SONG FILES DELETED" + : $"REMOVED // {removedPackName} // EXTERNAL SOURCE KEPT"; + } + catch (Exception exception) + { + _packRemovalConfirmationPending = false; + _pendingPackRemovalId = string.Empty; + _pendingPackRemovalName = string.Empty; + _status = $"PACK REMOVAL FAILED // {exception.Message}"; + _mode = AppMode.PackSelect; + Debug.LogException(exception); + } + _packRemovalInProgress = false; + } + + private void ClearActiveLibraryState() + { + _scanCancellation?.Cancel(); + _libraryScanGeneration++; + _libraryScanRunning = false; + _openLibraryWhenScanCompletes = false; + _libraryReady = false; + _libraryRoot = string.Empty; + _libraryLoadError = string.Empty; + _activeCatalog = null; + _aeryCatalog = null; + _courseIndex = null; + _searchAliases = SearchAliasIndex.Empty; + _aeryMatchedCharts = 0; + _catalogMatchedCharts = 0; + ClearLoadedSongState(); + _courseSession.ClearRun(); + _libraryController.Reset(); + ClearLibraryThumbnail(); + } + + private BmsPackDefinition SelectedRegisteredPack() + { + var packs = _packRegistry?.Packs; + return packs != null && _packSelection >= 0 && _packSelection < packs.Count + ? packs[_packSelection] + : null; + } + + private void RelinkSelectedPack() + { + var selected = SelectedRegisteredPack(); + if (selected == null) + return; + var selectedPath = NativeBmsPackPicker.ChooseFolder(); + if (string.IsNullOrWhiteSpace(selectedPath)) + { + _status = "PACK RELINK CANCELLED"; + return; + } + + var oldRoot = selected.ContentRoot; + var isActive = _activePack != null && + string.Equals(_activePack.Id, selected.Id, StringComparison.OrdinalIgnoreCase); + try + { + var pack = _packRegistry.RelinkFolder(selected.Id, selectedPath); + if (PathsEqual(_settings.BmsLibraryRoot, oldRoot)) + { + _settings.BmsLibraryRoot = string.Empty; + PersistSettings(); + } + _activePack = _packRegistry.ActivePack; + _status = $"RELINKED // {pack.DisplayName} // INDEX INVALIDATED"; + if (isActive) + { + ClearActiveLibraryState(); + StartCoroutine(InitializeLibrary(true)); + } + } + catch (Exception exception) + { + _status = $"PACK RELINK FAILED // {exception.Message}"; + Debug.LogException(exception); + } + } + + private void RenameSelectedPack() + { + var selected = SelectedRegisteredPack(); + if (selected == null) + return; + var displayName = NativeBmsPackPicker.ChoosePackDisplayName(selected.DisplayName); + if (string.IsNullOrWhiteSpace(displayName)) + { + _status = "PACK RENAME CANCELLED"; + return; + } + + try + { + var pack = _packRegistry.Rename(selected.Id, displayName); + _activePack = _packRegistry.ActivePack; + _status = $"RENAMED // {pack.DisplayName}"; + } + catch (Exception exception) + { + _status = $"PACK RENAME FAILED // {exception.Message}"; + Debug.LogException(exception); + } + } + + private void RescanSelectedPack() + { + var selected = SelectedRegisteredPack(); + if (selected == null) + return; + var isActive = _activePack != null && + string.Equals(_activePack.Id, selected.Id, StringComparison.OrdinalIgnoreCase); + try + { + _packRegistry.InvalidateIndex(selected.Id); + _status = $"INDEX CLEARED // {selected.DisplayName}"; + if (isActive) + { + ClearActiveLibraryState(); + StartCoroutine(InitializeLibrary(true)); + } + } + catch (Exception exception) + { + _status = $"PACK RESCAN FAILED // {exception.Message}"; + Debug.LogException(exception); + } + } + + private static bool PathsEqual(string left, string right) + { + if (string.IsNullOrWhiteSpace(left) || string.IsNullOrWhiteSpace(right)) + return false; + try + { + return string.Equals( + Path.GetFullPath(left), + Path.GetFullPath(right), + StringComparison.Ordinal); + } + catch + { + return false; + } + } + + private void ActivatePackSelection(int selection) + { + var packs = _packRegistry?.Packs; + var packCount = packs?.Count ?? 0; + if (selection < packCount) + { + var selected = packs[selection]; + if (!selected.IsAvailable) + { + _status = $"SOURCE MISSING // {selected.ContentRoot}"; + return; + } + + var alreadyLoaded = _libraryReady && _activePack != null && + string.Equals(_activePack.Id, selected.Id, StringComparison.OrdinalIgnoreCase); + _packRegistry.Select(selected.Id); + _activePack = selected; + if (alreadyLoaded) + { + _mode = AppMode.Library; + RefreshLibraryThumbnail(); + return; + } + + _courseSession.ClearRun(); + StartCoroutine(InitializeLibrary(true)); + return; + } + + if (selection == packCount) + AddPackFolder(); + else + StartZipPackImport(); + } + + private void AddPackFolder() + { + _flow.BeginLoading( + "ADD BMS PACK", + "WAITING FOR FOLDER SELECTION", + Time.realtimeSinceStartupAsDouble); + _mode = AppMode.PackImport; + StartCoroutine(PickPackFolder()); + } + + private IEnumerator PickPackFolder() + { + // Let retained click dispatch finish before opening the blocking native chooser. + yield return null; + var selectedPath = NativeBmsPackPicker.ChooseFolder(); + if (string.IsNullOrWhiteSpace(selectedPath)) + { + if (!string.IsNullOrWhiteSpace(NativeBmsPackPicker.LastError)) + { + SetError("PACK PICKER FAILED", NativeBmsPackPicker.LastError); + yield break; + } + _status = "FOLDER SELECTION CANCELLED"; + _mode = AppMode.PackSelect; + yield break; + } + + yield return RegisterPackFolder(selectedPath); + } + + private IEnumerator RegisterPackFolder(string selectedPath) + { + _packImportCancellation?.Cancel(); + _packImportCancellation?.Dispose(); + _packImportCancellation = new CancellationTokenSource(); + var token = _packImportCancellation.Token; + _flow.BeginLoading( + "VALIDATING BMS PACK", + Path.GetFileName(selectedPath.TrimEnd( + Path.DirectorySeparatorChar, + Path.AltDirectorySeparatorChar)), + Time.realtimeSinceStartupAsDouble); + _mode = AppMode.PackImport; + + var validationTask = Task.Run( + () => BmsLibraryLocator.ContainsChart(selectedPath, token), + token); + while (!validationTask.IsCompleted) + { + _flow.ReportLoading("SEARCHING FOR BMS / BME / BML CHARTS", 0, 0); + yield return null; + } + + if (validationTask.IsCanceled || token.IsCancellationRequested) + { + _status = "PACK REGISTRATION CANCELLED"; + _mode = AppMode.PackSelect; + yield break; + } + if (validationTask.IsFaulted) + { + SetError( + "PACK VALIDATION FAILED", + validationTask.Exception?.GetBaseException().Message ?? "Unknown folder validation error"); + yield break; + } + if (!validationTask.Result) + { + SetError( + "NO BMS CHARTS FOUND", + $"The selected folder does not contain BMS/BME/BML charts:\n{selectedPath}"); + yield break; + } + + try + { + var pack = _packRegistry.RegisterFolder(selectedPath, chartPresenceVerified: true); + _activePack = pack; + _packSelection = ResolveActivePackSelection(); + _status = $"REGISTERED // {pack.DisplayName}"; + StartCoroutine(InitializeLibrary(true)); + } + catch (Exception exception) + { + _status = $"PACK REGISTRATION FAILED // {exception.Message}"; + _mode = AppMode.PackSelect; + Debug.LogException(exception); + } + } + + private void StartZipPackImport() + { + _flow.BeginLoading( + "IMPORT BMS PACK ZIP", + "WAITING FOR ZIP SELECTION", + Time.realtimeSinceStartupAsDouble); + _mode = AppMode.PackImport; + StartCoroutine(PickPackZip()); + } + + private IEnumerator PickPackZip() + { + // See PickPackFolder: let retained input dispatch finish before AppleScript opens. + yield return null; + var selectedPath = NativeBmsPackPicker.ChooseZip(); + if (string.IsNullOrWhiteSpace(selectedPath)) + { + if (!string.IsNullOrWhiteSpace(NativeBmsPackPicker.LastError)) + { + SetError("PACK PICKER FAILED", NativeBmsPackPicker.LastError); + yield break; + } + _status = "ZIP SELECTION CANCELLED"; + _mode = AppMode.PackSelect; + yield break; + } + if (!string.Equals(Path.GetExtension(selectedPath), ".zip", StringComparison.OrdinalIgnoreCase)) + { + _status = "SELECT A .ZIP FILE"; + _mode = AppMode.PackSelect; + yield break; + } + yield return PreflightAndImportPackZip(selectedPath); + } + + private IEnumerator PreflightAndImportPackZip(string zipPath) + { + _flow.BeginLoading( + "INSPECTING BMS PACK ZIP", + Path.GetFileName(zipPath), + Time.realtimeSinceStartupAsDouble); + _mode = AppMode.PackImport; + var inspectionTask = Task.Run(() => BmsPackImportService.InspectAdjacentZip(zipPath)); + while (!inspectionTask.IsCompleted) + { + _flow.ReportLoading("CALCULATING EXTRACTED SIZE", 0, 0); + yield return null; + } + + if (inspectionTask.IsFaulted) + { + SetError( + "PACK INSPECTION FAILED", + inspectionTask.Exception?.GetBaseException().Message ?? "Unknown ZIP inspection error"); + yield break; + } + + var inspection = inspectionTask.Result; + if (Directory.Exists(inspection.DestinationPath) && + !BmsPackImportService.IsCompletedManagedExtraction(inspection.DestinationPath)) + { + SetError( + "EXTRACTION FOLDER ALREADY EXISTS", + $"ProjectQuint will not overwrite this folder:\n{inspection.DestinationPath}\n" + + "Rename the ZIP or the existing folder, then try again."); + yield break; + } + if (!inspection.HasEnoughSpace) + { + SetError( + "NOT ENOUGH STORAGE", + $"The pack needs {FormatStorageGigabytes(inspection.ExtractedBytes)}, but only " + + $"{FormatStorageGigabytes(inspection.AvailableBytes)} is available.\n" + + $"Destination: {inspection.DestinationPath}"); + yield break; + } + + if (!NativeBmsPackPicker.ConfirmZipImport(inspection)) + { + if (!string.IsNullOrWhiteSpace(NativeBmsPackPicker.LastError)) + { + SetError("PACK IMPORT CONFIRMATION FAILED", NativeBmsPackPicker.LastError); + yield break; + } + _status = "ZIP IMPORT CANCELLED"; + _mode = AppMode.PackSelect; + yield break; + } + + yield return ImportPackZip(zipPath); + } + + private IEnumerator ImportPackZip(string zipPath) + { + _packImportCancellation?.Cancel(); + _packImportCancellation?.Dispose(); + _packImportCancellation = new CancellationTokenSource(); + var token = _packImportCancellation.Token; + var progress = new BmsPackImportProgress(); + _flow.BeginLoading( + "IMPORTING BMS PACK", + Path.GetFileName(zipPath), + Time.realtimeSinceStartupAsDouble); + _mode = AppMode.PackImport; + + var importTask = Task.Run(() => BmsPackImportService.ExtractZipBesideArchive( + zipPath, + progress, + token), token); + while (!importTask.IsCompleted) + { + var snapshot = progress.Snapshot(); + var bytes = snapshot.TotalBytes > 0 + ? $"{snapshot.CompletedBytes / 1048576.0:0.0} / {snapshot.TotalBytes / 1048576.0:0.0} MiB" + : "READING ARCHIVE"; + _flow.ReportLoading( + $"{bytes} // {snapshot.CurrentEntry}", + snapshot.CompletedFiles, + snapshot.TotalFiles); + yield return null; + } + + if (importTask.IsCanceled || token.IsCancellationRequested) + { + _status = "ZIP IMPORT CANCELLED"; + _mode = AppMode.PackSelect; + yield break; + } + if (importTask.IsFaulted) + { + SetError( + "PACK IMPORT FAILED", + importTask.Exception?.GetBaseException().Message ?? "Unknown ZIP import error"); + yield break; + } + + try + { + var pack = _packRegistry.RegisterExtractedZip(zipPath, importTask.Result); + _activePack = pack; + _packSelection = ResolveActivePackSelection(); + _status = $"IMPORTED // {pack.DisplayName}"; + StartCoroutine(InitializeLibrary(true)); + } + catch (Exception exception) + { + SetError("PACK REGISTRATION FAILED", exception.Message); + Debug.LogException(exception); + } + } + + private static string FormatStorageGigabytes(long bytes) => + $"{Math.Max(0L, bytes) / 1_000_000_000.0:0.0} GB"; + + private void UpdatePackImportInput() + { + if (_packRemovalInProgress) + return; + if (Keyboard.current?.escapeKey.wasPressedThisFrame != true) + return; + _packImportCancellation?.Cancel(); + _status = "CANCELLING ZIP IMPORT..."; + } + + private void OpenDifficultyTableSetup() + { + if (_difficultyTableRegistry == null) + return; + var packs = _packRegistry?.Packs; + var selectedPack = packs != null && + _packSelection >= 0 && + _packSelection < packs.Count + ? packs[_packSelection] + : _activePack ?? _packRegistry?.ActivePack; + if (selectedPack == null) + { + _status = "SELECT A REGISTERED PACK BEFORE ASSIGNING A TABLE"; + return; + } + + _tableSetupPackId = selectedPack.Id; + _tableSetupSelection = ResolveTableSelection(selectedPack.DifficultyTableId); + var selected = ResolveSelectedDifficultyTable(); + _tableUrlDraft = selected?.HeaderUrl ?? string.Empty; + _tableSetupStatus = + $"{selectedPack.DisplayName} // UP / DOWN SELECT // ENTER ASSIGN // 1–6 PRESET"; + _tableUrlFocusRequested = false; + _tableUrlReleaseRequested = false; + _tableUrlFocused = false; + _mode = AppMode.DifficultyTableSetup; + } + + private BmsPackDefinition ResolveDifficultyTableSetupPack() => + _packRegistry?.Find(_tableSetupPackId) ?? + _activePack ?? + _packRegistry?.ActivePack; + + private BmsDifficultyTableDefinition ResolvePackDifficultyTable(BmsPackDefinition pack) => + pack == null || _difficultyTableRegistry == null + ? null + : _difficultyTableRegistry.Find(pack.DifficultyTableId); + + private void ApplyRecommendedDifficultyTable() + { + if (_difficultyTableRegistry == null || _packRegistry == null) + return; + + var targetPack = SelectedRegisteredPack(); + if (targetPack == null) + { + _status = "SELECT A REGISTERED PACK BEFORE ASSIGNING A TABLE"; + return; + } + + var recommendation = + PackMusicScreenPresenter.RecommendedDifficultyTableForPack(targetPack); + if (!recommendation.HasValue) + { + _status = "NO SINGLE TABLE RECOMMENDATION // PRESS 5 TO CHOOSE"; + return; + } + + var preset = recommendation.Value; + var table = _difficultyTableRegistry.Find(preset.Id) ?? + _difficultyTableRegistry.Register(preset.HeaderUrl, activate: false); + _packRegistry.AssignDifficultyTable(targetPack.Id, table.Id); + _tableSetupPackId = targetPack.Id; + _tableSetupSelection = ResolveTableSelection(table.Id); + OnPackDifficultyTableChanged( + targetPack, + $"{targetPack.DisplayName} // {table.DisplayName} ASSIGNED"); + } + + private int ResolveTableSelection(string tableId) + { + var tables = _difficultyTableRegistry?.Tables; + if (tables == null || tables.Count == 0) + return 0; + for (var index = 0; index < tables.Count; index++) + if (string.Equals( + tables[index].Id, + tableId, + StringComparison.OrdinalIgnoreCase)) + return index; + return 0; + } + + private BmsDifficultyTableDefinition ResolveSelectedDifficultyTable() + { + var tables = _difficultyTableRegistry?.Tables; + if (tables == null || tables.Count == 0) + return null; + _tableSetupSelection = Mathf.Clamp(_tableSetupSelection, 0, tables.Count - 1); + return tables[_tableSetupSelection]; + } + + private void UpdateDifficultyTableSetupInput() + { + var keyboard = Keyboard.current; + if (keyboard == null) + return; + + if (_tableUrlFocused) + { + if (keyboard.enterKey.wasPressedThisFrame || keyboard.numpadEnterKey.wasPressedThisFrame) + { + _tableUrlReleaseRequested = true; + _tableUrlFocused = false; + ApplyDifficultyTableUrl(); + } + else if (keyboard.escapeKey.wasPressedThisFrame) + { + _tableUrlReleaseRequested = true; + _tableUrlFocused = false; + _tableSetupStatus = "CUSTOM URL EDITING ENDED"; + } + return; + } + + var tableCount = _difficultyTableRegistry?.Tables.Count ?? 0; + if (keyboard.downArrowKey.wasPressedThisFrame && tableCount > 0) + { + _tableSetupSelection = MoveDifficultyTableSelection( + _tableSetupSelection, + tableCount, + 1); + var selected = ResolveSelectedDifficultyTable(); + _tableUrlDraft = selected?.HeaderUrl ?? string.Empty; + _tableSetupStatus = $"SELECTED // {selected?.DisplayName ?? "TABLE"} // ENTER TO ASSIGN"; + return; + } + if (keyboard.upArrowKey.wasPressedThisFrame && tableCount > 0) + { + _tableSetupSelection = MoveDifficultyTableSelection( + _tableSetupSelection, + tableCount, + -1); + var selected = ResolveSelectedDifficultyTable(); + _tableUrlDraft = selected?.HeaderUrl ?? string.Empty; + _tableSetupStatus = $"SELECTED // {selected?.DisplayName ?? "TABLE"} // ENTER TO ASSIGN"; + return; + } + + var presetIndex = keyboard.digit1Key.wasPressedThisFrame ? 0 : + keyboard.digit2Key.wasPressedThisFrame ? 1 : + keyboard.digit3Key.wasPressedThisFrame ? 2 : + keyboard.digit4Key.wasPressedThisFrame ? 3 : + keyboard.digit5Key.wasPressedThisFrame ? 4 : + keyboard.digit6Key.wasPressedThisFrame ? 5 : -1; + if (presetIndex >= 0 && presetIndex < BmsDifficultyTableIndex.Presets.Length) + { + _tableUrlDraft = BmsDifficultyTableIndex.Presets[presetIndex].HeaderUrl; + _tableSetupStatus = $"SELECTED // {BmsDifficultyTableIndex.Presets[presetIndex].DisplayName} // ENTER TO APPLY"; + return; + } + if (keyboard.cKey.wasPressedThisFrame) + { + _tableUrlFocusRequested = true; + _tableUrlFocused = true; + _tableSetupStatus = "PASTE A HEADER JSON OR BMSTABLE PAGE URL, THEN PRESS ENTER"; + return; + } + if (keyboard.deleteKey.wasPressedThisFrame || keyboard.backspaceKey.wasPressedThisFrame) + { + RemoveSelectedDifficultyTable(); + return; + } + if (keyboard.enterKey.wasPressedThisFrame || keyboard.numpadEnterKey.wasPressedThisFrame) + { + ApplyDifficultyTableUrl(); + return; + } + if (keyboard.escapeKey.wasPressedThisFrame) + _mode = AppMode.PackSelect; + } + + internal static int MoveDifficultyTableSelection(int current, int count, int delta) + { + if (count <= 0) + return 0; + var normalized = (current + delta) % count; + return normalized < 0 ? normalized + count : normalized; + } + + private void HandleDifficultyTableUi(DifficultyTableUiResult result) + { + _tableUrlDraft = result.Url ?? string.Empty; + _tableUrlFocused = result.UrlFocused; + if (result.SelectedIndex >= 0 && result.SelectedIndex < (_difficultyTableRegistry?.Tables.Count ?? 0)) + { + _tableSetupSelection = result.SelectedIndex; + var selected = ResolveSelectedDifficultyTable(); + _tableUrlDraft = selected?.HeaderUrl ?? string.Empty; + _tableSetupStatus = $"SELECTED // {selected?.DisplayName ?? "TABLE"} // ASSIGN TO PACK"; + } + if (result.PresetIndex >= 0 && result.PresetIndex < BmsDifficultyTableIndex.Presets.Length) + { + _tableUrlDraft = BmsDifficultyTableIndex.Presets[result.PresetIndex].HeaderUrl; + _tableSetupStatus = $"SELECTED // {BmsDifficultyTableIndex.Presets[result.PresetIndex].DisplayName} // APPLY TO SAVE"; + } + if (result.Clear) + { + ClearDifficultyTableAssignment(); + } + else if (result.Save) + { + ApplyDifficultyTableUrl(); + } + else if (result.Back) + { + _mode = AppMode.PackSelect; + } + } + + private void ApplyDifficultyTableUrl() + { + if (_difficultyTableRegistry == null || _packRegistry == null) + return; + var targetPack = ResolveDifficultyTableSetupPack(); + if (targetPack == null) + { + _tableSetupStatus = "PACK NOT FOUND // RETURN AND SELECT A PACK"; + return; + } + var url = (_tableUrlDraft ?? string.Empty).Trim(); + if (string.IsNullOrWhiteSpace(url)) + { + ClearDifficultyTableAssignment(); + return; + } + if ( + (!Uri.TryCreate(url, UriKind.Absolute, out var uri) || + uri.Scheme != Uri.UriSchemeHttps)) + { + _tableSetupStatus = "INVALID URL // HTTPS IS REQUIRED"; + return; + } + + var table = _difficultyTableRegistry.Register(url, activate: false); + _packRegistry.AssignDifficultyTable(targetPack.Id, table.Id); + _tableSetupSelection = ResolveTableSelection(table.Id); + OnPackDifficultyTableChanged( + targetPack, + $"{targetPack.DisplayName} // {table.DisplayName} ASSIGNED"); + } + + private void ClearDifficultyTableAssignment() + { + var targetPack = ResolveDifficultyTableSetupPack(); + if (targetPack == null || _packRegistry == null) + return; + _packRegistry.AssignDifficultyTable(targetPack.Id, string.Empty); + OnPackDifficultyTableChanged( + targetPack, + $"{targetPack.DisplayName} // TABLE ASSIGNMENT CLEARED"); + } + + private void RemoveSelectedDifficultyTable() + { + var selected = ResolveSelectedDifficultyTable(); + if (selected == null) + return; + var name = selected.DisplayName; + var activePackAffected = + _activePack != null && + string.Equals( + _activePack.DifficultyTableId, + selected.Id, + StringComparison.OrdinalIgnoreCase); + var clearedAssignments = + _packRegistry?.ClearDifficultyTableAssignments(selected.Id) ?? 0; + _difficultyTableRegistry.Remove(selected.Id); + _tableSetupSelection = Mathf.Clamp( + _tableSetupSelection, + 0, + Math.Max(0, (_difficultyTableRegistry?.Tables.Count ?? 1) - 1)); + _tableUrlDraft = ResolveSelectedDifficultyTable()?.HeaderUrl ?? string.Empty; + if (activePackAffected) + InvalidateActiveDifficultyTableState(); + _status = $"TABLE REMOVED // {name}"; + _tableSetupStatus = + $"REMOVED // {name} // {clearedAssignments:N0} PACK ASSIGNMENTS CLEARED"; + } + + private void OnPackDifficultyTableChanged( + BmsPackDefinition targetPack, + string status, + bool returnToPackSelect = true) + { + if (_activePack != null && targetPack != null && + string.Equals( + _activePack.Id, + targetPack.Id, + StringComparison.OrdinalIgnoreCase)) + InvalidateActiveDifficultyTableState(); + _status = status; + if (returnToPackSelect) + _mode = AppMode.PackSelect; + } + + private void InvalidateActiveDifficultyTableState() + { + _activeDifficultyTable = ResolvePackDifficultyTable(_activePack); + _libraryReady = false; + _activeCatalog = null; + _courseIndex = null; + _catalogMatchedCharts = 0; + } + + private void HandleLibraryUi(LibraryUiResult result) + { + HandleLibraryControllerResult( + _libraryController.HandleUi(result, _mode == AppMode.Library)); + } + + private void HandleLibraryControllerResult(LibraryControllerResult result) + { + if (result.RefreshThumbnail) + RefreshLibraryThumbnail(); + + switch (result.Command) + { + case LibraryCommand.BackToHome: + _mode = AppMode.PackSelect; + break; + case LibraryCommand.ReloadLibrary: + StartCoroutine(InitializeLibrary(true)); + break; + case LibraryCommand.OpenCourses: + OpenCourseSelect(); + break; + case LibraryCommand.OpenRanking: + OpenProfileModal(1); + break; + case LibraryCommand.OpenOptions: + OpenOptions(AppMode.Library); + break; + case LibraryCommand.OpenProfile: + OpenProfileModal(0); + break; + case LibraryCommand.OpenQuickOptions: + _flow.BeginQuickOptions(); + break; + case LibraryCommand.PlayRandomSong: + StartCoroutine(LoadRandomSong()); + break; + case LibraryCommand.PlaySelectedSong: + StartCoroutine(LoadSelectedSong()); + break; + case LibraryCommand.ToggleFavorite: + { + var song = _libraryController.State.SelectedSong; + if (song == null) + break; + var isFavorite = _favoriteRepository.Toggle(song); + _libraryController.State.RefreshFavoriteStatus(); + _status = isFavorite + ? $"FAVORITE ADDED // {song.DisplayTitle}" + : $"FAVORITE REMOVED // {song.DisplayTitle}"; + RefreshLibraryThumbnail(); + break; + } + } + + if (result.RefreshThumbnail || result.Command == LibraryCommand.ToggleFavorite) + StageActivePackLibraryViewState(); + if (result.Command != LibraryCommand.None) + FlushPendingLibraryViewState(force: true); + } + + private void StageActivePackLibraryViewState() + { + var state = _libraryController?.State; + if (_packRegistry == null || _activePack == null || state == null || !_libraryReady) + return; + var selectedChartMd5 = state.SelectedSong?.Metadata?.Md5 ?? string.Empty; + if (!_packRegistry.StageLibraryViewState( + _activePack.Id, + state.SelectedLevelFilters, + state.SortMode, + state.FavoritesOnly, + selectedChartMd5)) + return; + _libraryViewStateDirty = true; + _libraryViewStateSaveDeadline = + Time.realtimeSinceStartupAsDouble + LibraryViewStateSaveDelaySeconds; + } + + private void FlushPendingLibraryViewState(bool force) + { + if (!_libraryViewStateDirty || _packRegistry == null || + !force && Time.realtimeSinceStartupAsDouble < _libraryViewStateSaveDeadline) + return; + try + { + _packRegistry.FlushStagedChanges(); + _libraryViewStateDirty = false; + } + catch (Exception exception) + { + _libraryViewStateSaveDeadline = + Time.realtimeSinceStartupAsDouble + LibraryViewStateSaveDelaySeconds; + _status = $"LIBRARY VIEW STATE NOT SAVED // {exception.Message}"; + Debug.LogWarning( + $"[ProjectQuint Library] Pack view state could not be saved: {exception}"); + } + } + + private AeryCourseDefinition SelectedCourse + { + get + { + var courses = _courseIndex?.Courses; + return courses == null || courses.Count == 0 + ? null + : courses[Mathf.Clamp(_courseSelectedIndex, 0, courses.Count - 1)]; + } + } + + private LaneModifier EffectiveCourseModifier + { + get + { + var course = _activeCourse ?? SelectedCourse; + return CourseLaneModifierPolicy.Normalize(_laneModifier, course?.AllowsMirror == true); + } + } + + private void OpenCourseSelect() + { + _libraryController.ClearSearchFocus(); + var courseCount = _courseIndex?.Courses?.Count ?? 0; + _courseSession.Select(_courseSelectedIndex, courseCount); + _courseStatus = ResolveCourseEntryStatus(courseCount, _courseStatus); + _mode = AppMode.CourseSelect; + } + + internal static string ResolveCourseEntryStatus(int courseCount, string currentStatus) + { + if (courseCount <= 0) + return "ACTIVE DIFFICULTY TABLE HAS NO COURSES // ESC RETURN"; + return string.IsNullOrWhiteSpace(currentStatus) + ? "UP / DOWN SELECT // ENTER START // ESC RETURN" + : currentStatus; + } + + private void HandleCourseSelectUi(CourseSelectUiResult result) + { + if (result.SelectedIndex >= 0) + _courseSession.Select(result.SelectedIndex, _courseIndex?.Courses.Count ?? 0); + if (result.ToggleMirror) + ToggleCourseMirror(); + if (Math.Abs(result.SpeedDelta) > 0.001f) + ChangeNoteSpeed(result.SpeedDelta); + if (result.Play) + StartSelectedCourse(); + if (result.Back) + _mode = AppMode.Library; + } + + private void ToggleCourseMirror() + { + var course = _activeCourse ?? SelectedCourse; + if (course?.AllowsMirror != true) + { + _laneModifier = LaneModifier.Off; + _courseStatus = $"{course?.DisplayName ?? "COURSE"}: MIRROR IS NOT ALLOWED"; + PersistSettings(); + return; + } + _laneModifier = EffectiveCourseModifier == LaneModifier.Mirror + ? LaneModifier.Off + : LaneModifier.Mirror; + PersistSettings(); + } + + private void ChangeNoteSpeed(float delta, bool persist = true) + { + _noteSpeed = Mathf.Round(Mathf.Clamp( + _noteSpeed + delta, + GameSettings.MinimumNoteSpeed, + GameSettings.MaximumNoteSpeed) * 10f) / 10f; + if (persist) + PersistSettings(); + } + + private void CycleGearPosition(int delta) + { + const int count = 3; + var value = ((int)_gearPosition + delta) % count; + if (value < 0) value += count; + _gearPosition = (GameplayGearPosition)value; + PersistSettings(); + } + + private void CycleLaneModifier(int delta) + { + const int count = 3; + var value = ((int)_laneModifier + delta) % count; + if (value < 0) value += count; + _laneModifier = (LaneModifier)value; + PersistSettings(); + } + + private void CycleGaugeType(int delta) + { + _gaugeType = CycleSelectableGaugeType(_gaugeType, delta); + PersistSettings(); + } + + internal static GaugeMode CycleSelectableGaugeType(GaugeMode current, int delta) + { + var currentIndex = current switch + { + GaugeMode.Hard => 1, + GaugeMode.ExHard => 2, + _ => 0, + }; + var nextIndex = (currentIndex + delta) % 3; + if (nextIndex < 0) nextIndex += 3; + return nextIndex switch + { + 1 => GaugeMode.Hard, + 2 => GaugeMode.ExHard, + _ => GaugeMode.Normal, + }; + } + + private void CycleGameplaySkin(int delta) + { + const int count = 2; + var value = ((int)_gameplaySkin + delta) % count; + if (value < 0) value += count; + _gameplaySkin = (GameplaySkin)value; + PersistSettings(); + } + + private void CycleNoteSkin(int delta) + { + const int count = 2; + var value = ((int)_noteSkin + delta) % count; + if (value < 0) value += count; + _noteSkin = (GameplayNoteSkin)value; + PersistSettings(); + } + + private void CycleLongNoteStyle(int delta) + { + const int count = 2; + var value = ((int)_longNoteStyle + delta) % count; + if (value < 0) value += count; + _longNoteStyle = (LongNoteVisualStyle)value; + PersistSettings(); + } + + private void CycleBgaDisplayMode(int delta) + { + const int count = 2; + var value = ((int)_bgaDisplayMode + delta) % count; + if (value < 0) value += count; + _bgaDisplayMode = (BgaDisplayMode)value; + PersistSettings(); + } + + private void ToggleLaneSeparators() + { + _laneSeparatorsEnabled = !_laneSeparatorsEnabled; + PersistSettings(); + } + + private void ToggleTimingMeter() + { + _timingMeterEnabled = !_timingMeterEnabled; + PersistSettings(); + } + + private void ChangeJudgementOffset(int deltaMilliseconds, bool persist = true) + { + _judgementOffsetMilliseconds = Mathf.Clamp( + _judgementOffsetMilliseconds + deltaMilliseconds, + GameSettings.MinimumJudgementOffsetMilliseconds, + GameSettings.MaximumJudgementOffsetMilliseconds); + if (persist) + PersistSettings(); + } + + private void ChangeAudioOutputLatency(int deltaMilliseconds, bool persist = true) + { + _audioOutputLatencyMilliseconds = Mathf.Clamp( + _audioOutputLatencyMilliseconds + deltaMilliseconds, + GameSettings.MinimumAudioOutputLatencyMilliseconds, + GameSettings.MaximumAudioOutputLatencyMilliseconds); + if (persist) + { + SaveActiveDeviceAudioCalibration( + _audioOutputLatencyMilliseconds, + acceptedSampleCount: 0, + medianAbsoluteDeviationMilliseconds: 0f); + PersistSettings(); + } + } + + private void OpenAudioSyncCalibration() + { + if (_mode != AppMode.Options || _audioSyncCalibration != null) + return; + _audioSyncCalibrationError = string.Empty; + if (_audioBackendFactory is not FmodCoreSongAudioBackendFactory fmodFactory) + { + _audioSyncCalibrationError = + "AUDIO SYNC REQUIRES THE FMOD CORE AUDIO BACKEND."; + return; + } + + try + { + var deviceName = string.IsNullOrWhiteSpace(fmodFactory.ActiveOutputDeviceName) + ? fmodFactory.OutputConfiguration.DeviceName + : fmodFactory.ActiveOutputDeviceName; + _audioSyncCalibration = new AudioSyncCalibrationSession( + fmodFactory.CreateAudioSyncMetronome(_masterVolumePercent * 0.01f), + fmodFactory.Clock, + deviceName); + } + catch (Exception exception) + { + _audioSyncCalibrationError = exception.Message; + Debug.LogError( + $"[ProjectQuint Audio Sync] Could not open calibration: {exception.Message}"); + } + } + + private void StartAudioSyncCalibration() + { + if (_audioSyncCalibration == null) + { + OpenAudioSyncCalibration(); + if (_audioSyncCalibration == null) + return; + } + + DisposeAudioSyncTapAction(); + var keyboard = Keyboard.current; + if (keyboard == null) + { + _audioSyncCalibrationError = "A KEYBOARD IS REQUIRED FOR AUDIO SYNC."; + return; + } + + _audioSyncTapAction = new InputAction( + "Audio Sync Tap", + InputActionType.Button); + var boundNames = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var key in _settings.KeysFor(_optionsInputKeyCount)) + AddAudioSyncBinding(keyboard, key, boundNames); + if (_optionsInputKeyCount == 5) + AddAudioSyncBinding(keyboard, _centerAuxKey, boundNames); + AddAudioSyncBinding(keyboard, _settings.ScratchKey, boundNames); + _audioSyncTapAction.performed += OnAudioSyncTap; + _audioSyncTapAction.Enable(); + _audioSyncCalibration.Begin(); + if (_audioSyncCalibration.Phase != AudioSyncCalibrationPhase.Running) + DisposeAudioSyncTapAction(); + } + + private void AddAudioSyncBinding( + Keyboard keyboard, + Key key, + ISet boundNames) + { + if (!GameSettings.IsAssignableLaneKey(key)) + return; + var controlName = keyboard[key].name; + if (string.IsNullOrWhiteSpace(controlName) || !boundNames.Add(controlName)) + return; + _audioSyncTapAction.AddBinding($"/{controlName}"); + } + + private void OnAudioSyncTap(InputAction.CallbackContext context) + { + _audioSyncCalibration?.RecordTap(context.time, InputState.currentTime); + } + + private void UpdateAudioSyncCalibration() + { + if (_audioSyncCalibration == null) + return; + if (_audioBackendFactory is FmodCoreSongAudioBackendFactory fmodFactory) + fmodFactory.UpdateAudioSystem(); + _audioSyncCalibration.Update(); + if (_audioSyncCalibration.Phase != AudioSyncCalibrationPhase.Running) + DisposeAudioSyncTapAction(); + } + + private void ApplyAudioSyncCalibration() + { + if (_audioSyncCalibration?.Phase != AudioSyncCalibrationPhase.Result || + !_audioSyncCalibration.Result.CanApply) + return; + var result = _audioSyncCalibration.Result; + _audioOutputLatencyMilliseconds = Mathf.Clamp( + result.RecommendedLatencyMilliseconds, + GameSettings.MinimumAudioOutputLatencyMilliseconds, + GameSettings.MaximumAudioOutputLatencyMilliseconds); + SaveActiveDeviceAudioCalibration( + _audioOutputLatencyMilliseconds, + result.AcceptedSampleCount, + (float)result.MedianAbsoluteDeviationMilliseconds, + (float)result.MedianMilliseconds); + PersistSettings(); + Debug.Log( + $"[ProjectQuint Audio Sync] Applied {_audioOutputLatencyMilliseconds} ms to " + + $"'{ActiveAudioOutputDeviceName}' ({result.AcceptedSampleCount} accepted, " + + $"MAD {result.MedianAbsoluteDeviationMilliseconds:0.0} ms)."); + CloseAudioSyncCalibration(); + } + + private void CloseAudioSyncCalibration() + { + DisposeAudioSyncTapAction(); + _audioSyncCalibration?.Dispose(); + _audioSyncCalibration = null; + _audioSyncCalibrationError = string.Empty; + } + + private void DisposeAudioSyncTapAction() + { + if (_audioSyncTapAction == null) + return; + _audioSyncTapAction.performed -= OnAudioSyncTap; + _audioSyncTapAction.Disable(); + _audioSyncTapAction.Dispose(); + _audioSyncTapAction = null; + } + + private AudioSyncCalibrationViewState BuildAudioSyncCalibrationView() + { + if (_audioSyncCalibration == null) + { + return string.IsNullOrWhiteSpace(_audioSyncCalibrationError) + ? default + : new AudioSyncCalibrationViewState( + AudioSyncCalibrationViewPhase.Error, + ActiveAudioOutputDeviceName, + _audioOutputLatencyMilliseconds, + 0, + 0, + 0, + string.Empty, + false, + false, + _audioSyncCalibrationError); + } + + var phase = _audioSyncCalibration.Phase switch + { + AudioSyncCalibrationPhase.Intro => AudioSyncCalibrationViewPhase.Intro, + AudioSyncCalibrationPhase.Running => AudioSyncCalibrationViewPhase.Measuring, + AudioSyncCalibrationPhase.Result => AudioSyncCalibrationViewPhase.Result, + _ => AudioSyncCalibrationViewPhase.Error, + }; + var result = _audioSyncCalibration.Result; + return new AudioSyncCalibrationViewState( + phase, + _audioSyncCalibration.DeviceName, + _audioOutputLatencyMilliseconds, + _audioSyncCalibration.PracticeProgress + + _audioSyncCalibration.MeasurementProgress, + ProjectQuint.Core.Timing.AudioSyncCalibrationEstimator.PracticeClickCount + + ProjectQuint.Core.Timing.AudioSyncCalibrationEstimator.MeasurementClickCount, + result.RecommendedLatencyMilliseconds, + result.Stability == ProjectQuint.Core.Timing.AudioSyncStability.OutOfRange + ? "OUT OF RANGE" + : result.Stability.ToString().ToUpperInvariant(), + result.CanApply, + result.RecommendedLatencyMilliseconds >= 60, + _audioSyncCalibration.Error); + } + + private string ActiveAudioOutputDeviceId => + _audioBackendFactory is FmodCoreSongAudioBackendFactory fmodFactory + ? fmodFactory.ActiveOutputDeviceId + : _audioBackendFactory?.OutputConfiguration.DevicePersistentId ?? string.Empty; + + private string ActiveAudioOutputDeviceName => + _audioBackendFactory is FmodCoreSongAudioBackendFactory fmodFactory && + !string.IsNullOrWhiteSpace(fmodFactory.ActiveOutputDeviceName) + ? fmodFactory.ActiveOutputDeviceName + : _audioBackendFactory?.OutputConfiguration.DeviceName ?? "UNKNOWN DEVICE"; + + private void RestoreActiveDeviceAudioCalibration() + { + var backendId = _audioBackendFactory?.BackendId ?? string.Empty; + var deviceId = ActiveAudioOutputDeviceId; + if (string.IsNullOrWhiteSpace(backendId) || string.IsNullOrWhiteSpace(deviceId)) + return; + var profiles = _settings.AudioOutputCalibrationProfiles; + for (var index = 0; index < profiles.Length; index++) + { + if (profiles[index]?.Matches(backendId, deviceId) != true) + continue; + if (profiles[index].AlgorithmVersion != + AudioOutputCalibrationProfile.CurrentAlgorithmVersion) + { + _audioOutputLatencyMilliseconds = + GameSettingsDefaults.AudioOutputLatencyMilliseconds; + _settings.AudioOutputCalibrationMigrationPending = false; + return; + } + _audioOutputLatencyMilliseconds = profiles[index].OutputLatencyMilliseconds; + _settings.AudioOutputCalibrationMigrationPending = false; + return; + } + if (_settings.AudioOutputCalibrationMigrationPending) + { + SaveActiveDeviceAudioCalibration( + _audioOutputLatencyMilliseconds, + acceptedSampleCount: 0, + medianAbsoluteDeviationMilliseconds: 0f); + _settings.AudioOutputCalibrationMigrationPending = false; + return; + } + _audioOutputLatencyMilliseconds = + GameSettingsDefaults.AudioOutputLatencyMilliseconds; + } + + private void SaveActiveDeviceAudioCalibration( + int latencyMilliseconds, + int acceptedSampleCount, + float medianAbsoluteDeviationMilliseconds, + float medianMilliseconds = 0f) + { + var backendId = _audioBackendFactory?.BackendId ?? string.Empty; + var deviceId = ActiveAudioOutputDeviceId; + if (string.IsNullOrWhiteSpace(backendId) || string.IsNullOrWhiteSpace(deviceId)) + return; + + var profiles = new List( + _settings.AudioOutputCalibrationProfiles ?? Array.Empty()); + AudioOutputCalibrationProfile profile = null; + for (var index = 0; index < profiles.Count; index++) + { + if (profiles[index]?.Matches(backendId, deviceId) != true) + continue; + profile = profiles[index]; + break; + } + if (profile == null) + { + profile = new AudioOutputCalibrationProfile(); + profiles.Add(profile); + } + profile.BackendId = backendId; + profile.AlgorithmVersion = AudioOutputCalibrationProfile.CurrentAlgorithmVersion; + profile.DevicePersistentId = deviceId; + profile.DeviceName = ActiveAudioOutputDeviceName; + profile.OutputLatencyMilliseconds = latencyMilliseconds; + if (acceptedSampleCount > 0) + { + profile.AcceptedSampleCount = acceptedSampleCount; + profile.MedianMilliseconds = medianMilliseconds; + profile.MedianAbsoluteDeviationMilliseconds = + medianAbsoluteDeviationMilliseconds; + profile.CalibratedUtcTicks = DateTime.UtcNow.Ticks; + } + _settings.AudioOutputCalibrationProfiles = profiles.ToArray(); + } + + private void CycleAudioDspBufferProfile(int delta) + { + if (_mode != AppMode.Options || IsAudioSystemResetting) + return; + ApplyAudioDspBufferProfile( + GameSettings.CycleAudioDspBufferProfile(_audioDspBufferProfile, delta), + startup: false); + } + + private void ApplyAudioDspBufferProfile(AudioDspBufferProfile profile, bool startup) + { + if (_audioDspBufferController == null) + return; + + if (!startup && _audioBackendKind == SongAudioBackendKind.FmodCore) + { + ApplyFmodDspBufferProfile(profile); + return; + } + + var applied = _audioDspBufferController.TryApply( + profile, + PrepareForAudioReset, + out var snapshot); + if (!applied) + { + _audioDspStatus = $"RESET FAILED // {_audioDspBufferController.LastError}"; + Debug.LogError($"[ProjectQuint Audio] {_audioDspStatus}"); + return; + } + + _audioDspBufferProfile = profile; + _audioDspStatus = snapshot.IsExactBufferMatch + ? $"{GameSettings.AudioDspBufferProfileLabel(profile)} APPLIED" + : $"DEVICE USED {snapshot.BufferLength} SAMPLES FOR {snapshot.RequestedBufferSamples} REQUEST"; + if (!startup) + PersistSettings(); + Debug.Log( + $"[ProjectQuint Audio] DSP profile {GameSettings.AudioDspBufferProfileLabel(profile)}; " + + $"{snapshot.AppliedLabel}; exact match {snapshot.IsExactBufferMatch}."); + } + + private bool PrepareForAudioReset() + { + if (_mode != AppMode.Home && _mode != AppMode.Options) + return false; + CloseAudioSyncCalibration(); + ClearLoadedSongState(); + return true; + } + + private void ApplyFmodDspBufferProfile(AudioDspBufferProfile profile) + { + RestartFmodCore( + profile, + _settings.AudioOutputDeviceId, + _settings.AudioOutputDeviceName, + deviceSelectionChange: false); + } + + private void CycleAudioOutputDevice(int delta) + { + if (_mode != AppMode.Options || IsAudioSystemResetting || + _audioBackendFactory is not FmodCoreSongAudioBackendFactory fmodFactory) + return; + + try + { + fmodFactory.RefreshOutputDevices(); + } + catch (Exception exception) + { + _audioDspStatus = $"DEVICE LIST FAILED // {exception.Message}"; + Debug.LogError($"[ProjectQuint Audio] {_audioDspStatus}"); + return; + } + + var devices = fmodFactory.OutputDevices; + if (devices == null || devices.Count == 0) + { + _audioDspStatus = "NO COREAUDIO OUTPUT DEVICES FOUND"; + return; + } + + var currentIndex = 0; + if (!string.IsNullOrWhiteSpace(_settings.AudioOutputDeviceId)) + { + currentIndex = -1; + for (var index = 0; index < devices.Count; index++) + { + if (!string.Equals( + devices[index].PersistentId, + _settings.AudioOutputDeviceId, + StringComparison.OrdinalIgnoreCase)) + continue; + currentIndex = index; + break; + } + if (currentIndex < 0) + currentIndex = 0; + } + + var direction = delta < 0 ? -1 : 1; + var targetIndex = ((currentIndex + direction) % devices.Count + devices.Count) % devices.Count; + var target = devices[targetIndex]; + RestartFmodCore( + _audioDspBufferProfile, + target.PersistentId, + target.IsSystemDefault ? string.Empty : target.Name, + deviceSelectionChange: true); + } + + private void RefreshAudioOutputDevices() + { + if (_audioBackendFactory is not FmodCoreSongAudioBackendFactory fmodFactory || + IsAudioSystemResetting) + return; + try + { + fmodFactory.RefreshOutputDevices(); + } + catch (Exception exception) + { + _audioDspStatus = $"DEVICE LIST FAILED // {exception.Message}"; + Debug.LogError($"[ProjectQuint Audio] {_audioDspStatus}"); + } + } + + private void RestartFmodCore( + AudioDspBufferProfile profile, + string requestedOutputDeviceId, + string requestedOutputDeviceName, + bool deviceSelectionChange) + { + profile = Enum.IsDefined(typeof(AudioDspBufferProfile), profile) + ? profile + : GameSettingsDefaults.AudioDspBuffer; + if (!PrepareForAudioReset()) + { + _audioDspStatus = "RESET BLOCKED // RETURN TO HOME OR OPTIONS"; + return; + } + + _audioBackendResetting = true; + FmodCoreSongAudioBackendFactory candidate = null; + try + { + candidate = new FmodCoreSongAudioBackendFactory( + (uint)GameSettings.RequestedDspBufferSamples(profile), + requestedOutputDeviceId); + var output = candidate.OutputConfiguration; + var previousFactory = _audioBackendFactory; + + _audioBackendFactory = candidate; + _audioClock = candidate.Clock; + _songLoadCoordinator = new SongLoadCoordinator(gameObject, candidate); + _audioBackendInitializationError = string.Empty; + _audioDspBufferProfile = profile; + if (candidate.DeviceSelectionFallback) + { + _settings.AudioOutputDeviceId = string.Empty; + _settings.AudioOutputDeviceName = string.Empty; + _audioDspStatus = "SAVED DEVICE UNAVAILABLE // SYSTEM DEFAULT APPLIED"; + } + else + { + _settings.AudioOutputDeviceId = candidate.SelectedOutputDeviceId; + _settings.AudioOutputDeviceName = string.IsNullOrWhiteSpace(candidate.SelectedOutputDeviceId) + ? string.Empty + : requestedOutputDeviceName ?? string.Empty; + _audioDspStatus = deviceSelectionChange + ? $"OUTPUT DEVICE APPLIED // {output.DeviceName}" + : FormatAudioDspStatus(profile, output); + } + candidate = null; + RestoreActiveDeviceAudioCalibration(); + + if (previousFactory is IDisposable disposablePrevious) + { + try + { + disposablePrevious.Dispose(); + } + catch (Exception exception) + { + Debug.LogException(exception); + } + } + + PersistSettings(); + Debug.Log( + $"[ProjectQuint Audio] FMOD Core restarted; {output.AppliedLabel}; " + + $"saved device '{_settings.AudioOutputDeviceName}'."); + } + catch (Exception exception) + { + candidate?.Dispose(); + _audioDspStatus = $"FMOD RESET FAILED // {exception.Message}"; + Debug.LogError($"[ProjectQuint Audio] {_audioDspStatus}"); + } + finally + { + _audioBackendResetting = false; + } + } + + private static string FormatAudioDspStatus( + AudioDspBufferProfile profile, + AudioOutputConfigurationSnapshot output) + { + var requestedSamples = GameSettings.RequestedDspBufferSamples(profile); + return output.BufferLength == requestedSamples + ? $"{GameSettings.AudioDspBufferProfileLabel(profile)} APPLIED TO {output.BackendId}" + : $"{output.BackendId} USED {output.BufferLength} SAMPLES FOR {requestedSamples} REQUEST"; + } + + private void HandleExternalAudioConfigurationChange() + { + if (_audioDspBufferController?.ConsumeExternalDeviceChange() != true) + return; + + var snapshot = _audioDspBufferController.Snapshot; + var hadActiveSong = _preparedSong != null || _mode == AppMode.Loading || _mode == AppMode.Gameplay || + _mode == AppMode.Paused || _mode == AppMode.Results || _mode == AppMode.GameOver; + if (hadActiveSong) + { + ClearLoadedSongState(); + _courseSession.ClearRun(); + _mode = _libraryReady ? AppMode.Library : AppMode.Home; + RefreshLibraryThumbnail(); + } + + _audioDspStatus = "AUDIO DEVICE CHANGED // GENERATED AUDIO RELOAD REQUIRED"; + if (_audioBackendFactory is FmodCoreSongAudioBackendFactory fmodFactory) + fmodFactory.UpdateAudioSystem(); + RestoreActiveDeviceAudioCalibration(); + Debug.LogWarning( + $"[ProjectQuint Audio] Output device configuration changed; {snapshot.AppliedLabel}. " + + $"Active generated song resources discarded: {hadActiveSong}."); + } + + private void ChangeMasterVolume(int volumePercent, bool persist = true) + { + var clamped = Mathf.Clamp( + volumePercent, + GameSettings.MinimumMasterVolumePercent, + GameSettings.MaximumMasterVolumePercent); + if (_masterVolumePercent == clamped) + return; + + _masterVolumePercent = clamped; + ApplyMasterVolume(); + if (persist) + PersistSettings(); + } + + private void ApplyMasterVolume() + { + var normalizedVolume = _masterVolumePercent * 0.01f; + AudioListener.volume = normalizedVolume; + _audio?.SetMasterVolume(normalizedVolume); + } + + private void ConfigureAudioBackend(string[] commandLineArguments) + { + _audioBackendInitializationError = string.Empty; + if (!AudioBackendSelection.TryParse( + commandLineArguments, + out var backendKind, + out var parseError)) + { + _audioBackendInitializationError = parseError; + } + _audioBackendKind = backendKind; + + if (string.IsNullOrWhiteSpace(_audioBackendInitializationError)) + { + try + { + _audioBackendFactory = backendKind == SongAudioBackendKind.FmodCore + ? new FmodCoreSongAudioBackendFactory( + (uint)GameSettings.RequestedDspBufferSamples(_audioDspBufferProfile), + _settings.AudioOutputDeviceId) + : new UnitySongAudioBackendFactory(); + } + catch (Exception exception) + { + _audioBackendInitializationError = exception.Message; + } + } + + if (_audioBackendFactory == null) + { + // The Unity factory exists only to provide a valid inert composition graph. + // LoadSong refuses to play while the explicit initialization error is present. + _audioBackendFactory = new UnitySongAudioBackendFactory(); + Debug.LogError( + $"[ProjectQuint Audio] Requested backend is unavailable: " + + _audioBackendInitializationError); + } + + _audioClock = _audioBackendFactory.Clock; + _songLoadCoordinator = new SongLoadCoordinator(gameObject, _audioBackendFactory); + if (string.IsNullOrWhiteSpace(_audioBackendInitializationError)) + { + if (_audioBackendFactory is FmodCoreSongAudioBackendFactory fmodFactory && + fmodFactory.DeviceSelectionFallback) + { + _settings.AudioOutputDeviceId = string.Empty; + _settings.AudioOutputDeviceName = string.Empty; + _audioDspStatus = "SAVED DEVICE UNAVAILABLE // SYSTEM DEFAULT APPLIED"; + } + else + { + _audioDspStatus = FormatAudioDspStatus( + _audioDspBufferProfile, + _audioBackendFactory.OutputConfiguration); + } + } + Debug.Log( + $"[ProjectQuint Audio] Selected backend {_audioBackendFactory.BackendId}/" + + $"{_audioClock.ClockId}; {_audioBackendFactory.OutputConfiguration.AppliedLabel}."); + } + + private void ToggleReactiveKeySounds() + { + _reactiveKeySoundsEnabled = !_reactiveKeySoundsEnabled; + PersistSettings(); + } + + private void CycleFrameLimit(int delta) + { + const int count = 4; + var value = ((int)_frameLimitMode + delta) % count; + if (value < 0) value += count; + _frameLimitMode = (FrameLimitMode)value; + ApplyFrameLimit(); + PersistSettings(); + } + + private void ApplyFrameLimit() + { + // Unity uses vSyncCount instead of targetFrameRate when VSync is enabled. + QualitySettings.vSyncCount = _vSyncEnabled ? 1 : 0; + var refreshRate = Screen.currentResolution.refreshRateRatio.value; + _appliedDisplayRefreshRate = refreshRate; + _effectiveTargetFrameRate = GameSettings.EffectiveTargetFrameRate( + _frameLimitMode, + refreshRate, + _vSyncEnabled, + UsesDisplayLinkedPresentation); + Application.targetFrameRate = _effectiveTargetFrameRate; + Debug.Log( + $"[ProjectQuint Display] Frame policy: requested " + + $"{FormatFrameRate(GameSettings.TargetFrameRate(_frameLimitMode))}, effective " + + $"{FormatFrameRate(_effectiveTargetFrameRate)}, display {refreshRate:0.##}Hz, " + + $"VSync {(_vSyncEnabled ? "ON" : "OFF")}, " + + $"DisplayLink {(UsesDisplayLinkedPresentation ? "ON" : "OFF")}."); + } + + private void ReapplyFrameLimitWhenDisplayRefreshRateChanges() + { + if (!UsesDisplayLinkedPresentation) + return; + + var now = Time.realtimeSinceStartupAsDouble; + if (now < _nextDisplayRefreshCheckRealtime) + return; + + _nextDisplayRefreshCheckRealtime = now + 0.25; + var currentRefreshRate = Screen.currentResolution.refreshRateRatio.value; + if (!GameSettings.DisplayRefreshRateChanged( + _appliedDisplayRefreshRate, + currentRefreshRate)) + return; + + ApplyFrameLimit(); + } + + private static string FormatFrameRate(int framesPerSecond) => + framesPerSecond > 0 ? $"{framesPerSecond} FPS" : "UNLIMITED"; + + private static bool UsesDisplayLinkedPresentation + { + get + { +#if UNITY_STANDALONE_OSX && !UNITY_EDITOR + return true; +#else + return false; +#endif + } + } + + private void ToggleVSync() + { + _vSyncEnabled = !_vSyncEnabled; + ApplyFrameLimit(); + PersistSettings(); + } + + private void CycleDisplayResolution(int delta) + { + const int count = 4; + var value = ((int)_displayResolutionMode + delta) % count; + if (value < 0) value += count; + _displayResolutionMode = (DisplayResolutionMode)value; + PersistSettings(); +#if !UNITY_EDITOR + ApplyResolution(); +#endif + } + + private void ApplyResolution() + { + var size = GameSettings.ResolutionSize(_displayResolutionMode); + Screen.SetResolution(size.x, size.y, GameSettings.FullScreenModeFor(_windowMode)); + } + + private void CycleWindowMode(int delta) + { + const int count = 2; + var value = ((int)_windowMode + delta) % count; + if (value < 0) value += count; + _windowMode = (WindowMode)value; + PersistSettings(); +#if !UNITY_EDITOR + ApplyResolution(); +#endif + } + + private void ToggleFpsCounter() + { + _showFpsCounter = !_showFpsCounter; + _fpsSampleFrames = 0; + _fpsSampleElapsed = 0f; + _displayedFps = 0f; + PersistSettings(); + } + + private void CycleBgaPlaybackMode(int delta) + { + const int count = 3; + var value = ((int)_bgaPlaybackMode + delta) % count; + if (value < 0) value += count; + _bgaPlaybackMode = (BgaPlaybackMode)value; + if (!DynamicBgaEnabled) + _bga?.Clear(); + PersistSettings(); + } + + private void ChangeBackgroundDim(int deltaPercent, bool persist = true) + { + _backgroundDimPercent = Mathf.Clamp(_backgroundDimPercent + deltaPercent, 0, 90); + if (persist) + PersistSettings(); + } + + private void UpdateFpsSample() + { + if (!_showFpsCounter) + return; + + _fpsSampleFrames++; + _fpsSampleElapsed += Time.unscaledDeltaTime; + if (_fpsSampleElapsed < 0.35f) + return; + + _displayedFps = CalculateFrameRate(_fpsSampleFrames, _fpsSampleElapsed); + _fpsSampleFrames = 0; + _fpsSampleElapsed = 0f; + } + + internal static float CalculateFrameRate(int frames, float elapsedSeconds) => + frames <= 0 || elapsedSeconds <= 0f ? 0f : frames / elapsedSeconds; + + private void AssignInputBinding(int binding, Key key) + { + var changed = binding switch + { + GameSettings.CenterAuxBindingIndex when _optionsInputKeyCount == 5 => + _settings.AssignCenterAuxKey(key), + GameSettings.ScratchBindingIndex => _settings.AssignScratchKey(key), + _ => _settings.AssignProfileKey(_optionsInputKeyCount, binding, key), + }; + if (!changed) + return; + PersistSettings(); + RefreshLaneKeyLabels(); + } + + private void CycleInputProfile(int delta) + { + _optionsInputKeyCount = GameSettings.CycleInputKeyCount(_optionsInputKeyCount, delta); + _optionsSelectedRow = 0; + _rebindLane = -1; + RefreshLaneKeyLabels(); + } + + private void CycleOptionsCategory(int delta) => + SetOptionsCategory(ProjectQuintPresentation.CycleOptionsCategory(_optionsCategory, delta)); + + private void SetOptionsCategory(OptionsCategory category) + { + var normalized = ProjectQuintPresentation.CycleOptionsCategory(category, 0); + if (_optionsCategory == normalized) + return; + _optionsCategory = normalized; + _optionsSelectedRow = 0; + _optionsValueKeyRepeat.Reset(); + _rebindLane = -1; + _profileImportConfirmationPending = false; + if (_optionsCategory == OptionsCategory.Audio) + RefreshAudioOutputDevices(); + } + + private void ChangeSelectedOption(int direction) + { + direction = direction < 0 ? -1 : 1; + switch (_optionsCategory) + { + case OptionsCategory.Gameplay: + if (_optionsSelectedRow == 0) + ChangeNoteSpeed(direction * 0.1f); + else + ChangeJudgementOffset(direction); + break; + case OptionsCategory.Input: + if (_optionsSelectedRow == 0) + CycleInputProfile(direction); + else + _rebindLane = ProjectQuintPresentation.InputBindingForRow( + _optionsSelectedRow, + _optionsInputKeyCount); + break; + case OptionsCategory.Latency: + switch (_optionsSelectedRow) + { + case 0: + CycleInputPollingRate(direction); + break; + case 1: + CycleAudioDspBufferProfile(direction); + break; + case 2: + ChangeAudioOutputLatency(direction); + break; + case 3: + OpenAudioSyncCalibration(); + break; + } + break; + case OptionsCategory.Audio: + switch (_optionsSelectedRow) + { + case 0: + ChangeMasterVolume(_masterVolumePercent + direction); + break; + case 1: + ToggleReactiveKeySounds(); + break; + case 2: + CycleAudioOutputDevice(direction); + break; + case 3: + CycleAudioDspBufferProfile(direction); + break; + case 4: + ChangeAudioOutputLatency(direction); + break; + case 5: + RequestClearPremixCache(); + break; + case 6: + OpenPackCacheManager(); + break; + } + break; + case OptionsCategory.Display: + switch (_optionsSelectedRow) + { + case 0: + CycleFrameLimit(direction); + break; + case 1: + ToggleVSync(); + break; + case 2: + CycleDisplayResolution(direction); + break; + case 3: + CycleWindowMode(direction); + break; + case 4: + ToggleFpsCounter(); + break; + case 5: + CycleBgaPlaybackMode(direction); + break; + case 6: + CycleBgaDisplayMode(direction); + break; + case 7: + ChangeBackgroundDim(direction * 5); + break; + } + break; + case OptionsCategory.Data: + if (_optionsSelectedRow == 0) + ExportProfileArchive(); + else if (_optionsSelectedRow == 1) + RequestOrImportProfileArchive(); + else + RequestUpdateCheck(manual: true); + break; + } + } + + private bool IsSelectedOptionContinuous() => _optionsCategory switch + { + OptionsCategory.Gameplay => _optionsSelectedRow is 0 or 1, + OptionsCategory.Latency => _optionsSelectedRow == 2, + OptionsCategory.Audio => _optionsSelectedRow is 0 or 4, + OptionsCategory.Display => _optionsSelectedRow == 7, + _ => false, + }; + + private void ChangeSelectedContinuousOption(int stepCount) + { + if (stepCount == 0) + return; + + switch (_optionsCategory) + { + case OptionsCategory.Gameplay when _optionsSelectedRow == 0: + ChangeNoteSpeed(stepCount * 0.1f, persist: false); + break; + case OptionsCategory.Gameplay when _optionsSelectedRow == 1: + ChangeJudgementOffset(stepCount, persist: false); + break; + case OptionsCategory.Latency when _optionsSelectedRow == 2: + ChangeAudioOutputLatency(stepCount, persist: false); + break; + case OptionsCategory.Audio when _optionsSelectedRow == 0: + ChangeMasterVolume(_masterVolumePercent + stepCount, persist: false); + break; + case OptionsCategory.Audio when _optionsSelectedRow == 4: + ChangeAudioOutputLatency(stepCount, persist: false); + break; + case OptionsCategory.Display when _optionsSelectedRow == 7: + ChangeBackgroundDim(stepCount * 5, persist: false); + break; + } + _continuousOptionsDirty = true; + } + + private void CommitContinuousOptionsIfNeeded() + { + if (!_continuousOptionsDirty) + return; + _continuousOptionsDirty = false; + if ((_optionsCategory == OptionsCategory.Latency && _optionsSelectedRow == 2) || + (_optionsCategory == OptionsCategory.Audio && _optionsSelectedRow == 4)) + { + SaveActiveDeviceAudioCalibration( + _audioOutputLatencyMilliseconds, + acceptedSampleCount: 0, + medianAbsoluteDeviationMilliseconds: 0f); + } + PersistSettings(); + } + + private void ActivateSelectedOption() => ChangeSelectedOption(1); + + private void CycleInputPollingRate(int direction) + { + ApplyInputPollingRate( + GameSettings.CycleInputPollingRate(_inputPollingRate, direction), + startup: false); + } + + private void ApplyInputPollingRate(InputPollingRate rate, bool startup) + { + _inputPollingRate = Enum.IsDefined(typeof(InputPollingRate), rate) + ? rate + : GameSettingsDefaults.InputPolling; +#if UNITY_STANDALONE_OSX && !UNITY_EDITOR + _inputPollingStatus = + $"NATIVE macOS GAMEPLAY SAMPLER // TARGET {GameSettings.InputPollingRateHz(_inputPollingRate)} Hz"; +#elif UNITY_EDITOR + _inputPollingStatus = "STANDALONE PLAYER ONLY // EDITOR USES UNITY INPUT EVENTS"; +#else + _inputPollingStatus = "NATIVE POLLING BACKEND IS CURRENTLY AVAILABLE ON macOS ONLY"; +#endif + if (!startup) + PersistSettings(); + Debug.Log($"[ProjectQuint Input] {_inputPollingStatus}"); + } + + private void RefreshPremixCacheStatus(string prefix = null) + { + try + { + var label = PremixedSongCache.Shared.GetSnapshot().Label; + _premixCacheStatus = string.IsNullOrWhiteSpace(prefix) ? label : $"{prefix} // {label}"; + } + catch (Exception exception) + { + _premixCacheStatus = $"CACHE STATUS FAILED // {exception.Message}"; + } + } + + private void RequestClearPremixCache() + { + if (_mode != AppMode.Options || _premixCacheClearing) + return; + StartCoroutine(ClearPremixCache()); + } + + private IEnumerator ClearPremixCache() + { + _premixCacheClearing = true; + _premixCacheStatus = "CLEARING CACHE..."; + var clearTask = Task.Run(() => PremixedSongCache.Shared.Clear()); + while (!clearTask.IsCompleted) + yield return null; + _premixCacheClearing = false; + if (clearTask.IsFaulted) + { + _premixCacheStatus = + $"CACHE CLEAR FAILED // {clearTask.Exception?.GetBaseException().Message}"; + Debug.LogError($"[ProjectQuint Audio] {_premixCacheStatus}"); + yield break; + } + RefreshPremixCacheStatus("CACHE CLEARED"); + Debug.Log("[ProjectQuint Audio] Automatic premix cache cleared; approved pinned packs preserved."); + } + + private void RequestPinnedPackCacheEstimate() + { + if (_mode != AppMode.Options || _pinnedPackCacheWorking || + _pinnedPackCacheConfirmationPending) + return; + var pack = SelectedPackCacheDefinition(); + if (pack == null || !pack.IsAvailable) + { + _pinnedPackCacheStatus = "SELECTED PACK SOURCE IS UNAVAILABLE"; + return; + } + StartCoroutine(AnalyzePinnedPackCache(pack)); + } + + private IEnumerator AnalyzePinnedPackCache(BmsPackDefinition pack) + { + _pinnedPackCacheWorking = true; + _pinnedPackCacheStatus = $"INDEXING {pack.DisplayName}..."; + _pinnedPackCacheCancellation?.Cancel(); + _pinnedPackCacheCancellation?.Dispose(); + _pinnedPackCacheCancellation = new CancellationTokenSource(); + var token = _pinnedPackCacheCancellation.Token; + List entries; + if (_libraryReady && _activePack != null && + string.Equals(_activePack.Id, pack.Id, StringComparison.OrdinalIgnoreCase)) + { + entries = _libraryController.State.AllSongs.ToList(); + } + else + { + var scanner = new SongLibraryScanner( + _packRegistry.CachePathFor(pack), + legacyCachePath: null, + pack.Id, + pack.ForcedKeyCount); + var scanTask = Task.Run(() => scanner.Scan(pack.ContentRoot, token), token); + while (!scanTask.IsCompleted) + { + _pinnedPackCacheStatus = + $"INDEXING {pack.DisplayName} // {scanner.Completed:N0}/{scanner.Total:N0}"; + yield return null; + } + if (scanTask.IsCanceled) + { + _pinnedPackCacheWorking = false; + _pinnedPackCacheStatus = "PACK CACHE INDEX CANCELLED"; + yield break; + } + if (scanTask.IsFaulted) + { + _pinnedPackCacheWorking = false; + _pinnedPackCacheStatus = + $"PACK INDEX FAILED // {scanTask.Exception?.GetBaseException().Message}"; + yield break; + } + entries = scanTask.Result; + } + if (entries.Count == 0) + { + _pinnedPackCacheWorking = false; + _pinnedPackCacheStatus = "SELECTED PACK CONTAINS NO PLAYABLE CHARTS"; + yield break; + } + _pinnedPackCacheStatus = $"ANALYZING {pack.DisplayName} // {entries.Count:N0} CHARTS..."; + var sampleRate = _audioBackendFactory?.OutputConfiguration.SampleRate ?? 0; + if (sampleRate <= 0) + sampleRate = AudioSettings.outputSampleRate; + var task = _pinnedPackCacheService.AnalyzeAsync( + pack, + entries, + sampleRate, + token); + while (!task.IsCompleted) + yield return null; + _pinnedPackCacheWorking = false; + if (task.IsCanceled) + { + _pinnedPackCacheStatus = "PINNED PACK ESTIMATE CANCELLED"; + yield break; + } + if (task.IsFaulted) + { + _pinnedPackCacheStatus = + $"ESTIMATE FAILED // {task.Exception?.GetBaseException().Message}"; + yield break; + } + + _pendingPinnedPackCachePlan = task.Result; + _pinnedPackCacheStatus = _pendingPinnedPackCachePlan.Summary; + if (_pendingPinnedPackCachePlan.AvailableBytes > 0 && + _pendingPinnedPackCachePlan.EstimatedAdditionalBytes > + _pendingPinnedPackCachePlan.AvailableBytes) + _pinnedPackCacheStatus += "\nWARNING // ESTIMATE EXCEEDS CURRENT FREE SPACE"; + _pinnedPackCacheConfirmationPending = true; + } + + private void ConfirmPinnedPackCache() + { + if (!_pinnedPackCacheConfirmationPending || _pendingPinnedPackCachePlan == null || + _pinnedPackCacheWorking) + return; + _pinnedPackCacheConfirmationPending = false; + StartCoroutine(BuildPinnedPackCache(_pendingPinnedPackCachePlan)); + } + + private IEnumerator BuildPinnedPackCache(PinnedPackCachePlan plan) + { + _pinnedPackCacheWorking = true; + _pinnedPackCacheCancellation?.Cancel(); + _pinnedPackCacheCancellation?.Dispose(); + _pinnedPackCacheCancellation = new CancellationTokenSource(); + var token = _pinnedPackCacheCancellation.Token; + var completedStatus = string.Empty; + yield return _pinnedPackCacheService.Build( + plan, + progress => _pinnedPackCacheStatus = + $"{progress.Current:N0}/{progress.Total:N0} // {progress.Detail}", + () => token.IsCancellationRequested, + status => completedStatus = status); + _pinnedPackCacheWorking = false; + _pendingPinnedPackCachePlan = null; + _pinnedPackCacheStatus = string.IsNullOrWhiteSpace(completedStatus) + ? "PINNED PACK CACHE STOPPED" + : completedStatus; + RefreshPremixCacheStatus(); + RefreshPackCacheManagerStatuses(); + } + + private void CancelPinnedPackCache() + { + _pinnedPackCacheConfirmationPending = false; + _pendingPinnedPackCachePlan = null; + _pinnedPackCacheCancellation?.Cancel(); + _pinnedPackCacheStatus = _pinnedPackCacheWorking + ? "CANCELLING PINNED PACK CACHE..." + : "PINNED PACK CACHE CANCELLED"; + } + + private void OpenPackCacheManager() + { + if (_mode != AppMode.Options || _pinnedPackCacheWorking) + return; + _packCacheManagerOpen = true; + var packs = _packRegistry?.Packs; + if (packs != null && packs.Count > 0 && _activePack != null) + { + var activeIndex = packs.ToList().FindIndex(pack => + string.Equals(pack.Id, _activePack.Id, StringComparison.OrdinalIgnoreCase)); + if (activeIndex >= 0) + _packCacheSelection = activeIndex; + } + RefreshPackCacheManagerStatuses(); + } + + private void ClosePackCacheManager() + { + if (_pinnedPackCacheWorking || _pinnedPackCacheConfirmationPending) + return; + _packCacheManagerOpen = false; + } + + private void SetPackCacheSelection(int index) + { + var count = _packRegistry?.Packs.Count ?? 0; + _packCacheSelection = count <= 0 ? 0 : Mathf.Clamp(index, 0, count - 1); + } + + private BmsPackDefinition SelectedPackCacheDefinition() + { + var packs = _packRegistry?.Packs; + if (packs == null || packs.Count == 0) + return null; + _packCacheSelection = Mathf.Clamp(_packCacheSelection, 0, packs.Count - 1); + return packs[_packCacheSelection]; + } + + private void RefreshPackCacheManagerStatuses() + { + var packs = _packRegistry?.Packs ?? Array.Empty(); + try + { + _packCacheStatuses = _pinnedPackCacheService.GetPackStatuses(packs); + _packCacheUiEntries = _packCacheStatuses.Select(status => new PackCacheUiEntry( + status.Pack?.DisplayName ?? "UNKNOWN PACK", + status.IsPinned, + status.ObjectCount, + status.SharedObjectCount, + status.MissingObjectCount, + PremixedSongCacheSnapshot.FormatBytes(status.ReferencedBytes), + PremixedSongCacheSnapshot.FormatBytes(status.ExclusiveBytes), + status.CreatedUtc == default + ? "-" + : status.CreatedUtc.ToLocalTime().ToString("yyyy-MM-dd HH:mm"))).ToArray(); + SetPackCacheSelection(_packCacheSelection); + } + catch (Exception exception) + { + _pinnedPackCacheStatus = $"PACK CACHE STATUS FAILED // {exception.Message}"; + _packCacheStatuses = Array.Empty(); + _packCacheUiEntries = Array.Empty(); + } + } + + private void RequestDeleteSelectedPackCache() + { + if (!_packCacheManagerOpen || _pinnedPackCacheWorking) + return; + var pack = SelectedPackCacheDefinition(); + var status = _packCacheStatuses.FirstOrDefault(item => + item.Pack != null && pack != null && + string.Equals(item.Pack.Id, pack.Id, StringComparison.OrdinalIgnoreCase)); + if (pack == null || !status.IsPinned) + { + _pinnedPackCacheStatus = "SELECTED PACK HAS NO PINNED CACHE"; + return; + } + StartCoroutine(DeleteSelectedPackCache(pack)); + } + + private IEnumerator DeleteSelectedPackCache(BmsPackDefinition pack) + { + _pinnedPackCacheWorking = true; + _pinnedPackCacheStatus = $"REMOVING {pack.DisplayName} CACHE..."; + var task = Task.Run(() => _pinnedPackCacheService.DeletePackCache(pack.Id)); + while (!task.IsCompleted) + yield return null; + _pinnedPackCacheWorking = false; + if (task.IsFaulted) + _pinnedPackCacheStatus = + $"DELETE FAILED // {task.Exception?.GetBaseException().Message}"; + else + _pinnedPackCacheStatus = task.Result.Status; + RefreshPremixCacheStatus(); + RefreshPackCacheManagerStatuses(); + } + + private void ExportProfileArchive() + { + _profileImportConfirmationPending = false; + var defaultName = $"ProjectQuint-{PersistencePaths.DefaultProfileId}-{DateTime.Now:yyyyMMdd-HHmmss}" + + ProfileArchiveService.FileExtension; + var destination = NativeProfileArchivePicker.ChooseExportPath(defaultName); + if (string.IsNullOrWhiteSpace(destination)) + { + _profileTransferStatus = "PROFILE EXPORT CANCELLED"; + return; + } + + var result = _profileArchiveService.Export(PersistencePaths.DefaultProfileId, destination); + _profileTransferStatus = result.Status; + } + + private void RequestOrImportProfileArchive() + { + if (!_profileImportConfirmationPending) + { + _profileImportConfirmationPending = true; + _profileTransferStatus = "IMPORT REPLACES CURRENT RECORDS // CONFIRM ONCE MORE"; + return; + } + + _profileImportConfirmationPending = false; + var source = NativeProfileArchivePicker.ChooseImportPath(); + if (string.IsNullOrWhiteSpace(source)) + { + _profileTransferStatus = "PROFILE IMPORT CANCELLED"; + return; + } + + var result = _profileArchiveService.Import(source, PersistencePaths.DefaultProfileId); + _profileTransferStatus = result.Status; + if (result.Success) + ReloadImportedProfileState(); + } + + private void ReloadImportedProfileState() + { + SongRecordRepository.Shared.Reload(); + CourseRecordRepository.Shared.Reload(); + _progressionRepository.Reload(); + _favoriteRepository.Reload(); + + var songs = _libraryController?.State?.AllSongs; + if (songs != null) + { + _favoriteRepository.Apply(songs); + foreach (var song in songs) + { + song.ClearLamp = SongClearLampStore.Load(song); + song.BestRecord = SongPlayRecordStore.Load(song); + } + SongRecordRepository.Shared.EnrichChartMetadata(songs, _activePack?.DisplayName); + _courseIndex?.ResolveSongs(songs); + } + RefreshProfileSummary(); + LoadProfileImage(); + } + + private void RefreshLaneKeyLabels() + { + Array.Clear(_laneKeyLabels, 0, _laneKeyLabels.Length); + var keys = _settings.KeysFor(_optionsInputKeyCount); + for (var lane = 0; lane < keys.Length && lane < _laneKeyLabels.Length; lane++) + _laneKeyLabels[lane] = GameSettings.FormatLaneKeyLabel(keys[lane]); + } + + private void TryStartAutomaticUpdateCheck() + { + if (_automaticUpdateCheckStarted || + _settings == null || + _mode != AppMode.Home) + return; + + _automaticUpdateCheckStarted = true; + if (Application.isEditor) + return; + if (!ProjectQuintUpdateService.ShouldCheckAutomatically( + _settings.LastUpdateCheckUtcTicks, + DateTime.UtcNow.Ticks)) + { + _updateStatus = "CHECKED RECENTLY"; + return; + } + + RequestUpdateCheck(manual: false); + } + + private void RequestUpdateCheck(bool manual) + { + if (_updateCheckRunning) + { + if (manual) + _updateStatus = "CHECKING..."; + return; + } + + if (manual) + { + _updateDismissedThisRun = false; + _updateOfferPending = false; + } + _updateStatus = "CHECKING..."; + StartCoroutine(CheckForUpdates(manual)); + } + + private IEnumerator CheckForUpdates(bool manual) + { + _updateCheckRunning = true; + ProjectQuintUpdateCheckResult result = default; + yield return ProjectQuintUpdateService.Check(value => result = value); + _updateCheckRunning = false; + + if (!result.Succeeded) + { + if (manual) + _updateStatus = "CHECK FAILED"; + else + _updateStatus = "NOT CHECKED"; + Debug.LogWarning($"[ProjectQuint Update] {result.Error}"); + yield break; + } + + _settings.LastUpdateCheckUtcTicks = DateTime.UtcNow.Ticks; + PersistSettings(); + _availableUpdate = result.Manifest; + + if (!ProjectQuintUpdateService.IsUpdateAvailable(_availableUpdate)) + { + _updateStatus = $"UP TO DATE // {ProjectQuintReleaseVersion.DisplayVersion.ToUpperInvariant()}"; + yield break; + } + + _updateStatus = $"AVAILABLE // {_availableUpdate.version.ToUpperInvariant()}"; + if (ProjectQuintUpdateService.ShouldOffer( + _availableUpdate, + _settings.SkippedUpdateVersion, + manual)) + _updateOfferPending = true; + } + + private void TryPresentPendingUpdate() + { + if (!_updateOfferPending || + _updateModalOpen || + _updateDismissedThisRun || + _availableUpdate == null) + return; + + var safeHome = _mode == AppMode.Home && !_profileModalOpen; + var safeOptions = + _mode == AppMode.Options && + _audioSyncCalibration == null && + string.IsNullOrWhiteSpace(_audioSyncCalibrationError) && + !_packCacheManagerOpen && + !_profileImportConfirmationPending; + if (!safeHome && !safeOptions) + return; + + _updateOfferPending = false; + _updateModalSelection = 0; + _updateModalOpen = true; + } + + private void UpdateUpdateNoticeInput() + { + var keyboard = Keyboard.current; + if (keyboard == null) + return; + + if (keyboard.escapeKey.wasPressedThisFrame) + { + HandleUpdateNoticeAction(UpdateNoticeAction.Later); + return; + } + + if (keyboard.leftArrowKey.wasPressedThisFrame || + keyboard.upArrowKey.wasPressedThisFrame) + _updateModalSelection = (_updateModalSelection + 2) % 3; + else if (keyboard.rightArrowKey.wasPressedThisFrame || + keyboard.downArrowKey.wasPressedThisFrame) + _updateModalSelection = (_updateModalSelection + 1) % 3; + + if (keyboard.enterKey.wasPressedThisFrame || + keyboard.numpadEnterKey.wasPressedThisFrame) + HandleUpdateNoticeAction((UpdateNoticeAction)_updateModalSelection); + } + + private void HandleUpdateNoticeAction(UpdateNoticeAction action) + { + if (!_updateModalOpen || _availableUpdate == null) + return; + + switch (action) + { + case UpdateNoticeAction.OpenDownload: + var sparkleError = "Sparkle updater is not available."; + if (_sparkleUpdater != null && + _sparkleUpdater.TryCheckForUpdates(out sparkleError)) + { + _updateStatus = "OPENING SECURE UPDATE INSTALLER..."; + } + else + { + Debug.LogWarning( + $"[ProjectQuint] Sparkle update fallback: {sparkleError}"); + Application.OpenURL(ProjectQuintUpdateService.DownloadPageUrl); + } + break; + case UpdateNoticeAction.Skip: + _settings.SkippedUpdateVersion = _availableUpdate.version; + PersistSettings(); + break; + } + + _updateDismissedThisRun = true; + _updateOfferPending = false; + _updateModalOpen = false; + } + + private void PersistSettings() => _settingsRepository.Save(_settings); + + private void FailLibraryScan(string title, string detail) + { + _libraryScanRunning = false; + _libraryReady = false; + _libraryLoadError = $"{title}\n{detail}"; + _status = title; + if (_openLibraryWhenScanCompletes) + SetError(title, detail); + else + Debug.LogWarning($"[ProjectQuint] {title}: {detail}"); + _openLibraryWhenScanCompletes = false; + } + + private void UpdateLoadingInput() + { + if (Keyboard.current?.escapeKey.wasPressedThisFrame != true) + return; + + HandleRetainedLoadingCancel(); + } + + private void HandleRetainedLoadingCancel() + { + if (_mode == AppMode.PackImport) + { + _packImportCancellation?.Cancel(); + _status = "CANCELLING ZIP IMPORT..."; + return; + } + if (_mode != AppMode.Loading) + return; + + Debug.Log("[ProjectQuint] Song loading cancelled by player."); + if (_activeCourse != null) + ReturnToCourseSelect(); + else + ReturnToLibrary(); + } + + private void HandleRetainedErrorRetry() + { + if (_mode == AppMode.Error) + StartCoroutine(InitializeLibrary(true)); + } + + private void UpdateLibraryInput() + { + var keyboard = Keyboard.current; + if (keyboard == null) + return; + + var library = _libraryController.State; + var allowProfileShortcut = ShouldAllowProfileShortcutInLibrary( + library.SearchFocused, + library.LevelFilterPanelOpen, + library.SortPanelOpen); + if (HandleProfileModalKeyboard(keyboard, allowProfileShortcut)) + return; + HandleLibraryControllerResult(_libraryController.HandleKeyboard(keyboard)); + } + + internal static bool ShouldAllowProfileShortcutInLibrary( + bool searchFocused, + bool levelFilterPanelOpen, + bool sortPanelOpen) => + !searchFocused && !levelFilterPanelOpen && !sortPanelOpen; + + private bool HandleProfileModalKeyboard(Keyboard keyboard, bool allowOpen) + { + if (keyboard == null) + return false; + if (_profileModalOpen) + { + if (keyboard.iKey.wasPressedThisFrame) + { + ChangeProfileImage(); + return true; + } + if (keyboard.deleteKey.wasPressedThisFrame || + keyboard.backspaceKey.wasPressedThisFrame) + { + ResetProfileImage(); + return true; + } + if (keyboard.leftShiftKey.wasPressedThisFrame) + _profileModalTab = (_profileModalTab + 3) % 4; + if (keyboard.rightShiftKey.wasPressedThisFrame) + _profileModalTab = (_profileModalTab + 1) % 4; + if (_profileModalTab == 1 && _ratingContexts.Count > 1) + { + if (keyboard.upArrowKey.wasPressedThisFrame) + ShiftProfileRatingContext(-1); + if (keyboard.downArrowKey.wasPressedThisFrame) + ShiftProfileRatingContext(1); + } + if (_profileModalTab == 2) + { + if (keyboard.leftArrowKey.wasPressedThisFrame) + ShiftProfileCollectionColumn(-1); + if (keyboard.rightArrowKey.wasPressedThisFrame) + ShiftProfileCollectionColumn(1); + if (keyboard.upArrowKey.wasPressedThisFrame) + ShiftProfileCollectionSelection(-1); + if (keyboard.downArrowKey.wasPressedThisFrame) + ShiftProfileCollectionSelection(1); + } + if (_profileModalTab == 3 && _profileCourses.BadgeCount > 0) + { + if (keyboard.upArrowKey.wasPressedThisFrame) + ShiftProfileBadgeSelection(-1); + if (keyboard.downArrowKey.wasPressedThisFrame) + ShiftProfileBadgeSelection(1); + if (keyboard.enterKey.wasPressedThisFrame || keyboard.numpadEnterKey.wasPressedThisFrame) + SetFeaturedProfileBadge(_profileBadgeSelection); + } + var enterCloses = _profileModalTab != 3 || _profileCourses.BadgeCount == 0; + if (keyboard.escapeKey.wasPressedThisFrame || + keyboard.f8Key.wasPressedThisFrame || + keyboard.f11Key.wasPressedThisFrame || + enterCloses && (keyboard.enterKey.wasPressedThisFrame || + keyboard.numpadEnterKey.wasPressedThisFrame)) + _profileModalOpen = false; + return true; + } + + if (!allowOpen || !keyboard.f11Key.wasPressedThisFrame) + return false; + OpenProfileModal(0); + return true; + } + + private void OpenProfileModal(int tab) + { + RefreshProfileSummary(); + RefreshProfileRatingThumbnailSongs(); + _profileModalTab = Mathf.Clamp(tab, 0, 3); + _profileModalOpen = true; + } + + private void HandleProfileModalUi(ProfileModalUiResult profileResult) + { + if (profileResult.ChangeProfileImage) + ChangeProfileImage(); + if (profileResult.ResetProfileImage) + ResetProfileImage(); + if (profileResult.SelectedTab >= 0) + _profileModalTab = profileResult.SelectedTab; + if (profileResult.RatingContextDelta != 0) + ShiftProfileRatingContext(profileResult.RatingContextDelta); + if (profileResult.SelectedBadgeIndex >= 0) + _profileBadgeSelection = profileResult.SelectedBadgeIndex; + if (profileResult.SelectedCollectionColumn >= 0) + SetProfileCollectionSelection( + profileResult.SelectedCollectionColumn, + profileResult.SelectedCollectionIndex); + if (!string.IsNullOrWhiteSpace(profileResult.FeaturedBadgeCourseId)) + SetFeaturedProfileBadge(_profileBadgeSelection); + if (profileResult.Close) + _profileModalOpen = false; + } + + private void LoadProfileImage() + { + if (_profileImageStore == null) + return; + if (_profileImageStore.TryLoad(out var texture, out var error)) + { + ReplaceProfileImage(texture, string.Empty); + return; + } + if (string.IsNullOrWhiteSpace(error)) + { + ReplaceProfileImage(null, string.Empty); + return; + } + + _profileImageStatus = $"PHOTO LOAD FAILED // {error}"; + ApplyProfileImageToPresenters(); + Debug.LogWarning($"[ProjectQuint Profile] {_profileImageStatus}"); + } + + private void ChangeProfileImage() + { + if (_profileImageStore == null) + return; + var sourcePath = NativeProfileImagePicker.ChooseImagePath(); + if (string.IsNullOrWhiteSpace(sourcePath)) + { + if (!string.IsNullOrWhiteSpace(NativeBmsPackPicker.LastError)) + { + _profileImageStatus = + $"PHOTO PICKER FAILED // {NativeBmsPackPicker.LastError}"; + ApplyProfileImageToPresenters(); + Debug.LogWarning($"[ProjectQuint Profile] {_profileImageStatus}"); + } + return; + } + + var result = _profileImageStore.Import(sourcePath); + if (!result.Success) + { + _profileImageStatus = result.Status; + ApplyProfileImageToPresenters(); + Debug.LogWarning($"[ProjectQuint Profile] {_profileImageStatus}"); + return; + } + ReplaceProfileImage(result.Texture, result.Status); + } + + private void ResetProfileImage() + { + if (_profileImageStore == null) + return; + var result = _profileImageStore.Reset(); + if (!result.Success) + { + _profileImageStatus = result.Status; + ApplyProfileImageToPresenters(); + Debug.LogWarning($"[ProjectQuint Profile] {_profileImageStatus}"); + return; + } + + ReplaceProfileImage(null, result.Status); + if (!string.IsNullOrWhiteSpace(result.RecoveryPath)) + { + Debug.Log( + "[ProjectQuint Profile] The previous profile photo was preserved at " + + $"'{result.RecoveryPath}'."); + } + } + + private void ReplaceProfileImage(Texture2D texture, string status) + { + if (_profileImageTexture != null && _profileImageTexture != texture) + Destroy(_profileImageTexture); + _profileImageTexture = texture; + _profileImageStatus = status ?? string.Empty; + ApplyProfileImageToPresenters(); + } + + private void ApplyProfileImageToPresenters() + { + _packMusicScreenPresenter?.SetProfileImage(_profileImageTexture); + _optionsProfileCoursePresenter?.SetProfileImage( + _profileImageTexture, + _profileImageStatus); + _sessionScreensPresenter?.SetProfileImage(_profileImageTexture); + } + + private void UpdateCourseSelectInput() + { + var keyboard = Keyboard.current; + if (keyboard == null) + return; + + if (keyboard.escapeKey.wasPressedThisFrame) + { + _mode = AppMode.Library; + return; + } + var courses = _courseIndex?.Courses; + if (courses == null) + return; + if (keyboard.mKey.wasPressedThisFrame) + ToggleCourseMirror(); + if (keyboard.digit1Key.wasPressedThisFrame || keyboard.numpad1Key.wasPressedThisFrame) + ChangeNoteSpeed(-0.1f); + if (keyboard.digit2Key.wasPressedThisFrame || keyboard.numpad2Key.wasPressedThisFrame) + ChangeNoteSpeed(0.1f); + if (courses.Count == 0) + return; + + if (keyboard.downArrowKey.wasPressedThisFrame) + _courseSession.MoveSelection(1, courses.Count); + if (keyboard.upArrowKey.wasPressedThisFrame) + _courseSession.MoveSelection(-1, courses.Count); + if (keyboard.pageDownKey.wasPressedThisFrame) + _courseSession.MoveSelection(5, courses.Count); + if (keyboard.pageUpKey.wasPressedThisFrame) + _courseSession.MoveSelection(-5, courses.Count); + if (keyboard.enterKey.wasPressedThisFrame || keyboard.numpadEnterKey.wasPressedThisFrame) + StartSelectedCourse(); + } + + private void StartSelectedCourse() + { + var courses = _courseIndex?.Courses; + if (courses == null || courses.Count == 0) + return; + var course = courses[Mathf.Clamp(_courseSelectedIndex, 0, courses.Count - 1)]; + if (!course.IsPlayable) + { + _courseStatus = $"{course.DisplayName}: {course.UnavailableReason}"; + return; + } + StartCourse(course); + } + + private void StartCourse(AeryCourseDefinition course) + { + if (course == null || !course.IsPlayable) + return; + + ClearLoadedSongState(); + _autoPlayEnabled = false; + _courseSession.Begin(course); + StartCoroutine(LoadCourseStage(0)); + } + + private void UpdateQuickOptionsInput() + { + var keyboard = Keyboard.current; + if (keyboard == null) + return; + + if (keyboard.spaceKey.wasPressedThisFrame || keyboard.escapeKey.wasPressedThisFrame) + { + _mode = AppMode.Library; + return; + } + if (keyboard.digit1Key.wasPressedThisFrame || keyboard.numpad1Key.wasPressedThisFrame) + ChangeNoteSpeed(-0.1f); + if (keyboard.digit2Key.wasPressedThisFrame || keyboard.numpad2Key.wasPressedThisFrame) + ChangeNoteSpeed(0.1f); + if (keyboard.downArrowKey.wasPressedThisFrame) + _quickOptionsSelectedRow = MoveQuickOptionsSelection( + _quickOptionsSelectedRow, + 1); + if (keyboard.upArrowKey.wasPressedThisFrame) + _quickOptionsSelectedRow = MoveQuickOptionsSelection( + _quickOptionsSelectedRow, + -1); + + var delta = keyboard.leftArrowKey.wasPressedThisFrame ? -1 : + keyboard.rightArrowKey.wasPressedThisFrame ? 1 : 0; + if (delta == 0 && (keyboard.enterKey.wasPressedThisFrame || keyboard.numpadEnterKey.wasPressedThisFrame)) + delta = 1; + if (delta == 0) + return; + + if (_quickOptionsSelectedRow == 0) + ToggleAutoPlay(); + else if (_quickOptionsSelectedRow == 12) + ToggleAutoScratch(); + else if (_quickOptionsSelectedRow == 1) + CycleGearPosition(delta); + else if (_quickOptionsSelectedRow == 2) + ChangeNoteSpeed(delta * 0.1f); + else if (_quickOptionsSelectedRow == 3) + CycleGaugeType(delta); + else if (_quickOptionsSelectedRow == 4) + ToggleGaugeFallback(); + else if (_quickOptionsSelectedRow == 5) + CycleLaneModifier(delta); + else if (_quickOptionsSelectedRow == 6) + CycleGameplaySkin(delta); + else if (_quickOptionsSelectedRow == 7) + CycleNoteSkin(delta); + else if (_quickOptionsSelectedRow == 8) + CycleLongNoteStyle(delta); + else if (_quickOptionsSelectedRow == 9) + CycleBgaDisplayMode(delta); + else if (_quickOptionsSelectedRow == 10) + ToggleLaneSeparators(); + else + ToggleTimingMeter(); + } + + internal static int MoveQuickOptionsSelection(int currentRow, int direction) + { + var currentVisualIndex = Array.IndexOf(QuickOptionsVisualOrder, currentRow); + if (currentVisualIndex < 0) + currentVisualIndex = 0; + var nextVisualIndex = ( + currentVisualIndex + + Math.Sign(direction) + + QuickOptionsVisualOrder.Length) % + QuickOptionsVisualOrder.Length; + return QuickOptionsVisualOrder[nextVisualIndex]; + } + + private void HandleQuickOptionsUi(QuickOptionsUiResult result) + { + if (result.ToggleAutoPlay) + ToggleAutoPlay(); + if (result.ToggleAutoScratch) + ToggleAutoScratch(); + if (result.GearPositionDelta != 0) + CycleGearPosition(result.GearPositionDelta); + if (Math.Abs(result.SpeedDelta) > 0.001f) + ChangeNoteSpeed(result.SpeedDelta); + if (result.GaugeTypeDelta != 0) + CycleGaugeType(result.GaugeTypeDelta); + if (result.ToggleGaugeFallback) + ToggleGaugeFallback(); + if (result.LaneModifierDelta != 0) + CycleLaneModifier(result.LaneModifierDelta); + if (result.SkinDelta != 0) + CycleGameplaySkin(result.SkinDelta); + if (result.NoteSkinDelta != 0) + CycleNoteSkin(result.NoteSkinDelta); + if (result.LongNoteStyleDelta != 0) + CycleLongNoteStyle(result.LongNoteStyleDelta); + if (result.BgaDisplayModeDelta != 0) + CycleBgaDisplayMode(result.BgaDisplayModeDelta); + if (result.ToggleLaneSeparators) + ToggleLaneSeparators(); + if (result.ToggleTimingMeter) + ToggleTimingMeter(); + if (result.Close) + _mode = AppMode.Library; + } + + private void ToggleAutoPlay() + { + _autoPlayEnabled = !_autoPlayEnabled; + if (_autoPlayEnabled) + _autoScratchEnabled = false; + } + + private void ToggleAutoScratch() + { + _autoScratchEnabled = !_autoScratchEnabled; + if (_autoScratchEnabled) + _autoPlayEnabled = false; + } + + private void ToggleGaugeFallback() + { + _gaugeFallbackEnabled = !_gaugeFallbackEnabled; + PersistSettings(); + } + + private IEnumerator LoadSelectedSong() + { + var entry = _libraryController.State.SelectedSong; + if (_mode != AppMode.Library || entry == null) + yield break; + + _courseSession.ClearRun(); + yield return LoadSong(entry); + } + + private IEnumerator LoadRandomSong() + { + var candidates = _libraryController.State.VisibleSongs; + if (_mode != AppMode.Library || candidates.Count == 0) + yield break; + + var entry = ResolveRandomSong( + candidates, + UnityEngine.Random.Range(0, candidates.Count)); + if (entry == null) + yield break; + + _status = $"RANDOM SELECT // {entry.DisplayTitle}"; + _courseSession.ClearRun(); + yield return LoadSong(entry); + } + + internal static SongLibraryEntry ResolveRandomSong( + IReadOnlyList candidates, + int randomIndex) + { + if (candidates == null || candidates.Count == 0) + return null; + if (randomIndex < 0 || randomIndex >= candidates.Count) + throw new ArgumentOutOfRangeException(nameof(randomIndex)); + return candidates[randomIndex]; + } + + private IEnumerator LoadCourseStage(int stageIndex) + { + if (!_courseSession.TryBeginStage(stageIndex, out var entry)) + { + ReturnToCourseSelect(); + yield break; + } + + yield return LoadSong(entry); + } + + private IEnumerator LoadSong(SongLibraryEntry entry) + { + if (!string.IsNullOrWhiteSpace(_audioBackendInitializationError)) + { + SetError("AUDIO BACKEND UNAVAILABLE", _audioBackendInitializationError); + yield break; + } + if (entry == null || IsAudioSystemResetting) + { + if (entry != null) + _status = "AUDIO SYSTEM IS RESTARTING // TRY AGAIN"; + yield break; + } + + var loadGeneration = ++_songLoadGeneration; + _loadingEntry = entry; + var loadingTitle = _activeCourse == null + ? entry.DisplayTitle + : $"{_activeCourse.DisplayName} // STAGE {_courseStageIndex + 1}/{_activeCourse.StageCount}"; + _flow.BeginLoading( + loadingTitle, + "Parsing BMS timeline", + Time.realtimeSinceStartupAsDouble); + var playModifier = _activeCourse == null ? _laneModifier : EffectiveCourseModifier; + var request = new SongLoadRequest + { + Entry = entry, + LaneModifier = playModifier, + LaneRandomSeed = _activeCourse == null ? unchecked((int)DateTime.UtcNow.Ticks) : 0, + ReactiveKeySoundsEnabled = _reactiveKeySoundsEnabled, + BgaPlaybackMode = _bgaPlaybackMode, + CancellationRequested = () => IsSongLoadCancelled(loadGeneration), + }; + SongLoadResult result = null; + yield return _songLoadCoordinator.Load( + request, + progress => + { + if (IsSongLoadCancelled(loadGeneration)) + return; + _flow.ReportLoading(progress.Detail, progress.Current, progress.Total); + }, + completed => result = completed); + + if (IsSongLoadCancelled(loadGeneration)) + { + result?.Dispose(); + yield break; + } + if (result == null || !result.IsSuccess) + { + if (result?.IsCancelled == true) + { + result.Dispose(); + yield break; + } + var errorTitle = result?.ErrorTitle ?? "SONG LOAD FAILED"; + var errorDetail = result?.ErrorDetail ?? entry.ChartPath; + result?.Dispose(); + _loadingEntry = null; + SetError( + errorTitle, + errorDetail); + yield break; + } + + var candidate = result.TakePreparedSong(); + result.Dispose(); + if (!CommitPreparedSong(candidate)) + { + candidate?.Dispose(); + _loadingEntry = null; + SetError("SONG LOAD FAILED", "The prepared song could not be committed."); + yield break; + } + + BeginLoadedSongPlayback(); + Debug.Log(_songLoadCoordinator.BuildDiagnostics( + _preparedSong, + request, + $"{_gauge.Mode}, TOTAL {_gauge.Total:0.###}")); + } + + private bool CommitPreparedSong(PreparedSong candidate) + { + if (candidate == null || !candidate.TryCommit()) + return false; + + var previous = _preparedSong; + try + { + _preparedSong = candidate; + _audio = candidate.Audio; + _audio.SetMasterVolume(_masterVolumePercent * 0.01f); + _bga = candidate.Bga; + _stageTexture = candidate.StageTexture; + _playingEntry = candidate.Entry; + _loadingEntry = null; + _gameplaySession.LoadChart(candidate.Chart, candidate.NotesById); + } + catch (Exception exception) + { + _preparedSong = previous; + _audio = previous?.Audio; + _bga = previous?.Bga; + _stageTexture = previous?.StageTexture; + _playingEntry = previous?.Entry; + if (previous != null) + _gameplaySession.LoadChart(previous.Chart, previous.NotesById); + else + _gameplaySession.Clear(); + candidate.Dispose(); + Debug.LogException(exception); + return false; + } + + // The prior song remains intact throughout preparation. It is released only + // after every active reference has switched to the committed candidate. + try + { + previous?.Dispose(); + } + catch (Exception exception) + { + // A cleanup fault must not roll a successfully committed song back to an + // already-partially-disposed predecessor. + Debug.LogException(exception); + } + return true; + } + + private bool IsSongLoadCancelled(int loadGeneration) => + SongLoadCoordinator.ShouldCancel(loadGeneration, _songLoadGeneration, _mode == AppMode.Loading); + + private void BeginLoadedSongPlayback() + { + if (_chart == null || _audio == null || _bga == null) + return; + + _audio.Resume(); + DisableLaneInput(); + _resultSession.BeginPlayback(); + _gameplayNoteWindow.Reset(_chart.Notes); + _audio.BeginPlayback(_reactiveKeySoundsEnabled); + if (DynamicBgaEnabled) + _bga.BeginPlayback(); + var isCourseStage = _activeCourse != null; + _gameplaySession.Start( + new GameplaySessionStartRequest( + isCourseStage ? GaugeMode.Class : _gaugeType, + isCourseStage ? _courseGaugeValue : null, + isCourseStage && _courseProgress != null ? _courseProgress.CurrentCombo : 0, + isCourseStage && _courseProgress != null ? _courseProgress.MaxCombo : 0, + isCourseStage + ? _activeCourse.ResolvedGaugeProfile + : CourseGaugeProfile.Lr2, + autoPlay: !isCourseStage && _autoPlayEnabled, + gaugeFallbackEnabled: !isCourseStage && _gaugeFallbackEnabled, + autoScratch: ResolveAutoScratchForSession( + _autoScratchEnabled, + isCourseStage)), + CaptureGameplayClock()); + var gameplayStartRealtime = Time.realtimeSinceStartupAsDouble; + _gameplayPlayfieldPresenter.SetTelemetryEnabled(_gameplayDiagnosticsEnabled); + if (_gameplayDiagnosticsEnabled) + { + _framePacingMonitor.Reset(gameplayStartRealtime, GC.GetAllocatedBytesForCurrentThread()); + _unityFrameTimingMonitor.Reset(); + _inputMonitor.Reset(Keyboard.current?.displayName); + } + _gameplayNoteWindow.Update( + _judgement, + _frameSongTime, + ProjectQuintPresentation.CalculateApproachSeconds(_noteSpeed), + _chart.ScrollMap, + _chart.Metadata.InitialTempo); + if (!_gameplaySession.IsAutoPlay && !EnableLaneInput()) + return; + _mode = AppMode.Gameplay; + } + + private void OnJudged(JudgementResult result) + { + if (DynamicBgaEnabled && + result.Grade == JudgementGrade.Miss && + !result.SuppressGameplayPenalty) + _bga?.TriggerPoor(_frameSongTime); + } + + private void OnAutoPlayNotePressed(PlayableNote note) + { + if (_reactiveKeySoundsEnabled && note != null) + _audio?.PlayKeySound(note.SoundId, note.Lane); + } + + private void UpdateGameplay() + { + if (_resultSession.GameplayOutroActive) + { + var now = Time.realtimeSinceStartupAsDouble; + if (_resultSession.GameplayOutroComplete(now)) + { + _resultSession.CompleteGameplayOutro(now); + _mode = AppMode.Results; + } + return; + } + + using var gameplayScope = GameplayUpdateMarker.Auto(); + if (_gameplayDiagnosticsEnabled) + TrackGameplayFramePacing(); + var inputRealtime = Time.realtimeSinceStartupAsDouble; + _nativeInputPoller?.Drain(OnNativeLaneTransition, inputRealtime); + var phaseStarted = BeginGameplayPhaseTiming(); + _audio?.Update(); + RecordGameplayPhase(GameplayWorkloadPhase.Audio, phaseStarted); + var sessionSignal = _gameplaySession.AdvanceClock(CaptureGameplayClock()); + if ((sessionSignal & GameplaySessionSignal.ClockFallbackActivated) != 0) + { + var audioConfiguration = _audioDspBufferController?.Snapshot ?? default; + Debug.LogWarning( + "[ProjectQuint Audio] DSP clock stalled for at least 250 ms; possible output-device " + + $"interruption or buffer underrun ({audioConfiguration.AppliedLabel}). " + + "Gameplay is continuing on the monotonic realtime fallback clock. " + + "Frame-pacing hitches are measured and logged independently."); + } + if (DynamicBgaEnabled) + { + phaseStarted = BeginGameplayPhaseTiming(); + using (BgaUpdateMarker.Auto()) + _bga?.Update(_frameSongTime); + RecordGameplayPhase(GameplayWorkloadPhase.Bga, phaseStarted); + } + phaseStarted = BeginGameplayPhaseTiming(); + using (JudgementUpdateMarker.Auto()) + sessionSignal |= _gameplaySession.EvaluateFrame(_judgementOffsetMilliseconds); + RecordGameplayPhase(GameplayWorkloadPhase.Judgement, phaseStarted); + + if ((sessionSignal & GameplaySessionSignal.GaugeShifted) != 0) + { + _gameplayHudPresenter?.NotifyGaugeShift( + _gameplaySession.LastGaugeShiftFrom, + _gameplaySession.LastGaugeShiftTo, + _frameSongTime); + } + + if ((sessionSignal & GameplaySessionSignal.GaugeFailed) != 0) + { + EnterGameOver(); + return; + } + + var keyboard = Keyboard.current; + if (keyboard?.digit1Key.wasPressedThisFrame == true || keyboard?.numpad1Key.wasPressedThisFrame == true) + ChangeNoteSpeed(-0.1f); + if (keyboard?.digit2Key.wasPressedThisFrame == true || keyboard?.numpad2Key.wasPressedThisFrame == true) + ChangeNoteSpeed(0.1f); + + if (keyboard?.escapeKey.wasPressedThisFrame == true) + { + PauseGameplay(); + return; + } + + if ((sessionSignal & GameplaySessionSignal.SongFinished) != 0) + FinishSong(); + } + + private void TrackGameplayFramePacing() + { + var now = Time.realtimeSinceStartupAsDouble; + var targetFramesPerSecond = _effectiveTargetFrameRate; + _unityFrameTimingMonitor.CaptureLatest(); + if (!_framePacingMonitor.AddSample( + now, + GC.GetAllocatedBytesForCurrentThread(), + targetFramesPerSecond, + out var snapshot)) + return; + + var unityTiming = _unityFrameTimingMonitor.CaptureAndReset(); + var inputSnapshot = _inputMonitor.CaptureSnapshot(); + var workload = _workloadMonitor.CaptureAndReset(); + var message = + $"[ProjectQuint Profile] {(snapshot.IsUnstable ? "UNSTABLE" : "STABLE")} at song {_frameSongTime:0.000}s: " + + $"avg {snapshot.AverageMilliseconds:0.00} ms, p50 {snapshot.P50Milliseconds:0.00} ms, " + + $"p95 {snapshot.P95Milliseconds:0.00} ms, " + + $"p99 {snapshot.P99Milliseconds:0.00} ms, max {snapshot.MaximumMilliseconds:0.00} ms, " + + $"budget {snapshot.FrameBudgetMilliseconds:0.00} ms, max main-thread GC " + + $"{snapshot.MaximumAllocatedBytes / 1024.0:0.0} KiB, missed-refresh frames " + + $"{snapshot.MissedRefreshFrameCount}/{snapshot.SampleCount} " + + $"({(snapshot.SampleCount <= 0 ? 0.0 : snapshot.MissedRefreshFrameCount * 100.0 / snapshot.SampleCount):0.000}%), " + + $"estimated missed refreshes {snapshot.EstimatedMissedRefreshCount}; " + + $"Unity timing {(unityTiming.FeatureEnabled ? "ON" : "OFF")}, " + + $"samples {unityTiming.CpuFrame.SampleCount}, VSync source " + + $"{FormatUnityVSyncSource(unityTiming)}, " + + $"CPU total {FormatInputPhase(unityTiming.CpuFrame)}, main " + + $"{FormatInputPhase(unityTiming.CpuMainThread)}, present-wait " + + $"{FormatInputPhase(unityTiming.CpuPresentWait)}, GPU " + + $"{FormatInputPhase(unityTiming.GpuFrame)}; " + + $"phase avg/p99/max ms: audio {FormatPhase(workload.Audio)}, " + + $"judge {FormatPhase(workload.Judgement)}, BGA {FormatPhase(workload.Bga)}, " + + $"note-window {FormatPhase(workload.NoteWindow)}, " + + $"playfield-submit {FormatPhase(workload.PlayfieldSubmit)}, " + + $"HUD {FormatPhase(workload.HudSubmit)}, mesh {FormatPhase(workload.PlayfieldMesh)}; " + + $"visible notes avg/max {workload.AverageVisibleNotes:0.0}/{workload.MaximumVisibleNotes}, " + + $"mesh vertices avg/max {workload.AverageMeshVertices:0}/{workload.MaximumMeshVertices}; " + + $"audio sources {_audio?.ScheduledSourceCount ?? 0}, " + + $"pending audio events {_audio?.PendingScheduledEventCount ?? 0}, " + + $"dropped late audio {_audio?.DroppedLateEventCount ?? 0}, BGA '{_bga?.Status ?? string.Empty}', " + + $"input events {inputSnapshot.EventCount}, input queue p50/p95/p99/max " + + $"{inputSnapshot.P50QueueDelayMilliseconds:0.###}/" + + $"{inputSnapshot.P95QueueDelayMilliseconds:0.###}/" + + $"{inputSnapshot.P99QueueDelayMilliseconds:0.###}/" + + $"{inputSnapshot.MaximumQueueDelayMilliseconds:0.###} ms, " + + $"input pipeline p50/p95/p99/max ms: judge " + + $"{FormatInputPhase(inputSnapshot.JudgementDispatchLatency)}, reactive-audio " + + $"{FormatInputPhase(inputSnapshot.ReactiveAudioCommandLatency)}, visual " + + $"{FormatInputPhase(inputSnapshot.VisualFeedbackLatency)}, visual drops " + + $"{inputSnapshot.DroppedVisualFeedbackSamples}, out-of-order " + + $"{inputSnapshot.OutOfOrderCount}, invalid transitions " + + $"{inputSnapshot.InvalidTransitionCount}, " + + $"display {Screen.width}x{Screen.height}@" + + $"{Screen.currentResolution.refreshRateRatio.value:0.##}Hz, " + + $"VSync {(_vSyncEnabled ? "ON" : "OFF")}, requested " + + $"{FormatFrameRate(GameSettings.TargetFrameRate(_frameLimitMode))}, effective " + + $"{FormatFrameRate(targetFramesPerSecond)}, " + + $"BGA {_bgaPlaybackMode}, note {_noteSkin}/{_longNoteStyle}, " + + $"runtime {(Application.isEditor ? "Editor" : "Player")}."; + if (snapshot.IsUnstable) + Debug.LogWarning(message); + else + Debug.Log(message); + } + + private long BeginGameplayPhaseTiming() => + _gameplayDiagnosticsEnabled ? GameplayWorkloadMonitor.Timestamp() : 0; + + private void RecordGameplayPhase(GameplayWorkloadPhase phase, long startedTimestamp) + { + if (_gameplayDiagnosticsEnabled) + _workloadMonitor.Record(phase, startedTimestamp); + } + + private static string FormatPhase(GameplayPhaseTimingSnapshot phase) => + $"{phase.AverageMilliseconds:0.000}/{phase.P99Milliseconds:0.000}/{phase.MaximumMilliseconds:0.000}"; + + private static string FormatInputPhase(GameplayPhaseTimingSnapshot phase) => + phase.SampleCount <= 0 + ? "-" + : $"{phase.P50Milliseconds:0.000}/{phase.P95Milliseconds:0.000}/" + + $"{phase.P99Milliseconds:0.000}/{phase.MaximumMilliseconds:0.000}" + + $"({phase.SampleCount})"; + + private static string FormatUnityVSyncSource(UnityFrameTimingSnapshot timing) => + timing.VSyncsPerSecond > 0f + ? $"{timing.VSyncsPerSecond:0.##}Hz x{timing.SyncInterval}" + : $"unavailable x{timing.SyncInterval}"; + + private void FinishSong() + { + LogInputDiagnostics("SONG FINISHED"); + DisableLaneInput(); + _audio?.Stop(); + _bga?.Stop(); + if (_activeCourse != null) + { + FinishCourseStage(); + return; + } + + var previousRecord = SnapshotPlayRecord( + _playingEntry?.BestRecord ?? SongPlayRecordStore.Load(_playingEntry)); + var songCleared = _gauge != null && _gauge.IsCleared; + var lamp = SongClearLampStore.Resolve( + _gauge?.Mode ?? GaugeMode.Normal, + songCleared, + _gameplaySession?.IsAutoScratch == true); + RecordCurrentPlay(completed: true, songCleared, lamp); + var completion = GameplayCompletionOutcome.Evaluate( + previousRecord, + songCleared, + _gameplaySession?.IsAutoPlay == true, + !string.IsNullOrWhiteSpace(_resultSession.RecordSaveError), + _judgement?.Score ?? 0, + _judgement?.AccuracyPercent ?? 0.0, + _judgement?.MaxCombo ?? 0, + _judgement?.MissCount ?? 0, + _judgement?.PurePerfectCount ?? 0, + _judgement?.TotalScoringUnitCount ?? 0); + if (completion.HasPresentation) + { + _resultSession.BeginGameplayOutro( + previousRecord, + songCleared, + completion, + Time.realtimeSinceStartupAsDouble); + return; + } + _resultSession.BeginResults( + previousRecord, + songCleared, + Time.realtimeSinceStartupAsDouble); + _mode = AppMode.Results; + } + + private void FinishCourseStage() + { + if (_activeCourse == null || _courseProgress == null) + { + ReturnToLibrary(); + return; + } + + _courseSession.CaptureGauge(_gauge?.Value ?? 0.0); + AccumulateCourseStage(true); + var nextStageIndex = _courseStageIndex + 1; + + if (nextStageIndex < _activeCourse.StageCount) + { + // Course progress owns the gauge/combo that survives the transition. Audio, + // chart and BGA are stage-local: release them before decoding the next bank so + // dense courses never retain two songs' native samples at the same time. + ClearLoadedSongState(); + StartCoroutine(LoadCourseStage(nextStageIndex)); + return; + } + + ClearLoadedSongState(); + FinishCourseResult(true); + } + + private void AccumulateCourseStage(bool cleared) + { + _courseSession.TryAccumulateStage(_judgement, cleared); + } + + private void FinishCourseFailure() + { + DisableLaneInput(); + _audio?.Stop(); + _bga?.Stop(); + _courseSession.CaptureGauge(0.0); + AccumulateCourseStage(false); + ClearLoadedSongState(); + FinishCourseResult(false); + } + + private void FinishCourseResult(bool passed) + { + _resultSession.BeginResults(null, passed, Time.realtimeSinceStartupAsDouble); + if (_activeCourse != null && _courseProgress != null) + { + var result = _courseSession.Complete( + passed, + EffectiveCourseModifier.ToString().ToUpperInvariant()); + AeryCourseRecordStore.Record( + _activeCourse, + result); + } + else + { + _courseSession.Complete( + passed, + EffectiveCourseModifier.ToString().ToUpperInvariant()); + } + _mode = AppMode.CourseResults; + } + + private static SongPlayRecord SnapshotPlayRecord(SongPlayRecord source) + { + if (source == null) + return new SongPlayRecord(); + + return new SongPlayRecord + { + HasRecord = source.HasRecord, + BestScore = source.BestScore, + BestAccuracyPercent = source.BestAccuracyPercent, + BestMaxCombo = source.BestMaxCombo, + HasBestScoreJudgementBreakdown = source.HasBestScoreJudgementBreakdown, + BestScoreKoolCount = source.BestScoreKoolCount, + BestScoreCoolCount = source.BestScoreCoolCount, + BestScoreGoodCount = source.BestScoreGoodCount, + BestScoreMissCount = source.BestScoreMissCount, + BestScorePurePerfectCount = source.BestScorePurePerfectCount, + PlayCount = source.PlayCount, + HasJudgementBreakdown = source.HasJudgementBreakdown, + LastScore = source.LastScore, + LastAccuracyPercent = source.LastAccuracyPercent, + LastMaxCombo = source.LastMaxCombo, + LastKoolCount = source.LastKoolCount, + LastCoolCount = source.LastCoolCount, + LastGoodCount = source.LastGoodCount, + LastMissCount = source.LastMissCount, + LastFastCount = source.LastFastCount, + LastLateCount = source.LastLateCount, + LastAppliedEventId = source.LastAppliedEventId, + LastPurePerfectCount = source.LastPurePerfectCount, + LastJudgedUnits = source.LastJudgedUnits, + LastTotalScoringUnits = source.LastTotalScoringUnits, + LastCleared = source.LastCleared, + LastHardClear = source.LastHardClear, + EverFullCombo = source.EverFullCombo, + EverAllPure = source.EverAllPure, + BestRating = SnapshotRatingRecord(source.BestRating), + BestRatings = (source.BestRatings ?? new List()) + .ConvertAll(SnapshotRatingRecord), + }; + } + + private static RatingPlayRecord SnapshotRatingRecord(RatingPlayRecord source) + { + if (source == null) + return new RatingPlayRecord(); + + return new RatingPlayRecord + { + HasRecord = source.HasRecord, + EventId = source.EventId, + ProviderId = source.ProviderId, + ProviderDisplayName = source.ProviderDisplayName, + DifficultyLabel = source.DifficultyLabel, + DifficultyValue = source.DifficultyValue, + Title = source.Title, + Artist = source.Artist, + ChartConstant = source.ChartConstant, + KeyCount = source.KeyCount, + HasScratch = source.HasScratch, + LaneModifier = source.LaneModifier, + HardClear = source.HardClear, + AccuracyPercent = source.AccuracyPercent, + PlayRating = source.PlayRating, + PlayCount = source.PlayCount, + RatingRulesVersion = source.RatingRulesVersion, + RecordedUtcTicks = source.RecordedUtcTicks, + }; + } + + private void RecordCurrentPlay(bool completed, bool cleared, SongClearLamp lamp) + { + if (_gameplaySession?.AllowsRecordCommit != true || + _playingEntry == null || _judgement == null || !_resultSession.TryMarkRecorded()) + return; + + var annotation = _playingEntry.Catalog; + var difficultyLabel = annotation?.DifficultyLabel ?? string.Empty; + var difficultyValue = annotation?.DifficultyValue ?? string.Empty; + var chartConstant = annotation?.ChartConstant ?? 0.0; + if (chartConstant <= 0.0) + RatingRules.TryResolveChartConstant( + difficultyValue, + difficultyLabel, + out chartConstant); + var playEvent = new PlayResultEvent + { + EventId = string.IsNullOrWhiteSpace(_resultSession.CurrentPlayEventId) + ? Guid.NewGuid().ToString("N") + : _resultSession.CurrentPlayEventId, + PackId = _playingEntry.PackId ?? string.Empty, + PackDisplayName = _activePack?.DisplayName ?? string.Empty, + ProviderId = annotation?.ProviderId ?? string.Empty, + ProviderDisplayName = annotation?.ProviderDisplayName ?? string.Empty, + DifficultyLabel = difficultyLabel, + DifficultyValue = difficultyValue, + Title = _playingEntry.DisplayTitle ?? string.Empty, + Artist = _playingEntry.DisplayArtist ?? string.Empty, + ChartConstant = chartConstant, + KeyCount = _chart?.Metadata?.KeyCount ?? _playingEntry?.EffectiveKeyCount ?? 5, + HasScratch = _chart?.Metadata?.HasScratch ?? _playingEntry.Metadata?.HasScratch == true, + LaneModifier = _laneModifier.ToString().ToUpperInvariant(), + GaugeType = (_gauge?.Mode ?? _gaugeType).ToString().ToUpperInvariant(), + Completed = completed, + Cleared = cleared, + HardClear = lamp == SongClearLamp.HardClear || + lamp == SongClearLamp.ExHardClear, + ClearLamp = (int)lamp, + Score = _judgement.Score, + AccuracyPercent = _judgement.AccuracyPercent, + MaxCombo = _judgement.MaxCombo, + KoolCount = _judgement.KoolCount, + PurePerfectCount = _judgement.PurePerfectCount, + CoolCount = _judgement.CoolCount, + GoodCount = _judgement.GoodCount, + MissCount = _judgement.MissCount, + FastCount = _judgement.FastCount, + LateCount = _judgement.LateCount, + JudgedUnits = _judgement.JudgedNoteCount, + TotalScoringUnits = _judgement.TotalScoringUnitCount, + ActivePlaySeconds = Math.Max(0.0, _frameSongTime), + OccurredUtcTicks = DateTime.UtcNow.Ticks, + }; + if (PlayResultCommitGuard.TryCommit( + () => _playCommitCoordinator.Commit(_playingEntry, playEvent), + out var outcome, + out var userFacingError, + out var commitException)) + { + _resultSession.SetCommitOutcome(outcome); + RefreshProfileSummary(); + return; + } + + _resultSession.SetCommitFailure(userFacingError); + Debug.LogError( + $"[ProjectQuint Persistence] Play result was not saved; gameplay will still " + + $"advance to its terminal screen. {commitException}"); + } + + private void RefreshProfileSummary() + { + _profileSnapshot = _progressionRepository?.Snapshot() ?? default; + _profileCollection = SongRecordRepository.Shared.CalculateCollection(); + _profileCourses = CourseRecordRepository.Shared.CalculateCollection( + _progressionRepository?.FeaturedCourseBadgeId); + _ratingContexts = BuildProfileRatingContexts( + SongRecordRepository.Shared.CalculateRatingContexts(), + _difficultyTableRegistry?.Tables, + _libraryController?.State?.AllSongs, + _activeDifficultyTable?.Id, + _profileSnapshot, + SongRecordRepository.Shared.CalculateRating); + _homeRatingSummary = _profileSnapshot.LastRatingKeyCount > 0 && + !string.IsNullOrWhiteSpace(_profileSnapshot.LastRatingProviderId) + ? SongRecordRepository.Shared.CalculateRating( + _profileSnapshot.LastRatingProviderId, + _profileSnapshot.LastRatingProviderDisplayName, + _profileSnapshot.LastRatingKeyCount, + _profileSnapshot.LastRatingHasScratch) + : SongRecordRepository.Shared.CalculateMostRecentRatingContext( + _profileSnapshot.LastRatingKeyCount, + _profileSnapshot.LastRatingHasScratch); + _profileRatingContextIndex = FindRatingContextIndex(_homeRatingSummary); + if (_ratingContexts.Count > 0) + _profileRatingContextIndex = 0; + _profileRatingSummary = _ratingContexts.Count > 0 + ? _ratingContexts[_profileRatingContextIndex] + : _homeRatingSummary; + _profileBadgeSelection = FindFeaturedBadgeIndex(_profileCourses); + ClampProfileCollectionSelections(); + } + + private void RefreshProfileRatingThumbnailSongs() + { + var requestedChartIds = new HashSet(StringComparer.Ordinal); + foreach (var context in _ratingContexts ?? Array.Empty()) + { + foreach (var entry in context.BestPlays ?? Array.Empty()) + { + if (!string.IsNullOrWhiteSpace(entry.ChartId)) + requestedChartIds.Add(entry.ChartId); + } + } + + var signature = 17L; + unchecked + { + signature = signature * 31 + (_libraryController?.State?.SongsRevision ?? 0); + foreach (var chartId in requestedChartIds.OrderBy(id => id, StringComparer.Ordinal)) + signature = signature * 31 + StringComparer.Ordinal.GetHashCode(chartId); + foreach (var pack in _packRegistry?.Packs ?? Array.Empty()) + { + var cachePath = _packRegistry.CachePathFor(pack); + var cache = new FileInfo(cachePath); + signature = signature * 31 + StringComparer.OrdinalIgnoreCase.GetHashCode(pack.Id ?? string.Empty); + signature = signature * 31 + (cache.Exists ? cache.Length : 0L); + signature = signature * 31 + (cache.Exists ? cache.LastWriteTimeUtc.Ticks : 0L); + } + } + if (signature == _profileRatingThumbnailSourceSignature) + return; + + var songsByChartId = new Dictionary(StringComparer.Ordinal); + void AddSong(SongLibraryEntry song) + { + if (song == null) + return; + var chartId = ChartIdentityResolver.Resolve(song).Value; + if (requestedChartIds.Contains(chartId) && !songsByChartId.ContainsKey(chartId)) + songsByChartId.Add(chartId, song); + } + + foreach (var song in _libraryController?.State?.AllSongs ?? Array.Empty()) + AddSong(song); + foreach (var pack in _packRegistry?.Packs ?? Array.Empty()) + { + foreach (var song in SongLibraryScanner.LoadCachedThumbnailEntries( + _packRegistry.CachePathFor(pack), + pack.ContentRoot, + pack.Id, + requestedChartIds)) + AddSong(song); + } + + _profileRatingThumbnailSongs = songsByChartId.Values.ToArray(); + _profileRatingThumbnailSourceSignature = signature; + unchecked + { + _profileRatingThumbnailSongsRevision++; + } + } + + internal static IReadOnlyList BuildProfileRatingContexts( + IReadOnlyList recordedContexts, + IReadOnlyList registeredTables, + IReadOnlyList activeSongs, + string activeTableId, + ProfileProgressSnapshot profile, + Func resolveRating) + { + var contexts = new Dictionary(StringComparer.OrdinalIgnoreCase); + var tableNames = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var table in registeredTables ?? Array.Empty()) + { + if (table == null || string.IsNullOrWhiteSpace(table.Id)) + continue; + tableNames[table.Id.Trim()] = string.IsNullOrWhiteSpace(table.DisplayName) + ? table.Id.Trim() + : table.DisplayName.Trim(); + } + + void AddResolved(string providerId, string providerName, int keyCount, bool hasScratch) + { + if (string.IsNullOrWhiteSpace(providerId) || keyCount < 4 || keyCount > 7) + return; + var normalizedId = providerId.Trim(); + var key = RatingContextIdentity(normalizedId, keyCount, hasScratch); + if (contexts.ContainsKey(key)) + return; + var displayName = string.IsNullOrWhiteSpace(providerName) + ? tableNames.TryGetValue(normalizedId, out var registeredName) + ? registeredName + : normalizedId + : providerName.Trim(); + contexts[key] = resolveRating?.Invoke(normalizedId, displayName, keyCount, hasScratch) ?? + new RatingSummary(normalizedId, displayName, keyCount, hasScratch, 0.0, 0); + } + + foreach (var recorded in recordedContexts ?? Array.Empty()) + { + if (!recorded.HasContext) + continue; + contexts[RatingContextIdentity(recorded.ProviderId, recorded.KeyCount, recorded.HasScratch)] = recorded; + } + + foreach (var table in registeredTables ?? Array.Empty()) + { + if (table == null) + continue; + var preset = BmsDifficultyTableIndex.FindPreset(table.Id, table.HeaderUrl); + if (preset?.DefaultKeyCount >= 4) + AddResolved(table.Id, table.DisplayName, preset.Value.DefaultKeyCount, + preset.Value.DefaultHasScratch); + } + + if (!string.IsNullOrWhiteSpace(activeTableId)) + { + var activeName = tableNames.TryGetValue(activeTableId, out var registeredName) + ? registeredName + : activeTableId; + foreach (var form in (activeSongs ?? Array.Empty()) + .Where(song => song?.Metadata != null && + song.EffectiveKeyCount >= 4 && song.EffectiveKeyCount <= 7 && + song.Catalog?.HasDifficulty == true && + string.Equals(song.Catalog.ProviderId, activeTableId, + StringComparison.OrdinalIgnoreCase)) + .Select(song => new { KeyCount = song.EffectiveKeyCount, song.Metadata.HasScratch }) + .Distinct()) + AddResolved(activeTableId, activeName, form.KeyCount, form.HasScratch); + } + + AddResolved( + profile.LastRatingProviderId, + profile.LastRatingProviderDisplayName, + profile.LastRatingKeyCount, + profile.LastRatingHasScratch); + + return contexts.Values + .OrderByDescending(summary => summary.ActivityPlayCount) + .ThenByDescending(summary => summary.FilledSlots) + .ThenBy(summary => summary.ProviderDisplayName, StringComparer.OrdinalIgnoreCase) + .ThenBy(summary => summary.KeyCount) + .ThenBy(summary => summary.HasScratch) + .ToArray(); + } + + private static string RatingContextIdentity(string providerId, int keyCount, bool hasScratch) => + $"{(providerId ?? string.Empty).Trim().ToLowerInvariant()}:{keyCount}:{hasScratch}"; + + private int FindRatingContextIndex(RatingSummary target) + { + for (var index = 0; index < _ratingContexts.Count; index++) + { + var context = _ratingContexts[index]; + if (string.Equals(context.ProviderId, target.ProviderId, StringComparison.OrdinalIgnoreCase) && + context.KeyCount == target.KeyCount && context.HasScratch == target.HasScratch) + return index; + } + return 0; + } + + private void ShiftProfileRatingContext(int delta) + { + if (_ratingContexts.Count == 0 || delta == 0) + return; + _profileRatingContextIndex = (_profileRatingContextIndex + delta) % _ratingContexts.Count; + if (_profileRatingContextIndex < 0) + _profileRatingContextIndex += _ratingContexts.Count; + _profileRatingSummary = _ratingContexts[_profileRatingContextIndex]; + } + + private IReadOnlyList ProfileCollectionGroups(int column) + { + return Mathf.Clamp(column, 0, 2) switch + { + 0 => _profileCollection.Packs, + 1 => _profileCollection.KeyModes, + _ => _profileCollection.Difficulties, + }; + } + + private void ShiftProfileCollectionColumn(int delta) + { + if (delta == 0) + return; + _profileCollectionColumn = (_profileCollectionColumn + delta) % 3; + if (_profileCollectionColumn < 0) + _profileCollectionColumn += 3; + ClampProfileCollectionSelections(); + } + + private void ShiftProfileCollectionSelection(int delta) + { + var groups = ProfileCollectionGroups(_profileCollectionColumn); + if (groups == null || groups.Count == 0 || delta == 0) + return; + var current = _profileCollectionSelections[_profileCollectionColumn]; + current = (current + delta) % groups.Count; + if (current < 0) + current += groups.Count; + _profileCollectionSelections[_profileCollectionColumn] = current; + } + + private void SetProfileCollectionSelection(int column, int index) + { + _profileCollectionColumn = Mathf.Clamp(column, 0, 2); + var groups = ProfileCollectionGroups(_profileCollectionColumn); + _profileCollectionSelections[_profileCollectionColumn] = groups == null || groups.Count == 0 + ? 0 + : Mathf.Clamp(index, 0, groups.Count - 1); + } + + private void ClampProfileCollectionSelections() + { + _profileCollectionColumn = Mathf.Clamp(_profileCollectionColumn, 0, 2); + for (var column = 0; column < _profileCollectionSelections.Length; column++) + { + var groups = ProfileCollectionGroups(column); + _profileCollectionSelections[column] = groups == null || groups.Count == 0 + ? 0 + : Mathf.Clamp(_profileCollectionSelections[column], 0, groups.Count - 1); + } + } + + private static int FindFeaturedBadgeIndex(ProfileCourseSnapshot courses) + { + if (courses == null) + return 0; + for (var index = 0; index < courses.CertificationBadges.Count; index++) + { + if (string.Equals( + courses.CertificationBadges[index].CourseId, + courses.FeaturedBadgeCourseId, + StringComparison.OrdinalIgnoreCase)) + return index; + } + return 0; + } + + private void ShiftProfileBadgeSelection(int delta) + { + if (_profileCourses.BadgeCount == 0 || delta == 0) + return; + _profileBadgeSelection = (_profileBadgeSelection + delta) % _profileCourses.BadgeCount; + if (_profileBadgeSelection < 0) + _profileBadgeSelection += _profileCourses.BadgeCount; + } + + private void SetFeaturedProfileBadge(int badgeIndex) + { + if (_profileCourses.BadgeCount == 0) + return; + var normalized = Mathf.Clamp(badgeIndex, 0, _profileCourses.BadgeCount - 1); + var badge = _profileCourses.CertificationBadges[normalized]; + _progressionRepository?.SetFeaturedCourseBadge(badge.CourseId); + _profileCourses = CourseRecordRepository.Shared.CalculateCollection(badge.CourseId); + _profileBadgeSelection = FindFeaturedBadgeIndex(_profileCourses); + } + + private void UpdateResultsInput() + { + var keyboard = Keyboard.current; + if (keyboard == null) + return; + + if (keyboard.escapeKey.wasPressedThisFrame) + { + ReturnToLibrary(); + return; + } + + if (keyboard.rKey.wasPressedThisFrame) + { + ActivateResultsAction(ResultsMenuAction.Retry); + return; + } + + if (keyboard.enterKey.wasPressedThisFrame || keyboard.numpadEnterKey.wasPressedThisFrame) + ActivateResultsAction(ResultsMenuAction.ReturnToSelect); + } + + private void ActivateResultsAction(ResultsMenuAction action) + { + switch (action) + { + case ResultsMenuAction.Retry: + QuickRetryLoadedSong(); + break; + case ResultsMenuAction.ReturnToSelect: + ReturnToLibrary(); + break; + } + } + + private void UpdateCourseResultsInput() + { + var keyboard = Keyboard.current; + if (keyboard == null) + return; + + if (keyboard.escapeKey.wasPressedThisFrame) + { + ReturnToCourseSelect(); + return; + } + + if (keyboard.leftArrowKey.wasPressedThisFrame || keyboard.upArrowKey.wasPressedThisFrame || + keyboard.rightArrowKey.wasPressedThisFrame || keyboard.downArrowKey.wasPressedThisFrame) + _courseResultSelection = 1 - _courseResultSelection; + + if (keyboard.enterKey.wasPressedThisFrame || keyboard.numpadEnterKey.wasPressedThisFrame) + ActivateCourseResultAction((CourseResultMenuAction)_courseResultSelection); + } + + private void ActivateCourseResultAction(CourseResultMenuAction action) + { + switch (action) + { + case CourseResultMenuAction.Retry: + if (_activeCourse != null) + StartCourse(_activeCourse); + else + ReturnToCourseSelect(); + break; + case CourseResultMenuAction.ReturnToCourseSelect: + ReturnToCourseSelect(); + break; + } + } + + private void UpdatePauseInput() + { + var keyboard = Keyboard.current; + if (keyboard == null) + return; + + if (keyboard.escapeKey.wasPressedThisFrame) + { + ResumeGameplay(); + return; + } + + var speedDelta = PauseNoteSpeedDelta( + keyboard.digit1Key.wasPressedThisFrame || + keyboard.numpad1Key.wasPressedThisFrame, + keyboard.digit2Key.wasPressedThisFrame || + keyboard.numpad2Key.wasPressedThisFrame); + if (Math.Abs(speedDelta) > 0.001f) + ChangeNoteSpeed(speedDelta); + + if (keyboard.downArrowKey.wasPressedThisFrame) + _pauseSelection = (_pauseSelection + 1) % 3; + if (keyboard.upArrowKey.wasPressedThisFrame) + _pauseSelection = (_pauseSelection + 2) % 3; + if (keyboard.enterKey.wasPressedThisFrame || keyboard.numpadEnterKey.wasPressedThisFrame) + ActivatePauseAction((PauseMenuAction)_pauseSelection); + } + + internal static float PauseNoteSpeedDelta(bool decreasePressed, bool increasePressed) + { + if (decreasePressed == increasePressed) + return 0f; + return decreasePressed ? -0.1f : 0.1f; + } + + internal static bool ResolveAutoScratchForSession( + bool autoScratchEnabled, + bool isCourseStage) => + autoScratchEnabled && !isCourseStage; + + private void UpdateGameOverInput() + { + var keyboard = Keyboard.current; + if (keyboard == null) + return; + + if (keyboard.escapeKey.wasPressedThisFrame) + { + ReturnToLibrary(); + return; + } + _gameOverSelection = NavigateGameOverSelection( + _gameOverSelection, + keyboard.upArrowKey.wasPressedThisFrame, + keyboard.downArrowKey.wasPressedThisFrame); + if (keyboard.enterKey.wasPressedThisFrame || keyboard.numpadEnterKey.wasPressedThisFrame) + ActivateGameOverAction((GameOverMenuAction)_gameOverSelection); + } + + internal static int NavigateGameOverSelection( + int currentSelection, + bool upPressed, + bool downPressed) + { + var normalized = ((currentSelection % 2) + 2) % 2; + if (upPressed == downPressed) + return normalized; + return 1 - normalized; + } + + private void PauseGameplay() + { + _flow.BeginPause(); + _nativeInputPoller?.Suspend(); + if (DynamicBgaEnabled) + _bga?.Pause(); + _audio?.Pause(); + } + + private void ResumeGameplay() + { + _audio?.Resume(); + if (_gameplayDiagnosticsEnabled) + { + _framePacingMonitor.Reset( + Time.realtimeSinceStartupAsDouble, + GC.GetAllocatedBytesForCurrentThread()); + } + if (DynamicBgaEnabled) + _bga?.Resume(_frameSongTime); + + bool[] physicallyHeldLanes = null; + if (_nativeInputPoller?.IsRunning == true) + { + physicallyHeldLanes = _nativeInputPoller.ResumeAndCaptureHeldLanes(); + } + else + { + var keyboard = Keyboard.current; + if (keyboard != null && _laneHeld != null && _chart?.Metadata != null) + { + physicallyHeldLanes = new bool[_laneHeld.Length]; + for (var lane = 0; lane < _laneHeld.Length; lane++) + { + physicallyHeldLanes[lane] = IsGameplayLanePhysicallyHeld( + keyboard, + _chart.Metadata.KeyCount, + _chart.Metadata.HasScratch, + lane); + } + } + } + _gameplaySession.Resume(CaptureGameplayClock(), physicallyHeldLanes); + + _mode = AppMode.Gameplay; + } + + private void ActivatePauseAction(PauseMenuAction action) + { + switch (action) + { + case PauseMenuAction.Resume: + ResumeGameplay(); + break; + case PauseMenuAction.Retry: + QuickRetryLoadedSong(); + break; + case PauseMenuAction.ExitToSelect: + _audio?.Resume(); + if (_activeCourse != null) + ReturnToCourseSelect(); + else + ReturnToLibrary(); + break; + } + } + + private void EnterGameOver() + { + LogInputDiagnostics("GAME OVER"); + if (_activeCourse != null) + { + FinishCourseFailure(); + return; + } + + DisableLaneInput(); + _audio?.Stop(); + if (DynamicBgaEnabled) + _bga?.Pause(); + RecordCurrentPlay(completed: false, cleared: false, SongClearLamp.Failed); + _resultSession.BeginGameOver(); + _flow.BeginGameOver(); + } + + private void ActivateGameOverAction(GameOverMenuAction action) + { + switch (action) + { + case GameOverMenuAction.Retry: + QuickRetryLoadedSong(); + break; + case GameOverMenuAction.ExitToSelect: + ReturnToLibrary(); + break; + } + } + + private void QuickRetryLoadedSong() + { + if (_activeCourse != null) + { + StartCourse(_activeCourse); + return; + } + + if (_chart == null) + { + ReturnToLibrary(); + return; + } + + BeginLoadedSongPlayback(); + Debug.Log($"[ProjectQuint] Quick retry {_chart.Metadata.Title}; loaded audio, chart and BGA resources reused."); + } + + private void UpdateGameplayPresentation(bool advanceNoteWindow) + { + using var renderScope = GameplayRenderMarker.Auto(); + if (_gameplayDiagnosticsEnabled && + _gameplayPlayfieldPresenter.TryConsumeMeshTelemetry( + out var meshMilliseconds, + out var meshVertices)) + { + _workloadMonitor.RecordDuration(GameplayWorkloadPhase.PlayfieldMesh, meshMilliseconds); + _workloadMonitor.RecordMeshVertices(meshVertices); + } + var renderSongTime = GameplaySessionController.ResolveGameplayRenderSongTime( + advanceNoteWindow && (_audio?.StartDspTime ?? 0) > 0, + _gameplaySession.SampleSongTime(CaptureGameplayClock()), + _frameSongTime); + var phaseStarted = BeginGameplayPhaseTiming(); + if (advanceNoteWindow) + { + _gameplayNoteWindow.Update( + _judgement, + renderSongTime, + ProjectQuintPresentation.CalculateApproachSeconds(_noteSpeed), + _chart?.ScrollMap, + _chart?.Metadata?.InitialTempo ?? 150.0); + } + RecordGameplayPhase(GameplayWorkloadPhase.NoteWindow, phaseStarted); + if (_gameplayDiagnosticsEnabled) + _workloadMonitor.RecordVisibleNotes(_gameplayNoteWindow.VisibleNotes.Count); + + var bgaBaseTexture = DynamicBgaEnabled ? _bga?.CurrentBaseTexture : null; + var bgaLayerTexture = DynamicBgaEnabled ? _bga?.CurrentLayerTexture : null; + var bgaPoorTexture = DynamicBgaEnabled ? _bga?.CurrentPoorTexture : null; + var effectiveBgaDisplayMode = DynamicBgaEnabled + ? _bgaDisplayMode + : BgaDisplayMode.Full; + phaseStarted = BeginGameplayPhaseTiming(); + _gameplayPlayfieldPresenter.Update( + _gameplayNoteWindow.VisibleNotes, + _judgement, + renderSongTime, + _laneHeld, + _laneFlashTimes, + StageBackgroundEnabled ? _stageTexture : null, + bgaBaseTexture, + bgaLayerTexture, + bgaPoorTexture, + GameSettings.BackgroundDimOpacity(_backgroundDimPercent), + _noteSpeed, + _gearPosition, + _noteSkin, + _longNoteStyle, + effectiveBgaDisplayMode, + _gameplaySkin, + _laneSeparatorsEnabled, + _chart?.Metadata?.HasScratch == true, + _chart?.ScrollMap, + _chart?.Metadata?.InitialTempo ?? 150.0, + autoScratch: _gameplaySession?.IsAutoScratch == true); + RecordGameplayPhase(GameplayWorkloadPhase.PlayfieldSubmit, phaseStarted); + + var bgaStatus = _gameplaySession.IsClockFallbackActive + ? "DSP CLOCK STALLED // REALTIME FALLBACK ACTIVE" + : DynamicBgaEnabled ? _bga?.Status ?? string.Empty : string.Empty; + if (_activeCourse != null) + { + var courseStage = + $"DAN {_activeCourse.DisplayName} // STAGE {_courseStageIndex + 1}/{_activeCourse.StageCount}"; + bgaStatus = string.IsNullOrWhiteSpace(bgaStatus) + ? courseStage + : $"{courseStage} // {bgaStatus}"; + } + if (_gameplaySession.IsAutoPlay) + { + const string autoPlayStatus = "AUTO PLAY // RECORD NOT SAVED"; + bgaStatus = string.IsNullOrWhiteSpace(bgaStatus) + ? autoPlayStatus + : $"{autoPlayStatus} // {bgaStatus}"; + } + else if (_gameplaySession.IsAutoScratch) + { + const string autoScratchStatus = "AUTO SCRATCH // SCRATCH NOTES COUNT AS MISS"; + bgaStatus = string.IsNullOrWhiteSpace(bgaStatus) + ? autoScratchStatus + : $"{autoScratchStatus} // {bgaStatus}"; + } + phaseStarted = BeginGameplayPhaseTiming(); + _gameplayHudPresenter.Update( + _judgement, + _gauge, + renderSongTime, + _gearPosition, + _gameplaySkin, + bgaStatus, + _timingMeterEnabled, + _activeCourse != null ? _courseProgress : null); + if (_resultSession.GameplayOutroActive) + { + var completion = _resultSession.GameplayCompletion; + _gameplayHudPresenter.UpdateCompletion( + completion.PrimaryLabel, + completion.SecondaryLabel, + Math.Max( + 0.0, + Time.realtimeSinceStartupAsDouble - + _resultSession.GameplayOutroEnteredRealtime), + _gameplaySkin); + } + else + { + _gameplayHudPresenter.HideCompletion(); + } + RecordGameplayPhase(GameplayWorkloadPhase.HudSubmit, phaseStarted); + if (_gameplayDiagnosticsEnabled && advanceNoteWindow) + _inputMonitor.CommitVisualFeedback(GameplayWorkloadMonitor.Timestamp()); + } + + private bool EnableLaneInput() + { + DisableLaneInput(); + if (_chart?.Metadata == null) + return false; + +#if UNITY_STANDALONE_OSX && !UNITY_EDITOR + if (!_nativeInputPoller.TryStart( + _settings, + _chart.Metadata.KeyCount, + _chart.Metadata.HasScratch, + _inputPollingRate, + Time.realtimeSinceStartupAsDouble, + out var nativeInputError)) + { + SetError("NATIVE INPUT UNAVAILABLE", nativeInputError); + return false; + } + _inputPollingStatus = + $"NATIVE macOS ACTIVE // TARGET {GameSettings.InputPollingRateHz(_inputPollingRate)} Hz"; + if (_gameplayDiagnosticsEnabled) + _inputMonitor.Reset($"macOS NATIVE {GameSettings.InputPollingRateHz(_inputPollingRate)} Hz"); + return true; +#else + var keyboard = Keyboard.current; + if (keyboard == null) + return false; + + var keyCount = _chart.Metadata.KeyCount; + var hasScratch = _chart.Metadata.HasScratch; + _laneActions = new InputAction[_chart.Metadata.TotalLaneCount]; + for (var lane = 0; lane < _laneActions.Length; lane++) + { + var laneIndex = lane; + var action = new InputAction($"Lane {lane + 1}", InputActionType.Button); + foreach (var key in _settings.GameplayKeysForLane(keyCount, hasScratch, lane)) + action.AddBinding($"/{keyboard[key].name}"); + action.performed += context => OnLanePressed(laneIndex, context.time); + action.canceled += context => OnLaneReleased(laneIndex, context.time); + action.Enable(); + _laneActions[lane] = action; + } + return true; +#endif + } + + private bool IsGameplayLanePhysicallyHeld( + Keyboard keyboard, + int keyCount, + bool hasScratch, + int lane) + { + foreach (var key in _settings.GameplayKeysForLane(keyCount, hasScratch, lane)) + if (keyboard[key].isPressed) + return true; + return false; + } + + private void DisableLaneInput() + { + if (_nativeInputPoller?.IsRunning == true) + { + var snapshot = _nativeInputPoller.CaptureSnapshot(); + _inputPollingStatus = + $"LAST {snapshot.EffectiveHz:0} Hz // TARGET {snapshot.TargetHz} Hz // " + + $"LATE {snapshot.LateWakeupCount:N0} // DROPPED {snapshot.DroppedEventCount:N0}"; + Debug.Log($"[ProjectQuint Input Polling] {_inputPollingStatus}"); + _nativeInputPoller.Stop(); + } + if (_laneActions == null) + return; + foreach (var action in _laneActions) + { + action?.Disable(); + action?.Dispose(); + } + _laneActions = null; + } + + private void OnNativeLaneTransition( + int lane, + bool pressed, + double inputEventTime, + double currentInputTime) + { + if (pressed) + HandleLanePressed(lane, inputEventTime, currentInputTime); + else + HandleLaneReleased(lane, inputEventTime, currentInputTime); + } + + private void OnLanePressed(int lane, double inputEventTime) + => HandleLanePressed(lane, inputEventTime, InputState.currentTime); + + private void HandleLanePressed(int lane, double inputEventTime, double currentInputTime) + { + if (_mode != AppMode.Gameplay) + return; + + var callbackTimestamp = _gameplayDiagnosticsEnabled + ? GameplayWorkloadMonitor.Timestamp() + : 0; + var result = _gameplaySession.Press( + lane, + inputEventTime, + RecordInputTransition(lane, true, inputEventTime, currentInputTime), + CaptureGameplayClock(), + _judgementOffsetMilliseconds); + if (result.HasValue) + { + if (_gameplayDiagnosticsEnabled) + { + _inputMonitor.RecordJudgementDispatch( + callbackTimestamp, + GameplayWorkloadMonitor.Timestamp()); + _inputMonitor.QueueVisualFeedback(callbackTimestamp); + } + } + if (_reactiveKeySoundsEnabled && + _gameplaySession.TryResolvePressedKeySound(lane, result, out var soundId)) + { + if (_audio != null) + { + _audio.PlayKeySound(soundId, lane); + if (_gameplayDiagnosticsEnabled) + { + _inputMonitor.RecordReactiveAudioCommand( + callbackTimestamp, + GameplayWorkloadMonitor.Timestamp()); + } + } + } + } + + private void OnLaneReleased(int lane, double inputEventTime) + => HandleLaneReleased(lane, inputEventTime, InputState.currentTime); + + private void HandleLaneReleased(int lane, double inputEventTime, double currentInputTime) + { + if (_mode != AppMode.Gameplay) + return; + + // Tap calibration shifts note-on judgements only. Long-note tails have no + // timing grade, so applying the -25 ms tap offset made releases needlessly strict. + var callbackTimestamp = _gameplayDiagnosticsEnabled + ? GameplayWorkloadMonitor.Timestamp() + : 0; + var result = _gameplaySession.Release( + lane, + inputEventTime, + RecordInputTransition(lane, false, inputEventTime, currentInputTime), + CaptureGameplayClock()); + if (result.HasValue) + { + if (_gameplayDiagnosticsEnabled) + { + _inputMonitor.RecordJudgementDispatch( + callbackTimestamp, + GameplayWorkloadMonitor.Timestamp()); + _inputMonitor.QueueVisualFeedback(callbackTimestamp); + } + } + } + + private double RecordInputTransition( + int lane, + bool pressed, + double inputEventTime, + double currentInputTime) + { + // Keep InputState.currentTime as the judgement anchor. The diagnostic queue age + // uses realtime because InputAction timestamps share that timeline and Unity's + // own Input Debugger measures event age against realtimeSinceStartupAsDouble. + if (_gameplayDiagnosticsEnabled) + { + _inputMonitor.RecordTransition( + lane, + pressed, + inputEventTime, + Time.realtimeSinceStartupAsDouble, + Time.frameCount); + } + return currentInputTime; + } + + private void LogInputDiagnostics(string reason) + { + if (!_gameplayDiagnosticsEnabled) + return; + var snapshot = _inputMonitor.CaptureSnapshot(); + if (snapshot.EventCount <= 0) + return; + + Debug.Log( + $"[ProjectQuint Input] {reason}; device '{snapshot.DeviceName}', " + + $"events {snapshot.EventCount} ({snapshot.PressCount} press / " + + $"{snapshot.ReleaseCount} release), interval avg " + + $"{snapshot.AverageIntervalMilliseconds:0.###} ms, p50 " + + $"{snapshot.P50IntervalMilliseconds:0.###} ms, p95 " + + $"{snapshot.P95IntervalMilliseconds:0.###} ms, p99 " + + $"{snapshot.P99IntervalMilliseconds:0.###} ms, input queue avg " + + $"{snapshot.AverageQueueDelayMilliseconds:0.###} ms, p50/p95/p99/max " + + $"{snapshot.P50QueueDelayMilliseconds:0.###}/" + + $"{snapshot.P95QueueDelayMilliseconds:0.###}/" + + $"{snapshot.P99QueueDelayMilliseconds:0.###}/" + + $"{snapshot.MaximumQueueDelayMilliseconds:0.###} ms, pipeline " + + $"p50/p95/p99/max ms: judge {FormatInputPhase(snapshot.JudgementDispatchLatency)}, " + + $"reactive-audio {FormatInputPhase(snapshot.ReactiveAudioCommandLatency)}, " + + $"visual {FormatInputPhase(snapshot.VisualFeedbackLatency)}, visual drops " + + $"{snapshot.DroppedVisualFeedbackSamples}, max events/frame " + + $"{snapshot.MaximumEventsPerFrame}, out-of-order " + + $"{snapshot.OutOfOrderCount}, invalid transitions " + + $"{snapshot.InvalidTransitionCount}. This is observed event timing, not a hardware polling-rate setting."); + } + + private GameplayClockSample CaptureGameplayClock() => + new GameplayClockSample( + (_audio?.Clock ?? _audioClock)?.CurrentTime ?? 0, + _audio?.StartDspTime ?? 0, + Time.realtimeSinceStartupAsDouble, + _audioOutputLatencyMilliseconds); + + private void RefreshLibraryThumbnail() + { + var entry = _libraryController.State.SelectedSong; + if (entry == null) + { + ClearLibraryThumbnail(); + return; + } + + if (string.Equals(_thumbnailChartPath, entry.ChartPath, StringComparison.Ordinal)) + return; + + _thumbnailChartPath = entry.ChartPath; + var loadVersion = ++_thumbnailLoadVersion; + if (_libraryThumbnail != null) + { + Destroy(_libraryThumbnail); + _libraryThumbnail = null; + } + + var declared = !string.IsNullOrWhiteSpace(entry.ThumbnailFile) + ? entry.ThumbnailFile + : !string.IsNullOrWhiteSpace(entry.Metadata.StageFile) + ? entry.Metadata.StageFile + : entry.Metadata.BackBmp; + var imagePath = BmsResourceResolver.ResolveImage(entry.ChartPath, declared); + if (!string.IsNullOrEmpty(imagePath)) + StartCoroutine(LoadLibraryThumbnail(imagePath, loadVersion)); + } + + private IEnumerator LoadLibraryThumbnail(string imagePath, int loadVersion) + { + Texture2D loadedTexture = null; + yield return BmsImageLoader.Load(imagePath, texture => loadedTexture = texture); + if (loadVersion != _thumbnailLoadVersion) + { + BmsImageLoader.DestroyTexture(loadedTexture); + yield break; + } + + _libraryThumbnail = loadedTexture; + } + + private void ClearLibraryThumbnail() + { + _thumbnailLoadVersion++; + _thumbnailChartPath = string.Empty; + if (_libraryThumbnail == null) + return; + Destroy(_libraryThumbnail); + _libraryThumbnail = null; + } + + private void ClearLoadedSongState() + { + _songLoadGeneration++; + _audio?.Resume(); + DisableLaneInput(); + _preparedSong?.Dispose(); + _preparedSong = null; + _audio = null; + _bga = null; + _stageTexture = null; + _loadingEntry = null; + _playingEntry = null; + _gameplaySession.Clear(); + _resultSession.Clear(); + _gameplayNoteWindow.Reset(null); + _gameplayPlayfieldPresenter?.SetVisible(false); + _gameplayHudPresenter?.SetVisible(false); + } + + private void ReturnToLibrary() + { + ClearLoadedSongState(); + _courseSession.ClearRun(); + _mode = AppMode.Library; + RefreshLibraryThumbnail(); + } + + private void ReturnToCourseSelect() + { + ClearLoadedSongState(); + _courseSession.ClearRun(); + _mode = AppMode.CourseSelect; + } + + private void SetError(string title, string detail) + { + _audio?.Resume(); + DisableLaneInput(); + _audio?.Stop(); + _bga?.Stop(); + _flow.ShowError(title, detail); + Debug.LogError($"[ProjectQuint] {title}: {detail}"); + } + + private void OnApplicationFocus(bool hasFocus) + { + if (_nativeInputPoller?.IsRunning != true) + return; + if (!hasFocus) + { + _nativeInputPoller.Suspend(); + return; + } + if (_mode != AppMode.Gameplay) + return; + var physicallyHeld = _nativeInputPoller.ResumeAndCaptureHeldLanes(); + _gameplaySession?.Resume(CaptureGameplayClock(), physicallyHeld); + } + + private void OnDestroy() + { + CloseAudioSyncCalibration(); + FlushPendingLibraryViewState(force: true); + _audio?.Resume(); + _scanCancellation?.Cancel(); + _scanCancellation?.Dispose(); + _packImportCancellation?.Cancel(); + _packImportCancellation?.Dispose(); + _pinnedPackCacheCancellation?.Cancel(); + _pinnedPackCacheCancellation?.Dispose(); + DisableLaneInput(); + if (_gameplaySession != null) + { + _gameplaySession.Judged -= OnJudged; + _gameplaySession.AutoPlayNotePressed -= OnAutoPlayNotePressed; + _gameplaySession.Dispose(); + } + _preparedSong?.Dispose(); + _preparedSong = null; + _audio = null; + if (_audioBackendFactory is IDisposable disposableAudioBackendFactory) + disposableAudioBackendFactory.Dispose(); + _audioBackendFactory = null; + _audioClock = null; + _audioDspBufferController?.Dispose(); + _audioDspBufferController = null; + _nativeInputPoller?.Dispose(); + _nativeInputPoller = null; + _bga = null; + _stageTexture = null; + _homeScreenPresenter?.Dispose(); + _homeScreenPresenter = null; + _updateNoticePresenter?.Dispose(); + _updateNoticePresenter = null; + _sparkleUpdater?.Dispose(); + _sparkleUpdater = null; + _packMusicScreenPresenter?.Dispose(); + _packMusicScreenPresenter = null; + _optionsProfileCoursePresenter?.Dispose(); + _optionsProfileCoursePresenter = null; + if (_profileImageTexture != null) + Destroy(_profileImageTexture); + _profileImageTexture = null; + _sessionScreensPresenter?.Dispose(); + _sessionScreensPresenter = null; + _fpsCounterPresenter?.Dispose(); + _fpsCounterPresenter = null; + _gameplayHudPresenter?.Dispose(); + _gameplayHudPresenter = null; + _gameplayPlayfieldPresenter?.Dispose(); + _gameplayPlayfieldPresenter = null; + _startupBrandingPresenter?.Dispose(); + _startupBrandingPresenter = null; + if (_settingsRepository != null && _settings != null) + PersistSettings(); + ClearLibraryThumbnail(); + s_Created = false; + } + } +} diff --git a/Assets/ProjectQuint/Runtime/Unity/Bootstrap/ProjectQuintApp.cs.meta b/Assets/ProjectQuint/Runtime/Unity/Bootstrap/ProjectQuintApp.cs.meta new file mode 100644 index 0000000..3366617 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Bootstrap/ProjectQuintApp.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: bbc67d4ac70d6498d9a2abb194bba739 \ No newline at end of file diff --git a/Assets/ProjectQuint/Runtime/Unity/Bootstrap/ProjectQuintSparkleUpdater.cs b/Assets/ProjectQuint/Runtime/Unity/Bootstrap/ProjectQuintSparkleUpdater.cs new file mode 100644 index 0000000..ce88832 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Bootstrap/ProjectQuintSparkleUpdater.cs @@ -0,0 +1,137 @@ +using System; +using System.Runtime.InteropServices; +using UnityEngine; + +namespace ProjectQuint.Bootstrap +{ + internal sealed class ProjectQuintSparkleUpdater : IDisposable + { + private const string NativeLibrary = "ProjectQuintUpdater"; + private bool _initialized; + + public bool IsReady => _initialized && CanCheckSafely(); + + public bool TryInitialize(out string error) + { + error = string.Empty; + if (!IsSupportedRuntime(Application.platform, Application.isEditor)) + { + error = "Sparkle is available only in the built macOS application."; + return false; + } + + try + { + _initialized = PQUpdaterInitialize(); + if (!_initialized) + error = ReadNativeError("Sparkle updater could not be initialized."); + return _initialized; + } + catch (Exception exception) when ( + exception is DllNotFoundException || + exception is EntryPointNotFoundException || + exception is BadImageFormatException) + { + error = exception.Message; + _initialized = false; + return false; + } + } + + public bool TryCheckForUpdates(out string error) + { + error = string.Empty; + if (!_initialized) + { + error = "Sparkle updater is not ready."; + return false; + } + + try + { + if (!PQUpdaterCanCheckForUpdates()) + { + error = ReadNativeError("Sparkle is already checking for an update."); + return false; + } + if (!PQUpdaterCheckForUpdates()) + { + error = ReadNativeError("Sparkle could not start the update."); + return false; + } + return true; + } + catch (Exception exception) when ( + exception is DllNotFoundException || + exception is EntryPointNotFoundException || + exception is BadImageFormatException) + { + error = exception.Message; + _initialized = false; + return false; + } + } + + public void Dispose() + { + if (!_initialized) + return; + try + { + PQUpdaterShutdown(); + } + catch (Exception exception) when ( + exception is DllNotFoundException || + exception is EntryPointNotFoundException || + exception is BadImageFormatException) + { + Debug.LogWarning($"[ProjectQuint] Sparkle shutdown fallback: {exception.Message}"); + } + _initialized = false; + } + + internal static bool IsSupportedRuntime(RuntimePlatform platform, bool isEditor) => + !isEditor && platform == RuntimePlatform.OSXPlayer; + + private bool CanCheckSafely() + { + try + { + return PQUpdaterCanCheckForUpdates(); + } + catch (Exception exception) when ( + exception is DllNotFoundException || + exception is EntryPointNotFoundException || + exception is BadImageFormatException) + { + _initialized = false; + return false; + } + } + + private static string ReadNativeError(string fallback) + { + var pointer = PQUpdaterGetLastError(); + var message = pointer == IntPtr.Zero ? string.Empty : Marshal.PtrToStringUTF8(pointer); + return string.IsNullOrWhiteSpace(message) ? fallback : message; + } + + [DllImport(NativeLibrary, CallingConvention = CallingConvention.Cdecl)] + [return: MarshalAs(UnmanagedType.I1)] + private static extern bool PQUpdaterInitialize(); + + [DllImport(NativeLibrary, CallingConvention = CallingConvention.Cdecl)] + [return: MarshalAs(UnmanagedType.I1)] + private static extern bool PQUpdaterCanCheckForUpdates(); + + [DllImport(NativeLibrary, CallingConvention = CallingConvention.Cdecl)] + [return: MarshalAs(UnmanagedType.I1)] + private static extern bool PQUpdaterCheckForUpdates(); + + [DllImport(NativeLibrary, CallingConvention = CallingConvention.Cdecl)] + private static extern void PQUpdaterShutdown(); + + [DllImport(NativeLibrary, CallingConvention = CallingConvention.Cdecl)] + private static extern IntPtr PQUpdaterGetLastError(); + } +} diff --git a/Assets/ProjectQuint/Runtime/Unity/Bootstrap/ProjectQuintSparkleUpdater.cs.meta b/Assets/ProjectQuint/Runtime/Unity/Bootstrap/ProjectQuintSparkleUpdater.cs.meta new file mode 100644 index 0000000..b37cdaf --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Bootstrap/ProjectQuintSparkleUpdater.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4e2085009a0f0439fbb15066dcaedd70 \ No newline at end of file diff --git a/Assets/ProjectQuint/Runtime/Unity/Bootstrap/ProjectQuintUpdateService.cs b/Assets/ProjectQuint/Runtime/Unity/Bootstrap/ProjectQuintUpdateService.cs new file mode 100644 index 0000000..c8de287 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Bootstrap/ProjectQuintUpdateService.cs @@ -0,0 +1,212 @@ +using System; +using System.Collections; +using System.Globalization; +using System.Text.RegularExpressions; +using ProjectQuint.Core.Release; +using UnityEngine; +using UnityEngine.Networking; + +namespace ProjectQuint.Bootstrap +{ + [Serializable] + public sealed class ProjectQuintUpdateManifest + { + public int schemaVersion; + public string channel = string.Empty; + public bool available; + public string version = string.Empty; + public int buildNumber; + public string publishedAt = string.Empty; + public string downloadUrl = string.Empty; + public string notesUrl = string.Empty; + public string sha256 = string.Empty; + public long sizeBytes; + } + + public readonly struct ProjectQuintUpdateCheckResult + { + public ProjectQuintUpdateCheckResult( + bool succeeded, + ProjectQuintUpdateManifest manifest, + string error) + { + Succeeded = succeeded; + Manifest = manifest; + Error = error ?? string.Empty; + } + + public bool Succeeded { get; } + public ProjectQuintUpdateManifest Manifest { get; } + public string Error { get; } + } + + public static class ProjectQuintUpdateService + { + public const string ManifestUrl = + "https://projectquint.duckdns.org/updates/latest.json"; + public const string DownloadPageUrl = + "https://projectquint.duckdns.org/download/"; + public const string ExpectedHost = "projectquint.duckdns.org"; + public static readonly TimeSpan AutomaticCheckInterval = TimeSpan.FromHours(24); + + private static readonly Regex AlphaVersionPattern = new Regex( + @"^\d+\.\d+\.\d+-alpha\.\d+$", + RegexOptions.CultureInvariant); + private static readonly Regex Sha256Pattern = new Regex( + "^[0-9a-fA-F]{64}$", + RegexOptions.CultureInvariant); + + public static IEnumerator Check( + Action completed) + { + if (completed == null) + throw new ArgumentNullException(nameof(completed)); + + using var request = UnityWebRequest.Get(ManifestUrl); + request.timeout = 10; + request.SetRequestHeader("Accept", "application/json"); + yield return request.SendWebRequest(); + + if (request.result != UnityWebRequest.Result.Success) + { + completed(new ProjectQuintUpdateCheckResult( + false, + null, + string.IsNullOrWhiteSpace(request.error) + ? "UPDATE SERVER UNAVAILABLE" + : request.error)); + yield break; + } + + if (!TryParseManifest(request.downloadHandler.text, out var manifest, out var error)) + { + completed(new ProjectQuintUpdateCheckResult(false, null, error)); + yield break; + } + + completed(new ProjectQuintUpdateCheckResult(true, manifest, string.Empty)); + } + + public static bool TryParseManifest( + string json, + out ProjectQuintUpdateManifest manifest, + out string error) + { + manifest = null; + error = string.Empty; + if (string.IsNullOrWhiteSpace(json)) + return Fail("UPDATE MANIFEST IS EMPTY", out error); + + try + { + manifest = JsonUtility.FromJson(json); + } + catch (Exception exception) + { + return Fail($"UPDATE MANIFEST JSON IS INVALID: {exception.Message}", out error); + } + + if (manifest == null) + return Fail("UPDATE MANIFEST JSON IS INVALID", out error); + if (manifest.schemaVersion != 1) + return Fail("UPDATE MANIFEST SCHEMA IS UNSUPPORTED", out error); + if (!string.Equals( + manifest.channel, + ProjectQuintReleaseVersion.ReleaseChannel, + StringComparison.Ordinal)) + return Fail("UPDATE CHANNEL DOES NOT MATCH THIS BUILD", out error); + + if (!manifest.available) + return true; + if (!AlphaVersionPattern.IsMatch(manifest.version ?? string.Empty)) + return Fail("UPDATE VERSION IS INVALID", out error); + if (manifest.buildNumber <= 0) + return Fail("UPDATE BUILD NUMBER IS INVALID", out error); + if (!DateTimeOffset.TryParse( + manifest.publishedAt, + CultureInfo.InvariantCulture, + DateTimeStyles.RoundtripKind, + out _)) + return Fail("UPDATE PUBLISH DATE IS INVALID", out error); + if (!TryValidateProjectQuintUri( + manifest.downloadUrl, + requireDownloadPath: true, + out _)) + return Fail("UPDATE DOWNLOAD URL IS NOT TRUSTED", out error); + if (!TryValidateNotesUrl(manifest.notesUrl)) + return Fail("UPDATE NOTES URL IS NOT TRUSTED", out error); + if (!Sha256Pattern.IsMatch(manifest.sha256 ?? string.Empty)) + return Fail("UPDATE SHA-256 IS INVALID", out error); + if (manifest.sizeBytes < 1024 * 1024 || + manifest.sizeBytes > 1024L * 1024L * 1024L) + return Fail("UPDATE FILE SIZE IS INVALID", out error); + + manifest.version = manifest.version.Trim(); + manifest.sha256 = manifest.sha256.ToLowerInvariant(); + return true; + } + + public static bool IsUpdateAvailable(ProjectQuintUpdateManifest manifest) => + manifest != null && + manifest.available && + manifest.buildNumber > ProjectQuintReleaseVersion.BuildNumber; + + public static bool ShouldOffer( + ProjectQuintUpdateManifest manifest, + string skippedVersion, + bool manual) => + IsUpdateAvailable(manifest) && + (manual || + !string.Equals( + manifest.version, + skippedVersion?.Trim(), + StringComparison.OrdinalIgnoreCase)); + + public static bool ShouldCheckAutomatically(long lastSuccessfulCheckUtcTicks, long nowUtcTicks) + { + if (lastSuccessfulCheckUtcTicks <= 0 || nowUtcTicks <= lastSuccessfulCheckUtcTicks) + return true; + + return new TimeSpan(nowUtcTicks - lastSuccessfulCheckUtcTicks) >= + AutomaticCheckInterval; + } + + private static bool TryValidateNotesUrl(string value) + { + if (string.IsNullOrWhiteSpace(value)) + return false; + if (value.StartsWith("/", StringComparison.Ordinal) && + !value.StartsWith("//", StringComparison.Ordinal)) + return true; + return TryValidateProjectQuintUri(value, requireDownloadPath: false, out _); + } + + private static bool TryValidateProjectQuintUri( + string value, + bool requireDownloadPath, + out Uri uri) + { + uri = null; + if (!Uri.TryCreate(value, UriKind.Absolute, out var parsed) || + !string.Equals(parsed.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) || + !string.Equals(parsed.Host, ExpectedHost, StringComparison.OrdinalIgnoreCase) || + !parsed.IsDefaultPort || + !string.IsNullOrEmpty(parsed.UserInfo)) + return false; + + if (requireDownloadPath && + (!parsed.AbsolutePath.StartsWith("/downloads/", StringComparison.Ordinal) || + !parsed.AbsolutePath.EndsWith(".dmg", StringComparison.OrdinalIgnoreCase))) + return false; + + uri = parsed; + return true; + } + + private static bool Fail(string message, out string error) + { + error = message; + return false; + } + } +} diff --git a/Assets/ProjectQuint/Runtime/Unity/Bootstrap/ProjectQuintUpdateService.cs.meta b/Assets/ProjectQuint/Runtime/Unity/Bootstrap/ProjectQuintUpdateService.cs.meta new file mode 100644 index 0000000..9bc2043 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Bootstrap/ProjectQuintUpdateService.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 0a88f05bf2db44482bf8558fdbffd3e0 \ No newline at end of file diff --git a/Assets/ProjectQuint/Runtime/Unity/Bootstrap/StandaloneProfileOptions.cs b/Assets/ProjectQuint/Runtime/Unity/Bootstrap/StandaloneProfileOptions.cs new file mode 100644 index 0000000..370b729 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Bootstrap/StandaloneProfileOptions.cs @@ -0,0 +1,159 @@ +using System; +using ProjectQuint.Settings; + +namespace ProjectQuint.Bootstrap +{ + internal readonly struct StandaloneProfileOptions + { + internal const string ChartArgument = "-projectquint-profile-chart"; + internal const string DurationArgument = "-projectquint-profile-seconds"; + internal const string VSyncArgument = "-projectquint-profile-vsync"; + internal const string BgaArgument = "-projectquint-profile-bga"; + internal const string WindowArgument = "-projectquint-profile-window"; + internal const double DefaultDurationSeconds = 48.0; + + public StandaloneProfileOptions( + string chartPath, + double durationSeconds, + bool? vSyncEnabled = null, + BgaPlaybackMode? bgaPlayback = null, + WindowMode? windowMode = null) + { + ChartPath = chartPath ?? string.Empty; + DurationSeconds = durationSeconds; + VSyncEnabled = vSyncEnabled; + BgaPlayback = bgaPlayback; + WindowMode = windowMode; + } + + public string ChartPath { get; } + public double DurationSeconds { get; } + public bool? VSyncEnabled { get; } + public BgaPlaybackMode? BgaPlayback { get; } + public WindowMode? WindowMode { get; } + public bool Enabled => !string.IsNullOrWhiteSpace(ChartPath); + + public static bool TryParse( + string[] arguments, + out StandaloneProfileOptions options, + out string error) + { + options = default; + error = string.Empty; + if (arguments == null || arguments.Length == 0) + return true; + + var chartPath = string.Empty; + var durationSeconds = DefaultDurationSeconds; + bool? vSyncEnabled = null; + BgaPlaybackMode? bgaPlayback = null; + WindowMode? windowMode = null; + for (var index = 0; index < arguments.Length; index++) + { + var argument = arguments[index]; + if (string.Equals(argument, ChartArgument, StringComparison.OrdinalIgnoreCase)) + { + if (++index >= arguments.Length || string.IsNullOrWhiteSpace(arguments[index])) + { + error = $"{ChartArgument} requires a BMS chart path."; + return false; + } + chartPath = arguments[index]; + continue; + } + + if (string.Equals(argument, VSyncArgument, StringComparison.OrdinalIgnoreCase)) + { + if (++index >= arguments.Length || + !TryParseToggle(arguments[index], out var parsedVSync)) + { + error = $"{VSyncArgument} must be 'on' or 'off'."; + return false; + } + vSyncEnabled = parsedVSync; + continue; + } + + if (string.Equals(argument, BgaArgument, StringComparison.OrdinalIgnoreCase)) + { + if (++index >= arguments.Length || + !TryParseBga(arguments[index], out var parsedBga)) + { + error = $"{BgaArgument} must be 'full', 'static', or 'black'."; + return false; + } + bgaPlayback = parsedBga; + continue; + } + + if (string.Equals(argument, WindowArgument, StringComparison.OrdinalIgnoreCase)) + { + if (++index >= arguments.Length || + !TryParseWindow(arguments[index], out var parsedWindow)) + { + error = $"{WindowArgument} must be 'fullscreen' or 'windowed'."; + return false; + } + windowMode = parsedWindow; + continue; + } + + if (!string.Equals(argument, DurationArgument, StringComparison.OrdinalIgnoreCase)) + continue; + if (++index >= arguments.Length || + !double.TryParse( + arguments[index], + System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, + out durationSeconds) || + durationSeconds < 6.0 || durationSeconds > 600.0) + { + error = $"{DurationArgument} must be between 6 and 600 seconds."; + return false; + } + } + + options = new StandaloneProfileOptions( + chartPath, + durationSeconds, + vSyncEnabled, + bgaPlayback, + windowMode); + return true; + } + + private static bool TryParseToggle(string value, out bool enabled) + { + if (string.Equals(value, "on", StringComparison.OrdinalIgnoreCase)) + { + enabled = true; + return true; + } + if (string.Equals(value, "off", StringComparison.OrdinalIgnoreCase)) + { + enabled = false; + return true; + } + enabled = false; + return false; + } + + private static bool TryParseBga(string value, out BgaPlaybackMode mode) + { + if (Enum.TryParse(value, true, out mode) && + Enum.IsDefined(typeof(BgaPlaybackMode), mode)) + return true; + mode = default; + return false; + } + + private static bool TryParseWindow(string value, out WindowMode mode) + { + if (Enum.TryParse(value, true, out mode) && + Enum.IsDefined(typeof(WindowMode), mode)) + return true; + mode = default; + return false; + } + } +} diff --git a/Assets/ProjectQuint/Runtime/Unity/Bootstrap/StandaloneProfileOptions.cs.meta b/Assets/ProjectQuint/Runtime/Unity/Bootstrap/StandaloneProfileOptions.cs.meta new file mode 100644 index 0000000..ef77216 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Bootstrap/StandaloneProfileOptions.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4bc596ece525d4e5e82487fb62e0e38f \ No newline at end of file diff --git a/Assets/ProjectQuint/Runtime/Unity/Features.meta b/Assets/ProjectQuint/Runtime/Unity/Features.meta new file mode 100644 index 0000000..f376132 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Features.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 7416e3e446dc044e1a08a8720780fe39 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/ProjectQuint/Runtime/Unity/Features/AssemblyInfo.cs b/Assets/ProjectQuint/Runtime/Unity/Features/AssemblyInfo.cs new file mode 100644 index 0000000..12d104f --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Features/AssemblyInfo.cs @@ -0,0 +1,5 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("ProjectQuint.Bootstrap")] +[assembly: InternalsVisibleTo("ProjectQuint.Presentation")] +[assembly: InternalsVisibleTo("ProjectQuint.Tests.EditMode")] diff --git a/Assets/ProjectQuint/Runtime/Unity/Features/AssemblyInfo.cs.meta b/Assets/ProjectQuint/Runtime/Unity/Features/AssemblyInfo.cs.meta new file mode 100644 index 0000000..ec1a36e --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Features/AssemblyInfo.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 77f06534c4f3b4fa8836f1b805ba368c \ No newline at end of file diff --git a/Assets/ProjectQuint/Runtime/Unity/Features/Course.meta b/Assets/ProjectQuint/Runtime/Unity/Features/Course.meta new file mode 100644 index 0000000..f537d5b --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Features/Course.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 5f7e08a14be014a85ae8de300614d18b +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/ProjectQuint/Runtime/Unity/Features/Course/CourseSessionController.cs b/Assets/ProjectQuint/Runtime/Unity/Features/Course/CourseSessionController.cs new file mode 100644 index 0000000..e02e104 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Features/Course/CourseSessionController.cs @@ -0,0 +1,123 @@ +using System; +using System.Collections.Generic; +using ProjectQuint.Core.Course; +using ProjectQuint.Core.Gauge; +using ProjectQuint.Core.Judgement; +using ProjectQuint.Library; + +namespace ProjectQuint.Features.Course +{ + /// + /// Owns the state that survives between stages of one BMSTable course run. + /// Stage-local chart, audio and BGA resources remain outside this controller. + /// + internal sealed class CourseSessionController + { + public AeryCourseDefinition ActiveCourse { get; private set; } + public CourseProgress Progress { get; private set; } + public AeryCourseTrophy Trophy { get; private set; } + public double GaugeValue { get; private set; } = PlayGauge.ClassInitialValue; + public int SelectedIndex { get; private set; } + public int StageIndex { get; private set; } + public int ResultSelection { get; set; } = 1; + public bool StageAccumulated { get; private set; } + public bool Passed { get; private set; } + public bool CertificationBadgeEarned { get; private set; } + public string Status { get; set; } = string.Empty; + private bool HadCertificationAtStart { get; set; } + public bool IsActive => ActiveCourse != null; + + public void Select(int requestedIndex, int courseCount) + { + SelectedIndex = ClampIndex(requestedIndex, courseCount); + } + + public void MoveSelection(int delta, int courseCount) + { + Select(SelectedIndex + delta, courseCount); + } + + public void Begin(AeryCourseDefinition course) + { + ActiveCourse = course ?? throw new ArgumentNullException(nameof(course)); + if (!course.IsPlayable) + throw new InvalidOperationException(course.UnavailableReason); + Progress = new CourseProgress(); + Trophy = null; + GaugeValue = PlayGauge.ClassInitialValue; + StageIndex = 0; + StageAccumulated = false; + Passed = false; + HadCertificationAtStart = course.Record?.Cleared == true; + CertificationBadgeEarned = false; + ResultSelection = 1; + } + + public bool TryBeginStage(int stageIndex, out SongLibraryEntry entry) + { + entry = null; + if (ActiveCourse == null || !ActiveCourse.IsPlayable || + stageIndex < 0 || stageIndex >= ActiveCourse.StageCount) + return false; + + StageIndex = stageIndex; + StageAccumulated = false; + entry = ActiveCourse.Stages[stageIndex]; + return entry != null; + } + + public void CaptureGauge(double value) + { + GaugeValue = Math.Max(0.0, Math.Min(100.0, value)); + } + + public bool TryAccumulateStage(RhythmJudgementEngine judgement, bool cleared) + { + if (StageAccumulated || Progress == null || judgement == null) + return false; + + StageAccumulated = true; + Progress.AddStage(judgement, cleared); + return true; + } + + public AeryCourseResult Complete(bool passed, string modifier) + { + Passed = passed; + CertificationBadgeEarned = passed && !HadCertificationAtStart; + Trophy = passed + ? ActiveCourse?.ResolveTrophy( + Progress?.MissRate ?? 100.0, + Progress?.ExScoreRate ?? 0.0) + : null; + ResultSelection = 1; + return new AeryCourseResult( + passed, + StageIndex + 1, + Progress, + Trophy, + modifier); + } + + public void ClearRun() + { + ActiveCourse = null; + Progress = null; + Trophy = null; + GaugeValue = PlayGauge.ClassInitialValue; + StageIndex = 0; + StageAccumulated = false; + Passed = false; + HadCertificationAtStart = false; + CertificationBadgeEarned = false; + ResultSelection = 1; + } + + private static int ClampIndex(int requestedIndex, int count) + { + if (count <= 0) + return 0; + return Math.Max(0, Math.Min(count - 1, requestedIndex)); + } + } +} diff --git a/Assets/ProjectQuint/Runtime/Unity/Features/Course/CourseSessionController.cs.meta b/Assets/ProjectQuint/Runtime/Unity/Features/Course/CourseSessionController.cs.meta new file mode 100644 index 0000000..533ebbb --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Features/Course/CourseSessionController.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f475f77270de4458a8446485d0fb2dd2 \ No newline at end of file diff --git a/Assets/ProjectQuint/Runtime/Unity/Features/Flow.meta b/Assets/ProjectQuint/Runtime/Unity/Features/Flow.meta new file mode 100644 index 0000000..aac5d0d --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Features/Flow.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 7679479d3e559492ba25c0269c891213 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/ProjectQuint/Runtime/Unity/Features/Flow/AppFlowController.cs b/Assets/ProjectQuint/Runtime/Unity/Features/Flow/AppFlowController.cs new file mode 100644 index 0000000..4f1904b --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Features/Flow/AppFlowController.cs @@ -0,0 +1,121 @@ +using System; + +namespace ProjectQuint.Features.Flow +{ + public enum OptionsCategory + { + Gameplay, + Input, + Latency, + Audio, + Display, + Data, + } + + internal enum AppMode + { + StartupAttribution, + TeamDeniaBranding, + Home, + PackSelect, + PackImport, + DifficultyTableSetup, + Options, + Scanning, + Library, + CourseSelect, + QuickOptions, + Loading, + Gameplay, + Paused, + GameOver, + Results, + CourseResults, + Error, + } + + /// + /// Owns navigation and screen-local presentation state. Unity lifecycle code may request + /// transitions, but it no longer stores a second copy of the active screen or menu cursor. + /// + internal sealed class AppFlowController + { + public AppMode Mode { get; private set; } = AppMode.StartupAttribution; + public string Status { get; set; } = string.Empty; + public string ErrorTitle { get; private set; } = string.Empty; + public string ErrorDetail { get; private set; } = string.Empty; + + public static bool ShouldShowFpsCounter(AppMode mode, bool enabled) => + enabled && mode != AppMode.StartupAttribution && mode != AppMode.TeamDeniaBranding; + public string LoadingTitle { get; private set; } = string.Empty; + public string LoadingDetail { get; private set; } = string.Empty; + public int LoadingCurrent { get; private set; } + public int LoadingTotal { get; private set; } + public double LoadingEnteredRealtime { get; private set; } + public int HomeSelection { get; set; } + public int PackSelection { get; set; } + public OptionsCategory OptionsCategory { get; set; } = OptionsCategory.Gameplay; + public int OptionsSelectedRow { get; set; } + public int OptionsInputKeyCount { get; set; } = 4; + public int QuickOptionsSelectedRow { get; set; } + public int PauseSelection { get; set; } + public int GameOverSelection { get; set; } + public int RebindLane { get; set; } = -1; + + public void TransitionTo(AppMode mode) + { + Mode = mode; + } + + public void BeginOptions() + { + RebindLane = -1; + OptionsCategory = OptionsCategory.Gameplay; + OptionsSelectedRow = 0; + OptionsInputKeyCount = 4; + TransitionTo(AppMode.Options); + } + + public void BeginQuickOptions() + { + QuickOptionsSelectedRow = 0; + TransitionTo(AppMode.QuickOptions); + } + + public void BeginPause() + { + PauseSelection = 0; + TransitionTo(AppMode.Paused); + } + + public void BeginGameOver() + { + GameOverSelection = 0; + TransitionTo(AppMode.GameOver); + } + + public void BeginLoading(string title, string detail, double enteredRealtime = 0.0) + { + LoadingTitle = title ?? string.Empty; + LoadingDetail = detail ?? string.Empty; + LoadingCurrent = 0; + LoadingTotal = 0; + LoadingEnteredRealtime = Math.Max(0.0, enteredRealtime); + TransitionTo(AppMode.Loading); + } + + public void ReportLoading(string detail, int current, int total) + { + LoadingDetail = detail ?? string.Empty; + LoadingCurrent = current; + LoadingTotal = total; + } + + public void ShowError(string title, string detail) + { + ErrorTitle = title ?? string.Empty; + ErrorDetail = detail ?? string.Empty; + TransitionTo(AppMode.Error); + } + } +} diff --git a/Assets/ProjectQuint/Runtime/Unity/Features/Flow/AppFlowController.cs.meta b/Assets/ProjectQuint/Runtime/Unity/Features/Flow/AppFlowController.cs.meta new file mode 100644 index 0000000..119657d --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Features/Flow/AppFlowController.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 69bb1e435a6e34004acdfff2ae582e41 \ No newline at end of file diff --git a/Assets/ProjectQuint/Runtime/Unity/Features/Flow/DirectionalKeyRepeat.cs b/Assets/ProjectQuint/Runtime/Unity/Features/Flow/DirectionalKeyRepeat.cs new file mode 100644 index 0000000..3cf366e --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Features/Flow/DirectionalKeyRepeat.cs @@ -0,0 +1,61 @@ +using System; + +namespace ProjectQuint.Features.Flow +{ + public sealed class DirectionalKeyRepeat + { + private readonly double _initialDelaySeconds; + private readonly double _repeatIntervalSeconds; + private readonly int _maximumRepeatsPerUpdate; + private int _direction; + private double _nextRepeatTime; + + public DirectionalKeyRepeat( + double initialDelaySeconds = 0.28, + double repeatIntervalSeconds = 0.0125, + int maximumRepeatsPerUpdate = 16) + { + if (initialDelaySeconds < 0.0) + throw new ArgumentOutOfRangeException(nameof(initialDelaySeconds)); + if (repeatIntervalSeconds <= 0.0) + throw new ArgumentOutOfRangeException(nameof(repeatIntervalSeconds)); + if (maximumRepeatsPerUpdate <= 0) + throw new ArgumentOutOfRangeException(nameof(maximumRepeatsPerUpdate)); + + _initialDelaySeconds = initialDelaySeconds; + _repeatIntervalSeconds = repeatIntervalSeconds; + _maximumRepeatsPerUpdate = maximumRepeatsPerUpdate; + } + + public int Update(int direction, bool pressedThisFrame, double now) + { + direction = Math.Sign(direction); + if (direction == 0) + { + Reset(); + return 0; + } + + if (pressedThisFrame || direction != _direction) + { + _direction = direction; + _nextRepeatTime = now + _initialDelaySeconds; + return direction; + } + + if (now < _nextRepeatTime) + return 0; + + var repeats = 1 + (int)Math.Floor((now - _nextRepeatTime) / _repeatIntervalSeconds); + repeats = Math.Min(repeats, _maximumRepeatsPerUpdate); + _nextRepeatTime += repeats * _repeatIntervalSeconds; + return direction * repeats; + } + + public void Reset() + { + _direction = 0; + _nextRepeatTime = 0.0; + } + } +} diff --git a/Assets/ProjectQuint/Runtime/Unity/Features/Flow/DirectionalKeyRepeat.cs.meta b/Assets/ProjectQuint/Runtime/Unity/Features/Flow/DirectionalKeyRepeat.cs.meta new file mode 100644 index 0000000..7532f32 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Features/Flow/DirectionalKeyRepeat.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 81afde7d0602f459f8d1cf8d453387b5 \ No newline at end of file diff --git a/Assets/ProjectQuint/Runtime/Unity/Features/Flow/StartupAttributionTimeline.cs b/Assets/ProjectQuint/Runtime/Unity/Features/Flow/StartupAttributionTimeline.cs new file mode 100644 index 0000000..7913398 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Features/Flow/StartupAttributionTimeline.cs @@ -0,0 +1,72 @@ +using System; + +namespace ProjectQuint.Features.Flow +{ + /// + /// Defines the unskippable startup interval used for required middleware attribution. + /// Keeping the timing independent from rendering makes the legal splash deterministic. + /// + internal static class StartupAttributionTimeline + { + public const double FadeInSeconds = 0.35; + public const double HoldSeconds = 1.80; + public const double FadeOutSeconds = 0.45; + public const double DurationSeconds = FadeInSeconds + HoldSeconds + FadeOutSeconds; + + public static bool IsComplete(double elapsedSeconds) => + elapsedSeconds >= DurationSeconds; + + public static float OpacityAt(double elapsedSeconds) + { + var elapsed = Math.Max(0.0, elapsedSeconds); + if (elapsed < FadeInSeconds) + return SmoothStep((float)(elapsed / FadeInSeconds)); + + var fadeOutStart = FadeInSeconds + HoldSeconds; + if (elapsed <= fadeOutStart) + return 1f; + if (elapsed >= DurationSeconds) + return 0f; + + return 1f - SmoothStep((float)((elapsed - fadeOutStart) / FadeOutSeconds)); + } + + private static float SmoothStep(float value) + { + var clamped = Math.Max(0f, Math.Min(1f, value)); + return clamped * clamped * (3f - 2f * clamped); + } + } + + internal static class TeamDeniaBrandingTimeline + { + public const double FadeInSeconds = 0.30; + public const double HoldSeconds = 1.20; + public const double FadeOutSeconds = 0.35; + public const double DurationSeconds = FadeInSeconds + HoldSeconds + FadeOutSeconds; + + public static bool IsComplete(double elapsedSeconds) => + elapsedSeconds >= DurationSeconds; + + public static float OpacityAt(double elapsedSeconds) + { + var elapsed = Math.Max(0.0, elapsedSeconds); + if (elapsed < FadeInSeconds) + return SmoothStep((float)(elapsed / FadeInSeconds)); + + var fadeOutStart = FadeInSeconds + HoldSeconds; + if (elapsed <= fadeOutStart) + return 1f; + if (elapsed >= DurationSeconds) + return 0f; + + return 1f - SmoothStep((float)((elapsed - fadeOutStart) / FadeOutSeconds)); + } + + private static float SmoothStep(float value) + { + var clamped = Math.Max(0f, Math.Min(1f, value)); + return clamped * clamped * (3f - 2f * clamped); + } + } +} diff --git a/Assets/ProjectQuint/Runtime/Unity/Features/Flow/StartupAttributionTimeline.cs.meta b/Assets/ProjectQuint/Runtime/Unity/Features/Flow/StartupAttributionTimeline.cs.meta new file mode 100644 index 0000000..c0d8dc7 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Features/Flow/StartupAttributionTimeline.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 2e771a20dd9bc42ea84beb283e3b11ab \ No newline at end of file diff --git a/Assets/ProjectQuint/Runtime/Unity/Features/Gameplay.meta b/Assets/ProjectQuint/Runtime/Unity/Features/Gameplay.meta new file mode 100644 index 0000000..6a17402 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Features/Gameplay.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 204b7558ea2d248bc81cb1e94843272f +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/ProjectQuint/Runtime/Unity/Features/Gameplay/GameplayFramePacingMonitor.cs b/Assets/ProjectQuint/Runtime/Unity/Features/Gameplay/GameplayFramePacingMonitor.cs new file mode 100644 index 0000000..ec81c38 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Features/Gameplay/GameplayFramePacingMonitor.cs @@ -0,0 +1,481 @@ +using System; +using UnityEngine; + +namespace ProjectQuint.Features.Gameplay +{ + /// + /// Allocation-free rolling frame-time sampler sized for a two-second 240 Hz window. + /// A 50 ms-only hitch logger cannot see the 5-15 ms spikes that make high-refresh + /// rhythm gameplay look discontinuous, so this reports percentiles instead. + /// + internal sealed class GameplayFramePacingMonitor + { + internal const int SampleCapacity = 512; + internal const double ReportingWindowSeconds = 2.0; + internal const double DiscontinuityThresholdSeconds = 0.25; + private readonly double[] _frameMilliseconds = new double[SampleCapacity]; + private readonly double[] _sortedMilliseconds = new double[SampleCapacity]; + private int _sampleCount; + private double _lastRealtime = double.NegativeInfinity; + private double _windowStartedRealtime = double.NegativeInfinity; + private double _frameTotalMilliseconds; + private long _lastAllocatedBytes; + private long _maximumAllocatedBytes; + private int _missedRefreshFrameCount; + private int _estimatedMissedRefreshCount; + + public void Reset(double realtime, long allocatedBytes) + { + _sampleCount = 0; + _lastRealtime = realtime; + _windowStartedRealtime = realtime; + _frameTotalMilliseconds = 0; + _lastAllocatedBytes = allocatedBytes; + _maximumAllocatedBytes = 0; + _missedRefreshFrameCount = 0; + _estimatedMissedRefreshCount = 0; + } + + public bool AddSample( + double realtime, + long allocatedBytes, + int targetFramesPerSecond, + out GameplayFramePacingSnapshot snapshot) + { + snapshot = default; + if (double.IsNegativeInfinity(_lastRealtime)) + { + Reset(realtime, allocatedBytes); + return false; + } + + var frameMilliseconds = Math.Max(0, (realtime - _lastRealtime) * 1000.0); + _lastRealtime = realtime; + if (frameMilliseconds >= DiscontinuityThresholdSeconds * 1000.0) + { + Reset(realtime, allocatedBytes); + return false; + } + if (_sampleCount < SampleCapacity) + { + _frameMilliseconds[_sampleCount++] = frameMilliseconds; + _frameTotalMilliseconds += frameMilliseconds; + var sampleTarget = targetFramesPerSecond > 0 ? targetFramesPerSecond : 240; + var sampleBudget = 1000.0 / sampleTarget; + if (frameMilliseconds >= sampleBudget * 1.5) + { + _missedRefreshFrameCount++; + _estimatedMissedRefreshCount += Math.Max( + 1, + (int)Math.Round(frameMilliseconds / sampleBudget) - 1); + } + } + + if (allocatedBytes >= _lastAllocatedBytes) + _maximumAllocatedBytes = Math.Max(_maximumAllocatedBytes, allocatedBytes - _lastAllocatedBytes); + _lastAllocatedBytes = allocatedBytes; + + if (_sampleCount < SampleCapacity && + realtime - _windowStartedRealtime < ReportingWindowSeconds) + return false; + + Array.Copy(_frameMilliseconds, _sortedMilliseconds, _sampleCount); + Array.Sort(_sortedMilliseconds, 0, _sampleCount); + var averageMilliseconds = _sampleCount == 0 ? 0 : _frameTotalMilliseconds / _sampleCount; + var p50 = Percentile(_sortedMilliseconds, _sampleCount, 0.50); + var p95 = Percentile(_sortedMilliseconds, _sampleCount, 0.95); + var p99 = Percentile(_sortedMilliseconds, _sampleCount, 0.99); + var maximum = _sampleCount == 0 ? 0 : _sortedMilliseconds[_sampleCount - 1]; + var target = targetFramesPerSecond > 0 ? targetFramesPerSecond : 240; + var frameBudget = 1000.0 / target; + var warningThreshold = Math.Max(5.0, frameBudget * 1.5); + snapshot = new GameplayFramePacingSnapshot( + _sampleCount, + averageMilliseconds, + p50, + p95, + p99, + maximum, + _maximumAllocatedBytes, + frameBudget, + _missedRefreshFrameCount, + _estimatedMissedRefreshCount, + _missedRefreshFrameCount > 0 || p99 > warningThreshold); + + _sampleCount = 0; + _windowStartedRealtime = realtime; + _frameTotalMilliseconds = 0; + _maximumAllocatedBytes = 0; + _missedRefreshFrameCount = 0; + _estimatedMissedRefreshCount = 0; + return true; + } + + private static double Percentile(double[] sortedValues, int count, double percentile) + { + if (count <= 0) + return 0; + var index = (int)Math.Ceiling(percentile * count) - 1; + return sortedValues[Math.Max(0, Math.Min(count - 1, index))]; + } + } + + /// + /// Collects Unity's platform frame timings without allocating on gameplay frames. + /// The samples expose work and Present wait separately from Update-to-Update pacing. + /// + internal sealed class UnityFrameTimingMonitor + { + private readonly FrameTiming[] _latestTimings = new FrameTiming[1]; + private readonly GameplayPhaseHistogram _cpuFrame = new GameplayPhaseHistogram(); + private readonly GameplayPhaseHistogram _cpuMainThread = new GameplayPhaseHistogram(); + private readonly GameplayPhaseHistogram _cpuPresentWait = new GameplayPhaseHistogram(); + private readonly GameplayPhaseHistogram _gpuFrame = new GameplayPhaseHistogram(); + private ulong _lastFrameStartTimestamp; + private bool _hasFrameStartTimestamp; + private bool _featureEnabledObserved; + private float _vsyncsPerSecond; + private uint _latestSyncInterval; + + public void Reset() + { + ResetSamples(); + _lastFrameStartTimestamp = 0; + _hasFrameStartTimestamp = false; + _featureEnabledObserved = false; + _vsyncsPerSecond = 0f; + _latestSyncInterval = 0; + } + + public void CaptureLatest() + { + FrameTimingManager.CaptureFrameTimings(); + if (!FrameTimingManager.IsFeatureEnabled()) + return; + + _featureEnabledObserved = true; + _vsyncsPerSecond = FrameTimingManager.GetVSyncsPerSecond(); + if (float.IsNaN(_vsyncsPerSecond) || + float.IsInfinity(_vsyncsPerSecond) || + _vsyncsPerSecond < 0f) + _vsyncsPerSecond = 0f; + if (FrameTimingManager.GetLatestTimings(1, _latestTimings) == 0) + return; + AddSample(_latestTimings[0]); + } + + internal void AddSample(FrameTiming timing) + { + if (_hasFrameStartTimestamp && + timing.frameStartTimestamp == _lastFrameStartTimestamp) + return; + + _hasFrameStartTimestamp = true; + _lastFrameStartTimestamp = timing.frameStartTimestamp; + _latestSyncInterval = timing.syncInterval; + _cpuFrame.Add(timing.cpuFrameTime); + _cpuMainThread.Add(timing.cpuMainThreadFrameTime); + _cpuPresentWait.Add(timing.cpuMainThreadPresentWaitTime); + _gpuFrame.Add(timing.gpuFrameTime); + } + + public UnityFrameTimingSnapshot CaptureAndReset() + { + var snapshot = new UnityFrameTimingSnapshot( + _featureEnabledObserved, + _vsyncsPerSecond, + _latestSyncInterval, + _cpuFrame.Capture(), + _cpuMainThread.Capture(), + _cpuPresentWait.Capture(), + _gpuFrame.Capture()); + ResetSamples(); + return snapshot; + } + + private void ResetSamples() + { + _cpuFrame.Reset(); + _cpuMainThread.Reset(); + _cpuPresentWait.Reset(); + _gpuFrame.Reset(); + } + } + + internal enum GameplayWorkloadPhase + { + Audio, + Judgement, + Bga, + NoteWindow, + PlayfieldSubmit, + HudSubmit, + PlayfieldMesh, + } + + /// + /// Fixed-memory gameplay workload telemetry. Samples are accumulated into 0.01 ms + /// histogram buckets so the profiler itself never sorts or allocates on a play frame. + /// + internal sealed class GameplayWorkloadMonitor + { + private readonly GameplayPhaseHistogram[] _phases = + { + new GameplayPhaseHistogram(), + new GameplayPhaseHistogram(), + new GameplayPhaseHistogram(), + new GameplayPhaseHistogram(), + new GameplayPhaseHistogram(), + new GameplayPhaseHistogram(), + new GameplayPhaseHistogram(), + }; + private long _visibleNoteTotal; + private int _visibleNoteSamples; + private int _maximumVisibleNotes; + private long _meshVertexTotal; + private int _meshVertexSamples; + private int _maximumMeshVertices; + + public static long Timestamp() => System.Diagnostics.Stopwatch.GetTimestamp(); + + public void Record(GameplayWorkloadPhase phase, long startedTimestamp) + { + var elapsedMilliseconds = + (System.Diagnostics.Stopwatch.GetTimestamp() - startedTimestamp) * 1000.0 / + System.Diagnostics.Stopwatch.Frequency; + _phases[(int)phase].Add(elapsedMilliseconds); + } + + public void RecordDuration(GameplayWorkloadPhase phase, double elapsedMilliseconds) => + _phases[(int)phase].Add(elapsedMilliseconds); + + public void RecordVisibleNotes(int count) + { + count = Math.Max(0, count); + _visibleNoteTotal += count; + _visibleNoteSamples++; + _maximumVisibleNotes = Math.Max(_maximumVisibleNotes, count); + } + + public void RecordMeshVertices(int count) + { + count = Math.Max(0, count); + _meshVertexTotal += count; + _meshVertexSamples++; + _maximumMeshVertices = Math.Max(_maximumMeshVertices, count); + } + + public GameplayWorkloadSnapshot CaptureAndReset() + { + var snapshot = new GameplayWorkloadSnapshot( + _phases[(int)GameplayWorkloadPhase.Audio].Capture(), + _phases[(int)GameplayWorkloadPhase.Judgement].Capture(), + _phases[(int)GameplayWorkloadPhase.Bga].Capture(), + _phases[(int)GameplayWorkloadPhase.NoteWindow].Capture(), + _phases[(int)GameplayWorkloadPhase.PlayfieldSubmit].Capture(), + _phases[(int)GameplayWorkloadPhase.HudSubmit].Capture(), + _phases[(int)GameplayWorkloadPhase.PlayfieldMesh].Capture(), + _visibleNoteSamples == 0 ? 0 : _visibleNoteTotal / (double)_visibleNoteSamples, + _maximumVisibleNotes, + _meshVertexSamples == 0 ? 0 : _meshVertexTotal / (double)_meshVertexSamples, + _maximumMeshVertices); + foreach (var phase in _phases) + phase.Reset(); + _visibleNoteTotal = 0; + _visibleNoteSamples = 0; + _maximumVisibleNotes = 0; + _meshVertexTotal = 0; + _meshVertexSamples = 0; + _maximumMeshVertices = 0; + return snapshot; + } + } + + internal sealed class GameplayPhaseHistogram + { + internal const double BucketMilliseconds = 0.01; + internal const int BucketCount = 2002; + private readonly int[] _histogram = new int[BucketCount]; + private int _count; + private double _totalMilliseconds; + private double _maximumMilliseconds; + + public void Add(double milliseconds) + { + milliseconds = Math.Max(0, milliseconds); + var bucket = Math.Min(BucketCount - 1, (int)(milliseconds / BucketMilliseconds)); + _histogram[bucket]++; + _count++; + _totalMilliseconds += milliseconds; + _maximumMilliseconds = Math.Max(_maximumMilliseconds, milliseconds); + } + + public GameplayPhaseTimingSnapshot Capture() => new GameplayPhaseTimingSnapshot( + _count, + _count == 0 ? 0 : _totalMilliseconds / _count, + Percentile(0.50), + Percentile(0.95), + Percentile(0.99), + _maximumMilliseconds); + + public void Reset() + { + Array.Clear(_histogram, 0, _histogram.Length); + _count = 0; + _totalMilliseconds = 0; + _maximumMilliseconds = 0; + } + + private double Percentile(double percentile) + { + if (_count == 0) + return 0; + var target = (int)Math.Ceiling(_count * percentile); + var accumulated = 0; + for (var bucket = 0; bucket < _histogram.Length; bucket++) + { + accumulated += _histogram[bucket]; + if (accumulated >= target) + return bucket * BucketMilliseconds; + } + return (BucketCount - 1) * BucketMilliseconds; + } + } + + internal readonly struct GameplayPhaseTimingSnapshot + { + public GameplayPhaseTimingSnapshot( + int sampleCount, + double averageMilliseconds, + double p50Milliseconds, + double p95Milliseconds, + double p99Milliseconds, + double maximumMilliseconds) + { + SampleCount = sampleCount; + AverageMilliseconds = averageMilliseconds; + P50Milliseconds = p50Milliseconds; + P95Milliseconds = p95Milliseconds; + P99Milliseconds = p99Milliseconds; + MaximumMilliseconds = maximumMilliseconds; + } + + public int SampleCount { get; } + public double AverageMilliseconds { get; } + public double P50Milliseconds { get; } + public double P95Milliseconds { get; } + public double P99Milliseconds { get; } + public double MaximumMilliseconds { get; } + } + + internal readonly struct GameplayWorkloadSnapshot + { + public GameplayWorkloadSnapshot( + GameplayPhaseTimingSnapshot audio, + GameplayPhaseTimingSnapshot judgement, + GameplayPhaseTimingSnapshot bga, + GameplayPhaseTimingSnapshot noteWindow, + GameplayPhaseTimingSnapshot playfieldSubmit, + GameplayPhaseTimingSnapshot hudSubmit, + GameplayPhaseTimingSnapshot playfieldMesh, + double averageVisibleNotes, + int maximumVisibleNotes, + double averageMeshVertices, + int maximumMeshVertices) + { + Audio = audio; + Judgement = judgement; + Bga = bga; + NoteWindow = noteWindow; + PlayfieldSubmit = playfieldSubmit; + HudSubmit = hudSubmit; + PlayfieldMesh = playfieldMesh; + AverageVisibleNotes = averageVisibleNotes; + MaximumVisibleNotes = maximumVisibleNotes; + AverageMeshVertices = averageMeshVertices; + MaximumMeshVertices = maximumMeshVertices; + } + + public GameplayPhaseTimingSnapshot Audio { get; } + public GameplayPhaseTimingSnapshot Judgement { get; } + public GameplayPhaseTimingSnapshot Bga { get; } + public GameplayPhaseTimingSnapshot NoteWindow { get; } + public GameplayPhaseTimingSnapshot PlayfieldSubmit { get; } + public GameplayPhaseTimingSnapshot HudSubmit { get; } + public GameplayPhaseTimingSnapshot PlayfieldMesh { get; } + public double AverageVisibleNotes { get; } + public int MaximumVisibleNotes { get; } + public double AverageMeshVertices { get; } + public int MaximumMeshVertices { get; } + } + + internal readonly struct GameplayFramePacingSnapshot + { + public GameplayFramePacingSnapshot( + int sampleCount, + double averageMilliseconds, + double p50Milliseconds, + double p95Milliseconds, + double p99Milliseconds, + double maximumMilliseconds, + long maximumAllocatedBytes, + double frameBudgetMilliseconds, + int missedRefreshFrameCount, + int estimatedMissedRefreshCount, + bool isUnstable) + { + SampleCount = sampleCount; + AverageMilliseconds = averageMilliseconds; + P50Milliseconds = p50Milliseconds; + P95Milliseconds = p95Milliseconds; + P99Milliseconds = p99Milliseconds; + MaximumMilliseconds = maximumMilliseconds; + MaximumAllocatedBytes = maximumAllocatedBytes; + FrameBudgetMilliseconds = frameBudgetMilliseconds; + MissedRefreshFrameCount = missedRefreshFrameCount; + EstimatedMissedRefreshCount = estimatedMissedRefreshCount; + IsUnstable = isUnstable; + } + + public int SampleCount { get; } + public double AverageMilliseconds { get; } + public double P50Milliseconds { get; } + public double P95Milliseconds { get; } + public double P99Milliseconds { get; } + public double MaximumMilliseconds { get; } + public long MaximumAllocatedBytes { get; } + public double FrameBudgetMilliseconds { get; } + public int MissedRefreshFrameCount { get; } + public int EstimatedMissedRefreshCount { get; } + public bool IsUnstable { get; } + } + + internal readonly struct UnityFrameTimingSnapshot + { + public UnityFrameTimingSnapshot( + bool featureEnabled, + float vsyncsPerSecond, + uint syncInterval, + GameplayPhaseTimingSnapshot cpuFrame, + GameplayPhaseTimingSnapshot cpuMainThread, + GameplayPhaseTimingSnapshot cpuPresentWait, + GameplayPhaseTimingSnapshot gpuFrame) + { + FeatureEnabled = featureEnabled; + VSyncsPerSecond = vsyncsPerSecond; + SyncInterval = syncInterval; + CpuFrame = cpuFrame; + CpuMainThread = cpuMainThread; + CpuPresentWait = cpuPresentWait; + GpuFrame = gpuFrame; + } + + public bool FeatureEnabled { get; } + public float VSyncsPerSecond { get; } + public uint SyncInterval { get; } + public GameplayPhaseTimingSnapshot CpuFrame { get; } + public GameplayPhaseTimingSnapshot CpuMainThread { get; } + public GameplayPhaseTimingSnapshot CpuPresentWait { get; } + public GameplayPhaseTimingSnapshot GpuFrame { get; } + } +} diff --git a/Assets/ProjectQuint/Runtime/Unity/Features/Gameplay/GameplayFramePacingMonitor.cs.meta b/Assets/ProjectQuint/Runtime/Unity/Features/Gameplay/GameplayFramePacingMonitor.cs.meta new file mode 100644 index 0000000..d02d3b9 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Features/Gameplay/GameplayFramePacingMonitor.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: db454f2fb72d74adaba344ce12fd9972 \ No newline at end of file diff --git a/Assets/ProjectQuint/Runtime/Unity/Features/Gameplay/GameplaySessionController.cs b/Assets/ProjectQuint/Runtime/Unity/Features/Gameplay/GameplaySessionController.cs new file mode 100644 index 0000000..0dfbab0 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Features/Gameplay/GameplaySessionController.cs @@ -0,0 +1,513 @@ +using System; +using System.Collections.Generic; +using ProjectQuint.Core.Charts; +using ProjectQuint.Core.Gauge; +using ProjectQuint.Core.Judgement; +using ProjectQuint.Core.Timing; + +namespace ProjectQuint.Features.Gameplay +{ + [Flags] + internal enum GameplaySessionSignal + { + None = 0, + ClockFallbackActivated = 1 << 0, + GaugeFailed = 1 << 1, + SongFinished = 1 << 2, + GaugeShifted = 1 << 3, + } + + internal readonly struct GameplayClockSample + { + public GameplayClockSample( + double currentDspTime, + double startDspTime, + double currentRealtime, + int audioOutputLatencyMilliseconds) + { + CurrentDspTime = currentDspTime; + StartDspTime = startDspTime; + CurrentRealtime = currentRealtime; + AudioOutputLatencyMilliseconds = audioOutputLatencyMilliseconds; + } + + public double CurrentDspTime { get; } + public double StartDspTime { get; } + public double CurrentRealtime { get; } + public int AudioOutputLatencyMilliseconds { get; } + } + + internal readonly struct GameplaySessionStartRequest + { + public GameplaySessionStartRequest( + GaugeMode gaugeType, + double? restoredGaugeValue = null, + int restoredCombo = 0, + int restoredMaxCombo = 0, + CourseGaugeProfile courseGaugeProfile = CourseGaugeProfile.Lr2, + bool autoPlay = false, + bool gaugeFallbackEnabled = false, + bool autoScratch = false) + { + GaugeType = gaugeType; + RestoredGaugeValue = restoredGaugeValue; + RestoredCombo = restoredCombo; + RestoredMaxCombo = restoredMaxCombo; + CourseGaugeProfile = courseGaugeProfile; + AutoPlay = autoPlay; + GaugeFallbackEnabled = gaugeFallbackEnabled; + AutoScratch = autoScratch; + } + + public GaugeMode GaugeType { get; } + public double? RestoredGaugeValue { get; } + public int RestoredCombo { get; } + public int RestoredMaxCombo { get; } + public CourseGaugeProfile CourseGaugeProfile { get; } + public bool AutoPlay { get; } + public bool GaugeFallbackEnabled { get; } + public bool AutoScratch { get; } + } + + internal sealed class GameplaySessionController : IDisposable + { + private readonly ResilientSongClock _songClock = new ResilientSongClock(); + private IReadOnlyDictionary _notesById = + new Dictionary(); + private readonly List _autoPlayEvents = new List(); + private List[] _laneKeySounds = Array.Empty>(); + private int _autoPlayEventCursor; + private bool _previousClockFallbackState; + private GaugeFallbackChain _gaugeChain; + private bool _gaugeShiftPending; + + public GameplaySessionController() + { + LaneHeld = new bool[5]; + LaneFlashTimes = new double[5]; + ResetLaneState(); + } + + public event Action Judged; + public event Action AutoPlayNotePressed; + + public PlayableChart Chart { get; private set; } + public RhythmJudgementEngine Judgement { get; private set; } + public PlayGauge Gauge => _gaugeChain?.ActiveGauge; + public GaugeMode LastGaugeShiftFrom { get; private set; } + public GaugeMode LastGaugeShiftTo { get; private set; } + public bool[] LaneHeld { get; private set; } + public double[] LaneFlashTimes { get; private set; } + public double FrameSongTime { get; private set; } + public double LastPressSongTime { get; private set; } + public bool IsActive { get; private set; } + public bool IsClockFallbackActive => IsActive && _songClock.IsFallbackActive; + public bool IsAutoPlay { get; private set; } + public bool IsAutoScratch { get; private set; } + public bool AllowsRecordCommit => IsActive && !IsAutoPlay; + + public void LoadChart(PlayableChart chart, IReadOnlyDictionary notesById = null) + { + if (chart == null) + throw new ArgumentNullException(nameof(chart)); + + Clear(); + Chart = chart; + ConfigureLaneState(chart.Metadata?.TotalLaneCount ?? 5); + _laneKeySounds = LaneKeySoundResolver.Build(chart, LaneHeld.Length); + if (notesById != null) + { + _notesById = notesById; + return; + } + + var indexedNotes = new Dictionary(chart.Notes.Count); + foreach (var note in chart.Notes) + indexedNotes[note.Id] = note; + _notesById = indexedNotes; + } + + public void Start(GameplaySessionStartRequest request, GameplayClockSample clock) + { + if (Chart == null) + throw new InvalidOperationException("A chart must be loaded before gameplay starts."); + + ResetRuntimeState(); + Judgement = new RhythmJudgementEngine( + Chart.Notes, + laneCount: Chart.Metadata?.TotalLaneCount ?? 5, + tempoMap: Chart.TempoMap, + suppressMissPenalty: note => IsAutoScratch && note?.IsScratch == true); + Judgement.RestoreCourseCombo(request.RestoredCombo, request.RestoredMaxCombo); + _gaugeChain = new GaugeFallbackChain( + request.GaugeType, + Chart.Metadata.GaugeTotal, + Chart.Notes.Count, + request.GaugeFallbackEnabled, + request.RestoredGaugeValue, + request.CourseGaugeProfile); + Judgement.Judged += OnJudged; + _songClock.Reset(clock.CurrentDspTime, clock.StartDspTime, clock.CurrentRealtime); + _previousClockFallbackState = false; + IsAutoPlay = request.AutoPlay; + IsAutoScratch = + !IsAutoPlay && + request.AutoScratch && + Chart.Metadata?.HasScratch == true; + PrepareAutoPlayEvents(); + FrameSongTime = ResolveAudibleSongTime( + _songClock.CurrentSongTime, + clock.AudioOutputLatencyMilliseconds); + IsActive = true; + } + + public GameplaySessionSignal AdvanceClock(GameplayClockSample clock) + { + if (!IsActive) + return GameplaySessionSignal.None; + + FrameSongTime = SampleSongTime(clock); + var signal = !_previousClockFallbackState && _songClock.IsFallbackActive + ? GameplaySessionSignal.ClockFallbackActivated + : GameplaySessionSignal.None; + _previousClockFallbackState = _songClock.IsFallbackActive; + return signal; + } + + public GameplaySessionSignal EvaluateFrame(int judgementOffsetMilliseconds) + { + if (!IsActive || Judgement == null || Chart == null) + return GameplaySessionSignal.None; + + if (IsAutoPlay || IsAutoScratch) + ProcessAutoPlayEvents(FrameSongTime); + Judgement.Update( + FrameSongTime, + IsAutoPlay ? 0 : judgementOffsetMilliseconds * 0.001); + var signal = GameplaySessionSignal.None; + if (_gaugeShiftPending) + { + signal |= GameplaySessionSignal.GaugeShifted; + _gaugeShiftPending = false; + } + if (Gauge?.IsFailed == true) + signal |= GameplaySessionSignal.GaugeFailed; + if (FrameSongTime >= Chart.Duration + 0.5) + signal |= GameplaySessionSignal.SongFinished; + return signal; + } + + public JudgementResult? Press( + int lane, + double inputEventTime, + double currentInputTime, + GameplayClockSample clock, + int judgementOffsetMilliseconds) + { + if (!IsActive || Judgement == null || lane < 0 || lane >= LaneHeld.Length) + return null; + if (IsAutoScratchLane(lane)) + return null; + + LaneHeld[lane] = true; + var songTime = ResolveInputEventSongTime( + inputEventTime, + currentInputTime, + SampleSongTime(clock)); + LastPressSongTime = songTime; + var result = Judgement.Press( + lane, + songTime, + judgementOffsetMilliseconds * 0.001); + if (result.HasValue) + LaneFlashTimes[lane] = songTime; + return result; + } + + public JudgementResult? Release( + int lane, + double inputEventTime, + double currentInputTime, + GameplayClockSample clock) + { + if (!IsActive || Judgement == null || lane < 0 || lane >= LaneHeld.Length) + return null; + if (IsAutoScratchLane(lane)) + return null; + + LaneHeld[lane] = false; + return Judgement.Release( + lane, + ResolveInputEventSongTime( + inputEventTime, + currentInputTime, + SampleSongTime(clock))); + } + + public void Resume(GameplayClockSample clock, IReadOnlyList physicallyHeldLanes) + { + if (!IsActive || Judgement == null) + return; + + _songClock.Reanchor( + RestoreRawSongTime(FrameSongTime, clock.AudioOutputLatencyMilliseconds), + clock.CurrentDspTime, + clock.CurrentRealtime); + _previousClockFallbackState = false; + if (IsAutoPlay) + return; + if (physicallyHeldLanes == null) + return; + + for (var lane = 0; lane < LaneHeld.Length; lane++) + { + if (IsAutoScratchLane(lane)) + continue; + var physicallyHeld = lane < physicallyHeldLanes.Count && + physicallyHeldLanes[lane]; + if (LaneHeld[lane] && !physicallyHeld) + Judgement.Release(lane, FrameSongTime); + LaneHeld[lane] &= physicallyHeld; + } + } + + public double SampleSongTime(GameplayClockSample clock) + { + if (!IsActive || clock.StartDspTime <= 0) + return 0; + return ResolveAudibleSongTime( + _songClock.Sample( + clock.CurrentDspTime, + clock.StartDspTime, + clock.CurrentRealtime), + clock.AudioOutputLatencyMilliseconds); + } + + public bool TryGetLoadedNote(int noteId, out PlayableNote note) => + _notesById.TryGetValue(noteId, out note); + + public bool TryResolvePressedKeySound( + int lane, + JudgementResult? result, + out string soundId) + { + soundId = string.Empty; + if (IsAutoScratchLane(lane)) + return false; + if (result.HasValue && + _notesById.TryGetValue(result.Value.NoteId, out var hitNote) && + !string.IsNullOrWhiteSpace(hitNote.SoundId)) + { + soundId = hitNote.SoundId; + return true; + } + + return lane >= 0 && + lane < _laneKeySounds.Length && + LaneKeySoundResolver.TryResolve( + _laneKeySounds[lane], + LastPressSongTime, + out soundId); + } + + public void Clear() + { + ResetRuntimeState(); + Chart = null; + _notesById = new Dictionary(); + _laneKeySounds = Array.Empty>(); + } + + public void Dispose() + { + Clear(); + Judged = null; + AutoPlayNotePressed = null; + } + + internal static double ResolveInputEventSongTime( + double inputEventTime, + double currentInputTime, + double currentSongTime) => + ResilientSongClock.ResolveInputEventSongTime( + inputEventTime, + currentInputTime, + currentSongTime); + + internal static double ResolveAudibleSongTime( + double rawSongTime, + int audioOutputLatencyMilliseconds) => + rawSongTime - Math.Max(0, audioOutputLatencyMilliseconds) * 0.001; + + internal static double RestoreRawSongTime( + double audibleSongTime, + int audioOutputLatencyMilliseconds) => + audibleSongTime + Math.Max(0, audioOutputLatencyMilliseconds) * 0.001; + + internal static double ResolveGameplayRenderSongTime( + bool isGameplay, + double currentSongTime, + double frozenSongTime) => + isGameplay ? currentSongTime : frozenSongTime; + + private void OnJudged(JudgementResult result) + { + if (!result.IsHoldTick && !result.SuppressGameplayPenalty) + RegisterGaugeTransition(_gaugeChain?.Apply(result) ?? GaugeTransition.None); + else if (_notesById.TryGetValue(result.NoteId, out var note) && + note.Lane >= 0 && note.Lane < LaneFlashTimes.Length) + LaneFlashTimes[note.Lane] = FrameSongTime; + Judged?.Invoke(result); + } + + private void ResetRuntimeState() + { + if (Judgement != null) + Judgement.Judged -= OnJudged; + Judgement = null; + _gaugeChain = null; + _gaugeShiftPending = false; + LastGaugeShiftFrom = default; + LastGaugeShiftTo = default; + FrameSongTime = 0; + LastPressSongTime = 0; + IsActive = false; + IsAutoPlay = false; + IsAutoScratch = false; + _autoPlayEventCursor = 0; + _autoPlayEvents.Clear(); + _previousClockFallbackState = false; + ResetLaneState(); + } + + private void RegisterGaugeTransition(GaugeTransition transition) + { + if (!transition.Changed) + return; + + if (!_gaugeShiftPending) + LastGaugeShiftFrom = transition.From; + LastGaugeShiftTo = transition.To; + _gaugeShiftPending = true; + } + + private void ResetLaneState() + { + Array.Clear(LaneHeld, 0, LaneHeld.Length); + for (var lane = 0; lane < LaneFlashTimes.Length; lane++) + LaneFlashTimes[lane] = -10; + } + + private void ConfigureLaneState(int laneCount) + { + laneCount = Math.Max(1, Math.Min(8, laneCount)); + if (LaneHeld?.Length == laneCount && LaneFlashTimes?.Length == laneCount) + return; + LaneHeld = new bool[laneCount]; + LaneFlashTimes = new double[laneCount]; + ResetLaneState(); + } + + private void PrepareAutoPlayEvents() + { + _autoPlayEventCursor = 0; + _autoPlayEvents.Clear(); + if ((!IsAutoPlay && !IsAutoScratch) || Chart == null) + return; + + foreach (var note in Chart.Notes) + { + if (!IsAutoPlay && !note.IsScratch) + continue; + _autoPlayEvents.Add(new AutoPlayEvent(note.StartTime, note.Lane, note.Id, release: false)); + if (note.IsLong) + _autoPlayEvents.Add(new AutoPlayEvent(note.EndTime, note.Lane, note.Id, release: true)); + } + _autoPlayEvents.Sort(AutoPlayEvent.Compare); + } + + private void ProcessAutoPlayEvents(double songTime) + { + while (_autoPlayEventCursor < _autoPlayEvents.Count && + _autoPlayEvents[_autoPlayEventCursor].Time <= songTime + 0.000001) + { + var autoEvent = _autoPlayEvents[_autoPlayEventCursor++]; + if (autoEvent.Lane < 0 || autoEvent.Lane >= LaneHeld.Length) + continue; + + if (autoEvent.Release) + { + LaneHeld[autoEvent.Lane] = false; + if (IsAutoPlay) + Judgement.Release(autoEvent.Lane, autoEvent.Time); + continue; + } + + LaneHeld[autoEvent.Lane] = true; + if (IsAutoScratch) + { + LaneFlashTimes[autoEvent.Lane] = autoEvent.Time; + if (_notesById.TryGetValue(autoEvent.NoteId, out var scratchNote)) + { + AutoPlayNotePressed?.Invoke(scratchNote); + if (!scratchNote.IsLong) + LaneHeld[autoEvent.Lane] = false; + } + else + { + LaneHeld[autoEvent.Lane] = false; + } + continue; + } + + var result = Judgement.Press(autoEvent.Lane, autoEvent.Time, timingOffsetSeconds: 0); + if (result.HasValue) + { + LaneFlashTimes[autoEvent.Lane] = autoEvent.Time; + if (_notesById.TryGetValue(result.Value.NoteId, out var note)) + { + AutoPlayNotePressed?.Invoke(note); + if (!note.IsLong) + LaneHeld[autoEvent.Lane] = false; + } + } + else + { + LaneHeld[autoEvent.Lane] = false; + } + } + } + + private bool IsAutoScratchLane(int lane) => + IsAutoScratch && + Chart?.Metadata?.HasScratch == true && + lane == 0; + + private readonly struct AutoPlayEvent + { + public AutoPlayEvent(double time, int lane, int noteId, bool release) + { + Time = time; + Lane = lane; + NoteId = noteId; + Release = release; + } + + public double Time { get; } + public int Lane { get; } + public int NoteId { get; } + public bool Release { get; } + + public static int Compare(AutoPlayEvent left, AutoPlayEvent right) + { + var time = left.Time.CompareTo(right.Time); + if (time != 0) + return time; + if (left.Release != right.Release) + return left.Release ? -1 : 1; + var lane = left.Lane.CompareTo(right.Lane); + return lane != 0 ? lane : left.NoteId.CompareTo(right.NoteId); + } + } + } +} diff --git a/Assets/ProjectQuint/Runtime/Unity/Features/Gameplay/GameplaySessionController.cs.meta b/Assets/ProjectQuint/Runtime/Unity/Features/Gameplay/GameplaySessionController.cs.meta new file mode 100644 index 0000000..8b94991 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Features/Gameplay/GameplaySessionController.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f62ab48dbbfe84724bd269ffbfc21c86 \ No newline at end of file diff --git a/Assets/ProjectQuint/Runtime/Unity/Features/Gameplay/HighRateInputMonitor.cs b/Assets/ProjectQuint/Runtime/Unity/Features/Gameplay/HighRateInputMonitor.cs new file mode 100644 index 0000000..7ec33ee --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Features/Gameplay/HighRateInputMonitor.cs @@ -0,0 +1,327 @@ +using System; + +namespace ProjectQuint.Features.Gameplay +{ + /// + /// Observes gameplay input timing without changing or resampling input events. + /// The hot path is allocation-free so high-report-rate keyboards do not create + /// additional frame pacing pressure. Reported rates are observations only; + /// keyboard polling remains owned by the device, driver and operating system. + /// + internal sealed class HighRateInputMonitor + { + private const int HistogramBucketCount = 15; + private const int PendingVisualCapacity = 512; + private readonly int[] _intervalHistogram = new int[HistogramBucketCount]; + private readonly int[] _queueDelayHistogram = new int[HistogramBucketCount]; + private readonly GameplayPhaseHistogram _judgementDispatchLatency = new GameplayPhaseHistogram(); + private readonly GameplayPhaseHistogram _reactiveAudioCommandLatency = new GameplayPhaseHistogram(); + private readonly GameplayPhaseHistogram _visualFeedbackLatency = new GameplayPhaseHistogram(); + private readonly long[] _pendingVisualTimestamps = new long[PendingVisualCapacity]; + private string _deviceName = "UNKNOWN KEYBOARD"; + private double _lastEventTime = double.NegativeInfinity; + private double _intervalTotalSeconds; + private double _minimumIntervalSeconds; + private double _maximumIntervalSeconds; + private double _queueDelayTotalSeconds; + private double _maximumQueueDelaySeconds; + private int _lastFrameIndex = int.MinValue; + private int _eventsInCurrentFrame; + private int _heldLaneMask; + private int _pendingVisualHead; + private int _pendingVisualCount; + + public int EventCount { get; private set; } + public int PressCount { get; private set; } + public int ReleaseCount { get; private set; } + public int IntervalCount { get; private set; } + public int OutOfOrderCount { get; private set; } + public int InvalidTransitionCount { get; private set; } + public int MaximumEventsPerFrame { get; private set; } + public int DroppedVisualFeedbackSamples { get; private set; } + + public void Reset(string deviceName) + { + Array.Clear(_intervalHistogram, 0, _intervalHistogram.Length); + Array.Clear(_queueDelayHistogram, 0, _queueDelayHistogram.Length); + _judgementDispatchLatency.Reset(); + _reactiveAudioCommandLatency.Reset(); + _visualFeedbackLatency.Reset(); + _deviceName = string.IsNullOrWhiteSpace(deviceName) ? "UNKNOWN KEYBOARD" : deviceName; + _lastEventTime = double.NegativeInfinity; + _intervalTotalSeconds = 0; + _minimumIntervalSeconds = double.PositiveInfinity; + _maximumIntervalSeconds = 0; + _queueDelayTotalSeconds = 0; + _maximumQueueDelaySeconds = 0; + _lastFrameIndex = int.MinValue; + _eventsInCurrentFrame = 0; + _heldLaneMask = 0; + _pendingVisualHead = 0; + _pendingVisualCount = 0; + EventCount = 0; + PressCount = 0; + ReleaseCount = 0; + IntervalCount = 0; + OutOfOrderCount = 0; + InvalidTransitionCount = 0; + MaximumEventsPerFrame = 0; + DroppedVisualFeedbackSamples = 0; + } + + public void RecordTransition( + int lane, + bool pressed, + double eventTime, + double observedInputTime, + int frameIndex) + { + EventCount++; + if (pressed) + PressCount++; + else + ReleaseCount++; + + if (frameIndex == _lastFrameIndex) + { + _eventsInCurrentFrame++; + } + else + { + _lastFrameIndex = frameIndex; + _eventsInCurrentFrame = 1; + } + if (_eventsInCurrentFrame > MaximumEventsPerFrame) + MaximumEventsPerFrame = _eventsInCurrentFrame; + + if (lane >= 0 && lane < 31) + { + var laneBit = 1 << lane; + var wasHeld = (_heldLaneMask & laneBit) != 0; + if (pressed) + { + if (wasHeld) + InvalidTransitionCount++; + _heldLaneMask |= laneBit; + } + else + { + if (!wasHeld) + InvalidTransitionCount++; + _heldLaneMask &= ~laneBit; + } + } + + if (!double.IsNegativeInfinity(_lastEventTime)) + { + var intervalSeconds = eventTime - _lastEventTime; + if (intervalSeconds < 0) + { + OutOfOrderCount++; + } + else + { + IntervalCount++; + _intervalTotalSeconds += intervalSeconds; + if (intervalSeconds < _minimumIntervalSeconds) + _minimumIntervalSeconds = intervalSeconds; + if (intervalSeconds > _maximumIntervalSeconds) + _maximumIntervalSeconds = intervalSeconds; + _intervalHistogram[HistogramBucket(intervalSeconds * 1000.0)]++; + } + } + _lastEventTime = eventTime; + + var queueDelaySeconds = Math.Max(0, observedInputTime - eventTime); + _queueDelayTotalSeconds += queueDelaySeconds; + if (queueDelaySeconds > _maximumQueueDelaySeconds) + _maximumQueueDelaySeconds = queueDelaySeconds; + _queueDelayHistogram[HistogramBucket(queueDelaySeconds * 1000.0)]++; + } + + public void RecordJudgementDispatch(long callbackTimestamp, long completedTimestamp) => + _judgementDispatchLatency.Add(ElapsedMilliseconds(callbackTimestamp, completedTimestamp)); + + public void RecordReactiveAudioCommand(long callbackTimestamp, long completedTimestamp) => + _reactiveAudioCommandLatency.Add(ElapsedMilliseconds(callbackTimestamp, completedTimestamp)); + + public void QueueVisualFeedback(long callbackTimestamp) + { + if (_pendingVisualCount >= PendingVisualCapacity) + { + DroppedVisualFeedbackSamples++; + return; + } + + var tail = (_pendingVisualHead + _pendingVisualCount) % PendingVisualCapacity; + _pendingVisualTimestamps[tail] = callbackTimestamp; + _pendingVisualCount++; + } + + public void CommitVisualFeedback(long committedTimestamp) + { + while (_pendingVisualCount > 0) + { + var callbackTimestamp = _pendingVisualTimestamps[_pendingVisualHead]; + _pendingVisualHead = (_pendingVisualHead + 1) % PendingVisualCapacity; + _pendingVisualCount--; + _visualFeedbackLatency.Add(ElapsedMilliseconds(callbackTimestamp, committedTimestamp)); + } + } + + public HighRateInputSnapshot CaptureSnapshot() => new HighRateInputSnapshot( + _deviceName, + EventCount, + PressCount, + ReleaseCount, + OutOfOrderCount, + InvalidTransitionCount, + MaximumEventsPerFrame, + IntervalCount > 0 ? _minimumIntervalSeconds * 1000.0 : 0, + IntervalCount > 0 ? _intervalTotalSeconds * 1000.0 / IntervalCount : 0, + IntervalCount > 0 ? _maximumIntervalSeconds * 1000.0 : 0, + HistogramPercentile(_intervalHistogram, IntervalCount, 0.50), + HistogramPercentile(_intervalHistogram, IntervalCount, 0.95), + HistogramPercentile(_intervalHistogram, IntervalCount, 0.99), + EventCount > 0 ? _queueDelayTotalSeconds * 1000.0 / EventCount : 0, + HistogramPercentile(_queueDelayHistogram, EventCount, 0.50), + HistogramPercentile(_queueDelayHistogram, EventCount, 0.95), + HistogramPercentile(_queueDelayHistogram, EventCount, 0.99), + _maximumQueueDelaySeconds * 1000.0, + _judgementDispatchLatency.Capture(), + _reactiveAudioCommandLatency.Capture(), + _visualFeedbackLatency.Capture(), + DroppedVisualFeedbackSamples); + + private static double ElapsedMilliseconds(long startedTimestamp, long completedTimestamp) => + Math.Max(0, completedTimestamp - startedTimestamp) * 1000.0 / + System.Diagnostics.Stopwatch.Frequency; + + private static int HistogramBucket(double milliseconds) + { + if (milliseconds <= 0.125) return 0; + if (milliseconds <= 0.250) return 1; + if (milliseconds <= 0.500) return 2; + if (milliseconds <= 1.000) return 3; + if (milliseconds <= 2.000) return 4; + if (milliseconds <= 4.000) return 5; + if (milliseconds <= 8.000) return 6; + if (milliseconds <= 16.000) return 7; + if (milliseconds <= 33.000) return 8; + if (milliseconds <= 50.000) return 9; + if (milliseconds <= 100.000) return 10; + if (milliseconds <= 250.000) return 11; + if (milliseconds <= 500.000) return 12; + if (milliseconds <= 1000.000) return 13; + return 14; + } + + private static double HistogramPercentile(int[] histogram, int count, double percentile) + { + if (count <= 0) + return 0; + var target = Math.Max(1, (int)Math.Ceiling(count * percentile)); + var accumulated = 0; + for (var bucket = 0; bucket < histogram.Length; bucket++) + { + accumulated += histogram[bucket]; + if (accumulated >= target) + return HistogramUpperBoundMilliseconds(bucket); + } + return HistogramUpperBoundMilliseconds(histogram.Length - 1); + } + + private static double HistogramUpperBoundMilliseconds(int bucket) => bucket switch + { + 0 => 0.125, + 1 => 0.250, + 2 => 0.500, + 3 => 1.000, + 4 => 2.000, + 5 => 4.000, + 6 => 8.000, + 7 => 16.000, + 8 => 33.000, + 9 => 50.000, + 10 => 100.000, + 11 => 250.000, + 12 => 500.000, + 13 => 1000.000, + _ => double.PositiveInfinity, + }; + } + + internal readonly struct HighRateInputSnapshot + { + public HighRateInputSnapshot( + string deviceName, + int eventCount, + int pressCount, + int releaseCount, + int outOfOrderCount, + int invalidTransitionCount, + int maximumEventsPerFrame, + double minimumIntervalMilliseconds, + double averageIntervalMilliseconds, + double maximumIntervalMilliseconds, + double p50IntervalMilliseconds, + double p95IntervalMilliseconds, + double p99IntervalMilliseconds, + double averageQueueDelayMilliseconds, + double p50QueueDelayMilliseconds, + double p95QueueDelayMilliseconds, + double p99QueueDelayMilliseconds, + double maximumQueueDelayMilliseconds, + GameplayPhaseTimingSnapshot judgementDispatchLatency, + GameplayPhaseTimingSnapshot reactiveAudioCommandLatency, + GameplayPhaseTimingSnapshot visualFeedbackLatency, + int droppedVisualFeedbackSamples) + { + DeviceName = deviceName; + EventCount = eventCount; + PressCount = pressCount; + ReleaseCount = releaseCount; + OutOfOrderCount = outOfOrderCount; + InvalidTransitionCount = invalidTransitionCount; + MaximumEventsPerFrame = maximumEventsPerFrame; + MinimumIntervalMilliseconds = minimumIntervalMilliseconds; + AverageIntervalMilliseconds = averageIntervalMilliseconds; + MaximumIntervalMilliseconds = maximumIntervalMilliseconds; + P50IntervalMilliseconds = p50IntervalMilliseconds; + P95IntervalMilliseconds = p95IntervalMilliseconds; + P99IntervalMilliseconds = p99IntervalMilliseconds; + AverageQueueDelayMilliseconds = averageQueueDelayMilliseconds; + P50QueueDelayMilliseconds = p50QueueDelayMilliseconds; + P95QueueDelayMilliseconds = p95QueueDelayMilliseconds; + P99QueueDelayMilliseconds = p99QueueDelayMilliseconds; + MaximumQueueDelayMilliseconds = maximumQueueDelayMilliseconds; + JudgementDispatchLatency = judgementDispatchLatency; + ReactiveAudioCommandLatency = reactiveAudioCommandLatency; + VisualFeedbackLatency = visualFeedbackLatency; + DroppedVisualFeedbackSamples = droppedVisualFeedbackSamples; + } + + public string DeviceName { get; } + public int EventCount { get; } + public int PressCount { get; } + public int ReleaseCount { get; } + public int OutOfOrderCount { get; } + public int InvalidTransitionCount { get; } + public int MaximumEventsPerFrame { get; } + public double MinimumIntervalMilliseconds { get; } + public double AverageIntervalMilliseconds { get; } + public double MaximumIntervalMilliseconds { get; } + public double P50IntervalMilliseconds { get; } + public double P95IntervalMilliseconds { get; } + public double P99IntervalMilliseconds { get; } + public double AverageQueueDelayMilliseconds { get; } + public double P50QueueDelayMilliseconds { get; } + public double P95QueueDelayMilliseconds { get; } + public double P99QueueDelayMilliseconds { get; } + public double MaximumQueueDelayMilliseconds { get; } + public GameplayPhaseTimingSnapshot JudgementDispatchLatency { get; } + public GameplayPhaseTimingSnapshot ReactiveAudioCommandLatency { get; } + public GameplayPhaseTimingSnapshot VisualFeedbackLatency { get; } + public int DroppedVisualFeedbackSamples { get; } + } +} diff --git a/Assets/ProjectQuint/Runtime/Unity/Features/Gameplay/HighRateInputMonitor.cs.meta b/Assets/ProjectQuint/Runtime/Unity/Features/Gameplay/HighRateInputMonitor.cs.meta new file mode 100644 index 0000000..8152170 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Features/Gameplay/HighRateInputMonitor.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: cbc8a2d940617431b8d49e17e06fc5e8 \ No newline at end of file diff --git a/Assets/ProjectQuint/Runtime/Unity/Features/Gameplay/LaneKeySoundResolver.cs b/Assets/ProjectQuint/Runtime/Unity/Features/Gameplay/LaneKeySoundResolver.cs new file mode 100644 index 0000000..f610b46 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Features/Gameplay/LaneKeySoundResolver.cs @@ -0,0 +1,111 @@ +using System; +using System.Collections.Generic; +using ProjectQuint.Core.Charts; + +namespace ProjectQuint.Features.Gameplay +{ + internal readonly struct LaneKeySoundCandidate + { + public LaneKeySoundCandidate(double time, string soundId, bool visibleNote) + { + Time = time; + SoundId = soundId ?? string.Empty; + VisibleNote = visibleNote; + } + + public double Time { get; } + public string SoundId { get; } + public bool VisibleNote { get; } + } + + /// + /// BMS empty-hit semantics: use the latest visible/invisible lane sound at the press time; + /// before any assignment has passed, use the first upcoming visible note sound. + /// + internal static class LaneKeySoundResolver + { + public static List[] Build(PlayableChart chart, int laneCount) + { + if (chart == null) + throw new ArgumentNullException(nameof(chart)); + laneCount = Math.Max(1, Math.Min(8, laneCount)); + var lanes = new List[laneCount]; + for (var lane = 0; lane < laneCount; lane++) + lanes[lane] = new List(); + + foreach (var note in chart.Notes) + { + if (note.Lane < 0 || note.Lane >= laneCount || string.IsNullOrWhiteSpace(note.SoundId)) + continue; + lanes[note.Lane].Add(new LaneKeySoundCandidate( + note.StartTime, + note.SoundId, + visibleNote: true)); + } + foreach (var item in chart.LaneSoundEvents) + { + if (item.Lane < 0 || item.Lane >= laneCount || string.IsNullOrWhiteSpace(item.SoundId)) + continue; + lanes[item.Lane].Add(new LaneKeySoundCandidate( + item.Time, + item.SoundId, + visibleNote: false)); + } + + foreach (var lane in lanes) + { + lane.Sort((left, right) => + { + var time = left.Time.CompareTo(right.Time); + if (time != 0) + return time; + // At the same timestamp, the actual playable note owns the lane sound. + return left.VisibleNote.CompareTo(right.VisibleNote); + }); + } + return lanes; + } + + public static bool TryResolve( + IReadOnlyList candidates, + double songTime, + out string soundId) + { + soundId = string.Empty; + if (candidates == null || candidates.Count == 0) + return false; + + var low = 0; + var high = candidates.Count - 1; + var latest = -1; + while (low <= high) + { + var middle = low + ((high - low) >> 1); + if (candidates[middle].Time <= songTime) + { + latest = middle; + low = middle + 1; + } + else + { + high = middle - 1; + } + } + + if (latest >= 0) + { + soundId = candidates[latest].SoundId; + return !string.IsNullOrWhiteSpace(soundId); + } + + for (var index = 0; index < candidates.Count; index++) + { + if (!candidates[index].VisibleNote) + continue; + soundId = candidates[index].SoundId; + return !string.IsNullOrWhiteSpace(soundId); + } + return false; + } + } +} diff --git a/Assets/ProjectQuint/Runtime/Unity/Features/Gameplay/LaneKeySoundResolver.cs.meta b/Assets/ProjectQuint/Runtime/Unity/Features/Gameplay/LaneKeySoundResolver.cs.meta new file mode 100644 index 0000000..1351c15 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Features/Gameplay/LaneKeySoundResolver.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 092df6b498aaf4db0af4c3fcb17acf97 \ No newline at end of file diff --git a/Assets/ProjectQuint/Runtime/Unity/Features/Gameplay/MacNativeKeyboardPoller.cs b/Assets/ProjectQuint/Runtime/Unity/Features/Gameplay/MacNativeKeyboardPoller.cs new file mode 100644 index 0000000..671f4b6 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Features/Gameplay/MacNativeKeyboardPoller.cs @@ -0,0 +1,296 @@ +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using ProjectQuint.Settings; +using UnityEngine; +using UnityEngine.InputSystem; + +namespace ProjectQuint.Features.Gameplay +{ + internal readonly struct NativePollingSnapshot + { + public NativePollingSnapshot( + int targetHz, + long sampleCount, + long droppedEventCount, + long lateWakeupCount, + double elapsedSeconds) + { + TargetHz = targetHz; + SampleCount = sampleCount; + DroppedEventCount = droppedEventCount; + LateWakeupCount = lateWakeupCount; + ElapsedSeconds = elapsedSeconds; + } + + public int TargetHz { get; } + public long SampleCount { get; } + public long DroppedEventCount { get; } + public long LateWakeupCount { get; } + public double ElapsedSeconds { get; } + public double EffectiveHz => ElapsedSeconds > 0 ? SampleCount / ElapsedSeconds : 0; + } + + internal sealed class MacNativeKeyboardPoller : IDisposable + { + private const string NativeLibrary = "ProjectQuintInput"; + + [StructLayout(LayoutKind.Sequential)] + private struct NativeEvent + { + public int BindingIndex; + public int Pressed; + public double TimestampSeconds; + } + + [StructLayout(LayoutKind.Sequential)] + private struct NativeSnapshot + { + public int TargetHz; + public int BindingCount; + public ulong SampleCount; + public ulong DroppedEventCount; + public ulong LateWakeupCount; + public double ElapsedSeconds; + } + + private readonly struct Binding + { + public Binding(int lane, Key key, int nativeKeyCode) + { + Lane = lane; + Key = key; + NativeKeyCode = nativeKeyCode; + } + + public int Lane { get; } + public Key Key { get; } + public int NativeKeyCode { get; } + } + + private Binding[] _bindings = Array.Empty(); + private bool[] _bindingHeld = Array.Empty(); + private int[] _laneHeldCounts = Array.Empty(); + private double _nativeToRealtimeOffset; + private bool _disposed; + + public bool IsRunning { get; private set; } + + public bool TryStart( + GameSettings settings, + int keyCount, + bool hasScratch, + InputPollingRate rate, + double currentRealtime, + out string error) + { + if (_disposed) + throw new ObjectDisposedException(nameof(MacNativeKeyboardPoller)); + Stop(); + if (settings == null) + { + error = "GAME SETTINGS ARE UNAVAILABLE"; + return false; + } + + var bindings = new List(GameSettings.BindingCount); + var nativeCodes = new List(GameSettings.BindingCount); + var totalLaneCount = keyCount + (hasScratch ? 1 : 0); + for (var lane = 0; lane < totalLaneCount; lane++) + { + foreach (var key in settings.GameplayKeysForLane(keyCount, hasScratch, lane)) + { + if (!MacVirtualKeyCode.TryFromUnityKey(key, out var nativeCode)) + { + error = $"{GameSettings.FormatLaneKeyLabel(key)} IS NOT SUPPORTED BY THE macOS NATIVE INPUT BACKEND"; + return false; + } + if (nativeCodes.Contains(nativeCode)) + { + error = $"{GameSettings.FormatLaneKeyLabel(key)} IS BOUND TO MORE THAN ONE GAMEPLAY LANE"; + return false; + } + nativeCodes.Add(nativeCode); + bindings.Add(new Binding(lane, key, nativeCode)); + } + } + + try + { + if (PQInputStart(GameSettings.InputPollingRateHz(rate), nativeCodes.ToArray(), nativeCodes.Count) == 0) + { + error = Marshal.PtrToStringAnsi(PQInputGetLastError()) ?? "NATIVE INPUT START FAILED"; + return false; + } + + _bindings = bindings.ToArray(); + _bindingHeld = new bool[_bindings.Length]; + _laneHeldCounts = new int[Math.Max(1, totalLaneCount)]; + _nativeToRealtimeOffset = currentRealtime - PQInputNowSeconds(); + RebaseHeldState(); + IsRunning = true; + error = string.Empty; + return true; + } + catch (Exception exception) when ( + exception is DllNotFoundException || + exception is EntryPointNotFoundException || + exception is BadImageFormatException) + { + error = $"NATIVE INPUT PLUGIN UNAVAILABLE // {exception.GetType().Name}"; + return false; + } + } + + public void Drain(Action dispatch, double currentRealtime) + { + if (!IsRunning || dispatch == null) + return; + while (PQInputTryDequeue(out var inputEvent) != 0) + { + if (inputEvent.BindingIndex < 0 || inputEvent.BindingIndex >= _bindings.Length) + continue; + var bindingIndex = inputEvent.BindingIndex; + var pressed = inputEvent.Pressed != 0; + if (_bindingHeld[bindingIndex] == pressed) + continue; + _bindingHeld[bindingIndex] = pressed; + var lane = _bindings[bindingIndex].Lane; + var previousLaneCount = _laneHeldCounts[lane]; + _laneHeldCounts[lane] = Math.Max(0, previousLaneCount + (pressed ? 1 : -1)); + if (pressed && previousLaneCount == 0 || !pressed && _laneHeldCounts[lane] == 0) + { + dispatch( + lane, + pressed, + inputEvent.TimestampSeconds + _nativeToRealtimeOffset, + currentRealtime); + } + } + } + + public void Suspend() + { + if (!IsRunning) + return; + PQInputSetEnabled(0); + } + + public bool[] ResumeAndCaptureHeldLanes() + { + if (!IsRunning) + return null; + RebaseHeldState(); + PQInputSetEnabled(1); + var lanes = new bool[_laneHeldCounts.Length]; + for (var lane = 0; lane < lanes.Length; lane++) + lanes[lane] = _laneHeldCounts[lane] > 0; + return lanes; + } + + public NativePollingSnapshot CaptureSnapshot() + { + if (!IsRunning) + return default; + PQInputGetSnapshot(out var snapshot); + return new NativePollingSnapshot( + snapshot.TargetHz, + checked((long)snapshot.SampleCount), + checked((long)snapshot.DroppedEventCount), + checked((long)snapshot.LateWakeupCount), + snapshot.ElapsedSeconds); + } + + public void Stop() + { + if (IsRunning) + { + PQInputStop(); + IsRunning = false; + } + _bindings = Array.Empty(); + _bindingHeld = Array.Empty(); + _laneHeldCounts = Array.Empty(); + } + + public void Dispose() + { + if (_disposed) + return; + Stop(); + _disposed = true; + } + + private void RebaseHeldState() + { + Array.Clear(_laneHeldCounts, 0, _laneHeldCounts.Length); + for (var index = 0; index < _bindings.Length; index++) + { + var held = PQInputIsKeyDown(_bindings[index].NativeKeyCode) != 0; + _bindingHeld[index] = held; + if (held) + _laneHeldCounts[_bindings[index].Lane]++; + } + } + + [DllImport(NativeLibrary, CallingConvention = CallingConvention.Cdecl)] + private static extern int PQInputStart(int frequency, [In] int[] bindings, int bindingCount); + + [DllImport(NativeLibrary, CallingConvention = CallingConvention.Cdecl)] + private static extern void PQInputStop(); + + [DllImport(NativeLibrary, CallingConvention = CallingConvention.Cdecl)] + private static extern void PQInputSetEnabled(int enabled); + + [DllImport(NativeLibrary, CallingConvention = CallingConvention.Cdecl)] + private static extern int PQInputTryDequeue(out NativeEvent inputEvent); + + [DllImport(NativeLibrary, CallingConvention = CallingConvention.Cdecl)] + private static extern int PQInputIsKeyDown(int keyCode); + + [DllImport(NativeLibrary, CallingConvention = CallingConvention.Cdecl)] + private static extern double PQInputNowSeconds(); + + [DllImport(NativeLibrary, CallingConvention = CallingConvention.Cdecl)] + private static extern void PQInputGetSnapshot(out NativeSnapshot snapshot); + + [DllImport(NativeLibrary, CallingConvention = CallingConvention.Cdecl)] + private static extern IntPtr PQInputGetLastError(); + } + + internal static class MacVirtualKeyCode + { + public static bool TryFromUnityKey(Key key, out int code) + { + code = key switch + { + Key.A => 0, Key.S => 1, Key.D => 2, Key.F => 3, Key.H => 4, Key.G => 5, + Key.Z => 6, Key.X => 7, Key.C => 8, Key.V => 9, Key.B => 11, + Key.Q => 12, Key.W => 13, Key.E => 14, Key.R => 15, Key.Y => 16, Key.T => 17, + Key.Digit1 => 18, Key.Digit2 => 19, Key.Digit3 => 20, Key.Digit4 => 21, + Key.Digit6 => 22, Key.Digit5 => 23, Key.Equals => 24, Key.Digit9 => 25, + Key.Digit7 => 26, Key.Minus => 27, Key.Digit8 => 28, Key.Digit0 => 29, + Key.RightBracket => 30, Key.O => 31, Key.U => 32, Key.LeftBracket => 33, + Key.I => 34, Key.P => 35, Key.Enter => 36, Key.L => 37, Key.J => 38, + Key.Quote => 39, Key.K => 40, Key.Semicolon => 41, Key.Backslash => 42, + Key.Comma => 43, Key.Slash => 44, Key.N => 45, Key.M => 46, Key.Period => 47, + Key.Tab => 48, Key.Space => 49, Key.Backquote => 50, Key.Backspace => 51, + Key.NumpadEnter => 52, Key.Escape => 53, Key.RightMeta => 54, Key.LeftMeta => 55, + Key.LeftShift => 56, Key.CapsLock => 57, Key.LeftAlt => 58, Key.LeftCtrl => 59, + Key.RightShift => 60, Key.RightAlt => 61, Key.RightCtrl => 62, + Key.NumpadPeriod => 65, Key.NumpadMultiply => 67, Key.NumpadPlus => 69, + Key.NumLock => 71, Key.NumpadDivide => 75, Key.NumpadEquals => 81, + Key.Numpad0 => 82, Key.Numpad1 => 83, Key.Numpad2 => 84, Key.Numpad3 => 85, + Key.Numpad4 => 86, Key.Numpad5 => 87, Key.Numpad6 => 88, Key.Numpad7 => 89, + Key.Numpad8 => 91, Key.Numpad9 => 92, + Key.F5 => 96, Key.F6 => 97, Key.F7 => 98, Key.F3 => 99, Key.F8 => 100, + Key.F9 => 101, Key.F11 => 103, Key.F10 => 109, Key.F12 => 111, + Key.Home => 115, Key.PageUp => 116, Key.Delete => 117, Key.F4 => 118, + Key.End => 119, Key.F2 => 120, Key.PageDown => 121, Key.F1 => 122, + Key.LeftArrow => 123, Key.RightArrow => 124, Key.DownArrow => 125, Key.UpArrow => 126, + _ => -1, + }; + return code >= 0; + } + } +} diff --git a/Assets/ProjectQuint/Runtime/Unity/Features/Gameplay/MacNativeKeyboardPoller.cs.meta b/Assets/ProjectQuint/Runtime/Unity/Features/Gameplay/MacNativeKeyboardPoller.cs.meta new file mode 100644 index 0000000..ba1c93a --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Features/Gameplay/MacNativeKeyboardPoller.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f31a27b67569949bd88d9b1ca3ad0a9b \ No newline at end of file diff --git a/Assets/ProjectQuint/Runtime/Unity/Features/Library.meta b/Assets/ProjectQuint/Runtime/Unity/Features/Library.meta new file mode 100644 index 0000000..f112240 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Features/Library.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 2978d1a1c28364e6c90df3562fb08e38 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/ProjectQuint/Runtime/Unity/Features/Library/LibraryController.cs b/Assets/ProjectQuint/Runtime/Unity/Features/Library/LibraryController.cs new file mode 100644 index 0000000..6245b94 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Features/Library/LibraryController.cs @@ -0,0 +1,572 @@ +using System; +using System.Collections.Generic; +using ProjectQuint.Library; +using UnityEngine; +using UnityEngine.InputSystem; + +namespace ProjectQuint.Features.Library +{ + public struct LibraryUiResult + { + public int SelectedIndex; + public string SearchQuery; + public string ToggleLevelFilter; + public bool ToggleLevelFilterPanel; + public bool ClearLevelFilters; + public bool CycleSort; + public bool ToggleSortPanel; + public SongSortMode? SortModeSelection; + public bool ToggleFavorite; + public bool ToggleFavoritesFilter; + public bool Play; + public bool Back; + public bool OpenCourses; + public bool OpenRanking; + public bool OpenOptions; + public bool OpenProfile; + public bool OpenQuickOptions; + public bool SearchFocused; + } + + internal enum LibraryCommand + { + None, + BackToHome, + ReloadLibrary, + OpenCourses, + OpenRanking, + OpenOptions, + OpenProfile, + OpenQuickOptions, + PlayRandomSong, + PlaySelectedSong, + ToggleFavorite, + } + + internal enum LibraryEnterAction + { + None, + ReleaseSearchFocus, + PlaySelectedSong, + } + + internal readonly struct LibraryControllerResult + { + public LibraryControllerResult( + LibraryCommand command, + bool refreshThumbnail = false) + { + Command = command; + RefreshThumbnail = refreshThumbnail; + } + + public LibraryCommand Command { get; } + public bool RefreshThumbnail { get; } + } + + internal sealed class LibraryController + { + internal const int SortShortcutNumber = 7; + internal const int FilterShortcutNumber = 8; + internal const int FavoriteShortcutNumber = 9; + + internal sealed class SelectionKeyRepeat + { + internal const double InitialDelaySeconds = 0.30; + internal const double RepeatIntervalSeconds = 0.055; + private const int MaximumRepeatsPerFrame = 8; + private int _direction; + private double _nextRepeatTime; + + public int Update(int direction, bool pressedThisFrame, double now) + { + direction = Math.Sign(direction); + if (direction == 0) + { + Reset(); + return 0; + } + + if (pressedThisFrame || direction != _direction) + { + _direction = direction; + _nextRepeatTime = now + InitialDelaySeconds; + return direction; + } + + if (now < _nextRepeatTime) + return 0; + + var repeats = 1 + (int)Math.Floor((now - _nextRepeatTime) / RepeatIntervalSeconds); + repeats = Math.Min(repeats, MaximumRepeatsPerFrame); + _nextRepeatTime += repeats * RepeatIntervalSeconds; + return direction * repeats; + } + + public void Reset() + { + _direction = 0; + _nextRepeatTime = 0.0; + } + } + + private bool _focusSearchRequested; + private bool _releaseSearchFocusRequested; + private readonly SelectionKeyRepeat _selectionKeyRepeat = new SelectionKeyRepeat(); + + public LibraryController(LibraryState state = null) + { + State = state ?? new LibraryState(); + } + + public LibraryState State { get; } + + public void Reset() + { + State.Reset(); + _focusSearchRequested = false; + _releaseSearchFocusRequested = false; + _selectionKeyRepeat.Reset(); + } + + public void ReplaceSongs(IEnumerable songs) + { + State.ReplaceSongs(songs); + _focusSearchRequested = false; + _releaseSearchFocusRequested = false; + _selectionKeyRepeat.Reset(); + } + + public void RestoreViewState( + IReadOnlyCollection selectedLevelFilters, + SongSortMode sortMode, + bool favoritesOnly, + string selectedChartMd5) + { + State.RestoreViewState( + selectedLevelFilters, + sortMode, + favoritesOnly, + selectedChartMd5); + _focusSearchRequested = false; + _releaseSearchFocusRequested = true; + _selectionKeyRepeat.Reset(); + } + + public void RefreshCatalogMetadata() => State.RefreshCatalogMetadata(); + + public LibraryControllerResult HandleUi(LibraryUiResult result, bool allowPlay) + { + var refreshThumbnail = false; + State.SetSearchFocused(result.SearchFocused); + refreshThumbnail |= State.SetSearchQuery(result.SearchQuery); + if (result.ToggleLevelFilterPanel) + State.ToggleLevelFilterPanel(); + if (result.ToggleSortPanel) + State.ToggleSortPanel(); + if (result.ClearLevelFilters) + refreshThumbnail |= State.ClearLevelFilters(); + if (!string.IsNullOrWhiteSpace(result.ToggleLevelFilter)) + refreshThumbnail |= State.ToggleLevelFilter(result.ToggleLevelFilter); + if (result.CycleSort) + { + State.CycleSort(); + refreshThumbnail = true; + } + if (result.SortModeSelection.HasValue) + refreshThumbnail |= State.SetSortMode(result.SortModeSelection.Value); + if (result.ToggleFavoritesFilter) + refreshThumbnail |= State.ToggleFavoritesOnly(); + if (result.SelectedIndex >= 0) + refreshThumbnail |= State.SetSelectedIndex(result.SelectedIndex); + + var command = LibraryCommand.None; + if (result.Play && allowPlay && State.VisibleSongs.Count > 0) + command = State.IsRandomSelection + ? LibraryCommand.PlayRandomSong + : LibraryCommand.PlaySelectedSong; + if (result.OpenCourses) + command = LibraryCommand.OpenCourses; + if (result.OpenRanking) + command = LibraryCommand.OpenRanking; + if (result.OpenOptions) + command = LibraryCommand.OpenOptions; + if (result.OpenProfile) + command = LibraryCommand.OpenProfile; + if (result.OpenQuickOptions) + command = LibraryCommand.OpenQuickOptions; + if (result.Back) + command = LibraryCommand.BackToHome; + if (result.ToggleFavorite && State.SelectedSong != null) + command = LibraryCommand.ToggleFavorite; + return new LibraryControllerResult( + command, + refreshThumbnail); + } + + public LibraryControllerResult HandleKeyboard(Keyboard keyboard) + { + if (keyboard == null) + return default; + var numberShortcut = State.SearchFocused + ? -1 + : ReadNumberShortcut(keyboard); + if (State.SortPanelOpen) + { + _selectionKeyRepeat.Reset(); + if (keyboard.escapeKey.wasPressedThisFrame || + numberShortcut == SortShortcutNumber) + { + State.CloseSortPanel(); + return default; + } + + if (keyboard.leftArrowKey.wasPressedThisFrame) + State.MoveSortCursor(-1, 0); + else if (keyboard.rightArrowKey.wasPressedThisFrame) + State.MoveSortCursor(1, 0); + else if (keyboard.upArrowKey.wasPressedThisFrame) + State.MoveSortCursor(0, -1); + else if (keyboard.downArrowKey.wasPressedThisFrame) + State.MoveSortCursor(0, 1); + var applied = + (keyboard.enterKey.wasPressedThisFrame || + keyboard.numpadEnterKey.wasPressedThisFrame) && + State.ApplySortAtCursor(); + return new LibraryControllerResult(LibraryCommand.None, applied); + } + if (State.LevelFilterPanelOpen) + { + _selectionKeyRepeat.Reset(); + if (keyboard.escapeKey.wasPressedThisFrame || + numberShortcut == FilterShortcutNumber) + { + State.CloseLevelFilterPanel(); + return default; + } + + var filterChanged = false; + if (keyboard.leftArrowKey.wasPressedThisFrame) + State.MoveLevelFilterCursor(-1, 0); + else if (keyboard.rightArrowKey.wasPressedThisFrame) + State.MoveLevelFilterCursor(1, 0); + else if (keyboard.upArrowKey.wasPressedThisFrame) + State.MoveLevelFilterCursor(0, -1); + else if (keyboard.downArrowKey.wasPressedThisFrame) + State.MoveLevelFilterCursor(0, 1); + else if (keyboard.enterKey.wasPressedThisFrame || keyboard.numpadEnterKey.wasPressedThisFrame) + filterChanged = State.ToggleLevelFilterAtCursor(); + return new LibraryControllerResult(LibraryCommand.None, filterChanged); + } + if (numberShortcut == SortShortcutNumber) + { + _selectionKeyRepeat.Reset(); + State.ToggleSortPanel(); + return default; + } + if (numberShortcut == FilterShortcutNumber) + { + _selectionKeyRepeat.Reset(); + State.ToggleLevelFilterPanel(); + return default; + } + if ((numberShortcut == 1 || keyboard.f4Key.wasPressedThisFrame) && + RequestSearchFocus()) + return default; + if (keyboard.escapeKey.wasPressedThisFrame) + { + _selectionKeyRepeat.Reset(); + return new LibraryControllerResult(LibraryCommand.BackToHome); + } + if (keyboard.f5Key.wasPressedThisFrame) + { + _selectionKeyRepeat.Reset(); + return new LibraryControllerResult(LibraryCommand.ReloadLibrary); + } + if (!State.SearchFocused && + (ResolveNumberShortcutCommand(numberShortcut) == LibraryCommand.OpenRanking || + keyboard.f8Key.wasPressedThisFrame)) + { + _selectionKeyRepeat.Reset(); + return new LibraryControllerResult(LibraryCommand.OpenRanking); + } + if (!State.SearchFocused && + (numberShortcut == 3 || keyboard.f9Key.wasPressedThisFrame)) + { + _selectionKeyRepeat.Reset(); + return new LibraryControllerResult( + LibraryCommand.None, + State.ToggleFavoritesOnly()); + } + if (!State.SearchFocused && + (ResolveNumberShortcutCommand(numberShortcut) == LibraryCommand.OpenOptions || + keyboard.f10Key.wasPressedThisFrame)) + { + _selectionKeyRepeat.Reset(); + return new LibraryControllerResult(LibraryCommand.OpenOptions); + } + if (!State.SearchFocused && + (ResolveNumberShortcutCommand(numberShortcut) == LibraryCommand.OpenProfile || + keyboard.f11Key.wasPressedThisFrame)) + { + _selectionKeyRepeat.Reset(); + return new LibraryControllerResult(LibraryCommand.OpenProfile); + } + if (!State.SearchFocused && + (ResolveNumberShortcutCommand(numberShortcut) == LibraryCommand.OpenCourses || + keyboard.f12Key.wasPressedThisFrame)) + { + _selectionKeyRepeat.Reset(); + return new LibraryControllerResult(LibraryCommand.OpenCourses); + } + if (!State.SearchFocused && keyboard.spaceKey.wasPressedThisFrame) + { + _selectionKeyRepeat.Reset(); + _releaseSearchFocusRequested = true; + return new LibraryControllerResult(LibraryCommand.OpenQuickOptions); + } + + var enterPressed = keyboard.enterKey.wasPressedThisFrame || + keyboard.numpadEnterKey.wasPressedThisFrame; + var enterAction = ResolveEnterAction(State.SearchFocused, State.VisibleSongs.Count > 0, enterPressed); + if (enterAction == LibraryEnterAction.ReleaseSearchFocus) + { + _selectionKeyRepeat.Reset(); + _focusSearchRequested = false; + State.SetSearchFocused(false); + _releaseSearchFocusRequested = true; + return default; + } + if (State.SearchFocused) + { + _selectionKeyRepeat.Reset(); + return default; + } + + var shiftLevelDelta = ResolveLevelFilterDelta( + keyboard.leftShiftKey.wasPressedThisFrame, + keyboard.rightShiftKey.wasPressedThisFrame); + if (shiftLevelDelta != 0) + { + _selectionKeyRepeat.Reset(); + return new LibraryControllerResult( + LibraryCommand.None, + OverrideLevelFilterByShift(shiftLevelDelta)); + } + + if (State.VisibleSongs.Count == 0) + { + _selectionKeyRepeat.Reset(); + return default; + } + + if (numberShortcut == FavoriteShortcutNumber) + { + _selectionKeyRepeat.Reset(); + return new LibraryControllerResult(LibraryCommand.ToggleFavorite); + } + + var changed = false; + var verticalDirection = ResolveVerticalSelectionDirection( + keyboard.upArrowKey.isPressed, + keyboard.downArrowKey.isPressed, + keyboard.upArrowKey.wasPressedThisFrame, + keyboard.downArrowKey.wasPressedThisFrame); + var repeatedSelectionDelta = _selectionKeyRepeat.Update( + verticalDirection, + keyboard.upArrowKey.wasPressedThisFrame || keyboard.downArrowKey.wasPressedThisFrame, + Time.realtimeSinceStartupAsDouble); + if (repeatedSelectionDelta != 0) + changed |= State.MoveSelection(repeatedSelectionDelta); + if (keyboard.pageDownKey.wasPressedThisFrame) + changed |= State.MoveSelection(10); + if (keyboard.pageUpKey.wasPressedThisFrame) + changed |= State.MoveSelection(-10); + if (enterAction == LibraryEnterAction.PlaySelectedSong) + { + _selectionKeyRepeat.Reset(); + return new LibraryControllerResult( + State.IsRandomSelection + ? LibraryCommand.PlayRandomSong + : LibraryCommand.PlaySelectedSong, + changed); + } + return new LibraryControllerResult(LibraryCommand.None, changed); + } + + internal static LibraryCommand ResolveNumberShortcutCommand(int number) => + number switch + { + 2 => LibraryCommand.OpenRanking, + 4 => LibraryCommand.OpenOptions, + 5 => LibraryCommand.OpenProfile, + 6 => LibraryCommand.OpenCourses, + _ => LibraryCommand.None, + }; + + private static int ReadNumberShortcut(Keyboard keyboard) + { + if (keyboard.digit1Key.wasPressedThisFrame || keyboard.numpad1Key.wasPressedThisFrame) + return 1; + if (keyboard.digit2Key.wasPressedThisFrame || keyboard.numpad2Key.wasPressedThisFrame) + return 2; + if (keyboard.digit3Key.wasPressedThisFrame || keyboard.numpad3Key.wasPressedThisFrame) + return 3; + if (keyboard.digit4Key.wasPressedThisFrame || keyboard.numpad4Key.wasPressedThisFrame) + return 4; + if (keyboard.digit5Key.wasPressedThisFrame || keyboard.numpad5Key.wasPressedThisFrame) + return 5; + if (keyboard.digit6Key.wasPressedThisFrame || keyboard.numpad6Key.wasPressedThisFrame) + return 6; + if (keyboard.digit7Key.wasPressedThisFrame || keyboard.numpad7Key.wasPressedThisFrame) + return 7; + if (keyboard.digit8Key.wasPressedThisFrame || keyboard.numpad8Key.wasPressedThisFrame) + return 8; + if (keyboard.digit9Key.wasPressedThisFrame || keyboard.numpad9Key.wasPressedThisFrame) + return 9; + if (keyboard.digit0Key.wasPressedThisFrame || keyboard.numpad0Key.wasPressedThisFrame) + return 0; + return -1; + } + + internal static int ResolveVerticalSelectionDirection( + bool upHeld, + bool downHeld, + bool upPressedThisFrame, + bool downPressedThisFrame) + { + if (upPressedThisFrame != downPressedThisFrame) + return upPressedThisFrame ? -1 : 1; + if (upHeld == downHeld) + return 0; + return upHeld ? -1 : 1; + } + + public bool ConsumeReleaseSearchFocusRequest() + { + if (!_releaseSearchFocusRequested) + return false; + _releaseSearchFocusRequested = false; + return true; + } + + public bool ConsumeFocusSearchRequest() + { + if (!_focusSearchRequested) + return false; + _focusSearchRequested = false; + return true; + } + + internal bool RequestSearchFocus() + { + if (State.SearchFocused || State.LevelFilterPanelOpen || State.SortPanelOpen) + return false; + + _selectionKeyRepeat.Reset(); + _releaseSearchFocusRequested = false; + _focusSearchRequested = true; + State.SetSearchFocused(true); + return true; + } + + public void ClearSearchFocus() + { + _focusSearchRequested = false; + State.SetSearchFocused(false); + _releaseSearchFocusRequested = true; + } + + public bool OverrideLevelFilterByShift(int delta) + { + var nextLevel = ResolveShiftLevelFilter( + State.LevelFilters, + State.SelectedLevelFilters, + State.SelectedSong?.DifficultyLabel, + delta); + return State.OverrideLevelFilter(nextLevel); + } + + internal static LibraryEnterAction ResolveEnterAction( + bool searchFocused, + bool hasSongs, + bool enterPressed) + { + if (!enterPressed) + return LibraryEnterAction.None; + if (searchFocused) + return LibraryEnterAction.ReleaseSearchFocus; + return hasSongs ? LibraryEnterAction.PlaySelectedSong : LibraryEnterAction.None; + } + + internal static int ResolveLevelFilterDelta(bool leftShiftPressed, bool rightShiftPressed) + { + if (leftShiftPressed == rightShiftPressed) + return 0; + return leftShiftPressed ? -1 : 1; + } + + internal static string ResolveShiftLevelFilter( + IReadOnlyList levelFilters, + IReadOnlyCollection selectedLevelFilters, + string currentSongLevel, + int delta) + { + if (levelFilters == null || levelFilters.Count == 0 || delta == 0) + return string.Empty; + + var rankedLevels = new List(); + foreach (var level in levelFilters) + { + if (string.IsNullOrWhiteSpace(level) || + string.Equals(level, SongLibraryView.AllLevelsFilter, StringComparison.OrdinalIgnoreCase) || + string.Equals(level, SongLibraryView.UnrankedFilter, StringComparison.OrdinalIgnoreCase)) + continue; + rankedLevels.Add(level); + } + if (rankedLevels.Count == 0) + return string.Empty; + + string anchor = null; + if (selectedLevelFilters?.Count == 1) + foreach (var selected in selectedLevelFilters) + anchor = selected; + if (IndexOfLevel(rankedLevels, anchor) < 0) + anchor = currentSongLevel; + if (IndexOfLevel(rankedLevels, anchor) < 0 && selectedLevelFilters?.Count > 0) + { + var edgeIndex = delta > 0 ? int.MaxValue : int.MinValue; + foreach (var selected in selectedLevelFilters) + { + var selectedIndex = IndexOfLevel(rankedLevels, selected); + if (selectedIndex < 0) + continue; + edgeIndex = delta > 0 + ? Math.Min(edgeIndex, selectedIndex) + : Math.Max(edgeIndex, selectedIndex); + } + if (edgeIndex >= 0 && edgeIndex < rankedLevels.Count) + anchor = rankedLevels[edgeIndex]; + } + + var currentIndex = IndexOfLevel(rankedLevels, anchor); + if (currentIndex < 0) + return delta > 0 ? rankedLevels[0] : rankedLevels[rankedLevels.Count - 1]; + + var nextIndex = (currentIndex + (delta > 0 ? 1 : -1)) % rankedLevels.Count; + if (nextIndex < 0) + nextIndex += rankedLevels.Count; + return rankedLevels[nextIndex]; + } + + private static int IndexOfLevel(IReadOnlyList levels, string target) + { + if (string.IsNullOrWhiteSpace(target)) + return -1; + for (var index = 0; index < levels.Count; index++) + if (string.Equals(levels[index], target, StringComparison.OrdinalIgnoreCase)) + return index; + return -1; + } + } +} diff --git a/Assets/ProjectQuint/Runtime/Unity/Features/Library/LibraryController.cs.meta b/Assets/ProjectQuint/Runtime/Unity/Features/Library/LibraryController.cs.meta new file mode 100644 index 0000000..f07f074 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Features/Library/LibraryController.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 004cb9457f87d4d1f8954eb71e3cc3e2 diff --git a/Assets/ProjectQuint/Runtime/Unity/Features/Library/LibraryState.cs b/Assets/ProjectQuint/Runtime/Unity/Features/Library/LibraryState.cs new file mode 100644 index 0000000..b9b314a --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Features/Library/LibraryState.cs @@ -0,0 +1,395 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using ProjectQuint.Library; +using UnityEngine; + +namespace ProjectQuint.Features.Library +{ + internal sealed class LibraryState + { + private readonly List _allSongs = new List(); + private readonly List _visibleSongs = new List(); + private readonly HashSet _selectedLevelFilters = + new HashSet(StringComparer.OrdinalIgnoreCase); + private List _levelFilters = new List { SongLibraryView.AllLevelsFilter }; + + public IReadOnlyList AllSongs => _allSongs; + public IReadOnlyList VisibleSongs => _visibleSongs; + public IReadOnlyList LevelFilters => _levelFilters; + public IReadOnlyCollection SelectedLevelFilters => _selectedLevelFilters; + public int SongsRevision { get; private set; } + public int ViewRevision { get; private set; } + public int DisplayItemCount => _visibleSongs.Count == 0 ? 0 : _visibleSongs.Count + 1; + public int SelectedIndex { get; private set; } + public bool LevelFilterPanelOpen { get; private set; } + public int LevelFilterCursorIndex { get; private set; } + public bool SortPanelOpen { get; private set; } + public int SortCursorIndex { get; private set; } + public SongSortMode SortMode { get; private set; } = SongSortMode.DifficultyAscending; + public string SearchQuery { get; private set; } = string.Empty; + public bool SearchFocused { get; private set; } + public bool FavoritesOnly { get; private set; } + public bool IsRandomSelection => _visibleSongs.Count > 0 && SelectedIndex == 0; + public SongLibraryEntry SelectedSong => SelectedIndex <= 0 || _visibleSongs.Count == 0 + ? null + : _visibleSongs[Mathf.Clamp(SelectedIndex - 1, 0, _visibleSongs.Count - 1)]; + + public void Reset() + { + _allSongs.Clear(); + _visibleSongs.Clear(); + _selectedLevelFilters.Clear(); + _levelFilters = new List { SongLibraryView.AllLevelsFilter }; + SelectedIndex = 0; + LevelFilterPanelOpen = false; + LevelFilterCursorIndex = 0; + SortPanelOpen = false; + SortCursorIndex = 0; + SortMode = SongSortMode.DifficultyAscending; + SearchQuery = string.Empty; + SearchFocused = false; + FavoritesOnly = false; + unchecked + { + SongsRevision++; + ViewRevision++; + } + } + + public void ReplaceSongs(IEnumerable songs) + { + _allSongs.Clear(); + if (songs != null) + _allSongs.AddRange(songs); + _levelFilters = SongLibraryView.BuildLevelFilters(_allSongs); + _selectedLevelFilters.Clear(); + LevelFilterPanelOpen = false; + LevelFilterCursorIndex = 0; + SortPanelOpen = false; + SortCursorIndex = 0; + SortMode = SongSortMode.DifficultyAscending; + SearchQuery = string.Empty; + FavoritesOnly = false; + SelectedIndex = 0; + unchecked + { + SongsRevision++; + } + ApplyView(); + } + + public void RestoreViewState( + IReadOnlyCollection selectedLevelFilters, + SongSortMode sortMode, + bool favoritesOnly, + string selectedChartMd5) + { + _selectedLevelFilters.Clear(); + foreach (var level in selectedLevelFilters ?? Array.Empty()) + { + if (string.IsNullOrWhiteSpace(level) || + string.Equals( + level, + SongLibraryView.AllLevelsFilter, + StringComparison.OrdinalIgnoreCase) || + !_levelFilters.Contains(level, StringComparer.OrdinalIgnoreCase)) + continue; + _selectedLevelFilters.Add(level); + } + SortMode = Enum.IsDefined(typeof(SongSortMode), sortMode) + ? sortMode + : SongSortMode.DifficultyAscending; + FavoritesOnly = favoritesOnly; + SearchQuery = string.Empty; + SearchFocused = false; + LevelFilterPanelOpen = false; + SortPanelOpen = false; + SortCursorIndex = (int)SortMode; + LevelFilterCursorIndex = ResolveInitialLevelFilterCursor( + _levelFilters, + _selectedLevelFilters); + SelectedIndex = 0; + ApplyView(); + + var normalizedMd5 = (selectedChartMd5 ?? string.Empty).Trim(); + if (normalizedMd5.Length == 0) + return; + var selectedIndex = _visibleSongs.FindIndex(song => + string.Equals( + song?.Metadata?.Md5, + normalizedMd5, + StringComparison.OrdinalIgnoreCase)); + if (selectedIndex >= 0) + SelectedIndex = selectedIndex + 1; + } + + public void RefreshCatalogMetadata() + { + var selectedPath = SelectedSong?.ChartPath; + _levelFilters = SongLibraryView.BuildLevelFilters(_allSongs); + _selectedLevelFilters.RemoveWhere(level => + !_levelFilters.Contains(level, StringComparer.OrdinalIgnoreCase)); + LevelFilterCursorIndex = Mathf.Clamp( + LevelFilterCursorIndex, + 0, + Math.Max(0, _levelFilters.Count - 1)); + ApplyView(); + if (string.IsNullOrWhiteSpace(selectedPath)) + return; + + var selected = _visibleSongs.FindIndex(song => + string.Equals(song.ChartPath, selectedPath, StringComparison.OrdinalIgnoreCase)); + if (selected >= 0) + SelectedIndex = selected + 1; + } + + public bool SetSearchQuery(string query) + { + query ??= string.Empty; + if (string.Equals(SearchQuery, query, StringComparison.Ordinal)) + return false; + SearchQuery = query; + ApplyView(); + return true; + } + + public void SetSearchFocused(bool focused) => SearchFocused = focused; + + public void ToggleLevelFilterPanel() + { + LevelFilterPanelOpen = !LevelFilterPanelOpen; + if (!LevelFilterPanelOpen) + return; + + SortPanelOpen = false; + LevelFilterCursorIndex = ResolveInitialLevelFilterCursor( + _levelFilters, + _selectedLevelFilters); + } + + public void CloseLevelFilterPanel() => LevelFilterPanelOpen = false; + + public void ToggleSortPanel() + { + SortPanelOpen = !SortPanelOpen; + if (!SortPanelOpen) + return; + + LevelFilterPanelOpen = false; + SortCursorIndex = (int)SortMode; + } + + public void CloseSortPanel() => SortPanelOpen = false; + + public bool MoveSortCursor(int horizontalDelta, int verticalDelta) + { + var optionCount = Enum.GetValues(typeof(SongSortMode)).Length; + if (optionCount <= 0 || horizontalDelta == 0 && verticalDelta == 0) + return false; + var next = ResolveLevelFilterCursor( + SortCursorIndex, + optionCount, + 2, + horizontalDelta, + verticalDelta); + if (next == SortCursorIndex) + return false; + SortCursorIndex = next; + return true; + } + + public bool ApplySortAtCursor() => + SetSortMode((SongSortMode)Mathf.Clamp( + SortCursorIndex, + 0, + Enum.GetValues(typeof(SongSortMode)).Length - 1)); + + public bool SetSortMode(SongSortMode sortMode) + { + SortPanelOpen = false; + if (!Enum.IsDefined(typeof(SongSortMode), sortMode) || SortMode == sortMode) + return false; + + var selectedPath = SelectedSong?.ChartPath; + SortMode = sortMode; + SortCursorIndex = (int)sortMode; + ApplyView(); + if (string.IsNullOrWhiteSpace(selectedPath)) + return true; + var selected = _visibleSongs.FindIndex(song => + string.Equals(song.ChartPath, selectedPath, StringComparison.OrdinalIgnoreCase)); + if (selected >= 0) + SelectedIndex = selected + 1; + return true; + } + + public bool MoveLevelFilterCursor(int horizontalDelta, int verticalDelta, int columns = 5) + { + var next = ResolveLevelFilterCursor( + LevelFilterCursorIndex, + _levelFilters.Count, + columns, + horizontalDelta, + verticalDelta); + if (next == LevelFilterCursorIndex) + return false; + LevelFilterCursorIndex = next; + return true; + } + + public bool ToggleLevelFilterAtCursor() + { + if (_levelFilters.Count == 0) + return false; + + LevelFilterCursorIndex = Mathf.Clamp(LevelFilterCursorIndex, 0, _levelFilters.Count - 1); + var level = _levelFilters[LevelFilterCursorIndex]; + return string.Equals(level, SongLibraryView.AllLevelsFilter, StringComparison.OrdinalIgnoreCase) + ? ClearLevelFilters() + : ToggleLevelFilter(level); + } + + internal static int ResolveInitialLevelFilterCursor( + IReadOnlyList filters, + IReadOnlyCollection selectedLevels) + { + if (filters == null || filters.Count == 0 || selectedLevels == null || selectedLevels.Count == 0) + return 0; + + for (var index = 0; index < filters.Count; index++) + foreach (var selected in selectedLevels) + if (string.Equals(filters[index], selected, StringComparison.OrdinalIgnoreCase)) + return index; + return 0; + } + + internal static int ResolveLevelFilterCursor( + int current, + int optionCount, + int columns, + int horizontalDelta, + int verticalDelta) + { + if (optionCount <= 0) + return 0; + + columns = Math.Max(1, columns); + current = Mathf.Clamp(current, 0, optionCount - 1); + if (horizontalDelta != 0) + return (current + Math.Sign(horizontalDelta) + optionCount) % optionCount; + if (verticalDelta == 0) + return current; + + var column = current % columns; + var rows = (optionCount + columns - 1) / columns; + var row = current / columns; + for (var attempt = 0; attempt < rows; attempt++) + { + row = (row + Math.Sign(verticalDelta) + rows) % rows; + var candidate = row * columns + column; + if (candidate < optionCount) + return candidate; + } + return current; + } + + public bool ClearLevelFilters() + { + if (_selectedLevelFilters.Count == 0) + return false; + _selectedLevelFilters.Clear(); + ApplyView(); + return true; + } + + public bool ToggleLevelFilter(string level) + { + if (string.IsNullOrWhiteSpace(level)) + return false; + if (!_selectedLevelFilters.Add(level)) + _selectedLevelFilters.Remove(level); + ApplyView(); + return true; + } + + public void CycleSort() + { + SetSortMode( + (SongSortMode)(((int)SortMode + 1) % + Enum.GetValues(typeof(SongSortMode)).Length)); + } + + public bool ToggleFavoritesOnly() + { + FavoritesOnly = !FavoritesOnly; + SelectedIndex = 0; + ApplyView(); + return true; + } + + public void RefreshFavoriteStatus() + { + var selectedPath = SelectedSong?.ChartPath; + ApplyView(); + if (string.IsNullOrWhiteSpace(selectedPath)) + return; + + var selected = _visibleSongs.FindIndex(song => + string.Equals(song.ChartPath, selectedPath, StringComparison.OrdinalIgnoreCase)); + if (selected >= 0) + SelectedIndex = selected + 1; + } + + public bool SetSelectedIndex(int index) + { + var clamped = Mathf.Clamp(index, 0, Math.Max(0, DisplayItemCount - 1)); + if (SelectedIndex == clamped) + return false; + SelectedIndex = clamped; + return true; + } + + public bool MoveSelection(int delta) + { + if (DisplayItemCount == 0 || delta == 0) + return false; + + var next = ((SelectedIndex + delta) % DisplayItemCount + DisplayItemCount) % DisplayItemCount; + if (SelectedIndex == next) + return false; + SelectedIndex = next; + return true; + } + + public bool OverrideLevelFilter(string level) + { + if (string.IsNullOrWhiteSpace(level)) + return false; + _selectedLevelFilters.Clear(); + _selectedLevelFilters.Add(level); + LevelFilterPanelOpen = false; + SortPanelOpen = false; + LevelFilterCursorIndex = 0; + SelectedIndex = 0; + ApplyView(); + return true; + } + + private void ApplyView() + { + _visibleSongs.Clear(); + _visibleSongs.AddRange(SongLibraryView.Apply( + _allSongs, + SearchQuery, + _selectedLevelFilters, + SortMode, + FavoritesOnly)); + SelectedIndex = Mathf.Clamp(SelectedIndex, 0, Math.Max(0, DisplayItemCount - 1)); + unchecked + { + ViewRevision++; + } + } + } +} diff --git a/Assets/ProjectQuint/Runtime/Unity/Features/Library/LibraryState.cs.meta b/Assets/ProjectQuint/Runtime/Unity/Features/Library/LibraryState.cs.meta new file mode 100644 index 0000000..b725569 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Features/Library/LibraryState.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9f82783c7cd2e4806b35fe94350ddd7c diff --git a/Assets/ProjectQuint/Runtime/Unity/Features/Loading.meta b/Assets/ProjectQuint/Runtime/Unity/Features/Loading.meta new file mode 100644 index 0000000..c9b6407 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Features/Loading.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 0a46f58b2b49c4db581345f5086a0b9e +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/ProjectQuint/Runtime/Unity/Features/Loading/PreparedSong.cs b/Assets/ProjectQuint/Runtime/Unity/Features/Loading/PreparedSong.cs new file mode 100644 index 0000000..9b91214 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Features/Loading/PreparedSong.cs @@ -0,0 +1,119 @@ +using System; +using System.Collections.Generic; +using ProjectQuint.Audio; +using ProjectQuint.Core.Charts; +using ProjectQuint.Library; +using ProjectQuint.Video; +using UnityEngine; + +namespace ProjectQuint.Features.Loading +{ + internal enum PreparedSongState + { + Prepared, + Committed, + Disposed, + } + + /// + /// Owns every Unity and managed resource needed by one playable song. A candidate may be + /// committed exactly once; disposing an uncommitted candidate is the loading rollback path. + /// + internal sealed class PreparedSong : IDisposable + { + private GameObject _resourceRoot; + + public PreparedSong( + SongLibraryEntry entry, + PlayableChart chart, + IReadOnlyDictionary notesById, + int[] laneMap, + ISongAudioBackend audio, + BmsBgaSystem bga, + GameObject resourceRoot) + { + Entry = entry ?? throw new ArgumentNullException(nameof(entry)); + Chart = chart ?? throw new ArgumentNullException(nameof(chart)); + NotesById = notesById ?? throw new ArgumentNullException(nameof(notesById)); + LaneMap = laneMap ?? throw new ArgumentNullException(nameof(laneMap)); + Audio = audio ?? throw new ArgumentNullException(nameof(audio)); + Bga = bga ?? throw new ArgumentNullException(nameof(bga)); + _resourceRoot = resourceRoot ?? throw new ArgumentNullException(nameof(resourceRoot)); + } + + public SongLibraryEntry Entry { get; } + public PlayableChart Chart { get; } + public IReadOnlyDictionary NotesById { get; } + public int[] LaneMap { get; } + public ISongAudioBackend Audio { get; private set; } + public BmsBgaSystem Bga { get; private set; } + public Texture2D StageTexture { get; private set; } + public PreparedSongState State { get; private set; } = PreparedSongState.Prepared; + public bool IsDisposed => State == PreparedSongState.Disposed; + + public void AttachStageTexture(Texture2D texture) + { + if (State != PreparedSongState.Prepared) + throw new InvalidOperationException("Stage texture can only be attached while a song is being prepared."); + if (StageTexture != null && StageTexture != texture) + DestroyUnityObject(StageTexture); + StageTexture = texture; + } + + public bool TryCommit() + { + if (State != PreparedSongState.Prepared) + return false; + + State = PreparedSongState.Committed; + if (_resourceRoot != null) + _resourceRoot.name = $"Active Song Resources - {Chart.Metadata.Title}"; + return true; + } + + public void Dispose() + { + if (State == PreparedSongState.Disposed) + return; + + State = PreparedSongState.Disposed; + var audio = Audio; + var bga = Bga; + var stageTexture = StageTexture; + var resourceRoot = _resourceRoot; + Audio = null; + Bga = null; + StageTexture = null; + _resourceRoot = null; + + try + { + audio?.Dispose(); + } + finally + { + try + { + bga?.Dispose(); + } + finally + { + DestroyUnityObject(stageTexture); + DestroyUnityObject(resourceRoot); + } + } + } + + internal GameObject ResourceRoot => _resourceRoot; + + private static void DestroyUnityObject(UnityEngine.Object target) + { + if (target == null) + return; + if (Application.isPlaying) + UnityEngine.Object.Destroy(target); + else + UnityEngine.Object.DestroyImmediate(target); + } + } +} diff --git a/Assets/ProjectQuint/Runtime/Unity/Features/Loading/PreparedSong.cs.meta b/Assets/ProjectQuint/Runtime/Unity/Features/Loading/PreparedSong.cs.meta new file mode 100644 index 0000000..0f29b3c --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Features/Loading/PreparedSong.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 47793599139954525923d3f67ba25a6c \ No newline at end of file diff --git a/Assets/ProjectQuint/Runtime/Unity/Features/Loading/SongLoadCoordinator.cs b/Assets/ProjectQuint/Runtime/Unity/Features/Loading/SongLoadCoordinator.cs new file mode 100644 index 0000000..84290da --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Features/Loading/SongLoadCoordinator.cs @@ -0,0 +1,407 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using ProjectQuint.Audio; +using ProjectQuint.Core.Bms; +using ProjectQuint.Core.Charts; +using ProjectQuint.Infrastructure.Images; +using ProjectQuint.Library; +using ProjectQuint.Settings; +using ProjectQuint.Video; +using UnityEngine; + +namespace ProjectQuint.Features.Loading +{ + internal readonly struct SongLoadProgress + { + public SongLoadProgress(string detail, int current = 0, int total = 0) + { + Detail = detail ?? string.Empty; + Current = current; + Total = total; + } + + public string Detail { get; } + public int Current { get; } + public int Total { get; } + } + + internal sealed class SongLoadRequest + { + public SongLibraryEntry Entry { get; set; } + public LaneModifier LaneModifier { get; set; } + public int LaneRandomSeed { get; set; } + public bool ReactiveKeySoundsEnabled { get; set; } + public BgaPlaybackMode BgaPlaybackMode { get; set; } = BgaPlaybackMode.Full; + public bool BgaEnabled => BgaPlaybackMode == ProjectQuint.Settings.BgaPlaybackMode.Full; + public bool StageImageEnabled => BgaPlaybackMode != ProjectQuint.Settings.BgaPlaybackMode.Black; + public Func CancellationRequested { get; set; } + } + + internal sealed class SongLoadResult : IDisposable + { + private SongLoadResult() { } + + public bool IsSuccess { get; private set; } + public bool IsCancelled { get; private set; } + public string ErrorTitle { get; private set; } = string.Empty; + public string ErrorDetail { get; private set; } = string.Empty; + public PreparedSong PreparedSong { get; private set; } + public SongLibraryEntry Entry => PreparedSong?.Entry; + public PlayableChart Chart => PreparedSong?.Chart; + public Texture2D StageTexture => PreparedSong?.StageTexture; + public IReadOnlyDictionary NotesById => PreparedSong?.NotesById; + public int[] LaneMap => PreparedSong?.LaneMap ?? Array.Empty(); + + public static SongLoadResult Success(PreparedSong preparedSong) => + new SongLoadResult + { + IsSuccess = true, + PreparedSong = preparedSong ?? throw new ArgumentNullException(nameof(preparedSong)), + }; + + public static SongLoadResult Cancelled() => new SongLoadResult { IsCancelled = true }; + + public static SongLoadResult Failed(string title, string detail) => new SongLoadResult + { + ErrorTitle = title ?? "SONG LOAD FAILED", + ErrorDetail = detail ?? string.Empty, + }; + + public PreparedSong TakePreparedSong() + { + var preparedSong = PreparedSong; + PreparedSong = null; + return preparedSong; + } + + public void Dispose() + { + PreparedSong?.Dispose(); + PreparedSong = null; + } + } + + internal sealed class SongLoadCoordinator + { + private readonly GameObject _owner; + private readonly ISongAudioBackendFactory _audioBackendFactory; + + public SongLoadCoordinator( + GameObject owner, + ISongAudioBackendFactory audioBackendFactory = null) + { + _owner = owner ?? throw new ArgumentNullException(nameof(owner)); + _audioBackendFactory = audioBackendFactory ?? new UnitySongAudioBackendFactory(); + } + + public IEnumerator Load( + SongLoadRequest request, + Action progress, + Action completed) + { + if (request?.Entry == null) + { + completed?.Invoke(SongLoadResult.Failed("SONG LOAD FAILED", "No chart was selected.")); + yield break; + } + + progress?.Invoke(new SongLoadProgress("Parsing BMS timeline")); + var parseTask = Task.Run(() => ParseAndTransform( + request.Entry, + request.LaneModifier, + request.LaneRandomSeed)); + while (!parseTask.IsCompleted) + { + if (IsCancelled(request)) + { + ObserveFaultAfterCancellation(parseTask); + completed?.Invoke(SongLoadResult.Cancelled()); + yield break; + } + yield return null; + } + if (IsCancelled(request)) + { + completed?.Invoke(SongLoadResult.Cancelled()); + yield break; + } + if (parseTask.IsFaulted) + { + completed?.Invoke(SongLoadResult.Failed( + "CHART PARSE FAILED", + parseTask.Exception?.GetBaseException().Message ?? request.Entry.ChartPath)); + yield break; + } + + var prepared = parseTask.Result; + var chart = prepared.Chart; + GameObject resourceRoot = null; + PreparedSong candidate = null; + try + { + resourceRoot = new GameObject($"Prepared Song Resources - {chart.Metadata.Title}"); + resourceRoot.transform.SetParent(_owner.transform, false); + candidate = new PreparedSong( + request.Entry, + chart, + prepared.NotesById, + prepared.LaneMap, + _audioBackendFactory.Create(resourceRoot), + new BmsBgaSystem(resourceRoot), + resourceRoot); + resourceRoot = null; + + progress?.Invoke(new SongLoadProgress( + $"Loading referenced keysounds // {chart.Notes.Count:N0} notes")); + yield return candidate.Audio.Load( + chart, + (current, total, detail) => + { + if (IsCancelled(request)) + return; + progress?.Invoke(new SongLoadProgress( + string.IsNullOrEmpty(detail) ? "Keysounds loaded" : $"Loading keysound {detail}", + current, + total)); + }, + () => IsCancelled(request), + request.ReactiveKeySoundsEnabled); + if (IsCancelled(request)) + { + completed?.Invoke(SongLoadResult.Cancelled()); + yield break; + } + + if (!string.IsNullOrWhiteSpace(candidate.Audio.LoadError)) + { + completed?.Invoke(SongLoadResult.Failed( + "AUDIO LOAD FAILED", + candidate.Audio.LoadError)); + yield break; + } + + if (!request.ReactiveKeySoundsEnabled) + { + progress?.Invoke(new SongLoadProgress("Preparing single-track smooth playback")); + yield return candidate.Audio.PrepareSmoothPlaybackMix( + (current, total, detail) => + { + if (!IsCancelled(request)) + progress?.Invoke(new SongLoadProgress(detail, current, total)); + }, + () => IsCancelled(request)); + if (IsCancelled(request)) + { + completed?.Invoke(SongLoadResult.Cancelled()); + yield break; + } + if (!candidate.Audio.SmoothMixReady) + { + completed?.Invoke(SongLoadResult.Failed( + "AUDIO MIX FAILED", + string.IsNullOrWhiteSpace(candidate.Audio.SmoothMixError) + ? "The single-track smooth playback mix could not be prepared." + : candidate.Audio.SmoothMixError)); + yield break; + } + } + else + { + progress?.Invoke(new SongLoadProgress("Preparing reactive keysound source pool")); + string audioPrepareError = null; + try + { + candidate.Audio.PrepareReactivePlayback(); + } + catch (Exception exception) + { + audioPrepareError = exception.Message; + } + if (!string.IsNullOrWhiteSpace(audioPrepareError)) + { + completed?.Invoke(SongLoadResult.Failed("AUDIO PREPARE FAILED", audioPrepareError)); + yield break; + } + } + + if (candidate.Audio.MissingClipCount > 0 || + candidate.Audio.DecodeFailedClipCount > 0 || + candidate.Audio.SilentPlaceholderCount > 0) + { + Debug.LogWarning( + $"[ProjectQuint Audio] Keysound summary for {request.Entry.ChartPath}: " + + $"{candidate.Audio.MissingClipCount} missing, " + + $"{candidate.Audio.DecodeFailedClipCount} decode failed, " + + $"{candidate.Audio.SilentPlaceholderCount} intentional silent placeholders."); + } + + if (request.BgaEnabled) + { + progress?.Invoke(new SongLoadProgress("Preparing BGA timeline")); + yield return candidate.Bga.Load( + chart, + detail => + { + if (!IsCancelled(request) && !string.IsNullOrEmpty(detail)) + progress?.Invoke(new SongLoadProgress(detail)); + }, + () => IsCancelled(request)); + } + else + { + candidate.Bga.Clear(); + progress?.Invoke(new SongLoadProgress("BGA video disabled")); + } + if (IsCancelled(request)) + { + completed?.Invoke(SongLoadResult.Cancelled()); + yield break; + } + + if (request.StageImageEnabled) + { + Texture2D stageTexture = null; + yield return LoadStageTexture( + chart, + texture => stageTexture = texture, + request.CancellationRequested); + candidate.AttachStageTexture(stageTexture); + } + else + { + progress?.Invoke(new SongLoadProgress("Background image disabled")); + } + if (IsCancelled(request)) + { + completed?.Invoke(SongLoadResult.Cancelled()); + yield break; + } + + var result = SongLoadResult.Success(candidate); + completed?.Invoke(result); + if (completed != null) + candidate = null; + } + finally + { + // Any early exit, thrown exception or stopped coroutine rolls back all + // clips, textures, video players and AudioSources created by this load. + candidate?.Dispose(); + DestroyResourceRoot(resourceRoot); + } + } + + internal static PreparedChart ParseAndTransform( + SongLibraryEntry entry, + LaneModifier laneModifier, + int randomSeed) + { + if (entry == null) + throw new ArgumentNullException(nameof(entry)); + + var document = BmsParser.ParseFile( + entry.ChartPath, + forcedKeyCount: entry.EffectiveKeyCount); + var chart = BmsTimelineCompiler.Compile(document); + chart.Metadata.Md5 = entry.Metadata.Md5; + chart.Metadata.Sha256 = entry.Metadata.Sha256; + if (!string.IsNullOrWhiteSpace(entry.DisplayTitle)) + chart.Metadata.Title = entry.DisplayTitle; + if (!string.IsNullOrWhiteSpace(entry.DisplayArtist)) + chart.Metadata.Artist = entry.DisplayArtist; + + var conversion = BmsPlayableChartConverter.Convert(chart, laneModifier, randomSeed); + var playableChart = conversion.Chart; + playableChart.Metadata.DifficultyLabel = entry.DifficultyLabel; + var notesById = new Dictionary(playableChart.Notes.Count); + foreach (var note in playableChart.Notes) + notesById[note.Id] = note; + return new PreparedChart(playableChart, notesById, conversion.LaneMap); + } + + internal static bool ShouldCancel(int loadGeneration, int currentGeneration, bool isLoadingMode) => + loadGeneration != currentGeneration || !isLoadingMode; + + public string BuildDiagnostics(PreparedSong song, SongLoadRequest request, string gaugeLabel) + { + if (song?.Chart == null || request == null) + return string.Empty; + var chart = song.Chart; + return $"[ProjectQuint] Playing {chart.Metadata.Title}: {chart.Notes.Count} notes, " + + $"{chart.BackgroundAudioEvents.Count} BGM events, " + + $"{ChartKeyMode.FormatLabel(chart.Metadata.KeyCount, chart.Metadata.HasScratch)}, " + + $"{chart.ScratchObjectCount} scratch objects, " + + $"{song.Audio.LoadedClipCount} clips loaded, {song.Audio.MissingClipCount} missing, " + + $"{song.Audio.DecodeFailedClipCount} decode failed, " + + $"{song.Audio.SilentPlaceholderCount} silent placeholders, " + + $"audio PCM {FormatMiB(song.Audio.EstimatedDecodedPcmBytes)} decoded + " + + $"{FormatMiB(song.Audio.EstimatedPremixedPcmBytes)} premix, " + + $"premix cache {(request.ReactiveKeySoundsEnabled ? "N/A" : song.Audio.SmoothMixCacheStatus)}, " + + $"BGA events {song.Bga.BaseEventCount}/{song.Bga.LayerEventCount}/{song.Bga.PoorEventCount} base/layer/poor, " + + $"{song.Bga.StaticImageCount} images and {song.Bga.VideoCount} videos, " + + $"BGA {request.BgaPlaybackMode.ToString().ToUpperInvariant()}, " + + $"reactive keysounds {(request.ReactiveKeySoundsEnabled ? "ON" : "OFF")}, " + + $"audio backend {song.Audio.BackendId}/{song.Audio.Clock.ClockId}, " + + $"gauge {gaugeLabel}, modifier {request.LaneModifier}, lane map {string.Join("-", song.LaneMap)}."; + } + + private static IEnumerator LoadStageTexture( + PlayableChart chart, + Action completed, + Func cancellationRequested) + { + var declared = !string.IsNullOrWhiteSpace(chart.Metadata.StageImage) + ? chart.Metadata.StageImage + : chart.Metadata.BackgroundImage; + var path = BmsResourceResolver.ResolveImage(chart.Metadata.SourcePath, declared); + if (string.IsNullOrEmpty(path)) + { + completed?.Invoke(null); + yield break; + } + yield return BmsImageLoader.Load(path, completed, cancellationRequested); + } + + private static bool IsCancelled(SongLoadRequest request) => + request.CancellationRequested?.Invoke() == true; + + private static string FormatMiB(long bytes) => $"{bytes / (1024d * 1024d):0.#} MiB"; + + private static void DestroyResourceRoot(GameObject resourceRoot) + { + if (resourceRoot == null) + return; + if (Application.isPlaying) + UnityEngine.Object.Destroy(resourceRoot); + else + UnityEngine.Object.DestroyImmediate(resourceRoot); + } + + private static void ObserveFaultAfterCancellation(Task task) + { + task.ContinueWith( + completed => { _ = completed.Exception; }, + TaskContinuationOptions.OnlyOnFaulted); + } + + internal sealed class PreparedChart + { + public PreparedChart( + PlayableChart chart, + IReadOnlyDictionary notesById, + int[] laneMap) + { + Chart = chart; + NotesById = notesById; + LaneMap = laneMap; + } + + public PlayableChart Chart { get; } + public IReadOnlyDictionary NotesById { get; } + public int[] LaneMap { get; } + } + } +} diff --git a/Assets/ProjectQuint/Runtime/Unity/Features/Loading/SongLoadCoordinator.cs.meta b/Assets/ProjectQuint/Runtime/Unity/Features/Loading/SongLoadCoordinator.cs.meta new file mode 100644 index 0000000..bc12801 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Features/Loading/SongLoadCoordinator.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3b890350b1da44e198a83bad7aad7ce2 \ No newline at end of file diff --git a/Assets/ProjectQuint/Runtime/Unity/Features/ProjectQuint.Application.asmdef b/Assets/ProjectQuint/Runtime/Unity/Features/ProjectQuint.Application.asmdef new file mode 100644 index 0000000..5325f05 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Features/ProjectQuint.Application.asmdef @@ -0,0 +1,21 @@ +{ + "name": "ProjectQuint.Application", + "rootNamespace": "ProjectQuint.Features", + "references": [ + "ProjectQuint.Core", + "ProjectQuint.Infrastructure", + "ProjectQuint.Media.Audio", + "ProjectQuint.Media.Images", + "ProjectQuint.Media.Video", + "Unity.InputSystem" + ], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/Assets/ProjectQuint/Runtime/Unity/Features/ProjectQuint.Application.asmdef.meta b/Assets/ProjectQuint/Runtime/Unity/Features/ProjectQuint.Application.asmdef.meta new file mode 100644 index 0000000..16cbf65 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Features/ProjectQuint.Application.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 4df1c926a82d44ad3874c8098dcc6fb8 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/ProjectQuint/Runtime/Unity/Features/Results.meta b/Assets/ProjectQuint/Runtime/Unity/Features/Results.meta new file mode 100644 index 0000000..f00e2b5 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Features/Results.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: a4c02bbd16daa4fb097eb4bd238812af +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/ProjectQuint/Runtime/Unity/Features/Results/ResultSessionController.cs b/Assets/ProjectQuint/Runtime/Unity/Features/Results/ResultSessionController.cs new file mode 100644 index 0000000..c09fb96 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Features/Results/ResultSessionController.cs @@ -0,0 +1,231 @@ +using System; +using System.IO; +using ProjectQuint.Core.Progression; +using ProjectQuint.Library; +using ProjectQuint.Persistence; + +namespace ProjectQuint.Features.Results +{ + internal readonly struct GameplayCompletionOutcome + { + public GameplayCompletionOutcome(bool perfect, bool fullCombo, bool newPersonalBest) + { + Perfect = perfect; + FullCombo = fullCombo; + NewPersonalBest = newPersonalBest; + } + + public bool Perfect { get; } + public bool FullCombo { get; } + public bool NewPersonalBest { get; } + public bool HasPresentation => Perfect || FullCombo || NewPersonalBest; + public string PrimaryLabel => Perfect + ? "PERFECT" + : FullCombo ? "FULL COMBO" : NewPersonalBest ? "BEST" : string.Empty; + public string SecondaryLabel => + NewPersonalBest && (Perfect || FullCombo) ? "BEST" : string.Empty; + + public static GameplayCompletionOutcome Evaluate( + SongPlayRecord previousRecord, + bool cleared, + bool autoPlay, + bool saveFailed, + int score, + double accuracyPercent, + int maxCombo, + int missCount, + int purePerfectCount, + int totalScoringUnits) + { + var hasScoringUnits = totalScoringUnits > 0; + var perfect = cleared && + hasScoringUnits && + purePerfectCount >= totalScoringUnits; + var fullCombo = cleared && hasScoringUnits && missCount == 0; + var newPersonalBest = IsNewPersonalBest( + previousRecord, + autoPlay, + saveFailed, + score, + accuracyPercent, + maxCombo); + return new GameplayCompletionOutcome(perfect, fullCombo, newPersonalBest); + } + + public static bool IsNewPersonalBest( + SongPlayRecord previousRecord, + bool autoPlay, + bool saveFailed, + int score, + double accuracyPercent, + int maxCombo) + { + if (autoPlay || saveFailed) + return false; + if (previousRecord?.HasRecord != true) + return true; + return score > previousRecord.BestScore || + accuracyPercent > previousRecord.BestAccuracyPercent + 0.000001 || + maxCombo > previousRecord.BestMaxCombo; + } + } + + /// + /// Owns one normal-song result lifecycle independently from gameplay resources. + /// + internal sealed class ResultSessionController + { + public const double GameplayOutroDurationSeconds = 0.9; + + public bool SongCleared { get; private set; } + public bool CurrentPlayRecorded { get; private set; } + public string CurrentPlayEventId { get; private set; } = string.Empty; + public SongPlayRecord PreviousRecord { get; private set; } + public ProfileProgressUpdate ProfileUpdate { get; private set; } + public RatingUpdate RatingUpdate { get; private set; } + public string RecordSaveError { get; private set; } = string.Empty; + public double EnteredRealtime { get; private set; } + public long PlayedUtcTicks { get; private set; } + public int Selection { get; set; } = 1; + public bool GameplayOutroActive { get; private set; } + public double GameplayOutroEnteredRealtime { get; private set; } + public GameplayCompletionOutcome GameplayCompletion { get; private set; } + + public void BeginPlayback() + { + SongCleared = false; + CurrentPlayRecorded = false; + CurrentPlayEventId = Guid.NewGuid().ToString("N"); + ProfileUpdate = default; + RatingUpdate = default; + RecordSaveError = string.Empty; + GameplayOutroActive = false; + GameplayOutroEnteredRealtime = 0.0; + GameplayCompletion = default; + } + + public void BeginResults(SongPlayRecord previousRecord, bool songCleared, double enteredRealtime) + { + PreviousRecord = previousRecord; + SongCleared = songCleared; + EnteredRealtime = enteredRealtime; + PlayedUtcTicks = DateTime.UtcNow.Ticks; + Selection = 1; + } + + public void BeginGameplayOutro( + SongPlayRecord previousRecord, + bool songCleared, + GameplayCompletionOutcome completion, + double enteredRealtime) + { + BeginResults(previousRecord, songCleared, enteredRealtime); + GameplayCompletion = completion; + GameplayOutroEnteredRealtime = Math.Max(0.0, enteredRealtime); + GameplayOutroActive = completion.HasPresentation; + } + + public bool GameplayOutroComplete(double realtime) => + GameplayOutroActive && + realtime - GameplayOutroEnteredRealtime >= GameplayOutroDurationSeconds; + + public void CompleteGameplayOutro(double resultsEnteredRealtime) + { + GameplayOutroActive = false; + GameplayOutroEnteredRealtime = 0.0; + GameplayCompletion = default; + EnteredRealtime = Math.Max(0.0, resultsEnteredRealtime); + } + + public void BeginGameOver() + { + SongCleared = false; + } + + public bool TryMarkRecorded() + { + if (CurrentPlayRecorded) + return false; + + CurrentPlayRecorded = true; + return true; + } + + public void SetCommitOutcome(PlayCommitOutcome outcome) + { + ProfileUpdate = outcome.Profile; + RatingUpdate = outcome.Rating; + RecordSaveError = string.Empty; + } + + public void SetCommitFailure(string error) + { + ProfileUpdate = default; + RatingUpdate = default; + RecordSaveError = string.IsNullOrWhiteSpace(error) + ? "LOCAL RECORD COULD NOT BE SAVED" + : error.Trim(); + } + + public void ToggleSelection() + { + Selection = 1 - Selection; + } + + public void Clear() + { + SongCleared = false; + CurrentPlayRecorded = false; + CurrentPlayEventId = string.Empty; + PreviousRecord = null; + ProfileUpdate = default; + RatingUpdate = default; + RecordSaveError = string.Empty; + EnteredRealtime = 0.0; + PlayedUtcTicks = 0; + Selection = 1; + GameplayOutroActive = false; + GameplayOutroEnteredRealtime = 0.0; + GameplayCompletion = default; + } + } + + internal static class PlayResultCommitGuard + { + public static bool TryCommit( + Func commit, + out PlayCommitOutcome outcome, + out string userFacingError, + out Exception exception) + { + if (commit == null) + throw new ArgumentNullException(nameof(commit)); + + try + { + outcome = commit(); + userFacingError = string.Empty; + exception = null; + return true; + } + catch (Exception caught) + { + outcome = default; + exception = caught; + userFacingError = FormatUserFacingError(caught); + return false; + } + } + + internal static string FormatUserFacingError(Exception exception) + { + if (exception is IOException && + exception.Message.IndexOf("disk full", StringComparison.OrdinalIgnoreCase) >= 0) + { + return "STORAGE FULL // FREE DISK SPACE AND RETRY"; + } + + return "LOCAL RECORD COULD NOT BE SAVED // RETRY AFTER CHECKING STORAGE"; + } + } +} diff --git a/Assets/ProjectQuint/Runtime/Unity/Features/Results/ResultSessionController.cs.meta b/Assets/ProjectQuint/Runtime/Unity/Features/Results/ResultSessionController.cs.meta new file mode 100644 index 0000000..4ab27af --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Features/Results/ResultSessionController.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: b63e3c75914e04356abc0ba60f153338 \ No newline at end of file diff --git a/Assets/ProjectQuint/Runtime/Unity/Features/Settings.meta b/Assets/ProjectQuint/Runtime/Unity/Features/Settings.meta new file mode 100644 index 0000000..b2e7f9a --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Features/Settings.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 45c451b4a0e85426a83550d79223839b +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/ProjectQuint/Runtime/Unity/Features/Settings/AudioSyncCalibrationSession.cs b/Assets/ProjectQuint/Runtime/Unity/Features/Settings/AudioSyncCalibrationSession.cs new file mode 100644 index 0000000..d1c01f6 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Features/Settings/AudioSyncCalibrationSession.cs @@ -0,0 +1,147 @@ +using System; +using System.Collections.Generic; +using ProjectQuint.Audio; +using ProjectQuint.Core.Timing; + +namespace ProjectQuint.Features.Settings +{ + internal enum AudioSyncCalibrationPhase + { + Intro, + Running, + Result, + Error, + } + + internal sealed class AudioSyncCalibrationSession : IDisposable + { + private const double ClickIntervalSeconds = 0.75; + private const double InitialLeadSeconds = 1.0; + private const int MaximumExpiredMeasurementClicks = 5; + private readonly FmodAudioSyncMetronome _metronome; + private readonly IAudioClock _clock; + private readonly List _samples = new List( + AudioSyncCalibrationEstimator.MeasurementClickCount); + private double[] _clickTimes = Array.Empty(); + private int _nextExpectedClickIndex; + private int _expiredMeasurementClicks; + private bool _disposed; + + internal AudioSyncCalibrationSession( + FmodAudioSyncMetronome metronome, + IAudioClock clock, + string deviceName) + { + _metronome = metronome ?? throw new ArgumentNullException(nameof(metronome)); + _clock = clock ?? throw new ArgumentNullException(nameof(clock)); + DeviceName = string.IsNullOrWhiteSpace(deviceName) ? "UNKNOWN DEVICE" : deviceName.Trim(); + } + + internal AudioSyncCalibrationPhase Phase { get; private set; } = + AudioSyncCalibrationPhase.Intro; + internal string DeviceName { get; } + internal int PracticeProgress { get; private set; } + internal int MeasurementProgress { get; private set; } + internal AudioSyncCalibrationResult Result { get; private set; } + internal string Error { get; private set; } = string.Empty; + + internal void Begin() + { + ThrowIfDisposed(); + _samples.Clear(); + PracticeProgress = 0; + MeasurementProgress = 0; + Result = default; + Error = string.Empty; + _nextExpectedClickIndex = 0; + _expiredMeasurementClicks = 0; + try + { + _clickTimes = _metronome.ScheduleClicks( + AudioSyncCalibrationEstimator.PracticeClickCount + + AudioSyncCalibrationEstimator.MeasurementClickCount, + ClickIntervalSeconds, + InitialLeadSeconds); + Phase = AudioSyncCalibrationPhase.Running; + } + catch (Exception exception) + { + Error = exception.Message; + Phase = AudioSyncCalibrationPhase.Error; + } + } + + internal void Update() + { + if (Phase != AudioSyncCalibrationPhase.Running || _clickTimes.Length == 0) + return; + var now = _clock.CurrentTime; + while (AudioSyncTapMatcher.HasExpired( + _clickTimes, + _nextExpectedClickIndex, + now)) + { + if (_nextExpectedClickIndex >= AudioSyncCalibrationEstimator.PracticeClickCount) + _expiredMeasurementClicks++; + _nextExpectedClickIndex++; + } + + if (_expiredMeasurementClicks >= MaximumExpiredMeasurementClicks && + _samples.Count < AudioSyncCalibrationEstimator.MinimumAcceptedSamples) + { + Error = + "TAPS WERE NOT DETECTED WITHIN 650 ms. " + + "THE OUTPUT LATENCY MAY EXCEED THE SUPPORTED RANGE."; + Phase = AudioSyncCalibrationPhase.Error; + return; + } + + if (now <= _clickTimes[_clickTimes.Length - 1] + + AudioSyncTapMatcher.MaximumOffsetSeconds) + return; + + Result = AudioSyncCalibrationEstimator.Estimate(_samples); + Phase = AudioSyncCalibrationPhase.Result; + } + + internal bool RecordTap(double inputEventTime, double currentInputTime) + { + if (Phase != AudioSyncCalibrationPhase.Running || _clickTimes.Length == 0) + return false; + + var logicalTapTime = _clock.CurrentTime + inputEventTime - currentInputTime; + if (!AudioSyncTapMatcher.TryMatchExpected( + _clickTimes, + _nextExpectedClickIndex, + logicalTapTime, + out var offsetSeconds)) + return false; + + var matchedIndex = _nextExpectedClickIndex++; + if (matchedIndex >= AudioSyncCalibrationEstimator.PracticeClickCount) + { + _samples.Add(offsetSeconds * 1000.0); + MeasurementProgress = _samples.Count; + } + else + { + PracticeProgress++; + } + return true; + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + _metronome.Dispose(); + } + + private void ThrowIfDisposed() + { + if (_disposed) + throw new ObjectDisposedException(nameof(AudioSyncCalibrationSession)); + } + } +} diff --git a/Assets/ProjectQuint/Runtime/Unity/Features/Settings/AudioSyncCalibrationSession.cs.meta b/Assets/ProjectQuint/Runtime/Unity/Features/Settings/AudioSyncCalibrationSession.cs.meta new file mode 100644 index 0000000..976bc2a --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Features/Settings/AudioSyncCalibrationSession.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 2b5a7c14bbb64555977d58f68b6396cf diff --git a/Assets/ProjectQuint/Runtime/Unity/Infrastructure.meta b/Assets/ProjectQuint/Runtime/Unity/Infrastructure.meta new file mode 100644 index 0000000..0ca3c84 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Infrastructure.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: a60cd3633e88f4969a39a5a7e3c33009 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/ProjectQuint/Runtime/Unity/Infrastructure/Images.meta b/Assets/ProjectQuint/Runtime/Unity/Infrastructure/Images.meta new file mode 100644 index 0000000..f073142 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Infrastructure/Images.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: faec87aabb896407589e6ba72b2ae6ae +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/ProjectQuint/Runtime/Unity/Infrastructure/Images/AssemblyInfo.cs b/Assets/ProjectQuint/Runtime/Unity/Infrastructure/Images/AssemblyInfo.cs new file mode 100644 index 0000000..62a8163 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Infrastructure/Images/AssemblyInfo.cs @@ -0,0 +1,6 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("ProjectQuint.Application")] +[assembly: InternalsVisibleTo("ProjectQuint.Bootstrap")] +[assembly: InternalsVisibleTo("ProjectQuint.Presentation")] +[assembly: InternalsVisibleTo("ProjectQuint.Tests.EditMode")] diff --git a/Assets/ProjectQuint/Runtime/Unity/Infrastructure/Images/AssemblyInfo.cs.meta b/Assets/ProjectQuint/Runtime/Unity/Infrastructure/Images/AssemblyInfo.cs.meta new file mode 100644 index 0000000..1c041ad --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Infrastructure/Images/AssemblyInfo.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c780c1f3801da4314b596c96df564f45 \ No newline at end of file diff --git a/Assets/ProjectQuint/Runtime/Unity/Infrastructure/Images/BmsImageLoader.cs b/Assets/ProjectQuint/Runtime/Unity/Infrastructure/Images/BmsImageLoader.cs new file mode 100644 index 0000000..b788314 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Infrastructure/Images/BmsImageLoader.cs @@ -0,0 +1,117 @@ +using System; +using System.Collections; +using System.IO; +using System.Threading.Tasks; +using ProjectQuint.Library; +using UnityEngine; + +namespace ProjectQuint.Infrastructure.Images +{ + internal static class BmsImageLoader + { + public static IEnumerator Load( + string path, + Action completed, + Func cancellationRequested = null) + { + if (string.IsNullOrWhiteSpace(path)) + { + completed?.Invoke(null); + yield break; + } + + if (string.Equals(Path.GetExtension(path), ".bmp", StringComparison.OrdinalIgnoreCase)) + { + var decodeTask = Task.Run(() => BitmapImageDecoder.DecodeFile(path)); + while (!decodeTask.IsCompleted) + { + if (cancellationRequested?.Invoke() == true) + { + ObserveFaultAfterCancellation(decodeTask); + completed?.Invoke(null); + yield break; + } + yield return null; + } + if (cancellationRequested?.Invoke() == true) + { + completed?.Invoke(null); + yield break; + } + if (decodeTask.IsFaulted) + { + Debug.LogWarning( + $"[ProjectQuint] Could not decode BMP image '{path}': " + + decodeTask.Exception?.GetBaseException().Message); + completed?.Invoke(null); + yield break; + } + + var decoded = decodeTask.Result; + var bitmapTexture = new Texture2D(decoded.Width, decoded.Height, TextureFormat.RGBA32, false) + { + name = $"BMS Image {Path.GetFileName(path)}", + }; + bitmapTexture.SetPixels32(decoded.Pixels); + bitmapTexture.Apply(false, true); + completed?.Invoke(bitmapTexture); + yield break; + } + + var readTask = Task.Run(() => File.ReadAllBytes(path)); + while (!readTask.IsCompleted) + { + if (cancellationRequested?.Invoke() == true) + { + ObserveFaultAfterCancellation(readTask); + completed?.Invoke(null); + yield break; + } + yield return null; + } + if (cancellationRequested?.Invoke() == true) + { + completed?.Invoke(null); + yield break; + } + if (readTask.IsFaulted) + { + Debug.LogWarning( + $"[ProjectQuint] Could not read image '{path}': " + + readTask.Exception?.GetBaseException().Message); + completed?.Invoke(null); + yield break; + } + + var texture = new Texture2D(2, 2, TextureFormat.RGBA32, false) + { + name = $"BMS Image {Path.GetFileName(path)}", + }; + if (!ImageConversion.LoadImage(texture, readTask.Result, true)) + { + DestroyTexture(texture); + Debug.LogWarning($"[ProjectQuint] Could not decode image '{path}'."); + completed?.Invoke(null); + yield break; + } + completed?.Invoke(texture); + } + + public static void DestroyTexture(Texture2D texture) + { + if (texture == null) + return; + if (Application.isPlaying) + UnityEngine.Object.Destroy(texture); + else + UnityEngine.Object.DestroyImmediate(texture); + } + + private static void ObserveFaultAfterCancellation(Task task) + { + task.ContinueWith( + completed => { _ = completed.Exception; }, + TaskContinuationOptions.OnlyOnFaulted); + } + } +} diff --git a/Assets/ProjectQuint/Runtime/Unity/Infrastructure/Images/BmsImageLoader.cs.meta b/Assets/ProjectQuint/Runtime/Unity/Infrastructure/Images/BmsImageLoader.cs.meta new file mode 100644 index 0000000..52009fe --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Infrastructure/Images/BmsImageLoader.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: eb21859db5fa74522a92d7a1dc55f23c \ No newline at end of file diff --git a/Assets/ProjectQuint/Runtime/Unity/Infrastructure/Images/ProjectQuint.Media.Images.asmdef b/Assets/ProjectQuint/Runtime/Unity/Infrastructure/Images/ProjectQuint.Media.Images.asmdef new file mode 100644 index 0000000..01fb6ef --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Infrastructure/Images/ProjectQuint.Media.Images.asmdef @@ -0,0 +1,16 @@ +{ + "name": "ProjectQuint.Media.Images", + "rootNamespace": "ProjectQuint.Infrastructure.Images", + "references": [ + "ProjectQuint.Infrastructure" + ], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/Assets/ProjectQuint/Runtime/Unity/Infrastructure/Images/ProjectQuint.Media.Images.asmdef.meta b/Assets/ProjectQuint/Runtime/Unity/Infrastructure/Images/ProjectQuint.Media.Images.asmdef.meta new file mode 100644 index 0000000..5e0673f --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Infrastructure/Images/ProjectQuint.Media.Images.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 744952c04594d4e45831ad78f362ae6d +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/ProjectQuint/Runtime/Unity/Library.meta b/Assets/ProjectQuint/Runtime/Unity/Library.meta new file mode 100644 index 0000000..5b3a3a7 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Library.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 71c85534fc33a44a59fa589e99aba8e7 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/ProjectQuint/Runtime/Unity/Library/AeryCourseIndex.cs b/Assets/ProjectQuint/Runtime/Unity/Library/AeryCourseIndex.cs new file mode 100644 index 0000000..5eebde9 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Library/AeryCourseIndex.cs @@ -0,0 +1,424 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using ProjectQuint.Core.Gauge; +using UnityEngine; + +namespace ProjectQuint.Library +{ + public sealed class AeryCourseIndex + { + public const string HeaderUrl = "https://asumatoki.kr/table/aery/header.json"; + private readonly List _courses; + + private AeryCourseIndex( + string providerId, + string providerDisplayName, + string tableName, + string symbol, + string lastUpdate, + List courses) + { + ProviderId = NormalizeProviderId(providerId); + ProviderDisplayName = string.IsNullOrWhiteSpace(providerDisplayName) + ? tableName ?? "BMS TABLE" + : providerDisplayName.Trim(); + TableName = tableName ?? string.Empty; + Symbol = symbol ?? string.Empty; + LastUpdate = lastUpdate ?? string.Empty; + _courses = courses ?? new List(); + foreach (var course in _courses) + { + course.ProviderId = ProviderId; + course.ProviderDisplayName = ProviderDisplayName; + course.Rules = BmsCourseRules.Analyze(course.constraint); + } + } + + public string ProviderId { get; } + public string ProviderDisplayName { get; } + public string TableName { get; } + public string Symbol { get; } + public string LastUpdate { get; } + public ExternalDataOrigin Origin { get; private set; } + public IReadOnlyList Courses => _courses; + + public void ResolveSongs(IReadOnlyList songs) + { + var byHash = (songs ?? Array.Empty()) + .SelectMany(song => new[] + { + new { Hash = song?.Metadata?.Md5, Song = song }, + new { Hash = song?.Metadata?.Sha256, Song = song }, + }) + .Where(item => !string.IsNullOrWhiteSpace(item.Hash) && item.Song != null) + .GroupBy(item => item.Hash.Trim(), StringComparer.OrdinalIgnoreCase) + .ToDictionary(group => group.Key, group => group.First(), StringComparer.OrdinalIgnoreCase); + + foreach (var course in _courses) + { + var hashes = course.StageHashes; + course.Stages = new SongLibraryEntry[hashes.Length]; + for (var stage = 0; stage < hashes.Length; stage++) + { + if (byHash.TryGetValue(hashes[stage] ?? string.Empty, out var match)) + course.Stages[stage] = match.Song; + } + course.EvaluateStageCompatibility(); + course.Record = AeryCourseRecordStore.Load(course); + } + } + + internal static ExternalDataResult LoadLocal( + string cachePath, + string bundledJson) + { + var result = ExternalJsonDataSource.LoadLocal( + "AERY courses", + cachePath, + bundledJson, + json => ParseValidated("aery", "AERY", json)); + result.Data ??= Empty("aery", "AERY"); + result.Data.Origin = result.Origin; + return result; + } + + internal static ExternalDataResult LoadTableLocal( + string providerId, + string providerDisplayName, + string headerCachePath) + { + var result = ExternalJsonDataSource.LoadLocal( + $"{providerDisplayName} courses", + headerCachePath, + string.Empty, + json => ParseValidated(providerId, providerDisplayName, json)); + result.Data ??= Empty(providerId, providerDisplayName); + result.Data.Origin = result.Origin; + return result; + } + + internal static IEnumerator LoadOnline( + string cachePath, + string bundledJson, + Action> completed) + { + yield return ExternalJsonDataSource.LoadOnline( + "AERY courses", + HeaderUrl, + cachePath, + bundledJson, + json => ParseValidated("aery", "AERY", json), + result => + { + result.Data ??= Empty("aery", "AERY"); + result.Data.Origin = result.Origin; + completed?.Invoke(result); + }); + } + + internal static AeryCourseIndex Parse(string json) + => Parse("aery", "AERY", json); + + internal static AeryCourseIndex Parse(string providerId, string providerDisplayName, string json) + { + try + { + return ParseValidated(providerId, providerDisplayName, json) ?? Empty(providerId, providerDisplayName); + } + catch (Exception exception) + { + Debug.LogWarning($"[ProjectQuint] Failed to parse BMSTable course data for '{providerId}': {exception.Message}"); + return Empty(providerId, providerDisplayName); + } + } + + private static AeryCourseIndex ParseValidated(string providerId, string providerDisplayName, string json) + { + if (string.IsNullOrWhiteSpace(json)) + return null; + + var metadata = JsonUtility.FromJson(json) ?? new AeryCourseHeaderMetadata(); + var courseArray = ExtractArrayProperty(json, "course"); + var courses = ExtractCourseObjects(courseArray) + .Select(item => + { + var course = JsonUtility.FromJson(item.Json); + if (course != null) + course.GroupIndex = item.GroupIndex; + return course; + }) + .Where(course => course != null && course.StageHashes.Length > 0) + .ToList(); + return courses.Count > 0 + ? new AeryCourseIndex( + providerId, + string.IsNullOrWhiteSpace(metadata.name) ? providerDisplayName : metadata.name, + metadata.name, + metadata.symbol, + metadata.last_update, + courses) + : null; + } + + private static AeryCourseIndex Empty(string providerId, string providerDisplayName) => + new AeryCourseIndex( + providerId, + providerDisplayName, + providerDisplayName, + string.Empty, + string.Empty, + new List()); + + private static string ExtractArrayProperty(string json, string propertyName) + { + var propertyIndex = json.IndexOf($"\"{propertyName}\"", StringComparison.Ordinal); + if (propertyIndex < 0) + throw new FormatException($"JSON property '{propertyName}' was not found."); + + var start = json.IndexOf('[', propertyIndex); + if (start < 0) + throw new FormatException($"JSON property '{propertyName}' is not an array."); + var end = FindMatchingContainerEnd(json, start, '[', ']'); + return json.Substring(start, end - start + 1); + } + + private static IEnumerable ExtractCourseObjects(string courseArray) + { + var arrayDepth = 0; + var groupIndex = -1; + var inString = false; + var escaped = false; + for (var index = 0; index < courseArray.Length; index++) + { + var character = courseArray[index]; + if (inString) + { + if (escaped) + escaped = false; + else if (character == '\\') + escaped = true; + else if (character == '"') + inString = false; + continue; + } + if (character == '"') + { + inString = true; + continue; + } + if (character == '[') + { + arrayDepth++; + if (arrayDepth == 2) + groupIndex++; + continue; + } + if (character == ']') + { + arrayDepth--; + continue; + } + if (character != '{') + continue; + var end = FindMatchingContainerEnd(courseArray, index, '{', '}'); + yield return new CourseJsonItem( + Math.Max(0, groupIndex), + courseArray.Substring(index, end - index + 1)); + index = end; + } + } + + private static int FindMatchingContainerEnd(string json, int start, char open, char close) + { + var depth = 0; + var inString = false; + var escaped = false; + for (var index = start; index < json.Length; index++) + { + var character = json[index]; + if (inString) + { + if (escaped) + escaped = false; + else if (character == '\\') + escaped = true; + else if (character == '"') + inString = false; + continue; + } + + if (character == '"') + inString = true; + else if (character == open) + depth++; + else if (character == close && --depth == 0) + return index; + } + throw new FormatException($"Unterminated JSON container '{open}'."); + } + + private static string NormalizeProviderId(string providerId) + { + var normalized = (providerId ?? string.Empty).Trim().ToLowerInvariant(); + return string.IsNullOrWhiteSpace(normalized) ? "bmstable" : normalized; + } + + [Serializable] + private sealed class AeryCourseHeaderMetadata + { + public string name; + public string symbol; + public string last_update; + } + + private readonly struct CourseJsonItem + { + public CourseJsonItem(int groupIndex, string json) + { + GroupIndex = groupIndex; + Json = json; + } + + public int GroupIndex { get; } + public string Json { get; } + } + + } + + [Serializable] + public sealed class AeryCourseDefinition + { + public string name; + public string[] constraint; + public AeryCourseTrophy[] trophy; + public string[] md5; + public string[] sha256; + + [NonSerialized] public string ProviderId = "aery"; + [NonSerialized] public string ProviderDisplayName = "AERY"; + [NonSerialized] public int GroupIndex; + [NonSerialized] public BmsCourseRules Rules = BmsCourseRules.Analyze(Array.Empty()); + [NonSerialized] public CourseGaugeProfile ResolvedGaugeProfile = CourseGaugeProfile.Lr2; + [NonSerialized] public int ResolvedKeyCount; + [NonSerialized] public bool ResolvedHasScratch; + [NonSerialized] public string StageCompatibilityError = string.Empty; + [NonSerialized] public SongLibraryEntry[] Stages = Array.Empty(); + [NonSerialized] public AeryCourseRecord Record = new AeryCourseRecord(); + + public string[] StageHashes => md5 != null && md5.Length > 0 + ? md5 + : sha256 ?? Array.Empty(); + public int StageCount => StageHashes.Length; + public int MissingStageCount => Stages == null || Stages.Length != StageCount + ? StageCount + : Stages.Count(stage => stage == null); + public bool IsAvailable => StageCount > 0 && MissingStageCount == 0; + public bool AllowsMirror => Rules?.AllowsMirror == true; + public bool IsPlayable => IsAvailable && Rules?.IsConstraintSupported == true && + string.IsNullOrWhiteSpace(StageCompatibilityError); + public string UnavailableReason + { + get + { + if (Rules?.IsConstraintSupported == false) + return Rules.UnsupportedReason; + if (!string.IsNullOrWhiteSpace(StageCompatibilityError)) + return StageCompatibilityError; + return MissingStageCount > 0 + ? $"{MissingStageCount} LOCAL CHART(S) MISSING" + : StageCount == 0 ? "COURSE HAS NO STAGES" : string.Empty; + } + } + + internal void EvaluateStageCompatibility() + { + StageCompatibilityError = string.Empty; + ResolvedKeyCount = 0; + ResolvedHasScratch = false; + if (!IsAvailable) + return; + + var keyCounts = Stages + .Select(stage => stage?.EffectiveKeyCount ?? 0) + .Distinct() + .ToArray(); + if (keyCounts.Length != 1 || keyCounts[0] < 4 || keyCounts[0] > 7) + { + StageCompatibilityError = "MIXED OR UNSUPPORTED KEY MODES"; + return; + } + ResolvedKeyCount = keyCounts[0]; + // Scratch object presence can legitimately vary between stages of the + // same key mode. The stage loader configures the scratch lane per chart. + ResolvedHasScratch = Stages.Any(stage => stage?.Metadata?.HasScratch == true); + if (Rules.GaugeRule == BmsCourseGaugeRule.FiveKeys && ResolvedKeyCount != 5) + { + StageCompatibilityError = $"5KEY GAUGE CANNOT RUN A {ResolvedKeyCount}KEY COURSE"; + return; + } + if (Rules.GaugeRule == BmsCourseGaugeRule.SevenKeys && ResolvedKeyCount != 7) + { + StageCompatibilityError = $"7KEY GAUGE CANNOT RUN A {ResolvedKeyCount}KEY COURSE"; + return; + } + ResolvedGaugeProfile = Rules.ResolveGaugeProfile(ResolvedKeyCount); + } + + public string DisplayName + { + get + { + const string marker = "段位認定 "; + var source = name ?? string.Empty; + var start = source.IndexOf(marker, StringComparison.Ordinal); + if (start >= 0) + source = source.Substring(start + marker.Length); + var version = source.IndexOf(" /", StringComparison.Ordinal); + return (version >= 0 ? source.Substring(0, version) : source).Trim(); + } + } + + public string Version + { + get + { + var source = name ?? string.Empty; + var marker = source.LastIndexOf("ver.", StringComparison.OrdinalIgnoreCase); + return marker >= 0 ? source.Substring(marker).Trim() : string.Empty; + } + } + + public AeryCourseTrophy ResolveTrophy(double missRate, double scoreRate) + { + AeryCourseTrophy resolved = null; + foreach (var candidate in trophy ?? Array.Empty()) + { + if (missRate <= candidate.missrate + 0.000001 && + scoreRate + 0.000001 >= candidate.scorerate) + resolved = candidate; + } + return resolved; + } + } + + [Serializable] + public sealed class AeryCourseTrophy + { + public string name; + public float missrate; + public float scorerate; + + public int Rank => string.Equals(name, "goldmedal", StringComparison.OrdinalIgnoreCase) ? 3 : + string.Equals(name, "silvermedal", StringComparison.OrdinalIgnoreCase) ? 2 : + string.Equals(name, "bronzemedal", StringComparison.OrdinalIgnoreCase) ? 1 : 0; + public string DisplayName => Rank switch + { + 3 => "GOLD", + 2 => "SILVER", + 1 => "BRONZE", + _ => string.Empty, + }; + } +} diff --git a/Assets/ProjectQuint/Runtime/Unity/Library/AeryCourseIndex.cs.meta b/Assets/ProjectQuint/Runtime/Unity/Library/AeryCourseIndex.cs.meta new file mode 100644 index 0000000..2c2672a --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Library/AeryCourseIndex.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a890f24c14734b119e16ab7ce0fdfacb diff --git a/Assets/ProjectQuint/Runtime/Unity/Library/AeryCourseRecordStore.cs b/Assets/ProjectQuint/Runtime/Unity/Library/AeryCourseRecordStore.cs new file mode 100644 index 0000000..cff28e9 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Library/AeryCourseRecordStore.cs @@ -0,0 +1,111 @@ +using System; +using System.Collections.Generic; +using ProjectQuint.Core.Course; +using ProjectQuint.Persistence; + +namespace ProjectQuint.Library +{ + [Serializable] + public sealed class AeryCourseRecord + { + public bool HasRecord; + public bool Cleared; + public int PlayCount; + public int ClearCount; + public int BestStageReached; + public int ScoreFormatVersion; + public int BestScore; + public double BestAccuracyPercent; + public int BestExScore; + public double BestExScoreRate; + public double BestMissRate; + public int BestMaxCombo; + public int BestTrophyRank; + public string BestTrophyName = string.Empty; + public string LastModifier = string.Empty; + } + + internal readonly struct AeryCourseResult + { + public AeryCourseResult( + bool cleared, + int stageReached, + CourseProgress progress, + AeryCourseTrophy trophy, + string modifier) + { + Cleared = cleared; + StageReached = stageReached; + Progress = progress; + Trophy = trophy; + Modifier = modifier ?? string.Empty; + } + + public bool Cleared { get; } + public int StageReached { get; } + public CourseProgress Progress { get; } + public AeryCourseTrophy Trophy { get; } + public string Modifier { get; } + } + + internal static class AeryCourseRecordStore + { + private const string PreferencePrefix = "ProjectQuint.AeryCourseRecordV3."; + + public static AeryCourseRecord Load(AeryCourseDefinition course) + => CourseRecordRepository.Shared.Load(course); + + public static AeryCourseRecord Record(AeryCourseDefinition course, AeryCourseResult result) + { + return CourseRecordRepository.Shared.Record(course, result); + } + + internal static AeryCourseRecord Merge(AeryCourseRecord record, AeryCourseResult result) + { + record ??= new AeryCourseRecord(); + NormalizeScoreFormat(record); + var firstRecord = !record.HasRecord; + record.HasRecord = true; + record.PlayCount = Math.Max(0, record.PlayCount) + 1; + record.BestStageReached = Math.Max(record.BestStageReached, Math.Max(0, result.StageReached)); + record.BestScore = Math.Max(record.BestScore, result.Progress.Score); + record.BestAccuracyPercent = Math.Max(record.BestAccuracyPercent, result.Progress.AccuracyPercent); + record.BestExScore = Math.Max(record.BestExScore, result.Progress.ExScore); + record.BestExScoreRate = Math.Max(record.BestExScoreRate, result.Progress.ExScoreRate); + record.BestMaxCombo = Math.Max(record.BestMaxCombo, result.Progress.MaxCombo); + if (firstRecord || result.Progress.MissRate < record.BestMissRate) + record.BestMissRate = result.Progress.MissRate; + if (result.Cleared) + { + record.Cleared = true; + record.ClearCount = Math.Max(0, record.ClearCount) + 1; + } + if ((result.Trophy?.Rank ?? 0) > record.BestTrophyRank) + { + record.BestTrophyRank = result.Trophy.Rank; + record.BestTrophyName = result.Trophy.DisplayName; + } + record.LastModifier = result.Modifier; + return record; + } + + internal static bool NormalizeScoreFormat(AeryCourseRecord record) + { + if (record == null || + record.ScoreFormatVersion == CourseProgress.ScoreFormatVersion) + return false; + + // The legacy score normalized the whole course to one million. + // The current score sums one independent one-million score per + // stage, so the old numeric best cannot be compared or scaled + // exactly. Preserve certification/trophy/progression and retire + // only the incompatible score. + record.BestScore = 0; + record.ScoreFormatVersion = CourseProgress.ScoreFormatVersion; + return true; + } + + internal static string BuildPreferenceKey(AeryCourseDefinition course) + => PreferencePrefix + CourseRecordRepository.BuildLegacySuffix(course); + } +} diff --git a/Assets/ProjectQuint/Runtime/Unity/Library/AeryCourseRecordStore.cs.meta b/Assets/ProjectQuint/Runtime/Unity/Library/AeryCourseRecordStore.cs.meta new file mode 100644 index 0000000..f9ed1cd --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Library/AeryCourseRecordStore.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 66d857e744274b5591d15d979dfc59ef diff --git a/Assets/ProjectQuint/Runtime/Unity/Library/AeryTableIndex.cs b/Assets/ProjectQuint/Runtime/Unity/Library/AeryTableIndex.cs new file mode 100644 index 0000000..0f2e24a --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Library/AeryTableIndex.cs @@ -0,0 +1,116 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using UnityEngine; + +namespace ProjectQuint.Library +{ + public sealed class AeryTableIndex : IChartCatalog + { + public const string DataUrl = "https://asumatoki.kr/table/aery/data.json"; + private static readonly string[] LevelOrder = + { + "LEVEL 1", "LEVEL 2", "LEVEL 3", "LEVEL 4", "LEVEL 5", "LEVEL 6", "LEVEL 7", "LEVEL 8", + "LEVEL 9", "LEVEL 10", "LEVEL 11", "LEVEL 12", "LEVEL 13", "LEVEL 14", "LEVEL 15", "LEVEL 15+", + "LEVEL 16", "LEVEL 16+", "LEVEL 17", "LEVEL 17+", "LEVEL 18", "LEVEL 18+", "LEVEL 19", "LEVEL 19+", + "LEVEL 20", "LEVEL 20+", "LEVEL 99", "LEVEL GIMMICK", "LEVEL SPECIAL", "LEVEL DUMMY", "OLD CHARTS", + }; + + private readonly Dictionary _entries; + + private AeryTableIndex(Dictionary entries) + { + _entries = entries ?? new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + public string ProviderId => "aery"; + public string DisplayName => "AERY"; + public ExternalDataOrigin Origin { get; private set; } + public int Count => _entries.Count; + + public bool TryGet(string chartHash, out ChartCatalogEntry entry) => + _entries.TryGetValue((chartHash ?? string.Empty).Trim(), out entry); + + internal static ExternalDataResult LoadLocal(string cachePath, string bundledJson) + { + var result = ExternalJsonDataSource.LoadLocal( + "AERY table", + cachePath, + bundledJson, + ParseValidated); + result.Data ??= Empty(); + result.Data.Origin = result.Origin; + return result; + } + + internal static IEnumerator LoadOnline( + string cachePath, + string bundledJson, + Action> completed) + { + yield return ExternalJsonDataSource.LoadOnline( + "AERY table", + DataUrl, + cachePath, + bundledJson, + ParseValidated, + result => + { + result.Data ??= Empty(); + result.Data.Origin = result.Origin; + completed?.Invoke(result); + }); + } + + internal static AeryTableIndex Parse(string json) => + ParseValidated(json) ?? Empty(); + + private static AeryTableIndex ParseValidated(string json) + { + var wrapper = JsonUtility.FromJson($"{{\"items\":{json}}}"); + if (wrapper?.items == null || wrapper.items.Length == 0) + return null; + + var levelOrder = new Dictionary(StringComparer.OrdinalIgnoreCase); + for (var index = 0; index < LevelOrder.Length; index++) + levelOrder[LevelOrder[index]] = index; + + var entries = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var item in wrapper.items) + { + if (item == null || string.IsNullOrWhiteSpace(item.md5)) + continue; + var level = item.level ?? string.Empty; + entries[item.md5.Trim()] = new ChartCatalogEntry + { + DifficultyLabel = level, + DifficultyValue = level, + DifficultySortIndex = levelOrder.TryGetValue(level, out var sortIndex) + ? sortIndex + : int.MaxValue, + TitleOverride = item.title ?? string.Empty, + ArtistOverride = item.artist ?? string.Empty, + }; + } + return entries.Count > 0 ? new AeryTableIndex(entries) : null; + } + + private static AeryTableIndex Empty() => + new AeryTableIndex(new Dictionary(StringComparer.OrdinalIgnoreCase)); + + [Serializable] + private sealed class AeryTableWrapper + { + public AeryTableEntry[] items; + } + + [Serializable] + private sealed class AeryTableEntry + { + public string level; + public string title; + public string artist; + public string md5; + } + } +} diff --git a/Assets/ProjectQuint/Runtime/Unity/Library/AeryTableIndex.cs.meta b/Assets/ProjectQuint/Runtime/Unity/Library/AeryTableIndex.cs.meta new file mode 100644 index 0000000..791228c --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Library/AeryTableIndex.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: af68e030dde784779b15919621a9d264 \ No newline at end of file diff --git a/Assets/ProjectQuint/Runtime/Unity/Library/BitmapImageDecoder.cs b/Assets/ProjectQuint/Runtime/Unity/Library/BitmapImageDecoder.cs new file mode 100644 index 0000000..0da6f83 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Library/BitmapImageDecoder.cs @@ -0,0 +1,150 @@ +using System; +using System.IO; +using UnityEngine; + +namespace ProjectQuint.Library +{ + internal sealed class DecodedBitmap + { + public DecodedBitmap(int width, int height, Color32[] pixels) + { + Width = width; + Height = height; + Pixels = pixels; + } + + public int Width { get; } + public int Height { get; } + public Color32[] Pixels { get; } + } + + internal static class BitmapImageDecoder + { + private const uint BitmapInfoHeaderSize = 40; + private const uint UncompressedRgb = 0; + private const int MaximumPixelCount = 64 * 1024 * 1024; + + public static DecodedBitmap DecodeFile(string path) => Decode(File.ReadAllBytes(path)); + + public static DecodedBitmap Decode(byte[] data) + { + if (data == null || data.Length < 54 || data[0] != (byte)'B' || data[1] != (byte)'M') + throw new InvalidDataException("The file is not a Windows BMP image."); + + var pixelOffset = ReadUInt32(data, 10); + var headerSize = ReadUInt32(data, 14); + if (headerSize < BitmapInfoHeaderSize) + throw new InvalidDataException("Only Windows BITMAPINFOHEADER images are supported."); + + var width = ReadInt32(data, 18); + var signedHeight = ReadInt32(data, 22); + var planes = ReadUInt16(data, 26); + var bitsPerPixel = ReadUInt16(data, 28); + var compression = ReadUInt32(data, 30); + if (width <= 0 || signedHeight == 0 || planes != 1) + throw new InvalidDataException("The BMP image dimensions or plane count are invalid."); + if (bitsPerPixel != 1 && bitsPerPixel != 4 && bitsPerPixel != 8 && + bitsPerPixel != 24 && bitsPerPixel != 32) + throw new InvalidDataException($"Unsupported BMP bit depth: {bitsPerPixel}."); + if (compression != UncompressedRgb) + throw new InvalidDataException($"Unsupported compressed BMP format: {compression}."); + + var height = Math.Abs(signedHeight); + var pixelCountLong = checked((long)width * height); + if (pixelCountLong > MaximumPixelCount) + throw new InvalidDataException("The BMP image is too large to load safely."); + var pixelCount = (int)pixelCountLong; + + var rowStride = checked((int)(((long)width * bitsPerPixel + 31) / 32 * 4)); + var pixelBytes = checked((long)rowStride * height); + if (pixelOffset > data.Length || pixelOffset + pixelBytes > data.Length) + throw new InvalidDataException("The BMP pixel data is truncated."); + + Color32[] palette = null; + if (bitsPerPixel <= 8) + { + var colorsUsed = headerSize >= 36 ? ReadUInt32(data, 46) : 0; + var maximumPaletteCount = 1 << bitsPerPixel; + var paletteCount = colorsUsed == 0 + ? maximumPaletteCount + : checked((int)colorsUsed); + var paletteOffset = checked(14 + (int)headerSize); + if (paletteCount <= 0 || paletteCount > maximumPaletteCount || + paletteOffset + paletteCount * 4 > pixelOffset) + throw new InvalidDataException("The BMP color palette is invalid."); + + palette = new Color32[paletteCount]; + for (var index = 0; index < paletteCount; index++) + { + var entry = paletteOffset + index * 4; + palette[index] = new Color32(data[entry + 2], data[entry + 1], data[entry], 255); + } + } + + var pixels = new Color32[pixelCount]; + var topDown = signedHeight < 0; + for (var sourceRow = 0; sourceRow < height; sourceRow++) + { + var destinationRow = topDown ? height - 1 - sourceRow : sourceRow; + var source = checked((int)pixelOffset + sourceRow * rowStride); + var destination = destinationRow * width; + for (var x = 0; x < width; x++) + { + if (bitsPerPixel <= 8) + { + var paletteIndex = ReadPaletteIndex(data, source, x, bitsPerPixel); + if (paletteIndex >= palette.Length) + throw new InvalidDataException("A BMP pixel references a missing palette color."); + pixels[destination + x] = palette[paletteIndex]; + } + else + { + var bytesPerPixel = bitsPerPixel / 8; + var pixel = source + x * bytesPerPixel; + pixels[destination + x] = new Color32( + data[pixel + 2], + data[pixel + 1], + data[pixel], + 255); + } + } + } + + return new DecodedBitmap(width, height, pixels); + } + + private static int ReadPaletteIndex(byte[] data, int rowOffset, int x, int bitsPerPixel) + { + return bitsPerPixel switch + { + 1 => (data[rowOffset + x / 8] >> (7 - x % 8)) & 0x01, + 4 => (data[rowOffset + x / 2] >> (x % 2 == 0 ? 4 : 0)) & 0x0f, + 8 => data[rowOffset + x], + _ => throw new InvalidDataException($"Unsupported indexed BMP bit depth: {bitsPerPixel}."), + }; + } + + private static ushort ReadUInt16(byte[] data, int offset) + { + EnsureRange(data, offset, 2); + return (ushort)(data[offset] | data[offset + 1] << 8); + } + + private static uint ReadUInt32(byte[] data, int offset) + { + EnsureRange(data, offset, 4); + return (uint)(data[offset] | + data[offset + 1] << 8 | + data[offset + 2] << 16 | + data[offset + 3] << 24); + } + + private static int ReadInt32(byte[] data, int offset) => unchecked((int)ReadUInt32(data, offset)); + + private static void EnsureRange(byte[] data, int offset, int count) + { + if (offset < 0 || count < 0 || offset > data.Length - count) + throw new InvalidDataException("The BMP header is truncated."); + } + } +} diff --git a/Assets/ProjectQuint/Runtime/Unity/Library/BitmapImageDecoder.cs.meta b/Assets/ProjectQuint/Runtime/Unity/Library/BitmapImageDecoder.cs.meta new file mode 100644 index 0000000..654bad7 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Library/BitmapImageDecoder.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 1aad1a4655da0412db037a63625494d0 \ No newline at end of file diff --git a/Assets/ProjectQuint/Runtime/Unity/Library/BmsCourseRules.cs b/Assets/ProjectQuint/Runtime/Unity/Library/BmsCourseRules.cs new file mode 100644 index 0000000..f7f9df7 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Library/BmsCourseRules.cs @@ -0,0 +1,220 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using ProjectQuint.Core.Gauge; + +namespace ProjectQuint.Library +{ + public enum BmsCourseGradeRule + { + Unspecified, + NormalOnly, + MirrorAllowed, + RandomAllowed, + } + + public enum BmsCourseGaugeRule + { + Automatic, + Lr2, + FiveKeys, + SevenKeys, + NineKeys, + TwentyFourKeys, + } + + public enum BmsCourseLongNoteRule + { + Unspecified, + Ln, + Cn, + Hcn, + } + + public sealed class BmsCourseRules + { + private readonly string[] _unsupportedReasons; + + private BmsCourseRules( + BmsCourseGradeRule gradeRule, + BmsCourseGaugeRule gaugeRule, + BmsCourseLongNoteRule longNoteRule, + bool noSpeed, + bool noGood, + bool noGreat, + string[] unsupportedReasons) + { + GradeRule = gradeRule; + GaugeRule = gaugeRule; + LongNoteRule = longNoteRule; + NoSpeed = noSpeed; + NoGood = noGood; + NoGreat = noGreat; + _unsupportedReasons = unsupportedReasons ?? Array.Empty(); + } + + public BmsCourseGradeRule GradeRule { get; } + public BmsCourseGaugeRule GaugeRule { get; } + public BmsCourseLongNoteRule LongNoteRule { get; } + public bool NoSpeed { get; } + public bool NoGood { get; } + public bool NoGreat { get; } + public bool AllowsMirror => GradeRule == BmsCourseGradeRule.MirrorAllowed || + GradeRule == BmsCourseGradeRule.RandomAllowed; + public bool IsConstraintSupported => _unsupportedReasons.Length == 0; + public string UnsupportedReason => string.Join(" // ", _unsupportedReasons); + + public string Summary + { + get + { + var grade = GradeRule switch + { + BmsCourseGradeRule.NormalOnly => "NORMAL ONLY", + BmsCourseGradeRule.MirrorAllowed => "NORMAL / MIRROR", + BmsCourseGradeRule.RandomAllowed => "NORMAL / MIRROR (RANDOM DISABLED)", + _ => "PROJECTQUINT COURSE", + }; + var gauge = GaugeRule switch + { + BmsCourseGaugeRule.Lr2 => "LR2 GAUGE", + BmsCourseGaugeRule.FiveKeys => "5KEY GAUGE", + BmsCourseGaugeRule.SevenKeys => "7KEY GAUGE", + BmsCourseGaugeRule.NineKeys => "9KEY GAUGE", + BmsCourseGaugeRule.TwentyFourKeys => "24KEY GAUGE", + _ => "AUTO GAUGE", + }; + var longNote = LongNoteRule switch + { + BmsCourseLongNoteRule.Ln => "LN", + BmsCourseLongNoteRule.Cn => "CN", + BmsCourseLongNoteRule.Hcn => "HCN", + _ => "NATIVE LN", + }; + return $"{gauge} // {longNote} // {grade}"; + } + } + + public CourseGaugeProfile ResolveGaugeProfile(int keyCount) + { + return GaugeRule switch + { + BmsCourseGaugeRule.Lr2 => CourseGaugeProfile.Lr2, + BmsCourseGaugeRule.FiveKeys => CourseGaugeProfile.FiveKey, + BmsCourseGaugeRule.SevenKeys => CourseGaugeProfile.SevenKey, + _ => keyCount == 5 + ? CourseGaugeProfile.FiveKey + : CourseGaugeProfile.SevenKey, + }; + } + + public static BmsCourseRules Analyze(IEnumerable rawConstraints) + { + var grade = BmsCourseGradeRule.Unspecified; + var gauge = BmsCourseGaugeRule.Automatic; + var longNote = BmsCourseLongNoteRule.Unspecified; + var gradeSet = false; + var gaugeSet = false; + var longNoteSet = false; + var noSpeed = false; + var noGood = false; + var noGreat = false; + var unsupported = new List(); + + foreach (var raw in rawConstraints ?? Array.Empty()) + { + var value = (raw ?? string.Empty).Trim().ToLowerInvariant(); + if (value.Length == 0) + continue; + switch (value) + { + case "grade" when !gradeSet: + grade = BmsCourseGradeRule.NormalOnly; + gradeSet = true; + break; + case "grade_mirror" when !gradeSet: + grade = BmsCourseGradeRule.MirrorAllowed; + gradeSet = true; + break; + case "grade_random" when !gradeSet: + grade = BmsCourseGradeRule.RandomAllowed; + gradeSet = true; + break; + case "gauge_lr2" when !gaugeSet: + gauge = BmsCourseGaugeRule.Lr2; + gaugeSet = true; + break; + case "gauge_5k" when !gaugeSet: + gauge = BmsCourseGaugeRule.FiveKeys; + gaugeSet = true; + break; + case "gauge_7k" when !gaugeSet: + gauge = BmsCourseGaugeRule.SevenKeys; + gaugeSet = true; + break; + case "gauge_9k" when !gaugeSet: + gauge = BmsCourseGaugeRule.NineKeys; + gaugeSet = true; + unsupported.Add("9KEY COURSE GAUGE IS NOT SUPPORTED"); + break; + case "gauge_24k" when !gaugeSet: + gauge = BmsCourseGaugeRule.TwentyFourKeys; + gaugeSet = true; + unsupported.Add("24KEY COURSE GAUGE IS NOT SUPPORTED"); + break; + case "ln" when !longNoteSet: + longNote = BmsCourseLongNoteRule.Ln; + longNoteSet = true; + break; + case "cn" when !longNoteSet: + longNote = BmsCourseLongNoteRule.Cn; + longNoteSet = true; + unsupported.Add("CN COURSE SEMANTICS ARE NOT SUPPORTED"); + break; + case "hcn" when !longNoteSet: + longNote = BmsCourseLongNoteRule.Hcn; + longNoteSet = true; + unsupported.Add("HCN COURSE SEMANTICS ARE NOT SUPPORTED"); + break; + case "no_speed": + noSpeed = true; + unsupported.Add("NO_SPEED COURSE SEMANTICS ARE NOT SUPPORTED"); + break; + case "no_good": + noGood = true; + unsupported.Add("NO_GOOD COURSE SEMANTICS ARE NOT SUPPORTED"); + break; + case "no_great": + noGreat = true; + unsupported.Add("NO_GREAT COURSE SEMANTICS ARE NOT SUPPORTED"); + break; + case "grade": + case "grade_mirror": + case "grade_random": + case "gauge_lr2": + case "gauge_5k": + case "gauge_7k": + case "gauge_9k": + case "gauge_24k": + case "ln": + case "cn": + case "hcn": + // beatoraja keeps the first constraint from each category. + break; + default: + unsupported.Add($"UNKNOWN COURSE CONSTRAINT: {value.ToUpperInvariant()}"); + break; + } + } + + return new BmsCourseRules( + grade, + gauge, + longNote, + noSpeed, + noGood, + noGreat, + unsupported.Distinct(StringComparer.Ordinal).ToArray()); + } + } +} diff --git a/Assets/ProjectQuint/Runtime/Unity/Library/BmsCourseRules.cs.meta b/Assets/ProjectQuint/Runtime/Unity/Library/BmsCourseRules.cs.meta new file mode 100644 index 0000000..e5b9c78 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Library/BmsCourseRules.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d1faacc0d46574d188e8698ae01fec42 \ No newline at end of file diff --git a/Assets/ProjectQuint/Runtime/Unity/Library/BmsDifficultyTableIndex.cs b/Assets/ProjectQuint/Runtime/Unity/Library/BmsDifficultyTableIndex.cs new file mode 100644 index 0000000..ce419fd --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Library/BmsDifficultyTableIndex.cs @@ -0,0 +1,385 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.IO; +using System.Text.RegularExpressions; +using UnityEngine; +using UnityEngine.Networking; + +namespace ProjectQuint.Library +{ + public readonly struct BmsDifficultyTablePreset + { + public BmsDifficultyTablePreset( + string id, + string displayName, + string headerUrl, + int defaultKeyCount = 0, + bool defaultHasScratch = false) + { + Id = id; + DisplayName = displayName; + HeaderUrl = headerUrl; + DefaultKeyCount = defaultKeyCount; + DefaultHasScratch = defaultHasScratch; + } + + public string Id { get; } + public string DisplayName { get; } + public string HeaderUrl { get; } + public int DefaultKeyCount { get; } + public bool DefaultHasScratch { get; } + } + + public sealed class BmsDifficultyTableIndex : IChartCatalog + { + public static readonly BmsDifficultyTablePreset[] Presets = + { + new BmsDifficultyTablePreset( + "aery", + "5KEYS AERY", + "https://asumatoki.kr/table/aery/header.json", + 5), + new BmsDifficultyTablePreset( + "satellite", + "Satellite", + "https://stellabms.xyz/sl/header.json", + 7, + true), + new BmsDifficultyTablePreset( + "stella", + "Stella", + "https://stellabms.xyz/st/header.json", + 7, + true), + new BmsDifficultyTablePreset( + "genocide-insane", + "GENOCIDE Insane BMS (legacy mirror)", + "https://miraiscarlet.github.io/bms/table/genocide_insane/header_insane.json", + 7, + true), + new BmsDifficultyTablePreset( + "4ue", + "4UE Difficulty Table", + "https://classmaterma.github.io/4UE/header.json", + 4), + new BmsDifficultyTablePreset( + "6ue", + "6UE Difficulty Table", + "https://classmaterma.github.io/UE/table.html", + 6), + }; + + public static BmsDifficultyTablePreset? FindPreset(string tableId, string headerUrl = null) + { + foreach (var preset in Presets) + { + if (!string.IsNullOrWhiteSpace(tableId) && + string.Equals(preset.Id, tableId.Trim(), StringComparison.OrdinalIgnoreCase)) + return preset; + if (!string.IsNullOrWhiteSpace(headerUrl) && + string.Equals(preset.HeaderUrl, headerUrl.Trim(), StringComparison.OrdinalIgnoreCase)) + return preset; + } + return null; + } + + private static readonly Regex BmsTableMetaRegex = new Regex( + "]+name\\s*=\\s*[\\\"']bmstable[\\\"'][^>]+content\\s*=\\s*[\\\"'](?[^\\\"']+)[\\\"'][^>]*>|" + + "]+content\\s*=\\s*[\\\"'](?[^\\\"']+)[\\\"'][^>]+name\\s*=\\s*[\\\"']bmstable[\\\"'][^>]*>", + RegexOptions.IgnoreCase | RegexOptions.Compiled); + + private readonly Dictionary _entries; + private readonly int _chartCount; + + private BmsDifficultyTableIndex( + string providerId, + string displayName, + Dictionary entries, + int chartCount) + { + ProviderId = providerId ?? string.Empty; + DisplayName = string.IsNullOrWhiteSpace(displayName) ? "BMS TABLE" : displayName; + _entries = entries ?? new Dictionary(StringComparer.OrdinalIgnoreCase); + _chartCount = Math.Max(0, chartCount); + } + + public string ProviderId { get; } + public string DisplayName { get; } + public ExternalDataOrigin Origin { get; private set; } + public int Count => _chartCount; + + public bool TryGet(string chartHash, out ChartCatalogEntry entry) => + _entries.TryGetValue((chartHash ?? string.Empty).Trim(), out entry); + + internal static ExternalDataResult LoadLocal( + string providerId, + string headerCachePath, + string dataCachePath) + { + try + { + if (!File.Exists(headerCachePath) || !File.Exists(dataCachePath)) + return Unavailable("Difficulty table: cache not found"); + var headerJson = File.ReadAllText(headerCachePath); + var header = ParseHeader(headerJson); + var index = ParseData(providerId, header, headerJson, File.ReadAllText(dataCachePath)); + index.Origin = ExternalDataOrigin.Cache; + return new ExternalDataResult + { + Data = index, + Origin = ExternalDataOrigin.Cache, + Status = $"{index.DisplayName}: cached", + }; + } + catch (Exception exception) + { + return Unavailable($"Difficulty table cache invalid: {exception.Message}"); + } + } + + internal static IEnumerator LoadOnline( + string providerId, + string configuredUrl, + string headerCachePath, + string dataCachePath, + Action> completed) + { + if (!Uri.TryCreate(configuredUrl, UriKind.Absolute, out var configuredUri) || + configuredUri.Scheme != Uri.UriSchemeHttps) + { + completed?.Invoke(Unavailable("Difficulty table URL must use HTTPS")); + yield break; + } + + string firstDocument = null; + string requestFailure = null; + using (var request = UnityWebRequest.Get(configuredUri.AbsoluteUri)) + { + request.timeout = 12; + yield return request.SendWebRequest(); + if (request.result == UnityWebRequest.Result.Success) + firstDocument = request.downloadHandler.text; + else + requestFailure = request.error; + } + + var headerUrl = configuredUri.AbsoluteUri; + var headerJson = firstDocument; + if (!string.IsNullOrWhiteSpace(firstDocument) && LooksLikeHtml(firstDocument)) + { + var match = BmsTableMetaRegex.Match(firstDocument); + if (!match.Success) + { + completed?.Invoke(Unavailable("Table page has no bmstable header metadata")); + yield break; + } + var resolvedHeaderUri = new Uri(configuredUri, match.Groups["url"].Value); + if (resolvedHeaderUri.Scheme != Uri.UriSchemeHttps) + { + completed?.Invoke(Unavailable("Difficulty table header URL must use HTTPS")); + yield break; + } + headerUrl = resolvedHeaderUri.AbsoluteUri; + using var headerRequest = UnityWebRequest.Get(headerUrl); + headerRequest.timeout = 12; + yield return headerRequest.SendWebRequest(); + headerJson = headerRequest.result == UnityWebRequest.Result.Success + ? headerRequest.downloadHandler.text + : null; + requestFailure = headerRequest.result == UnityWebRequest.Result.Success + ? null + : headerRequest.error; + } + + if (string.IsNullOrWhiteSpace(headerJson)) + { + var cached = LoadLocal(providerId, headerCachePath, dataCachePath); + cached.Status = $"{cached.Status} // online header failed: {requestFailure}"; + completed?.Invoke(cached); + yield break; + } + + BmsTableHeader header; + try + { + header = ParseHeader(headerJson); + } + catch (Exception exception) + { + completed?.Invoke(Unavailable($"Difficulty table header invalid: {exception.Message}")); + yield break; + } + + var dataUri = new Uri(new Uri(headerUrl), header.data_url); + if (dataUri.Scheme != Uri.UriSchemeHttps) + { + completed?.Invoke(Unavailable("Difficulty table data URL must use HTTPS")); + yield break; + } + var dataUrl = dataUri.AbsoluteUri; + ExternalDataResult dataResult = null; + yield return ExternalJsonDataSource.LoadOnline( + string.IsNullOrWhiteSpace(header.name) ? "BMS table" : header.name, + dataUrl, + dataCachePath, + string.Empty, + json => ParseData(providerId, header, headerJson, json), + result => dataResult = result); + dataResult ??= Unavailable("Difficulty table data request did not complete"); + if (dataResult.Data != null) + dataResult.Data.Origin = dataResult.Origin; + if (dataResult.Origin != ExternalDataOrigin.Unavailable) + { + try + { + ExternalJsonDataSource.WriteAtomically(headerCachePath, headerJson); + } + catch (Exception exception) + { + dataResult.Status += $" // header cache failed: {exception.Message}"; + } + } + completed?.Invoke(dataResult); + } + + internal static BmsDifficultyTableIndex Parse( + string providerId, + string headerJson, + string dataJson) + { + var header = ParseHeader(headerJson); + return ParseData(providerId, header, headerJson, dataJson); + } + + private static BmsTableHeader ParseHeader(string json) + { + json = TrimBom(json); + var header = JsonUtility.FromJson(json); + if (header == null || string.IsNullOrWhiteSpace(header.data_url)) + throw new InvalidDataException("header data_url is missing"); + return header; + } + + private static BmsDifficultyTableIndex ParseData( + string providerId, + BmsTableHeader header, + string headerJson, + string dataJson) + { + var wrapper = JsonUtility.FromJson( + $"{{\"items\":{TrimBom(dataJson)}}}"); + if (wrapper?.items == null || wrapper.items.Length == 0) + throw new InvalidDataException("table data contains no charts"); + + var levelOrder = ParseLevelOrder(headerJson); + var entries = new Dictionary(StringComparer.OrdinalIgnoreCase); + var chartCount = 0; + foreach (var item in wrapper.items) + { + if (item == null) + continue; + var md5 = (item.md5 ?? string.Empty).Trim(); + var sha256 = (item.sha256 ?? string.Empty).Trim(); + if (md5.Length == 0 && sha256.Length == 0) + continue; + + var rawLevel = item.level ?? string.Empty; + var entry = new ChartCatalogEntry + { + DifficultyLabel = FormatLevel(header.symbol, rawLevel), + DifficultyValue = rawLevel, + DifficultySortIndex = ResolveSortIndex(rawLevel, levelOrder), + TitleOverride = item.title ?? string.Empty, + ArtistOverride = item.artist ?? string.Empty, + }; + if (md5.Length > 0) + entries[md5] = entry; + if (sha256.Length > 0) + entries[sha256] = entry; + chartCount++; + } + if (chartCount == 0) + throw new InvalidDataException("table data contains no chart hashes"); + return new BmsDifficultyTableIndex(providerId, header.name, entries, chartCount); + } + + private static Dictionary ParseLevelOrder(string headerJson) + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + var match = Regex.Match( + TrimBom(headerJson), + "\\\"level_order\\\"\\s*:\\s*\\[(?.*?)\\]", + RegexOptions.Singleline | RegexOptions.IgnoreCase); + if (!match.Success) + return result; + var tokens = Regex.Matches(match.Groups["items"].Value, "\\\"(?[^\\\"]*)\\\"|(?-?\\d+(?:\\.\\d+)?)"); + for (var index = 0; index < tokens.Count; index++) + { + var value = tokens[index].Groups["quoted"].Success + ? tokens[index].Groups["quoted"].Value + : tokens[index].Groups["plain"].Value; + result[value] = index; + } + return result; + } + + private static int ResolveSortIndex(string level, IReadOnlyDictionary order) + { + level ??= string.Empty; + if (order.TryGetValue(level, out var index)) + return index; + return double.TryParse(level, System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, out var numeric) + ? (int)Math.Round(numeric * 1000.0) + : int.MaxValue; + } + + private static string FormatLevel(string symbol, string level) + { + symbol ??= string.Empty; + level ??= string.Empty; + if (string.IsNullOrWhiteSpace(symbol) || + level.StartsWith(symbol, StringComparison.OrdinalIgnoreCase) || + level.IndexOf("LEVEL", StringComparison.OrdinalIgnoreCase) >= 0) + return level; + return symbol + level; + } + + private static bool LooksLikeHtml(string text) => + TrimBom(text).TrimStart().StartsWith("<", StringComparison.Ordinal); + + private static string TrimBom(string text) => (text ?? string.Empty).TrimStart('\uFEFF'); + + private static ExternalDataResult Unavailable(string status) => + new ExternalDataResult + { + Origin = ExternalDataOrigin.Unavailable, + Status = status ?? "Difficulty table unavailable", + }; + + [Serializable] + private sealed class BmsTableHeader + { + public string name; + public string symbol; + public string data_url; + } + + [Serializable] + private sealed class BmsTableDataWrapper + { + public BmsTableDataEntry[] items; + } + + [Serializable] + private sealed class BmsTableDataEntry + { + public string md5; + public string sha256; + public string level; + public string title; + public string artist; + } + } +} diff --git a/Assets/ProjectQuint/Runtime/Unity/Library/BmsDifficultyTableIndex.cs.meta b/Assets/ProjectQuint/Runtime/Unity/Library/BmsDifficultyTableIndex.cs.meta new file mode 100644 index 0000000..004aa13 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Library/BmsDifficultyTableIndex.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: aa03be4976b4a43409e640dde0dba7de \ No newline at end of file diff --git a/Assets/ProjectQuint/Runtime/Unity/Library/BmsDifficultyTableRegistry.cs b/Assets/ProjectQuint/Runtime/Unity/Library/BmsDifficultyTableRegistry.cs new file mode 100644 index 0000000..ba557d4 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Library/BmsDifficultyTableRegistry.cs @@ -0,0 +1,364 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using ProjectQuint.Persistence; + +namespace ProjectQuint.Library +{ + [Serializable] + public sealed class BmsDifficultyTableDefinition + { + public string Id = string.Empty; + public string DisplayName = string.Empty; + public string HeaderUrl = string.Empty; + public long AddedUtcTicks; + public List LegacyProviderIds = new List(); + + public bool IsConfigured => !string.IsNullOrWhiteSpace(HeaderUrl); + } + + [Serializable] + internal sealed class BmsDifficultyTableRegistryDocument + { + public const int CurrentSchemaVersion = 1; + + public int SchemaVersion = CurrentSchemaVersion; + public string ActiveTableId = string.Empty; + public List Tables = new List(); + } + + /// + /// Global difficulty-table definition and cache registry. Each pack stores only a table ID + /// reference; entries still match indexed charts by MD5/SHA-256. + /// + internal sealed class BmsDifficultyTableRegistry + { + private readonly AtomicJsonFile _file; + private readonly string _cacheRoot; + private readonly List _tables = + new List(); + + public BmsDifficultyTableRegistry( + string registryPath = null, + string cacheRoot = null, + Action logWarning = null) + { + var path = registryPath ?? PersistencePaths.DifficultyTableRegistry; + _cacheRoot = cacheRoot ?? PersistencePaths.DifficultyTableRoot; + _file = new AtomicJsonFile( + path, + document => document != null && + document.SchemaVersion == BmsDifficultyTableRegistryDocument.CurrentSchemaVersion && + document.Tables != null, + logWarning); + Load(); + EnsurePresets(); + } + + public IReadOnlyList Tables => _tables; + public string ActiveTableId { get; private set; } = string.Empty; + public BmsDifficultyTableDefinition ActiveTable => Find(ActiveTableId); + + public BmsDifficultyTableDefinition Register( + string headerUrl, + string displayName = null, + string preferredId = null, + bool activate = true) + { + var url = NormalizeUrl(headerUrl); + var preset = FindPresetByUrl(url); + var id = NormalizeId(!string.IsNullOrWhiteSpace(preferredId) + ? preferredId + : preset?.Id ?? BuildStableId(url)); + var existing = FindByUrl(url) ?? Find(id); + if (existing == null) + { + existing = new BmsDifficultyTableDefinition + { + Id = id, + DisplayName = ResolveDisplayName(displayName, preset, id), + HeaderUrl = url, + AddedUtcTicks = DateTime.UtcNow.Ticks, + }; + _tables.Add(existing); + } + else + { + existing.HeaderUrl = url; + if (!string.IsNullOrWhiteSpace(displayName)) + existing.DisplayName = displayName.Trim(); + else if (string.IsNullOrWhiteSpace(existing.DisplayName)) + existing.DisplayName = ResolveDisplayName(null, preset, existing.Id); + } + + if (activate) + ActiveTableId = existing.Id; + Save(); + return existing; + } + + public BmsDifficultyTableDefinition RegisterPreset(int presetIndex, bool activate = true) + { + if (presetIndex < 0 || presetIndex >= BmsDifficultyTableIndex.Presets.Length) + throw new ArgumentOutOfRangeException(nameof(presetIndex)); + var preset = BmsDifficultyTableIndex.Presets[presetIndex]; + return Register(preset.HeaderUrl, preset.DisplayName, preset.Id, activate); + } + + public void Select(string tableId) + { + var table = Find(tableId) ?? throw new ArgumentException("Unknown difficulty table.", nameof(tableId)); + ActiveTableId = table.Id; + Save(); + } + + public bool Remove(string tableId) + { + var table = Find(tableId); + if (table == null) + return false; + _tables.Remove(table); + if (string.Equals(ActiveTableId, table.Id, StringComparison.OrdinalIgnoreCase)) + ActiveTableId = string.Empty; + Save(); + return true; + } + + public void ClearSelection() + { + if (string.IsNullOrWhiteSpace(ActiveTableId)) + return; + ActiveTableId = string.Empty; + Save(); + } + + public BmsDifficultyTableDefinition Find(string tableId) + { + if (string.IsNullOrWhiteSpace(tableId)) + return null; + return _tables.Find(table => + string.Equals(table.Id, tableId.Trim(), StringComparison.OrdinalIgnoreCase)); + } + + public BmsDifficultyTableDefinition FindByUrl(string headerUrl) + { + if (string.IsNullOrWhiteSpace(headerUrl)) + return null; + var normalized = NormalizeUrl(headerUrl); + return _tables.Find(table => + string.Equals(table.HeaderUrl, normalized, StringComparison.OrdinalIgnoreCase)); + } + + public int MigrateLegacyPackTables(IEnumerable packs, string preferredPackId) + { + var candidates = (packs ?? Array.Empty()) + .Where(pack => pack != null && !string.IsNullOrWhiteSpace(pack.DifficultyTableUrl)) + .OrderBy(pack => string.Equals(pack.Id, preferredPackId, StringComparison.OrdinalIgnoreCase) ? 0 : 1) + .ToList(); + var migrated = 0; + BmsDifficultyTableDefinition preferred = null; + foreach (var pack in candidates) + { + var before = FindByUrl(pack.DifficultyTableUrl); + var table = Register(pack.DifficultyTableUrl, activate: false); + PromoteLegacyCache(pack.Id, table); + table.LegacyProviderIds ??= new List(); + if (!string.Equals(pack.Id, table.Id, StringComparison.OrdinalIgnoreCase) && + !table.LegacyProviderIds.Any(id => + string.Equals(id, pack.Id, StringComparison.OrdinalIgnoreCase))) + table.LegacyProviderIds.Add(pack.Id); + if (before == null) + migrated++; + if (preferred == null) + preferred = table; + } + if (preferred != null) + { + ActiveTableId = preferred.Id; + Save(); + } + return migrated; + } + + public IReadOnlyDictionary BuildLegacyProviderMap() + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var table in _tables) + { + foreach (var legacyId in table.LegacyProviderIds ?? new List()) + { + if (!string.IsNullOrWhiteSpace(legacyId)) + result[legacyId.Trim()] = table; + } + } + return result; + } + + public void UpdateDisplayName(string tableId, string displayName) + { + var table = Find(tableId); + if (table == null || string.IsNullOrWhiteSpace(displayName) || + string.Equals(table.DisplayName, displayName.Trim(), StringComparison.Ordinal)) + return; + table.DisplayName = displayName.Trim(); + Save(); + } + + public string HeaderCachePathFor(BmsDifficultyTableDefinition table) => + CachePathFor(table, "header.json"); + + public string DataCachePathFor(BmsDifficultyTableDefinition table) => + CachePathFor(table, "data.json"); + + private string CachePathFor(BmsDifficultyTableDefinition table, string fileName) + { + if (table == null || string.IsNullOrWhiteSpace(table.Id)) + throw new ArgumentNullException(nameof(table)); + return Path.Combine(_cacheRoot, NormalizeId(table.Id), fileName); + } + + private void EnsurePresets() + { + var changed = false; + foreach (var preset in BmsDifficultyTableIndex.Presets) + { + if (FindByUrl(preset.HeaderUrl) != null) + continue; + _tables.Add(new BmsDifficultyTableDefinition + { + Id = NormalizeId(preset.Id), + DisplayName = preset.DisplayName, + HeaderUrl = NormalizeUrl(preset.HeaderUrl), + AddedUtcTicks = DateTime.UtcNow.Ticks, + }); + changed = true; + } + if (changed) + Save(); + } + + private void PromoteLegacyCache(string packId, BmsDifficultyTableDefinition table) + { + if (string.IsNullOrWhiteSpace(packId) || table == null) + return; + var legacyRoot = Path.Combine(_cacheRoot, packId); + if (!Directory.Exists(legacyRoot)) + return; + var targetRoot = Path.Combine(_cacheRoot, NormalizeId(table.Id)); + Directory.CreateDirectory(targetRoot); + CopyIfMissing( + Path.Combine(legacyRoot, "header.json"), + Path.Combine(targetRoot, "header.json")); + CopyIfMissing( + Path.Combine(legacyRoot, "data.json"), + Path.Combine(targetRoot, "data.json")); + } + + private static void CopyIfMissing(string sourcePath, string destinationPath) + { + if (!File.Exists(sourcePath) || File.Exists(destinationPath)) + return; + File.Copy(sourcePath, destinationPath, overwrite: false); + } + + private void Load() + { + _tables.Clear(); + if (!_file.TryLoad(out var document, out _)) + return; + foreach (var table in document.Tables) + { + if (table == null || string.IsNullOrWhiteSpace(table.Id) || + string.IsNullOrWhiteSpace(table.HeaderUrl)) + continue; + try + { + table.Id = NormalizeId(table.Id); + table.HeaderUrl = NormalizeUrl(table.HeaderUrl); + } + catch + { + continue; + } + table.DisplayName = string.IsNullOrWhiteSpace(table.DisplayName) + ? table.Id + : table.DisplayName.Trim(); + table.LegacyProviderIds ??= new List(); + if (_tables.All(existing => + !string.Equals(existing.Id, table.Id, StringComparison.OrdinalIgnoreCase) && + !string.Equals(existing.HeaderUrl, table.HeaderUrl, StringComparison.OrdinalIgnoreCase))) + _tables.Add(table); + } + ActiveTableId = document.ActiveTableId ?? string.Empty; + if (Find(ActiveTableId) == null) + ActiveTableId = string.Empty; + } + + private void Save() + { + _file.Save(new BmsDifficultyTableRegistryDocument + { + ActiveTableId = ActiveTableId, + Tables = new List(_tables), + }); + } + + private static BmsDifficultyTablePreset? FindPresetByUrl(string url) + { + foreach (var preset in BmsDifficultyTableIndex.Presets) + if (string.Equals(preset.HeaderUrl, url, StringComparison.OrdinalIgnoreCase)) + return preset; + return null; + } + + private static string ResolveDisplayName( + string displayName, + BmsDifficultyTablePreset? preset, + string fallback) + { + if (!string.IsNullOrWhiteSpace(displayName)) + return displayName.Trim(); + if (preset.HasValue) + return preset.Value.DisplayName; + return string.IsNullOrWhiteSpace(fallback) ? "BMS TABLE" : fallback; + } + + private static string NormalizeUrl(string headerUrl) + { + var value = (headerUrl ?? string.Empty).Trim(); + if (!Uri.TryCreate(value, UriKind.Absolute, out var uri) || + uri.Scheme != Uri.UriSchemeHttps) + throw new ArgumentException("Difficulty table URL must use HTTPS.", nameof(headerUrl)); + return uri.AbsoluteUri; + } + + private static string NormalizeId(string tableId) + { + var value = (tableId ?? string.Empty).Trim().ToLowerInvariant(); + if (value.Length == 0) + throw new ArgumentException("A difficulty-table ID is required.", nameof(tableId)); + var builder = new StringBuilder(value.Length); + foreach (var character in value) + { + if (char.IsLetterOrDigit(character) || character == '-' || character == '_') + builder.Append(character); + else + builder.Append('-'); + } + return builder.ToString().Trim('-'); + } + + private static string BuildStableId(string url) + { + using var sha = SHA256.Create(); + var hash = sha.ComputeHash(Encoding.UTF8.GetBytes(url.ToUpperInvariant())); + var builder = new StringBuilder("table-"); + for (var index = 0; index < 10; index++) + builder.Append(hash[index].ToString("x2")); + return builder.ToString(); + } + } +} diff --git a/Assets/ProjectQuint/Runtime/Unity/Library/BmsDifficultyTableRegistry.cs.meta b/Assets/ProjectQuint/Runtime/Unity/Library/BmsDifficultyTableRegistry.cs.meta new file mode 100644 index 0000000..01a49b9 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Library/BmsDifficultyTableRegistry.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a0092f256d394498896e08c52ff58b15 \ No newline at end of file diff --git a/Assets/ProjectQuint/Runtime/Unity/Library/BmsLibraryLocator.cs b/Assets/ProjectQuint/Runtime/Unity/Library/BmsLibraryLocator.cs new file mode 100644 index 0000000..a2cb09f --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Library/BmsLibraryLocator.cs @@ -0,0 +1,123 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; + +namespace ProjectQuint.Library +{ + internal static class BmsLibraryLocator + { + private static readonly HashSet ChartExtensions = + new HashSet(new[] { ".bms", ".bme", ".bml" }, StringComparer.OrdinalIgnoreCase); + + public static string Find(string configuredRoot, string applicationDataPath) + { + if (!string.IsNullOrWhiteSpace(configuredRoot) && Directory.Exists(configuredRoot)) + return Path.GetFullPath(configuredRoot); + + var directory = string.IsNullOrWhiteSpace(applicationDataPath) + ? null + : new DirectoryInfo(Path.GetFullPath(applicationDataPath)); + for (var depth = 0; directory != null && depth < 10; depth++, directory = directory.Parent) + { + foreach (var conventionalName in new[] { "BMS", "BMS-Library", "Songs" }) + { + var conventionalRoot = Path.Combine(directory.FullName, conventionalName); + if (ContainsChart(conventionalRoot)) + return Path.GetFullPath(conventionalRoot); + } + + var discovered = DiscoverChartDirectories(directory.FullName); + if (discovered.Count == 1) + return discovered[0]; + if (discovered.Count > 1) + return directory.FullName; + } + return string.Empty; + } + + internal static bool ContainsChart(string root) => ContainsChart(root, CancellationToken.None); + + internal static bool ContainsChart(string root, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(root) || !Directory.Exists(root)) + return false; + try + { + const int maximumDepth = 6; + const int maximumDirectories = 10000; + var pending = new Queue<(string Path, int Depth)>(); + pending.Enqueue((root, 0)); + var visited = 0; + while (pending.Count > 0 && visited++ < maximumDirectories) + { + cancellationToken.ThrowIfCancellationRequested(); + var current = pending.Dequeue(); + foreach (var path in Directory.EnumerateFiles(current.Path, "*", SearchOption.TopDirectoryOnly)) + { + cancellationToken.ThrowIfCancellationRequested(); + if (ChartExtensions.Contains(Path.GetExtension(path))) + return true; + } + if (current.Depth >= maximumDepth) + continue; + + foreach (var child in Directory.EnumerateDirectories(current.Path)) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!ShouldSkipTraversal(child)) + pending.Enqueue((child, current.Depth + 1)); + } + } + return false; + } + catch (OperationCanceledException) + { + throw; + } + catch + { + return false; + } + } + + private static List DiscoverChartDirectories(string root) + { + try + { + return Directory.EnumerateDirectories(root) + .Where(path => !LooksLikeDevelopmentOutput(path)) + .Where(ContainsChart) + .Select(Path.GetFullPath) + .ToList(); + } + catch + { + return new List(); + } + } + + private static bool LooksLikeDevelopmentOutput(string path) + { + var name = Path.GetFileName(path) ?? string.Empty; + return name.StartsWith(".", StringComparison.Ordinal) || + name.EndsWith("Builds", StringComparison.OrdinalIgnoreCase) || + path.EndsWith(".app", StringComparison.OrdinalIgnoreCase) || + Directory.Exists(Path.Combine(path, ".git")) || + Directory.Exists(Path.Combine(path, "ProjectSettings")) && + Directory.Exists(Path.Combine(path, "Assets")); + } + + private static bool ShouldSkipTraversal(string path) + { + var name = Path.GetFileName(path) ?? string.Empty; + return name.StartsWith(".", StringComparison.Ordinal) || + name.Equals("Library", StringComparison.OrdinalIgnoreCase) || + name.Equals("Temp", StringComparison.OrdinalIgnoreCase) || + name.Equals("Logs", StringComparison.OrdinalIgnoreCase) || + name.Equals("obj", StringComparison.OrdinalIgnoreCase) || + path.EndsWith(".app", StringComparison.OrdinalIgnoreCase); + } + } +} diff --git a/Assets/ProjectQuint/Runtime/Unity/Library/BmsLibraryLocator.cs.meta b/Assets/ProjectQuint/Runtime/Unity/Library/BmsLibraryLocator.cs.meta new file mode 100644 index 0000000..05fc21d --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Library/BmsLibraryLocator.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: ae68d8275950a4e52a161f5f23890b5b \ No newline at end of file diff --git a/Assets/ProjectQuint/Runtime/Unity/Library/BmsPackImportService.cs b/Assets/ProjectQuint/Runtime/Unity/Library/BmsPackImportService.cs new file mode 100644 index 0000000..c54f688 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Library/BmsPackImportService.cs @@ -0,0 +1,521 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.IO.Compression; +using System.Text; +using System.Threading; + +namespace ProjectQuint.Library +{ + internal readonly struct BmsPackImportInspection + { + public BmsPackImportInspection( + string sourcePath, + string destinationPath, + int totalFiles, + long extractedBytes, + long availableBytes) + { + SourcePath = sourcePath ?? string.Empty; + DestinationPath = destinationPath ?? string.Empty; + TotalFiles = totalFiles; + ExtractedBytes = extractedBytes; + AvailableBytes = availableBytes; + } + + public string SourcePath { get; } + public string DestinationPath { get; } + public int TotalFiles { get; } + public long ExtractedBytes { get; } + public long AvailableBytes { get; } + public bool HasEnoughSpace => AvailableBytes >= ExtractedBytes; + } + + internal sealed class BmsPackImportProgress + { + private readonly object _gate = new object(); + private int _completedFiles; + private int _totalFiles; + private long _completedBytes; + private long _totalBytes; + private string _currentEntry = string.Empty; + + public void Report(int completedFiles, int totalFiles, long completedBytes, long totalBytes, string currentEntry) + { + lock (_gate) + { + _completedFiles = completedFiles; + _totalFiles = totalFiles; + _completedBytes = completedBytes; + _totalBytes = totalBytes; + _currentEntry = currentEntry ?? string.Empty; + } + } + + public BmsPackImportProgressSnapshot Snapshot() + { + lock (_gate) + return new BmsPackImportProgressSnapshot( + _completedFiles, + _totalFiles, + _completedBytes, + _totalBytes, + _currentEntry); + } + } + + internal readonly struct BmsPackImportProgressSnapshot + { + public BmsPackImportProgressSnapshot( + int completedFiles, + int totalFiles, + long completedBytes, + long totalBytes, + string currentEntry) + { + CompletedFiles = completedFiles; + TotalFiles = totalFiles; + CompletedBytes = completedBytes; + TotalBytes = totalBytes; + CurrentEntry = currentEntry ?? string.Empty; + } + + public int CompletedFiles { get; } + public int TotalFiles { get; } + public long CompletedBytes { get; } + public long TotalBytes { get; } + public string CurrentEntry { get; } + } + + internal static class BmsPackImportService + { + internal const string CompletionMarkerFileName = ".projectquint-import-complete"; + private static readonly Encoding[] EntryNameEncodingCandidates = CreateEntryNameEncodingCandidates(); + + public static BmsPackImportInspection InspectAdjacentZip(string zipPath) + { + ValidateZipPath(zipPath); + var source = Path.GetFullPath(zipPath); + var destination = BuildAdjacentExtractionPath(source); + using var stream = File.Open(source, FileMode.Open, FileAccess.Read, FileShare.Read); + var entryNameEncoding = DetectEntryNameEncoding(stream); + stream.Position = 0; + using var archive = new ZipArchive( + stream, + ZipArchiveMode.Read, + leaveOpen: false, + entryNameEncoding: entryNameEncoding); + CountArchive(archive, out var totalFiles, out var totalBytes); + var destinationParent = Path.GetDirectoryName(destination) ?? source; + var availableBytes = new DriveInfo(Path.GetPathRoot(destinationParent) ?? destinationParent) + .AvailableFreeSpace; + return new BmsPackImportInspection( + source, + destination, + totalFiles, + totalBytes, + availableBytes); + } + + public static string ExtractZipBesideArchive( + string zipPath, + BmsPackImportProgress progress, + CancellationToken cancellationToken) + { + ValidateZipPath(zipPath); + var source = Path.GetFullPath(zipPath); + var destination = BuildAdjacentExtractionPath(source); + if (IsCompletedManagedExtraction(destination)) + return destination; + if (Directory.Exists(destination)) + throw new IOException( + $"The extraction folder already exists and is not a completed ProjectQuint import: {destination}"); + + return ExtractZipToDestination( + source, + destination, + progress, + cancellationToken, + writeCompletionMarker: true); + } + + public static string ExtractZip( + string zipPath, + string importedPackRoot, + BmsPackImportProgress progress, + CancellationToken cancellationToken) + { + ValidateZipPath(zipPath); + var source = Path.GetFullPath(zipPath); + var destination = Path.Combine( + Path.GetFullPath(importedPackRoot), + BmsPackRegistry.BuildStableId(source)); + if (BmsLibraryLocator.ContainsChart(destination)) + return destination; + if (Directory.Exists(destination)) + Directory.Delete(destination, true); + + Directory.CreateDirectory(importedPackRoot); + return ExtractZipToDestination( + source, + destination, + progress, + cancellationToken, + writeCompletionMarker: false); + } + + public static string BuildAdjacentExtractionPath(string zipPath) + { + if (string.IsNullOrWhiteSpace(zipPath) || + !string.Equals(Path.GetExtension(zipPath), ".zip", StringComparison.OrdinalIgnoreCase)) + throw new InvalidDataException("A .zip source path is required."); + var source = Path.GetFullPath(zipPath); + var directory = Path.GetDirectoryName(source); + var name = Path.GetFileNameWithoutExtension(source).Trim(); + if (string.IsNullOrWhiteSpace(directory) || string.IsNullOrWhiteSpace(name)) + throw new InvalidDataException("The ZIP path cannot produce an extraction folder."); + return Path.Combine(directory, name); + } + + public static bool IsCompletedManagedExtraction(string path) => + !string.IsNullOrWhiteSpace(path) && + Directory.Exists(path) && + File.Exists(Path.Combine(path, CompletionMarkerFileName)) && + BmsLibraryLocator.ContainsChart(path); + + private static string ExtractZipToDestination( + string source, + string destination, + BmsPackImportProgress progress, + CancellationToken cancellationToken, + bool writeCompletionMarker) + { + var destinationParent = Path.GetDirectoryName(destination); + if (string.IsNullOrWhiteSpace(destinationParent)) + throw new InvalidDataException("The ZIP extraction destination has no parent folder."); + Directory.CreateDirectory(destinationParent); + var staging = destination + ".importing-" + Guid.NewGuid().ToString("N"); + Directory.CreateDirectory(staging); + var stagingPrefix = Path.GetFullPath(staging) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar; + + try + { + using var stream = File.Open(source, FileMode.Open, FileAccess.Read, FileShare.Read); + // Legacy BMS archives are commonly repacked on either Japanese or Korean + // Windows. Detect one lossless encoding for every unflagged central-directory + // name instead of replacing invalid bytes and breaking keysound references. + var entryNameEncoding = DetectEntryNameEncoding(stream); + stream.Position = 0; + using var archive = new ZipArchive( + stream, + ZipArchiveMode.Read, + leaveOpen: false, + entryNameEncoding: entryNameEncoding); + CountArchive(archive, out var totalFiles, out var totalBytes); + + var completedFiles = 0; + var completedBytes = 0L; + var copyBuffer = new byte[1024 * 1024]; + progress?.Report(0, totalFiles, 0, totalBytes, string.Empty); + foreach (var entry in archive.Entries) + { + cancellationToken.ThrowIfCancellationRequested(); + var outputPath = Path.GetFullPath(Path.Combine(staging, entry.FullName)); + if (!outputPath.StartsWith(stagingPrefix, StringComparison.Ordinal)) + throw new InvalidDataException($"Unsafe ZIP entry path: {entry.FullName}"); + + if (string.IsNullOrEmpty(entry.Name)) + { + Directory.CreateDirectory(outputPath); + continue; + } + + var parent = Path.GetDirectoryName(outputPath); + if (!string.IsNullOrWhiteSpace(parent)) + Directory.CreateDirectory(parent); + using (var input = entry.Open()) + using (var output = new FileStream(outputPath, FileMode.CreateNew, FileAccess.Write, FileShare.None)) + { + int read; + while ((read = input.Read(copyBuffer, 0, copyBuffer.Length)) > 0) + { + cancellationToken.ThrowIfCancellationRequested(); + output.Write(copyBuffer, 0, read); + completedBytes += read; + progress?.Report( + completedFiles, + totalFiles, + completedBytes, + totalBytes, + entry.FullName); + } + } + + completedFiles++; + progress?.Report( + completedFiles, + totalFiles, + completedBytes, + totalBytes, + entry.FullName); + } + + cancellationToken.ThrowIfCancellationRequested(); + if (!BmsLibraryLocator.ContainsChart(staging)) + throw new InvalidDataException("The selected ZIP does not contain BMS/BME/BML charts."); + if (writeCompletionMarker) + File.WriteAllText( + Path.Combine(staging, CompletionMarkerFileName), + $"source={source}{Environment.NewLine}completedUtc={DateTime.UtcNow:O}{Environment.NewLine}"); + if (Directory.Exists(destination)) + throw new IOException($"An incomplete imported pack already exists: {destination}"); + Directory.Move(staging, destination); + return destination; + } + catch + { + if (Directory.Exists(staging)) + Directory.Delete(staging, true); + throw; + } + } + + private static void ValidateZipPath(string zipPath) + { + if (string.IsNullOrWhiteSpace(zipPath) || !File.Exists(zipPath)) + throw new FileNotFoundException("BMS pack ZIP not found.", zipPath); + if (!string.Equals(Path.GetExtension(zipPath), ".zip", StringComparison.OrdinalIgnoreCase)) + throw new InvalidDataException("Only .zip BMS pack archives can be imported."); + } + + private static void CountArchive( + ZipArchive archive, + out int totalFiles, + out long totalBytes) + { + totalFiles = 0; + totalBytes = 0L; + foreach (var entry in archive.Entries) + { + if (string.IsNullOrEmpty(entry.Name)) + continue; + totalFiles++; + totalBytes += Math.Max(0L, entry.Length); + } + } + + private static Encoding DetectEntryNameEncoding(Stream stream) + { + Exception lastError = null; + Encoding bestEncoding = null; + var bestPenalty = long.MaxValue; + foreach (var encoding in EntryNameEncodingCandidates) + { + stream.Position = 0; + try + { + using var candidate = new ZipArchive( + stream, + ZipArchiveMode.Read, + leaveOpen: true, + entryNameEncoding: encoding); + var penalty = 0L; + foreach (var entry in candidate.Entries) + penalty += SuspiciousUnicodePenalty(entry.FullName); + if (penalty < bestPenalty) + { + bestEncoding = encoding; + bestPenalty = penalty; + } + } + catch (DecoderFallbackException exception) + { + lastError = exception; + } + catch (EncoderFallbackException exception) + { + lastError = exception; + } + } + + if (bestEncoding != null) + return bestEncoding; + + throw new InvalidDataException( + "ZIP filenames are not valid UTF-8, Japanese Windows (CP932), or Korean Windows (CP949). " + + "Repack the archive with UTF-8 filenames before importing.", + lastError); + } + + private static int SuspiciousUnicodePenalty(string value) + { + var penalty = 0; + foreach (var character in value ?? string.Empty) + { + if (character == '\uFFFD') + penalty += 10000; + else if (character == '?') + // Unity Mono's legacy code-page decoder can emit '?' even when configured + // with ExceptionFallback, so treat it as a lossy-decoding signal. + penalty += 1000; + else if (character >= '\uE000' && character <= '\uF8FF') + penalty += 1000; + else if (char.IsSurrogate(character)) + penalty += 10000; + else if (char.IsControl(character)) + penalty += 100; + } + return penalty; + } + + private static Encoding[] CreateEntryNameEncodingCandidates() + { + try + { + var providerType = Type.GetType( + "System.Text.CodePagesEncodingProvider, System.Text.Encoding.CodePages", + throwOnError: false); + var provider = providerType?.GetProperty("Instance")?.GetValue(null) as EncodingProvider; + if (provider != null) + Encoding.RegisterProvider(provider); + } + catch + { + // Unity's Mono runtime exposes Windows code pages directly. Registration is + // needed only on runtimes that ship them in System.Text.Encoding.CodePages. + } + + return new[] + { + new UTF8Encoding(false, true), + Encoding.GetEncoding( + 932, + EncoderFallback.ReplacementFallback, + DecoderFallback.ExceptionFallback), + Encoding.GetEncoding( + 949, + EncoderFallback.ReplacementFallback, + DecoderFallback.ExceptionFallback), + }; + } + } + + internal static class NativeBmsPackPicker + { + public static string LastError { get; private set; } = string.Empty; + + public static string ChooseFolder() => RunAppleScript( + "POSIX path of (choose folder with prompt \"Select a BMS pack folder\")"); + + public static string ChooseZip() => RunAppleScript( + "POSIX path of (choose file with prompt \"Select a BMS pack ZIP\")"); + + public static bool ConfirmZipImport(BmsPackImportInspection inspection) + { + var message = + $"Archive: {Path.GetFileName(inspection.SourcePath)}\n" + + $"Extracted size: {FormatGigabytes(inspection.ExtractedBytes)}\n" + + $"Available space: {FormatGigabytes(inspection.AvailableBytes)}\n" + + $"Destination: {inspection.DestinationPath}\n\n" + + "The original ZIP will be kept. Delete it manually after verifying the pack."; + var result = RunAppleScript( + "button returned of (display dialog \"" + + EscapeAppleScript(message) + + "\" with title \"Import BMS Pack\" buttons {\"Cancel\", \"Import\"} " + + "default button \"Import\" cancel button \"Cancel\")"); + return string.Equals(result, "Import", StringComparison.Ordinal); + } + + public static string ChoosePackDisplayName(string currentName) => RunAppleScript( + "text returned of (display dialog \"Rename BMS pack\" default answer \"" + + EscapeAppleScript(currentName) + + "\" buttons {\"Cancel\", \"Rename\"} default button \"Rename\" cancel button \"Cancel\")"); + + internal static string RunAppleScript(string script) + { +#if UNITY_STANDALONE_OSX || UNITY_EDITOR_OSX + LastError = string.Empty; + var scriptPath = Path.Combine( + Path.GetTempPath(), + "ProjectQuint-picker-" + Guid.NewGuid().ToString("N") + ".applescript"); + try + { + File.WriteAllText(scriptPath, script); + using var process = Process.Start(new ProcessStartInfo + { + FileName = "/usr/bin/osascript", + Arguments = "\"" + scriptPath.Replace("\"", "\\\"") + "\"", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + StandardOutputEncoding = Encoding.UTF8, + StandardErrorEncoding = Encoding.UTF8, + CreateNoWindow = true, + }); + if (process == null) + { + LastError = "Could not start the macOS file picker process."; + return string.Empty; + } + var outputTask = process.StandardOutput.ReadToEndAsync(); + var errorTask = process.StandardError.ReadToEndAsync(); + process.WaitForExit(); + System.Threading.Tasks.Task.WaitAll(outputTask, errorTask); + if (process.ExitCode == 0) + return outputTask.Result.Trim(); + + var error = errorTask.Result?.Trim() ?? string.Empty; + if (error.IndexOf("-128", StringComparison.Ordinal) < 0 && + error.IndexOf("cancel", StringComparison.OrdinalIgnoreCase) < 0) + LastError = string.IsNullOrWhiteSpace(error) + ? $"macOS file picker exited with code {process.ExitCode}." + : error; + return string.Empty; + } + catch (Exception exception) + { + LastError = exception.Message; + return string.Empty; + } + finally + { + if (File.Exists(scriptPath)) + File.Delete(scriptPath); + } +#else + return string.Empty; +#endif + } + + private static string EscapeAppleScript(string value) => (value ?? string.Empty) + .Replace("\\", "\\\\") + .Replace("\"", "\\\"") + .Replace("\r", string.Empty) + .Replace("\n", "\" & return & \""); + + private static string FormatGigabytes(long bytes) => + $"{Math.Max(0L, bytes) / 1_000_000_000.0:0.0} GB"; + } + + internal static class NativeProfileArchivePicker + { + public static string ChooseExportPath(string defaultName) => NativeBmsPackPicker.RunAppleScript( + "POSIX path of (choose file name with prompt \"Export ProjectQuint profile\" default name \"" + + EscapeAppleScript(defaultName) + "\")"); + + public static string ChooseImportPath() => NativeBmsPackPicker.RunAppleScript( + "POSIX path of (choose file with prompt \"Select a ProjectQuint profile archive\")"); + + private static string EscapeAppleScript(string value) => (value ?? string.Empty) + .Replace("\\", "\\\\") + .Replace("\"", "\\\""); + } + + internal static class NativeProfileImagePicker + { + public static string ChooseImagePath() => NativeBmsPackPicker.RunAppleScript( + "POSIX path of (choose file with prompt \"Select a ProjectQuint profile photo\" " + + "of type {\"public.png\", \"public.jpeg\"})"); + } +} diff --git a/Assets/ProjectQuint/Runtime/Unity/Library/BmsPackImportService.cs.meta b/Assets/ProjectQuint/Runtime/Unity/Library/BmsPackImportService.cs.meta new file mode 100644 index 0000000..024bce7 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Library/BmsPackImportService.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 8f8a5333b1f25481dbb2b16e265a1f7d \ No newline at end of file diff --git a/Assets/ProjectQuint/Runtime/Unity/Library/BmsPackRegistry.cs b/Assets/ProjectQuint/Runtime/Unity/Library/BmsPackRegistry.cs new file mode 100644 index 0000000..7e1f556 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Library/BmsPackRegistry.cs @@ -0,0 +1,650 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Security.Cryptography; +using System.Text; +using ProjectQuint.Persistence; + +namespace ProjectQuint.Library +{ + public enum BmsPackSourceKind + { + Folder, + ExtractedZip, + } + + [Serializable] + public sealed class BmsPackDefinition + { + public string Id = string.Empty; + public string DisplayName = string.Empty; + public string ContentRoot = string.Empty; + public string SourcePath = string.Empty; + public BmsPackSourceKind SourceKind; + // Schema-v1 migration seam only. New table registrations live in the global + // BmsDifficultyTableRegistry and this value is cleared after successful promotion. + public string DifficultyTableUrl = string.Empty; + // Schema-v2 pack-scoped reference. Table definitions and caches remain global; + // each pack independently selects which registered table annotates its charts. + public string DifficultyTableId = string.Empty; + // Schema-v3 music-select resume state. Search focus/query and open modal state are + // intentionally transient; these fields restore only the useful pack-scoped view. + public List SelectedLevelFilters = new List(); + public string LastSelectedChartMd5 = string.Empty; + public int LibrarySortMode; + public bool LibraryFavoritesOnly; + public int ForcedKeyCount; + public long AddedUtcTicks; + + public bool IsAvailable => !string.IsNullOrWhiteSpace(ContentRoot) && Directory.Exists(ContentRoot); + public string KeyModeLabel => ForcedKeyCount >= 4 && ForcedKeyCount <= 7 + ? $"{ForcedKeyCount}K" + : "AUTO"; + } + + [Serializable] + internal sealed class BmsPackRegistryDocument + { + public const int CurrentSchemaVersion = 3; + + public int SchemaVersion = CurrentSchemaVersion; + public string ActivePackId = string.Empty; + public List Packs = new List(); + } + + internal sealed class BmsPackRegistry + { + private readonly AtomicJsonFile _file; + private readonly string _packIndexRoot; + private readonly string _legacyImportedPackRoot; + private readonly Action _logWarning; + private readonly List _packs = new List(); + + public BmsPackRegistry( + string path = null, + Action logWarning = null, + string packIndexRoot = null, + string importedPackRoot = null) + { + _packIndexRoot = Path.GetFullPath(packIndexRoot ?? PersistencePaths.PackIndexRoot); + _legacyImportedPackRoot = + Path.GetFullPath(importedPackRoot ?? PersistencePaths.ImportedPackRoot); + _logWarning = logWarning; + _file = new AtomicJsonFile( + path ?? PersistencePaths.PackRegistry, + document => document != null && + document.SchemaVersion >= 1 && + document.SchemaVersion <= BmsPackRegistryDocument.CurrentSchemaVersion && + document.Packs != null, + logWarning); + Load(); + } + + public IReadOnlyList Packs => _packs; + public string ActivePackId { get; private set; } = string.Empty; + public BmsPackDefinition ActivePack => Find(ActivePackId); + public string LastRemovalWarning { get; private set; } = string.Empty; + + public BmsPackDefinition RegisterFolder( + string folderPath, + string displayName = null, + int forcedKeyCount = 0, + bool chartPresenceVerified = false) + { + if (string.IsNullOrWhiteSpace(folderPath)) + throw new ArgumentException("A BMS pack folder is required.", nameof(folderPath)); + + var root = Path.GetFullPath(folderPath); + if (!Directory.Exists(root)) + throw new DirectoryNotFoundException($"BMS pack folder not found: {root}"); + if (!chartPresenceVerified && !BmsLibraryLocator.ContainsChart(root)) + throw new InvalidDataException("The selected folder does not contain BMS/BME/BML charts."); + + return Register(new BmsPackDefinition + { + Id = BuildStableId(root), + DisplayName = ResolveDisplayName(displayName, root), + ContentRoot = root, + SourcePath = root, + SourceKind = BmsPackSourceKind.Folder, + ForcedKeyCount = NormalizeForcedKeyCount(forcedKeyCount), + AddedUtcTicks = DateTime.UtcNow.Ticks, + }); + } + + public BmsPackDefinition RegisterExtractedZip( + string zipPath, + string extractedRoot, + string displayName = null, + int forcedKeyCount = 0) + { + if (string.IsNullOrWhiteSpace(zipPath) || string.IsNullOrWhiteSpace(extractedRoot)) + throw new ArgumentException("ZIP source and extracted content root are required."); + + var source = Path.GetFullPath(zipPath); + var root = Path.GetFullPath(extractedRoot); + if (!File.Exists(source)) + throw new FileNotFoundException("BMS pack ZIP not found.", source); + if (!BmsLibraryLocator.ContainsChart(root)) + throw new InvalidDataException("The extracted ZIP does not contain BMS/BME/BML charts."); + + return Register(new BmsPackDefinition + { + Id = BuildStableId(source), + DisplayName = ResolveDisplayName(displayName, Path.GetFileNameWithoutExtension(source)), + ContentRoot = root, + SourcePath = source, + SourceKind = BmsPackSourceKind.ExtractedZip, + ForcedKeyCount = NormalizeForcedKeyCount(forcedKeyCount), + AddedUtcTicks = DateTime.UtcNow.Ticks, + }); + } + + public BmsPackDefinition MigrateLegacyFolder(string legacyRoot) + { + if (string.IsNullOrWhiteSpace(legacyRoot) || !Directory.Exists(legacyRoot)) + return null; + + var id = BuildStableId(Path.GetFullPath(legacyRoot)); + return Find(id) ?? RegisterFolder(legacyRoot); + } + + public void Select(string packId) + { + var pack = Find(packId) ?? throw new ArgumentException("Unknown BMS pack.", nameof(packId)); + ActivePackId = pack.Id; + Save(); + } + + public bool Unregister(string packId) + { + var pack = Find(packId); + if (pack == null) + return false; + + LastRemovalWarning = string.Empty; + var stagedOwnedContent = StageOwnedExtractedContentForRemoval(pack); + var removedIndex = _packs.IndexOf(pack); + var previousActivePackId = ActivePackId; + _packs.RemoveAt(removedIndex); + if (string.Equals(previousActivePackId, pack.Id, StringComparison.OrdinalIgnoreCase)) + { + ActivePackId = _packs.Count == 0 + ? string.Empty + : _packs[Math.Min(removedIndex, _packs.Count - 1)].Id; + } + + try + { + Save(); + } + catch + { + _packs.Insert(removedIndex, pack); + ActivePackId = previousActivePackId; + RestoreStagedOwnedContent(stagedOwnedContent, pack.ContentRoot); + throw; + } + + TryDeletePackIndex(pack); + DeleteStagedOwnedContent(stagedOwnedContent, pack); + return true; + } + + public BmsPackDefinition Rename(string packId, string displayName) + { + var pack = Find(packId) ?? throw new ArgumentException("Unknown BMS pack.", nameof(packId)); + var normalizedName = (displayName ?? string.Empty).Trim(); + if (normalizedName.Length == 0) + throw new ArgumentException("A pack display name is required.", nameof(displayName)); + if (normalizedName.Length > 80) + throw new ArgumentException("A pack display name cannot exceed 80 characters.", nameof(displayName)); + + var previousName = pack.DisplayName; + pack.DisplayName = normalizedName; + try + { + Save(); + } + catch + { + pack.DisplayName = previousName; + throw; + } + return pack; + } + + public BmsPackDefinition RelinkFolder(string packId, string folderPath) + { + var pack = Find(packId) ?? throw new ArgumentException("Unknown BMS pack.", nameof(packId)); + if (string.IsNullOrWhiteSpace(folderPath)) + throw new ArgumentException("A BMS pack folder is required.", nameof(folderPath)); + + var root = Path.GetFullPath(folderPath); + if (!Directory.Exists(root)) + throw new DirectoryNotFoundException($"BMS pack folder not found: {root}"); + if (!BmsLibraryLocator.ContainsChart(root)) + throw new InvalidDataException("The selected folder does not contain BMS/BME/BML charts."); + + InvalidateIndex(pack.Id); + var previousRoot = pack.ContentRoot; + var previousSourcePath = pack.SourcePath; + pack.ContentRoot = root; + if (pack.SourceKind == BmsPackSourceKind.Folder) + pack.SourcePath = root; + try + { + Save(); + } + catch + { + pack.ContentRoot = previousRoot; + pack.SourcePath = previousSourcePath; + throw; + } + return pack; + } + + public void InvalidateIndex(string packId) + { + var pack = Find(packId) ?? throw new ArgumentException("Unknown BMS pack.", nameof(packId)); + var directory = ResolvePackIndexDirectory(pack); + if (Directory.Exists(directory)) + Directory.Delete(directory, true); + } + + public int ClearLegacyDifficultyTableUrls() + { + var cleared = 0; + foreach (var pack in _packs) + { + if (string.IsNullOrWhiteSpace(pack.DifficultyTableUrl)) + continue; + pack.DifficultyTableUrl = string.Empty; + cleared++; + } + if (cleared > 0) + Save(); + return cleared; + } + + public BmsPackDefinition AssignDifficultyTable(string packId, string tableId) + { + var pack = Find(packId) ?? throw new ArgumentException("Unknown BMS pack.", nameof(packId)); + var normalizedTableId = (tableId ?? string.Empty).Trim(); + if (string.Equals( + pack.DifficultyTableId, + normalizedTableId, + StringComparison.OrdinalIgnoreCase)) + return pack; + + var previousTableId = pack.DifficultyTableId; + pack.DifficultyTableId = normalizedTableId; + try + { + Save(); + } + catch + { + pack.DifficultyTableId = previousTableId; + throw; + } + return pack; + } + + public BmsPackDefinition SetForcedKeyCount(string packId, int keyCount) + { + var pack = Find(packId) ?? throw new ArgumentException("Unknown BMS pack.", nameof(packId)); + var normalized = NormalizeForcedKeyCount(keyCount); + if (pack.ForcedKeyCount == normalized) + return pack; + + var previous = pack.ForcedKeyCount; + pack.ForcedKeyCount = normalized; + try + { + Save(); + InvalidateIndex(pack.Id); + } + catch + { + pack.ForcedKeyCount = previous; + Save(); + throw; + } + return pack; + } + + public int PromoteLegacyDifficultyTableAssignments(BmsDifficultyTableRegistry tableRegistry) + { + if (tableRegistry == null) + throw new ArgumentNullException(nameof(tableRegistry)); + + var promoted = 0; + // First recover schema-v1 URLs that have not yet been cleared. + foreach (var pack in _packs) + { + if (!string.IsNullOrWhiteSpace(pack.DifficultyTableId) || + string.IsNullOrWhiteSpace(pack.DifficultyTableUrl)) + continue; + var table = tableRegistry.FindByUrl(pack.DifficultyTableUrl); + if (table == null) + continue; + pack.DifficultyTableId = table.Id; + promoted++; + } + + // Earlier global-registry migrations preserved the former pack IDs on each + // table before clearing pack URLs. Use that durable provenance to restore + // per-pack assignments without guessing from folder or display names. + foreach (var table in tableRegistry.Tables) + { + foreach (var legacyPackId in table.LegacyProviderIds ?? new List()) + { + var pack = Find(legacyPackId); + if (pack == null || !string.IsNullOrWhiteSpace(pack.DifficultyTableId)) + continue; + pack.DifficultyTableId = table.Id; + promoted++; + } + } + if (promoted > 0) + Save(); + return promoted; + } + + public int ClearDifficultyTableAssignments(string tableId) + { + if (string.IsNullOrWhiteSpace(tableId)) + return 0; + + var cleared = 0; + foreach (var pack in _packs) + { + if (!string.Equals( + pack.DifficultyTableId, + tableId.Trim(), + StringComparison.OrdinalIgnoreCase)) + continue; + pack.DifficultyTableId = string.Empty; + cleared++; + } + if (cleared > 0) + Save(); + return cleared; + } + + public bool StageLibraryViewState( + string packId, + IReadOnlyCollection selectedLevelFilters, + SongSortMode sortMode, + bool favoritesOnly, + string selectedChartMd5) + { + var pack = Find(packId) ?? throw new ArgumentException("Unknown BMS pack.", nameof(packId)); + var normalizedFilters = NormalizeLevelFilters(selectedLevelFilters); + var normalizedSortMode = Enum.IsDefined(typeof(SongSortMode), sortMode) + ? sortMode + : SongSortMode.DifficultyAscending; + var normalizedChartMd5 = (selectedChartMd5 ?? string.Empty).Trim().ToLowerInvariant(); + if (LevelFiltersEqual(pack.SelectedLevelFilters, normalizedFilters) && + pack.LibrarySortMode == (int)normalizedSortMode && + pack.LibraryFavoritesOnly == favoritesOnly && + string.Equals( + pack.LastSelectedChartMd5, + normalizedChartMd5, + StringComparison.OrdinalIgnoreCase)) + return false; + + pack.SelectedLevelFilters = normalizedFilters; + pack.LibrarySortMode = (int)normalizedSortMode; + pack.LibraryFavoritesOnly = favoritesOnly; + pack.LastSelectedChartMd5 = normalizedChartMd5; + return true; + } + + public void FlushStagedChanges() => Save(); + + public BmsPackDefinition Find(string packId) + { + if (string.IsNullOrWhiteSpace(packId)) + return null; + return _packs.Find(pack => string.Equals(pack.Id, packId, StringComparison.OrdinalIgnoreCase)); + } + + public string CachePathFor(BmsPackDefinition pack) + { + if (pack == null || string.IsNullOrWhiteSpace(pack.Id)) + throw new ArgumentNullException(nameof(pack)); + return Path.Combine(ResolvePackIndexDirectory(pack), "song-index.json"); + } + + public static string BuildStableId(string sourcePath) + { + var normalized = Path.GetFullPath(sourcePath ?? string.Empty) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + .ToUpperInvariant(); + using var sha = SHA256.Create(); + var hash = sha.ComputeHash(Encoding.UTF8.GetBytes(normalized)); + var builder = new StringBuilder(20); + for (var index = 0; index < 10; index++) + builder.Append(hash[index].ToString("x2")); + return builder.ToString(); + } + + private BmsPackDefinition Register(BmsPackDefinition candidate) + { + var existing = Find(candidate.Id); + if (existing == null) + { + _packs.Add(candidate); + existing = candidate; + } + else + { + existing.DisplayName = candidate.DisplayName; + existing.ContentRoot = candidate.ContentRoot; + existing.SourcePath = candidate.SourcePath; + existing.SourceKind = candidate.SourceKind; + existing.ForcedKeyCount = candidate.ForcedKeyCount; + } + + ActivePackId = existing.Id; + Save(); + return existing; + } + + private void Load() + { + _packs.Clear(); + if (!_file.TryLoad(out var document, out _)) + return; + + var needsSchemaUpgrade = + document.SchemaVersion < BmsPackRegistryDocument.CurrentSchemaVersion; + foreach (var pack in document.Packs) + { + if (pack == null || string.IsNullOrWhiteSpace(pack.Id) || string.IsNullOrWhiteSpace(pack.ContentRoot)) + continue; + pack.ForcedKeyCount = NormalizeForcedKeyCount(pack.ForcedKeyCount); + pack.DisplayName = ResolveDisplayName(pack.DisplayName, pack.ContentRoot); + pack.DifficultyTableId = (pack.DifficultyTableId ?? string.Empty).Trim(); + pack.SelectedLevelFilters = NormalizeLevelFilters(pack.SelectedLevelFilters); + pack.LastSelectedChartMd5 = + (pack.LastSelectedChartMd5 ?? string.Empty).Trim().ToLowerInvariant(); + if (!Enum.IsDefined(typeof(SongSortMode), pack.LibrarySortMode)) + pack.LibrarySortMode = (int)SongSortMode.DifficultyAscending; + _packs.Add(pack); + } + ActivePackId = document.ActivePackId ?? string.Empty; + if (Find(ActivePackId) == null) + ActivePackId = string.Empty; + if (needsSchemaUpgrade) + Save(); + } + + private void Save() + { + _file.Save(new BmsPackRegistryDocument + { + ActivePackId = ActivePackId, + Packs = new List(_packs), + }); + } + + private string ResolvePackIndexDirectory(BmsPackDefinition pack) + { + var root = _packIndexRoot.TrimEnd( + Path.DirectorySeparatorChar, + Path.AltDirectorySeparatorChar); + var directory = Path.GetFullPath(Path.Combine(root, pack.Id)); + var rootPrefix = root + Path.DirectorySeparatorChar; + if (!directory.StartsWith(rootPrefix, StringComparison.Ordinal)) + throw new InvalidDataException("The pack index path escapes the cache root."); + return directory; + } + + private void TryDeletePackIndex(BmsPackDefinition pack) + { + try + { + var directory = ResolvePackIndexDirectory(pack); + if (Directory.Exists(directory)) + Directory.Delete(directory, true); + } + catch (Exception exception) + { + _logWarning?.Invoke( + $"BMS pack '{pack.DisplayName}' was unregistered, but its derived song index " + + $"could not be removed: {exception.Message}"); + } + } + + private string StageOwnedExtractedContentForRemoval(BmsPackDefinition pack) + { + if (pack?.SourceKind != BmsPackSourceKind.ExtractedZip || + string.IsNullOrWhiteSpace(pack.ContentRoot) || + !Directory.Exists(pack.ContentRoot)) + return string.Empty; + + var contentRoot = Path.GetFullPath(pack.ContentRoot); + var legacyOwned = IsPathWithinRoot(contentRoot, _legacyImportedPackRoot); + var adjacentOwned = false; + if (!string.IsNullOrWhiteSpace(pack.SourcePath)) + { + try + { + adjacentOwned = PathsEqual( + contentRoot, + BmsPackImportService.BuildAdjacentExtractionPath(pack.SourcePath)) && + File.Exists(Path.Combine( + contentRoot, + BmsPackImportService.CompletionMarkerFileName)); + } + catch (Exception) + { + adjacentOwned = false; + } + } + if (!legacyOwned && !adjacentOwned) + throw new InvalidDataException( + $"Refusing to delete an extracted pack outside its managed location: {contentRoot}"); + + var staged = contentRoot + ".removing-" + Guid.NewGuid().ToString("N"); + Directory.Move(contentRoot, staged); + return staged; + } + + private static void RestoreStagedOwnedContent(string stagedPath, string originalPath) + { + if (string.IsNullOrWhiteSpace(stagedPath) || !Directory.Exists(stagedPath)) + return; + if (Directory.Exists(originalPath)) + throw new IOException( + $"Could not restore removed pack content because the original path exists: {originalPath}"); + Directory.Move(stagedPath, originalPath); + } + + private void DeleteStagedOwnedContent(string stagedPath, BmsPackDefinition pack) + { + if (string.IsNullOrWhiteSpace(stagedPath) || !Directory.Exists(stagedPath)) + return; + try + { + Directory.Delete(stagedPath, true); + } + catch (Exception exception) + { + LastRemovalWarning = + $"Pack registration was removed, but managed song files could not be deleted: {exception.Message}"; + _logWarning?.Invoke( + $"BMS pack '{pack.DisplayName}' was unregistered, but its managed song files " + + $"could not be removed: {exception.Message}"); + } + } + + private static bool IsPathWithinRoot(string path, string root) + { + var normalizedPath = Path.GetFullPath(path) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + var normalizedRoot = Path.GetFullPath(root) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + return normalizedPath.StartsWith( + normalizedRoot + Path.DirectorySeparatorChar, + StringComparison.OrdinalIgnoreCase); + } + + private static bool PathsEqual(string left, string right) => + string.Equals( + Path.GetFullPath(left ?? string.Empty) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar), + Path.GetFullPath(right ?? string.Empty) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar), + StringComparison.OrdinalIgnoreCase); + + private static string ResolveDisplayName(string preferred, string source) + { + if (!string.IsNullOrWhiteSpace(preferred)) + return preferred.Trim(); + var trimmed = (source ?? string.Empty) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + var name = Path.GetFileName(trimmed); + return string.IsNullOrWhiteSpace(name) ? "BMS PACK" : name; + } + + private static int NormalizeForcedKeyCount(int keyCount) => + keyCount >= 4 && keyCount <= 7 ? keyCount : 0; + + private static List NormalizeLevelFilters( + IReadOnlyCollection selectedLevelFilters) + { + var normalized = new List(); + foreach (var level in selectedLevelFilters ?? Array.Empty()) + { + var value = (level ?? string.Empty).Trim(); + if (value.Length == 0 || + normalized.Exists(existing => + string.Equals(existing, value, StringComparison.OrdinalIgnoreCase))) + continue; + normalized.Add(value); + } + return normalized; + } + + private static bool LevelFiltersEqual( + IReadOnlyList left, + IReadOnlyList right) + { + if ((left?.Count ?? 0) != (right?.Count ?? 0)) + return false; + for (var index = 0; index < (left?.Count ?? 0); index++) + if (!string.Equals( + left[index], + right[index], + StringComparison.OrdinalIgnoreCase)) + return false; + return true; + } + + } +} diff --git a/Assets/ProjectQuint/Runtime/Unity/Library/BmsPackRegistry.cs.meta b/Assets/ProjectQuint/Runtime/Unity/Library/BmsPackRegistry.cs.meta new file mode 100644 index 0000000..e41bba2 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Library/BmsPackRegistry.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c11fd0e2ddcd948c0bc5ba843bbea2de \ No newline at end of file diff --git a/Assets/ProjectQuint/Runtime/Unity/Library/BmsResourceResolver.cs b/Assets/ProjectQuint/Runtime/Unity/Library/BmsResourceResolver.cs new file mode 100644 index 0000000..93a9e53 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Library/BmsResourceResolver.cs @@ -0,0 +1,51 @@ +using System; +using System.IO; + +namespace ProjectQuint.Library +{ + public static class BmsResourceResolver + { + private static readonly string[] AudioExtensions = { ".ogg", ".wav", ".mp3" }; + private static readonly string[] VideoExtensions = + { ".mpg", ".mpeg", ".mp4", ".m4v", ".mov", ".webm", ".ogv", ".vp8", ".wmv", ".avi" }; + + public static string ResolveAudio(string chartPath, string declaredPath) => + Resolve(chartPath, declaredPath, AudioExtensions); + + public static string ResolveImage(string chartPath, string declaredPath) => + Resolve(chartPath, declaredPath, new[] { ".png", ".jpg", ".jpeg", ".bmp" }); + + public static string ResolveVideo(string chartPath, string declaredPath) => + Resolve(chartPath, declaredPath, VideoExtensions); + + private static string Resolve(string chartPath, string declaredPath, string[] replacementExtensions) + { + if (string.IsNullOrWhiteSpace(chartPath) || string.IsNullOrWhiteSpace(declaredPath) || + declaredPath.IndexOf('\0') >= 0 || Path.IsPathRooted(declaredPath)) + return null; + + var chartDirectory = Path.GetFullPath(Path.GetDirectoryName(chartPath) ?? string.Empty); + var normalized = declaredPath.Replace('\\', Path.DirectorySeparatorChar).Replace('/', Path.DirectorySeparatorChar); + var candidate = Path.GetFullPath(Path.Combine(chartDirectory, normalized)); + var allowedPrefix = chartDirectory.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar; + if (!candidate.StartsWith(allowedPrefix, StringComparison.Ordinal)) + return null; + + if (File.Exists(candidate)) + return candidate; + + foreach (var extension in replacementExtensions) + { + var replacement = Path.ChangeExtension(candidate, extension); + if (File.Exists(replacement)) + return replacement; + + replacement = Path.ChangeExtension(candidate, extension.ToUpperInvariant()); + if (File.Exists(replacement)) + return replacement; + } + + return null; + } + } +} diff --git a/Assets/ProjectQuint/Runtime/Unity/Library/BmsResourceResolver.cs.meta b/Assets/ProjectQuint/Runtime/Unity/Library/BmsResourceResolver.cs.meta new file mode 100644 index 0000000..a71c6b3 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Library/BmsResourceResolver.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 2cc9cd9d313c742a0a492e9fb0205c08 \ No newline at end of file diff --git a/Assets/ProjectQuint/Runtime/Unity/Library/ChartCatalog.cs b/Assets/ProjectQuint/Runtime/Unity/Library/ChartCatalog.cs new file mode 100644 index 0000000..37abae0 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Library/ChartCatalog.cs @@ -0,0 +1,115 @@ +using System; +using System.Collections.Generic; +using ProjectQuint.Core.Progression; + +namespace ProjectQuint.Library +{ + public enum ExternalDataOrigin + { + Unavailable, + Network, + Cache, + BundledSnapshot, + } + + public sealed class ChartCatalogEntry + { + public string DifficultyLabel { get; set; } = string.Empty; + public string DifficultyValue { get; set; } = string.Empty; + public int DifficultySortIndex { get; set; } = int.MaxValue; + public string TitleOverride { get; set; } = string.Empty; + public string ArtistOverride { get; set; } = string.Empty; + } + + public interface IChartCatalog + { + string ProviderId { get; } + string DisplayName { get; } + ExternalDataOrigin Origin { get; } + int Count { get; } + bool TryGet(string chartHash, out ChartCatalogEntry entry); + } + + public sealed class ChartCatalogAnnotation + { + public string ProviderId { get; set; } = string.Empty; + public string ProviderDisplayName { get; set; } = string.Empty; + public ExternalDataOrigin Origin { get; set; } + public string DifficultyLabel { get; set; } = string.Empty; + public string DifficultyValue { get; set; } = string.Empty; + public double ChartConstant { get; set; } + public int DifficultySortIndex { get; set; } = int.MaxValue; + public string TitleOverride { get; set; } = string.Empty; + public string ArtistOverride { get; set; } = string.Empty; + public int KeyCountOverride { get; set; } + + public bool HasDifficulty => !string.IsNullOrWhiteSpace(DifficultyLabel); + } + + public static class ChartCatalogAnnotator + { + public static int Apply(IReadOnlyList songs, IChartCatalog catalog) + { + var matched = 0; + var preset = catalog == null + ? null + : BmsDifficultyTableIndex.FindPreset(catalog.ProviderId); + var keyCountOverride = preset.HasValue && + preset.Value.DefaultKeyCount >= 4 && + preset.Value.DefaultKeyCount <= 7 + ? preset.Value.DefaultKeyCount + : 0; + foreach (var song in songs ?? Array.Empty()) + { + if (song == null) + continue; + + song.Catalog = new ChartCatalogAnnotation(); + var sha256 = song.Metadata?.Sha256; + var md5 = song.Metadata?.Md5; + if (catalog == null || + !TryGetByPreferredHash(catalog, sha256, md5, out var entry) || entry == null) + continue; + + song.Catalog = new ChartCatalogAnnotation + { + ProviderId = catalog.ProviderId, + ProviderDisplayName = catalog.DisplayName, + Origin = catalog.Origin, + DifficultyLabel = entry.DifficultyLabel ?? string.Empty, + DifficultyValue = entry.DifficultyValue ?? string.Empty, + ChartConstant = ResolveChartConstant(entry), + DifficultySortIndex = entry.DifficultySortIndex, + TitleOverride = entry.TitleOverride ?? string.Empty, + ArtistOverride = entry.ArtistOverride ?? string.Empty, + KeyCountOverride = keyCountOverride, + }; + matched++; + } + return matched; + } + + private static double ResolveChartConstant(ChartCatalogEntry entry) => + RatingRules.TryResolveChartConstant( + entry?.DifficultyValue, + entry?.DifficultyLabel, + out var chartConstant) + ? chartConstant + : 0.0; + + private static bool TryGetByPreferredHash( + IChartCatalog catalog, + string sha256, + string md5, + out ChartCatalogEntry entry) + { + if (!string.IsNullOrWhiteSpace(sha256) && catalog.TryGet(sha256, out entry)) + return true; + if (!string.IsNullOrWhiteSpace(md5) && catalog.TryGet(md5, out entry)) + return true; + + entry = null; + return false; + } + } +} diff --git a/Assets/ProjectQuint/Runtime/Unity/Library/ChartCatalog.cs.meta b/Assets/ProjectQuint/Runtime/Unity/Library/ChartCatalog.cs.meta new file mode 100644 index 0000000..ab41f6f --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Library/ChartCatalog.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d9bc768902ea940a099cd353dfbc4f0f \ No newline at end of file diff --git a/Assets/ProjectQuint/Runtime/Unity/Library/ExternalJsonDataSource.cs b/Assets/ProjectQuint/Runtime/Unity/Library/ExternalJsonDataSource.cs new file mode 100644 index 0000000..eb93dc6 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Library/ExternalJsonDataSource.cs @@ -0,0 +1,225 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.IO; +using System.Text; +using UnityEngine.Networking; + +namespace ProjectQuint.Library +{ + internal sealed class ExternalDataResult + where TData : class + { + public TData Data { get; set; } + public ExternalDataOrigin Origin { get; set; } + public string Status { get; set; } = string.Empty; + public string RawJson { get; set; } = string.Empty; + } + + internal static class ExternalJsonDataSource + { + public static ExternalDataResult LoadLocal( + string displayName, + string cachePath, + string bundledJson, + Func parse) + where TData : class + { + return Select( + displayName, + networkJson: null, + ReadText(cachePath), + ReadText(cachePath + ".bak"), + bundledJson, + parse, + networkFailure: string.Empty); + } + + public static IEnumerator LoadOnline( + string displayName, + string url, + string cachePath, + string bundledJson, + Func parse, + Action> completed) + where TData : class + { + string networkJson = null; + string networkFailure = null; + if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) || + uri.Scheme != Uri.UriSchemeHttps) + { + networkFailure = "HTTPS is required"; + } + else + { + using var request = UnityWebRequest.Get(uri.AbsoluteUri); + request.timeout = 10; + yield return request.SendWebRequest(); + if (request.result == UnityWebRequest.Result.Success) + networkJson = request.downloadHandler.text; + else + networkFailure = request.error; + } + + var selected = Select( + displayName, + networkJson, + ReadText(cachePath), + ReadText(cachePath + ".bak"), + bundledJson, + parse, + networkFailure); + if (selected.Origin == ExternalDataOrigin.Network) + { + try + { + WriteAtomically(cachePath, selected.RawJson); + } + catch (Exception exception) + { + selected.Status += $" (cache write failed: {exception.Message})"; + } + } + completed?.Invoke(selected); + } + + internal static ExternalDataResult Select( + string displayName, + string networkJson, + string cacheJson, + string backupJson, + string bundledJson, + Func parse, + string networkFailure = null) + where TData : class + { + if (parse == null) + throw new ArgumentNullException(nameof(parse)); + + var failures = new List(); + if (TryCandidate(networkJson, parse, out var data, out var failure)) + { + return Result(data, ExternalDataOrigin.Network, $"{displayName}: online", networkJson); + } + if (!string.IsNullOrWhiteSpace(networkJson)) + failures.Add("online data invalid: " + failure); + else if (!string.IsNullOrWhiteSpace(networkFailure)) + failures.Add("online request failed: " + networkFailure); + + if (TryCandidate(cacheJson, parse, out data, out failure)) + return Result(data, ExternalDataOrigin.Cache, $"{displayName}: cached", cacheJson); + if (!string.IsNullOrWhiteSpace(cacheJson)) + failures.Add("cache invalid: " + failure); + + if (TryCandidate(backupJson, parse, out data, out failure)) + return Result(data, ExternalDataOrigin.Cache, $"{displayName}: backup cache", backupJson); + if (!string.IsNullOrWhiteSpace(backupJson)) + failures.Add("backup cache invalid: " + failure); + + if (TryCandidate(bundledJson, parse, out data, out failure)) + return Result(data, ExternalDataOrigin.BundledSnapshot, $"{displayName}: bundled snapshot", bundledJson); + if (!string.IsNullOrWhiteSpace(bundledJson)) + failures.Add("bundled snapshot invalid: " + failure); + + return new ExternalDataResult + { + Origin = ExternalDataOrigin.Unavailable, + Status = failures.Count == 0 + ? $"{displayName}: unavailable" + : $"{displayName}: unavailable ({string.Join("; ", failures)})", + }; + } + + private static ExternalDataResult Result( + TData data, + ExternalDataOrigin origin, + string status, + string rawJson) + where TData : class => new ExternalDataResult + { + Data = data, + Origin = origin, + Status = status, + RawJson = rawJson ?? string.Empty, + }; + + private static bool TryCandidate( + string json, + Func parse, + out TData data, + out string failure) + where TData : class + { + data = null; + failure = "empty"; + if (string.IsNullOrWhiteSpace(json)) + return false; + + try + { + data = parse(json); + if (data == null) + throw new InvalidDataException("parser rejected the document"); + failure = string.Empty; + return true; + } + catch (Exception exception) + { + data = null; + failure = exception.Message; + return false; + } + } + + private static string ReadText(string path) + { + if (string.IsNullOrWhiteSpace(path) || !File.Exists(path)) + return null; + try + { + return File.ReadAllText(path, Encoding.UTF8); + } + catch + { + return null; + } + } + + internal static void WriteAtomically(string path, string json) + { + var directory = Path.GetDirectoryName(path); + if (!string.IsNullOrWhiteSpace(directory)) + Directory.CreateDirectory(directory); + + var temporaryPath = path + ".tmp"; + if (File.Exists(temporaryPath)) + File.Delete(temporaryPath); + try + { + var bytes = new UTF8Encoding(false).GetBytes(json ?? string.Empty); + using (var stream = new FileStream( + temporaryPath, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + 4096, + FileOptions.WriteThrough)) + { + stream.Write(bytes, 0, bytes.Length); + stream.Flush(true); + } + + if (File.Exists(path)) + File.Replace(temporaryPath, path, path + ".bak"); + else + File.Move(temporaryPath, path); + } + finally + { + if (File.Exists(temporaryPath)) + File.Delete(temporaryPath); + } + } + } +} diff --git a/Assets/ProjectQuint/Runtime/Unity/Library/ExternalJsonDataSource.cs.meta b/Assets/ProjectQuint/Runtime/Unity/Library/ExternalJsonDataSource.cs.meta new file mode 100644 index 0000000..114078b --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Library/ExternalJsonDataSource.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3ed5633ca80714cd2882b3ed3cc0b0de \ No newline at end of file diff --git a/Assets/ProjectQuint/Runtime/Unity/Library/JapaneseSearchNormalizer.cs b/Assets/ProjectQuint/Runtime/Unity/Library/JapaneseSearchNormalizer.cs new file mode 100644 index 0000000..bfcb5ab --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Library/JapaneseSearchNormalizer.cs @@ -0,0 +1,238 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Text; + +namespace ProjectQuint.Library +{ + internal static class JapaneseSearchNormalizer + { + public static IReadOnlyList BuildIndexVariants(string value) + { + var variants = new List(3); + var unique = new HashSet(StringComparer.Ordinal); + AddVariant(unique, variants, NormalizeCompact(value)); + + var romanized = JapaneseKanaRomanizer.Romanize(value); + AddVariant(unique, variants, NormalizeCompact(romanized)); + if (ContainsJapaneseKana(value)) + { + var simplified = JapaneseKanaRomanizer.RomanizeWithSimplifiedLongVowels(value); + AddVariant(unique, variants, NormalizeCompact(simplified)); + } + return variants; + } + + public static IReadOnlyList BuildQueryVariants(string value) + { + var variants = new List(3); + var unique = new HashSet(StringComparer.Ordinal); + AddVariant(unique, variants, NormalizeCompact(value)); + + // Latin input is already the user's intended spelling. Applying lossy Japanese + // long-vowel rules here turned queries such as "aaaa" into the one-letter "a". + if (!ContainsJapaneseKana(value)) + return variants; + + var romanized = JapaneseKanaRomanizer.Romanize(value); + AddVariant(unique, variants, NormalizeCompact(romanized)); + var simplified = JapaneseKanaRomanizer.RomanizeWithSimplifiedLongVowels(value); + AddVariant(unique, variants, NormalizeCompact(simplified)); + return variants; + } + + public static string NormalizeCompact(string value) + { + if (string.IsNullOrWhiteSpace(value)) + return string.Empty; + + var normalized = value.Normalize(NormalizationForm.FormKC) + .Normalize(NormalizationForm.FormD); + var builder = new StringBuilder(normalized.Length); + foreach (var character in normalized) + { + var category = CharUnicodeInfo.GetUnicodeCategory(character); + if (category == UnicodeCategory.NonSpacingMark || + category == UnicodeCategory.SpacingCombiningMark || + category == UnicodeCategory.EnclosingMark) + continue; + if (char.IsLetterOrDigit(character)) + builder.Append(char.ToLowerInvariant(character)); + } + return builder.ToString(); + } + + private static bool ContainsJapaneseKana(string value) + { + if (string.IsNullOrEmpty(value)) + return false; + foreach (var character in value) + if ((character >= '\u3040' && character <= '\u309f') || + (character >= '\u30a0' && character <= '\u30ff') || + character == '\u30fc') + return true; + return false; + } + + private static void AddVariant(ISet unique, ICollection variants, string value) + { + if (!string.IsNullOrEmpty(value) && unique.Add(value)) + variants.Add(value); + } + + } + + internal static class JapaneseKanaRomanizer + { + private static readonly IReadOnlyDictionary Digraphs = + new Dictionary(StringComparer.Ordinal) + { + ["きゃ"] = "kya", ["きゅ"] = "kyu", ["きょ"] = "kyo", + ["しゃ"] = "sha", ["しゅ"] = "shu", ["しょ"] = "sho", + ["ちゃ"] = "cha", ["ちゅ"] = "chu", ["ちょ"] = "cho", + ["にゃ"] = "nya", ["にゅ"] = "nyu", ["にょ"] = "nyo", + ["ひゃ"] = "hya", ["ひゅ"] = "hyu", ["ひょ"] = "hyo", + ["みゃ"] = "mya", ["みゅ"] = "myu", ["みょ"] = "myo", + ["りゃ"] = "rya", ["りゅ"] = "ryu", ["りょ"] = "ryo", + ["ぎゃ"] = "gya", ["ぎゅ"] = "gyu", ["ぎょ"] = "gyo", + ["じゃ"] = "ja", ["じゅ"] = "ju", ["じょ"] = "jo", + ["びゃ"] = "bya", ["びゅ"] = "byu", ["びょ"] = "byo", + ["ぴゃ"] = "pya", ["ぴゅ"] = "pyu", ["ぴょ"] = "pyo", + ["いぇ"] = "ye", ["うぃ"] = "wi", ["うぇ"] = "we", ["うぉ"] = "wo", + ["ゔぁ"] = "va", ["ゔぃ"] = "vi", ["ゔぇ"] = "ve", ["ゔぉ"] = "vo", + ["ゔゅ"] = "vyu", ["しぇ"] = "she", ["じぇ"] = "je", ["ちぇ"] = "che", + ["てぃ"] = "ti", ["てゅ"] = "tyu", ["でぃ"] = "di", ["でゅ"] = "dyu", + ["とぅ"] = "tu", ["どぅ"] = "du", ["すぃ"] = "si", ["ずぃ"] = "zi", + ["つぁ"] = "tsa", ["つぃ"] = "tsi", ["つぇ"] = "tse", ["つぉ"] = "tso", + ["ふぁ"] = "fa", ["ふぃ"] = "fi", ["ふぇ"] = "fe", ["ふぉ"] = "fo", + ["ふゅ"] = "fyu", ["くぁ"] = "kwa", ["くぃ"] = "kwi", + ["くぇ"] = "kwe", ["くぉ"] = "kwo", ["ぐぁ"] = "gwa", + ["ぐぃ"] = "gwi", ["ぐぇ"] = "gwe", ["ぐぉ"] = "gwo", + }; + + private static readonly IReadOnlyDictionary Monographs = + new Dictionary + { + ['あ'] = "a", ['い'] = "i", ['う'] = "u", ['え'] = "e", ['お'] = "o", + ['か'] = "ka", ['き'] = "ki", ['く'] = "ku", ['け'] = "ke", ['こ'] = "ko", + ['さ'] = "sa", ['し'] = "shi", ['す'] = "su", ['せ'] = "se", ['そ'] = "so", + ['た'] = "ta", ['ち'] = "chi", ['つ'] = "tsu", ['て'] = "te", ['と'] = "to", + ['な'] = "na", ['に'] = "ni", ['ぬ'] = "nu", ['ね'] = "ne", ['の'] = "no", + ['は'] = "ha", ['ひ'] = "hi", ['ふ'] = "fu", ['へ'] = "he", ['ほ'] = "ho", + ['ま'] = "ma", ['み'] = "mi", ['む'] = "mu", ['め'] = "me", ['も'] = "mo", + ['や'] = "ya", ['ゆ'] = "yu", ['よ'] = "yo", + ['ら'] = "ra", ['り'] = "ri", ['る'] = "ru", ['れ'] = "re", ['ろ'] = "ro", + ['わ'] = "wa", ['ゐ'] = "wi", ['ゑ'] = "we", ['を'] = "wo", ['ん'] = "n", + ['が'] = "ga", ['ぎ'] = "gi", ['ぐ'] = "gu", ['げ'] = "ge", ['ご'] = "go", + ['ざ'] = "za", ['じ'] = "ji", ['ず'] = "zu", ['ぜ'] = "ze", ['ぞ'] = "zo", + ['だ'] = "da", ['ぢ'] = "ji", ['づ'] = "zu", ['で'] = "de", ['ど'] = "do", + ['ば'] = "ba", ['び'] = "bi", ['ぶ'] = "bu", ['べ'] = "be", ['ぼ'] = "bo", + ['ぱ'] = "pa", ['ぴ'] = "pi", ['ぷ'] = "pu", ['ぺ'] = "pe", ['ぽ'] = "po", + ['ゔ'] = "vu", ['ぁ'] = "a", ['ぃ'] = "i", ['ぅ'] = "u", ['ぇ'] = "e", + ['ぉ'] = "o", ['ゃ'] = "ya", ['ゅ'] = "yu", ['ょ'] = "yo", + ['ゎ'] = "wa", ['ゕ'] = "ka", ['ゖ'] = "ke", + }; + + public static string Romanize(string value) => Romanize(value, simplifyLongVowels: false); + + public static string RomanizeWithSimplifiedLongVowels(string value) => + Romanize(value, simplifyLongVowels: true); + + private static string Romanize(string value, bool simplifyLongVowels) + { + if (string.IsNullOrEmpty(value)) + return string.Empty; + + var source = value.Normalize(NormalizationForm.FormKC); + var builder = new StringBuilder(source.Length * 2); + var geminate = false; + var previousKanaVowel = '\0'; + for (var index = 0; index < source.Length; index++) + { + var character = ToHiragana(source[index]); + if (character == 'っ') + { + geminate = true; + continue; + } + if (character == 'ー') + { + if (!simplifyLongVowels) + AppendLastVowel(builder); + continue; + } + if (simplifyLongVowels && + ((character == 'う' && previousKanaVowel == 'o') || + (character == 'い' && previousKanaVowel == 'e'))) + continue; + + string romanized = null; + if (index + 1 < source.Length) + { + var pair = string.Concat(character, ToHiragana(source[index + 1])); + if (Digraphs.TryGetValue(pair, out romanized)) + index++; + } + if (romanized == null) + Monographs.TryGetValue(character, out romanized); + + if (romanized == null) + { + if (geminate) + builder.Append("tsu"); + geminate = false; + builder.Append(source[index]); + previousKanaVowel = '\0'; + continue; + } + + if (geminate && romanized.Length > 0 && IsAsciiConsonant(romanized[0])) + builder.Append(romanized[0]); + geminate = false; + builder.Append(romanized); + previousKanaVowel = LastVowel(romanized); + } + if (geminate) + builder.Append("tsu"); + return builder.ToString(); + } + + private static char ToHiragana(char character) => + character >= '\u30A1' && character <= '\u30F6' + ? (char)(character - 0x60) + : character; + + private static void AppendLastVowel(StringBuilder builder) + { + for (var index = builder.Length - 1; index >= 0; index--) + { + var character = builder[index]; + if (character == 'a' || character == 'e' || character == 'i' || + character == 'o' || character == 'u') + { + builder.Append(character); + return; + } + if (!char.IsLetter(character)) + break; + } + } + + private static char LastVowel(string value) + { + for (var index = value.Length - 1; index >= 0; index--) + { + var character = value[index]; + if (character == 'a' || character == 'e' || character == 'i' || + character == 'o' || character == 'u') + return character; + } + return '\0'; + } + + private static bool IsAsciiConsonant(char character) => + character >= 'a' && character <= 'z' && + character != 'a' && character != 'e' && character != 'i' && + character != 'o' && character != 'u'; + } +} diff --git a/Assets/ProjectQuint/Runtime/Unity/Library/JapaneseSearchNormalizer.cs.meta b/Assets/ProjectQuint/Runtime/Unity/Library/JapaneseSearchNormalizer.cs.meta new file mode 100644 index 0000000..9741cb5 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Library/JapaneseSearchNormalizer.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 20434ba50ac2f46b5b41bcfe956f9a46 \ No newline at end of file diff --git a/Assets/ProjectQuint/Runtime/Unity/Library/SongClearLampStore.cs b/Assets/ProjectQuint/Runtime/Unity/Library/SongClearLampStore.cs new file mode 100644 index 0000000..2e3c5f9 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Library/SongClearLampStore.cs @@ -0,0 +1,41 @@ +using System; +using ProjectQuint.Core.Gauge; +using ProjectQuint.Persistence; + +namespace ProjectQuint.Library +{ + internal static class SongClearLampStore + { + private const string PreferencePrefix = "ProjectQuint.ClearLampV3."; + + public static SongClearLamp Load(SongLibraryEntry entry) + => SongRecordRepository.Shared.LoadClearLamp(entry); + + public static void Record(SongLibraryEntry entry, SongClearLamp lamp) + { + SongRecordRepository.Shared.RecordClearLamp(entry, lamp); + } + + internal static SongClearLamp Resolve( + GaugeMode gaugeType, + bool cleared, + bool autoScratch = false) + { + if (!cleared) + return gaugeType == GaugeMode.Normal + ? SongClearLamp.NormalFailed + : SongClearLamp.Failed; + if (autoScratch) + return SongClearLamp.AutoScratchClear; + return gaugeType switch + { + GaugeMode.ExHard => SongClearLamp.ExHardClear, + GaugeMode.Hard => SongClearLamp.HardClear, + _ => SongClearLamp.NormalClear, + }; + } + + internal static string BuildPreferenceKey(SongLibraryEntry entry) + => PreferencePrefix + ChartIdentityResolver.BuildLegacyPreferenceSuffix(entry); + } +} diff --git a/Assets/ProjectQuint/Runtime/Unity/Library/SongClearLampStore.cs.meta b/Assets/ProjectQuint/Runtime/Unity/Library/SongClearLampStore.cs.meta new file mode 100644 index 0000000..794661d --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Library/SongClearLampStore.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: aa52098a446d4b5d98d93f2d19d97a26 diff --git a/Assets/ProjectQuint/Runtime/Unity/Library/SongLibraryScanner.cs b/Assets/ProjectQuint/Runtime/Unity/Library/SongLibraryScanner.cs new file mode 100644 index 0000000..57b2dd1 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Library/SongLibraryScanner.cs @@ -0,0 +1,765 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Threading; +using ProjectQuint.Core.Analysis; +using ProjectQuint.Core.Bms; +using ProjectQuint.Core.Identity; +using UnityEngine; + +namespace ProjectQuint.Library +{ + public enum SongClearLamp + { + None = 0, + NormalClear = 1, + HardClear = 2, + Failed = 3, + NormalFailed = 4, + ExHardClear = 5, + AutoScratchClear = 6, + } + + public sealed class SongLibraryEntry + { + public string ChartPath { get; set; } = string.Empty; + public string PackId { get; set; } = string.Empty; + public string ThumbnailFile { get; set; } = string.Empty; + public BmsMetadata Metadata { get; set; } = new BmsMetadata(); + public ChartCatalogAnnotation Catalog { get; set; } = new ChartCatalogAnnotation(); + public SongClearLamp ClearLamp { get; set; } + public SongPlayRecord BestRecord { get; set; } = new SongPlayRecord(); + public bool IsFavorite { get; set; } + public double DurationSeconds { get; set; } + public bool DurationIsEstimated { get; set; } + public ChartFeatureVector Features { get; set; } + public string FeatureAnalysisError { get; set; } = string.Empty; + public string SearchIndex { get; private set; } = string.Empty; + internal SongSearchDocument SearchDocument { get; private set; } = SongSearchDocument.Empty; + + public string DisplayTitle => !string.IsNullOrWhiteSpace(Catalog?.TitleOverride) + ? Catalog.TitleOverride + : Metadata?.Title ?? string.Empty; + public string DisplayArtist => !string.IsNullOrWhiteSpace(Catalog?.ArtistOverride) + ? Catalog.ArtistOverride + : Metadata?.Artist ?? string.Empty; + public string DifficultyLabel => Catalog?.HasDifficulty == true + ? Catalog.DifficultyLabel + : Metadata?.PlayLevel > 0 ? $"BMS LEVEL {Metadata.PlayLevel}" : string.Empty; + public string CompactDifficultyLabel => Catalog?.HasDifficulty == true + ? !string.IsNullOrWhiteSpace(Catalog.DifficultyValue) + ? Catalog.DifficultyValue + : Catalog.DifficultyLabel + : Metadata?.PlayLevel > 0 ? Metadata.PlayLevel.ToString() : string.Empty; + public int DifficultySortIndex => Catalog?.HasDifficulty == true + ? Catalog.DifficultySortIndex + : Metadata?.PlayLevel ?? int.MaxValue; + public bool HasCatalogDifficulty => Catalog?.HasDifficulty == true; + public string DifficultySourceLabel => HasCatalogDifficulty + ? Catalog.ProviderDisplayName + : Metadata?.PlayLevel > 0 ? "BMS PLAYLEVEL" : "UNRANKED"; + public int EffectiveKeyCount => + Catalog?.KeyCountOverride >= 4 && Catalog.KeyCountOverride <= 7 + ? Catalog.KeyCountOverride + : Metadata?.KeyCount ?? 5; + + internal void RebuildSearchIndex(SearchAliasIndex aliases = null) + { + SearchDocument = SongSearchIndexer.Build(this, aliases); + SearchIndex = SearchDocument.CombinedIndex; + } + } + + public sealed class SongLibraryScanner + { + private const int CacheVersion = 6; + private const int ThumbnailResolverVersion = 2; + private static readonly string[] ChartExtensions = { ".bms", ".bme", ".bml" }; + private static readonly HashSet ThumbnailImageExtensions = + new HashSet( + new[] { ".png", ".jpg", ".jpeg", ".bmp" }, + StringComparer.OrdinalIgnoreCase); + private static readonly string[] RejectedFallbackNameParts = + { + "poor", + "miss", + "layer", + "sprite", + "parts", + "font", + "judge", + "combo", + "lane", + "note", + }; + private int _completed; + private int _failed; + private int _total; + private int _cacheHits; + private int _cacheMisses; + private readonly string _cachePath; + private readonly string _legacyCachePath; + private readonly string _packId; + private readonly int _forcedKeyCount; + private int _loadedCacheVersion; + + public SongLibraryScanner( + string cachePath = null, + string legacyCachePath = null, + string packId = null, + int forcedKeyCount = 0) + { + _cachePath = cachePath; + _legacyCachePath = legacyCachePath; + _packId = packId ?? string.Empty; + _forcedKeyCount = forcedKeyCount; + } + + public int Completed => Volatile.Read(ref _completed); + public int Failed => Volatile.Read(ref _failed); + public int Total => Volatile.Read(ref _total); + public int CacheHits => Volatile.Read(ref _cacheHits); + public int CacheMisses => Volatile.Read(ref _cacheMisses); + public string CacheStatus { get; private set; } = string.Empty; + + internal static IReadOnlyList LoadCachedThumbnailEntries( + string cachePath, + string libraryRoot, + string packId, + IReadOnlyCollection requestedChartIds) + { + if (string.IsNullOrWhiteSpace(cachePath) || + string.IsNullOrWhiteSpace(libraryRoot) || + requestedChartIds == null || + requestedChartIds.Count == 0 || + !File.Exists(cachePath)) + return Array.Empty(); + + try + { + var document = JsonUtility.FromJson( + File.ReadAllText(cachePath)); + if (document == null || + document.Version < 1 || + document.Version > CacheVersion || + !string.Equals( + Path.GetFullPath(document.LibraryRoot ?? string.Empty), + Path.GetFullPath(libraryRoot), + StringComparison.OrdinalIgnoreCase)) + return Array.Empty(); + + var result = new List( + Math.Min(requestedChartIds.Count, document.Entries?.Length ?? 0)); + foreach (var cached in document.Entries ?? Array.Empty()) + { + if (cached == null || string.IsNullOrWhiteSpace(cached.ChartPath)) + continue; + var chartId = !string.IsNullOrWhiteSpace(cached.Md5) + ? ChartId.FromMd5(cached.Md5).Value + : ChartId.FromSha256(cached.Sha256).Value; + if (string.IsNullOrEmpty(chartId) || !requestedChartIds.Contains(chartId)) + continue; + var chartPath = Path.GetFullPath(cached.ChartPath); + result.Add(new SongLibraryEntry + { + ChartPath = chartPath, + PackId = packId ?? string.Empty, + ThumbnailFile = cached.ThumbnailFile ?? string.Empty, + Metadata = cached.ToMetadata(chartPath), + }); + } + return result; + } + catch (Exception exception) + { + Debug.LogWarning( + $"[ProjectQuint Library] Cached profile thumbnail index was ignored: {exception.Message}"); + return Array.Empty(); + } + } + + public List Scan(string libraryRoot, CancellationToken cancellationToken) + { + if (!Directory.Exists(libraryRoot)) + throw new DirectoryNotFoundException($"BMS library not found: {libraryRoot}"); + + Volatile.Write(ref _completed, 0); + Volatile.Write(ref _failed, 0); + Volatile.Write(ref _cacheHits, 0); + Volatile.Write(ref _cacheMisses, 0); + CacheStatus = string.Empty; + + var normalizedRoot = Path.GetFullPath(libraryRoot); + var cachedEntries = LoadCache(normalizedRoot); + + var paths = new List(); + foreach (var path in Directory.EnumerateFiles(normalizedRoot, "*", SearchOption.AllDirectories)) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!ChartExtensions.Contains(Path.GetExtension(path), StringComparer.OrdinalIgnoreCase)) + continue; + paths.Add(path); + // Publish discovery progress instead of displaying an apparently frozen 0/0 + // while a large pack tree is still being enumerated. + Volatile.Write(ref _total, paths.Count); + } + Volatile.Write(ref _total, paths.Count); + + var result = new List(paths.Count); + var refreshedCache = new List(paths.Count); + + foreach (var path in paths) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + var fullPath = Path.GetFullPath(path); + var file = new FileInfo(fullPath); + BmsMetadata rawMetadata; + ChartFeatureVector features; + string featureAnalysisError; + string thumbnailFile; + double durationSeconds; + bool durationIsEstimated; + var hasMatchingCache = cachedEntries.TryGetValue(fullPath, out var cached) && + cached.FileLength == file.Length && + cached.LastWriteUtcTicks == file.LastWriteTimeUtc.Ticks && + !string.IsNullOrWhiteSpace(cached.Md5) && + !string.IsNullOrWhiteSpace(cached.Sha256) && + cached.FeatureAnalyzerVersion == ChartFeatureVector.CurrentAnalyzerVersion; + if (hasMatchingCache && _loadedCacheVersion == CacheVersion) + { + rawMetadata = cached.ToMetadata(fullPath); + thumbnailFile = cached.ThumbnailFile ?? string.Empty; + if (cached.ThumbnailResolverVersion < ThumbnailResolverVersion && + string.IsNullOrWhiteSpace(thumbnailFile)) + { + try + { + var thumbnailDocument = BmsParser.ParseFile( + fullPath, + forcedKeyCount: _forcedKeyCount); + var thumbnailChart = BmsTimelineCompiler.Compile(thumbnailDocument); + thumbnailFile = SelectThumbnailFile( + fullPath, + rawMetadata, + thumbnailChart); + } + catch (Exception thumbnailException) + { + Debug.LogWarning( + $"[ProjectQuint Library] Thumbnail fallback scan failed for " + + $"'{fullPath}': {thumbnailException.Message}"); + } + } + BmsParser.EnsureSupportedLongNoteNotation(rawMetadata.LnType); + features = cached.Features; + featureAnalysisError = cached.FeatureAnalysisError ?? string.Empty; + durationSeconds = cached.DurationSeconds; + durationIsEstimated = false; + if (durationSeconds <= 0 && features?.ActiveDurationSeconds > 0) + { + durationSeconds = features.ActiveDurationSeconds; + durationIsEstimated = true; + } + if (features != null && + features.CalibrationVersion != ChartFeatureVector.CurrentCalibrationVersion) + { + ChartFeatureCalibration.Apply(features); + } + Interlocked.Increment(ref _cacheHits); + } + else + { + var document = BmsParser.ParseFile(fullPath, forcedKeyCount: _forcedKeyCount); + rawMetadata = document.Metadata; + var hashes = ComputeHashes(fullPath); + rawMetadata.Md5 = hashes.Md5; + rawMetadata.Sha256 = hashes.Sha256; + thumbnailFile = SelectThumbnailFile( + fullPath, + rawMetadata, + chart: null); + features = null; + featureAnalysisError = string.Empty; + durationSeconds = 0; + durationIsEstimated = false; + try + { + var compiled = BmsTimelineCompiler.Compile(document); + if (string.IsNullOrWhiteSpace(thumbnailFile)) + { + thumbnailFile = SelectThumbnailFile( + fullPath, + rawMetadata, + compiled); + } + var playable = BmsPlayableChartConverter.Convert(compiled).Chart; + durationSeconds = playable.Duration; + features = ChartFeatureAnalyzer.Analyze(playable); + } + catch (Exception featureException) + { + featureAnalysisError = featureException.Message; + } + Interlocked.Increment(ref _cacheMisses); + } + + refreshedCache.Add(SongLibraryCacheEntry.From( + rawMetadata, + file, + thumbnailFile, + features, + featureAnalysisError, + durationIsEstimated ? 0.0 : durationSeconds)); + result.Add(new SongLibraryEntry + { + ChartPath = path, + PackId = _packId, + ThumbnailFile = thumbnailFile, + Metadata = CloneMetadata(rawMetadata), + DurationSeconds = durationSeconds, + DurationIsEstimated = durationIsEstimated, + Features = features, + FeatureAnalysisError = featureAnalysisError, + }); + } + catch + { + Interlocked.Increment(ref _failed); + } + finally + { + Interlocked.Increment(ref _completed); + } + } + + cancellationToken.ThrowIfCancellationRequested(); + SaveCache(normalizedRoot, refreshedCache); + + var ordered = result + .OrderBy(entry => entry.Metadata.PlayLevel <= 0 ? 1 : 0) + .ThenBy(entry => entry.Metadata.PlayLevel) + .ThenBy(entry => entry.DisplayTitle, StringComparer.OrdinalIgnoreCase) + .ToList(); + SongSearchIndexer.Rebuild(ordered); + return ordered; + } + + private Dictionary LoadCache(string libraryRoot) + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + _loadedCacheVersion = 0; + var sourcePath = !string.IsNullOrWhiteSpace(_cachePath) && File.Exists(_cachePath) + ? _cachePath + : !string.IsNullOrWhiteSpace(_legacyCachePath) && File.Exists(_legacyCachePath) + ? _legacyCachePath + : string.Empty; + if (string.IsNullOrWhiteSpace(sourcePath)) + { + CacheStatus = "cache not found"; + return result; + } + + try + { + var document = JsonUtility.FromJson(File.ReadAllText(sourcePath)); + if (document == null || document.Version < 1 || document.Version > CacheVersion || + !string.Equals(Path.GetFullPath(document.LibraryRoot ?? string.Empty), libraryRoot, + StringComparison.OrdinalIgnoreCase) || + document.ForcedKeyCount != _forcedKeyCount) + { + CacheStatus = "cache version, library root or key mode changed"; + return result; + } + + foreach (var entry in document.Entries ?? Array.Empty()) + { + if (entry == null || string.IsNullOrWhiteSpace(entry.ChartPath)) + continue; + result[Path.GetFullPath(entry.ChartPath)] = entry; + } + _loadedCacheVersion = document.Version; + CacheStatus = document.Version == CacheVersion + ? $"loaded {result.Count:N0} cached charts" + : $"migrating {result.Count:N0} legacy chart hashes"; + } + catch (Exception exception) + { + CacheStatus = $"cache ignored: {exception.Message}"; + } + + return result; + } + + private void SaveCache(string libraryRoot, List entries) + { + if (string.IsNullOrWhiteSpace(_cachePath)) + return; + + try + { + var directory = Path.GetDirectoryName(_cachePath); + if (!string.IsNullOrWhiteSpace(directory)) + Directory.CreateDirectory(directory); + + var document = new SongLibraryCacheDocument + { + Version = CacheVersion, + LibraryRoot = libraryRoot, + ForcedKeyCount = _forcedKeyCount, + Entries = entries.ToArray(), + }; + var temporaryPath = _cachePath + ".tmp"; + File.WriteAllText(temporaryPath, JsonUtility.ToJson(document)); + if (File.Exists(_cachePath)) + File.Delete(_cachePath); + File.Move(temporaryPath, _cachePath); + CacheStatus = $"{CacheHits:N0} cached, {CacheMisses:N0} refreshed"; + } + catch (Exception exception) + { + CacheStatus = $"cache write failed: {exception.Message}"; + } + } + + private static BmsMetadata CloneMetadata(BmsMetadata source) => new BmsMetadata + { + ChartPath = source.ChartPath, + Title = source.Title, + Subtitle = source.Subtitle, + Artist = source.Artist, + SubArtist = source.SubArtist, + Genre = source.Genre, + StageFile = source.StageFile, + BackBmp = source.BackBmp, + Md5 = source.Md5, + Sha256 = source.Sha256, + InitialBpm = source.InitialBpm, + MinimumBpm = source.MinimumBpm, + MaximumBpm = source.MaximumBpm, + Total = source.Total, + PlayLevel = source.PlayLevel, + Difficulty = source.Difficulty, + Rank = source.Rank, + LnType = source.LnType, + LnObj = source.LnObj, + KeyCount = source.KeyCount, + HasScratch = source.HasScratch, + UsedKeyChannelMask = source.UsedKeyChannelMask, + }; + + internal static string SelectThumbnailFile( + string chartPath, + BmsMetadata metadata, + BmsChart chart) + { + var explicitCandidates = new[] + { + metadata?.StageFile, + metadata?.BackBmp, + }; + foreach (var declared in explicitCandidates) + { + if (IsResolvableThumbnailImage(chartPath, declared)) + return declared; + } + + if (chart?.BgaEvents == null || chart.BmpDefinitions == null) + return string.Empty; + + foreach (var bgaEvent in chart.BgaEvents + .Where(item => item.Layer == BmsBgaLayer.Base) + .OrderBy(item => item.Time)) + { + if (!chart.BmpDefinitions.TryGetValue( + bgaEvent.ResourceId ?? string.Empty, + out var declared) || + !IsResolvableThumbnailImage(chartPath, declared)) + continue; + return declared; + } + + foreach (var bgaEvent in chart.BgaEvents + .Where(item => item.Layer == BmsBgaLayer.Layer) + .OrderBy(item => item.Time)) + { + if (!chart.BmpDefinitions.TryGetValue( + bgaEvent.ResourceId ?? string.Empty, + out var declared) || + !IsSafeFallbackImage(chartPath, declared)) + continue; + return declared; + } + + var poorOnlyResources = new HashSet( + chart.BgaEvents + .Where(item => item.Layer == BmsBgaLayer.Poor) + .Select(item => item.ResourceId ?? string.Empty), + StringComparer.Ordinal); + poorOnlyResources.ExceptWith( + chart.BgaEvents + .Where(item => item.Layer != BmsBgaLayer.Poor) + .Select(item => item.ResourceId ?? string.Empty)); + + var declaredFallback = SelectBestFallbackCandidate( + chart.BmpDefinitions + .Where(item => !poorOnlyResources.Contains(item.Key)) + .Select(item => item.Value) + .Where(item => IsSafeFallbackImage(chartPath, item))); + if (!string.IsNullOrWhiteSpace(declaredFallback)) + return declaredFallback; + + var poorOnlyFiles = new HashSet( + chart.BmpDefinitions + .Where(item => poorOnlyResources.Contains(item.Key)) + .Select(item => Path.GetFileName(item.Value ?? string.Empty)), + StringComparer.OrdinalIgnoreCase); + return SelectDirectoryFallback(chartPath, poorOnlyFiles); + } + + private static bool IsResolvableThumbnailImage( + string chartPath, + string declaredPath) + { + var resolved = BmsResourceResolver.ResolveImage(chartPath, declaredPath); + return !string.IsNullOrEmpty(resolved) && + ThumbnailImageExtensions.Contains(Path.GetExtension(resolved)); + } + + private static bool IsSafeFallbackImage(string chartPath, string declaredPath) => + IsResolvableThumbnailImage(chartPath, declaredPath) && + !HasRejectedFallbackName(Path.GetFileName(declaredPath)); + + private static string SelectDirectoryFallback( + string chartPath, + IReadOnlyCollection excludedFileNames) + { + try + { + var directory = Path.GetDirectoryName(chartPath); + if (string.IsNullOrWhiteSpace(directory) || !Directory.Exists(directory)) + return string.Empty; + + var candidates = Directory + .EnumerateFiles(directory, "*", SearchOption.TopDirectoryOnly) + .Where(path => ThumbnailImageExtensions.Contains(Path.GetExtension(path))) + .Where(path => !HasRejectedFallbackName(Path.GetFileName(path))) + .Where(path => excludedFileNames == null || + !excludedFileNames.Contains( + Path.GetFileName(path), + StringComparer.OrdinalIgnoreCase)) + .Select(Path.GetFileName); + return SelectBestFallbackCandidate(candidates); + } + catch (Exception) + { + return string.Empty; + } + } + + private static string SelectBestFallbackCandidate(IEnumerable candidates) + { + var distinct = candidates + .Where(item => !string.IsNullOrWhiteSpace(item)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(item => item, StringComparer.OrdinalIgnoreCase) + .ToArray(); + if (distinct.Length == 1) + return distinct[0]; + + var ranked = distinct + .Select(item => new + { + Path = item, + Rank = SemanticFallbackRank(Path.GetFileNameWithoutExtension(item)), + }) + .Where(item => item.Rank < int.MaxValue) + .OrderBy(item => item.Rank) + .ThenBy(item => item.Path, StringComparer.OrdinalIgnoreCase) + .ToArray(); + if (ranked.Length == 0) + return string.Empty; + + var bestRank = ranked[0].Rank; + return ranked.Count(item => item.Rank == bestRank) == 1 + ? ranked[0].Path + : string.Empty; + } + + private static int SemanticFallbackRank(string fileStem) + { + var normalized = (fileStem ?? string.Empty) + .Replace("-", " ") + .Replace("_", " ") + .Replace(".", " ") + .ToLowerInvariant(); + var tokens = normalized.Split( + new[] { ' ' }, + StringSplitOptions.RemoveEmptyEntries); + if (tokens.Any(item => item == "jacket" || item == "cover")) + return 0; + if (tokens.Any(item => item == "stage" || item == "stagefile")) + return 1; + if (tokens.Any(item => item == "title")) + return 2; + if (tokens.Any(item => + item == "background" || item == "back" || item == "bgi" || item == "bg")) + return 3; + if (tokens.Any(item => + item == "preview" || item == "thumb" || item == "thumbnail")) + return 4; + if (tokens.Any(item => item == "banner")) + return 5; + return int.MaxValue; + } + + private static bool HasRejectedFallbackName(string fileName) + { + var normalized = Path.GetFileNameWithoutExtension(fileName ?? string.Empty) + .ToLowerInvariant(); + return RejectedFallbackNameParts.Any(normalized.Contains); + } + + private static ChartHashes ComputeHashes(string path) + { + using var stream = File.OpenRead(path); + using var md5 = MD5.Create(); + var md5Value = md5.ComputeHash(stream); + stream.Position = 0; + using var sha256 = SHA256.Create(); + var shaValue = sha256.ComputeHash(stream); + return new ChartHashes(ToHex(md5Value), ToHex(shaValue)); + } + + private static string ToHex(byte[] bytes) => + BitConverter.ToString(bytes).Replace("-", string.Empty).ToLowerInvariant(); + + private readonly struct ChartHashes + { + public ChartHashes(string md5, string sha256) + { + Md5 = md5; + Sha256 = sha256; + } + + public string Md5 { get; } + public string Sha256 { get; } + } + + [Serializable] + private sealed class SongLibraryCacheDocument + { + public int Version; + public string LibraryRoot; + public int ForcedKeyCount; + public SongLibraryCacheEntry[] Entries; + } + + [Serializable] + private sealed class SongLibraryCacheEntry + { + public string ChartPath; + public long FileLength; + public long LastWriteUtcTicks; + public string Title; + public string Subtitle; + public string Artist; + public string SubArtist; + public string Genre; + public string StageFile; + public string BackBmp; + public string ThumbnailFile; + public int ThumbnailResolverVersion; + public string Md5; + public string Sha256; + public double InitialBpm; + public double MinimumBpm; + public double MaximumBpm; + public double Total; + public int PlayLevel; + public int Difficulty; + public int Rank; + public int LnType; + public string LnObj; + public int KeyCount; + public bool HasScratch; + public int UsedKeyChannelMask; + public double DurationSeconds; + public int FeatureAnalyzerVersion; + public int FeatureCalibrationVersion; + public ChartFeatureVector Features; + public string FeatureAnalysisError; + + public static SongLibraryCacheEntry From( + BmsMetadata metadata, + FileInfo file, + string thumbnailFile, + ChartFeatureVector features, + string featureAnalysisError, + double durationSeconds) => + new SongLibraryCacheEntry + { + ChartPath = file.FullName, + FileLength = file.Length, + LastWriteUtcTicks = file.LastWriteTimeUtc.Ticks, + Title = metadata.Title, + Subtitle = metadata.Subtitle, + Artist = metadata.Artist, + SubArtist = metadata.SubArtist, + Genre = metadata.Genre, + StageFile = metadata.StageFile, + BackBmp = metadata.BackBmp, + ThumbnailFile = thumbnailFile ?? string.Empty, + ThumbnailResolverVersion = SongLibraryScanner.ThumbnailResolverVersion, + Md5 = metadata.Md5, + Sha256 = metadata.Sha256, + InitialBpm = metadata.InitialBpm, + MinimumBpm = metadata.MinimumBpm, + MaximumBpm = metadata.MaximumBpm, + Total = metadata.Total, + PlayLevel = metadata.PlayLevel, + Difficulty = metadata.Difficulty, + Rank = metadata.Rank, + LnType = metadata.LnType, + LnObj = metadata.LnObj, + KeyCount = metadata.KeyCount, + HasScratch = metadata.HasScratch, + UsedKeyChannelMask = metadata.UsedKeyChannelMask, + DurationSeconds = Math.Max(0.0, durationSeconds), + FeatureAnalyzerVersion = ChartFeatureVector.CurrentAnalyzerVersion, + FeatureCalibrationVersion = ChartFeatureVector.CurrentCalibrationVersion, + Features = features, + FeatureAnalysisError = featureAnalysisError ?? string.Empty, + }; + + public BmsMetadata ToMetadata(string chartPath) => new BmsMetadata + { + ChartPath = chartPath, + Title = Title, + Subtitle = Subtitle, + Artist = Artist, + SubArtist = SubArtist, + Genre = Genre, + StageFile = StageFile, + BackBmp = BackBmp, + Md5 = Md5, + Sha256 = Sha256, + InitialBpm = InitialBpm, + MinimumBpm = MinimumBpm, + MaximumBpm = MaximumBpm, + Total = Total, + PlayLevel = PlayLevel, + Difficulty = Difficulty, + Rank = Rank, + LnType = LnType, + LnObj = LnObj, + KeyCount = KeyCount <= 0 ? 5 : KeyCount, + HasScratch = HasScratch, + UsedKeyChannelMask = UsedKeyChannelMask, + }; + } + } +} diff --git a/Assets/ProjectQuint/Runtime/Unity/Library/SongLibraryScanner.cs.meta b/Assets/ProjectQuint/Runtime/Unity/Library/SongLibraryScanner.cs.meta new file mode 100644 index 0000000..6a00ea5 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Library/SongLibraryScanner.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 1a10454111d804d1b8797ae34b91007e \ No newline at end of file diff --git a/Assets/ProjectQuint/Runtime/Unity/Library/SongLibraryView.cs b/Assets/ProjectQuint/Runtime/Unity/Library/SongLibraryView.cs new file mode 100644 index 0000000..913f05a --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Library/SongLibraryView.cs @@ -0,0 +1,167 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace ProjectQuint.Library +{ + public enum SongSortMode + { + DifficultyAscending, + DifficultyDescending, + Title, + FavoritesFirst, + } + + public static class SongLibraryView + { + public const string RecommendedFilter = "LEVEL 14–15"; + public const string AllLevelsFilter = "ALL CHARTS"; + public const string UnrankedFilter = "UNRANKED"; + public const string FavoritesFilter = "FAVORITES"; + + public static List BuildLevelFilters(IReadOnlyList songs) + { + var filters = new List { AllLevelsFilter }; + if (songs == null) + return filters; + + filters.AddRange(songs + .Where(song => !string.IsNullOrWhiteSpace(song.CompactDifficultyLabel)) + .GroupBy(song => song.CompactDifficultyLabel, StringComparer.OrdinalIgnoreCase) + .Select(group => new + { + Level = group.Key, + Group = group.Min(DifficultyGroup), + SortIndex = group.Min(song => song.DifficultySortIndex), + }) + .OrderBy(item => item.Group) + .ThenBy(item => item.SortIndex) + .ThenBy(item => item.Level, StringComparer.OrdinalIgnoreCase) + .Select(item => item.Level)); + + if (songs.Any(song => string.IsNullOrWhiteSpace(song.CompactDifficultyLabel))) + filters.Add(UnrankedFilter); + return filters; + } + + public static List Apply( + IReadOnlyList songs, + string searchQuery, + string levelFilter, + SongSortMode sortMode, + bool favoritesOnly = false) + { + if (songs == null) + return new List(); + + var query = (searchQuery ?? string.Empty).Trim(); + IEnumerable filtered = songs.Where(song => + (!favoritesOnly || song.IsFavorite) && + MatchesLevel(song, levelFilter) && MatchesSearch(song, query)); + + return Sort(filtered, query, sortMode).ToList(); + } + + public static List Apply( + IReadOnlyList songs, + string searchQuery, + IReadOnlyCollection levelFilters, + SongSortMode sortMode, + bool favoritesOnly = false) + { + if (songs == null) + return new List(); + + var query = (searchQuery ?? string.Empty).Trim(); + var selectedLevels = levelFilters == null || levelFilters.Count == 0 + ? null + : new HashSet(levelFilters, StringComparer.OrdinalIgnoreCase); + IEnumerable filtered = songs.Where(song => + (!favoritesOnly || song.IsFavorite) && + MatchesAnyLevel(song, selectedLevels) && MatchesSearch(song, query)); + + return Sort(filtered, query, sortMode).ToList(); + } + + public static string SortLabel(SongSortMode sortMode) + { + switch (sortMode) + { + case SongSortMode.DifficultyDescending: return "DIFFICULTY ↓"; + case SongSortMode.Title: return "TITLE A–Z"; + case SongSortMode.FavoritesFirst: return "FAVORITES FIRST"; + default: return "DIFFICULTY ↑"; + } + } + + private static bool MatchesLevel(SongLibraryEntry song, string levelFilter) + { + if (string.IsNullOrWhiteSpace(levelFilter) || + string.Equals(levelFilter, AllLevelsFilter, StringComparison.OrdinalIgnoreCase)) + return true; + + var level = song.CompactDifficultyLabel ?? string.Empty; + if (string.Equals(levelFilter, RecommendedFilter, StringComparison.OrdinalIgnoreCase)) + return string.Equals(level, "LEVEL 14", StringComparison.OrdinalIgnoreCase) || + string.Equals(level, "LEVEL 15", StringComparison.OrdinalIgnoreCase); + if (string.Equals(levelFilter, UnrankedFilter, StringComparison.OrdinalIgnoreCase)) + return string.IsNullOrWhiteSpace(level); + return string.Equals(level, levelFilter, StringComparison.OrdinalIgnoreCase); + } + + private static bool MatchesAnyLevel(SongLibraryEntry song, ISet levelFilters) + { + if (levelFilters == null || levelFilters.Count == 0 || levelFilters.Contains(AllLevelsFilter)) + return true; + + var level = song.CompactDifficultyLabel ?? string.Empty; + return string.IsNullOrWhiteSpace(level) + ? levelFilters.Contains(UnrankedFilter) + : levelFilters.Contains(level); + } + + private static bool MatchesSearch(SongLibraryEntry song, string query) + => SongSearchIndexer.Matches(song, query); + + private static IOrderedEnumerable Sort( + IEnumerable songs, + string query, + SongSortMode sortMode) + { + // Search relevance is the primary order only while a query is active. The + // selected library sort remains the deterministic tie-breaker. + var ordered = songs.OrderByDescending(song => + string.IsNullOrWhiteSpace(query) + ? 0 + : SongSearchIndexer.CalculateMatchScore(song, query)); + switch (sortMode) + { + case SongSortMode.DifficultyDescending: + return ordered + .ThenBy(DifficultyGroup) + .ThenByDescending(song => song.DifficultySortIndex) + .ThenBy(song => song.DisplayTitle, StringComparer.OrdinalIgnoreCase); + case SongSortMode.Title: + return ordered + .ThenBy(song => song.DisplayTitle, StringComparer.OrdinalIgnoreCase) + .ThenBy(song => song.DisplayArtist, StringComparer.OrdinalIgnoreCase); + case SongSortMode.FavoritesFirst: + return ordered + .ThenByDescending(song => song.IsFavorite) + .ThenBy(DifficultyGroup) + .ThenBy(song => song.DifficultySortIndex) + .ThenBy(song => song.DisplayTitle, StringComparer.OrdinalIgnoreCase); + default: + return ordered + .ThenBy(DifficultyGroup) + .ThenBy(song => song.DifficultySortIndex) + .ThenBy(song => song.DisplayTitle, StringComparer.OrdinalIgnoreCase); + } + } + + private static int DifficultyGroup(SongLibraryEntry song) => + song?.HasCatalogDifficulty == true ? 0 : + song?.Metadata?.PlayLevel > 0 ? 1 : 2; + + } +} diff --git a/Assets/ProjectQuint/Runtime/Unity/Library/SongLibraryView.cs.meta b/Assets/ProjectQuint/Runtime/Unity/Library/SongLibraryView.cs.meta new file mode 100644 index 0000000..d6e64e4 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Library/SongLibraryView.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 443233f6785cd4356a3fc324354312d6 \ No newline at end of file diff --git a/Assets/ProjectQuint/Runtime/Unity/Library/SongPlayRecordStore.cs b/Assets/ProjectQuint/Runtime/Unity/Library/SongPlayRecordStore.cs new file mode 100644 index 0000000..c592100 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Library/SongPlayRecordStore.cs @@ -0,0 +1,506 @@ +using System; +using System.Collections.Generic; +using ProjectQuint.Core.Progression; +using ProjectQuint.Persistence; + +namespace ProjectQuint.Library +{ + [Serializable] + public sealed class RatingPlayRecord + { + public bool HasRecord; + public string EventId = string.Empty; + public string ProviderId = string.Empty; + public string ProviderDisplayName = string.Empty; + public string DifficultyLabel = string.Empty; + public string DifficultyValue = string.Empty; + public string Title = string.Empty; + public string Artist = string.Empty; + public double ChartConstant; + public int KeyCount; + public bool HasScratch; + public string LaneModifier = string.Empty; + public bool HardClear; + public double AccuracyPercent; + public double PlayRating; + public int PlayCount; + public int RatingRulesVersion; + public long RecordedUtcTicks; + } + + [Serializable] + public sealed class SongPlayRecord + { + public bool HasRecord; + public int BestScore; + public double BestAccuracyPercent; + public int BestMaxCombo; + public bool HasBestScoreJudgementBreakdown; + public int BestScoreKoolCount; + public int BestScoreCoolCount; + public int BestScoreGoodCount; + public int BestScoreMissCount; + public int BestScorePurePerfectCount; + public int PlayCount; + public bool HasJudgementBreakdown; + public int LastScore; + public double LastAccuracyPercent; + public int LastMaxCombo; + public int LastKoolCount; + public int LastCoolCount; + public int LastGoodCount; + public int LastMissCount; + public int LastFastCount; + public int LastLateCount; + public string LastAppliedEventId = string.Empty; + public int LastPurePerfectCount; + public int LastJudgedUnits; + public int LastTotalScoringUnits; + public bool LastCleared; + public bool LastHardClear; + public bool EverFullCombo; + public bool EverAllPure; + public RatingPlayRecord BestRating = new RatingPlayRecord(); + public List BestRatings = new List(); + } + + internal readonly struct SongPlayResult + { + public SongPlayResult( + int score, + double accuracyPercent, + int maxCombo, + int koolCount, + int coolCount, + int goodCount, + int missCount, + int fastCount, + int lateCount, + string eventId = "", + int purePerfectCount = 0, + int judgedUnits = 0, + int totalScoringUnits = 0, + bool cleared = false, + bool hardClear = false, + string packId = "", + string packDisplayName = "", + string providerId = "", + string providerDisplayName = "", + string difficultyLabel = "", + string difficultyValue = "", + string title = "", + string artist = "", + double chartConstant = 0.0, + int keyCount = 0, + bool hasScratch = false, + string laneModifier = "", + long occurredUtcTicks = 0) + { + Score = score; + AccuracyPercent = accuracyPercent; + MaxCombo = maxCombo; + KoolCount = koolCount; + CoolCount = coolCount; + GoodCount = goodCount; + MissCount = missCount; + FastCount = fastCount; + LateCount = lateCount; + EventId = eventId ?? string.Empty; + PurePerfectCount = purePerfectCount; + JudgedUnits = judgedUnits; + TotalScoringUnits = totalScoringUnits; + Cleared = cleared; + HardClear = hardClear; + PackId = packId ?? string.Empty; + PackDisplayName = packDisplayName ?? string.Empty; + ProviderId = providerId ?? string.Empty; + ProviderDisplayName = providerDisplayName ?? string.Empty; + DifficultyLabel = difficultyLabel ?? string.Empty; + DifficultyValue = difficultyValue ?? string.Empty; + Title = title ?? string.Empty; + Artist = artist ?? string.Empty; + ChartConstant = chartConstant; + KeyCount = keyCount; + HasScratch = hasScratch; + LaneModifier = laneModifier ?? string.Empty; + OccurredUtcTicks = occurredUtcTicks; + } + + public int Score { get; } + public double AccuracyPercent { get; } + public int MaxCombo { get; } + public int KoolCount { get; } + public int CoolCount { get; } + public int GoodCount { get; } + public int MissCount { get; } + public int FastCount { get; } + public int LateCount { get; } + public string EventId { get; } + public int PurePerfectCount { get; } + public int JudgedUnits { get; } + public int TotalScoringUnits { get; } + public bool Cleared { get; } + public bool HardClear { get; } + public string PackId { get; } + public string PackDisplayName { get; } + public string ProviderId { get; } + public string ProviderDisplayName { get; } + public string DifficultyLabel { get; } + public string DifficultyValue { get; } + public string Title { get; } + public string Artist { get; } + public double ChartConstant { get; } + public int KeyCount { get; } + public bool HasScratch { get; } + public string LaneModifier { get; } + public long OccurredUtcTicks { get; } + public bool RatingEligible => + Cleared && + !string.IsNullOrWhiteSpace(ProviderId) && + ChartConstant > 0.0 && + KeyCount >= 4 && KeyCount <= 7 && + !string.Equals(LaneModifier, "RANDOM", StringComparison.OrdinalIgnoreCase); + public double PlayRating => RatingEligible + ? RatingRules.CalculatePlayRating(ChartConstant, AccuracyPercent, HardClear) + : 0.0; + + public static SongPlayResult FromEvent(PlayResultEvent playEvent) => new SongPlayResult( + playEvent.Score, + playEvent.AccuracyPercent, + playEvent.MaxCombo, + playEvent.KoolCount, + playEvent.CoolCount, + playEvent.GoodCount, + playEvent.MissCount, + playEvent.FastCount, + playEvent.LateCount, + playEvent.EventId, + playEvent.PurePerfectCount, + playEvent.JudgedUnits, + playEvent.TotalScoringUnits, + playEvent.Cleared, + playEvent.HardClear, + playEvent.PackId, + playEvent.PackDisplayName, + playEvent.ProviderId, + playEvent.ProviderDisplayName, + playEvent.DifficultyLabel, + playEvent.DifficultyValue, + playEvent.Title, + playEvent.Artist, + playEvent.ChartConstant, + playEvent.KeyCount, + playEvent.HasScratch, + playEvent.LaneModifier, + playEvent.OccurredUtcTicks); + } + + internal static class SongPlayRecordStore + { + private const string PreferencePrefix = "ProjectQuint.PlayRecordV3."; + + public static SongPlayRecord Load(SongLibraryEntry entry) + => SongRecordRepository.Shared.LoadPlayRecord(entry); + + public static void Record(SongLibraryEntry entry, SongPlayResult result) + { + SongRecordRepository.Shared.RecordPlay(entry, result); + } + + internal static SongPlayRecord MergeRun(SongPlayRecord current, SongPlayResult result) + { + current ??= new SongPlayRecord(); + if (!string.IsNullOrWhiteSpace(result.EventId) && + string.Equals(current.LastAppliedEventId, result.EventId, StringComparison.Ordinal)) + return current; + + var hadRecord = current.HasRecord; + var previousBestScore = Math.Max(0, current.BestScore); + var capturesBestScoreBreakdown = + !hadRecord || + result.Score > previousBestScore || + !current.HasBestScoreJudgementBreakdown && result.Score == previousBestScore; + current = Merge(current, result.Score, result.AccuracyPercent, result.MaxCombo); + if (capturesBestScoreBreakdown) + { + current.HasBestScoreJudgementBreakdown = true; + current.BestScoreKoolCount = Math.Max(0, result.KoolCount); + current.BestScoreCoolCount = Math.Max(0, result.CoolCount); + current.BestScoreGoodCount = Math.Max(0, result.GoodCount); + current.BestScoreMissCount = Math.Max(0, result.MissCount); + current.BestScorePurePerfectCount = Math.Max(0, result.PurePerfectCount); + } + current.PlayCount = Math.Max(0, current.PlayCount) + 1; + current.HasJudgementBreakdown = true; + current.LastScore = Math.Max(0, result.Score); + current.LastAccuracyPercent = Math.Max(0, Math.Min(100, result.AccuracyPercent)); + current.LastMaxCombo = Math.Max(0, result.MaxCombo); + current.LastKoolCount = Math.Max(0, result.KoolCount); + current.LastCoolCount = Math.Max(0, result.CoolCount); + current.LastGoodCount = Math.Max(0, result.GoodCount); + current.LastMissCount = Math.Max(0, result.MissCount); + current.LastFastCount = Math.Max(0, result.FastCount); + current.LastLateCount = Math.Max(0, result.LateCount); + current.LastAppliedEventId = result.EventId ?? string.Empty; + current.LastPurePerfectCount = Math.Max(0, result.PurePerfectCount); + current.LastJudgedUnits = Math.Max(0, result.JudgedUnits); + current.LastTotalScoringUnits = Math.Max(0, result.TotalScoringUnits); + current.LastCleared = result.Cleared; + current.LastHardClear = result.HardClear; + current.EverFullCombo |= result.Cleared && result.MissCount == 0; + current.EverAllPure |= result.Cleared && result.TotalScoringUnits > 0 && + result.PurePerfectCount >= result.TotalScoringUnits; + NormalizeRatingHistory(current); + if (result.RatingEligible) + { + var scopedBestIndex = FindRatingScope( + current.BestRatings, + result.ProviderId, + result.KeyCount, + result.HasScratch); + var scopedBest = scopedBestIndex >= 0 + ? current.BestRatings[scopedBestIndex] + : null; + var scopedPlayCount = Math.Max(0, scopedBest?.PlayCount ?? 0) + 1; + if (scopedBest == null || result.PlayRating > scopedBest.PlayRating + 0.000000001) + { + var replacement = new RatingPlayRecord + { + HasRecord = true, + EventId = result.EventId, + ProviderId = result.ProviderId.Trim(), + ProviderDisplayName = string.IsNullOrWhiteSpace(result.ProviderDisplayName) + ? result.ProviderId.Trim() + : result.ProviderDisplayName.Trim(), + DifficultyLabel = result.DifficultyLabel, + DifficultyValue = result.DifficultyValue, + Title = result.Title, + Artist = result.Artist, + ChartConstant = result.ChartConstant, + KeyCount = result.KeyCount, + HasScratch = result.HasScratch, + LaneModifier = result.LaneModifier, + HardClear = result.HardClear, + AccuracyPercent = Math.Max(0.0, Math.Min(100.0, result.AccuracyPercent)), + PlayRating = result.PlayRating, + PlayCount = scopedPlayCount, + RatingRulesVersion = RatingRules.RulesVersion, + RecordedUtcTicks = result.OccurredUtcTicks, + }; + if (scopedBestIndex >= 0) + current.BestRatings[scopedBestIndex] = replacement; + else + current.BestRatings.Add(replacement); + } + else + { + scopedBest.PlayCount = scopedPlayCount; + } + } + RefreshLegacyBestRating(current); + return current; + } + + internal static bool NormalizeRatingHistory(SongPlayRecord record) + { + if (record == null) + return false; + + var changed = false; + record.BestRatings ??= new List(); + changed |= NormalizeRatingRules(record.BestRating); + if (record.BestRating?.HasRecord == true && IsScopedRating(record.BestRating)) + { + var legacyScope = FindRatingScope( + record.BestRatings, + record.BestRating.ProviderId, + record.BestRating.KeyCount, + record.BestRating.HasScratch); + if (legacyScope < 0) + { + record.BestRatings.Add(CloneRating(record.BestRating)); + changed = true; + } + else if (record.BestRating.PlayRating > record.BestRatings[legacyScope].PlayRating) + { + record.BestRatings[legacyScope] = CloneRating(record.BestRating); + changed = true; + } + } + + var normalized = new List(record.BestRatings.Count); + foreach (var candidate in record.BestRatings) + { + changed |= NormalizeRatingRules(candidate); + if (!IsScopedRating(candidate)) + { + changed = true; + continue; + } + + var existing = FindRatingScope( + normalized, + candidate.ProviderId, + candidate.KeyCount, + candidate.HasScratch); + if (existing < 0) + { + normalized.Add(candidate); + continue; + } + + changed = true; + var existingRating = normalized[existing]; + var combinedPlayCount = Math.Max(0, existingRating.PlayCount) + Math.Max(0, candidate.PlayCount); + if (candidate.PlayRating > existingRating.PlayRating) + { + candidate.PlayCount = combinedPlayCount; + normalized[existing] = candidate; + } + else + { + existingRating.PlayCount = combinedPlayCount; + } + } + if (normalized.Count != record.BestRatings.Count) + changed = true; + record.BestRatings = normalized; + for (var index = 0; index < record.BestRatings.Count; index++) + { + var rating = record.BestRatings[index]; + if (rating.PlayCount > 0) + continue; + rating.PlayCount = record.BestRatings.Count == 1 + ? Math.Max(1, record.PlayCount) + : 1; + changed = true; + } + + var previousBest = record.BestRating; + RefreshLegacyBestRating(record); + if (!SameRating(previousBest, record.BestRating)) + changed = true; + return changed; + } + + private static bool NormalizeRatingRules(RatingPlayRecord rating) + { + if (rating?.HasRecord != true || + rating.RatingRulesVersion >= RatingRules.RulesVersion) + return false; + + if (!RatingRules.TryResolveChartConstant( + rating.DifficultyValue, + rating.DifficultyLabel, + out var chartConstant)) + { + rating.HasRecord = false; + rating.ChartConstant = 0.0; + rating.PlayRating = 0.0; + rating.RatingRulesVersion = RatingRules.RulesVersion; + return true; + } + + rating.ChartConstant = chartConstant; + rating.PlayRating = RatingRules.CalculatePlayRating( + chartConstant, + rating.AccuracyPercent, + rating.HardClear); + rating.RatingRulesVersion = RatingRules.RulesVersion; + return true; + } + + private static void RefreshLegacyBestRating(SongPlayRecord record) + { + RatingPlayRecord strongest = null; + foreach (var candidate in record.BestRatings ?? new List()) + { + if (candidate?.HasRecord == true && + (strongest == null || candidate.PlayRating > strongest.PlayRating)) + strongest = candidate; + } + record.BestRating = strongest == null + ? record.BestRating ?? new RatingPlayRecord() + : CloneRating(strongest); + } + + private static int FindRatingScope( + IReadOnlyList ratings, + string providerId, + int keyCount, + bool hasScratch) + { + for (var index = 0; index < (ratings?.Count ?? 0); index++) + { + var candidate = ratings[index]; + if (candidate != null && + string.Equals(candidate.ProviderId?.Trim(), providerId?.Trim(), StringComparison.OrdinalIgnoreCase) && + candidate.KeyCount == keyCount && + candidate.HasScratch == hasScratch) + return index; + } + return -1; + } + + private static bool IsScopedRating(RatingPlayRecord rating) => + rating?.HasRecord == true && + !string.IsNullOrWhiteSpace(rating.ProviderId) && + rating.KeyCount >= 4 && rating.KeyCount <= 7 && + rating.PlayRating > 0.0; + + private static bool SameRating(RatingPlayRecord left, RatingPlayRecord right) => + ReferenceEquals(left, right) || + left != null && right != null && + string.Equals(left.EventId, right.EventId, StringComparison.Ordinal) && + string.Equals(left.ProviderId, right.ProviderId, StringComparison.OrdinalIgnoreCase) && + left.KeyCount == right.KeyCount && + left.HasScratch == right.HasScratch && + Math.Abs(left.PlayRating - right.PlayRating) <= 0.000000001; + + internal static RatingPlayRecord CloneRating(RatingPlayRecord source) + { + if (source == null) + return new RatingPlayRecord(); + return new RatingPlayRecord + { + HasRecord = source.HasRecord, + EventId = source.EventId, + ProviderId = source.ProviderId, + ProviderDisplayName = source.ProviderDisplayName, + DifficultyLabel = source.DifficultyLabel, + DifficultyValue = source.DifficultyValue, + Title = source.Title, + Artist = source.Artist, + ChartConstant = source.ChartConstant, + KeyCount = source.KeyCount, + HasScratch = source.HasScratch, + LaneModifier = source.LaneModifier, + HardClear = source.HardClear, + AccuracyPercent = source.AccuracyPercent, + PlayRating = source.PlayRating, + PlayCount = source.PlayCount, + RatingRulesVersion = source.RatingRulesVersion, + RecordedUtcTicks = source.RecordedUtcTicks, + }; + } + + internal static SongPlayRecord Merge( + SongPlayRecord current, + int score, + double accuracyPercent, + int maxCombo) + { + current ??= new SongPlayRecord(); + current.HasRecord = true; + current.BestScore = Math.Max(current.BestScore, Math.Max(0, score)); + current.BestAccuracyPercent = Math.Max( + current.BestAccuracyPercent, + Math.Max(0, Math.Min(100, accuracyPercent))); + current.BestMaxCombo = Math.Max(current.BestMaxCombo, Math.Max(0, maxCombo)); + return current; + } + + internal static string BuildPreferenceKey(SongLibraryEntry entry) + => PreferencePrefix + ChartIdentityResolver.BuildLegacyPreferenceSuffix(entry); + } +} diff --git a/Assets/ProjectQuint/Runtime/Unity/Library/SongPlayRecordStore.cs.meta b/Assets/ProjectQuint/Runtime/Unity/Library/SongPlayRecordStore.cs.meta new file mode 100644 index 0000000..3c4239e --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Library/SongPlayRecordStore.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 158b636928db4482b99df9a7ef8bfeb5 \ No newline at end of file diff --git a/Assets/ProjectQuint/Runtime/Unity/Library/SongSearchIndex.cs b/Assets/ProjectQuint/Runtime/Unity/Library/SongSearchIndex.cs new file mode 100644 index 0000000..3eb0872 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Library/SongSearchIndex.cs @@ -0,0 +1,224 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using UnityEngine; + +namespace ProjectQuint.Library +{ + internal sealed class SongSearchDocument + { + public static SongSearchDocument Empty { get; } = new SongSearchDocument( + Array.Empty(), + Array.Empty(), + Array.Empty(), + Array.Empty()); + + public SongSearchDocument( + string[] title, + string[] artist, + string[] fileName, + string[] aliases) + { + Title = title ?? Array.Empty(); + Artist = artist ?? Array.Empty(); + FileName = fileName ?? Array.Empty(); + Aliases = aliases ?? Array.Empty(); + CombinedIndex = string.Join("\n", Title + .Concat(Artist) + .Concat(FileName) + .Concat(Aliases) + .Distinct(StringComparer.Ordinal) + .OrderBy(token => token, StringComparer.Ordinal)); + } + + public string[] Title { get; } + public string[] Artist { get; } + public string[] FileName { get; } + public string[] Aliases { get; } + public string CombinedIndex { get; } + } + + [Serializable] + internal sealed class SearchAliasDocument + { + public SearchAliasEntry[] Entries = Array.Empty(); + } + + [Serializable] + internal sealed class SearchAliasEntry + { + public string ChartMd5 = string.Empty; + public string MatchText = string.Empty; + public string[] Aliases = Array.Empty(); + } + + internal sealed class SearchAliasIndex + { + private readonly IReadOnlyList _entries; + + private SearchAliasIndex(IReadOnlyList entries, string status) + { + _entries = entries ?? Array.Empty(); + Status = status ?? string.Empty; + } + + public static SearchAliasIndex Empty { get; } = + new SearchAliasIndex(Array.Empty(), "no aliases"); + + public int Count => _entries.Count; + public string Status { get; } + + public static SearchAliasIndex Load(string bundledJson, string userPath) + { + var entries = new List(); + var bundledCount = AddJson(entries, bundledJson); + var userCount = 0; + if (!string.IsNullOrWhiteSpace(userPath) && File.Exists(userPath)) + { + try + { + userCount = AddJson(entries, File.ReadAllText(userPath)); + } + catch (Exception) + { + userCount = 0; + } + } + return new SearchAliasIndex( + entries, + $"{bundledCount:N0} bundled + {userCount:N0} user aliases"); + } + + public static SearchAliasIndex Parse(string json) => + new SearchAliasIndex(ParseEntries(json), "parsed aliases"); + + public IEnumerable Resolve(SongLibraryEntry song) + { + if (song == null || _entries.Count == 0) + yield break; + + var md5 = song.Metadata?.Md5 ?? string.Empty; + var title = song.DisplayTitle ?? string.Empty; + var artist = song.DisplayArtist ?? string.Empty; + var fileName = Path.GetFileNameWithoutExtension(song.ChartPath) ?? string.Empty; + foreach (var entry in _entries) + { + if (entry == null) + continue; + if (!string.IsNullOrWhiteSpace(entry.ChartMd5) && + !string.Equals(entry.ChartMd5.Trim(), md5, StringComparison.OrdinalIgnoreCase)) + continue; + if (!string.IsNullOrWhiteSpace(entry.MatchText) && + title.IndexOf(entry.MatchText, StringComparison.OrdinalIgnoreCase) < 0 && + artist.IndexOf(entry.MatchText, StringComparison.OrdinalIgnoreCase) < 0 && + fileName.IndexOf(entry.MatchText, StringComparison.OrdinalIgnoreCase) < 0) + continue; + + foreach (var alias in entry.Aliases ?? Array.Empty()) + if (!string.IsNullOrWhiteSpace(alias)) + yield return alias; + } + } + + private static int AddJson(ICollection target, string json) + { + var parsed = ParseEntries(json); + foreach (var entry in parsed) + target.Add(entry); + return parsed.Count; + } + + private static IReadOnlyList ParseEntries(string json) + { + if (string.IsNullOrWhiteSpace(json)) + return Array.Empty(); + try + { + var document = JsonUtility.FromJson(json); + return (document?.Entries ?? Array.Empty()) + .Where(entry => entry != null && + (!string.IsNullOrWhiteSpace(entry.ChartMd5) || + !string.IsNullOrWhiteSpace(entry.MatchText)) && + entry.Aliases?.Any(alias => !string.IsNullOrWhiteSpace(alias)) == true) + .ToArray(); + } + catch (Exception) + { + return Array.Empty(); + } + } + } + + internal static class SongSearchIndexer + { + public static void Rebuild(IReadOnlyList songs, SearchAliasIndex aliases = null) + { + foreach (var song in songs ?? Array.Empty()) + song?.RebuildSearchIndex(aliases); + } + + public static bool Matches(SongLibraryEntry song, string query) + => CalculateMatchScore(song, query) > 0; + + public static int CalculateMatchScore(SongLibraryEntry song, string query) + { + if (song == null) + return 0; + if (string.IsNullOrWhiteSpace(query)) + return 1; + if (string.IsNullOrEmpty(song.SearchIndex)) + song.RebuildSearchIndex(); + + var best = 0; + foreach (var queryVariant in JapaneseSearchNormalizer.BuildQueryVariants(query)) + { + best = Math.Max(best, Score(song.SearchDocument.Artist, queryVariant, 1200)); + best = Math.Max(best, Score(song.SearchDocument.Title, queryVariant, 1100)); + best = Math.Max(best, Score(song.SearchDocument.Aliases, queryVariant, 1000)); + best = Math.Max(best, Score(song.SearchDocument.FileName, queryVariant, 900)); + } + return best; + } + + public static SongSearchDocument Build(SongLibraryEntry song, SearchAliasIndex aliases) + { + if (song == null) + return SongSearchDocument.Empty; + + return new SongSearchDocument( + BuildField(song.DisplayTitle), + BuildField(song.DisplayArtist), + BuildField(Path.GetFileNameWithoutExtension(song.ChartPath)), + BuildField(aliases?.Resolve(song))); + } + + private static int Score(IEnumerable indexedValues, string query, int exactScore) + { + if (string.IsNullOrEmpty(query)) + return 0; + var best = 0; + foreach (var value in indexedValues ?? Array.Empty()) + { + if (string.Equals(value, query, StringComparison.Ordinal)) + return exactScore; + if (value.StartsWith(query, StringComparison.Ordinal)) + best = Math.Max(best, exactScore - 100); + else if (value.IndexOf(query, StringComparison.Ordinal) >= 0) + best = Math.Max(best, exactScore - 200); + } + return best; + } + + private static string[] BuildField(string value) => BuildField(new[] { value }); + + private static string[] BuildField(IEnumerable values) + { + var tokens = new HashSet(StringComparer.Ordinal); + foreach (var value in values ?? Array.Empty()) + foreach (var variant in JapaneseSearchNormalizer.BuildIndexVariants(value)) + tokens.Add(variant); + return tokens.OrderBy(token => token, StringComparer.Ordinal).ToArray(); + } + } +} diff --git a/Assets/ProjectQuint/Runtime/Unity/Library/SongSearchIndex.cs.meta b/Assets/ProjectQuint/Runtime/Unity/Library/SongSearchIndex.cs.meta new file mode 100644 index 0000000..19cbd0a --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Library/SongSearchIndex.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4ae792a10ebac4753bd8a00488bf5f84 \ No newline at end of file diff --git a/Assets/ProjectQuint/Runtime/Unity/Persistence.meta b/Assets/ProjectQuint/Runtime/Unity/Persistence.meta new file mode 100644 index 0000000..e54a148 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Persistence.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 77ea2769dae9b460d9751a055fca3234 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/ProjectQuint/Runtime/Unity/Persistence/AtomicJsonFile.cs b/Assets/ProjectQuint/Runtime/Unity/Persistence/AtomicJsonFile.cs new file mode 100644 index 0000000..daec282 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Persistence/AtomicJsonFile.cs @@ -0,0 +1,176 @@ +using System; +using System.IO; +using System.Text; +using UnityEngine; + +namespace ProjectQuint.Persistence +{ + internal enum PersistentReadSource + { + Missing, + Primary, + Backup, + } + + internal sealed class AtomicJsonFile + where TDocument : class + { + private readonly string _path; + private readonly Func _validate; + private readonly Action _logWarning; + + public AtomicJsonFile( + string path, + Func validate, + Action logWarning = null) + { + if (string.IsNullOrWhiteSpace(path)) + throw new ArgumentException("A persistence path is required.", nameof(path)); + + _path = path; + _validate = validate ?? throw new ArgumentNullException(nameof(validate)); + _logWarning = logWarning ?? (message => Debug.LogWarning(message)); + } + + public string Path => _path; + public string BackupPath => _path + ".bak"; + + public bool TryLoad(out TDocument document, out PersistentReadSource source) + { + if (TryRead(_path, out document, out var primaryFailure)) + { + source = PersistentReadSource.Primary; + return true; + } + + if (TryRead(BackupPath, out document, out var backupFailure)) + { + source = PersistentReadSource.Backup; + _logWarning( + $"[ProjectQuint Persistence] Recovered '{_path}' from its backup. " + + $"Primary failure: {primaryFailure}"); + RestorePrimaryFromBackup(document); + return true; + } + + source = PersistentReadSource.Missing; + document = null; + if (File.Exists(_path) || File.Exists(BackupPath)) + { + _logWarning( + $"[ProjectQuint Persistence] Could not read '{_path}' or its backup. " + + $"Primary: {primaryFailure}; backup: {backupFailure}"); + } + return false; + } + + public void Save(TDocument document) + { + if (document == null) + throw new ArgumentNullException(nameof(document)); + if (!_validate(document)) + throw new InvalidDataException($"Document validation failed for '{_path}'."); + + var json = JsonUtility.ToJson(document, true); + WriteAtomically(json, preserveExistingBackup: false); + } + + private bool TryRead(string path, out TDocument document, out string failure) + { + document = null; + failure = "file not found"; + if (!File.Exists(path)) + return false; + + try + { + var json = File.ReadAllText(path, Encoding.UTF8); + document = JsonUtility.FromJson(json); + if (document == null) + throw new InvalidDataException("JSON produced no document"); + if (!_validate(document)) + throw new InvalidDataException("schema version or document contents are unsupported"); + failure = string.Empty; + return true; + } + catch (Exception exception) + { + document = null; + failure = exception.Message; + return false; + } + } + + private void RestorePrimaryFromBackup(TDocument document) + { + try + { + var json = JsonUtility.ToJson(document, true); + WriteAtomically(json, preserveExistingBackup: true); + } + catch (Exception exception) + { + _logWarning( + $"[ProjectQuint Persistence] Backup data was loaded, but primary restore failed for " + + $"'{_path}': {exception.Message}"); + } + } + + private void WriteAtomically(string json, bool preserveExistingBackup) + { + var directory = System.IO.Path.GetDirectoryName(_path); + if (!string.IsNullOrWhiteSpace(directory)) + Directory.CreateDirectory(directory); + + var temporaryPath = _path + ".tmp"; + if (File.Exists(temporaryPath)) + File.Delete(temporaryPath); + + try + { + var bytes = new UTF8Encoding(false).GetBytes(json ?? string.Empty); + using (var stream = new FileStream( + temporaryPath, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + 4096, + FileOptions.WriteThrough)) + { + stream.Write(bytes, 0, bytes.Length); + stream.Flush(true); + } + + if (!File.Exists(_path)) + { + File.Move(temporaryPath, _path); + return; + } + + if (preserveExistingBackup) + { + var corruptPath = BuildCorruptPath(); + File.Replace(temporaryPath, _path, corruptPath); + } + else + { + File.Replace(temporaryPath, _path, BackupPath); + } + } + finally + { + if (File.Exists(temporaryPath)) + File.Delete(temporaryPath); + } + } + + private string BuildCorruptPath() + { + var stamp = DateTime.UtcNow.ToString("yyyyMMdd-HHmmss-fff"); + var candidate = $"{_path}.corrupt-{stamp}"; + for (var suffix = 1; File.Exists(candidate); suffix++) + candidate = $"{_path}.corrupt-{stamp}-{suffix}"; + return candidate; + } + } +} diff --git a/Assets/ProjectQuint/Runtime/Unity/Persistence/AtomicJsonFile.cs.meta b/Assets/ProjectQuint/Runtime/Unity/Persistence/AtomicJsonFile.cs.meta new file mode 100644 index 0000000..5b6f4f2 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Persistence/AtomicJsonFile.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 30963ef3cbbef4bdcb835576399424a8 \ No newline at end of file diff --git a/Assets/ProjectQuint/Runtime/Unity/Persistence/ChartIdentityResolver.cs b/Assets/ProjectQuint/Runtime/Unity/Persistence/ChartIdentityResolver.cs new file mode 100644 index 0000000..f61d5b2 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Persistence/ChartIdentityResolver.cs @@ -0,0 +1,46 @@ +using System; +using System.IO; +using System.Security.Cryptography; +using System.Text; +using ProjectQuint.Core.Identity; +using ProjectQuint.Library; + +namespace ProjectQuint.Persistence +{ + internal static class ChartIdentityResolver + { + public static ChartId Resolve(SongLibraryEntry entry) + { + var md5 = entry?.Metadata?.Md5; + if (!string.IsNullOrWhiteSpace(md5)) + return ChartId.FromMd5(md5); + + var chartPath = entry?.ChartPath ?? string.Empty; + if (File.Exists(chartPath)) + { + using var stream = File.OpenRead(chartPath); + using var sha256 = SHA256.Create(); + return ChartId.FromSha256(ToHex(sha256.ComputeHash(stream))); + } + + return ChartId.FromPathFallback(HashText(chartPath)); + } + + public static string BuildLegacyPreferenceSuffix(SongLibraryEntry entry) + { + var md5 = entry?.Metadata?.Md5; + return !string.IsNullOrWhiteSpace(md5) + ? md5.Trim().ToLowerInvariant() + : HashText(entry?.ChartPath ?? string.Empty); + } + + public static string HashText(string value) + { + using var sha256 = SHA256.Create(); + return ToHex(sha256.ComputeHash(Encoding.UTF8.GetBytes(value ?? string.Empty))); + } + + private static string ToHex(byte[] bytes) => + BitConverter.ToString(bytes).Replace("-", string.Empty).ToLowerInvariant(); + } +} diff --git a/Assets/ProjectQuint/Runtime/Unity/Persistence/ChartIdentityResolver.cs.meta b/Assets/ProjectQuint/Runtime/Unity/Persistence/ChartIdentityResolver.cs.meta new file mode 100644 index 0000000..66d87e8 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Persistence/ChartIdentityResolver.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3fa475bdc85b54173b53821601f878a9 \ No newline at end of file diff --git a/Assets/ProjectQuint/Runtime/Unity/Persistence/CourseRecordRepository.cs b/Assets/ProjectQuint/Runtime/Unity/Persistence/CourseRecordRepository.cs new file mode 100644 index 0000000..b598d9c --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Persistence/CourseRecordRepository.cs @@ -0,0 +1,332 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using ProjectQuint.Core.Progression; +using ProjectQuint.Library; +using UnityEngine; + +namespace ProjectQuint.Persistence +{ + [Serializable] + internal sealed class CourseRecordsDocument + { + public const int CurrentSchemaVersion = 6; + + public int SchemaVersion = CurrentSchemaVersion; + public List Records = new List(); + } + + [Serializable] + internal sealed class CourseRecordData + { + public string CourseId = string.Empty; + public string ProviderId = string.Empty; + public string ProviderDisplayName = string.Empty; + public string CourseName = string.Empty; + public AeryCourseRecord Record = new AeryCourseRecord(); + } + + internal sealed class CourseRecordRepository + { + private static readonly Lazy SharedRepository = + new Lazy(() => new CourseRecordRepository()); + + private readonly AtomicJsonFile _file; + private readonly Action _logWarning; + private Dictionary _records; + + public CourseRecordRepository(string path = null, Action logWarning = null) + { + _logWarning = logWarning ?? (message => Debug.LogWarning(message)); + _file = new AtomicJsonFile( + path ?? PersistencePaths.CourseRecords, + document => document != null && + document.SchemaVersion >= 1 && + document.SchemaVersion <= CourseRecordsDocument.CurrentSchemaVersion && + document.Records != null, + _logWarning); + } + + public static CourseRecordRepository Shared => SharedRepository.Value; + + internal void Reload() + { + _records = null; + } + + public AeryCourseRecord Load(AeryCourseDefinition course) + { + if (course == null) + return new AeryCourseRecord(); + + EnsureLoaded(); + var courseId = BuildCourseId(course); + if (_records.TryGetValue(courseId, out var stored)) + { + if (ApplyCourseMetadata(stored, course)) + Save(); + if (stored.Record?.HasRecord == true) + return stored.Record ?? new AeryCourseRecord(); + } + + return stored?.Record ?? new AeryCourseRecord(); + } + + public AeryCourseRecord Record(AeryCourseDefinition course, AeryCourseResult result) + { + if (course == null || result.Progress == null) + return new AeryCourseRecord(); + + EnsureLoaded(); + var stored = GetOrCreate(course); + ApplyCourseMetadata(stored, course); + var current = course.Record?.HasRecord == true ? course.Record : stored.Record; + stored.Record = AeryCourseRecordStore.Merge(current, result); + course.Record = stored.Record; + Save(); + return stored.Record; + } + + public int MigrateDifficultyTableProviders( + IReadOnlyDictionary providerMap) + { + EnsureLoaded(); + if (providerMap == null || providerMap.Count == 0) + return 0; + + var migratedRecords = 0; + var migrated = new Dictionary(StringComparer.Ordinal); + foreach (var source in _records.Values) + { + if (source == null) + continue; + + var legacyId = (source.ProviderId ?? string.Empty).Trim(); + var table = legacyId.Length > 0 && providerMap.TryGetValue(legacyId, out var mapped) + ? mapped + : FindTableFromCourseId(source.CourseId, providerMap, out legacyId); + if (table != null) + { + var suffix = ExtractCourseSuffix(source.CourseId, legacyId); + source.ProviderId = table.Id; + source.ProviderDisplayName = string.IsNullOrWhiteSpace(table.DisplayName) + ? table.Id + : table.DisplayName; + source.CourseId = $"{NormalizeProviderId(table.Id)}-sha256:{suffix}"; + migratedRecords++; + } + + if (migrated.TryGetValue(source.CourseId, out var existing)) + MergeCourseRecord(existing, source); + else + migrated[source.CourseId] = source; + } + + if (migratedRecords <= 0) + return 0; + _records = migrated; + Save(); + return migratedRecords; + } + + public ProfileCourseSnapshot CalculateCollection(string featuredBadgeCourseId = null) + { + EnsureLoaded(); + var entries = _records.Values + .Where(item => item?.Record?.HasRecord == true) + .Select(item => new CourseCollectionEntry( + item.CourseId, + item.ProviderDisplayName, + item.CourseName, + item.Record.Cleared, + item.Record.PlayCount, + item.Record.ClearCount, + item.Record.BestStageReached, + item.Record.BestAccuracyPercent, + item.Record.BestTrophyRank, + item.Record.BestTrophyName)) + .OrderByDescending(item => item.Cleared) + .ThenByDescending(item => item.BestStageReached) + .ThenBy(item => item.CourseName, StringComparer.OrdinalIgnoreCase) + .ToArray(); + return entries.Length == 0 + ? ProfileCourseSnapshot.Empty + : new ProfileCourseSnapshot( + entries.Length, + entries.Count(item => item.Cleared), + entries.Sum(item => item.PlayCount), + entries, + featuredBadgeCourseId); + } + + internal static string BuildCourseId(AeryCourseDefinition course) + { + var providerId = NormalizeProviderId(course?.ProviderId); + return $"{providerId}-sha256:{BuildLegacySuffix(course)}"; + } + + internal static string BuildLegacySuffix(AeryCourseDefinition course) + { + var identity = string.Join(":", course?.StageHashes ?? Array.Empty()).ToLowerInvariant(); + return ChartIdentityResolver.HashText(identity); + } + + private void EnsureLoaded() + { + if (_records != null) + return; + + _records = new Dictionary(StringComparer.Ordinal); + if (!_file.TryLoad(out var document, out _)) + return; + + if (document.SchemaVersion < CourseRecordsDocument.CurrentSchemaVersion) + { + Save(); + return; + } + + var migratedDocument = document.SchemaVersion < CourseRecordsDocument.CurrentSchemaVersion; + + var scoreFormatMigrated = false; + foreach (var record in document.Records) + { + if (record == null || string.IsNullOrWhiteSpace(record.CourseId)) + continue; + record.CourseId = record.CourseId.Trim().ToLowerInvariant(); + record.Record ??= new AeryCourseRecord(); + scoreFormatMigrated |= AeryCourseRecordStore.NormalizeScoreFormat(record.Record); + _records[record.CourseId] = record; + } + if (migratedDocument || scoreFormatMigrated) + Save(); + } + + private CourseRecordData GetOrCreate(AeryCourseDefinition course) + { + var courseId = BuildCourseId(course); + if (_records.TryGetValue(courseId, out var stored)) + { + ApplyCourseMetadata(stored, course); + return stored; + } + + stored = new CourseRecordData { CourseId = courseId }; + ApplyCourseMetadata(stored, course); + _records.Add(courseId, stored); + return stored; + } + + private void Save() + { + _file.Save(new CourseRecordsDocument + { + Records = _records.Values + .OrderBy(record => record.CourseId, StringComparer.Ordinal) + .ToList(), + }); + } + + private static bool ApplyCourseMetadata(CourseRecordData stored, AeryCourseDefinition course) + { + if (stored == null || course == null) + return false; + var displayName = course.DisplayName; + var changed = false; + if (!string.IsNullOrWhiteSpace(displayName) && + !string.Equals(stored.CourseName, displayName, StringComparison.Ordinal)) + { + stored.CourseName = displayName; + changed = true; + } + var providerId = NormalizeProviderId(course.ProviderId); + var providerDisplayName = string.IsNullOrWhiteSpace(course.ProviderDisplayName) + ? providerId.ToUpperInvariant() + : course.ProviderDisplayName.Trim(); + if (!string.Equals(stored.ProviderId, providerId, StringComparison.Ordinal)) + { + stored.ProviderId = providerId; + changed = true; + } + if (!string.Equals(stored.ProviderDisplayName, providerDisplayName, StringComparison.Ordinal)) + { + stored.ProviderDisplayName = providerDisplayName; + changed = true; + } + return changed; + } + + private static string NormalizeProviderId(string providerId) + { + var normalized = (providerId ?? string.Empty).Trim().ToLowerInvariant(); + return string.IsNullOrWhiteSpace(normalized) ? "aery" : normalized; + } + + private static BmsDifficultyTableDefinition FindTableFromCourseId( + string courseId, + IReadOnlyDictionary providerMap, + out string legacyId) + { + foreach (var candidate in providerMap) + { + var prefix = $"{candidate.Key.Trim().ToLowerInvariant()}-sha256:"; + if (!string.IsNullOrWhiteSpace(courseId) && + courseId.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + legacyId = candidate.Key; + return candidate.Value; + } + } + legacyId = string.Empty; + return null; + } + + private static string ExtractCourseSuffix(string courseId, string providerId) + { + var prefix = $"{NormalizeProviderId(providerId)}-sha256:"; + return !string.IsNullOrWhiteSpace(courseId) && + courseId.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) + ? courseId.Substring(prefix.Length) + : ChartIdentityResolver.HashText(courseId ?? string.Empty); + } + + private static void MergeCourseRecord(CourseRecordData target, CourseRecordData source) + { + if (target == null || source == null) + return; + if (string.IsNullOrWhiteSpace(target.ProviderId)) + target.ProviderId = source.ProviderId; + if (string.IsNullOrWhiteSpace(target.ProviderDisplayName)) + target.ProviderDisplayName = source.ProviderDisplayName; + if (string.IsNullOrWhiteSpace(target.CourseName)) + target.CourseName = source.CourseName; + + var targetRecord = target.Record ??= new AeryCourseRecord(); + var sourceRecord = source.Record; + if (sourceRecord == null) + return; + AeryCourseRecordStore.NormalizeScoreFormat(targetRecord); + AeryCourseRecordStore.NormalizeScoreFormat(sourceRecord); + var targetHadRecord = targetRecord.HasRecord; + targetRecord.HasRecord |= sourceRecord.HasRecord; + targetRecord.Cleared |= sourceRecord.Cleared; + targetRecord.PlayCount = Math.Max(targetRecord.PlayCount, sourceRecord.PlayCount); + targetRecord.ClearCount = Math.Max(targetRecord.ClearCount, sourceRecord.ClearCount); + targetRecord.BestStageReached = Math.Max(targetRecord.BestStageReached, sourceRecord.BestStageReached); + targetRecord.BestScore = Math.Max(targetRecord.BestScore, sourceRecord.BestScore); + targetRecord.BestAccuracyPercent = Math.Max(targetRecord.BestAccuracyPercent, sourceRecord.BestAccuracyPercent); + targetRecord.BestExScore = Math.Max(targetRecord.BestExScore, sourceRecord.BestExScore); + targetRecord.BestExScoreRate = Math.Max(targetRecord.BestExScoreRate, sourceRecord.BestExScoreRate); + targetRecord.BestMaxCombo = Math.Max(targetRecord.BestMaxCombo, sourceRecord.BestMaxCombo); + if (!targetHadRecord || sourceRecord.HasRecord && sourceRecord.BestMissRate < targetRecord.BestMissRate) + targetRecord.BestMissRate = sourceRecord.BestMissRate; + if (sourceRecord.BestTrophyRank > targetRecord.BestTrophyRank) + { + targetRecord.BestTrophyRank = sourceRecord.BestTrophyRank; + targetRecord.BestTrophyName = sourceRecord.BestTrophyName; + } + if (string.IsNullOrWhiteSpace(targetRecord.LastModifier)) + targetRecord.LastModifier = sourceRecord.LastModifier; + } + } +} diff --git a/Assets/ProjectQuint/Runtime/Unity/Persistence/CourseRecordRepository.cs.meta b/Assets/ProjectQuint/Runtime/Unity/Persistence/CourseRecordRepository.cs.meta new file mode 100644 index 0000000..51690b2 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Persistence/CourseRecordRepository.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 64e967d5b3f99437e98474965baf367f \ No newline at end of file diff --git a/Assets/ProjectQuint/Runtime/Unity/Persistence/FavoriteChartRepository.cs b/Assets/ProjectQuint/Runtime/Unity/Persistence/FavoriteChartRepository.cs new file mode 100644 index 0000000..200e1be --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Persistence/FavoriteChartRepository.cs @@ -0,0 +1,118 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using ProjectQuint.Library; +using UnityEngine; + +namespace ProjectQuint.Persistence +{ + [Serializable] + internal sealed class FavoriteChartsDocument + { + public const int CurrentSchemaVersion = 1; + + public int SchemaVersion = CurrentSchemaVersion; + public List ChartIds = new List(); + } + + /// + /// Chart-level favorites live in their own atomic, versioned document. The set + /// intentionally retains IDs whose pack or chart is temporarily unavailable. + /// + internal sealed class FavoriteChartRepository + { + private static readonly Lazy SharedRepository = + new Lazy(() => new FavoriteChartRepository()); + + private readonly AtomicJsonFile _file; + private HashSet _chartIds; + + public FavoriteChartRepository(string path = null, Action logWarning = null) + { + _file = new AtomicJsonFile( + path ?? PersistencePaths.Favorites, + document => document != null && + document.SchemaVersion == FavoriteChartsDocument.CurrentSchemaVersion && + document.ChartIds != null, + logWarning ?? (message => Debug.LogWarning(message))); + } + + public static FavoriteChartRepository Shared => SharedRepository.Value; + + internal void Reload() + { + _chartIds = null; + } + internal int Count + { + get + { + EnsureLoaded(); + return _chartIds.Count; + } + } + + public void Apply(IReadOnlyList songs) + { + EnsureLoaded(); + foreach (var song in songs ?? Array.Empty()) + if (song != null) + song.IsFavorite = _chartIds.Contains(ResolveId(song)); + } + + public bool IsFavorite(SongLibraryEntry song) + { + if (song == null) + return false; + EnsureLoaded(); + return _chartIds.Contains(ResolveId(song)); + } + + public bool Toggle(SongLibraryEntry song) + { + if (song == null) + return false; + + EnsureLoaded(); + var chartId = ResolveId(song); + var isFavorite = _chartIds.Add(chartId); + if (!isFavorite) + _chartIds.Remove(chartId); + song.IsFavorite = isFavorite; + Save(); + return isFavorite; + } + + internal bool ContainsChartId(string chartId) + { + EnsureLoaded(); + return !string.IsNullOrWhiteSpace(chartId) && + _chartIds.Contains(chartId.Trim().ToLowerInvariant()); + } + + private void EnsureLoaded() + { + if (_chartIds != null) + return; + + _chartIds = new HashSet(StringComparer.Ordinal); + if (!_file.TryLoad(out var document, out _)) + return; + + foreach (var chartId in document.ChartIds) + if (!string.IsNullOrWhiteSpace(chartId)) + _chartIds.Add(chartId.Trim().ToLowerInvariant()); + } + + private void Save() + { + _file.Save(new FavoriteChartsDocument + { + ChartIds = _chartIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + }); + } + + private static string ResolveId(SongLibraryEntry song) => + ChartIdentityResolver.Resolve(song).Value; + } +} diff --git a/Assets/ProjectQuint/Runtime/Unity/Persistence/FavoriteChartRepository.cs.meta b/Assets/ProjectQuint/Runtime/Unity/Persistence/FavoriteChartRepository.cs.meta new file mode 100644 index 0000000..c9fcb89 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Persistence/FavoriteChartRepository.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 2a7afe15940149f4860e71189a75eabd diff --git a/Assets/ProjectQuint/Runtime/Unity/Persistence/PersistencePaths.cs b/Assets/ProjectQuint/Runtime/Unity/Persistence/PersistencePaths.cs new file mode 100644 index 0000000..bb9a887 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Persistence/PersistencePaths.cs @@ -0,0 +1,58 @@ +using System.IO; +using UnityEngine; + +namespace ProjectQuint.Persistence +{ + internal static class PersistencePaths + { + public const string DefaultProfileId = "local-player"; + + public static string Root => Path.Combine(Application.persistentDataPath, "ProjectQuint"); + public static string Settings => Path.Combine(Root, "settings.json"); + public static string ProfilesRoot => Path.Combine(Root, "profiles"); + public static string DefaultProfileRoot => ProfileRoot(DefaultProfileId); + public static string SongRecords => ProfileFile(DefaultProfileId, "records.json"); + public static string CourseRecords => ProfileFile(DefaultProfileId, "courses.json"); + public static string Favorites => ProfileFile(DefaultProfileId, "favorites.json"); + public static string PlayerProfile => ProfileFile(DefaultProfileId, "profile.json"); + public static string ProfileImage => ProfileFile(DefaultProfileId, "avatar.png"); + public static string PendingPlayCommit => ProfileFile(DefaultProfileId, "pending-play-commit.json"); + public static string ProfileMigrationMarker => ProfileFile(DefaultProfileId, "profile-storage-migration.json"); + public static string LegacyGlobalSongRecords => Path.Combine(Root, "records.json"); + public static string LegacyGlobalCourseRecords => Path.Combine(Root, "courses.json"); + public static string LegacyGlobalFavorites => Path.Combine(Root, "favorites.json"); + public static string SearchAliases => Path.Combine(Root, "search-aliases.json"); + public static string PackRegistry => Path.Combine(Root, "packs.json"); + public static string PackIndexRoot => Path.Combine(Root, "pack-indexes"); + public static string ImportedPackRoot => Path.Combine(Root, "imported-packs"); + public static string DifficultyTableRoot => Path.Combine(Root, "difficulty-tables"); + public static string DifficultyTableRegistry => Path.Combine(DifficultyTableRoot, "registry.json"); + public static string BgaVideoCache => Path.Combine(Root, "bga-video-cache"); + public static string PremixCache => Path.Combine(Root, "premix-cache"); + public static string PinnedPremixCache => Path.Combine(Root, "premix-cache-pinned"); + public static string PinnedPremixManifestRoot => Path.Combine(PinnedPremixCache, "manifests"); + + public static string ProfileRoot(string profileId) => + ProfileRootFor(Root, profileId); + + public static string ProfileFile(string profileId, string fileName) => + Path.Combine(ProfileRoot(profileId), fileName); + + internal static string ProfileRootFor(string root, string profileId) + { + var normalized = NormalizeProfileId(profileId); + return Path.Combine(root, "profiles", normalized); + } + + private static string NormalizeProfileId(string profileId) + { + var value = (profileId ?? string.Empty).Trim(); + if (value.Length == 0) + throw new System.ArgumentException("A profile ID is required.", nameof(profileId)); + foreach (var character in value) + if (!char.IsLetterOrDigit(character) && character != '-' && character != '_') + throw new System.ArgumentException($"Invalid profile ID '{profileId}'.", nameof(profileId)); + return value; + } + } +} diff --git a/Assets/ProjectQuint/Runtime/Unity/Persistence/PersistencePaths.cs.meta b/Assets/ProjectQuint/Runtime/Unity/Persistence/PersistencePaths.cs.meta new file mode 100644 index 0000000..b012334 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Persistence/PersistencePaths.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 1dd94e5a629204d4e85b73d71604b828 \ No newline at end of file diff --git a/Assets/ProjectQuint/Runtime/Unity/Persistence/PlayResultCommitCoordinator.cs b/Assets/ProjectQuint/Runtime/Unity/Persistence/PlayResultCommitCoordinator.cs new file mode 100644 index 0000000..a348494 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Persistence/PlayResultCommitCoordinator.cs @@ -0,0 +1,120 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using ProjectQuint.Core.Progression; +using ProjectQuint.Library; +using UnityEngine; + +namespace ProjectQuint.Persistence +{ + [Serializable] + internal sealed class PendingPlayCommitDocument + { + public const int CurrentSchemaVersion = 2; + + public int SchemaVersion = CurrentSchemaVersion; + public PlayResultEvent PlayEvent = new PlayResultEvent(); + } + + internal sealed class PlayResultCommitCoordinator + { + private readonly AtomicJsonFile _pendingFile; + private readonly SongRecordRepository _songRecords; + private readonly PlayerProgressionRepository _progression; + + public PlayResultCommitCoordinator( + string pendingPath = null, + SongRecordRepository songRecords = null, + PlayerProgressionRepository progression = null, + Action logWarning = null) + { + _songRecords = songRecords ?? SongRecordRepository.Shared; + _progression = progression ?? PlayerProgressionRepository.Shared; + _pendingFile = new AtomicJsonFile( + pendingPath ?? PersistencePaths.PendingPlayCommit, + document => document != null && + document.SchemaVersion == PendingPlayCommitDocument.CurrentSchemaVersion && + document.PlayEvent != null && + document.PlayEvent.RulesVersion == PlayResultEvent.CurrentRulesVersion && + !string.IsNullOrWhiteSpace(document.PlayEvent.EventId), + logWarning ?? (message => Debug.LogWarning(message))); + } + + public PlayCommitOutcome Commit(SongLibraryEntry entry, PlayResultEvent playEvent) + { + if (entry == null) + throw new ArgumentNullException(nameof(entry)); + if (playEvent == null) + throw new ArgumentNullException(nameof(playEvent)); + if (string.IsNullOrWhiteSpace(playEvent.EventId)) + throw new ArgumentException("A play event ID is required.", nameof(playEvent)); + + playEvent.NormalizeRatingRules(); + playEvent.ChartId = ChartIdentityResolver.Resolve(entry).Value; + _pendingFile.Save(new PendingPlayCommitDocument { PlayEvent = playEvent }); + + var previousRating = RatingFor(playEvent); + _songRecords.RecordPlay(entry, SongPlayResult.FromEvent(playEvent)); + _songRecords.RecordClearLamp(entry, NormalizeLamp(playEvent.ClearLamp)); + var profileUpdate = _progression.Record(playEvent); + var currentRating = RatingFor(playEvent); + ClearPending(); + + return new PlayCommitOutcome( + profileUpdate, + new RatingUpdate( + playEvent.RatingEligible, + playEvent.PlayRating, + previousRating, + currentRating)); + } + + public bool TryResume(IReadOnlyList songs, out PlayCommitOutcome outcome) + { + outcome = default; + if (!_pendingFile.TryLoad(out var pending, out _)) + return false; + + var chartId = (pending.PlayEvent.ChartId ?? string.Empty).Trim().ToLowerInvariant(); + var entry = (songs ?? Array.Empty()) + .FirstOrDefault(song => song != null && + string.Equals( + ChartIdentityResolver.Resolve(song).Value, + chartId, + StringComparison.Ordinal)); + if (entry == null) + return false; + + outcome = Commit(entry, pending.PlayEvent); + return true; + } + + private RatingSummary RatingFor(PlayResultEvent playEvent) => + playEvent.RatingEligible + ? _songRecords.CalculateRating( + playEvent.ProviderId, + playEvent.ProviderDisplayName, + playEvent.KeyCount, + playEvent.HasScratch) + : default; + + private void ClearPending() + { + DeleteIfPresent(_pendingFile.Path); + DeleteIfPresent(_pendingFile.BackupPath); + DeleteIfPresent(_pendingFile.Path + ".tmp"); + } + + private static void DeleteIfPresent(string path) + { + if (File.Exists(path)) + File.Delete(path); + } + + private static SongClearLamp NormalizeLamp(int value) => + Enum.IsDefined(typeof(SongClearLamp), value) + ? (SongClearLamp)value + : SongClearLamp.None; + } +} diff --git a/Assets/ProjectQuint/Runtime/Unity/Persistence/PlayResultCommitCoordinator.cs.meta b/Assets/ProjectQuint/Runtime/Unity/Persistence/PlayResultCommitCoordinator.cs.meta new file mode 100644 index 0000000..c3f0afc --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Persistence/PlayResultCommitCoordinator.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d33fa6a6a7c7a4770b312c015dae9f54 \ No newline at end of file diff --git a/Assets/ProjectQuint/Runtime/Unity/Persistence/PlayResultEvent.cs b/Assets/ProjectQuint/Runtime/Unity/Persistence/PlayResultEvent.cs new file mode 100644 index 0000000..2dd4053 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Persistence/PlayResultEvent.cs @@ -0,0 +1,86 @@ +using System; +using ProjectQuint.Core.Progression; + +namespace ProjectQuint.Persistence +{ + [Serializable] + internal sealed class PlayResultEvent + { + public const int CurrentRulesVersion = 2; + + public int RulesVersion = CurrentRulesVersion; + public string EventId = string.Empty; + public string ChartId = string.Empty; + public string PackId = string.Empty; + public string PackDisplayName = string.Empty; + public string ProviderId = string.Empty; + public string ProviderDisplayName = string.Empty; + public string DifficultyLabel = string.Empty; + public string DifficultyValue = string.Empty; + public string Title = string.Empty; + public string Artist = string.Empty; + public double ChartConstant; + public int KeyCount; + public bool HasScratch; + public string LaneModifier = string.Empty; + public string GaugeType = string.Empty; + public bool Completed; + public bool Cleared; + public bool HardClear; + public int ClearLamp; + public int Score; + public double AccuracyPercent; + public int MaxCombo; + public int KoolCount; + public int PurePerfectCount; + public int CoolCount; + public int GoodCount; + public int MissCount; + public int FastCount; + public int LateCount; + public int JudgedUnits; + public int TotalScoringUnits; + public double ActivePlaySeconds; + public long OccurredUtcTicks; + public int RatingRulesVersion = RatingRules.RulesVersion; + public int XpRulesVersion = ProfileLevelRules.XpRulesVersion; + + public bool RatingEligible => + Cleared && + !string.IsNullOrWhiteSpace(ProviderId) && + ChartConstant > 0.0 && + KeyCount >= 4 && KeyCount <= 7 && + !string.Equals(LaneModifier, "RANDOM", StringComparison.OrdinalIgnoreCase); + + public double PlayRating => RatingEligible + ? RatingRules.CalculatePlayRating(ChartConstant, AccuracyPercent, HardClear) + : 0.0; + + public bool NormalizeRatingRules() + { + if (RatingRulesVersion >= RatingRules.RulesVersion) + return false; + + ChartConstant = RatingRules.TryResolveChartConstant( + DifficultyValue, + DifficultyLabel, + out var chartConstant) + ? chartConstant + : 0.0; + RatingRulesVersion = RatingRules.RulesVersion; + return true; + } + } + + internal readonly struct PlayCommitOutcome + { + public PlayCommitOutcome(ProfileProgressUpdate profile, RatingUpdate rating) + { + Profile = profile; + Rating = rating; + } + + public ProfileProgressUpdate Profile { get; } + public RatingUpdate Rating { get; } + } +} diff --git a/Assets/ProjectQuint/Runtime/Unity/Persistence/PlayResultEvent.cs.meta b/Assets/ProjectQuint/Runtime/Unity/Persistence/PlayResultEvent.cs.meta new file mode 100644 index 0000000..58a77c7 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Persistence/PlayResultEvent.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a4899436b0069405c8cd06446e491556 \ No newline at end of file diff --git a/Assets/ProjectQuint/Runtime/Unity/Persistence/PlayerProgressionRepository.cs b/Assets/ProjectQuint/Runtime/Unity/Persistence/PlayerProgressionRepository.cs new file mode 100644 index 0000000..d9e38b8 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Persistence/PlayerProgressionRepository.cs @@ -0,0 +1,259 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using ProjectQuint.Core.Progression; +using UnityEngine; + +namespace ProjectQuint.Persistence +{ + [Serializable] + internal sealed class PlayerProgressionDocument + { + public const int CurrentSchemaVersion = 2; + + public int SchemaVersion = CurrentSchemaVersion; + public string ProfileId = "local-player"; + public string DisplayName = "PLAYER"; + public long CreatedUtcTicks; + public long LastPlayedUtcTicks; + public long TotalXp; + public int XpRulesVersion = ProfileLevelRules.XpRulesVersion; + public int ValidPlayCount; + public double ActivePlaySeconds; + public string LastRatingProviderId = string.Empty; + public string LastRatingProviderDisplayName = string.Empty; + public int LastRatingKeyCount; + public bool LastRatingHasScratch; + public string FeaturedCourseBadgeId = string.Empty; + public List AppliedEventIds = new List(); + public List ChartMilestones = new List(); + } + + [Serializable] + internal sealed class ProfileChartMilestoneData + { + public string ChartId = string.Empty; + public bool Played; + public bool NormalClear; + public bool HardClear; + public bool FullCombo; + public bool AllPure; + } + + internal sealed class PlayerProgressionRepository + { + private static readonly Lazy SharedRepository = + new Lazy(() => new PlayerProgressionRepository()); + + private readonly AtomicJsonFile _file; + private PlayerProgressionDocument _document; + private HashSet _appliedEvents; + private Dictionary _milestones; + + public PlayerProgressionRepository(string path = null, Action logWarning = null) + { + _file = new AtomicJsonFile( + path ?? PersistencePaths.PlayerProfile, + document => document != null && + document.SchemaVersion >= 1 && + document.SchemaVersion <= PlayerProgressionDocument.CurrentSchemaVersion && + document.AppliedEventIds != null && + document.ChartMilestones != null, + logWarning ?? (message => Debug.LogWarning(message))); + } + + public static PlayerProgressionRepository Shared => SharedRepository.Value; + + internal void Reload() + { + _document = null; + _appliedEvents = null; + _milestones = null; + } + + public ProfileProgressSnapshot Snapshot() + { + EnsureLoaded(); + return ToSnapshot(_document); + } + + public string FeaturedCourseBadgeId + { + get + { + EnsureLoaded(); + return _document.FeaturedCourseBadgeId ?? string.Empty; + } + } + + public void SetFeaturedCourseBadge(string courseId) + { + EnsureLoaded(); + var normalized = (courseId ?? string.Empty).Trim().ToLowerInvariant(); + if (string.Equals(_document.FeaturedCourseBadgeId, normalized, StringComparison.Ordinal)) + return; + _document.FeaturedCourseBadgeId = normalized; + Save(); + } + + public ProfileProgressUpdate Record(PlayResultEvent playEvent) + { + if (playEvent == null) + throw new ArgumentNullException(nameof(playEvent)); + if (string.IsNullOrWhiteSpace(playEvent.EventId)) + throw new ArgumentException("A play event ID is required.", nameof(playEvent)); + + EnsureLoaded(); + var previous = ToSnapshot(_document); + if (_appliedEvents.Contains(playEvent.EventId)) + return new ProfileProgressUpdate(false, 0, previous, previous, false, false, false, false, false); + + _appliedEvents.Add(playEvent.EventId); + _document.AppliedEventIds.Add(playEvent.EventId); + var validPlay = playEvent.Completed || + playEvent.ActivePlaySeconds >= 30.0 && playEvent.JudgedUnits >= 20; + var firstPlay = false; + var firstNormalClear = false; + var firstHardClear = false; + var firstFullCombo = false; + var firstAllPure = false; + var xp = 0; + + if (validPlay) + { + _document.ValidPlayCount++; + _document.ActivePlaySeconds += Math.Max(0.0, playEvent.ActivePlaySeconds); + xp = ProfileLevelRules.CalculateBaseXp( + playEvent.ActivePlaySeconds, + playEvent.JudgedUnits); + if (playEvent.Cleared) + xp += playEvent.HardClear ? 30 : 20; + + var chartId = (playEvent.ChartId ?? string.Empty).Trim().ToLowerInvariant(); + if (!_milestones.TryGetValue(chartId, out var milestone)) + { + milestone = new ProfileChartMilestoneData { ChartId = chartId }; + _milestones[chartId] = milestone; + _document.ChartMilestones.Add(milestone); + } + + if (!milestone.Played) + { + milestone.Played = true; + firstPlay = true; + xp += 20; + } + if (playEvent.Cleared && !milestone.NormalClear) + { + milestone.NormalClear = true; + firstNormalClear = true; + xp += 40; + } + if (playEvent.HardClear && !milestone.HardClear) + { + milestone.HardClear = true; + firstHardClear = true; + xp += 60; + } + if (playEvent.Cleared && playEvent.MissCount == 0 && !milestone.FullCombo) + { + milestone.FullCombo = true; + firstFullCombo = true; + xp += 80; + } + if (playEvent.Cleared && playEvent.TotalScoringUnits > 0 && + playEvent.PurePerfectCount >= playEvent.TotalScoringUnits && !milestone.AllPure) + { + milestone.AllPure = true; + firstAllPure = true; + xp += 120; + } + + _document.TotalXp += xp; + _document.LastPlayedUtcTicks = playEvent.OccurredUtcTicks > 0 + ? playEvent.OccurredUtcTicks + : DateTime.UtcNow.Ticks; + if (playEvent.RatingEligible) + { + _document.LastRatingProviderId = playEvent.ProviderId ?? string.Empty; + _document.LastRatingProviderDisplayName = string.IsNullOrWhiteSpace(playEvent.ProviderDisplayName) + ? _document.LastRatingProviderId + : playEvent.ProviderDisplayName; + _document.LastRatingKeyCount = playEvent.KeyCount; + _document.LastRatingHasScratch = playEvent.HasScratch; + } + } + + Save(); + return new ProfileProgressUpdate( + true, + xp, + previous, + ToSnapshot(_document), + firstPlay, + firstNormalClear, + firstHardClear, + firstFullCombo, + firstAllPure); + } + + private void EnsureLoaded() + { + if (_document != null) + return; + + if (!_file.TryLoad(out _document, out _)) + { + _document = new PlayerProgressionDocument + { + CreatedUtcTicks = DateTime.UtcNow.Ticks, + }; + } + else if (_document.SchemaVersion < PlayerProgressionDocument.CurrentSchemaVersion) + { + _document = new PlayerProgressionDocument + { + ProfileId = string.IsNullOrWhiteSpace(_document.ProfileId) + ? PersistencePaths.DefaultProfileId + : _document.ProfileId, + DisplayName = string.IsNullOrWhiteSpace(_document.DisplayName) + ? "PLAYER" + : _document.DisplayName, + CreatedUtcTicks = _document.CreatedUtcTicks > 0 + ? _document.CreatedUtcTicks + : DateTime.UtcNow.Ticks, + }; + Save(); + } + + _document.AppliedEventIds ??= new List(); + _document.ChartMilestones ??= new List(); + _document.FeaturedCourseBadgeId ??= string.Empty; + _appliedEvents = new HashSet( + _document.AppliedEventIds.Where(value => !string.IsNullOrWhiteSpace(value)), + StringComparer.Ordinal); + _milestones = _document.ChartMilestones + .Where(item => item != null && !string.IsNullOrWhiteSpace(item.ChartId)) + .GroupBy(item => item.ChartId.Trim().ToLowerInvariant(), StringComparer.Ordinal) + .ToDictionary(group => group.Key, group => group.Last(), StringComparer.Ordinal); + } + + private void Save() + { + _document.SchemaVersion = PlayerProgressionDocument.CurrentSchemaVersion; + _document.XpRulesVersion = ProfileLevelRules.XpRulesVersion; + _file.Save(_document); + } + + private static ProfileProgressSnapshot ToSnapshot(PlayerProgressionDocument document) => + new ProfileProgressSnapshot( + document?.DisplayName, + document?.TotalXp ?? 0, + document?.ValidPlayCount ?? 0, + document?.ActivePlaySeconds ?? 0.0, + document?.LastRatingProviderId, + document?.LastRatingProviderDisplayName, + document?.LastRatingKeyCount ?? 0, + document?.LastRatingHasScratch == true); + } +} diff --git a/Assets/ProjectQuint/Runtime/Unity/Persistence/PlayerProgressionRepository.cs.meta b/Assets/ProjectQuint/Runtime/Unity/Persistence/PlayerProgressionRepository.cs.meta new file mode 100644 index 0000000..21bed9c --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Persistence/PlayerProgressionRepository.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: fe5bc7cd44cbc4b1f974edbf6a7341e6 \ No newline at end of file diff --git a/Assets/ProjectQuint/Runtime/Unity/Persistence/ProductIdentityStorageMigrator.cs b/Assets/ProjectQuint/Runtime/Unity/Persistence/ProductIdentityStorageMigrator.cs new file mode 100644 index 0000000..4f48cc4 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Persistence/ProductIdentityStorageMigrator.cs @@ -0,0 +1,128 @@ +using System; +using System.IO; +using UnityEngine; + +namespace ProjectQuint.Persistence +{ + internal enum ProductIdentityStorageMigrationStatus + { + NoLegacyData, + Migrated, + DestinationConflict, + } + + internal readonly struct ProductIdentityStorageMigrationResult + { + public ProductIdentityStorageMigrationResult( + ProductIdentityStorageMigrationStatus status, + string sourcePath, + string destinationPath) + { + Status = status; + SourcePath = sourcePath ?? string.Empty; + DestinationPath = destinationPath ?? string.Empty; + } + + public ProductIdentityStorageMigrationStatus Status { get; } + public string SourcePath { get; } + public string DestinationPath { get; } + } + + internal static class ProductIdentityStorageMigrator + { + private const string LegacyCompanyName = "DefaultCompany"; + private const string ProductName = "ProjectQuint"; + private const string UnityBootstrapDirectoryName = "Unity"; + private const string UnityTestResultsFileName = "TestResults.xml"; + + public static ProductIdentityStorageMigrationResult MigrateLegacyDefaultCompany() + { + var destination = Path.GetFullPath(Application.persistentDataPath); + var companyDirectory = Directory.GetParent(destination); + var applicationSupportDirectory = companyDirectory?.Parent; + if (applicationSupportDirectory == null) + throw new InvalidOperationException( + $"Could not resolve the macOS Application Support root from '{destination}'."); + var source = Path.Combine( + applicationSupportDirectory.FullName, + LegacyCompanyName, + ProductName); + return Migrate(source, destination); + } + + internal static ProductIdentityStorageMigrationResult Migrate( + string sourcePath, + string destinationPath) + { + if (string.IsNullOrWhiteSpace(sourcePath) || string.IsNullOrWhiteSpace(destinationPath)) + throw new ArgumentException("Legacy and current product data paths are required."); + + var source = Path.GetFullPath(sourcePath); + var destination = Path.GetFullPath(destinationPath); + if (string.Equals(source, destination, StringComparison.Ordinal) || !Directory.Exists(source)) + { + return new ProductIdentityStorageMigrationResult( + ProductIdentityStorageMigrationStatus.NoLegacyData, + source, + destination); + } + + if (Directory.Exists(destination)) + { + if (ContainsUserData(destination)) + { + return new ProductIdentityStorageMigrationResult( + ProductIdentityStorageMigrationStatus.DestinationConflict, + source, + destination); + } + + // Unity can create Insights/Analytics data and TestResults.xml before the + // first MonoBehaviour Awake. Those bootstrap artifacts must not make a new + // product identity look like an independently populated user-data root. + Directory.Delete(destination, true); + } + + var destinationParent = Path.GetDirectoryName(destination); + if (string.IsNullOrWhiteSpace(destinationParent)) + throw new InvalidOperationException($"Product data destination has no parent: {destination}"); + Directory.CreateDirectory(destinationParent); + Directory.Move(source, destination); + return new ProductIdentityStorageMigrationResult( + ProductIdentityStorageMigrationStatus.Migrated, + source, + destination); + } + + private static bool ContainsUserData(string destination) + { + foreach (var entry in Directory.EnumerateFileSystemEntries(destination)) + { + var name = Path.GetFileName(entry); + if (Directory.Exists(entry)) + { + if (string.Equals(name, UnityBootstrapDirectoryName, + StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (Directory.GetFileSystemEntries(entry).Length == 0) + continue; + + return true; + } + + if (string.Equals(name, UnityTestResultsFileName, + StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + return true; + } + + return false; + } + } +} diff --git a/Assets/ProjectQuint/Runtime/Unity/Persistence/ProductIdentityStorageMigrator.cs.meta b/Assets/ProjectQuint/Runtime/Unity/Persistence/ProductIdentityStorageMigrator.cs.meta new file mode 100644 index 0000000..43c2860 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Persistence/ProductIdentityStorageMigrator.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 8bab4fcd7da9f47f39c919c06994e909 \ No newline at end of file diff --git a/Assets/ProjectQuint/Runtime/Unity/Persistence/ProfileArchiveService.cs b/Assets/ProjectQuint/Runtime/Unity/Persistence/ProfileArchiveService.cs new file mode 100644 index 0000000..f033495 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Persistence/ProfileArchiveService.cs @@ -0,0 +1,580 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using UnityEngine; + +namespace ProjectQuint.Persistence +{ + [Serializable] + internal sealed class ProfileArchiveManifest + { + public const int CurrentFormatVersion = 1; + public const string FormatName = "projectquint-profile-export"; + + public string format = FormatName; + public int formatVersion = CurrentFormatVersion; + public string exportId = string.Empty; + public string createdAtUtc = string.Empty; + public string appVersion = string.Empty; + public ProfileArchiveIdentity profile = new ProfileArchiveIdentity(); + public List documents = new List(); + } + + [Serializable] + internal sealed class ProfileArchiveIdentity + { + public string id = PersistencePaths.DefaultProfileId; + public string displayName = "PLAYER"; + } + + [Serializable] + internal sealed class ProfileArchiveDocumentEntry + { + public string path = string.Empty; + public int schemaVersion; + public string sha256 = string.Empty; + public long bytes; + } + + [Serializable] + internal sealed class ProfileImportJournal + { + public const int CurrentSchemaVersion = 1; + + public int SchemaVersion = CurrentSchemaVersion; + public string ProfileId = PersistencePaths.DefaultProfileId; + public string TargetPath = string.Empty; + public string StagingPath = string.Empty; + public string BackupPath = string.Empty; + } + + internal readonly struct ProfileArchiveResult + { + private ProfileArchiveResult(bool success, string status, string archivePath, string backupPath) + { + Success = success; + Status = status ?? string.Empty; + ArchivePath = archivePath ?? string.Empty; + BackupPath = backupPath ?? string.Empty; + } + + public bool Success { get; } + public string Status { get; } + public string ArchivePath { get; } + public string BackupPath { get; } + + public static ProfileArchiveResult Succeeded(string status, string archivePath, string backupPath = "") => + new ProfileArchiveResult(true, status, archivePath, backupPath); + + public static ProfileArchiveResult Failed(string status, string archivePath = "") => + new ProfileArchiveResult(false, status, archivePath, string.Empty); + } + + internal sealed class ProfileArchiveService + { + public const string FileExtension = ".pqprofile"; + private const string ManifestPath = "manifest.json"; + private const long MaximumManifestBytes = 1024 * 1024; + private const long MaximumDocumentBytes = 32L * 1024 * 1024; + private const long MaximumArchiveContentBytes = 128L * 1024 * 1024; + + private static readonly ProfileDocumentDefinition[] DocumentDefinitions = + { + new ProfileDocumentDefinition("profile/profile.json", "profile.json"), + new ProfileDocumentDefinition("profile/records.json", "records.json"), + new ProfileDocumentDefinition("profile/courses.json", "courses.json"), + new ProfileDocumentDefinition("profile/favorites.json", "favorites.json"), + }; + + private readonly string _root; + private readonly Func _utcNow; + private readonly string _appVersion; + + public ProfileArchiveService( + string root = null, + Func utcNow = null, + string appVersion = null) + { + _root = string.IsNullOrWhiteSpace(root) ? PersistencePaths.Root : Path.GetFullPath(root); + _utcNow = utcNow ?? (() => DateTime.UtcNow); + _appVersion = string.IsNullOrWhiteSpace(appVersion) ? Application.version : appVersion; + } + + public ProfileArchiveResult Export(string profileId, string destinationPath) + { + try + { + profileId = NormalizeProfileId(profileId); + destinationPath = NormalizeExportPath(destinationPath); + var sourceRoot = ProfileRoot(profileId); + var payloads = new Dictionary(StringComparer.Ordinal); + foreach (var definition in DocumentDefinitions) + payloads.Add( + definition.ArchivePath, + LoadDocumentForExport(sourceRoot, definition, profileId)); + + var progression = JsonUtility.FromJson( + Encoding.UTF8.GetString(payloads["profile/profile.json"].Bytes)); + var manifest = new ProfileArchiveManifest + { + exportId = Guid.NewGuid().ToString("D"), + createdAtUtc = _utcNow().ToUniversalTime().ToString("O"), + appVersion = _appVersion, + profile = new ProfileArchiveIdentity + { + id = profileId, + displayName = string.IsNullOrWhiteSpace(progression?.DisplayName) + ? "PLAYER" + : progression.DisplayName, + }, + }; + foreach (var definition in DocumentDefinitions) + { + var payload = payloads[definition.ArchivePath]; + manifest.documents.Add(new ProfileArchiveDocumentEntry + { + path = definition.ArchivePath, + schemaVersion = payload.SchemaVersion, + sha256 = Sha256(payload.Bytes), + bytes = payload.Bytes.LongLength, + }); + } + + var destinationDirectory = Path.GetDirectoryName(destinationPath); + if (!string.IsNullOrWhiteSpace(destinationDirectory)) + Directory.CreateDirectory(destinationDirectory); + var temporaryPath = destinationPath + ".tmp-" + Guid.NewGuid().ToString("N"); + try + { + using (var stream = new FileStream( + temporaryPath, + FileMode.CreateNew, + FileAccess.ReadWrite, + FileShare.None, + 4096, + FileOptions.WriteThrough)) + using (var archive = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: false)) + { + foreach (var definition in DocumentDefinitions) + WriteEntry(archive, definition.ArchivePath, payloads[definition.ArchivePath].Bytes); + WriteEntry( + archive, + ManifestPath, + new UTF8Encoding(false).GetBytes(JsonUtility.ToJson(manifest, true))); + } + if (File.Exists(destinationPath)) + File.Replace(temporaryPath, destinationPath, destinationPath + ".bak"); + else + File.Move(temporaryPath, destinationPath); + } + finally + { + if (File.Exists(temporaryPath)) + File.Delete(temporaryPath); + } + + return ProfileArchiveResult.Succeeded( + $"PROFILE EXPORTED // {Path.GetFileName(destinationPath)}", + destinationPath); + } + catch (Exception exception) + { + return ProfileArchiveResult.Failed($"PROFILE EXPORT FAILED // {exception.Message}", destinationPath); + } + } + + public ProfileArchiveResult Import(string archivePath, string targetProfileId) + { + string stagingPath = null; + string backupPath = null; + string targetPath = null; + var targetMoved = false; + try + { + targetProfileId = NormalizeProfileId(targetProfileId); + archivePath = Path.GetFullPath(archivePath ?? string.Empty); + if (!File.Exists(archivePath)) + throw new FileNotFoundException("Profile archive was not found.", archivePath); + if (!string.Equals(Path.GetExtension(archivePath), FileExtension, StringComparison.OrdinalIgnoreCase)) + throw new InvalidDataException($"Profile archive must use the {FileExtension} extension."); + + RecoverInterruptedImport(targetProfileId); + var imported = ReadAndValidateArchive(archivePath); + var profilesRoot = ProfilesRoot; + Directory.CreateDirectory(profilesRoot); + targetPath = ProfileRoot(targetProfileId); + stagingPath = Path.Combine(profilesRoot, $".{targetProfileId}.import-{Guid.NewGuid():N}"); + Directory.CreateDirectory(stagingPath); + foreach (var definition in DocumentDefinitions) + WriteFileDurably( + Path.Combine(stagingPath, definition.FileName), + imported[definition.ArchivePath].Bytes); + + backupPath = BuildBackupPath(targetProfileId); + var journal = new ProfileImportJournal + { + ProfileId = targetProfileId, + TargetPath = targetPath, + StagingPath = stagingPath, + BackupPath = backupPath, + }; + WriteJournal(journal); + + if (Directory.Exists(targetPath)) + { + Directory.CreateDirectory(Path.GetDirectoryName(backupPath)); + Directory.Move(targetPath, backupPath); + targetMoved = true; + } + Directory.Move(stagingPath, targetPath); + stagingPath = null; + DeleteJournal(); + return ProfileArchiveResult.Succeeded( + targetMoved + ? $"PROFILE IMPORTED // BACKUP {Path.GetFileName(backupPath)}" + : "PROFILE IMPORTED // NEW LOCAL PROFILE", + archivePath, + targetMoved ? backupPath : string.Empty); + } + catch (Exception exception) + { + if (targetMoved && !string.IsNullOrWhiteSpace(targetPath) && !Directory.Exists(targetPath) && + !string.IsNullOrWhiteSpace(backupPath) && Directory.Exists(backupPath)) + Directory.Move(backupPath, targetPath); + DeleteJournal(); + if (!string.IsNullOrWhiteSpace(stagingPath) && Directory.Exists(stagingPath)) + Directory.Delete(stagingPath, true); + return ProfileArchiveResult.Failed($"PROFILE IMPORT FAILED // {exception.Message}", archivePath); + } + } + + internal void RecoverInterruptedImport(string profileId) + { + profileId = NormalizeProfileId(profileId); + if (!File.Exists(JournalPath)) + return; + + var journal = JsonUtility.FromJson(File.ReadAllText(JournalPath, Encoding.UTF8)); + if (journal == null || journal.SchemaVersion != ProfileImportJournal.CurrentSchemaVersion || + !string.Equals(journal.ProfileId, profileId, StringComparison.Ordinal) || + !IsExpectedTargetPath(journal.TargetPath, profileId) || + !IsExpectedStagingPath(journal.StagingPath, profileId) || + !IsExpectedBackupPath(journal.BackupPath, profileId)) + throw new InvalidDataException("Profile import journal is invalid."); + + if (Directory.Exists(journal.TargetPath)) + { + if (Directory.Exists(journal.StagingPath)) + Directory.Delete(journal.StagingPath, true); + DeleteJournal(); + return; + } + if (Directory.Exists(journal.StagingPath)) + { + Directory.Move(journal.StagingPath, journal.TargetPath); + DeleteJournal(); + return; + } + if (Directory.Exists(journal.BackupPath)) + { + Directory.Move(journal.BackupPath, journal.TargetPath); + DeleteJournal(); + return; + } + throw new InvalidDataException("Profile import journal cannot recover its target, staging or backup."); + } + + private Dictionary ReadAndValidateArchive(string archivePath) + { + using var stream = File.OpenRead(archivePath); + using var archive = new ZipArchive(stream, ZipArchiveMode.Read, leaveOpen: false); + var allowed = new HashSet( + DocumentDefinitions.Select(item => item.ArchivePath).Append(ManifestPath), + StringComparer.Ordinal); + var entries = new Dictionary(StringComparer.Ordinal); + foreach (var entry in archive.Entries) + { + if (!allowed.Contains(entry.FullName) || !entries.TryAdd(entry.FullName, entry)) + throw new InvalidDataException($"Unexpected or duplicate archive entry '{entry.FullName}'."); + } + if (entries.Count != allowed.Count || allowed.Any(path => !entries.ContainsKey(path))) + throw new InvalidDataException("Profile archive is missing required entries."); + + var manifestBytes = ReadEntryBounded(entries[ManifestPath], MaximumManifestBytes); + var manifest = JsonUtility.FromJson(Encoding.UTF8.GetString(manifestBytes)); + if (manifest == null || !string.Equals(manifest.format, ProfileArchiveManifest.FormatName, StringComparison.Ordinal) || + manifest.formatVersion != ProfileArchiveManifest.CurrentFormatVersion || manifest.profile == null || + manifest.documents == null) + throw new InvalidDataException("Profile archive manifest format is unsupported."); + NormalizeProfileId(manifest.profile.id); + + var manifestEntries = manifest.documents + .Where(item => item != null && !string.IsNullOrWhiteSpace(item.path)) + .GroupBy(item => item.path, StringComparer.Ordinal) + .ToDictionary(group => group.Key, group => group.ToArray(), StringComparer.Ordinal); + if (manifestEntries.Count != DocumentDefinitions.Length || manifestEntries.Values.Any(group => group.Length != 1)) + throw new InvalidDataException("Profile archive manifest document list is invalid."); + + var result = new Dictionary(StringComparer.Ordinal); + long totalBytes = 0; + foreach (var definition in DocumentDefinitions) + { + if (!manifestEntries.TryGetValue(definition.ArchivePath, out var listed)) + throw new InvalidDataException($"Manifest is missing '{definition.ArchivePath}'."); + var manifestEntry = listed[0]; + var bytes = ReadEntryBounded(entries[definition.ArchivePath], MaximumDocumentBytes); + totalBytes = checked(totalBytes + bytes.LongLength); + if (totalBytes > MaximumArchiveContentBytes) + throw new InvalidDataException("Profile archive exceeds the uncompressed size limit."); + var schemaVersion = ValidateDocument(definition.FileName, bytes); + if (manifestEntry.bytes != bytes.LongLength || manifestEntry.schemaVersion != schemaVersion || + !string.Equals(manifestEntry.sha256, Sha256(bytes), StringComparison.OrdinalIgnoreCase)) + throw new InvalidDataException($"Manifest integrity check failed for '{definition.ArchivePath}'."); + result.Add(definition.ArchivePath, new ProfileDocumentPayload(bytes, schemaVersion)); + } + return result; + } + + private ProfileDocumentPayload LoadDocumentForExport( + string profileRoot, + ProfileDocumentDefinition definition, + string profileId) + { + var path = Path.Combine(profileRoot, definition.FileName); + foreach (var candidate in new[] { path, path + ".bak" }) + { + if (!File.Exists(candidate)) + continue; + try + { + var bytes = File.ReadAllBytes(candidate); + return new ProfileDocumentPayload(bytes, ValidateDocument(definition.FileName, bytes)); + } + catch when (!string.Equals(candidate, path + ".bak", StringComparison.Ordinal)) + { + // The backup candidate is validated next. If it also fails, export fails explicitly. + } + } + + if (File.Exists(path) || File.Exists(path + ".bak")) + throw new InvalidDataException($"'{definition.FileName}' and its backup are invalid."); + var defaultBytes = CreateDefaultDocument(definition.FileName, profileId); + return new ProfileDocumentPayload(defaultBytes, ValidateDocument(definition.FileName, defaultBytes)); + } + + private byte[] CreateDefaultDocument(string fileName, string profileId) + { + object document = fileName switch + { + "profile.json" => new PlayerProgressionDocument + { + ProfileId = profileId, + CreatedUtcTicks = _utcNow().ToUniversalTime().Ticks, + }, + "records.json" => new SongRecordsDocument(), + "courses.json" => new CourseRecordsDocument(), + "favorites.json" => new FavoriteChartsDocument(), + _ => throw new InvalidDataException($"Unsupported profile document '{fileName}'."), + }; + return new UTF8Encoding(false).GetBytes(JsonUtility.ToJson(document, true)); + } + + private static int ValidateDocument(string fileName, byte[] bytes) + { + if (bytes == null || bytes.LongLength == 0 || bytes.LongLength > MaximumDocumentBytes) + throw new InvalidDataException($"'{fileName}' has an invalid size."); + var json = Encoding.UTF8.GetString(bytes); + switch (fileName) + { + case "profile.json": + { + var document = JsonUtility.FromJson(json); + if (document == null || document.SchemaVersion != PlayerProgressionDocument.CurrentSchemaVersion || + document.AppliedEventIds == null || document.ChartMilestones == null) + throw new InvalidDataException("profile.json schema is unsupported."); + return document.SchemaVersion; + } + case "records.json": + { + var document = JsonUtility.FromJson(json); + if (document == null || document.SchemaVersion < 3 || + document.SchemaVersion > SongRecordsDocument.CurrentSchemaVersion || document.Records == null) + throw new InvalidDataException("records.json schema is unsupported."); + return document.SchemaVersion; + } + case "courses.json": + { + var document = JsonUtility.FromJson(json); + if (document == null || document.SchemaVersion < 3 || + document.SchemaVersion > CourseRecordsDocument.CurrentSchemaVersion || document.Records == null) + throw new InvalidDataException("courses.json schema is unsupported."); + return document.SchemaVersion; + } + case "favorites.json": + { + var document = JsonUtility.FromJson(json); + if (document == null || document.SchemaVersion != FavoriteChartsDocument.CurrentSchemaVersion || + document.ChartIds == null) + throw new InvalidDataException("favorites.json schema is unsupported."); + return document.SchemaVersion; + } + default: + throw new InvalidDataException($"Unsupported profile document '{fileName}'."); + } + } + + private void WriteJournal(ProfileImportJournal journal) + { + Directory.CreateDirectory(ProfilesRoot); + WriteFileDurably(JournalPath, new UTF8Encoding(false).GetBytes(JsonUtility.ToJson(journal, true))); + } + + private void DeleteJournal() + { + if (File.Exists(JournalPath)) + File.Delete(JournalPath); + } + + private string BuildBackupPath(string profileId) + { + var root = Path.Combine(ProfilesRoot, ".backups"); + var stamp = _utcNow().ToUniversalTime().ToString("yyyyMMdd-HHmmss-fff"); + var candidate = Path.Combine(root, $"{profileId}-{stamp}"); + for (var suffix = 1; Directory.Exists(candidate); suffix++) + candidate = Path.Combine(root, $"{profileId}-{stamp}-{suffix}"); + return candidate; + } + + private bool IsExpectedTargetPath(string path, string profileId) => + PathsEqual(path, ProfileRoot(profileId)); + + private bool IsExpectedStagingPath(string path, string profileId) + { + if (!TryGetSafeChild(path, ProfilesRoot, out var name)) + return false; + return name.StartsWith($".{profileId}.import-", StringComparison.Ordinal) && + name.Length > profileId.Length + ".import-".Length + 1; + } + + private bool IsExpectedBackupPath(string path, string profileId) => + TryGetSafeChild(path, Path.Combine(ProfilesRoot, ".backups"), out var name) && + name.StartsWith(profileId + "-", StringComparison.Ordinal) && + name.Length > profileId.Length + 1; + + private static bool TryGetSafeChild(string path, string parent, out string name) + { + name = string.Empty; + if (string.IsNullOrWhiteSpace(path) || string.IsNullOrWhiteSpace(parent)) + return false; + var full = Path.GetFullPath(path); + var fullParent = Path.GetFullPath(parent); + if (!string.Equals(Path.GetDirectoryName(full), fullParent, StringComparison.Ordinal)) + return false; + name = Path.GetFileName(full); + return name.Length > 0; + } + + private static bool PathsEqual(string left, string right) => + !string.IsNullOrWhiteSpace(left) && !string.IsNullOrWhiteSpace(right) && + string.Equals(Path.GetFullPath(left), Path.GetFullPath(right), StringComparison.Ordinal); + + private string ProfilesRoot => Path.Combine(_root, "profiles"); + private string JournalPath => Path.Combine(ProfilesRoot, ".profile-import-journal.json"); + private string ProfileRoot(string profileId) => Path.Combine(ProfilesRoot, profileId); + + private static string NormalizeProfileId(string profileId) + { + var value = (profileId ?? string.Empty).Trim(); + if (value.Length == 0) + throw new ArgumentException("A profile ID is required.", nameof(profileId)); + foreach (var character in value) + if (!char.IsLetterOrDigit(character) && character != '-' && character != '_') + throw new ArgumentException($"Invalid profile ID '{profileId}'.", nameof(profileId)); + return value; + } + + private static string NormalizeExportPath(string path) + { + if (string.IsNullOrWhiteSpace(path)) + throw new ArgumentException("An export destination is required.", nameof(path)); + var normalized = Path.GetFullPath(path.Trim()); + return string.Equals(Path.GetExtension(normalized), FileExtension, StringComparison.OrdinalIgnoreCase) + ? normalized + : normalized + FileExtension; + } + + private static byte[] ReadEntryBounded(ZipArchiveEntry entry, long maximumBytes) + { + if (entry == null || entry.Length < 0 || entry.Length > maximumBytes) + throw new InvalidDataException($"Archive entry '{entry?.FullName}' exceeds its size limit."); + using var source = entry.Open(); + using var destination = new MemoryStream((int)Math.Min(entry.Length, int.MaxValue)); + var buffer = new byte[81920]; + int read; + while ((read = source.Read(buffer, 0, buffer.Length)) > 0) + { + if (destination.Length + read > maximumBytes) + throw new InvalidDataException($"Archive entry '{entry.FullName}' exceeds its size limit."); + destination.Write(buffer, 0, read); + } + return destination.ToArray(); + } + + private static void WriteEntry(ZipArchive archive, string path, byte[] bytes) + { + var entry = archive.CreateEntry(path, System.IO.Compression.CompressionLevel.Optimal); + using var stream = entry.Open(); + stream.Write(bytes, 0, bytes.Length); + } + + private static void WriteFileDurably(string path, byte[] bytes) + { + var directory = Path.GetDirectoryName(path); + if (!string.IsNullOrWhiteSpace(directory)) + Directory.CreateDirectory(directory); + using var stream = new FileStream( + path, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + 4096, + FileOptions.WriteThrough); + stream.Write(bytes, 0, bytes.Length); + stream.Flush(true); + } + + private static string Sha256(byte[] bytes) + { + using var sha = SHA256.Create(); + return string.Concat(sha.ComputeHash(bytes).Select(value => value.ToString("x2"))); + } + + private readonly struct ProfileDocumentDefinition + { + public ProfileDocumentDefinition(string archivePath, string fileName) + { + ArchivePath = archivePath; + FileName = fileName; + } + + public string ArchivePath { get; } + public string FileName { get; } + } + + private readonly struct ProfileDocumentPayload + { + public ProfileDocumentPayload(byte[] bytes, int schemaVersion) + { + Bytes = bytes; + SchemaVersion = schemaVersion; + } + + public byte[] Bytes { get; } + public int SchemaVersion { get; } + } + } +} diff --git a/Assets/ProjectQuint/Runtime/Unity/Persistence/ProfileArchiveService.cs.meta b/Assets/ProjectQuint/Runtime/Unity/Persistence/ProfileArchiveService.cs.meta new file mode 100644 index 0000000..a0adaf6 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Persistence/ProfileArchiveService.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 5e106eda63b324fe391a950b76ec3c8d \ No newline at end of file diff --git a/Assets/ProjectQuint/Runtime/Unity/Persistence/ProfileImageStore.cs b/Assets/ProjectQuint/Runtime/Unity/Persistence/ProfileImageStore.cs new file mode 100644 index 0000000..7681a97 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Persistence/ProfileImageStore.cs @@ -0,0 +1,250 @@ +using System; +using System.IO; +using UnityEngine; + +namespace ProjectQuint.Persistence +{ + internal readonly struct ProfileImageOperationResult + { + public ProfileImageOperationResult( + bool success, + string status, + Texture2D texture = null, + string recoveryPath = null) + { + Success = success; + Status = status ?? string.Empty; + Texture = texture; + RecoveryPath = recoveryPath ?? string.Empty; + } + + public bool Success { get; } + public string Status { get; } + public Texture2D Texture { get; } + public string RecoveryPath { get; } + } + + /// + /// Owns the local profile avatar file. Imports are normalized to a centered, + /// square 256 px PNG so retained UI never decodes an arbitrary full-size photo. + /// + internal sealed class ProfileImageStore + { + internal const int OutputSize = 256; + internal const int MaximumSourceDimension = 8192; + internal const long MaximumSourceBytes = 32L * 1024L * 1024L; + + private readonly string _imagePath; + private readonly Func _utcNow; + + public ProfileImageStore( + string imagePath = null, + Func utcNow = null) + { + _imagePath = Path.GetFullPath( + string.IsNullOrWhiteSpace(imagePath) + ? PersistencePaths.ProfileImage + : imagePath); + _utcNow = utcNow ?? (() => DateTime.UtcNow); + } + + public string ImagePath => _imagePath; + + public bool TryLoad(out Texture2D texture, out string error) + { + texture = null; + error = string.Empty; + if (!File.Exists(_imagePath)) + return false; + + try + { + var file = new FileInfo(_imagePath); + if (file.Length <= 0 || file.Length > MaximumSourceBytes) + throw new InvalidDataException("The saved profile photo has an invalid file size."); + + var bytes = File.ReadAllBytes(_imagePath); + texture = Decode(bytes, markNonReadable: true); + if (texture.width != OutputSize || texture.height != OutputSize) + throw new InvalidDataException( + $"The saved profile photo must be {OutputSize} × {OutputSize} pixels."); + texture.name = "ProjectQuint Profile Avatar"; + return true; + } + catch (Exception exception) + { + DestroyTexture(texture); + texture = null; + error = exception.Message; + return false; + } + } + + public ProfileImageOperationResult Import(string sourcePath) + { + Texture2D source = null; + Texture2D normalized = null; + try + { + if (string.IsNullOrWhiteSpace(sourcePath)) + return Failure("PHOTO IMPORT CANCELLED"); + + var fullSourcePath = Path.GetFullPath(sourcePath); + if (!File.Exists(fullSourcePath)) + return Failure("PHOTO FILE NOT FOUND"); + if (!IsSupportedExtension(Path.GetExtension(fullSourcePath))) + return Failure("PHOTO MUST BE PNG OR JPEG"); + + var file = new FileInfo(fullSourcePath); + if (file.Length <= 0) + return Failure("PHOTO FILE IS EMPTY"); + if (file.Length > MaximumSourceBytes) + return Failure("PHOTO IS LARGER THAN 32 MB"); + + source = Decode(File.ReadAllBytes(fullSourcePath), markNonReadable: false); + if (source.width <= 0 || source.height <= 0 || + source.width > MaximumSourceDimension || + source.height > MaximumSourceDimension) + { + return Failure("PHOTO DIMENSIONS MUST BE BETWEEN 1 AND 8192 PX"); + } + + normalized = CenterCrop(source); + var png = ImageConversion.EncodeToPNG(normalized); + if (png == null || png.Length == 0) + throw new InvalidDataException("Could not encode the cropped profile photo."); + + WriteAtomically(png); + normalized.name = "ProjectQuint Profile Avatar"; + normalized.Apply(updateMipmaps: false, makeNoLongerReadable: true); + var result = new ProfileImageOperationResult( + success: true, + status: "PROFILE PHOTO UPDATED", + texture: normalized); + normalized = null; + return result; + } + catch (Exception exception) + { + return Failure($"PHOTO IMPORT FAILED // {exception.Message}"); + } + finally + { + DestroyTexture(source); + DestroyTexture(normalized); + } + } + + public ProfileImageOperationResult Reset() + { + try + { + if (!File.Exists(_imagePath)) + return new ProfileImageOperationResult(true, "USING DEFAULT K MONOGRAM"); + + var directory = Path.GetDirectoryName(_imagePath); + if (string.IsNullOrWhiteSpace(directory)) + throw new InvalidDataException("The profile photo directory is unavailable."); + Directory.CreateDirectory(directory); + var recoveryPath = Path.Combine( + directory, + $"avatar.removed-{_utcNow():yyyyMMdd-HHmmssfff}-{Guid.NewGuid():N}.png"); + File.Move(_imagePath, recoveryPath); + return new ProfileImageOperationResult( + success: true, + status: "PROFILE PHOTO RESET TO K", + recoveryPath: recoveryPath); + } + catch (Exception exception) + { + return Failure($"PHOTO RESET FAILED // {exception.Message}"); + } + } + + private static ProfileImageOperationResult Failure(string status) => + new ProfileImageOperationResult(false, status); + + private static bool IsSupportedExtension(string extension) => + string.Equals(extension, ".png", StringComparison.OrdinalIgnoreCase) || + string.Equals(extension, ".jpg", StringComparison.OrdinalIgnoreCase) || + string.Equals(extension, ".jpeg", StringComparison.OrdinalIgnoreCase) || + string.Equals(extension, ".jfif", StringComparison.OrdinalIgnoreCase); + + private static Texture2D Decode(byte[] bytes, bool markNonReadable) + { + var texture = new Texture2D(2, 2, TextureFormat.RGBA32, mipChain: false); + if (!ImageConversion.LoadImage(texture, bytes, markNonReadable)) + { + DestroyTexture(texture); + throw new InvalidDataException("The selected file is not a readable PNG or JPEG image."); + } + return texture; + } + + private static Texture2D CenterCrop(Texture2D source) + { + var cropSize = Math.Min(source.width, source.height); + var cropX = (source.width - cropSize) * 0.5f; + var cropY = (source.height - cropSize) * 0.5f; + var output = new Texture2D( + OutputSize, + OutputSize, + TextureFormat.RGBA32, + mipChain: false); + var pixels = new Color[OutputSize * OutputSize]; + for (var y = 0; y < OutputSize; y++) + { + var sourceY = (cropY + (y + 0.5f) * cropSize / OutputSize) / source.height; + for (var x = 0; x < OutputSize; x++) + { + var sourceX = (cropX + (x + 0.5f) * cropSize / OutputSize) / source.width; + pixels[y * OutputSize + x] = source.GetPixelBilinear(sourceX, sourceY); + } + } + output.SetPixels(pixels); + output.Apply(updateMipmaps: false, makeNoLongerReadable: false); + return output; + } + + private void WriteAtomically(byte[] png) + { + var directory = Path.GetDirectoryName(_imagePath); + if (string.IsNullOrWhiteSpace(directory)) + throw new InvalidDataException("The profile photo directory is unavailable."); + Directory.CreateDirectory(directory); + var temporaryPath = Path.Combine( + directory, + $".avatar.importing-{Guid.NewGuid():N}.png"); + try + { + File.WriteAllBytes(temporaryPath, png); + if (File.Exists(_imagePath)) + { + var backupPath = Path.Combine( + directory, + $"avatar.replaced-{_utcNow():yyyyMMdd-HHmmssfff}-{Guid.NewGuid():N}.png"); + File.Replace(temporaryPath, _imagePath, backupPath); + } + else + { + File.Move(temporaryPath, _imagePath); + } + } + finally + { + if (File.Exists(temporaryPath)) + File.Delete(temporaryPath); + } + } + + private static void DestroyTexture(Texture2D texture) + { + if (texture == null) + return; + if (Application.isPlaying) + UnityEngine.Object.Destroy(texture); + else + UnityEngine.Object.DestroyImmediate(texture); + } + } +} diff --git a/Assets/ProjectQuint/Runtime/Unity/Persistence/ProfileImageStore.cs.meta b/Assets/ProjectQuint/Runtime/Unity/Persistence/ProfileImageStore.cs.meta new file mode 100644 index 0000000..c980f36 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Persistence/ProfileImageStore.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 0485f48def194877b8a9adb51649d521 diff --git a/Assets/ProjectQuint/Runtime/Unity/Persistence/ProfileStorageMigrator.cs b/Assets/ProjectQuint/Runtime/Unity/Persistence/ProfileStorageMigrator.cs new file mode 100644 index 0000000..295f428 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Persistence/ProfileStorageMigrator.cs @@ -0,0 +1,140 @@ +using System; +using System.Collections.Generic; +using System.IO; +using UnityEngine; + +namespace ProjectQuint.Persistence +{ + [Serializable] + internal sealed class ProfileStorageMigrationDocument + { + public const int CurrentSchemaVersion = 1; + public const string CurrentMigrationId = "global-to-default-profile-v1"; + + public int SchemaVersion = CurrentSchemaVersion; + public string MigrationId = CurrentMigrationId; + public string ProfileId = PersistencePaths.DefaultProfileId; + public long CompletedUtcTicks; + public List CopiedFiles = new List(); + } + + internal readonly struct ProfileStorageMigrationResult + { + public ProfileStorageMigrationResult(bool alreadyCompleted, int copiedFileCount, string markerPath) + { + AlreadyCompleted = alreadyCompleted; + CopiedFileCount = Math.Max(0, copiedFileCount); + MarkerPath = markerPath ?? string.Empty; + } + + public bool AlreadyCompleted { get; } + public int CopiedFileCount { get; } + public string MarkerPath { get; } + } + + internal static class ProfileStorageMigrator + { + private static readonly string[] ProfileOwnedFiles = + { + "records.json", + "courses.json", + "favorites.json", + }; + + public static ProfileStorageMigrationResult MigrateDefaultProfile( + string root = null, + Action logWarning = null) + { + var persistenceRoot = string.IsNullOrWhiteSpace(root) + ? PersistencePaths.Root + : Path.GetFullPath(root); + var profileRoot = PersistencePaths.ProfileRootFor( + persistenceRoot, + PersistencePaths.DefaultProfileId); + var markerPath = Path.Combine(profileRoot, "profile-storage-migration.json"); + var warning = logWarning ?? (message => Debug.LogWarning(message)); + var marker = new AtomicJsonFile( + markerPath, + document => document != null && + document.SchemaVersion == ProfileStorageMigrationDocument.CurrentSchemaVersion && + string.Equals( + document.MigrationId, + ProfileStorageMigrationDocument.CurrentMigrationId, + StringComparison.Ordinal) && + document.CopiedFiles != null, + warning); + if (marker.TryLoad(out _, out _)) + return new ProfileStorageMigrationResult(true, 0, markerPath); + + Directory.CreateDirectory(profileRoot); + var copiedFiles = new List(); + foreach (var fileName in ProfileOwnedFiles) + { + CopyIfSourceExistsAndTargetIsMissing( + Path.Combine(persistenceRoot, fileName), + Path.Combine(profileRoot, fileName), + fileName, + copiedFiles); + CopyIfSourceExistsAndTargetIsMissing( + Path.Combine(persistenceRoot, fileName + ".bak"), + Path.Combine(profileRoot, fileName + ".bak"), + fileName + ".bak", + copiedFiles); + } + + marker.Save(new ProfileStorageMigrationDocument + { + ProfileId = PersistencePaths.DefaultProfileId, + CompletedUtcTicks = DateTime.UtcNow.Ticks, + CopiedFiles = copiedFiles, + }); + return new ProfileStorageMigrationResult(false, copiedFiles.Count, markerPath); + } + + private static void CopyIfSourceExistsAndTargetIsMissing( + string sourcePath, + string destinationPath, + string displayName, + ICollection copiedFiles) + { + if (!File.Exists(sourcePath) || File.Exists(destinationPath)) + return; + + var directory = Path.GetDirectoryName(destinationPath); + if (!string.IsNullOrWhiteSpace(directory)) + Directory.CreateDirectory(directory); + var temporaryPath = destinationPath + ".migration.tmp"; + if (File.Exists(temporaryPath)) + File.Delete(temporaryPath); + + try + { + using (var source = new FileStream( + sourcePath, + FileMode.Open, + FileAccess.Read, + FileShare.Read, + 81920, + FileOptions.SequentialScan)) + using (var destination = new FileStream( + temporaryPath, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + 81920, + FileOptions.WriteThrough)) + { + source.CopyTo(destination); + destination.Flush(true); + } + File.Move(temporaryPath, destinationPath); + copiedFiles.Add(displayName ?? Path.GetFileName(destinationPath)); + } + finally + { + if (File.Exists(temporaryPath)) + File.Delete(temporaryPath); + } + } + } +} diff --git a/Assets/ProjectQuint/Runtime/Unity/Persistence/ProfileStorageMigrator.cs.meta b/Assets/ProjectQuint/Runtime/Unity/Persistence/ProfileStorageMigrator.cs.meta new file mode 100644 index 0000000..6e2b0d9 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Persistence/ProfileStorageMigrator.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7083e549418184d3a850b86f2278d6f5 \ No newline at end of file diff --git a/Assets/ProjectQuint/Runtime/Unity/Persistence/SongRecordRepository.cs b/Assets/ProjectQuint/Runtime/Unity/Persistence/SongRecordRepository.cs new file mode 100644 index 0000000..2473e9b --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Persistence/SongRecordRepository.cs @@ -0,0 +1,606 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using ProjectQuint.Core.Progression; +using ProjectQuint.Library; +using UnityEngine; + +namespace ProjectQuint.Persistence +{ + [Serializable] + internal sealed class SongRecordsDocument + { + public const int CurrentSchemaVersion = 9; + + public int SchemaVersion = CurrentSchemaVersion; + public List Records = new List(); + } + + [Serializable] + internal sealed class SongRecordData + { + public string ChartId = string.Empty; + public string PackId = string.Empty; + public string PackDisplayName = string.Empty; + public string ProviderId = string.Empty; + public string ProviderDisplayName = string.Empty; + public string DifficultyLabel = string.Empty; + public string DifficultyValue = string.Empty; + public int DifficultySortIndex = int.MaxValue; + public string Title = string.Empty; + public string Artist = string.Empty; + public int KeyCount; + public bool HasScratch; + public SongPlayRecord PlayRecord = new SongPlayRecord(); + public int ClearLamp; + } + + internal sealed class SongRecordRepository + { + private static readonly Lazy SharedRepository = + new Lazy(() => new SongRecordRepository()); + + private readonly AtomicJsonFile _file; + private readonly Action _logWarning; + private Dictionary _records; + + public SongRecordRepository(string path = null, Action logWarning = null) + { + _logWarning = logWarning ?? (message => Debug.LogWarning(message)); + _file = new AtomicJsonFile( + path ?? PersistencePaths.SongRecords, + document => document != null && + document.SchemaVersion >= 1 && + document.SchemaVersion <= SongRecordsDocument.CurrentSchemaVersion && + document.Records != null, + _logWarning); + } + + public static SongRecordRepository Shared => SharedRepository.Value; + + internal void Reload() + { + _records = null; + } + + public SongPlayRecord LoadPlayRecord(SongLibraryEntry entry) + { + if (entry == null) + return new SongPlayRecord(); + + EnsureLoaded(); + var chartId = ChartIdentityResolver.Resolve(entry).Value; + if (_records.TryGetValue(chartId, out var stored) && stored.PlayRecord?.HasRecord == true) + return stored.PlayRecord ?? new SongPlayRecord(); + return stored?.PlayRecord ?? new SongPlayRecord(); + } + + public SongClearLamp LoadClearLamp(SongLibraryEntry entry) + { + if (entry == null) + return SongClearLamp.None; + + EnsureLoaded(); + var chartId = ChartIdentityResolver.Resolve(entry).Value; + if (_records.TryGetValue(chartId, out var stored) && + NormalizeLamp(stored.ClearLamp) != SongClearLamp.None) + return NormalizeLamp(stored.ClearLamp); + return NormalizeLamp(stored?.ClearLamp ?? 0); + } + + public void RecordPlay(SongLibraryEntry entry, SongPlayResult result) + { + if (entry == null) + return; + + EnsureLoaded(); + var stored = GetOrCreate(entry); + ApplyEntryMetadata(stored, entry); + stored.PackId = FirstNonEmpty(result.PackId, stored.PackId, entry.PackId); + stored.PackDisplayName = FirstNonEmpty(result.PackDisplayName, stored.PackDisplayName, stored.PackId); + stored.ProviderId = FirstNonEmpty(result.ProviderId, stored.ProviderId); + stored.ProviderDisplayName = FirstNonEmpty(result.ProviderDisplayName, stored.ProviderDisplayName, stored.ProviderId); + stored.DifficultyLabel = FirstNonEmpty(result.DifficultyLabel, stored.DifficultyLabel, entry.DifficultyLabel); + stored.DifficultyValue = FirstNonEmpty( + result.DifficultyValue, + stored.DifficultyValue, + entry.Catalog?.DifficultyValue); + stored.Title = FirstNonEmpty(result.Title, stored.Title, entry.DisplayTitle); + stored.Artist = FirstNonEmpty(result.Artist, stored.Artist, entry.DisplayArtist); + stored.KeyCount = result.KeyCount >= 4 ? result.KeyCount : stored.KeyCount; + stored.HasScratch = result.HasScratch || stored.HasScratch; + var current = entry.BestRecord?.HasRecord == true + ? entry.BestRecord + : stored.PlayRecord; + stored.PlayRecord = SongPlayRecordStore.MergeRun(current, result); + entry.BestRecord = stored.PlayRecord; + Save(); + } + + public RatingSummary CalculateRating( + string providerId, + string providerDisplayName, + int keyCount, + bool hasScratch) + { + EnsureLoaded(); + var normalizedProviderId = (providerId ?? string.Empty).Trim(); + var matchingRecords = _records.Values + .Where(record => record?.PlayRecord?.BestRatings != null) + .SelectMany(record => record.PlayRecord.BestRatings + .Where(rating => rating?.HasRecord == true) + .Select(rating => new { Record = record, Rating = rating })) + .Where(item => + string.Equals(item.Rating.ProviderId, normalizedProviderId, StringComparison.OrdinalIgnoreCase) && + item.Rating.KeyCount == keyCount && + item.Rating.HasScratch == hasScratch) + .ToArray(); + var bestPlays = matchingRecords + .OrderByDescending(item => item.Rating.PlayRating) + .Take(RatingRules.BestCount) + .Select(item => new RatingChartEntry( + item.Record.ChartId, + item.Rating.Title, + item.Rating.Artist, + item.Rating.DifficultyLabel, + item.Rating.AccuracyPercent, + item.Rating.HardClear, + item.Rating.PlayRating)) + .ToArray(); + var calculated = RatingRules.CalculateBest30( + normalizedProviderId, + providerDisplayName, + keyCount, + hasScratch, + bestPlays.Select(item => new RatingContribution(item.ChartId, item.PlayRating))); + return new RatingSummary( + calculated.ProviderId, + calculated.ProviderDisplayName, + calculated.KeyCount, + calculated.HasScratch, + calculated.Value, + calculated.FilledSlots, + bestPlays, + matchingRecords + .GroupBy(item => item.Record.ChartId, StringComparer.Ordinal) + .Sum(group => Math.Max(0, group.First().Rating.PlayCount))); + } + + public IReadOnlyList CalculateRatingContexts() + { + EnsureLoaded(); + return _records.Values + .Where(record => record?.PlayRecord?.BestRatings != null) + .SelectMany(record => record.PlayRecord.BestRatings) + .Where(rating => rating?.HasRecord == true && + !string.IsNullOrWhiteSpace(rating.ProviderId) && + rating.KeyCount >= 4) + .GroupBy( + rating => new RatingContextKey(rating.ProviderId, rating.KeyCount, rating.HasScratch), + RatingContextKey.Comparer) + .Select(group => + { + var newest = group.OrderByDescending(rating => rating.RecordedUtcTicks).First(); + return CalculateRating( + newest.ProviderId, + newest.ProviderDisplayName, + newest.KeyCount, + newest.HasScratch); + }) + .OrderByDescending(summary => summary.ActivityPlayCount) + .ThenByDescending(summary => summary.FilledSlots) + .ThenBy(summary => summary.ProviderDisplayName, StringComparer.OrdinalIgnoreCase) + .ThenBy(summary => summary.KeyCount) + .ThenBy(summary => summary.HasScratch) + .ToArray(); + } + + public RatingSummary CalculateMostRecentRatingContext(int keyCount = 0, bool hasScratch = false) + { + EnsureLoaded(); + var candidates = _records.Values + .Where(record => record?.PlayRecord?.BestRatings != null) + .SelectMany(record => record.PlayRecord.BestRatings) + .Where(rating => rating?.HasRecord == true && + !string.IsNullOrWhiteSpace(rating.ProviderId)); + if (keyCount >= 4) + candidates = candidates.Where(rating => rating.KeyCount == keyCount && rating.HasScratch == hasScratch); + var newest = candidates.OrderByDescending(rating => rating.RecordedUtcTicks).FirstOrDefault(); + return newest == null + ? default + : CalculateRating(newest.ProviderId, newest.ProviderDisplayName, newest.KeyCount, newest.HasScratch); + } + + public void EnrichChartMetadata( + IReadOnlyList entries, + string packDisplayName = null) + { + EnsureLoaded(); + var changed = false; + foreach (var entry in entries ?? Array.Empty()) + { + if (entry == null) + continue; + var chartId = ChartIdentityResolver.Resolve(entry).Value; + if (!_records.TryGetValue(chartId, out var stored)) + continue; + changed |= ApplyEntryMetadata(stored, entry); + if (!string.IsNullOrWhiteSpace(packDisplayName)) + changed |= SetIfDifferent(ref stored.PackDisplayName, packDisplayName); + var ratings = stored.PlayRecord?.BestRatings; + if (ratings == null || ratings.Count == 0) + continue; + foreach (var rating in ratings) + { + if (rating == null) + continue; + if (string.IsNullOrWhiteSpace(rating.Title)) + { + rating.Title = entry.DisplayTitle ?? string.Empty; + changed = true; + } + if (string.IsNullOrWhiteSpace(rating.Artist)) + { + rating.Artist = entry.DisplayArtist ?? string.Empty; + changed = true; + } + var sameProvider = string.Equals( + rating.ProviderId, + entry.Catalog?.ProviderId, + StringComparison.OrdinalIgnoreCase); + if (sameProvider && string.IsNullOrWhiteSpace(rating.ProviderDisplayName)) + { + rating.ProviderDisplayName = entry.Catalog?.ProviderDisplayName ?? rating.ProviderId; + changed = true; + } + if (sameProvider && string.IsNullOrWhiteSpace(rating.DifficultyLabel)) + { + rating.DifficultyLabel = entry.DifficultyLabel ?? string.Empty; + changed = true; + } + if (sameProvider && string.IsNullOrWhiteSpace(rating.DifficultyValue)) + { + rating.DifficultyValue = entry.Catalog?.DifficultyValue ?? string.Empty; + changed |= !string.IsNullOrWhiteSpace(rating.DifficultyValue); + } + } + changed |= SongPlayRecordStore.NormalizeRatingHistory(stored.PlayRecord); + } + if (changed) + Save(); + } + + public void EnrichRatingMetadata(IReadOnlyList entries) => + EnrichChartMetadata(entries); + + public ProfileCollectionSnapshot CalculateCollection() + { + EnsureLoaded(); + var recorded = _records.Values + .Where(record => record?.PlayRecord?.HasRecord == true) + .ToArray(); + if (recorded.Length == 0) + return ProfileCollectionSnapshot.Empty; + + var packs = recorded + .GroupBy(record => string.IsNullOrWhiteSpace(record.PackId) ? "unknown" : record.PackId, + StringComparer.OrdinalIgnoreCase) + .Select(group => BuildCollectionGroup( + group.Key, + FirstNonEmpty(group.Select(item => item.PackDisplayName).FirstOrDefault(value => !string.IsNullOrWhiteSpace(value)), group.Key), + group)) + .OrderByDescending(group => group.Played) + .ThenBy(group => group.DisplayName, StringComparer.OrdinalIgnoreCase) + .ToArray(); + var keyModes = recorded + .GroupBy(record => $"{Math.Max(0, record.KeyCount)}k:{record.HasScratch}", StringComparer.Ordinal) + .Select(group => + { + var first = group.First(); + var keyCount = first.KeyCount > 0 ? first.KeyCount : 5; + return BuildCollectionGroup( + group.Key, + RatingRules.KeyModeLabel(keyCount, first.HasScratch), + group); + }) + .OrderBy(group => group.DisplayName, StringComparer.OrdinalIgnoreCase) + .ToArray(); + var difficulties = recorded + .GroupBy(record => + $"{FirstNonEmpty(record.ProviderId, "unranked")}:{FirstNonEmpty(record.DifficultyLabel, "UNRANKED")}", + StringComparer.OrdinalIgnoreCase) + .Select(group => + { + var first = group.OrderBy(item => item.DifficultySortIndex).First(); + var provider = FirstNonEmpty(first.ProviderDisplayName, first.ProviderId); + var difficulty = FirstNonEmpty(first.DifficultyLabel, "UNRANKED"); + var label = string.Equals(provider, "unranked", StringComparison.OrdinalIgnoreCase) || + string.IsNullOrWhiteSpace(provider) + ? difficulty + : $"{provider} {difficulty}"; + return BuildCollectionGroup(group.Key, label, group); + }) + .OrderBy(group => group.DisplayName, StringComparer.OrdinalIgnoreCase) + .ToArray(); + return new ProfileCollectionSnapshot( + BuildCollectionGroup("total", "ALL RECORDED CHARTS", recorded), + packs, + keyModes, + difficulties); + } + + public void RecordClearLamp(SongLibraryEntry entry, SongClearLamp lamp) + { + if (entry == null || lamp == SongClearLamp.None) + return; + + EnsureLoaded(); + var stored = GetOrCreate(entry); + var current = NormalizeLamp(stored.ClearLamp); + if (LampPriority(current) >= LampPriority(lamp)) + { + entry.ClearLamp = current; + return; + } + + stored.ClearLamp = (int)lamp; + entry.ClearLamp = lamp; + Save(); + } + + public int MigrateDifficultyTableProviders( + IReadOnlyDictionary providerMap) + { + EnsureLoaded(); + if (providerMap == null || providerMap.Count == 0) + return 0; + + var migratedRecords = 0; + foreach (var stored in _records.Values) + { + if (stored == null) + continue; + + var changed = MigrateProvider( + ref stored.ProviderId, + ref stored.ProviderDisplayName, + providerMap); + var playRecord = stored.PlayRecord; + if (playRecord != null) + { + changed |= MigrateRatingProvider(playRecord.BestRating, providerMap); + foreach (var rating in playRecord.BestRatings ?? new List()) + changed |= MigrateRatingProvider(rating, providerMap); + changed |= SongPlayRecordStore.NormalizeRatingHistory(playRecord); + } + + if (!changed) + continue; + migratedRecords++; + } + + if (migratedRecords > 0) + Save(); + return migratedRecords; + } + + private void EnsureLoaded() + { + if (_records != null) + return; + + _records = new Dictionary(StringComparer.Ordinal); + if (!_file.TryLoad(out var document, out _)) + return; + + if (document.SchemaVersion < SongRecordsDocument.CurrentSchemaVersion) + { + Save(); + return; + } + + var migratedDocument = document.SchemaVersion < SongRecordsDocument.CurrentSchemaVersion; + + foreach (var record in document.Records) + { + if (record == null || string.IsNullOrWhiteSpace(record.ChartId)) + continue; + record.ChartId = record.ChartId.Trim().ToLowerInvariant(); + record.PlayRecord ??= new SongPlayRecord(); + record.PlayRecord.BestRating ??= new RatingPlayRecord(); + migratedDocument |= CopyDifficultyValueIfMissing( + record.PlayRecord.BestRating, + record.DifficultyValue); + foreach (var rating in record.PlayRecord.BestRatings ?? new List()) + migratedDocument |= CopyDifficultyValueIfMissing(rating, record.DifficultyValue); + migratedDocument |= SongPlayRecordStore.NormalizeRatingHistory(record.PlayRecord); + record.PlayRecord.EverFullCombo |= record.PlayRecord.LastCleared && + record.PlayRecord.LastMissCount == 0; + record.PlayRecord.EverAllPure |= record.PlayRecord.LastCleared && + record.PlayRecord.LastTotalScoringUnits > 0 && + record.PlayRecord.LastPurePerfectCount >= record.PlayRecord.LastTotalScoringUnits; + record.ClearLamp = (int)NormalizeLamp(record.ClearLamp); + _records[record.ChartId] = record; + } + if (migratedDocument) + Save(); + } + + private SongRecordData GetOrCreate(SongLibraryEntry entry) + { + var chartId = ChartIdentityResolver.Resolve(entry).Value; + if (_records.TryGetValue(chartId, out var stored)) + { + ApplyEntryMetadata(stored, entry); + return stored; + } + + stored = new SongRecordData { ChartId = chartId }; + ApplyEntryMetadata(stored, entry); + _records.Add(chartId, stored); + return stored; + } + + private void Save() + { + _file.Save(new SongRecordsDocument + { + SchemaVersion = SongRecordsDocument.CurrentSchemaVersion, + Records = _records.Values + .OrderBy(record => record.ChartId, StringComparer.Ordinal) + .ToList(), + }); + } + + private static SongClearLamp NormalizeLamp(int stored) => + Enum.IsDefined(typeof(SongClearLamp), stored) + ? (SongClearLamp)stored + : SongClearLamp.None; + + private static bool MigrateRatingProvider( + RatingPlayRecord rating, + IReadOnlyDictionary providerMap) + { + if (rating == null) + return false; + return MigrateProvider( + ref rating.ProviderId, + ref rating.ProviderDisplayName, + providerMap); + } + + private static bool MigrateProvider( + ref string providerId, + ref string providerDisplayName, + IReadOnlyDictionary providerMap) + { + var legacyId = (providerId ?? string.Empty).Trim(); + if (legacyId.Length == 0 || + !providerMap.TryGetValue(legacyId, out var table) || + table == null || string.IsNullOrWhiteSpace(table.Id)) + return false; + + var changed = SetIfDifferent(ref providerId, table.Id); + changed |= SetIfDifferent( + ref providerDisplayName, + string.IsNullOrWhiteSpace(table.DisplayName) ? table.Id : table.DisplayName); + return changed; + } + + private readonly struct RatingContextKey : IEquatable + { + public RatingContextKey(string providerId, int keyCount, bool hasScratch) + { + ProviderId = (providerId ?? string.Empty).Trim(); + KeyCount = keyCount; + HasScratch = hasScratch; + } + + public string ProviderId { get; } + public int KeyCount { get; } + public bool HasScratch { get; } + public bool Equals(RatingContextKey other) => + string.Equals(ProviderId, other.ProviderId, StringComparison.OrdinalIgnoreCase) && + KeyCount == other.KeyCount && HasScratch == other.HasScratch; + public override bool Equals(object obj) => obj is RatingContextKey other && Equals(other); + public override int GetHashCode() => + StringComparer.OrdinalIgnoreCase.GetHashCode(ProviderId) * 397 ^ KeyCount * 31 ^ (HasScratch ? 1 : 0); + public static IEqualityComparer Comparer { get; } = new ContextComparer(); + + private sealed class ContextComparer : IEqualityComparer + { + public bool Equals(RatingContextKey x, RatingContextKey y) => x.Equals(y); + public int GetHashCode(RatingContextKey obj) => obj.GetHashCode(); + } + } + + private static bool ApplyEntryMetadata(SongRecordData stored, SongLibraryEntry entry) + { + if (stored == null || entry == null) + return false; + var changed = false; + changed |= SetIfDifferent(ref stored.PackId, entry.PackId); + changed |= SetIfDifferent(ref stored.Title, entry.DisplayTitle); + changed |= SetIfDifferent(ref stored.Artist, entry.DisplayArtist); + changed |= SetIfDifferent(ref stored.ProviderId, entry.Catalog?.ProviderId); + changed |= SetIfDifferent(ref stored.ProviderDisplayName, entry.Catalog?.ProviderDisplayName); + changed |= SetIfDifferent(ref stored.DifficultyLabel, entry.DifficultyLabel); + changed |= SetIfDifferent(ref stored.DifficultyValue, entry.Catalog?.DifficultyValue); + if (entry.DifficultySortIndex != stored.DifficultySortIndex) + { + stored.DifficultySortIndex = entry.DifficultySortIndex; + changed = true; + } + var keyCount = entry.EffectiveKeyCount; + if (keyCount >= 4 && stored.KeyCount != keyCount) + { + stored.KeyCount = keyCount; + changed = true; + } + var hasScratch = entry.Metadata?.HasScratch == true; + if (stored.HasScratch != hasScratch) + { + stored.HasScratch = hasScratch; + changed = true; + } + return changed; + } + + private static bool CopyDifficultyValueIfMissing( + RatingPlayRecord rating, + string difficultyValue) + { + if (rating == null || + !string.IsNullOrWhiteSpace(rating.DifficultyValue) || + string.IsNullOrWhiteSpace(difficultyValue)) + return false; + rating.DifficultyValue = difficultyValue; + return true; + } + + private static bool SetIfDifferent(ref string target, string value) + { + var normalized = value ?? string.Empty; + if (string.IsNullOrWhiteSpace(normalized) || string.Equals(target, normalized, StringComparison.Ordinal)) + return false; + target = normalized; + return true; + } + + private static string FirstNonEmpty(params string[] values) => + (values ?? Array.Empty()).FirstOrDefault(value => !string.IsNullOrWhiteSpace(value)) ?? string.Empty; + + private static CollectionGroupSummary BuildCollectionGroup( + string id, + string displayName, + IEnumerable records) + { + var items = (records ?? Array.Empty()).ToArray(); + return new CollectionGroupSummary( + id, + displayName, + items.Length, + items.Count(item => + NormalizeLamp(item.ClearLamp) == SongClearLamp.NormalClear || + NormalizeLamp(item.ClearLamp) == SongClearLamp.AutoScratchClear), + items.Count(item => + NormalizeLamp(item.ClearLamp) == SongClearLamp.HardClear || + NormalizeLamp(item.ClearLamp) == SongClearLamp.ExHardClear), + items.Count(item => NormalizeLamp(item.ClearLamp) == SongClearLamp.Failed || + NormalizeLamp(item.ClearLamp) == SongClearLamp.NormalFailed), + items.Count(item => item.PlayRecord?.EverFullCombo == true), + items.Count(item => item.PlayRecord?.EverAllPure == true)); + } + + internal static int LampPriority(SongClearLamp lamp) => lamp switch + { + SongClearLamp.NormalFailed => 1, + SongClearLamp.Failed => 2, + SongClearLamp.AutoScratchClear => 3, + SongClearLamp.NormalClear => 4, + SongClearLamp.HardClear => 5, + SongClearLamp.ExHardClear => 6, + _ => 0, + }; + } +} diff --git a/Assets/ProjectQuint/Runtime/Unity/Persistence/SongRecordRepository.cs.meta b/Assets/ProjectQuint/Runtime/Unity/Persistence/SongRecordRepository.cs.meta new file mode 100644 index 0000000..92a0bb0 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Persistence/SongRecordRepository.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 33f058a69162b4173ac3281aaec39dfe \ No newline at end of file diff --git a/Assets/ProjectQuint/Runtime/Unity/ProjectQuint.Infrastructure.asmdef b/Assets/ProjectQuint/Runtime/Unity/ProjectQuint.Infrastructure.asmdef new file mode 100644 index 0000000..e3358b0 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/ProjectQuint.Infrastructure.asmdef @@ -0,0 +1,17 @@ +{ + "name": "ProjectQuint.Infrastructure", + "rootNamespace": "ProjectQuint", + "references": [ + "ProjectQuint.Core", + "Unity.InputSystem" + ], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/Assets/ProjectQuint/Runtime/Unity/ProjectQuint.Infrastructure.asmdef.meta b/Assets/ProjectQuint/Runtime/Unity/ProjectQuint.Infrastructure.asmdef.meta new file mode 100644 index 0000000..324234d --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/ProjectQuint.Infrastructure.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 9bddd54fa81ae4d17a57cac5ed4531a9 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/ProjectQuint/Runtime/Unity/Resources.meta b/Assets/ProjectQuint/Runtime/Unity/Resources.meta new file mode 100644 index 0000000..2bf63df --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Resources.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 06f422db0e4c34196a5aa89d58a41ff2 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/ProjectQuint/Runtime/Unity/Resources/ProjectQuint.meta b/Assets/ProjectQuint/Runtime/Unity/Resources/ProjectQuint.meta new file mode 100644 index 0000000..17896dd --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Resources/ProjectQuint.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 82eb98f103c2c4a52af4c2876d8d4d98 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/ProjectQuint/Runtime/Unity/Resources/ProjectQuint/Branding.meta b/Assets/ProjectQuint/Runtime/Unity/Resources/ProjectQuint/Branding.meta new file mode 100644 index 0000000..1b569de --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Resources/ProjectQuint/Branding.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 1463c7e3772b4471e81aafa4ccc818c5 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/ProjectQuint/Runtime/Unity/Resources/ProjectQuint/Branding/FMODLogoWhite.png b/Assets/ProjectQuint/Runtime/Unity/Resources/ProjectQuint/Branding/FMODLogoWhite.png new file mode 100644 index 0000000..0675b5d Binary files /dev/null and b/Assets/ProjectQuint/Runtime/Unity/Resources/ProjectQuint/Branding/FMODLogoWhite.png differ diff --git a/Assets/ProjectQuint/Runtime/Unity/Resources/ProjectQuint/Branding/FMODLogoWhite.png.meta b/Assets/ProjectQuint/Runtime/Unity/Resources/ProjectQuint/Branding/FMODLogoWhite.png.meta new file mode 100644 index 0000000..79d50c5 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Resources/ProjectQuint/Branding/FMODLogoWhite.png.meta @@ -0,0 +1,247 @@ +fileFormatVersion: 2 +guid: d53c19b99cdd645c7a195c2584d2582b +TextureImporter: + internalIDToNameTable: + - first: + 213: 3254892072582250316 + second: FMODLogoWhite_0 + - first: + 213: 8655615019453088085 + second: FMODLogoWhite_1 + - first: + 213: 295461772975993495 + second: FMODLogoWhite_2 + - first: + 213: -3503770552761754162 + second: FMODLogoWhite_3 + - first: + 213: 4385662376544641261 + second: FMODLogoWhite_4 + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 0 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 1 + aniso: 1 + mipBias: 0 + wrapU: 1 + wrapV: 1 + wrapW: 1 + nPOTScale: 0 + lightmap: 0 + compressionQuality: 50 + spriteMode: 2 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 100 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 1 + spriteTessellationDetail: -1 + textureType: 8 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: + - serializedVersion: 2 + name: FMODLogoWhite_0 + rect: + serializedVersion: 2 + x: 0 + y: 0 + width: 144 + height: 192 + alignment: 0 + pivot: {x: 0, y: 0} + border: {x: 0, y: 0, z: 0, w: 0} + customData: + outline: [] + physicsShape: [] + tessellationDetail: -1 + bones: [] + spriteID: c4fff784813bb2d20800000000000000 + internalID: 3254892072582250316 + vertices: [] + indices: + edges: [] + weights: [] + - serializedVersion: 2 + name: FMODLogoWhite_1 + rect: + serializedVersion: 2 + x: 105 + y: 0 + width: 301 + height: 137 + alignment: 0 + pivot: {x: 0, y: 0} + border: {x: 0, y: 0, z: 0, w: 0} + customData: + outline: [] + physicsShape: [] + tessellationDetail: -1 + bones: [] + spriteID: 5518903300cee1870800000000000000 + internalID: 8655615019453088085 + vertices: [] + indices: + edges: [] + weights: [] + - serializedVersion: 2 + name: FMODLogoWhite_2 + rect: + serializedVersion: 2 + x: 404 + y: 0 + width: 138 + height: 137 + alignment: 0 + pivot: {x: 0, y: 0} + border: {x: 0, y: 0, z: 0, w: 0} + customData: + outline: [] + physicsShape: [] + tessellationDetail: -1 + bones: [] + spriteID: 792f0f6cae0b91400800000000000000 + internalID: 295461772975993495 + vertices: [] + indices: + edges: [] + weights: [] + - serializedVersion: 2 + name: FMODLogoWhite_3 + rect: + serializedVersion: 2 + x: 550 + y: 0 + width: 144 + height: 192 + alignment: 0 + pivot: {x: 0, y: 0} + border: {x: 0, y: 0, z: 0, w: 0} + customData: + outline: [] + physicsShape: [] + tessellationDetail: -1 + bones: [] + spriteID: ecd592cfe3b106fc0800000000000000 + internalID: -3503770552761754162 + vertices: [] + indices: + edges: [] + weights: [] + - serializedVersion: 2 + name: FMODLogoWhite_4 + rect: + serializedVersion: 2 + x: 685 + y: 0 + width: 43 + height: 43 + alignment: 0 + pivot: {x: 0, y: 0} + border: {x: 0, y: 0, z: 0, w: 0} + customData: + outline: [] + physicsShape: [] + tessellationDetail: -1 + bones: [] + spriteID: de02dbaf1b00ddc30800000000000000 + internalID: 4385662376544641261 + vertices: [] + indices: + edges: [] + weights: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: + FMODLogoWhite_0: 3254892072582250316 + FMODLogoWhite_1: 8655615019453088085 + FMODLogoWhite_2: 295461772975993495 + FMODLogoWhite_3: -3503770552761754162 + FMODLogoWhite_4: 4385662376544641261 + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/ProjectQuint/Runtime/Unity/Resources/ProjectQuint/Branding/TeamDeniaMark.png b/Assets/ProjectQuint/Runtime/Unity/Resources/ProjectQuint/Branding/TeamDeniaMark.png new file mode 100644 index 0000000..e8594b5 Binary files /dev/null and b/Assets/ProjectQuint/Runtime/Unity/Resources/ProjectQuint/Branding/TeamDeniaMark.png differ diff --git a/Assets/ProjectQuint/Runtime/Unity/Resources/ProjectQuint/Branding/TeamDeniaMark.png.meta b/Assets/ProjectQuint/Runtime/Unity/Resources/ProjectQuint/Branding/TeamDeniaMark.png.meta new file mode 100644 index 0000000..fa71763 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Resources/ProjectQuint/Branding/TeamDeniaMark.png.meta @@ -0,0 +1,143 @@ +fileFormatVersion: 2 +guid: c852fdabb3a094e8ba63141c3cb8bfde +TextureImporter: + internalIDToNameTable: + - first: + 213: 9212231520204601289 + second: TeamDeniaMark_0 + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 0 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 1 + aniso: 1 + mipBias: 0 + wrapU: 1 + wrapV: 1 + wrapW: 1 + nPOTScale: 0 + lightmap: 0 + compressionQuality: 50 + spriteMode: 2 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 100 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 1 + spriteTessellationDetail: -1 + textureType: 8 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: + - serializedVersion: 2 + name: TeamDeniaMark_0 + rect: + serializedVersion: 2 + x: 257 + y: 171 + width: 740 + height: 912 + alignment: 0 + pivot: {x: 0, y: 0} + border: {x: 0, y: 0, z: 0, w: 0} + customData: + outline: [] + physicsShape: [] + tessellationDetail: -1 + bones: [] + spriteID: 9c7b37652cb68df70800000000000000 + internalID: 9212231520204601289 + vertices: [] + indices: + edges: [] + weights: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: + TeamDeniaMark_0: 9212231520204601289 + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/ProjectQuint/Runtime/Unity/Resources/ProjectQuint/Data.meta b/Assets/ProjectQuint/Runtime/Unity/Resources/ProjectQuint/Data.meta new file mode 100644 index 0000000..93671da --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Resources/ProjectQuint/Data.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: bbc458f1696741d5b92ea283337328ba +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/ProjectQuint/Runtime/Unity/Resources/ProjectQuint/Data/AeryHeader.json b/Assets/ProjectQuint/Runtime/Unity/Resources/ProjectQuint/Data/AeryHeader.json new file mode 100644 index 0000000..b099ef9 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Resources/ProjectQuint/Data/AeryHeader.json @@ -0,0 +1 @@ +{"name":"5KEYS AERY","symbol":"⑤","course":[[{"name":"[5KEYS AERY] 段位認定 1_DAN / ver.24","constraint":["grade_mirror","gauge_lr2","ln"],"trophy":[{"name":"bronzemedal","missrate":7.5,"scorerate":0.0},{"name":"silvermedal","missrate":5.0,"scorerate":70.0},{"name":"goldmedal","missrate":2.5,"scorerate":85.0}],"md5":["092d4c4597d78fde5bde5e79583e2a21","285b4d42a2feb17a8de615c5732f75cc","586eecf05016b61b3a737087da4b53f7","4c35df45e9853650bb11139089db0241"]},{"name":"[5KEYS AERY] 段位認定 2_DAN / ver.24","constraint":["grade_mirror","gauge_lr2","ln"],"trophy":[{"name":"bronzemedal","missrate":7.5,"scorerate":0.0},{"name":"silvermedal","missrate":5.0,"scorerate":70.0},{"name":"goldmedal","missrate":2.5,"scorerate":85.0}],"md5":["779c5cee43891cf6516ebd9a5e73b648","b7c62a473c739085f2b85ad19c8f6fb0","7384c7487c47149f5ee9b6bcbd6f9491","959614f4dd366638ab6f617e3b87fc6c"]},{"name":"[5KEYS AERY] 段位認定 3_DAN / ver.24","constraint":["grade_mirror","gauge_lr2","ln"],"trophy":[{"name":"bronzemedal","missrate":7.5,"scorerate":0.0},{"name":"silvermedal","missrate":5.0,"scorerate":70.0},{"name":"goldmedal","missrate":2.5,"scorerate":85.0}],"md5":["8a7d1acf1f4baa2160cece323197550d","1f6828e9c46f3646c7c8fe3a3885c045","632ad8cdfedf4ddec8bcbfc387b7e690","363945fb89b936aaa5f2e74df6cff924"]},{"name":"[5KEYS AERY] 段位認定 4_DAN / ver.24","constraint":["grade_mirror","gauge_lr2","ln"],"trophy":[{"name":"bronzemedal","missrate":7.5,"scorerate":0.0},{"name":"silvermedal","missrate":5.0,"scorerate":70.0},{"name":"goldmedal","missrate":2.5,"scorerate":85.0}],"md5":["2e374d1b6e04db7b77c02fc4c7f01da3","0ef415fe32c7b4643762425f7d9080b2","84f5aa52e5ca6753b8766bd38509cf88","9dd3f8494dbf2610a12c8fb06e675eba"]},{"name":"[5KEYS AERY] 段位認定 5_DAN / ver.24","constraint":["grade_mirror","gauge_lr2","ln"],"trophy":[{"name":"bronzemedal","missrate":7.5,"scorerate":0.0},{"name":"silvermedal","missrate":5.0,"scorerate":70.0},{"name":"goldmedal","missrate":2.5,"scorerate":85.0}],"md5":["199aa09741df8c57f75c1a68c5f7c660","8e26da57d0bebdf8691cda660c8ce771","7f6e2b3e8bb2d013566b59c4f6b1b11f","008de2c8277e04c8a3274dfab0b3f86b"]},{"name":"[5KEYS AERY] 段位認定 6_DAN / ver.24","constraint":["grade_mirror","gauge_lr2","ln"],"trophy":[{"name":"bronzemedal","missrate":7.5,"scorerate":0.0},{"name":"silvermedal","missrate":5.0,"scorerate":70.0},{"name":"goldmedal","missrate":2.5,"scorerate":85.0}],"md5":["448ee2bf92651eaf45a79c98eb7630c8","fc87e6e4b76e9883844e45328d6deca5","9c1d3541043e001547363d1539395aee","5f49f59a42d6b5241293fef257ad0165"]},{"name":"[5KEYS AERY] 段位認定 7_DAN / ver.24","constraint":["grade_mirror","gauge_lr2","ln"],"trophy":[{"name":"bronzemedal","missrate":7.5,"scorerate":0.0},{"name":"silvermedal","missrate":5.0,"scorerate":70.0},{"name":"goldmedal","missrate":2.5,"scorerate":85.0}],"md5":["45a6c24e6e05496975b6bf8e247976bb","0eb90124ce909c57643d2ab782e6ad2f","312bbaf65a12cc6953d7b8835b40d2bf","04138a82e311f4c0619a59e73b8274b6"]},{"name":"[5KEYS AERY] 段位認定 8_DAN / ver.24","constraint":["grade_mirror","gauge_lr2","ln"],"trophy":[{"name":"bronzemedal","missrate":7.5,"scorerate":0.0},{"name":"silvermedal","missrate":5.0,"scorerate":70.0},{"name":"goldmedal","missrate":2.5,"scorerate":85.0}],"md5":["ea4c20ea8770bf106f3e619339a7d29f","011b5b10874d806991feb0c477942c9c","a340ad6acd84023d22eb5576d5816384","a08272dc4fffda4f3a7bcee3c02c0f8a"]},{"name":"[5KEYS AERY] 段位認定 9_DAN / ver.24","constraint":["grade_mirror","gauge_lr2","ln"],"trophy":[{"name":"bronzemedal","missrate":7.5,"scorerate":0.0},{"name":"silvermedal","missrate":5.0,"scorerate":70.0},{"name":"goldmedal","missrate":2.5,"scorerate":85.0}],"md5":["d954040a6a94a78d82309e037aa6a4fd","92e87e37dc27498a746ee5b95e5bee2e","204e522a8a1450d28baa489fb1738409","68d70074bd6ab2d921df9c0afcbc1b0b"]},{"name":"[5KEYS AERY] 段位認定 10_DAN / ver.24","constraint":["grade_mirror","gauge_lr2","ln"],"trophy":[{"name":"bronzemedal","missrate":7.5,"scorerate":0.0},{"name":"silvermedal","missrate":5.0,"scorerate":70.0},{"name":"goldmedal","missrate":2.5,"scorerate":85.0}],"md5":["ded28f8d4dda2947ea4638f374bee967","4180f8fab3ad583a31b24b8d6cad2cab","41d5a80fb2d73df1dedeff4f20d63e41","5571fdd440253f1ab63ce9f0941f15b3"]},{"name":"[5KEYS AERY] 段位認定 皆伝 / ver.24","constraint":["grade_mirror","gauge_lr2","ln"],"trophy":[{"name":"bronzemedal","missrate":7.5,"scorerate":0.0},{"name":"silvermedal","missrate":5.0,"scorerate":70.0},{"name":"goldmedal","missrate":2.5,"scorerate":85.0}],"md5":["95f14007516fc382c36d44aedfcbb1cb","43eb214cfb65388b11933d3902043b77","4789bd0c9b22a03462d59df1baf111db","71a29e4cc396881b11c389879874598c"]},{"name":"[5KEYS AERY] 段位認定 GORILLA / ver.24","constraint":["grade_mirror","gauge_lr2","ln"],"trophy":[{"name":"bronzemedal","missrate":7.5,"scorerate":0.0},{"name":"silvermedal","missrate":5.0,"scorerate":70.0},{"name":"goldmedal","missrate":2.5,"scorerate":85.0}],"md5":["e66a509dcbb4ae2e0aa28e5b713291d4","7108a3a73795a5da5fb9df03d0c2b09d","2868c70113edd0c8f0f6d138924bcd27","20aad85c9812c6e3b7bc3633fc72b1c9"]},{"name":"[5KEYS AERY] 段位認定 OVERJOY / ver.24","constraint":["grade_mirror","gauge_lr2","ln"],"trophy":[{"name":"bronzemedal","missrate":7.5,"scorerate":0.0},{"name":"silvermedal","missrate":5.0,"scorerate":70.0},{"name":"goldmedal","missrate":2.5,"scorerate":85.0}],"md5":["fc24589ee1d0b183e1505fc3118ad482","753069bd564a0c9d671642bd6902c17a","94f9b80ee6758dd3091557cd7e2d1746","4dc9f4fc359de07faf5e6f1b0e5c68f4"]}]],"level_order":["LEVEL 1","LEVEL 2","LEVEL 3","LEVEL 4","LEVEL 5","LEVEL 6","LEVEL 7","LEVEL 8","LEVEL 9","LEVEL 10","LEVEL 11","LEVEL 12","LEVEL 13","LEVEL 14","LEVEL 15","LEVEL 15+","LEVEL 16","LEVEL 16+","LEVEL 17","LEVEL 17+","LEVEL 18","LEVEL 18+","LEVEL 19","LEVEL 19+","LEVEL 20","LEVEL 20+","LEVEL 99","LEVEL GIMMICK","LEVEL SPECIAL","LEVEL DUMMY","OLD CHARTS"],"enum_level_order":["LEVEL 1","LEVEL 2","LEVEL 3","LEVEL 4","LEVEL 5","LEVEL 6","LEVEL 7","LEVEL 8","LEVEL 9","LEVEL 10","LEVEL 11","LEVEL 12","LEVEL 13","LEVEL 14","LEVEL 15","LEVEL 15+","LEVEL 16","LEVEL 16+","LEVEL 17","LEVEL 17+","LEVEL 18","LEVEL 18+","LEVEL 19","LEVEL 19+","LEVEL 20","LEVEL 20+","LEVEL 99","LEVEL GIMMICK","LEVEL SPECIAL","LEVEL DUMMY","OLD CHARTS"],"folder_order":[],"folder_sort_key":"","folder_sort_ascending":true,"entry_type":"","data_url":"data.json","compat_prefix":"","last_update":"2025-06-15","editor_name":"BeMusicSeeker","editor_version":"0.3.5.6","output_date":"2025-06-15"} diff --git a/Assets/ProjectQuint/Runtime/Unity/Resources/ProjectQuint/Data/AeryHeader.json.meta b/Assets/ProjectQuint/Runtime/Unity/Resources/ProjectQuint/Data/AeryHeader.json.meta new file mode 100644 index 0000000..b2586b8 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Resources/ProjectQuint/Data/AeryHeader.json.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 9ecb325dc94a40e0bda41b480379a4fd +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/ProjectQuint/Runtime/Unity/Resources/ProjectQuint/Data/SearchAliases.json b/Assets/ProjectQuint/Runtime/Unity/Resources/ProjectQuint/Data/SearchAliases.json new file mode 100644 index 0000000..b89cba1 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Resources/ProjectQuint/Data/SearchAliases.json @@ -0,0 +1,17 @@ +{ + "Entries": [ + { + "MatchText": "モリモリあつし", + "Aliases": ["morimori atsushi", "morimoriatsushi"] + }, + { + "MatchText": "削除", + "Aliases": ["sakuzyo", "sakujo"] + }, + { + "ChartMd5": "071409e939ce4b3043dffa4436fb9c44", + "MatchText": "花嵐、空に溶けて", + "Aliases": ["hanaarashi sora ni tokete", "hanaarashi sorani tokete"] + } + ] +} diff --git a/Assets/ProjectQuint/Runtime/Unity/Resources/ProjectQuint/Data/SearchAliases.json.meta b/Assets/ProjectQuint/Runtime/Unity/Resources/ProjectQuint/Data/SearchAliases.json.meta new file mode 100644 index 0000000..82c2ff8 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Resources/ProjectQuint/Data/SearchAliases.json.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: ea78c15babb5049308b4779f389c7928 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/ProjectQuint/Runtime/Unity/Settings.meta b/Assets/ProjectQuint/Runtime/Unity/Settings.meta new file mode 100644 index 0000000..68e4e1b --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Settings.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 68e7be85689fa4455aa1a5674f4cb72a +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/ProjectQuint/Runtime/Unity/Settings/GameSettings.cs b/Assets/ProjectQuint/Runtime/Unity/Settings/GameSettings.cs new file mode 100644 index 0000000..43025d7 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Settings/GameSettings.cs @@ -0,0 +1,643 @@ +using System; +using System.Collections.Generic; +using ProjectQuint.Core.Charts; +using ProjectQuint.Core.Gauge; +using UnityEngine; +using UnityEngine.InputSystem; + +namespace ProjectQuint.Settings +{ + [Serializable] + public sealed class AudioOutputCalibrationProfile + { + public const int CurrentAlgorithmVersion = 2; + + public int AlgorithmVersion; + public string BackendId = string.Empty; + public string DevicePersistentId = string.Empty; + public string DeviceName = string.Empty; + public int OutputLatencyMilliseconds; + public int AcceptedSampleCount; + public float MedianMilliseconds; + public float MedianAbsoluteDeviationMilliseconds; + public long CalibratedUtcTicks; + + public bool Matches(string backendId, string devicePersistentId) => + !string.IsNullOrWhiteSpace(DevicePersistentId) && + string.Equals(BackendId, backendId, StringComparison.OrdinalIgnoreCase) && + string.Equals(DevicePersistentId, devicePersistentId, StringComparison.OrdinalIgnoreCase); + } + + public enum GameplaySkin + { + IceBlue, + Rose, + } + + public enum GameplayNoteSkin + { + Bar, + Circle, + } + + public enum LongNoteVisualStyle + { + Thin, + Full, + } + + public enum BgaDisplayMode + { + Full, + Cinema, + } + + public enum BgaPlaybackMode + { + Full, + Static, + Black, + } + + public enum GameplayGearPosition + { + Left, + Center, + Right, + } + + public enum FrameLimitMode + { + Fps60, + Fps144, + Fps240, + Unlimited, + } + + public enum DisplayResolutionMode + { + Hd, + FullHd, + QuadHd, + UltraHd4K, + } + + public enum WindowMode + { + Fullscreen, + Windowed, + } + + public enum AudioDspBufferProfile + { + LowLatency, + Balanced, + Safe, + } + + public enum InputPollingRate + { + Hz1000 = 1000, + Hz2000 = 2000, + Hz4000 = 4000, + Hz8000 = 8000, + } + + internal static class GameSettingsDefaults + { + public const float NoteSpeed = 4.3f; + public const int JudgementOffsetMilliseconds = 0; + public const int AudioOutputLatencyMilliseconds = 0; + public const int MasterVolumePercent = 100; + public const AudioDspBufferProfile AudioDspBuffer = AudioDspBufferProfile.Balanced; + public const InputPollingRate InputPolling = InputPollingRate.Hz1000; + public const FrameLimitMode FrameLimit = FrameLimitMode.Fps144; + public const DisplayResolutionMode DisplayResolution = DisplayResolutionMode.FullHd; + public const WindowMode DisplayMode = WindowMode.Fullscreen; + public const bool ShowFpsCounter = false; + public const bool BgaEnabled = true; + public const BgaPlaybackMode BgaPlayback = BgaPlaybackMode.Full; + public const BgaDisplayMode BgaMode = BgaDisplayMode.Full; + public const bool VSyncEnabled = true; + public const bool LaneSeparatorsEnabled = true; + public const bool TimingMeterEnabled = false; + public const bool GaugeFallbackEnabled = false; + public const GameplayNoteSkin NoteSkin = GameplayNoteSkin.Bar; + public const LongNoteVisualStyle LongNoteStyle = LongNoteVisualStyle.Full; + public const int BackgroundDimPercent = 40; + public const Key CenterAuxKey = Key.G; + public const Key ScratchKey = Key.LeftShift; + + public static readonly Key[] LaneKeys = { Key.D, Key.F, Key.J, Key.K, Key.L }; + public static readonly Key[] FourKeyLaneKeys = { Key.D, Key.F, Key.J, Key.K }; + public static readonly Key[] SixKeyLaneKeys = { Key.D, Key.F, Key.G, Key.J, Key.K, Key.L }; + public static readonly Key[] SevenKeyLaneKeys = { Key.D, Key.F, Key.G, Key.Space, Key.J, Key.K, Key.L }; + } + + internal sealed class GameSettings + { + public const int LaneCount = 5; + public const int CenterLaneIndex = 2; + public const int MaximumKeyCount = 7; + public const int ScratchBindingIndex = MaximumKeyCount; + public const int CenterAuxBindingIndex = MaximumKeyCount + 1; + public const int BindingCount = MaximumKeyCount + 2; + public const float MinimumNoteSpeed = 0.5f; + public const float MaximumNoteSpeed = 10f; + public const float NoteSpeedTravelScale = 7f / 5f; + public const int MinimumJudgementOffsetMilliseconds = -200; + public const int MaximumJudgementOffsetMilliseconds = 200; + public const int MinimumAudioOutputLatencyMilliseconds = 0; + public const int MaximumAudioOutputLatencyMilliseconds = 500; + public const int MinimumMasterVolumePercent = 0; + public const int MaximumMasterVolumePercent = 100; + public const int MinimumBackgroundDimPercent = 0; + public const int MaximumBackgroundDimPercent = 90; + + public float NoteSpeed { get; set; } = GameSettingsDefaults.NoteSpeed; + public int JudgementOffsetMilliseconds { get; set; } = GameSettingsDefaults.JudgementOffsetMilliseconds; + public int AudioOutputLatencyMilliseconds { get; set; } = GameSettingsDefaults.AudioOutputLatencyMilliseconds; + public int MasterVolumePercent { get; set; } = GameSettingsDefaults.MasterVolumePercent; + public AudioDspBufferProfile AudioDspBufferProfile { get; set; } = GameSettingsDefaults.AudioDspBuffer; + public InputPollingRate InputPollingRate { get; set; } = GameSettingsDefaults.InputPolling; + public string AudioOutputDeviceId { get; set; } = string.Empty; + public string AudioOutputDeviceName { get; set; } = string.Empty; + public AudioOutputCalibrationProfile[] AudioOutputCalibrationProfiles { get; set; } = + Array.Empty(); + public bool AudioOutputCalibrationMigrationPending { get; set; } + public bool ReactiveKeySoundsEnabled { get; set; } + public GameplayGearPosition GearPosition { get; set; } = GameplayGearPosition.Center; + public LaneModifier LaneModifier { get; set; } = LaneModifier.Off; + public GaugeMode GaugeType { get; set; } = GaugeMode.Normal; + public bool GaugeFallbackEnabled { get; set; } = GameSettingsDefaults.GaugeFallbackEnabled; + public GameplaySkin GameplaySkin { get; set; } = GameplaySkin.IceBlue; + public GameplayNoteSkin NoteSkin { get; set; } = GameSettingsDefaults.NoteSkin; + public LongNoteVisualStyle LongNoteStyle { get; set; } = GameSettingsDefaults.LongNoteStyle; + public FrameLimitMode FrameLimitMode { get; set; } = GameSettingsDefaults.FrameLimit; + public DisplayResolutionMode DisplayResolutionMode { get; set; } = GameSettingsDefaults.DisplayResolution; + public WindowMode WindowMode { get; set; } = GameSettingsDefaults.DisplayMode; + public bool ShowFpsCounter { get; set; } = GameSettingsDefaults.ShowFpsCounter; + public bool BgaEnabled { get; set; } = GameSettingsDefaults.BgaEnabled; + public bool BgaStaticBackgroundEnabled { get; set; } + public BgaPlaybackMode BgaPlayback + { + get => BgaEnabled + ? BgaPlaybackMode.Full + : BgaStaticBackgroundEnabled ? BgaPlaybackMode.Static : BgaPlaybackMode.Black; + set + { + BgaEnabled = value == BgaPlaybackMode.Full; + BgaStaticBackgroundEnabled = value == BgaPlaybackMode.Static; + } + } + public BgaDisplayMode BgaDisplayMode { get; set; } = GameSettingsDefaults.BgaMode; + public bool VSyncEnabled { get; set; } = GameSettingsDefaults.VSyncEnabled; + public bool LaneSeparatorsEnabled { get; set; } = GameSettingsDefaults.LaneSeparatorsEnabled; + public bool TimingMeterEnabled { get; set; } = GameSettingsDefaults.TimingMeterEnabled; + public int BackgroundDimPercent { get; set; } = GameSettingsDefaults.BackgroundDimPercent; + public string SkippedUpdateVersion { get; set; } = string.Empty; + public long LastUpdateCheckUtcTicks { get; set; } + public string BmsLibraryRoot { get; set; } = string.Empty; + public Key[] LaneKeys { get; } = (Key[])GameSettingsDefaults.LaneKeys.Clone(); + public Key[] FourKeyLaneKeys { get; } = (Key[])GameSettingsDefaults.FourKeyLaneKeys.Clone(); + public Key[] SixKeyLaneKeys { get; } = (Key[])GameSettingsDefaults.SixKeyLaneKeys.Clone(); + public Key[] SevenKeyLaneKeys { get; } = (Key[])GameSettingsDefaults.SevenKeyLaneKeys.Clone(); + public Key CenterAuxKey { get; set; } = GameSettingsDefaults.CenterAuxKey; + public Key ScratchKey { get; set; } = GameSettingsDefaults.ScratchKey; + + public void Normalize() + { + NoteSpeed = Mathf.Clamp(NoteSpeed, MinimumNoteSpeed, MaximumNoteSpeed); + JudgementOffsetMilliseconds = Mathf.Clamp( + JudgementOffsetMilliseconds, + MinimumJudgementOffsetMilliseconds, + MaximumJudgementOffsetMilliseconds); + AudioOutputLatencyMilliseconds = Mathf.Clamp( + AudioOutputLatencyMilliseconds, + MinimumAudioOutputLatencyMilliseconds, + MaximumAudioOutputLatencyMilliseconds); + MasterVolumePercent = Mathf.Clamp( + MasterVolumePercent, + MinimumMasterVolumePercent, + MaximumMasterVolumePercent); + AudioDspBufferProfile = DefinedOrDefault( + AudioDspBufferProfile, + GameSettingsDefaults.AudioDspBuffer); + InputPollingRate = DefinedOrDefault(InputPollingRate, GameSettingsDefaults.InputPolling); + AudioOutputDeviceId = AudioOutputDeviceId?.Trim() ?? string.Empty; + AudioOutputDeviceName = AudioOutputDeviceName?.Trim() ?? string.Empty; + AudioOutputCalibrationProfiles ??= Array.Empty(); + for (var index = 0; index < AudioOutputCalibrationProfiles.Length; index++) + { + var profile = AudioOutputCalibrationProfiles[index]; + if (profile == null) + continue; + profile.BackendId = profile.BackendId?.Trim() ?? string.Empty; + profile.DevicePersistentId = profile.DevicePersistentId?.Trim() ?? string.Empty; + profile.DeviceName = profile.DeviceName?.Trim() ?? string.Empty; + profile.OutputLatencyMilliseconds = Mathf.Clamp( + profile.OutputLatencyMilliseconds, + MinimumAudioOutputLatencyMilliseconds, + MaximumAudioOutputLatencyMilliseconds); + profile.AcceptedSampleCount = Math.Max(0, profile.AcceptedSampleCount); + profile.MedianAbsoluteDeviationMilliseconds = + Mathf.Max(0f, profile.MedianAbsoluteDeviationMilliseconds); + } + BackgroundDimPercent = Mathf.Clamp( + BackgroundDimPercent, + MinimumBackgroundDimPercent, + MaximumBackgroundDimPercent); + SkippedUpdateVersion = SkippedUpdateVersion?.Trim() ?? string.Empty; + LastUpdateCheckUtcTicks = Math.Max(0L, LastUpdateCheckUtcTicks); + BmsLibraryRoot ??= string.Empty; + + GearPosition = DefinedOrDefault(GearPosition, GameplayGearPosition.Center); + LaneModifier = DefinedOrDefault(LaneModifier, LaneModifier.Off); + GaugeType = DefinedOrDefault(GaugeType, GaugeMode.Normal); + GameplaySkin = DefinedOrDefault(GameplaySkin, GameplaySkin.IceBlue); + NoteSkin = DefinedOrDefault(NoteSkin, GameSettingsDefaults.NoteSkin); + LongNoteStyle = DefinedOrDefault(LongNoteStyle, GameSettingsDefaults.LongNoteStyle); + FrameLimitMode = DefinedOrDefault(FrameLimitMode, GameSettingsDefaults.FrameLimit); + DisplayResolutionMode = DefinedOrDefault(DisplayResolutionMode, GameSettingsDefaults.DisplayResolution); + WindowMode = DefinedOrDefault(WindowMode, GameSettingsDefaults.DisplayMode); + BgaDisplayMode = DefinedOrDefault(BgaDisplayMode, GameSettingsDefaults.BgaMode); + if (BgaEnabled) + BgaStaticBackgroundEnabled = false; + NormalizeLaneKeys(LaneKeys); + NormalizeLaneKeys(FourKeyLaneKeys, GameSettingsDefaults.FourKeyLaneKeys); + NormalizeLaneKeys(SixKeyLaneKeys, GameSettingsDefaults.SixKeyLaneKeys); + NormalizeLaneKeys(SevenKeyLaneKeys, GameSettingsDefaults.SevenKeyLaneKeys); + CenterAuxKey = NormalizeCenterAuxKey(CenterAuxKey, LaneKeys); + ScratchKey = NormalizeScratchKey(ScratchKey); + } + + public static float EffectiveNoteTravelSpeed(float displayedSpeed) => + Mathf.Clamp(displayedSpeed, MinimumNoteSpeed, MaximumNoteSpeed) * + NoteSpeedTravelScale; + + public static float ConvertPreV12NoteSpeed(float previousSpeed) + { + var converted = previousSpeed / NoteSpeedTravelScale; + return Mathf.Round( + Mathf.Clamp(converted, MinimumNoteSpeed, MaximumNoteSpeed) * 10f) / 10f; + } + + public bool AssignLaneKey(int lane, Key key) + { + if (lane < 0 || lane >= LaneKeys.Length || !IsAssignableLaneKey(key)) + return false; + + var existingLane = Array.IndexOf(LaneKeys, key); + if (existingLane == lane) + return false; + + var previousKey = LaneKeys[lane]; + if (key == CenterAuxKey) + CenterAuxKey = previousKey; + else if (existingLane >= 0) + LaneKeys[existingLane] = previousKey; + LaneKeys[lane] = key; + return true; + } + + public bool AssignCenterAuxKey(Key key) + { + if (!IsAssignableLaneKey(key) || key == CenterAuxKey) + return false; + + var previousKey = CenterAuxKey; + var existingLane = Array.IndexOf(LaneKeys, key); + if (existingLane >= 0) + LaneKeys[existingLane] = previousKey; + CenterAuxKey = key; + return true; + } + + public bool IsKeyBoundToLane(int lane, Key key) => + lane >= 0 && lane < LaneKeys.Length && + (LaneKeys[lane] == key || lane == CenterLaneIndex && CenterAuxKey == key); + + public Key[] KeysFor(int keyCount) => keyCount switch + { + 4 => FourKeyLaneKeys, + 6 => SixKeyLaneKeys, + 7 => SevenKeyLaneKeys, + _ => LaneKeys, + }; + + public bool AssignProfileKey(int keyCount, int keyIndex, Key key) + { + var keys = KeysFor(keyCount); + if (keyIndex < 0 || keyIndex >= keys.Length || !IsAssignableLaneKey(key)) + return false; + + var existing = Array.IndexOf(keys, key); + if (existing == keyIndex) + return false; + var previous = keys[keyIndex]; + if (existing >= 0) + keys[existing] = previous; + keys[keyIndex] = key; + if (key == ScratchKey) + ScratchKey = previous; + if (keyCount == 5 && key == CenterAuxKey) + CenterAuxKey = previous; + return true; + } + + public bool AssignScratchKey(Key key) + { + if (!IsAssignableLaneKey(key) || key == ScratchKey) + return false; + if (ContainsProfileKey(key)) + return false; + ScratchKey = key; + return true; + } + + public static int NormalizeInputKeyCount(int keyCount) => + Mathf.Clamp(keyCount, 4, MaximumKeyCount); + + public static AudioDspBufferProfile CycleAudioDspBufferProfile( + AudioDspBufferProfile current, + int delta) + { + var value = Enum.IsDefined(typeof(AudioDspBufferProfile), current) + ? (int)current + : (int)GameSettingsDefaults.AudioDspBuffer; + const int count = 3; + return (AudioDspBufferProfile)(((value + delta) % count + count) % count); + } + + public static int RequestedDspBufferSamples(AudioDspBufferProfile profile) => profile switch + { + AudioDspBufferProfile.LowLatency => 128, + AudioDspBufferProfile.Safe => 512, + _ => 256, + }; + + public static string AudioDspBufferProfileLabel(AudioDspBufferProfile profile) => profile switch + { + AudioDspBufferProfile.LowLatency => "LOW LATENCY", + AudioDspBufferProfile.Safe => "SAFE", + _ => "BALANCED", + }; + + public static int CycleInputKeyCount(int keyCount, int delta) + { + var normalized = NormalizeInputKeyCount(keyCount); + var index = normalized - 4; + return 4 + ((index + delta) % 4 + 4) % 4; + } + + public static InputPollingRate CycleInputPollingRate(InputPollingRate current, int delta) + { + var rates = new[] + { + InputPollingRate.Hz1000, + InputPollingRate.Hz2000, + InputPollingRate.Hz4000, + InputPollingRate.Hz8000, + }; + var index = Array.IndexOf(rates, current); + if (index < 0) + index = 0; + return rates[((index + delta) % rates.Length + rates.Length) % rates.Length]; + } + + public static int InputPollingRateHz(InputPollingRate rate) => + Enum.IsDefined(typeof(InputPollingRate), rate) + ? (int)rate + : (int)GameSettingsDefaults.InputPolling; + + public static InputPollingRate ParseInputPollingRate(int storedHz, int legacyProcessingMode = 0) + { + if (Enum.IsDefined(typeof(InputPollingRate), storedHz)) + return (InputPollingRate)storedHz; + return legacyProcessingMode == 1 ? InputPollingRate.Hz4000 : GameSettingsDefaults.InputPolling; + } + + public bool IsKeyBoundToGameplayLane( + int keyCount, + bool hasScratch, + int gameplayLane, + Key key) + { + if (hasScratch && gameplayLane == 0) + return ScratchKey == key; + var keyIndex = gameplayLane - (hasScratch ? 1 : 0); + var keys = KeysFor(keyCount); + return keyIndex >= 0 && keyIndex < keys.Length && + (keys[keyIndex] == key || keyCount == 5 && keyIndex == CenterLaneIndex && CenterAuxKey == key); + } + + public IEnumerable GameplayKeysForLane( + int keyCount, + bool hasScratch, + int gameplayLane) + { + if (hasScratch && gameplayLane == 0) + { + yield return ScratchKey; + yield break; + } + + var keyIndex = gameplayLane - (hasScratch ? 1 : 0); + var keys = KeysFor(keyCount); + if (keyIndex < 0 || keyIndex >= keys.Length) + yield break; + + yield return keys[keyIndex]; + if (keyCount == 5 && keyIndex == CenterLaneIndex && CenterAuxKey != keys[keyIndex]) + yield return CenterAuxKey; + } + + public static string DefaultLaneKeyNameAt(int lane) => GameSettingsDefaults.LaneKeys[lane].ToString(); + + public static string NormalizeLaneKeyPreferenceName(string storedName, string fallbackName) + { + var fallback = Enum.TryParse(fallbackName, true, out Key parsedFallback) + ? parsedFallback + : Key.None; + return ParseLaneKey(storedName, fallback).ToString(); + } + + public static Key ParseLaneKey(string storedName, Key fallback) => + Enum.TryParse(storedName, true, out Key parsed) && IsAssignableLaneKey(parsed) + ? parsed + : fallback; + + public static bool IsAssignableLaneKey(Key key) + { + if (key == Key.None || !Enum.IsDefined(typeof(Key), key)) + return false; + return key != Key.Escape && + key != Key.Digit1 && key != Key.Digit2 && + key != Key.Numpad1 && key != Key.Numpad2; + } + + public static string FormatLaneKeyLabel(Key key) + { + switch (key) + { + case Key.Semicolon: return ";"; + case Key.Quote: return "'"; + case Key.Comma: return ","; + case Key.Period: return "."; + case Key.Slash: return "/"; + case Key.Backslash: return "\\"; + case Key.LeftBracket: return "["; + case Key.RightBracket: return "]"; + case Key.Minus: return "-"; + case Key.Equals: return "="; + case Key.Backquote: return "`"; + case Key.LeftShift: return "L SHIFT"; + case Key.RightShift: return "R SHIFT"; + case Key.LeftCtrl: return "L CTRL"; + case Key.RightCtrl: return "R CTRL"; + case Key.LeftAlt: return "L ALT"; + case Key.RightAlt: return "R ALT"; + case Key.LeftMeta: return "L CMD"; + case Key.RightMeta: return "R CMD"; + case Key.CapsLock: return "CAPS"; + case Key.Backspace: return "DELETE"; + case Key.Enter: + case Key.NumpadEnter: + return "ENTER"; + } + + var name = key.ToString(); + if (name.StartsWith("Digit", StringComparison.Ordinal) && name.Length > 5) + return name.Substring(5); + if (name.StartsWith("Numpad", StringComparison.Ordinal) && name.Length > 6) + return $"NUM {name.Substring(6)}"; + return name.ToUpperInvariant(); + } + + public static int TargetFrameRate(FrameLimitMode mode) => mode switch + { + FrameLimitMode.Fps60 => 60, + FrameLimitMode.Fps144 => 144, + FrameLimitMode.Fps240 => 240, + FrameLimitMode.Unlimited => -1, + _ => 144, + }; + + public static int EffectiveTargetFrameRate( + FrameLimitMode mode, + double displayRefreshRate, + bool vSyncEnabled, + bool displayLinkedPresentation) + { + var requested = TargetFrameRate(mode); + if (double.IsNaN(displayRefreshRate) || + double.IsInfinity(displayRefreshRate) || + displayRefreshRate < 1.0) + return requested; + + var displayMaximum = Math.Max(1, (int)Math.Round(displayRefreshRate)); + if (vSyncEnabled) + return displayMaximum; + if (!displayLinkedPresentation) + return requested; + return requested > 0 ? Math.Min(requested, displayMaximum) : displayMaximum; + } + + public static bool DisplayRefreshRateChanged( + double appliedRefreshRate, + double currentRefreshRate, + double tolerance = 0.5) + { + if (double.IsNaN(currentRefreshRate) || + double.IsInfinity(currentRefreshRate) || + currentRefreshRate < 1.0) + return false; + + if (double.IsNaN(appliedRefreshRate) || + double.IsInfinity(appliedRefreshRate) || + appliedRefreshRate < 1.0) + return true; + + return Math.Abs(currentRefreshRate - appliedRefreshRate) >= tolerance; + } + + public static Vector2Int ResolutionSize(DisplayResolutionMode mode) => mode switch + { + DisplayResolutionMode.Hd => new Vector2Int(1280, 720), + DisplayResolutionMode.FullHd => new Vector2Int(1920, 1080), + DisplayResolutionMode.QuadHd => new Vector2Int(2560, 1440), + DisplayResolutionMode.UltraHd4K => new Vector2Int(3840, 2160), + _ => new Vector2Int(1920, 1080), + }; + + public static FullScreenMode FullScreenModeFor(WindowMode mode) => mode switch + { + WindowMode.Windowed => FullScreenMode.Windowed, + _ => FullScreenMode.FullScreenWindow, + }; + + public static float BackgroundDimOpacity(int percent) => + Mathf.Clamp(percent, MinimumBackgroundDimPercent, MaximumBackgroundDimPercent) * 0.01f; + + private static void NormalizeLaneKeys(Key[] laneKeys, Key[] defaults = null) + { + defaults ??= GameSettingsDefaults.LaneKeys; + var used = new HashSet(); + for (var lane = 0; lane < laneKeys.Length; lane++) + { + var key = laneKeys[lane]; + if (IsAssignableLaneKey(key) && used.Add(key)) + continue; + + for (var candidate = 0; candidate < defaults.Length; candidate++) + { + var defaultKey = defaults[candidate]; + if (!used.Add(defaultKey)) + continue; + laneKeys[lane] = defaultKey; + break; + } + } + } + + private static Key NormalizeCenterAuxKey(Key key, Key[] laneKeys) + { + if (IsAssignableLaneKey(key) && Array.IndexOf(laneKeys, key) < 0) + return key; + + var fallbacks = new[] + { + GameSettingsDefaults.CenterAuxKey, + Key.H, + Key.Space, + Key.S, + Key.A, + }; + foreach (var fallback in fallbacks) + { + if (IsAssignableLaneKey(fallback) && Array.IndexOf(laneKeys, fallback) < 0) + return fallback; + } + + foreach (Key fallback in Enum.GetValues(typeof(Key))) + { + if (IsAssignableLaneKey(fallback) && Array.IndexOf(laneKeys, fallback) < 0) + return fallback; + } + return GameSettingsDefaults.CenterAuxKey; + } + + private static Key NormalizeScratchKey(Key key) => + IsAssignableLaneKey(key) ? key : GameSettingsDefaults.ScratchKey; + + private bool ContainsProfileKey(Key key) => + Array.IndexOf(FourKeyLaneKeys, key) >= 0 || + Array.IndexOf(LaneKeys, key) >= 0 || + Array.IndexOf(SixKeyLaneKeys, key) >= 0 || + Array.IndexOf(SevenKeyLaneKeys, key) >= 0; + + private static TEnum DefinedOrDefault(TEnum value, TEnum fallback) + where TEnum : struct, Enum => + Enum.IsDefined(typeof(TEnum), value) ? value : fallback; + } +} diff --git a/Assets/ProjectQuint/Runtime/Unity/Settings/GameSettings.cs.meta b/Assets/ProjectQuint/Runtime/Unity/Settings/GameSettings.cs.meta new file mode 100644 index 0000000..1c83db0 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Settings/GameSettings.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 97c6a4273798c4bc78a729ac41412bfc \ No newline at end of file diff --git a/Assets/ProjectQuint/Runtime/Unity/Settings/GameSettingsRepository.cs b/Assets/ProjectQuint/Runtime/Unity/Settings/GameSettingsRepository.cs new file mode 100644 index 0000000..58613ac --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Settings/GameSettingsRepository.cs @@ -0,0 +1,332 @@ +using System; +using ProjectQuint.Core.Charts; +using ProjectQuint.Core.Gauge; +using ProjectQuint.Persistence; +using UnityEngine; +using UnityEngine.InputSystem; + +namespace ProjectQuint.Settings +{ + [Serializable] + internal sealed class GameSettingsDocument + { + public const int CurrentSchemaVersion = 12; + + public int SchemaVersion = CurrentSchemaVersion; + public GameSettingsData Settings = new GameSettingsData(); + } + + [Serializable] + internal sealed class GameSettingsData + { + public float NoteSpeed; + public int JudgementOffsetMilliseconds; + public int AudioOutputLatencyMilliseconds; + public int MasterVolumePercent; + public int AudioDspBufferProfile; + public int InputProcessingMode; + public int InputPollingRateHz; + public string AudioOutputDeviceId = string.Empty; + public string AudioOutputDeviceName = string.Empty; + public AudioOutputCalibrationProfile[] AudioOutputCalibrationProfiles = + Array.Empty(); + public bool AudioOutputCalibrationMigrationPending; + public bool ReactiveKeySoundsEnabled; + public int GearPosition; + public int LaneModifier; + public int GaugeType; + public bool GaugeFallbackEnabled; + public int GameplaySkin; + public int NoteSkin; + public int LongNoteStyle; + public bool LaneSeparatorsEnabled; + public bool TimingMeterEnabled; + public int FrameLimitMode; + public int DisplayResolutionMode; + public int WindowMode; + public bool ShowFpsCounter; + public bool BgaEnabled; + public bool BgaStaticBackgroundEnabled; + public int BgaDisplayMode; + public bool VSyncEnabled; + public int BackgroundDimPercent; + public string SkippedUpdateVersion = string.Empty; + public long LastUpdateCheckUtcTicks; + public string[] LaneKeys = Array.Empty(); + public string[] FourKeyLaneKeys = Array.Empty(); + public string[] SixKeyLaneKeys = Array.Empty(); + public string[] SevenKeyLaneKeys = Array.Empty(); + public string CenterAuxKey = string.Empty; + public string ScratchKey = string.Empty; + public string BmsLibraryRoot = string.Empty; + + public static GameSettingsData FromSettings(GameSettings settings) + { + var data = new GameSettingsData + { + NoteSpeed = settings.NoteSpeed, + JudgementOffsetMilliseconds = settings.JudgementOffsetMilliseconds, + AudioOutputLatencyMilliseconds = settings.AudioOutputLatencyMilliseconds, + MasterVolumePercent = settings.MasterVolumePercent, + AudioDspBufferProfile = (int)settings.AudioDspBufferProfile, + InputProcessingMode = settings.InputPollingRate == InputPollingRate.Hz1000 ? 0 : 1, + InputPollingRateHz = GameSettings.InputPollingRateHz(settings.InputPollingRate), + AudioOutputDeviceId = settings.AudioOutputDeviceId ?? string.Empty, + AudioOutputDeviceName = settings.AudioOutputDeviceName ?? string.Empty, + AudioOutputCalibrationProfiles = + settings.AudioOutputCalibrationProfiles ?? Array.Empty(), + AudioOutputCalibrationMigrationPending = + settings.AudioOutputCalibrationMigrationPending, + ReactiveKeySoundsEnabled = settings.ReactiveKeySoundsEnabled, + GearPosition = (int)settings.GearPosition, + LaneModifier = (int)settings.LaneModifier, + GaugeType = (int)settings.GaugeType, + GaugeFallbackEnabled = settings.GaugeFallbackEnabled, + GameplaySkin = (int)settings.GameplaySkin, + NoteSkin = (int)settings.NoteSkin, + LongNoteStyle = (int)settings.LongNoteStyle, + LaneSeparatorsEnabled = settings.LaneSeparatorsEnabled, + TimingMeterEnabled = settings.TimingMeterEnabled, + FrameLimitMode = (int)settings.FrameLimitMode, + DisplayResolutionMode = (int)settings.DisplayResolutionMode, + WindowMode = (int)settings.WindowMode, + ShowFpsCounter = settings.ShowFpsCounter, + BgaEnabled = settings.BgaEnabled, + BgaStaticBackgroundEnabled = settings.BgaStaticBackgroundEnabled, + BgaDisplayMode = (int)settings.BgaDisplayMode, + VSyncEnabled = settings.VSyncEnabled, + BackgroundDimPercent = settings.BackgroundDimPercent, + SkippedUpdateVersion = settings.SkippedUpdateVersion ?? string.Empty, + LastUpdateCheckUtcTicks = settings.LastUpdateCheckUtcTicks, + CenterAuxKey = settings.CenterAuxKey.ToString(), + ScratchKey = settings.ScratchKey.ToString(), + BmsLibraryRoot = settings.BmsLibraryRoot ?? string.Empty, + LaneKeys = new string[settings.LaneKeys.Length], + FourKeyLaneKeys = SerializeKeys(settings.FourKeyLaneKeys), + SixKeyLaneKeys = SerializeKeys(settings.SixKeyLaneKeys), + SevenKeyLaneKeys = SerializeKeys(settings.SevenKeyLaneKeys), + }; + + for (var lane = 0; lane < settings.LaneKeys.Length; lane++) + data.LaneKeys[lane] = settings.LaneKeys[lane].ToString(); + return data; + } + + private static string[] SerializeKeys(Key[] keys) + { + var result = new string[keys?.Length ?? 0]; + for (var index = 0; index < result.Length; index++) + result[index] = keys[index].ToString(); + return result; + } + + public GameSettings ToSettings() + { + var settings = new GameSettings + { + NoteSpeed = NoteSpeed, + JudgementOffsetMilliseconds = JudgementOffsetMilliseconds, + AudioOutputLatencyMilliseconds = AudioOutputLatencyMilliseconds, + MasterVolumePercent = MasterVolumePercent, + AudioDspBufferProfile = DefinedOrDefault( + AudioDspBufferProfile, + GameSettingsDefaults.AudioDspBuffer), + InputPollingRate = GameSettings.ParseInputPollingRate( + InputPollingRateHz, + InputProcessingMode), + AudioOutputDeviceId = AudioOutputDeviceId ?? string.Empty, + AudioOutputDeviceName = AudioOutputDeviceName ?? string.Empty, + AudioOutputCalibrationProfiles = + AudioOutputCalibrationProfiles ?? Array.Empty(), + AudioOutputCalibrationMigrationPending = + AudioOutputCalibrationMigrationPending, + ReactiveKeySoundsEnabled = ReactiveKeySoundsEnabled, + GearPosition = DefinedOrDefault(GearPosition, GameplayGearPosition.Center), + LaneModifier = DefinedOrDefault(LaneModifier, ProjectQuint.Core.Charts.LaneModifier.Off), + GaugeType = DefinedOrDefault(GaugeType, GaugeMode.Normal), + GaugeFallbackEnabled = GaugeFallbackEnabled, + GameplaySkin = DefinedOrDefault(GameplaySkin, ProjectQuint.Settings.GameplaySkin.IceBlue), + NoteSkin = DefinedOrDefault(NoteSkin, GameSettingsDefaults.NoteSkin), + LongNoteStyle = DefinedOrDefault(LongNoteStyle, GameSettingsDefaults.LongNoteStyle), + LaneSeparatorsEnabled = LaneSeparatorsEnabled, + TimingMeterEnabled = TimingMeterEnabled, + FrameLimitMode = DefinedOrDefault(FrameLimitMode, GameSettingsDefaults.FrameLimit), + DisplayResolutionMode = DefinedOrDefault(DisplayResolutionMode, GameSettingsDefaults.DisplayResolution), + WindowMode = DefinedOrDefault(WindowMode, GameSettingsDefaults.DisplayMode), + ShowFpsCounter = ShowFpsCounter, + BgaEnabled = BgaEnabled, + BgaStaticBackgroundEnabled = BgaStaticBackgroundEnabled, + BgaDisplayMode = DefinedOrDefault(BgaDisplayMode, GameSettingsDefaults.BgaMode), + VSyncEnabled = VSyncEnabled, + BackgroundDimPercent = BackgroundDimPercent, + SkippedUpdateVersion = SkippedUpdateVersion ?? string.Empty, + LastUpdateCheckUtcTicks = LastUpdateCheckUtcTicks, + BmsLibraryRoot = BmsLibraryRoot ?? string.Empty, + CenterAuxKey = GameSettings.ParseLaneKey(CenterAuxKey, GameSettingsDefaults.CenterAuxKey), + ScratchKey = GameSettings.ParseLaneKey(ScratchKey, GameSettingsDefaults.ScratchKey), + }; + + for (var lane = 0; lane < settings.LaneKeys.Length; lane++) + { + var stored = LaneKeys != null && lane < LaneKeys.Length + ? LaneKeys[lane] + : GameSettings.DefaultLaneKeyNameAt(lane); + settings.LaneKeys[lane] = GameSettings.ParseLaneKey(stored, GameSettingsDefaults.LaneKeys[lane]); + } + RestoreKeys(settings.FourKeyLaneKeys, FourKeyLaneKeys, GameSettingsDefaults.FourKeyLaneKeys); + RestoreKeys(settings.SixKeyLaneKeys, SixKeyLaneKeys, GameSettingsDefaults.SixKeyLaneKeys); + RestoreKeys(settings.SevenKeyLaneKeys, SevenKeyLaneKeys, GameSettingsDefaults.SevenKeyLaneKeys); + settings.Normalize(); + return settings; + } + + private static void RestoreKeys(Key[] target, string[] stored, Key[] defaults) + { + for (var index = 0; index < target.Length; index++) + { + var name = stored != null && index < stored.Length ? stored[index] : defaults[index].ToString(); + target[index] = GameSettings.ParseLaneKey(name, defaults[index]); + } + } + + private static TEnum DefinedOrDefault(int stored, TEnum fallback) + where TEnum : struct, Enum => + Enum.IsDefined(typeof(TEnum), stored) + ? (TEnum)Enum.ToObject(typeof(TEnum), stored) + : fallback; + } + + internal sealed class GameSettingsRepository + { + private const string NoteSpeedKey = "ProjectQuint.NoteSpeedV4"; + private const string JudgementOffsetKey = "ProjectQuint.JudgementOffsetMsV1"; + private const string AudioOutputLatencyKey = "ProjectQuint.AudioOutputLatencyMsV1"; + private const string MasterVolumeKey = "ProjectQuint.MasterVolumePercentV1"; + private const string ReactiveKeySoundKey = "ProjectQuint.ReactiveKeySoundV1"; + private const string GearPositionKey = "ProjectQuint.GearPositionV1"; + private const string LaneModifierKey = "ProjectQuint.LaneModifierV1"; + private const string GaugeTypeKey = "ProjectQuint.GaugeTypeV1"; + private const string GameplaySkinKey = "ProjectQuint.GameplaySkinV1"; + private const string NoteSkinKey = "ProjectQuint.NoteSkinV1"; + private const string LaneSeparatorsKey = "ProjectQuint.LaneSeparatorsV1"; + private const string TimingMeterKey = "ProjectQuint.TimingMeterV1"; + private const string FrameLimitKey = "ProjectQuint.FrameLimitV1"; + private const string DisplayResolutionKey = "ProjectQuint.DisplayResolutionV1"; + private const string FpsCounterKey = "ProjectQuint.FpsCounterV1"; + private const string BgaEnabledKey = "ProjectQuint.BgaEnabledV1"; + private const string VSyncKey = "ProjectQuint.VSyncV1"; + private const string BackgroundDimKey = "ProjectQuint.BackgroundDimPercentV1"; + private const string LaneKeyPrefix = "ProjectQuint.LaneKeyV2."; + private const string CenterAuxKeyPreference = "ProjectQuint.CenterAuxKeyV1"; + private const string BmsLibraryRootKey = "ProjectQuint.BmsLibraryRoot"; + + private readonly AtomicJsonFile _file; + + public GameSettingsRepository(string path = null, Action logWarning = null) + { + _file = new AtomicJsonFile( + path ?? PersistencePaths.Settings, + document => document != null && + document.SchemaVersion >= 1 && + document.SchemaVersion <= GameSettingsDocument.CurrentSchemaVersion && + document.Settings != null, + logWarning); + } + + public GameSettings Load() + { + if (_file.TryLoad(out var document, out _)) + { + var settings = document.Settings.ToSettings(); + if (document.SchemaVersion < 12) + settings.NoteSpeed = GameSettings.ConvertPreV12NoteSpeed(settings.NoteSpeed); + if (document.SchemaVersion < 3) + settings.AudioDspBufferProfile = GameSettingsDefaults.AudioDspBuffer; + if (document.SchemaVersion < 7) + settings.AudioOutputCalibrationMigrationPending = true; + if (document.SchemaVersion < GameSettingsDocument.CurrentSchemaVersion) + Save(settings); + return settings; + } + + var migrated = LoadLegacy(); + Save(migrated); + return migrated; + } + + public void Save(GameSettings settings) + { + if (settings == null) + throw new ArgumentNullException(nameof(settings)); + + settings.Normalize(); + _file.Save(new GameSettingsDocument + { + Settings = GameSettingsData.FromSettings(settings), + }); + } + + private static GameSettings LoadLegacy() + { + var settings = new GameSettings + { + NoteSpeed = PlayerPrefs.HasKey(NoteSpeedKey) + ? GameSettings.ConvertPreV12NoteSpeed(PlayerPrefs.GetFloat(NoteSpeedKey)) + : GameSettingsDefaults.NoteSpeed, + JudgementOffsetMilliseconds = PlayerPrefs.GetInt( + JudgementOffsetKey, + GameSettingsDefaults.JudgementOffsetMilliseconds), + AudioOutputLatencyMilliseconds = PlayerPrefs.GetInt( + AudioOutputLatencyKey, + GameSettingsDefaults.AudioOutputLatencyMilliseconds), + MasterVolumePercent = PlayerPrefs.GetInt(MasterVolumeKey, GameSettingsDefaults.MasterVolumePercent), + AudioDspBufferProfile = GameSettingsDefaults.AudioDspBuffer, + ReactiveKeySoundsEnabled = PlayerPrefs.GetInt(ReactiveKeySoundKey, 0) != 0, + GearPosition = LoadLegacyEnum(GearPositionKey, GameplayGearPosition.Center), + LaneModifier = LoadLegacyEnum(LaneModifierKey, ProjectQuint.Core.Charts.LaneModifier.Off), + GaugeType = LoadLegacyEnum(GaugeTypeKey, GaugeMode.Normal), + GameplaySkin = LoadLegacyEnum(GameplaySkinKey, ProjectQuint.Settings.GameplaySkin.IceBlue), + NoteSkin = LoadLegacyEnum(NoteSkinKey, GameSettingsDefaults.NoteSkin), + LongNoteStyle = GameSettingsDefaults.LongNoteStyle, + LaneSeparatorsEnabled = LoadLegacyBool(LaneSeparatorsKey, GameSettingsDefaults.LaneSeparatorsEnabled), + TimingMeterEnabled = LoadLegacyBool(TimingMeterKey, GameSettingsDefaults.TimingMeterEnabled), + FrameLimitMode = LoadLegacyEnum(FrameLimitKey, GameSettingsDefaults.FrameLimit), + DisplayResolutionMode = LoadLegacyEnum(DisplayResolutionKey, GameSettingsDefaults.DisplayResolution), + WindowMode = GameSettingsDefaults.DisplayMode, + ShowFpsCounter = LoadLegacyBool(FpsCounterKey, GameSettingsDefaults.ShowFpsCounter), + BgaEnabled = LoadLegacyBool(BgaEnabledKey, GameSettingsDefaults.BgaEnabled), + BgaDisplayMode = GameSettingsDefaults.BgaMode, + VSyncEnabled = LoadLegacyBool(VSyncKey, GameSettingsDefaults.VSyncEnabled), + BackgroundDimPercent = PlayerPrefs.GetInt( + BackgroundDimKey, + GameSettingsDefaults.BackgroundDimPercent), + CenterAuxKey = GameSettings.ParseLaneKey( + PlayerPrefs.GetString(CenterAuxKeyPreference, GameSettingsDefaults.CenterAuxKey.ToString()), + GameSettingsDefaults.CenterAuxKey), + ScratchKey = GameSettingsDefaults.ScratchKey, + BmsLibraryRoot = PlayerPrefs.GetString(BmsLibraryRootKey, string.Empty), + }; + + for (var lane = 0; lane < settings.LaneKeys.Length; lane++) + { + settings.LaneKeys[lane] = GameSettings.ParseLaneKey( + PlayerPrefs.GetString($"{LaneKeyPrefix}{lane}", GameSettings.DefaultLaneKeyNameAt(lane)), + GameSettingsDefaults.LaneKeys[lane]); + } + settings.Normalize(); + return settings; + } + + private static bool LoadLegacyBool(string key, bool fallback) => + PlayerPrefs.GetInt(key, fallback ? 1 : 0) != 0; + + private static TEnum LoadLegacyEnum(string key, TEnum fallback) + where TEnum : struct, Enum + { + var stored = PlayerPrefs.GetInt(key, Convert.ToInt32(fallback)); + return Enum.IsDefined(typeof(TEnum), stored) + ? (TEnum)Enum.ToObject(typeof(TEnum), stored) + : fallback; + } + } +} diff --git a/Assets/ProjectQuint/Runtime/Unity/Settings/GameSettingsRepository.cs.meta b/Assets/ProjectQuint/Runtime/Unity/Settings/GameSettingsRepository.cs.meta new file mode 100644 index 0000000..ba0f128 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Settings/GameSettingsRepository.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 96d00ce11e946447cb1123834d2f123b \ No newline at end of file diff --git a/Assets/ProjectQuint/Runtime/Unity/UI.meta b/Assets/ProjectQuint/Runtime/Unity/UI.meta new file mode 100644 index 0000000..0fa717a --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/UI.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 215259fe976b24da3a05b38c302cbe2a +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/ProjectQuint/Runtime/Unity/UI/AssemblyInfo.cs b/Assets/ProjectQuint/Runtime/Unity/UI/AssemblyInfo.cs new file mode 100644 index 0000000..3898698 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/UI/AssemblyInfo.cs @@ -0,0 +1,4 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("ProjectQuint.Bootstrap")] +[assembly: InternalsVisibleTo("ProjectQuint.Tests.EditMode")] diff --git a/Assets/ProjectQuint/Runtime/Unity/UI/AssemblyInfo.cs.meta b/Assets/ProjectQuint/Runtime/Unity/UI/AssemblyInfo.cs.meta new file mode 100644 index 0000000..bb994cb --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/UI/AssemblyInfo.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d2fae6ba8c82f4447aea17397aab86b6 \ No newline at end of file diff --git a/Assets/ProjectQuint/Runtime/Unity/UI/BpmDisplayFormatter.cs b/Assets/ProjectQuint/Runtime/Unity/UI/BpmDisplayFormatter.cs new file mode 100644 index 0000000..bd8f8ad --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/UI/BpmDisplayFormatter.cs @@ -0,0 +1,41 @@ +using System; +using System.Globalization; + +namespace ProjectQuint.UI +{ + internal static class BpmDisplayFormatter + { + public static string Format( + double initialTempo, + double minimumTempo, + double maximumTempo) + { + var initial = IsValid(initialTempo) ? initialTempo : 0.0; + var minimum = IsValid(minimumTempo) ? minimumTempo : initial; + var maximum = IsValid(maximumTempo) ? maximumTempo : initial; + + if (!IsValid(minimum) && IsValid(maximum)) + minimum = maximum; + if (!IsValid(maximum) && IsValid(minimum)) + maximum = minimum; + if (!IsValid(minimum) || !IsValid(maximum)) + return "BPM —"; + if (minimum > maximum) + (minimum, maximum) = (maximum, minimum); + + var minimumLabel = FormatValue(minimum); + var maximumLabel = FormatValue(maximum); + return string.Equals(minimumLabel, maximumLabel, StringComparison.Ordinal) + ? $"BPM {minimumLabel}" + : $"BPM {minimumLabel}–{maximumLabel}"; + } + + private static bool IsValid(double value) => + value > 0.0 && + !double.IsNaN(value) && + !double.IsInfinity(value); + + private static string FormatValue(double value) => + value.ToString("0.##", CultureInfo.InvariantCulture); + } +} diff --git a/Assets/ProjectQuint/Runtime/Unity/UI/BpmDisplayFormatter.cs.meta b/Assets/ProjectQuint/Runtime/Unity/UI/BpmDisplayFormatter.cs.meta new file mode 100644 index 0000000..74fbd47 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/UI/BpmDisplayFormatter.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c22f1095e578147a582f3c33d9f30aea \ No newline at end of file diff --git a/Assets/ProjectQuint/Runtime/Unity/UI/ChartFeatureRadarElement.cs b/Assets/ProjectQuint/Runtime/Unity/UI/ChartFeatureRadarElement.cs new file mode 100644 index 0000000..b5c0f6a --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/UI/ChartFeatureRadarElement.cs @@ -0,0 +1,340 @@ +using ProjectQuint.Core.Analysis; +using UnityEngine; +using UnityEngine.UIElements; + +namespace ProjectQuint.UI +{ + /// + /// Retained UI Toolkit radar used by Music Select. It briefly repaints + /// display-only values after a chart change, then stops its scheduler. + /// + internal sealed class ChartFeatureRadarElement : VisualElement + { + private const float OverflowDisplayScale = 0.18f; + private const int EdgeGradientSegments = 8; + private const float TransitionDurationSeconds = 0.20f; + private const long TransitionTickMilliseconds = 16L; + + private static readonly ChartFeatureAxis[] Axes = + { + ChartFeatureAxis.Notes, + ChartFeatureAxis.Peak, + ChartFeatureAxis.Chord, + ChartFeatureAxis.Long, + ChartFeatureAxis.SofLan, + ChartFeatureAxis.Tech, + }; + + private static readonly Color LowFeatureColor = new Color(0.10f, 0.74f, 0.92f, 1f); + private static readonly Color ReferenceFeatureColor = new Color(0.42f, 0.30f, 0.94f, 1f); + private static readonly Color ExtremeFeatureColor = new Color(0.96f, 0.36f, 0.43f, 1f); + + private readonly float[] _values = new float[Axes.Length]; + private readonly float[] _targetValues = new float[Axes.Length]; + private readonly DaylightFloatTween[] _axisTweens = + new DaylightFloatTween[Axes.Length]; + private readonly Vertex[] _fillVertices = new Vertex[Axes.Length + 1]; + private readonly ushort[] _fillIndices = new ushort[Axes.Length * 3]; + private readonly IVisualElementScheduledItem _transitionTick; + private bool _hasCurrentFeatures; + private bool _hasPresentedFeatures; + + public ChartFeatureRadarElement() + { + pickingMode = PickingMode.Ignore; + for (var axis = 0; axis < Axes.Length; axis++) + { + var indexOffset = axis * 3; + _fillIndices[indexOffset] = 0; + _fillIndices[indexOffset + 1] = (ushort)(axis + 1); + _fillIndices[indexOffset + 2] = (ushort)((axis + 1) % Axes.Length + 1); + } + + generateVisualContent += GenerateRadar; + _transitionTick = schedule + .Execute(UpdateTransition) + .Every(TransitionTickMilliseconds); + _transitionTick.Pause(); + } + + public void SetFeatures(ChartFeatureVector features) + { + var hasCurrentFeatures = features?.IsCurrent == true; + var hadCurrentFeatures = _hasCurrentFeatures; + var changed = hadCurrentFeatures != hasCurrentFeatures; + _hasCurrentFeatures = hasCurrentFeatures; + for (var axis = 0; axis < Axes.Length; axis++) + { + var next = hasCurrentFeatures + ? Mathf.Clamp(features.Value(Axes[axis]), 0f, ChartFeatureVector.MaximumValue) + : 0f; + if (!Mathf.Approximately(_targetValues[axis], next)) + { + _targetValues[axis] = next; + changed = true; + } + } + + if (!changed) + return; + + var animate = + hasCurrentFeatures && + hadCurrentFeatures && + _hasPresentedFeatures; + var now = Time.realtimeSinceStartupAsDouble; + var isRunning = false; + for (var axis = 0; axis < Axes.Length; axis++) + { + if (animate) + { + isRunning |= _axisTweens[axis].Start( + _targetValues[axis], + now, + TransitionDurationSeconds); + } + else + { + _axisTweens[axis].Snap(_targetValues[axis]); + } + + _values[axis] = _axisTweens[axis].Current; + } + + _hasPresentedFeatures = hasCurrentFeatures; + MarkDirtyRepaint(); + if (isRunning) + _transitionTick.Resume(); + else + _transitionTick.Pause(); + } + + private void UpdateTransition() + { + var now = Time.realtimeSinceStartupAsDouble; + var changed = false; + var isRunning = false; + for (var axis = 0; axis < Axes.Length; axis++) + { + changed |= _axisTweens[axis].Tick(now); + _values[axis] = _axisTweens[axis].Current; + isRunning |= _axisTweens[axis].IsRunning; + } + + if (changed) + MarkDirtyRepaint(); + if (!isRunning) + _transitionTick.Pause(); + } + + private void GenerateRadar(MeshGenerationContext context) + { + var rect = contentRect; + if (rect.width < 20f || rect.height < 20f) + return; + + var painter = context.painter2D; + var center = rect.center; + var referenceRadius = Mathf.Min(rect.width, rect.height) * 0.365f; + + painter.lineJoin = LineJoin.Round; + if (_hasCurrentFeatures) + DrawGradientFill(context, center, referenceRadius); + + // Draw the complete reference grid over the translucent data fill. + // This preserves the original hexagonal scale even when a dense chart + // covers most of the radar. + for (var ring = 1; ring <= 4; ring++) + { + var isReference = ring == 4; + painter.lineWidth = isReference ? 1.8f : 1f; + painter.strokeColor = isReference + ? new Color(0.42f, 0.30f, 0.94f, 0.46f) + : new Color(0.25f, 0.43f, 0.82f, 0.14f); + DrawPolygon(painter, center, referenceRadius * ring / 4f, null); + } + + painter.lineWidth = 1f; + painter.strokeColor = new Color(0.25f, 0.43f, 0.82f, 0.14f); + for (var axis = 0; axis < Axes.Length; axis++) + { + painter.BeginPath(); + painter.MoveTo(center); + painter.LineTo(RadarPoint(center, referenceRadius, axis)); + painter.Stroke(); + } + + if (!_hasCurrentFeatures) + return; + + DrawGradientOutline(painter, center, referenceRadius); + for (var axis = 0; axis < Axes.Length; axis++) + { + if (_values[axis] <= 0f) + continue; + var normalized = DisplayRatio(_values[axis]); + var point = RadarPoint(center, referenceRadius * normalized, axis); + DrawNode(painter, point, _values[axis]); + } + } + + internal static float DisplayRatio(float value) + { + value = Mathf.Clamp(value, 0f, ChartFeatureVector.MaximumValue); + if (value <= ChartFeatureVector.ReferenceValue) + return value / ChartFeatureVector.ReferenceValue; + + var overflowRange = + ChartFeatureVector.MaximumValue - ChartFeatureVector.ReferenceValue; + var overflowRatio = + (value - ChartFeatureVector.ReferenceValue) / overflowRange; + return 1f + overflowRatio * OverflowDisplayScale; + } + + internal static Color FeatureColor(float value) + { + value = Mathf.Clamp(value, 0f, ChartFeatureVector.MaximumValue); + const float violetStart = 80f; + if (value <= violetStart) + return LowFeatureColor; + + if (value <= ChartFeatureVector.ReferenceValue) + { + var referenceBlend = Mathf.InverseLerp( + violetStart, + ChartFeatureVector.ReferenceValue, + value); + return Color.Lerp(LowFeatureColor, ReferenceFeatureColor, referenceBlend); + } + + var extremeBlend = Mathf.InverseLerp( + ChartFeatureVector.ReferenceValue, + ChartFeatureVector.MaximumValue, + value); + return Color.Lerp(ReferenceFeatureColor, ExtremeFeatureColor, extremeBlend); + } + + private void DrawGradientFill( + MeshGenerationContext context, + Vector2 center, + float referenceRadius) + { + _fillVertices[0] = GradientVertex( + center, + new Color(LowFeatureColor.r, LowFeatureColor.g, LowFeatureColor.b, 0.08f)); + + for (var axis = 0; axis < Axes.Length; axis++) + { + var color = FeatureColor(_values[axis]); + color.a = _values[axis] > ChartFeatureVector.ReferenceValue ? 0.25f : 0.20f; + var point = RadarPoint( + center, + referenceRadius * DisplayRatio(_values[axis]), + axis); + _fillVertices[axis + 1] = GradientVertex(point, color); + } + + var mesh = context.Allocate(_fillVertices.Length, _fillIndices.Length); + mesh.SetAllVertices(_fillVertices); + mesh.SetAllIndices(_fillIndices); + } + + private void DrawGradientOutline( + Painter2D painter, + Vector2 center, + float referenceRadius) + { + painter.lineWidth = 2.8f; + for (var axis = 0; axis < Axes.Length; axis++) + { + var nextAxis = (axis + 1) % Axes.Length; + var start = RadarPoint( + center, + referenceRadius * DisplayRatio(_values[axis]), + axis); + var end = RadarPoint( + center, + referenceRadius * DisplayRatio(_values[nextAxis]), + nextAxis); + + for (var segment = 0; segment < EdgeGradientSegments; segment++) + { + var startT = segment / (float)EdgeGradientSegments; + var endT = (segment + 1f) / EdgeGradientSegments; + var colorT = (startT + endT) * 0.5f; + var interpolatedValue = Mathf.Lerp( + _values[axis], + _values[nextAxis], + colorT); + + painter.strokeColor = FeatureColor(interpolatedValue); + painter.BeginPath(); + painter.MoveTo(Vector2.Lerp(start, end, startT)); + painter.LineTo(Vector2.Lerp(start, end, endT)); + painter.Stroke(); + } + } + } + + private static Vertex GradientVertex(Vector2 point, Color color) + { + return new Vertex + { + position = new Vector3(point.x, point.y, Vertex.nearZ), + tint = color, + uv = Vector2.zero, + }; + } + + private static void DrawPolygon( + Painter2D painter, + Vector2 center, + float radius, + float[] values) + { + painter.BeginPath(); + for (var axis = 0; axis < Axes.Length; axis++) + { + var normalized = values == null + ? 1f + : DisplayRatio(values[axis]); + var point = RadarPoint(center, radius * normalized, axis); + if (axis == 0) + painter.MoveTo(point); + else + painter.LineTo(point); + } + painter.ClosePath(); + if (values != null) + painter.Fill(); + painter.Stroke(); + } + + private static Vector2 RadarPoint(Vector2 center, float radius, int axis) + { + var angle = (-90f + axis * 60f) * Mathf.Deg2Rad; + return center + new Vector2(Mathf.Cos(angle), Mathf.Sin(angle)) * radius; + } + + private static void DrawNode(Painter2D painter, Vector2 point, float value) + { + var radius = value >= ChartFeatureVector.ReferenceValue + ? 5f + : value >= 80f + ? 4.4f + : 3.8f; + painter.BeginPath(); + painter.MoveTo(point + new Vector2(0f, -radius)); + painter.LineTo(point + new Vector2(radius, 0f)); + painter.LineTo(point + new Vector2(0f, radius)); + painter.LineTo(point + new Vector2(-radius, 0f)); + painter.ClosePath(); + var featureColor = FeatureColor(value); + painter.fillColor = new Color(0.94f, 0.95f, 1f, 0.96f); + painter.Fill(); + painter.strokeColor = featureColor; + painter.lineWidth = 2f; + painter.Stroke(); + } + } +} diff --git a/Assets/ProjectQuint/Runtime/Unity/UI/ChartFeatureRadarElement.cs.meta b/Assets/ProjectQuint/Runtime/Unity/UI/ChartFeatureRadarElement.cs.meta new file mode 100644 index 0000000..6a2afac --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/UI/ChartFeatureRadarElement.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7c1ba9858a8c48b69838dcc8fd502bf3 diff --git a/Assets/ProjectQuint/Runtime/Unity/UI/CourseClearLampView.cs b/Assets/ProjectQuint/Runtime/Unity/UI/CourseClearLampView.cs new file mode 100644 index 0000000..074d6c1 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/UI/CourseClearLampView.cs @@ -0,0 +1,55 @@ +using ProjectQuint.Library; +using UnityEngine.UIElements; + +namespace ProjectQuint.UI +{ + internal enum CourseClearLampState + { + NotPlayed, + Failed, + Cleared, + } + + /// + /// Shared course-certification lamp. The stored course record remains the + /// only source of truth; this view only maps its three visible states. + /// + internal static class CourseClearLampView + { + private const string NotPlayedClass = "pq-course-lamp--not-played"; + private const string FailedClass = "pq-course-lamp--failed"; + private const string ClearedClass = "pq-course-lamp--cleared"; + + public static VisualElement Create(string name = "course-lamp") + { + var lamp = new VisualElement + { + name = name, + pickingMode = PickingMode.Ignore, + }; + lamp.AddToClassList("pq-course-lamp"); + SetState(lamp, CourseClearLampState.NotPlayed); + return lamp; + } + + public static CourseClearLampState Resolve(AeryCourseRecord record) + { + if (record?.HasRecord != true) + return CourseClearLampState.NotPlayed; + return record.Cleared + ? CourseClearLampState.Cleared + : CourseClearLampState.Failed; + } + + public static void SetState( + VisualElement lamp, + CourseClearLampState state) + { + if (lamp == null) + return; + lamp.EnableInClassList(NotPlayedClass, state == CourseClearLampState.NotPlayed); + lamp.EnableInClassList(FailedClass, state == CourseClearLampState.Failed); + lamp.EnableInClassList(ClearedClass, state == CourseClearLampState.Cleared); + } + } +} diff --git a/Assets/ProjectQuint/Runtime/Unity/UI/CourseClearLampView.cs.meta b/Assets/ProjectQuint/Runtime/Unity/UI/CourseClearLampView.cs.meta new file mode 100644 index 0000000..71e2da6 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/UI/CourseClearLampView.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 04beceba6b7f64e65bf3cdc39b19c800 \ No newline at end of file diff --git a/Assets/ProjectQuint/Runtime/Unity/UI/DaylightFloatTween.cs b/Assets/ProjectQuint/Runtime/Unity/UI/DaylightFloatTween.cs new file mode 100644 index 0000000..3adcf22 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/UI/DaylightFloatTween.cs @@ -0,0 +1,91 @@ +using UnityEngine; + +namespace ProjectQuint.UI +{ + internal enum DaylightTweenEasing + { + EaseOutCubic, + SmoothStep, + } + + /// + /// Allocation-free display-only interpolation for retained UI values. + /// Authoritative application state must never be read back from this type. + /// + internal struct DaylightFloatTween + { + private float _from; + private float _target; + private double _startedAt; + private float _durationSeconds; + private DaylightTweenEasing _easing; + + public float Current { get; private set; } + + public bool IsRunning { get; private set; } + + public void Snap(float value) + { + _from = value; + _target = value; + Current = value; + _startedAt = 0d; + _durationSeconds = 0f; + IsRunning = false; + } + + public bool Start( + float target, + double now, + float durationSeconds, + DaylightTweenEasing easing = DaylightTweenEasing.EaseOutCubic) + { + Tick(now); + if (durationSeconds <= 0f || Mathf.Approximately(Current, target)) + { + Snap(target); + return false; + } + + _from = Current; + _target = target; + _startedAt = now; + _durationSeconds = durationSeconds; + _easing = easing; + IsRunning = true; + return true; + } + + public bool Tick(double now) + { + if (!IsRunning) + return false; + + var progress = Mathf.Clamp01( + (float)((now - _startedAt) / _durationSeconds)); + var easedProgress = _easing == DaylightTweenEasing.SmoothStep + ? SmoothStep(progress) + : EaseOutCubic(progress); + Current = Mathf.LerpUnclamped(_from, _target, easedProgress); + if (progress < 1f) + return true; + + Current = _target; + IsRunning = false; + return true; + } + + internal static float EaseOutCubic(float progress) + { + progress = Mathf.Clamp01(progress); + var remaining = 1f - progress; + return 1f - remaining * remaining * remaining; + } + + internal static float SmoothStep(float progress) + { + progress = Mathf.Clamp01(progress); + return progress * progress * (3f - 2f * progress); + } + } +} diff --git a/Assets/ProjectQuint/Runtime/Unity/UI/DaylightFloatTween.cs.meta b/Assets/ProjectQuint/Runtime/Unity/UI/DaylightFloatTween.cs.meta new file mode 100644 index 0000000..20303f1 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/UI/DaylightFloatTween.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 059216a17e6a41d5975e752ba112a024 diff --git a/Assets/ProjectQuint/Runtime/Unity/UI/DaylightHeaderView.cs b/Assets/ProjectQuint/Runtime/Unity/UI/DaylightHeaderView.cs new file mode 100644 index 0000000..8830060 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/UI/DaylightHeaderView.cs @@ -0,0 +1,114 @@ +using System; +using UnityEngine; +using UnityEngine.UIElements; + +namespace ProjectQuint.UI +{ + internal enum DaylightHeaderVariant + { + Screen, + Compact, + } + + internal enum DaylightHeaderTone + { + Dark, + Light, + } + + /// + /// Shared Daylight Prism title rail. Screens and modal cards provide only a host, + /// content and tone so title geometry cannot drift between presenters. + /// + internal sealed class DaylightHeaderView + { + private readonly Font _multilingualFont; + private readonly Font _displayFont; + + public DaylightHeaderView( + VisualElement host, + string title, + string subtitle, + DaylightHeaderVariant variant, + DaylightHeaderTone tone, + Font multilingualFont, + Font displayFont, + string titleName = null, + string subtitleName = null) + { + Host = host ?? throw new ArgumentNullException(nameof(host)); + _multilingualFont = + multilingualFont ?? throw new ArgumentNullException(nameof(multilingualFont)); + _displayFont = displayFont ?? throw new ArgumentNullException(nameof(displayFont)); + + Host.Clear(); + Host.AddToClassList("pq-daylight-header"); + Host.AddToClassList( + variant == DaylightHeaderVariant.Compact + ? "pq-daylight-header--compact" + : "pq-daylight-header--screen"); + Host.AddToClassList( + tone == DaylightHeaderTone.Light + ? "pq-daylight-header--light" + : "pq-daylight-header--dark"); + + var accent = new VisualElement + { + name = string.IsNullOrWhiteSpace(titleName) + ? null + : $"{titleName}-accent", + pickingMode = PickingMode.Ignore, + }; + accent.AddToClassList("pq-daylight-header__accent"); + + var copy = new VisualElement + { + pickingMode = PickingMode.Ignore, + }; + copy.AddToClassList("pq-daylight-header__copy"); + + Title = new Label + { + name = titleName, + pickingMode = PickingMode.Ignore, + }; + Title.AddToClassList("pq-daylight-header__title"); + Title.AddToClassList(DaylightPrismFontPolicy.DisplayClassName); + + Subtitle = new Label + { + name = subtitleName, + pickingMode = PickingMode.Ignore, + }; + Subtitle.AddToClassList("pq-daylight-header__subtitle"); + Subtitle.AddToClassList(DaylightPrismFontPolicy.DisplayClassName); + + copy.Add(Title); + copy.Add(Subtitle); + Host.Add(accent); + Host.Add(copy); + SetContent(title, subtitle); + } + + public VisualElement Host { get; } + public Label Title { get; } + public Label Subtitle { get; } + + public void SetContent(string title, string subtitle) + { + DaylightPrismFontPolicy.SetText( + Title, + title ?? string.Empty, + _multilingualFont, + _displayFont); + DaylightPrismFontPolicy.SetText( + Subtitle, + subtitle ?? string.Empty, + _multilingualFont, + _displayFont); + Subtitle.style.display = string.IsNullOrWhiteSpace(subtitle) + ? DisplayStyle.None + : DisplayStyle.Flex; + } + } +} diff --git a/Assets/ProjectQuint/Runtime/Unity/UI/DaylightHeaderView.cs.meta b/Assets/ProjectQuint/Runtime/Unity/UI/DaylightHeaderView.cs.meta new file mode 100644 index 0000000..7476084 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/UI/DaylightHeaderView.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d0b68142a0c9c4a20a4e02b7dd309466 \ No newline at end of file diff --git a/Assets/ProjectQuint/Runtime/Unity/UI/DaylightMetricDeltaView.cs b/Assets/ProjectQuint/Runtime/Unity/UI/DaylightMetricDeltaView.cs new file mode 100644 index 0000000..88b54a3 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/UI/DaylightMetricDeltaView.cs @@ -0,0 +1,112 @@ +using System; +using UnityEngine; +using UnityEngine.UIElements; + +namespace ProjectQuint.UI +{ + internal enum DaylightMetricDeltaTone + { + Hidden, + Improvement, + Regression, + Neutral, + } + + internal readonly struct DaylightMetricDeltaContent + { + public DaylightMetricDeltaContent( + string value, + string delta, + DaylightMetricDeltaTone tone) + { + Value = value ?? string.Empty; + Delta = delta ?? string.Empty; + Tone = tone; + } + + public string Value { get; } + public string Delta { get; } + public DaylightMetricDeltaTone Tone { get; } + } + + /// + /// Shared value/delta binding for Daylight Prism metric rows. Layout remains + /// owned by UXML while font selection, visibility and semantic tone stay + /// consistent between results, profile and future comparison surfaces. + /// + internal sealed class DaylightMetricDeltaView + { + private const string ImprovementClass = "is-improvement"; + private const string RegressionClass = "is-regression"; + private const string NeutralClass = "is-neutral"; + private const string PositiveClass = "is-positive"; + private const string NegativeClass = "is-negative"; + + private readonly Font _multilingualFont; + private readonly Font _displayFont; + + public DaylightMetricDeltaView( + VisualElement root, + string valueName, + string deltaName, + Font multilingualFont, + Font displayFont) + { + Root = root ?? throw new ArgumentNullException(nameof(root)); + _multilingualFont = + multilingualFont ?? throw new ArgumentNullException(nameof(multilingualFont)); + _displayFont = displayFont ?? throw new ArgumentNullException(nameof(displayFont)); + Value = RequireLabel(valueName); + Delta = RequireLabel(deltaName); + Root.AddToClassList("pq-metric-delta"); + Value.AddToClassList("pq-metric-delta__value"); + Delta.AddToClassList("pq-metric-delta__delta"); + } + + public VisualElement Root { get; } + public Label Value { get; } + public Label Delta { get; } + + public void SetContent( + string value, + string delta, + DaylightMetricDeltaTone tone) + { + DaylightPrismFontPolicy.SetText( + Value, + value ?? string.Empty, + _multilingualFont, + _displayFont); + DaylightPrismFontPolicy.SetText( + Delta, + delta ?? string.Empty, + _multilingualFont, + _displayFont); + + Delta.EnableInClassList(ImprovementClass, tone == DaylightMetricDeltaTone.Improvement); + Delta.EnableInClassList(RegressionClass, tone == DaylightMetricDeltaTone.Regression); + Delta.EnableInClassList(NeutralClass, tone == DaylightMetricDeltaTone.Neutral); + var normalizedDelta = (delta ?? string.Empty).TrimStart(); + Delta.EnableInClassList( + PositiveClass, + normalizedDelta.StartsWith("+", StringComparison.Ordinal)); + Delta.EnableInClassList( + NegativeClass, + normalizedDelta.StartsWith("-", StringComparison.Ordinal)); + Delta.style.display = + tone == DaylightMetricDeltaTone.Hidden || string.IsNullOrWhiteSpace(delta) + ? DisplayStyle.None + : DisplayStyle.Flex; + } + + public void SetContent(DaylightMetricDeltaContent content) => + SetContent(content.Value, content.Delta, content.Tone); + + private Label RequireLabel(string name) + { + var label = Root.Q