diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index ea0485bf..2f9c097f 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -524,7 +524,7 @@ behavior. Estimated 17–26 days focused work, 3–5 weeks calendar. - **✓ SHIPPED — UI Studio** (2026-06-26, branch `claude/hopeful-maxwell-214a12`, ~33693c6→HEAD). Standalone `AcDream.App ui-studio [--layout 0xNNNN | --dump ] [--screenshot ]` Silk tool that previews any panel through the **production renderer** (`RenderBootstrap.cs` + GameWindow untouched). Sources: 26-window retail dump (`docs/research/2026-06-25-retail-ui-layout-dump.json`) via `--dump`, or live dat import + fixtures via `--layout`. Interactive canvas (click-routing to `UiHost`, Interact/Inspect ImGui toggle), headless `--screenshot`. Architecture: `src/AcDream.App/Studio/` (`StudioWindow`, `PanelFbo`, `FixtureProvider`, `LayoutSource`) + `src/AcDream.App/Rendering/RenderBootstrap.cs`. ISSUES #156 (inventory all-black in studio) and #157 (GameWindow font resolver) filed. - **✓ SHIPPED — Importer dat-fidelity** (Fix A/B/C, 2026-06-26, same branch). **Boundary established: look = importer (font/justification/color from dat); state/behavior = runtime controller logic.** Fix A: justification property `0x14`/`0x15` → `UiText` Centered/RightAligned/VerticalJustify. Fix B: FontColor property `0x1B` → `UiText.DefaultColor`; character colors proved RUNTIME (no importer fix). Fix C: FontDid → per-element dat font via a resolver in `DatWidgetFactory` — **STUDIO path only**; GameWindow passes `null` (Issue #157). Fix 4 (UIState-group activation): INVESTIGATED — no importer fix exists; the dat has no Visible encoding; runtime `gm*UI` is correct. - **✓ SHIPPED — Wave 3.3 unified primary-click router.** `ItemInteractionController.OfferPrimaryClick` returns `NotActive` / `ConsumedSuccess` / `ConsumedRejected`; inventory, bags, main pack, paperdoll, toolbar, radar, and world clicks all offer target mode before local fallback. Rejected targets cannot drift selection or open/use the clicked object. Issue #197 and AP-107 retired; retained matrix and live ACE-provisioned healing gate user-confirmed. The 3-D doll routes through authored hit mask `0x100001D6` (resolved `UiButton` above viewport `0x100001D5`), and local health prefers authoritative private-vital state. -- **✓ SHIPPED — retail world selection + vivid target indicator (2026-07-17).** The projected Setup-sphere rectangle and independent physics-wall ray are deleted. The normal visible-mesh traversal now publishes server-object parts through their exact drawing-BSP spheres; `RetailWorldPicker` ports the camera-eye ray, per-part local transform, double-precision retail sphere solve, first visual-polygon hit in DAT order, one-sided/convex-edge rules, and global polygon-over-sphere arbitration. The retained gameplay UI resolves client-enum category `0x10000009` values `1..4` to the four installed 12×12 corner surfaces, colorizes them with the radar-blip color, and ports the exact Setup-sphere rectangle plus separate-edge 8-pixel viewport clamp. Issues #70/#71 closed; AP-29/TS-16 retired. Research: `docs/research/2026-07-17-retail-world-selection-pseudocode.md`. +- **✓ SHIPPED — retail world selection + complete SmartBox feedback (2026-07-17).** The projected Setup-sphere rectangle and independent physics-wall ray are deleted. The normal visible-mesh traversal now publishes server-object parts through their exact drawing-BSP spheres; `RetailWorldPicker` ports the camera-eye ray, per-part local transform, double-precision retail sphere solve, first visual-polygon hit in DAT order, one-sided/convex-edge rules, and global polygon-over-sphere arbitration. The retained gameplay UI resolves client-enum category `0x10000009` values `1..4` to the four installed 12×12 corner surfaces, colorizes them with the radar-blip color, and ports the exact Setup-sphere rectangle plus separate-edge 8-pixel viewport clamp. Successful select/use/targeted-use/drop clicks also run SmartBox's separate 0.8-second HIGH/LOW/HIGH/LOW material-lighting pulse with the exact retail luminosity/diffuse replacements before restoring authored rendering. Issues #70/#71 closed; AP-29/TS-16 retired. Research: `docs/research/2026-07-17-retail-world-selection-pseudocode.md`. - **✓ SHIPPED — Wave 4.1 retail quick-slot input.** Bare `1..9` use toolbar slots 0..8, Ctrl+`1..9` selects them, Alt+`5..9` uses slots 13..17, and `0` creates a shortcut to the selected owned/eligible object in retail's first empty slot. `ToolbarInputController` is the focused semantic adapter; `ToolbarController` owns `UseShortcut` target-before-use/select ordering and auto-slot eligibility. Keybinding schema v2 migrates only the exact old default Ctrl+number chords, so custom bindings retain their chosen action. Full automated conformance and live user gate passed 2026-07-11. - **✓ SHIPPED — Wave 4.2 exact shortcut records.** One Core-owned `ShortcutEntry` preserves retail's signed `index_`, unsigned `objectID_`, and raw 32-bit `spellID_` through PlayerDescription parse, the nullable 18-slot session store, drag snapshots/reindexing, WorldSession, and exact 12-byte AddShortcut packing. The visible `gmToolbarUI` remains object-only; `favorite_spells_[8]` remains a separate future spell-bar source. AP-103 retired. Full automated conformance and live mutate→clean-relog persistence gate passed 2026-07-11. - **✓ SHIPPED — Wave 4.3a shortcut-alias boundary.** Drag feedback now preserves retail's neutral/accept/reject distinction. Inventory and paperdoll apply the `(DropItemFlags & 0xE) == 0` physical-item gate, so a toolbar alias can reorder within the toolbar but can never move, unwield, wield, or drop the object it references. Live equipped-helmet gate passed 2026-07-11: inventory drop removed only the shortcut. diff --git a/docs/research/2026-07-17-retail-world-selection-pseudocode.md b/docs/research/2026-07-17-retail-world-selection-pseudocode.md index 65a69de3..df15efb6 100644 --- a/docs/research/2026-07-17-retail-world-selection-pseudocode.md +++ b/docs/research/2026-07-17-retail-world-selection-pseudocode.md @@ -139,7 +139,64 @@ else: show the on-screen root and hide the off-screen root ``` -There is no selected-world-mesh tint or luminosity mutation in -`CPhysicsPart::Draw`, `ACCWeenieObject::SetSelected`, or the world mesh draw -path. The visible notification is the colorized four-corner DAT indicator. A -mesh shader tint would therefore be a new effect, not a retail port. +The indicator is persistent selection presentation. Retail also has a separate, +short-lived click confirmation owned by SmartBox; it is not implemented in +`CPhysicsPart::Draw` or `ACCWeenieObject::SetSelected`. + +## SmartBox click lighting pulse + +`UIElement_SmartBoxWrapper::RecvNotice_SmartBoxObjectFound @ 0x004E5AD0` + +```text +when FindObject succeeds for Select, Examine, Use, Drop, Drag, or TargetedUse: + if another object is currently pulsing: + RestoreLighting(previous object) + SetLighting(found object, luminosity=0.99, diffuse=1.0) // HIGH + flipCount = 1 + nextFlip = Timer::cur_time + 0.2 seconds + + continue with the reason-specific select/examine/use/drop behavior +``` + +`UIElement_SmartBoxWrapper::Global_Loop @ 0x004E5620` + +```text +if flipCount != 0 and Timer::cur_time >= nextFlip: + flipCount += 1 + if flipCount < 5: + lightingMode = ((flipCount & 1) != 0) + 1 + nextFlip = Timer::cur_time + 0.2 seconds + else: + flipCount = 0 + lightingMode = RESTORE + ApplyLighting(pulsing object, lightingMode) +``` + +`UIElement_SmartBoxWrapper::ApplyLighting @ 0x004E5320` + +```text +RESTORE: CPhysicsObj::RestoreLighting(object) +LOW: CPhysicsObj::SetLighting(object, luminosity=0.0, diffuse=0.35) +HIGH: CPhysicsObj::SetLighting(object, luminosity=0.99, diffuse=1.0) +``` + +The complete cadence is therefore HIGH immediately, LOW at 0.2 seconds, HIGH +at 0.4, LOW at 0.6, and authored material lighting restored at 0.8. A delayed +frame advances only one step because retail schedules the next flip from the +current `Timer::cur_time`; it does not drain missed intervals. + +`CPhysicsObj::SetLighting @ 0x00511A80` forwards to every physics part through +`CPartArray::SetLightingInternal @ 0x00518490`. `CPhysicsPart::SetLighting @ +0x0050E400` copies the material when needed, then calls +`CMaterial::SetLuminositySimple` followed by `CMaterial::SetDiffuseSimple`. +The modern renderer's equivalent is an instance-scoped material replacement: + +```text +lit = luminosity + diffuse * sceneLighting +rgb = sampledTexture.rgb * clamp(lit, 0, 1) +``` + +Normal/restored rendering uses `(luminosity=0, diffuse=1)` in the current object +shader. The pulse is distinct from the persistent four-corner indicator and +must restart on every successfully resolved SmartBox click, even when the click +is consumed by targeted use instead of changing the selected object. diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 6c8cc24f..bb640f57 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -12379,6 +12379,8 @@ public sealed class GameWindow : IDisposable if (payload is AcDream.App.UI.ItemDragPayload itemPayload) { uint target = PickWorldGuidAt(x, y, includeSelf: true) ?? 0u; + if (target != 0u) + _retailSelectionScene?.BeginLightingPulse(target); _itemInteractionController?.PlaceIn3D(itemPayload, target); } } @@ -13129,6 +13131,11 @@ public sealed class GameWindow : IDisposable if (picked is uint guid) { + // Retail UIElement_SmartBoxWrapper::RecvNotice_SmartBoxObjectFound + // @ 0x004E5AD0 pulses every successfully found click before it + // branches into select/use/targeted-use behavior. + _retailSelectionScene?.BeginLightingPulse(guid); + if (_itemInteractionController?.OfferPrimaryClick(guid) is not null and not AcDream.App.UI.ItemPrimaryClickResult.NotActive) return; diff --git a/src/AcDream.App/Rendering/Selection/IRetailSelectionLightingSource.cs b/src/AcDream.App/Rendering/Selection/IRetailSelectionLightingSource.cs new file mode 100644 index 00000000..d902ea7d --- /dev/null +++ b/src/AcDream.App/Rendering/Selection/IRetailSelectionLightingSource.cs @@ -0,0 +1,17 @@ +namespace AcDream.App.Rendering.Selection; + +/// +/// Supplies the transient material lighting override which retail applies to +/// a world object after SmartBox resolves a click. +/// +internal interface IRetailSelectionLightingSource +{ + /// Advances the retail SmartBox flip cadence once for this frame. + void TickLighting(); + + /// + /// Returns the current CMaterial luminosity/diffuse replacement for an + /// object, or false when its authored material values must be restored. + /// + bool TryGetLighting(uint serverGuid, out RetailSelectionLighting lighting); +} diff --git a/src/AcDream.App/Rendering/Selection/RetailSelectionLightingPulse.cs b/src/AcDream.App/Rendering/Selection/RetailSelectionLightingPulse.cs new file mode 100644 index 00000000..a1300da9 --- /dev/null +++ b/src/AcDream.App/Rendering/Selection/RetailSelectionLightingPulse.cs @@ -0,0 +1,101 @@ +using System.Diagnostics; + +namespace AcDream.App.Rendering.Selection; + +/// +/// Replacement values passed by retail to CPhysicsObj::SetLighting. +/// CMaterial receives luminosity first and diffuse second. +/// +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); +} + +/// +/// 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. +/// +internal sealed class RetailSelectionLightingPulse +{ + internal const double FlipIntervalSeconds = 0.2; + + private readonly Func _now; + private uint _serverGuid; + private int _flipCount; + private double _nextFlip; + private RetailSelectionLighting _lighting; + + public RetailSelectionLightingPulse(Func? 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; + } + + /// + /// 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. + /// + 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; +} diff --git a/src/AcDream.App/Rendering/Selection/RetailSelectionScene.cs b/src/AcDream.App/Rendering/Selection/RetailSelectionScene.cs index 4593f53c..16ae5f87 100644 --- a/src/AcDream.App/Rendering/Selection/RetailSelectionScene.cs +++ b/src/AcDream.App/Rendering/Selection/RetailSelectionScene.cs @@ -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. /// -internal sealed class RetailSelectionScene : IRetailSelectionRenderSink +internal sealed class RetailSelectionScene : IRetailSelectionRenderSink, IRetailSelectionLightingSource { private readonly RetailSelectionGeometryCache _geometry; + private readonly RetailSelectionLightingPulse _lightingPulse; private List _building = new(); private List _published = new(); private readonly HashSet _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(); + } + + /// + /// Starts retail's SmartBox click pulse independently of persistent target + /// selection. Examine, use, and targeted-use clicks receive the same pulse. + /// + 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() { diff --git a/src/AcDream.App/Rendering/Shaders/mesh_modern.frag b/src/AcDream.App/Rendering/Shaders/mesh_modern.frag index cf500f01..eb737155 100644 --- a/src/AcDream.App/Rendering/Shaders/mesh_modern.frag +++ b/src/AcDream.App/Rendering/Shaders/mesh_modern.frag @@ -8,6 +8,7 @@ in vec3 vLit; // A7: per-vertex Gouraud lighting (ambient + capped li in flat uvec2 vTextureHandle; in flat uint vTextureLayer; in flat float vOpacityMultiplier; // #188 +in flat vec2 vSelectionLighting; // x=luminosity, y=diffuse // uRenderPass values (Phase N.5 Decision 2 — two-pass alpha-test): // 0 = opaque pass — discard fragments with alpha < 0.95 @@ -84,8 +85,10 @@ void main() { if (color.a < 0.05) discard; } - // Per-vertex Gouraud lighting from the vertex shader (ambient + capped lights). - vec3 lit = vLit; + // Per-vertex Gouraud lighting from the vertex shader (ambient + capped + // lights), passed through retail CMaterial's two click-pulse replacements: + // emissive/luminosity + diffuse * scene lighting. Normal is (0,1). + vec3 lit = vec3(vSelectionLighting.x) + vSelectionLighting.y * vLit; // #176 stripe-hunt mode 3: show the raw per-vertex light field (texture // ignored). Stripes visible HERE = a vertex-lighting artifact; absent = diff --git a/src/AcDream.App/Rendering/Shaders/mesh_modern.vert b/src/AcDream.App/Rendering/Shaders/mesh_modern.vert index a28f2893..bfbe4bf6 100644 --- a/src/AcDream.App/Rendering/Shaders/mesh_modern.vert +++ b/src/AcDream.App/Rendering/Shaders/mesh_modern.vert @@ -7,10 +7,6 @@ layout(location = 2) in vec2 aTexCoord; struct InstanceData { mat4 transform; - // Reserved for Phase B.4 follow-up (selection-blink retail-faithful - // highlight): vec4 highlightColor; — extend stride here, increase the - // _instanceSsbo upload size in WbDrawDispatcher, add a flat varying out, - // and consume in mesh_modern.frag. }; struct BatchData { @@ -115,6 +111,15 @@ layout(std430, binding = 7) readonly buffer InstanceAlphaBuf { float instanceAlpha[]; }; +// Retail SmartBox click confirmation. One vec2 per OBJECT instance, parallel +// to binding=0: x = CMaterial luminosity, y = CMaterial diffuse. Normal +// rendering is (0,1); SmartBox alternates LOW=(0,.35) and HIGH=(.99,1). +// EnvCellRenderer uses uLightingMode=1 and deliberately never reads this +// object-only binding. +layout(std430, binding = 8) readonly buffer InstanceSelectionLightingBuf { + vec2 instanceSelectionLighting[]; +}; + // Core profile: redeclare gl_PerVertex so writing gl_ClipDistance[] is legal // alongside gl_Position. The array is sized 8 to match the CellClip plane budget // and the GL guarantee (GL_MAX_CLIP_DISTANCES >= 8). The host enables @@ -277,11 +282,15 @@ out vec3 vLit; // A7: per-vertex Gouraud lighting (ambient + capped l out flat uvec2 vTextureHandle; out flat uint vTextureLayer; out flat float vOpacityMultiplier; // #188 +out flat vec2 vSelectionLighting; void main() { int instanceIndex = gl_BaseInstanceARB + gl_InstanceID; mat4 model = Instances[instanceIndex].transform; vOpacityMultiplier = instanceAlpha[instanceIndex]; // #188 + vSelectionLighting = (uLightingMode == 0) + ? instanceSelectionLighting[instanceIndex] + : vec2(0.0, 1.0); vec4 worldPos = model * vec4(aPosition, 1.0); gl_Position = uViewProjection * worldPos; diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs index aca54e95..316c461a 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs @@ -87,6 +87,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable private readonly WbMeshAdapter _meshAdapter; private readonly EntitySpawnAdapter _entitySpawnAdapter; private readonly IRetailSelectionRenderSink? _selectionSink; + private readonly IRetailSelectionLightingSource? _selectionLighting; private readonly BindlessSupport _bindless; @@ -165,6 +166,10 @@ public sealed unsafe class WbDrawDispatcher : IDisposable // a clone of _indoorData / _instIndoorSsbo, one binding higher. private uint _instAlphaSsbo; private float[] _alphaData = new float[256]; + // Retail SmartBox click confirmation: per-instance CMaterial luminosity / + // diffuse replacement (binding=8), parallel to the transform buffer. + private uint _instSelectionLightingSsbo; + private Vector2[] _selectionLightingData = new Vector2[256]; // This frame's point-light snapshot, handed in by GameWindow before Draw via // SetSceneLights. Null/empty ⇒ only ambient + sun render (all instance sets -1). private IReadOnlyList? _pointSnapshot; @@ -178,6 +183,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable // (ParentCellId is an EnvCell). Appended to InstanceGroup.IndoorFlags in // AppendCurrentLightSet; uploaded as binding=6 instanceIndoor[]. private bool _currentEntityIndoor; + private Vector2 _currentEntitySelectionLighting = new(0f, 1f); // Phase U.3: the SHARED per-cell clip-region SSBO (binding=2), owned by the // GameWindow-level ClipFrame and handed to us via SetClipRegionSsbo. When 0 @@ -375,6 +381,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable _cache = classificationCache; _translucencyFades = translucencyFades; _selectionSink = selectionSink; + _selectionLighting = selectionSink as IRetailSelectionLightingSource; _bindless = bindless ?? throw new ArgumentNullException(nameof(bindless)); _instanceSsbo = _gl.GenBuffer(); @@ -385,6 +392,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable _instLightSetSsbo = _gl.GenBuffer(); // Fix B binding=5 _instIndoorSsbo = _gl.GenBuffer(); // #142 binding=6 _instAlphaSsbo = _gl.GenBuffer(); // #188 binding=7 + _instSelectionLightingSsbo = _gl.GenBuffer(); // SmartBox binding=8 } /// @@ -927,6 +935,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable EntitySet set = EntitySet.All) { _shader.Use(); + _selectionLighting?.TickLighting(); _indoorProbeFrameCounter++; var vp = camera.View * camera.Projection; _shader.SetMatrix4("uViewProjection", vp); @@ -1131,6 +1140,10 @@ public sealed unsafe class WbDrawDispatcher : IDisposable // is constant across the entity's parts/tuples), by the entity's // bounding sphere — camera-INDEPENDENT (minimize_object_lighting). ComputeEntityLightSet(entity); + _currentEntitySelectionLighting = + _selectionLighting?.TryGetLighting(entity.ServerGuid, out var lighting) == true + ? new Vector2(lighting.Luminosity, lighting.Diffuse) + : new Vector2(0f, 1f); // #119 decisive probe: one-shot dump (+ change re-emission) for // ACDREAM_DUMP_ENTITY-targeted entities. Before the culled-continue @@ -1508,6 +1521,9 @@ public sealed unsafe class WbDrawDispatcher : IDisposable if (_alphaData.Length < totalInstances) _alphaData = new float[totalInstances + 256]; + if (_selectionLightingData.Length < totalInstances) + _selectionLightingData = new Vector2[totalInstances + 256]; + _opaqueDraws.Clear(); _translucentDraws.Clear(); @@ -1546,6 +1562,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable // #188: Opacities[] is parallel to Matrices[]; write at the same // cursor so binding=7 instanceAlpha[] tracks binding=0 instances. _alphaData[cursor] = grp.Opacities[i]; + // SmartBox CMaterial replacement, parallel to Matrices. + _selectionLightingData[cursor] = grp.SelectionLighting[i]; cursor++; } @@ -1643,6 +1661,12 @@ public sealed unsafe class WbDrawDispatcher : IDisposable fixed (float* ap = _alphaData) UploadSsbo(_instAlphaSsbo, 7, ap, totalInstances * sizeof(float)); + // SmartBox click lighting: x=luminosity, y=diffuse. mesh_modern.vert + // reads this only for the object path (uLightingMode=0), so EnvCell's + // independent mode-1 renderer does not consume this binding. + fixed (Vector2* hp = _selectionLightingData) + UploadSsbo(_instSelectionLightingSsbo, 8, hp, totalInstances * sizeof(float) * 2); + // Fix B: global point-light buffer (binding=4) + per-instance light-set // buffer (binding=5). The global buffer is this frame's PointSnapshot; the // per-instance buffer holds 8 int indices into it per instance, laid out @@ -2189,6 +2213,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable // state for entities whose animation hooks fired — so a cached // instance can never be mid-fade. Always unmodified opacity. grp.Opacities.Add(1.0f); + grp.SelectionLighting.Add(_currentEntitySelectionLighting); } /// @@ -2370,6 +2395,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable grp.Slots.Add(_currentEntitySlot); // Phase U.4 — parallel to Matrices AppendCurrentLightSet(grp); // Fix B — 8 ints per instance, parallel to Matrices grp.Opacities.Add(opacityMultiplier); // #188 — parallel to Matrices + grp.SelectionLighting.Add(_currentEntitySelectionLighting); collector?.Add(new CachedBatch(key, texHandle, restPose)); } } @@ -2453,6 +2479,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable if (_instLightSetSsbo != 0) _gl.DeleteBuffer(_instLightSetSsbo); // Fix B binding=5 if (_instIndoorSsbo != 0) _gl.DeleteBuffer(_instIndoorSsbo); // #142 binding=6 if (_instAlphaSsbo != 0) _gl.DeleteBuffer(_instAlphaSsbo); // #188 binding=7 + if (_instSelectionLightingSsbo != 0) _gl.DeleteBuffer(_instSelectionLightingSsbo); // SmartBox binding=8 if (_gpuQueriesInitialized) { for (int i = 0; i < GpuQueryRingDepth; i++) @@ -2660,6 +2687,10 @@ public sealed unsafe class WbDrawDispatcher : IDisposable // the same cursor, so the binding=7 instanceAlpha[] tracks binding=0. public readonly List Opacities = new(); + // Retail SmartBox click lighting, parallel to Matrices. Each vec2 is + // (luminosity, diffuse) and is uploaded to binding=8. + public readonly List SelectionLighting = new(); + /// /// Resets every per-instance parallel list for a new frame. These lists are /// appended in lockstep (one entry per drawn instance) during group build, so @@ -2677,6 +2708,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable LightSets.Clear(); IndoorFlags.Clear(); Opacities.Clear(); + SelectionLighting.Clear(); } } } diff --git a/tests/AcDream.App.Tests/Rendering/RetailSelectionLightingPulseTests.cs b/tests/AcDream.App.Tests/Rendering/RetailSelectionLightingPulseTests.cs new file mode 100644 index 00000000..8ead7b21 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/RetailSelectionLightingPulseTests.cs @@ -0,0 +1,75 @@ +using AcDream.App.Rendering.Selection; + +namespace AcDream.App.Tests.Rendering; + +public sealed class RetailSelectionLightingPulseTests +{ + [Fact] + public void ClickRunsRetailHighLowHighLowRestoreCadence() + { + double now = 10d; + var pulse = new RetailSelectionLightingPulse(() => now); + + pulse.Start(0x5000_1234u); + AssertLighting(pulse, 0x5000_1234u, RetailSelectionLighting.High); + + now = 10.199; + pulse.Tick(); + AssertLighting(pulse, 0x5000_1234u, RetailSelectionLighting.High); + + now = 10.2; + pulse.Tick(); + AssertLighting(pulse, 0x5000_1234u, RetailSelectionLighting.Low); + + now = 10.4; + pulse.Tick(); + AssertLighting(pulse, 0x5000_1234u, RetailSelectionLighting.High); + + now = 10.6; + pulse.Tick(); + AssertLighting(pulse, 0x5000_1234u, RetailSelectionLighting.Low); + + now = 10.8; + pulse.Tick(); + Assert.False(pulse.TryGet(0x5000_1234u, out _)); + } + + [Fact] + public void LongFrameAdvancesOnlyOneRetailGlobalLoopFlip() + { + double now = 1d; + var pulse = new RetailSelectionLightingPulse(() => now); + pulse.Start(0x5000_1234u); + + now = 20d; + pulse.Tick(); + + AssertLighting(pulse, 0x5000_1234u, RetailSelectionLighting.Low); + } + + [Fact] + public void NewClickRestoresOldTargetAndRestartsHighOnNewTarget() + { + double now = 4d; + var pulse = new RetailSelectionLightingPulse(() => now); + pulse.Start(0x5000_0001u); + + now = 4.2; + pulse.Tick(); + AssertLighting(pulse, 0x5000_0001u, RetailSelectionLighting.Low); + + pulse.Start(0x5000_0002u); + + Assert.False(pulse.TryGet(0x5000_0001u, out _)); + AssertLighting(pulse, 0x5000_0002u, RetailSelectionLighting.High); + } + + private static void AssertLighting( + RetailSelectionLightingPulse pulse, + uint serverGuid, + RetailSelectionLighting expected) + { + Assert.True(pulse.TryGet(serverGuid, out var actual)); + Assert.Equal(expected, actual); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/Wb/InstanceGroupClearTests.cs b/tests/AcDream.App.Tests/Rendering/Wb/InstanceGroupClearTests.cs index 967392e8..b15ca057 100644 --- a/tests/AcDream.App.Tests/Rendering/Wb/InstanceGroupClearTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Wb/InstanceGroupClearTests.cs @@ -7,8 +7,8 @@ namespace AcDream.App.Tests.Rendering.Wb; public class InstanceGroupClearTests { // #193 (regression from #188, 2026-07-09): WbDrawDispatcher's InstanceGroup holds - // five per-instance parallel lists — Matrices, Slots, LightSets, IndoorFlags, - // Opacities — appended in lockstep (one entry per drawn instance) every frame. The + // six per-instance parallel lists — Matrices, Slots, LightSets, IndoorFlags, + // Opacities, SelectionLighting — appended in lockstep (one entry per drawn instance) every frame. The // per-frame reset must clear ALL of them. #188 added Opacities but left it out of // the inline clear loop, so it grew one float per instance per frame forever; as // List's backing array doubled it produced ~128 MB / ~512 MB LOH float[] @@ -23,6 +23,7 @@ public class InstanceGroupClearTests grp.LightSets.Add(-1); grp.IndoorFlags.Add(0u); grp.Opacities.Add(1.0f); + grp.SelectionLighting.Add(new Vector2(0f, 1f)); grp.ClearPerInstanceData(); @@ -31,5 +32,6 @@ public class InstanceGroupClearTests Assert.Empty(grp.LightSets); Assert.Empty(grp.IndoorFlags); Assert.Empty(grp.Opacities); // #193 — the list that leaked + Assert.Empty(grp.SelectionLighting); } }