acdream/src/AcDream.App/Rendering/RenderBootstrap.cs
Erik a0d33956f6 feat(ui): importer Fix C — per-element dat FontDid resolver (studio path); character level uses its retail font
DatWidgetFactory.Create now accepts an optional fontResolve: Func<uint,UiDatFont?>
parameter. When supplied and the element has a non-zero FontDid, the element
receives its own dat font instead of the shared global datFont fallback.
Null = original single-font behavior (the live GameWindow path passes null —
provably unchanged). LayoutImporter.Build/BuildFromInfos/Import all thread
the optional resolver down to the factory.

RenderStack gains a lazy font cache (ConcurrentDictionary, pre-seeded with
VitalsDatFont + LargeDatFont) and a ResolveDatFont(uint) method. StudioWindow
wires stack.ResolveDatFont into LayoutSource so every studio import gets
per-element fonts. GameWindow import calls left passing null (follow-up todo).

CharacterStatController font-hack cleanup (diagnosed via one-shot console dump
then removed):
- Name (0x10000231): dat FontDid = 18px font — remove datFont override (null)
- Heritage/PkStatus: dat FontDid = 14px fonts — remove override
- LevelCaption: dat FontDid = 16px — remove override (same font, no visual change)
- Level (0x1000023B): dat FontDid = 36px (the big retail gold font) — was forced
  to rowDatFont/LargeDatFont (18px); now drops to null so the dat 36px font drives
- TotalXpLabel/TotalXp: dat FontDid = 16px — remove override
- FooterTitle (0x1000024E): dat FontDid = 20px — remove datFont override
- KEEP: synthesized elements (XP meter overlays, 9 attribute rows, tab sprites)
  still use datFont directly since they have no dat origin

All Label/LabelTwoLine/LabelLeft/LabelProvider helpers updated: null = keep
build-time dat font; non-null = controller explicit override (backward-compat).

8 new tests in DatWidgetFactoryFontResolveTests:
- null resolver → DatFont == global datFont
- FontDid=0 → resolver not called
- resolver returns null → fallback to global datFont
- resolver called with element's FontDid
- controller DatFont override wins after build
- LayoutImporter.Build threads fontResolve to factory
- meter element fires resolver for non-zero FontDid
- BuildFromInfos without fontResolve param = original behavior

Build + all 710 App tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 10:30:13 +02:00

231 lines
10 KiB
C#

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;
}
}