feat: port retail magic lifecycle and retained spell UI

Complete the retail cast-intent, target, component, enchantment, and busy-state paths; mount the DAT-authored spell bar, spellbook, component book, effects panels, and shared panel lifecycle; and add scoped input plus conformance coverage.

Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
Erik 2026-07-15 10:55:22 +02:00
parent 7b7ffcd278
commit 07be994d97
84 changed files with 17822 additions and 1051 deletions

View file

@ -11,6 +11,10 @@ namespace AcDream.App.Rendering;
public sealed class GameWindow : IDisposable
{
private static double ClientTimerNow() =>
System.Diagnostics.Stopwatch.GetTimestamp()
/ (double)System.Diagnostics.Stopwatch.Frequency;
private readonly record struct SkyPesKey(int ObjectIndex, uint PesObjectId, bool PostScene);
private readonly AcDream.App.RuntimeOptions _options;
@ -833,6 +837,7 @@ public sealed class GameWindow : IDisposable
private AcDream.App.Combat.CombatAttackController? _combatAttackController;
private AcDream.App.Combat.CombatTargetController? _combatTargetController;
private AcDream.App.UI.ItemInteractionController? _itemInteractionController;
private AcDream.App.Spells.MagicRuntime? _magicRuntime;
private readonly AcDream.Core.Items.StackSplitQuantityState _stackSplitQuantity = new();
// Phase D.2b Sub-phase C Slice 2 — the 3-D doll viewport: an off-screen RTT renderer, the UiViewport
// widget that blits it, the inventory frame (for the open-gate), and a dirty flag (re-dress on 0xF625).
@ -1289,6 +1294,8 @@ public sealed class GameWindow : IDisposable
_inputDispatcher = new AcDream.UI.Abstractions.Input.InputDispatcher(
_kbSource, _mouseSource, _keyBindings);
_inputDispatcher.Fired += OnInputAction;
Combat.CombatModeChanged += SetInputCombatScope;
SetInputCombatScope(Combat.CurrentMode);
// Retail CameraSet::ToggleMouseLook / Rotate (0x00457490 /
// 0x00458310): the callback submits a filtered horizontal
@ -2085,11 +2092,8 @@ public sealed class GameWindow : IDisposable
_vitalsVm ??= new AcDream.UI.Abstractions.Panels.Vitals.VitalsVM(Combat, LocalPlayer);
_uiHost = new AcDream.App.UI.UiHost(_gl, shadersDir, _debugFont);
_uiHost.Root.UiLocked = _persistedGameplay.LockUI;
var spellComponentTable =
// DBObj file id is 0x0E00000F. Retail's IsComponentPack
// passes enum key 0x10000001 to DBObj::GetByEnum; that key is
// NOT a portal.dat file id (using it here reads unrelated bytes).
_dats!.Get<DatReaderWriter.DBObjs.SpellComponentTable>(0x0E00000Fu);
AcDream.App.Spells.MagicCatalog magicCatalog =
AcDream.App.Spells.MagicCatalog.Load(_dats!);
_itemInteractionController = new AcDream.App.UI.ItemInteractionController(
Objects,
playerGuid: () => _playerServerGuid,
@ -2110,7 +2114,7 @@ public sealed class GameWindow : IDisposable
playerOnGround: GetDebugPlayerOnGround,
inNonCombatMode: () => Combat.CurrentMode
== AcDream.Core.Combat.CombatMode.NonCombat,
isComponentPack: wcid => spellComponentTable?.Components.ContainsKey(wcid) == true,
isComponentPack: magicCatalog.IsComponentPack,
placeInBackpack: SendPickUp,
sendSplitToWorld: (item, amount) =>
_liveSession?.SendStackableSplitTo3D(item, amount),
@ -2138,6 +2142,24 @@ public sealed class GameWindow : IDisposable
sendRaiseVital: (statId, cost) => _liveSession?.SendRaiseVital(statId, cost),
sendRaiseSkill: (statId, cost) => _liveSession?.SendRaiseSkill(statId, cost),
sendTrainSkill: (statId, credits) => _liveSession?.SendTrainSkill(statId, credits));
_magicRuntime = AcDream.App.Spells.MagicRuntime.Create(
magicCatalog,
SpellBook,
Objects,
selectedObject: () => _selection.SelectedObjectId,
localPlayerId: () => _playerServerGuid,
// SpellFormula::Randomize hashes the canonical account spelling
// delivered by CharacterList.
accountName: () => _liveSession?.Characters?.AccountName
?? _options.LiveUser
?? string.Empty,
stopCompletely: PreparePlayerForAttackRequest,
sendUntargeted: spellId => _liveSession?.SendCastUntargetedSpell(spellId),
sendTargeted: (target, spellId) => _liveSession?.SendCastTargetedSpell(target, spellId),
displayMessage: text => Chat.OnSystemMessage(text, 0x1Au),
incrementBusy: () => _itemInteractionController.IncrementBusyCount(),
canSend: () => _liveSession is not null
&& _liveSession.CurrentState == AcDream.Core.Net.WorldSession.State.InWorld);
_uiHost.Root.DragReleasedOutsideUi += OnUiDragReleasedOutside;
// Feed Silk input to the UiRoot tree so windows drag / close / select.
@ -2228,6 +2250,31 @@ public sealed class GameWindow : IDisposable
_combatAttackController,
() => _persistedGameplay,
SetRetailCombatGameplay),
Magic: new AcDream.App.UI.MagicRuntimeBindings(
SpellBook,
_magicRuntime.Casting,
Objects,
() => _playerServerGuid,
magicCatalog.Components,
(type, icon, under, over, effects) =>
iconComposer.GetIcon(type, icon, under, over, effects),
(type, icon, under, over, effects) =>
iconComposer.GetDragIcon(type, icon, under, over, effects),
iconComposer.GetSpellIcon,
iconComposer.GetSpellComponentIcon,
_selection,
magicCatalog.GetSpellLevel,
guid => _selection.Select(
guid, AcDream.Core.Selection.SelectionChangeSource.Inventory),
UseItemByGuid,
(tab, position, spellId) =>
_liveSession?.SendAddSpellFavorite(spellId, position, tab),
(tab, spellId) =>
_liveSession?.SendRemoveSpellFavorite(spellId, tab),
filters => _liveSession?.SendSpellbookFilter(filters),
(componentId, amount) =>
_liveSession?.SendSetDesiredComponentLevel(componentId, amount),
ClientTimerNow),
JumpPowerbar: new AcDream.App.UI.JumpPowerbarRuntimeBindings(
() => _playerController?.JumpCharge ?? default),
Fps: new AcDream.App.UI.FpsRuntimeBindings(
@ -2763,6 +2810,9 @@ public sealed class GameWindow : IDisposable
// them down before dropping the canonical GUID/timestamp snapshots.
_equippedChildRenderer?.Clear();
Objects.Clear();
SpellBook.Clear();
_magicRuntime?.Reset();
_itemInteractionController?.ClearBusy();
_selection.Reset();
_pendingPostArrivalAction = null;
try
@ -2932,7 +2982,8 @@ public sealed class GameWindow : IDisposable
onCharacterOptions: (options1, _) =>
_characterOptions1 =
(AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1)
options1);
options1,
clientTime: ClientTimerNow);
// Phase I.7: subscribe to CombatState events and emit
// retail-faithful "You hit X for Y damage" chat lines into
@ -12423,6 +12474,17 @@ public sealed class GameWindow : IDisposable
/// <see cref="OnUpdate"/> via <see cref="InputDispatcher.IsActionHeld"/>;
/// this method handles transitional Press/Release events only.
/// </summary>
private void SetInputCombatScope(AcDream.Core.Combat.CombatMode mode)
{
_inputDispatcher?.SetCombatScope(mode switch
{
AcDream.Core.Combat.CombatMode.Melee => AcDream.UI.Abstractions.Input.InputScope.MeleeCombat,
AcDream.Core.Combat.CombatMode.Missile => AcDream.UI.Abstractions.Input.InputScope.MissileCombat,
AcDream.Core.Combat.CombatMode.Magic => AcDream.UI.Abstractions.Input.InputScope.MagicCombat,
_ => null,
});
}
private void OnInputAction(
AcDream.UI.Abstractions.Input.InputAction action,
AcDream.UI.Abstractions.Input.ActivationType activation)
@ -14584,6 +14646,7 @@ public sealed class GameWindow : IDisposable
_uiHost = null;
_itemInteractionController?.Dispose();
_itemInteractionController = null;
_magicRuntime = null;
// Phase A.1: join the streamer worker thread before tearing down GL
// state. The worker may still be processing a load job that references
@ -14632,6 +14695,7 @@ public sealed class GameWindow : IDisposable
_debugFont?.Dispose();
_frameProfiler.Dispose(); // MP0: releases the GpuFrameTimer query ring
_dats?.Dispose();
Combat.CombatModeChanged -= SetInputCombatScope;
_input?.Dispose();
_gl?.Dispose();
}

View file

@ -0,0 +1,197 @@
using System;
using System.Collections.Generic;
using System.Linq;
using AcDream.Core.Items;
using AcDream.Core.Spells;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
namespace AcDream.App.Spells;
/// <summary>Immutable presentation metadata for one retail spell component WCID.</summary>
public sealed record SpellComponentDescriptor(
uint WeenieClassId,
string Name,
uint Category,
uint IconId);
/// <summary>
/// App-layer projection of retail's spell, component, and school-focus DAT tables.
/// Keeping this catalog outside <c>GameWindow</c> makes the SCID/WCID boundary and
/// rough spell-level calculation one independently testable ownership seam.
/// </summary>
public sealed class MagicCatalog
{
private const uint SpellTableDid = 0x0E00000Eu;
private const uint SpellComponentTableDid = 0x0E00000Fu;
private const uint ComponentIdMapDid = 0x27000002u;
private readonly IReadOnlyDictionary<uint, SpellFormulaDefinition> _formulaDefinitions;
private readonly IReadOnlyDictionary<uint, uint> _wcidByScid;
private readonly IReadOnlyDictionary<uint, uint> _magicPackWcidBySchool;
private readonly IReadOnlyDictionary<uint, int> _spellLevels;
private MagicCatalog(
IReadOnlyDictionary<uint, SpellComponentDescriptor> components,
IReadOnlyDictionary<uint, SpellFormulaDefinition> formulaDefinitions,
IReadOnlyDictionary<uint, uint> wcidByScid,
IReadOnlyDictionary<uint, uint> magicPackWcidBySchool,
IReadOnlyDictionary<uint, int> spellLevels)
{
Components = components;
_formulaDefinitions = formulaDefinitions;
_wcidByScid = wcidByScid;
_magicPackWcidBySchool = magicPackWcidBySchool;
_spellLevels = spellLevels;
}
public IReadOnlyDictionary<uint, SpellComponentDescriptor> Components { get; }
public bool IsComponentPack(uint weenieClassId)
=> Components.ContainsKey(weenieClassId);
public int GetSpellLevel(uint spellId)
=> _spellLevels.TryGetValue(spellId, out int level) ? level : 0;
public SpellComponentRequirementService CreateRequirementService(
ClientObjectTable objects,
Func<uint> playerGuid,
Func<string> accountName)
=> new(
objects,
playerGuid,
accountName,
_formulaDefinitions,
_wcidByScid,
_magicPackWcidBySchool);
public static MagicCatalog Load(DatCollection dats)
{
ArgumentNullException.ThrowIfNull(dats);
var components = new Dictionary<uint, SpellComponentDescriptor>();
var wcidByScid = new Dictionary<uint, uint>();
SpellComponentTable? componentTable = dats.Get<SpellComponentTable>(SpellComponentTableDid);
DualEnumIDMap? componentIds = dats.Get<DualEnumIDMap>(ComponentIdMapDid);
if (componentTable is not null && componentIds is not null)
{
foreach (var pair in componentTable.Components)
{
if (!componentIds.ClientEnumToID.TryGetValue(pair.Key, out uint wcid))
continue;
wcidByScid[pair.Key] = wcid;
components[wcid] = new SpellComponentDescriptor(
wcid,
pair.Value.Name.Value,
pair.Value.Category,
pair.Value.Icon.DataId);
}
}
var formulaDefinitions = new Dictionary<uint, SpellFormulaDefinition>();
var spellLevels = new Dictionary<uint, int>();
DatReaderWriter.DBObjs.SpellTable? spellTable =
dats.Get<DatReaderWriter.DBObjs.SpellTable>(SpellTableDid);
if (spellTable is not null)
{
foreach (var pair in spellTable.Spells)
{
uint[] formula = pair.Value.Components.Take(8).ToArray();
formulaDefinitions[pair.Key] = new SpellFormulaDefinition(
pair.Value.FormulaVersion,
formula,
(uint)pair.Value.School);
spellLevels[pair.Key] =
RetailSpellFormula.InqSpellLevelByRoughHeuristic(formula);
}
}
var magicPackWcidBySchool = new Dictionary<uint, uint>();
// Retail IsComponentPack resolves enum key 0x10000001 in category 0x28;
// that key is not itself a portal.dat file id.
uint magicPackMapDid = AcDream.App.UI.RetailDataIdResolver.Resolve(
dats, 0x10000001u, 0x28u);
if (magicPackMapDid != 0u
&& dats.Portal.TryGet<EnumIDMap>(magicPackMapDid, out EnumIDMap? magicPackMap)
&& magicPackMap is not null)
{
foreach (var pair in magicPackMap.ClientEnumToID)
magicPackWcidBySchool[pair.Key] = pair.Value;
}
return new MagicCatalog(
components,
formulaDefinitions,
wcidByScid,
magicPackWcidBySchool,
spellLevels);
}
}
/// <summary>
/// App-layer owner for the live retail magic request path. The server owns
/// turning, motion, fizzle, mana/component consumption, effects, and results.
/// </summary>
public sealed class MagicRuntime
{
private MagicRuntime(MagicCatalog catalog, SpellCastingController casting)
{
Catalog = catalog;
Casting = casting;
}
public MagicCatalog Catalog { get; }
public SpellCastingController Casting { get; }
public static MagicRuntime Create(
MagicCatalog catalog,
Spellbook spellbook,
ClientObjectTable objects,
Func<uint?> selectedObject,
Func<uint> localPlayerId,
Func<string> accountName,
Action stopCompletely,
Action<uint> sendUntargeted,
Action<uint, uint> sendTargeted,
Action<string> displayMessage,
Action incrementBusy,
Func<bool> canSend)
{
ArgumentNullException.ThrowIfNull(catalog);
ArgumentNullException.ThrowIfNull(spellbook);
ArgumentNullException.ThrowIfNull(objects);
ArgumentNullException.ThrowIfNull(displayMessage);
SpellComponentRequirementService requirements = catalog.CreateRequirementService(
objects, localPlayerId, accountName);
bool TargetCompatible(uint target, SpellMetadata spell, bool showMessage)
{
if (objects.Get(target) is not { } targetObject)
return false;
SpellTargetPolicyResult result = RetailSpellTargetPolicy.Evaluate(
localPlayerId(), targetObject, spell);
if (showMessage && !result.Allowed && result.Message is { } message)
displayMessage(message);
return result.Allowed;
}
var casting = new SpellCastingController(
spellbook,
selectedObject,
localPlayerId,
stopCompletely,
sendUntargeted,
sendTargeted,
displayMessage,
targetCompatible: (target, spell) => TargetCompatible(target, spell, true),
hasRequiredComponents: requirements.HasRequiredComponents,
incrementBusy: incrementBusy,
canSend: canSend,
targetCompatibleSilent: (target, spell) => TargetCompatible(target, spell, false));
return new MagicRuntime(catalog, casting);
}
public void Reset() => Casting.Reset();
}

View file

@ -0,0 +1,45 @@
using AcDream.Core.Items;
using AcDream.Core.Spells;
namespace AcDream.App.Spells;
public readonly record struct SpellTargetPolicyResult(bool Allowed, string? Message)
{
public static SpellTargetPolicyResult Accept { get; } = new(true, null);
}
/// <summary>
/// Pure projection of ClientMagicSystem::ObjectCompatibleWithSpellTargetType
/// (0x00567230). The caller owns target lookup and message presentation.
/// </summary>
public static class RetailSpellTargetPolicy
{
private const uint SpecialTargetMask = 0x00008107u;
public static SpellTargetPolicyResult Evaluate(
uint localPlayerId,
ClientObject target,
SpellMetadata spell)
{
uint mask = spell.TargetMask;
uint special = mask & SpecialTargetMask;
if (target.ObjectId == localPlayerId && special == 0u)
return new(false, "You cannot cast this spell upon yourself.");
if (target.StackSize > 1)
return new(false, "Cannot cast spell on a stack of items.");
if (((uint)target.Type & mask) == 0u && special == 0u)
return new(false, $"This spell cannot be cast on {target.Name}.");
// Retail joins the ordinary type match and the 0x8107 special-mask
// bypass before these two gates. IsPlayer is PWD bit 3; otherwise the
// object must carry BF_ATTACKABLE. Pets are never legal spell targets.
var flags = (PublicWeenieFlags)target.PublicWeenieBitfield.GetValueOrDefault();
bool playerOrAttackable = (flags
& (PublicWeenieFlags.Player | PublicWeenieFlags.Attackable)) != 0;
if (!playerOrAttackable || target.PetOwnerId != 0u)
return new(false, $"This spell cannot be cast on {target.Name}.");
return SpellTargetPolicyResult.Accept;
}
}

View file

@ -0,0 +1,157 @@
using System;
using AcDream.Core.Spells;
namespace AcDream.App.Spells;
/// <summary>
/// App-layer owner for retail cast intent. It validates the client-owned
/// selection rules, stops movement, and emits exactly one targeted or
/// untargeted request. ACE owns every gameplay result after that boundary.
/// </summary>
/// <remarks>
/// Port of <c>ClientMagicSystem::CastSpell</c> (0x00568040) and
/// <c>FreeHandsAndCastSpell</c> (0x00566EF0). This controller deliberately
/// does not consume mana/components or start local effects.
/// </remarks>
public sealed class SpellCastingController
{
private readonly Spellbook _spellbook;
private readonly Func<uint?> _selectedObject;
private readonly Func<uint> _localPlayerId;
private readonly Action _stopCompletely;
private readonly Action<uint> _sendUntargeted;
private readonly Action<uint, uint> _sendTargeted;
private readonly Action<string> _displayMessage;
private readonly Func<uint, SpellMetadata, bool> _targetCompatible;
private readonly Func<uint, SpellMetadata, bool> _targetCompatibleSilent;
private readonly Func<uint, bool> _hasRequiredComponents;
private readonly Action _incrementBusy;
private readonly Func<bool> _canSend;
public SpellCastingController(
Spellbook spellbook,
Func<uint?> selectedObject,
Func<uint> localPlayerId,
Action stopCompletely,
Action<uint> sendUntargeted,
Action<uint, uint> sendTargeted,
Action<string> displayMessage,
Func<uint, SpellMetadata, bool>? targetCompatible = null,
Func<uint, bool>? hasRequiredComponents = null,
Action? incrementBusy = null,
Func<bool>? canSend = null,
Func<uint, SpellMetadata, bool>? targetCompatibleSilent = null)
{
_spellbook = spellbook ?? throw new ArgumentNullException(nameof(spellbook));
_selectedObject = selectedObject ?? throw new ArgumentNullException(nameof(selectedObject));
_localPlayerId = localPlayerId ?? throw new ArgumentNullException(nameof(localPlayerId));
_stopCompletely = stopCompletely ?? throw new ArgumentNullException(nameof(stopCompletely));
_sendUntargeted = sendUntargeted ?? throw new ArgumentNullException(nameof(sendUntargeted));
_sendTargeted = sendTargeted ?? throw new ArgumentNullException(nameof(sendTargeted));
_displayMessage = displayMessage ?? throw new ArgumentNullException(nameof(displayMessage));
_targetCompatible = targetCompatible ?? ((_, _) => true);
_targetCompatibleSilent = targetCompatibleSilent ?? _targetCompatible;
_hasRequiredComponents = hasRequiredComponents ?? (_ => true);
_incrementBusy = incrementBusy ?? (() => { });
_canSend = canSend ?? (() => true);
}
public uint? LastRequestedSpellId { get; private set; }
public uint? LastRequestedTargetId { get; private set; }
public bool IsTargetReady(uint spellId)
{
if (!_spellbook.Knows(spellId)
|| !_spellbook.TryGetMetadata(spellId, out SpellMetadata spell))
return false;
if (spell.IsSelfTargeted || spell.IsUntargeted || spell.TargetMask == 0u)
return true;
return _selectedObject() is uint target and not 0u
&& _targetCompatibleSilent(target, spell);
}
public CastRequestResult Cast(uint spellId)
{
if (!_spellbook.Knows(spellId) || !_spellbook.TryGetMetadata(spellId, out SpellMetadata spell))
{
_displayMessage("You do not know that spell.");
return CastRequestResult.UnknownSpell;
}
if (!_hasRequiredComponents(spellId))
{
_displayMessage("You do not have all of this spell's components.");
return CastRequestResult.MissingComponents;
}
uint? target;
bool untargeted;
if (spell.IsSelfTargeted)
{
uint playerId = _localPlayerId();
target = playerId == 0 ? null : playerId;
untargeted = target is null;
}
else if (spell.IsUntargeted || spell.TargetMask == 0)
{
target = null;
untargeted = true;
}
else
{
target = _selectedObject();
untargeted = false;
if (target is null or 0)
{
_displayMessage("You must select a suitable target.");
return CastRequestResult.NoTarget;
}
if (!_targetCompatible(target.Value, spell))
return CastRequestResult.IncompatibleTarget;
}
if (!_canSend())
{
_displayMessage("You cannot cast a spell right now.");
return CastRequestResult.Unavailable;
}
try
{
_stopCompletely();
LastRequestedSpellId = spellId;
LastRequestedTargetId = target;
if (untargeted)
_sendUntargeted(spellId);
else
_sendTargeted(target!.Value, spellId);
// FreeHandsAndCastSpell @ 0x00566EF0 increments the shared UI
// busy reference only after Event_Cast has been emitted. The
// matching server UseDone event decrements it.
_incrementBusy();
}
catch
{
LastRequestedSpellId = null;
LastRequestedTargetId = null;
throw;
}
return CastRequestResult.Sent;
}
public void Reset()
{
LastRequestedSpellId = null;
LastRequestedTargetId = null;
}
}
public enum CastRequestResult
{
Sent,
UnknownSpell,
NoTarget,
IncompatibleTarget,
MissingComponents,
Unavailable,
}

View file

@ -0,0 +1,121 @@
using System;
using System.Collections.Generic;
using System.Linq;
using AcDream.Core.Items;
using AcDream.Core.Spells;
namespace AcDream.App.Spells;
public sealed record SpellFormulaDefinition(
uint FormulaVersion,
IReadOnlyList<uint> ComponentIds,
uint School = 0u);
/// <summary>
/// Client-side retail component preflight for ClientMagicSystem::CastSpell
/// (0x00568040). Spell formula entries are SCIDs; carried inventory objects and
/// SetDesiredComponent use WCIDs, so all comparisons cross the DAT enum map.
/// </summary>
public sealed class SpellComponentRequirementService
{
public const uint SpellComponentsRequiredProperty = 68u;
private readonly ClientObjectTable _objects;
private readonly Func<uint> _playerGuid;
private readonly Func<string> _accountName;
private readonly IReadOnlyDictionary<uint, SpellFormulaDefinition> _formulas;
private readonly IReadOnlyDictionary<uint, uint> _wcidByScid;
private readonly IReadOnlyDictionary<uint, uint> _magicPackWcidBySchool;
public SpellComponentRequirementService(
ClientObjectTable objects,
Func<uint> playerGuid,
Func<string> accountName,
IReadOnlyDictionary<uint, SpellFormulaDefinition> formulas,
IReadOnlyDictionary<uint, uint> wcidByScid,
IReadOnlyDictionary<uint, uint>? magicPackWcidBySchool = null)
{
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
_playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid));
_accountName = accountName ?? throw new ArgumentNullException(nameof(accountName));
_formulas = formulas ?? throw new ArgumentNullException(nameof(formulas));
_wcidByScid = wcidByScid ?? throw new ArgumentNullException(nameof(wcidByScid));
_magicPackWcidBySchool = magicPackWcidBySchool
?? new Dictionary<uint, uint>();
}
public bool HasRequiredComponents(uint spellId)
{
uint playerGuid = _playerGuid();
ClientObject? player = _objects.Get(playerGuid);
if (player is not null
&& !player.Properties.GetBool(SpellComponentsRequiredProperty, true))
return true;
if (!_formulas.TryGetValue(spellId, out SpellFormulaDefinition? formula))
return false;
HashSet<uint> ownedWcids = _objects.Objects
.Where(item => IsOwnedByPlayer(item, playerGuid))
.Select(item => item.WeenieClassId)
.ToHashSet();
IReadOnlyList<uint> components = GetAppropriateFormula(
formula, player, playerGuid);
foreach (uint scid in components)
{
if (scid == 0u) continue;
if (!_wcidByScid.TryGetValue(scid, out uint wcid)
|| !ownedWcids.Contains(wcid))
return false;
}
return true;
}
/// <summary>
/// Retail <c>ClientMagicSystem::GetAppropriateSpellFormula</c>
/// (0x00567D50): an infused school or a directly carried school focus uses
/// the scarab-only formula; otherwise the account-customized formula wins.
/// </summary>
private IReadOnlyList<uint> GetAppropriateFormula(
SpellFormulaDefinition formula,
ClientObject? player,
uint playerGuid)
{
bool infused = SchoolInfusionProperty(formula.School) is uint propertyId
&& player?.Properties.GetInt(propertyId) > 0;
bool carriesMagicPack = _magicPackWcidBySchool.TryGetValue(
formula.School, out uint packWcid)
&& _objects.Objects.Any(item =>
item.ContainerId == playerGuid
&& item.WeenieClassId == packWcid);
return infused || carriesMagicPack
? RetailSpellFormula.InqScarabOnlyFormula(formula.ComponentIds)
: RetailSpellFormula.CustomizeForAccount(
formula.ComponentIds, formula.FormulaVersion, _accountName());
}
private static uint? SchoolInfusionProperty(uint school) => school switch
{
1u => 0x129u, // AugmentationInfusedWarMagic
2u => 0x128u, // AugmentationInfusedLifeMagic
3u => 0x127u, // AugmentationInfusedItemMagic
4u => 0x126u, // AugmentationInfusedCreatureMagic
5u => 0x148u, // AugmentationInfusedVoidMagic
_ => null,
};
private bool IsOwnedByPlayer(ClientObject item, uint playerGuid)
{
if (playerGuid == 0u) return false;
ClientObject current = item;
for (int depth = 0; depth < 16; depth++)
{
if (current.WielderId == playerGuid || current.ContainerId == playerGuid)
return true;
if (current.ContainerId == 0u
|| _objects.Get(current.ContainerId) is not { } parent)
return false;
current = parent;
}
return false;
}
}

View file

@ -4,6 +4,7 @@ using System.Numerics;
using AcDream.App.Rendering;
using AcDream.Core.Items;
using AcDream.Core.Textures;
using AcDream.Core.Spells;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
@ -35,6 +36,8 @@ public sealed class IconComposer
private readonly TextureCache _cache;
private readonly Dictionary<(uint, uint, uint, uint, uint), uint> _byTuple = new();
private readonly Dictionary<(uint, uint, uint), ComposedIcon> _dragByTuple = new();
private readonly Dictionary<uint, uint> _spellIcons = new();
private readonly Dictionary<uint, uint> _componentIcons = new();
private sealed record ComposedIcon(byte[] Rgba, int Width, int Height, uint Texture);
@ -298,4 +301,71 @@ public sealed class IconComposer
var decoded = SurfaceDecoder.DecodeRenderSurface(rs, palette: null);
layers.Add((decoded.Rgba8, decoded.Width, decoded.Height));
}
/// <summary>
/// Retail ClientMagicSystem::CompositeSpellIcon (0x00567550): power-level
/// backing, spell art, reversed/normal recolor, then self/fellow overlay.
/// </summary>
public uint GetSpellIcon(uint spellId)
{
if (_spellIcons.TryGetValue(spellId, out uint cached)) return cached;
DatReaderWriter.DBObjs.SpellTable? table =
_dats.Get<DatReaderWriter.DBObjs.SpellTable>(0x0E00000Eu);
if (table is null || !table.Spells.TryGetValue(spellId, out var spell)) return 0u;
uint power = spell.Components.Count == 0
? 0u
: RetailSpellFormula.DeterminePowerLevelOfComponent(spell.Components[0]);
uint powerBacking = RetailDataIdResolver.Resolve(_dats, power, 0x10000006u);
var layers = new List<(byte[] rgba, int w, int h)>();
AddLayer(layers, powerBacking);
AddLayer(layers, spell.Icon);
if (layers.Count == 0) return 0u;
var composed = Compose(layers);
uint tintIndex = (spell.Bitfield & DatReaderWriter.Enums.SpellIndex.Reversed) != 0
? 1u : 2u;
uint tintDid = RetailDataIdResolver.Resolve(_dats, tintIndex, 0x10000007u);
if (TryDecode(tintDid, out DecodedTexture tint))
ReplaceWhiteFromSurface(composed.rgba, composed.w, composed.h,
tint.Rgba8, tint.Width, tint.Height);
uint overlayIndex = (spell.Bitfield & DatReaderWriter.Enums.SpellIndex.FellowshipSpell) != 0
? 4u
: (spell.Bitfield & DatReaderWriter.Enums.SpellIndex.SelfTargeted) != 0
? 3u : 0u;
if (overlayIndex != 0u)
{
uint overlayDid = RetailDataIdResolver.Resolve(_dats, overlayIndex, 0x10000007u);
if (TryDecode(overlayDid, out DecodedTexture overlay))
composed = Compose([
(composed.rgba, composed.w, composed.h),
(overlay.Rgba8, overlay.Width, overlay.Height)]);
}
uint texture = _cache.UploadRgba8(composed.rgba, composed.w, composed.h, nearest: true);
_spellIcons[spellId] = texture;
return texture;
}
/// <summary>
/// Retail ClientMagicSystem::CompositeSpellComponentIcon (0x00567720).
/// Components use their raw DAT art with pure white replaced by black.
/// </summary>
public uint GetSpellComponentIcon(uint iconId)
{
if (iconId == 0u) return 0u;
if (_componentIcons.TryGetValue(iconId, out uint cached)) return cached;
if (!TryDecode(iconId, out DecodedTexture icon)) return 0u;
byte[] rgba = (byte[])icon.Rgba8.Clone();
for (int i = 0; i + 3 < rgba.Length; i += 4)
{
if (rgba[i] != 255 || rgba[i + 1] != 255 || rgba[i + 2] != 255 || rgba[i + 3] != 255)
continue;
rgba[i] = rgba[i + 1] = rgba[i + 2] = 0;
}
uint texture = _cache.UploadRgba8(rgba, icon.Width, icon.Height, nearest: true);
_componentIcons[iconId] = texture;
return texture;
}
}

View file

@ -125,6 +125,18 @@ public sealed class ItemInteractionController : IDisposable
public bool CanMakeInventoryRequest =>
_readyForInventoryRequest() && _busyCount == 0 && !_autoWield.IsBusy;
/// <summary>
/// Increments retail's shared <c>ClientUISystem</c> busy reference after a
/// request issued by another retained controller has been sent. The
/// corresponding UseDone releases it. Retail spell casts complete through
/// Item__UseDone too; AttackDone belongs only to the combat attack owner.
/// </summary>
public void IncrementBusyCount()
{
_busyCount++;
StateChanged?.Invoke();
}
/// <summary>
/// Raised for retail confirmation/auxiliary actions whose retained panel is
/// owned outside this controller (PK/NPK altar, volatile rare, trade, salvage).
@ -514,6 +526,14 @@ public sealed class ItemInteractionController : IDisposable
StateChanged?.Invoke();
}
/// <summary>Session teardown drops every outstanding client UI busy reference.</summary>
public void ClearBusy()
{
if (_busyCount == 0) return;
_busyCount = 0;
StateChanged?.Invoke();
}
private bool ConsumeUseThrottle()
{
long now = _nowMs();

View file

@ -20,7 +20,8 @@ public sealed class CombatUiController : IRetainedPanelController
{
public const uint LayoutId = 0x21000073u;
public const uint BasicPanelId = 0x1000005Cu;
public const uint AdvancedPanelId = 0x10000061u;
public const uint SpellcastingPanelId = 0x10000061u;
public const uint AdvancedPanelId = SpellcastingPanelId;
public const uint PowerControlId = 0x1000004Fu;
public const uint SpeedLabelId = 0x10000051u;
public const uint PowerLabelId = 0x10000052u;
@ -36,7 +37,7 @@ public sealed class CombatUiController : IRetainedPanelController
private readonly UiElement _root;
private readonly UiElement _basicPanel;
private readonly UiElement _advancedPanel;
private readonly UiElement _spellcastingPanel;
private readonly UiScrollbar _powerControl;
private readonly UiButton _high;
private readonly UiButton _medium;
@ -54,7 +55,7 @@ public sealed class CombatUiController : IRetainedPanelController
private CombatUiController(
ImportedLayout layout,
UiElement basicPanel,
UiElement advancedPanel,
UiElement spellcastingPanel,
UiScrollbar powerControl,
UiButton high,
UiButton medium,
@ -71,7 +72,7 @@ public sealed class CombatUiController : IRetainedPanelController
{
_root = layout.Root;
_basicPanel = basicPanel;
_advancedPanel = advancedPanel;
_spellcastingPanel = spellcastingPanel;
_powerControl = powerControl;
_high = high;
_medium = medium;
@ -85,11 +86,10 @@ public sealed class CombatUiController : IRetainedPanelController
_setGameplay = setGameplay;
_setWindowVisible = setWindowVisible;
// PlayerModule::AdvancedCombatUI false selects gmCombatUI's authored
// basic page. The advanced page belongs to the separate advanced-
// combat flow and stays hidden in the basic panel.
// Retail layout 0x21000073 contains two sibling pages: gmCombatUI's
// physical-attack controls and gmSpellcastingUI's favorite-spell bar.
_basicPanel.Visible = true;
_advancedPanel.Visible = false;
_spellcastingPanel.Visible = false;
_powerControl.SetScalarPosition(_attacks.DesiredPower);
_powerControl.ScalarChanged = _attacks.SetDesiredPower;
@ -140,7 +140,7 @@ public sealed class CombatUiController : IRetainedPanelController
ArgumentNullException.ThrowIfNull(setWindowVisible);
if (layout.FindElement(BasicPanelId) is not { } basic
|| layout.FindElement(AdvancedPanelId) is not { } advanced
|| layout.FindElement(SpellcastingPanelId) is not { } spellcasting
|| layout.FindElement(PowerControlId) is not UiScrollbar power
|| layout.FindElement(HighButtonId) is not UiButton high
|| layout.FindElement(MediumButtonId) is not UiButton medium
@ -151,7 +151,7 @@ public sealed class CombatUiController : IRetainedPanelController
return null;
return new CombatUiController(
layout, basic, advanced, power, high, medium, low,
layout, basic, spellcasting, power, high, medium, low,
repeatAttacks, autoTarget, keepInView,
combat, attacks, gameplay, setGameplay, labels, setWindowVisible);
}
@ -166,7 +166,9 @@ public sealed class CombatUiController : IRetainedPanelController
private void OnCombatModeChanged(CombatMode mode)
{
bool visible = CombatInputPlanner.SupportsTargetedAttack(mode);
bool visible = mode is CombatMode.Melee or CombatMode.Missile or CombatMode.Magic;
_basicPanel.Visible = mode is CombatMode.Melee or CombatMode.Missile;
_spellcastingPanel.Visible = mode == CombatMode.Magic;
if (_root is IUiDatStateful stateful)
{
if (mode == CombatMode.Melee)

View file

@ -77,6 +77,8 @@ public static class DatWidgetFactory
{
UiRadar.RetailClassId => new UiRadar(), // gmRadarUI (Register 0x004D8B80)
1 => BuildButton(info, resolve, elementFont, stringResolve), // UIElement_Button
EffectsIndicatorController.RetailClassId => BuildButton(
info, resolve, elementFont, stringResolve), // gmUIElement_EffectsIndicator
6 => new UiMenu(), // UIElement_Menu (reg :120163)
7 => BuildMeter(info, resolve, elementFont), // UIElement_Meter
0xD => new UiViewport(), // UIElement_Viewport — 3-D mini-scene blit leaf

View file

@ -0,0 +1,86 @@
using System;
using System.Linq;
using AcDream.Core.Spells;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Port of retail <c>gmUIElement_EffectsIndicator::PostInit @ 0x004E6930</c>
/// and <c>Update @ 0x004E6A50</c>: the authored Helpful and Harmful buttons
/// reflect whether matching enchantments exist and send panel visibility notices
/// for gmPanelUI slots 4 and 5.
/// </summary>
public sealed class EffectsIndicatorController : IRetainedPanelController
{
public const uint LayoutId = 0x21000071u;
public const uint RetailClassId = 0x10000002u;
public const uint HelpfulButtonId = 0x100000F5u;
public const uint HarmfulButtonId = 0x100000F6u;
private readonly Spellbook _spellbook;
private readonly UiButton _helpful;
private readonly UiButton _harmful;
private readonly Action<uint> _togglePanel;
private bool _disposed;
private EffectsIndicatorController(
Spellbook spellbook,
UiButton helpful,
UiButton harmful,
Action<uint> togglePanel)
{
_spellbook = spellbook;
_helpful = helpful;
_harmful = harmful;
_togglePanel = togglePanel;
_helpful.OnClick = () => _togglePanel(RetailPanelCatalog.PositiveEffects);
_harmful.OnClick = () => _togglePanel(RetailPanelCatalog.NegativeEffects);
_spellbook.EnchantmentsChanged += Update;
Update();
}
public static EffectsIndicatorController? Bind(
ImportedLayout layout,
Spellbook spellbook,
Action<uint> togglePanel)
{
ArgumentNullException.ThrowIfNull(layout);
ArgumentNullException.ThrowIfNull(spellbook);
ArgumentNullException.ThrowIfNull(togglePanel);
if (layout.FindElement(HelpfulButtonId) is not UiButton helpful
|| layout.FindElement(HarmfulButtonId) is not UiButton harmful)
return null;
return new EffectsIndicatorController(spellbook, helpful, harmful, togglePanel);
}
private void Update()
{
bool helpful = HasMatchingEffect(beneficial: true);
bool harmful = HasMatchingEffect(beneficial: false);
_helpful.TrySetRetailState(helpful
? UiButtonStateMachine.Normal
: UiButtonStateMachine.Ghosted);
_harmful.TrySetRetailState(harmful
? UiButtonStateMachine.Normal
: UiButtonStateMachine.Ghosted);
}
private bool HasMatchingEffect(bool beneficial)
// Retail SpellEffectMatchesUIType @ 0x004B76C0 supplies this helpful/harmful split.
=> _spellbook.ActiveEnchantmentSnapshot.Any(record =>
// Retail CountSpellsInList @ 0x00593CD0 counts every raw mult/add
// node. Unlike gmEffectsUI's list, indicator totals are not dueled
// by spell category first.
record.Bucket is 1u or 2u
&& _spellbook.TryGetMetadata(record.SpellId, out SpellMetadata metadata)
&& metadata.IsBeneficial == beneficial);
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_spellbook.EnchantmentsChanged -= Update;
_helpful.OnClick = null;
_harmful.OnClick = null;
}
}

View file

@ -0,0 +1,201 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using AcDream.Core.Spells;
namespace AcDream.App.UI.Layout;
/// <summary>Retail gmEffectsUI positive/negative instance binding.</summary>
public sealed class EffectsUiController : IRetainedPanelController
{
public const uint LayoutId = 0x2100001Bu;
// gmPanelUI's 0x10000184/185 are panel-slot IDs, not roots in this LayoutDesc.
// The authored positive/negative gmEffectsUI instances inherit template 0x10000122.
public const uint PositiveRootId = 0x1000011Fu;
public const uint NegativeRootId = 0x10000121u;
public const uint CloseId = 0x100000FCu;
public const uint ListId = 0x10000123u;
public const uint InfoTextId = 0x10000126u;
private readonly Spellbook _spellbook;
private readonly bool _positive;
private readonly Func<double> _serverTime;
private readonly Func<uint, uint> _resolveSpellIcon;
private readonly UiItemList _list;
private readonly UiText? _info;
private readonly UiButton? _close;
private readonly Dictionary<uint, UiCatalogSlot> _rows = new();
private uint? _selectedSpellId;
private double _lastDurationUpdate = double.NaN;
private bool _disposed;
internal uint? SelectedSpellId => _selectedSpellId;
private EffectsUiController(
ImportedLayout layout,
Spellbook spellbook,
bool positive,
Func<double> serverTime,
Func<uint, uint> resolveSpellIcon,
UiItemList list,
Action? close)
{
_spellbook = spellbook;
_positive = positive;
_serverTime = serverTime;
_resolveSpellIcon = resolveSpellIcon;
_list = list;
_info = layout.FindElement(InfoTextId) as UiText;
_close = layout.FindElement(CloseId) as UiButton;
if (_close is not null) _close.OnClick = close;
_list.Columns = 1;
_list.CellWidth = Math.Max(1f, _list.Width);
_list.CellHeight = 32f;
ConfigureInfo();
_spellbook.EnchantmentsChanged += Rebuild;
Rebuild();
}
public static EffectsUiController? Bind(
ImportedLayout layout,
Spellbook spellbook,
bool positive,
Func<double> serverTime,
Func<uint, (uint Texture, int Width, int Height)> spriteResolve,
Func<uint, uint> resolveSpellIcon,
Action? close = null)
{
UiElement? host = layout.FindElement(ListId);
if (host is null) return null;
UiItemList list;
if (host is UiItemList itemList)
list = itemList;
else
{
list = new UiItemList(spriteResolve)
{
Width = host.Width,
Height = host.Height,
Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right | AnchorEdges.Bottom,
};
host.AddChild(list);
}
return new EffectsUiController(
layout, spellbook, positive, serverTime, resolveSpellIcon, list, close);
}
public void Tick()
{
double now = _serverTime();
if (!double.IsFinite(now)) return;
if (double.IsFinite(_lastDurationUpdate)
&& now >= _lastDurationUpdate
&& now - _lastDurationUpdate < 1.0)
return;
_lastDurationUpdate = now;
foreach (ActiveEnchantmentRecord enchantment in VisibleEnchantments())
if (_rows.TryGetValue(enchantment.Identity, out UiCatalogSlot? row))
row.Detail = FormatRemaining(enchantment, now);
}
private void Rebuild()
{
_lastDurationUpdate = double.NaN;
_rows.Clear();
ActiveEnchantmentRecord[] enchantments = VisibleEnchantments().ToArray();
using (_list.DeferLayout())
{
_list.Flush();
foreach (ActiveEnchantmentRecord enchantment in enchantments)
{
_spellbook.TryGetMetadata(enchantment.SpellId, out SpellMetadata? metadata);
uint identity = enchantment.Identity;
var row = new UiCatalogSlot
{
EntryId = enchantment.SpellId,
CatalogIconTexture = metadata is null ? 0u : _resolveSpellIcon(enchantment.SpellId),
Label = metadata?.Name ?? $"Spell {enchantment.SpellId}",
Detail = FormatRemaining(enchantment, _serverTime()),
ShowLabel = true,
SpriteResolve = _list.SpriteResolve,
};
row.Clicked = () => Select(enchantment.SpellId);
_rows[identity] = row;
_list.AddItem(row);
}
}
if (_selectedSpellId is uint selected
&& !_rows.Values.Any(row => row.EntryId == selected))
_selectedSpellId = null;
SyncSelection();
}
private IEnumerable<ActiveEnchantmentRecord> VisibleEnchantments()
=> _spellbook.EnchantmentsInEffectSnapshot
.Where(record =>
{
if (!_spellbook.TryGetMetadata(record.SpellId, out SpellMetadata metadata))
return false;
return metadata.IsBeneficial == _positive;
})
.OrderBy(record => _spellbook.TryGetMetadata(record.SpellId, out SpellMetadata metadata)
? metadata.Name : record.SpellId.ToString(CultureInfo.InvariantCulture),
StringComparer.OrdinalIgnoreCase);
private static string FormatRemaining(ActiveEnchantmentRecord enchantment, double now)
{
if (enchantment.Duration < 0) return "Permanent";
double remaining = Math.Max(0, enchantment.StartTime + enchantment.Duration - now);
if (!double.IsFinite(remaining)) return "--:--";
remaining = Math.Min(remaining, TimeSpan.MaxValue.TotalSeconds);
TimeSpan time = TimeSpan.FromSeconds(remaining);
return time.TotalHours >= 1
? $"{(int)time.TotalHours}:{time.Minutes:00}:{time.Seconds:00}"
: $"{time.Minutes}:{time.Seconds:00}";
}
private void Select(uint spellId)
{
// Retail gmEffectsUI::SetSelectedSpell @ 0x004B8290 stores the
// token's spell stat, not its enchantment layer identity.
_selectedSpellId = _selectedSpellId == spellId ? null : spellId;
SyncSelection();
}
private void SyncSelection()
{
foreach (UiCatalogSlot row in _rows.Values)
row.Selected = row.EntryId == _selectedSpellId;
}
private void ConfigureInfo()
{
if (_info is null) return;
_info.LinesProvider = () =>
{
if (_selectedSpellId is not uint spellId)
return Array.Empty<UiText.Line>();
ActiveEnchantmentRecord? selected = _spellbook.ActiveEnchantmentSnapshot
.Cast<ActiveEnchantmentRecord?>()
.FirstOrDefault(record => record?.SpellId == spellId);
if (selected is null || !_spellbook.TryGetMetadata(selected.Value.SpellId, out SpellMetadata metadata))
return Array.Empty<UiText.Line>();
return
[
new UiText.Line(metadata.Name, _info.DefaultColor),
new UiText.Line(metadata.Description, _info.DefaultColor),
];
};
}
public void OnShown() => Rebuild();
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_spellbook.EnchantmentsChanged -= Rebuild;
if (_close is not null) _close.OnClick = null;
}
}

View file

@ -131,8 +131,9 @@ public sealed class ElementInfo
public string DefaultStateName = "";
/// <summary>
/// Resolved child elements (populated by the importer in Task 5).
/// Children come from the derived element's own tree, not the base element's.
/// Resolved child elements. The importer ports retail child-table incorporation:
/// base-only children remain, same-ID children merge recursively, and derived-only
/// children append after the retained inherited entries.
/// </summary>
public List<ElementInfo> Children = new();
@ -264,7 +265,9 @@ public static class ElementReader
/// base entries are the default; derived entries override (or add) per state name key.
/// </description></item>
/// <item><description>
/// <see cref="ElementInfo.Children"/>: come from the derived element's own tree only.
/// <see cref="ElementInfo.Children"/>: are not combined by this scalar merge helper;
/// <see cref="LayoutImporter"/> separately ports retail's recursive child-table
/// incorporation.
/// </description></item>
/// </list>
/// </para>
@ -306,10 +309,9 @@ public static class ElementReader
FontColor = derived.FontColor ?? base_.FontColor,
// DefaultStateName: derived wins if set; otherwise inherit the base's default.
DefaultStateName = !string.IsNullOrEmpty(derived.DefaultStateName) ? derived.DefaultStateName : base_.DefaultStateName,
// Children come from the derived element's own tree, not the base prototype's.
// Defensive copy: prevent a later mutation of either the merged result or the input
// from corrupting the other. Safe because derived.Children is fully
// populated by the recursive importer BEFORE Merge is called and never mutated after).
// This helper merges one element snapshot only. LayoutImporter separately
// incorporates the child tables after the scalar/state merge.
// Defensive copy prevents later mutation of either input.
Children = new List<ElementInfo>(derived.Children),
};
// Start with base StateMedia as defaults, then let derived entries override.

View file

@ -295,11 +295,9 @@ public static class LayoutImporter
// ── Inheritance resolution ────────────────────────────────────────────────
/// <summary>True when a pure-container leaf should inherit its base's subtree (the
/// gmInventoryUI sub-window mount): the derived element has no own children, no own
/// state media, and the base resolved to ≥1 child. Inert for media-bearing inheritors
/// (close button / title), elements with their own children, and childless style
/// prototypes (vitals/chat/toolbar text — the base prototype has no children).</summary>
/// <summary>True when a pure-container inheritor needs the mounted-base Z-layer
/// correction. Child inheritance itself is unconditional and follows retail
/// <c>ElementDesc::Incorporate</c> (0x0069B5A0).</summary>
internal static bool ShouldMountBaseChildren(int derivedChildCount, int derivedMediaCount, int baseChildCount)
=> derivedChildCount == 0 && derivedMediaCount == 0 && baseChildCount > 0;
@ -316,7 +314,7 @@ public static class LayoutImporter
// Read this element's own fields + media (no inheritance, no children yet).
var self = ToInfo(d);
var result = self;
List<ElementInfo>? baseChildren = null;
ElementInfo? baseInfo = null;
// Apply BaseElement / BaseLayoutId inheritance if present.
if (d.BaseElement != 0 && d.BaseLayoutId != 0
@ -327,32 +325,23 @@ public static class LayoutImporter
if (baseDesc is not null)
{
// Recurse the base chain (already guarded by the HashSet add above).
var baseInfo = Resolve(dats, baseDesc, baseChain);
baseInfo = Resolve(dats, baseDesc, baseChain);
// Derived fields override the base; children are attached below.
result = ElementReader.Merge(baseInfo, self);
baseChildren = baseInfo.Children; // capture for the sub-window mount
}
}
// Resolve + attach children. Each child gets a FRESH base-chain set:
// the cycle guard is per-element, not shared across siblings.
foreach (var kv in d.Children)
{
var child = Resolve(dats, kv.Value, new HashSet<(uint, uint)>());
SetOriginalParentSize(child, result.Width, result.Height);
result.Children.Add(child);
}
// Retail LayoutDesc::InqFullDesc (0x0069A520) recursively resolves the base,
// then ElementDesc::Incorporate (0x0069B5A0) merges the complete child table:
// base-only children remain, same-ID children incorporate recursively, and
// derived-only children append after the retained base entries.
IncorporateChildren(dats, result, baseInfo?.Children, d);
// Sub-window mount: a pure-container leaf (no own children, no own media) that inherits
// from a base WITH content attaches the base's subtree. Targets the gmInventoryUI panels
// (0x100001CD/CE/CF — Type-0, media-less, childless, BaseLayoutId → a gm*UI window);
// inert for media-bearing inheritors (close button/title) and childless style prototypes
// (vitals/chat/toolbar). See ShouldMountBaseChildren + the B-Grid spec.
if (baseChildren is not null
&& ShouldMountBaseChildren(d.Children.Count, self.StateMedia.Count, baseChildren.Count))
// A pure-container sub-window mount needs one additional layer correction.
// Child-table incorporation has already happened above for every inheritor.
if (baseInfo is not null
&& ShouldMountBaseChildren(d.Children.Count, self.StateMedia.Count, baseInfo.Children.Count))
{
result.Children.AddRange(baseChildren);
// The mounted slot's layer WITHIN THE FRAME is its OWN ZLevel, not the mounted
// sub-window root's. The gm*UI sub-window roots carry ZLevel 1000 (their standalone
// top-window layer); ElementReader.Merge's zero-wins-base rule made the slot (own
@ -372,6 +361,57 @@ public static class LayoutImporter
return result;
}
private static void IncorporateChildren(
DatCollection dats,
ElementInfo result,
IReadOnlyList<ElementInfo>? baseChildren,
ElementDesc derived)
{
baseChildren ??= Array.Empty<ElementInfo>();
var baseById = baseChildren.ToDictionary(child => child.Id);
int retainedBaseCount = baseChildren.Count(child =>
!derived.Children.ContainsKey(child.Id));
foreach (ElementInfo baseChild in baseChildren)
{
bool hasOverlay = derived.Children.TryGetValue(
baseChild.Id, out ElementDesc? overlay);
ElementInfo child = hasOverlay
? IncorporateResolvedChild(dats, baseChild, overlay!)
: baseChild;
// Base-only descendants retain the base layout's design parent size;
// UiLayoutPolicy then performs retail's base-to-derived parent resize.
if (hasOverlay)
SetOriginalParentSize(child, result.Width, result.Height);
result.Children.Add(child);
}
// Every new child receives a fresh base-chain set. Retail offsets its read order
// by the count of inherited children not replaced by a same-ID overlay.
foreach (var pair in derived.Children)
{
if (baseById.ContainsKey(pair.Key)) continue;
ElementInfo child = Resolve(dats, pair.Value, new HashSet<(uint, uint)>());
child.ReadOrder += checked((uint)retainedBaseCount);
SetOriginalParentSize(child, result.Width, result.Height);
result.Children.Add(child);
}
}
private static ElementInfo IncorporateResolvedChild(
DatCollection dats,
ElementInfo baseChild,
ElementDesc derivedChild)
{
// ElementDesc::Incorporate consumes the partial child directly. It does not
// independently re-resolve that child's BaseElement when the inherited table
// already contains the same identity.
ElementInfo self = ToInfo(derivedChild);
ElementInfo result = ElementReader.Merge(baseChild, self);
IncorporateChildren(dats, result, baseChild.Children, derivedChild);
return result;
}
/// <summary>
/// Read an <see cref="ElementDesc"/>'s own scalar fields + state media into a
/// fresh <see cref="ElementInfo"/>. No inheritance is applied; children are not

View file

@ -0,0 +1,144 @@
using System;
using System.Collections.Generic;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Retained-window port of retail <c>gmPanelUI::RecvNotice_SetPanelVisibility</c>
/// at <c>0x004BC6F0</c>. The original owns one active child panel and an optional
/// deferred child; this owner applies the same lifecycle to registered retained
/// windows without making the toolbar authoritative for non-toolbar panels.
/// </summary>
public sealed class RetailPanelUiController
{
public const uint RestorePreviousPropertyId = 0x10000049u;
private readonly Func<string, bool> _isVisible;
private readonly Func<string, bool> _show;
private readonly Func<string, bool> _hide;
private readonly Dictionary<uint, PanelEntry> _byPanel = new();
private readonly Dictionary<string, uint> _byWindow = new(StringComparer.Ordinal);
private uint? _activePanel;
private uint? _deferredPanel;
private bool _applying;
public RetailPanelUiController(
Func<string, bool> isVisible,
Func<string, bool> show,
Func<string, bool> hide)
{
_isVisible = isVisible ?? throw new ArgumentNullException(nameof(isVisible));
_show = show ?? throw new ArgumentNullException(nameof(show));
_hide = hide ?? throw new ArgumentNullException(nameof(hide));
}
public uint? ActivePanelId => _activePanel;
/// <param name="restorePrevious">
/// Effective DAT bool property <c>0x10000049</c>. Retail retains the previous
/// panel only when the newly shown panel has this property and the previous
/// panel does not.
/// </param>
public void Register(uint panelId, string windowName, bool restorePrevious = false)
{
if (panelId == 0) throw new ArgumentOutOfRangeException(nameof(panelId));
ArgumentException.ThrowIfNullOrWhiteSpace(windowName);
if (_byPanel.ContainsKey(panelId) || _byWindow.ContainsKey(windowName))
throw new InvalidOperationException(
$"Panel {panelId} or retained window '{windowName}' is already registered.");
_byPanel.Add(panelId, new PanelEntry(windowName, restorePrevious));
_byWindow.Add(windowName, panelId);
if (_isVisible(windowName))
ObserveWindowVisibility(windowName, visible: true);
}
/// <summary>
/// Registers a panel directly from its fully inherited retail root so the
/// authored deferred-panel behavior cannot be dropped at the mount seam.
/// </summary>
public void Register(uint panelId, string windowName, ElementInfo authoredRoot)
{
ArgumentNullException.ThrowIfNull(authoredRoot);
Register(
panelId,
windowName,
authoredRoot.TryGetEffectiveBool(
RestorePreviousPropertyId,
out bool restorePrevious)
&& restorePrevious);
}
public bool IsPanelVisible(uint panelId)
=> _byPanel.TryGetValue(panelId, out PanelEntry entry)
&& _isVisible(entry.WindowName);
public bool TogglePanel(uint panelId)
{
bool visible = !IsPanelVisible(panelId);
return SetPanelVisibility(panelId, visible) && visible;
}
public bool SetPanelVisibility(uint panelId, bool visible)
{
if (!_byPanel.TryGetValue(panelId, out PanelEntry requested)) return false;
_applying = true;
try
{
if (visible)
{
if (_activePanel == panelId)
return _show(requested.WindowName);
uint? previousId = _activePanel;
PanelEntry? previous = previousId is uint id
&& _byPanel.TryGetValue(id, out PanelEntry found)
&& _isVisible(found.WindowName)
? found
: null;
_deferredPanel = requested.RestorePrevious
&& previous is { RestorePrevious: false }
? previousId
: null;
_activePanel = panelId;
if (previous is not null)
_hide(previous.Value.WindowName);
return _show(requested.WindowName);
}
if (_activePanel != panelId)
{
if (_deferredPanel == panelId) _deferredPanel = null;
return _hide(requested.WindowName);
}
bool hidden = _hide(requested.WindowName);
_activePanel = null;
if (_deferredPanel is not uint deferredId
|| !_byPanel.TryGetValue(deferredId, out PanelEntry deferred))
return hidden;
_deferredPanel = null;
_activePanel = deferredId;
return _show(deferred.WindowName) || hidden;
}
finally
{
_applying = false;
}
}
/// <summary>
/// Feeds visibility changes made by persistence or another retained-window
/// owner back through the canonical panel lifecycle.
/// </summary>
public void ObserveWindowVisibility(string windowName, bool visible)
{
if (_applying || !_byWindow.TryGetValue(windowName, out uint panelId)) return;
SetPanelVisibility(panelId, visible);
}
private readonly record struct PanelEntry(string WindowName, bool RestorePrevious);
}

View file

@ -0,0 +1,457 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Numerics;
using AcDream.App.Spells;
using AcDream.Core.Items;
using AcDream.Core.Spells;
namespace AcDream.App.UI.Layout;
public enum SpellbookWindowPage { Spells, Components }
/// <summary>
/// Binds retail's combined spellbook/component-book LayoutDesc 0x21000034.
/// The spell page mirrors gmSpellbookUI filters; the component page mirrors
/// ComponentTracker and exposes the 0..5000 desired purchase level field.
/// </summary>
public sealed class SpellbookWindowController : IRetainedPanelController
{
public const uint LayoutId = 0x21000034u;
public const uint RootId = 0x100002A8u;
public const uint SpellTabId = 0x100002A9u;
public const uint ComponentTabId = 0x100002AAu;
public const uint CloseId = 0x100002ABu;
public const uint SpellPageId = 0x100002ACu;
public const uint ComponentPageId = 0x100002ADu;
public const uint SpellListId = 0x10000295u;
public const uint ComponentListId = 0x10000464u;
public const uint ComponentScrollbarId = 0x10000465u;
private static readonly (uint Id, uint Mask)[] FilterButtons =
[
(0x10000298u, 0x0001u), (0x10000299u, 0x0002u),
(0x1000029Au, 0x0004u), (0x1000029Bu, 0x0008u),
(0x100005C0u, 0x2000u),
(0x1000029Cu, 0x0010u), (0x1000029Du, 0x0020u),
(0x1000029Eu, 0x0040u), (0x1000029Fu, 0x0080u),
(0x100002A0u, 0x0100u), (0x100002A1u, 0x0200u),
(0x100002A2u, 0x0400u), (0x1000054Eu, 0x0800u),
];
private readonly Spellbook _spellbook;
private readonly ClientObjectTable _objects;
private readonly Func<uint> _playerGuid;
private readonly IReadOnlyDictionary<uint, SpellComponentDescriptor> _components;
private readonly Func<uint, uint> _resolveSpellIcon;
private readonly Func<uint, uint> _resolveComponentIcon;
private readonly Func<uint, int> _spellLevel;
private readonly Action<uint> _selectObject;
private readonly Action<uint> _addFavorite;
private readonly Action<uint> _sendFilter;
private readonly Action<uint, uint> _setDesiredComponent;
private readonly Action _close;
private readonly UiElement _spellPage;
private readonly UiElement _componentPage;
private readonly UiButton _spellTab;
private readonly UiButton _componentTab;
private readonly UiButton _closeButton;
private readonly UiItemList _spellList;
private readonly UiItemList _componentList;
private readonly List<(UiButton Button, uint Mask)> _filters = new();
private uint? _selectedSpell;
private bool _spellsDirty;
private bool _componentsDirty;
private bool _componentEditActive;
private bool _disposed;
private SpellbookWindowController(
ImportedLayout layout,
Spellbook spellbook,
ClientObjectTable objects,
Func<uint> playerGuid,
IReadOnlyDictionary<uint, SpellComponentDescriptor> components,
Func<uint, uint> resolveSpellIcon,
Func<uint, uint> resolveComponentIcon,
Func<uint, int> spellLevel,
Action<uint> selectObject,
Action<uint> addFavorite,
Action<uint> sendFilter,
Action<uint, uint> setDesiredComponent,
Action close,
UiElement spellPage,
UiElement componentPage,
UiButton spellTab,
UiButton componentTab,
UiButton closeButton,
UiItemList spellList,
UiItemList componentList)
{
_spellbook = spellbook;
_objects = objects;
_playerGuid = playerGuid;
_components = components;
_resolveSpellIcon = resolveSpellIcon;
_resolveComponentIcon = resolveComponentIcon;
_spellLevel = spellLevel;
_selectObject = selectObject;
_addFavorite = addFavorite;
_sendFilter = sendFilter;
_setDesiredComponent = setDesiredComponent;
_close = close;
_spellPage = spellPage;
_componentPage = componentPage;
_spellTab = spellTab;
_componentTab = componentTab;
_closeButton = closeButton;
_spellList = spellList;
_componentList = componentList;
spellTab.OnClick = () => ShowPage(SpellbookWindowPage.Spells);
componentTab.OnClick = () => ShowPage(SpellbookWindowPage.Components);
closeButton.OnClick = close;
foreach ((uint id, uint mask) in FilterButtons)
{
if (layout.FindElement(id) is not UiButton button) continue;
uint filterMask = mask;
button.OnClick = () => ToggleFilter(filterMask);
_filters.Add((button, mask));
}
ConfigureSpellList();
ConfigureComponentList(layout);
_spellbook.SpellbookChanged += OnSpellbookChanged;
_spellbook.DesiredComponentsChanged += OnDesiredComponentsChanged;
_objects.ObjectAdded += OnObjectChanged;
_objects.ObjectUpdated += OnObjectChanged;
_objects.ObjectRemoved += OnObjectRemoved;
_objects.ObjectMoved += OnObjectMoved;
_objects.ContainerContentsReplaced += OnContainerContentsReplaced;
ShowPage(SpellbookWindowPage.Spells);
RebuildAll();
}
public SpellbookWindowPage CurrentPage { get; private set; }
public static SpellbookWindowController? Bind(
ImportedLayout layout,
Spellbook spellbook,
ClientObjectTable objects,
Func<uint> playerGuid,
IReadOnlyDictionary<uint, SpellComponentDescriptor> components,
Func<uint, uint> resolveSpellIcon,
Func<uint, uint> resolveComponentIcon,
Func<uint, int> spellLevel,
Action<uint> selectObject,
Action<uint> addFavorite,
Action<uint> sendFilter,
Action<uint, uint> setDesiredComponent,
Action close)
{
if (layout.FindElement(SpellPageId) is not { } spellPage
|| layout.FindElement(ComponentPageId) is not { } componentPage
|| layout.FindElement(SpellTabId) is not UiButton spellTab
|| layout.FindElement(ComponentTabId) is not UiButton componentTab
|| layout.FindElement(CloseId) is not UiButton closeButton
|| layout.FindElement(SpellListId) is not UiItemList spellList)
return null;
UiElement? componentHost = layout.FindElement(ComponentListId);
if (componentHost is null) return null;
UiItemList componentList;
if (componentHost is UiItemList itemList)
componentList = itemList;
else
{
componentList = new UiItemList(spellList.SpriteResolve)
{
Width = componentHost.Width,
Height = componentHost.Height,
Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right | AnchorEdges.Bottom,
};
componentHost.AddChild(componentList);
}
return new SpellbookWindowController(
layout, spellbook, objects, playerGuid, components,
resolveSpellIcon, resolveComponentIcon,
spellLevel, selectObject,
addFavorite, sendFilter, setDesiredComponent, close,
spellPage, componentPage, spellTab, componentTab, closeButton,
spellList, componentList);
}
public void ShowPage(SpellbookWindowPage page)
{
CurrentPage = page;
_spellPage.Visible = page == SpellbookWindowPage.Spells;
_componentPage.Visible = page == SpellbookWindowPage.Components;
_spellTab.Selected = page == SpellbookWindowPage.Spells;
_componentTab.Selected = page == SpellbookWindowPage.Components;
}
private void ConfigureSpellList()
{
_spellList.Columns = 6;
_spellList.CellWidth = 32f;
_spellList.CellHeight = 32f;
}
private void ConfigureComponentList(ImportedLayout layout)
{
_componentList.Columns = 1;
_componentList.CellWidth = Math.Max(1f, _componentList.Width);
_componentList.CellHeight = 32f;
if (layout.FindElement(ComponentScrollbarId) is UiScrollbar scrollbar)
scrollbar.Model = _componentList.Scroll;
}
private void ToggleFilter(uint mask)
{
uint filters = _spellbook.SpellbookFilters ^ mask;
_spellbook.SetSpellbookFilters(filters);
_sendFilter(filters);
}
private void RebuildAll()
{
RebuildSpells();
RebuildComponents();
_spellsDirty = false;
_componentsDirty = false;
}
private void RebuildSpells()
{
uint effective = _spellbook.SpellbookFilters;
foreach ((UiButton button, uint mask) in _filters)
button.Selected = (effective & mask) != 0;
SpellMetadata[] spells = _spellbook.LearnedSpells
.Select(id => _spellbook.TryGetMetadata(id, out SpellMetadata metadata) ? metadata : null)
.Where(metadata => metadata is not null && IsVisible(metadata, effective))
.OrderBy(metadata => metadata!.SortKey)
.ThenBy(metadata => metadata!.Name, StringComparer.OrdinalIgnoreCase)
.Cast<SpellMetadata>()
.ToArray();
using (_spellList.DeferLayout())
{
_spellList.Flush();
foreach (SpellMetadata metadata in spells)
{
uint spellId = metadata.SpellId;
var slot = new UiCatalogSlot
{
EntryId = spellId,
CatalogIconTexture = _resolveSpellIcon(spellId),
Label = metadata.Name,
SpriteResolve = _spellList.SpriteResolve,
};
slot.Clicked = () => SelectSpell(spellId);
// gmSpellbookUI double-click publishes AddSpellShortcut; the open
// gmSpellcastingUI tab chooses the server favorite-list destination.
slot.DoubleClicked = () => { SelectSpell(spellId); _addFavorite(spellId); };
_spellList.AddItem(slot);
}
}
SyncSpellSelection();
}
private void RebuildComponents()
{
uint player = _playerGuid();
var packs = _objects.Objects
.Where(item => (item.IsComponentPack || _components.ContainsKey(item.WeenieClassId))
&& IsOwnedByPlayer(item, player))
.GroupBy(item => item.WeenieClassId)
.Select(group => new
{
ComponentId = group.Key,
First = group.First(),
Quantity = group.Sum(item => Math.Max(1, item.StackSize)),
})
.OrderBy(group => _components.TryGetValue(group.ComponentId, out var descriptor) ? descriptor.Category : uint.MaxValue)
.ThenBy(group => group.First.Name, StringComparer.OrdinalIgnoreCase)
.ToArray();
float width = Math.Max(96f, _componentList.Width);
_componentList.CellWidth = width;
using (_componentList.DeferLayout())
{
_componentList.Flush();
uint? category = null;
foreach (var packGroup in packs)
{
ClientObject pack = packGroup.First;
uint componentId = packGroup.ComponentId;
_components.TryGetValue(componentId, out SpellComponentDescriptor? descriptor);
uint nextCategory = descriptor?.Category ?? 8u;
if (category != nextCategory)
{
category = nextCategory;
_componentList.AddItem(new UiCatalogSlot
{
EntryId = uint.MaxValue,
Label = ComponentCategoryName(nextCategory),
ShowLabel = true,
SpriteResolve = _componentList.SpriteResolve,
});
}
uint desired = _spellbook.DesiredComponents.TryGetValue(componentId, out uint amount) ? amount : 0u;
var row = new UiCatalogSlot
{
EntryId = componentId,
CatalogIconTexture = _resolveComponentIcon(
pack.IconId != 0 ? pack.IconId : descriptor?.IconId ?? 0u),
Label = string.IsNullOrWhiteSpace(pack.Name) ? descriptor?.Name ?? $"Component {componentId}" : pack.Name,
Detail = packGroup.Quantity.ToString(CultureInfo.InvariantCulture),
ShowLabel = true,
SpriteResolve = _componentList.SpriteResolve,
};
var desiredField = new UiField
{
Left = width - 47f,
Top = 4f,
Width = 43f,
Height = 24f,
MaxCharacters = 4,
ClearOnSubmit = false,
RecordHistory = false,
SelectAllOnFocus = true,
RightAligned = true,
CharacterFilter = char.IsAsciiDigit,
BackgroundColor = new Vector4(0f, 0f, 0f, 0.65f),
};
desiredField.SetText(desired.ToString(CultureInfo.InvariantCulture));
desiredField.OnFocusGained = () => _componentEditActive = true;
uint committed = desired;
void Commit(string value)
{
if (!uint.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out uint parsed)
|| parsed > 5000u)
{
desiredField.SetText(committed.ToString(CultureInfo.InvariantCulture));
return;
}
if (parsed == committed) return;
committed = parsed;
_spellbook.SetDesiredComponent(componentId, parsed);
_setDesiredComponent(componentId, parsed);
}
desiredField.OnSubmit = Commit;
desiredField.OnFocusLost = value =>
{
Commit(value);
_componentEditActive = false;
};
row.AddChild(desiredField);
row.Clicked = () => _selectObject(pack.ObjectId);
_componentList.AddItem(row);
}
}
}
private static string ComponentCategoryName(uint category) => category switch
{
0u => "Scarabs",
1u => "Herbs",
2u => "Powdered Gems",
3u => "Alchemical Substances",
4u => "Talismans",
5u => "Tapers",
6u => "Prismatic Components",
_ => "Other Components",
};
private bool IsOwnedByPlayer(ClientObject item, uint playerGuid)
{
if (playerGuid == 0) return item.ContainerId != 0 || item.WielderId != 0;
ClientObject current = item;
for (int depth = 0; depth < 16; depth++)
{
if (current.WielderId == playerGuid || current.ContainerId == playerGuid) return true;
if (current.ContainerId == 0 || _objects.Get(current.ContainerId) is not { } parent) return false;
current = parent;
}
return false;
}
private bool IsVisible(SpellMetadata metadata, uint filters)
{
uint school = metadata.School switch
{
"Creature Enchantment" => 0x0001u,
"Item Enchantment" => 0x0002u,
"Life Magic" => 0x0004u,
"War Magic" => 0x0008u,
"Void Magic" => 0x2000u,
_ => 0u,
};
int spellLevel = _spellLevel(metadata.SpellId);
if (spellLevel is < 1 or > 8) return false;
uint level = 0x10u << (spellLevel - 1);
return school != 0 && (filters & school) != 0 && (filters & level) != 0;
}
private void SelectSpell(uint spellId)
{
_selectedSpell = spellId;
SyncSpellSelection();
}
private void SyncSpellSelection()
{
for (int i = 0; i < _spellList.GetNumUIItems(); i++)
if (_spellList.GetItem(i) is UiCatalogSlot slot)
slot.Selected = slot.EntryId == _selectedSpell;
}
public void Tick()
{
if (_spellsDirty)
{
_spellsDirty = false;
RebuildSpells();
}
if (_componentsDirty && !_componentEditActive)
{
_componentsDirty = false;
RebuildComponents();
}
}
private void OnSpellbookChanged() => _spellsDirty = true;
private void OnDesiredComponentsChanged() => _componentsDirty = true;
private void OnObjectChanged(ClientObject item)
{
if (IsComponent(item)) _componentsDirty = true;
}
private void OnObjectRemoved(ClientObject item)
{
if (IsComponent(item)) _componentsDirty = true;
}
private void OnObjectMoved(ClientObjectMove _) => _componentsDirty = true;
private void OnContainerContentsReplaced(uint _) => _componentsDirty = true;
private bool IsComponent(ClientObject item)
=> item.IsComponentPack || _components.ContainsKey(item.WeenieClassId);
public void OnShown() => RebuildAll();
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_spellbook.SpellbookChanged -= OnSpellbookChanged;
_spellbook.DesiredComponentsChanged -= OnDesiredComponentsChanged;
_objects.ObjectAdded -= OnObjectChanged;
_objects.ObjectUpdated -= OnObjectChanged;
_objects.ObjectRemoved -= OnObjectRemoved;
_objects.ObjectMoved -= OnObjectMoved;
_objects.ContainerContentsReplaced -= OnContainerContentsReplaced;
_spellTab.OnClick = null;
_componentTab.OnClick = null;
_closeButton.OnClick = null;
foreach ((UiButton button, _) in _filters) button.OnClick = null;
}
}

View file

@ -0,0 +1,472 @@
using System;
using System.Collections.Generic;
using System.Linq;
using AcDream.App.Spells;
using AcDream.Core.Combat;
using AcDream.Core.Items;
using AcDream.Core.Spells;
using AcDream.Core.Selection;
using AcDream.UI.Abstractions.Input;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Retail gmSpellcastingUI binding for the authored magic page inside combat
/// LayoutDesc 0x21000073. Favorites remain server-persisted; casting emits one
/// request through <see cref="SpellCastingController"/>.
/// </summary>
public sealed class SpellcastingUiController : IRetainedPanelController
{
public const uint PageId = 0x10000061u;
public const uint SpellNameId = 0x1000048Bu;
public const uint EndowmentId = 0x100000B1u;
public const uint CastButtonId = 0x100000B2u;
public const uint FavoriteListId = 0x100000B6u;
private static readonly uint[] TabIds =
[
0x100000A3u, 0x100000A4u, 0x100000A5u, 0x100000A6u,
0x100000A7u, 0x100000A8u, 0x100000A9u, 0x100005C2u,
];
private static readonly uint[] GroupIds =
[
0x100000AAu, 0x100000ABu, 0x100000ACu, 0x100000ADu,
0x100000AEu, 0x100000AFu, 0x100000B0u, 0x100005C3u,
];
private readonly Spellbook _spellbook;
private readonly SpellCastingController _casting;
private readonly SelectionState _selection;
private readonly ClientObjectTable _objects;
private readonly Func<uint> _playerGuid;
private readonly Func<uint, uint> _resolveSpellIcon;
private readonly Func<ClientObject, uint> _resolveItemDragIcon;
private readonly Action<uint> _useItem;
private readonly Action<int, int, uint>? _addFavorite;
private readonly Action<int, uint>? _removeFavorite;
private readonly UiElement[] _tabs;
private readonly UiElement[] _groups;
private readonly UiItemList?[] _lists;
private readonly UiButton _cast;
private readonly UiText? _spellName;
private readonly UiElement _endowmentHost;
private readonly UiCatalogSlot _endowmentSlot;
private int _activeTab;
private readonly uint?[] _selected = new uint?[8];
private readonly bool[] _endowmentSelected = new bool[8];
private uint _endowmentItemId;
private uint _endowmentSpellId;
private bool _disposed;
private bool _favoritesDirty;
private bool _endowmentDirty;
private SpellcastingUiController(
ImportedLayout layout,
Spellbook spellbook,
SpellCastingController casting,
ClientObjectTable objects,
Func<uint> playerGuid,
Func<uint, uint> resolveSpellIcon,
Func<ClientObject, uint> resolveItemDragIcon,
Action<uint> useItem,
SelectionState selection,
Action<int, int, uint>? addFavorite,
Action<int, uint>? removeFavorite,
UiElement[] tabs,
UiElement[] groups,
UiItemList?[] lists,
UiButton cast,
UiElement endowmentHost)
{
_spellbook = spellbook;
_casting = casting;
_selection = selection;
_objects = objects;
_playerGuid = playerGuid;
_resolveSpellIcon = resolveSpellIcon;
_resolveItemDragIcon = resolveItemDragIcon;
_useItem = useItem;
_addFavorite = addFavorite;
_removeFavorite = removeFavorite;
_tabs = tabs;
_groups = groups;
_lists = lists;
_cast = cast;
_spellName = layout.FindElement(SpellNameId) as UiText;
_endowmentHost = endowmentHost;
foreach (UiElement child in _endowmentHost.Children) child.Visible = false;
_endowmentSlot = new UiCatalogSlot
{
Left = 0f,
Top = 0f,
Width = endowmentHost.Width,
Height = endowmentHost.Height,
SpriteResolve = lists.FirstOrDefault(list => list is not null)?.SpriteResolve,
};
_endowmentSlot.Clicked = SelectEndowment;
_endowmentSlot.DoubleClicked = () => { SelectEndowment(); CastSelected(); };
_endowmentHost.AddChild(_endowmentSlot);
for (int i = 0; i < tabs.Length; i++)
{
int index = i;
SetClick(tabs[i], () => SelectTab(index));
}
_cast.OnClick = CastSelected;
ConfigureSpellName();
_spellbook.SpellbookChanged += OnSpellbookChanged;
_selection.Changed += OnSelectionChanged;
_objects.ObjectAdded += OnObjectChanged;
_objects.ObjectUpdated += OnObjectChanged;
_objects.ObjectRemoved += OnObjectChanged;
_objects.Cleared += OnObjectsCleared;
UpdateEndowment();
SelectTab(0);
Rebuild();
}
public int ActiveTab => _activeTab;
public static SpellcastingUiController? Bind(
ImportedLayout layout,
Spellbook spellbook,
SpellCastingController casting,
ClientObjectTable objects,
Func<uint> playerGuid,
Func<uint, uint> resolveSpellIcon,
Func<ClientObject, uint> resolveItemDragIcon,
Action<uint> useItem,
SelectionState selection,
Action<int, int, uint>? addFavorite,
Action<int, uint>? removeFavorite)
{
if (layout.FindElement(CastButtonId) is not UiButton cast
|| layout.FindElement(EndowmentId) is not { } endowmentHost)
return null;
var tabs = new UiElement[8];
var groups = new UiElement[8];
var lists = new UiItemList?[8];
for (int i = 0; i < 8; i++)
{
if (layout.FindElement(TabIds[i]) is not { } tab
|| layout.FindElement(GroupIds[i]) is not { } group)
return null;
tabs[i] = tab;
groups[i] = group;
lists[i] = Descendants(group).OfType<UiItemList>().FirstOrDefault();
}
return new SpellcastingUiController(
layout, spellbook, casting, objects, playerGuid, resolveSpellIcon,
resolveItemDragIcon, useItem, selection,
addFavorite, removeFavorite,
tabs, groups, lists, cast, endowmentHost);
}
public void AddFavorite(uint spellId)
{
IReadOnlyList<uint> spells = _spellbook.GetFavorites(_activeTab);
if (spells.Contains(spellId))
{
SelectSpell(spellId);
return;
}
int position = spells.Count;
_addFavorite?.Invoke(_activeTab, position, spellId);
_spellbook.SetFavorite(_activeTab, position, spellId);
SelectSpell(spellId);
}
public bool Handle(InputAction action)
{
if (action is >= InputAction.UseSpellSlot_1 and <= InputAction.UseSpellSlot_9)
{
int index = (int)action - (int)InputAction.UseSpellSlot_1;
IReadOnlyList<uint> spells = _spellbook.GetFavorites(_activeTab);
if (index < spells.Count)
{
SelectSpell(spells[index]);
CastSelected();
}
return true;
}
switch (action)
{
case InputAction.CombatPrevSpellTab: SelectTab((_activeTab + 7) % 8); return true;
case InputAction.CombatNextSpellTab: SelectTab((_activeTab + 1) % 8); return true;
case InputAction.CombatFirstSpellTab: SelectTab(0); return true;
case InputAction.CombatLastSpellTab: SelectTab(7); return true;
case InputAction.CombatPrevSpell: MoveSelection(-1, false); return true;
case InputAction.CombatNextSpell: MoveSelection(1, false); return true;
case InputAction.CombatFirstSpell: MoveSelection(0, true); return true;
case InputAction.CombatLastSpell: MoveSelection(-1, true); return true;
case InputAction.CombatCastCurrentSpell: CastSelected(); return true;
default: return false;
}
}
private void SelectTab(int tab)
{
_activeTab = Math.Clamp(tab, 0, 7);
for (int i = 0; i < 8; i++)
{
SetSelected(_tabs[i], i == _activeTab);
_groups[i].Visible = i == _activeTab;
}
IReadOnlyList<uint> favorites = _spellbook.GetFavorites(_activeTab);
if (_endowmentSelected[_activeTab] && _endowmentItemId != 0u)
_selected[_activeTab] = null;
else if (_selected[_activeTab] is not uint selected || !favorites.Contains(selected))
{
_selected[_activeTab] = favorites.Count == 0 ? null : favorites[0];
_endowmentSelected[_activeTab] = favorites.Count == 0 && _endowmentItemId != 0u;
}
SyncSelection();
UpdateCastAvailability();
}
private void MoveSelection(int delta, bool edge)
{
IReadOnlyList<uint> spells = _spellbook.GetFavorites(_activeTab);
int count = spells.Count + (_endowmentItemId != 0u ? 1 : 0);
if (count == 0) return;
int current = _endowmentSelected[_activeTab]
? 0
: (_selected[_activeTab] is uint id ? spells.IndexOf(id) : -1)
+ (_endowmentItemId != 0u ? 1 : 0);
int next = edge ? (delta < 0 ? count - 1 : 0) : (current + delta + count) % count;
if (_endowmentItemId != 0u && next == 0) SelectEndowment();
else SelectSpell(spells[next - (_endowmentItemId != 0u ? 1 : 0)]);
}
private void SelectSpell(uint spellId)
{
_endowmentSelected[_activeTab] = false;
_selected[_activeTab] = spellId;
SyncSelection();
UpdateCastAvailability();
}
private void CastSelected()
{
if (_endowmentSelected[_activeTab] && _endowmentItemId != 0u)
_useItem(_endowmentItemId);
else if (_selected[_activeTab] is uint spellId)
_casting.Cast(spellId);
}
private void SelectEndowment()
{
if (_endowmentItemId == 0u) return;
_endowmentSelected[_activeTab] = true;
_selected[_activeTab] = null;
SyncSelection();
UpdateCastAvailability();
}
private void Rebuild()
{
for (int tab = 0; tab < 8; tab++)
{
UiItemList? list = _lists[tab];
if (list is null) continue;
IReadOnlyList<uint> favorites = _spellbook.GetFavorites(tab);
using (list.DeferLayout())
{
list.Flush();
list.Columns = 9;
list.CellWidth = 32f;
list.CellHeight = 32f;
foreach (uint spellId in favorites)
{
uint id = spellId;
int position = list.GetNumUIItems();
_spellbook.TryGetMetadata(id, out SpellMetadata? metadata);
var slot = new UiCatalogSlot
{
EntryId = id,
CatalogIconTexture = metadata is null ? 0u : _resolveSpellIcon(id),
Label = metadata?.Name ?? $"Spell {id}",
SpriteResolve = list.SpriteResolve,
CatalogDragPayload = new SpellFavoriteDragPayload(tab, position, id),
DragBegan = payload => BeginFavoriteDrag((SpellFavoriteDragPayload)payload),
DragEnded = payload => EndFavoriteDrag((SpellFavoriteDragPayload)payload),
Dropped = payload =>
{
if (payload is SpellFavoriteDragPayload favorite)
DropFavorite(favorite, tab, position);
},
};
slot.Clicked = () => SelectSpell(id);
slot.DoubleClicked = () => { SelectSpell(id); CastSelected(); };
list.AddItem(slot);
}
}
}
SelectTab(_activeTab);
}
private void BeginFavoriteDrag(SpellFavoriteDragPayload payload)
=> _removeFavorite?.Invoke(payload.SourceTab, payload.SpellId);
private void EndFavoriteDrag(SpellFavoriteDragPayload payload)
=> _spellbook.RemoveFavorite(payload.SourceTab, payload.SpellId);
private void DropFavorite(SpellFavoriteDragPayload payload, int targetTab, int targetPosition)
{
_addFavorite?.Invoke(targetTab, targetPosition, payload.SpellId);
_spellbook.SetFavorite(targetTab, targetPosition, payload.SpellId);
_selected[targetTab] = payload.SpellId;
}
private void OnSpellbookChanged() => _favoritesDirty = true;
private void OnObjectChanged(ClientObject _) => _endowmentDirty = true;
private void OnObjectsCleared() => _endowmentDirty = true;
public void Tick()
{
if (_endowmentDirty)
{
_endowmentDirty = false;
UpdateEndowment();
SelectTab(_activeTab);
}
if (_favoritesDirty)
{
_favoritesDirty = false;
Rebuild();
}
}
private void SyncSelection()
{
for (int tab = 0; tab < 8; tab++)
{
UiItemList? list = _lists[tab];
if (list is null) continue;
for (int i = 0; i < list.GetNumUIItems(); i++)
if (list.GetItem(i) is UiCatalogSlot slot)
slot.Selected = slot.EntryId == _selected[tab];
_endowmentSlot.Selected = _endowmentItemId != 0u
&& _endowmentSelected[_activeTab];
}
}
private void OnSelectionChanged(SelectionTransition _) => UpdateCastAvailability();
private void UpdateCastAvailability()
=> _cast.Enabled = _endowmentSelected[_activeTab]
? _endowmentItemId != 0u
: _selected[_activeTab] is uint spellId
&& _casting.IsTargetReady(spellId);
private void ConfigureSpellName()
{
if (_spellName is null) return;
_spellName.OneLine = true;
_spellName.Centered = true;
_spellName.Padding = 0;
_spellName.LinesProvider = () =>
{
uint? chosenSpell = _endowmentSelected[_activeTab]
? (_endowmentSpellId == 0u ? null : _endowmentSpellId)
: _selected[_activeTab];
string name = chosenSpell is uint spellId
&& _spellbook.TryGetMetadata(spellId, out SpellMetadata metadata)
? metadata.Name : string.Empty;
return [new UiText.Line(name, _spellName.DefaultColor)];
};
}
private void UpdateEndowment()
{
ClientObject? endowment = _objects.GetEquippedBy(_playerGuid())
.FirstOrDefault(item =>
(item.CurrentlyEquippedLocation & EquipMask.Held) != 0
&& (item.Type & ItemType.Caster) != 0
&& item.SpellId.GetValueOrDefault() != 0u);
_endowmentItemId = endowment?.ObjectId ?? 0u;
_endowmentSpellId = endowment?.SpellId ?? 0u;
_endowmentHost.Visible = endowment is not null;
_endowmentSlot.EntryId = _endowmentItemId;
_endowmentSlot.CatalogIconTexture = _endowmentSpellId == 0u
? 0u : _resolveSpellIcon(_endowmentSpellId);
_endowmentSlot.CatalogOverlayTexture = endowment is null
? 0u : _resolveItemDragIcon(endowment);
string spellName = _spellbook.TryGetMetadata(_endowmentSpellId, out SpellMetadata metadata)
? metadata.Name : $"Spell {_endowmentSpellId}";
_endowmentSlot.Label = endowment is null
? string.Empty : $"{endowment.GetAppropriateName()} ({spellName})";
for (int tab = 0; tab < 8; tab++)
{
if (_endowmentItemId != 0u && _selected[tab] is null)
_endowmentSelected[tab] = true;
else if (_endowmentItemId == 0u && _endowmentSelected[tab])
_endowmentSelected[tab] = false;
}
}
private static IEnumerable<UiElement> Descendants(UiElement root)
{
foreach (UiElement child in root.Children)
{
yield return child;
foreach (UiElement nested in Descendants(child)) yield return nested;
}
}
public void OnShown() => SyncSelection();
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_spellbook.SpellbookChanged -= OnSpellbookChanged;
_selection.Changed -= OnSelectionChanged;
_objects.ObjectAdded -= OnObjectChanged;
_objects.ObjectUpdated -= OnObjectChanged;
_objects.ObjectRemoved -= OnObjectChanged;
_objects.Cleared -= OnObjectsCleared;
foreach (UiElement tab in _tabs) SetClick(tab, null);
_cast.OnClick = null;
}
private static void SetClick(UiElement element, Action? action)
{
element.ClickThrough = action is null;
switch (element)
{
case UiButton button: button.OnClick = action; break;
case UiText text: text.OnClick = action; break;
case UiDatElement dat: dat.OnClick = action; break;
}
}
private static void SetSelected(UiElement element, bool selected)
{
if (element is UiButton button)
button.Selected = selected;
else if (element is UiText text)
text.DefaultColor = selected
? new System.Numerics.Vector4(1f, 1f, 1f, 1f)
: new System.Numerics.Vector4(0.65f, 0.65f, 0.65f, 1f);
}
}
public sealed record SpellFavoriteDragPayload(int SourceTab, int SourcePosition, uint SpellId);
internal static class FavoriteListExtensions
{
public static int IndexOf(this IReadOnlyList<uint> values, uint value)
{
for (int i = 0; i < values.Count; i++) if (values[i] == value) return i;
return -1;
}
}

View file

@ -1,24 +1,36 @@
namespace AcDream.App.UI;
/// <summary>
/// Retail toolbar panel ids carried by DAT property <c>0x10000029</c>.
/// Retail reference: <c>gmToolbarUI::PostInit @ 0x004BEA80</c> discovers the
/// seven buttons and reads this property from each one.
/// Only panels mounted in the retained runtime are mapped; other authored
/// toolbar buttons remain present but ghosted until their panel ships.
/// Retail <c>gmPanelUI</c> panel ids carried by DAT property <c>0x10000029</c>.
/// The toolbar exposes only a subset of these ids; Helpful/Harmful effects use
/// the authored <c>gmUIElement_EffectsIndicator</c> buttons instead.
/// </summary>
public static class RetailPanelCatalog
{
public const uint PositiveEffects = 4u;
public const uint NegativeEffects = 5u;
public const uint Inventory = 7u;
public const uint Character = 11u;
public const uint Magic = 13u;
private static readonly (uint PanelId, string WindowName)[] Mounted =
{
(PositiveEffects, WindowNames.PositiveEffects),
(NegativeEffects, WindowNames.NegativeEffects),
(Inventory, WindowNames.Inventory),
(Character, WindowNames.Character),
(Magic, WindowNames.Spellbook),
};
private static readonly (uint PanelId, string WindowName)[] Toolbar =
{
(Inventory, WindowNames.Inventory),
(Character, WindowNames.Character),
(Magic, WindowNames.Spellbook),
};
public static IReadOnlyList<(uint PanelId, string WindowName)> MountedPanels => Mounted;
public static IReadOnlyList<(uint PanelId, string WindowName)> ToolbarPanels => Toolbar;
public static bool TryGetWindowName(uint panelId, out string windowName)
{

View file

@ -3,12 +3,14 @@ using AcDream.App.Plugins;
using AcDream.App.Combat;
using AcDream.App.Input;
using AcDream.App.Rendering;
using AcDream.App.Spells;
using AcDream.App.UI.Layout;
using AcDream.App.UI.Testing;
using AcDream.Core.Combat;
using AcDream.Core.Items;
using AcDream.Core.Net.Messages;
using AcDream.Core.Selection;
using AcDream.Core.Spells;
using AcDream.UI.Abstractions;
using AcDream.UI.Abstractions.Panels.Chat;
using AcDream.UI.Abstractions.Panels.Settings;
@ -43,6 +45,26 @@ public sealed record CombatRuntimeBindings(
Func<GameplaySettings> Gameplay,
Action<GameplaySettings> SetGameplay);
public sealed record MagicRuntimeBindings(
Spellbook Spellbook,
SpellCastingController Casting,
ClientObjectTable Objects,
Func<uint> PlayerGuid,
IReadOnlyDictionary<uint, SpellComponentDescriptor> Components,
Func<ItemType, uint, uint, uint, uint, uint> ResolveIcon,
Func<ItemType, uint, uint, uint, uint, uint> ResolveDragIcon,
Func<uint, uint> ResolveSpellIcon,
Func<uint, uint> ResolveComponentIcon,
SelectionState Selection,
Func<uint, int> SpellLevel,
Action<uint> SelectObject,
Action<uint> UseItem,
Action<int, int, uint> AddFavorite,
Action<int, uint> RemoveFavorite,
Action<uint> SendSpellbookFilter,
Action<uint, uint> SetDesiredComponent,
Func<double> ServerTime);
public sealed record JumpPowerbarRuntimeBindings(Func<JumpChargeSnapshot> Snapshot);
public sealed record FpsRuntimeBindings(
@ -116,6 +138,7 @@ public sealed record RetailUiRuntimeBindings(
ChatRuntimeBindings Chat,
RadarRuntimeBindings Radar,
CombatRuntimeBindings Combat,
MagicRuntimeBindings Magic,
JumpPowerbarRuntimeBindings JumpPowerbar,
FpsRuntimeBindings Fps,
ToolbarRuntimeBindings Toolbar,
@ -139,6 +162,7 @@ public sealed class RetailUiRuntime : IDisposable
private StackSplitQuantityState StackSplitQuantity => _bindings.StackSplitQuantity;
private readonly RetailWindowLayoutPersistence? _persistence;
private readonly RetailUiAutomationScriptRunner? _automation;
private readonly RetailPanelUiController _panelUi;
private GameplayConfirmationController? _gameplayConfirmationController;
private RetailItemConfirmationController? _itemConfirmationController;
private RetailSkillTrainingConfirmationController? _skillTrainingConfirmationController;
@ -147,12 +171,19 @@ public sealed class RetailUiRuntime : IDisposable
private RetailUiRuntime(RetailUiRuntimeBindings bindings)
{
_bindings = bindings;
_panelUi = new RetailPanelUiController(
bindings.Host.IsWindowVisible,
bindings.Host.ShowWindow,
bindings.Host.HideWindow);
MountFpsDisplay();
MountVitals();
MountRadar();
MountChat();
MountToolbar();
MountCombat();
MountSpellbook();
MountEffects();
MountIndicators();
MountJumpPowerbar();
MountDialogFactory();
MountCharacter();
@ -192,6 +223,11 @@ public sealed class RetailUiRuntime : IDisposable
public ToolbarController? ToolbarController { get; private set; }
public ToolbarInputController? ToolbarInputController { get; private set; }
public CombatUiController? CombatUiController { get; private set; }
public SpellcastingUiController? SpellcastingUiController { get; private set; }
public SpellbookWindowController? SpellbookWindowController { get; private set; }
public EffectsUiController? PositiveEffectsController { get; private set; }
public EffectsUiController? NegativeEffectsController { get; private set; }
public EffectsIndicatorController? EffectsIndicatorController { get; private set; }
public JumpPowerbarController? JumpPowerbarController { get; private set; }
public RetailFpsController? FpsController { get; private set; }
public SelectedObjectController? SelectedObjectController { get; private set; }
@ -216,6 +252,10 @@ public sealed class RetailUiRuntime : IDisposable
public void Tick(double deltaSeconds)
{
FpsController?.Tick();
SpellbookWindowController?.Tick();
SpellcastingUiController?.Tick();
PositiveEffectsController?.Tick();
NegativeEffectsController?.Tick();
JumpPowerbarController?.Tick();
SelectedObjectController?.Tick(deltaSeconds);
DialogFactory?.Tick();
@ -226,7 +266,33 @@ public sealed class RetailUiRuntime : IDisposable
public void Draw(System.Numerics.Vector2 screenSize) => Host.Draw(screenSize);
public bool HandleInputAction(AcDream.UI.Abstractions.Input.InputAction action)
=> ToolbarInputController?.Handle(action) == true;
{
if (SpellcastingUiController?.Handle(action) == true)
return true;
if (action == AcDream.UI.Abstractions.Input.InputAction.ToggleSpellbookPanel)
{
OpenSpellbook(SpellbookWindowPage.Spells);
return true;
}
if (action == AcDream.UI.Abstractions.Input.InputAction.ToggleSpellComponentsPanel)
{
OpenSpellbook(SpellbookWindowPage.Components);
return true;
}
return ToolbarInputController?.Handle(action) == true;
}
private void OpenSpellbook(SpellbookWindowPage page)
{
bool visible = Host.IsWindowVisible(WindowNames.Spellbook);
if (visible && SpellbookWindowController?.CurrentPage == page)
CloseWindow(WindowNames.Spellbook);
else
{
SpellbookWindowController?.ShowPage(page);
_panelUi.SetPanelVisibility(RetailPanelCatalog.Magic, visible: true);
}
}
public bool HandleConfirmationRequest(GameEvents.CharacterConfirmationRequest request)
=> _gameplayConfirmationController?.HandleRequest(request) == true;
@ -258,15 +324,22 @@ public sealed class RetailUiRuntime : IDisposable
public void RestoreNamedLayout(string profileName) => _persistence?.RestoreNamed(profileName);
public bool ToggleWindow(string name)
=> Host.ToggleWindow(name);
=> RetailPanelCatalog.TryGetPanelId(name, out uint panelId)
? _panelUi.TogglePanel(panelId)
: Host.ToggleWindow(name);
public void CloseWindow(string name)
=> Host.HideWindow(name);
{
if (RetailPanelCatalog.TryGetPanelId(name, out uint panelId))
_panelUi.SetPanelVisibility(panelId, visible: false);
else
Host.HideWindow(name);
}
public void SyncToolbarWindowButtons()
{
if (ToolbarController is null) return;
foreach (var (panelId, windowName) in RetailPanelCatalog.MountedPanels)
foreach (var (panelId, windowName) in RetailPanelCatalog.ToolbarPanels)
ToolbarController.SetPanelOpen(panelId, Host.IsWindowVisible(windowName));
}
@ -284,6 +357,7 @@ public sealed class RetailUiRuntime : IDisposable
private void OnWindowVisibilityChanged(string windowName, bool visible)
{
_panelUi.ObserveWindowVisibility(windowName, visible);
if (RetailPanelCatalog.TryGetPanelId(windowName, out uint panelId))
ToolbarController?.SetPanelOpen(panelId, visible);
}
@ -583,7 +657,28 @@ public sealed class RetailUiRuntime : IDisposable
return;
}
SpellcastingUiController? spellcasting = Layout.SpellcastingUiController.Bind(
layout,
_bindings.Magic.Spellbook,
_bindings.Magic.Casting,
_bindings.Magic.Objects,
_bindings.Magic.PlayerGuid,
_bindings.Magic.ResolveSpellIcon,
item => _bindings.Magic.ResolveDragIcon(
item.Type, item.IconId, item.IconUnderlayId,
item.IconOverlayId, item.Effects),
_bindings.Magic.UseItem,
_bindings.Magic.Selection,
_bindings.Magic.AddFavorite,
_bindings.Magic.RemoveFavorite);
if (spellcasting is null)
Console.WriteLine("[M3] spellcasting: required controls missing in LayoutDesc 0x21000073.");
CombatUiController = controller;
SpellcastingUiController = spellcasting;
IRetainedPanelController controllerOwner = spellcasting is null
? controller
: new RetainedPanelControllerGroup(controller, spellcasting);
UiElement root = layout.Root;
RetailWindowFrame.Mount(
Host.Root,
@ -601,10 +696,190 @@ public sealed class RetailUiRuntime : IDisposable
ResizeY = false,
ConstrainDragToParent = true,
ContentClickThrough = false,
Controller = controller,
Controller = controllerOwner,
});
controller.SyncVisibility();
Console.WriteLine("[M2] retail combat bar from gmCombatUI LayoutDesc 0x21000073.");
Console.WriteLine(spellcasting is null
? "[M2] retail combat from LayoutDesc 0x21000073; magic binding unavailable."
: "[M3] retail combat + spell bar from LayoutDesc 0x21000073.");
}
private void MountSpellbook()
{
ImportedLayout? layout;
lock (_bindings.Assets.DatLock)
{
layout = LayoutImporter.Import(
_bindings.Assets.Dats,
SpellbookWindowController.LayoutId,
SpellbookWindowController.RootId,
_bindings.Assets.ResolveSprite,
_bindings.Assets.DefaultFont,
_bindings.Assets.ResolveFont);
}
if (layout is null)
{
Console.WriteLine("[M3] spellbook: LayoutDesc 0x21000034 not found.");
return;
}
SpellbookWindowController? controller = Layout.SpellbookWindowController.Bind(
layout,
_bindings.Magic.Spellbook,
_bindings.Magic.Objects,
_bindings.Magic.PlayerGuid,
_bindings.Magic.Components,
_bindings.Magic.ResolveSpellIcon,
_bindings.Magic.ResolveComponentIcon,
_bindings.Magic.SpellLevel,
_bindings.Magic.SelectObject,
spellId => SpellcastingUiController?.AddFavorite(spellId),
_bindings.Magic.SendSpellbookFilter,
_bindings.Magic.SetDesiredComponent,
() => CloseWindow(WindowNames.Spellbook));
if (controller is null)
{
Console.WriteLine("[M3] spellbook: required controls missing in LayoutDesc 0x21000034.");
return;
}
SpellbookWindowController = controller;
UiElement root = layout.Root;
RetailWindowFrame.Mount(
Host.Root,
root,
_bindings.Assets.ResolveSprite,
new RetailWindowFrame.Options
{
WindowName = WindowNames.Spellbook,
Chrome = RetailWindowChrome.Imported,
Left = 18f,
Top = 18f,
Visible = false,
Resizable = false,
ResizeX = false,
ResizeY = false,
ConstrainDragToParent = true,
ContentClickThrough = false,
Controller = controller,
});
_panelUi.Register(RetailPanelCatalog.Magic, WindowNames.Spellbook);
Console.WriteLine("[M3] retail spellbook/component book from LayoutDesc 0x21000034.");
}
private void MountEffects()
{
MountEffectsInstance(positive: true);
MountEffectsInstance(positive: false);
}
private void MountEffectsInstance(bool positive)
{
uint rootId = positive ? EffectsUiController.PositiveRootId : EffectsUiController.NegativeRootId;
ElementInfo? rootInfo;
ImportedLayout? layout;
lock (_bindings.Assets.DatLock)
{
rootInfo = LayoutImporter.ImportInfos(
_bindings.Assets.Dats,
EffectsUiController.LayoutId,
rootId);
layout = rootInfo is null
? null
: LayoutImporter.Build(
rootInfo,
_bindings.Assets.ResolveSprite,
_bindings.Assets.DefaultFont,
_bindings.Assets.ResolveFont,
new DatStringResolver(_bindings.Assets.Dats).Resolve);
}
if (rootInfo is null || layout is null)
{
Console.WriteLine($"[M3] effects: root 0x{rootId:X8} not found.");
return;
}
EffectsUiController? controller = Layout.EffectsUiController.Bind(
layout,
_bindings.Magic.Spellbook,
positive,
_bindings.Magic.ServerTime,
_bindings.Assets.ResolveSprite,
_bindings.Magic.ResolveSpellIcon,
close: () => CloseWindow(
positive ? WindowNames.PositiveEffects : WindowNames.NegativeEffects));
if (controller is null)
{
Console.WriteLine($"[M3] effects: list missing under root 0x{rootId:X8}.");
return;
}
if (positive) PositiveEffectsController = controller;
else NegativeEffectsController = controller;
UiElement root = layout.Root;
RetailWindowFrame.Mount(
Host.Root,
root,
_bindings.Assets.ResolveSprite,
new RetailWindowFrame.Options
{
WindowName = positive ? WindowNames.PositiveEffects : WindowNames.NegativeEffects,
Chrome = RetailWindowChrome.Imported,
Left = Math.Max(0f, Host.Root.Width - root.Width - 12f),
Top = 18f,
Visible = false,
Resizable = false,
ResizeX = false,
ResizeY = false,
ConstrainDragToParent = true,
ContentClickThrough = false,
Controller = controller,
});
_panelUi.Register(
positive ? RetailPanelCatalog.PositiveEffects : RetailPanelCatalog.NegativeEffects,
positive ? WindowNames.PositiveEffects : WindowNames.NegativeEffects,
rootInfo);
}
private void MountIndicators()
{
ImportedLayout? layout = Import(EffectsIndicatorController.LayoutId);
if (layout is null)
{
Console.WriteLine("[M3] effects indicators: LayoutDesc 0x21000071 not found.");
return;
}
EffectsIndicatorController? controller = Layout.EffectsIndicatorController.Bind(
layout,
_bindings.Magic.Spellbook,
panelId => _panelUi.TogglePanel(panelId));
if (controller is null)
{
Console.WriteLine("[M3] effects indicators: authored Helpful/Harmful buttons missing.");
return;
}
EffectsIndicatorController = controller;
RetailWindowFrame.Mount(
Host.Root,
layout.Root,
_bindings.Assets.ResolveSprite,
new RetailWindowFrame.Options
{
WindowName = WindowNames.Indicators,
Chrome = RetailWindowChrome.Imported,
Left = 10f,
Top = 96f,
Visible = true,
Resizable = false,
ResizeX = false,
ResizeY = false,
ConstrainDragToParent = true,
ContentClickThrough = false,
Controller = controller,
});
Console.WriteLine("[M3] retail Helpful/Harmful effect indicators from LayoutDesc 0x21000071.");
}
private void MountJumpPowerbar()
@ -825,6 +1100,7 @@ public sealed class RetailUiRuntime : IDisposable
ContentAnchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom,
ContentClickThrough = false,
});
_panelUi.Register(RetailPanelCatalog.Character, WindowNames.Character);
Console.WriteLine("[D.2b-C] retail character window from LayoutDesc importer (0x2100002E).");
}
@ -906,6 +1182,7 @@ public sealed class RetailUiRuntime : IDisposable
Host.Root.Height - root.Top),
ContentAnchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom,
});
_panelUi.Register(RetailPanelCatalog.Inventory, WindowNames.Inventory);
uint contents, sideBag, mainPack;
IReadOnlyDictionary<uint, uint> paperdollEmptySprites;

View file

@ -0,0 +1,97 @@
using System;
using System.Numerics;
namespace AcDream.App.UI;
/// <summary>
/// Icon-list cell for non-weenie catalog entries such as spells and components.
/// It reuses the retail UIItemList geometry without pretending a spell id is an
/// object GUID and without participating in item drag/drop.
/// </summary>
public sealed class UiCatalogSlot : UiItemSlot
{
public uint EntryId { get; set; }
public uint CatalogIconTexture { get; set; }
/// <summary>Optional second 32x32 layer (retail spell endowment item icon).</summary>
public uint CatalogOverlayTexture { get; set; }
public string Label { get; set; } = string.Empty;
public string? Detail { get; set; }
public bool ShowLabel { get; init; }
public new Action? Clicked { get; set; }
public new Action? DoubleClicked { get; set; }
public object? CatalogDragPayload { get; init; }
public Action<object>? DragBegan { get; init; }
public Action<object>? DragEnded { get; init; }
public Action<object>? Dropped { get; init; }
public override string? GetTooltipText() => string.IsNullOrWhiteSpace(Label) ? null : Label;
public override bool IsDragSource => CatalogDragPayload is not null;
public override object? GetDragPayload() => CatalogDragPayload;
public override (uint tex, int w, int h)? GetDragGhost() =>
CatalogDragPayload is not null && CatalogIconTexture != 0u
? (CatalogIconTexture, 32, 32)
: null;
internal override void SetDragSourceActive(bool active, object? payload)
{
if (!active && payload is not null) DragEnded?.Invoke(payload);
}
public override bool OnEvent(in UiEvent e)
{
switch (e.Type)
{
case UiEventType.MouseDown:
return true;
case UiEventType.Click:
Clicked?.Invoke();
return true;
case UiEventType.DoubleClick:
DoubleClicked?.Invoke();
return true;
case UiEventType.DragBegin:
if (e.Payload is not null) DragBegan?.Invoke(e.Payload);
return true;
case UiEventType.DragEnter:
case UiEventType.DragOver:
return true;
case UiEventType.DropReleased:
if (e.Payload is not null) Dropped?.Invoke(e.Payload);
return true;
default:
return false;
}
}
protected override void OnDraw(UiRenderContext ctx)
{
float iconSize = ShowLabel ? MathF.Min(32f, Height) : Width;
if (CatalogIconTexture != 0)
ctx.DrawSprite(CatalogIconTexture, 0f, 0f, iconSize, iconSize, 0f, 0f, 1f, 1f, Vector4.One);
else if (SpriteResolve is not null && EmptySprite != 0)
{
var (texture, _, _) = SpriteResolve(EmptySprite);
if (texture != 0)
ctx.DrawSprite(texture, 0f, 0f, iconSize, iconSize, 0f, 0f, 1f, 1f, Vector4.One);
}
if (CatalogOverlayTexture != 0)
ctx.DrawSprite(CatalogOverlayTexture, 0f, 0f, iconSize, iconSize, 0f, 0f, 1f, 1f, Vector4.One);
if (Selected && SpriteResolve is not null && SelectedSprite != 0)
{
var (texture, _, _) = SpriteResolve(SelectedSprite);
if (texture != 0)
ctx.DrawSprite(texture, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Vector4.One);
}
if (ShowLabel)
{
ctx.DrawString(Label, iconSize + 4f, 3f, new Vector4(0.92f, 0.88f, 0.70f, 1f));
if (!string.IsNullOrWhiteSpace(Detail))
ctx.DrawString(Detail!, MathF.Max(iconSize + 4f, Width - 48f), 3f, Vector4.One);
}
}
}

View file

@ -61,6 +61,7 @@ public sealed class UiField : UiElement
private int _caret;
private int? _selAnchor; // selection fixed end (null = no selection); span = [min,max] with _caret
public string Text => _text;
public bool IsFocused => _focused;
public int CaretPos => _caret;
private readonly List<string> _history = new();

View file

@ -10,7 +10,7 @@ namespace AcDream.App.UI;
/// pre-composited icon texture (set by the controller). Holds the bound weenie
/// guid (retail UIElement_UIItem::itemID, +0x5FC).
/// </summary>
public sealed class UiItemSlot : UiElement
public class UiItemSlot : UiElement
{
public UiItemSlot() { ClickThrough = false; }

View file

@ -22,6 +22,9 @@ namespace AcDream.App.UI;
/// </summary>
public sealed class UiText : UiElement
{
/// <summary>Optional base-element click notice used by authored text tabs.</summary>
public Action? OnClick { get; set; }
public override bool HandlesClick => OnClick is not null || base.HandlesClick;
/// <summary>Dat element id for imported UIElement_Text widgets. 0 for synthesized text.</summary>
public uint ElementId { get; set; }
@ -360,6 +363,11 @@ public sealed class UiText : UiElement
public override bool OnEvent(in UiEvent e)
{
if (e.Type == UiEventType.Click && OnClick is not null)
{
OnClick();
return true;
}
switch (e.Type)
{
case UiEventType.Scroll:

View file

@ -12,4 +12,8 @@ public static class WindowNames
public const string Radar = "radar";
public const string Combat = "combat";
public const string JumpPowerbar = "jump-powerbar";
public const string Spellbook = "spellbook";
public const string Indicators = "indicators";
public const string PositiveEffects = "effects-positive";
public const string NegativeEffects = "effects-negative";
}

View file

@ -1,4 +1,5 @@
using System;
using System.Linq;
using AcDream.Core.Chat;
using AcDream.Core.Combat;
using AcDream.Core.Items;
@ -77,13 +78,15 @@ public static class GameEventWiring
FriendsState? friends = null,
SquelchState? squelch = null,
Action<IReadOnlyList<(uint Id, uint Amount)>>? onDesiredComponents = null,
Action<uint /*options1*/, uint /*options2*/>? onCharacterOptions = null)
Action<uint /*options1*/, uint /*options2*/>? onCharacterOptions = null,
Func<double>? clientTime = null)
{
ArgumentNullException.ThrowIfNull(dispatcher);
ArgumentNullException.ThrowIfNull(items);
ArgumentNullException.ThrowIfNull(combat);
ArgumentNullException.ThrowIfNull(spellbook);
ArgumentNullException.ThrowIfNull(chat);
clientTime ??= static () => 0d;
// ── Chat ──────────────────────────────────────────────────
dispatcher.Register(GameEventType.ChannelBroadcast, e =>
@ -283,21 +286,44 @@ public static class GameEventWiring
dispatcher.Register(GameEventType.MagicUpdateEnchantment, e =>
{
var p = GameEvents.ParseMagicUpdateEnchantment(e.Payload.Span);
if (p is not null) spellbook.OnEnchantmentAdded(
p.Value.SpellId, p.Value.LayerId, p.Value.Duration, p.Value.CasterGuid);
if (p is not null) spellbook.OnEnchantmentAdded(ToActiveEnchantment(p.Value, clientTime()));
});
dispatcher.Register(GameEventType.MagicUpdateMultipleEnchantments, e =>
{
var entries = GameEvents.ParseMagicUpdateMultipleEnchantments(e.Payload.Span);
if (entries is not null)
{
double receivedAt = clientTime();
spellbook.OnEnchantmentsAdded(entries.Select(entry =>
ToActiveEnchantment(entry, receivedAt)));
}
});
dispatcher.Register(GameEventType.MagicRemoveEnchantment, e =>
{
var p = GameEvents.ParseMagicRemoveEnchantment(e.Payload.Span);
if (p is not null) spellbook.OnEnchantmentRemoved(p.Value.LayerId, p.Value.SpellId);
if (p is not null) spellbook.OnEnchantmentRemoved(p.Value.Layer, p.Value.SpellId);
});
dispatcher.Register(GameEventType.MagicRemoveMultipleEnchantments, e =>
{
var entries = GameEvents.ParseMagicLayeredSpellList(e.Payload.Span);
if (entries is not null)
spellbook.OnEnchantmentsRemoved(entries.Select(item => ((uint)item.SpellId, (uint)item.Layer)));
});
dispatcher.Register(GameEventType.MagicDispelEnchantment, e =>
{
var p = GameEvents.ParseMagicDispelEnchantment(e.Payload.Span);
if (p is not null) spellbook.OnEnchantmentRemoved(p.Value.LayerId, p.Value.SpellId);
if (p is not null) spellbook.OnEnchantmentRemoved(p.Value.Layer, p.Value.SpellId);
});
dispatcher.Register(GameEventType.MagicDispelMultipleEnchantments, e =>
{
var entries = GameEvents.ParseMagicLayeredSpellList(e.Payload.Span);
if (entries is not null)
spellbook.OnEnchantmentsRemoved(entries.Select(item => ((uint)item.SpellId, (uint)item.Layer)));
});
dispatcher.Register(GameEventType.MagicPurgeEnchantments,
_ => spellbook.OnPurgeAll());
dispatcher.Register(GameEventType.MagicPurgeBadEnchantments,
_ => spellbook.OnPurgeBadEnchantments());
// ── Inventory ─────────────────────────────────────────────
dispatcher.Register(GameEventType.WieldObject, e =>
@ -407,8 +433,8 @@ public static class GameEventWiring
// spellbook arrives via PlayerDescription (0x0013), which uses
// a different wire format (see WorldSession + LocalPlayerState
// — feeds vitals from PrivateUpdateVital instead).
foreach (uint sid in p.Value.SpellBook)
spellbook.OnSpellLearned(sid);
// The appraised spellbook belongs to that item. The local player's
// learned spell manifest arrives only in PlayerDescription.
});
// ── Player ────────────────────────────────────────────────
@ -441,6 +467,17 @@ public static class GameEventWiring
onCharacterOptions?.Invoke(p.Value.Options1, p.Value.Options2);
onDesiredComponents?.Invoke(p.Value.DesiredComps);
double receivedAt = clientTime();
ActiveEnchantmentRecord[] enchantments = p.Value.Enchantments
.Select(entry => ToActiveEnchantment(entry, receivedAt))
.ToArray();
spellbook.ReplaceManifest(
p.Value.Spells,
enchantments,
p.Value.HotbarSpells,
p.Value.DesiredComps,
p.Value.SpellbookFilters);
// B-Wire: deliver the player's OWN properties to the player ClientObject.
// (PD's "membership manifest" rule is about ITEMS, whose data comes from
// CreateObject; the player's own stats legitimately come from PD.) Upsert
@ -508,9 +545,6 @@ public static class GameEventWiring
}
}
foreach (uint sid in p.Value.Spells.Keys)
spellbook.OnSpellLearned(sid);
// K-fix7 (2026-04-26): push Run + Jump skill values to the
// PlayerMovementController so the runRate / jump-arc formulas
// use the SERVER's authoritative skill instead of our
@ -566,21 +600,6 @@ public static class GameEventWiring
// Issue #7 — enchantment block: feed each entry into the
// Spellbook with full StatMod data so EnchantmentMath can
// aggregate buffs in vital-max calc (issue #6 lights up).
foreach (var ench in p.Value.Enchantments)
{
spellbook.OnEnchantmentAdded(new AcDream.Core.Spells.ActiveEnchantmentRecord(
SpellId: ench.SpellId,
LayerId: ench.Layer,
Duration: (float)ench.Duration,
CasterGuid: ench.CasterGuid,
StatModType: ench.StatModType,
StatModKey: ench.StatModKey,
StatModValue: ench.StatModValue,
Bucket: (uint)ench.Bucket));
if (dumpPd)
Console.WriteLine($"vitals: PD-ench spell={ench.SpellId} layer={ench.Layer} bucket={ench.Bucket} key={ench.StatModKey} val={ench.StatModValue}");
}
// D.5.4: PlayerDescription is a membership MANIFEST, not the data
// source. Record existence (+ equip slot); CreateObject fills the
// actual weenie data via ObjectTableWiring. (Previously this seeded
@ -630,4 +649,46 @@ public static class GameEventWiring
onShortcuts?.Invoke(p.Value.Shortcuts);
});
}
private static ActiveEnchantmentRecord ToActiveEnchantment(
PlayerDescriptionParser.EnchantmentEntry enchantment,
double receivedAt) => new(
SpellId: enchantment.SpellId,
LayerId: enchantment.Layer,
Duration: enchantment.Duration,
CasterGuid: enchantment.CasterGuid,
StatModType: enchantment.StatModType,
StatModKey: enchantment.StatModKey,
StatModValue: enchantment.StatModValue,
Bucket: enchantment.Bucket == 0
? ClassifyLiveEnchantmentBucket(enchantment.StatModType)
: (uint)enchantment.Bucket,
// Retail Enchantment::UnPack (0x005CB040) converts both relative
// wire timestamps to the monotonic client Timer domain at receipt.
StartTime: receivedAt + enchantment.StartTime,
SpellCategory: enchantment.SpellCategory,
PowerLevel: enchantment.PowerLevel,
DegradeModifier: enchantment.DegradeModifier,
DegradeLimit: enchantment.DegradeLimit,
LastTimeDegraded: receivedAt + enchantment.LastTimeDegraded,
SpellSetId: enchantment.SpellSetId);
/// <summary>
/// Live 0x02C2/0x02C4 records do not carry PlayerDescription's outer
/// EnchantmentMask bucket. Retail reconstructs the registry list from the
/// StatMod type flags; this is the same ordering used by ACE's
/// EnchantmentRegistry.BuildCategories.
/// </summary>
private static uint ClassifyLiveEnchantmentBucket(uint statModType)
{
const uint Multiplicative = 0x00004000u;
const uint Additive = 0x00008000u;
const uint Vitae = 0x00800000u;
const uint Cooldown = 0x01000000u;
if ((statModType & Vitae) != 0) return 4u;
if ((statModType & Cooldown) != 0) return 8u;
if ((statModType & Multiplicative) != 0) return 1u;
if ((statModType & Additive) != 0) return 2u;
return 0u;
}
}

