feat(ui): port retail creature appraisal presentation

Render assessed creatures through the shared private viewport with retail heading, bounding-box camera, and light. Build the exact authored nine-row stat list and resolve creature names from the retail EnumMapper while keeping remaining font/sequencer adaptations explicit.

Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-23 12:55:24 +02:00
parent f1a7912160
commit 7eaa68a5f4
26 changed files with 2780 additions and 237 deletions

View file

@ -441,7 +441,9 @@ internal sealed class FrameRootCompositionPhase
session.LocalTeleport,
host.CameraController),
renderFrameResources,
live.PaperdollPresenter,
new PrivateEntityViewportFrameGroup(
live.PaperdollPresenter,
live.CreatureAppraisalPresenter),
retainedGameplayUi,
settings.DevTools?.Presenter,
privateScreenshot);

View file

@ -94,6 +94,8 @@ internal sealed record LivePresentationResult(
RetainedUiGameplayBinding? RetainedGameplay,
PaperdollViewportRenderer? PaperdollRenderer,
PaperdollFramePresenter? PaperdollPresenter,
CreatureAppraisalViewportRenderer? CreatureAppraisalRenderer,
CreatureAppraisalFramePresenter? CreatureAppraisalPresenter,
WbFrustum EnvCellFrustum,
EnvCellRenderer EnvCellRenderer,
LandblockPresentationPipeline LandblockPipeline,
@ -120,7 +122,7 @@ internal enum LivePresentationCompositionPoint
EffectRoutingBound,
SelectionAndRadarBound,
RetainedGameplayBound,
PaperdollCreated,
PrivateCreatureViewportsCreated,
EnvironmentCellsCreated,
LandblockPipelineCreated,
PortalResourcesCreated,
@ -694,7 +696,48 @@ internal sealed class LivePresentationCompositionPhase
content.AnimationLoader,
d.DatLock)));
}
Fault(LivePresentationCompositionPoint.PaperdollCreated);
CompositionAcquisitionScope.CompositionAcquisitionLease<
CreatureAppraisalViewportRenderer>? creatureAppraisalLease = null;
CreatureAppraisalFramePresenter? creatureAppraisalPresenter = null;
if (interaction.RetainedUi?.Runtime.CreatureAppraisalViewportWidget
is { } creatureViewport
&& interaction.RetainedUi.Runtime.ExaminationFrame
is { } examinationFrame
&& interaction.RetainedUi.Runtime.AppraisalController
is { } appraisalController)
{
creatureAppraisalLease = scope.Acquire(
"creature appraisal viewport",
() => new CreatureAppraisalViewportRenderer(
d.Gl,
dispatcherLease.Resource,
foundation.SceneLighting,
foundation.TextureCache),
static value => value.Dispose());
IUiViewportRenderer? previousRenderer = creatureViewport.Renderer;
creatureViewport.Renderer = creatureAppraisalLease.Resource;
bindings.AdoptRelease(
"creature appraisal viewport target",
() =>
{
if (ReferenceEquals(
creatureViewport.Renderer,
creatureAppraisalLease.Resource))
{
creatureViewport.Renderer = previousRenderer;
}
});
creatureAppraisalPresenter = new CreatureAppraisalFramePresenter(
creatureAppraisalLease.Resource,
new RetailCreatureAppraisalFrameView(
creatureViewport,
examinationFrame,
appraisalController),
new RetailCreatureAppraisalCloneFactory(
new LiveCreatureAppraisalEntityLookup(liveEntities)));
}
Fault(LivePresentationCompositionPoint.PrivateCreatureViewportsCreated);
var envCellFrustum = new WbFrustum();
var envCellLease = scope.Acquire(
@ -890,6 +933,8 @@ internal sealed class LivePresentationCompositionPhase
retainedGameplayLease?.Resource,
paperdollLease?.Resource,
paperdollPresenter,
creatureAppraisalLease?.Resource,
creatureAppraisalPresenter,
envCellFrustum,
envCellLease.Resource,
landblockPipeline,
@ -910,6 +955,7 @@ internal sealed class LivePresentationCompositionPhase
dispatcherLease.Transfer();
retainedGameplayLease?.Transfer();
paperdollLease?.Transfer();
creatureAppraisalLease?.Transfer();
envCellLease.Transfer();
clipFrameLease.Transfer();
portalDepthLease.Transfer();

View file

@ -0,0 +1,407 @@
using System.Numerics;
using AcDream.App.Rendering.Wb;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.App.World;
using AcDream.Core.Lighting;
using AcDream.Core.World;
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering;
internal interface ICreatureAppraisalRenderer
{
void SetCreature(
WorldEntity? creature,
Vector3 boundsMin,
Vector3 boundsMax);
uint Render(int width, int height);
}
internal interface ICreatureAppraisalFrameView
{
bool TryGetVisibleTarget(
out uint serverGuid,
out int width,
out int height);
void SetTextureHandle(uint textureHandle);
}
internal interface ICreatureAppraisalEntityLookup
{
bool TryGet(uint serverGuid, out WorldEntity entity);
}
internal interface ICreatureAppraisalCloneFactory
{
bool TrySynchronize(
uint serverGuid,
WorldEntity? currentClone,
out WorldEntity? synchronizedClone,
out Vector3 boundsMin,
out Vector3 boundsMax);
}
/// <summary>
/// Per-frame owner for retail's examination <c>CreatureMode</c>. Unlike the
/// inventory doll's held pose, the examination clone follows the live target's
/// current animated part transforms every frame.
/// </summary>
internal sealed class CreatureAppraisalFramePresenter :
IPrivateEntityViewportFrame
{
private readonly ICreatureAppraisalRenderer _renderer;
private readonly ICreatureAppraisalFrameView _view;
private readonly ICreatureAppraisalCloneFactory _factory;
private WorldEntity? _clone;
private uint _serverGuid;
public CreatureAppraisalFramePresenter(
ICreatureAppraisalRenderer renderer,
ICreatureAppraisalFrameView view,
ICreatureAppraisalCloneFactory factory)
{
_renderer = renderer ?? throw new ArgumentNullException(nameof(renderer));
_view = view ?? throw new ArgumentNullException(nameof(view));
_factory = factory ?? throw new ArgumentNullException(nameof(factory));
}
public void Render()
{
if (!_view.TryGetVisibleTarget(
out uint serverGuid,
out int width,
out int height))
{
return;
}
if (_serverGuid != serverGuid)
{
_serverGuid = serverGuid;
_clone = null;
}
if (!_factory.TrySynchronize(
serverGuid,
_clone,
out WorldEntity? synchronized,
out Vector3 boundsMin,
out Vector3 boundsMax))
{
_clone = null;
_renderer.SetCreature(null, Vector3.Zero, Vector3.Zero);
_view.SetTextureHandle(0u);
return;
}
_clone = synchronized;
_renderer.SetCreature(_clone, boundsMin, boundsMax);
_view.SetTextureHandle(_renderer.Render(width, height));
}
}
/// <summary>
/// Retained-view adapter for LayoutDesc 0x2100006B element 0x10000148.
/// Parent visibility is checked explicitly because the viewport remains locally
/// visible while retail switches the examination subview to an item.
/// </summary>
internal sealed class RetailCreatureAppraisalFrameView :
ICreatureAppraisalFrameView
{
private readonly UiViewport _viewport;
private readonly UiElement _windowFrame;
private readonly AppraisalUiController _controller;
public RetailCreatureAppraisalFrameView(
UiViewport viewport,
UiElement windowFrame,
AppraisalUiController controller)
{
_viewport = viewport ?? throw new ArgumentNullException(nameof(viewport));
_windowFrame = windowFrame ?? throw new ArgumentNullException(nameof(windowFrame));
_controller = controller ?? throw new ArgumentNullException(nameof(controller));
}
public bool TryGetVisibleTarget(
out uint serverGuid,
out int width,
out int height)
{
serverGuid = 0u;
width = 0;
height = 0;
if (_controller.ActiveView is not (
AppraisalView.Creature or AppraisalView.Character)
|| !IsEffectivelyVisible(_windowFrame)
|| !IsEffectivelyVisible(_viewport)
|| _controller.CurrentObjectId == 0u)
{
return false;
}
serverGuid = _controller.CurrentObjectId;
width = (int)_viewport.Width;
height = (int)_viewport.Height;
return width > 0 && height > 0;
}
public void SetTextureHandle(uint textureHandle) =>
_viewport.TextureHandle = textureHandle;
private static bool IsEffectivelyVisible(UiElement element)
{
for (UiElement? current = element; current is not null; current = current.Parent)
if (!current.Visible)
return false;
return true;
}
}
internal sealed class LiveCreatureAppraisalEntityLookup :
ICreatureAppraisalEntityLookup
{
private readonly LiveEntityRuntime _liveEntities;
public LiveCreatureAppraisalEntityLookup(LiveEntityRuntime liveEntities)
{
_liveEntities = liveEntities
?? throw new ArgumentNullException(nameof(liveEntities));
}
public bool TryGet(uint serverGuid, out WorldEntity entity) =>
_liveEntities.TryGetWorldEntity(serverGuid, out entity);
}
/// <summary>
/// Retail <c>BasicCreatureExamineUI::Init @ 0x004AB9C0</c> clones the selected
/// physics object, fixes its heading at 191.367905 degrees, and lets the private
/// CreatureMode animate that clone. ACDream shares the already-resolved live
/// MeshRefs and updates that reference every frame, giving the private clone the
/// same current animated pose without registering a second gameplay entity.
/// </summary>
internal sealed class RetailCreatureAppraisalCloneFactory :
ICreatureAppraisalCloneFactory
{
private readonly ICreatureAppraisalEntityLookup _entities;
public RetailCreatureAppraisalCloneFactory(
ICreatureAppraisalEntityLookup entities)
{
_entities = entities ?? throw new ArgumentNullException(nameof(entities));
}
public bool TrySynchronize(
uint serverGuid,
WorldEntity? currentClone,
out WorldEntity? synchronizedClone,
out Vector3 boundsMin,
out Vector3 boundsMax)
{
synchronizedClone = null;
boundsMin = Vector3.Zero;
boundsMax = Vector3.Zero;
if (!_entities.TryGet(serverGuid, out WorldEntity source)
|| source.MeshRefs.Count == 0)
{
return false;
}
WorldEntity clone = currentClone is not null
&& currentClone.SourceGfxObjOrSetupId == source.SourceGfxObjOrSetupId
? currentClone
: CreatureAppraisalEntityBuilder.Build(source);
clone.ApplyAppearance(
source.MeshRefs,
source.PaletteOverride,
source.PartOverrides);
clone.IsDrawVisible = source.IsDrawVisible;
clone.IsAncestorDrawVisible = source.IsAncestorDrawVisible;
if (source.HasLocalBounds)
clone.SetLocalBounds(source.LocalBoundMin, source.LocalBoundMax);
(boundsMin, boundsMax) =
CreatureAppraisalEntityBuilder.RotatedBounds(source);
synchronizedClone = clone;
return true;
}
}
internal static class CreatureAppraisalEntityBuilder
{
public const uint RenderId = 0xDA11_D022u;
public const uint ServerGuid = 0xDA11_D021u;
private const float HeadingDegrees = 191.367905f;
private static readonly Quaternion Heading = Quaternion.CreateFromAxisAngle(
Vector3.UnitZ,
-HeadingDegrees * (MathF.PI / 180f));
public static WorldEntity Build(WorldEntity source)
{
ArgumentNullException.ThrowIfNull(source);
var clone = new WorldEntity
{
Id = RenderId,
ServerGuid = ServerGuid,
SourceGfxObjOrSetupId = source.SourceGfxObjOrSetupId,
Position = Vector3.Zero,
Rotation = Heading,
MeshRefs = source.MeshRefs,
PaletteOverride = source.PaletteOverride,
PartOverrides = source.PartOverrides,
HiddenPartsMask = source.HiddenPartsMask,
Scale = source.Scale,
ParentCellId = null,
EffectCellId = null,
};
if (source.HasLocalBounds)
clone.SetLocalBounds(source.LocalBoundMin, source.LocalBoundMax);
return clone;
}
public static (Vector3 Min, Vector3 Max) RotatedBounds(
WorldEntity source)
{
Vector3 min;
Vector3 max;
if (source.HasLocalBounds)
{
min = source.LocalBoundMin;
max = source.LocalBoundMax;
}
else
{
min = new Vector3(-0.5f, -0.5f, 0f);
max = new Vector3(0.5f, 0.5f, 2f);
foreach (MeshRef mesh in source.MeshRefs)
{
Vector3 p = mesh.PartTransform.Translation;
min = Vector3.Min(min, p - new Vector3(0.5f));
max = Vector3.Max(max, p + new Vector3(0.5f));
}
}
Vector3 rotatedMin = default;
Vector3 rotatedMax = default;
for (int corner = 0; corner < 8; corner++)
{
Vector3 point = new(
(corner & 1) == 0 ? min.X : max.X,
(corner & 2) == 0 ? min.Y : max.Y,
(corner & 4) == 0 ? min.Z : max.Z);
point = Vector3.Transform(point, Heading);
if (corner == 0)
rotatedMin = rotatedMax = point;
else
{
rotatedMin = Vector3.Min(rotatedMin, point);
rotatedMax = Vector3.Max(rotatedMax, point);
}
}
return (rotatedMin, rotatedMax);
}
}
/// <summary>
/// Bounding-box camera from <c>BasicCreatureExamineUI::Init @ 0x004AB9C0</c>.
/// The camera keeps retail's identity direction (+Y) and 45-degree vertical
/// field of view. Its distance is the fitted vertical span times 1.20710683,
/// plus half the creature's front-to-back depth.
/// </summary>
internal sealed class CreatureAppraisalCamera :
IPrivateEntityViewportCamera
{
private const float FitDistanceFactor = 1.20710683f;
private Vector3 _boundsMin = new(-0.5f, -0.5f, 0f);
private Vector3 _boundsMax = new(0.5f, 0.5f, 2f);
private float _aspect = 1f;
private Vector3 _eye;
public CreatureAppraisalCamera() => Recalculate();
public float Aspect
{
get => _aspect;
set
{
_aspect = float.IsFinite(value) && value > 0f ? value : 1f;
Recalculate();
}
}
public Vector3 Eye => _eye;
public float FovRadians { get; set; } = MathF.PI / 4f;
public float Near { get; set; } = 0.1f;
public float Far { get; set; } = 2048f;
public Matrix4x4 View =>
Matrix4x4.CreateLookAt(_eye, _eye + Vector3.UnitY, Vector3.UnitZ);
public Matrix4x4 Projection => Matrix4x4.CreatePerspectiveFieldOfView(
FovRadians,
_aspect,
Near,
Far);
public void Fit(Vector3 min, Vector3 max)
{
_boundsMin = Vector3.Min(min, max);
_boundsMax = Vector3.Max(min, max);
Recalculate();
}
private void Recalculate()
{
Vector3 span = Vector3.Max(
_boundsMax - _boundsMin,
new Vector3(0.001f));
Vector3 center = (_boundsMin + _boundsMax) * 0.5f;
float fittedVertical = MathF.Max(span.Z, span.X / _aspect);
float distance = fittedVertical * FitDistanceFactor + span.Y * 0.5f;
_eye = new Vector3(center.X, center.Y - distance, center.Z);
}
}
/// <summary>Examination-specific facade over the shared private renderer.</summary>
internal sealed class CreatureAppraisalViewportRenderer :
IUiViewportRenderer,
ICreatureAppraisalRenderer,
IDisposable
{
private readonly CreatureAppraisalCamera _camera = new();
private readonly PrivateEntityViewportRenderer _renderer;
public CreatureAppraisalViewportRenderer(
GL gl,
WbDrawDispatcher dispatcher,
SceneLightingUboBinding lightUbo,
IEntityTextureLifetime textureLifetime)
{
_renderer = new PrivateEntityViewportRenderer(
gl,
dispatcher,
lightUbo,
textureLifetime,
CreatureAppraisalEntityBuilder.RenderId,
_camera,
"creature examination");
}
public void SetCreature(
WorldEntity? creature,
Vector3 boundsMin,
Vector3 boundsMax)
{
if (creature is not null)
_camera.Fit(boundsMin, boundsMax);
_renderer.SetEntity(creature);
}
public uint Render(int width, int height) =>
_renderer.Render(width, height);
public void Dispose() => _renderer.Dispose();
}

View file

@ -31,7 +31,7 @@ namespace AcDream.App.Rendering;
public sealed class DollCamera : ICamera
{
// Retail paperdoll camera origin (decomp 0x004a5a510x004a5a61).
private static readonly Vector3 Eye = new(0.12f, -2.4f, 0.88f);
internal static readonly Vector3 RetailEye = new(0.12f, -2.4f, 0.88f);
// Identity view orientation ⇒ look straight down +Y (no yaw/pitch). Target = Eye + (0,1,0).
private static readonly Vector3 Target = new(0.12f, -1.4f, 0.88f);
private static readonly Vector3 Up = Vector3.UnitZ; // AC up-axis = +Z, same as ChaseCamera
@ -45,9 +45,42 @@ public sealed class DollCamera : ICamera
/// <inheritdoc/>
public Matrix4x4 View =>
Matrix4x4.CreateLookAt(Eye, Target, Up);
Matrix4x4.CreateLookAt(RetailEye, Target, Up);
/// <inheritdoc/>
public Matrix4x4 Projection =>
Matrix4x4.CreatePerspectiveFieldOfView(FovRadians, Aspect <= 0f ? 1f : Aspect, Near, Far);
}
/// <summary>
/// Internal private-viewport adapter that preserves <see cref="DollCamera"/>'s
/// existing public camera contract.
/// </summary>
internal sealed class DollViewportCamera : IPrivateEntityViewportCamera
{
private readonly DollCamera _camera = new();
public Vector3 Eye => DollCamera.RetailEye;
public float FovRadians
{
get => _camera.FovRadians;
set => _camera.FovRadians = value;
}
public float Near
{
get => _camera.Near;
set => _camera.Near = value;
}
public float Far
{
get => _camera.Far;
set => _camera.Far = value;
}
public float Aspect
{
get => _camera.Aspect;
set => _camera.Aspect = value;
}
public Matrix4x4 View => _camera.View;
public Matrix4x4 Projection => _camera.Projection;
}

View file

@ -378,6 +378,10 @@ public sealed class GameWindow :
// widget that blits it, the inventory frame (for the open-gate), and a dirty flag (re-dress on 0xF625).
private AcDream.App.Rendering.PaperdollViewportRenderer? _paperdollViewportRenderer;
private AcDream.App.Rendering.PaperdollFramePresenter? _paperdollFramePresenter;
private AcDream.App.Rendering.CreatureAppraisalViewportRenderer?
_creatureAppraisalViewportRenderer;
private AcDream.App.Rendering.CreatureAppraisalFramePresenter?
_creatureAppraisalFramePresenter;
// Phase D.2b Task 9 — plugin UI registrations buffered before OnLoad; drained in OnLoad.
private readonly AcDream.App.Plugins.BufferedUiRegistry? _uiRegistry;
// Phase I.2: ImGui debug panel ViewModel. The devtools presenter owns
@ -948,6 +952,8 @@ public sealed class GameWindow :
|| _animationPresenter is not null
|| _paperdollViewportRenderer is not null
|| _paperdollFramePresenter is not null
|| _creatureAppraisalViewportRenderer is not null
|| _creatureAppraisalFramePresenter is not null
|| _envCellRenderer is not null
|| _envCellFrustum is not null
|| _landblockPresentationPipeline is not null
@ -983,6 +989,8 @@ public sealed class GameWindow :
_retainedUiGameplayBinding = result.RetainedGameplay;
_paperdollViewportRenderer = result.PaperdollRenderer;
_paperdollFramePresenter = result.PaperdollPresenter;
_creatureAppraisalViewportRenderer = result.CreatureAppraisalRenderer;
_creatureAppraisalFramePresenter = result.CreatureAppraisalPresenter;
_envCellFrustum = result.EnvCellFrustum;
_envCellRenderer = result.EnvCellRenderer;
_landblockPresentationPipeline = result.LandblockPipeline;
@ -1581,6 +1589,7 @@ public sealed class GameWindow :
_localPlayerTeleport,
_portalTunnelFallback,
_paperdollViewportRenderer,
_creatureAppraisalViewportRenderer,
_wbDrawDispatcher,
_envCellRenderer,
_portalDepthMask,

View file

@ -96,6 +96,7 @@ internal sealed record RenderShutdownRoots(
LocalPlayerTeleportController? LocalTeleport,
TransferableResourceSlot<PortalTunnelPresentation> PortalTunnelFallback,
PaperdollViewportRenderer? Paperdoll,
CreatureAppraisalViewportRenderer? CreatureAppraisal,
WbDrawDispatcher? DrawDispatcher,
EnvCellRenderer? EnvironmentCells,
PortalDepthMaskRenderer? PortalDepthMask,
@ -413,6 +414,9 @@ internal static class GameWindowShutdownManifest
render.PortalTunnelFallback.ReleaseFallback();
}),
Hard("paperdoll viewport", () => render.Paperdoll?.Dispose()),
Hard(
"creature appraisal viewport",
() => render.CreatureAppraisal?.Dispose()),
Hard("mesh draw dispatcher", () => render.DrawDispatcher?.Dispose()),
Hard("environment cells", () => render.EnvironmentCells?.Dispose()),
Hard("portal depth mask", () => render.PortalDepthMask?.Dispose()),

View file

@ -48,7 +48,7 @@ internal interface IPaperdollPoseApplicator
/// The GL renderer remains a borrowed resource disposed by the existing window
/// shutdown transaction.
/// </summary>
internal sealed class PaperdollFramePresenter : IPrivatePaperdollFrame
internal sealed class PaperdollFramePresenter : IPrivateEntityViewportFrame
{
private readonly IPaperdollDollRenderer _renderer;
private readonly IPaperdollFrameView _view;

View file

@ -1,6 +1,3 @@
using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.App.Rendering.Wb;
using AcDream.App.UI;
using AcDream.Core.Lighting;
@ -10,52 +7,15 @@ using Silk.NET.OpenGL;
namespace AcDream.App.Rendering;
/// <summary>
/// Render-to-texture renderer for the paperdoll 3-D doll (the C# analog of retail
/// <c>CreatureMode::Render</c>). Draws ONE re-dressed player clone (a <see cref="WorldEntity"/>,
/// built by <see cref="DollEntityBuilder"/> + wired by <see cref="PaperdollFramePresenter"/>) into a private off-screen
/// framebuffer with a fixed <see cref="DollCamera"/> + one distant light, then hands the color
/// texture to the <see cref="UiViewport"/> widget which blits it as a normal 2-D sprite.
///
/// <para>The whole 3-D pass is sealed in a <see cref="GLStateScope"/> so it can't disturb the
/// surrounding world / UI GL state ([[feedback_render_self_contained_gl_state]]). It runs in the
/// per-frame pre-UI hook, gated on inventory-open ∧ doll-view — NOT from the widget's
/// 2-D OnDraw.</para>
///
/// <para>Reuses the existing <see cref="WbDrawDispatcher"/> + the player's already-resolved,
/// already-GPU-resident MeshRefs (the doll clones them), so no separate mesh/texture upload is
/// needed. With <c>frustum: null</c> the doll's synthetic landblock is always "visible" and the
/// entity is walked from <c>entry.Entities</c> and drawn from its current MeshRefs — a static held pose.</para>
/// Paperdoll-specific facade over the shared private creature viewport. The
/// fixed camera remains the verbatim retail <c>gmPaperDollUI</c> camera.
/// </summary>
public sealed unsafe class PaperdollViewportRenderer :
public sealed class PaperdollViewportRenderer :
IUiViewportRenderer,
IPaperdollDollRenderer,
IDisposable
{
private readonly GL _gl;
private readonly WbDrawDispatcher _dispatcher;
private readonly SceneLightingUboBinding _lightUbo;
private readonly FixedEntityTextureOwnerLease _textureOwnerLease;
private readonly DollCamera _camera = new();
// Off-screen target (lazily (re)created on size change).
private uint _fbo;
private uint _colorTex;
private uint _depthRbo;
private int _fbW;
private int _fbH;
// The static held-pose doll. null = nothing to draw.
private WorldEntity? _doll;
// Synthetic landblock for the doll's one-entry Draw tuple. With frustum:null + this as
// neverCullLandblockId the entity is never culled. 0 is unused by live streaming on the doll path.
private const uint DollLandblockId = 0u;
// The doll's render id, passed as "animated" so the dispatcher bypasses the Tier-1 classification
// cache (WbDrawDispatcher.cs:1142) and re-classifies from the current MeshRefs every frame. This is
// what makes a re-dress (a new WorldEntity built with the same DollRenderId) actually take effect,
// AND what lets the idle animation (later slice, TickAnimations rebuilds MeshRefs) show.
private static readonly HashSet<uint> _dollAnimatedIds = new() { DollEntityBuilder.DollRenderId };
private readonly PrivateEntityViewportRenderer _renderer;
public PaperdollViewportRenderer(
GL gl,
@ -63,151 +23,20 @@ public sealed unsafe class PaperdollViewportRenderer :
SceneLightingUboBinding lightUbo,
IEntityTextureLifetime textureLifetime)
{
_gl = gl ?? throw new ArgumentNullException(nameof(gl));
_dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
_lightUbo = lightUbo ?? throw new ArgumentNullException(nameof(lightUbo));
_textureOwnerLease = new FixedEntityTextureOwnerLease(
_renderer = new PrivateEntityViewportRenderer(
gl,
dispatcher,
lightUbo,
textureLifetime,
DollEntityBuilder.DollRenderId);
DollEntityBuilder.DollRenderId,
new DollViewportCamera(),
"paperdoll");
}
/// <summary>Set (or clear) the doll entity built from the live player projection.</summary>
public void SetDoll(WorldEntity? doll)
{
if (ReferenceEquals(_doll, doll))
return;
_textureOwnerLease.Replace(doll is not null);
_doll = doll;
}
public void SetDoll(WorldEntity? doll) => _renderer.SetEntity(doll);
/// <inheritdoc/>
public uint Render(int width, int height)
{
var doll = _doll;
if (doll is null || doll.MeshRefs.Count == 0 || width <= 0 || height <= 0)
return 0u;
public uint Render(int width, int height) =>
_renderer.Render(width, height);
EnsureFramebuffer(width, height);
if (_fbo == 0) return 0u;
_camera.Aspect = width / (float)height;
// Seal the entire 3-D pass — GLStateScope restores viewport/scissor/depth/cull/blend/program/
// FBO bindings on dispose, so the surrounding world + 2-D UI passes are untouched.
using var scope = new GLStateScope(_gl);
_gl.BindFramebuffer(FramebufferTarget.Framebuffer, _fbo);
_gl.Viewport(0, 0, (uint)width, (uint)height);
_gl.Disable(EnableCap.ScissorTest);
_gl.ClearColor(0f, 0f, 0f, 0f); // transparent — the window backdrop shows through behind the doll
_gl.ClearDepth(1.0);
_gl.DepthMask(true); // depth clears honor glDepthMask
_gl.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit);
_gl.Enable(EnableCap.DepthTest);
_gl.DepthFunc(DepthFunction.Less);
_gl.Enable(EnableCap.CullFace);
_gl.CullFace(TriangleFace.Back);
_gl.FrontFace(FrontFaceDirection.Ccw); // matches the world object pass; if the doll renders inside-out at the gate, flip to Cw
_gl.Disable(EnableCap.Blend);
UploadDollLight();
// One synthetic landblock entry holding just the doll. frustum:null ⇒ always "visible" ⇒
// walked from entry.Entities and drawn from doll.MeshRefs (WbDrawDispatcher.cs:692,1190).
var entities = new WorldEntity[] { doll };
var entries = new (uint, Vector3, Vector3, IReadOnlyList<WorldEntity>, IReadOnlyDictionary<uint, WorldEntity>?)[]
{
(DollLandblockId, new Vector3(-4f, -4f, -4f), new Vector3(4f, 4f, 4f), entities, null),
};
_dispatcher.Draw(
_camera,
entries,
frustum: null,
neverCullLandblockId: DollLandblockId,
visibleCellIds: null,
animatedEntityIds: _dollAnimatedIds); // doll treated as animated ⇒ cache-bypass (re-dress correctness)
return _colorTex;
}
/// <summary>Overwrite the shared scene-lighting UBO (binding=1) with the doll's single distant
/// light. Safe: nothing else draws 3-D after the doll this frame, and GameWindow rebuilds the
/// world UBO at the start of the next frame. Retail values: DISTANT_LIGHT dir (0.3,1.9,0.65)
/// intensity 2.0 (gmPaperDollUI::PostInit decomp 175529-175533).</summary>
private void UploadDollLight()
{
var dir = Vector3.Normalize(new Vector3(0.3f, 1.9f, 0.65f));
var ubo = new SceneLightingUbo
{
Light0 = new UboLight
{
PosAndKind = new Vector4(0f, 0f, 0f, 0f), // kind 0 = directional
DirAndRange = new Vector4(dir, 1e9f), // huge range = no distance cutoff
ColorAndIntensity = new Vector4(1f, 1f, 1f, 2.0f), // white @ retail intensity 2.0
ConeAngleEtc = Vector4.Zero,
},
CellAmbient = new Vector4(0.30f, 0.30f, 0.30f, 1f), // retail CreatureMode default ambient (ctor 0x004543cf = 0.3,0.3,0.3)
FogParams = new Vector4(1e9f, 1e9f, 0f, 0f), // fog pushed to infinity = no fog on the doll
FogColor = Vector4.Zero,
CameraAndTime = new Vector4(0.12f, -2.4f, 0.88f, 0f), // camera world pos (matches DollCamera eye)
};
_lightUbo.Upload(ubo);
}
/// <summary>(Re)create the FBO + color texture + depth-stencil renderbuffer when the requested
/// size changes. Color is RGBA8 with linear filtering + clamp; depth is Depth24Stencil8.</summary>
private void EnsureFramebuffer(int width, int height)
{
if (_fbo != 0 && width == _fbW && height == _fbH) return;
DeleteFramebuffer();
_fbW = width;
_fbH = height;
_colorTex = _gl.GenTexture();
_gl.BindTexture(TextureTarget.Texture2D, _colorTex);
_gl.TexImage2D(TextureTarget.Texture2D, 0, InternalFormat.Rgba8, (uint)width, (uint)height, 0,
PixelFormat.Rgba, PixelType.UnsignedByte, (void*)0);
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.Linear);
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMagFilter, (int)TextureMinFilter.Linear);
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapS, (int)TextureWrapMode.ClampToEdge);
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapT, (int)TextureWrapMode.ClampToEdge);
_gl.BindTexture(TextureTarget.Texture2D, 0);
_depthRbo = _gl.GenRenderbuffer();
_gl.BindRenderbuffer(RenderbufferTarget.Renderbuffer, _depthRbo);
_gl.RenderbufferStorage(RenderbufferTarget.Renderbuffer, InternalFormat.Depth24Stencil8, (uint)width, (uint)height);
_gl.BindRenderbuffer(RenderbufferTarget.Renderbuffer, 0);
_fbo = _gl.GenFramebuffer();
_gl.BindFramebuffer(FramebufferTarget.Framebuffer, _fbo);
_gl.FramebufferTexture2D(FramebufferTarget.Framebuffer, FramebufferAttachment.ColorAttachment0,
TextureTarget.Texture2D, _colorTex, 0);
_gl.FramebufferRenderbuffer(FramebufferTarget.Framebuffer, FramebufferAttachment.DepthStencilAttachment,
RenderbufferTarget.Renderbuffer, _depthRbo);
var status = _gl.CheckFramebufferStatus(FramebufferTarget.Framebuffer);
_gl.BindFramebuffer(FramebufferTarget.Framebuffer, 0);
if (status != GLEnum.FramebufferComplete)
{
Console.WriteLine($"[paperdoll] framebuffer incomplete: {status} ({width}x{height})");
DeleteFramebuffer();
}
}
private void DeleteFramebuffer()
{
if (_fbo != 0) { _gl.DeleteFramebuffer(_fbo); _fbo = 0; }
if (_colorTex != 0) { _gl.DeleteTexture(_colorTex); _colorTex = 0; }
if (_depthRbo != 0) { _gl.DeleteRenderbuffer(_depthRbo); _depthRbo = 0; }
_fbW = _fbH = 0;
}
public void Dispose()
{
_doll = null;
_textureOwnerLease.Dispose();
DeleteFramebuffer();
}
public void Dispose() => _renderer.Dispose();
}

View file

@ -0,0 +1,252 @@
using System.Numerics;
using AcDream.App.Rendering.Wb;
using AcDream.App.UI;
using AcDream.Core.Lighting;
using AcDream.Core.World;
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering;
/// <summary>
/// Camera contract for retail <c>CreatureMode</c>-style UI viewports. The
/// renderer supplies the authored viewport aspect before each draw and uses
/// <see cref="Eye"/> when it publishes the private scene lighting UBO.
/// </summary>
internal interface IPrivateEntityViewportCamera : ICamera
{
Vector3 Eye { get; }
}
/// <summary>
/// Shared render-to-texture implementation for the private 3-D creature
/// viewports used by paperdoll and examination UI. Each instance owns one FBO,
/// one synthetic render identity, and one balanced texture-owner lease.
/// </summary>
internal sealed unsafe class PrivateEntityViewportRenderer :
IUiViewportRenderer,
IDisposable
{
private const uint PrivateLandblockId = 0u;
private readonly GL _gl;
private readonly WbDrawDispatcher _dispatcher;
private readonly SceneLightingUboBinding _lightUbo;
private readonly FixedEntityTextureOwnerLease _textureOwnerLease;
private readonly IPrivateEntityViewportCamera _camera;
private readonly HashSet<uint> _animatedIds;
private readonly string _diagnosticName;
private uint _fbo;
private uint _colorTex;
private uint _depthRbo;
private int _fbW;
private int _fbH;
private WorldEntity? _entity;
public PrivateEntityViewportRenderer(
GL gl,
WbDrawDispatcher dispatcher,
SceneLightingUboBinding lightUbo,
IEntityTextureLifetime textureLifetime,
uint renderId,
IPrivateEntityViewportCamera camera,
string diagnosticName)
{
if (renderId == 0u)
throw new ArgumentOutOfRangeException(nameof(renderId));
_gl = gl ?? throw new ArgumentNullException(nameof(gl));
_dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
_lightUbo = lightUbo ?? throw new ArgumentNullException(nameof(lightUbo));
_camera = camera ?? throw new ArgumentNullException(nameof(camera));
_diagnosticName = string.IsNullOrWhiteSpace(diagnosticName)
? "creature viewport"
: diagnosticName;
_animatedIds = [renderId];
_textureOwnerLease = new FixedEntityTextureOwnerLease(
textureLifetime ?? throw new ArgumentNullException(nameof(textureLifetime)),
renderId);
}
public void SetEntity(WorldEntity? entity)
{
if (ReferenceEquals(_entity, entity))
return;
_textureOwnerLease.Replace(entity is not null);
_entity = entity;
}
public uint Render(int width, int height)
{
WorldEntity? entity = _entity;
if (entity is null || entity.MeshRefs.Count == 0 || width <= 0 || height <= 0)
return 0u;
EnsureFramebuffer(width, height);
if (_fbo == 0u)
return 0u;
_camera.Aspect = width / (float)height;
using var scope = new GLStateScope(_gl);
_gl.BindFramebuffer(FramebufferTarget.Framebuffer, _fbo);
_gl.Viewport(0, 0, (uint)width, (uint)height);
_gl.Disable(EnableCap.ScissorTest);
_gl.ClearColor(0f, 0f, 0f, 0f);
_gl.ClearDepth(1.0);
_gl.DepthMask(true);
_gl.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit);
_gl.Enable(EnableCap.DepthTest);
_gl.DepthFunc(DepthFunction.Less);
_gl.Enable(EnableCap.CullFace);
_gl.CullFace(TriangleFace.Back);
_gl.FrontFace(FrontFaceDirection.Ccw);
_gl.Disable(EnableCap.Blend);
UploadCreatureLight();
WorldEntity[] entities = [entity];
var entries =
new (uint, Vector3, Vector3, IReadOnlyList<WorldEntity>,
IReadOnlyDictionary<uint, WorldEntity>?)[]
{
(
PrivateLandblockId,
new Vector3(-1024f),
new Vector3(1024f),
entities,
null),
};
_dispatcher.Draw(
_camera,
entries,
frustum: null,
neverCullLandblockId: PrivateLandblockId,
visibleCellIds: null,
animatedEntityIds: _animatedIds);
return _colorTex;
}
/// <summary>
/// Both retail paperdoll and creature examination call
/// <c>UIElement_Viewport::SetLight(DISTANT_LIGHT, 2, (0.3,1.9,0.65))</c>.
/// </summary>
private void UploadCreatureLight()
{
Vector3 direction = Vector3.Normalize(new Vector3(0.3f, 1.9f, 0.65f));
_lightUbo.Upload(new SceneLightingUbo
{
Light0 = new UboLight
{
PosAndKind = Vector4.Zero,
DirAndRange = new Vector4(direction, 1e9f),
ColorAndIntensity = new Vector4(1f, 1f, 1f, 2f),
ConeAngleEtc = Vector4.Zero,
},
CellAmbient = new Vector4(0.3f, 0.3f, 0.3f, 1f),
FogParams = new Vector4(1e9f, 1e9f, 0f, 0f),
FogColor = Vector4.Zero,
CameraAndTime = new Vector4(_camera.Eye, 0f),
});
}
private void EnsureFramebuffer(int width, int height)
{
if (_fbo != 0u && width == _fbW && height == _fbH)
return;
DeleteFramebuffer();
_fbW = width;
_fbH = height;
_colorTex = _gl.GenTexture();
_gl.BindTexture(TextureTarget.Texture2D, _colorTex);
_gl.TexImage2D(
TextureTarget.Texture2D,
0,
InternalFormat.Rgba8,
(uint)width,
(uint)height,
0,
PixelFormat.Rgba,
PixelType.UnsignedByte,
(void*)0);
_gl.TexParameter(
TextureTarget.Texture2D,
TextureParameterName.TextureMinFilter,
(int)TextureMinFilter.Linear);
_gl.TexParameter(
TextureTarget.Texture2D,
TextureParameterName.TextureMagFilter,
(int)TextureMinFilter.Linear);
_gl.TexParameter(
TextureTarget.Texture2D,
TextureParameterName.TextureWrapS,
(int)TextureWrapMode.ClampToEdge);
_gl.TexParameter(
TextureTarget.Texture2D,
TextureParameterName.TextureWrapT,
(int)TextureWrapMode.ClampToEdge);
_gl.BindTexture(TextureTarget.Texture2D, 0u);
_depthRbo = _gl.GenRenderbuffer();
_gl.BindRenderbuffer(RenderbufferTarget.Renderbuffer, _depthRbo);
_gl.RenderbufferStorage(
RenderbufferTarget.Renderbuffer,
InternalFormat.Depth24Stencil8,
(uint)width,
(uint)height);
_gl.BindRenderbuffer(RenderbufferTarget.Renderbuffer, 0u);
_fbo = _gl.GenFramebuffer();
_gl.BindFramebuffer(FramebufferTarget.Framebuffer, _fbo);
_gl.FramebufferTexture2D(
FramebufferTarget.Framebuffer,
FramebufferAttachment.ColorAttachment0,
TextureTarget.Texture2D,
_colorTex,
0);
_gl.FramebufferRenderbuffer(
FramebufferTarget.Framebuffer,
FramebufferAttachment.DepthStencilAttachment,
RenderbufferTarget.Renderbuffer,
_depthRbo);
GLEnum status = _gl.CheckFramebufferStatus(
FramebufferTarget.Framebuffer);
_gl.BindFramebuffer(FramebufferTarget.Framebuffer, 0u);
if (status != GLEnum.FramebufferComplete)
{
Console.WriteLine(
$"[{_diagnosticName}] framebuffer incomplete: {status} ({width}x{height})");
DeleteFramebuffer();
}
}
private void DeleteFramebuffer()
{
if (_fbo != 0u)
{
_gl.DeleteFramebuffer(_fbo);
_fbo = 0u;
}
if (_colorTex != 0u)
{
_gl.DeleteTexture(_colorTex);
_colorTex = 0u;
}
if (_depthRbo != 0u)
{
_gl.DeleteRenderbuffer(_depthRbo);
_depthRbo = 0u;
}
_fbW = 0;
_fbH = 0;
}
public void Dispose()
{
_entity = null;
_textureOwnerLease.Dispose();
DeleteFramebuffer();
}
}

View file

@ -11,7 +11,7 @@ internal interface IPrivatePortalViewport
void Draw(int width, int height);
}
internal interface IPrivatePaperdollFrame
internal interface IPrivateEntityViewportFrame
{
void Render();
}
@ -35,7 +35,7 @@ internal sealed class PrivatePresentationRenderer : IPrivatePresentationFramePha
{
private readonly IPrivatePortalViewport _portal;
private readonly IRenderFrameFoundationSource _foundation;
private readonly IPrivatePaperdollFrame? _paperdoll;
private readonly IPrivateEntityViewportFrame? _entityViewports;
private readonly IRetainedGameplayUiFrame? _gameplayUi;
private readonly IDevToolsFrameLifecycle? _devTools;
private readonly IPrivateFrameScreenshot? _screenshots;
@ -43,7 +43,7 @@ internal sealed class PrivatePresentationRenderer : IPrivatePresentationFramePha
public PrivatePresentationRenderer(
IPrivatePortalViewport portal,
IRenderFrameFoundationSource foundation,
IPrivatePaperdollFrame? paperdoll,
IPrivateEntityViewportFrame? entityViewports,
IRetainedGameplayUiFrame? gameplayUi,
IDevToolsFrameLifecycle? devTools,
IPrivateFrameScreenshot? screenshots)
@ -51,7 +51,7 @@ internal sealed class PrivatePresentationRenderer : IPrivatePresentationFramePha
_portal = portal ?? throw new ArgumentNullException(nameof(portal));
_foundation = foundation
?? throw new ArgumentNullException(nameof(foundation));
_paperdoll = paperdoll;
_entityViewports = entityViewports;
_gameplayUi = gameplayUi;
_devTools = devTools;
_screenshots = screenshots;
@ -68,7 +68,7 @@ internal sealed class PrivatePresentationRenderer : IPrivatePresentationFramePha
bool portalViewportVisible =
_foundation.Foundation.PortalViewportVisible;
_portal.Draw(input.ViewportWidth, input.ViewportHeight);
_paperdoll?.Render();
_entityViewports?.Render();
_gameplayUi?.Render(
input.DeltaSeconds,
input.ViewportWidth,
@ -86,6 +86,30 @@ internal sealed class PrivatePresentationRenderer : IPrivatePresentationFramePha
}
}
/// <summary>
/// Ordered private creature-view presentation. Retail renders each visible
/// <c>CreatureMode</c> before the retained 2-D tree that displays its texture.
/// </summary>
internal sealed class PrivateEntityViewportFrameGroup :
IPrivateEntityViewportFrame
{
private readonly IPrivateEntityViewportFrame[] _frames;
public PrivateEntityViewportFrameGroup(
params IPrivateEntityViewportFrame?[] frames)
{
_frames = frames.Where(static frame => frame is not null)
.Cast<IPrivateEntityViewportFrame>()
.ToArray();
}
public void Render()
{
foreach (IPrivateEntityViewportFrame frame in _frames)
frame.Render();
}
}
/// <summary>Typed portal viewport adapter over the canonical teleport owner.</summary>
internal sealed class LocalPlayerPortalViewport : IPrivatePortalViewport
{

View file

@ -27,6 +27,10 @@ public sealed class AppraisalUiController : IRetainedPanelController
public const uint SignatureTextId = 0x1000013Fu;
public const uint InscriptionScrollbarId = 0x1000046Eu;
public const uint CreaturePanelId = 0x10000140u;
public const uint CreatureViewportId = 0x10000148u;
public const uint CreatureStatsListId = 0x10000149u;
public const uint CreatureDisplayNameId = 0x1000014Eu;
public const uint CreatureLevelValueId = 0x1000014Cu;
public const uint SpellPanelId = 0x10000153u;
private const uint TemplateStringProperty = 5u;
@ -53,6 +57,9 @@ public sealed class AppraisalUiController : IRetainedPanelController
private readonly UiText? _signature;
private readonly UiDatElement? _inscriptionBackground;
private readonly UiButton? _close;
private readonly UiItemList? _creatureStats;
private readonly CreatureAppraisalRowTemplateFactory? _creatureRowTemplates;
private readonly CreatureDisplayNameResolver _creatureNames;
private AppraisalView _activeView;
private uint _itemObjectId;
private uint _creatureObjectId;
@ -81,7 +88,9 @@ public sealed class AppraisalUiController : IRetainedPanelController
UiElement itemPanel,
UiElement creaturePanel,
UiText title,
UiText itemText)
UiText itemText,
CreatureAppraisalRowTemplateFactory? creatureRowTemplates,
CreatureDisplayNameResolver? creatureNames)
{
_layout = layout;
_objects = objects;
@ -97,6 +106,10 @@ public sealed class AppraisalUiController : IRetainedPanelController
_spellPanel = layout.FindElement(SpellPanelId);
_title = title;
_itemText = itemText;
_creatureRowTemplates = creatureRowTemplates;
_creatureNames = creatureNames
?? new CreatureDisplayNameResolver(
new Dictionary<uint, string>());
_inscriptionText = layout.FindElement(InscriptionTextId) as UiText;
_inscriptionField = layout.FindElement(InscriptionTextId) as UiField;
_signature = layout.FindElement(SignatureTextId) as UiText;
@ -136,6 +149,25 @@ public sealed class AppraisalUiController : IRetainedPanelController
_inscriptionBackground.ClickThrough = false;
}
if (creatureRowTemplates is not null
&& layout.FindElement(CreatureStatsListId) is { } statsHost)
{
_creatureStats = statsHost as UiItemList
?? new UiItemList
{
Width = statsHost.Width,
Height = statsHost.Height,
Anchors = AnchorEdges.Left | AnchorEdges.Top
| AnchorEdges.Right | AnchorEdges.Bottom,
};
if (!ReferenceEquals(_creatureStats, statsHost))
statsHost.AddChild(_creatureStats);
_creatureStats.Flush();
_creatureStats.Columns = 1;
_creatureStats.CellWidth = creatureRowTemplates.Width;
_creatureStats.CellHeight = creatureRowTemplates.Height;
}
SetActiveView(AppraisalView.Item);
}
@ -152,7 +184,9 @@ public sealed class AppraisalUiController : IRetainedPanelController
Action<uint, string> sendSetInscription,
Action<string> systemMessage,
Action show,
Action close)
Action close,
CreatureAppraisalRowTemplateFactory? creatureRowTemplates = null,
CreatureDisplayNameResolver? creatureNames = null)
{
ArgumentNullException.ThrowIfNull(layout);
ArgumentNullException.ThrowIfNull(objects);
@ -185,7 +219,9 @@ public sealed class AppraisalUiController : IRetainedPanelController
itemPanel,
creaturePanel,
title,
itemText);
itemText,
creatureRowTemplates,
creatureNames);
}
/// <summary>
@ -452,17 +488,12 @@ public sealed class AppraisalUiController : IRetainedPanelController
{
ClearCreatureText();
PropertyBundle p = appraisal.Properties;
SetText(0x1000014Au, character
? GetString(p, 4u)
: AppraisalTextFormatter.CreatureTypeName(GetInt(p, 2u)));
SetText(0x1000014Bu, GetString(p, 2u));
SetText(0x1000014Cu, GetString(p, 14u));
string report = AppraisalTextFormatter.BuildCreature(
obj,
appraisal,
character);
SetText(0x1000014Eu, report, scrollable: true);
int level = GetInt(p, 25u);
SetText(
CreatureLevelValueId,
level > 0
? level.ToString(CultureInfo.CurrentCulture)
: "???");
if (character)
{
@ -470,6 +501,14 @@ public sealed class AppraisalUiController : IRetainedPanelController
SetText(0x10000151u, GetString(p, 47u));
SetText(0x10000152u, GetString(p, 11u));
}
else
{
SetText(
CreatureDisplayNameId,
_creatureNames.Resolve(GetInt(p, 2u)));
}
RebuildCreatureStats(appraisal);
SetText(0x1000053Au, appraisal.Success
? string.Empty
@ -478,6 +517,25 @@ public sealed class AppraisalUiController : IRetainedPanelController
ResetCreatureScroll();
}
private void RebuildCreatureStats(AppraiseInfoParser.Parsed appraisal)
{
if (_creatureStats is null || _creatureRowTemplates is null)
return;
using (_creatureStats.DeferLayout())
{
_creatureStats.Flush();
if (appraisal.CreatureProfile is not { } profile)
return;
foreach (CreatureAppraisalRow row in CreatureAppraisalRows.Build(
profile,
appraisal.Success))
{
_creatureStats.AddItem(_creatureRowTemplates.Create(row));
}
}
}
private void ConfigureScrollableText(
UiText text,
uint scrollbarId,
@ -513,15 +571,17 @@ public sealed class AppraisalUiController : IRetainedPanelController
{
foreach (uint id in new uint[]
{
0x1000014Au, 0x1000014Bu, 0x1000014Cu, 0x1000014Eu,
CreatureLevelValueId, CreatureDisplayNameId,
0x10000150u, 0x10000151u, 0x10000152u, 0x1000053Au,
})
if (_layout.FindElement(id) is UiText text)
text.LinesProvider = static () => Array.Empty<UiText.Line>();
_creatureStats?.Flush();
}
private void ResetCreatureScroll()
{
_creatureStats?.Scroll.SetScrollY(0);
foreach (UiText text in Descendants(_creaturePanel).OfType<UiText>())
text.Scroll.SetScrollY(0);
}

View file

@ -0,0 +1,304 @@
using System.Globalization;
using System.Numerics;
using AcDream.Content;
using AcDream.Core.Net.Messages;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Types;
namespace AcDream.App.UI.Layout;
public enum CreatureAppraisalValueStyle
{
Normal,
Positive,
Negative,
Incomplete,
}
public readonly record struct CreatureAppraisalRow(
string Label,
string Value,
CreatureAppraisalValueStyle Style);
/// <summary>
/// Pure projection of retail's six <c>AttributeInfoRegion</c> and three
/// <c>Attribute2ndInfoRegion</c> tokens. Ordering and formatting come from
/// <c>BasicCreatureExamineUI</c> and the two Update overloads at
/// 0x004F1D90/0x004F1E80.
/// </summary>
public static class CreatureAppraisalRows
{
private const string Unknown = "???";
public static IReadOnlyList<CreatureAppraisalRow> Build(
AppraiseInfoParser.CreatureProfile profile,
bool success)
{
return
[
Primary("Strength", profile.Strength, 0, profile, success),
Primary("Endurance", profile.Endurance, 1, profile, success),
Primary("Coordination", profile.Coordination, 3, profile, success),
Primary("Quickness", profile.Quickness, 2, profile, success),
Primary("Focus", profile.Focus, 4, profile, success),
Primary("Self", profile.Self, 5, profile, success),
Secondary(
"Health",
profile.Health,
profile.HealthMax,
showPercent: true,
enchantmentBit: 6,
profile,
success),
Secondary(
"Stamina",
profile.Stamina,
profile.StaminaMax,
showPercent: false,
enchantmentBit: 7,
profile,
success),
Secondary(
"Mana",
profile.Mana,
profile.ManaMax,
showPercent: false,
enchantmentBit: 8,
profile,
success),
];
}
private static CreatureAppraisalRow Primary(
string label,
uint? value,
int enchantmentBit,
AppraiseInfoParser.CreatureProfile profile,
bool success)
=> new(
label,
value is > 0
? value.Value.ToString(CultureInfo.CurrentCulture)
: Unknown,
Style(enchantmentBit, profile, success));
private static CreatureAppraisalRow Secondary(
string label,
uint? current,
uint? maximum,
bool showPercent,
int enchantmentBit,
AppraiseInfoParser.CreatureProfile profile,
bool success)
{
string value = Unknown;
if (current is > 0 && maximum is > 0)
{
int percent = RoundedPercent(current.Value, maximum.Value);
if (success)
{
value = showPercent
? $"{current.Value.ToString(CultureInfo.CurrentCulture)}/{maximum.Value.ToString(CultureInfo.CurrentCulture)} ({percent.ToString(CultureInfo.CurrentCulture)} %)"
: $"{current.Value.ToString(CultureInfo.CurrentCulture)}/{maximum.Value.ToString(CultureInfo.CurrentCulture)}";
}
else if (showPercent)
{
value = $"{percent.ToString(CultureInfo.CurrentCulture)} %";
}
}
return new CreatureAppraisalRow(
label,
value,
Style(enchantmentBit, profile, success));
}
private static int RoundedPercent(uint numerator, uint denominator)
=> denominator == 0u
? 0
: (int)Math.Min(
int.MaxValue,
((100L * numerator) + denominator / 2L) / denominator);
private static CreatureAppraisalValueStyle Style(
int bit,
AppraiseInfoParser.CreatureProfile profile,
bool success)
{
if (!success)
return CreatureAppraisalValueStyle.Incomplete;
ushort highlight = profile.AttributeHighlights ?? 0;
if ((highlight & (1 << bit)) == 0)
return CreatureAppraisalValueStyle.Normal;
ushort color = profile.AttributeColors ?? 0;
return (color & (1 << bit)) != 0
? CreatureAppraisalValueStyle.Positive
: CreatureAppraisalValueStyle.Negative;
}
}
/// <summary>
/// Instantiates LayoutDesc 0x2100006B's InfoRegion token template
/// 0x10000166, matching <c>UIElement_ListBox::AddItemFromTemplateList</c>.
/// </summary>
public sealed class CreatureAppraisalRowTemplateFactory
{
public const uint TemplateId = 0x10000166u;
public const uint LabelId = 0x1000012Au;
public const uint ValueId = 0x1000012Bu;
private readonly ElementInfo _template;
private readonly Func<uint, (uint Texture, int Width, int Height)> _resolveSprite;
private readonly UiDatFont? _defaultFont;
private readonly IReadOnlyDictionary<uint, UiDatFont?> _fonts;
public CreatureAppraisalRowTemplateFactory(
ElementInfo template,
Func<uint, (uint Texture, int Width, int Height)> resolveSprite,
UiDatFont? defaultFont,
IReadOnlyDictionary<uint, UiDatFont?>? fonts = null)
{
_template = template ?? throw new ArgumentNullException(nameof(template));
_resolveSprite = resolveSprite ?? throw new ArgumentNullException(nameof(resolveSprite));
_defaultFont = defaultFont;
_fonts = fonts ?? new Dictionary<uint, UiDatFont?>();
}
public float Width => _template.Width;
public float Height => _template.Height;
public static CreatureAppraisalRowTemplateFactory? TryLoad(
IDatReaderWriter dats,
Func<uint, (uint Texture, int Width, int Height)> resolveSprite,
UiDatFont? defaultFont,
Func<uint, UiDatFont?>? resolveFont)
{
ElementInfo? template = LayoutImporter.ImportInfos(
dats,
AppraisalUiController.LayoutId,
TemplateId);
if (template is null
|| Find(template, LabelId) is null
|| Find(template, ValueId) is null
|| template.Width <= 0f
|| template.Height <= 0f)
{
return null;
}
var fonts = new Dictionary<uint, UiDatFont?>();
CaptureFonts(template, resolveFont, fonts);
return new CreatureAppraisalRowTemplateFactory(
template,
resolveSprite,
defaultFont,
fonts);
}
public UiTemplateListSlot Create(CreatureAppraisalRow row)
{
ImportedLayout content = LayoutImporter.Build(
_template,
_resolveSprite,
_defaultFont,
did => _fonts.TryGetValue(did, out UiDatFont? font)
? font
: _defaultFont);
UiText label = Required<UiText>(content, LabelId);
UiText value = Required<UiText>(content, ValueId);
label.LinesProvider = () =>
[new UiText.Line(row.Label, label.DefaultColor)];
value.LinesProvider = () =>
[new UiText.Line(row.Value, ResolveColor(row.Style, value.DefaultColor))];
return new UiTemplateListSlot(
content,
entryId: 0u,
content.Root is IUiDatStateful stateful
? stateful.ActiveRetailStateId
: UiStateInfo.DirectStateId,
selectedState: null);
}
private static Vector4 ResolveColor(
CreatureAppraisalValueStyle style,
Vector4 normal)
{
// Retail selects one of four authored FontInfo entries here. Keep the
// semantic state in the row model, but do not invent substitute RGB
// values while that FontInfo-list binding remains unported (AP-110).
_ = style;
return normal;
}
private static T Required<T>(ImportedLayout content, uint id)
where T : UiElement
=> content.FindElement(id) as T
?? throw new InvalidOperationException(
$"Retail creature appraisal template element 0x{id:X8} did not resolve to {typeof(T).Name}.");
private static ElementInfo? Find(ElementInfo root, uint id)
{
if (root.Id == id)
return root;
foreach (ElementInfo child in root.Children)
if (Find(child, id) is { } found)
return found;
return null;
}
private static void CaptureFonts(
ElementInfo info,
Func<uint, UiDatFont?>? resolveFont,
Dictionary<uint, UiDatFont?> fonts)
{
if (info.FontDid != 0u && !fonts.ContainsKey(info.FontDid))
fonts[info.FontDid] = resolveFont?.Invoke(info.FontDid);
foreach (ElementInfo child in info.Children)
CaptureFonts(child, resolveFont, fonts);
}
}
/// <summary>
/// Retail <c>AppraisalSystem::InqCreatureDisplayName @ 0x005B59E0</c> resolves
/// creature enum 0x10000005 through portal EnumMapper 0x2200000E and replaces
/// underscores with spaces.
/// </summary>
public sealed class CreatureDisplayNameResolver
{
public const uint MapperDid = 0x2200000Eu;
private readonly IReadOnlyDictionary<uint, string> _names;
public CreatureDisplayNameResolver(
IReadOnlyDictionary<uint, string> names)
{
_names = names ?? throw new ArgumentNullException(nameof(names));
}
public static CreatureDisplayNameResolver Load(IDatReaderWriter dats)
{
ArgumentNullException.ThrowIfNull(dats);
var names = new Dictionary<uint, string>();
var visited = new HashSet<uint>();
uint did = MapperDid;
while (did != 0u && visited.Add(did))
{
EnumMapper? mapper = dats.Get<EnumMapper>(did);
if (mapper is null)
break;
foreach ((uint id, PStringBase<byte> text) in mapper.IdToStringMap)
names.TryAdd(id, text.Value.Replace('_', ' '));
did = mapper.BaseEnumMap;
}
return new CreatureDisplayNameResolver(names);
}
public string Resolve(int creatureType)
=> creatureType > 0
&& _names.TryGetValue((uint)creatureType, out string? name)
? name
: string.Empty;
}

View file

@ -301,6 +301,8 @@ public sealed class RetailUiRuntime : IDisposable
public SpellcastingUiController? SpellcastingUiController { get; private set; }
public SpellbookWindowController? SpellbookWindowController { get; private set; }
public AppraisalUiController? AppraisalController { get; private set; }
public UiViewport? CreatureAppraisalViewportWidget { get; private set; }
public UiElement? ExaminationFrame { get; private set; }
public EffectsUiController? PositiveEffectsController { get; private set; }
public EffectsUiController? NegativeEffectsController { get; private set; }
public LinkStatusUiController? LinkStatusUiController { get; private set; }
@ -961,9 +963,26 @@ public sealed class RetailUiRuntime : IDisposable
private void MountAppraisal()
{
ImportedLayout? layout = Import(
AppraisalUiController.LayoutId,
AppraisalUiController.RootId);
ImportedLayout? layout;
CreatureAppraisalRowTemplateFactory? creatureRows;
CreatureDisplayNameResolver? creatureNames;
lock (_bindings.Assets.DatLock)
{
layout = LayoutImporter.Import(
_bindings.Assets.Dats,
AppraisalUiController.LayoutId,
AppraisalUiController.RootId,
_bindings.Assets.ResolveSprite,
_bindings.Assets.DefaultFont,
_bindings.Assets.ResolveFont);
creatureRows = CreatureAppraisalRowTemplateFactory.TryLoad(
_bindings.Assets.Dats,
_bindings.Assets.ResolveSprite,
_bindings.Assets.DefaultFont,
_bindings.Assets.ResolveFont);
creatureNames = CreatureDisplayNameResolver.Load(
_bindings.Assets.Dats);
}
if (layout is null)
{
Console.WriteLine(
@ -981,7 +1000,9 @@ public sealed class RetailUiRuntime : IDisposable
_bindings.Appraisal.SendSetInscription,
_bindings.Appraisal.DisplaySystemMessage,
show: () => Host.ShowWindow(WindowNames.Examination),
close: () => CloseWindow(WindowNames.Examination));
close: () => CloseWindow(WindowNames.Examination),
creatureRowTemplates: creatureRows,
creatureNames: creatureNames);
if (controller is null)
{
Console.WriteLine(
@ -991,7 +1012,7 @@ public sealed class RetailUiRuntime : IDisposable
AppraisalController = controller;
UiElement root = layout.Root;
_ = RetailWindowFrame.Mount(
RetailWindowHandle handle = RetailWindowFrame.Mount(
Host.Root,
root,
_bindings.Assets.ResolveSprite,
@ -1013,6 +1034,10 @@ public sealed class RetailUiRuntime : IDisposable
ContentClickThrough = false,
Controller = controller,
});
CreatureAppraisalViewportWidget =
layout.FindElement(AppraisalUiController.CreatureViewportId)
as UiViewport;
ExaminationFrame = handle.OuterFrame;
Console.WriteLine(
"[M4] retail examination window from LayoutDesc 0x2100006B.");
}