Repository navigation
Expand file tree
/
Copy pathSoundPhysicsAdaptedModSystem.cs
More file actions
2193 lines (1937 loc) · 106 KB
/
Copy pathSoundPhysicsAdaptedModSystem.cs
File metadata and controls
2193 lines (1937 loc) · 106 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
using Vintagestory.API.Common;
using Vintagestory.API.Client;
using Vintagestory.API.Server;
using Vintagestory.API.MathTools;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Runtime.CompilerServices;
using System.Linq;
using System.Reflection;
using System.Text;
using HarmonyLib;
using Vintagestory.GameContent;
using soundphysicsadapted.Patches;
using Vintagestory.API.Common.Entities;
using Vintagestory.API.Datastructures;
namespace soundphysicsadapted
{
/// <summary>
/// Sound Physics Adapted - Adds realistic sound occlusion and reverb to Vintage Story
///
/// Phase 1 Features:
/// - Raycast-based sound occlusion through blocks
/// - Lowpass filtering for muffled sounds
/// - Per-sound OpenAL filters
///
/// Phase 3 Features (SPR-Style):
/// - Completely replaces vanilla reverb
/// - Multi-slot EAX reverb with different decay times
/// - Works for ALL materials (wood, glass, soil, etc.)
/// - Works for small spaces (tunnels) unlike vanilla
///
/// Algorithm based on Sound Physics Remastered (GPL)
/// </summary>
public class SoundPhysicsAdaptedModSystem : ModSystem
{
private static SoundPhysicsAdaptedModSystem instance;
private Harmony harmony;
private static SoundPhysicsConfig config;
/// <summary>
/// Version of the main config file. A config file with a different version
/// is replaced by fresh defaults. Bump this when the defaults change.
/// Keep it equal to <see cref="MaterialSoundConfig.CurrentVersion"/> — both
/// config files use one version number.
/// </summary>
private const int CurrentConfigVersion = 12;
private static MaterialSoundConfig materialConfig;
private static ICoreClientAPI clientApi;
private static AudioPhysicsSystem acousticsManager;
private static SoundPlaybackThrottle soundThrottle;
// Phase 5A: Weather audio manager
private static WeatherAudioManager weatherManager;
private long weatherTimerId = 0;
// Debug visualization (IRenderer wireframe)
private static DebugVisualization debugViz;
// === BLOCK ACCESSOR FOR THE COMPUTE CORE ===
// Every DDA step reads one block. The default accessor takes two locks per read:
// the global chunk lock in ClientWorldMap.GetChunkAtPos, and the chunk's own pack
// lock in WorldChunk.UnpackAndReadBlock. A 90 s profile put 3056 ms of the mod's
// 4465 ms main-thread cost inside those two calls — 68 percent of all mod CPU,
// and 63 percent of the whole main thread's chunk lookups.
//
// ICachingBlockAccessor keeps the last two chunks and reads their block array
// directly, so a ray that stays inside one chunk takes no lock at all. It returns
// the same block ids: both paths end at ChunkData.GetBlockId(index, layer).
//
// Begin() drops the two cached chunks. It must run at the start of every tick,
// because a chunk can unload between ticks and the cache would then hold a dead
// reference.
//
// Main thread only — this type is not thread safe. Off-thread code (the sound
// start patches) keeps using clientApi.World.BlockAccessor.
private static ICachingBlockAccessor tickBlockAccessor;
// === WORLD READINESS GATE ===
// Prevents tick handlers and Harmony patches from running expensive raycasts
// before the world is fully loaded. Without this, DDA raycasts against an
// incomplete block accessor take 100+ seconds and freeze the client.
private static bool _worldReady = false;
private static int _warmupTicksRemaining = 0;
private const int WARMUP_TICKS = 4; // ~100ms stabilization after LevelFinalize
/// <summary>Full readiness: world loaded + warmup complete. Gates heavy ACOUSTICS tick.</summary>
public static bool IsWorldReady => _worldReady && _warmupTicksRemaining <= 0;
/// <summary>Block accessor available (LevelFinalize fired). Gates cheap single-ray occlusion on sound start.</summary>
public static bool IsWorldDataLoaded => _worldReady;
// === FREEZE DIAGNOSTIC: Heartbeat + timing infrastructure ===
private static Stopwatch _diagStopwatch = new Stopwatch();
private static long _diagHeartbeatCounter = 0;
private static long _diagLastHeartbeatLogMs = 0;
private const long DIAG_HEARTBEAT_INTERVAL_MS = 5000; // Log heartbeat every 5s
private static long _diagSmoothTickCount = 0;
private static long _diagOcclusionTickCount = 0;
private static long _diagCleanupTickCount = 0;
private static double _diagSmoothTotalMs = 0;
private static double _diagOcclusionTotalMs = 0;
private static double _diagSmoothMaxMs = 0;
private static double _diagOcclusionMaxMs = 0;
// FPS tracking: measure frame time via smoothing tick (fires every 25ms game tick)
private static Stopwatch _diagFpsStopwatch = new Stopwatch();
private static long _diagFrameCount = 0;
private static double _diagFrameTimeSum = 0;
private static double _diagFrameTimeMax = 0;
// Mod compatibility detection
private static bool carryOnModLoaded = false;
private static bool surroundSoundModLoaded = false;
/// <summary>
/// True if the "Carry On" mod is loaded - changes resonator pause/resume from Shift+RMB to Ctrl+RMB
/// </summary>
public static bool CarryOnModLoaded => carryOnModLoaded;
/// <summary>
/// True if "Vintage Story Surround Sound" mod is loaded - weather audio files may be
/// replaced with 5.1 surround (6ch) OGGs. MonoDownmixManager handles N-channel downmix.
/// Their experimental RainEmitterSystem may produce double positional rain if both active.
/// </summary>
public static bool SurroundSoundModLoaded => surroundSoundModLoaded;
public static SoundPhysicsConfig Config => config;
public static MaterialSoundConfig MaterialConfig => materialConfig;
public static ICoreClientAPI ClientApi => clientApi;
public static SoundPhysicsAdaptedModSystem Instance => instance;
public static AudioPhysicsSystem Acoustics => acousticsManager;
public static WeatherAudioManager Weather => weatherManager;
public static SoundPlaybackThrottle Throttle => soundThrottle;
public const string HARMONY_ID = "com.soundphysicsadapted.mod";
public const string MOD_ID = "soundphysicsadapted";
// Networking
public static IClientNetworkChannel ClientChannel;
public static IServerNetworkChannel ServerChannel;
public override void Start(ICoreAPI api)
{
base.Start(api);
instance = this;
// Register Network Channel
api.Network.RegisterChannel("soundphysicsadapted")
.RegisterMessageType(typeof(ResonatorSyncPacket))
.RegisterMessageType(typeof(ServerHandshakePacket))
.RegisterMessageType(typeof(BoomboxSyncPacket));
// Load main configuration
try
{
config = api.LoadModConfig<SoundPhysicsConfig>("soundphysicsadapted.json");
if (config == null)
{
// First install — generate fresh defaults
config = new SoundPhysicsConfig();
config.ConfigVersion = CurrentConfigVersion;
api.Logger.Notification("[SoundPhysicsAdapted] No config found, generating defaults.");
}
else if (config.ConfigVersion > CurrentConfigVersion)
{
// Newer config (mod downgrade) gets regenerated from fresh defaults —
// it may carry values this version can't interpret, so silently
// re-stamping its version would be wrong.
api.Logger.Notification($"[SoundPhysicsAdapted] Config v{config.ConfigVersion} is newer than mod's v{CurrentConfigVersion}. Regenerating with fresh defaults.");
config = new SoundPhysicsConfig();
config.ConfigVersion = CurrentConfigVersion;
}
else if (config.ConfigVersion < CurrentConfigVersion)
{
if (UpgradeConfig(config, api.Logger))
{
MigrateConfig(config);
}
else
{
api.Logger.Notification($"[SoundPhysicsAdapted] Config v{config.ConfigVersion} has no upgrade step to v{CurrentConfigVersion}. Regenerating with fresh defaults.");
config = new SoundPhysicsConfig();
config.ConfigVersion = CurrentConfigVersion;
}
}
else
{
// Current version — clamp-only sanity pass
MigrateConfig(config);
}
api.StoreModConfig(config, "soundphysicsadapted.json");
config.RecalculateDerived();
api.Logger.Notification($"[SoundPhysicsAdapted] Config loaded (v{config.ConfigVersion}) - Enabled: {config.Enabled}, DebugMode: {config.DebugMode}, InaudibleThreshold: {config.InaudibleOcclusionThreshold:F1}");
}
catch (Exception ex)
{
api.Logger.Error($"[SoundPhysicsAdapted] Failed to load config: {ex.Message}");
config = new SoundPhysicsConfig();
config.RecalculateDerived();
}
// Load material sound configuration
try
{
materialConfig = api.LoadModConfig<MaterialSoundConfig>("soundphysicsadapted_materials.json");
if (materialConfig == null)
{
materialConfig = MaterialSoundConfig.CreateDefault();
}
else if (materialConfig.Version > MaterialSoundConfig.CurrentVersion)
{
// Newer material config (mod downgrade) — regenerate, same reason as
// the main config above.
api.Logger.Notification($"[SoundPhysicsAdapted] Material config v{materialConfig.Version} is newer than mod's v{MaterialSoundConfig.CurrentVersion}. Regenerating with fresh defaults.");
materialConfig = MaterialSoundConfig.CreateDefault();
}
else if (materialConfig.Version < MaterialSoundConfig.CurrentVersion)
{
// Upgrade the file the user has. Only entries whose default changed
// are touched. A version with no upgrade step falls back to defaults.
if (!materialConfig.Upgrade(api.Logger))
{
api.Logger.Notification($"[SoundPhysicsAdapted] Material config v{materialConfig.Version} has no upgrade step to v{MaterialSoundConfig.CurrentVersion}. Regenerating with fresh defaults.");
materialConfig = MaterialSoundConfig.CreateDefault();
}
}
// Always re-save to add any new properties from updates
api.StoreModConfig(materialConfig, "soundphysicsadapted_materials.json");
api.Logger.Notification($"[SoundPhysicsAdapted] Material config loaded - {materialConfig.Occlusion.Materials.Count} materials, {materialConfig.Occlusion.BlockOverrides.Count} overrides, {materialConfig.Occlusion.TreatAsFullCube?.Count ?? 0} full-cube patterns, {materialConfig.SoundPenetration?.Overrides?.Count ?? 0} penetration overrides");
}
catch (Exception ex)
{
api.Logger.Error($"[SoundPhysicsAdapted] Failed to load material config: {ex.Message}");
try
{
materialConfig = MaterialSoundConfig.CreateDefault();
}
catch (Exception ex2)
{
api.Logger.Error($"[SoundPhysicsAdapted] CreateDefault also failed: {ex2.Message}");
materialConfig = new MaterialSoundConfig();
}
}
// Initialize sound override manager (logs active overrides)
Core.SoundOverrideManager.Initialize(api, config);
// Optional ConfigLib integration: in-game config GUI if mod is present
if (api.ModLoader.IsModEnabled("configlib"))
new ConfigLibBridge(api, config);
}
// Server-side Harmony instance for resonator patches
private Harmony serverHarmony;
private static ICoreServerAPI serverApi;
/// <summary>
/// Distance filter for boombox sync relay (blocks).
/// Matches the point where vanilla resonator volume curve hits ~0.
/// </summary>
private const float BOOMBOX_RELAY_RANGE = 48f;
/// <summary>
/// Server-side cache of the most recent BoomboxSyncPacket per carrier (key = entity id).
/// Populated whenever a carrier sends an IsPlaying=true packet, cleared on IsPlaying=false.
/// Used to (1) sync state to late joiners and (2) push state to players who newly enter
/// relay range, since the periodic 500ms relay only reaches in-range players at send time.
/// </summary>
private static readonly Dictionary<long, BoomboxSyncPacket> activeBoomboxesByCarrier = new Dictionary<long, BoomboxSyncPacket>();
/// <summary>
/// Per-receiver tracking of which carriers each player currently has "in range" (received).
/// Used so we only resend a cached packet on rising edge (player crosses INTO range), not
/// every server tick. Keyed by receiver entity id, value = set of carrier entity ids known.
/// </summary>
private static readonly Dictionary<long, HashSet<long>> boomboxInRangeByReceiver = new Dictionary<long, HashSet<long>>();
/// <summary>
/// Server tick handle for the boombox range-entry / cleanup loop.
/// </summary>
private long boomboxServerTickId = 0;
public override void StartServerSide(ICoreServerAPI api)
{
serverApi = api;
// Detect Carry On mod on server side (needed for boombox itemstack position updates)
if (!carryOnModLoaded)
{
carryOnModLoaded = api.ModLoader.Mods.Any(m => m.Info.ModID.Equals("carryon", StringComparison.OrdinalIgnoreCase));
if (carryOnModLoaded)
{
api.Logger.Notification("[SoundPhysicsAdapted] Server: Carry On mod detected - boombox itemstack sync enabled");
}
}
// Channel + message types already registered in Start().
// Only get the channel reference and set handlers here — do NOT re-register
// message types, as duplicate RegisterMessageType calls can shift packet indices
// and cause server/client mismatch (handshake packets silently dropped).
ServerChannel = api.Network.GetChannel("soundphysicsadapted");
ServerChannel.SetMessageHandler<ResonatorSyncPacket>(OnResonatorSyncPacket);
ServerChannel.SetMessageHandler<BoomboxSyncPacket>(OnBoomboxSyncPacket);
// Apply server-side Harmony patches for resonator interaction
// This ensures OnInteract prefix fires on the server too (critical for multiplayer)
// Only apply if enabled in config
serverHarmony = new Harmony(HARMONY_ID + ".server");
if (config.EnableResonatorFix)
{
ResonatorPatches.ApplyShared(serverHarmony, api);
}
else
{
api.Logger.Notification("[SoundPhysicsAdapted] Server resonator patches DISABLED by config");
}
// Always apply persistence patches on server (ToTreeAttributes/FromTreeAttributes)
// These are normally applied via PatchAll in StartClientSide, but PatchAll cannot
// run on the server because AudioLoaderPatch targets client-only OggDecoder type.
ResonatorPatches.ApplyServer(serverHarmony, api);
// Send handshake to clients when they join
api.Event.PlayerJoin += (byPlayer) =>
{
ServerChannel.SendPacket(new ServerHandshakePacket { ModVersion = Mod.Info.Version }, byPlayer);
api.Logger.Debug($"[SoundPhysicsAdapted] Sent handshake to {byPlayer.PlayerName}");
// Sync any currently-active carried boomboxes to the joiner so they hear them
// immediately, regardless of distance. The remote handler already drift-syncs
// PlaybackPosition based on what we send here.
try
{
if (activeBoomboxesByCarrier.Count > 0 && byPlayer?.Entity != null)
{
// Snapshot to avoid mutation-during-enumeration if a packet arrives mid-loop
var snapshot = activeBoomboxesByCarrier.Values.ToArray();
int sent = 0;
foreach (var pkt in snapshot)
{
// Don't echo a carrier's own boombox to themselves
if (pkt.CarrierEntityId == byPlayer.Entity.EntityId) continue;
ServerChannel.SendPacket(pkt, byPlayer);
sent++;
}
if (sent > 0)
api.Logger.Debug($"[SoundPhysicsAdapted] Boombox join-sync: sent {sent} active carrier states to {byPlayer.PlayerName}");
}
}
catch (Exception ex)
{
api.Logger.Warning($"[SoundPhysicsAdapted] Boombox join-sync failed for {byPlayer.PlayerName}: {ex.Message}");
}
};
// Drop carrier from cache when they disconnect (otherwise stale state lingers
// and gets pushed to every future joiner).
api.Event.PlayerDisconnect += (byPlayer) =>
{
if (byPlayer?.Entity == null) return;
long eid = byPlayer.Entity.EntityId;
if (activeBoomboxesByCarrier.Remove(eid) && serverApi != null)
{
serverApi.Logger.Debug($"[SoundPhysicsAdapted] Boombox cache: removed disconnected carrier {byPlayer.PlayerName} ({eid})");
}
boomboxInRangeByReceiver.Remove(eid);
// Also forget this player as a receiver in everyone else's range sets — not
// strictly needed since lookups are keyed by receiver, but keeps memory clean.
};
// Periodic boombox range-entry tick: when a player crosses INTO BOOMBOX_RELAY_RANGE
// of an active carrier, push the cached packet to them once. This covers chunk-load
// and walking-into-range cases that the carrier's own 500ms relay misses (the relay
// only reaches players in range AT send time).
boomboxServerTickId = api.Event.RegisterGameTickListener(OnBoomboxRangeTick, 1000);
}
/// <summary>
/// Server tick (~1s) that detects players newly entering boombox relay range
/// and sends them the cached carrier state once on rising edge.
/// </summary>
private void OnBoomboxRangeTick(float dt)
{
if (serverApi == null || activeBoomboxesByCarrier.Count == 0) return;
try
{
var allPlayers = serverApi.World.AllOnlinePlayers;
// Build quick lookup of carrier entities so we can read their current position
// without trusting the cached PosX/Y/Z which may be stale by up to 500ms.
foreach (var kv in activeBoomboxesByCarrier)
{
long carrierId = kv.Key;
var pkt = kv.Value;
// Locate carrier player. If they went offline without a clean disconnect
// event, drop the entry.
IServerPlayer carrierPlr = null;
for (int i = 0; i < allPlayers.Length; i++)
{
if (allPlayers[i]?.Entity?.EntityId == carrierId)
{
carrierPlr = allPlayers[i] as IServerPlayer;
break;
}
}
if (carrierPlr?.Entity == null) continue;
var carrierPos = carrierPlr.Entity.Pos.XYZ;
for (int i = 0; i < allPlayers.Length; i++)
{
var rcv = allPlayers[i] as IServerPlayer;
if (rcv == null || rcv == carrierPlr || rcv.Entity == null) continue;
if (rcv.ConnectionState != EnumClientState.Playing) continue;
long rcvId = rcv.Entity.EntityId;
double dist = rcv.Entity.Pos.XYZ.DistanceTo(carrierPos);
bool inRange = dist <= BOOMBOX_RELAY_RANGE;
if (!boomboxInRangeByReceiver.TryGetValue(rcvId, out var known))
{
known = new HashSet<long>();
boomboxInRangeByReceiver[rcvId] = known;
}
bool wasInRange = known.Contains(carrierId);
if (inRange && !wasInRange)
{
// Rising edge — push cached packet so they pick up the music now
// instead of waiting for the next 500ms carrier relay tick.
ServerChannel.SendPacket(pkt, rcv);
known.Add(carrierId);
}
else if (!inRange && wasInRange)
{
known.Remove(carrierId);
}
}
}
}
catch (Exception ex)
{
serverApi?.Logger.Warning($"[SoundPhysicsAdapted] Boombox range-entry tick failed: {ex.Message}");
}
}
// === SERVER-SIDE PACKET VALIDATION ===
// Client packets are untrusted input in multiplayer. Without these checks a
// modified client could grow the pos-keyed dictionaries without bound, pause
// resonators anywhere on the map, or relay arbitrary strings to nearby clients
// as an AssetLocation for LoadSound.
private const double RESONATOR_SYNC_MAX_DIST = 12.0; // blocks; reach is ~5, auto-pause target is near the player
private const double BOOMBOX_SYNC_MAX_DIST = 16.0; // blocks; carrier moves while playing
private const int RESONATOR_SYNC_MAX_ENTRIES = 512; // cap on the pos-keyed stores
private const long PACKET_MIN_INTERVAL_MS = 200; // per-player rate floor (~2 Hz senders unaffected)
private static readonly Dictionary<string, long> packetLastMsByPlayer = new Dictionary<string, long>();
/// <summary>Per-player, per-packet-type rate limit. True = accept the packet.
/// Separate buckets: a playing boombox relays at ~2 Hz and must not eat the
/// resonator's budget (or vice versa).</summary>
private static bool AcceptPacketRate(IServerPlayer player, string packetKind)
{
long now = Environment.TickCount64;
string key = packetKind + ":" + player.PlayerUID;
if (packetLastMsByPlayer.TryGetValue(key, out long last) && now - last < PACKET_MIN_INTERVAL_MS)
{
return false;
}
packetLastMsByPlayer[key] = now;
return true;
}
/// <summary>
/// Track strings are relayed to every nearby client and fed to AssetLocation /
/// LoadSound there. VS asset codes are lowercase alphanumerics plus :/._- —
/// anything else never came from a legit client.
/// </summary>
private static bool ValidTrackLocation(string loc)
{
if (string.IsNullOrEmpty(loc)) return true; // empty = "stopped playing", legitimate
if (loc.Length > 256) return false;
foreach (char c in loc)
{
bool ok = (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')
|| c == ':' || c == '/' || c == '.' || c == '_' || c == '-';
if (!ok) return false;
}
return true;
}
private void OnResonatorSyncPacket(IServerPlayer player, ResonatorSyncPacket packet)
{
if (player?.Entity?.World?.BlockAccessor == null || packet?.Pos == null) return;
if (!AcceptPacketRate(player, "resonator")) return;
// Trust boundary: the sender must be near the resonator they claim to sync.
var resonatorCenter = new Vec3d(packet.Pos.X + 0.5, packet.Pos.Y + 0.5, packet.Pos.Z + 0.5);
double dist = player.Entity.Pos.XYZ.DistanceTo(resonatorCenter);
if (dist > RESONATOR_SYNC_MAX_DIST)
{
player.Entity.World.Logger.Debug(
$"[SoundPhysicsAdapted] Rejected resonator sync from {player.PlayerName}: {dist:F0} blocks from {packet.Pos}");
return;
}
// Bound the pos-keyed stores: a hostile client must not be able to grow
// them without bound. Known positions keep updating; new ones need room.
if (ResonatorPatches.serverPositionsByPos.Count >= RESONATOR_SYNC_MAX_ENTRIES
&& !ResonatorPatches.serverPositionsByPos.ContainsKey(packet.Pos))
{
player.Entity.World.Logger.Warning(
$"[SoundPhysicsAdapted] Resonator sync store full ({RESONATOR_SYNC_MAX_ENTRIES}) — dropping sync for {packet.Pos}");
return;
}
// ALWAYS store by BlockPos first - this survives chunk reload
// The weak table entries are lost when chunk unloads (instance destroyed)
ResonatorPatches.serverPositionsByPos[packet.Pos.Copy()] = packet.PlaybackPosition;
ResonatorPatches.serverPausedByPos[packet.Pos.Copy()] = packet.IsPaused;
ResonatorPatches.serverRotationsByPos[packet.Pos.Copy()] = packet.FrozenRotation;
if (SoundPhysicsAdaptedModSystem.IsDebugEnabled)
player.Entity.World.Logger.Debug($"[SoundPhysicsAdapted] Server received sync: pos={packet.Pos}, position={packet.PlaybackPosition:F2}s, isPaused={packet.IsPaused}, rotation={packet.FrozenRotation:F2}");
BlockEntity be = player.Entity.World.BlockAccessor.GetBlockEntity(packet.Pos);
if (be is BlockEntityResonator resonator)
{
// Also update weak table for current instance (used during same session)
ResonatorPatches.savedPositions.Remove(resonator);
ResonatorPatches.savedPositions.Add(resonator, new PlaybackPosition(packet.PlaybackPosition));
// Update paused state for persistence
ResonatorPatches.pausedStates.Remove(resonator);
ResonatorPatches.pausedStates.Add(resonator, new PausedState(packet.IsPaused));
// If a client auto-paused this resonator (because they started another one),
// the server's OnInteractPrefix won't have run for this resonator and IsPlaying
// is still true. Apply the IsPaused=true intent here. We gate on the explicit
// RequestServerAutoPause flag so a regular Ctrl+RMB pause (which DOES run server
// OnInteract) doesn't double-toggle and accidentally restart playback.
if (packet.RequestServerAutoPause && packet.IsPaused && resonator.IsPlaying)
{
try
{
resonator.IsPlaying = false;
HarmonyLib.AccessTools.Method(typeof(BlockEntityResonator), "StopMusic")?.Invoke(resonator, null);
resonator.MarkDirty(true);
player.Entity.World.Logger.Debug($"[SoundPhysicsAdapted] Server applied auto-pause for {packet.Pos} from client {player.PlayerName}");
}
catch (Exception ex)
{
player.Entity.World.Logger.Warning($"[SoundPhysicsAdapted] Server auto-pause apply failed at {packet.Pos}: {ex.Message}");
}
}
}
}
/// <summary>
/// Server relay for boombox sync: forward to nearby players (except sender).
/// </summary>
private void OnBoomboxSyncPacket(IServerPlayer sender, BoomboxSyncPacket packet)
{
if (sender?.Entity == null || serverApi == null) return;
if (!AcceptPacketRate(sender, "boombox")) return;
var senderPos = sender.Entity.Pos.XYZ;
// Trust boundary: the sender must be near the carrier position it reports.
var claimedPos = new Vec3d(packet.PosX, packet.PosY, packet.PosZ);
if (senderPos.DistanceTo(claimedPos) > BOOMBOX_SYNC_MAX_DIST)
{
serverApi.Logger.Debug(
$"[SoundPhysicsAdapted] Rejected boombox sync from {sender.PlayerName}: {senderPos.DistanceTo(claimedPos):F0} blocks from reported carrier");
return;
}
// The track string reaches every nearby client and is fed to
// AssetLocation/LoadSound there. Reject anything that is not a VS asset code.
if (!ValidTrackLocation(packet.TrackLocation))
{
serverApi.Logger.Debug(
$"[SoundPhysicsAdapted] Rejected boombox sync from {sender.PlayerName}: malformed track location '{packet.TrackLocation}'");
return;
}
long carrierId = sender.Entity.EntityId;
// Maintain the active-carrier cache used by join-sync and range-entry sync.
// IsPlaying=false is sent on placement / drop; remove the entry then so we
// don't keep replaying a stopped boombox to future joiners.
if (packet.IsPlaying)
{
// Defensive: ensure the cached packet always carries the correct carrier id
// even if a buggy client sent 0 (matches what receivers expect).
if (packet.CarrierEntityId == 0) packet.CarrierEntityId = carrierId;
activeBoomboxesByCarrier[carrierId] = packet;
}
else
{
activeBoomboxesByCarrier.Remove(carrierId);
// Forget receivers' "in range" memory for this carrier so a future pickup
// re-triggers the rising-edge push.
foreach (var set in boomboxInRangeByReceiver.Values) set.Remove(carrierId);
}
var allPlayers = serverApi.World.AllOnlinePlayers;
int relayCount = 0;
for (int i = 0; i < allPlayers.Length; i++)
{
var plr = allPlayers[i] as IServerPlayer;
if (plr == null || plr == sender || plr.Entity == null) continue;
double dist = plr.Entity.Pos.XYZ.DistanceTo(senderPos);
if (dist <= BOOMBOX_RELAY_RANGE)
{
ServerChannel.SendPacket(packet, plr);
relayCount++;
// Mark this receiver as currently knowing about this carrier so the
// periodic range-entry tick doesn't double-send.
if (packet.IsPlaying)
{
long rcvId = plr.Entity.EntityId;
if (!boomboxInRangeByReceiver.TryGetValue(rcvId, out var known))
{
known = new HashSet<long>();
boomboxInRangeByReceiver[rcvId] = known;
}
known.Add(carrierId);
}
}
}
// Per-relay packet log — 2 Hz per playing boombox in normal play, so it
// stays behind the debug gate instead of flooding the server log.
if (SoundPhysicsAdaptedModSystem.IsDebugEnabled)
serverApi.Logger.Debug($"[SoundPhysicsAdapted] Boombox relay: {sender.PlayerName} playing={packet.IsPlaying} track={packet.TrackLocation} pos=({packet.PosX:F0},{packet.PosY:F0},{packet.PosZ:F0}) relayed to {relayCount} players, cache={activeBoomboxesByCarrier.Count}");
// Sync the playback position to the carrier's Itemstack so that when the boombox
// is placed down, it starts from the correct position.
UpdateCarrierItemStackPosition(sender.Entity, packet.PlaybackPosition);
}
/// <summary>
/// Update the saved playback position on the carrier's Itemstack while they are carrying it.
/// </summary>
private void UpdateCarrierItemStackPosition(Entity carrier, float playbackPosition)
{
if (carrier == null || !carryOnModLoaded) return;
try
{
var carriedAttr = carrier.WatchedAttributes.GetTreeAttribute("carryon:Carried");
var carriedDataAttr = carrier.Attributes.GetTreeAttribute("carryon:Carried");
if (carriedAttr == null && carriedDataAttr == null) return;
bool updated = false;
// Check Hands slot first
if (UpdateSlotPlaybackPosition(carrier, carriedAttr, carriedDataAttr, "Hands", playbackPosition))
{
updated = true;
}
// Check Back slot
else if (UpdateSlotPlaybackPosition(carrier, carriedAttr, carriedDataAttr, "Back", playbackPosition))
{
updated = true;
}
if (updated)
{
// Force the attribute to sync
carrier.WatchedAttributes.MarkPathDirty("carryon:Carried");
}
}
catch (Exception ex)
{
serverApi?.Logger.Warning($"[SoundPhysicsAdapted] Error updating carrier itemstack position: {ex.Message}");
}
}
private bool UpdateSlotPlaybackPosition(Entity carrier, ITreeAttribute carriedAttr, ITreeAttribute carriedDataAttr, string slotName, float playbackPosition)
{
var slotAttr = carriedAttr?.GetTreeAttribute(slotName);
if (slotAttr == null) return false;
var stack = slotAttr.GetItemstack("Stack");
if (stack == null) return false;
// Resolve the block to check if it's a resonator
stack.ResolveBlockOrItem(carrier.World);
if (stack.Block == null || !(stack.Block.Code?.Path?.Contains("resonator") == true)) return false;
// Found a resonator. Update its savedPlaybackPos in the block entity data.
stack.Attributes.SetFloat("savedPlaybackPos", playbackPosition);
stack.Attributes.SetBool("isPaused", false);
// Set the modified stack back to the tree
slotAttr.SetItemstack("Stack", stack);
var dataSlotAttr = carriedDataAttr?.GetTreeAttribute(slotName);
var blockEntityData = dataSlotAttr?.GetTreeAttribute("Data");
if (blockEntityData != null)
{
blockEntityData.SetFloat("savedPlaybackPos", playbackPosition);
blockEntityData.SetBool("isPaused", false);
}
return true;
}
private void OnServerHandshake(ServerHandshakePacket packet)
{
ResonatorPatches.ServerHasMod = true;
clientApi?.Logger.Notification($"[SoundPhysicsAdapted] Server has mod v{packet.ModVersion} — resonator pause/resume enabled.");
}
// Cleanup timer
private long cleanupTimerId = 0;
private const float CLEANUP_INTERVAL_SEC = 1.0f;
// Occlusion update timer - AcousticsManager handles interval logic internally
private long occlusionUpdateTimerId = 0;
private const float OCCLUSION_UPDATE_INTERVAL_MS = 50f; // 20 times per second
// Smoothing tick - decoupled from raycast, runs fast for all sounds
private long smoothingTimerId = 0;
public override void StartClientSide(ICoreClientAPI api)
{
base.StartClientSide(api);
clientApi = api;
// Channel + message types already registered in Start().
// Do NOT re-register — duplicate RegisterMessageType shifts packet indices.
ClientChannel = api.Network.GetChannel("soundphysicsadapted");
// Listen for server handshake — confirms server has the mod
ClientChannel.SetMessageHandler<ServerHandshakePacket>(OnServerHandshake);
// Listen for boombox sync from other players carrying resonators
ClientChannel.SetMessageHandler<BoomboxSyncPacket>(BoomboxRemoteHandler.OnBoomboxSyncReceived);
BoomboxRemoteHandler.Initialize(api);
// In singleplayer, client and server share the same process
// so the server-side patches already cover both sides.
// Set the flag immediately for SP.
if (api.IsSinglePlayer)
{
ResonatorPatches.ServerHasMod = true;
}
// Detect Carry On mod - it hijacks Shift+RMB for block pickup, conflicting with our resonator pause/resume
carryOnModLoaded = api.ModLoader.Mods.Any(m => m.Info.ModID.Equals("carryon", StringComparison.OrdinalIgnoreCase));
if (carryOnModLoaded)
{
api.Logger.Notification("[SoundPhysicsAdapted] Carry On mod detected - resonator pause/resume changed to Ctrl+RMB");
}
// Detect Surround Sound mod - replaces vanilla weather OGGs with 5.1 surround (6ch).
// Our N-channel MonoDownmixManager handles the 6ch->mono conversion for positional sources.
// Their experimental RainEmitterSystem may cause double positional rain alongside Phase 5B.
surroundSoundModLoaded = api.ModLoader.IsModEnabled("vintagestorysurroundsound");
if (surroundSoundModLoaded)
{
api.Logger.Notification("[SoundPhysicsAdapted] Surround Sound mod detected - weather audio may be 5.1 surround, N-channel mono downmix active");
api.Logger.Warning("[SoundPhysicsAdapted] If Surround Sound's experimental rain emitters are enabled alongside SPA positional weather, double rain audio may occur. Disable one system for best results.");
}
// Apply Harmony patches (client-side only - sounds are client-side)
// Using manual patching since ClientMain is internal to VintagestoryLib
try
{
harmony = new Harmony(HARMONY_ID);
// Phase 1: Occlusion patches (manual - targets internal types)
LoadSoundPatch.ApplyPatches(harmony, api);
// Resonator patches (consolidated - logic, audio, networking)
// Only apply if enabled in config
if (config.EnableResonatorFix)
{
// Carry On compatibility: Boombox feature
// MUST be applied BEFORE ResonatorPatches so its StopMusicPrefix runs first
// (checks pausingResonators before ResonatorPatches clears it)
if (carryOnModLoaded)
{
CarryOnCompatPatches.ApplyPatches(harmony, api);
}
ResonatorPatches.ApplyShared(harmony, api);
ResonatorPatches.ApplyClient(harmony, api);
}
else
{
api.Logger.Notification("[SoundPhysicsAdapted] Resonator enhancements DISABLED by config");
}
// Phase 3: Reverb patches (disable vanilla, enable our system)
ReverbPatch.ApplyPatches(harmony, api);
// Phase 5A: Weather sound suppression patches
WeatherSoundPatches.ApplyPatches(harmony, api);
// Ambient sound bbox face-sampling patches (beehive/water occlusion fix)
AmbientSoundPatches.ApplyPatches(harmony, api);
// Block ambient injection: rain surface impacts + torch ambient
// Patches GetAmbientSoundStrength to control when injected sounds play
BlockAmbientInjector.ApplyPatches(harmony, api);
}
catch (Exception ex)
{
api.Logger.Error("[SoundPhysicsAdapted] Failed to apply manual Harmony patches: " + ex.Message);
api.Logger.Error("[SoundPhysicsAdapted] Stack: " + ex.StackTrace);
}
// Attribute-based patches (AudioLoaderPatch etc.) - isolated so failures
// here can't block the manual patches above
try
{
harmony.PatchAll(Assembly.GetExecutingAssembly());
}
catch (Exception ex)
{
api.Logger.Warning($"[SoundPhysicsAdapted] PatchAll failed (non-critical): {ex.Message}");
}
// Block ambient injection is deferred to LevelFinalize (below)
// because api.World.Blocks is empty during StartClientSide on servers.
// Note: Universal mono downmix is handled by MonoDownmixManager +
// LoadSoundPatch.StartPlayingAudioMonoPrefix (auto-detects positional stereo sounds)
// and LoadSoundPatch.LoadSoundMonoPrefix (explicit requests from weather/resonator)
// Initialize AudioPhysicsSystem (optimization: time-slicing, sky probe, static cache)
acousticsManager = new AudioPhysicsSystem();
// Initialize sound playback throttle (limits concurrent OpenAL sources)
soundThrottle = new SoundPlaybackThrottle();
// Register periodic cleanup for disposed sounds
if (AudioRenderer.IsInitialized)
{
cleanupTimerId = api.Event.RegisterGameTickListener(OnCleanupTick, (int)(CLEANUP_INTERVAL_SEC * 1000));
occlusionUpdateTimerId = api.Event.RegisterGameTickListener(OnOcclusionUpdateTick, (int)OCCLUSION_UPDATE_INTERVAL_MS);
smoothingTimerId = api.Event.RegisterGameTickListener(OnSmoothingTick, (int)AudioRenderer.SmoothTickIntervalMs);
api.Logger.Debug("[SoundPhysicsAdapted] Registered cleanup, occlusion, and smoothing tick handlers");
_diagLastHeartbeatLogMs = api.ElapsedMilliseconds;
// Optimization: Invalidate cache on block changes
api.Event.BlockChanged += OnBlockChanged;
api.Logger.Debug("[SoundPhysicsAdapted] Hooked BlockChanged event");
}
// WORLD READINESS: Defer all heavy processing until LevelFinalize.
// The block accessor is incomplete/slow before this fires, causing 100s+ freezes
// in DDA raycasts. player != null is NOT sufficient — entity exists before chunks load.
api.Event.LevelFinalize += () =>
{
_worldReady = true;
_warmupTicksRemaining = WARMUP_TICKS;
api.Logger.Notification("[SoundPhysicsAdapted] World ready — warmup started, raycasting deferred");
// Block ambient injection: inject Sounds.Ambient onto matching block types.
// Must run here (not StartClientSide) because api.World.Blocks is empty on
// multiplayer servers until blocks are received from the server.
BlockAmbientInjector.InjectAmbientSounds(api);
// DIAGNOSTIC: Dump beehive entries from soundAudioData after a delay.
// Debug-gated: it walks the whole soundAudioData dictionary via
// reflection + dynamic, which ran on EVERY world join regardless of
// DebugMode and stalled joins on mod-heavy installs.
if (config.DebugMode)
{
long diagId = 0;
diagId = api.Event.RegisterGameTickListener((float dt) =>
{
api.Event.UnregisterGameTickListener(diagId);
DumpBeehiveSoundDiag(api);
}, 3000); // 3 second delay to let CatalogSounds finish
}
};
// Phase 3: Initialize reverb effect system
if (config.EnableCustomReverb)
{
if (ReverbEffects.Initialize())
{
api.Logger.Notification("[SoundPhysicsAdapted] Reverb effects initialized");
}
else
{
api.Logger.Warning("[SoundPhysicsAdapted] Reverb effects initialization failed - reverb disabled");
config.EnableCustomReverb = false;
}
}
// Phase 5A: Initialize weather audio manager
if (config.EnableWeatherEnhancement && WeatherSoundPatches.PatchesApplied)
{
weatherManager = new WeatherAudioManager(api);
if (weatherManager.Initialize())
{
weatherTimerId = api.Event.RegisterGameTickListener(OnWeatherTick, config.WeatherTickIntervalMs);
api.Logger.Notification("[SoundPhysicsAdapted] Weather audio system initialized");
}
else
{
api.Logger.Warning("[SoundPhysicsAdapted] Weather audio initialization failed");
weatherManager = null;
}
}
// Initialize debug visualization (IRenderer wireframe)
debugViz = new DebugVisualization(api);
// Register debug commands
RegisterCommands(api);
string reverbStatus = config.EnableCustomReverb
? (ReverbPatch.VanillaReverbDisabled ? "SPR-style reverb ENABLED (vanilla disabled)" : "Reverb enabled (vanilla NOT disabled)")
: "Reverb disabled";
api.Logger.Notification($"[SoundPhysicsAdapted] Initialized - Occlusion enabled, {reverbStatus}");
}
// === Master toggle lifecycle ===
// Last state of config.Enabled that we actually acted on. null = nothing applied
// yet (startup), so the first observation only records the state.
private static bool? appliedEnabledState = null;
/// <summary>
/// Detect a change of the master toggle and run the matching transition once.
///
/// The per-tick guards elsewhere only stop NEW work. They cannot undo the OpenAL
/// state already written to sounds that are playing right now, so the OFF edge
/// needs an explicit restore.
/// </summary>
private void SyncEnabledState()
{
if (config == null) return;
bool nowEnabled = config.Enabled;
if (appliedEnabledState == nowEnabled) return;
bool firstObservation = appliedEnabledState == null;
appliedEnabledState = nowEnabled;
// Startup: nothing of ours is applied yet, so there is nothing to undo.
if (firstObservation) return;
if (nowEnabled) ReArmAfterToggle();
else RestoreVanillaState();
}
/// <summary>
/// Give the game back to vanilla audio. Undoes every per-source change we made
/// to sounds that are playing now; the suppression patches stand down on their
/// own because they all read config.Enabled.
/// </summary>
private void RestoreVanillaState()
{
int sources = AudioRenderer.RestoreAllToVanilla();
int distanceOverrides = LoadSoundPatch.RestoreVanillaDistanceModel();
// Our replacement weather/thunder sounds must stop, and the patches that
// zero the vanilla loops now let VS control its own volumes again.
weatherManager?.StopAllReplacements();
// New sounds load as vanilla stereo again. Buffers already downmixed to
// mono stay mono until the game reloads that asset.
MonoDownmixManager.ClearCache();
acousticsManager?.CellCache?.Clear();
OcclusionCalculator.ClearCache();
soundThrottle?.Clear();
clientApi?.Logger.Notification(
$"[SoundPhysicsAdapted] Master toggle OFF — vanilla audio restored " +
$"({sources} live sources cleared, {distanceOverrides} distance overrides reverted). " +
$"Sound-file overrides and injected block ambients are applied at asset load and stay until restart.");
}
/// <summary>
/// Resume our processing. The ticks compute real values again within one raycast
/// interval; this only puts our filter objects back and drops stale geometry
/// caches so the first result reflects the world as it is now.
/// </summary>
private void ReArmAfterToggle()
{
OcclusionCalculator.ClearCache();
acousticsManager?.CellCache?.Clear();
int reattached = AudioRenderer.ReattachAllFilters();