View file

@ -33,6 +33,9 @@ public static class ClientCommandRequests
public const uint DisplayConsentOpcode = 0x0217u;
public const uint RemoveConsentOpcode = 0x0218u;
public const uint SetDesiredComponentLevelOpcode = 0x0224u;
public const uint AddSpellFavoriteOpcode = 0x01E3u;
public const uint RemoveSpellFavoriteOpcode = 0x01E4u;
public const uint SpellbookFilterOpcode = 0x0286u;
public const uint LegacyFriendsOpcode = 0xF7CDu;
// Named-retail anchors:
@ -177,6 +180,31 @@ public static class ClientCommandRequests
return body;
}
// CM_Character::Event_AddSpellFavorite @ 0x006A0F70.
public static byte[] BuildAddSpellFavorite(
uint sequence, uint spellId, int position, int tabIndex)
{
byte[] body = CreateBody(sequence, AddSpellFavoriteOpcode, 12);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), spellId);
BinaryPrimitives.WriteInt32LittleEndian(body.AsSpan(16), position);
BinaryPrimitives.WriteInt32LittleEndian(body.AsSpan(20), tabIndex);
return body;
}
// CM_Character::Event_RemoveSpellFavorite @ 0x006A1890.
public static byte[] BuildRemoveSpellFavorite(
uint sequence, uint spellId, int tabIndex)
{
byte[] body = CreateBody(sequence, RemoveSpellFavoriteOpcode, 8);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), spellId);
BinaryPrimitives.WriteInt32LittleEndian(body.AsSpan(16), tabIndex);
return body;
}
// CM_Character::Event_SpellbookFilterEvent @ 0x006A1A30.
public static byte[] BuildSpellbookFilter(uint sequence, uint filters) =>
BuildUInt32(sequence, SpellbookFilterOpcode, filters);
private static byte[] BuildParameterless(uint sequence, uint opcode)
{
byte[] body = new byte[12];

View file

@ -207,6 +207,9 @@ public static class CreateObject
// PublicWeenieDesc._ammoType, gated by WeenieHeader flag 0x100.
// AMMO_NONE is the explicit wire value zero; null means absent.
ushort? AmmoType = null,
// PublicWeenieDesc._spellID, gated by PWD_Packed_SpellID. The packed
// wire field is u16 and widens into retail's u32 runtime member.
uint? SpellId = null,
// Complete immutable PhysicsDesc projection. Kept alongside the
// legacy convenience fields while live-entity ownership migrates to
// LiveEntityRuntime in Step 2.
@ -862,6 +865,7 @@ public static class CreateObject
byte? radarBehavior = null;
byte? combatUse = null;
ushort? ammoType = null;
uint? spellId = null;
uint iconOverlayId = 0;
uint iconUnderlayId = 0;
uint uiEffects = 0;
@ -1011,6 +1015,7 @@ public static class CreateObject
if ((weenieFlags & 0x00400000u) != 0) // Spell u16
{
if (body.Length - pos < 2) throw new FormatException("trunc Spell");
spellId = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos));
pos += 2;
}
if ((weenieFlags & 0x02000000u) != 0) // HouseOwner u32
@ -1113,6 +1118,7 @@ public static class CreateObject
PluralName: pluralName,
PetOwnerId: petOwnerId,
AmmoType: ammoType,
SpellId: spellId,
Physics: physics);
}
catch

View file

@ -0,0 +1,115 @@
using System;
using System.Buffers.Binary;
using System.Collections.Generic;
namespace AcDream.Core.Net.Messages;
/// <summary>
/// Retail <c>Enchantment::UnPack</c> reader shared by PlayerDescription and
/// the 0x02C2/0x02C4 magic update events. The record is 60 bytes, plus the
/// optional four-byte spell-set id.
/// </summary>
/// <remarks>
/// Retail reference: <c>CM_Magic::DispatchUI_UpdateEnchantment</c>
/// (0x006A32F0). Layout cross-checked against ACE
/// <c>Network/Structure/Enchantment.cs</c>.
/// </remarks>
public static class EnchantmentWireReader
{
public static PlayerDescriptionParser.EnchantmentEntry Read(
ReadOnlySpan<byte> source,
ref int position,
PlayerDescriptionParser.EnchantmentBucket bucket = 0)
{
if (source.Length - position < 60)
throw new FormatException("truncated enchantment record");
ushort spellId = ReadU16(source, ref position);
ushort layer = ReadU16(source, ref position);
ushort spellCategory = ReadU16(source, ref position);
ushort hasSpellSetId = ReadU16(source, ref position);
uint powerLevel = ReadU32(source, ref position);
double startTime = ReadF64(source, ref position);
double duration = ReadF64(source, ref position);
uint casterGuid = ReadU32(source, ref position);
float degradeModifier = ReadF32(source, ref position);
float degradeLimit = ReadF32(source, ref position);
double lastDegraded = ReadF64(source, ref position);
uint statModType = ReadU32(source, ref position);
uint statModKey = ReadU32(source, ref position);
float statModValue = ReadF32(source, ref position);
uint? spellSetId = hasSpellSetId != 0 ? ReadU32(source, ref position) : null;
if (!double.IsFinite(startTime)
|| !double.IsFinite(duration)
|| !float.IsFinite(degradeModifier)
|| !float.IsFinite(degradeLimit)
|| !double.IsFinite(lastDegraded)
|| !float.IsFinite(statModValue))
{
throw new FormatException("non-finite enchantment value");
}
return new PlayerDescriptionParser.EnchantmentEntry(
spellId, layer, spellCategory, hasSpellSetId, powerLevel,
startTime, duration, casterGuid, degradeModifier, degradeLimit,
lastDegraded, statModType, statModKey, statModValue, spellSetId,
bucket);
}
public static IReadOnlyList<PlayerDescriptionParser.EnchantmentEntry> ReadList(
ReadOnlySpan<byte> source,
ref int position,
PlayerDescriptionParser.EnchantmentBucket bucket = 0)
{
if (source.Length - position < 4)
throw new FormatException("truncated enchantment list count");
uint count = ReadU32(source, ref position);
if (count > 0x4000)
throw new FormatException("unreasonable enchantment list count");
var result = new List<PlayerDescriptionParser.EnchantmentEntry>((int)count);
for (uint i = 0; i < count; i++)
result.Add(Read(source, ref position, bucket));
return result;
}
private static ushort ReadU16(ReadOnlySpan<byte> source, ref int position)
{
Require(source, position, 2);
ushort value = BinaryPrimitives.ReadUInt16LittleEndian(source.Slice(position, 2));
position += 2;
return value;
}
private static uint ReadU32(ReadOnlySpan<byte> source, ref int position)
{
Require(source, position, 4);
uint value = BinaryPrimitives.ReadUInt32LittleEndian(source.Slice(position, 4));
position += 4;
return value;
}
private static float ReadF32(ReadOnlySpan<byte> source, ref int position)
{
Require(source, position, 4);
float value = BinaryPrimitives.ReadSingleLittleEndian(source.Slice(position, 4));
position += 4;
return value;
}
private static double ReadF64(ReadOnlySpan<byte> source, ref int position)
{
Require(source, position, 8);
double value = BinaryPrimitives.ReadDoubleLittleEndian(source.Slice(position, 8));
position += 8;
return value;
}
private static void Require(ReadOnlySpan<byte> source, int position, int count)
{
if (position < 0 || source.Length - position < count)
throw new FormatException("truncated enchantment record");
}
}

View file

@ -1,5 +1,6 @@
using System;
using System.Buffers.Binary;
using System.Collections.Generic;
using System.Text;
namespace AcDream.Core.Net.Messages;
@ -271,14 +272,19 @@ public static class GameEvents
/// <summary>
/// 0x02C3 MagicRemoveEnchantment — (layerId, spellId).
/// </summary>
public readonly record struct MagicRemoveEnchantment(uint LayerId, uint SpellId);
public readonly record struct LayeredSpellId(ushort SpellId, ushort Layer)
{
public uint Packed => SpellId | ((uint)Layer << 16);
}
public readonly record struct MagicRemoveEnchantment(ushort SpellId, ushort Layer);
public static MagicRemoveEnchantment? ParseMagicRemoveEnchantment(ReadOnlySpan<byte> payload)
{
if (payload.Length < 8) return null;
if (payload.Length < 4) return null;
return new MagicRemoveEnchantment(
BinaryPrimitives.ReadUInt32LittleEndian(payload),
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)));
BinaryPrimitives.ReadUInt16LittleEndian(payload),
BinaryPrimitives.ReadUInt16LittleEndian(payload.Slice(2)));
}
/// <summary>0x01A8 MagicRemoveSpell — spell id removed from spellbook.</summary>
@ -294,26 +300,20 @@ public static class GameEvents
/// stat mods. We expose the first few fields that drive the enchant
/// bar UI; the rest is available via the raw payload view.
/// </summary>
public readonly record struct EnchantmentSummary(
uint SpellId,
uint LayerId,
float Duration,
uint CasterGuid);
public static EnchantmentSummary? ParseMagicUpdateEnchantment(ReadOnlySpan<byte> payload)
public static PlayerDescriptionParser.EnchantmentEntry? ParseMagicUpdateEnchantment(
ReadOnlySpan<byte> payload)
{
// Layout (ACE Enchantment.Pack):
// u32 spellId
// u32 layerId
// f32 duration
// u32 casterGuid
// ... (stat mods, category, power, etc.)
if (payload.Length < 16) return null;
return new EnchantmentSummary(
BinaryPrimitives.ReadUInt32LittleEndian(payload),
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)),
BinaryPrimitives.ReadSingleLittleEndian(payload.Slice(8)),
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(12)));
int position = 0;
try { return EnchantmentWireReader.Read(payload, ref position); }
catch (FormatException) { return null; }
}
public static IReadOnlyList<PlayerDescriptionParser.EnchantmentEntry>?
ParseMagicUpdateMultipleEnchantments(ReadOnlySpan<byte> payload)
{
int position = 0;
try { return EnchantmentWireReader.ReadList(payload, ref position); }
catch (FormatException) { return null; }
}
/// <summary>
@ -323,6 +323,23 @@ public static class GameEvents
public static MagicRemoveEnchantment? ParseMagicDispelEnchantment(ReadOnlySpan<byte> payload)
=> ParseMagicRemoveEnchantment(payload);
public static IReadOnlyList<LayeredSpellId>? ParseMagicLayeredSpellList(
ReadOnlySpan<byte> payload)
{
if (payload.Length < 4) return null;
uint count = BinaryPrimitives.ReadUInt32LittleEndian(payload);
if (count > 0x4000 || payload.Length - 4 < checked((int)count * 4)) return null;
var result = new LayeredSpellId[count];
for (int i = 0; i < result.Length; i++)
{
int offset = 4 + i * 4;
result[i] = new LayeredSpellId(
BinaryPrimitives.ReadUInt16LittleEndian(payload.Slice(offset, 2)),
BinaryPrimitives.ReadUInt16LittleEndian(payload.Slice(offset + 2, 2)));
}
return result;
}
// ── Appraise / identify ─────────────────────────────────────────────────
/// <summary>0x00C9 IdentifyObjectResponse header.</summary>

View file

@ -328,7 +328,9 @@ public static class PlayerDescriptionParser
CharacterOptionDataFlag optionFlags = CharacterOptionDataFlag.None;
uint options1 = 0;
uint options2 = 0;
uint spellbookFilters = 0;
// Retail CPlayerModule ctor (0x005D5245) enables every school and
// level filter before any optional PlayerDescription override.
uint spellbookFilters = 0x3FFFu;
List<ShortcutEntry> shortcuts = new();
List<IReadOnlyList<uint>> hotbarSpells = new();
List<(uint, uint)> desiredComps = new();
@ -687,10 +689,7 @@ public static class PlayerDescriptionParser
ReadOnlySpan<byte> src, ref int pos, List<EnchantmentEntry> dest,
EnchantmentBucket bucket)
{
uint count = ReadU32(src, ref pos);
if (count > 0x4000) throw new FormatException("unreasonable enchantment list count");
for (int i = 0; i < count; i++)
dest.Add(ReadEnchantment(src, ref pos, bucket));
dest.AddRange(EnchantmentWireReader.ReadList(src, ref pos, bucket));
}
private static EnchantmentEntry ReadEnchantment(
@ -703,29 +702,7 @@ public static class PlayerDescriptionParser
// f32 degrade_modifier, f32 degrade_limit, f64 last_time_degraded,
// u32 stat_mod_type, u32 stat_mod_key, f32 stat_mod_value, (28)
// if has_spell_set_id != 0: u32 spell_set_id (0 or 4)
if (src.Length - pos < 60) throw new FormatException("truncated enchantment record");
ushort spellId = ReadU16(src, ref pos);
ushort layer = ReadU16(src, ref pos);
ushort spellCategory = ReadU16(src, ref pos);
ushort hasSpellSetId = ReadU16(src, ref pos);
uint powerLevel = ReadU32(src, ref pos);
double startTime = ReadF64(src, ref pos);
double duration = ReadF64(src, ref pos);
uint casterGuid = ReadU32(src, ref pos);
float degradeModifier= ReadF32(src, ref pos);
float degradeLimit = ReadF32(src, ref pos);
double lastDegraded = ReadF64(src, ref pos);
uint statModType = ReadU32(src, ref pos);
uint statModKey = ReadU32(src, ref pos);
float statModValue = ReadF32(src, ref pos);
uint? spellSetId = null;
if (hasSpellSetId != 0)
spellSetId = ReadU32(src, ref pos);
return new EnchantmentEntry(
spellId, layer, spellCategory, hasSpellSetId, powerLevel,
startTime, duration, casterGuid, degradeModifier, degradeLimit,
lastDegraded, statModType, statModKey, statModValue, spellSetId,
bucket);
return EnchantmentWireReader.Read(src, ref pos, bucket);
}
/// <summary>Strict inventory + equipped block reader. Returns true if

View file

@ -138,5 +138,6 @@ public static class ObjectTableWiring
CombatUse: s.CombatUse,
PluralName: s.PluralName,
PetOwnerId: s.PetOwnerId,
AmmoType: s.AmmoType);
AmmoType: s.AmmoType,
SpellId: s.SpellId);
}

View file

@ -131,6 +131,7 @@ public sealed class WorldSession : IDisposable
string? PluralName = null,
uint? PetOwnerId = null,
ushort? AmmoType = null,
uint? SpellId = null,
PhysicsSpawnData? Physics = null);
/// <summary>
@ -191,6 +192,7 @@ public sealed class WorldSession : IDisposable
PluralName: parsed.PluralName,
PetOwnerId: parsed.PetOwnerId,
AmmoType: parsed.AmmoType,
SpellId: parsed.SpellId,
Physics: parsed.Physics);
/// <summary>Fires when the session finishes parsing a CreateObject.</summary>
@ -1451,6 +1453,45 @@ public sealed class WorldSession : IDisposable
seq, componentId: 0u, amount: uint.MaxValue));
}
public void SendSetDesiredComponentLevel(uint componentId, uint amount)
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildSetDesiredComponentLevel(
seq, componentId, amount));
}
public void SendAddSpellFavorite(uint spellId, int position, int tabIndex)
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildAddSpellFavorite(
seq, spellId, position, tabIndex));
}
public void SendRemoveSpellFavorite(uint spellId, int tabIndex)
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildRemoveSpellFavorite(
seq, spellId, tabIndex));
}
public void SendSpellbookFilter(uint filters)
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildSpellbookFilter(seq, filters));
}
public void SendCastUntargetedSpell(uint spellId)
{
uint seq = NextGameActionSequence();
SendGameAction(CastSpellRequest.BuildUntargeted(seq, spellId));
}
public void SendCastTargetedSpell(uint targetGuid, uint spellId)
{
uint seq = NextGameActionSequence();
SendGameAction(CastSpellRequest.BuildTargeted(seq, targetGuid, spellId));
}
/// <summary>Send retail ChangeCombatMode (0x0053).</summary>
public void SendChangeCombatMode(CombatMode mode)
{

View file

@ -191,6 +191,8 @@ public sealed class ClientObject
public byte? CombatUse { get; set; }
/// <summary>Retail <c>PublicWeenieDesc._ammoType</c>; zero is <c>AMMO_NONE</c>.</summary>
public ushort? AmmoType { get; set; }
/// <summary>Retail <c>PublicWeenieDesc._spellID</c>; used by caster endowments.</summary>
public uint? SpellId { get; set; }
/// <summary>Client-side trade state used by ItemHolder legality gates.</summary>
public int TradeState { get; set; }
/// <summary>Resolved membership in SpellComponentTable (retail IsComponentPack).</summary>
@ -258,7 +260,8 @@ public readonly record struct WeenieData(
byte? CombatUse = null,
string? PluralName = null,
uint? PetOwnerId = null,
ushort? AmmoType = null);
ushort? AmmoType = null,
uint? SpellId = null);
/// <summary>
/// Retail ITEM_USEABLE helpers (acclient.h:6478, ItemUses::* at 0x004fccd0).

View file

@ -700,6 +700,7 @@ public sealed class ClientObjectTable
if (d.PetOwnerId is { } petOwnerId) obj.PetOwnerId = petOwnerId;
if (d.CombatUse is { } combatUse) obj.CombatUse = combatUse;
if (d.AmmoType is { } ammoType) obj.AmmoType = ammoType;
if (d.SpellId is { } spellId) obj.SpellId = spellId;
if (d.RadarBlipColor is { } radarBlipColor) obj.RadarBlipColor = radarBlipColor;
if (d.RadarBehavior is { } radarBehavior) obj.RadarBehavior = radarBehavior;
if (d.ItemsCapacity is { } ic) obj.ItemsCapacity = ic;

View file

@ -0,0 +1,67 @@
using System;
using System.Collections.Generic;
namespace AcDream.Core.Spells;
/// <summary>
/// Pure port of retail's ordinary in-effect registry projection:
/// <c>CEnchantmentRegistry::GetEnchantmentsInEffect @ 0x00594540</c>,
/// <c>GetEnchantmentsInEffectFromList @ 0x00594430</c>,
/// <c>Duel @ 0x005942B0</c>, and <c>Enchantment::Duel @ 0x005CACE0</c>.
/// </summary>
public static class EnchantmentRegistryProjection
{
/// <summary>
/// Merges multiplicative then additive records and retains one winner per
/// spell category. Higher power wins; equal power is replaced only by a
/// later start time. Vitae and cooldown registries do not participate.
/// </summary>
public static IReadOnlyList<ActiveEnchantmentRecord> GetEnchantmentsInEffect(
IEnumerable<ActiveEnchantmentRecord> enchantments)
{
ArgumentNullException.ThrowIfNull(enchantments);
ActiveEnchantmentRecord[] source = enchantments as ActiveEnchantmentRecord[]
?? [.. enchantments];
var result = new List<ActiveEnchantmentRecord>();
DuelList(source, bucket: 1u, result);
DuelList(source, bucket: 2u, result);
return result;
}
private static void DuelList(
IReadOnlyList<ActiveEnchantmentRecord> source,
uint bucket,
List<ActiveEnchantmentRecord> result)
{
for (int sourceIndex = 0; sourceIndex < source.Count; sourceIndex++)
{
ActiveEnchantmentRecord candidate = source[sourceIndex];
if (candidate.Bucket != bucket) continue;
int incumbentIndex = result.FindIndex(existing =>
existing.SpellCategory == candidate.SpellCategory);
if (incumbentIndex >= 0)
{
ActiveEnchantmentRecord incumbent = result[incumbentIndex];
if (!CandidateWins(incumbent, candidate))
continue;
// Retail removes the losing node, then appends the winner.
result.RemoveAt(incumbentIndex);
}
result.Add(candidate);
}
}
private static bool CandidateWins(
ActiveEnchantmentRecord incumbent,
ActiveEnchantmentRecord candidate)
{
// Retail Enchantment::_power_level is signed int even though the wire
// field is read as four raw bytes.
int incumbentPower = unchecked((int)incumbent.PowerLevel);
int candidatePower = unchecked((int)candidate.PowerLevel);
return candidatePower > incumbentPower
|| (candidatePower == incumbentPower
&& candidate.StartTime > incumbent.StartTime);
}
}

View file

@ -0,0 +1,190 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
namespace AcDream.Core.Spells;
/// <summary>
/// Pure retail spell-formula helpers. The formula is an eight-entry list of
/// spell-component enum IDs (SCIDs), not inventory weenie class IDs.
/// </summary>
public static class RetailSpellFormula
{
private const uint LowestTaperId = 63u;
private const uint PrismaticTaperId = 0xBCu;
/// <summary>
/// Retail MagicSystem::DeterminePowerLevelOfComponent (0x005BD240).
/// </summary>
public static uint DeterminePowerLevelOfComponent(uint componentId) => componentId switch
{
>= 1u and <= 6u => componentId,
0x6Eu => 7u,
0x70u => 8u,
0xC0u => 9u,
0xC1u => 10u,
_ => 0u,
};
/// <summary>
/// Retail CSpellBase::InqSpellLevelByRoughHeuristic (0x00597260).
/// This is the I-VIII level used by gmSpellbookUI's level filters.
/// </summary>
public static int InqSpellLevelByRoughHeuristic(IReadOnlyList<uint> components)
{
uint power = components.Count == 0
? 0u
: DeterminePowerLevelOfComponent(components[0]);
return power switch
{
< 7u => (int)power,
>= 9u => (int)power - 2,
_ => (int)power - 1,
};
}
/// <summary>
/// Retail <c>CSpellBase::InqScarabOnlyFormula</c> (0x00597050).
/// Foci and the infused-magic augmentations replace the ordinary taper
/// formula with its power components plus one to four prismatic tapers.
/// </summary>
public static IReadOnlyList<uint> InqScarabOnlyFormula(
IReadOnlyList<uint> components)
{
ArgumentNullException.ThrowIfNull(components);
var result = new List<uint>(8);
uint strongestPower = 0u;
foreach (uint component in components.Take(8))
{
if (component == 0u)
break;
if (!IsPowerComponent(component))
continue;
result.Add(component);
strongestPower = Math.Max(
strongestPower,
DeterminePowerLevelOfComponent(component));
}
int taperCount = strongestPower switch
{
1u => 1,
2u => 2,
3u or 4u or 7u => 3,
5u or 6u or 8u or 9u or 10u => 4,
_ => 0,
};
while (taperCount-- > 0 && result.Count < 8)
result.Add(PrismaticTaperId);
return result;
}
private static bool IsPowerComponent(uint component) => component is
>= 1u and <= 6u or 0x6Eu or 0x6Fu or 0x70u or 0xC0u or 0xC1u;
/// <summary>
/// Retail PString's CP-1252 hash used by SpellFormula::RandomizeForName.
/// </summary>
public static uint ComputeNameHash(string value)
{
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
long result = 0;
foreach (byte raw in Encoding.GetEncoding(1252).GetBytes(value ?? string.Empty))
{
sbyte character = unchecked((sbyte)raw);
result = character + (result << 4);
if ((result & 0xF0000000L) != 0)
result = (result ^ ((result & 0xF0000000L) >> 24)) & 0x0FFFFFFF;
}
return unchecked((uint)result);
}
/// <summary>
/// Retail SpellFormula::RandomizeForName (0x005BD050), cross-checked with
/// ACE.DatLoader SpellTable.GetSpellFormula. Only taper SCIDs change.
/// </summary>
public static IReadOnlyList<uint> CustomizeForAccount(
IReadOnlyList<uint> components,
uint formulaVersion,
string accountName)
{
uint[] result = components.Take(8).ToArray();
return formulaVersion switch
{
1u => RandomizeVersion1(result, accountName),
2u => RandomizeVersion2(result, accountName),
3u => RandomizeVersion3(result, accountName),
_ => result,
};
}
private static IReadOnlyList<uint> RandomizeVersion1(uint[] c, string accountName)
{
int count = c.Count(value => value != 0);
if (count < 5) return c;
uint seed = ComputeNameHash(accountName) % 0x13D573u;
uint scarab = c[0];
int herbIndex = count > 5 ? 2 : 1;
uint herb = c[herbIndex];
int powderIndex = herbIndex + 1 + (count > 6 ? 1 : 0);
if (powderIndex + 1 >= c.Length) return c;
uint powder = c[powderIndex];
uint potion = c[powderIndex + 1];
int talismanIndex = powderIndex + 2 + (count > 7 ? 1 : 0);
if (talismanIndex >= c.Length) return c;
uint talisman = c[talismanIndex];
if (count > 5)
c[1] = unchecked(powder + 2u * herb + potion + talisman + scarab) % 12u + LowestTaperId;
if (count > 6)
{
uint denominator = unchecked(scarab + powder + potion);
if (denominator != 0)
c[3] = unchecked((scarab + herb + talisman + 2u * (powder + potion))
* (seed / denominator)) % 12u + LowestTaperId;
}
if (count > 7)
{
uint denominator = unchecked(talisman + scarab);
if (denominator != 0)
c[6] = unchecked((powder + 2u * talisman + potion + herb + scarab)
* (seed / denominator)) % 12u + LowestTaperId;
}
return c;
}
private static IReadOnlyList<uint> RandomizeVersion2(uint[] c, string accountName)
{
if (c.Length < 8) return c;
uint seed = ComputeNameHash(accountName) % 0x13D573u;
uint p1 = c[0], c4 = c[4], x = c[5], a = c[7];
c[3] = unchecked(a + 3u * p1 + 2u * c4 * x + c[2] + c[1]) % 12u + LowestTaperId;
uint denominator = unchecked(c[1] * a + 2u * c4);
if (denominator != 0)
c[6] = unchecked((a + 3u * p1 * c[2] + 2u * x + c4)
* (seed / denominator)) % 12u + LowestTaperId;
return c;
}
private static IReadOnlyList<uint> RandomizeVersion3(uint[] c, string accountName)
{
if (c.Length < 7) return c;
uint hash = ComputeNameHash(accountName);
uint h0 = unchecked(hash % 0x13D573u + c[0]) % 12u;
uint h1 = unchecked(hash % 0x4AEFDu + c[1]) % 12u;
uint h2 = unchecked(hash % 0x96A7Fu + c[2]) % 12u;
uint h4 = unchecked(hash % 0x100A03u + c[4]) % 12u;
uint h5 = unchecked(hash % 0xEB2EFu + c[5]) % 12u;
uint h7 = unchecked(hash % 0x121E7Du + (c.Length > 7 ? c[7] : 0u)) % 12u;
c[3] = unchecked(h0 + h1 + h2 + h4 + h5 + h2 * h5 + h0 * h1 + h7 * (h4 + 1u))
% 12u + LowestTaperId;
c[6] = unchecked(h0 + h1 + h2 + h4 + hash % 0x65039u % 12u
+ h7 * (h4 * (h0 * h1 * h2 * h5 + 7u) + 1u)
+ h5 + 5u * h0 * h1 + 11u * h2 * h5) % 12u + LowestTaperId;
return c;
}
}

View file

@ -41,4 +41,21 @@ public sealed record SpellMetadata(
int ManaCost,
bool IsDebuff,
bool IsFellowship,
string Description);
string Description,
int SortKey,
int Difficulty,
uint Flags,
int Generation,
bool IsFastWindup,
bool IsOffensive,
bool IsUntargeted,
float Speed,
uint CasterEffect,
uint TargetEffect,
uint TargetMask,
int SpellType)
{
public bool IsSelfTargeted => (Flags & 0x00000008u) != 0;
public bool IsBeneficial => (Flags & 0x00000004u) != 0;
public bool IsProjectile => (Flags & 0x00000100u) != 0;
}

View file

@ -18,11 +18,9 @@ public enum MagicSchool : uint
None = 0,
WarMagic = 1,
LifeMagic = 2,
CreatureEnchantment = 3,
ItemEnchantment = 4,
PortalMagic = 5,
// VoidMagic added in later retail revisions; uses LifeMagic skill.
VoidMagic = 6,
ItemEnchantment = 3,
CreatureEnchantment = 4,
VoidMagic = 5,
}
/// <summary>
@ -55,14 +53,23 @@ public enum SpellCategory : uint
public enum SpellFlags : uint
{
None = 0,
Beneficial = 0x00000001,
Resistable = 0x00000002,
Projectile = 0x00000004,
EnchantmentDispel = 0x00000008,
PurgeOnReset = 0x00000010,
Resistable = 0x00000001,
PKSensitive = 0x00000002,
Beneficial = 0x00000004,
SelfTargeted = 0x00000008,
Reversed = 0x00000010,
NotIndoors = 0x00000020,
Melee = 0x00000040,
Missile = 0x00000080,
NotOutdoors = 0x00000040,
NotResearchable = 0x00000080,
Projectile = 0x00000100,
CreatureSpell = 0x00000200,
ExcludedFromItemDescriptions = 0x00000400,
IgnoresManaConversion = 0x00000800,
NonTrackingProjectile = 0x00001000,
FellowshipSpell = 0x00002000,
FastCast = 0x00004000,
IndoorLongRange = 0x00008000,
DamageOverTime = 0x00010000,
// more flags in r01 §1
}

View file

@ -101,6 +101,18 @@ public sealed class SpellTable
int? iIsDebuff = Get("IsDebuff");
int? iIsFellow = Get("IsFellowship");
int? iDescription = Get("Description");
int? iSortKey = Get("SortKey");
int? iDifficulty = Get("Difficulty");
int? iFlags = Get("Flags [Hex]");
int? iGeneration = Get("Generation");
int? iFastWindup = Get("IsFastWindup");
int? iOffensive = Get("IsOffensive");
int? iUntargeted = Get("IsUntargetted");
int? iSpeed = Get("Speed");
int? iCasterFx = Get("CasterEffect");
int? iTargetFx = Get("TargetEffect");
int? iTargetMask = Get("TargetMask [Hex]");
int? iSpellType = Get("Type");
if (iSpellId is null || iName is null) return new SpellTable(byId);
@ -125,10 +137,24 @@ public sealed class SpellTable
bool isDebuff = ParseBool(fields, iIsDebuff);
bool isFellow = ParseBool(fields, iIsFellow);
string description = iDescription is int d && d < fields.Count ? fields[d] : "";
int sortKey = (int)ParseUInt(fields, iSortKey);
int difficulty = (int)ParseUInt(fields, iDifficulty);
uint flags = ParseHexUInt(fields, iFlags);
int generation = (int)ParseUInt(fields, iGeneration);
bool fastWindup = ParseBool(fields, iFastWindup);
bool offensive = ParseBool(fields, iOffensive);
bool untargeted = ParseBool(fields, iUntargeted);
float speed = ParseFloat(fields, iSpeed);
uint casterEffect = ParseUInt(fields, iCasterFx);
uint targetEffect = ParseUInt(fields, iTargetFx);
uint targetMask = ParseHexUInt(fields, iTargetMask);
int spellType = (int)ParseUInt(fields, iSpellType);
byId[spellId] = new SpellMetadata(
spellId, name, school, family, iconId, words, duration,
mana, isDebuff, isFellow, description);
mana, isDebuff, isFellow, description, sortKey, difficulty,
flags, generation, fastWindup, offensive, untargeted, speed,
casterEffect, targetEffect, targetMask, spellType);
}
return new SpellTable(byId);

View file

@ -1,6 +1,6 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
namespace AcDream.Core.Spells;
@ -17,9 +17,14 @@ namespace AcDream.Core.Spells;
/// </summary>
public sealed class Spellbook
{
private readonly HashSet<uint> _learnedSpells = new();
private readonly ConcurrentDictionary<uint, ActiveEnchantmentRecord> _activeByLayer = new();
private readonly Dictionary<uint, float> _learnedSpells = new();
private readonly Dictionary<uint, ActiveEnchantmentRecord> _activeById = new();
private readonly Dictionary<uint, List<uint>> _enchantmentOrderByBucket = new();
private readonly List<uint>[] _favoriteSpells = Enumerable.Range(0, 8)
.Select(_ => new List<uint>()).ToArray();
private readonly Dictionary<uint, uint> _desiredComponents = new();
private readonly SpellTable _table;
private uint _spellbookFilters = 0x3FFFu;
/// <summary>
/// Build a Spellbook with an optional <see cref="SpellTable"/>
@ -69,39 +74,98 @@ public sealed class Spellbook
/// <summary>Fires when an enchantment is removed (expired / dispelled).</summary>
public event Action<ActiveEnchantmentRecord>? EnchantmentRemoved;
/// <summary>Fires when any manifest-backed magic UI state changes.</summary>
public event Action? StateChanged;
/// <summary>Fires when learned spells, favorites, or spellbook filters change.</summary>
public event Action? SpellbookChanged;
/// <summary>Fires when the active enchantment set changes.</summary>
public event Action? EnchantmentsChanged;
/// <summary>Fires when the desired spell-component levels change.</summary>
public event Action? DesiredComponentsChanged;
/// <summary>All currently learned spell ids.</summary>
public IReadOnlyCollection<uint> LearnedSpells => _learnedSpells;
public IReadOnlyCollection<uint> LearnedSpells => _learnedSpells.Keys;
/// <summary>Stable learned-spell snapshot, including server spell power.</summary>
public IReadOnlyList<LearnedSpellRecord> LearnedSpellSnapshot => _learnedSpells
.Select(pair => new LearnedSpellRecord(pair.Key, pair.Value))
.OrderBy(spell => spell.SpellId)
.ToArray();
/// <summary>All currently-active enchantments.</summary>
public IEnumerable<ActiveEnchantmentRecord> ActiveEnchantments => _activeByLayer.Values;
public IEnumerable<ActiveEnchantmentRecord> ActiveEnchantments => _activeById.Values;
public IReadOnlyList<ActiveEnchantmentRecord> ActiveEnchantmentSnapshot =>
_activeById.Values.OrderBy(record => record.Identity).ToArray();
/// <summary>
/// Retail ordinary-effect projection after mult/add filtering and the
/// spell-category duel. This is the source for effects-list rows; retail's
/// indicator counts scan the raw mult/add records before this projection.
/// </summary>
public IReadOnlyList<ActiveEnchantmentRecord> EnchantmentsInEffectSnapshot
{
get
{
var ordered = new List<ActiveEnchantmentRecord>();
AppendBucket(bucket: 1u, ordered);
AppendBucket(bucket: 2u, ordered);
return EnchantmentRegistryProjection.GetEnchantmentsInEffect(ordered);
}
}
public IReadOnlyList<uint> GetFavorites(int tabIndex)
{
if ((uint)tabIndex >= _favoriteSpells.Length) return Array.Empty<uint>();
return _favoriteSpells[tabIndex].ToArray();
}
public IReadOnlyDictionary<uint, uint> DesiredComponents => _desiredComponents;
public uint SpellbookFilters => _spellbookFilters;
public int LearnedCount => _learnedSpells.Count;
public int ActiveCount => _activeByLayer.Count;
public int ActiveCount => _activeById.Count;
public bool Knows(uint spellId) => _learnedSpells.Contains(spellId);
public bool Knows(uint spellId) => _learnedSpells.ContainsKey(spellId);
// ── Inbound handlers ─────────────────────────────────────────────────────
/// <summary>0x02C1 MagicUpdateSpell: learn a spell.</summary>
public void OnSpellLearned(uint spellId)
public void OnSpellLearned(uint spellId, float power = 0f)
{
if (_learnedSpells.Add(spellId))
if (_learnedSpells.TryAdd(spellId, power))
{
SpellLearned?.Invoke(spellId);
NotifySpellbookChanged();
}
else if (_learnedSpells[spellId] != power)
{
_learnedSpells[spellId] = power;
NotifySpellbookChanged();
}
}
/// <summary>0x01A8 MagicRemoveSpell: forget a spell.</summary>
public void OnSpellForgotten(uint spellId)
{
if (_learnedSpells.Remove(spellId))
{
SpellForgotten?.Invoke(spellId);
NotifySpellbookChanged();
}
}
/// <summary>0x02C2 MagicUpdateEnchantment: enchantment added / refreshed.</summary>
public void OnEnchantmentAdded(uint spellId, uint layerId, float duration, uint casterGuid)
{
var record = new ActiveEnchantmentRecord(spellId, layerId, duration, casterGuid);
_activeByLayer[layerId] = record;
UpsertLiveEnchantment(record);
EnchantmentAdded?.Invoke(record);
NotifyEnchantmentsChanged();
}
/// <summary>
@ -113,56 +177,305 @@ public sealed class Spellbook
/// </summary>
public void OnEnchantmentAdded(ActiveEnchantmentRecord record)
{
_activeByLayer[record.LayerId] = record;
UpsertLiveEnchantment(record);
EnchantmentAdded?.Invoke(record);
NotifyEnchantmentsChanged();
}
/// <summary>0x02C3 / 0x02C7 MagicRemove/DispelEnchantment.</summary>
public void OnEnchantmentRemoved(uint layerId, uint spellId)
{
if (_activeByLayer.TryRemove(layerId, out var record))
uint identity = ActiveEnchantmentRecord.MakeIdentity(spellId, layerId);
if (RemoveActiveEnchantment(identity, out ActiveEnchantmentRecord record))
{
EnchantmentRemoved?.Invoke(record);
else
EnchantmentRemoved?.Invoke(new ActiveEnchantmentRecord(spellId, layerId, 0f, 0));
NotifyEnchantmentsChanged();
}
}
/// <summary>0x02C6 MagicPurgeEnchantments: clear all active buffs.</summary>
public void OnEnchantmentsAdded(IEnumerable<ActiveEnchantmentRecord> records)
{
foreach (ActiveEnchantmentRecord record in records)
{
UpsertLiveEnchantment(record);
EnchantmentAdded?.Invoke(record);
}
NotifyEnchantmentsChanged();
}
public void OnEnchantmentsRemoved(IEnumerable<(uint SpellId, uint Layer)> spells)
{
bool changed = false;
foreach ((uint spellId, uint layer) in spells)
{
uint identity = ActiveEnchantmentRecord.MakeIdentity(spellId, layer);
if (!RemoveActiveEnchantment(identity, out ActiveEnchantmentRecord record)) continue;
changed = true;
EnchantmentRemoved?.Invoke(record);
}
if (changed) NotifyEnchantmentsChanged();
}
/// <summary>
/// Retail EnchantmentRegistry::PurgeEnchantments (0x00594DE0): purge only
/// finite-duration multiplicative/additive enchantments. Vitae, cooldowns,
/// and permanent enchantments live in separate registries and survive.
/// </summary>
public void OnPurgeAll()
{
foreach (var rec in _activeByLayer.Values)
ActiveEnchantmentRecord[] removed = _activeById.Values
.Where(IsPurgeable)
.ToArray();
foreach (ActiveEnchantmentRecord record in removed)
RemoveActiveEnchantment(record.Identity, out _);
foreach (var rec in removed)
EnchantmentRemoved?.Invoke(rec);
_activeByLayer.Clear();
if (removed.Length != 0) NotifyEnchantmentsChanged();
}
public void OnPurgeBadEnchantments()
{
// Retail PurgeBadEnchantments (0x00594E00) tests the StatMod
// Beneficial bit directly; spell-table labels do not participate.
const uint BeneficialStatMod = 0x02000000u;
ActiveEnchantmentRecord[] removed = _activeById.Values
.Where(record => IsPurgeable(record)
&& (record.StatModType.GetValueOrDefault() & BeneficialStatMod) == 0)
.ToArray();
foreach (ActiveEnchantmentRecord record in removed)
RemoveActiveEnchantment(record.Identity, out _);
foreach (ActiveEnchantmentRecord record in removed)
EnchantmentRemoved?.Invoke(record);
if (removed.Length != 0) NotifyEnchantmentsChanged();
}
private static bool IsPurgeable(ActiveEnchantmentRecord record) =>
(record.Bucket is 1u or 2u) && record.Duration != -1f;
/// <summary>
/// PlayerDescription is a complete character manifest. Replace every
/// magic collection atomically so relogging or switching characters cannot
/// retain state from the prior snapshot.
/// </summary>
public void ReplaceManifest(
IReadOnlyDictionary<uint, float> learnedSpells,
IEnumerable<ActiveEnchantmentRecord> enchantments,
IReadOnlyList<IReadOnlyList<uint>> favorites,
IReadOnlyList<(uint Id, uint Amount)> desiredComponents,
uint spellbookFilters)
{
_learnedSpells.Clear();
foreach ((uint spellId, float power) in learnedSpells)
_learnedSpells[spellId] = power;
_activeById.Clear();
_enchantmentOrderByBucket.Clear();
foreach (ActiveEnchantmentRecord enchantment in enchantments)
UpsertManifestEnchantment(enchantment);
for (int i = 0; i < _favoriteSpells.Length; i++)
{
_favoriteSpells[i].Clear();
if (i < favorites.Count)
_favoriteSpells[i].AddRange(favorites[i]);
}
_desiredComponents.Clear();
foreach ((uint id, uint amount) in desiredComponents)
_desiredComponents[id] = amount;
_spellbookFilters = spellbookFilters;
SpellbookChanged?.Invoke();
EnchantmentsChanged?.Invoke();
DesiredComponentsChanged?.Invoke();
StateChanged?.Invoke();
}
public void SetFavorite(int tabIndex, int position, uint spellId)
{
if ((uint)tabIndex >= _favoriteSpells.Length || position < 0) return;
List<uint> tab = _favoriteSpells[tabIndex];
if (position > tab.Count) position = tab.Count;
tab.Remove(spellId);
tab.Insert(Math.Min(position, tab.Count), spellId);
NotifySpellbookChanged();
}
public void RemoveFavorite(int tabIndex, uint spellId)
{
if ((uint)tabIndex >= _favoriteSpells.Length) return;
if (_favoriteSpells[tabIndex].Remove(spellId)) NotifySpellbookChanged();
}
public void SetSpellbookFilters(uint filters)
{
if (_spellbookFilters == filters) return;
_spellbookFilters = filters;
NotifySpellbookChanged();
}
public void SetDesiredComponent(uint componentId, uint amount)
{
uint current = _desiredComponents.TryGetValue(componentId, out uint existing)
? existing : 0u;
if (current == amount) return;
if (amount == 0) _desiredComponents.Remove(componentId);
else _desiredComponents[componentId] = amount;
DesiredComponentsChanged?.Invoke();
StateChanged?.Invoke();
}
public void Clear()
{
_learnedSpells.Clear();
_activeByLayer.Clear();
_activeById.Clear();
_enchantmentOrderByBucket.Clear();
foreach (List<uint> tab in _favoriteSpells) tab.Clear();
_desiredComponents.Clear();
_spellbookFilters = 0x3FFFu;
SpellbookChanged?.Invoke();
EnchantmentsChanged?.Invoke();
DesiredComponentsChanged?.Invoke();
StateChanged?.Invoke();
}
private void NotifySpellbookChanged()
{
SpellbookChanged?.Invoke();
StateChanged?.Invoke();
}
private void NotifyEnchantmentsChanged()
{
EnchantmentsChanged?.Invoke();
StateChanged?.Invoke();
}
private void AppendBucket(uint bucket, List<ActiveEnchantmentRecord> destination)
{
if (!_enchantmentOrderByBucket.TryGetValue(bucket, out List<uint>? order)) return;
foreach (uint identity in order)
if (_activeById.TryGetValue(identity, out ActiveEnchantmentRecord record)
&& record.Bucket == bucket)
destination.Add(record);
}
/// <summary>
/// Retail <c>AddEnchantmentToList @ 0x00593DF0</c> inserts a new live
/// mult/add record at the list head. Refreshing an existing identity keeps
/// its node position; moving buckets creates a new head node.
/// </summary>
private void UpsertLiveEnchantment(ActiveEnchantmentRecord record)
{
uint identity = record.Identity;
if (_activeById.TryGetValue(identity, out ActiveEnchantmentRecord previous))
{
_activeById[identity] = record;
if (previous.Bucket == record.Bucket)
{
EnsureOrdered(identity, record.Bucket, insertAtHead: true);
return;
}
RemoveFromOrder(previous);
}
else
{
_activeById.Add(identity, record);
}
GetBucketOrder(record.Bucket).Insert(0, identity);
}
/// <summary>Bulk UnPack preserves the received head-to-tail list order.</summary>
private void UpsertManifestEnchantment(ActiveEnchantmentRecord record)
{
uint identity = record.Identity;
if (_activeById.TryGetValue(identity, out ActiveEnchantmentRecord previous))
{
_activeById[identity] = record;
if (previous.Bucket == record.Bucket)
{
EnsureOrdered(identity, record.Bucket, insertAtHead: false);
return;
}
RemoveFromOrder(previous);
}
else
{
_activeById.Add(identity, record);
}
GetBucketOrder(record.Bucket).Add(identity);
}
private bool RemoveActiveEnchantment(
uint identity,
out ActiveEnchantmentRecord record)
{
if (!_activeById.Remove(identity, out record)) return false;
RemoveFromOrder(record);
return true;
}
private void EnsureOrdered(uint identity, uint bucket, bool insertAtHead)
{
List<uint> order = GetBucketOrder(bucket);
if (order.Contains(identity)) return;
if (insertAtHead) order.Insert(0, identity);
else order.Add(identity);
}
private List<uint> GetBucketOrder(uint bucket)
{
if (_enchantmentOrderByBucket.TryGetValue(bucket, out List<uint>? order))
return order;
order = new List<uint>();
_enchantmentOrderByBucket.Add(bucket, order);
return order;
}
private void RemoveFromOrder(ActiveEnchantmentRecord record)
{
if (!_enchantmentOrderByBucket.TryGetValue(record.Bucket, out List<uint>? order))
return;
order.Remove(record.Identity);
if (order.Count == 0)
_enchantmentOrderByBucket.Remove(record.Bucket);
}
}
public readonly record struct LearnedSpellRecord(uint SpellId, float Power);
/// <summary>
/// Summary of one active enchantment layer on the player. The
/// optional StatMod fields (issue #12) carry the wire-level
/// `_smod` triad <c>(type, key, val)</c> when available — only
/// `PlayerDescription`'s enchantment block currently populates these
/// (<see cref="AcDream.Core.Net.Messages.PlayerDescriptionParser"/>).
/// `MagicUpdateEnchantment` events still produce records with these
/// fields null until the wire parser is extended.
/// `_smod` triad <c>(type, key, val)</c>. Both the complete
/// `PlayerDescription` snapshot and live MagicUpdateEnchantment events
/// populate the same record shape.
///
/// <para>
/// <see cref="Bucket"/> tells <c>EnchantmentMath</c> whether this
/// enchantment's StatMod is multiplicative (<c>0x01</c>), additive
/// (<c>0x02</c>), cooldown (<c>0x04</c>), or vitae (<c>0x08</c>) per
/// (<c>0x02</c>), vitae (<c>0x04</c>), or cooldown (<c>0x08</c>) per
/// the retail <c>EnchantmentMask</c> classification.
/// </para>
/// </summary>
public readonly record struct ActiveEnchantmentRecord(
uint SpellId,
uint LayerId,
float Duration,
double Duration,
uint CasterGuid,
uint? StatModType = null,
uint? StatModKey = null,
float? StatModValue = null,
uint Bucket = 0);
uint Bucket = 0,
double StartTime = 0,
uint SpellCategory = 0,
uint PowerLevel = 0,
float DegradeModifier = 0,
float DegradeLimit = 0,
double LastTimeDegraded = 0,
uint? SpellSetId = null)
{
public uint Identity => MakeIdentity(SpellId, LayerId);
public static uint MakeIdentity(uint spellId, uint layerId) =>
(spellId & 0xFFFFu) | ((layerId & 0xFFFFu) << 16);
}

View file

@ -13,4 +13,5 @@ namespace AcDream.UI.Abstractions.Input;
public readonly record struct Binding(
KeyChord Chord,
InputAction Action,
ActivationType Activation = ActivationType.Press);
ActivationType Activation = ActivationType.Press,
InputScope Scope = InputScope.Game);

View file

@ -36,6 +36,7 @@ public sealed class InputDispatcher
private readonly IMouseSource _mouse;
private KeyBindings _bindings;
private readonly Stack<InputScope> _scopes = new();
private InputScope? _combatScope;
private readonly HashSet<KeyChord> _heldHoldChords = new();
// Double-click detection. _lastMouseDownButton == null means no recent press.
@ -72,7 +73,33 @@ public sealed class InputDispatcher
}
/// <summary>Topmost scope on the stack — what the dispatcher looks up first.</summary>
public InputScope ActiveScope => _scopes.Peek();
public InputScope ActiveScope => _scopes.Peek() == InputScope.Game && _combatScope is { } combat
? combat
: _scopes.Peek();
/// <summary>Set the mode-dependent combat layer that shadows normal game chords.</summary>
public void SetCombatScope(InputScope? scope)
{
if (scope is not null && scope is not (
InputScope.MeleeCombat or InputScope.MissileCombat or InputScope.MagicCombat))
throw new ArgumentOutOfRangeException(nameof(scope));
if (_combatScope == scope) return;
ReleaseHeldHoldBindings();
_combatScope = scope;
}
private Binding? FindActive(KeyChord chord, ActivationType activation)
{
foreach (InputScope scope in _scopes)
{
if (scope == InputScope.Game && _combatScope is { } combat
&& _bindings.Find(chord, activation, combat) is { } combatBinding)
return combatBinding;
if (_bindings.Find(chord, activation, scope) is { } binding)
return binding;
}
return null;
}
/// <summary>True iff a <see cref="BeginCapture"/> is in progress.</summary>
public bool IsCapturing => _captureCallback is not null;
@ -115,15 +142,18 @@ public sealed class InputDispatcher
/// </summary>
public void SetBindings(KeyBindings bindings)
{
_bindings = bindings ?? throw new ArgumentNullException(nameof(bindings));
_heldHoldChords.Clear();
ArgumentNullException.ThrowIfNull(bindings);
ReleaseHeldHoldBindings();
_bindings = bindings;
}
/// <summary>
/// Per-frame "is this action's chord currently held" query. Walks every
/// binding for the given action; returns true if any of them has its
/// chord currently held in the underlying keyboard/mouse state AND the
/// modifier mask matches.
/// modifier mask matches. Scope precedence is resolved through the same
/// <see cref="FindActive"/> path as transition dispatch, so a combat binding
/// on a chord shadows the normal Game action during held polling too.
///
/// <para>
/// Used by per-frame movement polling (<c>MovementInput.Forward</c>
@ -149,11 +179,28 @@ public sealed class InputDispatcher
if (_mouse.WantCaptureKeyboard) return false;
foreach (var b in _bindings.ForAction(action))
{
if (IsChordHeld(b.Chord)) return true;
if (!IsChordHeld(b.Chord)) continue;
Binding? active = FindActiveHeld(b.Chord, b.Activation);
if (active?.Action == action) return true;
}
return false;
}
private Binding? FindActiveHeld(KeyChord candidate, ActivationType activation)
{
// IsChordHeld deliberately lets a bare movement chord coexist with the
// retail Shift-to-walk modifier. Resolve an explicitly bound current
// modifier chord first so a higher combat scope can still shadow it;
// only fall back to the authored bare chord when Shift has no binding.
var actual = candidate with { Modifiers = _keyboard.CurrentModifiers };
Binding? active = FindActive(actual, activation);
if (active is not null) return active;
return candidate.Modifiers == ModifierMask.None
&& _keyboard.CurrentModifiers == ModifierMask.Shift
? FindActive(candidate, activation)
: null;
}
/// <summary>True iff the given chord's primary key is currently down on
/// the appropriate device AND the keyboard's current modifier mask
/// matches the chord's required modifier mask. Match semantics:
@ -208,7 +255,11 @@ public sealed class InputDispatcher
};
/// <summary>Push a scope onto the active stack. Top wins.</summary>
public void PushScope(InputScope scope) => _scopes.Push(scope);
public void PushScope(InputScope scope)
{
ReleaseHeldHoldBindings();
_scopes.Push(scope);
}
/// <summary>Pop the topmost scope. <paramref name="expected"/> is the
/// scope the caller believes is on top; mismatch throws to catch
@ -218,9 +269,22 @@ public sealed class InputDispatcher
if (_scopes.Peek() != expected)
throw new InvalidOperationException(
$"PopScope expected {expected} but top is {_scopes.Peek()}");
ReleaseHeldHoldBindings();
_scopes.Pop();
}
private void ReleaseHeldHoldBindings()
{
if (_heldHoldChords.Count == 0) return;
var releases = new List<Binding>(_heldHoldChords.Count);
foreach (KeyChord chord in _heldHoldChords)
if (FindActive(chord, ActivationType.Hold) is { } binding)
releases.Add(binding);
_heldHoldChords.Clear();
foreach (Binding binding in releases)
Fired?.Invoke(binding.Action, ActivationType.Release);
}
/// <summary>
/// Per-frame tick. Re-fires <see cref="ActivationType.Hold"/>
/// activations for every chord that's currently held. Call once per
@ -238,7 +302,12 @@ public sealed class InputDispatcher
for (int i = 0; i < snapshot.Length; i++)
{
var chord = snapshot[i];
var hold = _bindings.Find(chord, ActivationType.Hold);
// A preceding Hold callback may have changed scope or bindings.
// Those transitions synchronously release and clear every held
// chord; never dispatch a stale snapshot entry afterward.
if (!_heldHoldChords.Contains(chord))
continue;
var hold = FindActive(chord, ActivationType.Hold);
if (hold is not null)
Fired?.Invoke(hold.Value.Action, ActivationType.Hold);
}
@ -273,10 +342,10 @@ public sealed class InputDispatcher
if (_mouse.WantCaptureKeyboard) return;
var chord = new KeyChord(key, mods, Device: 0);
var press = _bindings.Find(chord, ActivationType.Press);
var press = FindActive(chord, ActivationType.Press);
if (press is not null) Fired?.Invoke(press.Value.Action, ActivationType.Press);
var hold = _bindings.Find(chord, ActivationType.Hold);
var hold = FindActive(chord, ActivationType.Hold);
if (hold is not null)
{
// Emit a Press transition so subscribers can latch state, then
@ -305,7 +374,7 @@ public sealed class InputDispatcher
// mid-press.
var chord = new KeyChord(key, mods, Device: 0);
var release = _bindings.Find(chord, ActivationType.Release);
var release = FindActive(chord, ActivationType.Release);
if (release is not null) Fired?.Invoke(release.Value.Action, ActivationType.Release);
// Any matching Hold binding gets a Release transition. Walk the
@ -320,7 +389,7 @@ public sealed class InputDispatcher
foreach (var held in toRemove)
{
_heldHoldChords.Remove(held);
var hold = _bindings.Find(held, ActivationType.Hold);
var hold = FindActive(held, ActivationType.Hold);
if (hold is not null) Fired?.Invoke(hold.Value.Action, ActivationType.Release);
}
}
@ -330,10 +399,10 @@ public sealed class InputDispatcher
if (_mouse.WantCaptureMouse) return;
var chord = new KeyChord(MouseButtonToKey(button), mods, Device: 1);
var press = _bindings.Find(chord, ActivationType.Press);
var press = FindActive(chord, ActivationType.Press);
if (press is not null) Fired?.Invoke(press.Value.Action, ActivationType.Press);
var hold = _bindings.Find(chord, ActivationType.Hold);
var hold = FindActive(chord, ActivationType.Hold);
if (hold is not null)
{
Fired?.Invoke(hold.Value.Action, ActivationType.Press);
@ -348,7 +417,7 @@ public sealed class InputDispatcher
if (_lastMouseDownButton == button
&& nowMs - _lastMouseDownTickMs <= DoubleClickThresholdMs)
{
var dbl = _bindings.Find(chord, ActivationType.DoubleClick);
var dbl = FindActive(chord, ActivationType.DoubleClick);
if (dbl is not null) Fired?.Invoke(dbl.Value.Action, ActivationType.DoubleClick);
_lastMouseDownButton = null; // consumed; require fresh pair for next
}
@ -363,7 +432,7 @@ public sealed class InputDispatcher
{
var chord = new KeyChord(MouseButtonToKey(button), mods, Device: 1);
var release = _bindings.Find(chord, ActivationType.Release);
var release = FindActive(chord, ActivationType.Release);
if (release is not null) Fired?.Invoke(release.Value.Action, ActivationType.Release);
var keyForLookup = MouseButtonToKey(button);
@ -376,7 +445,7 @@ public sealed class InputDispatcher
foreach (var held in toRemove)
{
_heldHoldChords.Remove(held);
var hold = _bindings.Find(held, ActivationType.Hold);
var hold = FindActive(held, ActivationType.Hold);
if (hold is not null) Fired?.Invoke(hold.Value.Action, ActivationType.Release);
}
}

View file

@ -26,7 +26,7 @@ namespace AcDream.UI.Abstractions.Input;
/// </summary>
public sealed class KeyBindings
{
private const int CurrentSchemaVersion = 3;
private const int CurrentSchemaVersion = 4;
private readonly List<Binding> _bindings = new();
@ -47,11 +47,22 @@ public sealed class KeyBindings
/// activation type matches the requested phase. Null if none.
/// </summary>
public Binding? Find(KeyChord chord, ActivationType activation)
{
for (int i = 0; i < _bindings.Count; i++)
{
Binding binding = _bindings[i];
if (binding.Chord == chord && binding.Activation == activation)
return binding;
}
return null;
}
public Binding? Find(KeyChord chord, ActivationType activation, InputScope scope)
{
for (int i = 0; i < _bindings.Count; i++)
{
var b = _bindings[i];
if (b.Chord == chord && b.Activation == activation) return b;
if (b.Chord == chord && b.Activation == activation && b.Scope == scope) return b;
}
return null;
}
@ -242,37 +253,37 @@ public sealed class KeyBindings
// ── Combat (mode-dependent — dormant in K, lights up in Phase L) ──
b.Add(new(new KeyChord(Key.GraveAccent, ModifierMask.None), InputAction.CombatToggleCombat));
// Melee mode (active when MeleeCombat scope pushed).
b.Add(new(new KeyChord(Key.Insert, ModifierMask.None), InputAction.CombatDecreaseAttackPower));
b.Add(new(new KeyChord(Key.PageUp, ModifierMask.None), InputAction.CombatIncreaseAttackPower));
b.Add(new(new KeyChord(Key.Insert, ModifierMask.None), InputAction.CombatDecreaseAttackPower, Scope: InputScope.MeleeCombat));
b.Add(new(new KeyChord(Key.PageUp, ModifierMask.None), InputAction.CombatIncreaseAttackPower, Scope: InputScope.MeleeCombat));
// Retail HandleCombatAction consumes key-down AND key-up for attack
// height actions: down starts charging, up ends the request. Hold is
// our binding representation for that transition pair.
b.Add(new(new KeyChord(Key.Delete, ModifierMask.None), InputAction.CombatLowAttack, ActivationType.Hold));
b.Add(new(new KeyChord(Key.End, ModifierMask.None), InputAction.CombatMediumAttack, ActivationType.Hold));
b.Add(new(new KeyChord(Key.PageDown, ModifierMask.None), InputAction.CombatHighAttack, ActivationType.Hold));
b.Add(new(new KeyChord(Key.Delete, ModifierMask.None), InputAction.CombatLowAttack, ActivationType.Hold, InputScope.MeleeCombat));
b.Add(new(new KeyChord(Key.End, ModifierMask.None), InputAction.CombatMediumAttack, ActivationType.Hold, InputScope.MeleeCombat));
b.Add(new(new KeyChord(Key.PageDown, ModifierMask.None), InputAction.CombatHighAttack, ActivationType.Hold, InputScope.MeleeCombat));
// Missile + Magic + Spell-tab — same chords; resolved by scope at
// runtime per InputDispatcher's stack lookup. Add the bindings;
// subscribers arrive in Phase L when CombatState.CurrentMode is
// wired.
b.Add(new(new KeyChord(Key.Insert, ModifierMask.None), InputAction.CombatDecreaseMissileAccuracy));
b.Add(new(new KeyChord(Key.PageUp, ModifierMask.None), InputAction.CombatIncreaseMissileAccuracy));
b.Add(new(new KeyChord(Key.Delete, ModifierMask.None), InputAction.CombatAimLow));
b.Add(new(new KeyChord(Key.End, ModifierMask.None), InputAction.CombatAimMedium));
b.Add(new(new KeyChord(Key.PageDown, ModifierMask.None), InputAction.CombatAimHigh));
b.Add(new(new KeyChord(Key.Insert, ModifierMask.None), InputAction.CombatPrevSpellTab));
b.Add(new(new KeyChord(Key.PageUp, ModifierMask.None), InputAction.CombatNextSpellTab));
b.Add(new(new KeyChord(Key.Delete, ModifierMask.None), InputAction.CombatPrevSpell));
b.Add(new(new KeyChord(Key.End, ModifierMask.None), InputAction.CombatCastCurrentSpell));
b.Add(new(new KeyChord(Key.PageDown, ModifierMask.None), InputAction.CombatNextSpell));
b.Add(new(new KeyChord(Key.Insert, ModifierMask.Ctrl), InputAction.CombatFirstSpellTab));
b.Add(new(new KeyChord(Key.PageUp, ModifierMask.Ctrl), InputAction.CombatLastSpellTab));
b.Add(new(new KeyChord(Key.Delete, ModifierMask.Ctrl), InputAction.CombatFirstSpell));
b.Add(new(new KeyChord(Key.PageDown, ModifierMask.Ctrl), InputAction.CombatLastSpell));
b.Add(new(new KeyChord(Key.Insert, ModifierMask.None), InputAction.CombatDecreaseMissileAccuracy, Scope: InputScope.MissileCombat));
b.Add(new(new KeyChord(Key.PageUp, ModifierMask.None), InputAction.CombatIncreaseMissileAccuracy, Scope: InputScope.MissileCombat));
b.Add(new(new KeyChord(Key.Delete, ModifierMask.None), InputAction.CombatAimLow, Scope: InputScope.MissileCombat));
b.Add(new(new KeyChord(Key.End, ModifierMask.None), InputAction.CombatAimMedium, Scope: InputScope.MissileCombat));
b.Add(new(new KeyChord(Key.PageDown, ModifierMask.None), InputAction.CombatAimHigh, Scope: InputScope.MissileCombat));
b.Add(new(new KeyChord(Key.Insert, ModifierMask.None), InputAction.CombatPrevSpellTab, Scope: InputScope.MagicCombat));
b.Add(new(new KeyChord(Key.PageUp, ModifierMask.None), InputAction.CombatNextSpellTab, Scope: InputScope.MagicCombat));
b.Add(new(new KeyChord(Key.Delete, ModifierMask.None), InputAction.CombatPrevSpell, Scope: InputScope.MagicCombat));
b.Add(new(new KeyChord(Key.End, ModifierMask.None), InputAction.CombatCastCurrentSpell, Scope: InputScope.MagicCombat));
b.Add(new(new KeyChord(Key.PageDown, ModifierMask.None), InputAction.CombatNextSpell, Scope: InputScope.MagicCombat));
b.Add(new(new KeyChord(Key.Insert, ModifierMask.Ctrl), InputAction.CombatFirstSpellTab, Scope: InputScope.MagicCombat));
b.Add(new(new KeyChord(Key.PageUp, ModifierMask.Ctrl), InputAction.CombatLastSpellTab, Scope: InputScope.MagicCombat));
b.Add(new(new KeyChord(Key.Delete, ModifierMask.Ctrl), InputAction.CombatFirstSpell, Scope: InputScope.MagicCombat));
b.Add(new(new KeyChord(Key.PageDown, ModifierMask.Ctrl), InputAction.CombatLastSpell, Scope: InputScope.MagicCombat));
for (int i = 1; i <= 9; i++)
{
var k = (Key)((int)Key.Number0 + i);
var action = (InputAction)((int)InputAction.UseSpellSlot_1 + i - 1);
b.Add(new(new KeyChord(k, ModifierMask.None), action));
b.Add(new(new KeyChord(k, ModifierMask.None), action, Scope: InputScope.MagicCombat));
}
// ── Emotes ──────────────────────────────────────────────
@ -424,7 +435,17 @@ public sealed class KeyBindings
var chord = new KeyChord(silkKey, mods, device);
action = MigrateLegacyQuickSlotIntent(version, action, chord, activation);
activation = MigrateCombatAttackActivation(version, action, activation);
loaded.Add(new(chord, action, activation));
InputScope scope = defaults.ForAction(action)
.Select(binding => binding.Scope)
.DefaultIfEmpty(InputScope.Game)
.First();
if (bindingEl.TryGetProperty("scope", out var scopeEl)
&& scopeEl.ValueKind == JsonValueKind.String
&& Enum.TryParse(scopeEl.GetString(), out InputScope parsedScope))
{
scope = parsedScope;
}
loaded.Add(new(chord, action, activation, scope));
}
}
}
@ -480,6 +501,8 @@ public sealed class KeyBindings
entry["device"] = (int)binding.Chord.Device;
if (binding.Activation != ActivationType.Press)
entry["activation"] = binding.Activation.ToString();
if (binding.Scope != InputScope.Game)
entry["scope"] = binding.Scope.ToString();
list.Add(entry);
}

View file

@ -253,7 +253,10 @@ public sealed class SettingsVM
// captured chord + same activation type, but on a DIFFERENT
// action. (Same-action bindings are fine — that's already in
// _draft for this action and gets removed when we apply.)
var existing = _draft.Find(chord, RebindOriginal!.Value.Activation);
var existing = _draft.Find(
chord,
RebindOriginal!.Value.Activation,
RebindOriginal.Value.Scope);
if (existing is not null && existing.Value.Action != RebindInProgress!.Value)
{
PendingConflict = new ConflictPrompt(
@ -293,7 +296,11 @@ public sealed class SettingsVM
private void ApplyRebind(KeyChord chord)
{
_draft.Remove(RebindOriginal!.Value);
_draft.Add(new Binding(chord, RebindInProgress!.Value, RebindOriginal.Value.Activation));
_draft.Add(new Binding(
chord,
RebindInProgress!.Value,
RebindOriginal.Value.Activation,
RebindOriginal.Value.Scope));
RebindInProgress = null;
RebindOriginal = null;
}