feat(chat): Campaign CH slice CH3 — side-channel membership, wire, and echo parity

Ports retail's SendTurbineChat (@0x0057db10) local pre-send membership gate
so Roleplay/Society/Olthoi stop silently swallowing outbound chat: a new
TurbineChatMembershipGate checks Turbine availability and the player's own
Hear*Chat option before sending, raising "Turbine chat is not available."
or the 0x0551 YouAreNotListeningTo_Channel refusal through the CH2 AddText
chokepoint instead. Wired into both the graphical (LiveSessionCommandRouter)
and headless (DirectGameRuntimeCommandAdapter) send paths so they can't
diverge. Retracts the 26-day-old false "ACE doesn't run a TurbineChat
server" claim from ISSUES.md, the roadmap, and project_chat_pipeline.md —
ACE's TurbineChat implementation is complete and on by default; the real
bug was treating Hear*Chat as a display filter instead of room membership.

Also: implements SetSingleCharacterOption (0x0005), the only wire message
that actually joins/leaves a Turbine room, and wires the five Settings Chat
toggles to it (publish on Save, changed bits only) plus seeds ChatSettings
from the server's own CharacterOptions2 on every PlayerDescription. Fixes
the legacy-channel double-print (Fellow/Vassals/Patron/Monarch/CoVassals
skip the local echo now that ChatChannelInfo.IsSelfEchoChannel is finally
consulted). Routes /a to Turbine unconditionally (retail's @a never falls
back to the legacy bitflag) and adds /ab for the legacy AllegianceBroadcast
verb retail actually has. Surfaces a nonzero TurbineChat ack HResult instead
of discarding it silently. Deletes the malformed, callerless SetCharacterOptions
(0x01A1) and AddChannel/RemoveChannel (0x0145/0x0146) builders.

Files every AC-specific algorithm change cites the named retail decomp
(SendTurbineChat 0x0057db10, StartupTurbineChatSystem 0x0057EFB0,
GameActionSetSingleCharacterOption) plus ACE/holtburger cross-checks.
Register rows AP-181 (no client-side spam throttle) and UN-9 (an
incidentally-discovered CharacterOptions1.Default literal mismatch, not
investigated further) filed per the divergence-register rule.

11,957 passed / 4 skipped / 0 failed (full Release suite, up from the
11,916/4/0 baseline).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-09 19:39:44 +02:00
parent fc9590e4fc
commit 614a1e055f
35 changed files with 1453 additions and 229 deletions

View file

@ -37,7 +37,11 @@ public static class ChannelResolver
// CoVassals = 0x01000000
// AllegianceBroadcast = 0x02000000
ChatChannelKind.Fellowship => new Resolved(0x00000800u, "Fellowship"),
ChatChannelKind.Allegiance => new Resolved(0x02000000u, "Allegiance"),
// CH3 (2026-08-09): Allegiance itself is ALWAYS Turbine now — see
// ChatChannelKind.AllegianceBroadcast's doc comment. This resolver
// handles only the legacy pipeline, so the id 0x02000000 moved to
// that dedicated kind (retail's @ab verb).
ChatChannelKind.AllegianceBroadcast => new Resolved(0x02000000u, "Allegiance"),
ChatChannelKind.Vassals => new Resolved(0x00001000u, "Vassals"),
ChatChannelKind.Patron => new Resolved(0x00002000u, "Patron"),
ChatChannelKind.Monarch => new Resolved(0x00004000u, "Monarch"),

View file

