1
0
Fork 0

Rewrite client-side vanilla-compat mode

Amends b8ef4d7937

Networking and playback are fixed, but client-side vanilla-compat mode
can never work as intended, because timers are actually client-side and
not synchronized at all. No matter what your local timer is set to, the
host gets to decide when to pop Jester at a completely random for your
client moment. There can be no meaningful prediction whatsoever.
This commit is contained in:
ivan tkachenko 2026-01-19 21:38:36 +02:00
parent a2cf66476c
commit 6a5cc637ac
3 changed files with 183 additions and 83 deletions

View File

@ -2,6 +2,8 @@
## MuzikaGromche 1337.9001.68 ## MuzikaGromche 1337.9001.68
- Fixed occasionally broken playback of v1337.9001.67, sorry about that.
- Turns out, client-side vanilla-compat mode can never be perfectly timed, so don't expect much without a modded host.
## MuzikaGromche 1337.9001.67 - LocalHost Edition ## MuzikaGromche 1337.9001.67 - LocalHost Edition

View File

@ -3176,47 +3176,49 @@ namespace MuzikaGromche
ChooseTrackDeferred(); ChooseTrackDeferred();
} }
bool HostSetTrack = false; // Once host has set a track via RPC, it is considered modded, and expected to always set tracks, so never reset this flag back to false.
bool HostIsModded = false;
// Playing with modded host automatically disables vanilla compatability mode // Playing with modded host automatically disables vanilla compatability mode
public bool VanillaCompatMode => IsHost ? Config.VanillaCompatMode : !HostSetTrack; public bool VanillaCompatMode => IsServer ? Config.VanillaCompatMode : !HostIsModded;
IEnumerator ChooseTrackDeferredCoroutine() IEnumerator ChooseTrackDeferredCoroutine()
{ {
yield return new WaitForEndOfFrame(); yield return new WaitForEndOfFrame();
DeferredCoroutine = null; DeferredCoroutine = null;
// Attempt to call server RPC on host, but set timeout and fallback to client-side vanilla compat mode. Plugin.Log.LogDebug($"ChooseTrack: Config.VanillaCompatMode? {Config.VanillaCompatMode}, IsServer? {IsServer}, HostIsModded? {HostIsModded}");
// Host is never running in compat mode.
HostSetTrack = false;
// vanilla compat host should skip networking, making it client-side only if (Config.VanillaCompatMode)
if (!Config.VanillaCompatMode) {
// In vanilla compat mode no, matter whether you are a host or a client, you should skip networking anyway
ChooseTrackCompat();
}
else if (IsServer)
{ {
ChooseTrackServerRpc(); ChooseTrackServerRpc();
} }
else
// Alternatively, there could be another RPC to inform clients of host's capabilities when joining the lobby.
// If host sets a track later, it would override the locally-selected one.
// The only downside of false-positive eager loading is the overhead of loading
// an extra pair of audio files and keeping them in cache until the end of round.
const float HostTimeout = 1f;
yield return new WaitForSeconds(HostTimeout);
if (!HostSetTrack)
{ {
Plugin.Log.LogInfo("Host did not set track in time, choosing locally"); // Alternatively, there could be another RPC to inform clients of host's capabilities when joining the lobby.
var vanillaPopUpTimer = gameObject.GetComponent<JesterAI>().popUpTimer; // If host sets a track later, it would override the locally-selected one.
var audioTrack = Plugin.ChooseTrackCompat(vanillaPopUpTimer); // The only downside of false-positive eager loading is the overhead of loading
// it is important to reset any previous track if no new compatible one is found // an extra pair of audio files and keeping them in cache until the end of round.
SetTrack(audioTrack?.Name); const float HostTimeout = 1f;
yield return new WaitForSeconds(HostTimeout);
if (!HostIsModded)
{
ChooseTrackCompat();
}
} }
} }
[ClientRpc] [ClientRpc]
void SetTrackClientRpc(string name) void SetTrackClientRpc(string name)
{ {
Plugin.Log.LogDebug($"SetTrackClientRpc {name}");
SetTrack(name); SetTrack(name);
HostSetTrack = true; HostIsModded = true;
} }
void SetTrack(string? name) void SetTrack(string? name)
@ -3224,6 +3226,7 @@ namespace MuzikaGromche
Plugin.Log.LogInfo($"SetTrack {name ?? "<none>"}"); Plugin.Log.LogInfo($"SetTrack {name ?? "<none>"}");
if (name != null && Plugin.FindTrackNamed(name) is { } track) if (name != null && Plugin.FindTrackNamed(name) is { } track)
{ {
// By the time it is time to start playing the intro, the clips should be done loading from disk.
AudioClipsCacheManager.LoadAudioTrack(track); AudioClipsCacheManager.LoadAudioTrack(track);
CurrentTrack = Config.OverrideCurrentTrack(track); CurrentTrack = Config.OverrideCurrentTrack(track);
} }
@ -3243,19 +3246,129 @@ namespace MuzikaGromche
SelectedTrackIndex += 1; SelectedTrackIndex += 1;
} }
internal void PlayScheduledLoop(double introStartDspTime) void ChooseTrackCompat()
{ {
double loopStartDspTime = introStartDspTime + IntroAudioSource.clip.length - IntroAudioSource.time; var vanillaPopUpTimer = gameObject.GetComponent<JesterAI>().popUpTimer;
LoopAudioSource.PlayScheduled(loopStartDspTime); Plugin.Log.LogInfo($"Vanilla compat mode, choosing track locally for timer {vanillaPopUpTimer}");
Plugin.Log.LogDebug($"Play Intro: dspTime={AudioSettings.dspTime:N4}, intro.time={IntroAudioSource.time:N4}/{IntroAudioSource.clip.length:N4}, scheduled Loop={loopStartDspTime}"); var audioTrack = Plugin.ChooseTrackCompat(vanillaPopUpTimer);
// it is important to reset any previous track if no new compatible one is found
SetTrack(audioTrack?.Name);
} }
public void Stop() // Paused == not playing. Scheduled == playing.
internal bool IsPlaying
{
get
{
if (IntroAudioSource == null || LoopAudioSource == null || IntroAudioSource.clip == null || LoopAudioSource.clip == null)
{
return false;
}
return IntroAudioSource.isPlaying;
}
}
internal bool IsPaused { get; private set; }
internal void Play(JesterAI jester)
{
if (IntroAudioSource == null || LoopAudioSource == null || CurrentTrack == null || CurrentTrack.LoadedIntro == null || CurrentTrack.LoadedLoop == null)
{
return;
}
if (IsPlaying || IsPaused)
{
return;
}
IntroAudioSource.clip = CurrentTrack.LoadedIntro;
LoopAudioSource.clip = CurrentTrack.LoadedLoop;
BeatTimeState = new BeatTimeState(CurrentTrack);
if (!VanillaCompatMode)
{
// In non-vanilla-compat mode, override the popup timer (which is shorter than the Intro audio clip)
jester.popUpTimer = CurrentTrack.WindUpTimer;
}
float IntroAudioSourceTime;
if (Config.ShouldSkipWindingPhase && !VanillaCompatMode)
{
const float rewind = 5f;
jester.popUpTimer = rewind;
IntroAudioSourceTime = CurrentTrack.WindUpTimer - rewind;
}
else
{
// reset if previously skipped winding by assigning different starting time.
IntroAudioSourceTime = 0f;
}
// Reading .time back only changes after Play(), hence a standalone variable for reliability
IntroAudioSource.time = IntroAudioSourceTime;
double dspTime = AudioSettings.dspTime;
double loopStartDspTime = dspTime + IntroAudioSource.clip.length - IntroAudioSourceTime;
Plugin.Log.LogDebug($"Play: dspTime={dspTime:N4}, intro.time={IntroAudioSourceTime:N4}/{IntroAudioSource.clip.length:N4}, scheduled loop={loopStartDspTime:N4}");
IntroAudioSource.Play();
LoopAudioSource.PlayScheduled(loopStartDspTime);
}
internal void Pause()
{
if (IntroAudioSource == null || LoopAudioSource == null || IntroAudioSource.clip == null || LoopAudioSource.clip == null)
{
return;
}
if (!IsPlaying || IsPaused)
{
return;
}
IsPaused = true;
double dspTime = AudioSettings.dspTime;
Plugin.Log.LogDebug($"Pause: dspTime={dspTime:N4}, intro.time={IntroAudioSource.time:N4}/{IntroAudioSource.clip.length:N4}, loop.time={LoopAudioSource.time:N4}/{LoopAudioSource.clip.length:N4}");
IntroAudioSource.Pause();
LoopAudioSource.Stop();
}
internal void UnPause()
{
if (IntroAudioSource == null || LoopAudioSource == null || IntroAudioSource.clip == null || LoopAudioSource.clip == null)
{
return;
}
if (!IsPaused)
{
return;
}
IsPaused = false;
double dspTime = AudioSettings.dspTime;
double loopStartDspTime = dspTime + IntroAudioSource.clip.length - IntroAudioSource.time;
Plugin.Log.LogDebug($"UnPause: dspTime={dspTime:N4}, intro.time={IntroAudioSource.time:N4}/{IntroAudioSource.clip.length:N4}, scheduled Loop={loopStartDspTime:N4}");
IntroAudioSource.UnPause();
LoopAudioSource.PlayScheduled(loopStartDspTime);
}
internal void Stop()
{ {
PoweredLightsBehaviour.Instance.ResetLightColor(); PoweredLightsBehaviour.Instance.ResetLightColor();
DiscoBallManager.Disable(); DiscoBallManager.Disable();
ScreenFiltersManager.Clear(); ScreenFiltersManager.Clear();
double dspTime = AudioSettings.dspTime;
if (IntroAudioSource != null && LoopAudioSource != null && IntroAudioSource.clip != null && LoopAudioSource.clip != null)
{
Plugin.Log.LogDebug($"Stop: dspTime={dspTime:N4}, intro.time={IntroAudioSource.time:N4}/{IntroAudioSource.clip.length:N4}, loop.time={LoopAudioSource.time:N4}/{LoopAudioSource.clip.length:N4}");
}
else
{
Plugin.Log.LogDebug($"Stop: dspTime={dspTime:N4}");
}
if (IntroAudioSource != null) if (IntroAudioSource != null)
{ {
IntroAudioSource.Stop(); IntroAudioSource.Stop();
@ -3267,6 +3380,7 @@ namespace MuzikaGromche
LoopAudioSource.clip = null; LoopAudioSource.clip = null;
} }
BeatTimeState = null; BeatTimeState = null;
IsPaused = false;
// Just in case if players have spawned multiple Jesters, // Just in case if players have spawned multiple Jesters,
// Don't reset Config.CurrentTrack to null, // Don't reset Config.CurrentTrack to null,
@ -3292,9 +3406,9 @@ namespace MuzikaGromche
[HarmonyPostfix] [HarmonyPostfix]
static void SetJesterInitialValuesPostfix(JesterAI __instance) static void SetJesterInitialValuesPostfix(JesterAI __instance)
{ {
// music will be fully stopped & reset later in the Update, so it won't trip over CurrentTrack null checks at the beginning
var behaviour = __instance.GetComponent<MuzikaGromcheJesterNetworkBehaviour>(); var behaviour = __instance.GetComponent<MuzikaGromcheJesterNetworkBehaviour>();
behaviour.Stop(); behaviour.Pause();
#if DEBUG #if DEBUG
// Almost instant follow timer // Almost instant follow timer
__instance.beginCrankingTimer = 1f + Plugin.CompatModeAllowLongerTrack; __instance.beginCrankingTimer = 1f + Plugin.CompatModeAllowLongerTrack;
@ -3355,31 +3469,36 @@ namespace MuzikaGromche
DedupLog.Clear(); DedupLog.Clear();
#endif #endif
var vanillaCompatMode = behaviour.VanillaCompatMode; var vanillaCompatMode = behaviour.VanillaCompatMode;
// If greater than zero, delay the intro to start playing later.
// If less than zero, start playing before the beginCrankingTimer runs out.
var introDelta = vanillaCompatMode ? __instance.popUpTimer - behaviour.CurrentTrack.WindUpTimer : 0;
// This switch statement resembles the one from JesterAI.Update // This switch statement resembles the one from JesterAI.Update
switch (__state.currentBehaviourStateIndex) switch (__state.currentBehaviourStateIndex)
{ {
case 0: case 0:
// implied vanillaCompatMode // Only ever consider playing audio in case 0 (roaming/following state) in vanilla-compat mode
if (__instance.targetPlayer != null && introDelta < 0 && __instance.beginCrankingTimer < -introDelta) if (vanillaCompatMode)
{ {
if (!introAudioSource.isPlaying) // The intro has to be actually longer than the wind-up timer.
// The timer was never overridden in vanilla compat mode,
// AND vanilla only decreases it in case 1 (winding state),
// so these calculations are numerically stable.
var extraAudioDuration = behaviour.CurrentTrack.WindUpTimer - __instance.popUpTimer;
if (extraAudioDuration > 0f)
{ {
introAudioSource.clip = behaviour.CurrentTrack.LoadedIntro; // The cranking timer, however, is everdecreasing in this state.
loopAudioSource.clip = behaviour.CurrentTrack.LoadedLoop; // Wait for this timer to become smaller than the extra audio length.
behaviour.BeatTimeState = new BeatTimeState(behaviour.CurrentTrack); if (__instance.beginCrankingTimer < extraAudioDuration)
{
// custom popup timer and config skip are not supported in compat mode // The audio could already be playing (since last Update)
behaviour.Play(__instance);
// reset if previously skipped winding by assigning different starting time. if (__instance.stunNormalizedTimer > 0f)
introAudioSource.time = 0f; {
behaviour.Pause();
var introStartDspTime = AudioSettings.dspTime; }
introAudioSource.PlayScheduled(introStartDspTime); else
behaviour.PlayScheduledLoop(introStartDspTime); {
behaviour.UnPause();
}
}
} }
} }
break; break;
@ -3388,50 +3507,29 @@ namespace MuzikaGromche
// Base method only starts it in the case 1 branch, no need to stop it elsewhere. // Base method only starts it in the case 1 branch, no need to stop it elsewhere.
__instance.farAudio.Stop(); __instance.farAudio.Stop();
if (__state.previousState != 1 && introDelta >= 0) if (__state.previousState != 1 && !vanillaCompatMode)
{ {
// if just started winding up // In non-vanilla-compat mode, start playing immediately upon entering case 1 (winding state)
// then stop the default music... (already done above) behaviour.Play(__instance);
// ...and set up both modded audio clips in advance }
introAudioSource.clip = behaviour.CurrentTrack.LoadedIntro; else if (vanillaCompatMode)
loopAudioSource.clip = behaviour.CurrentTrack.LoadedLoop; {
behaviour.BeatTimeState = new BeatTimeState(behaviour.CurrentTrack); // In vanilla-compat mode, the intro has to actually be no longer than the wind-up timer to be started here in case 1 (winding state).
// The Jester's pop-up timer, however, is everdecreasing in this state.
if (!vanillaCompatMode) // Wait for this timer to become smaller than the audio length.
var introDuration = behaviour.CurrentTrack.WindUpTimer;
if (__instance.popUpTimer <= introDuration)
{ {
// Set up custom popup timer, which is shorter than Intro audio behaviour.Play(__instance);
__instance.popUpTimer = behaviour.CurrentTrack.WindUpTimer;
} }
if (Config.ShouldSkipWindingPhase && !vanillaCompatMode)
{
var rewind = 5f;
__instance.popUpTimer = rewind;
introAudioSource.time = behaviour.CurrentTrack.WindUpTimer - rewind;
}
else
{
// reset if previously skipped winding by assigning different starting time.
introAudioSource.time = 0f;
}
var introStartDspTime = AudioSettings.dspTime + introDelta;
introAudioSource.PlayScheduled(introStartDspTime);
behaviour.PlayScheduledLoop(introStartDspTime);
} }
if (__instance.stunNormalizedTimer > 0f) if (__instance.stunNormalizedTimer > 0f)
{ {
introAudioSource.Pause(); behaviour.Pause();
loopAudioSource.Stop();
} }
else else
{ {
if (!introAudioSource.isPlaying) behaviour.UnPause();
{
var introStartDspTime = AudioSettings.dspTime + introDelta;
introAudioSource.UnPause();
behaviour.PlayScheduledLoop(introStartDspTime);
}
} }
break; break;
case 2: case 2:

View File

@ -19,7 +19,7 @@ All tracks are available on the web player: [ratijas.me/muzika-gromche](https://
Muzika Gromche is compatible with *Almost Vanilla™* gameplay and [*High Quota Mindset*](https://youtu.be/18RUCgQldGg?t=2553). It slightly changes Jester's wind-up timers to pop up on the drop, so won't be compatible with leaderboards. If you are a streamer™, be aware that it does play *copyrighted content.* Muzika Gromche is compatible with *Almost Vanilla™* gameplay and [*High Quota Mindset*](https://youtu.be/18RUCgQldGg?t=2553). It slightly changes Jester's wind-up timers to pop up on the drop, so won't be compatible with leaderboards. If you are a streamer™, be aware that it does play *copyrighted content.*
- **Modded host is compatible** with vanilla clients, but if a selected track's wind-up timer differs significantly from the vanilla value, vanilla clients may observe Jester pop early or hear its theme restart from the beginning. - **Modded host is compatible** with vanilla clients, but if a selected track's wind-up timer differs significantly from the vanilla value, vanilla clients may observe Jester pop early or hear its theme restart from the beginning.
- **Modded client is compatible** with vanilla host: the client will locally select the track whose wind-up timer most closely matches vanilla value, but it will never play tracks whose timer fall far outside of vanilla range. - **Modded client is compatible** with vanilla host: the client will locally select the track whose wind-up timer most closely matches vanilla value, but it will never play tracks whose timer fall far outside of vanilla range. It is impossible to time the drop perfectly, because clients don't know timer values of host.
Muzika Gromche v1337.9001.0 has been updated to work with Lethal Company v73. Previous versions of Muzika Gromche work with all Lethal Company versions from all the way back to v40 and up to v72. Muzika Gromche v1337.9001.0 has been updated to work with Lethal Company v73. Previous versions of Muzika Gromche work with all Lethal Company versions from all the way back to v40 and up to v72.