fix(client): restore retail interaction parity
All checks were successful
CI / linux-portable (push) Successful in 3m27s
CI / windows-gate (push) Successful in 6m42s
CI / release (push) Successful in 2m12s

Harden keyboard and camera routing, inventory and vendor interactions, chat/emotes, relog portal flow, and paperdoll rendering. Add retail research, connected gate coverage, and release-gate validation.
This commit is contained in:
Erik 2026-08-26 20:45:11 +02:00
parent 0c699240e0
commit f6fe0f2a4f
151 changed files with 10162 additions and 1211 deletions

View file

@ -85,6 +85,28 @@ internal sealed class CameraFrameController : ICameraFramePhase
retail.AdjustPitch(+adjustment * 0.02f);
if (input.Lower)
retail.AdjustPitch(-adjustment * 0.02f);
if (input.RotateLeft)
retail.YawOffset += adjustment * 0.02f;
if (input.RotateRight)
retail.YawOffset -= adjustment * 0.02f;
}
else
{
ChaseCameraAdjustmentInput input = _input.CaptureChaseAdjustment();
float adjustment = CameraDiagnostics.CameraAdjustmentSpeed
* timing.SimulationDeltaSecondsSingle;
if (input.ZoomIn)
legacy.AdjustDistance(-adjustment);
if (input.ZoomOut)
legacy.AdjustDistance(+adjustment);
if (input.Raise)
legacy.AdjustPitch(+adjustment * 0.02f);
if (input.Lower)
legacy.AdjustPitch(-adjustment * 0.02f);
if (input.RotateLeft)
legacy.YawOffset += adjustment * 0.02f;
if (input.RotateRight)
legacy.YawOffset -= adjustment * 0.02f;
}
if (!_localFrame.TryGetPresentationAfterNetwork(out var playerFrame))

View file

@ -10,6 +10,21 @@ namespace AcDream.App.Rendering;
/// </summary>
public sealed class ChaseCamera : ICamera
{
private const float RetailDefaultBack = 2.5f;
private const float RetailDefaultUp = 0.75f;
private bool _lookingDown;
private bool _mapMode;
private bool _inHead;
private bool _savedInHead;
private float _savedDistance;
private float _savedPitch;
private float _savedYawOffset;
private Vector3? _targetDirectionLocal;
private Vector3? _savedTargetDirectionLocal;
public bool IsLookingDown => _lookingDown;
public bool IsMapMode => _mapMode;
public bool IsInHead => _inHead;
public Vector3 Position { get; private set; }
public float Aspect { get; set; } = 16f / 9f;
// #389: smartbox law at the 16:9 default aspect — see RetailFieldOfView.
@ -108,10 +123,35 @@ public sealed class ChaseCamera : ICamera
float horizontalDist = Distance * MathF.Cos(Pitch);
float verticalDist = Distance * MathF.Sin(Pitch);
Position = new Vector3(
playerPosition.X - forwardX * horizontalDist,
playerPosition.Y - forwardY * horizontalDist,
_trackedZ + EyeHeight + verticalDist); // ← uses tracked Z (pinned to ground while airborne)
if (_inHead)
{
Vector3 forward = new(MathF.Cos(playerYaw), MathF.Sin(playerYaw), 0f);
Position = new Vector3(
playerPosition.X,
playerPosition.Y,
_trackedZ + EyeHeight) + forward * 0.18f;
_lookAt = Position + forward;
}
else if (_targetDirectionLocal is { } localDirection)
{
Vector3 pivot = new(playerPosition.X, playerPosition.Y, _trackedZ + EyeHeight);
var directedPose = RetailChaseCamera.ComputeTargetDirectionPose(
pivot,
new Vector3(MathF.Cos(playerYaw), MathF.Sin(playerYaw), 0f),
Distance,
Pitch,
localDirection);
Position = directedPose.eye;
Vector3 direction = directedPose.forward;
_lookAt = Position + direction;
}
else
{
Position = new Vector3(
playerPosition.X - forwardX * horizontalDist,
playerPosition.Y - forwardY * horizontalDist,
_trackedZ + EyeHeight + verticalDist); // ← uses tracked Z (pinned to ground while airborne)
}
}
/// <summary>
@ -119,6 +159,8 @@ public sealed class ChaseCamera : ICamera
/// </summary>
public void AdjustPitch(float delta)
{
ExitLookDownForAdjustment();
ExitInHeadForAdjustment();
Pitch = Math.Clamp(Pitch + delta, PitchMin, PitchMax);
}
@ -127,6 +169,101 @@ public sealed class ChaseCamera : ICamera
/// </summary>
public void AdjustDistance(float delta)
{
ExitLookDownForAdjustment();
ExitInHeadForAdjustment();
Distance = Math.Clamp(Distance + delta, DistanceMin, DistanceMax);
}
public void SetRetailDefaultView()
{
_lookingDown = false;
_mapMode = false;
_inHead = false;
_targetDirectionLocal = null;
YawOffset = 0f;
EyeHeight = 1.5f;
SetViewerOffset(RetailDefaultBack, RetailDefaultUp);
}
public void SetRetailFirstPersonView()
{
_lookingDown = false;
_mapMode = false;
_inHead = true;
_targetDirectionLocal = null;
YawOffset = 0f;
Distance = 0.18f;
Pitch = 0f;
}
public void ToggleRetailLookDownView()
{
if (_lookingDown)
{
RestoreLookDownView();
return;
}
SaveLookDownView();
_lookingDown = true;
_mapMode = false;
_inHead = false;
_targetDirectionLocal = new Vector3(0f, 0.5f, -1.8f);
SetViewerOffset(2f, RetailDefaultUp);
}
public void ToggleRetailMapModeView()
{
if (_mapMode)
{
RestoreLookDownView();
return;
}
if (!_lookingDown)
SaveLookDownView();
_lookingDown = true;
_mapMode = true;
_inHead = false;
_targetDirectionLocal = new Vector3(0f, 0.5f, -1.8f);
SetViewerOffset(450f, RetailDefaultUp);
}
private void SaveLookDownView()
{
_savedDistance = Distance;
_savedPitch = Pitch;
_savedYawOffset = YawOffset;
_savedTargetDirectionLocal = _targetDirectionLocal;
_savedInHead = _inHead;
}
private void RestoreLookDownView()
{
Distance = _savedDistance;
Pitch = _savedPitch;
YawOffset = _savedYawOffset;
_targetDirectionLocal = _savedTargetDirectionLocal;
_inHead = _savedInHead;
_lookingDown = false;
_mapMode = false;
}
private void ExitLookDownForAdjustment()
{
if (_lookingDown)
RestoreLookDownView();
}
private void ExitInHeadForAdjustment()
{
if (!_inHead)
return;
_inHead = false;
Distance = DistanceMin;
}
private void SetViewerOffset(float back, float up)
{
Distance = MathF.Sqrt(back * back + up * up);
Pitch = MathF.Atan2(up, back);
}
}

View file

@ -1,5 +1,6 @@
using AcDream.Core.Plugins;
using AcDream.App.Composition;
using AcDream.App.Input;
using AcDream.App.Physics;
using AcDream.App.Rendering.Gpu;
using AcDream.App.Rendering.Scene;
@ -17,6 +18,7 @@ using DatReaderWriter;
using Silk.NET.Input;
using Silk.NET.Maths;
using Silk.NET.Windowing;
using AcDream.UI.Abstractions.Input;
namespace AcDream.App.Rendering;
@ -599,14 +601,18 @@ public sealed class GameWindow :
// startup — no other call to RetailDefaults() / AcdreamCurrentDefaults()
// should land in the GameWindow construction path.
private readonly AcDream.UI.Abstractions.Input.KeyBindings _keyBindings;
private bool _keyBindingsPersisted;
private readonly GraphicalHostPlatformServices _platformServices;
private readonly ApplicationPathSet _applicationPaths;
private static AcDream.UI.Abstractions.Input.KeyBindings LoadStartupKeyBindings(
string path)
{
var bindings = AcDream.UI.Abstractions.Input.KeyBindings.LoadOrDefault(path);
Console.WriteLine($"keybinds: loaded {bindings.All.Count} bindings from {path}");
var bindings = AcDream.App.Input.RetailKeymapProfileStore.LoadActiveOrJson(
path, out string profileName);
Console.WriteLine(
$"keybinds: loaded {bindings.All.Count} bindings; active retail profile "
+ $"'{profileName}', JSON mirror {path}");
return bindings;
}
@ -1522,7 +1528,8 @@ public sealed class GameWindow :
hostInputCamera.GpuFrameLifetime,
() => WorldTime.CurrentCalendar,
settingsDevTools.RenderPacks,
_renderPackDiagnostics.CaptureDiagnostics),
_renderPackDiagnostics.CaptureDiagnostics,
_applicationPaths.ScreenshotsDirectory),
_retailUiLease,
this).Compose(
platformResult,
@ -1569,6 +1576,7 @@ public sealed class GameWindow :
_cellVisibility,
_liveWorldOrigin,
_localPlayerIdentity,
_chaseCameraInput,
_pointerPosition,
_playerApproachCompletions,
_renderResourceLifetime,
@ -1821,6 +1829,7 @@ public sealed class GameWindow :
if (!_lifetime.HasShutdownRoots)
{
PersistKeyBindingsAtShutdown();
// Campaign LA slice LA1: capture BEFORE the shutdown roots run —
// by the time teardown completes, IsInWorld is always false
// regardless of whether a real session was ever connected.
@ -1861,6 +1870,33 @@ public sealed class GameWindow :
ReportExited(report);
}
private void PersistKeyBindingsAtShutdown()
{
// Construction-only tests and failed starts never create the input
// dispatcher. They must not materialize a profile in the real user's
// Documents folder merely because the half-built window is disposed.
if (_keyBindingsPersisted || _inputDispatcher is null) return;
_keyBindingsPersisted = true;
try
{
KeyBindings current = _inputDispatcher.Bindings;
var profiles = new AcDream.App.Input.RetailKeymapProfileStore(
_applicationPaths.KeyBindingsFile);
RetailKeymapSaveResult saved = profiles.SaveActive(current);
if (saved.Status != RetailKeymapSaveStatus.Saved)
{
Console.WriteLine(
$"keymap: shutdown save failed ({saved.Status}): {saved.Error}");
return;
}
current.SaveToFile(_applicationPaths.KeyBindingsFile);
}
catch (Exception failure)
{
Console.WriteLine($"keymap: shutdown persistence failed: {failure.Message}");
}
}
/// <summary>
/// Writes the ONE terminal "exited" status event for this session
/// (fix #406). A resource-shutdown transaction can converge cleanly

View file

@ -14,6 +14,8 @@ internal interface IPaperdollDollRenderer
{
void SetDoll(WorldEntity? doll);
void Prepare();
uint Render(int width, int height);
}
@ -73,9 +75,6 @@ internal sealed class PaperdollFramePresenter : IPrivateEntityViewportFrame
public void Render()
{
if (!_view.TryGetVisibleSize(out int width, out int height))
return;
if (_dirty)
{
if (_factory.TryBuild(out WorldEntity? doll))
@ -101,6 +100,11 @@ internal sealed class PaperdollFramePresenter : IPrivateEntityViewportFrame
}
}
_renderer.Prepare();
if (!_view.TryGetVisibleSize(out int width, out int height))
return;
_view.SetTextureHandle(_renderer.Render(width, height));
}

View file

@ -42,6 +42,8 @@ public sealed class PaperdollViewportRenderer :
public void SetDoll(WorldEntity? doll) => _renderer.SetEntity(doll);
public void Prepare() => _renderer.Prepare();
public uint Render(int width, int height) =>
_renderer.Render(width, height);

View file

@ -94,6 +94,11 @@ internal sealed class PrivateEntityViewportRenderer :
/// feature does not exist for them, not just "unused".</summary>
private readonly EntitySlot? _backdropSlot;
// One stable sampled texture-table slot is part of the retained viewport's
// presentation contract. Rotating the slot with the Vulkan flight index
// made the UI sample a freshly-created/cleared sibling after world reveal.
// The frame submission order already protects this target's write -> sample
// transition; keep its identity stable until resize or disposal.
private IGpuRenderTarget? _target;
private IGpuSampler? _sampler;
private GpuTextureSlot _slot = GpuTextureSlot.Unassigned;
@ -170,6 +175,29 @@ internal sealed class PrivateEntityViewportRenderer :
public void SetEntity(WorldEntity? entity) => _mainSlot.Set(entity);
/// <summary>
/// Advances the private entity's mesh and texture-composite readiness
/// without allocating or clearing a render target. Paperdoll uses this
/// while its tab is hidden so first-open work is already resident.
/// </summary>
public bool Prepare()
{
if (!_mainSlot.PrepareForDraw()
|| !(_backdropSlot?.PrepareForDraw() ?? true))
{
return false;
}
WorldEntity? entity = _mainSlot.Entity;
if (entity is null || entity.MeshRefs.Count == 0)
{
return false;
}
IReadOnlyList<WorldEntity> entities = BuildDrawEntities(
_backdropSlot?.Entity,
entity);
return _dispatcher.PreparePrivateEntityResources(entities);
}
/// <summary>
/// Sets or clears the environment backdrop entity drawn BEHIND the main
/// entity — GF-7/GF-14's fix, retail's <c>gmCG3DView::m_pbgObject</c>. Only
@ -219,6 +247,16 @@ internal sealed class PrivateEntityViewportRenderer :
if (entity is null || entity.MeshRefs.Count == 0 || width <= 0 || height <= 0)
return 0u;
IReadOnlyList<WorldEntity> drawEntities = BuildDrawEntities(
_backdropSlot?.Entity,
entity);
if (!_dispatcher.PreparePrivateEntityResources(drawEntities))
{
return _hasRenderedScene && _slot.IsAssigned
? UiTextureTableHandle.FromSlot(_slot)
: 0u;
}
EnsureRenderTarget(width, height);
if (_target is null)
return 0u;
@ -254,7 +292,6 @@ internal sealed class PrivateEntityViewportRenderer :
UploadCreatureLight();
IReadOnlyList<WorldEntity> drawEntities = BuildDrawEntities(_backdropSlot?.Entity, entity);
var entries =
new (uint, Vector3, Vector3, IReadOnlyList<WorldEntity>,
IReadOnlyDictionary<uint, WorldEntity>?)[]

View file

@ -29,6 +29,12 @@ namespace AcDream.App.Rendering;
/// </summary>
public sealed class RetailChaseCamera : ICamera
{
private const float RetailDefaultBack = 2.5f;
private const float RetailDefaultUp = 0.75f;
private const float RetailLookDownBack = 2f;
private const float RetailMapBack = 450f;
private const float RetailFirstPersonForward = 0.18f;
// ICamera surface.
public Vector3 Position { get; private set; }
@ -75,6 +81,20 @@ public sealed class RetailChaseCamera : ICamera
/// <summary>Height of look-at anchor above the player's feet (m). Retail default 1.5.</summary>
public float PivotHeight { get; set; } = 1.5f;
private bool _lookingDown;
private bool _mapMode;
private bool _inHead;
private bool _savedInHead;
private float _savedDistance;
private float _savedPitch;
private float _savedYawOffset;
private Vector3? _targetDirectionLocal;
private Vector3? _savedTargetDirectionLocal;
public bool IsLookingDown => _lookingDown;
public bool IsMapMode => _mapMode;
public bool IsInHead => _inHead;
/// <summary>
/// Optional spring-arm collision probe. When set (and
/// <see cref="CameraDiagnostics.CollideCamera"/> is true), the damped eye
@ -172,8 +192,13 @@ public sealed class RetailChaseCamera : ICamera
// target supplies the frame heading. Without this local rotation, enabling
// Keep in View snaps the camera behind the target and disables RMB orbit.
float viewerYawOffset = trackedHeading.HasValue ? YawOffset : 0f;
(Vector3 targetEye, Vector3 targetForward) = ComputeDesiredPose(
pivotWorld, heading, Distance, Pitch, viewerYawOffset);
(Vector3 targetEye, Vector3 targetForward) = _inHead
? ComputeInHeadPose(pivotWorld, heading)
: _targetDirectionLocal is { } localDirection
? ComputeTargetDirectionPose(
pivotWorld, heading, Distance, Pitch, localDirection)
: ComputeDesiredPose(
pivotWorld, heading, Distance, Pitch, viewerYawOffset);
// 5. Stateful sought position (#180). Retail CameraManager::UpdateCamera
// (0x00456660) interpolates FROM THE CURRENT SWEPT VIEWER toward the
@ -279,16 +304,120 @@ public sealed class RetailChaseCamera : ICamera
/// <see cref="DistanceMin"/>..<see cref="DistanceMax"/>. Mirrors
/// legacy <c>ChaseCamera.AdjustDistance</c>.
/// </summary>
public void AdjustDistance(float delta) =>
public void AdjustDistance(float delta)
{
ExitLookDownForAdjustment();
ExitInHeadForAdjustment();
Distance = Math.Clamp(Distance + delta, DistanceMin, DistanceMax);
}
/// <summary>
/// Adjust the camera pitch by a delta (radians), clamped to
/// <see cref="PitchMin"/>..<see cref="PitchMax"/>. Mirrors legacy
/// <c>ChaseCamera.AdjustPitch</c>.
/// </summary>
public void AdjustPitch(float delta) =>
public void AdjustPitch(float delta)
{
ExitLookDownForAdjustment();
ExitInHeadForAdjustment();
Pitch = Math.Clamp(Pitch + delta, PitchMin, PitchMax);
}
public void SetRetailDefaultView()
{
_lookingDown = false;
_mapMode = false;
_inHead = false;
_targetDirectionLocal = null;
YawOffset = 0f;
PivotHeight = 1.5f;
SetViewerOffset(RetailDefaultBack, RetailDefaultUp);
}
public void SetRetailFirstPersonView()
{
_lookingDown = false;
_mapMode = false;
_inHead = true;
_targetDirectionLocal = null;
YawOffset = 0f;
Distance = RetailFirstPersonForward;
Pitch = 0f;
// Do not spend a transition frame inside the head/neck. Retail's
// SetInHead installs the new viewer offset as one camera preset.
_initialised = false;
}
public void ToggleRetailLookDownView()
{
if (_lookingDown)
{
RestoreLookDownView();
return;
}
SaveLookDownView();
_lookingDown = true;
_mapMode = false;
_inHead = false;
_targetDirectionLocal = new Vector3(0f, 0.5f, -1.8f);
SetViewerOffset(RetailLookDownBack, RetailDefaultUp);
}
public void ToggleRetailMapModeView()
{
if (_mapMode)
{
RestoreLookDownView();
return;
}
if (!_lookingDown)
SaveLookDownView();
_lookingDown = true;
_mapMode = true;
_inHead = false;
_targetDirectionLocal = new Vector3(0f, 0.5f, -1.8f);
SetViewerOffset(RetailMapBack, RetailDefaultUp);
}
private void SaveLookDownView()
{
_savedDistance = Distance;
_savedPitch = Pitch;
_savedYawOffset = YawOffset;
_savedTargetDirectionLocal = _targetDirectionLocal;
_savedInHead = _inHead;
}
private void RestoreLookDownView()
{
Distance = _savedDistance;
Pitch = _savedPitch;
YawOffset = _savedYawOffset;
_targetDirectionLocal = _savedTargetDirectionLocal;
_inHead = _savedInHead;
_lookingDown = false;
_mapMode = false;
}
private void ExitLookDownForAdjustment()
{
if (_lookingDown)
RestoreLookDownView();
}
private void ExitInHeadForAdjustment()
{
if (!_inHead)
return;
_inHead = false;
Distance = DistanceMin;
}
private void SetViewerOffset(float back, float up)
{
Distance = MathF.Sqrt(back * back + up * up);
Pitch = MathF.Atan2(up, back);
}
/// <summary>
/// Public entry point for the mouse-input low-pass filter. Calls
@ -436,6 +565,47 @@ public sealed class RetailChaseCamera : ICamera
return (eye, forward);
}
/// <summary>
/// Retail <c>CameraSet::SetInHead @ 0x00458CE0</c>: the target direction
/// is local +Y and the viewer offset is local +Y * 0.18. It is not a
/// negative chase boom looking back toward the player's neck.
/// </summary>
internal static (Vector3 eye, Vector3 forward) ComputeInHeadPose(
Vector3 pivotWorld,
Vector3 heading)
{
Vector3 forward = Vector3.Normalize(heading);
return (pivotWorld + forward * RetailFirstPersonForward, forward);
}
/// <summary>
/// Transform both retail <c>viewer_offset</c> and <c>target_direction</c>
/// through the target frame. LookDown/MapMode do not merely point a
/// horizontally-positioned camera toward the ground: the downward target
/// direction pitches the frame whose -Y/+Z offset places the viewer. For
/// MapMode's (0,-450,0.75) offset this puts the viewer high above and only
/// slightly behind the character, matching CameraSet::SetMapMode.
/// </summary>
internal static (Vector3 eye, Vector3 forward) ComputeTargetDirectionPose(
Vector3 pivotWorld,
Vector3 heading,
float distance,
float pitch,
Vector3 targetDirectionLocal)
{
var (frameForward, frameRight, frameUp) = BuildBasis(heading);
Vector3 targetForward = Vector3.Normalize(
frameForward * targetDirectionLocal.Y
- frameRight * targetDirectionLocal.X
+ frameUp * targetDirectionLocal.Z);
var (_, _, targetUp) = BuildBasis(targetForward);
float back = distance * MathF.Cos(pitch);
float up = distance * MathF.Sin(pitch);
Vector3 eye = pivotWorld - targetForward * back + targetUp * up;
return (eye, targetForward);
}
/// <summary>
/// Build an orthonormal basis with <c>forward = heading</c>. World
/// up is <c>(0, 0, 1)</c>; if <c>heading</c> is near-parallel to it

View file

@ -473,6 +473,7 @@ public sealed unsafe partial class WbDrawDispatcher
}
float opacity = PackedPartOpacity(
entity.ServerGuid,
entity.LocalEntityId,
(uint)setupPartIndex);
if (opacity < 1f)
@ -527,6 +528,7 @@ public sealed unsafe partial class WbDrawDispatcher
// one-part assumption and kept the Bind Stone's four
// hook-hidden shard parts visible.
float opacity = PackedPartOpacity(
entity.ServerGuid,
entity.LocalEntityId,
(uint)partIndex);
if (opacity < 1f)
@ -585,20 +587,24 @@ public sealed unsafe partial class WbDrawDispatcher
anyVao != 0 && alphaQueueCollecting;
private float PackedPartOpacity(
uint serverGuid,
uint localEntityId,
uint setupPartIndex)
{
float opacity = EntityOpacity(serverGuid);
if (opacity <= 0f)
return 0f;
if (!_translucencyFades.TryGetCurrentValue(
localEntityId,
setupPartIndex,
out float translucency))
{
return 1f;
return opacity;
}
return translucency >= 1f
? 0f
: 1f - translucency;
: opacity * (1f - translucency);
}
private bool ClassifyPackedBatches(

View file

@ -176,7 +176,8 @@ public sealed unsafe partial class WbDrawDispatcher
RetailAlphaQueue? alphaQueue = null,
long? alphaScratchBudgetBytes = null,
TerrainAtlas.RetailDetailTextureBinding buildingDetail = default,
Func<bool>? buildingDetailEnabled = null)
Func<bool>? buildingDetailEnabled = null,
Func<uint, float>? hierarchicalTranslucency = null)
{
_device = device ?? throw new ArgumentNullException(nameof(device));
_frames = frames ?? throw new ArgumentNullException(nameof(frames));
@ -192,6 +193,7 @@ public sealed unsafe partial class WbDrawDispatcher
_selectionSink = selectionSink;
_selectionLighting = selectionSink as IRetailSelectionLightingSource;
_alphaQueue = alphaQueue;
_hierarchicalTranslucency = hierarchicalTranslucency;
_alphaSource = new AlphaDrawSource(this);
_buildingDetail = buildingDetail;
_buildingDetailEnabled = buildingDetailEnabled ?? DisableDetailTextures;

View file

@ -91,6 +91,7 @@ public sealed partial class WbDrawDispatcher : IDisposable
private readonly IRetailSelectionRenderSink? _selectionSink;
private readonly IRetailSelectionLightingSource? _selectionLighting;
private readonly RetailAlphaQueue? _alphaQueue;
private readonly Func<uint, float>? _hierarchicalTranslucency;
private readonly AlphaDrawSource _alphaSource;
private readonly RetainedScratchCapacityPolicy _alphaScratchPolicy;
private int _scratchPeakUnits;
@ -1784,11 +1785,12 @@ public sealed partial class WbDrawDispatcher : IDisposable
// 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;
float opacityMultiplier = EntityOpacity(entity.ServerGuid);
if (opacityMultiplier <= 0f) continue;
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
opacityMultiplier *= 1f - translucencyValue; // CMaterial::SetTranslucencySimple 0x005396f0
}
if (!ClassifyBatches(partData, model, entity, meshRef, paletteIdentity, restPose, opacityMultiplier, collector, entityHasCutoutSubset))
@ -1818,12 +1820,14 @@ public sealed partial class WbDrawDispatcher : IDisposable
// entity — the Bind Stone's idle cycle hides its four authored
// shard parts (3-6) with TransparentPartHook start=end=1.0
// every loop, and they stayed visible.
float opacityMultiplier = 1.0f;
float opacityMultiplier = EntityOpacity(entity.ServerGuid);
bool fullyInvisible = false;
if (opacityMultiplier <= 0f)
fullyInvisible = true;
if (_translucencyFades.TryGetCurrentValue(entity.Id, (uint)partIdx, out float translucencyValue))
{
if (translucencyValue >= 1.0f) fullyInvisible = true;
else opacityMultiplier = 1f - translucencyValue;
else opacityMultiplier *= 1f - translucencyValue;
}
if (!fullyInvisible)
@ -1901,6 +1905,32 @@ public sealed partial class WbDrawDispatcher : IDisposable
observeCurrentPath: true);
}
/// <summary>
/// Readiness barrier for a private creature viewport. Unlike the world
/// reveal queue this has no retained scan state: the caller owns a tiny,
/// exact entity list and retries it each frame. A completed result means
/// both mesh render data and every palette/original-texture composite can
/// be classified without clearing the private target to an empty frame.
/// </summary>
internal bool PreparePrivateEntityResources(
IReadOnlyList<WorldEntity> entities)
{
ArgumentNullException.ThrowIfNull(entities);
bool complete = true;
for (int i = 0; i < entities.Count; i++)
{
if (PrepareCompositeEntity(entities[i]) != CompositeWarmupResult.Complete)
complete = false;
}
return complete;
}
private float EntityOpacity(uint serverGuid)
{
float translucency = _hierarchicalTranslucency?.Invoke(serverGuid) ?? 0f;
return 1f - Math.Clamp(translucency, 0f, 1f);
}
/// <summary>
/// Whether there is a mesh source to draw from. The encoder arm has no
/// vertex array of its own — the pipeline owns one shaped by