fix(rendering): bound portal resource lifetime

Separate logical ownership, render publication, and GPU retirement across live entities, landblocks, particles, textures, mesh arenas, portal/UI teardown, and per-frame scratch storage. Add bounded DAT/texture caches, upload budgets, three-frame fence retirement, exact-incarnation appearance reconciliation, frame pacing, and extensive lifetime conformance coverage.\n\nThe seven-destination connected route now cuts peak working/private memory roughly in half, returns Caul to 125-153 FPS locally, and produces no WER or AMD reset.\n\nCo-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-18 21:35:16 +02:00
parent 3971997689
commit 749e8ceeb1
225 changed files with 29107 additions and 3914 deletions

View file

@ -3,6 +3,7 @@ using System.Collections.Generic;
using AcDream.Core.Items;
using AcDream.Core.Player;
using DatReaderWriter;
using AcDream.Content;
namespace AcDream.App.UI.Layout;
@ -186,7 +187,7 @@ public sealed class CharacterSheetProvider
/// never silently swallowed — and leave raise costs unavailable (0).
/// </summary>
public static DatReaderWriter.DBObjs.ExperienceTable? LoadExperienceTable(
DatCollection dats, Action<string>? log = null)
IDatReaderWriter dats, Action<string>? log = null)
{
if (dats is null) return null;

View file

@ -75,6 +75,17 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
private ChatChannelKind _activeChannel = ChatChannelKind.Say;
// UiText polls LinesProvider while drawing and hit-testing. Keep the fully
// formatted + wrapped transcript until either its source revision or the
// metrics that determine wrapping change. This makes an idle chat window
// allocation-free instead of snapshotting/formatting/wrapping every frame.
private IReadOnlyList<UiText.Line> _cachedTranscriptLines = Array.Empty<UiText.Line>();
private long _cachedTranscriptRevision = -1;
private float _cachedTranscriptWrapWidth = float.NaN;
private UiDatFont? _cachedTranscriptDatFont;
private BitmapFont? _cachedTranscriptDebugFont;
internal int TranscriptLayoutBuildCount { get; private set; }
// ── Channel knowledge (ported from old UiChannelMenu — gmMainChatUI::InitTalkFocusMenu @0x4cdc50) ──
private static readonly (string Label, ChatChannelKind? Channel)[] ChannelItems =
@ -215,7 +226,7 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
c.Transcript.OneLine = false;
c.Transcript.Selectable = true;
c.Transcript.BackgroundColor = new Vector4(0f, 0f, 0f, 0.35f); // retail translucent transcript
c.Transcript.LinesProvider = () => BuildLines(vm, c.Transcript, datFont, debugFont);
c.Transcript.LinesProvider = () => c.GetTranscriptLines(vm);
// ── Input ────────────────────────────────────────────────────────
// Editable/selectable/one-line semantics and state sprites came from the
@ -415,20 +426,35 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
/// <see cref="UiText.Line"/> record format, applying retail-faithful
/// per-<see cref="ChatKind"/> colors.
/// </summary>
private static IReadOnlyList<UiText.Line> BuildLines(
ChatVM vm, UiText view, UiDatFont? datFont, BitmapFont? debugFont)
private IReadOnlyList<UiText.Line> GetTranscriptLines(ChatVM vm)
{
float maxW = Transcript.Width - 2f * Transcript.Padding;
UiDatFont? datFont = Transcript.DatFont;
BitmapFont? debugFont = Transcript.Font;
long revision = vm.Revision;
if (_cachedTranscriptRevision == revision
&& _cachedTranscriptWrapWidth.Equals(maxW)
&& ReferenceEquals(_cachedTranscriptDatFont, datFont)
&& ReferenceEquals(_cachedTranscriptDebugFont, debugFont))
{
return _cachedTranscriptLines;
}
var detailed = vm.RecentLinesDetailed();
if (detailed.Count == 0) return Array.Empty<UiText.Line>();
if (detailed.Count == 0)
{
return StoreTranscriptLayout(
Array.Empty<UiText.Line>(), revision, maxW, datFont, debugFont);
}
// Word-wrap each message to the transcript's current pixel width (ports retail
// GlyphList::Recalculate @0x473800 — break at word boundaries when the line would
// exceed wrapWidth). Re-evaluated each frame so wrapping follows window resize.
float maxW = view.Width - 2f * view.Padding;
// exceed wrapWidth). The cache key re-evaluates it after window resize.
Func<string, float> measure =
datFont is { } df ? s => df.MeasureWidth(s)
: debugFont is { } bf ? s => bf.MeasureWidth(s)
: s => s.Length * 7f;
: static s => s.Length * 7f;
var result = new List<UiText.Line>(detailed.Count);
foreach (var d in detailed)
@ -437,7 +463,23 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
foreach (var frag in WrapText(d.Text, maxW, measure))
result.Add(new UiText.Line(frag, color));
}
return result;
return StoreTranscriptLayout(result, revision, maxW, datFont, debugFont);
}
private IReadOnlyList<UiText.Line> StoreTranscriptLayout(
IReadOnlyList<UiText.Line> lines,
long revision,
float wrapWidth,
UiDatFont? datFont,
BitmapFont? debugFont)
{
_cachedTranscriptRevision = revision;
_cachedTranscriptWrapWidth = wrapWidth;
_cachedTranscriptDatFont = datFont;
_cachedTranscriptDebugFont = debugFont;
_cachedTranscriptLines = lines;
TranscriptLayoutBuildCount++;
return lines;
}
/// <summary>

