acdream/src/AcDream.App/UI/Layout/CharacterSheetProvider.cs
Erik 749e8ceeb1 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>
2026-07-18 21:35:16 +02:00

488 lines
20 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Collections.Generic;
using AcDream.Core.Items;
using AcDream.Core.Player;
using DatReaderWriter;
using AcDream.Content;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Assembles the live <see cref="CharacterSheet"/> for the retail Character
/// window and owns the raise-request flow (wire send + optimistic local
/// apply). Extracted from <c>GameWindow</c> (Code Structure Rule 1: sheet
/// assembly + XP-curve math is feature logic, not wiring).
///
/// <para>Retail property ids and the decomp anchors for every field are
/// documented on <see cref="CharacterSheet"/>; the raise-cost formulas are
/// cited there too (gmAttributeUI::GetCostToRaise 0x0049cb80 family).</para>
///
/// <para><b>State ownership:</b> optimistic debits go through the owning
/// store's eventful APIs — <see cref="ClientObjectTable.UpdateIntProperty"/> /
/// <see cref="ClientObjectTable.UpdateInt64Property"/> (fires ObjectUpdated)
/// when the player object is in the table, else
/// <see cref="LocalPlayerState.DebitIntProperty"/> /
/// <see cref="LocalPlayerState.DebitInt64Property"/> (fires CharacterChanged).
/// Never write the raw property dictionaries from UI code. The next server
/// snapshot remains authoritative over every optimistic value.</para>
/// </summary>
public sealed class CharacterSheetProvider
{
/// <summary>PropertyInt64 2 = unassigned (banked) XP — CharacterSheet.UnassignedXp.</summary>
private const uint UnassignedXpPropertyId = 2u;
/// <summary>
/// Retail PropertyInt 0x18 = available skill credits. Properties 0xB5 and
/// 0xC0 are Chess Rank and Fishing Skill and must never be debited.
/// </summary>
private static readonly uint[] SkillCreditPropertyIds = { 0x18u };
private readonly ClientObjectTable _objects;
private readonly LocalPlayerState _localPlayer;
private readonly Func<uint> _playerGuid;
private readonly Func<string?>? _activeToonName;
private readonly Func<string, CharacterSheet>? _fallbackSheet;
private readonly Func<bool>? _canSendRaise;
private readonly Action<uint, ulong>? _sendRaiseAttribute;
private readonly Action<uint, ulong>? _sendRaiseVital;
private readonly Action<uint, ulong>? _sendRaiseSkill;
private readonly Action<uint, uint>? _sendTrainSkill;
/// <summary>Portal SkillTable (0x0E000004) — set by the host once dats load.</summary>
public DatReaderWriter.DBObjs.SkillTable? SkillTable { get; set; }
/// <summary>Portal ExperienceTable (0x0E000018) — set by the host once dats load.</summary>
public DatReaderWriter.DBObjs.ExperienceTable? ExperienceTable { get; set; }
public CharacterSheetProvider(
ClientObjectTable objects,
LocalPlayerState localPlayer,
Func<uint> playerGuid,
Func<string?>? activeToonName = null,
Func<string, CharacterSheet>? fallbackSheet = null,
Func<bool>? canSendRaise = null,
Action<uint, ulong>? sendRaiseAttribute = null,
Action<uint, ulong>? sendRaiseVital = null,
Action<uint, ulong>? sendRaiseSkill = null,
Action<uint, uint>? sendTrainSkill = null)
{
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
_localPlayer = localPlayer ?? throw new ArgumentNullException(nameof(localPlayer));
_playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid));
_activeToonName = activeToonName;
_fallbackSheet = fallbackSheet;
_canSendRaise = canSendRaise;
_sendRaiseAttribute = sendRaiseAttribute;
_sendRaiseVital = sendRaiseVital;
_sendRaiseSkill = sendRaiseSkill;
_sendTrainSkill = sendTrainSkill;
}
// ── Sheet assembly ─────────────────────────────────────────────────────
/// <summary>Best display name: active toon key, else the live object's name, else "Player".</summary>
public string CharacterName()
{
string? toon = _activeToonName?.Invoke();
if (!string.IsNullOrWhiteSpace(toon) && toon != "default")
return toon;
uint guid = _playerGuid();
if (guid != 0u && _objects.Get(guid)?.Name is { Length: > 0 } objectName)
return objectName;
return "Player";
}
/// <summary>
/// Build the sheet from live state; falls back to the injected sample
/// sheet (Studio data) until any live character data has arrived.
/// Called per repaint by the Character window's bound widgets.
/// </summary>
public CharacterSheet BuildSheet()
{
if (!HasLiveData())
return _fallbackSheet?.Invoke(CharacterName()) ?? new CharacterSheet { Name = CharacterName() };
var props = CurrentPlayerProperties();
int level = props.GetInt(0x19u);
long totalXp = props.GetInt64(1u);
long unassignedXp = props.GetInt64(UnassignedXpPropertyId);
var xp = ComputeLevelXp(level, totalXp);
int skillCredits = props.GetInt(0x18u);
return new CharacterSheet
{
Name = CharacterName(),
Level = level,
Gender = CharacterIdentityText.GenderDisplayName(
props.GetInt(CharacterIdentityText.GenderPropertyId)),
Heritage = CharacterIdentityText.HeritageGroupDisplayName(
props.GetInt(CharacterIdentityText.HeritageGroupPropertyId)),
PkStatus = PkStatusText(props.GetInt(134u, 0)),
TotalXp = totalXp,
XpToNextLevel = xp.toNext,
XpFraction = xp.fraction,
HealthCurrent = VitalCurrent(LocalPlayerState.VitalKind.Health),
HealthMax = VitalMax(LocalPlayerState.VitalKind.Health),
StaminaCurrent = VitalCurrent(LocalPlayerState.VitalKind.Stamina),
StaminaMax = VitalMax(LocalPlayerState.VitalKind.Stamina),
ManaCurrent = VitalCurrent(LocalPlayerState.VitalKind.Mana),
ManaMax = VitalMax(LocalPlayerState.VitalKind.Mana),
Strength = AttrCurrent(LocalPlayerState.AttributeKind.Strength),
Endurance = AttrCurrent(LocalPlayerState.AttributeKind.Endurance),
Coordination = AttrCurrent(LocalPlayerState.AttributeKind.Coordination),
Quickness = AttrCurrent(LocalPlayerState.AttributeKind.Quickness),
Focus = AttrCurrent(LocalPlayerState.AttributeKind.Focus),
Self = AttrCurrent(LocalPlayerState.AttributeKind.Self),
UnspentSkillCredits = skillCredits,
SpecializedSkillCredits = 0,
ChessRank = props.GetInt(0xB5u),
FishingSkill = props.GetInt(0xC0u),
BirthTimestamp = props.Ints.TryGetValue(0x62u, out int born)
? born
: null,
TotalPlayTimeSeconds = props.Ints.TryGetValue(0x7Du, out int played)
? played
: null,
Deaths = props.GetInt(0x2Bu),
SkillCredits = skillCredits,
UnassignedXp = unassignedXp,
AttributeRaiseCosts = BuildAttributeRaiseCosts(amount: 1),
AttributeRaise10Costs = BuildAttributeRaiseCosts(amount: 10),
Skills = BuildLiveCharacterSkills(),
BurdenCurrent = props.GetInt(5u),
BurdenMax = props.GetInt(96u),
EncumbranceAugmentations = props.GetInt(0xE6u),
CharacterInfoProperties = new Dictionary<uint, int>(props.Ints),
};
}
private bool HasLiveData()
{
var props = CurrentPlayerProperties();
return props.Ints.Count > 0
|| props.Int64s.Count > 0
|| _localPlayer.Skills.Count > 0
|| _localPlayer.GetAttribute(LocalPlayerState.AttributeKind.Strength) is not null
|| _localPlayer.Get(LocalPlayerState.VitalKind.Health) is not null;
}
/// <summary>The player's canonical bundle: the live ClientObject's when the
/// player object has arrived (CreateObject merge-upsert), else the
/// PlayerDescription snapshot on LocalPlayerState.</summary>
private PropertyBundle CurrentPlayerProperties()
{
uint guid = _playerGuid();
return guid != 0u && _objects.Get(guid) is { } player
? player.Properties
: _localPlayer.Properties;
}
/// <summary>
/// Load the portal ExperienceTable (0x0E000018), falling back to a
/// type scan for older or odd dat collections. Failures are logged —
/// never silently swallowed — and leave raise costs unavailable (0).
/// </summary>
public static DatReaderWriter.DBObjs.ExperienceTable? LoadExperienceTable(
IDatReaderWriter dats, Action<string>? log = null)
{
if (dats is null) return null;
try
{
var table = dats.Get<DatReaderWriter.DBObjs.ExperienceTable>(0x0E000018u);
if (table is not null) return table;
}
catch (Exception ex)
{
log?.Invoke($"[D.2b-C] ExperienceTable 0x0E000018 read failed ({ex.GetType().Name}: {ex.Message}); trying type scan.");
}
try
{
foreach (uint id in dats.GetAllIdsOfType<DatReaderWriter.DBObjs.ExperienceTable>())
{
var table = dats.Get<DatReaderWriter.DBObjs.ExperienceTable>(id);
if (table is not null) return table;
}
}
catch (Exception ex)
{
log?.Invoke($"[D.2b-C] ExperienceTable type scan failed ({ex.GetType().Name}: {ex.Message}); raise costs unavailable.");
}
return null;
}
/// <summary>XP still needed for the next level + fill fraction of the
/// current level band (retail (curbase)/(capbase); CharacterSheet.XpFraction).</summary>
private (long toNext, float fraction) ComputeLevelXp(int level, long totalXp)
{
var levels = ExperienceTable?.Levels;
if (levels is null || level < 0 || level + 1 >= levels.Length)
return (0L, 0f);
long current = ClampToLong(levels[level]);
long next = ClampToLong(levels[level + 1]);
if (next <= current) return (0L, 0f);
long clampedXp = totalXp < current ? current : totalXp > next ? next : totalXp;
long toNext = next - clampedXp;
float fraction = (float)(clampedXp - current) / (next - current);
return (toNext, fraction);
}
/// <summary>Per-attribute/vital raise costs in retail display order
/// (see CharacterSheet.AttributeRaiseCosts). Cost 0 = maxed / unknown.</summary>
private long[] BuildAttributeRaiseCosts(int amount)
{
var xp = ExperienceTable;
return new[]
{
AttributeRaiseCost(LocalPlayerState.AttributeKind.Strength),
AttributeRaiseCost(LocalPlayerState.AttributeKind.Endurance),
AttributeRaiseCost(LocalPlayerState.AttributeKind.Coordination),
AttributeRaiseCost(LocalPlayerState.AttributeKind.Quickness),
AttributeRaiseCost(LocalPlayerState.AttributeKind.Focus),
AttributeRaiseCost(LocalPlayerState.AttributeKind.Self),
VitalRaiseCost(LocalPlayerState.VitalKind.Health),
VitalRaiseCost(LocalPlayerState.VitalKind.Stamina),
VitalRaiseCost(LocalPlayerState.VitalKind.Mana),
};
long AttributeRaiseCost(LocalPlayerState.AttributeKind kind)
{
var attr = _localPlayer.GetAttribute(kind);
return attr is null || xp is null ? 0L : RaiseCostFromXpCurve(xp.Attributes, attr.Value.Ranks, attr.Value.Xp, amount);
}
long VitalRaiseCost(LocalPlayerState.VitalKind kind)
{
var vital = _localPlayer.Get(kind);
return vital is null || xp is null ? 0L : RaiseCostFromXpCurve(xp.Vitals, vital.Value.Ranks, vital.Value.Xp, amount);
}
}
private IReadOnlyList<CharacterSkill> BuildLiveCharacterSkills()
{
var result = new List<CharacterSkill>();
var skillTable = SkillTable;
var xp = ExperienceTable;
foreach (var snapshot in _localPlayer.Skills.Values)
{
var advancement = AdvancementFromStatus(snapshot.Status);
if (advancement == CharacterSkillAdvancementClass.Inactive)
continue;
DatReaderWriter.Types.SkillBase? skillBase = null;
if (skillTable?.Skills is not null)
skillTable.Skills.TryGetValue((DatReaderWriter.Enums.SkillId)snapshot.SkillId, out skillBase);
string? name = skillBase?.Name.Value;
if (string.IsNullOrWhiteSpace(name))
name = $"Skill {snapshot.SkillId}";
uint icon = skillBase?.IconId.DataId ?? 0u;
int trainedCost = skillBase?.TrainedCost ?? 0;
int specializedCost = skillBase?.SpecializedCost ?? 0;
long raiseCost = SkillRaiseCost(xp, advancement, snapshot, 1);
long raise10Cost = SkillRaiseCost(xp, advancement, snapshot, 10);
result.Add(new CharacterSkill(
snapshot.SkillId,
name,
icon,
advancement,
checked((int)Math.Min(int.MaxValue, snapshot.BaseLevel)),
checked((int)Math.Min(int.MaxValue, snapshot.CurrentLevel)),
IsUsableUntrained(snapshot.SkillId),
trainedCost,
specializedCost,
raiseCost,
raise10Cost));
}
return result;
}
private static CharacterSkillAdvancementClass AdvancementFromStatus(uint status) => status switch
{
1u => CharacterSkillAdvancementClass.Untrained,
2u => CharacterSkillAdvancementClass.Trained,
3u => CharacterSkillAdvancementClass.Specialized,
_ => CharacterSkillAdvancementClass.Inactive,
};
private static bool IsUsableUntrained(uint skillId) => skillId switch
{
18u or 37u or 38u or 39u or 40u => false,
_ => true,
};
private static long SkillRaiseCost(
DatReaderWriter.DBObjs.ExperienceTable? xp,
CharacterSkillAdvancementClass advancement,
LocalPlayerState.SkillSnapshot skill,
int amount)
{
if (xp is null) return 0L;
uint[] curve = advancement == CharacterSkillAdvancementClass.Specialized
? xp.SpecializedSkills
: xp.TrainedSkills;
return RaiseCostFromXpCurve(curve, skill.Ranks, skill.Xp, amount);
}
/// <summary>Cost to advance <paramref name="amount"/> ranks along a retail
/// cumulative-XP curve: curve[target] xpAlreadySpent, clamped at the
/// curve end (retail GetCostToRaise/GetCostToRaise10 0x0049cb80/0x0049cc70).</summary>
private static long RaiseCostFromXpCurve(uint[]? curve, uint ranks, uint spentXp, int amount)
{
if (curve is null || amount <= 0) return 0L;
long maxIndex = curve.Length - 1L;
if (maxIndex <= ranks) return 0L;
long targetLong = Math.Min((long)ranks + amount, maxIndex);
long targetXp = curve[(int)targetLong];
long cost = targetXp - spentXp;
return cost > 0 ? cost : 0L;
}
private static long ClampToLong(ulong value) =>
value > long.MaxValue ? long.MaxValue : (long)value;
private static string? PkStatusText(int status) => status switch
{
0x2 => "Non-Player Killer",
0x4 => "Player Killer",
0x40 => "Player Killer Lite",
_ => null,
};
private int AttrCurrent(LocalPlayerState.AttributeKind kind) =>
_localPlayer.GetAttribute(kind) is { } attr ? checked((int)Math.Min(int.MaxValue, attr.Current)) : 0;
private int VitalCurrent(LocalPlayerState.VitalKind kind) =>
_localPlayer.Get(kind) is { } vital ? checked((int)Math.Min(int.MaxValue, vital.Current)) : 0;
private int VitalMax(LocalPlayerState.VitalKind kind) =>
_localPlayer.GetMaxApprox(kind) is { } max ? checked((int)Math.Min(int.MaxValue, max)) : 0;
// ── Raise-request flow ─────────────────────────────────────────────────
/// <summary>
/// Send a raise/train action to the server and, when a send delegate
/// fired, optimistically apply the local effect so the sheet stays
/// current during the round trip. The next server snapshot remains
/// authoritative (a rejected raise is corrected by the property echo —
/// pending/rollback ledger tracked as a follow-up issue).
/// </summary>
public void HandleRaiseRequest(CharacterStatController.RaiseRequest request)
{
if (request.Cost <= 0) return;
if (_canSendRaise is not null && !_canSendRaise()) return;
bool sent = false;
switch (request.Kind)
{
case CharacterStatController.RaiseTargetKind.Attribute:
if (_sendRaiseAttribute is not null)
{
_sendRaiseAttribute(request.StatId, (ulong)request.Cost);
sent = true;
}
break;
case CharacterStatController.RaiseTargetKind.Vital:
if (_sendRaiseVital is not null)
{
_sendRaiseVital(request.StatId, (ulong)request.Cost);
sent = true;
}
break;
case CharacterStatController.RaiseTargetKind.Skill:
if (_sendRaiseSkill is not null)
{
_sendRaiseSkill(request.StatId, (ulong)request.Cost);
sent = true;
}
break;
case CharacterStatController.RaiseTargetKind.TrainSkill:
if (_sendTrainSkill is not null && request.Cost <= uint.MaxValue)
{
_sendTrainSkill(request.StatId, (uint)request.Cost);
sent = true;
}
break;
}
if (sent)
ApplyLocalRaise(request);
}
private void ApplyLocalRaise(CharacterStatController.RaiseRequest request)
{
uint amount = request.Amount <= 0 ? 1u : (uint)request.Amount;
ulong cost = (ulong)request.Cost;
switch (request.Kind)
{
case CharacterStatController.RaiseTargetKind.Attribute:
if (_localPlayer.ApplyAttributeRaise(request.StatId, amount, cost))
SpendUnassignedExperience(request.Cost);
break;
case CharacterStatController.RaiseTargetKind.Vital:
if (_localPlayer.ApplyVitalRaise(request.StatId, amount, cost))
SpendUnassignedExperience(request.Cost);
break;
case CharacterStatController.RaiseTargetKind.Skill:
if (_localPlayer.ApplySkillRaise(request.StatId, amount, cost))
SpendUnassignedExperience(request.Cost);
break;
case CharacterStatController.RaiseTargetKind.TrainSkill:
if (_localPlayer.ApplySkillTraining(request.StatId))
SpendSkillCredits(request.Cost);
break;
}
}
private void SpendUnassignedExperience(long cost)
{
if (cost <= 0) return;
uint guid = _playerGuid();
if (guid != 0u && _objects.Get(guid) is { } player)
{
long current = player.Properties.GetInt64(UnassignedXpPropertyId);
if (current <= 0) return;
_objects.UpdateInt64Property(guid, UnassignedXpPropertyId,
current > cost ? current - cost : 0L);
return;
}
_localPlayer.DebitInt64Property(UnassignedXpPropertyId, cost);
}
private void SpendSkillCredits(long cost)
{
if (cost <= 0) return;
int debit = cost > int.MaxValue ? int.MaxValue : (int)cost;
uint guid = _playerGuid();
if (guid != 0u && _objects.Get(guid) is { } player)
{
foreach (uint propertyId in SkillCreditPropertyIds)
{
if (!player.Properties.Ints.TryGetValue(propertyId, out int current))
continue;
_objects.UpdateIntProperty(guid, propertyId,
current > debit ? current - debit : 0);
return;
}
return;
}
foreach (uint propertyId in SkillCreditPropertyIds)
{
if (_localPlayer.DebitIntProperty(propertyId, debit))
return;
}
}
}