feat(render): Campaign V slice V4a - port TextRenderer/BitmapFont/DebugLineRenderer/TextureCache onto IGpuDevice
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>
This commit is contained in:
parent
ec414d60cd
commit
ceec3bc440
334 changed files with 3660 additions and 3840 deletions
|
|
@ -1,4 +1,4 @@
|
|||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using AcDream.App.Rendering;
|
||||
|
|
@ -10,7 +10,7 @@ using AcDream.UI.Abstractions.Panels.Chat;
|
|||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Binds the imported chat LayoutDesc (0x21000006) to live behavior — the acdream
|
||||
/// Binds the imported chat LayoutDesc (0x21000006) to live behavior — the acdream
|
||||
/// analogue of retail <c>ChatInterface</c> + <c>gmMainChatUI::PostInit @0x4ce130</c>.
|
||||
///
|
||||
/// <para>
|
||||
|
|
@ -24,20 +24,20 @@ namespace AcDream.App.UI.Layout;
|
|||
/// and bound in place.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class ChatWindowController : IRetainedWindowStateController, IRetainedPanelController
|
||||
internal sealed class ChatWindowController : IRetainedWindowStateController, IRetainedPanelController
|
||||
{
|
||||
public const uint LayoutId = 0x21000006u;
|
||||
private bool _disposed;
|
||||
|
||||
// Element ids from chat LayoutDesc 0x21000006 (confirmed in Task D/G1).
|
||||
private const uint RootId = 0x1000000Eu;
|
||||
private const uint ResizeBarId = 0x1000000Fu; // dat top resize bar (800px — dropped; nine-slice grips replace it)
|
||||
private const uint ResizeBarId = 0x1000000Fu; // dat top resize bar (800px — dropped; nine-slice grips replace it)
|
||||
private const uint TranscriptPanelId = 0x10000010u;
|
||||
private const uint TranscriptId = 0x10000011u; // Type-12 prototype — skipped by factory
|
||||
private const uint TranscriptId = 0x10000011u; // Type-12 prototype — skipped by factory
|
||||
private const uint TrackId = 0x10000012u;
|
||||
private const uint InputBarId = 0x10000013u;
|
||||
private const uint MenuId = 0x10000014u;
|
||||
private const uint InputId = 0x10000016u; // Type-12 Text + Editable 0x16 → UiField
|
||||
private const uint InputId = 0x10000016u; // Type-12 Text + Editable 0x16 → UiField
|
||||
private const uint SendId = 0x10000019u;
|
||||
private const uint MaxMinId = 0x1000046Fu;
|
||||
|
||||
|
|
@ -48,7 +48,7 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
|
|||
private const uint MenuItemRow = 0x0600124Eu; // item row bg (template 0x1000001E)
|
||||
private const uint MenuItemSelected = 0x0600124Du; // active channel row
|
||||
|
||||
// ── Public surface ─────────────────────────────────────────────────────
|
||||
// ── Public surface ─────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Root element of the imported layout (the chat window chrome).</summary>
|
||||
public UiElement Root { get; private set; } = null!;
|
||||
|
|
@ -71,7 +71,7 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
|
|||
public RetailWindowHandle? WindowHandle { get; private set; }
|
||||
public bool IsMaximized => _maximized;
|
||||
|
||||
// ── Private state ──────────────────────────────────────────────────────
|
||||
// ── Private state ──────────────────────────────────────────────────────
|
||||
|
||||
private ChatChannelKind _activeChannel = ChatChannelKind.Say;
|
||||
|
||||
|
|
@ -86,7 +86,7 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
|
|||
private BitmapFont? _cachedTranscriptDebugFont;
|
||||
internal int TranscriptLayoutBuildCount { get; private set; }
|
||||
|
||||
// ── Channel knowledge (ported from old UiChannelMenu — gmMainChatUI::InitTalkFocusMenu @0x4cdc50) ──
|
||||
// ── Channel knowledge (ported from old UiChannelMenu — gmMainChatUI::InitTalkFocusMenu @0x4cdc50) ──
|
||||
|
||||
private static readonly (string Label, ChatChannelKind? Channel)[] ChannelItems =
|
||||
{
|
||||
|
|
@ -133,7 +133,7 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
|
|||
private bool _maximized;
|
||||
private UiButton? _maxMinButton;
|
||||
|
||||
// ── Factory ────────────────────────────────────────────────────────────
|
||||
// ── Factory ────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Bind an imported chat layout to live behavior.
|
||||
|
|
@ -156,7 +156,7 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
|
|||
/// <param name="datFont">Retail dat font for transcript + input rendering.</param>
|
||||
/// <param name="debugFont">Fallback debug bitmap font (used when
|
||||
/// <paramref name="datFont"/> is null).</param>
|
||||
/// <param name="resolve">Dat RenderSurface id → (GL tex handle, px width, px height).
|
||||
/// <param name="resolve">Dat RenderSurface id → (GL tex handle, px width, px height).
|
||||
/// Forwarded to <see cref="UiScrollbar"/> and <see cref="UiMenu"/>.</param>
|
||||
public static ChatWindowController? Bind(
|
||||
ElementInfo rootInfo,
|
||||
|
|
@ -165,7 +165,7 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
|
|||
Func<ICommandBus> busProvider,
|
||||
UiDatFont? datFont,
|
||||
BitmapFont? debugFont,
|
||||
Func<uint, (uint tex, int w, int h)> resolve)
|
||||
Func<uint, (GpuTextureSlot tex, int w, int h)> resolve)
|
||||
{
|
||||
// Their parent panels must exist as real widgets in the layout tree.
|
||||
var transcriptPanel = layout.FindElement(TranscriptPanelId);
|
||||
|
|
@ -177,14 +177,14 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
|
|||
Console.WriteLine(
|
||||
$"[D.2b] ChatWindowController.Bind: missing required elements " +
|
||||
$"(input={input is not null}, " +
|
||||
$"panel={transcriptPanel is not null}, bar={inputBar is not null}) — " +
|
||||
$"panel={transcriptPanel is not null}, bar={inputBar is not null}) — " +
|
||||
$"chat window will not be interactive.");
|
||||
return null;
|
||||
}
|
||||
|
||||
// LayoutDesc 0x21000006 has SEVERAL top-level elements: the gmMainChatUI window
|
||||
// (RootId 0x1000000E) PLUS stray auxiliary elements that are NOT part of the docked
|
||||
// window — a separate Field+ListBox (0x1000001C/1D, the floaty scrollback), the
|
||||
// window — a separate Field+ListBox (0x1000001C/1D, the floaty scrollback), the
|
||||
// talk-focus highlight strip (0x1000001E), and a scroll-button prototype (0x10000526).
|
||||
// LayoutImporter.ImportInfos wraps all top-level elements in a synthetic Type-3 root,
|
||||
// so using layout.Root would render the strays overlapping the real window (the
|
||||
|
|
@ -210,9 +210,9 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
|
|||
transcriptPanel.Top = 0f;
|
||||
transcriptPanel.Height += 9f; // dat resize-bar height (0x1000000F H=9)
|
||||
|
||||
// ── Transcript ───────────────────────────────────────────────────
|
||||
// ── Transcript ───────────────────────────────────────────────────
|
||||
// The factory now builds the Type-12 transcript element (0x10000011) as a UiText.
|
||||
// Find it in the widget tree and bind the live providers — no remove/add needed.
|
||||
// Find it in the widget tree and bind the live providers — no remove/add needed.
|
||||
c.Transcript = layout.FindElement(TranscriptId) as UiText
|
||||
?? throw new InvalidOperationException("chat transcript 0x10000011 not built as UiText");
|
||||
c.Transcript.DatFont = datFont;
|
||||
|
|
@ -228,7 +228,7 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
|
|||
c.Transcript.BackgroundColor = new Vector4(0f, 0f, 0f, 0.35f); // retail translucent transcript
|
||||
c.Transcript.LinesProvider = () => c.GetTranscriptLines(vm);
|
||||
|
||||
// ── Input ────────────────────────────────────────────────────────
|
||||
// ── Input ────────────────────────────────────────────────────────
|
||||
// Editable/selectable/one-line semantics and state sprites came from the
|
||||
// imported property/state bags. The controller supplies runtime services only.
|
||||
c.Input = input;
|
||||
|
|
@ -238,9 +238,9 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
|
|||
c.Input.SpriteResolve = resolve;
|
||||
c.Input.OnSubmit = text => ChatCommandRouter.Submit(text, vm, busProvider(), c._activeChannel);
|
||||
|
||||
// ── Scrollbar — bind the factory-built Type-11 track element ────────
|
||||
// ── Scrollbar — bind the factory-built Type-11 track element ────────
|
||||
// The factory now builds the Type-11 track element (0x10000012) as a UiScrollbar
|
||||
// directly. Find it, bind it in place — no remove/add needed.
|
||||
// directly. Find it, bind it in place — no remove/add needed.
|
||||
var track = layout.FindElement(TrackId);
|
||||
if (track is UiScrollbar bar)
|
||||
{
|
||||
|
|
@ -253,7 +253,7 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
|
|||
c.Scrollbar = bar;
|
||||
}
|
||||
|
||||
// ── Channel menu — bind the factory-built Type-6 UiMenu ──────────
|
||||
// ── Channel menu — bind the factory-built Type-6 UiMenu ──────────
|
||||
if (layout.FindElement(MenuId) is UiMenu menu)
|
||||
{
|
||||
menu.DatFont = datFont; menu.Font = debugFont; menu.SpriteResolve = resolve;
|
||||
|
|
@ -268,7 +268,7 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
|
|||
menu.EnabledProvider = p => p is not ChatChannelKind ch || ChannelAvailable(ch);
|
||||
menu.ButtonLabelProvider = () => ChannelButtonLabel(c._activeChannel);
|
||||
// The widget reports the pick; the controller owns Selected. Only a talk-channel
|
||||
// payload updates the active channel + highlight — the null-payload specials are
|
||||
// payload updates the active channel + highlight — the null-payload specials are
|
||||
// deferred no-ops (see the chat re-drive deferred list) and leave selection intact.
|
||||
menu.OnSelect = p =>
|
||||
{
|
||||
|
|
@ -277,18 +277,18 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
|
|||
c.Menu = menu;
|
||||
}
|
||||
|
||||
// ── Send button — Enter-alternate submit trigger ──────────────────
|
||||
// ── Send button — Enter-alternate submit trigger ──────────────────
|
||||
// Retail's gmMainChatUI wires the Send button to the same ProcessCommand path.
|
||||
if (layout.FindElement(SendId) is UiButton sendEl)
|
||||
{
|
||||
sendEl.OnClick = () => c.Input.Submit();
|
||||
// The Send sprite is a blank gold button — retail draws the caption as text.
|
||||
// The Send sprite is a blank gold button — retail draws the caption as text.
|
||||
sendEl.Label = "Send";
|
||||
sendEl.LabelFont = datFont;
|
||||
sendEl.LabelColor = new Vector4(1f, 0.92f, 0.72f, 1f);
|
||||
}
|
||||
|
||||
// ── Size the channel button to its label + reflow the input field ─
|
||||
// ── Size the channel button to its label + reflow the input field ─
|
||||
// Retail's talk-focus button autosizes to the selected channel name; the input
|
||||
// field then fills the gap from the button's right edge to the Send button. The
|
||||
// dat authors the button at a fixed 46px (too narrow for "Chat" once the LED +
|
||||
|
|
@ -310,12 +310,12 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
|
|||
ReflowInputRow();
|
||||
}
|
||||
|
||||
// ── Max/min toggle — gmMainChatUI::HandleMaximizeButton ──
|
||||
// ── Max/min toggle — gmMainChatUI::HandleMaximizeButton ──
|
||||
if (layout.FindElement(MaxMinId) is UiButton maxMinEl)
|
||||
{
|
||||
// The dat puts max/min and the scrollbar up-button at the SAME X (both
|
||||
// right-anchored), so at content width they overlap. Retail shows max/min
|
||||
// just LEFT of the scrollbar column — shift it one button-width left.
|
||||
// just LEFT of the scrollbar column — shift it one button-width left.
|
||||
if (track is not null)
|
||||
maxMinEl.Left = track.Left - maxMinEl.Width;
|
||||
maxMinEl.ResetAnchorCapture();
|
||||
|
|
@ -326,7 +326,7 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
|
|||
return c;
|
||||
}
|
||||
|
||||
// ── Max/min implementation ─────────────────────────────────────────────
|
||||
// ── Max/min implementation ─────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Attach the typed outer-frame handle after the controller's imported content
|
||||
|
|
@ -419,7 +419,7 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
|
|||
return null;
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────
|
||||
// ── Helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Convert the ChatVM's detailed lines to the transcript's
|
||||
|
|
@ -449,7 +449,7 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
|
|||
}
|
||||
|
||||
// Word-wrap each message to the transcript's current pixel width (ports retail
|
||||
// GlyphList::Recalculate @0x473800 — break at word boundaries when the line would
|
||||
// GlyphList::Recalculate @0x473800 — break at word boundaries when the line would
|
||||
// exceed wrapWidth). The cache key re-evaluates it after window resize.
|
||||
Func<string, float> measure =
|
||||
datFont is { } df ? s => df.MeasureWidth(s)
|
||||
|
|
@ -486,8 +486,8 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
|
|||
/// Greedy word-wrap: split <paramref name="text"/> into fragments that each fit in
|
||||
/// <paramref name="maxW"/> pixels (per <paramref name="measure"/>), breaking at spaces.
|
||||
/// A word that is itself wider than the line is broken at CHARACTER boundaries (no
|
||||
/// hyphen), packed onto the current line first — so a long unbroken token (e.g. a URL
|
||||
/// or "wwwww…") wraps instead of overflowing, and a "You say," prefix stays on the same
|
||||
/// hyphen), packed onto the current line first — so a long unbroken token (e.g. a URL
|
||||
/// or "wwwww…") wraps instead of overflowing, and a "You say," prefix stays on the same
|
||||
/// row as the start of the message. Mirrors retail GlyphList::Recalculate's per-GlyphLine
|
||||
/// emission (which breaks mid-glyph-run when a run exceeds the wrap width).
|
||||
/// </summary>
|
||||
|
|
@ -510,7 +510,7 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
|
|||
}
|
||||
if (line.Length > 0 && measure(word) <= maxW)
|
||||
{
|
||||
yield return line.ToString(); // word fits alone → push to a new line
|
||||
yield return line.ToString(); // word fits alone → push to a new line
|
||||
line.Clear();
|
||||
line.Append(word);
|
||||
continue;
|
||||
|
|
@ -532,7 +532,7 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-<see cref="ChatKind"/> text color — the EXACT retail RGBA values read from a
|
||||
/// Per-<see cref="ChatKind"/> text color — the EXACT retail RGBA values read from a
|
||||
/// live retail client via cdb (the named <c>RGBAColor</c> constants at acclient
|
||||
/// 0x81c4a8+, e.g. <c>colorWhite</c>/<c>colorBrightPurple</c>/<c>colorLightBlue</c>/
|
||||
/// <c>colorGreen</c>, used by <c>ChatInterface::BuildChatColorLookupTable @0x4f31c0</c>).
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue