The retail Options panel (Campaign OP) needs a Runtime-owned option map
covering all 53 PlayerOption ids and the real batched SetCharacterOptions
(0x01A1) blob before any UI can be built on top of it. Today's surface only
modeled 6 ListenTo*Chat ids and the 0x01A1 builder was a malformed 16-byte
stub (deleted at Campaign CH slice CH3, docs/research/2026-08-09-chat-side-
channels-vs-ace.md).
- CharacterOptionTable.cs: the ONE typed table, PlayerOption id (0x00..0x34)
-> (Options1/Options2 word, mask, IsAutoSave, ClientDefault), transcribed
from acclient.h's verbatim CharacterOption/CharacterOptions2/PlayerOption
enums and byte-verified against IsAutoSaveOption @0x0059A600 (the 21-id
auto-save table) and GetDefaultOptionValue @0x005D2A30 (the Defaults-
button table). Reconstructing CharacterOptions1/2 defaults from the
ClientDefault column independently reproduces 0x50C4A54A / 0x00008700,
cross-confirming the id-mask mapping. CharacterOptionId (SocialActions.cs)
widened from 6 to all 53 ids to match.
- RuntimeCharacterOptionsState: SetOptionBit now resolves through the full
table (was a 6-case switch). New TrySetOption is the ONE shared local-
write-then-send/dirty seam — mirrors CPlayerModule::OnChanged exactly:
write the bit locally first, then either send 0x0005 immediately (auto-
save ids) or MarkDirty for the batched blob, no-op on an unchanged value
(retail's own early-return) or an unmodeled id. New dirty model (IsDirty/
FirstDirtiedAt/MarkDirty/TryFlush/TryFlushIfAutoSaveDue) uses an injected
TimeProvider so it's fully unit-testable without a live clock.
- Both IRuntimeCharacterCommands.SetSingleOption adapters (Direct + Current)
now route through TrySetOption instead of duplicating the write; this
fixes the headless local-write gap the OP1 research flagged (the direct
adapter previously sent the wire message without writing the bit first,
same class of bug CH4 fixed for the graphical host). Both also reject an
id outside the table instead of silently accepting it. LiveSessionRuntime
Factory's SendSingleCharacterOption closure now delegates to the same
seam instead of duplicating write-then-send inline.
- New IRuntimeCharacterCommands.SaveOptions(generation) — the explicit
blob-flush verb (retail's SaveToServer(force: 0)) — wired end-to-end in
both adapters, including a new SaveCharacterOptionsRuntimeCmd on the
graphical router.
- SocialActions.BuildSetCharacterOptions + WorldSession.SendSetCharacterOptions:
the real PlayerModule::Pack body per the wire research's field-by-field
layout — header always 0x460 OR'd with 0x001/0x008 when shortcuts/desired
comps are non-empty, favorite spells always 8 lists, never sets 0x100 or
0x200. Echoes last-parsed shortcuts/favorites/desired-comps/spellbook
filters (via new CharacterOptionsBlobSource) instead of zeroing them.
Conformance: a hand-computed golden byte vector (not generated by the
builder under test — the CH3 builder died of tests that pinned a wrong
shape and looked green) plus a round-trip through PlayerDescriptionParser.
Contract deviation: the 480 s auto-save timer and the flush-before-logout
trigger are implemented as fully-tested pure state-machine logic
(TryFlushIfAutoSaveDue) but are NOT wired into either host's live per-frame
loop or graceful-shutdown sequence in this slice — only the explicit
SaveOptions verb is production-wired. Wiring the timer touches App's
UpdateFrameOrchestrator graph and Headless's tick loop (outside this
slice's Runtime/wire-layer scope); wiring logout risks the already-fragile
graceful-shutdown sequence CLAUDE.md flags. Filed as TS-71 per the plan's
own escape valve ("target: not deferred" with a register row if deferred).
Also filed: AP-193 (the 0x34 HearPKDeathMessages id/mask is ACE-sourced,
unverifiable against the 2013 binary) and AP-194 (GetDefaultOptionValue's
table disagrees with the constructor default for ConfirmVolatileRareUse/
ShowHelm/ShowCloak — retail's own quirk, reproduced not fixed).
Tests: table completeness x53, auto-save/client-default split pinned
id-by-id against the byte-verified tables, unknown/reserved-id rejection
(0x35/0x36 landmines), local-write-then-send on both adapters + the router,
the dirty/flush state machine, SaveOptions, and the wire golden vector +
PlayerDescriptionParser round-trip. Full Release suite: 12,745 passed / 4
skipped / 0 failed (baseline 12,611/4/0 — slice adds 134 passing tests,
zero regressions).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
416 lines
14 KiB
C#
416 lines
14 KiB
C#
using System.Numerics;
|
|
using System.Runtime.CompilerServices;
|
|
using AcDream.App.Composition;
|
|
using AcDream.App.Input;
|
|
using AcDream.App.Interaction;
|
|
using AcDream.App.Net;
|
|
using AcDream.App.Rendering;
|
|
using AcDream.App.UI;
|
|
using AcDream.App.UI.Layout;
|
|
using AcDream.App.World;
|
|
using AcDream.Core.Net;
|
|
using AcDream.Core.World;
|
|
using AcDream.Runtime;
|
|
using AcDream.UI.Abstractions;
|
|
|
|
namespace AcDream.App.Tests.Composition;
|
|
|
|
public sealed class InteractionUiRuntimeSourcesTests
|
|
{
|
|
[Fact]
|
|
public void SessionAuthorityDefaultsBindUnbindRebindAndDeactivateExactly()
|
|
{
|
|
var source = new DeferredLiveSessionUiAuthority();
|
|
var first = new SessionTarget(true);
|
|
var second = new SessionTarget(false);
|
|
|
|
Assert.False(source.IsInWorld);
|
|
Assert.Same(NullCommandBus.Instance, source.Commands);
|
|
|
|
IDisposable stale = source.Bind(first);
|
|
Assert.True(source.IsInWorld);
|
|
Assert.Same(first.Commands, source.Commands);
|
|
Assert.Throws<InvalidOperationException>(() => source.Bind(second));
|
|
|
|
stale.Dispose();
|
|
IDisposable current = source.Bind(second);
|
|
stale.Dispose();
|
|
Assert.False(source.IsInWorld);
|
|
Assert.Same(second.Commands, source.Commands);
|
|
|
|
source.Deactivate();
|
|
current.Dispose();
|
|
Assert.Same(NullCommandBus.Instance, source.Commands);
|
|
Assert.Throws<ObjectDisposedException>(() => source.Bind(first));
|
|
}
|
|
|
|
[Fact]
|
|
public void SelectionAuthorityUsesRetailSafeDefaultsAndExactBindingLifetime()
|
|
{
|
|
var source = new DeferredSelectionUiAuthority();
|
|
var query = new SelectionQuery();
|
|
SelectionInteractionController interactions =
|
|
Stub<SelectionInteractionController>();
|
|
|
|
Assert.True(source.IsWithinExternalContainerUseRange(1u));
|
|
Assert.False(source.ShouldShowHealth(1u));
|
|
Assert.Null(source.ResolveVividTargetInfo(1u));
|
|
|
|
IDisposable binding = source.Bind(query, interactions);
|
|
Assert.False(source.IsWithinExternalContainerUseRange(1u));
|
|
Assert.True(source.ShouldShowHealth(1u));
|
|
Assert.Equal(3f, source.ResolveVividTargetInfo(1u)?.SelectionSphereRadius);
|
|
Assert.Throws<InvalidOperationException>(() => source.Bind(query, interactions));
|
|
|
|
binding.Dispose();
|
|
Assert.True(source.IsWithinExternalContainerUseRange(1u));
|
|
source.Deactivate();
|
|
Assert.Throws<ObjectDisposedException>(() => source.Bind(query, interactions));
|
|
}
|
|
|
|
[Fact]
|
|
public void GameRuntimeCommandsCaptureTheExactCurrentGeneration()
|
|
{
|
|
var source = new DeferredGameRuntimeStateCommands();
|
|
var first = new RuntimeTarget(new RuntimeGenerationToken(7));
|
|
var second = new RuntimeTarget(new RuntimeGenerationToken(9));
|
|
|
|
Assert.False(source.IsInWorld);
|
|
Assert.Equal(
|
|
RuntimeCommandStatus.Inactive,
|
|
source.Advance(RuntimeAdvancementKind.Skill, 6u, 100u).Status);
|
|
|
|
IDisposable stale = source.Bind(first, first);
|
|
Assert.True(source.IsInWorld);
|
|
Assert.True(source.Advance(
|
|
RuntimeAdvancementKind.Skill,
|
|
6u,
|
|
100u).Accepted);
|
|
Assert.Equal(new RuntimeGenerationToken(7), first.LastGeneration);
|
|
Assert.Throws<InvalidOperationException>(() =>
|
|
source.Bind(second, second));
|
|
|
|
stale.Dispose();
|
|
using IDisposable current = source.Bind(second, second);
|
|
stale.Dispose();
|
|
Assert.True(source.AddFavorite(0, 2, 42u).Accepted);
|
|
Assert.Equal(new RuntimeGenerationToken(9), second.LastGeneration);
|
|
Assert.Equal(
|
|
RuntimeCommandStatus.Rejected,
|
|
source.RemoveShortcut(uint.MaxValue).Status);
|
|
|
|
source.Deactivate();
|
|
Assert.Equal(
|
|
RuntimeCommandStatus.Inactive,
|
|
source.RemoveShortcut(3u).Status);
|
|
Assert.Throws<ObjectDisposedException>(() => source.Bind(first, first));
|
|
}
|
|
|
|
[Fact]
|
|
public void RadarNeverCachesAnUnboundBootstrapSnapshot()
|
|
{
|
|
var source = new DeferredRadarSnapshotSource();
|
|
Assert.Same(UiRadarSnapshot.Empty, source.Snapshot());
|
|
|
|
var provider = new RadarSnapshotProvider(
|
|
new AcDream.Core.Items.ClientObjectTable(),
|
|
EmptyRadarSource.Instance,
|
|
static () => new Dictionary<uint, WorldSession.EntitySpawn>(),
|
|
static () => 0u,
|
|
static () => 0f,
|
|
static () => 0u,
|
|
static () => null,
|
|
static () => false,
|
|
static () => true);
|
|
IDisposable binding = source.Bind(provider);
|
|
|
|
Assert.True(source.Snapshot().UiLocked);
|
|
binding.Dispose();
|
|
Assert.False(source.Snapshot().UiLocked);
|
|
}
|
|
|
|
[Fact]
|
|
public void AutomationProxyIsInertUntilExactRuntimeBinds()
|
|
{
|
|
var source = new DeferredWorldLifecycleAutomationRuntime();
|
|
Assert.False(source.TryRequestCheckpoint(
|
|
"early", out _, out string earlyError));
|
|
Assert.Contains("not bound", earlyError);
|
|
|
|
var target = new AutomationRuntime();
|
|
IDisposable binding = source.Bind(target);
|
|
Assert.True(source.IsWorldReady);
|
|
Assert.True(source.TryRequestCheckpoint("ready", out _, out _));
|
|
Assert.Equal("ready", target.LastCheckpoint);
|
|
|
|
binding.Dispose();
|
|
Assert.False(source.IsWorldReady);
|
|
source.Deactivate();
|
|
Assert.Throws<ObjectDisposedException>(() => source.Bind(target));
|
|
}
|
|
|
|
[Fact]
|
|
public void ExpectedOwnerBindingReleasesOnce()
|
|
{
|
|
object target = new();
|
|
int releases = 0;
|
|
var binding = new ExpectedOwnerBinding<object>(target, value =>
|
|
{
|
|
Assert.Same(target, value);
|
|
releases++;
|
|
});
|
|
|
|
binding.Dispose();
|
|
binding.Dispose();
|
|
|
|
Assert.Equal(1, releases);
|
|
}
|
|
|
|
[Fact]
|
|
public void RetainedInputCaptureBindingCannotClearAReplacement()
|
|
{
|
|
var slot = new RetainedUiInputCaptureSlot();
|
|
var first = new UiRoot();
|
|
var second = new UiRoot();
|
|
|
|
IDisposable stale = slot.Bind(first);
|
|
Assert.Same(first, slot.Root);
|
|
Assert.Throws<InvalidOperationException>(() => slot.Bind(second));
|
|
stale.Dispose();
|
|
IDisposable current = slot.Bind(second);
|
|
stale.Dispose();
|
|
Assert.Same(second, slot.Root);
|
|
current.Dispose();
|
|
Assert.Null(slot.Root);
|
|
}
|
|
|
|
[Fact]
|
|
public void LateBindingCleanupRetriesOnlyTheFailedSuffix()
|
|
{
|
|
var bindings = new InteractionUiLateBindings();
|
|
var first = new RetryBinding(failures: 0);
|
|
var second = new RetryBinding(failures: 1);
|
|
bindings.AdoptLateOwnerBinding("first", first);
|
|
bindings.AdoptLateOwnerBinding("second", second);
|
|
|
|
Assert.Throws<AggregateException>(bindings.Dispose);
|
|
Assert.Equal(1, first.Calls);
|
|
Assert.Equal(1, second.Calls);
|
|
|
|
bindings.Dispose();
|
|
Assert.Equal(1, first.Calls);
|
|
Assert.Equal(2, second.Calls);
|
|
Assert.Throws<ObjectDisposedException>(() =>
|
|
bindings.AdoptLateOwnerBinding("late", new RetryBinding(0)));
|
|
}
|
|
|
|
private sealed class SessionTarget(bool inWorld) : ILiveUiSessionTarget
|
|
{
|
|
public bool IsInWorld { get; } = inWorld;
|
|
public WorldSession? CurrentSession => null;
|
|
public ICommandBus Commands { get; } = new LiveCommandBus();
|
|
}
|
|
|
|
private sealed class RuntimeTarget
|
|
: IGameRuntimeView,
|
|
IGameRuntimeCommands,
|
|
IRuntimeInventoryStateCommands,
|
|
IRuntimeSpellbookCommands,
|
|
IRuntimeCharacterCommands
|
|
{
|
|
public RuntimeTarget(RuntimeGenerationToken generation)
|
|
{
|
|
Generation = generation;
|
|
}
|
|
|
|
public RuntimeGenerationToken LastGeneration { get; private set; }
|
|
public RuntimeGenerationToken Generation { get; }
|
|
public RuntimeLifecycleSnapshot Lifecycle =>
|
|
new(Generation, RuntimeLifecycleState.InWorld, 1u, true);
|
|
public IGameRuntimeClock Clock => null!;
|
|
public IRuntimeEntityView Entities => null!;
|
|
public IRuntimeInventoryView Inventory => null!;
|
|
public IRuntimeInventoryStateView InventoryState => null!;
|
|
public IRuntimeCharacterView Character => null!;
|
|
public IRuntimeSocialView Social => null!;
|
|
public IRuntimeChatView Chat => null!;
|
|
public IRuntimeActionView Actions => null!;
|
|
public IRuntimeMovementView Movement => null!;
|
|
public AcDream.Runtime.World.IRuntimeWorldEnvironmentView Environment =>
|
|
null!;
|
|
public IRuntimePortalView Portal => null!;
|
|
public IRuntimeSessionCommands Session => null!;
|
|
public IRuntimeSelectionCommands Selection => null!;
|
|
public IRuntimeCombatCommands Combat => null!;
|
|
public IRuntimeMagicCommands Magic => null!;
|
|
IRuntimeMovementCommands IGameRuntimeCommands.Movement => null!;
|
|
IRuntimeChatCommands IGameRuntimeCommands.Chat => null!;
|
|
IRuntimePortalCommands IGameRuntimeCommands.Portal => null!;
|
|
IRuntimeInventoryStateCommands IGameRuntimeCommands.InventoryState => this;
|
|
IRuntimeSpellbookCommands IGameRuntimeCommands.Spellbook => this;
|
|
IRuntimeCharacterCommands IGameRuntimeCommands.Character => this;
|
|
IRuntimeSocialCommands IGameRuntimeCommands.Social => null!;
|
|
|
|
public RuntimeStateCheckpoint CaptureCheckpoint() => default;
|
|
|
|
public RuntimeCommandResult AddShortcut(
|
|
RuntimeGenerationToken expectedGeneration,
|
|
in RuntimeShortcutCommand command) =>
|
|
Accepted(expectedGeneration, command.ObjectId);
|
|
|
|
public RuntimeCommandResult RemoveShortcut(
|
|
RuntimeGenerationToken expectedGeneration,
|
|
int index) =>
|
|
Accepted(expectedGeneration);
|
|
|
|
public RuntimeCommandResult AddFavorite(
|
|
RuntimeGenerationToken expectedGeneration,
|
|
int tabIndex,
|
|
int position,
|
|
uint spellId) =>
|
|
Accepted(expectedGeneration, spellId);
|
|
|
|
public RuntimeCommandResult RemoveFavorite(
|
|
RuntimeGenerationToken expectedGeneration,
|
|
int tabIndex,
|
|
uint spellId) =>
|
|
Accepted(expectedGeneration, spellId);
|
|
|
|
public RuntimeCommandResult SetFilter(
|
|
RuntimeGenerationToken expectedGeneration,
|
|
uint filters) =>
|
|
Accepted(expectedGeneration);
|
|
|
|
public RuntimeCommandResult ForgetSpell(
|
|
RuntimeGenerationToken expectedGeneration,
|
|
uint spellId) =>
|
|
Accepted(expectedGeneration, spellId);
|
|
|
|
public RuntimeCommandResult SetDesiredComponent(
|
|
RuntimeGenerationToken expectedGeneration,
|
|
uint componentId,
|
|
uint amount) =>
|
|
Accepted(expectedGeneration, componentId);
|
|
|
|
public RuntimeCommandResult ClearDesiredComponents(
|
|
RuntimeGenerationToken expectedGeneration) =>
|
|
Accepted(expectedGeneration);
|
|
|
|
public RuntimeCommandResult Advance(
|
|
RuntimeGenerationToken expectedGeneration,
|
|
in RuntimeAdvancementCommand command) =>
|
|
Accepted(expectedGeneration, command.StatId);
|
|
|
|
public RuntimeCommandResult SetSingleOption(
|
|
RuntimeGenerationToken expectedGeneration,
|
|
uint optionId,
|
|
bool value) =>
|
|
Accepted(expectedGeneration);
|
|
|
|
public RuntimeCommandResult SaveOptions(
|
|
RuntimeGenerationToken expectedGeneration) =>
|
|
Accepted(expectedGeneration);
|
|
|
|
private RuntimeCommandResult Accepted(
|
|
RuntimeGenerationToken generation,
|
|
uint objectId = 0u)
|
|
{
|
|
LastGeneration = generation;
|
|
return new RuntimeCommandResult(
|
|
RuntimeCommandStatus.Accepted,
|
|
generation,
|
|
objectId);
|
|
}
|
|
}
|
|
|
|
private sealed class SelectionQuery : IRetainedUiSelectionQuery
|
|
{
|
|
public bool ShouldShowHealth(uint serverGuid) => true;
|
|
public VividTargetInfo? ResolveVividTargetInfo(uint serverGuid) =>
|
|
new(Vector3.Zero, 3f, 0u, 0u);
|
|
public bool IsWithinExternalContainerUseRange(uint serverGuid) => false;
|
|
}
|
|
|
|
private sealed class AutomationRuntime
|
|
: AcDream.App.UI.Testing.IRetailUiAutomationRuntime
|
|
{
|
|
private sealed record CompletedCheckpoint(int Sequence, string Name)
|
|
: AcDream.App.UI.Testing.IRetailUiAutomationCheckpoint
|
|
{
|
|
public AcDream.App.UI.Testing.RetailUiAutomationCheckpointStatus Status =>
|
|
AcDream.App.UI.Testing.RetailUiAutomationCheckpointStatus.Succeeded;
|
|
public string? Error => null;
|
|
}
|
|
|
|
public bool IsWorldReady => true;
|
|
public bool IsWorldViewportVisible => true;
|
|
public int PortalMaterializationCount => 2;
|
|
public string? LastCheckpoint { get; private set; }
|
|
|
|
public bool TryRequestCheckpoint(
|
|
string name,
|
|
out AcDream.App.UI.Testing.IRetailUiAutomationCheckpoint? checkpoint,
|
|
out string error)
|
|
{
|
|
LastCheckpoint = name;
|
|
checkpoint = new CompletedCheckpoint(1, name);
|
|
error = string.Empty;
|
|
return true;
|
|
}
|
|
|
|
public void CancelCheckpoint(
|
|
AcDream.App.UI.Testing.IRetailUiAutomationCheckpoint checkpoint)
|
|
{
|
|
}
|
|
|
|
public bool TryRequestScreenshot(string name, out string error)
|
|
{
|
|
error = string.Empty;
|
|
return true;
|
|
}
|
|
|
|
public bool IsScreenshotComplete(string name) => true;
|
|
}
|
|
|
|
private sealed class RetryBinding(int failures) : IDisposable
|
|
{
|
|
private int _failures = failures;
|
|
public int Calls { get; private set; }
|
|
|
|
public void Dispose()
|
|
{
|
|
Calls++;
|
|
if (_failures > 0)
|
|
{
|
|
_failures--;
|
|
throw new InvalidOperationException("retry");
|
|
}
|
|
}
|
|
}
|
|
|
|
private sealed class EmptyRadarSource : ILiveEntityRadarSource
|
|
{
|
|
public static EmptyRadarSource Instance { get; } = new();
|
|
|
|
public bool TryGetMaterialized(
|
|
uint serverGuid,
|
|
out WorldEntity entity)
|
|
{
|
|
entity = null!;
|
|
return false;
|
|
}
|
|
|
|
public bool TryGetVisible(uint serverGuid, out WorldEntity entity)
|
|
{
|
|
entity = null!;
|
|
return false;
|
|
}
|
|
|
|
public void CopyVisibleTo(
|
|
List<KeyValuePair<uint, WorldEntity>> destination) =>
|
|
destination.Clear();
|
|
}
|
|
|
|
private static T Stub<T>() where T : class =>
|
|
(T)RuntimeHelpers.GetUninitializedObject(typeof(T));
|
|
}
|