fix(CT): CT2 fix round — dedupe client-side title add, drop retail-inexact send guard
Opus dual-lens review of CT2 (bcfddc97) found 4 SHOULD-FIX + notes; this
applies the campaign lead's rulings.
F1 (the important one): retail's client-side table add is DEDUPED —
gmCharacterTitleUI::RecvNotice_AddCharacterTitle @0x0049a990 walks
mTitleList and returns without effect when the id is already present,
only inserting on a miss. The server-side SendNotice_AddCharacterTitle
broadcast is unconditional, but RuntimeCharacterTitleState.ApplyUpdateTitle
models the CLIENT receive side, so TitleAdded now fires only on a genuine
new membership. Inverted the pin:
ApplyUpdateTitle_AlreadyEarnedId_DoesNotFireTitleAddedOrBumpRevision.
F3: removed the send-side titleId==0 rejection 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 real
protection is the UI ghost-when-current gate (CT3's job), not a
send-side rejection. No register row: this makes acdream MORE
retail-exact.
A2: ResetSession now publishes TableReplaced unconditionally and
DisplayTitleChanged when the display id was non-zero before the clear,
matching the LocalPlayerState.Clear() precedent (publish every category
even when Clear is repeated, so a failed reset can converge on retry).
A3: RuntimeCharacterState.CaptureOwnership reads the new non-allocating
Titles.Count instead of EarnedTitleIds.Count; EarnedTitleIds now carries
an XML warning that every read allocates.
A4/A5: ReplaceTable/ApplyUpdateTitle now mutate under one _gate hold with
change flags computed inside the lock and events raised after release;
every revision bump is gated on an actual state change (a no-op wire
resend produces zero revision edges), matching the change-gated
RuntimeMovementSkillState precedent. TableReplaced itself still fires
unconditionally per retail's own Refresh() dispatch on 0x0029.
A1/A6/A7/A8: CharacterTitleResolverLiveDatTests honors ACDREAM_DAT_DIR
first (CT1 fix-round pattern); documented the EmitResult
primaryObjectId-as-title-id precedent inline; corrected the "third
consumer" comment (CT1 §5 already records gmAttributeUI::PostInit's
icon-DID lookup — CT5 factors the shared GetDIDByEnum helper); added a
titleId -> resolved-string memo to CharacterTitleResolver, the DAT-static
equivalent of retail's lazy-hash cache on the string buffer.
Appended a "CT3 anchors from the CT2 review" list to the plan doc's CT2
ledger entry for CT3 to consume.
Build green. Runtime (102), Core.Net (12), and App (27 + 3 InstalledDat
pins under ACDREAM_RUN_INSTALLED_DAT_TESTS=1) title-scoped tests pass.
Full hermetic solution suite (Lane exclusions per the release gate) is
green: 0 failures across all 15 test projects.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
bcfddc97e7
commit
544f8cb2d7
8 changed files with 350 additions and 69 deletions
|
|
@ -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(
|
|||
/// <c>UpdateTitle</c> (never re-add an optimistic write here — the CA
|
||||
/// campaign lesson).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// CT2 fix round (2026-08-24), F1: the NOTICE broadcast is unconditional —
|
||||
/// retail's server-side <c>SendNotice_AddCharacterTitle</c> fires
|
||||
/// regardless of prior membership — but the client-side table ADD is
|
||||
/// DEDUPED: <c>gmCharacterTitleUI::RecvNotice_AddCharacterTitle
|
||||
/// @0x0049a990</c> walks <c>mTitleList</c> and returns without effect when
|
||||
/// the id is already present, only inserting + adding the row on a miss.
|
||||
/// <see cref="ApplyUpdateTitle"/> models the client-side receive handler,
|
||||
/// so <see cref="TitleAdded"/> fires only on a genuine new membership.
|
||||
/// </remarks>
|
||||
public sealed class RuntimeCharacterTitleState
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
|
|
@ -1245,32 +1255,54 @@ public sealed class RuntimeCharacterTitleState
|
|||
private uint _displayTitleId;
|
||||
private long _revision;
|
||||
|
||||
/// <summary>Fires after a full <c>0x0029 CharacterTitle</c> table replace.</summary>
|
||||
/// <summary>
|
||||
/// Fires after every <c>0x0029 CharacterTitle</c> table replace,
|
||||
/// unconditionally — matches retail's own
|
||||
/// <c>gmCharacterTitleUI::RecvNotice_UpdateCharacterTitleTable</c>,
|
||||
/// which always calls <c>Refresh()</c> regardless of whether the new
|
||||
/// table differs from the old one.
|
||||
/// </summary>
|
||||
public event Action? TableReplaced;
|
||||
|
||||
/// <summary>
|
||||
/// Fires once per <c>0x002B UpdateTitle</c> arrival, UNCONDITIONALLY —
|
||||
/// matches retail's own <c>SendNotice_AddCharacterTitle</c>, which
|
||||
/// broadcasts regardless of whether the id was already in the earned
|
||||
/// set. Carries the added title id.
|
||||
/// Fires only when an <c>0x002B UpdateTitle</c> arrival actually adds a
|
||||
/// NEW id to the earned set — matches retail's client-side
|
||||
/// <c>gmCharacterTitleUI::RecvNotice_AddCharacterTitle @0x0049a990</c>,
|
||||
/// which dedupes against <c>mTitleList</c> before inserting (see the
|
||||
/// class remarks: the F1 fix-round correction). Carries the added
|
||||
/// title id.
|
||||
/// </summary>
|
||||
public event Action<uint>? TitleAdded;
|
||||
|
||||
/// <summary>
|
||||
/// Fires whenever the display title id changes — from either a fresh
|
||||
/// <c>0x0029</c> table (a differing seed) or an <c>0x002B</c> whose
|
||||
/// <c>setAsDisplay</c> flag is set. Carries the NEW display title id.
|
||||
/// Fires whenever the display title id actually changes — from either a
|
||||
/// fresh <c>0x0029</c> table (a differing seed) or an <c>0x002B</c>
|
||||
/// whose <c>setAsDisplay</c> flag is set to an id that differs from the
|
||||
/// current display title. Carries the NEW display title id.
|
||||
/// </summary>
|
||||
public event Action<uint>? DisplayTitleChanged;
|
||||
|
||||
public uint DisplayTitleId => Volatile.Read(ref _displayTitleId);
|
||||
public long Revision => Interlocked.Read(ref _revision);
|
||||
|
||||
/// <summary>
|
||||
/// WARNING: every read allocates a fresh array (<c>ToArray()</c> under
|
||||
/// the gate). Fine for UI refresh call sites (CT3), but NEVER read this
|
||||
/// per-frame — use <see cref="Count"/> or <see cref="HasEarnedTitle"/>
|
||||
/// for hot-path checks.
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<uint> EarnedTitleIds
|
||||
{
|
||||
get { lock (_gate) return _earnedTitleIds.ToArray(); }
|
||||
}
|
||||
|
||||
/// <summary>Non-allocating earned-title count; prefer this over
|
||||
/// <c>EarnedTitleIds.Count</c> in hot paths (A3, CT2 fix round).</summary>
|
||||
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
|
|||
/// <c>0x0029 CharacterTitle</c> — a WHOLESALE authoritative replace
|
||||
/// (retail's <c>CharacterTitleTable::UnPack</c> always rebuilds
|
||||
/// <c>mTitleList</c> 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
|
||||
/// <see cref="_gate"/> 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); <see cref="TableReplaced"/>
|
||||
/// itself still fires unconditionally, matching retail's own
|
||||
/// unconditional <c>Refresh()</c> dispatch on this opcode.
|
||||
/// </summary>
|
||||
public void ReplaceTable(uint displayTitleId, IReadOnlyList<uint> 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
|
|||
|
||||
/// <summary>
|
||||
/// <c>0x002B UpdateTitle</c> — retail's
|
||||
/// <c>ClientUISystem::Handle_Social__AddOrSetCharacterTitle</c>: ALWAYS
|
||||
/// add, and additionally set-display only when
|
||||
/// <paramref name="setAsDisplay"/> is true.
|
||||
/// <c>ClientUISystem::Handle_Social__AddOrSetCharacterTitle</c> ALWAYS
|
||||
/// broadcasts the add notice server-side, and additionally broadcasts
|
||||
/// set-display only when <paramref name="setAsDisplay"/> is true. On
|
||||
/// the CLIENT receive side this method models, F1 (CT2 fix round): the
|
||||
/// add is deduped (<see cref="TitleAdded"/> fires only when
|
||||
/// <c>HashSet<uint>.Add</c> reports a genuine new membership,
|
||||
/// matching <c>gmCharacterTitleUI::RecvNotice_AddCharacterTitle
|
||||
/// @0x0049a990</c>'s membership check before insert). A4: both halves
|
||||
/// mutate under one <see cref="_gate"/> 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 <paramref name="setAsDisplay"/> pointing at
|
||||
/// the already-current display id, up to twice when both halves change.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A2 (CT2 fix round): publishes the clear like
|
||||
/// <c>LocalPlayerState.Clear()</c> — <see cref="TableReplaced"/> fires
|
||||
/// unconditionally and <see cref="DisplayTitleChanged"/> 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.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue