diff --git a/src/AcDream.App/UI/IUiViewportRenderer.cs b/src/AcDream.App/UI/IUiViewportRenderer.cs
new file mode 100644
index 00000000..13023078
--- /dev/null
+++ b/src/AcDream.App/UI/IUiViewportRenderer.cs
@@ -0,0 +1,11 @@
+namespace AcDream.App.UI;
+
+/// 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.
+public interface IUiViewportRenderer
+{
+ /// Render at (width,height); return the color-texture GL handle, or 0 if nothing rendered.
+ uint Render(int width, int height);
+}
diff --git a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs
index 2fb75977..125f174a 100644
--- a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs
+++ b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs
@@ -62,13 +62,14 @@ public static class DatWidgetFactory
// still ships — it just isn't wired into the factory switch yet.
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)
+ 1 => new UiButton(info, resolve), // UIElement_Button (reg :125828)
+ 6 => new UiMenu(), // UIElement_Menu (reg :120163)
+ 7 => BuildMeter(info, resolve, datFont), // UIElement_Meter
+ 0xD => new UiViewport(), // UIElement_Viewport — 3-D mini-scene blit leaf
+ 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)
+ _ => new UiDatElement(info, resolve), // generic fallback (incl. Type 3 chrome/containers)
};
// Propagate position + size (pixel-exact from the dat).
diff --git a/src/AcDream.App/UI/UiViewport.cs b/src/AcDream.App/UI/UiViewport.cs
new file mode 100644
index 00000000..22ceabf5
--- /dev/null
+++ b/src/AcDream.App/UI/UiViewport.cs
@@ -0,0 +1,25 @@
+using System.Numerics;
+
+namespace AcDream.App.UI;
+
+/// 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).
+public sealed class UiViewport : UiElement
+{
+ public override bool ConsumesDatChildren => true;
+
+ /// Renderer that produces the off-screen texture. Set by GameWindow wiring (later task).
+ public IUiViewportRenderer? Renderer { get; set; }
+
+ /// Last GL color-texture handle produced by the pre-UI hook. 0 = nothing to blit.
+ 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).
+ // Draw the full-texture sprite with UV 0..1 and white tint (no color modulation).
+ ctx.DrawSprite(TextureHandle, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Vector4.One);
+ }
+}
diff --git a/tests/AcDream.App.Tests/UI/Layout/UiViewportFactoryTests.cs b/tests/AcDream.App.Tests/UI/Layout/UiViewportFactoryTests.cs
new file mode 100644
index 00000000..03a4873c
--- /dev/null
+++ b/tests/AcDream.App.Tests/UI/Layout/UiViewportFactoryTests.cs
@@ -0,0 +1,29 @@
+using AcDream.App.UI;
+using AcDream.App.UI.Layout;
+
+namespace AcDream.App.Tests.UI.Layout;
+
+public class UiViewportFactoryTests
+{
+ private static (uint, int, int) NoTex(uint _) => (0, 0, 0);
+
+ [Fact]
+ public void Factory_builds_UiViewport_for_dat_type_0xD()
+ {
+ var info = new ElementInfo { Type = 0xD, Width = 200, Height = 300 };
+ var widget = DatWidgetFactory.Create(info, NoTex, null);
+ var viewport = Assert.IsType(widget);
+ Assert.True(viewport.ConsumesDatChildren);
+ }
+
+ [Fact]
+ public void UiViewport_rect_set_from_ElementInfo()
+ {
+ var info = new ElementInfo { Type = 0xD, X = 10, Y = 20, Width = 180, Height = 240 };
+ var widget = DatWidgetFactory.Create(info, NoTex, null)!;
+ Assert.Equal(10f, widget.Left);
+ Assert.Equal(20f, widget.Top);
+ Assert.Equal(180f, widget.Width);
+ Assert.Equal(240f, widget.Height);
+ }
+}