From cf4e501cbac1602489ec04c15fd755408e6eb6b1 Mon Sep 17 00:00:00 2001 From: marceld23 Date: Tue, 4 Aug 2026 18:25:22 +0200 Subject: [PATCH] fix(determinism): asteroid roll keys on the saved world seed; stable audio hashes (#719, #720) - #719: SpawnAsteroid's family/size roll mixed _config.Seed (launch flag, 0 in desktop singleplayer) instead of the persisted _meta.Seed - desktop worlds all shared the same per-field rock patterns, and relaunching a hosted world with a different/absent --seed silently re-rolled every asteroid. Regression test: reloading the same save under a different launch-config seed keeps the rocks. - #720: ProceduralAudio seeded its noise synthesis from name.GetHashCode() at 10 sites; string hashes are per-process randomised on .NET Core and differ across runtimes. Replaced with the codebase-standard stable order-dependent hash. Co-Authored-By: Claude Fable 5 --- TODO.md | 15 ++++++++ .../Scripts/ProceduralAudio.cs | 34 +++++++++++++------ .../GameServerSpaceStructure.cs | 5 +-- .../SpaceCombatTests.cs | 4 +++ 4 files changed, 46 insertions(+), 12 deletions(-) diff --git a/TODO.md b/TODO.md index d75b14de..ef0c8865 100644 --- a/TODO.md +++ b/TODO.md @@ -7099,6 +7099,21 @@ is **pre-approved** (keys in `tools/ai-assets/.env`, run via `uv`). --- +## ✅ Done (2026-08-04): seed-determinism fixes — asteroid roll keys on the saved seed; stable audio hashing (#719/#720) + +Two findings from a worldgen-determinism audit (world = seed-recomputed baseline + persisted deltas; +the audit confirmed the model holds except for a few runtime-random systems): + +- **#719 (server)** — `SpawnAsteroid`'s family/size roll mixed **`_config.Seed`** (the launch flag, + 0 on desktop singleplayer) instead of the persisted **`_meta.Seed`**. Desktop worlds therefore all + shared the same per-field family patterns, and relaunching a hosted world with a different/absent + `--seed` silently re-rolled every rock. One-line fix + regression test: reloading the same save + under a different launch-config seed must yield identical rocks. +- **#720 (client)** — `ProceduralAudio` seeded its noise synthesis from `name.GetHashCode()` at 10 + sites; string hash codes are per-process randomised on .NET Core and differ across runtimes, which + would re-roll every synthesized sound's noise character under a runtime/backend change. Replaced + with the codebase-standard stable order-dependent hash (NpcView/FloraTints convention). + ## ✅ Done (2026-08-04): server test-writing guide + de-ghosted spec citations (#571 follow-up) A contributor stepped down from #571 ("not enough business-logic context to write meaningful diff --git a/client/Assets/BlocksBeyondTheStars/Scripts/ProceduralAudio.cs b/client/Assets/BlocksBeyondTheStars/Scripts/ProceduralAudio.cs index 57ec5387..fc090e9a 100644 --- a/client/Assets/BlocksBeyondTheStars/Scripts/ProceduralAudio.cs +++ b/client/Assets/BlocksBeyondTheStars/Scripts/ProceduralAudio.cs @@ -94,6 +94,20 @@ private static AudioClip Buf(string name, float seconds, System.Action return clip; } + /// Order-dependent string hash that is stable across sessions, runtimes and machines — + /// .NET string hash codes are randomised per process, which would re-roll the noise character of + /// every synthesized sound between runs (#720; same convention as NpcView/FloraTints). + private static int StableHash(string s) + { + int h = 17; + foreach (char c in s ?? string.Empty) + { + h = unchecked(h * 31 + c); + } + + return h; + } + /// Crossfades the tail into the head so the clip loops without a click. private static void LoopFade(float[] d, int fade) { @@ -141,7 +155,7 @@ private static AudioClip Arp(string name, float dur, float vol, bool up) => Buf( private static AudioClip NoiseHit(string name, float dur, float vol, float cutoff, float decay) => Buf(name, dur, d => { - var rng = new System.Random(name.GetHashCode()); + var rng = new System.Random(StableHash(name)); float lp = 0f, a = Mathf.Clamp01(cutoff / Rate * 6f); for (int i = 0; i < d.Length; i++) { @@ -154,7 +168,7 @@ private static AudioClip NoiseHit(string name, float dur, float vol, float cutof private static AudioClip Thud(string name, float dur, float vol) => Buf(name, dur, d => { - var rng = new System.Random(name.GetHashCode()); + var rng = new System.Random(StableHash(name)); for (int i = 0; i < d.Length; i++) { float t = i / (float)Rate; @@ -182,7 +196,7 @@ private static AudioClip Clang(string name, float freq, float dur, float vol) => private static AudioClip Explosion(string name, float dur, float vol) => Buf(name, dur, d => { - var rng = new System.Random(name.GetHashCode()); + var rng = new System.Random(StableHash(name)); float lp = 0f; for (int i = 0; i < d.Length; i++) { @@ -218,7 +232,7 @@ private static AudioClip Hurt(string name) => Buf(name, 0.3f, d => private static AudioClip Thunder(string name, int variant) => Buf(name, 1.6f, d => { - var rng = new System.Random(name.GetHashCode() + variant); + var rng = new System.Random(StableHash(name) + variant); float lp = 0f; for (int i = 0; i < d.Length; i++) { @@ -286,7 +300,7 @@ private static AudioClip Shimmer(string name) => Buf(name, 0.6f, d => private static AudioClip Roar(string name, bool rising) => Buf(name, 1.6f, d => { - var rng = new System.Random(name.GetHashCode()); + var rng = new System.Random(StableHash(name)); float lp = 0f; for (int i = 0; i < d.Length; i++) { @@ -304,7 +318,7 @@ private static AudioClip Roar(string name, bool rising) => Buf(name, 1.6f, d => private static AudioClip WindLoop(string name, float dur, float vol, float cutoff, bool chirp = false, bool rumble = false) => Buf(name, dur, d => { - var rng = new System.Random(name.GetHashCode()); + var rng = new System.Random(StableHash(name)); float lp = 0f, a = Mathf.Clamp01(cutoff * 0.02f + 0.01f); for (int i = 0; i < d.Length; i++) { @@ -323,7 +337,7 @@ private static AudioClip WindLoop(string name, float dur, float vol, float cutof private static AudioClip RainLoop(string name, float vol) => Buf(name, 3f, d => { - var rng = new System.Random(name.GetHashCode()); + var rng = new System.Random(StableHash(name)); float lp = 0f; for (int i = 0; i < d.Length; i++) { @@ -337,7 +351,7 @@ private static AudioClip RainLoop(string name, float vol) => Buf(name, 3f, d => private static AudioClip BubbleLoop(string name) => Buf(name, 3f, d => { - var rng = new System.Random(name.GetHashCode()); + var rng = new System.Random(StableHash(name)); for (int i = 0; i < d.Length; i++) { float t = i / (float)Rate; @@ -351,7 +365,7 @@ private static AudioClip BubbleLoop(string name) => Buf(name, 3f, d => private static AudioClip DrillLoop(string name) => Buf(name, 1f, d => { - var rng = new System.Random(name.GetHashCode()); + var rng = new System.Random(StableHash(name)); for (int i = 0; i < d.Length; i++) { float t = i / (float)Rate; @@ -365,7 +379,7 @@ private static AudioClip DrillLoop(string name) => Buf(name, 1f, d => private static AudioClip EngineLoop(string name) => Buf(name, 1.5f, d => { - var rng = new System.Random(name.GetHashCode()); + var rng = new System.Random(StableHash(name)); float lp = 0f; for (int i = 0; i < d.Length; i++) { diff --git a/src/BlocksBeyondTheStars.GameServer/GameServerSpaceStructure.cs b/src/BlocksBeyondTheStars.GameServer/GameServerSpaceStructure.cs index 4af06812..315247e9 100644 --- a/src/BlocksBeyondTheStars.GameServer/GameServerSpaceStructure.cs +++ b/src/BlocksBeyondTheStars.GameServer/GameServerSpaceStructure.cs @@ -991,7 +991,8 @@ private static List AsteroidLoot(string family, int radius) /// Spawns one asteroid: a combat entity (for ship targeting/firing + respawn accounting) paired with /// a voxel ore structure of the same id (for rendering + EVA mining) — item 20 S3. The entity's hull tracks /// its block count so laser fire carves the rock down as it depletes. #687: the family/size roll is seeded - /// from the instance id + spawn , so the same world always grows the same rocks; + /// from the world's saved seed + instance id + spawn , so the same world always + /// grows the same rocks (#719: the SAVED seed, not the launch config's — those differ on name-seeded worlds); /// ordinal 0 is pinned to the classic metallic r=2 rock so every field guarantees one titanium core /// (mirrors the start-planet ring pin). private void SpawnAsteroid(SpaceInstance instance, Vector3f pos, int ordinal, bool broadcast) @@ -1001,7 +1002,7 @@ private void SpawnAsteroid(SpaceInstance instance, Vector3f pos, int ordinal, bo if (ordinal > 0) { long h = WorldGenerator.StableHash(instance.Id); - uint state = (uint)(h ^ (h >> 32) ^ (_config.Seed * 397L) ^ (ordinal * 668265263L)) | 1u; + uint state = (uint)(h ^ (h >> 32) ^ (_meta.Seed * 397L) ^ (ordinal * 668265263L)) | 1u; family = PickWeighted(AsteroidFamilies, ref state); radius = PickWeighted(AsteroidSizes, ref state); } diff --git a/tests/BlocksBeyondTheStars.Tests/SpaceCombatTests.cs b/tests/BlocksBeyondTheStars.Tests/SpaceCombatTests.cs index a93271d9..03ad77c4 100644 --- a/tests/BlocksBeyondTheStars.Tests/SpaceCombatTests.cs +++ b/tests/BlocksBeyondTheStars.Tests/SpaceCombatTests.cs @@ -374,6 +374,10 @@ public void SpaceAsteroids_RollSeededFamiliesAndSizes() // The same world rolls the same rocks again (deterministic — restart-safe). Assert.Equal(first, Snapshot("astroll", 1)); + // #719: the roll keys on the SAVED world seed, so reloading the same save under a different + // launch-config seed must not re-roll the rocks. + Assert.Equal(first, Snapshot("astroll", 999)); + // And across a handful of seeds the rolled rocks are NOT all clones of the classic one. var rolled = new List<(int Count, ushort Core)>(); for (long seed = 2; seed <= 5; seed++)