fix(selection): port SmartBox click lighting pulse

Port the retail high/low material-lighting cadence for successful world clicks, keep it instance-scoped in the modern renderer, and restore authored lighting after 0.8 seconds. Correct the selection oracle and pin timing plus per-frame buffer lifecycle with tests.

Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-17 23:00:04 +02:00
parent 146a963aeb
commit ea4f52ec51
11 changed files with 338 additions and 16 deletions

View file

@ -0,0 +1,17 @@
namespace AcDream.App.Rendering.Selection;
/// <summary>
/// Supplies the transient material lighting override which retail applies to
/// a world object after SmartBox resolves a click.
/// </summary>
internal interface IRetailSelectionLightingSource
{
/// <summary>Advances the retail SmartBox flip cadence once for this frame.</summary>
void TickLighting();
/// <summary>
/// Returns the current CMaterial luminosity/diffuse replacement for an
/// object, or false when its authored material values must be restored.
/// </summary>
bool TryGetLighting(uint serverGuid, out RetailSelectionLighting lighting);
}

View file

@ -0,0 +1,101 @@
using System.Diagnostics;
namespace AcDream.App.Rendering.Selection;
/// <summary>
/// Replacement values passed by retail to CPhysicsObj::SetLighting.
/// CMaterial receives luminosity first and diffuse second.
/// </summary>
internal readonly record struct RetailSelectionLighting(float Luminosity, float Diffuse)
{
public static readonly RetailSelectionLighting Normal = new(0f, 1f);
public static readonly RetailSelectionLighting Low = new(0f, 0.35f);
public static readonly RetailSelectionLighting High = new(0.99f, 1f);
}
/// <summary>
/// Retail SmartBox click-confirmation lighting pulse.
///
/// UIElement_SmartBoxWrapper::RecvNotice_SmartBoxObjectFound @ 0x004E5AD0
/// applies HIGH immediately. Global_Loop @ 0x004E5620 then advances
/// LOW/HIGH/LOW/RESTORE at 0.2-second intervals. A new click restores the old
/// target implicitly and starts the sequence again on the new target.
/// </summary>
internal sealed class RetailSelectionLightingPulse
{
internal const double FlipIntervalSeconds = 0.2;
private readonly Func<double> _now;
private uint _serverGuid;
private int _flipCount;
private double _nextFlip;
private RetailSelectionLighting _lighting;
public RetailSelectionLightingPulse(Func<double>? now = null)
=> _now = now ?? MonotonicSeconds;
public void Start(uint serverGuid)
{
if (serverGuid == 0u)
{
Clear();
return;
}
_serverGuid = serverGuid;
_flipCount = 1;
_lighting = RetailSelectionLighting.High;
_nextFlip = _now() + FlipIntervalSeconds;
}
/// <summary>
/// Advances at most one flip per frame, matching retail Global_Loop. The
/// next deadline is based on the current frame time, so a long frame does
/// not replay multiple material mutations in one draw.
/// </summary>
public void Tick()
{
if (_flipCount == 0)
return;
double now = _now();
if (now < _nextFlip)
return;
int nextCount = _flipCount + 1;
if (nextCount >= 5)
{
Clear();
return;
}
_flipCount = nextCount;
_lighting = (nextCount & 1) != 0
? RetailSelectionLighting.High
: RetailSelectionLighting.Low;
_nextFlip = now + FlipIntervalSeconds;
}
public bool TryGet(uint serverGuid, out RetailSelectionLighting lighting)
{
if (_flipCount != 0 && serverGuid != 0u && serverGuid == _serverGuid)
{
lighting = _lighting;
return true;
}
lighting = default;
return false;
}
public void Clear()
{
_serverGuid = 0u;
_flipCount = 0;
_nextFlip = 0d;
_lighting = default;
}
private static double MonotonicSeconds()
=> Stopwatch.GetTimestamp() / (double)Stopwatch.Frequency;
}

View file

@ -9,9 +9,10 @@ namespace AcDream.App.Rendering.Selection;
/// The renderer builds one frame while input queries the previously completed
/// frame, avoiding partial visibility state during multi-slice portal drawing.
/// </summary>
internal sealed class RetailSelectionScene : IRetailSelectionRenderSink
internal sealed class RetailSelectionScene : IRetailSelectionRenderSink, IRetailSelectionLightingSource
{
private readonly RetailSelectionGeometryCache _geometry;
private readonly RetailSelectionLightingPulse _lightingPulse;
private List<RetailSelectionPart> _building = new();
private List<RetailSelectionPart> _published = new();
private readonly HashSet<PartKey> _buildingKeys = new();
@ -21,8 +22,26 @@ internal sealed class RetailSelectionScene : IRetailSelectionRenderSink
private readonly record struct PartKey(uint LocalEntityId, int PartIndex, uint GfxObjId);
public RetailSelectionScene(RetailSelectionGeometryCache geometry)
=> _geometry = geometry ?? throw new ArgumentNullException(nameof(geometry));
public RetailSelectionScene(
RetailSelectionGeometryCache geometry,
RetailSelectionLightingPulse? lightingPulse = null)
{
_geometry = geometry ?? throw new ArgumentNullException(nameof(geometry));
_lightingPulse = lightingPulse ?? new RetailSelectionLightingPulse();
}
/// <summary>
/// Starts retail's SmartBox click pulse independently of persistent target
/// selection. Examine, use, and targeted-use clicks receive the same pulse.
/// </summary>
public void BeginLightingPulse(uint serverGuid)
=> _lightingPulse.Start(serverGuid);
public void TickLighting()
=> _lightingPulse.Tick();
public bool TryGetLighting(uint serverGuid, out RetailSelectionLighting lighting)
=> _lightingPulse.TryGet(serverGuid, out lighting);
public void BeginFrame()
{