프로젝트 소스 공개

This commit is contained in:
2026-09-06 17:11:40 +09:00
commit 0f0431de5a
676 changed files with 106118 additions and 0 deletions
+798
View File
@@ -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:
<length>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:
<length>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:
<length>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:
<length>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:
<length>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:
<length>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:
<length>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:
<length>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
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 3f9215ea0144899419cfbc0957140d3f
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
File diff suppressed because it is too large Load Diff
@@ -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:
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 2520ca8182cfd49cabc3a8e7a80706b6
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 2cac0cde16bcd41db91aae47d021c3cf
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 501e3597be9954100bdbb5e7f8a574e2
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: cfff49ae6cc144318ba88c227fc3e88b
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -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: []
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: b688e6cf157394eb994baa18eb318210
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 10238152fc3374e21808394d5539c5e9
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -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
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: a51d0bf46d0fd4e0a8224569e29b7cb3
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: b9fcfc305c32f47c6894393884afbe27
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: dbee217e0a68d43ec9fcf523aa9b915d
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: cc3b5549db61d4e829d9608adfb38452
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

@@ -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:
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 39aa599a9465344fd9bbf8714fe9e66d
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -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
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 608820f0ced354ca8984048c7444040b
@@ -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 = "<string>&#169; 2026 Team Denia</string>";
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<XmlNode>().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<string>();
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
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: b0d7464bb03b24dd4b47b33d5bcddcbf
@@ -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<Texture2D>(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
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 262f464501a9e4e74b58ab78ccd699d7
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 9d3df3121b0094dfc8d2b7b52f6be9b9
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 6248c2c2485984703b8814051c952fe3
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 407742959808e452d95dc237db8e178b
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 529df0bdea17b4c449d335536cf75642
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>English</string>
<key>CFBundleExecutable</key>
<string>ProjectQuintInput</string>
<key>CFBundleIdentifier</key>
<string>com.teamdenia.projectquint.input</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>ProjectQuintInput</string>
<key>CFBundlePackageType</key>
<string>BNDL</string>
<key>CFBundleShortVersionString</key>
<string>1.0.0</string>
<key>CFBundleVersion</key>
<string>1</string>
</dict>
</plist>
@@ -0,0 +1,115 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>files</key>
<dict/>
<key>files2</key>
<dict/>
<key>rules</key>
<dict>
<key>^Resources/</key>
<true/>
<key>^Resources/.*\.lproj/</key>
<dict>
<key>optional</key>
<true/>
<key>weight</key>
<real>1000</real>
</dict>
<key>^Resources/.*\.lproj/locversion.plist$</key>
<dict>
<key>omit</key>
<true/>
<key>weight</key>
<real>1100</real>
</dict>
<key>^Resources/Base\.lproj/</key>
<dict>
<key>weight</key>
<real>1010</real>
</dict>
<key>^version.plist$</key>
<true/>
</dict>
<key>rules2</key>
<dict>
<key>.*\.dSYM($|/)</key>
<dict>
<key>weight</key>
<real>11</real>
</dict>
<key>^(.*/)?\.DS_Store$</key>
<dict>
<key>omit</key>
<true/>
<key>weight</key>
<real>2000</real>
</dict>
<key>^(Frameworks|SharedFrameworks|PlugIns|Plug-ins|XPCServices|Helpers|MacOS|Library/(Automator|Spotlight|LoginItems))/</key>
<dict>
<key>nested</key>
<true/>
<key>weight</key>
<real>10</real>
</dict>
<key>^.*</key>
<true/>
<key>^Info\.plist$</key>
<dict>
<key>omit</key>
<true/>
<key>weight</key>
<real>20</real>
</dict>
<key>^PkgInfo$</key>
<dict>
<key>omit</key>
<true/>
<key>weight</key>
<real>20</real>
</dict>
<key>^Resources/</key>
<dict>
<key>weight</key>
<real>20</real>
</dict>
<key>^Resources/.*\.lproj/</key>
<dict>
<key>optional</key>
<true/>
<key>weight</key>
<real>1000</real>
</dict>
<key>^Resources/.*\.lproj/locversion.plist$</key>
<dict>
<key>omit</key>
<true/>
<key>weight</key>
<real>1100</real>
</dict>
<key>^Resources/Base\.lproj/</key>
<dict>
<key>weight</key>
<real>1010</real>
</dict>
<key>^[^/]+$</key>
<dict>
<key>nested</key>
<true/>
<key>weight</key>
<real>10</real>
</dict>
<key>^embedded\.provisionprofile$</key>
<dict>
<key>weight</key>
<real>20</real>
</dict>
<key>^version\.plist$</key>
<dict>
<key>weight</key>
<real>20</real>
</dict>
</dict>
</dict>
</plist>
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: c361e5c9916a44c1b72e671552512b9c
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>English</string>
<key>CFBundleExecutable</key>
<string>ProjectQuintUpdater</string>
<key>CFBundleIdentifier</key>
<string>com.teamdenia.projectquint.updater</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>ProjectQuintUpdater</string>
<key>CFBundlePackageType</key>
<string>BNDL</string>
<key>CFBundleShortVersionString</key>
<string>1.0.0</string>
<key>CFBundleVersion</key>
<string>1</string>
</dict>
</plist>
@@ -0,0 +1,115 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>files</key>
<dict/>
<key>files2</key>
<dict/>
<key>rules</key>
<dict>
<key>^Resources/</key>
<true/>
<key>^Resources/.*\.lproj/</key>
<dict>
<key>optional</key>
<true/>
<key>weight</key>
<real>1000</real>
</dict>
<key>^Resources/.*\.lproj/locversion.plist$</key>
<dict>
<key>omit</key>
<true/>
<key>weight</key>
<real>1100</real>
</dict>
<key>^Resources/Base\.lproj/</key>
<dict>
<key>weight</key>
<real>1010</real>
</dict>
<key>^version.plist$</key>
<true/>
</dict>
<key>rules2</key>
<dict>
<key>.*\.dSYM($|/)</key>
<dict>
<key>weight</key>
<real>11</real>
</dict>
<key>^(.*/)?\.DS_Store$</key>
<dict>
<key>omit</key>
<true/>
<key>weight</key>
<real>2000</real>
</dict>
<key>^(Frameworks|SharedFrameworks|PlugIns|Plug-ins|XPCServices|Helpers|MacOS|Library/(Automator|Spotlight|LoginItems))/</key>
<dict>
<key>nested</key>
<true/>
<key>weight</key>
<real>10</real>
</dict>
<key>^.*</key>
<true/>
<key>^Info\.plist$</key>
<dict>
<key>omit</key>
<true/>
<key>weight</key>
<real>20</real>
</dict>
<key>^PkgInfo$</key>
<dict>
<key>omit</key>
<true/>
<key>weight</key>
<real>20</real>
</dict>
<key>^Resources/</key>
<dict>
<key>weight</key>
<real>20</real>
</dict>
<key>^Resources/.*\.lproj/</key>
<dict>
<key>optional</key>
<true/>
<key>weight</key>
<real>1000</real>
</dict>
<key>^Resources/.*\.lproj/locversion.plist$</key>
<dict>
<key>omit</key>
<true/>
<key>weight</key>
<real>1100</real>
</dict>
<key>^Resources/Base\.lproj/</key>
<dict>
<key>weight</key>
<real>1010</real>
</dict>
<key>^[^/]+$</key>
<dict>
<key>nested</key>
<true/>
<key>weight</key>
<real>10</real>
</dict>
<key>^embedded\.provisionprofile$</key>
<dict>
<key>weight</key>
<real>20</real>
</dict>
<key>^version\.plist$</key>
<dict>
<key>weight</key>
<real>20</real>
</dict>
</dict>
</dict>
</plist>
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 77996964903b549a3ba6124b7f2e91ac
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 17adf60357a0142419c85a863df17c9b
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: fa19f129aed7947eba7998908f182719
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -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<OnsetGroup> BuildOnsetGroups(
IReadOnlyList<PlayableNote> source,
int totalLaneCount,
out List<PlayableNote> uniqueNotes,
out int anomalyCount)
{
var sorted = new List<PlayableNote>();
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<PlayableNote>(sorted.Count);
var groups = new List<OnsetGroup>();
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<OnsetGroup> 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<OnsetGroup> 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<OnsetGroup> 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<PlayableNote> notes,
IReadOnlyList<OnsetGroup> groups,
double activeStart,
double activeEnd,
int laneCount)
{
var byLane = new List<HoldInterval>[laneCount];
for (var lane = 0; lane < laneCount; lane++)
byLane[lane] = new List<HoldInterval>();
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<HoldInterval>();
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<HoldSweepEvent>(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<OnsetGroup> 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<PlayableNote> notes,
IReadOnlyList<OnsetGroup> groups,
double activeDuration,
int keyCount,
bool hasScratch)
{
var laneTimes = new Dictionary<int, double>();
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<OnsetGroup> 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; }
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: b0f6f0d468cb04ff397643fcc59325e7
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 6196c448b624e48dbbc87bf93d7c2273
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,175 @@
using System;
using System.Collections.Generic;
using ProjectQuint.Core.Timing;
namespace ProjectQuint.Core.Bms
{
public readonly struct BmsFraction : IEquatable<BmsFraction>, IComparable<BmsFraction>
{
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<BmsPosition>, IComparable<BmsPosition>
{
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<string, string> WavDefinitions { get; } = new Dictionary<string, string>(StringComparer.Ordinal);
public Dictionary<string, string> BmpDefinitions { get; } = new Dictionary<string, string>(StringComparer.Ordinal);
public Dictionary<string, double> ExtendedBpms { get; } = new Dictionary<string, double>(StringComparer.Ordinal);
public Dictionary<string, double> Stops { get; } = new Dictionary<string, double>(StringComparer.Ordinal);
public Dictionary<string, double> Scrolls { get; } = new Dictionary<string, double>(StringComparer.Ordinal);
public Dictionary<int, double> MeasureLengths { get; } = new Dictionary<int, double>();
public List<BmsRawEvent> Events { get; } = new List<BmsRawEvent>();
public List<string> Warnings { get; } = new List<string>();
}
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<string, string> WavDefinitions { get; set; } = new Dictionary<string, string>(StringComparer.Ordinal);
public Dictionary<string, string> BmpDefinitions { get; set; } = new Dictionary<string, string>(StringComparer.Ordinal);
public List<BmsNote> Notes { get; } = new List<BmsNote>();
public List<BmsAudioEvent> BackgroundSounds { get; } = new List<BmsAudioEvent>();
public List<BmsLaneSoundEvent> LaneSoundEvents { get; } = new List<BmsLaneSoundEvent>();
public List<BmsBgaEvent> BgaEvents { get; } = new List<BmsBgaEvent>();
public List<string> Warnings { get; } = new List<string>();
public int ScratchObjectCount { get; set; }
public double Duration { get; set; }
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 8b835aeba53ee44eca669ba79b1f4fad
@@ -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<string> 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<string, string> 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<int> _randomValues = new Stack<int>();
private readonly Stack<ConditionalFrame> _conditions = new Stack<ConditionalFrame>();
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<string>(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();
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 995f0998f5d77462eb5d93d5ddd9c47e
@@ -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; }
}
/// <summary>
/// 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.
/// </summary>
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<string, string>(
source.WavDefinitions,
StringComparer.Ordinal),
VisualResources = new Dictionary<string, string>(
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),
};
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 61f617e6ec3b74517a372d950d8a7b95
@@ -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<string, string>(document.WavDefinitions, StringComparer.Ordinal),
BmpDefinitions = new Dictionary<string, string>(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<BmsRawEvent> MergeDuplicateChannelRows(IEnumerable<BmsRawEvent> events)
{
var backgroundEvents = new List<BmsRawEvent>();
var merged = new Dictionary<MergeKey, BmsRawEvent>();
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<int, double> 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<TimedEvent> BuildTimedEvents(
BmsDocument document,
List<BmsRawEvent> events,
double[] measureStarts,
List<string> warnings,
out TempoMap tempoMap)
{
var result = new List<TimedEvent>(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<TempoMapNode> { 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<TimedEvent> events,
TempoMap tempoMap,
List<string> warnings)
{
var nodes = new List<ScrollMapNode>();
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<TimedEvent> 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<TimedEvent> 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<TimedEvent> 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<BmsNote>();
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<TimedEvent> 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<TimedEvent> 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<TimedEvent> 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<TimedEvent> 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<TimedEvent> 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<BmsNote>();
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<TimedEvent> 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<int, double> 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<string> warnings, string warning)
{
if (!warnings.Contains(warning))
warnings.Add(warning);
}
private readonly struct MergeKey : IEquatable<MergeKey>
{
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; }
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: baa77492ad43445629ed6490886a40e3
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: d2bc03fb2c08c47b1b20db316c595ca2
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -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; }
}
/// <summary>
/// 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.
/// </summary>
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<string, string> AudioResources { get; set; } =
new Dictionary<string, string>(StringComparer.Ordinal);
public Dictionary<string, string> VisualResources { get; set; } =
new Dictionary<string, string>(StringComparer.Ordinal);
public List<PlayableNote> Notes { get; } = new List<PlayableNote>();
public List<PlayableAudioEvent> BackgroundAudioEvents { get; } = new List<PlayableAudioEvent>();
public List<PlayableLaneSoundEvent> LaneSoundEvents { get; } = new List<PlayableLaneSoundEvent>();
public List<PlayableVisualEvent> VisualEvents { get; } = new List<PlayableVisualEvent>();
public List<string> Warnings { get; } = new List<string>();
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);
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 06a0a05da456f45cf80b873aee380ba0
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: c4d67179ecaf44a5937315f956dfa7a9
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,26 @@
using ProjectQuint.Core.Charts;
namespace ProjectQuint.Core.Course
{
/// <summary>
/// Competitive course runs permit only the authored lane order or its mirror.
/// RANDOM is normalized to the authored order before any stage is transformed.
/// </summary>
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;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: d88302233d278417d9827be5358f8de2
@@ -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<CourseStageResult> _stageResults =
new List<CourseStageResult>(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<CourseStageResult> 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;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 50b6be1327764ee4ac0141e729418c72
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 5538736f60e3447cb97e3a501798db0c
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,120 @@
using System;
using ProjectQuint.Core.Judgement;
namespace ProjectQuint.Core.Gauge
{
/// <summary>
/// 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.
/// </summary>
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; }
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 8f9710e41db44ce3a2317d8ef1f95ed7
@@ -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,
}
/// <summary>
/// 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.
/// </summary>
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;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: f17a55945f92a41b8b11cafbae0b788a
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: ce17824f818ae4ce59c3510373fb2848
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,46 @@
using System;
namespace ProjectQuint.Core.Identity
{
public readonly struct ChartId : IEquatable<ChartId>
{
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}");
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 8a50d6052e42b438f902e8410ddb3682
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 831a80b86d35b4af99c56b93f4ad623d
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -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<double>();
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<double>();
var tickCount = (int)Math.Floor((endBeat - startBeat + 0.000000001) / BeatInterval);
if (tickCount <= 0)
return Array.Empty<double>();
var result = new List<double>(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);
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: a557f3222278452785f524b4dd244e27
@@ -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<PlayableNote, bool> _suppressMissPenalty;
private readonly List<RuntimeNote> _notes;
private readonly List<RuntimeNote>[] _notesByLane;
private readonly Dictionary<int, RuntimeNote> _notesById;
private readonly List<TimingSample> _recentTimingSamples = new List<TimingSample>(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<RuntimeNote> _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<JudgementResult> Judged;
public RhythmJudgementEngine(
IEnumerable<PlayableNote> notes,
JudgementProfile profile = null,
int laneCount = 0,
TempoMap tempoMap = null,
LongNoteTickPolicy longNoteTickPolicy = null,
LongNoteReleasePolicy longNoteReleasePolicy = null,
Func<PlayableNote, bool> 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<PlayableNote>())
.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<RuntimeNote>[laneCount];
_lanePendingCursors = new int[laneCount];
_activeLongNotes = new RuntimeNote[laneCount];
_failedLongNotes = new List<RuntimeNote>(laneCount);
for (var lane = 0; lane < _notesByLane.Length; lane++)
_notesByLane[lane] = new List<RuntimeNote>();
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<TimingSample> RecentTimingSamples => _recentTimingSamples;
public IReadOnlyList<int> 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<double>();
}
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; }
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 97c17f28bde3e452bb4ad0bf51210d12
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: dc48a4e4f54c34f28870b1b3532ca1f0
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -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<CollectionGroupSummary>(),
Array.Empty<CollectionGroupSummary>(),
Array.Empty<CollectionGroupSummary>());
public ProfileCollectionSnapshot(
CollectionGroupSummary total,
IReadOnlyList<CollectionGroupSummary> packs,
IReadOnlyList<CollectionGroupSummary> keyModes,
IReadOnlyList<CollectionGroupSummary> difficulties)
{
Total = total;
Packs = packs ?? Array.Empty<CollectionGroupSummary>();
KeyModes = keyModes ?? Array.Empty<CollectionGroupSummary>();
Difficulties = difficulties ?? Array.Empty<CollectionGroupSummary>();
}
public CollectionGroupSummary Total { get; }
public IReadOnlyList<CollectionGroupSummary> Packs { get; }
public IReadOnlyList<CollectionGroupSummary> KeyModes { get; }
public IReadOnlyList<CollectionGroupSummary> 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<CourseCollectionEntry>());
public ProfileCourseSnapshot(
int recordedCourses,
int clearedCourses,
int totalPlayCount,
IReadOnlyList<CourseCollectionEntry> entries,
string featuredBadgeCourseId = null)
{
RecordedCourses = Math.Max(0, recordedCourses);
ClearedCourses = Math.Max(0, clearedCourses);
TotalPlayCount = Math.Max(0, totalPlayCount);
Entries = entries ?? Array.Empty<CourseCollectionEntry>();
CertificationBadges = BuildCertificationBadges(Entries);
FeaturedBadgeCourseId = ResolveFeaturedBadgeCourseId(
CertificationBadges,
featuredBadgeCourseId);
}
public int RecordedCourses { get; }
public int ClearedCourses { get; }
public int TotalPlayCount { get; }
public IReadOnlyList<CourseCollectionEntry> Entries { get; }
public IReadOnlyList<CourseCertificationBadge> 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<CourseCertificationBadge> BuildCertificationBadges(
IReadOnlyList<CourseCollectionEntry> entries)
{
var badges = new List<CourseCertificationBadge>();
foreach (var entry in entries ?? Array.Empty<CourseCollectionEntry>())
{
if (entry.Cleared)
badges.Add(new CourseCertificationBadge(entry));
}
return badges;
}
private static string ResolveFeaturedBadgeCourseId(
IReadOnlyList<CourseCertificationBadge> badges,
string requestedCourseId)
{
var requested = (requestedCourseId ?? string.Empty).Trim();
foreach (var badge in badges ?? Array.Empty<CourseCertificationBadge>())
{
if (string.Equals(badge.CourseId, requested, StringComparison.OrdinalIgnoreCase))
return badge.CourseId;
}
return badges != null && badges.Count > 0 ? badges[0].CourseId : string.Empty;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: abc0e008a277e4267aad53209773e6bb
@@ -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<RatingChartEntry> 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<RatingChartEntry>();
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<RatingChartEntry> 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<RatingContribution> contributions)
{
var bestByChart = new Dictionary<string, double>(StringComparer.Ordinal);
foreach (var item in contributions ?? Array.Empty<RatingContribution>())
{
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));
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 459ea391724314c1397e5b3531e642d3
@@ -0,0 +1,14 @@
{
"name": "ProjectQuint.Core",
"rootNamespace": "ProjectQuint.Core",
"references": [],
"includePlatforms": [],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"precompiledReferences": [],
"autoReferenced": true,
"defineConstraints": [],
"versionDefines": [],
"noEngineReferences": true
}
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 3b5f07eda49ea4e18b3acb164cc392c1
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 823010ad9f0f4b81b0b9bbee2ff34369
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,29 @@
using System.Globalization;
namespace ProjectQuint.Core.Release
{
/// <summary>
/// Public product version metadata shared by the runtime, release tooling, and update contract.
/// Persistent-data schema versions remain independent from this product version.
/// </summary>
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})";
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: e73b647666ee4452a7f5d0ed2735461d
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 3efd064767d294d688e7a4fafe935965
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -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;
}
/// <summary>
/// Robustly estimates audible output latency from tap-minus-scheduled-click samples.
/// The estimator is deterministic and deliberately independent from Unity and FMOD.
/// </summary>
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<double> 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<double>(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<double>(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<double> CopyFinite(IReadOnlyList<double> values)
{
var result = new List<double>(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<double> sorted)
{
var middle = sorted.Count / 2;
return sorted.Count % 2 == 0
? (sorted[middle - 1] + sorted[middle]) * 0.5
: sorted[middle];
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: f9ad2638954b4c7682a159ec2ad15d3d
@@ -0,0 +1,43 @@
using System;
using System.Collections.Generic;
namespace ProjectQuint.Core.Timing
{
/// <summary>
/// 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.
/// </summary>
public static class AudioSyncTapMatcher
{
public const double MaximumOffsetSeconds = 0.650;
public static bool TryMatchExpected(
IReadOnlyList<double> 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<double> clickTimes,
int expectedClickIndex,
double currentTime)
{
if (clickTimes == null)
throw new ArgumentNullException(nameof(clickTimes));
return expectedClickIndex >= 0 &&
expectedClickIndex < clickTimes.Count &&
currentTime > clickTimes[expectedClickIndex] + MaximumOffsetSeconds;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 37c9a6416f6248c39db4c32d614aa5d3
@@ -0,0 +1,321 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace ProjectQuint.Core.Timing
{
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
public sealed class TempoMap
{
private const double Epsilon = 0.000000001;
private readonly TempoMapNode[] _nodes;
public TempoMap(IEnumerable<TempoMapNode> 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<TempoMapNode> 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; }
}
/// <summary>
/// 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.
/// </summary>
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<ScrollMapNode>())
{
}
public ScrollMap(TempoMap tempoMap, IEnumerable<ScrollMapNode> 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<ScrollMapNode>(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<ScrollMapNode> 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;
}
/// <summary>
/// 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.
/// </summary>
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);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 287be2f5502b49849f5a30773b68e98a
@@ -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;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: fd5a9a33c48fb42df9852b599f5a3eeb
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: f6ab15a3928614b1dae5cecabd46a518
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -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")]
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 2af160e05b69349aeb7ac43baa29c62f
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: a8a18e0ff9fe24ad6a98a191cf6c35dd
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,5 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("ProjectQuint.Application")]
[assembly: InternalsVisibleTo("ProjectQuint.Bootstrap")]
[assembly: InternalsVisibleTo("ProjectQuint.Tests.EditMode")]
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 2fb26024c41234d0798636f56c78e9f8
@@ -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; }
}
/// <summary>
/// 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.
/// </summary>
internal sealed class AudioAssetStore : IDisposable
{
private static readonly string[] AudioExtensionFallbacks = { ".wav", ".ogg", ".mp3" };
internal const long DefaultDecodedPcmBudgetBytes = 1536L * 1024L * 1024L;
private readonly Dictionary<string, AudioClip> _clips =
new Dictionary<string, AudioClip>(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<int, int, string> progress,
Func<bool> 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<string>(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<AudioResourceLoadResult> completed,
Func<bool> 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<string, double> GetClipDurations()
{
var durations = new Dictionary<string, double>(_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<bool> 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<bool> 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; }
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: dd4f69d881f884738aed3561ef698e14
@@ -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";
}
/// <summary>
/// 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.
/// </summary>
public interface IAudioClock
{
string ClockId { get; }
double CurrentTime { get; }
int OutputSampleRate { get; }
}
/// <summary>
/// Per-song audio session contract shared by loading, gameplay and diagnostics. Backend
/// implementations own their native clips, channels, scheduling clock and pause semantics.
/// </summary>
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<int, int, string> progress,
Func<bool> cancellationRequested = null,
bool reactiveKeySoundsEnabled = true);
void PrepareReactivePlayback(double leadSeconds = 0.35);
IEnumerator PrepareSmoothPlaybackMix(
Action<int, int, string> progress,
Func<bool> 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();
}
/// <summary>
/// Composition boundary used by SongLoadCoordinator. A future FMOD implementation can be
/// selected here without introducing FMOD types into loading or gameplay assemblies.
/// </summary>
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);
}
}

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