acdream/src/AcDream.Core/Audio/AmbientSoundGatherer.cs
Erik 7c4dd1ade7 feat(audio): Campaign A slice A5 — retail's region ambient soundscape
acdream had no ambient system: StartAmbient minted a handle and played
nothing. Retail's is a weighted-accumulation + timer-queue engine, not
looping voices. On every objcell change (24 m) CellManager::ChangePosition
rebuilds per-sound weights over the 3x3 landblock ring x 64 land cells
each, decoding each cell's terrain word through the region file's
terrain -> scene -> AmbientSTBDesc chain; playback is a min-heap of
absolute deadlines drained from the frame tick, where each pop fires a
one-shot and re-arms.

A continuous bed (base_chance == 0) is non-positional, crossfaded by its
share of the TOTAL weight, and re-fired every min_rate seconds — that
rate is the author's intended loop period, and re-firing is how retail
fakes a sustained bed with no looping voice, re-rolling the variant and
the crossfade each time. An intermittent one keeps its authored volume,
plays at a random accumulated compass bearing at min + (max-min)*t^2,
and is dice-gated. Indoors is silent by design: CEnvCell's contributor is
a folded ret and EnvCell carries no sound data.

The Opus review caught four bugs before this landed, one fatal:

- Cell offsets were built in ABSOLUTE world coordinates and differenced
  against the listener's STREAMED-frame position, so every one of 576
  offsets came out ~32 km, every contribution was culled, and the whole
  feature was silent with nothing logged. Offsets are now landblock-local
  the way Position::get_offset builds them, and the streamed-frame
  position is carried separately for playback, where it belongs.
- The cell's weight was added to the shared denominator once per
  DESCRIPTOR instead of once per CELL, dividing every bed's crossfade by
  the table's entry count — enough to push a typical authored volume
  under the 0.03 audibility floor.
- The drain used  where retail's UseTime is strictly
  below, so a descriptor authored with a zero rate re-armed at the same
  instant and spun the frame forever.
- Arming only enqueued; retail's UpdatePlayQueue PLAYS and then re-arms,
  so a newly audible ambient was silent for a full period after the
  crossing that made it audible.

Also: beds now go through retail's single 16-voice priority pool rather
than acdream's UI pool (retail has one pool; parking beds in the UI pool
let an A4 portal cue chop one mid-wave and discarded the authored
priority), and CalcDir's in-block test is XY-only, since CalcWeight
includes Z on purpose and CalcDir excludes it on purpose.

Two behaviours are knowingly incomplete and registered rather than
guessed at slice end: TS-66 (sky-lit interiors should keep the outdoor
set) and TS-67 (contribution weight is computed in-plane). Retires TS-29.

The frame-loop hook is a typed IAmbientFramePhase, not a callback — the
first attempt used an Action<float> and the architecture guard
ExtractedUpdateOwners_DoNotRetainAnonymousCallbacks correctly rejected it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 22:53:41 +02:00

211 lines
8.5 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Collections.Generic;
using System.Numerics;
using DatReaderWriter.DBObjs;
namespace AcDream.Core.Audio;
/// <summary>
/// Builds the live ambient set from the region file's authored data — retail's
/// <c>Ambient::InitSounds</c> plus <c>CLandBlock::add_ambient_sounds</c> @
/// <c>0x50AC10</c> and <c>LScape::add_ambient_sounds</c>.
///
/// <para>
/// <b>Where ambients come from.</b> Entirely <c>region.dat</c>:
/// <c>Region.SoundInfo.STBDesc[]</c> holds the <c>AmbientSTBDesc</c> entries,
/// <c>Region.SceneInfo.SceneTypes[i].StbIndex</c> points into that list, and
/// <c>Region.TerrainInfo.TerrainTypes[t].SceneTypes[s]</c> points at the scene
/// type. <c>0xFFFFFFFF</c> means none. There is no separate ambient dat range —
/// <c>AmbientSTBDesc.STBId</c> is an ordinary SoundTable DID.
/// </para>
///
/// <para>
/// <b>Granularity.</b> Selection is per LAND CELL, not per landblock: retail
/// walks the 8×8 cells of each landblock, decodes that cell's terrain word to
/// <c>(terrainType, sceneIndex)</c>, and positions the contribution at the
/// cell's SW vertex. Only the 3×3 landblock ring around the viewer contributes
/// (<c>LScape::add_ambient_sounds</c> feeds blocks whose
/// <c>get_block_orient</c> is ring ≤ 1).
/// </para>
///
/// <para>
/// <b>Indoors is silent.</b> <c>CEnvCell::add_ambient_sounds</c> exists in the
/// PDB but is ICF-folded onto a bare <c>ret</c>, and the EnvCell format carries
/// no sound field — corroborated independently by two research lanes. Dungeon
/// silence is retail-correct; any indoor ambient would be a new feature needing
/// a divergence row, not a port.
/// </para>
/// </summary>
public sealed class AmbientSoundGatherer
{
/// <summary>Land cells per landblock side.</summary>
public const int CellsPerSide = 8;
/// <summary>Terrain-word entries per landblock side (a 9×9 vertex grid).</summary>
private const int VerticesPerSide = 9;
/// <summary>Retail's "no entry" sentinel in the scene/STB index chain.</summary>
private const uint NoIndex = 0xFFFFFFFFu;
private readonly AmbientSoundScheduler _scheduler;
public AmbientSoundGatherer(AmbientSoundScheduler scheduler) =>
_scheduler = scheduler ?? throw new ArgumentNullException(nameof(scheduler));
/// <summary>
/// Rebuild the ambient set for a listener standing at
/// <paramref name="listenerLocalPosition"/> — the listener's LANDBLOCK-LOCAL
/// position, x and y in <c>[0, 192)</c>, as retail's <c>Position</c> carries
/// it. <paramref name="landblocks"/> supplies the terrain words for each
/// landblock in the 3×3 ring, keyed by landblock id; a missing entry simply
/// contributes nothing.
///
/// <para>
/// <b>Frames matter here.</b> Offsets are computed the way retail's
/// <c>Position::get_offset</c> / <c>LandDefs::get_block_offset</c> do —
/// landblock delta plus in-block coordinates — NOT by differencing absolute
/// world coordinates. acdream's live <c>Position</c> is in a streamed frame
/// rebased on the streaming centre, so subtracting it from an absolute cell
/// coordinate yields tens of kilometres and culls every contribution. The
/// streamed-frame listener position is still needed for PLAYBACK, but it is
/// a separate value carried by the scheduler.
/// </para>
/// </summary>
public void Rebuild(
Region region,
uint viewerLandblockId,
Vector3 listenerLocalPosition,
Func<uint, ushort[]?> landblocks,
double now)
{
ArgumentNullException.ThrowIfNull(region);
ArgumentNullException.ThrowIfNull(landblocks);
_scheduler.BeginRebuild();
uint viewerX = viewerLandblockId >> 24;
uint viewerY = (viewerLandblockId >> 16) & 0xFFu;
for (int dx = -1; dx <= 1; dx++)
{
for (int dy = -1; dy <= 1; dy++)
{
long blockX = viewerX + dx;
long blockY = viewerY + dy;
if (blockX < 0 || blockX > 0xFF || blockY < 0 || blockY > 0xFF)
continue;
uint landblockId = ((uint)blockX << 24) | ((uint)blockY << 16) | 0xFFFFu;
ushort[]? terrain = landblocks(landblockId);
if (terrain is null || terrain.Length < VerticesPerSide * VerticesPerSide)
continue;
ContributeLandblock(
region,
dx,
dy,
terrain,
listenerLocalPosition);
}
}
_scheduler.EndRebuild(now);
}
private void ContributeLandblock(
Region region,
int blockDeltaX,
int blockDeltaY,
ushort[] terrain,
Vector3 listenerLocalPosition)
{
// The ring neighbour's origin RELATIVE to the listener's own landblock.
const float landblockLength = CellsPerSide * AmbientSoundConstants.LandCellLength;
float blockOriginX = blockDeltaX * landblockLength;
float blockOriginY = blockDeltaY * landblockLength;
// 8x8 CELLS, not the 9x9 vertex grid: each cell contributes once, at its
// south-west vertex.
for (int x = 0; x < CellsPerSide; x++)
{
for (int y = 0; y < CellsPerSide; y++)
{
ushort raw = terrain[(x * VerticesPerSide) + y];
uint terrainType = (uint)((raw >> 2) & 0x1F);
uint sceneIndex = (uint)((raw >> 11) & 0x1F);
if (!TryResolveStbDesc(region, terrainType, sceneIndex, out var stb))
continue;
// Retail positions each contribution at the land cell's SW
// vertex. Z stays planar: CalcDir ignores Z outright and
// CalcWeight's Z term is the terrain height difference, which we
// do not sample here (see the register row).
var offset = new Vector3(
blockOriginX + (x * AmbientSoundConstants.LandCellLength)
- listenerLocalPosition.X,
blockOriginY + (y * AmbientSoundConstants.LandCellLength)
- listenerLocalPosition.Y,
0f);
// Cheap reject before touching the descriptor list: beyond 120 m
// the weight is zero and retail's AddSound gate drops it.
if (offset.LengthSquared() > AmbientSoundConstants.MaxDistanceSq)
continue;
// Ambient::AddSound @ 0x551610 adds this cell's weight to the
// shared denominator ONCE, then feeds every descriptor in the
// table. Adding it per descriptor would divide each bed's
// crossfade by the entry count and push quiet beds under the
// 0.03 audibility floor.
_scheduler.ContributeCell(offset, stb!, static (stb, index) =>
{
var sound = stb.AmbientSounds[index];
return new AmbientSoundDescriptor(
(SoundId)(uint)sound.SType,
sound.Volume,
sound.BaseChance,
sound.MinRate,
sound.MaxRate);
});
}
}
}
/// <summary>
/// terrain type → scene type → STB descriptor, with retail's
/// <c>0xFFFFFFFF</c> "none" sentinel honoured at each hop.
/// </summary>
private static bool TryResolveStbDesc(
Region region,
uint terrainType,
uint sceneIndex,
out DatReaderWriter.Types.AmbientSTBDesc? stb)
{
stb = null;
var terrainTypes = region.TerrainInfo?.TerrainTypes;
if (terrainTypes is null || terrainType >= terrainTypes.Count)
return false;
var sceneTypes = terrainTypes[(int)terrainType].SceneTypes;
if (sceneIndex >= sceneTypes.Count)
return false;
uint sceneTypeIndex = sceneTypes[(int)sceneIndex];
var sceneList = region.SceneInfo?.SceneTypes;
if (sceneTypeIndex == NoIndex || sceneList is null || sceneTypeIndex >= sceneList.Count)
return false;
uint stbIndex = sceneList[(int)sceneTypeIndex].StbIndex;
var descriptors = region.SoundInfo?.STBDesc;
if (stbIndex == NoIndex || descriptors is null || stbIndex >= descriptors.Count)
return false;
var candidate = descriptors[(int)stbIndex];
if (candidate.STBId == 0 || candidate.AmbientSounds.Count == 0)
return false;
stb = candidate;
return true;
}
}