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
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -17,11 +17,16 @@ namespace AcDream.App.UI.Layout;
|
|||
/// resolved DIDs are hardcoded here rather than porting the indirection
|
||||
/// generically — the same precedent <see cref="RetailKeyNames"/> already
|
||||
/// set for this exact category-4 family (its <c>KeyNameTableId</c>/
|
||||
/// <c>MetaKeyNameTableId</c>/<c>DelimiterTableId</c>); factor out a shared
|
||||
/// helper only if a THIRD consumer of <c>GetDIDByEnum</c> appears. Both DIDs
|
||||
/// were verified end-to-end against ACE's <c>CharacterTitle.WarMage = 13</c>
|
||||
/// in Campaign CT slice CT1
|
||||
/// (docs/research/2026-08-24-campaign-ct-dat-ground-truth.md §5, pinned by
|
||||
/// <c>MetaKeyNameTableId</c>/<c>DelimiterTableId</c>). A THIRD consumer of
|
||||
/// <c>GetDIDByEnum</c> has already appeared —
|
||||
/// <c>gmAttributeUI::PostInit @0x0049DB70</c> resolves per-attribute icon
|
||||
/// DIDs via category <c>0x10000002</c> — so CT5 (not "if a third consumer
|
||||
/// appears") is where the shared <c>GetDIDByEnum(enumValue, category)</c>
|
||||
/// 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
|
||||
/// <c>CharacterTitle.WarMage = 13</c> in Campaign CT slice CT1 (same §5,
|
||||
/// pinned by
|
||||
/// <c>CharacterPanelLiveDatTests.TitleStringTable_ResolvesWarMageEndToEnd</c>).
|
||||
/// </para>
|
||||
/// <para>
|
||||
|
|
@ -42,6 +47,7 @@ public sealed class CharacterTitleResolver
|
|||
|
||||
private readonly IDatReaderWriter _dats;
|
||||
private readonly DatStringResolver _strings;
|
||||
private readonly Dictionary<uint, string?> _resolvedCache = new();
|
||||
private EnumMapper? _titleEnumMapper;
|
||||
private bool _loadedMapper;
|
||||
|
||||
|
|
@ -59,27 +65,38 @@ public sealed class CharacterTitleResolver
|
|||
/// <see cref="DatStringResolver.Resolve(uint, uint, int)"/>'s own
|
||||
/// null-on-miss contract.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A8 (CT2 fix round, 2026-08-24): memoizes the final resolved string
|
||||
/// per <paramref name="titleId"/> (including misses). Retail caches the
|
||||
/// computed hash directly on the string buffer
|
||||
/// (<c>GetCharacterTitleFromID</c>'s <c>0xFFFFFFFF</c> lazy-hash
|
||||
/// sentinel); a memo of the final string is the equivalent here given
|
||||
/// our immutable, DAT-static tables — no invalidation is needed.
|
||||
/// </remarks>
|
||||
public string? Resolve(uint titleId)
|
||||
{
|
||||
if (titleId == 0u)
|
||||
return null;
|
||||
|
||||
if (_resolvedCache.TryGetValue(titleId, out string? cached))
|
||||
return cached;
|
||||
|
||||
if (!_loadedMapper)
|
||||
{
|
||||
_dats.Portal.TryGet<EnumMapper>(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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue