diff --git a/docs/plans/2026-08-24-character-panel-parity-campaign.md b/docs/plans/2026-08-24-character-panel-parity-campaign.md index ca44648b..22e7524b 100644 --- a/docs/plans/2026-08-24-character-panel-parity-campaign.md +++ b/docs/plans/2026-08-24-character-panel-parity-campaign.md @@ -155,14 +155,11 @@ setAsDisplay). New sibling owner `RuntimeCharacterTitleState` (`RuntimeCharacterState.Titles`) holds the earned-title set + display title id, clears at generation reset (`CaptureOwnership`/`IsConverged` extended with `TitleCount`/`DisplayTitleIsDefault`), and fires -`TableReplaced`/`TitleAdded`/`DisplayTitleChanged` — matching retail's -own unconditional-add / gated-display-set contract -(`Handle_Social__AddOrSetCharacterTitle @0x00564260`). Outbound +`TableReplaced`/`TitleAdded`/`DisplayTitleChanged`. Outbound `TitleSet (0x002C)` ships behind `IRuntimeCharacterCommands.SetTitle` on both hosts (`DirectGameRuntimeCommandAdapter` direct-send, `CurrentGameRuntimeCommandAdapter` via the `LiveCommandBus`/ -`LiveSessionCommandRouter` queue) with **NO optimistic local -mutation** — verified against retail's own +`LiveSessionCommandRouter` queue) — verified against retail's own `CM_Social::Event_SetDisplayCharacterTitle @0x006a5720`, which sends the wire message and touches no local field; the display title updates only from the server's own echo. No register row: this slice @@ -173,10 +170,66 @@ StringTable(`0x2300000E`) chain for CT3/CT4 to consume; Runtime stays id-only. Conformance tests against ACE's writer shapes (`tests/AcDream.Core.Net.Tests/Messages/CharacterTitleEventsTests.cs`), Runtime owner tests (`RuntimeCharacterTitleStateTests.cs` + -`RuntimeCharacterStateTests.cs` integration), a no-local-mutation -command test (`DirectGameRuntimeCommandAdapterTests.cs`), and an -InstalledDat pin (`CharacterTitleResolverLiveDatTests.cs`, ids 0/1/2/3/ -5/13/14) all pass. +`RuntimeCharacterStateTests.cs` integration), a wire-send command test +(`DirectGameRuntimeCommandAdapterTests.cs`), and an InstalledDat pin +(`CharacterTitleResolverLiveDatTests.cs`, ids 0/1/2/3/5/13/14) all pass. + +**CT2 fix round (Opus dual-lens review, 2026-08-24).** Four SHOULD-FIX +corrections landed. **F1 (the important one):** the NOTICE broadcast is +unconditional (retail's server-side `SendNotice_AddCharacterTitle` fires +regardless of prior membership), but the client-side table ADD is +DEDUPED — `gmCharacterTitleUI::RecvNotice_AddCharacterTitle @0x0049a990` +walks `mTitleList` and returns without effect when the id is already +present, only inserting + adding the row on a miss. +`RuntimeCharacterTitleState.ApplyUpdateTitle` (which models the CLIENT +receive side, not the server send side) now fires `TitleAdded` only on a +genuine new membership; the inverted pin is +`ApplyUpdateTitle_AlreadyEarnedId_DoesNotFireTitleAddedOrBumpRevision`. +**F3:** the send-side `titleId == 0` rejection is REMOVED from both +command adapters — retail's own send path +(`Event_SetDisplayCharacterTitle @0x006a5720`) packs whatever id it is +handed, and ACE accepts id 0 (`CharacterTitle.Invalid` is a defined enum +value); retail's actual protection is the UI ghost-when-current gate +(CT3's job), not a send-side rejection. No register row: removing the +guard makes acdream MORE retail-exact, not less. The fix round also +closed four SHOULD-FIX-adjacent items: A2 (`ResetSession` now publishes +`TableReplaced` unconditionally and `DisplayTitleChanged` when the +display id was non-zero before the clear, matching the +`LocalPlayerState.Clear()` precedent), A3 (`RuntimeCharacterState +.CaptureOwnership` reads the new non-allocating `Titles.Count` instead of +`EarnedTitleIds.Count`), A4 (the whole mutation in `ReplaceTable`/ +`ApplyUpdateTitle` now happens under one `_gate` hold, with change flags +computed inside the lock and events raised after release), and A5 (every +revision bump is now gated on an actual state change — a no-op wire +resend produces zero revision edges; `TableReplaced` itself still fires +unconditionally per retail's own `Refresh()` dispatch). A1 +(`CharacterTitleResolverLiveDatTests` now honors `ACDREAM_DAT_DIR` +first), A6 (documented the `EmitResult` `primaryObjectId`-as-title-id +precedent inline), A7 (corrected the "third consumer" comment — CT1 §5 +already records `gmAttributeUI::PostInit`'s icon-DID lookup as that third +consumer; CT5 is where the shared `GetDIDByEnum` helper gets factored), +and A8 (`CharacterTitleResolver` now memoizes the final resolved string +per title id, the DAT-static equivalent of retail's lazy-hash cache on +the string buffer) round out the fix round. + +**CT3 anchors from the CT2 review** (carried forward for CT3 to consume, +not yet acted on): +1. CT3 must refresh the display-title TEXT from `TableReplaced` as well + as `DisplayTitleChanged` — retail's + `RecvNotice_UpdateCharacterTitleTable` unconditionally `Refresh()`es + on every `0x0029` arrival, not only when the display id differs. +2. ACE sends NO echo when re-setting the already-current title — the + Set-as-Display button must not wait for a confirmation that never + arrives; retail prevents the send in the first place via the UI + ghost-when-current gate. +3. Retail's fallback display text when a title id doesn't resolve is the + hardcoded literal `"Unknown"` (`Refresh @0x0049abc0`), not a + StringTable key — `CharacterTitleResolver.Resolve` returning `null` + is the correct signal for CT3 to substitute that literal. +4. The deduped client-side add contract (F1 above) — CT3's title-list + row rendering must not assume every `TitleAdded` firing corresponds + to a wire arrival; the reverse still holds (every genuine new row has + a `TitleAdded` firing). **CT3 — Titles page UI.** Bind the authored page through the standard GUI classes (`UiTemplateListBox`/`UiScrollbar`/`UiButton` — zero diff --git a/src/AcDream.App/Runtime/CurrentGameRuntimeCommandAdapter.cs b/src/AcDream.App/Runtime/CurrentGameRuntimeCommandAdapter.cs index e7e8ca90..dd8c811b 100644 --- a/src/AcDream.App/Runtime/CurrentGameRuntimeCommandAdapter.cs +++ b/src/AcDream.App/Runtime/CurrentGameRuntimeCommandAdapter.cs @@ -725,17 +725,22 @@ internal sealed class CurrentGameRuntimeCommandAdapter RuntimeCommandStatus gate = Validate(expectedGeneration, requireWorld: true); if (gate != RuntimeCommandStatus.Accepted) return Result(gate); - if (titleId == 0u) - { - return EmitResult( - RuntimeCommandDomain.Character, - operation: 6, - RuntimeCommandStatus.Rejected); - } + // F3 (CT2 fix round, 2026-08-24): NO id-0 guard here — retail's own + // send path (Event_SetDisplayCharacterTitle @0x006a5720) packs + // whatever id it is handed, and ACE accepts id 0 + // (CharacterTitle.Invalid is a defined enum value). Retail's + // protection against sending an unearned/invalid id is the UI + // ghost-when-current gate (CT3's job), not a send-side rejection — + // a client-side guard here blocks a state the server honors. // CT2: NO optimistic local mutation — matches // DirectGameRuntimeCommandAdapter.SetTitle; RuntimeCharacterState. // Titles updates only from the server's own echo. _commands.Publish(new SetTitleRuntimeCmd(titleId)); + // A6 (CT2 fix round): titleId rides the EmitResult objectId slot — + // same in-class precedent as Advance's command.StatId above (see + // the S4 history comment on SaveOptions/EmitResult, which + // established this field as a typed domain-payload-id slot, not + // always an object guid). return EmitResult( RuntimeCommandDomain.Character, operation: 6, diff --git a/src/AcDream.App/UI/Layout/CharacterTitleResolver.cs b/src/AcDream.App/UI/Layout/CharacterTitleResolver.cs index f967b288..6bea0f71 100644 --- a/src/AcDream.App/UI/Layout/CharacterTitleResolver.cs +++ b/src/AcDream.App/UI/Layout/CharacterTitleResolver.cs @@ -17,11 +17,16 @@ namespace AcDream.App.UI.Layout; /// resolved DIDs are hardcoded here rather than porting the indirection /// generically — the same precedent already /// set for this exact category-4 family (its KeyNameTableId/ -/// MetaKeyNameTableId/DelimiterTableId); factor out a shared -/// helper only if a THIRD consumer of GetDIDByEnum appears. Both DIDs -/// were verified end-to-end against ACE's CharacterTitle.WarMage = 13 -/// in Campaign CT slice CT1 -/// (docs/research/2026-08-24-campaign-ct-dat-ground-truth.md §5, pinned by +/// MetaKeyNameTableId/DelimiterTableId). A THIRD consumer of +/// GetDIDByEnum has already appeared — +/// gmAttributeUI::PostInit @0x0049DB70 resolves per-attribute icon +/// DIDs via category 0x10000002 — so CT5 (not "if a third consumer +/// appears") is where the shared GetDIDByEnum(enumValue, category) +/// helper gets factored out, per +/// docs/research/2026-08-24-campaign-ct-dat-ground-truth.md §5 item 1. +/// Both DIDs used here were verified end-to-end against ACE's +/// CharacterTitle.WarMage = 13 in Campaign CT slice CT1 (same §5, +/// pinned by /// CharacterPanelLiveDatTests.TitleStringTable_ResolvesWarMageEndToEnd). /// /// @@ -42,6 +47,7 @@ public sealed class CharacterTitleResolver private readonly IDatReaderWriter _dats; private readonly DatStringResolver _strings; + private readonly Dictionary _resolvedCache = new(); private EnumMapper? _titleEnumMapper; private bool _loadedMapper; @@ -59,27 +65,38 @@ public sealed class CharacterTitleResolver /// 's own /// null-on-miss contract. /// + /// + /// A8 (CT2 fix round, 2026-08-24): memoizes the final resolved string + /// per (including misses). Retail caches the + /// computed hash directly on the string buffer + /// (GetCharacterTitleFromID's 0xFFFFFFFF lazy-hash + /// sentinel); a memo of the final string is the equivalent here given + /// our immutable, DAT-static tables — no invalidation is needed. + /// public string? Resolve(uint titleId) { if (titleId == 0u) return null; + if (_resolvedCache.TryGetValue(titleId, out string? cached)) + return cached; + if (!_loadedMapper) { _dats.Portal.TryGet(TitleEnumMapperId, out _titleEnumMapper); _loadedMapper = true; } - if (_titleEnumMapper is null - || !_titleEnumMapper.IdToStringMap.TryGetValue(titleId, out var rawNameValue)) + string? resolved = null; + if (_titleEnumMapper is not null + && _titleEnumMapper.IdToStringMap.TryGetValue(titleId, out var rawNameValue)) { - return null; + string rawName = rawNameValue.ToString(); + if (!string.IsNullOrEmpty(rawName)) + resolved = _strings.Resolve(TitleStringTableId, DatStringResolver.ComputeHash(rawName)); } - string rawName = rawNameValue.ToString(); - if (string.IsNullOrEmpty(rawName)) - return null; - - return _strings.Resolve(TitleStringTableId, DatStringResolver.ComputeHash(rawName)); + _resolvedCache[titleId] = resolved; + return resolved; } } diff --git a/src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs b/src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs index 1fce941c..a32aff5c 100644 --- a/src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs +++ b/src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs @@ -252,7 +252,7 @@ public sealed class RuntimeCharacterState : IDisposable && _movementSkillAugmentations == default, AutonomyLevel == FullAutonomyLevel, OptionsAreClean: !Options.IsDirty, - TitleCount: Titles.EarnedTitleIds.Count, + TitleCount: Titles.Count, DisplayTitleIsDefault: Titles.DisplayTitleId == 0u); } @@ -1238,6 +1238,16 @@ public readonly record struct RuntimeCharacterTitleSnapshot( /// UpdateTitle (never re-add an optimistic write here — the CA /// campaign lesson). /// +/// +/// CT2 fix round (2026-08-24), F1: the NOTICE broadcast is unconditional — +/// retail's server-side SendNotice_AddCharacterTitle fires +/// regardless of prior membership — but the client-side table ADD is +/// DEDUPED: gmCharacterTitleUI::RecvNotice_AddCharacterTitle +/// @0x0049a990 walks mTitleList and returns without effect when +/// the id is already present, only inserting + adding the row on a miss. +/// models the client-side receive handler, +/// so fires only on a genuine new membership. +/// public sealed class RuntimeCharacterTitleState { private readonly object _gate = new(); @@ -1245,32 +1255,54 @@ public sealed class RuntimeCharacterTitleState private uint _displayTitleId; private long _revision; - /// Fires after a full 0x0029 CharacterTitle table replace. + /// + /// Fires after every 0x0029 CharacterTitle table replace, + /// unconditionally — matches retail's own + /// gmCharacterTitleUI::RecvNotice_UpdateCharacterTitleTable, + /// which always calls Refresh() regardless of whether the new + /// table differs from the old one. + /// public event Action? TableReplaced; /// - /// Fires once per 0x002B UpdateTitle arrival, UNCONDITIONALLY — - /// matches retail's own SendNotice_AddCharacterTitle, which - /// broadcasts regardless of whether the id was already in the earned - /// set. Carries the added title id. + /// Fires only when an 0x002B UpdateTitle arrival actually adds a + /// NEW id to the earned set — matches retail's client-side + /// gmCharacterTitleUI::RecvNotice_AddCharacterTitle @0x0049a990, + /// which dedupes against mTitleList before inserting (see the + /// class remarks: the F1 fix-round correction). Carries the added + /// title id. /// public event Action? TitleAdded; /// - /// Fires whenever the display title id changes — from either a fresh - /// 0x0029 table (a differing seed) or an 0x002B whose - /// setAsDisplay flag is set. Carries the NEW display title id. + /// Fires whenever the display title id actually changes — from either a + /// fresh 0x0029 table (a differing seed) or an 0x002B + /// whose setAsDisplay flag is set to an id that differs from the + /// current display title. Carries the NEW display title id. /// public event Action? DisplayTitleChanged; public uint DisplayTitleId => Volatile.Read(ref _displayTitleId); public long Revision => Interlocked.Read(ref _revision); + /// + /// WARNING: every read allocates a fresh array (ToArray() under + /// the gate). Fine for UI refresh call sites (CT3), but NEVER read this + /// per-frame — use or + /// for hot-path checks. + /// public IReadOnlyCollection EarnedTitleIds { get { lock (_gate) return _earnedTitleIds.ToArray(); } } + /// Non-allocating earned-title count; prefer this over + /// EarnedTitleIds.Count in hot paths (A3, CT2 fix round). + public int Count + { + get { lock (_gate) return _earnedTitleIds.Count; } + } + public bool HasEarnedTitle(uint titleId) { lock (_gate) return _earnedTitleIds.Contains(titleId); @@ -1290,20 +1322,32 @@ public sealed class RuntimeCharacterTitleState /// 0x0029 CharacterTitle — a WHOLESALE authoritative replace /// (retail's CharacterTitleTable::UnPack always rebuilds /// mTitleList from scratch; there is no incremental-merge path - /// on this opcode). + /// on this opcode). A4 (CT2 fix round): the whole mutation — set clear + /// + rebuild, display-id compare + write — happens under one + /// hold, with change flags computed inside the lock + /// and events raised only after it releases. A5: the revision counter + /// bumps only for an actual content/display change (a byte-identical + /// resend must not produce a revision edge); + /// itself still fires unconditionally, matching retail's own + /// unconditional Refresh() dispatch on this opcode. /// public void ReplaceTable(uint displayTitleId, IReadOnlyList titleIds) { ArgumentNullException.ThrowIfNull(titleIds); + bool setChanged; + bool displayChanged; lock (_gate) { + setChanged = !_earnedTitleIds.SetEquals(titleIds); _earnedTitleIds.Clear(); foreach (uint id in titleIds) _earnedTitleIds.Add(id); + displayChanged = _displayTitleId != displayTitleId; + if (displayChanged) + Volatile.Write(ref _displayTitleId, displayTitleId); } - bool displayChanged = DisplayTitleId != displayTitleId; - Volatile.Write(ref _displayTitleId, displayTitleId); - Interlocked.Increment(ref _revision); + if (setChanged || displayChanged) + Interlocked.Increment(ref _revision); TableReplaced?.Invoke(); if (displayChanged) DisplayTitleChanged?.Invoke(displayTitleId); @@ -1311,27 +1355,63 @@ public sealed class RuntimeCharacterTitleState /// /// 0x002B UpdateTitle — retail's - /// ClientUISystem::Handle_Social__AddOrSetCharacterTitle: ALWAYS - /// add, and additionally set-display only when - /// is true. + /// ClientUISystem::Handle_Social__AddOrSetCharacterTitle ALWAYS + /// broadcasts the add notice server-side, and additionally broadcasts + /// set-display only when is true. On + /// the CLIENT receive side this method models, F1 (CT2 fix round): the + /// add is deduped ( fires only when + /// HashSet<uint>.Add reports a genuine new membership, + /// matching gmCharacterTitleUI::RecvNotice_AddCharacterTitle + /// @0x0049a990's membership check before insert). A4: both halves + /// mutate under one hold with change flags computed + /// inside the lock; events raise after release. A5: the revision + /// counter bumps once per REAL change — zero times for an already- + /// earned id re-sent with pointing at + /// the already-current display id, up to twice when both halves change. /// public void ApplyUpdateTitle(uint titleId, bool setAsDisplay) { - lock (_gate) _earnedTitleIds.Add(titleId); - Interlocked.Increment(ref _revision); - TitleAdded?.Invoke(titleId); - if (setAsDisplay) + bool added; + bool displayChanged; + lock (_gate) { - Volatile.Write(ref _displayTitleId, titleId); - Interlocked.Increment(ref _revision); - DisplayTitleChanged?.Invoke(titleId); + added = _earnedTitleIds.Add(titleId); + displayChanged = setAsDisplay && _displayTitleId != titleId; + if (displayChanged) + Volatile.Write(ref _displayTitleId, titleId); } + if (added) + Interlocked.Increment(ref _revision); + if (displayChanged) + Interlocked.Increment(ref _revision); + if (added) + TitleAdded?.Invoke(titleId); + if (displayChanged) + DisplayTitleChanged?.Invoke(titleId); } + /// + /// A2 (CT2 fix round): publishes the clear like + /// LocalPlayerState.Clear() fires + /// unconditionally and fires when the + /// display id was non-zero before the clear, so a failed/retried reset + /// attempt can safely converge (process-lived views pull through this + /// object and use these events as their invalidation edge). The + /// revision counter itself stays unconditional, matching this class's + /// pre-existing reset contract. + /// public void ResetSession() { - lock (_gate) _earnedTitleIds.Clear(); - Volatile.Write(ref _displayTitleId, 0u); + uint previousDisplayTitleId; + lock (_gate) + { + previousDisplayTitleId = _displayTitleId; + _earnedTitleIds.Clear(); + Volatile.Write(ref _displayTitleId, 0u); + } Interlocked.Increment(ref _revision); + TableReplaced?.Invoke(); + if (previousDisplayTitleId != 0u) + DisplayTitleChanged?.Invoke(0u); } } diff --git a/src/AcDream.Runtime/Session/DirectGameRuntimeCommandAdapter.cs b/src/AcDream.Runtime/Session/DirectGameRuntimeCommandAdapter.cs index 42e3b18e..e70ece3c 100644 --- a/src/AcDream.Runtime/Session/DirectGameRuntimeCommandAdapter.cs +++ b/src/AcDream.Runtime/Session/DirectGameRuntimeCommandAdapter.cs @@ -731,18 +731,22 @@ public sealed class DirectGameRuntimeCommandAdapter Validate(expectedGeneration, out WorldSession? session); if (gate != RuntimeCommandStatus.Accepted) return Result(gate); - if (titleId == 0u) - { - return EmitResult( - RuntimeCommandDomain.Character, - operation: 6, - RuntimeCommandStatus.Rejected); - } + // F3 (CT2 fix round, 2026-08-24): NO id-0 guard here — retail's own + // send path (Event_SetDisplayCharacterTitle @0x006a5720) packs + // whatever id it is handed, and ACE accepts id 0 + // (CharacterTitle.Invalid is a defined enum value). Retail's + // protection against sending an unearned/invalid id is the UI + // ghost-when-current gate (CT3's job), not a send-side rejection — + // a client-side guard here blocks a state the server honors. // CT2: NO optimistic local mutation — retail's own // Event_SetDisplayCharacterTitle send path touches no local state; // RuntimeCharacterState.Titles.DisplayTitleId updates only when the // server echoes UpdateTitle (0x002B) with setAsDisplay=true. session!.SendSetTitle(titleId); + // A6 (CT2 fix round): titleId rides the EmitResult primaryObjectId + // slot — same in-class precedent established for a typed + // domain-payload id, not always an object guid (see the S4 history + // comment on SaveOptions/EmitResult above). return EmitResult( RuntimeCommandDomain.Character, operation: 6, diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterTitleResolverLiveDatTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterTitleResolverLiveDatTests.cs index bbf3ff9a..bf4a8876 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterTitleResolverLiveDatTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterTitleResolverLiveDatTests.cs @@ -20,7 +20,8 @@ namespace AcDream.App.Tests.UI.Layout; public sealed class CharacterTitleResolverLiveDatTests { private static string DatDirectory => - Path.Combine( + System.Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR") + ?? Path.Combine( System.Environment.GetFolderPath(System.Environment.SpecialFolder.UserProfile), "Documents", "Asheron's Call"); diff --git a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCharacterTitleStateTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCharacterTitleStateTests.cs index 0aa1f1a2..fed205fc 100644 --- a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCharacterTitleStateTests.cs +++ b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCharacterTitleStateTests.cs @@ -119,19 +119,59 @@ public sealed class RuntimeCharacterTitleStateTests } [Fact] - public void ApplyUpdateTitle_AlreadyEarnedId_StillFiresTitleAddedUnconditionally() + public void ApplyUpdateTitle_AlreadyEarnedId_DoesNotFireTitleAddedOrBumpRevision() { - // Retail's own broadcast is unconditional — it does not check - // membership before firing the notice. + // F1 (CT2 fix round, 2026-08-24): the SERVER-side broadcast + // (SendNotice_AddCharacterTitle) is unconditional, but this method + // models the CLIENT-side receive handler + // (gmCharacterTitleUI::RecvNotice_AddCharacterTitle @0x0049a990), + // which walks mTitleList and returns without effect when the id is + // already present — only a genuine miss inserts + adds the row. var titles = new RuntimeCharacterTitleState(); titles.ApplyUpdateTitle(7u, setAsDisplay: false); + long revisionAfterFirstAdd = titles.Revision; var added = new List(); titles.TitleAdded += id => added.Add(id); titles.ApplyUpdateTitle(7u, setAsDisplay: false); - Assert.Equal([7u], added); + Assert.Empty(added); Assert.Single(titles.EarnedTitleIds); + Assert.Equal(revisionAfterFirstAdd, titles.Revision); + } + + [Fact] + public void ApplyUpdateTitle_SetAsDisplayOnAlreadyCurrentId_DoesNotFireDisplayTitleChangedOrBumpRevision() + { + // A5 (CT2 fix round): a re-notice for the id that is ALREADY the + // display title is a no-op wire message — it must not produce a + // revision edge or a spurious DisplayTitleChanged. + var titles = new RuntimeCharacterTitleState(); + titles.ApplyUpdateTitle(13u, setAsDisplay: true); + long revisionAfterFirst = titles.Revision; + var displayChanged = new List(); + titles.DisplayTitleChanged += id => displayChanged.Add(id); + var added = new List(); + titles.TitleAdded += id => added.Add(id); + + titles.ApplyUpdateTitle(13u, setAsDisplay: true); + + Assert.Empty(displayChanged); + Assert.Empty(added); + Assert.Equal(revisionAfterFirst, titles.Revision); + } + + [Fact] + public void ApplyUpdateTitle_AddsNewIdAndSetsDisplay_BumpsRevisionTwice() + { + // A5: a single 0x002B that both adds a NEW id and changes the + // display title bumps the revision once per real half-change. + var titles = new RuntimeCharacterTitleState(); + long before = titles.Revision; + + titles.ApplyUpdateTitle(13u, setAsDisplay: true); + + Assert.Equal(before + 2, titles.Revision); } [Fact] @@ -147,6 +187,25 @@ public sealed class RuntimeCharacterTitleStateTests Assert.Equal(99u, titles.DisplayTitleId); } + [Fact] + public void ReplaceTable_IdenticalResend_DoesNotBumpRevision_ButStillFiresTableReplaced() + { + // A5: a byte-identical 0x0029 resend (same table, same display id) + // is a no-op wire message for the revision counter. TableReplaced + // itself still fires unconditionally — retail's own + // RecvNotice_UpdateCharacterTitleTable always Refresh()es. + var titles = new RuntimeCharacterTitleState(); + titles.ReplaceTable(13u, [1u, 5u, 13u]); + long revisionAfterFirst = titles.Revision; + int tableReplacedCount = 0; + titles.TableReplaced += () => tableReplacedCount++; + + titles.ReplaceTable(13u, [1u, 5u, 13u]); + + Assert.Equal(revisionAfterFirst, titles.Revision); + Assert.Equal(1, tableReplacedCount); + } + [Fact] public void ResetSession_ClearsEarnedIdsAndDisplayTitle() { @@ -170,6 +229,57 @@ public sealed class RuntimeCharacterTitleStateTests Assert.True(titles.Revision > before); } + [Fact] + public void ResetSession_NonEmptyState_FiresTableReplacedAndDisplayTitleChanged() + { + // A2 (CT2 fix round): matches the LocalPlayerState.Clear() precedent + // — publish every category even when Clear is repeated, so a failed + // reset attempt can safely converge on retry. + var titles = new RuntimeCharacterTitleState(); + titles.ReplaceTable(13u, [1u, 5u, 13u]); + int tableReplacedCount = 0; + titles.TableReplaced += () => tableReplacedCount++; + var displayChanged = new List(); + titles.DisplayTitleChanged += id => displayChanged.Add(id); + + titles.ResetSession(); + + Assert.Equal(1, tableReplacedCount); + Assert.Equal([0u], displayChanged); + } + + [Fact] + public void ResetSession_RepeatedReset_StillFiresTableReplacedButNotDisplayTitleChanged() + { + // A2: TableReplaced publishes unconditionally on every reset; a + // SECOND reset (display id already 0) must not re-fire + // DisplayTitleChanged — there is no real transition to report. + var titles = new RuntimeCharacterTitleState(); + titles.ResetSession(); + int tableReplacedCount = 0; + titles.TableReplaced += () => tableReplacedCount++; + var displayChanged = new List(); + titles.DisplayTitleChanged += id => displayChanged.Add(id); + + titles.ResetSession(); + + Assert.Equal(1, tableReplacedCount); + Assert.Empty(displayChanged); + } + + [Fact] + public void Count_ReflectsEarnedTitleIdsWithoutAllocatingTheArray() + { + // A3 (CT2 fix round): non-allocating count accessor for hot paths + // like RuntimeCharacterState.CaptureOwnership. + var titles = new RuntimeCharacterTitleState(); + Assert.Equal(0, titles.Count); + + titles.ReplaceTable(13u, [1u, 5u, 13u]); + + Assert.Equal(3, titles.Count); + } + [Fact] public void Snapshot_ReflectsDisplayTitleAndCount() { diff --git a/tests/AcDream.Runtime.Tests/Session/DirectGameRuntimeCommandAdapterTests.cs b/tests/AcDream.Runtime.Tests/Session/DirectGameRuntimeCommandAdapterTests.cs index e61390e0..18a3d679 100644 --- a/tests/AcDream.Runtime.Tests/Session/DirectGameRuntimeCommandAdapterTests.cs +++ b/tests/AcDream.Runtime.Tests/Session/DirectGameRuntimeCommandAdapterTests.cs @@ -376,8 +376,14 @@ public sealed class DirectGameRuntimeCommandAdapterTests } [Fact] - public void SetTitle_ZeroTitleId_RejectsWithoutSendingAnything() + public void SetTitle_ZeroTitleId_StillSendsTheWireAction() { + // F3 (CT2 fix round, 2026-08-24): retail's own send path + // (Event_SetDisplayCharacterTitle @0x006a5720) packs whatever id it + // is handed, with no id-0 rejection — and ACE accepts id 0 + // (CharacterTitle.Invalid is a defined enum value). A client-side + // id-0 guard here would block a state the server honors; retail's + // real protection is the UI ghost-when-current gate (CT3's job). (GameRuntime runtime, DirectGameRuntimeCommandAdapter adapter, FixtureSessionOperations operations) = CreateStartedHarness(); var gameActions = new List(); @@ -387,8 +393,13 @@ public sealed class DirectGameRuntimeCommandAdapterTests runtime.Generation, titleId: 0u); - Assert.Equal(RuntimeCommandStatus.Rejected, result.Status); - Assert.Empty(gameActions); + Assert.True(result.Accepted); + Assert.Single(gameActions); + byte[] sent = gameActions[0]; + Assert.Equal( + SocialActions.TitleSetOpcode, + BinaryPrimitives.ReadUInt32LittleEndian(sent.AsSpan(8))); + Assert.Equal(0u, BinaryPrimitives.ReadUInt32LittleEndian(sent.AsSpan(12))); runtime.Dispose(); }