Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
34 changes: 24 additions & 10 deletions client/Assets/BlocksBeyondTheStars/Scripts/ProceduralAudio.cs
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,20 @@ private static AudioClip Buf(string name, float seconds, System.Action<float[]>
return clip;
}

/// <summary>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).</summary>
private static int StableHash(string s)
{
int h = 17;
foreach (char c in s ?? string.Empty)
{
h = unchecked(h * 31 + c);
}

return h;
}

/// <summary>Crossfades the tail into the head so the clip loops without a click.</summary>
private static void LoopFade(float[] d, int fade)
{
Expand Down Expand Up @@ -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++)
{
Expand All @@ -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;
Expand Down Expand Up @@ -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++)
{
Expand Down Expand Up @@ -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++)
{
Expand Down Expand Up @@ -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++)
{
Expand All @@ -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++)
{
Expand All @@ -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++)
{
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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++)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -991,7 +991,8 @@ private static List<ItemAmount> AsteroidLoot(string family, int radius)
/// <summary>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 <paramref name="ordinal"/>, so the same world always grows the same rocks;
/// from the world's saved seed + instance id + spawn <paramref name="ordinal"/>, 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).</summary>
private void SpawnAsteroid(SpaceInstance instance, Vector3f pos, int ordinal, bool broadcast)
Expand All @@ -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);
}
Expand Down
4 changes: 4 additions & 0 deletions tests/BlocksBeyondTheStars.Tests/SpaceCombatTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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++)
Expand Down