View file

@ -1,4 +1,5 @@
using DatReaderWriter;
using AcDream.Content;
namespace AcDream.App.UI.Layout;
@ -62,7 +63,7 @@ public sealed class ComponentBookTemplateFactory
/// so later inventory refreshes instantiate rows without reading DAT files.
/// </summary>
public static ComponentBookTemplateFactory? TryLoad(
DatCollection dats,
IDatReaderWriter dats,
Func<uint, (uint tex, int w, int h)> resolveSprite,
UiDatFont? defaultFont,
Func<uint, UiDatFont?>? resolveFont)

View file

@ -1,4 +1,5 @@
using DatReaderWriter;
using AcDream.Content;
using DatReaderWriter.DBObjs;
namespace AcDream.App.UI.Layout;
@ -14,10 +15,10 @@ namespace AcDream.App.UI.Layout;
/// </remarks>
public sealed class DatStringResolver
{
private readonly DatCollection _dats;
private readonly IDatReaderWriter _dats;
private readonly Dictionary<uint, StringTable?> _tables = new();
public DatStringResolver(DatCollection dats)
public DatStringResolver(IDatReaderWriter dats)
=> _dats = dats ?? throw new ArgumentNullException(nameof(dats));
public string? Resolve(UiStringInfoValue info)

View file

@ -1,4 +1,5 @@
using DatReaderWriter;
using AcDream.Content;
namespace AcDream.App.UI.Layout;
@ -31,7 +32,7 @@ public sealed class EffectRowTemplateFactory
public float Height => _template.Height;
public static EffectRowTemplateFactory? TryLoad(
DatCollection dats,
IDatReaderWriter dats,
Func<uint, (uint tex, int w, int h)> resolveSprite,
UiDatFont? defaultFont,
Func<uint, UiDatFont?>? resolveFont)

View file

@ -1,5 +1,6 @@
using System.Collections.Generic;
using DatReaderWriter;
using AcDream.Content;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Types;
@ -30,7 +31,7 @@ public static class ItemListCellTemplate
/// Returns 0 when the layout/element/attribute/prototype/media is absent (the caller keeps
/// the <see cref="UiItemSlot"/> default).
/// </summary>
public static uint ResolveEmptySprite(DatCollection dats, uint listLayoutId, uint listElementId)
public static uint ResolveEmptySprite(IDatReaderWriter dats, uint listLayoutId, uint listElementId)
{
var listLd = dats.Get<LayoutDesc>(listLayoutId);
if (listLd is null) return 0;
@ -52,7 +53,7 @@ public static class ItemListCellTemplate
/// attribute <c>0x1000000E</c>.
/// </summary>
public static uint ResolveEmptySprite(
DatCollection dats,
IDatReaderWriter dats,
ElementInfo resolvedRoot,
uint listElementId)
{
@ -71,7 +72,7 @@ public static class ItemListCellTemplate
/// <see cref="CatalogLayoutId"/>. Paperdoll lists select distinct prototypes for their
/// jewelry, weapon, clothing, and armor locations even though the lists share one base.
/// </summary>
public static uint ResolvePrototypeEmptySprite(DatCollection dats, uint prototypeElementId)
public static uint ResolvePrototypeEmptySprite(IDatReaderWriter dats, uint prototypeElementId)
{
if (prototypeElementId == 0) return 0;

View file

@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using AcDream.Content;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
@ -179,7 +180,7 @@ public static class LayoutImporter
/// </summary>
/// <param name="dats">The dat collection to read the LayoutDesc from.</param>
/// <param name="layoutId">The LayoutDesc dat id to read.</param>
public static ElementInfo? ImportInfos(DatCollection dats, uint layoutId)
public static ElementInfo? ImportInfos(IDatReaderWriter dats, uint layoutId)
{
var ld = dats.Get<LayoutDesc>(layoutId);
if (ld is null) return null;
@ -234,7 +235,7 @@ public static class LayoutImporter
/// top-level template. DialogFactory uses this path for the shared dialog catalog.
/// </summary>
public static ElementInfo? ImportInfos(
DatCollection dats,
IDatReaderWriter dats,
uint layoutId,
uint rootElementId)
{
@ -273,7 +274,7 @@ public static class LayoutImporter
/// <param name="fontResolve">Optional per-element font resolver (see
/// <see cref="Build"/> for details). Null = original single-font behavior.</param>
public static ImportedLayout? Import(
DatCollection dats,
IDatReaderWriter dats,
uint layoutId,
Func<uint, (uint, int, int)> resolve,
UiDatFont? datFont,
@ -287,7 +288,7 @@ public static class LayoutImporter
/// <summary>Import one selected root from a catalog-style LayoutDesc.</summary>
public static ImportedLayout? Import(
DatCollection dats,
IDatReaderWriter dats,
uint layoutId,
uint rootElementId,
Func<uint, (uint, int, int)> resolve,
@ -314,7 +315,7 @@ public static class LayoutImporter
/// (cycle-guarded by <paramref name="baseChain"/>), then resolves + attaches children.
/// </summary>
private static ElementInfo Resolve(
DatCollection dats,
IDatReaderWriter dats,
ElementDesc d,
HashSet<(uint layoutId, uint elementId)> baseChain)
{
@ -369,7 +370,7 @@ public static class LayoutImporter
}
private static void IncorporateChildren(
DatCollection dats,
IDatReaderWriter dats,
ElementInfo result,
IReadOnlyList<ElementInfo>? baseChildren,
ElementDesc derived)
@ -406,7 +407,7 @@ public static class LayoutImporter
}
private static ElementInfo IncorporateResolvedChild(
DatCollection dats,
IDatReaderWriter dats,
ElementInfo baseChild,
ElementDesc derivedChild)
{

View file

@ -1,5 +1,6 @@
using AcDream.Core.Items;
using AcDream.Core.Textures;
using AcDream.Content;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
@ -38,11 +39,11 @@ public sealed class PaperdollClickMap
/// and decodes the resulting RenderSurface for exact per-pixel sampling.
/// The caller owns synchronization around <paramref name="dats"/>.
/// </summary>
public static PaperdollClickMap? Load(DatCollection dats)
public static PaperdollClickMap? Load(IDatReaderWriter dats)
{
ArgumentNullException.ThrowIfNull(dats);
uint masterDid = (uint)dats.Portal.Header.MasterMapId;
uint masterDid = (uint)dats.Portal.Db.Header.MasterMapId;
if (masterDid == 0
|| !dats.Portal.TryGet<EnumIDMap>(masterDid, out var master)
|| master is null

View file

@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using AcDream.Core.Items;
using AcDream.Content;
using DatReaderWriter;
namespace AcDream.App.UI.Layout;
@ -56,7 +57,7 @@ public static class PaperdollSlotBackgrounds
};
/// <summary>Resolves every supported slot's exact empty RenderSurface from the live DAT.</summary>
public static IReadOnlyDictionary<uint, uint> ResolveEmptySprites(DatCollection dats)
public static IReadOnlyDictionary<uint, uint> ResolveEmptySprites(IDatReaderWriter dats)
{
var result = new Dictionary<uint, uint>(Definitions.Length);
foreach (Definition definition in Definitions)

View file

@ -29,6 +29,8 @@ public sealed class RadarSnapshotProvider
private readonly Func<bool> _coordinatesOnRadar;
private readonly Func<bool> _uiLocked;
private readonly Func<uint, RadarRelationshipTraits>? _relationshipFor;
private readonly Action<uint, int, List<KeyValuePair<uint, WorldEntity>>>? _copySpatialCandidates;
private readonly List<KeyValuePair<uint, WorldEntity>> _candidateScratch = new();
public RadarSnapshotProvider(
ClientObjectTable objects,
@ -41,7 +43,8 @@ public sealed class RadarSnapshotProvider
Func<bool> coordinatesOnRadar,
Func<bool> uiLocked,
Func<uint, RadarRelationshipTraits>? relationshipFor = null,
Func<IReadOnlyDictionary<uint, WorldEntity>>? playerEntities = null)
Func<IReadOnlyDictionary<uint, WorldEntity>>? playerEntities = null,
Action<uint, int, List<KeyValuePair<uint, WorldEntity>>>? copySpatialCandidates = null)
{
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
_worldEntities = worldEntities ?? throw new ArgumentNullException(nameof(worldEntities));
@ -54,6 +57,7 @@ public sealed class RadarSnapshotProvider
_coordinatesOnRadar = coordinatesOnRadar ?? throw new ArgumentNullException(nameof(coordinatesOnRadar));
_uiLocked = uiLocked ?? throw new ArgumentNullException(nameof(uiLocked));
_relationshipFor = relationshipFor;
_copySpatialCandidates = copySpatialCandidates;
}
public UiRadarSnapshot BuildSnapshot()
@ -85,14 +89,33 @@ public sealed class RadarSnapshotProvider
(uint)(_objects.Get(playerGuid)?.Type ?? ItemType.None), playerPwd);
float range = RetailRadar.GetRangeMeters(isOutside);
var blips = new List<UiRadarBlip>(worldEntities.Count);
foreach (var pair in worldEntities)
_candidateScratch.Clear();
if (_copySpatialCandidates is not null)
{
// Retail radar is at most 75 metres while a landblock is 192
// metres wide. The current block plus its eight neighbours is a
// complete broadphase (the cheap spatial pre-filter).
_copySpatialCandidates(playerCellId, 1, _candidateScratch);
}
else
{
foreach (var pair in worldEntities)
_candidateScratch.Add(pair);
}
var blips = new List<UiRadarBlip>(Math.Min(_candidateScratch.Count, 64));
foreach (var pair in _candidateScratch)
{
uint guid = pair.Key;
if (guid == playerGuid)
continue;
var entity = pair.Value;
if (!worldEntities.TryGetValue(guid, out WorldEntity? visibleEntity)
|| !ReferenceEquals(entity, visibleEntity))
{
continue;
}
var clientObject = _objects.Get(guid);
spawns.TryGetValue(guid, out var spawn);

View file

@ -1,4 +1,5 @@
using System.Numerics;
using AcDream.Content;
using DatReaderWriter;
namespace AcDream.App.UI.Layout;
@ -33,7 +34,7 @@ public readonly record struct SpellbookRowStyle(
/// Returns null if the authored prototype is incomplete; callers then leave the
/// panel unbound rather than inventing replacement art.
/// </summary>
public static SpellbookRowStyle? TryLoad(DatCollection dats)
public static SpellbookRowStyle? TryLoad(IDatReaderWriter dats)
{
ArgumentNullException.ThrowIfNull(dats);
ElementInfo? prototype = LayoutImporter.ImportInfos(dats, CatalogLayoutId, PrototypeId);