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 b36eb2d5..ca44648b 100644 --- a/docs/plans/2026-08-24-character-panel-parity-campaign.md +++ b/docs/plans/2026-08-24-character-panel-parity-campaign.md @@ -110,8 +110,9 @@ the composing function verbatim before writing a line of C#. 0x10000539`); pages currently show retail-authored closed visuals. - Header labels partially bound (`StatHeaderLine` + `PkStatus` seams exist in `CharacterStatController.Bind` — content contract wrong). -- `GameEventType.CharacterTitle/UpdateTitle` enum entries exist; no - parser, no state owner, no outbound builder. +- `GameEventType.CharacterTitle/UpdateTitle` enum entries exist and now + (CT2, landed) have a parser, a `RuntimeCharacterTitleState` owner, and + an outbound `TitleSet` builder — see CT2's paragraph below. - The character window registers with `DatConstraintSource` — authored min/max plumbing exists in `RetailWindowFrame`; Y-resize for this window and the list-scrollbar contract do not. @@ -145,13 +146,37 @@ rows), the value-column right margin, header element fonts/colors min/max constraints. Output: research doc + InstalledDat pins (the tooltip/scrollbar-pin pattern). No production changes. -**CT2 — Runtime title ownership + wire.** Parse `0x0029`/`0x002B`; -locate the DAT title-string table `GetCharacterTitleFromID` reads and -port the lookup; `RuntimeCharacterState` owns the title set + display -title (J4.3 owner; clears at generation reset); outbound `TitleSet` -builder behind a typed Runtime command; ordered change events for UI -and headless bots (#368 contract: hosts observe the same owner). -Conformance tests against ACE's writer shapes. +**CT2 — Runtime title ownership + wire. LANDED 2026-08-24.** Parsed +`0x0029 CharacterTitle` (retail's `CharacterTitleTable::UnPack +@0x005c6e90` — the leading ACE `1u`/retail-Pack-constant field is +discarded, matching retail's own read) and `0x002B UpdateTitle` +(`CM_Social::DispatchUI_AddOrSetCharacterTitle @0x006a54c0`: title id + +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 +`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 +`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 +introduces no retail deviation. App-layer `CharacterTitleResolver` +(`src/AcDream.App/UI/Layout/CharacterTitleResolver.cs`) ports +`GetCharacterTitleFromID`'s EnumMapper(`0x22000041`) → hash → +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. **CT3 — Titles page UI.** Bind the authored page through the standard GUI classes (`UiTemplateListBox`/`UiScrollbar`/`UiButton` — zero diff --git a/src/AcDream.App/Net/LiveSessionCommandRouter.cs b/src/AcDream.App/Net/LiveSessionCommandRouter.cs index 9b7aa83a..c2969e76 100644 --- a/src/AcDream.App/Net/LiveSessionCommandRouter.cs +++ b/src/AcDream.App/Net/LiveSessionCommandRouter.cs @@ -55,6 +55,10 @@ internal sealed record LiveSessionCommandBindings( // research §2.3-§2.7. No-ops when the batched module is clean, matching // retail's CPlayerModule::SaveToServer(force: 0). Action SaveCharacterOptions, + // Campaign CT slice CT2 (2026-08-24): TitleSet (0x002C) — sends only, no + // local mutation (RuntimeCharacterState.Titles updates from the + // server's own echo). + Action SendSetTitle, // Campaign FA slice FA2 (2026-08-12): fellowship + allegiance send // wrappers, the App-bus twin of DirectGameRuntimeCommandAdapter's // direct session.SendXxx calls. @@ -95,6 +99,7 @@ internal readonly record struct SetSingleCharacterOptionRuntimeCmd( uint OptionId, bool Value); internal readonly record struct SaveCharacterOptionsRuntimeCmd; +internal readonly record struct SetTitleRuntimeCmd(uint TitleId); internal readonly record struct AddFriendRuntimeCmd(string Name); internal readonly record struct RemoveFriendRuntimeCmd(uint CharacterId); @@ -228,6 +233,8 @@ internal sealed class LiveSessionCommandRouter : ILiveSessionCommandRouting command.Value))); commands.Register( _ => SendIfActive(bindings.SaveCharacterOptions)); + commands.Register( + command => SendIfActive(() => bindings.SendSetTitle(command.TitleId))); commands.Register( command => SendIfActive(() => bindings.AddFriend(command.Name))); commands.Register( diff --git a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs index fe3a19c0..9aa33819 100644 --- a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs +++ b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs @@ -770,6 +770,8 @@ internal sealed class LiveSessionRuntimeFactory CharacterState: _domain.Character, SendSingleCharacterOption: SendSingleCharacterOption, SaveCharacterOptions: SaveCharacterOptionsIfDirty, + // Campaign CT slice CT2 (2026-08-24). + SendSetTitle: session.SendSetTitle, // Campaign FA slice FA2 (2026-08-12): fellowship + allegiance send // wrappers, matching the WorldSession.SendXxx methods FA2 added. SendFellowshipCreate: session.SendFellowshipCreate, diff --git a/src/AcDream.App/Runtime/CurrentGameRuntimeCommandAdapter.cs b/src/AcDream.App/Runtime/CurrentGameRuntimeCommandAdapter.cs index 7ec92c68..e7e8ca90 100644 --- a/src/AcDream.App/Runtime/CurrentGameRuntimeCommandAdapter.cs +++ b/src/AcDream.App/Runtime/CurrentGameRuntimeCommandAdapter.cs @@ -718,6 +718,31 @@ internal sealed class CurrentGameRuntimeCommandAdapter RuntimeCommandStatus.Accepted); } + public RuntimeCommandResult SetTitle( + RuntimeGenerationToken expectedGeneration, + uint titleId) + { + RuntimeCommandStatus gate = Validate(expectedGeneration, requireWorld: true); + if (gate != RuntimeCommandStatus.Accepted) + return Result(gate); + if (titleId == 0u) + { + return EmitResult( + RuntimeCommandDomain.Character, + operation: 6, + RuntimeCommandStatus.Rejected); + } + // CT2: NO optimistic local mutation — matches + // DirectGameRuntimeCommandAdapter.SetTitle; RuntimeCharacterState. + // Titles updates only from the server's own echo. + _commands.Publish(new SetTitleRuntimeCmd(titleId)); + return EmitResult( + RuntimeCommandDomain.Character, + operation: 6, + RuntimeCommandStatus.Accepted, + titleId); + } + public RuntimeCommandResult Execute( RuntimeGenerationToken expectedGeneration, in RuntimeFriendCommand command) diff --git a/src/AcDream.App/UI/Layout/CharacterTitleResolver.cs b/src/AcDream.App/UI/Layout/CharacterTitleResolver.cs new file mode 100644 index 00000000..f967b288 --- /dev/null +++ b/src/AcDream.App/UI/Layout/CharacterTitleResolver.cs @@ -0,0 +1,85 @@ +using AcDream.Content; +using DatReaderWriter.DBObjs; + +namespace AcDream.App.UI.Layout; + +/// +/// Ports CharacterTitleTable::GetCharacterTitleFromID @0x005c6ed0's +/// title id -> display string chain: EnumMapper(0x22000041) +/// canonical key name -> compute_str_hash -> +/// StringTable(0x2300000E) localized text. +/// +/// +/// +/// Retail's chain is a two-level DBObj::GetDIDByEnum indirection +/// through a master map (DID 0x25000000): category 1 (EMAPPER) for +/// the EnumMapper, category 4 (STRINGTABLE) for the StringTable. Both +/// 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 +/// CharacterPanelLiveDatTests.TitleStringTable_ResolvesWarMageEndToEnd). +/// +/// +/// stays +/// id-only (the #368 headless-bot-observes-the-same-owner contract); this +/// resolver is the App-layer seam CT3 (Titles page) and CT4 (header +/// identity line) consume for display strings. +/// +/// +public sealed class CharacterTitleResolver +{ + /// EnumMapper DID — title id -> canonical key name (e.g. + /// "ID_CharacterTitle_War_Mage"). + public const uint TitleEnumMapperId = 0x22000041u; + + /// StringTable DID — canonical key hash -> localized text. + public const uint TitleStringTableId = 0x2300000Eu; + + private readonly IDatReaderWriter _dats; + private readonly DatStringResolver _strings; + private EnumMapper? _titleEnumMapper; + private bool _loadedMapper; + + public CharacterTitleResolver(IDatReaderWriter dats) + { + _dats = dats ?? throw new ArgumentNullException(nameof(dats)); + _strings = new DatStringResolver(dats); + } + + /// + /// Resolves one title id to its localized display string. Returns null + /// for id 0 (retail's own early-return in + /// GetCharacterTitleFromID), an id absent from the EnumMapper, or + /// an unlocalized string — matching + /// 's own + /// null-on-miss contract. + /// + public string? Resolve(uint titleId) + { + if (titleId == 0u) + return null; + + if (!_loadedMapper) + { + _dats.Portal.TryGet(TitleEnumMapperId, out _titleEnumMapper); + _loadedMapper = true; + } + + if (_titleEnumMapper is null + || !_titleEnumMapper.IdToStringMap.TryGetValue(titleId, out var rawNameValue)) + { + return null; + } + + string rawName = rawNameValue.ToString(); + if (string.IsNullOrEmpty(rawName)) + return null; + + return _strings.Resolve(TitleStringTableId, DatStringResolver.ComputeHash(rawName)); + } +} diff --git a/src/AcDream.Core.Net/GameEventWiring.cs b/src/AcDream.Core.Net/GameEventWiring.cs index 554008fc..3ce26e23 100644 --- a/src/AcDream.Core.Net/GameEventWiring.cs +++ b/src/AcDream.Core.Net/GameEventWiring.cs @@ -138,7 +138,15 @@ public static class GameEventWiring // in GameEventType since the wire catalog with nothing behind them, // so until this wiring the bytes arrived and were dropped. Action>? onContractTable = null, - Action? onContractUpdate = null) + Action? onContractUpdate = null, + // Campaign CT slice CT2 (2026-08-24): the title-table (0x0029) and + // add/set-display (0x002B) delegate holes. RuntimeCharacterState. + // Titles is an AcDream.Runtime type — Core.Net cannot reference + // AcDream.Runtime directly, so these are delegate holes exactly like + // every other Runtime-owned sink above. Optional/nullable so every + // existing caller compiles unchanged. + Action /*titleIds*/>? onCharacterTitleTable = null, + Action? onUpdateTitle = null) { ArgumentNullException.ThrowIfNull(dispatcher); ArgumentNullException.ThrowIfNull(items); @@ -474,6 +482,24 @@ public static class GameEventWiring }); } + // ── Character titles (Campaign CT slice CT2, 2026-08-24) ──────── + if (onCharacterTitleTable is not null) + { + registrar.Register(GameEventType.CharacterTitle, e => + { + var p = GameEvents.ParseCharacterTitleTable(e.Payload.Span); + if (p is not null) onCharacterTitleTable(p.Value.DisplayTitleId, p.Value.TitleIds); + }); + } + if (onUpdateTitle is not null) + { + registrar.Register(GameEventType.UpdateTitle, e => + { + var p = GameEvents.ParseUpdateTitle(e.Payload.Span); + if (p is not null) onUpdateTitle(p.Value.TitleId, p.Value.SetAsDisplay); + }); + } + if (onConfirmationRequest is not null) { registrar.Register(GameEventType.CharacterConfirmationRequest, e => diff --git a/src/AcDream.Core.Net/Messages/GameEvents.cs b/src/AcDream.Core.Net/Messages/GameEvents.cs index 4226fb0b..e14ac19f 100644 --- a/src/AcDream.Core.Net/Messages/GameEvents.cs +++ b/src/AcDream.Core.Net/Messages/GameEvents.cs @@ -869,6 +869,73 @@ public static class GameEvents public static FellowshipFellowStatsDone ParseFellowshipFellowStatsDone(ReadOnlySpan payload) => new(payload.Length >= 4 ? BinaryPrimitives.ReadUInt32LittleEndian(payload) : null); + // ── Character titles (Campaign CT slice CT2, 2026-08-24) ──────────────── + + /// + /// 0x0029 CharacterTitle — retail CharacterTitleTable::UnPack + /// @0x005c6e90 (named-retail pseudo-C offset 471514-471526). The + /// FIRST u32 is advanced past but never stored into any field — retail's + /// own CharacterTitleTable::Pack @0x005c6e40 (offset 471494-471510) + /// always writes the literal constant 1 there + /// (**(uint32_t**)arg2 = 1), and ACE's + /// GameEventCharacterTitle.cs matches with an unconditional + /// Writer.Write(1u) — a version/format tag retail itself discards + /// on read, not meaningful gameplay data (CT2 task item 1). Then the + /// current display title id (mDisplayTitle), then the + /// count-prefixed PList<uint> of every earned title id + /// (mTitleList). + /// + public readonly record struct CharacterTitleTable( + uint DisplayTitleId, + IReadOnlyList TitleIds); + + public static CharacterTitleTable? ParseCharacterTitleTable(ReadOnlySpan payload) + { + try + { + int pos = 0; + _ = FellowshipReadU32(payload, ref pos); // discarded pack-version tag — see doc comment above + uint displayTitleId = FellowshipReadU32(payload, ref pos); + uint count = FellowshipReadU32(payload, ref pos); + // PList::UnPack stores a 32-bit count bounded only by the + // remaining packet — same generous guard as + // SocialStateMessages.ParseFriendsUpdate. + if (count > 65_536) return null; + var titleIds = new uint[count]; + for (int i = 0; i < titleIds.Length; i++) + titleIds[i] = FellowshipReadU32(payload, ref pos); + return new CharacterTitleTable(displayTitleId, titleIds); + } + catch (FormatException) { return null; } + } + + /// + /// 0x002B UpdateTitle — retail's dispatch entry + /// CM_Social::DispatchUI_AddOrSetCharacterTitle @0x006a54c0 reads + /// exactly titleId then setAsDisplay and forwards to + /// ClientUISystem::Handle_Social__AddOrSetCharacterTitle + /// @0x00564260, which ALWAYS broadcasts + /// SendNotice_AddCharacterTitle(titleId) (a title just earned is + /// unconditionally added to the earned set) and, only when + /// setAsDisplay != 0, ALSO broadcasts + /// SendNotice_SetDisplayCharacterTitle(titleId). ACE's + /// GameEventUpdateTitle.cs: u32 title, u32 + /// setAsDisplayTitle — matches exactly. + /// + public readonly record struct UpdateTitle(uint TitleId, bool SetAsDisplay); + + public static UpdateTitle? ParseUpdateTitle(ReadOnlySpan payload) + { + try + { + int pos = 0; + uint titleId = FellowshipReadU32(payload, ref pos); + bool setAsDisplay = FellowshipReadU32(payload, ref pos) != 0u; + return new UpdateTitle(titleId, setAsDisplay); + } + catch (FormatException) { return null; } + } + private static FellowMember ReadFellow(ReadOnlySpan payload, ref int pos, uint guid) { uint cpCache = FellowshipReadU32(payload, ref pos); diff --git a/src/AcDream.Core.Net/Messages/SocialActions.cs b/src/AcDream.Core.Net/Messages/SocialActions.cs index 02ef46c8..2ca551e7 100644 --- a/src/AcDream.Core.Net/Messages/SocialActions.cs +++ b/src/AcDream.Core.Net/Messages/SocialActions.cs @@ -52,6 +52,16 @@ public static class SocialActions public const uint FellowshipAssignNewLeaderOpcode = 0x0290u; // u32 newLeaderGuid public const uint FellowshipChangeOpennessOpcode = 0x0291u; // u32 isOpen (0/1) — the REAL openness toggle + // Character titles (Campaign CT slice CT2, 2026-08-24). ACE + // GameActionType.TitleSet = 0x002C; GameActionSetTitle.cs reads exactly + // one u32 title id (session.Player.HandleActionSetTitle(title)). Retail + // sender CM_Social::Event_SetDisplayCharacterTitle @0x006a5720 (verified + // in the named pseudo-C): builds a 0x10-byte OrderHdr'd body whose + // payload is [u32 0x2c][u32 titleId] and sends via + // Proto_UI::SendToWeenie — no local state touched (see CT2's report: no + // optimistic mutation, matching this slice's CA-lesson design). + public const uint TitleSetOpcode = 0x002Cu; // u32 titleId + // Character options // CH3 (2026-08-09): the full-blob SetCharacterOptions (0x01A1) builder // and the string-payload AddChannel/RemoveChannel (0x0145/0x0146) @@ -230,6 +240,17 @@ public static class SocialActions return body; } + /// + /// Set the character's display title — 0x002C TitleSet. Retail's + /// Event_SetDisplayCharacterTitle sends this and touches NO local + /// state; the display title updates only when the server echoes back + /// UpdateTitle (0x002B) with setAsDisplay=true (CT2: no + /// optimistic local mutation, matching retail exactly — the CA-campaign + /// lesson never re-add one). + /// + public static byte[] BuildTitleSet(uint seq, uint titleId) + => SingleGuid(seq, TitleSetOpcode, titleId); + /// /// Toggle one character option and push it to the server. /// GameActionSetSingleCharacterOption @ GameActionType 0x0005 — diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs index 60d378fe..35f5888e 100644 --- a/src/AcDream.Core.Net/WorldSession.cs +++ b/src/AcDream.Core.Net/WorldSession.cs @@ -2719,6 +2719,18 @@ public sealed class WorldSession : IDisposable spellbookFilters)); } + /// + /// Send retail TitleSet (0x002C) — sets the character's display + /// title. Sends only; no local state changes here (Campaign CT slice + /// CT2: retail's own send path is non-optimistic — see + /// ). + /// + public void SendSetTitle(uint titleId) + { + uint seq = NextGameActionSequence(); + SendGameAction(SocialActions.BuildTitleSet(seq, titleId)); + } + public void SendAddFriend(string name) { uint seq = NextGameActionSequence(); diff --git a/src/AcDream.Runtime/GameRuntimeCommands.cs b/src/AcDream.Runtime/GameRuntimeCommands.cs index cef6e78d..d593910f 100644 --- a/src/AcDream.Runtime/GameRuntimeCommands.cs +++ b/src/AcDream.Runtime/GameRuntimeCommands.cs @@ -268,6 +268,19 @@ public interface IRuntimeCharacterCommands /// production call sites — Apply, logout — pass force = 0). /// RuntimeCommandResult SaveOptions(RuntimeGenerationToken expectedGeneration); + + /// + /// Retail TitleSet (GameActionType 0x002C) — sets the character's + /// display title (Campaign CT slice CT2, 2026-08-24; + /// CM_Social::Event_SetDisplayCharacterTitle @0x006a5720). Sends + /// only — no optimistic local mutation. The display title updates when + /// the server echoes UpdateTitle (0x002B) with + /// setAsDisplay=true, or the next CharacterTitle (0x0029) + /// table arrives, through RuntimeCharacterState.Titles. + /// + RuntimeCommandResult SetTitle( + RuntimeGenerationToken expectedGeneration, + uint titleId); } public enum RuntimeFriendCommandKind diff --git a/src/AcDream.Runtime/GameRuntimeGameplayViews.cs b/src/AcDream.Runtime/GameRuntimeGameplayViews.cs index ade50b95..e8277d1e 100644 --- a/src/AcDream.Runtime/GameRuntimeGameplayViews.cs +++ b/src/AcDream.Runtime/GameRuntimeGameplayViews.cs @@ -42,7 +42,11 @@ public readonly record struct RuntimeCharacterSnapshot( int ActiveEnchantmentCount, int DesiredComponentCount, int SkillCount, - uint SpellbookFilters); + uint SpellbookFilters, + // Campaign CT slice CT2 (2026-08-24): trailing/optional so every + // existing positional caller (GameRuntimeContractTests) compiles + // unchanged. + RuntimeCharacterTitleSnapshot Titles = default); public readonly record struct RuntimeVitalSnapshot( int Kind, diff --git a/src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs b/src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs index 4e5a86fa..1fce941c 100644 --- a/src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs +++ b/src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs @@ -30,7 +30,18 @@ public readonly record struct RuntimeCharacterOwnershipSnapshot( /// still set — the ledger exists precisely to catch state a reset must /// clear but a value-only comparison would miss. /// - bool OptionsAreClean = true) + bool OptionsAreClean = true, + /// + /// Campaign CT slice CT2 (2026-08-24): the earned-title set's count — + /// zero when has never + /// received a table and after every reset. + /// + int TitleCount = 0, + /// + /// CT2: Titles.DisplayTitleId == 0 — the client-constructor + /// default (no display title id known yet). + /// + bool DisplayTitleIsDefault = true) { public bool IsConverged => IsDisposed @@ -47,7 +58,9 @@ public readonly record struct RuntimeCharacterOwnershipSnapshot( && OptionsAreDefaults && MovementSkillsAreReset && AutonomyIsDefault - && OptionsAreClean; + && OptionsAreClean + && TitleCount == 0 + && DisplayTitleIsDefault; } /// @@ -104,6 +117,7 @@ public sealed class RuntimeCharacterState : IDisposable LocalPlayer = new LocalPlayerState(Spellbook); Options = new RuntimeCharacterOptionsState(timeProvider); MovementSkills = new RuntimeMovementSkillState(); + Titles = new RuntimeCharacterTitleState(); View = new CharacterView(this); Spellbook.StateChanged += OnSpellbookChanged; Spellbook.EnchantmentsChanged += OnEnchantmentsChangedForMovement; @@ -117,6 +131,11 @@ public sealed class RuntimeCharacterState : IDisposable public LocalPlayerState LocalPlayer { get; } public RuntimeCharacterOptionsState Options { get; } public RuntimeMovementSkillState MovementSkills { get; } + /// Campaign CT slice CT2 (2026-08-24): earned titles + current + /// display title (retail's CharacterTitleTable). Id-only — + /// display-string resolution is an App-layer concern + /// (CharacterTitleResolver). + public RuntimeCharacterTitleState Titles { get; } public IRuntimeCharacterView View { get; } public bool IsDisposed => _disposed; @@ -232,7 +251,9 @@ public sealed class RuntimeCharacterState : IDisposable && _jumpSkillBase == -1 && _movementSkillAugmentations == default, AutonomyLevel == FullAutonomyLevel, - OptionsAreClean: !Options.IsDirty); + OptionsAreClean: !Options.IsDirty, + TitleCount: Titles.EarnedTitleIds.Count, + DisplayTitleIsDefault: Titles.DisplayTitleId == 0u); } /// @@ -445,6 +466,7 @@ public sealed class RuntimeCharacterState : IDisposable _movementSkillAugmentations = default; Volatile.Write(ref _autonomyLevel, FullAutonomyLevel); Try(MovementSkills.ResetSession, ref failures); + Try(Titles.ResetSession, ref failures); if (failures is not null) { throw new AggregateException( @@ -471,6 +493,7 @@ public sealed class RuntimeCharacterState : IDisposable _movementSkillAugmentations = default; Volatile.Write(ref _autonomyLevel, FullAutonomyLevel); Try(MovementSkills.ResetSession, ref failures); + Try(Titles.ResetSession, ref failures); } finally { @@ -526,7 +549,8 @@ public sealed class RuntimeCharacterState : IDisposable owner.Spellbook.ActiveEnchantments.Count(), owner.Spellbook.DesiredComponents.Count, owner.LocalPlayer.Skills.Count, - owner.Spellbook.SpellbookFilters); + owner.Spellbook.SpellbookFilters, + owner.Titles.Snapshot); public bool TryGetVital(int kind, out RuntimeVitalSnapshot vital) { @@ -1189,3 +1213,125 @@ public sealed class RuntimeMovementSkillState Interlocked.Increment(ref _revision); } } + +public readonly record struct RuntimeCharacterTitleSnapshot( + uint DisplayTitleId, + int TitleCount, + long Revision); + +/// +/// Campaign CT slice CT2 (2026-08-24): retail's CharacterTitleTable +/// (mDisplayTitle + mTitleList) ported into +/// 's existing options/movement-skill +/// sibling-owner shape. Two inbound wire events populate this: the full +/// table (0x0029 CharacterTitle, ) and the +/// incremental add/set-display notice (0x002B UpdateTitle, +/// ). Runtime stays id-only — display-string +/// resolution (EnumMapper -> hash -> StringTable, +/// CharacterTitleTable::GetCharacterTitleFromID @0x005c6ed0) is an +/// App-layer concern (AcDream.App.UI.Layout.CharacterTitleResolver), +/// matching this class's presentation-independent contract and the #368 +/// headless-bot-observes-the-same-owner rule. Outbound +/// TitleSet (0x002C) never mutates this state locally — retail's own +/// Event_SetDisplayCharacterTitle send path touches no local field; +/// the display title changes only when the server echoes back +/// UpdateTitle (never re-add an optimistic write here — the CA +/// campaign lesson). +/// +public sealed class RuntimeCharacterTitleState +{ + private readonly object _gate = new(); + private readonly HashSet _earnedTitleIds = new(); + private uint _displayTitleId; + private long _revision; + + /// Fires after a full 0x0029 CharacterTitle table replace. + 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. + /// + 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. + /// + public event Action? DisplayTitleChanged; + + public uint DisplayTitleId => Volatile.Read(ref _displayTitleId); + public long Revision => Interlocked.Read(ref _revision); + + public IReadOnlyCollection EarnedTitleIds + { + get { lock (_gate) return _earnedTitleIds.ToArray(); } + } + + public bool HasEarnedTitle(uint titleId) + { + lock (_gate) return _earnedTitleIds.Contains(titleId); + } + + public RuntimeCharacterTitleSnapshot Snapshot + { + get + { + int count; + lock (_gate) count = _earnedTitleIds.Count; + return new RuntimeCharacterTitleSnapshot(DisplayTitleId, count, Revision); + } + } + + /// + /// 0x0029 CharacterTitle — a WHOLESALE authoritative replace + /// (retail's CharacterTitleTable::UnPack always rebuilds + /// mTitleList from scratch; there is no incremental-merge path + /// on this opcode). + /// + public void ReplaceTable(uint displayTitleId, IReadOnlyList titleIds) + { + ArgumentNullException.ThrowIfNull(titleIds); + lock (_gate) + { + _earnedTitleIds.Clear(); + foreach (uint id in titleIds) + _earnedTitleIds.Add(id); + } + bool displayChanged = DisplayTitleId != displayTitleId; + Volatile.Write(ref _displayTitleId, displayTitleId); + Interlocked.Increment(ref _revision); + TableReplaced?.Invoke(); + if (displayChanged) + DisplayTitleChanged?.Invoke(displayTitleId); + } + + /// + /// 0x002B UpdateTitle — retail's + /// ClientUISystem::Handle_Social__AddOrSetCharacterTitle: ALWAYS + /// add, and additionally set-display only when + /// is true. + /// + public void ApplyUpdateTitle(uint titleId, bool setAsDisplay) + { + lock (_gate) _earnedTitleIds.Add(titleId); + Interlocked.Increment(ref _revision); + TitleAdded?.Invoke(titleId); + if (setAsDisplay) + { + Volatile.Write(ref _displayTitleId, titleId); + Interlocked.Increment(ref _revision); + DisplayTitleChanged?.Invoke(titleId); + } + } + + public void ResetSession() + { + lock (_gate) _earnedTitleIds.Clear(); + Volatile.Write(ref _displayTitleId, 0u); + Interlocked.Increment(ref _revision); + } +} diff --git a/src/AcDream.Runtime/Session/DirectGameRuntimeCommandAdapter.cs b/src/AcDream.Runtime/Session/DirectGameRuntimeCommandAdapter.cs index 3a9bcd10..42e3b18e 100644 --- a/src/AcDream.Runtime/Session/DirectGameRuntimeCommandAdapter.cs +++ b/src/AcDream.Runtime/Session/DirectGameRuntimeCommandAdapter.cs @@ -723,6 +723,33 @@ public sealed class DirectGameRuntimeCommandAdapter RuntimeCommandStatus.Accepted); } + public RuntimeCommandResult SetTitle( + RuntimeGenerationToken expectedGeneration, + uint titleId) + { + RuntimeCommandStatus gate = + Validate(expectedGeneration, out WorldSession? session); + if (gate != RuntimeCommandStatus.Accepted) + return Result(gate); + if (titleId == 0u) + { + return EmitResult( + RuntimeCommandDomain.Character, + operation: 6, + RuntimeCommandStatus.Rejected); + } + // 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); + return EmitResult( + RuntimeCommandDomain.Character, + operation: 6, + RuntimeCommandStatus.Accepted, + titleId); + } + public RuntimeCommandResult Execute( RuntimeGenerationToken expectedGeneration, in RuntimeFriendCommand command) diff --git a/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs b/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs index d4a15522..a0df61ed 100644 --- a/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs +++ b/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs @@ -345,7 +345,16 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting : null, onContractUpdate: social.Contracts is { } contractUpdate ? contractUpdate.ApplyUpdate - : null)); + : null, + // Campaign CT slice CT2 (2026-08-24): RuntimeCharacterState. + // Titles is a required child of the required `character. + // Character` owner (not an optional sibling like Fellowship/ + // Allegiance/Trade/House above), so these are wired + // unconditionally. + onCharacterTitleTable: (displayTitleId, titleIds) => + character.Character.Titles.ReplaceTable(displayTitleId, titleIds), + onUpdateTitle: (titleId, setAsDisplay) => + character.Character.Titles.ApplyUpdateTitle(titleId, setAsDisplay))); ConstructionCheckpoint(); // Campaign P Slice P1 (2026-07-30): burden recompute triggers — diff --git a/tests/AcDream.App.Tests/Composition/InteractionUiRuntimeSourcesTests.cs b/tests/AcDream.App.Tests/Composition/InteractionUiRuntimeSourcesTests.cs index fe53d729..679b0210 100644 --- a/tests/AcDream.App.Tests/Composition/InteractionUiRuntimeSourcesTests.cs +++ b/tests/AcDream.App.Tests/Composition/InteractionUiRuntimeSourcesTests.cs @@ -436,6 +436,11 @@ public sealed class InteractionUiRuntimeSourcesTests RuntimeGenerationToken expectedGeneration) => Accepted(expectedGeneration); + public RuntimeCommandResult SetTitle( + RuntimeGenerationToken expectedGeneration, + uint titleId) => + Accepted(expectedGeneration, titleId); + private RuntimeCommandResult Accepted( RuntimeGenerationToken generation, uint objectId = 0u) diff --git a/tests/AcDream.App.Tests/Net/LiveSessionCommandRouterTests.cs b/tests/AcDream.App.Tests/Net/LiveSessionCommandRouterTests.cs index 82b2db72..b02f7826 100644 --- a/tests/AcDream.App.Tests/Net/LiveSessionCommandRouterTests.cs +++ b/tests/AcDream.App.Tests/Net/LiveSessionCommandRouterTests.cs @@ -728,7 +728,8 @@ public sealed class LiveSessionCommandRouterTests RuntimeCommunicationState? communication = null, RuntimeCharacterState? characterState = null, Action? sendSingleCharacterOption = null, - Action? saveCharacterOptions = null) => new( + Action? saveCharacterOptions = null, + Action? sendSetTitle = null) => new( new LiveSessionCommandBindings( clientBindings ?? NewClientBindings(), chat ?? new ChatLog(), @@ -767,6 +768,7 @@ public sealed class LiveSessionCommandRouterTests CharacterState: characterState ?? new RuntimeCharacterState(), SendSingleCharacterOption: sendSingleCharacterOption ?? ((_, _) => { }), SaveCharacterOptions: saveCharacterOptions ?? (() => { }), + SendSetTitle: sendSetTitle ?? (_ => { }), SendFellowshipCreate: (_, _) => { }, SendFellowshipRecruit: _ => { }, SendFellowshipDismiss: _ => { }, diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterTitleResolverLiveDatTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterTitleResolverLiveDatTests.cs new file mode 100644 index 00000000..bbf3ff9a --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterTitleResolverLiveDatTests.cs @@ -0,0 +1,68 @@ +using AcDream.App.UI.Layout; +using AcDream.Content; +using DatReaderWriter; + +namespace AcDream.App.Tests.UI.Layout; + +/// +/// Campaign CT slice CT2 (2026-08-24) pin: +/// 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 CharacterTitle.WarMage; this +/// pin adds several more low-ordinal ids from the same enum +/// (CharacterTitle.cs: 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 . +/// +[Trait("Lane", "InstalledDat")] +public sealed class CharacterTitleResolverLiveDatTests +{ + private static string DatDirectory => + 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); + } +} diff --git a/tests/AcDream.Core.Net.Tests/Messages/CharacterTitleEventsTests.cs b/tests/AcDream.Core.Net.Tests/Messages/CharacterTitleEventsTests.cs new file mode 100644 index 00000000..8d952178 --- /dev/null +++ b/tests/AcDream.Core.Net.Tests/Messages/CharacterTitleEventsTests.cs @@ -0,0 +1,137 @@ +using System; +using AcDream.Core.Net.Messages; +using Xunit; + +namespace AcDream.Core.Net.Tests.Messages; + +/// +/// Campaign CT slice CT2 (2026-08-24): golden-vector round-trip tests for +/// the two character-title S→C parsers added to . +/// Fixtures are built with (the ACE-mirror +/// writer) so a pass proves agreement with ACE's own +/// GameEventCharacterTitle.cs / GameEventUpdateTitle.cs +/// writer shapes, not just with itself. +/// +public sealed class CharacterTitleEventsTests +{ + // ── 0x0029 CharacterTitle ──────────────────────────────────────────── + + [Fact] + public void ParseCharacterTitleTable_RoundTrips_DiscardsLeadingVersionTag() + { + byte[] wire = new AceWireWriter() + .Write(1u) // ACE's literal pack-version tag — must be discarded + .Write(13u) // displayTitleId + .Write(3u) // count + .Write(1u) + .Write(5u) + .Write(13u) + .ToArray(); + + GameEvents.CharacterTitleTable? table = GameEvents.ParseCharacterTitleTable(wire); + + Assert.NotNull(table); + Assert.Equal(13u, table!.Value.DisplayTitleId); + Assert.Equal(new uint[] { 1u, 5u, 13u }, table.Value.TitleIds); + } + + [Fact] + public void ParseCharacterTitleTable_IgnoresNonOneLeadingTag() + { + // Retail's own UnPack never reads the leading dword into any field — + // it is advanced past unconditionally. A hostile/odd server value + // there must not change the parse outcome. + byte[] wire = new AceWireWriter() + .Write(0xDEADBEEFu) + .Write(7u) + .Write(0u) // empty title list + .ToArray(); + + GameEvents.CharacterTitleTable? table = GameEvents.ParseCharacterTitleTable(wire); + + Assert.NotNull(table); + Assert.Equal(7u, table!.Value.DisplayTitleId); + Assert.Empty(table.Value.TitleIds); + } + + [Fact] + public void ParseCharacterTitleTable_EmptyTitleList_RoundTrips() + { + byte[] wire = new AceWireWriter() + .Write(1u) + .Write(0u) // displayTitleId — no display title yet + .Write(0u) // count + .ToArray(); + + GameEvents.CharacterTitleTable? table = GameEvents.ParseCharacterTitleTable(wire); + + Assert.NotNull(table); + Assert.Equal(0u, table!.Value.DisplayTitleId); + Assert.Empty(table.Value.TitleIds); + } + + [Theory] + [InlineData(0)] // empty payload + [InlineData(4)] // only the leading tag + [InlineData(8)] // leading tag + displayTitleId, missing count + public void ParseCharacterTitleTable_TruncatedHeader_ReturnsNull(int length) + { + byte[] wire = new byte[length]; + Assert.Null(GameEvents.ParseCharacterTitleTable(wire)); + } + + [Fact] + public void ParseCharacterTitleTable_CountExceedsAvailableTitleIds_ReturnsNull() + { + byte[] wire = new AceWireWriter() + .Write(1u) + .Write(1u) + .Write(2u) // count says 2 title ids follow + .Write(1u) // only 1 is actually present + .ToArray(); + + Assert.Null(GameEvents.ParseCharacterTitleTable(wire)); + } + + // ── 0x002B UpdateTitle ─────────────────────────────────────────────── + + [Theory] + [InlineData(0u, false)] + [InlineData(13u, true)] + public void ParseUpdateTitle_RoundTrips(uint titleId, bool setAsDisplay) + { + byte[] wire = new AceWireWriter() + .Write(titleId) + .Write(setAsDisplay ? 1u : 0u) + .ToArray(); + + GameEvents.UpdateTitle? update = GameEvents.ParseUpdateTitle(wire); + + Assert.NotNull(update); + Assert.Equal(titleId, update!.Value.TitleId); + Assert.Equal(setAsDisplay, update.Value.SetAsDisplay); + } + + [Fact] + public void ParseUpdateTitle_NonZeroSetAsDisplay_IsTrue() + { + // ACE writes Convert.ToUInt32(bool) (always 0 or 1), but retail's own + // dispatch reads `!= 0`, not `== 1` — a non-1 truthy value must still + // resolve true. + byte[] wire = new AceWireWriter().Write(5u).Write(0xFFu).ToArray(); + + GameEvents.UpdateTitle? update = GameEvents.ParseUpdateTitle(wire); + + Assert.NotNull(update); + Assert.True(update!.Value.SetAsDisplay); + } + + [Theory] + [InlineData(0)] + [InlineData(4)] // only titleId, missing setAsDisplay + public void ParseUpdateTitle_Truncated_ReturnsNull(int length) + { + byte[] wire = new byte[length]; + Assert.Null(GameEvents.ParseUpdateTitle(wire)); + } +} diff --git a/tests/AcDream.Core.Net.Tests/Messages/SocialActionsTests.cs b/tests/AcDream.Core.Net.Tests/Messages/SocialActionsTests.cs index a02d1928..02cfb284 100644 --- a/tests/AcDream.Core.Net.Tests/Messages/SocialActionsTests.cs +++ b/tests/AcDream.Core.Net.Tests/Messages/SocialActionsTests.cs @@ -378,4 +378,31 @@ public sealed class SocialActionsTests Assert.Contains((0x68000002u, 3u), parsed.Value.DesiredComps); Assert.Contains((0x68000003u, 7u), parsed.Value.DesiredComps); } + + // ── Campaign CT slice CT2 (2026-08-24): TitleSet (0x002C) ──────────────── + + [Fact] + public void BuildTitleSet_GoldenByteVector() + { + byte[] body = SocialActions.BuildTitleSet(seq: 7, titleId: 13u); + + byte[] expected = + [ + 0xB1, 0xF7, 0x00, 0x00, // envelope 0xF7B1 + 0x07, 0x00, 0x00, 0x00, // seq 7 + 0x2C, 0x00, 0x00, 0x00, // opcode 0x002C TitleSet + 0x0D, 0x00, 0x00, 0x00, // titleId 13 + ]; + Assert.Equal(expected, body); + } + + [Fact] + public void BuildTitleSet_HasOpcodeAndTitleId() + { + byte[] body = SocialActions.BuildTitleSet(seq: 1, titleId: 0xBEEFu); + Assert.Equal(SocialActions.TitleSetOpcode, + BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8))); + Assert.Equal(0xBEEFu, + BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12))); + } } diff --git a/tests/AcDream.Headless.Tests/HeadlessCharacterOptionsSeederTests.cs b/tests/AcDream.Headless.Tests/HeadlessCharacterOptionsSeederTests.cs index c0a1f4f5..46be2071 100644 --- a/tests/AcDream.Headless.Tests/HeadlessCharacterOptionsSeederTests.cs +++ b/tests/AcDream.Headless.Tests/HeadlessCharacterOptionsSeederTests.cs @@ -285,5 +285,11 @@ public sealed class HeadlessCharacterOptionsSeederTests RuntimeCommandStatus.Accepted, expectedGeneration); } + + public RuntimeCommandResult SetTitle( + RuntimeGenerationToken expectedGeneration, + uint titleId) => + throw new NotSupportedException( + "HeadlessCharacterOptionsSeeder never calls SetTitle."); } } diff --git a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCharacterStateTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCharacterStateTests.cs index b577b92b..414bef28 100644 --- a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCharacterStateTests.cs +++ b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCharacterStateTests.cs @@ -3,6 +3,7 @@ using AcDream.Core.Net.Messages; using AcDream.Core.Properties; using AcDream.Core.Spells; using AcDream.Core.Player; +using AcDream.Runtime; using AcDream.Runtime.Gameplay; namespace AcDream.Runtime.Tests.Gameplay; @@ -1051,6 +1052,61 @@ public sealed class RuntimeCharacterStateTests Assert.True(state.CaptureOwnership().AutonomyIsDefault); } + // ── Campaign CT slice CT2 (2026-08-24): Titles owner integration ───── + + [Fact] + public void Titles_IsOwnedAsASiblingOfOptionsAndMovementSkills() + { + using var state = new RuntimeCharacterState(); + + state.Titles.ReplaceTable(13u, [1u, 5u, 13u]); + + Assert.Equal(13u, state.Titles.DisplayTitleId); + Assert.Equal(3, state.Titles.EarnedTitleIds.Count); + Assert.Equal(3, state.CaptureOwnership().TitleCount); + Assert.False(state.CaptureOwnership().DisplayTitleIsDefault); + } + + [Fact] + public void ResetSession_ClearsTitles() + { + using var state = new RuntimeCharacterState(); + state.Titles.ReplaceTable(13u, [1u, 5u, 13u]); + + state.ResetSession(); + + Assert.Equal(0u, state.Titles.DisplayTitleId); + Assert.Empty(state.Titles.EarnedTitleIds); + Assert.True(state.CaptureOwnership().TitleCount == 0); + Assert.True(state.CaptureOwnership().DisplayTitleIsDefault); + Assert.True(state.CaptureOwnership().IsConverged is false); // IsDisposed still false + } + + [Fact] + public void Dispose_ClearsTitlesAndConverges() + { + var state = new RuntimeCharacterState(); + state.Titles.ReplaceTable(13u, [1u, 5u, 13u]); + + state.Dispose(); + + Assert.Equal(0u, state.Titles.DisplayTitleId); + Assert.Empty(state.Titles.EarnedTitleIds); + Assert.True(state.CaptureOwnership().IsConverged); + } + + [Fact] + public void CharacterSnapshot_EmbedsTitlesSnapshot() + { + using var state = new RuntimeCharacterState(); + state.Titles.ReplaceTable(13u, [1u, 5u, 13u]); + + RuntimeCharacterSnapshot snapshot = state.View.Snapshot; + + Assert.Equal(13u, snapshot.Titles.DisplayTitleId); + Assert.Equal(3, snapshot.Titles.TitleCount); + } + private static ActiveEnchantmentRecord MakeVitae(uint spellId, float val) => new( spellId, LayerId: 0u, Duration: -1f, CasterGuid: 0u, diff --git a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCharacterTitleStateTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCharacterTitleStateTests.cs new file mode 100644 index 00000000..0aa1f1a2 --- /dev/null +++ b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCharacterTitleStateTests.cs @@ -0,0 +1,193 @@ +using AcDream.Runtime.Gameplay; + +namespace AcDream.Runtime.Tests.Gameplay; + +/// +/// Campaign CT slice CT2 (2026-08-24): unit tests for +/// — the retail +/// CharacterTitleTable port. Covers the full table replace +/// (0x0029 CharacterTitle), the incremental add/set-display notice +/// (0x002B UpdateTitle), the retail-verified unconditional-add / +/// gated-display-set contract, and generation-reset clearing. +/// +public sealed class RuntimeCharacterTitleStateTests +{ + [Fact] + public void InitialState_IsEmptyWithDefaultDisplayTitle() + { + var titles = new RuntimeCharacterTitleState(); + + Assert.Equal(0u, titles.DisplayTitleId); + Assert.Empty(titles.EarnedTitleIds); + Assert.False(titles.HasEarnedTitle(13u)); + } + + [Fact] + public void ReplaceTable_SetsEarnedIdsAndDisplayTitle_FiresTableReplaced() + { + var titles = new RuntimeCharacterTitleState(); + int tableReplacedCount = 0; + titles.TableReplaced += () => tableReplacedCount++; + + titles.ReplaceTable(13u, [1u, 5u, 13u]); + + Assert.Equal(13u, titles.DisplayTitleId); + Assert.Equal(new HashSet { 1u, 5u, 13u }, titles.EarnedTitleIds.ToHashSet()); + Assert.True(titles.HasEarnedTitle(5u)); + Assert.False(titles.HasEarnedTitle(99u)); + Assert.Equal(1, tableReplacedCount); + } + + [Fact] + public void ReplaceTable_IsAWholesaleReplace_DropsIdsMissingFromTheNewTable() + { + var titles = new RuntimeCharacterTitleState(); + titles.ReplaceTable(1u, [1u, 2u, 3u]); + + titles.ReplaceTable(1u, [1u]); + + Assert.Equal(new uint[] { 1u }, titles.EarnedTitleIds.ToArray()); + Assert.False(titles.HasEarnedTitle(2u)); + Assert.False(titles.HasEarnedTitle(3u)); + } + + [Fact] + public void ReplaceTable_DisplayTitleIdUnchanged_DoesNotFireDisplayTitleChanged() + { + var titles = new RuntimeCharacterTitleState(); + titles.ReplaceTable(13u, [13u]); + var fired = new List(); + titles.DisplayTitleChanged += id => fired.Add(id); + + titles.ReplaceTable(13u, [13u, 14u]); + + Assert.Empty(fired); + Assert.Equal(13u, titles.DisplayTitleId); + } + + [Fact] + public void ReplaceTable_DisplayTitleIdChanges_FiresDisplayTitleChangedWithNewId() + { + var titles = new RuntimeCharacterTitleState(); + titles.ReplaceTable(13u, [13u, 14u]); + var fired = new List(); + titles.DisplayTitleChanged += id => fired.Add(id); + + titles.ReplaceTable(14u, [13u, 14u]); + + Assert.Equal([14u], fired); + Assert.Equal(14u, titles.DisplayTitleId); + } + + // ── 0x002B UpdateTitle: unconditional add, gated display-set ───────── + // Retail's Handle_Social__AddOrSetCharacterTitle @0x00564260 ALWAYS + // calls SendNotice_AddCharacterTitle, and additionally calls + // SendNotice_SetDisplayCharacterTitle only when setAsDisplay != 0. + + [Fact] + public void ApplyUpdateTitle_AlwaysAddsAndFiresTitleAdded_EvenWhenNotSetAsDisplay() + { + var titles = new RuntimeCharacterTitleState(); + var added = new List(); + titles.TitleAdded += id => added.Add(id); + var displayChanged = new List(); + titles.DisplayTitleChanged += id => displayChanged.Add(id); + + titles.ApplyUpdateTitle(7u, setAsDisplay: false); + + Assert.True(titles.HasEarnedTitle(7u)); + Assert.Equal([7u], added); + Assert.Empty(displayChanged); + Assert.Equal(0u, titles.DisplayTitleId); + } + + [Fact] + public void ApplyUpdateTitle_SetAsDisplayTrue_AddsAndUpdatesDisplayTitle() + { + var titles = new RuntimeCharacterTitleState(); + var added = new List(); + titles.TitleAdded += id => added.Add(id); + var displayChanged = new List(); + titles.DisplayTitleChanged += id => displayChanged.Add(id); + + titles.ApplyUpdateTitle(13u, setAsDisplay: true); + + Assert.True(titles.HasEarnedTitle(13u)); + Assert.Equal([13u], added); + Assert.Equal([13u], displayChanged); + Assert.Equal(13u, titles.DisplayTitleId); + } + + [Fact] + public void ApplyUpdateTitle_AlreadyEarnedId_StillFiresTitleAddedUnconditionally() + { + // Retail's own broadcast is unconditional — it does not check + // membership before firing the notice. + var titles = new RuntimeCharacterTitleState(); + titles.ApplyUpdateTitle(7u, setAsDisplay: false); + var added = new List(); + titles.TitleAdded += id => added.Add(id); + + titles.ApplyUpdateTitle(7u, setAsDisplay: false); + + Assert.Equal([7u], added); + Assert.Single(titles.EarnedTitleIds); + } + + [Fact] + public void ApplyUpdateTitle_SetAsDisplayOnAnUnearnedId_AddsItAndSetsDisplay() + { + // Retail sends UpdateTitle for a title the player just earned — the + // id is not necessarily already in the earned set beforehand. + var titles = new RuntimeCharacterTitleState(); + + titles.ApplyUpdateTitle(99u, setAsDisplay: true); + + Assert.True(titles.HasEarnedTitle(99u)); + Assert.Equal(99u, titles.DisplayTitleId); + } + + [Fact] + public void ResetSession_ClearsEarnedIdsAndDisplayTitle() + { + var titles = new RuntimeCharacterTitleState(); + titles.ReplaceTable(13u, [1u, 5u, 13u]); + + titles.ResetSession(); + + Assert.Equal(0u, titles.DisplayTitleId); + Assert.Empty(titles.EarnedTitleIds); + } + + [Fact] + public void ResetSession_BumpsRevisionEvenWhenAlreadyEmpty() + { + var titles = new RuntimeCharacterTitleState(); + long before = titles.Revision; + + titles.ResetSession(); + + Assert.True(titles.Revision > before); + } + + [Fact] + public void Snapshot_ReflectsDisplayTitleAndCount() + { + var titles = new RuntimeCharacterTitleState(); + titles.ReplaceTable(13u, [1u, 5u, 13u]); + + RuntimeCharacterTitleSnapshot snapshot = titles.Snapshot; + + Assert.Equal(13u, snapshot.DisplayTitleId); + Assert.Equal(3, snapshot.TitleCount); + Assert.Equal(titles.Revision, snapshot.Revision); + } + + [Fact] + public void ReplaceTable_NullTitleIds_Throws() + { + var titles = new RuntimeCharacterTitleState(); + Assert.Throws( + () => titles.ReplaceTable(1u, null!)); + } +} diff --git a/tests/AcDream.Runtime.Tests/Session/DirectGameRuntimeCommandAdapterTests.cs b/tests/AcDream.Runtime.Tests/Session/DirectGameRuntimeCommandAdapterTests.cs index c2f4fe6d..e61390e0 100644 --- a/tests/AcDream.Runtime.Tests/Session/DirectGameRuntimeCommandAdapterTests.cs +++ b/tests/AcDream.Runtime.Tests/Session/DirectGameRuntimeCommandAdapterTests.cs @@ -1,3 +1,4 @@ +using System.Buffers.Binary; using System.Net; using AcDream.Core.Combat; using AcDream.Core.Items; @@ -345,6 +346,69 @@ public sealed class DirectGameRuntimeCommandAdapterTests runtime.Dispose(); } + // ── Campaign CT slice CT2 (2026-08-24): SetTitle (TitleSet 0x002C) ─── + + [Fact] + public void SetTitle_SendsTheWireActionWithoutAnyLocalMutation() + { + (GameRuntime runtime, DirectGameRuntimeCommandAdapter adapter, FixtureSessionOperations operations) = + CreateStartedHarness(); + var gameActions = new List(); + operations.Sessions[^1].GameActionCapture = body => gameActions.Add(body); + + RuntimeCommandResult result = adapter.Character.SetTitle( + runtime.Generation, + titleId: 13u); + + Assert.True(result.Accepted); + Assert.Single(gameActions); + byte[] sent = gameActions[0]; + Assert.Equal( + SocialActions.TitleSetOpcode, + BinaryPrimitives.ReadUInt32LittleEndian(sent.AsSpan(8))); + Assert.Equal(13u, BinaryPrimitives.ReadUInt32LittleEndian(sent.AsSpan(12))); + + // CA-lesson: NO optimistic local mutation. RuntimeCharacterState. + // Titles only updates from the server's own echo (0x002B/0x0029). + Assert.Equal(0u, runtime.CharacterOwner.Titles.DisplayTitleId); + Assert.Empty(runtime.CharacterOwner.Titles.EarnedTitleIds); + runtime.Dispose(); + } + + [Fact] + public void SetTitle_ZeroTitleId_RejectsWithoutSendingAnything() + { + (GameRuntime runtime, DirectGameRuntimeCommandAdapter adapter, FixtureSessionOperations operations) = + CreateStartedHarness(); + var gameActions = new List(); + operations.Sessions[^1].GameActionCapture = body => gameActions.Add(body); + + RuntimeCommandResult result = adapter.Character.SetTitle( + runtime.Generation, + titleId: 0u); + + Assert.Equal(RuntimeCommandStatus.Rejected, result.Status); + Assert.Empty(gameActions); + runtime.Dispose(); + } + + [Fact] + public void SetTitle_StaleGeneration_RejectsWithoutSendingAnything() + { + (GameRuntime runtime, DirectGameRuntimeCommandAdapter adapter, FixtureSessionOperations operations) = + CreateStartedHarness(); + var gameActions = new List(); + operations.Sessions[^1].GameActionCapture = body => gameActions.Add(body); + RuntimeGenerationToken stale = runtime.Generation; + _ = adapter.Session.Reconnect(runtime.Generation); + + RuntimeCommandResult result = adapter.Character.SetTitle(stale, titleId: 13u); + + Assert.Equal(RuntimeCommandStatus.StaleGeneration, result.Status); + Assert.Empty(gameActions); + runtime.Dispose(); + } + [Fact] public void SaveOptions_FlushesTheDirtyBlobThenNoOpsWhenClean() {