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
|
|
@ -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<uint>();
|
||||
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<uint>();
|
||||
titles.DisplayTitleChanged += id => displayChanged.Add(id);
|
||||
var added = new List<uint>();
|
||||
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<uint>();
|
||||
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<uint>();
|
||||
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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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<byte[]>();
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue