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:
parent
3971997689
commit
749e8ceeb1
225 changed files with 29107 additions and 3914 deletions
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue