Vulkan is the sole, user-signed-off backend (V10 landed) and step 1 already removed ImGui/Studio/DevTools. This step deletes the GL rendering backend itself: every Gpu/Gl/** implementation, the Wb ManagedGL*/GLHelpers/GLSLShader/GLStateScope/RenderStateCache/ BindlessSupport family, Shader/ShaderProgramConstruction/SamplerCache, RenderBootstrap, and RenderFrameGlStateController. GameWindow.cs's Run()/CreateGraphics()/CreateBackbufferReader()/ OnLoad() collapse to their Vulkan-only arm; GameWindowGraphics loses its OpenGlGameWindowGraphics subclass. RuntimeOptions.RenderBackend and RenderBackendKind (incl. the Gl member of GpuBackendKind) are gone — there is nothing left to select between. The five world-draw dual-arm renderers (WbDrawDispatcher, EnvCellRenderer, TerrainModernRenderer, ParticleRenderer, SkyRenderer) and the composition roots (WorldRenderComposition, HostInputCameraComposition, LivePresentationComposition, FrameRootComposition) collapse to their RHI-only arm. GL-only diagnostic properties with a live external reader (DynamicBufferCount and friends) simplify to a documented `=> 0`/no-op rather than disappearing, since the reader is out of this commit's scope. A few GL-flavored mechanisms turned out to be backend-neutral once isolated: GlConstructionCleanupLedger is renamed ResourceConstructionCleanupLedger (exception-chain walking has nothing to do with GL), and GlfwNativePlatformProbe moved out of the otherwise GL-only GraphicalCapabilityRecord.cs into GraphicalWindowBackendSelection.cs before the rest of that file was deleted. Test files with no surviving subject are deleted outright (GraphicalCapabilityRequirementsTests, ShaderProgramConstructionTests, PortalDepthShaderParityTests, TextureCacheBindlessTests, TextRendererFailureSafetyTests, ClipFrameUploadTests, every Gpu/Gl/*Tests, GlTextureOwnershipTests, RenderFrameGlStateControllerTests); others get their dead GL-only members trimmed while their live assertions stay (ClipFrameLayoutTests' MeshClipSsboBinding check now reads GpuBindingModel.StorageClipRegions, the same binding index under its new backend-neutral name; GpuResourceRetirementTransactionTests drops its OpenGLGraphicsDevice-subclassing test double and the two GL queue tests it existed for). EnvCellRendererTests' construction helper now builds a real ObjectMeshManager via VulkanMeshPipelineDevice instead of passing null through a null-forgiving operator, since the RHI constructor never tolerated a null mesh manager and the old GL constructor (which did) is gone. Deferred to the next two steps, deliberately not touched here: the Silk.NET.OpenGL/.Extensions.ARB package references, IMeshPipelineDevice.Gl (WbMeshAdapter's GL? threading stays in place), Chorizite.Core's stale csproj comment (the package itself is still load-bearing — TextureFormat and friends are used well beyond the deleted ManagedGLUniformBuffer), and the CI/gate scripts. Build: `dotnet build AcDream.slnx -c Release` — 0 warnings, 0 errors. Tests: full-solution `dotnet test` green across every project (App.Tests 3937/3940 + 3 skips, Core.Tests 3296/3298 + 2 skips, all others 100%); the 2 App.Tests names that flake under full-suite parallel execution (#250-family, documented pre-existing) pass in isolation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
407 lines
13 KiB
C#
407 lines
13 KiB
C#
using System.Numerics;
|
|
using AcDream.App.Input;
|
|
using AcDream.App.Rendering.Gpu;
|
|
using AcDream.App.UI;
|
|
using AcDream.App.World;
|
|
using AcDream.Content;
|
|
using AcDream.Core.Physics;
|
|
using AcDream.Core.World;
|
|
using DatReaderWriter;
|
|
|
|
namespace AcDream.App.Rendering;
|
|
|
|
internal interface IPaperdollDollRenderer
|
|
{
|
|
void SetDoll(WorldEntity? doll);
|
|
|
|
uint Render(int width, int height);
|
|
}
|
|
|
|
internal interface IPaperdollFrameView
|
|
{
|
|
bool TryGetVisibleSize(out int width, out int height);
|
|
|
|
void SetTextureHandle(uint textureHandle);
|
|
}
|
|
|
|
internal interface IPaperdollInventoryVisibility
|
|
{
|
|
bool IsVisible { get; }
|
|
}
|
|
|
|
internal interface IPaperdollDollFactory
|
|
{
|
|
bool TryBuild(out WorldEntity? doll);
|
|
}
|
|
|
|
internal interface IPaperdollEntityLookup
|
|
{
|
|
bool TryGet(uint serverGuid, out WorldEntity player);
|
|
}
|
|
|
|
internal interface IPaperdollPoseApplicator
|
|
{
|
|
void Apply(WorldEntity doll, uint setupId);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Owns paperdoll dirty/rebuild state and the private render-target
|
|
/// presentation edge. The renderer remains a borrowed resource disposed by
|
|
/// the existing window shutdown transaction.
|
|
/// </summary>
|
|
internal sealed class PaperdollFramePresenter : IPrivateEntityViewportFrame
|
|
{
|
|
private readonly IPaperdollDollRenderer _renderer;
|
|
private readonly IPaperdollFrameView _view;
|
|
private readonly IPaperdollDollFactory _factory;
|
|
private WorldEntity? _doll;
|
|
private bool _dirty = true;
|
|
|
|
public PaperdollFramePresenter(
|
|
IPaperdollDollRenderer renderer,
|
|
IPaperdollFrameView view,
|
|
IPaperdollDollFactory factory)
|
|
{
|
|
_renderer = renderer ?? throw new ArgumentNullException(nameof(renderer));
|
|
_view = view ?? throw new ArgumentNullException(nameof(view));
|
|
_factory = factory ?? throw new ArgumentNullException(nameof(factory));
|
|
}
|
|
|
|
internal bool IsDirty => _dirty;
|
|
|
|
public void MarkDirty() => _dirty = true;
|
|
|
|
public void Render()
|
|
{
|
|
if (!_view.TryGetVisibleSize(out int width, out int height))
|
|
return;
|
|
|
|
if (_dirty)
|
|
{
|
|
if (_factory.TryBuild(out WorldEntity? doll))
|
|
{
|
|
// Same-generation CreateObject refreshes can repeat the exact
|
|
// player ObjDesc at a portal boundary. Retail redresses its
|
|
// private inventory object in place; releasing and reacquiring
|
|
// an identical synthetic owner briefly blanks the viewport and
|
|
// churns its texture composites.
|
|
if (!HasEquivalentAppearance(_doll, doll))
|
|
{
|
|
_renderer.SetDoll(doll);
|
|
_doll = doll;
|
|
}
|
|
_dirty = false;
|
|
}
|
|
else
|
|
{
|
|
// gmPaperDollUI::RedressCreature @ 0x004A3BC0 leaves its
|
|
// private m_pInventoryObject intact when the SmartBox player
|
|
// is temporarily unavailable. Keep the successful doll and
|
|
// retry this dirty redress on the next visible frame.
|
|
}
|
|
}
|
|
|
|
_view.SetTextureHandle(_renderer.Render(width, height));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Clears the private object only at the owning character-session
|
|
/// boundary, matching gmPaperDollUI's private-object lifetime.
|
|
/// </summary>
|
|
public void ResetSession()
|
|
{
|
|
_renderer.SetDoll(null);
|
|
_doll = null;
|
|
_dirty = true;
|
|
}
|
|
|
|
private static bool HasEquivalentAppearance(
|
|
WorldEntity? current,
|
|
WorldEntity? candidate)
|
|
{
|
|
if (current is null || candidate is null)
|
|
return ReferenceEquals(current, candidate);
|
|
if (current.SourceGfxObjOrSetupId != candidate.SourceGfxObjOrSetupId
|
|
|| current.Scale != candidate.Scale
|
|
|| current.HiddenPartsMask != candidate.HiddenPartsMask
|
|
|| current.MeshRefs.Count != candidate.MeshRefs.Count
|
|
|| current.PartOverrides.Count != candidate.PartOverrides.Count)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
for (int i = 0; i < current.MeshRefs.Count; i++)
|
|
{
|
|
MeshRef left = current.MeshRefs[i];
|
|
MeshRef right = candidate.MeshRefs[i];
|
|
if (left.GfxObjId != right.GfxObjId
|
|
|| left.PartTransform != right.PartTransform
|
|
|| !DictionaryEquals(
|
|
left.SurfaceOverrides,
|
|
right.SurfaceOverrides))
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
for (int i = 0; i < current.PartOverrides.Count; i++)
|
|
{
|
|
if (current.PartOverrides[i] != candidate.PartOverrides[i])
|
|
return false;
|
|
}
|
|
|
|
PaletteOverride? leftPalette = current.PaletteOverride;
|
|
PaletteOverride? rightPalette = candidate.PaletteOverride;
|
|
if (leftPalette is null || rightPalette is null)
|
|
return leftPalette is null && rightPalette is null;
|
|
if (leftPalette.BasePaletteId != rightPalette.BasePaletteId
|
|
|| leftPalette.SubPalettes.Count != rightPalette.SubPalettes.Count)
|
|
{
|
|
return false;
|
|
}
|
|
for (int i = 0; i < leftPalette.SubPalettes.Count; i++)
|
|
{
|
|
if (leftPalette.SubPalettes[i] != rightPalette.SubPalettes[i])
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
private static bool DictionaryEquals(
|
|
IReadOnlyDictionary<uint, uint>? left,
|
|
IReadOnlyDictionary<uint, uint>? right)
|
|
{
|
|
if (left is null || right is null)
|
|
return left is null && right is null;
|
|
if (left.Count != right.Count)
|
|
return false;
|
|
foreach ((uint key, uint value) in left)
|
|
{
|
|
if (!right.TryGetValue(key, out uint rightValue)
|
|
|| rightValue != value)
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
}
|
|
|
|
/// <summary>Retained-UI visibility and texture publication for the doll view.</summary>
|
|
internal sealed class RetailPaperdollFrameView : IPaperdollFrameView
|
|
{
|
|
private readonly UiViewport _viewport;
|
|
private readonly IPaperdollInventoryVisibility _inventory;
|
|
|
|
public RetailPaperdollFrameView(
|
|
UiViewport viewport,
|
|
IPaperdollInventoryVisibility inventory)
|
|
{
|
|
_viewport = viewport ?? throw new ArgumentNullException(nameof(viewport));
|
|
_inventory = inventory ?? throw new ArgumentNullException(nameof(inventory));
|
|
}
|
|
|
|
public bool TryGetVisibleSize(out int width, out int height)
|
|
{
|
|
width = 0;
|
|
height = 0;
|
|
if (!_viewport.Visible || !_inventory.IsVisible)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
width = (int)_viewport.Width;
|
|
height = (int)_viewport.Height;
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Campaign V slice V6k: the renderer already hands out a
|
|
/// <see cref="UiTextureTableHandle"/>, so this decodes rather than registers.
|
|
/// The §7.1 external-texture seam it used to call is deleted with V4g.
|
|
/// </summary>
|
|
public void SetTextureHandle(uint textureHandle) =>
|
|
_viewport.TextureSlot = UiTextureTableHandle.ToSlot(textureHandle);
|
|
}
|
|
|
|
/// <summary>Narrow visibility adapter for the paperdoll's inventory host.</summary>
|
|
internal sealed class PaperdollInventoryVisibility : IPaperdollInventoryVisibility
|
|
{
|
|
private readonly UiElement _inventoryFrame;
|
|
|
|
public PaperdollInventoryVisibility(UiElement inventoryFrame)
|
|
{
|
|
_inventoryFrame = inventoryFrame
|
|
?? throw new ArgumentNullException(nameof(inventoryFrame));
|
|
}
|
|
|
|
public bool IsVisible => _inventoryFrame.Visible;
|
|
}
|
|
|
|
/// <summary>Canonical live-entity lookup used by the paperdoll factory.</summary>
|
|
internal sealed class LivePaperdollEntityLookup : IPaperdollEntityLookup
|
|
{
|
|
private readonly LiveEntityRuntime _liveEntities;
|
|
|
|
public LivePaperdollEntityLookup(LiveEntityRuntime liveEntities)
|
|
{
|
|
_liveEntities = liveEntities
|
|
?? throw new ArgumentNullException(nameof(liveEntities));
|
|
}
|
|
|
|
public bool TryGet(uint serverGuid, out WorldEntity player) =>
|
|
_liveEntities.TryGetWorldEntity(serverGuid, out player);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds the static retail paperdoll clone from the canonical live player
|
|
/// projection and applies the DAT-defined held pose.
|
|
/// </summary>
|
|
internal sealed class RetailPaperdollDollFactory : IPaperdollDollFactory
|
|
{
|
|
private readonly IPaperdollEntityLookup _entities;
|
|
private readonly ILocalPlayerIdentitySource _identity;
|
|
private readonly IPaperdollPoseApplicator _pose;
|
|
|
|
public RetailPaperdollDollFactory(
|
|
IPaperdollEntityLookup entities,
|
|
ILocalPlayerIdentitySource identity,
|
|
IPaperdollPoseApplicator pose)
|
|
{
|
|
_entities = entities ?? throw new ArgumentNullException(nameof(entities));
|
|
_identity = identity ?? throw new ArgumentNullException(nameof(identity));
|
|
_pose = pose ?? throw new ArgumentNullException(nameof(pose));
|
|
}
|
|
|
|
public bool TryBuild(out WorldEntity? doll)
|
|
{
|
|
doll = null;
|
|
if (!_entities.TryGet(_identity.ServerGuid, out WorldEntity player)
|
|
|| player.MeshRefs.Count == 0)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
uint? basePalette = null;
|
|
List<(uint, byte, byte)>? subPalettes = null;
|
|
if (player.PaletteOverride is { } palette)
|
|
{
|
|
basePalette = palette.BasePaletteId;
|
|
subPalettes = new List<(uint, byte, byte)>();
|
|
foreach (var range in palette.SubPalettes)
|
|
{
|
|
subPalettes.Add((
|
|
range.SubPaletteId,
|
|
range.Offset,
|
|
range.Length));
|
|
}
|
|
}
|
|
|
|
List<(byte, uint)>? partOverrides = null;
|
|
if (player.PartOverrides.Count > 0)
|
|
{
|
|
partOverrides = new List<(byte, uint)>(player.PartOverrides.Count);
|
|
foreach (var part in player.PartOverrides)
|
|
partOverrides.Add((part.PartIndex, part.GfxObjId));
|
|
}
|
|
|
|
doll = DollEntityBuilder.Build(
|
|
player.SourceGfxObjOrSetupId,
|
|
new List<MeshRef>(player.MeshRefs),
|
|
basePalette,
|
|
subPalettes,
|
|
partOverrides);
|
|
_pose.Apply(doll, player.SourceGfxObjOrSetupId);
|
|
return true;
|
|
}
|
|
}
|
|
|
|
/// <summary>Applies retail's DAT-defined settled paperdoll stance.</summary>
|
|
internal sealed class RetailPaperdollPoseApplicator : IPaperdollPoseApplicator
|
|
{
|
|
private readonly IDatReaderWriter _dats;
|
|
private readonly IAnimationLoader _animations;
|
|
private readonly object _datLock;
|
|
|
|
public RetailPaperdollPoseApplicator(
|
|
IDatReaderWriter dats,
|
|
IAnimationLoader animations,
|
|
object datLock)
|
|
{
|
|
_dats = dats ?? throw new ArgumentNullException(nameof(dats));
|
|
_animations = animations ?? throw new ArgumentNullException(nameof(animations));
|
|
_datLock = datLock ?? throw new ArgumentNullException(nameof(datLock));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Retail <c>gmPaperDollUI</c> resolves its held pose with
|
|
/// <c>DBCache::GetDIDFromEnumStatic(0x10000005, 7)</c>. The master map
|
|
/// therefore resolves key 7 to a sub-map, then key 0x10000005 to the
|
|
/// Animation DID.
|
|
/// </summary>
|
|
private uint ResolvePoseDid()
|
|
{
|
|
uint masterDid = (uint)_dats.Portal.Db.Header.MasterMapId;
|
|
if (masterDid == 0
|
|
|| !_dats.Portal.TryGet<DatReaderWriter.DBObjs.EnumIDMap>(
|
|
masterDid,
|
|
out var master)
|
|
|| !master.ClientEnumToID.TryGetValue(7u, out uint subDid)
|
|
|| !_dats.Portal.TryGet<DatReaderWriter.DBObjs.EnumIDMap>(
|
|
subDid,
|
|
out var sub))
|
|
{
|
|
return 0u;
|
|
}
|
|
|
|
return sub.ClientEnumToID.TryGetValue(0x10000005u, out uint did)
|
|
? did
|
|
: 0u;
|
|
}
|
|
|
|
public void Apply(WorldEntity doll, uint setupId)
|
|
{
|
|
DatReaderWriter.DBObjs.Animation? animation;
|
|
DatReaderWriter.DBObjs.Setup? setup;
|
|
lock (_datLock)
|
|
{
|
|
uint poseDid = ResolvePoseDid();
|
|
if ((poseDid >> 24) != 0x03u)
|
|
return;
|
|
|
|
animation = _animations.LoadAnimation(poseDid);
|
|
setup = _dats.Get<DatReaderWriter.DBObjs.Setup>(setupId);
|
|
}
|
|
if (animation is null || setup is null || animation.PartFrames.Count == 0)
|
|
return;
|
|
|
|
// RedressCreature @ 0x004A3C22 installs the pose with zero frame rate
|
|
// and holds the settled final frame.
|
|
var frame = animation.PartFrames[^1];
|
|
var reposed = new List<MeshRef>(doll.MeshRefs.Count);
|
|
for (int index = 0; index < doll.MeshRefs.Count; index++)
|
|
{
|
|
Vector3 scale = index < setup.DefaultScale.Count
|
|
? setup.DefaultScale[index]
|
|
: Vector3.One;
|
|
Vector3 origin = Vector3.Zero;
|
|
Quaternion orientation = Quaternion.Identity;
|
|
if (index < frame.Frames.Count)
|
|
{
|
|
origin = frame.Frames[index].Origin;
|
|
orientation = frame.Frames[index].Orientation;
|
|
}
|
|
|
|
Matrix4x4 transform = Matrix4x4.CreateScale(scale)
|
|
* Matrix4x4.CreateFromQuaternion(orientation)
|
|
* Matrix4x4.CreateTranslation(origin);
|
|
MeshRef source = doll.MeshRefs[index];
|
|
reposed.Add(new MeshRef(source.GfxObjId, transform)
|
|
{
|
|
SurfaceOverrides = source.SurfaceOverrides,
|
|
});
|
|
}
|
|
|
|
doll.MeshRefs = reposed;
|
|
}
|
|
}
|