fix(client): restore retail interaction parity
Harden keyboard and camera routing, inventory and vendor interactions, chat/emotes, relog portal flow, and paperdoll rendering. Add retail research, connected gate coverage, and release-gate validation.
This commit is contained in:
parent
0c699240e0
commit
f6fe0f2a4f
151 changed files with 10162 additions and 1211 deletions
|
|
@ -96,7 +96,8 @@ internal sealed record InteractionRetainedUiDependencies(
|
|||
Func<AcDream.Core.World.DerethDateTime.Calendar> CurrentCalendar,
|
||||
AcDream.App.Rendering.Packs.RenderPackCatalogSource? RenderPackCatalog = null,
|
||||
Func<AcDream.App.Rendering.Packs.RenderPackDiagnosticsSnapshot>?
|
||||
RenderPackDiagnostics = null)
|
||||
RenderPackDiagnostics = null,
|
||||
string? ScreenshotsDirectory = null)
|
||||
{
|
||||
public RuntimeActionState Actions => Runtime.ActionOwner;
|
||||
|
||||
|
|
@ -388,9 +389,15 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
|
|||
container,
|
||||
placement,
|
||||
amount),
|
||||
sendStackableMerge: (source, target, amount) =>
|
||||
session.CurrentSession?.SendStackableMerge(source, target, amount),
|
||||
requestExternalContainer: guid =>
|
||||
{
|
||||
d.Inventory.ExternalContainers.RequestOpen(guid);
|
||||
ClientObject? container = d.Inventory.Objects.Get(guid);
|
||||
bool isCorpse = container is not null
|
||||
&& ((PublicWeenieFlags)(container.PublicWeenieBitfield ?? 0u)
|
||||
& PublicWeenieFlags.Corpse) != 0;
|
||||
d.Inventory.ExternalContainers.RequestOpen(guid, isCorpse);
|
||||
},
|
||||
requestUse: selection.RequestUse,
|
||||
// Slice 6.3: ItemInteractionController.TryBuy owns the
|
||||
|
|
@ -663,16 +670,20 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
|
|||
CharacterKey: () => d.Settings.ActiveToonKey,
|
||||
ScreenSize: () => (d.Window.Size.X, d.Window.Size.Y));
|
||||
void ProbeLog(string message) => d.Log("[UI-PROBE] " + message);
|
||||
FrameScreenshotController? screenshots = null;
|
||||
if (d.Options.UiProbeEnabled
|
||||
&& d.Options.AutomationArtifactDirectory is { } artifactDirectory)
|
||||
{
|
||||
screenshots = new FrameScreenshotController(
|
||||
d.BackbufferReader,
|
||||
Path.Combine(artifactDirectory, "screenshots"),
|
||||
ProbeLog,
|
||||
d.RenderPackDiagnostics);
|
||||
}
|
||||
string screenshotDirectory =
|
||||
d.Options.UiProbeEnabled
|
||||
&& d.Options.AutomationArtifactDirectory is { } artifactDirectory
|
||||
? Path.Combine(artifactDirectory, "screenshots")
|
||||
: !string.IsNullOrWhiteSpace(d.ScreenshotsDirectory)
|
||||
? d.ScreenshotsDirectory
|
||||
: Path.Combine(
|
||||
Path.GetDirectoryName(d.KeyBindingsFilePath)!,
|
||||
"screenshots");
|
||||
var screenshots = new FrameScreenshotController(
|
||||
d.BackbufferReader,
|
||||
screenshotDirectory,
|
||||
ProbeLog,
|
||||
d.RenderPackDiagnostics);
|
||||
checkpoint(InteractionRetainedUiCompositionPoint.UiProbeCreated);
|
||||
|
||||
var assets = new RetailUiAssets(
|
||||
|
|
@ -1157,11 +1168,10 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
|
|||
late.GameRuntime.CharacterSelectionConfirmDelete,
|
||||
late.GameRuntime.CharacterSelectionRestore,
|
||||
late.GameRuntime.CharacterSelectionCancel,
|
||||
// Campaign LA gate round 2 finding 1: the SAME
|
||||
// window-close path GameplayInputCommandController's
|
||||
// Escape fallback uses (IGameplayWindowCommands.Close
|
||||
// /GameplayWindowCommands wrap this same d.Window.Close
|
||||
// delegate) — no separate exit path.
|
||||
// Campaign LA gate round 2 finding 1: the character
|
||||
// selection screen's Exit button uses the ordinary host
|
||||
// close path. Gameplay Escape is independent: retail
|
||||
// clears selection or toggles the Gameplay Options page.
|
||||
d.Window.Close),
|
||||
// Campaign CC slice CC4: same late-bound generation-capturing
|
||||
// seam as CharacterSelection above. RequestExit here is a
|
||||
|
|
@ -1203,7 +1213,24 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
|
|||
RandomizeAppearance: late.GameRuntime.CharacterCreationRandomizeAppearance,
|
||||
RandomizeClothing: late.GameRuntime.CharacterCreationRandomizeClothing,
|
||||
GetSkillScore: chargenSkillScoreResolver.Resolve,
|
||||
OpenOnStart: d.Options.OpenCharacterCreationOnStart));
|
||||
OpenOnStart: d.Options.OpenCharacterCreationOnStart),
|
||||
CaptureScreenshot: () =>
|
||||
{
|
||||
if (screenshots.TryRequestRetailScreenshot(
|
||||
out string path,
|
||||
out string error))
|
||||
{
|
||||
d.Communication.AddText(
|
||||
$"Screenshot saved to {path}",
|
||||
RetailLogTextType.ClientLocal);
|
||||
}
|
||||
else
|
||||
{
|
||||
d.Communication.AddText(
|
||||
$"Screenshot failed: {error}",
|
||||
RetailLogTextType.ClientLocal);
|
||||
}
|
||||
});
|
||||
RetailUiRuntime runtime = lease.Mount(
|
||||
() => RetailUiRuntime.CreateUninitialized(bindings));
|
||||
checkpoint(InteractionRetainedUiCompositionPoint.UiRuntimeMounted);
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ internal sealed record LivePresentationDependencies(
|
|||
CellVisibility CellVisibility,
|
||||
LiveWorldOriginState WorldOrigin,
|
||||
LocalPlayerIdentityState PlayerIdentity,
|
||||
ChaseCameraInputState ChaseCameraInput,
|
||||
PointerPositionState PointerPosition,
|
||||
PlayerApproachCompletionState PlayerApproachCompletions,
|
||||
GameRenderResourceLifetime RenderResourceLifetime,
|
||||
|
|
@ -808,7 +809,12 @@ internal sealed class LivePresentationCompositionPhase
|
|||
d.RetailAlphaQueue,
|
||||
alphaScratchBudgets.DispatcherBytes,
|
||||
foundation.TerrainAtlas?.BuildingDetailTexture ?? default,
|
||||
() => d.Settings.DisplayPreview.BuildingDetailTextures),
|
||||
() => d.Settings.DisplayPreview.BuildingDetailTextures,
|
||||
serverGuid => serverGuid != 0u
|
||||
&& serverGuid == d.PlayerIdentity.ServerGuid
|
||||
? d.ChaseCameraInput.Retail?.PlayerTranslucency
|
||||
?? (d.ChaseCameraInput.Legacy?.IsInHead == true ? 1f : 0f)
|
||||
: 0f),
|
||||
static value => value.Dispose());
|
||||
var selectionQuery = new WorldSelectionQuery(
|
||||
liveEntities,
|
||||
|
|
@ -845,7 +851,11 @@ internal sealed class LivePresentationCompositionPhase
|
|||
localEntityId =>
|
||||
d.EffectPoses.TryGetRootPose(localEntityId, out Matrix4x4 childRoot)
|
||||
? childRoot
|
||||
: null);
|
||||
: null,
|
||||
hasOpenedCorpse:
|
||||
d.Runtime.InventoryOwner.ExternalContainers.HasCorpseBeenOpened,
|
||||
combatMode: () => d.Runtime.ActionOwner.Combat.CurrentMode,
|
||||
isFellow: guid => d.Runtime.Fellowship.TryGetMember(guid, out _));
|
||||
var radarSnapshotProvider = new RadarSnapshotProvider(
|
||||
d.EntityObjects.Objects,
|
||||
liveEntities,
|
||||
|
|
@ -876,7 +886,12 @@ internal sealed class LivePresentationCompositionPhase
|
|||
() => d.PlayerController.Controller,
|
||||
d.PlayerApproachCompletions),
|
||||
d.Toast,
|
||||
d.PlayerApproachCompletions);
|
||||
d.PlayerApproachCompletions,
|
||||
splitStack: guid =>
|
||||
interaction.RetainedUi?.Runtime.SelectedObjectController?
|
||||
.FocusSplitStackEntry(guid) ?? false,
|
||||
fellowshipMembers: () =>
|
||||
d.Runtime.Fellowship.GetMembers().Select(static member => member.Guid));
|
||||
selectionInteractionSource.Bind(selectionInteractions);
|
||||
bindings.Adopt(
|
||||
"world selection",
|
||||
|
|
|
|||
|
|
@ -1169,6 +1169,7 @@ internal sealed class SessionPlayerCompositionPhase
|
|||
live.SelectionInteractions),
|
||||
new LiveSessionWorldRuntime(
|
||||
content.Dats,
|
||||
d.DatLock,
|
||||
content.Audio?.Engine is { } sessionAudioEngine
|
||||
? new AcDream.App.Audio.WorldAudioSessionGate(
|
||||
sessionAudioEngine,
|
||||
|
|
@ -1279,14 +1280,10 @@ internal sealed class SessionPlayerCompositionPhase
|
|||
new RetainedGameplayWindowCommands(
|
||||
interaction.RetainedUi?.Runtime),
|
||||
runtimeDiagnostics,
|
||||
new PlayerModeGameplayCommands(
|
||||
d.PlayerMode,
|
||||
playerMode),
|
||||
new PlayerModeGameplayCommands(playerMode),
|
||||
new ItemTargetModeCommands(interaction.ItemInteraction),
|
||||
new GameplayCameraModeCommands(host.CameraController),
|
||||
gameRuntime,
|
||||
gameRuntime.Combat,
|
||||
new GameplayWindowCommands(d.Window.Close),
|
||||
toggleAudioMute: content.Audio?.Engine is { } audioEngine
|
||||
? () =>
|
||||
{
|
||||
|
|
@ -1305,6 +1302,7 @@ internal sealed class SessionPlayerCompositionPhase
|
|||
gameRuntime,
|
||||
gameRuntime.Selection,
|
||||
gameRuntime.MovementCommands,
|
||||
gameRuntime.CharacterCommands,
|
||||
commands);
|
||||
GameplayInputActionRouter gameplayActions =
|
||||
GameplayInputActionRouter.Create(
|
||||
|
|
|
|||
|
|
@ -18,8 +18,8 @@ namespace AcDream.App.Composition;
|
|||
/// <c>UiHost</c>/<c>UiRoot</c> tree — D1) instead of a new
|
||||
/// <c>IPanelRenderer</c> implementation, and its OP9 closeout retired the
|
||||
/// unrendered ImGui-era SettingsPanel/SettingsVM outright. Keybind remapping
|
||||
/// is Campaign OP slice OP8's Configure Keyboard screen, persisting to
|
||||
/// keybinds.json (not retail's <c>.keymap</c> format — register row AP-202).
|
||||
/// is Campaign OP slice OP8's Configure Keyboard screen, persisting retail
|
||||
/// <c>*.keymap</c> profiles with <c>keybinds.json</c> as the host-command mirror.
|
||||
/// </summary>
|
||||
internal sealed record SettingsDevToolsResult(
|
||||
AcDream.UI.Abstractions.Settings.QualitySettings ResolvedQuality)
|
||||
|
|
|
|||
|
|
@ -63,6 +63,36 @@ internal sealed class FrameScreenshotController
|
|||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Queues the first free retail-style screenshot name. Retail scans
|
||||
/// <c>ScreenShot00000.jpg</c> through <c>ScreenShot99999.jpg</c> beside
|
||||
/// its preferences file; acdream keeps the exact stem/numbering while
|
||||
/// writing lossless PNGs in the portable screenshots directory.
|
||||
/// </summary>
|
||||
public bool TryRequestRetailScreenshot(out string path, out string error)
|
||||
{
|
||||
for (int index = 0; index < 100_000; index++)
|
||||
{
|
||||
string name = $"ScreenShot{index:D5}";
|
||||
string candidate = Path.Combine(_directory, name + ".png");
|
||||
if (File.Exists(candidate) || _status.ContainsKey(name))
|
||||
continue;
|
||||
|
||||
if (TryRequest(name, out error))
|
||||
{
|
||||
path = candidate;
|
||||
return true;
|
||||
}
|
||||
|
||||
path = string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
path = string.Empty;
|
||||
error = "all retail screenshot names ScreenShot00000 through ScreenShot99999 are in use";
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool IsComplete(string name) =>
|
||||
_status.TryGetValue(name, out CaptureStatus? status)
|
||||
&& status.State == CaptureState.Complete;
|
||||
|
|
|
|||
|
|
@ -316,6 +316,88 @@ internal sealed class CameraPointerInputController
|
|||
}
|
||||
}
|
||||
|
||||
public bool HandleCameraAction(
|
||||
InputAction action,
|
||||
ActivationType activation)
|
||||
{
|
||||
if (activation != ActivationType.Press
|
||||
|| !_playerMode.IsPlayerMode
|
||||
|| !_camera.IsChaseMode)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
bool handled = action is
|
||||
InputAction.CameraViewDefault
|
||||
or InputAction.CameraAlternateViewDefault
|
||||
or InputAction.CameraViewFirstPerson
|
||||
or InputAction.CameraAlternateViewFirstPerson
|
||||
or InputAction.CameraViewLookDown
|
||||
or InputAction.CameraAlternateViewLookDown
|
||||
or InputAction.CameraViewMapMode
|
||||
or InputAction.CameraAlternateViewMapMode;
|
||||
if (!handled)
|
||||
return false;
|
||||
|
||||
ApplyCameraPreset(_chase.Retail, action);
|
||||
ApplyCameraPreset(_chase.Legacy, action);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void ApplyCameraPreset(
|
||||
RetailChaseCamera? camera,
|
||||
InputAction action)
|
||||
{
|
||||
if (camera is null)
|
||||
return;
|
||||
switch (action)
|
||||
{
|
||||
case InputAction.CameraViewDefault:
|
||||
case InputAction.CameraAlternateViewDefault:
|
||||
camera.SetRetailDefaultView();
|
||||
break;
|
||||
case InputAction.CameraViewFirstPerson:
|
||||
case InputAction.CameraAlternateViewFirstPerson:
|
||||
camera.SetRetailFirstPersonView();
|
||||
break;
|
||||
case InputAction.CameraViewLookDown:
|
||||
case InputAction.CameraAlternateViewLookDown:
|
||||
camera.ToggleRetailLookDownView();
|
||||
break;
|
||||
case InputAction.CameraViewMapMode:
|
||||
case InputAction.CameraAlternateViewMapMode:
|
||||
camera.ToggleRetailMapModeView();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static void ApplyCameraPreset(
|
||||
ChaseCamera? camera,
|
||||
InputAction action)
|
||||
{
|
||||
if (camera is null)
|
||||
return;
|
||||
switch (action)
|
||||
{
|
||||
case InputAction.CameraViewDefault:
|
||||
case InputAction.CameraAlternateViewDefault:
|
||||
camera.SetRetailDefaultView();
|
||||
break;
|
||||
case InputAction.CameraViewFirstPerson:
|
||||
case InputAction.CameraAlternateViewFirstPerson:
|
||||
camera.SetRetailFirstPersonView();
|
||||
break;
|
||||
case InputAction.CameraViewLookDown:
|
||||
case InputAction.CameraAlternateViewLookDown:
|
||||
camera.ToggleRetailLookDownView();
|
||||
break;
|
||||
case InputAction.CameraViewMapMode:
|
||||
case InputAction.CameraAlternateViewMapMode:
|
||||
camera.ToggleRetailMapModeView();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
public string AdjustSensitivity(float factor)
|
||||
{
|
||||
string mode;
|
||||
|
|
|
|||
|
|
@ -15,7 +15,9 @@ internal readonly record struct ChaseCameraAdjustmentInput(
|
|||
bool ZoomIn,
|
||||
bool ZoomOut,
|
||||
bool Raise,
|
||||
bool Lower);
|
||||
bool Lower,
|
||||
bool RotateLeft,
|
||||
bool RotateRight);
|
||||
|
||||
internal interface ICameraFrameInputSource
|
||||
{
|
||||
|
|
@ -71,9 +73,21 @@ internal sealed class DispatcherCameraInputSource : ICameraFrameInputSource
|
|||
return default;
|
||||
|
||||
return new ChaseCameraAdjustmentInput(
|
||||
dispatcher.IsActionHeld(InputAction.CameraZoomIn),
|
||||
dispatcher.IsActionHeld(InputAction.CameraZoomOut),
|
||||
dispatcher.IsActionHeld(InputAction.CameraRaise),
|
||||
dispatcher.IsActionHeld(InputAction.CameraLower));
|
||||
dispatcher.IsActionHeld(InputAction.CameraZoomIn)
|
||||
|| dispatcher.IsActionHeld(InputAction.CameraMoveToward)
|
||||
|| dispatcher.IsActionHeld(InputAction.CameraAlternateMoveToward),
|
||||
dispatcher.IsActionHeld(InputAction.CameraZoomOut)
|
||||
|| dispatcher.IsActionHeld(InputAction.CameraMoveAway)
|
||||
|| dispatcher.IsActionHeld(InputAction.CameraAlternateMoveAway),
|
||||
dispatcher.IsActionHeld(InputAction.CameraRaise)
|
||||
|| dispatcher.IsActionHeld(InputAction.CameraRotateUp)
|
||||
|| dispatcher.IsActionHeld(InputAction.CameraAlternateRotateUp),
|
||||
dispatcher.IsActionHeld(InputAction.CameraLower)
|
||||
|| dispatcher.IsActionHeld(InputAction.CameraRotateDown)
|
||||
|| dispatcher.IsActionHeld(InputAction.CameraAlternateRotateDown),
|
||||
dispatcher.IsActionHeld(InputAction.CameraRotateLeft)
|
||||
|| dispatcher.IsActionHeld(InputAction.CameraAlternateRotateLeft),
|
||||
dispatcher.IsActionHeld(InputAction.CameraRotateRight)
|
||||
|| dispatcher.IsActionHeld(InputAction.CameraAlternateRotateRight));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ using AcDream.App.Rendering;
|
|||
using AcDream.App.UI;
|
||||
using AcDream.Core.Combat;
|
||||
using AcDream.Runtime;
|
||||
using AcDream.Runtime.Gameplay;
|
||||
using AcDream.UI.Abstractions.Input;
|
||||
|
||||
namespace AcDream.App.Input;
|
||||
|
|
@ -14,6 +15,8 @@ internal interface IGameplayInputActionSurface
|
|||
void RemoveFired(Action<InputAction, ActivationType> callback);
|
||||
|
||||
void SetCombatScope(InputScope? scope);
|
||||
|
||||
void SetCameraAlternateScope(bool active);
|
||||
}
|
||||
|
||||
internal sealed class DispatcherGameplayInputActionSurface(InputDispatcher dispatcher)
|
||||
|
|
@ -30,6 +33,9 @@ internal sealed class DispatcherGameplayInputActionSurface(InputDispatcher dispa
|
|||
|
||||
public void SetCombatScope(InputScope? scope) =>
|
||||
_dispatcher.SetCombatScope(scope);
|
||||
|
||||
public void SetCameraAlternateScope(bool active) =>
|
||||
_dispatcher.SetCameraAlternateScope(active);
|
||||
}
|
||||
|
||||
internal interface ICombatModeEventSurface
|
||||
|
|
@ -66,6 +72,8 @@ internal interface IGameplayInputPriorityTargets
|
|||
|
||||
bool HandleRetainedUiAction(InputAction action);
|
||||
|
||||
bool HandleCharacterOptionAction(InputAction action);
|
||||
|
||||
bool HandleSelectionAction(InputAction action);
|
||||
|
||||
bool HandlePressedMovementAction(InputAction action);
|
||||
|
|
@ -87,6 +95,7 @@ internal sealed class RuntimeGameplayInputPriorityTargets
|
|||
private readonly IGameRuntimeView _runtimeView;
|
||||
private readonly IRuntimeSelectionCommands _runtimeSelection;
|
||||
private readonly IRuntimeMovementCommands _runtimeMovement;
|
||||
private readonly IRuntimeCharacterCommands _runtimeCharacter;
|
||||
private readonly IGameplayInputCommandTarget _commands;
|
||||
|
||||
public RuntimeGameplayInputPriorityTargets(
|
||||
|
|
@ -97,6 +106,7 @@ internal sealed class RuntimeGameplayInputPriorityTargets
|
|||
IGameRuntimeView runtimeView,
|
||||
IRuntimeSelectionCommands runtimeSelection,
|
||||
IRuntimeMovementCommands runtimeMovement,
|
||||
IRuntimeCharacterCommands runtimeCharacter,
|
||||
IGameplayInputCommandTarget commands)
|
||||
{
|
||||
_frame = frame ?? throw new ArgumentNullException(nameof(frame));
|
||||
|
|
@ -109,11 +119,14 @@ internal sealed class RuntimeGameplayInputPriorityTargets
|
|||
?? throw new ArgumentNullException(nameof(runtimeSelection));
|
||||
_runtimeMovement = runtimeMovement
|
||||
?? throw new ArgumentNullException(nameof(runtimeMovement));
|
||||
_runtimeCharacter = runtimeCharacter
|
||||
?? throw new ArgumentNullException(nameof(runtimeCharacter));
|
||||
_commands = commands ?? throw new ArgumentNullException(nameof(commands));
|
||||
}
|
||||
|
||||
public bool HandlePointerAction(InputAction action, ActivationType activation) =>
|
||||
_frame.HandlePointerAction(action, activation);
|
||||
_frame.HandlePointerAction(action, activation)
|
||||
|| _pointer.HandleCameraAction(action, activation);
|
||||
|
||||
public void HandleScroll(InputAction action) =>
|
||||
_pointer.HandleScroll(action);
|
||||
|
|
@ -122,10 +135,74 @@ internal sealed class RuntimeGameplayInputPriorityTargets
|
|||
_frame.HandleCombatAction(action, activation);
|
||||
|
||||
public bool HandleRetainedUiAction(InputAction action) =>
|
||||
_retainedUi?.HandleInputAction(action) == true;
|
||||
FinishJumpBeforeUi(action)
|
||||
|| _retainedUi?.HandleInputAction(action) == true;
|
||||
|
||||
public bool HandleCharacterOptionAction(InputAction action)
|
||||
{
|
||||
if (!RetailActionIdentityTable.TryGetCharacterOptionId(
|
||||
action,
|
||||
out uint optionId)
|
||||
|| !CharacterOptionTable.TryGet(
|
||||
optionId,
|
||||
out CharacterOptionTableEntry entry))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
RuntimeCharacterOptionsSnapshot options =
|
||||
_runtimeView.Character.Snapshot.Options;
|
||||
uint word = entry.IsOptions1 ? options.Options1 : options.Options2;
|
||||
bool current = (word & entry.Mask) != 0u;
|
||||
_runtimeCharacter.SetSingleOption(
|
||||
_runtimeView.Generation,
|
||||
optionId,
|
||||
!current);
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool FinishJumpBeforeUi(InputAction action)
|
||||
{
|
||||
RuntimeMovementCommand? command = ResolveEscapeMovementCommand(
|
||||
action,
|
||||
_runtimeView.Movement.IsStandingStill,
|
||||
_runtimeView.Movement.JumpCharge,
|
||||
_runtimeView.Actions.Snapshot.CombatAttack);
|
||||
if (command != RuntimeMovementCommand.FinishJump)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_runtimeMovement.Execute(
|
||||
_runtimeView.Generation,
|
||||
command.Value);
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool HandleSelectionAction(InputAction action)
|
||||
{
|
||||
if (action == InputAction.EscapeKey)
|
||||
{
|
||||
IRuntimeMovementView movement = _runtimeView.Movement;
|
||||
RuntimeCombatAttackSnapshot attack = _runtimeView.Actions.Snapshot
|
||||
.CombatAttack;
|
||||
RuntimeMovementCommand? escapeCommand =
|
||||
ResolveEscapeMovementCommand(
|
||||
action,
|
||||
movement.IsStandingStill,
|
||||
movement.JumpCharge,
|
||||
attack);
|
||||
if (escapeCommand == RuntimeMovementCommand.StopCompletely)
|
||||
{
|
||||
_runtimeMovement.Execute(
|
||||
_runtimeView.Generation,
|
||||
escapeCommand.Value);
|
||||
if (attack.RepeatAttackInProgress)
|
||||
_frame.AbortAutomaticAttack();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
RuntimeSelectionCommand? command = action switch
|
||||
{
|
||||
InputAction.SelectionClosestMonster =>
|
||||
|
|
@ -149,15 +226,32 @@ internal sealed class RuntimeGameplayInputPriorityTargets
|
|||
return _selection?.HandleInputAction(action) == true;
|
||||
}
|
||||
|
||||
internal static RuntimeMovementCommand? ResolveEscapeMovementCommand(
|
||||
InputAction action,
|
||||
bool isStandingStill,
|
||||
in AcDream.Runtime.Gameplay.JumpChargeSnapshot jumpCharge,
|
||||
in RuntimeCombatAttackSnapshot attack)
|
||||
{
|
||||
if (action != InputAction.EscapeKey)
|
||||
return null;
|
||||
if (jumpCharge.IsCharging)
|
||||
return RuntimeMovementCommand.FinishJump;
|
||||
if (!isStandingStill || attack.RepeatAttackInProgress)
|
||||
return RuntimeMovementCommand.StopCompletely;
|
||||
return null;
|
||||
}
|
||||
|
||||
public bool HandlePressedMovementAction(InputAction action)
|
||||
{
|
||||
RuntimeMovementCommand? command = action switch
|
||||
if (RetailEmoteMotionTable.TryGetMotion(action, out uint motion))
|
||||
{
|
||||
InputAction.MovementRunLock =>
|
||||
RuntimeMovementCommand.ToggleRunLock,
|
||||
InputAction.MovementStop => RuntimeMovementCommand.Stop,
|
||||
_ => null,
|
||||
};
|
||||
_runtimeMovement.ExecuteMotion(
|
||||
_runtimeView.Generation,
|
||||
motion);
|
||||
return true;
|
||||
}
|
||||
|
||||
RuntimeMovementCommand? command = ResolvePressedMovementCommand(action);
|
||||
if (command is { } typed)
|
||||
{
|
||||
_runtimeMovement.Execute(_runtimeView.Generation, typed);
|
||||
|
|
@ -167,6 +261,18 @@ internal sealed class RuntimeGameplayInputPriorityTargets
|
|||
return _frame.HandlePressedMovementAction(action);
|
||||
}
|
||||
|
||||
internal static RuntimeMovementCommand? ResolvePressedMovementCommand(
|
||||
InputAction action) => action switch
|
||||
{
|
||||
InputAction.MovementRunLock => RuntimeMovementCommand.ToggleRunLock,
|
||||
InputAction.MovementStop => RuntimeMovementCommand.Stop,
|
||||
InputAction.Ready => RuntimeMovementCommand.Ready,
|
||||
InputAction.Sitting => RuntimeMovementCommand.Sit,
|
||||
InputAction.Crouch => RuntimeMovementCommand.Crouch,
|
||||
InputAction.Sleeping => RuntimeMovementCommand.Sleep,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
public void HandleCommand(InputAction action) =>
|
||||
_commands.Handle(action);
|
||||
}
|
||||
|
|
@ -298,6 +404,14 @@ internal sealed class GameplayInputActionRouter : IDisposable
|
|||
{
|
||||
_log($"[input] {action} {activation}");
|
||||
|
||||
if (action == InputAction.CameraActivateAlternateMode)
|
||||
{
|
||||
if (activation == ActivationType.Press)
|
||||
_actions.SetCameraAlternateScope(true);
|
||||
else if (activation == ActivationType.Release)
|
||||
_actions.SetCameraAlternateScope(false);
|
||||
}
|
||||
|
||||
if (_targets.HandlePointerAction(action, activation))
|
||||
return;
|
||||
|
||||
|
|
@ -320,6 +434,8 @@ internal sealed class GameplayInputActionRouter : IDisposable
|
|||
|
||||
if (_targets.HandleRetainedUiAction(action))
|
||||
return;
|
||||
if (_targets.HandleCharacterOptionAction(action))
|
||||
return;
|
||||
if (_targets.HandleSelectionAction(action))
|
||||
return;
|
||||
if (_targets.HandlePressedMovementAction(action))
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
using AcDream.App.Combat;
|
||||
using AcDream.App.Diagnostics;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.UI;
|
||||
using AcDream.Runtime;
|
||||
using AcDream.UI.Abstractions.Input;
|
||||
|
|
@ -24,6 +23,12 @@ internal interface IRetainedGameplayWindowCommands
|
|||
/// <c>RetailUiRuntime.BindToolbarPanelButtons</c>.
|
||||
/// </summary>
|
||||
void ToggleOptionsPanel();
|
||||
|
||||
void ToggleGameplayOptionsPage();
|
||||
|
||||
void FocusChatEntry();
|
||||
|
||||
void LogOutCharacter();
|
||||
}
|
||||
|
||||
internal sealed class RetainedGameplayWindowCommands(RetailUiRuntime? runtime)
|
||||
|
|
@ -39,35 +44,31 @@ internal sealed class RetainedGameplayWindowCommands(RetailUiRuntime? runtime)
|
|||
|
||||
public void ToggleOptionsPanel() =>
|
||||
_runtime?.ToggleWindow(WindowNames.Options);
|
||||
|
||||
public void ToggleGameplayOptionsPage() =>
|
||||
_runtime?.ToggleGameplayOptionsPage();
|
||||
|
||||
public void FocusChatEntry() => _runtime?.FocusChatEntry();
|
||||
|
||||
public void LogOutCharacter() => _runtime?.LogOutCharacter();
|
||||
}
|
||||
|
||||
internal interface IPlayerModeGameplayCommands
|
||||
{
|
||||
bool IsPlayerMode { get; }
|
||||
|
||||
void ToggleFlyOrChase();
|
||||
|
||||
void TogglePlayerMode();
|
||||
|
||||
void ExitPlayerMode();
|
||||
}
|
||||
|
||||
internal sealed class PlayerModeGameplayCommands(
|
||||
ILocalPlayerModeSource mode,
|
||||
PlayerModeController controller) : IPlayerModeGameplayCommands
|
||||
internal sealed class PlayerModeGameplayCommands(PlayerModeController controller)
|
||||
: IPlayerModeGameplayCommands
|
||||
{
|
||||
private readonly ILocalPlayerModeSource _mode = mode
|
||||
?? throw new ArgumentNullException(nameof(mode));
|
||||
private readonly PlayerModeController _controller = controller
|
||||
?? throw new ArgumentNullException(nameof(controller));
|
||||
|
||||
public bool IsPlayerMode => _mode.IsPlayerMode;
|
||||
|
||||
public void ToggleFlyOrChase() => _controller.ToggleFlyOrChase();
|
||||
|
||||
public void TogglePlayerMode() => _controller.Toggle();
|
||||
|
||||
public void ExitPlayerMode() => _controller.Exit();
|
||||
}
|
||||
|
||||
internal interface IItemTargetModeCommands
|
||||
|
|
@ -88,37 +89,6 @@ internal sealed class ItemTargetModeCommands(ItemInteractionController items)
|
|||
public void CancelTargetMode() => _items.CancelTargetMode();
|
||||
}
|
||||
|
||||
internal interface IGameplayCameraModeCommands
|
||||
{
|
||||
bool IsFlyMode { get; }
|
||||
|
||||
void ExitFlyMode();
|
||||
}
|
||||
|
||||
internal sealed class GameplayCameraModeCommands(CameraController camera)
|
||||
: IGameplayCameraModeCommands
|
||||
{
|
||||
private readonly CameraController _camera = camera
|
||||
?? throw new ArgumentNullException(nameof(camera));
|
||||
|
||||
public bool IsFlyMode => _camera.IsFlyMode;
|
||||
|
||||
public void ExitFlyMode() => _camera.ToggleFly();
|
||||
}
|
||||
|
||||
internal interface IGameplayWindowCommands
|
||||
{
|
||||
void Close();
|
||||
}
|
||||
|
||||
internal sealed class GameplayWindowCommands(Action close) : IGameplayWindowCommands
|
||||
{
|
||||
private readonly Action _close = close
|
||||
?? throw new ArgumentNullException(nameof(close));
|
||||
|
||||
public void Close() => _close();
|
||||
}
|
||||
|
||||
internal interface IGameplayInputCommandTarget
|
||||
{
|
||||
bool Handle(InputAction action);
|
||||
|
|
@ -135,10 +105,8 @@ internal sealed class GameplayInputCommandController : IGameplayInputCommandTarg
|
|||
private readonly IRuntimeDiagnosticCommands _diagnostics;
|
||||
private readonly IPlayerModeGameplayCommands _playerMode;
|
||||
private readonly IItemTargetModeCommands _targetMode;
|
||||
private readonly IGameplayCameraModeCommands _camera;
|
||||
private readonly IGameRuntimeView _runtimeView;
|
||||
private readonly IRuntimeCombatCommands _combat;
|
||||
private readonly IGameplayWindowCommands _window;
|
||||
private readonly Action? _toggleAudioMute;
|
||||
|
||||
public GameplayInputCommandController(
|
||||
|
|
@ -146,21 +114,17 @@ internal sealed class GameplayInputCommandController : IGameplayInputCommandTarg
|
|||
IRuntimeDiagnosticCommands diagnostics,
|
||||
IPlayerModeGameplayCommands playerMode,
|
||||
IItemTargetModeCommands targetMode,
|
||||
IGameplayCameraModeCommands camera,
|
||||
IGameRuntimeView runtimeView,
|
||||
IRuntimeCombatCommands combat,
|
||||
IGameplayWindowCommands window,
|
||||
Action? toggleAudioMute = null)
|
||||
{
|
||||
_retained = retained ?? throw new ArgumentNullException(nameof(retained));
|
||||
_diagnostics = diagnostics ?? throw new ArgumentNullException(nameof(diagnostics));
|
||||
_playerMode = playerMode ?? throw new ArgumentNullException(nameof(playerMode));
|
||||
_targetMode = targetMode ?? throw new ArgumentNullException(nameof(targetMode));
|
||||
_camera = camera ?? throw new ArgumentNullException(nameof(camera));
|
||||
_runtimeView = runtimeView
|
||||
?? throw new ArgumentNullException(nameof(runtimeView));
|
||||
_combat = combat ?? throw new ArgumentNullException(nameof(combat));
|
||||
_window = window ?? throw new ArgumentNullException(nameof(window));
|
||||
_toggleAudioMute = toggleAudioMute;
|
||||
}
|
||||
|
||||
|
|
@ -206,10 +170,12 @@ internal sealed class GameplayInputCommandController : IGameplayInputCommandTarg
|
|||
_playerMode.TogglePlayerMode();
|
||||
return true;
|
||||
case InputAction.ToggleChatEntry:
|
||||
// OP9: IDevToolsGameplayCommands.FocusChatInput() retired —
|
||||
// same shape as AcdreamToggleDebugPanel above (its ImGui
|
||||
// ChatPanel target was already gone). Tab is still consumed
|
||||
// here, matching the prior no-op's "handled" contract.
|
||||
case InputAction.EnterChatMode:
|
||||
// Physical Tab/Enter are normally consumed by UiRoot before
|
||||
// the dispatcher. This semantic route is what makes a rebound
|
||||
// key and headless/UI automation reach that same retained
|
||||
// chat field.
|
||||
_retained.FocusChatEntry();
|
||||
return true;
|
||||
case InputAction.ToggleOptionsPanel:
|
||||
// Campaign OP slice OP3 (D1): F11 opens the RETAIL Options
|
||||
|
|
@ -227,6 +193,9 @@ internal sealed class GameplayInputCommandController : IGameplayInputCommandTarg
|
|||
_runtimeView.Generation,
|
||||
RuntimeCombatCommand.ToggleMode);
|
||||
return true;
|
||||
case InputAction.LOGOUT:
|
||||
_retained.LogOutCharacter();
|
||||
return true;
|
||||
case InputAction.EscapeKey:
|
||||
HandleEscape();
|
||||
return true;
|
||||
|
|
@ -239,9 +208,7 @@ internal sealed class GameplayInputCommandController : IGameplayInputCommandTarg
|
|||
{
|
||||
if (_targetMode.IsAnyTargetModeActive)
|
||||
_targetMode.CancelTargetMode();
|
||||
else if (_playerMode.IsPlayerMode)
|
||||
_playerMode.ExitPlayerMode();
|
||||
else
|
||||
_window.Close();
|
||||
_retained.ToggleGameplayOptionsPage();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ internal interface ICombatInputFrameController
|
|||
{
|
||||
void Tick();
|
||||
void HandleMovementInput(InputAction action, ActivationType activation);
|
||||
void AbortAutomaticAttack();
|
||||
bool HandleInputAction(InputAction action, ActivationType activation);
|
||||
}
|
||||
|
||||
|
|
@ -38,6 +39,11 @@ internal sealed class CombatAttackInputFrameAdapter : ICombatInputFrameControlle
|
|||
RuntimeInputActivation.Press));
|
||||
}
|
||||
|
||||
public void AbortAutomaticAttack() =>
|
||||
_owner.HandleCommand(new RuntimeCombatAttackInput(
|
||||
RuntimeCombatAttackCommand.AbortForMovement,
|
||||
RuntimeInputActivation.Press));
|
||||
|
||||
public bool HandleInputAction(InputAction action, ActivationType activation)
|
||||
{
|
||||
RuntimeCombatAttackCommand? command = action switch
|
||||
|
|
@ -52,16 +58,37 @@ internal sealed class CombatAttackInputFrameAdapter : ICombatInputFrameControlle
|
|||
RuntimeCombatAttackCommand.DecreasePower,
|
||||
InputAction.CombatIncreaseAttackPower =>
|
||||
RuntimeCombatAttackCommand.IncreasePower,
|
||||
InputAction.CombatDecreaseMissileAccuracy =>
|
||||
RuntimeCombatAttackCommand.DecreasePower,
|
||||
InputAction.CombatIncreaseMissileAccuracy =>
|
||||
RuntimeCombatAttackCommand.IncreasePower,
|
||||
InputAction.CombatAimLow =>
|
||||
RuntimeCombatAttackCommand.LowAttack,
|
||||
InputAction.CombatAimMedium =>
|
||||
RuntimeCombatAttackCommand.MediumAttack,
|
||||
InputAction.CombatAimHigh =>
|
||||
RuntimeCombatAttackCommand.HighAttack,
|
||||
_ => null,
|
||||
};
|
||||
if (command is null)
|
||||
return false;
|
||||
|
||||
// A retail Hold binding emits Press once, Hold every input frame, then
|
||||
// Release on key-up. RuntimeCombatAttackState already measures the
|
||||
// Press-to-Release interval; forwarding the repeated Hold pulse as a
|
||||
// Release made Delete/End/PageDown attack on the first frame instead
|
||||
// of charging until the player released the key.
|
||||
if (activation == ActivationType.Hold)
|
||||
return true;
|
||||
|
||||
return _owner.HandleCommand(new RuntimeCombatAttackInput(
|
||||
command.Value,
|
||||
activation == ActivationType.Press
|
||||
? RuntimeInputActivation.Press
|
||||
: RuntimeInputActivation.Release));
|
||||
activation switch
|
||||
{
|
||||
ActivationType.Press => RuntimeInputActivation.Press,
|
||||
ActivationType.Release => RuntimeInputActivation.Release,
|
||||
_ => RuntimeInputActivation.Press,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -114,6 +141,8 @@ internal sealed class GameplayInputFrameController
|
|||
public bool HandlePressedMovementAction(InputAction action) =>
|
||||
_movement.HandlePressedAction(action);
|
||||
|
||||
public void AbortAutomaticAttack() => _combat.AbortAutomaticAttack();
|
||||
|
||||
public void QueueRawMouseDelta(float dx, float dy) =>
|
||||
_mouseLook?.QueueRawDelta(dx, dy);
|
||||
|
||||
|
|
|
|||
|
|
@ -149,7 +149,9 @@ internal sealed class MouseLookController : IMouseLookInputFrameController
|
|||
return true;
|
||||
}
|
||||
|
||||
if (action != InputAction.CameraInstantMouseLook)
|
||||
if (action is not (
|
||||
InputAction.CameraInstantMouseLook
|
||||
or InputAction.CameraActivateAlternateMode))
|
||||
return false;
|
||||
|
||||
if (activation == ActivationType.Press)
|
||||
|
|
|
|||
129
src/AcDream.App/Input/RetailEmoteMotionTable.cs
Normal file
129
src/AcDream.App/Input/RetailEmoteMotionTable.cs
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
using AcDream.UI.Abstractions.Input;
|
||||
|
||||
namespace AcDream.App.Input;
|
||||
|
||||
/// <summary>
|
||||
/// Verbatim Sept-2013 <c>ACCmdInterp::InitializeEmoteInputActionHash</c>
|
||||
/// (<c>0x0058B510</c>). <c>ACCmdInterp::OnAction</c>
|
||||
/// (<c>0x0058B370</c>) resolves one of these input actions and submits the
|
||||
/// corresponding raw motion through <c>SetMotion</c> with start=true.
|
||||
/// </summary>
|
||||
internal static class RetailEmoteMotionTable
|
||||
{
|
||||
private const uint EmoteInputMap = 0x10000006u;
|
||||
private const uint FirstEmoteAction = 0x10000098u;
|
||||
|
||||
// Action ids 0x10000098..0x100000EE are contiguous in retail's ActionMap.
|
||||
// Values come from the named Motion_* globals used by the initializer.
|
||||
private static readonly uint[] Motions =
|
||||
[
|
||||
0x43000118u, // AFKState
|
||||
0x13000088u, // Akimbo
|
||||
0x420000F9u, // ATOYOT
|
||||
0x430000F2u, // AkimboState
|
||||
0x43000146u, // AtEaseState
|
||||
0x1300007Au, // Beckon
|
||||
0x1300007Bu, // BeSeeingYou
|
||||
0x1300007Cu, // BlowKiss
|
||||
0x1300007Du, // BowDeep
|
||||
0x430000ECu, // BowDeepState
|
||||
0x1300004Cu, // Cheer
|
||||
0x1300007Eu, // ClapHands
|
||||
0x430000EDu, // ClapHandsState
|
||||
0x13000091u, // Cringe
|
||||
0x430000EEu, // CrossArmsState
|
||||
0x1300007Fu, // Cry
|
||||
0x43000117u, // CurtseyState
|
||||
0x1300014Eu, // DrudgeDance
|
||||
0x43000141u, // DrudgeDanceState
|
||||
0x1300014Fu, // HaveASeat
|
||||
0x43000145u, // HaveASeatState
|
||||
0x13000089u, // HeartyLaugh
|
||||
0x13000132u, // Helper
|
||||
0x13000092u, // Kneel
|
||||
0x430000F7u, // KneelState
|
||||
0x1300014Cu, // Knock
|
||||
0x13000080u, // Laugh
|
||||
0x430000F6u, // LeanState
|
||||
0x43000119u, // MeditateState
|
||||
0x13000082u, // MimeDrink
|
||||
0x13000081u, // MimeEat
|
||||
0x130000CBu, // Mock
|
||||
0x13000083u, // Nod
|
||||
0x13000147u, // NudgeLeft
|
||||
0x13000148u, // NudgeRight
|
||||
0x13000093u, // Plead
|
||||
0x430000F8u, // PleadState
|
||||
0x13000084u, // Point
|
||||
0x430000F0u, // PointState
|
||||
0x1300014Bu, // PointDown
|
||||
0x43000140u, // PointDownState
|
||||
0x13000149u, // PointLeft
|
||||
0x4300013Du, // PointLeftState
|
||||
0x1300014Au, // PointRight
|
||||
0x4300013Eu, // PointRightState
|
||||
0x43000142u, // PossumState
|
||||
0x130000CAu, // Pray
|
||||
0x430000EBu, // PrayState
|
||||
0x43000143u, // ReadState
|
||||
0x1300008Au, // Salute
|
||||
0x430000F3u, // SaluteState
|
||||
0x1300014Du, // ScanHorizon
|
||||
0x1300008Bu, // ScratchHead
|
||||
0x430000F4u, // ScratchHeadState
|
||||
0x13000079u, // ShakeFist
|
||||
0x430000EAu, // ShakeFistState
|
||||
0x13000085u, // ShakeHead
|
||||
0x13000094u, // Shiver
|
||||
0x430000EFu, // ShiverState
|
||||
0x13000095u, // Shoo
|
||||
0x13000086u, // Shrug
|
||||
0x4300013Au, // SitState
|
||||
0x4300013Cu, // SitBackState
|
||||
0x4300013Bu, // SitCrossleggedState
|
||||
0x13000096u, // Slouch
|
||||
0x430000FAu, // SlouchState
|
||||
0x1300008Cu, // SmackHead
|
||||
0x43000115u, // SnowAngelState
|
||||
0x13000097u, // Spit
|
||||
0x13000098u, // Surrender
|
||||
0x430000FBu, // SurrenderState
|
||||
0x4300013Fu, // TalktotheHandState
|
||||
0x1300008Du, // TapFoot
|
||||
0x430000F5u, // TapFootState
|
||||
0x130000CCu, // Teapot
|
||||
0x43000144u, // ThinkerState
|
||||
0x13000116u, // WarmHands
|
||||
0x13000087u, // Wave
|
||||
0x430000F1u, // WaveState
|
||||
0x1300008Fu, // WaveLow
|
||||
0x1300008Eu, // WaveHigh
|
||||
0x1300009Au, // Winded
|
||||
0x430000FDu, // WindedState
|
||||
0x13000099u, // Woah
|
||||
0x430000FCu, // WoahState
|
||||
0x13000090u, // YawnStretch
|
||||
0x1200009Bu, // YMCA
|
||||
];
|
||||
|
||||
public static int Count => Motions.Length;
|
||||
|
||||
public static bool TryGetMotion(InputAction action, out uint motion)
|
||||
{
|
||||
motion = 0u;
|
||||
if (!RetailActionIdentityTable.TryGetRetailIdentity(
|
||||
action,
|
||||
out var identity)
|
||||
|| identity.InputMapId != EmoteInputMap)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
uint index = identity.ActionId - FirstEmoteAction;
|
||||
if (index >= Motions.Length)
|
||||
return false;
|
||||
|
||||
motion = Motions[index];
|
||||
return true;
|
||||
}
|
||||
}
|
||||
601
src/AcDream.App/Input/RetailKeymapFile.cs
Normal file
601
src/AcDream.App/Input/RetailKeymapFile.cs
Normal file
|
|
@ -0,0 +1,601 @@
|
|||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using AcDream.UI.Abstractions.Input;
|
||||
|
||||
namespace AcDream.App.Input;
|
||||
|
||||
/// <summary>
|
||||
/// Parser/writer for retail's editable <c>Documents\Asheron's Call\*.keymap</c>
|
||||
/// PFile text. Only the fourteen user-bindable input maps are replaced when a
|
||||
/// profile is loaded; acdream-only commands and retail's fixed system/edit/
|
||||
/// pointer maps remain owned by the host's base <see cref="KeyBindings"/>.
|
||||
/// </summary>
|
||||
public static class RetailKeymapFile
|
||||
{
|
||||
private static readonly Regex BindingLine = new(
|
||||
"^(?<action>[A-Za-z0-9_]+)\\s*\\[\\s*\"\"\\s*\\[\\s*"
|
||||
+ "(?<device>[0-9]+)\\s+(?<control>[A-Za-z0-9_]+)"
|
||||
+ "(?:\\s+(?<sub>[A-Za-z]+))?\\s*\\]"
|
||||
+ "(?:\\s+(?<modifier>0x[0-9A-Fa-f]+|[0-9]+))?"
|
||||
+ "(?:\\s+(?<activation>[A-Za-z]+))?\\s*\\]$",
|
||||
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||
|
||||
private static readonly (uint Id, string Name)[] GroupOrder =
|
||||
{
|
||||
(0x00000004u, "MovementCommands"),
|
||||
(0x10000007u, "ItemSelectionCommands"),
|
||||
(0x10000009u, "UICommands"),
|
||||
(0x1000000Cu, "QuickslotCommands"),
|
||||
(0x1000000Du, "ToggleChatEntry"),
|
||||
(0x1000000Au, "ChatCommands"),
|
||||
(0x10000002u, "Combat"),
|
||||
(0x10000003u, "MeleeCombat"),
|
||||
(0x10000004u, "MissileCombat"),
|
||||
(0x10000005u, "MagicCombat"),
|
||||
(0x10000006u, "Emotes"),
|
||||
(0x00000005u, "CameraControls"),
|
||||
(0x00000006u, "CameraAlternateControls"),
|
||||
(0x10000008u, "CharacterOptionCommands"),
|
||||
};
|
||||
|
||||
private static readonly IReadOnlyDictionary<string, uint> GroupIds =
|
||||
GroupOrder.ToDictionary(static group => group.Name, static group => group.Id,
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Lazy because the explicit character-option semantic table is declared
|
||||
// later in this type; field initializers otherwise observe it as null.
|
||||
private static readonly Lazy<IReadOnlyDictionary<InputAction, string>> ActionNamesHolder =
|
||||
new(BuildActionNames);
|
||||
private static readonly Lazy<IReadOnlyDictionary<string, InputAction>> ActionsByFileNameHolder =
|
||||
new(BuildActionsByFileName);
|
||||
private static IReadOnlyDictionary<InputAction, string> ActionNames => ActionNamesHolder.Value;
|
||||
private static IReadOnlyDictionary<string, InputAction> ActionsByFileName =>
|
||||
ActionsByFileNameHolder.Value;
|
||||
|
||||
public static KeyBindings Parse(string text, KeyBindings baseBindings)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(text);
|
||||
ArgumentNullException.ThrowIfNull(baseBindings);
|
||||
|
||||
var result = new KeyBindings();
|
||||
foreach (Binding binding in baseBindings.All)
|
||||
{
|
||||
if (!RetailActionIdentityTable.ReverseMap.ContainsKey(binding.Action))
|
||||
result.Add(binding);
|
||||
}
|
||||
|
||||
bool foundBindings = false;
|
||||
bool inBindings = false;
|
||||
uint? currentGroup = null;
|
||||
int lineNumber = 0;
|
||||
foreach (string rawLine in text.Replace("\r\n", "\n", StringComparison.Ordinal).Split('\n'))
|
||||
{
|
||||
lineNumber++;
|
||||
string line = rawLine.Trim();
|
||||
if (line.Length == 0 || line.StartsWith('#'))
|
||||
continue;
|
||||
if (line.Equals("Bindings", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
foundBindings = true;
|
||||
inBindings = true;
|
||||
currentGroup = null;
|
||||
continue;
|
||||
}
|
||||
if (!inBindings)
|
||||
continue;
|
||||
|
||||
// In the PFile grammar every input-map name is a bare identifier on
|
||||
// the line before its opening bracket. An unknown map clears the
|
||||
// user-map context so fixed SystemKeys/EditControls rows are ignored.
|
||||
if (Regex.IsMatch(line, "^[A-Za-z][A-Za-z0-9_]*$",
|
||||
RegexOptions.CultureInvariant))
|
||||
{
|
||||
currentGroup = GroupIds.TryGetValue(line, out uint groupId)
|
||||
? groupId
|
||||
: null;
|
||||
continue;
|
||||
}
|
||||
if (currentGroup is not uint inputMapId || line is "[" or "]")
|
||||
continue;
|
||||
|
||||
Match match = BindingLine.Match(line);
|
||||
if (!match.Success)
|
||||
throw new FormatException(
|
||||
$"Malformed retail key binding at line {lineNumber}: {line}");
|
||||
|
||||
string actionName = match.Groups["action"].Value;
|
||||
if (!ActionsByFileName.TryGetValue(FileIdentity(inputMapId, actionName), out InputAction action))
|
||||
{
|
||||
// Several fixed/non-user-bindable controls live inside an
|
||||
// otherwise editable map (UICommands.EscapeKey/LOGOUT in the
|
||||
// shipped file). They remain in baseBindings just like the
|
||||
// wholly fixed maps below the user maps.
|
||||
continue;
|
||||
}
|
||||
|
||||
string control = match.Groups["control"].Value;
|
||||
if (!RetailScanCodeMap.TryFromFileControl(control, out uint scan, out uint tokenDevice)
|
||||
|| !uint.TryParse(match.Groups["device"].Value,
|
||||
NumberStyles.None, CultureInfo.InvariantCulture, out uint device)
|
||||
|| device != tokenDevice
|
||||
|| RetailScanCodeMap.ToSilkKey(scan, device) is not { } key)
|
||||
{
|
||||
throw new FormatException(
|
||||
$"Unsupported retail control '{control}' at line {lineNumber}.");
|
||||
}
|
||||
|
||||
uint fileModifier = 0u;
|
||||
if (match.Groups["modifier"].Success)
|
||||
{
|
||||
string value = match.Groups["modifier"].Value;
|
||||
NumberStyles style = value.StartsWith("0x", StringComparison.OrdinalIgnoreCase)
|
||||
? NumberStyles.AllowHexSpecifier
|
||||
: NumberStyles.None;
|
||||
string digits = style == NumberStyles.AllowHexSpecifier ? value[2..] : value;
|
||||
if (!uint.TryParse(digits, style, CultureInfo.InvariantCulture, out fileModifier))
|
||||
throw new FormatException($"Invalid modifier at line {lineNumber}.");
|
||||
}
|
||||
|
||||
var chord = new KeyChord(key, (ModifierMask)(fileModifier & 0x0Fu), (byte)device);
|
||||
result.Add(new Binding(
|
||||
chord,
|
||||
action,
|
||||
RetailActionIdentityTable.ActivationFor(inputMapId,
|
||||
RetailActionIdentityTable.ReverseMap[action].ActionId),
|
||||
RetailActionIdentityTable.ScopeForInputMap(inputMapId)));
|
||||
}
|
||||
|
||||
if (!foundBindings)
|
||||
throw new FormatException("The file does not contain a retail Bindings section.");
|
||||
return result;
|
||||
}
|
||||
|
||||
public static string Write(KeyBindings bindings)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(bindings);
|
||||
var output = new StringBuilder(24_000);
|
||||
output.AppendLine("#Asheron's Call: Throne of Destiny Keymap File")
|
||||
.AppendLine("#")
|
||||
.AppendLine("#Generated by acdream's retail Configure Keyboard screen.")
|
||||
.AppendLine("#This file is compatible with the Sept-2013 retail PFile keymap grammar.")
|
||||
.AppendLine("#")
|
||||
.AppendLine("\"User Defined Keymap\" [ 00000000-0000-0000-0000-000000000000 ]")
|
||||
.AppendLine()
|
||||
.AppendLine("Devices")
|
||||
.AppendLine("[")
|
||||
.AppendLine(" Keyboard [ GUID_SysKeyboard ]")
|
||||
.AppendLine(" Mouse [ GUID_SysMouse ]")
|
||||
.AppendLine(" Virtual [ GUID_Virtual ]")
|
||||
.AppendLine("]")
|
||||
.AppendLine()
|
||||
.AppendLine("MetaKeys")
|
||||
.AppendLine("[")
|
||||
.AppendLine(" 1 [ 0 DIK_LSHIFT ]")
|
||||
.AppendLine(" 2 [ 0 DIK_LCONTROL ]")
|
||||
.AppendLine(" 2 [ 0 DIK_RCONTROL ]")
|
||||
.AppendLine(" 3 [ 0 DIK_LMENU ]")
|
||||
.AppendLine(" 3 [ 0 DIK_RALT ]")
|
||||
.AppendLine(" 4 [ 0 DIK_LWIN ]")
|
||||
.AppendLine(" 4 [ 0 DIK_RWIN ]")
|
||||
.AppendLine(" 5 [ 1 DIMOFS_BUTTON3 ]")
|
||||
.AppendLine(" 6 [ 1 DIMOFS_BUTTON4 ]")
|
||||
.AppendLine("]")
|
||||
.AppendLine()
|
||||
.AppendLine("Bindings")
|
||||
.AppendLine("[");
|
||||
|
||||
foreach ((uint inputMapId, string groupName) in GroupOrder)
|
||||
{
|
||||
output.Append(" ").AppendLine(groupName).AppendLine(" [");
|
||||
foreach (((uint InputMapId, uint ActionId) identity, InputAction action) in
|
||||
RetailActionIdentityTable.Map
|
||||
.Where(pair => pair.Key.InputMapId == inputMapId)
|
||||
.OrderBy(static pair => pair.Key.ActionId))
|
||||
{
|
||||
foreach (Binding binding in bindings.ForAction(action))
|
||||
{
|
||||
if (!RetailScanCodeMap.TryToFileControl(binding.Chord, out string control))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"{binding.Chord} cannot be represented by the retail DirectInput keymap.");
|
||||
}
|
||||
|
||||
output.Append(" ").Append(ActionNames[action])
|
||||
.Append(" [ \"\" [ ").Append(binding.Chord.Device)
|
||||
.Append(' ').Append(control).Append(" ]");
|
||||
uint modifier = (uint)binding.Chord.Modifiers & 0x0Fu;
|
||||
if (modifier != 0u)
|
||||
output.Append(" 0x").Append(modifier.ToString("X8", CultureInfo.InvariantCulture));
|
||||
output.AppendLine(" ]");
|
||||
}
|
||||
}
|
||||
// Bare Escape is a fixed MasterInputMap control rather than one of
|
||||
// the user-bindable ActionMap rows (LOGOUT is a normal row and was
|
||||
// emitted above). Keep it in exported files so the Sept-2013 client
|
||||
// retains its priority Escape ladder when opening our profile.
|
||||
if (inputMapId == 0x10000009u)
|
||||
output.AppendLine(" EscapeKey [ \"\" [ 0 DIK_ESCAPE ] ]");
|
||||
output.AppendLine(" ]").AppendLine();
|
||||
}
|
||||
|
||||
// Retail's fixed maps are included so a file can also be opened by the
|
||||
// 2013 client. They are deliberately not imported into the 306-row GUI.
|
||||
output.Append(FixedRetailMaps);
|
||||
output.AppendLine("]");
|
||||
return output.ToString();
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<InputAction, string> BuildActionNames()
|
||||
{
|
||||
var names = new Dictionary<InputAction, string>();
|
||||
foreach (InputAction action in RetailActionIdentityTable.ReverseMap.Keys)
|
||||
names[action] = FileActionName(action);
|
||||
return names;
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, InputAction> BuildActionsByFileName()
|
||||
{
|
||||
var actions = new Dictionary<string, InputAction>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (((uint InputMapId, uint ActionId) identity, InputAction action) in
|
||||
RetailActionIdentityTable.Map)
|
||||
{
|
||||
actions.Add(FileIdentity(identity.InputMapId, ActionNames[action]), action);
|
||||
}
|
||||
return actions;
|
||||
}
|
||||
|
||||
private static string FileIdentity(uint inputMapId, string actionName) =>
|
||||
$"{inputMapId:X8}:{actionName}";
|
||||
|
||||
private static string GroupName(uint inputMapId) =>
|
||||
GroupOrder.First(group => group.Id == inputMapId).Name;
|
||||
|
||||
private static string FileActionName(InputAction action)
|
||||
{
|
||||
if (CharacterOptionNames.TryGetValue(action, out string? characterOption))
|
||||
return characterOption;
|
||||
if (action == InputAction.SelectionPlaceInInventory) return "SelectionPickUp";
|
||||
if (action == InputAction.UseSelected) return "USE";
|
||||
string name = action.ToString();
|
||||
if (name.StartsWith("CameraAlternate", StringComparison.Ordinal))
|
||||
return "Camera" + name["CameraAlternate".Length..];
|
||||
if (!name.StartsWith("Emote", StringComparison.Ordinal))
|
||||
return name;
|
||||
string emote = name["Emote".Length..];
|
||||
return emote switch
|
||||
{
|
||||
"AfkState" => "AFKState",
|
||||
"AToyotState" => "ATOYOT",
|
||||
"MimeDrinking" => "MimeDrink",
|
||||
"MimeEating" => "MimeEat",
|
||||
"TalkToTheHandState" => "TalktotheHandState",
|
||||
"YawnAndStretch" => "YawnStretch",
|
||||
"Ymca" => "YMCA",
|
||||
_ => emote,
|
||||
};
|
||||
}
|
||||
|
||||
private static readonly IReadOnlyDictionary<InputAction, string> CharacterOptionNames =
|
||||
new Dictionary<InputAction, string>
|
||||
{
|
||||
[InputAction.ToggleCharacterOptionAutoRepeatAttack] = "AutoRepeatAttacks",
|
||||
[InputAction.ToggleCharacterOptionIgnoreAllegianceRequests] = "IgnoreAllegianceRequests",
|
||||
[InputAction.ToggleCharacterOptionIgnoreFellowshipRequests] = "IgnoreFellowshipRequests",
|
||||
[InputAction.ToggleCharacterOptionIgnoreTradeRequests] = "IgnoreTradeRequests",
|
||||
[InputAction.ToggleCharacterOptionPersistentAtDay] = "PersistentAtDay",
|
||||
[InputAction.ToggleCharacterOptionAllowGive] = "LetPlayersGiveYouItems",
|
||||
[InputAction.ToggleCharacterOptionViewCombatTarget] = "AutoTrackCombatTargets",
|
||||
[InputAction.ToggleCharacterOptionShowTooltips] = "DisplayTooltips",
|
||||
[InputAction.ToggleCharacterOptionUseDeception] = "AttemptToDeceivePlayers",
|
||||
[InputAction.ToggleCharacterOptionToggleRun] = "RunAsDefaultMovement",
|
||||
[InputAction.ToggleCharacterOptionStayInChatMode] = "StayInChatModeAfterSend",
|
||||
[InputAction.ToggleCharacterOptionAdvancedCombatUi] = "AdvancedCombatInterface",
|
||||
[InputAction.ToggleCharacterOptionAutoTarget] = "AutoTarget",
|
||||
[InputAction.ToggleCharacterOptionVividTargetingIndicator] = "VividTargetIndicator",
|
||||
[InputAction.ToggleCharacterOptionFellowshipShareXp] = "ShareFellowshipXP",
|
||||
[InputAction.ToggleCharacterOptionAcceptLootPermits] = "AcceptCorpseLooting",
|
||||
[InputAction.ToggleCharacterOptionFellowshipShareLoot] = "ShareFellowshipLoot",
|
||||
[InputAction.ToggleCharacterOptionFellowshipAutoAcceptRequests] = "AutomaticallyAcceptFellowshipRequests",
|
||||
[InputAction.ToggleCharacterOptionCoordinatesOnRadar] = "ShowRadarCoordinates",
|
||||
[InputAction.ToggleCharacterOptionSpellDuration] = "ShowSpellDurations",
|
||||
[InputAction.ToggleCharacterOptionDisableHouseRestrictionEffects] = "DisableHouseEffect",
|
||||
[InputAction.ToggleCharacterOptionDragItemOnPlayerOpensSecureTrade] = "DragItemOnPlayerOpensSecureTrade",
|
||||
[InputAction.ToggleCharacterOptionDisplayAllegianceLogonNotifications] = "DisplayAllegianceLogonNotifications",
|
||||
[InputAction.ToggleCharacterOptionUseChargeAttack] = "UseChargeAttack",
|
||||
[InputAction.ToggleCharacterOptionUseCraftSuccessDialog] = "ToggleCraftingChanceOfSuccessDialog",
|
||||
[InputAction.ToggleCharacterOptionListenToAllegianceChat] = "AllegianceChat",
|
||||
[InputAction.ToggleCharacterOptionDisplayDateOfBirth] = "DisplayDateOfBirth",
|
||||
[InputAction.ToggleCharacterOptionDisplayAge] = "DisplayAge",
|
||||
[InputAction.ToggleCharacterOptionDisplayChessRank] = "DisplayChessRank",
|
||||
[InputAction.ToggleCharacterOptionDisplayFishingSkill] = "Fishing",
|
||||
[InputAction.ToggleCharacterOptionDisplayNumberDeaths] = "DisplayNumberDeaths",
|
||||
[InputAction.ToggleCharacterOptionDisplayTimeStamps] = "DisplayTimeStamps",
|
||||
[InputAction.ToggleCharacterOptionSalvageMultiple] = "SalvageMultiple",
|
||||
[InputAction.ToggleCharacterOptionListenToGeneralChat] = "GeneralChat",
|
||||
[InputAction.ToggleCharacterOptionListenToTradeChat] = "TradeChat",
|
||||
[InputAction.ToggleCharacterOptionListenToLfgChat] = "LFGChat",
|
||||
[InputAction.ToggleCharacterOptionListenToRoleplayChat] = "RoleplayChat",
|
||||
[InputAction.ToggleCharacterOptionDisplayNumberCharacterTitles] = "DisplayNumberCharacterTitles",
|
||||
[InputAction.ToggleCharacterOptionMainPackPreferred] = "MainPackPreferred",
|
||||
[InputAction.ToggleCharacterOptionLeadMissileTargets] = "LeadMissileTargets",
|
||||
[InputAction.ToggleCharacterOptionUseFastMissiles] = "UseFastMissiles",
|
||||
[InputAction.ToggleCharacterOptionFilterLanguage] = "FilterLanguage",
|
||||
[InputAction.ToggleCharacterOptionConfirmVolatileRareUse] = "ConfirmVolatileRareUse",
|
||||
[InputAction.ToggleCharacterOptionListenToSocietyChat] = "SocietyChat",
|
||||
[InputAction.ToggleCharacterOptionShowHelm] = "ShowHelm",
|
||||
[InputAction.ToggleCharacterOptionDisableDistanceFog] = "DisableDistanceFog",
|
||||
[InputAction.ToggleCharacterOptionShowCloak] = "ShowCloak",
|
||||
[InputAction.ToggleCharacterOptionSideBySideVitals] = "SideBySideVitals",
|
||||
};
|
||||
|
||||
private const string FixedRetailMaps = """
|
||||
TargetedUsage
|
||||
[
|
||||
SelectLeft [ "" [ 1 DIMOFS_BUTTON0 ] ]
|
||||
SelectRight [ "" [ 1 DIMOFS_BUTTON1 ] ]
|
||||
]
|
||||
|
||||
SystemKeys
|
||||
[
|
||||
AltEnter [ "" [ 0 DIK_RETURN ] 0x00000004 ]
|
||||
AltTab [ "" [ 0 DIK_TAB ] 0x00000004 ]
|
||||
AltF4 [ "" [ 0 DIK_F4 ] 0x00000004 ]
|
||||
CtrlShiftEsc [ "" [ 0 DIK_ESCAPE ] 0x00000003 ]
|
||||
]
|
||||
|
||||
MouseCommands
|
||||
[
|
||||
PointerX [ "" [ 1 DIMOFS_X ] 0x00000000 Analog ]
|
||||
PointerY [ "" [ 1 DIMOFS_Y ] 0x00000000 Analog ]
|
||||
SelectLeft [ "" [ 1 DIMOFS_BUTTON0 ] ]
|
||||
SelectRight [ "" [ 1 DIMOFS_BUTTON1 ] ]
|
||||
SelectMid [ "" [ 1 DIMOFS_BUTTON2 ] ]
|
||||
SelectDblLeft [ "" [ 1 DIMOFS_BUTTON0 ] 0x00000000 MouseDblClick ]
|
||||
SelectDblRight [ "" [ 1 DIMOFS_BUTTON1 ] 0x00000000 MouseDblClick ]
|
||||
SelectDblMid [ "" [ 1 DIMOFS_BUTTON2 ] 0x00000000 MouseDblClick ]
|
||||
]
|
||||
|
||||
ScrollableControls
|
||||
[
|
||||
ScrollUp [ "" [ 1 DIMOFS_Z AxisPositive ] ]
|
||||
ScrollDown [ "" [ 1 DIMOFS_Z AxisNegative ] ]
|
||||
ScrollUp [ "" [ 0 DIK_UPARROW ] 0x00000002 ]
|
||||
ScrollDown [ "" [ 0 DIK_DOWNARROW ] 0x00000002 ]
|
||||
]
|
||||
|
||||
EditControls
|
||||
[
|
||||
CursorCharLeft [ "" [ 0 DIK_LEFT ] ]
|
||||
CursorCharRight [ "" [ 0 DIK_RIGHTARROW ] ]
|
||||
CursorPreviousLine [ "" [ 0 DIK_UPARROW ] ]
|
||||
CursorNextLine [ "" [ 0 DIK_DOWNARROW ] ]
|
||||
CursorPreviousPage [ "" [ 0 DIK_PGUP ] ]
|
||||
CursorNextPage [ "" [ 0 DIK_PGDN ] ]
|
||||
CursorWordLeft [ "" [ 0 DIK_LEFT ] 0x00000002 ]
|
||||
CursorWordRight [ "" [ 0 DIK_RIGHTARROW ] 0x00000002 ]
|
||||
CursorStartOfLine [ "" [ 0 DIK_HOME ] ]
|
||||
CursorStartOfDocument [ "" [ 0 DIK_HOME ] 0x00000002 ]
|
||||
CursorEndOfLine [ "" [ 0 DIK_END ] ]
|
||||
CursorEndOfDocument [ "" [ 0 DIK_END ] 0x00000002 ]
|
||||
EscapeKey [ "" [ 0 DIK_ESCAPE ] ]
|
||||
AcceptInput [ "" [ 0 DIK_RETURN ] ]
|
||||
DeleteKey [ "" [ 0 DIK_DELETE ] ]
|
||||
BackspaceKey [ "" [ 0 DIK_BACK ] ]
|
||||
]
|
||||
|
||||
CopyAndPasteControls
|
||||
[
|
||||
CopyText [ "" [ 0 DIK_C ] 0x00000002 ]
|
||||
CopyText [ "" [ 0 DIK_INSERT ] 0x00000002 ]
|
||||
CutText [ "" [ 0 DIK_X ] 0x00000002 ]
|
||||
CutText [ "" [ 0 DIK_DELETE ] 0x00000001 ]
|
||||
PasteText [ "" [ 0 DIK_V ] 0x00000002 ]
|
||||
PasteText [ "" [ 0 DIK_INSERT ] 0x00000001 ]
|
||||
]
|
||||
|
||||
DialogBoxes
|
||||
[
|
||||
EscapeKey [ "" [ 0 DIK_ESCAPE ] ]
|
||||
AcceptInput [ "" [ 0 DIK_RETURN ] ]
|
||||
]
|
||||
|
||||
""";
|
||||
}
|
||||
|
||||
public enum RetailKeymapSaveStatus
|
||||
{
|
||||
Saved,
|
||||
Exists,
|
||||
ReadOnly,
|
||||
InvalidName,
|
||||
Failed,
|
||||
}
|
||||
|
||||
public readonly record struct RetailKeymapSaveResult(
|
||||
RetailKeymapSaveStatus Status,
|
||||
string FileName,
|
||||
string? Error = null);
|
||||
|
||||
/// <summary>
|
||||
/// Owns retail's active-profile preference and <c>*.keymap</c> directory.
|
||||
/// The profile selector lives beside acdream's portable JSON mirror; profile
|
||||
/// files live in retail's Documents/Asheron's Call folder.
|
||||
/// </summary>
|
||||
public sealed class RetailKeymapProfileStore
|
||||
{
|
||||
public const string DefaultFileName = "acdream.keymap";
|
||||
|
||||
private readonly string _jsonPath;
|
||||
private readonly string _directory;
|
||||
private readonly string _selectorPath;
|
||||
|
||||
public RetailKeymapProfileStore(string jsonPath, string? keymapDirectory = null)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(jsonPath);
|
||||
_jsonPath = Path.GetFullPath(jsonPath);
|
||||
string configDirectory = Path.GetDirectoryName(_jsonPath)
|
||||
?? Directory.GetCurrentDirectory();
|
||||
_selectorPath = Path.Combine(configDirectory, "active-keymap.txt");
|
||||
_directory = keymapDirectory ?? Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments),
|
||||
"Asheron's Call");
|
||||
}
|
||||
|
||||
public string DirectoryPath => _directory;
|
||||
|
||||
public string CurrentFileName
|
||||
{
|
||||
get
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(_selectorPath))
|
||||
{
|
||||
string selected = NormalizeFileName(File.ReadAllText(_selectorPath));
|
||||
if (selected.Length != 0) return selected;
|
||||
}
|
||||
}
|
||||
catch (Exception failure)
|
||||
{
|
||||
Console.WriteLine($"keymap: active-profile preference could not be read: {failure.Message}");
|
||||
}
|
||||
return DefaultFileName;
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<string> ListFiles()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!Directory.Exists(_directory)) return Array.Empty<string>();
|
||||
return Directory.EnumerateFiles(_directory, "*.keymap", SearchOption.TopDirectoryOnly)
|
||||
.Select(Path.GetFileName)
|
||||
.Where(static name => !string.IsNullOrEmpty(name))
|
||||
.Cast<string>()
|
||||
.OrderBy(static name => name, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
catch (Exception failure)
|
||||
{
|
||||
Console.WriteLine($"keymap: profile list failed: {failure.Message}");
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryLoad(
|
||||
string fileName,
|
||||
KeyBindings baseBindings,
|
||||
out KeyBindings bindings,
|
||||
out string? error)
|
||||
{
|
||||
bindings = baseBindings;
|
||||
error = null;
|
||||
string normalized = NormalizeFileName(fileName);
|
||||
if (normalized.Length == 0)
|
||||
{
|
||||
error = "The keymap filename is invalid.";
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
string text = File.ReadAllText(Path.Combine(_directory, normalized));
|
||||
bindings = RetailKeymapFile.Parse(text, baseBindings);
|
||||
WriteSelector(normalized);
|
||||
return true;
|
||||
}
|
||||
catch (Exception failure)
|
||||
{
|
||||
error = failure.Message;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public RetailKeymapSaveResult Save(
|
||||
string fileName,
|
||||
KeyBindings bindings,
|
||||
bool overwrite)
|
||||
{
|
||||
string normalized = NormalizeFileName(fileName);
|
||||
if (normalized.Length == 0)
|
||||
return new(RetailKeymapSaveStatus.InvalidName, string.Empty);
|
||||
|
||||
string path = Path.Combine(_directory, normalized);
|
||||
try
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
if (!overwrite)
|
||||
return new(RetailKeymapSaveStatus.Exists, normalized);
|
||||
if ((File.GetAttributes(path) & FileAttributes.ReadOnly) != 0)
|
||||
return new(RetailKeymapSaveStatus.ReadOnly, normalized);
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(_directory);
|
||||
AtomicWrite(path, RetailKeymapFile.Write(bindings));
|
||||
WriteSelector(normalized);
|
||||
return new(RetailKeymapSaveStatus.Saved, normalized);
|
||||
}
|
||||
catch (UnauthorizedAccessException failure)
|
||||
{
|
||||
return new(RetailKeymapSaveStatus.ReadOnly, normalized, failure.Message);
|
||||
}
|
||||
catch (Exception failure)
|
||||
{
|
||||
return new(RetailKeymapSaveStatus.Failed, normalized, failure.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public RetailKeymapSaveResult SaveActive(KeyBindings bindings) =>
|
||||
Save(CurrentFileName, bindings, overwrite: true);
|
||||
|
||||
public static KeyBindings LoadActiveOrJson(
|
||||
string jsonPath,
|
||||
out string profileName)
|
||||
{
|
||||
KeyBindings fallback = KeyBindings.LoadOrDefault(jsonPath);
|
||||
var store = new RetailKeymapProfileStore(jsonPath);
|
||||
profileName = store.CurrentFileName;
|
||||
string profilePath = Path.Combine(store.DirectoryPath, profileName);
|
||||
if (!File.Exists(profilePath)) return fallback;
|
||||
if (store.TryLoad(profileName, fallback, out KeyBindings loaded, out string? error))
|
||||
return loaded;
|
||||
Console.WriteLine($"keymap: '{profileName}' could not be loaded; using JSON/defaults: {error}");
|
||||
return fallback;
|
||||
}
|
||||
|
||||
public static string NormalizeFileName(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return string.Empty;
|
||||
string trimmed = value.Trim();
|
||||
if (!string.Equals(trimmed, Path.GetFileName(trimmed), StringComparison.Ordinal))
|
||||
return string.Empty;
|
||||
if (trimmed.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0)
|
||||
return string.Empty;
|
||||
return trimmed.EndsWith(".keymap", StringComparison.OrdinalIgnoreCase)
|
||||
? trimmed
|
||||
: trimmed + ".keymap";
|
||||
}
|
||||
|
||||
private void WriteSelector(string fileName)
|
||||
{
|
||||
string? directory = Path.GetDirectoryName(_selectorPath);
|
||||
if (!string.IsNullOrEmpty(directory)) Directory.CreateDirectory(directory);
|
||||
AtomicWrite(_selectorPath, fileName + Environment.NewLine);
|
||||
}
|
||||
|
||||
private static void AtomicWrite(string path, string content)
|
||||
{
|
||||
string temp = path + ".tmp-" + Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture);
|
||||
try
|
||||
{
|
||||
File.WriteAllText(temp, content, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
|
||||
File.Move(temp, path, overwrite: true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(temp)) File.Delete(temp);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -24,6 +24,8 @@ internal sealed class SelectionInteractionController
|
|||
private readonly IPlayerInteractionMovementSink _movement;
|
||||
private readonly PlayerApproachCompletionState _approachCompletions;
|
||||
private readonly Action<string>? _toast;
|
||||
private readonly Func<uint, bool>? _splitStack;
|
||||
private readonly Func<IEnumerable<uint>> _fellowshipMembers;
|
||||
|
||||
public SelectionInteractionController(
|
||||
SelectionState selection,
|
||||
|
|
@ -32,7 +34,9 @@ internal sealed class SelectionInteractionController
|
|||
IRuntimeInteractionTransport transport,
|
||||
IPlayerInteractionMovementSink movement,
|
||||
Action<string>? toast = null,
|
||||
PlayerApproachCompletionState? approachCompletions = null)
|
||||
PlayerApproachCompletionState? approachCompletions = null,
|
||||
Func<uint, bool>? splitStack = null,
|
||||
Func<IEnumerable<uint>>? fellowshipMembers = null)
|
||||
{
|
||||
_selection = selection ?? throw new ArgumentNullException(nameof(selection));
|
||||
_query = query ?? throw new ArgumentNullException(nameof(query));
|
||||
|
|
@ -43,14 +47,96 @@ internal sealed class SelectionInteractionController
|
|||
_toast = toast;
|
||||
_approachCompletions = approachCompletions
|
||||
?? new PlayerApproachCompletionState();
|
||||
_splitStack = splitStack;
|
||||
_fellowshipMembers = fellowshipMembers ?? (() => Array.Empty<uint>());
|
||||
}
|
||||
|
||||
public bool HandleInputAction(InputAction action)
|
||||
{
|
||||
switch (action)
|
||||
{
|
||||
case InputAction.SelectionSelf:
|
||||
SelectSelf();
|
||||
return true;
|
||||
case InputAction.SelectionPlaceInInventory:
|
||||
PlaceSelectionInBackpack(mainPack: false);
|
||||
return true;
|
||||
case InputAction.SelectionPlaceInMainPack:
|
||||
PlaceSelectionInBackpack(mainPack: true);
|
||||
return true;
|
||||
case InputAction.SelectionSplitStack:
|
||||
if (_selection.SelectedObjectId is { } stack)
|
||||
_splitStack?.Invoke(stack);
|
||||
return true;
|
||||
case InputAction.SelectionClosestCompassItem:
|
||||
SelectRetailTarget(RetailSelectionKind.CompassItem, RetailSelectionDirection.Closest);
|
||||
return true;
|
||||
case InputAction.SelectionPreviousCompassItem:
|
||||
SelectRetailTarget(RetailSelectionKind.CompassItem, RetailSelectionDirection.Previous);
|
||||
return true;
|
||||
case InputAction.SelectionNextCompassItem:
|
||||
SelectRetailTarget(RetailSelectionKind.CompassItem, RetailSelectionDirection.Next);
|
||||
return true;
|
||||
case InputAction.SelectionClosestItem:
|
||||
SelectRetailTarget(
|
||||
RetailSelectionKind.Item,
|
||||
RetailSelectionDirection.Closest,
|
||||
excludeOwnedByPlayer: true);
|
||||
return true;
|
||||
case InputAction.SelectionPreviousItem:
|
||||
SelectRetailTarget(RetailSelectionKind.Item, RetailSelectionDirection.Previous);
|
||||
return true;
|
||||
case InputAction.SelectionNextItem:
|
||||
SelectRetailTarget(RetailSelectionKind.Item, RetailSelectionDirection.Next);
|
||||
return true;
|
||||
case InputAction.SelectionClosestMonster:
|
||||
SelectClosestCombatTarget(showToast: true);
|
||||
SelectRetailTarget(
|
||||
RetailSelectionKind.Monster,
|
||||
RetailSelectionDirection.Closest,
|
||||
showToast: true);
|
||||
return true;
|
||||
case InputAction.SelectionPreviousMonster:
|
||||
SelectRetailTarget(RetailSelectionKind.Monster, RetailSelectionDirection.Previous);
|
||||
return true;
|
||||
case InputAction.SelectionNextMonster:
|
||||
SelectRetailTarget(RetailSelectionKind.Monster, RetailSelectionDirection.Next);
|
||||
return true;
|
||||
case InputAction.SelectionLastAttacker:
|
||||
if (_query.FindLastAttacker() is { } attacker)
|
||||
_selection.Select(attacker, SelectionChangeSource.Keyboard);
|
||||
return true;
|
||||
case InputAction.SelectionClosestPlayer:
|
||||
SelectRetailTarget(RetailSelectionKind.Player, RetailSelectionDirection.Closest);
|
||||
return true;
|
||||
case InputAction.SelectionPreviousPlayer:
|
||||
SelectRetailTarget(RetailSelectionKind.Player, RetailSelectionDirection.Previous);
|
||||
return true;
|
||||
case InputAction.SelectionNextPlayer:
|
||||
SelectRetailTarget(RetailSelectionKind.Player, RetailSelectionDirection.Next);
|
||||
return true;
|
||||
case InputAction.SelectionPreviousFellow:
|
||||
SelectFellow(previous: true);
|
||||
return true;
|
||||
case InputAction.SelectionNextFellow:
|
||||
SelectFellow(previous: false);
|
||||
return true;
|
||||
case InputAction.SelectionClosestUnopenedCorpse:
|
||||
SelectRetailTarget(RetailSelectionKind.UnopenedCorpse, RetailSelectionDirection.Closest);
|
||||
return true;
|
||||
case InputAction.SelectionNextUnopenedCorpse:
|
||||
SelectRetailTarget(RetailSelectionKind.UnopenedCorpse, RetailSelectionDirection.Next);
|
||||
return true;
|
||||
case InputAction.SelectionUseClosestUnopenedCorpse:
|
||||
SelectAndUseCorpse(RetailSelectionDirection.Closest);
|
||||
return true;
|
||||
case InputAction.SelectionUseNextUnopenedCorpse:
|
||||
SelectAndUseCorpse(RetailSelectionDirection.Next);
|
||||
return true;
|
||||
case InputAction.SelectionGiveToTarget:
|
||||
GiveSelectionToPreviousTarget();
|
||||
return true;
|
||||
case InputAction.SelectionDrop:
|
||||
DropSelection();
|
||||
return true;
|
||||
case InputAction.SelectionPreviousSelection:
|
||||
_selection.SelectPrevious();
|
||||
|
|
@ -87,11 +173,109 @@ internal sealed class SelectionInteractionController
|
|||
case InputAction.EscapeKey when _items.IsAnyTargetModeActive:
|
||||
_items.CancelTargetMode();
|
||||
return true;
|
||||
case InputAction.EscapeKey when _selection.SelectedObjectId is not null:
|
||||
// ClientUISystem::OnAction @0x00564C8E: Escape willingly
|
||||
// loses the current target before it reaches the Gameplay
|
||||
// Options fallback at 0x00564CBF.
|
||||
_selection.Clear(SelectionChangeSource.Keyboard);
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void SelectSelf()
|
||||
{
|
||||
uint playerGuid = _query.PlayerGuid;
|
||||
if (playerGuid == 0u)
|
||||
return;
|
||||
if (_items.OfferPrimaryClick(playerGuid) is not ItemPrimaryClickResult.NotActive)
|
||||
return;
|
||||
_selection.Select(playerGuid, SelectionChangeSource.Keyboard);
|
||||
}
|
||||
|
||||
private void PlaceSelectionInBackpack(bool mainPack)
|
||||
{
|
||||
if (_selection.SelectedObjectId is { } selected)
|
||||
_items.PlaceWorldItemInBackpack(selected, mainPack);
|
||||
}
|
||||
|
||||
private void SelectRetailTarget(
|
||||
RetailSelectionKind kind,
|
||||
RetailSelectionDirection direction,
|
||||
bool excludeOwnedByPlayer = false,
|
||||
bool showToast = false)
|
||||
{
|
||||
uint? anchor = _selection.SelectedObjectId ?? _selection.PreviousObjectId;
|
||||
uint? target = _query.FindSelectionTarget(
|
||||
kind,
|
||||
direction,
|
||||
anchor,
|
||||
excludeOwnedByPlayer);
|
||||
if (target is { } guid)
|
||||
{
|
||||
_selection.Select(guid, SelectionChangeSource.Keyboard);
|
||||
if (showToast)
|
||||
_toast?.Invoke(_query.Describe(guid));
|
||||
}
|
||||
}
|
||||
|
||||
private void SelectAndUseCorpse(RetailSelectionDirection direction)
|
||||
{
|
||||
SelectRetailTarget(RetailSelectionKind.UnopenedCorpse, direction);
|
||||
if (_selection.SelectedObjectId is { } corpse)
|
||||
EnqueueIdentityBound(
|
||||
RuntimeQueuedInteractionKind.Use,
|
||||
corpse,
|
||||
requireLiveEntity: false);
|
||||
}
|
||||
|
||||
private void SelectFellow(bool previous)
|
||||
{
|
||||
uint[] fellows = _fellowshipMembers()
|
||||
.Where(static guid => guid != 0u)
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
if (fellows.Length == 0)
|
||||
return;
|
||||
|
||||
int current = _selection.SelectedObjectId is { } selected
|
||||
? Array.IndexOf(fellows, selected)
|
||||
: -1;
|
||||
int next = previous
|
||||
? (current > 0 ? current - 1 : fellows.Length - 1)
|
||||
: (current >= 0 && current + 1 < fellows.Length ? current + 1 : 0);
|
||||
_selection.Select(fellows[next], SelectionChangeSource.Keyboard);
|
||||
}
|
||||
|
||||
private void GiveSelectionToPreviousTarget()
|
||||
{
|
||||
if (_selection.SelectedObjectId is not { } selected
|
||||
|| _selection.PreviousObjectId is not { } target
|
||||
|| selected == target
|
||||
|| !_query.IsCreature(target))
|
||||
{
|
||||
_toast?.Invoke(
|
||||
"You must select a creature or a character to give that to.\n");
|
||||
return;
|
||||
}
|
||||
|
||||
if (_items.PlaceSelectedIn3D(selected, target))
|
||||
_selection.Select(target, SelectionChangeSource.Keyboard);
|
||||
}
|
||||
|
||||
private void DropSelection()
|
||||
{
|
||||
if (_selection.SelectedObjectId is not { } selected)
|
||||
return;
|
||||
if (!_items.IsOwnedByPlayer(selected))
|
||||
{
|
||||
_toast?.Invoke("You must pick that up first");
|
||||
return;
|
||||
}
|
||||
_items.PlaceSelectedIn3D(selected, targetGuid: 0u);
|
||||
}
|
||||
|
||||
public uint? PickAtCursor(bool includeSelf)
|
||||
=> _query.PickAtCursor(includeSelf);
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,9 @@ using AcDream.Core.Combat;
|
|||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Net;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Properties;
|
||||
using AcDream.Core.Selection;
|
||||
using AcDream.Core.Ui;
|
||||
using AcDream.Core.World;
|
||||
|
||||
namespace AcDream.App.Interaction;
|
||||
|
|
@ -25,6 +27,22 @@ internal readonly record struct WorldInteractionTarget(
|
|||
|
||||
internal readonly record struct ClosestCombatTarget(uint ServerGuid, float DistanceSquared);
|
||||
|
||||
internal enum RetailSelectionKind
|
||||
{
|
||||
Item,
|
||||
CompassItem,
|
||||
Monster,
|
||||
Player,
|
||||
UnopenedCorpse,
|
||||
}
|
||||
|
||||
internal enum RetailSelectionDirection
|
||||
{
|
||||
Closest,
|
||||
Previous,
|
||||
Next,
|
||||
}
|
||||
|
||||
internal readonly record struct InteractionApproach(
|
||||
WorldInteractionTarget Target,
|
||||
PlayerInteractionPose Player,
|
||||
|
|
@ -36,6 +54,7 @@ internal readonly record struct InteractionApproach(
|
|||
|
||||
internal interface IWorldSelectionQuery
|
||||
{
|
||||
uint PlayerGuid => 0u;
|
||||
uint? PickAtCursor(bool includeSelf);
|
||||
uint? PickAt(float mouseX, float mouseY, bool includeSelf);
|
||||
void BeginLightingPulse(uint serverGuid);
|
||||
|
|
@ -46,6 +65,16 @@ internal interface IWorldSelectionQuery
|
|||
bool IsHostileMonster(uint serverGuid);
|
||||
bool IsAttackableTarget(uint serverGuid);
|
||||
ClosestCombatTarget? FindClosestHostileMonster();
|
||||
uint? FindSelectionTarget(
|
||||
RetailSelectionKind kind,
|
||||
RetailSelectionDirection direction,
|
||||
uint? anchor,
|
||||
bool excludeOwnedByPlayer = false) =>
|
||||
kind == RetailSelectionKind.Monster
|
||||
&& direction == RetailSelectionDirection.Closest
|
||||
? FindClosestHostileMonster()?.ServerGuid
|
||||
: null;
|
||||
uint? FindLastAttacker() => null;
|
||||
bool IsUseable(uint serverGuid);
|
||||
bool IsPickupable(uint serverGuid);
|
||||
bool IsWieldedByPlayer(uint serverGuid);
|
||||
|
|
@ -111,6 +140,9 @@ internal sealed class WorldSelectionQuery
|
|||
private readonly Func<uint, WorldEntity, (float Radius, float Height)> _setupCylinder;
|
||||
private readonly Func<uint, (Vector3 Origin, float Radius)?> _selectionSphere;
|
||||
private readonly Func<uint, Matrix4x4?> _childRootPose;
|
||||
private readonly Func<uint, bool> _hasOpenedCorpse;
|
||||
private readonly Func<CombatMode> _combatMode;
|
||||
private readonly Func<uint, bool> _isFellow;
|
||||
|
||||
public WorldSelectionQuery(
|
||||
LiveEntityRuntime liveEntities,
|
||||
|
|
@ -122,7 +154,10 @@ internal sealed class WorldSelectionQuery
|
|||
Func<PlayerInteractionPose?> playerPose,
|
||||
Func<uint, WorldEntity, (float Radius, float Height)> setupCylinder,
|
||||
Func<uint, (Vector3 Origin, float Radius)?> selectionSphere,
|
||||
Func<uint, Matrix4x4?> childRootPose)
|
||||
Func<uint, Matrix4x4?> childRootPose,
|
||||
Func<uint, bool>? hasOpenedCorpse = null,
|
||||
Func<CombatMode>? combatMode = null,
|
||||
Func<uint, bool>? isFellow = null)
|
||||
{
|
||||
_liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities));
|
||||
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
|
||||
|
|
@ -134,8 +169,13 @@ internal sealed class WorldSelectionQuery
|
|||
_setupCylinder = setupCylinder ?? throw new ArgumentNullException(nameof(setupCylinder));
|
||||
_selectionSphere = selectionSphere ?? throw new ArgumentNullException(nameof(selectionSphere));
|
||||
_childRootPose = childRootPose ?? throw new ArgumentNullException(nameof(childRootPose));
|
||||
_hasOpenedCorpse = hasOpenedCorpse ?? (_ => false);
|
||||
_combatMode = combatMode ?? (() => CombatMode.NonCombat);
|
||||
_isFellow = isFellow ?? (_ => false);
|
||||
}
|
||||
|
||||
public uint PlayerGuid => _playerGuid();
|
||||
|
||||
public uint? PickAtCursor(bool includeSelf)
|
||||
{
|
||||
Vector2 cursor = _cursor();
|
||||
|
|
@ -293,6 +333,183 @@ internal sealed class WorldSelectionQuery
|
|||
return best;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Port of retail <c>CPlayerSystem::SelectNext @ 0x0055F9A0</c>. The
|
||||
/// ordering scalar is the retail player-space horizontal distance plus
|
||||
/// <c>1.2 * abs(z)</c>; the object id breaks exact-distance ties through
|
||||
/// <c>CPlayerSystem::Farther @ 0x0055D830</c>. Previous/next wrap exactly
|
||||
/// as the paired calls in <c>CPlayerSystem::OnAction @ 0x00561890</c>.
|
||||
/// </summary>
|
||||
public uint? FindSelectionTarget(
|
||||
RetailSelectionKind kind,
|
||||
RetailSelectionDirection direction,
|
||||
uint? anchor,
|
||||
bool excludeOwnedByPlayer = false)
|
||||
{
|
||||
uint playerGuid = _playerGuid();
|
||||
if (!_liveEntities.TryGetWorldEntity(playerGuid, out WorldEntity player))
|
||||
return null;
|
||||
|
||||
float radarRadius = IsOutdoorCell(player.VisibilityCellId)
|
||||
? RetailRadar.OutdoorRangeMeters
|
||||
: RetailRadar.IndoorRangeMeters;
|
||||
var candidates = new List<(uint Guid, float Order)>();
|
||||
foreach (LiveEntityRecord record in _liveEntities.VisibleRecords)
|
||||
{
|
||||
uint guid = record.ServerGuid;
|
||||
if (guid == 0u
|
||||
|| guid == playerGuid
|
||||
|| record.WorldEntity is not { } entity
|
||||
|| _objects.Get(guid) is not { } obj
|
||||
|| (excludeOwnedByPlayer
|
||||
&& _objects.IsOwnedByObject(guid, playerGuid)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
float order = SelectionOrder(player, entity);
|
||||
if (order > radarRadius
|
||||
|| !MatchesSelectionKind(kind, guid, obj, record.FinalPhysicsState))
|
||||
continue;
|
||||
candidates.Add((guid, order));
|
||||
}
|
||||
|
||||
if (candidates.Count == 0)
|
||||
return null;
|
||||
candidates.Sort(static (left, right) =>
|
||||
{
|
||||
int distance = left.Order.CompareTo(right.Order);
|
||||
return distance != 0 ? distance : left.Guid.CompareTo(right.Guid);
|
||||
});
|
||||
|
||||
if (direction == RetailSelectionDirection.Closest)
|
||||
return candidates[0].Guid;
|
||||
|
||||
(float Order, uint Guid)? anchorKey = null;
|
||||
if (anchor is { } anchorGuid
|
||||
&& _liveEntities.TryGetWorldEntity(anchorGuid, out WorldEntity anchorEntity))
|
||||
{
|
||||
anchorKey = (SelectionOrder(player, anchorEntity), anchorGuid);
|
||||
}
|
||||
|
||||
if (anchorKey is null)
|
||||
{
|
||||
return direction == RetailSelectionDirection.Previous
|
||||
? candidates[^1].Guid
|
||||
: candidates[0].Guid;
|
||||
}
|
||||
|
||||
if (direction == RetailSelectionDirection.Next)
|
||||
{
|
||||
foreach ((uint guid, float order) in candidates)
|
||||
{
|
||||
if (CompareSelectionKey(order, guid, anchorKey.Value.Order, anchorKey.Value.Guid) > 0)
|
||||
return guid;
|
||||
}
|
||||
return candidates[0].Guid;
|
||||
}
|
||||
|
||||
for (int i = candidates.Count - 1; i >= 0; i--)
|
||||
{
|
||||
(uint guid, float order) = candidates[i];
|
||||
if (CompareSelectionKey(order, guid, anchorKey.Value.Order, anchorKey.Value.Guid) < 0)
|
||||
return guid;
|
||||
}
|
||||
return candidates[^1].Guid;
|
||||
}
|
||||
|
||||
public uint? FindLastAttacker()
|
||||
{
|
||||
uint playerGuid = _playerGuid();
|
||||
uint attacker = 0u;
|
||||
if (_objects.Get(playerGuid) is not { } playerObject
|
||||
|| !playerObject.Properties.InstanceIds.TryGetValue(
|
||||
(uint)PropertyInstanceId.CurrentAttacker,
|
||||
out attacker)
|
||||
|| attacker == 0u
|
||||
|| !_liveEntities.TryGetWorldEntity(playerGuid, out WorldEntity player)
|
||||
|| !_liveEntities.TryGetWorldEntity(attacker, out WorldEntity target))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
float radarRadius = IsOutdoorCell(player.VisibilityCellId)
|
||||
? RetailRadar.OutdoorRangeMeters
|
||||
: RetailRadar.IndoorRangeMeters;
|
||||
return SelectionOrder(player, target) <= radarRadius ? attacker : null;
|
||||
}
|
||||
|
||||
private bool MatchesSelectionKind(
|
||||
RetailSelectionKind kind,
|
||||
uint guid,
|
||||
ClientObject obj,
|
||||
PhysicsStateFlags physicsState)
|
||||
{
|
||||
bool showableOnRadar = obj.RadarBehavior is { } behavior
|
||||
&& RetailRadar.IsShowable((RadarBehavior)behavior, hasPhysicsObject: true);
|
||||
PublicWeenieFlags flags = (PublicWeenieFlags)(obj.PublicWeenieBitfield ?? 0u);
|
||||
bool isFellow = _isFellow(guid);
|
||||
bool isCombatCompass = _combatMode() is CombatMode.Melee or CombatMode.Missile;
|
||||
bool isSpecialCompassObject = (flags
|
||||
& (PublicWeenieFlags.Lifestone
|
||||
| PublicWeenieFlags.Portal
|
||||
| PublicWeenieFlags.Bindstone)) != 0;
|
||||
|
||||
// The common tail of CPlayerSystem::SelectNext rejects every object
|
||||
// currently inside a container, every cloaked physics object, and a
|
||||
// PWD carrying the reserved sign bit, independent of selection kind.
|
||||
if (obj.ContainerId != 0u
|
||||
|| (physicsState & PhysicsStateFlags.Cloaked) != 0
|
||||
|| (((uint)flags & 0x8000_0000u) != 0))
|
||||
return false;
|
||||
|
||||
return kind switch
|
||||
{
|
||||
RetailSelectionKind.Item =>
|
||||
obj.RadarBehavior is null or 0
|
||||
|| isSpecialCompassObject,
|
||||
RetailSelectionKind.CompassItem =>
|
||||
(isSpecialCompassObject || showableOnRadar)
|
||||
&& (!isCombatCompass
|
||||
|| (IsAttackableTarget(guid)
|
||||
&& !isFellow
|
||||
&& (flags & PublicWeenieFlags.Vendor) == 0
|
||||
&& (physicsState & PhysicsStateFlags.ReportAsEnvironment) == 0)),
|
||||
RetailSelectionKind.Monster =>
|
||||
showableOnRadar
|
||||
&& IsAttackableTarget(guid)
|
||||
&& !isFellow
|
||||
&& (flags & PublicWeenieFlags.Vendor) == 0,
|
||||
RetailSelectionKind.Player =>
|
||||
showableOnRadar && (flags & PublicWeenieFlags.Player) != 0,
|
||||
RetailSelectionKind.UnopenedCorpse =>
|
||||
(flags & PublicWeenieFlags.Corpse) != 0
|
||||
&& !_hasOpenedCorpse(guid),
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
private static float SelectionOrder(WorldEntity player, WorldEntity target)
|
||||
{
|
||||
Vector3 delta = target.Position - player.Position;
|
||||
Vector3 local = Vector3.Transform(delta, Quaternion.Inverse(player.Rotation));
|
||||
return MathF.Sqrt(local.X * local.X + local.Y * local.Y)
|
||||
+ MathF.Abs(local.Z) * 1.2f;
|
||||
}
|
||||
|
||||
private static int CompareSelectionKey(
|
||||
float leftOrder,
|
||||
uint leftGuid,
|
||||
float rightOrder,
|
||||
uint rightGuid)
|
||||
{
|
||||
int order = leftOrder.CompareTo(rightOrder);
|
||||
return order != 0 ? order : leftGuid.CompareTo(rightGuid);
|
||||
}
|
||||
|
||||
private static bool IsOutdoorCell(uint? cellId)
|
||||
=> cellId is null || (cellId.Value & 0xFFFFu) < 0x100u;
|
||||
|
||||
/// <summary>
|
||||
/// #298 follow-up: retail <c>ClientCombatSystem::UpdateTargetTracking
|
||||
/// @ 0x0056A950</c> (pc:375691-375696) gates <c>CameraSet::TrackTarget</c>
|
||||
|
|
|
|||
81
src/AcDream.App/Net/DatChatPoseCatalog.cs
Normal file
81
src/AcDream.App/Net/DatChatPoseCatalog.cs
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
using AcDream.Runtime.Chat;
|
||||
using AcDream.Content;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DatMotionCommand = DatReaderWriter.Enums.MotionCommand;
|
||||
|
||||
namespace AcDream.App.Net;
|
||||
|
||||
/// <summary>
|
||||
/// Immutable projection of retail's portal-DAT ChatPoseTable (0x0E000007).
|
||||
/// Command lookup is case-insensitive, matching
|
||||
/// <c>ChatPoseTable::InqChatPoseCommand @ 0x00570AD0</c>.
|
||||
/// </summary>
|
||||
internal sealed class DatChatPoseCatalog
|
||||
{
|
||||
private const uint ChatPoseTableId = 0x0E000007u;
|
||||
private readonly IReadOnlyDictionary<string, RetailChatPose> _poses;
|
||||
|
||||
private DatChatPoseCatalog(
|
||||
IReadOnlyDictionary<string, RetailChatPose> poses) =>
|
||||
_poses = poses;
|
||||
|
||||
public static DatChatPoseCatalog Load(IDatReaderWriter dats, object datLock)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(dats);
|
||||
ArgumentNullException.ThrowIfNull(datLock);
|
||||
lock (datLock)
|
||||
{
|
||||
ChatPoseTable? table = dats.Get<ChatPoseTable>(ChatPoseTableId);
|
||||
if (table is null)
|
||||
return new DatChatPoseCatalog(
|
||||
new Dictionary<string, RetailChatPose>(
|
||||
StringComparer.OrdinalIgnoreCase));
|
||||
|
||||
var emotes = new Dictionary<string, (string Self, string Others)>(
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var pair in table.ChatEmotes)
|
||||
{
|
||||
emotes[pair.Key.Value] = (
|
||||
pair.Value.MyEmote.Value,
|
||||
pair.Value.OtherEmote.Value);
|
||||
}
|
||||
|
||||
var poses = new Dictionary<string, RetailChatPose>(
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var pair in table.ChatPoses)
|
||||
{
|
||||
string command = pair.Key.Value;
|
||||
string motionName = pair.Value.Value;
|
||||
if (string.IsNullOrEmpty(command)
|
||||
|| !Enum.TryParse(
|
||||
motionName,
|
||||
ignoreCase: true,
|
||||
out DatMotionCommand motion))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
emotes.TryGetValue(motionName, out var text);
|
||||
poses[command] = new RetailChatPose(
|
||||
(uint)motion,
|
||||
text.Self ?? string.Empty,
|
||||
text.Others ?? string.Empty);
|
||||
}
|
||||
return new DatChatPoseCatalog(poses);
|
||||
}
|
||||
}
|
||||
|
||||
public RetailChatPose? Resolve(string command, bool male)
|
||||
{
|
||||
if (!_poses.TryGetValue(command, out RetailChatPose pose))
|
||||
return null;
|
||||
string possessive = male ? "his" : "her";
|
||||
return pose with
|
||||
{
|
||||
OthersText = pose.OthersText.Replace(
|
||||
"%p",
|
||||
possessive,
|
||||
StringComparison.Ordinal),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -74,7 +74,10 @@ internal sealed record LiveSessionCommandBindings(
|
|||
Action<uint> SendAllegianceKick,
|
||||
Action<string> SendAllegianceInfoRequest,
|
||||
Action<bool> SendAllegianceUpdateRequest,
|
||||
Action<string>? Log = null);
|
||||
Action<string>? Log = null,
|
||||
Func<string, RetailChatPose?>? ResolvePose = null,
|
||||
Action<uint>? ExecuteMotion = null,
|
||||
Action<string>? SendSoulEmote = null);
|
||||
|
||||
internal readonly record struct AddShortcutRuntimeCmd(ShortcutEntry Entry);
|
||||
internal readonly record struct RemoveShortcutRuntimeCmd(uint Index);
|
||||
|
|
@ -185,7 +188,10 @@ internal sealed class LiveSessionCommandRouter : ILiveSessionCommandRouting
|
|||
bindings.SendTell,
|
||||
bindings.SendChannel,
|
||||
bindings.SendTurbineChat,
|
||||
bindings.Log));
|
||||
bindings.Log,
|
||||
bindings.ResolvePose,
|
||||
bindings.ExecuteMotion,
|
||||
bindings.SendSoulEmote));
|
||||
// Campaign CH slice CH4 (2026-08-09): the 22 unregistered
|
||||
// ChannelSystem::GetChannelID fallback tags — bypasses
|
||||
// ChatChannelKind/ChannelResolver entirely and sends the raw
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ internal sealed record LiveSessionInteractionRuntime(
|
|||
|
||||
internal sealed record LiveSessionWorldRuntime(
|
||||
IDatReaderWriter Dats,
|
||||
object DatLock,
|
||||
// Logout-audio round (2026-08-17): null only when audio is disabled
|
||||
// (ACDREAM_NO_AUDIO / init failure) — the reset step and entered-world
|
||||
// resume both no-op then.
|
||||
|
|
@ -114,6 +115,7 @@ internal sealed class LiveSessionRuntimeFactory
|
|||
private readonly IReadOnlyList<string> _loginCommands;
|
||||
private readonly TimeSpan _loginCommandDelay;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly DatChatPoseCatalog _chatPoses;
|
||||
|
||||
/// <summary>
|
||||
/// Where a bare <c>@log</c> filename lands. See <see cref="ChatSessionLog"/>
|
||||
|
|
@ -162,6 +164,7 @@ internal sealed class LiveSessionRuntimeFactory
|
|||
_loginCommands = loginCommands is null ? [] : [.. loginCommands];
|
||||
_loginCommandDelay = TimeSpan.FromMilliseconds(loginCommandDelayMs);
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_chatPoses = DatChatPoseCatalog.Load(_world.Dats, _world.DatLock);
|
||||
// C3c-F1: stat recomputes route through the Runtime movement owner's
|
||||
// typed application seam; App keeps zero direct controller mutations.
|
||||
_movementStats = new LiveMovementStatsApplier(
|
||||
|
|
@ -217,6 +220,15 @@ internal sealed class LiveSessionRuntimeFactory
|
|||
RestoreLayout: () =>
|
||||
{
|
||||
_ui.RetailUi?.RestoreLayout();
|
||||
// The retained inventory controller exists before the
|
||||
// character object graph is complete. Rebuild its open
|
||||
// container once EnteredWorld makes that graph
|
||||
// authoritative, otherwise the already-open main pack can
|
||||
// keep the empty construction-time cells until the user
|
||||
// switches packs. Redress the private doll at the same
|
||||
// character-complete edge.
|
||||
_ui.RetailUi?.InventoryPanelController?.Populate();
|
||||
_ui.Paperdoll?.MarkDirty();
|
||||
// MUST-FIX 3 re-fix (FA4 re-review REOPEN): re-declare a
|
||||
// still-open Fellowship page's 0x00A6 now we are in world —
|
||||
// RestoreLayout is the post-world UI-restore moment, and
|
||||
|
|
@ -786,7 +798,14 @@ internal sealed class LiveSessionRuntimeFactory
|
|||
SendAllegianceKick: session.SendAllegianceKick,
|
||||
SendAllegianceInfoRequest: session.SendAllegianceInfoRequest,
|
||||
SendAllegianceUpdateRequest: session.SendAllegianceUpdateRequest,
|
||||
Log: _log);
|
||||
Log: _log,
|
||||
ResolvePose: command => _chatPoses.Resolve(
|
||||
command,
|
||||
male: _domain.EntityObjects.Objects
|
||||
.Get(_player.Identity.ServerGuid)?
|
||||
.Properties.GetInt(0x71u) == 1),
|
||||
ExecuteMotion: motion => _player.Controller.ExecuteMotion(motion),
|
||||
SendSoulEmote: session.SendSoulEmote);
|
||||
}
|
||||
|
||||
private static double ClientTimerNow() =>
|
||||
|
|
|
|||
|
|
@ -85,6 +85,28 @@ internal sealed class CameraFrameController : ICameraFramePhase
|
|||
retail.AdjustPitch(+adjustment * 0.02f);
|
||||
if (input.Lower)
|
||||
retail.AdjustPitch(-adjustment * 0.02f);
|
||||
if (input.RotateLeft)
|
||||
retail.YawOffset += adjustment * 0.02f;
|
||||
if (input.RotateRight)
|
||||
retail.YawOffset -= adjustment * 0.02f;
|
||||
}
|
||||
else
|
||||
{
|
||||
ChaseCameraAdjustmentInput input = _input.CaptureChaseAdjustment();
|
||||
float adjustment = CameraDiagnostics.CameraAdjustmentSpeed
|
||||
* timing.SimulationDeltaSecondsSingle;
|
||||
if (input.ZoomIn)
|
||||
legacy.AdjustDistance(-adjustment);
|
||||
if (input.ZoomOut)
|
||||
legacy.AdjustDistance(+adjustment);
|
||||
if (input.Raise)
|
||||
legacy.AdjustPitch(+adjustment * 0.02f);
|
||||
if (input.Lower)
|
||||
legacy.AdjustPitch(-adjustment * 0.02f);
|
||||
if (input.RotateLeft)
|
||||
legacy.YawOffset += adjustment * 0.02f;
|
||||
if (input.RotateRight)
|
||||
legacy.YawOffset -= adjustment * 0.02f;
|
||||
}
|
||||
|
||||
if (!_localFrame.TryGetPresentationAfterNetwork(out var playerFrame))
|
||||
|
|
|
|||
|
|
@ -10,6 +10,21 @@ namespace AcDream.App.Rendering;
|
|||
/// </summary>
|
||||
public sealed class ChaseCamera : ICamera
|
||||
{
|
||||
private const float RetailDefaultBack = 2.5f;
|
||||
private const float RetailDefaultUp = 0.75f;
|
||||
private bool _lookingDown;
|
||||
private bool _mapMode;
|
||||
private bool _inHead;
|
||||
private bool _savedInHead;
|
||||
private float _savedDistance;
|
||||
private float _savedPitch;
|
||||
private float _savedYawOffset;
|
||||
private Vector3? _targetDirectionLocal;
|
||||
private Vector3? _savedTargetDirectionLocal;
|
||||
|
||||
public bool IsLookingDown => _lookingDown;
|
||||
public bool IsMapMode => _mapMode;
|
||||
public bool IsInHead => _inHead;
|
||||
public Vector3 Position { get; private set; }
|
||||
public float Aspect { get; set; } = 16f / 9f;
|
||||
// #389: smartbox law at the 16:9 default aspect — see RetailFieldOfView.
|
||||
|
|
@ -108,10 +123,35 @@ public sealed class ChaseCamera : ICamera
|
|||
float horizontalDist = Distance * MathF.Cos(Pitch);
|
||||
float verticalDist = Distance * MathF.Sin(Pitch);
|
||||
|
||||
Position = new Vector3(
|
||||
playerPosition.X - forwardX * horizontalDist,
|
||||
playerPosition.Y - forwardY * horizontalDist,
|
||||
_trackedZ + EyeHeight + verticalDist); // ← uses tracked Z (pinned to ground while airborne)
|
||||
if (_inHead)
|
||||
{
|
||||
Vector3 forward = new(MathF.Cos(playerYaw), MathF.Sin(playerYaw), 0f);
|
||||
Position = new Vector3(
|
||||
playerPosition.X,
|
||||
playerPosition.Y,
|
||||
_trackedZ + EyeHeight) + forward * 0.18f;
|
||||
_lookAt = Position + forward;
|
||||
}
|
||||
else if (_targetDirectionLocal is { } localDirection)
|
||||
{
|
||||
Vector3 pivot = new(playerPosition.X, playerPosition.Y, _trackedZ + EyeHeight);
|
||||
var directedPose = RetailChaseCamera.ComputeTargetDirectionPose(
|
||||
pivot,
|
||||
new Vector3(MathF.Cos(playerYaw), MathF.Sin(playerYaw), 0f),
|
||||
Distance,
|
||||
Pitch,
|
||||
localDirection);
|
||||
Position = directedPose.eye;
|
||||
Vector3 direction = directedPose.forward;
|
||||
_lookAt = Position + direction;
|
||||
}
|
||||
else
|
||||
{
|
||||
Position = new Vector3(
|
||||
playerPosition.X - forwardX * horizontalDist,
|
||||
playerPosition.Y - forwardY * horizontalDist,
|
||||
_trackedZ + EyeHeight + verticalDist); // ← uses tracked Z (pinned to ground while airborne)
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -119,6 +159,8 @@ public sealed class ChaseCamera : ICamera
|
|||
/// </summary>
|
||||
public void AdjustPitch(float delta)
|
||||
{
|
||||
ExitLookDownForAdjustment();
|
||||
ExitInHeadForAdjustment();
|
||||
Pitch = Math.Clamp(Pitch + delta, PitchMin, PitchMax);
|
||||
}
|
||||
|
||||
|
|
@ -127,6 +169,101 @@ public sealed class ChaseCamera : ICamera
|
|||
/// </summary>
|
||||
public void AdjustDistance(float delta)
|
||||
{
|
||||
ExitLookDownForAdjustment();
|
||||
ExitInHeadForAdjustment();
|
||||
Distance = Math.Clamp(Distance + delta, DistanceMin, DistanceMax);
|
||||
}
|
||||
|
||||
public void SetRetailDefaultView()
|
||||
{
|
||||
_lookingDown = false;
|
||||
_mapMode = false;
|
||||
_inHead = false;
|
||||
_targetDirectionLocal = null;
|
||||
YawOffset = 0f;
|
||||
EyeHeight = 1.5f;
|
||||
SetViewerOffset(RetailDefaultBack, RetailDefaultUp);
|
||||
}
|
||||
|
||||
public void SetRetailFirstPersonView()
|
||||
{
|
||||
_lookingDown = false;
|
||||
_mapMode = false;
|
||||
_inHead = true;
|
||||
_targetDirectionLocal = null;
|
||||
YawOffset = 0f;
|
||||
Distance = 0.18f;
|
||||
Pitch = 0f;
|
||||
}
|
||||
|
||||
public void ToggleRetailLookDownView()
|
||||
{
|
||||
if (_lookingDown)
|
||||
{
|
||||
RestoreLookDownView();
|
||||
return;
|
||||
}
|
||||
SaveLookDownView();
|
||||
_lookingDown = true;
|
||||
_mapMode = false;
|
||||
_inHead = false;
|
||||
_targetDirectionLocal = new Vector3(0f, 0.5f, -1.8f);
|
||||
SetViewerOffset(2f, RetailDefaultUp);
|
||||
}
|
||||
|
||||
public void ToggleRetailMapModeView()
|
||||
{
|
||||
if (_mapMode)
|
||||
{
|
||||
RestoreLookDownView();
|
||||
return;
|
||||
}
|
||||
if (!_lookingDown)
|
||||
SaveLookDownView();
|
||||
_lookingDown = true;
|
||||
_mapMode = true;
|
||||
_inHead = false;
|
||||
_targetDirectionLocal = new Vector3(0f, 0.5f, -1.8f);
|
||||
SetViewerOffset(450f, RetailDefaultUp);
|
||||
}
|
||||
|
||||
private void SaveLookDownView()
|
||||
{
|
||||
_savedDistance = Distance;
|
||||
_savedPitch = Pitch;
|
||||
_savedYawOffset = YawOffset;
|
||||
_savedTargetDirectionLocal = _targetDirectionLocal;
|
||||
_savedInHead = _inHead;
|
||||
}
|
||||
|
||||
private void RestoreLookDownView()
|
||||
{
|
||||
Distance = _savedDistance;
|
||||
Pitch = _savedPitch;
|
||||
YawOffset = _savedYawOffset;
|
||||
_targetDirectionLocal = _savedTargetDirectionLocal;
|
||||
_inHead = _savedInHead;
|
||||
_lookingDown = false;
|
||||
_mapMode = false;
|
||||
}
|
||||
|
||||
private void ExitLookDownForAdjustment()
|
||||
{
|
||||
if (_lookingDown)
|
||||
RestoreLookDownView();
|
||||
}
|
||||
|
||||
private void ExitInHeadForAdjustment()
|
||||
{
|
||||
if (!_inHead)
|
||||
return;
|
||||
_inHead = false;
|
||||
Distance = DistanceMin;
|
||||
}
|
||||
|
||||
private void SetViewerOffset(float back, float up)
|
||||
{
|
||||
Distance = MathF.Sqrt(back * back + up * up);
|
||||
Pitch = MathF.Atan2(up, back);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
using AcDream.Core.Plugins;
|
||||
using AcDream.App.Composition;
|
||||
using AcDream.App.Input;
|
||||
using AcDream.App.Physics;
|
||||
using AcDream.App.Rendering.Gpu;
|
||||
using AcDream.App.Rendering.Scene;
|
||||
|
|
@ -17,6 +18,7 @@ using DatReaderWriter;
|
|||
using Silk.NET.Input;
|
||||
using Silk.NET.Maths;
|
||||
using Silk.NET.Windowing;
|
||||
using AcDream.UI.Abstractions.Input;
|
||||
|
||||
namespace AcDream.App.Rendering;
|
||||
|
||||
|
|
@ -599,14 +601,18 @@ public sealed class GameWindow :
|
|||
// startup — no other call to RetailDefaults() / AcdreamCurrentDefaults()
|
||||
// should land in the GameWindow construction path.
|
||||
private readonly AcDream.UI.Abstractions.Input.KeyBindings _keyBindings;
|
||||
private bool _keyBindingsPersisted;
|
||||
private readonly GraphicalHostPlatformServices _platformServices;
|
||||
private readonly ApplicationPathSet _applicationPaths;
|
||||
|
||||
private static AcDream.UI.Abstractions.Input.KeyBindings LoadStartupKeyBindings(
|
||||
string path)
|
||||
{
|
||||
var bindings = AcDream.UI.Abstractions.Input.KeyBindings.LoadOrDefault(path);
|
||||
Console.WriteLine($"keybinds: loaded {bindings.All.Count} bindings from {path}");
|
||||
var bindings = AcDream.App.Input.RetailKeymapProfileStore.LoadActiveOrJson(
|
||||
path, out string profileName);
|
||||
Console.WriteLine(
|
||||
$"keybinds: loaded {bindings.All.Count} bindings; active retail profile "
|
||||
+ $"'{profileName}', JSON mirror {path}");
|
||||
return bindings;
|
||||
}
|
||||
|
||||
|
|
@ -1522,7 +1528,8 @@ public sealed class GameWindow :
|
|||
hostInputCamera.GpuFrameLifetime,
|
||||
() => WorldTime.CurrentCalendar,
|
||||
settingsDevTools.RenderPacks,
|
||||
_renderPackDiagnostics.CaptureDiagnostics),
|
||||
_renderPackDiagnostics.CaptureDiagnostics,
|
||||
_applicationPaths.ScreenshotsDirectory),
|
||||
_retailUiLease,
|
||||
this).Compose(
|
||||
platformResult,
|
||||
|
|
@ -1569,6 +1576,7 @@ public sealed class GameWindow :
|
|||
_cellVisibility,
|
||||
_liveWorldOrigin,
|
||||
_localPlayerIdentity,
|
||||
_chaseCameraInput,
|
||||
_pointerPosition,
|
||||
_playerApproachCompletions,
|
||||
_renderResourceLifetime,
|
||||
|
|
@ -1821,6 +1829,7 @@ public sealed class GameWindow :
|
|||
|
||||
if (!_lifetime.HasShutdownRoots)
|
||||
{
|
||||
PersistKeyBindingsAtShutdown();
|
||||
// Campaign LA slice LA1: capture BEFORE the shutdown roots run —
|
||||
// by the time teardown completes, IsInWorld is always false
|
||||
// regardless of whether a real session was ever connected.
|
||||
|
|
@ -1861,6 +1870,33 @@ public sealed class GameWindow :
|
|||
ReportExited(report);
|
||||
}
|
||||
|
||||
private void PersistKeyBindingsAtShutdown()
|
||||
{
|
||||
// Construction-only tests and failed starts never create the input
|
||||
// dispatcher. They must not materialize a profile in the real user's
|
||||
// Documents folder merely because the half-built window is disposed.
|
||||
if (_keyBindingsPersisted || _inputDispatcher is null) return;
|
||||
_keyBindingsPersisted = true;
|
||||
try
|
||||
{
|
||||
KeyBindings current = _inputDispatcher.Bindings;
|
||||
var profiles = new AcDream.App.Input.RetailKeymapProfileStore(
|
||||
_applicationPaths.KeyBindingsFile);
|
||||
RetailKeymapSaveResult saved = profiles.SaveActive(current);
|
||||
if (saved.Status != RetailKeymapSaveStatus.Saved)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"keymap: shutdown save failed ({saved.Status}): {saved.Error}");
|
||||
return;
|
||||
}
|
||||
current.SaveToFile(_applicationPaths.KeyBindingsFile);
|
||||
}
|
||||
catch (Exception failure)
|
||||
{
|
||||
Console.WriteLine($"keymap: shutdown persistence failed: {failure.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes the ONE terminal "exited" status event for this session
|
||||
/// (fix #406). A resource-shutdown transaction can converge cleanly
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ internal interface IPaperdollDollRenderer
|
|||
{
|
||||
void SetDoll(WorldEntity? doll);
|
||||
|
||||
void Prepare();
|
||||
|
||||
uint Render(int width, int height);
|
||||
}
|
||||
|
||||
|
|
@ -73,9 +75,6 @@ internal sealed class PaperdollFramePresenter : IPrivateEntityViewportFrame
|
|||
|
||||
public void Render()
|
||||
{
|
||||
if (!_view.TryGetVisibleSize(out int width, out int height))
|
||||
return;
|
||||
|
||||
if (_dirty)
|
||||
{
|
||||
if (_factory.TryBuild(out WorldEntity? doll))
|
||||
|
|
@ -101,6 +100,11 @@ internal sealed class PaperdollFramePresenter : IPrivateEntityViewportFrame
|
|||
}
|
||||
}
|
||||
|
||||
_renderer.Prepare();
|
||||
|
||||
if (!_view.TryGetVisibleSize(out int width, out int height))
|
||||
return;
|
||||
|
||||
_view.SetTextureHandle(_renderer.Render(width, height));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -42,6 +42,8 @@ public sealed class PaperdollViewportRenderer :
|
|||
|
||||
public void SetDoll(WorldEntity? doll) => _renderer.SetEntity(doll);
|
||||
|
||||
public void Prepare() => _renderer.Prepare();
|
||||
|
||||
public uint Render(int width, int height) =>
|
||||
_renderer.Render(width, height);
|
||||
|
||||
|
|
|
|||
|
|
@ -94,6 +94,11 @@ internal sealed class PrivateEntityViewportRenderer :
|
|||
/// feature does not exist for them, not just "unused".</summary>
|
||||
private readonly EntitySlot? _backdropSlot;
|
||||
|
||||
// One stable sampled texture-table slot is part of the retained viewport's
|
||||
// presentation contract. Rotating the slot with the Vulkan flight index
|
||||
// made the UI sample a freshly-created/cleared sibling after world reveal.
|
||||
// The frame submission order already protects this target's write -> sample
|
||||
// transition; keep its identity stable until resize or disposal.
|
||||
private IGpuRenderTarget? _target;
|
||||
private IGpuSampler? _sampler;
|
||||
private GpuTextureSlot _slot = GpuTextureSlot.Unassigned;
|
||||
|
|
@ -170,6 +175,29 @@ internal sealed class PrivateEntityViewportRenderer :
|
|||
|
||||
public void SetEntity(WorldEntity? entity) => _mainSlot.Set(entity);
|
||||
|
||||
/// <summary>
|
||||
/// Advances the private entity's mesh and texture-composite readiness
|
||||
/// without allocating or clearing a render target. Paperdoll uses this
|
||||
/// while its tab is hidden so first-open work is already resident.
|
||||
/// </summary>
|
||||
public bool Prepare()
|
||||
{
|
||||
if (!_mainSlot.PrepareForDraw()
|
||||
|| !(_backdropSlot?.PrepareForDraw() ?? true))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
WorldEntity? entity = _mainSlot.Entity;
|
||||
if (entity is null || entity.MeshRefs.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
IReadOnlyList<WorldEntity> entities = BuildDrawEntities(
|
||||
_backdropSlot?.Entity,
|
||||
entity);
|
||||
return _dispatcher.PreparePrivateEntityResources(entities);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets or clears the environment backdrop entity drawn BEHIND the main
|
||||
/// entity — GF-7/GF-14's fix, retail's <c>gmCG3DView::m_pbgObject</c>. Only
|
||||
|
|
@ -219,6 +247,16 @@ internal sealed class PrivateEntityViewportRenderer :
|
|||
if (entity is null || entity.MeshRefs.Count == 0 || width <= 0 || height <= 0)
|
||||
return 0u;
|
||||
|
||||
IReadOnlyList<WorldEntity> drawEntities = BuildDrawEntities(
|
||||
_backdropSlot?.Entity,
|
||||
entity);
|
||||
if (!_dispatcher.PreparePrivateEntityResources(drawEntities))
|
||||
{
|
||||
return _hasRenderedScene && _slot.IsAssigned
|
||||
? UiTextureTableHandle.FromSlot(_slot)
|
||||
: 0u;
|
||||
}
|
||||
|
||||
EnsureRenderTarget(width, height);
|
||||
if (_target is null)
|
||||
return 0u;
|
||||
|
|
@ -254,7 +292,6 @@ internal sealed class PrivateEntityViewportRenderer :
|
|||
|
||||
UploadCreatureLight();
|
||||
|
||||
IReadOnlyList<WorldEntity> drawEntities = BuildDrawEntities(_backdropSlot?.Entity, entity);
|
||||
var entries =
|
||||
new (uint, Vector3, Vector3, IReadOnlyList<WorldEntity>,
|
||||
IReadOnlyDictionary<uint, WorldEntity>?)[]
|
||||
|
|
|
|||
|
|
@ -29,6 +29,12 @@ namespace AcDream.App.Rendering;
|
|||
/// </summary>
|
||||
public sealed class RetailChaseCamera : ICamera
|
||||
{
|
||||
private const float RetailDefaultBack = 2.5f;
|
||||
private const float RetailDefaultUp = 0.75f;
|
||||
private const float RetailLookDownBack = 2f;
|
||||
private const float RetailMapBack = 450f;
|
||||
private const float RetailFirstPersonForward = 0.18f;
|
||||
|
||||
// ICamera surface.
|
||||
public Vector3 Position { get; private set; }
|
||||
|
||||
|
|
@ -75,6 +81,20 @@ public sealed class RetailChaseCamera : ICamera
|
|||
/// <summary>Height of look-at anchor above the player's feet (m). Retail default 1.5.</summary>
|
||||
public float PivotHeight { get; set; } = 1.5f;
|
||||
|
||||
private bool _lookingDown;
|
||||
private bool _mapMode;
|
||||
private bool _inHead;
|
||||
private bool _savedInHead;
|
||||
private float _savedDistance;
|
||||
private float _savedPitch;
|
||||
private float _savedYawOffset;
|
||||
private Vector3? _targetDirectionLocal;
|
||||
private Vector3? _savedTargetDirectionLocal;
|
||||
|
||||
public bool IsLookingDown => _lookingDown;
|
||||
public bool IsMapMode => _mapMode;
|
||||
public bool IsInHead => _inHead;
|
||||
|
||||
/// <summary>
|
||||
/// Optional spring-arm collision probe. When set (and
|
||||
/// <see cref="CameraDiagnostics.CollideCamera"/> is true), the damped eye
|
||||
|
|
@ -172,8 +192,13 @@ public sealed class RetailChaseCamera : ICamera
|
|||
// target supplies the frame heading. Without this local rotation, enabling
|
||||
// Keep in View snaps the camera behind the target and disables RMB orbit.
|
||||
float viewerYawOffset = trackedHeading.HasValue ? YawOffset : 0f;
|
||||
(Vector3 targetEye, Vector3 targetForward) = ComputeDesiredPose(
|
||||
pivotWorld, heading, Distance, Pitch, viewerYawOffset);
|
||||
(Vector3 targetEye, Vector3 targetForward) = _inHead
|
||||
? ComputeInHeadPose(pivotWorld, heading)
|
||||
: _targetDirectionLocal is { } localDirection
|
||||
? ComputeTargetDirectionPose(
|
||||
pivotWorld, heading, Distance, Pitch, localDirection)
|
||||
: ComputeDesiredPose(
|
||||
pivotWorld, heading, Distance, Pitch, viewerYawOffset);
|
||||
|
||||
// 5. Stateful sought position (#180). Retail CameraManager::UpdateCamera
|
||||
// (0x00456660) interpolates FROM THE CURRENT SWEPT VIEWER toward the
|
||||
|
|
@ -279,16 +304,120 @@ public sealed class RetailChaseCamera : ICamera
|
|||
/// <see cref="DistanceMin"/>..<see cref="DistanceMax"/>. Mirrors
|
||||
/// legacy <c>ChaseCamera.AdjustDistance</c>.
|
||||
/// </summary>
|
||||
public void AdjustDistance(float delta) =>
|
||||
public void AdjustDistance(float delta)
|
||||
{
|
||||
ExitLookDownForAdjustment();
|
||||
ExitInHeadForAdjustment();
|
||||
Distance = Math.Clamp(Distance + delta, DistanceMin, DistanceMax);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adjust the camera pitch by a delta (radians), clamped to
|
||||
/// <see cref="PitchMin"/>..<see cref="PitchMax"/>. Mirrors legacy
|
||||
/// <c>ChaseCamera.AdjustPitch</c>.
|
||||
/// </summary>
|
||||
public void AdjustPitch(float delta) =>
|
||||
public void AdjustPitch(float delta)
|
||||
{
|
||||
ExitLookDownForAdjustment();
|
||||
ExitInHeadForAdjustment();
|
||||
Pitch = Math.Clamp(Pitch + delta, PitchMin, PitchMax);
|
||||
}
|
||||
|
||||
public void SetRetailDefaultView()
|
||||
{
|
||||
_lookingDown = false;
|
||||
_mapMode = false;
|
||||
_inHead = false;
|
||||
_targetDirectionLocal = null;
|
||||
YawOffset = 0f;
|
||||
PivotHeight = 1.5f;
|
||||
SetViewerOffset(RetailDefaultBack, RetailDefaultUp);
|
||||
}
|
||||
|
||||
public void SetRetailFirstPersonView()
|
||||
{
|
||||
_lookingDown = false;
|
||||
_mapMode = false;
|
||||
_inHead = true;
|
||||
_targetDirectionLocal = null;
|
||||
YawOffset = 0f;
|
||||
Distance = RetailFirstPersonForward;
|
||||
Pitch = 0f;
|
||||
// Do not spend a transition frame inside the head/neck. Retail's
|
||||
// SetInHead installs the new viewer offset as one camera preset.
|
||||
_initialised = false;
|
||||
}
|
||||
|
||||
public void ToggleRetailLookDownView()
|
||||
{
|
||||
if (_lookingDown)
|
||||
{
|
||||
RestoreLookDownView();
|
||||
return;
|
||||
}
|
||||
SaveLookDownView();
|
||||
_lookingDown = true;
|
||||
_mapMode = false;
|
||||
_inHead = false;
|
||||
_targetDirectionLocal = new Vector3(0f, 0.5f, -1.8f);
|
||||
SetViewerOffset(RetailLookDownBack, RetailDefaultUp);
|
||||
}
|
||||
|
||||
public void ToggleRetailMapModeView()
|
||||
{
|
||||
if (_mapMode)
|
||||
{
|
||||
RestoreLookDownView();
|
||||
return;
|
||||
}
|
||||
if (!_lookingDown)
|
||||
SaveLookDownView();
|
||||
_lookingDown = true;
|
||||
_mapMode = true;
|
||||
_inHead = false;
|
||||
_targetDirectionLocal = new Vector3(0f, 0.5f, -1.8f);
|
||||
SetViewerOffset(RetailMapBack, RetailDefaultUp);
|
||||
}
|
||||
|
||||
private void SaveLookDownView()
|
||||
{
|
||||
_savedDistance = Distance;
|
||||
_savedPitch = Pitch;
|
||||
_savedYawOffset = YawOffset;
|
||||
_savedTargetDirectionLocal = _targetDirectionLocal;
|
||||
_savedInHead = _inHead;
|
||||
}
|
||||
|
||||
private void RestoreLookDownView()
|
||||
{
|
||||
Distance = _savedDistance;
|
||||
Pitch = _savedPitch;
|
||||
YawOffset = _savedYawOffset;
|
||||
_targetDirectionLocal = _savedTargetDirectionLocal;
|
||||
_inHead = _savedInHead;
|
||||
_lookingDown = false;
|
||||
_mapMode = false;
|
||||
}
|
||||
|
||||
private void ExitLookDownForAdjustment()
|
||||
{
|
||||
if (_lookingDown)
|
||||
RestoreLookDownView();
|
||||
}
|
||||
|
||||
private void ExitInHeadForAdjustment()
|
||||
{
|
||||
if (!_inHead)
|
||||
return;
|
||||
_inHead = false;
|
||||
Distance = DistanceMin;
|
||||
}
|
||||
|
||||
private void SetViewerOffset(float back, float up)
|
||||
{
|
||||
Distance = MathF.Sqrt(back * back + up * up);
|
||||
Pitch = MathF.Atan2(up, back);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Public entry point for the mouse-input low-pass filter. Calls
|
||||
|
|
@ -436,6 +565,47 @@ public sealed class RetailChaseCamera : ICamera
|
|||
return (eye, forward);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>CameraSet::SetInHead @ 0x00458CE0</c>: the target direction
|
||||
/// is local +Y and the viewer offset is local +Y * 0.18. It is not a
|
||||
/// negative chase boom looking back toward the player's neck.
|
||||
/// </summary>
|
||||
internal static (Vector3 eye, Vector3 forward) ComputeInHeadPose(
|
||||
Vector3 pivotWorld,
|
||||
Vector3 heading)
|
||||
{
|
||||
Vector3 forward = Vector3.Normalize(heading);
|
||||
return (pivotWorld + forward * RetailFirstPersonForward, forward);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Transform both retail <c>viewer_offset</c> and <c>target_direction</c>
|
||||
/// through the target frame. LookDown/MapMode do not merely point a
|
||||
/// horizontally-positioned camera toward the ground: the downward target
|
||||
/// direction pitches the frame whose -Y/+Z offset places the viewer. For
|
||||
/// MapMode's (0,-450,0.75) offset this puts the viewer high above and only
|
||||
/// slightly behind the character, matching CameraSet::SetMapMode.
|
||||
/// </summary>
|
||||
internal static (Vector3 eye, Vector3 forward) ComputeTargetDirectionPose(
|
||||
Vector3 pivotWorld,
|
||||
Vector3 heading,
|
||||
float distance,
|
||||
float pitch,
|
||||
Vector3 targetDirectionLocal)
|
||||
{
|
||||
var (frameForward, frameRight, frameUp) = BuildBasis(heading);
|
||||
Vector3 targetForward = Vector3.Normalize(
|
||||
frameForward * targetDirectionLocal.Y
|
||||
- frameRight * targetDirectionLocal.X
|
||||
+ frameUp * targetDirectionLocal.Z);
|
||||
var (_, _, targetUp) = BuildBasis(targetForward);
|
||||
|
||||
float back = distance * MathF.Cos(pitch);
|
||||
float up = distance * MathF.Sin(pitch);
|
||||
Vector3 eye = pivotWorld - targetForward * back + targetUp * up;
|
||||
return (eye, targetForward);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build an orthonormal basis with <c>forward = heading</c>. World
|
||||
/// up is <c>(0, 0, 1)</c>; if <c>heading</c> is near-parallel to it
|
||||
|
|
|
|||
|
|
@ -473,6 +473,7 @@ public sealed unsafe partial class WbDrawDispatcher
|
|||
}
|
||||
|
||||
float opacity = PackedPartOpacity(
|
||||
entity.ServerGuid,
|
||||
entity.LocalEntityId,
|
||||
(uint)setupPartIndex);
|
||||
if (opacity < 1f)
|
||||
|
|
@ -527,6 +528,7 @@ public sealed unsafe partial class WbDrawDispatcher
|
|||
// one-part assumption and kept the Bind Stone's four
|
||||
// hook-hidden shard parts visible.
|
||||
float opacity = PackedPartOpacity(
|
||||
entity.ServerGuid,
|
||||
entity.LocalEntityId,
|
||||
(uint)partIndex);
|
||||
if (opacity < 1f)
|
||||
|
|
@ -585,20 +587,24 @@ public sealed unsafe partial class WbDrawDispatcher
|
|||
anyVao != 0 && alphaQueueCollecting;
|
||||
|
||||
private float PackedPartOpacity(
|
||||
uint serverGuid,
|
||||
uint localEntityId,
|
||||
uint setupPartIndex)
|
||||
{
|
||||
float opacity = EntityOpacity(serverGuid);
|
||||
if (opacity <= 0f)
|
||||
return 0f;
|
||||
if (!_translucencyFades.TryGetCurrentValue(
|
||||
localEntityId,
|
||||
setupPartIndex,
|
||||
out float translucency))
|
||||
{
|
||||
return 1f;
|
||||
return opacity;
|
||||
}
|
||||
|
||||
return translucency >= 1f
|
||||
? 0f
|
||||
: 1f - translucency;
|
||||
: opacity * (1f - translucency);
|
||||
}
|
||||
|
||||
private bool ClassifyPackedBatches(
|
||||
|
|
|
|||
|
|
@ -176,7 +176,8 @@ public sealed unsafe partial class WbDrawDispatcher
|
|||
RetailAlphaQueue? alphaQueue = null,
|
||||
long? alphaScratchBudgetBytes = null,
|
||||
TerrainAtlas.RetailDetailTextureBinding buildingDetail = default,
|
||||
Func<bool>? buildingDetailEnabled = null)
|
||||
Func<bool>? buildingDetailEnabled = null,
|
||||
Func<uint, float>? hierarchicalTranslucency = null)
|
||||
{
|
||||
_device = device ?? throw new ArgumentNullException(nameof(device));
|
||||
_frames = frames ?? throw new ArgumentNullException(nameof(frames));
|
||||
|
|
@ -192,6 +193,7 @@ public sealed unsafe partial class WbDrawDispatcher
|
|||
_selectionSink = selectionSink;
|
||||
_selectionLighting = selectionSink as IRetailSelectionLightingSource;
|
||||
_alphaQueue = alphaQueue;
|
||||
_hierarchicalTranslucency = hierarchicalTranslucency;
|
||||
_alphaSource = new AlphaDrawSource(this);
|
||||
_buildingDetail = buildingDetail;
|
||||
_buildingDetailEnabled = buildingDetailEnabled ?? DisableDetailTextures;
|
||||
|
|
|
|||
|
|
@ -91,6 +91,7 @@ public sealed partial class WbDrawDispatcher : IDisposable
|
|||
private readonly IRetailSelectionRenderSink? _selectionSink;
|
||||
private readonly IRetailSelectionLightingSource? _selectionLighting;
|
||||
private readonly RetailAlphaQueue? _alphaQueue;
|
||||
private readonly Func<uint, float>? _hierarchicalTranslucency;
|
||||
private readonly AlphaDrawSource _alphaSource;
|
||||
private readonly RetainedScratchCapacityPolicy _alphaScratchPolicy;
|
||||
private int _scratchPeakUnits;
|
||||
|
|
@ -1784,11 +1785,12 @@ public sealed partial class WbDrawDispatcher : IDisposable
|
|||
// sets draw_state|=1 and skips the whole part outright — not a
|
||||
// blend to nothing. TranslucencyFadeManager.AdvanceAll guarantees
|
||||
// t=1 commits the bitwise-exact value so this check is safe.
|
||||
float opacityMultiplier = 1.0f;
|
||||
float opacityMultiplier = EntityOpacity(entity.ServerGuid);
|
||||
if (opacityMultiplier <= 0f) continue;
|
||||
if (_translucencyFades.TryGetCurrentValue(entity.Id, (uint)setupPartIndex, out float translucencyValue))
|
||||
{
|
||||
if (translucencyValue >= 1.0f) continue; // skip this part's draw entirely
|
||||
opacityMultiplier = 1f - translucencyValue; // CMaterial::SetTranslucencySimple 0x005396f0
|
||||
opacityMultiplier *= 1f - translucencyValue; // CMaterial::SetTranslucencySimple 0x005396f0
|
||||
}
|
||||
|
||||
if (!ClassifyBatches(partData, model, entity, meshRef, paletteIdentity, restPose, opacityMultiplier, collector, entityHasCutoutSubset))
|
||||
|
|
@ -1818,12 +1820,14 @@ public sealed partial class WbDrawDispatcher : IDisposable
|
|||
// entity — the Bind Stone's idle cycle hides its four authored
|
||||
// shard parts (3-6) with TransparentPartHook start=end=1.0
|
||||
// every loop, and they stayed visible.
|
||||
float opacityMultiplier = 1.0f;
|
||||
float opacityMultiplier = EntityOpacity(entity.ServerGuid);
|
||||
bool fullyInvisible = false;
|
||||
if (opacityMultiplier <= 0f)
|
||||
fullyInvisible = true;
|
||||
if (_translucencyFades.TryGetCurrentValue(entity.Id, (uint)partIdx, out float translucencyValue))
|
||||
{
|
||||
if (translucencyValue >= 1.0f) fullyInvisible = true;
|
||||
else opacityMultiplier = 1f - translucencyValue;
|
||||
else opacityMultiplier *= 1f - translucencyValue;
|
||||
}
|
||||
|
||||
if (!fullyInvisible)
|
||||
|
|
@ -1901,6 +1905,32 @@ public sealed partial class WbDrawDispatcher : IDisposable
|
|||
observeCurrentPath: true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Readiness barrier for a private creature viewport. Unlike the world
|
||||
/// reveal queue this has no retained scan state: the caller owns a tiny,
|
||||
/// exact entity list and retries it each frame. A completed result means
|
||||
/// both mesh render data and every palette/original-texture composite can
|
||||
/// be classified without clearing the private target to an empty frame.
|
||||
/// </summary>
|
||||
internal bool PreparePrivateEntityResources(
|
||||
IReadOnlyList<WorldEntity> entities)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entities);
|
||||
bool complete = true;
|
||||
for (int i = 0; i < entities.Count; i++)
|
||||
{
|
||||
if (PrepareCompositeEntity(entities[i]) != CompositeWarmupResult.Complete)
|
||||
complete = false;
|
||||
}
|
||||
return complete;
|
||||
}
|
||||
|
||||
private float EntityOpacity(uint serverGuid)
|
||||
{
|
||||
float translucency = _hierarchicalTranslucency?.Invoke(serverGuid) ?? 0f;
|
||||
return 1f - Math.Clamp(translucency, 0f, 1f);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether there is a mesh source to draw from. The encoder arm has no
|
||||
/// vertex array of its own — the pipeline owns one shaped by
|
||||
|
|
|
|||
|
|
@ -317,6 +317,28 @@ internal sealed class CurrentGameRuntimeCommandAdapter
|
|||
return Result(status);
|
||||
}
|
||||
|
||||
public RuntimeCommandResult ExecuteMotion(
|
||||
RuntimeGenerationToken expectedGeneration,
|
||||
uint motionCommand)
|
||||
{
|
||||
RuntimeCommandStatus gate = Validate(
|
||||
expectedGeneration,
|
||||
requireWorld: true);
|
||||
if (gate != RuntimeCommandStatus.Accepted)
|
||||
return Result(gate);
|
||||
|
||||
RuntimeCommandStatus status = _movement.ExecuteMotion(motionCommand)
|
||||
? RuntimeCommandStatus.Accepted
|
||||
: RuntimeCommandStatus.Unsupported;
|
||||
|
||||
_events.EmitCommand(
|
||||
RuntimeCommandDomain.Movement,
|
||||
operation: 0x102,
|
||||
status,
|
||||
motionCommand);
|
||||
return Result(status, motionCommand);
|
||||
}
|
||||
|
||||
public RuntimeCommandResult SetIntent(
|
||||
RuntimeGenerationToken expectedGeneration,
|
||||
in MovementInput input)
|
||||
|
|
|
|||
|
|
@ -736,6 +736,16 @@ internal sealed class LocalPlayerTeleportController
|
|||
/// </summary>
|
||||
private readonly ILocalPlayerLogoutOperations _logout;
|
||||
|
||||
/// <summary>
|
||||
/// The confirmed-logoff pump must let the old streaming window finish
|
||||
/// its asynchronous session retirement before Runtime exposes the next
|
||||
/// character generation. The reset transaction immediately calls back
|
||||
/// into <see cref="ResetGenerationPresentation"/>; this latch transfers
|
||||
/// that already-completed retirement across the synchronous callback so
|
||||
/// it is consumed once instead of starting a second old-window pass.
|
||||
/// </summary>
|
||||
private bool _logoutStreamingRetirementPrepared;
|
||||
|
||||
public LocalPlayerTeleportController(
|
||||
ILocalPlayerTeleportAuthority authority,
|
||||
ILocalPlayerTeleportInputLifetime input,
|
||||
|
|
@ -984,6 +994,8 @@ internal sealed class LocalPlayerTeleportController
|
|||
return false;
|
||||
}
|
||||
|
||||
_logoutStreamingRetirementPrepared = false;
|
||||
|
||||
if (!_transit.TryBeginLogoutRequest(_logout.IsLocalPlayerKiller))
|
||||
return false;
|
||||
|
||||
|
|
@ -1088,8 +1100,23 @@ internal sealed class LocalPlayerTeleportController
|
|||
|
||||
private void CompleteLogoutHandoff(long generation)
|
||||
{
|
||||
if (!_transit.CompleteLogout() || _lifetimeGeneration != generation)
|
||||
// Reset(sessionEnding: true) is a retained, frame-budgeted old-world
|
||||
// retirement. Do not let CompleteCharacterLogOff expose the fresh
|
||||
// Runtime generation until that barrier has converged; otherwise a
|
||||
// quick re-entry can inherit the origin-recenter gate and remain in
|
||||
// portal space with no landblocks admitted (lb 0/0).
|
||||
if (!_streaming.ResetRecenter(sessionEnding: true)
|
||||
|| _lifetimeGeneration != generation)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_logoutStreamingRetirementPrepared = true;
|
||||
if (!_transit.CompleteLogout() || _lifetimeGeneration != generation)
|
||||
{
|
||||
_logoutStreamingRetirementPrepared = false;
|
||||
return;
|
||||
}
|
||||
|
||||
Console.WriteLine(
|
||||
"live: logout confirmed — returning to character select");
|
||||
|
|
@ -1102,6 +1129,8 @@ internal sealed class LocalPlayerTeleportController
|
|||
return;
|
||||
}
|
||||
|
||||
_logoutStreamingRetirementPrepared = false;
|
||||
|
||||
// The transaction refused or degraded to a full stop. If a reset
|
||||
// reached this controller the lifetime moved and everything is
|
||||
// already clean; otherwise retire the presentation here so a
|
||||
|
|
@ -1927,6 +1956,11 @@ internal sealed class LocalPlayerTeleportController
|
|||
bool clearSession,
|
||||
bool resetCanonicalTransit = false)
|
||||
{
|
||||
bool streamingRetirementPrepared = clearSession
|
||||
&& _logoutStreamingRetirementPrepared;
|
||||
if (clearSession)
|
||||
_logoutStreamingRetirementPrepared = false;
|
||||
|
||||
long generation = checked(++_lifetimeGeneration);
|
||||
|
||||
_pendingCell = 0u;
|
||||
|
|
@ -1951,7 +1985,8 @@ internal sealed class LocalPlayerTeleportController
|
|||
if (clearSession)
|
||||
_loginPlacementCompleted = false;
|
||||
|
||||
_streaming.ResetRecenter(clearSession);
|
||||
if (!streamingRetirementPrepared)
|
||||
_streaming.ResetRecenter(clearSession);
|
||||
if (_lifetimeGeneration != generation)
|
||||
return generation;
|
||||
|
||||
|
|
|
|||
|
|
@ -62,10 +62,10 @@ internal sealed class AutoWieldController : IDisposable
|
|||
private readonly Func<uint> _playerGuid;
|
||||
private readonly Action<uint, uint>? _sendWield;
|
||||
private readonly Action<uint, uint, int>? _sendPutItemInContainer;
|
||||
private readonly Action<string>? _toast;
|
||||
private readonly Action<string>? _systemMessage;
|
||||
private readonly CombatState? _combatState;
|
||||
private readonly Action<CombatMode>? _sendChangeCombatMode;
|
||||
private readonly InventoryTransactionState? _transactions;
|
||||
|
||||
private PendingSwitch? _pendingSwitch;
|
||||
private PendingCombatSettlement? _pendingCombatSettlement;
|
||||
|
|
@ -79,19 +79,19 @@ internal sealed class AutoWieldController : IDisposable
|
|||
Func<uint> playerGuid,
|
||||
Action<uint, uint>? sendWield,
|
||||
Action<uint, uint, int>? sendPutItemInContainer,
|
||||
Action<string>? toast,
|
||||
Action<string>? systemMessage = null,
|
||||
CombatState? combatState = null,
|
||||
Action<CombatMode>? sendChangeCombatMode = null)
|
||||
Action<CombatMode>? sendChangeCombatMode = null,
|
||||
InventoryTransactionState? transactions = null)
|
||||
{
|
||||
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
|
||||
_playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid));
|
||||
_sendWield = sendWield;
|
||||
_sendPutItemInContainer = sendPutItemInContainer;
|
||||
_toast = toast;
|
||||
_systemMessage = systemMessage;
|
||||
_combatState = combatState;
|
||||
_sendChangeCombatMode = sendChangeCombatMode;
|
||||
_transactions = transactions;
|
||||
|
||||
_objects.ObjectMoved += OnObjectMoved;
|
||||
_objects.ObjectRemoved += OnObjectRemoved;
|
||||
|
|
@ -238,8 +238,20 @@ internal sealed class AutoWieldController : IDisposable
|
|||
: BestAvailableEquipMask(item);
|
||||
if (mask == EquipMask.None)
|
||||
{
|
||||
_toast?.Invoke("That slot is already in use");
|
||||
return false;
|
||||
// UsingItem calls retail AutoWield with its automatic-unblock flag.
|
||||
// When every compatible slot is occupied, retail chooses the first
|
||||
// compatible slot, moves that blocker to the backpack, and retries
|
||||
// only after RecvNotice_ServerSaysMoveItem confirms the move.
|
||||
// CPlayerSystem::AutoWield @ 0x0056173D-0x0056186E.
|
||||
mask = FirstCompatibleEquipMask(item);
|
||||
ClientObject? blocker = GetEquippedObjectAtLocation(
|
||||
mask, priority: 0, item.ObjectId);
|
||||
return blocker is not null
|
||||
&& BeginWeaponReplacement(
|
||||
item.ObjectId,
|
||||
blocker,
|
||||
mask,
|
||||
combatModeAfterWield: null);
|
||||
}
|
||||
|
||||
return SendWield(item, mask, combatModeAfterWield: null);
|
||||
|
|
@ -252,10 +264,7 @@ internal sealed class AutoWieldController : IDisposable
|
|||
CombatMode? combatModeAfterWield)
|
||||
{
|
||||
if (_sendPutItemInContainer is null)
|
||||
{
|
||||
_toast?.Invoke("That slot is already in use");
|
||||
return false;
|
||||
}
|
||||
|
||||
uint player = _playerGuid();
|
||||
if (player == 0)
|
||||
|
|
@ -272,8 +281,17 @@ internal sealed class AutoWieldController : IDisposable
|
|||
// is the transaction boundary and preserves its stance-specific motion.
|
||||
_systemMessage?.Invoke(
|
||||
$"Moving {blockingItem.GetAppropriateName()} to your backpack");
|
||||
_sendPutItemInContainer(blockingItem.ObjectId, player, 0);
|
||||
return true;
|
||||
bool dispatched = DispatchInventoryRequest(
|
||||
InventoryRequestKind.PutInContainer,
|
||||
blockingItem.ObjectId,
|
||||
() =>
|
||||
{
|
||||
_sendPutItemInContainer(blockingItem.ObjectId, player, 0);
|
||||
return true;
|
||||
});
|
||||
if (!dispatched)
|
||||
_pendingSwitch = null;
|
||||
return dispatched;
|
||||
}
|
||||
|
||||
private bool SendWield(
|
||||
|
|
@ -288,17 +306,26 @@ internal sealed class AutoWieldController : IDisposable
|
|||
BlockingItemId: 0,
|
||||
RequestedMask: mask,
|
||||
CombatModeAfterWield: combatModeAfterWield);
|
||||
if (!_objects.WieldItemOptimistic(item.ObjectId, _playerGuid(), mask))
|
||||
{
|
||||
_pendingSwitch = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Retail ACCWeenieObject::UIAttemptWield @ 0x0058D590.
|
||||
_sendWield(item.ObjectId, (uint)mask);
|
||||
return true;
|
||||
bool dispatched = DispatchInventoryRequest(
|
||||
InventoryRequestKind.Wield,
|
||||
item.ObjectId,
|
||||
() =>
|
||||
{
|
||||
_sendWield(item.ObjectId, (uint)mask);
|
||||
return true;
|
||||
});
|
||||
if (!dispatched)
|
||||
_pendingSwitch = null;
|
||||
return dispatched;
|
||||
}
|
||||
|
||||
private bool DispatchInventoryRequest(
|
||||
InventoryRequestKind kind,
|
||||
uint itemId,
|
||||
Func<bool> dispatch)
|
||||
=> _transactions?.TryDispatch(kind, itemId, dispatch) ?? dispatch();
|
||||
|
||||
private void OnObjectMoved(ClientObjectMove move)
|
||||
{
|
||||
if (_pendingSwitch is not { } pending
|
||||
|
|
@ -454,6 +481,14 @@ internal sealed class AutoWieldController : IDisposable
|
|||
return EquipMask.None;
|
||||
}
|
||||
|
||||
private static EquipMask FirstCompatibleEquipMask(ClientObject item)
|
||||
{
|
||||
foreach (EquipMask mask in AutoEquipOrder)
|
||||
if ((item.ValidLocations & mask) != EquipMask.None)
|
||||
return mask;
|
||||
return EquipMask.None;
|
||||
}
|
||||
|
||||
private bool AutoWearIsLegal(
|
||||
ClientObject item,
|
||||
out ClientObject? blocker)
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ public sealed class ItemInteractionController : IDisposable
|
|||
private readonly Action<uint, uint>? _sendSplitToWorld;
|
||||
private readonly Action<uint, uint, int>? _sendPutItemInContainer;
|
||||
private readonly Action<uint, uint, uint, uint>? _sendSplitToContainer;
|
||||
private readonly Action<uint, uint, uint>? _sendStackableMerge;
|
||||
private readonly Action<uint, uint, uint>? _sendGive;
|
||||
private readonly Action<string>? _toast;
|
||||
private readonly Func<bool> _readyForInventoryRequest;
|
||||
|
|
@ -118,7 +119,8 @@ public sealed class ItemInteractionController : IDisposable
|
|||
Func<uint, uint, int, uint, bool>? sendBuy = null,
|
||||
Func<uint, IReadOnlyList<(int Amount, uint ItemGuid)>, uint, bool>? sendBuyAll = null,
|
||||
Func<uint, IReadOnlyList<(int Amount, uint ItemGuid)>, bool>? sendSell = null,
|
||||
Action<string, RetailLogTextType>? interfaceText = null)
|
||||
Action<string, RetailLogTextType>? interfaceText = null,
|
||||
Action<uint, uint, uint>? sendStackableMerge = null)
|
||||
{
|
||||
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
|
||||
_playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid));
|
||||
|
|
@ -130,6 +132,7 @@ public sealed class ItemInteractionController : IDisposable
|
|||
_sendSplitToWorld = sendSplitToWorld;
|
||||
_sendPutItemInContainer = sendPutItemInContainer;
|
||||
_sendSplitToContainer = sendSplitToContainer;
|
||||
_sendStackableMerge = sendStackableMerge;
|
||||
_sendGive = sendGive;
|
||||
_nowMs = nowMs ?? (() => Environment.TickCount64);
|
||||
_toast = toast;
|
||||
|
|
@ -168,10 +171,10 @@ public sealed class ItemInteractionController : IDisposable
|
|||
_playerGuid,
|
||||
_sendWield,
|
||||
sendPutItemInContainer,
|
||||
_toast,
|
||||
_systemMessage,
|
||||
combatState,
|
||||
sendChangeCombatMode);
|
||||
sendChangeCombatMode,
|
||||
_transactions);
|
||||
_interactionState.Changed += OnInteractionModeChanged;
|
||||
_transactions.StateChanged += OnTransactionStateChanged;
|
||||
_transactions.RequestCompleted += OnInventoryRequestCompleted;
|
||||
|
|
@ -182,6 +185,12 @@ public sealed class ItemInteractionController : IDisposable
|
|||
|
||||
public event Action? StateChanged;
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>ItemHolder::AttemptMerge</c> immediately selects the target
|
||||
/// stack and publishes the toolbar merge-attempt notice after dispatch.
|
||||
/// </summary>
|
||||
public event Action<uint, uint>? MergeAttempted;
|
||||
|
||||
/// <summary>
|
||||
/// Retail's two secure-trade open paths surface here for the trade UI:
|
||||
/// (partnerGuid, itemGuid) — itemGuid 0 for Use-on-player
|
||||
|
|
@ -457,6 +466,40 @@ public sealed class ItemInteractionController : IDisposable
|
|||
public bool TryGetPendingInventoryRequest(out PendingInventoryRequest pending)
|
||||
=> _transactions.TryGetPending(out pending);
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>ACCWeenieObject::UIAttemptSplitToContainer</c>: split an
|
||||
/// exact partial quantity into a container through the canonical
|
||||
/// one-request inventory gate. The source remains in place until the
|
||||
/// authoritative stack update and newly-created split object arrive.
|
||||
/// </summary>
|
||||
public bool TrySplitToContainer(
|
||||
uint itemId,
|
||||
uint containerId,
|
||||
uint placement,
|
||||
uint amount)
|
||||
{
|
||||
if (itemId == 0u
|
||||
|| containerId == 0u
|
||||
|| _sendSplitToContainer is null
|
||||
|| _objects.Get(itemId) is not { } item)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
uint fullStack = (uint)Math.Max(1, item.StackSize);
|
||||
if (amount == 0u || amount >= fullStack)
|
||||
return false;
|
||||
|
||||
return TryDispatchInventoryRequest(
|
||||
InventoryRequestKind.SplitToContainer,
|
||||
itemId,
|
||||
() =>
|
||||
{
|
||||
_sendSplitToContainer(itemId, containerId, placement, amount);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Increments retail's shared <c>ClientUISystem</c> busy reference after a
|
||||
/// request issued by another retained controller has been sent. The
|
||||
|
|
@ -486,6 +529,29 @@ public sealed class ItemInteractionController : IDisposable
|
|||
public bool IsPendingSource(uint itemGuid)
|
||||
=> itemGuid != 0 && itemGuid == PendingSourceItem;
|
||||
|
||||
/// <summary>
|
||||
/// True while retail's global inventory request latch owns this physical
|
||||
/// item. Retained item lists use it for the waiting/ghosted source visual;
|
||||
/// canonical placement remains unchanged until the server response.
|
||||
/// </summary>
|
||||
public bool IsPendingInventorySource(uint itemGuid)
|
||||
=> itemGuid != 0
|
||||
&& _transactions.TryGetPending(out PendingInventoryRequest pending)
|
||||
&& pending.ItemId == itemGuid;
|
||||
|
||||
/// <summary>Route a literal local refusal to retail's SpewBox channel.</summary>
|
||||
public void ReportClientLocal(string message)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(message))
|
||||
return;
|
||||
if (_interfaceText is not null)
|
||||
_interfaceText(message, RetailLogTextType.ClientLocal);
|
||||
else if (_systemMessage is not null)
|
||||
_systemMessage(message);
|
||||
else
|
||||
_toast?.Invoke(message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>ACCWeenieObject::IsOwnedByPlayer</c> projection shared with
|
||||
/// toolbar shortcut creation.
|
||||
|
|
@ -689,15 +755,46 @@ public sealed class ItemInteractionController : IDisposable
|
|||
/// publishes the waiting destination slot before issuing the move request,
|
||||
/// exactly like double-click pickup through ItemHolder.
|
||||
/// </summary>
|
||||
public bool PlaceWorldItemInBackpack(uint itemGuid)
|
||||
public bool PlaceWorldItemInBackpack(uint itemGuid, bool mainPack = false)
|
||||
{
|
||||
if (itemGuid == 0u || _placeInBackpack is null)
|
||||
return false;
|
||||
|
||||
uint containerId = _backpackContainerId();
|
||||
uint containerId = mainPack ? _playerGuid() : _backpackContainerId();
|
||||
if (containerId == 0u)
|
||||
containerId = _playerGuid();
|
||||
const int placement = 0;
|
||||
|
||||
// CPlayerSystem::PlaceInBackpack passes autoMerge=true to
|
||||
// ItemHolder::AttemptToPlaceInContainer. Retail searches the player's
|
||||
// exhaustive carried inventory first and only merges when one target
|
||||
// can accept the complete selected split quantity.
|
||||
if (TryPlanAutoMerge(itemGuid) is { } merge)
|
||||
{
|
||||
if (!TryDispatchPendingBackpackPlacement(
|
||||
itemGuid,
|
||||
containerId,
|
||||
placement,
|
||||
InventoryRequestKind.Merge,
|
||||
() =>
|
||||
{
|
||||
_sendStackableMerge!(
|
||||
merge.SourceObjectId,
|
||||
merge.TargetObjectId,
|
||||
merge.Amount);
|
||||
MergeAttempted?.Invoke(
|
||||
merge.SourceObjectId,
|
||||
merge.TargetObjectId);
|
||||
return true;
|
||||
}))
|
||||
{
|
||||
// As with ordinary pickup, retail consumes the key while the
|
||||
// shared inventory-request gate is busy.
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!TryBeginPendingBackpackPlacement(
|
||||
itemGuid,
|
||||
containerId,
|
||||
|
|
@ -716,6 +813,67 @@ public sealed class ItemInteractionController : IDisposable
|
|||
return true;
|
||||
}
|
||||
|
||||
private StackMergePlan? TryPlanAutoMerge(uint sourceId)
|
||||
{
|
||||
if (_sendStackableMerge is null
|
||||
|| _objects.Get(sourceId) is not { } source
|
||||
|| source.StackSizeMax <= 1)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
uint requested = _stackSplitQuantity?.GetObjectSplitSize(
|
||||
sourceId,
|
||||
_selectedObjectId(),
|
||||
(uint)Math.Max(1, source.StackSize))
|
||||
?? (uint)Math.Max(1, source.StackSize);
|
||||
int requestedAmount = (int)Math.Min(requested, int.MaxValue);
|
||||
var sourceMerge = ToStackMergeItem(source);
|
||||
uint player = _playerGuid();
|
||||
if (player == 0u)
|
||||
return null;
|
||||
|
||||
var visitedContainers = new HashSet<uint>();
|
||||
foreach (uint targetId in ExhaustiveContents(player, visitedContainers))
|
||||
{
|
||||
if (_objects.Get(targetId) is not { } target)
|
||||
continue;
|
||||
StackMergePlan? plan = StackMergePlanner.Plan(
|
||||
sourceMerge,
|
||||
ToStackMergeItem(target),
|
||||
CanMakeInventoryRequest,
|
||||
requestedAmount);
|
||||
// AttemptAutoMerge rejects a partial fit and keeps searching.
|
||||
if (plan is { } complete && complete.Amount == requested)
|
||||
return complete;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private IEnumerable<uint> ExhaustiveContents(
|
||||
uint containerId,
|
||||
HashSet<uint> visitedContainers)
|
||||
{
|
||||
if (!visitedContainers.Add(containerId))
|
||||
yield break;
|
||||
|
||||
foreach (uint itemId in _objects.GetContents(containerId))
|
||||
{
|
||||
yield return itemId;
|
||||
if (_objects.GetContents(itemId).Count == 0)
|
||||
continue;
|
||||
foreach (uint nested in ExhaustiveContents(itemId, visitedContainers))
|
||||
yield return nested;
|
||||
}
|
||||
}
|
||||
|
||||
private static StackMergeItem ToStackMergeItem(ClientObject item) => new(
|
||||
item.ObjectId,
|
||||
item.WeenieClassId,
|
||||
item.StackSize,
|
||||
item.StackSizeMax,
|
||||
item.TradeState);
|
||||
|
||||
public bool TryBeginPendingBackpackPlacement(
|
||||
uint itemGuid,
|
||||
uint containerId,
|
||||
|
|
@ -1043,6 +1201,14 @@ public sealed class ItemInteractionController : IDisposable
|
|||
public bool DropToWorld(ItemDragPayload payload)
|
||||
=> PlaceIn3D(payload, targetGuid: 0u);
|
||||
|
||||
/// <summary>
|
||||
/// Keyboard equivalent of dropping the selected inventory item into the
|
||||
/// 3-D view. Retail routes Give Selected and Drop Selected through the
|
||||
/// same <c>ItemHolder::AttemptPlaceIn3D @ 0x00588600</c> policy as a drag.
|
||||
/// </summary>
|
||||
public bool PlaceSelectedIn3D(uint itemGuid, uint targetGuid)
|
||||
=> PlaceIn3D(itemGuid, ItemDragSource.Inventory, targetGuid);
|
||||
|
||||
/// <summary>
|
||||
/// Retail inventory drag released into SmartBox. The release target is the
|
||||
/// world object under the cursor, or zero for empty ground. This is the live
|
||||
|
|
@ -1052,9 +1218,17 @@ public sealed class ItemInteractionController : IDisposable
|
|||
{
|
||||
ArgumentNullException.ThrowIfNull(payload);
|
||||
|
||||
if (payload.SourceKind == ItemDragSource.ShortcutBar)
|
||||
return PlaceIn3D(payload.ObjId, payload.SourceKind, targetGuid);
|
||||
}
|
||||
|
||||
private bool PlaceIn3D(
|
||||
uint itemGuid,
|
||||
ItemDragSource sourceKind,
|
||||
uint targetGuid)
|
||||
{
|
||||
if (sourceKind == ItemDragSource.ShortcutBar)
|
||||
return false;
|
||||
if (payload.ObjId == 0 || _objects.Get(payload.ObjId) is not { } item)
|
||||
if (itemGuid == 0 || _objects.Get(itemGuid) is not { } item)
|
||||
return false;
|
||||
if (!EnsureInventoryRequestReady())
|
||||
return false;
|
||||
|
|
@ -1154,7 +1328,7 @@ public sealed class ItemInteractionController : IDisposable
|
|||
break;
|
||||
case ItemPolicyActionKind.Reject:
|
||||
if (!string.IsNullOrWhiteSpace(action.Message))
|
||||
_toast?.Invoke(action.Message);
|
||||
ReportClientLocal(action.Message);
|
||||
break;
|
||||
case ItemPolicyActionKind.OpenSecureTrade:
|
||||
// Use-on-player (ItemHolder::DetermineUseResult
|
||||
|
|
@ -1169,8 +1343,9 @@ public sealed class ItemInteractionController : IDisposable
|
|||
PolicyActionRequested?.Invoke(action);
|
||||
bool handled = _auxiliaryAction is not null || PolicyActionRequested is not null;
|
||||
if (!handled)
|
||||
_toast?.Invoke(PolicyActionMessage(action));
|
||||
acted |= handled || _toast is not null;
|
||||
ReportClientLocal(PolicyActionMessage(action));
|
||||
acted |= handled || _interfaceText is not null
|
||||
|| _systemMessage is not null || _toast is not null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
|
@ -1199,14 +1374,8 @@ public sealed class ItemInteractionController : IDisposable
|
|||
action.ObjectId,
|
||||
() =>
|
||||
{
|
||||
if (_sendDrop is null
|
||||
|| !_objects.MoveItemOptimistic(
|
||||
action.ObjectId,
|
||||
newContainerId: 0u,
|
||||
newSlot: -1))
|
||||
{
|
||||
if (_sendDrop is null)
|
||||
return false;
|
||||
}
|
||||
_sendDrop(action.ObjectId);
|
||||
return true;
|
||||
});
|
||||
|
|
@ -1290,13 +1459,13 @@ public sealed class ItemInteractionController : IDisposable
|
|||
}
|
||||
case ItemPolicyActionKind.Reject:
|
||||
if (!string.IsNullOrWhiteSpace(action.Message))
|
||||
_toast?.Invoke(action.Message);
|
||||
ReportClientLocal(action.Message);
|
||||
break;
|
||||
default:
|
||||
_auxiliaryAction?.Invoke(action);
|
||||
PolicyActionRequested?.Invoke(action);
|
||||
if (_auxiliaryAction is null && PolicyActionRequested is null)
|
||||
_toast?.Invoke(PolicyActionMessage(action));
|
||||
ReportClientLocal(PolicyActionMessage(action));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
|
@ -1308,7 +1477,7 @@ public sealed class ItemInteractionController : IDisposable
|
|||
_interactionState.EnterUseItemOnTarget(sourceGuid);
|
||||
var name = _objects.Get(sourceGuid)?.Name;
|
||||
if (!string.IsNullOrWhiteSpace(name))
|
||||
_toast?.Invoke($"Choose a target for the {name}");
|
||||
ReportClientLocal($"Choose a target for the {name}");
|
||||
}
|
||||
|
||||
private void ClearTargetMode()
|
||||
|
|
@ -1387,8 +1556,6 @@ public sealed class ItemInteractionController : IDisposable
|
|||
PendingInventoryRequest request,
|
||||
uint weenieError)
|
||||
{
|
||||
if (_interfaceText is null)
|
||||
return;
|
||||
ClientObject? item = request.ItemIdentity ?? _objects.Get(request.ItemId);
|
||||
if (item is null)
|
||||
return;
|
||||
|
|
@ -1407,7 +1574,7 @@ public sealed class ItemInteractionController : IDisposable
|
|||
if (InventoryFailureMessages.Compose(request.Kind, name, weenieError)
|
||||
is { } text)
|
||||
{
|
||||
_interfaceText(text, RetailLogTextType.ClientLocal);
|
||||
ReportClientLocal(text);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1443,6 +1610,7 @@ public sealed class ItemInteractionController : IDisposable
|
|||
_transactions.RequestCompleted -= OnInventoryRequestCompleted;
|
||||
_transactions.StateChanged -= OnTransactionStateChanged;
|
||||
WorldDropDispatched = null;
|
||||
MergeAttempted = null;
|
||||
_autoWield.Dispose();
|
||||
}
|
||||
|
||||
|
|
@ -1575,7 +1743,8 @@ public sealed class ItemInteractionController : IDisposable
|
|||
stackSize,
|
||||
stackSize,
|
||||
IsIn3DView: item.ContainerId == 0 && item.WielderId == 0
|
||||
&& item.ObjectId != _playerGuid());
|
||||
&& item.ObjectId != _playerGuid(),
|
||||
Name: item.GetAppropriateName());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -255,13 +255,23 @@ public static class CharacterStatController
|
|||
// RetailScrollbarChrome (2026-08-24: the previous local constants seated
|
||||
// the DOWN-arrow art on the top button).
|
||||
|
||||
private enum CharacterStatTab
|
||||
public enum CharacterStatTab
|
||||
{
|
||||
Attributes,
|
||||
Skills,
|
||||
Titles,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Live character-panel binding. Keyboard panel actions use the same
|
||||
/// tab switch function as the authored tab buttons, so F8/F9 cannot
|
||||
/// diverge from click behavior.
|
||||
/// </summary>
|
||||
public sealed record Binding(
|
||||
Action Refresh,
|
||||
Action<CharacterStatTab> ShowTab,
|
||||
Func<CharacterStatTab> CurrentTab);
|
||||
|
||||
public enum RaiseTargetKind
|
||||
{
|
||||
Attribute,
|
||||
|
|
@ -386,7 +396,7 @@ public static class CharacterStatController
|
|||
/// next click. The caller invokes this from the sheet-changed
|
||||
/// subscription.
|
||||
/// </returns>
|
||||
public static Action Bind(
|
||||
public static Binding Bind(
|
||||
ImportedLayout layout,
|
||||
Func<CharacterSheet> data,
|
||||
UiDatFont? datFont = null,
|
||||
|
|
@ -881,7 +891,10 @@ public static class CharacterStatController
|
|||
// luminance-award quality change.
|
||||
}
|
||||
|
||||
return () => RefreshAfterRaise(null);
|
||||
return new Binding(
|
||||
() => RefreshAfterRaise(null),
|
||||
SwitchTab,
|
||||
() => activeTab[0]);
|
||||
}
|
||||
|
||||
private static UiScrollbar? PrepareSkillScrollbar(
|
||||
|
|
|
|||
|
|
@ -100,9 +100,10 @@ internal static class ChatTranscriptRenderer
|
|||
/// accumulating, so the two-threshold hysteresis has nothing to damp — it
|
||||
/// exists to stop retail trimming on every single append. A single cap
|
||||
/// gives a STABLE window here; oscillating one would make the oldest
|
||||
/// visible line jump around as messages arrive. Cutting at whole lines is
|
||||
/// automatic for the same reason: our unit already is the line, which is
|
||||
/// what retail's newline preference is trying to achieve.
|
||||
/// visible line jump around as messages arrive. Most entries are already
|
||||
/// one line; an oversized server entry with embedded newlines is clipped
|
||||
/// at the first complete line inside the retained suffix, matching
|
||||
/// retail's newline preference.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public const int MaxTranscriptCharacters = 0x2710;
|
||||
|
|
@ -119,6 +120,14 @@ internal static class ChatTranscriptRenderer
|
|||
IReadOnlyList<FormattedLine> detailed,
|
||||
Func<uint, bool>? accept,
|
||||
int budget = MaxTranscriptCharacters)
|
||||
=> FindBudgetStart(detailed, accept, budget).LineIndex;
|
||||
|
||||
private readonly record struct BudgetStart(int LineIndex, int CharacterOffset);
|
||||
|
||||
private static BudgetStart FindBudgetStart(
|
||||
IReadOnlyList<FormattedLine> detailed,
|
||||
Func<uint, bool>? accept,
|
||||
int budget = MaxTranscriptCharacters)
|
||||
{
|
||||
long used = 0;
|
||||
for (int i = detailed.Count - 1; i >= 0; i--)
|
||||
|
|
@ -127,11 +136,68 @@ internal static class ChatTranscriptRenderer
|
|||
continue;
|
||||
|
||||
// +1 for the newline retail stores between lines.
|
||||
used += detailed[i].Text.Length + 1;
|
||||
if (used > budget)
|
||||
return i + 1;
|
||||
long cost = detailed[i].Text.Length + 1L;
|
||||
if (used + cost <= budget)
|
||||
{
|
||||
used += cost;
|
||||
continue;
|
||||
}
|
||||
|
||||
int available = (int)Math.Max(0L, budget - used - 1L);
|
||||
if (available > 0)
|
||||
{
|
||||
string text = detailed[i].Text;
|
||||
int minimumOffset = Math.Max(0, text.Length - available);
|
||||
int offset = FirstCharacterAfterLineBreak(text, minimumOffset);
|
||||
if (offset < text.Length)
|
||||
return new BudgetStart(i, offset);
|
||||
|
||||
// A single newest unbroken message must still remain visible;
|
||||
// dropping it wholesale is what made large @acecommands
|
||||
// replies render as an empty transcript.
|
||||
if (used == 0 && text.Length > 0)
|
||||
return new BudgetStart(i, minimumOffset);
|
||||
}
|
||||
return new BudgetStart(i + 1, 0);
|
||||
}
|
||||
return 0;
|
||||
return new BudgetStart(0, 0);
|
||||
}
|
||||
|
||||
private static int FirstCharacterAfterLineBreak(string text, int start)
|
||||
{
|
||||
for (int i = Math.Clamp(start, 0, text.Length); i < text.Length; i++)
|
||||
{
|
||||
if (text[i] is not ('\r' or '\n'))
|
||||
continue;
|
||||
if (text[i] == '\r' && i + 1 < text.Length && text[i + 1] == '\n')
|
||||
i++;
|
||||
return i + 1;
|
||||
}
|
||||
return text.Length;
|
||||
}
|
||||
|
||||
private static FormattedLine SliceLine(FormattedLine line, int offset)
|
||||
{
|
||||
if (offset <= 0)
|
||||
return line;
|
||||
|
||||
string text = line.Text[offset..];
|
||||
if (line.Spans is not { Count: > 0 } spans)
|
||||
return line with { Text = text };
|
||||
|
||||
var sliced = new List<ChatTextSpan>();
|
||||
int at = 0;
|
||||
foreach (ChatTextSpan span in spans)
|
||||
{
|
||||
int end = at + span.Text.Length;
|
||||
if (end > offset)
|
||||
{
|
||||
int from = Math.Max(offset, at) - at;
|
||||
sliced.Add(span with { Text = span.Text[from..] });
|
||||
}
|
||||
at = end;
|
||||
}
|
||||
return line with { Text = text, Spans = sliced };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -257,12 +323,14 @@ internal static class ChatTranscriptRenderer
|
|||
// (defaultColor), matching retail's DoFontReset — not the color table's
|
||||
// unrelated index-0x00 slot.
|
||||
Vector4 currentColor = defaultColor;
|
||||
int firstLine = FirstLineWithinBudget(detailed, accept);
|
||||
for (int lineIndex = firstLine; lineIndex < detailed.Count; lineIndex++)
|
||||
BudgetStart start = FindBudgetStart(detailed, accept);
|
||||
for (int lineIndex = start.LineIndex; lineIndex < detailed.Count; lineIndex++)
|
||||
{
|
||||
FormattedLine d = detailed[lineIndex];
|
||||
if (accept is not null && !accept(d.LogTextType))
|
||||
continue;
|
||||
if (lineIndex == start.LineIndex && start.CharacterOffset > 0)
|
||||
d = SliceLine(d, start.CharacterOffset);
|
||||
if (RetailChatColorTable.TryGetColor(d.LogTextType, out Vector4 resolved))
|
||||
currentColor = resolved;
|
||||
// Wrapping can DROP the space it broke on, so a fragment is not
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ using AcDream.App.Rendering;
|
|||
using AcDream.App.UI;
|
||||
using AcDream.Core.Chat;
|
||||
using AcDream.UI.Abstractions;
|
||||
using AcDream.UI.Abstractions.Input;
|
||||
using AcDream.UI.Abstractions.Panels.Chat;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
|
@ -1029,6 +1030,44 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
|
|||
FindRootOf(Input)?.SetKeyboardFocus(Input);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>EnterChatMode</c>: enter write mode and select the complete
|
||||
/// existing entry so the next typed character replaces it.
|
||||
/// </summary>
|
||||
internal void EnterChatMode(KeyChord? physicalChord = null)
|
||||
{
|
||||
UiRoot? root = FindRootOf(Input);
|
||||
root?.SetKeyboardFocus(Input);
|
||||
if (physicalChord is { Device: 0 } chord)
|
||||
root?.SuppressPhysicalKeyUntilRelease(chord.Key);
|
||||
Input.SelectAllText();
|
||||
}
|
||||
|
||||
/// <summary>Retail <c>ToggleChatEntry</c>: toggle write-mode focus.</summary>
|
||||
internal void ToggleChatEntry(KeyChord? physicalChord = null)
|
||||
{
|
||||
UiRoot? root = FindRootOf(Input);
|
||||
if (root is null)
|
||||
return;
|
||||
root.SetKeyboardFocus(ReferenceEquals(root.KeyboardFocus, Input) ? null : Input);
|
||||
if (physicalChord is { Device: 0 } chord)
|
||||
root.SuppressPhysicalKeyUntilRelease(chord.Key);
|
||||
}
|
||||
|
||||
/// <summary>Retail command/alias hotkey: begin an ordinary slash command.</summary>
|
||||
internal void StartCommand()
|
||||
{
|
||||
Input.SetText("/");
|
||||
FindRootOf(Input)?.SetKeyboardFocus(Input);
|
||||
}
|
||||
|
||||
/// <summary>Retail reply keys are silent when their independent target is empty.</summary>
|
||||
internal void StartReply(string? name)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(name))
|
||||
StartTell(name);
|
||||
}
|
||||
|
||||
private static UiRoot? FindRootOf(UiElement element)
|
||||
{
|
||||
for (UiElement? at = element; at is not null; at = at.Parent)
|
||||
|
|
|
|||
|
|
@ -115,7 +115,7 @@ public static class DatWidgetFactory
|
|||
// pre-OP2 UiDatElement fallback (media drawn, ClickThrough=true, state
|
||||
// propagation) because nothing ever activates it.
|
||||
5 => new UiTemplateListBox(info, resolve, info.TemplateList, info.ScrollbarElementId),
|
||||
6 => new UiMenu(), // UIElement_Menu (reg :120163)
|
||||
6 => BuildMenu(info, resolve, elementFont, fontResolve), // UIElement_Menu (reg :120163)
|
||||
7 => BuildMeter(info, resolve, elementFont, stringResolve), // UIElement_Meter
|
||||
// UIElement_Panel (Type 8) — retail's tab-strip host (dat property 0x2E;
|
||||
// research doc §1.3/§10.1). OP2 rework (docs/research/2026-08-11-op2-
|
||||
|
|
@ -133,6 +133,7 @@ public static class DatWidgetFactory
|
|||
11 => BuildScrollbar(info, resolve), // UIElement_Scrollbar (reg :124137)
|
||||
12 => BuildText(info, resolve, elementFont, stringResolve), // UIElement_Text
|
||||
0x13 => new UiDialogRoot(), // ConfirmationDialog
|
||||
0x14 => new UiDialogRoot(), // ConfirmationMenuDialog
|
||||
0x15 => new UiDialogRoot(), // ConfirmationTextInputDialog
|
||||
0x17 => new UiDialogRoot(), // MessageDialog
|
||||
0x19 => new UiDialogRoot(), // WaitDialog (catalog root 0x31 — OP8 #396)
|
||||
|
|
@ -163,7 +164,7 @@ public static class DatWidgetFactory
|
|||
// ArrowCapClosedSprite doc comment). Built blank, exactly like the Type-6
|
||||
// case above — a page controller wires its sprites/items the same way
|
||||
// ChatWindowController wires the channel menu.
|
||||
0x10000038u => new UiMenu(),
|
||||
0x10000038u => BuildMenu(info, resolve, elementFont, fontResolve),
|
||||
// UIOption_CheckboxBitfield64 (Type 0x10000044): the Chat tab's per-window
|
||||
// text-filter block. OP2 rework (docs/research/2026-08-11-op2-review-
|
||||
// mechanism.md MUST-FIX 4): the authored template (0x10000520) DOES author
|
||||
|
|
@ -209,6 +210,42 @@ public static class DatWidgetFactory
|
|||
return e;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail's generic menu class supplies its standard face/popup chrome even
|
||||
/// when no game-specific controller customizes it. This matters for catalog
|
||||
/// dialogs such as ConfirmationMenu: their Type-6 leaf is the whole control,
|
||||
/// and <see cref="UiMenu.ConsumesDatChildren"/> intentionally absorbs the
|
||||
/// authored label child. Existing chat/vendor/options controllers overwrite
|
||||
/// these defaults with their own probed variants.
|
||||
/// </summary>
|
||||
private static UiMenu BuildMenu(
|
||||
ElementInfo info,
|
||||
Func<uint, (uint, int, int)> resolve,
|
||||
UiDatFont? elementFont,
|
||||
Func<uint, UiDatFont?>? fontResolve)
|
||||
{
|
||||
ElementInfo? label = info.Children.FirstOrDefault(
|
||||
static child => child.Type == 12u);
|
||||
UiDatFont? labelFont = label is { FontDid: not 0u } && fontResolve is not null
|
||||
? fontResolve(label.FontDid) ?? elementFont
|
||||
: elementFont;
|
||||
var menu = new UiMenu
|
||||
{
|
||||
SpriteResolve = resolve,
|
||||
DatFont = labelFont,
|
||||
ButtonDatFont = labelFont,
|
||||
NormalSprite = 0x06004D65u,
|
||||
PressedSprite = 0x06004D66u,
|
||||
PopupBgSprite = 0x0600124Cu,
|
||||
ItemNormalSprite = 0x0600124Eu,
|
||||
ItemHighlightSprite = 0x0600124Du,
|
||||
ButtonTextCentered = label?.HJustify == HJustify.Center,
|
||||
};
|
||||
if (label?.FontColor is { } color)
|
||||
menu.TextColor = color;
|
||||
return menu;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bind inherited scrollbar media structurally. Property 0x77 names the
|
||||
/// increment button and 0x78 the decrement button; retail
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ public sealed class ExternalContainerController : IItemListDragHandler, IRetaine
|
|||
private readonly UiItemList _contentsList;
|
||||
|
||||
private uint _openContainer;
|
||||
private PendingBackpackPlacement? _pendingPlacement;
|
||||
private bool _closeRequested;
|
||||
private bool _disposed;
|
||||
|
||||
|
|
@ -115,6 +116,9 @@ public sealed class ExternalContainerController : IItemListDragHandler, IRetaine
|
|||
_objects.Cleared += OnObjectsCleared;
|
||||
_selection.Changed += OnSelectionChanged;
|
||||
_itemInteraction.StateChanged += OnInteractionStateChanged;
|
||||
_itemInteraction.PendingBackpackPlacementRequested += OnPendingPlacementRequested;
|
||||
_itemInteraction.PendingBackpackPlacementCancelled += OnPendingPlacementCancelled;
|
||||
_itemInteraction.PendingBackpackPlacementResolved += OnPendingPlacementResolved;
|
||||
ClearLists();
|
||||
}
|
||||
|
||||
|
|
@ -210,13 +214,14 @@ public sealed class ExternalContainerController : IItemListDragHandler, IRetaine
|
|||
UiItemSlot targetCell,
|
||||
ItemDragPayload payload)
|
||||
{
|
||||
if (payload.SourceKind == ItemDragSource.ShortcutBar)
|
||||
return ItemDragAcceptance.None;
|
||||
if (!ReferenceEquals(targetList, _contentsList)
|
||||
|| payload.SourceKind == ItemDragSource.ShortcutBar
|
||||
|| payload.ObjId == 0u
|
||||
|| _openContainer == 0u
|
||||
|| payload.ObjId == _openContainer)
|
||||
|| _openContainer == 0u)
|
||||
return ItemDragAcceptance.Reject;
|
||||
return ItemDragAcceptance.Accept;
|
||||
return EvaluateDrop(payload.ObjId) == InventoryContainerPlacementRejection.None
|
||||
? ItemDragAcceptance.Accept
|
||||
: ItemDragAcceptance.Reject;
|
||||
}
|
||||
|
||||
public void HandleDropRelease(
|
||||
|
|
@ -224,8 +229,19 @@ public sealed class ExternalContainerController : IItemListDragHandler, IRetaine
|
|||
UiItemSlot targetCell,
|
||||
ItemDragPayload payload)
|
||||
{
|
||||
if (OnDragOver(targetList, targetCell, payload) != ItemDragAcceptance.Accept)
|
||||
InventoryContainerPlacementRejection legality = EvaluateDrop(payload.ObjId);
|
||||
if (legality != InventoryContainerPlacementRejection.None)
|
||||
{
|
||||
if (InventoryContainerPlacementPolicy.ComposeClientLocal(
|
||||
legality,
|
||||
_objects.Get(payload.ObjId),
|
||||
_objects.Get(_openContainer),
|
||||
playerId: 0u) is { } refusal)
|
||||
{
|
||||
_itemInteraction.ReportClientLocal(refusal);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!_itemInteraction.EnsureInventoryRequestReady())
|
||||
return;
|
||||
if (_objects.Get(payload.ObjId) is not { } item)
|
||||
|
|
@ -246,17 +262,30 @@ public sealed class ExternalContainerController : IItemListDragHandler, IRetaine
|
|||
InventoryRequestKind kind = amount < fullStack
|
||||
? InventoryRequestKind.SplitToContainer
|
||||
: InventoryRequestKind.PutInContainer;
|
||||
_itemInteraction.TryDispatchInventoryRequest(
|
||||
kind,
|
||||
item.ObjectId,
|
||||
() =>
|
||||
{
|
||||
if (amount < fullStack)
|
||||
if (amount < fullStack)
|
||||
{
|
||||
_itemInteraction.TryDispatchInventoryRequest(
|
||||
kind,
|
||||
item.ObjectId,
|
||||
() =>
|
||||
{
|
||||
_sendSplitToContainer(item.ObjectId, _openContainer, (uint)placement, amount);
|
||||
else
|
||||
return true;
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
_itemInteraction.TryDispatchPendingBackpackPlacement(
|
||||
item.ObjectId,
|
||||
_openContainer,
|
||||
placement,
|
||||
kind,
|
||||
() =>
|
||||
{
|
||||
_sendPutItemInContainer(item.ObjectId, _openContainer, placement);
|
||||
return true;
|
||||
});
|
||||
return true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private void OnExternalContainerChanged(ExternalContainerTransition transition)
|
||||
|
|
@ -314,10 +343,29 @@ public sealed class ExternalContainerController : IItemListDragHandler, IRetaine
|
|||
AddContainerCell(guid);
|
||||
}
|
||||
|
||||
var visibleContents = new List<uint>();
|
||||
foreach (uint guid in _objects.GetContents(_openContainer))
|
||||
{
|
||||
if (!IsContainer(_objects.Get(guid)))
|
||||
AddContentsCell(guid);
|
||||
visibleContents.Add(guid);
|
||||
}
|
||||
if (_pendingPlacement is { } pending
|
||||
&& pending.ContainerId == _openContainer
|
||||
&& _objects.Get(pending.ItemId) is { } pendingItem
|
||||
&& !IsContainer(pendingItem))
|
||||
{
|
||||
visibleContents.Remove(pending.ItemId);
|
||||
visibleContents.Insert(
|
||||
Math.Clamp(pending.Placement, 0, visibleContents.Count),
|
||||
pending.ItemId);
|
||||
}
|
||||
foreach (uint guid in visibleContents)
|
||||
{
|
||||
bool waiting = _itemInteraction.IsPendingInventorySource(guid)
|
||||
|| _pendingPlacement is { } projection
|
||||
&& projection.ContainerId == _openContainer
|
||||
&& projection.ItemId == guid;
|
||||
AddContentsCell(guid, waiting);
|
||||
}
|
||||
ApplyIndicators();
|
||||
}
|
||||
|
|
@ -334,15 +382,15 @@ public sealed class ExternalContainerController : IItemListDragHandler, IRetaine
|
|||
private void AddContainerCell(uint guid)
|
||||
{
|
||||
UiItemSlot cell = CreateCell(_containerList, guid, ItemDragSource.Ground);
|
||||
cell.Clicked = () => OpenNestedContainer(guid);
|
||||
SetCapacity(cell, guid);
|
||||
_containerList.AddItem(cell);
|
||||
}
|
||||
|
||||
private void AddContentsCell(uint guid)
|
||||
private void AddContentsCell(uint guid, bool waiting = false)
|
||||
{
|
||||
UiItemSlot cell = CreateCell(_contentsList, guid, ItemDragSource.Ground);
|
||||
cell.DoubleClicked = () => _itemInteraction.ActivateItem(guid);
|
||||
cell.SetWaitingState(waiting);
|
||||
cell.DragAcceptSprite = 0x060011F9u;
|
||||
cell.DragRejectSprite = 0x060011F8u;
|
||||
_contentsList.AddItem(cell);
|
||||
|
|
@ -387,7 +435,10 @@ public sealed class ExternalContainerController : IItemListDragHandler, IRetaine
|
|||
{
|
||||
if (_itemInteraction.OfferPrimaryClick(guid) != ItemPrimaryClickResult.NotActive)
|
||||
return true;
|
||||
Select(guid);
|
||||
if (IsContainer(_objects.Get(guid)) && guid != _state.CurrentContainerId)
|
||||
OpenNestedContainer(guid);
|
||||
else
|
||||
Select(guid);
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
@ -406,7 +457,8 @@ public sealed class ExternalContainerController : IItemListDragHandler, IRetaine
|
|||
bool pendingSource = _itemInteraction.IsPendingSource(cell.ItemId);
|
||||
cell.Selected = cell.ItemId != 0u
|
||||
&& cell.ItemId == _selection.SelectedObjectId
|
||||
&& !pendingSource;
|
||||
&& !pendingSource
|
||||
&& !_itemInteraction.IsPendingInventorySource(cell.ItemId);
|
||||
cell.IsOpenContainer = cell.ItemId != 0u && cell.ItemId == _openContainer;
|
||||
}
|
||||
}
|
||||
|
|
@ -486,7 +538,48 @@ public sealed class ExternalContainerController : IItemListDragHandler, IRetaine
|
|||
}
|
||||
|
||||
private void OnSelectionChanged(SelectionTransition _) => ApplyIndicators();
|
||||
private void OnInteractionStateChanged() => ApplyIndicators();
|
||||
private void OnInteractionStateChanged()
|
||||
{
|
||||
if (_window.IsVisible)
|
||||
Populate();
|
||||
else
|
||||
ApplyIndicators();
|
||||
}
|
||||
|
||||
private void OnPendingPlacementRequested(PendingBackpackPlacement pending)
|
||||
{
|
||||
if (pending.ContainerId != _openContainer)
|
||||
return;
|
||||
_pendingPlacement = pending;
|
||||
if (_window.IsVisible)
|
||||
Populate();
|
||||
}
|
||||
|
||||
private void OnPendingPlacementCancelled(PendingBackpackPlacement pending)
|
||||
=> ResolvePendingPlacement(pending);
|
||||
|
||||
private void OnPendingPlacementResolved(PendingBackpackPlacement pending)
|
||||
=> ResolvePendingPlacement(pending);
|
||||
|
||||
private void ResolvePendingPlacement(PendingBackpackPlacement pending)
|
||||
{
|
||||
if (_pendingPlacement is not { } current || current.Token != pending.Token)
|
||||
return;
|
||||
_pendingPlacement = null;
|
||||
if (_window.IsVisible)
|
||||
Populate();
|
||||
}
|
||||
|
||||
private InventoryContainerPlacementRejection EvaluateDrop(uint itemId)
|
||||
{
|
||||
if (_objects.Get(itemId) is { } source && IsContainer(source))
|
||||
return InventoryContainerPlacementRejection.ContainerCapacityFull;
|
||||
return InventoryContainerPlacementPolicy.Evaluate(
|
||||
_objects,
|
||||
itemId,
|
||||
_openContainer,
|
||||
playerId: 0u);
|
||||
}
|
||||
|
||||
private static bool IsContainer(ClientObject? item)
|
||||
=> item is not null
|
||||
|
|
@ -573,6 +666,9 @@ public sealed class ExternalContainerController : IItemListDragHandler, IRetaine
|
|||
_objects.Cleared -= OnObjectsCleared;
|
||||
_selection.Changed -= OnSelectionChanged;
|
||||
_itemInteraction.StateChanged -= OnInteractionStateChanged;
|
||||
_itemInteraction.PendingBackpackPlacementRequested -= OnPendingPlacementRequested;
|
||||
_itemInteraction.PendingBackpackPlacementCancelled -= OnPendingPlacementCancelled;
|
||||
_itemInteraction.PendingBackpackPlacementResolved -= OnPendingPlacementResolved;
|
||||
_topContainer.PrimaryItemPressed = null;
|
||||
_containerList.PrimaryItemPressed = null;
|
||||
_contentsList.PrimaryItemPressed = null;
|
||||
|
|
|
|||
|
|
@ -116,6 +116,8 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
|||
_itemInteraction = itemInteraction;
|
||||
_stackSplitQuantity = stackSplitQuantity;
|
||||
_selection = selection ?? throw new ArgumentNullException(nameof(selection));
|
||||
if (_itemInteraction is not null)
|
||||
_itemInteraction.MergeAttempted += OnMergeAttempted;
|
||||
|
||||
WindowChromeController.BindCloseButton(layout, onClose);
|
||||
|
||||
|
|
@ -299,14 +301,13 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
|||
if (containerId == EffectiveOpen() || containerId == _playerGuid())
|
||||
Populate();
|
||||
}
|
||||
private void OnInteractionStateChanged() => ApplyIndicators();
|
||||
private void OnInteractionStateChanged() => Populate();
|
||||
private void OnPendingBackpackPlacementRequested(PendingBackpackPlacement pending)
|
||||
{
|
||||
if (_pendingListPlacement is not null
|
||||
|| pending.ItemId == 0u
|
||||
|| pending.ContainerId != EffectiveOpen()
|
||||
|| _objects.Get(pending.ItemId) is not { } item
|
||||
|| IsBag(item))
|
||||
|| pending.ContainerId == 0u
|
||||
|| _objects.Get(pending.ItemId) is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
|
@ -375,12 +376,33 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
|||
|
||||
// Side-bag column: ALWAYS the player's bags (constant across container switches; only the
|
||||
// open/selected indicators move). Equipped items never appear here.
|
||||
var visibleBags = new List<uint>();
|
||||
foreach (var guid in _objects.GetContents(p))
|
||||
{
|
||||
var item = _objects.Get(guid);
|
||||
if (item is null || item.CurrentlyEquippedLocation != EquipMask.None) continue;
|
||||
bool isBag = IsBag(item);
|
||||
if (isBag) AddCell(_containerList, guid, isContainer: true);
|
||||
if (isBag) visibleBags.Add(guid);
|
||||
}
|
||||
|
||||
PendingListPlacement? pending = _pendingListPlacement;
|
||||
if (pending is { } bagProjection
|
||||
&& bagProjection.ContainerId == p
|
||||
&& _objects.Get(bagProjection.ItemId) is { } pendingBag
|
||||
&& IsBag(pendingBag))
|
||||
{
|
||||
visibleBags.Remove(bagProjection.ItemId);
|
||||
int index = Math.Clamp(bagProjection.Placement, 0, visibleBags.Count);
|
||||
visibleBags.Insert(index, bagProjection.ItemId);
|
||||
}
|
||||
|
||||
foreach (uint guid in visibleBags)
|
||||
{
|
||||
bool waiting = IsWaitingSource(guid)
|
||||
|| pending is { } waitingBagProjection
|
||||
&& waitingBagProjection.ContainerId == p
|
||||
&& waitingBagProjection.ItemId == guid;
|
||||
AddCell(_containerList, guid, isContainer: true, waiting);
|
||||
}
|
||||
|
||||
// Contents grid: the OPEN container's loose items. (Bags live in the column; a side bag has
|
||||
|
|
@ -394,20 +416,20 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
|||
if (!isBag) visibleContents.Add(guid);
|
||||
}
|
||||
|
||||
PendingListPlacement? pending = _pendingListPlacement;
|
||||
if (pending is { } projection
|
||||
&& projection.ContainerId == open
|
||||
&& !visibleContents.Contains(projection.ItemId)
|
||||
&& _objects.Get(projection.ItemId) is { } pendingItem
|
||||
&& !IsBag(pendingItem))
|
||||
{
|
||||
visibleContents.Remove(projection.ItemId);
|
||||
int index = Math.Clamp(projection.Placement, 0, visibleContents.Count);
|
||||
visibleContents.Insert(index, projection.ItemId);
|
||||
}
|
||||
|
||||
foreach (uint guid in visibleContents)
|
||||
{
|
||||
bool waiting = pending is { } waitingProjection
|
||||
bool waiting = IsWaitingSource(guid)
|
||||
|| pending is { } waitingProjection
|
||||
&& waitingProjection.ContainerId == open
|
||||
&& waitingProjection.ItemId == guid;
|
||||
AddCell(_contentsGrid, guid, isContainer: false, waiting);
|
||||
|
|
@ -455,8 +477,8 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
|||
dragIconTexture: _dragIconIds?.Invoke(
|
||||
ItemType.Container, PlayerPackBaseIcon, 0u, 0u, 0u) ?? 0u);
|
||||
main.DragAcceptSprite = 0x060011F7u; main.DragRejectSprite = 0x060011F8u;
|
||||
main.SetWaitingState(IsWaitingSource(p));
|
||||
main.Clicked = () => OpenContainer(p);
|
||||
main.DoubleClicked = () => _itemInteraction?.ActivateItem(p);
|
||||
SetCapacityBar(main, p); // main-pack fullness (items / ItemsCapacity)
|
||||
_topContainer.AddItem(main);
|
||||
}
|
||||
|
|
@ -474,6 +496,21 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
|||
|| item.Type.HasFlag(ItemType.Container)
|
||||
|| item.ItemsCapacity > 0;
|
||||
|
||||
private int CountLooseContents(uint containerId)
|
||||
{
|
||||
int count = 0;
|
||||
foreach (uint guid in _objects.GetContents(containerId))
|
||||
{
|
||||
if (_objects.Get(guid) is { } item
|
||||
&& item.CurrentlyEquippedLocation == EquipMask.None
|
||||
&& !IsBag(item))
|
||||
{
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private uint EffectiveOpen() => _openContainer != 0 ? _openContainer : _playerGuid();
|
||||
|
||||
/// <summary>The owned destination retail PlaceInBackpack currently uses.</summary>
|
||||
|
|
@ -499,12 +536,15 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
|||
cell.SetWaitingState(waiting);
|
||||
cell.SlotIndex = list.GetNumUIItems(); // index it will occupy (== its slot in a packed list)
|
||||
ConfigureDropFeedback(list, cell);
|
||||
cell.DoubleClicked = () => _itemInteraction?.ActivateItem(guid);
|
||||
if (isContainer)
|
||||
{
|
||||
cell.Clicked = () => OpenContainer(guid);
|
||||
SetCapacityBar(cell, guid);
|
||||
}
|
||||
else
|
||||
{
|
||||
cell.DoubleClicked = () => _itemInteraction?.ActivateItem(guid);
|
||||
}
|
||||
list.AddItem(cell);
|
||||
}
|
||||
|
||||
|
|
@ -513,7 +553,10 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
|||
if (_itemInteraction?.OfferPrimaryClick(guid)
|
||||
is not null and not ItemPrimaryClickResult.NotActive)
|
||||
return true;
|
||||
SelectItem(guid);
|
||||
if (_objects.Get(guid) is { } item && IsBag(item))
|
||||
OpenContainer(guid);
|
||||
else
|
||||
SelectItem(guid);
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
@ -522,7 +565,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
|||
if (_itemInteraction?.OfferSelfPrimaryClick()
|
||||
is not null and not ItemPrimaryClickResult.NotActive)
|
||||
return true;
|
||||
SelectItem(guid);
|
||||
OpenContainer(guid);
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
@ -556,11 +599,15 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
|||
{
|
||||
int cap = _objects.Get(containerGuid)?.ItemsCapacity ?? 0;
|
||||
if (cap <= 0) { cell.CapacityFill = -1f; return; }
|
||||
int n = _objects.GetContents(containerGuid).Count;
|
||||
// Player contents contain two independent retail lists: loose items
|
||||
// and side packs. ItemsCapacity applies only to the former; counting
|
||||
// packs here made a main pack stay visually/full logically rejected
|
||||
// even after the player freed an item slot.
|
||||
int n = CountLooseContents(containerGuid);
|
||||
cell.CapacityFill = Math.Clamp(n / (float)cap, 0f, 1f);
|
||||
}
|
||||
|
||||
// ── IItemListDragHandler (B-Drag) — drop an item to move it (optimistic + wire) ──────────────
|
||||
// ── IItemListDragHandler (B-Drag) — request first; server owns placement ────────────────────
|
||||
/// <summary>Retail ItemList_BeginDrag selects an unselected item before enabling its waiting
|
||||
/// mesh. Inventory items do not lift-remove (unlike the toolbar): the item stays in its slot
|
||||
/// until the server confirms the eventual drop.</summary>
|
||||
|
|
@ -583,35 +630,15 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
|||
// remove-on-lift stands.
|
||||
if (payload.SourceKind == ItemDragSource.ShortcutBar)
|
||||
return ItemDragAcceptance.None;
|
||||
if (payload.ObjId == 0)
|
||||
return ItemDragAcceptance.Reject;
|
||||
bool sourceIsBag = _objects.Get(payload.ObjId) is { } source && IsBag(source);
|
||||
if (targetList == _contentsGrid)
|
||||
return sourceIsBag
|
||||
? ItemDragAcceptance.Reject
|
||||
: ItemDragAcceptance.Accept;
|
||||
if (targetList == _containerList || targetList == _topContainer)
|
||||
{
|
||||
// UIElement_ItemList::ItemList_DragOver @0x004E3400 checks the
|
||||
// dragged object's container flag before interpreting this list.
|
||||
// A container drag addresses the player's contained-container
|
||||
// list itself; an empty authored slot is therefore a valid pack
|
||||
// destination rather than "no target".
|
||||
if (sourceIsBag)
|
||||
return targetCell.ItemId == payload.ObjId
|
||||
? ItemDragAcceptance.Reject
|
||||
: ItemDragAcceptance.Accept;
|
||||
if (targetCell.ItemId == 0 || targetCell.ItemId == payload.ObjId)
|
||||
return ItemDragAcceptance.Reject;
|
||||
return IsContainerFull(targetCell.ItemId)
|
||||
? ItemDragAcceptance.Reject
|
||||
: ItemDragAcceptance.Accept;
|
||||
}
|
||||
return ItemDragAcceptance.Reject;
|
||||
return EvaluateDrop(targetList, targetCell, payload.ObjId, out _, out _)
|
||||
== InventoryContainerPlacementRejection.None
|
||||
? ItemDragAcceptance.Accept
|
||||
: ItemDragAcceptance.Reject;
|
||||
}
|
||||
|
||||
/// <summary>Resolve the destination and either split or move the stack. A partial split waits
|
||||
/// for the server-created object's guid; a whole move remains optimistic. Retail:
|
||||
/// for the server-created object's guid; a whole move displays only the
|
||||
/// destination list's waiting projection until the server responds. Retail:
|
||||
/// <c>ItemHolder::AttemptToPlaceInContainer @ 0x00588140</c>.</summary>
|
||||
public void HandleDropRelease(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload)
|
||||
{
|
||||
|
|
@ -627,8 +654,24 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
|||
// DropReleased is still delivered to the list after a reject overlay;
|
||||
// pin the release to the same retail policy instead of relying on the
|
||||
// advisory color alone.
|
||||
if (OnDragOver(targetList, targetCell, payload) != ItemDragAcceptance.Accept)
|
||||
InventoryContainerPlacementRejection legality = EvaluateDrop(
|
||||
targetList,
|
||||
targetCell,
|
||||
item,
|
||||
out _,
|
||||
out uint legalityDestination);
|
||||
if (legality != InventoryContainerPlacementRejection.None)
|
||||
{
|
||||
if (InventoryContainerPlacementPolicy.ComposeClientLocal(
|
||||
legality,
|
||||
_objects.Get(item),
|
||||
_objects.Get(legalityDestination),
|
||||
_playerGuid()) is { } refusal)
|
||||
{
|
||||
_itemInteraction?.ReportClientLocal(refusal);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// UIElement_ItemList::AcceptDragObject @ 0x004E4250 rejects every
|
||||
// release while m_pendingItem exists, before merge, split, or ordinary
|
||||
|
|
@ -662,7 +705,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
|||
container = EffectiveOpen();
|
||||
placement = targetCell.ItemId != 0
|
||||
? targetCell.SlotIndex // insert-before = the target's GRID INDEX (gapless), not its raw ContainerSlot
|
||||
: _objects.GetContents(container).Count; // first empty = append
|
||||
: CountLooseContents(container); // first empty = append after visible loose items
|
||||
}
|
||||
else if (targetList == _containerList || targetList == _topContainer)
|
||||
{
|
||||
|
|
@ -697,79 +740,65 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
|||
{
|
||||
// UIAttemptSplitToContainer leaves the source stack where it is. ACE will
|
||||
// publish the reduced source plus a newly-guided destination stack.
|
||||
DispatchInventoryRequest(
|
||||
InventoryRequestKind.SplitToContainer,
|
||||
item,
|
||||
() =>
|
||||
{
|
||||
if (_sendStackableSplitToContainer is null)
|
||||
return false;
|
||||
_sendStackableSplitToContainer(
|
||||
item,
|
||||
container,
|
||||
(uint)placement,
|
||||
splitSize);
|
||||
return true;
|
||||
});
|
||||
if (_itemInteraction is not null)
|
||||
{
|
||||
_itemInteraction.TrySplitToContainer(
|
||||
item,
|
||||
container,
|
||||
(uint)placement,
|
||||
splitSize);
|
||||
}
|
||||
else
|
||||
{
|
||||
DispatchInventoryRequest(
|
||||
InventoryRequestKind.SplitToContainer,
|
||||
item,
|
||||
() =>
|
||||
{
|
||||
if (_sendStackableSplitToContainer is null)
|
||||
return false;
|
||||
_sendStackableSplitToContainer(
|
||||
item,
|
||||
container,
|
||||
(uint)placement,
|
||||
splitSize);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// External-container contents retain canonical ownership while the request
|
||||
// is in flight, but retail immediately inserts an m_pendingItem copy into
|
||||
// the chosen destination slot and ghosts it. The server move/failure notice
|
||||
// resolves that visual projection. UIElement_ItemList::HandleDropRelease
|
||||
// @ 0x004E4790; ACCWeenieObject::UIAttemptPutInContainer @ 0x0058D680.
|
||||
if (payload.SourceKind == ItemDragSource.Ground)
|
||||
// Canonical ownership never changes on request. Retail immediately
|
||||
// publishes the destination ItemList's m_pendingItem projection and
|
||||
// resolves it from the server move/failure response.
|
||||
if (_itemInteraction is not null)
|
||||
{
|
||||
if (_itemInteraction is not null)
|
||||
{
|
||||
if (!_itemInteraction.TryDispatchPendingBackpackPlacement(
|
||||
item,
|
||||
container,
|
||||
placement,
|
||||
InventoryRequestKind.Pickup,
|
||||
() =>
|
||||
{
|
||||
if (_sendPutItemInContainer is null)
|
||||
return false;
|
||||
_sendPutItemInContainer(item, container, placement);
|
||||
return true;
|
||||
}))
|
||||
{
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (_pendingListPlacement is not null)
|
||||
return;
|
||||
_pendingListPlacement = new PendingListPlacement(0u, item, container, placement);
|
||||
Populate();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (_itemInteraction is not null)
|
||||
{
|
||||
DispatchInventoryRequest(
|
||||
InventoryRequestKind.PutInContainer,
|
||||
InventoryRequestKind kind = payload.SourceKind == ItemDragSource.Ground
|
||||
? InventoryRequestKind.Pickup
|
||||
: InventoryRequestKind.PutInContainer;
|
||||
if (!_itemInteraction.TryDispatchPendingBackpackPlacement(
|
||||
item,
|
||||
container,
|
||||
placement,
|
||||
kind,
|
||||
() =>
|
||||
{
|
||||
if (_sendPutItemInContainer is null
|
||||
|| !_objects.MoveItemOptimistic(item, container, placement))
|
||||
{
|
||||
if (_sendPutItemInContainer is null)
|
||||
return false;
|
||||
}
|
||||
_sendPutItemInContainer(item, container, placement);
|
||||
return true;
|
||||
});
|
||||
}))
|
||||
{
|
||||
return;
|
||||
}
|
||||
_objects.MoveItemOptimistic(item, container, placement);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_pendingListPlacement is not null)
|
||||
return;
|
||||
_pendingListPlacement = new PendingListPlacement(0u, item, container, placement);
|
||||
Populate();
|
||||
_sendPutItemInContainer?.Invoke(item, container, placement);
|
||||
}
|
||||
|
||||
|
|
@ -832,9 +861,57 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
|||
{
|
||||
int cap = _objects.Get(container)?.ItemsCapacity ?? 0;
|
||||
if (cap <= 0) return false;
|
||||
return _objects.GetContents(container).Count >= cap;
|
||||
return CountLooseContents(container) >= cap;
|
||||
}
|
||||
|
||||
private void OnMergeAttempted(uint sourceId, uint targetId)
|
||||
{
|
||||
_notifyMergeAttempt?.Invoke(sourceId, targetId);
|
||||
_selection.Select(targetId, SelectionChangeSource.Inventory);
|
||||
}
|
||||
|
||||
private InventoryContainerPlacementRejection EvaluateDrop(
|
||||
UiItemList targetList,
|
||||
UiItemSlot targetCell,
|
||||
uint itemId,
|
||||
out bool sourceIsBag,
|
||||
out uint destinationId)
|
||||
{
|
||||
sourceIsBag = _objects.Get(itemId) is { } source && IsBag(source);
|
||||
destinationId = 0u;
|
||||
if (itemId == 0u)
|
||||
return InventoryContainerPlacementRejection.InvalidItem;
|
||||
|
||||
if (ReferenceEquals(targetList, _contentsGrid))
|
||||
{
|
||||
destinationId = EffectiveOpen();
|
||||
// Carried containers belong to the authored container selector,
|
||||
// never the loose-item grid, even when both address the player.
|
||||
if (sourceIsBag)
|
||||
return InventoryContainerPlacementRejection.ContainerCapacityFull;
|
||||
}
|
||||
else if (ReferenceEquals(targetList, _containerList)
|
||||
|| ReferenceEquals(targetList, _topContainer))
|
||||
{
|
||||
destinationId = sourceIsBag ? _playerGuid() : targetCell.ItemId;
|
||||
if (!sourceIsBag && (targetCell.ItemId == 0u || targetCell.ItemId == itemId))
|
||||
return InventoryContainerPlacementRejection.InvalidDestination;
|
||||
}
|
||||
else
|
||||
{
|
||||
return InventoryContainerPlacementRejection.InvalidDestination;
|
||||
}
|
||||
|
||||
return InventoryContainerPlacementPolicy.Evaluate(
|
||||
_objects,
|
||||
itemId,
|
||||
destinationId,
|
||||
_playerGuid());
|
||||
}
|
||||
|
||||
private bool IsWaitingSource(uint itemGuid)
|
||||
=> _itemInteraction?.IsPendingInventorySource(itemGuid) == true;
|
||||
|
||||
/// <summary>Select an item (panel-wide green square) without changing the open container or
|
||||
/// touching the wire. Retail: UIElement_ItemList::ItemList_SetSelectedItem (0x004e2fe0).</summary>
|
||||
private void SelectItem(uint guid)
|
||||
|
|
@ -895,7 +972,8 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
|||
{
|
||||
var cell = list.GetItem(i);
|
||||
if (cell is null) continue;
|
||||
bool pendingTargetSource = _itemInteraction?.IsPendingSource(cell.ItemId) == true;
|
||||
bool pendingTargetSource = _itemInteraction?.IsPendingSource(cell.ItemId) == true
|
||||
|| IsWaitingSource(cell.ItemId);
|
||||
cell.Selected = cell.ItemId != 0
|
||||
&& cell.ItemId == _selection.SelectedObjectId
|
||||
&& !pendingTargetSource;
|
||||
|
|
@ -1012,6 +1090,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
|||
}
|
||||
if (_itemInteraction is not null)
|
||||
{
|
||||
_itemInteraction.MergeAttempted -= OnMergeAttempted;
|
||||
_itemInteraction.StateChanged -= OnInteractionStateChanged;
|
||||
_itemInteraction.PendingBackpackPlacementRequested -= OnPendingBackpackPlacementRequested;
|
||||
_itemInteraction.PendingBackpackPlacementCancelled -= OnPendingBackpackPlacementCancelled;
|
||||
|
|
|
|||
|
|
@ -180,6 +180,9 @@ public sealed class JournalPanelController : IRetainedPanelController
|
|||
/// <summary>Switches to the notes tab — what opening a page from the index does.</summary>
|
||||
public void ShowNotes() => _tabPanel.SwitchTo(NotesPageId);
|
||||
|
||||
/// <summary>Switches to the authored journal index tab.</summary>
|
||||
public void ShowPageList() => _tabPanel.SwitchTo(PageListPageId);
|
||||
|
||||
/// <summary>
|
||||
/// Completes construction. The index needs a callback that switches tabs,
|
||||
/// which needs the panel — so it is attached rather than constructed.
|
||||
|
|
|
|||
|
|
@ -66,19 +66,17 @@ namespace AcDream.App.UI.Layout;
|
|||
///
|
||||
/// <para>
|
||||
/// <b>Row identity and binding storage (D4).</b> Every row's identity is the DAT
|
||||
/// pair <c>(InputMapId, ActionId)</c> — retail's own row key. Where
|
||||
/// <see cref="RetailActionIdentityTable"/> resolves that pair to an acdream
|
||||
/// <see cref="InputAction"/> (research: roughly half of the DAT's 306 rows — see
|
||||
/// that table's class doc for the full accounting), the row's bindings ARE
|
||||
/// pair <c>(InputMapId, ActionId)</c> — retail's own row key. The installed EoR
|
||||
/// ActionMap's 306 pairs each resolve to one distinct acdream
|
||||
/// <see cref="InputAction"/>; the row's bindings ARE
|
||||
/// <see cref="KeyBindings"/>'s bindings for that action: a rebind here takes
|
||||
/// effect immediately for live gameplay dispatch through the SAME
|
||||
/// <see cref="InputDispatcher"/> every other input path uses, and persists to
|
||||
/// <c>keybinds.json</c> exactly like any other rebind (D4 — no separate
|
||||
/// <c>.keymap</c> file format). Where no <see cref="InputAction"/> exists yet
|
||||
/// (mostly Emotes and CharacterSettings — see the identity table's class doc),
|
||||
/// the row is still fully rendered, bindable, conflict-checked, and persisted
|
||||
/// (<see cref="Bindings.CurrentForUnmapped"/>/<see cref="Bindings.SetForUnmapped"/>),
|
||||
/// it just has no live gameplay consumer yet (register row).
|
||||
/// <see cref="InputDispatcher"/> every other input path uses. Retail Load File /
|
||||
/// Save As exchange the original PFile <c>*.keymap</c> format; acdream also writes
|
||||
/// <c>keybinds.json</c> as its portable mirror for host-only commands. The
|
||||
/// nullable/unmapped delegates remain solely
|
||||
/// so an unknown future-DAT row stays visible and round-trippable instead of
|
||||
/// crashing an older client.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
|
|
@ -90,8 +88,8 @@ namespace AcDream.App.UI.Layout;
|
|||
/// against every multi-chord action in <c>KeyBindings.RetailDefaults()</c>:
|
||||
/// walk-mode's Hold, the three melee/missile/magic combat scopes, ...), so this
|
||||
/// row captures that pair ONCE at build time (from the first live binding, or
|
||||
/// <see cref="ActivationType.Press"/>/<see cref="InputScope.Game"/> if the action
|
||||
/// starts wholly unbound) and reapplies it to every chord this row ever writes —
|
||||
/// the retail identity table if the action starts wholly unbound) and reapplies
|
||||
/// it to every chord this row ever writes —
|
||||
/// on a live rebind, on Cancel/Revert (<c>RestoreSavedValue</c>), and on Defaults
|
||||
/// (<c>RestoreDefaultValue</c>, which restores DAT-sourced KEYS only; Activation/
|
||||
/// Scope are retail-side properties of the ACTION, not of which physical key
|
||||
|
|
@ -113,9 +111,8 @@ namespace AcDream.App.UI.Layout;
|
|||
/// ANY conflicting target is non-user-bindable). This port's non-user-bindable
|
||||
/// analogue is a chord already bound to an acdream-only action with no
|
||||
/// <see cref="RetailActionIdentityTable"/> row at all (Ctrl+M mute, the debug
|
||||
/// F-keys, ...) — refused via <see cref="Bindings.NonBindableRefusalText"/>
|
||||
/// exactly like retail's distinct <c>OpenCantOverwriteBindingDialog</c>, with no
|
||||
/// dialog (a hard stop, matching the DAT-verified refusal string). A genuine
|
||||
/// F-keys, ...) — refused through retail's type-3
|
||||
/// <c>OpenCantOverwriteBindingDialog</c> with the exact DAT template. A genuine
|
||||
/// cross-row conflict collects EVERY conflicting row (not just the first) and
|
||||
/// opens a real confirm dialog through <see cref="Bindings.ConfirmOverwrite"/> —
|
||||
/// retail's <c>OpenOverwriteBindingDialog(&conflicts)</c> — BEFORE reassigning;
|
||||
|
|
@ -123,15 +120,10 @@ namespace AcDream.App.UI.Layout;
|
|||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Caption dimming (AD-78, user-directed, 2026-08-11, gate 2).</b> A row
|
||||
/// whose <see cref="RowView.MappedAction"/> is null (AP-203's store-only
|
||||
/// set — mostly Emotes and CharacterSettings, plus every non-user-bindable
|
||||
/// InputMap this screen renders) dims its synthesized caption via
|
||||
/// <see cref="UiRenderContext.StoreOnlyCaptionColor"/> in
|
||||
/// <see cref="BuildActionRow"/>. The row stays fully rendered, bindable,
|
||||
/// conflict-checked, and persisted (per the paragraph above) — only the
|
||||
/// caption color changes, so the dim is a visual "no live gameplay consumer
|
||||
/// yet" marker, not a functional restriction.
|
||||
/// Campaign KB maps all 306 installed EoR rows to distinct live actions, so
|
||||
/// every authored command is enabled and uses the normal caption color. The
|
||||
/// nullable defensive path remains only to make an unknown future DAT row
|
||||
/// visible without crashing an older client.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class KeyboardConfigController
|
||||
|
|
@ -204,8 +196,14 @@ public sealed class KeyboardConfigController
|
|||
Action<Action<KeyChord?>> BeginCapture,
|
||||
Action Save,
|
||||
Action Toggle,
|
||||
Action<string> DisplaySystemMessage,
|
||||
string NonBindableRefusalText,
|
||||
// Resolves one of retail's ID_ActionKeyMap_* templates from string-table
|
||||
// enum 0x10000004 (installed DID 0x23000004). Null means the retail text
|
||||
// is unavailable; callers then leave the operation inert instead of
|
||||
// inventing UI prose.
|
||||
Func<string, IReadOnlyDictionary<uint, string>, string?> ResolveTemplate,
|
||||
// Retail OpenCantOverwriteBindingDialog is a type-3 priority message on
|
||||
// keyboard queue 0x10000001, not a scrolling-chat/system message.
|
||||
Action<string> ShowMessage,
|
||||
// M3 (2026-08-11 review): retail's OpenOverwriteBindingDialog — confirm
|
||||
// BEFORE reassigning a chord already bound to another row on this screen.
|
||||
// message is pre-composed (real row labels, no invented retail text);
|
||||
|
|
@ -221,25 +219,36 @@ public sealed class KeyboardConfigController
|
|||
// or ESC). Null keeps the pre-dialog capture behavior for hosts with
|
||||
// no dialog factory (unit fixtures).
|
||||
Func<string, uint>? OpenCaptureInstructions = null,
|
||||
Action<uint>? CloseCaptureInstructions = null);
|
||||
Action<uint>? CloseCaptureInstructions = null,
|
||||
// Retail gmKeyboardUI's Load File / Save As workflows. Each opener
|
||||
// invokes its callback only after a successful profile operation.
|
||||
Func<string>? CurrentKeymapFilename = null,
|
||||
Action<Action>? OpenLoadKeymap = null,
|
||||
Action<Action>? OpenSaveKeymap = null);
|
||||
|
||||
public OptionPage Page { get; } = new();
|
||||
public IReadOnlyList<RowView> Rows => _rows;
|
||||
|
||||
private readonly List<RowView> _rows = new();
|
||||
private readonly Dictionary<(uint LayoutId, uint ElementId), UiDatFont?> _templateFontCache = new();
|
||||
private RetailActionMapSnapshot? _snapshot;
|
||||
private Bindings? _bindings;
|
||||
private Func<KeyChord, string> _describe = DescribeChord;
|
||||
private Func<uint, uint, UiDatFont?>? _resolveTemplateFont;
|
||||
|
||||
private static readonly uint ActionVariable = DatStringResolver.ComputeHash("ACTION");
|
||||
private static readonly uint BindingsVariable = DatStringResolver.ComputeHash("BINDINGS");
|
||||
private static readonly uint KeyVariable = DatStringResolver.ComputeHash("KEY");
|
||||
private static readonly uint LabelVariable = DatStringResolver.ComputeHash("LABEL");
|
||||
private static readonly uint ValueVariable = DatStringResolver.ComputeHash("VALUE");
|
||||
|
||||
private KeyboardConfigController() { }
|
||||
|
||||
/// <summary>
|
||||
/// Builds every header + row across all six pages from
|
||||
/// <paramref name="snapshot"/>, wires each row's key buttons to modal
|
||||
/// capture / right-click erase, and wires the screen's own six buttons
|
||||
/// (Defaults/Revert/OK/Cancel; Load/Save File are INERT — D4, no
|
||||
/// <c>.keymap</c> interchange). Returns null if the layout's window root
|
||||
/// (Load File/Save As/Defaults/Revert/OK/Cancel). Returns null if the layout's window root
|
||||
/// did not import (a missing/malformed LayoutDesc).
|
||||
/// </summary>
|
||||
public static KeyboardConfigController? Bind(
|
||||
|
|
@ -266,6 +275,7 @@ public sealed class KeyboardConfigController
|
|||
|
||||
var controller = new KeyboardConfigController
|
||||
{
|
||||
_snapshot = snapshot,
|
||||
_bindings = bindings,
|
||||
_resolveTemplateFont = resolveTemplateFont,
|
||||
// OP8 re-gate (2026-08-14): key-button captions through retail's
|
||||
|
|
@ -397,11 +407,9 @@ public sealed class KeyboardConfigController
|
|||
|
||||
// The row's own caption — synthesized, composed beside the authored key
|
||||
// buttons (UiText is sealed; see class doc). Occupies the "Command" column
|
||||
// (x=0..270, matching the authored column headers). AD-78 (user-directed,
|
||||
// 2026-08-11, gate 2): an unmapped row (MappedAction null — no live
|
||||
// InputDispatcher consumer, AP-203) dims its caption; the key buttons
|
||||
// themselves stay fully interactive (bindable/persisted/conflict-checked,
|
||||
// see class doc).
|
||||
// (x=0..270, matching the authored column headers). All 306 EoR rows
|
||||
// are mapped; the dim color is only a forward-compatible signal for
|
||||
// a row introduced by a different DAT revision.
|
||||
var captionText = new UiText
|
||||
{
|
||||
Left = 0f,
|
||||
|
|
@ -423,39 +431,41 @@ public sealed class KeyboardConfigController
|
|||
};
|
||||
if (label is not null)
|
||||
captionText.LinesProvider = () => new[] { new UiText.Line(label, captionText.DefaultColor) };
|
||||
// UIOption_ActionKeyMap::SetTooltip applies the ActionMap row's own
|
||||
// tooltip to the row, while Refresh replaces each key button's tooltip
|
||||
// with the dedicated existing/new-binding templates below.
|
||||
captionText.AuthoredTooltipText = tooltip;
|
||||
built.AddChild(captionText);
|
||||
|
||||
// M1: capture this row's live Activation/Scope ONCE, from the first
|
||||
// existing binding for the action (every multi-chord action in
|
||||
// KeyBindings.RetailDefaults() shares one Activation/Scope pair across
|
||||
// all its bindings — see class doc). Falls back to the Binding record's
|
||||
// own defaults (Press/Game) only when the action starts wholly unbound.
|
||||
// retail action-identity metadata when the action starts wholly unbound.
|
||||
IReadOnlyList<Binding> liveBindings = mapped
|
||||
? bindings.CurrentForAction(action)
|
||||
: Array.Empty<Binding>();
|
||||
(ActivationType Activation, InputScope Scope) template = liveBindings.Count > 0
|
||||
? (liveBindings[0].Activation, liveBindings[0].Scope)
|
||||
: (ActivationType.Press, InputScope.Game);
|
||||
: (
|
||||
RetailActionIdentityTable.ActivationFor(row.InputMapId, row.ActionId),
|
||||
RetailActionIdentityTable.ScopeForInputMap(row.InputMapId));
|
||||
|
||||
IReadOnlyList<KeyChord> defaults = DatDefaultsToChords(row.DefaultBindings);
|
||||
IReadOnlyList<KeyChord> storedUnmapped = mapped
|
||||
? Array.Empty<KeyChord>()
|
||||
: bindings.CurrentForUnmapped((row.InputMapId, row.ActionId));
|
||||
// OP8 re-review round 2 (SHOULD-FIX): an unmapped/store-only row with
|
||||
// no persisted chords displays its DAT DEFAULTS — retail shows the
|
||||
// authored bindings (the Camera Alternate rows' arrow keys) and a
|
||||
// blank row misreads as "unbound". Display-only: nothing here feeds
|
||||
// the InputDispatcher, and the store only gains the defaults if the
|
||||
// user actually edits the row (the apply closure below).
|
||||
// An unknown future-DAT row with no persisted chords displays its DAT
|
||||
// defaults. Installed EoR rows always take the mapped branch.
|
||||
IReadOnlyList<KeyChord> initial = mapped
|
||||
? liveBindings.Select(b => b.Chord).ToArray()
|
||||
: storedUnmapped.Count > 0 ? storedUnmapped : defaults;
|
||||
|
||||
var model = new ActionKeyMapOptionRow(initial, defaults, apply: value =>
|
||||
{
|
||||
// Interior/padding default(KeyChord) entries (S4 — sparse-slot
|
||||
// display, see ReplaceSlotValue) are never real bindings; filter
|
||||
// them out at the write boundary, not at storage time.
|
||||
// A legacy compatibility store can still contain padding
|
||||
// default(KeyChord) entries even though the retail production
|
||||
// editor is dense; never publish those sentinels as bindings.
|
||||
IReadOnlyList<KeyChord> real = value.Where(c => c != default).ToArray();
|
||||
if (mapped)
|
||||
bindings.SetForAction(
|
||||
|
|
@ -474,7 +484,6 @@ public sealed class KeyboardConfigController
|
|||
for (int slot = 0; slot < keyButtons.Count; slot++)
|
||||
{
|
||||
int capturedSlot = slot;
|
||||
keyButtons[slot].TooltipText = tooltip;
|
||||
keyButtons[slot].OnClick = () => BeginSlotCapture(view, capturedSlot, bindings);
|
||||
keyButtons[slot].OnRightClick = () => EraseSlot(view, capturedSlot);
|
||||
}
|
||||
|
|
@ -516,10 +525,36 @@ public sealed class KeyboardConfigController
|
|||
for (int i = 0; i < view.KeyButtons.Count; i++)
|
||||
{
|
||||
bool bound = i < current.Count && current[i] != default;
|
||||
view.KeyButtons[i].Label = bound ? _describe(current[i]) : null;
|
||||
if (!bound)
|
||||
{
|
||||
view.KeyButtons[i].Label = null;
|
||||
view.KeyButtons[i].TooltipText = ResolveTemplate(
|
||||
"ID_ActionKeyMap_TT_NewBinding",
|
||||
EmptyTemplateVariables);
|
||||
continue;
|
||||
}
|
||||
|
||||
string keyName = _describe(current[i]);
|
||||
string? buttonLabel = ResolveTemplate(
|
||||
"ID_ActionKeyMap_ButtonLabel",
|
||||
new Dictionary<uint, string> { [LabelVariable] = keyName });
|
||||
view.KeyButtons[i].Label = buttonLabel;
|
||||
view.KeyButtons[i].TooltipText = buttonLabel is null
|
||||
? null
|
||||
: ResolveTemplate(
|
||||
"ID_ActionKeyMap_TT_ExistingBinding",
|
||||
new Dictionary<uint, string> { [ValueVariable] = buttonLabel });
|
||||
}
|
||||
}
|
||||
|
||||
private static readonly IReadOnlyDictionary<uint, string> EmptyTemplateVariables =
|
||||
new Dictionary<uint, string>();
|
||||
|
||||
private string? ResolveTemplate(
|
||||
string key,
|
||||
IReadOnlyDictionary<uint, string> variables) =>
|
||||
_bindings?.ResolveTemplate(key, variables);
|
||||
|
||||
/// <summary>Raw enum spelling — construction-time default until Bind swaps
|
||||
/// in <see cref="RetailKeyNames.Describe"/>, and that class's own fallback
|
||||
/// for controls outside the DIK table.</summary>
|
||||
|
|
@ -548,13 +583,34 @@ public sealed class KeyboardConfigController
|
|||
}
|
||||
}
|
||||
|
||||
bindings.BeginCapture(captured =>
|
||||
void ArmCapture() => bindings.BeginCapture(captured =>
|
||||
{
|
||||
if (captured is { } unsupported && IsUnsupportedRetailCapture(unsupported))
|
||||
{
|
||||
// KeyHitHandler @0x004895AF..0x004895DF leaves its input
|
||||
// handler registered for joystick input and mouse buttons 0/1.
|
||||
// The authored MapInstructions says the same explicitly. Our
|
||||
// dispatcher capture is one-shot, so re-arm it while leaving
|
||||
// the existing wait dialog open.
|
||||
ArmCapture();
|
||||
return;
|
||||
}
|
||||
|
||||
if (instructionsContext != 0u)
|
||||
bindings.CloseCaptureInstructions?.Invoke(instructionsContext);
|
||||
|
||||
if (captured is not { } chord) return; // Escape — retail cancels silently.
|
||||
|
||||
// KeyHitHandler @ 0x0048963B..0x0048964A checks the row's own
|
||||
// current controls for an EXACT match before it performs any
|
||||
// cross-map conflict work. Choosing a chord already present in a
|
||||
// different slot of this row is therefore a silent no-op; it must
|
||||
// not duplicate the chord into the clicked slot (and must not be
|
||||
// rejected because an unrelated non-user-bindable action happens
|
||||
// to share it).
|
||||
if (view.Model.Current.Contains(chord))
|
||||
return;
|
||||
|
||||
(ConflictOutcome outcome, List<RowView> conflictRows) = FindConflicts(chord, exclude: view);
|
||||
switch (outcome)
|
||||
{
|
||||
|
|
@ -564,18 +620,23 @@ public sealed class KeyboardConfigController
|
|||
// conflicting target is non-user-bindable. This port's
|
||||
// analogue: a chord already bound to an acdream-only action
|
||||
// with no DAT row at all (Ctrl+M mute, the debug F-keys, ...) —
|
||||
// OpenCantOverwriteBindingDialog's ported refusal, no dialog.
|
||||
bindings.DisplaySystemMessage(bindings.NonBindableRefusalText);
|
||||
// OpenCantOverwriteBindingDialog @ 0x00489300: exact
|
||||
// ID_ActionKeyMap_NonUserBindableBinding(KEY) text in a
|
||||
// type-3 priority message dialog on queue 0x10000001.
|
||||
string? refusal = bindings.ResolveTemplate(
|
||||
"ID_ActionKeyMap_NonUserBindableBinding",
|
||||
new Dictionary<uint, string> { [KeyVariable] = _describe(chord) });
|
||||
if (refusal is not null)
|
||||
bindings.ShowMessage(refusal);
|
||||
return;
|
||||
|
||||
case ConflictOutcome.Rows:
|
||||
// M3: retail's OpenOverwriteBindingDialog — confirm BEFORE
|
||||
// reassigning (N-way: every conflicting row is named, not just
|
||||
// the first). Only on accept do the losing rows lose the slot.
|
||||
string names = string.Join(", ", conflictRows.Select(r => r.Label ?? "?"));
|
||||
string message =
|
||||
$"'{_describe(chord)}' is already bound to {names}. "
|
||||
+ $"Reassign it to '{view.Label}'?";
|
||||
string? message = ComposeOverwriteMessage(chord, conflictRows, bindings);
|
||||
if (message is null)
|
||||
return;
|
||||
bindings.ConfirmOverwrite(message, accepted =>
|
||||
{
|
||||
if (!accepted) return;
|
||||
|
|
@ -593,13 +654,76 @@ public sealed class KeyboardConfigController
|
|||
return;
|
||||
}
|
||||
});
|
||||
|
||||
ArmCapture();
|
||||
}
|
||||
|
||||
private static bool IsUnsupportedRetailCapture(KeyChord chord)
|
||||
{
|
||||
if (chord.Device > 1)
|
||||
return true; // joystick/unknown device
|
||||
if (chord.Device == 1
|
||||
&& (chord.Key == InputDispatcher.MouseButtonToKey(Silk.NET.Input.MouseButton.Left)
|
||||
|| chord.Key == InputDispatcher.MouseButtonToKey(Silk.NET.Input.MouseButton.Right)))
|
||||
return true;
|
||||
return !RetailScanCodeMap.TryToFileControl(chord, out _);
|
||||
}
|
||||
|
||||
private string? ComposeOverwriteMessage(
|
||||
KeyChord chord,
|
||||
IReadOnlyList<RowView> conflicts,
|
||||
Bindings bindings)
|
||||
{
|
||||
string keyName = _describe(chord);
|
||||
if (conflicts.Count == 1)
|
||||
{
|
||||
string? action = conflicts[0].Label;
|
||||
if (action is null) return null;
|
||||
return bindings.ResolveTemplate(
|
||||
"ID_ActionKeyMap_OverwriteExistingBinding",
|
||||
new Dictionary<uint, string>
|
||||
{
|
||||
[KeyVariable] = keyName,
|
||||
[ActionVariable] = action,
|
||||
});
|
||||
}
|
||||
|
||||
var lines = new List<string>(conflicts.Count);
|
||||
foreach (RowView conflict in conflicts)
|
||||
{
|
||||
if (conflict.Label is null) return null;
|
||||
string? line = bindings.ResolveTemplate(
|
||||
"ID_ActionKeyMap_Binding",
|
||||
new Dictionary<uint, string>
|
||||
{
|
||||
[ActionVariable] = conflict.Label,
|
||||
[KeyVariable] = keyName,
|
||||
});
|
||||
if (line is null) return null;
|
||||
lines.Add(line);
|
||||
}
|
||||
|
||||
return bindings.ResolveTemplate(
|
||||
"ID_ActionKeyMap_OverwriteExistingBindings",
|
||||
new Dictionary<uint, string>
|
||||
{
|
||||
[KeyVariable] = keyName,
|
||||
[BindingsVariable] = string.Join("\n", lines),
|
||||
});
|
||||
}
|
||||
|
||||
private void ApplySlot(RowView view, int slot, KeyChord chord)
|
||||
{
|
||||
List<KeyChord> updated = new(view.Model.Current);
|
||||
while (updated.Count <= slot) updated.Add(default);
|
||||
updated[slot] = chord;
|
||||
// SetBinding @ 0x00487B32..0x00487B47 clamps a requested slot past
|
||||
// m_qclCurrent.Count to Count. Retail's bindings are a dense list:
|
||||
// clicking Mapping 3 on an empty row appends at Mapping 1; clicking it
|
||||
// on a one-binding row appends at Mapping 2.
|
||||
int targetSlot = Math.Clamp(slot, 0, updated.Count);
|
||||
if (targetSlot == updated.Count)
|
||||
updated.Add(chord);
|
||||
else
|
||||
updated[targetSlot] = chord;
|
||||
ReplaceSlotValue(view, updated);
|
||||
RefreshRowButtons(view);
|
||||
}
|
||||
|
|
@ -616,13 +740,9 @@ public sealed class KeyboardConfigController
|
|||
|
||||
private static void ReplaceSlotValue(RowView view, IReadOnlyList<KeyChord> value)
|
||||
{
|
||||
// S4 (2026-08-11 review): only trim TRAILING empty slots. Retail's
|
||||
// SetBinding(qc, slot) writes the SPECIFIC slot the user clicked — a row
|
||||
// with no bindings whose "Mapping 3" button is set must keep the chord at
|
||||
// display index 2, not collapse it onto index 0. Interior default(KeyChord)
|
||||
// entries only ever come from ApplySlot's own padding, so trimming just the
|
||||
// tail keeps RefreshRowButtons' positional read correct without inventing a
|
||||
// nullable-chord storage type.
|
||||
// The production path is dense (ApplySlot clamps to Count and erase
|
||||
// removes an element). Keep the trailing-default trim as a defensive
|
||||
// boundary for compatibility stores created by older schema versions.
|
||||
int lastReal = -1;
|
||||
for (int i = 0; i < value.Count; i++)
|
||||
if (value[i] != default) lastReal = i;
|
||||
|
|
@ -639,9 +759,8 @@ public sealed class KeyboardConfigController
|
|||
/// <c>ICIDM::FindConflictingInputMaps</c>/<c>FindConflictingControls</c>),
|
||||
/// scoped to this screen's own universe: the non-user-bindable check runs
|
||||
/// FIRST (S1 — retail's own order), then EVERY OTHER row's current chord set
|
||||
/// (covers BOTH mapped and unmapped rows — a chord already claimed by an
|
||||
/// unmapped row is just as real a conflict as one claimed by a mapped one) is
|
||||
/// collected in full, not just the first match.
|
||||
/// (all 306 installed EoR rows are mapped) is collected in full, not just
|
||||
/// the first match.
|
||||
/// </summary>
|
||||
private (ConflictOutcome Outcome, List<RowView> Rows) FindConflicts(KeyChord chord, RowView exclude)
|
||||
{
|
||||
|
|
@ -659,15 +778,20 @@ public sealed class KeyboardConfigController
|
|||
foreach (RowView other in _rows)
|
||||
{
|
||||
if (ReferenceEquals(other, exclude)) continue;
|
||||
// OP8 re-review round 2 R1: store-only rows (MappedAction null —
|
||||
// the Camera Alternate scheme, Emote/CharacterSettings hotkeys)
|
||||
// never reach the InputDispatcher, so a chord they display cannot
|
||||
// actually collide with anything; counting them made the ten
|
||||
// arrow-key defaults trip a false N-way confirm on any arrow
|
||||
// rebind. Retail-mapped cross-context sharing (ConflictingMaps —
|
||||
// the Insert/Delete/End/PageUp/PageDown combat cluster) remains
|
||||
// deferred as ISSUES #373; only INERT rows are excluded here.
|
||||
// A future unknown-DAT row never reaches the dispatcher, so its
|
||||
// display-only chord cannot create a live conflict. #373: mapped
|
||||
// cross-context sharing consults the
|
||||
// installed DAT's ActionMap.ConflictingMaps table. In particular,
|
||||
// the melee/missile/magic contexts legitimately share the retail
|
||||
// Insert/Delete/End/PageUp/PageDown cluster and must not erase one
|
||||
// another.
|
||||
if (other.MappedAction is null) continue;
|
||||
if (_snapshot?.InputMapsConflict(
|
||||
exclude.InputMapId,
|
||||
other.InputMapId) != true)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (other.Model.Current.Contains(chord))
|
||||
rows.Add(other);
|
||||
}
|
||||
|
|
@ -677,24 +801,42 @@ public sealed class KeyboardConfigController
|
|||
private static void WireScreenButtons(
|
||||
ImportedLayout layout, KeyboardConfigController controller, Bindings bindings)
|
||||
{
|
||||
// Load File / Save As — INERT (D4: keybinds.json only, no .keymap
|
||||
// interchange). Authored, clickable, no handler — same shape as OP3's
|
||||
// still-inert buttons.
|
||||
_ = layout.FindElement(LoadButtonId);
|
||||
_ = layout.FindElement(SaveAsButtonId);
|
||||
_ = layout.FindElement(FilenameLabelId);
|
||||
UiText? filename = layout.FindElement(FilenameLabelId) as UiText;
|
||||
void RefreshFilename()
|
||||
{
|
||||
if (filename is null || bindings.CurrentKeymapFilename is null) return;
|
||||
string value = bindings.CurrentKeymapFilename();
|
||||
filename.LinesProvider = () =>
|
||||
new[] { new UiText.Line(value, filename.DefaultColor) };
|
||||
}
|
||||
RefreshFilename();
|
||||
|
||||
if (layout.FindElement(LoadButtonId) is UiButton loadButton
|
||||
&& bindings.OpenLoadKeymap is { } openLoad)
|
||||
{
|
||||
loadButton.OnClick = () => openLoad(() =>
|
||||
{
|
||||
controller.ReloadRowsFromBindings(bindings);
|
||||
RefreshFilename();
|
||||
});
|
||||
}
|
||||
|
||||
if (layout.FindElement(SaveAsButtonId) is UiButton saveAsButton
|
||||
&& bindings.OpenSaveKeymap is { } openSave)
|
||||
{
|
||||
saveAsButton.OnClick = () => openSave(RefreshFilename);
|
||||
}
|
||||
|
||||
if (layout.FindElement(DefaultsButtonId) is UiButton defaultsButton)
|
||||
defaultsButton.OnClick = () =>
|
||||
{
|
||||
foreach (RowView row in controller._rows)
|
||||
row.Model.SetDefaultValue(row.Model.DefaultValue);
|
||||
controller.Page.Defaults();
|
||||
foreach (RowView row in controller._rows)
|
||||
controller.RefreshRowButtons(row);
|
||||
};
|
||||
|
||||
if (layout.FindElement(RevertButtonId) is UiButton revertButton)
|
||||
{
|
||||
revertButton.OnClick = () =>
|
||||
{
|
||||
controller.Page.Reset();
|
||||
|
|
@ -702,16 +844,29 @@ public sealed class KeyboardConfigController
|
|||
controller.RefreshRowButtons(row);
|
||||
};
|
||||
|
||||
// OK — right-click release in retail (idMessage 0x19); ported as a plain
|
||||
// left-click here, matching every other Campaign OP button (the asymmetry
|
||||
// is authored-input-only — no user-visible affordance differs, since
|
||||
// retail's own right-click-release on just this pair of buttons carries
|
||||
// no distinguishing visual cue either).
|
||||
// gmKeyboardUI::OnOptionChanged @ 0x004DA890 addresses the
|
||||
// m_pKeyboardRevertToSavedButton slot through the secondary
|
||||
// IOptionChangeHandler base. It is Normal (state 1) exactly while
|
||||
// OptionPage::Changed is true, otherwise Ghosted (state 0xD).
|
||||
controller.Page.OnOptionChanged = () =>
|
||||
revertButton.Enabled = controller.Page.Changed;
|
||||
controller.Page.OnOptionChanged();
|
||||
}
|
||||
|
||||
// gmKeyboardUI::ListenToElementMessage @ 0x004DD230 handles the
|
||||
// authored button action/release message (id 0x19, parameter 7). That
|
||||
// is the ordinary retained-button click path, not evidence of a
|
||||
// special right-click gesture.
|
||||
if (layout.FindElement(OkButtonId) is UiButton okButton)
|
||||
okButton.OnClick = () =>
|
||||
{
|
||||
bool changed = controller.Page.Changed;
|
||||
// Retail only rewrites the active keymap when at least one
|
||||
// row differs; SaveCurrentValues still advances the Revert
|
||||
// baseline unconditionally.
|
||||
if (changed)
|
||||
bindings.Save();
|
||||
controller.Page.Apply();
|
||||
bindings.Save();
|
||||
bindings.Toggle();
|
||||
};
|
||||
|
||||
|
|
@ -724,4 +879,17 @@ public sealed class KeyboardConfigController
|
|||
bindings.Toggle();
|
||||
};
|
||||
}
|
||||
|
||||
private void ReloadRowsFromBindings(Bindings bindings)
|
||||
{
|
||||
foreach (RowView row in _rows)
|
||||
{
|
||||
IReadOnlyList<KeyChord> chords = row.MappedAction is { } action
|
||||
? bindings.CurrentForAction(action).Select(static value => value.Chord).ToArray()
|
||||
: bindings.CurrentForUnmapped((row.InputMapId, row.ActionId));
|
||||
row.Model.ReloadCurrentAndSaved(chords);
|
||||
RefreshRowButtons(row);
|
||||
}
|
||||
Page.OnOptionChanged?.Invoke();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -140,6 +140,12 @@ public sealed class MapHousePanelController : IRetainedPanelController
|
|||
|
||||
public bool IsShowingHouse => _tabPanel.ActivePageElementId == HousePageId;
|
||||
|
||||
public bool IsShowingMap => _tabPanel.ActivePageElementId == MapPageId;
|
||||
|
||||
public void ShowMap() => _tabPanel.SwitchTo(MapPageId);
|
||||
|
||||
public void ShowHouse() => _tabPanel.SwitchTo(HousePageId);
|
||||
|
||||
public void OnShown()
|
||||
{
|
||||
_visible = true;
|
||||
|
|
|
|||
|
|
@ -554,10 +554,10 @@ public sealed class ActionKeyMapOptionRow : IOptionRow
|
|||
|
||||
public bool Changed => !_current.SequenceEqual(_saved);
|
||||
|
||||
/// <summary>Reset-to-Defaults reloads the DAT master maps fresh
|
||||
/// (<c>gmKeyboardUI::RestoreDefaultValues</c> — research doc §5.6) before
|
||||
/// restoring each row, so the default slot list itself can change between
|
||||
/// presses (a fresh DAT read), not just at construction time.</summary>
|
||||
/// <summary>Replaces the DAT master-map default used by the next
|
||||
/// Reset-to-Defaults operation. The installed DAT is immutable during one
|
||||
/// client process, so the keyboard controller normally seeds this once
|
||||
/// when it builds the row.</summary>
|
||||
public void SetDefaultValue(IReadOnlyList<KeyChord> value) => _default = value;
|
||||
|
||||
/// <summary>The capture/erase entry point — writes <c>m_current</c> and applies
|
||||
|
|
@ -574,6 +574,16 @@ public sealed class ActionKeyMapOptionRow : IOptionRow
|
|||
|
||||
public void SaveCurrentValue() => _saved = _current;
|
||||
|
||||
/// <summary>Re-seeds both the live value and Revert baseline after retail's
|
||||
/// Load File swaps the dispatcher keymap. The dispatcher has already applied
|
||||
/// the profile, so this intentionally does not call the row's write-back.</summary>
|
||||
public void ReloadCurrentAndSaved(IReadOnlyList<KeyChord> value)
|
||||
{
|
||||
_current = value;
|
||||
_saved = value;
|
||||
_notifyPageOptionChanged?.Invoke();
|
||||
}
|
||||
|
||||
public void RestoreSavedValue()
|
||||
{
|
||||
_current = _saved;
|
||||
|
|
|
|||
|
|
@ -134,6 +134,28 @@ public sealed class OptionsPanelController : IRetainedPanelController
|
|||
|
||||
public OptionPage ConfigPage => _pages[ConfigPageId];
|
||||
|
||||
/// <summary>True when the authored Gameplay Options page is active.</summary>
|
||||
public bool IsShowingGameplay =>
|
||||
_tabPanel.ActivePageElementId == GameplayPageId;
|
||||
|
||||
public bool IsShowingCharacter =>
|
||||
_tabPanel.ActivePageElementId == CharacterPageId;
|
||||
|
||||
public bool IsShowingConfiguration =>
|
||||
_tabPanel.ActivePageElementId == ConfigPageId;
|
||||
|
||||
/// <summary>
|
||||
/// Programmatic form of retail action <c>0x1000001B</c>, resolved from
|
||||
/// the installed ActionMap as "Show/Hide Gameplay Options Page". This is
|
||||
/// the final fallback of <c>ClientUISystem::OnAction(EscapeKey)</c> at
|
||||
/// <c>0x00564CBF</c>.
|
||||
/// </summary>
|
||||
public void ShowGameplay() => _tabPanel.SwitchTo(GameplayPageId);
|
||||
|
||||
public void ShowCharacter() => _tabPanel.SwitchTo(CharacterPageId);
|
||||
|
||||
public void ShowConfiguration() => _tabPanel.SwitchTo(ConfigPageId);
|
||||
|
||||
private OptionsPanelController(UiTabPanel tabPanel, Action? afterApply)
|
||||
{
|
||||
_tabPanel = tabPanel;
|
||||
|
|
|
|||
|
|
@ -115,6 +115,7 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo
|
|||
_objects.ObjectUpdated += OnObjectChanged;
|
||||
_objects.Cleared += OnObjectsCleared;
|
||||
_selection.Changed += OnSelectionChanged;
|
||||
_itemInteraction.StateChanged += OnInteractionStateChanged;
|
||||
|
||||
// ── Slots-toggle wiring ───────────────────────────────────────────────────────────────────
|
||||
foreach (var id in ArmorSlotElementIds)
|
||||
|
|
@ -216,6 +217,7 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo
|
|||
Populate();
|
||||
}
|
||||
private void OnSelectionChanged(SelectionTransition _) => ApplySelectionIndicators();
|
||||
private void OnInteractionStateChanged() => Populate();
|
||||
private void OnObjectsCleared()
|
||||
{
|
||||
ApplyAetheriaVisibility();
|
||||
|
|
@ -225,8 +227,8 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo
|
|||
/// <summary>The object belongs to the player (wielded gear or pack contents) — so a change to it may
|
||||
/// add/remove/repaint a doll slot. Player-scoped: an NPC's or vendor's wielded item (which also carries
|
||||
/// CurrentlyEquippedLocation from the wire) must NOT trigger a repaint. A player-equipped item always
|
||||
/// has WielderId==p (login, from CreateObject) or ContainerId==p (live/optimistic wield, set by
|
||||
/// WieldItemOptimistic), so the equip-location need not be tested here; OnObjectMoved carries the
|
||||
/// has WielderId==p (login, from CreateObject) or ContainerId==p, so the
|
||||
/// equip-location need not be tested here; OnObjectMoved carries the
|
||||
/// complete old/new retail placement for transitions that satisfy neither after mutation.</summary>
|
||||
private bool Concerns(ClientObject o)
|
||||
{
|
||||
|
|
@ -256,6 +258,8 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo
|
|||
uint dragTex = _dragIconIds?.Invoke(
|
||||
worn.Type, worn.IconId, worn.IconUnderlayId, worn.IconOverlayId, worn.Effects) ?? 0u;
|
||||
list.Cell.SetItem(worn.ObjectId, tex, dragIconTexture: dragTex);
|
||||
list.Cell.SetWaitingState(
|
||||
_itemInteraction.IsPendingInventorySource(worn.ObjectId));
|
||||
}
|
||||
ApplyAetheriaVisibility();
|
||||
ApplySelectionIndicators();
|
||||
|
|
@ -278,7 +282,8 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo
|
|||
foreach (var (_, list) in _slots)
|
||||
{
|
||||
list.Cell.Selected = list.Cell.ItemId != 0
|
||||
&& list.Cell.ItemId == _selection.SelectedObjectId;
|
||||
&& list.Cell.ItemId == _selection.SelectedObjectId
|
||||
&& !_itemInteraction.IsPendingInventorySource(list.Cell.ItemId);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -369,6 +374,7 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo
|
|||
_objects.ObjectUpdated -= OnObjectChanged;
|
||||
_objects.Cleared -= OnObjectsCleared;
|
||||
_selection.Changed -= OnSelectionChanged;
|
||||
_itemInteraction.StateChanged -= OnInteractionStateChanged;
|
||||
foreach (var (_, list) in _slots)
|
||||
{
|
||||
list.PrimaryItemPressed = null;
|
||||
|
|
|
|||
122
src/AcDream.App/UI/Layout/RetailConfirmationMenuDialogView.cs
Normal file
122
src/AcDream.App/UI/Layout/RetailConfirmationMenuDialogView.cs
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
using AcDream.App.UI;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>Retail type-7 <c>ConfirmationMenuDialog</c>, used by Configure
|
||||
/// Keyboard's authored Load File button.</summary>
|
||||
internal sealed class RetailConfirmationMenuDialogView : IRetailDialogView
|
||||
{
|
||||
public const uint RootElementId = 0x1Fu;
|
||||
public const uint MenuElementId = 0x21u;
|
||||
public const uint AcceptButtonId = 0x22u;
|
||||
public const uint RejectButtonId = 0x23u;
|
||||
public const uint PopupElementId = 0x3Du;
|
||||
|
||||
private readonly UiRoot _host;
|
||||
private readonly RetailDialogData _data;
|
||||
private readonly uint _context;
|
||||
private readonly Action<uint> _closeDialog;
|
||||
private readonly UiElement? _popup;
|
||||
private readonly UiMenu _menu;
|
||||
private readonly UiButton _accept;
|
||||
private readonly UiButton _reject;
|
||||
|
||||
public RetailConfirmationMenuDialogView(
|
||||
UiRoot host,
|
||||
ImportedLayout layout,
|
||||
RetailDialogData data,
|
||||
uint context,
|
||||
Action<uint> closeDialog)
|
||||
{
|
||||
_host = host ?? throw new ArgumentNullException(nameof(host));
|
||||
ArgumentNullException.ThrowIfNull(layout);
|
||||
_data = data ?? throw new ArgumentNullException(nameof(data));
|
||||
_context = context;
|
||||
_closeDialog = closeDialog ?? throw new ArgumentNullException(nameof(closeDialog));
|
||||
|
||||
Root = layout.Root as UiDialogRoot
|
||||
?? throw new ArgumentException(
|
||||
"Confirmation-menu layout root is not a UiDialogRoot.", nameof(layout));
|
||||
_popup = layout.FindElement(PopupElementId);
|
||||
_menu = layout.FindElement(MenuElementId) as UiMenu
|
||||
?? throw new ArgumentException(
|
||||
"Confirmation-menu layout is missing menu element 0x21.", nameof(layout));
|
||||
_accept = layout.FindElement(AcceptButtonId) as UiButton
|
||||
?? throw new ArgumentException(
|
||||
"Confirmation-menu layout is missing accept button 0x22.", nameof(layout));
|
||||
_reject = layout.FindElement(RejectButtonId) as UiButton
|
||||
?? throw new ArgumentException(
|
||||
"Confirmation-menu layout is missing reject button 0x23.", nameof(layout));
|
||||
|
||||
IReadOnlyList<string> items = _data.TryGet<string[]>(
|
||||
RetailDialogProperty.MenuItems, out string[] values)
|
||||
? values
|
||||
: Array.Empty<string>();
|
||||
_menu.Items = items.Select(
|
||||
static (label, index) => new UiMenu.MenuItem(label, index)).ToArray();
|
||||
int selected = Math.Clamp(
|
||||
_data.GetInt32(RetailDialogProperty.MenuSelection),
|
||||
0,
|
||||
Math.Max(0, items.Count - 1));
|
||||
_menu.Selected = items.Count == 0 ? null : selected;
|
||||
_menu.OnSelect = payload => _menu.Selected = payload;
|
||||
_menu.ButtonLabelProvider = () =>
|
||||
_menu.Selected is int index && index >= 0 && index < items.Count
|
||||
? items[index]
|
||||
: string.Empty;
|
||||
|
||||
if (_data.GetString(RetailDialogProperty.MenuAcceptLabel) is { } acceptLabel)
|
||||
_accept.Label = acceptLabel;
|
||||
if (_data.GetString(RetailDialogProperty.MenuRejectLabel) is { } rejectLabel)
|
||||
_reject.Label = rejectLabel;
|
||||
|
||||
Root.Cancel = Reject;
|
||||
_accept.OnClick = Accept;
|
||||
_reject.OnClick = Reject;
|
||||
SizeAndCenter();
|
||||
}
|
||||
|
||||
public UiDialogRoot Root { get; }
|
||||
|
||||
public void Tick() => SizeAndCenter();
|
||||
|
||||
public void SetPendingCount(int count)
|
||||
{
|
||||
}
|
||||
|
||||
public void DetachHandlers()
|
||||
{
|
||||
Root.Cancel = null;
|
||||
_accept.OnClick = null;
|
||||
_reject.OnClick = null;
|
||||
_menu.OnSelect = null;
|
||||
}
|
||||
|
||||
private void Accept()
|
||||
{
|
||||
_data.Set(
|
||||
RetailDialogProperty.MenuSelection,
|
||||
_menu.Selected is int selected ? selected : -1);
|
||||
_closeDialog(_context);
|
||||
}
|
||||
|
||||
private void Reject()
|
||||
{
|
||||
_data.Set(RetailDialogProperty.MenuSelection, -1);
|
||||
_closeDialog(_context);
|
||||
}
|
||||
|
||||
private void SizeAndCenter()
|
||||
{
|
||||
var space = _host.EffectiveCanvasSize;
|
||||
Root.Left = 0f;
|
||||
Root.Top = 0f;
|
||||
Root.Width = space.X;
|
||||
Root.Height = space.Y;
|
||||
if (_popup is null) return;
|
||||
_popup.LayoutPolicy = null;
|
||||
_popup.Anchors = AnchorEdges.None;
|
||||
_popup.Left = MathF.Round((Root.Width - _popup.Width) * 0.5f);
|
||||
_popup.Top = MathF.Round((Root.Height - _popup.Height) * 0.5f);
|
||||
}
|
||||
}
|
||||
|
|
@ -14,6 +14,11 @@ public static class RetailDialogProperty
|
|||
public const uint TextInputAcceptLabel = 0x9Au;
|
||||
public const uint TextInputRejectLabel = 0x9Bu;
|
||||
public const uint TextInputResult = 0x9Cu;
|
||||
public const uint MenuItems = 0xA6u;
|
||||
public const uint MenuItem = 0xA7u;
|
||||
public const uint MenuAcceptLabel = 0xA8u;
|
||||
public const uint MenuRejectLabel = 0xA9u;
|
||||
public const uint MenuSelection = 0xABu;
|
||||
/// <summary>
|
||||
/// When true, <c>Dialog::SetData @ 0x00476BE0</c> sets UIElement boolean
|
||||
/// attribute <c>0x40</c>. The Keystone-owned attribute name is unavailable.
|
||||
|
|
@ -97,6 +102,19 @@ public sealed class RetailDialogData
|
|||
}
|
||||
: defaultValue;
|
||||
|
||||
public int GetInt32(uint propertyId, int defaultValue = 0)
|
||||
=> _values.TryGetValue(propertyId, out object? raw)
|
||||
? raw switch
|
||||
{
|
||||
byte value => value,
|
||||
ushort value => value,
|
||||
int value => value,
|
||||
uint value when value <= int.MaxValue => (int)value,
|
||||
Enum value => Convert.ToInt32(value),
|
||||
_ => defaultValue,
|
||||
}
|
||||
: defaultValue;
|
||||
|
||||
public string? GetString(uint propertyId)
|
||||
=> _values.TryGetValue(propertyId, out object? raw) ? raw as string : null;
|
||||
|
||||
|
|
@ -148,4 +166,18 @@ public sealed class RetailDialogData
|
|||
.Set(RetailDialogProperty.ElementAttribute40, true)
|
||||
.Set(RetailDialogProperty.Message, message);
|
||||
}
|
||||
|
||||
/// <summary>Type-7 confirmation menu used by retail's keyboard-profile
|
||||
/// Load File workflow (<c>gmKeyboardUI::MakeLoadKeymapDialog</c>).</summary>
|
||||
public static RetailDialogData ConfirmationMenu(
|
||||
IReadOnlyList<string> items,
|
||||
int selectedIndex = 0)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(items);
|
||||
return new RetailDialogData()
|
||||
.Set(RetailDialogProperty.Type, RetailDialogType.ConfirmationMenu)
|
||||
.Set(RetailDialogProperty.ElementAttribute40, true)
|
||||
.Set(RetailDialogProperty.MenuItems, items.ToArray())
|
||||
.Set(RetailDialogProperty.MenuSelection, selectedIndex);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -169,20 +169,28 @@ public sealed class RetailDialogFactory : IDisposable
|
|||
/// <c>UIOption_ActionKeyMap::OpenMapWarnDialog @ 0x00488A00</c>: type 2,
|
||||
/// caller-chosen queue key, element attribute 0x40 set, message text.
|
||||
/// </summary>
|
||||
public uint MakeWait(string message, uint queueKey = DefaultQueueKey)
|
||||
public uint MakeWait(
|
||||
string message,
|
||||
uint queueKey = DefaultQueueKey,
|
||||
bool priority = false)
|
||||
{
|
||||
RetailDialogData data = RetailDialogData.Wait(message)
|
||||
.Set(RetailDialogProperty.QueueKey, queueKey);
|
||||
if (priority)
|
||||
data.Set(RetailDialogProperty.Priority, true);
|
||||
return MakeDialog(data, callback: null);
|
||||
}
|
||||
|
||||
public uint MakeMessage(
|
||||
string message,
|
||||
Action<RetailDialogData>? callback = null,
|
||||
uint queueKey = DefaultQueueKey)
|
||||
uint queueKey = DefaultQueueKey,
|
||||
bool priority = false)
|
||||
{
|
||||
RetailDialogData data = RetailDialogData.Message(message)
|
||||
.Set(RetailDialogProperty.QueueKey, queueKey);
|
||||
if (priority)
|
||||
data.Set(RetailDialogProperty.Priority, true);
|
||||
return MakeDialog(data, callback);
|
||||
}
|
||||
|
||||
|
|
@ -196,6 +204,17 @@ public sealed class RetailDialogFactory : IDisposable
|
|||
return MakeDialog(data, callback);
|
||||
}
|
||||
|
||||
public uint MakeConfirmationMenu(
|
||||
IReadOnlyList<string> items,
|
||||
int selectedIndex,
|
||||
Action<RetailDialogData>? callback = null,
|
||||
uint queueKey = DefaultQueueKey)
|
||||
{
|
||||
RetailDialogData data = RetailDialogData.ConfirmationMenu(items, selectedIndex)
|
||||
.Set(RetailDialogProperty.QueueKey, queueKey);
|
||||
return MakeDialog(data, callback);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>CloseDialog @ 0x00478160</c>. The context can identify an active
|
||||
/// nonqueued dialog, an active queued dialog, or an item still pending in a queue.
|
||||
|
|
@ -395,7 +414,8 @@ public sealed class RetailDialogFactory : IDisposable
|
|||
if (type is not (RetailDialogType.Confirmation
|
||||
or RetailDialogType.Wait
|
||||
or RetailDialogType.Message
|
||||
or RetailDialogType.ConfirmationTextInput))
|
||||
or RetailDialogType.ConfirmationTextInput
|
||||
or RetailDialogType.ConfirmationMenu))
|
||||
{
|
||||
throw new NotSupportedException(
|
||||
$"Retail dialog type {(uint)type} does not have a ported presenter yet.");
|
||||
|
|
@ -415,6 +435,10 @@ public sealed class RetailDialogFactory : IDisposable
|
|||
new RetailConfirmationTextInputDialogView(
|
||||
_host, layout, info.Data, info.Context,
|
||||
context => CloseDialog(context)),
|
||||
RetailDialogType.ConfirmationMenu =>
|
||||
new RetailConfirmationMenuDialogView(
|
||||
_host, layout, info.Data, info.Context,
|
||||
context => CloseDialog(context)),
|
||||
_ => new RetailConfirmationDialogView(
|
||||
_host, layout, info.Data, info.Context,
|
||||
context => CloseDialog(context)),
|
||||
|
|
|
|||
|
|
@ -37,8 +37,7 @@ namespace AcDream.App.UI.Layout;
|
|||
/// / DIK_LMENU), names its METAKEY through the meta table + OS fallback and
|
||||
/// joins with the authored <c>ID_KeyDescDelimiter</c> ("+", table enum 3 →
|
||||
/// DID <c>0x23000007</c>). A binding whose KEY IS a modifier key (retail's
|
||||
/// walk-mode DIK_LSHIFT row has meta-mode 0; acdream's <see cref="KeyChord"/>
|
||||
/// carries the wire-side self-modifier bit) shows only the key name — never
|
||||
/// walk-mode DIK_LSHIFT row has meta-mode 0) shows only the key name — never
|
||||
/// "Shift+ShiftLeft".
|
||||
/// </para>
|
||||
/// </summary>
|
||||
|
|
@ -79,21 +78,34 @@ public sealed class RetailKeyNames
|
|||
|
||||
/// <summary>
|
||||
/// Display name for one bound chord — retail
|
||||
/// <c>GetNameFromKey(QualifiedControl)</c>. Mouse chords keep the
|
||||
/// pre-existing enum spelling: retail names mouse controls through the
|
||||
/// DirectInput mouse device, which this port does not have (AD-95a).
|
||||
/// <c>GetNameFromKey(QualifiedControl)</c>. Mouse controls use retail's
|
||||
/// DIMOFS semantic/table lookup. If the table misses, DirectInput would
|
||||
/// provide a localized object name; acdream's non-DirectInput fallback is
|
||||
/// the stable user-facing "Mouse Button N".
|
||||
/// </summary>
|
||||
public string Describe(KeyChord chord)
|
||||
{
|
||||
if (chord == default)
|
||||
return string.Empty;
|
||||
if (TryGetMouseSemantic(chord, out string? mouseSemantic, out int buttonNumber))
|
||||
{
|
||||
string mouseName = _resolveString(
|
||||
KeyNameTableId,
|
||||
DatStringResolver.ComputeHash(mouseSemantic!))
|
||||
?? $"Mouse Button {buttonNumber}";
|
||||
return Compose(chord, mouseName);
|
||||
}
|
||||
if (!TryGetDik(chord.Key, out byte dik, out string? dikName))
|
||||
return FallbackSpelling(chord);
|
||||
|
||||
return Compose(chord, LookupName(dikName!, dik, KeyNameTableId));
|
||||
}
|
||||
|
||||
private string Compose(KeyChord chord, string keyName)
|
||||
{
|
||||
var composed = new System.Text.StringBuilder();
|
||||
// Meta-mode bits ascending, skipping the key's own self-modifier bit
|
||||
// (retail's walk-mode LSHIFT row carries meta-mode 0 on the wire; the
|
||||
// chord's stored self bit is acdream's encoding, not display truth).
|
||||
// (retail's walk-mode LSHIFT row carries meta-mode 0 on the wire).
|
||||
foreach ((ModifierMask flag, Key metaKey) in MetaOrder)
|
||||
{
|
||||
if ((chord.Modifiers & flag) == 0 || IsSelfModifier(chord.Key, flag))
|
||||
|
|
@ -104,10 +116,36 @@ public sealed class RetailKeyNames
|
|||
composed.Append(_delimiter);
|
||||
}
|
||||
|
||||
composed.Append(LookupName(dikName!, dik, KeyNameTableId));
|
||||
composed.Append(keyName);
|
||||
return composed.ToString();
|
||||
}
|
||||
|
||||
private static bool TryGetMouseSemantic(
|
||||
KeyChord chord,
|
||||
out string? semantic,
|
||||
out int buttonNumber)
|
||||
{
|
||||
int zeroBased = (int)chord.Key switch
|
||||
{
|
||||
-1001 => 0,
|
||||
-1002 => 1,
|
||||
-1003 => 2,
|
||||
-1004 => 3,
|
||||
-1005 => 4,
|
||||
_ => -1,
|
||||
};
|
||||
if (chord.Device != 1 || zeroBased < 0)
|
||||
{
|
||||
semantic = null;
|
||||
buttonNumber = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
semantic = $"DIMOFS_BUTTON{zeroBased}";
|
||||
buttonNumber = zeroBased + 1;
|
||||
return true;
|
||||
}
|
||||
|
||||
private string LookupName(string dikName, byte dik, uint tableId)
|
||||
=> _resolveString(tableId, DatStringResolver.ComputeHash(dikName))
|
||||
?? _osKeyName?.Invoke((byte)(dik & 0x7F), (dik & 0x80) != 0)
|
||||
|
|
@ -126,6 +164,7 @@ public sealed class RetailKeyNames
|
|||
(ModifierMask.Shift, Key.ShiftLeft),
|
||||
(ModifierMask.Ctrl, Key.ControlLeft),
|
||||
(ModifierMask.Alt, Key.AltLeft),
|
||||
(ModifierMask.Win, Key.SuperLeft),
|
||||
};
|
||||
|
||||
private static bool IsSelfModifier(Key key, ModifierMask flag)
|
||||
|
|
@ -134,15 +173,15 @@ public sealed class RetailKeyNames
|
|||
ModifierMask.Shift => key is Key.ShiftLeft or Key.ShiftRight,
|
||||
ModifierMask.Ctrl => key is Key.ControlLeft or Key.ControlRight,
|
||||
ModifierMask.Alt => key is Key.AltLeft or Key.AltRight,
|
||||
ModifierMask.Win => key is Key.SuperLeft or Key.SuperRight,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Silk key → DirectInput scan code + DIK name — the reverse of
|
||||
/// <see cref="RetailScanCodeMap.ToSilkKey"/>'s keyboard table (same 84
|
||||
/// DAT-observed codes) plus the modifier keys live capture can produce
|
||||
/// that no DAT default binds directly (DIK_LCONTROL 0x1D, DIK_LMENU 0x38,
|
||||
/// DIK_RMENU 0xB8). DIK codes with bit 0x80 are the extended set — the
|
||||
/// <see cref="RetailScanCodeMap.ToSilkKey"/>'s keyboard table: the 84
|
||||
/// DAT-default codes plus the additional controls accepted by retail's
|
||||
/// plain-text keymap format. DIK codes with bit 0x80 are the extended set — the
|
||||
/// same split Win32's GetKeyNameText expects in bit 24.
|
||||
/// </summary>
|
||||
private static bool TryGetDik(Key key, out byte dik, out string? name)
|
||||
|
|
@ -206,6 +245,7 @@ public sealed class RetailKeyNames
|
|||
Key.KeypadMultiply => ((byte)0x37, "DIK_MULTIPLY"),
|
||||
Key.AltLeft => ((byte)0x38, "DIK_LMENU"),
|
||||
Key.Space => ((byte)0x39, "DIK_SPACE"),
|
||||
Key.CapsLock => ((byte)0x3A, "DIK_CAPITAL"),
|
||||
Key.F1 => ((byte)0x3B, "DIK_F1"),
|
||||
Key.F2 => ((byte)0x3C, "DIK_F2"),
|
||||
Key.F3 => ((byte)0x3D, "DIK_F3"),
|
||||
|
|
@ -233,10 +273,15 @@ public sealed class RetailKeyNames
|
|||
Key.KeypadDecimal => ((byte)0x53, "DIK_DECIMAL"),
|
||||
Key.F11 => ((byte)0x57, "DIK_F11"),
|
||||
Key.F12 => ((byte)0x58, "DIK_F12"),
|
||||
Key.F13 => ((byte)0x64, "DIK_F13"),
|
||||
Key.F14 => ((byte)0x65, "DIK_F14"),
|
||||
Key.F15 => ((byte)0x66, "DIK_F15"),
|
||||
Key.KeypadEnter => ((byte)0x9C, "DIK_NUMPADENTER"),
|
||||
Key.ControlRight => ((byte)0x9D, "DIK_RCONTROL"),
|
||||
Key.KeypadDivide => ((byte)0xB5, "DIK_DIVIDE"),
|
||||
Key.PrintScreen => ((byte)0xB7, "DIK_SYSRQ"),
|
||||
Key.AltRight => ((byte)0xB8, "DIK_RMENU"),
|
||||
Key.Pause => ((byte)0xC5, "DIK_PAUSE"),
|
||||
Key.Home => ((byte)0xC7, "DIK_HOME"),
|
||||
Key.Up => ((byte)0xC8, "DIK_UP"),
|
||||
Key.PageUp => ((byte)0xC9, "DIK_PRIOR"),
|
||||
|
|
@ -247,6 +292,9 @@ public sealed class RetailKeyNames
|
|||
Key.PageDown => ((byte)0xD1, "DIK_NEXT"),
|
||||
Key.Insert => ((byte)0xD2, "DIK_INSERT"),
|
||||
Key.Delete => ((byte)0xD3, "DIK_DELETE"),
|
||||
Key.SuperLeft => ((byte)0xDB, "DIK_LWIN"),
|
||||
Key.SuperRight => ((byte)0xDC, "DIK_RWIN"),
|
||||
Key.Menu => ((byte)0xDD, "DIK_APPS"),
|
||||
_ => ((byte)0, null),
|
||||
};
|
||||
return name is not null;
|
||||
|
|
|
|||
|
|
@ -94,6 +94,8 @@ public sealed class SelectedObjectController : IRetainedPanelController
|
|||
private readonly StackSplitQuantityState _splitQuantity;
|
||||
private readonly SelectionState _selection;
|
||||
private readonly Func<uint, bool> _isVendorSplitExempt;
|
||||
private readonly Func<uint, bool> _isCoinstack;
|
||||
private readonly Func<int> _coinTotal;
|
||||
private readonly Action<Action<uint, float>> _unsubscribeHealthChanged;
|
||||
private readonly Action<Action<uint, float, bool>> _unsubscribeItemManaChanged;
|
||||
private readonly Action<Action<ClientObject>> _unsubscribeObjectUpdated;
|
||||
|
|
@ -128,7 +130,9 @@ public sealed class SelectedObjectController : IRetainedPanelController
|
|||
StackSplitQuantityState splitQuantity,
|
||||
Action<Action<ClientObject>> subscribeObjectUpdated,
|
||||
Action<Action<ClientObject>> unsubscribeObjectUpdated,
|
||||
Func<uint, bool> isVendorSplitExempt)
|
||||
Func<uint, bool> isVendorSplitExempt,
|
||||
Func<uint, bool>? isCoinstack,
|
||||
Func<int>? coinTotal)
|
||||
{
|
||||
_isHealthTarget = isHealthTarget;
|
||||
_isOwnedByPlayer = isOwnedByPlayer;
|
||||
|
|
@ -143,6 +147,8 @@ public sealed class SelectedObjectController : IRetainedPanelController
|
|||
_selection = selection ?? throw new ArgumentNullException(nameof(selection));
|
||||
_isVendorSplitExempt = isVendorSplitExempt
|
||||
?? throw new ArgumentNullException(nameof(isVendorSplitExempt));
|
||||
_isCoinstack = isCoinstack ?? (_ => false);
|
||||
_coinTotal = coinTotal ?? (() => 0);
|
||||
_unsubscribeHealthChanged = unsubscribeHealthChanged;
|
||||
_unsubscribeItemManaChanged = unsubscribeItemManaChanged;
|
||||
_unsubscribeObjectUpdated = unsubscribeObjectUpdated;
|
||||
|
|
@ -319,7 +325,9 @@ public sealed class SelectedObjectController : IRetainedPanelController
|
|||
StackSplitQuantityState splitQuantity,
|
||||
Action<Action<ClientObject>> subscribeObjectUpdated,
|
||||
Action<Action<ClientObject>> unsubscribeObjectUpdated,
|
||||
Func<uint, bool> isVendorSplitExempt)
|
||||
Func<uint, bool> isVendorSplitExempt,
|
||||
Func<uint, bool>? isCoinstack = null,
|
||||
Func<int>? coinTotal = null)
|
||||
=> new SelectedObjectController(
|
||||
layout, selection,
|
||||
subscribeHealthChanged, unsubscribeHealthChanged,
|
||||
|
|
@ -327,7 +335,7 @@ public sealed class SelectedObjectController : IRetainedPanelController
|
|||
isHealthTarget, isOwnedByPlayer, name, healthPercent, hasHealth, stackSize,
|
||||
sendQueryHealth, manaPercent, sendQueryItemMana, datFont,
|
||||
splitQuantity, subscribeObjectUpdated, unsubscribeObjectUpdated,
|
||||
isVendorSplitExempt);
|
||||
isVendorSplitExempt, isCoinstack, coinTotal);
|
||||
|
||||
/// <summary>
|
||||
/// Port of <c>gmToolbarUI::HandleSelectionChanged</c> (<c>:198635</c>):
|
||||
|
|
@ -373,9 +381,11 @@ public sealed class SelectedObjectController : IRetainedPanelController
|
|||
// ── 2. Name (displayed via the UiText child's LinesProvider reading _currentName). ──
|
||||
uint stackSize = _stackSize(g);
|
||||
string? objectName = _resolveName(g);
|
||||
_currentName = stackSize > 1u && !string.IsNullOrEmpty(objectName)
|
||||
? $"{stackSize} {objectName}"
|
||||
: objectName;
|
||||
_currentName = _isCoinstack(g) && _isOwnedByPlayer(g)
|
||||
? $"{stackSize} {objectName} (of {_coinTotal()})"
|
||||
: stackSize > 1u && !string.IsNullOrEmpty(objectName)
|
||||
? $"{stackSize} {objectName}"
|
||||
: objectName;
|
||||
|
||||
// ── 3. Selection overlay: brief flash (retail container ObjectSelected
|
||||
// = Pause(0.25s)→Normal). "StackedItemSelected" for stacks. ──────────────
|
||||
|
|
@ -522,6 +532,26 @@ public sealed class SelectedObjectController : IRetainedPanelController
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>gmToolbarUI::RecvNotice_SplitStack @ 0x004BD2A0</c>: when
|
||||
/// the notice still names the selected stack and its size is greater than
|
||||
/// one, focus the numeric quantity field and select all of its text.
|
||||
/// </summary>
|
||||
public bool FocusSplitStackEntry(uint objectId)
|
||||
{
|
||||
if (_current != objectId
|
||||
|| _stackSize(objectId) <= 1u
|
||||
|| _stackSizeEntry is null
|
||||
|| !_stackSizeEntry.Visible)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_stackSizeEntry.FindRoot()?.SetKeyboardFocus(_stackSizeEntry);
|
||||
_stackSizeEntry.SelectAllText();
|
||||
return true;
|
||||
}
|
||||
|
||||
private void OnObjectUpdated(ClientObject updated)
|
||||
{
|
||||
if (_current == updated.ObjectId && _stackSize(updated.ObjectId) != _splitQuantity.Maximum)
|
||||
|
|
|
|||
|
|
@ -243,6 +243,8 @@ public sealed class SocialPanelController : IRetainedPanelController
|
|||
/// <summary>F4 <c>ToggleFellowshipPanel</c>'s tab-switch half.</summary>
|
||||
public void ShowFellowship() => _tabPanel.SwitchTo(FellowshipPageId);
|
||||
|
||||
public void ShowFriends() => _tabPanel.SwitchTo(FriendsPageId);
|
||||
|
||||
/// <summary>True when the Allegiance tab is the active page — lets
|
||||
/// <see cref="RetailUiRuntime.HandleInputAction"/> implement the
|
||||
/// close-on-second-press-of-the-SAME-tab semantics every other
|
||||
|
|
@ -264,6 +266,8 @@ public sealed class SocialPanelController : IRetainedPanelController
|
|||
/// <summary>True when the Fellowship tab is the active page.</summary>
|
||||
public bool IsShowingFellowship => _tabPanel.ActivePageElementId == FellowshipPageId;
|
||||
|
||||
public bool IsShowingFriends => _tabPanel.ActivePageElementId == FriendsPageId;
|
||||
|
||||
/// <summary>True while the social panel's own window is shown — set by
|
||||
/// <see cref="OnShown"/>/<see cref="OnHidden"/>. Fix-round blast SF-2:
|
||||
/// gates the Friends/Squelch rebuild (see <see cref="Tick"/>) so their
|
||||
|
|
|
|||
|
|
@ -216,9 +216,8 @@ public sealed class SpellcastingUiController : IRetainedPanelController
|
|||
|
||||
public bool Handle(InputAction action)
|
||||
{
|
||||
if (action is >= InputAction.UseSpellSlot_1 and <= InputAction.UseSpellSlot_9)
|
||||
if (TryMapSpellShortcut(action, out int index))
|
||||
{
|
||||
int index = (int)action - (int)InputAction.UseSpellSlot_1;
|
||||
IReadOnlyList<uint> spells = _spellbook.GetFavorites(_activeTab);
|
||||
if (index < spells.Count)
|
||||
{
|
||||
|
|
@ -243,6 +242,27 @@ public sealed class SpellcastingUiController : IRetainedPanelController
|
|||
}
|
||||
}
|
||||
|
||||
internal static bool TryMapSpellShortcut(
|
||||
InputAction action,
|
||||
out int index)
|
||||
{
|
||||
if (action is >= InputAction.UseSpellSlot_1
|
||||
and <= InputAction.UseSpellSlot_9)
|
||||
{
|
||||
index = (int)action - (int)InputAction.UseSpellSlot_1;
|
||||
return true;
|
||||
}
|
||||
|
||||
index = action switch
|
||||
{
|
||||
InputAction.UseSpellSlot_10 => 9,
|
||||
InputAction.UseSpellSlot_11 => 10,
|
||||
InputAction.UseSpellSlot_12 => 11,
|
||||
_ => -1,
|
||||
};
|
||||
return index >= 0;
|
||||
}
|
||||
|
||||
private void SelectTab(int tab)
|
||||
{
|
||||
_activeTab = Math.Clamp(tab, 0, 7);
|
||||
|
|
|
|||
|
|
@ -56,6 +56,23 @@ public sealed class ToolbarInputController
|
|||
return true;
|
||||
}
|
||||
|
||||
if (action is InputAction.UseQuickSlot_10
|
||||
or InputAction.UseQuickSlot_11
|
||||
or InputAction.UseQuickSlot_12
|
||||
or InputAction.UseQuickSlot_13)
|
||||
{
|
||||
slot = action switch
|
||||
{
|
||||
InputAction.UseQuickSlot_10 => 9,
|
||||
InputAction.UseQuickSlot_11 => 10,
|
||||
InputAction.UseQuickSlot_12 => 11,
|
||||
InputAction.UseQuickSlot_13 => 12,
|
||||
_ => -1,
|
||||
};
|
||||
use = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (value >= (int)InputAction.UseQuickSlot_14
|
||||
&& value <= (int)InputAction.UseQuickSlot_18)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -381,10 +381,18 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
|
|||
// X-close confirmation is already up; HandleButtonClicks' 0x100000d6
|
||||
// case only opens a NEW one when this is 0 (pc:204155).
|
||||
private uint _closeConfirmContext;
|
||||
private int _lastAlternateCurrencyPurchase;
|
||||
private bool _alternateCurrencyInventoryObserved;
|
||||
private PendingVendorSplit? _pendingVendorSplit;
|
||||
// F5: see DragOverGlobalTimeSink's own doc comment.
|
||||
private readonly DragOverGlobalTimeSink _dragOverSink;
|
||||
private bool _disposed;
|
||||
|
||||
private readonly record struct PendingVendorSplit(
|
||||
uint SourceGuid,
|
||||
uint WeenieClassId,
|
||||
int Quantity);
|
||||
|
||||
private VendorUiController(
|
||||
VendorState vendor,
|
||||
RetailWindowHandle window,
|
||||
|
|
@ -499,6 +507,7 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
|
|||
// succeeds and this stops being a dead end — closes half of AP-161
|
||||
// finding #2.
|
||||
_itemList.ExamineItemRequested = ExamineItem;
|
||||
_itemList.PrimaryItemPressed = PressVendorItem;
|
||||
if (itemScrollbar is not null)
|
||||
{
|
||||
itemScrollbar.Model = _itemList.Scroll;
|
||||
|
|
@ -528,6 +537,16 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
|
|||
// gate (pc:204229-204246) — the Selling tab's list is the ONLY drop
|
||||
// target. UiItemList.RegisterDragHandler is the structural analogue.
|
||||
_sellingList?.RegisterDragHandler(this);
|
||||
if (_buyingList is not null)
|
||||
{
|
||||
_buyingList.PrimaryItemPressed = PressVendorItem;
|
||||
_buyingList.ExamineItemRequested = ExamineItem;
|
||||
}
|
||||
if (_sellingList is not null)
|
||||
{
|
||||
_sellingList.PrimaryItemPressed = PressVendorItem;
|
||||
_sellingList.ExamineItemRequested = ExamineItem;
|
||||
}
|
||||
|
||||
// F5: mount the global-time sink so a live drag hovering anywhere
|
||||
// over this window auto-switches to the Selling tab — see
|
||||
|
|
@ -637,7 +656,10 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
|
|||
// separate "staging changed" gate from "holdings changed" (both
|
||||
// UpdateTotalValue calls read the LIVE holding fresh, same as
|
||||
// BuildCostText's own PropertyInt.CoinValue read).
|
||||
_objects.ObjectAdded += OnObjectAdded;
|
||||
_objects.ObjectUpdated += OnObjectMoneyChanged;
|
||||
_objects.StackSizeUpdated += OnStackSizeUpdated;
|
||||
_objects.ObjectMoved += OnObjectMoved;
|
||||
|
||||
ShowTab(VendorPanelTab.Items);
|
||||
ClearContent();
|
||||
|
|
@ -661,6 +683,7 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
|
|||
// mechanism every other panel already uses, not a vendor-specific
|
||||
// special case.
|
||||
_objects.ObjectRemoved += OnObjectRemoved;
|
||||
_itemInteraction.RuntimeTransactions.Inventory.RequestFailed += OnInventoryRequestFailed;
|
||||
// Slice 6.3: mirrors ExternalContainerController's own
|
||||
// _itemInteraction.StateChanged subscription — the Buy button must
|
||||
// disable the instant a reservation is taken (BeginUseRequestReservation
|
||||
|
|
@ -866,6 +889,14 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
|
|||
|
||||
private void ShowTab(VendorPanelTab tab)
|
||||
{
|
||||
// gmVendorUI::OpenTab resets m_last_sale. Authoritative inventory
|
||||
// remains the preferred source; this only clears the optimistic
|
||||
// post-buy subtraction used before that update arrives.
|
||||
if (_lastAlternateCurrencyPurchase != 0)
|
||||
{
|
||||
_lastAlternateCurrencyPurchase = 0;
|
||||
RefreshMoneyText();
|
||||
}
|
||||
_itemsPage.Visible = tab == VendorPanelTab.Items;
|
||||
_buyingPage.Visible = tab == VendorPanelTab.Buying;
|
||||
_sellingPage.Visible = tab == VendorPanelTab.Selling;
|
||||
|
|
@ -894,6 +925,9 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
|
|||
// staging lists the same way the category selection resets.
|
||||
_buyStaging.Clear();
|
||||
_sellStaging.Clear();
|
||||
_pendingVendorSplit = null;
|
||||
ResetAlternateCurrencyTracking();
|
||||
RefreshMoneyText();
|
||||
_selectedCategoryIndex = -1;
|
||||
ShowTab(VendorPanelTab.Items);
|
||||
RebuildCategories();
|
||||
|
|
@ -910,6 +944,8 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
|
|||
// the time this fires the relevant list is already empty in
|
||||
// the normal flow, and the OTHER (untouched) list must
|
||||
// survive a refresh triggered by its sibling.
|
||||
ResetAlternateCurrencyTracking();
|
||||
RefreshMoneyText();
|
||||
ShowTab(VendorPanelTab.Items);
|
||||
RebuildCategories();
|
||||
_window.Show();
|
||||
|
|
@ -920,6 +956,8 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
|
|||
// the session (contract's C2/C3 close semantics).
|
||||
_buyStaging.Clear();
|
||||
_sellStaging.Clear();
|
||||
_pendingVendorSplit = null;
|
||||
ResetAlternateCurrencyTracking();
|
||||
ClearContent();
|
||||
ShowTab(VendorPanelTab.Items);
|
||||
_window.Hide();
|
||||
|
|
@ -1112,15 +1150,10 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
|
|||
cell.SetItem(item.ItemGuid, icon);
|
||||
cell.Selected = item.ItemGuid == selectedGuid;
|
||||
VendorShopItem captured = item;
|
||||
cell.Clicked = () => _selection.Select(captured.ItemGuid, SelectionChangeSource.Vendor);
|
||||
// AP-171: double-click buys the item — a DELIBERATE,
|
||||
// user-approved modernization. Retail has NO
|
||||
// double-click-to-buy anywhere in the named function
|
||||
// table (negative evidence recorded at the Slice 6
|
||||
// research); the user requested it explicitly
|
||||
// 2026-08-08 after being told so. Select-then-buy so
|
||||
// the quantity/price path is identical to the Buy
|
||||
// button's.
|
||||
cell.Clicked = () =>
|
||||
_selection.Select(captured.ItemGuid, SelectionChangeSource.Vendor);
|
||||
// gmVendorUI::HandleMousePresses @ 0x004C40D0: a
|
||||
// double-click in the browse list calls BuySingleItem.
|
||||
cell.DoubleClicked = () =>
|
||||
{
|
||||
_selection.Select(captured.ItemGuid, SelectionChangeSource.Vendor);
|
||||
|
|
@ -1252,14 +1285,23 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
|
|||
SetPlainText(_itemNameText, nameText);
|
||||
|
||||
VendorShopProfile profile = _vendor.Profile;
|
||||
int rawValue = item.Value ?? 0;
|
||||
int perUnit = VendorPricing.PerUnitValue(rawValue, item.DescStackSize);
|
||||
int price = VendorPricing.SellPrice(perUnit, item.ItemType ?? 0u, profile.SellPrice, quantity);
|
||||
int price = ComputeShopItemPrice(item, quantity);
|
||||
SetPlainText(_itemCostText, BuildCostText(profile, quantity, price));
|
||||
|
||||
SetActionButtonsEnabled(true);
|
||||
}
|
||||
|
||||
private int ComputeShopItemPrice(VendorShopItem item, int quantity)
|
||||
{
|
||||
int rawValue = item.Value ?? 0;
|
||||
int perUnit = VendorPricing.PerUnitValue(rawValue, item.DescStackSize);
|
||||
return VendorPricing.SellPrice(
|
||||
perUnit,
|
||||
item.ItemType ?? 0u,
|
||||
_vendor.Profile.SellPrice,
|
||||
quantity);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Right-click examine on a shop row — mirrors
|
||||
/// <c>ExternalContainerController.ExamineItem</c>'s "select then
|
||||
|
|
@ -1276,6 +1318,13 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
|
|||
_itemInteraction.ExamineSelectedOrEnterMode(guid);
|
||||
}
|
||||
|
||||
private bool PressVendorItem(uint guid)
|
||||
{
|
||||
if (guid != 0u)
|
||||
_selection.Select(guid, SelectionChangeSource.Vendor);
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Slice 6.2: reacts to ANY global selection change, not just ones this
|
||||
/// panel originated — mirrors <c>ExternalContainerController.OnSelectionChanged</c>.
|
||||
|
|
@ -1390,6 +1439,15 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
|
|||
/// </summary>
|
||||
private void OnObjectRemoved(ClientObject item)
|
||||
{
|
||||
if (IsCurrentAlternateCurrency(item))
|
||||
{
|
||||
_alternateCurrencyInventoryObserved = true;
|
||||
_lastAlternateCurrencyPurchase = 0;
|
||||
RefreshMoneyText();
|
||||
}
|
||||
if (_pendingVendorSplit is { } split && split.SourceGuid == item.ObjectId)
|
||||
_pendingVendorSplit = null;
|
||||
|
||||
if (_selection.SelectedObjectId == item.ObjectId)
|
||||
{
|
||||
_selection.Clear(
|
||||
|
|
@ -1463,11 +1521,10 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
|
|||
/// (<c>pc:203494-203497</c>) via the SAME <see cref="ClientObjectTable"/>
|
||||
/// generic int-property bundle every other PropertyInt-driven display
|
||||
/// reads. The alt-currency holding is retail's
|
||||
/// <c>shopVendorProfile->trade_num - m_last_sale</c>;
|
||||
/// <c>m_last_sale</c> only changes on a completed Slice-6 purchase, so
|
||||
/// with no purchase mechanism yet this port uses
|
||||
/// <see cref="VendorShopProfile.AlternateCurrencyAmount"/> directly
|
||||
/// (retail's <c>m_last_sale == 0</c> case — see the register, AP-161).
|
||||
/// <c>shopVendorProfile->trade_num - m_last_sale</c>. This controller
|
||||
/// mirrors the immediate subtraction after dispatch and then reconciles
|
||||
/// to the authoritative player-owned currency stacks when their object
|
||||
/// updates arrive; the profile amount is only the pre-observation fallback.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private string BuildCostText(VendorShopProfile profile, int quantity, int price)
|
||||
|
|
@ -1479,7 +1536,7 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
|
|||
"This item costs {0} {1}. You have {2} {1}.",
|
||||
price,
|
||||
profile.AlternateCurrencyPluralName,
|
||||
(int)profile.AlternateCurrencyAmount);
|
||||
ResolveAlternateCurrencyAmount(profile));
|
||||
}
|
||||
|
||||
int playerTotal = _objects.Get(_playerGuid())?.Properties.GetInt((uint)PropertyInt.CoinValue) ?? 0;
|
||||
|
|
@ -1576,11 +1633,17 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
|
|||
return;
|
||||
|
||||
uint quantity = ResolveBuyQuantity(shopItem);
|
||||
_itemInteraction.TryBuy(
|
||||
_vendor.VendorId,
|
||||
shopItem.ItemGuid,
|
||||
(int)quantity,
|
||||
_vendor.Profile.AlternateCurrencyWcid);
|
||||
VendorShopProfile profile = _vendor.Profile;
|
||||
if (_itemInteraction.TryBuy(
|
||||
_vendor.VendorId,
|
||||
shopItem.ItemGuid,
|
||||
(int)quantity,
|
||||
profile.AlternateCurrencyWcid))
|
||||
{
|
||||
RecordAlternateCurrencyPurchase(
|
||||
profile,
|
||||
ComputeShopItemPrice(shopItem, (int)quantity));
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryFindShopItem(uint guid, out VendorShopItem shopItem)
|
||||
|
|
@ -1653,6 +1716,9 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
|
|||
(int)quantity,
|
||||
_vendor.Profile.AlternateCurrencyWcid))
|
||||
{
|
||||
RecordAlternateCurrencyPurchase(
|
||||
_vendor.Profile,
|
||||
ComputeShopItemPrice(shopItem, (int)quantity));
|
||||
_buyStaging.Remove(shopItem.ItemGuid, BuyStagingRemovalAmount(shopItem));
|
||||
}
|
||||
}
|
||||
|
|
@ -1684,11 +1750,8 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
|
|||
/// <list type="number">
|
||||
/// <item>pyreal affordability — transaction total vs. purse
|
||||
/// (<c>pc:204017</c>: <c>m_transactionValue <= m_totalValue</c>).</item>
|
||||
/// <item>alt-currency affordability — vs. held trade currency minus
|
||||
/// <c>m_last_sale</c> (<c>pc:204032</c>). This session tracks no
|
||||
/// <c>m_last_sale</c> credit yet (see the register's AP-161 residual),
|
||||
/// so this uses the vendor's raw held count, retail's own
|
||||
/// <c>m_last_sale == 0</c> case.</item>
|
||||
/// <item>alt-currency affordability — vs. the authoritative held trade
|
||||
/// currency minus <c>m_last_sale</c> (<c>pc:204032</c>).</item>
|
||||
/// <item>container-slot capacity (<c>pc:204053</c>:
|
||||
/// <c>containerSlotsNeeded > player.ContainersCapacity - containersUsed</c>).</item>
|
||||
/// <item>item-slot capacity (<c>pc:204067</c>: the same shape for
|
||||
|
|
@ -1753,7 +1816,7 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
|
|||
return;
|
||||
}
|
||||
}
|
||||
else if (transactionValue > (int)profile.AlternateCurrencyAmount)
|
||||
else if (transactionValue > ResolveAlternateCurrencyAmount(profile))
|
||||
{
|
||||
_systemMessage?.Invoke(NotEnoughMoneyMessage);
|
||||
return;
|
||||
|
|
@ -1778,7 +1841,10 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
|
|||
}
|
||||
|
||||
if (_itemInteraction.TryBuyAll(_vendor.VendorId, items, profile.AlternateCurrencyWcid))
|
||||
{
|
||||
RecordAlternateCurrencyPurchase(profile, transactionValue);
|
||||
_buyStaging.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>F1: the SAME per-row price formula <see cref="ApplyItemDisplay"/> shows, summed over every staged entry.</summary>
|
||||
|
|
@ -1904,7 +1970,7 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
|
|||
return string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
"You have {0} {1}.",
|
||||
(int)profile.AlternateCurrencyAmount,
|
||||
ResolveAlternateCurrencyAmount(profile),
|
||||
profile.AlternateCurrencyPluralName);
|
||||
}
|
||||
int playerTotal = _objects.Get(_playerGuid())?.Properties.GetInt((uint)PropertyInt.CoinValue) ?? 0;
|
||||
|
|
@ -1961,8 +2027,52 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
|
|||
/// </summary>
|
||||
private void OnObjectMoneyChanged(ClientObject updated)
|
||||
{
|
||||
TryResolvePendingVendorSplit(updated);
|
||||
if (updated.ObjectId != _playerGuid())
|
||||
return;
|
||||
RefreshMoneyText();
|
||||
}
|
||||
|
||||
private void OnObjectAdded(ClientObject item)
|
||||
{
|
||||
TryResolvePendingVendorSplit(item);
|
||||
if (IsCurrentAlternateCurrency(item)
|
||||
&& _objects.IsOwnedByObject(item.ObjectId, _playerGuid()))
|
||||
{
|
||||
ReconcileAlternateCurrencyInventory();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnStackSizeUpdated(ClientObject item)
|
||||
{
|
||||
if (IsCurrentAlternateCurrency(item)
|
||||
&& _objects.IsOwnedByObject(item.ObjectId, _playerGuid()))
|
||||
{
|
||||
ReconcileAlternateCurrencyInventory();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnObjectMoved(ClientObjectMove move)
|
||||
{
|
||||
if (move.Item is not { } item)
|
||||
return;
|
||||
|
||||
TryResolvePendingVendorSplit(item);
|
||||
if (!IsCurrentAlternateCurrency(item))
|
||||
return;
|
||||
|
||||
ReconcileAlternateCurrencyInventory();
|
||||
}
|
||||
|
||||
private void ReconcileAlternateCurrencyInventory()
|
||||
{
|
||||
_alternateCurrencyInventoryObserved = true;
|
||||
_lastAlternateCurrencyPurchase = 0;
|
||||
RefreshMoneyText();
|
||||
}
|
||||
|
||||
private void RefreshMoneyText()
|
||||
{
|
||||
UpdateBuyTransactionText();
|
||||
UpdateSellTransactionText();
|
||||
// Post-buy gate finding (2026-08-08): the Items tab's cost sentence
|
||||
|
|
@ -1972,6 +2082,57 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
|
|||
RefreshSelectionDisplay();
|
||||
}
|
||||
|
||||
private bool IsCurrentAlternateCurrency(ClientObject item)
|
||||
{
|
||||
uint wcid = _vendor.Profile.AlternateCurrencyWcid;
|
||||
return wcid != 0u && item.WeenieClassId == wcid;
|
||||
}
|
||||
|
||||
private int ResolveAlternateCurrencyAmount(VendorShopProfile profile)
|
||||
{
|
||||
if (profile.AlternateCurrencyWcid == 0u)
|
||||
return 0;
|
||||
|
||||
long live = 0;
|
||||
bool found = false;
|
||||
foreach (ClientObject item in _objects.Objects)
|
||||
{
|
||||
if (item.WeenieClassId != profile.AlternateCurrencyWcid
|
||||
|| !_objects.IsOwnedByObject(item.ObjectId, _playerGuid()))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
found = true;
|
||||
live += Math.Max(1, item.StackSize);
|
||||
}
|
||||
|
||||
long baseline = found || _alternateCurrencyInventoryObserved
|
||||
? live
|
||||
: profile.AlternateCurrencyAmount;
|
||||
return (int)Math.Clamp(
|
||||
baseline - _lastAlternateCurrencyPurchase,
|
||||
0L,
|
||||
int.MaxValue);
|
||||
}
|
||||
|
||||
private void RecordAlternateCurrencyPurchase(VendorShopProfile profile, int price)
|
||||
{
|
||||
if (profile.AlternateCurrencyWcid == 0u || price <= 0)
|
||||
return;
|
||||
_lastAlternateCurrencyPurchase = price;
|
||||
RefreshMoneyText();
|
||||
}
|
||||
|
||||
private void ResetAlternateCurrencyTracking()
|
||||
{
|
||||
_lastAlternateCurrencyPurchase = 0;
|
||||
uint wcid = _vendor.Profile.AlternateCurrencyWcid;
|
||||
_alternateCurrencyInventoryObserved = wcid != 0u
|
||||
&& _objects.Objects.Any(item =>
|
||||
item.WeenieClassId == wcid
|
||||
&& _objects.IsOwnedByObject(item.ObjectId, _playerGuid()));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// F1: port of <c>gmVendorUI::InqListSlotCount</c> (<c>pc:200038-200065</c>,
|
||||
/// <c>0x004c0c10</c>) — see <see cref="BuyAllButtonPressed"/>'s own doc
|
||||
|
|
@ -2216,7 +2377,9 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
|
|||
cell.SetItem(shopItem.ItemGuid, icon);
|
||||
cell.Selected = shopItem.ItemGuid == selectedGuid;
|
||||
VendorShopItem captured = shopItem;
|
||||
cell.Clicked = () => _selection.Select(captured.ItemGuid, SelectionChangeSource.Vendor);
|
||||
cell.Clicked = () =>
|
||||
_selection.Select(captured.ItemGuid, SelectionChangeSource.Vendor);
|
||||
cell.DoubleClicked = () => RemoveOneBuyingUnit(captured.ItemGuid);
|
||||
list.AddItem(cell);
|
||||
}
|
||||
}
|
||||
|
|
@ -2249,13 +2412,16 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
|
|||
{
|
||||
SpriteResolve = list.SpriteResolve,
|
||||
SlotIndex = list.GetNumUIItems(),
|
||||
AllowDragSource = false,
|
||||
AllowDragSource = true,
|
||||
SourceKind = ItemDragSource.Inventory,
|
||||
TooltipTextResolve = g => _objects.Get(g)?.GetTooltipDisplayName(),
|
||||
};
|
||||
cell.SetItem(item.ObjectId, icon);
|
||||
cell.Selected = item.ObjectId == selectedGuid;
|
||||
uint captured = item.ObjectId;
|
||||
cell.Clicked = () => _selection.Select(captured, SelectionChangeSource.Vendor);
|
||||
cell.Clicked = () =>
|
||||
_selection.Select(captured, SelectionChangeSource.Vendor);
|
||||
cell.DoubleClicked = () => RemoveSellingEntry(captured);
|
||||
list.AddItem(cell);
|
||||
}
|
||||
}
|
||||
|
|
@ -2267,15 +2433,62 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
|
|||
// gate, pc:204229-204246) ──────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// The Selling list never sources a drag of its own — every staged cell
|
||||
/// sets <c>AllowDragSource = false</c> (F3, Slice 6 review), the same
|
||||
/// non-drag-source convention every vendor row uses — so
|
||||
/// <see cref="UiItemSlot"/>'s drag-lift dispatch (which routes to the
|
||||
/// SOURCE list's own registered handler) can never actually reach this
|
||||
/// method in practice. Implemented as a no-op for interface completeness.
|
||||
/// Retail <c>RecvNotice_ItemListBeginDrag @ 0x004C4380</c>: lifting an
|
||||
/// already-staged Selling row removes it in full. A partial toolbar split
|
||||
/// is not applied to this list; retail prints the literal refusal and
|
||||
/// restores the slider to its maximum.
|
||||
/// </summary>
|
||||
public void OnDragLift(UiItemList sourceList, UiItemSlot sourceCell, ItemDragPayload payload)
|
||||
{
|
||||
if (!ReferenceEquals(sourceList, _sellingList) || payload.ObjId == 0u)
|
||||
return;
|
||||
|
||||
_selection.Select(payload.ObjId, SelectionChangeSource.Vendor);
|
||||
RemoveSellingEntry(payload.ObjId, reportRemoval: false);
|
||||
if (_objects.Get(payload.ObjId) is not { } item)
|
||||
return;
|
||||
|
||||
uint fullStack = (uint)Math.Max(1, item.StackSize);
|
||||
uint selected = _splitQuantity.GetObjectSplitSize(
|
||||
payload.ObjId,
|
||||
_selection.SelectedObjectId ?? 0u,
|
||||
fullStack);
|
||||
if (selected < fullStack)
|
||||
{
|
||||
_itemInteraction.ReportClientLocal(
|
||||
"You cannot split items from this panel");
|
||||
_splitQuantity.Reset(fullStack);
|
||||
}
|
||||
}
|
||||
|
||||
private void RemoveOneBuyingUnit(uint itemGuid)
|
||||
{
|
||||
if (!_buyStaging.TryGet(itemGuid, out _))
|
||||
return;
|
||||
_selection.Select(itemGuid, SelectionChangeSource.Vendor);
|
||||
ReportShoppingListRemoval(itemGuid);
|
||||
_buyStaging.Remove(itemGuid, 1);
|
||||
}
|
||||
|
||||
private void RemoveSellingEntry(uint itemGuid, bool reportRemoval = true)
|
||||
{
|
||||
if (!_sellStaging.TryGet(itemGuid, out _))
|
||||
return;
|
||||
_selection.Select(itemGuid, SelectionChangeSource.Vendor);
|
||||
if (reportRemoval)
|
||||
ReportShoppingListRemoval(itemGuid);
|
||||
_sellStaging.Remove(itemGuid, -1);
|
||||
}
|
||||
|
||||
private void ReportShoppingListRemoval(uint itemGuid)
|
||||
{
|
||||
string? name = _objects.Get(itemGuid)?.GetAppropriateName();
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
name = _vendor.Items.FirstOrDefault(item => item.ItemGuid == itemGuid).Name;
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
name = "that item";
|
||||
_itemInteraction.ReportClientLocal(
|
||||
$"Removing {name} from shopping list");
|
||||
}
|
||||
|
||||
public ItemDragAcceptance OnDragOver(
|
||||
|
|
@ -2335,8 +2548,9 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
|
|||
/// <c>silent=0</c>, showing a rejection string) chained into
|
||||
/// <c>VendorSellUI::AddItemToSell</c> (<c>pc:203546-203567</c>) on
|
||||
/// success: auto-switch to the "Selling" tab, globally select the
|
||||
/// dropped item, stage it. Purely client-local — sends nothing to the
|
||||
/// server, matching the Buying tab's "Add to List".
|
||||
/// dropped item, and stage it. For a partial stack retail first calls
|
||||
/// <c>AttemptToPlaceInContainer</c>, stages the source as a temporary
|
||||
/// row, then replaces that row when the new split object arrives.
|
||||
/// </summary>
|
||||
public void HandleDropRelease(
|
||||
UiItemList targetList,
|
||||
|
|
@ -2356,6 +2570,29 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
|
|||
|
||||
ShowTab(VendorPanelTab.Selling);
|
||||
_selection.Select(payload.ObjId, SelectionChangeSource.Vendor);
|
||||
|
||||
ClientObject item = _objects.Get(payload.ObjId)!;
|
||||
int fullStack = Math.Max(1, item.StackSize);
|
||||
if (quantity < fullStack)
|
||||
{
|
||||
_pendingVendorSplit = new PendingVendorSplit(
|
||||
payload.ObjId,
|
||||
item.WeenieClassId,
|
||||
quantity);
|
||||
if (!_itemInteraction.TrySplitToContainer(
|
||||
payload.ObjId,
|
||||
item.ContainerId,
|
||||
0u,
|
||||
(uint)quantity))
|
||||
{
|
||||
_pendingVendorSplit = null;
|
||||
_systemMessage?.Invoke("Cannot split the stack to sell it");
|
||||
return;
|
||||
}
|
||||
|
||||
string name = string.IsNullOrWhiteSpace(item.Name) ? "item" : item.Name;
|
||||
_systemMessage?.Invoke($"Splitting the {name} before selling them");
|
||||
}
|
||||
_sellStaging.Add(payload.ObjId, quantity);
|
||||
}
|
||||
|
||||
|
|
@ -2366,18 +2603,12 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
|
|||
/// <paramref name="quantity"/> is the staged quantity a successful drop
|
||||
/// would use.
|
||||
/// <para>
|
||||
/// F6 (Slice 6b/6c review, byte-verified): this is ALWAYS the item's
|
||||
/// FULL current stack — retail's <c>VendorSellUI::AddItemToSell</c>
|
||||
/// (<c>pc:203546-203567</c>) stages via <c>gmVendorUI::AddItem(...,
|
||||
/// itemGuid, -1, ...)</c>, a LITERAL <c>-1</c> "full stack" sentinel
|
||||
/// argument, never a slider read. A prior version of this port read the
|
||||
/// LIVE split-quantity slider here instead (the Slice 6b/6c research
|
||||
/// doc's Q4 section had flagged this exact source as an unverified
|
||||
/// inferred analogy to the Buying tab's <c>AddToBuyList</c>) — that
|
||||
/// inference is now known WRONG: Sell staging has no partial-quantity
|
||||
/// feature in retail at all, unlike Buy. See
|
||||
/// <c>VendorStagingList.Add</c>'s own doc comment for the Buy side's
|
||||
/// (genuinely slider-driven) contrast.
|
||||
/// Retail's full-stack branch does pass the literal <c>-1</c> sentinel
|
||||
/// to <c>AddItemToSell</c>. The enclosing
|
||||
/// <c>VendorSellUI::AcceptDragObject</c>, however, first compares the
|
||||
/// live split slider with the maximum and creates a separate stack when
|
||||
/// they differ. Therefore the quantity exposed here is the live slider
|
||||
/// amount for stackables, not always the source's full count.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private VendorSellRejection EvaluateSellAcceptability(uint itemGuid, out int quantity)
|
||||
|
|
@ -2401,10 +2632,44 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
|
|||
item.PublicWeenieBitfield ?? 0u);
|
||||
|
||||
if (rejection == VendorSellRejection.None)
|
||||
quantity = (int)Math.Max(1, item.StackSize);
|
||||
{
|
||||
uint fullStack = (uint)Math.Max(1, item.StackSize);
|
||||
quantity = (int)_splitQuantity.GetObjectSplitSize(
|
||||
itemGuid,
|
||||
_selection.SelectedObjectId ?? 0u,
|
||||
fullStack);
|
||||
}
|
||||
return rejection;
|
||||
}
|
||||
|
||||
private void TryResolvePendingVendorSplit(ClientObject item)
|
||||
{
|
||||
if (_pendingVendorSplit is not { } pending
|
||||
|| item.ObjectId == pending.SourceGuid
|
||||
|| item.WeenieClassId != pending.WeenieClassId
|
||||
|| item.StackSize != pending.Quantity
|
||||
|| !_objects.IsOwnedByObject(item.ObjectId, _playerGuid()))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_sellStaging.Replace(pending.SourceGuid, item.ObjectId))
|
||||
_pendingVendorSplit = null;
|
||||
}
|
||||
|
||||
private void OnInventoryRequestFailed(PendingInventoryRequest request, uint _)
|
||||
{
|
||||
if (_pendingVendorSplit is not { } pending
|
||||
|| request.Kind != InventoryRequestKind.SplitToContainer
|
||||
|| request.ItemId != pending.SourceGuid)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_sellStaging.Remove(pending.SourceGuid, -1);
|
||||
_pendingVendorSplit = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// G4/Slice 6b: port of retail's close/pushpin button handler —
|
||||
/// <c>gmVendorUI::HandleButtonClicks</c>'s <c>0x100000d6</c> case
|
||||
|
|
@ -2564,8 +2829,12 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
|
|||
_disposed = true;
|
||||
_vendor.Changed -= OnVendorChanged;
|
||||
_selection.Changed -= OnSelectionTransition;
|
||||
_objects.ObjectAdded -= OnObjectAdded;
|
||||
_objects.ObjectRemoved -= OnObjectRemoved;
|
||||
_objects.ObjectUpdated -= OnObjectMoneyChanged;
|
||||
_objects.StackSizeUpdated -= OnStackSizeUpdated;
|
||||
_objects.ObjectMoved -= OnObjectMoved;
|
||||
_itemInteraction.RuntimeTransactions.Inventory.RequestFailed -= OnInventoryRequestFailed;
|
||||
_itemInteraction.StateChanged -= OnInteractionStateChanged;
|
||||
_splitQuantity.Changed -= OnSplitQuantityChanged;
|
||||
_buyStaging.Changed -= RebuildBuyingList;
|
||||
|
|
@ -2581,6 +2850,17 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
|
|||
_typeMenu.OnSelect = null;
|
||||
_typeMenu.ButtonLabelProvider = null;
|
||||
_itemList.ExamineItemRequested = null;
|
||||
_itemList.PrimaryItemPressed = null;
|
||||
if (_buyingList is not null)
|
||||
{
|
||||
_buyingList.ExamineItemRequested = null;
|
||||
_buyingList.PrimaryItemPressed = null;
|
||||
}
|
||||
if (_sellingList is not null)
|
||||
{
|
||||
_sellingList.ExamineItemRequested = null;
|
||||
_sellingList.PrimaryItemPressed = null;
|
||||
}
|
||||
if (_close is not null)
|
||||
_close.OnClick = null;
|
||||
if (_buyButton is not null)
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ using AcDream.Core.Combat;
|
|||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Net;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Core.Properties;
|
||||
using AcDream.Core.Selection;
|
||||
using AcDream.Core.Spells;
|
||||
using AcDream.Runtime;
|
||||
|
|
@ -441,7 +442,8 @@ public sealed record VendorRuntimeBindings(
|
|||
/// Campaign OP slice OP8: the Configure Keyboard screen's live read/write seam —
|
||||
/// the ONE live <see cref="InputDispatcher"/> (Bindings for reads,
|
||||
/// SetBindings+BeginCapture for writes/capture) plus the portable
|
||||
/// <c>keybinds.json</c> path (D4 — no <c>.keymap</c> file interchange). Null
|
||||
/// <c>keybinds.json</c> mirror path. Retail <c>*.keymap</c> profiles live in
|
||||
/// Documents/Asheron's Call and the selected profile is reloaded at startup. Null
|
||||
/// <see cref="Dispatcher"/> (headless/no-window hosts, or before the graphical
|
||||
/// input stack finishes constructing) degrades to "Configure Keyboard has no
|
||||
/// live effect" exactly like every other null-dependency Options-panel seam.
|
||||
|
|
@ -464,11 +466,10 @@ public sealed record KeyboardRuntimeBindings(
|
|||
/// (<c>RecvNotice_CloseDialog@0x004ed760</c> case 1) retail queues UI mode
|
||||
/// <c>0x10000009</c> (<c>gmEpilogueUI</c>) rather than exiting immediately —
|
||||
/// out of scope here. This is a plain host action, not a generation-gated
|
||||
/// Runtime command: it is the SAME window-close path
|
||||
/// <c>GameplayWindowCommands</c>/<c>IGameplayWindowCommands.Close</c> already
|
||||
/// use for the in-world Escape fallback (<c>d.Window.Close</c> at
|
||||
/// composition), so status events <c>disconnected</c>/<c>exited</c> still
|
||||
/// fire through <c>GameWindow.OnClosing</c> → <c>CompleteShutdown</c>.
|
||||
/// Runtime command. It closes through <c>d.Window.Close</c>, so status events
|
||||
/// <c>disconnected</c>/<c>exited</c> still fire through
|
||||
/// <c>GameWindow.OnClosing</c> → <c>CompleteShutdown</c>. In-world Escape does
|
||||
/// not use this path; retail clears selection or toggles Gameplay Options.
|
||||
/// </param>
|
||||
public sealed record CharacterSelectionRuntimeBindings(
|
||||
Func<IRuntimeCharacterSelectionView?> View,
|
||||
|
|
@ -529,7 +530,8 @@ public sealed record RetailUiRuntimeBindings(
|
|||
KeyboardRuntimeBindings? Keyboard = null,
|
||||
CharacterSelectionRuntimeBindings? CharacterSelection = null,
|
||||
// Campaign CC slice CC4: sibling of CharacterSelection above.
|
||||
CharacterCreationRuntimeBindings? CharacterCreation = null);
|
||||
CharacterCreationRuntimeBindings? CharacterCreation = null,
|
||||
Action? CaptureScreenshot = null);
|
||||
|
||||
/// <summary>
|
||||
/// Composition owner for the production retained gameplay UI. GameWindow supplies
|
||||
|
|
@ -742,6 +744,7 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
public VendorUiController? VendorController { get; private set; }
|
||||
public OptionsPanelController? OptionsPanelController { get; private set; }
|
||||
public SocialPanelController? SocialPanelController { get; private set; }
|
||||
private CharacterStatController.Binding? _characterStatBinding;
|
||||
|
||||
/// <summary>Campaign QT slice QT5 — the three-tab Journal panel.</summary>
|
||||
public Layout.JournalPanelController? JournalPanelController { get; private set; }
|
||||
|
|
@ -1006,56 +1009,278 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
{
|
||||
if (SpellcastingUiController?.Handle(action) == true)
|
||||
return true;
|
||||
if (action == AcDream.UI.Abstractions.Input.InputAction.ToggleSpellbookPanel)
|
||||
|
||||
switch (action)
|
||||
{
|
||||
OpenSpellbook(SpellbookWindowPage.Spells);
|
||||
return true;
|
||||
}
|
||||
if (action == AcDream.UI.Abstractions.Input.InputAction.ToggleSpellComponentsPanel)
|
||||
{
|
||||
OpenSpellbook(SpellbookWindowPage.Components);
|
||||
return true;
|
||||
}
|
||||
// Campaign FA slice FA3: F3/F4 — keyboard-only open paths (lane A
|
||||
// §6.1: neither action authors a toolbar button). Both share the
|
||||
// one social panel (RetailPanelCatalog.SocialPanel) and switch to
|
||||
// their own tab; the panel participates in the SAME gmPanelUI
|
||||
// one-active-panel exclusivity every sibling panel gets from
|
||||
// RetailPanelUiController.RegisterMainPanel.
|
||||
if (action == AcDream.UI.Abstractions.Input.InputAction.ToggleAllegiancePanel)
|
||||
{
|
||||
OpenSocialPanel(showAllegiance: true);
|
||||
return true;
|
||||
}
|
||||
if (action == AcDream.UI.Abstractions.Input.InputAction.ToggleFellowshipPanel)
|
||||
{
|
||||
OpenSocialPanel(showAllegiance: false);
|
||||
return true;
|
||||
case AcDream.UI.Abstractions.Input.InputAction.CaptureScreenshot:
|
||||
_bindings.CaptureScreenshot?.Invoke();
|
||||
return true;
|
||||
case AcDream.UI.Abstractions.Input.InputAction.ToggleHelp:
|
||||
// EoR delegates this to the separately shipped ACHelpPlugin.
|
||||
// That binary is not part of acdream; consume the retail action
|
||||
// and report the unavailable external surface honestly.
|
||||
_bindings.Options.DisplaySystemMessage(
|
||||
"In-game help is unavailable because the retail help plugin is not installed.");
|
||||
return true;
|
||||
case AcDream.UI.Abstractions.Input.InputAction.TogglePluginManager:
|
||||
_bindings.Options.DisplaySystemMessage(
|
||||
"The retail plugin manager is not available in acdream.");
|
||||
return true;
|
||||
case AcDream.UI.Abstractions.Input.InputAction.ToggleAbuseReportingPanel:
|
||||
_bindings.Options.DisplaySystemMessage(OptionsPanelText.ReportAbuseUnavailable);
|
||||
return true;
|
||||
case AcDream.UI.Abstractions.Input.InputAction.ToggleUrgentAssistancePanel:
|
||||
_bindings.Options.DisplaySystemMessage(OptionsPanelText.UrgentAssistanceUnavailable);
|
||||
return true;
|
||||
case AcDream.UI.Abstractions.Input.InputAction.ChatReply:
|
||||
_chatWindowController?.StartReply(_bindings.Chat.ViewModel.LastIncomingTellSender);
|
||||
return true;
|
||||
case AcDream.UI.Abstractions.Input.InputAction.ChatMonarchReply:
|
||||
_chatWindowController?.StartReply(_bindings.Chat.ViewModel.LastMonarchSender);
|
||||
return true;
|
||||
case AcDream.UI.Abstractions.Input.InputAction.ChatPatronReply:
|
||||
_chatWindowController?.StartReply(_bindings.Chat.ViewModel.LastPatronSender);
|
||||
return true;
|
||||
case AcDream.UI.Abstractions.Input.InputAction.ChatStartCommand:
|
||||
_chatWindowController?.StartCommand();
|
||||
return true;
|
||||
case AcDream.UI.Abstractions.Input.InputAction.ChatTellToSelected:
|
||||
{
|
||||
uint selected = _bindings.Toolbar.Selection.SelectedObjectId ?? 0u;
|
||||
if (selected is >= 0x50000001u and <= 0x6FFFFFFFu)
|
||||
{
|
||||
string? name = _bindings.Toolbar.ResolveName(selected);
|
||||
if (!string.IsNullOrEmpty(name))
|
||||
_chatWindowController?.StartTell(name);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
case AcDream.UI.Abstractions.Input.InputAction.EnterChatMode:
|
||||
_chatWindowController?.EnterChatMode(
|
||||
_bindings.Keyboard?.Dispatcher?.CurrentPhysicalChord);
|
||||
return true;
|
||||
case AcDream.UI.Abstractions.Input.InputAction.ToggleChatEntry:
|
||||
_chatWindowController?.ToggleChatEntry(
|
||||
_bindings.Keyboard?.Dispatcher?.CurrentPhysicalChord);
|
||||
return true;
|
||||
case AcDream.UI.Abstractions.Input.InputAction.ToggleCharacterInfoPanel:
|
||||
ToggleWindow(WindowNames.CharacterInformation);
|
||||
return true;
|
||||
case AcDream.UI.Abstractions.Input.InputAction.TogglePositiveMagicPanel:
|
||||
ToggleWindow(WindowNames.PositiveEffects);
|
||||
return true;
|
||||
case AcDream.UI.Abstractions.Input.InputAction.ToggleNegativeMagicPanel:
|
||||
ToggleWindow(WindowNames.NegativeEffects);
|
||||
return true;
|
||||
case AcDream.UI.Abstractions.Input.InputAction.ToggleLinkStatusPanel:
|
||||
ToggleWindow(WindowNames.LinkStatus);
|
||||
return true;
|
||||
case AcDream.UI.Abstractions.Input.InputAction.ToggleVitaePanel:
|
||||
ToggleWindow(WindowNames.Vitae);
|
||||
return true;
|
||||
case AcDream.UI.Abstractions.Input.InputAction.ToggleSocialPanel:
|
||||
ToggleWindow(WindowNames.SocialPanel);
|
||||
return true;
|
||||
case AcDream.UI.Abstractions.Input.InputAction.ToggleAllegiancePanel:
|
||||
OpenSocialPanel(SocialPanelPage.Allegiance);
|
||||
return true;
|
||||
case AcDream.UI.Abstractions.Input.InputAction.ToggleFellowshipPanel:
|
||||
OpenSocialPanel(SocialPanelPage.Fellowship);
|
||||
return true;
|
||||
case AcDream.UI.Abstractions.Input.InputAction.ToggleFriendsPage:
|
||||
OpenSocialPanel(SocialPanelPage.Friends);
|
||||
return true;
|
||||
case AcDream.UI.Abstractions.Input.InputAction.ToggleSpellManagementPanel:
|
||||
ToggleWindow(WindowNames.Spellbook);
|
||||
return true;
|
||||
case AcDream.UI.Abstractions.Input.InputAction.ToggleSpellbookPanel:
|
||||
OpenSpellbook(SpellbookWindowPage.Spells);
|
||||
return true;
|
||||
case AcDream.UI.Abstractions.Input.InputAction.ToggleSpellComponentsPanel:
|
||||
OpenSpellbook(SpellbookWindowPage.Components);
|
||||
return true;
|
||||
case AcDream.UI.Abstractions.Input.InputAction.ToggleCharacterDetailPanel:
|
||||
ToggleWindow(WindowNames.Character);
|
||||
return true;
|
||||
case AcDream.UI.Abstractions.Input.InputAction.ToggleAttributesPanel:
|
||||
OpenCharacterPanel(CharacterStatController.CharacterStatTab.Attributes);
|
||||
return true;
|
||||
case AcDream.UI.Abstractions.Input.InputAction.ToggleSkillsPanel:
|
||||
OpenCharacterPanel(CharacterStatController.CharacterStatTab.Skills);
|
||||
return true;
|
||||
case AcDream.UI.Abstractions.Input.InputAction.ToggleCharacterTitlesPage:
|
||||
OpenCharacterPanel(CharacterStatController.CharacterStatTab.Titles);
|
||||
return true;
|
||||
case AcDream.UI.Abstractions.Input.InputAction.ToggleWorldPanel:
|
||||
ToggleWindow(WindowNames.MapHouse);
|
||||
return true;
|
||||
case AcDream.UI.Abstractions.Input.InputAction.ToggleMapPage:
|
||||
OpenWorldPanel(showHouse: false);
|
||||
return true;
|
||||
case AcDream.UI.Abstractions.Input.InputAction.ToggleHousePage:
|
||||
OpenWorldPanel(showHouse: true);
|
||||
return true;
|
||||
case AcDream.UI.Abstractions.Input.InputAction.ToggleOptionsPanel:
|
||||
ToggleWindow(WindowNames.Options);
|
||||
return true;
|
||||
case AcDream.UI.Abstractions.Input.InputAction.ToggleGameplayOptionsPage:
|
||||
OpenOptionsPage(OptionsPanelPage.Gameplay);
|
||||
return true;
|
||||
case AcDream.UI.Abstractions.Input.InputAction.ToggleCharacterSettingsPage:
|
||||
OpenOptionsPage(OptionsPanelPage.Character);
|
||||
return true;
|
||||
case AcDream.UI.Abstractions.Input.InputAction.ToggleConfigurationPage:
|
||||
OpenOptionsPage(OptionsPanelPage.Configuration);
|
||||
return true;
|
||||
case AcDream.UI.Abstractions.Input.InputAction.ToggleCompass:
|
||||
Host.ToggleWindow(WindowNames.Radar);
|
||||
return true;
|
||||
case AcDream.UI.Abstractions.Input.InputAction.ToggleKeyboardConfiguration:
|
||||
ToggleWindow(WindowNames.KeyboardConfig);
|
||||
return true;
|
||||
case AcDream.UI.Abstractions.Input.InputAction.ToggleQuestJournalPage:
|
||||
OpenJournalPanel(JournalPanelPage.Notes);
|
||||
return true;
|
||||
case AcDream.UI.Abstractions.Input.InputAction.ToggleQuestDetailPanel:
|
||||
// EoR's quest-detail action addresses the quest-management
|
||||
// surface. The current authored Journal host's server-backed
|
||||
// Contracts page is that surface in acdream.
|
||||
OpenJournalPanel(JournalPanelPage.Contracts);
|
||||
return true;
|
||||
case AcDream.UI.Abstractions.Input.InputAction.ToggleJournalPageList:
|
||||
OpenJournalPanel(JournalPanelPage.PageList);
|
||||
return true;
|
||||
case AcDream.UI.Abstractions.Input.InputAction.ToggleContractsPage:
|
||||
OpenJournalPanel(JournalPanelPage.Contracts);
|
||||
return true;
|
||||
}
|
||||
|
||||
return ToolbarInputController?.Handle(action) == true;
|
||||
}
|
||||
|
||||
private void OpenCharacterPanel(CharacterStatController.CharacterStatTab tab)
|
||||
{
|
||||
bool visible = Host.IsWindowVisible(WindowNames.Character);
|
||||
bool onTargetTab = _characterStatBinding?.CurrentTab() == tab;
|
||||
if (visible && onTargetTab)
|
||||
{
|
||||
CloseWindow(WindowNames.Character);
|
||||
return;
|
||||
}
|
||||
|
||||
_characterStatBinding?.ShowTab(tab);
|
||||
_panelUi.SetPanelVisibility(RetailPanelCatalog.Character, visible: true);
|
||||
}
|
||||
|
||||
private void OpenWorldPanel(bool showHouse)
|
||||
{
|
||||
bool visible = Host.IsWindowVisible(WindowNames.MapHouse);
|
||||
bool onTargetTab = showHouse
|
||||
? MapHousePanelController?.IsShowingHouse == true
|
||||
: MapHousePanelController?.IsShowingMap == true;
|
||||
if (visible && onTargetTab)
|
||||
{
|
||||
CloseWindow(WindowNames.MapHouse);
|
||||
return;
|
||||
}
|
||||
|
||||
if (showHouse)
|
||||
MapHousePanelController?.ShowHouse();
|
||||
else
|
||||
MapHousePanelController?.ShowMap();
|
||||
_panelUi.SetPanelVisibility(RetailPanelCatalog.MapHouse, visible: true);
|
||||
}
|
||||
|
||||
private enum OptionsPanelPage { Gameplay, Character, Configuration }
|
||||
|
||||
private void OpenOptionsPage(OptionsPanelPage page)
|
||||
{
|
||||
bool visible = Host.IsWindowVisible(WindowNames.Options);
|
||||
bool onTargetTab = page switch
|
||||
{
|
||||
OptionsPanelPage.Gameplay => OptionsPanelController?.IsShowingGameplay == true,
|
||||
OptionsPanelPage.Character => OptionsPanelController?.IsShowingCharacter == true,
|
||||
OptionsPanelPage.Configuration => OptionsPanelController?.IsShowingConfiguration == true,
|
||||
_ => false,
|
||||
};
|
||||
if (visible && onTargetTab)
|
||||
{
|
||||
CloseWindow(WindowNames.Options);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (page)
|
||||
{
|
||||
case OptionsPanelPage.Gameplay: OptionsPanelController?.ShowGameplay(); break;
|
||||
case OptionsPanelPage.Character: OptionsPanelController?.ShowCharacter(); break;
|
||||
case OptionsPanelPage.Configuration: OptionsPanelController?.ShowConfiguration(); break;
|
||||
}
|
||||
_panelUi.SetPanelVisibility(RetailPanelCatalog.Options, visible: true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail Escape's final fallback: toggle action <c>0x1000001B</c>,
|
||||
/// whose installed-DAT ActionMap label is "Show/Hide Gameplay Options
|
||||
/// Page". Reuses the authored Options tab and panel owners.
|
||||
/// </summary>
|
||||
public void ToggleGameplayOptionsPage()
|
||||
=> OpenOptionsPage(OptionsPanelPage.Gameplay);
|
||||
|
||||
/// <summary>Semantic/rebound form of retail Enter/Tab chat activation.</summary>
|
||||
public void FocusChatEntry()
|
||||
{
|
||||
if (Host.Root.DefaultTextInput is { } input)
|
||||
Host.Root.SetKeyboardFocus(input);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shift+Escape's retail LOGOUT action: no confirmation dialog; the
|
||||
/// normal grounded/airborne/no-player gate still applies.
|
||||
/// </summary>
|
||||
public void LogOutCharacter() => EndCharacterSessionWithRetailGates();
|
||||
|
||||
/// <summary>Shared F3/F4 handler — same "toggle closes on a repeat press
|
||||
/// of the SAME tab, otherwise show + switch" shape as <see cref="OpenSpellbook"/>.</summary>
|
||||
private void OpenSocialPanel(bool showAllegiance)
|
||||
private enum SocialPanelPage { Friends, Allegiance, Fellowship }
|
||||
|
||||
private void OpenSocialPanel(SocialPanelPage page)
|
||||
{
|
||||
bool visible = Host.IsWindowVisible(WindowNames.SocialPanel);
|
||||
bool onTargetTab = showAllegiance
|
||||
? SocialPanelController?.IsShowingAllegiance == true
|
||||
: SocialPanelController?.IsShowingFellowship == true;
|
||||
bool onTargetTab = page switch
|
||||
{
|
||||
SocialPanelPage.Friends => SocialPanelController?.IsShowingFriends == true,
|
||||
SocialPanelPage.Allegiance => SocialPanelController?.IsShowingAllegiance == true,
|
||||
SocialPanelPage.Fellowship => SocialPanelController?.IsShowingFellowship == true,
|
||||
_ => false,
|
||||
};
|
||||
if (visible && onTargetTab)
|
||||
{
|
||||
CloseWindow(WindowNames.SocialPanel);
|
||||
return;
|
||||
}
|
||||
|
||||
if (showAllegiance)
|
||||
SocialPanelController?.ShowAllegiance();
|
||||
else
|
||||
SocialPanelController?.ShowFellowship();
|
||||
switch (page)
|
||||
{
|
||||
case SocialPanelPage.Friends: SocialPanelController?.ShowFriends(); break;
|
||||
case SocialPanelPage.Allegiance: SocialPanelController?.ShowAllegiance(); break;
|
||||
case SocialPanelPage.Fellowship: SocialPanelController?.ShowFellowship(); break;
|
||||
}
|
||||
_panelUi.SetPanelVisibility(RetailPanelCatalog.SocialPanel, visible: true);
|
||||
}
|
||||
|
||||
private enum JournalPanelPage { Contracts, Notes, PageList }
|
||||
|
||||
private void OpenJournalPanel(JournalPanelPage page)
|
||||
{
|
||||
switch (page)
|
||||
{
|
||||
case JournalPanelPage.Contracts: JournalPanelController?.ShowContracts(); break;
|
||||
case JournalPanelPage.Notes: JournalPanelController?.ShowNotes(); break;
|
||||
case JournalPanelPage.PageList: JournalPanelController?.ShowPageList(); break;
|
||||
}
|
||||
_panelUi.SetPanelVisibility(RetailPanelCatalog.Journal, visible: true);
|
||||
}
|
||||
|
||||
private void OpenSpellbook(SpellbookWindowPage page)
|
||||
{
|
||||
bool visible = Host.IsWindowVisible(WindowNames.Spellbook);
|
||||
|
|
@ -1853,7 +2078,10 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
StackSplitQuantity,
|
||||
handler => b.Objects.ObjectUpdated += handler,
|
||||
handler => b.Objects.ObjectUpdated -= handler,
|
||||
b.IsVendorSplitExempt);
|
||||
b.IsVendorSplitExempt,
|
||||
isCoinstack: guid => b.Objects.Get(guid)?.WeenieClassId == 273u,
|
||||
coinTotal: () => b.Objects.Get(b.PlayerGuid())?.Properties.GetInt(
|
||||
(uint)PropertyInt.CoinValue) ?? 0);
|
||||
|
||||
UiElement root = layout.Root;
|
||||
RetailWindowHandle handle = RetailWindowFrame.Mount(
|
||||
|
|
@ -3072,12 +3300,88 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
|
||||
string unmappedPath = UnmappedKeyBindingsPath(keyboard.KeyBindingsFilePath);
|
||||
var unmapped = RetailUnmappedKeyBindings.LoadOrEmpty(unmappedPath);
|
||||
var keymaps = new RetailKeymapProfileStore(keyboard.KeyBindingsFilePath);
|
||||
|
||||
// ID_KeyMapCantOverwriteReadOnlyKeymap_Label — table 0x23000004, byte-
|
||||
// verified 2026-08-11 (live probe): "Could not overwrite ". Falls back
|
||||
// to silence (no invented English) if the DAT string is ever missing.
|
||||
string? refusalText = strings.Resolve(
|
||||
0x23000004u, DatStringResolver.ComputeHash("ID_KeyMapCantOverwriteReadOnlyKeymap_Label"));
|
||||
string? ResolveKeymapTemplate(string key, string fileName)
|
||||
{
|
||||
// The localized templates use one named filename variable. Keep
|
||||
// the common retail spellings populated; ResolveTemplate selects
|
||||
// only the hash actually authored by the DAT entry.
|
||||
var variables = new Dictionary<uint, string>
|
||||
{
|
||||
[DatStringResolver.ComputeHash("LABEL")] = fileName,
|
||||
[DatStringResolver.ComputeHash("KEYMAP")] = fileName,
|
||||
[DatStringResolver.ComputeHash("FILENAME")] = fileName,
|
||||
[DatStringResolver.ComputeHash("NAME")] = fileName,
|
||||
[DatStringResolver.ComputeHash("VALUE")] = fileName,
|
||||
};
|
||||
lock (_bindings.Assets.DatLock)
|
||||
return strings.ResolveTemplate(0x23000004u, key, variables);
|
||||
}
|
||||
|
||||
void ShowKeymapMessage(string? message)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(message) && DialogFactory is not null)
|
||||
DialogFactory.MakeMessage(message, queueKey: 0x10000001u, priority: true);
|
||||
}
|
||||
|
||||
void SaveMirrors()
|
||||
{
|
||||
dispatcher.Bindings.SaveToFile(keyboard.KeyBindingsFilePath);
|
||||
unmapped.SaveToFile(unmappedPath);
|
||||
}
|
||||
|
||||
void HandleSaveResult(
|
||||
RetailKeymapSaveResult result,
|
||||
string requestedName,
|
||||
Action onSaved)
|
||||
{
|
||||
switch (result.Status)
|
||||
{
|
||||
case RetailKeymapSaveStatus.Saved:
|
||||
try
|
||||
{
|
||||
SaveMirrors();
|
||||
}
|
||||
catch (Exception failure)
|
||||
{
|
||||
Console.WriteLine($"keyboard config: JSON mirror save failed: {failure.Message}");
|
||||
}
|
||||
// The retail .keymap is the canonical save. A failure in
|
||||
// acdream's compatibility JSON mirror must not leave the
|
||||
// authored filename label showing the previous profile.
|
||||
onSaved();
|
||||
return;
|
||||
|
||||
case RetailKeymapSaveStatus.Exists:
|
||||
string? overwrite = ResolveKeymapTemplate(
|
||||
"ID_KeyMapOverwriteKeymap_Label", result.FileName);
|
||||
if (overwrite is null || DialogFactory is null) return;
|
||||
DialogFactory.MakeConfirmation(
|
||||
overwrite,
|
||||
data =>
|
||||
{
|
||||
if (!data.GetBoolean(RetailDialogProperty.ConfirmationResult)) return;
|
||||
HandleSaveResult(
|
||||
keymaps.Save(requestedName, dispatcher.Bindings, overwrite: true),
|
||||
requestedName,
|
||||
onSaved);
|
||||
},
|
||||
queueKey: 0x10000001u,
|
||||
priority: true);
|
||||
return;
|
||||
|
||||
case RetailKeymapSaveStatus.ReadOnly:
|
||||
ShowKeymapMessage(ResolveKeymapTemplate(
|
||||
"ID_KeyMapCantOverwriteReadOnlyKeymap_Label", result.FileName));
|
||||
return;
|
||||
|
||||
default:
|
||||
Console.WriteLine(
|
||||
$"keyboard config: keymap save failed ({result.Status}): {result.Error}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Layout.KeyboardConfigController? controller = Layout.KeyboardConfigController.Bind(
|
||||
layout,
|
||||
|
|
@ -3120,15 +3424,12 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
chord => onResult(chord == default ? null : chord)),
|
||||
Save: () =>
|
||||
{
|
||||
// S3 (2026-08-11 review): match the existing keybinds.json
|
||||
// writer's own discipline (RuntimeKeyBindingTarget.Apply) —
|
||||
// an IO failure is reported, not thrown out of UiButton.OnClick
|
||||
// into the input/render loop, and does not roll back the
|
||||
// already-accepted live binding.
|
||||
try
|
||||
{
|
||||
dispatcher.Bindings.SaveToFile(keyboard.KeyBindingsFilePath);
|
||||
unmapped.SaveToFile(unmappedPath);
|
||||
HandleSaveResult(
|
||||
keymaps.SaveActive(dispatcher.Bindings),
|
||||
keymaps.CurrentFileName,
|
||||
static () => { });
|
||||
}
|
||||
catch (Exception failure)
|
||||
{
|
||||
|
|
@ -3136,11 +3437,23 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
}
|
||||
},
|
||||
Toggle: () => ToggleWindow(WindowNames.KeyboardConfig),
|
||||
DisplaySystemMessage: text =>
|
||||
ResolveTemplate: (key, variables) =>
|
||||
{
|
||||
if (!string.IsNullOrEmpty(text)) _bindings.Options.DisplaySystemMessage(text);
|
||||
lock (_bindings.Assets.DatLock)
|
||||
{
|
||||
return strings.ResolveTemplate(0x23000004u, key, variables);
|
||||
}
|
||||
},
|
||||
// UIOption_ActionKeyMap::OpenCantOverwriteBindingDialog
|
||||
// @0x00489300: type 3, keyboard queue 0x10000001, priority.
|
||||
ShowMessage: message =>
|
||||
{
|
||||
if (DialogFactory is null) return;
|
||||
DialogFactory.MakeMessage(
|
||||
message,
|
||||
queueKey: 0x10000001u,
|
||||
priority: true);
|
||||
},
|
||||
NonBindableRefusalText: refusalText ?? string.Empty,
|
||||
// M3 (2026-08-11 review): retail's OpenOverwriteBindingDialog —
|
||||
// confirm through the SAME RetailDialogFactory/MakeConfirmation
|
||||
// seam GameplayConfirmationController already uses, before
|
||||
|
|
@ -3153,7 +3466,9 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
if (DialogFactory is null) { onResult(false); return; }
|
||||
DialogFactory.MakeConfirmation(
|
||||
message,
|
||||
data => onResult(data.GetBoolean(RetailDialogProperty.ConfirmationResult)));
|
||||
data => onResult(data.GetBoolean(RetailDialogProperty.ConfirmationResult)),
|
||||
queueKey: 0x10000001u,
|
||||
priority: true);
|
||||
},
|
||||
// OP8 re-gate (2026-08-14): retail's capture-instruction dialog
|
||||
// (InitiateBinding @ 0x004899D0 → OpenMapWarnDialog @ 0x00488A00):
|
||||
|
|
@ -3179,7 +3494,10 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
// `text` arrives with real line breaks.
|
||||
try
|
||||
{
|
||||
return DialogFactory.MakeWait(text, queueKey: 0x10000001u);
|
||||
return DialogFactory.MakeWait(
|
||||
text,
|
||||
queueKey: 0x10000001u,
|
||||
priority: true);
|
||||
}
|
||||
catch (Exception failure)
|
||||
{
|
||||
|
|
@ -3196,7 +3514,64 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
}
|
||||
},
|
||||
CloseCaptureInstructions: context =>
|
||||
DialogFactory?.CloseDialog(context)),
|
||||
DialogFactory?.CloseDialog(context),
|
||||
CurrentKeymapFilename: () => keymaps.CurrentFileName,
|
||||
OpenLoadKeymap: onLoaded =>
|
||||
{
|
||||
if (DialogFactory is null) return;
|
||||
IReadOnlyList<string> files = keymaps.ListFiles();
|
||||
int selected = files
|
||||
.Select(static (name, index) => (name, index))
|
||||
.FirstOrDefault(
|
||||
pair => string.Equals(
|
||||
pair.name,
|
||||
keymaps.CurrentFileName,
|
||||
StringComparison.OrdinalIgnoreCase),
|
||||
(name: string.Empty, index: 0)).index;
|
||||
DialogFactory.MakeConfirmationMenu(
|
||||
files,
|
||||
selected,
|
||||
data =>
|
||||
{
|
||||
int choice = data.GetInt32(RetailDialogProperty.MenuSelection, -1);
|
||||
if (choice < 0 || choice >= files.Count) return;
|
||||
if (!keymaps.TryLoad(
|
||||
files[choice],
|
||||
dispatcher.Bindings,
|
||||
out KeyBindings loaded,
|
||||
out string? error))
|
||||
{
|
||||
Console.WriteLine($"keyboard config: keymap load failed: {error}");
|
||||
return;
|
||||
}
|
||||
dispatcher.SetBindings(loaded);
|
||||
try { SaveMirrors(); }
|
||||
catch (Exception failure)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"keyboard config: loaded profile JSON mirror failed: {failure.Message}");
|
||||
}
|
||||
onLoaded();
|
||||
},
|
||||
queueKey: 0x10000001u);
|
||||
},
|
||||
OpenSaveKeymap: onSaved =>
|
||||
{
|
||||
if (DialogFactory is null) return;
|
||||
DialogFactory.MakeConfirmationTextInput(
|
||||
string.Empty,
|
||||
data =>
|
||||
{
|
||||
string name = data.GetString(RetailDialogProperty.TextInputResult)
|
||||
?? string.Empty;
|
||||
if (name.Length == 0) return;
|
||||
HandleSaveResult(
|
||||
keymaps.Save(name, dispatcher.Bindings, overwrite: false),
|
||||
name,
|
||||
onSaved);
|
||||
},
|
||||
queueKey: 0x10000001u);
|
||||
}),
|
||||
resolveTemplateFont: (templateLayoutId, templateElementId) =>
|
||||
{
|
||||
lock (_bindings.Assets.DatLock)
|
||||
|
|
@ -4073,7 +4448,7 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
lock (_bindings.Assets.DatLock)
|
||||
return RetailDataIdResolver.Resolve(_bindings.Assets.Dats, enumValue, category);
|
||||
}
|
||||
Action refreshRows = CharacterStatController.Bind(
|
||||
_characterStatBinding = CharacterStatController.Bind(
|
||||
layout,
|
||||
() => currentSheet,
|
||||
_bindings.Assets.DefaultFont,
|
||||
|
|
@ -4090,7 +4465,7 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
_characterSheetSubscription = provider.SubscribeChanged(() =>
|
||||
{
|
||||
currentSheet = provider.BuildSheet();
|
||||
refreshRows();
|
||||
_characterStatBinding?.Refresh();
|
||||
});
|
||||
|
||||
// CT3 (2026-08-24): the Titles page's row template lives in a
|
||||
|
|
|
|||
|
|
@ -184,6 +184,12 @@ public sealed class UiRoot : UiElement
|
|||
/// <summary>Widget currently receiving keyboard events.</summary>
|
||||
public UiElement? KeyboardFocus { get; private set; }
|
||||
|
||||
// The dispatcher is attached before retained UI. A semantic binding can
|
||||
// therefore focus chat before this tree receives the same native key.
|
||||
// Suppress that exact key through KeyChar/KeyUp so it cannot immediately
|
||||
// submit the newly-focused field or insert a rebound printable key.
|
||||
private int? _suppressedPhysicalKey;
|
||||
|
||||
/// <summary>The edit control activated by Tab/Enter when nothing is focused — retail's
|
||||
/// chat input "write mode" toggle. Set by the host once the chat window is built.</summary>
|
||||
public UiElement? DefaultTextInput { get; set; }
|
||||
|
|
@ -497,7 +503,18 @@ public sealed class UiRoot : UiElement
|
|||
|
||||
internal void OnSubtreeRemoving(UiElement subtree)
|
||||
{
|
||||
ClearSubtreeOwnership(subtree);
|
||||
// Inventory/external-container lists rebuild procedurally when an
|
||||
// authoritative object update arrives. That rebuild removes each old
|
||||
// UIItem before adding its replacement. Once BeginDrag has promoted
|
||||
// the gesture, however, retail's UIElementManager owns a separate
|
||||
// root-level drag element (StartDragandDrop @ 0x0045E040) and transfers
|
||||
// mouse capture to it; the source list cell is no longer the gesture's
|
||||
// lifetime owner. Our drag ghost is likewise snapshotted/root-owned,
|
||||
// so preserve it when the exact source leaf is replaced mid-drag and
|
||||
// transfer capture to this root. Removing a containing subtree (window
|
||||
// teardown) still cancels normally.
|
||||
bool replacingActiveDragSource = ReferenceEquals(subtree, DragSource);
|
||||
ClearSubtreeOwnership(subtree, preserveDetachedDrag: replacingActiveDragSource);
|
||||
WindowManager.OnSubtreeRemoving(subtree);
|
||||
}
|
||||
|
||||
|
|
@ -511,13 +528,16 @@ public sealed class UiRoot : UiElement
|
|||
internal void OnElementVisibilityChanged(UiElement element, bool visible)
|
||||
=> ElementVisibilityChanged?.Invoke(element, visible);
|
||||
|
||||
internal void ClearSubtreeOwnership(UiElement subtree)
|
||||
internal void ClearSubtreeOwnership(UiElement subtree, bool preserveDetachedDrag = false)
|
||||
{
|
||||
if (IsWithinSubtree(KeyboardFocus, subtree))
|
||||
SetKeyboardFocus(null);
|
||||
if (IsWithinSubtree(Captured, subtree))
|
||||
{
|
||||
ReleaseCapture();
|
||||
if (preserveDetachedDrag && ReferenceEquals(Captured, DragSource))
|
||||
SetCapture(this);
|
||||
else
|
||||
ReleaseCapture();
|
||||
_dragCandidate = false;
|
||||
}
|
||||
if (IsWithinSubtree(DefaultTextInput, subtree))
|
||||
|
|
@ -527,10 +547,13 @@ public sealed class UiRoot : UiElement
|
|||
if (IsWithinSubtree(DragSource, subtree))
|
||||
{
|
||||
DragSource?.SetDragSourceActive(false, DragPayload);
|
||||
DragSource = null;
|
||||
DragPayload = null;
|
||||
_dragGhost = null;
|
||||
_dragCandidate = false;
|
||||
if (!preserveDetachedDrag)
|
||||
{
|
||||
DragSource = null;
|
||||
DragPayload = null;
|
||||
_dragGhost = null;
|
||||
_dragCandidate = false;
|
||||
}
|
||||
}
|
||||
if (IsWithinSubtree(_hoverWidget, subtree))
|
||||
{
|
||||
|
|
@ -1090,13 +1113,15 @@ public sealed class UiRoot : UiElement
|
|||
|
||||
public void OnKeyDown(int vk, uint lparam = 0)
|
||||
{
|
||||
if (_suppressedPhysicalKey == vk)
|
||||
return;
|
||||
|
||||
// Nothing focused yet: Tab or Enter enters "write mode" by focusing the chat
|
||||
// input (retail's chat-activation hotkeys). Consumed so the same press doesn't
|
||||
// also fall through to a game hotkey.
|
||||
if (KeyboardFocus is null && DefaultTextInput is not null
|
||||
&& (vk == (int)Silk.NET.Input.Key.Tab
|
||||
|| vk == (int)Silk.NET.Input.Key.Enter
|
||||
|| vk == (int)Silk.NET.Input.Key.KeypadEnter))
|
||||
|| vk == (int)Silk.NET.Input.Key.Enter))
|
||||
{
|
||||
SetKeyboardFocus(DefaultTextInput);
|
||||
return;
|
||||
|
|
@ -1125,6 +1150,11 @@ public sealed class UiRoot : UiElement
|
|||
|
||||
public void OnKeyUp(int vk, uint lparam = 0)
|
||||
{
|
||||
if (_suppressedPhysicalKey == vk)
|
||||
{
|
||||
_suppressedPhysicalKey = null;
|
||||
return;
|
||||
}
|
||||
if (KeyboardFocus is not null)
|
||||
{
|
||||
var e = new UiEvent(KeyboardFocus.EventId, KeyboardFocus, UiEventType.KeyUp,
|
||||
|
|
@ -1136,12 +1166,18 @@ public sealed class UiRoot : UiElement
|
|||
|
||||
public void OnChar(int codepoint)
|
||||
{
|
||||
if (_suppressedPhysicalKey is not null)
|
||||
return;
|
||||
if (KeyboardFocus is null || !KeyboardFocus.IsEditControl) return;
|
||||
var e = new UiEvent(KeyboardFocus.EventId, KeyboardFocus, UiEventType.Char,
|
||||
Data0: codepoint);
|
||||
BubbleEvent(KeyboardFocus, in e);
|
||||
}
|
||||
|
||||
/// <summary>Suppress the raw retained-UI tail of a semantic key action.</summary>
|
||||
public void SuppressPhysicalKeyUntilRelease(Silk.NET.Input.Key key)
|
||||
=> _suppressedPhysicalKey = (int)key;
|
||||
|
||||
// ── Focus + capture ─────────────────────────────────────────────────
|
||||
|
||||
public void SetKeyboardFocus(UiElement? e)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue