Merge claude/hopeful-maxwell-214a12 — D.2b UI Studio + faithful importer + Character window
UI Studio (preview panels through the production renderer), importer dat-fidelity (Fix A/B/C/4/5: the importer carries dat font/justification/color; boundary look=importer / state=runtime), and the Character window Attributes tab (reads as retail). 3062 tests green. Handoff: docs/research/2026-06-26-mockup-stage-handoff.md. # Conflicts: # docs/ISSUES.md # docs/architecture/retail-divergence-register.md
This commit is contained in:
commit
ca94b479bf
61 changed files with 125881 additions and 44 deletions
|
|
@ -4,6 +4,14 @@ using AcDream.App.Rendering;
|
|||
using AcDream.Core.Plugins;
|
||||
using Serilog;
|
||||
|
||||
if (args.Length >= 1 && args[0] == "ui-studio")
|
||||
{
|
||||
var so = AcDream.App.Studio.StudioOptions.Parse(args[1..]);
|
||||
using var sw = new AcDream.App.Studio.StudioWindow(so);
|
||||
sw.Run();
|
||||
return 0;
|
||||
}
|
||||
|
||||
Log.Logger = new LoggerConfiguration()
|
||||
.MinimumLevel.Debug()
|
||||
.WriteTo.Console()
|
||||
|
|
|
|||
53
src/AcDream.App/Rendering/DollCamera.cs
Normal file
53
src/AcDream.App/Rendering/DollCamera.cs
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
using System;
|
||||
using System.Numerics;
|
||||
|
||||
namespace AcDream.App.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// Fixed camera for the paperdoll mini-scene — retail-exact, ported from the gmPaperDollUI viewport
|
||||
/// setup (decomp 0x004a5a39–0x004a5a69). The viewport (element <c>0x100001d5</c>) is configured by
|
||||
/// <c>UIElement_Viewport::SetCamera(position, direction)</c> with:
|
||||
/// <list type="bullet">
|
||||
/// <item>position = (0.12, −2.4, 0.88) [hex 0x3df5c28f, 0xc019999a, 0x3f6147ae]</item>
|
||||
/// <item>direction = (0, 0, 0) ⇒ <c>CreatureMode::SetCameraDirection</c> resets the view frame to
|
||||
/// IDENTITY (<c>euler_set_rotate(0,0,0)</c> then <c>rotate(0,0,0)</c>), so the camera looks
|
||||
/// straight down +Y with +Z up — NO yaw, NO pitch.</item>
|
||||
/// </list>
|
||||
/// The doll is framed purely by camera POSITION + FOV, NOT by aiming at the body: at distance 2.4 with
|
||||
/// a π/4 (45°) vertical FOV the visible vertical band is z≈[−0.11, 1.87], which covers the whole ~1.6 m
|
||||
/// figure even though the model origin sits at the FEET (z=0). FOV π/4 is <c>CreatureMode</c>'s default
|
||||
/// <c>m_fFOVRadians</c> (ctor 0x004543cf, hex 0x3f490fdb); default ambient is (0.3,0.3,0.3); the
|
||||
/// paperdoll uses <c>UseSharpMode</c> (not SmartboxFOV) so <c>Render::SetFOVRad(m_fFOVRadians)</c> applies.
|
||||
///
|
||||
/// <para>An earlier hand-tune aimed the camera at mid-body to "fit the figure", which introduced a
|
||||
/// spurious yaw (Target.x ≠ Eye.x) that rotated the view and turned the doll's face away from the
|
||||
/// viewer. This restores retail's zero-yaw frame, where full-body framing comes from the eye height +
|
||||
/// FOV, not from aiming.</para>
|
||||
///
|
||||
/// Uses the same <see cref="Matrix4x4.CreateLookAt"/> / <see cref="Matrix4x4.CreatePerspectiveFieldOfView"/>
|
||||
/// convention as <see cref="ChaseCamera"/> so the doll's triangle winding + back-face culling match the
|
||||
/// world render pass. AC up-axis = +Z.
|
||||
/// </summary>
|
||||
public sealed class DollCamera : ICamera
|
||||
{
|
||||
// Retail paperdoll camera origin (decomp 0x004a5a51–0x004a5a61).
|
||||
private static readonly Vector3 Eye = 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
|
||||
|
||||
/// <summary>Vertical field of view — retail <c>CreatureMode</c> default <c>m_fFOVRadians</c> = π/4 (45°).</summary>
|
||||
public float FovRadians { get; set; } = MathF.PI / 4f;
|
||||
|
||||
public float Near { get; set; } = 0.1f; // same near plane as ChaseCamera / retail znear
|
||||
public float Far { get; set; } = 50f; // doll scene is small; 50 m is ample
|
||||
public float Aspect { get; set; } = 1f;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Matrix4x4 View =>
|
||||
Matrix4x4.CreateLookAt(Eye, Target, Up);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Matrix4x4 Projection =>
|
||||
Matrix4x4.CreatePerspectiveFieldOfView(FovRadians, Aspect <= 0f ? 1f : Aspect, Near, Far);
|
||||
}
|
||||
133
src/AcDream.App/Rendering/DollEntityBuilder.cs
Normal file
133
src/AcDream.App/Rendering/DollEntityBuilder.cs
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.World;
|
||||
|
||||
namespace AcDream.App.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the dedicated paperdoll WorldEntity — retail's <c>makeObject(player)</c>
|
||||
/// clone: the player's Setup id + current ObjDesc (base palette + subpalette overlays
|
||||
/// + part overrides), posed at the scene origin facing the viewer.
|
||||
///
|
||||
/// <para>
|
||||
/// The palette / part-override mapping mirrors the inline construction in
|
||||
/// <c>GameWindow.cs</c> around lines 3390–3431. Extracted here so it is
|
||||
/// unit-testable without dats and so the paperdoll renderer owns a clean
|
||||
/// seam: it calls <see cref="Build"/> with fresh player state each time the
|
||||
/// ObjDesc changes, and the renderer only has to swap the entity into its
|
||||
/// dedicated mini-scene.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// The <see cref="DollServerGuid"/> is a reserved synthetic guid that is:
|
||||
/// (a) non-zero (so <c>EntitySpawnAdapter.OnCreate</c>'s <c>ServerGuid != 0</c>
|
||||
/// guard accepts it), and (b) high enough that it never collides with a real
|
||||
/// server-assigned guid.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class DollEntityBuilder
|
||||
{
|
||||
/// <summary>
|
||||
/// Reserved synthetic guid for the paperdoll clone. High, deliberately
|
||||
/// outside the server's assignable range, and non-zero.
|
||||
/// </summary>
|
||||
public const uint DollServerGuid = 0xDA11_D011u;
|
||||
|
||||
/// <summary>
|
||||
/// Reserved render-local entity id for the doll. FIXED (the doll is a singleton)
|
||||
/// and high enough never to collide with <c>_liveEntityIdCounter</c> ids. The
|
||||
/// renderer passes this in <c>animatedEntityIds</c> so the dispatcher treats the
|
||||
/// doll as animated — which BYPASSES the Tier-1 classification cache
|
||||
/// (WbDrawDispatcher.cs:1142). That matters because a re-dress builds a NEW
|
||||
/// WorldEntity with this SAME id; without the cache bypass the dispatcher would
|
||||
/// serve the previous doll's cached batches and the new gear wouldn't appear.
|
||||
/// </summary>
|
||||
public const uint DollRenderId = 0xDA11_D012u;
|
||||
|
||||
// retail RedressCreature: CPhysicsObj::set_heading(191.367905°) (decomp 0x004a3c0a). Frame::set_heading(h)
|
||||
// (decomp 0x00535e40) builds the facing vector as (sin h°, cos h°, 0): at h=0 the creature faces +Y (AC
|
||||
// North), the heading increasing CLOCKWISE toward +X. We rotate the doll's default +Y-forward body about
|
||||
// +Z; System.Numerics rotates +Y → (−sin θ, cos θ), so to land on retail's (sin h, cos h) the angle is
|
||||
// NEGATED (θ = −h). Using +h mirrors the X-lean (~22° off → the face reads as turned away from the viewer).
|
||||
private const float _headingDegrees = 191.367905f;
|
||||
private static readonly float _headingRad = -_headingDegrees * (MathF.PI / 180f);
|
||||
private static readonly Quaternion _dollRotation =
|
||||
Quaternion.CreateFromAxisAngle(new Vector3(0f, 0f, 1f), _headingRad);
|
||||
|
||||
/// <summary>
|
||||
/// Builds a synthetic <see cref="WorldEntity"/> for the paperdoll mini-scene.
|
||||
/// </summary>
|
||||
/// <param name="setupId">The player's Setup dat id (0x02xxxxxx).</param>
|
||||
/// <param name="meshRefs">Pre-resolved mesh refs (may be empty; caller fills them in).</param>
|
||||
/// <param name="basePaletteId">
|
||||
/// ObjDesc base palette id (0x04xxxxxx). Passed only when subpalettes are
|
||||
/// also present — mirrors GameWindow which only builds a PaletteOverride
|
||||
/// when <c>SubPalettes.Count > 0</c>.
|
||||
/// </param>
|
||||
/// <param name="subPalettes">
|
||||
/// Subpalette overlays from the server ObjDesc. Each tuple carries the
|
||||
/// subpalette dat id, byte offset into the base palette, and byte length.
|
||||
/// Null or empty → <c>PaletteOverride</c> on the returned entity is null.
|
||||
/// </param>
|
||||
/// <param name="partOverrides">
|
||||
/// AnimPartChange swaps from the server ObjDesc. Each tuple is a
|
||||
/// (PartIndex, replacement GfxObj id) pair. Null or empty → empty array.
|
||||
/// </param>
|
||||
public static WorldEntity Build(
|
||||
uint setupId,
|
||||
IReadOnlyList<MeshRef> meshRefs,
|
||||
uint? basePaletteId = null,
|
||||
IReadOnlyList<(uint SubPaletteId, byte Offset, byte Length)>? subPalettes = null,
|
||||
IReadOnlyList<(byte PartIndex, uint GfxObjId)>? partOverrides = null)
|
||||
{
|
||||
// --- palette override (mirrors GameWindow:3395-3405) ---
|
||||
// Only build when there are sub-palette overlays — same gate as GameWindow.
|
||||
PaletteOverride? paletteOverride = null;
|
||||
if (subPalettes is { Count: > 0 } spList)
|
||||
{
|
||||
var ranges = new PaletteOverride.SubPaletteRange[spList.Count];
|
||||
for (int i = 0; i < spList.Count; i++)
|
||||
ranges[i] = new PaletteOverride.SubPaletteRange(
|
||||
spList[i].SubPaletteId,
|
||||
spList[i].Offset,
|
||||
spList[i].Length);
|
||||
paletteOverride = new PaletteOverride(
|
||||
BasePaletteId: basePaletteId ?? 0u,
|
||||
SubPalettes: ranges);
|
||||
}
|
||||
|
||||
// --- part overrides (mirrors GameWindow:3407-3418) ---
|
||||
PartOverride[] entityPartOverrides;
|
||||
if (partOverrides is null or { Count: 0 })
|
||||
{
|
||||
entityPartOverrides = Array.Empty<PartOverride>();
|
||||
}
|
||||
else
|
||||
{
|
||||
entityPartOverrides = new PartOverride[partOverrides.Count];
|
||||
for (int i = 0; i < partOverrides.Count; i++)
|
||||
entityPartOverrides[i] = new PartOverride(
|
||||
partOverrides[i].PartIndex,
|
||||
partOverrides[i].GfxObjId);
|
||||
}
|
||||
|
||||
// --- assemble entity (mirrors GameWindow:3420-3431) ---
|
||||
// Id=0: the paperdoll renderer assigns its own render-local id when it
|
||||
// registers this entity with the mini-scene GpuWorldState. We set zero
|
||||
// here so the builder stays pure (no static counter). The caller may
|
||||
// replace it before registration.
|
||||
return new WorldEntity
|
||||
{
|
||||
Id = DollRenderId,
|
||||
ServerGuid = DollServerGuid,
|
||||
SourceGfxObjOrSetupId = setupId,
|
||||
Position = Vector3.Zero,
|
||||
Rotation = _dollRotation,
|
||||
MeshRefs = meshRefs,
|
||||
PaletteOverride = paletteOverride,
|
||||
PartOverrides = entityPartOverrides,
|
||||
ParentCellId = null, // paperdoll mini-scene has no parent cell
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -667,6 +667,12 @@ public sealed class GameWindow : IDisposable
|
|||
private AcDream.App.UI.Layout.InventoryController? _inventoryController;
|
||||
// Phase D.2b Sub-phase C — paperdoll equip-slot controller (wield drag handler).
|
||||
private AcDream.App.UI.Layout.PaperdollController? _paperdollController;
|
||||
// Phase D.2b Sub-phase C Slice 2 — the 3-D doll viewport: an off-screen RTT renderer, the UiViewport
|
||||
// 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.UI.UiViewport? _paperdollViewportWidget;
|
||||
private AcDream.App.UI.UiNineSlicePanel? _inventoryFrame;
|
||||
private bool _paperdollDollDirty = true;
|
||||
// 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. Lives for as long as
|
||||
|
|
@ -2296,7 +2302,15 @@ public sealed class GameWindow : IDisposable
|
|||
sendWield: (item, mask) => _liveSession?.SendGetAndWieldItem(item, mask),
|
||||
// Empty equip slots show a visible frame (same square as the inventory grid) so every
|
||||
// slot position is seen + usable; the live 3D character (the doll) is Slice 2.
|
||||
emptySlotSprite: contentsEmpty);
|
||||
emptySlotSprite: contentsEmpty,
|
||||
datFont: vitalsDatFont); // Slice 2: caption the "Slots" toggle button
|
||||
|
||||
// Slice 2: capture the doll viewport widget (Type 0xD, element 0x100001D5) + the inventory
|
||||
// frame so the per-frame pre-UI hook can render the 3-D doll into the widget's texture only
|
||||
// when the window is open and in doll-view. The renderer itself is built after the
|
||||
// WbDrawDispatcher exists (further down this method).
|
||||
_paperdollViewportWidget = invLayout.FindElement(0x100001D5u) as AcDream.App.UI.UiViewport;
|
||||
_inventoryFrame = inventoryFrame;
|
||||
|
||||
Console.WriteLine("[D.2b-B] retail inventory window from LayoutDesc importer (0x21000023).");
|
||||
}
|
||||
|
|
@ -2407,6 +2421,16 @@ public sealed class GameWindow : IDisposable
|
|||
// A.5 T22.5: apply A2C gate from quality preset.
|
||||
_wbDrawDispatcher.AlphaToCoverage = _resolvedQuality.AlphaToCoverage;
|
||||
|
||||
// Slice 2: now that the dispatcher exists, build the paperdoll doll RTT renderer and hand it to
|
||||
// the viewport widget. Reuses the dispatcher + the scene-lighting UBO + _gl. The widget only
|
||||
// blits the texture; the pre-UI hook drives the 3-D pass (see OnRender).
|
||||
if (_paperdollViewportWidget is not null && _sceneLightingUbo is not null)
|
||||
{
|
||||
_paperdollViewportRenderer = new AcDream.App.Rendering.PaperdollViewportRenderer(
|
||||
_gl, _wbDrawDispatcher, _sceneLightingUbo);
|
||||
_paperdollViewportWidget.Renderer = _paperdollViewportRenderer;
|
||||
}
|
||||
|
||||
// Phase A8: EnvCellRenderer init. Shares _meshShader (mesh_modern.{vert,frag})
|
||||
// with the dispatcher — both consume the same global mesh buffer (VAO/IBO)
|
||||
// from ObjectMeshManager.GlobalBuffer.
|
||||
|
|
@ -3898,6 +3922,121 @@ public sealed class GameWindow : IDisposable
|
|||
BasePaletteId = md.BasePaletteId,
|
||||
};
|
||||
OnLiveEntitySpawned(newSpawn);
|
||||
|
||||
// Slice 2: a player appearance change (equip / unequip) rebuilt _entitiesByServerGuid[player]
|
||||
// above; flag the paperdoll doll to re-clone from it on the next doll pass (the C# analog of
|
||||
// RedressCreature). Cheap flag — the rebuild is deferred to the pre-UI hook when visible.
|
||||
if (update.Guid == _playerServerGuid)
|
||||
_paperdollDollDirty = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rebuilds the paperdoll doll from the live player entity (retail makeObject(player) +
|
||||
/// DoObjDescChangesFromDefault). Clones the player's already-resolved Setup / MeshRefs / palette /
|
||||
/// part overrides into a dedicated <see cref="DollEntityBuilder"/> entity posed at the scene origin
|
||||
/// facing the viewer. The MeshRefs are COPIED so the doll holds a stable pose independent of the
|
||||
/// player's live in-world animation. Returns false (leaving the dirty flag set to retry) when the
|
||||
/// player entity isn't available yet.
|
||||
/// </summary>
|
||||
private bool RefreshPaperdollDoll()
|
||||
{
|
||||
if (_paperdollViewportRenderer is null) return false;
|
||||
if (!_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var pe) || pe.MeshRefs.Count == 0)
|
||||
{
|
||||
_paperdollViewportRenderer.SetDoll(null);
|
||||
return false; // player not ready — retry next frame
|
||||
}
|
||||
|
||||
uint? basePal = null;
|
||||
List<(uint, byte, byte)>? subs = null;
|
||||
if (pe.PaletteOverride is { } po)
|
||||
{
|
||||
basePal = po.BasePaletteId;
|
||||
subs = new List<(uint, byte, byte)>();
|
||||
foreach (var r in po.SubPalettes) subs.Add((r.SubPaletteId, r.Offset, r.Length));
|
||||
}
|
||||
|
||||
List<(byte, uint)>? parts = null;
|
||||
if (pe.PartOverrides.Count > 0)
|
||||
{
|
||||
parts = new List<(byte, uint)>(pe.PartOverrides.Count);
|
||||
foreach (var p in pe.PartOverrides) parts.Add((p.PartIndex, p.GfxObjId));
|
||||
}
|
||||
|
||||
var meshRefsCopy = new List<AcDream.Core.World.MeshRef>(pe.MeshRefs); // dressed parts (player's live frame)
|
||||
var doll = AcDream.App.Rendering.DollEntityBuilder.Build(
|
||||
pe.SourceGfxObjOrSetupId, meshRefsCopy, basePal, subs, parts);
|
||||
ApplyPaperdollPose(doll, pe.SourceGfxObjOrSetupId); // re-pose into the paperdoll "posing" stance
|
||||
_paperdollViewportRenderer.SetDoll(doll);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve the paperdoll "posing" stance DID (the model pose — left leg back) exactly as retail:
|
||||
/// <c>gmPaperDollUI</c> ctor (decomp 174243) sets <c>m_didAnimation = DBObj::GetDIDByEnum(0x10000005, 7)</c>,
|
||||
/// which is <c>DBCache::GetDIDFromEnumStatic</c> (decomp 20380) = <c>master[MasterMapId][0x10000005]</c>
|
||||
/// → submap <c>0x25000009</c> → key <c>7</c>. (Icon effects use keys 1-6 of the same submap; key 7 is the
|
||||
/// paperdoll pose.) Per-race <c>UpdateForRace</c> override deferred — the ctor default applies to all
|
||||
/// body-types for now. Returns 0 if the chain can't resolve.
|
||||
/// </summary>
|
||||
private uint ResolvePaperdollPoseDid()
|
||||
{
|
||||
var dats = _dats;
|
||||
if (dats is null) return 0u;
|
||||
uint masterDid = (uint)dats.Portal.Header.MasterMapId;
|
||||
if (masterDid == 0) return 0u;
|
||||
if (!dats.Portal.TryGet<DatReaderWriter.DBObjs.EnumIDMap>(masterDid, out var master)) return 0u;
|
||||
// DBCache::GetDIDFromEnum (decomp 20380) resolves master[arg4] → submap, then submap[arg3].
|
||||
// GetDIDFromEnumStatic(0x10000005, 7) ⇒ arg3=0x10000005, arg4=7 ⇒ master[7] → submap, submap[0x10000005].
|
||||
if (!master.ClientEnumToID.TryGetValue(7u, out var subDid)) return 0u; // master-level key = 7
|
||||
if (!dats.Portal.TryGet<DatReaderWriter.DBObjs.EnumIDMap>(subDid, out var sub)) return 0u;
|
||||
return sub.ClientEnumToID.TryGetValue(0x10000005u, out var did) ? did : 0u; // submap index = 0x10000005
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Re-pose the doll's already-dressed parts into the paperdoll "posing" stance — the C# analog of
|
||||
/// retail <c>set_sequence_animation(doll, m_didAnimation, …)</c>. Keeps each cloned part's GfxObjId +
|
||||
/// surface overrides (the dressed appearance), but replaces its transform with the pose animation's
|
||||
/// frame-0 per-part transform, using the same Scale·Rotate·Translate composition as the per-frame
|
||||
/// animation tick (<see cref="TickAnimations"/>, GameWindow.cs ~9999). Static (the pose is fixed). If the
|
||||
/// DID does not resolve to an <c>Animation</c>, the doll keeps its cloned pose (no regression) — the log
|
||||
/// line surfaces what resolved so the pose is verified, not guessed.
|
||||
/// </summary>
|
||||
private void ApplyPaperdollPose(AcDream.Core.World.WorldEntity doll, uint setupId)
|
||||
{
|
||||
var dats = _dats;
|
||||
if (dats is null) return;
|
||||
// Retail's paperdoll doll is STATIC — it holds the pose animation's frame, it does NOT loop.
|
||||
_animatedEntities.Remove(doll.Id);
|
||||
// poseDID is cdb-CONFIRMED = 0x030003C0 (== retail gmPaperDollUI m_didAnimation for Horan; UpdateForRace
|
||||
// did not override). Crash guard: only load a 0x03xxxxxx (Animation) DID (a non-animation id parses a
|
||||
// garbage frame count → OOM).
|
||||
uint poseDid = ResolvePaperdollPoseDid();
|
||||
if ((poseDid >> 24) != 0x03u) return;
|
||||
var anim = dats.Get<DatReaderWriter.DBObjs.Animation>(poseDid);
|
||||
var setup = dats.Get<DatReaderWriter.DBObjs.Setup>(setupId);
|
||||
if (anim is null || setup is null || anim.PartFrames.Count == 0) return;
|
||||
|
||||
// Retail plays the pose animation once and HOLDS the last frame (set_sequence_animation framerate=0,
|
||||
// RedressCreature decomp 0x004a3c22). For 0x030003C0 the animation has only two distinct keyframes —
|
||||
// frame 0 (a transitional, raised/bent arm) and frames 1..N (all byte-identical: the settled stance,
|
||||
// arms down + one leg back) — verified by dumping the dat. The held pose is therefore the last frame.
|
||||
int frameIdx = anim.PartFrames.Count - 1;
|
||||
var frame = anim.PartFrames[frameIdx];
|
||||
var src = doll.MeshRefs;
|
||||
var reposed = new List<AcDream.Core.World.MeshRef>(src.Count);
|
||||
for (int i = 0; i < src.Count; i++)
|
||||
{
|
||||
var scale = i < setup.DefaultScale.Count ? setup.DefaultScale[i] : System.Numerics.Vector3.One;
|
||||
System.Numerics.Vector3 origin = System.Numerics.Vector3.Zero;
|
||||
System.Numerics.Quaternion orient = System.Numerics.Quaternion.Identity;
|
||||
if (i < frame.Frames.Count) { origin = frame.Frames[i].Origin; orient = frame.Frames[i].Orientation; }
|
||||
var transform = System.Numerics.Matrix4x4.CreateScale(scale)
|
||||
* System.Numerics.Matrix4x4.CreateFromQuaternion(orient)
|
||||
* System.Numerics.Matrix4x4.CreateTranslation(origin);
|
||||
reposed.Add(new AcDream.Core.World.MeshRef(src[i].GfxObjId, transform) { SurfaceOverrides = src[i].SurfaceOverrides });
|
||||
}
|
||||
doll.MeshRefs = reposed;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -9329,6 +9468,20 @@ public sealed class GameWindow : IDisposable
|
|||
SkipWorldGeometry: ;
|
||||
}
|
||||
|
||||
// Phase D.2b Sub-phase C Slice 2 — paperdoll 3-D doll: render the re-dressed player clone into the
|
||||
// viewport's off-screen texture BEFORE the 2-D UI pass blits it, but only when the inventory window
|
||||
// is open AND the paperdoll is in doll-view (the widget's Visible is driven by the Slots toggle).
|
||||
// The 3-D pass is fully sealed in a GLStateScope so it doesn't disturb the world/UI state.
|
||||
if (_options.RetailUi && _paperdollViewportRenderer is not null
|
||||
&& _paperdollViewportWidget is { Visible: true } dollWidget
|
||||
&& _inventoryFrame is { Visible: true })
|
||||
{
|
||||
if (_paperdollDollDirty && RefreshPaperdollDoll())
|
||||
_paperdollDollDirty = false;
|
||||
dollWidget.TextureHandle = _paperdollViewportRenderer.Render(
|
||||
(int)dollWidget.Width, (int)dollWidget.Height);
|
||||
}
|
||||
|
||||
// Phase D.2b — retail-look UI tree (render-only; input integration deferred).
|
||||
// Self-contained 2D pass: UiHost.Draw → TextRenderer.Flush sets its own
|
||||
// blend/depth state and restores. Drawn before ImGui so the devtools
|
||||
|
|
@ -13469,6 +13622,7 @@ public sealed class GameWindow : IDisposable
|
|||
_terrain?.Dispose();
|
||||
_terrainModernShader?.Dispose();
|
||||
_sceneLightingUbo?.Dispose();
|
||||
_paperdollViewportRenderer?.Dispose(); // Slice 2: the doll's off-screen FBO + textures
|
||||
_particleRenderer?.Dispose();
|
||||
_debugLines?.Dispose();
|
||||
_uiHost?.Dispose();
|
||||
|
|
|
|||
193
src/AcDream.App/Rendering/PaperdollViewportRenderer.cs
Normal file
193
src/AcDream.App/Rendering/PaperdollViewportRenderer.cs
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
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>
|
||||
/// 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 GameWindow) 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 (GameWindow), 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 pose
|
||||
/// for now; idle animation (TickAnimations rebuilding the doll's MeshRefs) is a later slice.</para>
|
||||
/// </summary>
|
||||
public sealed unsafe class PaperdollViewportRenderer : IUiViewportRenderer, IDisposable
|
||||
{
|
||||
private readonly GL _gl;
|
||||
private readonly WbDrawDispatcher _dispatcher;
|
||||
private readonly SceneLightingUboBinding _lightUbo;
|
||||
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 doll WorldEntity (held by reference: when GameWindow's TickAnimations later rebuilds its
|
||||
// MeshRefs each frame, this renderer sees the updated pose automatically). 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 };
|
||||
|
||||
public PaperdollViewportRenderer(GL gl, WbDrawDispatcher dispatcher, SceneLightingUboBinding lightUbo)
|
||||
{
|
||||
_gl = gl ?? throw new ArgumentNullException(nameof(gl));
|
||||
_dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
|
||||
_lightUbo = lightUbo ?? throw new ArgumentNullException(nameof(lightUbo));
|
||||
}
|
||||
|
||||
/// <summary>Set (or clear) the doll entity. GameWindow builds/re-dresses it from the live player.</summary>
|
||||
public void SetDoll(WorldEntity? doll) => _doll = 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;
|
||||
|
||||
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() => DeleteFramebuffer();
|
||||
}
|
||||
231
src/AcDream.App/Rendering/RenderBootstrap.cs
Normal file
231
src/AcDream.App/Rendering/RenderBootstrap.cs
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
using System.Collections.Concurrent;
|
||||
using DatReaderWriter;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Silk.NET.OpenGL;
|
||||
|
||||
namespace AcDream.App.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// The subset of the production render stack that the UI Studio needs:
|
||||
/// GL + dats + UiHost + the WB mesh pipeline. Constructed from the same
|
||||
/// classes and the same order as <see cref="GameWindow.OnLoad"/>, minus
|
||||
/// terrain / sky / physics / streaming.
|
||||
/// </summary>
|
||||
public sealed record RenderStack(
|
||||
GL Gl,
|
||||
DatReaderWriter.DatCollection Dats,
|
||||
string ShaderDir,
|
||||
Wb.BindlessSupport Bindless,
|
||||
TextureCache TextureCache,
|
||||
Shader MeshShader,
|
||||
Wb.WbMeshAdapter MeshAdapter,
|
||||
Wb.EntitySpawnAdapter EntitySpawnAdapter,
|
||||
Wb.WbDrawDispatcher DrawDispatcher,
|
||||
SceneLightingUboBinding LightingUbo,
|
||||
AcDream.App.UI.UiHost UiHost,
|
||||
AcDream.App.UI.UiDatFont? VitalsDatFont,
|
||||
AcDream.App.UI.UiDatFont? LargeDatFont) : System.IDisposable
|
||||
{
|
||||
/// <summary>Dispose the GL pieces this stack OWNS (everything created in
|
||||
/// <see cref="RenderBootstrap.Create"/>). <see cref="Dats"/> + <see cref="Gl"/> are caller-owned
|
||||
/// and NOT disposed here. Called once at studio teardown.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
DrawDispatcher.Dispose();
|
||||
MeshAdapter.Dispose();
|
||||
TextureCache.Dispose();
|
||||
MeshShader.Dispose();
|
||||
LightingUbo.Dispose();
|
||||
UiHost.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a sprite id (0x06xxxxxx) to a (GL handle, width, height) triple.
|
||||
/// Copied verbatim from GameWindow's ResolveChrome closure — it calls
|
||||
/// TextureCache.GetOrUploadRenderSurface(id, out w, out h).
|
||||
/// </summary>
|
||||
public (uint handle, int width, int height) ResolveChrome(uint spriteId)
|
||||
{
|
||||
uint t = TextureCache.GetOrUploadRenderSurface(spriteId, out int w, out int h);
|
||||
return (t, w, h);
|
||||
}
|
||||
|
||||
// ── Font cache (per-stack, keyed by FontDid) ─────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Cache of loaded dat fonts keyed by FontDid (0x40000000-range).
|
||||
/// Populated lazily by <see cref="ResolveDatFont"/>. Thread-safe for
|
||||
/// concurrent reads from the studio render loop; writes happen only
|
||||
/// during the first load of each distinct FontDid.
|
||||
/// </summary>
|
||||
private readonly ConcurrentDictionary<uint, AcDream.App.UI.UiDatFont?> _fontCache = new();
|
||||
|
||||
/// <summary>
|
||||
/// Lazily load and cache a dat font by its FontDid. Returns null (and
|
||||
/// caches null) when the Font DBObj is absent or has no foreground surface —
|
||||
/// callers fall back to the global font in that case.
|
||||
///
|
||||
/// <para>Pre-seeds <see cref="VitalsDatFont"/> (0x40000000) and
|
||||
/// <see cref="LargeDatFont"/> (0x40000001) from the already-loaded instances
|
||||
/// to avoid a redundant upload on those two ids.</para>
|
||||
/// </summary>
|
||||
public AcDream.App.UI.UiDatFont? ResolveDatFont(uint fontDid)
|
||||
{
|
||||
return _fontCache.GetOrAdd(fontDid, id =>
|
||||
AcDream.App.UI.UiDatFont.Load(Dats, TextureCache, id));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pre-seeds the font cache from the two already-loaded font instances
|
||||
/// (VitalsDatFont = 0x40000000, LargeDatFont = 0x40000001) so that
|
||||
/// <see cref="ResolveDatFont"/> returns them without a redundant GL upload.
|
||||
/// Called once by <see cref="RenderBootstrap.Create"/> after the stack is
|
||||
/// fully constructed.
|
||||
/// </summary>
|
||||
internal void SeedFontCache()
|
||||
{
|
||||
if (VitalsDatFont is not null)
|
||||
_fontCache.TryAdd(AcDream.App.UI.UiDatFont.DefaultFontId, VitalsDatFont);
|
||||
if (LargeDatFont is not null)
|
||||
_fontCache.TryAdd(0x40000001u, LargeDatFont);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Options for <see cref="RenderBootstrap.Create"/>.</summary>
|
||||
public sealed record RenderBootstrapOptions(
|
||||
AcDream.UI.Abstractions.Settings.QualitySettings Quality);
|
||||
|
||||
/// <summary>
|
||||
/// Constructs the UI Studio's render stack from the production classes,
|
||||
/// in the same order as <see cref="GameWindow.OnLoad"/>.
|
||||
/// </summary>
|
||||
public static class RenderBootstrap
|
||||
{
|
||||
/// <summary>
|
||||
/// Build the studio's render stack. Throws <see cref="NotSupportedException"/>
|
||||
/// (same message as GameWindow) if GL_ARB_bindless_texture or
|
||||
/// GL_ARB_shader_draw_parameters are absent — the modern path is mandatory.
|
||||
/// </summary>
|
||||
public static RenderStack Create(
|
||||
GL gl,
|
||||
DatReaderWriter.DatCollection dats,
|
||||
RenderBootstrapOptions opts)
|
||||
{
|
||||
// --- Bindless detection (GameWindow ~1701-1723) ---
|
||||
if (!Wb.BindlessSupport.TryCreate(gl, out var bindless)
|
||||
|| bindless is null
|
||||
|| !bindless.HasShaderDrawParameters(gl))
|
||||
{
|
||||
throw new NotSupportedException(
|
||||
"acdream requires GL_ARB_bindless_texture + GL_ARB_shader_draw_parameters " +
|
||||
"(GL 4.3+ with bindless support). Your GPU/driver does not expose these extensions. " +
|
||||
"If this is unexpected, please file a bug report with your GPU vendor + driver version.");
|
||||
}
|
||||
|
||||
// --- Shared infra (GameWindow ~1198, ~1211) ---
|
||||
string shaderDir = Path.Combine(AppContext.BaseDirectory, "Rendering", "Shaders");
|
||||
var lightingUbo = new SceneLightingUboBinding(gl);
|
||||
|
||||
// --- Mesh shader (GameWindow ~1769-1771) ---
|
||||
var meshShader = new Shader(gl,
|
||||
Path.Combine(shaderDir, "mesh_modern.vert"),
|
||||
Path.Combine(shaderDir, "mesh_modern.frag"));
|
||||
|
||||
// --- TextureCache (GameWindow ~1774) ---
|
||||
var textureCache = new TextureCache(gl, dats, bindless);
|
||||
|
||||
// --- AnimLoader (GameWindow ~1240) ---
|
||||
var animLoader = new AcDream.Core.Physics.DatCollectionLoader(dats);
|
||||
|
||||
// --- WbMeshAdapter (GameWindow ~2286-2287) ---
|
||||
var wbLogger = NullLogger<Wb.WbMeshAdapter>.Instance;
|
||||
var meshAdapter = new Wb.WbMeshAdapter(gl, dats, wbLogger);
|
||||
|
||||
// --- SequencerFactory (GameWindow ~2306-2334) ---
|
||||
var capturedDats = dats;
|
||||
var capturedAnimLoader = animLoader;
|
||||
AcDream.Core.Physics.AnimationSequencer SequencerFactory(AcDream.Core.World.WorldEntity e)
|
||||
{
|
||||
if (capturedDats is not null && capturedAnimLoader is not null)
|
||||
{
|
||||
var setup = capturedDats.Get<DatReaderWriter.DBObjs.Setup>(e.SourceGfxObjOrSetupId);
|
||||
if (setup is not null)
|
||||
{
|
||||
uint mtableId = (uint)setup.DefaultMotionTable;
|
||||
if (mtableId != 0)
|
||||
{
|
||||
var mtable = capturedDats.Get<DatReaderWriter.DBObjs.MotionTable>(mtableId);
|
||||
if (mtable is not null)
|
||||
return new AcDream.Core.Physics.AnimationSequencer(
|
||||
setup, mtable, capturedAnimLoader);
|
||||
}
|
||||
// Setup exists but no motion table — no-op sequencer.
|
||||
return new AcDream.Core.Physics.AnimationSequencer(
|
||||
setup,
|
||||
new DatReaderWriter.DBObjs.MotionTable(),
|
||||
capturedAnimLoader);
|
||||
}
|
||||
}
|
||||
// Complete fallback: empty setup + empty motion table + null loader.
|
||||
return new AcDream.Core.Physics.AnimationSequencer(
|
||||
new DatReaderWriter.DBObjs.Setup(),
|
||||
new DatReaderWriter.DBObjs.MotionTable(),
|
||||
new NullAnimLoader());
|
||||
}
|
||||
|
||||
// --- EntitySpawnAdapter (GameWindow ~2335-2336) ---
|
||||
var entitySpawnAdapter = new Wb.EntitySpawnAdapter(
|
||||
textureCache, SequencerFactory, meshAdapter);
|
||||
|
||||
// --- EntityClassificationCache (GameWindow ~217 — field initializer, new()) ---
|
||||
var classificationCache = new Wb.EntityClassificationCache();
|
||||
|
||||
// --- WbDrawDispatcher (GameWindow ~2377-2381) ---
|
||||
var drawDispatcher = new Wb.WbDrawDispatcher(
|
||||
gl, meshShader, textureCache, meshAdapter, entitySpawnAdapter,
|
||||
bindless, classificationCache);
|
||||
drawDispatcher.AlphaToCoverage = opts.Quality.AlphaToCoverage;
|
||||
|
||||
// --- Vitals dat font (GameWindow ~1820-1822) ---
|
||||
var vitalsDatFont = AcDream.App.UI.UiDatFont.Load(dats, textureCache);
|
||||
|
||||
// --- Larger retail font (0x40000001, MaxCharHeight=18) for attribute row text.
|
||||
// The default font (0x40000000, 16px) renders the row names too small; the 18px
|
||||
// variant (confirmed in client_portal.dat 2026-06-26) matches the retail character
|
||||
// window list more closely (≈ icon height ≈ 24px target, 18px is best available).
|
||||
var largeDatFont = AcDream.App.UI.UiDatFont.Load(dats, textureCache, 0x40000001u);
|
||||
|
||||
// --- UiHost (GameWindow ~1790); pass null for debugFont (only used as
|
||||
// a fallback BitmapFont for the world-space HUD — not needed for the
|
||||
// UI Studio, and BitmapFont requires a system font byte array) ---
|
||||
var uiHost = new AcDream.App.UI.UiHost(gl, shaderDir, defaultFont: null);
|
||||
|
||||
var stack = new RenderStack(
|
||||
Gl: gl,
|
||||
Dats: dats,
|
||||
ShaderDir: shaderDir,
|
||||
Bindless: bindless,
|
||||
TextureCache: textureCache,
|
||||
MeshShader: meshShader,
|
||||
MeshAdapter: meshAdapter,
|
||||
EntitySpawnAdapter: entitySpawnAdapter,
|
||||
DrawDispatcher: drawDispatcher,
|
||||
LightingUbo: lightingUbo,
|
||||
UiHost: uiHost,
|
||||
VitalsDatFont: vitalsDatFont,
|
||||
LargeDatFont: largeDatFont);
|
||||
|
||||
// Pre-seed the font cache with the two already-uploaded atlas instances
|
||||
// so ResolveDatFont(0x40000000) and ResolveDatFont(0x40000001) hit the cache
|
||||
// rather than re-uploading the same GL texture a second time.
|
||||
stack.SeedFontCache();
|
||||
|
||||
return stack;
|
||||
}
|
||||
|
||||
// NullAnimLoader mirrors GameWindow's private NullAnimLoader (GameWindow ~13327-13330).
|
||||
private sealed class NullAnimLoader : AcDream.Core.Physics.IAnimationLoader
|
||||
{
|
||||
public DatReaderWriter.DBObjs.Animation? LoadAnimation(uint id) => null;
|
||||
}
|
||||
}
|
||||
235
src/AcDream.App/Studio/DumpLayout.cs
Normal file
235
src/AcDream.App/Studio/DumpLayout.cs
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.UI;
|
||||
|
||||
namespace AcDream.App.Studio;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// DumpLayout — load a panel from the retail UI layout dump
|
||||
//
|
||||
// The dump stores every node's rect in ABSOLUTE screen coordinates (the
|
||||
// panel's design position in the retail UI, not relative to its parent).
|
||||
// Evidence: for the "inventory" panel, the root node is at x=500,y=138 and
|
||||
// its direct children are also at x=500,y=161 — the child y=161 is only
|
||||
// 23 pixels below the parent y=138, which makes sense as a child offset
|
||||
// (the header row), not as the raw rect. If the rects were parent-relative,
|
||||
// (500,161) would place the child way off the window.
|
||||
//
|
||||
// DumpLayout converts absolute → parent-relative by computing:
|
||||
// child.Left = child.Rect.X - parent.Rect.X
|
||||
// child.Top = child.Rect.Y - parent.Rect.Y
|
||||
//
|
||||
// The root node (ParentTraversalIndex == null) is placed at (0,0) so the
|
||||
// whole tree sits at the UiHost origin rather than at the panel's retail
|
||||
// screen position.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Builds a static <see cref="UiElement"/> tree from the retail UI layout dump
|
||||
/// JSON. The tree is a hierarchy of <see cref="DumpSpriteElement"/> (draws its
|
||||
/// sprite) or plain <see cref="UiElement"/> containers (Group nodes), with each
|
||||
/// node's <see cref="UiElement.EventId"/> set to the dump's element_id and
|
||||
/// <see cref="UiElement.Name"/> set to the widget_kind string.
|
||||
///
|
||||
/// <para>This source is STATIC — no controllers, no FixtureProvider, no live
|
||||
/// game data. It is a build reference for the UI Studio showing any of the 26
|
||||
/// retail windows without needing the production panel wired up.</para>
|
||||
/// </summary>
|
||||
public static class DumpLayout
|
||||
{
|
||||
/// <summary>
|
||||
/// Parse the dump at <paramref name="dumpPath"/>, find the panel whose slug
|
||||
/// matches <paramref name="slug"/>, and build a <see cref="UiElement"/> tree.
|
||||
///
|
||||
/// <para>
|
||||
/// <paramref name="resolve"/> maps a RenderSurface id (0x06xxxxxx) to a
|
||||
/// (GL texture handle, native width, native height) triple — pass
|
||||
/// <c>RenderStack.ResolveChrome</c> from the studio, or a stub returning
|
||||
/// (1,1,1) for tests.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>Returns null and sets <paramref name="error"/> on failure.</para>
|
||||
/// </summary>
|
||||
public static UiElement? Load(
|
||||
string dumpPath,
|
||||
string slug,
|
||||
Func<uint, (uint, int, int)> resolve,
|
||||
out string? error)
|
||||
{
|
||||
// ── 1. Parse the dump JSON ────────────────────────────────────────
|
||||
var dump = UiDumpModel.Parse(dumpPath);
|
||||
if (dump is null)
|
||||
{
|
||||
error = $"[dump] Failed to parse '{dumpPath}'.";
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── 2. Find the requested panel ───────────────────────────────────
|
||||
var panel = dump.Panels.FirstOrDefault(
|
||||
p => string.Equals(p.Slug, slug, StringComparison.OrdinalIgnoreCase));
|
||||
if (panel is null)
|
||||
{
|
||||
error = $"[dump] Panel slug '{slug}' not found. " +
|
||||
$"Available: {string.Join(", ", dump.Panels.Select(p => p.Slug))}";
|
||||
return null;
|
||||
}
|
||||
|
||||
if (panel.Nodes.Count == 0)
|
||||
{
|
||||
error = $"[dump] Panel '{slug}' has no nodes.";
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── 3. Build a traversal-index → node lookup ──────────────────────
|
||||
var byIndex = new Dictionary<int, DumpNode>(panel.Nodes.Count);
|
||||
foreach (var n in panel.Nodes)
|
||||
byIndex[n.TraversalIndex] = n;
|
||||
|
||||
// ── 4. Create UiElement objects for every node ────────────────────
|
||||
var elements = new Dictionary<int, UiElement>(panel.Nodes.Count);
|
||||
foreach (var node in panel.Nodes)
|
||||
{
|
||||
var el = BuildElement(node, resolve);
|
||||
elements[node.TraversalIndex] = el;
|
||||
}
|
||||
|
||||
// ── 5. Wire parent–child relationships + set parent-relative coords ─
|
||||
UiElement? root = null;
|
||||
foreach (var node in panel.Nodes)
|
||||
{
|
||||
var el = elements[node.TraversalIndex];
|
||||
|
||||
if (node.ParentTraversalIndex is null)
|
||||
{
|
||||
// Root node — place at (0,0) so the tree sits at the UiHost origin.
|
||||
// The panel's absolute rect offset is discarded here (it was the
|
||||
// retail design position inside the retail screen, which we don't need).
|
||||
el.Left = 0f;
|
||||
el.Top = 0f;
|
||||
root = el;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Non-root: convert absolute → parent-relative by subtracting parent rect.
|
||||
// child.Left = child.Rect.X - parent.Rect.X
|
||||
// child.Top = child.Rect.Y - parent.Rect.Y
|
||||
// This preserves the visual layout inside each group without placing the
|
||||
// entire panel at its retail screen origin.
|
||||
var parentNode = byIndex[node.ParentTraversalIndex.Value];
|
||||
el.Left = node.Rect.X - parentNode.Rect.X;
|
||||
el.Top = node.Rect.Y - parentNode.Rect.Y;
|
||||
|
||||
var parentEl = elements[node.ParentTraversalIndex.Value];
|
||||
parentEl.AddChild(el);
|
||||
}
|
||||
}
|
||||
|
||||
if (root is null)
|
||||
{
|
||||
error = $"[dump] Panel '{slug}': no root node found (all nodes have a parent).";
|
||||
return null;
|
||||
}
|
||||
|
||||
// Give the root the full panel dimensions (from the dump's width/height record).
|
||||
root.Width = panel.Width;
|
||||
root.Height = panel.Height;
|
||||
|
||||
error = null;
|
||||
return root;
|
||||
}
|
||||
|
||||
// ── Private helpers ───────────────────────────────────────────────────────
|
||||
|
||||
private static UiElement BuildElement(
|
||||
DumpNode node,
|
||||
Func<uint, (uint, int, int)> resolve)
|
||||
{
|
||||
uint imageId = UiDumpModel.PickImageId(node);
|
||||
var kind = node.WidgetKind ?? "Group";
|
||||
|
||||
UiElement el;
|
||||
if (imageId != 0 && !string.Equals(kind, "Group", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Sprite/Button/Scrollbar/Slider — create a sprite-drawing element.
|
||||
el = new DumpSpriteElement(imageId, resolve)
|
||||
{
|
||||
Name = kind,
|
||||
ClickThrough = true, // static mockup; no behavior
|
||||
Anchors = AnchorEdges.None,
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
// Group (or sprite without an image) — plain container, no own draw.
|
||||
el = new DumpGroupElement()
|
||||
{
|
||||
Name = kind,
|
||||
ClickThrough = true,
|
||||
Anchors = AnchorEdges.None,
|
||||
};
|
||||
}
|
||||
|
||||
// EventId is set from the dump's element_id (cast to uint — the decimal
|
||||
// values in the JSON represent the same dat handle used at runtime).
|
||||
el.EventId = (uint)node.ElementId;
|
||||
el.Left = node.Rect.X; // overwritten by caller per root/child logic
|
||||
el.Top = node.Rect.Y;
|
||||
el.Width = node.Rect.Width;
|
||||
el.Height = node.Rect.Height;
|
||||
|
||||
return el;
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// DumpSpriteElement — minimal element that draws a single sprite
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Draws a single sprite at its native size tiled to fill <see cref="UiElement.Width"/>
|
||||
/// × <see cref="UiElement.Height"/>. Used for Sprite/Button/Scrollbar/Slider nodes from
|
||||
/// the retail UI dump.
|
||||
///
|
||||
/// <para>We do NOT reuse <see cref="AcDream.App.UI.Layout.UiDatElement"/> here because
|
||||
/// that class requires an <c>ElementInfo</c> with a populated <c>StateMedia</c>
|
||||
/// dictionary — the dat-import plumbing — which is not needed for a static dump
|
||||
/// preview. A minimal subclass keeps the code simpler and the dependency surface
|
||||
/// smaller.</para>
|
||||
/// </summary>
|
||||
internal sealed class DumpSpriteElement : UiElement
|
||||
{
|
||||
private readonly uint _imageId;
|
||||
private readonly Func<uint, (uint tex, int w, int h)> _resolve;
|
||||
|
||||
public DumpSpriteElement(uint imageId, Func<uint, (uint tex, int w, int h)> resolve)
|
||||
{
|
||||
_imageId = imageId;
|
||||
_resolve = resolve;
|
||||
}
|
||||
|
||||
protected override void OnDraw(UiRenderContext ctx)
|
||||
{
|
||||
if (_imageId == 0) return;
|
||||
|
||||
var (tex, tw, th) = _resolve(_imageId);
|
||||
if (tex == 0 || tw == 0 || th == 0) return;
|
||||
|
||||
// Tile at native resolution (same as UiDatElement.OnDraw — UV-repeat on both
|
||||
// axes via GL_REPEAT, Width/tw and Height/th tile the texture).
|
||||
ctx.DrawSprite(tex, 0, 0, Width, Height,
|
||||
0, 0, Width / tw, Height / th, Vector4.One);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// DumpGroupElement — pure container (Group nodes from the dump)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Container element for dump Group nodes — no own draw, just hosts children.
|
||||
/// Extending UiElement directly (no OnDraw override) gives transparent groups,
|
||||
/// which matches Group nodes in the retail layout that have no background sprite.
|
||||
/// </summary>
|
||||
internal sealed class DumpGroupElement : UiElement
|
||||
{
|
||||
// No OnDraw — completely transparent container.
|
||||
}
|
||||
159
src/AcDream.App/Studio/FixtureProvider.cs
Normal file
159
src/AcDream.App/Studio/FixtureProvider.cs
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
using AcDream.App.Rendering;
|
||||
using AcDream.App.UI;
|
||||
using AcDream.App.UI.Layout;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using DatReaderWriter;
|
||||
|
||||
namespace AcDream.App.Studio;
|
||||
|
||||
/// <summary>
|
||||
/// Populates a loaded panel with sample data by calling the production
|
||||
/// controller Bind methods against <see cref="SampleData"/>.
|
||||
///
|
||||
/// <para>
|
||||
/// The studio is intentionally thin — there is no live game session, no
|
||||
/// server connection, and no network. FixtureProvider bridges that gap by
|
||||
/// feeding static fixtures (vitals percentages, a fake inventory, empty
|
||||
/// shortcut lists) so the bound widgets show plausible state instead of
|
||||
/// empty zeroes.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>IconIds approach:</b> raw-resolve stub — resolve the base <c>iconId</c>
|
||||
/// via <see cref="RenderStack.ResolveChrome"/> and return the GL handle
|
||||
/// directly. This is intentionally simpler than GameWindow's full
|
||||
/// <see cref="IconComposer"/> (5-layer composite). The raw icon is enough
|
||||
/// to confirm the grid cells draw something in the studio; the full
|
||||
/// compositor is the live-game concern, not the layout preview concern.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class FixtureProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Populate <paramref name="layout"/> with sample data appropriate for
|
||||
/// <paramref name="layoutId"/>. Calls the production controller Bind
|
||||
/// methods so the panel's widgets drive off the same code path as the
|
||||
/// full game.
|
||||
/// </summary>
|
||||
/// <param name="layoutId">The LayoutDesc dat id used to import the layout.</param>
|
||||
/// <param name="layout">The imported layout whose widgets to populate.</param>
|
||||
/// <param name="stack">The live render stack (for <see cref="RenderStack.ResolveChrome"/>
|
||||
/// and <see cref="RenderStack.VitalsDatFont"/>).</param>
|
||||
/// <param name="objects">A <see cref="ClientObjectTable"/> already seeded by
|
||||
/// <see cref="SampleData.BuildObjectTable"/>.</param>
|
||||
/// <param name="dats">The live DAT collection used to resolve per-list empty-slot sprites
|
||||
/// (same lookup GameWindow.OnLoad performs for the production binding).</param>
|
||||
public static void Populate(
|
||||
uint layoutId,
|
||||
ImportedLayout layout,
|
||||
RenderStack stack,
|
||||
ClientObjectTable objects,
|
||||
DatCollection dats)
|
||||
{
|
||||
switch (layoutId)
|
||||
{
|
||||
case 0x2100006Cu: // vitals
|
||||
VitalsController.Bind(layout,
|
||||
healthPct: () => SampleData.HealthPct,
|
||||
staminaPct: () => SampleData.StaminaPct,
|
||||
manaPct: () => SampleData.ManaPct,
|
||||
healthText: () => "80/100",
|
||||
staminaText: () => "60/100",
|
||||
manaText: () => "90/100");
|
||||
break;
|
||||
|
||||
case 0x21000016u: // toolbar
|
||||
ToolbarController.Bind(
|
||||
layout,
|
||||
objects,
|
||||
shortcuts: () => System.Array.Empty<PlayerDescriptionParser.ShortcutEntry>(),
|
||||
iconIds: MakeIconIds(stack),
|
||||
useItem: _ => { },
|
||||
combatState: null,
|
||||
peaceDigits: null,
|
||||
warDigits: null,
|
||||
emptyDigits: null,
|
||||
sendAddShortcut: null,
|
||||
sendRemoveShortcut: null);
|
||||
break;
|
||||
|
||||
case 0x21000023u: // inventory + paperdoll
|
||||
{
|
||||
// Resolve the per-list empty-slot art from the dat cell template, matching the
|
||||
// exact lookup GameWindow.OnLoad performs (UIElement_ItemList::InternalCreateItem
|
||||
// 0x004e3570 → attr 0x1000000e → catalog 0x21000037 → ItemSlot_Empty).
|
||||
uint contentsEmpty = ItemListCellTemplate.ResolveEmptySprite(dats, 0x21000021u, 0x100001C6u);
|
||||
uint sideBagEmpty = ItemListCellTemplate.ResolveEmptySprite(dats, 0x21000022u, 0x100001CAu);
|
||||
uint mainPackEmpty = ItemListCellTemplate.ResolveEmptySprite(dats, 0x21000022u, 0x100001C9u);
|
||||
|
||||
var iconIds = MakeIconIds(stack);
|
||||
|
||||
InventoryController.Bind(
|
||||
layout,
|
||||
objects,
|
||||
playerGuid: () => SampleData.PlayerGuid,
|
||||
iconIds: iconIds,
|
||||
strength: () => 100,
|
||||
datFont: stack.VitalsDatFont,
|
||||
contentsEmptySprite: contentsEmpty,
|
||||
sideBagEmptySprite: sideBagEmpty,
|
||||
mainPackEmptySprite: mainPackEmpty);
|
||||
|
||||
// Bind the paperdoll equip slots (same imported subtree as the inventory).
|
||||
// Mirrors GameWindow:2257-2265: PaperdollController.Bind with contentsEmpty as
|
||||
// emptySlotSprite (each slot shows the same square-frame placeholder as the grid).
|
||||
PaperdollController.Bind(
|
||||
layout,
|
||||
objects,
|
||||
playerGuid: () => SampleData.PlayerGuid,
|
||||
iconIds: iconIds,
|
||||
sendWield: null, // no live session in the studio
|
||||
emptySlotSprite: contentsEmpty,
|
||||
datFont: stack.VitalsDatFont);
|
||||
break;
|
||||
}
|
||||
|
||||
case 0x2100002Eu: // gmStatManagementUI — Attributes/Skills/Titles window (LayoutDesc 0x2100002E)
|
||||
// Bind the REAL importer-mounted header + list elements (name/heritage/PK/level/
|
||||
// total-XP/XP-meter + the 9-row attribute list + footer State-A). NOT the text-report
|
||||
// sub-panel (that is gmCharacterInfoUI 0x2100001A → CharacterController).
|
||||
// LargeDatFont (0x40000001, MaxCharHeight=18) is used for the attribute row text;
|
||||
// fallback to VitalsDatFont (0x40000000, 16px) if unavailable.
|
||||
CharacterStatController.Bind(
|
||||
layout,
|
||||
data: SampleData.SampleCharacter,
|
||||
datFont: stack.VitalsDatFont,
|
||||
rowDatFont: stack.LargeDatFont ?? stack.VitalsDatFont,
|
||||
spriteResolve: stack.ResolveChrome);
|
||||
break;
|
||||
|
||||
default:
|
||||
// Unknown layout — no-op; the panel renders structurally.
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Build the <c>iconIds</c> delegate for toolbar / inventory controllers.
|
||||
///
|
||||
/// <para>
|
||||
/// Raw-resolve stub: resolve the base <paramref name="iconId"/> (arg 2)
|
||||
/// via <see cref="RenderStack.ResolveChrome"/> and return its GL handle.
|
||||
/// The remaining args (type, underlayId, overlayId, effects) are ignored
|
||||
/// for the studio — a single-layer icon is sufficient for layout preview.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>This is what the task spec calls "v1 raw-resolve stub".</para>
|
||||
/// </summary>
|
||||
private static Func<ItemType, uint, uint, uint, uint, uint> MakeIconIds(RenderStack stack)
|
||||
=> (_, iconId, _, _, _) =>
|
||||
{
|
||||
if (iconId == 0u) return 0u;
|
||||
var (handle, _, _) = stack.ResolveChrome(iconId);
|
||||
return handle;
|
||||
};
|
||||
|
||||
}
|
||||
123
src/AcDream.App/Studio/LayoutSource.cs
Normal file
123
src/AcDream.App/Studio/LayoutSource.cs
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
using AcDream.App.UI;
|
||||
using AcDream.App.UI.Layout;
|
||||
using DatReaderWriter;
|
||||
|
||||
namespace AcDream.App.Studio;
|
||||
|
||||
/// <summary>Which kind of source the studio is currently previewing.</summary>
|
||||
public enum LayoutSourceKind { DatLayout, Markup }
|
||||
|
||||
/// <summary>
|
||||
/// Wraps the two ways the UI Studio can load a panel to preview:
|
||||
/// a LayoutDesc dat id, or a KSML markup file path (Task 6 — unsupported now).
|
||||
///
|
||||
/// <para>Call <see cref="Load"/> with the current <see cref="StudioOptions"/> to
|
||||
/// import the layout and get the root <see cref="UiElement"/>. The result is also
|
||||
/// cached in <see cref="CurrentLayout"/> so <see cref="Reload"/> can re-run the same
|
||||
/// source without re-reading the options.</para>
|
||||
/// </summary>
|
||||
public sealed class LayoutSource
|
||||
{
|
||||
private readonly DatCollection _dats;
|
||||
private readonly Func<uint, (uint, int, int)> _resolve;
|
||||
private readonly UiDatFont? _datFont;
|
||||
private readonly Func<uint, UiDatFont?>? _fontResolve;
|
||||
|
||||
public LayoutSourceKind Kind { get; private set; }
|
||||
public uint? LayoutId { get; private set; }
|
||||
public string? MarkupPath { get; private set; }
|
||||
public string? LastError { get; private set; }
|
||||
public ImportedLayout? CurrentLayout { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Create a LayoutSource.
|
||||
/// </summary>
|
||||
/// <param name="fontResolve">Optional per-element font resolver: FontDid →
|
||||
/// <see cref="UiDatFont"/> (null when the font isn't in the dats). When supplied,
|
||||
/// elements with a non-zero FontDid receive their own dat font at build time
|
||||
/// instead of the shared <paramref name="datFont"/> global. Controllers that
|
||||
/// explicitly set <see cref="UiText.DatFont"/> after
|
||||
/// <see cref="ImportedLayout.FindElement"/> still override the build-time value.
|
||||
/// Pass null (default) for the original single-font behavior — the live
|
||||
/// <see cref="GameWindow"/> path passes null so it is provably unchanged.</param>
|
||||
public LayoutSource(
|
||||
DatCollection dats,
|
||||
Func<uint, (uint, int, int)> resolve,
|
||||
UiDatFont? datFont,
|
||||
Func<uint, UiDatFont?>? fontResolve = null)
|
||||
{
|
||||
_dats = dats ?? throw new ArgumentNullException(nameof(dats));
|
||||
_resolve = resolve ?? throw new ArgumentNullException(nameof(resolve));
|
||||
_datFont = datFont;
|
||||
_fontResolve = fontResolve;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Load the layout described by <paramref name="opts"/>. For a dat layout
|
||||
/// (<see cref="StudioOptions.LayoutId"/> is non-null) calls
|
||||
/// <see cref="LayoutImporter.Import"/>. For a markup path sets
|
||||
/// <see cref="LastError"/> and returns null (Task 6, not yet implemented).
|
||||
///
|
||||
/// Returns the root <see cref="UiElement"/> on success, or null on failure
|
||||
/// (check <see cref="LastError"/>).
|
||||
/// </summary>
|
||||
public UiElement? Load(StudioOptions opts)
|
||||
{
|
||||
LastError = null;
|
||||
CurrentLayout = null;
|
||||
|
||||
if (opts.MarkupPath is not null)
|
||||
{
|
||||
Kind = LayoutSourceKind.Markup;
|
||||
MarkupPath = opts.MarkupPath;
|
||||
LastError = "markup unsupported (Task 6)";
|
||||
return null;
|
||||
}
|
||||
|
||||
if (opts.LayoutId is null)
|
||||
{
|
||||
LastError = "ui-studio: no layout id or markup path specified.";
|
||||
return null;
|
||||
}
|
||||
|
||||
Kind = LayoutSourceKind.DatLayout;
|
||||
LayoutId = opts.LayoutId;
|
||||
return LoadDat(opts.LayoutId.Value);
|
||||
}
|
||||
|
||||
/// <summary>Re-run the most-recently-configured source without re-reading options.</summary>
|
||||
public UiElement? Reload()
|
||||
{
|
||||
LastError = null;
|
||||
CurrentLayout = null;
|
||||
|
||||
if (Kind == LayoutSourceKind.Markup)
|
||||
{
|
||||
LastError = "markup unsupported (Task 6)";
|
||||
return null;
|
||||
}
|
||||
|
||||
if (LayoutId is null)
|
||||
{
|
||||
LastError = "ui-studio: no layout id to reload.";
|
||||
return null;
|
||||
}
|
||||
|
||||
return LoadDat(LayoutId.Value);
|
||||
}
|
||||
|
||||
// ── Private ──────────────────────────────────────────────────────────────────
|
||||
|
||||
private UiElement? LoadDat(uint layoutId)
|
||||
{
|
||||
var imported = LayoutImporter.Import(_dats, layoutId, _resolve, _datFont, _fontResolve);
|
||||
if (imported is null)
|
||||
{
|
||||
LastError = $"ui-studio: LayoutDesc 0x{layoutId:X8} not found in dats.";
|
||||
return null;
|
||||
}
|
||||
|
||||
CurrentLayout = imported;
|
||||
return imported.Root;
|
||||
}
|
||||
}
|
||||
156
src/AcDream.App/Studio/PanelFbo.cs
Normal file
156
src/AcDream.App/Studio/PanelFbo.cs
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
using System;
|
||||
using System.Numerics;
|
||||
using AcDream.App.Rendering.Wb;
|
||||
using AcDream.App.UI;
|
||||
using Silk.NET.OpenGL;
|
||||
|
||||
namespace AcDream.App.Studio;
|
||||
|
||||
/// <summary>
|
||||
/// Renders a <see cref="UiHost"/> into an off-screen FBO each frame and
|
||||
/// returns the color texture handle for display in ImGui.
|
||||
///
|
||||
/// <para>Pattern lifted verbatim from <see cref="AcDream.App.Rendering.PaperdollViewportRenderer"/>:
|
||||
/// RGBA8 color texture + Depth24Stencil8 renderbuffer, lazily (re)created on
|
||||
/// size change. The entire 2-D UI pass is sealed in a <see cref="GLStateScope"/>
|
||||
/// so it cannot disturb the surrounding ImGui GL state.</para>
|
||||
///
|
||||
/// <para>FBO origin is bottom-left (GL convention). The caller must flip V when
|
||||
/// displaying the texture in ImGui (pass uv0=(0,1), uv1=(1,0) to ImGui.Image)
|
||||
/// so the image appears right-side-up in ImGui's top-left coordinate system.</para>
|
||||
/// </summary>
|
||||
public sealed unsafe class PanelFbo : IDisposable
|
||||
{
|
||||
private readonly GL _gl;
|
||||
|
||||
// Off-screen target — lazily (re)created when the requested size changes.
|
||||
private uint _fbo;
|
||||
private uint _colorTex;
|
||||
private uint _depthRbo;
|
||||
private int _fbW;
|
||||
private int _fbH;
|
||||
|
||||
public PanelFbo(GL gl)
|
||||
{
|
||||
_gl = gl ?? throw new ArgumentNullException(nameof(gl));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Render <paramref name="host"/> (a full <see cref="UiHost"/> draw pass) into a
|
||||
/// private FBO at <paramref name="width"/> × <paramref name="height"/> pixels.
|
||||
/// Returns the GL color texture handle (0 on failure). The texture is valid until
|
||||
/// the next call to <see cref="Render"/> with a different size, or until <see cref="Dispose"/>.
|
||||
/// </summary>
|
||||
public uint Render(int width, int height, UiHost host)
|
||||
{
|
||||
if (width <= 0 || height <= 0 || host is null) return 0u;
|
||||
|
||||
EnsureFramebuffer(width, height);
|
||||
if (_fbo == 0) return 0u;
|
||||
|
||||
// Seal the entire pass: GLStateScope saves + restores every GL state the
|
||||
// UI draw touches (viewport, blend, FBO binding, etc.) so ImGui's own state
|
||||
// — set up by BeginFrame and expected intact by Render — is 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(0.18f, 0.18f, 0.18f, 1f); // opaque dark-grey canvas background (the FBO IS the canvas)
|
||||
_gl.ClearDepth(1.0);
|
||||
_gl.DepthMask(true);
|
||||
_gl.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit);
|
||||
|
||||
host.Draw(new Vector2(width, height));
|
||||
|
||||
// FBO stays bound here; GLStateScope.Dispose() restores the previous binding.
|
||||
return _colorTex;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read the FBO color attachment back to CPU as a flat RGBA8 byte array.
|
||||
/// Must be called AFTER <see cref="Render"/> for the same <paramref name="width"/> and
|
||||
/// <paramref name="height"/> (so the FBO exists and is the right size).
|
||||
///
|
||||
/// <para>FBO origin is bottom-left (GL convention). The caller is responsible for
|
||||
/// flipping rows vertically before saving as a top-left-origin image format (PNG).</para>
|
||||
///
|
||||
/// <para>Returns an empty array when the FBO is not ready.</para>
|
||||
/// </summary>
|
||||
public unsafe byte[] ReadColorRgba(int width, int height)
|
||||
{
|
||||
if (_fbo == 0 || width <= 0 || height <= 0) return Array.Empty<byte>();
|
||||
|
||||
int byteCount = width * height * 4;
|
||||
var buf = new byte[byteCount];
|
||||
|
||||
_gl.BindFramebuffer(FramebufferTarget.Framebuffer, _fbo);
|
||||
fixed (byte* p = buf)
|
||||
{
|
||||
_gl.ReadPixels(0, 0, (uint)width, (uint)height,
|
||||
PixelFormat.Rgba, PixelType.UnsignedByte, p);
|
||||
}
|
||||
_gl.BindFramebuffer(FramebufferTarget.Framebuffer, 0);
|
||||
|
||||
return buf;
|
||||
}
|
||||
|
||||
// ── FBO lifecycle (mirrors PaperdollViewportRenderer.EnsureFramebuffer) ──────
|
||||
|
||||
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($"[studio] PanelFbo 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() => DeleteFramebuffer();
|
||||
}
|
||||
227
src/AcDream.App/Studio/SampleData.cs
Normal file
227
src/AcDream.App/Studio/SampleData.cs
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
using AcDream.App.UI.Layout;
|
||||
using AcDream.Core.Items;
|
||||
|
||||
namespace AcDream.App.Studio;
|
||||
|
||||
/// <summary>
|
||||
/// Static sample data for the UI Studio fixture provider.
|
||||
/// Provides a pre-built <see cref="ClientObjectTable"/> populated with a
|
||||
/// representative player, their main-pack items, side bags, and equipped gear,
|
||||
/// so the 2-D inventory / paperdoll panels render populated when previewed in
|
||||
/// the studio without a live game session.
|
||||
///
|
||||
/// Icon ids used (all real 0x06xxxxxx RenderSurface ids confirmed in the dats):
|
||||
/// Inventory empty-slot sprite : 0x06004D20
|
||||
/// Generic "misc" item : 0x060011D4 (icon_misc_underlay / fallback)
|
||||
/// Iron Sword (melee weapon) : 0x060011CBu (acclient default weapon underlay)
|
||||
/// Leather Breastplate (armor) : 0x060011CFu (acclient default armor underlay)
|
||||
/// Leather Gloves : 0x060011F3u (acclient default clothing underlay)
|
||||
/// Steel Ring : 0x060011D5u (acclient default jewelry underlay)
|
||||
/// Healing Kit : 0x060011D4u (generic misc fallback)
|
||||
/// Spell components : 0x060011D4u (generic misc fallback)
|
||||
/// Side-bag 1 (Container) : 0x06004D20u
|
||||
/// Side-bag 2 (Container) : 0x06004D20u
|
||||
/// Equipped helm (HeadWear) : 0x060011F3u
|
||||
/// Equipped chest armor : 0x060011CFu
|
||||
/// Equipped melee weapon : 0x060011CBu
|
||||
///
|
||||
/// These are the icon *base* RenderSurface ids — the same ids GameWindow passes
|
||||
/// as `iconId` into the iconIds lambda. FixtureProvider resolves them via
|
||||
/// <see cref="Rendering.RenderStack.ResolveChrome"/> and returns the raw GL handle.
|
||||
/// </summary>
|
||||
public static class SampleData
|
||||
{
|
||||
// ── Guids ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Fake server guid for the studio's synthetic player.</summary>
|
||||
public const uint PlayerGuid = 0x50000001u;
|
||||
|
||||
// Items in main pack (slots 0–5).
|
||||
private const uint SwordGuid = 0x50000010u;
|
||||
private const uint ChestGuid = 0x50000011u;
|
||||
private const uint GlovesGuid = 0x50000012u;
|
||||
private const uint RingGuid = 0x50000013u;
|
||||
private const uint HealKitGuid = 0x50000014u;
|
||||
private const uint CompGuid = 0x50000015u;
|
||||
|
||||
// Side bags (also in main pack, ContainerId = PlayerGuid; slots 6 & 7).
|
||||
private const uint Bag1Guid = 0x50000020u;
|
||||
private const uint Bag2Guid = 0x50000021u;
|
||||
|
||||
// Equipped items (ContainerId = PlayerGuid, CurrentlyEquippedLocation set).
|
||||
private const uint HelmGuid = 0x50000030u;
|
||||
private const uint ChestEqGuid = 0x50000031u;
|
||||
private const uint WeaponEqGuid = 0x50000032u;
|
||||
|
||||
// ── Icon ids (0x06xxxxxx RenderSurface dat ids) ───────────────────────────
|
||||
|
||||
// These are the same underlay/fallback icon ids the IconComposer tests pin.
|
||||
private const uint IconWeapon = 0x060011CBu; // weapon underlay
|
||||
private const uint IconArmor = 0x060011CFu; // armor underlay
|
||||
private const uint IconClothing = 0x060011F3u; // clothing underlay
|
||||
private const uint IconJewelry = 0x060011D5u; // jewelry underlay
|
||||
private const uint IconMisc = 0x060011D4u; // misc / fallback underlay
|
||||
|
||||
// ── Public API ──────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Build a fresh <see cref="ClientObjectTable"/> populated with the
|
||||
/// studio's sample player + a realistic inventory snapshot.
|
||||
/// The table is owned by the caller and should be kept alive for the
|
||||
/// window's lifetime.
|
||||
/// </summary>
|
||||
public static ClientObjectTable BuildObjectTable()
|
||||
{
|
||||
var t = new ClientObjectTable();
|
||||
|
||||
// ── Player object ─────────────────────────────────────────────────
|
||||
t.AddOrUpdate(new ClientObject
|
||||
{
|
||||
ObjectId = PlayerGuid,
|
||||
Name = "Studio Player",
|
||||
Type = ItemType.Creature,
|
||||
ItemsCapacity = 102,
|
||||
ContainersCapacity = 7,
|
||||
});
|
||||
|
||||
// ── Loose items in main pack (slots 0–5) ──────────────────────────
|
||||
|
||||
AddItem(t, SwordGuid, ItemType.MeleeWeapon, IconWeapon, "Iron Sword", PlayerGuid, 0, burden: 60);
|
||||
AddItem(t, ChestGuid, ItemType.Armor, IconArmor, "Leather Breastplate", PlayerGuid, 1, burden: 200);
|
||||
AddItem(t, GlovesGuid, ItemType.Clothing, IconClothing, "Leather Gloves", PlayerGuid, 2, burden: 50);
|
||||
AddItem(t, RingGuid, ItemType.Jewelry, IconJewelry, "Steel Ring", PlayerGuid, 3, burden: 10);
|
||||
AddItem(t, HealKitGuid, ItemType.Misc, IconMisc, "Healing Kit", PlayerGuid, 4, burden: 30);
|
||||
AddItem(t, CompGuid, ItemType.SpellComponents, IconMisc, "Spell Comps", PlayerGuid, 5, burden: 25, stackSize: 50, stackMax: 100);
|
||||
|
||||
// ── Side bags (Container items in main pack, slots 6 & 7) ─────────
|
||||
|
||||
AddItem(t, Bag1Guid, ItemType.Container, IconMisc, "Small Pack 1",
|
||||
containerId: PlayerGuid, slot: 6, burden: 20, itemsCapacity: 24);
|
||||
AddItem(t, Bag2Guid, ItemType.Container, IconMisc, "Small Pack 2",
|
||||
containerId: PlayerGuid, slot: 7, burden: 20, itemsCapacity: 24);
|
||||
|
||||
// ── Equipped items (ContainerId = PlayerGuid, CurrentlyEquippedLocation set) ──
|
||||
|
||||
AddEquipped(t, HelmGuid, ItemType.Armor, IconClothing, "Tin Helm", EquipMask.HeadWear);
|
||||
AddEquipped(t, ChestEqGuid, ItemType.Armor, IconArmor, "Chain Coat", EquipMask.ChestArmor);
|
||||
AddEquipped(t, WeaponEqGuid, ItemType.MeleeWeapon, IconWeapon, "Wooden Sword", EquipMask.MeleeWeapon);
|
||||
|
||||
return t;
|
||||
}
|
||||
|
||||
// ── Sample vital constants (used by FixtureProvider) ────────────────────
|
||||
|
||||
public const float HealthPct = 0.8f;
|
||||
public const float StaminaPct = 0.6f;
|
||||
public const float ManaPct = 0.9f;
|
||||
|
||||
// ── Sample character sheet (used by CharacterController in the Studio) ───
|
||||
|
||||
/// <summary>
|
||||
/// Returns a representative <see cref="CharacterSheet"/> for the studio's
|
||||
/// synthetic character. Values are plausible retail-scale numbers so the
|
||||
/// report renders with well-proportioned text in all sections.
|
||||
/// </summary>
|
||||
public static CharacterSheet SampleCharacter() => new()
|
||||
{
|
||||
Name = "Studio Player",
|
||||
Level = 126,
|
||||
Race = "Aluvian",
|
||||
Heritage = "Aluvian Heritage",
|
||||
Title = "the Adventurer",
|
||||
BirthDate = "January 5, 2001",
|
||||
PlayTime = "2 years, 114 days, 4 hours",
|
||||
Deaths = 42,
|
||||
|
||||
PkStatus = "Non-Player Killer",
|
||||
TotalXp = 1_250_000_000,
|
||||
XpToNextLevel = 42_000_000,
|
||||
XpFraction = 0.63f,
|
||||
|
||||
// Vitals: retail screenshot spec (Pass 1 acceptance criteria §Goal).
|
||||
HealthCurrent = 5, HealthMax = 5,
|
||||
StaminaCurrent = 10, StaminaMax = 10,
|
||||
ManaCurrent = 10, ManaMax = 10,
|
||||
|
||||
// Attributes: Strength + Quickness = 200; all others = 10 (retail screenshot spec §Goal).
|
||||
Strength = 200,
|
||||
Endurance = 10,
|
||||
Quickness = 200,
|
||||
Coordination = 10,
|
||||
Focus = 10,
|
||||
Self = 10,
|
||||
|
||||
UnspentSkillCredits = 12,
|
||||
SpecializedSkillCredits = 4,
|
||||
|
||||
// Available skill credits (retail InqInt(0x18)); shown in Attributes tab footer State-A.
|
||||
SkillCredits = 96,
|
||||
|
||||
// Unassigned (banked) XP (retail InqInt64(2)); footer State-A line-2 value.
|
||||
UnassignedXp = 87_757_321_741L,
|
||||
|
||||
// Raise costs in retail display order (Strength, Endurance, Coordination, Quickness,
|
||||
// Focus, Self, Health, Stamina, Mana).
|
||||
// Str@200 = maxed → 0 (disabled). Quickness@200 = maxed → 0. Others @10 → affordable.
|
||||
// Focus@10 → 110 matches the authoritative retail screenshot (spec §4).
|
||||
// Formula bracket at value=10: ExperienceToAttributeLevel(11) − ExperienceToAttributeLevel(10).
|
||||
AttributeRaiseCosts = new long[] { 0L, 95L, 100L, 0L, 110L, 105L, 90L, 88L, 112L },
|
||||
|
||||
AugmentationName = "Swords",
|
||||
|
||||
BurdenCurrent = 1200,
|
||||
BurdenMax = 4500,
|
||||
};
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
private static void AddItem(
|
||||
ClientObjectTable t,
|
||||
uint guid,
|
||||
ItemType type,
|
||||
uint iconId,
|
||||
string name,
|
||||
uint containerId,
|
||||
int slot,
|
||||
int burden = 0,
|
||||
int stackSize = 1,
|
||||
int stackMax = 1,
|
||||
int itemsCapacity = 0)
|
||||
{
|
||||
t.AddOrUpdate(new ClientObject
|
||||
{
|
||||
ObjectId = guid,
|
||||
Name = name,
|
||||
Type = type,
|
||||
IconId = iconId,
|
||||
Burden = burden,
|
||||
StackSize = stackSize,
|
||||
StackSizeMax = stackMax,
|
||||
ItemsCapacity = itemsCapacity,
|
||||
});
|
||||
t.MoveItem(guid, containerId, slot);
|
||||
}
|
||||
|
||||
private static void AddEquipped(
|
||||
ClientObjectTable t,
|
||||
uint guid,
|
||||
ItemType type,
|
||||
uint iconId,
|
||||
string name,
|
||||
EquipMask equipMask)
|
||||
{
|
||||
t.AddOrUpdate(new ClientObject
|
||||
{
|
||||
ObjectId = guid,
|
||||
Name = name,
|
||||
Type = type,
|
||||
IconId = iconId,
|
||||
ValidLocations = equipMask,
|
||||
CurrentlyEquippedLocation = equipMask,
|
||||
ContainerId = PlayerGuid,
|
||||
});
|
||||
// Update the equip location on the object via MoveItem (sets ContainerId +
|
||||
// CurrentlyEquippedLocation via the equip overload).
|
||||
t.MoveItem(guid, PlayerGuid, newSlot: -1, newEquipLocation: equipMask);
|
||||
}
|
||||
}
|
||||
295
src/AcDream.App/Studio/StudioInspector.cs
Normal file
295
src/AcDream.App/Studio/StudioInspector.cs
Normal file
|
|
@ -0,0 +1,295 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.UI;
|
||||
using ImGuiNET;
|
||||
|
||||
namespace AcDream.App.Studio;
|
||||
|
||||
/// <summary>
|
||||
/// All canvas mouse events gathered by <see cref="StudioInspector.DrawCanvas"/> in one frame.
|
||||
/// All coordinates are already mapped to panel-local pixels (origin top-left, same as UiRoot).
|
||||
/// </summary>
|
||||
public readonly struct CanvasInputEvent
|
||||
{
|
||||
/// <summary>Mouse is currently hovering the canvas image. When false all other fields are 0 / false.</summary>
|
||||
public readonly bool IsHovered;
|
||||
/// <summary>Panel-local pixel coordinate of the mouse this frame (valid when <see cref="IsHovered"/>).</summary>
|
||||
public readonly int MouseX;
|
||||
/// <summary>Panel-local pixel coordinate of the mouse this frame (valid when <see cref="IsHovered"/>).</summary>
|
||||
public readonly int MouseY;
|
||||
/// <summary>Left-button went down this frame.</summary>
|
||||
public readonly bool LeftDown;
|
||||
/// <summary>Left-button came up this frame.</summary>
|
||||
public readonly bool LeftUp;
|
||||
/// <summary>Mouse-wheel scroll delta (lines, positive = up). Zero when no scroll.</summary>
|
||||
public readonly int ScrollDelta;
|
||||
|
||||
public CanvasInputEvent(bool hovered, int mx, int my, bool ld, bool lu, int scroll)
|
||||
{
|
||||
IsHovered = hovered;
|
||||
MouseX = mx;
|
||||
MouseY = my;
|
||||
LeftDown = ld;
|
||||
LeftUp = lu;
|
||||
ScrollDelta = scroll;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Four-pane ImGui IDE for the acdream UI Studio:
|
||||
/// <list type="bullet">
|
||||
/// <item><b>Toolbar</b> — panel picker (slug combo) across the top.</item>
|
||||
/// <item><b>Canvas</b> — shows the panel FBO texture; in Interact mode mouse events
|
||||
/// are forwarded to the panel UiHost (buttons/tabs respond); in Inspect mode a
|
||||
/// left-click hit-tests and selects the element under the cursor.</item>
|
||||
/// <item><b>Tree</b> — recursive ImGui tree of the element hierarchy; clicking a node
|
||||
/// sets <see cref="Selected"/>.</item>
|
||||
/// <item><b>Properties</b> — shows the <see cref="Selected"/> element's geometry,
|
||||
/// anchors, and z-order.</item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para><b>Coordinate mapping for the canvas:</b>
|
||||
/// The FBO is rendered at the full window size and displayed 1:1 inside the Canvas ImGui
|
||||
/// sub-window. After <c>ImGui.Image</c> we call <c>ImGui.GetItemRectMin()</c> to get the
|
||||
/// screen-space top-left of the drawn image (accounting for the sub-window's title bar,
|
||||
/// padding, and any scrolling). Subtracting that from the raw mouse screen position gives
|
||||
/// panel-local pixels directly — no additional scale factor is needed because the image is
|
||||
/// drawn 1:1.</para>
|
||||
///
|
||||
/// <para><b>V-flip — no extra Y inversion needed:</b>
|
||||
/// The FBO origin is bottom-left (GL convention), so we pass uv0=(0,1), uv1=(1,0) to
|
||||
/// <c>ImGui.Image</c> to flip V. After this flip, displayed row 0 (top of the image on
|
||||
/// screen) corresponds to panel Y=0 (the top of the UI panel), matching UiRoot's
|
||||
/// top-left origin. Therefore the panel-local Y computed above maps directly into UiRoot
|
||||
/// without further inversion — do NOT flip Y again.</para>
|
||||
///
|
||||
/// <para>Layout: the four panes call <c>SetNextWindowPos</c> + <c>SetNextWindowSize</c>
|
||||
/// with <c>ImGuiCond.FirstUseEver</c> so they start docked but can be freely dragged.</para>
|
||||
/// </summary>
|
||||
public sealed class StudioInspector
|
||||
{
|
||||
/// <summary>Currently selected element (set by tree-click or canvas-click in Inspect mode).</summary>
|
||||
public UiElement? Selected { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When true (default) canvas mouse events are forwarded to the panel UiHost so elements
|
||||
/// respond to clicks. When false a canvas click hit-tests and selects an element in the
|
||||
/// inspector tree instead. Toggle via the "Interact / Inspect" checkbox in the toolbar.
|
||||
/// </summary>
|
||||
public bool InteractMode { get; set; } = true;
|
||||
|
||||
// ── Toolbar ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Draw the "Studio" toolbar window (top strip) containing a slug combo-box and
|
||||
/// the Interact / Inspect mode toggle. Returns the newly-selected slug when the
|
||||
/// user picks a different panel, or null when unchanged.
|
||||
/// <para>The mode toggle sets <see cref="InteractMode"/>: checked = Interact (panel
|
||||
/// elements respond to clicks), unchecked = Inspect (clicks select elements in the
|
||||
/// tree).</para>
|
||||
/// </summary>
|
||||
/// <param name="slugs">All available panel slugs (from <c>UiDumpModel.ListSlugs</c>).</param>
|
||||
/// <param name="current">The slug of the panel currently loaded.</param>
|
||||
/// <param name="windowW">Studio window width (pixels).</param>
|
||||
public string? DrawToolbar(IReadOnlyList<string> slugs, string? current, int windowW)
|
||||
{
|
||||
ImGui.SetNextWindowPos(new Vector2(0f, 0f), ImGuiCond.FirstUseEver);
|
||||
ImGui.SetNextWindowSize(new Vector2(windowW, 40f), ImGuiCond.FirstUseEver);
|
||||
ImGui.Begin("Studio",
|
||||
ImGuiWindowFlags.NoScrollbar | ImGuiWindowFlags.NoScrollWithMouse);
|
||||
|
||||
ImGui.SetNextItemWidth(300f);
|
||||
string? result = null;
|
||||
string preview = current ?? "(none)";
|
||||
if (ImGui.BeginCombo("Panel", preview))
|
||||
{
|
||||
foreach (var slug in slugs)
|
||||
{
|
||||
bool selected = string.Equals(slug, current, StringComparison.OrdinalIgnoreCase);
|
||||
if (ImGui.Selectable(slug, selected) && !selected)
|
||||
result = slug;
|
||||
if (selected)
|
||||
ImGui.SetItemDefaultFocus();
|
||||
}
|
||||
ImGui.EndCombo();
|
||||
}
|
||||
|
||||
ImGui.SameLine();
|
||||
bool interact = InteractMode;
|
||||
if (ImGui.Checkbox("Interact", ref interact))
|
||||
InteractMode = interact;
|
||||
if (ImGui.IsItemHovered())
|
||||
ImGui.SetTooltip("Interact: canvas clicks reach the panel (buttons/tabs respond).\nUncheck to Inspect: clicks select elements in the tree.");
|
||||
|
||||
ImGui.End();
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Canvas ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Draw the "Canvas" ImGui window containing the panel FBO texture and return all
|
||||
/// canvas mouse events for this frame as a <see cref="CanvasInputEvent"/>.
|
||||
///
|
||||
/// <para><b>Coordinate mapping:</b> After <c>ImGui.Image</c>, <c>GetItemRectMin()</c>
|
||||
/// returns the actual screen-space top-left of the drawn image (accounting for the
|
||||
/// sub-window title bar, padding, and scrolling). Subtracting that from the raw ImGui
|
||||
/// mouse position gives panel-local pixels directly — no scale factor because the
|
||||
/// image is drawn 1:1.</para>
|
||||
///
|
||||
/// <para><b>V-flip — no extra Y inversion:</b> we pass uv0=(0,1) / uv1=(1,0) so the
|
||||
/// GL bottom-left origin is flipped to top-left on screen. After the flip, screen
|
||||
/// row 0 = panel Y 0 (top of the UI), so the computed Y already matches UiRoot's
|
||||
/// top-left origin — do NOT flip Y again.</para>
|
||||
///
|
||||
/// <para>If <see cref="Selected"/> is non-null a bright-green 2-pixel outline is
|
||||
/// drawn over it using the window draw list.</para>
|
||||
/// </summary>
|
||||
public CanvasInputEvent DrawCanvas(nint panelTex, int width, int height,
|
||||
int windowX, int windowW, int windowY, int windowH)
|
||||
{
|
||||
ImGui.SetNextWindowPos(new Vector2(windowX, windowY), ImGuiCond.FirstUseEver);
|
||||
ImGui.SetNextWindowSize(new Vector2(windowW, windowH), ImGuiCond.FirstUseEver);
|
||||
ImGui.Begin("Canvas");
|
||||
|
||||
var imageSize = new Vector2(width, height);
|
||||
// V-flip: FBO origin is bottom-left; ImGui images expect top-left.
|
||||
// uv0 = bottom-left of texture = top of the panel in screen space.
|
||||
// uv1 = top-right of texture = bottom of the panel in screen space.
|
||||
var uv0 = new Vector2(0f, 1f);
|
||||
var uv1 = new Vector2(1f, 0f);
|
||||
ImGui.Image(panelTex, imageSize, uv0, uv1);
|
||||
|
||||
// rectMin: screen-space top-left of the image AFTER ImGui.Image + any chrome offset.
|
||||
// This is what lets us translate raw mouse screen coords into panel-local pixels.
|
||||
var rectMin = ImGui.GetItemRectMin();
|
||||
|
||||
// ── Selection highlight ───────────────────────────────────────────────
|
||||
var el = Selected;
|
||||
if (el is not null && el.Width > 0f && el.Height > 0f)
|
||||
{
|
||||
var sp = el.ScreenPosition;
|
||||
var p0 = new Vector2(rectMin.X + sp.X, rectMin.Y + sp.Y);
|
||||
var p1 = new Vector2(p0.X + el.Width, p0.Y + el.Height);
|
||||
var dl = ImGui.GetWindowDrawList();
|
||||
dl.AddRect(p0, p1,
|
||||
ImGui.GetColorU32(new Vector4(0.2f, 1f, 0.4f, 1f)),
|
||||
0f, ImDrawFlags.None, 2f);
|
||||
}
|
||||
|
||||
// ── Gather canvas mouse events ────────────────────────────────────────
|
||||
// IsItemHovered is true when the mouse is over the Image item (not just the window).
|
||||
bool hovered = ImGui.IsItemHovered();
|
||||
int mx = 0, my = 0;
|
||||
bool leftDown = false, leftUp = false;
|
||||
int scroll = 0;
|
||||
|
||||
if (hovered)
|
||||
{
|
||||
var mousePos = ImGui.GetMousePos();
|
||||
// Panel-local pixel = mouse offset from the image's screen-space top-left.
|
||||
// Scale is 1:1 (image drawn at full FBO size). Y needs no extra flip — see summary.
|
||||
int ix = (int)(mousePos.X - rectMin.X);
|
||||
int iy = (int)(mousePos.Y - rectMin.Y);
|
||||
// Clamp to image bounds (mouse can be on the image edge pixel).
|
||||
if (ix >= 0 && ix < width && iy >= 0 && iy < height)
|
||||
{
|
||||
mx = ix;
|
||||
my = iy;
|
||||
leftDown = ImGui.IsMouseClicked(ImGuiMouseButton.Left);
|
||||
leftUp = ImGui.IsMouseReleased(ImGuiMouseButton.Left);
|
||||
float wheelY = ImGui.GetIO().MouseWheel;
|
||||
scroll = (int)wheelY; // positive = scroll up
|
||||
}
|
||||
else
|
||||
{
|
||||
// Mouse is over ImGui chrome (title bar, padding) adjacent to image — not over the panel.
|
||||
hovered = false;
|
||||
}
|
||||
}
|
||||
|
||||
ImGui.End();
|
||||
return new CanvasInputEvent(hovered, mx, my, leftDown, leftUp, scroll);
|
||||
}
|
||||
|
||||
// ── Tree ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Draw the "Tree" ImGui window. Clicking a node sets <see cref="Selected"/>.</summary>
|
||||
public void DrawTree(UiElement root, int windowX, int windowY, int windowW, int windowH)
|
||||
{
|
||||
ImGui.SetNextWindowPos(new Vector2(windowX, windowY), ImGuiCond.FirstUseEver);
|
||||
ImGui.SetNextWindowSize(new Vector2(windowW, windowH), ImGuiCond.FirstUseEver);
|
||||
ImGui.Begin("Tree");
|
||||
DrawTreeNode(root);
|
||||
ImGui.End();
|
||||
}
|
||||
|
||||
private void DrawTreeNode(UiElement el)
|
||||
{
|
||||
// Label: EventId (hex) + C# type name, e.g. "0x10000001 [UiDatElement]"
|
||||
string label = $"0x{el.EventId:X8} [{el.GetType().Name}]";
|
||||
|
||||
bool isSelected = ReferenceEquals(el, Selected);
|
||||
bool hasChildren = el.Children.Count > 0;
|
||||
|
||||
ImGuiTreeNodeFlags flags = ImGuiTreeNodeFlags.OpenOnArrow
|
||||
| ImGuiTreeNodeFlags.SpanAvailWidth;
|
||||
if (!hasChildren)
|
||||
flags |= ImGuiTreeNodeFlags.Leaf;
|
||||
if (isSelected)
|
||||
flags |= ImGuiTreeNodeFlags.Selected;
|
||||
|
||||
bool open = ImGui.TreeNodeEx(label, flags);
|
||||
|
||||
// Click on the node label (not the arrow) selects it.
|
||||
if (ImGui.IsItemClicked(ImGuiMouseButton.Left))
|
||||
Selected = el;
|
||||
|
||||
if (open)
|
||||
{
|
||||
foreach (var child in el.Children)
|
||||
DrawTreeNode(child);
|
||||
ImGui.TreePop();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Properties ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Draw the "Properties" ImGui window for <see cref="Selected"/>.</summary>
|
||||
public void DrawProperties(int windowX, int windowY, int windowW, int windowH)
|
||||
{
|
||||
ImGui.SetNextWindowPos(new Vector2(windowX, windowY), ImGuiCond.FirstUseEver);
|
||||
ImGui.SetNextWindowSize(new Vector2(windowW, windowH), ImGuiCond.FirstUseEver);
|
||||
ImGui.Begin("Properties");
|
||||
|
||||
var el = Selected;
|
||||
if (el is null)
|
||||
{
|
||||
ImGui.TextUnformatted("(nothing selected)");
|
||||
ImGui.End();
|
||||
return;
|
||||
}
|
||||
|
||||
ImGui.TextUnformatted($"Id (EventId): 0x{el.EventId:X8}");
|
||||
ImGui.TextUnformatted($"Type: {el.GetType().Name}");
|
||||
ImGui.TextUnformatted($"Name: {el.Name ?? "(null)"}");
|
||||
ImGui.Separator();
|
||||
ImGui.TextUnformatted($"Rect: ({el.Left}, {el.Top}, {el.Width} x {el.Height})");
|
||||
ImGui.TextUnformatted($"Anchors: {el.Anchors}");
|
||||
ImGui.TextUnformatted($"ZOrder: {el.ZOrder}");
|
||||
ImGui.Separator();
|
||||
ImGui.TextUnformatted($"Visible: {el.Visible}");
|
||||
ImGui.TextUnformatted($"Enabled: {el.Enabled}");
|
||||
ImGui.TextUnformatted($"ClickThrough: {el.ClickThrough}");
|
||||
ImGui.TextUnformatted($"Draggable: {el.Draggable}");
|
||||
ImGui.TextUnformatted($"Resizable: {el.Resizable}");
|
||||
ImGui.TextUnformatted($"IsDragSource: {el.IsDragSource}");
|
||||
ImGui.TextUnformatted($"HandlesClick: {el.HandlesClick}");
|
||||
ImGui.TextUnformatted($"Opacity: {el.Opacity:F2}");
|
||||
ImGui.Separator();
|
||||
var sp = el.ScreenPosition;
|
||||
ImGui.TextUnformatted($"ScreenPos: ({sp.X:F1}, {sp.Y:F1})");
|
||||
ImGui.TextUnformatted($"Children: {el.Children.Count}");
|
||||
|
||||
ImGui.End();
|
||||
}
|
||||
}
|
||||
116
src/AcDream.App/Studio/StudioOptions.cs
Normal file
116
src/AcDream.App/Studio/StudioOptions.cs
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
namespace AcDream.App.Studio;
|
||||
|
||||
/// <summary>
|
||||
/// Parsed options for the acdream UI Studio standalone tool.
|
||||
/// Constructed by <see cref="Parse"/> from the command-line tokens that follow
|
||||
/// the <c>ui-studio</c> dispatch token.
|
||||
/// </summary>
|
||||
public sealed record StudioOptions(
|
||||
string DatDir,
|
||||
uint? LayoutId,
|
||||
string? MarkupPath,
|
||||
string? DumpSlug = null,
|
||||
string? DumpFile = null,
|
||||
string? ScreenshotPath = null)
|
||||
{
|
||||
/// <summary>
|
||||
/// Parse studio options from the args that come AFTER the <c>ui-studio</c> token.
|
||||
///
|
||||
/// <para>Positional (first non-flag arg): dat directory. Falls back to
|
||||
/// <c>ACDREAM_DAT_DIR</c> when omitted.</para>
|
||||
/// <para><c>--layout 0xNNNN</c>: hex LayoutDesc dat id to preview.</para>
|
||||
/// <para><c>--markup <path></c>: path to a KSML markup file (Task 6, unsupported for now).</para>
|
||||
/// <para><c>--dump <slug></c>: load a panel from the retail UI dump JSON by slug
|
||||
/// (e.g. <c>inventory</c>, <c>radar</c>, <c>toolbar</c>). Static mockup — no controllers.</para>
|
||||
/// <para><c>--dump-file <path></c>: override the default dump file path
|
||||
/// (<c>docs/research/2026-06-25-retail-ui-layout-dump.json</c> from the solution root).
|
||||
/// Only meaningful when <c>--dump</c> is also given.</para>
|
||||
/// <para><c>--screenshot <path></c>: headless mode — render the loaded panel to a PNG
|
||||
/// at <paramref name="path"/> and exit without showing an interactive window.
|
||||
/// Combines with <c>--dump</c> or <c>--layout</c>.</para>
|
||||
/// <para>When neither <c>--layout</c>, <c>--markup</c>, nor <c>--dump</c> is given the
|
||||
/// default layout <c>0x2100006C</c> (vitals) is used.</para>
|
||||
/// </summary>
|
||||
public static StudioOptions Parse(string[] args)
|
||||
{
|
||||
string? datDir = null;
|
||||
uint? layoutId = null;
|
||||
string? markupPath = null;
|
||||
string? dumpSlug = null;
|
||||
string? dumpFile = null;
|
||||
string? screenshotPath = null;
|
||||
|
||||
for (int i = 0; i < args.Length; i++)
|
||||
{
|
||||
if (args[i] == "--layout" && i + 1 < args.Length)
|
||||
{
|
||||
var raw = args[++i];
|
||||
// Accept 0xNNNN or plain hex.
|
||||
if (raw.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
|
||||
raw = raw[2..];
|
||||
if (uint.TryParse(raw, System.Globalization.NumberStyles.HexNumber, null, out var id))
|
||||
layoutId = id;
|
||||
}
|
||||
else if (args[i] == "--markup" && i + 1 < args.Length)
|
||||
{
|
||||
markupPath = args[++i];
|
||||
}
|
||||
else if (args[i] == "--dump" && i + 1 < args.Length)
|
||||
{
|
||||
dumpSlug = args[++i];
|
||||
}
|
||||
else if (args[i] == "--dump-file" && i + 1 < args.Length)
|
||||
{
|
||||
dumpFile = args[++i];
|
||||
}
|
||||
else if (args[i] == "--screenshot" && i + 1 < args.Length)
|
||||
{
|
||||
screenshotPath = args[++i];
|
||||
}
|
||||
else if (!args[i].StartsWith('-'))
|
||||
{
|
||||
datDir ??= args[i];
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to ACDREAM_DAT_DIR when no positional dat dir was given.
|
||||
datDir ??= Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(datDir))
|
||||
throw new InvalidOperationException(
|
||||
"ui-studio: dat directory required — pass as first arg or set ACDREAM_DAT_DIR.");
|
||||
|
||||
// Default layout: vitals (0x2100006C), unless a dump slug or markup is requested.
|
||||
if (layoutId is null && markupPath is null && dumpSlug is null)
|
||||
layoutId = 0x2100006Cu;
|
||||
|
||||
return new StudioOptions(datDir, layoutId, markupPath, dumpSlug, dumpFile, screenshotPath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve the dump file path for this session:
|
||||
/// <list type="bullet">
|
||||
/// <item><see cref="DumpFile"/> if explicitly set.</item>
|
||||
/// <item>Otherwise <c><solutionRoot>/docs/research/2026-06-25-retail-ui-layout-dump.json</c>.</item>
|
||||
/// </list>
|
||||
/// Returns null when neither the override nor the default file exists.
|
||||
/// </summary>
|
||||
public string? ResolveDumpFile()
|
||||
{
|
||||
if (!string.IsNullOrEmpty(DumpFile))
|
||||
return DumpFile;
|
||||
|
||||
// Walk up from the App binary to the solution root (same approach as
|
||||
// ConformanceDats.SolutionRoot in the test project).
|
||||
var dir = AppContext.BaseDirectory;
|
||||
while (!string.IsNullOrEmpty(dir))
|
||||
{
|
||||
var candidate = Path.Combine(dir, "docs", "research",
|
||||
"2026-06-25-retail-ui-layout-dump.json");
|
||||
if (File.Exists(candidate))
|
||||
return candidate;
|
||||
dir = Path.GetDirectoryName(dir);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
458
src/AcDream.App/Studio/StudioWindow.cs
Normal file
458
src/AcDream.App/Studio/StudioWindow.cs
Normal file
|
|
@ -0,0 +1,458 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.UI;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.Options;
|
||||
using Silk.NET.Input;
|
||||
using Silk.NET.Maths;
|
||||
using Silk.NET.OpenGL;
|
||||
using Silk.NET.Windowing;
|
||||
using SixLabors.ImageSharp;
|
||||
using SixLabors.ImageSharp.PixelFormats;
|
||||
using static Silk.NET.OpenGL.ClearBufferMask;
|
||||
|
||||
namespace AcDream.App.Studio;
|
||||
|
||||
/// <summary>
|
||||
/// Standalone Silk.NET window that boots the production render stack
|
||||
/// (<see cref="RenderBootstrap"/>) and previews a single UI panel
|
||||
/// identified by a <see cref="LayoutSource"/>.
|
||||
///
|
||||
/// Usage: dotnet run -- ui-studio [dat-dir] [--layout 0xNNNN] [--markup path]
|
||||
///
|
||||
/// Task 3 adds an ImGui IDE on top of the panel FBO:
|
||||
/// <list type="bullet">
|
||||
/// <item>Canvas pane — the panel rendered off-screen via <see cref="PanelFbo"/>.</item>
|
||||
/// <item>Tree pane — the element hierarchy; click-to-select.</item>
|
||||
/// <item>Properties pane — geometry/anchors/flags of the selected element.</item>
|
||||
/// <item>Click-to-inspect — a left-click in the canvas selects the topmost
|
||||
/// element under the cursor via <see cref="UiRoot.Pick"/>.</item>
|
||||
/// </list>
|
||||
///
|
||||
/// The window is intentionally thin: no game world, no physics, no streaming —
|
||||
/// just GL + UiHost + the layout under test, identical to how the panel
|
||||
/// appears inside <c>GameWindow</c>.
|
||||
/// </summary>
|
||||
public sealed class StudioWindow : IDisposable
|
||||
{
|
||||
private readonly StudioOptions _opts;
|
||||
|
||||
// Created in OnLoad, released in OnClosing.
|
||||
private IWindow? _window;
|
||||
private DatCollection? _dats;
|
||||
private RenderStack? _stack;
|
||||
private LayoutSource? _source;
|
||||
|
||||
// Task 3 additions.
|
||||
private AcDream.UI.ImGui.ImGuiBootstrapper? _imgui;
|
||||
private PanelFbo? _panelFbo;
|
||||
private StudioInspector? _inspector;
|
||||
private UiElement? _panelRoot; // top-level element added to UiRoot (for hit-test + tree)
|
||||
|
||||
// UX-pass additions: panel picker + current-slug tracking.
|
||||
private string? _currentSlug; // slug of the panel currently displayed (null = non-dump mode)
|
||||
private string? _dumpFile; // resolved dump file path (once, in OnLoad)
|
||||
private IReadOnlyList<string> _dumpSlugs = Array.Empty<string>(); // all slugs from the dump
|
||||
|
||||
// Task 4: sample data table — built once in OnLoad and kept alive for the window's lifetime
|
||||
// so the controller subscriptions (ObjectAdded/ObjectMoved etc.) fire correctly.
|
||||
private AcDream.Core.Items.ClientObjectTable? _objects;
|
||||
|
||||
// Headless screenshot mode: set when --screenshot was passed.
|
||||
// True after the first OnRender fires the screenshot (guard against repeat).
|
||||
private bool _screenshotDone;
|
||||
|
||||
public StudioWindow(StudioOptions opts)
|
||||
{
|
||||
_opts = opts ?? throw new ArgumentNullException(nameof(opts));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Open the window and block until it is closed.
|
||||
/// Mirrors <c>GameWindow.Run()</c>.
|
||||
/// </summary>
|
||||
public void Run()
|
||||
{
|
||||
// Resolve quality settings the same way GameWindow.Run() does
|
||||
// (SettingsStore → QualitySettings.From → WithEnvOverrides).
|
||||
var startupStore = new AcDream.UI.Abstractions.Panels.Settings.SettingsStore(
|
||||
AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath());
|
||||
var startupDisplay = startupStore.LoadDisplay();
|
||||
var startupBase = AcDream.UI.Abstractions.Settings.QualitySettings.From(startupDisplay.Quality);
|
||||
var startupQuality = AcDream.UI.Abstractions.Settings.QualitySettings.WithEnvOverrides(startupBase);
|
||||
|
||||
var options = WindowOptions.Default with
|
||||
{
|
||||
Size = new Vector2D<int>(1280, 720),
|
||||
Title = "acdream UI Studio",
|
||||
API = new GraphicsAPI(
|
||||
ContextAPI.OpenGL,
|
||||
ContextProfile.Core,
|
||||
ContextFlags.ForwardCompatible,
|
||||
new APIVersion(4, 3)),
|
||||
VSync = false,
|
||||
// MSAA from quality preset — must be baked into the GL context at creation.
|
||||
Samples = startupQuality.MsaaSamples,
|
||||
PreferredStencilBufferBits = 8,
|
||||
// Headless screenshot mode: hide the window so no desktop flash occurs.
|
||||
// The GL context is still fully valid on a hidden window; FBO rendering
|
||||
// is off-screen and independent of window visibility.
|
||||
IsVisible = _opts.ScreenshotPath is null,
|
||||
};
|
||||
|
||||
_window = Window.Create(options);
|
||||
_window.Load += OnLoad;
|
||||
_window.Update += OnUpdate;
|
||||
_window.Render += OnRender;
|
||||
_window.Closing += OnClosing;
|
||||
_window.Run();
|
||||
}
|
||||
|
||||
private void OnLoad()
|
||||
{
|
||||
var gl = GL.GetApi(_window!);
|
||||
|
||||
_dats = new DatCollection(_opts.DatDir, DatAccessType.Read);
|
||||
|
||||
// Build QualitySettings for RenderBootstrap (same as Run() above — re-read
|
||||
// after the GL context is confirmed, mirroring GameWindow.OnLoad).
|
||||
var store = new AcDream.UI.Abstractions.Panels.Settings.SettingsStore(
|
||||
AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath());
|
||||
var display = store.LoadDisplay();
|
||||
var quality = AcDream.UI.Abstractions.Settings.QualitySettings.WithEnvOverrides(
|
||||
AcDream.UI.Abstractions.Settings.QualitySettings.From(display.Quality));
|
||||
|
||||
_stack = RenderBootstrap.Create(gl, _dats, new RenderBootstrapOptions(quality));
|
||||
|
||||
// Load the panel described by options and add it to the UI tree.
|
||||
// Task 4b: --dump <slug> uses DumpLayout (static retail mockup, no controllers).
|
||||
// All other modes use LayoutSource + FixtureProvider (production path).
|
||||
//
|
||||
// Fix C: pass the per-element font resolver into LayoutSource so that elements
|
||||
// with a non-zero FontDid get their own dat font at build time. This is wired
|
||||
// ONLY in the studio path; GameWindow's Import calls continue to pass null so the
|
||||
// live game path is provably unchanged (follow-up: GameWindow font-resolver wire-up).
|
||||
_source = new LayoutSource(_dats, _stack.ResolveChrome, _stack.VitalsDatFont,
|
||||
fontResolve: _stack.ResolveDatFont);
|
||||
|
||||
// Resolve the dump file once (used by OnLoad + by LoadDumpPanel at runtime).
|
||||
_dumpFile = _opts.ResolveDumpFile();
|
||||
if (_dumpFile is not null)
|
||||
_dumpSlugs = UiDumpModel.ListSlugs(_dumpFile)
|
||||
.OrderBy(s => s, StringComparer.OrdinalIgnoreCase).ToList();
|
||||
|
||||
UiElement? root;
|
||||
if (_opts.DumpSlug is not null)
|
||||
{
|
||||
if (_dumpFile is null)
|
||||
{
|
||||
Console.Error.WriteLine("[studio] --dump: retail UI dump file not found. " +
|
||||
"Expected docs/research/2026-06-25-retail-ui-layout-dump.json in the source tree, " +
|
||||
"or pass --dump-file <path>.");
|
||||
root = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
root = DumpLayout.Load(_dumpFile, _opts.DumpSlug, _stack.ResolveChrome, out var dumpErr);
|
||||
if (root is null)
|
||||
Console.Error.WriteLine($"[studio] dump load failed: {dumpErr}");
|
||||
else
|
||||
_currentSlug = _opts.DumpSlug;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
root = _source.Load(_opts);
|
||||
if (root is null)
|
||||
Console.Error.WriteLine($"[studio] panel load failed: {_source.LastError}");
|
||||
}
|
||||
|
||||
_panelRoot = root;
|
||||
if (root is not null)
|
||||
{
|
||||
_stack.UiHost.Root.AddChild(root);
|
||||
|
||||
// Task 4: populate the panel with sample data via production controllers,
|
||||
// so inventory / vitals / toolbar panels render with plausible content.
|
||||
// Dump source is static — no FixtureProvider needed.
|
||||
if (_opts.DumpSlug is null && _source.CurrentLayout is not null)
|
||||
{
|
||||
uint layoutId = _opts.LayoutId ?? 0x2100006Cu;
|
||||
_objects = SampleData.BuildObjectTable();
|
||||
FixtureProvider.Populate(layoutId, _source.CurrentLayout, _stack, _objects, _dats);
|
||||
}
|
||||
}
|
||||
|
||||
// Task 3: ImGui IDE — interactive mode only.
|
||||
// Headless screenshot mode needs only PanelFbo; ImGui/inspector/input are skipped.
|
||||
_panelFbo = new PanelFbo(gl);
|
||||
if (_opts.ScreenshotPath is null)
|
||||
{
|
||||
var input = _window!.CreateInput();
|
||||
// Wire KEYBOARD input into UiHost (keyboard coords are not spatial, so no remapping needed).
|
||||
// Do NOT wire mouse here — raw Silk window coords would be offset by the canvas sub-window's
|
||||
// position (tree pane width + ImGui chrome) and land in the wrong panel-local location.
|
||||
// Mouse is forwarded manually from DrawCanvas with correct panel-local mapping below.
|
||||
foreach (var kb in input.Keyboards)
|
||||
_stack.UiHost.WireKeyboard(kb);
|
||||
_imgui = new AcDream.UI.ImGui.ImGuiBootstrapper(gl, _window!, input);
|
||||
_inspector = new StudioInspector();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnUpdate(double dt) { }
|
||||
|
||||
private void OnRender(double dt)
|
||||
{
|
||||
if (_stack is null || _panelFbo is null) return;
|
||||
|
||||
// ── HEADLESS SCREENSHOT PATH ──────────────────────────────────────────────
|
||||
if (_opts.ScreenshotPath is not null)
|
||||
{
|
||||
if (_screenshotDone) return; // fire exactly once
|
||||
_screenshotDone = true;
|
||||
|
||||
// Pick render size from the loaded root's bounds (clamped to sane limits).
|
||||
// Fall back to 1280×720 when the root has no explicit size.
|
||||
int w = 1280, h = 720;
|
||||
if (_panelRoot is not null)
|
||||
{
|
||||
float rw = _panelRoot.Width;
|
||||
float rh = _panelRoot.Height;
|
||||
if (rw >= 1f && rh >= 1f)
|
||||
{
|
||||
w = Math.Clamp((int)rw, 256, 2048);
|
||||
h = Math.Clamp((int)rh, 256, 2048);
|
||||
}
|
||||
}
|
||||
|
||||
// Tick once so widget state is initialised (e.g. bar fills).
|
||||
_stack.UiHost.Tick(dt);
|
||||
|
||||
// Render the panel into the FBO.
|
||||
_panelFbo.Render(w, h, _stack.UiHost);
|
||||
|
||||
// Read back RGBA pixels (FBO origin = bottom-left).
|
||||
byte[] pixels = _panelFbo.ReadColorRgba(w, h);
|
||||
if (pixels.Length == 0)
|
||||
{
|
||||
Console.Error.WriteLine("[studio-screenshot] FBO readback returned no pixels.");
|
||||
_window?.Close();
|
||||
return;
|
||||
}
|
||||
|
||||
// Flip rows vertically: FBO bottom-left → PNG top-left.
|
||||
int stride = w * 4;
|
||||
byte[] flipped = new byte[pixels.Length];
|
||||
for (int row = 0; row < h; row++)
|
||||
{
|
||||
System.Buffer.BlockCopy(pixels, row * stride, flipped, (h - 1 - row) * stride, stride);
|
||||
}
|
||||
|
||||
// Build ImageSharp image and save as PNG.
|
||||
var path = _opts.ScreenshotPath;
|
||||
var dir = Path.GetDirectoryName(path);
|
||||
if (!string.IsNullOrEmpty(dir))
|
||||
Directory.CreateDirectory(dir);
|
||||
|
||||
using var img = Image.LoadPixelData<Rgba32>(flipped, w, h);
|
||||
img.SaveAsPng(path);
|
||||
Console.WriteLine($"[studio-screenshot] wrote {path} ({w}x{h})");
|
||||
|
||||
_window?.Close();
|
||||
return;
|
||||
}
|
||||
|
||||
// ── INTERACTIVE PATH ──────────────────────────────────────────────────────
|
||||
if (_imgui is null || _inspector is null) return;
|
||||
|
||||
var gl = _stack.Gl;
|
||||
int iw = _window!.Size.X;
|
||||
int ih = _window!.Size.Y;
|
||||
|
||||
// 1. Tick the UI widgets (OnRender's own dt — Update + Render fire with the same delta).
|
||||
_stack.UiHost.Tick(dt);
|
||||
|
||||
// 2. Render the panel into the off-screen FBO; get the color texture.
|
||||
// The FBO is the same logical size as the window, so element rects map 1:1 to
|
||||
// FBO pixels — no scale factor needed when displaying the canvas at full size.
|
||||
uint panelTex = _panelFbo.Render(iw, ih, _stack.UiHost);
|
||||
|
||||
// 3. Clear the window back-buffer (the dark ImGui background shows behind panes).
|
||||
gl.ClearColor(0.1f, 0.1f, 0.1f, 1f);
|
||||
gl.Clear(ColorBufferBit | DepthBufferBit);
|
||||
|
||||
// 4. Begin the ImGui frame.
|
||||
_imgui.BeginFrame((float)dt);
|
||||
|
||||
// ── Layout constants (fixed pane arrangement, FirstUseEver) ──────────────
|
||||
// MenuBar: always-on-top main menu bar (~22px) — panel picker lives here so it
|
||||
// is never covered by the floating panes (replaces the old 40px toolbar).
|
||||
// Tree: 280px wide on the left, below menu bar.
|
||||
// Canvas: centre strip between tree and properties.
|
||||
// Props: 340px wide on the right, below menu bar.
|
||||
const int kMenuBarH = 22; // ImGui default main menu bar height
|
||||
const int kTreeW = 280;
|
||||
const int kPropsW = 340;
|
||||
int canvasX = kTreeW;
|
||||
int canvasW = Math.Max(1, iw - kTreeW - kPropsW);
|
||||
int propsX = iw - kPropsW;
|
||||
int paneY = kMenuBarH;
|
||||
int paneH = Math.Max(1, ih - kMenuBarH);
|
||||
|
||||
// 5. Main menu bar — panel picker combo pinned to the window top.
|
||||
// BeginMainMenuBar returns true when the bar is visible (always is); the combo
|
||||
// inside it is always-on-top and is never occluded by Tree/Canvas/Props panes.
|
||||
string? pickedSlug = null;
|
||||
if (ImGuiNET.ImGui.BeginMainMenuBar())
|
||||
{
|
||||
ImGuiNET.ImGui.SetNextItemWidth(300f);
|
||||
string preview = _currentSlug ?? "(none)";
|
||||
if (ImGuiNET.ImGui.BeginCombo("Panel", preview))
|
||||
{
|
||||
foreach (var slug in _dumpSlugs)
|
||||
{
|
||||
bool selected = string.Equals(slug, _currentSlug,
|
||||
System.StringComparison.OrdinalIgnoreCase);
|
||||
if (ImGuiNET.ImGui.Selectable(slug, selected) && !selected)
|
||||
pickedSlug = slug;
|
||||
if (selected)
|
||||
ImGuiNET.ImGui.SetItemDefaultFocus();
|
||||
}
|
||||
ImGuiNET.ImGui.EndCombo();
|
||||
}
|
||||
ImGuiNET.ImGui.EndMainMenuBar();
|
||||
}
|
||||
if (pickedSlug is not null)
|
||||
LoadDumpPanel(pickedSlug);
|
||||
|
||||
// 6. Canvas pane — show the FBO texture; gather canvas mouse events.
|
||||
var canvasEvt = default(CanvasInputEvent);
|
||||
if (panelTex != 0)
|
||||
canvasEvt = _inspector.DrawCanvas(
|
||||
(nint)panelTex, iw, ih,
|
||||
canvasX, canvasW, paneY, paneH);
|
||||
|
||||
// 7. Forward canvas mouse events to the panel UiHost or the inspector tree.
|
||||
//
|
||||
// Coordinate mapping (see StudioInspector.DrawCanvas summary):
|
||||
// panel-local pixel = raw_mouse - ImGui.GetItemRectMin() (1:1 scale, no Y flip)
|
||||
// The image is drawn V-flipped (uv0.Y=1, uv1.Y=0) so screen top = panel Y=0.
|
||||
//
|
||||
// Interact mode (default): canvas mouse events go directly to UiRoot so elements respond.
|
||||
// OnMouseMove + OnMouseDown/Up + OnScroll are all forwarded.
|
||||
// A Console.WriteLine confirms each forwarded left-click for live verification.
|
||||
//
|
||||
// Inspect mode: left-click hit-tests and selects the element in the tree (old behavior).
|
||||
// OnMouseMove is still forwarded so hover/tooltip state in the panel stays live.
|
||||
if (canvasEvt.IsHovered)
|
||||
{
|
||||
int mx = canvasEvt.MouseX;
|
||||
int my = canvasEvt.MouseY;
|
||||
var root = _stack.UiHost.Root;
|
||||
|
||||
// Always forward mouse-move so hover highlights / tooltips in the panel work.
|
||||
root.OnMouseMove(mx, my);
|
||||
|
||||
if (_inspector.InteractMode)
|
||||
{
|
||||
// ── Interact: live panel interaction ──────────────────────────────
|
||||
if (canvasEvt.LeftDown)
|
||||
{
|
||||
Console.WriteLine($"[studio] canvas click → panel ({mx}, {my})");
|
||||
root.OnMouseDown(UiMouseButton.Left, mx, my);
|
||||
}
|
||||
if (canvasEvt.LeftUp)
|
||||
root.OnMouseUp(UiMouseButton.Left, mx, my);
|
||||
if (canvasEvt.ScrollDelta != 0)
|
||||
root.OnScroll(canvasEvt.ScrollDelta);
|
||||
}
|
||||
else
|
||||
{
|
||||
// ── Inspect: click selects an element in the tree ─────────────────
|
||||
if (canvasEvt.LeftDown)
|
||||
{
|
||||
var hit = root.Pick(mx, my);
|
||||
if (hit is not null)
|
||||
_inspector.Selected = hit;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 8. Element tree pane.
|
||||
if (_panelRoot is not null)
|
||||
_inspector.DrawTree(_panelRoot, 0, paneY, kTreeW, paneH);
|
||||
|
||||
// 9. Properties pane.
|
||||
_inspector.DrawProperties(propsX, paneY, kPropsW, paneH);
|
||||
|
||||
// 9. Finalise ImGui and flush draw data to the window.
|
||||
_imgui.Render();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Load a different dump panel at runtime (no relaunch required).
|
||||
/// Removes the current <see cref="_panelRoot"/> from the UI tree,
|
||||
/// loads the named slug from the dump, and installs the new root.
|
||||
/// Resets <see cref="StudioInspector.Selected"/> to null.
|
||||
/// No-op when the dump file is not available or the slug fails to load.
|
||||
/// </summary>
|
||||
public void LoadDumpPanel(string slug)
|
||||
{
|
||||
if (_stack is null || _inspector is null) return;
|
||||
if (_dumpFile is null)
|
||||
{
|
||||
Console.Error.WriteLine("[studio] LoadDumpPanel: dump file not available.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove the existing panel root from the tree.
|
||||
if (_panelRoot is not null)
|
||||
{
|
||||
_stack.UiHost.Root.RemoveChild(_panelRoot);
|
||||
_panelRoot = null;
|
||||
}
|
||||
|
||||
// Load the new panel.
|
||||
var newRoot = DumpLayout.Load(_dumpFile, slug, _stack.ResolveChrome, out var err);
|
||||
if (newRoot is null)
|
||||
{
|
||||
Console.Error.WriteLine($"[studio] LoadDumpPanel('{slug}') failed: {err}");
|
||||
_currentSlug = null;
|
||||
return;
|
||||
}
|
||||
|
||||
_stack.UiHost.Root.AddChild(newRoot);
|
||||
_panelRoot = newRoot;
|
||||
_currentSlug = slug;
|
||||
_inspector.Selected = null;
|
||||
}
|
||||
|
||||
private void OnClosing()
|
||||
{
|
||||
_imgui?.Dispose();
|
||||
_panelFbo?.Dispose();
|
||||
_imgui = null;
|
||||
_panelFbo = null;
|
||||
_stack?.Dispose(); // whole render stack: dispatcher/mesh/textures/shader/ubo/uihost
|
||||
_dats?.Dispose();
|
||||
_dats = null;
|
||||
_stack = null;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_imgui?.Dispose();
|
||||
_panelFbo?.Dispose();
|
||||
_imgui = null;
|
||||
_panelFbo = null;
|
||||
_window?.Dispose();
|
||||
_window = null;
|
||||
// If OnClosing wasn't called (e.g. an exception before Run() completed), dispose the FULL
|
||||
// stack anyway — the review flagged that disposing only UiHost here leaked the rest.
|
||||
_stack?.Dispose();
|
||||
_dats?.Dispose();
|
||||
_dats = null;
|
||||
_stack = null;
|
||||
}
|
||||
}
|
||||
198
src/AcDream.App/Studio/UiDumpModel.cs
Normal file
198
src/AcDream.App/Studio/UiDumpModel.cs
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace AcDream.App.Studio;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// UiDumpModel — POCOs for docs/research/2026-06-25-retail-ui-layout-dump.json
|
||||
//
|
||||
// Schema (v1):
|
||||
// { "version":1, "panels":[ { "id":int, "slug":string, "title":string,
|
||||
// "bucket":string, "parent_slug":string|null,
|
||||
// "width":int, "height":int,
|
||||
// "nodes":[ { "traversal_index":int, "element_id":int,
|
||||
// "layout_id":int, "parent_layout_id":int|null,
|
||||
// "parent_traversal_index":int|null, "base_layout_id":int,
|
||||
// "rect":{x,y,width,height},
|
||||
// "widget_kind":"Group"|"Sprite"|"Button"|"Scrollbar"|"Slider",
|
||||
// "state_set":{ "default_image":{image_id,alpha_image_id}|null,
|
||||
// "states":[{state_id,image:{...}}] }
|
||||
// } ] } ] }
|
||||
//
|
||||
// All ids in the dump are DECIMAL ints (e.g. element_id=268435925 = 0x100001D5,
|
||||
// image_id=100693194 = 0x060074CA). Cast to uint before use in dat/GL APIs.
|
||||
//
|
||||
// Rect coordinates are ABSOLUTE (screen-space origin = panel's design position
|
||||
// in retail layout, NOT relative to the parent). DumpLayout.Load converts them
|
||||
// to parent-relative when building the UiElement tree.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Top-level container for the retail UI layout dump.</summary>
|
||||
public sealed class UiDump
|
||||
{
|
||||
[JsonPropertyName("version")]
|
||||
public int Version { get; set; }
|
||||
|
||||
[JsonPropertyName("panels")]
|
||||
public List<DumpPanel> Panels { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>One panel (window) exported from the retail UI.</summary>
|
||||
public sealed class DumpPanel
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public long Id { get; set; }
|
||||
|
||||
[JsonPropertyName("slug")]
|
||||
public string Slug { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("title")]
|
||||
public string Title { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("bucket")]
|
||||
public string Bucket { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("parent_slug")]
|
||||
public string? ParentSlug { get; set; }
|
||||
|
||||
[JsonPropertyName("width")]
|
||||
public float Width { get; set; }
|
||||
|
||||
[JsonPropertyName("height")]
|
||||
public float Height { get; set; }
|
||||
|
||||
[JsonPropertyName("nodes")]
|
||||
public List<DumpNode> Nodes { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>One element node within a panel's traversal list.</summary>
|
||||
public sealed class DumpNode
|
||||
{
|
||||
[JsonPropertyName("traversal_index")]
|
||||
public int TraversalIndex { get; set; }
|
||||
|
||||
[JsonPropertyName("element_id")]
|
||||
public long ElementId { get; set; }
|
||||
|
||||
[JsonPropertyName("layout_id")]
|
||||
public long LayoutId { get; set; }
|
||||
|
||||
[JsonPropertyName("parent_layout_id")]
|
||||
public long? ParentLayoutId { get; set; }
|
||||
|
||||
[JsonPropertyName("parent_traversal_index")]
|
||||
public int? ParentTraversalIndex { get; set; }
|
||||
|
||||
[JsonPropertyName("base_layout_id")]
|
||||
public long BaseLayoutId { get; set; }
|
||||
|
||||
[JsonPropertyName("rect")]
|
||||
public DumpRect Rect { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("widget_kind")]
|
||||
public string WidgetKind { get; set; } = "Group";
|
||||
|
||||
[JsonPropertyName("state_set")]
|
||||
public DumpStateSet StateSet { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>Absolute screen-space rect (see comment above — must subtract parent rect for UiElement).</summary>
|
||||
public sealed class DumpRect
|
||||
{
|
||||
[JsonPropertyName("x")]
|
||||
public float X { get; set; }
|
||||
|
||||
[JsonPropertyName("y")]
|
||||
public float Y { get; set; }
|
||||
|
||||
[JsonPropertyName("width")]
|
||||
public float Width { get; set; }
|
||||
|
||||
[JsonPropertyName("height")]
|
||||
public float Height { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>State set for a node — default image plus per-state overrides.</summary>
|
||||
public sealed class DumpStateSet
|
||||
{
|
||||
[JsonPropertyName("default_image")]
|
||||
public DumpImage? DefaultImage { get; set; }
|
||||
|
||||
[JsonPropertyName("states")]
|
||||
public List<DumpState> States { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>Image reference (RenderSurface dat id + optional separate alpha surface).</summary>
|
||||
public sealed class DumpImage
|
||||
{
|
||||
[JsonPropertyName("image_id")]
|
||||
public long ImageId { get; set; }
|
||||
|
||||
[JsonPropertyName("alpha_image_id")]
|
||||
public long? AlphaImageId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>A named state override.</summary>
|
||||
public sealed class DumpState
|
||||
{
|
||||
[JsonPropertyName("state_id")]
|
||||
public int StateId { get; set; }
|
||||
|
||||
[JsonPropertyName("image")]
|
||||
public DumpImage Image { get; set; } = new();
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Helper statics
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Parsing helpers for the retail UI dump JSON.
|
||||
/// </summary>
|
||||
public static class UiDumpModel
|
||||
{
|
||||
private static readonly JsonSerializerOptions _opts = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
AllowTrailingCommas = true,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
};
|
||||
|
||||
/// <summary>Parse the full dump from a file path. Returns null on failure.</summary>
|
||||
public static UiDump? Parse(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var stream = File.OpenRead(path);
|
||||
return JsonSerializer.Deserialize<UiDump>(stream, _opts);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Return the list of slugs in the dump (for smoke-testing every panel).
|
||||
/// Returns an empty list if the file cannot be parsed.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<string> ListSlugs(string path)
|
||||
{
|
||||
var dump = Parse(path);
|
||||
if (dump is null) return Array.Empty<string>();
|
||||
return dump.Panels.Select(p => p.Slug).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pick the sprite id to use for a node: prefer default_image.image_id;
|
||||
/// fall back to states[0].image.image_id; return 0 if neither exists.
|
||||
/// </summary>
|
||||
public static uint PickImageId(DumpNode node)
|
||||
{
|
||||
if (node.StateSet.DefaultImage is { ImageId: > 0 } di)
|
||||
return (uint)di.ImageId;
|
||||
if (node.StateSet.States.Count > 0 && node.StateSet.States[0].Image.ImageId > 0)
|
||||
return (uint)node.StateSet.States[0].Image.ImageId;
|
||||
return 0u;
|
||||
}
|
||||
}
|
||||
11
src/AcDream.App/UI/IUiViewportRenderer.cs
Normal file
11
src/AcDream.App/UI/IUiViewportRenderer.cs
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
namespace AcDream.App.UI;
|
||||
|
||||
/// <summary>Renders a 3-D mini-scene into an off-screen buffer and returns the GL color-texture
|
||||
/// handle. Called by the per-frame pre-UI hook (GameWindow), NOT from UiViewport.OnDraw. Implemented
|
||||
/// by PaperdollViewportRenderer in AcDream.App.Rendering. Intra-App decoupling so the UI widget
|
||||
/// doesn't depend on WbDrawDispatcher/GameWindow.</summary>
|
||||
public interface IUiViewportRenderer
|
||||
{
|
||||
/// <summary>Render at (width,height); return the color-texture GL handle, or 0 if nothing rendered.</summary>
|
||||
uint Render(int width, int height);
|
||||
}
|
||||
182
src/AcDream.App/UI/Layout/CharacterController.cs
Normal file
182
src/AcDream.App/UI/Layout/CharacterController.cs
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using AcDream.App.UI;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Per-window controller for the Character window (LayoutDesc 0x2100002E, gmCharacterInfoUI).
|
||||
///
|
||||
/// <para>Retail fills a single scrollable <c>UIElement_Text</c> element (id 0x1000011d,
|
||||
/// <c>m_pMainText</c>) by calling a sequence of Update* methods that each
|
||||
/// <c>AppendStringInfo</c> into the same element. This controller replicates that report
|
||||
/// structure using acdream's <see cref="UiText.LinesProvider"/>.</para>
|
||||
///
|
||||
/// <para>Section order (ported from <c>gmCharacterInfoUI::Update</c> 0x004ba790):
|
||||
/// <list type="number">
|
||||
/// <item>Birth / age / deaths (<c>UpdatePlayerBirthAgeDeaths</c> 0x004b8cb0)</item>
|
||||
/// <item>Vitals / endurance (<c>UpdateEnduranceInfo</c> 0x004b8eb0)</item>
|
||||
/// <item>Innate attributes (<c>UpdateInnateAttributeInfo</c> 0x004b87e0):
|
||||
/// InqAttribute 1,2,4,3,5,6 = Strength, Endurance, Quickness, Coordination, Focus, Self</item>
|
||||
/// <item>Skills summary (<c>UpdateFakeSkills</c> 0x004b8930)</item>
|
||||
/// <item>Augmentations (<c>UpdateAugmentations</c> 0x004b9000)</item>
|
||||
/// <item>Burden / load (<c>UpdateLoad</c> 0x004b8a20)</item>
|
||||
/// </list>
|
||||
/// Each section is separated by a blank line (retail: <c>AppendStringInfo(m_pMainText, &var_120)</c>
|
||||
/// with an empty StringInfo between sections).</para>
|
||||
///
|
||||
/// <para>StringInfo resolution is NOT ported — the full dat string-table lookup is a
|
||||
/// later refinement. For this pilot, labels are composed directly from the well-known
|
||||
/// AC attribute / stat names confirmed in the decomp string literals and data identifiers.</para>
|
||||
/// </summary>
|
||||
public static class CharacterController
|
||||
{
|
||||
/// <summary>Dat element id for the main report text (m_pMainText). Confirmed in
|
||||
/// <c>gmCharacterInfoUI::PostInit</c> 0x004b86f0: <c>GetChildRecursive(this, 0x1000011d)</c>.</summary>
|
||||
public const uint MainTextId = 0x1000011du;
|
||||
|
||||
/// <summary>White body text — matches retail's default UIElement_Text foreground.</summary>
|
||||
private static readonly Vector4 BodyColor = new(1f, 1f, 1f, 1f);
|
||||
|
||||
/// <summary>Yellow section header — retail uses a different StringInfo color per section header.</summary>
|
||||
private static readonly Vector4 HeaderColor = new(1f, 0.85f, 0.35f, 1f);
|
||||
|
||||
/// <summary>
|
||||
/// Bind the character report text element found in <paramref name="layout"/> to
|
||||
/// <paramref name="data"/>. The report is regenerated each frame via
|
||||
/// <see cref="UiText.LinesProvider"/> so the Studio or a live session can push a fresh
|
||||
/// <see cref="CharacterSheet"/> without re-binding.
|
||||
///
|
||||
/// <para>The element must resolve as a <see cref="UiText"/> (Type 12 in the dat).
|
||||
/// If 0x1000011d is absent from the layout the bind is a no-op — partial layouts
|
||||
/// (e.g. test fakes) don't cause errors.</para>
|
||||
/// </summary>
|
||||
/// <param name="layout">Imported 0x2100002E layout tree.</param>
|
||||
/// <param name="data">Provider returning the current <see cref="CharacterSheet"/>.</param>
|
||||
/// <param name="datFont">Retail dat font forwarded from the render stack. May be null
|
||||
/// (falls back to the BitmapFont debug path).</param>
|
||||
public static void Bind(
|
||||
ImportedLayout layout,
|
||||
Func<CharacterSheet> data,
|
||||
UiDatFont? datFont = null)
|
||||
{
|
||||
// Retail's gmCharacterInfoUI CREATES m_pMainText (0x1000011d) at RUNTIME — it is NOT a static
|
||||
// element in any LayoutDesc (confirmed: acdream's dat-import of 0x2100002E AND 0x2100006E both
|
||||
// lack it; only a runtime UI-tree capture has it). So replicate that: build the report text
|
||||
// element ourselves and place it in the panel body, exactly as the gm*UI does at runtime.
|
||||
var root = layout.Root;
|
||||
if (root is null) return;
|
||||
|
||||
var text = new UiText
|
||||
{
|
||||
EventId = MainTextId,
|
||||
Name = "m_pMainText",
|
||||
Left = 12f,
|
||||
Top = 44f, // below the window header row
|
||||
Width = System.Math.Max(40f, root.Width - 24f),
|
||||
Height = System.Math.Max(40f, root.Height - 56f),
|
||||
Anchors = AnchorEdges.None,
|
||||
ZOrder = 1_000_000, // draw above the static chrome
|
||||
DatFont = datFont,
|
||||
ClickThrough = false,
|
||||
};
|
||||
text.LinesProvider = () => BuildReport(data());
|
||||
root.AddChild(text);
|
||||
}
|
||||
|
||||
// ── Report builder ────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Build the complete character report as a line list.
|
||||
/// Order mirrors <c>gmCharacterInfoUI::Update</c> (0x004ba790).
|
||||
/// </summary>
|
||||
private static IReadOnlyList<UiText.Line> BuildReport(CharacterSheet s)
|
||||
{
|
||||
var lines = new List<UiText.Line>();
|
||||
|
||||
// ── Section 1: Birth / age / deaths (UpdatePlayerBirthAgeDeaths 0x004b8cb0) ──
|
||||
// Retail InqInt(0x62) = DateOfBirth (unix timestamp); InqInt(0x7d) = TotalPlayTime;
|
||||
// InqInt(0x2b) = NumDeaths.
|
||||
AppendHeader(lines, s.Name);
|
||||
Append(lines, $"Level {s.Level}");
|
||||
if (s.Race is not null)
|
||||
Append(lines, s.Race);
|
||||
if (s.Heritage is not null)
|
||||
Append(lines, s.Heritage);
|
||||
if (s.Title is not null)
|
||||
Append(lines, s.Title);
|
||||
AppendBlank(lines);
|
||||
|
||||
if (s.BirthDate is not null)
|
||||
Append(lines, $"Birth: {s.BirthDate}");
|
||||
if (s.PlayTime is not null)
|
||||
Append(lines, $"Age: {s.PlayTime}");
|
||||
Append(lines, $"Deaths: {s.Deaths}");
|
||||
AppendBlank(lines);
|
||||
|
||||
// ── Section 2: Vitals / endurance (UpdateEnduranceInfo 0x004b8eb0) ──
|
||||
// Retail InqAttribute(1) = Strength base, InqAttribute(2) = Endurance base.
|
||||
// Computes max-stamina tier and max-health tier from (Str+End) and (End+2*End) sums.
|
||||
AppendHeader(lines, "Vitals");
|
||||
Append(lines, $"Health: {s.HealthCurrent} / {s.HealthMax}");
|
||||
Append(lines, $"Stamina: {s.StaminaCurrent} / {s.StaminaMax}");
|
||||
Append(lines, $"Mana: {s.ManaCurrent} / {s.ManaMax}");
|
||||
AppendBlank(lines);
|
||||
|
||||
// ── Section 3: Innate attributes (UpdateInnateAttributeInfo 0x004b87e0) ──
|
||||
// InqAttribute order: 1,2,4,3,5,6 = Strength, Endurance, Quickness, Coordination,
|
||||
// Focus, Self (confirmed from the AddVariable_Int sequence in the decomp).
|
||||
AppendHeader(lines, "Attributes");
|
||||
Append(lines, $"Strength: {s.Strength}");
|
||||
Append(lines, $"Endurance: {s.Endurance}");
|
||||
Append(lines, $"Quickness: {s.Quickness}");
|
||||
Append(lines, $"Coordination: {s.Coordination}");
|
||||
Append(lines, $"Focus: {s.Focus}");
|
||||
Append(lines, $"Self: {s.Self}");
|
||||
AppendBlank(lines);
|
||||
|
||||
// ── Section 4: Skills summary (UpdateFakeSkills 0x004b8930) ──
|
||||
// Retail InqInt(0xb5) = SkillCredits remaining; InqInt(0xc0) = AvailableSkillCredits.
|
||||
AppendHeader(lines, "Skills");
|
||||
Append(lines, $"Unspent skill credits: {s.UnspentSkillCredits}");
|
||||
if (s.SpecializedSkillCredits > 0)
|
||||
Append(lines, $"Specialized credits: {s.SpecializedSkillCredits}");
|
||||
AppendBlank(lines);
|
||||
|
||||
// ── Section 5: Augmentations (UpdateAugmentations 0x004b9000) ──
|
||||
// Retail InqInt(0x162) = AugmentationStat; string-switch on value 1..0xb + Unknown.
|
||||
AppendHeader(lines, "Augmentations");
|
||||
if (s.AugmentationName is not null)
|
||||
Append(lines, s.AugmentationName);
|
||||
else
|
||||
Append(lines, "None");
|
||||
AppendBlank(lines);
|
||||
|
||||
// ── Section 6: Burden / load (UpdateLoad 0x004b8a20) ──
|
||||
// Retail InqLoad → EncumbranceCapacity(Strength, AugEncumbrance).
|
||||
// When load >= 1.0 (overloaded): shows "X burden over capacity; Y% speed penalty".
|
||||
// When aug > 0: shows "N augmentations (X.X% bonus)".
|
||||
AppendHeader(lines, "Encumbrance");
|
||||
Append(lines, $"Burden: {s.BurdenCurrent}");
|
||||
Append(lines, $"Capacity: {s.BurdenMax}");
|
||||
if (s.BurdenMax > 0)
|
||||
{
|
||||
int pct = (int)Math.Round(100.0 * s.BurdenCurrent / s.BurdenMax);
|
||||
Append(lines, $"Load: {pct}%");
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
// ── Line helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
private static void AppendHeader(List<UiText.Line> lines, string text)
|
||||
=> lines.Add(new UiText.Line(text, HeaderColor));
|
||||
|
||||
private static void Append(List<UiText.Line> lines, string text)
|
||||
=> lines.Add(new UiText.Line(text, BodyColor));
|
||||
|
||||
private static void AppendBlank(List<UiText.Line> lines)
|
||||
=> lines.Add(new UiText.Line(string.Empty, BodyColor));
|
||||
}
|
||||
137
src/AcDream.App/UI/Layout/CharacterSheet.cs
Normal file
137
src/AcDream.App/UI/Layout/CharacterSheet.cs
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
using System;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot of all character data the Character window report uses.
|
||||
/// Passed to <see cref="CharacterController.Bind"/> as a provider delegate
|
||||
/// so the window can be rebound to live data in GameWindow or a static
|
||||
/// fixture in the UI Studio.
|
||||
///
|
||||
/// <para>Field names and retail property ids confirmed from
|
||||
/// <c>gmCharacterInfoUI::UpdatePlayerBirthAgeDeaths</c> (0x004b8cb0),
|
||||
/// <c>UpdateEnduranceInfo</c> (0x004b8eb0),
|
||||
/// <c>UpdateInnateAttributeInfo</c> (0x004b87e0),
|
||||
/// <c>UpdateFakeSkills</c> (0x004b8930),
|
||||
/// <c>UpdateAugmentations</c> (0x004b9000), and
|
||||
/// <c>UpdateLoad</c> (0x004b8a20).</para>
|
||||
/// </summary>
|
||||
public sealed class CharacterSheet
|
||||
{
|
||||
// ── Identity ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Character name (first line of the report).</summary>
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>Character level.</summary>
|
||||
public int Level { get; init; }
|
||||
|
||||
/// <summary>Race string, e.g. "Aluvian". Null = omit.</summary>
|
||||
public string? Race { get; init; }
|
||||
|
||||
/// <summary>Heritage string, e.g. "Aluvian Heritage". Null = omit.</summary>
|
||||
public string? Heritage { get; init; }
|
||||
|
||||
/// <summary>Title string, e.g. "the Adventurer". Null = omit.</summary>
|
||||
public string? Title { get; init; }
|
||||
|
||||
// ── Experience / PK (gmStatManagementUI::UpdateExperience 0x004f0a70,
|
||||
// UpdatePKStatus 0x004f00a0) — the Attributes-tab header strip ──────────
|
||||
|
||||
/// <summary>Total accrued experience (retail PropertyInt64 1). Header value
|
||||
/// element 0x10000235 (m_pTotalXPText).</summary>
|
||||
public long TotalXp { get; init; }
|
||||
|
||||
/// <summary>Experience remaining to the next level. Header value element
|
||||
/// 0x10000238 (m_pXPToLevelText).</summary>
|
||||
public long XpToNextLevel { get; init; }
|
||||
|
||||
/// <summary>XP-to-next-level meter fill, 0..1 (retail (cur−base)/(cap−base)).
|
||||
/// Drives the header meter 0x10000236 (m_pXPToLevelMeter).</summary>
|
||||
public float XpFraction { get; init; }
|
||||
|
||||
/// <summary>PK status display string, e.g. "Non-Player Killer". Header element
|
||||
/// 0x10000233 (m_pPKStatusText). Null = omit.</summary>
|
||||
public string? PkStatus { get; init; }
|
||||
|
||||
// ── Birth / age / deaths (UpdatePlayerBirthAgeDeaths 0x004b8cb0) ─────────
|
||||
|
||||
/// <summary>Formatted birth date string (retail InqInt(0x62) → strftime).
|
||||
/// Null = omit the birth line.</summary>
|
||||
public string? BirthDate { get; init; }
|
||||
|
||||
/// <summary>Formatted play-time duration (retail InqInt(0x7d) → QueryDuration).
|
||||
/// Null = omit the age line.</summary>
|
||||
public string? PlayTime { get; init; }
|
||||
|
||||
/// <summary>Total deaths (retail InqInt(0x2b) = NumDeaths).</summary>
|
||||
public int Deaths { get; init; }
|
||||
|
||||
// ── Vitals (UpdateEnduranceInfo 0x004b8eb0) ─────────────────────────────
|
||||
|
||||
public int HealthCurrent { get; init; }
|
||||
public int HealthMax { get; init; }
|
||||
public int StaminaCurrent { get; init; }
|
||||
public int StaminaMax { get; init; }
|
||||
public int ManaCurrent { get; init; }
|
||||
public int ManaMax { get; init; }
|
||||
|
||||
// ── Innate attributes (UpdateInnateAttributeInfo 0x004b87e0) ────────────
|
||||
// InqAttribute order: 1,2,4,3,5,6 = Strength, Endurance, Quickness, Coordination, Focus, Self.
|
||||
|
||||
public int Strength { get; init; }
|
||||
public int Endurance { get; init; }
|
||||
public int Quickness { get; init; }
|
||||
public int Coordination { get; init; }
|
||||
public int Focus { get; init; }
|
||||
public int Self { get; init; }
|
||||
|
||||
// ── Skills (UpdateFakeSkills 0x004b8930) ────────────────────────────────
|
||||
// Retail InqInt(0xb5) = SkillCredits; InqInt(0xc0) = AvailableSkillCredits.
|
||||
// InqInt(0x18) = available skill credits — footer 0x10000245 in the Attributes tab.
|
||||
|
||||
public int UnspentSkillCredits { get; init; }
|
||||
public int SpecializedSkillCredits { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Available (unspent) skill credits shown in the Attributes tab footer State-A.
|
||||
/// Retail InqInt(0x18) — gmStatManagementUI::DisplayDefaultFooter (0x0049cde0).
|
||||
/// Element 0x10000243 (footer line-1 value in the studio's 3-line layout).
|
||||
/// </summary>
|
||||
public int SkillCredits { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Unassigned (banked) experience points.
|
||||
/// Retail InqInt64(2) — shown in footer line-2 in State-A display.
|
||||
/// Element 0x10000245 (footer line-2 value).
|
||||
/// </summary>
|
||||
public long UnassignedXp { get; init; }
|
||||
|
||||
// ── Attribute raise costs (ExperienceToAttributeLevel, gmAttributeUI::PostInit) ──
|
||||
// Retail formula: ExperienceToAttributeLevel(value + 1) − ExperienceToAttributeLevel(value).
|
||||
// We store pre-computed per-attribute costs for the fixture; cost == 0 → attribute is
|
||||
// at max or not trainable (raise button ghosted/hidden). Ordered to match AttrRows
|
||||
// (Strength, Endurance, Coordination, Quickness, Focus, Self, Health, Stamina, Mana).
|
||||
// Source: gmAttributeUI::AttributeInfoRegion::Update (0x004f1910) + CM_Train::Event_TrainAttribute.
|
||||
|
||||
/// <summary>
|
||||
/// XP cost to raise each of the 9 attributes/vitals by 1, in retail display order:
|
||||
/// [0]=Strength, [1]=Endurance, [2]=Coordination, [3]=Quickness, [4]=Focus, [5]=Self,
|
||||
/// [6]=Health, [7]=Stamina, [8]=Mana.
|
||||
/// Cost 0 means the attribute is at max (raise button ghosted).
|
||||
/// </summary>
|
||||
public long[] AttributeRaiseCosts { get; init; } = Array.Empty<long>();
|
||||
|
||||
// ── Augmentations (UpdateAugmentations 0x004b9000) ─────────────────────
|
||||
// Retail InqInt(0x162) = AugmentationStat; string-switch 1..0xb.
|
||||
|
||||
/// <summary>Augmentation name from the switch in UpdateAugmentations (0x004b9000),
|
||||
/// e.g. "Swords", "Two Handed Weapons". Null = "None".</summary>
|
||||
public string? AugmentationName { get; init; }
|
||||
|
||||
// ── Burden / load (UpdateLoad 0x004b8a20) ───────────────────────────────
|
||||
// Retail InqLoad + EncumbranceCapacity(Strength, AugEncumbrance).
|
||||
|
||||
public int BurdenCurrent { get; init; }
|
||||
public int BurdenMax { get; init; }
|
||||
}
|
||||
1081
src/AcDream.App/UI/Layout/CharacterStatController.cs
Normal file
1081
src/AcDream.App/UI/Layout/CharacterStatController.cs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -45,9 +45,15 @@ public static class DatWidgetFactory
|
|||
/// Returns (0,0,0) when the texture is not yet uploaded.</param>
|
||||
/// <param name="datFont">Retail UI font for the meter's "cur/max" number overlay.
|
||||
/// May be null pre-load — the meter falls back to the debug bitmap font.</param>
|
||||
/// <param name="fontResolve">Optional font resolver: FontDid → <see cref="UiDatFont"/>
|
||||
/// (or null when the font can't be loaded). When non-null, any element whose
|
||||
/// <see cref="ElementInfo.FontDid"/> is non-zero gets ITS OWN dat font applied instead of
|
||||
/// the shared <paramref name="datFont"/> fallback. Null = original behavior (use
|
||||
/// <paramref name="datFont"/> for every element).</param>
|
||||
/// <returns>The widget for this element. Never null — every type produces a widget.</returns>
|
||||
public static UiElement? Create(ElementInfo info,
|
||||
Func<uint, (uint, int, int)> resolve, UiDatFont? datFont)
|
||||
Func<uint, (uint, int, int)> resolve, UiDatFont? datFont,
|
||||
Func<uint, UiDatFont?>? fontResolve = null)
|
||||
{
|
||||
// Retail Type 3 = UIElement_Field (reg :126190), but in acdream's CURRENT layouts
|
||||
// (vitals 0x2100006C / chat 0x21000006) Type-3 elements are sprite-bearing chrome +
|
||||
|
|
@ -60,15 +66,23 @@ public static class DatWidgetFactory
|
|||
// actually carries a factory-built editable Type-3 field (and UiField grows a
|
||||
// background-media draw + an opt-in editable flag at that point). UiField (the widget)
|
||||
// still ships — it just isn't wired into the factory switch yet.
|
||||
// Resolve this element's own dat font if a resolver is provided and the element
|
||||
// has a FontDid. Falls back to the shared datFont when not set (FontDid==0) or
|
||||
// when the resolver returns null (font missing from dats).
|
||||
UiDatFont? elementFont = datFont;
|
||||
if (fontResolve is not null && info.FontDid != 0)
|
||||
elementFont = fontResolve(info.FontDid) ?? datFont;
|
||||
|
||||
UiElement e = info.Type switch
|
||||
{
|
||||
1 => new UiButton(info, resolve), // UIElement_Button (reg :125828)
|
||||
6 => new UiMenu(), // UIElement_Menu (reg :120163)
|
||||
7 => BuildMeter(info, resolve, datFont), // UIElement_Meter
|
||||
11 => new UiScrollbar(), // UIElement_Scrollbar (reg :124137)
|
||||
12 => BuildText(info, resolve), // UIElement_Text (reg :115655)
|
||||
0x10000031u => new UiItemList(resolve), // UIElement_ItemList — toolbar/inventory/paperdoll slots
|
||||
_ => new UiDatElement(info, resolve), // generic fallback (incl. Type 3 chrome/containers)
|
||||
1 => new UiButton(info, resolve), // UIElement_Button (reg :125828)
|
||||
6 => new UiMenu(), // UIElement_Menu (reg :120163)
|
||||
7 => BuildMeter(info, resolve, elementFont), // UIElement_Meter
|
||||
0xD => new UiViewport(), // UIElement_Viewport — 3-D mini-scene blit leaf
|
||||
11 => new UiScrollbar(), // UIElement_Scrollbar (reg :124137)
|
||||
12 => BuildText(info, resolve, elementFont), // UIElement_Text (reg :115655)
|
||||
0x10000031u => new UiItemList(resolve), // UIElement_ItemList — toolbar/inventory/paperdoll slots
|
||||
_ => new UiDatElement(info, resolve), // generic fallback (incl. Type 3 chrome/containers)
|
||||
};
|
||||
|
||||
// Propagate position + size (pixel-exact from the dat).
|
||||
|
|
@ -238,13 +252,63 @@ public static class DatWidgetFactory
|
|||
/// own Direct/Normal media (if any) becomes the background sprite, drawn under the text —
|
||||
/// so a Type-12 element that previously rendered via UiDatElement keeps its sprite. Lines
|
||||
/// are bound later by the controller (LinesProvider). An unbound UiText draws nothing
|
||||
/// because <see cref="UiText.BackgroundColor"/> defaults to transparent.</summary>
|
||||
private static UiText BuildText(ElementInfo info, Func<uint, (uint, int, int)> resolve)
|
||||
/// because <see cref="UiText.BackgroundColor"/> defaults to transparent.
|
||||
///
|
||||
/// <para>
|
||||
/// Justification from the dat (<see cref="ElementInfo.HJustify"/> /
|
||||
/// <see cref="ElementInfo.VJustify"/>) is applied here at build time so that controllers
|
||||
/// that subsequently call <see cref="UiText.Centered"/> / <see cref="UiText.RightAligned"/>
|
||||
/// on dat-origin elements can be simplified. Controllers that <em>explicitly</em> set those
|
||||
/// properties after <see cref="ImportedLayout.FindElement"/> still override the build-time
|
||||
/// defaults — the build-time value is just the starting point, not a lock.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="elementFont">The font to seed on the widget. When a font resolver was
|
||||
/// provided and the element's FontDid resolved successfully, this is that element-specific
|
||||
/// font; otherwise it is the shared global fallback. Controllers that call
|
||||
/// <see cref="ImportedLayout.FindElement"/> and set <see cref="UiText.DatFont"/> afterward
|
||||
/// still override this — the build-time value is just the starting point.</param>
|
||||
private static UiText BuildText(ElementInfo info, Func<uint, (uint, int, int)> resolve,
|
||||
UiDatFont? elementFont = null)
|
||||
{
|
||||
uint bg = info.StateMedia.TryGetValue(
|
||||
!string.IsNullOrEmpty(info.DefaultStateName) ? info.DefaultStateName
|
||||
: info.StateMedia.ContainsKey("Normal") ? "Normal" : "", out var m)
|
||||
? m.File : 0u;
|
||||
return new UiText { BackgroundSprite = bg, SpriteResolve = resolve };
|
||||
|
||||
// Apply horizontal + vertical justification from the dat at build time.
|
||||
// Controllers that call FindElement and set Centered/RightAligned/VerticalJustify
|
||||
// afterward will override these — this is only the dat-driven default.
|
||||
bool centered = info.HJustify == HJustify.Center;
|
||||
bool rightAligned = info.HJustify == HJustify.Right;
|
||||
var vJustify = info.VJustify switch
|
||||
{
|
||||
VJustify.Top => VJustify.Top,
|
||||
VJustify.Bottom => VJustify.Bottom,
|
||||
_ => VJustify.Center,
|
||||
};
|
||||
|
||||
var t = new UiText
|
||||
{
|
||||
BackgroundSprite = bg,
|
||||
SpriteResolve = resolve,
|
||||
Centered = centered,
|
||||
RightAligned = rightAligned,
|
||||
VerticalJustify = vJustify,
|
||||
// Seed the dat-driven font. When a font resolver was supplied and the element
|
||||
// carries a non-zero FontDid, elementFont is the element-specific dat font; otherwise
|
||||
// it is the shared global fallback. Controllers that call FindElement and explicitly
|
||||
// set DatFont afterward STILL override this (backward-compat guarantee).
|
||||
DatFont = elementFont,
|
||||
};
|
||||
|
||||
// Font color from dat property 0x1B (ColorBaseProperty).
|
||||
// When present, seed DefaultColor so controllers that read it don't have to hard-code colors.
|
||||
// Controllers that supply explicit per-line colors via LinesProvider still win — this is only
|
||||
// the build-time default.
|
||||
if (info.FontColor.HasValue)
|
||||
t.DefaultColor = info.FontColor.Value;
|
||||
|
||||
return t;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,20 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Horizontal text justification read from dat property 0x14 (UIElement HorizontalJustification).
|
||||
/// Values match the retail enum: 0=Left, 1=Center, 3/5=Right.
|
||||
/// </summary>
|
||||
public enum HJustify : byte { Left = 0, Center = 1, Right = 2 }
|
||||
|
||||
/// <summary>
|
||||
/// Vertical text justification read from dat property 0x15 (UIElement VerticalJustification).
|
||||
/// Values: 2=Top, 4=Bottom; absent/other = Center.
|
||||
/// </summary>
|
||||
public enum VJustify : byte { Top = 0, Center = 1, Bottom = 2 }
|
||||
|
||||
/// <summary>
|
||||
/// GL-free, dat-free snapshot of a resolved layout element.
|
||||
/// Populated by the LayoutDesc importer from <c>DatReaderWriter.ElementDesc</c>
|
||||
|
|
@ -51,6 +64,30 @@ public sealed class ElementInfo
|
|||
/// </summary>
|
||||
public uint FontDid;
|
||||
|
||||
/// <summary>
|
||||
/// Horizontal text justification from dat <c>Properties[0x14]</c>
|
||||
/// (<c>EnumBaseProperty</c>: 0=Left, 1=Center, 3/5=Right).
|
||||
/// Default is <see cref="HJustify.Center"/> to preserve existing behavior where
|
||||
/// controllers set <c>Centered=true</c> and no property was read.
|
||||
/// </summary>
|
||||
public HJustify HJustify = HJustify.Center;
|
||||
|
||||
/// <summary>
|
||||
/// Vertical text justification from dat <c>Properties[0x15]</c>
|
||||
/// (<c>EnumBaseProperty</c>: 2=Top, 4=Bottom; absent/other = Center).
|
||||
/// Default is <see cref="VJustify.Center"/> to preserve existing behavior.
|
||||
/// </summary>
|
||||
public VJustify VJustify = VJustify.Center;
|
||||
|
||||
/// <summary>
|
||||
/// Font color from dat <c>Properties[0x1B]</c> (<c>ColorBaseProperty</c>, ARGB bytes).
|
||||
/// Null when the dat carries no color for this element; the factory then leaves the
|
||||
/// widget at its default white (<see cref="System.Numerics.Vector4.One"/>).
|
||||
/// Propagated in <see cref="ElementReader.Merge"/> with the same "non-null derived wins"
|
||||
/// rule used for <see cref="FontDid"/> and <see cref="HJustify"/>.
|
||||
/// </summary>
|
||||
public Vector4? FontColor;
|
||||
|
||||
/// <summary>
|
||||
/// Sprite per state: state name → (RenderSurface file id, DrawMode int).
|
||||
/// The <c>""</c> key represents the unnamed DirectState (<c>ElementDesc.StateDesc</c>).
|
||||
|
|
@ -160,6 +197,15 @@ public static class ElementReader
|
|||
ReadOrder = derived.ReadOrder,
|
||||
ZLevel = derived.ZLevel != 0 ? derived.ZLevel : base_.ZLevel,
|
||||
FontDid = derived.FontDid != 0 ? derived.FontDid : base_.FontDid,
|
||||
// HJustify/VJustify: derived wins when it carries an explicit non-Center value
|
||||
// (the dat property was present and read); otherwise inherit the base prototype's value.
|
||||
// Center is the default (= "not set by this element") so Center-derived never overrides
|
||||
// a non-Center base — matching the FontDid "non-zero wins" convention.
|
||||
HJustify = derived.HJustify != HJustify.Center ? derived.HJustify : base_.HJustify,
|
||||
VJustify = derived.VJustify != VJustify.Center ? derived.VJustify : base_.VJustify,
|
||||
// FontColor: derived wins when it has an explicit (non-null) color; otherwise inherit the base.
|
||||
// Null means "dat carried no 0x1B property" — so null-derived does NOT override a non-null base.
|
||||
FontColor = derived.FontColor ?? base_.FontColor,
|
||||
// DefaultStateName: derived wins if set; otherwise inherit the base's default.
|
||||
DefaultStateName = !string.IsNullOrEmpty(derived.DefaultStateName) ? derived.DefaultStateName : base_.DefaultStateName,
|
||||
// Children come from the derived element's own tree, not the base prototype's.
|
||||
|
|
|
|||
|
|
@ -68,25 +68,33 @@ public static class LayoutImporter
|
|||
ElementInfo rootInfo,
|
||||
IEnumerable<ElementInfo> children,
|
||||
Func<uint, (uint, int, int)> resolve,
|
||||
UiDatFont? datFont)
|
||||
UiDatFont? datFont,
|
||||
Func<uint, UiDatFont?>? fontResolve = null)
|
||||
{
|
||||
rootInfo.Children = new List<ElementInfo>(children);
|
||||
return Build(rootInfo, resolve, datFont);
|
||||
return Build(rootInfo, resolve, datFont, fontResolve);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure builder: produce the widget tree from a fully resolved
|
||||
/// <see cref="ElementInfo"/> tree (children already attached).
|
||||
/// </summary>
|
||||
/// <param name="fontResolve">Optional per-element font resolver — FontDid →
|
||||
/// <see cref="UiDatFont"/> (or null if the font can't be loaded). When supplied,
|
||||
/// elements with a non-zero <see cref="ElementInfo.FontDid"/> get their own dat
|
||||
/// font at build time instead of the shared <paramref name="datFont"/> fallback.
|
||||
/// Null preserves the original single-font behavior for all callers that don't
|
||||
/// pass it — no behavior change for the live game path.</param>
|
||||
public static ImportedLayout Build(
|
||||
ElementInfo rootInfo,
|
||||
Func<uint, (uint, int, int)> resolve,
|
||||
UiDatFont? datFont)
|
||||
UiDatFont? datFont,
|
||||
Func<uint, UiDatFont?>? fontResolve = null)
|
||||
{
|
||||
var byId = new Dictionary<uint, UiElement>();
|
||||
// Root is never a Type-12 prototype in practice; fall back to a generic
|
||||
// container if the factory returns null for an exotic root type.
|
||||
var root = BuildWidget(rootInfo, resolve, datFont, byId);
|
||||
var root = BuildWidget(rootInfo, resolve, datFont, fontResolve, byId);
|
||||
if (root is null)
|
||||
{
|
||||
Console.WriteLine($"[D.2b] LayoutImporter: root element 0x{rootInfo.Id:X8} (type {rootInfo.Type}) produced no widget — using empty container fallback.");
|
||||
|
|
@ -99,9 +107,10 @@ public static class LayoutImporter
|
|||
ElementInfo info,
|
||||
Func<uint, (uint, int, int)> resolve,
|
||||
UiDatFont? datFont,
|
||||
Func<uint, UiDatFont?>? fontResolve,
|
||||
Dictionary<uint, UiElement> byId)
|
||||
{
|
||||
var w = DatWidgetFactory.Create(info, resolve, datFont);
|
||||
var w = DatWidgetFactory.Create(info, resolve, datFont, fontResolve);
|
||||
if (w is null) return null; // Type-12 style prototype — skip
|
||||
|
||||
if (info.Id != 0) byId[info.Id] = w;
|
||||
|
|
@ -117,7 +126,33 @@ public static class LayoutImporter
|
|||
{
|
||||
foreach (var child in info.Children)
|
||||
{
|
||||
var cw = BuildWidget(child, resolve, datFont, byId);
|
||||
var cw = BuildWidget(child, resolve, datFont, fontResolve, byId);
|
||||
if (cw is not null) w.AddChild(cw);
|
||||
}
|
||||
}
|
||||
else if (w is UiMeter)
|
||||
{
|
||||
// Fix 5: UiMeter.ConsumesDatChildren=true swallows ALL children, including text
|
||||
// label/value overlays that are separate renderable widgets (not part of the bar
|
||||
// art). BuildMeter in DatWidgetFactory already consumed the Type-3 slice containers
|
||||
// (reads their grandchild sprite ids to populate Back*/Front* properties). The
|
||||
// remaining non-Type-3 children (typically Type-12 UIElement_Text overlays such
|
||||
// as the XP meter's 0x10000237 label + 0x10000238 value) ARE renderable and belong
|
||||
// in the widget tree. We build them here explicitly, registered in byId so
|
||||
// FindElement can locate them, and attached as children of the meter so they render
|
||||
// as overlays at their dat-local coordinates. The controller can then locate these
|
||||
// widgets via FindElement and bind LinesProvider without injecting new runtime nodes.
|
||||
//
|
||||
// Type-3 children are SKIPPED here because BuildMeter already consumed them (they
|
||||
// carry the 3-slice sprite ids, not text content; building them again would
|
||||
// double-draw the bar art). All other child types are built normally.
|
||||
//
|
||||
// Safe for vitals: the health/stamina/mana meters have ONLY Type-3 slice children
|
||||
// (no text children). This loop finds nothing for them → no change to vitals.
|
||||
foreach (var child in info.Children)
|
||||
{
|
||||
if (child.Type == 3) continue; // slice containers: already consumed by BuildMeter
|
||||
var cw = BuildWidget(child, resolve, datFont, fontResolve, byId);
|
||||
if (cw is not null) w.AddChild(cw);
|
||||
}
|
||||
}
|
||||
|
|
@ -177,16 +212,38 @@ public static class LayoutImporter
|
|||
/// Dat shell: load the LayoutDesc, resolve inheritance for every top-level
|
||||
/// element, and build the widget tree. Returns null if the layout is absent
|
||||
/// from the dats.
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Dat UIState visibility model (2026-06-26 audit):</b>
|
||||
/// The dat's <c>ElementDesc.DefaultState</c> field specifies which SPRITE/MEDIA
|
||||
/// state an element starts in (e.g., <c>Normal</c>, <c>Minimized</c>). It does
|
||||
/// NOT encode visibility of sibling Group containers. The <c>StateDesc</c>'s
|
||||
/// <see cref="DatReaderWriter.Enums.IncorporationFlags"/> contains X/Y/Width/Height/
|
||||
/// ZLevel/PassToChildren — there is no Visible flag.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Windows that display multiple sibling Group containers at the same position
|
||||
/// (the character footer's three state-groups; the tab-page content areas) manage
|
||||
/// visibility purely at runtime via C++ controller code. Retail uses
|
||||
/// <c>UIElement::SetState(stateId)</c> on the parent to propagate state, then
|
||||
/// C++ getters access the right sub-group by element id. All groups are shipped
|
||||
/// as visible in the imported widget tree; the relevant controllers
|
||||
/// (<see cref="CharacterStatController"/>) perform the initial show/hide.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="fontResolve">Optional per-element font resolver (see
|
||||
/// <see cref="Build"/> for details). Null = original single-font behavior.</param>
|
||||
public static ImportedLayout? Import(
|
||||
DatCollection dats,
|
||||
uint layoutId,
|
||||
Func<uint, (uint, int, int)> resolve,
|
||||
UiDatFont? datFont)
|
||||
UiDatFont? datFont,
|
||||
Func<uint, UiDatFont?>? fontResolve = null)
|
||||
{
|
||||
var rootInfo = ImportInfos(dats, layoutId);
|
||||
if (rootInfo is null) return null;
|
||||
return Build(rootInfo, resolve, datFont);
|
||||
return Build(rootInfo, resolve, datFont, fontResolve);
|
||||
}
|
||||
|
||||
// ── Inheritance resolution ────────────────────────────────────────────────
|
||||
|
|
@ -255,6 +312,43 @@ public static class LayoutImporter
|
|||
// ZLevel 0 so it escaped). Restore the slot's own frame-layer so the panel sits in
|
||||
// FRONT of the backdrop. (B-Controller debug 2026-06-21; continuation of #145.)
|
||||
result.ZLevel = self.ZLevel;
|
||||
|
||||
// Sub-layout slot sizing: when a sub-layout is mounted into a slot that is TALLER
|
||||
// (or wider) than the sub-layout's own design height, the cascade of Bottom/Right
|
||||
// anchored elements must be resized to fill the slot. Without this step, the
|
||||
// background element (0x10000226 for the Attributes tab, designed at 337px) captures
|
||||
// _amB = slotH - designH = 238 and permanently stays at 337px, and every element
|
||||
// within it with Bottom anchor stays at its design size too (list box at 160px
|
||||
// instead of the correct 398px).
|
||||
//
|
||||
// Retail reference: UIElement::UpdateForParentSizeChange (C++ runtime) propagates a
|
||||
// new parent height down the whole tree, updating each Bottom-anchored child's
|
||||
// rect by maintaining its bottom margin. We replicate that cascade here at import
|
||||
// time on the base-children subtree so the anchor-capture during the first render
|
||||
// frame sees zero (or unchanged) margins — not the spurious "slot bigger than design"
|
||||
// margin.
|
||||
//
|
||||
// Safe guard: only fire when the slot has explicit size AND the base children are
|
||||
// smaller (i.e., this is the "slot bigger than design" case).
|
||||
// Inventory/paperdoll slots are unaffected because their slot H already matches the
|
||||
// sub-layout design H, so no cascade occurs.
|
||||
if (result.Width > 0 && result.Height > 0)
|
||||
{
|
||||
foreach (var child in baseChildren)
|
||||
{
|
||||
var childAnchors = ElementReader.ToAnchors(child.Left, child.Top, child.Right, child.Bottom);
|
||||
const AnchorEdges StretchAll = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right | AnchorEdges.Bottom;
|
||||
if (child.X == 0 && child.Y == 0
|
||||
&& (childAnchors & StretchAll) == StretchAll // full-stretch background
|
||||
&& child.Width <= result.Width
|
||||
&& child.Height > 0 && child.Height < result.Height) // smaller than slot
|
||||
{
|
||||
// Cascade UpdateForParentSizeChange down the subtree: maintain each
|
||||
// Bottom-anchored element's bottom margin while growing the parent height.
|
||||
CascadeHeight(child, child.Height, result.Height);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
|
|
@ -325,6 +419,53 @@ public static class LayoutImporter
|
|||
{
|
||||
info.FontDid = did.Value;
|
||||
}
|
||||
|
||||
if (sd.Properties is not null)
|
||||
{
|
||||
// HorizontalJustification (0x14): EnumBaseProperty.
|
||||
// Retail values: 0=Left, 1=Center, 3=Right, 5=Right (treat 5 as Right per spec).
|
||||
// Only update if still at the default (Center); derived-wins handled in Merge.
|
||||
if (info.HJustify == HJustify.Center
|
||||
&& sd.Properties.TryGetValue(0x14u, out var hRaw)
|
||||
&& hRaw is EnumBaseProperty hEnum)
|
||||
{
|
||||
info.HJustify = hEnum.Value switch
|
||||
{
|
||||
0u => HJustify.Left,
|
||||
1u => HJustify.Center,
|
||||
3u => HJustify.Right,
|
||||
5u => HJustify.Right,
|
||||
_ => HJustify.Center, // unknown → center (safe default)
|
||||
};
|
||||
}
|
||||
|
||||
// VerticalJustification (0x15): EnumBaseProperty.
|
||||
// Retail values: 2=Top, 4=Bottom; absent/other = Center.
|
||||
if (info.VJustify == VJustify.Center
|
||||
&& sd.Properties.TryGetValue(0x15u, out var vRaw)
|
||||
&& vRaw is EnumBaseProperty vEnum)
|
||||
{
|
||||
info.VJustify = vEnum.Value switch
|
||||
{
|
||||
2u => VJustify.Top,
|
||||
4u => VJustify.Bottom,
|
||||
_ => VJustify.Center,
|
||||
};
|
||||
}
|
||||
|
||||
// ColorBaseProperty (0x1B): ARGB bytes → normalized [0,1] Vector4 (R,G,B,A).
|
||||
// Only read when not already set (first dat state wins; Merge propagates from base).
|
||||
if (info.FontColor is null
|
||||
&& sd.Properties.TryGetValue(0x1Bu, out var cRaw)
|
||||
&& cRaw is ColorBaseProperty cProp)
|
||||
{
|
||||
var c = cProp.Value;
|
||||
// ColorARGB stores components as bytes (0–255); normalize to [0,1] for Vector4.
|
||||
// Alpha=0 in the dat typically means fully opaque (retail convention: 0 → 255).
|
||||
float a = c.Alpha == 0 ? 1f : c.Alpha / 255f;
|
||||
info.FontColor = new System.Numerics.Vector4(c.Red / 255f, c.Green / 255f, c.Blue / 255f, a);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Prototype detection helpers ───────────────────────────────────────────
|
||||
|
|
@ -384,4 +525,60 @@ public static class LayoutImporter
|
|||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Sub-layout slot sizing helpers ────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Recursively propagates a parent-height change from <paramref name="oldParentH"/>
|
||||
/// to <paramref name="newParentH"/> through <paramref name="el"/> and all its
|
||||
/// descendants, replicating <c>UIElement::UpdateForParentSizeChange</c> from the
|
||||
/// retail runtime.
|
||||
///
|
||||
/// <para>Three anchor cases for the vertical axis:
|
||||
/// <list type="bullet">
|
||||
/// <item>Top+Bottom (stretch): maintain top margin AND bottom margin; height grows.</item>
|
||||
/// <item>Bottom only (pin-to-bottom, fixed height): maintain bottom margin; Y moves, H unchanged.</item>
|
||||
/// <item>Top only or None: Y and H unchanged (pinned to top with fixed height).</item>
|
||||
/// </list>
|
||||
/// Recurse with the element's new height so grandchildren see the correct parent size.</para>
|
||||
///
|
||||
/// <para>This is called once at import time on base-children that are mounted into a
|
||||
/// slot taller than the sub-layout's design height.</para>
|
||||
/// </summary>
|
||||
private static void CascadeHeight(ElementInfo el, float oldParentH, float newParentH)
|
||||
{
|
||||
float oldH = el.Height;
|
||||
float newH = el.Height;
|
||||
float oldY = el.Y;
|
||||
|
||||
var anchors = ElementReader.ToAnchors(el.Left, el.Top, el.Right, el.Bottom);
|
||||
|
||||
bool hasTop = (anchors & AnchorEdges.Top) != 0;
|
||||
bool hasBottom = (anchors & AnchorEdges.Bottom) != 0;
|
||||
|
||||
if (hasTop && hasBottom)
|
||||
{
|
||||
// Stretch: maintain both top and bottom margins.
|
||||
// amT = el.Y (pinned to top, unchanged)
|
||||
// amB = oldParentH - (el.Y + el.H)
|
||||
float amB = oldParentH - (el.Y + el.Height);
|
||||
newH = newParentH - el.Y - amB;
|
||||
if (newH < 0f) newH = 0f;
|
||||
el.Height = newH;
|
||||
}
|
||||
else if (hasBottom && !hasTop)
|
||||
{
|
||||
// Pin to bottom: maintain bottom margin, height fixed, Y moves.
|
||||
// amB = oldParentH - (el.Y + el.H)
|
||||
float amB = oldParentH - (el.Y + el.Height);
|
||||
float newY = newParentH - amB - el.Height;
|
||||
el.Y = newY;
|
||||
// Height unchanged; newH = oldH for recursion.
|
||||
}
|
||||
// else: Top-only or None — element is top-pinned with fixed height; nothing moves.
|
||||
|
||||
// Recurse into children with the element's new height as their parent.
|
||||
foreach (var child in el.Children)
|
||||
CascadeHeight(child, oldH, newH);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,32 @@ namespace AcDream.App.UI.Layout;
|
|||
/// </summary>
|
||||
public sealed class PaperdollController : IItemListDragHandler
|
||||
{
|
||||
// ── Slots-toggle public surface ───────────────────────────────────────────────────────────────
|
||||
/// <summary>
|
||||
/// The 9 armor-slot element-ids whose Visible state the Slots button (0x100005BE) toggles.
|
||||
/// Doll-view: hidden. Slot-view: shown. Source: gmPaperDollUI::ListenToElementMessage decomp
|
||||
/// 175674-175706 — these are the only 9 ids that element flips; the other ~12 equip slots
|
||||
/// (jewelry, weapon, wrists, feet…) are NON-armor and remain visible in both views.
|
||||
/// </summary>
|
||||
public static readonly uint[] ArmorSlotElementIds =
|
||||
{
|
||||
0x100005abu, 0x100005acu, 0x100005adu, 0x100005aeu, 0x100005afu,
|
||||
0x100005b0u, 0x100005b1u, 0x100005b2u, 0x100005b3u,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// View-state model for the Slots toggle (pure logic, no UI coupling).
|
||||
/// Default is doll-view (SlotView == false): the 3-D character is visible,
|
||||
/// the 9 armor slots are hidden. Calling Toggle() alternates between views.
|
||||
/// </summary>
|
||||
public sealed class PaperdollViewState
|
||||
{
|
||||
public bool SlotView { get; private set; } // false = doll-view (default)
|
||||
public bool DollVisible => !SlotView;
|
||||
public bool ArmorSlotsVisible => SlotView;
|
||||
public void Toggle() => SlotView = !SlotView;
|
||||
}
|
||||
|
||||
// WEAPON_READY_SLOT_LOC (acclient.h:3235): the weapon-hand doll slot accepts any wieldable weapon.
|
||||
private const EquipMask WeaponSlotMask =
|
||||
EquipMask.MeleeWeapon | EquipMask.MissileWeapon | EquipMask.Held | EquipMask.TwoHanded;
|
||||
|
|
@ -51,10 +77,15 @@ public sealed class PaperdollController : IItemListDragHandler
|
|||
private readonly Action<uint, uint>? _sendWield; // (itemGuid, equipMask) → GetAndWieldItem 0x001A
|
||||
private readonly List<(EquipMask Mask, UiItemList List)> _slots = new();
|
||||
|
||||
// ── Slots-toggle state ────────────────────────────────────────────────────────────────────────
|
||||
private readonly PaperdollViewState _viewState = new();
|
||||
private readonly List<UiItemList> _armorSlots = new();
|
||||
private UiElement? _dollViewport; // UiViewport wired in Slice 3; UiElement? keeps this task independent
|
||||
|
||||
private PaperdollController(
|
||||
ImportedLayout layout, ClientObjectTable objects, Func<uint> playerGuid,
|
||||
Func<ItemType, uint, uint, uint, uint, uint> iconIds, Action<uint, uint>? sendWield,
|
||||
uint emptySlotSprite)
|
||||
uint emptySlotSprite, UiDatFont? datFont)
|
||||
{
|
||||
_objects = objects; _playerGuid = playerGuid; _iconIds = iconIds; _sendWield = sendWield;
|
||||
|
||||
|
|
@ -80,14 +111,37 @@ public sealed class PaperdollController : IItemListDragHandler
|
|||
_objects.ObjectRemoved += OnObjectChanged;
|
||||
_objects.ObjectUpdated += OnObjectChanged;
|
||||
|
||||
// ── Slots-toggle wiring ───────────────────────────────────────────────────────────────────
|
||||
foreach (var id in ArmorSlotElementIds)
|
||||
if (layout.FindElement(id) is UiItemList armor) _armorSlots.Add(armor);
|
||||
|
||||
_dollViewport = layout.FindElement(0x100001D5u); // doll viewport (may be null until Slice 3)
|
||||
|
||||
var slotsBtnEl = layout.FindElement(0x100005BEu);
|
||||
if (slotsBtnEl is UiButton slotsBtn)
|
||||
{
|
||||
slotsBtn.OnClick = () => { _viewState.Toggle(); ApplyView(); };
|
||||
// The dat element has no face sprite, so without a label the button is invisible. Give it a
|
||||
// left-aligned WHITE "Slots" caption (retail is white, not gold) so it's findable + clickable.
|
||||
if (datFont is not null)
|
||||
{
|
||||
slotsBtn.Label = "Slots";
|
||||
slotsBtn.LabelFont = datFont;
|
||||
slotsBtn.LabelColor = System.Numerics.Vector4.One; // white (was gold)
|
||||
slotsBtn.LabelAlign = UiButton.LabelAlignment.Left; // sit at the left, before the slots
|
||||
}
|
||||
}
|
||||
|
||||
ApplyView(); // initial state = doll-view (armor slots hidden)
|
||||
|
||||
Populate();
|
||||
}
|
||||
|
||||
public static PaperdollController Bind(
|
||||
ImportedLayout layout, ClientObjectTable objects, Func<uint> playerGuid,
|
||||
Func<ItemType, uint, uint, uint, uint, uint> iconIds, Action<uint, uint>? sendWield = null,
|
||||
uint emptySlotSprite = 0u)
|
||||
=> new PaperdollController(layout, objects, playerGuid, iconIds, sendWield, emptySlotSprite);
|
||||
uint emptySlotSprite = 0u, UiDatFont? datFont = null)
|
||||
=> new PaperdollController(layout, objects, playerGuid, iconIds, sendWield, emptySlotSprite, datFont);
|
||||
|
||||
private void OnObjectChanged(ClientObject o) { if (Concerns(o)) Populate(); }
|
||||
private void OnObjectMoved(ClientObject o, uint from, uint to)
|
||||
|
|
@ -161,6 +215,17 @@ public sealed class PaperdollController : IItemListDragHandler
|
|||
_sendWield?.Invoke(payload.ObjId, (uint)wieldMask);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pushes the current <see cref="PaperdollViewState"/> onto the live widgets:
|
||||
/// hides/shows the doll viewport and each armor slot according to the toggle.
|
||||
/// Called once at construction (doll-view default) and on every button click.
|
||||
/// </summary>
|
||||
private void ApplyView()
|
||||
{
|
||||
if (_dollViewport is not null) _dollViewport.Visible = _viewState.DollVisible;
|
||||
foreach (var a in _armorSlots) a.Visible = _viewState.ArmorSlotsVisible;
|
||||
}
|
||||
|
||||
/// <summary>Detach event handlers (idempotent).</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -42,6 +42,11 @@ public sealed class UiDatElement : UiElement
|
|||
private readonly ElementInfo _info;
|
||||
private readonly Func<uint, (uint tex, int w, int h)> _resolve;
|
||||
|
||||
/// <summary>The dat element id from <see cref="ElementInfo.Id"/>. Exposed so controllers
|
||||
/// can identify which logical element a UiDatElement represents when walking subtrees
|
||||
/// (e.g. footer state groups that appear once per tab page but share the same dat id).</summary>
|
||||
public uint ElementId => _info.Id;
|
||||
|
||||
/// <summary>Which state name to render. <c>""</c> = the unnamed DirectState.
|
||||
/// Falls back to DirectState if the named state is absent.</summary>
|
||||
public string ActiveState { get; set; } = "";
|
||||
|
|
|
|||
|
|
@ -47,6 +47,13 @@ public sealed class UiButton : UiElement
|
|||
/// <summary>Label color (default white).</summary>
|
||||
public Vector4 LabelColor { get; set; } = Vector4.One;
|
||||
|
||||
/// <summary>Horizontal alignment of <see cref="Label"/>. Center (default) for normal buttons;
|
||||
/// Left for the paperdoll "Slots" caption that sits at the left edge, before the slots.</summary>
|
||||
public LabelAlignment LabelAlign { get; set; } = LabelAlignment.Center;
|
||||
|
||||
/// <summary>Label horizontal alignment options.</summary>
|
||||
public enum LabelAlignment { Center, Left }
|
||||
|
||||
/// <summary>
|
||||
/// Active state name, runtime-settable (e.g. Max/Min toggling Normal ↔ Minimized).
|
||||
/// Matches <see cref="UiDatElement.ActiveState"/>.
|
||||
|
|
@ -75,6 +82,11 @@ public sealed class UiButton : UiElement
|
|||
/// procedurally, so the importer must not build the button's children as widgets.</summary>
|
||||
public override bool ConsumesDatChildren => true;
|
||||
|
||||
/// <summary>A button is interactive — it must receive its Click even inside a whole-window-Draggable
|
||||
/// frame (e.g. the paperdoll "Slots" toggle in the inventory window), so it opts out of the
|
||||
/// IA-12 whole-window-drag that would otherwise swallow the press.</summary>
|
||||
public override bool HandlesClick => true;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the File id for the current <see cref="ActiveState"/>, falling back to
|
||||
/// the DirectState ("" key) if the named state is absent.
|
||||
|
|
@ -101,8 +113,10 @@ public sealed class UiButton : UiElement
|
|||
|
||||
if (Label is { Length: > 0 } label && LabelFont is { } lf)
|
||||
{
|
||||
float tx = (Width - lf.MeasureWidth(label)) * 0.5f;
|
||||
float ty = (Height - lf.LineHeight) * 0.5f;
|
||||
float tx = LabelAlign == LabelAlignment.Left
|
||||
? 3f // small left pad (room for a future checkbox box)
|
||||
: (Width - lf.MeasureWidth(label)) * 0.5f; // centered (default)
|
||||
float ty = (Height - lf.LineHeight) * 0.5f;
|
||||
ctx.DrawStringDat(lf, label, tx, ty, LabelColor);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -121,6 +121,15 @@ public abstract class UiElement
|
|||
/// false; overridden by drag sources (e.g. an occupied <see cref="UiItemSlot"/>).</summary>
|
||||
public virtual bool IsDragSource => false;
|
||||
|
||||
/// <summary>If true, a left-press on this element is handled BY the element (it receives the Click
|
||||
/// on release) instead of being captured as a whole-window move on a Draggable ancestor. Set by
|
||||
/// interactive leaf widgets (e.g. <see cref="UiButton"/>) so they stay clickable inside a
|
||||
/// whole-window-Draggable frame like the inventory window — where, without this, the IA-12
|
||||
/// whole-window-drag swallows the press and the Click is never emitted. Distinct from
|
||||
/// <see cref="IsDragSource"/> (starts a drag-drop) and <see cref="CapturesPointerDrag"/> (a
|
||||
/// self-driven interior drag such as text selection). Default false.</summary>
|
||||
public virtual bool HandlesClick => false;
|
||||
|
||||
/// <summary>Minimum size enforced while resizing.</summary>
|
||||
public float MinWidth { get; set; } = 40f;
|
||||
public float MinHeight { get; set; } = 40f;
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
using System;
|
||||
using System.Numerics;
|
||||
|
||||
namespace AcDream.App.UI;
|
||||
|
|
@ -23,10 +24,28 @@ public class UiPanel : UiElement
|
|||
|
||||
public float BorderThickness { get; set; } = 1f;
|
||||
|
||||
/// <summary>Optional dat RenderSurface id for the panel background sprite, drawn
|
||||
/// in place of (or alongside) <see cref="BackgroundColor"/>. 0 = none.
|
||||
/// When set, the sprite is stretched to fill the panel rect.
|
||||
/// Used by the attribute-list selected-row highlight (sprite 0x06001397 = Button state 6).</summary>
|
||||
public uint BackgroundSprite { get; set; }
|
||||
|
||||
/// <summary>Resolves a dat RenderSurface id to (GL tex handle, pixel width, pixel height).
|
||||
/// Required when <see cref="BackgroundSprite"/> is non-zero.</summary>
|
||||
public Func<uint, (uint tex, int w, int h)>? SpriteResolve { get; set; }
|
||||
|
||||
protected override void OnDraw(UiRenderContext ctx)
|
||||
{
|
||||
if (BackgroundColor.W > 0f)
|
||||
if (BackgroundSprite != 0 && SpriteResolve is { } sr)
|
||||
{
|
||||
var (tex, tw, th) = sr(BackgroundSprite);
|
||||
if (tex != 0 && tw != 0 && th != 0)
|
||||
ctx.DrawSprite(tex, 0, 0, Width, Height, 0, 0, Width / tw, Height / th, Vector4.One);
|
||||
}
|
||||
else if (BackgroundColor.W > 0f)
|
||||
{
|
||||
ctx.DrawRect(0, 0, Width, Height, BackgroundColor);
|
||||
}
|
||||
|
||||
if (BorderColor.W > 0f && BorderThickness > 0f)
|
||||
ctx.DrawRectOutline(0, 0, Width, Height, BorderColor, BorderThickness);
|
||||
|
|
@ -94,3 +113,90 @@ public class UiSimpleButton : UiPanel
|
|||
ctx.DrawString(Text, tx, ty, TextColor);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="UiPanel"/> that fires an <see cref="OnClick"/> callback when the user
|
||||
/// left-clicks it. Used for the attribute-list rows in the Character window — each row
|
||||
/// is a transparent container that needs to respond to pointer hits while its children
|
||||
/// (icon, name, value) are ClickThrough decorations.
|
||||
///
|
||||
/// <para>Retail analog: the <c>AttributeInfoRegion</c> row widget in <c>gmAttributeUI</c>
|
||||
/// catches <c>UIEvent_LeftClick</c> (0x01) and calls <c>SetSelectedAttribute</c> on the
|
||||
/// parent window. In acdream we wire the equivalent via this action callback instead of
|
||||
/// the retail message bus.</para>
|
||||
///
|
||||
/// <para>When <see cref="UseSelectionBars"/> is true and <see cref="UiPanel.BackgroundSprite"/>
|
||||
/// is non-zero, draws the sprite as a thin full-width bar at the TOP and BOTTOM edges of
|
||||
/// the row (not stretched to fill). This matches retail's selection highlight which shows
|
||||
/// a horizontal dark bar on both the top and bottom edge of the selected attribute row,
|
||||
/// with NO left/right end-caps. Bar height is <see cref="SelectionBarHeight"/> pixels
|
||||
/// (default 3px).</para>
|
||||
/// </summary>
|
||||
public class UiClickablePanel : UiPanel
|
||||
{
|
||||
/// <summary>Called when the user releases the left mouse button over this panel.</summary>
|
||||
public Action? OnClick { get; set; }
|
||||
|
||||
/// <summary>When true and <see cref="UiPanel.BackgroundSprite"/> is non-zero, draws
|
||||
/// the sprite as a thin horizontal bar at the top AND bottom edges of the panel,
|
||||
/// NOT as a full-height stretched fill. Matches retail's selected-row highlight
|
||||
/// (sprite 0x06001397 — 300×32 px — shown as bars, not a block fill).
|
||||
/// Default false (preserves legacy full-stretch behavior).</summary>
|
||||
public bool UseSelectionBars { get; set; }
|
||||
|
||||
/// <summary>Height in pixels of each selection bar (top and bottom). Default 3px.
|
||||
/// Ignored when <see cref="UseSelectionBars"/> is false.</summary>
|
||||
public float SelectionBarHeight { get; set; } = 3f;
|
||||
|
||||
public UiClickablePanel()
|
||||
{
|
||||
// Rows must receive pointer events — override the UiPanel default (ClickThrough=false,
|
||||
// which is the UiElement base default). Explicit for clarity.
|
||||
ClickThrough = false;
|
||||
}
|
||||
|
||||
/// <summary>HandlesClick = true ensures this row receives its own Click even when
|
||||
/// it is nested inside a Draggable ancestor window frame (e.g. the future character
|
||||
/// window with a whole-window drag handle). Without this, the press would be consumed
|
||||
/// by the ancestor's drag logic and the Click would never fire.</summary>
|
||||
public override bool HandlesClick => true;
|
||||
|
||||
public override bool OnEvent(in UiEvent e)
|
||||
{
|
||||
if (e.Type == UiEventType.Click && Enabled)
|
||||
{
|
||||
OnClick?.Invoke();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
protected override void OnDraw(UiRenderContext ctx)
|
||||
{
|
||||
if (UseSelectionBars && BackgroundSprite != 0 && SpriteResolve is { } sr)
|
||||
{
|
||||
// Draw the selection highlight as a thin bar at the TOP and BOTTOM of the row.
|
||||
// The sprite (0x06001397) is 300×32 px — we draw it as horizontal strips at
|
||||
// native height (SelectionBarHeight), stretched to full panel width (UV tile
|
||||
// horizontally). No left/right end-caps: u0=0, u1=Width/nativeW (UV repeat).
|
||||
var (tex, tw, th) = sr(BackgroundSprite);
|
||||
if (tex != 0 && tw > 0 && th > 0)
|
||||
{
|
||||
float barH = SelectionBarHeight;
|
||||
float uTile = tw > 0 ? Width / tw : 1f;
|
||||
// Top bar: shows the top barH px of the sprite (v = 0 → barH/th).
|
||||
float vBot = th > 0 ? barH / th : 1f;
|
||||
ctx.DrawSprite(tex, 0f, 0f, Width, barH, 0f, 0f, uTile, vBot, Vector4.One);
|
||||
// Bottom bar: shows the bottom barH px of the sprite (v = 1−barH/th → 1).
|
||||
float vTop2 = th > 0 ? 1f - barH / th : 0f;
|
||||
ctx.DrawSprite(tex, 0f, Height - barH, Width, barH, 0f, vTop2, uTile, 1f, Vector4.One);
|
||||
}
|
||||
// Selection-bar mode draws no border (rows have BorderColor=Zero by design).
|
||||
}
|
||||
else
|
||||
{
|
||||
// Default UiPanel draw: handles BackgroundSprite, BackgroundColor, AND border.
|
||||
base.OnDraw(ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -275,12 +275,13 @@ public sealed class UiRoot : UiElement
|
|||
// the bar movable by its empty cells / chrome.
|
||||
_dragCandidate = true;
|
||||
}
|
||||
else if (target.CapturesPointerDrag)
|
||||
else if (target.CapturesPointerDrag || target.HandlesClick)
|
||||
{
|
||||
// The pressed widget owns interior drags (e.g. text selection):
|
||||
// do NOT move the ancestor window. The already-dispatched MouseDown
|
||||
// event + SetCapture(target) let the target drive its own drag via
|
||||
// the MouseMove events it receives while captured.
|
||||
// The pressed widget owns its pointer interaction — either an interior drag (e.g. text
|
||||
// selection, CapturesPointerDrag) or a click it must receive (e.g. a UiButton,
|
||||
// HandlesClick). Either way do NOT move the ancestor window. The already-dispatched
|
||||
// MouseDown + SetCapture(target) let the target handle it; on release OnMouseUp emits
|
||||
// the Click over the same element. (A HandlesClick widget is not a drag candidate.)
|
||||
_dragCandidate = false;
|
||||
}
|
||||
else if (window.Draggable)
|
||||
|
|
@ -654,6 +655,10 @@ public sealed class UiRoot : UiElement
|
|||
return (null, 0, 0);
|
||||
}
|
||||
|
||||
/// <summary>Public hit-test for tooling (the UI Studio inspector): the topmost element under
|
||||
/// (x,y) in root space, honoring modal exclusivity + Z-order. Wraps the private HitTestTopDown.</summary>
|
||||
public UiElement? Pick(int x, int y) => HitTestTopDown(x, y).element;
|
||||
|
||||
private static UiElement? FindWindow(UiElement? e)
|
||||
{
|
||||
while (e is not null)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ using System.Collections.Generic;
|
|||
using System.Numerics;
|
||||
using System.Text;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.UI.Layout;
|
||||
|
||||
namespace AcDream.App.UI;
|
||||
|
||||
|
|
@ -44,6 +45,19 @@ public sealed class UiText : UiElement
|
|||
/// the host from <see cref="UiHost.Keyboard"/>.</summary>
|
||||
public Silk.NET.Input.IKeyboard? Keyboard { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Default line color used by controllers when they do not supply a per-line
|
||||
/// <see cref="Vector4"/> color explicitly. Set by <c>DatWidgetFactory.BuildText</c>
|
||||
/// from <c>ElementInfo.FontColor</c> when the dat carries a 0x1B ColorBaseProperty;
|
||||
/// otherwise white (<see cref="Vector4.One"/>).
|
||||
///
|
||||
/// <para>Controllers that supply a per-line color via <see cref="LinesProvider"/>
|
||||
/// (e.g. <c>new UiText.Line(text, explicitColor)</c>) are unaffected — they always
|
||||
/// win over this default. This property is only a convenience starting point for
|
||||
/// controllers that want to read the dat color rather than hard-code it.</para>
|
||||
/// </summary>
|
||||
public Vector4 DefaultColor { get; set; } = Vector4.One;
|
||||
|
||||
/// <summary>Backing fill behind the text. Defaults to transparent so an unbound
|
||||
/// UiText (no controller) draws nothing. Set to the retail translucent value by
|
||||
/// the controller (e.g. <c>ChatWindowController</c>).</summary>
|
||||
|
|
@ -71,6 +85,30 @@ public sealed class UiText : UiElement
|
|||
/// after the rewire. Pair with <c>ClickThrough = true</c> for non-interactive labels.</summary>
|
||||
public bool Centered { get; set; }
|
||||
|
||||
/// <summary>Static right-aligned single-line mode: draws the FIRST line right-justified
|
||||
/// within the element rect, vertically centered, with NO scroll/selection machinery.
|
||||
/// Used for value labels in attribute/skill rows where the number must hug the right edge.
|
||||
/// Mutually exclusive with <see cref="Centered"/> — if both are true, Centered takes
|
||||
/// precedence. Pair with <c>ClickThrough = true</c> for non-interactive labels.</summary>
|
||||
public bool RightAligned { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Vertical position of the text within the element rect in single-line mode
|
||||
/// (<see cref="Centered"/> or <see cref="RightAligned"/>).
|
||||
/// <list type="bullet">
|
||||
/// <item><description><b>Center</b> (default) — vertically centered, matching the original
|
||||
/// behavior of the centered/right-aligned paths.</description></item>
|
||||
/// <item><description><b>Top</b> — text is placed at <c>y = Padding</c> (top of the content
|
||||
/// area), so the text sits at the top of the element rather than centering in it.
|
||||
/// Used for footer title elements whose dat box is the full footer height (55 px) but
|
||||
/// the text should render near the top.</description></item>
|
||||
/// <item><description><b>Bottom</b> — text is placed at <c>y = Height - lineHeight - Padding</c>.</description></item>
|
||||
/// </list>
|
||||
/// Only meaningful when <see cref="Centered"/> or <see cref="RightAligned"/> is true.
|
||||
/// Has no effect on the scrollable multi-line path.
|
||||
/// </summary>
|
||||
public VJustify VerticalJustify { get; set; } = VJustify.Center;
|
||||
|
||||
/// <summary>The scroll model — also read by the linked UiScrollbar.</summary>
|
||||
public UiScrollable Scroll { get; } = new();
|
||||
|
||||
|
|
@ -131,8 +169,8 @@ public sealed class UiText : UiElement
|
|||
ctx.DrawFill(0, 0, Width, Height, BackgroundColor);
|
||||
|
||||
// Static centered single-line mode (vitals cur/max numbers etc.): draw the first
|
||||
// line centered H+V with the SAME formula UIElement_Meter used for its label, then
|
||||
// skip the scroll/selection machinery entirely.
|
||||
// line centered H+V (or H+Top/Bottom per VerticalJustify) with the SAME formula
|
||||
// UIElement_Meter used for its label, then skip the scroll/selection machinery entirely.
|
||||
if (Centered)
|
||||
{
|
||||
var cLines = LinesProvider();
|
||||
|
|
@ -141,18 +179,40 @@ public sealed class UiText : UiElement
|
|||
if (DatFont is { } cdf)
|
||||
{
|
||||
float cx = (Width - cdf.MeasureWidth(line0.Text)) * 0.5f;
|
||||
float cy = (Height - cdf.LineHeight) * 0.5f;
|
||||
float cy = VOffset(Height, cdf.LineHeight, Padding, VerticalJustify);
|
||||
ctx.DrawStringDat(cdf, line0.Text, cx, cy, line0.Color);
|
||||
}
|
||||
else if ((Font ?? ctx.DefaultFont) is { } cbf)
|
||||
{
|
||||
float cx = (Width - cbf.MeasureWidth(line0.Text)) * 0.5f;
|
||||
float cy = (Height - cbf.LineHeight) * 0.5f;
|
||||
float cy = VOffset(Height, cbf.LineHeight, Padding, VerticalJustify);
|
||||
ctx.DrawString(line0.Text, cx, cy, line0.Color, cbf);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Static right-aligned single-line mode: draw the first line flush with the right
|
||||
// edge, vertical position per VerticalJustify, then skip the scroll/selection machinery.
|
||||
if (RightAligned)
|
||||
{
|
||||
var rLines = LinesProvider();
|
||||
if (rLines.Count == 0) return;
|
||||
var line0 = rLines[0];
|
||||
if (DatFont is { } rdf)
|
||||
{
|
||||
float rx = Width - rdf.MeasureWidth(line0.Text) - Padding;
|
||||
float ry = VOffset(Height, rdf.LineHeight, Padding, VerticalJustify);
|
||||
ctx.DrawStringDat(rdf, line0.Text, rx, ry, line0.Color);
|
||||
}
|
||||
else if ((Font ?? ctx.DefaultFont) is { } rbf)
|
||||
{
|
||||
float rx = Width - rbf.MeasureWidth(line0.Text) - Padding;
|
||||
float ry = VOffset(Height, rbf.LineHeight, Padding, VerticalJustify);
|
||||
ctx.DrawString(line0.Text, rx, ry, line0.Color, rbf);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Prefer the retail dat font when set; fall back to BitmapFont.
|
||||
var datFont = DatFont;
|
||||
var bitmapFont = datFont is null ? (Font ?? ctx.DefaultFont) : null;
|
||||
|
|
@ -337,6 +397,23 @@ public sealed class UiText : UiElement
|
|||
|
||||
// ── Pure, testable logic (no GL / no font texture) ───────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Compute the Y offset (local space) for a single line in the Centered/RightAligned
|
||||
/// single-line path, given the element height, font line-height, padding, and
|
||||
/// vertical justification.
|
||||
/// </summary>
|
||||
/// <param name="height">Element height in pixels.</param>
|
||||
/// <param name="lineHeight">Font line height in pixels.</param>
|
||||
/// <param name="padding">Content padding.</param>
|
||||
/// <param name="vj">Vertical justification.</param>
|
||||
public static float VOffset(float height, float lineHeight, float padding, VJustify vj)
|
||||
=> vj switch
|
||||
{
|
||||
VJustify.Top => padding,
|
||||
VJustify.Bottom => height - lineHeight - padding,
|
||||
_ => (height - lineHeight) * 0.5f, // Center (default)
|
||||
};
|
||||
|
||||
/// <summary>Order two caret positions so the first is <= the second (by line,
|
||||
/// then column).</summary>
|
||||
public static (Pos start, Pos end) Order(Pos a, Pos b)
|
||||
|
|
|
|||
27
src/AcDream.App/UI/UiViewport.cs
Normal file
27
src/AcDream.App/UI/UiViewport.cs
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
using System.Numerics;
|
||||
|
||||
namespace AcDream.App.UI;
|
||||
|
||||
/// <summary>Leaf widget for dat Type 0xD (UIElement_Viewport). Blits the texture produced by its
|
||||
/// IUiViewportRenderer (run in the pre-UI hook) as a single sprite at its own rect. The 3-D render
|
||||
/// does NOT happen here (OnDraw only has a 2-D context).</summary>
|
||||
public sealed class UiViewport : UiElement
|
||||
{
|
||||
public override bool ConsumesDatChildren => true;
|
||||
|
||||
/// <summary>Renderer that produces the off-screen texture. Set by GameWindow wiring (later task).</summary>
|
||||
public IUiViewportRenderer? Renderer { get; set; }
|
||||
|
||||
/// <summary>Last GL color-texture handle produced by the pre-UI hook. 0 = nothing to blit.</summary>
|
||||
public uint TextureHandle { get; set; }
|
||||
|
||||
protected override void OnDraw(UiRenderContext ctx)
|
||||
{
|
||||
if (!Visible || TextureHandle == 0) return;
|
||||
// Local origin is already at this widget's Left/Top (PushTransform applied by DrawSelfAndChildren).
|
||||
// V is FLIPPED (v0=1, v1=0): TextureHandle is an off-screen FBO color texture, whose origin is
|
||||
// bottom-left (GL), while the UI sprite convention is top-left. Without the flip the doll renders
|
||||
// upside-down. (If the doll appears upside-down at the visual gate, this is the line to revisit.)
|
||||
ctx.DrawSprite(TextureHandle, 0f, 0f, Width, Height, 0f, 1f, 1f, 0f, Vector4.One);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue