feat(audio): Campaign A slice A2 — retail's 2D pan+gain mixer replaces AL 3D
Retail is not a 3D audio engine. Every gameplay buffer is created with m_3D = 0 and the DirectSound 3D listener the client sets up is dead code; spatialization is two CPU scalars per voice, frozen at emission. This slice ports that math and demotes OpenAL to a voice bank. RetailSoundMixer (new, Core) carries the byte-decoded curve from SoundManager::GetAttenuation @0x00550020: g = dist < 5 ? vol : 25*vol/d2, clamped to 1 BEFORE the single master multiply, db = ceil(20*log10 g), with a hard -50 dB floor at which retail does not start the voice at all (audible radius ~94.2 m at unity). Pan is PlaySoundInternal @0x00550170's (int)(-15*sin(delta-bearing)) in whole decibels, truncating toward zero, forced to dead centre when (int)distance < 5, with no front/back and no elevation cue. Every AL source is now source-relative with rolloff 0 and the global distance model is None: AL's InverseDistanceClamped was first-power (2/d), quieter than retail up close and far louder at range with no cutoff whatsoever. That was the largest audible divergence in the subsystem (AP-28, retired here). RetailVoicePool (new, Core) ports the allocator at 0x0054FEC0: ring scan for a free or finished slot, then evict the first slot whose DAT priority is strictly lower, else drop. Eviction compared GAIN before, so a loud unimportant sound could silence a quiet important one. It lives in Core because the engine's play path talks to native AL handles and could not be tested; the pool now has 12 conformance tests. The listener keeps using the camera position, which the decode shows is retail-faithful (SmartBox::set_viewer @0x00452D36 hands the same collided camera Position to SoundManager) — only the heading extraction changes, since retail reads one compass bearing and never a forward/up basis. An earlier draft of the plan called this a defect; corrected in the plan so it is not fixed backwards. Opus review found and this commit fixes: a linear pan-to-azimuth mapping that saturated to full separation at 30 degrees (OpenAL Soft's own speaker angle) where retail gives 15 dB — now inverts the constant-power pan law, so full deflection reaches 0.776 of the arc and both channels stay live; the stale FUN_00550ad0 / gain-eviction class header, which contradicted the register row this commit writes; missing discriminating tests for clamp order and pan truncation; dead PlayingGain state whose comment invented a retail symbol; and a third in-tree copy of Position::heading, now delegating to MoveToMath.PositionHeading. MasterVolume folds into the mixer's one multiply instead of AL listener gain, so the cutoff, radius and dB quantisation move with the slider. Register: AP-28 retired; AP-173 (pan law), AP-174 (volume taxonomy), TS-64 (two unimplemented sound prefs), TS-65 (volume-squared quirk, applied on the ambient path only) filed. Research note corrected twice where its summary contradicted its own decode (30 m dB, floor vs trunc). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
c69b3bde04
commit
e42b99482e
13 changed files with 1123 additions and 106 deletions
|
|
@ -39,32 +39,12 @@ public sealed class WaveData
|
|||
public TimeSpan Duration { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Falloff math (r05 §2). Retail is CPU-side inverse-square, NOT
|
||||
/// DirectSound3DBuffer. No doppler, no cone, no HRTF.
|
||||
/// </summary>
|
||||
public static class AudioFalloff
|
||||
{
|
||||
/// <summary>
|
||||
/// Attenuation factor based on distance. Retail uses pure inverse-square
|
||||
/// above a minimum-distance threshold.
|
||||
/// </summary>
|
||||
public static float AttenuationAt(float distanceMeters, float minDistance = 1.0f)
|
||||
{
|
||||
if (distanceMeters < minDistance) return 1.0f;
|
||||
float att = (minDistance * minDistance) / (distanceMeters * distanceMeters);
|
||||
return Math.Clamp(att, 0f, 1f);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stereo pan from listener-relative X coord. ±1.0 fully panned.
|
||||
/// </summary>
|
||||
public static float PanFromRelative(float relativeX, float panRange = 20f)
|
||||
{
|
||||
if (panRange <= 0) return 0f;
|
||||
return Math.Clamp(relativeX / panRange, -1f, 1f);
|
||||
}
|
||||
}
|
||||
// An `AudioFalloff` helper lived here until 2026-08-08 (Campaign A slice A2).
|
||||
// It had the right shape but a 1-metre reference distance where retail's is 5,
|
||||
// a `PanFromRelative` that was linear in relative X over an invented 20-metre
|
||||
// range where retail pans by the SINE of a compass bearing, and no audibility
|
||||
// cutoff. Nothing ever called either method. Both are superseded by
|
||||
// `RetailSoundMixer`, which carries the byte-decoded retail math.
|
||||
|
||||
/// <summary>
|
||||
/// Interface the platform audio engine (AcDream.App layer) implements.
|
||||
|
|
@ -78,10 +58,17 @@ public interface IAudioEngine : IDisposable
|
|||
float MusicVolume { get; set; }
|
||||
float AmbientVolume{ get; set; }
|
||||
|
||||
/// <summary>Update listener pose (called per frame from player position).</summary>
|
||||
void SetListener(float posX, float posY, float posZ,
|
||||
float forwardX, float forwardY, float forwardZ,
|
||||
float upX, float upY, float upZ);
|
||||
/// <summary>
|
||||
/// Update the listener pose. Retail's listener is <c>SmartBox::viewer</c> —
|
||||
/// the COLLIDED third-person camera Position, refreshed once per rendered
|
||||
/// frame (<c>SmartBox::update_viewer</c> @ <c>0x00453CE0</c>), falling back
|
||||
/// to the player's own position when the camera sweep fails. Only two
|
||||
/// things are ever read out of it: the origin, for distance, and
|
||||
/// <c>Frame::get_heading</c>, for pan. There is no up vector, no velocity,
|
||||
/// and therefore no doppler and no elevation cue — which is why this takes
|
||||
/// a single compass heading rather than a forward/up basis.
|
||||
/// </summary>
|
||||
void SetListener(float posX, float posY, float posZ, float headingDegrees);
|
||||
|
||||
/// <summary>Play a 2D UI sound (no falloff).</summary>
|
||||
void PlayUi(SoundId id);
|
||||
|
|
|
|||
263
src/AcDream.Core/Audio/RetailSoundMixer.cs
Normal file
263
src/AcDream.Core/Audio/RetailSoundMixer.cs
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
using System;
|
||||
using System.Numerics;
|
||||
|
||||
namespace AcDream.Core.Audio;
|
||||
|
||||
/// <summary>
|
||||
/// One voice's mixing decision, as retail computes it at emission time.
|
||||
/// </summary>
|
||||
/// <param name="Play">
|
||||
/// False when retail would not start the voice at all — the attenuation fell
|
||||
/// below <see cref="RetailSoundMixer.VolMinDecibels"/>. Retail does not start a
|
||||
/// quiet voice; it starts nothing, so no pool slot is consumed either.
|
||||
/// </param>
|
||||
/// <param name="Decibels">
|
||||
/// Integer decibels, <c>ceil(20·log10 g)</c>, floored at
|
||||
/// <see cref="RetailSoundMixer.VolMinDecibels"/>.
|
||||
/// </param>
|
||||
/// <param name="Pan">
|
||||
/// Retail's DirectSound pan in whole decibels, <c>[-15, +15]</c>; negative is
|
||||
/// left, positive right, 0 dead centre.
|
||||
/// </param>
|
||||
public readonly record struct RetailVoiceMix(bool Play, int Decibels, int Pan);
|
||||
|
||||
/// <summary>
|
||||
/// Retail's sound mixing math — the whole of its spatialization.
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Retail is not a 3D audio engine.</b> Every gameplay sound buffer is
|
||||
/// created with <c>m_3D = 0</c>; the DirectSound 3D listener the client sets up
|
||||
/// (rolloff 0.01, front (−1,0,0), top (0,1,0)) is dead code that no buffer ever
|
||||
/// consults. Spatialization is exactly two CPU-computed scalars per voice,
|
||||
/// frozen at emission: a gain in whole decibels from distance, and a stereo pan
|
||||
/// in whole decibels from bearing.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Sources: <c>SoundManager::GetAttenuation</c> @ <c>0x00550020</c> and
|
||||
/// <c>SoundManager::PlaySoundInternal(SoundBufRef*, const Position*, float, int)</c>
|
||||
/// @ <c>0x00550170</c>, both byte-decoded from the PDB-paired binary — Binary
|
||||
/// Ninja elides the x87 memory constants in the first (its pseudo-C multiplies
|
||||
/// by a literal <c>0f</c>, which would port as silence at every distance) and
|
||||
/// misattributes a reused stack slot in the second (reporting the 5-metre
|
||||
/// deadzone as an angle test rather than a distance test). Full decode:
|
||||
/// <c>docs/research/2026-08-08-audio-retail-soundmanager-core.md</c> §1.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class RetailSoundMixer
|
||||
{
|
||||
/// <summary>Distance below which gain is flat at the authored volume, metres.</summary>
|
||||
public const float VolMinDistance = 5.0f;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="VolMinDistance"/> squared — retail's numerator, so the curve
|
||||
/// is continuous at the knee.
|
||||
/// </summary>
|
||||
public const float VolMinDistanceSq = 25.0f;
|
||||
|
||||
/// <summary>
|
||||
/// Retail's audibility floor. A voice whose computed decibels fall below
|
||||
/// this is never started.
|
||||
/// </summary>
|
||||
public const int VolMinDecibels = -50;
|
||||
|
||||
/// <summary>Retail's pan scale, applied to the sine of the reversed bearing.</summary>
|
||||
public const float PanScale = -15.0f;
|
||||
|
||||
/// <summary>
|
||||
/// Pan is forced to dead centre when <c>(int)distance</c> is below this.
|
||||
/// Retail truncates the distance before comparing, so this is an integer
|
||||
/// metre test, not a float one.
|
||||
/// </summary>
|
||||
public const int PanDeadzoneMetres = 5;
|
||||
|
||||
/// <summary>Retail's voice count (<c>playing_sounds_[0x10]</c>).</summary>
|
||||
public const int VoiceCount = 16;
|
||||
|
||||
/// <summary>
|
||||
/// Retail's own single-precision degrees-to-radians literal, as it appears
|
||||
/// in the pan computation.
|
||||
/// </summary>
|
||||
private const float DegreesToRadians = 0.0174532924f;
|
||||
|
||||
/// <summary>
|
||||
/// <c>SoundManager::GetAttenuation</c>. Returns whole decibels and whether
|
||||
/// retail would start the voice.
|
||||
///
|
||||
/// <para>
|
||||
/// <paramref name="masterVolume"/> is retail's ONE master multiply —
|
||||
/// <c>effect_sound_volume</c> for ordinary sounds, <c>ambient_sound_volume</c>
|
||||
/// when <c>ambient != 0</c>. Callers on the paths where retail ALSO
|
||||
/// pre-multiplies by the same knob (its volume-squared quirk) must
|
||||
/// pre-multiply <paramref name="volume"/> themselves; this function applies
|
||||
/// the knob exactly once.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static bool TryGetAttenuation(
|
||||
float distanceMetres,
|
||||
float volume,
|
||||
float masterVolume,
|
||||
out int decibels)
|
||||
{
|
||||
float g = distanceMetres < VolMinDistance
|
||||
? volume
|
||||
: (VolMinDistanceSq * volume) / (distanceMetres * distanceMetres);
|
||||
|
||||
if (g > 1.0f) g = 1.0f;
|
||||
g *= masterVolume;
|
||||
|
||||
if (g <= 0.0f || float.IsNaN(g))
|
||||
{
|
||||
decibels = VolMinDecibels;
|
||||
return false;
|
||||
}
|
||||
|
||||
decibels = (int)MathF.Ceiling(20.0f * MathF.Log10(g));
|
||||
if (decibels >= VolMinDecibels)
|
||||
return true;
|
||||
|
||||
decibels = VolMinDecibels;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail's compass heading convention (<c>Position::heading</c> @
|
||||
/// <c>0x005A9520</c>): degrees clockwise from +Y (north), +X (east) = 90°.
|
||||
///
|
||||
/// <para>
|
||||
/// Delegates to <see cref="Physics.Motion.MoveToMath.PositionHeading"/> —
|
||||
/// the pinned port of the same retail function, with golden-cardinal
|
||||
/// coverage — rather than carrying a third copy of the formula. Retail's own
|
||||
/// constant is the double <c>57.29577951308232</c>; the single-precision
|
||||
/// narrowing there is at most ~1.5e-6 degrees, far inside the integer
|
||||
/// truncation the pan applies afterwards.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static float CompassHeadingDegrees(Vector3 from, Vector3 to) =>
|
||||
Physics.Motion.MoveToMath.PositionHeading(from, to);
|
||||
|
||||
/// <summary>
|
||||
/// Retail's heading-difference normalisation, verbatim: <c>fmod(delta, 360)</c>
|
||||
/// then <c>if (!(delta <= 180)) delta -= 360</c>.
|
||||
///
|
||||
/// <para>
|
||||
/// The output range is retail's <c>(-360, 180]</c>, NOT <c>(-180, 180]</c> —
|
||||
/// retail does not fold negatives back up, so an input of −270 stays −270.
|
||||
/// That is harmless because the only consumer is <c>sin</c>, which has
|
||||
/// period 360, and reproducing it exactly keeps this function comparable to
|
||||
/// the decode.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static float NormalizeSignedDegrees(float degrees)
|
||||
{
|
||||
float delta = degrees % 360.0f;
|
||||
if (!(delta <= 180.0f)) delta -= 360.0f;
|
||||
return delta;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail's pan, in whole decibels. <paramref name="bearingSourceToListener"/>
|
||||
/// is <c>Position::heading(soundPos, listenerPos)</c> — the REVERSED
|
||||
/// bearing; combined with the negative <see cref="PanScale"/> that yields
|
||||
/// the correct handedness (a source due east of a north-facing listener
|
||||
/// pans right).
|
||||
///
|
||||
/// <para>
|
||||
/// There is no front/back and no elevation cue: a source dead ahead and one
|
||||
/// directly behind both pan to 0, and Z reaches the mix only through
|
||||
/// distance.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static int GetPan(
|
||||
float bearingSourceToListener,
|
||||
float listenerHeadingDegrees,
|
||||
float distanceMetres,
|
||||
bool panningEnabled = true)
|
||||
{
|
||||
if (!panningEnabled)
|
||||
return 0;
|
||||
|
||||
// Retail truncates distance to an int before the deadzone compare.
|
||||
if (Math.Abs((int)distanceMetres) < PanDeadzoneMetres)
|
||||
return 0;
|
||||
|
||||
float delta = NormalizeSignedDegrees(bearingSourceToListener - listenerHeadingDegrees);
|
||||
int pan = (int)(MathF.Sin(delta * DegreesToRadians) * PanScale);
|
||||
return Math.Clamp(pan, (int)PanScale, (int)-PanScale);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The whole per-play decision for a world sound: distance gain plus pan,
|
||||
/// composed the way <c>PlaySoundInternal</c> composes them.
|
||||
/// </summary>
|
||||
public static RetailVoiceMix Mix(
|
||||
Vector3 listenerPosition,
|
||||
float listenerHeadingDegrees,
|
||||
Vector3 sourcePosition,
|
||||
float volume,
|
||||
float masterVolume,
|
||||
bool panningEnabled = true)
|
||||
{
|
||||
float distance = Vector3.Distance(listenerPosition, sourcePosition);
|
||||
|
||||
// Pan is computed from the reversed bearing: source -> listener.
|
||||
float bearing = CompassHeadingDegrees(sourcePosition, listenerPosition);
|
||||
int pan = GetPan(bearing, listenerHeadingDegrees, distance, panningEnabled);
|
||||
|
||||
bool play = TryGetAttenuation(distance, volume, masterVolume, out int decibels);
|
||||
return new RetailVoiceMix(play, decibels, pan);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Linear amplitude for whole decibels: <c>10^(db/20)</c>. Retail hands the
|
||||
/// decibel value straight to <c>IDirectSoundBuffer::SetVolume</c>, which is
|
||||
/// hundredths of a decibel; OpenAL wants linear gain, so the conversion
|
||||
/// happens here rather than changing the quantisation.
|
||||
/// </summary>
|
||||
public static float LinearGain(int decibels) =>
|
||||
MathF.Pow(10.0f, decibels / 20.0f);
|
||||
|
||||
/// <summary>
|
||||
/// Converts retail's pan (whole decibels of inter-channel difference, the
|
||||
/// quantity <c>IDirectSoundBuffer::SetPan</c> expresses directly) into a
|
||||
/// normalised stereo position in <c>[-1, 1]</c> for a constant-power panner
|
||||
/// such as OpenAL's.
|
||||
///
|
||||
/// <para>
|
||||
/// A constant-power panpot at position <c>p</c> over a speaker pair puts
|
||||
/// <c>cos((p+1)·π/4)</c> in the left channel and <c>sin((p+1)·π/4)</c> in
|
||||
/// the right, so the inter-channel difference is
|
||||
/// <c>20·log10(tan((p+1)·π/4))</c>. Inverting that for a target difference
|
||||
/// gives <c>p = (4/π)·atan(10^(pan/20)) − 1</c>, which reaches only ±0.775
|
||||
/// at retail's ±15 dB — both channels stay live, exactly as DirectSound's
|
||||
/// one-channel attenuation keeps them. Mapping pan linearly onto the
|
||||
/// panner's full range instead would saturate to infinite separation at the
|
||||
/// edges, which retail never does.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static float StereoPositionFromPan(int pan)
|
||||
{
|
||||
float difference = MathF.Pow(10.0f, pan / 20.0f);
|
||||
float position = (4.0f / MathF.PI) * MathF.Atan(difference) - 1.0f;
|
||||
return Math.Clamp(position, -1.0f, 1.0f);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The maximum distance at which a sound of this volume is audible at all,
|
||||
/// in metres — the radius where <c>ceil(20·log10(25·vol·master/d²))</c>
|
||||
/// last reaches <see cref="VolMinDecibels"/>. About 94.2 m at
|
||||
/// <c>vol·master == 1</c>, 66.6 m at 0.5, 29.8 m at 0.1. Diagnostic and
|
||||
/// test use; the live path gets the same answer from
|
||||
/// <see cref="TryGetAttenuation"/>.
|
||||
/// </summary>
|
||||
public static float AudibleRadius(float volume, float masterVolume)
|
||||
{
|
||||
float scale = volume * masterVolume;
|
||||
if (scale <= 0f) return 0f;
|
||||
|
||||
// Audible while db >= -50, i.e. ceil(20·log10 g) >= -50, i.e.
|
||||
// 20·log10 g > -51 (ceil of anything above -51 is at least -50).
|
||||
float minGain = MathF.Pow(10.0f, -51.0f / 20.0f);
|
||||
return MathF.Sqrt(VolMinDistanceSq * scale / minGain);
|
||||
}
|
||||
}
|
||||
102
src/AcDream.Core/Audio/RetailVoicePool.cs
Normal file
102
src/AcDream.Core/Audio/RetailVoicePool.cs
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
using System;
|
||||
|
||||
namespace AcDream.Core.Audio;
|
||||
|
||||
/// <summary>
|
||||
/// One voice slot's state, as retail's <c>SoundPlayingData</c> carries it:
|
||||
/// whether a buffer is installed, and the DAT-authored priority it was claimed
|
||||
/// with. Retail also stores a <c>start_time</c>, which it writes and never
|
||||
/// reads — age enters allocation only through the ring cursor — so there is no
|
||||
/// timestamp here.
|
||||
/// </summary>
|
||||
/// <param name="Occupied">
|
||||
/// A buffer is installed in this slot (retail: <c>buffer != null</c> with a live
|
||||
/// <c>m_pBuf</c>).
|
||||
/// </param>
|
||||
/// <param name="StillPlaying">
|
||||
/// The installed buffer is still reporting <c>DSBSTATUS_PLAYING</c>. A finished
|
||||
/// voice is reclaimed by the first pass exactly like an empty slot.
|
||||
/// </param>
|
||||
/// <param name="Priority">The priority the slot's current sound was claimed with.</param>
|
||||
public readonly record struct VoiceSlotState(bool Occupied, bool StillPlaying, float Priority);
|
||||
|
||||
/// <summary>
|
||||
/// Retail's voice allocator — <c>SoundManager::PlaySoundInternal(SoundBufRef*,
|
||||
/// int pan, int volDb)</c> @ <c>0x0054FEC0</c>, byte-decoded.
|
||||
///
|
||||
/// <para>
|
||||
/// Two passes, both walking the 16 slots in ring order from a persistent cursor:
|
||||
/// </para>
|
||||
/// <list type="number">
|
||||
/// <item><description>
|
||||
/// Claim the first slot that is empty, has a broken buffer, or is no longer
|
||||
/// playing.
|
||||
/// </description></item>
|
||||
/// <item><description>
|
||||
/// Otherwise claim the first slot whose priority is <b>strictly less</b> than
|
||||
/// the incoming sound's — so equal priority never evicts, and gain is never
|
||||
/// consulted at all. Before Campaign A slice A2 acdream compared GAIN here,
|
||||
/// which let a loud unimportant sound evict a quiet important one.
|
||||
/// </description></item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// If neither pass finds a slot the new sound is silently dropped.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// This lives in Core, separate from the OpenAL engine, because it is pure
|
||||
/// index logic over slot state: the engine's own play path talks to native AL
|
||||
/// handles and cannot be reached by a test, and this is the second-largest
|
||||
/// behavioural change in the audio campaign.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class RetailVoicePool
|
||||
{
|
||||
/// <summary>Sentinel for "no slot available — drop the sound".</summary>
|
||||
public const int NoSlot = -1;
|
||||
|
||||
/// <summary>
|
||||
/// Pick the slot retail would claim for a sound of <paramref name="priority"/>,
|
||||
/// or <see cref="NoSlot"/>.
|
||||
/// </summary>
|
||||
/// <param name="slots">
|
||||
/// The pool, in slot order. Length is retail's
|
||||
/// <see cref="RetailSoundMixer.VoiceCount"/> in production but any length
|
||||
/// works so tests can use small pools.
|
||||
/// </param>
|
||||
/// <param name="cursor">
|
||||
/// Retail's <c>curr_playing_buffer_</c> — where the ring scan starts.
|
||||
/// </param>
|
||||
public static int Acquire(ReadOnlySpan<VoiceSlotState> slots, int cursor, float priority)
|
||||
{
|
||||
if (slots.Length == 0) return NoSlot;
|
||||
|
||||
for (int i = 0; i < slots.Length; i++)
|
||||
{
|
||||
int idx = Ring(cursor, i, slots.Length);
|
||||
VoiceSlotState slot = slots[idx];
|
||||
if (!slot.Occupied || !slot.StillPlaying) return idx;
|
||||
}
|
||||
|
||||
for (int i = 0; i < slots.Length; i++)
|
||||
{
|
||||
int idx = Ring(cursor, i, slots.Length);
|
||||
if (slots[idx].Priority < priority) return idx;
|
||||
}
|
||||
|
||||
return NoSlot;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail's post-claim cursor advance: <c>curr_playing_buffer_ = (slot + 1)
|
||||
/// mod 16</c>. Only a successful claim moves it.
|
||||
/// </summary>
|
||||
public static int AdvanceCursor(int claimedSlot, int slotCount) =>
|
||||
slotCount <= 0 ? 0 : (claimedSlot + 1) % slotCount;
|
||||
|
||||
private static int Ring(int cursor, int offset, int slotCount)
|
||||
{
|
||||
int idx = (cursor + offset) % slotCount;
|
||||
return idx < 0 ? idx + slotCount : idx;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue