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>
211 lines
8.5 KiB
C#
211 lines
8.5 KiB
C#
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;
|
||
}
|
||
}
|