🔍 s&box Package Code Search
Search C# source code, UI razor templates, shaders, and configs across s&box packages.
🔗 Raw Facepunch API Link: https://public.facepunch.com/sbox/code/search/1/?ident=gamah.splitclicker&take=20
Showing code results for query:
*
(45 total matches found)
Game
game
.colorproperty
{
align-items: center;
position: relative;
.colorsquare
{
width: 20px;
height: 20px;
border-radius: 4px;
margin-right: 8px;
position: absolute;
left: 7px;
z-index: 1;
cursor: pointer;
}
> .textentry:not( .a.b.c )
{
padding-left: 36px;
}
}
Game
game
VectorControl
{
gap: 2px;
flex-grow: 1;
}
VectorControl NumberEntry
{
flex-basis: 50%;
}
Game
game
ControlSheetRow
{
flex-direction: row;
flex-shrink: 0;
max-height: 32px;
border-radius: 4px;
&.hidden
{
display: none;
}
}
ControlSheetRow > .left
{
width: 150px;
flex-shrink: 0;
flex-grow: 0;
padding: 6px;
}
ControlSheetRow > .right
{
flex-grow: 1;
}
ControlSheetRow > .left > .title
{
width: 150px;
flex-shrink: 0;
flex-grow: 0;
font-size: 13px;
white-space: nowrap;
overflow: hidden;
}
ControlSheetRow > .right > textentry,
ControlSheetRow > .right numberentry
{
background-color: #000a;
flex-grow: 1;
padding: 4px 8px;
border-radius: 2px;
}
Game
game
.package-list
{
flex-shrink: 1;
flex-wrap: wrap;
flex-grow: 1;
h1
{
width: 100%;
margin-top: 50px;
font-size: 40px;
}
PackageCard
{
&:hover
{
sound-in: "ui.button.over";
}
}
VirtualGrid
{
width: 100%;
height: 100%;
.cell
{
}
}
}
Game
game
@import "/styles/_theme.scss";
.packagefilter
{
width: 100%;
flex-shrink: 0;
.left
{
flex-grow: 1;
flex-shrink: 0;
gap: 16px;
}
.right
{
flex-grow: 1;
flex-shrink: 0;
gap: 16px;
}
}
.packagefilter textentry
{
flex-grow: 1;
padding: 8px 12px;
pointer-events: all;
Label
{
margin-left: 26px;
}
iconpanel
{
position: absolute;
left: 12px;
opacity: 0.3;
font-size: $text-large;
}
}
.packagefilterfacet, .packagefilter textentry
{
background-color: rgba($default-950, 0.3);
color: $default-text;
outline: 1px solid $default-border;
transition: all 150ms $easing;
border-radius: $rounding-default;
padding: 4px 12px;
gap: 16px;
flex-shrink: 0;
font-size: 14px;
cursor: pointer;
align-items: center;
.clear-button
{
height: 100%;
margin-right: -8px;
align-items: center;
padding: 0px 4px;
&:hover
{
color: red;
border-radius: 100%;
}
}
&.is-active
{
background-color: $default-700;
}
&:not(.is-active)
{
&:hover
{
outline: 1px solid $default-50;
}
}
}
Game
game
@import "/styles/_theme.scss";
$background-size: 8px;
PackageCard
{
flex-shrink: 0;
&:hover
{
sound-in: "ui.button.over";
}
&:active
{
sound-in: "ui.button.press";
}
flex-direction: column;
position: relative;
background-color: rgba( $default-950, 0 );
border-radius: $rounding-default;
transition: all 150ms ease;
cursor: pointer;
z-index: 0;
height: 200px;
.image
{
flex-grow: 1;
flex-shrink: 0;
transition: all 150ms ease;
border: 1px solid rgba( white, 0.025 );
aspect-ratio: 16 / 9;
background-position: center;
background-size: cover;
border-radius: $rounding-small;
position: relative;
}
column
{
padding: 8px 2px; // Optically aligned
}
&.list
{
flex-grow: 1;
height: 64px;
flex-direction: row;
gap: 12px;
padding: 4px;
.inner column
{
flex-grow: 1;
}
.image
{
height: 100%;
aspect-ratio: 1;
flex-grow: 0;
flex-shrink: 0;
}
column
{
gap: 3px;
justify-content: center;
}
.package-title
{
flex-shrink: 0;
max-width: 500px;
}
.package-users
{
top: 1px;
right: 1px;
}
&:hover
{
background-color: $default-800;
transform: none;
&::after
{
display: none;
}
}
}
&.wide
{
height: 250px;
.package-title
{
max-width: 300px;
}
}
&.small
{
height: 200px;
aspect-ratio: 3/4;
.image
{
aspect-ratio: 1;
}
.package-title
{
max-width: 140px;
}
}
&.tall
{
height: 400px;
.image
{
aspect-ratio: 9/16;
}
.package-title
{
max-width: 200px;
}
}
.package-title
{
text-overflow: ellipsis;
max-height: 24px;
flex-shrink: 1;
font-size: 14px;
}
.package-users
{
position: absolute;
bottom: 4px;
right: 4px;
background-color: rgba( 10, 40, 10, 0.95 );
padding: 3px 5px;
border-radius: 2px;
justify-content: center;
align-items: center;
gap: 3px;
font-size: 11px;
color: #def;
border: 1px solid #252;
color: #2f3;
&:before
{
content: '●';
font-size: 0.5rem;
}
}
// Hover effect (not for list)
&:not(.list)
{
&::after
{
content: "";
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
background-color: rgba( $default-950, 0 );
transition: all 150ms ease;
z-index: -10;
border-radius: $rounding-large;
pointer-events: none;
}
&:hover
{
transform: scale( 1.05 );
&::after
{
background-color: $default-800;
top: -$background-size;
left: -$background-size;
right: -$background-size;
bottom: -$background-size;
box-shadow: 0 0 25px rgba( black, 0.3 );
}
z-index: 100;
background-color: $default-900;
}
}
}
.package-card.list packageflairbar
{
display: none;
}
.package-card.list .package-users
{
display: none;
}
Game
game
ColorSaturationValueControl
{
width: 240px;
height: 240px;
background-color: red;
position: relative;
border-radius: 4px;
cursor: pointer;
border: 1px solid #333;
&:hover
{
border: 1px solid #08f;
}
&:active
{
border: 1px solid #fff;
}
.handle
{
width: 16px;
height: 16px;
border-radius: 100px;
border: 2px solid #444;
position: absolute;
background-color: white;
box-shadow: 2px 2px 16px #000a;
transform: translateX( -50% ) translateY( -50% );
pointer-events: none;
z-index: 100;
z-index: 100;
}
.gradient
{
position: absolute;
width: 100%;
height: 100%;
border-radius: 4px;
background: linear-gradient( to right, white, rgba( 255, 255, 255, 0 ) );
&:after
{
content: "";
position: absolute;
width: 100%;
height: 100%;
border-radius: 4px;
background: linear-gradient( to top, black, rgba( 0, 0, 0, 0 ) );
}
}
}
Game
game
using Sandbox;
namespace Splitclicker.Audio;
// Thin wrapper over the five UI sound effects (see scripts/gen_sounds.py). Every voice
// is a square + triangle blend around G; together they form the round's audio motif:
// arming heartbeat → armed "go" → click blips → disarm bookend, with throttle as the
// "nope" for clicking while the button is dormant. Driven from ClickController.
public static class SoundPlayer
{
public static void PlayArming() => Sound.Play( "sounds/arming.sound" );
public static void PlayArmed() => Sound.Play( "sounds/armed.sound" );
public static void PlayClick() => Sound.Play( "sounds/click.sound" );
public static void PlayDisarm() => Sound.Play( "sounds/disarm.sound" );
public static void PlayThrottle() => Sound.Play( "sounds/throttle.sound" );
// An opponent's scoring click during the live window: the click blip a major
// fifth up (3:2 ratio ⇒ Pitch 1.5) at half volume, so the field of other
// players' clicks reads as related-but-subordinate to your own click.
public static void PlayPip()
{
var h = Sound.Play( "sounds/click.sound" );
if ( h != null && h.IsValid )
{
h.Pitch = 1.5f;
h.Volume = 0.5f;
}
}
}
Game
game
using System;
using Sandbox;
namespace Splitclicker.Game;
// Maps server-pushed `you.*` facts to s&box Services (Stats + Achievements).
// The server is the source of truth; we just record. Stat increments are NOT
// idempotent, so guard them with the round_id/game_id the server stamps; manual
// Unlock calls are idempotent and need no guard (PLAN §7.1).
//
// Named *Tracker to avoid colliding with Sandbox.Services.Achievements.
public static class AchievementTracker
{
// round_result → bump the `points` stat once per unseen round. Stat-threshold
// achievements (first_point, points_50, points_100) then fire automatically.
public static void OnRoundResult( int pointsDelta, string roundId )
{
if ( pointsDelta <= 0 || string.IsNullOrEmpty( roundId ) ) return;
// "Ahead of the Curve": more than 5 scoring clicks in a single round. Manual
// Unlock is idempotent, so it's safe to attempt on every delivery (no round_id
// guard needed) — unlike the `points` increment below.
if ( pointsDelta > 5 ) Unlock( "aotc" );
var pd = PlayerData.Load();
if ( pd.LastPointsRoundId == roundId ) return; // duplicate delivery / reconnect replay
pd.LastPointsRoundId = roundId;
pd.Save();
try { Sandbox.Services.Stats.Increment( "points", pointsDelta ); }
catch ( Exception e ) { Log.Warning( $"[Splitclicker] points stat failed: {e.Message}" ); }
}
// game_over → placement achievements (idempotent Unlock) + the `wins` stat
// once per unseen game (drives first_win / wins_5 / wins_10).
public static void OnGameOver( int placement, bool won, string gameId )
{
if ( placement >= 1 && placement <= 5 ) Unlock( "top_5" );
if ( placement >= 1 && placement <= 3 ) Unlock( "top_3" );
// "Chicken Dinner": win a session (finish #1 in the game's final standings).
if ( won ) Unlock( "firstwin" );
if ( !won || string.IsNullOrEmpty( gameId ) ) return;
var pd = PlayerData.Load();
if ( pd.LastWinGameId == gameId ) return;
pd.LastWinGameId = gameId;
pd.Save();
try { Sandbox.Services.Stats.Increment( "wins", 1 ); }
catch ( Exception e ) { Log.Warning( $"[Splitclicker] wins stat failed: {e.Message}" ); }
}
// Out-of-band achievement push from the server (the `achievement` WS frame):
// fire the named unlock straight through. Used for feats the server detects off
// the game socket — `fart` (poked the backend into a 404) and `hackerman`
// (fumbled the admin password) — matched back to this client by IP. The Unlock
// is idempotent, so a repeat push is a harmless no-op.
public static void OnAchievement( string ident )
{
if ( string.IsNullOrEmpty( ident ) ) return;
Unlock( ident );
}
// Idempotent achievement unlock with a console trace. Manual unlocks give no
// visible feedback in the editor (the ident must be defined on the published
// package to actually pop), so the log line is how we confirm it fired.
static void Unlock( string ident )
{
Log.Info( $"[Splitclicker] fired achievement {ident}" );
try { Sandbox.Services.Achievements.Unlock( ident ); }
catch ( Exception e ) { Log.Warning( $"[Splitclicker] unlock {ident} failed: {e.Message}" ); }
}
}
Game
game
using System;
using System.Collections.Generic;
namespace Skafinity;
/// <summary>
/// Deterministic procedural ska / reggae-rock track generator (issue: procedural music).
///
/// A player's 8-hex <c>player_tag</c> seeds a portable PRNG (xmur3 → mulberry32);
/// the PRNG drives every musical choice (tempo, key, progression, bass / skank /
/// organ / lead / drum patterns) so the SAME tag always produces the SAME song.
/// Output is interleaved stereo 16-bit PCM (for SoundStream) or a WAV (debug).
///
/// PORTABILITY (web parity): the *composition* is whatever the PRNG emits — a web
/// client reproduces the same arrangement by mirroring xmur3 + mulberry32 and this
/// file's note-selection order, using the same <see cref="Config"/> values. Synthesis
/// (oscillators/filters below) only needs matching for bit-identical audio.
///
/// Synthesis: subtractive — unison-detuned oscillators through a resonant low-pass
/// state-variable filter with a cutoff envelope (warm, not "8-bit"); full synth drum
/// kit (kick/snare/toms/hats/crash + fills). Default voicing aims for a Sublime vibe:
/// laid-back reggae-rock tempo, bass-forward, prominent clean skank + organ bubble.
/// </summary>
public sealed class MusicGen
{
public sealed class Config
{
// Genre — selects the instrument set + arrangement. 0 = Ska (bass/skank/organ/lead/
// horns/drums), 1 = Rock (drums/bass/keys/lead-gtr/rhythm-gtr), 2 = Country, 3 = Metal,
// 4 = Punk (lean power-pop), 5 = Pop (synth/dance). New genres append here.
public int Genre = 0;
// Output
public int SampleRate = 44100;
public float TargetSeconds = 80f; // (legacy) song length now follows the structure
public int Bars = 64; // fallback if TargetSeconds <= 0
// Tempo — main is laid-back reggae-rock; Fast is an uptempo ska feel.
public int BpmMin = 130, BpmMax = 185;
public float FastChance = 0.30f;
public int FastBpmMin = 150, FastBpmMax = 168;
public float Swing = 0.14f, FastSwing = 0.05f;
// Mix (per-voice gain pre-master) — the six "volume" sliders are normalized:
// same 0..1.5 range and the same 1.0 default (flat mix), tune from there.
public float BassVol = 1.00f;
public float SkankVol = 1.00f;
public float OrganVol = 1.00f;
public float MelodyVol = 1.00f;
public float HornVol = 1.00f;
// Per-part kit trims. The kit is balanced for EQUAL PEAK LEVEL in the file internally
// (see *Balance consts below), so these knobs all share a 1.0 default — every piece
// peaks at the same level out of the box and these are pure user trims around that.
public float KickVol = 1.00f;
public float SnareVol = 1.00f;
public float TomVol = 1.00f;
public float HatVol = 1.00f;
public float CrashVol = 1.00f;
public float DrumVol = 1.00f; // master gain over the whole kit
// Drum "tone" — toms↔cymbals bias for the PART. 0 = boom (fills/decoration lean to
// toms, cymbals pulled back), 0.5 = neutral, 1 = bright (fills/decoration lean to
// cymbals, toms pulled back). Drives both what's played and a gentle per-voice gain lean.
public float DrumTone = 0.5f;
// Drum "drive" — pull↔push timing feel (replaces the old DrumPush magnitude roll).
// 0 = lay back behind the beat, 0.5 = dead-on, 1 = push ahead of the beat.
public float DrumDrive = 0.5f;
// Rock instruments (Genre 1). Bass + drums reuse the shared knobs above.
// KEYS — the offbeat-chord comp (was labelled "rhythm guitar", but it always read as a
// keyboard, so it's named for what it sounds like). Power-chord comping on every eighth.
public float KeysVol = 1.00f;
public float KeysCutoff = 1700f; // Hz low-pass (darker wall)
public float KeysDrive = 3.2f; // distortion amount (tanh drive)
public float KeysChug = 0.5f; // 0 = ringing chords, 1 = tight palm-mute chug
// RHYTHM GTR — twangy distorted power chords. Shares the lead guitar's voice but strums
// chords at a lower base distortion than the lead.
public float RhythmGtrVol = 1.00f;
public float RhythmGtrCutoff = 2600f; // Hz low-pass on the rhythm guitar
public float RhythmGtrDrive = 2.8f; // distortion amount (tanh drive) — under the lead
public float RhythmGtrChug = 0.5f; // 0 = ringing chords, 1 = tight palm-mute chug
// LEAD GTR — twangy, heavily distorted single-note lead.
public float LeadGtrVol = 1.00f;
public float LeadGtrCutoff = 2600f; // Hz low-pass on the lead guitar
public float LeadGtrDrive = 5.0f; // distortion amount (tanh drive) — new floor of the DISTORTION knob
public float LeadGtrBend = 0.30f; // rock lead "bendiness" 0..1 — propensity to bend up into notes and scoop
// Tone — low drives + filtering for warmth; detune for width.
public float Detune = 14f; // cents, unison spread
public float BassCutoff = 380f; // Hz low-pass on bass
public float SkankCutoff = 3000f; // Hz low-pass on skank
public float SkankHighpass = 500f;// Hz high-pass to thin the skank ("skank bite")
public float SkankChop = 0.5f; // skank chop length as a fraction of an eighth
public float LeadCutoff = 3200f; // Hz low-pass on lead
public float OrganCutoff = 1400f; // Hz low-pass on the organ bubble
public float OrganVibrato = 5.5f; // organ bubble vibrato depth
public float HornCutoff = 3200f; // Hz low-pass on the backing horns
public float Resonance = 1.0f; // SVF damping (lower = more resonant)
public float BassDrive = 1.5f;
public float SkankDrive = 1.3f;
public float MelodyDrive = 1.3f;
public float HornDrive = 1.4f;
public float MasterDrive = 1.1f;
public float MasterPeak = 0.95f;
// Master room reverb — a touch of stereo space so the mix reads with depth
// instead of dry/"16-bit". Wet = blend, Decay = tail length (0..1).
public float MasterReverb = 0.5f;
public float ReverbDecay = 0.5f;
// Feel
public float OctavePopChance = 0.30f;
public float OrganBubbleChance = 0.55f;
public float KickSyncChance = 0.25f;
public float GhostSnareChance = 0.35f;
public float FillChance = 0.6f; // drum fill at phrase ends
public float DrumBusy = 0.6f; // 0..1 overall kit activity: 16th hats, ghosts, kick syncopation
public float TripletChance = 0.06f; // 0..0.2 chance of triplet/16th ornament in fills / lead runs (potent)
public float BassTriplets = 0.06f; // 0..0.1 bass-only 16th/triplet ornament rate (own knob)
public float MelodyRestChance = 0.30f;
public float MelodyLeapChance = 0.18f;
public float MelodyVibrato = 5.0f;
// STEREO WIDTH (vibe slider): master 0..1 stereo amount. Scales the lead/horn placement
// AND the drum pan + double-tracking spread/decorrelation (see _widthScale). 1 = full
// design width (the "100%" the rest of the mix is tuned for); 0 = mono.
public float PanAmount = 1.0f;
// Lead instrument weights (RNG picks one; ForceInstrument overrides:
// -1 = RNG, 0=Trumpet 1=Sax 2=Organ 3=Trombone).
public float TrumpetWeight = 1.0f;
public float SaxWeight = 1.0f;
public float OrganWeight = 0.8f;
public float TromboneWeight = 0.4f;
public int ForceInstrument = -1;
// Backing horn section
public float HornSectionChance = 0.5f;
public float HornDensity = 0.35f;
// ── Advanced peak-balance / level tuning (NOT vibe knobs) ──
// These were hardcoded consts; they're Config fields so the house mix can be retuned at
// runtime (web: config.json) without a rebuild. They're deliberately kept OUT of the vibe
// seed and the per-genre slider grid (see VibeCodec.AdvancedFields) — they shape the
// baseline mix, not a song's shareable identity. Edit these to re-balance, not the *Vol
// defaults (which stay a flat 1.0 so the knobs read as pure user trims around this).
//
// KitPresence: drums are short transients fighting a sustained melodic bed; this baseline
// boost lets the kit sit in the mix at DRUMS = 1.0 (parity with the other voice sliders).
public float KitPresence = 2.0f;
// Kit per-voice level balance. The pieces synthesise at very different raw levels (a kick
// is huge, a hat is thin noise). Starting point was EQUAL PRE-MASTER PEAK (kick 0.40 /
// snare 0.476 / tom 0.496 / hat 0.582 / crash 0.515), then hand-tuned by ear: equal peak
// buried the sparse kick/snare/toms under the constant hats, so drums were pushed back up
// and the hats backed off.
public float KickBalance = 0.800f;
public float SnareBalance = 0.900f;
public float TomBalance = 0.780f; // tuned by ear
public float HatBalance = 0.407f; // equal-peak baseline (0.582) backed off 30% by ear
public float CrashBalance = 0.515f;
// Melodic-voice peak balance — the instrument analog of the kit balances above (same
// equal-peak-then-tune workflow). Bass measured the same peak across genres, so one value.
public float BassBalance = 0.733f;
public float SkankBalance = 1.223f;
public float OrganBalance = 1.237f;
public float MelodyBalance = 0.896f; // ska horn lead
public float HornBalance = 1.142f; // backing horn section
public float KeysBalance = 1.065f; // rock offbeat keys
public float RhythmGtrBalance = 1.053f;
public float LeadGtrBalance = 0.896f;
// ── Stereo double-tracking (width) ── (config-only; see VibeCodec.AdvancedFields)
// Widen the non-drum voices the way a band double-tracks guitars (bass excepted — it
// stays centred for a tight, mono-safe low end): each widened note is rendered
// as TWO decorrelated "takes" panned apart — NOT one mono signal copied to both channels
// (that just collapses to centre). The width comes entirely from the micro-differences
// between the takes: a few cents of detune, a small constant timing offset + per-note
// jitter, independent oscillator start phase, and per-note amplitude/cutoff variation.
// Tunable at runtime (no rebuild). Drums are panned separately (see DrumPan).
public float DoubleTrack = 1f; // master enable: <0.5 = off (single centred take, as before)
public float WidthBacking = 0.5f; // pan spread (±) for backing voices — 50% L/R
public float WidthLead = 1.0f; // pan spread (±) for a doubled lead. INERT by default: the
// lead is monophonic and rendered as a single clean take
// (see RenderLeadNote) — doubling a solo line smeared pitch
// ("out of key"). Only takes effect if the lead path is
// switched back to lead:true. Chordal voices use WidthBacking.
public float WidthDetune = 6f; // cents BETWEEN the two takes (split ±half each)
public float WidthDelayMs = 9f; // constant timing offset of the 2nd take (ms)
public float WidthJitterMs = 4f; // extra random per-note timing jitter (ms, ± per take)
public float WidthAmpVar = 0.08f; // per-note amplitude variation between takes (0..1)
public float WidthCutoffVar = 0.10f; // per-note filter-cutoff variation between takes (0..1)
}
readonly Config _c;
readonly int _sr;
readonly float _drumGain; // master kit gain — straight 0..1.5 slider × Config.KitPresence baseline
float[] _bufL, _bufR;
MusicGen( Config c ) { _c = c ?? new Config(); _sr = _c.SampleRate; _drumGain = Math.Clamp( _c.DrumVol, 0f, 1.5f ) * _c.KitPresence; }
public const int Channels = 2;
public static byte[] Generate( string tag, Config cfg = null )
{
var g = new MusicGen( cfg );
return g.EncodeWav( g.Compose( tag ) );
}
public static short[] GenerateSamples( string tag, Config cfg, out int sampleRate )
{
var g = new MusicGen( cfg );
float gain = g.Compose( tag );
sampleRate = g._sr;
return g.ToShorts( gain );
}
// ── Chunked generation (parallel synthesis) ──
// Composition + drum synthesis are sequential (RNG-bound); pitched-voice synthesis
// pulls no RNG, so the caller can split it across worker threads. Flow:
// var g = MusicGen.BeginPlan( tag, cfg ); // sequential plan + drums
// parallel-for window in 0..g.TotalSamples: g.RenderPitchedRange( from, to );
// short[] pcm = g.FinishStereo(); // master + interleave
public static MusicGen BeginPlan( string tag, Config cfg )
{
var g = new MusicGen( cfg );
g.ComposePlan( tag );
return g;
}
public int TotalSamples => _bufL?.Length ?? 0;
public int SampleRate => _sr;
/// <summary>Master-normalize and interleave to stereo 16-bit PCM. Call after every
/// <see cref="RenderPitchedRange"/> window has finished.</summary>
public short[] FinishStereo() => ToShorts( Master() );
const int EighthsPerBar = 8;
// ── Portable PRNG (mirror exactly in the web client) ──
static uint Xmur3( string str )
{
uint h = 1779033703u ^ (uint)str.Length;
for ( int i = 0; i < str.Length; i++ )
{
h = unchecked( (h ^ str[i]) * 3432918353u );
h = (h << 13) | (h >> 19);
}
h = unchecked( (h ^ (h >> 16)) * 2246822507u );
h = unchecked( (h ^ (h >> 13)) * 3266489909u );
return h ^ (h >> 16);
}
sealed class Rng
{
uint _a;
public Rng( uint seed ) { _a = seed; }
public float Next()
{
_a = unchecked( _a + 0x6D2B79F5u );
uint t = _a;
t = unchecked( (t ^ (t >> 15)) * (t | 1u) );
t ^= unchecked( t + (t ^ (t >> 7)) * (t | 61u) );
return (t ^ (t >> 14)) / 4294967296f;
}
public int Int( int n ) => n <= 0 ? 0 : Math.Min( n - 1, (int)(Next() * n) );
public bool Chance( float p ) => Next() < p;
public T Pick<T>( T[] arr ) => arr[Int( arr.Length )];
}
// ── Harmony ──
// Major-leaning scales (Sublime / reggae sit in major & mixolydian mostly).
static readonly int[][] Scales =
{
new[] { 0, 2, 4, 5, 7, 9, 11 }, // major
new[] { 0, 2, 4, 5, 7, 9, 10 }, // mixolydian
new[] { 0, 2, 4, 5, 7, 9, 11 }, // major (weighted)
new[] { 0, 2, 4, 5, 7, 9, 11 }, // major (weighted — was dorian; ska/reggae stays bright)
};
static readonly int[][] Progressions =
{
new[] { 0, 4, 5, 3 }, // I–V–vi–IV
new[] { 0, 3, 4, 4 }, // I–IV–V–V
new[] { 0, 5, 3, 4 }, // I–vi–IV–V
new[] { 0, 6, 3, 0 }, // I–bVII–IV–I (mixolydian)
new[] { 5, 3, 0, 4 }, // vi–IV–I–V
new[] { 0, 3, 0, 4 }, // I–IV–I–V
};
// Bass patterns: semitone offsets from the chord root per eighth; -99 = rest
// (note sustains to the next onset). Slot 7 is the "approach" → walks to the
// next chord. Mix of melodic / one-drop / rocking / busy ska.
const int Rest = -99, Approach = 99;
static readonly int[][] BassPatterns =
{
new[] { 0, Rest, 0, 12, Rest, 7, 5, Approach }, // sublime melodic
new[] { Rest, Rest, 0, Rest, 0, Rest, 7, Approach }, // one-drop spacey
new[] { 0, Rest, 7, Rest, 12, Rest, 7, Approach }, // rocking
new[] { 0, 12, 0, 7, 0, 12, 7, Approach }, // busy ska
new[] { 0, Rest, 0, Rest, 5, Rest, 7, Approach }, // root–fifth
};
// ── Rock harmony (Genre 1) ──
// Darker, power-chord-friendly modes (minor / dorian / mixolydian) so rock doesn't
// share ska's bright major themes. Picked with the SAME RNG draw as the ska tables, so
// ska songs are byte-identical — only genre 1 reads these.
// All minor-3rd modes: the RockProgressions are written/labelled as MINOR (i–♭VII–♭VI …), so
// pairing them with a major-3rd mode (e.g. mixolydian) flipped the tonic major and the dark
// rock vamp evaporated. Aeolian/dorian/phrygian keep a minor tonic — the progression reads as
// intended — while still giving modal variety (dorian's ♮6, phrygian's ♭II colour). 4 entries
// kept so the genre Pick stays at the same draw and other genres are byte-identical.
static readonly int[][] RockScales =
{
new[] { 0, 2, 3, 5, 7, 8, 10 }, // natural minor (aeolian)
new[] { 0, 2, 3, 5, 7, 9, 10 }, // dorian (minor, brighter ♮6 — classic rock)
new[] { 0, 1, 3, 5, 7, 8, 10 }, // phrygian (dark)
new[] { 0, 2, 3, 5, 7, 8, 10 }, // natural minor (weighted)
};
// Degrees are read against the (often minor) scale, so 5 = ♭VI, 6 = ♭VII, 3 = iv, 4 = v.
static readonly int[][] RockProgressions =
{
new[] { 0, 6, 5, 6 }, // i–♭VII–♭VI–♭VII (driving rock vamp)
new[] { 0, 5, 6, 0 }, // i–♭VI–♭VII–i
new[] { 0, 6, 3, 0 }, // i–♭VII–IV–i (mixolydian rock)
new[] { 0, 3, 6, 0 }, // i–iv–♭VII–i
new[] { 0, 0, 6, 6 }, // i / ♭VII riff vamp
new[] { 0, 3, 4, 0 }, // i–iv–v–i
};
// Driving root/octave eighths that lock to the kick — the rock engine room, vs ska's
// syncopated off-beat one-drop bass.
static readonly int[][] RockBassPatterns =
{
new[] { 0, 0, 0, 0, 0, 0, 0, Approach }, // straight eighth chug
new[] { 0, Rest, 0, Rest, 0, Rest, 0, Approach },// quarter-note pulse
new[] { 0, 0, 12, 0, 0, 0, 12, Approach }, // root with octave pushes
new[] { 0, 0, 7, 0, 0, 0, 7, Approach }, // root–fifth gallop
new[] { 0, Rest, 0, 0, Rest, 0, 12, Approach }, // syncopated driver
};
// ── Country harmony (Genre 2) ──
// Bright and major — country lives in major / mixolydian. Same RNG draws as the other
// tables, so songs in other genres are untouched.
static readonly int[][] CountryScales =
{
new[] { 0, 2, 4, 5, 7, 9, 11 }, // major
new[] { 0, 2, 4, 5, 7, 9, 10 }, // mixolydian
new[] { 0, 2, 4, 5, 7, 9, 11 }, // major
new[] { 0, 2, 4, 5, 7, 9, 11 }, // major (weighted)
};
static readonly int[][] CountryProgressions =
{
new[] { 0, 3, 4, 0 }, // I–IV–V–I (the country backbone)
new[] { 0, 0, 4, 4 }, // I–V vamp
new[] { 0, 3, 0, 4 }, // I–IV–I–V
new[] { 0, 4, 5, 3 }, // I–V–vi–IV
new[] { 0, 4, 0, 4 }, // I–V two-chord
};
// "Boom-chick" alternating root/fifth on the beats (the guitar/snare take the off "chick"),
// walking up to the next chord on the approach.
static readonly int[][] CountryBassPatterns =
{
new[] { 0, Rest, 7, Rest, 0, Rest, 7, Approach }, // alternating root–fifth
new[] { 0, Rest, 7, Rest, 12, Rest, 7, Approach }, // root–fifth with the octave
new[] { 0, Rest, 7, Rest, 0, Rest, 5, Approach }, // root–fifth, lean on the 4th
new[] { 0, Rest, 4, Rest, 7, Rest, 5, Approach }, // walking-ish
};
// ── Metal harmony (Genre 3) ──
// Dark and tight — natural minor / phrygian / harmonic minor for the menacing power-chord
// riffs. Same RNG draws as the other tables.
static readonly int[][] MetalScales =
{
new[] { 0, 2, 3, 5, 7, 8, 10 }, // natural minor (aeolian)
new[] { 0, 1, 3, 5, 7, 8, 10 }, // phrygian (the metal mode)
new[] { 0, 2, 3, 5, 7, 8, 11 }, // harmonic minor
new[] { 0, 2, 3, 5, 7, 8, 10 }, // natural minor (weighted)
};
// Degrees read against the (minor) scale: 5 = ♭VI, 6 = ♭VII, 1 = ♭II, 3 = iv.
static readonly int[][] MetalProgressions =
{
new[] { 0, 5, 6, 0 }, // i–♭VI–♭VII–i
new[] { 0, 6, 5, 6 }, // i–♭VII–♭VI–♭VII (driving)
new[] { 0, 1, 0, 6 }, // i–♭II–i–♭VII (phrygian menace)
new[] { 0, 0, 5, 6 }, // i pedal → ♭VI–♭VII
new[] { 0, 6, 3, 0 }, // i–♭VII–iv–i
};
// Driving roots locked to the double-kick; octave pushes for the gallop.
static readonly int[][] MetalBassPatterns =
{
new[] { 0, 0, 0, 0, 0, 0, 0, Approach }, // straight chug
new[] { 0, 0, 12, 0, 0, 0, 12, Approach }, // root with octave pushes
new[] { 0, 0, 0, 12, 0, 0, 0, Approach }, // syncopated octave
new[] { 0, Rest, 0, 0, Rest, 0, 0, Approach }, // syncopated driver
};
// ── Punk harmony (Genre 4) ──
// "Lean punk" / power-pop: bright and major (the opposite pole to rock's dark minor), riding
// the anthemic four-chord turnarounds at speed. Same RNG draws as the other tables.
static readonly int[][] PunkScales =
{
new[] { 0, 2, 4, 5, 7, 9, 11 }, // major (the pop-punk default)
new[] { 0, 2, 4, 5, 7, 9, 10 }, // mixolydian (a little grit on the ♭7)
new[] { 0, 2, 4, 5, 7, 9, 11 }, // major (weighted)
new[] { 0, 2, 4, 5, 7, 9, 11 }, // major (weighted)
};
// Major degrees: 3 = IV, 4 = V, 5 = vi — the anthem turnarounds.
static readonly int[][] PunkProgressions =
{
new[] { 0, 4, 5, 3 }, // I–V–vi–IV (the pop-punk anthem)
new[] { 5, 3, 0, 4 }, // vi–IV–I–V
new[] { 0, 5, 3, 4 }, // I–vi–IV–V (50s changes, sped up)
new[] { 0, 3, 4, 4 }, // I–IV–V–V
new[] { 0, 4, 3, 4 }, // I–V–IV–V (three-chord drive)
};
// Driving root/octave eighths locked to the kick — same engine room as rock, pushed harder.
static readonly int[][] PunkBassPatterns =
{
new[] { 0, 0, 0, 0, 0, 0, 0, Approach }, // straight eighth chug
new[] { 0, 0, 12, 0, 0, 0, 12, Approach }, // root with octave pushes
new[] { 0, 0, 7, 0, 0, 0, 7, Approach }, // root–fifth gallop
new[] { 0, Rest, 0, Rest, 0, Rest, 0, Approach },// quarter-note pulse
};
// ── Pop harmony (Genre 5) ──
// Modern synth/dance-pop: bright major / lydian over the ubiquitous four-chord loops, built
// to sit on a four-on-the-floor kick. Same RNG draws as the other tables.
static readonly int[][] PopScales =
{
new[] { 0, 2, 4, 5, 7, 9, 11 }, // major
new[] { 0, 2, 4, 6, 7, 9, 11 }, // lydian (the sparkly ♯4 — synth-pop shimmer)
new[] { 0, 2, 4, 5, 7, 9, 11 }, // major (weighted)
new[] { 0, 2, 4, 5, 7, 9, 11 }, // major (weighted)
};
static readonly int[][] PopProgressions =
{
new[] { 0, 4, 5, 3 }, // I–V–vi–IV
new[] { 5, 3, 0, 4 }, // vi–IV–I–V (the "Axis" loop)
new[] { 0, 5, 3, 4 }, // I–vi–IV–V
new[] { 0, 3, 0, 4 }, // I–IV–I–V (two-chord pulse)
};
// Four-on-the-floor synth bass: a steady root pulse with octave pops for bounce.
static readonly int[][] PopBassPatterns =
{
new[] { 0, Rest, 0, Rest, 0, Rest, 0, Approach }, // root on every beat (locks the floor)
new[] { 0, 0, 12, 0, 0, 0, 12, Approach }, // root with octave pops
new[] { 0, 0, 0, 0, 0, 0, 0, Approach }, // straight eighth pulse
new[] { 0, Rest, 0, 0, Rest, 0, 12, Approach }, // syncopated synth bass
};
// The genre's harmony tables (one RNG Pick each, so other genres stay byte-identical).
static int[][] ScalesFor( int g ) => g switch
{ 1 => RockScales, 2 => CountryScales, 3 => MetalScales, 4 => PunkScales, 5 => PopScales, _ => Scales };
static int[][] ProgressionsFor( int g ) => g switch
{ 1 => RockProgressions, 2 => CountryProgressions, 3 => MetalProgressions, 4 => PunkProgressions, 5 => PopProgressions, _ => Progressions };
static int[][] BassPatternsFor( int g ) => g switch
{ 1 => RockBassPatterns, 2 => CountryBassPatterns, 3 => MetalBassPatterns, 4 => PunkBassPatterns, 5 => PopBassPatterns, _ => BassPatterns };
int[] _scale, _prog;
int _rootMidi;
Instrument _lead;
float _leadPan;
float _widthScale = 1f; // STEREO WIDTH slider (PanAmount) as a 0..1 master: scales the drum
// pan AND the double-tracking spread/decorrelation. 1 = full (design)
// width; 0 = everything collapses to centre (mono).
float _drumPan = DrumPan;// per-song effective drum spread = DrumPan * _widthScale
bool _hasHorns;
bool[] _hornMask;
int[] _bassPat;
int _drumStyle; // 0 one-drop, 1 steppers, 2 straight backbeat
int[] _kickAccents = Array.Empty<int>(); // per-song backbeat kick accents (see BackbeatKickAccents)
bool _ride; // per-SECTION: ride cymbal drives the eighth pulse instead of closed hats (set in RenderSection from _ridePref)
float _ridePref; // per-song lean toward riding the ride vs the hats; each section rolls its own _ride against this
bool _crashBrightLeft; // per-song: which side the kit's two crashes sit on (bright crash left ⇄ dark crash right, or flipped)
bool _organBubble;
bool _fast;
int _genre; // 0 ska, 1 rock, 2 country, 3 metal, 4 punk, 5 pop
string _tag; // the per-song seed string, reused to seed per-section streams
int _drumPush; // per-song-constant kit timing bias in samples (− ahead / + back)
float _swing; // per-song shuffle: offbeat eighths pushed late by this fraction of an eighth (whole band, via Swung)
float _drumTone = 0.5f; // DrumTone 0..1 → toms↔cymbals CONTENT bias in fills/groove decoration
float _drumLowMul = 1f; // DrumTone → kick/tom gain lean (gentle, on top of the content bias)
float _drumHighMul = 1f; // DrumTone → hat/cymbal gain lean (gentle, on top of the content bias)
// ── Song structure ──
// A song is an ordered list of sections. Hardcoded for now (will be RNG-generated once
// there are more part types); the fixed run is intro → chorus → verse(0) → chorus →
// verse(1) → chorus → ending. Non-lead voices are seeded by section TYPE so every chorus
// (and both verses) play identical backing; the lead is seeded by type + verse index so
// it evolves across the Nth verse; the section-end fill is seeded by absolute index so
// every section closes with a different fill.
enum Section { Intro, Chorus, Verse, Ending }
// Extra seconds appended after the last bar so the ending's final tonic chord (and the
// master reverb) can ring out naturally instead of being clipped at the buffer edge.
const float RingOutTail = 2.4f;
readonly struct Part
{
public readonly Section Type; public readonly int Bars; public readonly int VerseIndex;
public Part( Section t, int bars, int verse ) { Type = t; Bars = bars; VerseIndex = verse; }
}
static List<Part> BuildStructure() => new()
{
new Part( Section.Intro, 4, 0 ),
new Part( Section.Chorus, 8, 0 ),
new Part( Section.Verse, 8, 0 ),
new Part( Section.Chorus, 8, 0 ),
new Part( Section.Verse, 8, 1 ),
new Part( Section.Chorus, 8, 0 ),
new Part( Section.Ending, 2, 0 ),
};
static string SectionKey( Section s ) => s switch
{
Section.Intro => "intro",
Section.Chorus => "chorus",
Section.Verse => "verse",
_ => "ending",
};
// Single-threaded generation (used by Generate / GenerateSamples). The controller
// uses the chunked path instead (BeginPlan → parallel RenderPitchedRange → FinishStereo).
float Compose( string tag )
{
ComposePlan( tag );
RenderPitchedRange( 0, _bufL.Length );
return Master();
}
// Sequential planning pass: RNG composition + drum synthesis written straight into
// the buffer, while every pitched note is collected as an event (rendered later,
// possibly in parallel). RNG draw order is identical to the old inline render —
// RenderPatch now only enqueues, and it never pulled RNG anyway.
void ComposePlan( string tag )
{
_events.Clear();
_tag = string.IsNullOrEmpty( tag ) ? "rotaliate" : tag;
_genre = Math.Clamp( _c.Genre, 0, 5 );
var rng = new Rng( Xmur3( _tag.ToLowerInvariant() ) );
// TEMPO BIAS — punk always runs hot (it's the fast genre); the roll still consumes one
// draw so every other genre's later picks stay byte-identical.
_fast = rng.Chance( _c.FastChance ) || _genre == 4;
int bpm = _fast
? _c.FastBpmMin + rng.Int( Math.Max( 1, _c.FastBpmMax - _c.FastBpmMin + 1 ) )
: _c.BpmMin + rng.Int( Math.Max( 1, _c.BpmMax - _c.BpmMin + 1 ) );
_scale = rng.Pick( ScalesFor( _genre ) );
_prog = rng.Pick( ProgressionsFor( _genre ) );
_rootMidi = 28 + rng.Int( 8 ); // E1..B1 bass root
_lead = Instrument.Trumpet; // ska lead is fixed; other genres use guitar
_leadPan = (rng.Next() * 2f - 1f) * _c.PanAmount;
_widthScale = Math.Clamp( _c.PanAmount, 0f, 1f );
_drumPan = DrumPan * _widthScale;
_bassPat = rng.Pick( BassPatternsFor( _genre ) );
_drumStyle = _genre switch // 0 ska rolls a style; the rest are fixed
{
1 => 2, // rock: straight backbeat
2 => 2, // country: train-beat backbeat
3 => 3, // metal: double-kick
4 => 2, // punk: straight backbeat (fast)
5 => 4, // pop: four-on-the-floor
_ => _fast ? 2 : rng.Int( 2 ),
};
_organBubble = true;
_hasHorns = true;
_hornMask = new bool[EighthsPerBar];
_hornMask[0] = true;
for ( int e = 1; e < EighthsPerBar; e++ )
_hornMask[e] = rng.Chance( _c.HornDensity * (e % 2 == 1 ? 1.3f : 0.5f) );
// How much this song leans on the ride cymbal vs the closed hats for the main pulse.
// Every song can do both — each SECTION rolls its own choice against this preference
// (see RenderSection), so a song can hat the verse and ride the chorus. The lean is
// genre-biased (rock/metal ride more) and spread per song so some strongly prefer one.
// One rng.Next() (same draw count as the old single Chance), so later draws stay aligned.
float rideBase = _genre switch { 1 => 0.55f, 3 => 0.65f, 2 => 0.30f, 4 => 0.20f, 5 => 0.30f, _ => 0.40f };
_ridePref = Math.Clamp( rideBase - 0.25f + 0.5f * rng.Next(), 0f, 1f );
// Which side the two crashes sit on (±25%); flips per song so the stereo image varies.
_crashBrightLeft = rng.Chance( 0.5f );
// This song's backbeat kick personality — which off-beat eighths the kick leans into
// beyond the fixed beat-1 & 3 anchors. Only the straight backbeat (rock/country/fast
// ska) reads it; drawn last so it shifts no earlier choice.
_kickAccents = rng.Pick( BackbeatKickAccents );
_swing = _fast ? _c.FastSwing : _c.Swing;
if ( _genre == 5 ) _swing *= 0.25f; // pop sits on a tight, straight dance grid
double secPerEighth = 60.0 / bpm / 2.0;
int spe = (int)Math.Round( _sr * secPerEighth );
// Drum tone (toms↔cymbals) → per-voice gain split, and drive (pull↔push) → a constant
// kit timing bias (− = ahead/push, + = behind/lay back; 0.5 = dead on).
float dt = Math.Clamp( _c.DrumTone, 0f, 1f );
_drumTone = dt;
// Gentle gain lean (neutral at 0.5 so the balanced kit is untouched there); the
// bulk of the toms↔cymbals bias now comes from what the part actually plays.
_drumLowMul = 1.2f - 0.4f * dt;
_drumHighMul = 0.7f + 0.6f * dt;
_drumPush = (int)Math.Round( (0.5f - Math.Clamp( _c.DrumDrive, 0f, 1f )) * 2f * 0.13f * spe );
// Lay out the structure and size the buffers to its total length.
var structure = BuildStructure();
int totalBars = 0;
foreach ( var p in structure ) totalBars += p.Bars;
// Size to the structure plus a ring-out tail, so the ending's final chord and the
// reverb decay into real silence past the last bar rather than being cut off.
int structured = spe * EighthsPerBar * totalBars;
int total = structured + (int)(_sr * RingOutTail);
_bufL = new float[total];
_bufR = new float[total];
int barCursor = 0;
for ( int si = 0; si < structure.Count; si++ )
{
var part = structure[si];
RenderSection( part, si, barCursor * EighthsPerBar * spe, spe, secPerEighth );
barCursor += part.Bars;
}
}
// Render one section. Each voice gets its own per-section RNG stream keyed so that repeats
// of a section type reproduce identical backing, while the lead key folds in the verse
// index (so the Nth verse's lead differs) and the fill key folds in the absolute section
// index (so every section closes with a unique fill).
void RenderSection( Part part, int absIndex, int sectionStart, int spe, double secPerEighth )
{
string bk = SectionKey( part.Type );
string lk = part.Type == Section.Verse ? $"verse:{part.VerseIndex}" : bk;
var bassRng = new Rng( Xmur3( $"{_tag}:bass:{bk}" ) );
var bassOrn = new Rng( Xmur3( $"{_tag}:bassorn:{bk}" ) );
var rhythmRng = new Rng( Xmur3( $"{_tag}:rhythm:{bk}" ) );
var keysRng = new Rng( Xmur3( $"{_tag}:keys:{bk}" ) );
var hornRng = new Rng( Xmur3( $"{_tag}:horn:{bk}" ) );
var leadRng = new Rng( Xmur3( $"{_tag}:lead:{lk}" ) );
// Expression (vibrato/bend/glide/scoop) rolls off their own stream so adding them
// leaves every voice's existing note CHOICES untouched — only pitch-shaping is layered on.
var exprRng = new Rng( Xmur3( $"{_tag}:expr:{lk}" ) );
var noise = new Rng( Xmur3( $"{_tag}:drums:{bk}" ) );
// Hats vs ride is decided per section off its own stream (keyed by section TYPE, so every
// chorus rides-or-hats the same, but a verse can differ). Rolled against the song's
// _ridePref — independent stream, so it disturbs no other voice's draws.
_ride = new Rng( Xmur3( $"{_tag}:ride:{bk}" ) ).Chance( _ridePref );
var fillRng = new Rng( Xmur3( $"{_tag}:fill:{absIndex}" ) );
var fillNoise = new Rng( Xmur3( $"{_tag}:fillnoise:{absIndex}" ) );
bool isIntro = part.Type == Section.Intro;
bool isEnding = part.Type == Section.Ending;
for ( int bar = 0; bar < part.Bars; bar++ )
{
int chord = (bar / 2) % _prog.Length;
int nextChord = ((bar / 2) + 1) % _prog.Length;
int barStart = sectionStart + bar * EighthsPerBar * spe;
// The ending lands on a held tonic chord that rings out — the band stops on the
// "one", it doesn't roll forward as if looping. The bar before it fills to lead in.
if ( isEnding && bar == part.Bars - 1 )
{
RenderEnding( barStart, spe, noise );
continue;
}
// Every section's last bar fills; for the ending that fill moves one bar earlier so
// it sets up the final hit instead of pushing past the end into nothing.
bool lastBar = bar == part.Bars - 1 || (isEnding && bar == part.Bars - 2);
// Intro build-in: rather than slamming in at full band (which reads as looping back
// into the middle of the song), the voices enter a layer at a time — bass + drums
// lay down the groove first, then the chordal voice, then the horns/lead on top. The
// thresholds are derived from the intro length (not hardcoded bar numbers) so the build
// always spans the whole intro and can't silently collapse if part.Bars changes: the
// chord enters a quarter of the way in, the top half-way. (4-bar intro ⇒ bars 1 and 2.)
bool playChord = !isIntro || bar >= part.Bars / 4;
bool playTop = !isIntro || bar >= part.Bars / 2;
RenderBassBar( barStart, spe, secPerEighth, chord, nextChord, bassRng, bassOrn, exprRng );
if ( playChord )
switch ( _genre )
{
case 1: // rock: keys comp + power-chord guitar
case 2: // country: honky-tonk piano comp + strummed twang guitar
RenderKeysBar( barStart, spe, secPerEighth, chord, keysRng, exprRng );
RenderRhythmGuitarBar( barStart, spe, secPerEighth, chord, rhythmRng, exprRng );
break;
case 3: // metal: palm-muted gallop riff carries the bar
RenderMetalRiffBar( barStart, spe, secPerEighth, chord, rhythmRng, exprRng );
break;
case 4: // punk: lean — power-chord guitar carries it, no keys
RenderRhythmGuitarBar( barStart, spe, secPerEighth, chord, rhythmRng, exprRng );
break;
case 5: // pop: synth comp (the keys voice, run clean + bright)
RenderKeysBar( barStart, spe, secPerEighth, chord, keysRng, exprRng );
break;
default: // ska: skank chop + horn stabs
RenderRhythmBar( barStart, spe, secPerEighth, chord, rhythmRng, exprRng );
if ( _hasHorns && playTop )
RenderHornStabs( barStart, spe, secPerEighth, chord, hornRng, exprRng );
break;
}
RenderDrumBar( barStart, spe, lastBar, noise, fillRng, fillNoise );
// No lead in the ending: a lead phrase starts every two bars and runs ~two bars, so in
// the short outro it spilled melody notes across the held final chord — the band has
// already resolved and stopped, so the lead must too (it read as "random notes after
// the hold"). The ending is just the fill bar → the ringing tonic.
if ( playTop && bar % 2 == 0 && !isEnding )
RenderLeadPhrase( barStart, spe, secPerEighth, chord, leadRng, exprRng );
}
}
// The song's final downbeat. The whole band resolves home to the tonic on the "one" and
// lets the chord ring out into the tail RingOutTail reserved — a landing, not a turnaround.
// The previous bar's fill (see RenderSection) leads in and its terminal crash lands right
// here, so the ending itself only adds the kick + the sustained, decaying chord.
// ── Swing: warp the eighth-note grid so the whole band shuffles together ──
// On-beat (even) eighths are anchors that stay put; off-beat (odd) eighths are pushed late
// by _swing of an eighth, and positions between anchors interpolate. So a sixteenth (e+0.5)
// or a triplet subdivision lands on the SAME warped grid as the eighths — every voice swings
// in lockstep instead of the skank chop alone. `eighths` is a within-bar position measured in
// eighth-notes (0.5 = a sixteenth); returns the absolute sample index.
int Swung( int barStart, int spe, double eighths )
{
double baseE = Math.Floor( eighths );
double frac = eighths - baseE;
long slot = (long)baseE;
double startShift = (slot & 1) == 1 ? _swing : 0.0; // this eighth's onset shift
double endShift = ((slot + 1) & 1) == 1 ? _swing : 0.0; // the next eighth's onset shift
double pos = baseE + startShift + frac * (1.0 + endShift - startShift);
return barStart + (int)Math.Round( pos * spe );
}
void RenderEnding( int barStart, int spe, Rng noise )
{
int at = Math.Max( 0, barStart );
RenderKick( at, noise );
// Resolve to the progression's tonic (slot 0) wherever the chord cycle happened to
// land, and ring it with a natural exponential tail (Sustained = false) long enough to
// bloom into the reserved tail room. A held triad up top + the root an octave below.
int dur = (int)(_sr * RingOutTail * 0.92f);
int baseMidi = _rootMidi + 19;
int[] degs = { _prog[0], _prog[0] + 2, _prog[0] + 4, _prog[0] + 7 };
foreach ( var d in degs )
{
var pad = new Patch
{
Osc = 1, Voices = 3, Detune = _c.Detune,
Amp = 0.7f / degs.Length, Attack = 0.006f, Decay = 1.2,
Sustain = 0f, Sustained = false,
Cutoff = _c.SkankCutoff, CutEnv = 700f, Reso = 0.8f,
Drive = _genre == 3 ? 3.5f : 1.3f, Pan = 0f,
};
RenderPatch( at, dur, Midi( ScaleMidi( baseMidi, d ) ), pad );
}
var low = new Patch
{
Osc = 3, Voices = 2, Detune = _c.Detune * 0.4f,
Amp = _c.BassVol * _c.BassBalance, Attack = 0.004f, Decay = 1.4,
Sustain = 0f, Sustained = false,
Cutoff = _c.BassCutoff, CutEnv = 350f, Reso = 0.9f,
Drive = _c.BassDrive, Pan = 0f,
};
RenderPatch( at, dur, Midi( ChordRoot( 0 ) ), low, mono: true );
}
// Master: gentle soft-clip + normalize. The mix peak is first normalized to 1.0
// BEFORE the soft-clipper so it always has headroom — otherwise a hot sustained
// bed (all voices flat at 1.0) saturated the tanh and swallowed the drum
// transients (kick/snare washed out). MasterDrive now sets how hard a
// peak-normalized signal hits the clipper, so the dynamics stay intact.
// Call only after every RenderPitchedRange window has completed.
float Master()
{
int total = _bufL.Length;
float rawPeak = 0f;
for ( int i = 0; i < total; i++ )
rawPeak = Math.Max( rawPeak, Math.Max( MathF.Abs( _bufL[i] ), MathF.Abs( _bufR[i] ) ) );
float pre = rawPeak > 0.0001f ? _c.MasterDrive / rawPeak : _c.MasterDrive;
for ( int i = 0; i < total; i++ )
{
_bufL[i] = (float)Math.Tanh( _bufL[i] * pre );
_bufR[i] = (float)Math.Tanh( _bufR[i] * pre );
}
// A touch of stereo room reverb — the dry mix alone read flat/"16-bit".
ApplyReverb();
float peak = 0f;
for ( int i = 0; i < total; i++ )
{
float a = Math.Max( MathF.Abs( _bufL[i] ), MathF.Abs( _bufR[i] ) );
if ( a > peak ) peak = a;
}
return peak > 0.0001f ? _c.MasterPeak / peak : 1f;
}
// ── Master reverb ──
// A Schroeder/Freeverb-style bank: several parallel damped comb filters (the dense
// tail) feeding a chain of allpasses (diffusion). The two channels use slightly
// different delay lengths so the room is decorrelated → real stereo width and depth.
static readonly int[] CombBase = { 1116, 1188, 1277, 1356, 1422, 1491 }; // samples @ 44.1k
static readonly int[] ApBase = { 556, 441, 341 };
const int ReverbStereoSpread = 23; // R-channel delay offset for decorrelation
void ApplyReverb()
{
float wet = Math.Clamp( _c.MasterReverb, 0f, 1f );
if ( wet <= 0.0001f ) return;
float feedback = 0.70f + 0.28f * Math.Clamp( _c.ReverbDecay, 0f, 1f ); // tail length
const float damp = 0.25f, damp1 = 1f - damp; // HF damping in the tail
const float apg = 0.5f; // allpass coefficient
const float inGain = 0.25f; // drive into the reverb
double srk = _sr / 44100.0; // scale delays to the rate
for ( int ch = 0; ch < 2; ch++ )
{
var buf = ch == 0 ? _bufL : _bufR;
int off = ch == 0 ? 0 : ReverbStereoSpread;
int nc = CombBase.Length, na = ApBase.Length;
var combBuf = new float[nc][];
var combIdx = new int[nc];
var combStore = new float[nc];
for ( int j = 0; j < nc; j++ )
combBuf[j] = new float[Math.Max( 1, (int)Math.Round( (CombBase[j] + off) * srk ) )];
var apBuf = new float[na][];
var apIdx = new int[na];
for ( int j = 0; j < na; j++ )
apBuf[j] = new float[Math.Max( 1, (int)Math.Round( (ApBase[j] + off) * srk ) )];
int n = buf.Length;
for ( int i = 0; i < n; i++ )
{
float input = buf[i] * inGain;
float acc = 0f;
for ( int j = 0; j < nc; j++ )
{
var cb = combBuf[j];
int idx = combIdx[j];
float r = cb[idx];
combStore[j] = r * damp1 + combStore[j] * damp;
cb[idx] = input + combStore[j] * feedback;
if ( ++idx >= cb.Length ) idx = 0;
combIdx[j] = idx;
acc += r;
}
acc /= nc;
for ( int j = 0; j < na; j++ )
{
var ab = apBuf[j];
int idx = apIdx[j];
float r = ab[idx];
float o = r - acc;
ab[idx] = acc + r * apg;
if ( ++idx >= ab.Length ) idx = 0;
apIdx[j] = idx;
acc = o;
}
buf[i] += wet * acc;
}
}
}
int ScaleMidi( int baseMidi, int degree )
{
int len = _scale.Length;
int oct = (int)Math.Floor( degree / (double)len );
return baseMidi + _scale[degree - oct * len] + 12 * oct;
}
int ChordRoot( int c ) => ScaleMidi( _rootMidi, _prog[c] );
// ── Instrument expression ──
// Four expressive PROPERTIES every pitched voice can lean on (drums are excluded). Each
// instrument gets a genre-specific PROPENSITY for each, "based on what it is" — a brass
// lead sings and scoops, a bass slides, a power-chord guitar stays dead straight. The
// realization is the per-note pitch shaping in RenderEvent (vibrato depth + bend envelope).
// Vib — #1 vibrato depth (a constant lean, no per-note roll)
// BendIn — #2 bend up INTO the note from a step below (per-note chance)
// Glide — #3 portamento from the previous note's pitch (per-note chance)
// Scoop — #4 bend up-and-back within the note (per-note chance)
readonly struct Expression
{
public readonly float Vib, BendIn, Glide, Scoop;
public Expression( float vib, float bendIn, float glide, float scoop )
{ Vib = vib; BendIn = bendIn; Glide = glide; Scoop = scoop; }
}
const int NoPrev = int.MinValue; // "no previous note" sentinel for glide
// The per-instrument propensity table — genre-aware. Leads route here by genre
// ("LEAD" = ska brass, "LEAD GTR" = rock guitar). Rock lead's BENDINESS knob drives its
// bend-in + scoop directly (that's what "bendiness" is). Tune these by ear.
Expression Expr( string voice )
{
switch ( voice )
{
case "BASS": return _genre switch
{
1 => new Expression( 0f, 0f, 0.10f, 0f ), // rock: locked
2 => new Expression( 0f, 0f, 0.12f, 0.03f ), // country: a subtle slide
3 => default, // metal: dead straight, fast
4 => default, // punk: dead straight, fast
5 => default, // pop: tight synth bass, no slide
_ => new Expression( 0f, 0f, 0.25f, 0.05f ), // reggae bass slides
};
case "SKANK": return default; // staccato chops — dead straight
case "ORGAN": return new Expression( 0.15f, 0f, 0f, 0f ); // gentle bubble vibrato (only blooms on held notes)
case "LEAD": return new Expression( 0.35f, 0.15f, 0.10f, 0.25f ); // brass sings + scoops
case "HORNS": return new Expression( 0.20f, 0f, 0f, 0.20f ); // section stabs fall/scoop
case "KEYS": return default; // organ comp — locked, no wobble
case "RHYTHM GTR": return default; // power chords — straight
case "LEAD GTR":
{
// Country leans hard into bends (the telecaster twang); rock/metal ride the knob.
float bend = _genre == 2 ? MathF.Max( _c.LeadGtrBend, 0.5f ) : _c.LeadGtrBend;
return new Expression( 0.30f, bend, 0.10f, bend );
}
default: return default;
}
}
// A rolled-per-note voicing: the concrete pitch-shaping a note will get. Vibrato is a
// constant depth (no draw); bend-in/glide/scoop are rolled against their propensities, so
// only voices that lean on them ever pull from the expression stream.
struct Voicing { public float VibDepth, BendSemis, BendTime, ScoopSemis; }
Voicing Roll( in Expression ex, int midi, int prevMidi, Rng rng )
{
var v = new Voicing();
// Vibrato depth is a SMALL pitch fraction (lean 0.5 ≈ ±10 cents) and it's delayed in
// the synth, so notes read locked-on, not seasick. BendTime is in SECONDS — a quick
// slide that resolves and locks, never a fraction of a long held note.
if ( ex.Vib > 0f ) v.VibDepth = 0.003f + 0.006f * ex.Vib;
if ( ex.Glide > 0f && prevMidi != NoPrev && rng.Chance( ex.Glide ) )
{
v.BendSemis = Math.Clamp( (prevMidi - midi) * 0.3f, -2f, 2f ); // lean toward the prev pitch, not all the way
v.BendTime = 0.13f; // ~130 ms portamento
}
else if ( ex.BendIn > 0f && rng.Chance( ex.BendIn ) )
{
v.BendSemis = rng.Chance( 0.5f ) ? -0.3f : -0.55f; // a subtle lean up into pitch
v.BendTime = 0.09f; // ~90 ms bend up into pitch
}
if ( ex.Scoop > 0f && rng.Chance( ex.Scoop ) )
v.ScoopSemis = rng.Chance( 0.5f ) ? 0.15f : 0.3f; // a slight attack hump
return v;
}
// Bake a rolled voicing onto a patch. VibDepth is harmless unless the patch carries a
// vibrato RATE (p.Vibrato) — so a voice the user muted to 0 Hz stays dry — which means a
// voice that wants expression-vibrato must set its own rate in its patch literal.
static void ApplyVoicing( ref Patch p, in Voicing v )
{
if ( v.VibDepth > 0f ) p.VibDepth = v.VibDepth;
p.BendSemis = v.BendSemis; p.BendTime = v.BendTime; p.ScoopSemis = v.ScoopSemis;
}
// ── Bass ──
void RenderBassBar( int barStart, int spe, double secPerEighth, int chord, int nextChord, Rng rng, Rng bassOrn, Rng exprRng )
{
int root = ChordRoot( chord );
var ex = Expr( "BASS" );
int prevMidi = NoPrev;
// Bass has its own ornament knob (BASS TRIPLETS), nudged up a touch by overall
// kit busyness so a busy vibe gets a busier bass.
float ornChance = _c.BassTriplets * 0.5f + _c.DrumBusy * 0.05f;
for ( int e = 0; e < EighthsPerBar; e++ )
{
int off = _bassPat[e];
if ( off == Rest ) continue;
int midi;
if ( off == Approach )
{
int target = ChordRoot( nextChord );
midi = target - (rng.Chance( 0.5f ) ? 1 : 2); // chromatic/step lead-in
}
else
{
midi = root + off;
if ( off == 0 && e > 0 && rng.Chance( _c.OctavePopChance ) ) midi += 12;
}
// note runs until the next onset (legato reggae feel)
int len = 1;
while ( e + len < EighthsPerBar && _bassPat[e + len] == Rest ) len++;
// Chop a standalone (non-sustaining) note into a 16th pair or 16th-note
// triplet so the line reads "long long short short" / "long short long long"
// instead of even eighths. Driven by a dedicated stream, so the main
// composition RNG order — and every existing song — is left unchanged.
var vc = Roll( ex, midi, prevMidi, exprRng );
prevMidi = midi;
if ( off != Approach && len == 1 && bassOrn.Chance( ornChance ) )
{
int n = bassOrn.Chance( 0.65f ) ? 2 : 3; // 16th pair / 16th triplet
int step = spe / n;
int[] moves = { 0, 7, 12 }; // root / fifth / octave
for ( int k = 0; k < n; k++ )
{
int bm = midi + (k == 0 ? 0 : moves[bassOrn.Int( moves.Length )]);
EmitBass( Swung( barStart, spe, e + (double)k / n ), (int)(step * 0.9f), bm, secPerEighth / n * 0.8, vc );
}
continue;
}
EmitBass( Swung( barStart, spe, e ), (int)(spe * len * 0.95f), midi, secPerEighth * len * 0.8, vc );
}
}
void EmitBass( int at, int dur, int midi, double decaySec, in Voicing vc )
{
// Triangle body for a round, deep reggae/dub bass (saw alone read as too
// buzzy) — but triangle alone was too subtle, so layer a quieter square
// underneath for presence/definition. The square's odd harmonics give the
// bass its bite; both share the bass low-pass so the tone stays warm.
var body = new Patch
{
Osc = 3, Voices = 2, Detune = _c.Detune * 0.4f,
Amp = _c.BassVol * _c.BassBalance, Attack = 0.004f, Decay = decaySec,
Sustain = 0.55f, Sustained = true,
Cutoff = _c.BassCutoff, CutEnv = 350f, Reso = 0.9f,
Drive = _c.BassDrive, Pan = 0f,
};
var sub = new Patch
{
Osc = 2, Voices = 1, Detune = 0f,
Amp = _c.BassVol * 0.4f * _c.BassBalance, Attack = 0.004f, Decay = decaySec,
Sustain = 0.55f, Sustained = true,
Cutoff = _c.BassCutoff, CutEnv = 350f, Reso = 0.9f,
Drive = _c.BassDrive, Pan = 0f,
};
ApplyVoicing( ref body, vc ); ApplyVoicing( ref sub, vc );
RenderPatch( at, dur, Midi( midi ), body, mono: true );
RenderPatch( at, dur, Midi( midi ), sub, mono: true );
}
// ── Skank guitar (the signature) + reggae organ bubble — offbeats, centered ──
void RenderRhythmBar( int barStart, int spe, double secPerEighth, int chord, Rng rng, Rng exprRng )
{
// +24: skank/organ sit an octave above the old register — at +12 (E2..B2) the
// chop was too low/muddy to cut through and read as missing. Organ stays a
// further octave down via the -12 below.
int gBase = _rootMidi + 24;
int[] degs = { _prog[chord], _prog[chord] + 2, _prog[chord] + 4, _prog[chord] + 7 };
// Skank is dead straight (default); the organ bubble gets a gentle vibrato depth.
var organVc = Roll( Expr( "ORGAN" ), 0, NoPrev, exprRng );
for ( int e = 1; e < EighthsPerBar; e += 2 ) // offbeats
{
int at = Swung( barStart, spe, e ); // odd e = offbeat → Swung pushes it late by _swing
// bright, thin, short guitar chop
foreach ( var d in degs )
RenderPatch( at, (int)(spe * Math.Clamp( _c.SkankChop, 0.15f, 1f )), Midi( ScaleMidi( gBase, d ) ), new Patch
{
Osc = 1, Voices = 3, Detune = _c.Detune,
Amp = _c.SkankVol * _c.SkankBalance / degs.Length, Attack = 0.002f, Decay = 0.10,
Sustain = 0f, Sustained = false,
Cutoff = _c.SkankCutoff, CutEnv = 1500f, Reso = 0.8f,
Highpass = _c.SkankHighpass, Drive = _c.SkankDrive, Pan = 0f,
} );
// reggae organ "bubble": a softer, rounder offbeat under the guitar
if ( _organBubble )
foreach ( var d in degs )
{
var organ = new Patch
{
Osc = 0, Voices = 2, Detune = _c.Detune * 0.5f,
Amp = _c.OrganVol * _c.OrganBalance / degs.Length, Attack = 0.004f, Decay = 0.16,
Sustain = 0.3f, Sustained = false,
Cutoff = _c.OrganCutoff, CutEnv = 0f, Reso = 1.0f, Drive = 1.1f, Pan = 0f,
Vibrato = _c.OrganVibrato,
};
ApplyVoicing( ref organ, organVc );
RenderPatch( at, (int)(spe * 0.55f), Midi( ScaleMidi( gBase, d ) - 12 ), organ );
}
}
}
// ── Lead melody (chord-tone locked → consonant) ──
void RenderLeadPhrase( int barStart, int spe, double secPerEighth, int chord, Rng rng, Rng exprRng )
{
int slots = EighthsPerBar * 2;
int melBase = _rootMidi + 24;
int[] tones = { _prog[chord], _prog[chord] + 2, _prog[chord] + 4, _prog[chord] + 6 }; // chord tones
int degree = tones[rng.Int( 3 )];
bool guitarLead = _genre != 0; // ska is the only horn lead
float amp = guitarLead ? _c.LeadGtrVol * _c.LeadGtrBalance : _c.MelodyVol * _c.MelodyBalance;
float drive = guitarLead ? _c.LeadGtrDrive : _c.MelodyDrive;
// Rock lead trades fast RUNS for BENDINESS (handled via expression), so its run rate is
// forced to 0; metal shreds (a high floor of runs); ska/country keep the TRIPLETS knob.
float tripChance = _genre switch
{
1 => 0f,
3 => MathF.Max( _c.TripletChance, 0.4f ),
_ => _c.TripletChance,
};
var ex = guitarLead ? Expr( "LEAD GTR" ) : Expr( "LEAD" );
int prevMidi = NoPrev;
int e = 0;
while ( e < slots )
{
if ( rng.Chance( _c.MelodyRestChance ) ) { e++; continue; }
// ornament: a sixteenth pair, or a triplet at one of three rates — a tight
// 16th-triplet (3 in an eighth), an eighth-note triplet (3 in a beat), or a
// wide quarter-note triplet (3 over two beats). Wider spans give the lazy,
// over-the-barline triplet feel, not just the fast run.
if ( rng.Chance( tripChance ) )
{
float r = rng.Next();
int n, spanE; // n notes evenly across spanE eighths
if ( r < 0.25f ) { n = 2; spanE = 1; } // sixteenth pair
else if ( r < 0.50f ) { n = 3; spanE = 1; } // 16th-note triplet
else if ( r < 0.80f ) { n = 3; spanE = 2; } // eighth-note triplet (1 beat)
else { n = 3; spanE = 4; } // quarter-note triplet (2 beats)
if ( e + spanE > slots ) spanE = 1;
int span = spanE * spe;
int step = span / n;
int firstMidi = ScaleMidi( melBase, Math.Clamp( degree - n / 2, _prog[chord] - 3, _prog[chord] + 10 ) );
var runVc = Roll( ex, firstMidi, prevMidi, exprRng );
for ( int k = 0; k < n; k++ )
{
int d2 = Math.Clamp( degree + (k - n / 2), _prog[chord] - 3, _prog[chord] + 10 );
int m2 = ScaleMidi( melBase, d2 );
RenderLeadNote( Swung( barStart, spe, e + (double)k * spanE / n ), (int)(step * 0.9f),
m2, amp, secPerEighth * spanE / (double)n * 0.85, drive, runVc );
prevMidi = m2;
}
e += spanE;
continue;
}
int len = 1 + rng.Int( 3 );
if ( e + len > slots ) len = slots - e;
bool strong = (e % 2) == 0;
if ( strong )
{
// land on a chord tone near the current degree
int best = tones[0], bestD = 999;
foreach ( var t in tones )
{
for ( int oc = -7; oc <= 14; oc += 7 )
{
int cand = t + (oc / 7) * 7; // keep in degree space
int dist = Math.Abs( cand - degree );
if ( dist < bestD ) { bestD = dist; best = cand; }
}
}
degree = best;
}
else
{
int step = rng.Chance( _c.MelodyLeapChance ) ? (rng.Chance( 0.5f ) ? 3 : -3) : (rng.Chance( 0.5f ) ? 1 : -1);
degree = Math.Clamp( degree + step, _prog[chord] - 3, _prog[chord] + 10 );
}
int midi = ScaleMidi( melBase, degree );
var vc = Roll( ex, midi, prevMidi, exprRng );
RenderLeadNote( Swung( barStart, spe, e ), (int)(spe * len * 0.9f), midi,
amp, secPerEighth * len * 0.7f, drive, vc );
prevMidi = midi;
e += len;
}
}
// Dispatch a lead note to the genre's lead voice: a distorted single-note guitar for rock,
// otherwise the ska horn (RenderLead → trumpet).
void RenderLeadNote( int at, int dur, int midi, float amp, double decaySec, float drive, in Voicing vc )
{
if ( _genre != 0 )
{
// Twang = a bright cutoff-envelope snap on each pick (high CutEnv, decays fast) through
// a resonant SVF, plus a BASE distortion under the slider so it reads as an electric
// guitar even at the slider minimum. The base is genre-set: rock = 3 (overdriven),
// metal = 4 hot (heavy), country = clean (the bite comes from the twang snap + bends,
// not gain). The bends (BENDINESS knob → bend-in + scoop) come in via the voicing.
float driveAmt = _genre switch
{
3 => 4f + MathF.Max( 1f, _c.LeadGtrDrive ), // metal: heavy
2 => 0.8f + 0.3f * MathF.Max( 1f, _c.LeadGtrDrive ),// country: clean twang
4 => 2f + MathF.Max( 1f, _c.LeadGtrDrive ), // punk: bright, lightly driven
5 => 0.6f + 0.2f * MathF.Max( 1f, _c.LeadGtrDrive ),// pop: clean synth pluck/lead
_ => 3f + MathF.Max( 1f, _c.LeadGtrDrive ), // rock
};
// Country/pop get a brighter cutoff snap — country for telecaster twang, pop for a
// plucky synth attack.
float cutEnv = _genre == 2 ? 3000f : _genre == 5 ? 3500f : 2200f;
var gtr = new Patch
{
Osc = 1, Voices = 1, Detune = 0f, Amp = amp,
Attack = 0.002f, Decay = decaySec, Sustain = 0.55f, Sustained = true,
Cutoff = _c.LeadGtrCutoff, CutEnv = cutEnv, Reso = 0.65f,
Drive = driveAmt, Pan = _leadPan, Vibrato = _c.MelodyVibrato,
};
ApplyVoicing( ref gtr, vc );
// The lead is monophonic — a single clean take at its per-song pan (_leadPan). It is NOT
// double-tracked: splitting a solo line into two detuned, hard-panned, time-offset takes
// beats on sustained notes and overlaps adjacent pitches on runs, reading as "out of key".
// Double-tracking width is for the chordal/strummed voices, not the melody.
RenderPatch( at, dur, Midi( midi ), gtr, mono: true );
return;
}
RenderLead( at, dur, midi, amp, decaySec, drive, vc );
}
// ── Rock KEYS — their OWN part, not a double of the guitar. A syncopated organ comp (the
// "1, &-of-2, 3, &-of-4" Charleston push) playing diatonic TRIADS (root/3rd/5th) in a high
// keyboard register. Different notes (a real triad vs the guitar's bare power chord) AND a
// different rhythm (a 4-hit syncopation vs the guitar's every-eighth chug), so the two
// interlock instead of playing in lockstep. KeysChug rings the chords (0) or tightens them
// toward short stabs (1).
static readonly int[] KeysOnsets = { 0, 3, 4, 7 }; // eighth positions of the comp's hits
void RenderKeysBar( int barStart, int spe, double secPerEighth, int chord, Rng rng, Rng exprRng )
{
int kBase = _rootMidi + 24; // keyboard register, an octave over the rhythm guitar
int[] degs = { _prog[chord], _prog[chord] + 2, _prog[chord] + 4 }; // diatonic triad
float chug = Math.Clamp( _c.KeysChug, 0f, 1f );
// Country reads this comp as a honky-tonk piano and pop as a clean bright synth: keep both
// clean (rock drives it dirty).
float keysDrive = _genre == 2 || _genre == 5 ? 1f + 0.2f * MathF.Max( 1f, _c.KeysDrive )
: MathF.Max( 1f, _c.KeysDrive );
var keysVc = Roll( Expr( "KEYS" ), 0, NoPrev, exprRng ); // gentle vibrato only
for ( int oi = 0; oi < KeysOnsets.Length; oi++ )
{
int e = KeysOnsets[oi];
int nextE = oi + 1 < KeysOnsets.Length ? KeysOnsets[oi + 1] : EighthsPerBar; // ring up to the next hit
int gap = nextE - e;
bool ring = chug < 0.5f;
int dur = (int)(gap * spe * Math.Max( 0.25f, 1f - 0.7f * chug ));
double dec = secPerEighth * gap * (ring ? 0.9 : 0.4);
foreach ( var d in degs )
{
var keys = new Patch
{
Osc = 1, Voices = 2, Detune = _c.Detune * 0.5f,
Amp = _c.KeysVol * _c.KeysBalance / degs.Length,
Attack = 0.004f, Decay = dec, Sustain = ring ? 0.6f : 0.2f, Sustained = ring,
Cutoff = _c.KeysCutoff, CutEnv = 250f, Reso = 1.0f,
Drive = keysDrive, Pan = 0f,
};
ApplyVoicing( ref keys, keysVc );
RenderPatch( Swung( barStart, spe, e ), dur, Midi( ScaleMidi( kBase, d ) ), keys );
}
}
}
// ── Rock rhythm guitar — twangy distorted power chords. Shares the lead guitar's voice (the
// bright cutoff-envelope "twang" through a resonant SVF) but strums root+fifth+octave and
// runs a LOWER base distortion than the lead so the two layer instead of mush. Downbeats
// ring; offbeats tighten toward a palm-muted chug as RhythmGtrChug rises.
// exprRng is unused: power chords stay dead straight (Expr("RHYTHM GTR") is default), but the
// param keeps every instrument's call site uniform.
void RenderRhythmGuitarBar( int barStart, int spe, double secPerEighth, int chord, Rng rng, Rng exprRng )
{
bool country = _genre == 2;
int root = ChordRoot( chord ) + 12; // chunky register, an octave up
// Country strums a full DIATONIC triad (root/3rd/5th + octave) clean and bright — built in
// degree space (ScaleMidi) so the 3rd follows the mode, matching the keys/lead. A hardcoded
// major 3rd clashed on the minor chords of a progression (e.g. the vi in {0,4,5,3}). Rock
// chunks a bare, mode-neutral power chord (root/5th/octave) with more base distortion.
int triBase = _rootMidi + 12; // same register as `root`, in degree space
int[] notes = country
? new[] { ScaleMidi( triBase, _prog[chord] ), ScaleMidi( triBase, _prog[chord] + 2 ),
ScaleMidi( triBase, _prog[chord] + 4 ), ScaleMidi( triBase, _prog[chord] + 7 ) }
: new[] { root, root + 7, root + 12 };
float chug = Math.Clamp( _c.RhythmGtrChug, 0f, 1f );
float cutEnv = country ? 2600f : 1400f; // brighter twang for the clean strum
float driveAmt = country ? 0.8f + 0.3f * MathF.Max( 1f, _c.RhythmGtrDrive )
: 1.5f + MathF.Max( 1f, _c.RhythmGtrDrive ); // less base than lead
for ( int e = 0; e < EighthsPerBar; e++ )
{
bool accent = (e % 2) == 0; // downbeats ring, offbeats chug
float lenFrac = accent ? (1f - 0.5f * chug) : (0.35f - 0.2f * chug);
int dur = (int)(spe * Math.Max( 0.12f, lenFrac ));
double dec = secPerEighth * (accent ? 0.8 : 0.3);
foreach ( var m in notes )
RenderPatch( Swung( barStart, spe, e ), dur, Midi( m ), new Patch
{
Osc = 1, Voices = 2, Detune = _c.Detune * 0.5f,
Amp = _c.RhythmGtrVol * _c.RhythmGtrBalance / notes.Length * (accent ? 1f : 0.7f),
Attack = 0.002f, Decay = dec, Sustain = accent ? 0.45f : 0f, Sustained = accent,
Cutoff = _c.RhythmGtrCutoff, CutEnv = cutEnv, Reso = 0.8f, // twang
Drive = driveAmt, Pan = 0f,
} );
}
}
// ── Metal rhythm guitar — palm-muted 16th-note gallop on the low root with power-chord
// accents. The relentless 16th chug (under the double-kick) is the "fast riff" engine; the
// downbeats and a few syncopated stabs ring a full power chord. Heavy base distortion, dark
// and tight. rng (the rhythm stream) breaks up the accent placement so riffs vary by section.
void RenderMetalRiffBar( int barStart, int spe, double secPerEighth, int chord, Rng rng, Rng exprRng )
{
int root = ChordRoot( chord ); // low, chunky — no octave bump
int[] power = { 0, 7, 12 };
int six = spe / 2;
if ( six <= 0 ) return;
float chug = Math.Clamp( _c.RhythmGtrChug, 0f, 1f );
float driveAmt = 4f + MathF.Max( 1f, _c.RhythmGtrDrive ); // heavy
for ( int s = 0; s < EighthsPerBar * 2; s++ ) // 16 sixteenths
{
int at = Swung( barStart, spe, s * 0.5 ); // 16 sixteenths on the swung grid
bool beat = s % 4 == 0; // quarter-note downbeats → ring a chord
bool ring = beat || (s % 2 == 0 && rng.Chance( 0.3f )); // some offbeat eighths ring too
int[] offs = ring ? power : new[] { 0 }; // accents = power chord, chugs = root only
float gain = ring ? 1f : 0.6f;
// Palm mute = short, tight; accents ring longer. Chug tightens the muted notes further.
int dur = (int)(six * (ring ? 0.9f : Math.Max( 0.25f, 0.55f - 0.3f * chug )));
double dec = secPerEighth * (ring ? 0.4 : 0.12);
foreach ( var o in offs )
RenderPatch( at, dur, Midi( root + o ), new Patch
{
Osc = 1, Voices = 2, Detune = _c.Detune * 0.5f,
Amp = _c.RhythmGtrVol * _c.RhythmGtrBalance / offs.Length * gain,
Attack = 0.002f, Decay = dec, Sustain = ring ? 0.35f : 0f, Sustained = ring,
Cutoff = _c.RhythmGtrCutoff, CutEnv = 1100f, Reso = 0.7f,
Drive = driveAmt, Pan = 0f,
} );
}
}
// ── Backing horns (panned spread) ──
// Block stabs on the mask read "samey" (only eighth-note chords). A dedicated
// stream (horn:tag, so the main composition order is unchanged) breaks them up
// with rolling arpeggios, 16th pairs, grace pickups and varied length. Kept
// modest — the bass got over-busy when its ornament rate ran high.
void RenderHornStabs( int barStart, int spe, double secPerEighth, int chord, Rng orn, Rng exprRng )
{
int baseMidi = _rootMidi + 19;
int[] degs = { _prog[chord], _prog[chord] + 2, _prog[chord] + 4 };
float spread = _c.PanAmount * 0.7f;
int six = spe / 2;
float ornChance = 0.18f + _c.TripletChance; // ~0.24 default; rides the same knob
// Section expression — vibrato + a chance of a scoop/fall, rolled once per onset (below)
// and applied to every tone of that stab so the whole section bends together.
var ex = Expr( "HORNS" );
Voicing hornVc = default;
// one chord-tone voice
void Note( int at, int dur, int k, double dec, float gain )
{
var horn = new Patch
{
Osc = 1, Voices = 3, Detune = _c.Detune,
Amp = _c.HornVol * _c.HornBalance / degs.Length * gain, Attack = 0.008f, Decay = dec,
Sustain = 0.2f, Sustained = false,
Cutoff = _c.HornCutoff, CutEnv = 1200f, Reso = 1.0f,
Drive = _c.HornDrive, Pan = spread * (k / (float)(degs.Length - 1) * 2f - 1f),
Vibrato = _c.MelodyVibrato,
};
ApplyVoicing( ref horn, hornVc );
RenderPatch( at, dur, Midi( ScaleMidi( baseMidi, degs[k] ) ), horn );
}
// full block chord stab
void Stab( int at, int dur, double dec, float gain )
{
for ( int k = 0; k < degs.Length; k++ ) Note( at, dur, k, dec, gain );
}
for ( int e = 0; e < EighthsPerBar; e++ )
{
if ( !_hornMask[e] ) continue;
int at = Swung( barStart, spe, e );
hornVc = Roll( ex, baseMidi, NoPrev, exprRng );
if ( six > 0 && orn.Chance( ornChance ) )
{
float r = orn.Next();
if ( r < 0.4f )
{
// rolling arpeggio: chord tones climb across a 16th-triplet
int step = spe / 3;
for ( int k = 0; k < degs.Length; k++ )
Note( Swung( barStart, spe, e + (double)k / 3 ), (int)(step * 0.9f), k, secPerEighth / 3 * 0.8, 1f );
continue;
}
if ( r < 0.75f )
{
// 16th pair: stab on the beat, softer echo on the "e"
Stab( at, (int)(six * 0.85f), secPerEighth * 0.5 * 0.8, 1f );
Stab( Swung( barStart, spe, e + 0.5 ), (int)(six * 0.85f), secPerEighth * 0.5 * 0.7, 0.6f );
continue;
}
// grace pickup: a soft single tone just before the block stab
Note( Swung( barStart, spe, e - 0.5 ), (int)(six * 0.8f), 0, secPerEighth * 0.5 * 0.6, 0.5f );
Stab( at, (int)(spe * 0.6f), 0.22, 1f );
continue;
}
// plain stab — length varies a touch so even straight bars aren't identical
float lenMul = 0.45f + orn.Next() * 0.35f;
Stab( at, (int)(spe * lenMul), 0.22, 1f );
}
}
// ── Lead instrument voices ──
enum Instrument { Trumpet, Sax, Organ, Trombone }
Instrument PickInstrument( Rng rng )
{
if ( _c.ForceInstrument >= 0 && _c.ForceInstrument <= 3 ) return (Instrument)_c.ForceInstrument;
float tw = MathF.Max( 0f, _c.TrumpetWeight ), sw = MathF.Max( 0f, _c.SaxWeight );
float ow = MathF.Max( 0f, _c.OrganWeight ), bw = MathF.Max( 0f, _c.TromboneWeight );
float sum = tw + sw + ow + bw;
if ( sum <= 0f ) return Instrument.Trumpet;
float r = rng.Next() * sum;
if ( (r -= tw) < 0f ) return Instrument.Trumpet;
if ( (r -= sw) < 0f ) return Instrument.Sax;
if ( (r -= ow) < 0f ) return Instrument.Organ;
return Instrument.Trombone;
}
void RenderLead( int at, int dur, int midi, float amp, double decaySec, float drive, in Voicing vc )
{
Patch p; int m = midi;
switch ( _lead )
{
case Instrument.Trumpet:
p = new Patch
{
Osc = 1, Voices = 3, Detune = _c.Detune * 0.7f, Amp = amp,
Attack = 0.01f, Decay = decaySec, Sustain = 0.7f, Sustained = true,
Cutoff = _c.LeadCutoff, CutEnv = 1800f, Reso = 1.0f, Drive = drive,
Pan = _leadPan, Vibrato = _c.MelodyVibrato,
};
break;
case Instrument.Trombone:
m = midi - 12;
p = new Patch
{
Osc = 1, Voices = 3, Detune = _c.Detune * 0.7f, Amp = amp * 1.1f,
Attack = 0.02f, Decay = decaySec, Sustain = 0.7f, Sustained = true,
Cutoff = _c.LeadCutoff * 0.7f, CutEnv = 900f, Reso = 1.0f, Drive = MathF.Max( 1f, drive * 0.8f ),
Pan = _leadPan, Vibrato = _c.MelodyVibrato * 0.7f,
};
break;
case Instrument.Sax:
p = new Patch
{
Osc = 3, Voices = 2, Detune = _c.Detune * 0.5f, Amp = amp * 1.15f,
Attack = 0.014f, Decay = decaySec, Sustain = 0.75f, Sustained = true,
Cutoff = _c.LeadCutoff, CutEnv = 1400f, Reso = 0.7f, Drive = MathF.Max( 1.2f, drive ),
Pan = _leadPan, Vibrato = _c.MelodyVibrato, Breath = 0.03f,
};
break;
default: // Organ
p = new Patch
{
Osc = 0, Voices = 3, Detune = _c.Detune * 0.6f, Amp = amp,
Attack = 0.006f, Decay = decaySec * 1.5, Sustain = 0.9f, Sustained = true,
Cutoff = 2600f, CutEnv = 0f, Reso = 1.0f, Drive = 1.15f,
Pan = _leadPan, Vibrato = _c.MelodyVibrato * 0.9f,
};
break;
}
ApplyVoicing( ref p, vc );
// Single clean take at _leadPan — the ska horn/organ lead is monophonic, so it is not
// double-tracked (see the guitar-lead note in RenderLeadNote: doubling a solo line smears
// pitch). Only chordal/strummed voices get the width.
RenderPatch( at, dur, Midi( m ), p, mono: true );
}
// ── Synth core: unison osc → optional high-pass → resonant low-pass (cutoff
// envelope) → soft drive → AD/sustain amp env. ──
struct Patch
{
public int Osc; // 0 sine 1 saw 2 square 3 triangle
public int Voices;
public float Detune; // cents
public float Amp;
public float Attack; // sec
public double Decay; // sec (exp time constant)
public float Sustain; // 0..1 (only if Sustained)
public bool Sustained;
public float Cutoff; // Hz low-pass
public float CutEnv; // Hz added at attack, decays with Decay
public float Reso; // SVF damping (lower = more resonance)
public float Highpass; // Hz one-pole high-pass (0 = off)
public float Drive; // tanh
public float Pan; // -1..1
public float Vibrato; // Hz (rate of the pitch wobble)
public float Breath; // 0..1 noise mix (reeds)
// ── Expression (per-note pitch shaping; see Expression/Voicing) ──
public float VibDepth; // vibrato depth as a pitch fraction (0 → legacy 0.005 when Vibrato>0)
public float BendSemis; // pitch offset in semitones at note START, glides to 0 (bend-in / glide); −ve starts below
public float BendTime; // 0..1 fraction of the note over which BendSemis glides to 0
public float ScoopSemis; // height (semitones) of a mid-note bend-up-and-back hump (0 = none)
public float PhaseSeed; // oscillator start phase (0..1); 0 = legacy in-phase start. Used to
// decorrelate the two double-tracking takes (see RenderPatch).
}
// Pitched note events collected during ComposePlan, then synthesized by
// RenderPitchedRange. Synthesis pulls no RNG, so windows parallelize across threads.
struct NoteEvent { public int Start, Dur; public float Freq; public Patch P; }
readonly List<NoteEvent> _events = new();
// During ComposePlan this only enqueues; the synthesis happens in RenderPitchedRange.
// When DoubleTrack is on, every (non-drum) note is widened into two decorrelated takes
// panned apart — see the Config "width" block. `lead` selects the wider lead spread.
void RenderPatch( int start, int dur, float freq, Patch p, bool lead = false, bool mono = false )
{
if ( start < 0 || dur <= 0 || p.Voices < 1 ) return;
// Bass stays centred (mono): doubling/detuning low frequencies smears the low end and
// cancels in mono, so the bass is the one voice kept dead-centre regardless of width.
if ( mono || _c.DoubleTrack < 0.5f )
{
_events.Add( new NoteEvent { Start = start, Dur = dur, Freq = freq, P = p } );
return;
}
// The STEREO WIDTH slider (_widthScale) scales the whole effect: pan AND the
// decorrelation (detune / delay / jitter / phase / per-note variation, all scaled inside
// AddTake). At 100% it's the full design width; at 0% both takes collapse onto each other
// at centre → effectively mono.
float width = Math.Clamp( lead ? _c.WidthLead : _c.WidthBacking, 0f, 1f ) * _widthScale;
float halfCents = _c.WidthDetune * 0.5f * _widthScale;
int delay = (int)(_c.WidthDelayMs * 0.001f * _sr * _widthScale);
int jit = (int)(_c.WidthJitterMs * 0.001f * _sr * _widthScale);
// Take A sits left and slightly flat; take B sits right, slightly sharp, and lags by the
// constant offset. Both get independent phase + per-note amp/cutoff/timing variation, so
// the two channels are genuinely different performances (the source of the width) rather
// than a phantom-centre mono copy.
AddTake( start, dur, freq, p, 0, -width, -halfCents, 0, jit );
AddTake( start, dur, freq, p, 1, +width, +halfCents, delay, jit );
}
// Enqueue one double-tracking take. All per-note variation is derived from a local hash of
// (start, freq, take) so it stays deterministic and pulls no RNG (synthesis must remain a
// pure function for the parallel RenderPitchedRange windows).
void AddTake( int start, int dur, float freq, Patch p, int take, float pan, float cents, int delayBase, int jit )
{
uint s = unchecked( (uint)start * 2654435761u + (uint)(int)freq * 40503u )
^ (take == 0 ? 0x85EBCA6Bu : 0xC2B2AE35u);
float r1 = Hash01( ref s ); // start phase
float r2 = Hash01( ref s ); // amplitude
float r3 = Hash01( ref s ); // cutoff
float r4 = Hash01( ref s ); // timing jitter
int st = start + delayBase + (int)((r4 * 2f - 1f) * jit);
if ( st < 0 ) st = 0;
var q = p;
q.Pan = pan;
// Phase + per-note variation also scale with the width slider, so at 0% the two takes
// become identical and centred (clean mono) instead of a flangy centred double.
q.PhaseSeed = r1 * _widthScale;
q.Amp = p.Amp * (1f + (r2 * 2f - 1f) * _c.WidthAmpVar * _widthScale);
q.Cutoff = p.Cutoff * (1f + (r3 * 2f - 1f) * _c.WidthCutoffVar * _widthScale);
float f = freq * (float)Math.Pow( 2.0, cents / 1200.0 );
_events.Add( new NoteEvent { Start = st, Dur = dur, Freq = f, P = q } );
}
// Fast deterministic [0,1) hash step (xorshift32).
static float Hash01( ref uint s )
{
s ^= s << 13; s ^= s >> 17; s ^= s << 5;
return (s & 0xFFFFFFu) / 16777216f;
}
/// <summary>Synthesize every pitched event whose span overlaps <c>[from, to)</c>,
/// writing ONLY samples inside that window. Safe to call concurrently for disjoint
/// windows: each output index is owned by exactly one window, a boundary-spanning
/// note is re-rendered from its own start by each window (the SVF / high-pass state
/// can't be resumed mid-stream), and each window walks <c>_events</c> in order, so
/// writes never collide and the per-index sum order is deterministic.</summary>
public void RenderPitchedRange( int from, int to )
{
from = Math.Max( 0, from );
to = Math.Min( _bufL.Length, to );
if ( to <= from ) return;
var ph = new double[8];
var inc = new double[8];
var events = _events;
for ( int k = 0; k < events.Count; k++ )
{
var ev = events[k];
int end = Math.Min( _bufL.Length, ev.Start + ev.Dur );
if ( end <= from || ev.Start >= to ) continue; // no overlap with this window
RenderEvent( ev, from, to, ph, inc );
}
}
// One pitched note. Computes from the note's own start (the running filter / breath
// state can't be resumed mid-note) but writes only within [clipFrom, clipTo), and
// stops once past clipTo since later windows own those samples. ph/inc are caller-
// owned scratch (per-thread → no shared state).
void RenderEvent( in NoteEvent ev, int clipFrom, int clipTo, double[] ph, double[] inc )
{
int start = ev.Start, dur = ev.Dur;
float freq = ev.Freq;
var p = ev.P;
StereoGains( p.Pan, out float gL, out float gR );
int atk = Math.Max( 1, (int)(p.Attack * _sr) );
double decSamp = Math.Max( 1.0, p.Decay * _sr );
int rel = Math.Max( 1, (int)(0.006f * _sr) );
int voices = Math.Min( 8, p.Voices );
for ( int v = 0; v < voices; v++ )
{
ph[v] = p.PhaseSeed; // 0 for un-doubled notes → identical to the old in-phase start
float cents = voices == 1 ? 0f : (v - (voices - 1) * 0.5f) * p.Detune;
inc[v] = freq * Math.Pow( 2, cents / 1200.0 ) / _sr;
}
float low = 0, band = 0;
float reso = Math.Clamp( p.Reso, 0.2f, 2f );
float dnorm = p.Drive > 1f ? 1f / (float)Math.Tanh( p.Drive ) : 1f;
float hpA = p.Highpass > 0f ? (float)(1.0 / (1.0 + 2 * Math.PI * p.Highpass / _sr)) : 0f;
float hpInPrev = 0f, hpOutPrev = 0f;
uint bn = 0x9E3779B9u;
int end = Math.Min( Math.Min( _bufL.Length, start + dur ), clipTo );
int relStart = dur - rel;
// Expression windows (samples): vibrato holds off then ramps in; the scoop is a quick
// attack gesture. Kept fixed/absolute so a long held note locks on pitch after them.
int vibDelay = (int)(0.18f * _sr);
int vibRamp = Math.Max( 1, (int)(0.16f * _sr) );
int scoopWin = Math.Max( 1, (int)(0.16f * _sr) );
for ( int i = 0; start + i < end; i++ )
{
float env;
if ( i < atk ) env = (float)i / atk;
else if ( p.Sustained )
{
float d = (float)Math.Exp( -(i - atk) / decSamp );
env = p.Sustain + (1f - p.Sustain) * d;
}
else env = (float)Math.Exp( -(i - atk) / decSamp );
if ( i >= relStart ) env *= Math.Max( 0f, (float)(dur - i) / rel );
if ( env < 0.0006f && i > atk && !p.Sustained ) break;
float s = 0f;
// Vibrato: subtle, and DELAYED so the note locks on pitch first and only blooms a
// wobble if it's held — short notes stay dead-on. Depth is a small pitch fraction.
float vib = 1f;
if ( p.Vibrato > 0f && p.VibDepth > 0f )
{
float ramp = MathF.Max( 0f, (i - vibDelay) / (float)vibRamp );
if ( ramp > 1f ) ramp = 1f;
if ( ramp > 0f )
vib = (float)(1.0 + p.VibDepth * ramp * Math.Sin( i / (double)_sr * p.Vibrato * 2 * Math.PI ));
}
// Pitch-bend envelope (semitones) on top of vibrato. Both are QUICK gestures over a
// short fixed window so the note then sits locked on its target pitch (bendMul == 1):
// BendSemis snaps to 0 over BendTime seconds (bend-in / glide); ScoopSemis is a fast
// up-and-back hump confined to the attack (bend-and-release).
float bendSemis = 0f;
if ( p.BendSemis != 0f && p.BendTime > 0f )
{
int bt = Math.Min( dur, Math.Max( 1, (int)(p.BendTime * _sr) ) );
if ( i < bt ) { float u = i / (float)bt; bendSemis += p.BendSemis * (1f - u * u * (3f - 2f * u)); }
}
if ( p.ScoopSemis != 0f && i < scoopWin )
bendSemis += p.ScoopSemis * MathF.Sin( (float)(i / (float)Math.Min( dur, scoopWin ) * Math.PI) );
float bendMul = bendSemis != 0f ? (float)Math.Pow( 2.0, bendSemis / 12.0 ) : 1f;
for ( int v = 0; v < voices; v++ )
{
double dt = inc[v] * vib * bendMul;
s += BlepOsc( p.Osc, ph[v] - Math.Floor( ph[v] ), dt );
ph[v] += dt;
}
s /= voices;
if ( p.Breath > 0f )
{
bn = unchecked( bn * 1664525u + 1013904223u );
s += (bn / 4294967296f * 2f - 1f) * p.Breath;
}
if ( hpA > 0f )
{
float hp = hpA * (hpOutPrev + s - hpInPrev);
hpInPrev = s; hpOutPrev = hp; s = hp;
}
// resonant low-pass (Chamberlin SVF) with cutoff envelope.
// Clamp to ~sr/6 to keep the SVF stable.
float cut = p.Cutoff + (p.CutEnv > 0f ? p.CutEnv * (float)Math.Exp( -i / decSamp ) : 0f);
float f = (float)(2 * Math.Sin( Math.PI * Math.Min( cut, _sr * 0.16f ) / _sr ));
float high = s - low - reso * band;
band += f * high;
low += f * band;
float outp = low;
if ( p.Drive > 1f ) outp = (float)Math.Tanh( outp * p.Drive ) * dnorm;
float val = outp * env * p.Amp;
int idx = start + i;
if ( idx >= clipFrom )
{
_bufL[idx] += val * gL;
_bufR[idx] += val * gR;
}
}
}
// Band-limited oscillator. Naive saw/square step instantaneously at the phase wrap,
// and those discontinuities alias into harsh inharmonic tones — the core of the
// "8-/16-bit" buzz. PolyBLEP rounds each discontinuity over one sample so the harmonics
// fold back cleanly, for a warm analog edge instead. Sine is already band-limited;
// triangle's corners roll off as 1/n² so its aliasing is inaudible.
// p = phase in [0,1), dt = phase increment per sample (cycles/sample).
static float BlepOsc( int t, double p, double dt )
{
switch ( t )
{
case 0:
return MathF.Sin( (float)(p * 2 * Math.PI) );
case 1: // saw
return (float)(2 * p - 1) - PolyBlep( p, dt );
case 2: // square (50% duty = two opposed discontinuities)
{
float v = p < 0.5 ? 1f : -1f;
v += PolyBlep( p, dt );
double p2 = p + 0.5; if ( p2 >= 1.0 ) p2 -= 1.0;
return v - PolyBlep( p2, dt );
}
default: // triangle
return 4f * MathF.Abs( (float)p - 0.5f ) - 1f;
}
}
// PolyBLEP residual: the correction applied around a step discontinuity.
static float PolyBlep( double t, double dt )
{
if ( dt <= 0 ) return 0f;
if ( t < dt ) { t /= dt; return (float)(t + t - t * t - 1.0); }
if ( t > 1.0 - dt ) { t = (t - 1.0) / dt; return (float)(t * t + t + t + 1.0); }
return 0f;
}
static float Midi( int m ) => 440f * MathF.Pow( 2f, (m - 69) / 12f );
const float Sqrt2 = 1.41421356f;
// Fixed (not randomized) stereo spread for the kit's off-centre voices: 25% each way.
const float DrumPan = 0.25f;
static void StereoGains( float pan, out float gL, out float gR )
{
pan = Math.Clamp( pan, -1f, 1f );
double ang = (pan + 1) * 0.5 * (Math.PI / 2);
gL = (float)Math.Cos( ang ) * Sqrt2;
gR = (float)Math.Sin( ang ) * Sqrt2;
}
// ── Drums ──
// Render a bar of kit. On a section's last bar (fillEnd) the closing beat is replaced by a
// fill — driven by its own RNG streams so every section's fill is different even when the
// groove before it is identical.
void RenderDrumBar( int barStart, int spe, bool fillEnd, Rng noise, Rng fillRng, Rng fillNoise )
{
// Knob ceiling was too frantic: scale so DRUM BUSY 100% reads as the old 75%.
float busy = Math.Clamp( _c.DrumBusy, 0f, 1f ) * 0.75f;
int six = spe / 2;
int hatEnd = fillEnd ? 6 : EighthsPerBar; // hats stop where the fill begins
// closed hats on eighths (open on the "and of 4"); busy fills the gaps with
// quieter sixteenth-note hats (constant 16th chatter at the top of the range). On
// ride songs the ride cymbal carries the eighth pulse instead (bell on the beats), with
// the open hat still punctuating the "and of 4".
for ( int e = 0; e < hatEnd; e++ )
{
int at = Swung( barStart, spe, e );
// Pop pumps an open hat on every offbeat (the classic four-on-the-floor "ts-ts-ts");
// every other style opens only on the "and of 4".
bool open = e == 7 || (_drumStyle == 4 && e % 2 == 1);
float amp = e % 2 == 1 ? _c.HatVol : _c.HatVol * 0.6f;
if ( _ride && !open )
RenderRide( at, e % 2 == 0, amp, noise ); // bell accent on the downbeats
else
RenderHat( at, open, amp, noise );
if ( !open && six > 0 && noise.Chance( busy ) )
{
int sixAt = Swung( barStart, spe, e + 0.5 );
if ( _ride ) RenderRide( sixAt, false, _c.HatVol * 0.4f, noise );
else RenderHat( sixAt, false, _c.HatVol * 0.4f, noise );
}
}
if ( fillEnd )
{
RenderKickSnareGroove( barStart, spe, 0, 6, busy, noise ); // first 3 beats normal
RenderFill( barStart, spe, 6, fillNoise, fillRng );
return;
}
RenderKickSnareGroove( barStart, spe, 0, EighthsPerBar, busy, noise );
}
// Per-song kick accents for the straight backbeat: eighths (beyond the beat-1 & 3 anchors)
// the kick leans into. e1 = "and of 1", e3 = "and of 2", e5 = "and of 3", e6 = beat 4,
// e7 = "and of 4". One set is picked per song, then each accent is rolled per bar so the
// groove breathes instead of stamping the same kick pattern every bar — the main nuance
// lever that keeps rock/country from all sharing one mechanical backbeat.
static readonly int[][] BackbeatKickAccents =
{
new int[0], // bone-dry: just 1 & 3
new[] { 3 }, // push into the snare ("and of 2")
new[] { 7 }, // pickup into the next bar ("and of 4")
new[] { 3, 7 }, // push + pickup
new[] { 6 }, // driving beat-4 kick
new[] { 5, 7 }, // syncopated "and of 3" + pickup
new[] { 3, 6 }, // push into 3 + beat-4 drive
};
void RenderKickSnareGroove( int barStart, int spe, int from, int to, float busy, Rng noise )
{
int six = spe / 2;
for ( int e = from; e < to; e++ )
{
int at = Swung( barStart, spe, e );
int sixAt = six > 0 ? Swung( barStart, spe, e + 0.5 ) : at;
switch ( _drumStyle )
{
case 0: // one-drop: kick + snare together on beat 3
if ( e == 4 ) { RenderKick( at, noise ); RenderSnare( at, noise, false ); }
else if ( e == 2 && noise.Chance( _c.GhostSnareChance * (0.4f + busy) ) ) RenderSnare( at, noise, true );
break;
case 1: // steppers: kick every beat, snare on 2 & 4
if ( e % 2 == 0 ) RenderKick( at, noise );
if ( e == 2 || e == 6 ) RenderSnare( at, noise, false );
break;
case 3: // metal double-kick: 16th-note kick gallop + crashing, snare backbeat
if ( e == 0 && noise.Chance( 0.55f ) ) RenderCrash( at, noise, noise.Chance( 0.35f ) );
RenderKick( at, noise );
if ( six > 0 ) RenderKick( sixAt, noise ); // the second pedal → the 16th gallop
if ( e == 2 || e == 6 ) RenderSnare( at, noise, false );
break;
case 4: // pop four-on-the-floor: kick on every beat, backbeat snare/clap on 2 & 4
if ( e % 2 == 0 ) RenderKick( at, noise );
if ( e == 2 || e == 6 ) RenderSnare( at, noise, false );
else if ( noise.Chance( _c.GhostSnareChance * busy * 0.5f ) ) RenderSnare( at, noise, true );
break;
default: // straight backbeat — anchors on beats 1 & 3, plus this song's kick
// accents, each humanised per bar so the groove breathes
bool kick = e == 0 || e == 4;
if ( !kick && Array.IndexOf( _kickAccents, e ) >= 0 )
kick = noise.Chance( 0.82f ); // mostly play the accent, occasionally lay out
else if ( !kick && e == 3 )
kick = noise.Chance( _c.KickSyncChance * (0.4f + busy) ); // stray push into beat 3
if ( kick ) RenderKick( at, noise );
if ( e == 2 || e == 6 ) RenderSnare( at, noise, false );
else if ( noise.Chance( _c.GhostSnareChance * busy ) ) RenderSnare( at, noise, true );
break;
}
// Busy fills the "e/a" sixteenths between hits: more snare as busy rises, and —
// when the tone leans low — toms too. (Busy → snare + toms; tone → tom vs cymbal.)
// Metal already fills every 16th with the double-kick, so it skips the ghost layer.
if ( _drumStyle != 3 && six > 0 && e != 4 && noise.Chance( _c.GhostSnareChance * busy ) )
{
if ( noise.Chance( (1f - _drumTone) * 0.5f ) )
RenderTom( sixAt, 150f + 40f * (e & 1), noise );
else
RenderSnare( sixAt, noise, true );
}
}
}
// Tom/snare roll across the last beat (two eighths). Straight = four 16ths;
// triplet (TripletChance) = six even subdivisions for a rolling shuffle feel.
// The fill rolls across the last beat — the two eighths starting at baseE (6) up to the bar
// line — on the same swung grid as the rest of the band.
void RenderFill( int barStart, int spe, int baseE, Rng noise, Rng rng )
{
// straight = four 16ths; triplet = either an eighth-note triplet (3) or a
// faster 16th-note triplet (6) across the beat.
int n = rng.Chance( _c.TripletChance ) ? (rng.Chance( 0.5f ) ? 3 : 6) : 4;
int step = (spe * 2) / n;
float[] toms = { 260f, 215f, 175f, 145f, 120f, 100f };
// Half the hits stay snare so it still reads as a drum fill; the rest are biased by
// DrumTone — toms when the tone leans low, cymbals (ride hits) when it leans high.
for ( int i = 0; i < n; i++ )
{
int t = Swung( barStart, spe, baseE + i * 2.0 / n );
if ( rng.Chance( 0.5f ) ) RenderSnare( t, noise, false );
else if ( rng.Chance( _drumTone ) ) RenderRide( t, false, _c.HatVol, noise );
else RenderTom( t, toms[i], noise ); // pan derived from pitch inside RenderTom
}
// crash into the downbeat (lands on the bar line, an on-beat anchor → dead straight) — a
// bright crash or a darker, washier crash, picked off the fill stream so the cymbal colour
// varies section to section.
RenderCrash( Swung( barStart, spe, baseE + 2.0 ), noise, rng.Chance( 0.4f ) );
}
void RenderKick( int start, Rng noise )
{
start = Math.Max( 0, start + _drumPush );
int dur = (int)(_sr * 0.17f); // a little longer tail for thump (was 0.13)
double decay = dur * 0.31; // slightly slower decay = a touch more boom
double subDecay = dur * 0.55; // sub layer rings longer for weight
double phase = 0, subPhase = 0;
// noise.Next() only fires in the fixed 3ms click below, so changing dur/decay
// here does NOT shift the drum RNG stream (patterns are preserved).
int clickLen = (int)(_sr * 0.003f);
int end = Math.Min( _bufL.Length, start + dur );
for ( int i = 0; start + i < end; i++ )
{
float t = (float)i / dur;
phase += (127f - 80f * MathF.Min( 1f, t * 2.6f )) / _sr; // pitch drop 127→47
subPhase += 44f / _sr; // steady sub fundamental
float env = (float)Math.Exp( -i / decay );
float subEnv = (float)Math.Exp( -i / subDecay );
float body = (float)Math.Tanh( MathF.Sin( (float)(phase * 2 * Math.PI) ) * 1.6f ) * env;
float sub = MathF.Sin( (float)(subPhase * 2 * Math.PI) ) * 0.3f * subEnv;
float click = i < clickLen ? (noise.Next() * 2f - 1f) * 0.55f * (1f - i / (float)clickLen) : 0f;
float v = (body + sub + click) * _c.KickVol * _c.KickBalance * _drumGain * _drumLowMul;
_bufL[start + i] += v; _bufR[start + i] += v;
}
}
// One-pole high-pass coefficient (unconditionally stable).
float HpCoeff( float fc ) => (float)(1.0 / (1.0 + 2 * Math.PI * fc / _sr));
void RenderSnare( int start, Rng noise, bool ghost )
{
start = Math.Max( 0, start + _drumPush );
// dur and the single noise.Next()/sample are kept exactly so the drum RNG stream
// is unchanged — only the timbre (more shell body) is rerolled.
int dur = (int)(_sr * (ghost ? 0.06f : 0.15f));
double decay = dur * (ghost ? 0.3 : 0.32);
double phase = 0, phase2 = 0;
float amp = _c.SnareVol * _c.SnareBalance * (ghost ? 0.3f : 1f) * _drumGain;
float a = HpCoeff( 1350f ); // slightly crisper wire crack (was 1200)
float inPrev = 0f, outPrev = 0f;
int end = Math.Min( _bufL.Length, start + dur );
for ( int i = 0; start + i < end; i++ )
{
float t = (float)i / dur;
float env = (float)Math.Exp( -i / decay );
float drop = 1f - 0.14f * t; // shell pitch sags a touch → "dow"
phase += 185f * drop / _sr;
phase2 += 268f * drop / _sr;
float n = noise.Next() * 2f - 1f;
float hp = a * (outPrev + n - inPrev); inPrev = n; outPrev = hp;
// two-tone shell body, a bit fuller than before, vs the wire layer
float body = (MathF.Sin( (float)(phase * 2 * Math.PI) ) + MathF.Sin( (float)(phase2 * 2 * Math.PI) ) * 0.6f) * 0.375f;
float v = ((float)Math.Tanh( hp * 1.2f ) * 0.6f + body) * env * amp;
_bufL[start + i] += v; _bufR[start + i] += v;
}
}
void RenderTom( int start, float baseFreq, Rng noise )
{
start = Math.Max( 0, start + _drumPush );
// Pan by PITCH so a given tom always sits in the same spot (rack/high left → floor/low
// right). Mapped across the commonly-played fill range (~145 Hz floor .. 260 Hz rack) so a
// normal fill sweeps the full field and the low toms read clearly to the right; anything
// below the range clamps hard right. Scaled by the STEREO WIDTH slider via _drumPan.
float u = Math.Clamp( (baseFreq - 145f) / (260f - 145f), 0f, 1f ); // 0 = low/floor, 1 = high/rack
StereoGains( -_drumPan * (u * 2f - 1f), out float gL, out float gR );
int dur = (int)(_sr * 0.18f);
double decay = dur * 0.3;
double attackDecay = dur * 0.06; // fast-decaying upper partial → beater "snap"
double phase = 0, phase2 = 0;
// deterministic per-hit stick attack — a local LFSR so the shared drum RNG stream
// (and therefore every other pattern) is left byte-identical.
uint ns = (uint)start * 2654435761u | 1u;
int clickLen = (int)(_sr * 0.006f);
int end = Math.Min( _bufL.Length, start + dur );
for ( int i = 0; start + i < end; i++ )
{
float t = (float)i / dur;
float pf = baseFreq * (1f - 0.22f * t); // less pitch sag → holds its note, more bite
phase += pf / _sr;
phase2 += pf * 2.5f / _sr; // inharmonic upper partial for attack snap
float env = (float)Math.Exp( -i / decay );
float aenv = (float)Math.Exp( -i / attackDecay );
float body = MathF.Sin( (float)(phase * 2 * Math.PI) ) * env;
float snap = MathF.Sin( (float)(phase2 * 2 * Math.PI) ) * aenv * 0.5f;
float click = 0f;
if ( i < clickLen )
{
ns ^= ns << 13; ns ^= ns >> 17; ns ^= ns << 5;
click = ((ns & 0xffff) / 32768f - 1f) * 0.45f * (1f - i / (float)clickLen);
}
float v = (body + snap + click) * _c.TomVol * _c.TomBalance * _drumGain * _drumLowMul;
_bufL[start + i] += v * gL; _bufR[start + i] += v * gR;
}
}
void RenderHat( int start, bool open, float amp, Rng noise )
{
start = Math.Max( 0, start + _drumPush );
// Closed/open hats live on the left of the kit (the hi-hat stand); ride sits opposite.
StereoGains( -_drumPan, out float gL, out float gR );
int dur = (int)(_sr * (open ? 0.16f : 0.035f));
double decay = dur * 0.4;
float a = HpCoeff( 7000f );
float inPrev = 0f, outPrev = 0f;
int end = Math.Min( _bufL.Length, start + dur );
for ( int i = 0; start + i < end; i++ )
{
float env = (float)Math.Exp( -i / decay );
float n = noise.Next() * 2f - 1f;
float hp = a * (outPrev + n - inPrev); inPrev = n; outPrev = hp;
float v = hp * env * amp * _c.HatBalance * _drumGain * _drumHighMul;
_bufL[start + i] += v * gL; _bufR[start + i] += v * gR;
}
}
// Two crash colours off one voice: the bright crash (short-ish, high-passed high) and a
// dark crash — lower cutoff, longer wash, a touch quieter — for a bigger china/ride-crash.
// The kit has two crashes panned apart (±25%); the bright/dark colour picks which physical
// cymbal, and _crashBrightLeft (per song) decides which side that is.
void RenderCrash( int start, Rng noise, bool dark = false )
{
start = Math.Max( 0, start + _drumPush );
StereoGains( (dark == _crashBrightLeft ? _drumPan : -_drumPan), out float gL, out float gR );
int dur = (int)(_sr * (dark ? 0.9f : 0.6f));
double decay = dur * (dark ? 0.5 : 0.45);
float a = HpCoeff( dark ? 2600f : 4000f );
float amp = _c.CrashVol * _c.CrashBalance * (dark ? 0.85f : 1f);
float inPrev = 0f, outPrev = 0f;
int end = Math.Min( _bufL.Length, start + dur );
for ( int i = 0; start + i < end; i++ )
{
float env = (float)Math.Exp( -i / decay );
float n = noise.Next() * 2f - 1f;
float hp = a * (outPrev + n - inPrev); inPrev = n; outPrev = hp;
float v = hp * env * amp * _drumGain * _drumHighMul;
_bufL[start + i] += v * gL; _bufR[start + i] += v * gR;
}
}
// Ride cymbal — a sustained, high-passed noise wash, nothing more. A cymbal is mostly
// filtered noise; we deliberately render *only* that body and no pitched component. Earlier
// versions layered inharmonic sine partials for a metallic "ping"/bell, but any tonal layer
// (however quiet or detuned) read as a pitched ring/ding, so it's gone entirely. The bell
// hit (on the beat) just rings a touch longer than the bow hit (on the "and"). Tracks off
// HatVol via the caller's amp so the existing DRUMS knobs still balance it.
void RenderRide( int start, bool bell, float amp, Rng noise )
{
start = Math.Max( 0, start + _drumPush );
// Ride sits opposite the hats, on the right of the kit.
StereoGains( _drumPan, out float gL, out float gR );
int dur = (int)(_sr * (bell ? 0.34f : 0.22f));
double decay = dur * 0.42;
float a = HpCoeff( 7000f );
float inPrev = 0f, outPrev = 0f;
int end = Math.Min( _bufL.Length, start + dur );
for ( int i = 0; start + i < end; i++ )
{
float env = (float)Math.Exp( -i / decay );
float n = noise.Next() * 2f - 1f;
float hp = a * (outPrev + n - inPrev); inPrev = n; outPrev = hp;
float v = hp * 0.7f * env * amp * _c.HatBalance * _drumGain * _drumHighMul;
_bufL[start + i] += v * gL; _bufR[start + i] += v * gR;
}
}
// ── Output ──
short[] ToShorts( float gain )
{
int n = _bufL.Length;
var s = new short[n * Channels];
for ( int i = 0; i < n; i++ )
{
s[i * 2] = ToS16( _bufL[i] * gain );
s[i * 2 + 1] = ToS16( _bufR[i] * gain );
}
return s;
}
static short ToS16( float v ) => (short)(Math.Clamp( v, -1f, 1f ) * 32767f);
/// <summary>Wrap already-rendered 16-bit samples in a WAV (for export). Mono or
/// interleaved stereo per <paramref name="channels"/>.</summary>
public static byte[] WavFromSamples( short[] samples, int channels, int sampleRate )
{
int dataSize = samples.Length * 2;
int blockAlign = channels * 2;
var bytes = new System.Collections.Generic.List<byte>( 44 + dataSize );
void Str( string s ) { foreach ( var ch in s ) bytes.Add( (byte)ch ); }
void U32( uint v ) { bytes.Add( (byte)v ); bytes.Add( (byte)(v >> 8) ); bytes.Add( (byte)(v >> 16) ); bytes.Add( (byte)(v >> 24) ); }
void U16( ushort v ) { bytes.Add( (byte)v ); bytes.Add( (byte)(v >> 8) ); }
Str( "RIFF" ); U32( (uint)(36 + dataSize) ); Str( "WAVE" );
Str( "fmt " ); U32( 16 ); U16( 1 ); U16( (ushort)channels );
U32( (uint)sampleRate ); U32( (uint)(sampleRate * blockAlign) ); U16( (ushort)blockAlign ); U16( 16 );
Str( "data" ); U32( (uint)dataSize );
foreach ( var s in samples ) { ushort u = (ushort)s; bytes.Add( (byte)u ); bytes.Add( (byte)(u >> 8) ); }
return bytes.ToArray();
}
byte[] EncodeWav( float gain )
{
int n = _bufL.Length;
int dataSize = n * 4;
var bytes = new System.Collections.Generic.List<byte>( 44 + dataSize );
void Str( string s ) { foreach ( var ch in s ) bytes.Add( (byte)ch ); }
void U32( uint v ) { bytes.Add( (byte)v ); bytes.Add( (byte)(v >> 8) ); bytes.Add( (byte)(v >> 16) ); bytes.Add( (byte)(v >> 24) ); }
void U16( ushort v ) { bytes.Add( (byte)v ); bytes.Add( (byte)(v >> 8) ); }
Str( "RIFF" ); U32( (uint)(36 + dataSize) ); Str( "WAVE" );
Str( "fmt " ); U32( 16 ); U16( 1 ); U16( 2 );
U32( (uint)_sr ); U32( (uint)(_sr * 4) ); U16( 4 ); U16( 16 );
Str( "data" ); U32( (uint)dataSize );
for ( int i = 0; i < n; i++ )
{
ushort l = (ushort)ToS16( _bufL[i] * gain );
ushort r = (ushort)ToS16( _bufR[i] * gain );
bytes.Add( (byte)l ); bytes.Add( (byte)(l >> 8) );
bytes.Add( (byte)r ); bytes.Add( (byte)(r >> 8) );
}
return bytes.ToArray();
}
}
Game
game
@using System
@using System.Collections.Generic
@using System.Linq
@using System.Threading.Tasks
@using Sandbox
@using Sandbox.UI
@using Splitclicker.Api
@using Splitclicker.Game
@using Skafinity
@namespace Splitclicker.UI
@inherits PanelComponent
@*
The whole HUD as a SINGLE root PanelComponent. This matters: a ScreenPanel lays
its child PanelComponent roots out in a flex row and ignores `position` set on
those roots, so sibling panels can't pin/center themselves. The working pattern
(see rotaliate's GameHud) is one root with `width/height: 100%` that genuinely
fills the screen, with every piece an absolutely-positioned CHILD of it:
• session board — pinned top-left
• a bounding box inset between the left/right boards — holds the status HUD
(centred) and the global button, which is small and JUMPS to a random spot
inside the box each time the round arms (whack-a-mole); "clicks sent" sits
pinned to the box bottom
• hourly board — pinned top-right, just left of hours-won
• hours-won board — pinned hard to the top-right edge
*@
<root>
@{ var c = ClickController.Instance; }
@* ── left column: the session board stacked above the all-time board, flowed in
a flex column so they can never overlap however many rows each grows to
(both are capped at DisplayLimit). Pinned top-left. ── *@
<div class="left-col">
@* ── session board: current game's cumulative standings ── *@
<div class="board session">
<div class="title">SESSION · TOP CLICKERS</div>
<div class="divider"></div>
@if ( SessionEntries.Count == 0 )
{
<div class="empty">No scores yet</div>
}
else
{
@for ( int i = 0; i < SessionEntries.Count && i < DisplayLimit; i++ )
{
var s = SessionEntries[i];
<div class="row @(IsMe( s ) ? "me" : "")">
<div class="rank">@(i + 1)</div>
<div class="dot @StatusClass( s )"></div>
<div class="name clickable" onclick="@(() => CopyProfile( s ))">@DisplayName( s )</div>
@if ( s.BehindMs > 0 )
{
<div class="behind" title="lost the game by @(s.BehindMs)ms">+@(s.BehindMs)ms</div>
}
<div class="pts">@s.Points</div>
</div>
}
}
</div>
@* ── all-time clickers board: lifetime total scoring clicks across every skin
(never resets) ── *@
<div class="board alltime">
<div class="title">ALL-TIME · TOP CLICKERS</div>
<div class="divider"></div>
@if ( _allTime.Count == 0 )
{
<div class="empty">@(_allTimeLoaded ? "No clicks recorded yet" : "loading…")</div>
}
else
{
@for ( int i = 0; i < _allTime.Count; i++ )
{
var s = _allTime[i];
<div class="row @(IsMe( s ) ? "me" : "")">
<div class="rank">@(i + 1)</div>
<div class="dot @StatusClass( s )"></div>
<div class="name clickable" onclick="@(() => CopyProfile( s ))">@DisplayName( s )</div>
<div class="pts">@s.Points</div>
</div>
}
}
</div>
</div>
@* ── GAME INFO opener: a wide labelled bar pinned bottom-left, mirroring the
MUSIC bar that sits beneath it. Toggles the floating "How to Play" popup;
the open/closed choice is persisted to local storage (PlayerData) so it
survives across sessions. The popup itself is rendered near the end of the
root so it overlays everything. ── *@
@* ── PAUSE control: lets a player step away without being flagged AFK. Pinned
bottom-left ABOVE the GAME INFO bar. Green "PAUSE" at rest; red "PAUSED" while
away. Pressing it parks the player (the server withholds frames + drops them
from the round's N); pressing again rejoins on the next button. Also engaged
automatically when the server auto-parks an idle player. Hidden until connected. ── *@
@if ( C != null && C.Phase != GamePhase.Connecting && C.Phase != GamePhase.Disconnected )
{
<div class="pause-fab @PauseFabClass()" onclick="@TogglePause">@PauseFabLabel()</div>
}
<div class="info-fab" onclick="@ToggleHowTo">GAME INFO</div>
@* ── the music opener is the Skafinity library's own toggle button: it lives on the
MusicUI ScreenPanel and is styled (ShowFab/WideFab/FabLabel in the scene) into the
wide "MUSIC" bar pinned bottom-left under the GAME INFO bar, opening/closing its
own board. This HUD only reads MusicOpen (the panel's IsOpen) to stop painting the
game while the board is up. ── *@
@* ── the live board + status HUD over it ── *@
@* While the music board is open (MusicOpen) the board + status + "clicks sent" stop
drawing — the panel overlays from its own ScreenPanel, and the connection stays open
(we just don't paint the game underneath it). *@
<div class="center" @ref="_box">
@if ( !MusicOpen )
{
@* The scoring surface: one clickable button per live board slot, at its
server-placed position (board-% space, same as the pips). The board is the
only surface and exists only while armed — NOTHING during the arming wait or
for stray bad clicks. *@
@if ( c != null && c.HasBoard )
{
@foreach ( var btn in c.LiveButtons )
{
<div class="board-btn" style="left: @(PipPct( btn.X ))%; top: @(PipPct( btn.Y ))%;"
onclick="@(() => OnPressSlot( btn.Slot ))"></div>
}
}
@* Opponent cursors: a labelled amber dot per sampled opponent pointer. *@
@foreach ( var cur in CursorsToShow() )
{
<div class="opp-cursor" style="left: @(PipPct( cur.X ))%; top: @(PipPct( cur.Y ))%;">
<div class="opp-cursor-name">@cur.Name</div>
</div>
}
@* Opponent click pips: each a half-size button that fades quickly (CSS
keyframe) at the clicker's normalized position with their name over it.
Positioned in the same box-% space as the global button, so a pip lands
where that player's button was when they clicked. *@
@foreach ( var pip in PipsToShow() )
{
<div class="pip" style="left: @(PipPct( pip.X ))%; top: @(PipPct( pip.Y ))%;">
<div class="pip-name">@pip.Name</div>
</div>
}
@* Each line below is a SINGLE interpolated expression on purpose: mixing
literal text with razor expressions in one element emits several text nodes,
which s&box's panel reconciler reorders across rebuilds (the "arms in 2-6s"
-> "- arms in 26s" / vanishing-penalty bug). One expression = one stable node. *@
<div class="hud">
<div class="meta">
<div class="players">@($"{c?.Players ?? 0} online")</div>
<div class="sep">·</div>
<div class="clicks">@($"{c?.ClicksToWin ?? 0} clicks exist for this round")</div>
</div>
<div class="round">@($"Round {c?.Round ?? 0} / {c?.Of ?? 0}")</div>
<div class="state @PhaseClass()">@PhaseText()</div>
@* live clicks-remaining counter: descends from N as the race fills,
driven by the server tick (clamped at 0). Only while the window is open. *@
@if ( c?.Phase == GamePhase.Armed )
{
<div class="remaining">@($"{RemainingShown( c )} left")</div>
}
@if ( c?.Phase == GamePhase.Pending && (c?.ArmMaxSec ?? 0) > 0 )
{
<div class="arm">@($"arms in {c.ArmMinSec}–{c.ArmMaxSec}s")</div>
}
@* dev note: a host-pushed broadcast message, orange. Independent of round
phase; shows until the server sends an empty note. *@
@if ( c != null && !string.IsNullOrEmpty( c.DevNote ) )
{
<div class="dev-note">@c.DevNote</div>
}
</div>
<div class="clicks-sent">@($"clicks sent: {c?.ClicksSent ?? 0}")</div>
}
</div>
@* ── top-clickers board: points scored during the current bounty window
(resets when the skin is won), pinned right (inner) ── *@
<div class="board hourly">
<div class="title">TOP CLICKERS · THIS BOUNTY</div>
<div class="reset">resets when the bounty is won</div>
<div class="divider"></div>
@if ( !_hasBounty )
{
<div class="empty">No active bounty</div>
}
else if ( _hourly.Count == 0 )
{
<div class="empty">@(_hourlyLoaded ? "No clicks for this bounty yet" : "loading…")</div>
}
else
{
@for ( int i = 0; i < _hourly.Count; i++ )
{
var s = _hourly[i];
<div class="row @(IsMe( s ) ? "me" : "")">
<div class="rank">@(i + 1)</div>
<div class="dot @StatusClass( s )"></div>
<div class="name clickable" onclick="@(() => CopyProfile( s ))">@DisplayName( s )</div>
<div class="pts">@s.Points</div>
</div>
}
}
</div>
@* ── hours-won board: UTC clock-hours topped during the current bounty window
(resets when the skin is won), pinned right (outer) ── *@
<div class="board hourswon">
<div class="title">HOURS WON · THIS BOUNTY</div>
<div class="divider"></div>
@if ( !_hasBounty )
{
<div class="empty">No active bounty</div>
}
else if ( _hoursWon.Count == 0 )
{
<div class="empty">@(_hoursWonLoaded ? "No hours won for this bounty yet" : "loading…")</div>
}
else
{
@for ( int i = 0; i < _hoursWon.Count; i++ )
{
var s = _hoursWon[i];
<div class="row @(IsMe( s ) ? "me" : "")">
<div class="rank">@(i + 1)</div>
<div class="dot @StatusClass( s )"></div>
<div class="name clickable" onclick="@(() => CopyProfile( s ))">@DisplayName( s )</div>
<div class="pts">@s.Points</div>
</div>
}
}
</div>
@* ── games-won board: games topped during the current bounty window — this is
the board the bounty winner is read from (resets when the skin is won),
pinned bottom-right ── *@
<div class="board sessionswon">
<div class="title">GAMES WON · THIS BOUNTY</div>
<div class="divider"></div>
@if ( !_hasBounty )
{
<div class="empty">No active bounty</div>
}
else if ( _sessionsWon.Count == 0 )
{
<div class="empty">@(_sessionsWonLoaded ? "No games won for this bounty yet" : "loading…")</div>
}
else
{
@for ( int i = 0; i < _sessionsWon.Count; i++ )
{
var s = _sessionsWon[i];
<div class="row @(IsMe( s ) ? "me" : "")">
<div class="rank">@(i + 1)</div>
<div class="dot @StatusClass( s )"></div>
<div class="name clickable" onclick="@(() => CopyProfile( s ))">@DisplayName( s )</div>
<div class="pts">@s.Points</div>
</div>
}
}
</div>
@* ── leader's "skin": image served by the backend (selectable server-side),
pinned just left of the sessions-won board, captioned with the current
sessions-won leader's name. Above it, a countdown to when the winner is
locked in; once that passes, a prompt for Gamah to set a new skin. ── *@
<div class="skin-panel">
@if ( !_hasBounty )
{
@* No bounty is live: the config skin/countdown are a stale fallback, so show
nothing of it — just say there's no skin to win right now. *@
<div class="no-bounty">
<div class="no-bounty-title">No active bounty</div>
<div class="no-bounty-sub">Gamah will set a new bounty to win soon — check back shortly</div>
</div>
}
else
{
@if ( HasWinnerLock )
{
@if ( WinnerLockPassed() )
{
<div class="skin-locked">Gamah will set a new bounty to win when he sees this</div>
}
else
{
<div class="skin-countdown">
<div class="cd-label">Time until winner is locked in</div>
<div class="cd-time">@WinnerCountdown()</div>
</div>
}
}
<div class="skin-img" style="background-image: url( '@SkinImgUrl()' );"></div>
@if ( _skin != null && _skin.DecodeOk )
{
@if ( _skin.ImageOk )
{
<div class="skin-name">@_skin.Name</div>
}
<div class="skin-stats">
<div class="stat"><span class="k">Wear</span><span class="v">@_skin.WearName</span></div>
<div class="stat"><span class="k">Float</span><span class="v">@FloatStr()</span></div>
<div class="stat"><span class="k">Seed</span><span class="v">@_skin.PaintSeed</span></div>
</div>
<div class="wear-bar">
<div class="seg fn"></div>
<div class="seg mw"></div>
<div class="seg ft"></div>
<div class="seg ww"></div>
<div class="seg bs"></div>
<div class="wear-marker" style="left: @(MarkerPct())%;"></div>
</div>
@if ( !_skin.ImageOk )
{
<div class="skin-note">unable to fetch bounty from steam</div>
}
}
else if ( _skin != null )
{
<div class="skin-note">unable to fetch bounty from steam</div>
}
@if ( !string.IsNullOrWhiteSpace( _inspectLink ) )
{
<div class="skin-copy clickable" onclick="@CopyInspect">Copy inspect link</div>
}
<div class="skin-caption">@($"{SkinLeaderName()}'s Bounty")</div>
}
</div>
@* ── previous winner: a duplicate of the skin panel showing who won the last
bounty and the skin it was (decoded from its inspect link, same as the live
one). Shown once a bounty has settled; refreshed on a bounty_update push /
reconnect. The panel is pointer-events:none so it can't intercept a click
meant for the roaming button beneath it; only the winner line re-enables
clicks, to copy their Steam profile link. ── *@
@if ( _prevBounty != null )
{
<div class="skin-panel previous">
<div class="prev-header">PREVIOUS WINNER</div>
@* The winner is the panel's identity — bold and (when we have a steamid)
clickable to copy their profile link, the only interactive bit (the rest
of the panel is pointer-events:none so it can't steal a button click). *@
@if ( !string.IsNullOrEmpty( _prevBounty.WinnerSteamId ) )
{
<div class="prev-winner clickable" onclick="@CopyPrevWinner">@PrevWinnerName()</div>
}
else
{
<div class="prev-winner">@PrevWinnerName()</div>
}
<div class="prev-wins">@($"won {_prevBounty.WinnerWins} games")</div>
<div class="skin-img" style="background-image: url( '@PrevSkinImgUrl()' );"></div>
@if ( _prevSkin != null && _prevSkin.DecodeOk )
{
@if ( _prevSkin.ImageOk )
{
<div class="skin-name">@_prevSkin.Name</div>
}
<div class="skin-stats">
<div class="stat"><span class="k">Wear</span><span class="v">@_prevSkin.WearName</span></div>
<div class="stat"><span class="k">Float</span><span class="v">@PrevFloatStr()</span></div>
<div class="stat"><span class="k">Seed</span><span class="v">@_prevSkin.PaintSeed</span></div>
</div>
<div class="wear-bar">
<div class="seg fn"></div>
<div class="seg mw"></div>
<div class="seg ft"></div>
<div class="seg ww"></div>
<div class="seg bs"></div>
<div class="wear-marker" style="left: @(PrevMarkerPct())%;"></div>
</div>
}
else if ( !string.IsNullOrEmpty( _prevBounty.Label ) )
{
@* Only as a skin-name fallback when the link didn't decode — never as the
headline (the winner is the headline). *@
<div class="skin-caption">@_prevBounty.Label</div>
}
</div>
}
@* ── per-round scores flash: a transient panel listing who scored the round that
just ended (the round's winners, in first-click order, with the points each
grabbed THIS round), shown briefly after every round_result then fading out.
Non-interactive (pointer-events:none) so it never eats a click. Each text line
is a single interpolated expression (the mixed-text-node reorder bug). ── *@
@if ( FlashVisible )
{
<div class="round-flash">
<div class="rf-panel">
<div class="rf-title">@($"ROUND {_flashRound} SCORES")</div>
<div class="divider"></div>
@if ( _flashWinners.Count == 0 )
{
<div class="rf-empty">Nobody scored this round</div>
}
else
{
@for ( int i = 0; i < _flashWinners.Count && i < DisplayLimit; i++ )
{
var w = _flashWinners[i];
<div class="rf-row @(IsMe( w ) ? "me" : "")">
<div class="rf-rank">@(i + 1)</div>
<div class="rf-name">@DisplayName( w )</div>
<div class="rf-pts">@($"+{w.Points}")</div>
</div>
}
}
</div>
</div>
}
@* ── transient "copied profile link" toast, top-center for ~a second ── *@
@if ( ToastVisible )
{
<div class="toast">@_toast</div>
}
@* ── "How to Play" popup: a floating card centred over the screen (like the music
player), shown while _howtoOpen. A dim full-screen backdrop captures input so the
game pauses behind it; the ✕ (or the GAME INFO bar) closes it. Two sections: the
round-state primer and a rundown of the server-side anticheat checks (see
../server/internal/game). Every text line is a single literal or one interpolated
expression (the mixed-text-node reorder bug). ── *@
@if ( _howtoOpen )
{
<div class="howto-overlay">
<div class="howto-card">
<div class="howto-head">
<div class="howto-title">HOW TO PLAY</div>
<div class="howto-close" onclick="@ToggleHowTo">✕</div>
</div>
<div class="divider"></div>
<div class="line">One global button, limited clicks, shared by everyone.</div>
<div class="state-row"><span class="tag wait">WAIT…</span><span class="desc">Button is arming - DO NOT CLICK just because it's green</span></div>
<div class="state-row"><span class="tag go">CLICK!</span><span class="desc">Button is live! One point per click.</span></div>
<div class="state-row"><span class="tag over">ROUND OVER</span><span class="desc">All clicks used up, current score shown.</span></div>
<div class="state-row"><span class="tag over">GAME OVER</span><span class="desc">Session winner picked, new game next!</span></div>
<div class="line penalty-note">Clicking before the button is live is allowed but scores nothing — just wait for it to go green.</div>
<div class="divider"></div>
<div class="howto-subtitle">STEPPING AWAY</div>
<div class="state-row"><span class="tag wait">PAUSE</span><span class="desc">Need to step away? Hit PAUSE (above GAME INFO). You drop out of the round and the leaderboard race and won't be flagged AFK. Hit it again to rejoin on the next button.</span></div>
<div class="line penalty-note">Sit still and idle through a round and the server pauses you automatically — just hit PAUSE to come back when you're ready.</div>
<div class="divider"></div>
<div class="howto-subtitle">ANTI-CHEAT</div>
<div class="line">Every round, the server watches for tell-tale bot behaviour. The first flags just bench you behind a quick math question before the button arms again.</div>
<div class="state-row"><span class="tag over">TOO FAST</span><span class="desc">Two of your scoring clicks landed closer together than any human hand can manage.</span></div>
<div class="state-row"><span class="tag over">TOO MANY</span><span class="desc">In a busy round you took far more than your fair share of the clicks (solo rounds are exempt — there's no one to share with).</span></div>
<div class="state-row"><span class="tag over">SOLO FARM</span><span class="desc">You're miles ahead on the bounty with nobody around to race. Nothing's wrong, just check back when it's busier, or bring some friends so it counts.</span></div>
<div class="state-row"><span class="tag over">RUNAWAY</span><span class="desc">You out-clicked a runner-up who was genuinely competing by an impossible margin (beating an idle player is fine).</span></div>
<div class="state-row"><span class="tag over">AFK</span><span class="desc">Don't sit still during the wait — move your cursor while the button is arming.</span></div>
<div class="state-row"><span class="tag over">BUSTED</span><span class="desc">You scored without ever moving your cursor.</span></div>
<div class="line penalty-note">Keep getting flagged in one bounty and it escalates: too many flags puts you on a timed cooldown, and a few more after that sidelines you until the bounty is won. Play fair and none of this ever fires — these only catch automation.</div>
<div class="divider"></div>
@* Single interpolated string (mixed literal + @() text nodes reorder on
rebuild — see CLAUDE.md). Click copies the invite; toast confirms. *@
<div class="discord-line clickable" onclick="@CopyDiscord">@($"Think a flag was wrong, or want a heads-up on upcoming changes? Click here to copy our Discord invite ({DiscordInvite}) and come say hi.")</div>
</div>
</div>
}
@* ── parked / away gate: shown while the player has stepped away — either they hit
PAUSE or the server auto-parked them off an afk_idle verdict. A dim overlay
captures input so nothing underneath registers; the server withholds every game
frame until they rejoin, so there's nothing to play here. The PAUSE bar (which
stays visible above GAME INFO) is the single place to rejoin. ── *@
@* Suppressed while a panel is open: an open how-to/music panel is itself the "away"
surface, and the park-overlay (z:190) would otherwise stack its RESUME card on top of
the popup (z:160). Closing the panel auto-rejoins a panel-park, so the overlay only
ever shows for a real PAUSE / server afk-park with no panel up. ── *@
@if ( C?.Parked == true && !_howtoOpen && !MusicOpen )
{
<div class="park-overlay">
<div class="park-card">
<div class="park-title">PAUSED — AWAY</div>
<div class="park-sub">You've stepped away, so you're out of the round and off the leaderboard race — no AFK flags while you're paused.</div>
@* While a RESUME is in flight the server defers the rejoin to the next arming
boundary, so we stay on this surface showing "rejoining next round" until the
next round_pending drops us to STAND BY. Otherwise show the RESUME button —
a child of the overlay card (painted above the backdrop) so it's reliably
clickable, unlike the bottom-left PAUSE bar, which s&box hit-tests under this
full-screen overlay regardless of z-index. ── *@
@if ( C.Resuming )
{
<div class="park-hint">Rejoining — you'll be back on the next button…</div>
}
else
{
<div class="park-hint">Press RESUME to rejoin — you'll be back in on the next button.</div>
<div class="park-resume" onclick="@TogglePause">RESUME</div>
}
</div>
</div>
}
@* ── anticheat test gate: shown when the player failed an end-of-round check and
is benched. A full-screen dim overlay (captures input) with the question and
an answer box; answering correctly un-benches them and the server arms them
again. Every text line is a single literal or one interpolated expression
(the mixed-text-node reorder bug). ── *@
@if ( C?.HasTest == true )
{
<div class="test-overlay">
<div class="test-card">
<div class="test-title">Gamah Anti-Cheat v0.67.69.420</div>
<div class="test-sub">You probably know what you did so do some math now instead...</div>
@if ( !string.IsNullOrEmpty( C.TestMessage ) )
{
<div class="test-reason">@C.TestMessage</div>
}
<div class="test-prompt">@($"{C.TestPrompt} = ?")</div>
<TextEntry @ref="_testEntry" placeholder="answer" class="test-input" maxlength="12" />
<div class="test-submit" onclick="@SubmitTest">SUBMIT</div>
</div>
</div>
}
@* ── anticheat sanction gate: shown when the player is past the test rung — on a
timed cooldown or ignored for the rest of the bounty. Same dim overlay, but no
test to answer: just the reason and a countdown to when the state lifts (reuses
the bounty-timer HH:MM:SS format). Each line is a single interpolated
expression (the mixed-text-node reorder bug). ── *@
@if ( !string.IsNullOrEmpty( C?.SanctionState ) )
{
<div class="test-overlay">
<div class="test-card sanction">
<div class="test-title">Gamah Anti-Cheat v0.67.69.420</div>
<div class="test-reason">@(C.SanctionMessage)</div>
<div class="sanction-label">@(C.SanctionState == "ignored" ? "Back in the game when the bounty is won" : "Cooldown ends in")</div>
<div class="sanction-time">@SanctionCountdown()</div>
</div>
</div>
}
</root>
<style>
/* The root genuinely fills the screen, so every absolutely-positioned child
below anchors against the full screen rect. pointer-events:none here; only
the button re-enables them. */
root {
width: 100%;
height: 100%;
pointer-events: none;
}
/* ── boards: shared look; each pinned via its own position class ── */
.board {
position: absolute;
top: 18px;
width: 280px;
flex-direction: column;
background-color: rgba(3, 13, 7, 0.85);
border: 1.5px solid rgba(255, 255, 255, 0.12);
border-radius: 10px;
padding: 12px 14px;
gap: 3px;
}
/* left column: session board stacked above the all-time board, pinned top-left.
The two boards flow in this flex column (their own absolute pin is cancelled
below) so they grow downward independently and can never overlap each other. */
.left-col {
position: absolute;
top: 18px;
left: 14px;
flex-direction: column;
gap: 14px;
}
.left-col .board { position: relative; top: auto; left: auto; right: auto; }
/* GAME INFO opener: a wide labelled bar pinned bottom-left (top:auto cancels the
shared pins), lifted clear of the bottom edge so the MUSIC bar sits beneath it.
Mirrors the music FAB's wide-bar geometry/colours so the two read as a pair.
Re-enables pointer events against the pointer-events:none root. */
.info-fab {
position: absolute;
left: 14px;
bottom: 66px;
width: 300px;
height: 44px;
justify-content: center;
align-items: center;
font-size: 15px;
font-weight: bold;
letter-spacing: 1px;
color: rgba(255, 255, 255, 0.85);
background-color: rgba(3, 13, 7, 0.85);
border: 1.5px solid rgba(255, 255, 255, 0.12);
border-radius: 8px;
cursor: pointer;
pointer-events: all;
z-index: 150;
}
.info-fab:hover {
color: #ffffff;
border-color: rgba(6, 214, 160, 0.6);
}
/* PAUSE control: same wide-bar geometry as the GAME INFO / MUSIC bars, pinned one
slot ABOVE the GAME INFO bar. Green "PAUSE" at rest; red "PAUSED" while away. */
.pause-fab {
position: absolute;
left: 14px;
bottom: 120px;
width: 300px;
height: 44px;
justify-content: center;
align-items: center;
font-size: 15px;
font-weight: bold;
letter-spacing: 1px;
color: #04130b;
background-color: rgba(6, 214, 160, 0.9);
border: 1.5px solid rgba(6, 214, 160, 0.55);
border-radius: 8px;
cursor: pointer;
pointer-events: all;
/* The PARK direction (not parked → away) happens here, with no overlay up. While
PARKED the full-screen overlay sits over this bar (s&box hit-tests the overlay
on top regardless of z-index), so rejoin is the overlay's RESUME button, not this
bar — the bar then just reads "PAUSED" as a status. */
z-index: 150;
}
.pause-fab:hover { background-color: rgba(6, 214, 160, 1); }
.pause-fab.paused {
color: #ffffff;
background-color: rgba(214, 40, 40, 0.9);
border-color: rgba(214, 40, 40, 0.65);
}
.pause-fab.paused:hover { background-color: rgba(214, 40, 40, 1); }
/* "PAUSING…": a manual PAUSE pressed mid-armed, waiting for the next arming boundary
to take effect (amber, between the green rest and red paused states). */
.pause-fab.pending {
color: #04130b;
background-color: rgba(255, 209, 102, 0.92);
border-color: rgba(255, 209, 102, 0.6);
}
.pause-fab.pending:hover { background-color: rgba(255, 209, 102, 1); }
/* ── parked / away overlay: a dim full-screen backdrop (captures input so nothing
underneath registers) with a centred card. Sits above the how-to popup, below the
anticheat test gate (z:200). The PAUSE bar is lifted above this backdrop (z:195) so
it stays clickable — it's the one way to rejoin. ── */
.park-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
align-items: center;
justify-content: center;
background-color: rgba(0, 0, 0, 0.72);
pointer-events: all;
z-index: 190;
}
.park-card {
flex-direction: column;
flex-shrink: 0;
width: 520px;
padding: 26px 30px;
align-items: center;
background-color: rgba(3, 13, 7, 0.98);
border: 1.5px solid rgba(214, 40, 40, 0.55);
border-radius: 12px;
gap: 10px;
pointer-events: all;
}
.park-title {
font-size: 26px;
font-weight: bold;
letter-spacing: 2px;
color: #ff6b6b;
}
.park-sub {
font-size: 16px;
color: rgba(255, 255, 255, 0.85);
text-align: center;
}
.park-hint {
font-size: 15px;
color: rgba(6, 214, 160, 0.95);
text-align: center;
}
/* RESUME button on the parked overlay card — the reliable rejoin control. */
.park-resume {
margin-top: 8px;
padding: 12px 40px;
justify-content: center;
align-items: center;
font-size: 18px;
font-weight: bold;
letter-spacing: 2px;
color: #04130b;
background-color: rgba(6, 214, 160, 0.92);
border: 1.5px solid rgba(6, 214, 160, 0.6);
border-radius: 8px;
cursor: pointer;
pointer-events: all;
}
.park-resume:hover { background-color: rgba(6, 214, 160, 1); }
/* ── "How to Play" popup: a dim full-screen backdrop (captures input) with a
floating card centred over it, like the music player. Sits below the anticheat
test gate (z:200) but above everything else. ── */
.howto-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
align-items: center;
justify-content: center;
background-color: rgba(0, 0, 0, 0.7);
pointer-events: all;
z-index: 160;
}
/* Sized to its content — NOT scrollable. An overflow:scroll range becomes
drag-pannable in s&box (you can drag the card around inside the overlay), so
the card grows to fit instead, like the music board. The content is lean
enough to fit the 1080-tall reference ScreenPanel scales to. */
.howto-card {
flex-direction: column;
flex-shrink: 0;
width: 560px;
padding: 22px 28px;
background-color: rgba(3, 13, 7, 0.98);
border: 1.5px solid rgba(255, 255, 255, 0.12);
border-radius: 12px;
gap: 3px;
pointer-events: all;
}
.howto-card .howto-head {
flex-direction: row;
align-items: center;
justify-content: space-between;
margin-bottom: 4px;
}
.howto-card .howto-title {
font-size: 22px;
font-weight: bold;
color: rgba(255, 255, 255, 0.9);
letter-spacing: 1px;
}
.howto-card .howto-subtitle {
font-size: 16px;
font-weight: bold;
color: #06d6a0;
letter-spacing: 1px;
margin-top: 2px;
margin-bottom: 4px;
}
.howto-card .howto-close {
width: 34px;
height: 34px;
justify-content: center;
align-items: center;
font-size: 20px;
font-weight: bold;
color: rgba(255, 255, 255, 0.6);
border-radius: 6px;
cursor: pointer;
}
.howto-card .howto-close:hover {
color: #ffffff;
background-color: rgba(255, 255, 255, 0.08);
}
.howto-card .divider {
height: 1px;
background-color: rgba(255, 255, 255, 0.1);
margin-top: 6px;
margin-bottom: 8px;
}
.howto-card .line {
font-size: 14px;
color: rgba(255, 255, 255, 0.6);
margin-bottom: 6px;
white-space: normal;
}
.howto-card .penalty-note {
margin-bottom: 0;
margin-top: 2px;
}
.howto-card .state-row {
flex-direction: row;
align-items: center;
gap: 10px;
margin-bottom: 5px;
}
.howto-card .tag {
width: 110px;
flex-shrink: 0;
font-size: 13px;
font-weight: bold;
letter-spacing: 0.5px;
}
.howto-card .tag.wait { color: #06d6a0; }
.howto-card .tag.go { color: #ffffff; }
.howto-card .tag.over { color: #e63946; }
.howto-card .desc {
flex-grow: 1;
font-size: 13px;
color: rgba(255, 255, 255, 0.6);
white-space: normal;
}
/* click-to-copy Discord invite blurb (there's no in-game URL open; Clipboard
works, so it copies). Discord blurple, underlines on hover. */
.howto-card .discord-line {
font-size: 13px;
font-weight: bold;
color: #8b93f5;
white-space: normal;
pointer-events: all;
cursor: pointer;
}
.howto-card .discord-line:hover { color: #ffffff; text-decoration: underline; }
/* The music opener lives on the Skafinity panel's own ScreenPanel (see scene
ShowFab/WideFab/FabLabel), not here. */
.board.hourly { right: 308px; } /* 14 (edge) + 280 (hours-won) + 14 (gap) */
.board.hourswon { right: 14px; }
/* sessions-won pins to the free bottom-right corner (top:auto cancels the
shared top:18px) so the three right-hand boards never crowd the button. */
.board.sessionswon { top: auto; bottom: 18px; right: 14px; }
.board .title {
font-size: 14px;
font-weight: bold;
color: rgba(255, 255, 255, 0.85);
letter-spacing: 1px;
justify-content: center;
margin-bottom: 4px;
}
.board .reset {
font-size: 11px;
color: rgba(255, 255, 255, 0.5);
justify-content: center;
margin-top: -2px;
margin-bottom: 4px;
}
.board .divider {
height: 1px;
background-color: rgba(255, 255, 255, 0.1);
margin-bottom: 5px;
}
.board .row {
flex-direction: row;
align-items: center;
font-size: 13px;
color: rgba(255, 255, 255, 0.78);
padding: 2px 5px;
border-radius: 4px;
}
.board .rank { width: 24px; color: rgba(255, 255, 255, 0.4); font-size: 12px; }
/* anticheat status dot: green live / yellow cooldown / red ignored. */
.board .dot {
width: 8px;
height: 8px;
flex-shrink: 0;
margin-right: 7px;
border-radius: 50%;
background-color: #06d6a0;
}
.board .dot.live { background-color: #06d6a0; }
.board .dot.cooldown { background-color: #ffd166; }
.board .dot.ignored { background-color: #e63946; }
.board .name { flex-grow: 1; overflow: hidden; white-space: nowrap; }
.board .pts { color: #06d6a0; font-weight: bold; }
.board .row.me { background-color: rgba(6, 214, 160, 0.15); }
/* the tie-break margin ("+12ms"): how far this player trailed the one above
them on the same score. Dim amber, just left of the points. */
.board .behind { color: #ffb37a; font-size: 11px; margin-right: 6px; flex-shrink: 0; }
/* names are clickable (copy the player's Steam profile link); re-enable
pointer events on just these against the pointer-events:none root. */
.board .name.clickable { pointer-events: all; cursor: pointer; }
.board .name.clickable:hover { color: #ffffff; text-decoration: underline; }
/* hours-won is the gold board */
.board.hourswon .pts { color: #ffd166; }
.board.hourswon .row.me { background-color: rgba(255, 209, 102, 0.15); }
/* sessions-won is the blue board */
.board.sessionswon .pts { color: #4cc9f0; }
.board.sessionswon .row.me { background-color: rgba(76, 201, 240, 0.15); }
/* all-time clickers: the violet board (stacked under the session board in the
left column; positioning handled by .left-col). */
.board.alltime .pts { color: #c77dff; }
.board.alltime .row.me { background-color: rgba(199, 125, 255, 0.15); }
/* the leader's "skin": bundled image + caption, pinned just left of the
sessions-won board (308 = 14 edge + 280 board + 14 gap), bottom-aligned. */
.skin-panel {
position: absolute;
bottom: 18px;
right: 308px;
width: 200px;
flex-direction: column;
align-items: center;
background-color: rgba(3, 13, 7, 0.85);
border: 1.5px solid rgba(255, 255, 255, 0.12);
border-radius: 10px;
padding: 10px;
gap: 6px;
}
/* countdown to the winner-lock time, sitting above the image. */
.skin-countdown {
flex-direction: column;
align-items: center;
gap: 1px;
}
.skin-countdown .cd-label {
font-size: 12px;
font-weight: bold;
color: rgba(255, 255, 255, 0.6);
text-align: center;
justify-content: center;
letter-spacing: 0.5px;
}
.skin-countdown .cd-time {
font-size: 22px;
font-weight: bold;
color: #ffd166;
justify-content: center;
letter-spacing: 1px;
}
/* shown in place of the countdown once the winner-lock time has passed. */
.skin-locked {
font-size: 13px;
font-weight: bold;
color: #ff9f1c;
text-align: center;
justify-content: center;
}
/* shown in place of the whole skin/countdown when no bounty is active (the
config skin/countdown would otherwise be a stale fallback). */
.no-bounty {
flex-direction: column;
align-items: center;
justify-content: center;
padding: 24px 12px;
gap: 8px;
}
.no-bounty-title {
font-size: 20px;
font-weight: bold;
color: #ffd166;
text-align: center;
justify-content: center;
letter-spacing: 0.5px;
}
.no-bounty-sub {
font-size: 12px;
color: rgba(255, 255, 255, 0.6);
text-align: center;
justify-content: center;
}
/* the image (served by the backend) scales to fit the panel width
(contain keeps its aspect ratio); the url is bound inline from config. */
.skin-img {
width: 100%;
height: 240px;
background-repeat: no-repeat;
background-position: center;
background-size: contain;
}
.skin-caption {
font-size: 15px;
font-weight: bold;
color: #4cc9f0;
justify-content: center;
text-align: center;
}
/* click-to-copy the bounty's CS2 inspect link (no in-game URL open; Clipboard
works, so it copies). Underlines on hover like the other clickable lines. */
.skin-copy {
font-size: 12px;
font-weight: bold;
color: #8b93f5;
justify-content: center;
text-align: center;
pointer-events: all;
cursor: pointer;
}
.skin-copy:hover { color: #ffffff; text-decoration: underline; }
/* the decoded skin's name (from the inspect link's dataset lookup). */
.skin-name {
font-size: 14px;
font-weight: bold;
color: #ffffff;
text-align: center;
justify-content: center;
}
/* wear / float / seed rows decoded from the inspect link. */
.skin-stats {
width: 100%;
flex-direction: column;
gap: 2px;
}
.skin-stats .stat {
flex-direction: row;
justify-content: space-between;
font-size: 12px;
}
.skin-stats .stat .k { color: rgba(255, 255, 255, 0.55); }
.skin-stats .stat .v { color: #e0e0e0; font-weight: bold; }
/* the FN→BS wear bar: five segments sized to the wear thresholds with a
marker line at the float position. */
.wear-bar {
position: relative;
width: 100%;
height: 8px;
flex-direction: row;
border-radius: 3px;
}
.wear-bar .seg { height: 100%; }
.wear-bar .seg.fn { width: 7%; background-color: #4cd964; }
.wear-bar .seg.mw { width: 8%; background-color: #a8e05f; }
.wear-bar .seg.ft { width: 23%; background-color: #ffd166; }
.wear-bar .seg.ww { width: 7%; background-color: #ff9f1c; }
.wear-bar .seg.bs { width: 55%; background-color: #ff4d4d; }
.wear-marker {
position: absolute;
top: -2px;
width: 2px;
height: 12px;
background-color: #ffffff;
border: 1px solid rgba(0, 0, 0, 0.6);
}
/* shown when the inspect link couldn't be decoded or its image fetched. */
.skin-note {
font-size: 11px;
font-weight: bold;
color: #ff9f1c;
text-align: center;
justify-content: center;
}
/* previous-winner panel: a duplicate skin panel pinned just left of the live one
(308 + 200 + 20). Display-only — pointer-events:none so the roaming button
beneath stays fully clickable; a gold edge + slightly shorter image distinguish
it from the live "skin to win". */
.skin-panel.previous {
right: 528px;
pointer-events: none;
border-color: rgba(255, 209, 102, 0.35);
}
.skin-panel.previous .skin-img { height: 150px; }
.prev-header {
font-size: 12px;
font-weight: bold;
color: #ffd166;
letter-spacing: 1px;
text-align: center;
justify-content: center;
}
.prev-winner {
font-size: 16px;
font-weight: bold;
color: #ffffff;
text-align: center;
justify-content: center;
}
/* the winner line re-enables pointer events over the panel's pointer-events:none
(only this line is interactive — click to copy the winner's profile link). */
.prev-winner.clickable { pointer-events: all; cursor: pointer; }
.prev-winner.clickable:hover { color: #ffd166; text-decoration: underline; }
.prev-wins {
font-size: 12px;
color: rgba(255, 255, 255, 0.6);
text-align: center;
justify-content: center;
}
.board .empty {
justify-content: center;
font-size: 12px;
color: rgba(255, 255, 255, 0.35);
margin-top: 4px;
}
/* ── center: the BOUNDING BOX the button roams inside, inset between the
left session board (right edge ≈ 308px) and the right board column (also
308px), with a little top/bottom breathing room. The button is positioned
absolutely within this box; the status HUD overlays it centred. ──
The box (and so the button + status HUD + music panel inside it) is kept ABOVE every
board at all times via z-index. The boards are declared after .center in the markup,
so without this they'd paint — and take clicks — on top of anything in the box. The
box is normally inset clear of every board, but the roaming button (and the music
button it can overlap at the bottom-left) must always win paint AND click priority
when they do intersect, so the box outranks them unconditionally. */
.center {
position: absolute;
top: 24px;
bottom: 24px;
left: 308px;
right: 308px;
flex-direction: column;
align-items: center;
justify-content: center;
pointer-events: none;
z-index: 100;
}
/* Opponent click pip: a half-size (70px vs the button's 140px) green circle that
pops in and fades over ~0.45s (matched to PipLifetime so it's gone before
ClickController prunes it). Positioned in the box's % space (left/top set inline),
with margin offsets of −half so the inline %, which is the clicker's button
CENTRE, places the pip centred there. pointer-events:none so it never eats a
click; below the penalty pops, above the button. */
.pip {
position: absolute;
width: 70px;
height: 70px;
margin-left: -35px;
margin-top: -35px;
border-radius: 50%;
background-color: #06d6a0;
border: 2px solid rgba(255, 255, 255, 0.8);
flex-direction: column;
align-items: center;
justify-content: center;
pointer-events: none;
z-index: 110;
animation-name: pip-fade;
animation-duration: 0.45s;
animation-timing-function: ease-out;
animation-fill-mode: forwards;
}
@@keyframes pip-fade {
0% { opacity: 0.9; transform: scale(0.6); }
25% { opacity: 0.85; transform: scale(1); }
100% { opacity: 0; transform: scale(1.1); }
}
/* The clicker's name centred over their pip. */
.pip-name {
font-size: 13px;
font-weight: bold;
color: #ffffff;
text-stroke: 1px #000000;
text-align: center;
white-space: nowrap;
}
/* v5 board button: one clickable green SQUARE per live slot. Positioned by its
server-placed centre (inline left/top %, same box-% space as the pips), centred via
−half margins. Square (not a circle) so the visual matches the actual click hitbox
AND the `touch` hitbox — s&box hit-tests the panel's box, so a circle would leave the
corners clickable-but-untouched and false-trip the no_hover check; it also matches the
server's square non-overlap spacing model (board.go posMinDist). Smaller than the
legacy single button since up to X share the box. No teleport — positions are
server-authoritative, so left/top can transition-free. */
.board-btn {
position: absolute;
width: 96px;
height: 96px;
margin-left: -48px;
margin-top: -48px;
border-radius: 12px;
background-color: #06d6a0; /* live green — the board only exists while armed */
border: 4px solid rgba(255, 255, 255, 0.9);
transition: transform 0.08s ease-out;
pointer-events: all;
cursor: pointer;
z-index: 105;
}
.board-btn:active { transform: scale(0.9); }
/* Opponent cursor: a small amber dot at another player's sampled pointer, with their
name beneath. Box-% space like the pips; never eats a click. */
.opp-cursor {
position: absolute;
width: 16px;
height: 16px;
margin-left: -8px;
margin-top: -8px;
border-radius: 50%;
background-color: rgba(255, 209, 102, 0.9);
border: 2px solid rgba(0, 0, 0, 0.5);
flex-direction: column;
align-items: center;
pointer-events: none;
z-index: 108;
}
.opp-cursor-name {
position: absolute;
top: 16px;
font-size: 13px;
color: #ffd166;
text-stroke: 1px rgba(0, 0, 0, 0.8);
white-space: nowrap;
pointer-events: none;
}
/* Live clicks-remaining counter, green, shown while the window is open. */
.remaining {
font-size: 40px;
font-weight: bold;
color: #06d6a0;
text-stroke: 1px rgba(0, 0, 0, 0.5);
}
/* HUD pinned to the top edge of the screen, centred horizontally over the box;
the button roams below/behind it and clicks fall through. */
.hud {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
flex-direction: column;
align-items: center;
justify-content: flex-start;
gap: 10px;
pointer-events: none;
}
.round {
font-size: 22px;
font-weight: bold;
color: rgba(255, 255, 255, 0.85);
letter-spacing: 1px;
}
.state {
font-size: 54px;
font-weight: bold;
color: rgba(255, 255, 255, 0.9);
letter-spacing: 3px;
}
.state.armed { color: #ffffff; }
.state.offline { color: #ffd166; }
.meta {
flex-direction: row;
gap: 10px;
align-items: center;
font-size: 18px;
font-weight: bold;
color: rgba(255, 255, 255, 0.85);
}
.meta .sep { color: rgba(255, 255, 255, 0.4); }
.arm {
font-size: 16px;
font-weight: bold;
color: rgba(255, 255, 255, 0.6);
letter-spacing: 1px;
}
/* Host-pushed broadcast note: a large orange message centred in the status HUD. */
.dev-note {
font-size: 32px;
font-weight: bold;
color: #ff8c00;
}
/* The status HUD now floats centred on the dark box (the button roams away from
it), so the text stays white in every state — no on-button contrast override. */
/* Pinned to the bottom of the bounding box, centred horizontally. */
.clicks-sent {
position: absolute;
bottom: 0;
left: 0;
right: 0;
justify-content: center;
font-size: 22px;
font-weight: bold;
color: rgba(255, 255, 255, 0.65);
letter-spacing: 1px;
pointer-events: none;
}
/* transient confirmation that a profile link was copied; top-center, fades by
simply un-rendering after ToastDuration (see BuildHash). */
.toast {
position: absolute;
top: 40px;
left: 0;
right: 0;
justify-content: center;
font-size: 20px;
font-weight: bold;
color: #4cc9f0;
letter-spacing: 1px;
pointer-events: none;
/* Above every overlay (the .center box at 100, the how-to popup at 160, the
anticheat gate at 200) so the "copied" confirmation is visible no matter what's
open — e.g. copying the Discord invite from inside the how-to popup. */
z-index: 210;
}
/* ── per-round scores flash: a card centred over the play area, shown for
FlashDuration after each round closes then fading out. The .round-flash layer
fills the screen and centres the card; the keyframe (pop in → hold → fade) runs
for the full 4s so the card unmounts as the fade ends. Non-interactive and above
the boards/button but below the how-to popup (160) and the anticheat gate (200).
The card is sized to its content (no scroll — an overflow range becomes
drag-pannable in s&box). */
.round-flash {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
flex-direction: column;
align-items: center;
justify-content: center;
pointer-events: none;
z-index: 140;
animation-name: round-flash-anim;
animation-duration: 4s;
animation-timing-function: ease-out;
animation-fill-mode: forwards;
}
@@keyframes round-flash-anim {
0% { opacity: 0; }
6% { opacity: 1; }
82% { opacity: 1; }
100% { opacity: 0; }
}
/* the visible card: same dark panel look as the boards. */
.round-flash .rf-panel {
flex-direction: column;
flex-shrink: 0;
width: 360px;
padding: 16px 20px;
gap: 3px;
background-color: rgba(3, 13, 7, 0.95);
border: 1.5px solid rgba(255, 255, 255, 0.12);
border-radius: 12px;
}
.round-flash .rf-title {
font-size: 22px;
font-weight: bold;
color: rgba(255, 255, 255, 0.9);
letter-spacing: 2px;
justify-content: center;
margin-bottom: 2px;
}
.round-flash .divider {
height: 1px;
background-color: rgba(255, 255, 255, 0.1);
margin-bottom: 6px;
}
.round-flash .rf-row {
flex-direction: row;
align-items: center;
font-size: 18px;
color: rgba(255, 255, 255, 0.85);
padding: 3px 6px;
border-radius: 4px;
}
.round-flash .rf-rank { width: 30px; color: rgba(255, 255, 255, 0.4); font-size: 15px; }
.round-flash .rf-name { flex-grow: 1; overflow: hidden; white-space: nowrap; }
.round-flash .rf-pts { color: #06d6a0; font-weight: bold; }
.round-flash .rf-row.me { background-color: rgba(6, 214, 160, 0.15); }
.round-flash .rf-empty {
justify-content: center;
font-size: 15px;
color: rgba(255, 255, 255, 0.5);
padding: 4px 0;
}
/* ── anticheat test gate: a full-screen dim overlay that captures input (above
everything else) with a centred card holding the question + answer box. ── */
.test-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
align-items: center;
justify-content: center;
background-color: rgba(0, 0, 0, 0.75);
pointer-events: all; /* block the game beneath while benched */
z-index: 200;
}
.test-card {
flex-direction: column;
align-items: center;
gap: 14px;
width: 420px;
padding: 28px 32px;
background-color: rgba(3, 13, 7, 0.98);
border: 2px solid #e63946;
border-radius: 14px;
}
.test-title {
font-size: 26px;
font-weight: bold;
color: #e63946;
letter-spacing: 2px;
justify-content: center;
}
.test-sub {
font-size: 14px;
color: rgba(255, 255, 255, 0.6);
text-align: center;
justify-content: center;
white-space: normal;
}
/* the specific check (or sanction) reason, in the anticheat red. */
.test-reason {
font-size: 16px;
font-weight: bold;
color: #ff8c69;
text-align: center;
justify-content: center;
white-space: normal;
}
/* cooldown/ignored card: no answer box, just the reason + a countdown. The
amber border separates it from the red math-test card. */
.test-card.sanction { border-color: #ffd166; }
.sanction-label {
font-size: 14px;
font-weight: bold;
color: rgba(255, 255, 255, 0.6);
text-align: center;
justify-content: center;
letter-spacing: 0.5px;
}
.sanction-time {
font-size: 40px;
font-weight: bold;
color: #ffd166;
letter-spacing: 2px;
justify-content: center;
}
.test-prompt {
font-size: 40px;
font-weight: bold;
color: #ffffff;
letter-spacing: 2px;
justify-content: center;
}
/* Mirror the known-good TextEntry recipe (skafinity .tag-input / rotaliate
.chat-entry): an explicit height + plain `color: white`. Without a height the
text box collapses and the typed text is clipped (it's still captured — the
answer sends — just not painted); text-align on a TextEntry also hides it. */
.test-input {
width: 240px;
height: 44px;
padding: 0 14px;
font-size: 24px;
color: white;
background-color: rgba(255, 255, 255, 0.08);
border: 1.5px solid rgba(255, 255, 255, 0.3);
border-radius: 8px;
pointer-events: all;
}
.test-submit {
font-size: 18px;
font-weight: bold;
color: #030d07;
background-color: #06d6a0;
letter-spacing: 1px;
padding: 10px 28px;
border-radius: 8px;
cursor: pointer;
pointer-events: all;
}
.test-submit:hover { background-color: #08f0b4; }
</style>
@code {
const int DisplayLimit = 10;
const float RefreshInterval = 20f;
// Touch-hitbox geometry (reference px, the 1080-tall ScreenPanel space). CenterRefH is
// the .center box's reference height (1080 − top:24 − bottom:24); BoardBtnRefHalf is the
// .board-btn half-extent (96px square ⇒ 48). TouchPadRef DELIBERATELY enlarges the touch
// hitbox beyond the visual/click box so the `touch` zone is a strict SUPERSET of the
// clickable area: a click can land in the 4px border ring or a frame where the per-frame
// sampler just missed the edge, and no_hover only targets EGREGIOUS cases (cursor nowhere
// near the button), so erring bigger trades a hair of sensitivity for zero edge-click
// false positives. Used to size the square hitbox in actual px when detecting a `touch`
// enter (see OnUpdate).
const float CenterRefH = 1080f - 24f - 24f;
const float BoardBtnRefHalf = 48f;
const float TouchPadRef = 32f;
ClickController C => ClickController.Instance;
// ── session: live standings pushed inside round_result/game_over (never fetched) ──
static List<Standing> SessionEntries => ClickController.Instance?.Standings ?? new List<Standing>();
// ── hourly + hours-won: fetched over HTTP, deliberately infrequently (once on
// connect, then when a round closes, throttled) so neither becomes a fan-in
// GET stampede. The hot click path never touches HTTP. ──
List<Standing> _hourly = new();
bool _hourlyLoaded, _hourlyFetching;
RealTimeSince _hourlyLast;
List<Standing> _hoursWon = new();
bool _hoursWonLoaded, _hoursWonFetching;
RealTimeSince _hoursWonLast;
List<Standing> _sessionsWon = new();
bool _sessionsWonLoaded, _sessionsWonFetching;
RealTimeSince _sessionsWonLast;
List<Standing> _allTime = new();
bool _allTimeLoaded, _allTimeFetching;
RealTimeSince _allTimeLast;
GamePhase _lastPhase = GamePhase.Connecting;
// ── the bounding box (.center), captured by @ref so the board buttons, opponent
// pips and cursor can be measured/normalized against it. The board buttons are
// server-placed (board-% space); the client draws no button of its own. ──
Panel _box;
// ── server-driven skin + winner-lock countdown (GET /api/v1/config) ──
// _winnerLockMs is the winner-lock instant as Unix epoch ms (0 = unset, hides
// the countdown); _skinUrl is the absolute URL the backend serves the current
// "skin to win" image from. Both come from config so they retune without a
// client rebuild. Epoch-ms (not a parsed DateTime) because the s&box sandbox
// doesn't whitelist System.Globalization — the countdown is integer math.
long _winnerLockMs;
string _skinUrl = "";
// True only while a bounty is actually active (config's has_bounty). When false the
// skin/countdown above is the host config.json/env fallback (a stale "old" skin) and
// the bounty-scoped boards have fallen back to all-time data — so the skin panel and
// the "THIS SKIN" boards show a "no active bounty" state instead of that stale data.
bool _hasBounty;
// ── inspect-link skin (decoded locally + name/image resolved off the dataset) ──
// Null until an inspect-linked bounty's config arrives and resolution completes;
// its DecodeOk/ImageOk flags drive what the skin panel shows vs. the server image.
SkinInspect.Skin _skin;
// The active bounty's raw inspect link ("" when the skin is an uploaded image
// only). Kept verbatim so the "copy inspect link" line can hand it to players to
// paste into CS2, independent of whether it decoded/resolved locally.
string _inspectLink = "";
// ── previous winner (the just-settled bounty) ── a duplicate of the skin panel
// showing who won the last skin and what it was. _prevBounty is the most recent
// won bounty (null until fetched / when there's no history); _prevSkin is its
// inspect link decoded locally (same as _skin); _prevSkinUrl is the per-bounty
// image fallback. _lastBountyRefresh tracks ClickController.BountyRefreshSeq so a
// hello/reconnect or a bounty_update push re-fetches both current and previous.
PreviousBounty _prevBounty;
SkinInspect.Skin _prevSkin;
string _prevSkinUrl = "";
int _lastBountyRefresh;
// Image for every skin panel: a locally-bundled placeholder. Real CS2/Valve
// skins are no longer surfaced and the image is no longer fetched from the
// server — it ships with the client (see .sbproj Resources).
const string TempgunImg = "media/tempgun.png";
string SkinImgUrl() => TempgunImg;
// Wear-bar marker position as a 0..100 percentage at the float value, clamped.
int MarkerPct()
{
int pct = (int)((_skin?.Float ?? 0f) * 100f + 0.5f);
return pct < 0 ? 0 : pct > 100 ? 100 : pct;
}
// Float formatted to 10 dp without culture-sensitive APIs (the sandbox doesn't
// whitelist System.Globalization): integer part + zero-padded fractional digits.
string FloatStr()
{
double d = _skin?.Float ?? 0.0;
if ( d < 0 ) d = 0;
const long Scale = 10_000_000_000L; // 10 fractional digits, like the float checker
long scaled = (long)(d * Scale + 0.5);
return $"{scaled / Scale}.{(scaled % Scale).ToString().PadLeft( 10, '0' )}";
}
// ── previous-winner panel helpers (mirror the current-skin ones, reading the
// previous bounty's decoded skin/image instead) ──
string PrevSkinImgUrl() => TempgunImg;
int PrevMarkerPct()
{
int pct = (int)((_prevSkin?.Float ?? 0f) * 100f + 0.5f);
return pct < 0 ? 0 : pct > 100 ? 100 : pct;
}
string PrevFloatStr()
{
double d = _prevSkin?.Float ?? 0.0;
if ( d < 0 ) d = 0;
const long Scale = 10_000_000_000L;
long scaled = (long)(d * Scale + 0.5);
return $"{scaled / Scale}.{(scaled % Scale).ToString().PadLeft( 10, '0' )}";
}
// The previous winner's display name, tagged "(you)" when the local player won
// it (matched by the public tag, the only id the client knows itself by).
string PrevWinnerName()
{
if ( _prevBounty == null ) return "";
var name = string.IsNullOrEmpty( _prevBounty.WinnerName ) ? "—" : _prevBounty.WinnerName;
var myTag = ClickController.Instance?.Tag;
if ( !string.IsNullOrEmpty( myTag ) && _prevBounty.WinnerTag == myTag ) name += " (you)";
return name;
}
// ── anticheat test gate: the answer box (captured by @ref so SubmitTest can read
// it) shown in the overlay while the player is benched. ──
TextEntry _testEntry;
// Send the typed answer to the server and clear the box. The server replies with a
// `test` cleared frame (correct → un-benched) or a fresh `test` (wrong); either way
// ClickController updates HasTest/TestPrompt and the overlay re-renders.
void SubmitTest()
{
var ans = _testEntry?.Text?.Trim();
if ( string.IsNullOrEmpty( ans ) ) return;
ClickController.Instance?.SubmitTestAnswer( ans );
if ( _testEntry != null ) _testEntry.Text = "";
}
// ── "How to Play" popup: open state mirrors PlayerData.HowToPlayOpen (persisted to
// local storage), seeded once from it here so the player's choice survives across
// sessions. First-time players default to open (PlayerData default). ──
bool _howtoOpen = PlayerData.Load().HowToPlayOpen;
// True once WE auto-parked the player because a panel (the how-to popup or the music
// board) is open — reading the rules or browsing music means they're not playing, so we
// step them out of the round + the AFK pass instead of letting a still cursor flag them.
// Latched only when our own park took, so a server afk-park or a manual PAUSE that
// happens to coincide with a panel is never auto-resumed when the panel closes. Driven
// from OnUpdate (PanelParkSync).
bool _panelParked;
// Toggle the popup and persist the new state to local storage so it sticks.
void ToggleHowTo()
{
_howtoOpen = !_howtoOpen;
var data = PlayerData.Load();
data.HowToPlayOpen = _howtoOpen;
data.Save();
StateHasChanged();
}
// Keep the parked state in sync with the open panels (how-to popup / music board). Open
// panel ⇒ park; once they're all closed ⇒ rejoin — but only if WE were the ones who
// parked for a panel (_panelParked). A server afk-park or a manual PAUSE that overlaps a
// panel is left alone (we never latched it), so closing the panel doesn't resume them.
// Park is a no-op without a live socket (e.g. the first-launch popup before connect), so
// we only latch once the park actually took, and retry every frame until it does.
void PanelParkSync()
{
if ( C == null ) return;
bool panelOpen = _howtoOpen || MusicOpen;
if ( panelOpen )
{
// Request a park once. Don't re-issue while one is already parked OR pending: a
// PAUSE pressed mid-armed is deferred to the next arming boundary (Parked stays
// false, ParkPending true until then), and re-calling TogglePark would read that
// as a RESUME and toggle it straight back off.
if ( !C.Parked && !C.ParkPending )
{
C.TogglePark();
if ( C.Parked || C.ParkPending ) _panelParked = true; // our park took (now or deferred)
StateHasChanged();
}
}
else if ( _panelParked )
{
// Panels closed: rejoin if we're parked or a park is still in flight.
if ( C.Parked || C.ParkPending ) C.TogglePark();
_panelParked = false;
StateHasChanged();
}
}
// Toggle the away/parked state from the PAUSE bar / parked-overlay RESUME. Delegates to
// the controller (which parks immediately, defers a mid-armed park to the next arming
// boundary, or requests a deferred rejoin) and re-renders so the bar + overlay update.
void TogglePause()
{
ClickController.Instance?.TogglePark();
StateHasChanged();
}
// PAUSE-bar label/class. "PAUSING…" (amber) while a mid-armed park waits for the next
// arming boundary; "PAUSED" (red) once parked; "PAUSE" (green) at rest.
string PauseFabLabel()
{
var c = C;
if ( c == null ) return "PAUSE";
if ( c.ParkPending ) return "PAUSING…";
return c.Parked ? "PAUSED" : "PAUSE";
}
string PauseFabClass()
{
var c = C;
if ( c == null ) return "";
if ( c.ParkPending ) return "pending";
return c.Parked ? "paused" : "";
}
// ── "copied profile link" toast: shown for ToastDuration after a name click ──
const float ToastDuration = 1.2f;
string _toast = "";
RealTimeSince _toastSince = 1000f; // start expired
bool ToastVisible => _toastSince < ToastDuration;
// ── per-round scores flash: a transient panel of the round's scorers (the
// round_result `winners`, carrying the points each grabbed THAT round), shown
// for FlashDuration when a round closes. Triggered on the phase change into
// Result (the final round folds into game_over, whose GAME OVER standings stand
// in for its flash). A snapshot is taken so it survives the next round overwriting
// C.Winners. _flashSince starts expired so nothing shows until the first round. ──
const float FlashDuration = 4f;
List<Standing> _flashWinners = new();
int _flashRound;
RealTimeSince _flashSince = 1000f;
bool FlashVisible => _flashSince < FlashDuration;
// ── post-game board refresh: the server credits the session win and refreshes
// the board cache asynchronously (afterGame) AFTER it broadcasts game_over, so
// a fetch fired the instant game_over lands races ahead of that write and shows
// the games/hours-won boards a game behind ("lags by 1"). When a game ends we
// arm a one-shot refresh a short delay later — past the async write (a sub-second
// local DB write; the 5s intermission easily covers it) — so the just-finished
// game is reflected while the GAME OVER standings are still up. ──
const float PostGameRefreshDelay = 2f;
bool _postGamePending;
RealTimeSince _postGameSince = 1000f;
protected override void OnTreeFirstBuilt()
{
_ = LoadConfig();
_ = LoadPreviousBounty();
_ = RefreshHourly();
_ = RefreshHoursWon();
_ = RefreshSessionsWon();
_ = RefreshAllTime();
}
// Fetch the winner-lock time + skin image URL (the active bounty). Re-run on
// (re)connect and on a bounty_update push, so the panel always reflects the
// current bounty. On failure the countdown simply doesn't show and the image
// stays blank — no hard dependency.
async Task LoadConfig()
{
var cfg = await ApiClient.GetConfig();
if ( cfg == null ) return;
_skinUrl = ApiClient.AbsoluteUrl( cfg.SkinUrl ) ?? "";
_winnerLockMs = cfg.WinnerLockMs;
_inspectLink = cfg.InspectLink ?? "";
_hasBounty = cfg.HasBounty;
// Clear the previous bounty's decoded skin so a re-fetch (rollover) doesn't
// leave the old name/stats showing under the new skin — ResolveSkin refills it
// when this bounty also has an inspect link; an image-only bounty keeps it null.
_skin = null;
StateHasChanged();
// If the active bounty was set by an inspect link, decode it locally and
// resolve its name/stats (cached dataset) in the background. We use the decoded
// name + wear/float/seed, but the displayed image is always the local tempgun
// placeholder (SkinImgUrl), never the Valve weapon image.
if ( !string.IsNullOrWhiteSpace( cfg.InspectLink ) )
_ = ResolveSkin( cfg.InspectLink );
}
// Decode the bounty's inspect link + resolve its name/stats, then re-render. On
// any failure _skin is left with DecodeOk/ImageOk false so the panel shows the
// "unable to fetch bounty from steam" note; the image is the tempgun placeholder
// either way.
async Task ResolveSkin( string link )
{
_skin = await SkinInspect.Resolve( link );
StateHasChanged();
}
// Fetch the most recent settled bounty (the "previous winner") and, if it was an
// inspect-link skin, decode its name/stats for the render (the image stays the
// tempgun placeholder). Re-run alongside LoadConfig so a rollover surfaces the
// just-settled winner immediately. The resolve is guarded by id so a slow decode
// can't clobber a newer previous bounty.
async Task LoadPreviousBounty()
{
var list = await ApiClient.GetPreviousBounties();
var b = ( list != null && list.Count > 0 ) ? list[0] : null;
_prevBounty = b;
_prevSkin = null;
_prevSkinUrl = b != null ? ( ApiClient.AbsoluteUrl( b.SkinUrl ) ?? "" ) : "";
StateHasChanged();
if ( b != null && !string.IsNullOrWhiteSpace( b.InspectLink ) )
{
var id = b.Id;
var skin = await SkinInspect.Resolve( b.InspectLink );
if ( _prevBounty != null && _prevBounty.Id == id ) // still the same previous bounty
{
_prevSkin = skin;
StateHasChanged();
}
}
}
protected override void OnUpdate()
{
// The bounty rolled over (or we just (re)connected): re-fetch the active skin
// and the previous winner so neither goes stale. Driven by the server push /
// hello via ClickController.BountyRefreshSeq — no polling.
int seq = C?.BountyRefreshSeq ?? 0;
if ( seq != _lastBountyRefresh )
{
_lastBountyRefresh = seq;
_ = LoadConfig();
_ = LoadPreviousBounty();
}
// While a panel is open (the how-to popup — which pops up on first launch — or the
// music board), the player is reading/browsing, not playing: their cursor sits still
// and the server would flag them AFK. Auto-park them (drops them out of the round's N
// + the AFK pass) and rejoin them when the panels close. See _panelParked.
PanelParkSync();
var phase = C?.Phase ?? GamePhase.Connecting;
// Report our pointer to the server (throttled in SendCursor) so other players see our
// roaming cursor AND so the server's AFK pass can tell a present player from an away
// one. As of v7 this runs during BOTH the arming wait (Pending) and the live window
// (Armed) — the server now watches arming-phase movement, not just armed. The .center
// box (_box) is laid out throughout (it's a fixed absolutely-positioned container that
// exists in every phase, with or without buttons), so its rect is valid during Pending
// too. Mouse.Position and Box.Rect share the same screen-pixel space, so the pointer
// normalizes to the box exactly like the buttons/pips (−1..1, 0 = centre). Re-render is
// driven by BoardHash in BuildHash (board claims + cursor moves), so no per-frame
// StateHasChanged needed here.
if ( ( phase == GamePhase.Armed || phase == GamePhase.Pending ) && C != null )
{
var r = _box?.Box.Rect ?? default;
if ( r.Width > 0f && r.Height > 0f )
{
var m = Sandbox.Mouse.Position;
float nx = ( m.x - r.Center.x ) / ( r.Width / 2f );
float ny = ( m.y - r.Center.y ) / ( r.Height / 2f );
// Only report the cursor while it's actually ON the board (within -1..1).
// A pointer resting OUTSIDE the board (over the side leaderboards) used to
// clamp to the +-1 edge, and tiny hand jitter oscillating across that edge
// made a physically-still cursor look like it moved a lot, defeating the
// afk check. Off-board samples are simply not sent: a player parked off the
// board reports no cursor (the server reads that as afk by the no-cursor
// half), and on-board movement is measured cleanly with no edge artefact.
if ( nx >= -1f && nx <= 1f && ny >= -1f && ny <= 1f )
C.SendCursor( nx, ny );
// Armed-only: fire a one-shot `touch` the instant the pointer enters a live
// button's hitbox (ClickController de-dupes per window). The button is a 96px
// CSS SQUARE; the touch hitbox is an axis-aligned box of half-extent
// (BoardBtnRefHalf + TouchPadRef), deliberately a bit BIGGER than the visual/
// click box so an edge click (the 4px border ring, or a frame the sampler just
// missed) always counts as touched — no_hover only targets egregious "cursor
// nowhere near" cases. ScreenPanel scales uniformly, so the half-extent is the
// same on both axes in actual px. .center spans top:24/bottom:24 of the 1080
// ref, so its reference height is CenterRefH; scale = actual box height ÷ that.
if ( phase == GamePhase.Armed )
{
float half = ( BoardBtnRefHalf + TouchPadRef ) * ( r.Height / CenterRefH );
foreach ( var btn in C.LiveButtons )
{
float cx = r.Center.x + btn.X * ( r.Width / 2f );
float cy = r.Center.y + btn.Y * ( r.Height / 2f );
if ( MathF.Abs( m.x - cx ) <= half && MathF.Abs( m.y - cy ) <= half )
C.SendTouch( btn.Slot );
}
}
}
}
if ( phase != _lastPhase )
{
// A round just closed: flash its scores. Snapshot the winners (the next
// round overwrites C.Winners) and restart the flash timer. Only on Result —
// the final round folds into GAME OVER, whose standings are its own display.
if ( phase == GamePhase.Result )
{
_flashWinners = C?.Winners != null ? new List<Standing>( C.Winners ) : new();
_flashRound = C?.Round ?? 0;
_flashSince = 0f;
}
// Refresh the boards when a round just closed (player is reading scores
// between presses), throttled so rapid result→pending churn can't spam.
// GAME OVER is deliberately excluded: a fetch here races the server's async
// post-game write (see below), so the games/hours-won boards would show a
// game behind. Game-end refresh is handled by the delayed one-shot instead.
if ( phase == GamePhase.Result )
{
if ( _hourlyLast > RefreshInterval ) _ = RefreshHourly();
if ( _hoursWonLast > RefreshInterval ) _ = RefreshHoursWon();
if ( _sessionsWonLast > RefreshInterval ) _ = RefreshSessionsWon();
if ( _allTimeLast > RefreshInterval ) _ = RefreshAllTime();
}
// A game just ended: arm the one-shot post-game refresh so the boards pick
// up this game's session win once the server's afterGame write has landed.
if ( phase == GamePhase.GameOver )
{
_postGamePending = true;
_postGameSince = 0f;
}
_lastPhase = phase;
StateHasChanged();
}
// Fire the armed post-game refresh once the delay has elapsed (the server's
// async session-win write is done by now), so the games/hours-won boards reflect
// the just-finished game instead of lagging a game behind.
if ( _postGamePending && _postGameSince >= PostGameRefreshDelay )
{
_postGamePending = false;
_ = RefreshHourly();
_ = RefreshHoursWon();
_ = RefreshSessionsWon();
_ = RefreshAllTime();
}
}
async Task RefreshHourly()
{
if ( _hourlyFetching ) return;
_hourlyFetching = true;
_hourlyLast = 0;
try
{
var board = await ApiClient.GetHourlyLeaderboard( DisplayLimit );
_hourly = board ?? new List<Standing>();
_hourlyLoaded = true;
StateHasChanged();
}
finally { _hourlyFetching = false; }
}
async Task RefreshHoursWon()
{
if ( _hoursWonFetching ) return;
_hoursWonFetching = true;
_hoursWonLast = 0;
try
{
var board = await ApiClient.GetHoursWonLeaderboard( DisplayLimit );
_hoursWon = board ?? new List<Standing>();
_hoursWonLoaded = true;
StateHasChanged();
}
finally { _hoursWonFetching = false; }
}
async Task RefreshSessionsWon()
{
if ( _sessionsWonFetching ) return;
_sessionsWonFetching = true;
_sessionsWonLast = 0;
try
{
var board = await ApiClient.GetSessionsWonLeaderboard( DisplayLimit );
_sessionsWon = board ?? new List<Standing>();
_sessionsWonLoaded = true;
StateHasChanged();
}
finally { _sessionsWonFetching = false; }
}
async Task RefreshAllTime()
{
if ( _allTimeFetching ) return;
_allTimeFetching = true;
_allTimeLast = 0;
try
{
var board = await ApiClient.GetAllTimeClickersLeaderboard( DisplayLimit );
_allTime = board ?? new List<Standing>();
_allTimeLoaded = true;
StateHasChanged();
}
finally { _allTimeFetching = false; }
}
// Copy the clicked player's public Steam community profile link to the
// clipboard and flash a short confirmation toast. SteamID64 is public, so the
// link works for anyone; no-op if the row carries no id.
void CopyProfile( Standing s )
{
if ( s == null || string.IsNullOrEmpty( s.SteamId ) ) return;
var url = $"https://steamcommunity.com/profiles/{s.SteamId}";
try { Clipboard.SetText( url ); }
catch ( Exception e ) { Log.Warning( $"[Splitclicker] clipboard copy failed: {e.Message}" ); }
_toast = $"Copied {DisplayName( s )}'s profile link";
_toastSince = 0f;
StateHasChanged();
}
// Copy the previous bounty winner's Steam profile link (same as CopyProfile, but
// the winner is carried on the bounty record, not a Standing). No-op when the
// settled bounty had no winner (empty window → no steamid).
void CopyPrevWinner()
{
var sid = _prevBounty?.WinnerSteamId;
if ( string.IsNullOrEmpty( sid ) ) return;
var url = $"https://steamcommunity.com/profiles/{sid}";
try { Clipboard.SetText( url ); }
catch ( Exception e ) { Log.Warning( $"[Splitclicker] clipboard copy failed: {e.Message}" ); }
var who = string.IsNullOrEmpty( _prevBounty.WinnerName ) ? "winner" : _prevBounty.WinnerName;
_toast = $"Copied {who}'s profile link";
_toastSince = 0f;
StateHasChanged();
}
// Discord invite (shared with the rotaliate family). No in-game URL-open API, so
// the how-to popup's blurb copies it to the clipboard instead. DiscordInvite is the
// short display form; DiscordUrl is what lands on the clipboard.
const string DiscordInvite = "discord.gg/GG8HWUfFpD";
const string DiscordUrl = "https://discord.gg/GG8HWUfFpD";
// Copy the Discord invite and flash the confirmation toast (visible above the
// how-to popup — see the toast's z-index).
void CopyDiscord()
{
try { Clipboard.SetText( DiscordUrl ); }
catch ( Exception e ) { Log.Warning( $"[Splitclicker] clipboard copy failed: {e.Message}" ); }
_toast = "Copied the Discord invite — see you there!";
_toastSince = 0f;
StateHasChanged();
}
// Copy the active bounty's CS2 inspect link to the clipboard so the player can
// paste it into the game and inspect the exact skin. No-op when there's no link.
void CopyInspect()
{
if ( string.IsNullOrWhiteSpace( _inspectLink ) ) return;
try { Clipboard.SetText( _inspectLink ); }
catch ( Exception e ) { Log.Warning( $"[Splitclicker] clipboard copy failed: {e.Message}" ); }
_toast = "Copied the bounty's inspect link";
_toastSince = 0f;
StateHasChanged();
}
static bool IsMe( Standing s )
{
var tag = ClickController.Instance?.Tag;
return !string.IsNullOrEmpty( tag ) && s.Tag == tag;
}
// Anticheat status dot class for a board row: green when live (the default),
// yellow on a cooldown, red when ignored for the bounty. Empty/unknown ⇒ live.
static string StatusClass( Standing s ) => s?.Status switch
{
"cooldown" => "cooldown",
"ignored" => "ignored",
_ => "live",
};
// Show the player's name (claimed username or Steam display name, resolved
// server-side), never the opaque hex tag.
static string DisplayName( Standing s ) =>
!string.IsNullOrWhiteSpace( s.Username ) ? s.Username : "anonymous";
// The "current leader" for the skin caption: the sessions-won #1, falling back
// to the live session leader, then to a placeholder when nobody has scored.
string SkinLeaderName()
{
if ( _sessionsWon.Count > 0 ) return DisplayName( _sessionsWon[0] );
var session = SessionEntries;
if ( session.Count > 0 ) return DisplayName( session[0] );
return "Nobody";
}
// Whether a winner-lock time is configured at all (0 = unset → hide countdown).
bool HasWinnerLock => _winnerLockMs > 0;
static long NowMs() => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
// True once the winner-lock instant has passed (config loaded and now ≥ it).
bool WinnerLockPassed() => HasWinnerLock && NowMs() >= _winnerLockMs;
// Time left until the winner locks in, as HH:MM:SS (clamped at zero). Pure
// integer math on epoch-ms — no DateTime parsing (sandbox-safe).
string WinnerCountdown()
{
long left = _winnerLockMs - NowMs();
if ( left < 0 ) left = 0;
long s = left / 1000;
return $"{s / 3600:D2}:{(s % 3600) / 60:D2}:{s % 60:D2}";
}
// Time left on the active anticheat sanction (cooldown end, or the bounty resolve
// time for "ignored"), as HH:MM:SS clamped at zero — same integer epoch-ms math
// as the winner countdown (sandbox-safe).
string SanctionCountdown()
{
long left = ( C?.SanctionUntilMs ?? 0 ) - NowMs();
if ( left < 0 ) left = 0;
long s = left / 1000;
return $"{s / 3600:D2}:{(s % 3600) / 60:D2}:{s % 60:D2}";
}
// A click on a board button, addressed by its slot id (NOT a captured button
// object): resolve the button currently live at that slot and score it via its nonce.
// Slots are never reused within a round, so a stale render-closure firing an old slot
// simply resolves to null and no-ops here — it can never send a consumed nonce at a
// button that's already been claimed/replaced. No teleport; the board is server-placed.
void OnPressSlot( ushort slot )
{
var btn = ClickController.Instance?.FindButton( slot );
if ( btn == null )
return; // already claimed/replaced (or a stale closure) — clean miss, nothing sent
ClickController.Instance?.SendButtonClick( btn );
StateHasChanged();
}
// ── opponent pips (read from ClickController, rendered + faded by CSS) ──
static readonly List<ClickController.PipButton> NoPips = new();
IReadOnlyList<ClickController.PipButton> PipsToShow() => ClickController.Instance?.ActivePips ?? NoPips;
// ── opponent cursors (read from ClickController, rendered as labelled dots) ──
static readonly List<ClickController.CursorDot> NoCursors = new();
IReadOnlyList<ClickController.CursorDot> CursorsToShow() => ClickController.Instance?.Cursors ?? NoCursors;
// A cheap hash of the live board + opponent cursors so BuildHash re-renders when a
// button is claimed/spawned or a cursor moves (positions quantized to ~box-% steps).
int BoardHash()
{
var c = C;
if ( c == null ) return 0;
var h = new HashCode();
h.Add( c.LiveButtons.Count );
foreach ( var b in c.LiveButtons ) h.Add( b.Slot );
h.Add( c.Cursors.Count );
foreach ( var cur in c.Cursors )
{
h.Add( (int)(cur.X * 200f) );
h.Add( (int)(cur.Y * 200f) );
}
return h.ToHashCode();
}
// Normalized −1..1 position → box left/top percentage (0..100), clamped so an
// off-screen pip pins to the box edge rather than overflowing. Integer math keeps
// the inline style locale-safe (no culture-sensitive float formatting).
static int PipPct( float n ) => (int)Math.Clamp( ( n + 1f ) / 2f * 100f, 0f, 100f );
// The live clicks-remaining counter, floored at 0 (a late tick can't show negative).
static int RemainingShown( ClickController c ) => Math.Max( 0, c.RemainingThisRound );
// ── music panel toggle ──
// The settings board is the Skafinity library's own SkafinityMusicPanel (the green
// drop-in), mounted on its OWN ScreenPanel GameObject — a PanelComponent can't be a
// sibling child of this HUD's single root (see CLAUDE.md ScreenPanel note). It owns its
// own opener (the wide "MUSIC" bar — ShowFab/WideFab/FabLabel in the scene) and its own
// close, so the HUD never toggles it; it only READS IsOpen to stop painting the game
// while the board is up. The optional inspector ref is just a fast-path; we fall back to
// a scene lookup so a blank field can't leave the HUD blind to the board's state.
[Property] public SkafinityMusicPanel MusicBoard { get; set; }
SkafinityMusicPanel Board => MusicBoard ??= Scene?.GetAllComponents<SkafinityMusicPanel>().FirstOrDefault();
// "Open" = the panel's board is showing. MusicOpen feeds BuildHash, so the panel's own
// open/close (which flips IsOpen) re-renders the HUD and shows/hides the game beneath it.
bool MusicOpen => Board?.IsOpen ?? false;
string PhaseText() => C?.Phase switch
{
GamePhase.Armed => "CLICK!",
GamePhase.Pending => "WAIT…",
GamePhase.Waiting => "STAND BY…",
GamePhase.Result => "ROUND OVER",
GamePhase.GameOver => "GAME OVER",
GamePhase.Disconnected => "RECONNECTING…",
GamePhase.Connecting => "CONNECTING…",
_ => "",
};
string PhaseClass() => C?.Phase switch
{
GamePhase.Armed => "armed",
GamePhase.Disconnected => "offline",
_ => "",
};
// Include the seconds-remaining so the countdown re-renders as it ticks, and
// ToastVisible so the copied-link toast appears and clears itself on time.
protected override int BuildHash() => HashCode.Combine(
HashCode.Combine( C?.Phase ?? GamePhase.Connecting, C?.Round ?? 0, C?.Of ?? 0,
C?.Players ?? 0, C?.ClicksToWin ?? 0, C?.ClicksSent ?? 0 ),
HashCode.Combine( SessionEntries.Count, _hourly.Count, _hourlyLoaded, _hoursWon.Count, _hoursWonLoaded,
C?.ArmMaxSec ?? 0, C?.Tag, (int)(DateTime.UtcNow - DateTime.UtcNow.Date).TotalSeconds ),
HashCode.Combine( _sessionsWon.Count, _sessionsWonLoaded, ToastVisible, SkinLeaderName(), MusicOpen,
_skinUrl, _winnerLockMs, HashCode.Combine( WinnerLockPassed(), _hasBounty ) ),
HashCode.Combine( _allTime.Count, _allTimeLoaded,
C?.HasTest ?? false, C?.TestPrompt ),
_howtoOpen,
// sanction overlay state (the per-second countdown tick is already covered by
// the seconds term in the second combine above); plus the parked/away state — and
// the deferred park-pending / resuming flags — so the PAUSE bar + parked overlay
// repaint the instant any of them flips.
HashCode.Combine( C?.TestMessage, C?.SanctionState, C?.SanctionUntilMs,
C?.Parked ?? false, C?.ParkPending ?? false, C?.Resuming ?? false ),
// round-scores flash: FlashVisible flips the panel off on time; round + count
// rebuild it when a new round's scores come up.
HashCode.Combine( FlashVisible, _flashRound, _flashWinners.Count ),
// live-window tick: the descending counter (changes ~tick-rate while armed) and
// the on-screen pip count (a pip spawning/expiring changes it) drive re-render;
// plus the previous-winner panel state (which bounty, and whether its skin has
// resolved) so it repaints when a rollover/refresh loads a new one.
HashCode.Combine( C?.RemainingThisRound ?? 0, PipsToShow().Count,
_prevBounty?.Id ?? 0L, _prevSkin != null, _prevSkinUrl, BoardHash() ) );
}
Game
game
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace Splitclicker.Api;
// POST /api/v1/auth response: the public tag/username plus a single-use WS ticket
// (mint immediately before connecting; expires after ttl_ms).
// Username is the *claimed* handle only (empty when the player never set one) —
// persist/re-send only this, never DisplayName, or the Steam name gets bounced
// back as a username and 422s on reconnect. DisplayName is the resolved string
// to show (claimed handle, else Steam name, else hex tag).
public record AuthResponse(
[property: JsonPropertyName( "tag" )] string Tag,
[property: JsonPropertyName( "username" )] string Username,
[property: JsonPropertyName( "display_name" )] string DisplayName,
[property: JsonPropertyName( "ticket" )] string Ticket,
[property: JsonPropertyName( "ttl_ms" )] long TtlMs
);
// GET /api/v1/config response: server-driven bits the client needs at startup.
// WinnerLockMs is the countdown target as Unix epoch milliseconds (0 = unset) —
// a plain number so no date parsing is needed (the s&box sandbox doesn't
// whitelist System.Globalization). SkinUrl is the server path the current
// "skin to win" image is served from (made absolute against BaseUrl).
// InspectLink is the active bounty's CS2 inspect link, or "" when the skin is an
// uploaded image only. When set, the client decodes it locally and renders the
// live float / seed / name / wear bar, falling back to SkinUrl's image on failure.
// HasBounty is false when no bounty is active — the WinnerLockMs/SkinUrl are then the
// host config.json/env fallback (a stale "old" skin) and the bounty-scoped boards have
// fallen back to all-time, so the client shows a "no active bounty" state instead.
public record ConfigResponse(
[property: JsonPropertyName( "winner_lock_ms" )] long WinnerLockMs,
[property: JsonPropertyName( "skin_url" )] string SkinUrl,
[property: JsonPropertyName( "inspect_link" )] string InspectLink,
[property: JsonPropertyName( "has_bounty" )] bool HasBounty
);
// GET /api/v1/bounties/previous response row: a settled bounty + its winner, for
// the "previous winner" panel. SkinUrl is the per-bounty image fallback (made
// absolute against BaseUrl); InspectLink, when set, is decoded locally for the
// live skin render exactly like the current bounty. WinnerTag matches the client's
// own tag when the local player won (so the panel can say "you"); WinnerSteamId
// opens the profile; WonAtMs is epoch ms (no date parsing in the sandbox).
public record PreviousBounty(
[property: JsonPropertyName( "id" )] long Id,
[property: JsonPropertyName( "label" )] string Label,
[property: JsonPropertyName( "skin_url" )] string SkinUrl,
[property: JsonPropertyName( "inspect_link" )] string InspectLink,
[property: JsonPropertyName( "winner_tag" )] string WinnerTag,
[property: JsonPropertyName( "winner_steam_id" )] string WinnerSteamId,
[property: JsonPropertyName( "winner_name" )] string WinnerName,
[property: JsonPropertyName( "winner_wins" )] int WinnerWins,
[property: JsonPropertyName( "won_at_ms" )] long WonAtMs
);
// One row of a leaderboard (GET /api/v1/leaderboard/*) and of the standings
// embedded in round_result / game_over. SteamId is the public SteamID64, used to
// open/copy the player's steamcommunity.com profile.
public record Standing(
[property: JsonPropertyName( "tag" )] string Tag,
[property: JsonPropertyName( "username" )] string Username,
[property: JsonPropertyName( "points" )] int Points,
[property: JsonPropertyName( "steam_id" )] string SteamId,
// Anticheat status for the active bounty: "live" / "cooldown" / "ignored".
// Drives the coloured status dot on every board row. Empty ⇒ treated as live.
[property: JsonPropertyName( "status" )] string Status,
// BehindMs is how many ms this player's tie-deciding click landed after the
// player ranked just above them with the SAME points — "how much they lost the
// game by". Only the final game_over standings carry it (the end-of-game tie is
// the one that matters); 0/absent ⇒ top of a tie group or a unique score.
[property: JsonPropertyName( "behind_ms" )] int BehindMs
);
// --- WebSocket server→client frames (the "t" field selects the shape) ---
public record HelloYou(
[property: JsonPropertyName( "tag" )] string Tag,
[property: JsonPropertyName( "username" )] string Username
);
public record HelloGame(
[property: JsonPropertyName( "round" )] int Round,
[property: JsonPropertyName( "of" )] int Of,
[property: JsonPropertyName( "phase" )] string Phase,
[property: JsonPropertyName( "players" )] int Players,
[property: JsonPropertyName( "clicks" )] int Clicks,
[property: JsonPropertyName( "arm_min" )] int ArmMin,
[property: JsonPropertyName( "arm_max" )] int ArmMax,
[property: JsonPropertyName( "dev_note" )] string DevNote,
// Live-window tick interval in ms (0 = ticking off). Sizes the pip jitter-buffer
// playback delay so opponent pips replay at their true relative moment.
[property: JsonPropertyName( "tick_ms" )] int TickMs
);
public record HelloMsg(
[property: JsonPropertyName( "you" )] HelloYou You,
[property: JsonPropertyName( "game" )] HelloGame Game
);
// roster is the full {tag → username} map of connected players (v5+ only), sent at
// the arming stage so opponent pips — which carry only a 4-byte tag — can be
// labelled with a name. Absent on older servers/clients (null).
public record RosterEntry(
[property: JsonPropertyName( "tag" )] string Tag,
[property: JsonPropertyName( "username" )] string Username
);
public record PendingMsg(
[property: JsonPropertyName( "round" )] int Round,
[property: JsonPropertyName( "of" )] int Of,
[property: JsonPropertyName( "players" )] int Players,
[property: JsonPropertyName( "clicks" )] int Clicks,
[property: JsonPropertyName( "roster" )] List<RosterEntry> Roster
);
// One live button in the v5 armed board: id is the compact slot handle (referenced
// by tick claim events), nonce the secret a scoring click on it must echo, x/y its
// server-placed normalized position (int16, 0 = centre). Same shape the tick frame's
// spawned replacements carry.
public record ButtonMsg(
[property: JsonPropertyName( "id" )] int Id,
[property: JsonPropertyName( "nonce" )] string Nonce,
[property: JsonPropertyName( "x" )] int X,
[property: JsonPropertyName( "y" )] int Y
);
// buttons is the initial multi-button board; each button carries a hex nonce (an
// unguessable 64-bit token) echoed back verbatim in the click frame — never
// parse/reformat it. (The old per-connection arm-delay penalty_ms was gutted in v7.)
public record ArmedMsg(
[property: JsonPropertyName( "round" )] int Round,
[property: JsonPropertyName( "seq" )] int Seq,
[property: JsonPropertyName( "buttons" )] List<ButtonMsg> Buttons,
[property: JsonPropertyName( "players" )] int Players,
[property: JsonPropertyName( "clicks" )] int Clicks
);
public record YouResult(
[property: JsonPropertyName( "points_delta" )] int PointsDelta,
[property: JsonPropertyName( "round_id" )] string RoundId
);
public record RoundResultMsg(
[property: JsonPropertyName( "round" )] int Round,
[property: JsonPropertyName( "of" )] int Of,
[property: JsonPropertyName( "winners" )] List<Standing> Winners,
[property: JsonPropertyName( "standings" )] List<Standing> Standings,
[property: JsonPropertyName( "you" )] YouResult You
);
// points_delta/round_id carry the FINAL round's score (the last round folds into
// game_over with no round_result of its own) so the client can drive its `points`
// stat once, guarded by round_id like a normal round.
public record YouGameOver(
[property: JsonPropertyName( "placement" )] int Placement,
[property: JsonPropertyName( "won" )] bool Won,
[property: JsonPropertyName( "game_id" )] string GameId,
[property: JsonPropertyName( "points_delta" )] int PointsDelta,
[property: JsonPropertyName( "round_id" )] string RoundId
);
public record GameOverMsg(
[property: JsonPropertyName( "standings" )] List<Standing> Standings,
[property: JsonPropertyName( "you" )] YouGameOver You
);
// note is the host-editable broadcast message (orange line under the throttle);
// empty clears it. Pushed once per game and carried in hello for mid-game joiners.
public record DevNoteMsg(
[property: JsonPropertyName( "note" )] string Note
);
// An anticheat frame pushed to this client after it failed end-of-round checks.
// state is the ladder rung:
// "test" — answer prompt (echoing id) before the server will arm us again.
// "cooldown" — sidelined until until_ms (a timed cooldown); no test to answer.
// "ignored" — sidelined until until_ms (the bounty's resolve time); no test.
// message is the player-facing explanation for every state; until_ms is the epoch
// ms the cooldown/ignored state ends (the client shows a countdown to it).
// cleared=true means we're back in play — dismiss any overlay. (an empty state is
// treated as "test".)
public record TestMsg(
[property: JsonPropertyName( "state" )] string State,
[property: JsonPropertyName( "id" )] string Id,
[property: JsonPropertyName( "kind" )] string Kind,
[property: JsonPropertyName( "prompt" )] string Prompt,
[property: JsonPropertyName( "message" )] string Message,
[property: JsonPropertyName( "until_ms" )] long UntilMs,
[property: JsonPropertyName( "cleared" )] bool Cleared
);
// ident is a server-pushed manual achievement unlock for an out-of-band feat
// (e.g. poking the backend into a 404, fumbling the admin password); it must
// match an achievement defined in the s&box project.
public record AchievementMsg(
[property: JsonPropertyName( "ident" )] string Ident
);
// on=true parks us (auto-park off an afk_idle verdict); the server only ever sends
// on:true (unpark is the client's call). Drives the parked/away state.
public record ParkMsg(
[property: JsonPropertyName( "on" )] bool On
);
Game
game
using System;
using System.Threading.Tasks;
using Sandbox;
namespace Skafinity;
/// <summary>
/// Streams an endless, deterministic procedural ska / reggae-rock track (see
/// <see cref="MusicGen"/>) through Web Audio-style scheduling over a <see cref="SoundStream"/>.
///
/// Drop this <see cref="Component"/> on any GameObject. It generates a ~80s loop from the
/// seed <c>tag:n</c>, plays it <see cref="LoopsPerSong"/> times, then equal-power crossfades
/// into the pre-generated next song (<c>tag:n+1</c>), forever. Every generator knob is an
/// inspector <c>[Property]</c>; with <see cref="LiveReload"/> on, tweaking one regenerates
/// after a short settle so you can dial in a vibe in play mode.
///
/// A whole song is just its seed, so the arrangement is shareable: copy <see cref="CurrentSeed"/>
/// (<c>vibe:tag:n</c>) and anyone who calls <see cref="PlaySeed"/> with it hears the same track.
///
/// This is a self-contained extraction of the Rotaliate music engine with no game-specific
/// dependencies (no player data, networking, or UI). Persistence of the song index is opt-in
/// via <see cref="PersistProgress"/>.
/// </summary>
public sealed class SkafinityPlayer : Component
{
// ── Master ──
/// <summary>Music master switch. 'new' so it's distinct from <see cref="Component.Enabled"/>.</summary>
[Property, Group( "Music" )] public new bool Enabled { get; set; } = true;
[Property, Group( "Music" ), Range( 0f, 2f )] public float Volume { get; set; } = 0.7f;
/// <summary>Regenerate automatically a moment after any generator knob changes (editor tuning).</summary>
[Property, Group( "Music" )] public bool LiveReload { get; set; } = true;
/// <summary>Optional mixer name to route the music to (e.g. "Music"). Empty = default mixer.</summary>
[Property, Group( "Music" )] public string MixerName { get; set; } = "";
/// <summary>Begin playing automatically in <see cref="OnStart"/>. Off = call <see cref="StartSequence"/> yourself.</summary>
[Property, Group( "Music" )] public bool AutoPlay { get; set; } = true;
/// <summary>Shuffle mode: re-randomise every knob (incl. genre + volumes) as each new song
/// begins, so the sequence keeps reinventing itself. Off = the seed's vibe stays put.</summary>
[Property, Group( "Music" )] public bool RandomEverySong { get; set; } = false;
// ── Seed ──
/// <summary>Seed tag — any string (a name, a word). Empty falls back to "skafinity".</summary>
[Property, Group( "Seed" )] public string Tag { get; set; } = "";
/// <summary>Song index in the infinite sequence (0,1,2…). <see cref="StepN"/>/<see cref="NextSong"/> walk it.</summary>
[Property, Group( "Seed" )] public int StartN { get; set; } = 0;
/// <summary>Optional base-36 vibe override (see <see cref="VibeCodec"/>). When set it overrides
/// the matching inspector knobs, so a shared vibe reproduces the same voicing on any client.</summary>
[Property, Group( "Seed" )] public string Vibe { get; set; } = "";
/// <summary>Persist the current song index across sessions (FileSystem.Data, keyed by <see cref="SaveSlot"/>).</summary>
[Property, Group( "Seed" )] public bool PersistProgress { get; set; } = false;
[Property, Group( "Seed" )] public string SaveSlot { get; set; } = "default";
// ── Output ──
[Property, Group( "Output" ), Range( 8000, 48000 )] public int SampleRate { get; set; } = 32000;
/// <summary>Target track length; bar count adapts to tempo to hit this.</summary>
[Property, Group( "Output" ), Range( 30f, 180f )] public float TargetSeconds { get; set; } = 80f;
/// <summary>Worker threads the pitched-voice synthesis is split across (composition + drums
/// stay single-threaded). Keeps each worker burst under s&box's ~1000ms no-yield advisory.</summary>
[Property, Group( "Output" ), Range( 1, 8 )] public int RenderThreads { get; set; } = 6;
// ── Crossfade / scheduling ──
/// <summary>Crossfade window (also the first song's fade-in from silence), seconds. The two
/// songs only both-audible for <see cref="CrossfadeOverlap"/> of this, centred.</summary>
[Property, Group( "Crossfade" ), Range( 0.5f, 8f )] public float Crossfade { get; set; } = 3.75f;
[Property, Group( "Crossfade" ), Range( 0f, 1f )] public float CrossfadeOverlap { get; set; } = 0.5f;
/// <summary>How many times each loop plays before crossfading on (2 = play through, loop once, switch).</summary>
[Property, Group( "Crossfade" ), Range( 1, 4 )] public int LoopsPerSong { get; set; } = 2;
/// <summary>How many upcoming songs to keep pre-generated (built one-per-tick so the fill never stalls a frame).</summary>
[Property, Group( "Crossfade" ), Range( 1, 8 )] public int AheadCount { get; set; } = 5;
// ── Tempo (main = laid-back reggae-rock; Fast = uptempo ska) ──
[Property, Group( "Tempo" ), Range( 60, 200 )] public int BpmMin { get; set; } = 130;
[Property, Group( "Tempo" ), Range( 60, 200 )] public int BpmMax { get; set; } = 185;
[Property, Group( "Tempo" ), Range( 0f, 1f )] public float FastChance { get; set; } = 0.30f;
[Property, Group( "Tempo" ), Range( 100, 220 )] public int FastBpmMin { get; set; } = 150;
[Property, Group( "Tempo" ), Range( 100, 220 )] public int FastBpmMax { get; set; } = 168;
[Property, Group( "Tempo" ), Range( 0f, 0.4f )] public float Swing { get; set; } = 0.14f;
[Property, Group( "Tempo" ), Range( 0f, 0.4f )] public float FastSwing { get; set; } = 0.05f;
// ── Mix ──
[Property, Group( "Mix" ), Range( 0f, 1.5f )] public float BassVol { get; set; } = 1.00f;
[Property, Group( "Mix" ), Range( 0f, 1.5f )] public float SkankVol { get; set; } = 1.00f;
[Property, Group( "Mix" ), Range( 0f, 1.5f )] public float OrganVol { get; set; } = 1.00f;
[Property, Group( "Mix" ), Range( 0f, 1.5f )] public float MelodyVol { get; set; } = 1.00f;
[Property, Group( "Mix" ), Range( 0f, 1.5f )] public float HornVol { get; set; } = 1.00f;
[Property, Group( "Mix" ), Range( 0f, 1.5f )] public float KickVol { get; set; } = 1.00f;
[Property, Group( "Mix" ), Range( 0f, 1.5f )] public float SnareVol { get; set; } = 0.70f;
[Property, Group( "Mix" ), Range( 0f, 1.5f )] public float TomVol { get; set; } = 0.60f;
[Property, Group( "Mix" ), Range( 0f, 1.5f )] public float HatVol { get; set; } = 0.22f;
[Property, Group( "Mix" ), Range( 0f, 1.5f )] public float CrashVol { get; set; } = 0.35f;
[Property, Group( "Mix" ), Range( 0f, 1.5f )] public float DrumVol { get; set; } = 1.00f;
// ── Tone ──
[Property, Group( "Tone" ), Range( 0f, 40f )] public float Detune { get; set; } = 14f;
[Property, Group( "Tone" ), Range( 80f, 1200f )] public float BassCutoff { get; set; } = 380f;
[Property, Group( "Tone" ), Range( 500f, 8000f )] public float SkankCutoff { get; set; } = 3000f;
[Property, Group( "Tone" ), Range( 0f, 2000f )] public float SkankHighpass { get; set; } = 500f;
[Property, Group( "Tone" ), Range( 0.15f, 1f )] public float SkankChop { get; set; } = 0.5f;
[Property, Group( "Tone" ), Range( 500f, 8000f )] public float LeadCutoff { get; set; } = 3200f;
[Property, Group( "Tone" ), Range( 500f, 8000f )] public float OrganCutoff { get; set; } = 1400f;
[Property, Group( "Tone" ), Range( 0f, 12f )] public float OrganVibrato { get; set; } = 5.5f;
[Property, Group( "Tone" ), Range( 500f, 8000f )] public float HornCutoff { get; set; } = 3200f;
[Property, Group( "Tone" ), Range( 0.2f, 2f )] public float Resonance { get; set; } = 1.0f;
[Property, Group( "Tone" ), Range( 1f, 4f )] public float BassDrive { get; set; } = 1.5f;
[Property, Group( "Tone" ), Range( 1f, 4f )] public float SkankDrive { get; set; } = 1.3f;
[Property, Group( "Tone" ), Range( 1f, 4f )] public float MelodyDrive { get; set; } = 1.3f;
[Property, Group( "Tone" ), Range( 1f, 4f )] public float HornDrive { get; set; } = 1.4f;
[Property, Group( "Tone" ), Range( 0.5f, 3f )] public float MasterDrive { get; set; } = 1.1f;
[Property, Group( "Tone" ), Range( 0.2f, 1f )] public float MasterPeak { get; set; } = 0.95f;
// ── Feel ──
[Property, Group( "Feel" ), Range( 0f, 1f )] public float OctavePopChance { get; set; } = 0.30f;
[Property, Group( "Feel" ), Range( 0f, 1f )] public float OrganBubbleChance { get; set; } = 0.55f;
[Property, Group( "Feel" ), Range( 0f, 1f )] public float KickSyncChance { get; set; } = 0.25f;
[Property, Group( "Feel" ), Range( 0f, 1f )] public float GhostSnareChance { get; set; } = 0.35f;
[Property, Group( "Feel" ), Range( 0f, 1f )] public float FillChance { get; set; } = 0.6f;
[Property, Group( "Feel" ), Range( 0f, 1f )] public float DrumBusy { get; set; } = 0.6f;
[Property, Group( "Feel" ), Range( 0f, 1f )] public float DrumTone { get; set; } = 0.5f;
[Property, Group( "Feel" ), Range( 0f, 1f )] public float DrumDrive { get; set; } = 0.5f;
[Property, Group( "Feel" ), Range( 0f, 0.2f )] public float TripletChance { get; set; } = 0.06f;
[Property, Group( "Feel" ), Range( 0f, 0.1f )] public float BassTriplets { get; set; } = 0.06f;
[Property, Group( "Feel" ), Range( 0f, 1f )] public float MelodyRestChance { get; set; } = 0.30f;
[Property, Group( "Feel" ), Range( 0f, 1f )] public float MelodyLeapChance { get; set; } = 0.18f;
[Property, Group( "Feel" ), Range( 0f, 12f )] public float MelodyVibrato { get; set; } = 5.0f;
// ── Stereo ──
[Property, Group( "Stereo" ), Range( 0f, 1f )] public float PanAmount { get; set; } = 0.4f;
// ── Lead instrument (RNG picks one per tag, weighted; Force overrides) ──
[Property, Group( "Instrument" ), Range( 0f, 4f )] public float TrumpetWeight { get; set; } = 1.0f;
[Property, Group( "Instrument" ), Range( 0f, 4f )] public float SaxWeight { get; set; } = 1.0f;
[Property, Group( "Instrument" ), Range( 0f, 4f )] public float OrganWeight { get; set; } = 0.8f;
[Property, Group( "Instrument" ), Range( 0f, 4f )] public float TromboneWeight { get; set; } = 0.4f;
/// <summary>-1 = RNG; 0=Trumpet 1=Sax 2=Organ 3=Trombone.</summary>
[Property, Group( "Instrument" ), Range( -1, 3 )] public int ForceInstrument { get; set; } = -1;
// ── Backing horns ──
[Property, Group( "Horns" ), Range( 0f, 1f )] public float HornSectionChance { get; set; } = 0.5f;
[Property, Group( "Horns" ), Range( 0f, 1f )] public float HornDensity { get; set; } = 0.35f;
// ── Genre & rock instruments ──
// Genre selects the instrument set: 0 = Ska, 1 = Rock (drums/bass/rhythm-gtr/lead-gtr).
[Property, Group( "Genre" ), Range( 0, 1 )] public int Genre { get; set; } = 0;
// KEYS — the offbeat-chord comp (was the "rhythm guitar"; it reads as keys).
[Property, Group( "Rock" ), Range( 0f, 1.5f )] public float KeysVol { get; set; } = 1.00f;
[Property, Group( "Rock" ), Range( 500f, 8000f )] public float KeysCutoff { get; set; } = 1700f;
[Property, Group( "Rock" ), Range( 1f, 5f )] public float KeysDrive { get; set; } = 3.2f;
[Property, Group( "Rock" ), Range( 0f, 1f )] public float KeysChug { get; set; } = 0.5f;
// RHYTHM GTR — twangy distorted power chords (shares the lead voice, lower base distortion).
[Property, Group( "Rock" ), Range( 0f, 1.5f )] public float RhythmGtrVol { get; set; } = 1.00f;
[Property, Group( "Rock" ), Range( 500f, 8000f )] public float RhythmGtrCutoff { get; set; } = 2600f;
[Property, Group( "Rock" ), Range( 1f, 5f )] public float RhythmGtrDrive { get; set; } = 2.8f;
[Property, Group( "Rock" ), Range( 0f, 1f )] public float RhythmGtrChug { get; set; } = 0.5f;
[Property, Group( "Rock" ), Range( 0f, 1.5f )] public float LeadGtrVol { get; set; } = 1.00f;
[Property, Group( "Rock" ), Range( 500f, 8000f )] public float LeadGtrCutoff { get; set; } = 2600f;
[Property, Group( "Rock" ), Range( 1f, 5f )] public float LeadGtrDrive { get; set; } = 3.6f;
[Property, Group( "Rock" ), Range( 0f, 1f )] public float LeadGtrBend { get; set; } = 0.30f;
SoundStream _stream;
SoundHandle _handle;
int _sr;
short[] _curRaw; // current song, full single loop (raw, for export)
readonly System.Collections.Generic.List<short[]> _ahead = new(); // pre-generated n+1, n+2, …
int _curN; // index of the currently-playing song
// Per-instrument volumes, keyed by voice NAME (BASS, DRUMS, …) so the level follows the
// instrument across genres. Pulled out of the vibe seed; persisted to FileSystem.Data and
// overlaid onto every BuildConfig. See VibeCodec.ReadVolumes/ApplyVolumes.
System.Collections.Generic.Dictionary<string, float> _vols = new();
// Shared house-mix config (peak balances / kit presence) read from the addon's
// skafinity.config.json — the SAME file the web toy uses. Overlaid onto every BuildConfig.
System.Collections.Generic.Dictionary<string, float> _houseConfig = new();
int _curReserve; // samples of the current song's tail held back for the crossfade
double _pushedSeconds; // total audio pushed to the stream
TimeSince _sinceStart; // wall clock since playback started
int _lastConfigHash;
bool _dirty;
TimeSince _dirtySince;
bool _starting; // StartSequenceAsync is in flight
bool _fillingAhead; // FillAhead is in flight
bool Generating => _starting || _fillingAhead;
int _seq; // bumped on each StartSequence; stale async results are discarded
bool _flatConfigured; // ConfigureFlat applied to the live handle
bool _restartPending; // a debounced restart (vibe edit) is queued
TimeSince _restartPendingSince;
/// <summary>Currently-playing song index.</summary>
public int N => _curN;
/// <summary>The effective vibe — the override if set, else the encoded inspector knobs.</summary>
public string CurrentVibe => VibeCodec.Encode( BuildConfig() );
/// <summary>Shareable seed for the playing song: <c>vibe:tag:n</c>.</summary>
public string CurrentSeed => $"{CurrentVibe}:{SeedTag}:{_curN}";
/// <summary>True once a stream handle is live and audible.</summary>
public bool IsPlaying => _handle != null;
string SeedTag => string.IsNullOrEmpty( Tag ) ? "" : Tag;
// Build the PRNG seed string from a resolved tag, so worker code never re-reads state.
static string SeedFor( string tag, int n ) => $"{(string.IsNullOrEmpty( tag ) ? "skafinity" : tag.ToLowerInvariant())}:{n}";
string Seed( int n ) => SeedFor( SeedTag, n );
protected override void OnStart()
{
_lastConfigHash = ConfigHash();
_curN = Math.Max( 0, PersistProgress ? LoadN() ?? StartN : StartN );
_vols = LoadVols();
_houseConfig = LoadHouseConfig();
if ( AutoPlay ) StartSequence();
}
protected override void OnDestroy()
{
_seq++; // invalidate any in-flight worker generation
_handle?.Stop();
_handle = null;
_stream = null;
}
protected override void OnUpdate()
{
if ( _handle != null )
_handle.Volume = TargetVolume();
// Keep the look-ahead buffer topped up. Generation runs on a worker thread so this
// never blocks the frame.
if ( !Generating && _curRaw != null && _ahead.Count < Math.Max( 1, AheadCount ) )
_ = FillAhead( _seq );
// When the queued audio is about to run out, crossfade into the next song.
if ( _stream != null && _ahead.Count > 0 && _curRaw != null
&& _pushedSeconds - _sinceStart < 2.0 )
PushTransition();
int h = ConfigHash();
if ( h != _lastConfigHash )
{
_lastConfigHash = h;
_dirty = true;
_dirtySince = 0;
}
if ( _dirty && LiveReload && !Generating && _dirtySince > 0.5f )
{
_dirty = false;
StartSequence();
}
// Debounced restart for vibe edits: only regenerate once edits have settled.
if ( _restartPending && !Generating && _restartPendingSince > 0.35f )
{
_restartPending = false;
StartSequence();
}
}
/// <summary>Make the stream play as flat 2D (SpacialBlend=0, parented to the camera with
/// FollowParent so the listener can't pan/attenuate it). Optionally routes to a named mixer.</summary>
void ConfigureFlat()
{
if ( _handle == null || _flatConfigured ) return;
_handle.SpacialBlend = 0f;
var camGo = Scene?.Camera?.GameObject;
if ( camGo.IsValid() )
{
_handle.Parent = camGo;
_handle.FollowParent = true;
}
if ( !string.IsNullOrEmpty( MixerName ) )
{
var mixer = Sandbox.Audio.Mixer.FindMixerByName( MixerName );
if ( mixer != null )
_handle.TargetMixer = mixer;
}
_flatConfigured = true;
}
float TargetVolume() => Enabled ? Volume : 0f;
/// <summary>The config currently in effect (inspector knobs with any <see cref="Vibe"/> applied).</summary>
public MusicGen.Config EffectiveConfig() => BuildConfig();
MusicGen.Config BuildConfig()
{
var cfg = BuildKnobConfig();
// Shared house-mix baseline (peak balances / kit presence) from skafinity.config.json —
// the same file the web toy reads. Independent of the vibe/volume knobs below.
VibeCodec.ApplyAdvanced( _houseConfig, cfg );
// A vibe override sets the important knobs (so a shared vibe:tag:n reproduces the same
// voicing regardless of this client's inspector knobs).
if ( !string.IsNullOrEmpty( Vibe ) )
VibeCodec.Apply( Vibe, cfg );
// Per-instrument volumes are NOT in the seed — overlay the persisted per-voice mix on top.
VibeCodec.ApplyVolumes( cfg.Genre, _vols, cfg );
return cfg;
}
MusicGen.Config BuildKnobConfig() => new()
{
SampleRate = SampleRate,
TargetSeconds = TargetSeconds,
BpmMin = BpmMin,
BpmMax = BpmMax,
FastChance = FastChance,
FastBpmMin = FastBpmMin,
FastBpmMax = FastBpmMax,
Swing = Swing,
FastSwing = FastSwing,
BassVol = BassVol,
SkankVol = SkankVol,
OrganVol = OrganVol,
MelodyVol = MelodyVol,
HornVol = HornVol,
KickVol = KickVol,
SnareVol = SnareVol,
TomVol = TomVol,
HatVol = HatVol,
CrashVol = CrashVol,
DrumVol = DrumVol,
Detune = Detune,
BassCutoff = BassCutoff,
SkankCutoff = SkankCutoff,
SkankHighpass = SkankHighpass,
SkankChop = SkankChop,
LeadCutoff = LeadCutoff,
OrganCutoff = OrganCutoff,
OrganVibrato = OrganVibrato,
HornCutoff = HornCutoff,
Resonance = Resonance,
BassDrive = BassDrive,
SkankDrive = SkankDrive,
MelodyDrive = MelodyDrive,
HornDrive = HornDrive,
MasterDrive = MasterDrive,
MasterPeak = MasterPeak,
OctavePopChance = OctavePopChance,
OrganBubbleChance = OrganBubbleChance,
KickSyncChance = KickSyncChance,
GhostSnareChance = GhostSnareChance,
FillChance = FillChance,
DrumBusy = DrumBusy,
TripletChance = TripletChance,
BassTriplets = BassTriplets,
MelodyRestChance = MelodyRestChance,
MelodyLeapChance = MelodyLeapChance,
MelodyVibrato = MelodyVibrato,
PanAmount = PanAmount,
TrumpetWeight = TrumpetWeight,
SaxWeight = SaxWeight,
OrganWeight = OrganWeight,
TromboneWeight = TromboneWeight,
ForceInstrument = ForceInstrument,
HornSectionChance = HornSectionChance,
HornDensity = HornDensity,
Genre = Genre,
DrumTone = DrumTone,
DrumDrive = DrumDrive,
KeysVol = KeysVol,
KeysCutoff = KeysCutoff,
KeysDrive = KeysDrive,
KeysChug = KeysChug,
RhythmGtrVol = RhythmGtrVol,
RhythmGtrCutoff = RhythmGtrCutoff,
RhythmGtrDrive = RhythmGtrDrive,
RhythmGtrChug = RhythmGtrChug,
LeadGtrVol = LeadGtrVol,
LeadGtrCutoff = LeadGtrCutoff,
LeadGtrDrive = LeadGtrDrive,
LeadGtrBend = LeadGtrBend,
};
int ConfigHash()
{
var h = new HashCode();
h.Add( SampleRate ); h.Add( TargetSeconds );
h.Add( BpmMin ); h.Add( BpmMax ); h.Add( FastChance );
h.Add( FastBpmMin ); h.Add( FastBpmMax ); h.Add( Swing ); h.Add( FastSwing );
h.Add( BassVol ); h.Add( SkankVol ); h.Add( OrganVol ); h.Add( MelodyVol ); h.Add( HornVol );
h.Add( KickVol ); h.Add( SnareVol ); h.Add( TomVol ); h.Add( HatVol ); h.Add( CrashVol ); h.Add( DrumVol );
h.Add( Detune ); h.Add( BassCutoff ); h.Add( SkankCutoff ); h.Add( SkankHighpass ); h.Add( SkankChop );
h.Add( LeadCutoff ); h.Add( OrganCutoff ); h.Add( OrganVibrato ); h.Add( HornCutoff ); h.Add( Resonance );
h.Add( BassDrive ); h.Add( SkankDrive ); h.Add( MelodyDrive ); h.Add( HornDrive );
h.Add( MasterDrive ); h.Add( MasterPeak );
h.Add( OctavePopChance ); h.Add( OrganBubbleChance ); h.Add( KickSyncChance );
h.Add( GhostSnareChance ); h.Add( FillChance );
h.Add( DrumBusy ); h.Add( DrumTone ); h.Add( DrumDrive ); h.Add( TripletChance ); h.Add( BassTriplets );
h.Add( MelodyRestChance ); h.Add( MelodyLeapChance ); h.Add( MelodyVibrato );
h.Add( PanAmount );
h.Add( TrumpetWeight ); h.Add( SaxWeight ); h.Add( OrganWeight ); h.Add( TromboneWeight );
h.Add( ForceInstrument );
h.Add( HornSectionChance ); h.Add( HornDensity );
h.Add( Genre );
h.Add( KeysVol ); h.Add( KeysCutoff ); h.Add( KeysDrive ); h.Add( KeysChug );
h.Add( RhythmGtrVol ); h.Add( RhythmGtrCutoff ); h.Add( RhythmGtrDrive ); h.Add( RhythmGtrChug );
h.Add( LeadGtrVol ); h.Add( LeadGtrCutoff ); h.Add( LeadGtrDrive ); h.Add( LeadGtrBend );
h.Add( Tag ); h.Add( Vibe );
return h.ToHashCode();
}
// Run the (pure, CPU-heavy) synthesis on worker threads so it never blocks the frame AND so
// no single worker burst runs long enough to trip s&box's ~1000ms no-yield advisory.
// Composition + drum synthesis are RNG-bound and stay sequential (BeginPlan, one worker); the
// pitched voices pull no RNG, so they fan out across RenderThreads disjoint windows joined by
// Task.WhenAll; the master+interleave runs on one worker. Result is interleaved stereo PCM.
async Task<short[]> GenerateStereoAsync( string seedStr, MusicGen.Config cfg )
{
MusicGen g = null;
await GameTask.RunInThreadAsync( () => { g = MusicGen.BeginPlan( seedStr, cfg ); return Task.CompletedTask; } );
int total = g.TotalSamples;
int k = Math.Clamp( RenderThreads, 1, 8 );
if ( k <= 1 )
{
await GameTask.RunInThreadAsync( () => { g.RenderPitchedRange( 0, total ); return Task.CompletedTask; } );
}
else
{
var jobs = new Task[k];
for ( int i = 0; i < k; i++ )
{
int from = (int)((long)total * i / k);
int to = (int)((long)total * (i + 1) / k);
jobs[i] = GameTask.RunInThreadAsync( () => { g.RenderPitchedRange( from, to ); return Task.CompletedTask; } );
}
await Task.WhenAll( jobs );
}
short[] pcm = null;
await GameTask.RunInThreadAsync( () => { pcm = g.FinishStereo(); return Task.CompletedTask; } );
_sr = g.SampleRate;
return pcm;
}
int FadeFrames => Math.Max( 1, (int)(Math.Clamp( Crossfade, 0.25f, 8f ) * _sr) );
// All look-ahead buffers are interleaved stereo PCM; lengths/offsets below are in frames.
static int Frames( short[] pcm ) => pcm.Length / MusicGen.Channels;
/// <summary>Top the look-ahead buffer up to <see cref="AheadCount"/>, generating each song on
/// a worker thread. Fire-and-forget from OnUpdate; <paramref name="seq"/> guards against a
/// sequence restart landing a stale song in the buffer.</summary>
async Task FillAhead( int seq )
{
if ( Generating ) return;
try
{
_fillingAhead = true;
string tag = SeedTag;
var cfg = BuildConfig();
while ( seq == _seq && _curRaw != null && _ahead.Count < Math.Max( 1, AheadCount ) )
{
int n = _curN + 1 + _ahead.Count;
var song = await GenerateStereoAsync( SeedFor( tag, n ), cfg );
if ( seq != _seq ) return; // sequence restarted while we were generating
_ahead.Add( song );
}
}
catch ( Exception e ) { Log.Warning( $"SkafinityPlayer: FillAhead failed: {e.Message}" ); }
finally { _fillingAhead = false; }
}
/// <summary>Write <see cref="LoopsPerSong"/> passes of interleaved-stereo <paramref name="raw"/>
/// to the stream, given the first <paramref name="headConsumed"/> frames of pass 0 were already
/// emitted, holding back the final <paramref name="reserve"/> frames for the next crossfade.
/// Optional fade-in over the first <paramref name="fadeIn"/> frames of pass 0. Returns frames
/// written.</summary>
int WriteSongBody( short[] raw, int headConsumed, int reserve, int fadeIn )
{
const int ch = MusicGen.Channels;
int loops = Math.Max( 1, LoopsPerSong );
int rawFrames = raw.Length / ch;
int total = 0;
for ( int loop = 0; loop < loops; loop++ )
{
int start = loop == 0 ? headConsumed : 0;
int end = loop == loops - 1 ? rawFrames - reserve : rawFrames;
if ( end <= start ) continue;
int len = end - start;
var seg = new short[len * ch];
for ( int i = 0; i < len; i++ )
{
int frame = start + i;
float g = (loop == 0 && fadeIn > 0 && frame < fadeIn) ? (float)frame / fadeIn : 1f;
for ( int c = 0; c < ch; c++ )
seg[i * ch + c] = (short)(raw[frame * ch + c] * g);
}
_stream.WriteData( seg );
total += len;
}
return total;
}
/// <summary>(Re)start the infinite sequence at the current tag/n. Bumps the sequence token
/// (invalidating any in-flight generation), stops the current handle, then kicks the async
/// (worker-thread) start so the caller never blocks.</summary>
public void StartSequence()
{
int seq = ++_seq;
_ahead.Clear();
_handle?.Stop();
_handle = null;
_stream = null;
_flatConfigured = false;
_curRaw = null;
_ = StartSequenceAsync( seq );
}
// The first song fades in; thereafter songs play LoopsPerSong passes and crossfade into the
// pre-generated next. Synthesis is offloaded; stream setup is on the main thread.
async Task StartSequenceAsync( int seq )
{
try
{
_starting = true;
int n = Math.Max( 0, _curN );
string tag = SeedTag;
var cfg = BuildConfig();
var raw = await GenerateStereoAsync( SeedFor( tag, n ), cfg );
if ( seq != _seq ) return; // superseded by a newer StartSequence
_curN = n;
_curRaw = raw;
int fade = Math.Min( FadeFrames, Frames( _curRaw ) / 3 );
_curReserve = fade;
_stream = new SoundStream( _sr, MusicGen.Channels );
// First song: LoopsPerSong passes, fade-in over the first `fade`, last `fade` of the
// final pass held back for the crossfade into the next song.
int written = WriteSongBody( _curRaw, 0, _curReserve, fade );
_pushedSeconds = written / (double)_sr;
_handle = _stream.Play();
if ( _handle != null )
{
_handle.Volume = TargetVolume();
ConfigureFlat();
}
_sinceStart = 0;
}
catch ( Exception e )
{
Log.Warning( $"SkafinityPlayer: StartSequence failed: {e.Message}" );
}
finally { _starting = false; }
}
// Queue the crossfade from the current song's tail into the next song's head, then the next
// song's body. Advances n (persisted if enabled); the following song is topped up by OnUpdate.
void PushTransition()
{
try
{
var next = _ahead[0];
_ahead.RemoveAt( 0 );
// Crossfade window = the current song's held-back tail (so there's no gap or overlap
// even when songs differ in length). The two songs only overlap for CrossfadeOverlap
// of this window, centred — the rest plays in the clear.
const int ch = MusicGen.Channels;
int W = Math.Min( _curReserve, Frames( next ) / 3 );
int curStart = Frames( _curRaw ) - W;
int cross = Math.Clamp( (int)(W * CrossfadeOverlap), 1, W );
int ws = (W - cross) / 2; // overlap starts here
int we = ws + cross; // overlap ends here
var xf = new short[W * ch];
for ( int i = 0; i < W; i++ )
{
float gOut, gIn;
if ( i < ws ) { gOut = 1f; gIn = 0f; } // outgoing in the clear
else if ( i >= we ) { gOut = 0f; gIn = 1f; } // incoming in the clear
else
{
double t = (i - ws + 0.5) / cross * (Math.PI / 2); // equal-power cross
gOut = (float)Math.Cos( t );
gIn = (float)Math.Sin( t );
}
for ( int c = 0; c < ch; c++ )
xf[i * ch + c] = (short)Math.Clamp( _curRaw[(curStart + i) * ch + c] * gOut + next[i * ch + c] * gIn, -32768, 32767 );
}
_stream.WriteData( xf );
// next song: LoopsPerSong passes, first W of pass 0 already in the crossfade, last
// `nextReserve` of the final pass held back for the following crossfade.
int nextReserve = Math.Min( FadeFrames, Frames( next ) / 3 );
int written = WriteSongBody( next, W, nextReserve, 0 );
_pushedSeconds += (W + written) / (double)_sr;
_curRaw = next;
_curReserve = nextReserve;
_curN++;
if ( PersistProgress ) SaveN( _curN );
// Shuffle mode: each new song gets a fresh set of vibe knobs (NOT volumes — those are
// a local mix preference, kept out of the seed — and NOT genre, matching the web toy).
// Re-voice WITHOUT a restart (restart: false) — a restart would throw away the song we
// just crossfaded into and replay _curN from scratch, stalling the n+1 progression.
// Instead drop the look-ahead so FillAhead (OnUpdate) regenerates upcoming songs with
// the new vibe, and absorb the Vibe change into the config hash so LiveReload doesn't
// fire its own restart either. The current crossfade keeps playing; n keeps advancing.
if ( RandomEverySong )
{
RerollVibe( restart: false );
_ahead.Clear();
_lastConfigHash = ConfigHash();
}
}
catch ( Exception e )
{
Log.Warning( $"SkafinityPlayer: PushTransition failed: {e.Message}" );
}
}
// ── Public control surface ──
// Parse a shareable seed in any of vibe:tag:n / tag:n / tag. Missing parts stay null.
static void ParseSeed( string seed, out string vibe, out string tag, out int? n )
{
vibe = null; tag = null; n = null;
seed = seed?.Trim();
if ( string.IsNullOrEmpty( seed ) ) return;
var p = seed.Split( ':' );
if ( p.Length >= 3 ) { vibe = p[0]; tag = p[1]; if ( int.TryParse( p[2], out var v ) ) n = v; }
else if ( p.Length == 2 )
{
if ( int.TryParse( p[1], out var v ) ) { tag = p[0]; n = v; }
else if ( VibeCodec.LooksLikeVibe( p[0] ) ) { vibe = p[0]; tag = p[1]; }
else tag = p[0];
}
else tag = p[0];
}
/// <summary>Play a shareable seed in any of the forms <c>vibe:tag:n</c>, <c>tag:n</c>, or
/// <c>tag</c>. Missing components are left unchanged; a vibe is only applied when present.
/// Restarts the sequence.</summary>
public void PlaySeed( string seed )
{
ParseSeed( seed, out string vibe, out string tag, out int? n );
if ( tag != null ) Tag = tag.Trim().ToLowerInvariant();
if ( vibe != null ) Vibe = VibeCodec.LooksLikeVibe( vibe ) ? vibe.ToLowerInvariant() : "";
if ( n.HasValue ) _curN = Math.Max( 0, n.Value );
if ( PersistProgress ) SaveN( _curN );
StartSequence();
}
/// <summary>Set just the seed tag (empty = the default "skafinity" seed). Restarts.</summary>
public void SetTag( string tag )
{
Tag = string.IsNullOrEmpty( tag ) ? "" : tag.Trim().ToLowerInvariant();
StartSequence();
}
/// <summary>Jump to song index n in the sequence (clamped ≥ 0). Restarts.</summary>
public void SetN( int n )
{
_curN = Math.Max( 0, n );
if ( PersistProgress ) SaveN( _curN );
StartSequence();
}
/// <summary>Step the song index by <paramref name="delta"/> (e.g. +1 / -1). Restarts.</summary>
public void StepN( int delta ) => SetN( _curN + delta );
/// <summary>Skip to the next song in the sequence.</summary>
public void NextSong() => StepN( 1 );
/// <summary>Step back to the previous song in the sequence.</summary>
public void PrevSong() => StepN( -1 );
/// <summary>Set vibe field <paramref name="index"/> (see <see cref="VibeCodec.Fields(int)"/>) from
/// a 0..1 fraction, store the re-encoded <see cref="Vibe"/>, and restart on a short debounce.</summary>
public void SetVibe( int index, float norm )
{
var cfg = BuildConfig();
var fields = VibeCodec.Fields( cfg.Genre );
if ( index < 0 || index >= fields.Count ) return;
var f = fields[index];
f.SetNorm( cfg, norm );
if ( VibeCodec.IsVolume( f ) )
{
// Volume is a local mix preference, not part of the seed — store per-voice + persist.
_vols[f.Voice] = norm;
SaveVols();
}
else
{
Vibe = VibeCodec.Encode( cfg );
}
_restartPending = true;
_restartPendingSince = 0;
}
/// <summary>Switch genre (rides in the vibe's first char): re-encode the effective config
/// with the new genre into <see cref="Vibe"/> so it sticks over the inspector knobs, then
/// restart. Use this rather than setting <see cref="Genre"/> directly — an existing
/// <see cref="Vibe"/> override otherwise wins and the change wouldn't take.</summary>
public void SetGenre( int genre )
{
var cfg = BuildConfig();
cfg.Genre = Math.Clamp( genre, 0, VibeCodec.GenreCount - 1 );
Vibe = VibeCodec.Encode( cfg );
StartSequence();
}
/// <summary>Randomize the vibe knobs and restart on a short debounce. By default the
/// per-instrument volumes (and genre) are left alone so a reroll re-voices without upending
/// the mix; pass <paramref name="includeVolumes"/> / <paramref name="includeGenre"/> for a
/// full shuffle. Pass <paramref name="restart"/> = false to re-voice without yanking the
/// playhead — the caller is then responsible for letting the change take effect (e.g. by
/// clearing the look-ahead so upcoming songs regenerate with the new vibe).</summary>
public void RerollVibe( bool includeVolumes = false, bool includeGenre = false, bool restart = true )
{
var cfg = BuildConfig();
var rng = System.Random.Shared;
if ( includeGenre )
cfg.Genre = rng.Next( VibeCodec.GenreCount );
foreach ( var f in VibeCodec.Fields( cfg.Genre ) )
{
if ( !includeVolumes && f.Voice != null && f.Column == 0 ) continue; // skip per-instrument volumes
f.SetNorm( cfg, rng.NextSingle() );
}
if ( cfg.BpmMin > cfg.BpmMax ) (cfg.BpmMin, cfg.BpmMax) = (cfg.BpmMax, cfg.BpmMin);
Vibe = VibeCodec.Encode( cfg );
if ( includeVolumes )
{
// Capture the freshly-randomized volumes into the persisted per-voice store (they
// don't ride in the encoded vibe).
foreach ( var kv in VibeCodec.ReadVolumes( cfg.Genre, cfg ) ) _vols[kv.Key] = kv.Value;
SaveVols();
}
if ( restart )
{
_restartPending = true;
_restartPendingSince = 0;
}
}
/// <summary>Write the playing song's raw loop (no fade) to a WAV under FileSystem.Data.
/// Returns the filename written, or null on failure.</summary>
public string SaveCurrentToFile()
{
if ( _curRaw == null || _sr <= 0 ) return null;
var tag = string.IsNullOrEmpty( SeedTag ) ? "skafinity" : SeedTag.ToLowerInvariant();
var name = $"{tag}_{_curN}.wav";
try
{
FileSystem.Data.WriteAllBytes( name, MusicGen.WavFromSamples( _curRaw, 1, _sr ) );
return name;
}
catch ( Exception e )
{
Log.Warning( $"SkafinityPlayer: save failed: {e.Message}" );
return null;
}
}
// ── Optional progress persistence (FileSystem.Data, see assets/file-system.md) ──
string ProgressFile => $"skafinity_{(string.IsNullOrEmpty( SaveSlot ) ? "default" : SaveSlot)}.n";
void SaveN( int n )
{
try { FileSystem.Data.WriteAllText( ProgressFile, n.ToString() ); }
catch ( Exception e ) { Log.Warning( $"SkafinityPlayer: save progress failed: {e.Message}" ); }
}
int? LoadN()
{
try
{
if ( FileSystem.Data.FileExists( ProgressFile )
&& int.TryParse( FileSystem.Data.ReadAllText( ProgressFile ), out var v ) )
return Math.Max( 0, v );
}
catch ( Exception e ) { Log.Warning( $"SkafinityPlayer: load progress failed: {e.Message}" ); }
return null;
}
// ── Per-instrument volume persistence (FileSystem.Data, keyed by SaveSlot) ──
// Stored as JSON voice→0..1 level, separate from progress and from the (volume-free) seed,
// so the mix is a local preference that survives sessions and follows each voice across genres.
string VolumeFile => $"skafinity_{(string.IsNullOrEmpty( SaveSlot ) ? "default" : SaveSlot)}.vol";
void SaveVols()
{
try { FileSystem.Data.WriteAllText( VolumeFile, Json.Serialize( _vols ) ); }
catch ( Exception e ) { Log.Warning( $"SkafinityPlayer: save volumes failed: {e.Message}" ); }
}
System.Collections.Generic.Dictionary<string, float> LoadVols()
{
try
{
if ( FileSystem.Data.FileExists( VolumeFile ) )
return Json.Deserialize<System.Collections.Generic.Dictionary<string, float>>(
FileSystem.Data.ReadAllText( VolumeFile ) ) ?? new();
}
catch ( Exception e ) { Log.Warning( $"SkafinityPlayer: load volumes failed: {e.Message}" ); }
return new();
}
// ── Shared house-mix config (read-only, shipped with the addon) ──
// The SAME JSON the web toy uses (web/config.json is `make`-copied from the library's
// skafinity.config.json). Its "advanced" block overlays the baseline peak-balance / level
// mix onto every BuildConfig, so the house mix is retuned by editing one file rather than
// recompiling. Read-only addon content → FileSystem.Mounted. See VibeCodec.ApplyAdvanced.
const string HouseConfigFile = "skafinity.config.json";
class HouseConfigDto { public System.Collections.Generic.Dictionary<string, float> advanced { get; set; } }
System.Collections.Generic.Dictionary<string, float> LoadHouseConfig()
{
try
{
if ( FileSystem.Mounted.FileExists( HouseConfigFile ) )
{
var dto = Json.Deserialize<HouseConfigDto>( FileSystem.Mounted.ReadAllText( HouseConfigFile ) );
return dto?.advanced ?? new();
}
}
catch ( Exception e ) { Log.Warning( $"SkafinityPlayer: load house config failed: {e.Message}" ); }
return new();
}
}
Game
game
@using System
@using System.Collections.Generic
@using System.Linq
@using Sandbox
@using Sandbox.UI
@namespace Skafinity
@inherits PanelComponent
@*
The optional drop-in settings panel for the Skafinity music engine.
Add this PanelComponent to a GameObject under a ScreenPanel (or WorldPanel). It finds a
SkafinityPlayer in the scene (or set Player explicitly) and offers the whole knob surface
as UI — you don't have to wire anything. A floating ♪ button toggles the board open/closed.
The engine needs nothing from this: SkafinityPlayer plays on its own. This panel is pure
convenience for players who want to tweak the vibe rather than tune it in the inspector.
The vibe editor is driven entirely from the library's VibeCodec field metadata for the
current genre: each field reports its voice (matrix row, or null for a GLOBAL knob) and
column (0 volume / 1 tone / 2 character / 3 extra). A new genre — or a new knob — is a pure
engine change; there is no field table here. s&box has no slider widget, so each knob is a
strip of tick cells (one per base-36 level the seed encodes); each change re-encodes the
vibe and restarts the player on a short debounce.
Re-theming: the palette lives as SCSS variables at the top of SkafinityMusicPanel.razor.scss.
Override those (or supply your own panel against the same SkafinityPlayer API) to restyle.
*@
<root class="@( IsOpen ? "open" : "" )">
@if ( !IsOpen && ShowFab )
{
<div class="fab @( WideFab ? "wide" : "" )" onclick="@Toggle">@FabLabel</div>
}
else
{
@{ var cfg = Player?.EffectiveConfig(); int genre = cfg?.Genre ?? 0; }
<div class="music">
<div class="header">
<div class="title">MUSIC</div>
<div class="close" onclick="@Toggle">✕</div>
</div>
<div class="divider"></div>
<div class="top">
<div class="row">
<div class="label">NOW PLAYING</div>
<div class="play-row">
<div class="seed-box">@Seed()</div>
<div class="btn" onclick="@CopySeed">@_copyLabel</div>
</div>
</div>
<div class="row">
<div class="label">SONG</div>
<div class="play-row">
<div class="btn wide" onclick="@( () => Step( -1 ) )">◀ Prev</div>
<div class="num">@( Player?.N ?? 0 )</div>
<div class="btn wide" onclick="@( () => Step( 1 ) )">Next ▶</div>
</div>
</div>
<div class="row">
<div class="label">PLAY A SEED</div>
<div class="play-row">
<TextEntry @ref="_tagEntry" placeholder="Paste vibe:tag:n (or a tag — blank = default)" class="tag-input" />
<div class="btn" onclick="@Play">Play</div>
<div class="btn" onclick="@UseDefault">Use default</div>
</div>
</div>
<div class="row">
<div class="label">MUTE</div>
<div class="play-row">
<div class="btn toggle @( Player?.Enabled == false ? "on" : "" )" onclick="@ToggleMute">@( Player?.Enabled == false ? "MUTED" : "PLAYING" )</div>
</div>
</div>
<div class="row">
<div class="label">VOLUME</div>
<div class="cells">
@foreach ( var v in VolumeSteps )
{
var vv = v;
<div class="cell @( ( Player?.Volume ?? 1f ) >= vv - 0.001f ? "filled" : "" )"
onclick="@( () => SetVolume( vv ) )">@VolLabel( vv )</div>
}
</div>
</div>
</div>
<div class="divider"></div>
<div class="row genre-row">
<div class="label">GENRE</div>
<div class="cells">
@for ( int g = 0; g < VibeCodec.GenreCount; g++ )
{
var gg = g;
<div class="cell choice @( g == genre ? "selected" : "" )"
onclick="@( () => SetGenre( gg ) )">@VibeCodec.Genres[g]</div>
}
<div class="btn reroll" onclick="@Reroll">🎲 Reroll</div>
<div class="btn reroll toggle @( Player?.RandomEverySong == true ? "on" : "" )"
onclick="@ToggleRandomEverySong">🎲 Random every song: @( Player?.RandomEverySong == true ? "ON" : "OFF" )</div>
<div class="btn" onclick="@Save">Save .wav</div>
</div>
</div>
<div class="label">VIBE — per-instrument mixer (tweak, then share the seed)</div>
<div class="matrix">
<div class="mrow mhead">
<div class="mvoice"></div>
@foreach ( var h in ColHeaders )
{
<div class="mcell mlabel">@h</div>
}
</div>
@foreach ( var (voice, cells) in InstrumentRows( genre ) )
{
<div class="mrow">
<div class="mvoice">@voice</div>
@for ( int col = 0; col < ColHeaders.Length; col++ )
{
var f = cells[col];
<div class="mcell">
@if ( f != null )
{
@Knob( f, cfg, f.Name == ColHeaders[col] ? "" : f.Name )
}
</div>
}
</div>
}
</div>
<div class="divider"></div>
<div class="label">GLOBAL</div>
<div class="vibe-grid">
@foreach ( var f in GlobalRows( genre ) )
{
<div class="vibe">@Knob( f, cfg, f.Name )</div>
}
</div>
@if ( _msg != null )
{
<div class="hint ok">@_msg</div>
}
</div>
}
</root>
@code
{
/// <summary>The player this panel drives. Leave unset to auto-find a <see cref="SkafinityPlayer"/>
/// in the scene on start.</summary>
[Property] public SkafinityPlayer Player { get; set; }
// One tick cell per base-36 level the seed encodes (VibeCodec.Levels).
static int UiTicks => VibeCodec.Levels;
static readonly string[] ColHeaders = { "VOLUME", "TONE", "CHARACTER", "EXTRA" };
// Volume control steps over the player's 0..2 range.
static readonly float[] VolumeSteps = { 0f, 0.25f, 0.5f, 0.75f, 1f, 1.25f, 1.5f, 1.75f, 2f };
/// <summary>Whether the settings board is showing (toggled by the ♪ button).</summary>
public bool IsOpen { get; private set; }
/// <summary>Show the built-in toggle button when the board is closed. Set false when a host
/// UI provides its own opener and drives the board via <see cref="Toggle"/>.</summary>
[Property] public bool ShowFab { get; set; } = true;
/// <summary>Label on the closed-state toggle button. Defaults to the ♪ glyph; a host can set
/// e.g. "MUSIC" to make it read as a labelled bar (pair with <see cref="WideFab"/>).</summary>
[Property] public string FabLabel { get; set; } = "♪";
/// <summary>Render the toggle button as a wide labelled bar (host chrome) instead of the
/// compact square glyph.</summary>
[Property] public bool WideFab { get; set; }
TextEntry _tagEntry;
bool _tagInit;
string _copyLabel = "Copy";
string _msg;
protected override void OnStart()
{
Player ??= Scene.GetAllComponents<SkafinityPlayer>().FirstOrDefault();
if ( Player == null )
Log.Warning( "SkafinityMusicPanel: no SkafinityPlayer found in the scene — add one (or set Player)." );
}
protected override void OnUpdate()
{
// One-time seed of the text entry with the current tag once the board opens.
if ( !IsOpen ) { _tagInit = false; return; }
if ( !_tagInit && _tagEntry != null ) { _tagEntry.Text = Player?.Tag ?? ""; _tagInit = true; }
}
string Seed() => Player?.CurrentSeed ?? "—";
/// <summary>Open/close the settings board.</summary>
public void Toggle()
{
IsOpen = !IsOpen;
if ( !IsOpen ) _tagInit = false;
}
static string VolLabel( float v ) => v <= 0f ? "0" : $"{(int)Math.Round( v * 100 )}%";
// Instrument rows for the genre: each voice with its [vol, tone, character, extra] cells
// (null where a genre leaves a column empty). Order = the library's display/wire order.
static IEnumerable<(string Voice, VibeCodec.Field[] Cells)> InstrumentRows( int genre )
{
var order = new List<string>();
var byVoice = new Dictionary<string, VibeCodec.Field[]>();
foreach ( var f in VibeCodec.Fields( genre ) )
{
if ( f.Voice == null ) continue;
if ( !byVoice.TryGetValue( f.Voice, out var cells ) )
{
cells = new VibeCodec.Field[ColHeaders.Length];
byVoice[f.Voice] = cells;
order.Add( f.Voice );
}
if ( f.Column >= 0 && f.Column < cells.Length ) cells[f.Column] = f;
}
foreach ( var v in order ) yield return (v, byVoice[v]);
}
static IEnumerable<VibeCodec.Field> GlobalRows( int genre ) =>
VibeCodec.Fields( genre ).Where( f => f.Voice == null );
// Index of a field within the current genre's field list (what Player.SetVibe expects).
static int FieldIndex( int genre, VibeCodec.Field field )
{
var fields = VibeCodec.Fields( genre );
for ( int i = 0; i < fields.Count; i++ )
if ( ReferenceEquals( fields[i], field ) ) return i;
return -1;
}
// One knob: a name/value header over a strip of tick cells (or labeled choice cells).
RenderFragment Knob( VibeCodec.Field f, MusicGen.Config cfg, string label )
{
return @<text>
<div class="vibe-head">
<div class="vibe-name">@label</div>
<div class="vibe-val">@( cfg != null ? f.Display( cfg ) : "" )</div>
</div>
<div class="cells">
@{
int genre = cfg?.Genre ?? 0;
int idx = FieldIndex( genre, f );
}
@if ( f.Choices != null )
{
int sel = cfg != null ? (int)MathF.Round( f.GetNorm( cfg ) * (f.Choices.Length - 1) ) : 0;
@for ( int k = 0; k < f.Choices.Length; k++ )
{
var kk = k; var n = f.Choices.Length;
<div class="cell choice @( k == sel ? "selected" : "" )"
onclick="@( () => SetVibe( idx, kk / (float)(n - 1) ) )">@f.Choices[k]</div>
}
}
else
{
int sel = cfg != null ? (int)MathF.Round( f.GetNorm( cfg ) * (UiTicks - 1) ) : 0;
@for ( int k = 0; k < UiTicks; k++ )
{
var kk = k;
<div class="cell tick @( k <= sel ? "filled" : "" ) @( k == sel ? "selected" : "" )"
onclick="@( () => SetVibe( idx, kk / (float)(UiTicks - 1) ) )"></div>
}
}
</div>
</text>;
}
void Step( int d ) { Player?.StepN( d ); _msg = null; }
void ToggleMute() { if ( Player != null ) Player.Enabled = !Player.Enabled; }
void SetVolume( float v ) { if ( Player != null ) Player.Volume = v; }
void SetVibe( int index, float norm )
{
if ( index < 0 ) return;
Player?.SetVibe( index, norm );
_msg = null;
}
void SetGenre( int genre ) { Player?.SetGenre( genre ); _msg = null; }
void Play()
{
Player?.PlaySeed( _tagEntry?.Text );
_msg = Player != null ? $"Playing {Player.CurrentSeed}" : null;
}
void UseDefault()
{
Player?.SetTag( "" );
if ( _tagEntry != null ) _tagEntry.Text = "";
_msg = "Back to the default tag and vibe";
}
void CopySeed()
{
try { Clipboard.SetText( Seed() ); _copyLabel = "Copied!"; }
catch { _copyLabel = "—"; }
}
void Save()
{
var name = Player?.SaveCurrentToFile();
_msg = string.IsNullOrEmpty( name ) ? "Couldn't save song" : $"Saved {name} to your s&box data folder";
}
void Reroll() { Player?.RerollVibe(); _msg = "Rerolled every knob but the volumes"; }
void ToggleRandomEverySong()
{
if ( Player == null ) return;
bool on = !Player.RandomEverySong;
Player.RandomEverySong = on;
if ( on ) Player.RerollVibe();
_msg = on ? "Shuffle: every new song re-rolls the vibe (keeps your volumes)" : "Shuffle off";
}
protected override int BuildHash() =>
HashCode.Combine(
IsOpen, Player?.CurrentSeed, Player?.CurrentVibe, Player?.Enabled ?? true,
Player?.Volume ?? 1f, Player?.RandomEverySong ?? false, _msg, _copyLabel );
}
Game
game
.button
{
position: relative;
> .button-right-column
{
flex-direction: column;
}
}
// default menu position is below
.button-hover-menu
{
position: absolute;
top: 100%;
flex-direction: column;
&.hidden
{
opacity: 0;
pointer-events: none;
}
}
Game
game
ColorPickerControl
{
flex-direction: column;
flex-shrink: 0;
gap: 0.5rem;
margin: 1rem;
}
Game
game
RootPanel
{
font-family: Roboto;
color: white;
}
IconPanel, i
{
font-family: Material Icons;
background-position: center;
background-size: contain;
}
.tooltip
{
background-color: rgba( black, 0.8 );
border: 1px solid rgba( #555, 0.1 );
padding: 10px;
border-radius: 4px;
color: white;
font-family: Inter;
font-weight: bold;
transition: opacity 0.09s ease;
opacity: 1;
margin: 0px;
&:intro, &:outro
{
opacity: 0;
}
}
.textentry
{
cursor: text;
align-items: center;
justify-content: center;
white-space: nowrap;
overflow: hidden;
flex-direction: row;
position: relative;
padding: 10px;
&.disabled
{
cursor: default;
}
&.is-multiline
{
align-items: flex-start;
white-space: normal;
}
.content-label,
.placeholder
{
flex-grow: 1;
}
.placeholder
{
opacity: 0.2;
pointer-events: none;
}
.iconpanel
{
position: absolute;
right: 0;
padding-right: 10px;
pointer-events: none;
&.clearbutton
{
color: #fff;
pointer-events: all;
cursor: pointer;
opacity: 0.1;
&:hover
{
opacity: 0.5;
}
&:active
{
opacity: 1;
}
}
}
&:empty
{
.clearbutton
{
display: none;
}
}
&.has-icon
{
padding-right: 30px;
}
.content-label,
.prefix-label,
.suffix-label,
.placeholder
{
overflow: hidden;
}
.content-label
{
order: 1;
}
.suffix-label
{
order: 2;
}
.prefix-label,
.suffix-label
{
flex-shrink: 0;
opacity: 0.5;
}
&.invalid
{
box-shadow: 0px 0px 1px 2px #f8356b;
}
}
.popup-panel
{
background-color: #1b202c;
flex-direction: column;
color: #8e9ec2;
transition: all 0.1s ease-out;
position: absolute;
z-index: 2000;
min-height: 20px;
min-width: 10px;
overflow: scroll;
pointer-events: all;
box-shadow: 3px 3px 64px #000;
border-radius: 4px;
&.medium
{
max-width: 300px;
}
&.center-horizontal
{
transform-origin: top center;
}
&.below-center
{
transform: translateX( -50% );
transform-origin: 0% 0%;
}
&.above-left
{
transform-origin: 0% 100%;
}
&.above-center
{
transform: translateX( -50% );
transform-origin: 0% 100%;
}
&.left
{
transform-origin: 100% 50%;
transform: translate( 00% -50% );
}
&.right
{
transform-origin: 0 50%;
transform: translate( 0% -50% );
}
&:intro
{
transform: scale( 0 );
pointer-events: none;
opacity: 0;
}
&:outro
{
opacity: 0;
transition: all 0.1s ease-in;
margin-top: 50px;
pointer-events: none;
}
&.below-stretch
{
transform-origin: 0% 0%;
&:intro
{
transform: scaleY( 0.1 );
}
}
> .canvas
{
flex-direction: column;
}
.information
{
padding: 16px;
font-size: 14px;
opacity: 0.5;
}
button
{
padding: 2px 8px;
cursor: pointer;
font-family: Poppins;
font-weight: 600;
flex-shrink: 0;
&:hover
{
background-color: #08f;
color: #fff;
}
}
}
Game
game
$primary: red !default;
$primary-alt: white !default;
$switch-padding: 6px !default;
.coloreditor
{
flex-direction: column;
min-width: 250px;
max-width: 250px;
> .canvas
{
border-top: 1px solid rgba( white, 0.5 );
flex-grow: 1;
flex-direction: column;
padding: 10px 15px;
> textentry.textentry
{
flex-grow: 0;
flex-shrink: 0;
width: auto;
}
}
.slider
{
padding: 6.5px;
}
.slider .track
{
height: 14px;
border-radius: 14px;
left: 0px;
right: 0px;
transition: all 0.1s ease-out;
.inner
{
display: none;
}
}
.slider:hover, .slider:active
{
padding: 7px;
.track
{
height: 20px;
border-radius: 6px;
}
.thumb
{
width: 10px;
height: 16px;
margin-left: 2px;
}
}
.slider .thumb
{
width: 10px;
height: 10px;
box-shadow: 0px 0px 0px transparent;
background-color: white;
backdrop-filter: invert( 1 );
margin-left: 1px;
pointer-events: none;
}
.alpha_slider .track
{
background-image: linear-gradient( to right, rgba( #ccc, 0.05 ), rgba( #ccc, 0.9 ) );
}
.hue .track
{
background: linear-gradient(to right, #ff0000 0%, #ffff00 17%, #00ff00 33%, #00ffff 50%, #0000ff 67%, #ff00ff 83%, #ff0000 100%);
.inner
{
display: none;
}
}
.satval
{
background: linear-gradient(to right, white, transparent);
height: 200px;
position: relative;
&:active
{
z-index: 10;
cursor: none;
}
.value_gradient
{
background: linear-gradient(to bottom, black, white);
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
pointer-events: none;
mix-blend-mode: multiply;
}
.thumb
{
z-index: 10;
}
}
.presets
{
flex-wrap: wrap;
align-content: center;
justify-content: center;
padding: 15px;
border-top: 1px solid rgba( white, 0.5 );
.preset
{
background-color: white;
width: 16px;
height: 16px;
margin: 2px;
border-radius: 20px;
cursor: pointer;
&:hover
{
box-shadow: 0px 0px 1px 2px white;
}
}
}
}
.coloreditorrgba
{
flex-direction: column;
.slider
{
padding-right: 16px;
}
.slider .track
{
background-color: transparent;
height: 14px;
border-radius: 14px;
left: 0px;
right: 0px;
.inner
{
display: none;
}
}
.slider .thumb
{
width: 12px;
height: 12px;
box-shadow: 0px 0px 0px transparent;
background-color: white;
margin-left: 2px;
backdrop-filter: invert( 1 );
}
.red_slider .track
{
background-image: linear-gradient( to right, rgba( #e63439, 0.1 ), rgba( #e63439, 0.9 ) );
}
.green_slider .track
{
background-image: linear-gradient( to right, rgba( #54be35, 0.1 ), rgba( #54be35, 0.9 ) );
}
.blue_slider .track
{
background-image: linear-gradient( to right, rgba( #3472e6, 0.1 ), rgba( #3472e6, 0.9 ) );
}
.alpha_slider .track
{
background-image: linear-gradient( to right, rgba( #ccc, 0.05 ), rgba( #ccc, 0.9 ) );
}
}
.coloreditorhsva
{
flex-direction: column;
.slider
{
padding-right: 16px;
}
.slider .track
{
background-color: transparent;
height: 14px;
border-radius: 14px;
left: 0px;
right: 0px;
.inner
{
display: none;
}
}
.slider .thumb
{
width: 12px;
height: 12px;
box-shadow: 0px 0px 0px transparent;
background-color: white;
margin-left: 2px;
backdrop-filter: invert( 1 );
}
.hue_slider .track
{
background-image: linear-gradient( to right, #f00, #ff0, #0f0, #0ff, #00f, #f0f, #f00 );
}
.alpha_slider .track
{
background-image: linear-gradient( to right, rgba( #ccc, 0.05 ), rgba( #ccc, 0.9 ) );
}
}
Game
game
menupanel
{
position: absolute;
z-index: 1000;
pointer-events: all;
font-size: 12px;
flex-shrink: 0;
.background
{
position: absolute;
left: -5000px;
right: -5000px;
top: -5000px;
bottom: -5000px;
}
> .inner
{
min-width: 200px;
min-height: 20px;
flex-direction: column;
font-family: Poppins;
font-weight: bold;
border-radius: 10px;
box-shadow: 5px 5px 30px #000e;
background-color: #2a2a2a;
flex-shrink: 0;
.spacer
{
height: 1px;
background-color: #0005;
}
.option
{
color: #fffa;
padding: 0px 8px;
cursor: pointer;
flex-shrink: 0;
height: 32px;
&:first-child
{
border-top-left-radius: 10px;
border-top-right-radius: 10px;
}
&:last-child
{
border-bottom-left-radius: 10px;
border-bottom-right-radius: 10px;
}
.icon
{
padding: 8px;
font-family: Material Icons;
justify-content: center;
align-items: center;
flex-shrink: 0;
}
.text
{
padding: 8px;
flex-shrink: 0;
}
&:hover
{
background-color: #3472e6;
color: #f5f8fe;
}
}
}
}
Game
game
ColorHueControl
{
gap: 0.5rem;
flex-grow: 1;
pointer-events: all;
background: linear-gradient( to right, red, yellow, lime, cyan, blue, magenta, red );
border-radius: 4px;
padding: 2px;
height: 12px;
position: relative;
cursor: pointer;
margin: 10px 0;
border: 1px solid #333;
&:hover
{
border: 1px solid #08f;
}
&:active
{
border: 1px solid #fff;
}
.handle
{
top: -5px;
bottom: -5px;
aspect-ratio: 1;
border-radius: 100px;
border: 2px solid #444;
position: absolute;
background-color: white;
box-shadow: 2px 2px 16px #000a;
transform: translateX( -50% );
pointer-events: none;
}
}
Debug: View Raw JSON Response
{
"TotalCount": 45,
"Files": [
{
"Ident": "gamah.splitclicker",
"Path": "styles/form/_colorproperty.scss",
"FileName": "_colorproperty.scss",
"PackageType": "game",
"CodeKind": "Game",
"AssetVersionId": 301560,
"Code": "\r\n\r\n.colorproperty\r\n{\r\n\talign-items: center;\r\n\tposition: relative;\r\n\r\n\t.colorsquare\r\n\t{\r\n\t\twidth: 20px;\r\n\t\theight: 20px;\r\n\t\tborder-radius: 4px;\r\n\t\tmargin-right: 8px;\r\n\t\tposition: absolute;\r\n\t\tleft: 7px;\r\n\t\tz-index: 1;\r\n\t\tcursor: pointer;\r\n\t}\r\n\r\n\t> .textentry:not( .a.b.c )\r\n\t{\r\n\t\tpadding-left: 36px;\r\n\t}\r\n}"
},
{
"Ident": "gamah.splitclicker",
"Path": "ui/controls/vectorcontrol.cs.scss",
"FileName": "vectorcontrol.cs.scss",
"PackageType": "game",
"CodeKind": "Game",
"AssetVersionId": 301560,
"Code": "VectorControl\r\n{\r\n\tgap: 2px;\r\n\tflex-grow: 1;\r\n}\r\n\r\nVectorControl NumberEntry\r\n{\r\n\tflex-basis: 50%;\r\n}"
},
{
"Ident": "gamah.splitclicker",
"Path": "ui/controlsheet/controlsheetrow.cs.scss",
"FileName": "controlsheetrow.cs.scss",
"PackageType": "game",
"CodeKind": "Game",
"AssetVersionId": 301560,
"Code": "\r\nControlSheetRow\r\n{\r\n\tflex-direction: row;\r\n\tflex-shrink: 0;\r\n\tmax-height: 32px;\r\n\tborder-radius: 4px;\r\n\r\n\t&.hidden\r\n\t{\r\n\t\tdisplay: none;\r\n\t}\r\n}\r\n\r\nControlSheetRow > .left\r\n{\r\n\twidth: 150px;\r\n\tflex-shrink: 0;\r\n\tflex-grow: 0;\r\n\tpadding: 6px;\r\n}\r\n\r\nControlSheetRow > .right\r\n{\r\n\tflex-grow: 1;\r\n}\r\n\r\nControlSheetRow > .left > .title\r\n{\r\n\twidth: 150px;\r\n\tflex-shrink: 0;\r\n\tflex-grow: 0;\r\n\tfont-size: 13px;\r\n\twhite-space: nowrap;\r\n\toverflow: hidden;\r\n}\r\n\r\nControlSheetRow > .right > textentry,\r\nControlSheetRow > .right numberentry\r\n{\r\n\tbackground-color: #000a;\r\n\tflex-grow: 1;\r\n\tpadding: 4px 8px;\r\n\tborder-radius: 2px;\r\n}\r\n\r\n"
},
{
"Ident": "gamah.splitclicker",
"Path": "ui/components/packagelist.razor.scss",
"FileName": "packagelist.razor.scss",
"PackageType": "game",
"CodeKind": "Game",
"AssetVersionId": 301560,
"Code": ".package-list\r\n{\r\n flex-shrink: 1;\r\n flex-wrap: wrap;\r\n flex-grow: 1;\r\n\r\n h1\r\n {\r\n width: 100%;\r\n margin-top: 50px;\r\n font-size: 40px;\r\n }\r\n\r\n PackageCard\r\n {\r\n &:hover\r\n {\r\n sound-in: \"ui.button.over\";\r\n }\r\n }\r\n\r\n VirtualGrid\r\n {\r\n width: 100%;\r\n height: 100%;\r\n\r\n .cell\r\n {\r\n \r\n }\r\n }\r\n}"
},
{
"Ident": "gamah.splitclicker",
"Path": "ui/components/packagefilters.razor.scss",
"FileName": "packagefilters.razor.scss",
"PackageType": "game",
"CodeKind": "Game",
"AssetVersionId": 301560,
"Code": "\r\n@import \"/styles/_theme.scss\";\r\n\r\n.packagefilter\r\n{\r\n\twidth: 100%;\r\n\tflex-shrink: 0;\r\n\r\n\t.left\r\n\t{\r\n\t\tflex-grow: 1;\r\n\t\tflex-shrink: 0;\r\n\t\tgap: 16px;\r\n\t}\r\n\r\n\t.right\r\n\t{\r\n\t\tflex-grow: 1;\r\n\t\tflex-shrink: 0;\r\n\t\tgap: 16px;\r\n\t}\r\n}\r\n\r\n.packagefilter textentry\r\n{\r\n\tflex-grow: 1;\r\n\tpadding: 8px 12px;\r\n\tpointer-events: all;\r\n\r\n\tLabel\r\n\t{\r\n\t\tmargin-left: 26px;\r\n\t}\r\n\r\n\ticonpanel\r\n\t{\r\n\t\tposition: absolute;\r\n\t\tleft: 12px;\r\n\t\topacity: 0.3;\r\n\t\tfont-size: $text-large;\r\n\t}\r\n}\r\n\r\n\t.packagefilterfacet, .packagefilter textentry\r\n\t{\r\n\t\tbackground-color: rgba($default-950, 0.3);\r\n\t\tcolor: $default-text;\r\n\t\toutline: 1px solid $default-border;\r\n\t\ttransition: all 150ms $easing;\r\n\t\tborder-radius: $rounding-default;\r\n\t\tpadding: 4px 12px;\r\n\t\tgap: 16px;\r\n\t\tflex-shrink: 0;\r\n\t\tfont-size: 14px;\r\n\t\tcursor: pointer;\r\n\t\talign-items: center;\r\n\r\n\t\t.clear-button\r\n\t\t{\r\n\t\t\theight: 100%;\r\n\t\t\tmargin-right: -8px;\r\n\t\t\talign-items: center;\r\n\t\t\tpadding: 0px 4px;\r\n\r\n\t\t\t&:hover\r\n\t\t\t{\r\n\t\t\t\tcolor: red;\r\n\t\t\t\tborder-radius: 100%;\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\t&.is-active\r\n\t\t{\r\n\t\t\tbackground-color: $default-700;\r\n\t\t}\r\n\r\n\t\t&:not(.is-active)\r\n\t\t{\r\n\t\t\t&:hover\r\n\t\t\t{\r\n\t\t\t\toutline: 1px solid $default-50;\r\n\t\t\t}\r\n\t\t}\r\n\t}"
},
{
"Ident": "gamah.splitclicker",
"Path": "ui/components/packagecard.razor.scss",
"FileName": "packagecard.razor.scss",
"PackageType": "game",
"CodeKind": "Game",
"AssetVersionId": 301560,
"Code": "@import \"/styles/_theme.scss\";\r\n$background-size: 8px;\r\n\r\nPackageCard\r\n{\r\n\tflex-shrink: 0;\r\n\r\n\t&:hover\r\n\t{\r\n\t\tsound-in: \"ui.button.over\";\r\n\t}\r\n\r\n\t&:active\r\n\t{\r\n\t\tsound-in: \"ui.button.press\";\r\n\t}\r\n\r\n\tflex-direction: column;\r\n\tposition: relative;\r\n\tbackground-color: rgba( $default-950, 0 );\r\n\tborder-radius: $rounding-default;\r\n\ttransition: all 150ms ease;\r\n\tcursor: pointer;\r\n\tz-index: 0;\r\n\theight: 200px;\r\n\r\n\t.image\r\n\t{\r\n\t\tflex-grow: 1;\r\n\t\tflex-shrink: 0;\r\n\t\ttransition: all 150ms ease;\r\n\t\tborder: 1px solid rgba( white, 0.025 );\r\n\t\taspect-ratio: 16 / 9;\r\n\t\tbackground-position: center;\r\n\t\tbackground-size: cover;\r\n\t\tborder-radius: $rounding-small;\r\n\t\tposition: relative;\r\n\t}\r\n\r\n\tcolumn\r\n\t{\r\n\t\tpadding: 8px 2px; // Optically aligned\r\n\t}\r\n\r\n\t\r\n\r\n\t&.list\r\n\t{\r\n\t\tflex-grow: 1;\r\n\t\theight: 64px;\r\n\t\tflex-direction: row;\r\n\t\tgap: 12px;\r\n\t\tpadding: 4px;\r\n\r\n\t\t.inner column\r\n\t\t{\r\n\t\t\tflex-grow: 1;\r\n\t\t}\r\n\r\n\t\t.image\r\n\t\t{\r\n\t\t\theight: 100%;\r\n\t\t\taspect-ratio: 1;\r\n\t\t\tflex-grow: 0;\r\n\t\t\tflex-shrink: 0;\r\n\t\t}\r\n\r\n\t\tcolumn\r\n\t\t{\r\n\t\t\tgap: 3px;\r\n\t\t\tjustify-content: center;\r\n\t\t}\r\n\r\n\t\t.package-title\r\n\t\t{\r\n\t\t\tflex-shrink: 0;\r\n\t\t\tmax-width: 500px;\r\n\t\t}\r\n\r\n\t\t.package-users\r\n\t\t{\r\n\t\t\ttop: 1px;\r\n\t\t\tright: 1px;\r\n\t\t}\r\n\r\n\t\t&:hover\r\n\t\t{\r\n\t\t\tbackground-color: $default-800;\r\n\t\t\ttransform: none;\r\n\r\n\t\t\t&::after\r\n\t\t\t{\r\n\t\t\t\tdisplay: none;\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n\r\n\t&.wide\r\n\t{\r\n\t\theight: 250px;\r\n\r\n\t\t.package-title\r\n\t\t{\r\n\t\t\tmax-width: 300px;\r\n\t\t}\r\n\t}\r\n\r\n\t&.small\r\n\t{\r\n\t\theight: 200px;\r\n\t\taspect-ratio: 3/4;\r\n\r\n\t\t.image\r\n\t\t{\r\n\t\t\taspect-ratio: 1;\r\n\t\t}\r\n\r\n\t\t.package-title\r\n\t\t{\r\n\t\t\tmax-width: 140px;\r\n\t\t}\r\n\t}\r\n\r\n\t&.tall\r\n\t{\r\n\t\theight: 400px;\r\n\r\n\t\t.image\r\n\t\t{\r\n\t\t\taspect-ratio: 9/16;\r\n\t\t}\r\n\r\n\t\t.package-title\r\n\t\t{\r\n\t\t\tmax-width: 200px;\r\n\t\t}\r\n\t}\r\n\r\n\t.package-title\r\n\t{\r\n\t\ttext-overflow: ellipsis;\r\n\t\tmax-height: 24px;\r\n\t\tflex-shrink: 1;\r\n\t\tfont-size: 14px;\r\n\t}\r\n\r\n\t.package-users\r\n\t{\r\n\t\tposition: absolute;\r\n\t\tbottom: 4px;\r\n\t\tright: 4px;\r\n\t\tbackground-color: rgba( 10, 40, 10, 0.95 );\r\n\t\tpadding: 3px 5px;\r\n\t\tborder-radius: 2px;\r\n\t\tjustify-content: center;\r\n\t\talign-items: center;\r\n\t\tgap: 3px;\r\n\t\tfont-size: 11px;\r\n\t\tcolor: #def;\r\n\t\tborder: 1px solid #252;\r\n\t\tcolor: #2f3;\r\n\r\n\t\t&:before\r\n\t\t{\r\n\t\t\tcontent: '\u25cf';\r\n\t\t\tfont-size: 0.5rem;\r\n\t\t}\r\n\t}\r\n\t// Hover effect (not for list)\r\n\t&:not(.list)\r\n\t{\r\n\t\t&::after\r\n\t\t{\r\n\t\t\tcontent: \"\";\r\n\t\t\tposition: absolute;\r\n\t\t\ttop: 0;\r\n\t\t\tleft: 0;\r\n\t\t\tbottom: 0;\r\n\t\t\tright: 0;\r\n\t\t\tbackground-color: rgba( $default-950, 0 );\r\n\t\t\ttransition: all 150ms ease;\r\n\t\t\tz-index: -10;\r\n\t\t\tborder-radius: $rounding-large;\r\n\t\t\tpointer-events: none;\r\n\t\t}\r\n\r\n\t\t&:hover\r\n\t\t{\r\n\t\t\ttransform: scale( 1.05 );\r\n\r\n\t\t\t&::after\r\n\t\t\t{\r\n\t\t\t\tbackground-color: $default-800;\r\n\t\t\t\ttop: -$background-size;\r\n\t\t\t\tleft: -$background-size;\r\n\t\t\t\tright: -$background-size;\r\n\t\t\t\tbottom: -$background-size;\r\n\t\t\t\tbox-shadow: 0 0 25px rgba( black, 0.3 );\r\n\t\t\t}\r\n\r\n\t\t\tz-index: 100;\r\n\t\t\tbackground-color: $default-900;\r\n\t\t}\r\n\t}\r\n}\r\n\r\n\r\n.package-card.list packageflairbar\r\n{\r\n\tdisplay: none;\r\n}\r\n\r\n.package-card.list .package-users\r\n{\r\n\tdisplay: none;\r\n}"
},
{
"Ident": "gamah.splitclicker",
"Path": "ui/controls/color/colorsaturationvaluecontrol.cs.scss",
"FileName": "colorsaturationvaluecontrol.cs.scss",
"PackageType": "game",
"CodeKind": "Game",
"AssetVersionId": 301560,
"Code": "ColorSaturationValueControl\r\n{\r\n\twidth: 240px;\r\n\theight: 240px;\r\n\tbackground-color: red;\r\n\tposition: relative;\r\n\tborder-radius: 4px;\r\n\tcursor: pointer;\r\n\tborder: 1px solid #333;\r\n\r\n\t&:hover\r\n\t{\r\n\t\tborder: 1px solid #08f;\r\n\t}\r\n\r\n\t&:active\r\n\t{\r\n\t\tborder: 1px solid #fff;\r\n\t}\r\n\r\n\t.handle\r\n\t{\r\n\t\twidth: 16px;\r\n\t\theight: 16px;\r\n\t\tborder-radius: 100px;\r\n\t\tborder: 2px solid #444;\r\n\t\tposition: absolute;\r\n\t\tbackground-color: white;\r\n\t\tbox-shadow: 2px 2px 16px #000a;\r\n\t\ttransform: translateX( -50% ) translateY( -50% );\r\n\t\tpointer-events: none;\r\n\t\tz-index: 100;\r\n\t\tz-index: 100;\r\n\t}\r\n\r\n\t.gradient\r\n\t{\r\n\t\tposition: absolute;\r\n\t\twidth: 100%;\r\n\t\theight: 100%;\r\n\t\tborder-radius: 4px;\r\n\t\tbackground: linear-gradient( to right, white, rgba( 255, 255, 255, 0 ) );\r\n\r\n\t\t&:after\r\n\t\t{\r\n\t\t\tcontent: \"\";\r\n\t\t\tposition: absolute;\r\n\t\t\twidth: 100%;\r\n\t\t\theight: 100%;\r\n\t\t\tborder-radius: 4px;\r\n\t\t\tbackground: linear-gradient( to top, black, rgba( 0, 0, 0, 0 ) );\r\n\t\t}\r\n\t}\r\n}\r\n"
},
{
"Ident": "gamah.splitclicker",
"Path": "Audio/SoundPlayer.cs",
"FileName": "SoundPlayer.cs",
"PackageType": "game",
"CodeKind": "Game",
"AssetVersionId": 301560,
"Code": "using Sandbox;\n\nnamespace Splitclicker.Audio;\n\n// Thin wrapper over the five UI sound effects (see scripts/gen_sounds.py). Every voice\n// is a square + triangle blend around G; together they form the round's audio motif:\n// arming heartbeat \u2192 armed \"go\" \u2192 click blips \u2192 disarm bookend, with throttle as the\n// \"nope\" for clicking while the button is dormant. Driven from ClickController.\npublic static class SoundPlayer\n{\n\tpublic static void PlayArming() => Sound.Play( \"sounds/arming.sound\" );\n\tpublic static void PlayArmed() => Sound.Play( \"sounds/armed.sound\" );\n\tpublic static void PlayClick() => Sound.Play( \"sounds/click.sound\" );\n\tpublic static void PlayDisarm() => Sound.Play( \"sounds/disarm.sound\" );\n\tpublic static void PlayThrottle() => Sound.Play( \"sounds/throttle.sound\" );\n\n\t// An opponent's scoring click during the live window: the click blip a major\n\t// fifth up (3:2 ratio \u21d2 Pitch 1.5) at half volume, so the field of other\n\t// players' clicks reads as related-but-subordinate to your own click.\n\tpublic static void PlayPip()\n\t{\n\t\tvar h = Sound.Play( \"sounds/click.sound\" );\n\t\tif ( h != null && h.IsValid )\n\t\t{\n\t\t\th.Pitch = 1.5f;\n\t\t\th.Volume = 0.5f;\n\t\t}\n\t}\n}\n"
},
{
"Ident": "gamah.splitclicker",
"Path": "Game/AchievementTracker.cs",
"FileName": "AchievementTracker.cs",
"PackageType": "game",
"CodeKind": "Game",
"AssetVersionId": 301560,
"Code": "using System;\nusing Sandbox;\n\nnamespace Splitclicker.Game;\n\n// Maps server-pushed `you.*` facts to s&box Services (Stats + Achievements).\n// The server is the source of truth; we just record. Stat increments are NOT\n// idempotent, so guard them with the round_id/game_id the server stamps; manual\n// Unlock calls are idempotent and need no guard (PLAN \u00a77.1).\n//\n// Named *Tracker to avoid colliding with Sandbox.Services.Achievements.\npublic static class AchievementTracker\n{\n\t// round_result \u2192 bump the `points` stat once per unseen round. Stat-threshold\n\t// achievements (first_point, points_50, points_100) then fire automatically.\n\tpublic static void OnRoundResult( int pointsDelta, string roundId )\n\t{\n\t\tif ( pointsDelta <= 0 || string.IsNullOrEmpty( roundId ) ) return;\n\n\t\t// \"Ahead of the Curve\": more than 5 scoring clicks in a single round. Manual\n\t\t// Unlock is idempotent, so it's safe to attempt on every delivery (no round_id\n\t\t// guard needed) \u2014 unlike the `points` increment below.\n\t\tif ( pointsDelta > 5 ) Unlock( \"aotc\" );\n\n\t\tvar pd = PlayerData.Load();\n\t\tif ( pd.LastPointsRoundId == roundId ) return; // duplicate delivery / reconnect replay\n\t\tpd.LastPointsRoundId = roundId;\n\t\tpd.Save();\n\t\ttry { Sandbox.Services.Stats.Increment( \"points\", pointsDelta ); }\n\t\tcatch ( Exception e ) { Log.Warning( $\"[Splitclicker] points stat failed: {e.Message}\" ); }\n\t}\n\n\t// game_over \u2192 placement achievements (idempotent Unlock) + the `wins` stat\n\t// once per unseen game (drives first_win / wins_5 / wins_10).\n\tpublic static void OnGameOver( int placement, bool won, string gameId )\n\t{\n\t\tif ( placement >= 1 && placement <= 5 ) Unlock( \"top_5\" );\n\t\tif ( placement >= 1 && placement <= 3 ) Unlock( \"top_3\" );\n\t\t// \"Chicken Dinner\": win a session (finish #1 in the game's final standings).\n\t\tif ( won ) Unlock( \"firstwin\" );\n\n\t\tif ( !won || string.IsNullOrEmpty( gameId ) ) return;\n\t\tvar pd = PlayerData.Load();\n\t\tif ( pd.LastWinGameId == gameId ) return;\n\t\tpd.LastWinGameId = gameId;\n\t\tpd.Save();\n\t\ttry { Sandbox.Services.Stats.Increment( \"wins\", 1 ); }\n\t\tcatch ( Exception e ) { Log.Warning( $\"[Splitclicker] wins stat failed: {e.Message}\" ); }\n\t}\n\n\t// Out-of-band achievement push from the server (the `achievement` WS frame):\n\t// fire the named unlock straight through. Used for feats the server detects off\n\t// the game socket \u2014 `fart` (poked the backend into a 404) and `hackerman`\n\t// (fumbled the admin password) \u2014 matched back to this client by IP. The Unlock\n\t// is idempotent, so a repeat push is a harmless no-op.\n\tpublic static void OnAchievement( string ident )\n\t{\n\t\tif ( string.IsNullOrEmpty( ident ) ) return;\n\t\tUnlock( ident );\n\t}\n\n\t// Idempotent achievement unlock with a console trace. Manual unlocks give no\n\t// visible feedback in the editor (the ident must be defined on the published\n\t// package to actually pop), so the log line is how we confirm it fired.\n\tstatic void Unlock( string ident )\n\t{\n\t\tLog.Info( $\"[Splitclicker] fired achievement {ident}\" );\n\t\ttry { Sandbox.Services.Achievements.Unlock( ident ); }\n\t\tcatch ( Exception e ) { Log.Warning( $\"[Splitclicker] unlock {ident} failed: {e.Message}\" ); }\n\t}\n}\n"
},
{
"Ident": "gamah.splitclicker",
"Path": "MusicGen.cs",
"FileName": "MusicGen.cs",
"PackageType": "game",
"CodeKind": "Game",
"AssetVersionId": 301560,
"Code": "using System;\nusing System.Collections.Generic;\n\nnamespace Skafinity;\n\n/// <summary>\n/// Deterministic procedural ska / reggae-rock track generator (issue: procedural music).\n///\n/// A player's 8-hex <c>player_tag</c> seeds a portable PRNG (xmur3 \u2192 mulberry32);\n/// the PRNG drives every musical choice (tempo, key, progression, bass / skank /\n/// organ / lead / drum patterns) so the SAME tag always produces the SAME song.\n/// Output is interleaved stereo 16-bit PCM (for SoundStream) or a WAV (debug).\n///\n/// PORTABILITY (web parity): the *composition* is whatever the PRNG emits \u2014 a web\n/// client reproduces the same arrangement by mirroring xmur3 + mulberry32 and this\n/// file's note-selection order, using the same <see cref=\"Config\"/> values. Synthesis\n/// (oscillators/filters below) only needs matching for bit-identical audio.\n///\n/// Synthesis: subtractive \u2014 unison-detuned oscillators through a resonant low-pass\n/// state-variable filter with a cutoff envelope (warm, not \"8-bit\"); full synth drum\n/// kit (kick/snare/toms/hats/crash + fills). Default voicing aims for a Sublime vibe:\n/// laid-back reggae-rock tempo, bass-forward, prominent clean skank + organ bubble.\n/// </summary>\npublic sealed class MusicGen\n{\n\tpublic sealed class Config\n\t{\n\t\t// Genre \u2014 selects the instrument set + arrangement. 0 = Ska (bass/skank/organ/lead/\n\t\t// horns/drums), 1 = Rock (drums/bass/keys/lead-gtr/rhythm-gtr), 2 = Country, 3 = Metal,\n\t\t// 4 = Punk (lean power-pop), 5 = Pop (synth/dance). New genres append here.\n\t\tpublic int Genre = 0;\n\n\t\t// Output\n\t\tpublic int SampleRate = 44100;\n\t\tpublic float TargetSeconds = 80f; // (legacy) song length now follows the structure\n\t\tpublic int Bars = 64; // fallback if TargetSeconds <= 0\n\n\t\t// Tempo \u2014 main is laid-back reggae-rock; Fast is an uptempo ska feel.\n\t\tpublic int BpmMin = 130, BpmMax = 185;\n\t\tpublic float FastChance = 0.30f;\n\t\tpublic int FastBpmMin = 150, FastBpmMax = 168;\n\t\tpublic float Swing = 0.14f, FastSwing = 0.05f;\n\n\t\t// Mix (per-voice gain pre-master) \u2014 the six \"volume\" sliders are normalized:\n\t\t// same 0..1.5 range and the same 1.0 default (flat mix), tune from there.\n\t\tpublic float BassVol = 1.00f;\n\t\tpublic float SkankVol = 1.00f;\n\t\tpublic float OrganVol = 1.00f;\n\t\tpublic float MelodyVol = 1.00f;\n\t\tpublic float HornVol = 1.00f;\n\t\t// Per-part kit trims. The kit is balanced for EQUAL PEAK LEVEL in the file internally\n\t\t// (see *Balance consts below), so these knobs all share a 1.0 default \u2014 every piece\n\t\t// peaks at the same level out of the box and these are pure user trims around that.\n\t\tpublic float KickVol = 1.00f;\n\t\tpublic float SnareVol = 1.00f;\n\t\tpublic float TomVol = 1.00f;\n\t\tpublic float HatVol = 1.00f;\n\t\tpublic float CrashVol = 1.00f;\n\t\tpublic float DrumVol = 1.00f; // master gain over the whole kit\n\t\t// Drum \"tone\" \u2014 toms\u2194cymbals bias for the PART. 0 = boom (fills/decoration lean to\n\t\t// toms, cymbals pulled back), 0.5 = neutral, 1 = bright (fills/decoration lean to\n\t\t// cymbals, toms pulled back). Drives both what's played and a gentle per-voice gain lean.\n\t\tpublic float DrumTone = 0.5f;\n\t\t// Drum \"drive\" \u2014 pull\u2194push timing feel (replaces the old DrumPush magnitude roll).\n\t\t// 0 = lay back behind the beat, 0.5 = dead-on, 1 = push ahead of the beat.\n\t\tpublic float DrumDrive = 0.5f;\n\n\t\t// Rock instruments (Genre 1). Bass + drums reuse the shared knobs above.\n\t\t// KEYS \u2014 the offbeat-chord comp (was labelled \"rhythm guitar\", but it always read as a\n\t\t// keyboard, so it's named for what it sounds like). Power-chord comping on every eighth.\n\t\tpublic float KeysVol = 1.00f;\n\t\tpublic float KeysCutoff = 1700f; // Hz low-pass (darker wall)\n\t\tpublic float KeysDrive = 3.2f; // distortion amount (tanh drive)\n\t\tpublic float KeysChug = 0.5f; // 0 = ringing chords, 1 = tight palm-mute chug\n\t\t// RHYTHM GTR \u2014 twangy distorted power chords. Shares the lead guitar's voice but strums\n\t\t// chords at a lower base distortion than the lead.\n\t\tpublic float RhythmGtrVol = 1.00f;\n\t\tpublic float RhythmGtrCutoff = 2600f; // Hz low-pass on the rhythm guitar\n\t\tpublic float RhythmGtrDrive = 2.8f; // distortion amount (tanh drive) \u2014 under the lead\n\t\tpublic float RhythmGtrChug = 0.5f; // 0 = ringing chords, 1 = tight palm-mute chug\n\t\t// LEAD GTR \u2014 twangy, heavily distorted single-note lead.\n\t\tpublic float LeadGtrVol = 1.00f;\n\t\tpublic float LeadGtrCutoff = 2600f; // Hz low-pass on the lead guitar\n\t\tpublic float LeadGtrDrive = 5.0f; // distortion amount (tanh drive) \u2014 new floor of the DISTORTION knob\n\t\tpublic float LeadGtrBend = 0.30f; // rock lead \"bendiness\" 0..1 \u2014 propensity to bend up into notes and scoop\n\n\t\t// Tone \u2014 low drives + filtering for warmth; detune for width.\n\t\tpublic float Detune = 14f; // cents, unison spread\n\t\tpublic float BassCutoff = 380f; // Hz low-pass on bass\n\t\tpublic float SkankCutoff = 3000f; // Hz low-pass on skank\n\t\tpublic float SkankHighpass = 500f;// Hz high-pass to thin the skank (\"skank bite\")\n\t\tpublic float SkankChop = 0.5f; // skank chop length as a fraction of an eighth\n\t\tpublic float LeadCutoff = 3200f; // Hz low-pass on lead\n\t\tpublic float OrganCutoff = 1400f; // Hz low-pass on the organ bubble\n\t\tpublic float OrganVibrato = 5.5f; // organ bubble vibrato depth\n\t\tpublic float HornCutoff = 3200f; // Hz low-pass on the backing horns\n\t\tpublic float Resonance = 1.0f; // SVF damping (lower = more resonant)\n\t\tpublic float BassDrive = 1.5f;\n\t\tpublic float SkankDrive = 1.3f;\n\t\tpublic float MelodyDrive = 1.3f;\n\t\tpublic float HornDrive = 1.4f;\n\t\tpublic float MasterDrive = 1.1f;\n\t\tpublic float MasterPeak = 0.95f;\n\n\t\t// Master room reverb \u2014 a touch of stereo space so the mix reads with depth\n\t\t// instead of dry/\"16-bit\". Wet = blend, Decay = tail length (0..1).\n\t\tpublic float MasterReverb = 0.5f;\n\t\tpublic float ReverbDecay = 0.5f;\n\n\t\t// Feel\n\t\tpublic float OctavePopChance = 0.30f;\n\t\tpublic float OrganBubbleChance = 0.55f;\n\t\tpublic float KickSyncChance = 0.25f;\n\t\tpublic float GhostSnareChance = 0.35f;\n\t\tpublic float FillChance = 0.6f; // drum fill at phrase ends\n\t\tpublic float DrumBusy = 0.6f; // 0..1 overall kit activity: 16th hats, ghosts, kick syncopation\n\t\tpublic float TripletChance = 0.06f; // 0..0.2 chance of triplet/16th ornament in fills / lead runs (potent)\n\t\tpublic float BassTriplets = 0.06f; // 0..0.1 bass-only 16th/triplet ornament rate (own knob)\n\t\tpublic float MelodyRestChance = 0.30f;\n\t\tpublic float MelodyLeapChance = 0.18f;\n\t\tpublic float MelodyVibrato = 5.0f;\n\n\t\t// STEREO WIDTH (vibe slider): master 0..1 stereo amount. Scales the lead/horn placement\n\t\t// AND the drum pan + double-tracking spread/decorrelation (see _widthScale). 1 = full\n\t\t// design width (the \"100%\" the rest of the mix is tuned for); 0 = mono.\n\t\tpublic float PanAmount = 1.0f;\n\n\t\t// Lead instrument weights (RNG picks one; ForceInstrument overrides:\n\t\t// -1 = RNG, 0=Trumpet 1=Sax 2=Organ 3=Trombone).\n\t\tpublic float TrumpetWeight = 1.0f;\n\t\tpublic float SaxWeight = 1.0f;\n\t\tpublic float OrganWeight = 0.8f;\n\t\tpublic float TromboneWeight = 0.4f;\n\t\tpublic int ForceInstrument = -1;\n\n\t\t// Backing horn section\n\t\tpublic float HornSectionChance = 0.5f;\n\t\tpublic float HornDensity = 0.35f;\n\n\t\t// \u2500\u2500 Advanced peak-balance / level tuning (NOT vibe knobs) \u2500\u2500\n\t\t// These were hardcoded consts; they're Config fields so the house mix can be retuned at\n\t\t// runtime (web: config.json) without a rebuild. They're deliberately kept OUT of the vibe\n\t\t// seed and the per-genre slider grid (see VibeCodec.AdvancedFields) \u2014 they shape the\n\t\t// baseline mix, not a song's shareable identity. Edit these to re-balance, not the *Vol\n\t\t// defaults (which stay a flat 1.0 so the knobs read as pure user trims around this).\n\t\t//\n\t\t// KitPresence: drums are short transients fighting a sustained melodic bed; this baseline\n\t\t// boost lets the kit sit in the mix at DRUMS = 1.0 (parity with the other voice sliders).\n\t\tpublic float KitPresence = 2.0f;\n\t\t// Kit per-voice level balance. The pieces synthesise at very different raw levels (a kick\n\t\t// is huge, a hat is thin noise). Starting point was EQUAL PRE-MASTER PEAK (kick 0.40 /\n\t\t// snare 0.476 / tom 0.496 / hat 0.582 / crash 0.515), then hand-tuned by ear: equal peak\n\t\t// buried the sparse kick/snare/toms under the constant hats, so drums were pushed back up\n\t\t// and the hats backed off.\n\t\tpublic float KickBalance = 0.800f;\n\t\tpublic float SnareBalance = 0.900f;\n\t\tpublic float TomBalance = 0.780f; // tuned by ear\n\t\tpublic float HatBalance = 0.407f; // equal-peak baseline (0.582) backed off 30% by ear\n\t\tpublic float CrashBalance = 0.515f;\n\t\t// Melodic-voice peak balance \u2014 the instrument analog of the kit balances above (same\n\t\t// equal-peak-then-tune workflow). Bass measured the same peak across genres, so one value.\n\t\tpublic float BassBalance = 0.733f;\n\t\tpublic float SkankBalance = 1.223f;\n\t\tpublic float OrganBalance = 1.237f;\n\t\tpublic float MelodyBalance = 0.896f; // ska horn lead\n\t\tpublic float HornBalance = 1.142f; // backing horn section\n\t\tpublic float KeysBalance = 1.065f; // rock offbeat keys\n\t\tpublic float RhythmGtrBalance = 1.053f;\n\t\tpublic float LeadGtrBalance = 0.896f;\n\n\t\t// \u2500\u2500 Stereo double-tracking (width) \u2500\u2500 (config-only; see VibeCodec.AdvancedFields)\n\t\t// Widen the non-drum voices the way a band double-tracks guitars (bass excepted \u2014 it\n\t\t// stays centred for a tight, mono-safe low end): each widened note is rendered\n\t\t// as TWO decorrelated \"takes\" panned apart \u2014 NOT one mono signal copied to both channels\n\t\t// (that just collapses to centre). The width comes entirely from the micro-differences\n\t\t// between the takes: a few cents of detune, a small constant timing offset + per-note\n\t\t// jitter, independent oscillator start phase, and per-note amplitude/cutoff variation.\n\t\t// Tunable at runtime (no rebuild). Drums are panned separately (see DrumPan).\n\t\tpublic float DoubleTrack = 1f; // master enable: <0.5 = off (single centred take, as before)\n\t\tpublic float WidthBacking = 0.5f; // pan spread (\u00b1) for backing voices \u2014 50% L/R\n\t\tpublic float WidthLead = 1.0f; // pan spread (\u00b1) for a doubled lead. INERT by default: the\n\t\t // lead is monophonic and rendered as a single clean take\n\t\t // (see RenderLeadNote) \u2014 doubling a solo line smeared pitch\n\t\t // (\"out of key\"). Only takes effect if the lead path is\n\t\t // switched back to lead:true. Chordal voices use WidthBacking.\n\t\tpublic float WidthDetune = 6f; // cents BETWEEN the two takes (split \u00b1half each)\n\t\tpublic float WidthDelayMs = 9f; // constant timing offset of the 2nd take (ms)\n\t\tpublic float WidthJitterMs = 4f; // extra random per-note timing jitter (ms, \u00b1 per take)\n\t\tpublic float WidthAmpVar = 0.08f; // per-note amplitude variation between takes (0..1)\n\t\tpublic float WidthCutoffVar = 0.10f; // per-note filter-cutoff variation between takes (0..1)\n\t}\n\n\treadonly Config _c;\n\treadonly int _sr;\n\treadonly float _drumGain; // master kit gain \u2014 straight 0..1.5 slider \u00d7 Config.KitPresence baseline\n\tfloat[] _bufL, _bufR;\n\n\tMusicGen( Config c ) { _c = c ?? new Config(); _sr = _c.SampleRate; _drumGain = Math.Clamp( _c.DrumVol, 0f, 1.5f ) * _c.KitPresence; }\n\n\tpublic const int Channels = 2;\n\n\tpublic static byte[] Generate( string tag, Config cfg = null )\n\t{\n\t\tvar g = new MusicGen( cfg );\n\t\treturn g.EncodeWav( g.Compose( tag ) );\n\t}\n\n\tpublic static short[] GenerateSamples( string tag, Config cfg, out int sampleRate )\n\t{\n\t\tvar g = new MusicGen( cfg );\n\t\tfloat gain = g.Compose( tag );\n\t\tsampleRate = g._sr;\n\t\treturn g.ToShorts( gain );\n\t}\n\n\t// \u2500\u2500 Chunked generation (parallel synthesis) \u2500\u2500\n\t// Composition + drum synthesis are sequential (RNG-bound); pitched-voice synthesis\n\t// pulls no RNG, so the caller can split it across worker threads. Flow:\n\t// var g = MusicGen.BeginPlan( tag, cfg ); // sequential plan + drums\n\t// parallel-for window in 0..g.TotalSamples: g.RenderPitchedRange( from, to );\n\t// short[] pcm = g.FinishStereo(); // master + interleave\n\tpublic static MusicGen BeginPlan( string tag, Config cfg )\n\t{\n\t\tvar g = new MusicGen( cfg );\n\t\tg.ComposePlan( tag );\n\t\treturn g;\n\t}\n\n\tpublic int TotalSamples => _bufL?.Length ?? 0;\n\tpublic int SampleRate => _sr;\n\n\t/// <summary>Master-normalize and interleave to stereo 16-bit PCM. Call after every\n\t/// <see cref=\"RenderPitchedRange\"/> window has finished.</summary>\n\tpublic short[] FinishStereo() => ToShorts( Master() );\n\n\tconst int EighthsPerBar = 8;\n\n\t// \u2500\u2500 Portable PRNG (mirror exactly in the web client) \u2500\u2500\n\tstatic uint Xmur3( string str )\n\t{\n\t\tuint h = 1779033703u ^ (uint)str.Length;\n\t\tfor ( int i = 0; i < str.Length; i++ )\n\t\t{\n\t\t\th = unchecked( (h ^ str[i]) * 3432918353u );\n\t\t\th = (h << 13) | (h >> 19);\n\t\t}\n\t\th = unchecked( (h ^ (h >> 16)) * 2246822507u );\n\t\th = unchecked( (h ^ (h >> 13)) * 3266489909u );\n\t\treturn h ^ (h >> 16);\n\t}\n\n\tsealed class Rng\n\t{\n\t\tuint _a;\n\t\tpublic Rng( uint seed ) { _a = seed; }\n\t\tpublic float Next()\n\t\t{\n\t\t\t_a = unchecked( _a + 0x6D2B79F5u );\n\t\t\tuint t = _a;\n\t\t\tt = unchecked( (t ^ (t >> 15)) * (t | 1u) );\n\t\t\tt ^= unchecked( t + (t ^ (t >> 7)) * (t | 61u) );\n\t\t\treturn (t ^ (t >> 14)) / 4294967296f;\n\t\t}\n\t\tpublic int Int( int n ) => n <= 0 ? 0 : Math.Min( n - 1, (int)(Next() * n) );\n\t\tpublic bool Chance( float p ) => Next() < p;\n\t\tpublic T Pick<T>( T[] arr ) => arr[Int( arr.Length )];\n\t}\n\n\t// \u2500\u2500 Harmony \u2500\u2500\n\t// Major-leaning scales (Sublime / reggae sit in major & mixolydian mostly).\n\tstatic readonly int[][] Scales =\n\t{\n\t\tnew[] { 0, 2, 4, 5, 7, 9, 11 }, // major\n\t\tnew[] { 0, 2, 4, 5, 7, 9, 10 }, // mixolydian\n\t\tnew[] { 0, 2, 4, 5, 7, 9, 11 }, // major (weighted)\n\t\tnew[] { 0, 2, 4, 5, 7, 9, 11 }, // major (weighted \u2014 was dorian; ska/reggae stays bright)\n\t};\n\n\tstatic readonly int[][] Progressions =\n\t{\n\t\tnew[] { 0, 4, 5, 3 }, // I\u2013V\u2013vi\u2013IV\n\t\tnew[] { 0, 3, 4, 4 }, // I\u2013IV\u2013V\u2013V\n\t\tnew[] { 0, 5, 3, 4 }, // I\u2013vi\u2013IV\u2013V\n\t\tnew[] { 0, 6, 3, 0 }, // I\u2013bVII\u2013IV\u2013I (mixolydian)\n\t\tnew[] { 5, 3, 0, 4 }, // vi\u2013IV\u2013I\u2013V\n\t\tnew[] { 0, 3, 0, 4 }, // I\u2013IV\u2013I\u2013V\n\t};\n\n\t// Bass patterns: semitone offsets from the chord root per eighth; -99 = rest\n\t// (note sustains to the next onset). Slot 7 is the \"approach\" \u2192 walks to the\n\t// next chord. Mix of melodic / one-drop / rocking / busy ska.\n\tconst int Rest = -99, Approach = 99;\n\tstatic readonly int[][] BassPatterns =\n\t{\n\t\tnew[] { 0, Rest, 0, 12, Rest, 7, 5, Approach }, // sublime melodic\n\t\tnew[] { Rest, Rest, 0, Rest, 0, Rest, 7, Approach }, // one-drop spacey\n\t\tnew[] { 0, Rest, 7, Rest, 12, Rest, 7, Approach }, // rocking\n\t\tnew[] { 0, 12, 0, 7, 0, 12, 7, Approach }, // busy ska\n\t\tnew[] { 0, Rest, 0, Rest, 5, Rest, 7, Approach }, // root\u2013fifth\n\t};\n\n\t// \u2500\u2500 Rock harmony (Genre 1) \u2500\u2500\n\t// Darker, power-chord-friendly modes (minor / dorian / mixolydian) so rock doesn't\n\t// share ska's bright major themes. Picked with the SAME RNG draw as the ska tables, so\n\t// ska songs are byte-identical \u2014 only genre 1 reads these.\n\t// All minor-3rd modes: the RockProgressions are written/labelled as MINOR (i\u2013\u266dVII\u2013\u266dVI \u2026), so\n\t// pairing them with a major-3rd mode (e.g. mixolydian) flipped the tonic major and the dark\n\t// rock vamp evaporated. Aeolian/dorian/phrygian keep a minor tonic \u2014 the progression reads as\n\t// intended \u2014 while still giving modal variety (dorian's \u266e6, phrygian's \u266dII colour). 4 entries\n\t// kept so the genre Pick stays at the same draw and other genres are byte-identical.\n\tstatic readonly int[][] RockScales =\n\t{\n\t\tnew[] { 0, 2, 3, 5, 7, 8, 10 }, // natural minor (aeolian)\n\t\tnew[] { 0, 2, 3, 5, 7, 9, 10 }, // dorian (minor, brighter \u266e6 \u2014 classic rock)\n\t\tnew[] { 0, 1, 3, 5, 7, 8, 10 }, // phrygian (dark)\n\t\tnew[] { 0, 2, 3, 5, 7, 8, 10 }, // natural minor (weighted)\n\t};\n\n\t// Degrees are read against the (often minor) scale, so 5 = \u266dVI, 6 = \u266dVII, 3 = iv, 4 = v.\n\tstatic readonly int[][] RockProgressions =\n\t{\n\t\tnew[] { 0, 6, 5, 6 }, // i\u2013\u266dVII\u2013\u266dVI\u2013\u266dVII (driving rock vamp)\n\t\tnew[] { 0, 5, 6, 0 }, // i\u2013\u266dVI\u2013\u266dVII\u2013i\n\t\tnew[] { 0, 6, 3, 0 }, // i\u2013\u266dVII\u2013IV\u2013i (mixolydian rock)\n\t\tnew[] { 0, 3, 6, 0 }, // i\u2013iv\u2013\u266dVII\u2013i\n\t\tnew[] { 0, 0, 6, 6 }, // i / \u266dVII riff vamp\n\t\tnew[] { 0, 3, 4, 0 }, // i\u2013iv\u2013v\u2013i\n\t};\n\n\t// Driving root/octave eighths that lock to the kick \u2014 the rock engine room, vs ska's\n\t// syncopated off-beat one-drop bass.\n\tstatic readonly int[][] RockBassPatterns =\n\t{\n\t\tnew[] { 0, 0, 0, 0, 0, 0, 0, Approach }, // straight eighth chug\n\t\tnew[] { 0, Rest, 0, Rest, 0, Rest, 0, Approach },// quarter-note pulse\n\t\tnew[] { 0, 0, 12, 0, 0, 0, 12, Approach }, // root with octave pushes\n\t\tnew[] { 0, 0, 7, 0, 0, 0, 7, Approach }, // root\u2013fifth gallop\n\t\tnew[] { 0, Rest, 0, 0, Rest, 0, 12, Approach }, // syncopated driver\n\t};\n\n\t// \u2500\u2500 Country harmony (Genre 2) \u2500\u2500\n\t// Bright and major \u2014 country lives in major / mixolydian. Same RNG draws as the other\n\t// tables, so songs in other genres are untouched.\n\tstatic readonly int[][] CountryScales =\n\t{\n\t\tnew[] { 0, 2, 4, 5, 7, 9, 11 }, // major\n\t\tnew[] { 0, 2, 4, 5, 7, 9, 10 }, // mixolydian\n\t\tnew[] { 0, 2, 4, 5, 7, 9, 11 }, // major\n\t\tnew[] { 0, 2, 4, 5, 7, 9, 11 }, // major (weighted)\n\t};\n\n\tstatic readonly int[][] CountryProgressions =\n\t{\n\t\tnew[] { 0, 3, 4, 0 }, // I\u2013IV\u2013V\u2013I (the country backbone)\n\t\tnew[] { 0, 0, 4, 4 }, // I\u2013V vamp\n\t\tnew[] { 0, 3, 0, 4 }, // I\u2013IV\u2013I\u2013V\n\t\tnew[] { 0, 4, 5, 3 }, // I\u2013V\u2013vi\u2013IV\n\t\tnew[] { 0, 4, 0, 4 }, // I\u2013V two-chord\n\t};\n\n\t// \"Boom-chick\" alternating root/fifth on the beats (the guitar/snare take the off \"chick\"),\n\t// walking up to the next chord on the approach.\n\tstatic readonly int[][] CountryBassPatterns =\n\t{\n\t\tnew[] { 0, Rest, 7, Rest, 0, Rest, 7, Approach }, // alternating root\u2013fifth\n\t\tnew[] { 0, Rest, 7, Rest, 12, Rest, 7, Approach }, // root\u2013fifth with the octave\n\t\tnew[] { 0, Rest, 7, Rest, 0, Rest, 5, Approach }, // root\u2013fifth, lean on the 4th\n\t\tnew[] { 0, Rest, 4, Rest, 7, Rest, 5, Approach }, // walking-ish\n\t};\n\n\t// \u2500\u2500 Metal harmony (Genre 3) \u2500\u2500\n\t// Dark and tight \u2014 natural minor / phrygian / harmonic minor for the menacing power-chord\n\t// riffs. Same RNG draws as the other tables.\n\tstatic readonly int[][] MetalScales =\n\t{\n\t\tnew[] { 0, 2, 3, 5, 7, 8, 10 }, // natural minor (aeolian)\n\t\tnew[] { 0, 1, 3, 5, 7, 8, 10 }, // phrygian (the metal mode)\n\t\tnew[] { 0, 2, 3, 5, 7, 8, 11 }, // harmonic minor\n\t\tnew[] { 0, 2, 3, 5, 7, 8, 10 }, // natural minor (weighted)\n\t};\n\n\t// Degrees read against the (minor) scale: 5 = \u266dVI, 6 = \u266dVII, 1 = \u266dII, 3 = iv.\n\tstatic readonly int[][] MetalProgressions =\n\t{\n\t\tnew[] { 0, 5, 6, 0 }, // i\u2013\u266dVI\u2013\u266dVII\u2013i\n\t\tnew[] { 0, 6, 5, 6 }, // i\u2013\u266dVII\u2013\u266dVI\u2013\u266dVII (driving)\n\t\tnew[] { 0, 1, 0, 6 }, // i\u2013\u266dII\u2013i\u2013\u266dVII (phrygian menace)\n\t\tnew[] { 0, 0, 5, 6 }, // i pedal \u2192 \u266dVI\u2013\u266dVII\n\t\tnew[] { 0, 6, 3, 0 }, // i\u2013\u266dVII\u2013iv\u2013i\n\t};\n\n\t// Driving roots locked to the double-kick; octave pushes for the gallop.\n\tstatic readonly int[][] MetalBassPatterns =\n\t{\n\t\tnew[] { 0, 0, 0, 0, 0, 0, 0, Approach }, // straight chug\n\t\tnew[] { 0, 0, 12, 0, 0, 0, 12, Approach }, // root with octave pushes\n\t\tnew[] { 0, 0, 0, 12, 0, 0, 0, Approach }, // syncopated octave\n\t\tnew[] { 0, Rest, 0, 0, Rest, 0, 0, Approach }, // syncopated driver\n\t};\n\n\t// \u2500\u2500 Punk harmony (Genre 4) \u2500\u2500\n\t// \"Lean punk\" / power-pop: bright and major (the opposite pole to rock's dark minor), riding\n\t// the anthemic four-chord turnarounds at speed. Same RNG draws as the other tables.\n\tstatic readonly int[][] PunkScales =\n\t{\n\t\tnew[] { 0, 2, 4, 5, 7, 9, 11 }, // major (the pop-punk default)\n\t\tnew[] { 0, 2, 4, 5, 7, 9, 10 }, // mixolydian (a little grit on the \u266d7)\n\t\tnew[] { 0, 2, 4, 5, 7, 9, 11 }, // major (weighted)\n\t\tnew[] { 0, 2, 4, 5, 7, 9, 11 }, // major (weighted)\n\t};\n\n\t// Major degrees: 3 = IV, 4 = V, 5 = vi \u2014 the anthem turnarounds.\n\tstatic readonly int[][] PunkProgressions =\n\t{\n\t\tnew[] { 0, 4, 5, 3 }, // I\u2013V\u2013vi\u2013IV (the pop-punk anthem)\n\t\tnew[] { 5, 3, 0, 4 }, // vi\u2013IV\u2013I\u2013V\n\t\tnew[] { 0, 5, 3, 4 }, // I\u2013vi\u2013IV\u2013V (50s changes, sped up)\n\t\tnew[] { 0, 3, 4, 4 }, // I\u2013IV\u2013V\u2013V\n\t\tnew[] { 0, 4, 3, 4 }, // I\u2013V\u2013IV\u2013V (three-chord drive)\n\t};\n\n\t// Driving root/octave eighths locked to the kick \u2014 same engine room as rock, pushed harder.\n\tstatic readonly int[][] PunkBassPatterns =\n\t{\n\t\tnew[] { 0, 0, 0, 0, 0, 0, 0, Approach }, // straight eighth chug\n\t\tnew[] { 0, 0, 12, 0, 0, 0, 12, Approach }, // root with octave pushes\n\t\tnew[] { 0, 0, 7, 0, 0, 0, 7, Approach }, // root\u2013fifth gallop\n\t\tnew[] { 0, Rest, 0, Rest, 0, Rest, 0, Approach },// quarter-note pulse\n\t};\n\n\t// \u2500\u2500 Pop harmony (Genre 5) \u2500\u2500\n\t// Modern synth/dance-pop: bright major / lydian over the ubiquitous four-chord loops, built\n\t// to sit on a four-on-the-floor kick. Same RNG draws as the other tables.\n\tstatic readonly int[][] PopScales =\n\t{\n\t\tnew[] { 0, 2, 4, 5, 7, 9, 11 }, // major\n\t\tnew[] { 0, 2, 4, 6, 7, 9, 11 }, // lydian (the sparkly \u266f4 \u2014 synth-pop shimmer)\n\t\tnew[] { 0, 2, 4, 5, 7, 9, 11 }, // major (weighted)\n\t\tnew[] { 0, 2, 4, 5, 7, 9, 11 }, // major (weighted)\n\t};\n\n\tstatic readonly int[][] PopProgressions =\n\t{\n\t\tnew[] { 0, 4, 5, 3 }, // I\u2013V\u2013vi\u2013IV\n\t\tnew[] { 5, 3, 0, 4 }, // vi\u2013IV\u2013I\u2013V (the \"Axis\" loop)\n\t\tnew[] { 0, 5, 3, 4 }, // I\u2013vi\u2013IV\u2013V\n\t\tnew[] { 0, 3, 0, 4 }, // I\u2013IV\u2013I\u2013V (two-chord pulse)\n\t};\n\n\t// Four-on-the-floor synth bass: a steady root pulse with octave pops for bounce.\n\tstatic readonly int[][] PopBassPatterns =\n\t{\n\t\tnew[] { 0, Rest, 0, Rest, 0, Rest, 0, Approach }, // root on every beat (locks the floor)\n\t\tnew[] { 0, 0, 12, 0, 0, 0, 12, Approach }, // root with octave pops\n\t\tnew[] { 0, 0, 0, 0, 0, 0, 0, Approach }, // straight eighth pulse\n\t\tnew[] { 0, Rest, 0, 0, Rest, 0, 12, Approach }, // syncopated synth bass\n\t};\n\n\t// The genre's harmony tables (one RNG Pick each, so other genres stay byte-identical).\n\tstatic int[][] ScalesFor( int g ) => g switch\n\t{ 1 => RockScales, 2 => CountryScales, 3 => MetalScales, 4 => PunkScales, 5 => PopScales, _ => Scales };\n\tstatic int[][] ProgressionsFor( int g ) => g switch\n\t{ 1 => RockProgressions, 2 => CountryProgressions, 3 => MetalProgressions, 4 => PunkProgressions, 5 => PopProgressions, _ => Progressions };\n\tstatic int[][] BassPatternsFor( int g ) => g switch\n\t{ 1 => RockBassPatterns, 2 => CountryBassPatterns, 3 => MetalBassPatterns, 4 => PunkBassPatterns, 5 => PopBassPatterns, _ => BassPatterns };\n\n\tint[] _scale, _prog;\n\tint _rootMidi;\n\tInstrument _lead;\n\tfloat _leadPan;\n\tfloat _widthScale = 1f; // STEREO WIDTH slider (PanAmount) as a 0..1 master: scales the drum\n\t // pan AND the double-tracking spread/decorrelation. 1 = full (design)\n\t // width; 0 = everything collapses to centre (mono).\n\tfloat _drumPan = DrumPan;// per-song effective drum spread = DrumPan * _widthScale\n\tbool _hasHorns;\n\tbool[] _hornMask;\n\tint[] _bassPat;\n\tint _drumStyle; // 0 one-drop, 1 steppers, 2 straight backbeat\n\tint[] _kickAccents = Array.Empty<int>(); // per-song backbeat kick accents (see BackbeatKickAccents)\n\tbool _ride; // per-SECTION: ride cymbal drives the eighth pulse instead of closed hats (set in RenderSection from _ridePref)\n\tfloat _ridePref; // per-song lean toward riding the ride vs the hats; each section rolls its own _ride against this\n\tbool _crashBrightLeft; // per-song: which side the kit's two crashes sit on (bright crash left \u21c4 dark crash right, or flipped)\n\tbool _organBubble;\n\tbool _fast;\n\tint _genre; // 0 ska, 1 rock, 2 country, 3 metal, 4 punk, 5 pop\n\tstring _tag; // the per-song seed string, reused to seed per-section streams\n\tint _drumPush; // per-song-constant kit timing bias in samples (\u2212 ahead / + back)\n\tfloat _swing; // per-song shuffle: offbeat eighths pushed late by this fraction of an eighth (whole band, via Swung)\n\tfloat _drumTone = 0.5f; // DrumTone 0..1 \u2192 toms\u2194cymbals CONTENT bias in fills/groove decoration\n\tfloat _drumLowMul = 1f; // DrumTone \u2192 kick/tom gain lean (gentle, on top of the content bias)\n\tfloat _drumHighMul = 1f; // DrumTone \u2192 hat/cymbal gain lean (gentle, on top of the content bias)\n\n\t// \u2500\u2500 Song structure \u2500\u2500\n\t// A song is an ordered list of sections. Hardcoded for now (will be RNG-generated once\n\t// there are more part types); the fixed run is intro \u2192 chorus \u2192 verse(0) \u2192 chorus \u2192\n\t// verse(1) \u2192 chorus \u2192 ending. Non-lead voices are seeded by section TYPE so every chorus\n\t// (and both verses) play identical backing; the lead is seeded by type + verse index so\n\t// it evolves across the Nth verse; the section-end fill is seeded by absolute index so\n\t// every section closes with a different fill.\n\tenum Section { Intro, Chorus, Verse, Ending }\n\n\t// Extra seconds appended after the last bar so the ending's final tonic chord (and the\n\t// master reverb) can ring out naturally instead of being clipped at the buffer edge.\n\tconst float RingOutTail = 2.4f;\n\n\treadonly struct Part\n\t{\n\t\tpublic readonly Section Type; public readonly int Bars; public readonly int VerseIndex;\n\t\tpublic Part( Section t, int bars, int verse ) { Type = t; Bars = bars; VerseIndex = verse; }\n\t}\n\n\tstatic List<Part> BuildStructure() => new()\n\t{\n\t\tnew Part( Section.Intro, 4, 0 ),\n\t\tnew Part( Section.Chorus, 8, 0 ),\n\t\tnew Part( Section.Verse, 8, 0 ),\n\t\tnew Part( Section.Chorus, 8, 0 ),\n\t\tnew Part( Section.Verse, 8, 1 ),\n\t\tnew Part( Section.Chorus, 8, 0 ),\n\t\tnew Part( Section.Ending, 2, 0 ),\n\t};\n\n\tstatic string SectionKey( Section s ) => s switch\n\t{\n\t\tSection.Intro => \"intro\",\n\t\tSection.Chorus => \"chorus\",\n\t\tSection.Verse => \"verse\",\n\t\t_ => \"ending\",\n\t};\n\n\t// Single-threaded generation (used by Generate / GenerateSamples). The controller\n\t// uses the chunked path instead (BeginPlan \u2192 parallel RenderPitchedRange \u2192 FinishStereo).\n\tfloat Compose( string tag )\n\t{\n\t\tComposePlan( tag );\n\t\tRenderPitchedRange( 0, _bufL.Length );\n\t\treturn Master();\n\t}\n\n\t// Sequential planning pass: RNG composition + drum synthesis written straight into\n\t// the buffer, while every pitched note is collected as an event (rendered later,\n\t// possibly in parallel). RNG draw order is identical to the old inline render \u2014\n\t// RenderPatch now only enqueues, and it never pulled RNG anyway.\n\tvoid ComposePlan( string tag )\n\t{\n\t\t_events.Clear();\n\t\t_tag = string.IsNullOrEmpty( tag ) ? \"rotaliate\" : tag;\n\t\t_genre = Math.Clamp( _c.Genre, 0, 5 );\n\t\tvar rng = new Rng( Xmur3( _tag.ToLowerInvariant() ) );\n\n\t\t// TEMPO BIAS \u2014 punk always runs hot (it's the fast genre); the roll still consumes one\n\t\t// draw so every other genre's later picks stay byte-identical.\n\t\t_fast = rng.Chance( _c.FastChance ) || _genre == 4;\n\t\tint bpm = _fast\n\t\t\t? _c.FastBpmMin + rng.Int( Math.Max( 1, _c.FastBpmMax - _c.FastBpmMin + 1 ) )\n\t\t\t: _c.BpmMin + rng.Int( Math.Max( 1, _c.BpmMax - _c.BpmMin + 1 ) );\n\t\t_scale = rng.Pick( ScalesFor( _genre ) );\n\t\t_prog = rng.Pick( ProgressionsFor( _genre ) );\n\t\t_rootMidi = 28 + rng.Int( 8 ); // E1..B1 bass root\n\t\t_lead = Instrument.Trumpet; // ska lead is fixed; other genres use guitar\n\t\t_leadPan = (rng.Next() * 2f - 1f) * _c.PanAmount;\n\t\t_widthScale = Math.Clamp( _c.PanAmount, 0f, 1f );\n\t\t_drumPan = DrumPan * _widthScale;\n\t\t_bassPat = rng.Pick( BassPatternsFor( _genre ) );\n\t\t_drumStyle = _genre switch // 0 ska rolls a style; the rest are fixed\n\t\t{\n\t\t\t1 => 2, // rock: straight backbeat\n\t\t\t2 => 2, // country: train-beat backbeat\n\t\t\t3 => 3, // metal: double-kick\n\t\t\t4 => 2, // punk: straight backbeat (fast)\n\t\t\t5 => 4, // pop: four-on-the-floor\n\t\t\t_ => _fast ? 2 : rng.Int( 2 ),\n\t\t};\n\t\t_organBubble = true;\n\t\t_hasHorns = true;\n\t\t_hornMask = new bool[EighthsPerBar];\n\t\t_hornMask[0] = true;\n\t\tfor ( int e = 1; e < EighthsPerBar; e++ )\n\t\t\t_hornMask[e] = rng.Chance( _c.HornDensity * (e % 2 == 1 ? 1.3f : 0.5f) );\n\t\t// How much this song leans on the ride cymbal vs the closed hats for the main pulse.\n\t\t// Every song can do both \u2014 each SECTION rolls its own choice against this preference\n\t\t// (see RenderSection), so a song can hat the verse and ride the chorus. The lean is\n\t\t// genre-biased (rock/metal ride more) and spread per song so some strongly prefer one.\n\t\t// One rng.Next() (same draw count as the old single Chance), so later draws stay aligned.\n\t\tfloat rideBase = _genre switch { 1 => 0.55f, 3 => 0.65f, 2 => 0.30f, 4 => 0.20f, 5 => 0.30f, _ => 0.40f };\n\t\t_ridePref = Math.Clamp( rideBase - 0.25f + 0.5f * rng.Next(), 0f, 1f );\n\t\t// Which side the two crashes sit on (\u00b125%); flips per song so the stereo image varies.\n\t\t_crashBrightLeft = rng.Chance( 0.5f );\n\t\t// This song's backbeat kick personality \u2014 which off-beat eighths the kick leans into\n\t\t// beyond the fixed beat-1 & 3 anchors. Only the straight backbeat (rock/country/fast\n\t\t// ska) reads it; drawn last so it shifts no earlier choice.\n\t\t_kickAccents = rng.Pick( BackbeatKickAccents );\n\n\t\t_swing = _fast ? _c.FastSwing : _c.Swing;\n\t\tif ( _genre == 5 ) _swing *= 0.25f; // pop sits on a tight, straight dance grid\n\t\tdouble secPerEighth = 60.0 / bpm / 2.0;\n\t\tint spe = (int)Math.Round( _sr * secPerEighth );\n\n\t\t// Drum tone (toms\u2194cymbals) \u2192 per-voice gain split, and drive (pull\u2194push) \u2192 a constant\n\t\t// kit timing bias (\u2212 = ahead/push, + = behind/lay back; 0.5 = dead on).\n\t\tfloat dt = Math.Clamp( _c.DrumTone, 0f, 1f );\n\t\t_drumTone = dt;\n\t\t// Gentle gain lean (neutral at 0.5 so the balanced kit is untouched there); the\n\t\t// bulk of the toms\u2194cymbals bias now comes from what the part actually plays.\n\t\t_drumLowMul = 1.2f - 0.4f * dt;\n\t\t_drumHighMul = 0.7f + 0.6f * dt;\n\t\t_drumPush = (int)Math.Round( (0.5f - Math.Clamp( _c.DrumDrive, 0f, 1f )) * 2f * 0.13f * spe );\n\n\t\t// Lay out the structure and size the buffers to its total length.\n\t\tvar structure = BuildStructure();\n\t\tint totalBars = 0;\n\t\tforeach ( var p in structure ) totalBars += p.Bars;\n\t\t// Size to the structure plus a ring-out tail, so the ending's final chord and the\n\t\t// reverb decay into real silence past the last bar rather than being cut off.\n\t\tint structured = spe * EighthsPerBar * totalBars;\n\t\tint total = structured + (int)(_sr * RingOutTail);\n\t\t_bufL = new float[total];\n\t\t_bufR = new float[total];\n\n\t\tint barCursor = 0;\n\t\tfor ( int si = 0; si < structure.Count; si++ )\n\t\t{\n\t\t\tvar part = structure[si];\n\t\t\tRenderSection( part, si, barCursor * EighthsPerBar * spe, spe, secPerEighth );\n\t\t\tbarCursor += part.Bars;\n\t\t}\n\t}\n\n\t// Render one section. Each voice gets its own per-section RNG stream keyed so that repeats\n\t// of a section type reproduce identical backing, while the lead key folds in the verse\n\t// index (so the Nth verse's lead differs) and the fill key folds in the absolute section\n\t// index (so every section closes with a unique fill).\n\tvoid RenderSection( Part part, int absIndex, int sectionStart, int spe, double secPerEighth )\n\t{\n\t\tstring bk = SectionKey( part.Type );\n\t\tstring lk = part.Type == Section.Verse ? $\"verse:{part.VerseIndex}\" : bk;\n\t\tvar bassRng = new Rng( Xmur3( $\"{_tag}:bass:{bk}\" ) );\n\t\tvar bassOrn = new Rng( Xmur3( $\"{_tag}:bassorn:{bk}\" ) );\n\t\tvar rhythmRng = new Rng( Xmur3( $\"{_tag}:rhythm:{bk}\" ) );\n\t\tvar keysRng = new Rng( Xmur3( $\"{_tag}:keys:{bk}\" ) );\n\t\tvar hornRng = new Rng( Xmur3( $\"{_tag}:horn:{bk}\" ) );\n\t\tvar leadRng = new Rng( Xmur3( $\"{_tag}:lead:{lk}\" ) );\n\t\t// Expression (vibrato/bend/glide/scoop) rolls off their own stream so adding them\n\t\t// leaves every voice's existing note CHOICES untouched \u2014 only pitch-shaping is layered on.\n\t\tvar exprRng = new Rng( Xmur3( $\"{_tag}:expr:{lk}\" ) );\n\t\tvar noise = new Rng( Xmur3( $\"{_tag}:drums:{bk}\" ) );\n\t\t// Hats vs ride is decided per section off its own stream (keyed by section TYPE, so every\n\t\t// chorus rides-or-hats the same, but a verse can differ). Rolled against the song's\n\t\t// _ridePref \u2014 independent stream, so it disturbs no other voice's draws.\n\t\t_ride = new Rng( Xmur3( $\"{_tag}:ride:{bk}\" ) ).Chance( _ridePref );\n\t\tvar fillRng = new Rng( Xmur3( $\"{_tag}:fill:{absIndex}\" ) );\n\t\tvar fillNoise = new Rng( Xmur3( $\"{_tag}:fillnoise:{absIndex}\" ) );\n\n\t\tbool isIntro = part.Type == Section.Intro;\n\t\tbool isEnding = part.Type == Section.Ending;\n\n\t\tfor ( int bar = 0; bar < part.Bars; bar++ )\n\t\t{\n\t\t\tint chord = (bar / 2) % _prog.Length;\n\t\t\tint nextChord = ((bar / 2) + 1) % _prog.Length;\n\t\t\tint barStart = sectionStart + bar * EighthsPerBar * spe;\n\n\t\t\t// The ending lands on a held tonic chord that rings out \u2014 the band stops on the\n\t\t\t// \"one\", it doesn't roll forward as if looping. The bar before it fills to lead in.\n\t\t\tif ( isEnding && bar == part.Bars - 1 )\n\t\t\t{\n\t\t\t\tRenderEnding( barStart, spe, noise );\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\t// Every section's last bar fills; for the ending that fill moves one bar earlier so\n\t\t\t// it sets up the final hit instead of pushing past the end into nothing.\n\t\t\tbool lastBar = bar == part.Bars - 1 || (isEnding && bar == part.Bars - 2);\n\n\t\t\t// Intro build-in: rather than slamming in at full band (which reads as looping back\n\t\t\t// into the middle of the song), the voices enter a layer at a time \u2014 bass + drums\n\t\t\t// lay down the groove first, then the chordal voice, then the horns/lead on top. The\n\t\t\t// thresholds are derived from the intro length (not hardcoded bar numbers) so the build\n\t\t\t// always spans the whole intro and can't silently collapse if part.Bars changes: the\n\t\t\t// chord enters a quarter of the way in, the top half-way. (4-bar intro \u21d2 bars 1 and 2.)\n\t\t\tbool playChord = !isIntro || bar >= part.Bars / 4;\n\t\t\tbool playTop = !isIntro || bar >= part.Bars / 2;\n\n\t\t\tRenderBassBar( barStart, spe, secPerEighth, chord, nextChord, bassRng, bassOrn, exprRng );\n\t\t\tif ( playChord )\n\t\t\t\tswitch ( _genre )\n\t\t\t\t{\n\t\t\t\t\tcase 1: // rock: keys comp + power-chord guitar\n\t\t\t\t\tcase 2: // country: honky-tonk piano comp + strummed twang guitar\n\t\t\t\t\t\tRenderKeysBar( barStart, spe, secPerEighth, chord, keysRng, exprRng );\n\t\t\t\t\t\tRenderRhythmGuitarBar( barStart, spe, secPerEighth, chord, rhythmRng, exprRng );\n\t\t\t\t\t\tbreak;\n\t\t\t\t\tcase 3: // metal: palm-muted gallop riff carries the bar\n\t\t\t\t\t\tRenderMetalRiffBar( barStart, spe, secPerEighth, chord, rhythmRng, exprRng );\n\t\t\t\t\t\tbreak;\n\t\t\t\t\tcase 4: // punk: lean \u2014 power-chord guitar carries it, no keys\n\t\t\t\t\t\tRenderRhythmGuitarBar( barStart, spe, secPerEighth, chord, rhythmRng, exprRng );\n\t\t\t\t\t\tbreak;\n\t\t\t\t\tcase 5: // pop: synth comp (the keys voice, run clean + bright)\n\t\t\t\t\t\tRenderKeysBar( barStart, spe, secPerEighth, chord, keysRng, exprRng );\n\t\t\t\t\t\tbreak;\n\t\t\t\t\tdefault: // ska: skank chop + horn stabs\n\t\t\t\t\t\tRenderRhythmBar( barStart, spe, secPerEighth, chord, rhythmRng, exprRng );\n\t\t\t\t\t\tif ( _hasHorns && playTop )\n\t\t\t\t\t\t\tRenderHornStabs( barStart, spe, secPerEighth, chord, hornRng, exprRng );\n\t\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\tRenderDrumBar( barStart, spe, lastBar, noise, fillRng, fillNoise );\n\n\t\t\t// No lead in the ending: a lead phrase starts every two bars and runs ~two bars, so in\n\t\t\t// the short outro it spilled melody notes across the held final chord \u2014 the band has\n\t\t\t// already resolved and stopped, so the lead must too (it read as \"random notes after\n\t\t\t// the hold\"). The ending is just the fill bar \u2192 the ringing tonic.\n\t\t\tif ( playTop && bar % 2 == 0 && !isEnding )\n\t\t\t\tRenderLeadPhrase( barStart, spe, secPerEighth, chord, leadRng, exprRng );\n\t\t}\n\t}\n\n\t// The song's final downbeat. The whole band resolves home to the tonic on the \"one\" and\n\t// lets the chord ring out into the tail RingOutTail reserved \u2014 a landing, not a turnaround.\n\t// The previous bar's fill (see RenderSection) leads in and its terminal crash lands right\n\t// here, so the ending itself only adds the kick + the sustained, decaying chord.\n\t// \u2500\u2500 Swing: warp the eighth-note grid so the whole band shuffles together \u2500\u2500\n\t// On-beat (even) eighths are anchors that stay put; off-beat (odd) eighths are pushed late\n\t// by _swing of an eighth, and positions between anchors interpolate. So a sixteenth (e+0.5)\n\t// or a triplet subdivision lands on the SAME warped grid as the eighths \u2014 every voice swings\n\t// in lockstep instead of the skank chop alone. `eighths` is a within-bar position measured in\n\t// eighth-notes (0.5 = a sixteenth); returns the absolute sample index.\n\tint Swung( int barStart, int spe, double eighths )\n\t{\n\t\tdouble baseE = Math.Floor( eighths );\n\t\tdouble frac = eighths - baseE;\n\t\tlong slot = (long)baseE;\n\t\tdouble startShift = (slot & 1) == 1 ? _swing : 0.0; // this eighth's onset shift\n\t\tdouble endShift = ((slot + 1) & 1) == 1 ? _swing : 0.0; // the next eighth's onset shift\n\t\tdouble pos = baseE + startShift + frac * (1.0 + endShift - startShift);\n\t\treturn barStart + (int)Math.Round( pos * spe );\n\t}\n\n\tvoid RenderEnding( int barStart, int spe, Rng noise )\n\t{\n\t\tint at = Math.Max( 0, barStart );\n\t\tRenderKick( at, noise );\n\n\t\t// Resolve to the progression's tonic (slot 0) wherever the chord cycle happened to\n\t\t// land, and ring it with a natural exponential tail (Sustained = false) long enough to\n\t\t// bloom into the reserved tail room. A held triad up top + the root an octave below.\n\t\tint dur = (int)(_sr * RingOutTail * 0.92f);\n\t\tint baseMidi = _rootMidi + 19;\n\t\tint[] degs = { _prog[0], _prog[0] + 2, _prog[0] + 4, _prog[0] + 7 };\n\t\tforeach ( var d in degs )\n\t\t{\n\t\t\tvar pad = new Patch\n\t\t\t{\n\t\t\t\tOsc = 1, Voices = 3, Detune = _c.Detune,\n\t\t\t\tAmp = 0.7f / degs.Length, Attack = 0.006f, Decay = 1.2,\n\t\t\t\tSustain = 0f, Sustained = false,\n\t\t\t\tCutoff = _c.SkankCutoff, CutEnv = 700f, Reso = 0.8f,\n\t\t\t\tDrive = _genre == 3 ? 3.5f : 1.3f, Pan = 0f,\n\t\t\t};\n\t\t\tRenderPatch( at, dur, Midi( ScaleMidi( baseMidi, d ) ), pad );\n\t\t}\n\t\tvar low = new Patch\n\t\t{\n\t\t\tOsc = 3, Voices = 2, Detune = _c.Detune * 0.4f,\n\t\t\tAmp = _c.BassVol * _c.BassBalance, Attack = 0.004f, Decay = 1.4,\n\t\t\tSustain = 0f, Sustained = false,\n\t\t\tCutoff = _c.BassCutoff, CutEnv = 350f, Reso = 0.9f,\n\t\t\tDrive = _c.BassDrive, Pan = 0f,\n\t\t};\n\t\tRenderPatch( at, dur, Midi( ChordRoot( 0 ) ), low, mono: true );\n\t}\n\n\t// Master: gentle soft-clip + normalize. The mix peak is first normalized to 1.0\n\t// BEFORE the soft-clipper so it always has headroom \u2014 otherwise a hot sustained\n\t// bed (all voices flat at 1.0) saturated the tanh and swallowed the drum\n\t// transients (kick/snare washed out). MasterDrive now sets how hard a\n\t// peak-normalized signal hits the clipper, so the dynamics stay intact.\n\t// Call only after every RenderPitchedRange window has completed.\n\tfloat Master()\n\t{\n\t\tint total = _bufL.Length;\n\t\tfloat rawPeak = 0f;\n\t\tfor ( int i = 0; i < total; i++ )\n\t\t\trawPeak = Math.Max( rawPeak, Math.Max( MathF.Abs( _bufL[i] ), MathF.Abs( _bufR[i] ) ) );\n\t\tfloat pre = rawPeak > 0.0001f ? _c.MasterDrive / rawPeak : _c.MasterDrive;\n\n\t\tfor ( int i = 0; i < total; i++ )\n\t\t{\n\t\t\t_bufL[i] = (float)Math.Tanh( _bufL[i] * pre );\n\t\t\t_bufR[i] = (float)Math.Tanh( _bufR[i] * pre );\n\t\t}\n\n\t\t// A touch of stereo room reverb \u2014 the dry mix alone read flat/\"16-bit\".\n\t\tApplyReverb();\n\n\t\tfloat peak = 0f;\n\t\tfor ( int i = 0; i < total; i++ )\n\t\t{\n\t\t\tfloat a = Math.Max( MathF.Abs( _bufL[i] ), MathF.Abs( _bufR[i] ) );\n\t\t\tif ( a > peak ) peak = a;\n\t\t}\n\t\treturn peak > 0.0001f ? _c.MasterPeak / peak : 1f;\n\t}\n\n\t// \u2500\u2500 Master reverb \u2500\u2500\n\t// A Schroeder/Freeverb-style bank: several parallel damped comb filters (the dense\n\t// tail) feeding a chain of allpasses (diffusion). The two channels use slightly\n\t// different delay lengths so the room is decorrelated \u2192 real stereo width and depth.\n\tstatic readonly int[] CombBase = { 1116, 1188, 1277, 1356, 1422, 1491 }; // samples @ 44.1k\n\tstatic readonly int[] ApBase = { 556, 441, 341 };\n\tconst int ReverbStereoSpread = 23; // R-channel delay offset for decorrelation\n\n\tvoid ApplyReverb()\n\t{\n\t\tfloat wet = Math.Clamp( _c.MasterReverb, 0f, 1f );\n\t\tif ( wet <= 0.0001f ) return;\n\t\tfloat feedback = 0.70f + 0.28f * Math.Clamp( _c.ReverbDecay, 0f, 1f ); // tail length\n\t\tconst float damp = 0.25f, damp1 = 1f - damp; // HF damping in the tail\n\t\tconst float apg = 0.5f; // allpass coefficient\n\t\tconst float inGain = 0.25f; // drive into the reverb\n\t\tdouble srk = _sr / 44100.0; // scale delays to the rate\n\n\t\tfor ( int ch = 0; ch < 2; ch++ )\n\t\t{\n\t\t\tvar buf = ch == 0 ? _bufL : _bufR;\n\t\t\tint off = ch == 0 ? 0 : ReverbStereoSpread;\n\t\t\tint nc = CombBase.Length, na = ApBase.Length;\n\t\t\tvar combBuf = new float[nc][];\n\t\t\tvar combIdx = new int[nc];\n\t\t\tvar combStore = new float[nc];\n\t\t\tfor ( int j = 0; j < nc; j++ )\n\t\t\t\tcombBuf[j] = new float[Math.Max( 1, (int)Math.Round( (CombBase[j] + off) * srk ) )];\n\t\t\tvar apBuf = new float[na][];\n\t\t\tvar apIdx = new int[na];\n\t\t\tfor ( int j = 0; j < na; j++ )\n\t\t\t\tapBuf[j] = new float[Math.Max( 1, (int)Math.Round( (ApBase[j] + off) * srk ) )];\n\n\t\t\tint n = buf.Length;\n\t\t\tfor ( int i = 0; i < n; i++ )\n\t\t\t{\n\t\t\t\tfloat input = buf[i] * inGain;\n\t\t\t\tfloat acc = 0f;\n\t\t\t\tfor ( int j = 0; j < nc; j++ )\n\t\t\t\t{\n\t\t\t\t\tvar cb = combBuf[j];\n\t\t\t\t\tint idx = combIdx[j];\n\t\t\t\t\tfloat r = cb[idx];\n\t\t\t\t\tcombStore[j] = r * damp1 + combStore[j] * damp;\n\t\t\t\t\tcb[idx] = input + combStore[j] * feedback;\n\t\t\t\t\tif ( ++idx >= cb.Length ) idx = 0;\n\t\t\t\t\tcombIdx[j] = idx;\n\t\t\t\t\tacc += r;\n\t\t\t\t}\n\t\t\t\tacc /= nc;\n\t\t\t\tfor ( int j = 0; j < na; j++ )\n\t\t\t\t{\n\t\t\t\t\tvar ab = apBuf[j];\n\t\t\t\t\tint idx = apIdx[j];\n\t\t\t\t\tfloat r = ab[idx];\n\t\t\t\t\tfloat o = r - acc;\n\t\t\t\t\tab[idx] = acc + r * apg;\n\t\t\t\t\tif ( ++idx >= ab.Length ) idx = 0;\n\t\t\t\t\tapIdx[j] = idx;\n\t\t\t\t\tacc = o;\n\t\t\t\t}\n\t\t\t\tbuf[i] += wet * acc;\n\t\t\t}\n\t\t}\n\t}\n\n\tint ScaleMidi( int baseMidi, int degree )\n\t{\n\t\tint len = _scale.Length;\n\t\tint oct = (int)Math.Floor( degree / (double)len );\n\t\treturn baseMidi + _scale[degree - oct * len] + 12 * oct;\n\t}\n\tint ChordRoot( int c ) => ScaleMidi( _rootMidi, _prog[c] );\n\n\t// \u2500\u2500 Instrument expression \u2500\u2500\n\t// Four expressive PROPERTIES every pitched voice can lean on (drums are excluded). Each\n\t// instrument gets a genre-specific PROPENSITY for each, \"based on what it is\" \u2014 a brass\n\t// lead sings and scoops, a bass slides, a power-chord guitar stays dead straight. The\n\t// realization is the per-note pitch shaping in RenderEvent (vibrato depth + bend envelope).\n\t// Vib \u2014 #1 vibrato depth (a constant lean, no per-note roll)\n\t// BendIn \u2014 #2 bend up INTO the note from a step below (per-note chance)\n\t// Glide \u2014 #3 portamento from the previous note's pitch (per-note chance)\n\t// Scoop \u2014 #4 bend up-and-back within the note (per-note chance)\n\treadonly struct Expression\n\t{\n\t\tpublic readonly float Vib, BendIn, Glide, Scoop;\n\t\tpublic Expression( float vib, float bendIn, float glide, float scoop )\n\t\t{ Vib = vib; BendIn = bendIn; Glide = glide; Scoop = scoop; }\n\t}\n\n\tconst int NoPrev = int.MinValue; // \"no previous note\" sentinel for glide\n\n\t// The per-instrument propensity table \u2014 genre-aware. Leads route here by genre\n\t// (\"LEAD\" = ska brass, \"LEAD GTR\" = rock guitar). Rock lead's BENDINESS knob drives its\n\t// bend-in + scoop directly (that's what \"bendiness\" is). Tune these by ear.\n\tExpression Expr( string voice )\n\t{\n\t\tswitch ( voice )\n\t\t{\n\t\t\tcase \"BASS\": return _genre switch\n\t\t\t{\n\t\t\t\t1 => new Expression( 0f, 0f, 0.10f, 0f ), // rock: locked\n\t\t\t\t2 => new Expression( 0f, 0f, 0.12f, 0.03f ), // country: a subtle slide\n\t\t\t\t3 => default, // metal: dead straight, fast\n\t\t\t\t4 => default, // punk: dead straight, fast\n\t\t\t\t5 => default, // pop: tight synth bass, no slide\n\t\t\t\t_ => new Expression( 0f, 0f, 0.25f, 0.05f ), // reggae bass slides\n\t\t\t};\n\t\t\tcase \"SKANK\": return default; // staccato chops \u2014 dead straight\n\t\t\tcase \"ORGAN\": return new Expression( 0.15f, 0f, 0f, 0f ); // gentle bubble vibrato (only blooms on held notes)\n\t\t\tcase \"LEAD\": return new Expression( 0.35f, 0.15f, 0.10f, 0.25f ); // brass sings + scoops\n\t\t\tcase \"HORNS\": return new Expression( 0.20f, 0f, 0f, 0.20f ); // section stabs fall/scoop\n\t\t\tcase \"KEYS\": return default; // organ comp \u2014 locked, no wobble\n\t\t\tcase \"RHYTHM GTR\": return default; // power chords \u2014 straight\n\t\t\tcase \"LEAD GTR\":\n\t\t\t{\n\t\t\t\t// Country leans hard into bends (the telecaster twang); rock/metal ride the knob.\n\t\t\t\tfloat bend = _genre == 2 ? MathF.Max( _c.LeadGtrBend, 0.5f ) : _c.LeadGtrBend;\n\t\t\t\treturn new Expression( 0.30f, bend, 0.10f, bend );\n\t\t\t}\n\t\t\tdefault: return default;\n\t\t}\n\t}\n\n\t// A rolled-per-note voicing: the concrete pitch-shaping a note will get. Vibrato is a\n\t// constant depth (no draw); bend-in/glide/scoop are rolled against their propensities, so\n\t// only voices that lean on them ever pull from the expression stream.\n\tstruct Voicing { public float VibDepth, BendSemis, BendTime, ScoopSemis; }\n\n\tVoicing Roll( in Expression ex, int midi, int prevMidi, Rng rng )\n\t{\n\t\tvar v = new Voicing();\n\t\t// Vibrato depth is a SMALL pitch fraction (lean 0.5 \u2248 \u00b110 cents) and it's delayed in\n\t\t// the synth, so notes read locked-on, not seasick. BendTime is in SECONDS \u2014 a quick\n\t\t// slide that resolves and locks, never a fraction of a long held note.\n\t\tif ( ex.Vib > 0f ) v.VibDepth = 0.003f + 0.006f * ex.Vib;\n\t\tif ( ex.Glide > 0f && prevMidi != NoPrev && rng.Chance( ex.Glide ) )\n\t\t{\n\t\t\tv.BendSemis = Math.Clamp( (prevMidi - midi) * 0.3f, -2f, 2f ); // lean toward the prev pitch, not all the way\n\t\t\tv.BendTime = 0.13f; // ~130 ms portamento\n\t\t}\n\t\telse if ( ex.BendIn > 0f && rng.Chance( ex.BendIn ) )\n\t\t{\n\t\t\tv.BendSemis = rng.Chance( 0.5f ) ? -0.3f : -0.55f; // a subtle lean up into pitch\n\t\t\tv.BendTime = 0.09f; // ~90 ms bend up into pitch\n\t\t}\n\t\tif ( ex.Scoop > 0f && rng.Chance( ex.Scoop ) )\n\t\t\tv.ScoopSemis = rng.Chance( 0.5f ) ? 0.15f : 0.3f; // a slight attack hump\n\t\treturn v;\n\t}\n\n\t// Bake a rolled voicing onto a patch. VibDepth is harmless unless the patch carries a\n\t// vibrato RATE (p.Vibrato) \u2014 so a voice the user muted to 0 Hz stays dry \u2014 which means a\n\t// voice that wants expression-vibrato must set its own rate in its patch literal.\n\tstatic void ApplyVoicing( ref Patch p, in Voicing v )\n\t{\n\t\tif ( v.VibDepth > 0f ) p.VibDepth = v.VibDepth;\n\t\tp.BendSemis = v.BendSemis; p.BendTime = v.BendTime; p.ScoopSemis = v.ScoopSemis;\n\t}\n\n\t// \u2500\u2500 Bass \u2500\u2500\n\tvoid RenderBassBar( int barStart, int spe, double secPerEighth, int chord, int nextChord, Rng rng, Rng bassOrn, Rng exprRng )\n\t{\n\t\tint root = ChordRoot( chord );\n\t\tvar ex = Expr( \"BASS\" );\n\t\tint prevMidi = NoPrev;\n\t\t// Bass has its own ornament knob (BASS TRIPLETS), nudged up a touch by overall\n\t\t// kit busyness so a busy vibe gets a busier bass.\n\t\tfloat ornChance = _c.BassTriplets * 0.5f + _c.DrumBusy * 0.05f;\n\t\tfor ( int e = 0; e < EighthsPerBar; e++ )\n\t\t{\n\t\t\tint off = _bassPat[e];\n\t\t\tif ( off == Rest ) continue;\n\n\t\t\tint midi;\n\t\t\tif ( off == Approach )\n\t\t\t{\n\t\t\t\tint target = ChordRoot( nextChord );\n\t\t\t\tmidi = target - (rng.Chance( 0.5f ) ? 1 : 2); // chromatic/step lead-in\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tmidi = root + off;\n\t\t\t\tif ( off == 0 && e > 0 && rng.Chance( _c.OctavePopChance ) ) midi += 12;\n\t\t\t}\n\n\t\t\t// note runs until the next onset (legato reggae feel)\n\t\t\tint len = 1;\n\t\t\twhile ( e + len < EighthsPerBar && _bassPat[e + len] == Rest ) len++;\n\n\t\t\t// Chop a standalone (non-sustaining) note into a 16th pair or 16th-note\n\t\t\t// triplet so the line reads \"long long short short\" / \"long short long long\"\n\t\t\t// instead of even eighths. Driven by a dedicated stream, so the main\n\t\t\t// composition RNG order \u2014 and every existing song \u2014 is left unchanged.\n\t\t\tvar vc = Roll( ex, midi, prevMidi, exprRng );\n\t\t\tprevMidi = midi;\n\n\t\t\tif ( off != Approach && len == 1 && bassOrn.Chance( ornChance ) )\n\t\t\t{\n\t\t\t\tint n = bassOrn.Chance( 0.65f ) ? 2 : 3; // 16th pair / 16th triplet\n\t\t\t\tint step = spe / n;\n\t\t\t\tint[] moves = { 0, 7, 12 }; // root / fifth / octave\n\t\t\t\tfor ( int k = 0; k < n; k++ )\n\t\t\t\t{\n\t\t\t\t\tint bm = midi + (k == 0 ? 0 : moves[bassOrn.Int( moves.Length )]);\n\t\t\t\t\tEmitBass( Swung( barStart, spe, e + (double)k / n ), (int)(step * 0.9f), bm, secPerEighth / n * 0.8, vc );\n\t\t\t\t}\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tEmitBass( Swung( barStart, spe, e ), (int)(spe * len * 0.95f), midi, secPerEighth * len * 0.8, vc );\n\t\t}\n\t}\n\n\tvoid EmitBass( int at, int dur, int midi, double decaySec, in Voicing vc )\n\t{\n\t\t// Triangle body for a round, deep reggae/dub bass (saw alone read as too\n\t\t// buzzy) \u2014 but triangle alone was too subtle, so layer a quieter square\n\t\t// underneath for presence/definition. The square's odd harmonics give the\n\t\t// bass its bite; both share the bass low-pass so the tone stays warm.\n\t\tvar body = new Patch\n\t\t{\n\t\t\tOsc = 3, Voices = 2, Detune = _c.Detune * 0.4f,\n\t\t\tAmp = _c.BassVol * _c.BassBalance, Attack = 0.004f, Decay = decaySec,\n\t\t\tSustain = 0.55f, Sustained = true,\n\t\t\tCutoff = _c.BassCutoff, CutEnv = 350f, Reso = 0.9f,\n\t\t\tDrive = _c.BassDrive, Pan = 0f,\n\t\t};\n\t\tvar sub = new Patch\n\t\t{\n\t\t\tOsc = 2, Voices = 1, Detune = 0f,\n\t\t\tAmp = _c.BassVol * 0.4f * _c.BassBalance, Attack = 0.004f, Decay = decaySec,\n\t\t\tSustain = 0.55f, Sustained = true,\n\t\t\tCutoff = _c.BassCutoff, CutEnv = 350f, Reso = 0.9f,\n\t\t\tDrive = _c.BassDrive, Pan = 0f,\n\t\t};\n\t\tApplyVoicing( ref body, vc ); ApplyVoicing( ref sub, vc );\n\t\tRenderPatch( at, dur, Midi( midi ), body, mono: true );\n\t\tRenderPatch( at, dur, Midi( midi ), sub, mono: true );\n\t}\n\n\t// \u2500\u2500 Skank guitar (the signature) + reggae organ bubble \u2014 offbeats, centered \u2500\u2500\n\tvoid RenderRhythmBar( int barStart, int spe, double secPerEighth, int chord, Rng rng, Rng exprRng )\n\t{\n\t\t// +24: skank/organ sit an octave above the old register \u2014 at +12 (E2..B2) the\n\t\t// chop was too low/muddy to cut through and read as missing. Organ stays a\n\t\t// further octave down via the -12 below.\n\t\tint gBase = _rootMidi + 24;\n\t\tint[] degs = { _prog[chord], _prog[chord] + 2, _prog[chord] + 4, _prog[chord] + 7 };\n\t\t// Skank is dead straight (default); the organ bubble gets a gentle vibrato depth.\n\t\tvar organVc = Roll( Expr( \"ORGAN\" ), 0, NoPrev, exprRng );\n\n\t\tfor ( int e = 1; e < EighthsPerBar; e += 2 ) // offbeats\n\t\t{\n\t\t\tint at = Swung( barStart, spe, e ); // odd e = offbeat \u2192 Swung pushes it late by _swing\n\n\t\t\t// bright, thin, short guitar chop\n\t\t\tforeach ( var d in degs )\n\t\t\t\tRenderPatch( at, (int)(spe * Math.Clamp( _c.SkankChop, 0.15f, 1f )), Midi( ScaleMidi( gBase, d ) ), new Patch\n\t\t\t\t{\n\t\t\t\t\tOsc = 1, Voices = 3, Detune = _c.Detune,\n\t\t\t\t\tAmp = _c.SkankVol * _c.SkankBalance / degs.Length, Attack = 0.002f, Decay = 0.10,\n\t\t\t\t\tSustain = 0f, Sustained = false,\n\t\t\t\t\tCutoff = _c.SkankCutoff, CutEnv = 1500f, Reso = 0.8f,\n\t\t\t\t\tHighpass = _c.SkankHighpass, Drive = _c.SkankDrive, Pan = 0f,\n\t\t\t\t} );\n\n\t\t\t// reggae organ \"bubble\": a softer, rounder offbeat under the guitar\n\t\t\tif ( _organBubble )\n\t\t\t\tforeach ( var d in degs )\n\t\t\t\t{\n\t\t\t\t\tvar organ = new Patch\n\t\t\t\t\t{\n\t\t\t\t\t\tOsc = 0, Voices = 2, Detune = _c.Detune * 0.5f,\n\t\t\t\t\t\tAmp = _c.OrganVol * _c.OrganBalance / degs.Length, Attack = 0.004f, Decay = 0.16,\n\t\t\t\t\t\tSustain = 0.3f, Sustained = false,\n\t\t\t\t\t\tCutoff = _c.OrganCutoff, CutEnv = 0f, Reso = 1.0f, Drive = 1.1f, Pan = 0f,\n\t\t\t\t\t\tVibrato = _c.OrganVibrato,\n\t\t\t\t\t};\n\t\t\t\t\tApplyVoicing( ref organ, organVc );\n\t\t\t\t\tRenderPatch( at, (int)(spe * 0.55f), Midi( ScaleMidi( gBase, d ) - 12 ), organ );\n\t\t\t\t}\n\t\t}\n\t}\n\n\t// \u2500\u2500 Lead melody (chord-tone locked \u2192 consonant) \u2500\u2500\n\tvoid RenderLeadPhrase( int barStart, int spe, double secPerEighth, int chord, Rng rng, Rng exprRng )\n\t{\n\t\tint slots = EighthsPerBar * 2;\n\t\tint melBase = _rootMidi + 24;\n\t\tint[] tones = { _prog[chord], _prog[chord] + 2, _prog[chord] + 4, _prog[chord] + 6 }; // chord tones\n\t\tint degree = tones[rng.Int( 3 )];\n\t\tbool guitarLead = _genre != 0; // ska is the only horn lead\n\t\tfloat amp = guitarLead ? _c.LeadGtrVol * _c.LeadGtrBalance : _c.MelodyVol * _c.MelodyBalance;\n\t\tfloat drive = guitarLead ? _c.LeadGtrDrive : _c.MelodyDrive;\n\t\t// Rock lead trades fast RUNS for BENDINESS (handled via expression), so its run rate is\n\t\t// forced to 0; metal shreds (a high floor of runs); ska/country keep the TRIPLETS knob.\n\t\tfloat tripChance = _genre switch\n\t\t{\n\t\t\t1 => 0f,\n\t\t\t3 => MathF.Max( _c.TripletChance, 0.4f ),\n\t\t\t_ => _c.TripletChance,\n\t\t};\n\t\tvar ex = guitarLead ? Expr( \"LEAD GTR\" ) : Expr( \"LEAD\" );\n\t\tint prevMidi = NoPrev;\n\n\t\tint e = 0;\n\t\twhile ( e < slots )\n\t\t{\n\t\t\tif ( rng.Chance( _c.MelodyRestChance ) ) { e++; continue; }\n\n\t\t\t// ornament: a sixteenth pair, or a triplet at one of three rates \u2014 a tight\n\t\t\t// 16th-triplet (3 in an eighth), an eighth-note triplet (3 in a beat), or a\n\t\t\t// wide quarter-note triplet (3 over two beats). Wider spans give the lazy,\n\t\t\t// over-the-barline triplet feel, not just the fast run.\n\t\t\tif ( rng.Chance( tripChance ) )\n\t\t\t{\n\t\t\t\tfloat r = rng.Next();\n\t\t\t\tint n, spanE; // n notes evenly across spanE eighths\n\t\t\t\tif ( r < 0.25f ) { n = 2; spanE = 1; } // sixteenth pair\n\t\t\t\telse if ( r < 0.50f ) { n = 3; spanE = 1; } // 16th-note triplet\n\t\t\t\telse if ( r < 0.80f ) { n = 3; spanE = 2; } // eighth-note triplet (1 beat)\n\t\t\t\telse { n = 3; spanE = 4; } // quarter-note triplet (2 beats)\n\t\t\t\tif ( e + spanE > slots ) spanE = 1;\n\n\t\t\t\tint span = spanE * spe;\n\t\t\t\tint step = span / n;\n\t\t\t\tint firstMidi = ScaleMidi( melBase, Math.Clamp( degree - n / 2, _prog[chord] - 3, _prog[chord] + 10 ) );\n\t\t\t\tvar runVc = Roll( ex, firstMidi, prevMidi, exprRng );\n\t\t\t\tfor ( int k = 0; k < n; k++ )\n\t\t\t\t{\n\t\t\t\t\tint d2 = Math.Clamp( degree + (k - n / 2), _prog[chord] - 3, _prog[chord] + 10 );\n\t\t\t\t\tint m2 = ScaleMidi( melBase, d2 );\n\t\t\t\t\tRenderLeadNote( Swung( barStart, spe, e + (double)k * spanE / n ), (int)(step * 0.9f),\n\t\t\t\t\t\tm2, amp, secPerEighth * spanE / (double)n * 0.85, drive, runVc );\n\t\t\t\t\tprevMidi = m2;\n\t\t\t\t}\n\t\t\t\te += spanE;\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tint len = 1 + rng.Int( 3 );\n\t\t\tif ( e + len > slots ) len = slots - e;\n\t\t\tbool strong = (e % 2) == 0;\n\n\t\t\tif ( strong )\n\t\t\t{\n\t\t\t\t// land on a chord tone near the current degree\n\t\t\t\tint best = tones[0], bestD = 999;\n\t\t\t\tforeach ( var t in tones )\n\t\t\t\t{\n\t\t\t\t\tfor ( int oc = -7; oc <= 14; oc += 7 )\n\t\t\t\t\t{\n\t\t\t\t\t\tint cand = t + (oc / 7) * 7; // keep in degree space\n\t\t\t\t\t\tint dist = Math.Abs( cand - degree );\n\t\t\t\t\t\tif ( dist < bestD ) { bestD = dist; best = cand; }\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tdegree = best;\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tint step = rng.Chance( _c.MelodyLeapChance ) ? (rng.Chance( 0.5f ) ? 3 : -3) : (rng.Chance( 0.5f ) ? 1 : -1);\n\t\t\t\tdegree = Math.Clamp( degree + step, _prog[chord] - 3, _prog[chord] + 10 );\n\t\t\t}\n\n\t\t\tint midi = ScaleMidi( melBase, degree );\n\t\t\tvar vc = Roll( ex, midi, prevMidi, exprRng );\n\t\t\tRenderLeadNote( Swung( barStart, spe, e ), (int)(spe * len * 0.9f), midi,\n\t\t\t\tamp, secPerEighth * len * 0.7f, drive, vc );\n\t\t\tprevMidi = midi;\n\t\t\te += len;\n\t\t}\n\t}\n\n\t// Dispatch a lead note to the genre's lead voice: a distorted single-note guitar for rock,\n\t// otherwise the ska horn (RenderLead \u2192 trumpet).\n\tvoid RenderLeadNote( int at, int dur, int midi, float amp, double decaySec, float drive, in Voicing vc )\n\t{\n\t\tif ( _genre != 0 )\n\t\t{\n\t\t\t// Twang = a bright cutoff-envelope snap on each pick (high CutEnv, decays fast) through\n\t\t\t// a resonant SVF, plus a BASE distortion under the slider so it reads as an electric\n\t\t\t// guitar even at the slider minimum. The base is genre-set: rock = 3 (overdriven),\n\t\t\t// metal = 4 hot (heavy), country = clean (the bite comes from the twang snap + bends,\n\t\t\t// not gain). The bends (BENDINESS knob \u2192 bend-in + scoop) come in via the voicing.\n\t\t\tfloat driveAmt = _genre switch\n\t\t\t{\n\t\t\t\t3 => 4f + MathF.Max( 1f, _c.LeadGtrDrive ), // metal: heavy\n\t\t\t\t2 => 0.8f + 0.3f * MathF.Max( 1f, _c.LeadGtrDrive ),// country: clean twang\n\t\t\t\t4 => 2f + MathF.Max( 1f, _c.LeadGtrDrive ), // punk: bright, lightly driven\n\t\t\t\t5 => 0.6f + 0.2f * MathF.Max( 1f, _c.LeadGtrDrive ),// pop: clean synth pluck/lead\n\t\t\t\t_ => 3f + MathF.Max( 1f, _c.LeadGtrDrive ), // rock\n\t\t\t};\n\t\t\t// Country/pop get a brighter cutoff snap \u2014 country for telecaster twang, pop for a\n\t\t\t// plucky synth attack.\n\t\t\tfloat cutEnv = _genre == 2 ? 3000f : _genre == 5 ? 3500f : 2200f;\n\t\t\tvar gtr = new Patch\n\t\t\t{\n\t\t\t\tOsc = 1, Voices = 1, Detune = 0f, Amp = amp,\n\t\t\t\tAttack = 0.002f, Decay = decaySec, Sustain = 0.55f, Sustained = true,\n\t\t\t\tCutoff = _c.LeadGtrCutoff, CutEnv = cutEnv, Reso = 0.65f,\n\t\t\t\tDrive = driveAmt, Pan = _leadPan, Vibrato = _c.MelodyVibrato,\n\t\t\t};\n\t\t\tApplyVoicing( ref gtr, vc );\n\t\t\t// The lead is monophonic \u2014 a single clean take at its per-song pan (_leadPan). It is NOT\n\t\t\t// double-tracked: splitting a solo line into two detuned, hard-panned, time-offset takes\n\t\t\t// beats on sustained notes and overlaps adjacent pitches on runs, reading as \"out of key\".\n\t\t\t// Double-tracking width is for the chordal/strummed voices, not the melody.\n\t\t\tRenderPatch( at, dur, Midi( midi ), gtr, mono: true );\n\t\t\treturn;\n\t\t}\n\t\tRenderLead( at, dur, midi, amp, decaySec, drive, vc );\n\t}\n\n\t// \u2500\u2500 Rock KEYS \u2014 their OWN part, not a double of the guitar. A syncopated organ comp (the\n\t// \"1, &-of-2, 3, &-of-4\" Charleston push) playing diatonic TRIADS (root/3rd/5th) in a high\n\t// keyboard register. Different notes (a real triad vs the guitar's bare power chord) AND a\n\t// different rhythm (a 4-hit syncopation vs the guitar's every-eighth chug), so the two\n\t// interlock instead of playing in lockstep. KeysChug rings the chords (0) or tightens them\n\t// toward short stabs (1).\n\tstatic readonly int[] KeysOnsets = { 0, 3, 4, 7 }; // eighth positions of the comp's hits\n\tvoid RenderKeysBar( int barStart, int spe, double secPerEighth, int chord, Rng rng, Rng exprRng )\n\t{\n\t\tint kBase = _rootMidi + 24; // keyboard register, an octave over the rhythm guitar\n\t\tint[] degs = { _prog[chord], _prog[chord] + 2, _prog[chord] + 4 }; // diatonic triad\n\t\tfloat chug = Math.Clamp( _c.KeysChug, 0f, 1f );\n\t\t// Country reads this comp as a honky-tonk piano and pop as a clean bright synth: keep both\n\t\t// clean (rock drives it dirty).\n\t\tfloat keysDrive = _genre == 2 || _genre == 5 ? 1f + 0.2f * MathF.Max( 1f, _c.KeysDrive )\n\t\t : MathF.Max( 1f, _c.KeysDrive );\n\t\tvar keysVc = Roll( Expr( \"KEYS\" ), 0, NoPrev, exprRng ); // gentle vibrato only\n\t\tfor ( int oi = 0; oi < KeysOnsets.Length; oi++ )\n\t\t{\n\t\t\tint e = KeysOnsets[oi];\n\t\t\tint nextE = oi + 1 < KeysOnsets.Length ? KeysOnsets[oi + 1] : EighthsPerBar; // ring up to the next hit\n\t\t\tint gap = nextE - e;\n\t\t\tbool ring = chug < 0.5f;\n\t\t\tint dur = (int)(gap * spe * Math.Max( 0.25f, 1f - 0.7f * chug ));\n\t\t\tdouble dec = secPerEighth * gap * (ring ? 0.9 : 0.4);\n\t\t\tforeach ( var d in degs )\n\t\t\t{\n\t\t\t\tvar keys = new Patch\n\t\t\t\t{\n\t\t\t\t\tOsc = 1, Voices = 2, Detune = _c.Detune * 0.5f,\n\t\t\t\t\tAmp = _c.KeysVol * _c.KeysBalance / degs.Length,\n\t\t\t\t\tAttack = 0.004f, Decay = dec, Sustain = ring ? 0.6f : 0.2f, Sustained = ring,\n\t\t\t\t\tCutoff = _c.KeysCutoff, CutEnv = 250f, Reso = 1.0f,\n\t\t\t\t\tDrive = keysDrive, Pan = 0f,\n\t\t\t\t};\n\t\t\t\tApplyVoicing( ref keys, keysVc );\n\t\t\t\tRenderPatch( Swung( barStart, spe, e ), dur, Midi( ScaleMidi( kBase, d ) ), keys );\n\t\t\t}\n\t\t}\n\t}\n\n\t// \u2500\u2500 Rock rhythm guitar \u2014 twangy distorted power chords. Shares the lead guitar's voice (the\n\t// bright cutoff-envelope \"twang\" through a resonant SVF) but strums root+fifth+octave and\n\t// runs a LOWER base distortion than the lead so the two layer instead of mush. Downbeats\n\t// ring; offbeats tighten toward a palm-muted chug as RhythmGtrChug rises.\n\t// exprRng is unused: power chords stay dead straight (Expr(\"RHYTHM GTR\") is default), but the\n\t// param keeps every instrument's call site uniform.\n\tvoid RenderRhythmGuitarBar( int barStart, int spe, double secPerEighth, int chord, Rng rng, Rng exprRng )\n\t{\n\t\tbool country = _genre == 2;\n\t\tint root = ChordRoot( chord ) + 12; // chunky register, an octave up\n\t\t// Country strums a full DIATONIC triad (root/3rd/5th + octave) clean and bright \u2014 built in\n\t\t// degree space (ScaleMidi) so the 3rd follows the mode, matching the keys/lead. A hardcoded\n\t\t// major 3rd clashed on the minor chords of a progression (e.g. the vi in {0,4,5,3}). Rock\n\t\t// chunks a bare, mode-neutral power chord (root/5th/octave) with more base distortion.\n\t\tint triBase = _rootMidi + 12; // same register as `root`, in degree space\n\t\tint[] notes = country\n\t\t\t? new[] { ScaleMidi( triBase, _prog[chord] ), ScaleMidi( triBase, _prog[chord] + 2 ),\n\t\t\t ScaleMidi( triBase, _prog[chord] + 4 ), ScaleMidi( triBase, _prog[chord] + 7 ) }\n\t\t\t: new[] { root, root + 7, root + 12 };\n\t\tfloat chug = Math.Clamp( _c.RhythmGtrChug, 0f, 1f );\n\t\tfloat cutEnv = country ? 2600f : 1400f; // brighter twang for the clean strum\n\t\tfloat driveAmt = country ? 0.8f + 0.3f * MathF.Max( 1f, _c.RhythmGtrDrive )\n\t\t : 1.5f + MathF.Max( 1f, _c.RhythmGtrDrive ); // less base than lead\n\t\tfor ( int e = 0; e < EighthsPerBar; e++ )\n\t\t{\n\t\t\tbool accent = (e % 2) == 0; // downbeats ring, offbeats chug\n\t\t\tfloat lenFrac = accent ? (1f - 0.5f * chug) : (0.35f - 0.2f * chug);\n\t\t\tint dur = (int)(spe * Math.Max( 0.12f, lenFrac ));\n\t\t\tdouble dec = secPerEighth * (accent ? 0.8 : 0.3);\n\t\t\tforeach ( var m in notes )\n\t\t\t\tRenderPatch( Swung( barStart, spe, e ), dur, Midi( m ), new Patch\n\t\t\t\t{\n\t\t\t\t\tOsc = 1, Voices = 2, Detune = _c.Detune * 0.5f,\n\t\t\t\t\tAmp = _c.RhythmGtrVol * _c.RhythmGtrBalance / notes.Length * (accent ? 1f : 0.7f),\n\t\t\t\t\tAttack = 0.002f, Decay = dec, Sustain = accent ? 0.45f : 0f, Sustained = accent,\n\t\t\t\t\tCutoff = _c.RhythmGtrCutoff, CutEnv = cutEnv, Reso = 0.8f, // twang\n\t\t\t\t\tDrive = driveAmt, Pan = 0f,\n\t\t\t\t} );\n\t\t}\n\t}\n\n\t// \u2500\u2500 Metal rhythm guitar \u2014 palm-muted 16th-note gallop on the low root with power-chord\n\t// accents. The relentless 16th chug (under the double-kick) is the \"fast riff\" engine; the\n\t// downbeats and a few syncopated stabs ring a full power chord. Heavy base distortion, dark\n\t// and tight. rng (the rhythm stream) breaks up the accent placement so riffs vary by section.\n\tvoid RenderMetalRiffBar( int barStart, int spe, double secPerEighth, int chord, Rng rng, Rng exprRng )\n\t{\n\t\tint root = ChordRoot( chord ); // low, chunky \u2014 no octave bump\n\t\tint[] power = { 0, 7, 12 };\n\t\tint six = spe / 2;\n\t\tif ( six <= 0 ) return;\n\t\tfloat chug = Math.Clamp( _c.RhythmGtrChug, 0f, 1f );\n\t\tfloat driveAmt = 4f + MathF.Max( 1f, _c.RhythmGtrDrive ); // heavy\n\t\tfor ( int s = 0; s < EighthsPerBar * 2; s++ ) // 16 sixteenths\n\t\t{\n\t\t\tint at = Swung( barStart, spe, s * 0.5 ); // 16 sixteenths on the swung grid\n\t\t\tbool beat = s % 4 == 0; // quarter-note downbeats \u2192 ring a chord\n\t\t\tbool ring = beat || (s % 2 == 0 && rng.Chance( 0.3f )); // some offbeat eighths ring too\n\t\t\tint[] offs = ring ? power : new[] { 0 }; // accents = power chord, chugs = root only\n\t\t\tfloat gain = ring ? 1f : 0.6f;\n\t\t\t// Palm mute = short, tight; accents ring longer. Chug tightens the muted notes further.\n\t\t\tint dur = (int)(six * (ring ? 0.9f : Math.Max( 0.25f, 0.55f - 0.3f * chug )));\n\t\t\tdouble dec = secPerEighth * (ring ? 0.4 : 0.12);\n\t\t\tforeach ( var o in offs )\n\t\t\t\tRenderPatch( at, dur, Midi( root + o ), new Patch\n\t\t\t\t{\n\t\t\t\t\tOsc = 1, Voices = 2, Detune = _c.Detune * 0.5f,\n\t\t\t\t\tAmp = _c.RhythmGtrVol * _c.RhythmGtrBalance / offs.Length * gain,\n\t\t\t\t\tAttack = 0.002f, Decay = dec, Sustain = ring ? 0.35f : 0f, Sustained = ring,\n\t\t\t\t\tCutoff = _c.RhythmGtrCutoff, CutEnv = 1100f, Reso = 0.7f,\n\t\t\t\t\tDrive = driveAmt, Pan = 0f,\n\t\t\t\t} );\n\t\t}\n\t}\n\n\t// \u2500\u2500 Backing horns (panned spread) \u2500\u2500\n\t// Block stabs on the mask read \"samey\" (only eighth-note chords). A dedicated\n\t// stream (horn:tag, so the main composition order is unchanged) breaks them up\n\t// with rolling arpeggios, 16th pairs, grace pickups and varied length. Kept\n\t// modest \u2014 the bass got over-busy when its ornament rate ran high.\n\tvoid RenderHornStabs( int barStart, int spe, double secPerEighth, int chord, Rng orn, Rng exprRng )\n\t{\n\t\tint baseMidi = _rootMidi + 19;\n\t\tint[] degs = { _prog[chord], _prog[chord] + 2, _prog[chord] + 4 };\n\t\tfloat spread = _c.PanAmount * 0.7f;\n\t\tint six = spe / 2;\n\t\tfloat ornChance = 0.18f + _c.TripletChance; // ~0.24 default; rides the same knob\n\n\t\t// Section expression \u2014 vibrato + a chance of a scoop/fall, rolled once per onset (below)\n\t\t// and applied to every tone of that stab so the whole section bends together.\n\t\tvar ex = Expr( \"HORNS\" );\n\t\tVoicing hornVc = default;\n\n\t\t// one chord-tone voice\n\t\tvoid Note( int at, int dur, int k, double dec, float gain )\n\t\t{\n\t\t\tvar horn = new Patch\n\t\t\t{\n\t\t\t\tOsc = 1, Voices = 3, Detune = _c.Detune,\n\t\t\t\tAmp = _c.HornVol * _c.HornBalance / degs.Length * gain, Attack = 0.008f, Decay = dec,\n\t\t\t\tSustain = 0.2f, Sustained = false,\n\t\t\t\tCutoff = _c.HornCutoff, CutEnv = 1200f, Reso = 1.0f,\n\t\t\t\tDrive = _c.HornDrive, Pan = spread * (k / (float)(degs.Length - 1) * 2f - 1f),\n\t\t\t\tVibrato = _c.MelodyVibrato,\n\t\t\t};\n\t\t\tApplyVoicing( ref horn, hornVc );\n\t\t\tRenderPatch( at, dur, Midi( ScaleMidi( baseMidi, degs[k] ) ), horn );\n\t\t}\n\n\t\t// full block chord stab\n\t\tvoid Stab( int at, int dur, double dec, float gain )\n\t\t{\n\t\t\tfor ( int k = 0; k < degs.Length; k++ ) Note( at, dur, k, dec, gain );\n\t\t}\n\n\t\tfor ( int e = 0; e < EighthsPerBar; e++ )\n\t\t{\n\t\t\tif ( !_hornMask[e] ) continue;\n\t\t\tint at = Swung( barStart, spe, e );\n\t\t\thornVc = Roll( ex, baseMidi, NoPrev, exprRng );\n\n\t\t\tif ( six > 0 && orn.Chance( ornChance ) )\n\t\t\t{\n\t\t\t\tfloat r = orn.Next();\n\t\t\t\tif ( r < 0.4f )\n\t\t\t\t{\n\t\t\t\t\t// rolling arpeggio: chord tones climb across a 16th-triplet\n\t\t\t\t\tint step = spe / 3;\n\t\t\t\t\tfor ( int k = 0; k < degs.Length; k++ )\n\t\t\t\t\t\tNote( Swung( barStart, spe, e + (double)k / 3 ), (int)(step * 0.9f), k, secPerEighth / 3 * 0.8, 1f );\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\t\t\t\tif ( r < 0.75f )\n\t\t\t\t{\n\t\t\t\t\t// 16th pair: stab on the beat, softer echo on the \"e\"\n\t\t\t\t\tStab( at, (int)(six * 0.85f), secPerEighth * 0.5 * 0.8, 1f );\n\t\t\t\t\tStab( Swung( barStart, spe, e + 0.5 ), (int)(six * 0.85f), secPerEighth * 0.5 * 0.7, 0.6f );\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\t\t\t\t// grace pickup: a soft single tone just before the block stab\n\t\t\t\tNote( Swung( barStart, spe, e - 0.5 ), (int)(six * 0.8f), 0, secPerEighth * 0.5 * 0.6, 0.5f );\n\t\t\t\tStab( at, (int)(spe * 0.6f), 0.22, 1f );\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\t// plain stab \u2014 length varies a touch so even straight bars aren't identical\n\t\t\tfloat lenMul = 0.45f + orn.Next() * 0.35f;\n\t\t\tStab( at, (int)(spe * lenMul), 0.22, 1f );\n\t\t}\n\t}\n\n\t// \u2500\u2500 Lead instrument voices \u2500\u2500\n\tenum Instrument { Trumpet, Sax, Organ, Trombone }\n\n\tInstrument PickInstrument( Rng rng )\n\t{\n\t\tif ( _c.ForceInstrument >= 0 && _c.ForceInstrument <= 3 ) return (Instrument)_c.ForceInstrument;\n\t\tfloat tw = MathF.Max( 0f, _c.TrumpetWeight ), sw = MathF.Max( 0f, _c.SaxWeight );\n\t\tfloat ow = MathF.Max( 0f, _c.OrganWeight ), bw = MathF.Max( 0f, _c.TromboneWeight );\n\t\tfloat sum = tw + sw + ow + bw;\n\t\tif ( sum <= 0f ) return Instrument.Trumpet;\n\t\tfloat r = rng.Next() * sum;\n\t\tif ( (r -= tw) < 0f ) return Instrument.Trumpet;\n\t\tif ( (r -= sw) < 0f ) return Instrument.Sax;\n\t\tif ( (r -= ow) < 0f ) return Instrument.Organ;\n\t\treturn Instrument.Trombone;\n\t}\n\n\tvoid RenderLead( int at, int dur, int midi, float amp, double decaySec, float drive, in Voicing vc )\n\t{\n\t\tPatch p; int m = midi;\n\t\tswitch ( _lead )\n\t\t{\n\t\t\tcase Instrument.Trumpet:\n\t\t\t\tp = new Patch\n\t\t\t\t{\n\t\t\t\t\tOsc = 1, Voices = 3, Detune = _c.Detune * 0.7f, Amp = amp,\n\t\t\t\t\tAttack = 0.01f, Decay = decaySec, Sustain = 0.7f, Sustained = true,\n\t\t\t\t\tCutoff = _c.LeadCutoff, CutEnv = 1800f, Reso = 1.0f, Drive = drive,\n\t\t\t\t\tPan = _leadPan, Vibrato = _c.MelodyVibrato,\n\t\t\t\t};\n\t\t\t\tbreak;\n\t\t\tcase Instrument.Trombone:\n\t\t\t\tm = midi - 12;\n\t\t\t\tp = new Patch\n\t\t\t\t{\n\t\t\t\t\tOsc = 1, Voices = 3, Detune = _c.Detune * 0.7f, Amp = amp * 1.1f,\n\t\t\t\t\tAttack = 0.02f, Decay = decaySec, Sustain = 0.7f, Sustained = true,\n\t\t\t\t\tCutoff = _c.LeadCutoff * 0.7f, CutEnv = 900f, Reso = 1.0f, Drive = MathF.Max( 1f, drive * 0.8f ),\n\t\t\t\t\tPan = _leadPan, Vibrato = _c.MelodyVibrato * 0.7f,\n\t\t\t\t};\n\t\t\t\tbreak;\n\t\t\tcase Instrument.Sax:\n\t\t\t\tp = new Patch\n\t\t\t\t{\n\t\t\t\t\tOsc = 3, Voices = 2, Detune = _c.Detune * 0.5f, Amp = amp * 1.15f,\n\t\t\t\t\tAttack = 0.014f, Decay = decaySec, Sustain = 0.75f, Sustained = true,\n\t\t\t\t\tCutoff = _c.LeadCutoff, CutEnv = 1400f, Reso = 0.7f, Drive = MathF.Max( 1.2f, drive ),\n\t\t\t\t\tPan = _leadPan, Vibrato = _c.MelodyVibrato, Breath = 0.03f,\n\t\t\t\t};\n\t\t\t\tbreak;\n\t\t\tdefault: // Organ\n\t\t\t\tp = new Patch\n\t\t\t\t{\n\t\t\t\t\tOsc = 0, Voices = 3, Detune = _c.Detune * 0.6f, Amp = amp,\n\t\t\t\t\tAttack = 0.006f, Decay = decaySec * 1.5, Sustain = 0.9f, Sustained = true,\n\t\t\t\t\tCutoff = 2600f, CutEnv = 0f, Reso = 1.0f, Drive = 1.15f,\n\t\t\t\t\tPan = _leadPan, Vibrato = _c.MelodyVibrato * 0.9f,\n\t\t\t\t};\n\t\t\t\tbreak;\n\t\t}\n\t\tApplyVoicing( ref p, vc );\n\t\t// Single clean take at _leadPan \u2014 the ska horn/organ lead is monophonic, so it is not\n\t\t// double-tracked (see the guitar-lead note in RenderLeadNote: doubling a solo line smears\n\t\t// pitch). Only chordal/strummed voices get the width.\n\t\tRenderPatch( at, dur, Midi( m ), p, mono: true );\n\t}\n\n\t// \u2500\u2500 Synth core: unison osc \u2192 optional high-pass \u2192 resonant low-pass (cutoff\n\t// envelope) \u2192 soft drive \u2192 AD/sustain amp env. \u2500\u2500\n\tstruct Patch\n\t{\n\t\tpublic int Osc; // 0 sine 1 saw 2 square 3 triangle\n\t\tpublic int Voices;\n\t\tpublic float Detune; // cents\n\t\tpublic float Amp;\n\t\tpublic float Attack; // sec\n\t\tpublic double Decay; // sec (exp time constant)\n\t\tpublic float Sustain; // 0..1 (only if Sustained)\n\t\tpublic bool Sustained;\n\t\tpublic float Cutoff; // Hz low-pass\n\t\tpublic float CutEnv; // Hz added at attack, decays with Decay\n\t\tpublic float Reso; // SVF damping (lower = more resonance)\n\t\tpublic float Highpass; // Hz one-pole high-pass (0 = off)\n\t\tpublic float Drive; // tanh\n\t\tpublic float Pan; // -1..1\n\t\tpublic float Vibrato; // Hz (rate of the pitch wobble)\n\t\tpublic float Breath; // 0..1 noise mix (reeds)\n\t\t// \u2500\u2500 Expression (per-note pitch shaping; see Expression/Voicing) \u2500\u2500\n\t\tpublic float VibDepth; // vibrato depth as a pitch fraction (0 \u2192 legacy 0.005 when Vibrato>0)\n\t\tpublic float BendSemis; // pitch offset in semitones at note START, glides to 0 (bend-in / glide); \u2212ve starts below\n\t\tpublic float BendTime; // 0..1 fraction of the note over which BendSemis glides to 0\n\t\tpublic float ScoopSemis; // height (semitones) of a mid-note bend-up-and-back hump (0 = none)\n\t\tpublic float PhaseSeed; // oscillator start phase (0..1); 0 = legacy in-phase start. Used to\n\t\t // decorrelate the two double-tracking takes (see RenderPatch).\n\t}\n\n\t// Pitched note events collected during ComposePlan, then synthesized by\n\t// RenderPitchedRange. Synthesis pulls no RNG, so windows parallelize across threads.\n\tstruct NoteEvent { public int Start, Dur; public float Freq; public Patch P; }\n\treadonly List<NoteEvent> _events = new();\n\n\t// During ComposePlan this only enqueues; the synthesis happens in RenderPitchedRange.\n\t// When DoubleTrack is on, every (non-drum) note is widened into two decorrelated takes\n\t// panned apart \u2014 see the Config \"width\" block. `lead` selects the wider lead spread.\n\tvoid RenderPatch( int start, int dur, float freq, Patch p, bool lead = false, bool mono = false )\n\t{\n\t\tif ( start < 0 || dur <= 0 || p.Voices < 1 ) return;\n\t\t// Bass stays centred (mono): doubling/detuning low frequencies smears the low end and\n\t\t// cancels in mono, so the bass is the one voice kept dead-centre regardless of width.\n\t\tif ( mono || _c.DoubleTrack < 0.5f )\n\t\t{\n\t\t\t_events.Add( new NoteEvent { Start = start, Dur = dur, Freq = freq, P = p } );\n\t\t\treturn;\n\t\t}\n\t\t// The STEREO WIDTH slider (_widthScale) scales the whole effect: pan AND the\n\t\t// decorrelation (detune / delay / jitter / phase / per-note variation, all scaled inside\n\t\t// AddTake). At 100% it's the full design width; at 0% both takes collapse onto each other\n\t\t// at centre \u2192 effectively mono.\n\t\tfloat width = Math.Clamp( lead ? _c.WidthLead : _c.WidthBacking, 0f, 1f ) * _widthScale;\n\t\tfloat halfCents = _c.WidthDetune * 0.5f * _widthScale;\n\t\tint delay = (int)(_c.WidthDelayMs * 0.001f * _sr * _widthScale);\n\t\tint jit = (int)(_c.WidthJitterMs * 0.001f * _sr * _widthScale);\n\t\t// Take A sits left and slightly flat; take B sits right, slightly sharp, and lags by the\n\t\t// constant offset. Both get independent phase + per-note amp/cutoff/timing variation, so\n\t\t// the two channels are genuinely different performances (the source of the width) rather\n\t\t// than a phantom-centre mono copy.\n\t\tAddTake( start, dur, freq, p, 0, -width, -halfCents, 0, jit );\n\t\tAddTake( start, dur, freq, p, 1, +width, +halfCents, delay, jit );\n\t}\n\n\t// Enqueue one double-tracking take. All per-note variation is derived from a local hash of\n\t// (start, freq, take) so it stays deterministic and pulls no RNG (synthesis must remain a\n\t// pure function for the parallel RenderPitchedRange windows).\n\tvoid AddTake( int start, int dur, float freq, Patch p, int take, float pan, float cents, int delayBase, int jit )\n\t{\n\t\tuint s = unchecked( (uint)start * 2654435761u + (uint)(int)freq * 40503u )\n\t\t\t^ (take == 0 ? 0x85EBCA6Bu : 0xC2B2AE35u);\n\t\tfloat r1 = Hash01( ref s ); // start phase\n\t\tfloat r2 = Hash01( ref s ); // amplitude\n\t\tfloat r3 = Hash01( ref s ); // cutoff\n\t\tfloat r4 = Hash01( ref s ); // timing jitter\n\t\tint st = start + delayBase + (int)((r4 * 2f - 1f) * jit);\n\t\tif ( st < 0 ) st = 0;\n\t\tvar q = p;\n\t\tq.Pan = pan;\n\t\t// Phase + per-note variation also scale with the width slider, so at 0% the two takes\n\t\t// become identical and centred (clean mono) instead of a flangy centred double.\n\t\tq.PhaseSeed = r1 * _widthScale;\n\t\tq.Amp = p.Amp * (1f + (r2 * 2f - 1f) * _c.WidthAmpVar * _widthScale);\n\t\tq.Cutoff = p.Cutoff * (1f + (r3 * 2f - 1f) * _c.WidthCutoffVar * _widthScale);\n\t\tfloat f = freq * (float)Math.Pow( 2.0, cents / 1200.0 );\n\t\t_events.Add( new NoteEvent { Start = st, Dur = dur, Freq = f, P = q } );\n\t}\n\n\t// Fast deterministic [0,1) hash step (xorshift32).\n\tstatic float Hash01( ref uint s )\n\t{\n\t\ts ^= s << 13; s ^= s >> 17; s ^= s << 5;\n\t\treturn (s & 0xFFFFFFu) / 16777216f;\n\t}\n\n\t/// <summary>Synthesize every pitched event whose span overlaps <c>[from, to)</c>,\n\t/// writing ONLY samples inside that window. Safe to call concurrently for disjoint\n\t/// windows: each output index is owned by exactly one window, a boundary-spanning\n\t/// note is re-rendered from its own start by each window (the SVF / high-pass state\n\t/// can't be resumed mid-stream), and each window walks <c>_events</c> in order, so\n\t/// writes never collide and the per-index sum order is deterministic.</summary>\n\tpublic void RenderPitchedRange( int from, int to )\n\t{\n\t\tfrom = Math.Max( 0, from );\n\t\tto = Math.Min( _bufL.Length, to );\n\t\tif ( to <= from ) return;\n\t\tvar ph = new double[8];\n\t\tvar inc = new double[8];\n\t\tvar events = _events;\n\t\tfor ( int k = 0; k < events.Count; k++ )\n\t\t{\n\t\t\tvar ev = events[k];\n\t\t\tint end = Math.Min( _bufL.Length, ev.Start + ev.Dur );\n\t\t\tif ( end <= from || ev.Start >= to ) continue; // no overlap with this window\n\t\t\tRenderEvent( ev, from, to, ph, inc );\n\t\t}\n\t}\n\n\t// One pitched note. Computes from the note's own start (the running filter / breath\n\t// state can't be resumed mid-note) but writes only within [clipFrom, clipTo), and\n\t// stops once past clipTo since later windows own those samples. ph/inc are caller-\n\t// owned scratch (per-thread \u2192 no shared state).\n\tvoid RenderEvent( in NoteEvent ev, int clipFrom, int clipTo, double[] ph, double[] inc )\n\t{\n\t\tint start = ev.Start, dur = ev.Dur;\n\t\tfloat freq = ev.Freq;\n\t\tvar p = ev.P;\n\t\tStereoGains( p.Pan, out float gL, out float gR );\n\t\tint atk = Math.Max( 1, (int)(p.Attack * _sr) );\n\t\tdouble decSamp = Math.Max( 1.0, p.Decay * _sr );\n\t\tint rel = Math.Max( 1, (int)(0.006f * _sr) );\n\t\tint voices = Math.Min( 8, p.Voices );\n\n\t\tfor ( int v = 0; v < voices; v++ )\n\t\t{\n\t\t\tph[v] = p.PhaseSeed; // 0 for un-doubled notes \u2192 identical to the old in-phase start\n\t\t\tfloat cents = voices == 1 ? 0f : (v - (voices - 1) * 0.5f) * p.Detune;\n\t\t\tinc[v] = freq * Math.Pow( 2, cents / 1200.0 ) / _sr;\n\t\t}\n\n\t\tfloat low = 0, band = 0;\n\t\tfloat reso = Math.Clamp( p.Reso, 0.2f, 2f );\n\t\tfloat dnorm = p.Drive > 1f ? 1f / (float)Math.Tanh( p.Drive ) : 1f;\n\t\tfloat hpA = p.Highpass > 0f ? (float)(1.0 / (1.0 + 2 * Math.PI * p.Highpass / _sr)) : 0f;\n\t\tfloat hpInPrev = 0f, hpOutPrev = 0f;\n\t\tuint bn = 0x9E3779B9u;\n\n\t\tint end = Math.Min( Math.Min( _bufL.Length, start + dur ), clipTo );\n\t\tint relStart = dur - rel;\n\t\t// Expression windows (samples): vibrato holds off then ramps in; the scoop is a quick\n\t\t// attack gesture. Kept fixed/absolute so a long held note locks on pitch after them.\n\t\tint vibDelay = (int)(0.18f * _sr);\n\t\tint vibRamp = Math.Max( 1, (int)(0.16f * _sr) );\n\t\tint scoopWin = Math.Max( 1, (int)(0.16f * _sr) );\n\t\tfor ( int i = 0; start + i < end; i++ )\n\t\t{\n\t\t\tfloat env;\n\t\t\tif ( i < atk ) env = (float)i / atk;\n\t\t\telse if ( p.Sustained )\n\t\t\t{\n\t\t\t\tfloat d = (float)Math.Exp( -(i - atk) / decSamp );\n\t\t\t\tenv = p.Sustain + (1f - p.Sustain) * d;\n\t\t\t}\n\t\t\telse env = (float)Math.Exp( -(i - atk) / decSamp );\n\t\t\tif ( i >= relStart ) env *= Math.Max( 0f, (float)(dur - i) / rel );\n\t\t\tif ( env < 0.0006f && i > atk && !p.Sustained ) break;\n\n\t\t\tfloat s = 0f;\n\t\t\t// Vibrato: subtle, and DELAYED so the note locks on pitch first and only blooms a\n\t\t\t// wobble if it's held \u2014 short notes stay dead-on. Depth is a small pitch fraction.\n\t\t\tfloat vib = 1f;\n\t\t\tif ( p.Vibrato > 0f && p.VibDepth > 0f )\n\t\t\t{\n\t\t\t\tfloat ramp = MathF.Max( 0f, (i - vibDelay) / (float)vibRamp );\n\t\t\t\tif ( ramp > 1f ) ramp = 1f;\n\t\t\t\tif ( ramp > 0f )\n\t\t\t\t\tvib = (float)(1.0 + p.VibDepth * ramp * Math.Sin( i / (double)_sr * p.Vibrato * 2 * Math.PI ));\n\t\t\t}\n\t\t\t// Pitch-bend envelope (semitones) on top of vibrato. Both are QUICK gestures over a\n\t\t\t// short fixed window so the note then sits locked on its target pitch (bendMul == 1):\n\t\t\t// BendSemis snaps to 0 over BendTime seconds (bend-in / glide); ScoopSemis is a fast\n\t\t\t// up-and-back hump confined to the attack (bend-and-release).\n\t\t\tfloat bendSemis = 0f;\n\t\t\tif ( p.BendSemis != 0f && p.BendTime > 0f )\n\t\t\t{\n\t\t\t\tint bt = Math.Min( dur, Math.Max( 1, (int)(p.BendTime * _sr) ) );\n\t\t\t\tif ( i < bt ) { float u = i / (float)bt; bendSemis += p.BendSemis * (1f - u * u * (3f - 2f * u)); }\n\t\t\t}\n\t\t\tif ( p.ScoopSemis != 0f && i < scoopWin )\n\t\t\t\tbendSemis += p.ScoopSemis * MathF.Sin( (float)(i / (float)Math.Min( dur, scoopWin ) * Math.PI) );\n\t\t\tfloat bendMul = bendSemis != 0f ? (float)Math.Pow( 2.0, bendSemis / 12.0 ) : 1f;\n\t\t\tfor ( int v = 0; v < voices; v++ )\n\t\t\t{\n\t\t\t\t\tdouble dt = inc[v] * vib * bendMul;\n\t\t\t\t\ts += BlepOsc( p.Osc, ph[v] - Math.Floor( ph[v] ), dt );\n\t\t\t\t\tph[v] += dt;\n\t\t\t}\n\t\t\ts /= voices;\n\t\t\tif ( p.Breath > 0f )\n\t\t\t{\n\t\t\t\tbn = unchecked( bn * 1664525u + 1013904223u );\n\t\t\t\ts += (bn / 4294967296f * 2f - 1f) * p.Breath;\n\t\t\t}\n\t\t\tif ( hpA > 0f )\n\t\t\t{\n\t\t\t\tfloat hp = hpA * (hpOutPrev + s - hpInPrev);\n\t\t\t\thpInPrev = s; hpOutPrev = hp; s = hp;\n\t\t\t}\n\n\t\t\t// resonant low-pass (Chamberlin SVF) with cutoff envelope.\n\t\t\t// Clamp to ~sr/6 to keep the SVF stable.\n\t\t\tfloat cut = p.Cutoff + (p.CutEnv > 0f ? p.CutEnv * (float)Math.Exp( -i / decSamp ) : 0f);\n\t\t\tfloat f = (float)(2 * Math.Sin( Math.PI * Math.Min( cut, _sr * 0.16f ) / _sr ));\n\t\t\tfloat high = s - low - reso * band;\n\t\t\tband += f * high;\n\t\t\tlow += f * band;\n\t\t\tfloat outp = low;\n\n\t\t\tif ( p.Drive > 1f ) outp = (float)Math.Tanh( outp * p.Drive ) * dnorm;\n\t\t\tfloat val = outp * env * p.Amp;\n\t\t\tint idx = start + i;\n\t\t\tif ( idx >= clipFrom )\n\t\t\t{\n\t\t\t\t_bufL[idx] += val * gL;\n\t\t\t\t_bufR[idx] += val * gR;\n\t\t\t}\n\t\t}\n\t}\n\n\t// Band-limited oscillator. Naive saw/square step instantaneously at the phase wrap,\n\t// and those discontinuities alias into harsh inharmonic tones \u2014 the core of the\n\t// \"8-/16-bit\" buzz. PolyBLEP rounds each discontinuity over one sample so the harmonics\n\t// fold back cleanly, for a warm analog edge instead. Sine is already band-limited;\n\t// triangle's corners roll off as 1/n\u00b2 so its aliasing is inaudible.\n\t// p = phase in [0,1), dt = phase increment per sample (cycles/sample).\n\tstatic float BlepOsc( int t, double p, double dt )\n\t{\n\t\tswitch ( t )\n\t\t{\n\t\t\tcase 0:\n\t\t\t\treturn MathF.Sin( (float)(p * 2 * Math.PI) );\n\t\t\tcase 1: // saw\n\t\t\t\treturn (float)(2 * p - 1) - PolyBlep( p, dt );\n\t\t\tcase 2: // square (50% duty = two opposed discontinuities)\n\t\t\t{\n\t\t\t\tfloat v = p < 0.5 ? 1f : -1f;\n\t\t\t\tv += PolyBlep( p, dt );\n\t\t\t\tdouble p2 = p + 0.5; if ( p2 >= 1.0 ) p2 -= 1.0;\n\t\t\t\treturn v - PolyBlep( p2, dt );\n\t\t\t}\n\t\t\tdefault: // triangle\n\t\t\t\treturn 4f * MathF.Abs( (float)p - 0.5f ) - 1f;\n\t\t}\n\t}\n\n\t// PolyBLEP residual: the correction applied around a step discontinuity.\n\tstatic float PolyBlep( double t, double dt )\n\t{\n\t\tif ( dt <= 0 ) return 0f;\n\t\tif ( t < dt ) { t /= dt; return (float)(t + t - t * t - 1.0); }\n\t\tif ( t > 1.0 - dt ) { t = (t - 1.0) / dt; return (float)(t * t + t + t + 1.0); }\n\t\treturn 0f;\n\t}\n\n\tstatic float Midi( int m ) => 440f * MathF.Pow( 2f, (m - 69) / 12f );\n\n\tconst float Sqrt2 = 1.41421356f;\n\t// Fixed (not randomized) stereo spread for the kit's off-centre voices: 25% each way.\n\tconst float DrumPan = 0.25f;\n\tstatic void StereoGains( float pan, out float gL, out float gR )\n\t{\n\t\tpan = Math.Clamp( pan, -1f, 1f );\n\t\tdouble ang = (pan + 1) * 0.5 * (Math.PI / 2);\n\t\tgL = (float)Math.Cos( ang ) * Sqrt2;\n\t\tgR = (float)Math.Sin( ang ) * Sqrt2;\n\t}\n\n\t// \u2500\u2500 Drums \u2500\u2500\n\t// Render a bar of kit. On a section's last bar (fillEnd) the closing beat is replaced by a\n\t// fill \u2014 driven by its own RNG streams so every section's fill is different even when the\n\t// groove before it is identical.\n\tvoid RenderDrumBar( int barStart, int spe, bool fillEnd, Rng noise, Rng fillRng, Rng fillNoise )\n\t{\n\t\t// Knob ceiling was too frantic: scale so DRUM BUSY 100% reads as the old 75%.\n\t\tfloat busy = Math.Clamp( _c.DrumBusy, 0f, 1f ) * 0.75f;\n\t\tint six = spe / 2;\n\t\tint hatEnd = fillEnd ? 6 : EighthsPerBar; // hats stop where the fill begins\n\n\t\t// closed hats on eighths (open on the \"and of 4\"); busy fills the gaps with\n\t\t// quieter sixteenth-note hats (constant 16th chatter at the top of the range). On\n\t\t// ride songs the ride cymbal carries the eighth pulse instead (bell on the beats), with\n\t\t// the open hat still punctuating the \"and of 4\".\n\t\tfor ( int e = 0; e < hatEnd; e++ )\n\t\t{\n\t\t\tint at = Swung( barStart, spe, e );\n\t\t\t// Pop pumps an open hat on every offbeat (the classic four-on-the-floor \"ts-ts-ts\");\n\t\t\t// every other style opens only on the \"and of 4\".\n\t\t\tbool open = e == 7 || (_drumStyle == 4 && e % 2 == 1);\n\t\t\tfloat amp = e % 2 == 1 ? _c.HatVol : _c.HatVol * 0.6f;\n\t\t\tif ( _ride && !open )\n\t\t\t\tRenderRide( at, e % 2 == 0, amp, noise ); // bell accent on the downbeats\n\t\t\telse\n\t\t\t\tRenderHat( at, open, amp, noise );\n\t\t\tif ( !open && six > 0 && noise.Chance( busy ) )\n\t\t\t{\n\t\t\t\tint sixAt = Swung( barStart, spe, e + 0.5 );\n\t\t\t\tif ( _ride ) RenderRide( sixAt, false, _c.HatVol * 0.4f, noise );\n\t\t\t\telse RenderHat( sixAt, false, _c.HatVol * 0.4f, noise );\n\t\t\t}\n\t\t}\n\n\t\tif ( fillEnd )\n\t\t{\n\t\t\tRenderKickSnareGroove( barStart, spe, 0, 6, busy, noise ); // first 3 beats normal\n\t\t\tRenderFill( barStart, spe, 6, fillNoise, fillRng );\n\t\t\treturn;\n\t\t}\n\t\tRenderKickSnareGroove( barStart, spe, 0, EighthsPerBar, busy, noise );\n\t}\n\n\t// Per-song kick accents for the straight backbeat: eighths (beyond the beat-1 & 3 anchors)\n\t// the kick leans into. e1 = \"and of 1\", e3 = \"and of 2\", e5 = \"and of 3\", e6 = beat 4,\n\t// e7 = \"and of 4\". One set is picked per song, then each accent is rolled per bar so the\n\t// groove breathes instead of stamping the same kick pattern every bar \u2014 the main nuance\n\t// lever that keeps rock/country from all sharing one mechanical backbeat.\n\tstatic readonly int[][] BackbeatKickAccents =\n\t{\n\t\tnew int[0], // bone-dry: just 1 & 3\n\t\tnew[] { 3 }, // push into the snare (\"and of 2\")\n\t\tnew[] { 7 }, // pickup into the next bar (\"and of 4\")\n\t\tnew[] { 3, 7 }, // push + pickup\n\t\tnew[] { 6 }, // driving beat-4 kick\n\t\tnew[] { 5, 7 }, // syncopated \"and of 3\" + pickup\n\t\tnew[] { 3, 6 }, // push into 3 + beat-4 drive\n\t};\n\n\tvoid RenderKickSnareGroove( int barStart, int spe, int from, int to, float busy, Rng noise )\n\t{\n\t\tint six = spe / 2;\n\t\tfor ( int e = from; e < to; e++ )\n\t\t{\n\t\t\tint at = Swung( barStart, spe, e );\n\t\t\tint sixAt = six > 0 ? Swung( barStart, spe, e + 0.5 ) : at;\n\t\t\tswitch ( _drumStyle )\n\t\t\t{\n\t\t\t\tcase 0: // one-drop: kick + snare together on beat 3\n\t\t\t\t\tif ( e == 4 ) { RenderKick( at, noise ); RenderSnare( at, noise, false ); }\n\t\t\t\t\telse if ( e == 2 && noise.Chance( _c.GhostSnareChance * (0.4f + busy) ) ) RenderSnare( at, noise, true );\n\t\t\t\t\tbreak;\n\t\t\t\tcase 1: // steppers: kick every beat, snare on 2 & 4\n\t\t\t\t\tif ( e % 2 == 0 ) RenderKick( at, noise );\n\t\t\t\t\tif ( e == 2 || e == 6 ) RenderSnare( at, noise, false );\n\t\t\t\t\tbreak;\n\t\t\t\tcase 3: // metal double-kick: 16th-note kick gallop + crashing, snare backbeat\n\t\t\t\t\tif ( e == 0 && noise.Chance( 0.55f ) ) RenderCrash( at, noise, noise.Chance( 0.35f ) );\n\t\t\t\t\tRenderKick( at, noise );\n\t\t\t\t\tif ( six > 0 ) RenderKick( sixAt, noise ); // the second pedal \u2192 the 16th gallop\n\t\t\t\t\tif ( e == 2 || e == 6 ) RenderSnare( at, noise, false );\n\t\t\t\t\tbreak;\n\t\t\t\tcase 4: // pop four-on-the-floor: kick on every beat, backbeat snare/clap on 2 & 4\n\t\t\t\t\tif ( e % 2 == 0 ) RenderKick( at, noise );\n\t\t\t\t\tif ( e == 2 || e == 6 ) RenderSnare( at, noise, false );\n\t\t\t\t\telse if ( noise.Chance( _c.GhostSnareChance * busy * 0.5f ) ) RenderSnare( at, noise, true );\n\t\t\t\t\tbreak;\n\t\t\t\tdefault: // straight backbeat \u2014 anchors on beats 1 & 3, plus this song's kick\n\t\t\t\t\t // accents, each humanised per bar so the groove breathes\n\t\t\t\t\tbool kick = e == 0 || e == 4;\n\t\t\t\t\tif ( !kick && Array.IndexOf( _kickAccents, e ) >= 0 )\n\t\t\t\t\t\tkick = noise.Chance( 0.82f ); // mostly play the accent, occasionally lay out\n\t\t\t\t\telse if ( !kick && e == 3 )\n\t\t\t\t\t\tkick = noise.Chance( _c.KickSyncChance * (0.4f + busy) ); // stray push into beat 3\n\t\t\t\t\tif ( kick ) RenderKick( at, noise );\n\t\t\t\t\tif ( e == 2 || e == 6 ) RenderSnare( at, noise, false );\n\t\t\t\t\telse if ( noise.Chance( _c.GhostSnareChance * busy ) ) RenderSnare( at, noise, true );\n\t\t\t\t\tbreak;\n\t\t\t}\n\t\t\t// Busy fills the \"e/a\" sixteenths between hits: more snare as busy rises, and \u2014\n\t\t\t// when the tone leans low \u2014 toms too. (Busy \u2192 snare + toms; tone \u2192 tom vs cymbal.)\n\t\t\t// Metal already fills every 16th with the double-kick, so it skips the ghost layer.\n\t\t\tif ( _drumStyle != 3 && six > 0 && e != 4 && noise.Chance( _c.GhostSnareChance * busy ) )\n\t\t\t{\n\t\t\t\tif ( noise.Chance( (1f - _drumTone) * 0.5f ) )\n\t\t\t\t\tRenderTom( sixAt, 150f + 40f * (e & 1), noise );\n\t\t\t\telse\n\t\t\t\t\tRenderSnare( sixAt, noise, true );\n\t\t\t}\n\t\t}\n\t}\n\n\t// Tom/snare roll across the last beat (two eighths). Straight = four 16ths;\n\t// triplet (TripletChance) = six even subdivisions for a rolling shuffle feel.\n\t// The fill rolls across the last beat \u2014 the two eighths starting at baseE (6) up to the bar\n\t// line \u2014 on the same swung grid as the rest of the band.\n\tvoid RenderFill( int barStart, int spe, int baseE, Rng noise, Rng rng )\n\t{\n\t\t// straight = four 16ths; triplet = either an eighth-note triplet (3) or a\n\t\t// faster 16th-note triplet (6) across the beat.\n\t\tint n = rng.Chance( _c.TripletChance ) ? (rng.Chance( 0.5f ) ? 3 : 6) : 4;\n\t\tint step = (spe * 2) / n;\n\t\tfloat[] toms = { 260f, 215f, 175f, 145f, 120f, 100f };\n\t\t// Half the hits stay snare so it still reads as a drum fill; the rest are biased by\n\t\t// DrumTone \u2014 toms when the tone leans low, cymbals (ride hits) when it leans high.\n\t\tfor ( int i = 0; i < n; i++ )\n\t\t{\n\t\t\tint t = Swung( barStart, spe, baseE + i * 2.0 / n );\n\t\t\tif ( rng.Chance( 0.5f ) ) RenderSnare( t, noise, false );\n\t\t\telse if ( rng.Chance( _drumTone ) ) RenderRide( t, false, _c.HatVol, noise );\n\t\t\telse RenderTom( t, toms[i], noise ); // pan derived from pitch inside RenderTom\n\t\t}\n\t\t// crash into the downbeat (lands on the bar line, an on-beat anchor \u2192 dead straight) \u2014 a\n\t\t// bright crash or a darker, washier crash, picked off the fill stream so the cymbal colour\n\t\t// varies section to section.\n\t\tRenderCrash( Swung( barStart, spe, baseE + 2.0 ), noise, rng.Chance( 0.4f ) );\n\t}\n\n\tvoid RenderKick( int start, Rng noise )\n\t{\n\t\tstart = Math.Max( 0, start + _drumPush );\n\t\tint dur = (int)(_sr * 0.17f); // a little longer tail for thump (was 0.13)\n\t\tdouble decay = dur * 0.31; // slightly slower decay = a touch more boom\n\t\tdouble subDecay = dur * 0.55; // sub layer rings longer for weight\n\t\tdouble phase = 0, subPhase = 0;\n\t\t// noise.Next() only fires in the fixed 3ms click below, so changing dur/decay\n\t\t// here does NOT shift the drum RNG stream (patterns are preserved).\n\t\tint clickLen = (int)(_sr * 0.003f);\n\t\tint end = Math.Min( _bufL.Length, start + dur );\n\t\tfor ( int i = 0; start + i < end; i++ )\n\t\t{\n\t\t\tfloat t = (float)i / dur;\n\t\t\tphase += (127f - 80f * MathF.Min( 1f, t * 2.6f )) / _sr; // pitch drop 127\u219247\n\t\t\tsubPhase += 44f / _sr; // steady sub fundamental\n\t\t\tfloat env = (float)Math.Exp( -i / decay );\n\t\t\tfloat subEnv = (float)Math.Exp( -i / subDecay );\n\t\t\tfloat body = (float)Math.Tanh( MathF.Sin( (float)(phase * 2 * Math.PI) ) * 1.6f ) * env;\n\t\t\tfloat sub = MathF.Sin( (float)(subPhase * 2 * Math.PI) ) * 0.3f * subEnv;\n\t\t\tfloat click = i < clickLen ? (noise.Next() * 2f - 1f) * 0.55f * (1f - i / (float)clickLen) : 0f;\n\t\t\tfloat v = (body + sub + click) * _c.KickVol * _c.KickBalance * _drumGain * _drumLowMul;\n\t\t\t_bufL[start + i] += v; _bufR[start + i] += v;\n\t\t}\n\t}\n\n\t// One-pole high-pass coefficient (unconditionally stable).\n\tfloat HpCoeff( float fc ) => (float)(1.0 / (1.0 + 2 * Math.PI * fc / _sr));\n\n\tvoid RenderSnare( int start, Rng noise, bool ghost )\n\t{\n\t\tstart = Math.Max( 0, start + _drumPush );\n\t\t// dur and the single noise.Next()/sample are kept exactly so the drum RNG stream\n\t\t// is unchanged \u2014 only the timbre (more shell body) is rerolled.\n\t\tint dur = (int)(_sr * (ghost ? 0.06f : 0.15f));\n\t\tdouble decay = dur * (ghost ? 0.3 : 0.32);\n\t\tdouble phase = 0, phase2 = 0;\n\t\tfloat amp = _c.SnareVol * _c.SnareBalance * (ghost ? 0.3f : 1f) * _drumGain;\n\t\tfloat a = HpCoeff( 1350f ); // slightly crisper wire crack (was 1200)\n\t\tfloat inPrev = 0f, outPrev = 0f;\n\t\tint end = Math.Min( _bufL.Length, start + dur );\n\t\tfor ( int i = 0; start + i < end; i++ )\n\t\t{\n\t\t\tfloat t = (float)i / dur;\n\t\t\tfloat env = (float)Math.Exp( -i / decay );\n\t\t\tfloat drop = 1f - 0.14f * t; // shell pitch sags a touch \u2192 \"dow\"\n\t\t\tphase += 185f * drop / _sr;\n\t\t\tphase2 += 268f * drop / _sr;\n\t\t\tfloat n = noise.Next() * 2f - 1f;\n\t\t\tfloat hp = a * (outPrev + n - inPrev); inPrev = n; outPrev = hp;\n\t\t\t// two-tone shell body, a bit fuller than before, vs the wire layer\n\t\t\tfloat body = (MathF.Sin( (float)(phase * 2 * Math.PI) ) + MathF.Sin( (float)(phase2 * 2 * Math.PI) ) * 0.6f) * 0.375f;\n\t\t\tfloat v = ((float)Math.Tanh( hp * 1.2f ) * 0.6f + body) * env * amp;\n\t\t\t_bufL[start + i] += v; _bufR[start + i] += v;\n\t\t}\n\t}\n\n\tvoid RenderTom( int start, float baseFreq, Rng noise )\n\t{\n\t\tstart = Math.Max( 0, start + _drumPush );\n\t\t// Pan by PITCH so a given tom always sits in the same spot (rack/high left \u2192 floor/low\n\t\t// right). Mapped across the commonly-played fill range (~145 Hz floor .. 260 Hz rack) so a\n\t\t// normal fill sweeps the full field and the low toms read clearly to the right; anything\n\t\t// below the range clamps hard right. Scaled by the STEREO WIDTH slider via _drumPan.\n\t\tfloat u = Math.Clamp( (baseFreq - 145f) / (260f - 145f), 0f, 1f ); // 0 = low/floor, 1 = high/rack\n\t\tStereoGains( -_drumPan * (u * 2f - 1f), out float gL, out float gR );\n\t\tint dur = (int)(_sr * 0.18f);\n\t\tdouble decay = dur * 0.3;\n\t\tdouble attackDecay = dur * 0.06; // fast-decaying upper partial \u2192 beater \"snap\"\n\t\tdouble phase = 0, phase2 = 0;\n\t\t// deterministic per-hit stick attack \u2014 a local LFSR so the shared drum RNG stream\n\t\t// (and therefore every other pattern) is left byte-identical.\n\t\tuint ns = (uint)start * 2654435761u | 1u;\n\t\tint clickLen = (int)(_sr * 0.006f);\n\t\tint end = Math.Min( _bufL.Length, start + dur );\n\t\tfor ( int i = 0; start + i < end; i++ )\n\t\t{\n\t\t\tfloat t = (float)i / dur;\n\t\t\tfloat pf = baseFreq * (1f - 0.22f * t); // less pitch sag \u2192 holds its note, more bite\n\t\t\tphase += pf / _sr;\n\t\t\tphase2 += pf * 2.5f / _sr; // inharmonic upper partial for attack snap\n\t\t\tfloat env = (float)Math.Exp( -i / decay );\n\t\t\tfloat aenv = (float)Math.Exp( -i / attackDecay );\n\t\t\tfloat body = MathF.Sin( (float)(phase * 2 * Math.PI) ) * env;\n\t\t\tfloat snap = MathF.Sin( (float)(phase2 * 2 * Math.PI) ) * aenv * 0.5f;\n\t\t\tfloat click = 0f;\n\t\t\tif ( i < clickLen )\n\t\t\t{\n\t\t\t\tns ^= ns << 13; ns ^= ns >> 17; ns ^= ns << 5;\n\t\t\t\tclick = ((ns & 0xffff) / 32768f - 1f) * 0.45f * (1f - i / (float)clickLen);\n\t\t\t}\n\t\t\tfloat v = (body + snap + click) * _c.TomVol * _c.TomBalance * _drumGain * _drumLowMul;\n\t\t\t_bufL[start + i] += v * gL; _bufR[start + i] += v * gR;\n\t\t}\n\t}\n\n\tvoid RenderHat( int start, bool open, float amp, Rng noise )\n\t{\n\t\tstart = Math.Max( 0, start + _drumPush );\n\t\t// Closed/open hats live on the left of the kit (the hi-hat stand); ride sits opposite.\n\t\tStereoGains( -_drumPan, out float gL, out float gR );\n\t\tint dur = (int)(_sr * (open ? 0.16f : 0.035f));\n\t\tdouble decay = dur * 0.4;\n\t\tfloat a = HpCoeff( 7000f );\n\t\tfloat inPrev = 0f, outPrev = 0f;\n\t\tint end = Math.Min( _bufL.Length, start + dur );\n\t\tfor ( int i = 0; start + i < end; i++ )\n\t\t{\n\t\t\tfloat env = (float)Math.Exp( -i / decay );\n\t\t\tfloat n = noise.Next() * 2f - 1f;\n\t\t\tfloat hp = a * (outPrev + n - inPrev); inPrev = n; outPrev = hp;\n\t\t\tfloat v = hp * env * amp * _c.HatBalance * _drumGain * _drumHighMul;\n\t\t\t_bufL[start + i] += v * gL; _bufR[start + i] += v * gR;\n\t\t}\n\t}\n\n\t// Two crash colours off one voice: the bright crash (short-ish, high-passed high) and a\n\t// dark crash \u2014 lower cutoff, longer wash, a touch quieter \u2014 for a bigger china/ride-crash.\n\t// The kit has two crashes panned apart (\u00b125%); the bright/dark colour picks which physical\n\t// cymbal, and _crashBrightLeft (per song) decides which side that is.\n\tvoid RenderCrash( int start, Rng noise, bool dark = false )\n\t{\n\t\tstart = Math.Max( 0, start + _drumPush );\n\t\tStereoGains( (dark == _crashBrightLeft ? _drumPan : -_drumPan), out float gL, out float gR );\n\t\tint dur = (int)(_sr * (dark ? 0.9f : 0.6f));\n\t\tdouble decay = dur * (dark ? 0.5 : 0.45);\n\t\tfloat a = HpCoeff( dark ? 2600f : 4000f );\n\t\tfloat amp = _c.CrashVol * _c.CrashBalance * (dark ? 0.85f : 1f);\n\t\tfloat inPrev = 0f, outPrev = 0f;\n\t\tint end = Math.Min( _bufL.Length, start + dur );\n\t\tfor ( int i = 0; start + i < end; i++ )\n\t\t{\n\t\t\tfloat env = (float)Math.Exp( -i / decay );\n\t\t\tfloat n = noise.Next() * 2f - 1f;\n\t\t\tfloat hp = a * (outPrev + n - inPrev); inPrev = n; outPrev = hp;\n\t\t\tfloat v = hp * env * amp * _drumGain * _drumHighMul;\n\t\t\t_bufL[start + i] += v * gL; _bufR[start + i] += v * gR;\n\t\t}\n\t}\n\n\t// Ride cymbal \u2014 a sustained, high-passed noise wash, nothing more. A cymbal is mostly\n\t// filtered noise; we deliberately render *only* that body and no pitched component. Earlier\n\t// versions layered inharmonic sine partials for a metallic \"ping\"/bell, but any tonal layer\n\t// (however quiet or detuned) read as a pitched ring/ding, so it's gone entirely. The bell\n\t// hit (on the beat) just rings a touch longer than the bow hit (on the \"and\"). Tracks off\n\t// HatVol via the caller's amp so the existing DRUMS knobs still balance it.\n\tvoid RenderRide( int start, bool bell, float amp, Rng noise )\n\t{\n\t\tstart = Math.Max( 0, start + _drumPush );\n\t\t// Ride sits opposite the hats, on the right of the kit.\n\t\tStereoGains( _drumPan, out float gL, out float gR );\n\t\tint dur = (int)(_sr * (bell ? 0.34f : 0.22f));\n\t\tdouble decay = dur * 0.42;\n\t\tfloat a = HpCoeff( 7000f );\n\t\tfloat inPrev = 0f, outPrev = 0f;\n\t\tint end = Math.Min( _bufL.Length, start + dur );\n\t\tfor ( int i = 0; start + i < end; i++ )\n\t\t{\n\t\t\tfloat env = (float)Math.Exp( -i / decay );\n\t\t\tfloat n = noise.Next() * 2f - 1f;\n\t\t\tfloat hp = a * (outPrev + n - inPrev); inPrev = n; outPrev = hp;\n\t\t\tfloat v = hp * 0.7f * env * amp * _c.HatBalance * _drumGain * _drumHighMul;\n\t\t\t_bufL[start + i] += v * gL; _bufR[start + i] += v * gR;\n\t\t}\n\t}\n\n\t// \u2500\u2500 Output \u2500\u2500\n\tshort[] ToShorts( float gain )\n\t{\n\t\tint n = _bufL.Length;\n\t\tvar s = new short[n * Channels];\n\t\tfor ( int i = 0; i < n; i++ )\n\t\t{\n\t\t\ts[i * 2] = ToS16( _bufL[i] * gain );\n\t\t\ts[i * 2 + 1] = ToS16( _bufR[i] * gain );\n\t\t}\n\t\treturn s;\n\t}\n\n\tstatic short ToS16( float v ) => (short)(Math.Clamp( v, -1f, 1f ) * 32767f);\n\n\t/// <summary>Wrap already-rendered 16-bit samples in a WAV (for export). Mono or\n\t/// interleaved stereo per <paramref name=\"channels\"/>.</summary>\n\tpublic static byte[] WavFromSamples( short[] samples, int channels, int sampleRate )\n\t{\n\t\tint dataSize = samples.Length * 2;\n\t\tint blockAlign = channels * 2;\n\t\tvar bytes = new System.Collections.Generic.List<byte>( 44 + dataSize );\n\t\tvoid Str( string s ) { foreach ( var ch in s ) bytes.Add( (byte)ch ); }\n\t\tvoid U32( uint v ) { bytes.Add( (byte)v ); bytes.Add( (byte)(v >> 8) ); bytes.Add( (byte)(v >> 16) ); bytes.Add( (byte)(v >> 24) ); }\n\t\tvoid U16( ushort v ) { bytes.Add( (byte)v ); bytes.Add( (byte)(v >> 8) ); }\n\t\tStr( \"RIFF\" ); U32( (uint)(36 + dataSize) ); Str( \"WAVE\" );\n\t\tStr( \"fmt \" ); U32( 16 ); U16( 1 ); U16( (ushort)channels );\n\t\tU32( (uint)sampleRate ); U32( (uint)(sampleRate * blockAlign) ); U16( (ushort)blockAlign ); U16( 16 );\n\t\tStr( \"data\" ); U32( (uint)dataSize );\n\t\tforeach ( var s in samples ) { ushort u = (ushort)s; bytes.Add( (byte)u ); bytes.Add( (byte)(u >> 8) ); }\n\t\treturn bytes.ToArray();\n\t}\n\n\tbyte[] EncodeWav( float gain )\n\t{\n\t\tint n = _bufL.Length;\n\t\tint dataSize = n * 4;\n\t\tvar bytes = new System.Collections.Generic.List<byte>( 44 + dataSize );\n\t\tvoid Str( string s ) { foreach ( var ch in s ) bytes.Add( (byte)ch ); }\n\t\tvoid U32( uint v ) { bytes.Add( (byte)v ); bytes.Add( (byte)(v >> 8) ); bytes.Add( (byte)(v >> 16) ); bytes.Add( (byte)(v >> 24) ); }\n\t\tvoid U16( ushort v ) { bytes.Add( (byte)v ); bytes.Add( (byte)(v >> 8) ); }\n\t\tStr( \"RIFF\" ); U32( (uint)(36 + dataSize) ); Str( \"WAVE\" );\n\t\tStr( \"fmt \" ); U32( 16 ); U16( 1 ); U16( 2 );\n\t\tU32( (uint)_sr ); U32( (uint)(_sr * 4) ); U16( 4 ); U16( 16 );\n\t\tStr( \"data\" ); U32( (uint)dataSize );\n\t\tfor ( int i = 0; i < n; i++ )\n\t\t{\n\t\t\tushort l = (ushort)ToS16( _bufL[i] * gain );\n\t\t\tushort r = (ushort)ToS16( _bufR[i] * gain );\n\t\t\tbytes.Add( (byte)l ); bytes.Add( (byte)(l >> 8) );\n\t\t\tbytes.Add( (byte)r ); bytes.Add( (byte)(r >> 8) );\n\t\t}\n\t\treturn bytes.ToArray();\n\t}\n}\n"
},
{
"Ident": "gamah.splitclicker",
"Path": "UI/Hud.razor",
"FileName": "Hud.razor",
"PackageType": "game",
"CodeKind": "Game",
"AssetVersionId": 301560,
"Code": "@using System\n@using System.Collections.Generic\n@using System.Linq\n@using System.Threading.Tasks\n@using Sandbox\n@using Sandbox.UI\n@using Splitclicker.Api\n@using Splitclicker.Game\n@using Skafinity\n@namespace Splitclicker.UI\n@inherits PanelComponent\n\n@*\n\tThe whole HUD as a SINGLE root PanelComponent. This matters: a ScreenPanel lays\n\tits child PanelComponent roots out in a flex row and ignores `position` set on\n\tthose roots, so sibling panels can't pin/center themselves. The working pattern\n\t(see rotaliate's GameHud) is one root with `width/height: 100%` that genuinely\n\tfills the screen, with every piece an absolutely-positioned CHILD of it:\n\n\t \u2022 session board \u2014 pinned top-left\n\t \u2022 a bounding box inset between the left/right boards \u2014 holds the status HUD\n\t (centred) and the global button, which is small and JUMPS to a random spot\n\t inside the box each time the round arms (whack-a-mole); \"clicks sent\" sits\n\t pinned to the box bottom\n\t \u2022 hourly board \u2014 pinned top-right, just left of hours-won\n\t \u2022 hours-won board \u2014 pinned hard to the top-right edge\n*@\n\n<root>\n\t@{ var c = ClickController.Instance; }\n\n\t@* \u2500\u2500 left column: the session board stacked above the all-time board, flowed in\n\t a flex column so they can never overlap however many rows each grows to\n\t (both are capped at DisplayLimit). Pinned top-left. \u2500\u2500 *@\n\t<div class=\"left-col\">\n\n\t@* \u2500\u2500 session board: current game's cumulative standings \u2500\u2500 *@\n\t<div class=\"board session\">\n\t\t<div class=\"title\">SESSION \u00b7 TOP CLICKERS</div>\n\t\t<div class=\"divider\"></div>\n\t\t@if ( SessionEntries.Count == 0 )\n\t\t{\n\t\t\t<div class=\"empty\">No scores yet</div>\n\t\t}\n\t\telse\n\t\t{\n\t\t\t@for ( int i = 0; i < SessionEntries.Count && i < DisplayLimit; i++ )\n\t\t\t{\n\t\t\t\tvar s = SessionEntries[i];\n\t\t\t\t<div class=\"row @(IsMe( s ) ? \"me\" : \"\")\">\n\t\t\t\t\t<div class=\"rank\">@(i + 1)</div>\n\t\t\t\t\t<div class=\"dot @StatusClass( s )\"></div>\n\t\t\t\t\t<div class=\"name clickable\" onclick=\"@(() => CopyProfile( s ))\">@DisplayName( s )</div>\n\t\t\t\t\t@if ( s.BehindMs > 0 )\n\t\t\t\t\t{\n\t\t\t\t\t\t<div class=\"behind\" title=\"lost the game by @(s.BehindMs)ms\">+@(s.BehindMs)ms</div>\n\t\t\t\t\t}\n\t\t\t\t\t<div class=\"pts\">@s.Points</div>\n\t\t\t\t</div>\n\t\t\t}\n\t\t}\n\t</div>\n\n\t@* \u2500\u2500 all-time clickers board: lifetime total scoring clicks across every skin\n\t (never resets) \u2500\u2500 *@\n\t<div class=\"board alltime\">\n\t\t<div class=\"title\">ALL-TIME \u00b7 TOP CLICKERS</div>\n\t\t<div class=\"divider\"></div>\n\t\t@if ( _allTime.Count == 0 )\n\t\t{\n\t\t\t<div class=\"empty\">@(_allTimeLoaded ? \"No clicks recorded yet\" : \"loading\u2026\")</div>\n\t\t}\n\t\telse\n\t\t{\n\t\t\t@for ( int i = 0; i < _allTime.Count; i++ )\n\t\t\t{\n\t\t\t\tvar s = _allTime[i];\n\t\t\t\t<div class=\"row @(IsMe( s ) ? \"me\" : \"\")\">\n\t\t\t\t\t<div class=\"rank\">@(i + 1)</div>\n\t\t\t\t\t<div class=\"dot @StatusClass( s )\"></div>\n\t\t\t\t\t<div class=\"name clickable\" onclick=\"@(() => CopyProfile( s ))\">@DisplayName( s )</div>\n\t\t\t\t\t<div class=\"pts\">@s.Points</div>\n\t\t\t\t</div>\n\t\t\t}\n\t\t}\n\t</div>\n\n\t</div>\n\n\t@* \u2500\u2500 GAME INFO opener: a wide labelled bar pinned bottom-left, mirroring the\n\t MUSIC bar that sits beneath it. Toggles the floating \"How to Play\" popup;\n\t the open/closed choice is persisted to local storage (PlayerData) so it\n\t survives across sessions. The popup itself is rendered near the end of the\n\t root so it overlays everything. \u2500\u2500 *@\n\t@* \u2500\u2500 PAUSE control: lets a player step away without being flagged AFK. Pinned\n\t bottom-left ABOVE the GAME INFO bar. Green \"PAUSE\" at rest; red \"PAUSED\" while\n\t away. Pressing it parks the player (the server withholds frames + drops them\n\t from the round's N); pressing again rejoins on the next button. Also engaged\n\t automatically when the server auto-parks an idle player. Hidden until connected. \u2500\u2500 *@\n\t@if ( C != null && C.Phase != GamePhase.Connecting && C.Phase != GamePhase.Disconnected )\n\t{\n\t\t<div class=\"pause-fab @PauseFabClass()\" onclick=\"@TogglePause\">@PauseFabLabel()</div>\n\t}\n\n\t<div class=\"info-fab\" onclick=\"@ToggleHowTo\">GAME INFO</div>\n\n\t@* \u2500\u2500 the music opener is the Skafinity library's own toggle button: it lives on the\n\t MusicUI ScreenPanel and is styled (ShowFab/WideFab/FabLabel in the scene) into the\n\t wide \"MUSIC\" bar pinned bottom-left under the GAME INFO bar, opening/closing its\n\t own board. This HUD only reads MusicOpen (the panel's IsOpen) to stop painting the\n\t game while the board is up. \u2500\u2500 *@\n\n\t@* \u2500\u2500 the live board + status HUD over it \u2500\u2500 *@\n\t@* While the music board is open (MusicOpen) the board + status + \"clicks sent\" stop\n\t drawing \u2014 the panel overlays from its own ScreenPanel, and the connection stays open\n\t (we just don't paint the game underneath it). *@\n\t<div class=\"center\" @ref=\"_box\">\n\t\t@if ( !MusicOpen )\n\t\t{\n\t\t\t@* The scoring surface: one clickable button per live board slot, at its\n\t\t\t server-placed position (board-% space, same as the pips). The board is the\n\t\t\t only surface and exists only while armed \u2014 NOTHING during the arming wait or\n\t\t\t for stray bad clicks. *@\n\t\t\t@if ( c != null && c.HasBoard )\n\t\t\t{\n\t\t\t\t@foreach ( var btn in c.LiveButtons )\n\t\t\t\t{\n\t\t\t\t\t<div class=\"board-btn\" style=\"left: @(PipPct( btn.X ))%; top: @(PipPct( btn.Y ))%;\"\n\t\t\t\t\t onclick=\"@(() => OnPressSlot( btn.Slot ))\"></div>\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t@* Opponent cursors: a labelled amber dot per sampled opponent pointer. *@\n\t\t\t@foreach ( var cur in CursorsToShow() )\n\t\t\t{\n\t\t\t\t<div class=\"opp-cursor\" style=\"left: @(PipPct( cur.X ))%; top: @(PipPct( cur.Y ))%;\">\n\t\t\t\t\t<div class=\"opp-cursor-name\">@cur.Name</div>\n\t\t\t\t</div>\n\t\t\t}\n\n\t\t\t@* Opponent click pips: each a half-size button that fades quickly (CSS\n\t\t\t keyframe) at the clicker's normalized position with their name over it.\n\t\t\t Positioned in the same box-% space as the global button, so a pip lands\n\t\t\t where that player's button was when they clicked. *@\n\t\t\t@foreach ( var pip in PipsToShow() )\n\t\t\t{\n\t\t\t\t<div class=\"pip\" style=\"left: @(PipPct( pip.X ))%; top: @(PipPct( pip.Y ))%;\">\n\t\t\t\t\t<div class=\"pip-name\">@pip.Name</div>\n\t\t\t\t</div>\n\t\t\t}\n\n\t\t\t@* Each line below is a SINGLE interpolated expression on purpose: mixing\n\t\t\t literal text with razor expressions in one element emits several text nodes,\n\t\t\t which s&box's panel reconciler reorders across rebuilds (the \"arms in 2-6s\"\n\t\t\t -> \"- arms in 26s\" / vanishing-penalty bug). One expression = one stable node. *@\n\t\t\t<div class=\"hud\">\n\t\t\t\t<div class=\"meta\">\n\t\t\t\t\t<div class=\"players\">@($\"{c?.Players ?? 0} online\")</div>\n\t\t\t\t\t<div class=\"sep\">\u00b7</div>\n\t\t\t\t\t<div class=\"clicks\">@($\"{c?.ClicksToWin ?? 0} clicks exist for this round\")</div>\n\t\t\t\t</div>\n\t\t\t\t<div class=\"round\">@($\"Round {c?.Round ?? 0} / {c?.Of ?? 0}\")</div>\n\t\t\t\t<div class=\"state @PhaseClass()\">@PhaseText()</div>\n\t\t\t\t@* live clicks-remaining counter: descends from N as the race fills,\n\t\t\t\t driven by the server tick (clamped at 0). Only while the window is open. *@\n\t\t\t\t@if ( c?.Phase == GamePhase.Armed )\n\t\t\t\t{\n\t\t\t\t\t<div class=\"remaining\">@($\"{RemainingShown( c )} left\")</div>\n\t\t\t\t}\n\t\t\t\t@if ( c?.Phase == GamePhase.Pending && (c?.ArmMaxSec ?? 0) > 0 )\n\t\t\t\t{\n\t\t\t\t\t<div class=\"arm\">@($\"arms in {c.ArmMinSec}\u2013{c.ArmMaxSec}s\")</div>\n\t\t\t\t}\n\t\t\t\t@* dev note: a host-pushed broadcast message, orange. Independent of round\n\t\t\t\t phase; shows until the server sends an empty note. *@\n\t\t\t\t@if ( c != null && !string.IsNullOrEmpty( c.DevNote ) )\n\t\t\t\t{\n\t\t\t\t\t<div class=\"dev-note\">@c.DevNote</div>\n\t\t\t\t}\n\t\t\t</div>\n\n\t\t\t<div class=\"clicks-sent\">@($\"clicks sent: {c?.ClicksSent ?? 0}\")</div>\n\t\t}\n\t</div>\n\n\t@* \u2500\u2500 top-clickers board: points scored during the current bounty window\n\t (resets when the skin is won), pinned right (inner) \u2500\u2500 *@\n\t<div class=\"board hourly\">\n\t\t<div class=\"title\">TOP CLICKERS \u00b7 THIS BOUNTY</div>\n\t\t<div class=\"reset\">resets when the bounty is won</div>\n\t\t<div class=\"divider\"></div>\n\t\t@if ( !_hasBounty )\n\t\t{\n\t\t\t<div class=\"empty\">No active bounty</div>\n\t\t}\n\t\telse if ( _hourly.Count == 0 )\n\t\t{\n\t\t\t<div class=\"empty\">@(_hourlyLoaded ? \"No clicks for this bounty yet\" : \"loading\u2026\")</div>\n\t\t}\n\t\telse\n\t\t{\n\t\t\t@for ( int i = 0; i < _hourly.Count; i++ )\n\t\t\t{\n\t\t\t\tvar s = _hourly[i];\n\t\t\t\t<div class=\"row @(IsMe( s ) ? \"me\" : \"\")\">\n\t\t\t\t\t<div class=\"rank\">@(i + 1)</div>\n\t\t\t\t\t<div class=\"dot @StatusClass( s )\"></div>\n\t\t\t\t\t<div class=\"name clickable\" onclick=\"@(() => CopyProfile( s ))\">@DisplayName( s )</div>\n\t\t\t\t\t<div class=\"pts\">@s.Points</div>\n\t\t\t\t</div>\n\t\t\t}\n\t\t}\n\t</div>\n\n\t@* \u2500\u2500 hours-won board: UTC clock-hours topped during the current bounty window\n\t (resets when the skin is won), pinned right (outer) \u2500\u2500 *@\n\t<div class=\"board hourswon\">\n\t\t<div class=\"title\">HOURS WON \u00b7 THIS BOUNTY</div>\n\t\t<div class=\"divider\"></div>\n\t\t@if ( !_hasBounty )\n\t\t{\n\t\t\t<div class=\"empty\">No active bounty</div>\n\t\t}\n\t\telse if ( _hoursWon.Count == 0 )\n\t\t{\n\t\t\t<div class=\"empty\">@(_hoursWonLoaded ? \"No hours won for this bounty yet\" : \"loading\u2026\")</div>\n\t\t}\n\t\telse\n\t\t{\n\t\t\t@for ( int i = 0; i < _hoursWon.Count; i++ )\n\t\t\t{\n\t\t\t\tvar s = _hoursWon[i];\n\t\t\t\t<div class=\"row @(IsMe( s ) ? \"me\" : \"\")\">\n\t\t\t\t\t<div class=\"rank\">@(i + 1)</div>\n\t\t\t\t\t<div class=\"dot @StatusClass( s )\"></div>\n\t\t\t\t\t<div class=\"name clickable\" onclick=\"@(() => CopyProfile( s ))\">@DisplayName( s )</div>\n\t\t\t\t\t<div class=\"pts\">@s.Points</div>\n\t\t\t\t</div>\n\t\t\t}\n\t\t}\n\t</div>\n\n\t@* \u2500\u2500 games-won board: games topped during the current bounty window \u2014 this is\n\t the board the bounty winner is read from (resets when the skin is won),\n\t pinned bottom-right \u2500\u2500 *@\n\t<div class=\"board sessionswon\">\n\t\t<div class=\"title\">GAMES WON \u00b7 THIS BOUNTY</div>\n\t\t<div class=\"divider\"></div>\n\t\t@if ( !_hasBounty )\n\t\t{\n\t\t\t<div class=\"empty\">No active bounty</div>\n\t\t}\n\t\telse if ( _sessionsWon.Count == 0 )\n\t\t{\n\t\t\t<div class=\"empty\">@(_sessionsWonLoaded ? \"No games won for this bounty yet\" : \"loading\u2026\")</div>\n\t\t}\n\t\telse\n\t\t{\n\t\t\t@for ( int i = 0; i < _sessionsWon.Count; i++ )\n\t\t\t{\n\t\t\t\tvar s = _sessionsWon[i];\n\t\t\t\t<div class=\"row @(IsMe( s ) ? \"me\" : \"\")\">\n\t\t\t\t\t<div class=\"rank\">@(i + 1)</div>\n\t\t\t\t\t<div class=\"dot @StatusClass( s )\"></div>\n\t\t\t\t\t<div class=\"name clickable\" onclick=\"@(() => CopyProfile( s ))\">@DisplayName( s )</div>\n\t\t\t\t\t<div class=\"pts\">@s.Points</div>\n\t\t\t\t</div>\n\t\t\t}\n\t\t}\n\t</div>\n\n\t@* \u2500\u2500 leader's \"skin\": image served by the backend (selectable server-side),\n\t pinned just left of the sessions-won board, captioned with the current\n\t sessions-won leader's name. Above it, a countdown to when the winner is\n\t locked in; once that passes, a prompt for Gamah to set a new skin. \u2500\u2500 *@\n\t<div class=\"skin-panel\">\n\t\t@if ( !_hasBounty )\n\t\t{\n\t\t\t@* No bounty is live: the config skin/countdown are a stale fallback, so show\n\t\t\t nothing of it \u2014 just say there's no skin to win right now. *@\n\t\t\t<div class=\"no-bounty\">\n\t\t\t\t<div class=\"no-bounty-title\">No active bounty</div>\n\t\t\t\t<div class=\"no-bounty-sub\">Gamah will set a new bounty to win soon \u2014 check back shortly</div>\n\t\t\t</div>\n\t\t}\n\t\telse\n\t\t{\n\t\t@if ( HasWinnerLock )\n\t\t{\n\t\t\t@if ( WinnerLockPassed() )\n\t\t\t{\n\t\t\t\t<div class=\"skin-locked\">Gamah will set a new bounty to win when he sees this</div>\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\t<div class=\"skin-countdown\">\n\t\t\t\t\t<div class=\"cd-label\">Time until winner is locked in</div>\n\t\t\t\t\t<div class=\"cd-time\">@WinnerCountdown()</div>\n\t\t\t\t</div>\n\t\t\t}\n\t\t}\n\t\t<div class=\"skin-img\" style=\"background-image: url( '@SkinImgUrl()' );\"></div>\n\t\t@if ( _skin != null && _skin.DecodeOk )\n\t\t{\n\t\t\t@if ( _skin.ImageOk )\n\t\t\t{\n\t\t\t\t<div class=\"skin-name\">@_skin.Name</div>\n\t\t\t}\n\t\t\t<div class=\"skin-stats\">\n\t\t\t\t<div class=\"stat\"><span class=\"k\">Wear</span><span class=\"v\">@_skin.WearName</span></div>\n\t\t\t\t<div class=\"stat\"><span class=\"k\">Float</span><span class=\"v\">@FloatStr()</span></div>\n\t\t\t\t<div class=\"stat\"><span class=\"k\">Seed</span><span class=\"v\">@_skin.PaintSeed</span></div>\n\t\t\t</div>\n\t\t\t<div class=\"wear-bar\">\n\t\t\t\t<div class=\"seg fn\"></div>\n\t\t\t\t<div class=\"seg mw\"></div>\n\t\t\t\t<div class=\"seg ft\"></div>\n\t\t\t\t<div class=\"seg ww\"></div>\n\t\t\t\t<div class=\"seg bs\"></div>\n\t\t\t\t<div class=\"wear-marker\" style=\"left: @(MarkerPct())%;\"></div>\n\t\t\t</div>\n\t\t\t@if ( !_skin.ImageOk )\n\t\t\t{\n\t\t\t\t<div class=\"skin-note\">unable to fetch bounty from steam</div>\n\t\t\t}\n\t\t}\n\t\telse if ( _skin != null )\n\t\t{\n\t\t\t<div class=\"skin-note\">unable to fetch bounty from steam</div>\n\t\t}\n\t\t@if ( !string.IsNullOrWhiteSpace( _inspectLink ) )\n\t\t{\n\t\t\t<div class=\"skin-copy clickable\" onclick=\"@CopyInspect\">Copy inspect link</div>\n\t\t}\n\t\t<div class=\"skin-caption\">@($\"{SkinLeaderName()}'s Bounty\")</div>\n\t\t}\n\t</div>\n\n\t@* \u2500\u2500 previous winner: a duplicate of the skin panel showing who won the last\n\t bounty and the skin it was (decoded from its inspect link, same as the live\n\t one). Shown once a bounty has settled; refreshed on a bounty_update push /\n\t reconnect. The panel is pointer-events:none so it can't intercept a click\n\t meant for the roaming button beneath it; only the winner line re-enables\n\t clicks, to copy their Steam profile link. \u2500\u2500 *@\n\t@if ( _prevBounty != null )\n\t{\n\t\t<div class=\"skin-panel previous\">\n\t\t\t<div class=\"prev-header\">PREVIOUS WINNER</div>\n\t\t\t@* The winner is the panel's identity \u2014 bold and (when we have a steamid)\n\t\t\t clickable to copy their profile link, the only interactive bit (the rest\n\t\t\t of the panel is pointer-events:none so it can't steal a button click). *@\n\t\t\t@if ( !string.IsNullOrEmpty( _prevBounty.WinnerSteamId ) )\n\t\t\t{\n\t\t\t\t<div class=\"prev-winner clickable\" onclick=\"@CopyPrevWinner\">@PrevWinnerName()</div>\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\t<div class=\"prev-winner\">@PrevWinnerName()</div>\n\t\t\t}\n\t\t\t<div class=\"prev-wins\">@($\"won {_prevBounty.WinnerWins} games\")</div>\n\t\t\t<div class=\"skin-img\" style=\"background-image: url( '@PrevSkinImgUrl()' );\"></div>\n\t\t\t@if ( _prevSkin != null && _prevSkin.DecodeOk )\n\t\t\t{\n\t\t\t\t@if ( _prevSkin.ImageOk )\n\t\t\t\t{\n\t\t\t\t\t<div class=\"skin-name\">@_prevSkin.Name</div>\n\t\t\t\t}\n\t\t\t\t<div class=\"skin-stats\">\n\t\t\t\t\t<div class=\"stat\"><span class=\"k\">Wear</span><span class=\"v\">@_prevSkin.WearName</span></div>\n\t\t\t\t\t<div class=\"stat\"><span class=\"k\">Float</span><span class=\"v\">@PrevFloatStr()</span></div>\n\t\t\t\t\t<div class=\"stat\"><span class=\"k\">Seed</span><span class=\"v\">@_prevSkin.PaintSeed</span></div>\n\t\t\t\t</div>\n\t\t\t\t<div class=\"wear-bar\">\n\t\t\t\t\t<div class=\"seg fn\"></div>\n\t\t\t\t\t<div class=\"seg mw\"></div>\n\t\t\t\t\t<div class=\"seg ft\"></div>\n\t\t\t\t\t<div class=\"seg ww\"></div>\n\t\t\t\t\t<div class=\"seg bs\"></div>\n\t\t\t\t\t<div class=\"wear-marker\" style=\"left: @(PrevMarkerPct())%;\"></div>\n\t\t\t\t</div>\n\t\t\t}\n\t\t\telse if ( !string.IsNullOrEmpty( _prevBounty.Label ) )\n\t\t\t{\n\t\t\t\t@* Only as a skin-name fallback when the link didn't decode \u2014 never as the\n\t\t\t\t headline (the winner is the headline). *@\n\t\t\t\t<div class=\"skin-caption\">@_prevBounty.Label</div>\n\t\t\t}\n\t\t</div>\n\t}\n\n\t@* \u2500\u2500 per-round scores flash: a transient panel listing who scored the round that\n\t just ended (the round's winners, in first-click order, with the points each\n\t grabbed THIS round), shown briefly after every round_result then fading out.\n\t Non-interactive (pointer-events:none) so it never eats a click. Each text line\n\t is a single interpolated expression (the mixed-text-node reorder bug). \u2500\u2500 *@\n\t@if ( FlashVisible )\n\t{\n\t\t<div class=\"round-flash\">\n\t\t\t<div class=\"rf-panel\">\n\t\t\t\t<div class=\"rf-title\">@($\"ROUND {_flashRound} SCORES\")</div>\n\t\t\t\t<div class=\"divider\"></div>\n\t\t\t\t@if ( _flashWinners.Count == 0 )\n\t\t\t\t{\n\t\t\t\t\t<div class=\"rf-empty\">Nobody scored this round</div>\n\t\t\t\t}\n\t\t\t\telse\n\t\t\t\t{\n\t\t\t\t\t@for ( int i = 0; i < _flashWinners.Count && i < DisplayLimit; i++ )\n\t\t\t\t\t{\n\t\t\t\t\t\tvar w = _flashWinners[i];\n\t\t\t\t\t\t<div class=\"rf-row @(IsMe( w ) ? \"me\" : \"\")\">\n\t\t\t\t\t\t\t<div class=\"rf-rank\">@(i + 1)</div>\n\t\t\t\t\t\t\t<div class=\"rf-name\">@DisplayName( w )</div>\n\t\t\t\t\t\t\t<div class=\"rf-pts\">@($\"+{w.Points}\")</div>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t</div>\n\t\t</div>\n\t}\n\n\t@* \u2500\u2500 transient \"copied profile link\" toast, top-center for ~a second \u2500\u2500 *@\n\t@if ( ToastVisible )\n\t{\n\t\t<div class=\"toast\">@_toast</div>\n\t}\n\n\t@* \u2500\u2500 \"How to Play\" popup: a floating card centred over the screen (like the music\n\t player), shown while _howtoOpen. A dim full-screen backdrop captures input so the\n\t game pauses behind it; the \u2715 (or the GAME INFO bar) closes it. Two sections: the\n\t round-state primer and a rundown of the server-side anticheat checks (see\n\t ../server/internal/game). Every text line is a single literal or one interpolated\n\t expression (the mixed-text-node reorder bug). \u2500\u2500 *@\n\t@if ( _howtoOpen )\n\t{\n\t\t<div class=\"howto-overlay\">\n\t\t\t<div class=\"howto-card\">\n\t\t\t\t<div class=\"howto-head\">\n\t\t\t\t\t<div class=\"howto-title\">HOW TO PLAY</div>\n\t\t\t\t\t<div class=\"howto-close\" onclick=\"@ToggleHowTo\">\u2715</div>\n\t\t\t\t</div>\n\t\t\t\t<div class=\"divider\"></div>\n\n\t\t\t\t<div class=\"line\">One global button, limited clicks, shared by everyone.</div>\n\t\t\t\t<div class=\"state-row\"><span class=\"tag wait\">WAIT\u2026</span><span class=\"desc\">Button is arming - DO NOT CLICK just because it's green</span></div>\n\t\t\t\t<div class=\"state-row\"><span class=\"tag go\">CLICK!</span><span class=\"desc\">Button is live! One point per click.</span></div>\n\t\t\t\t<div class=\"state-row\"><span class=\"tag over\">ROUND OVER</span><span class=\"desc\">All clicks used up, current score shown.</span></div>\n\t\t\t\t<div class=\"state-row\"><span class=\"tag over\">GAME OVER</span><span class=\"desc\">Session winner picked, new game next!</span></div>\n\t\t\t\t<div class=\"line penalty-note\">Clicking before the button is live is allowed but scores nothing \u2014 just wait for it to go green.</div>\n\n\t\t\t\t<div class=\"divider\"></div>\n\t\t\t\t<div class=\"howto-subtitle\">STEPPING AWAY</div>\n\t\t\t\t<div class=\"state-row\"><span class=\"tag wait\">PAUSE</span><span class=\"desc\">Need to step away? Hit PAUSE (above GAME INFO). You drop out of the round and the leaderboard race and won't be flagged AFK. Hit it again to rejoin on the next button.</span></div>\n\t\t\t\t<div class=\"line penalty-note\">Sit still and idle through a round and the server pauses you automatically \u2014 just hit PAUSE to come back when you're ready.</div>\n\n\t\t\t\t<div class=\"divider\"></div>\n\t\t\t\t<div class=\"howto-subtitle\">ANTI-CHEAT</div>\n\t\t\t\t<div class=\"line\">Every round, the server watches for tell-tale bot behaviour. The first flags just bench you behind a quick math question before the button arms again.</div>\n\t\t\t\t<div class=\"state-row\"><span class=\"tag over\">TOO FAST</span><span class=\"desc\">Two of your scoring clicks landed closer together than any human hand can manage.</span></div>\n\t\t\t\t<div class=\"state-row\"><span class=\"tag over\">TOO MANY</span><span class=\"desc\">In a busy round you took far more than your fair share of the clicks (solo rounds are exempt \u2014 there's no one to share with).</span></div>\n\t\t\t\t<div class=\"state-row\"><span class=\"tag over\">SOLO FARM</span><span class=\"desc\">You're miles ahead on the bounty with nobody around to race. Nothing's wrong, just check back when it's busier, or bring some friends so it counts.</span></div>\n\t\t\t\t<div class=\"state-row\"><span class=\"tag over\">RUNAWAY</span><span class=\"desc\">You out-clicked a runner-up who was genuinely competing by an impossible margin (beating an idle player is fine).</span></div>\n\t\t\t\t<div class=\"state-row\"><span class=\"tag over\">AFK</span><span class=\"desc\">Don't sit still during the wait \u2014 move your cursor while the button is arming.</span></div>\n\t\t\t\t<div class=\"state-row\"><span class=\"tag over\">BUSTED</span><span class=\"desc\">You scored without ever moving your cursor.</span></div>\n\t\t\t\t<div class=\"line penalty-note\">Keep getting flagged in one bounty and it escalates: too many flags puts you on a timed cooldown, and a few more after that sidelines you until the bounty is won. Play fair and none of this ever fires \u2014 these only catch automation.</div>\n\n\t\t\t\t<div class=\"divider\"></div>\n\t\t\t\t@* Single interpolated string (mixed literal + @() text nodes reorder on\n\t\t\t\t rebuild \u2014 see CLAUDE.md). Click copies the invite; toast confirms. *@\n\t\t\t\t<div class=\"discord-line clickable\" onclick=\"@CopyDiscord\">@($\"Think a flag was wrong, or want a heads-up on upcoming changes? Click here to copy our Discord invite ({DiscordInvite}) and come say hi.\")</div>\n\t\t\t</div>\n\t\t</div>\n\t}\n\n\t@* \u2500\u2500 parked / away gate: shown while the player has stepped away \u2014 either they hit\n\t PAUSE or the server auto-parked them off an afk_idle verdict. A dim overlay\n\t captures input so nothing underneath registers; the server withholds every game\n\t frame until they rejoin, so there's nothing to play here. The PAUSE bar (which\n\t stays visible above GAME INFO) is the single place to rejoin. \u2500\u2500 *@\n\t@* Suppressed while a panel is open: an open how-to/music panel is itself the \"away\"\n\t surface, and the park-overlay (z:190) would otherwise stack its RESUME card on top of\n\t the popup (z:160). Closing the panel auto-rejoins a panel-park, so the overlay only\n\t ever shows for a real PAUSE / server afk-park with no panel up. \u2500\u2500 *@\n\t@if ( C?.Parked == true && !_howtoOpen && !MusicOpen )\n\t{\n\t\t<div class=\"park-overlay\">\n\t\t\t<div class=\"park-card\">\n\t\t\t\t<div class=\"park-title\">PAUSED \u2014 AWAY</div>\n\t\t\t\t<div class=\"park-sub\">You've stepped away, so you're out of the round and off the leaderboard race \u2014 no AFK flags while you're paused.</div>\n\t\t\t\t@* While a RESUME is in flight the server defers the rejoin to the next arming\n\t\t\t\t boundary, so we stay on this surface showing \"rejoining next round\" until the\n\t\t\t\t next round_pending drops us to STAND BY. Otherwise show the RESUME button \u2014\n\t\t\t\t a child of the overlay card (painted above the backdrop) so it's reliably\n\t\t\t\t clickable, unlike the bottom-left PAUSE bar, which s&box hit-tests under this\n\t\t\t\t full-screen overlay regardless of z-index. \u2500\u2500 *@\n\t\t\t\t@if ( C.Resuming )\n\t\t\t\t{\n\t\t\t\t\t<div class=\"park-hint\">Rejoining \u2014 you'll be back on the next button\u2026</div>\n\t\t\t\t}\n\t\t\t\telse\n\t\t\t\t{\n\t\t\t\t\t<div class=\"park-hint\">Press RESUME to rejoin \u2014 you'll be back in on the next button.</div>\n\t\t\t\t\t<div class=\"park-resume\" onclick=\"@TogglePause\">RESUME</div>\n\t\t\t\t}\n\t\t\t</div>\n\t\t</div>\n\t}\n\n\t@* \u2500\u2500 anticheat test gate: shown when the player failed an end-of-round check and\n\t is benched. A full-screen dim overlay (captures input) with the question and\n\t an answer box; answering correctly un-benches them and the server arms them\n\t again. Every text line is a single literal or one interpolated expression\n\t (the mixed-text-node reorder bug). \u2500\u2500 *@\n\t@if ( C?.HasTest == true )\n\t{\n\t\t<div class=\"test-overlay\">\n\t\t\t<div class=\"test-card\">\n\t\t\t\t<div class=\"test-title\">Gamah Anti-Cheat v0.67.69.420</div>\n\t\t\t\t<div class=\"test-sub\">You probably know what you did so do some math now instead...</div>\n\t\t\t\t@if ( !string.IsNullOrEmpty( C.TestMessage ) )\n\t\t\t\t{\n\t\t\t\t\t<div class=\"test-reason\">@C.TestMessage</div>\n\t\t\t\t}\n\t\t\t\t<div class=\"test-prompt\">@($\"{C.TestPrompt} = ?\")</div>\n\t\t\t\t<TextEntry @ref=\"_testEntry\" placeholder=\"answer\" class=\"test-input\" maxlength=\"12\" />\n\t\t\t\t<div class=\"test-submit\" onclick=\"@SubmitTest\">SUBMIT</div>\n\t\t\t</div>\n\t\t</div>\n\t}\n\n\t@* \u2500\u2500 anticheat sanction gate: shown when the player is past the test rung \u2014 on a\n\t timed cooldown or ignored for the rest of the bounty. Same dim overlay, but no\n\t test to answer: just the reason and a countdown to when the state lifts (reuses\n\t the bounty-timer HH:MM:SS format). Each line is a single interpolated\n\t expression (the mixed-text-node reorder bug). \u2500\u2500 *@\n\t@if ( !string.IsNullOrEmpty( C?.SanctionState ) )\n\t{\n\t\t<div class=\"test-overlay\">\n\t\t\t<div class=\"test-card sanction\">\n\t\t\t\t<div class=\"test-title\">Gamah Anti-Cheat v0.67.69.420</div>\n\t\t\t\t<div class=\"test-reason\">@(C.SanctionMessage)</div>\n\t\t\t\t<div class=\"sanction-label\">@(C.SanctionState == \"ignored\" ? \"Back in the game when the bounty is won\" : \"Cooldown ends in\")</div>\n\t\t\t\t<div class=\"sanction-time\">@SanctionCountdown()</div>\n\t\t\t</div>\n\t\t</div>\n\t}\n</root>\n\n<style>\n\t/* The root genuinely fills the screen, so every absolutely-positioned child\n\t below anchors against the full screen rect. pointer-events:none here; only\n\t the button re-enables them. */\n\troot {\n\t\twidth: 100%;\n\t\theight: 100%;\n\t\tpointer-events: none;\n\t}\n\n\t/* \u2500\u2500 boards: shared look; each pinned via its own position class \u2500\u2500 */\n\t.board {\n\t\tposition: absolute;\n\t\ttop: 18px;\n\t\twidth: 280px;\n\t\tflex-direction: column;\n\t\tbackground-color: rgba(3, 13, 7, 0.85);\n\t\tborder: 1.5px solid rgba(255, 255, 255, 0.12);\n\t\tborder-radius: 10px;\n\t\tpadding: 12px 14px;\n\t\tgap: 3px;\n\t}\n\n\t/* left column: session board stacked above the all-time board, pinned top-left.\n\t The two boards flow in this flex column (their own absolute pin is cancelled\n\t below) so they grow downward independently and can never overlap each other. */\n\t.left-col {\n\t\tposition: absolute;\n\t\ttop: 18px;\n\t\tleft: 14px;\n\t\tflex-direction: column;\n\t\tgap: 14px;\n\t}\n\t.left-col .board { position: relative; top: auto; left: auto; right: auto; }\n\n\t/* GAME INFO opener: a wide labelled bar pinned bottom-left (top:auto cancels the\n\t shared pins), lifted clear of the bottom edge so the MUSIC bar sits beneath it.\n\t Mirrors the music FAB's wide-bar geometry/colours so the two read as a pair.\n\t Re-enables pointer events against the pointer-events:none root. */\n\t.info-fab {\n\t\tposition: absolute;\n\t\tleft: 14px;\n\t\tbottom: 66px;\n\t\twidth: 300px;\n\t\theight: 44px;\n\t\tjustify-content: center;\n\t\talign-items: center;\n\t\tfont-size: 15px;\n\t\tfont-weight: bold;\n\t\tletter-spacing: 1px;\n\t\tcolor: rgba(255, 255, 255, 0.85);\n\t\tbackground-color: rgba(3, 13, 7, 0.85);\n\t\tborder: 1.5px solid rgba(255, 255, 255, 0.12);\n\t\tborder-radius: 8px;\n\t\tcursor: pointer;\n\t\tpointer-events: all;\n\t\tz-index: 150;\n\t}\n\n\t.info-fab:hover {\n\t\tcolor: #ffffff;\n\t\tborder-color: rgba(6, 214, 160, 0.6);\n\t}\n\n\t/* PAUSE control: same wide-bar geometry as the GAME INFO / MUSIC bars, pinned one\n\t slot ABOVE the GAME INFO bar. Green \"PAUSE\" at rest; red \"PAUSED\" while away. */\n\t.pause-fab {\n\t\tposition: absolute;\n\t\tleft: 14px;\n\t\tbottom: 120px;\n\t\twidth: 300px;\n\t\theight: 44px;\n\t\tjustify-content: center;\n\t\talign-items: center;\n\t\tfont-size: 15px;\n\t\tfont-weight: bold;\n\t\tletter-spacing: 1px;\n\t\tcolor: #04130b;\n\t\tbackground-color: rgba(6, 214, 160, 0.9);\n\t\tborder: 1.5px solid rgba(6, 214, 160, 0.55);\n\t\tborder-radius: 8px;\n\t\tcursor: pointer;\n\t\tpointer-events: all;\n\t\t/* The PARK direction (not parked \u2192 away) happens here, with no overlay up. While\n\t\t PARKED the full-screen overlay sits over this bar (s&box hit-tests the overlay\n\t\t on top regardless of z-index), so rejoin is the overlay's RESUME button, not this\n\t\t bar \u2014 the bar then just reads \"PAUSED\" as a status. */\n\t\tz-index: 150;\n\t}\n\n\t.pause-fab:hover { background-color: rgba(6, 214, 160, 1); }\n\n\t.pause-fab.paused {\n\t\tcolor: #ffffff;\n\t\tbackground-color: rgba(214, 40, 40, 0.9);\n\t\tborder-color: rgba(214, 40, 40, 0.65);\n\t}\n\n\t.pause-fab.paused:hover { background-color: rgba(214, 40, 40, 1); }\n\n\t/* \"PAUSING\u2026\": a manual PAUSE pressed mid-armed, waiting for the next arming boundary\n\t to take effect (amber, between the green rest and red paused states). */\n\t.pause-fab.pending {\n\t\tcolor: #04130b;\n\t\tbackground-color: rgba(255, 209, 102, 0.92);\n\t\tborder-color: rgba(255, 209, 102, 0.6);\n\t}\n\n\t.pause-fab.pending:hover { background-color: rgba(255, 209, 102, 1); }\n\n\t/* \u2500\u2500 parked / away overlay: a dim full-screen backdrop (captures input so nothing\n\t underneath registers) with a centred card. Sits above the how-to popup, below the\n\t anticheat test gate (z:200). The PAUSE bar is lifted above this backdrop (z:195) so\n\t it stays clickable \u2014 it's the one way to rejoin. \u2500\u2500 */\n\t.park-overlay {\n\t\tposition: absolute;\n\t\ttop: 0;\n\t\tleft: 0;\n\t\tright: 0;\n\t\tbottom: 0;\n\t\talign-items: center;\n\t\tjustify-content: center;\n\t\tbackground-color: rgba(0, 0, 0, 0.72);\n\t\tpointer-events: all;\n\t\tz-index: 190;\n\t}\n\n\t.park-card {\n\t\tflex-direction: column;\n\t\tflex-shrink: 0;\n\t\twidth: 520px;\n\t\tpadding: 26px 30px;\n\t\talign-items: center;\n\t\tbackground-color: rgba(3, 13, 7, 0.98);\n\t\tborder: 1.5px solid rgba(214, 40, 40, 0.55);\n\t\tborder-radius: 12px;\n\t\tgap: 10px;\n\t\tpointer-events: all;\n\t}\n\n\t.park-title {\n\t\tfont-size: 26px;\n\t\tfont-weight: bold;\n\t\tletter-spacing: 2px;\n\t\tcolor: #ff6b6b;\n\t}\n\n\t.park-sub {\n\t\tfont-size: 16px;\n\t\tcolor: rgba(255, 255, 255, 0.85);\n\t\ttext-align: center;\n\t}\n\n\t.park-hint {\n\t\tfont-size: 15px;\n\t\tcolor: rgba(6, 214, 160, 0.95);\n\t\ttext-align: center;\n\t}\n\n\t/* RESUME button on the parked overlay card \u2014 the reliable rejoin control. */\n\t.park-resume {\n\t\tmargin-top: 8px;\n\t\tpadding: 12px 40px;\n\t\tjustify-content: center;\n\t\talign-items: center;\n\t\tfont-size: 18px;\n\t\tfont-weight: bold;\n\t\tletter-spacing: 2px;\n\t\tcolor: #04130b;\n\t\tbackground-color: rgba(6, 214, 160, 0.92);\n\t\tborder: 1.5px solid rgba(6, 214, 160, 0.6);\n\t\tborder-radius: 8px;\n\t\tcursor: pointer;\n\t\tpointer-events: all;\n\t}\n\n\t.park-resume:hover { background-color: rgba(6, 214, 160, 1); }\n\n\t/* \u2500\u2500 \"How to Play\" popup: a dim full-screen backdrop (captures input) with a\n\t floating card centred over it, like the music player. Sits below the anticheat\n\t test gate (z:200) but above everything else. \u2500\u2500 */\n\t.howto-overlay {\n\t\tposition: absolute;\n\t\ttop: 0;\n\t\tleft: 0;\n\t\tright: 0;\n\t\tbottom: 0;\n\t\talign-items: center;\n\t\tjustify-content: center;\n\t\tbackground-color: rgba(0, 0, 0, 0.7);\n\t\tpointer-events: all;\n\t\tz-index: 160;\n\t}\n\n\t/* Sized to its content \u2014 NOT scrollable. An overflow:scroll range becomes\n\t drag-pannable in s&box (you can drag the card around inside the overlay), so\n\t the card grows to fit instead, like the music board. The content is lean\n\t enough to fit the 1080-tall reference ScreenPanel scales to. */\n\t.howto-card {\n\t\tflex-direction: column;\n\t\tflex-shrink: 0;\n\t\twidth: 560px;\n\t\tpadding: 22px 28px;\n\t\tbackground-color: rgba(3, 13, 7, 0.98);\n\t\tborder: 1.5px solid rgba(255, 255, 255, 0.12);\n\t\tborder-radius: 12px;\n\t\tgap: 3px;\n\t\tpointer-events: all;\n\t}\n\n\t.howto-card .howto-head {\n\t\tflex-direction: row;\n\t\talign-items: center;\n\t\tjustify-content: space-between;\n\t\tmargin-bottom: 4px;\n\t}\n\n\t.howto-card .howto-title {\n\t\tfont-size: 22px;\n\t\tfont-weight: bold;\n\t\tcolor: rgba(255, 255, 255, 0.9);\n\t\tletter-spacing: 1px;\n\t}\n\n\t.howto-card .howto-subtitle {\n\t\tfont-size: 16px;\n\t\tfont-weight: bold;\n\t\tcolor: #06d6a0;\n\t\tletter-spacing: 1px;\n\t\tmargin-top: 2px;\n\t\tmargin-bottom: 4px;\n\t}\n\n\t.howto-card .howto-close {\n\t\twidth: 34px;\n\t\theight: 34px;\n\t\tjustify-content: center;\n\t\talign-items: center;\n\t\tfont-size: 20px;\n\t\tfont-weight: bold;\n\t\tcolor: rgba(255, 255, 255, 0.6);\n\t\tborder-radius: 6px;\n\t\tcursor: pointer;\n\t}\n\n\t.howto-card .howto-close:hover {\n\t\tcolor: #ffffff;\n\t\tbackground-color: rgba(255, 255, 255, 0.08);\n\t}\n\n\t.howto-card .divider {\n\t\theight: 1px;\n\t\tbackground-color: rgba(255, 255, 255, 0.1);\n\t\tmargin-top: 6px;\n\t\tmargin-bottom: 8px;\n\t}\n\n\t.howto-card .line {\n\t\tfont-size: 14px;\n\t\tcolor: rgba(255, 255, 255, 0.6);\n\t\tmargin-bottom: 6px;\n\t\twhite-space: normal;\n\t}\n\n\t.howto-card .penalty-note {\n\t\tmargin-bottom: 0;\n\t\tmargin-top: 2px;\n\t}\n\n\t.howto-card .state-row {\n\t\tflex-direction: row;\n\t\talign-items: center;\n\t\tgap: 10px;\n\t\tmargin-bottom: 5px;\n\t}\n\n\t.howto-card .tag {\n\t\twidth: 110px;\n\t\tflex-shrink: 0;\n\t\tfont-size: 13px;\n\t\tfont-weight: bold;\n\t\tletter-spacing: 0.5px;\n\t}\n\n\t.howto-card .tag.wait { color: #06d6a0; }\n\t.howto-card .tag.go { color: #ffffff; }\n\t.howto-card .tag.over { color: #e63946; }\n\n\t.howto-card .desc {\n\t\tflex-grow: 1;\n\t\tfont-size: 13px;\n\t\tcolor: rgba(255, 255, 255, 0.6);\n\t\twhite-space: normal;\n\t}\n\n\t/* click-to-copy Discord invite blurb (there's no in-game URL open; Clipboard\n\t works, so it copies). Discord blurple, underlines on hover. */\n\t.howto-card .discord-line {\n\t\tfont-size: 13px;\n\t\tfont-weight: bold;\n\t\tcolor: #8b93f5;\n\t\twhite-space: normal;\n\t\tpointer-events: all;\n\t\tcursor: pointer;\n\t}\n\t.howto-card .discord-line:hover { color: #ffffff; text-decoration: underline; }\n\n\t/* The music opener lives on the Skafinity panel's own ScreenPanel (see scene\n\t ShowFab/WideFab/FabLabel), not here. */\n\t.board.hourly { right: 308px; } /* 14 (edge) + 280 (hours-won) + 14 (gap) */\n\t.board.hourswon { right: 14px; }\n\t/* sessions-won pins to the free bottom-right corner (top:auto cancels the\n\t shared top:18px) so the three right-hand boards never crowd the button. */\n\t.board.sessionswon { top: auto; bottom: 18px; right: 14px; }\n\n\t.board .title {\n\t\tfont-size: 14px;\n\t\tfont-weight: bold;\n\t\tcolor: rgba(255, 255, 255, 0.85);\n\t\tletter-spacing: 1px;\n\t\tjustify-content: center;\n\t\tmargin-bottom: 4px;\n\t}\n\n\t.board .reset {\n\t\tfont-size: 11px;\n\t\tcolor: rgba(255, 255, 255, 0.5);\n\t\tjustify-content: center;\n\t\tmargin-top: -2px;\n\t\tmargin-bottom: 4px;\n\t}\n\n\t.board .divider {\n\t\theight: 1px;\n\t\tbackground-color: rgba(255, 255, 255, 0.1);\n\t\tmargin-bottom: 5px;\n\t}\n\n\t.board .row {\n\t\tflex-direction: row;\n\t\talign-items: center;\n\t\tfont-size: 13px;\n\t\tcolor: rgba(255, 255, 255, 0.78);\n\t\tpadding: 2px 5px;\n\t\tborder-radius: 4px;\n\t}\n\n\t.board .rank { width: 24px; color: rgba(255, 255, 255, 0.4); font-size: 12px; }\n\t/* anticheat status dot: green live / yellow cooldown / red ignored. */\n\t.board .dot {\n\t\twidth: 8px;\n\t\theight: 8px;\n\t\tflex-shrink: 0;\n\t\tmargin-right: 7px;\n\t\tborder-radius: 50%;\n\t\tbackground-color: #06d6a0;\n\t}\n\t.board .dot.live { background-color: #06d6a0; }\n\t.board .dot.cooldown { background-color: #ffd166; }\n\t.board .dot.ignored { background-color: #e63946; }\n\t.board .name { flex-grow: 1; overflow: hidden; white-space: nowrap; }\n\t.board .pts { color: #06d6a0; font-weight: bold; }\n\t.board .row.me { background-color: rgba(6, 214, 160, 0.15); }\n\n\t/* the tie-break margin (\"+12ms\"): how far this player trailed the one above\n\t them on the same score. Dim amber, just left of the points. */\n\t.board .behind { color: #ffb37a; font-size: 11px; margin-right: 6px; flex-shrink: 0; }\n\n\t/* names are clickable (copy the player's Steam profile link); re-enable\n\t pointer events on just these against the pointer-events:none root. */\n\t.board .name.clickable { pointer-events: all; cursor: pointer; }\n\t.board .name.clickable:hover { color: #ffffff; text-decoration: underline; }\n\n\t/* hours-won is the gold board */\n\t.board.hourswon .pts { color: #ffd166; }\n\t.board.hourswon .row.me { background-color: rgba(255, 209, 102, 0.15); }\n\n\t/* sessions-won is the blue board */\n\t.board.sessionswon .pts { color: #4cc9f0; }\n\t.board.sessionswon .row.me { background-color: rgba(76, 201, 240, 0.15); }\n\n\t/* all-time clickers: the violet board (stacked under the session board in the\n\t left column; positioning handled by .left-col). */\n\t.board.alltime .pts { color: #c77dff; }\n\t.board.alltime .row.me { background-color: rgba(199, 125, 255, 0.15); }\n\n\t/* the leader's \"skin\": bundled image + caption, pinned just left of the\n\t sessions-won board (308 = 14 edge + 280 board + 14 gap), bottom-aligned. */\n\t.skin-panel {\n\t\tposition: absolute;\n\t\tbottom: 18px;\n\t\tright: 308px;\n\t\twidth: 200px;\n\t\tflex-direction: column;\n\t\talign-items: center;\n\t\tbackground-color: rgba(3, 13, 7, 0.85);\n\t\tborder: 1.5px solid rgba(255, 255, 255, 0.12);\n\t\tborder-radius: 10px;\n\t\tpadding: 10px;\n\t\tgap: 6px;\n\t}\n\n\t/* countdown to the winner-lock time, sitting above the image. */\n\t.skin-countdown {\n\t\tflex-direction: column;\n\t\talign-items: center;\n\t\tgap: 1px;\n\t}\n\n\t.skin-countdown .cd-label {\n\t\tfont-size: 12px;\n\t\tfont-weight: bold;\n\t\tcolor: rgba(255, 255, 255, 0.6);\n\t\ttext-align: center;\n\t\tjustify-content: center;\n\t\tletter-spacing: 0.5px;\n\t}\n\n\t.skin-countdown .cd-time {\n\t\tfont-size: 22px;\n\t\tfont-weight: bold;\n\t\tcolor: #ffd166;\n\t\tjustify-content: center;\n\t\tletter-spacing: 1px;\n\t}\n\n\t/* shown in place of the countdown once the winner-lock time has passed. */\n\t.skin-locked {\n\t\tfont-size: 13px;\n\t\tfont-weight: bold;\n\t\tcolor: #ff9f1c;\n\t\ttext-align: center;\n\t\tjustify-content: center;\n\t}\n\n\t/* shown in place of the whole skin/countdown when no bounty is active (the\n\t config skin/countdown would otherwise be a stale fallback). */\n\t.no-bounty {\n\t\tflex-direction: column;\n\t\talign-items: center;\n\t\tjustify-content: center;\n\t\tpadding: 24px 12px;\n\t\tgap: 8px;\n\t}\n\n\t.no-bounty-title {\n\t\tfont-size: 20px;\n\t\tfont-weight: bold;\n\t\tcolor: #ffd166;\n\t\ttext-align: center;\n\t\tjustify-content: center;\n\t\tletter-spacing: 0.5px;\n\t}\n\n\t.no-bounty-sub {\n\t\tfont-size: 12px;\n\t\tcolor: rgba(255, 255, 255, 0.6);\n\t\ttext-align: center;\n\t\tjustify-content: center;\n\t}\n\n\t/* the image (served by the backend) scales to fit the panel width\n\t (contain keeps its aspect ratio); the url is bound inline from config. */\n\t.skin-img {\n\t\twidth: 100%;\n\t\theight: 240px;\n\t\tbackground-repeat: no-repeat;\n\t\tbackground-position: center;\n\t\tbackground-size: contain;\n\t}\n\n\t.skin-caption {\n\t\tfont-size: 15px;\n\t\tfont-weight: bold;\n\t\tcolor: #4cc9f0;\n\t\tjustify-content: center;\n\t\ttext-align: center;\n\t}\n\n\t/* click-to-copy the bounty's CS2 inspect link (no in-game URL open; Clipboard\n\t works, so it copies). Underlines on hover like the other clickable lines. */\n\t.skin-copy {\n\t\tfont-size: 12px;\n\t\tfont-weight: bold;\n\t\tcolor: #8b93f5;\n\t\tjustify-content: center;\n\t\ttext-align: center;\n\t\tpointer-events: all;\n\t\tcursor: pointer;\n\t}\n\t.skin-copy:hover { color: #ffffff; text-decoration: underline; }\n\n\t/* the decoded skin's name (from the inspect link's dataset lookup). */\n\t.skin-name {\n\t\tfont-size: 14px;\n\t\tfont-weight: bold;\n\t\tcolor: #ffffff;\n\t\ttext-align: center;\n\t\tjustify-content: center;\n\t}\n\n\t/* wear / float / seed rows decoded from the inspect link. */\n\t.skin-stats {\n\t\twidth: 100%;\n\t\tflex-direction: column;\n\t\tgap: 2px;\n\t}\n\n\t.skin-stats .stat {\n\t\tflex-direction: row;\n\t\tjustify-content: space-between;\n\t\tfont-size: 12px;\n\t}\n\n\t.skin-stats .stat .k { color: rgba(255, 255, 255, 0.55); }\n\t.skin-stats .stat .v { color: #e0e0e0; font-weight: bold; }\n\n\t/* the FN\u2192BS wear bar: five segments sized to the wear thresholds with a\n\t marker line at the float position. */\n\t.wear-bar {\n\t\tposition: relative;\n\t\twidth: 100%;\n\t\theight: 8px;\n\t\tflex-direction: row;\n\t\tborder-radius: 3px;\n\t}\n\n\t.wear-bar .seg { height: 100%; }\n\t.wear-bar .seg.fn { width: 7%; background-color: #4cd964; }\n\t.wear-bar .seg.mw { width: 8%; background-color: #a8e05f; }\n\t.wear-bar .seg.ft { width: 23%; background-color: #ffd166; }\n\t.wear-bar .seg.ww { width: 7%; background-color: #ff9f1c; }\n\t.wear-bar .seg.bs { width: 55%; background-color: #ff4d4d; }\n\n\t.wear-marker {\n\t\tposition: absolute;\n\t\ttop: -2px;\n\t\twidth: 2px;\n\t\theight: 12px;\n\t\tbackground-color: #ffffff;\n\t\tborder: 1px solid rgba(0, 0, 0, 0.6);\n\t}\n\n\t/* shown when the inspect link couldn't be decoded or its image fetched. */\n\t.skin-note {\n\t\tfont-size: 11px;\n\t\tfont-weight: bold;\n\t\tcolor: #ff9f1c;\n\t\ttext-align: center;\n\t\tjustify-content: center;\n\t}\n\n\t/* previous-winner panel: a duplicate skin panel pinned just left of the live one\n\t (308 + 200 + 20). Display-only \u2014 pointer-events:none so the roaming button\n\t beneath stays fully clickable; a gold edge + slightly shorter image distinguish\n\t it from the live \"skin to win\". */\n\t.skin-panel.previous {\n\t\tright: 528px;\n\t\tpointer-events: none;\n\t\tborder-color: rgba(255, 209, 102, 0.35);\n\t}\n\n\t.skin-panel.previous .skin-img { height: 150px; }\n\n\t.prev-header {\n\t\tfont-size: 12px;\n\t\tfont-weight: bold;\n\t\tcolor: #ffd166;\n\t\tletter-spacing: 1px;\n\t\ttext-align: center;\n\t\tjustify-content: center;\n\t}\n\n\t.prev-winner {\n\t\tfont-size: 16px;\n\t\tfont-weight: bold;\n\t\tcolor: #ffffff;\n\t\ttext-align: center;\n\t\tjustify-content: center;\n\t}\n\n\t/* the winner line re-enables pointer events over the panel's pointer-events:none\n\t (only this line is interactive \u2014 click to copy the winner's profile link). */\n\t.prev-winner.clickable { pointer-events: all; cursor: pointer; }\n\t.prev-winner.clickable:hover { color: #ffd166; text-decoration: underline; }\n\n\t.prev-wins {\n\t\tfont-size: 12px;\n\t\tcolor: rgba(255, 255, 255, 0.6);\n\t\ttext-align: center;\n\t\tjustify-content: center;\n\t}\n\n\t.board .empty {\n\t\tjustify-content: center;\n\t\tfont-size: 12px;\n\t\tcolor: rgba(255, 255, 255, 0.35);\n\t\tmargin-top: 4px;\n\t}\n\n\t/* \u2500\u2500 center: the BOUNDING BOX the button roams inside, inset between the\n\t left session board (right edge \u2248 308px) and the right board column (also\n\t 308px), with a little top/bottom breathing room. The button is positioned\n\t absolutely within this box; the status HUD overlays it centred. \u2500\u2500\n\n\t The box (and so the button + status HUD + music panel inside it) is kept ABOVE every\n\t board at all times via z-index. The boards are declared after .center in the markup,\n\t so without this they'd paint \u2014 and take clicks \u2014 on top of anything in the box. The\n\t box is normally inset clear of every board, but the roaming button (and the music\n\t button it can overlap at the bottom-left) must always win paint AND click priority\n\t when they do intersect, so the box outranks them unconditionally. */\n\t.center {\n\t\tposition: absolute;\n\t\ttop: 24px;\n\t\tbottom: 24px;\n\t\tleft: 308px;\n\t\tright: 308px;\n\t\tflex-direction: column;\n\t\talign-items: center;\n\t\tjustify-content: center;\n\t\tpointer-events: none;\n\t\tz-index: 100;\n\t}\n\n\t/* Opponent click pip: a half-size (70px vs the button's 140px) green circle that\n\t pops in and fades over ~0.45s (matched to PipLifetime so it's gone before\n\t ClickController prunes it). Positioned in the box's % space (left/top set inline),\n\t with margin offsets of \u2212half so the inline %, which is the clicker's button\n\t CENTRE, places the pip centred there. pointer-events:none so it never eats a\n\t click; below the penalty pops, above the button. */\n\t.pip {\n\t\tposition: absolute;\n\t\twidth: 70px;\n\t\theight: 70px;\n\t\tmargin-left: -35px;\n\t\tmargin-top: -35px;\n\t\tborder-radius: 50%;\n\t\tbackground-color: #06d6a0;\n\t\tborder: 2px solid rgba(255, 255, 255, 0.8);\n\t\tflex-direction: column;\n\t\talign-items: center;\n\t\tjustify-content: center;\n\t\tpointer-events: none;\n\t\tz-index: 110;\n\t\tanimation-name: pip-fade;\n\t\tanimation-duration: 0.45s;\n\t\tanimation-timing-function: ease-out;\n\t\tanimation-fill-mode: forwards;\n\t}\n\n\t@@keyframes pip-fade {\n\t\t0% { opacity: 0.9; transform: scale(0.6); }\n\t\t25% { opacity: 0.85; transform: scale(1); }\n\t\t100% { opacity: 0; transform: scale(1.1); }\n\t}\n\n\t/* The clicker's name centred over their pip. */\n\t.pip-name {\n\t\tfont-size: 13px;\n\t\tfont-weight: bold;\n\t\tcolor: #ffffff;\n\t\ttext-stroke: 1px #000000;\n\t\ttext-align: center;\n\t\twhite-space: nowrap;\n\t}\n\n\t/* v5 board button: one clickable green SQUARE per live slot. Positioned by its\n\t server-placed centre (inline left/top %, same box-% space as the pips), centred via\n\t \u2212half margins. Square (not a circle) so the visual matches the actual click hitbox\n\t AND the `touch` hitbox \u2014 s&box hit-tests the panel's box, so a circle would leave the\n\t corners clickable-but-untouched and false-trip the no_hover check; it also matches the\n\t server's square non-overlap spacing model (board.go posMinDist). Smaller than the\n\t legacy single button since up to X share the box. No teleport \u2014 positions are\n\t server-authoritative, so left/top can transition-free. */\n\t.board-btn {\n\t\tposition: absolute;\n\t\twidth: 96px;\n\t\theight: 96px;\n\t\tmargin-left: -48px;\n\t\tmargin-top: -48px;\n\t\tborder-radius: 12px;\n\t\tbackground-color: #06d6a0; /* live green \u2014 the board only exists while armed */\n\t\tborder: 4px solid rgba(255, 255, 255, 0.9);\n\t\ttransition: transform 0.08s ease-out;\n\t\tpointer-events: all;\n\t\tcursor: pointer;\n\t\tz-index: 105;\n\t}\n\t.board-btn:active { transform: scale(0.9); }\n\n\t/* Opponent cursor: a small amber dot at another player's sampled pointer, with their\n\t name beneath. Box-% space like the pips; never eats a click. */\n\t.opp-cursor {\n\t\tposition: absolute;\n\t\twidth: 16px;\n\t\theight: 16px;\n\t\tmargin-left: -8px;\n\t\tmargin-top: -8px;\n\t\tborder-radius: 50%;\n\t\tbackground-color: rgba(255, 209, 102, 0.9);\n\t\tborder: 2px solid rgba(0, 0, 0, 0.5);\n\t\tflex-direction: column;\n\t\talign-items: center;\n\t\tpointer-events: none;\n\t\tz-index: 108;\n\t}\n\n\t.opp-cursor-name {\n\t\tposition: absolute;\n\t\ttop: 16px;\n\t\tfont-size: 13px;\n\t\tcolor: #ffd166;\n\t\ttext-stroke: 1px rgba(0, 0, 0, 0.8);\n\t\twhite-space: nowrap;\n\t\tpointer-events: none;\n\t}\n\n\t/* Live clicks-remaining counter, green, shown while the window is open. */\n\t.remaining {\n\t\tfont-size: 40px;\n\t\tfont-weight: bold;\n\t\tcolor: #06d6a0;\n\t\ttext-stroke: 1px rgba(0, 0, 0, 0.5);\n\t}\n\n\t/* HUD pinned to the top edge of the screen, centred horizontally over the box;\n\t the button roams below/behind it and clicks fall through. */\n\t.hud {\n\t\tposition: absolute;\n\t\ttop: 0;\n\t\tleft: 0;\n\t\tright: 0;\n\t\tbottom: 0;\n\t\tflex-direction: column;\n\t\talign-items: center;\n\t\tjustify-content: flex-start;\n\t\tgap: 10px;\n\t\tpointer-events: none;\n\t}\n\n\t.round {\n\t\tfont-size: 22px;\n\t\tfont-weight: bold;\n\t\tcolor: rgba(255, 255, 255, 0.85);\n\t\tletter-spacing: 1px;\n\t}\n\n\t.state {\n\t\tfont-size: 54px;\n\t\tfont-weight: bold;\n\t\tcolor: rgba(255, 255, 255, 0.9);\n\t\tletter-spacing: 3px;\n\t}\n\n\t.state.armed { color: #ffffff; }\n\t.state.offline { color: #ffd166; }\n\n\t.meta {\n\t\tflex-direction: row;\n\t\tgap: 10px;\n\t\talign-items: center;\n\t\tfont-size: 18px;\n\t\tfont-weight: bold;\n\t\tcolor: rgba(255, 255, 255, 0.85);\n\t}\n\n\t.meta .sep { color: rgba(255, 255, 255, 0.4); }\n\n\t.arm {\n\t\tfont-size: 16px;\n\t\tfont-weight: bold;\n\t\tcolor: rgba(255, 255, 255, 0.6);\n\t\tletter-spacing: 1px;\n\t}\n\n\t/* Host-pushed broadcast note: a large orange message centred in the status HUD. */\n\t.dev-note {\n\t\tfont-size: 32px;\n\t\tfont-weight: bold;\n\t\tcolor: #ff8c00;\n\t}\n\n\t/* The status HUD now floats centred on the dark box (the button roams away from\n\t it), so the text stays white in every state \u2014 no on-button contrast override. */\n\n\t/* Pinned to the bottom of the bounding box, centred horizontally. */\n\t.clicks-sent {\n\t\tposition: absolute;\n\t\tbottom: 0;\n\t\tleft: 0;\n\t\tright: 0;\n\t\tjustify-content: center;\n\t\tfont-size: 22px;\n\t\tfont-weight: bold;\n\t\tcolor: rgba(255, 255, 255, 0.65);\n\t\tletter-spacing: 1px;\n\t\tpointer-events: none;\n\t}\n\n\t/* transient confirmation that a profile link was copied; top-center, fades by\n\t simply un-rendering after ToastDuration (see BuildHash). */\n\t.toast {\n\t\tposition: absolute;\n\t\ttop: 40px;\n\t\tleft: 0;\n\t\tright: 0;\n\t\tjustify-content: center;\n\t\tfont-size: 20px;\n\t\tfont-weight: bold;\n\t\tcolor: #4cc9f0;\n\t\tletter-spacing: 1px;\n\t\tpointer-events: none;\n\t\t/* Above every overlay (the .center box at 100, the how-to popup at 160, the\n\t\t anticheat gate at 200) so the \"copied\" confirmation is visible no matter what's\n\t\t open \u2014 e.g. copying the Discord invite from inside the how-to popup. */\n\t\tz-index: 210;\n\t}\n\n\t/* \u2500\u2500 per-round scores flash: a card centred over the play area, shown for\n\t FlashDuration after each round closes then fading out. The .round-flash layer\n\t fills the screen and centres the card; the keyframe (pop in \u2192 hold \u2192 fade) runs\n\t for the full 4s so the card unmounts as the fade ends. Non-interactive and above\n\t the boards/button but below the how-to popup (160) and the anticheat gate (200).\n\t The card is sized to its content (no scroll \u2014 an overflow range becomes\n\t drag-pannable in s&box). */\n\t.round-flash {\n\t\tposition: absolute;\n\t\ttop: 0;\n\t\tleft: 0;\n\t\tright: 0;\n\t\tbottom: 0;\n\t\tflex-direction: column;\n\t\talign-items: center;\n\t\tjustify-content: center;\n\t\tpointer-events: none;\n\t\tz-index: 140;\n\t\tanimation-name: round-flash-anim;\n\t\tanimation-duration: 4s;\n\t\tanimation-timing-function: ease-out;\n\t\tanimation-fill-mode: forwards;\n\t}\n\n\t@@keyframes round-flash-anim {\n\t\t0% { opacity: 0; }\n\t\t6% { opacity: 1; }\n\t\t82% { opacity: 1; }\n\t\t100% { opacity: 0; }\n\t}\n\n\t/* the visible card: same dark panel look as the boards. */\n\t.round-flash .rf-panel {\n\t\tflex-direction: column;\n\t\tflex-shrink: 0;\n\t\twidth: 360px;\n\t\tpadding: 16px 20px;\n\t\tgap: 3px;\n\t\tbackground-color: rgba(3, 13, 7, 0.95);\n\t\tborder: 1.5px solid rgba(255, 255, 255, 0.12);\n\t\tborder-radius: 12px;\n\t}\n\n\t.round-flash .rf-title {\n\t\tfont-size: 22px;\n\t\tfont-weight: bold;\n\t\tcolor: rgba(255, 255, 255, 0.9);\n\t\tletter-spacing: 2px;\n\t\tjustify-content: center;\n\t\tmargin-bottom: 2px;\n\t}\n\n\t.round-flash .divider {\n\t\theight: 1px;\n\t\tbackground-color: rgba(255, 255, 255, 0.1);\n\t\tmargin-bottom: 6px;\n\t}\n\n\t.round-flash .rf-row {\n\t\tflex-direction: row;\n\t\talign-items: center;\n\t\tfont-size: 18px;\n\t\tcolor: rgba(255, 255, 255, 0.85);\n\t\tpadding: 3px 6px;\n\t\tborder-radius: 4px;\n\t}\n\n\t.round-flash .rf-rank { width: 30px; color: rgba(255, 255, 255, 0.4); font-size: 15px; }\n\t.round-flash .rf-name { flex-grow: 1; overflow: hidden; white-space: nowrap; }\n\t.round-flash .rf-pts { color: #06d6a0; font-weight: bold; }\n\t.round-flash .rf-row.me { background-color: rgba(6, 214, 160, 0.15); }\n\n\t.round-flash .rf-empty {\n\t\tjustify-content: center;\n\t\tfont-size: 15px;\n\t\tcolor: rgba(255, 255, 255, 0.5);\n\t\tpadding: 4px 0;\n\t}\n\n\t/* \u2500\u2500 anticheat test gate: a full-screen dim overlay that captures input (above\n\t everything else) with a centred card holding the question + answer box. \u2500\u2500 */\n\t.test-overlay {\n\t\tposition: absolute;\n\t\ttop: 0;\n\t\tleft: 0;\n\t\tright: 0;\n\t\tbottom: 0;\n\t\talign-items: center;\n\t\tjustify-content: center;\n\t\tbackground-color: rgba(0, 0, 0, 0.75);\n\t\tpointer-events: all; /* block the game beneath while benched */\n\t\tz-index: 200;\n\t}\n\n\t.test-card {\n\t\tflex-direction: column;\n\t\talign-items: center;\n\t\tgap: 14px;\n\t\twidth: 420px;\n\t\tpadding: 28px 32px;\n\t\tbackground-color: rgba(3, 13, 7, 0.98);\n\t\tborder: 2px solid #e63946;\n\t\tborder-radius: 14px;\n\t}\n\n\t.test-title {\n\t\tfont-size: 26px;\n\t\tfont-weight: bold;\n\t\tcolor: #e63946;\n\t\tletter-spacing: 2px;\n\t\tjustify-content: center;\n\t}\n\n\t.test-sub {\n\t\tfont-size: 14px;\n\t\tcolor: rgba(255, 255, 255, 0.6);\n\t\ttext-align: center;\n\t\tjustify-content: center;\n\t\twhite-space: normal;\n\t}\n\n\t/* the specific check (or sanction) reason, in the anticheat red. */\n\t.test-reason {\n\t\tfont-size: 16px;\n\t\tfont-weight: bold;\n\t\tcolor: #ff8c69;\n\t\ttext-align: center;\n\t\tjustify-content: center;\n\t\twhite-space: normal;\n\t}\n\n\t/* cooldown/ignored card: no answer box, just the reason + a countdown. The\n\t amber border separates it from the red math-test card. */\n\t.test-card.sanction { border-color: #ffd166; }\n\n\t.sanction-label {\n\t\tfont-size: 14px;\n\t\tfont-weight: bold;\n\t\tcolor: rgba(255, 255, 255, 0.6);\n\t\ttext-align: center;\n\t\tjustify-content: center;\n\t\tletter-spacing: 0.5px;\n\t}\n\n\t.sanction-time {\n\t\tfont-size: 40px;\n\t\tfont-weight: bold;\n\t\tcolor: #ffd166;\n\t\tletter-spacing: 2px;\n\t\tjustify-content: center;\n\t}\n\n\t.test-prompt {\n\t\tfont-size: 40px;\n\t\tfont-weight: bold;\n\t\tcolor: #ffffff;\n\t\tletter-spacing: 2px;\n\t\tjustify-content: center;\n\t}\n\n\t/* Mirror the known-good TextEntry recipe (skafinity .tag-input / rotaliate\n\t .chat-entry): an explicit height + plain `color: white`. Without a height the\n\t text box collapses and the typed text is clipped (it's still captured \u2014 the\n\t answer sends \u2014 just not painted); text-align on a TextEntry also hides it. */\n\t.test-input {\n\t\twidth: 240px;\n\t\theight: 44px;\n\t\tpadding: 0 14px;\n\t\tfont-size: 24px;\n\t\tcolor: white;\n\t\tbackground-color: rgba(255, 255, 255, 0.08);\n\t\tborder: 1.5px solid rgba(255, 255, 255, 0.3);\n\t\tborder-radius: 8px;\n\t\tpointer-events: all;\n\t}\n\n\t.test-submit {\n\t\tfont-size: 18px;\n\t\tfont-weight: bold;\n\t\tcolor: #030d07;\n\t\tbackground-color: #06d6a0;\n\t\tletter-spacing: 1px;\n\t\tpadding: 10px 28px;\n\t\tborder-radius: 8px;\n\t\tcursor: pointer;\n\t\tpointer-events: all;\n\t}\n\n\t.test-submit:hover { background-color: #08f0b4; }\n</style>\n\n@code {\n\tconst int DisplayLimit = 10;\n\tconst float RefreshInterval = 20f;\n\n\t// Touch-hitbox geometry (reference px, the 1080-tall ScreenPanel space). CenterRefH is\n\t// the .center box's reference height (1080 \u2212 top:24 \u2212 bottom:24); BoardBtnRefHalf is the\n\t// .board-btn half-extent (96px square \u21d2 48). TouchPadRef DELIBERATELY enlarges the touch\n\t// hitbox beyond the visual/click box so the `touch` zone is a strict SUPERSET of the\n\t// clickable area: a click can land in the 4px border ring or a frame where the per-frame\n\t// sampler just missed the edge, and no_hover only targets EGREGIOUS cases (cursor nowhere\n\t// near the button), so erring bigger trades a hair of sensitivity for zero edge-click\n\t// false positives. Used to size the square hitbox in actual px when detecting a `touch`\n\t// enter (see OnUpdate).\n\tconst float CenterRefH = 1080f - 24f - 24f;\n\tconst float BoardBtnRefHalf = 48f;\n\tconst float TouchPadRef = 32f;\n\n\tClickController C => ClickController.Instance;\n\n\t// \u2500\u2500 session: live standings pushed inside round_result/game_over (never fetched) \u2500\u2500\n\tstatic List<Standing> SessionEntries => ClickController.Instance?.Standings ?? new List<Standing>();\n\n\t// \u2500\u2500 hourly + hours-won: fetched over HTTP, deliberately infrequently (once on\n\t// connect, then when a round closes, throttled) so neither becomes a fan-in\n\t// GET stampede. The hot click path never touches HTTP. \u2500\u2500\n\tList<Standing> _hourly = new();\n\tbool _hourlyLoaded, _hourlyFetching;\n\tRealTimeSince _hourlyLast;\n\n\tList<Standing> _hoursWon = new();\n\tbool _hoursWonLoaded, _hoursWonFetching;\n\tRealTimeSince _hoursWonLast;\n\n\tList<Standing> _sessionsWon = new();\n\tbool _sessionsWonLoaded, _sessionsWonFetching;\n\tRealTimeSince _sessionsWonLast;\n\n\tList<Standing> _allTime = new();\n\tbool _allTimeLoaded, _allTimeFetching;\n\tRealTimeSince _allTimeLast;\n\n\tGamePhase _lastPhase = GamePhase.Connecting;\n\n\t// \u2500\u2500 the bounding box (.center), captured by @ref so the board buttons, opponent\n\t// pips and cursor can be measured/normalized against it. The board buttons are\n\t// server-placed (board-% space); the client draws no button of its own. \u2500\u2500\n\tPanel _box;\n\n\t// \u2500\u2500 server-driven skin + winner-lock countdown (GET /api/v1/config) \u2500\u2500\n\t// _winnerLockMs is the winner-lock instant as Unix epoch ms (0 = unset, hides\n\t// the countdown); _skinUrl is the absolute URL the backend serves the current\n\t// \"skin to win\" image from. Both come from config so they retune without a\n\t// client rebuild. Epoch-ms (not a parsed DateTime) because the s&box sandbox\n\t// doesn't whitelist System.Globalization \u2014 the countdown is integer math.\n\tlong _winnerLockMs;\n\tstring _skinUrl = \"\";\n\n\t// True only while a bounty is actually active (config's has_bounty). When false the\n\t// skin/countdown above is the host config.json/env fallback (a stale \"old\" skin) and\n\t// the bounty-scoped boards have fallen back to all-time data \u2014 so the skin panel and\n\t// the \"THIS SKIN\" boards show a \"no active bounty\" state instead of that stale data.\n\tbool _hasBounty;\n\n\t// \u2500\u2500 inspect-link skin (decoded locally + name/image resolved off the dataset) \u2500\u2500\n\t// Null until an inspect-linked bounty's config arrives and resolution completes;\n\t// its DecodeOk/ImageOk flags drive what the skin panel shows vs. the server image.\n\tSkinInspect.Skin _skin;\n\n\t// The active bounty's raw inspect link (\"\" when the skin is an uploaded image\n\t// only). Kept verbatim so the \"copy inspect link\" line can hand it to players to\n\t// paste into CS2, independent of whether it decoded/resolved locally.\n\tstring _inspectLink = \"\";\n\n\t// \u2500\u2500 previous winner (the just-settled bounty) \u2500\u2500 a duplicate of the skin panel\n\t// showing who won the last skin and what it was. _prevBounty is the most recent\n\t// won bounty (null until fetched / when there's no history); _prevSkin is its\n\t// inspect link decoded locally (same as _skin); _prevSkinUrl is the per-bounty\n\t// image fallback. _lastBountyRefresh tracks ClickController.BountyRefreshSeq so a\n\t// hello/reconnect or a bounty_update push re-fetches both current and previous.\n\tPreviousBounty _prevBounty;\n\tSkinInspect.Skin _prevSkin;\n\tstring _prevSkinUrl = \"\";\n\tint _lastBountyRefresh;\n\n\t// Image for every skin panel: a locally-bundled placeholder. Real CS2/Valve\n\t// skins are no longer surfaced and the image is no longer fetched from the\n\t// server \u2014 it ships with the client (see .sbproj Resources).\n\tconst string TempgunImg = \"media/tempgun.png\";\n\tstring SkinImgUrl() => TempgunImg;\n\n\t// Wear-bar marker position as a 0..100 percentage at the float value, clamped.\n\tint MarkerPct()\n\t{\n\t\tint pct = (int)((_skin?.Float ?? 0f) * 100f + 0.5f);\n\t\treturn pct < 0 ? 0 : pct > 100 ? 100 : pct;\n\t}\n\n\t// Float formatted to 10 dp without culture-sensitive APIs (the sandbox doesn't\n\t// whitelist System.Globalization): integer part + zero-padded fractional digits.\n\tstring FloatStr()\n\t{\n\t\tdouble d = _skin?.Float ?? 0.0;\n\t\tif ( d < 0 ) d = 0;\n\t\tconst long Scale = 10_000_000_000L; // 10 fractional digits, like the float checker\n\t\tlong scaled = (long)(d * Scale + 0.5);\n\t\treturn $\"{scaled / Scale}.{(scaled % Scale).ToString().PadLeft( 10, '0' )}\";\n\t}\n\n\t// \u2500\u2500 previous-winner panel helpers (mirror the current-skin ones, reading the\n\t// previous bounty's decoded skin/image instead) \u2500\u2500\n\tstring PrevSkinImgUrl() => TempgunImg;\n\n\tint PrevMarkerPct()\n\t{\n\t\tint pct = (int)((_prevSkin?.Float ?? 0f) * 100f + 0.5f);\n\t\treturn pct < 0 ? 0 : pct > 100 ? 100 : pct;\n\t}\n\n\tstring PrevFloatStr()\n\t{\n\t\tdouble d = _prevSkin?.Float ?? 0.0;\n\t\tif ( d < 0 ) d = 0;\n\t\tconst long Scale = 10_000_000_000L;\n\t\tlong scaled = (long)(d * Scale + 0.5);\n\t\treturn $\"{scaled / Scale}.{(scaled % Scale).ToString().PadLeft( 10, '0' )}\";\n\t}\n\n\t// The previous winner's display name, tagged \"(you)\" when the local player won\n\t// it (matched by the public tag, the only id the client knows itself by).\n\tstring PrevWinnerName()\n\t{\n\t\tif ( _prevBounty == null ) return \"\";\n\t\tvar name = string.IsNullOrEmpty( _prevBounty.WinnerName ) ? \"\u2014\" : _prevBounty.WinnerName;\n\t\tvar myTag = ClickController.Instance?.Tag;\n\t\tif ( !string.IsNullOrEmpty( myTag ) && _prevBounty.WinnerTag == myTag ) name += \" (you)\";\n\t\treturn name;\n\t}\n\n\t// \u2500\u2500 anticheat test gate: the answer box (captured by @ref so SubmitTest can read\n\t// it) shown in the overlay while the player is benched. \u2500\u2500\n\tTextEntry _testEntry;\n\n\t// Send the typed answer to the server and clear the box. The server replies with a\n\t// `test` cleared frame (correct \u2192 un-benched) or a fresh `test` (wrong); either way\n\t// ClickController updates HasTest/TestPrompt and the overlay re-renders.\n\tvoid SubmitTest()\n\t{\n\t\tvar ans = _testEntry?.Text?.Trim();\n\t\tif ( string.IsNullOrEmpty( ans ) ) return;\n\t\tClickController.Instance?.SubmitTestAnswer( ans );\n\t\tif ( _testEntry != null ) _testEntry.Text = \"\";\n\t}\n\n\t// \u2500\u2500 \"How to Play\" popup: open state mirrors PlayerData.HowToPlayOpen (persisted to\n\t// local storage), seeded once from it here so the player's choice survives across\n\t// sessions. First-time players default to open (PlayerData default). \u2500\u2500\n\tbool _howtoOpen = PlayerData.Load().HowToPlayOpen;\n\n\t// True once WE auto-parked the player because a panel (the how-to popup or the music\n\t// board) is open \u2014 reading the rules or browsing music means they're not playing, so we\n\t// step them out of the round + the AFK pass instead of letting a still cursor flag them.\n\t// Latched only when our own park took, so a server afk-park or a manual PAUSE that\n\t// happens to coincide with a panel is never auto-resumed when the panel closes. Driven\n\t// from OnUpdate (PanelParkSync).\n\tbool _panelParked;\n\n\t// Toggle the popup and persist the new state to local storage so it sticks.\n\tvoid ToggleHowTo()\n\t{\n\t\t_howtoOpen = !_howtoOpen;\n\t\tvar data = PlayerData.Load();\n\t\tdata.HowToPlayOpen = _howtoOpen;\n\t\tdata.Save();\n\t\tStateHasChanged();\n\t}\n\n\t// Keep the parked state in sync with the open panels (how-to popup / music board). Open\n\t// panel \u21d2 park; once they're all closed \u21d2 rejoin \u2014 but only if WE were the ones who\n\t// parked for a panel (_panelParked). A server afk-park or a manual PAUSE that overlaps a\n\t// panel is left alone (we never latched it), so closing the panel doesn't resume them.\n\t// Park is a no-op without a live socket (e.g. the first-launch popup before connect), so\n\t// we only latch once the park actually took, and retry every frame until it does.\n\tvoid PanelParkSync()\n\t{\n\t\tif ( C == null ) return;\n\t\tbool panelOpen = _howtoOpen || MusicOpen;\n\t\tif ( panelOpen )\n\t\t{\n\t\t\t// Request a park once. Don't re-issue while one is already parked OR pending: a\n\t\t\t// PAUSE pressed mid-armed is deferred to the next arming boundary (Parked stays\n\t\t\t// false, ParkPending true until then), and re-calling TogglePark would read that\n\t\t\t// as a RESUME and toggle it straight back off.\n\t\t\tif ( !C.Parked && !C.ParkPending )\n\t\t\t{\n\t\t\t\tC.TogglePark();\n\t\t\t\tif ( C.Parked || C.ParkPending ) _panelParked = true; // our park took (now or deferred)\n\t\t\t\tStateHasChanged();\n\t\t\t}\n\t\t}\n\t\telse if ( _panelParked )\n\t\t{\n\t\t\t// Panels closed: rejoin if we're parked or a park is still in flight.\n\t\t\tif ( C.Parked || C.ParkPending ) C.TogglePark();\n\t\t\t_panelParked = false;\n\t\t\tStateHasChanged();\n\t\t}\n\t}\n\n\t// Toggle the away/parked state from the PAUSE bar / parked-overlay RESUME. Delegates to\n\t// the controller (which parks immediately, defers a mid-armed park to the next arming\n\t// boundary, or requests a deferred rejoin) and re-renders so the bar + overlay update.\n\tvoid TogglePause()\n\t{\n\t\tClickController.Instance?.TogglePark();\n\t\tStateHasChanged();\n\t}\n\n\t// PAUSE-bar label/class. \"PAUSING\u2026\" (amber) while a mid-armed park waits for the next\n\t// arming boundary; \"PAUSED\" (red) once parked; \"PAUSE\" (green) at rest.\n\tstring PauseFabLabel()\n\t{\n\t\tvar c = C;\n\t\tif ( c == null ) return \"PAUSE\";\n\t\tif ( c.ParkPending ) return \"PAUSING\u2026\";\n\t\treturn c.Parked ? \"PAUSED\" : \"PAUSE\";\n\t}\n\n\tstring PauseFabClass()\n\t{\n\t\tvar c = C;\n\t\tif ( c == null ) return \"\";\n\t\tif ( c.ParkPending ) return \"pending\";\n\t\treturn c.Parked ? \"paused\" : \"\";\n\t}\n\n\t// \u2500\u2500 \"copied profile link\" toast: shown for ToastDuration after a name click \u2500\u2500\n\tconst float ToastDuration = 1.2f;\n\tstring _toast = \"\";\n\tRealTimeSince _toastSince = 1000f; // start expired\n\tbool ToastVisible => _toastSince < ToastDuration;\n\n\t// \u2500\u2500 per-round scores flash: a transient panel of the round's scorers (the\n\t// round_result `winners`, carrying the points each grabbed THAT round), shown\n\t// for FlashDuration when a round closes. Triggered on the phase change into\n\t// Result (the final round folds into game_over, whose GAME OVER standings stand\n\t// in for its flash). A snapshot is taken so it survives the next round overwriting\n\t// C.Winners. _flashSince starts expired so nothing shows until the first round. \u2500\u2500\n\tconst float FlashDuration = 4f;\n\tList<Standing> _flashWinners = new();\n\tint _flashRound;\n\tRealTimeSince _flashSince = 1000f;\n\tbool FlashVisible => _flashSince < FlashDuration;\n\n\t// \u2500\u2500 post-game board refresh: the server credits the session win and refreshes\n\t// the board cache asynchronously (afterGame) AFTER it broadcasts game_over, so\n\t// a fetch fired the instant game_over lands races ahead of that write and shows\n\t// the games/hours-won boards a game behind (\"lags by 1\"). When a game ends we\n\t// arm a one-shot refresh a short delay later \u2014 past the async write (a sub-second\n\t// local DB write; the 5s intermission easily covers it) \u2014 so the just-finished\n\t// game is reflected while the GAME OVER standings are still up. \u2500\u2500\n\tconst float PostGameRefreshDelay = 2f;\n\tbool _postGamePending;\n\tRealTimeSince _postGameSince = 1000f;\n\n\tprotected override void OnTreeFirstBuilt()\n\t{\n\t\t_ = LoadConfig();\n\t\t_ = LoadPreviousBounty();\n\t\t_ = RefreshHourly();\n\t\t_ = RefreshHoursWon();\n\t\t_ = RefreshSessionsWon();\n\t\t_ = RefreshAllTime();\n\t}\n\n\t// Fetch the winner-lock time + skin image URL (the active bounty). Re-run on\n\t// (re)connect and on a bounty_update push, so the panel always reflects the\n\t// current bounty. On failure the countdown simply doesn't show and the image\n\t// stays blank \u2014 no hard dependency.\n\tasync Task LoadConfig()\n\t{\n\t\tvar cfg = await ApiClient.GetConfig();\n\t\tif ( cfg == null ) return;\n\t\t_skinUrl = ApiClient.AbsoluteUrl( cfg.SkinUrl ) ?? \"\";\n\t\t_winnerLockMs = cfg.WinnerLockMs;\n\t\t_inspectLink = cfg.InspectLink ?? \"\";\n\t\t_hasBounty = cfg.HasBounty;\n\t\t// Clear the previous bounty's decoded skin so a re-fetch (rollover) doesn't\n\t\t// leave the old name/stats showing under the new skin \u2014 ResolveSkin refills it\n\t\t// when this bounty also has an inspect link; an image-only bounty keeps it null.\n\t\t_skin = null;\n\t\tStateHasChanged();\n\n\t\t// If the active bounty was set by an inspect link, decode it locally and\n\t\t// resolve its name/stats (cached dataset) in the background. We use the decoded\n\t\t// name + wear/float/seed, but the displayed image is always the local tempgun\n\t\t// placeholder (SkinImgUrl), never the Valve weapon image.\n\t\tif ( !string.IsNullOrWhiteSpace( cfg.InspectLink ) )\n\t\t\t_ = ResolveSkin( cfg.InspectLink );\n\t}\n\n\t// Decode the bounty's inspect link + resolve its name/stats, then re-render. On\n\t// any failure _skin is left with DecodeOk/ImageOk false so the panel shows the\n\t// \"unable to fetch bounty from steam\" note; the image is the tempgun placeholder\n\t// either way.\n\tasync Task ResolveSkin( string link )\n\t{\n\t\t_skin = await SkinInspect.Resolve( link );\n\t\tStateHasChanged();\n\t}\n\n\t// Fetch the most recent settled bounty (the \"previous winner\") and, if it was an\n\t// inspect-link skin, decode its name/stats for the render (the image stays the\n\t// tempgun placeholder). Re-run alongside LoadConfig so a rollover surfaces the\n\t// just-settled winner immediately. The resolve is guarded by id so a slow decode\n\t// can't clobber a newer previous bounty.\n\tasync Task LoadPreviousBounty()\n\t{\n\t\tvar list = await ApiClient.GetPreviousBounties();\n\t\tvar b = ( list != null && list.Count > 0 ) ? list[0] : null;\n\t\t_prevBounty = b;\n\t\t_prevSkin = null;\n\t\t_prevSkinUrl = b != null ? ( ApiClient.AbsoluteUrl( b.SkinUrl ) ?? \"\" ) : \"\";\n\t\tStateHasChanged();\n\n\t\tif ( b != null && !string.IsNullOrWhiteSpace( b.InspectLink ) )\n\t\t{\n\t\t\tvar id = b.Id;\n\t\t\tvar skin = await SkinInspect.Resolve( b.InspectLink );\n\t\t\tif ( _prevBounty != null && _prevBounty.Id == id ) // still the same previous bounty\n\t\t\t{\n\t\t\t\t_prevSkin = skin;\n\t\t\t\tStateHasChanged();\n\t\t\t}\n\t\t}\n\t}\n\n\tprotected override void OnUpdate()\n\t{\n\t\t// The bounty rolled over (or we just (re)connected): re-fetch the active skin\n\t\t// and the previous winner so neither goes stale. Driven by the server push /\n\t\t// hello via ClickController.BountyRefreshSeq \u2014 no polling.\n\t\tint seq = C?.BountyRefreshSeq ?? 0;\n\t\tif ( seq != _lastBountyRefresh )\n\t\t{\n\t\t\t_lastBountyRefresh = seq;\n\t\t\t_ = LoadConfig();\n\t\t\t_ = LoadPreviousBounty();\n\t\t}\n\n\t\t// While a panel is open (the how-to popup \u2014 which pops up on first launch \u2014 or the\n\t\t// music board), the player is reading/browsing, not playing: their cursor sits still\n\t\t// and the server would flag them AFK. Auto-park them (drops them out of the round's N\n\t\t// + the AFK pass) and rejoin them when the panels close. See _panelParked.\n\t\tPanelParkSync();\n\n\t\tvar phase = C?.Phase ?? GamePhase.Connecting;\n\n\t\t// Report our pointer to the server (throttled in SendCursor) so other players see our\n\t\t// roaming cursor AND so the server's AFK pass can tell a present player from an away\n\t\t// one. As of v7 this runs during BOTH the arming wait (Pending) and the live window\n\t\t// (Armed) \u2014 the server now watches arming-phase movement, not just armed. The .center\n\t\t// box (_box) is laid out throughout (it's a fixed absolutely-positioned container that\n\t\t// exists in every phase, with or without buttons), so its rect is valid during Pending\n\t\t// too. Mouse.Position and Box.Rect share the same screen-pixel space, so the pointer\n\t\t// normalizes to the box exactly like the buttons/pips (\u22121..1, 0 = centre). Re-render is\n\t\t// driven by BoardHash in BuildHash (board claims + cursor moves), so no per-frame\n\t\t// StateHasChanged needed here.\n\t\tif ( ( phase == GamePhase.Armed || phase == GamePhase.Pending ) && C != null )\n\t\t{\n\t\t\tvar r = _box?.Box.Rect ?? default;\n\t\t\tif ( r.Width > 0f && r.Height > 0f )\n\t\t\t{\n\t\t\t\tvar m = Sandbox.Mouse.Position;\n\t\t\t\tfloat nx = ( m.x - r.Center.x ) / ( r.Width / 2f );\n\t\t\t\tfloat ny = ( m.y - r.Center.y ) / ( r.Height / 2f );\n\t\t\t\t// Only report the cursor while it's actually ON the board (within -1..1).\n\t\t\t\t// A pointer resting OUTSIDE the board (over the side leaderboards) used to\n\t\t\t\t// clamp to the +-1 edge, and tiny hand jitter oscillating across that edge\n\t\t\t\t// made a physically-still cursor look like it moved a lot, defeating the\n\t\t\t\t// afk check. Off-board samples are simply not sent: a player parked off the\n\t\t\t\t// board reports no cursor (the server reads that as afk by the no-cursor\n\t\t\t\t// half), and on-board movement is measured cleanly with no edge artefact.\n\t\t\t\tif ( nx >= -1f && nx <= 1f && ny >= -1f && ny <= 1f )\n\t\t\t\t\tC.SendCursor( nx, ny );\n\n\t\t\t\t// Armed-only: fire a one-shot `touch` the instant the pointer enters a live\n\t\t\t\t// button's hitbox (ClickController de-dupes per window). The button is a 96px\n\t\t\t\t// CSS SQUARE; the touch hitbox is an axis-aligned box of half-extent\n\t\t\t\t// (BoardBtnRefHalf + TouchPadRef), deliberately a bit BIGGER than the visual/\n\t\t\t\t// click box so an edge click (the 4px border ring, or a frame the sampler just\n\t\t\t\t// missed) always counts as touched \u2014 no_hover only targets egregious \"cursor\n\t\t\t\t// nowhere near\" cases. ScreenPanel scales uniformly, so the half-extent is the\n\t\t\t\t// same on both axes in actual px. .center spans top:24/bottom:24 of the 1080\n\t\t\t\t// ref, so its reference height is CenterRefH; scale = actual box height \u00f7 that.\n\t\t\t\tif ( phase == GamePhase.Armed )\n\t\t\t\t{\n\t\t\t\t\tfloat half = ( BoardBtnRefHalf + TouchPadRef ) * ( r.Height / CenterRefH );\n\t\t\t\t\tforeach ( var btn in C.LiveButtons )\n\t\t\t\t\t{\n\t\t\t\t\t\tfloat cx = r.Center.x + btn.X * ( r.Width / 2f );\n\t\t\t\t\t\tfloat cy = r.Center.y + btn.Y * ( r.Height / 2f );\n\t\t\t\t\t\tif ( MathF.Abs( m.x - cx ) <= half && MathF.Abs( m.y - cy ) <= half )\n\t\t\t\t\t\t\tC.SendTouch( btn.Slot );\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tif ( phase != _lastPhase )\n\t\t{\n\t\t\t// A round just closed: flash its scores. Snapshot the winners (the next\n\t\t\t// round overwrites C.Winners) and restart the flash timer. Only on Result \u2014\n\t\t\t// the final round folds into GAME OVER, whose standings are its own display.\n\t\t\tif ( phase == GamePhase.Result )\n\t\t\t{\n\t\t\t\t_flashWinners = C?.Winners != null ? new List<Standing>( C.Winners ) : new();\n\t\t\t\t_flashRound = C?.Round ?? 0;\n\t\t\t\t_flashSince = 0f;\n\t\t\t}\n\n\t\t\t// Refresh the boards when a round just closed (player is reading scores\n\t\t\t// between presses), throttled so rapid result\u2192pending churn can't spam.\n\t\t\t// GAME OVER is deliberately excluded: a fetch here races the server's async\n\t\t\t// post-game write (see below), so the games/hours-won boards would show a\n\t\t\t// game behind. Game-end refresh is handled by the delayed one-shot instead.\n\t\t\tif ( phase == GamePhase.Result )\n\t\t\t{\n\t\t\t\tif ( _hourlyLast > RefreshInterval ) _ = RefreshHourly();\n\t\t\t\tif ( _hoursWonLast > RefreshInterval ) _ = RefreshHoursWon();\n\t\t\t\tif ( _sessionsWonLast > RefreshInterval ) _ = RefreshSessionsWon();\n\t\t\t\tif ( _allTimeLast > RefreshInterval ) _ = RefreshAllTime();\n\t\t\t}\n\n\t\t\t// A game just ended: arm the one-shot post-game refresh so the boards pick\n\t\t\t// up this game's session win once the server's afterGame write has landed.\n\t\t\tif ( phase == GamePhase.GameOver )\n\t\t\t{\n\t\t\t\t_postGamePending = true;\n\t\t\t\t_postGameSince = 0f;\n\t\t\t}\n\n\t\t\t_lastPhase = phase;\n\t\t\tStateHasChanged();\n\t\t}\n\n\t\t// Fire the armed post-game refresh once the delay has elapsed (the server's\n\t\t// async session-win write is done by now), so the games/hours-won boards reflect\n\t\t// the just-finished game instead of lagging a game behind.\n\t\tif ( _postGamePending && _postGameSince >= PostGameRefreshDelay )\n\t\t{\n\t\t\t_postGamePending = false;\n\t\t\t_ = RefreshHourly();\n\t\t\t_ = RefreshHoursWon();\n\t\t\t_ = RefreshSessionsWon();\n\t\t\t_ = RefreshAllTime();\n\t\t}\n\t}\n\n\tasync Task RefreshHourly()\n\t{\n\t\tif ( _hourlyFetching ) return;\n\t\t_hourlyFetching = true;\n\t\t_hourlyLast = 0;\n\t\ttry\n\t\t{\n\t\t\tvar board = await ApiClient.GetHourlyLeaderboard( DisplayLimit );\n\t\t\t_hourly = board ?? new List<Standing>();\n\t\t\t_hourlyLoaded = true;\n\t\t\tStateHasChanged();\n\t\t}\n\t\tfinally { _hourlyFetching = false; }\n\t}\n\n\tasync Task RefreshHoursWon()\n\t{\n\t\tif ( _hoursWonFetching ) return;\n\t\t_hoursWonFetching = true;\n\t\t_hoursWonLast = 0;\n\t\ttry\n\t\t{\n\t\t\tvar board = await ApiClient.GetHoursWonLeaderboard( DisplayLimit );\n\t\t\t_hoursWon = board ?? new List<Standing>();\n\t\t\t_hoursWonLoaded = true;\n\t\t\tStateHasChanged();\n\t\t}\n\t\tfinally { _hoursWonFetching = false; }\n\t}\n\n\tasync Task RefreshSessionsWon()\n\t{\n\t\tif ( _sessionsWonFetching ) return;\n\t\t_sessionsWonFetching = true;\n\t\t_sessionsWonLast = 0;\n\t\ttry\n\t\t{\n\t\t\tvar board = await ApiClient.GetSessionsWonLeaderboard( DisplayLimit );\n\t\t\t_sessionsWon = board ?? new List<Standing>();\n\t\t\t_sessionsWonLoaded = true;\n\t\t\tStateHasChanged();\n\t\t}\n\t\tfinally { _sessionsWonFetching = false; }\n\t}\n\n\tasync Task RefreshAllTime()\n\t{\n\t\tif ( _allTimeFetching ) return;\n\t\t_allTimeFetching = true;\n\t\t_allTimeLast = 0;\n\t\ttry\n\t\t{\n\t\t\tvar board = await ApiClient.GetAllTimeClickersLeaderboard( DisplayLimit );\n\t\t\t_allTime = board ?? new List<Standing>();\n\t\t\t_allTimeLoaded = true;\n\t\t\tStateHasChanged();\n\t\t}\n\t\tfinally { _allTimeFetching = false; }\n\t}\n\n\t// Copy the clicked player's public Steam community profile link to the\n\t// clipboard and flash a short confirmation toast. SteamID64 is public, so the\n\t// link works for anyone; no-op if the row carries no id.\n\tvoid CopyProfile( Standing s )\n\t{\n\t\tif ( s == null || string.IsNullOrEmpty( s.SteamId ) ) return;\n\t\tvar url = $\"https://steamcommunity.com/profiles/{s.SteamId}\";\n\t\ttry { Clipboard.SetText( url ); }\n\t\tcatch ( Exception e ) { Log.Warning( $\"[Splitclicker] clipboard copy failed: {e.Message}\" ); }\n\t\t_toast = $\"Copied {DisplayName( s )}'s profile link\";\n\t\t_toastSince = 0f;\n\t\tStateHasChanged();\n\t}\n\n\t// Copy the previous bounty winner's Steam profile link (same as CopyProfile, but\n\t// the winner is carried on the bounty record, not a Standing). No-op when the\n\t// settled bounty had no winner (empty window \u2192 no steamid).\n\tvoid CopyPrevWinner()\n\t{\n\t\tvar sid = _prevBounty?.WinnerSteamId;\n\t\tif ( string.IsNullOrEmpty( sid ) ) return;\n\t\tvar url = $\"https://steamcommunity.com/profiles/{sid}\";\n\t\ttry { Clipboard.SetText( url ); }\n\t\tcatch ( Exception e ) { Log.Warning( $\"[Splitclicker] clipboard copy failed: {e.Message}\" ); }\n\t\tvar who = string.IsNullOrEmpty( _prevBounty.WinnerName ) ? \"winner\" : _prevBounty.WinnerName;\n\t\t_toast = $\"Copied {who}'s profile link\";\n\t\t_toastSince = 0f;\n\t\tStateHasChanged();\n\t}\n\n\t// Discord invite (shared with the rotaliate family). No in-game URL-open API, so\n\t// the how-to popup's blurb copies it to the clipboard instead. DiscordInvite is the\n\t// short display form; DiscordUrl is what lands on the clipboard.\n\tconst string DiscordInvite = \"discord.gg/GG8HWUfFpD\";\n\tconst string DiscordUrl = \"https://discord.gg/GG8HWUfFpD\";\n\n\t// Copy the Discord invite and flash the confirmation toast (visible above the\n\t// how-to popup \u2014 see the toast's z-index).\n\tvoid CopyDiscord()\n\t{\n\t\ttry { Clipboard.SetText( DiscordUrl ); }\n\t\tcatch ( Exception e ) { Log.Warning( $\"[Splitclicker] clipboard copy failed: {e.Message}\" ); }\n\t\t_toast = \"Copied the Discord invite \u2014 see you there!\";\n\t\t_toastSince = 0f;\n\t\tStateHasChanged();\n\t}\n\n\t// Copy the active bounty's CS2 inspect link to the clipboard so the player can\n\t// paste it into the game and inspect the exact skin. No-op when there's no link.\n\tvoid CopyInspect()\n\t{\n\t\tif ( string.IsNullOrWhiteSpace( _inspectLink ) ) return;\n\t\ttry { Clipboard.SetText( _inspectLink ); }\n\t\tcatch ( Exception e ) { Log.Warning( $\"[Splitclicker] clipboard copy failed: {e.Message}\" ); }\n\t\t_toast = \"Copied the bounty's inspect link\";\n\t\t_toastSince = 0f;\n\t\tStateHasChanged();\n\t}\n\n\tstatic bool IsMe( Standing s )\n\t{\n\t\tvar tag = ClickController.Instance?.Tag;\n\t\treturn !string.IsNullOrEmpty( tag ) && s.Tag == tag;\n\t}\n\n\t// Anticheat status dot class for a board row: green when live (the default),\n\t// yellow on a cooldown, red when ignored for the bounty. Empty/unknown \u21d2 live.\n\tstatic string StatusClass( Standing s ) => s?.Status switch\n\t{\n\t\t\"cooldown\" => \"cooldown\",\n\t\t\"ignored\" => \"ignored\",\n\t\t_ => \"live\",\n\t};\n\n\t// Show the player's name (claimed username or Steam display name, resolved\n\t// server-side), never the opaque hex tag.\n\tstatic string DisplayName( Standing s ) =>\n\t\t!string.IsNullOrWhiteSpace( s.Username ) ? s.Username : \"anonymous\";\n\n\t// The \"current leader\" for the skin caption: the sessions-won #1, falling back\n\t// to the live session leader, then to a placeholder when nobody has scored.\n\tstring SkinLeaderName()\n\t{\n\t\tif ( _sessionsWon.Count > 0 ) return DisplayName( _sessionsWon[0] );\n\t\tvar session = SessionEntries;\n\t\tif ( session.Count > 0 ) return DisplayName( session[0] );\n\t\treturn \"Nobody\";\n\t}\n\n\t// Whether a winner-lock time is configured at all (0 = unset \u2192 hide countdown).\n\tbool HasWinnerLock => _winnerLockMs > 0;\n\n\tstatic long NowMs() => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();\n\n\t// True once the winner-lock instant has passed (config loaded and now \u2265 it).\n\tbool WinnerLockPassed() => HasWinnerLock && NowMs() >= _winnerLockMs;\n\n\t// Time left until the winner locks in, as HH:MM:SS (clamped at zero). Pure\n\t// integer math on epoch-ms \u2014 no DateTime parsing (sandbox-safe).\n\tstring WinnerCountdown()\n\t{\n\t\tlong left = _winnerLockMs - NowMs();\n\t\tif ( left < 0 ) left = 0;\n\t\tlong s = left / 1000;\n\t\treturn $\"{s / 3600:D2}:{(s % 3600) / 60:D2}:{s % 60:D2}\";\n\t}\n\n\t// Time left on the active anticheat sanction (cooldown end, or the bounty resolve\n\t// time for \"ignored\"), as HH:MM:SS clamped at zero \u2014 same integer epoch-ms math\n\t// as the winner countdown (sandbox-safe).\n\tstring SanctionCountdown()\n\t{\n\t\tlong left = ( C?.SanctionUntilMs ?? 0 ) - NowMs();\n\t\tif ( left < 0 ) left = 0;\n\t\tlong s = left / 1000;\n\t\treturn $\"{s / 3600:D2}:{(s % 3600) / 60:D2}:{s % 60:D2}\";\n\t}\n\n\t// A click on a board button, addressed by its slot id (NOT a captured button\n\t// object): resolve the button currently live at that slot and score it via its nonce.\n\t// Slots are never reused within a round, so a stale render-closure firing an old slot\n\t// simply resolves to null and no-ops here \u2014 it can never send a consumed nonce at a\n\t// button that's already been claimed/replaced. No teleport; the board is server-placed.\n\tvoid OnPressSlot( ushort slot )\n\t{\n\t\tvar btn = ClickController.Instance?.FindButton( slot );\n\t\tif ( btn == null )\n\t\t\treturn; // already claimed/replaced (or a stale closure) \u2014 clean miss, nothing sent\n\n\t\tClickController.Instance?.SendButtonClick( btn );\n\t\tStateHasChanged();\n\t}\n\n\t// \u2500\u2500 opponent pips (read from ClickController, rendered + faded by CSS) \u2500\u2500\n\tstatic readonly List<ClickController.PipButton> NoPips = new();\n\tIReadOnlyList<ClickController.PipButton> PipsToShow() => ClickController.Instance?.ActivePips ?? NoPips;\n\n\t// \u2500\u2500 opponent cursors (read from ClickController, rendered as labelled dots) \u2500\u2500\n\tstatic readonly List<ClickController.CursorDot> NoCursors = new();\n\tIReadOnlyList<ClickController.CursorDot> CursorsToShow() => ClickController.Instance?.Cursors ?? NoCursors;\n\n\t// A cheap hash of the live board + opponent cursors so BuildHash re-renders when a\n\t// button is claimed/spawned or a cursor moves (positions quantized to ~box-% steps).\n\tint BoardHash()\n\t{\n\t\tvar c = C;\n\t\tif ( c == null ) return 0;\n\t\tvar h = new HashCode();\n\t\th.Add( c.LiveButtons.Count );\n\t\tforeach ( var b in c.LiveButtons ) h.Add( b.Slot );\n\t\th.Add( c.Cursors.Count );\n\t\tforeach ( var cur in c.Cursors )\n\t\t{\n\t\t\th.Add( (int)(cur.X * 200f) );\n\t\t\th.Add( (int)(cur.Y * 200f) );\n\t\t}\n\t\treturn h.ToHashCode();\n\t}\n\n\t// Normalized \u22121..1 position \u2192 box left/top percentage (0..100), clamped so an\n\t// off-screen pip pins to the box edge rather than overflowing. Integer math keeps\n\t// the inline style locale-safe (no culture-sensitive float formatting).\n\tstatic int PipPct( float n ) => (int)Math.Clamp( ( n + 1f ) / 2f * 100f, 0f, 100f );\n\n\t// The live clicks-remaining counter, floored at 0 (a late tick can't show negative).\n\tstatic int RemainingShown( ClickController c ) => Math.Max( 0, c.RemainingThisRound );\n\n\t// \u2500\u2500 music panel toggle \u2500\u2500\n\t// The settings board is the Skafinity library's own SkafinityMusicPanel (the green\n\t// drop-in), mounted on its OWN ScreenPanel GameObject \u2014 a PanelComponent can't be a\n\t// sibling child of this HUD's single root (see CLAUDE.md ScreenPanel note). It owns its\n\t// own opener (the wide \"MUSIC\" bar \u2014 ShowFab/WideFab/FabLabel in the scene) and its own\n\t// close, so the HUD never toggles it; it only READS IsOpen to stop painting the game\n\t// while the board is up. The optional inspector ref is just a fast-path; we fall back to\n\t// a scene lookup so a blank field can't leave the HUD blind to the board's state.\n\t[Property] public SkafinityMusicPanel MusicBoard { get; set; }\n\n\tSkafinityMusicPanel Board => MusicBoard ??= Scene?.GetAllComponents<SkafinityMusicPanel>().FirstOrDefault();\n\n\t// \"Open\" = the panel's board is showing. MusicOpen feeds BuildHash, so the panel's own\n\t// open/close (which flips IsOpen) re-renders the HUD and shows/hides the game beneath it.\n\tbool MusicOpen => Board?.IsOpen ?? false;\n\n\tstring PhaseText() => C?.Phase switch\n\t{\n\t\tGamePhase.Armed => \"CLICK!\",\n\t\tGamePhase.Pending => \"WAIT\u2026\",\n\t\tGamePhase.Waiting => \"STAND BY\u2026\",\n\t\tGamePhase.Result => \"ROUND OVER\",\n\t\tGamePhase.GameOver => \"GAME OVER\",\n\t\tGamePhase.Disconnected => \"RECONNECTING\u2026\",\n\t\tGamePhase.Connecting => \"CONNECTING\u2026\",\n\t\t_ => \"\",\n\t};\n\n\tstring PhaseClass() => C?.Phase switch\n\t{\n\t\tGamePhase.Armed => \"armed\",\n\t\tGamePhase.Disconnected => \"offline\",\n\t\t_ => \"\",\n\t};\n\n\t// Include the seconds-remaining so the countdown re-renders as it ticks, and\n\t// ToastVisible so the copied-link toast appears and clears itself on time.\n\tprotected override int BuildHash() => HashCode.Combine(\n\t\tHashCode.Combine( C?.Phase ?? GamePhase.Connecting, C?.Round ?? 0, C?.Of ?? 0,\n\t\t\tC?.Players ?? 0, C?.ClicksToWin ?? 0, C?.ClicksSent ?? 0 ),\n\t\tHashCode.Combine( SessionEntries.Count, _hourly.Count, _hourlyLoaded, _hoursWon.Count, _hoursWonLoaded,\n\t\t\tC?.ArmMaxSec ?? 0, C?.Tag, (int)(DateTime.UtcNow - DateTime.UtcNow.Date).TotalSeconds ),\n\t\tHashCode.Combine( _sessionsWon.Count, _sessionsWonLoaded, ToastVisible, SkinLeaderName(), MusicOpen,\n\t\t\t_skinUrl, _winnerLockMs, HashCode.Combine( WinnerLockPassed(), _hasBounty ) ),\n\t\tHashCode.Combine( _allTime.Count, _allTimeLoaded,\n\t\t\tC?.HasTest ?? false, C?.TestPrompt ),\n\t\t_howtoOpen,\n\t\t// sanction overlay state (the per-second countdown tick is already covered by\n\t\t// the seconds term in the second combine above); plus the parked/away state \u2014 and\n\t\t// the deferred park-pending / resuming flags \u2014 so the PAUSE bar + parked overlay\n\t\t// repaint the instant any of them flips.\n\t\tHashCode.Combine( C?.TestMessage, C?.SanctionState, C?.SanctionUntilMs,\n\t\t\tC?.Parked ?? false, C?.ParkPending ?? false, C?.Resuming ?? false ),\n\t\t// round-scores flash: FlashVisible flips the panel off on time; round + count\n\t\t// rebuild it when a new round's scores come up.\n\t\tHashCode.Combine( FlashVisible, _flashRound, _flashWinners.Count ),\n\t\t// live-window tick: the descending counter (changes ~tick-rate while armed) and\n\t\t// the on-screen pip count (a pip spawning/expiring changes it) drive re-render;\n\t\t// plus the previous-winner panel state (which bounty, and whether its skin has\n\t\t// resolved) so it repaints when a rollover/refresh loads a new one.\n\t\tHashCode.Combine( C?.RemainingThisRound ?? 0, PipsToShow().Count,\n\t\t\t_prevBounty?.Id ?? 0L, _prevSkin != null, _prevSkinUrl, BoardHash() ) );\n}\n"
},
{
"Ident": "gamah.splitclicker",
"Path": "Api/Models.cs",
"FileName": "Models.cs",
"PackageType": "game",
"CodeKind": "Game",
"AssetVersionId": 301560,
"Code": "using System.Collections.Generic;\nusing System.Text.Json.Serialization;\n\nnamespace Splitclicker.Api;\n\n// POST /api/v1/auth response: the public tag/username plus a single-use WS ticket\n// (mint immediately before connecting; expires after ttl_ms).\n// Username is the *claimed* handle only (empty when the player never set one) \u2014\n// persist/re-send only this, never DisplayName, or the Steam name gets bounced\n// back as a username and 422s on reconnect. DisplayName is the resolved string\n// to show (claimed handle, else Steam name, else hex tag).\npublic record AuthResponse(\n\t[property: JsonPropertyName( \"tag\" )] string Tag,\n\t[property: JsonPropertyName( \"username\" )] string Username,\n\t[property: JsonPropertyName( \"display_name\" )] string DisplayName,\n\t[property: JsonPropertyName( \"ticket\" )] string Ticket,\n\t[property: JsonPropertyName( \"ttl_ms\" )] long TtlMs\n);\n\n// GET /api/v1/config response: server-driven bits the client needs at startup.\n// WinnerLockMs is the countdown target as Unix epoch milliseconds (0 = unset) \u2014\n// a plain number so no date parsing is needed (the s&box sandbox doesn't\n// whitelist System.Globalization). SkinUrl is the server path the current\n// \"skin to win\" image is served from (made absolute against BaseUrl).\n// InspectLink is the active bounty's CS2 inspect link, or \"\" when the skin is an\n// uploaded image only. When set, the client decodes it locally and renders the\n// live float / seed / name / wear bar, falling back to SkinUrl's image on failure.\n// HasBounty is false when no bounty is active \u2014 the WinnerLockMs/SkinUrl are then the\n// host config.json/env fallback (a stale \"old\" skin) and the bounty-scoped boards have\n// fallen back to all-time, so the client shows a \"no active bounty\" state instead.\npublic record ConfigResponse(\n\t[property: JsonPropertyName( \"winner_lock_ms\" )] long WinnerLockMs,\n\t[property: JsonPropertyName( \"skin_url\" )] string SkinUrl,\n\t[property: JsonPropertyName( \"inspect_link\" )] string InspectLink,\n\t[property: JsonPropertyName( \"has_bounty\" )] bool HasBounty\n);\n\n// GET /api/v1/bounties/previous response row: a settled bounty + its winner, for\n// the \"previous winner\" panel. SkinUrl is the per-bounty image fallback (made\n// absolute against BaseUrl); InspectLink, when set, is decoded locally for the\n// live skin render exactly like the current bounty. WinnerTag matches the client's\n// own tag when the local player won (so the panel can say \"you\"); WinnerSteamId\n// opens the profile; WonAtMs is epoch ms (no date parsing in the sandbox).\npublic record PreviousBounty(\n\t[property: JsonPropertyName( \"id\" )] long Id,\n\t[property: JsonPropertyName( \"label\" )] string Label,\n\t[property: JsonPropertyName( \"skin_url\" )] string SkinUrl,\n\t[property: JsonPropertyName( \"inspect_link\" )] string InspectLink,\n\t[property: JsonPropertyName( \"winner_tag\" )] string WinnerTag,\n\t[property: JsonPropertyName( \"winner_steam_id\" )] string WinnerSteamId,\n\t[property: JsonPropertyName( \"winner_name\" )] string WinnerName,\n\t[property: JsonPropertyName( \"winner_wins\" )] int WinnerWins,\n\t[property: JsonPropertyName( \"won_at_ms\" )] long WonAtMs\n);\n\n// One row of a leaderboard (GET /api/v1/leaderboard/*) and of the standings\n// embedded in round_result / game_over. SteamId is the public SteamID64, used to\n// open/copy the player's steamcommunity.com profile.\npublic record Standing(\n\t[property: JsonPropertyName( \"tag\" )] string Tag,\n\t[property: JsonPropertyName( \"username\" )] string Username,\n\t[property: JsonPropertyName( \"points\" )] int Points,\n\t[property: JsonPropertyName( \"steam_id\" )] string SteamId,\n\t// Anticheat status for the active bounty: \"live\" / \"cooldown\" / \"ignored\".\n\t// Drives the coloured status dot on every board row. Empty \u21d2 treated as live.\n\t[property: JsonPropertyName( \"status\" )] string Status,\n\t// BehindMs is how many ms this player's tie-deciding click landed after the\n\t// player ranked just above them with the SAME points \u2014 \"how much they lost the\n\t// game by\". Only the final game_over standings carry it (the end-of-game tie is\n\t// the one that matters); 0/absent \u21d2 top of a tie group or a unique score.\n\t[property: JsonPropertyName( \"behind_ms\" )] int BehindMs\n);\n\n// --- WebSocket server\u2192client frames (the \"t\" field selects the shape) ---\n\npublic record HelloYou(\n\t[property: JsonPropertyName( \"tag\" )] string Tag,\n\t[property: JsonPropertyName( \"username\" )] string Username\n);\n\npublic record HelloGame(\n\t[property: JsonPropertyName( \"round\" )] int Round,\n\t[property: JsonPropertyName( \"of\" )] int Of,\n\t[property: JsonPropertyName( \"phase\" )] string Phase,\n\t[property: JsonPropertyName( \"players\" )] int Players,\n\t[property: JsonPropertyName( \"clicks\" )] int Clicks,\n\t[property: JsonPropertyName( \"arm_min\" )] int ArmMin,\n\t[property: JsonPropertyName( \"arm_max\" )] int ArmMax,\n\t[property: JsonPropertyName( \"dev_note\" )] string DevNote,\n\t// Live-window tick interval in ms (0 = ticking off). Sizes the pip jitter-buffer\n\t// playback delay so opponent pips replay at their true relative moment.\n\t[property: JsonPropertyName( \"tick_ms\" )] int TickMs\n);\n\npublic record HelloMsg(\n\t[property: JsonPropertyName( \"you\" )] HelloYou You,\n\t[property: JsonPropertyName( \"game\" )] HelloGame Game\n);\n\n// roster is the full {tag \u2192 username} map of connected players (v5+ only), sent at\n// the arming stage so opponent pips \u2014 which carry only a 4-byte tag \u2014 can be\n// labelled with a name. Absent on older servers/clients (null).\npublic record RosterEntry(\n\t[property: JsonPropertyName( \"tag\" )] string Tag,\n\t[property: JsonPropertyName( \"username\" )] string Username\n);\n\npublic record PendingMsg(\n\t[property: JsonPropertyName( \"round\" )] int Round,\n\t[property: JsonPropertyName( \"of\" )] int Of,\n\t[property: JsonPropertyName( \"players\" )] int Players,\n\t[property: JsonPropertyName( \"clicks\" )] int Clicks,\n\t[property: JsonPropertyName( \"roster\" )] List<RosterEntry> Roster\n);\n\n// One live button in the v5 armed board: id is the compact slot handle (referenced\n// by tick claim events), nonce the secret a scoring click on it must echo, x/y its\n// server-placed normalized position (int16, 0 = centre). Same shape the tick frame's\n// spawned replacements carry.\npublic record ButtonMsg(\n\t[property: JsonPropertyName( \"id\" )] int Id,\n\t[property: JsonPropertyName( \"nonce\" )] string Nonce,\n\t[property: JsonPropertyName( \"x\" )] int X,\n\t[property: JsonPropertyName( \"y\" )] int Y\n);\n\n// buttons is the initial multi-button board; each button carries a hex nonce (an\n// unguessable 64-bit token) echoed back verbatim in the click frame \u2014 never\n// parse/reformat it. (The old per-connection arm-delay penalty_ms was gutted in v7.)\npublic record ArmedMsg(\n\t[property: JsonPropertyName( \"round\" )] int Round,\n\t[property: JsonPropertyName( \"seq\" )] int Seq,\n\t[property: JsonPropertyName( \"buttons\" )] List<ButtonMsg> Buttons,\n\t[property: JsonPropertyName( \"players\" )] int Players,\n\t[property: JsonPropertyName( \"clicks\" )] int Clicks\n);\n\npublic record YouResult(\n\t[property: JsonPropertyName( \"points_delta\" )] int PointsDelta,\n\t[property: JsonPropertyName( \"round_id\" )] string RoundId\n);\n\npublic record RoundResultMsg(\n\t[property: JsonPropertyName( \"round\" )] int Round,\n\t[property: JsonPropertyName( \"of\" )] int Of,\n\t[property: JsonPropertyName( \"winners\" )] List<Standing> Winners,\n\t[property: JsonPropertyName( \"standings\" )] List<Standing> Standings,\n\t[property: JsonPropertyName( \"you\" )] YouResult You\n);\n\n// points_delta/round_id carry the FINAL round's score (the last round folds into\n// game_over with no round_result of its own) so the client can drive its `points`\n// stat once, guarded by round_id like a normal round.\npublic record YouGameOver(\n\t[property: JsonPropertyName( \"placement\" )] int Placement,\n\t[property: JsonPropertyName( \"won\" )] bool Won,\n\t[property: JsonPropertyName( \"game_id\" )] string GameId,\n\t[property: JsonPropertyName( \"points_delta\" )] int PointsDelta,\n\t[property: JsonPropertyName( \"round_id\" )] string RoundId\n);\n\npublic record GameOverMsg(\n\t[property: JsonPropertyName( \"standings\" )] List<Standing> Standings,\n\t[property: JsonPropertyName( \"you\" )] YouGameOver You\n);\n\n// note is the host-editable broadcast message (orange line under the throttle);\n// empty clears it. Pushed once per game and carried in hello for mid-game joiners.\npublic record DevNoteMsg(\n\t[property: JsonPropertyName( \"note\" )] string Note\n);\n\n// An anticheat frame pushed to this client after it failed end-of-round checks.\n// state is the ladder rung:\n// \"test\" \u2014 answer prompt (echoing id) before the server will arm us again.\n// \"cooldown\" \u2014 sidelined until until_ms (a timed cooldown); no test to answer.\n// \"ignored\" \u2014 sidelined until until_ms (the bounty's resolve time); no test.\n// message is the player-facing explanation for every state; until_ms is the epoch\n// ms the cooldown/ignored state ends (the client shows a countdown to it).\n// cleared=true means we're back in play \u2014 dismiss any overlay. (an empty state is\n// treated as \"test\".)\npublic record TestMsg(\n\t[property: JsonPropertyName( \"state\" )] string State,\n\t[property: JsonPropertyName( \"id\" )] string Id,\n\t[property: JsonPropertyName( \"kind\" )] string Kind,\n\t[property: JsonPropertyName( \"prompt\" )] string Prompt,\n\t[property: JsonPropertyName( \"message\" )] string Message,\n\t[property: JsonPropertyName( \"until_ms\" )] long UntilMs,\n\t[property: JsonPropertyName( \"cleared\" )] bool Cleared\n);\n\n// ident is a server-pushed manual achievement unlock for an out-of-band feat\n// (e.g. poking the backend into a 404, fumbling the admin password); it must\n// match an achievement defined in the s&box project.\npublic record AchievementMsg(\n\t[property: JsonPropertyName( \"ident\" )] string Ident\n);\n\n// on=true parks us (auto-park off an afk_idle verdict); the server only ever sends\n// on:true (unpark is the client's call). Drives the parked/away state.\npublic record ParkMsg(\n\t[property: JsonPropertyName( \"on\" )] bool On\n);\n"
},
{
"Ident": "gamah.splitclicker",
"Path": "SkafinityPlayer.cs",
"FileName": "SkafinityPlayer.cs",
"PackageType": "game",
"CodeKind": "Game",
"AssetVersionId": 301560,
"Code": "using System;\nusing System.Threading.Tasks;\nusing Sandbox;\n\nnamespace Skafinity;\n\n/// <summary>\n/// Streams an endless, deterministic procedural ska / reggae-rock track (see\n/// <see cref=\"MusicGen\"/>) through Web Audio-style scheduling over a <see cref=\"SoundStream\"/>.\n///\n/// Drop this <see cref=\"Component\"/> on any GameObject. It generates a ~80s loop from the\n/// seed <c>tag:n</c>, plays it <see cref=\"LoopsPerSong\"/> times, then equal-power crossfades\n/// into the pre-generated next song (<c>tag:n+1</c>), forever. Every generator knob is an\n/// inspector <c>[Property]</c>; with <see cref=\"LiveReload\"/> on, tweaking one regenerates\n/// after a short settle so you can dial in a vibe in play mode.\n///\n/// A whole song is just its seed, so the arrangement is shareable: copy <see cref=\"CurrentSeed\"/>\n/// (<c>vibe:tag:n</c>) and anyone who calls <see cref=\"PlaySeed\"/> with it hears the same track.\n///\n/// This is a self-contained extraction of the Rotaliate music engine with no game-specific\n/// dependencies (no player data, networking, or UI). Persistence of the song index is opt-in\n/// via <see cref=\"PersistProgress\"/>.\n/// </summary>\npublic sealed class SkafinityPlayer : Component\n{\n\t// \u2500\u2500 Master \u2500\u2500\n\t/// <summary>Music master switch. 'new' so it's distinct from <see cref=\"Component.Enabled\"/>.</summary>\n\t[Property, Group( \"Music\" )] public new bool Enabled { get; set; } = true;\n\t[Property, Group( \"Music\" ), Range( 0f, 2f )] public float Volume { get; set; } = 0.7f;\n\t/// <summary>Regenerate automatically a moment after any generator knob changes (editor tuning).</summary>\n\t[Property, Group( \"Music\" )] public bool LiveReload { get; set; } = true;\n\t/// <summary>Optional mixer name to route the music to (e.g. \"Music\"). Empty = default mixer.</summary>\n\t[Property, Group( \"Music\" )] public string MixerName { get; set; } = \"\";\n\t/// <summary>Begin playing automatically in <see cref=\"OnStart\"/>. Off = call <see cref=\"StartSequence\"/> yourself.</summary>\n\t[Property, Group( \"Music\" )] public bool AutoPlay { get; set; } = true;\n\t/// <summary>Shuffle mode: re-randomise every knob (incl. genre + volumes) as each new song\n\t/// begins, so the sequence keeps reinventing itself. Off = the seed's vibe stays put.</summary>\n\t[Property, Group( \"Music\" )] public bool RandomEverySong { get; set; } = false;\n\n\t// \u2500\u2500 Seed \u2500\u2500\n\t/// <summary>Seed tag \u2014 any string (a name, a word). Empty falls back to \"skafinity\".</summary>\n\t[Property, Group( \"Seed\" )] public string Tag { get; set; } = \"\";\n\t/// <summary>Song index in the infinite sequence (0,1,2\u2026). <see cref=\"StepN\"/>/<see cref=\"NextSong\"/> walk it.</summary>\n\t[Property, Group( \"Seed\" )] public int StartN { get; set; } = 0;\n\t/// <summary>Optional base-36 vibe override (see <see cref=\"VibeCodec\"/>). When set it overrides\n\t/// the matching inspector knobs, so a shared vibe reproduces the same voicing on any client.</summary>\n\t[Property, Group( \"Seed\" )] public string Vibe { get; set; } = \"\";\n\t/// <summary>Persist the current song index across sessions (FileSystem.Data, keyed by <see cref=\"SaveSlot\"/>).</summary>\n\t[Property, Group( \"Seed\" )] public bool PersistProgress { get; set; } = false;\n\t[Property, Group( \"Seed\" )] public string SaveSlot { get; set; } = \"default\";\n\n\t// \u2500\u2500 Output \u2500\u2500\n\t[Property, Group( \"Output\" ), Range( 8000, 48000 )] public int SampleRate { get; set; } = 32000;\n\t/// <summary>Target track length; bar count adapts to tempo to hit this.</summary>\n\t[Property, Group( \"Output\" ), Range( 30f, 180f )] public float TargetSeconds { get; set; } = 80f;\n\t/// <summary>Worker threads the pitched-voice synthesis is split across (composition + drums\n\t/// stay single-threaded). Keeps each worker burst under s&box's ~1000ms no-yield advisory.</summary>\n\t[Property, Group( \"Output\" ), Range( 1, 8 )] public int RenderThreads { get; set; } = 6;\n\n\t// \u2500\u2500 Crossfade / scheduling \u2500\u2500\n\t/// <summary>Crossfade window (also the first song's fade-in from silence), seconds. The two\n\t/// songs only both-audible for <see cref=\"CrossfadeOverlap\"/> of this, centred.</summary>\n\t[Property, Group( \"Crossfade\" ), Range( 0.5f, 8f )] public float Crossfade { get; set; } = 3.75f;\n\t[Property, Group( \"Crossfade\" ), Range( 0f, 1f )] public float CrossfadeOverlap { get; set; } = 0.5f;\n\t/// <summary>How many times each loop plays before crossfading on (2 = play through, loop once, switch).</summary>\n\t[Property, Group( \"Crossfade\" ), Range( 1, 4 )] public int LoopsPerSong { get; set; } = 2;\n\t/// <summary>How many upcoming songs to keep pre-generated (built one-per-tick so the fill never stalls a frame).</summary>\n\t[Property, Group( \"Crossfade\" ), Range( 1, 8 )] public int AheadCount { get; set; } = 5;\n\n\t// \u2500\u2500 Tempo (main = laid-back reggae-rock; Fast = uptempo ska) \u2500\u2500\n\t[Property, Group( \"Tempo\" ), Range( 60, 200 )] public int BpmMin { get; set; } = 130;\n\t[Property, Group( \"Tempo\" ), Range( 60, 200 )] public int BpmMax { get; set; } = 185;\n\t[Property, Group( \"Tempo\" ), Range( 0f, 1f )] public float FastChance { get; set; } = 0.30f;\n\t[Property, Group( \"Tempo\" ), Range( 100, 220 )] public int FastBpmMin { get; set; } = 150;\n\t[Property, Group( \"Tempo\" ), Range( 100, 220 )] public int FastBpmMax { get; set; } = 168;\n\t[Property, Group( \"Tempo\" ), Range( 0f, 0.4f )] public float Swing { get; set; } = 0.14f;\n\t[Property, Group( \"Tempo\" ), Range( 0f, 0.4f )] public float FastSwing { get; set; } = 0.05f;\n\n\t// \u2500\u2500 Mix \u2500\u2500\n\t[Property, Group( \"Mix\" ), Range( 0f, 1.5f )] public float BassVol { get; set; } = 1.00f;\n\t[Property, Group( \"Mix\" ), Range( 0f, 1.5f )] public float SkankVol { get; set; } = 1.00f;\n\t[Property, Group( \"Mix\" ), Range( 0f, 1.5f )] public float OrganVol { get; set; } = 1.00f;\n\t[Property, Group( \"Mix\" ), Range( 0f, 1.5f )] public float MelodyVol { get; set; } = 1.00f;\n\t[Property, Group( \"Mix\" ), Range( 0f, 1.5f )] public float HornVol { get; set; } = 1.00f;\n\t[Property, Group( \"Mix\" ), Range( 0f, 1.5f )] public float KickVol { get; set; } = 1.00f;\n\t[Property, Group( \"Mix\" ), Range( 0f, 1.5f )] public float SnareVol { get; set; } = 0.70f;\n\t[Property, Group( \"Mix\" ), Range( 0f, 1.5f )] public float TomVol { get; set; } = 0.60f;\n\t[Property, Group( \"Mix\" ), Range( 0f, 1.5f )] public float HatVol { get; set; } = 0.22f;\n\t[Property, Group( \"Mix\" ), Range( 0f, 1.5f )] public float CrashVol { get; set; } = 0.35f;\n\t[Property, Group( \"Mix\" ), Range( 0f, 1.5f )] public float DrumVol { get; set; } = 1.00f;\n\n\t// \u2500\u2500 Tone \u2500\u2500\n\t[Property, Group( \"Tone\" ), Range( 0f, 40f )] public float Detune { get; set; } = 14f;\n\t[Property, Group( \"Tone\" ), Range( 80f, 1200f )] public float BassCutoff { get; set; } = 380f;\n\t[Property, Group( \"Tone\" ), Range( 500f, 8000f )] public float SkankCutoff { get; set; } = 3000f;\n\t[Property, Group( \"Tone\" ), Range( 0f, 2000f )] public float SkankHighpass { get; set; } = 500f;\n\t[Property, Group( \"Tone\" ), Range( 0.15f, 1f )] public float SkankChop { get; set; } = 0.5f;\n\t[Property, Group( \"Tone\" ), Range( 500f, 8000f )] public float LeadCutoff { get; set; } = 3200f;\n\t[Property, Group( \"Tone\" ), Range( 500f, 8000f )] public float OrganCutoff { get; set; } = 1400f;\n\t[Property, Group( \"Tone\" ), Range( 0f, 12f )] public float OrganVibrato { get; set; } = 5.5f;\n\t[Property, Group( \"Tone\" ), Range( 500f, 8000f )] public float HornCutoff { get; set; } = 3200f;\n\t[Property, Group( \"Tone\" ), Range( 0.2f, 2f )] public float Resonance { get; set; } = 1.0f;\n\t[Property, Group( \"Tone\" ), Range( 1f, 4f )] public float BassDrive { get; set; } = 1.5f;\n\t[Property, Group( \"Tone\" ), Range( 1f, 4f )] public float SkankDrive { get; set; } = 1.3f;\n\t[Property, Group( \"Tone\" ), Range( 1f, 4f )] public float MelodyDrive { get; set; } = 1.3f;\n\t[Property, Group( \"Tone\" ), Range( 1f, 4f )] public float HornDrive { get; set; } = 1.4f;\n\t[Property, Group( \"Tone\" ), Range( 0.5f, 3f )] public float MasterDrive { get; set; } = 1.1f;\n\t[Property, Group( \"Tone\" ), Range( 0.2f, 1f )] public float MasterPeak { get; set; } = 0.95f;\n\n\t// \u2500\u2500 Feel \u2500\u2500\n\t[Property, Group( \"Feel\" ), Range( 0f, 1f )] public float OctavePopChance { get; set; } = 0.30f;\n\t[Property, Group( \"Feel\" ), Range( 0f, 1f )] public float OrganBubbleChance { get; set; } = 0.55f;\n\t[Property, Group( \"Feel\" ), Range( 0f, 1f )] public float KickSyncChance { get; set; } = 0.25f;\n\t[Property, Group( \"Feel\" ), Range( 0f, 1f )] public float GhostSnareChance { get; set; } = 0.35f;\n\t[Property, Group( \"Feel\" ), Range( 0f, 1f )] public float FillChance { get; set; } = 0.6f;\n\t[Property, Group( \"Feel\" ), Range( 0f, 1f )] public float DrumBusy { get; set; } = 0.6f;\n\t[Property, Group( \"Feel\" ), Range( 0f, 1f )] public float DrumTone { get; set; } = 0.5f;\n\t[Property, Group( \"Feel\" ), Range( 0f, 1f )] public float DrumDrive { get; set; } = 0.5f;\n\t[Property, Group( \"Feel\" ), Range( 0f, 0.2f )] public float TripletChance { get; set; } = 0.06f;\n\t[Property, Group( \"Feel\" ), Range( 0f, 0.1f )] public float BassTriplets { get; set; } = 0.06f;\n\t[Property, Group( \"Feel\" ), Range( 0f, 1f )] public float MelodyRestChance { get; set; } = 0.30f;\n\t[Property, Group( \"Feel\" ), Range( 0f, 1f )] public float MelodyLeapChance { get; set; } = 0.18f;\n\t[Property, Group( \"Feel\" ), Range( 0f, 12f )] public float MelodyVibrato { get; set; } = 5.0f;\n\n\t// \u2500\u2500 Stereo \u2500\u2500\n\t[Property, Group( \"Stereo\" ), Range( 0f, 1f )] public float PanAmount { get; set; } = 0.4f;\n\n\t// \u2500\u2500 Lead instrument (RNG picks one per tag, weighted; Force overrides) \u2500\u2500\n\t[Property, Group( \"Instrument\" ), Range( 0f, 4f )] public float TrumpetWeight { get; set; } = 1.0f;\n\t[Property, Group( \"Instrument\" ), Range( 0f, 4f )] public float SaxWeight { get; set; } = 1.0f;\n\t[Property, Group( \"Instrument\" ), Range( 0f, 4f )] public float OrganWeight { get; set; } = 0.8f;\n\t[Property, Group( \"Instrument\" ), Range( 0f, 4f )] public float TromboneWeight { get; set; } = 0.4f;\n\t/// <summary>-1 = RNG; 0=Trumpet 1=Sax 2=Organ 3=Trombone.</summary>\n\t[Property, Group( \"Instrument\" ), Range( -1, 3 )] public int ForceInstrument { get; set; } = -1;\n\n\t// \u2500\u2500 Backing horns \u2500\u2500\n\t[Property, Group( \"Horns\" ), Range( 0f, 1f )] public float HornSectionChance { get; set; } = 0.5f;\n\t[Property, Group( \"Horns\" ), Range( 0f, 1f )] public float HornDensity { get; set; } = 0.35f;\n\n\t// \u2500\u2500 Genre & rock instruments \u2500\u2500\n\t// Genre selects the instrument set: 0 = Ska, 1 = Rock (drums/bass/rhythm-gtr/lead-gtr).\n\t[Property, Group( \"Genre\" ), Range( 0, 1 )] public int Genre { get; set; } = 0;\n\t// KEYS \u2014 the offbeat-chord comp (was the \"rhythm guitar\"; it reads as keys).\n\t[Property, Group( \"Rock\" ), Range( 0f, 1.5f )] public float KeysVol { get; set; } = 1.00f;\n\t[Property, Group( \"Rock\" ), Range( 500f, 8000f )] public float KeysCutoff { get; set; } = 1700f;\n\t[Property, Group( \"Rock\" ), Range( 1f, 5f )] public float KeysDrive { get; set; } = 3.2f;\n\t[Property, Group( \"Rock\" ), Range( 0f, 1f )] public float KeysChug { get; set; } = 0.5f;\n\t// RHYTHM GTR \u2014 twangy distorted power chords (shares the lead voice, lower base distortion).\n\t[Property, Group( \"Rock\" ), Range( 0f, 1.5f )] public float RhythmGtrVol { get; set; } = 1.00f;\n\t[Property, Group( \"Rock\" ), Range( 500f, 8000f )] public float RhythmGtrCutoff { get; set; } = 2600f;\n\t[Property, Group( \"Rock\" ), Range( 1f, 5f )] public float RhythmGtrDrive { get; set; } = 2.8f;\n\t[Property, Group( \"Rock\" ), Range( 0f, 1f )] public float RhythmGtrChug { get; set; } = 0.5f;\n\t[Property, Group( \"Rock\" ), Range( 0f, 1.5f )] public float LeadGtrVol { get; set; } = 1.00f;\n\t[Property, Group( \"Rock\" ), Range( 500f, 8000f )] public float LeadGtrCutoff { get; set; } = 2600f;\n\t[Property, Group( \"Rock\" ), Range( 1f, 5f )] public float LeadGtrDrive { get; set; } = 3.6f;\n\t[Property, Group( \"Rock\" ), Range( 0f, 1f )] public float LeadGtrBend { get; set; } = 0.30f;\n\n\tSoundStream _stream;\n\tSoundHandle _handle;\n\tint _sr;\n\n\tshort[] _curRaw; // current song, full single loop (raw, for export)\n\treadonly System.Collections.Generic.List<short[]> _ahead = new(); // pre-generated n+1, n+2, \u2026\n\tint _curN; // index of the currently-playing song\n\t// Per-instrument volumes, keyed by voice NAME (BASS, DRUMS, \u2026) so the level follows the\n\t// instrument across genres. Pulled out of the vibe seed; persisted to FileSystem.Data and\n\t// overlaid onto every BuildConfig. See VibeCodec.ReadVolumes/ApplyVolumes.\n\tSystem.Collections.Generic.Dictionary<string, float> _vols = new();\n\t// Shared house-mix config (peak balances / kit presence) read from the addon's\n\t// skafinity.config.json \u2014 the SAME file the web toy uses. Overlaid onto every BuildConfig.\n\tSystem.Collections.Generic.Dictionary<string, float> _houseConfig = new();\n\tint _curReserve; // samples of the current song's tail held back for the crossfade\n\tdouble _pushedSeconds; // total audio pushed to the stream\n\tTimeSince _sinceStart; // wall clock since playback started\n\tint _lastConfigHash;\n\tbool _dirty;\n\tTimeSince _dirtySince;\n\tbool _starting; // StartSequenceAsync is in flight\n\tbool _fillingAhead; // FillAhead is in flight\n\tbool Generating => _starting || _fillingAhead;\n\tint _seq; // bumped on each StartSequence; stale async results are discarded\n\tbool _flatConfigured; // ConfigureFlat applied to the live handle\n\tbool _restartPending; // a debounced restart (vibe edit) is queued\n\tTimeSince _restartPendingSince;\n\n\t/// <summary>Currently-playing song index.</summary>\n\tpublic int N => _curN;\n\t/// <summary>The effective vibe \u2014 the override if set, else the encoded inspector knobs.</summary>\n\tpublic string CurrentVibe => VibeCodec.Encode( BuildConfig() );\n\t/// <summary>Shareable seed for the playing song: <c>vibe:tag:n</c>.</summary>\n\tpublic string CurrentSeed => $\"{CurrentVibe}:{SeedTag}:{_curN}\";\n\t/// <summary>True once a stream handle is live and audible.</summary>\n\tpublic bool IsPlaying => _handle != null;\n\n\tstring SeedTag => string.IsNullOrEmpty( Tag ) ? \"\" : Tag;\n\t// Build the PRNG seed string from a resolved tag, so worker code never re-reads state.\n\tstatic string SeedFor( string tag, int n ) => $\"{(string.IsNullOrEmpty( tag ) ? \"skafinity\" : tag.ToLowerInvariant())}:{n}\";\n\tstring Seed( int n ) => SeedFor( SeedTag, n );\n\n\tprotected override void OnStart()\n\t{\n\t\t_lastConfigHash = ConfigHash();\n\t\t_curN = Math.Max( 0, PersistProgress ? LoadN() ?? StartN : StartN );\n\t\t_vols = LoadVols();\n\t\t_houseConfig = LoadHouseConfig();\n\t\tif ( AutoPlay ) StartSequence();\n\t}\n\n\tprotected override void OnDestroy()\n\t{\n\t\t_seq++; // invalidate any in-flight worker generation\n\t\t_handle?.Stop();\n\t\t_handle = null;\n\t\t_stream = null;\n\t}\n\n\tprotected override void OnUpdate()\n\t{\n\t\tif ( _handle != null )\n\t\t\t_handle.Volume = TargetVolume();\n\n\t\t// Keep the look-ahead buffer topped up. Generation runs on a worker thread so this\n\t\t// never blocks the frame.\n\t\tif ( !Generating && _curRaw != null && _ahead.Count < Math.Max( 1, AheadCount ) )\n\t\t\t_ = FillAhead( _seq );\n\n\t\t// When the queued audio is about to run out, crossfade into the next song.\n\t\tif ( _stream != null && _ahead.Count > 0 && _curRaw != null\n\t\t\t&& _pushedSeconds - _sinceStart < 2.0 )\n\t\t\tPushTransition();\n\n\t\tint h = ConfigHash();\n\t\tif ( h != _lastConfigHash )\n\t\t{\n\t\t\t_lastConfigHash = h;\n\t\t\t_dirty = true;\n\t\t\t_dirtySince = 0;\n\t\t}\n\t\tif ( _dirty && LiveReload && !Generating && _dirtySince > 0.5f )\n\t\t{\n\t\t\t_dirty = false;\n\t\t\tStartSequence();\n\t\t}\n\n\t\t// Debounced restart for vibe edits: only regenerate once edits have settled.\n\t\tif ( _restartPending && !Generating && _restartPendingSince > 0.35f )\n\t\t{\n\t\t\t_restartPending = false;\n\t\t\tStartSequence();\n\t\t}\n\t}\n\n\t/// <summary>Make the stream play as flat 2D (SpacialBlend=0, parented to the camera with\n\t/// FollowParent so the listener can't pan/attenuate it). Optionally routes to a named mixer.</summary>\n\tvoid ConfigureFlat()\n\t{\n\t\tif ( _handle == null || _flatConfigured ) return;\n\t\t_handle.SpacialBlend = 0f;\n\t\tvar camGo = Scene?.Camera?.GameObject;\n\t\tif ( camGo.IsValid() )\n\t\t{\n\t\t\t_handle.Parent = camGo;\n\t\t\t_handle.FollowParent = true;\n\t\t}\n\t\tif ( !string.IsNullOrEmpty( MixerName ) )\n\t\t{\n\t\t\tvar mixer = Sandbox.Audio.Mixer.FindMixerByName( MixerName );\n\t\t\tif ( mixer != null )\n\t\t\t\t_handle.TargetMixer = mixer;\n\t\t}\n\t\t_flatConfigured = true;\n\t}\n\n\tfloat TargetVolume() => Enabled ? Volume : 0f;\n\n\t/// <summary>The config currently in effect (inspector knobs with any <see cref=\"Vibe\"/> applied).</summary>\n\tpublic MusicGen.Config EffectiveConfig() => BuildConfig();\n\n\tMusicGen.Config BuildConfig()\n\t{\n\t\tvar cfg = BuildKnobConfig();\n\t\t// Shared house-mix baseline (peak balances / kit presence) from skafinity.config.json \u2014\n\t\t// the same file the web toy reads. Independent of the vibe/volume knobs below.\n\t\tVibeCodec.ApplyAdvanced( _houseConfig, cfg );\n\t\t// A vibe override sets the important knobs (so a shared vibe:tag:n reproduces the same\n\t\t// voicing regardless of this client's inspector knobs).\n\t\tif ( !string.IsNullOrEmpty( Vibe ) )\n\t\t\tVibeCodec.Apply( Vibe, cfg );\n\t\t// Per-instrument volumes are NOT in the seed \u2014 overlay the persisted per-voice mix on top.\n\t\tVibeCodec.ApplyVolumes( cfg.Genre, _vols, cfg );\n\t\treturn cfg;\n\t}\n\n\tMusicGen.Config BuildKnobConfig() => new()\n\t{\n\t\tSampleRate = SampleRate,\n\t\tTargetSeconds = TargetSeconds,\n\t\tBpmMin = BpmMin,\n\t\tBpmMax = BpmMax,\n\t\tFastChance = FastChance,\n\t\tFastBpmMin = FastBpmMin,\n\t\tFastBpmMax = FastBpmMax,\n\t\tSwing = Swing,\n\t\tFastSwing = FastSwing,\n\t\tBassVol = BassVol,\n\t\tSkankVol = SkankVol,\n\t\tOrganVol = OrganVol,\n\t\tMelodyVol = MelodyVol,\n\t\tHornVol = HornVol,\n\t\tKickVol = KickVol,\n\t\tSnareVol = SnareVol,\n\t\tTomVol = TomVol,\n\t\tHatVol = HatVol,\n\t\tCrashVol = CrashVol,\n\t\tDrumVol = DrumVol,\n\t\tDetune = Detune,\n\t\tBassCutoff = BassCutoff,\n\t\tSkankCutoff = SkankCutoff,\n\t\tSkankHighpass = SkankHighpass,\n\t\tSkankChop = SkankChop,\n\t\tLeadCutoff = LeadCutoff,\n\t\tOrganCutoff = OrganCutoff,\n\t\tOrganVibrato = OrganVibrato,\n\t\tHornCutoff = HornCutoff,\n\t\tResonance = Resonance,\n\t\tBassDrive = BassDrive,\n\t\tSkankDrive = SkankDrive,\n\t\tMelodyDrive = MelodyDrive,\n\t\tHornDrive = HornDrive,\n\t\tMasterDrive = MasterDrive,\n\t\tMasterPeak = MasterPeak,\n\t\tOctavePopChance = OctavePopChance,\n\t\tOrganBubbleChance = OrganBubbleChance,\n\t\tKickSyncChance = KickSyncChance,\n\t\tGhostSnareChance = GhostSnareChance,\n\t\tFillChance = FillChance,\n\t\tDrumBusy = DrumBusy,\n\t\tTripletChance = TripletChance,\n\t\tBassTriplets = BassTriplets,\n\t\tMelodyRestChance = MelodyRestChance,\n\t\tMelodyLeapChance = MelodyLeapChance,\n\t\tMelodyVibrato = MelodyVibrato,\n\t\tPanAmount = PanAmount,\n\t\tTrumpetWeight = TrumpetWeight,\n\t\tSaxWeight = SaxWeight,\n\t\tOrganWeight = OrganWeight,\n\t\tTromboneWeight = TromboneWeight,\n\t\tForceInstrument = ForceInstrument,\n\t\tHornSectionChance = HornSectionChance,\n\t\tHornDensity = HornDensity,\n\t\tGenre = Genre,\n\t\tDrumTone = DrumTone,\n\t\tDrumDrive = DrumDrive,\n\t\tKeysVol = KeysVol,\n\t\tKeysCutoff = KeysCutoff,\n\t\tKeysDrive = KeysDrive,\n\t\tKeysChug = KeysChug,\n\t\tRhythmGtrVol = RhythmGtrVol,\n\t\tRhythmGtrCutoff = RhythmGtrCutoff,\n\t\tRhythmGtrDrive = RhythmGtrDrive,\n\t\tRhythmGtrChug = RhythmGtrChug,\n\t\tLeadGtrVol = LeadGtrVol,\n\t\tLeadGtrCutoff = LeadGtrCutoff,\n\t\tLeadGtrDrive = LeadGtrDrive,\n\t\tLeadGtrBend = LeadGtrBend,\n\t};\n\n\tint ConfigHash()\n\t{\n\t\tvar h = new HashCode();\n\t\th.Add( SampleRate ); h.Add( TargetSeconds );\n\t\th.Add( BpmMin ); h.Add( BpmMax ); h.Add( FastChance );\n\t\th.Add( FastBpmMin ); h.Add( FastBpmMax ); h.Add( Swing ); h.Add( FastSwing );\n\t\th.Add( BassVol ); h.Add( SkankVol ); h.Add( OrganVol ); h.Add( MelodyVol ); h.Add( HornVol );\n\t\th.Add( KickVol ); h.Add( SnareVol ); h.Add( TomVol ); h.Add( HatVol ); h.Add( CrashVol ); h.Add( DrumVol );\n\t\th.Add( Detune ); h.Add( BassCutoff ); h.Add( SkankCutoff ); h.Add( SkankHighpass ); h.Add( SkankChop );\n\t\th.Add( LeadCutoff ); h.Add( OrganCutoff ); h.Add( OrganVibrato ); h.Add( HornCutoff ); h.Add( Resonance );\n\t\th.Add( BassDrive ); h.Add( SkankDrive ); h.Add( MelodyDrive ); h.Add( HornDrive );\n\t\th.Add( MasterDrive ); h.Add( MasterPeak );\n\t\th.Add( OctavePopChance ); h.Add( OrganBubbleChance ); h.Add( KickSyncChance );\n\t\th.Add( GhostSnareChance ); h.Add( FillChance );\n\t\th.Add( DrumBusy ); h.Add( DrumTone ); h.Add( DrumDrive ); h.Add( TripletChance ); h.Add( BassTriplets );\n\t\th.Add( MelodyRestChance ); h.Add( MelodyLeapChance ); h.Add( MelodyVibrato );\n\t\th.Add( PanAmount );\n\t\th.Add( TrumpetWeight ); h.Add( SaxWeight ); h.Add( OrganWeight ); h.Add( TromboneWeight );\n\t\th.Add( ForceInstrument );\n\t\th.Add( HornSectionChance ); h.Add( HornDensity );\n\t\th.Add( Genre );\n\t\th.Add( KeysVol ); h.Add( KeysCutoff ); h.Add( KeysDrive ); h.Add( KeysChug );\n\t\th.Add( RhythmGtrVol ); h.Add( RhythmGtrCutoff ); h.Add( RhythmGtrDrive ); h.Add( RhythmGtrChug );\n\t\th.Add( LeadGtrVol ); h.Add( LeadGtrCutoff ); h.Add( LeadGtrDrive ); h.Add( LeadGtrBend );\n\t\th.Add( Tag ); h.Add( Vibe );\n\t\treturn h.ToHashCode();\n\t}\n\n\t// Run the (pure, CPU-heavy) synthesis on worker threads so it never blocks the frame AND so\n\t// no single worker burst runs long enough to trip s&box's ~1000ms no-yield advisory.\n\t// Composition + drum synthesis are RNG-bound and stay sequential (BeginPlan, one worker); the\n\t// pitched voices pull no RNG, so they fan out across RenderThreads disjoint windows joined by\n\t// Task.WhenAll; the master+interleave runs on one worker. Result is interleaved stereo PCM.\n\tasync Task<short[]> GenerateStereoAsync( string seedStr, MusicGen.Config cfg )\n\t{\n\t\tMusicGen g = null;\n\t\tawait GameTask.RunInThreadAsync( () => { g = MusicGen.BeginPlan( seedStr, cfg ); return Task.CompletedTask; } );\n\n\t\tint total = g.TotalSamples;\n\t\tint k = Math.Clamp( RenderThreads, 1, 8 );\n\t\tif ( k <= 1 )\n\t\t{\n\t\t\tawait GameTask.RunInThreadAsync( () => { g.RenderPitchedRange( 0, total ); return Task.CompletedTask; } );\n\t\t}\n\t\telse\n\t\t{\n\t\t\tvar jobs = new Task[k];\n\t\t\tfor ( int i = 0; i < k; i++ )\n\t\t\t{\n\t\t\t\tint from = (int)((long)total * i / k);\n\t\t\t\tint to = (int)((long)total * (i + 1) / k);\n\t\t\t\tjobs[i] = GameTask.RunInThreadAsync( () => { g.RenderPitchedRange( from, to ); return Task.CompletedTask; } );\n\t\t\t}\n\t\t\tawait Task.WhenAll( jobs );\n\t\t}\n\n\t\tshort[] pcm = null;\n\t\tawait GameTask.RunInThreadAsync( () => { pcm = g.FinishStereo(); return Task.CompletedTask; } );\n\t\t_sr = g.SampleRate;\n\t\treturn pcm;\n\t}\n\n\tint FadeFrames => Math.Max( 1, (int)(Math.Clamp( Crossfade, 0.25f, 8f ) * _sr) );\n\n\t// All look-ahead buffers are interleaved stereo PCM; lengths/offsets below are in frames.\n\tstatic int Frames( short[] pcm ) => pcm.Length / MusicGen.Channels;\n\n\t/// <summary>Top the look-ahead buffer up to <see cref=\"AheadCount\"/>, generating each song on\n\t/// a worker thread. Fire-and-forget from OnUpdate; <paramref name=\"seq\"/> guards against a\n\t/// sequence restart landing a stale song in the buffer.</summary>\n\tasync Task FillAhead( int seq )\n\t{\n\t\tif ( Generating ) return;\n\t\ttry\n\t\t{\n\t\t\t_fillingAhead = true;\n\t\t\tstring tag = SeedTag;\n\t\t\tvar cfg = BuildConfig();\n\t\t\twhile ( seq == _seq && _curRaw != null && _ahead.Count < Math.Max( 1, AheadCount ) )\n\t\t\t{\n\t\t\t\tint n = _curN + 1 + _ahead.Count;\n\t\t\t\tvar song = await GenerateStereoAsync( SeedFor( tag, n ), cfg );\n\t\t\t\tif ( seq != _seq ) return; // sequence restarted while we were generating\n\t\t\t\t_ahead.Add( song );\n\t\t\t}\n\t\t}\n\t\tcatch ( Exception e ) { Log.Warning( $\"SkafinityPlayer: FillAhead failed: {e.Message}\" ); }\n\t\tfinally { _fillingAhead = false; }\n\t}\n\n\t/// <summary>Write <see cref=\"LoopsPerSong\"/> passes of interleaved-stereo <paramref name=\"raw\"/>\n\t/// to the stream, given the first <paramref name=\"headConsumed\"/> frames of pass 0 were already\n\t/// emitted, holding back the final <paramref name=\"reserve\"/> frames for the next crossfade.\n\t/// Optional fade-in over the first <paramref name=\"fadeIn\"/> frames of pass 0. Returns frames\n\t/// written.</summary>\n\tint WriteSongBody( short[] raw, int headConsumed, int reserve, int fadeIn )\n\t{\n\t\tconst int ch = MusicGen.Channels;\n\t\tint loops = Math.Max( 1, LoopsPerSong );\n\t\tint rawFrames = raw.Length / ch;\n\t\tint total = 0;\n\t\tfor ( int loop = 0; loop < loops; loop++ )\n\t\t{\n\t\t\tint start = loop == 0 ? headConsumed : 0;\n\t\t\tint end = loop == loops - 1 ? rawFrames - reserve : rawFrames;\n\t\t\tif ( end <= start ) continue;\n\t\t\tint len = end - start;\n\t\t\tvar seg = new short[len * ch];\n\t\t\tfor ( int i = 0; i < len; i++ )\n\t\t\t{\n\t\t\t\tint frame = start + i;\n\t\t\t\tfloat g = (loop == 0 && fadeIn > 0 && frame < fadeIn) ? (float)frame / fadeIn : 1f;\n\t\t\t\tfor ( int c = 0; c < ch; c++ )\n\t\t\t\t\tseg[i * ch + c] = (short)(raw[frame * ch + c] * g);\n\t\t\t}\n\t\t\t_stream.WriteData( seg );\n\t\t\ttotal += len;\n\t\t}\n\t\treturn total;\n\t}\n\n\t/// <summary>(Re)start the infinite sequence at the current tag/n. Bumps the sequence token\n\t/// (invalidating any in-flight generation), stops the current handle, then kicks the async\n\t/// (worker-thread) start so the caller never blocks.</summary>\n\tpublic void StartSequence()\n\t{\n\t\tint seq = ++_seq;\n\t\t_ahead.Clear();\n\t\t_handle?.Stop();\n\t\t_handle = null;\n\t\t_stream = null;\n\t\t_flatConfigured = false;\n\t\t_curRaw = null;\n\t\t_ = StartSequenceAsync( seq );\n\t}\n\n\t// The first song fades in; thereafter songs play LoopsPerSong passes and crossfade into the\n\t// pre-generated next. Synthesis is offloaded; stream setup is on the main thread.\n\tasync Task StartSequenceAsync( int seq )\n\t{\n\t\ttry\n\t\t{\n\t\t\t_starting = true;\n\t\t\tint n = Math.Max( 0, _curN );\n\t\t\tstring tag = SeedTag;\n\t\t\tvar cfg = BuildConfig();\n\t\t\tvar raw = await GenerateStereoAsync( SeedFor( tag, n ), cfg );\n\t\t\tif ( seq != _seq ) return; // superseded by a newer StartSequence\n\n\t\t\t_curN = n;\n\t\t\t_curRaw = raw;\n\t\t\tint fade = Math.Min( FadeFrames, Frames( _curRaw ) / 3 );\n\t\t\t_curReserve = fade;\n\n\t\t\t_stream = new SoundStream( _sr, MusicGen.Channels );\n\n\t\t\t// First song: LoopsPerSong passes, fade-in over the first `fade`, last `fade` of the\n\t\t\t// final pass held back for the crossfade into the next song.\n\t\t\tint written = WriteSongBody( _curRaw, 0, _curReserve, fade );\n\t\t\t_pushedSeconds = written / (double)_sr;\n\n\t\t\t_handle = _stream.Play();\n\t\t\tif ( _handle != null )\n\t\t\t{\n\t\t\t\t_handle.Volume = TargetVolume();\n\t\t\t\tConfigureFlat();\n\t\t\t}\n\t\t\t_sinceStart = 0;\n\t\t}\n\t\tcatch ( Exception e )\n\t\t{\n\t\t\tLog.Warning( $\"SkafinityPlayer: StartSequence failed: {e.Message}\" );\n\t\t}\n\t\tfinally { _starting = false; }\n\t}\n\n\t// Queue the crossfade from the current song's tail into the next song's head, then the next\n\t// song's body. Advances n (persisted if enabled); the following song is topped up by OnUpdate.\n\tvoid PushTransition()\n\t{\n\t\ttry\n\t\t{\n\t\t\tvar next = _ahead[0];\n\t\t\t_ahead.RemoveAt( 0 );\n\n\t\t\t// Crossfade window = the current song's held-back tail (so there's no gap or overlap\n\t\t\t// even when songs differ in length). The two songs only overlap for CrossfadeOverlap\n\t\t\t// of this window, centred \u2014 the rest plays in the clear.\n\t\t\tconst int ch = MusicGen.Channels;\n\t\t\tint W = Math.Min( _curReserve, Frames( next ) / 3 );\n\t\t\tint curStart = Frames( _curRaw ) - W;\n\t\t\tint cross = Math.Clamp( (int)(W * CrossfadeOverlap), 1, W );\n\t\t\tint ws = (W - cross) / 2; // overlap starts here\n\t\t\tint we = ws + cross; // overlap ends here\n\n\t\t\tvar xf = new short[W * ch];\n\t\t\tfor ( int i = 0; i < W; i++ )\n\t\t\t{\n\t\t\t\tfloat gOut, gIn;\n\t\t\t\tif ( i < ws ) { gOut = 1f; gIn = 0f; } // outgoing in the clear\n\t\t\t\telse if ( i >= we ) { gOut = 0f; gIn = 1f; } // incoming in the clear\n\t\t\t\telse\n\t\t\t\t{\n\t\t\t\t\tdouble t = (i - ws + 0.5) / cross * (Math.PI / 2); // equal-power cross\n\t\t\t\t\tgOut = (float)Math.Cos( t );\n\t\t\t\t\tgIn = (float)Math.Sin( t );\n\t\t\t\t}\n\t\t\t\tfor ( int c = 0; c < ch; c++ )\n\t\t\t\t\txf[i * ch + c] = (short)Math.Clamp( _curRaw[(curStart + i) * ch + c] * gOut + next[i * ch + c] * gIn, -32768, 32767 );\n\t\t\t}\n\t\t\t_stream.WriteData( xf );\n\n\t\t\t// next song: LoopsPerSong passes, first W of pass 0 already in the crossfade, last\n\t\t\t// `nextReserve` of the final pass held back for the following crossfade.\n\t\t\tint nextReserve = Math.Min( FadeFrames, Frames( next ) / 3 );\n\t\t\tint written = WriteSongBody( next, W, nextReserve, 0 );\n\t\t\t_pushedSeconds += (W + written) / (double)_sr;\n\n\t\t\t_curRaw = next;\n\t\t\t_curReserve = nextReserve;\n\t\t\t_curN++;\n\t\t\tif ( PersistProgress ) SaveN( _curN );\n\n\t\t\t// Shuffle mode: each new song gets a fresh set of vibe knobs (NOT volumes \u2014 those are\n\t\t\t// a local mix preference, kept out of the seed \u2014 and NOT genre, matching the web toy).\n\t\t\t// Re-voice WITHOUT a restart (restart: false) \u2014 a restart would throw away the song we\n\t\t\t// just crossfaded into and replay _curN from scratch, stalling the n+1 progression.\n\t\t\t// Instead drop the look-ahead so FillAhead (OnUpdate) regenerates upcoming songs with\n\t\t\t// the new vibe, and absorb the Vibe change into the config hash so LiveReload doesn't\n\t\t\t// fire its own restart either. The current crossfade keeps playing; n keeps advancing.\n\t\t\tif ( RandomEverySong )\n\t\t\t{\n\t\t\t\tRerollVibe( restart: false );\n\t\t\t\t_ahead.Clear();\n\t\t\t\t_lastConfigHash = ConfigHash();\n\t\t\t}\n\t\t}\n\t\tcatch ( Exception e )\n\t\t{\n\t\t\tLog.Warning( $\"SkafinityPlayer: PushTransition failed: {e.Message}\" );\n\t\t}\n\t}\n\n\t// \u2500\u2500 Public control surface \u2500\u2500\n\n\t// Parse a shareable seed in any of vibe:tag:n / tag:n / tag. Missing parts stay null.\n\tstatic void ParseSeed( string seed, out string vibe, out string tag, out int? n )\n\t{\n\t\tvibe = null; tag = null; n = null;\n\t\tseed = seed?.Trim();\n\t\tif ( string.IsNullOrEmpty( seed ) ) return;\n\t\tvar p = seed.Split( ':' );\n\t\tif ( p.Length >= 3 ) { vibe = p[0]; tag = p[1]; if ( int.TryParse( p[2], out var v ) ) n = v; }\n\t\telse if ( p.Length == 2 )\n\t\t{\n\t\t\tif ( int.TryParse( p[1], out var v ) ) { tag = p[0]; n = v; }\n\t\t\telse if ( VibeCodec.LooksLikeVibe( p[0] ) ) { vibe = p[0]; tag = p[1]; }\n\t\t\telse tag = p[0];\n\t\t}\n\t\telse tag = p[0];\n\t}\n\n\t/// <summary>Play a shareable seed in any of the forms <c>vibe:tag:n</c>, <c>tag:n</c>, or\n\t/// <c>tag</c>. Missing components are left unchanged; a vibe is only applied when present.\n\t/// Restarts the sequence.</summary>\n\tpublic void PlaySeed( string seed )\n\t{\n\t\tParseSeed( seed, out string vibe, out string tag, out int? n );\n\t\tif ( tag != null ) Tag = tag.Trim().ToLowerInvariant();\n\t\tif ( vibe != null ) Vibe = VibeCodec.LooksLikeVibe( vibe ) ? vibe.ToLowerInvariant() : \"\";\n\t\tif ( n.HasValue ) _curN = Math.Max( 0, n.Value );\n\t\tif ( PersistProgress ) SaveN( _curN );\n\t\tStartSequence();\n\t}\n\n\t/// <summary>Set just the seed tag (empty = the default \"skafinity\" seed). Restarts.</summary>\n\tpublic void SetTag( string tag )\n\t{\n\t\tTag = string.IsNullOrEmpty( tag ) ? \"\" : tag.Trim().ToLowerInvariant();\n\t\tStartSequence();\n\t}\n\n\t/// <summary>Jump to song index n in the sequence (clamped \u2265 0). Restarts.</summary>\n\tpublic void SetN( int n )\n\t{\n\t\t_curN = Math.Max( 0, n );\n\t\tif ( PersistProgress ) SaveN( _curN );\n\t\tStartSequence();\n\t}\n\n\t/// <summary>Step the song index by <paramref name=\"delta\"/> (e.g. +1 / -1). Restarts.</summary>\n\tpublic void StepN( int delta ) => SetN( _curN + delta );\n\t/// <summary>Skip to the next song in the sequence.</summary>\n\tpublic void NextSong() => StepN( 1 );\n\t/// <summary>Step back to the previous song in the sequence.</summary>\n\tpublic void PrevSong() => StepN( -1 );\n\n\t/// <summary>Set vibe field <paramref name=\"index\"/> (see <see cref=\"VibeCodec.Fields(int)\"/>) from\n\t/// a 0..1 fraction, store the re-encoded <see cref=\"Vibe\"/>, and restart on a short debounce.</summary>\n\tpublic void SetVibe( int index, float norm )\n\t{\n\t\tvar cfg = BuildConfig();\n\t\tvar fields = VibeCodec.Fields( cfg.Genre );\n\t\tif ( index < 0 || index >= fields.Count ) return;\n\t\tvar f = fields[index];\n\t\tf.SetNorm( cfg, norm );\n\t\tif ( VibeCodec.IsVolume( f ) )\n\t\t{\n\t\t\t// Volume is a local mix preference, not part of the seed \u2014 store per-voice + persist.\n\t\t\t_vols[f.Voice] = norm;\n\t\t\tSaveVols();\n\t\t}\n\t\telse\n\t\t{\n\t\t\tVibe = VibeCodec.Encode( cfg );\n\t\t}\n\t\t_restartPending = true;\n\t\t_restartPendingSince = 0;\n\t}\n\n\t/// <summary>Switch genre (rides in the vibe's first char): re-encode the effective config\n\t/// with the new genre into <see cref=\"Vibe\"/> so it sticks over the inspector knobs, then\n\t/// restart. Use this rather than setting <see cref=\"Genre\"/> directly \u2014 an existing\n\t/// <see cref=\"Vibe\"/> override otherwise wins and the change wouldn't take.</summary>\n\tpublic void SetGenre( int genre )\n\t{\n\t\tvar cfg = BuildConfig();\n\t\tcfg.Genre = Math.Clamp( genre, 0, VibeCodec.GenreCount - 1 );\n\t\tVibe = VibeCodec.Encode( cfg );\n\t\tStartSequence();\n\t}\n\n\t/// <summary>Randomize the vibe knobs and restart on a short debounce. By default the\n\t/// per-instrument volumes (and genre) are left alone so a reroll re-voices without upending\n\t/// the mix; pass <paramref name=\"includeVolumes\"/> / <paramref name=\"includeGenre\"/> for a\n\t/// full shuffle. Pass <paramref name=\"restart\"/> = false to re-voice without yanking the\n\t/// playhead \u2014 the caller is then responsible for letting the change take effect (e.g. by\n\t/// clearing the look-ahead so upcoming songs regenerate with the new vibe).</summary>\n\tpublic void RerollVibe( bool includeVolumes = false, bool includeGenre = false, bool restart = true )\n\t{\n\t\tvar cfg = BuildConfig();\n\t\tvar rng = System.Random.Shared;\n\t\tif ( includeGenre )\n\t\t\tcfg.Genre = rng.Next( VibeCodec.GenreCount );\n\t\tforeach ( var f in VibeCodec.Fields( cfg.Genre ) )\n\t\t{\n\t\t\tif ( !includeVolumes && f.Voice != null && f.Column == 0 ) continue; // skip per-instrument volumes\n\t\t\tf.SetNorm( cfg, rng.NextSingle() );\n\t\t}\n\t\tif ( cfg.BpmMin > cfg.BpmMax ) (cfg.BpmMin, cfg.BpmMax) = (cfg.BpmMax, cfg.BpmMin);\n\t\tVibe = VibeCodec.Encode( cfg );\n\t\tif ( includeVolumes )\n\t\t{\n\t\t\t// Capture the freshly-randomized volumes into the persisted per-voice store (they\n\t\t\t// don't ride in the encoded vibe).\n\t\t\tforeach ( var kv in VibeCodec.ReadVolumes( cfg.Genre, cfg ) ) _vols[kv.Key] = kv.Value;\n\t\t\tSaveVols();\n\t\t}\n\t\tif ( restart )\n\t\t{\n\t\t\t_restartPending = true;\n\t\t\t_restartPendingSince = 0;\n\t\t}\n\t}\n\n\t/// <summary>Write the playing song's raw loop (no fade) to a WAV under FileSystem.Data.\n\t/// Returns the filename written, or null on failure.</summary>\n\tpublic string SaveCurrentToFile()\n\t{\n\t\tif ( _curRaw == null || _sr <= 0 ) return null;\n\t\tvar tag = string.IsNullOrEmpty( SeedTag ) ? \"skafinity\" : SeedTag.ToLowerInvariant();\n\t\tvar name = $\"{tag}_{_curN}.wav\";\n\t\ttry\n\t\t{\n\t\t\tFileSystem.Data.WriteAllBytes( name, MusicGen.WavFromSamples( _curRaw, 1, _sr ) );\n\t\t\treturn name;\n\t\t}\n\t\tcatch ( Exception e )\n\t\t{\n\t\t\tLog.Warning( $\"SkafinityPlayer: save failed: {e.Message}\" );\n\t\t\treturn null;\n\t\t}\n\t}\n\n\t// \u2500\u2500 Optional progress persistence (FileSystem.Data, see assets/file-system.md) \u2500\u2500\n\tstring ProgressFile => $\"skafinity_{(string.IsNullOrEmpty( SaveSlot ) ? \"default\" : SaveSlot)}.n\";\n\n\tvoid SaveN( int n )\n\t{\n\t\ttry { FileSystem.Data.WriteAllText( ProgressFile, n.ToString() ); }\n\t\tcatch ( Exception e ) { Log.Warning( $\"SkafinityPlayer: save progress failed: {e.Message}\" ); }\n\t}\n\n\tint? LoadN()\n\t{\n\t\ttry\n\t\t{\n\t\t\tif ( FileSystem.Data.FileExists( ProgressFile )\n\t\t\t\t&& int.TryParse( FileSystem.Data.ReadAllText( ProgressFile ), out var v ) )\n\t\t\t\treturn Math.Max( 0, v );\n\t\t}\n\t\tcatch ( Exception e ) { Log.Warning( $\"SkafinityPlayer: load progress failed: {e.Message}\" ); }\n\t\treturn null;\n\t}\n\n\t// \u2500\u2500 Per-instrument volume persistence (FileSystem.Data, keyed by SaveSlot) \u2500\u2500\n\t// Stored as JSON voice\u21920..1 level, separate from progress and from the (volume-free) seed,\n\t// so the mix is a local preference that survives sessions and follows each voice across genres.\n\tstring VolumeFile => $\"skafinity_{(string.IsNullOrEmpty( SaveSlot ) ? \"default\" : SaveSlot)}.vol\";\n\n\tvoid SaveVols()\n\t{\n\t\ttry { FileSystem.Data.WriteAllText( VolumeFile, Json.Serialize( _vols ) ); }\n\t\tcatch ( Exception e ) { Log.Warning( $\"SkafinityPlayer: save volumes failed: {e.Message}\" ); }\n\t}\n\n\tSystem.Collections.Generic.Dictionary<string, float> LoadVols()\n\t{\n\t\ttry\n\t\t{\n\t\t\tif ( FileSystem.Data.FileExists( VolumeFile ) )\n\t\t\t\treturn Json.Deserialize<System.Collections.Generic.Dictionary<string, float>>(\n\t\t\t\t\tFileSystem.Data.ReadAllText( VolumeFile ) ) ?? new();\n\t\t}\n\t\tcatch ( Exception e ) { Log.Warning( $\"SkafinityPlayer: load volumes failed: {e.Message}\" ); }\n\t\treturn new();\n\t}\n\n\t// \u2500\u2500 Shared house-mix config (read-only, shipped with the addon) \u2500\u2500\n\t// The SAME JSON the web toy uses (web/config.json is `make`-copied from the library's\n\t// skafinity.config.json). Its \"advanced\" block overlays the baseline peak-balance / level\n\t// mix onto every BuildConfig, so the house mix is retuned by editing one file rather than\n\t// recompiling. Read-only addon content \u2192 FileSystem.Mounted. See VibeCodec.ApplyAdvanced.\n\tconst string HouseConfigFile = \"skafinity.config.json\";\n\n\tclass HouseConfigDto { public System.Collections.Generic.Dictionary<string, float> advanced { get; set; } }\n\n\tSystem.Collections.Generic.Dictionary<string, float> LoadHouseConfig()\n\t{\n\t\ttry\n\t\t{\n\t\t\tif ( FileSystem.Mounted.FileExists( HouseConfigFile ) )\n\t\t\t{\n\t\t\t\tvar dto = Json.Deserialize<HouseConfigDto>( FileSystem.Mounted.ReadAllText( HouseConfigFile ) );\n\t\t\t\treturn dto?.advanced ?? new();\n\t\t\t}\n\t\t}\n\t\tcatch ( Exception e ) { Log.Warning( $\"SkafinityPlayer: load house config failed: {e.Message}\" ); }\n\t\treturn new();\n\t}\n}\n"
},
{
"Ident": "gamah.splitclicker",
"Path": "UI/SkafinityMusicPanel.razor",
"FileName": "SkafinityMusicPanel.razor",
"PackageType": "game",
"CodeKind": "Game",
"AssetVersionId": 301560,
"Code": "@using System\n@using System.Collections.Generic\n@using System.Linq\n@using Sandbox\n@using Sandbox.UI\n@namespace Skafinity\n@inherits PanelComponent\n\n@*\n\tThe optional drop-in settings panel for the Skafinity music engine.\n\n\tAdd this PanelComponent to a GameObject under a ScreenPanel (or WorldPanel). It finds a\n\tSkafinityPlayer in the scene (or set Player explicitly) and offers the whole knob surface\n\tas UI \u2014 you don't have to wire anything. A floating \u266a button toggles the board open/closed.\n\n\tThe engine needs nothing from this: SkafinityPlayer plays on its own. This panel is pure\n\tconvenience for players who want to tweak the vibe rather than tune it in the inspector.\n\n\tThe vibe editor is driven entirely from the library's VibeCodec field metadata for the\n\tcurrent genre: each field reports its voice (matrix row, or null for a GLOBAL knob) and\n\tcolumn (0 volume / 1 tone / 2 character / 3 extra). A new genre \u2014 or a new knob \u2014 is a pure\n\tengine change; there is no field table here. s&box has no slider widget, so each knob is a\n\tstrip of tick cells (one per base-36 level the seed encodes); each change re-encodes the\n\tvibe and restarts the player on a short debounce.\n\n\tRe-theming: the palette lives as SCSS variables at the top of SkafinityMusicPanel.razor.scss.\n\tOverride those (or supply your own panel against the same SkafinityPlayer API) to restyle.\n*@\n\n<root class=\"@( IsOpen ? \"open\" : \"\" )\">\n\t@if ( !IsOpen && ShowFab )\n\t{\n\t\t<div class=\"fab @( WideFab ? \"wide\" : \"\" )\" onclick=\"@Toggle\">@FabLabel</div>\n\t}\n\telse\n\t{\n\t@{ var cfg = Player?.EffectiveConfig(); int genre = cfg?.Genre ?? 0; }\n\t<div class=\"music\">\n\t\t<div class=\"header\">\n\t\t\t<div class=\"title\">MUSIC</div>\n\t\t\t<div class=\"close\" onclick=\"@Toggle\">\u2715</div>\n\t\t</div>\n\t\t<div class=\"divider\"></div>\n\n\t\t<div class=\"top\">\n\t\t\t<div class=\"row\">\n\t\t\t\t<div class=\"label\">NOW PLAYING</div>\n\t\t\t\t<div class=\"play-row\">\n\t\t\t\t\t<div class=\"seed-box\">@Seed()</div>\n\t\t\t\t\t<div class=\"btn\" onclick=\"@CopySeed\">@_copyLabel</div>\n\t\t\t\t</div>\n\t\t\t</div>\n\n\t\t\t<div class=\"row\">\n\t\t\t\t<div class=\"label\">SONG</div>\n\t\t\t\t<div class=\"play-row\">\n\t\t\t\t\t<div class=\"btn wide\" onclick=\"@( () => Step( -1 ) )\">\u25c0 Prev</div>\n\t\t\t\t\t<div class=\"num\">@( Player?.N ?? 0 )</div>\n\t\t\t\t\t<div class=\"btn wide\" onclick=\"@( () => Step( 1 ) )\">Next \u25b6</div>\n\t\t\t\t</div>\n\t\t\t</div>\n\n\t\t\t<div class=\"row\">\n\t\t\t\t<div class=\"label\">PLAY A SEED</div>\n\t\t\t\t<div class=\"play-row\">\n\t\t\t\t\t<TextEntry @ref=\"_tagEntry\" placeholder=\"Paste vibe:tag:n (or a tag \u2014 blank = default)\" class=\"tag-input\" />\n\t\t\t\t\t<div class=\"btn\" onclick=\"@Play\">Play</div>\n\t\t\t\t\t<div class=\"btn\" onclick=\"@UseDefault\">Use default</div>\n\t\t\t\t</div>\n\t\t\t</div>\n\n\t\t\t<div class=\"row\">\n\t\t\t\t<div class=\"label\">MUTE</div>\n\t\t\t\t<div class=\"play-row\">\n\t\t\t\t\t<div class=\"btn toggle @( Player?.Enabled == false ? \"on\" : \"\" )\" onclick=\"@ToggleMute\">@( Player?.Enabled == false ? \"MUTED\" : \"PLAYING\" )</div>\n\t\t\t\t</div>\n\t\t\t</div>\n\n\t\t\t<div class=\"row\">\n\t\t\t\t<div class=\"label\">VOLUME</div>\n\t\t\t\t<div class=\"cells\">\n\t\t\t\t\t@foreach ( var v in VolumeSteps )\n\t\t\t\t\t{\n\t\t\t\t\t\tvar vv = v;\n\t\t\t\t\t\t<div class=\"cell @( ( Player?.Volume ?? 1f ) >= vv - 0.001f ? \"filled\" : \"\" )\"\n\t\t\t\t\t\t\t onclick=\"@( () => SetVolume( vv ) )\">@VolLabel( vv )</div>\n\t\t\t\t\t}\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</div>\n\n\t\t<div class=\"divider\"></div>\n\n\t\t<div class=\"row genre-row\">\n\t\t\t<div class=\"label\">GENRE</div>\n\t\t\t<div class=\"cells\">\n\t\t\t\t@for ( int g = 0; g < VibeCodec.GenreCount; g++ )\n\t\t\t\t{\n\t\t\t\t\tvar gg = g;\n\t\t\t\t\t<div class=\"cell choice @( g == genre ? \"selected\" : \"\" )\"\n\t\t\t\t\t\t onclick=\"@( () => SetGenre( gg ) )\">@VibeCodec.Genres[g]</div>\n\t\t\t\t}\n\t\t\t\t<div class=\"btn reroll\" onclick=\"@Reroll\">\ud83c\udfb2 Reroll</div>\n\t\t\t\t<div class=\"btn reroll toggle @( Player?.RandomEverySong == true ? \"on\" : \"\" )\"\n\t\t\t\t\t onclick=\"@ToggleRandomEverySong\">\ud83c\udfb2 Random every song: @( Player?.RandomEverySong == true ? \"ON\" : \"OFF\" )</div>\n\t\t\t\t<div class=\"btn\" onclick=\"@Save\">Save .wav</div>\n\t\t\t</div>\n\t\t</div>\n\n\t\t<div class=\"label\">VIBE \u2014 per-instrument mixer (tweak, then share the seed)</div>\n\t\t<div class=\"matrix\">\n\t\t\t<div class=\"mrow mhead\">\n\t\t\t\t<div class=\"mvoice\"></div>\n\t\t\t\t@foreach ( var h in ColHeaders )\n\t\t\t\t{\n\t\t\t\t\t<div class=\"mcell mlabel\">@h</div>\n\t\t\t\t}\n\t\t\t</div>\n\t\t\t@foreach ( var (voice, cells) in InstrumentRows( genre ) )\n\t\t\t{\n\t\t\t\t<div class=\"mrow\">\n\t\t\t\t\t<div class=\"mvoice\">@voice</div>\n\t\t\t\t\t@for ( int col = 0; col < ColHeaders.Length; col++ )\n\t\t\t\t\t{\n\t\t\t\t\t\tvar f = cells[col];\n\t\t\t\t\t\t<div class=\"mcell\">\n\t\t\t\t\t\t\t@if ( f != null )\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t@Knob( f, cfg, f.Name == ColHeaders[col] ? \"\" : f.Name )\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t}\n\t\t\t\t</div>\n\t\t\t}\n\t\t</div>\n\n\t\t<div class=\"divider\"></div>\n\t\t<div class=\"label\">GLOBAL</div>\n\t\t<div class=\"vibe-grid\">\n\t\t\t@foreach ( var f in GlobalRows( genre ) )\n\t\t\t{\n\t\t\t\t<div class=\"vibe\">@Knob( f, cfg, f.Name )</div>\n\t\t\t}\n\t\t</div>\n\n\t\t@if ( _msg != null )\n\t\t{\n\t\t\t<div class=\"hint ok\">@_msg</div>\n\t\t}\n\t</div>\n\t}\n</root>\n\n@code\n{\n\t/// <summary>The player this panel drives. Leave unset to auto-find a <see cref=\"SkafinityPlayer\"/>\n\t/// in the scene on start.</summary>\n\t[Property] public SkafinityPlayer Player { get; set; }\n\n\t// One tick cell per base-36 level the seed encodes (VibeCodec.Levels).\n\tstatic int UiTicks => VibeCodec.Levels;\n\tstatic readonly string[] ColHeaders = { \"VOLUME\", \"TONE\", \"CHARACTER\", \"EXTRA\" };\n\n\t// Volume control steps over the player's 0..2 range.\n\tstatic readonly float[] VolumeSteps = { 0f, 0.25f, 0.5f, 0.75f, 1f, 1.25f, 1.5f, 1.75f, 2f };\n\n\t/// <summary>Whether the settings board is showing (toggled by the \u266a button).</summary>\n\tpublic bool IsOpen { get; private set; }\n\n\t/// <summary>Show the built-in toggle button when the board is closed. Set false when a host\n\t/// UI provides its own opener and drives the board via <see cref=\"Toggle\"/>.</summary>\n\t[Property] public bool ShowFab { get; set; } = true;\n\n\t/// <summary>Label on the closed-state toggle button. Defaults to the \u266a glyph; a host can set\n\t/// e.g. \"MUSIC\" to make it read as a labelled bar (pair with <see cref=\"WideFab\"/>).</summary>\n\t[Property] public string FabLabel { get; set; } = \"\u266a\";\n\n\t/// <summary>Render the toggle button as a wide labelled bar (host chrome) instead of the\n\t/// compact square glyph.</summary>\n\t[Property] public bool WideFab { get; set; }\n\n\tTextEntry _tagEntry;\n\tbool _tagInit;\n\tstring _copyLabel = \"Copy\";\n\tstring _msg;\n\n\tprotected override void OnStart()\n\t{\n\t\tPlayer ??= Scene.GetAllComponents<SkafinityPlayer>().FirstOrDefault();\n\t\tif ( Player == null )\n\t\t\tLog.Warning( \"SkafinityMusicPanel: no SkafinityPlayer found in the scene \u2014 add one (or set Player).\" );\n\t}\n\n\tprotected override void OnUpdate()\n\t{\n\t\t// One-time seed of the text entry with the current tag once the board opens.\n\t\tif ( !IsOpen ) { _tagInit = false; return; }\n\t\tif ( !_tagInit && _tagEntry != null ) { _tagEntry.Text = Player?.Tag ?? \"\"; _tagInit = true; }\n\t}\n\n\tstring Seed() => Player?.CurrentSeed ?? \"\u2014\";\n\n\t/// <summary>Open/close the settings board.</summary>\n\tpublic void Toggle()\n\t{\n\t\tIsOpen = !IsOpen;\n\t\tif ( !IsOpen ) _tagInit = false;\n\t}\n\n\tstatic string VolLabel( float v ) => v <= 0f ? \"0\" : $\"{(int)Math.Round( v * 100 )}%\";\n\n\t// Instrument rows for the genre: each voice with its [vol, tone, character, extra] cells\n\t// (null where a genre leaves a column empty). Order = the library's display/wire order.\n\tstatic IEnumerable<(string Voice, VibeCodec.Field[] Cells)> InstrumentRows( int genre )\n\t{\n\t\tvar order = new List<string>();\n\t\tvar byVoice = new Dictionary<string, VibeCodec.Field[]>();\n\t\tforeach ( var f in VibeCodec.Fields( genre ) )\n\t\t{\n\t\t\tif ( f.Voice == null ) continue;\n\t\t\tif ( !byVoice.TryGetValue( f.Voice, out var cells ) )\n\t\t\t{\n\t\t\t\tcells = new VibeCodec.Field[ColHeaders.Length];\n\t\t\t\tbyVoice[f.Voice] = cells;\n\t\t\t\torder.Add( f.Voice );\n\t\t\t}\n\t\t\tif ( f.Column >= 0 && f.Column < cells.Length ) cells[f.Column] = f;\n\t\t}\n\t\tforeach ( var v in order ) yield return (v, byVoice[v]);\n\t}\n\n\tstatic IEnumerable<VibeCodec.Field> GlobalRows( int genre ) =>\n\t\tVibeCodec.Fields( genre ).Where( f => f.Voice == null );\n\n\t// Index of a field within the current genre's field list (what Player.SetVibe expects).\n\tstatic int FieldIndex( int genre, VibeCodec.Field field )\n\t{\n\t\tvar fields = VibeCodec.Fields( genre );\n\t\tfor ( int i = 0; i < fields.Count; i++ )\n\t\t\tif ( ReferenceEquals( fields[i], field ) ) return i;\n\t\treturn -1;\n\t}\n\n\t// One knob: a name/value header over a strip of tick cells (or labeled choice cells).\n\tRenderFragment Knob( VibeCodec.Field f, MusicGen.Config cfg, string label )\n\t{\n\t\treturn @<text>\n\t\t<div class=\"vibe-head\">\n\t\t\t<div class=\"vibe-name\">@label</div>\n\t\t\t<div class=\"vibe-val\">@( cfg != null ? f.Display( cfg ) : \"\" )</div>\n\t\t</div>\n\t\t<div class=\"cells\">\n\t\t\t@{\n\t\t\t\tint genre = cfg?.Genre ?? 0;\n\t\t\t\tint idx = FieldIndex( genre, f );\n\t\t\t}\n\t\t\t@if ( f.Choices != null )\n\t\t\t{\n\t\t\t\tint sel = cfg != null ? (int)MathF.Round( f.GetNorm( cfg ) * (f.Choices.Length - 1) ) : 0;\n\t\t\t\t@for ( int k = 0; k < f.Choices.Length; k++ )\n\t\t\t\t{\n\t\t\t\t\tvar kk = k; var n = f.Choices.Length;\n\t\t\t\t\t<div class=\"cell choice @( k == sel ? \"selected\" : \"\" )\"\n\t\t\t\t\t\t onclick=\"@( () => SetVibe( idx, kk / (float)(n - 1) ) )\">@f.Choices[k]</div>\n\t\t\t\t}\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tint sel = cfg != null ? (int)MathF.Round( f.GetNorm( cfg ) * (UiTicks - 1) ) : 0;\n\t\t\t\t@for ( int k = 0; k < UiTicks; k++ )\n\t\t\t\t{\n\t\t\t\t\tvar kk = k;\n\t\t\t\t\t<div class=\"cell tick @( k <= sel ? \"filled\" : \"\" ) @( k == sel ? \"selected\" : \"\" )\"\n\t\t\t\t\t\t onclick=\"@( () => SetVibe( idx, kk / (float)(UiTicks - 1) ) )\"></div>\n\t\t\t\t}\n\t\t\t}\n\t\t</div>\n\t</text>;\n\t}\n\n\tvoid Step( int d ) { Player?.StepN( d ); _msg = null; }\n\n\tvoid ToggleMute() { if ( Player != null ) Player.Enabled = !Player.Enabled; }\n\n\tvoid SetVolume( float v ) { if ( Player != null ) Player.Volume = v; }\n\n\tvoid SetVibe( int index, float norm )\n\t{\n\t\tif ( index < 0 ) return;\n\t\tPlayer?.SetVibe( index, norm );\n\t\t_msg = null;\n\t}\n\n\tvoid SetGenre( int genre ) { Player?.SetGenre( genre ); _msg = null; }\n\n\tvoid Play()\n\t{\n\t\tPlayer?.PlaySeed( _tagEntry?.Text );\n\t\t_msg = Player != null ? $\"Playing {Player.CurrentSeed}\" : null;\n\t}\n\n\tvoid UseDefault()\n\t{\n\t\tPlayer?.SetTag( \"\" );\n\t\tif ( _tagEntry != null ) _tagEntry.Text = \"\";\n\t\t_msg = \"Back to the default tag and vibe\";\n\t}\n\n\tvoid CopySeed()\n\t{\n\t\ttry { Clipboard.SetText( Seed() ); _copyLabel = \"Copied!\"; }\n\t\tcatch { _copyLabel = \"\u2014\"; }\n\t}\n\n\tvoid Save()\n\t{\n\t\tvar name = Player?.SaveCurrentToFile();\n\t\t_msg = string.IsNullOrEmpty( name ) ? \"Couldn't save song\" : $\"Saved {name} to your s&box data folder\";\n\t}\n\n\tvoid Reroll() { Player?.RerollVibe(); _msg = \"Rerolled every knob but the volumes\"; }\n\n\tvoid ToggleRandomEverySong()\n\t{\n\t\tif ( Player == null ) return;\n\t\tbool on = !Player.RandomEverySong;\n\t\tPlayer.RandomEverySong = on;\n\t\tif ( on ) Player.RerollVibe();\n\t\t_msg = on ? \"Shuffle: every new song re-rolls the vibe (keeps your volumes)\" : \"Shuffle off\";\n\t}\n\n\tprotected override int BuildHash() =>\n\t\tHashCode.Combine(\n\t\t\tIsOpen, Player?.CurrentSeed, Player?.CurrentVibe, Player?.Enabled ?? true,\n\t\t\tPlayer?.Volume ?? 1f, Player?.RandomEverySong ?? false, _msg, _copyLabel );\n}\n"
},
{
"Ident": "gamah.splitclicker",
"Path": "ui/button.cs.scss",
"FileName": "button.cs.scss",
"PackageType": "game",
"CodeKind": "Game",
"AssetVersionId": 301560,
"Code": ".button\r\n{\r\n\tposition: relative;\r\n\t\r\n\t> .button-right-column\r\n\t{\r\n\t\tflex-direction: column;\r\n\t}\r\n}\r\n\r\n// default menu position is below\r\n.button-hover-menu\r\n{\r\n\tposition: absolute;\r\n\ttop: 100%;\r\n\tflex-direction: column;\r\n\r\n\t&.hidden\r\n\t{\r\n\t\topacity: 0;\r\n\t\tpointer-events: none;\r\n\t}\r\n}"
},
{
"Ident": "gamah.splitclicker",
"Path": "ui/controls/color/colorpickercontrol.cs.scss",
"FileName": "colorpickercontrol.cs.scss",
"PackageType": "game",
"CodeKind": "Game",
"AssetVersionId": 301560,
"Code": "ColorPickerControl\r\n{\r\n\tflex-direction: column;\r\n\tflex-shrink: 0;\r\n\tgap: 0.5rem;\r\n\tmargin: 1rem;\r\n}"
},
{
"Ident": "gamah.splitclicker",
"Path": "styles/rootpanel.scss",
"FileName": "rootpanel.scss",
"PackageType": "game",
"CodeKind": "Game",
"AssetVersionId": 301560,
"Code": "\r\n\r\nRootPanel\r\n{\r\n\tfont-family: Roboto;\r\n\tcolor: white;\r\n}\r\n\r\nIconPanel, i\r\n{\r\n\tfont-family: Material Icons;\r\n\tbackground-position: center;\r\n\tbackground-size: contain;\r\n}\r\n\r\n.tooltip\r\n{\r\n\tbackground-color: rgba( black, 0.8 );\r\n\tborder: 1px solid rgba( #555, 0.1 );\r\n\tpadding: 10px;\r\n\tborder-radius: 4px;\r\n\tcolor: white;\r\n\tfont-family: Inter;\r\n\tfont-weight: bold;\r\n\ttransition: opacity 0.09s ease;\r\n\topacity: 1;\r\n\tmargin: 0px;\r\n\r\n\t&:intro, &:outro\r\n\t{\r\n\t\topacity: 0;\r\n\t}\r\n}\r\n\r\n.textentry\r\n{\r\n\tcursor: text;\r\n\talign-items: center;\r\n\tjustify-content: center;\r\n\twhite-space: nowrap;\r\n\toverflow: hidden;\r\n\tflex-direction: row;\r\n\tposition: relative;\r\n\tpadding: 10px;\r\n\r\n\t&.disabled\r\n\t{\r\n\t\tcursor: default;\r\n\t}\r\n\r\n\t&.is-multiline\r\n\t{\r\n\t\talign-items: flex-start;\r\n\t\twhite-space: normal;\r\n\t}\r\n\r\n\t.content-label,\r\n\t.placeholder\r\n\t{\r\n\t\tflex-grow: 1;\r\n\t}\r\n\r\n\t.placeholder\r\n\t{\r\n\t\topacity: 0.2;\r\n\t\tpointer-events: none;\r\n\t}\r\n\r\n\t.iconpanel\r\n\t{\r\n\t\tposition: absolute;\r\n\t\tright: 0;\r\n\t\tpadding-right: 10px;\r\n\t\tpointer-events: none;\r\n\r\n\t\t&.clearbutton\r\n\t\t{\r\n\t\t\tcolor: #fff;\r\n\t\t\tpointer-events: all;\r\n\t\t\tcursor: pointer;\r\n\t\t\topacity: 0.1;\r\n\r\n\t\t\t&:hover\r\n\t\t\t{\r\n\t\t\t\topacity: 0.5;\r\n\t\t\t}\r\n\r\n\t\t\t&:active\r\n\t\t\t{\r\n\t\t\t\topacity: 1;\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n\r\n\t&:empty\r\n\t{\r\n\t\t.clearbutton\r\n\t\t{\r\n\t\t\tdisplay: none;\r\n\t\t}\r\n\t}\r\n\r\n\t&.has-icon\r\n\t{\r\n\t\tpadding-right: 30px;\r\n\t}\r\n\r\n\t.content-label,\r\n\t.prefix-label,\r\n\t.suffix-label,\r\n\t.placeholder\r\n\t{\r\n\t\toverflow: hidden;\r\n\t}\r\n\r\n\t.content-label\r\n\t{\r\n\t\torder: 1;\r\n\t}\r\n\r\n\t.suffix-label\r\n\t{\r\n\t\torder: 2;\r\n\t}\r\n\r\n\t.prefix-label,\r\n\t.suffix-label\r\n\t{\r\n\t\tflex-shrink: 0;\r\n\t\topacity: 0.5;\r\n\t}\r\n\r\n\t&.invalid\r\n\t{\r\n\t\tbox-shadow: 0px 0px 1px 2px #f8356b;\r\n\t}\r\n}\r\n\r\n.popup-panel\r\n{\r\n\tbackground-color: #1b202c;\r\n\tflex-direction: column;\r\n\tcolor: #8e9ec2;\r\n\ttransition: all 0.1s ease-out;\r\n\tposition: absolute;\r\n\tz-index: 2000;\r\n\tmin-height: 20px;\r\n\tmin-width: 10px;\r\n\toverflow: scroll;\r\n\tpointer-events: all;\r\n\tbox-shadow: 3px 3px 64px #000;\r\n\tborder-radius: 4px;\r\n\r\n\t&.medium\r\n\t{\r\n\t\tmax-width: 300px;\r\n\t}\r\n\r\n\t&.center-horizontal\r\n\t{\r\n\t\ttransform-origin: top center;\r\n\t}\r\n\r\n\t&.below-center\r\n\t{\r\n\t\ttransform: translateX( -50% );\r\n\t\ttransform-origin: 0% 0%;\r\n\t}\r\n\r\n\t&.above-left\r\n\t{\r\n\t\ttransform-origin: 0% 100%;\r\n\t}\r\n\r\n\t&.above-center\r\n\t{\r\n\t\ttransform: translateX( -50% );\r\n\t\ttransform-origin: 0% 100%;\r\n\t}\r\n\r\n\t&.left\r\n\t{\r\n\t\ttransform-origin: 100% 50%;\r\n\t\ttransform: translate( 00% -50% );\r\n\t}\r\n\r\n\t&.right\r\n\t{\r\n\t\ttransform-origin: 0 50%;\r\n\t\ttransform: translate( 0% -50% );\r\n\t}\r\n\r\n\t&:intro\r\n\t{\r\n\t\ttransform: scale( 0 );\r\n\t\tpointer-events: none;\r\n\t\topacity: 0;\r\n\t}\r\n\r\n\t&:outro\r\n\t{\r\n\t\topacity: 0;\r\n\t\ttransition: all 0.1s ease-in;\r\n\t\tmargin-top: 50px;\r\n\t\tpointer-events: none;\r\n\t}\r\n\r\n\t&.below-stretch\r\n\t{\r\n\t\ttransform-origin: 0% 0%;\r\n\r\n\t\t&:intro\r\n\t\t{\r\n\t\t\ttransform: scaleY( 0.1 );\r\n\t\t}\r\n\t}\r\n\r\n\t> .canvas\r\n\t{\r\n\t\tflex-direction: column;\r\n\t}\r\n\r\n\t.information\r\n\t{\r\n\t\tpadding: 16px;\r\n\t\tfont-size: 14px;\r\n\t\topacity: 0.5;\r\n\t}\r\n\r\n\tbutton\r\n\t{\r\n\t\tpadding: 2px 8px;\r\n\t\tcursor: pointer;\r\n\t\tfont-family: Poppins;\r\n\t\tfont-weight: 600;\r\n\t\tflex-shrink: 0;\r\n\r\n\t\t&:hover\r\n\t\t{\r\n\t\t\tbackground-color: #08f;\r\n\t\t\tcolor: #fff;\r\n\t\t}\r\n\t}\r\n}\r\n"
},
{
"Ident": "gamah.splitclicker",
"Path": "styles/form/_coloreditor.scss",
"FileName": "_coloreditor.scss",
"PackageType": "game",
"CodeKind": "Game",
"AssetVersionId": 301560,
"Code": "\r\n$primary: red !default;\r\n$primary-alt: white !default;\r\n\r\n$switch-padding: 6px !default;\r\n\r\n.coloreditor\r\n{\r\n\tflex-direction: column;\r\n\tmin-width: 250px;\r\n\tmax-width: 250px;\r\n\r\n\t> .canvas\r\n\t{\r\n\t\tborder-top: 1px solid rgba( white, 0.5 );\r\n\t\tflex-grow: 1;\r\n\t\tflex-direction: column;\r\n\t\tpadding: 10px 15px;\r\n\r\n\r\n\t\t> textentry.textentry\r\n\t\t{\r\n\t\t\tflex-grow: 0;\r\n\t\t\tflex-shrink: 0;\r\n\t\t\twidth: auto;\r\n\t\t}\r\n\t}\r\n\r\n\t.slider\r\n\t{\r\n\t\tpadding: 6.5px;\r\n\t}\r\n\r\n\t.slider .track\r\n\t{\r\n\t\theight: 14px;\r\n\t\tborder-radius: 14px;\r\n\t\tleft: 0px;\r\n\t\tright: 0px;\r\n\t\ttransition: all 0.1s ease-out;\r\n\r\n\t\t.inner\r\n\t\t{\r\n\t\t\tdisplay: none;\r\n\t\t}\r\n\t}\r\n\r\n\t.slider:hover, .slider:active\r\n\t{\r\n\t\tpadding: 7px;\r\n\r\n\t\t.track\r\n\t\t{\r\n\t\t\theight: 20px;\r\n\t\t\tborder-radius: 6px;\r\n\t\t}\r\n\r\n\t\t.thumb\r\n\t\t{\r\n\t\t\twidth: 10px;\r\n\t\t\theight: 16px;\r\n\t\t\tmargin-left: 2px;\r\n\t\t}\r\n\t}\r\n\r\n\t.slider .thumb\r\n\t{\r\n\t\twidth: 10px;\r\n\t\theight: 10px;\r\n\t\tbox-shadow: 0px 0px 0px transparent;\r\n\t\tbackground-color: white;\r\n\t\tbackdrop-filter: invert( 1 );\r\n\t\tmargin-left: 1px;\r\n\t\tpointer-events: none;\r\n\t}\r\n\r\n\t.alpha_slider .track\r\n\t{\r\n\t\tbackground-image: linear-gradient( to right, rgba( #ccc, 0.05 ), rgba( #ccc, 0.9 ) );\r\n\t}\r\n\r\n\t.hue .track\r\n\t{\r\n\t\tbackground: linear-gradient(to right, #ff0000 0%, #ffff00 17%, #00ff00 33%, #00ffff 50%, #0000ff 67%, #ff00ff 83%, #ff0000 100%);\r\n\r\n\t\t.inner\r\n\t\t{\r\n\t\t\tdisplay: none;\r\n\t\t}\r\n\t}\r\n\r\n\t.satval\r\n\t{\r\n\t\tbackground: linear-gradient(to right, white, transparent);\r\n\t\theight: 200px;\r\n\t\tposition: relative;\r\n\r\n\t\t&:active\r\n\t\t{\r\n\t\t\tz-index: 10;\r\n\t\t\tcursor: none;\r\n\t\t}\r\n\r\n\t\t.value_gradient\r\n\t\t{\r\n\t\t\tbackground: linear-gradient(to bottom, black, white);\r\n\t\t\tposition: absolute;\r\n\t\t\ttop: 0;\r\n\t\t\tleft: 0;\r\n\t\t\tright: 0;\r\n\t\t\tbottom: 0;\r\n\t\t\tpointer-events: none;\r\n\t\t\tmix-blend-mode: multiply;\r\n\t\t}\r\n\r\n\t\t.thumb\r\n\t\t{\r\n\t\t\tz-index: 10;\r\n\t\t}\r\n\t}\r\n\r\n\t.presets\r\n\t{\r\n\t\tflex-wrap: wrap;\r\n\t\talign-content: center;\r\n\t\tjustify-content: center;\r\n\t\tpadding: 15px;\r\n\t\tborder-top: 1px solid rgba( white, 0.5 );\r\n\r\n\t\t.preset\r\n\t\t{\r\n\t\t\tbackground-color: white;\r\n\t\t\twidth: 16px;\r\n\t\t\theight: 16px;\r\n\t\t\tmargin: 2px;\r\n\t\t\tborder-radius: 20px;\r\n\t\t\tcursor: pointer;\r\n\t\t\t\r\n\t\t\t&:hover\r\n\t\t\t{\r\n\t\t\t\tbox-shadow: 0px 0px 1px 2px white;\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n}\r\n\r\n\r\n.coloreditorrgba\r\n{\r\n\tflex-direction: column;\r\n\r\n\t.slider\r\n\t{\r\n\t\tpadding-right: 16px;\r\n\t}\r\n\r\n\t.slider .track\r\n\t{\r\n\t\tbackground-color: transparent;\r\n\t\theight: 14px;\r\n\t\tborder-radius: 14px;\r\n\t\tleft: 0px;\r\n\t\tright: 0px;\r\n\r\n\t\t.inner\r\n\t\t{\r\n\t\t\tdisplay: none;\r\n\t\t}\r\n\t}\r\n\r\n\t.slider .thumb\r\n\t{\r\n\t\twidth: 12px;\r\n\t\theight: 12px;\r\n\t\tbox-shadow: 0px 0px 0px transparent;\r\n\t\tbackground-color: white;\r\n\t\tmargin-left: 2px;\r\n\t\tbackdrop-filter: invert( 1 );\r\n\t}\r\n\r\n\t.red_slider .track\r\n\t{\r\n\t\tbackground-image: linear-gradient( to right, rgba( #e63439, 0.1 ), rgba( #e63439, 0.9 ) );\r\n\t}\r\n\r\n\t.green_slider .track\r\n\t{\r\n\t\tbackground-image: linear-gradient( to right, rgba( #54be35, 0.1 ), rgba( #54be35, 0.9 ) );\r\n\t}\r\n\r\n\t.blue_slider .track\r\n\t{\r\n\t\tbackground-image: linear-gradient( to right, rgba( #3472e6, 0.1 ), rgba( #3472e6, 0.9 ) );\r\n\t}\r\n\r\n\t.alpha_slider .track\r\n\t{\r\n\t\tbackground-image: linear-gradient( to right, rgba( #ccc, 0.05 ), rgba( #ccc, 0.9 ) );\r\n\t}\r\n}\r\n\r\n\r\n.coloreditorhsva\r\n{\r\n\tflex-direction: column;\r\n\r\n\t.slider\r\n\t{\r\n\t\tpadding-right: 16px;\r\n\t}\r\n\r\n\t.slider .track\r\n\t{\r\n\t\tbackground-color: transparent;\r\n\t\theight: 14px;\r\n\t\tborder-radius: 14px;\r\n\t\tleft: 0px;\r\n\t\tright: 0px;\r\n\r\n\t\t.inner\r\n\t\t{\r\n\t\t\tdisplay: none;\r\n\t\t}\r\n\t}\r\n\r\n\t.slider .thumb\r\n\t{\r\n\t\twidth: 12px;\r\n\t\theight: 12px;\r\n\t\tbox-shadow: 0px 0px 0px transparent;\r\n\t\tbackground-color: white;\r\n\t\tmargin-left: 2px;\r\n\t\tbackdrop-filter: invert( 1 );\r\n\t}\r\n\t\t\r\n\t.hue_slider .track\r\n\t{\r\n\t\tbackground-image: linear-gradient( to right, #f00, #ff0, #0f0, #0ff, #00f, #f0f, #f00 );\r\n\t}\r\n\r\n\t.alpha_slider .track\r\n\t{\r\n\t\tbackground-image: linear-gradient( to right, rgba( #ccc, 0.05 ), rgba( #ccc, 0.9 ) );\r\n\t}\r\n}"
},
{
"Ident": "gamah.splitclicker",
"Path": "ui/menupanel.razor.scss",
"FileName": "menupanel.razor.scss",
"PackageType": "game",
"CodeKind": "Game",
"AssetVersionId": 301560,
"Code": "\r\nmenupanel\r\n{\r\n position: absolute;\r\n z-index: 1000;\r\n pointer-events: all;\r\n font-size: 12px;\r\n flex-shrink: 0;\r\n\r\n .background\r\n {\r\n position: absolute;\r\n left: -5000px;\r\n right: -5000px;\r\n top: -5000px;\r\n bottom: -5000px;\r\n }\r\n\r\n > .inner\r\n {\r\n min-width: 200px;\r\n min-height: 20px;\r\n flex-direction: column;\r\n font-family: Poppins;\r\n font-weight: bold;\r\n border-radius: 10px;\r\n box-shadow: 5px 5px 30px #000e;\r\n background-color: #2a2a2a;\r\n flex-shrink: 0;\r\n\r\n .spacer\r\n {\r\n height: 1px;\r\n background-color: #0005;\r\n }\r\n\r\n .option\r\n {\r\n color: #fffa;\r\n padding: 0px 8px;\r\n cursor: pointer;\r\n flex-shrink: 0;\r\n height: 32px;\r\n\r\n &:first-child\r\n {\r\n border-top-left-radius: 10px;\r\n border-top-right-radius: 10px;\r\n }\r\n\r\n &:last-child\r\n {\r\n border-bottom-left-radius: 10px;\r\n border-bottom-right-radius: 10px;\r\n }\r\n\r\n .icon\r\n {\r\n padding: 8px;\r\n font-family: Material Icons;\r\n justify-content: center;\r\n align-items: center;\r\n flex-shrink: 0;\r\n }\r\n\r\n .text\r\n {\r\n padding: 8px;\r\n flex-shrink: 0;\r\n }\r\n\r\n &:hover\r\n {\r\n background-color: #3472e6;\r\n color: #f5f8fe;\r\n }\r\n }\r\n }\r\n}\r\n"
},
{
"Ident": "gamah.splitclicker",
"Path": "ui/controls/color/colorhuecontrol.cs.scss",
"FileName": "colorhuecontrol.cs.scss",
"PackageType": "game",
"CodeKind": "Game",
"AssetVersionId": 301560,
"Code": "ColorHueControl\r\n{\r\n\tgap: 0.5rem;\r\n\tflex-grow: 1;\r\n\tpointer-events: all;\r\n\tbackground: linear-gradient( to right, red, yellow, lime, cyan, blue, magenta, red );\r\n\tborder-radius: 4px;\r\n\tpadding: 2px;\r\n\theight: 12px;\r\n\tposition: relative;\r\n\tcursor: pointer;\r\n\tmargin: 10px 0;\r\n\tborder: 1px solid #333;\r\n\r\n\t&:hover\r\n\t{\r\n\t\tborder: 1px solid #08f;\r\n\t}\r\n\r\n\t&:active\r\n\t{\r\n\t\tborder: 1px solid #fff;\r\n\t}\r\n\r\n\t.handle\r\n\t{\r\n\t\ttop: -5px;\r\n\t\tbottom: -5px;\r\n\t\taspect-ratio: 1;\r\n\t\tborder-radius: 100px;\r\n\t\tborder: 2px solid #444;\r\n\t\tposition: absolute;\r\n\t\tbackground-color: white;\r\n\t\tbox-shadow: 2px 2px 16px #000a;\r\n\t\ttransform: translateX( -50% );\r\n\t\tpointer-events: none;\r\n\t}\r\n}\r\n"
}
]
}