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();
}