diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index ba5c2824..bcd57201 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -104,7 +104,7 @@ accepted-divergence entries (#96, #49, #50). --- -## 3. Documented approximation (AP) — 77 rows (AP-79 retired R5-V2 — the P4 TargetTracker adapter replaced by the ported TargetManager voyeur system; AP-82 added R5-V3 — sticky deep-overlap sign pin; AP-6 retired 2026-07-05 — full CCylSphere family ported verbatim, residual AP-83 added same commit; AP-84 added 2026-07-07 — CSphere collide_with_point PerfectClip TOI decoded via ACE, dead in M1.5, alongside the CSphere family port that retired TS-45; AP-86/AP-87 added 2026-07-07 — the remote-creature de-overlap #184: shadow-follows-resolved via a movement-gated re-flood, and the NPC MoveOrTeleport 4 m/no-Sequencer placement snap; AP-88 added 2026-07-08 — the remote omega player/NPC fork surviving #184 Slice 2b's fork collapse; AP-86 updated same day — the per-UP raw-pos shadow sync is now retired for players too) +## 3. Documented approximation (AP) — 78 rows (AP-79 retired R5-V2 — the P4 TargetTracker adapter replaced by the ported TargetManager voyeur system; AP-82 added R5-V3 — sticky deep-overlap sign pin; AP-6 retired 2026-07-05 — full CCylSphere family ported verbatim, residual AP-83 added same commit; AP-84 added 2026-07-07 — CSphere collide_with_point PerfectClip TOI decoded via ACE, dead in M1.5, alongside the CSphere family port that retired TS-45; AP-86/AP-87 added 2026-07-07 — the remote-creature de-overlap #184: shadow-follows-resolved via a movement-gated re-flood, and the NPC MoveOrTeleport 4 m/no-Sequencer placement snap; AP-88 added 2026-07-08 — the remote omega player/NPC fork surviving #184 Slice 2b's fork collapse; AP-86 updated same day — the per-UP raw-pos shadow sync is now retired for players too; AP-89 added 2026-07-08 — #188 fading-wall TransparentPartHook multiplies sampled texture alpha, not a separate material alpha channel) | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| @@ -191,6 +191,7 @@ accepted-divergence entries (#96, #49, #50). | AP-86 | **Remote SHADOW-follows-resolved via a movement-gated per-tick re-flood** (remote-creature de-overlap #184, 2026-07-07): a moving NPC remote's collision shadow is re-registered at its RESOLVED body position (`SyncRemoteShadowToBody` → `ShadowObjects.UpdatePosition`) so neighbours + the player de-overlap / collide against where the monster actually is (== where it renders), and the de-overlap PERSISTS. This is retail-faithful in EFFECT (retail re-registers a moved object's shadow every accepted transition step, `SetPositionInternal`→`remove/add_shadows_to_cells` 0x00515330), but the IMPLEMENTATION differs: acdream runs the FULL `RegisterMultiPart` cell-flood, gated on `|Body−LastShadowSyncPos| > 1 cm`, rather than an in-place sphere translate with cell-relink-only-on-change; and the per-UP raw-worldPos shadow sync is RETIRED for EVERY remote (Slice 2b, 2026-07-08 — was players-only through Slice 1): every remote's shadow (player + NPC) is written ONLY by this per-tick loop + the UP-branch tail, both to the RESOLVED body, since Slice 2b collapsed the player/NPC fork so grounded PLAYER remotes also run the sweep + shadow-follows-resolved. Proven: `RemoteDeOverlapMechanismTests` (with-sync 0.86 m stable vs without-sync <0.40 m; real-interp loop absorbs the stall-blip — incl. the `ConvergingPlayers_RealInterpLoop` player-config pair added in Slice 2b) | `src/AcDream.App/Rendering/GameWindow.cs` (`RemotePhysicsUpdater.SyncRemoteShadowToBody` + the DR-tick movement gate; the NPC UP-branch tail sync + the player UP-branch tail sync; the RETIRED raw-pos sync site — a comment where the players-only `:5669` block used to be); `src/AcDream.Core/Physics/ShadowObjectRegistry.cs` (`UpdatePosition`) | The movement gate is exact at the de-overlap equilibrium (the resolved position genuinely stops moving there, so no needed sync is skipped) and the flood result is identical to retail's per-step re-register; the cost is the only divergence | A dense town of many animated remotes re-floods per moving creature per tick (Gen0 churn — the MP-track FPS class); a still crowd is gated out. Optimize with an in-place shadow-move if profiling shows it. `rm.CellId==0` on a partial resolve keeps the prior cell (same pre-existing exposure as `:5669`) — only a landblock-crossing on that tick misplaces the shadow | `SetPositionInternal` 0x00515bd0 → `change_cell`/`add_shadows_to_cells` 0x00515330 (registers shadow from resolved `m_position`) | | AP-87 | **NPC MoveOrTeleport placement adds a 4 m body-to-target snap + a no-Sequencer snap** beyond retail's <96 m-unconditional interpolate (remote-creature de-overlap #184, 2026-07-07): retail `CPhysicsObj::MoveOrTeleport` (0x00516330) hard-places only on the teleport-timestamp / cell==0 branch or the ≥96 m far-snap, and InterpolateTo-queues every near correction; acdream ADDS two snap conditions — `|Body.Position − worldPos| > 4 m` (a large correction / an unplaced first-UP body) and `!willBeDrTicked` (no Sequencer to consume the queue). Without them an unplaced body (origin / spawn seed) would enqueue, the InterpolationManager's 100 m far-blip would fire, and the per-tick sweep would run over a huge distance in a cell not containing the body → garbage resolved pos → the reverted attempt's INVISIBLE monster. `firstUp` (`LastServerPosTime<=0`) is a belt hint only — the 4 m guard is the load-bearing backstop | `src/AcDream.App/Rendering/GameWindow.cs` (`OnLivePositionUpdated` NPC MoveOrTeleport routing, `BodySnapThresholdNpc`/`willBeDrTickedNpc`) | acdream's catch-up+sweep needs the body already near the target (a valid nearby cell) for the per-frame sweep to be small; the 4 m snap keeps it there, and retail's own large-correction path (the 100 m far-blip) is upstream of it. The de-overlap sweep also uses the fixed human sphere (R 0.48 / H 1.835) for the mover regardless of creature size, so large packed creatures de-overlap at human radii — inherits **TS-46** | A grounded remote that legitimately lags >4 m from its server pos snaps (a small pop) where retail would slide; a no-Sequencer server-moved entity hard-snaps every UP (no DR smoothing). Both are rare | `CPhysicsObj::MoveOrTeleport` 0x00516330 (near-interpolate <96 m; teleport/cell-0 snap; far-snap ≥96 m); `InterpolationManager` 100 m `AutonomyBlipDistance` (the retail large-correction path) | | AP-88 | **Remote omega is reconstructed with a player/NPC fork retail does not have** (remote-creature de-overlap #184 Slice 2b, 2026-07-08): retail's `UpdateObjectInternal` applies ONE angular-velocity integration to every object; acdream reconstructs remote omega from the wire and keeps a player/NPC split inherited from the two former DR paths — a grounded PLAYER remote applies `ObservedOmega ∥ seqOmega` (falls back to the sequencer's synthesised cycle omega when the wire-TurnCommand-derived `ObservedOmega` is 0 — the "circling player sends RunForward+TurnLeft on ONE UM" case) in the WORLD frame (pre-multiply, `Quaternion.Concatenate`); an NPC or an AIRBORNE body applies `ObservedOmega`-only in the BODY frame (post-multiply, `Quaternion.Multiply`). Both feed the same downstream integrate; `calc_acceleration` zeroes `Body.Omega` for grounded bodies so `UpdatePhysicsInternal` never double-integrates | `src/AcDream.App/Physics/RemotePhysicsUpdater.cs` (`Tick`, Step 2 omega fork) | For an UPRIGHT body (the only remote pose — creatures/players never pitch or roll; the wire orientation is yaw-only) rotating about world-Z (the only turn axis) the pre- and post-multiply orders COMMUTE and both branches reduce to the same yaw increment; the seqOmega fallback only adds rotation a circling player genuinely has, and applying it to NPCs would spuriously add their baked cycle omega — so the fork is behaviourally faithful for every reachable pose (it is what the pre-2b Path A / Path B already did, now explicit in one method). ALSO: the merge applies omega BEFORE `ComputeOffset` for everyone (Path B's order), whereas pre-2b Path A applied a grounded PLAYER's omega AFTER its compose — so the `ori` fed to the anim-root-motion fallback (`Transform(seqVel·dt, ori)`, used only when the interp queue is empty/head-reached) is yaw-advanced by one tick (~ω·dt ≈ 2° at 2.24 rad/s, non-accumulating, zero while the catch-up is active). Keeping Path B's order leaves the shipped/gate-passed NPC omega untouched and only nudges a turning grounded player's fallback direction ~2° in rare queue-empty frames — cosmetically negligible; adopting Path A's order instead would have perturbed NPCs by the same amount | If a remote ever acquires a non-yaw orientation (pitch/roll — a future flying mount / ragdoll) the two multiplication orders diverge and a player would rotate differently from an NPC at the same omega; collapse to one order + a shared fallback policy then | `CPhysicsObj::UpdateObjectInternal` 0x005156b0 (single apply_physics omega integration, no object-class fork); `apply_interpreted_movement` 0x00528600 (retail's server-driven omega source acdream reconstructs) | +| AP-89 | **TransparentPartHook fade multiplies the SAMPLED TEXTURE alpha, not a separate material alpha channel** (#188, 2026-07-08 — the fading-wall secret-passage doors, e.g. "Pedestal Weak Spot"): retail's `CPhysicsPart::SetTranslucency` (0x0050e670) → `CMaterial::SetTranslucencySimple` (0x005396f0) REPLACES the D3D9 material's 4 alpha channels wholesale (`Ambient.a = Diffuse.a = Specular.a = Emissive.a = 1 − translucency`) — a per-material alpha that composes with, but is conceptually separate from, the surface's own sampled texture alpha. acdream's `mesh_modern.frag` has no material-alpha concept at all; the port multiplies the runtime fade's opacity multiplier directly against the already-sampled `color.a` (`FragColor = vec4(rgb, color.a * vOpacityMultiplier)`) | `src/AcDream.App/Rendering/Shaders/mesh_modern.frag` (final `FragColor` line); `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (`ClassifyBatches` `opacityMultiplier` param, `InstanceGroup.Opacities`); `src/AcDream.Core/Rendering/TranslucencyFadeManager.cs` | Observably identical to retail for any surface whose base texture alpha is 1.0 everywhere — the Pedestal Weak Spot's stone-wall texture, and the overwhelming majority of AC surfaces, since `color.a * 1.0 == color.a` and the fade multiplier alone then drives the ramp exactly as `1 − translucency` would | A hypothetical object that is BOTH already alpha-keyed/blended from its own texture (stained glass, a flame surface) AND plays a TransparentPartHook fade simultaneously would compound the two alphas (texture-alpha × fade-multiplier) instead of the fade cleanly replacing/overriding the surface's own alpha as retail's material-replace does — such an object would fade darker / more-transparent than retail, not just at retail's rate | `CPhysicsPart::SetTranslucency` 0x0050e670; `CMaterial::SetTranslucencySimple` 0x005396f0 (`alpha = 1 − translucency`, applied to all 4 D3D9 material alpha channels) | ## 4. Temporary stopgap (TS) — 38 rows (TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-44 narrowed same day — NPC UP unified onto the interp queue, gate retained for orientation) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index ca08901a..867ab9f4 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -799,6 +799,14 @@ public sealed class GameWindow : IDisposable // from the animation pipeline flip the matching LightSource.IsLit. private AcDream.Core.Lighting.LightingHookSink? _lightingSink; + // #188 — TransparentPartHook fires from the animation pipeline drive + // a per-(entity,part) translucency ramp; WbDrawDispatcher reads it + // per frame. Wired into the hook router in OnLoad, advanced once per + // frame in the main loop regardless of _animatedEntities membership + // (a fade must keep running even if its entity's one-shot cycle ends). + private readonly AcDream.Core.Rendering.TranslucencyFadeManager _translucencyFades = new(); + private AcDream.Core.Rendering.TranslucencyHookSink? _translucencySink; + // Phase G.1 sky renderer + shared UBO. Created once the GL context // exists in OnLoad; shared across every other renderer via // binding = 1 so terrain/mesh/instanced/sky all read the same @@ -1425,6 +1433,11 @@ public sealed class GameWindow : IDisposable _lightingSink = new AcDream.Core.Lighting.LightingHookSink(Lighting); _hookRouter.Register(_lightingSink); + // #188 — TransparentPartHook (per-part translucency fade, e.g. + // the "fading wall" secret-passage doors) routes here. + _translucencySink = new AcDream.Core.Rendering.TranslucencyHookSink(_translucencyFades); + _hookRouter.Register(_translucencySink); + // Phase E.2 audio: init OpenAL + hook sink. Suppressible via // ACDREAM_NO_AUDIO=1 for headless tests / broken audio drivers. if (!_options.NoAudio) @@ -2541,7 +2554,7 @@ public sealed class GameWindow : IDisposable _wbDrawDispatcher = new AcDream.App.Rendering.Wb.WbDrawDispatcher( _gl, _meshShader!, _textureCache!, _wbMeshAdapter!, _wbEntitySpawnAdapter, _bindlessSupport!, - _classificationCache); + _classificationCache, _translucencyFades); // A.5 T22.5: apply A2C gate from quality preset. _wbDrawDispatcher.AlphaToCoverage = _resolvedQuality.AlphaToCoverage; @@ -2648,7 +2661,10 @@ public sealed class GameWindow : IDisposable _worldState.TryGetLandblock(id, out var lb)) { foreach (var ent in lb!.Entities) + { _lightingSink.UnregisterOwner(ent.Id); + _translucencyFades.ClearEntity(ent.Id); // #188 + } } _terrain?.RemoveLandblock(id); _physicsEngine.RemoveLandblock(id); @@ -4708,6 +4724,7 @@ public sealed class GameWindow : IDisposable // double-register the new one. (Was gated on logDelete — harmless only // while live weenies registered no lights, which is no longer true.) _lightingSink?.UnregisterOwner(existingEntity.Id); + _translucencyFades.ClearEntity(existingEntity.Id); // #188 return true; } @@ -7882,6 +7899,7 @@ public sealed class GameWindow : IDisposable // sort). Clear the owner's previous registration first: the // re-apply becomes idempotent, and a first apply is a no-op. _lightingSink.UnregisterOwner(entity.Id); + _translucencyFades.ClearEntity(entity.Id); // #188 var loaded = AcDream.Core.Lighting.LightInfoLoader.Load( datSetup, ownerId: entity.Id, @@ -9045,6 +9063,12 @@ public sealed class GameWindow : IDisposable if (_animatedEntities.Count > 0) TickAnimations((float)deltaSeconds); + // #188 — advance translucency fades UNCONDITIONALLY (not gated on + // _animatedEntities.Count): a one-shot open-cycle animation can + // finish and drop its entity from _animatedEntities before the + // fade's own Time has elapsed, but the ramp must keep running. + _translucencyFades.AdvanceAll((float)deltaSeconds); + // Phase G.1: weather state machine — deterministic per-day roll // + transitions + lightning flash. var cal = WorldTime.CurrentCalendar; @@ -9303,6 +9327,29 @@ public sealed class GameWindow : IDisposable // WalkEntitiesInto) treat null and an empty set identically, so an // always-non-null (possibly empty) set is behaviorally the same as // the old null-when-nothing-animated local. + // + // Every entity in _animatedEntities (i.e. every entity with a + // Sequencer) is added UNCONDITIONALLY: membership here is the + // "re-classify me every frame, don't use the Tier-1 static cache" + // flag, and the Tier-1 cache captures an entity's REST pose + + // opacity-1.0 exactly once. Any entity whose rendered state can + // depart that rest — a door held at its final OPEN frame, a wall + // held FADED-OUT by a TransparentPartHook — MUST stay on the + // per-frame path so the live sequencer pose + TranslucencyFadeManager + // opacity are read; otherwise it flips back to the stale cached + // closed/opaque state the instant its animation settles. + // + // DO NOT re-narrow this to "only if the current cycle is multi-frame" + // (the reverted IsEntityCurrentlyMoving gate, 2026-07-09): a settled + // one-shot hold IS pose-stable frame-to-frame, but stable at the + // HELD pose the cache does NOT contain — that is the door/fade + // flip-back. The per-frame re-classification cost that narrowing + // saved was a DEBUG-build artifact; Release is GPU-bound (the CPU + // work hides under GPU time), so the unconditional add is free where + // it matters. If a real Release CPU cost from static-prop + // re-classification is ever measured, gate on "entity is at its + // captured rest state" (default motion AND no active fade), never on + // "is mid-cycle". _animatedIdsScratch.Clear(); foreach (var k in _animatedEntities.Keys) _animatedIdsScratch.Add(k); @@ -10440,6 +10487,12 @@ public sealed class GameWindow : IDisposable } } + // IsEntityCurrentlyMoving REMOVED (2026-07-09): it powered a cache-bypass + // narrowing that dropped settled-open doors / faded-out walls back onto the + // stale Tier-1 rest-pose cache entry (the door/fade "flip-back"). See the + // animatedIds build site — every Sequencer entity is now added + // unconditionally, which is the known-good pre-optimization behavior. + // R3-W6: UpdatePlayerAnimation DELETED — the player's sequencer is // driven through the SAME MotionTableDispatchSink/DefaultSink funnel // remotes use (edge-driven DoMotion/StopMotion/set_hold_run in @@ -11650,7 +11703,10 @@ public sealed class GameWindow : IDisposable _worldState.TryGetLandblock(id, out var lb)) { foreach (var ent in lb!.Entities) + { _lightingSink.UnregisterOwner(ent.Id); + _translucencyFades.ClearEntity(ent.Id); // #188 + } } _terrain?.RemoveLandblock(id); _physicsEngine.RemoveLandblock(id); diff --git a/src/AcDream.App/Rendering/RenderBootstrap.cs b/src/AcDream.App/Rendering/RenderBootstrap.cs index b7ee7015..81359dbf 100644 --- a/src/AcDream.App/Rendering/RenderBootstrap.cs +++ b/src/AcDream.App/Rendering/RenderBootstrap.cs @@ -180,10 +180,13 @@ public static class RenderBootstrap // --- EntityClassificationCache (GameWindow ~217 — field initializer, new()) --- var classificationCache = new Wb.EntityClassificationCache(); + // --- TranslucencyFadeManager (GameWindow — field initializer, new()) --- + var translucencyFades = new AcDream.Core.Rendering.TranslucencyFadeManager(); + // --- WbDrawDispatcher (GameWindow ~2377-2381) --- var drawDispatcher = new Wb.WbDrawDispatcher( gl, meshShader, textureCache, meshAdapter, entitySpawnAdapter, - bindless, classificationCache); + bindless, classificationCache, translucencyFades); drawDispatcher.AlphaToCoverage = opts.Quality.AlphaToCoverage; // --- Vitals dat font (GameWindow ~1820-1822) --- diff --git a/src/AcDream.App/Rendering/Shaders/mesh_modern.frag b/src/AcDream.App/Rendering/Shaders/mesh_modern.frag index e51cbcfc..cf500f01 100644 --- a/src/AcDream.App/Rendering/Shaders/mesh_modern.frag +++ b/src/AcDream.App/Rendering/Shaders/mesh_modern.frag @@ -7,6 +7,7 @@ in vec3 vWorldPos; in vec3 vLit; // A7: per-vertex Gouraud lighting (ambient + capped lights), from mesh_modern.vert in flat uvec2 vTextureHandle; in flat uint vTextureLayer; +in flat float vOpacityMultiplier; // #188 // uRenderPass values (Phase N.5 Decision 2 — two-pass alpha-test): // 0 = opaque pass — discard fragments with alpha < 0.95 @@ -102,5 +103,9 @@ void main() { vec3 rgb = color.rgb * lit; rgb = applyFog(rgb, vWorldPos); - FragColor = vec4(rgb, color.a); + // #188: multiply the FINAL alpha only — the discard thresholds above stay + // keyed on the raw sampled color.a, so the last few frames of a fade + // (multiplier crossing under 0.05) still ramp smoothly toward zero rather + // than popping invisible early against the discard cutoff. + FragColor = vec4(rgb, color.a * vOpacityMultiplier); } diff --git a/src/AcDream.App/Rendering/Shaders/mesh_modern.vert b/src/AcDream.App/Rendering/Shaders/mesh_modern.vert index 23f5d817..a28f2893 100644 --- a/src/AcDream.App/Rendering/Shaders/mesh_modern.vert +++ b/src/AcDream.App/Rendering/Shaders/mesh_modern.vert @@ -105,6 +105,16 @@ layout(std430, binding = 6) readonly buffer InstanceIndoorBuf { uint instanceIndoor[]; }; +// #188: per-instance opacity multiplier, 1 per instance, parallel to the +// binding=0 instance buffer (same instanceIndex). 1.0 = unmodified; <1.0 +// while a TransparentPartHook translucency fade is in flight for the +// entity/part this instance belongs to (e.g. the "fading wall" secret- +// passage doors). Multiplied against the sampled texture alpha in +// mesh_modern.frag. +layout(std430, binding = 7) readonly buffer InstanceAlphaBuf { + float instanceAlpha[]; +}; + // 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 @@ -266,10 +276,12 @@ out vec3 vWorldPos; out vec3 vLit; // A7: per-vertex Gouraud lighting (ambient + capped lights) out flat uvec2 vTextureHandle; out flat uint vTextureLayer; +out flat float vOpacityMultiplier; // #188 void main() { int instanceIndex = gl_BaseInstanceARB + gl_InstanceID; mat4 model = Instances[instanceIndex].transform; + vOpacityMultiplier = instanceAlpha[instanceIndex]; // #188 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 772ea9f2..8fb2a90c 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs @@ -107,6 +107,11 @@ public sealed unsafe class WbDrawDispatcher : IDisposable // miss-populate / hit-fast-path through the loop. private readonly EntityClassificationCache _cache; + // #188 — per-(entity, Setup-part) translucency ramp state (fading doors / + // secret-passage walls). ClassifyBatches reads this per part to compute + // the instance's opacity multiplier; never mutated here. + private readonly AcDream.Core.Rendering.TranslucencyFadeManager _translucencyFades; + // ACDREAM_DISABLE_TIER1_CACHE=1 A/B diagnostic — forces every static // entity through the slow path. Read once in ctor. private readonly bool _tier1CacheDisabled = @@ -150,6 +155,14 @@ public sealed unsafe class WbDrawDispatcher : IDisposable // Mechanically a clone of _clipSlotData / _clipSlotSsbo. private uint _instIndoorSsbo; private uint[] _indoorData = new uint[256]; + + // #188: per-instance opacity multiplier (binding=7), one float per + // instance, parallel to _instanceSsbo. 1.0 = unmodified (the dat's own + // material/texture alpha, untouched); < 1.0 multiplies the shader's + // sampled alpha for an entity mid-TransparentPartHook fade. Mechanically + // a clone of _indoorData / _instIndoorSsbo, one binding higher. + private uint _instAlphaSsbo; + private float[] _alphaData = new float[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; @@ -340,7 +353,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable WbMeshAdapter meshAdapter, EntitySpawnAdapter entitySpawnAdapter, BindlessSupport bindless, - EntityClassificationCache classificationCache) + EntityClassificationCache classificationCache, + AcDream.Core.Rendering.TranslucencyFadeManager translucencyFades) { ArgumentNullException.ThrowIfNull(gl); ArgumentNullException.ThrowIfNull(shader); @@ -348,6 +362,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable ArgumentNullException.ThrowIfNull(meshAdapter); ArgumentNullException.ThrowIfNull(entitySpawnAdapter); ArgumentNullException.ThrowIfNull(classificationCache); + ArgumentNullException.ThrowIfNull(translucencyFades); _gl = gl; _shader = shader; @@ -355,6 +370,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable _meshAdapter = meshAdapter; _entitySpawnAdapter = entitySpawnAdapter; _cache = classificationCache; + _translucencyFades = translucencyFades; _bindless = bindless ?? throw new ArgumentNullException(nameof(bindless)); _instanceSsbo = _gl.GenBuffer(); @@ -364,6 +380,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable _globalLightsSsbo = _gl.GenBuffer(); // Fix B binding=4 _instLightSetSsbo = _gl.GenBuffer(); // Fix B binding=5 _instIndoorSsbo = _gl.GenBuffer(); // #142 binding=6 + _instAlphaSsbo = _gl.GenBuffer(); // #188 binding=7 } /// @@ -1290,8 +1307,17 @@ public sealed unsafe class WbDrawDispatcher : IDisposable bool drewAny = false; if (renderData.IsSetup && renderData.SetupParts.Count > 0) { - foreach (var (partGfxObjId, partTransform) in renderData.SetupParts) + // #188: setupPartIndex is the SAME index space + // TransparentPartHook.PartIndex addresses — retail's CPartArray + // numbers parts by their ordinal position in the Setup's own + // part list (SetupPartTransforms.Compute is the other verified + // consumer of this exact indexing: one Matrix4x4 per + // Setup.Parts[i]). NOT the outer per-MeshRef loop index — a + // MeshRef is acdream's own decomposition of top-level + // attachments (weapon/shield/etc), a different concept. + for (int setupPartIndex = 0; setupPartIndex < renderData.SetupParts.Count; setupPartIndex++) { + var (partGfxObjId, partTransform) = renderData.SetupParts[setupPartIndex]; var partData = _meshAdapter.TryGetRenderData(partGfxObjId); if (partData is null) { @@ -1332,15 +1358,42 @@ public sealed unsafe class WbDrawDispatcher : IDisposable } var restPose = partTransform * meshRef.PartTransform; - ClassifyBatches(partData, partGfxObjId, model, entity, meshRef, palHash, metaTable, restPose, collector); + + // #188 retail CPhysicsPart::Draw (0x0050d7a0) early-out: once a + // part's translucency hits EXACTLY 1.0 (fully invisible), retail + // sets draw_state|=1 and skips the whole part outright — not a + // blend to nothing. TranslucencyFadeManager.AdvanceAll guarantees + // t=1 commits the bitwise-exact value so this check is safe. + float opacityMultiplier = 1.0f; + if (_translucencyFades.TryGetCurrentValue(entity.Id, (uint)setupPartIndex, out float translucencyValue)) + { + if (translucencyValue >= 1.0f) continue; // skip this part's draw entirely + opacityMultiplier = 1f - translucencyValue; // CMaterial::SetTranslucencySimple 0x005396f0 + } + + ClassifyBatches(partData, partGfxObjId, model, entity, meshRef, palHash, metaTable, restPose, opacityMultiplier, collector); drewAny = true; } } else { - var model = meshRef.PartTransform * entityWorld; - ClassifyBatches(renderData, gfxObjId, model, entity, meshRef, palHash, metaTable, restPose: meshRef.PartTransform, collector: collector); - drewAny = true; + // #188: a bare (non-Setup) GfxObj entity has exactly one part — + // retail's CPartArray for such an object is a single-entry array, + // so TransparentPartHook.PartIndex for it is always 0. + float opacityMultiplier = 1.0f; + bool fullyInvisible = false; + if (_translucencyFades.TryGetCurrentValue(entity.Id, 0u, out float translucencyValue)) + { + if (translucencyValue >= 1.0f) fullyInvisible = true; + else opacityMultiplier = 1f - translucencyValue; + } + + if (!fullyInvisible) + { + var model = meshRef.PartTransform * entityWorld; + ClassifyBatches(renderData, gfxObjId, model, entity, meshRef, palHash, metaTable, restPose: meshRef.PartTransform, opacityMultiplier: opacityMultiplier, collector: collector); + drewAny = true; + } } // Track THIS entity for the next iteration's flush check. Only @@ -1427,6 +1480,11 @@ public sealed unsafe class WbDrawDispatcher : IDisposable if (_indoorData.Length < totalInstances) _indoorData = new uint[totalInstances + 256]; + // #188: per-instance opacity buffer, one float per instance, parallel to + // _clipSlotData / _instanceData. Grown on demand like the others. + if (_alphaData.Length < totalInstances) + _alphaData = new float[totalInstances + 256]; + _opaqueDraws.Clear(); _translucentDraws.Clear(); @@ -1462,6 +1520,9 @@ public sealed unsafe class WbDrawDispatcher : IDisposable // #142: IndoorFlags[] is parallel to Matrices[]; write at the same // cursor so binding=6 instanceIndoor[] tracks binding=0 instances. _indoorData[cursor] = grp.IndoorFlags[i]; + // #188: Opacities[] is parallel to Matrices[]; write at the same + // cursor so binding=7 instanceAlpha[] tracks binding=0 instances. + _alphaData[cursor] = grp.Opacities[i]; cursor++; } @@ -1553,6 +1614,12 @@ public sealed unsafe class WbDrawDispatcher : IDisposable fixed (uint* dp = _indoorData) UploadSsbo(_instIndoorSsbo, 6, dp, totalInstances * sizeof(uint)); + // #188: per-instance opacity buffer (binding=7), one float per instance, + // laid out parallel to _instanceData in Phase 3. Only [0..totalInstances) + // is uploaded — stale tail never read (same guarantee as clip-slot above). + fixed (float* ap = _alphaData) + UploadSsbo(_instAlphaSsbo, 7, ap, totalInstances * sizeof(float)); + // 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 @@ -2056,6 +2123,11 @@ public sealed unsafe class WbDrawDispatcher : IDisposable grp.Matrices.Add(model); grp.Slots.Add(_currentEntitySlot); // Phase U.4 — parallel to Matrices AppendCurrentLightSet(grp); // Fix B — 8 ints per instance, parallel to Matrices + // #188: cache-hit entities are always non-animated (the Tier-1 cache + // gates on !isAnimated), and TranslucencyFadeManager only ever holds + // state for entities whose animation hooks fired — so a cached + // instance can never be mid-fade. Always unmodified opacity. + grp.Opacities.Add(1.0f); } /// @@ -2181,6 +2253,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable ulong palHash, AcSurfaceMetadataTable metaTable, Matrix4x4 restPose, + float opacityMultiplier = 1.0f, List? collector = null) { for (int batchIdx = 0; batchIdx < renderData.Batches.Count; batchIdx++) @@ -2199,6 +2272,13 @@ public sealed unsafe class WbDrawDispatcher : IDisposable : TranslucencyKind.Opaque; } + // #188: a mid-fade instance whose surface is otherwise Opaque/ClipMap + // must route through the alpha-blend pass so mesh_modern.frag's + // (blend-enabled) shader actually composites the reduced alpha — + // the no-blend opaque pass would ignore it. + if (opacityMultiplier < 1.0f && IsOpaque(translucency)) + translucency = TranslucencyKind.AlphaBlend; + ulong texHandle = ResolveTexture(entity, meshRef, batch, palHash); if (texHandle == 0) continue; @@ -2228,6 +2308,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable grp.Matrices.Add(model); 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 collector?.Add(new CachedBatch(key, texHandle, restPose)); } } @@ -2310,6 +2391,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable if (_globalLightsSsbo != 0) _gl.DeleteBuffer(_globalLightsSsbo); // Fix B binding=4 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 (_gpuQueriesInitialized) { for (int i = 0; i < GpuQueryRingDepth; i++) @@ -2509,5 +2591,12 @@ public sealed unsafe class WbDrawDispatcher : IDisposable // for outdoor objects (gets the sun). Written into _indoorData at the same // cursor as Matrices, so binding=6 instanceIndoor[] tracks binding=0. public readonly List IndoorFlags = new(); + + // #188: per-instance opacity multiplier, parallel to Matrices. + // Opacities[i] is 1.0=unmodified, or <1.0 while a TransparentPartHook + // fade is in flight for the instance whose matrix is Matrices[i]. At + // layout time the dispatcher writes Opacities[i] into _alphaData at + // the same cursor, so the binding=7 instanceAlpha[] tracks binding=0. + public readonly List Opacities = new(); } } diff --git a/src/AcDream.Core/Physics/IAnimationHookSink.cs b/src/AcDream.Core/Physics/IAnimationHookSink.cs index 259df82b..9e4201b4 100644 --- a/src/AcDream.Core/Physics/IAnimationHookSink.cs +++ b/src/AcDream.Core/Physics/IAnimationHookSink.cs @@ -34,8 +34,14 @@ namespace AcDream.Core.Physics; /// (animation-hook frame = damage frame for melee / thrown). /// /// +/// → #188 +/// AcDream.Core.Rendering.TranslucencyHookSink (per-Setup-part +/// translucency ramp; see TranslucencyFadeManager). +/// +/// /// , -/// , +/// (whole-object variant — NOT yet +/// ported, #188 scope was the per-part case only), /// , /// , /// , @@ -43,7 +49,9 @@ namespace AcDream.Core.Physics; /// , /// , /// → -/// GfxObjMesh / renderer state mutations on the target entity. +/// GfxObjMesh / renderer state mutations on the target entity +/// ( is the one exception already wired, +/// via AcDream.Core.Lighting.LightingHookSink). /// /// /// → UI / controller notifications diff --git a/src/AcDream.Core/Rendering/TranslucencyFadeManager.cs b/src/AcDream.Core/Rendering/TranslucencyFadeManager.cs new file mode 100644 index 00000000..58be5c04 --- /dev/null +++ b/src/AcDream.Core/Rendering/TranslucencyFadeManager.cs @@ -0,0 +1,167 @@ +using System; +using System.Collections.Generic; + +namespace AcDream.Core.Rendering; + +/// +/// #188 — per-(entity, Setup-part) translucency ramp state. Ports retail's +/// FPHook TRANSLUCENCY / PART_TRANSLUCENCY interpolation +/// (CPhysicsObj::SetPartTranslucency 0x00511730, +/// FPHook::Execute 0x0051baa0, CPhysicsObj::process_fp_hook +/// 0x005135c0): a linear ramp from Start to End over +/// Time seconds, ticked every frame rather than retail's per-object +/// physics tick (behaviorally identical — both re-evaluate the same +/// elapsed/duration ratio every simulated step). +/// +/// +/// Retail's translucency scale is the OPPOSITE of "opacity": 0 = fully +/// solid/opaque, 1 = fully invisible (CMaterial::SetTranslucencySimple +/// 0x005396f0: alpha = 1 - translucency). Values here are stored on +/// that same [0,1] translucency scale — callers convert to an alpha/opacity +/// multiplier at the point of use. +/// +/// +/// +/// Retail's instant-apply shortcut: if the hook's Time is at or below +/// ~0.2 ms, the target value is applied immediately with no ramp +/// (SetPartTranslucency's epsilon check). +/// is that exact retail constant. +/// +/// +public sealed class TranslucencyFadeManager +{ + /// + /// Retail's exact epsilon (CPhysicsObj::SetPartTranslucency + /// 0x00511730): a hook Time at or below this is applied + /// instantly, no ramp. + /// + public const float TranslucencyInstantEpsilon = 0.000199999995f; + + private sealed class Fade + { + public float Elapsed; + public float Duration; + public float Start; + public float End; + } + + // entityId -> partIndex -> in-flight ramp. + private readonly Dictionary> _activeFades = new(); + + // entityId -> partIndex -> current committed translucency value + // ([0,1], retail scale). Persists after a fade completes so later + // frames keep reading the settled value. + private readonly Dictionary> _committed = new(); + + /// + /// Start (or replace) a translucency ramp for one Setup part of one + /// entity. Mirrors CPhysicsObj::SetPartTranslucency: a + /// near-zero applies + /// immediately; otherwise the value ramps from + /// to across + /// seconds, advanced by . + /// + public void StartPartFade(uint entityId, uint partIndex, float start, float end, float time) + { + if (time <= TranslucencyInstantEpsilon) + { + if (_activeFades.TryGetValue(entityId, out var activeForEntity)) + activeForEntity.Remove(partIndex); + Commit(entityId, partIndex, end); + return; + } + + if (!_activeFades.TryGetValue(entityId, out var fades)) + { + fades = new Dictionary(); + _activeFades[entityId] = fades; + } + fades[partIndex] = new Fade { Elapsed = 0f, Duration = time, Start = start, End = end }; + + // FPHook's first Execute call (at elapsed=0) already reports + // value=start — commit it now so a reader on the same frame the + // hook fired sees the ramp's starting value, not the stale prior one. + Commit(entityId, partIndex, start); + } + + /// + /// Advance every in-flight fade by seconds + /// (plain linear interpolation, no easing — matches + /// FPHook::Execute). A fade that reaches its duration commits + /// the bitwise-exact End value and stops advancing (retail + /// deletes the FPHook at that point); the committed value stays + /// readable via . + /// + public void AdvanceAll(float dt) + { + if (_activeFades.Count == 0) return; + + List? emptyEntities = null; + foreach (var (entityId, fades) in _activeFades) + { + List? finished = null; + foreach (var (partIndex, fade) in fades) + { + fade.Elapsed += dt; + float t = fade.Duration <= TranslucencyInstantEpsilon + ? 1f + : Math.Clamp(fade.Elapsed / fade.Duration, 0f, 1f); + + float value = t >= 1f ? fade.End : fade.Start + (fade.End - fade.Start) * t; + Commit(entityId, partIndex, value); + + if (t >= 1f) + { + finished ??= new List(); + finished.Add(partIndex); + } + } + + if (finished is not null) + { + foreach (var partIndex in finished) + fades.Remove(partIndex); + if (fades.Count == 0) + { + emptyEntities ??= new List(); + emptyEntities.Add(entityId); + } + } + } + + if (emptyEntities is not null) + foreach (var entityId in emptyEntities) + _activeFades.Remove(entityId); + } + + /// + /// Read the current committed translucency value ([0,1], retail + /// scale — 0 opaque, 1 invisible) for one entity part. Returns false + /// if this part has never had a fade start (caller should fall back + /// to the dat-authored default — a no-op). + /// + public bool TryGetCurrentValue(uint entityId, uint partIndex, out float value) + { + if (_committed.TryGetValue(entityId, out var parts) && parts.TryGetValue(partIndex, out value)) + return true; + value = 0f; + return false; + } + + /// Drop all fade state for an entity (despawn / unload). + public void ClearEntity(uint entityId) + { + _activeFades.Remove(entityId); + _committed.Remove(entityId); + } + + private void Commit(uint entityId, uint partIndex, float value) + { + if (!_committed.TryGetValue(entityId, out var parts)) + { + parts = new Dictionary(); + _committed[entityId] = parts; + } + parts[partIndex] = value; + } +} diff --git a/src/AcDream.Core/Rendering/TranslucencyHookSink.cs b/src/AcDream.Core/Rendering/TranslucencyHookSink.cs new file mode 100644 index 00000000..ee39c8b7 --- /dev/null +++ b/src/AcDream.Core/Rendering/TranslucencyHookSink.cs @@ -0,0 +1,38 @@ +using System; +using System.Numerics; +using AcDream.Core.Physics; +using DatReaderWriter.Types; + +namespace AcDream.Core.Rendering; + +/// +/// #188 — routes animation hooks to a +/// . Retail's +/// TransparentPartHook::Execute (0x00526cc0) is a one-liner calling +/// CPhysicsObj::SetPartTranslucency(obj, part, start, end, time) — +/// this sink is that same one-line forward. +/// +/// +/// Whole-object and +/// are deliberately NOT handled here. Retail's EtherealHook::Execute +/// (0x00526bc0) touches only CPhysicsObj::set_ethereal — collision +/// state, zero rendering coupling — and acdream's collision-passthrough +/// already applies server-authoritative via the SetState wire +/// message, independent of this hook firing client-side. +/// +/// +public sealed class TranslucencyHookSink : IAnimationHookSink +{ + private readonly TranslucencyFadeManager _fades; + + public TranslucencyHookSink(TranslucencyFadeManager fades) + { + _fades = fades ?? throw new ArgumentNullException(nameof(fades)); + } + + public void OnHook(uint entityId, Vector3 entityWorldPosition, AnimationHook hook) + { + if (hook is not TransparentPartHook tph) return; + _fades.StartPartFade(entityId, tph.PartIndex, tph.Start, tph.End, tph.Time); + } +} diff --git a/tests/AcDream.Core.Tests/Physics/Issue188FadingDoorMotionTableInspectionTests.cs b/tests/AcDream.Core.Tests/Physics/Issue188FadingDoorMotionTableInspectionTests.cs index c04e3a25..289c97c4 100644 --- a/tests/AcDream.Core.Tests/Physics/Issue188FadingDoorMotionTableInspectionTests.cs +++ b/tests/AcDream.Core.Tests/Physics/Issue188FadingDoorMotionTableInspectionTests.cs @@ -48,8 +48,10 @@ public class Issue188FadingDoorMotionTableInspectionTests foreach (var t in asm.GetTypes().Where(t => t.Name.Contains("Hook", StringComparison.OrdinalIgnoreCase))) { _out.WriteLine($"=== type {t.FullName} (base={t.BaseType?.Name}) ==="); - foreach (var p in t.GetProperties(BindingFlags.Public | BindingFlags.Instance)) - _out.WriteLine($" {p.PropertyType.Name} {p.Name}"); + foreach (var p in t.GetProperties(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)) + _out.WriteLine($" [prop] {p.PropertyType.Name} {p.Name}"); + foreach (var f in t.GetFields(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)) + _out.WriteLine($" [field] {f.FieldType.Name} {f.Name}"); } void DumpMembers(Type t, string label) @@ -107,6 +109,7 @@ public class Issue188FadingDoorMotionTableInspectionTests if (anim is null) { _out.WriteLine($" [{source}] anim 0x{animId:X8} NOT FOUND"); continue; } int frameCount = 0, hookCount = 0; + int frameIdx = 0; foreach (var frame in GetFrames(anim)) { frameCount++; @@ -114,7 +117,14 @@ public class Issue188FadingDoorMotionTableInspectionTests { hookCount++; hookTypesSeen.Add(hook.GetType().Name); + // Dump every field's actual VALUE for this specific hook instance + // (not just the type) -- what alpha/duration retail authored. + var fieldDump = string.Join(" ", hook.GetType() + .GetFields(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly) + .Select(f => $"{f.Name}={f.GetValue(hook)}")); + _out.WriteLine($" frame[{frameIdx}] {hook.GetType().Name}: {fieldDump}"); } + frameIdx++; } _out.WriteLine($" [{source}] anim 0x{animId:X8}: frames={frameCount} hooks={hookCount}"); } diff --git a/tests/AcDream.Core.Tests/Rendering/TranslucencyFadeManagerTests.cs b/tests/AcDream.Core.Tests/Rendering/TranslucencyFadeManagerTests.cs new file mode 100644 index 00000000..41be6390 --- /dev/null +++ b/tests/AcDream.Core.Tests/Rendering/TranslucencyFadeManagerTests.cs @@ -0,0 +1,152 @@ +using AcDream.Core.Rendering; +using Xunit; + +namespace AcDream.Core.Tests.Rendering; + +public sealed class TranslucencyFadeManagerTests +{ + [Fact] + public void StartPartFade_InstantEpsilon_CommitsEndImmediately() + { + var mgr = new TranslucencyFadeManager(); + + mgr.StartPartFade(entityId: 1, partIndex: 0, start: 0f, end: 1f, + time: TranslucencyFadeManager.TranslucencyInstantEpsilon); + + Assert.True(mgr.TryGetCurrentValue(1, 0, out float value)); + Assert.Equal(1f, value); + + // No ramp survives — advancing further changes nothing. + mgr.AdvanceAll(10f); + Assert.True(mgr.TryGetCurrentValue(1, 0, out float after)); + Assert.Equal(1f, after); + } + + [Fact] + public void StartPartFade_ZeroTime_IsTreatedAsInstant() + { + var mgr = new TranslucencyFadeManager(); + mgr.StartPartFade(entityId: 5, partIndex: 2, start: 0f, end: 0.5f, time: 0f); + + Assert.True(mgr.TryGetCurrentValue(5, 2, out float value)); + Assert.Equal(0.5f, value); + } + + [Fact] + public void StartPartFade_RealDuration_ImmediatelyReadableAsStart() + { + var mgr = new TranslucencyFadeManager(); + mgr.StartPartFade(entityId: 1, partIndex: 0, start: 0.2f, end: 0.8f, time: 1f); + + // Before any AdvanceAll — the ramp's t=0 value must already be readable + // (FPHook's first Execute call reports value=start on the same frame). + Assert.True(mgr.TryGetCurrentValue(1, 0, out float value)); + Assert.Equal(0.2f, value); + } + + [Fact] + public void AdvanceAll_LinearInterpolation_MidpointIsExact() + { + var mgr = new TranslucencyFadeManager(); + mgr.StartPartFade(entityId: 1, partIndex: 0, start: 0f, end: 1f, time: 1f); + + mgr.AdvanceAll(0.5f); + + Assert.True(mgr.TryGetCurrentValue(1, 0, out float value)); + Assert.Equal(0.5f, value, 5); + } + + [Fact] + public void AdvanceAll_AsymmetricRange_LinearAtQuarterDuration() + { + var mgr = new TranslucencyFadeManager(); + mgr.StartPartFade(entityId: 1, partIndex: 0, start: 0.2f, end: 1.0f, time: 4f); + + mgr.AdvanceAll(1f); // t = 0.25 + + Assert.True(mgr.TryGetCurrentValue(1, 0, out float value)); + Assert.Equal(0.2f + (1.0f - 0.2f) * 0.25f, value, 5); + } + + [Fact] + public void AdvanceAll_Overshoot_CommitsExactEndValue() + { + var mgr = new TranslucencyFadeManager(); + mgr.StartPartFade(entityId: 1, partIndex: 0, start: 0f, end: 1f, time: 0.267f); + + mgr.AdvanceAll(10f); // massive overshoot past duration + + Assert.True(mgr.TryGetCurrentValue(1, 0, out float value)); + Assert.Equal(1f, value); // bitwise-exact, not approximately-equal + } + + [Fact] + public void AdvanceAll_PastCompletion_IsIdempotent() + { + var mgr = new TranslucencyFadeManager(); + mgr.StartPartFade(entityId: 1, partIndex: 0, start: 0f, end: 1f, time: 0.1f); + + mgr.AdvanceAll(1f); + Assert.True(mgr.TryGetCurrentValue(1, 0, out float first)); + + mgr.AdvanceAll(1f); // ramp already finished — a second advance must not change anything + Assert.True(mgr.TryGetCurrentValue(1, 0, out float second)); + + Assert.Equal(first, second); + Assert.Equal(1f, second); + } + + [Fact] + public void ClearEntity_RemovesActiveAndCommittedState() + { + var mgr = new TranslucencyFadeManager(); + mgr.StartPartFade(entityId: 1, partIndex: 0, start: 0f, end: 1f, time: 1f); + Assert.True(mgr.TryGetCurrentValue(1, 0, out _)); + + mgr.ClearEntity(1); + + Assert.False(mgr.TryGetCurrentValue(1, 0, out _)); + mgr.AdvanceAll(1f); // must not throw even though the entity is gone + Assert.False(mgr.TryGetCurrentValue(1, 0, out _)); + } + + [Fact] + public void StartPartFade_SecondCallReplacesFirst() + { + var mgr = new TranslucencyFadeManager(); + mgr.StartPartFade(entityId: 1, partIndex: 0, start: 0f, end: 1f, time: 10f); + mgr.AdvanceAll(1f); // 10% into the first (abandoned) ramp + + // Restart with a fresh, shorter ramp — the old target must be abandoned. + mgr.StartPartFade(entityId: 1, partIndex: 0, start: 0.9f, end: 0f, time: 1f); + Assert.True(mgr.TryGetCurrentValue(1, 0, out float atRestart)); + Assert.Equal(0.9f, atRestart); + + mgr.AdvanceAll(1f); // finishes the SECOND ramp, not the first + Assert.True(mgr.TryGetCurrentValue(1, 0, out float value)); + Assert.Equal(0f, value); + } + + [Fact] + public void TryGetCurrentValue_UnknownPart_ReturnsFalse() + { + var mgr = new TranslucencyFadeManager(); + Assert.False(mgr.TryGetCurrentValue(entityId: 999, partIndex: 0, out float value)); + Assert.Equal(0f, value); + } + + [Fact] + public void MultiplePartsOnSameEntity_AdvanceIndependently() + { + var mgr = new TranslucencyFadeManager(); + mgr.StartPartFade(entityId: 1, partIndex: 0, start: 0f, end: 1f, time: 1f); + mgr.StartPartFade(entityId: 1, partIndex: 1, start: 0f, end: 1f, time: 2f); + + mgr.AdvanceAll(1f); + + Assert.True(mgr.TryGetCurrentValue(1, 0, out float part0)); + Assert.True(mgr.TryGetCurrentValue(1, 1, out float part1)); + Assert.Equal(1f, part0); // part 0's 1s ramp is done + Assert.Equal(0.5f, part1, 5); // part 1's 2s ramp is halfway + } +} diff --git a/tests/AcDream.Core.Tests/Rendering/TranslucencyHookSinkTests.cs b/tests/AcDream.Core.Tests/Rendering/TranslucencyHookSinkTests.cs new file mode 100644 index 00000000..8917d3f4 --- /dev/null +++ b/tests/AcDream.Core.Tests/Rendering/TranslucencyHookSinkTests.cs @@ -0,0 +1,70 @@ +using System.Numerics; +using AcDream.Core.Rendering; +using DatReaderWriter.Types; +using Xunit; + +namespace AcDream.Core.Tests.Rendering; + +public sealed class TranslucencyHookSinkTests +{ + [Fact] + public void TransparentPartHook_ForwardsFieldsVerbatimToManager() + { + var mgr = new TranslucencyFadeManager(); + var sink = new TranslucencyHookSink(mgr); + + var hook = new TransparentPartHook { PartIndex = 3, Start = 0.1f, End = 0.9f, Time = 0.5f }; + sink.OnHook(entityId: 42, entityWorldPosition: Vector3.Zero, hook: hook); + + Assert.True(mgr.TryGetCurrentValue(42, 3, out float value)); + Assert.Equal(0.1f, value); // t=0 value, readable immediately after the hook fires + } + + [Fact] + public void TransparentPartHook_InstantTime_CommitsEndImmediately() + { + var mgr = new TranslucencyFadeManager(); + var sink = new TranslucencyHookSink(mgr); + + var hook = new TransparentPartHook { PartIndex = 0, Start = 0f, End = 1f, Time = 0f }; + sink.OnHook(entityId: 1, entityWorldPosition: Vector3.Zero, hook: hook); + + Assert.True(mgr.TryGetCurrentValue(1, 0, out float value)); + Assert.Equal(1f, value); + } + + [Fact] + public void EtherealHook_IsIgnored() + { + var mgr = new TranslucencyFadeManager(); + var sink = new TranslucencyHookSink(mgr); + + sink.OnHook(entityId: 1, entityWorldPosition: Vector3.Zero, hook: new EtherealHook { Ethereal = true }); + + Assert.False(mgr.TryGetCurrentValue(1, 0, out _)); + } + + [Fact] + public void WholeObjectTransparentHook_IsIgnored() + { + var mgr = new TranslucencyFadeManager(); + var sink = new TranslucencyHookSink(mgr); + + // #188 scope: the whole-object variant is deliberately NOT ported this pass. + sink.OnHook(entityId: 1, entityWorldPosition: Vector3.Zero, + hook: new TransparentHook { Start = 0f, End = 1f, Time = 1f }); + + Assert.False(mgr.TryGetCurrentValue(1, 0, out _)); + } + + [Fact] + public void UnrelatedHook_IsIgnored() + { + var mgr = new TranslucencyFadeManager(); + var sink = new TranslucencyHookSink(mgr); + + sink.OnHook(entityId: 1, entityWorldPosition: Vector3.Zero, hook: new SoundTableHook()); + + Assert.False(mgr.TryGetCurrentValue(1, 0, out _)); + } +}