@ -11,10 +11,17 @@ namespace AcDream.UI.Abstractions;
/// <para>
/// Channels split into:
/// <list type="bullet">
/// <item><b>Legacy</b> (Fellowship..CoVassals): map to a fixed <c>ChatChannel</c>
/// bitflag id via <see cref="ChannelResolver"/> and ride 0x0147 ChatChannel.</item>
/// <item><b>Turbine</b> (General..Olthoi): require a TurbineChat channel id
/// resolved at runtime — not yet wired (Phase I.6 owns TurbineChat).</item>
/// <item><b>Legacy</b> (Fellowship, Vassals, Patron, Monarch, CoVassals,
/// AllegianceBroadcast): map to a fixed <c>ChatChannel</c> bitflag id
/// via <see cref="ChannelResolver"/> and ride 0x0147 ChatChannel.</item>
/// <item><b>Turbine</b> (Allegiance, General, Trade, Lfg, Roleplay, Society,
/// Olthoi): resolve to a server-assigned Turbine room id at runtime via
/// <c>TurbineChatMembershipGate</c> and ride 0xF7DE TurbineChat.
/// Campaign CH slice CH3 (2026-08-09) moved <see cref="Allegiance"/>
/// here unconditionally — retail's <c>@a</c> binds to
/// <c>DoTurbineChat_Allegiance</c>, never the legacy bitflag; see
/// <see cref="AllegianceBroadcast"/> for the legacy sibling
/// (retail's <c>@ab</c>).</item>
/// <item><b>Say</b> / <b>Tell</b>: route to the dedicated 0x0015 / 0x005D
/// opcodes — no channel id needed.</item>
/// </list>
@ -36,5 +43,17 @@ public enum ChatChannelKind
Roleplay,
Society,
Olthoi,
/// <summary>
/// Campaign CH slice CH3 (2026-08-09): the legacy
/// <c>ChatChannel.AllegianceBroadcast (0x02000000)</c> bound to retail's
/// <c>@ab</c> verb — a monarch/speaker-permission broadcast over the
/// legacy 0x0147 pipe. Distinct from <see cref="Allegiance"/>, which is
/// ALWAYS the Turbine room now: retail's <c>@a</c> binds unconditionally
/// to <c>DoTurbineChat_Allegiance</c> once Turbine chat starts (research
/// doc §4.3), so it never falls back to this bitflag.
/// </summary>
AllegianceBroadcast,
Unknown,
}

View file

@ -62,6 +62,13 @@ public static class ChatInputParser
("/fellowship", ChatChannelKind.Fellowship),
("/a", ChatChannelKind.Allegiance),
("/allegiance", ChatChannelKind.Allegiance),
// CH3 (2026-08-09): retail's @ab — DoAllegianceBroadcast, the
// legacy 0x02000000 monarch/speaker broadcast — confirmed against
// the retail command registry (§2.3/§2.5). "/allegiancebroadcast"
// was deliberately NOT added: it is not a registered retail verb
// (only "ab" is), and CH3's scope is "cross-check the registry;
// do not add verbs it doesn't have."
("/ab", ChatChannelKind.AllegianceBroadcast),
("/m", ChatChannelKind.Monarch),
("/monarch", ChatChannelKind.Monarch),
("/p", ChatChannelKind.Patron),

View file

@ -9,12 +9,17 @@ namespace AcDream.UI.Abstractions.Panels.Settings;
/// retail bit values.
///
/// <para>
/// L.0 scope: <b>local-only</b> like the rest of L.0. The Hear*Chat
/// flags affect client-side <i>display</i> filtering of the existing
/// channels — the server still streams every line; the client decides
/// what to render. Server-sync arrives in a later phase that flips the
/// retail-faithful "tell server which channels I'm subscribed to"
/// switch.
/// <b>Campaign CH slice CH3 (2026-08-09):</b> these five Hear*Chat flags
/// are no longer local-only display filters — they ARE retail's server-side
/// Turbine room membership. The App host seeds this draft from
/// <c>RuntimeCharacterOptionsState.Options2</c> (already parsed out of
/// PlayerDescription) whenever a fresh description lands, and Save publishes
/// any changed bit through <c>SetSingleCharacterOption (0x0005)</c> —
/// <c>RuntimeSettingsController.SyncChatFromServerOptions</c> /
/// <c>SaveChat</c>. There is no sixth toggle for
/// <c>HearAllegianceChat (CharacterOptions1 0x40000000)</c>: retail's own
/// Settings UI does not expose one either — Allegiance chat membership rides
/// allegiance membership, not a standalone preference.
/// </para>
/// </summary>
public sealed record ChatSettings(

View file

@ -215,6 +215,22 @@ public sealed class SettingsVM
_chatDraft = value ?? throw new ArgumentNullException(nameof(value));
}
/// <summary>
/// Apply one externally learned Chat change (server truth — CH3,
/// 2026-08-09) to both snapshots, matching
/// <see cref="ApplyExternalGameplayChange"/>'s shape. Existing unsaved
/// edits to unrelated fields remain drafts rather than being accidentally
/// promoted to persisted state.
/// </summary>
public void ApplyExternalChatChange(Func<ChatSettings, ChatSettings> update)
{
ArgumentNullException.ThrowIfNull(update);
_chatPersisted = update(_chatPersisted)
?? throw new InvalidOperationException("Chat update returned null.");
_chatDraft = update(_chatDraft)
?? throw new InvalidOperationException("Chat update returned null.");
}
/// <summary>
/// Replace the entire Character draft with <paramref name="value"/>.
/// Per-toon — the host knows which toon's bag we're editing because