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>
488 lines
20 KiB
C#
488 lines
20 KiB
C#
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 (cur−base)/(cap−base); 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;
|
||
}
|
||
}
|
||
}
|