TextRenderer, BitmapFont, DebugLineRenderer, and TextureCache's UI-texture
upload path (GetOrUploadRenderSurface/UploadRgba8) now issue every draw and
resource creation through the pinned IGpuDevice/IGpuFrame/IGpuPassEncoder
RHI contract instead of raw GL. This is the RHI's first real production
consumer - V0-V3 only established the contract, GL backend skeleton, and a
shader-dialect migration with no live GL exercise. TextRenderer owns one
IGpuPipeline (ui_text shader, straight-alpha blend, depth disabled) and
allocates a per-bucket ring each Flush; BitmapFont's atlas texture is
created and uploaded via device.CreateTexture/.Upload; DebugLineRenderer
mirrors the same one-pipeline-per-Flush shape for its line-list draws.
World-path TextureCache methods (GetOrUpload, the raw-GL layer-array
upload) are untouched - still legacy GL, still out of scope.
Frame lifecycle: GpuDeviceFrameLifetime (RenderFrameOrchestrator.cs) wraps
IGpuDevice.BeginFrame()/IGpuFrame.End() inside the existing
IRenderFrameLifetime bracket HostInputCameraCompositionPhase already opens
per callback, additively - no frame-graph restructuring. Ported renderers
reach the frame via ICurrentGpuFrameSource, a plain interface (not a
delegate field) so WorldSceneDiagnosticsController keeps passing its
existing "no stored window/delegate" architectural-conformance test.
Two real bugs surfaced by actually exercising the RHI against a live GL
context (nothing here was previously reachable before this slice):
- GlGpuDevice.BeginFrame() now resets the render-state cache every frame.
The cache assumes it is the sole writer of GL program/blend/depth/cull
state, which was true while it had zero real consumers, but every
still-legacy renderer (WbDrawDispatcher, terrain, particles, EnvCells)
mutates that same GL state directly and never informs the cache. Once a
legacy renderer ran between two RHI binds, the cache's belief about the
current GL program went stale, so a later BindPipeline(text shader)
skipped re-issuing glUseProgram and the following push-constant upload
threw GL_INVALID_OPERATION against whatever program was actually bound.
Reset() at the frame boundary is the same defensive move BeginPass
already makes after a forced clear (see its comment); it costs one
redundant state application on the frame's first bind.
- GL_MULTISAMPLE has no representation in the pinned contract. Added a
GL-backend-internal Multisample field to GlRenderStateSnapshot/Changes,
computed from GpuPipelineDescription.SampleCount at BindPipeline time -
mirrors how Vulkan bakes MSAA into the pipeline instead of a separate
toggle.
Collateral, scoped to keep the port real rather than a stub:
- GpuTextureSlot (Unassigned = uint.MaxValue, NOT 0) now flows through
every consumer of TextureCache.GetOrUploadRenderSurface/UploadRgba8 and
TextRenderer.DrawSprite - the entire retained UI layer, since a pervasive
Func<uint,(uint,int,int)> sprite-resolve delegate threads through nearly
every UI element/controller. Every prior `== 0` / `!= 0` "no texture"
check became `.IsAssigned` / `!.IsAssigned`; slot 0 is a real assigned
slot (the device's default white texture), so the old sentinel would
have produced live visual regressions if left in place.
- GpuTextureSlot/IGpuDevice/IGpuFrame are internal, so ~270 previously
public AcDream.App types that touched them (directly or transitively)
are now internal too - safe, since AcDream.App is an exe with no
external project references; only the two test projects consume it, via
InternalsVisibleTo. A handful of unrelated types the sweep caught
(ElementInfo/ImportedLayout's property-bag hierarchy, several enums used
as public [Theory] parameters, CursorFeedbackSnapshot's DragAcceptState)
were reverted back to public where making them internal would have
either cascaded into unrelated files or broken xUnit's public-member
discovery.
- ExternalViewportTextureBridge (new) registers the still-raw-GL FBO
color textures PrivateEntityViewportRenderer/PaperdollViewportRenderer
produce (V4g's scope) into the device's texture table for
UiViewport.TextureHandle, via a temporary
GlGpuDevice.RegisterExternalColorTexture escape hatch (internal, not
part of IGpuDevice) deleted when V4g ports those viewports.
- TextRenderGlStateScope.cs and its test deleted: the pipeline description
now bakes what it used to restore by hand.
- ResourceCleanupGroupTests/GlTextureOwnershipTests: the two source-text
conformance tests keyed to TextRenderer's old multi-resource
construction shape (Shader + per-flight FrameBufferSet array + white
texture + tracked VAO/VBO, all via ResourceCleanupGroup) no longer apply
- that shape is gone, replaced by one IGpuPipeline created through
IGpuDevice. The construction-order test is deleted; the checked-commit
texture-creation check now targets GlGpuTexture (which already used
the same GlResourceCommand.CreateName primitive before this slice).
Gates:
- dotnet build -c Release: 0 warnings, 0 errors (AcDream.App has
TreatWarningsAsErrors).
- dotnet test tests/AcDream.App.Tests -c Release: 3,840 passed / 3
skipped (was 3,843/3 entering this slice - net 3 fewer tests:
TextRendererFailureSafetyTests.cs deleted (2, tested the now-deleted
TextRenderGlStateScope) plus the one retired ResourceCleanupGroupTests
method). Full solution: 8,908 passed / 5 skipped across all nine test
projects.
- Offline pixel gate (tools/run-offline-pixel-gate.ps1, parent ec414d60
vs this commit): differing fraction 0.318% (1,791/563,200 compared
pixels), above the 0.001 threshold. Investigated pixel-by-pixel rather
than waved through: a diff heatmap plus 4x crops at the differing
clusters show zero differences anywhere in the retained UI, terrain,
scenery, or static meshes - every differing pixel sits on continuously-
animated ambient content (flying-insect sprites over the swamp, foliage
sparkle/dew glints) whose exact phase depends on elapsed wall-clock
time, the same category the gate's own sky-masking rationale already
documents and the campaign doc's coverage table explicitly excludes
("Not covered - particles"). Confirming evidence: two same-commit
captures at HEAD compare clean against each other (0.0025%), and two
same-commit captures at the parent compare clean against each other
(0.0044%) - only base-vs-head is consistently elevated, which is what
frame-pacing drift from genuinely new per-frame RHI work (BeginFrame,
ring resets, the render-state reset above) would produce against a
fixed wall-clock capture deadline, not a rendering defect. Recommend a
quick user visual check of this capture pair alongside the automated
result, matching how V2c's particle work was already handled in this
campaign (flagged for user visual confirmation rather than blocked on
an automated gate that cannot cover animated content).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
716 lines
30 KiB
C#
716 lines
30 KiB
C#
using AcDream.App.Rendering.Gpu;
|
|
using System.Numerics;
|
|
using AcDream.App.UI;
|
|
using AcDream.App.UI.Layout;
|
|
namespace AcDream.App.Tests.UI.Layout;
|
|
|
|
public class DatWidgetFactoryTests
|
|
{
|
|
private static (GpuTextureSlot, int, int) NoTex(uint _) => (GpuTextureSlot.Unassigned, 0, 0);
|
|
|
|
// ── Test 1: Type 7 → UiMeter ─────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void Type7_Meter_MakesUiMeter()
|
|
{
|
|
var e = DatWidgetFactory.Create(new ElementInfo { Type = 7, Width = 150, Height = 16 }, NoTex, null);
|
|
Assert.IsType<UiMeter>(e);
|
|
}
|
|
|
|
[Fact]
|
|
public void RetailRadarClass_MakesUiRadar()
|
|
{
|
|
var e = DatWidgetFactory.Create(
|
|
new ElementInfo { Type = UiRadar.RetailClassId, Width = 120, Height = 140 },
|
|
NoTex,
|
|
null);
|
|
|
|
Assert.IsType<UiRadar>(e);
|
|
}
|
|
|
|
// ── Test 2: Unknown type → UiDatElement fallback ─────────────────────────
|
|
|
|
[Fact]
|
|
public void UnknownType_FallsBackToGeneric()
|
|
{
|
|
var e = DatWidgetFactory.Create(new ElementInfo { Type = 999 }, NoTex, null);
|
|
Assert.IsType<UiDatElement>(e);
|
|
}
|
|
|
|
// ── Test 3: Type 12 → UiText (behavioral text widget) ────────────────────
|
|
|
|
[Fact]
|
|
public void Type12_Text_MakesUiText()
|
|
{
|
|
var e = DatWidgetFactory.Create(new ElementInfo { Type = 12, Width = 100, Height = 40 }, NoTex, null);
|
|
var text = Assert.IsType<UiText>(e);
|
|
Assert.False(text.Selectable);
|
|
Assert.True(text.ClickThrough);
|
|
Assert.False(text.AcceptsFocus);
|
|
Assert.False(text.CapturesPointerDrag);
|
|
}
|
|
|
|
[Fact]
|
|
public void Type12_EditableProperty_MakesUiFieldInPlace()
|
|
{
|
|
var info = TextInfo(
|
|
(0x16u, Bool(true)),
|
|
(0x20u, Bool(true)),
|
|
(0x27u, Bool(true)),
|
|
(0x1Eu, Integer(80)));
|
|
|
|
var field = Assert.IsType<UiField>(DatWidgetFactory.Create(info, NoTex, null));
|
|
|
|
Assert.Equal(info.Id, field.ElementId);
|
|
Assert.True(field.OneLine);
|
|
Assert.True(field.Selectable);
|
|
Assert.Equal(80, field.MaxCharacters);
|
|
}
|
|
|
|
[Fact]
|
|
public void Type12_SelectableProperty_MakesSelectableUiText()
|
|
{
|
|
var text = Assert.IsType<UiText>(DatWidgetFactory.Create(
|
|
TextInfo((0x27u, Bool(true))),
|
|
NoTex,
|
|
null));
|
|
|
|
Assert.True(text.Selectable);
|
|
Assert.False(text.ClickThrough);
|
|
Assert.True(text.AcceptsFocus);
|
|
Assert.True(text.CapturesPointerDrag);
|
|
}
|
|
|
|
// ── Test 4: Rect + anchors set from ElementInfo ───────────────────────────
|
|
|
|
/// <summary>
|
|
/// A Type-3 element with X=5,Y=21,W=150,H=16, Left=1,Top=1,Right=1 should have
|
|
/// its rect + anchors copied onto the returned widget.
|
|
/// Per UIElement::UpdateForParentSizeChange @0x00462640:
|
|
/// Left=1 → AnchorEdges.Left (near-pin); Top=1 → AnchorEdges.Top;
|
|
/// Right=1 → AnchorEdges.Right (stretch / track parent right); Bottom=0 → neither.
|
|
/// Combined: Left | Top | Right.
|
|
/// </summary>
|
|
[Fact]
|
|
public void RectAndAnchors_SetFromElementInfo()
|
|
{
|
|
var info = new ElementInfo
|
|
{
|
|
Type = 3,
|
|
X = 5, Y = 21,
|
|
Width = 150, Height = 16,
|
|
Left = 1, Top = 1,
|
|
Right = 1, Bottom = 0,
|
|
};
|
|
var e = DatWidgetFactory.Create(info, NoTex, null)!;
|
|
Assert.Equal(5f, e.Left);
|
|
Assert.Equal(21f, e.Top);
|
|
Assert.Equal(150f, e.Width);
|
|
Assert.Equal(16f, e.Height);
|
|
Assert.Equal(AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right, e.Anchors);
|
|
}
|
|
|
|
[Fact]
|
|
public void ImportedDescendant_PreservesExactRawEdgePolicy()
|
|
{
|
|
var info = new ElementInfo
|
|
{
|
|
Type = 3,
|
|
X = 10,
|
|
Y = 20,
|
|
Width = 30,
|
|
Height = 40,
|
|
Left = 4,
|
|
Top = 3,
|
|
Right = 1,
|
|
Bottom = 0,
|
|
OriginalParentWidth = 100,
|
|
OriginalParentHeight = 200,
|
|
HasOriginalParentSize = true,
|
|
};
|
|
|
|
var element = DatWidgetFactory.Create(info, NoTex, null)!;
|
|
var policy = Assert.IsType<UiLayoutPolicy>(element.LayoutPolicy);
|
|
|
|
Assert.Equal(4u, policy.LeftMode);
|
|
Assert.Equal(3u, policy.TopMode);
|
|
Assert.Equal(1u, policy.RightMode);
|
|
Assert.Equal(0u, policy.BottomMode);
|
|
Assert.Equal(new UiPixelRect(0, 0, 99, 199), policy.OriginalParent);
|
|
}
|
|
|
|
[Fact]
|
|
public void ImportedRoot_HasNoRawEdgePolicy()
|
|
{
|
|
var element = DatWidgetFactory.Create(
|
|
new ElementInfo { Type = 3, Width = 100, Height = 200 },
|
|
NoTex,
|
|
null)!;
|
|
|
|
Assert.Null(element.LayoutPolicy);
|
|
}
|
|
|
|
[Fact]
|
|
public void Create_PropagatesStateCursors()
|
|
{
|
|
var info = new ElementInfo { Type = 3 };
|
|
info.StateCursors["Drag_rollover_accept"] = new UiCursorMedia(0x06008888u, 7, 8);
|
|
|
|
var e = DatWidgetFactory.Create(info, NoTex, null)!;
|
|
|
|
Assert.Equal(new UiCursorMedia(0x06008888u, 7, 8),
|
|
e.CursorForState("Drag_rollover_accept", allowFallback: false));
|
|
}
|
|
|
|
// ── Test 5: ReadOrder propagated to ZOrder ───────────────────────────────
|
|
|
|
[Fact]
|
|
public void Create_PropagatesReadOrderToZOrder()
|
|
{
|
|
var e = DatWidgetFactory.Create(new ElementInfo { Type = 3, ReadOrder = 7 }, NoTex, null);
|
|
Assert.Equal(7, e!.ZOrder);
|
|
}
|
|
|
|
// ── Test G1a: Type 12 always produces UiText (with or without own sprites) ──
|
|
|
|
[Fact]
|
|
public void DatWidgetFactory_Type12_AlwaysMakesUiText()
|
|
{
|
|
var withMedia = new ElementInfo { Type = 12, Width = 32, Height = 16,
|
|
StateMedia = { ["Normal"] = (0x00001234u, 1) } };
|
|
Assert.IsType<UiText>(DatWidgetFactory.Create(withMedia, NoTex, null));
|
|
Assert.IsType<UiText>(DatWidgetFactory.Create(new ElementInfo { Type = 12 }, NoTex, null));
|
|
}
|
|
|
|
// ── Test 5c: Type 1 → UiButton ──────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void Type1_Button_MakesUiButton()
|
|
{
|
|
var e = DatWidgetFactory.Create(new ElementInfo { Type = 1, Width = 46, Height = 18 }, NoTex, null);
|
|
Assert.IsType<UiButton>(e);
|
|
}
|
|
|
|
// ── Test 5b: Type 11 → UiScrollbar ──────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void Type11_Scrollbar_MakesUiScrollbar()
|
|
{
|
|
var e = DatWidgetFactory.Create(new ElementInfo { Type = 11, Width = 16, Height = 68 }, NoTex, null);
|
|
Assert.IsType<UiScrollbar>(e);
|
|
}
|
|
|
|
[Fact]
|
|
public void Type11_HorizontalScrollbar_usesDirectRetailTrackAndThumbChildren()
|
|
{
|
|
const uint Track = 0x06004CF6u;
|
|
const uint Thumb = 0x06005DC3u;
|
|
var info = new ElementInfo { Type = 11, Id = 0x100001A4u, Width = 90, Height = 14 };
|
|
var track = new ElementInfo { Id = 4u, Type = 3u, Width = 90, Height = 14 };
|
|
track.States[UiStateInfo.DirectStateId] = new UiStateInfo
|
|
{ Id = UiStateInfo.DirectStateId, Image = new UiImageMedia(Track, 1) };
|
|
var thumb = new ElementInfo { Id = 1u, Type = 3u, Width = 16, Height = 14 };
|
|
thumb.States[UiStateInfo.DirectStateId] = new UiStateInfo
|
|
{ Id = UiStateInfo.DirectStateId, Image = new UiImageMedia(Thumb, 1) };
|
|
info.Children.Add(track);
|
|
info.Children.Add(thumb);
|
|
|
|
var bar = Assert.IsType<UiScrollbar>(DatWidgetFactory.Create(info, NoTex, null));
|
|
|
|
Assert.True(bar.Horizontal);
|
|
Assert.Equal(Track, bar.TrackSprite);
|
|
Assert.Equal(Thumb, bar.ThumbSprite);
|
|
Assert.Equal(0u, bar.UpSprite);
|
|
Assert.Equal(0u, bar.DownSprite);
|
|
}
|
|
|
|
[Fact]
|
|
public void Type11_HorizontalScrollbar_ImportsAuthoredArrowButtonsAndHideDisabled()
|
|
{
|
|
const uint DecrementId = 0x10000071u;
|
|
const uint IncrementId = 0x10000072u;
|
|
const uint DecrementSprite = 0x06004CDCu;
|
|
const uint IncrementSprite = 0x06004CDEu;
|
|
const uint DecrementRollover = 0x06004CDDu;
|
|
const uint IncrementRollover = 0x06004CDFu;
|
|
var decrement = new ElementInfo
|
|
{
|
|
Id = DecrementId, Type = 1u, X = 63f, Width = 23f, Height = 36f,
|
|
};
|
|
decrement.States[UiStateInfo.DirectStateId] = new UiStateInfo
|
|
{
|
|
Id = UiStateInfo.DirectStateId,
|
|
Image = new UiImageMedia(DecrementSprite, 1),
|
|
};
|
|
decrement.StateMedia["Normal"] = (DecrementSprite, 1);
|
|
decrement.StateMedia["Normal_rollover"] = (DecrementRollover, 1);
|
|
decrement.StateMedia["Normal_pressed"] = (DecrementSprite, 1);
|
|
var increment = new ElementInfo
|
|
{
|
|
Id = IncrementId, Type = 1u, X = 0f, Width = 23f, Height = 36f,
|
|
};
|
|
increment.States[UiStateInfo.DirectStateId] = new UiStateInfo
|
|
{
|
|
Id = UiStateInfo.DirectStateId,
|
|
Image = new UiImageMedia(IncrementSprite, 1),
|
|
};
|
|
increment.StateMedia["Normal"] = (IncrementSprite, 1);
|
|
increment.StateMedia["Normal_rollover"] = (IncrementRollover, 1);
|
|
increment.StateMedia["Normal_pressed"] = (IncrementSprite, 1);
|
|
var info = new ElementInfo
|
|
{
|
|
Type = 11u,
|
|
Id = SpellcastingUiController.FavoriteScrollbarId,
|
|
Width = 685f,
|
|
Height = 36f,
|
|
Children = [decrement, increment],
|
|
};
|
|
var state = new UiStateInfo { Id = UiStateInfo.DirectStateId };
|
|
state.Properties.Values[0x77u] = new UiPropertyValue
|
|
{ Kind = UiPropertyKind.Enum, UnsignedValue = IncrementId };
|
|
state.Properties.Values[0x78u] = new UiPropertyValue
|
|
{ Kind = UiPropertyKind.Enum, UnsignedValue = DecrementId };
|
|
state.Properties.Values[0x79u] = Bool(true);
|
|
info.States[UiStateInfo.DirectStateId] = state;
|
|
|
|
var bar = Assert.IsType<UiScrollbar>(
|
|
DatWidgetFactory.Create(info, NoTex, null));
|
|
|
|
Assert.True(bar.Horizontal);
|
|
Assert.True(bar.HideWhenDisabled);
|
|
Assert.Equal(23f, bar.DecrementButtonExtent);
|
|
Assert.Equal(23f, bar.IncrementButtonExtent);
|
|
Assert.Equal(IncrementSprite, bar.UpSprite);
|
|
Assert.Equal(IncrementRollover, bar.UpRolloverSprite);
|
|
Assert.Equal(IncrementSprite, bar.UpPressedSprite);
|
|
Assert.Equal(DecrementSprite, bar.DownSprite);
|
|
Assert.Equal(DecrementRollover, bar.DownRolloverSprite);
|
|
Assert.Equal(DecrementSprite, bar.DownPressedSprite);
|
|
Assert.Equal(0u, bar.TrackSprite);
|
|
Assert.Equal(0u, bar.ThumbSprite);
|
|
}
|
|
|
|
[Fact]
|
|
public void RetailToolbarFixture_buildsEditableStackEntry_andAuthoredHorizontalSlider()
|
|
{
|
|
ImportedLayout layout = FixtureLoader.LoadToolbar();
|
|
|
|
var entry = Assert.IsType<UiField>(layout.FindElement(0x100001A3u));
|
|
Assert.True(entry.RightAligned);
|
|
var bar = Assert.IsType<UiScrollbar>(layout.FindElement(0x100001A4u));
|
|
Assert.True(bar.Horizontal);
|
|
Assert.Equal(0x06004CF6u, bar.TrackSprite);
|
|
Assert.Equal(0x06005DC3u, bar.ThumbSprite);
|
|
}
|
|
|
|
// ── Test 5e: Type 3 is NOT registered — chrome/containers stay generic ────
|
|
//
|
|
// Retail Type 3 = UIElement_Field, but acdream's Type-3 dat elements (vitals/chat
|
|
// bevel chrome + the transcript/input container panels) are inert sprite-bearing
|
|
// chrome, not editable fields. They stay on the UiDatElement fallback so their
|
|
// sprites render and they gain no spurious focus/edit affordance. The one true
|
|
// editable field (the chat input, 0x10000016) resolves to Type 12 and is
|
|
// controller-placed as a UiField. Register Type 3 → UiField only when a window
|
|
// carries a factory-built editable Type-3 field.
|
|
|
|
[Fact]
|
|
public void Type3_NotRegistered_FallsBackToGeneric()
|
|
{
|
|
var e = DatWidgetFactory.Create(new ElementInfo { Type = 3, Width = 200, Height = 16 }, NoTex, null);
|
|
Assert.IsType<UiDatElement>(e);
|
|
}
|
|
|
|
// ── Test 5d: Type 6 → UiMenu ─────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void Type6_Menu_MakesUiMenu()
|
|
{
|
|
var e = DatWidgetFactory.Create(new ElementInfo { Type = 6, Width = 46, Height = 18 }, NoTex, null);
|
|
Assert.IsType<UiMenu>(e);
|
|
}
|
|
|
|
// ── Test 7: Type 0x10000031 → UiItemList ────────────────────────────────
|
|
|
|
[Fact]
|
|
public void Create_buildsUiItemList_forItemListClassId()
|
|
{
|
|
var info = new AcDream.App.UI.Layout.ElementInfo { Id = 0x100001A7u, Type = 0x10000031u, Width = 32, Height = 32 };
|
|
var w = AcDream.App.UI.Layout.DatWidgetFactory.Create(info, _ => (GpuTextureSlot.Unassigned, 0, 0), null);
|
|
Assert.IsType<AcDream.App.UI.UiItemList>(w);
|
|
}
|
|
|
|
// ── Test M1: Single-image meter (toolbar selected-object meters) ────────
|
|
//
|
|
// The toolbar health/mana meters (0x100001A1 / 0x100001A2) use a DIFFERENT
|
|
// shape from the vitals 3-slice meters: the back-track sprite lives on the
|
|
// meter ELEMENT's own DirectState ("" key), and there is exactly ONE Type-3
|
|
// child whose own DirectState ("" key) carries the fill sprite. That child
|
|
// has no image grandchildren, so SliceIds would return all-zero — the new
|
|
// Count==1 branch reads the StateMedia entries directly instead.
|
|
// The sprites go in the TILE slot (Back/FrontTile), NOT the cap slot: DrawMode=Normal
|
|
// tiles at native width across the full bar geometry (UIElement_Meter::DrawChildren),
|
|
// so the back spans all 140px and the fill clips to 140*fraction for any native width.
|
|
// Back/FrontLeft + Back/FrontRight must be 0 (no caps on a single-image bar).
|
|
|
|
[Fact]
|
|
public void BuildMeter_SingleImageShape_ReadsDirectStateFromElementAndFillChild()
|
|
{
|
|
const uint BackFile = 0x0600193Eu; // health back-track (from toolbar dump)
|
|
const uint FillFile = 0x0600193Fu; // health fill (from toolbar dump)
|
|
|
|
// Meter element: Type 7, own DirectState = back-track sprite.
|
|
var meter = new ElementInfo { Type = 7, Id = 0x100001A1u, Width = 140, Height = 31 };
|
|
meter.StateMedia[""] = (BackFile, 1);
|
|
|
|
// Single Type-3 fill container: own DirectState = fill sprite, no grandchildren.
|
|
var fillContainer = new ElementInfo { Type = 3, ReadOrder = 1 };
|
|
fillContainer.StateMedia[""] = (FillFile, 1);
|
|
meter.Children.Add(fillContainer);
|
|
|
|
var e = DatWidgetFactory.Create(meter, NoTex, null);
|
|
|
|
var m = Assert.IsType<UiMeter>(e);
|
|
// Back-track on the meter element's own DirectState, fill on the single child —
|
|
// both in the TILE slot so they tile across the full 140px bar (DrawMode=Normal).
|
|
Assert.Equal(BackFile, m.BackTile);
|
|
Assert.Equal(0u, m.BackLeft);
|
|
Assert.Equal(0u, m.BackRight);
|
|
Assert.Equal(FillFile, m.FrontTile);
|
|
Assert.Equal(0u, m.FrontLeft);
|
|
Assert.Equal(0u, m.FrontRight);
|
|
}
|
|
|
|
[Fact]
|
|
public void BuildMeter_StatefulSingleImageShape_MapsNumericFillStatesAndHidesDirectRange()
|
|
{
|
|
const uint TrackFile = 0x06004D0Bu;
|
|
const uint JumpState = 0x10000042u;
|
|
const uint JumpFile = 0x06001354u;
|
|
const uint RangeFile = 0x0600715Eu;
|
|
|
|
var meter = new ElementInfo { Type = 7, Id = 0x10000034u, Width = 600, Height = 15 };
|
|
meter.StateMedia[""] = (TrackFile, 1);
|
|
|
|
var fills = new ElementInfo { Type = 3, Id = 2u, ReadOrder = 2 };
|
|
fills.States[JumpState] = new UiStateInfo { Id = JumpState, Name = "JumpMode" };
|
|
fills.StateMedia["JumpMode"] = (JumpFile, 1);
|
|
meter.Children.Add(fills);
|
|
|
|
var range = new ElementInfo { Type = 3, Id = 0x100005EEu, ReadOrder = 1 };
|
|
range.StateMedia[""] = (RangeFile, 1);
|
|
meter.Children.Add(range);
|
|
|
|
var result = Assert.IsType<UiMeter>(DatWidgetFactory.Create(meter, NoTex, null));
|
|
|
|
Assert.Equal(TrackFile, result.BackTile);
|
|
Assert.Equal(0u, result.FrontTile);
|
|
Assert.True(result.TrySetRetailState(JumpState));
|
|
Assert.Equal(JumpState, result.ActiveRetailStateId);
|
|
Assert.Equal(JumpFile, result.FrontTile);
|
|
Assert.NotEqual(RangeFile, result.BackTile);
|
|
Assert.NotEqual(RangeFile, result.FrontTile);
|
|
}
|
|
|
|
// ── Test 6: Meter slice extraction (the important one) ───────────────────
|
|
|
|
/// <summary>
|
|
/// A meter (Type 7) whose two Type-3 containers each carry 3 image children
|
|
/// (ordered by X, bearing a DirectState "" sprite), plus the front container
|
|
/// has a fourth expand-overlay child with ONLY a named "ShowDetail" state —
|
|
/// that overlay must be excluded from the slice count.
|
|
/// </summary>
|
|
[Fact]
|
|
public void MeterSliceExtraction_ReadsGrandchildImageIds_IgnoresOverlay()
|
|
{
|
|
// Slice ids sourced from format doc §11 — real health-bar ids.
|
|
const uint BackL = 0x0600747Eu, BackT = 0x0600747Fu, BackR = 0x06007480u;
|
|
const uint FrontL = 0x06007481u, FrontT = 0x06007482u, FrontR = 0x06007483u;
|
|
const uint OverlayFile = 0x06007490u;
|
|
|
|
// Back container (ReadOrder 0 — drawn first / behind)
|
|
var backChild = new ElementInfo { Type = 3, ReadOrder = 0 };
|
|
backChild.Children.Add(new ElementInfo { X = 0, StateMedia = { [""] = (BackL, 1) } });
|
|
backChild.Children.Add(new ElementInfo { X = 10, StateMedia = { [""] = (BackT, 1) } });
|
|
backChild.Children.Add(new ElementInfo { X = 140, StateMedia = { [""] = (BackR, 1) } });
|
|
|
|
// Front container (ReadOrder 1 — drawn on top)
|
|
var frontChild = new ElementInfo { Type = 3, ReadOrder = 1 };
|
|
frontChild.Children.Add(new ElementInfo { X = 0, StateMedia = { [""] = (FrontL, 1) } });
|
|
frontChild.Children.Add(new ElementInfo { X = 10, StateMedia = { [""] = (FrontT, 1) } });
|
|
frontChild.Children.Add(new ElementInfo { X = 140, StateMedia = { [""] = (FrontR, 1) } });
|
|
// Expand-detail overlay: named state only — NO DirectState "" — must be ignored.
|
|
frontChild.Children.Add(new ElementInfo
|
|
{
|
|
X = 0,
|
|
StateMedia = { ["ShowDetail"] = (OverlayFile, 3) }
|
|
});
|
|
|
|
var meter = new ElementInfo { Type = 7, Width = 150, Height = 16 };
|
|
meter.Children.Add(backChild);
|
|
meter.Children.Add(frontChild);
|
|
|
|
var e = DatWidgetFactory.Create(meter, NoTex, null);
|
|
|
|
var m = Assert.IsType<UiMeter>(e);
|
|
Assert.Equal(BackL, m.BackLeft);
|
|
Assert.Equal(BackT, m.BackTile);
|
|
Assert.Equal(BackR, m.BackRight);
|
|
Assert.Equal(FrontL, m.FrontLeft);
|
|
Assert.Equal(FrontT, m.FrontTile);
|
|
Assert.Equal(FrontR, m.FrontRight);
|
|
// Overlay (ShowDetail-only, no DirectState "") must not leak into any slice slot.
|
|
Assert.NotEqual(OverlayFile, m.FrontRight);
|
|
Assert.NotEqual(OverlayFile, m.FrontTile);
|
|
}
|
|
|
|
// ── Justification build-time application (new for importer Fix A) ────────
|
|
|
|
/// <summary>
|
|
/// A Type-12 text element with HJustify=Center (the default) must produce a
|
|
/// UiText with Centered=true and RightAligned=false at build time.
|
|
/// This proves BuildText applies the dat's HJustify at construction without a
|
|
/// controller binding step.
|
|
/// </summary>
|
|
[Fact]
|
|
public void BuildText_HJustifyCenter_SetsCentered()
|
|
{
|
|
var info = new ElementInfo { Type = 12, Width = 100, Height = 20, HJustify = HJustify.Center };
|
|
var t = Assert.IsType<UiText>(DatWidgetFactory.Create(info, NoTex, null));
|
|
Assert.True(t.Centered);
|
|
Assert.False(t.RightAligned);
|
|
Assert.Equal(VJustify.Center, t.VerticalJustify);
|
|
}
|
|
|
|
/// <summary>
|
|
/// A Type-12 text element with HJustify=Right must produce a UiText with
|
|
/// Centered=false and RightAligned=true at build time.
|
|
/// </summary>
|
|
[Fact]
|
|
public void BuildText_HJustifyRight_SetsRightAligned()
|
|
{
|
|
var info = new ElementInfo { Type = 12, Width = 100, Height = 20, HJustify = HJustify.Right };
|
|
var t = Assert.IsType<UiText>(DatWidgetFactory.Create(info, NoTex, null));
|
|
Assert.False(t.Centered);
|
|
Assert.True(t.RightAligned);
|
|
}
|
|
|
|
/// <summary>
|
|
/// A Type-12 text element with HJustify=Left must produce a UiText with
|
|
/// Centered=false and RightAligned=false at build time.
|
|
/// </summary>
|
|
[Fact]
|
|
public void BuildText_HJustifyLeft_SetsNeitherCenteredNorRight()
|
|
{
|
|
var info = new ElementInfo { Type = 12, Width = 100, Height = 20, HJustify = HJustify.Left };
|
|
var t = Assert.IsType<UiText>(DatWidgetFactory.Create(info, NoTex, null));
|
|
Assert.False(t.Centered);
|
|
Assert.False(t.RightAligned);
|
|
}
|
|
|
|
/// <summary>
|
|
/// A Type-12 text element with VJustify=Top must produce a UiText with
|
|
/// VerticalJustify=Top at build time.
|
|
/// </summary>
|
|
[Fact]
|
|
public void BuildText_VJustifyTop_SetsVerticalJustifyTop()
|
|
{
|
|
var info = new ElementInfo { Type = 12, Width = 100, Height = 55, VJustify = VJustify.Top };
|
|
var t = Assert.IsType<UiText>(DatWidgetFactory.Create(info, NoTex, null));
|
|
Assert.Equal(VJustify.Top, t.VerticalJustify);
|
|
}
|
|
|
|
/// <summary>
|
|
/// A controller override: build-time sets Centered=true (HJustify=Center), then
|
|
/// a controller sets Centered=false afterward. The controller's override wins —
|
|
/// this is the backward-compatibility guarantee.
|
|
/// </summary>
|
|
[Fact]
|
|
public void BuildText_ControllerOverrideWinsAfterBuild()
|
|
{
|
|
var info = new ElementInfo { Type = 12, Width = 100, Height = 20, HJustify = HJustify.Center };
|
|
var t = Assert.IsType<UiText>(DatWidgetFactory.Create(info, NoTex, null));
|
|
Assert.True(t.Centered); // build-time value
|
|
t.Centered = false; // controller override
|
|
Assert.False(t.Centered); // override wins
|
|
}
|
|
|
|
// ── DefaultColor from dat FontColor (importer Fix B) ─────────────────────
|
|
|
|
/// <summary>
|
|
/// When ElementInfo.FontColor is set (dat carried 0x1B ColorBaseProperty),
|
|
/// DatWidgetFactory.BuildText must copy it onto UiText.DefaultColor.
|
|
/// </summary>
|
|
[Fact]
|
|
public void BuildText_FontColorPresent_SetsDefaultColor()
|
|
{
|
|
var gold = new Vector4(1f, 0.82f, 0.36f, 1f);
|
|
var info = new ElementInfo { Type = 12, Width = 100, Height = 20, FontColor = gold };
|
|
var t = Assert.IsType<UiText>(DatWidgetFactory.Create(info, NoTex, null));
|
|
Assert.Equal(gold, t.DefaultColor);
|
|
}
|
|
|
|
[Fact]
|
|
public void BuildText_RetailConstructorDefaultsToZeroMargins()
|
|
{
|
|
var info = new ElementInfo { Type = 12, Width = 100, Height = 20 };
|
|
var text = Assert.IsType<UiText>(DatWidgetFactory.Create(info, NoTex, null));
|
|
|
|
Assert.Equal(0f, text.Padding);
|
|
Assert.False(text.OneLine);
|
|
}
|
|
|
|
[Fact]
|
|
public void BuildText_AuthoredLineTracksStateFontColor()
|
|
{
|
|
var info = new ElementInfo
|
|
{
|
|
Type = 12,
|
|
Width = 100,
|
|
Height = 20,
|
|
DefaultStateId = RetailUiStateIds.Closed,
|
|
DefaultStateName = "Closed",
|
|
};
|
|
var direct = new UiStateInfo { Id = UiStateInfo.DirectStateId };
|
|
direct.Properties.Values[0x17u] = new UiPropertyValue
|
|
{
|
|
Kind = UiPropertyKind.StringInfo,
|
|
StringInfoValue = new UiStringInfoValue(0, 1, 2, 0, 1, 0),
|
|
};
|
|
var closed = new UiStateInfo { Id = RetailUiStateIds.Closed, Name = "Closed" };
|
|
closed.Properties.Values[0x1Bu] = Color(127, 127, 127);
|
|
var open = new UiStateInfo { Id = RetailUiStateIds.Open, Name = "Open" };
|
|
open.Properties.Values[0x1Bu] = Color(204, 204, 204);
|
|
info.States[UiStateInfo.DirectStateId] = direct;
|
|
info.States[RetailUiStateIds.Closed] = closed;
|
|
info.States[RetailUiStateIds.Open] = open;
|
|
|
|
var text = Assert.IsType<UiText>(DatWidgetFactory.Create(
|
|
info, NoTex, null, stringResolve: _ => "Spells"));
|
|
Assert.Equal(127f / 255f, text.LinesProvider()[0].Color.X, 5);
|
|
|
|
Assert.True(text.TrySetRetailState(RetailUiStateIds.Open));
|
|
|
|
Assert.Equal(204f / 255f, text.LinesProvider()[0].Color.X, 5);
|
|
}
|
|
|
|
[Fact]
|
|
public void HorizontalScrollbar_PreservesNestedCombatMeterFillSprite()
|
|
{
|
|
var track = new ElementInfo { Id = 4, Type = 3, Width = 507, Height = 14 };
|
|
track.StateMedia[""] = (0x06001919u, 1);
|
|
track.States[UiStateInfo.DirectStateId] = new UiStateInfo
|
|
{ Id = UiStateInfo.DirectStateId, Image = new UiImageMedia(0x06001919u, 1) };
|
|
var thumb = new ElementInfo { Id = 1, Type = 1, Width = 12, Height = 14 };
|
|
thumb.StateMedia[""] = (0x06001923u, 1);
|
|
thumb.States[UiStateInfo.DirectStateId] = new UiStateInfo
|
|
{ Id = UiStateInfo.DirectStateId, Image = new UiImageMedia(0x06001923u, 1) };
|
|
var fill = new ElementInfo { Id = 2, Type = 3, Width = 507, Height = 14 };
|
|
fill.StateMedia[""] = (0x06001200u, 1);
|
|
fill.States[UiStateInfo.DirectStateId] = new UiStateInfo
|
|
{ Id = UiStateInfo.DirectStateId, Image = new UiImageMedia(0x06001200u, 1) };
|
|
var range = new ElementInfo
|
|
{
|
|
Id = 0x100005EFu,
|
|
Type = 3,
|
|
X = 63,
|
|
Width = 407,
|
|
Height = 14,
|
|
OriginalParentWidth = 507,
|
|
OriginalParentHeight = 14,
|
|
HasOriginalParentSize = true,
|
|
};
|
|
range.StateMedia[""] = (0x0600715Eu, 1);
|
|
range.States[UiStateInfo.DirectStateId] = new UiStateInfo
|
|
{ Id = UiStateInfo.DirectStateId, Image = new UiImageMedia(0x0600715Eu, 1) };
|
|
var meter = new ElementInfo
|
|
{
|
|
Id = CombatUiController.PowerControlId + 1,
|
|
Type = 7,
|
|
Width = 507,
|
|
Height = 14,
|
|
Children = [fill, range],
|
|
};
|
|
var meterState = new UiStateInfo { Id = UiStateInfo.DirectStateId };
|
|
meterState.Properties.Values[0x6Fu] = new UiPropertyValue
|
|
{ Kind = UiPropertyKind.Enum, UnsignedValue = 1u };
|
|
meter.States[UiStateInfo.DirectStateId] = meterState;
|
|
var info = new ElementInfo
|
|
{
|
|
Id = CombatUiController.PowerControlId,
|
|
Type = 11,
|
|
Width = 507,
|
|
Height = 14,
|
|
Children = [track, meter, thumb],
|
|
};
|
|
|
|
var bar = Assert.IsType<UiScrollbar>(
|
|
DatWidgetFactory.Create(info, NoTex, null));
|
|
|
|
Assert.True(bar.Horizontal);
|
|
Assert.Equal(0x06001919u, bar.TrackSprite);
|
|
Assert.Equal(0x06001923u, bar.ThumbSprite);
|
|
Assert.Equal(0x06001200u, bar.ScalarFillSprite);
|
|
Assert.Equal(0x0600715Eu, bar.ScalarRangeSprite);
|
|
Assert.NotNull(bar.ScalarRangeLayoutPolicy);
|
|
Assert.False(bar.ScalarFillFromRight);
|
|
}
|
|
|
|
private static ElementInfo TextInfo(params (uint Id, UiPropertyValue Value)[] properties)
|
|
{
|
|
var info = new ElementInfo { Id = 0x10000016u, Type = 12, Width = 100, Height = 20 };
|
|
var state = new UiStateInfo { Id = UiStateInfo.DirectStateId };
|
|
foreach (var property in properties)
|
|
state.Properties.Values[property.Id] = property.Value;
|
|
info.States[UiStateInfo.DirectStateId] = state;
|
|
return info;
|
|
}
|
|
|
|
private static UiPropertyValue Bool(bool value)
|
|
=> new() { Kind = UiPropertyKind.Bool, BoolValue = value };
|
|
|
|
private static UiPropertyValue Integer(int value)
|
|
=> new() { Kind = UiPropertyKind.Integer, IntegerValue = value };
|
|
|
|
private static UiPropertyValue Color(byte red, byte green, byte blue)
|
|
=> new()
|
|
{
|
|
Kind = UiPropertyKind.Color,
|
|
ColorValue = new UiColorValue(blue, green, red, 255),
|
|
};
|
|
|
|
/// <summary>
|
|
/// When ElementInfo.FontColor is null (dat carried no 0x1B), DefaultColor must
|
|
/// stay at white (Vector4.One) — the backward-compatible default.
|
|
/// </summary>
|
|
[Fact]
|
|
public void BuildText_FontColorAbsent_DefaultColorIsWhite()
|
|
{
|
|
var info = new ElementInfo { Type = 12, Width = 100, Height = 20, FontColor = null };
|
|
var t = Assert.IsType<UiText>(DatWidgetFactory.Create(info, NoTex, null));
|
|
Assert.Equal(Vector4.One, t.DefaultColor);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Backward-compat guarantee: a controller that sets LinesProvider with an
|
|
/// explicit per-line color AFTER build is unaffected by DefaultColor.
|
|
/// The controller's per-line color is stored in the Line record, not DefaultColor,
|
|
/// so DefaultColor changing from dat does not touch existing controller bindings.
|
|
/// </summary>
|
|
[Fact]
|
|
public void BuildText_ControllerExplicitLineColor_UnaffectedByDefaultColor()
|
|
{
|
|
// Dat sets a parchment color.
|
|
var parchment = new Vector4(0.92f, 0.90f, 0.82f, 1f);
|
|
var info = new ElementInfo { Type = 12, Width = 100, Height = 20, FontColor = parchment };
|
|
var t = Assert.IsType<UiText>(DatWidgetFactory.Create(info, NoTex, null));
|
|
|
|
// Controller overrides with an explicit gold color in the Line record.
|
|
var gold = new Vector4(1f, 0.82f, 0.36f, 1f);
|
|
t.LinesProvider = () => new[] { new UiText.Line("text", gold) };
|
|
|
|
// The line's color is gold (controller wins), NOT the dat parchment.
|
|
Assert.Equal(gold, t.LinesProvider()[0].Color);
|
|
// DefaultColor still holds the dat parchment (unmodified by the controller binding).
|
|
Assert.Equal(parchment, t.DefaultColor);
|
|
}
|
|
}
|