feat(mosstank): add VTank-style automation PoC
This commit is contained in:
parent
f6fe0f2a4f
commit
4e6e9bc9d9
212 changed files with 49462 additions and 416 deletions
|
|
@ -112,13 +112,17 @@
|
|||
AfterTargets="Build"
|
||||
Condition="'$(IsCrossTargetingBuild)' != 'true'">
|
||||
<PropertyGroup>
|
||||
<_SmokePluginSourceDir>$(MSBuildProjectDirectory)/../AcDream.Plugins.Smoke/bin/$(Configuration)/$(TargetFramework)</_SmokePluginSourceDir>
|
||||
<_SmokePluginSourceDir Condition="'$(RuntimeIdentifier)' != ''">$(_SmokePluginSourceDir)/$(RuntimeIdentifier)</_SmokePluginSourceDir>
|
||||
<_SmokePluginDestDir>$(OutputPath)plugins/AcDream.Plugins.Smoke</_SmokePluginDestDir>
|
||||
</PropertyGroup>
|
||||
<MSBuild
|
||||
Projects="$(MSBuildProjectDirectory)/../AcDream.Plugins.Smoke/AcDream.Plugins.Smoke.csproj"
|
||||
Targets="GetTargetPath"
|
||||
Properties="Configuration=$(Configuration);TargetFramework=$(TargetFramework);RuntimeIdentifier=$(RuntimeIdentifier);OutputPath=$(OutputPath)">
|
||||
<Output TaskParameter="TargetOutputs" ItemName="_SmokePluginBuildTarget" />
|
||||
</MSBuild>
|
||||
<MakeDir Directories="$(_SmokePluginDestDir)" />
|
||||
<Copy
|
||||
SourceFiles="$(_SmokePluginSourceDir)/AcDream.Plugins.Smoke.dll"
|
||||
SourceFiles="@(_SmokePluginBuildTarget)"
|
||||
DestinationFolder="$(_SmokePluginDestDir)"
|
||||
SkipUnchangedFiles="true" />
|
||||
<WriteLinesToFile
|
||||
|
|
@ -132,13 +136,17 @@
|
|||
AfterTargets="Publish"
|
||||
Condition="'$(IsCrossTargetingBuild)' != 'true'">
|
||||
<PropertyGroup>
|
||||
<_SmokePluginPublishSourceDir>$(MSBuildProjectDirectory)/../AcDream.Plugins.Smoke/bin/$(Configuration)/$(TargetFramework)</_SmokePluginPublishSourceDir>
|
||||
<_SmokePluginPublishSourceDir Condition="'$(RuntimeIdentifier)' != ''">$(_SmokePluginPublishSourceDir)/$(RuntimeIdentifier)</_SmokePluginPublishSourceDir>
|
||||
<_SmokePluginPublishDestDir>$(PublishDir)plugins/AcDream.Plugins.Smoke</_SmokePluginPublishDestDir>
|
||||
</PropertyGroup>
|
||||
<MSBuild
|
||||
Projects="$(MSBuildProjectDirectory)/../AcDream.Plugins.Smoke/AcDream.Plugins.Smoke.csproj"
|
||||
Targets="GetTargetPath"
|
||||
Properties="Configuration=$(Configuration);TargetFramework=$(TargetFramework);RuntimeIdentifier=$(RuntimeIdentifier);OutputPath=$(OutputPath)">
|
||||
<Output TaskParameter="TargetOutputs" ItemName="_SmokePluginPublishTarget" />
|
||||
</MSBuild>
|
||||
<MakeDir Directories="$(_SmokePluginPublishDestDir)" />
|
||||
<Copy
|
||||
SourceFiles="$(_SmokePluginPublishSourceDir)/AcDream.Plugins.Smoke.dll"
|
||||
SourceFiles="@(_SmokePluginPublishTarget)"
|
||||
DestinationFolder="$(_SmokePluginPublishDestDir)"
|
||||
SkipUnchangedFiles="true" />
|
||||
<WriteLinesToFile
|
||||
|
|
@ -161,13 +169,17 @@
|
|||
AfterTargets="Build"
|
||||
Condition="'$(IsCrossTargetingBuild)' != 'true'">
|
||||
<PropertyGroup>
|
||||
<_MossTankSourceDir>$(MSBuildProjectDirectory)/../AcDream.Plugins.MossTank/bin/$(Configuration)/$(TargetFramework)</_MossTankSourceDir>
|
||||
<_MossTankSourceDir Condition="'$(RuntimeIdentifier)' != ''">$(_MossTankSourceDir)/$(RuntimeIdentifier)</_MossTankSourceDir>
|
||||
<_MossTankDestDir>$(OutputPath)plugins/AcDream.Plugins.MossTank</_MossTankDestDir>
|
||||
</PropertyGroup>
|
||||
<MSBuild
|
||||
Projects="$(MSBuildProjectDirectory)/../AcDream.Plugins.MossTank/AcDream.Plugins.MossTank.csproj"
|
||||
Targets="GetTargetPath"
|
||||
Properties="Configuration=$(Configuration);TargetFramework=$(TargetFramework);RuntimeIdentifier=$(RuntimeIdentifier);OutputPath=$(OutputPath)">
|
||||
<Output TaskParameter="TargetOutputs" ItemName="_MossTankPluginBuildTarget" />
|
||||
</MSBuild>
|
||||
<MakeDir Directories="$(_MossTankDestDir)" />
|
||||
<Copy
|
||||
SourceFiles="$(_MossTankSourceDir)/AcDream.Plugins.MossTank.dll;$(_MossTankSourceDir)/mosstank.xml;$(_MossTankSourceDir)/mosstank-settings.xml"
|
||||
SourceFiles="@(_MossTankPluginBuildTarget);$(MSBuildProjectDirectory)/../AcDream.Plugins.MossTank/mosstank.xml"
|
||||
DestinationFolder="$(_MossTankDestDir)"
|
||||
SkipUnchangedFiles="true" />
|
||||
<WriteLinesToFile
|
||||
|
|
@ -181,13 +193,17 @@
|
|||
AfterTargets="Publish"
|
||||
Condition="'$(IsCrossTargetingBuild)' != 'true'">
|
||||
<PropertyGroup>
|
||||
<_MossTankPublishSourceDir>$(MSBuildProjectDirectory)/../AcDream.Plugins.MossTank/bin/$(Configuration)/$(TargetFramework)</_MossTankPublishSourceDir>
|
||||
<_MossTankPublishSourceDir Condition="'$(RuntimeIdentifier)' != ''">$(_MossTankPublishSourceDir)/$(RuntimeIdentifier)</_MossTankPublishSourceDir>
|
||||
<_MossTankPublishDestDir>$(PublishDir)plugins/AcDream.Plugins.MossTank</_MossTankPublishDestDir>
|
||||
</PropertyGroup>
|
||||
<MSBuild
|
||||
Projects="$(MSBuildProjectDirectory)/../AcDream.Plugins.MossTank/AcDream.Plugins.MossTank.csproj"
|
||||
Targets="GetTargetPath"
|
||||
Properties="Configuration=$(Configuration);TargetFramework=$(TargetFramework);RuntimeIdentifier=$(RuntimeIdentifier);OutputPath=$(OutputPath)">
|
||||
<Output TaskParameter="TargetOutputs" ItemName="_MossTankPluginPublishTarget" />
|
||||
</MSBuild>
|
||||
<MakeDir Directories="$(_MossTankPublishDestDir)" />
|
||||
<Copy
|
||||
SourceFiles="$(_MossTankPublishSourceDir)/AcDream.Plugins.MossTank.dll;$(_MossTankPublishSourceDir)/mosstank.xml;$(_MossTankPublishSourceDir)/mosstank-settings.xml"
|
||||
SourceFiles="@(_MossTankPluginPublishTarget);$(MSBuildProjectDirectory)/../AcDream.Plugins.MossTank/mosstank.xml"
|
||||
DestinationFolder="$(_MossTankPublishDestDir)"
|
||||
SkipUnchangedFiles="true" />
|
||||
<WriteLinesToFile
|
||||
|
|
|
|||
|
|
@ -97,7 +97,8 @@ internal sealed record InteractionRetainedUiDependencies(
|
|||
AcDream.App.Rendering.Packs.RenderPackCatalogSource? RenderPackCatalog = null,
|
||||
Func<AcDream.App.Rendering.Packs.RenderPackDiagnosticsSnapshot>?
|
||||
RenderPackDiagnostics = null,
|
||||
string? ScreenshotsDirectory = null)
|
||||
string? ScreenshotsDirectory = null,
|
||||
AppAutomationSurface? Automation = null)
|
||||
{
|
||||
public RuntimeActionState Actions => Runtime.ActionOwner;
|
||||
|
||||
|
|
@ -429,6 +430,13 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
|
|||
return false;
|
||||
activeSession.SendSell(vendorGuid, items);
|
||||
return true;
|
||||
},
|
||||
sendSalvage: (toolGuid, itemGuids) =>
|
||||
{
|
||||
if (session.CurrentSession is not { } activeSession || !session.IsInWorld)
|
||||
return false;
|
||||
activeSession.SendSalvage(toolGuid, itemGuids);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -1230,7 +1238,10 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
|
|||
$"Screenshot failed: {error}",
|
||||
RetailLogTextType.ClientLocal);
|
||||
}
|
||||
});
|
||||
},
|
||||
ProjectileDebugSamples: d.Automation is null
|
||||
? null
|
||||
: d.Automation.CaptureProjectileDebugSamples);
|
||||
RetailUiRuntime runtime = lease.Mount(
|
||||
() => RetailUiRuntime.CreateUninitialized(bindings));
|
||||
checkpoint(InteractionRetainedUiCompositionPoint.UiRuntimeMounted);
|
||||
|
|
|
|||
|
|
@ -83,6 +83,7 @@ internal sealed record SessionPlayerDependencies(
|
|||
CombatFeedbackSlot CombatFeedback,
|
||||
TransferableResourceSlot<PortalTunnelPresentation> PortalTunnelFallback,
|
||||
Action<string> Log,
|
||||
Func<string, bool>? TryHandlePluginCommand,
|
||||
/// <summary>Campaign LA slice LA1: the shared per-session status-event
|
||||
/// writer, no-op when <see cref="RuntimeOptions.StatusFilePath"/> was
|
||||
/// not configured.</summary>
|
||||
|
|
@ -112,6 +113,7 @@ internal sealed record SessionPlayerResult(
|
|||
DatSpawnClaimHydrationClassifier SpawnClaimHydration,
|
||||
LiveSessionController LiveSession,
|
||||
LiveEntityHydrationController Hydration,
|
||||
LiveEntityDeletionController Deletion,
|
||||
LiveEntityNetworkUpdateController NetworkUpdates,
|
||||
LiveEntityLivenessController Liveness,
|
||||
LiveEntitySessionController SessionEvents,
|
||||
|
|
@ -357,7 +359,8 @@ internal sealed class SessionPlayerCompositionPhase
|
|||
// LiveSessionCommandSurface has no dependencies of its own, so
|
||||
// hoisting its construction is inert; the later site now reuses
|
||||
// this instance instead of constructing a second one.
|
||||
var liveSessionCommands = new LiveSessionCommandSurface();
|
||||
var liveSessionCommands = new LiveSessionCommandSurface(
|
||||
d.TryHandlePluginCommand);
|
||||
var settingsTargets = new RuntimeSettingsTargets(
|
||||
new SilkRuntimeDisplayWindowTarget(d.Window),
|
||||
live.DrawDispatcher,
|
||||
|
|
@ -1332,6 +1335,7 @@ internal sealed class SessionPlayerCompositionPhase
|
|||
spawnClaimClassifier,
|
||||
liveSession,
|
||||
hydration,
|
||||
deletion,
|
||||
networkUpdates,
|
||||
liveness,
|
||||
sessionEvents,
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ internal sealed class DispatcherMovementInputSource : IMovementInputSource
|
|||
return default;
|
||||
|
||||
if (_movement.HasCommandInput)
|
||||
return _movement.CommandInput;
|
||||
return _movement.CommandInput with { IsPersistentCommand = true };
|
||||
|
||||
if (_dispatcher is not { } dispatcher)
|
||||
return default;
|
||||
|
|
|
|||
|
|
@ -36,11 +36,17 @@ internal sealed class LiveSessionAppSource
|
|||
/// retained UI may keep this surface, while the displaced route itself becomes
|
||||
/// inert before inbound subscriptions detach.
|
||||
/// </summary>
|
||||
internal sealed class LiveSessionCommandSurface : ICommandBus
|
||||
internal sealed class LiveSessionCommandSurface : IPluginCommandBus
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
private readonly Func<string, bool>? _tryHandlePluginCommand;
|
||||
private LiveSessionCommandRouter? _active;
|
||||
|
||||
public LiveSessionCommandSurface(Func<string, bool>? tryHandlePluginCommand = null)
|
||||
{
|
||||
_tryHandlePluginCommand = tryHandlePluginCommand;
|
||||
}
|
||||
|
||||
public ILiveSessionCommandRouting Attach(LiveSessionCommandRouter route)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(route);
|
||||
|
|
@ -65,6 +71,9 @@ internal sealed class LiveSessionCommandSurface : ICommandBus
|
|||
route?.Publish(command);
|
||||
}
|
||||
|
||||
public bool TryHandlePluginCommand(string commandLine) =>
|
||||
_tryHandlePluginCommand?.Invoke(commandLine) == true;
|
||||
|
||||
private void Release(LiveSessionCommandRouter expected)
|
||||
{
|
||||
expected.Dispose();
|
||||
|
|
|
|||
|
|
@ -455,6 +455,7 @@ internal sealed class LiveSessionRuntimeFactory
|
|||
OnUseDone: error =>
|
||||
{
|
||||
_domain.Inventory.ExternalContainers.ApplyUseDone(error);
|
||||
_domain.Actions.SpellCast.CompleteUse(error);
|
||||
_domain.Actions.Transactions.CompleteUse(error);
|
||||
},
|
||||
_domain.Inventory.ItemMana,
|
||||
|
|
|
|||
|
|
@ -151,6 +151,13 @@ internal static class GraphicalWindowBackendConfigurator
|
|||
_ => throw new ArgumentOutOfRangeException(
|
||||
nameof(requested)),
|
||||
});
|
||||
// #451: InitHint proves the packaged glfw3.dll is loaded but runs
|
||||
// before glfwInit creates any window or begins polling. This is
|
||||
// the one safe point to narrow GLFW's GetActiveWindow import so a
|
||||
// temporarily joined Win32 input queue cannot hand it another
|
||||
// acdream process's private GLFWwindow pointer.
|
||||
if (platform.OperatingSystem == GraphicalHostOperatingSystem.Windows)
|
||||
Win32GlfwActiveWindowGuard.Install();
|
||||
_glfw = glfw;
|
||||
_configuredProtocol = requested;
|
||||
}
|
||||
|
|
|
|||
282
src/AcDream.App/Platform/Win32GlfwActiveWindowGuard.cs
Normal file
282
src/AcDream.App/Platform/Win32GlfwActiveWindowGuard.cs
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace AcDream.App.Platform;
|
||||
|
||||
/// <summary>
|
||||
/// Prevents GLFW's Win32 modifier-key repair pass from accepting a window
|
||||
/// owned by another process.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// GLFW 3.4's <c>_glfwPollEventsWin32</c> calls <c>GetActiveWindow</c>, then
|
||||
/// reads that HWND's process-global <c>GLFW</c> property and dereferences the
|
||||
/// result as a local <c>_GLFWwindow*</c>. Normally <c>GetActiveWindow</c> can
|
||||
/// only return a window from this thread's input queue. Windows automation,
|
||||
/// accessibility software, and some multi-box window managers temporarily
|
||||
/// join input queues, however, allowing it to return another acdream process's
|
||||
/// window. Every GLFW process uses the same property name, so <c>GetPropW</c>
|
||||
/// then succeeds but returns a pointer meaningful only in the other process.
|
||||
/// The next modifier-key read is an access violation (#451).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Patch only GLFW's import-address-table entry for <c>GetActiveWindow</c>.
|
||||
/// The replacement returns the real active HWND when it belongs to this
|
||||
/// process and zero otherwise. Zero is GLFW's existing, intentional
|
||||
/// "nothing to repair" path. No process-global Win32 hook is installed and
|
||||
/// no other module's User32 calls are changed.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static unsafe class Win32GlfwActiveWindowGuard
|
||||
{
|
||||
private const string GlfwModuleName = "glfw3.dll";
|
||||
private const string User32ModuleName = "USER32.dll";
|
||||
private const string GetActiveWindowImport = "GetActiveWindow";
|
||||
private const uint PageReadWrite = 0x04;
|
||||
private const ushort DosSignature = 0x5A4D;
|
||||
private const uint PeSignature = 0x00004550;
|
||||
private const ushort Pe32Magic = 0x010B;
|
||||
private const ushort Pe32PlusMagic = 0x020B;
|
||||
private const int ImportDescriptorSize = 20;
|
||||
|
||||
private static readonly uint CurrentProcessId =
|
||||
checked((uint)Environment.ProcessId);
|
||||
private static int _installState;
|
||||
|
||||
internal static bool IsInstalled => Volatile.Read(ref _installState) == 1;
|
||||
|
||||
internal static void Install()
|
||||
{
|
||||
if (!OperatingSystem.IsWindows()
|
||||
|| Interlocked.CompareExchange(ref _installState, 2, 0) != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
nint module = GetModuleHandleW(GlfwModuleName);
|
||||
if (module == 0
|
||||
|| !TryFindImportSlot(
|
||||
module,
|
||||
User32ModuleName,
|
||||
GetActiveWindowImport,
|
||||
out nint slot))
|
||||
{
|
||||
Volatile.Write(ref _installState, -1);
|
||||
Console.Error.WriteLine(
|
||||
"windowing: could not install the GLFW foreign-active-window guard");
|
||||
return;
|
||||
}
|
||||
|
||||
nint replacement = (nint)(delegate* unmanaged[Stdcall]<nint>)
|
||||
&GetCurrentProcessActiveWindow;
|
||||
if (!VirtualProtect(
|
||||
slot,
|
||||
checked((nuint)IntPtr.Size),
|
||||
PageReadWrite,
|
||||
out uint oldProtection))
|
||||
{
|
||||
Volatile.Write(ref _installState, -1);
|
||||
Console.Error.WriteLine(
|
||||
"windowing: GLFW active-window import was not writable");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
*(nint*)slot = replacement;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_ = VirtualProtect(
|
||||
slot,
|
||||
checked((nuint)IntPtr.Size),
|
||||
oldProtection,
|
||||
out _);
|
||||
}
|
||||
|
||||
Volatile.Write(ref _installState, 1);
|
||||
Console.WriteLine(
|
||||
"windowing: GLFW foreign-active-window guard installed (#451).");
|
||||
}
|
||||
catch (Exception failure)
|
||||
{
|
||||
Volatile.Write(ref _installState, -1);
|
||||
Console.Error.WriteLine(
|
||||
$"windowing: GLFW active-window guard failed: {failure.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
[UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])]
|
||||
private static nint GetCurrentProcessActiveWindow()
|
||||
{
|
||||
nint window = GetActiveWindow();
|
||||
if (window == 0)
|
||||
return 0;
|
||||
|
||||
_ = GetWindowThreadProcessId(window, out uint ownerProcessId);
|
||||
return AcceptWindow(window, ownerProcessId, CurrentProcessId);
|
||||
}
|
||||
|
||||
internal static nint AcceptWindow(
|
||||
nint window,
|
||||
uint ownerProcessId,
|
||||
uint currentProcessId) =>
|
||||
window != 0
|
||||
&& ownerProcessId != 0
|
||||
&& ownerProcessId == currentProcessId
|
||||
? window
|
||||
: 0;
|
||||
|
||||
private static bool TryFindImportSlot(
|
||||
nint module,
|
||||
string importedModule,
|
||||
string importedFunction,
|
||||
out nint slot)
|
||||
{
|
||||
slot = 0;
|
||||
byte* image = (byte*)module;
|
||||
if (*(ushort*)image != DosSignature)
|
||||
return false;
|
||||
|
||||
int peOffset = *(int*)(image + 0x3C);
|
||||
if (peOffset <= 0 || *(uint*)(image + peOffset) != PeSignature)
|
||||
return false;
|
||||
|
||||
byte* optionalHeader = image + peOffset + 24;
|
||||
ushort magic = *(ushort*)optionalHeader;
|
||||
int dataDirectoryOffset;
|
||||
int thunkSize;
|
||||
ulong ordinalFlag;
|
||||
if (magic == Pe32PlusMagic)
|
||||
{
|
||||
dataDirectoryOffset = 112;
|
||||
thunkSize = 8;
|
||||
ordinalFlag = 0x8000000000000000UL;
|
||||
}
|
||||
else if (magic == Pe32Magic)
|
||||
{
|
||||
dataDirectoryOffset = 96;
|
||||
thunkSize = 4;
|
||||
ordinalFlag = 0x80000000UL;
|
||||
}
|
||||
else
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
uint sizeOfImage = *(uint*)(optionalHeader + 56);
|
||||
uint importRva = *(uint*)(optionalHeader + dataDirectoryOffset + 8);
|
||||
uint importSize = *(uint*)(optionalHeader + dataDirectoryOffset + 12);
|
||||
if (!Contains(sizeOfImage, importRva, ImportDescriptorSize))
|
||||
return false;
|
||||
|
||||
int descriptorLimit = importSize >= ImportDescriptorSize
|
||||
? checked((int)(importSize / ImportDescriptorSize))
|
||||
: checked((int)((sizeOfImage - importRva) / ImportDescriptorSize));
|
||||
for (int descriptorIndex = 0;
|
||||
descriptorIndex < descriptorLimit;
|
||||
descriptorIndex++)
|
||||
{
|
||||
byte* descriptor = image
|
||||
+ importRva
|
||||
+ descriptorIndex * ImportDescriptorSize;
|
||||
uint originalFirstThunk = *(uint*)descriptor;
|
||||
uint nameRva = *(uint*)(descriptor + 12);
|
||||
uint firstThunk = *(uint*)(descriptor + 16);
|
||||
if (originalFirstThunk == 0 && nameRva == 0 && firstThunk == 0)
|
||||
break;
|
||||
if (!MatchesAsciiZ(image, sizeOfImage, nameRva, importedModule, true))
|
||||
continue;
|
||||
if (originalFirstThunk == 0
|
||||
|| !Contains(sizeOfImage, originalFirstThunk, thunkSize)
|
||||
|| !Contains(sizeOfImage, firstThunk, thunkSize))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
int thunkLimit = checked((int)Math.Min(
|
||||
(sizeOfImage - originalFirstThunk) / (uint)thunkSize,
|
||||
(sizeOfImage - firstThunk) / (uint)thunkSize));
|
||||
for (int thunkIndex = 0; thunkIndex < thunkLimit; thunkIndex++)
|
||||
{
|
||||
ulong nameThunk = thunkSize == 8
|
||||
? *(ulong*)(image + originalFirstThunk + thunkIndex * thunkSize)
|
||||
: *(uint*)(image + originalFirstThunk + thunkIndex * thunkSize);
|
||||
if (nameThunk == 0)
|
||||
break;
|
||||
if ((nameThunk & ordinalFlag) != 0)
|
||||
continue;
|
||||
|
||||
uint importByNameRva = checked((uint)nameThunk);
|
||||
if (!Contains(sizeOfImage, importByNameRva, 3)
|
||||
|| !MatchesAsciiZ(
|
||||
image,
|
||||
sizeOfImage,
|
||||
importByNameRva + 2,
|
||||
importedFunction,
|
||||
false))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
slot = (nint)(image + firstThunk + thunkIndex * thunkSize);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool Contains(uint imageSize, uint offset, int length) =>
|
||||
length >= 0
|
||||
&& offset < imageSize
|
||||
&& (ulong)offset + (uint)length <= imageSize;
|
||||
|
||||
private static bool MatchesAsciiZ(
|
||||
byte* image,
|
||||
uint imageSize,
|
||||
uint offset,
|
||||
string expected,
|
||||
bool ignoreCase)
|
||||
{
|
||||
if (!Contains(imageSize, offset, expected.Length + 1))
|
||||
return false;
|
||||
|
||||
for (int i = 0; i < expected.Length; i++)
|
||||
{
|
||||
char actual = (char)image[offset + (uint)i];
|
||||
char wanted = expected[i];
|
||||
if (ignoreCase)
|
||||
{
|
||||
actual = char.ToUpperInvariant(actual);
|
||||
wanted = char.ToUpperInvariant(wanted);
|
||||
}
|
||||
if (actual != wanted)
|
||||
return false;
|
||||
}
|
||||
return image[offset + (uint)expected.Length] == 0;
|
||||
}
|
||||
|
||||
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
||||
private static extern nint GetModuleHandleW(string moduleName);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool VirtualProtect(
|
||||
nint address,
|
||||
nuint size,
|
||||
uint newProtection,
|
||||
out uint oldProtection);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern nint GetActiveWindow();
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern uint GetWindowThreadProcessId(
|
||||
nint window,
|
||||
out uint processId);
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -10,7 +10,10 @@ public sealed class AppPluginHost : IPluginHost
|
|||
IEvents events,
|
||||
ISelectionService selection,
|
||||
IUiRegistry ui,
|
||||
IAutomationSurface automation)
|
||||
IAutomationSurface automation,
|
||||
IPluginStorage? storage = null,
|
||||
IPluginCommandRegistry? commands = null,
|
||||
IPluginLootClassifierRegistry? lootClassifiers = null)
|
||||
{
|
||||
Log = log;
|
||||
State = state;
|
||||
|
|
@ -18,6 +21,10 @@ public sealed class AppPluginHost : IPluginHost
|
|||
Selection = selection;
|
||||
Ui = ui;
|
||||
Automation = automation;
|
||||
Storage = storage ?? NoOpPluginStorage.Instance;
|
||||
Commands = commands ?? NoOpPluginCommandRegistry.Instance;
|
||||
LootClassifiers = lootClassifiers
|
||||
?? NoOpPluginLootClassifierRegistry.Instance;
|
||||
}
|
||||
|
||||
public bool HasUi => true;
|
||||
|
|
@ -27,4 +34,7 @@ public sealed class AppPluginHost : IPluginHost
|
|||
public ISelectionService Selection { get; }
|
||||
public IUiRegistry Ui { get; }
|
||||
public IAutomationSurface Automation { get; }
|
||||
public IPluginStorage Storage { get; }
|
||||
public IPluginCommandRegistry Commands { get; }
|
||||
public IPluginLootClassifierRegistry LootClassifiers { get; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,18 +11,36 @@ namespace AcDream.App.Plugins;
|
|||
/// </summary>
|
||||
public sealed class BufferedUiRegistry : IScopedUiRegistry
|
||||
{
|
||||
public readonly record struct Pending(string MarkupPath, object Binding)
|
||||
public readonly record struct Pending(
|
||||
PluginUiOwner Owner,
|
||||
PluginPanelDescriptor Descriptor,
|
||||
string MarkupPath,
|
||||
object Binding)
|
||||
{
|
||||
internal long RegistrationId { get; init; }
|
||||
internal string? MarkupContent { get; init; }
|
||||
|
||||
/// <summary>Stable, manifest-scoped retained-window persistence key.</summary>
|
||||
public string WindowName =>
|
||||
$"plugin:{Owner.Id}:{Descriptor.WindowId}";
|
||||
}
|
||||
|
||||
private sealed class Registration(string markupPath, object binding)
|
||||
private sealed class Registration(
|
||||
PluginUiOwner owner,
|
||||
PluginPanelDescriptor descriptor,
|
||||
string markupPath,
|
||||
object binding,
|
||||
string? markupContent = null)
|
||||
{
|
||||
internal PluginUiOwner Owner { get; } = owner;
|
||||
internal PluginPanelDescriptor Descriptor { get; } = descriptor;
|
||||
internal string MarkupPath { get; } = markupPath;
|
||||
internal object Binding { get; } = binding;
|
||||
internal string? MarkupContent { get; } = markupContent;
|
||||
internal bool Drained { get; set; }
|
||||
internal UiRoot? Root { get; set; }
|
||||
internal UiElement? Element { get; set; }
|
||||
internal Action? WindowCleanup { get; set; }
|
||||
}
|
||||
|
||||
private readonly object _gate = new();
|
||||
|
|
@ -32,15 +50,114 @@ public sealed class BufferedUiRegistry : IScopedUiRegistry
|
|||
public void AddMarkupPanel(string markupPath, object binding)
|
||||
=> _ = RegisterMarkupPanel(markupPath, binding);
|
||||
|
||||
public void AddPanel(
|
||||
PluginPanelDescriptor descriptor,
|
||||
string markupPath,
|
||||
object binding)
|
||||
=> _ = RegisterPanel(
|
||||
new PluginUiOwner("unscoped", descriptor.Title),
|
||||
descriptor,
|
||||
markupPath,
|
||||
binding);
|
||||
|
||||
public IDisposable RegisterPanel(
|
||||
PluginPanelDescriptor descriptor,
|
||||
string markupPath,
|
||||
object binding) => RegisterPanel(
|
||||
new PluginUiOwner("unscoped", descriptor.Title),
|
||||
descriptor,
|
||||
markupPath,
|
||||
binding);
|
||||
|
||||
public IDisposable RegisterPanelContent(
|
||||
PluginPanelDescriptor descriptor,
|
||||
string markupContent,
|
||||
object binding) => RegisterPanelContent(
|
||||
new PluginUiOwner("unscoped", descriptor.Title),
|
||||
descriptor,
|
||||
markupContent,
|
||||
binding);
|
||||
|
||||
public bool ViewExists(string viewName) =>
|
||||
ViewExists(new PluginUiOwner("unscoped", "Plugin"), viewName);
|
||||
|
||||
public bool IsViewVisible(string viewName) =>
|
||||
IsViewVisible(new PluginUiOwner("unscoped", "Plugin"), viewName);
|
||||
|
||||
public bool ControlExists(string viewName, string controlName) =>
|
||||
ControlExists(
|
||||
new PluginUiOwner("unscoped", "Plugin"), viewName, controlName);
|
||||
|
||||
public bool SetControlLabel(
|
||||
string viewName,
|
||||
string controlName,
|
||||
string label) => SetControlLabel(
|
||||
new PluginUiOwner("unscoped", "Plugin"), viewName, controlName, label);
|
||||
|
||||
public bool SetControlVisible(
|
||||
string viewName,
|
||||
string controlName,
|
||||
bool visible) => SetControlVisible(
|
||||
new PluginUiOwner("unscoped", "Plugin"), viewName, controlName, visible);
|
||||
|
||||
public IDisposable RegisterMarkupPanel(string markupPath, object binding)
|
||||
=> RegisterPanel(
|
||||
new PluginUiOwner("legacy", "Plugin"),
|
||||
new PluginPanelDescriptor(
|
||||
Path.GetFileNameWithoutExtension(markupPath),
|
||||
Path.GetFileNameWithoutExtension(markupPath)),
|
||||
markupPath,
|
||||
binding);
|
||||
|
||||
public IDisposable RegisterPanel(
|
||||
PluginUiOwner owner,
|
||||
PluginPanelDescriptor descriptor,
|
||||
string markupPath,
|
||||
object binding)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(owner.Id);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(owner.DisplayName);
|
||||
ArgumentNullException.ThrowIfNull(descriptor);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(descriptor.WindowId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(descriptor.Title);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(markupPath);
|
||||
ArgumentNullException.ThrowIfNull(binding);
|
||||
long id;
|
||||
lock (_gate)
|
||||
{
|
||||
id = checked(++_nextRegistrationId);
|
||||
_registrations.Add(id, new Registration(markupPath, binding));
|
||||
_registrations.Add(
|
||||
id,
|
||||
new Registration(owner, descriptor, markupPath, binding));
|
||||
}
|
||||
return new RegistrationToken(this, id);
|
||||
}
|
||||
|
||||
public IDisposable RegisterPanelContent(
|
||||
PluginUiOwner owner,
|
||||
PluginPanelDescriptor descriptor,
|
||||
string markupContent,
|
||||
object binding)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(owner.Id);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(owner.DisplayName);
|
||||
ArgumentNullException.ThrowIfNull(descriptor);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(descriptor.WindowId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(descriptor.Title);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(markupContent);
|
||||
ArgumentNullException.ThrowIfNull(binding);
|
||||
long id;
|
||||
lock (_gate)
|
||||
{
|
||||
id = checked(++_nextRegistrationId);
|
||||
_registrations.Add(
|
||||
id,
|
||||
new Registration(
|
||||
owner,
|
||||
descriptor,
|
||||
$"<inline:{descriptor.WindowId}>",
|
||||
binding,
|
||||
markupContent));
|
||||
}
|
||||
return new RegistrationToken(this, id);
|
||||
}
|
||||
|
|
@ -57,10 +174,13 @@ public sealed class BufferedUiRegistry : IScopedUiRegistry
|
|||
continue;
|
||||
registration.Drained = true;
|
||||
pending.Add(new Pending(
|
||||
registration.Owner,
|
||||
registration.Descriptor,
|
||||
registration.MarkupPath,
|
||||
registration.Binding)
|
||||
{
|
||||
RegistrationId = id,
|
||||
MarkupContent = registration.MarkupContent,
|
||||
});
|
||||
}
|
||||
return pending;
|
||||
|
|
@ -88,6 +208,27 @@ public sealed class BufferedUiRegistry : IScopedUiRegistry
|
|||
root.RemoveChild(element);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Publishes the window-manager half of a mounted registration. Disposal
|
||||
/// may race between retained-tree mount and window registration, so a late
|
||||
/// publication cleans itself up immediately when ownership is already gone.
|
||||
/// </summary>
|
||||
internal void CompleteWindowMount(Pending pending, Action cleanup)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(cleanup);
|
||||
bool stillRegistered;
|
||||
lock (_gate)
|
||||
{
|
||||
stillRegistered = _registrations.TryGetValue(
|
||||
pending.RegistrationId,
|
||||
out Registration? registration);
|
||||
if (stillRegistered)
|
||||
registration!.WindowCleanup = cleanup;
|
||||
}
|
||||
if (!stillRegistered)
|
||||
cleanup();
|
||||
}
|
||||
|
||||
internal void FailMount(Pending pending) => Remove(pending.RegistrationId);
|
||||
|
||||
internal int RegistrationCount
|
||||
|
|
@ -99,18 +240,114 @@ public sealed class BufferedUiRegistry : IScopedUiRegistry
|
|||
}
|
||||
}
|
||||
|
||||
public bool ViewExists(PluginUiOwner owner, string viewName)
|
||||
{
|
||||
lock (_gate)
|
||||
return FindRegistrationLocked(owner, viewName) is not null;
|
||||
}
|
||||
|
||||
public bool IsViewVisible(PluginUiOwner owner, string viewName)
|
||||
{
|
||||
UiElement? view;
|
||||
lock (_gate)
|
||||
view = FindRegistrationLocked(owner, viewName)?.Element;
|
||||
return view?.Visible == true;
|
||||
}
|
||||
|
||||
public bool ControlExists(
|
||||
PluginUiOwner owner,
|
||||
string viewName,
|
||||
string controlName) =>
|
||||
FindControl(owner, viewName, controlName) is not null;
|
||||
|
||||
public bool SetControlLabel(
|
||||
PluginUiOwner owner,
|
||||
string viewName,
|
||||
string controlName,
|
||||
string label)
|
||||
{
|
||||
UiElement? control = FindControl(owner, viewName, controlName);
|
||||
switch (control)
|
||||
{
|
||||
case UiSimpleButton button:
|
||||
button.TextSource = null;
|
||||
button.Text = label;
|
||||
return true;
|
||||
case UiMarkupToggle toggle:
|
||||
toggle.TextSource = null;
|
||||
toggle.Text = label;
|
||||
return true;
|
||||
case UiLabel text:
|
||||
text.TextSource = null;
|
||||
text.Text = label;
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public bool SetControlVisible(
|
||||
PluginUiOwner owner,
|
||||
string viewName,
|
||||
string controlName,
|
||||
bool visible)
|
||||
{
|
||||
UiElement? control = FindControl(owner, viewName, controlName);
|
||||
if (control is null)
|
||||
return false;
|
||||
control.VisibleSource = null;
|
||||
control.Visible = visible;
|
||||
return true;
|
||||
}
|
||||
|
||||
private UiElement? FindControl(
|
||||
PluginUiOwner owner,
|
||||
string viewName,
|
||||
string controlName)
|
||||
{
|
||||
UiElement? view;
|
||||
lock (_gate)
|
||||
view = FindRegistrationLocked(owner, viewName)?.Element;
|
||||
return view is null ? null : FindByName(view, controlName);
|
||||
}
|
||||
|
||||
private Registration? FindRegistrationLocked(
|
||||
PluginUiOwner owner,
|
||||
string viewName) => _registrations.Values.FirstOrDefault(registration =>
|
||||
registration.Owner == owner
|
||||
&& (registration.Descriptor.WindowId.Equals(
|
||||
viewName, StringComparison.Ordinal)
|
||||
|| registration.Descriptor.Title.Equals(
|
||||
viewName, StringComparison.Ordinal)));
|
||||
|
||||
private static UiElement? FindByName(UiElement root, string name)
|
||||
{
|
||||
if (root.Name?.Equals(name, StringComparison.Ordinal) == true)
|
||||
return root;
|
||||
foreach (UiElement child in root.Children)
|
||||
{
|
||||
UiElement? found = FindByName(child, name);
|
||||
if (found is not null)
|
||||
return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void Remove(long id)
|
||||
{
|
||||
UiRoot? root;
|
||||
UiElement? element;
|
||||
Action? windowCleanup;
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_registrations.Remove(id, out Registration? registration))
|
||||
return;
|
||||
root = registration.Root;
|
||||
element = registration.Element;
|
||||
windowCleanup = registration.WindowCleanup;
|
||||
}
|
||||
|
||||
windowCleanup?.Invoke();
|
||||
if (root is not null && element is not null)
|
||||
root.RemoveChild(element);
|
||||
}
|
||||
|
|
|
|||
86
src/AcDream.App/Plugins/FilePluginStorage.cs
Normal file
86
src/AcDream.App/Plugins/FilePluginStorage.cs
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
using System.Text;
|
||||
using AcDream.Plugin.Abstractions;
|
||||
|
||||
namespace AcDream.App.Plugins;
|
||||
|
||||
/// <summary>Crash-safe filesystem implementation behind scoped plugin keys.</summary>
|
||||
internal sealed class FilePluginStorage : IPluginStorage
|
||||
{
|
||||
private readonly string _root;
|
||||
|
||||
internal FilePluginStorage(string root)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(root);
|
||||
_root = Path.GetFullPath(root);
|
||||
}
|
||||
|
||||
public bool IsAvailable => true;
|
||||
|
||||
public string? ReadText(string key)
|
||||
{
|
||||
string path = Resolve(key);
|
||||
return File.Exists(path)
|
||||
? File.ReadAllText(path, Encoding.UTF8)
|
||||
: null;
|
||||
}
|
||||
|
||||
public IReadOnlyList<string> List(string prefix)
|
||||
{
|
||||
string directory = Resolve(prefix);
|
||||
if (!Directory.Exists(directory))
|
||||
return Array.Empty<string>();
|
||||
return Directory.EnumerateFiles(directory, "*", SearchOption.AllDirectories)
|
||||
.Select(path => Path.GetRelativePath(_root, path)
|
||||
.Replace(Path.DirectorySeparatorChar, '/'))
|
||||
.OrderBy(static key => key, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
public void WriteText(string key, string content)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(content);
|
||||
string path = Resolve(key);
|
||||
string directory = Path.GetDirectoryName(path)!;
|
||||
Directory.CreateDirectory(directory);
|
||||
string temporary = Path.Combine(
|
||||
directory,
|
||||
$".{Path.GetFileName(path)}.{Guid.NewGuid():N}.tmp");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(temporary, content, new UTF8Encoding(false));
|
||||
File.Move(temporary, path, overwrite: true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(temporary))
|
||||
File.Delete(temporary);
|
||||
}
|
||||
}
|
||||
|
||||
public bool Delete(string key)
|
||||
{
|
||||
string path = Resolve(key);
|
||||
if (!File.Exists(path))
|
||||
return false;
|
||||
File.Delete(path);
|
||||
return true;
|
||||
}
|
||||
|
||||
private string Resolve(string key)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(key);
|
||||
if (Path.IsPathRooted(key))
|
||||
throw new ArgumentException("Plugin storage keys must be relative.", nameof(key));
|
||||
string path = Path.GetFullPath(Path.Combine(_root, key));
|
||||
string relative = Path.GetRelativePath(_root, path);
|
||||
if (Path.IsPathRooted(relative)
|
||||
|| relative.Equals("..", StringComparison.Ordinal)
|
||||
|| relative.StartsWith(
|
||||
".." + Path.DirectorySeparatorChar,
|
||||
StringComparison.Ordinal))
|
||||
{
|
||||
throw new ArgumentException("Plugin storage key escapes its root.", nameof(key));
|
||||
}
|
||||
return path;
|
||||
}
|
||||
}
|
||||
225
src/AcDream.App/Plugins/LocalPluginPeerRegistry.cs
Normal file
225
src/AcDream.App/Plugins/LocalPluginPeerRegistry.cs
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
using System.Text.Json;
|
||||
using AcDream.Plugin.Abstractions;
|
||||
|
||||
namespace AcDream.App.Plugins;
|
||||
|
||||
/// <summary>
|
||||
/// Small cross-process peer roster for plugins. UtilityBelt used a local TCP
|
||||
/// relay; acdream uses bounded heartbeat documents in the user's local app
|
||||
/// data, which provides the same machine-local discovery without a privileged
|
||||
/// daemon or a fixed port. The files carry data only—never commands.
|
||||
/// </summary>
|
||||
internal sealed class LocalPluginPeerRegistry : IDisposable
|
||||
{
|
||||
internal static readonly TimeSpan StaleAfter = TimeSpan.FromSeconds(15);
|
||||
private const long MaximumDocumentBytes = 64 * 1024;
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
};
|
||||
|
||||
private readonly string _directory;
|
||||
private readonly string _path;
|
||||
private readonly TimeProvider _time;
|
||||
private readonly Guid _instanceId;
|
||||
private bool _disposed;
|
||||
|
||||
public LocalPluginPeerRegistry(
|
||||
string directory,
|
||||
TimeProvider? timeProvider = null,
|
||||
Guid? instanceId = null)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(directory);
|
||||
_directory = Path.GetFullPath(directory);
|
||||
_time = timeProvider ?? TimeProvider.System;
|
||||
_instanceId = instanceId ?? Guid.NewGuid();
|
||||
_path = Path.Combine(_directory, $"peer-{_instanceId:N}.json");
|
||||
ClientId = BitConverter.ToUInt32(_instanceId.ToByteArray(), 0);
|
||||
if (ClientId == 0u)
|
||||
ClientId = 1u;
|
||||
}
|
||||
|
||||
public uint ClientId { get; private set; }
|
||||
|
||||
public void Publish(in PluginNetworkClient client)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
Directory.CreateDirectory(_directory);
|
||||
var document = PeerDocument.From(
|
||||
client with { ClientId = ClientId },
|
||||
_instanceId,
|
||||
_time.GetUtcNow().ToUnixTimeMilliseconds());
|
||||
string temporary = _path + "." + Guid.NewGuid().ToString("N") + ".tmp";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(temporary, JsonSerializer.Serialize(document, JsonOptions));
|
||||
File.Move(temporary, _path, overwrite: true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(temporary))
|
||||
File.Delete(temporary);
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<PluginNetworkClient> CaptureRemoteClients()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
if (!Directory.Exists(_directory))
|
||||
return Array.Empty<PluginNetworkClient>();
|
||||
long newestAllowed = _time.GetUtcNow().Subtract(StaleAfter)
|
||||
.ToUnixTimeMilliseconds();
|
||||
var result = new List<PluginNetworkClient>();
|
||||
foreach (string file in Directory.EnumerateFiles(
|
||||
_directory,
|
||||
"peer-*.json",
|
||||
SearchOption.TopDirectoryOnly))
|
||||
{
|
||||
if (file.Equals(_path, StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
try
|
||||
{
|
||||
var info = new FileInfo(file);
|
||||
if (info.Length is <= 0 or > MaximumDocumentBytes)
|
||||
continue;
|
||||
PeerDocument? document = JsonSerializer.Deserialize<PeerDocument>(
|
||||
File.ReadAllText(file),
|
||||
JsonOptions);
|
||||
if (document is null
|
||||
|| document.InstanceId == _instanceId
|
||||
|| document.UpdatedUnixMs < newestAllowed
|
||||
|| document.ClientId == 0u
|
||||
|| document.PlayerId == 0u
|
||||
|| string.IsNullOrWhiteSpace(document.Name)
|
||||
|| document.Name.Length > 128
|
||||
|| document.WorldName is null
|
||||
|| document.WorldName.Length > 128
|
||||
|| document.Tags is null
|
||||
|| document.Tags.Length > 128
|
||||
|| !double.IsFinite(document.EastWest)
|
||||
|| !double.IsFinite(document.NorthSouth)
|
||||
|| !double.IsFinite(document.Elevation)
|
||||
|| !float.IsFinite(document.Heading))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
result.Add(document.ToClient());
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// A peer can atomically replace or remove its own heartbeat
|
||||
// between enumeration and read. It will reappear next scan.
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
}
|
||||
}
|
||||
return result
|
||||
.OrderBy(static client => client.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(static client => client.ClientId)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
public void Withdraw()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
try
|
||||
{
|
||||
if (File.Exists(_path))
|
||||
File.Delete(_path);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
Withdraw();
|
||||
_disposed = true;
|
||||
}
|
||||
|
||||
private sealed class PeerDocument
|
||||
{
|
||||
public Guid InstanceId { get; set; }
|
||||
public long UpdatedUnixMs { get; set; }
|
||||
public uint ClientId { get; set; }
|
||||
public uint PlayerId { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string WorldName { get; set; } = string.Empty;
|
||||
public string[] Tags { get; set; } = [];
|
||||
public uint CellId { get; set; }
|
||||
public double EastWest { get; set; }
|
||||
public double NorthSouth { get; set; }
|
||||
public double Elevation { get; set; }
|
||||
public bool IsOutdoor { get; set; }
|
||||
public float Heading { get; set; }
|
||||
public uint CurrentHealth { get; set; }
|
||||
public uint CurrentMana { get; set; }
|
||||
public uint CurrentStamina { get; set; }
|
||||
public uint MaxHealth { get; set; }
|
||||
public uint MaxMana { get; set; }
|
||||
public uint MaxStamina { get; set; }
|
||||
|
||||
public static PeerDocument From(
|
||||
in PluginNetworkClient client,
|
||||
Guid instanceId,
|
||||
long updatedUnixMs) => new()
|
||||
{
|
||||
InstanceId = instanceId,
|
||||
UpdatedUnixMs = updatedUnixMs,
|
||||
ClientId = client.ClientId,
|
||||
PlayerId = client.PlayerId,
|
||||
Name = client.Name,
|
||||
WorldName = client.WorldName,
|
||||
Tags = client.Tags
|
||||
.Where(static tag => !string.IsNullOrWhiteSpace(tag))
|
||||
.Select(static tag => tag.Trim())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.Take(128)
|
||||
.ToArray(),
|
||||
CellId = client.Position.CellId,
|
||||
EastWest = client.Position.EastWest,
|
||||
NorthSouth = client.Position.NorthSouth,
|
||||
Elevation = client.Position.Elevation,
|
||||
IsOutdoor = client.Position.IsOutdoor,
|
||||
Heading = client.Heading,
|
||||
CurrentHealth = client.CurrentHealth,
|
||||
CurrentMana = client.CurrentMana,
|
||||
CurrentStamina = client.CurrentStamina,
|
||||
MaxHealth = client.MaxHealth,
|
||||
MaxMana = client.MaxMana,
|
||||
MaxStamina = client.MaxStamina,
|
||||
};
|
||||
|
||||
public PluginNetworkClient ToClient() => new(
|
||||
ClientId,
|
||||
PlayerId,
|
||||
Name,
|
||||
WorldName,
|
||||
new PluginNavigationPosition(
|
||||
CellId,
|
||||
EastWest,
|
||||
NorthSouth,
|
||||
Elevation,
|
||||
Heading,
|
||||
IsOutdoor),
|
||||
Tags,
|
||||
CurrentHealth,
|
||||
CurrentMana,
|
||||
CurrentStamina,
|
||||
MaxHealth,
|
||||
MaxMana,
|
||||
MaxStamina,
|
||||
Heading);
|
||||
}
|
||||
}
|
||||
|
|
@ -160,7 +160,13 @@ using IDisposable atmosphericPackRegistration = renderPackRegistry.Register(
|
|||
"spv")));
|
||||
// Constructed here and handed to both sides: GameWindow binds it to the live
|
||||
// session's Runtime owners, the plugin host exposes it to plugins.
|
||||
using var automation = new AcDream.App.Plugins.AppAutomationSurface();
|
||||
using var automation = new AcDream.App.Plugins.AppAutomationSurface(
|
||||
worldEvents,
|
||||
new AcDream.App.Plugins.LocalPluginPeerRegistry(Path.Combine(
|
||||
applicationPaths.DataDirectory,
|
||||
"plugin-peers")),
|
||||
runtimeOptions.PluginTags);
|
||||
var lootClassifiers = new AcDream.Core.Plugins.PluginLootClassifierRegistry();
|
||||
using var window = new GameWindow(
|
||||
runtimeOptions,
|
||||
worldGameState,
|
||||
|
|
@ -175,7 +181,11 @@ var host = new AppPluginHost(
|
|||
worldEvents,
|
||||
window.Selection,
|
||||
uiRegistry,
|
||||
automation);
|
||||
automation,
|
||||
new FilePluginStorage(
|
||||
Path.Combine(applicationPaths.ConfigDirectory, "plugins")),
|
||||
automation.PluginCommands,
|
||||
lootClassifiers);
|
||||
GraphicalPluginSession pluginSession = GraphicalPluginSession.Create(
|
||||
applicationPaths,
|
||||
runtimeOptions.Plugins,
|
||||
|
|
|
|||
|
|
@ -715,6 +715,7 @@ public sealed class GameWindow :
|
|||
// reset across generations. Re-binding per session would be re-binding
|
||||
// the same two references.
|
||||
_automation?.Bind(_runtime, _runtime.CharacterOwner, _runtime.ActionOwner.SpellCast);
|
||||
_automation?.BindProjectileCollision(_physicsEngine);
|
||||
_localPlayerIdentity = new AcDream.App.Input.LocalPlayerIdentityState(
|
||||
_runtime.PlayerIdentity);
|
||||
_updateFrameClock = new AcDream.App.Update.UpdateFrameClock(
|
||||
|
|
@ -960,6 +961,10 @@ public sealed class GameWindow :
|
|||
// zero skills with no error to explain it.
|
||||
if (_automation is null)
|
||||
return;
|
||||
_automation.BindSpeciesNameResolver(
|
||||
AcDream.App.UI.Layout.CreatureDisplayNameResolver.Load(value).Resolve);
|
||||
_automation.BindPaletteColorResolver(
|
||||
new AcDream.Content.CharGen.ChargenAppearanceCatalog(value));
|
||||
if (!value.TryGet<DatReaderWriter.DBObjs.SkillTable>(0x0E000004u, out var skillTable)
|
||||
|| skillTable is null)
|
||||
{
|
||||
|
|
@ -983,8 +988,11 @@ public sealed class GameWindow :
|
|||
"prepared asset source");
|
||||
|
||||
void IGameWindowContentEffectsAudioPublication.PublishMagicCatalog(
|
||||
MagicCatalog value) =>
|
||||
MagicCatalog value)
|
||||
{
|
||||
PublishCompositionOwner(ref _magicCatalog, value, "magic catalog");
|
||||
_automation?.BindMagicCatalog(value);
|
||||
}
|
||||
|
||||
void IGameWindowContentEffectsAudioPublication.PublishAnimationLoader(
|
||||
AcDream.Core.Physics.IAnimationLoader value) =>
|
||||
|
|
@ -1144,6 +1152,25 @@ public sealed class GameWindow :
|
|||
_combatAttackController = result.CombatAttack;
|
||||
_externalContainerLifecycle = result.ExternalContainerLifecycle;
|
||||
_itemInteractionController = result.ItemInteraction;
|
||||
_automation?.BindEquipment(
|
||||
(itemId, requestedLocation) =>
|
||||
result.ItemInteraction.TryWieldItem(
|
||||
itemId,
|
||||
(AcDream.Core.Items.EquipMask)requestedLocation),
|
||||
() => result.ItemInteraction.IsAutoWieldBusy);
|
||||
_automation?.BindItems(
|
||||
result.ItemInteraction.TryUseItemForAutomation,
|
||||
result.ItemInteraction.TryApplyItem,
|
||||
result.ItemInteraction.TryMoveItemForAutomation,
|
||||
result.ItemInteraction.TryMergeItemsForAutomation,
|
||||
result.ItemInteraction.TryDropItemForAutomation,
|
||||
result.ItemInteraction.TryGiveItemForAutomation,
|
||||
result.ItemInteraction.PlaceWorldItemInBackpack,
|
||||
result.ItemInteraction.TryAppraiseForAutomation,
|
||||
result.ItemInteraction.TrySalvageItemsForAutomation,
|
||||
(vendorId, itemId, amount) => result.ItemInteraction.TrySell(
|
||||
vendorId,
|
||||
[(amount, itemId)]));
|
||||
_interactionUiLateBindings = result.LateBindings;
|
||||
_magicRuntime = result.Magic;
|
||||
if (result.RetainedUi is { } retained)
|
||||
|
|
@ -1219,6 +1246,17 @@ public sealed class GameWindow :
|
|||
_retailSelectionScene = result.SelectionScene;
|
||||
_worldSelectionQuery = result.SelectionQuery;
|
||||
_selectionInteractions = result.SelectionInteractions;
|
||||
_automation?.BindSelectionActions(action =>
|
||||
result.SelectionInteractions.HandleInputAction(action switch
|
||||
{
|
||||
AcDream.Plugin.Abstractions.PluginSelectionAction.PreviousSelection =>
|
||||
InputAction.SelectionPreviousSelection,
|
||||
AcDream.Plugin.Abstractions.PluginSelectionAction.PreviousPlayer =>
|
||||
InputAction.SelectionPreviousPlayer,
|
||||
AcDream.Plugin.Abstractions.PluginSelectionAction.NextPlayer =>
|
||||
InputAction.SelectionNextPlayer,
|
||||
_ => InputAction.None,
|
||||
}));
|
||||
_retainedUiGameplayBinding = result.RetainedGameplay;
|
||||
_paperdollViewportRenderer = result.PaperdollRenderer;
|
||||
_paperdollFramePresenter = result.PaperdollPresenter;
|
||||
|
|
@ -1272,6 +1310,7 @@ public sealed class GameWindow :
|
|||
_worldReveal = result.WorldReveal;
|
||||
_spawnClaimHydration = result.SpawnClaimHydration;
|
||||
_liveEntityHydration = result.Hydration;
|
||||
_automation?.BindGhostDeletion(result.Deletion.DeleteClientGhost);
|
||||
_liveEntityNetworkUpdates = result.NetworkUpdates;
|
||||
_liveEntityLiveness = result.Liveness;
|
||||
_liveEntitySessionEvents = result.SessionEvents;
|
||||
|
|
@ -1282,6 +1321,7 @@ public sealed class GameWindow :
|
|||
_playerModeAutoEntry = result.PlayerModeAutoEntry;
|
||||
_localPlayerTeleport = result.LocalTeleport;
|
||||
_liveSessionHost = result.SessionHost;
|
||||
_automation?.BindSessionCommands(result.GameRuntime);
|
||||
_gameplayInputActions = result.GameplayActions;
|
||||
_sessionPlayerBindings = result.RuntimeBindings;
|
||||
}
|
||||
|
|
@ -1529,7 +1569,8 @@ public sealed class GameWindow :
|
|||
() => WorldTime.CurrentCalendar,
|
||||
settingsDevTools.RenderPacks,
|
||||
_renderPackDiagnostics.CaptureDiagnostics,
|
||||
_applicationPaths.ScreenshotsDirectory),
|
||||
_applicationPaths.ScreenshotsDirectory,
|
||||
_automation),
|
||||
_retailUiLease,
|
||||
this).Compose(
|
||||
platformResult,
|
||||
|
|
@ -1653,6 +1694,9 @@ public sealed class GameWindow :
|
|||
_combatFeedback,
|
||||
_portalTunnelFallback,
|
||||
Console.WriteLine,
|
||||
_automation is null
|
||||
? null
|
||||
: _automation.TryHandlePluginCommand,
|
||||
_statusWriter),
|
||||
this).Compose(
|
||||
hostInputCamera,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ using AcDream.App.Interaction;
|
|||
using AcDream.App.Net;
|
||||
using AcDream.Core.CharGen;
|
||||
using AcDream.Runtime;
|
||||
using AcDream.Runtime.Chat;
|
||||
using AcDream.Runtime.Session;
|
||||
using AcDream.Runtime.World;
|
||||
using AcDream.UI.Abstractions;
|
||||
|
|
@ -21,6 +22,7 @@ internal sealed class CurrentGameRuntimeAdapter
|
|||
{
|
||||
private readonly GameRuntime _runtime;
|
||||
private readonly CurrentGameRuntimeCommandAdapter _commands;
|
||||
private readonly ICommandBus _commandBus;
|
||||
private readonly CharacterSelectionProjection _characterSelection;
|
||||
private readonly CharacterCreationProjection _characterCreation;
|
||||
private readonly IDisposable _hostLease;
|
||||
|
|
@ -39,6 +41,7 @@ internal sealed class CurrentGameRuntimeAdapter
|
|||
ArgumentNullException.ThrowIfNull(commands);
|
||||
ArgumentNullException.ThrowIfNull(selection);
|
||||
|
||||
_commandBus = commands;
|
||||
_hostLease = runtime.AcquireHostLease(
|
||||
"graphical game-runtime command adapter");
|
||||
try
|
||||
|
|
@ -137,6 +140,24 @@ internal sealed class CurrentGameRuntimeAdapter
|
|||
public IRuntimeAllegianceCommands AllegianceCommands => _commands;
|
||||
IRuntimeAllegianceCommands IGameRuntimeCommands.Allegiance => _commands;
|
||||
|
||||
/// <summary>
|
||||
/// Automation entrance to the same parser used by the retail chat field.
|
||||
/// Plugins see this only through the BCL-only <c>IPluginChat</c> contract.
|
||||
/// </summary>
|
||||
internal bool SubmitChatText(string text)
|
||||
{
|
||||
if (!IsActive || string.IsNullOrWhiteSpace(text))
|
||||
return false;
|
||||
SubmitOutcome outcome = ChatCommandRouter.Submit(
|
||||
text,
|
||||
new RuntimeChatCommandFeedback(_runtime.CommunicationOwner),
|
||||
_commandBus,
|
||||
ChatChannelKind.Say);
|
||||
return outcome is not (SubmitOutcome.Empty
|
||||
or SubmitOutcome.UnknownCommand
|
||||
or SubmitOutcome.Dropped);
|
||||
}
|
||||
|
||||
public RuntimeStateCheckpoint CaptureCheckpoint()
|
||||
{
|
||||
RuntimeStateCheckpoint checkpoint = _runtime.CaptureCheckpoint();
|
||||
|
|
|
|||
|
|
@ -122,6 +122,11 @@ public sealed record RuntimeOptions(
|
|||
|
||||
public uint? PreparedAssetEffectiveRecipeVersion { get; init; }
|
||||
|
||||
/// <summary>Optional machine-local peer tags advertised to other plugin
|
||||
/// instances. Parsed once here so the live automation surface never reads
|
||||
/// process configuration directly.</summary>
|
||||
public IReadOnlyList<string> PluginTags { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Build options from the process environment. Used by
|
||||
/// <c>Program.cs</c> at startup.
|
||||
|
|
@ -247,7 +252,10 @@ public sealed record RuntimeOptions(
|
|||
StatusFilePath: null,
|
||||
Plugins: null,
|
||||
LoginCommands: [],
|
||||
LoginCommandDelayMs: 500);
|
||||
LoginCommandDelayMs: 500)
|
||||
{
|
||||
PluginTags = ParsePluginTags(env("ACDREAM_PLUGIN_TAGS")),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -367,6 +375,15 @@ public sealed record RuntimeOptions(
|
|||
private static string? NullIfEmpty(string? s)
|
||||
=> string.IsNullOrEmpty(s) ? null : s;
|
||||
|
||||
private static IReadOnlyList<string> ParsePluginTags(string? value) =>
|
||||
(value ?? string.Empty)
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries
|
||||
| StringSplitOptions.TrimEntries)
|
||||
.Where(static tag => tag.Length <= 128)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.Take(128)
|
||||
.ToArray();
|
||||
|
||||
private static int? TryParseInt(string? s)
|
||||
=> int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out var v) ? v : null;
|
||||
|
||||
|
|
|
|||
|
|
@ -75,6 +75,7 @@ public sealed class ItemInteractionController : IDisposable
|
|||
// dispatched-vs-silent-no-op shape as _sendBuy — see TryBuyAll/TrySell.
|
||||
private readonly Func<uint, IReadOnlyList<(int Amount, uint ItemGuid)>, uint, bool>? _sendBuyAll;
|
||||
private readonly Func<uint, IReadOnlyList<(int Amount, uint ItemGuid)>, bool>? _sendSell;
|
||||
private readonly Func<uint, IReadOnlyList<uint>, bool>? _sendSalvage;
|
||||
private readonly RuntimeInteractionTransactionState _runtimeTransactions;
|
||||
private readonly InventoryTransactionState _transactions;
|
||||
|
||||
|
|
@ -120,7 +121,8 @@ public sealed class ItemInteractionController : IDisposable
|
|||
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<uint, uint, uint>? sendStackableMerge = null)
|
||||
Action<uint, uint, uint>? sendStackableMerge = null,
|
||||
Func<uint, IReadOnlyList<uint>, bool>? sendSalvage = null)
|
||||
{
|
||||
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
|
||||
_playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid));
|
||||
|
|
@ -155,6 +157,7 @@ public sealed class ItemInteractionController : IDisposable
|
|||
_sendBuy = sendBuy;
|
||||
_sendBuyAll = sendBuyAll;
|
||||
_sendSell = sendSell;
|
||||
_sendSalvage = sendSalvage;
|
||||
_interactionState = interactionState
|
||||
?? throw new ArgumentNullException(nameof(interactionState));
|
||||
_runtimeTransactions = runtimeTransactions
|
||||
|
|
@ -500,6 +503,197 @@ public sealed class ItemInteractionController : IDisposable
|
|||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Plugin-facing form of retail's put/split-to-container attempts. It
|
||||
/// borrows this controller's exact transaction gate and wire delegates;
|
||||
/// plugins supply policy, never a second optimistic inventory model.
|
||||
/// </summary>
|
||||
public bool TryMoveItemForAutomation(
|
||||
uint itemId,
|
||||
uint containerId,
|
||||
uint amount = 0u,
|
||||
int placement = 0)
|
||||
{
|
||||
if (itemId == 0u
|
||||
|| containerId == 0u
|
||||
|| _sendPutItemInContainer is null
|
||||
|| _objects.Get(itemId) is not { } item
|
||||
|| !IsOwnedByPlayer(itemId)
|
||||
|| (containerId != _playerGuid() && !IsOwnedByPlayer(containerId)))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
uint fullStack = (uint)Math.Max(1, item.StackSize);
|
||||
uint requested = amount == 0u ? fullStack : amount;
|
||||
if (requested == 0u || requested > fullStack)
|
||||
return false;
|
||||
if (requested < fullStack)
|
||||
{
|
||||
return TrySplitToContainer(
|
||||
itemId,
|
||||
containerId,
|
||||
(uint)Math.Max(0, placement),
|
||||
requested);
|
||||
}
|
||||
|
||||
return TryDispatchInventoryRequest(
|
||||
InventoryRequestKind.PutInContainer,
|
||||
itemId,
|
||||
() =>
|
||||
{
|
||||
_sendPutItemInContainer(itemId, containerId, placement);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Plugin-facing retail stack merge. The shared planner performs the same
|
||||
/// WCID, maximum-size, staged-trade, and transfer-size checks as a drag.
|
||||
/// </summary>
|
||||
public bool TryMergeItemsForAutomation(
|
||||
uint sourceItemId,
|
||||
uint targetItemId,
|
||||
uint amount = 0u)
|
||||
{
|
||||
if (_sendStackableMerge is null
|
||||
|| !IsOwnedByPlayer(sourceItemId)
|
||||
|| !IsOwnedByPlayer(targetItemId)
|
||||
|| _objects.Get(sourceItemId) is not { } source
|
||||
|| _objects.Get(targetItemId) is not { } target)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
int requested = amount > int.MaxValue ? int.MaxValue : (int)amount;
|
||||
StackMergePlan? plan = StackMergePlanner.Plan(
|
||||
ToStackMergeItem(source),
|
||||
ToStackMergeItem(target),
|
||||
CanMakeInventoryRequest,
|
||||
requested);
|
||||
if (plan is not { } merge)
|
||||
return false;
|
||||
|
||||
return TryDispatchInventoryRequest(
|
||||
InventoryRequestKind.Merge,
|
||||
sourceItemId,
|
||||
() =>
|
||||
{
|
||||
_sendStackableMerge(
|
||||
merge.SourceObjectId,
|
||||
merge.TargetObjectId,
|
||||
merge.Amount);
|
||||
MergeAttempted?.Invoke(
|
||||
merge.SourceObjectId,
|
||||
merge.TargetObjectId);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Plugin-facing retail full-stack drop or split-to-world.</summary>
|
||||
public bool TryDropItemForAutomation(uint itemId, uint amount = 0u)
|
||||
{
|
||||
if (!IsOwnedByPlayer(itemId) || _objects.Get(itemId) is not { } item)
|
||||
return false;
|
||||
|
||||
uint fullStack = (uint)Math.Max(1, item.StackSize);
|
||||
uint requested = amount == 0u ? fullStack : amount;
|
||||
if (requested == 0u || requested > fullStack)
|
||||
return false;
|
||||
InventoryRequestKind kind = requested < fullStack
|
||||
? InventoryRequestKind.SplitToWorld
|
||||
: InventoryRequestKind.DropToWorld;
|
||||
return TryDispatchInventoryRequest(
|
||||
kind,
|
||||
itemId,
|
||||
() =>
|
||||
{
|
||||
if (requested < fullStack)
|
||||
{
|
||||
if (_sendSplitToWorld is null)
|
||||
return false;
|
||||
_sendSplitToWorld(itemId, requested);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (_sendDrop is null)
|
||||
return false;
|
||||
_sendDrop(itemId);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Plugin-facing retail Give attempt with an exact stack amount.</summary>
|
||||
public bool TryGiveItemForAutomation(
|
||||
uint itemId,
|
||||
uint targetId,
|
||||
uint amount = 0u)
|
||||
{
|
||||
if (_sendGive is null
|
||||
|| targetId == 0u
|
||||
|| targetId == _playerGuid()
|
||||
|| _objects.Get(targetId) is null
|
||||
|| !IsOwnedByPlayer(itemId)
|
||||
|| _objects.Get(itemId) is not { } item)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
uint fullStack = (uint)Math.Max(1, item.StackSize);
|
||||
uint requested = amount == 0u ? fullStack : amount;
|
||||
if (requested == 0u || requested > fullStack)
|
||||
return false;
|
||||
return TryDispatchInventoryRequest(
|
||||
InventoryRequestKind.Give,
|
||||
itemId,
|
||||
() =>
|
||||
{
|
||||
_sendGive(targetId, itemId, requested);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Plugin-facing form of gmSalvageUI::Salvage. Retail validates an owned
|
||||
/// tinkering tool and a non-empty ordered list of suitable owned source
|
||||
/// items, then sends 0x027D without entering the ordinary one-item move
|
||||
/// transaction. The server owns the final material and option checks.
|
||||
/// </summary>
|
||||
public bool TrySalvageItemsForAutomation(
|
||||
uint toolId,
|
||||
IReadOnlyList<uint> itemIds)
|
||||
{
|
||||
if (_sendSalvage is null
|
||||
|| toolId == 0u
|
||||
|| itemIds is null
|
||||
|| itemIds.Count == 0
|
||||
|| !CanMakeInventoryRequest
|
||||
|| !IsOwnedByPlayer(toolId)
|
||||
|| _objects.Get(toolId) is not { } tool
|
||||
|| (tool.Type & ItemType.TinkeringTool) == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var distinct = new HashSet<uint>();
|
||||
foreach (uint itemId in itemIds)
|
||||
{
|
||||
if (itemId == 0u
|
||||
|| itemId == toolId
|
||||
|| !distinct.Add(itemId)
|
||||
|| !IsOwnedByPlayer(itemId)
|
||||
|| _objects.Get(itemId) is not { } item
|
||||
|| item.MaterialType is null or 0u
|
||||
|| item.Structure >= 100
|
||||
|| ((item.PublicWeenieBitfield ?? 0u) & 0xFF000000u) != 0u)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return _sendSalvage(toolId, itemIds);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Increments retail's shared <c>ClientUISystem</c> busy reference after a
|
||||
/// request issued by another retained controller has been sent. The
|
||||
|
|
@ -655,6 +849,21 @@ public sealed class ItemInteractionController : IDisposable
|
|||
_runtimeTransactions.TryRequestAppraisal(objectId, _sendExamine);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Plugin/automation appraisal through the one retail appraisal owner.
|
||||
/// It does not mutate selection or open/raise the examination window.
|
||||
/// </summary>
|
||||
public bool TryAppraiseForAutomation(uint objectId)
|
||||
{
|
||||
if (objectId == 0u
|
||||
|| _sendExamine is null
|
||||
|| _objects.Get(objectId) is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return _runtimeTransactions.TryRequestAppraisal(objectId, _sendExamine);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Accepts only the pending or current appraisal, matching
|
||||
/// <c>gmExaminationUI::SetAppraiseInfo @ 0x004ADAE0</c>.
|
||||
|
|
@ -750,6 +959,76 @@ public sealed class ItemInteractionController : IDisposable
|
|||
return ExecuteUseActions(decision.Actions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Plugin/automation entry for an ordinary item request. Unlike interactive
|
||||
/// activation, it never turns a use request into wielding, sorting, or a
|
||||
/// modal target cursor, and returns true only when a wire Use was issued.
|
||||
/// </summary>
|
||||
public bool TryUseItemForAutomation(uint itemGuid)
|
||||
{
|
||||
if (itemGuid == 0u || _objects.Get(itemGuid) is not { } item)
|
||||
return false;
|
||||
if (ItemUseability.IsTargeted(item.Useability ?? ItemUseability.Undef))
|
||||
return false;
|
||||
if (!ConsumeUseThrottle())
|
||||
return false;
|
||||
if (!EnsureInventoryRequestReady())
|
||||
return false;
|
||||
|
||||
var input = new ItemUsePolicyInput(
|
||||
Snapshot(item),
|
||||
_playerGuid(),
|
||||
_groundObjectId(),
|
||||
CanMakeInventoryRequest,
|
||||
_activeVendorId(),
|
||||
BypassClassification: true,
|
||||
UseCurrentSelection: false,
|
||||
SelectedTarget: null,
|
||||
ConfirmVolatileRareUses: true,
|
||||
InNonCombatMode: _inNonCombatMode());
|
||||
ItemUsePolicyDecision decision = ItemInteractionPolicy.DecideUse(input);
|
||||
bool sends = decision.Actions.Any(static action =>
|
||||
action.Kind == ItemPolicyActionKind.SendUse);
|
||||
return sends && ExecuteUseActions(decision.Actions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Plugin/automation entry for a targeted item action. It follows the same
|
||||
/// retail compatibility, throttle, busy-reference and UseDone ownership as
|
||||
/// choosing a target through the interactive target cursor, without
|
||||
/// installing a modal cursor state that automation cannot safely own.
|
||||
/// </summary>
|
||||
public bool TryApplyItem(uint itemGuid, uint targetGuid)
|
||||
{
|
||||
if (itemGuid == 0u || targetGuid == 0u)
|
||||
return false;
|
||||
if (_objects.Get(itemGuid) is not { } item
|
||||
|| _objects.Get(targetGuid) is not { } target)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (!ConsumeUseThrottle())
|
||||
return true;
|
||||
if (!EnsureInventoryRequestReady())
|
||||
return false;
|
||||
|
||||
var input = new ItemUsePolicyInput(
|
||||
Snapshot(item),
|
||||
_playerGuid(),
|
||||
_groundObjectId(),
|
||||
CanMakeInventoryRequest,
|
||||
_activeVendorId(),
|
||||
BypassClassification: true,
|
||||
UseCurrentSelection: true,
|
||||
SelectedTarget: Snapshot(target),
|
||||
ConfirmVolatileRareUses: true,
|
||||
InNonCombatMode: _inNonCombatMode());
|
||||
ItemUsePolicyDecision decision = ItemInteractionPolicy.DecideUse(input);
|
||||
bool sends = decision.Actions.Any(static action =>
|
||||
action.Kind == ItemPolicyActionKind.SendUseWithTarget);
|
||||
return sends && ExecuteUseActions(decision.Actions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail keyboard pickup entry point. <c>CPlayerSystem::PlaceInBackpack</c>
|
||||
/// publishes the waiting destination slot before issuing the move request,
|
||||
|
|
@ -1095,6 +1374,24 @@ public sealed class ItemInteractionController : IDisposable
|
|||
return _autoWield.TryWield(item, targetMask);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Plugin/automation entry into the exact same AutoWield transaction used
|
||||
/// by inventory activation and paperdoll drops.
|
||||
/// </summary>
|
||||
public bool TryWieldItem(uint itemGuid, EquipMask requestedMask = EquipMask.None)
|
||||
{
|
||||
if (itemGuid == 0u || _objects.Get(itemGuid) is not { } item)
|
||||
return false;
|
||||
if (!EnsureInventoryRequestReady())
|
||||
return false;
|
||||
return requestedMask == EquipMask.None
|
||||
? _autoWield.TryWield(item)
|
||||
: _autoWield.TryWield(item, requestedMask);
|
||||
}
|
||||
|
||||
public bool IsAutoWieldBusy =>
|
||||
_autoWield.IsBusy || !_transactions.CanBeginRequest;
|
||||
|
||||
/// <summary>User combat-mode input supersedes AutoWield's retained mode.</summary>
|
||||
public void NotifyExplicitCombatModeRequest()
|
||||
=> _autoWield.NotifyExplicitCombatModeRequest();
|
||||
|
|
|
|||
137
src/AcDream.App/UI/Layout/ProjectileDebugOverlayController.cs
Normal file
137
src/AcDream.App/UI/Layout/ProjectileDebugOverlayController.cs
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
using System.Numerics;
|
||||
using AcDream.Core.Selection;
|
||||
using AcDream.Plugin.Abstractions;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Retained-view projection of VTank's <c>ShowCollisionDebug</c> shapes.
|
||||
/// The collision query remains in the canonical physics world; this owner
|
||||
/// only projects its detached per-quantum samples into the already-open UI
|
||||
/// phase, avoiding a nested Vulkan backbuffer pass.
|
||||
/// </summary>
|
||||
internal sealed class ProjectileDebugOverlayController
|
||||
{
|
||||
private static readonly Vector4 ClearColor = new(0f, 1f, 0f, 0.95f);
|
||||
private static readonly Vector4 BlockedColor = new(1f, 0f, 0f, 0.95f);
|
||||
|
||||
private readonly UiPanel _root;
|
||||
private readonly Func<IReadOnlyList<PluginProjectileDebugSample>> _samples;
|
||||
private readonly Func<(Matrix4x4 View, Matrix4x4 Projection, Vector2 Viewport)>
|
||||
_camera;
|
||||
private readonly List<UiPanel> _markers = [];
|
||||
|
||||
private ProjectileDebugOverlayController(
|
||||
UiPanel root,
|
||||
Func<IReadOnlyList<PluginProjectileDebugSample>> samples,
|
||||
Func<(Matrix4x4 View, Matrix4x4 Projection, Vector2 Viewport)> camera)
|
||||
{
|
||||
_root = root;
|
||||
_samples = samples;
|
||||
_camera = camera;
|
||||
}
|
||||
|
||||
internal static ProjectileDebugOverlayController Mount(
|
||||
UiRoot host,
|
||||
Func<IReadOnlyList<PluginProjectileDebugSample>> samples,
|
||||
Func<(Matrix4x4 View, Matrix4x4 Projection, Vector2 Viewport)> camera)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(host);
|
||||
ArgumentNullException.ThrowIfNull(samples);
|
||||
ArgumentNullException.ThrowIfNull(camera);
|
||||
var root = new UiPanel
|
||||
{
|
||||
Name = "PluginProjectileDebugOverlay",
|
||||
BackgroundColor = Vector4.Zero,
|
||||
BorderColor = Vector4.Zero,
|
||||
ClickThrough = true,
|
||||
Visible = false,
|
||||
ZOrder = -9_999,
|
||||
Anchors = AnchorEdges.None,
|
||||
};
|
||||
host.AddChild(root);
|
||||
return new ProjectileDebugOverlayController(root, samples, camera);
|
||||
}
|
||||
|
||||
internal void Tick()
|
||||
{
|
||||
IReadOnlyList<PluginProjectileDebugSample> samples = _samples();
|
||||
var camera = _camera();
|
||||
if (samples.Count == 0
|
||||
|| camera.Viewport.X <= 0f
|
||||
|| camera.Viewport.Y <= 0f)
|
||||
{
|
||||
HideAll();
|
||||
return;
|
||||
}
|
||||
|
||||
EnsureMarkerCount(samples.Count);
|
||||
_root.Left = 0f;
|
||||
_root.Top = 0f;
|
||||
_root.Width = camera.Viewport.X;
|
||||
_root.Height = camera.Viewport.Y;
|
||||
int visible = 0;
|
||||
for (int index = 0; index < samples.Count; index++)
|
||||
{
|
||||
PluginProjectileDebugSample sample = samples[index];
|
||||
if (!ScreenProjection.TryProjectSphereToScreenRect(
|
||||
sample.WorldPosition,
|
||||
sample.Radius,
|
||||
camera.View,
|
||||
camera.Projection,
|
||||
camera.Viewport,
|
||||
out Vector2 minimum,
|
||||
out Vector2 maximum,
|
||||
out _,
|
||||
minSidePixels: 4f)
|
||||
|| maximum.X < 0f
|
||||
|| maximum.Y < 0f
|
||||
|| minimum.X > camera.Viewport.X
|
||||
|| minimum.Y > camera.Viewport.Y)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
UiPanel marker = _markers[visible++];
|
||||
marker.Left = MathF.Max(0f, minimum.X);
|
||||
marker.Top = MathF.Max(0f, minimum.Y);
|
||||
marker.Width = MathF.Max(
|
||||
1f,
|
||||
MathF.Min(camera.Viewport.X, maximum.X) - marker.Left);
|
||||
marker.Height = MathF.Max(
|
||||
1f,
|
||||
MathF.Min(camera.Viewport.Y, maximum.Y) - marker.Top);
|
||||
marker.BorderColor = sample.IsClear ? ClearColor : BlockedColor;
|
||||
marker.Visible = true;
|
||||
}
|
||||
for (int index = visible; index < _markers.Count; index++)
|
||||
_markers[index].Visible = false;
|
||||
_root.Visible = visible > 0;
|
||||
}
|
||||
|
||||
private void EnsureMarkerCount(int count)
|
||||
{
|
||||
while (_markers.Count < count)
|
||||
{
|
||||
var marker = new UiPanel
|
||||
{
|
||||
Name = $"PluginProjectileDebugMarker{_markers.Count}",
|
||||
BackgroundColor = Vector4.Zero,
|
||||
BorderColor = ClearColor,
|
||||
BorderThickness = 1.5f,
|
||||
ClickThrough = true,
|
||||
Visible = false,
|
||||
Anchors = AnchorEdges.None,
|
||||
};
|
||||
_markers.Add(marker);
|
||||
_root.AddChild(marker);
|
||||
}
|
||||
}
|
||||
|
||||
private void HideAll()
|
||||
{
|
||||
_root.Visible = false;
|
||||
for (int index = 0; index < _markers.Count; index++)
|
||||
_markers[index].Visible = false;
|
||||
}
|
||||
}
|
||||
|
|
@ -14,6 +14,12 @@ namespace AcDream.App.UI;
|
|||
/// </summary>
|
||||
public static class MarkupDocument
|
||||
{
|
||||
// Retail's generic runtime-text tooltip skin. Plugin controls have no
|
||||
// LayoutDesc of their own, so a tooltip= attribute explicitly opts them
|
||||
// into the same popup that game-code SetTooltip call sites use.
|
||||
private const uint RuntimeTooltipRootElementId = 0x10000397u;
|
||||
private const uint RuntimeTooltipLayoutDid = 0x21000041u;
|
||||
|
||||
/// <param name="xml">Raw XML markup for a single panel.</param>
|
||||
/// <param name="binding">Object whose public properties are bound to <c>{PropName}</c> attributes.</param>
|
||||
/// <param name="resolve">Surface id → (GL handle, width, height) for chrome sprites.</param>
|
||||
|
|
@ -74,13 +80,47 @@ public static class MarkupDocument
|
|||
}
|
||||
|
||||
foreach (var el in root.Elements())
|
||||
AddElement(panel, el, binding, resolve, datFont);
|
||||
return panel;
|
||||
}
|
||||
|
||||
private static void AddElement(
|
||||
UiElement parent,
|
||||
XElement el,
|
||||
object binding,
|
||||
Func<uint, (uint, int, int)> resolve,
|
||||
UiDatFont? datFont)
|
||||
{
|
||||
switch (el.Name.LocalName)
|
||||
{
|
||||
switch (el.Name.LocalName)
|
||||
{
|
||||
case "meter":
|
||||
case "group":
|
||||
var group = new UiPanel
|
||||
{
|
||||
Left = F(el, "x"),
|
||||
Top = F(el, "y"),
|
||||
Width = F(el, "w"),
|
||||
Height = F(el, "h"),
|
||||
BackgroundColor = el.Attribute("background") is null
|
||||
? Vector4.Zero
|
||||
: Color((string?)el.Attribute("background")),
|
||||
BorderColor = el.Attribute("border") is null
|
||||
? Vector4.Zero
|
||||
: Color((string?)el.Attribute("border")),
|
||||
BorderThickness = el.Attribute("border") is null ? 0f : 1f,
|
||||
// Transparent layout groups do not claim empty space, while
|
||||
// their interactive descendants remain hittable.
|
||||
ClickThrough = true,
|
||||
};
|
||||
ApplyCommon(group, el, binding);
|
||||
parent.AddChild(group);
|
||||
foreach (XElement child in el.Elements())
|
||||
AddElement(group, child, binding, resolve, datFont);
|
||||
break;
|
||||
|
||||
case "meter":
|
||||
var cur = BindUint((string?)el.Attribute("cur"), binding);
|
||||
var max = BindUint((string?)el.Attribute("max"), binding);
|
||||
panel.AddChild(new UiMeter
|
||||
var meter = new UiMeter
|
||||
{
|
||||
Left = F(el, "x"),
|
||||
Top = F(el, "y"),
|
||||
|
|
@ -97,10 +137,12 @@ public static class MarkupDocument
|
|||
FrontLeft = Hex((string?)el.Attribute("frontleft")),
|
||||
FrontTile = Hex((string?)el.Attribute("fronttile")),
|
||||
FrontRight = Hex((string?)el.Attribute("frontright")),
|
||||
});
|
||||
};
|
||||
ApplyCommon(meter, el, binding);
|
||||
parent.AddChild(meter);
|
||||
break;
|
||||
|
||||
case "label":
|
||||
case "label":
|
||||
// Text may be a literal or a {Binding}. Bound labels re-read
|
||||
// their property every frame through the Func, so a plugin
|
||||
// updates its status line by assigning a property rather
|
||||
|
|
@ -114,10 +156,11 @@ public static class MarkupDocument
|
|||
};
|
||||
if (el.Attribute("color") is not null)
|
||||
label.TextColor = Color((string?)el.Attribute("color"));
|
||||
panel.AddChild(label);
|
||||
ApplyCommon(label, el, binding);
|
||||
parent.AddChild(label);
|
||||
break;
|
||||
|
||||
case "button":
|
||||
case "button":
|
||||
// onclick binds to an Action property on the binding
|
||||
// object. Resolved once at build time: a button whose
|
||||
// handler silently failed to bind is a bug worth failing
|
||||
|
|
@ -151,13 +194,243 @@ public static class MarkupDocument
|
|||
button.TextSource = BindString(caption, binding);
|
||||
if (el.Attribute("color") is not null)
|
||||
button.TextColor = Color((string?)el.Attribute("color"));
|
||||
if (el.Attribute("background") is not null)
|
||||
button.BackgroundColor = Color(
|
||||
(string?)el.Attribute("background"));
|
||||
if (el.Attribute("border") is not null)
|
||||
button.BorderColor = Color(
|
||||
(string?)el.Attribute("border"));
|
||||
ApplyCommon(button, el, binding);
|
||||
if (onClick is not null)
|
||||
button.Click += onClick;
|
||||
panel.AddChild(button);
|
||||
parent.AddChild(button);
|
||||
break;
|
||||
}
|
||||
|
||||
case "tab":
|
||||
string? tabClickName = (string?)el.Attribute("onclick");
|
||||
Action? tabClick = BindAction(tabClickName, binding);
|
||||
if (tabClickName is not null && tabClick is null)
|
||||
{
|
||||
throw new FormatException(
|
||||
$"<tab onclick=\"{tabClickName}\"> did not resolve to an "
|
||||
+ $"Action property on {binding.GetType().Name}");
|
||||
}
|
||||
|
||||
var tab = new UiMarkupTabButton
|
||||
{
|
||||
Left = F(el, "x"),
|
||||
Top = F(el, "y"),
|
||||
Width = F(el, "w"),
|
||||
Height = F(el, "h"),
|
||||
Text = (string?)el.Attribute("text") ?? string.Empty,
|
||||
DatFont = datFont,
|
||||
SelectedSource = BindRequiredBoolReader(
|
||||
(string?)el.Attribute("selected"),
|
||||
binding,
|
||||
"tab selected"),
|
||||
};
|
||||
ApplyCommon(tab, el, binding);
|
||||
if (tabClick is not null)
|
||||
tab.Click += tabClick;
|
||||
parent.AddChild(tab);
|
||||
break;
|
||||
|
||||
case "toggle":
|
||||
string? toggleClickName = (string?)el.Attribute("onclick");
|
||||
Action? toggleClick = BindAction(toggleClickName, binding);
|
||||
if (toggleClickName is not null && toggleClick is null)
|
||||
{
|
||||
throw new FormatException(
|
||||
$"<toggle onclick=\"{toggleClickName}\"> did not resolve to an "
|
||||
+ $"Action property on {binding.GetType().Name}");
|
||||
}
|
||||
|
||||
string? toggleCaption = (string?)el.Attribute("text");
|
||||
var toggle = new UiMarkupToggle
|
||||
{
|
||||
Left = F(el, "x"),
|
||||
Top = F(el, "y"),
|
||||
Width = F(el, "w"),
|
||||
Height = F(el, "h"),
|
||||
Text = toggleCaption ?? string.Empty,
|
||||
TextSource = BindString(toggleCaption, binding),
|
||||
CheckedSource = BindRequiredBoolReader(
|
||||
(string?)el.Attribute("checked"),
|
||||
binding,
|
||||
"toggle checked"),
|
||||
DatFont = datFont,
|
||||
Toggle = toggleClick,
|
||||
};
|
||||
if (el.Attribute("color") is not null)
|
||||
toggle.TextColor = Color((string?)el.Attribute("color"));
|
||||
ApplyCommon(toggle, el, binding);
|
||||
parent.AddChild(toggle);
|
||||
break;
|
||||
|
||||
case "slider":
|
||||
string? changeName = (string?)el.Attribute("onchange");
|
||||
Action<float>? changed = BindFloatAction(changeName, binding);
|
||||
if (changeName is not null && changed is null)
|
||||
{
|
||||
throw new FormatException(
|
||||
$"<slider onchange=\"{changeName}\"> did not resolve to an "
|
||||
+ $"Action<float> property on {binding.GetType().Name}");
|
||||
}
|
||||
|
||||
var slider = new UiScrollbar
|
||||
{
|
||||
Left = F(el, "x"),
|
||||
Top = F(el, "y"),
|
||||
Width = F(el, "w"),
|
||||
Height = F(el, "h"),
|
||||
Horizontal = true,
|
||||
SpriteResolve = resolve,
|
||||
ScalarPositionSource = BindFloat(
|
||||
(string?)el.Attribute("value"),
|
||||
binding),
|
||||
ScalarChanged = changed,
|
||||
};
|
||||
RetailScrollbarChrome.ApplyHorizontal(slider);
|
||||
ApplyCommon(slider, el, binding);
|
||||
parent.AddChild(slider);
|
||||
break;
|
||||
|
||||
case "field":
|
||||
string? fieldChangeName = (string?)el.Attribute("onchange");
|
||||
Action<string>? fieldChanged = BindStringAction(
|
||||
fieldChangeName,
|
||||
binding);
|
||||
if (fieldChangeName is not null && fieldChanged is null)
|
||||
{
|
||||
throw new FormatException(
|
||||
$"<field onchange=\"{fieldChangeName}\"> did not resolve to an "
|
||||
+ $"Action<string> property on {binding.GetType().Name}");
|
||||
}
|
||||
string? submitName = (string?)el.Attribute("onsubmit");
|
||||
Action<string>? submitted = BindStringAction(submitName, binding);
|
||||
if (submitName is not null && submitted is null)
|
||||
{
|
||||
throw new FormatException(
|
||||
$"<field onsubmit=\"{submitName}\"> did not resolve to an "
|
||||
+ $"Action<string> property on {binding.GetType().Name}");
|
||||
}
|
||||
|
||||
var field = new UiField
|
||||
{
|
||||
Left = F(el, "x"),
|
||||
Top = F(el, "y"),
|
||||
Width = F(el, "w"),
|
||||
Height = F(el, "h"),
|
||||
DatFont = datFont,
|
||||
BackgroundColor = el.Attribute("background") is null
|
||||
? new Vector4(0f, 0f, 0f, 0.9f)
|
||||
: Color((string?)el.Attribute("background")),
|
||||
TextColor = el.Attribute("color") is null
|
||||
? new Vector4(0.91f, 0.87f, 0.76f, 1f)
|
||||
: Color((string?)el.Attribute("color")),
|
||||
MaxCharacters = Math.Max(1, I(el, "maxlength", 128)),
|
||||
ClearOnSubmit = B(el, "clearonsubmit", false),
|
||||
RecordHistory = false,
|
||||
OnTextChanged = fieldChanged,
|
||||
OnSubmit = submitted,
|
||||
};
|
||||
field.SetText(BindString((string?)el.Attribute("text"), binding)());
|
||||
ApplyCommon(field, el, binding);
|
||||
parent.AddChild(field);
|
||||
break;
|
||||
|
||||
case "menu":
|
||||
string? menuChangeName = (string?)el.Attribute("onchange");
|
||||
Action<string>? menuChanged = BindStringAction(
|
||||
menuChangeName,
|
||||
binding);
|
||||
if (menuChangeName is not null && menuChanged is null)
|
||||
{
|
||||
throw new FormatException(
|
||||
$"<menu onchange=\"{menuChangeName}\"> did not resolve to an "
|
||||
+ $"Action<string> property on {binding.GetType().Name}");
|
||||
}
|
||||
Func<IReadOnlyList<string>> menuItems = BindStringList(
|
||||
(string?)el.Attribute("items"),
|
||||
binding,
|
||||
"menu items");
|
||||
Func<string?> menuSelected = BindString(
|
||||
(string?)el.Attribute("selected"),
|
||||
binding);
|
||||
var menu = new UiMenu
|
||||
{
|
||||
Left = F(el, "x"),
|
||||
Top = F(el, "y"),
|
||||
Width = F(el, "w"),
|
||||
Height = F(el, "h"),
|
||||
DatFont = datFont,
|
||||
SpriteResolve = resolve,
|
||||
RowsPerColumn = Math.Max(1, I(el, "rows", 7)),
|
||||
RowHeight = Math.Max(12f, FOr(el, "rowheight", 18f)),
|
||||
ColumnWidth = Math.Max(20f, F(el, "w")),
|
||||
OpenUpward = B(el, "openupward", false),
|
||||
TextIndent = 6f,
|
||||
ButtonTextIndent = 6f,
|
||||
NormalSprite = 0x06004D65u,
|
||||
PressedSprite = 0x06004D66u,
|
||||
PopupBgSprite = 0x0600124Cu,
|
||||
ItemNormalSprite = 0x0600124Eu,
|
||||
ItemHighlightSprite = 0x0600124Du,
|
||||
ButtonLabelProvider = () => menuSelected() ?? string.Empty,
|
||||
OnSelect = payload =>
|
||||
{
|
||||
if (payload is string value)
|
||||
menuChanged?.Invoke(value);
|
||||
},
|
||||
};
|
||||
void RefreshMenu()
|
||||
{
|
||||
menu.Items = menuItems()
|
||||
.Select(static value => new UiMenu.MenuItem(value, value))
|
||||
.ToArray();
|
||||
menu.Selected = menuSelected();
|
||||
}
|
||||
RefreshMenu();
|
||||
menu.BeforeOpen = RefreshMenu;
|
||||
ApplyCommon(menu, el, binding);
|
||||
parent.AddChild(menu);
|
||||
break;
|
||||
|
||||
case "list":
|
||||
string? listChangeName = (string?)el.Attribute("onchange");
|
||||
Action<int>? listChanged = BindIntAction(listChangeName, binding);
|
||||
if (listChangeName is not null && listChanged is null)
|
||||
{
|
||||
throw new FormatException(
|
||||
$"<list onchange=\"{listChangeName}\"> did not resolve to an "
|
||||
+ $"Action<int> property on {binding.GetType().Name}");
|
||||
}
|
||||
var list = new UiMarkupList
|
||||
{
|
||||
Left = F(el, "x"),
|
||||
Top = F(el, "y"),
|
||||
Width = F(el, "w"),
|
||||
Height = F(el, "h"),
|
||||
RowHeight = Math.Max(12f, FOr(el, "rowheight", 18f)),
|
||||
DatFont = datFont,
|
||||
ItemsSource = BindStringList(
|
||||
(string?)el.Attribute("items"),
|
||||
binding,
|
||||
"list items"),
|
||||
ItemColorsSource = BindUintList(
|
||||
(string?)el.Attribute("colors"),
|
||||
binding,
|
||||
"list colors"),
|
||||
SelectedIndexSource = BindRequiredIntReader(
|
||||
(string?)el.Attribute("selected"),
|
||||
binding,
|
||||
"list selected"),
|
||||
SelectionChanged = listChanged,
|
||||
};
|
||||
ApplyCommon(list, el, binding);
|
||||
parent.AddChild(list);
|
||||
break;
|
||||
}
|
||||
return panel;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -197,13 +470,197 @@ public static class MarkupDocument
|
|||
return () => (property.GetValue(binding) as Action)?.Invoke();
|
||||
}
|
||||
|
||||
private static Action<float>? BindFloatAction(
|
||||
string? attribute,
|
||||
object binding)
|
||||
{
|
||||
if (attribute is null || !IsBinding(attribute))
|
||||
return null;
|
||||
|
||||
string name = attribute[1..^1];
|
||||
PropertyInfo? property = binding.GetType().GetProperty(name);
|
||||
if (property is null
|
||||
|| !typeof(Action<float>).IsAssignableFrom(property.PropertyType))
|
||||
return null;
|
||||
return value => (property.GetValue(binding) as Action<float>)?.Invoke(value);
|
||||
}
|
||||
|
||||
private static Action<string>? BindStringAction(
|
||||
string? attribute,
|
||||
object binding)
|
||||
{
|
||||
if (attribute is null || !IsBinding(attribute))
|
||||
return null;
|
||||
|
||||
string name = attribute[1..^1];
|
||||
PropertyInfo? property = binding.GetType().GetProperty(name);
|
||||
if (property is null
|
||||
|| !typeof(Action<string>).IsAssignableFrom(property.PropertyType))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return value => (property.GetValue(binding) as Action<string>)?.Invoke(value);
|
||||
}
|
||||
|
||||
private static Action<int>? BindIntAction(string? attribute, object binding)
|
||||
{
|
||||
if (attribute is null || !IsBinding(attribute))
|
||||
return null;
|
||||
PropertyInfo? property = binding.GetType().GetProperty(attribute[1..^1]);
|
||||
if (property is null
|
||||
|| !typeof(Action<int>).IsAssignableFrom(property.PropertyType))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return value => (property.GetValue(binding) as Action<int>)?.Invoke(value);
|
||||
}
|
||||
|
||||
private static Func<IReadOnlyList<string>> BindStringList(
|
||||
string? expression,
|
||||
object binding,
|
||||
string context)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(expression) || !IsBinding(expression))
|
||||
throw new FormatException($"{context} must be a string-list binding");
|
||||
PropertyInfo? property = binding.GetType().GetProperty(expression[1..^1]);
|
||||
if (property is null
|
||||
|| !typeof(IEnumerable<string>).IsAssignableFrom(property.PropertyType))
|
||||
{
|
||||
throw new FormatException(
|
||||
$"{expression} did not resolve to an IEnumerable<string> property on "
|
||||
+ binding.GetType().Name);
|
||||
}
|
||||
return () => property.GetValue(binding) is IEnumerable<string> values
|
||||
? values.ToArray()
|
||||
: Array.Empty<string>();
|
||||
}
|
||||
|
||||
private static Func<IReadOnlyList<uint>> BindUintList(
|
||||
string? expression,
|
||||
object binding,
|
||||
string context)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(expression))
|
||||
return static () => Array.Empty<uint>();
|
||||
if (!IsBinding(expression))
|
||||
throw new FormatException($"{context} must be a uint-list binding");
|
||||
PropertyInfo? property = binding.GetType().GetProperty(expression[1..^1]);
|
||||
if (property is null
|
||||
|| !typeof(IEnumerable<uint>).IsAssignableFrom(property.PropertyType))
|
||||
{
|
||||
throw new FormatException(
|
||||
$"{expression} did not resolve to an IEnumerable<uint> property on "
|
||||
+ binding.GetType().Name);
|
||||
}
|
||||
return () => property.GetValue(binding) is IEnumerable<uint> values
|
||||
? values.ToArray()
|
||||
: Array.Empty<uint>();
|
||||
}
|
||||
|
||||
private static bool IsBinding(string value) =>
|
||||
value.Length > 2 && value[0] == '{' && value[^1] == '}';
|
||||
|
||||
private static void ApplyCommon(
|
||||
UiElement element,
|
||||
XElement source,
|
||||
object binding)
|
||||
{
|
||||
element.Name = (string?)source.Attribute("name")
|
||||
?? (string?)source.Attribute("id");
|
||||
BindBool((string?)source.Attribute("visible"), binding,
|
||||
value => element.Visible = value,
|
||||
sourceReader => element.VisibleSource = sourceReader);
|
||||
BindBool((string?)source.Attribute("enabled"), binding,
|
||||
value => element.Enabled = value,
|
||||
sourceReader => element.EnabledSource = sourceReader);
|
||||
|
||||
string? tooltip = (string?)source.Attribute("tooltip");
|
||||
if (!string.IsNullOrWhiteSpace(tooltip))
|
||||
{
|
||||
element.RuntimeTooltipTextSource = BindString(tooltip, binding);
|
||||
element.AuthoredTooltipRootElementId = RuntimeTooltipRootElementId;
|
||||
element.AuthoredTooltipLayoutDid = RuntimeTooltipLayoutDid;
|
||||
element.AuthoredTooltipEnabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
private static void BindBool(
|
||||
string? expression,
|
||||
object binding,
|
||||
Action<bool> setLiteral,
|
||||
Action<Func<bool>> setSource)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(expression))
|
||||
return;
|
||||
if (!IsBinding(expression))
|
||||
{
|
||||
if (bool.TryParse(expression, out bool literal))
|
||||
setLiteral(literal);
|
||||
return;
|
||||
}
|
||||
|
||||
PropertyInfo? property = binding.GetType().GetProperty(expression[1..^1]);
|
||||
if (property is null || property.PropertyType != typeof(bool))
|
||||
{
|
||||
throw new FormatException(
|
||||
$"{expression} did not resolve to a bool property on "
|
||||
+ binding.GetType().Name);
|
||||
}
|
||||
setSource(() => property.GetValue(binding) is true);
|
||||
}
|
||||
|
||||
private static Func<bool> BindRequiredBoolReader(
|
||||
string? expression,
|
||||
object binding,
|
||||
string context)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(expression) || !IsBinding(expression))
|
||||
throw new FormatException($"{context} must be a bool binding");
|
||||
|
||||
PropertyInfo? property = binding.GetType().GetProperty(expression[1..^1]);
|
||||
if (property is null || property.PropertyType != typeof(bool))
|
||||
{
|
||||
throw new FormatException(
|
||||
$"{expression} did not resolve to a bool property on "
|
||||
+ binding.GetType().Name);
|
||||
}
|
||||
return () => property.GetValue(binding) is true;
|
||||
}
|
||||
|
||||
private static Func<int> BindRequiredIntReader(
|
||||
string? expression,
|
||||
object binding,
|
||||
string context)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(expression) || !IsBinding(expression))
|
||||
throw new FormatException($"{context} must be an int binding");
|
||||
PropertyInfo? property = binding.GetType().GetProperty(expression[1..^1]);
|
||||
if (property is null || property.PropertyType != typeof(int))
|
||||
{
|
||||
throw new FormatException(
|
||||
$"{expression} did not resolve to an int property on "
|
||||
+ binding.GetType().Name);
|
||||
}
|
||||
return () => property.GetValue(binding) is int value ? value : -1;
|
||||
}
|
||||
|
||||
private static float F(XElement e, string attr)
|
||||
=> float.TryParse((string?)e.Attribute(attr), NumberStyles.Float,
|
||||
CultureInfo.InvariantCulture, out var v) ? v : 0f;
|
||||
|
||||
private static float FOr(XElement e, string attr, float fallback)
|
||||
=> float.TryParse((string?)e.Attribute(attr), NumberStyles.Float,
|
||||
CultureInfo.InvariantCulture, out float value) ? value : fallback;
|
||||
|
||||
private static int I(XElement e, string attr, int fallback)
|
||||
=> int.TryParse((string?)e.Attribute(attr), NumberStyles.Integer,
|
||||
CultureInfo.InvariantCulture, out int value) ? value : fallback;
|
||||
|
||||
private static bool B(XElement e, string attr, bool fallback)
|
||||
=> bool.TryParse((string?)e.Attribute(attr), out bool value)
|
||||
? value
|
||||
: fallback;
|
||||
|
||||
/// <summary>
|
||||
/// Parses <c>#AARRGGBB</c> → RGBA <see cref="Vector4"/> (alpha first, matching
|
||||
/// controls.ini convention). Falls back to opaque white on bad input.
|
||||
|
|
|
|||
355
src/AcDream.App/UI/PluginSidePanel.cs
Normal file
355
src/AcDream.App/UI/PluginSidePanel.cs
Normal file
|
|
@ -0,0 +1,355 @@
|
|||
using System.Numerics;
|
||||
using AcDream.Plugin.Abstractions;
|
||||
|
||||
namespace AcDream.App.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Host-owned shelf for running gameplay plugins. A shelf button changes only
|
||||
/// presentation visibility; it never touches plugin enable/session lifetime.
|
||||
/// </summary>
|
||||
public sealed class PluginSidePanel : UiPanel, IDisposable
|
||||
{
|
||||
private const float OuterPadding = 4f;
|
||||
private const float ButtonExtent = 28f;
|
||||
private const float ButtonGap = 4f;
|
||||
private const float DefaultTop = 116f;
|
||||
|
||||
private readonly RetailWindowManager _windows;
|
||||
private readonly Func<uint, (uint tex, int width, int height)> _resolve;
|
||||
private readonly UiDatFont? _font;
|
||||
private readonly Dictionary<RetailWindowHandle, ShelfEntry> _entries = [];
|
||||
private bool _disposed;
|
||||
private float _lastLayoutHeight = -1f;
|
||||
|
||||
public PluginSidePanel(
|
||||
RetailWindowManager windows,
|
||||
Func<uint, (uint tex, int width, int height)> resolve,
|
||||
UiDatFont? font)
|
||||
{
|
||||
_windows = windows ?? throw new ArgumentNullException(nameof(windows));
|
||||
_resolve = resolve ?? throw new ArgumentNullException(nameof(resolve));
|
||||
_font = font;
|
||||
|
||||
Width = ButtonExtent + OuterPadding * 2f;
|
||||
Height = OuterPadding * 2f;
|
||||
Top = DefaultTop;
|
||||
Anchors = AnchorEdges.None;
|
||||
Draggable = false;
|
||||
Resizable = false;
|
||||
BackgroundColor = new Vector4(0f, 0f, 0f, 0.88f);
|
||||
BorderColor = new Vector4(0.62f, 0.48f, 0.16f, 1f);
|
||||
BorderThickness = 1f;
|
||||
Visible = false;
|
||||
|
||||
_windows.WindowUnregistered += OnWindowUnregistered;
|
||||
}
|
||||
|
||||
/// <summary>Number of live plugin-window entries, exposed for gates.</summary>
|
||||
public int EntryCount => _entries.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Adds one manifest-scoped plugin window and its minimize affordance.
|
||||
/// Duplicate handles are idempotent.
|
||||
/// </summary>
|
||||
public void Add(
|
||||
PluginUiOwner owner,
|
||||
PluginPanelDescriptor descriptor,
|
||||
RetailWindowHandle handle)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(owner.Id);
|
||||
ArgumentNullException.ThrowIfNull(descriptor);
|
||||
ArgumentNullException.ThrowIfNull(handle);
|
||||
if (_entries.ContainsKey(handle))
|
||||
return;
|
||||
|
||||
// Plugin windows are ordinary retained windows, but unlike imported
|
||||
// retail windows they have no authored MoveTo override. Keep their
|
||||
// chrome reachable at the minimum 800x600 canvas and after a display
|
||||
// resize. An oversized window follows retail's top-left-priority rule:
|
||||
// pin to zero rather than stranding the title/minimize controls.
|
||||
handle.OuterFrame.ConstrainDragToParent = true;
|
||||
handle.OuterFrame.ConstrainResizeToParent = true;
|
||||
KeepWindowReachable(handle);
|
||||
|
||||
var button = new PluginShelfButton(
|
||||
descriptor,
|
||||
owner.DisplayName,
|
||||
handle,
|
||||
_resolve,
|
||||
_font)
|
||||
{
|
||||
Width = ButtonExtent,
|
||||
Height = ButtonExtent,
|
||||
};
|
||||
button.Click += () =>
|
||||
{
|
||||
if (handle.IsVisible)
|
||||
handle.Hide();
|
||||
else
|
||||
handle.Show();
|
||||
};
|
||||
|
||||
var minimize = new PluginMinimizeButton(handle, _font)
|
||||
{
|
||||
Left = MathF.Max(8f, handle.OuterFrame.Width - 23f),
|
||||
Top = 3f,
|
||||
Width = 18f,
|
||||
Height = 17f,
|
||||
Anchors = AnchorEdges.Top | AnchorEdges.Right,
|
||||
};
|
||||
handle.OuterFrame.AddChild(minimize);
|
||||
|
||||
_entries.Add(handle, new ShelfEntry(button, minimize));
|
||||
AddChild(button);
|
||||
Reflow();
|
||||
}
|
||||
|
||||
protected override void OnTick(double deltaSeconds)
|
||||
{
|
||||
base.OnTick(deltaSeconds);
|
||||
|
||||
// Screen-edge dock: root bounds become authoritative at draw time, so
|
||||
// compute this from the live parent rather than capturing an anchor
|
||||
// margin while the pre-first-frame root still measures 0x0.
|
||||
if (Parent is { } parent)
|
||||
{
|
||||
float availableHeight = MathF.Max(
|
||||
ButtonExtent + OuterPadding * 2f,
|
||||
parent.Height - Top - OuterPadding);
|
||||
if (MathF.Abs(availableHeight - _lastLayoutHeight) > 0.5f)
|
||||
{
|
||||
_lastLayoutHeight = availableHeight;
|
||||
Reflow(availableHeight);
|
||||
}
|
||||
Left = MathF.Max(0f, parent.Width - Width - OuterPadding);
|
||||
}
|
||||
|
||||
foreach (RetailWindowHandle handle in _entries.Keys)
|
||||
KeepWindowReachable(handle);
|
||||
|
||||
// The shelf remains reachable even after ordinary windows are raised.
|
||||
if (Parent is { } root)
|
||||
{
|
||||
int highest = 0;
|
||||
foreach (UiElement sibling in root.Children)
|
||||
{
|
||||
if (!ReferenceEquals(sibling, this))
|
||||
highest = Math.Max(highest, sibling.ZOrder);
|
||||
}
|
||||
if (ZOrder <= highest)
|
||||
ZOrder = highest == int.MaxValue ? highest : highest + 1;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnWindowUnregistered(RetailWindowHandle handle)
|
||||
{
|
||||
if (!_entries.Remove(handle, out ShelfEntry entry))
|
||||
return;
|
||||
|
||||
RemoveChild(entry.Button);
|
||||
if (ReferenceEquals(entry.Minimize.Parent, handle.OuterFrame))
|
||||
handle.OuterFrame.RemoveChild(entry.Minimize);
|
||||
entry.Button.DisposeSubscriptions();
|
||||
Reflow();
|
||||
}
|
||||
|
||||
private static void KeepWindowReachable(RetailWindowHandle handle)
|
||||
{
|
||||
if (handle.OuterFrame.Parent is not { } parent
|
||||
|| parent.Width <= 0f
|
||||
|| parent.Height <= 0f)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
float left = Math.Clamp(
|
||||
handle.Left,
|
||||
0f,
|
||||
MathF.Max(0f, parent.Width - handle.Width));
|
||||
float top = Math.Clamp(
|
||||
handle.Top,
|
||||
0f,
|
||||
MathF.Max(0f, parent.Height - handle.Height));
|
||||
if (left != handle.Left || top != handle.Top)
|
||||
handle.MoveTo(left, top);
|
||||
}
|
||||
|
||||
private void Reflow(float maximumHeight = float.PositiveInfinity)
|
||||
{
|
||||
int maximumRows = float.IsPositiveInfinity(maximumHeight)
|
||||
? Math.Max(1, _entries.Count)
|
||||
: Math.Max(
|
||||
1,
|
||||
(int)MathF.Floor(
|
||||
(maximumHeight - OuterPadding * 2f + ButtonGap)
|
||||
/ (ButtonExtent + ButtonGap)));
|
||||
int index = 0;
|
||||
foreach (ShelfEntry entry in _entries.Values)
|
||||
{
|
||||
int column = index / maximumRows;
|
||||
int row = index % maximumRows;
|
||||
entry.Button.Left = OuterPadding
|
||||
+ column * (ButtonExtent + ButtonGap);
|
||||
entry.Button.Top = OuterPadding
|
||||
+ row * (ButtonExtent + ButtonGap);
|
||||
index++;
|
||||
}
|
||||
|
||||
int rows = Math.Min(index, maximumRows);
|
||||
int columns = index == 0 ? 1 : (index + maximumRows - 1) / maximumRows;
|
||||
Width = OuterPadding * 2f
|
||||
+ columns * ButtonExtent
|
||||
+ Math.Max(0, columns - 1) * ButtonGap;
|
||||
Height = OuterPadding * 2f
|
||||
+ rows * ButtonExtent
|
||||
+ Math.Max(0, rows - 1) * ButtonGap;
|
||||
Visible = index > 0;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
_disposed = true;
|
||||
_windows.WindowUnregistered -= OnWindowUnregistered;
|
||||
|
||||
foreach ((RetailWindowHandle handle, ShelfEntry entry) in _entries)
|
||||
{
|
||||
entry.Button.DisposeSubscriptions();
|
||||
if (ReferenceEquals(entry.Minimize.Parent, handle.OuterFrame))
|
||||
handle.OuterFrame.RemoveChild(entry.Minimize);
|
||||
}
|
||||
_entries.Clear();
|
||||
Visible = false;
|
||||
}
|
||||
|
||||
private readonly record struct ShelfEntry(
|
||||
PluginShelfButton Button,
|
||||
PluginMinimizeButton Minimize);
|
||||
|
||||
private sealed class PluginShelfButton : UiSimpleButton
|
||||
{
|
||||
private static readonly Vector4 HiddenBackground =
|
||||
new(0.025f, 0.025f, 0.02f, 0.96f);
|
||||
private static readonly Vector4 VisibleBackground =
|
||||
new(0.09f, 0.19f, 0.055f, 0.96f);
|
||||
private static readonly Vector4 HiddenBorder =
|
||||
new(0.48f, 0.38f, 0.14f, 1f);
|
||||
private static readonly Vector4 VisibleBorder =
|
||||
new(0.76f, 0.64f, 0.25f, 1f);
|
||||
|
||||
private readonly RetailWindowHandle _handle;
|
||||
private readonly Func<uint, (uint tex, int width, int height)> _resolve;
|
||||
private readonly uint _iconSurfaceId;
|
||||
private readonly string _tooltip;
|
||||
|
||||
internal PluginShelfButton(
|
||||
PluginPanelDescriptor descriptor,
|
||||
string ownerDisplayName,
|
||||
RetailWindowHandle handle,
|
||||
Func<uint, (uint tex, int width, int height)> resolve,
|
||||
UiDatFont? font)
|
||||
{
|
||||
_handle = handle;
|
||||
_resolve = resolve;
|
||||
_iconSurfaceId = descriptor.IconSurfaceId;
|
||||
_tooltip = string.Equals(descriptor.Title, ownerDisplayName,
|
||||
StringComparison.Ordinal)
|
||||
? descriptor.Title
|
||||
: $"{ownerDisplayName} — {descriptor.Title}";
|
||||
Text = _iconSurfaceId == 0
|
||||
? Initials(descriptor.IconText, descriptor.Title)
|
||||
: string.Empty;
|
||||
DatFont = font;
|
||||
Outline = true;
|
||||
BorderThickness = 1f;
|
||||
_handle.Shown += OnVisibilityChanged;
|
||||
_handle.Hidden += OnVisibilityChanged;
|
||||
RefreshPresentation();
|
||||
}
|
||||
|
||||
public override string? GetTooltipText() => _tooltip;
|
||||
|
||||
protected override void OnTick(double deltaSeconds)
|
||||
{
|
||||
base.OnTick(deltaSeconds);
|
||||
RefreshPresentation();
|
||||
}
|
||||
|
||||
protected override void OnDraw(UiRenderContext ctx)
|
||||
{
|
||||
base.OnDraw(ctx);
|
||||
if (_iconSurfaceId == 0)
|
||||
return;
|
||||
|
||||
(uint texture, int width, int height) = _resolve(_iconSurfaceId);
|
||||
if (texture == 0 || width <= 0 || height <= 0)
|
||||
return;
|
||||
float extent = MathF.Min(Width - 6f, Height - 6f);
|
||||
ctx.DrawSprite(
|
||||
texture,
|
||||
(Width - extent) * 0.5f,
|
||||
(Height - extent) * 0.5f,
|
||||
extent,
|
||||
extent,
|
||||
0f,
|
||||
0f,
|
||||
1f,
|
||||
1f,
|
||||
Vector4.One);
|
||||
}
|
||||
|
||||
internal void DisposeSubscriptions()
|
||||
{
|
||||
_handle.Shown -= OnVisibilityChanged;
|
||||
_handle.Hidden -= OnVisibilityChanged;
|
||||
}
|
||||
|
||||
private void OnVisibilityChanged(RetailWindowHandle _) =>
|
||||
RefreshPresentation();
|
||||
|
||||
private void RefreshPresentation()
|
||||
{
|
||||
BackgroundColor = _handle.IsVisible
|
||||
? VisibleBackground
|
||||
: HiddenBackground;
|
||||
BorderColor = _handle.IsVisible ? VisibleBorder : HiddenBorder;
|
||||
}
|
||||
|
||||
private static string Initials(string? requested, string title)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(requested))
|
||||
return requested.Trim()[..Math.Min(3, requested.Trim().Length)];
|
||||
|
||||
string[] words = title.Split(
|
||||
' ',
|
||||
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
if (words.Length == 0)
|
||||
return "?";
|
||||
if (words.Length == 1)
|
||||
return words[0][..Math.Min(2, words[0].Length)].ToUpperInvariant();
|
||||
return string.Concat(words.Take(2).Select(static word =>
|
||||
char.ToUpperInvariant(word[0])));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class PluginMinimizeButton : UiSimpleButton
|
||||
{
|
||||
private readonly RetailWindowHandle _handle;
|
||||
|
||||
internal PluginMinimizeButton(RetailWindowHandle handle, UiDatFont? font)
|
||||
{
|
||||
_handle = handle;
|
||||
Text = "–";
|
||||
DatFont = font;
|
||||
Outline = true;
|
||||
BackgroundColor = new Vector4(0.02f, 0.02f, 0.015f, 0.94f);
|
||||
BorderColor = new Vector4(0.58f, 0.46f, 0.17f, 1f);
|
||||
BorderThickness = 1f;
|
||||
Click += () => _handle.Hide();
|
||||
}
|
||||
|
||||
public override string? GetTooltipText() => "Minimize to plugin sidepanel";
|
||||
}
|
||||
}
|
||||
|
|
@ -25,6 +25,7 @@ using AcDream.UI.Abstractions.Panels.Chat;
|
|||
using AcDream.UI.Abstractions.Panels.Settings;
|
||||
using AcDream.UI.Abstractions.Panels.Vitals;
|
||||
using AcDream.UI.Abstractions.Input;
|
||||
using AcDream.Plugin.Abstractions;
|
||||
using DatReaderWriter;
|
||||
using Silk.NET.Input;
|
||||
|
||||
|
|
@ -531,7 +532,9 @@ public sealed record RetailUiRuntimeBindings(
|
|||
CharacterSelectionRuntimeBindings? CharacterSelection = null,
|
||||
// Campaign CC slice CC4: sibling of CharacterSelection above.
|
||||
CharacterCreationRuntimeBindings? CharacterCreation = null,
|
||||
Action? CaptureScreenshot = null);
|
||||
Action? CaptureScreenshot = null,
|
||||
Func<IReadOnlyList<PluginProjectileDebugSample>>?
|
||||
ProjectileDebugSamples = null);
|
||||
|
||||
/// <summary>
|
||||
/// Composition owner for the production retained gameplay UI. GameWindow supplies
|
||||
|
|
@ -553,9 +556,11 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
private UiShortcutDigitGraphics? _shortcutDigitGraphics;
|
||||
private ItemCooldownUiController? _itemCooldownController;
|
||||
private VividTargetIndicatorController? _vividTargetIndicator;
|
||||
private ProjectileDebugOverlayController? _projectileDebugOverlay;
|
||||
private Layout.VitalsSideBySideController? _vitalsSideBySide;
|
||||
private CharacterManagementUiMountCoordinator? _characterManagementMount;
|
||||
private CharacterCreationUiMountCoordinator? _characterCreationMount;
|
||||
private PluginSidePanel? _pluginSidePanel;
|
||||
private IDisposable? _characterSheetSubscription;
|
||||
private Layout.CharacterTitlesController? _characterTitlesController;
|
||||
private ResourceShutdownTransaction? _shutdown;
|
||||
|
|
@ -602,6 +607,7 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
RetailUiRuntimeBindings bindings = _bindings;
|
||||
MountFpsDisplay();
|
||||
MountVividTargetIndicator();
|
||||
MountProjectileDebugOverlay();
|
||||
MountVitals();
|
||||
MountRadar();
|
||||
MountChat();
|
||||
|
|
@ -935,6 +941,7 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
Layout.UiMediaClock.Advance(deltaSeconds);
|
||||
FpsController?.Tick();
|
||||
_vividTargetIndicator?.Tick();
|
||||
_projectileDebugOverlay?.Tick();
|
||||
_vitalsSideBySide?.Tick();
|
||||
SpellbookWindowController?.Tick();
|
||||
AppraisalController?.Tick(deltaSeconds);
|
||||
|
|
@ -1655,6 +1662,18 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
: "[D.2b] vivid target indicator mounted from client-enum category 0x10000009.");
|
||||
}
|
||||
|
||||
private void MountProjectileDebugOverlay()
|
||||
{
|
||||
if (_bindings.ProjectileDebugSamples is not { } samples)
|
||||
return;
|
||||
_projectileDebugOverlay = ProjectileDebugOverlayController.Mount(
|
||||
Host.Root,
|
||||
samples,
|
||||
_bindings.VividTarget.Camera);
|
||||
Console.WriteLine(
|
||||
"[PluginUI] projectile collision debug overlay mounted.");
|
||||
}
|
||||
|
||||
private void MountVitals()
|
||||
{
|
||||
ImportedLayout? layout = Import(0x2100006Cu);
|
||||
|
|
@ -4608,16 +4627,64 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
{
|
||||
try
|
||||
{
|
||||
string xml = File.ReadAllText(panel.MarkupPath);
|
||||
UiElement element = MarkupDocument.Build(
|
||||
string xml = panel.MarkupContent
|
||||
?? File.ReadAllText(panel.MarkupPath);
|
||||
UiNineSlicePanel element = MarkupDocument.Build(
|
||||
xml,
|
||||
panel.Binding,
|
||||
_bindings.Assets.ResolveSprite,
|
||||
_bindings.Assets.Controls,
|
||||
_bindings.Assets.DefaultFont);
|
||||
|
||||
if (Host.WindowManager.TryGet(panel.WindowName, out _))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Plugin window '{panel.WindowName}' is already registered. "
|
||||
+ "Window ids must be unique within one plugin.");
|
||||
}
|
||||
|
||||
// Markup's root visibility is an availability gate (for example,
|
||||
// a world-only panel), while the descriptor/persisted state is the
|
||||
// user's minimize choice. Keep those two axes independent so an
|
||||
// availability transition never disables the running plugin or
|
||||
// forgets that the user wanted its window open.
|
||||
Func<bool>? availability = element.VisibleSource;
|
||||
var visibility = new PluginWindowVisibilityController(
|
||||
availability,
|
||||
panel.Descriptor.StartVisible);
|
||||
element.VisibleSource = visibility.ShouldBeVisible;
|
||||
element.Visible = visibility.ShouldBeVisible();
|
||||
|
||||
Host.Root.AddChild(element);
|
||||
// Publish ownership immediately after the tree mutation. Any
|
||||
// later registration/sidepanel failure then rolls the mounted
|
||||
// subtree back through FailMount instead of leaking it.
|
||||
_bindings.Plugins.CompleteMount(panel, Host.Root, element);
|
||||
Console.WriteLine($"[D.2b] plugin UI panel loaded: {panel.MarkupPath}");
|
||||
RetailWindowHandle handle = Host.WindowManager.Register(
|
||||
panel.WindowName,
|
||||
element,
|
||||
element,
|
||||
visibility);
|
||||
_bindings.Plugins.CompleteWindowMount(
|
||||
panel,
|
||||
() => Host.WindowManager.Unregister(panel.WindowName));
|
||||
|
||||
if (panel.Descriptor.ShowInSidePanel)
|
||||
{
|
||||
if (_pluginSidePanel is null)
|
||||
{
|
||||
_pluginSidePanel = new PluginSidePanel(
|
||||
Host.WindowManager,
|
||||
_bindings.Assets.ResolveSprite,
|
||||
_bindings.Assets.DefaultFont);
|
||||
Host.Root.AddChild(_pluginSidePanel);
|
||||
}
|
||||
_pluginSidePanel.Add(panel.Owner, panel.Descriptor, handle);
|
||||
}
|
||||
|
||||
Console.WriteLine(
|
||||
$"[D.2b] plugin UI window loaded: {panel.WindowName} "
|
||||
+ $"({panel.MarkupPath})");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
|
@ -5312,6 +5379,7 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
{
|
||||
_characterSheetSubscription?.Dispose();
|
||||
_characterTitlesController?.Dispose();
|
||||
_pluginSidePanel?.Dispose();
|
||||
Host.WindowManager.WindowVisibilityChanged -= OnWindowVisibilityChanged;
|
||||
WindowLockPresentation.Dispose();
|
||||
WindowOpacity.Dispose();
|
||||
|
|
@ -5339,6 +5407,36 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
_disposed = _shutdown.IsComplete;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Separates plugin availability from the user's minimized/open choice.
|
||||
/// Window-manager callbacks update only the latter; a false availability
|
||||
/// predicate hides temporarily without forgetting the requested state.
|
||||
/// </summary>
|
||||
private sealed class PluginWindowVisibilityController(
|
||||
Func<bool>? availability,
|
||||
bool startVisible) : IRetainedPanelController
|
||||
{
|
||||
private bool _requestedVisible = startVisible;
|
||||
|
||||
internal bool ShouldBeVisible() =>
|
||||
_requestedVisible && (availability?.Invoke() ?? true);
|
||||
|
||||
public void OnShown() => _requestedVisible = true;
|
||||
|
||||
public void OnHidden()
|
||||
{
|
||||
// Hidden because the markup's availability gate went false is
|
||||
// temporary. Hidden while available is a real minimize/restore-
|
||||
// persistence transition and changes the requested state.
|
||||
if (availability?.Invoke() ?? true)
|
||||
_requestedVisible = false;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
internal static ResourceShutdownTransaction CreateShutdownTransaction(
|
||||
Action disposeAutomation,
|
||||
Action disposePersistence,
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ public abstract class UiElement
|
|||
public uint DatElementId { get; internal set; }
|
||||
|
||||
/// <summary>Human-readable name for debugging / FindByName.</summary>
|
||||
public string? Name { get; init; }
|
||||
public string? Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// GF-13 (Campaign CC gate round 1, Batch A): mirrors
|
||||
|
|
@ -274,6 +274,12 @@ public abstract class UiElement
|
|||
/// </summary>
|
||||
public Func<bool>? VisibleSource { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional live enabled reader. Declarative plugin controls use this to
|
||||
/// expose unavailable/busy state without retaining presentation objects.
|
||||
/// </summary>
|
||||
public Func<bool>? EnabledSource { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// If true, <see cref="UiRoot"/> will set focus here on click,
|
||||
/// routing WM_KEYDOWN / WM_CHAR to <see cref="OnEvent"/> as
|
||||
|
|
@ -642,7 +648,19 @@ public abstract class UiElement
|
|||
/// hover delay (0.25 seconds by default), then queries the widget's virtual "GetString"
|
||||
/// (vtable +0x88) to render the tooltip body.
|
||||
/// </summary>
|
||||
public virtual string? GetTooltipText() => null;
|
||||
/// <remarks>
|
||||
/// Runtime-created/plugin-markup widgets have no LayoutDesc property bag from
|
||||
/// which to import P0x49. They use this live source instead; the markup host
|
||||
/// still supplies retail's shared tooltip-popup locator, so presentation stays
|
||||
/// inside the common retained tooltip pipeline rather than becoming plugin UI.
|
||||
/// </remarks>
|
||||
public Func<string?>? RuntimeTooltipTextSource { get; set; }
|
||||
|
||||
public virtual string? GetTooltipText()
|
||||
{
|
||||
string? text = RuntimeTooltipTextSource?.Invoke();
|
||||
return string.IsNullOrWhiteSpace(text) ? null : text;
|
||||
}
|
||||
|
||||
// ── Framework entry points (internal, called by UiRoot) ─────────────
|
||||
|
||||
|
|
@ -763,6 +781,8 @@ public abstract class UiElement
|
|||
if (VisibleSource is { } visibility)
|
||||
Visible = visibility();
|
||||
if (!Visible) return;
|
||||
if (EnabledSource is { } enabled)
|
||||
Enabled = enabled();
|
||||
OnTick(dt);
|
||||
for (int i = 0; i < _children.Count; i++)
|
||||
_children[i].TickSelfAndChildren(dt);
|
||||
|
|
|
|||
|
|
@ -110,6 +110,13 @@ public sealed class UiField : UiElement
|
|||
public Action<string>? OnSubmit { get; set; }
|
||||
public Action? OnFocusGained { get; set; }
|
||||
public Action<string>? OnFocusLost { get; set; }
|
||||
/// <summary>
|
||||
/// Live text mutation callback used by retained plugin markup. This is
|
||||
/// deliberately separate from submit/focus-loss: editors need their
|
||||
/// binding model to track typing so an adjacent button can consume the
|
||||
/// current value without reaching into the widget tree.
|
||||
/// </summary>
|
||||
public Action<string>? OnTextChanged { get; set; }
|
||||
|
||||
private string _textValue = "";
|
||||
|
||||
|
|
@ -127,8 +134,11 @@ public sealed class UiField : UiElement
|
|||
get => _textValue;
|
||||
set
|
||||
{
|
||||
if (string.Equals(_textValue, value, StringComparison.Ordinal))
|
||||
return;
|
||||
_textValue = value;
|
||||
_textVersion++;
|
||||
OnTextChanged?.Invoke(value);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
96
src/AcDream.App/UI/UiMarkupList.cs
Normal file
96
src/AcDream.App/UI/UiMarkupList.cs
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
using System.Numerics;
|
||||
|
||||
namespace AcDream.App.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Lightweight, data-bound string list for plugin markup. It owns only row
|
||||
/// selection and scroll position; the plugin binding remains the sole owner of
|
||||
/// rows and selected index. This deliberately avoids exposing App widget types
|
||||
/// through the BCL plugin contract.
|
||||
/// </summary>
|
||||
public sealed class UiMarkupList : UiElement
|
||||
{
|
||||
public Func<IReadOnlyList<string>> ItemsSource { get; set; } =
|
||||
static () => Array.Empty<string>();
|
||||
public Func<IReadOnlyList<uint>> ItemColorsSource { get; set; } =
|
||||
static () => Array.Empty<uint>();
|
||||
public Func<int> SelectedIndexSource { get; set; } = static () => -1;
|
||||
public Action<int>? SelectionChanged { get; set; }
|
||||
public UiDatFont? DatFont { get; set; }
|
||||
public float RowHeight { get; set; } = 18f;
|
||||
public float Padding { get; set; } = 3f;
|
||||
public Vector4 BackgroundColor { get; set; } = new(0f, 0f, 0f, 0.92f);
|
||||
public Vector4 BorderColor { get; set; } = new(0.46f, 0.37f, 0.16f, 1f);
|
||||
public Vector4 TextColor { get; set; } = new(0.91f, 0.87f, 0.76f, 1f);
|
||||
public Vector4 SelectedColor { get; set; } = new(0.28f, 0.23f, 0.08f, 0.95f);
|
||||
|
||||
private int _topRow;
|
||||
|
||||
public override bool HandlesClick => true;
|
||||
|
||||
protected override void OnDraw(UiRenderContext context)
|
||||
{
|
||||
IReadOnlyList<string> items = ItemsSource();
|
||||
IReadOnlyList<uint> itemColors = ItemColorsSource();
|
||||
int visibleRows = VisibleRows;
|
||||
int selected = SelectedIndexSource();
|
||||
if (selected >= 0 && selected < items.Count)
|
||||
{
|
||||
if (selected < _topRow)
|
||||
_topRow = selected;
|
||||
else if (selected >= _topRow + visibleRows)
|
||||
_topRow = selected - visibleRows + 1;
|
||||
}
|
||||
ClampTop(items.Count, visibleRows);
|
||||
|
||||
context.DrawFill(0f, 0f, Width, Height, BackgroundColor);
|
||||
context.DrawRectOutline(0f, 0f, Width, Height, BorderColor, 1f);
|
||||
int end = Math.Min(items.Count, _topRow + visibleRows);
|
||||
for (int index = _topRow; index < end; index++)
|
||||
{
|
||||
float y = (index - _topRow) * RowHeight;
|
||||
if (index == selected)
|
||||
context.DrawFill(1f, y + 1f, Width - 2f, RowHeight - 1f, SelectedColor);
|
||||
string text = items[index];
|
||||
Vector4 textColor = index < itemColors.Count
|
||||
? Rgb(itemColors[index])
|
||||
: TextColor;
|
||||
float textY = y + MathF.Max(0f,
|
||||
(RowHeight - (DatFont?.LineHeight ?? 14f)) * 0.5f);
|
||||
if (DatFont is { } font)
|
||||
context.DrawStringDat(font, text, Padding, textY, textColor, true);
|
||||
else
|
||||
context.DrawString(text, Padding, textY, textColor);
|
||||
}
|
||||
}
|
||||
|
||||
public override bool OnEvent(in UiEvent e)
|
||||
{
|
||||
IReadOnlyList<string> items = ItemsSource();
|
||||
if (e.Type == UiEventType.Scroll)
|
||||
{
|
||||
_topRow -= Math.Sign(e.Data0);
|
||||
ClampTop(items.Count, VisibleRows);
|
||||
return true;
|
||||
}
|
||||
if (e.Type != UiEventType.MouseDown || !Enabled)
|
||||
return false;
|
||||
int row = (int)MathF.Floor(e.Data2 / MathF.Max(1f, RowHeight));
|
||||
int index = _topRow + row;
|
||||
if (row >= 0 && row < VisibleRows && index >= 0 && index < items.Count)
|
||||
SelectionChanged?.Invoke(index);
|
||||
return true;
|
||||
}
|
||||
|
||||
private int VisibleRows => Math.Max(1, (int)MathF.Floor(
|
||||
Height / MathF.Max(1f, RowHeight)));
|
||||
|
||||
private void ClampTop(int count, int visibleRows) =>
|
||||
_topRow = Math.Clamp(_topRow, 0, Math.Max(0, count - visibleRows));
|
||||
|
||||
private static Vector4 Rgb(uint value) => new(
|
||||
((value >> 16) & 0xFFu) / 255f,
|
||||
((value >> 8) & 0xFFu) / 255f,
|
||||
(value & 0xFFu) / 255f,
|
||||
1f);
|
||||
}
|
||||
47
src/AcDream.App/UI/UiMarkupTabButton.cs
Normal file
47
src/AcDream.App/UI/UiMarkupTabButton.cs
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
using System.Numerics;
|
||||
|
||||
namespace AcDream.App.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Compact KSML tab used by plugin windows. It deliberately uses the retained
|
||||
/// input/font path and VTank's text-strip presentation rather than introducing
|
||||
/// a plugin-owned renderer.
|
||||
/// </summary>
|
||||
public sealed class UiMarkupTabButton : UiSimpleButton
|
||||
{
|
||||
private static readonly Vector4 ActiveText =
|
||||
new(0.94f, 0.76f, 0.18f, 1f);
|
||||
private static readonly Vector4 NormalText =
|
||||
new(0.78f, 0.76f, 0.67f, 1f);
|
||||
private static readonly Vector4 DisabledText =
|
||||
new(0.34f, 0.33f, 0.29f, 1f);
|
||||
private static readonly Vector4 Underline =
|
||||
new(0.77f, 0.59f, 0.12f, 1f);
|
||||
|
||||
public Func<bool>? SelectedSource { get; set; }
|
||||
|
||||
public bool IsSelected => SelectedSource?.Invoke() ?? false;
|
||||
|
||||
public UiMarkupTabButton()
|
||||
{
|
||||
BackgroundColor = Vector4.Zero;
|
||||
BorderColor = Vector4.Zero;
|
||||
BorderThickness = 0f;
|
||||
Outline = true;
|
||||
}
|
||||
|
||||
protected override void OnTick(double deltaSeconds)
|
||||
{
|
||||
base.OnTick(deltaSeconds);
|
||||
TextColor = !Enabled
|
||||
? DisabledText
|
||||
: IsSelected ? ActiveText : NormalText;
|
||||
}
|
||||
|
||||
protected override void OnDraw(UiRenderContext ctx)
|
||||
{
|
||||
base.OnDraw(ctx);
|
||||
if (IsSelected)
|
||||
ctx.DrawFill(2f, Height - 2f, MathF.Max(0f, Width - 4f), 1f, Underline);
|
||||
}
|
||||
}
|
||||
75
src/AcDream.App/UI/UiMarkupToggle.cs
Normal file
75
src/AcDream.App/UI/UiMarkupToggle.cs
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
using System.Numerics;
|
||||
|
||||
namespace AcDream.App.UI;
|
||||
|
||||
/// <summary>
|
||||
/// KSML boolean toggle with the compact lamp-and-caption presentation used by
|
||||
/// VTank. State and action remain reflected BCL bindings owned by the plugin.
|
||||
/// </summary>
|
||||
public sealed class UiMarkupToggle : UiElement
|
||||
{
|
||||
private static readonly Vector4 CheckedOuter =
|
||||
new(0.36f, 0.58f, 0.12f, 1f);
|
||||
private static readonly Vector4 CheckedInner =
|
||||
new(0.52f, 1f, 0.08f, 1f);
|
||||
private static readonly Vector4 UncheckedOuter =
|
||||
new(0.26f, 0.22f, 0.13f, 1f);
|
||||
private static readonly Vector4 UncheckedInner =
|
||||
new(0.38f, 0.34f, 0.23f, 1f);
|
||||
|
||||
public string Text { get; set; } = string.Empty;
|
||||
public Func<string?>? TextSource { get; set; }
|
||||
public Func<bool>? CheckedSource { get; set; }
|
||||
public UiDatFont? DatFont { get; set; }
|
||||
public Vector4 TextColor { get; set; } =
|
||||
new(0.86f, 0.84f, 0.74f, 1f);
|
||||
public Action? Toggle { get; set; }
|
||||
|
||||
public bool IsChecked => CheckedSource?.Invoke() ?? false;
|
||||
|
||||
public override bool HandlesClick => true;
|
||||
|
||||
public override bool OnEvent(in UiEvent e)
|
||||
{
|
||||
if (e.Type != UiEventType.Click || !Enabled)
|
||||
return false;
|
||||
Toggle?.Invoke();
|
||||
return true;
|
||||
}
|
||||
|
||||
protected override void OnDraw(UiRenderContext ctx)
|
||||
{
|
||||
Vector4 outer = IsChecked ? CheckedOuter : UncheckedOuter;
|
||||
Vector4 inner = IsChecked ? CheckedInner : UncheckedInner;
|
||||
DrawLamp(ctx, 1f, MathF.Max(1f, (Height - 11f) * 0.5f), outer, inner);
|
||||
|
||||
string caption = TextSource?.Invoke() ?? Text;
|
||||
Vector4 color = Enabled
|
||||
? TextColor
|
||||
: new Vector4(TextColor.X, TextColor.Y, TextColor.Z, 0.42f);
|
||||
float y = DatFont is { } font
|
||||
? (Height - font.LineHeight) * 0.5f
|
||||
: 1f;
|
||||
if (DatFont is { } dat)
|
||||
ctx.DrawStringDat(dat, caption, 17f, y, color, outline: true);
|
||||
else
|
||||
ctx.DrawString(caption, 17f, y, color);
|
||||
}
|
||||
|
||||
private static void DrawLamp(
|
||||
UiRenderContext ctx,
|
||||
float x,
|
||||
float y,
|
||||
Vector4 outer,
|
||||
Vector4 inner)
|
||||
{
|
||||
// Five bands form the small circular indicator without introducing a
|
||||
// plugin bitmap or a new renderer primitive.
|
||||
ctx.DrawFill(x + 3f, y, 5f, 1f, outer);
|
||||
ctx.DrawFill(x + 1f, y + 1f, 9f, 2f, outer);
|
||||
ctx.DrawFill(x, y + 3f, 11f, 5f, outer);
|
||||
ctx.DrawFill(x + 1f, y + 8f, 9f, 2f, outer);
|
||||
ctx.DrawFill(x + 3f, y + 10f, 5f, 1f, outer);
|
||||
ctx.DrawFill(x + 3f, y + 3f, 5f, 5f, inner);
|
||||
}
|
||||
}
|
||||
|
|
@ -74,7 +74,9 @@ public sealed class UiMenu : UiElement
|
|||
string? live = TooltipTextProvider?.Invoke();
|
||||
if (!string.IsNullOrWhiteSpace(live))
|
||||
return live;
|
||||
return string.IsNullOrWhiteSpace(TooltipText) ? null : TooltipText;
|
||||
return string.IsNullOrWhiteSpace(TooltipText)
|
||||
? base.GetTooltipText()
|
||||
: TooltipText;
|
||||
}
|
||||
|
||||
public int RowsPerColumn { get; set; } = 7; // items per column (dat item template);
|
||||
|
|
|
|||
|
|
@ -37,6 +37,12 @@ public sealed class UiScrollbar : UiElement
|
|||
/// </summary>
|
||||
public float ScalarPosition { get; private set; }
|
||||
public Action<float>? ScalarChanged { get; set; }
|
||||
/// <summary>
|
||||
/// Optional live scalar reader used by plugin markup. It is sampled while
|
||||
/// no thumb gesture is active so external/profile changes reach the widget
|
||||
/// without fighting the value under the user's cursor.
|
||||
/// </summary>
|
||||
public Func<float?>? ScalarPositionSource { get; set; }
|
||||
public bool Horizontal { get; set; }
|
||||
|
||||
/// <summary>True while a thumb drag is in progress (between a thumb-hit
|
||||
|
|
@ -94,6 +100,13 @@ public sealed class UiScrollbar : UiElement
|
|||
public void SetScalarPosition(float position)
|
||||
=> ScalarPosition = Math.Clamp(position, 0f, 1f);
|
||||
|
||||
protected override void OnTick(double deltaSeconds)
|
||||
{
|
||||
base.OnTick(deltaSeconds);
|
||||
if (!_draggingThumb && ScalarPositionSource?.Invoke() is { } value)
|
||||
SetScalarPosition(value);
|
||||
}
|
||||
|
||||
/// <summary>Settable tooltip, surfaced through the shared
|
||||
/// <see cref="UiElement.GetTooltipText"/> hover pipeline — the SAME
|
||||
/// pattern <see cref="UiButton.TooltipText"/> already established
|
||||
|
|
@ -105,7 +118,9 @@ public sealed class UiScrollbar : UiElement
|
|||
|
||||
/// <inheritdoc />
|
||||
public override string? GetTooltipText() =>
|
||||
string.IsNullOrWhiteSpace(TooltipText) ? null : TooltipText;
|
||||
string.IsNullOrWhiteSpace(TooltipText)
|
||||
? base.GetTooltipText()
|
||||
: TooltipText;
|
||||
|
||||
/// <summary>RenderSurface id → (GL tex, w, h). 0 id = skip.</summary>
|
||||
public Func<uint, (uint tex, int w, int h)>? SpriteResolve { get; set; }
|
||||
|
|
|
|||
|
|
@ -65,6 +65,21 @@ internal sealed class LiveEntityDeletionController : ILiveEntityPruneSink
|
|||
return removed || removedDormant;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// VTank ghost cleanup: synthesize the exact current incarnation delete,
|
||||
/// then use the same complete teardown transaction as a server DeleteObject.
|
||||
/// </summary>
|
||||
public bool DeleteClientGhost(uint serverGuid)
|
||||
{
|
||||
if (serverGuid == 0u
|
||||
|| serverGuid == _identity.ServerGuid
|
||||
|| !_runtime.TryGetRecord(serverGuid, out LiveEntityRecord record))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return Delete(new DeleteObject.Parsed(serverGuid, record.Generation));
|
||||
}
|
||||
|
||||
public bool Prune(LiveEntityPruneCandidate candidate)
|
||||
{
|
||||
if (!_runtime.TryGetRecord(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue