feat(#188): fading-wall + sliding-door translucency; hold open past animation settle

Lands the fading-secret-door feature and fixes the door "flip-back" that
surfaced while testing it.

#188 — fading-wall doors (e.g. "Pedestal Weak Spot") fade their wall part
out via TransparentPartHook instead of swinging:
  - TranslucencyHookSink consumes TransparentPartHook -> TranslucencyFadeManager
    (per-(entity,part) linear translucency ramp; holds at End frame).
  - WbDrawDispatcher: new per-instance alpha SSBO (binding 7); ClassifyBatches
    takes opacityMultiplier (1 - translucency, per CMaterial::SetTranslucencySimple
    0x005396f0) forcing AlphaBlend; fully-invisible parts skipped.
  - mesh_modern.vert/.frag: binding-7 InstanceAlphaBuf -> vOpacityMultiplier ->
    FragColor.a *= vOpacityMultiplier.
  - Register AP-89: the fade multiplies sampled texture alpha, not a separate
    D3D9 material alpha channel (observably identical for texture-alpha==1 surfaces).

Door flip-back fix (affected BOTH #188 fading walls AND #187 sliding doors): a
door/wall that finished opening holds a single unchanging frame, so the
uncommitted IsEntityCurrentlyMoving cache-bypass narrowing dropped it onto the
Tier-1 static cache -- which only remembers the REST pose + opacity 1.0 --
snapping it visually shut/opaque while physics stayed open. Reverted that
narrowing: every Sequencer entity stays on the per-frame path (live pose + live
fade opacity), the known-good pre-optimization behavior. The per-frame CPU cost
that narrowing chased was a Debug-build artifact -- Release is GPU-bound
(~200 fps in Sawato, measured), so the unconditional add is free where it
matters. Left a code comment barring re-introduction.

Tests: full Core suite green (2649 passed, 2 skipped). Live visual gate PASSED --
both fading-wall and sliding doors hold open.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-09 09:17:50 +02:00
parent e2e285b855
commit 3284dd0aed
12 changed files with 625 additions and 14 deletions

View file

@ -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 | | # | 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 `|BodyLastShadowSyncPos| > 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-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 `|BodyLastShadowSyncPos| > 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-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-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) ## 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)

View file

@ -799,6 +799,14 @@ public sealed class GameWindow : IDisposable
// from the animation pipeline flip the matching LightSource.IsLit. // from the animation pipeline flip the matching LightSource.IsLit.
private AcDream.Core.Lighting.LightingHookSink? _lightingSink; 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 // Phase G.1 sky renderer + shared UBO. Created once the GL context
// exists in OnLoad; shared across every other renderer via // exists in OnLoad; shared across every other renderer via
// binding = 1 so terrain/mesh/instanced/sky all read the same // 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); _lightingSink = new AcDream.Core.Lighting.LightingHookSink(Lighting);
_hookRouter.Register(_lightingSink); _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 // Phase E.2 audio: init OpenAL + hook sink. Suppressible via
// ACDREAM_NO_AUDIO=1 for headless tests / broken audio drivers. // ACDREAM_NO_AUDIO=1 for headless tests / broken audio drivers.
if (!_options.NoAudio) if (!_options.NoAudio)
@ -2541,7 +2554,7 @@ public sealed class GameWindow : IDisposable
_wbDrawDispatcher = new AcDream.App.Rendering.Wb.WbDrawDispatcher( _wbDrawDispatcher = new AcDream.App.Rendering.Wb.WbDrawDispatcher(
_gl, _meshShader!, _textureCache!, _wbMeshAdapter!, _wbEntitySpawnAdapter, _bindlessSupport!, _gl, _meshShader!, _textureCache!, _wbMeshAdapter!, _wbEntitySpawnAdapter, _bindlessSupport!,
_classificationCache); _classificationCache, _translucencyFades);
// A.5 T22.5: apply A2C gate from quality preset. // A.5 T22.5: apply A2C gate from quality preset.
_wbDrawDispatcher.AlphaToCoverage = _resolvedQuality.AlphaToCoverage; _wbDrawDispatcher.AlphaToCoverage = _resolvedQuality.AlphaToCoverage;
@ -2648,7 +2661,10 @@ public sealed class GameWindow : IDisposable
_worldState.TryGetLandblock(id, out var lb)) _worldState.TryGetLandblock(id, out var lb))
{ {
foreach (var ent in lb!.Entities) foreach (var ent in lb!.Entities)
{
_lightingSink.UnregisterOwner(ent.Id); _lightingSink.UnregisterOwner(ent.Id);
_translucencyFades.ClearEntity(ent.Id); // #188
}
} }
_terrain?.RemoveLandblock(id); _terrain?.RemoveLandblock(id);
_physicsEngine.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 // double-register the new one. (Was gated on logDelete — harmless only
// while live weenies registered no lights, which is no longer true.) // while live weenies registered no lights, which is no longer true.)
_lightingSink?.UnregisterOwner(existingEntity.Id); _lightingSink?.UnregisterOwner(existingEntity.Id);
_translucencyFades.ClearEntity(existingEntity.Id); // #188
return true; return true;
} }
@ -7882,6 +7899,7 @@ public sealed class GameWindow : IDisposable
// sort). Clear the owner's previous registration first: the // sort). Clear the owner's previous registration first: the
// re-apply becomes idempotent, and a first apply is a no-op. // re-apply becomes idempotent, and a first apply is a no-op.
_lightingSink.UnregisterOwner(entity.Id); _lightingSink.UnregisterOwner(entity.Id);
_translucencyFades.ClearEntity(entity.Id); // #188
var loaded = AcDream.Core.Lighting.LightInfoLoader.Load( var loaded = AcDream.Core.Lighting.LightInfoLoader.Load(
datSetup, datSetup,
ownerId: entity.Id, ownerId: entity.Id,
@ -9045,6 +9063,12 @@ public sealed class GameWindow : IDisposable
if (_animatedEntities.Count > 0) if (_animatedEntities.Count > 0)
TickAnimations((float)deltaSeconds); 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 // Phase G.1: weather state machine — deterministic per-day roll
// + transitions + lightning flash. // + transitions + lightning flash.
var cal = WorldTime.CurrentCalendar; var cal = WorldTime.CurrentCalendar;
@ -9303,6 +9327,29 @@ public sealed class GameWindow : IDisposable
// WalkEntitiesInto) treat null and an empty set identically, so an // WalkEntitiesInto) treat null and an empty set identically, so an
// always-non-null (possibly empty) set is behaviorally the same as // always-non-null (possibly empty) set is behaviorally the same as
// the old null-when-nothing-animated local. // 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(); _animatedIdsScratch.Clear();
foreach (var k in _animatedEntities.Keys) foreach (var k in _animatedEntities.Keys)
_animatedIdsScratch.Add(k); _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 // R3-W6: UpdatePlayerAnimation DELETED — the player's sequencer is
// driven through the SAME MotionTableDispatchSink/DefaultSink funnel // driven through the SAME MotionTableDispatchSink/DefaultSink funnel
// remotes use (edge-driven DoMotion/StopMotion/set_hold_run in // 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)) _worldState.TryGetLandblock(id, out var lb))
{ {
foreach (var ent in lb!.Entities) foreach (var ent in lb!.Entities)
{
_lightingSink.UnregisterOwner(ent.Id); _lightingSink.UnregisterOwner(ent.Id);
_translucencyFades.ClearEntity(ent.Id); // #188
}
} }
_terrain?.RemoveLandblock(id); _terrain?.RemoveLandblock(id);
_physicsEngine.RemoveLandblock(id); _physicsEngine.RemoveLandblock(id);

View file

@ -180,10 +180,13 @@ public static class RenderBootstrap
// --- EntityClassificationCache (GameWindow ~217 — field initializer, new()) --- // --- EntityClassificationCache (GameWindow ~217 — field initializer, new()) ---
var classificationCache = new Wb.EntityClassificationCache(); var classificationCache = new Wb.EntityClassificationCache();
// --- TranslucencyFadeManager (GameWindow — field initializer, new()) ---
var translucencyFades = new AcDream.Core.Rendering.TranslucencyFadeManager();
// --- WbDrawDispatcher (GameWindow ~2377-2381) --- // --- WbDrawDispatcher (GameWindow ~2377-2381) ---
var drawDispatcher = new Wb.WbDrawDispatcher( var drawDispatcher = new Wb.WbDrawDispatcher(
gl, meshShader, textureCache, meshAdapter, entitySpawnAdapter, gl, meshShader, textureCache, meshAdapter, entitySpawnAdapter,
bindless, classificationCache); bindless, classificationCache, translucencyFades);
drawDispatcher.AlphaToCoverage = opts.Quality.AlphaToCoverage; drawDispatcher.AlphaToCoverage = opts.Quality.AlphaToCoverage;
// --- Vitals dat font (GameWindow ~1820-1822) --- // --- Vitals dat font (GameWindow ~1820-1822) ---

View file

@ -7,6 +7,7 @@ in vec3 vWorldPos;
in vec3 vLit; // A7: per-vertex Gouraud lighting (ambient + capped lights), from mesh_modern.vert in vec3 vLit; // A7: per-vertex Gouraud lighting (ambient + capped lights), from mesh_modern.vert
in flat uvec2 vTextureHandle; in flat uvec2 vTextureHandle;
in flat uint vTextureLayer; in flat uint vTextureLayer;
in flat float vOpacityMultiplier; // #188
// uRenderPass values (Phase N.5 Decision 2 — two-pass alpha-test): // uRenderPass values (Phase N.5 Decision 2 — two-pass alpha-test):
// 0 = opaque pass — discard fragments with alpha < 0.95 // 0 = opaque pass — discard fragments with alpha < 0.95
@ -102,5 +103,9 @@ void main() {
vec3 rgb = color.rgb * lit; vec3 rgb = color.rgb * lit;
rgb = applyFog(rgb, vWorldPos); 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);
} }

View file

@ -105,6 +105,16 @@ layout(std430, binding = 6) readonly buffer InstanceIndoorBuf {
uint instanceIndoor[]; 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 // 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 // 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 // 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 vec3 vLit; // A7: per-vertex Gouraud lighting (ambient + capped lights)
out flat uvec2 vTextureHandle; out flat uvec2 vTextureHandle;
out flat uint vTextureLayer; out flat uint vTextureLayer;
out flat float vOpacityMultiplier; // #188
void main() { void main() {
int instanceIndex = gl_BaseInstanceARB + gl_InstanceID; int instanceIndex = gl_BaseInstanceARB + gl_InstanceID;
mat4 model = Instances[instanceIndex].transform; mat4 model = Instances[instanceIndex].transform;
vOpacityMultiplier = instanceAlpha[instanceIndex]; // #188
vec4 worldPos = model * vec4(aPosition, 1.0); vec4 worldPos = model * vec4(aPosition, 1.0);
gl_Position = uViewProjection * worldPos; gl_Position = uViewProjection * worldPos;

View file

@ -107,6 +107,11 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
// miss-populate / hit-fast-path through the loop. // miss-populate / hit-fast-path through the loop.
private readonly EntityClassificationCache _cache; 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 // ACDREAM_DISABLE_TIER1_CACHE=1 A/B diagnostic — forces every static
// entity through the slow path. Read once in ctor. // entity through the slow path. Read once in ctor.
private readonly bool _tier1CacheDisabled = private readonly bool _tier1CacheDisabled =
@ -150,6 +155,14 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
// Mechanically a clone of _clipSlotData / _clipSlotSsbo. // Mechanically a clone of _clipSlotData / _clipSlotSsbo.
private uint _instIndoorSsbo; private uint _instIndoorSsbo;
private uint[] _indoorData = new uint[256]; 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 // This frame's point-light snapshot, handed in by GameWindow before Draw via
// SetSceneLights. Null/empty ⇒ only ambient + sun render (all instance sets -1). // SetSceneLights. Null/empty ⇒ only ambient + sun render (all instance sets -1).
private IReadOnlyList<LightSource>? _pointSnapshot; private IReadOnlyList<LightSource>? _pointSnapshot;
@ -340,7 +353,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
WbMeshAdapter meshAdapter, WbMeshAdapter meshAdapter,
EntitySpawnAdapter entitySpawnAdapter, EntitySpawnAdapter entitySpawnAdapter,
BindlessSupport bindless, BindlessSupport bindless,
EntityClassificationCache classificationCache) EntityClassificationCache classificationCache,
AcDream.Core.Rendering.TranslucencyFadeManager translucencyFades)
{ {
ArgumentNullException.ThrowIfNull(gl); ArgumentNullException.ThrowIfNull(gl);
ArgumentNullException.ThrowIfNull(shader); ArgumentNullException.ThrowIfNull(shader);
@ -348,6 +362,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
ArgumentNullException.ThrowIfNull(meshAdapter); ArgumentNullException.ThrowIfNull(meshAdapter);
ArgumentNullException.ThrowIfNull(entitySpawnAdapter); ArgumentNullException.ThrowIfNull(entitySpawnAdapter);
ArgumentNullException.ThrowIfNull(classificationCache); ArgumentNullException.ThrowIfNull(classificationCache);
ArgumentNullException.ThrowIfNull(translucencyFades);
_gl = gl; _gl = gl;
_shader = shader; _shader = shader;
@ -355,6 +370,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
_meshAdapter = meshAdapter; _meshAdapter = meshAdapter;
_entitySpawnAdapter = entitySpawnAdapter; _entitySpawnAdapter = entitySpawnAdapter;
_cache = classificationCache; _cache = classificationCache;
_translucencyFades = translucencyFades;
_bindless = bindless ?? throw new ArgumentNullException(nameof(bindless)); _bindless = bindless ?? throw new ArgumentNullException(nameof(bindless));
_instanceSsbo = _gl.GenBuffer(); _instanceSsbo = _gl.GenBuffer();
@ -364,6 +380,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
_globalLightsSsbo = _gl.GenBuffer(); // Fix B binding=4 _globalLightsSsbo = _gl.GenBuffer(); // Fix B binding=4
_instLightSetSsbo = _gl.GenBuffer(); // Fix B binding=5 _instLightSetSsbo = _gl.GenBuffer(); // Fix B binding=5
_instIndoorSsbo = _gl.GenBuffer(); // #142 binding=6 _instIndoorSsbo = _gl.GenBuffer(); // #142 binding=6
_instAlphaSsbo = _gl.GenBuffer(); // #188 binding=7
} }
/// <summary> /// <summary>
@ -1290,8 +1307,17 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
bool drewAny = false; bool drewAny = false;
if (renderData.IsSetup && renderData.SetupParts.Count > 0) 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); var partData = _meshAdapter.TryGetRenderData(partGfxObjId);
if (partData is null) if (partData is null)
{ {
@ -1332,16 +1358,43 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
} }
var restPose = partTransform * meshRef.PartTransform; 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; drewAny = true;
} }
} }
else else
{
// #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; var model = meshRef.PartTransform * entityWorld;
ClassifyBatches(renderData, gfxObjId, model, entity, meshRef, palHash, metaTable, restPose: meshRef.PartTransform, collector: collector); ClassifyBatches(renderData, gfxObjId, model, entity, meshRef, palHash, metaTable, restPose: meshRef.PartTransform, opacityMultiplier: opacityMultiplier, collector: collector);
drewAny = true; drewAny = true;
} }
}
// Track THIS entity for the next iteration's flush check. Only // Track THIS entity for the next iteration's flush check. Only
// when collector is non-null (entity is static); animated entities // when collector is non-null (entity is static); animated entities
@ -1427,6 +1480,11 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
if (_indoorData.Length < totalInstances) if (_indoorData.Length < totalInstances)
_indoorData = new uint[totalInstances + 256]; _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(); _opaqueDraws.Clear();
_translucentDraws.Clear(); _translucentDraws.Clear();
@ -1462,6 +1520,9 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
// #142: IndoorFlags[] is parallel to Matrices[]; write at the same // #142: IndoorFlags[] is parallel to Matrices[]; write at the same
// cursor so binding=6 instanceIndoor[] tracks binding=0 instances. // cursor so binding=6 instanceIndoor[] tracks binding=0 instances.
_indoorData[cursor] = grp.IndoorFlags[i]; _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++; cursor++;
} }
@ -1553,6 +1614,12 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
fixed (uint* dp = _indoorData) fixed (uint* dp = _indoorData)
UploadSsbo(_instIndoorSsbo, 6, dp, totalInstances * sizeof(uint)); 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 // Fix B: global point-light buffer (binding=4) + per-instance light-set
// buffer (binding=5). The global buffer is this frame's PointSnapshot; the // 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 // 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.Matrices.Add(model);
grp.Slots.Add(_currentEntitySlot); // Phase U.4 — parallel to Matrices grp.Slots.Add(_currentEntitySlot); // Phase U.4 — parallel to Matrices
AppendCurrentLightSet(grp); // Fix B — 8 ints per instance, 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);
} }
/// <summary> /// <summary>
@ -2181,6 +2253,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
ulong palHash, ulong palHash,
AcSurfaceMetadataTable metaTable, AcSurfaceMetadataTable metaTable,
Matrix4x4 restPose, Matrix4x4 restPose,
float opacityMultiplier = 1.0f,
List<CachedBatch>? collector = null) List<CachedBatch>? collector = null)
{ {
for (int batchIdx = 0; batchIdx < renderData.Batches.Count; batchIdx++) for (int batchIdx = 0; batchIdx < renderData.Batches.Count; batchIdx++)
@ -2199,6 +2272,13 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
: TranslucencyKind.Opaque; : 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); ulong texHandle = ResolveTexture(entity, meshRef, batch, palHash);
if (texHandle == 0) continue; if (texHandle == 0) continue;
@ -2228,6 +2308,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
grp.Matrices.Add(model); grp.Matrices.Add(model);
grp.Slots.Add(_currentEntitySlot); // Phase U.4 — parallel to Matrices grp.Slots.Add(_currentEntitySlot); // Phase U.4 — parallel to Matrices
AppendCurrentLightSet(grp); // Fix B — 8 ints per instance, 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)); 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 (_globalLightsSsbo != 0) _gl.DeleteBuffer(_globalLightsSsbo); // Fix B binding=4
if (_instLightSetSsbo != 0) _gl.DeleteBuffer(_instLightSetSsbo); // Fix B binding=5 if (_instLightSetSsbo != 0) _gl.DeleteBuffer(_instLightSetSsbo); // Fix B binding=5
if (_instIndoorSsbo != 0) _gl.DeleteBuffer(_instIndoorSsbo); // #142 binding=6 if (_instIndoorSsbo != 0) _gl.DeleteBuffer(_instIndoorSsbo); // #142 binding=6
if (_instAlphaSsbo != 0) _gl.DeleteBuffer(_instAlphaSsbo); // #188 binding=7
if (_gpuQueriesInitialized) if (_gpuQueriesInitialized)
{ {
for (int i = 0; i < GpuQueryRingDepth; i++) 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 // for outdoor objects (gets the sun). Written into _indoorData at the same
// cursor as Matrices, so binding=6 instanceIndoor[] tracks binding=0. // cursor as Matrices, so binding=6 instanceIndoor[] tracks binding=0.
public readonly List<uint> IndoorFlags = new(); public readonly List<uint> 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<float> Opacities = new();
} }
} }

View file

@ -34,8 +34,14 @@ namespace AcDream.Core.Physics;
/// (animation-hook frame = damage frame for melee / thrown). /// (animation-hook frame = damage frame for melee / thrown).
/// </description></item> /// </description></item>
/// <item><description> /// <item><description>
/// <see cref="TransparentPartHook"/> → #188
/// <c>AcDream.Core.Rendering.TranslucencyHookSink</c> (per-Setup-part
/// translucency ramp; see <c>TranslucencyFadeManager</c>).
/// </description></item>
/// <item><description>
/// <see cref="ReplaceObjectHook"/>, /// <see cref="ReplaceObjectHook"/>,
/// <see cref="TransparentHook"/>, /// <see cref="TransparentHook"/> (whole-object variant — NOT yet
/// ported, #188 scope was the per-part case only),
/// <see cref="LuminousHook"/>, /// <see cref="LuminousHook"/>,
/// <see cref="DiffuseHook"/>, /// <see cref="DiffuseHook"/>,
/// <see cref="ScaleHook"/>, /// <see cref="ScaleHook"/>,
@ -43,7 +49,9 @@ namespace AcDream.Core.Physics;
/// <see cref="SetOmegaHook"/>, /// <see cref="SetOmegaHook"/>,
/// <see cref="TextureVelocityHook"/>, /// <see cref="TextureVelocityHook"/>,
/// <see cref="SetLightHook"/> → /// <see cref="SetLightHook"/> →
/// GfxObjMesh / renderer state mutations on the target entity. /// GfxObjMesh / renderer state mutations on the target entity
/// (<see cref="SetLightHook"/> is the one exception already wired,
/// via <c>AcDream.Core.Lighting.LightingHookSink</c>).
/// </description></item> /// </description></item>
/// <item><description> /// <item><description>
/// <see cref="AnimationDoneHook"/> → UI / controller notifications /// <see cref="AnimationDoneHook"/> → UI / controller notifications

View file

@ -0,0 +1,167 @@
using System;
using System.Collections.Generic;
namespace AcDream.Core.Rendering;
/// <summary>
/// #188 — per-(entity, Setup-part) translucency ramp state. Ports retail's
/// FPHook TRANSLUCENCY / PART_TRANSLUCENCY interpolation
/// (<c>CPhysicsObj::SetPartTranslucency</c> 0x00511730,
/// <c>FPHook::Execute</c> 0x0051baa0, <c>CPhysicsObj::process_fp_hook</c>
/// 0x005135c0): a linear ramp from <c>Start</c> to <c>End</c> over
/// <c>Time</c> 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).
///
/// <para>
/// Retail's translucency scale is the OPPOSITE of "opacity": 0 = fully
/// solid/opaque, 1 = fully invisible (<c>CMaterial::SetTranslucencySimple</c>
/// 0x005396f0: <c>alpha = 1 - translucency</c>). Values here are stored on
/// that same [0,1] translucency scale — callers convert to an alpha/opacity
/// multiplier at the point of use.
/// </para>
///
/// <para>
/// Retail's instant-apply shortcut: if the hook's <c>Time</c> is at or below
/// ~0.2 ms, the target value is applied immediately with no ramp
/// (<c>SetPartTranslucency</c>'s epsilon check). <see cref="TranslucencyInstantEpsilon"/>
/// is that exact retail constant.
/// </para>
/// </summary>
public sealed class TranslucencyFadeManager
{
/// <summary>
/// Retail's exact epsilon (<c>CPhysicsObj::SetPartTranslucency</c>
/// 0x00511730): a hook <c>Time</c> at or below this is applied
/// instantly, no ramp.
/// </summary>
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<uint, Dictionary<uint, Fade>> _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<uint, Dictionary<uint, float>> _committed = new();
/// <summary>
/// Start (or replace) a translucency ramp for one Setup part of one
/// entity. Mirrors <c>CPhysicsObj::SetPartTranslucency</c>: a
/// near-zero <paramref name="time"/> applies <paramref name="end"/>
/// immediately; otherwise the value ramps from
/// <paramref name="start"/> to <paramref name="end"/> across
/// <paramref name="time"/> seconds, advanced by <see cref="AdvanceAll"/>.
/// </summary>
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<uint, Fade>();
_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);
}
/// <summary>
/// Advance every in-flight fade by <paramref name="dt"/> seconds
/// (plain linear interpolation, no easing — matches
/// <c>FPHook::Execute</c>). A fade that reaches its duration commits
/// the bitwise-exact <c>End</c> value and stops advancing (retail
/// deletes the FPHook at that point); the committed value stays
/// readable via <see cref="TryGetCurrentValue"/>.
/// </summary>
public void AdvanceAll(float dt)
{
if (_activeFades.Count == 0) return;
List<uint>? emptyEntities = null;
foreach (var (entityId, fades) in _activeFades)
{
List<uint>? 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<uint>();
finished.Add(partIndex);
}
}
if (finished is not null)
{
foreach (var partIndex in finished)
fades.Remove(partIndex);
if (fades.Count == 0)
{
emptyEntities ??= new List<uint>();
emptyEntities.Add(entityId);
}
}
}
if (emptyEntities is not null)
foreach (var entityId in emptyEntities)
_activeFades.Remove(entityId);
}
/// <summary>
/// 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).
/// </summary>
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;
}
/// <summary>Drop all fade state for an entity (despawn / unload).</summary>
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<uint, float>();
_committed[entityId] = parts;
}
parts[partIndex] = value;
}
}

View file

@ -0,0 +1,38 @@
using System;
using System.Numerics;
using AcDream.Core.Physics;
using DatReaderWriter.Types;
namespace AcDream.Core.Rendering;
/// <summary>
/// #188 — routes <see cref="TransparentPartHook"/> animation hooks to a
/// <see cref="TranslucencyFadeManager"/>. Retail's
/// <c>TransparentPartHook::Execute</c> (0x00526cc0) is a one-liner calling
/// <c>CPhysicsObj::SetPartTranslucency(obj, part, start, end, time)</c> —
/// this sink is that same one-line forward.
///
/// <para>
/// Whole-object <see cref="TransparentHook"/> and <see cref="EtherealHook"/>
/// are deliberately NOT handled here. Retail's <c>EtherealHook::Execute</c>
/// (0x00526bc0) touches only <c>CPhysicsObj::set_ethereal</c> — collision
/// state, zero rendering coupling — and acdream's collision-passthrough
/// already applies server-authoritative via the <c>SetState</c> wire
/// message, independent of this hook firing client-side.
/// </para>
/// </summary>
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);
}
}

View file

@ -48,8 +48,10 @@ public class Issue188FadingDoorMotionTableInspectionTests
foreach (var t in asm.GetTypes().Where(t => t.Name.Contains("Hook", StringComparison.OrdinalIgnoreCase))) foreach (var t in asm.GetTypes().Where(t => t.Name.Contains("Hook", StringComparison.OrdinalIgnoreCase)))
{ {
_out.WriteLine($"=== type {t.FullName} (base={t.BaseType?.Name}) ==="); _out.WriteLine($"=== type {t.FullName} (base={t.BaseType?.Name}) ===");
foreach (var p in t.GetProperties(BindingFlags.Public | BindingFlags.Instance)) foreach (var p in t.GetProperties(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly))
_out.WriteLine($" {p.PropertyType.Name} {p.Name}"); _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) 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; } if (anim is null) { _out.WriteLine($" [{source}] anim 0x{animId:X8} NOT FOUND"); continue; }
int frameCount = 0, hookCount = 0; int frameCount = 0, hookCount = 0;
int frameIdx = 0;
foreach (var frame in GetFrames(anim)) foreach (var frame in GetFrames(anim))
{ {
frameCount++; frameCount++;
@ -114,7 +117,14 @@ public class Issue188FadingDoorMotionTableInspectionTests
{ {
hookCount++; hookCount++;
hookTypesSeen.Add(hook.GetType().Name); 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}"); _out.WriteLine($" [{source}] anim 0x{animId:X8}: frames={frameCount} hooks={hookCount}");
} }

View file

@ -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
}
}

View file

@ -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 _));
}
}