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>
69 lines
2.9 KiB
C#
69 lines
2.9 KiB
C#
using AcDream.App.UI.Layout;
|
|
using AcDream.Content;
|
|
using DatReaderWriter;
|
|
|
|
namespace AcDream.App.Tests.UI.Layout;
|
|
|
|
/// <summary>
|
|
/// Campaign CT slice CT2 (2026-08-24) pin: <see cref="CharacterTitleResolver"/>
|
|
/// against the installed DAT set, end to end (EnumMapper 0x22000041 ->
|
|
/// compute_str_hash -> StringTable 0x2300000E). CT1's research doc
|
|
/// (docs/research/2026-08-24-campaign-ct-dat-ground-truth.md §5) verified
|
|
/// id 13 == "War Mage" against ACE's <c>CharacterTitle.WarMage</c>; this
|
|
/// pin adds several more low-ordinal ids from the same enum
|
|
/// (<c>CharacterTitle.cs</c>: Invalid=0, Adventurer=1, Archer=2,
|
|
/// Blademaster=3, LifeMage=5, Wayfarer=14) so a DAT revision or resolver
|
|
/// regression fails loudly instead of drifting unnoticed into CT3/CT4.
|
|
/// Follows the durable-pin pattern of <see cref="ChatStringsLiveDatTests"/>.
|
|
/// </summary>
|
|
[Trait("Lane", "InstalledDat")]
|
|
public sealed class CharacterTitleResolverLiveDatTests
|
|
{
|
|
private static string DatDirectory =>
|
|
System.Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR")
|
|
?? Path.Combine(
|
|
System.Environment.GetFolderPath(System.Environment.SpecialFolder.UserProfile),
|
|
"Documents", "Asheron's Call");
|
|
|
|
[InstalledDatFact]
|
|
public void Resolve_PinsSeveralTitleIdsToTheirRetailDisplayStrings()
|
|
{
|
|
using var dats = new DatCollection(DatDirectory, DatReaderWriter.Options.DatAccessType.Read);
|
|
var resolver = new CharacterTitleResolver(new DatCollectionAdapter(dats));
|
|
|
|
// Retail's own early-return: id 0 (Invalid) never resolves.
|
|
Assert.Null(resolver.Resolve(0u));
|
|
|
|
Assert.Equal("Adventurer", resolver.Resolve(1u));
|
|
Assert.Equal("Archer", resolver.Resolve(2u));
|
|
Assert.Equal("Blademaster", resolver.Resolve(3u));
|
|
Assert.Equal("Life Mage", resolver.Resolve(5u));
|
|
Assert.Equal("War Mage", resolver.Resolve(13u));
|
|
Assert.Equal("Wayfarer", resolver.Resolve(14u));
|
|
}
|
|
|
|
[InstalledDatFact]
|
|
public void Resolve_UnmappedId_ReturnsNull()
|
|
{
|
|
using var dats = new DatCollection(DatDirectory, DatReaderWriter.Options.DatAccessType.Read);
|
|
var resolver = new CharacterTitleResolver(new DatCollectionAdapter(dats));
|
|
|
|
Assert.Null(resolver.Resolve(0xFFFFFFFEu));
|
|
}
|
|
|
|
[InstalledDatFact]
|
|
public void Resolve_CachesTheEnumMapperAcrossCalls()
|
|
{
|
|
// Not a durable behavioral pin — just confirms the lazy-load path
|
|
// used by the assertions above resolves the SAME value on a second
|
|
// call (the cached-mapper branch), not only on the first (cold) one.
|
|
using var dats = new DatCollection(DatDirectory, DatReaderWriter.Options.DatAccessType.Read);
|
|
var resolver = new CharacterTitleResolver(new DatCollectionAdapter(dats));
|
|
|
|
string? first = resolver.Resolve(13u);
|
|
string? second = resolver.Resolve(13u);
|
|
|
|
Assert.Equal("War Mage", first);
|
|
Assert.Equal(first, second);
|
|
}
|
|
}
|