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>
231 lines
10 KiB
C#
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;
|
|
}
|
|
}
|