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

@ -15229,7 +15229,7 @@ parallel to existing handlers (no behavior change).
**Closed:** 2026-04-25 **Closed:** 2026-04-25
**Commit:** `ca968fc` **Commit:** `ca968fc`
**Resolution:** Full `0xF7DE` codec with three payload variants (`EventSendToRoom`, `RequestSendToRoomById`, `Response`), UTF-16LE strings with variable-length prefix, `SetTurbineChatChannels (0x0295)` parser, unified `ChatChannelInfo` (Legacy + Turbine variants), `TurbineChatState`. **Note: ACE doesn't run a TurbineChat server — codec is ready for retail-server-emulating setups.** **Resolution:** Full `0xF7DE` codec with three payload variants (`EventSendToRoom`, `RequestSendToRoomById`, `Response`), UTF-16LE strings with variable-length prefix, `SetTurbineChatChannels (0x0295)` parser, unified `ChatChannelInfo` (Legacy + Turbine variants), `TurbineChatState`. **Correction (Campaign CH slice CH3, 2026-08-09): the "ACE doesn't run a TurbineChat server" note above was FALSE.** ACE has a complete TurbineChat implementation (`TurbineChatHandler.cs`, 387 lines), on by default (`use_turbine_chat = true`), and our own launch logs have shown parsed `SetTurbineChatChannels` room ids since at least 2026-05-21. See `docs/research/2026-08-09-chat-side-channels-vs-ace.md` §1.
--- ---

File diff suppressed because one or more lines are too long

View file

@ -426,7 +426,7 @@ W1 plan: [`docs/superpowers/plans/2026-06-02-unified-cell-graph-stage1.md`](../s
| I.3 | `LiveCommandBus` + `WorldSession.SendTalk` / `SendTell` / `SendChannel` — replaces `NullCommandBus.Instance` with a real handler-registry `ICommandBus`. New `SendChatCmd` record + `ChannelResolver` legacy-id mapping (per holtburger). 3-line wrappers around existing `ChatRequests.BuildTalk/Tell/ChatChannel`. | Tests ✓ | | I.3 | `LiveCommandBus` + `WorldSession.SendTalk` / `SendTell` / `SendChannel` — replaces `NullCommandBus.Instance` with a real handler-registry `ICommandBus`. New `SendChatCmd` record + `ChannelResolver` legacy-id mapping (per holtburger). 3-line wrappers around existing `ChatRequests.BuildTalk/Tell/ChatChannel`. | Tests ✓ |
| I.4 | `ChatPanel` input field + slash commands — Enter-to-submit input field; `ChatInputParser` recognises `/say` `/t` `/tell` `/r` `/g` `/f` `/a` `/m` `/p` `/v` `/cv` `/lfg` `/trade` `/role` `/society` `/olthoi`; `ChatVM.LastIncomingTellSender` tracks for `/r` reply. `ImGui.WantCaptureKeyboard` already suppresses WASD on focus. | Live ✓ | | I.4 | `ChatPanel` input field + slash commands — Enter-to-submit input field; `ChatInputParser` recognises `/say` `/t` `/tell` `/r` `/g` `/f` `/a` `/m` `/p` `/v` `/cv` `/lfg` `/trade` `/role` `/society` `/olthoi`; `ChatVM.LastIncomingTellSender` tracks for `/r` reply. `ImGui.WantCaptureKeyboard` already suppresses WASD on focus. | Live ✓ |
| I.5 | Holtburger inbound chat parity + Windows-1252 codec — `EmoteText (0x01E0)`, `SoulEmote (0x01E2)`, `ServerMessage (0xF7E0)`, `PlayerKilled (0x019E)` parsers + `WeenieError` routing through `GameEventWiring`. Global string codec switch from `Encoding.ASCII` to `Encoding.GetEncoding(1252)` so accented names round-trip per retail + holtburger. | Tests ✓ | | I.5 | Holtburger inbound chat parity + Windows-1252 codec — `EmoteText (0x01E0)`, `SoulEmote (0x01E2)`, `ServerMessage (0xF7E0)`, `PlayerKilled (0x019E)` parsers + `WeenieError` routing through `GameEventWiring`. Global string codec switch from `Encoding.ASCII` to `Encoding.GetEncoding(1252)` so accented names round-trip per retail + holtburger. | Tests ✓ |
| I.6 | TurbineChat codec + `ChatChannelInfo` — full `0xF7DE` codec with three payload variants (`EventSendToRoom`, `RequestSendToRoomById`, `Response`), UTF-16LE strings with variable-length prefix, `SetTurbineChatChannels (0x0295)` parser, unified `ChatChannelInfo` (Legacy + Turbine variants), `TurbineChatState`. **ACE doesn't host a TurbineChat server — codec is ready when retail-emulating servers exist.** | Tests ✓ | | I.6 | TurbineChat codec + `ChatChannelInfo` — full `0xF7DE` codec with three payload variants (`EventSendToRoom`, `RequestSendToRoomById`, `Response`), UTF-16LE strings with variable-length prefix, `SetTurbineChatChannels (0x0295)` parser, unified `ChatChannelInfo` (Legacy + Turbine variants), `TurbineChatState`. **Correction (Campaign CH slice CH3, 2026-08-09): the "ACE doesn't host a TurbineChat server" note above was FALSE — ACE has a complete, on-by-default TurbineChat implementation; see `docs/research/2026-08-09-chat-side-channels-vs-ace.md` §1.** | Tests ✓ |
| I.7 | `CombatChatTranslator` — retail-faithful combat-text formatters into `ChatLog` ("You hit drudge for 50 slashing damage (87%)"). Subscribes to `CombatState`'s `DamageTaken` / `DamageDealtAccepted` / `EvadedIncoming` / `MissedOutgoing` / `KillLanded`; `AttackDone` is control-only and deliberately silent. | Tests ✓ | | I.7 | `CombatChatTranslator` — retail-faithful combat-text formatters into `ChatLog` ("You hit drudge for 50 slashing damage (87%)"). Subscribes to `CombatState`'s `DamageTaken` / `DamageDealtAccepted` / `EvadedIncoming` / `MissedOutgoing` / `KillLanded`; `AttackDone` is control-only and deliberately silent. | Tests ✓ |
| K | Input architecture — `Action` enum, `KeyChord`, `KeyBindings`, multicast `InputDispatcher` with scope-stack + modal capture, retail-default keymap (152 bindings), `keybinds.json` persistence, F11 Settings panel with click-to-rebind + conflict detection, main menu bar + View menu | Live ✓ | | K | Input architecture — `Action` enum, `KeyChord`, `KeyBindings`, multicast `InputDispatcher` with scope-stack + modal capture, retail-default keymap (152 bindings), `keybinds.json` persistence, F11 Settings panel with click-to-rebind + conflict detection, main menu bar + View menu | Live ✓ |
| L.0 | Full retail-style Settings interface — F11 tabbed panel with 6 tabs (Keybinds + Display + Audio + Gameplay + Chat + Character). `settings.json` at `%LOCALAPPDATA%\acdream\`, per-toon `Character` keying (swapped on EnterWorld). Display GL knobs (Resolution / Fullscreen / VSync / FOV / ShowFps) + Audio (Master / SFX) live-wired; Gameplay / Chat / Character settings persist for server-sync wiring later. Tab API extension to `IPanelRenderer`; chat Copy mode (read-only multi-line); per-panel layout reset; FramebufferResize handler keeps GL viewport + camera aspect + panel positions in sync. | Live ✓ | | L.0 | Full retail-style Settings interface — F11 tabbed panel with 6 tabs (Keybinds + Display + Audio + Gameplay + Chat + Character). `settings.json` at `%LOCALAPPDATA%\acdream\`, per-toon `Character` keying (swapped on EnterWorld). Display GL knobs (Resolution / Fullscreen / VSync / FOV / ShowFps) + Audio (Master / SFX) live-wired; Gameplay / Chat / Character settings persist for server-sync wiring later. Tab API extension to `IPanelRenderer`; chat Copy mode (read-only multi-line); per-panel layout reset; FramebufferResize handler keeps GL viewport + camera aspect + panel positions in sync. | Live ✓ |

View file

@ -6,7 +6,9 @@ gate (`77c8296e`, REJECT-reviewed at
`docs/research/2026-08-09-ch2-review-findings.md`, reworked `e0e78883`, `docs/research/2026-08-09-ch2-review-findings.md`, reworked `e0e78883`,
re-reviewed APPROVE-WITH-FIXES with nits applied this commit) — the sole re-reviewed APPROVE-WITH-FIXES with nits applied this commit) — the sole
outstanding item is the in-client user gate (jump-in-air / jump-loaded outstanding item is the in-client user gate (jump-in-air / jump-loaded
refusals showing on-screen, not in chat). CH3 (side channels) next. refusals showing on-screen, not in chat). CH3 (side channels)
CODE-COMPLETE, pending the connected user gate — see the CH3 row below and
`docs/research/2026-08-09-chat-side-channels-vs-ace.md`.
**Why now:** first track of the alpha-release program (chat is the most **Why now:** first track of the alpha-release program (chat is the most
visible daily surface for the friend-alpha). User-directed 2026-08-09. visible daily surface for the friend-alpha). User-directed 2026-08-09.
@ -106,6 +108,76 @@ implementer per slice against a pinned contract (per
| R1R4 research | `see docs/research/2026-08-09-chat-retail-*` | — | — | — | | R1R4 research | `see docs/research/2026-08-09-chat-retail-*` | — | — | — |
| CH1 colors | `172c6f9a` | 11,835 passed / 4 skipped / 0 failed | APPROVE-WITH-FIXES; fixed `34d8a3c0` | pending | | CH1 colors | `172c6f9a` | 11,835 passed / 4 skipped / 0 failed | APPROVE-WITH-FIXES; fixed `34d8a3c0` | pending |
| CH2 interface text | `77c8296e`, reworked `e0e78883` | 11,916 passed / 4 skipped / 0 failed | REJECT → reworked `e0e78883` → re-review APPROVE-WITH-FIXES → nits `233c30d1` | pending | | CH2 interface text | `77c8296e`, reworked `e0e78883` | 11,916 passed / 4 skipped / 0 failed | REJECT → reworked `e0e78883` → re-review APPROVE-WITH-FIXES → nits `233c30d1` | pending |
| CH3 side channels | — | — | — | — | | CH3 side channels | `PENDING-SHA` | 11,957 passed / 4 skipped / 0 failed | not yet reviewed | pending (connected gate — see handoff below) |
| CH4 commands | — | — | — | — | | CH4 commands | — | — | — | — |
| CH5 closeout | — | — | — | — | | CH5 closeout | — | — | — | — |
### CH3 closeout handoff (2026-08-09)
All nine steps of the research doc's §6 fix list landed:
1. **The false "ACE doesn't run a TurbineChat server" claim retracted** in
`docs/ISSUES.md` (#19) and `docs/plans/2026-04-11-roadmap.md` (I.6, ×2).
2. **`TurbineChatMembershipGate`** (new, `AcDream.Runtime.Gameplay`) ports
retail's `SendTurbineChat @0x0057db10` local pre-send gate — Turbine
off/room-0 → `"Turbine chat is not available."`; Hear-option off →
`0x0551 YouAreNotListeningTo_Channel` — both raised through
`RuntimeCommunicationState.AddText`. Wired into BOTH
`LiveSessionCommandRouter` (graphical) and `DirectGameRuntimeCommandAdapter`
(headless) so the two hosts can't diverge. `RuntimeCharacterState.IsOlthoiPlayer`
added (heritage-gated, not an option) for the Olthoi room.
3. **`SetSingleCharacterOption (0x0005)`** implemented end to end (codec,
`WorldSession.SendSetSingleCharacterOption`, `IRuntimeCharacterCommands.
SetSingleOption`, both adapters, `LiveSessionCommandRouter` registration)
and wired to the 5 Settings Chat toggles via `RuntimeSettingsController.
SaveChat` (publishes only the CHANGED bits) through a hoisted
`LiveSessionCommandSurface` now shared with `RuntimeSettingsTargets`.
No 6th (Allegiance) toggle was added — `ChatSettings` has never had one
and retail's own Settings UI was not confirmed to have one either; flagged
for the user rather than guessed.
4. **`ChatSettings` seeded from server truth** — `RuntimeSettingsController.
SyncChatFromServerOptions` reseeds both the persisted snapshot and any
live unsaved draft from `CharacterOptions2` whenever a fresh
PlayerDescription lands (`LiveCharacterSessionBindings.
OnCharacterOptionsChanged`, new optional hook).
5. **Self-echo double-print fixed** — `LiveSessionCommandRouter.
RouteLegacyChannel` now consults `ChatChannelInfo.Legacy(...).
IsSelfEchoChannel()`; Fellow/Vassals/Patron/Monarch/CoVassals skip the
local echo (server resends with `""` sender), AllegianceBroadcast/Say/Tell
keep it. Existing `TellAndLegacyChannel_PreserveOutboundAndEchoPolicy`
test corrected to the fixed (single-print) expectation.
6. **TurbineChat ack HResult surfaced** — `LiveSessionEventRouter.
RouteTurbineChat` now switches on `Payload.Response{HResult}`; nonzero
surfaces as a system chat line, zero (the common case) stays silent
matching retail.
7. **`/a` routes to Turbine unconditionally** — `ChatChannelKind.Allegiance`
is exhaustively dispatched to the Turbine pipeline (never falls through to
legacy); a new `ChatChannelKind.AllegianceBroadcast` + `/ab` verb owns the
legacy `0x02000000` path retail's own `@ab` verb uses.
`/allegiancebroadcast` was deliberately NOT added — the retail command
registry (`docs/research/2026-08-09-chat-retail-command-registry.md`)
only has `ab`, not that long form.
8. **Malformed builders resolved**`SocialActions.BuildSetCharacterOptions`
(0x01A1, no caller), `BuildAddChannel`/`BuildRemoveChannel` (0x0145/0x0146,
wrong payload type, no caller) DELETED along with their entire call chain
(`WorldSession.SendSetCharacterOptions`, `IRuntimeCharacterCommands.
SetOptions1`, `SetCharacterOptionsRuntimeCmd`) — replaced by
`SetSingleCharacterOption`, the message step 3 actually needed.
9. **Register + memory** — AP-181 (no client-side chat spam throttle) and
UN-9 (an incidentally-discovered, unexplained one-byte
`CharacterOptions1.Default` mismatch vs ACE's own literal — not
investigated further, flagged for a future pass) filed in
`docs/architecture/retail-divergence-register.md`.
`claude-memory/project_chat_pipeline.md` line ~111 corrected.
**Deviations from the literal ordered list:** none structural; the two
notes above (no 6th Allegiance toggle, no `/allegiancebroadcast` verb) are
scope-narrowing decisions made against the retail command registry and the
existing `ChatSettings` shape, not skipped work.
**What the connected gate must verify (not run this session — build+test
only per the CH3 task's hard constraint):** General/Trade/LFG round-trip
send+receive; Roleplay is now silent-but-correctly-refused until the user
turns it on via Settings (then works); `/a` with and without an allegiance;
`/ab`; the legacy family no longer double-prints; the TurbineChat ack
HResult line never appears on an ordinary successful send.

View file

@ -322,6 +322,14 @@ internal sealed class SessionPlayerCompositionPhase
Fault(SessionPlayerCompositionPoint.StreamingCreated); Fault(SessionPlayerCompositionPoint.StreamingCreated);
bindings = new SessionPlayerRuntimeBindings(); bindings = new SessionPlayerRuntimeBindings();
// CH3 (2026-08-09): constructed here (rather than at its original
// site further down, alongside LiveSessionAppSource) purely so
// RuntimeSettingsTargets can publish SetSingleCharacterOption
// through the SAME stable command surface the retained UI uses —
// LiveSessionCommandSurface has no dependencies of its own, so
// hoisting its construction is inert; the later site now reuses
// this instance instead of constructing a second one.
var liveSessionCommands = new LiveSessionCommandSurface();
var settingsTargets = new RuntimeSettingsTargets( var settingsTargets = new RuntimeSettingsTargets(
new SilkRuntimeDisplayWindowTarget(d.Window), new SilkRuntimeDisplayWindowTarget(d.Window),
live.DrawDispatcher, live.DrawDispatcher,
@ -329,6 +337,7 @@ internal sealed class SessionPlayerCompositionPhase
streaming, streaming,
d.RenderRange, d.RenderRange,
interaction.RetainedUi?.Host.Root, interaction.RetainedUi?.Host.Root,
liveSessionCommands,
d.Log); d.Log);
bindings.Adopt( bindings.Adopt(
"runtime settings targets", "runtime settings targets",
@ -419,6 +428,7 @@ internal sealed class SessionPlayerCompositionPhase
worldReveal, worldReveal,
spawnClaimClassifier, spawnClaimClassifier,
bindings, bindings,
liveSessionCommands,
ref bindingsOwnedByScope); ref bindingsOwnedByScope);
} }
@ -436,6 +446,10 @@ internal sealed class SessionPlayerCompositionPhase
WorldRevealCoordinator worldReveal, WorldRevealCoordinator worldReveal,
DatSpawnClaimHydrationClassifier spawnClaimClassifier, DatSpawnClaimHydrationClassifier spawnClaimClassifier,
SessionPlayerRuntimeBindings bindings, SessionPlayerRuntimeBindings bindings,
// CH3 (2026-08-09): threaded through from ComposeCore, where it was
// constructed early (alongside RuntimeSettingsTargets) instead of at
// its original site below — see the construction-site comment.
LiveSessionCommandSurface liveSessionCommands,
ref bool bindingsOwnedByScope) ref bool bindingsOwnedByScope)
{ {
SessionPlayerDependencies d = _dependencies; SessionPlayerDependencies d = _dependencies;
@ -474,7 +488,9 @@ internal sealed class SessionPlayerCompositionPhase
d.Log); d.Log);
LiveSessionController liveSession = d.Runtime.Session; LiveSessionController liveSession = d.Runtime.Session;
var liveSessionCommands = new LiveSessionCommandSurface(); // liveSessionCommands arrives as a parameter — constructed in
// ComposeCore alongside RuntimeSettingsTargets (CH3, 2026-08-09) and
// threaded through, not reconstructed here.
var liveSessionSource = new LiveSessionAppSource( var liveSessionSource = new LiveSessionAppSource(
liveSession, liveSession,
liveSessionCommands); liveSessionCommands);

View file

@ -2,6 +2,7 @@ using AcDream.App.UI;
using AcDream.Core.Chat; using AcDream.Core.Chat;
using AcDream.Core.Items; using AcDream.Core.Items;
using AcDream.Core.Net.Messages; using AcDream.Core.Net.Messages;
using AcDream.Runtime.Gameplay;
using AcDream.Runtime.Session; using AcDream.Runtime.Session;
using AcDream.UI.Abstractions; using AcDream.UI.Abstractions;
@ -28,7 +29,6 @@ internal sealed record LiveSessionCommandBindings(
Action<uint, ulong> RaiseVital, Action<uint, ulong> RaiseVital,
Action<uint, ulong> RaiseSkill, Action<uint, ulong> RaiseSkill,
Action<uint, uint> TrainSkill, Action<uint, uint> TrainSkill,
Action<uint> SetCharacterOptions,
Action<string> AddFriend, Action<string> AddFriend,
Action<uint> RemoveFriend, Action<uint> RemoveFriend,
Action ClearFriends, Action ClearFriends,
@ -36,6 +36,15 @@ internal sealed record LiveSessionCommandBindings(
Action<bool, uint, string, uint> ModifyCharacterSquelch, Action<bool, uint, string, uint> ModifyCharacterSquelch,
Action<bool, string> ModifyAccountSquelch, Action<bool, string> ModifyAccountSquelch,
Action<bool, uint> ModifyGlobalSquelch, Action<bool, uint> ModifyGlobalSquelch,
// Campaign CH slice CH3 (2026-08-09): the RuntimeCommunicationState.
// AddText chokepoint (local refusals — Turbine unavailable / not
// listening) and the RuntimeCharacterState owner (Hear*Chat options +
// IsOlthoiPlayer) the Turbine membership gate reads, plus the
// SetSingleCharacterOption (0x0005) sender that replaced the malformed,
// callerless full-blob SetCharacterOptions (0x01A1) path.
RuntimeCommunicationState Communication,
RuntimeCharacterState CharacterState,
Action<uint, bool> SendSingleCharacterOption,
Action<string>? Log = null); Action<string>? Log = null);
internal readonly record struct AddShortcutRuntimeCmd(ShortcutEntry Entry); internal readonly record struct AddShortcutRuntimeCmd(ShortcutEntry Entry);
@ -57,7 +66,9 @@ internal readonly record struct RaiseAttributeRuntimeCmd(uint StatId, ulong Cost
internal readonly record struct RaiseVitalRuntimeCmd(uint StatId, ulong Cost); internal readonly record struct RaiseVitalRuntimeCmd(uint StatId, ulong Cost);
internal readonly record struct RaiseSkillRuntimeCmd(uint StatId, ulong Cost); internal readonly record struct RaiseSkillRuntimeCmd(uint StatId, ulong Cost);
internal readonly record struct TrainSkillRuntimeCmd(uint StatId, uint Cost); internal readonly record struct TrainSkillRuntimeCmd(uint StatId, uint Cost);
internal readonly record struct SetCharacterOptionsRuntimeCmd(uint Options); internal readonly record struct SetSingleCharacterOptionRuntimeCmd(
uint OptionId,
bool Value);
internal readonly record struct AddFriendRuntimeCmd(string Name); internal readonly record struct AddFriendRuntimeCmd(string Name);
internal readonly record struct RemoveFriendRuntimeCmd(uint CharacterId); internal readonly record struct RemoveFriendRuntimeCmd(uint CharacterId);
internal readonly record struct ClearFriendsRuntimeCmd; internal readonly record struct ClearFriendsRuntimeCmd;
@ -97,6 +108,8 @@ internal sealed class LiveSessionCommandRouter : ILiveSessionCommandRouting
ArgumentNullException.ThrowIfNull(bindings.SendTell); ArgumentNullException.ThrowIfNull(bindings.SendTell);
ArgumentNullException.ThrowIfNull(bindings.SendChannel); ArgumentNullException.ThrowIfNull(bindings.SendChannel);
ArgumentNullException.ThrowIfNull(bindings.SendTurbineChat); ArgumentNullException.ThrowIfNull(bindings.SendTurbineChat);
ArgumentNullException.ThrowIfNull(bindings.Communication);
ArgumentNullException.ThrowIfNull(bindings.CharacterState);
_clientCommands = bindings.ClientCommands; _clientCommands = bindings.ClientCommands;
var commands = new LiveCommandBus(); var commands = new LiveCommandBus();
@ -145,9 +158,11 @@ internal sealed class LiveSessionCommandRouter : ILiveSessionCommandRouting
commands.Register<TrainSkillRuntimeCmd>( commands.Register<TrainSkillRuntimeCmd>(
command => SendIfActive(() => command => SendIfActive(() =>
bindings.TrainSkill(command.StatId, command.Cost))); bindings.TrainSkill(command.StatId, command.Cost)));
commands.Register<SetCharacterOptionsRuntimeCmd>( commands.Register<SetSingleCharacterOptionRuntimeCmd>(
command => SendIfActive(() => command => SendIfActive(() =>
bindings.SetCharacterOptions(command.Options))); bindings.SendSingleCharacterOption(
command.OptionId,
command.Value)));
commands.Register<AddFriendRuntimeCmd>( commands.Register<AddFriendRuntimeCmd>(
command => SendIfActive(() => bindings.AddFriend(command.Name))); command => SendIfActive(() => bindings.AddFriend(command.Name)));
commands.Register<RemoveFriendRuntimeCmd>( commands.Register<RemoveFriendRuntimeCmd>(
@ -216,6 +231,28 @@ internal sealed class LiveSessionCommandRouter : ILiveSessionCommandRouting
commands?.Clear(); commands?.Clear();
} }
/// <summary>
/// The seven <see cref="ChatChannelKind"/> values that ride Turbine
/// (0xF7DE), mapped to the lighter <see cref="ChatChannelKindLite"/>
/// <see cref="TurbineChatMembershipGate"/> reads. Every OTHER channel
/// kind (Fellowship/Vassals/Patron/Monarch/CoVassals/AllegianceBroadcast)
/// is legacy-only (0x0147) — the two pipelines never overlap, so this
/// dispatch is exhaustive rather than "try Turbine, fall back to
/// legacy." That fallback was the bug (CH3 research doc §5.3): with no
/// allegiance, <c>/a</c> silently downgraded to the legacy
/// AllegianceBroadcast bitflag instead of retail's local refusal.
/// </summary>
private static readonly Dictionary<ChatChannelKind, ChatChannelKindLite> TurbineChannelKinds = new()
{
[ChatChannelKind.Allegiance] = ChatChannelKindLite.Allegiance,
[ChatChannelKind.General] = ChatChannelKindLite.General,
[ChatChannelKind.Trade] = ChatChannelKindLite.Trade,
[ChatChannelKind.Lfg] = ChatChannelKindLite.Lfg,
[ChatChannelKind.Roleplay] = ChatChannelKindLite.Roleplay,
[ChatChannelKind.Society] = ChatChannelKindLite.Society,
[ChatChannelKind.Olthoi] = ChatChannelKindLite.Olthoi,
};
private void RouteChat( private void RouteChat(
LiveSessionCommandBindings bindings, LiveSessionCommandBindings bindings,
SendChatCmd command) SendChatCmd command)
@ -248,45 +285,100 @@ internal sealed class LiveSessionCommandRouter : ILiveSessionCommandRouting
return; return;
} }
TurbineResolution? turbine = TurbineChatRouting.Resolve( if (TurbineChannelKinds.TryGetValue(command.Channel, out ChatChannelKindLite liteKind))
command.Channel,
bindings.TurbineChat);
if (turbine is not null)
{ {
uint cookie = bindings.TurbineChat.NextContextId(); RouteTurbineChat(bindings, liteKind, command.Text);
uint senderGuid = bindings.PlayerGuid();
bindings.Log?.Invoke(
$"chat: outbound TurbineChat {turbine.Value.DisplayName} " +
$"room=0x{turbine.Value.RoomId:X8} chatType={turbine.Value.ChatType} " +
$"cookie=0x{cookie:X} sender=0x{senderGuid:X8} len={command.Text.Length}");
SendIfActive(() => bindings.SendTurbineChat(
turbine.Value.RoomId,
turbine.Value.ChatType,
(uint)TurbineChat.DispatchType.SendToRoomById,
senderGuid,
command.Text,
cookie));
return; return;
} }
ChannelResolver.Resolved? legacy = ChannelResolver.Resolve(command.Channel); RouteLegacyChannel(bindings, command.Channel, command.Text);
}
/// <summary>
/// Step 2 of the CH3 fix list: retail
/// <c>ClientCommunicationSystem::SendTurbineChat @0x0057db10</c>'s local
/// membership gate, raised through the same
/// <c>RuntimeCommunicationState.AddText</c> chokepoint CH2 built for
/// every other client-raised refusal.
/// </summary>
private void RouteTurbineChat(
LiveSessionCommandBindings bindings,
ChatChannelKindLite kind,
string text)
{
TurbineChatGateResult gate = TurbineChatMembershipGate.Evaluate(
kind,
bindings.TurbineChat,
bindings.CharacterState.Options,
bindings.CharacterState.IsOlthoiPlayer);
switch (gate.Status)
{
case TurbineChatGateStatus.Unavailable:
bindings.Communication.AddText(
ClientTextRefusals.TurbineChatUnavailable,
RetailLogTextType.Default);
return;
case TurbineChatGateStatus.NotListening:
{
(string? refusal, RetailLogTextType type) =
WeenieErrorMessages.Resolve(0x0551u, gate.DisplayName);
if (refusal is not null)
bindings.Communication.AddText(refusal, type);
return;
}
}
uint cookie = bindings.TurbineChat.NextContextId();
uint senderGuid = bindings.PlayerGuid();
bindings.Log?.Invoke(
$"chat: outbound TurbineChat {gate.DisplayName} " +
$"room=0x{gate.RoomId:X8} chatType={gate.ChatType} " +
$"cookie=0x{cookie:X} sender=0x{senderGuid:X8} len={text.Length}");
SendIfActive(() => bindings.SendTurbineChat(
gate.RoomId,
gate.ChatType,
(uint)TurbineChat.DispatchType.SendToRoomById,
senderGuid,
text,
cookie));
}
private void RouteLegacyChannel(
LiveSessionCommandBindings bindings,
ChatChannelKind channel,
string text)
{
ChannelResolver.Resolved? legacy = ChannelResolver.Resolve(channel);
if (legacy is null) if (legacy is null)
{ {
bindings.Log?.Invoke( bindings.Log?.Invoke(
$"chat: SendChatCmd kind={command.Channel} dropped " + $"chat: SendChatCmd kind={channel} dropped (no legacy id)");
$"(turbine.Enabled={bindings.TurbineChat.Enabled} no legacy id)");
return; return;
} }
bindings.Log?.Invoke( bindings.Log?.Invoke(
$"chat: outbound legacy ChatChannel {legacy.Value.DisplayName} " + $"chat: outbound legacy ChatChannel {legacy.Value.DisplayName} " +
$"id=0x{legacy.Value.ChannelId:X8} len={command.Text.Length}"); $"id=0x{legacy.Value.ChannelId:X8} len={text.Length}");
if (!SendIfActive(() => if (!SendIfActive(() =>
bindings.SendChannel(legacy.Value.ChannelId, command.Text))) bindings.SendChannel(legacy.Value.ChannelId, text)))
return; return;
// Step 5: wire ChatChannelInfo.IsSelfEchoChannel() — ACE resends
// Fellow/Vassals/Patron/Monarch/CoVassals to the sender with an
// empty sender name, so a local optimistic echo double-prints.
// AllegianceBroadcast includes the sender in its real-name broadcast
// with no such server echo, so it keeps the local echo (research
// doc §3.7/§5.4).
bool serverEchoes = new ChatChannelInfo.Legacy(
legacy.Value.ChannelId,
legacy.Value.DisplayName).IsSelfEchoChannel();
if (serverEchoes)
return;
bindings.Chat.OnSelfSent( bindings.Chat.OnSelfSent(
ChatKind.Channel, ChatKind.Channel,
command.Text, text,
targetOrChannel: legacy.Value.DisplayName, targetOrChannel: legacy.Value.DisplayName,
// Precise per-bit own-send type (LegacyChannelChatType.Resolve's // Precise per-bit own-send type (LegacyChannelChatType.Resolve's
// ownSend:true branch) — e.g. Fellowship keeps 0x13, Patron/ // ownSend:true branch) — e.g. Fellowship keeps 0x13, Patron/
@ -389,50 +481,3 @@ internal sealed class LiveSessionCommandRouter : ILiveSessionCommandRouting
} }
} }
} }
internal readonly record struct TurbineResolution(
uint RoomId,
uint ChatType,
string DisplayName);
internal static class TurbineChatRouting
{
/// <summary>
/// Resolve the server-assigned Turbine room for one UI channel. This is
/// the existing holtburger <c>resolve_turbine_channel</c> mapping
/// (<c>references/holtburger/.../client/commands.rs</c>, lines 64-98),
/// moved intact from GameWindow with its runtime-room gate preserved.
/// </summary>
public static TurbineResolution? Resolve(
ChatChannelKind kind,
TurbineChatState state)
{
ArgumentNullException.ThrowIfNull(state);
if (!state.Enabled)
return null;
(uint Room, uint ChatType, string Name) = kind switch
{
ChatChannelKind.Allegiance =>
(state.AllegianceRoom, (uint)TurbineChat.ChatType.Allegiance, "Allegiance"),
ChatChannelKind.General =>
(state.GeneralRoom, (uint)TurbineChat.ChatType.General, "General"),
ChatChannelKind.Trade =>
(state.TradeRoom, (uint)TurbineChat.ChatType.Trade, "Trade"),
ChatChannelKind.Lfg =>
(state.LfgRoom, (uint)TurbineChat.ChatType.Lfg, "LFG"),
ChatChannelKind.Roleplay =>
(state.RoleplayRoom, (uint)TurbineChat.ChatType.Roleplay, "Roleplay"),
ChatChannelKind.Society =>
(state.SocietyRoom, (uint)TurbineChat.ChatType.Society, "Society"),
ChatChannelKind.Olthoi =>
(state.OlthoiRoom, (uint)TurbineChat.ChatType.Olthoi, "Olthoi"),
_ => (0u, 0u, string.Empty),
};
return Room == 0u
? null
: new TurbineResolution(Room, ChatType, Name);
}
}

View file

@ -310,7 +310,13 @@ internal sealed class LiveSessionRuntimeFactory
// (pseudocode doc §8/§9). C3c-F1: that seam is now the Runtime // (pseudocode doc §8/§9). C3c-F1: that seam is now the Runtime
// movement owner's typed application entry — see // movement owner's typed application entry — see
// LiveMovementStatsApplier. // LiveMovementStatsApplier.
OnMovementStatsUpdated: () => _movementStats.Apply("stats")); OnMovementStatsUpdated: () => _movementStats.Apply("stats"),
// Campaign CH slice CH3 (2026-08-09): reseed the Settings Chat
// draft from server truth every time a PlayerDescription lands
// (research doc §5.2/§6.4 — the local ChatSettings.Default lied
// relative to ACE's CharacterOptions2.Default).
OnCharacterOptionsChanged: (_, options2) =>
_interaction.Settings.SyncChatFromServerOptions(options2));
} }
private LiveSessionCommandBindings CreateCommandBindings( private LiveSessionCommandBindings CreateCommandBindings(
@ -448,7 +454,6 @@ internal sealed class LiveSessionRuntimeFactory
RaiseVital: session.SendRaiseVital, RaiseVital: session.SendRaiseVital,
RaiseSkill: session.SendRaiseSkill, RaiseSkill: session.SendRaiseSkill,
TrainSkill: session.SendTrainSkill, TrainSkill: session.SendTrainSkill,
SetCharacterOptions: session.SendSetCharacterOptions,
AddFriend: session.SendAddFriend, AddFriend: session.SendAddFriend,
RemoveFriend: session.SendRemoveFriend, RemoveFriend: session.SendRemoveFriend,
ClearFriends: session.SendClearFriends, ClearFriends: session.SendClearFriends,
@ -456,6 +461,9 @@ internal sealed class LiveSessionRuntimeFactory
ModifyCharacterSquelch: session.SendModifyCharacterSquelch, ModifyCharacterSquelch: session.SendModifyCharacterSquelch,
ModifyAccountSquelch: session.SendModifyAccountSquelch, ModifyAccountSquelch: session.SendModifyAccountSquelch,
ModifyGlobalSquelch: session.SendModifyGlobalSquelch, ModifyGlobalSquelch: session.SendModifyGlobalSquelch,
Communication: _domain.Communication,
CharacterState: _domain.Character,
SendSingleCharacterOption: session.SendSetSingleCharacterOption,
Log: _log); Log: _log);
private static double ClientTimerNow() => private static double ClientTimerNow() =>

View file

@ -665,14 +665,15 @@ internal sealed class CurrentGameRuntimeCommandAdapter
command.StatId); command.StatId);
} }
public RuntimeCommandResult SetOptions1( public RuntimeCommandResult SetSingleOption(
RuntimeGenerationToken expectedGeneration, RuntimeGenerationToken expectedGeneration,
uint options) uint optionId,
bool value)
{ {
RuntimeCommandStatus gate = Validate(expectedGeneration, requireWorld: true); RuntimeCommandStatus gate = Validate(expectedGeneration, requireWorld: true);
if (gate != RuntimeCommandStatus.Accepted) if (gate != RuntimeCommandStatus.Accepted)
return Result(gate); return Result(gate);
_commands.Publish(new SetCharacterOptionsRuntimeCmd(options)); _commands.Publish(new SetSingleCharacterOptionRuntimeCmd(optionId, value));
return EmitResult( return EmitResult(
RuntimeCommandDomain.Character, RuntimeCommandDomain.Character,
operation: 4, operation: 4,
@ -835,6 +836,7 @@ internal sealed class CurrentGameRuntimeCommandAdapter
RuntimeChatChannel.Tell => ChatChannelKind.Tell, RuntimeChatChannel.Tell => ChatChannelKind.Tell,
RuntimeChatChannel.Fellowship => ChatChannelKind.Fellowship, RuntimeChatChannel.Fellowship => ChatChannelKind.Fellowship,
RuntimeChatChannel.Allegiance => ChatChannelKind.Allegiance, RuntimeChatChannel.Allegiance => ChatChannelKind.Allegiance,
RuntimeChatChannel.AllegianceBroadcast => ChatChannelKind.AllegianceBroadcast,
RuntimeChatChannel.Vassals => ChatChannelKind.Vassals, RuntimeChatChannel.Vassals => ChatChannelKind.Vassals,
RuntimeChatChannel.Patron => ChatChannelKind.Patron, RuntimeChatChannel.Patron => ChatChannelKind.Patron,
RuntimeChatChannel.Monarch => ChatChannelKind.Monarch, RuntimeChatChannel.Monarch => ChatChannelKind.Monarch,

View file

@ -1,4 +1,5 @@
using AcDream.App.Combat; using AcDream.App.Combat;
using AcDream.Core.Net.Messages;
using AcDream.UI.Abstractions.Input; using AcDream.UI.Abstractions.Input;
using AcDream.UI.Abstractions.Panels.Settings; using AcDream.UI.Abstractions.Panels.Settings;
using AcDream.UI.Abstractions.Settings; using AcDream.UI.Abstractions.Settings;
@ -121,6 +122,15 @@ internal interface IRuntimeSettingsTargets
void ApplyQuality(QualitySettings quality); void ApplyQuality(QualitySettings quality);
void ApplyUiLock(bool locked); void ApplyUiLock(bool locked);
/// <summary>
/// Campaign CH slice CH3 (2026-08-09): the generation-gated seam
/// (matching J4.4's pattern) that publishes retail's
/// <c>SetSingleCharacterOption (0x0005)</c> for one Settings Chat
/// toggle. <paramref name="optionId"/> is an ACE
/// <c>CharacterOption</c> id (e.g. <c>ListenToGeneralChat = 0x23</c>).
/// </summary>
void SetSingleCharacterOption(uint optionId, bool value);
} }
internal interface IRuntimeSettingsPreviewSource internal interface IRuntimeSettingsPreviewSource
@ -499,6 +509,7 @@ internal sealed class RuntimeSettingsController :
private void SaveChat(ChatSettings chat) private void SaveChat(ChatSettings chat)
{ {
ChatSettings previous = Chat;
try try
{ {
_storage.SaveChat(chat); _storage.SaveChat(chat);
@ -508,6 +519,81 @@ internal sealed class RuntimeSettingsController :
catch (Exception ex) catch (Exception ex)
{ {
_log($"settings: chat save failed: {ex.Message}"); _log($"settings: chat save failed: {ex.Message}");
return;
}
// CH3 (2026-08-09): retail toggles a Hear*Chat option and pushes
// SetSingleCharacterOption (0x0005) in the same step (mirrors
// SaveGameplay's ApplyUiLock push above) — ACE's handler both flips
// the option AND joins/leaves the matching Turbine room.
PublishHearOptionChange(
previous.HearGeneralChat, chat.HearGeneralChat,
(uint)CharacterOptionId.ListenToGeneralChat);
PublishHearOptionChange(
previous.HearTradeChat, chat.HearTradeChat,
(uint)CharacterOptionId.ListenToTradeChat);
PublishHearOptionChange(
previous.HearLFGChat, chat.HearLFGChat,
(uint)CharacterOptionId.ListenToLFGChat);
PublishHearOptionChange(
previous.HearRoleplayChat, chat.HearRoleplayChat,
(uint)CharacterOptionId.ListenToRoleplayChat);
PublishHearOptionChange(
previous.HearSocietyChat, chat.HearSocietyChat,
(uint)CharacterOptionId.ListenToSocietyChat);
}
private void PublishHearOptionChange(bool previous, bool current, uint optionId)
{
if (previous == current)
return;
_runtimeTargets?.SetSingleCharacterOption(optionId, current);
}
/// <summary>
/// CH3 (2026-08-09): reseed the persisted + draft Chat snapshot from the
/// server's own <c>CharacterOptions2</c> bitfield (already parsed out of
/// PlayerDescription) — called whenever a fresh description lands. The
/// local <see cref="ChatSettings.Default"/> lies relative to ACE's
/// default (Roleplay/Society start OFF server-side), so this is the only
/// way the checkbox ever reflects truth for a character that never
/// explicitly saved a Chat preference.
/// </summary>
public void SyncChatFromServerOptions(uint options2)
{
// Applied identically to BOTH the persisted snapshot and the live
// draft (mirrors ApplyExternalGameplayChange's own idempotent-update
// shape) so an unsaved draft edit to an unrelated field (font size,
// timestamps, ...) survives the reseed instead of being clobbered by
// a value computed once against the persisted snapshot.
ChatSettings Reseed(ChatSettings current) => current with
{
HearGeneralChat = (options2
& (uint)PlayerDescriptionParser.CharacterOptions2.HearGeneralChat) != 0u,
HearTradeChat = (options2
& (uint)PlayerDescriptionParser.CharacterOptions2.HearTradeChat) != 0u,
HearLFGChat = (options2
& (uint)PlayerDescriptionParser.CharacterOptions2.HearLFGChat) != 0u,
HearRoleplayChat = (options2
& (uint)PlayerDescriptionParser.CharacterOptions2.HearRoleplayChat) != 0u,
HearSocietyChat = (options2
& (uint)PlayerDescriptionParser.CharacterOptions2.HearSocietyChat) != 0u,
};
ChatSettings synced = Reseed(Chat);
if (synced == Chat)
return;
Chat = synced;
_viewModel?.ApplyExternalChatChange(Reseed);
try
{
_storage.SaveChat(synced);
_log($"settings: chat synced from server options2=0x{options2:X8}");
}
catch (Exception ex)
{
_log($"settings: chat sync save failed: {ex.Message}");
} }
} }

View file

@ -1,8 +1,10 @@
using AcDream.App.Audio; using AcDream.App.Audio;
using AcDream.App.Net;
using AcDream.App.Rendering; using AcDream.App.Rendering;
using AcDream.App.Rendering.Wb; using AcDream.App.Rendering.Wb;
using AcDream.App.Streaming; using AcDream.App.Streaming;
using AcDream.App.UI; using AcDream.App.UI;
using AcDream.UI.Abstractions;
using AcDream.UI.Abstractions.Panels.Settings; using AcDream.UI.Abstractions.Panels.Settings;
using AcDream.UI.Abstractions.Settings; using AcDream.UI.Abstractions.Settings;
using Silk.NET.Maths; using Silk.NET.Maths;
@ -203,6 +205,7 @@ internal sealed class RuntimeSettingsTargets : IRuntimeSettingsTargets
private readonly IRuntimeDisplayWindowTarget _displayWindow; private readonly IRuntimeDisplayWindowTarget _displayWindow;
private readonly IRuntimeQualityApplicationTarget _quality; private readonly IRuntimeQualityApplicationTarget _quality;
private readonly IRuntimeUiLockTarget _uiLock; private readonly IRuntimeUiLockTarget _uiLock;
private readonly ICommandBus _commands;
private readonly Action<string> _log; private readonly Action<string> _log;
public RuntimeSettingsTargets( public RuntimeSettingsTargets(
@ -212,6 +215,7 @@ internal sealed class RuntimeSettingsTargets : IRuntimeSettingsTargets
StreamingController streaming, StreamingController streaming,
WorldRenderRangeState renderRange, WorldRenderRangeState renderRange,
UiRoot? uiRoot, UiRoot? uiRoot,
ICommandBus commands,
Action<string>? log = null) Action<string>? log = null)
: this( : this(
displayWindow, displayWindow,
@ -223,6 +227,7 @@ internal sealed class RuntimeSettingsTargets : IRuntimeSettingsTargets
uiRoot is null uiRoot is null
? NullRuntimeUiLockTarget.Instance ? NullRuntimeUiLockTarget.Instance
: new RuntimeUiLockTarget(uiRoot), : new RuntimeUiLockTarget(uiRoot),
commands,
log) log)
{ {
} }
@ -231,12 +236,14 @@ internal sealed class RuntimeSettingsTargets : IRuntimeSettingsTargets
IRuntimeDisplayWindowTarget displayWindow, IRuntimeDisplayWindowTarget displayWindow,
IRuntimeQualityApplicationTarget quality, IRuntimeQualityApplicationTarget quality,
IRuntimeUiLockTarget uiLock, IRuntimeUiLockTarget uiLock,
ICommandBus commands,
Action<string>? log = null) Action<string>? log = null)
{ {
_displayWindow = displayWindow _displayWindow = displayWindow
?? throw new ArgumentNullException(nameof(displayWindow)); ?? throw new ArgumentNullException(nameof(displayWindow));
_quality = quality ?? throw new ArgumentNullException(nameof(quality)); _quality = quality ?? throw new ArgumentNullException(nameof(quality));
_uiLock = uiLock ?? throw new ArgumentNullException(nameof(uiLock)); _uiLock = uiLock ?? throw new ArgumentNullException(nameof(uiLock));
_commands = commands ?? throw new ArgumentNullException(nameof(commands));
_log = log ?? Console.WriteLine; _log = log ?? Console.WriteLine;
} }
@ -257,4 +264,14 @@ internal sealed class RuntimeSettingsTargets : IRuntimeSettingsTargets
} }
public void ApplyUiLock(bool locked) => _uiLock.Apply(locked); public void ApplyUiLock(bool locked) => _uiLock.Apply(locked);
/// <summary>
/// CH3 (2026-08-09): publishes through the SAME
/// <see cref="LiveSessionCommandRouter"/> generation-gated route every
/// other outbound Settings/chat command uses — a no-op when no route is
/// currently attached (disconnected / reconnecting), exactly like every
/// other <c>ICommandBus.Publish</c> call site.
/// </summary>
public void SetSingleCharacterOption(uint optionId, bool value) =>
_commands.Publish(new SetSingleCharacterOptionRuntimeCmd(optionId, value));
} }

View file

@ -207,10 +207,35 @@ public static class PlayerDescriptionParser
{ {
None = 0, None = 0,
AllowGive = 0x00000040, AllowGive = 0x00000040,
// Campaign CH slice CH3 (2026-08-09): retail's Turbine-room
// membership gate for the Allegiance chat room
// (ClientCommunicationSystem::SendTurbineChat @0x0057db10 reads
// PlayerModule::HearAllegianceChat as its "hearOption" argument for
// DoTurbineChat_Allegiance). ACE: CharacterOptions1.cs.
HearAllegianceChat = 0x40000000,
DragItemOnPlayerOpensSecureTrade = 0x04000000, DragItemOnPlayerOpensSecureTrade = 0x04000000,
Default = 0x50C4A54A, Default = 0x50C4A54A,
} }
/// <summary>
/// Second character-option bitfield (the trailer's
/// <c>CharacterOptions2</c> u32, present when
/// <see cref="CharacterOptionDataFlag.CharacterOptions2"/> is set).
/// Campaign CH slice CH3 (2026-08-09): only the five <c>Hear*Chat</c>
/// membership bits retail's <c>SendTurbineChat</c> reads are modeled
/// here — ACE <c>CharacterOptions2.cs</c> has the complete bitfield.
/// </summary>
[Flags]
public enum CharacterOptions2 : uint
{
None = 0,
HearGeneralChat = 0x00000100,
HearTradeChat = 0x00000200,
HearLFGChat = 0x00000400,
HearRoleplayChat = 0x00000800,
HearSocietyChat = 0x00080000,
}
/// <summary>One inventory entry — a guid plus a ContainerType /// <summary>One inventory entry — a guid plus a ContainerType
/// discriminator (0=NonContainer, 1=Container, 2=Foci). Holtburger /// discriminator (0=NonContainer, 1=Container, 2=Foci). Holtburger
/// <c>events.rs:143-168</c> validates <c>ContainerType &lt;= 2</c> /// <c>events.rs:143-168</c> validates <c>ContainerType &lt;= 2</c>

View file

@ -23,6 +23,24 @@ namespace AcDream.Core.Net.Messages;
/// References: r08 §3 rows for each opcode. /// References: r08 §3 rows for each opcode.
/// </para> /// </para>
/// </summary> /// </summary>
/// <summary>
/// ACE <c>CharacterOption</c> ids (a LINEAR enum, distinct from the
/// <c>CharacterOptions1</c>/<c>CharacterOptions2</c> BITFIELDS) — the first
/// <c>u32</c> of a <c>SetSingleCharacterOption (0x0005)</c> payload. Only
/// the six <c>ListenTo*Chat</c> ids Campaign CH slice CH3 (2026-08-09) needs
/// are modeled here; ACE <c>Source/ACE.Entity/Enum/CharacterOption.cs</c>
/// has the complete list.
/// </summary>
public enum CharacterOptionId : uint
{
ListenToAllegianceChat = 0x1B,
ListenToGeneralChat = 0x23,
ListenToTradeChat = 0x24,
ListenToLFGChat = 0x25,
ListenToRoleplayChat = 0x26,
ListenToSocietyChat = 0x2E,
}
public static class SocialActions public static class SocialActions
{ {
public const uint GameActionEnvelope = 0xF7B1u; public const uint GameActionEnvelope = 0xF7B1u;
@ -40,11 +58,15 @@ public static class SocialActions
public const uint FellowshipUpdateOpcode = 0x00A6u; // bool open public const uint FellowshipUpdateOpcode = 0x00A6u; // bool open
// Character options // Character options
public const uint SetCharacterOptionsOpcode = 0x01A1u; // u32 options bitmap // CH3 (2026-08-09): the full-blob SetCharacterOptions (0x01A1) builder
// and the string-payload AddChannel/RemoveChannel (0x0145/0x0146)
// Chat channels // builders were deleted here — none had a production caller, and all
public const uint AddChannelOpcode = 0x0145u; // string16L channelName // three were malformed against ACE's real reader (research doc
public const uint RemoveChannelOpcode = 0x0146u; // string16L channelName // 2026-08-09-chat-side-channels-vs-ace.md §3.8/§3.7/§5.5). Only
// SetSingleCharacterOption (0x0005) — the message that actually
// changes Turbine room membership — has a caller (WorldSession.
// SendSetSingleCharacterOption), so only it is implemented.
public const uint SetSingleCharacterOptionOpcode = 0x0005u; // u32 optionId, u32 value (0/1)
/// <summary>Query a target's health — server replies with UpdateHealth (0x01C0).</summary> /// <summary>Query a target's health — server replies with UpdateHealth (0x01C0).</summary>
public static byte[] BuildQueryHealth(uint seq, uint targetGuid) public static byte[] BuildQueryHealth(uint seq, uint targetGuid)
@ -132,25 +154,29 @@ public static class SocialActions
return body; return body;
} }
/// <summary>Push the client's character-options bitmap to the server.</summary> /// <summary>
public static byte[] BuildSetCharacterOptions(uint seq, uint optionsBitmap) /// Toggle one character option and push it to the server.
/// <c>GameActionSetSingleCharacterOption @ GameActionType 0x0005</c> —
/// the ONLY wire message that changes Turbine room membership: for the
/// <c>ListenTo*Chat</c> option ids, ACE's handler both flips the option
/// AND calls <c>JoinTurbineChatChannel</c>/<c>LeaveTurbineChatChannel</c>
/// (re-pushing <c>SetTurbineChatChannels</c>). Payload
/// <c>u32 option, u32 value</c> confirmed against ACE
/// (<c>GameActionSetSingleCharacterOption.cs:11-12</c>) and holtburger
/// (<c>SetSingleCharacterOptionActionData::pack</c>,
/// <c>messages/player/actions.rs:149-157</c>) — both agree byte-for-byte.
/// </summary>
public static byte[] BuildSetSingleCharacterOption(uint seq, uint optionId, bool value)
{ {
byte[] body = new byte[16]; byte[] body = new byte[20];
BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope); BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq); BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), SetCharacterOptionsOpcode); BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), SetSingleCharacterOptionOpcode);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), optionsBitmap); BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), optionId);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(16), value ? 1u : 0u);
return body; return body;
} }
/// <summary>Subscribe to a named chat channel.</summary>
public static byte[] BuildAddChannel(uint seq, string channelName)
=> SingleString(seq, AddChannelOpcode, channelName);
/// <summary>Unsubscribe from a named chat channel.</summary>
public static byte[] BuildRemoveChannel(uint seq, string channelName)
=> SingleString(seq, RemoveChannelOpcode, channelName);
// ── Helpers ────────────────────────────────────────────────────────────── // ── Helpers ──────────────────────────────────────────────────────────────
private static byte[] SingleGuid(uint seq, uint sub, uint guid) private static byte[] SingleGuid(uint seq, uint sub, uint guid)
@ -163,17 +189,6 @@ public static class SocialActions
return body; return body;
} }
private static byte[] SingleString(uint seq, uint sub, string s)
{
byte[] str = PackString16L(s);
byte[] body = new byte[12 + str.Length];
BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), sub);
Array.Copy(str, 0, body, 12, str.Length);
return body;
}
private static byte[] PackString16L(string s) private static byte[] PackString16L(string s)
{ {
ArgumentNullException.ThrowIfNull(s); ArgumentNullException.ThrowIfNull(s);

View file

@ -2195,13 +2195,14 @@ public sealed class WorldSession : IDisposable
} }
/// <summary> /// <summary>
/// Send retail SetCharacterOptions (0x01A1) for the first character /// Send retail SetSingleCharacterOption (0x0005) — toggles one character
/// option bitmap. /// option. For the six <c>ListenTo*Chat</c> ids this is the message that
/// actually joins/leaves a Turbine room server-side (CH3, 2026-08-09).
/// </summary> /// </summary>
public void SendSetCharacterOptions(uint options) public void SendSetSingleCharacterOption(uint optionId, bool value)
{ {
uint seq = NextGameActionSequence(); uint seq = NextGameActionSequence();
SendGameAction(SocialActions.BuildSetCharacterOptions(seq, options)); SendGameAction(SocialActions.BuildSetSingleCharacterOption(seq, optionId, value));
} }
public void SendAddFriend(string name) public void SendAddFriend(string name)

View file

@ -50,6 +50,14 @@ public abstract record ChatChannelInfo(string DisplayName, ChatChannelSource Sou
// channels are the ones the server echoes back to the sender // channels are the ones the server echoes back to the sender
// with an empty sender field. Bitflag values from // with an empty sender field. Bitflag values from
// references/holtburger/.../messages/chat/types.rs::ChatChannel. // references/holtburger/.../messages/chat/types.rs::ChatChannel.
//
// CH3 (2026-08-09, research doc §3.7/§5.4): AllegianceBroadcast
// (0x02000000) deliberately falls to the `false` default below —
// ACE's GameActionChatChannel handler includes the sender in the
// normal real-name member broadcast for that channel (no
// separate "" -sender echo the way Fellow/Vassals/Patron/
// Monarch/CoVassals get), so the client must keep its own local
// optimistic echo or the sender never sees their own line.
return ChannelId switch return ChannelId switch
{ {
0x00000800u => true, // Fellow 0x00000800u => true, // Fellow

View file

@ -106,4 +106,21 @@ public static class ClientTextRefusals
/// "You can't use chat emotes from t…". /// "You can't use chat emotes from t…".
/// </summary> /// </summary>
public const string CantEmotePosition = "You can't use chat emotes from this position"; public const string CantEmotePosition = "You can't use chat emotes from this position";
/// <summary>
/// Campaign CH slice CH3 (2026-08-09): retail
/// <c>ClientCommunicationSystem::SendTurbineChat @0x0057db10</c>'s local
/// refusal when Turbine chat is disabled or the target room id is 0 (no
/// allegiance, no society, etc.) — raised BEFORE any wire send, exactly
/// like the jump refusals above. Byte-read off the PDB-paired binary at
/// <c>0x007e0d1c</c>: <c>"Turbine chat is not available.\n"</c>. The call
/// site (<c>0057dd64</c>) passes type <c>0</c> (Default), not <c>0x1A</c>
/// (ClientLocal) — confirmed by direct comparison against the
/// unambiguous <c>cant_jump_in_air</c> call sites, which use the same
/// <c>AddTextToScroll(this, &amp;text, type, allowPluginFilter,
/// windowId)</c> argument order with a literal <c>0x1a</c> in the type
/// slot; this call has a literal <c>0</c> there instead. So this
/// refusal lands in the chat transcript, not the SpewBox.
/// </summary>
public const string TurbineChatUnavailable = "Turbine chat is not available.";
} }

View file

@ -76,6 +76,17 @@ public enum RuntimeChatChannel
Roleplay, Roleplay,
Society, Society,
Olthoi, Olthoi,
/// <summary>
/// Campaign CH slice CH3 (2026-08-09): the legacy
/// <c>ChatChannel.AllegianceBroadcast (0x02000000)</c> — the monarch/
/// speaker-permission broadcast bound to retail's <c>@ab</c> verb.
/// 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 — see the
/// research doc §4.3/§6.7).
/// </summary>
AllegianceBroadcast,
} }
public readonly record struct RuntimeChatCommand( public readonly record struct RuntimeChatCommand(
@ -227,9 +238,18 @@ public interface IRuntimeCharacterCommands
RuntimeGenerationToken expectedGeneration, RuntimeGenerationToken expectedGeneration,
in RuntimeAdvancementCommand command); in RuntimeAdvancementCommand command);
RuntimeCommandResult SetOptions1( /// <summary>
/// Retail <c>SetSingleCharacterOption (GameActionType 0x0005)</c> —
/// toggles one character option. Campaign CH slice CH3 (2026-08-09)
/// replaced the former <c>SetOptions1</c> (the malformed, callerless
/// full-Options1-blob 0x01A1 builder) with this: the only wire message
/// that actually changes Turbine room membership for the six
/// <c>ListenTo*Chat</c> ids.
/// </summary>
RuntimeCommandResult SetSingleOption(
RuntimeGenerationToken expectedGeneration, RuntimeGenerationToken expectedGeneration,
uint options); uint optionId,
bool value);
} }
public enum RuntimeFriendCommandKind public enum RuntimeFriendCommandKind

View file

@ -2,6 +2,7 @@ using AcDream.Core.Player;
using AcDream.Core.Net.Messages; using AcDream.Core.Net.Messages;
using AcDream.Core.Spells; using AcDream.Core.Spells;
using AcDream.Core.Items; using AcDream.Core.Items;
using AcDream.Core.Properties;
namespace AcDream.Runtime.Gameplay; namespace AcDream.Runtime.Gameplay;
@ -107,6 +108,27 @@ public sealed class RuntimeCharacterState : IDisposable
public IRuntimeCharacterView View { get; } public IRuntimeCharacterView View { get; }
public bool IsDisposed => _disposed; public bool IsDisposed => _disposed;
/// <summary>
/// Campaign CH slice CH3 (2026-08-09): retail
/// <c>PlayerModule::IsOlthoi</c> / ACE <c>Player.IsOlthoiPlayer</c>
/// (<c>Player.cs:169</c>, <c>HeritageGroup == Olthoi ||
/// HeritageGroup == OlthoiAcid</c>) — the gate
/// <c>TurbineChatMembershipGate</c> consults for the Olthoi room instead
/// of a <c>Hear*Chat</c> option (there is no such toggle; only heritage
/// gates that room). Reads the already-parsed
/// <c>PropertyInt.HeritageGroup (188)</c> off the local player's own
/// property bundle — 0 (unparsed/unknown) reads as "not Olthoi", matching
/// every other heritage-gated check in this codebase.
/// </summary>
public bool IsOlthoiPlayer
{
get
{
int heritage = LocalPlayer.Properties.GetInt((uint)PropertyInt.HeritageGroup, 0);
return heritage == 12 || heritage == 13; // HeritageGroup.Olthoi / OlthoiAcid
}
}
/// <summary>Retail <c>CommandInterpreter::GetAutonomyLevel</c>.</summary> /// <summary>Retail <c>CommandInterpreter::GetAutonomyLevel</c>.</summary>
public uint AutonomyLevel => Volatile.Read(ref _autonomyLevel); public uint AutonomyLevel => Volatile.Read(ref _autonomyLevel);

View file

@ -0,0 +1,143 @@
using AcDream.Core.Chat;
using AcDream.Core.Net.Messages;
namespace AcDream.Runtime.Gameplay;
/// <summary>
/// Outcome of <see cref="TurbineChatMembershipGate.Evaluate"/>.
/// </summary>
public enum TurbineChatGateStatus
{
/// <summary>The send may proceed to the wire.</summary>
Allowed,
/// <summary>
/// Turbine chat is disabled, or this room's id is still 0 (not
/// populated by <c>SetTurbineChatChannels</c> — e.g. no allegiance, no
/// society). Retail: <c>"Turbine chat is not available."</c>
/// (<see cref="ClientTextRefusals.TurbineChatUnavailable"/>), raised
/// locally with NOTHING sent.
/// </summary>
Unavailable,
/// <summary>
/// The room exists and Turbine chat is enabled, but the player's own
/// <c>Hear*Chat</c> option (or, for Olthoi, heritage) is off. Retail:
/// <c>WeenieErrorWithString 0x0551 YouAreNotListeningTo_Channel</c>,
/// raised locally with NOTHING sent.
/// </summary>
NotListening,
}
/// <summary>One evaluated gate decision, carrying the room/chatType/display
/// name a caller needs whether the send proceeds or gets refused.</summary>
public readonly record struct TurbineChatGateResult(
TurbineChatGateStatus Status,
uint RoomId,
uint ChatType,
string DisplayName);
/// <summary>
/// Retail's local pre-send membership gate for a Turbine-room chat channel,
/// ported from <c>ClientCommunicationSystem::SendTurbineChat @0x0057db10</c>
/// (Sept 2013 EoR build; research doc
/// <c>docs/research/2026-08-09-chat-side-channels-vs-ace.md</c> §4.2/§6.2).
///
/// <para>
/// Both the graphical (<c>LiveSessionCommandRouter</c>) and headless
/// (<c>DirectGameRuntimeCommandAdapter</c>) outbound chat paths call this
/// SAME chokepoint so the two hosts can never diverge on which channels are
/// joined and which local refusal a blocked send produces. Retail refuses
/// LOCALLY (nothing reaches the wire) in exactly two cases — Turbine chat
/// off / room id 0, or the player's own <c>Hear*Chat</c> option off — before
/// ever building a <c>TurbineChatBlob</c>.
/// </para>
/// </summary>
public static class TurbineChatMembershipGate
{
public static TurbineChatGateResult Evaluate(
ChatChannelKindLite kind,
TurbineChatState turbineChat,
RuntimeCharacterOptionsState options,
bool isOlthoiPlayer)
{
ArgumentNullException.ThrowIfNull(turbineChat);
ArgumentNullException.ThrowIfNull(options);
(uint room, uint chatType, string name) = kind switch
{
ChatChannelKindLite.Allegiance => (
turbineChat.AllegianceRoom,
(uint)TurbineChat.ChatType.Allegiance,
"Allegiance"),
ChatChannelKindLite.General => (
turbineChat.GeneralRoom,
(uint)TurbineChat.ChatType.General,
"General"),
ChatChannelKindLite.Trade => (
turbineChat.TradeRoom,
(uint)TurbineChat.ChatType.Trade,
"Trade"),
ChatChannelKindLite.Lfg => (
turbineChat.LfgRoom,
(uint)TurbineChat.ChatType.Lfg,
"LFG"),
ChatChannelKindLite.Roleplay => (
turbineChat.RoleplayRoom,
(uint)TurbineChat.ChatType.Roleplay,
"Roleplay"),
ChatChannelKindLite.Society => (
turbineChat.SocietyRoom,
(uint)TurbineChat.ChatType.Society,
"Society"),
ChatChannelKindLite.Olthoi => (
turbineChat.OlthoiRoom,
(uint)TurbineChat.ChatType.Olthoi,
"Olthoi"),
_ => (0u, 0u, string.Empty),
};
if (!turbineChat.Enabled || room == 0u)
{
return new TurbineChatGateResult(
TurbineChatGateStatus.Unavailable, room, chatType, name);
}
bool hearOption = kind switch
{
ChatChannelKindLite.Allegiance =>
(options.Options1
& (uint)PlayerDescriptionParser.CharacterOptions1.HearAllegianceChat)
!= 0u,
ChatChannelKindLite.General =>
(options.Options2
& (uint)PlayerDescriptionParser.CharacterOptions2.HearGeneralChat)
!= 0u,
ChatChannelKindLite.Trade =>
(options.Options2
& (uint)PlayerDescriptionParser.CharacterOptions2.HearTradeChat)
!= 0u,
ChatChannelKindLite.Lfg =>
(options.Options2
& (uint)PlayerDescriptionParser.CharacterOptions2.HearLFGChat)
!= 0u,
ChatChannelKindLite.Roleplay =>
(options.Options2
& (uint)PlayerDescriptionParser.CharacterOptions2.HearRoleplayChat)
!= 0u,
ChatChannelKindLite.Society =>
(options.Options2
& (uint)PlayerDescriptionParser.CharacterOptions2.HearSocietyChat)
!= 0u,
// Retail's Olthoi entry point passes CPlayerSystem::IsOlthoi()
// as its hearOption argument instead of a PlayerModule flag —
// there is no "Hear Olthoi chat" toggle, only heritage.
ChatChannelKindLite.Olthoi => isOlthoiPlayer,
_ => true,
};
return hearOption
? new TurbineChatGateResult(TurbineChatGateStatus.Allowed, room, chatType, name)
: new TurbineChatGateResult(TurbineChatGateStatus.NotListening, room, chatType, name);
}
}

View file

@ -649,15 +649,16 @@ public sealed class DirectGameRuntimeCommandAdapter
command.StatId); command.StatId);
} }
public RuntimeCommandResult SetOptions1( public RuntimeCommandResult SetSingleOption(
RuntimeGenerationToken expectedGeneration, RuntimeGenerationToken expectedGeneration,
uint options) uint optionId,
bool value)
{ {
RuntimeCommandStatus gate = RuntimeCommandStatus gate =
Validate(expectedGeneration, out WorldSession? session); Validate(expectedGeneration, out WorldSession? session);
if (gate != RuntimeCommandStatus.Accepted) if (gate != RuntimeCommandStatus.Accepted)
return Result(gate); return Result(gate);
session!.SendSetCharacterOptions(options); session!.SendSetSingleCharacterOption(optionId, value);
return EmitResult( return EmitResult(
RuntimeCommandDomain.Character, RuntimeCommandDomain.Character,
operation: 4, operation: 4,
@ -951,31 +952,49 @@ public sealed class DirectGameRuntimeCommandAdapter
RuntimeChatChannel channel, RuntimeChatChannel channel,
string text) string text)
{ {
if (TryMapTurbine( if (TryMapTurbine(channel, out ChatChannelKindLite turbineKind))
channel,
out ChatChannelKindLite turbineKind,
out TurbineChat.ChatType chatType))
{ {
TurbineChatState state = // CH3 (2026-08-09): the SAME membership gate the graphical host
_runtime.CommunicationOwner.TurbineChat; // uses (LiveSessionCommandRouter.RouteTurbineChat) — retail
uint roomId = state.RoomFor(turbineKind); // SendTurbineChat @0x0057db10 never falls through to the legacy
if (state.Enabled && roomId != 0u) // ChatChannel bitflag; it refuses locally instead.
TurbineChatGateResult gate = TurbineChatMembershipGate.Evaluate(
turbineKind,
_runtime.CommunicationOwner.TurbineChat,
_runtime.CharacterOwner.Options,
_runtime.CharacterOwner.IsOlthoiPlayer);
switch (gate.Status)
{ {
session.SendTurbineChatTo( case TurbineChatGateStatus.Unavailable:
roomId, _runtime.CommunicationOwner.AddText(
(uint)chatType, ClientTextRefusals.TurbineChatUnavailable,
(uint)TurbineChat.DispatchType.SendToRoomById, RetailLogTextType.Default);
_runtime.PlayerIdentity.ServerGuid, return true;
text, case TurbineChatGateStatus.NotListening:
state.NextContextId()); {
return true; (string? refusal, RetailLogTextType type) =
WeenieErrorMessages.Resolve(0x0551u, gate.DisplayName);
if (refusal is not null)
_runtime.CommunicationOwner.AddText(refusal, type);
return true;
}
} }
session.SendTurbineChatTo(
gate.RoomId,
gate.ChatType,
(uint)TurbineChat.DispatchType.SendToRoomById,
_runtime.PlayerIdentity.ServerGuid,
text,
_runtime.CommunicationOwner.TurbineChat.NextContextId());
return true;
} }
uint? legacyChannel = channel switch uint? legacyChannel = channel switch
{ {
RuntimeChatChannel.Fellowship => 0x00000800u, RuntimeChatChannel.Fellowship => 0x00000800u,
RuntimeChatChannel.Allegiance => 0x02000000u, RuntimeChatChannel.AllegianceBroadcast => 0x02000000u,
RuntimeChatChannel.Vassals => 0x00001000u, RuntimeChatChannel.Vassals => 0x00001000u,
RuntimeChatChannel.Patron => 0x00002000u, RuntimeChatChannel.Patron => 0x00002000u,
RuntimeChatChannel.Monarch => 0x00004000u, RuntimeChatChannel.Monarch => 0x00004000u,
@ -990,32 +1009,17 @@ public sealed class DirectGameRuntimeCommandAdapter
private static bool TryMapTurbine( private static bool TryMapTurbine(
RuntimeChatChannel channel, RuntimeChatChannel channel,
out ChatChannelKindLite kind, out ChatChannelKindLite kind)
out TurbineChat.ChatType chatType)
{ {
(kind, chatType) = channel switch kind = channel switch
{ {
RuntimeChatChannel.Allegiance => ( RuntimeChatChannel.Allegiance => ChatChannelKindLite.Allegiance,
ChatChannelKindLite.Allegiance, RuntimeChatChannel.General => ChatChannelKindLite.General,
TurbineChat.ChatType.Allegiance), RuntimeChatChannel.Trade => ChatChannelKindLite.Trade,
RuntimeChatChannel.General => ( RuntimeChatChannel.LookingForGroup => ChatChannelKindLite.Lfg,
ChatChannelKindLite.General, RuntimeChatChannel.Roleplay => ChatChannelKindLite.Roleplay,
TurbineChat.ChatType.General), RuntimeChatChannel.Society => ChatChannelKindLite.Society,
RuntimeChatChannel.Trade => ( RuntimeChatChannel.Olthoi => ChatChannelKindLite.Olthoi,
ChatChannelKindLite.Trade,
TurbineChat.ChatType.Trade),
RuntimeChatChannel.LookingForGroup => (
ChatChannelKindLite.Lfg,
TurbineChat.ChatType.Lfg),
RuntimeChatChannel.Roleplay => (
ChatChannelKindLite.Roleplay,
TurbineChat.ChatType.Roleplay),
RuntimeChatChannel.Society => (
ChatChannelKindLite.Society,
TurbineChat.ChatType.Society),
RuntimeChatChannel.Olthoi => (
ChatChannelKindLite.Olthoi,
TurbineChat.ChatType.Olthoi),
_ => default, _ => default,
}; };
return channel is RuntimeChatChannel.Allegiance return channel is RuntimeChatChannel.Allegiance

View file

@ -57,7 +57,15 @@ public sealed record LiveCharacterSessionBindings(
// OnSkillsUpdated's existing shape. Optional/nullable so every existing // OnSkillsUpdated's existing shape. Optional/nullable so every existing
// caller (including Headless's OnSkillsUpdated: null pattern) compiles // caller (including Headless's OnSkillsUpdated: null pattern) compiles
// unchanged. // unchanged.
Action? OnMovementStatsUpdated = null); Action? OnMovementStatsUpdated = null,
// Campaign CH slice CH3 (2026-08-09): fires with the raw
// (options1, options2) pair whenever a fresh PlayerDescription lands —
// AFTER Character.Options.Replace has already committed them. Lets the
// graphical host reseed its Settings "Hear * Chat" draft from server
// truth (research doc §5.2/§6.4: the local ChatSettings.Default lies
// relative to ACE's CharacterOptions2.Default). Optional/nullable so
// every existing caller compiles unchanged.
Action<uint, uint>? OnCharacterOptionsChanged = null);
public sealed record LiveSocialSessionBindings( public sealed record LiveSocialSessionBindings(
ChatLog Chat, ChatLog Chat,
@ -197,7 +205,11 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting
friends: social.Friends, friends: social.Friends,
squelch: social.Squelch, squelch: social.Squelch,
onDesiredComponents: null, onDesiredComponents: null,
onCharacterOptions: character.Character.Options.Replace, onCharacterOptions: (options1, options2) =>
{
character.Character.Options.Replace(options1, options2);
character.OnCharacterOptionsChanged?.Invoke(options1, options2);
},
clientTime: character.ClientTime, clientTime: character.ClientTime,
externalContainers: inventory.ExternalContainers, externalContainers: inventory.ExternalContainers,
vendor: inventory.Vendor, vendor: inventory.Vendor,
@ -501,19 +513,40 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting
private static void RouteTurbineChat(ChatLog chat, TurbineChat.Parsed parsed) private static void RouteTurbineChat(ChatLog chat, TurbineChat.Parsed parsed)
{ {
if (parsed.Body is not TurbineChat.Payload.EventSendToRoom message) switch (parsed.Body)
return; {
case TurbineChat.Payload.EventSendToRoom message:
// message.RoomId is an opaque per-session Turbine room GUID,
// not a legacy channel bitflag — ChatLog.OnChannelBroadcast's
// default (legacy-bit) LogTextType derivation would
// misclassify it, so the room's own ChatType maps to
// LogTextType explicitly here instead.
chat.OnChannelBroadcast(
message.RoomId,
message.SenderName,
message.Message,
logTextType: TurbineChatDisplayNames.LogTextType(message.ChatType),
channelName: TurbineChatDisplayNames.Resolve(
message.RoomId,
message.ChatType));
return;
// message.RoomId is an opaque per-session Turbine room GUID, not a case TurbineChat.Payload.Response { HResult: not 0 } response:
// legacy channel bitflag — ChatLog.OnChannelBroadcast's default // CH3 (2026-08-09, research doc §2.7/§6.6): previously
// (legacy-bit) LogTextType derivation would misclassify it, so the // discarded unconditionally — a server-side send rejection
// room's own ChatType maps to LogTextType explicitly here instead. // was completely invisible. HResult==0 (the overwhelmingly
chat.OnChannelBroadcast( // common case) stays silent, matching retail's own quiet
message.RoomId, // success ack.
message.SenderName, chat.OnSystemMessage(
message.Message, "TurbineChat send rejected "
logTextType: TurbineChatDisplayNames.LogTextType(message.ChatType), + $"(hresult=0x{unchecked((uint)response.HResult):X8}).",
channelName: TurbineChatDisplayNames.Resolve(message.RoomId, message.ChatType)); (uint)RetailLogTextType.Default);
return;
default:
// Response with HResult==0, or Unknown — nothing to surface.
return;
}
} }
private static void Validate( private static void Validate(

View file

@ -37,7 +37,11 @@ public static class ChannelResolver
// CoVassals = 0x01000000 // CoVassals = 0x01000000
// AllegianceBroadcast = 0x02000000 // AllegianceBroadcast = 0x02000000
ChatChannelKind.Fellowship => new Resolved(0x00000800u, "Fellowship"), 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.Vassals => new Resolved(0x00001000u, "Vassals"),
ChatChannelKind.Patron => new Resolved(0x00002000u, "Patron"), ChatChannelKind.Patron => new Resolved(0x00002000u, "Patron"),
ChatChannelKind.Monarch => new Resolved(0x00004000u, "Monarch"), ChatChannelKind.Monarch => new Resolved(0x00004000u, "Monarch"),

View file

@ -11,10 +11,17 @@ namespace AcDream.UI.Abstractions;
/// <para> /// <para>
/// Channels split into: /// Channels split into:
/// <list type="bullet"> /// <list type="bullet">
/// <item><b>Legacy</b> (Fellowship..CoVassals): map to a fixed <c>ChatChannel</c> /// <item><b>Legacy</b> (Fellowship, Vassals, Patron, Monarch, CoVassals,
/// bitflag id via <see cref="ChannelResolver"/> and ride 0x0147 ChatChannel.</item> /// AllegianceBroadcast): map to a fixed <c>ChatChannel</c> bitflag id
/// <item><b>Turbine</b> (General..Olthoi): require a TurbineChat channel id /// via <see cref="ChannelResolver"/> and ride 0x0147 ChatChannel.</item>
/// resolved at runtime — not yet wired (Phase I.6 owns TurbineChat).</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 /// <item><b>Say</b> / <b>Tell</b>: route to the dedicated 0x0015 / 0x005D
/// opcodes — no channel id needed.</item> /// opcodes — no channel id needed.</item>
/// </list> /// </list>
@ -36,5 +43,17 @@ public enum ChatChannelKind
Roleplay, Roleplay,
Society, Society,
Olthoi, 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, Unknown,
} }

View file

@ -62,6 +62,13 @@ public static class ChatInputParser
("/fellowship", ChatChannelKind.Fellowship), ("/fellowship", ChatChannelKind.Fellowship),
("/a", ChatChannelKind.Allegiance), ("/a", ChatChannelKind.Allegiance),
("/allegiance", 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), ("/m", ChatChannelKind.Monarch),
("/monarch", ChatChannelKind.Monarch), ("/monarch", ChatChannelKind.Monarch),
("/p", ChatChannelKind.Patron), ("/p", ChatChannelKind.Patron),

View file

@ -9,12 +9,17 @@ namespace AcDream.UI.Abstractions.Panels.Settings;
/// retail bit values. /// retail bit values.
/// ///
/// <para> /// <para>
/// L.0 scope: <b>local-only</b> like the rest of L.0. The Hear*Chat /// <b>Campaign CH slice CH3 (2026-08-09):</b> these five Hear*Chat flags
/// flags affect client-side <i>display</i> filtering of the existing /// are no longer local-only display filters — they ARE retail's server-side
/// channels — the server still streams every line; the client decides /// Turbine room membership. The App host seeds this draft from
/// what to render. Server-sync arrives in a later phase that flips the /// <c>RuntimeCharacterOptionsState.Options2</c> (already parsed out of
/// retail-faithful "tell server which channels I'm subscribed to" /// PlayerDescription) whenever a fresh description lands, and Save publishes
/// switch. /// 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> /// </para>
/// </summary> /// </summary>
public sealed record ChatSettings( public sealed record ChatSettings(

View file

@ -215,6 +215,22 @@ public sealed class SettingsVM
_chatDraft = value ?? throw new ArgumentNullException(nameof(value)); _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> /// <summary>
/// Replace the entire Character draft with <paramref name="value"/>. /// Replace the entire Character draft with <paramref name="value"/>.
/// Per-toon — the host knows which toon's bag we're editing because /// Per-toon — the host knows which toon's bag we're editing because

View file

@ -301,9 +301,10 @@ public sealed class InteractionUiRuntimeSourcesTests
in RuntimeAdvancementCommand command) => in RuntimeAdvancementCommand command) =>
Accepted(expectedGeneration, command.StatId); Accepted(expectedGeneration, command.StatId);
public RuntimeCommandResult SetOptions1( public RuntimeCommandResult SetSingleOption(
RuntimeGenerationToken expectedGeneration, RuntimeGenerationToken expectedGeneration,
uint options) => uint optionId,
bool value) =>
Accepted(expectedGeneration); Accepted(expectedGeneration);
private RuntimeCommandResult Accepted( private RuntimeCommandResult Accepted(

View file

@ -71,17 +71,16 @@ public sealed class LiveSessionCommandRouterTests
Assert.Equal([("Friend", "hello")], tells); Assert.Equal([("Friend", "hello")], tells);
Assert.Equal([(0x00000800u, "group")], channels); Assert.Equal([(0x00000800u, "group")], channels);
// CH3 (2026-08-09): Fellowship is one of the ACE server-echoing
// legacy channels (resends with an empty sender) — the router must
// NOT also emit a local optimistic echo, or the line double-prints.
// Only the Tell echo (which the server never resends) survives.
Assert.Collection( Assert.Collection(
chat.Snapshot(), chat.Snapshot(),
entry => entry =>
{ {
Assert.Equal(ChatKind.Tell, entry.Kind); Assert.Equal(ChatKind.Tell, entry.Kind);
Assert.Equal("Friend", entry.Sender); Assert.Equal("Friend", entry.Sender);
},
entry =>
{
Assert.Equal(ChatKind.Channel, entry.Kind);
Assert.Equal("Fellowship", entry.ChannelName);
}); });
} }
@ -124,6 +123,151 @@ public sealed class LiveSessionCommandRouterTests
Assert.Equal(0, chat.Count); Assert.Equal(0, chat.Count);
} }
// ── Campaign CH slice CH3 (2026-08-09): Turbine membership gate ──
[Fact]
public void TurbineUnavailable_RaisesRetailStringThroughAddText_AndSendsNothing()
{
var communication = new RuntimeCommunicationState();
var sent = new List<string>();
var router = NewRouter(
chat: communication.Chat,
communication: communication,
turbine: new TurbineChatState(), // never received SetTurbineChatChannels
sendTurbine: (_, _, _, _, text, _) => sent.Add(text));
router.Activate();
router.Publish(new SendChatCmd(ChatChannelKind.General, null, "hello"));
Assert.Empty(sent);
Assert.Equal(1, communication.Chat.Count);
Assert.Equal(
"Turbine chat is not available.",
communication.Chat.Snapshot()[0].Text);
}
[Fact]
public void HearOptionOff_RaisesNotListeningRefusal_AndSendsNothing()
{
// Fresh RuntimeCharacterOptionsState defaults omit HearRoleplayChat
// (matches ACE CharacterOptions2.Default) — a populated Roleplay
// room must still refuse locally rather than send.
var turbine = new TurbineChatState();
turbine.OnChannelsReceived(
0u, 0u, 0u, 0u, roleplayRoom: 0x14u, 0u, 0u, 0u, 0u, 0u);
var communication = new RuntimeCommunicationState();
var sent = new List<string>();
var router = NewRouter(
chat: communication.Chat,
communication: communication,
turbine: turbine,
sendTurbine: (_, _, _, _, text, _) => sent.Add(text));
router.Activate();
router.Publish(new SendChatCmd(ChatChannelKind.Roleplay, null, "hi"));
Assert.Empty(sent);
Assert.Equal(1, communication.Chat.Count);
Assert.Equal(
"You are not listening to the Roleplay channel!",
communication.Chat.Snapshot()[0].Text);
}
[Fact]
public void AllegianceWithNoRoom_RefusesLocally_NeverDowngradesToLegacyChannel()
{
// The CH3 headline /a bug (research doc §5.3): retail's @a is bound
// unconditionally to Turbine — no allegiance must refuse locally,
// NOT silently fall through to the legacy AllegianceBroadcast
// bitflag.
var communication = new RuntimeCommunicationState();
var legacySent = new List<(uint Id, string Text)>();
var turbineSent = new List<string>();
var router = NewRouter(
chat: communication.Chat,
communication: communication,
turbine: new TurbineChatState(), // AllegianceRoom stays 0
sendChannel: (id, text) => legacySent.Add((id, text)),
sendTurbine: (_, _, _, _, text, _) => turbineSent.Add(text));
router.Activate();
router.Publish(new SendChatCmd(ChatChannelKind.Allegiance, null, "guild hi"));
Assert.Empty(legacySent);
Assert.Empty(turbineSent);
Assert.Equal(
"Turbine chat is not available.",
communication.Chat.Snapshot()[0].Text);
}
[Fact]
public void AllegianceBroadcast_AbVerbChannel_RoutesLegacyWithSelfEcho()
{
// /ab (retail DoAllegianceBroadcast) rides the legacy 0x0147 pipe —
// distinct from /a, which is always Turbine now.
var chat = new ChatLog();
var legacySent = new List<(uint Id, string Text)>();
var router = NewRouter(
chat: chat,
sendChannel: (id, text) => legacySent.Add((id, text)));
router.Activate();
router.Publish(new SendChatCmd(
ChatChannelKind.AllegianceBroadcast, null, "to the whole allegiance"));
Assert.Equal([(0x02000000u, "to the whole allegiance")], legacySent);
Assert.Collection(
chat.Snapshot(),
entry =>
{
Assert.Equal(ChatKind.Channel, entry.Kind);
Assert.Equal("Allegiance", entry.ChannelName);
});
}
// ── Campaign CH slice CH3: per-channel self-echo matrix ──
[Theory]
[InlineData(ChatChannelKind.Fellowship, 0x00000800u)]
[InlineData(ChatChannelKind.Vassals, 0x00001000u)]
[InlineData(ChatChannelKind.Patron, 0x00002000u)]
[InlineData(ChatChannelKind.Monarch, 0x00004000u)]
[InlineData(ChatChannelKind.CoVassals, 0x01000000u)]
public void ServerEchoingLegacyChannels_SkipLocalOptimisticEcho(
ChatChannelKind kind,
uint expectedId)
{
var chat = new ChatLog();
var legacySent = new List<(uint Id, string Text)>();
var router = NewRouter(
chat: chat,
sendChannel: (id, text) => legacySent.Add((id, text)));
router.Activate();
router.Publish(new SendChatCmd(kind, null, "hi"));
Assert.Equal([(expectedId, "hi")], legacySent);
Assert.Equal(0, chat.Count); // no local echo — the server's own
// "" -sender resend is the only echo.
}
[Fact]
public void AllegianceBroadcast_KeepsLocalOptimisticEcho()
{
var chat = new ChatLog();
var router = NewRouter(
chat: chat,
sendChannel: (_, _) => { });
router.Activate();
router.Publish(new SendChatCmd(
ChatChannelKind.AllegianceBroadcast, null, "hi"));
Assert.Equal(1, chat.Count); // real-name broadcast includes the
// sender — no separate "" echo, so the
// client keeps its own.
}
[Fact] [Fact]
public void ActivateAfterDispose_IsRejected() public void ActivateAfterDispose_IsRejected()
{ {
@ -226,17 +370,17 @@ public sealed class LiveSessionCommandRouterTests
public void RuntimeStateCommandsUseActiveGenerationRouteAndBecomeInert() public void RuntimeStateCommandsUseActiveGenerationRouteAndBecomeInert()
{ {
var shortcuts = new List<ShortcutEntry>(); var shortcuts = new List<ShortcutEntry>();
var options = new List<uint>(); var options = new List<(uint OptionId, bool Value)>();
LiveSessionCommandRouter router = NewRouter( LiveSessionCommandRouter router = NewRouter(
addShortcut: shortcuts.Add, addShortcut: shortcuts.Add,
setCharacterOptions: options.Add); sendSingleCharacterOption: (id, value) => options.Add((id, value)));
router.Publish(new AddShortcutRuntimeCmd( router.Publish(new AddShortcutRuntimeCmd(
new ShortcutEntry(1, 0x80000001u, 0u))); new ShortcutEntry(1, 0x80000001u, 0u)));
router.Activate(); router.Activate();
router.Publish(new AddShortcutRuntimeCmd( router.Publish(new AddShortcutRuntimeCmd(
new ShortcutEntry(2, 0x80000002u, 0u))); new ShortcutEntry(2, 0x80000002u, 0u)));
router.Publish(new SetCharacterOptionsRuntimeCmd(0x50C4A54Au)); router.Publish(new SetSingleCharacterOptionRuntimeCmd(0x26u, true));
router.Dispose(); router.Dispose();
router.Publish(new AddShortcutRuntimeCmd( router.Publish(new AddShortcutRuntimeCmd(
new ShortcutEntry(3, 0x80000003u, 0u))); new ShortcutEntry(3, 0x80000003u, 0u)));
@ -248,7 +392,7 @@ public sealed class LiveSessionCommandRouterTests
Assert.Equal(2, entry.Index); Assert.Equal(2, entry.Index);
Assert.Equal(0x80000002u, entry.ObjectId); Assert.Equal(0x80000002u, entry.ObjectId);
}); });
Assert.Equal([0x50C4A54Au], options); Assert.Equal([(0x26u, true)], options);
} }
[Fact] [Fact]
@ -308,9 +452,11 @@ public sealed class LiveSessionCommandRouterTests
Action<uint, string>? sendChannel = null, Action<uint, string>? sendChannel = null,
Action<uint, uint, uint, uint, string, uint>? sendTurbine = null, Action<uint, uint, uint, uint, string, uint>? sendTurbine = null,
Action<ShortcutEntry>? addShortcut = null, Action<ShortcutEntry>? addShortcut = null,
Action<uint>? setCharacterOptions = null,
Action<string>? log = null, Action<string>? log = null,
ClientCommandController.Bindings? clientBindings = null) => new( ClientCommandController.Bindings? clientBindings = null,
RuntimeCommunicationState? communication = null,
RuntimeCharacterState? characterState = null,
Action<uint, bool>? sendSingleCharacterOption = null) => new(
new LiveSessionCommandBindings( new LiveSessionCommandBindings(
clientBindings ?? NewClientBindings(), clientBindings ?? NewClientBindings(),
chat ?? new ChatLog(), chat ?? new ChatLog(),
@ -332,7 +478,6 @@ public sealed class LiveSessionCommandRouterTests
RaiseVital: (_, _) => { }, RaiseVital: (_, _) => { },
RaiseSkill: (_, _) => { }, RaiseSkill: (_, _) => { },
TrainSkill: (_, _) => { }, TrainSkill: (_, _) => { },
SetCharacterOptions: setCharacterOptions ?? (_ => { }),
AddFriend: _ => { }, AddFriend: _ => { },
RemoveFriend: _ => { }, RemoveFriend: _ => { },
ClearFriends: () => { }, ClearFriends: () => { },
@ -340,6 +485,9 @@ public sealed class LiveSessionCommandRouterTests
ModifyCharacterSquelch: (_, _, _, _) => { }, ModifyCharacterSquelch: (_, _, _, _) => { },
ModifyAccountSquelch: (_, _) => { }, ModifyAccountSquelch: (_, _) => { },
ModifyGlobalSquelch: (_, _) => { }, ModifyGlobalSquelch: (_, _) => { },
Communication: communication ?? new RuntimeCommunicationState(),
CharacterState: characterState ?? new RuntimeCharacterState(),
SendSingleCharacterOption: sendSingleCharacterOption ?? ((_, _) => { }),
Log: log)); Log: log));
[MethodImpl(MethodImplOptions.NoInlining)] [MethodImpl(MethodImplOptions.NoInlining)]

View file

@ -426,9 +426,10 @@ public sealed class CurrentGameRuntimeAdapterTests
RuntimeAdvancementKind.Skill, RuntimeAdvancementKind.Skill,
StatId: 6u, StatId: 6u,
Cost: 500u)); Cost: 500u));
RuntimeCommandResult options = commands.Character.SetOptions1( RuntimeCommandResult options = commands.Character.SetSingleOption(
generation, generation,
0x50C4A54Au); 0x26u,
true);
RuntimeCommandResult friend = commands.Social.Execute( RuntimeCommandResult friend = commands.Social.Execute(
generation, generation,
new RuntimeFriendCommand( new RuntimeFriendCommand(
@ -471,7 +472,7 @@ public sealed class CurrentGameRuntimeAdapterTests
static command => command is RaiseSkillRuntimeCmd); static command => command is RaiseSkillRuntimeCmd);
Assert.Contains( Assert.Contains(
harness.Commands.Published, harness.Commands.Published,
static command => command is SetCharacterOptionsRuntimeCmd); static command => command is SetSingleCharacterOptionRuntimeCmd);
Assert.Contains( Assert.Contains(
harness.Commands.Published, harness.Commands.Published,
static command => command is AddFriendRuntimeCmd); static command => command is AddFriendRuntimeCmd);
@ -514,9 +515,10 @@ public sealed class CurrentGameRuntimeAdapterTests
Assert.Equal(10u, storedDesired); Assert.Equal(10u, storedDesired);
int published = harness.Commands.Published.Count; int published = harness.Commands.Published.Count;
RuntimeCommandResult stale = commands.Character.SetOptions1( RuntimeCommandResult stale = commands.Character.SetSingleOption(
new RuntimeGenerationToken(generation.Value - 1), new RuntimeGenerationToken(generation.Value - 1),
0u); 0x26u,
false);
Assert.Equal(RuntimeCommandStatus.StaleGeneration, stale.Status); Assert.Equal(RuntimeCommandStatus.StaleGeneration, stale.Status);
Assert.Equal(published, harness.Commands.Published.Count); Assert.Equal(published, harness.Commands.Published.Count);
} }

View file

@ -1,6 +1,8 @@
using AcDream.App.Diagnostics; using AcDream.App.Diagnostics;
using AcDream.App.Rendering; using AcDream.App.Rendering;
using AcDream.App.Settings; using AcDream.App.Settings;
using AcDream.Core.Net.Messages;
using AcDream.UI.Abstractions;
using AcDream.UI.Abstractions.Input; using AcDream.UI.Abstractions.Input;
using AcDream.UI.Abstractions.Panels.Settings; using AcDream.UI.Abstractions.Panels.Settings;
using AcDream.UI.Abstractions.Settings; using AcDream.UI.Abstractions.Settings;
@ -195,6 +197,7 @@ public sealed class RuntimeSettingsControllerTests
displayTarget, displayTarget,
qualityTarget, qualityTarget,
uiTarget, uiTarget,
NullCommandBus.Instance,
static _ => { }); static _ => { });
var quality = new QualitySettings(6, 17, 4, 12, true, 7); var quality = new QualitySettings(6, 17, 4, 12, true, 7);
@ -231,6 +234,7 @@ public sealed class RuntimeSettingsControllerTests
new InspectingDisplayWindowTarget(static _ => { }), new InspectingDisplayWindowTarget(static _ => { }),
new FailingQualityApplicationTarget(events, failureIndex), new FailingQualityApplicationTarget(events, failureIndex),
new RecordingUiLockTarget(events), new RecordingUiLockTarget(events),
NullCommandBus.Instance,
static _ => { }); static _ => { });
Assert.Throws<InvalidOperationException>(() => Assert.Throws<InvalidOperationException>(() =>
@ -291,6 +295,143 @@ public sealed class RuntimeSettingsControllerTests
Assert.Equal(resolved, controller.ResolvedQuality); Assert.Equal(resolved, controller.ResolvedQuality);
} }
[Fact]
public void SaveChatPublishesSetSingleCharacterOptionOnlyForChangedBits()
{
// CH3 (2026-08-09): SaveChat must publish SetSingleCharacterOption
// (0x0005) for exactly the Hear*Chat bits that actually changed —
// touching one checkbox must not resend the other four.
var storage = new FakeStorage();
var controller = new RuntimeSettingsController(
storage,
static preset => QualitySettings.From(preset),
static _ => { });
var targets = new FakeRuntimeTargets([]);
controller.BindRuntimeTargets(targets);
using InputDispatcher dispatcher = CreateDispatcher();
SettingsVM viewModel = controller.CreateViewModel(
new KeyBindings(),
dispatcher,
static _ => { });
// ChatSettings.Default starts every Hear*Chat bit true — flip one
// off first so the second edit below can flip it back on.
Assert.True(viewModel.ChatDraft.HearRoleplayChat);
viewModel.SetChat(viewModel.ChatDraft with { HearRoleplayChat = false });
viewModel.Save();
Assert.Equal(
[((uint)CharacterOptionId.ListenToRoleplayChat, false)],
targets.SingleOptionCalls);
targets.SingleOptionCalls.Clear();
viewModel.SetChat(viewModel.ChatDraft with
{
HearRoleplayChat = true,
HearSocietyChat = false,
});
viewModel.Save();
Assert.Equal(
[
((uint)CharacterOptionId.ListenToRoleplayChat, true),
((uint)CharacterOptionId.ListenToSocietyChat, false),
],
targets.SingleOptionCalls);
}
[Fact]
public void SaveChatWithNoHearOptionChangePublishesNothing()
{
var controller = CreateController();
var targets = new FakeRuntimeTargets([]);
controller.BindRuntimeTargets(targets);
using InputDispatcher dispatcher = CreateDispatcher();
SettingsVM viewModel = controller.CreateViewModel(
new KeyBindings(),
dispatcher,
static _ => { });
// Touch a Chat field that is NOT a Hear*Chat membership bit.
viewModel.SetChat(viewModel.ChatDraft with { ShowTimestamps = false });
viewModel.Save();
Assert.Empty(targets.SingleOptionCalls);
}
[Fact]
public void SyncChatFromServerOptionsReseedsPersistedAndDraft()
{
// Research doc §5.2: ACE's CharacterOptions2.Default omits
// HearRoleplayChat/HearSocietyChat even though acdream's local
// ChatSettings.Default claims both are on.
var storage = new FakeStorage();
var controller = new RuntimeSettingsController(
storage,
static preset => QualitySettings.From(preset),
static _ => { });
using InputDispatcher dispatcher = CreateDispatcher();
SettingsVM viewModel = controller.CreateViewModel(
new KeyBindings(),
dispatcher,
static _ => { });
Assert.True(controller.Chat.HearRoleplayChat);
Assert.True(viewModel.ChatDraft.HearRoleplayChat);
controller.SyncChatFromServerOptions(0x00948700u); // ACE's real default
Assert.True(controller.Chat.HearGeneralChat);
Assert.True(controller.Chat.HearTradeChat);
Assert.True(controller.Chat.HearLFGChat);
Assert.False(controller.Chat.HearRoleplayChat);
Assert.False(controller.Chat.HearSocietyChat);
Assert.False(viewModel.ChatDraft.HearRoleplayChat);
Assert.False(viewModel.ChatDraft.HearSocietyChat);
Assert.Same(controller.Chat, storage.ChatValue);
}
[Fact]
public void SyncChatFromServerOptionsPreservesUnsavedUnrelatedDraftEdits()
{
var controller = CreateController();
using InputDispatcher dispatcher = CreateDispatcher();
SettingsVM viewModel = controller.CreateViewModel(
new KeyBindings(),
dispatcher,
static _ => { });
viewModel.SetChat(viewModel.ChatDraft with { FontSize = 18f });
controller.SyncChatFromServerOptions(0x00948700u);
Assert.Equal(18f, viewModel.ChatDraft.FontSize);
Assert.False(viewModel.ChatDraft.HearRoleplayChat);
}
[Fact]
public void SyncChatFromServerOptionsIsANoOpWhenUnchanged()
{
var storage = new FakeStorage();
var controller = new RuntimeSettingsController(
storage,
static preset => QualitySettings.From(preset),
static _ => { });
storage.ClearEvents();
// ChatSettings.Default already has all five Hear*Chat bits on —
// options2 with only those five bits set (any other bits are
// irrelevant, the sync only masks these) reproduces exactly that,
// so the sync must be a true no-op.
const uint allFiveHearBitsOn =
(uint)PlayerDescriptionParser.CharacterOptions2.HearGeneralChat
| (uint)PlayerDescriptionParser.CharacterOptions2.HearTradeChat
| (uint)PlayerDescriptionParser.CharacterOptions2.HearLFGChat
| (uint)PlayerDescriptionParser.CharacterOptions2.HearRoleplayChat
| (uint)PlayerDescriptionParser.CharacterOptions2.HearSocietyChat;
controller.SyncChatFromServerOptions(allFiveHearBitsOn);
Assert.Equal(0, storage.ChatSaves);
}
[Fact] [Fact]
public void DraftPreviewAndExternalCommandsShareCanonicalState() public void DraftPreviewAndExternalCommandsShareCanonicalState()
{ {
@ -910,6 +1051,14 @@ public sealed class RuntimeSettingsControllerTests
throw new InvalidOperationException("UI-lock target failed"); throw new InvalidOperationException("UI-lock target failed");
} }
} }
public List<(uint OptionId, bool Value)> SingleOptionCalls { get; } = [];
public void SetSingleCharacterOption(uint optionId, bool value)
{
SingleOptionCalls.Add((optionId, value));
events.Add($"target-single-option:0x{optionId:X}:{value}");
}
} }
private sealed class InspectingDisplayWindowTarget( private sealed class InspectingDisplayWindowTarget(
@ -1048,6 +1197,8 @@ public sealed class RuntimeSettingsControllerTests
public int GameplaySaves { get; private set; } public int GameplaySaves { get; private set; }
public int ChatSaves { get; private set; }
public string? LastLoadedCharacter { get; private set; } public string? LastLoadedCharacter { get; private set; }
public bool ThrowOnDisplaySave { get; init; } public bool ThrowOnDisplaySave { get; init; }
@ -1128,6 +1279,7 @@ public sealed class RuntimeSettingsControllerTests
public void SaveChat(ChatSettings chat) public void SaveChat(ChatSettings chat)
{ {
ChatSaves++;
_events.Add("save-chat"); _events.Add("save-chat");
if (ThrowOnChatSave) if (ThrowOnChatSave)
throw new IOException("chat persistence failed"); throw new IOException("chat persistence failed");

View file

@ -102,29 +102,32 @@ public sealed class SocialActionsTests
} }
[Fact] [Fact]
public void BuildSetCharacterOptions_HasBitmap() public void BuildSetSingleCharacterOption_HasOptionIdThenValue()
{ {
byte[] body = SocialActions.BuildSetCharacterOptions(seq: 1, optionsBitmap: 0xDEADBEEFu); // ACE GameActionSetSingleCharacterOption.cs:11-12 and holtburger
Assert.Equal(0xDEADBEEFu, // SetSingleCharacterOptionActionData::pack: u32 option, u32 value.
byte[] body = SocialActions.BuildSetSingleCharacterOption(
seq: 1, optionId: 0x26u, value: true);
Assert.Equal(20, body.Length);
Assert.Equal(SocialActions.GameActionEnvelope,
BinaryPrimitives.ReadUInt32LittleEndian(body));
Assert.Equal(1u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(4)));
Assert.Equal(SocialActions.SetSingleCharacterOptionOpcode,
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)));
Assert.Equal(0x26u,
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12))); BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12)));
Assert.Equal(1u,
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(16)));
} }
[Fact] [Fact]
public void BuildAddChannel_ContainsName() public void BuildSetSingleCharacterOption_FalseValueEncodesZero()
{ {
byte[] body = SocialActions.BuildAddChannel(seq: 1, channelName: "Trade"); byte[] body = SocialActions.BuildSetSingleCharacterOption(
Assert.Equal(SocialActions.AddChannelOpcode, seq: 2, optionId: 0x23u, value: false);
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)));
ushort len = BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(12));
Assert.Equal(5, len);
Assert.Equal("Trade", Encoding.ASCII.GetString(body.AsSpan(14, 5)));
}
[Fact] Assert.Equal(0u,
public void BuildRemoveChannel_HasOpcode0x0146() BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(16)));
{
byte[] body = SocialActions.BuildRemoveChannel(seq: 1, channelName: "General");
Assert.Equal(SocialActions.RemoveChannelOpcode,
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)));
} }
} }

View file

@ -1,3 +1,5 @@
using AcDream.Core.Items;
using AcDream.Core.Properties;
using AcDream.Core.Spells; using AcDream.Core.Spells;
using AcDream.Core.Player; using AcDream.Core.Player;
using AcDream.Runtime.Gameplay; using AcDream.Runtime.Gameplay;
@ -6,6 +8,42 @@ namespace AcDream.Runtime.Tests.Gameplay;
public sealed class RuntimeCharacterStateTests public sealed class RuntimeCharacterStateTests
{ {
// ── Campaign CH slice CH3 (2026-08-09): IsOlthoiPlayer ──
[Fact]
public void IsOlthoiPlayer_FalseByDefault_NoHeritageParsedYet()
{
using var state = new RuntimeCharacterState();
Assert.False(state.IsOlthoiPlayer);
}
[Theory]
[InlineData(12)] // HeritageGroup.Olthoi
[InlineData(13)] // HeritageGroup.OlthoiAcid
public void IsOlthoiPlayer_TrueForOlthoiHeritageGroups(int heritageGroup)
{
using var state = new RuntimeCharacterState();
var properties = new PropertyBundle();
properties.Ints[(uint)PropertyInt.HeritageGroup] = heritageGroup;
state.LocalPlayer.OnProperties(properties);
Assert.True(state.IsOlthoiPlayer);
}
[Fact]
public void IsOlthoiPlayer_FalseForNonOlthoiHeritage()
{
using var state = new RuntimeCharacterState();
var properties = new PropertyBundle();
properties.Ints[(uint)PropertyInt.HeritageGroup] = 1; // Aluvian
state.LocalPlayer.OnProperties(properties);
Assert.False(state.IsOlthoiPlayer);
}
[Fact] [Fact]
public void OwnsOneCoupledSpellbookAndLocalPlayerGraph() public void OwnsOneCoupledSpellbookAndLocalPlayerGraph()
{ {

View file

@ -0,0 +1,185 @@
using AcDream.Core.Chat;
using AcDream.Core.Net.Messages;
using AcDream.Runtime.Gameplay;
namespace AcDream.Runtime.Tests.Gameplay;
/// <summary>
/// Campaign CH slice CH3 (2026-08-09): pins the retail
/// <c>SendTurbineChat @0x0057db10</c> local pre-send gate — the fix for
/// "side channels don't work" (research doc §5.2/§6.2).
/// </summary>
public sealed class TurbineChatMembershipGateTests
{
[Fact]
public void TurbineDisabled_ReturnsUnavailable_EvenWithRoomIdAndOptionOn()
{
var turbine = new TurbineChatState(); // never received SetTurbineChatChannels
var options = new RuntimeCharacterOptionsState();
TurbineChatGateResult result = TurbineChatMembershipGate.Evaluate(
ChatChannelKindLite.General, turbine, options, isOlthoiPlayer: false);
Assert.Equal(TurbineChatGateStatus.Unavailable, result.Status);
Assert.Equal(0u, result.RoomId);
}
[Fact]
public void RoomIdZero_ReturnsUnavailable_NoAllegiance()
{
TurbineChatState turbine = ReceivedRooms(allegianceRoom: 0u);
var options = new RuntimeCharacterOptionsState();
TurbineChatGateResult result = TurbineChatMembershipGate.Evaluate(
ChatChannelKindLite.Allegiance, turbine, options, isOlthoiPlayer: false);
Assert.Equal(TurbineChatGateStatus.Unavailable, result.Status);
}
[Theory]
[InlineData(ChatChannelKindLite.General)]
[InlineData(ChatChannelKindLite.Trade)]
[InlineData(ChatChannelKindLite.Lfg)]
[InlineData(ChatChannelKindLite.Roleplay)]
[InlineData(ChatChannelKindLite.Society)]
public void HearOptionOff_ReturnsNotListening_RoomIdAndTypeStillReported(
ChatChannelKindLite kind)
{
TurbineChatState turbine = ReceivedRooms();
var options = new RuntimeCharacterOptionsState();
options.Replace(options.Options1, 0u); // every Hear*Chat bit off
TurbineChatGateResult result = TurbineChatMembershipGate.Evaluate(
kind, turbine, options, isOlthoiPlayer: false);
Assert.Equal(TurbineChatGateStatus.NotListening, result.Status);
Assert.NotEqual(0u, result.RoomId);
Assert.NotEqual(string.Empty, result.DisplayName);
}
[Fact]
public void AllegianceHearOptionOff_ReturnsNotListening()
{
TurbineChatState turbine = ReceivedRooms();
var options = new RuntimeCharacterOptionsState();
options.Replace(0u, options.Options2); // HearAllegianceChat bit off
TurbineChatGateResult result = TurbineChatMembershipGate.Evaluate(
ChatChannelKindLite.Allegiance, turbine, options, isOlthoiPlayer: false);
Assert.Equal(TurbineChatGateStatus.NotListening, result.Status);
}
[Fact]
public void OlthoiRoom_GatesOnHeritageNotAnOption()
{
TurbineChatState turbine = ReceivedRooms();
var options = new RuntimeCharacterOptionsState(); // no Hear-Olthoi bit exists
TurbineChatGateResult notOlthoi = TurbineChatMembershipGate.Evaluate(
ChatChannelKindLite.Olthoi, turbine, options, isOlthoiPlayer: false);
TurbineChatGateResult olthoi = TurbineChatMembershipGate.Evaluate(
ChatChannelKindLite.Olthoi, turbine, options, isOlthoiPlayer: true);
Assert.Equal(TurbineChatGateStatus.NotListening, notOlthoi.Status);
Assert.Equal(TurbineChatGateStatus.Allowed, olthoi.Status);
}
[Theory]
[InlineData(ChatChannelKindLite.Allegiance, 0x10u, (uint)TurbineChat.ChatType.Allegiance, "Allegiance")]
[InlineData(ChatChannelKindLite.General, 0x11u, (uint)TurbineChat.ChatType.General, "General")]
[InlineData(ChatChannelKindLite.Trade, 0x12u, (uint)TurbineChat.ChatType.Trade, "Trade")]
[InlineData(ChatChannelKindLite.Lfg, 0x13u, (uint)TurbineChat.ChatType.Lfg, "LFG")]
[InlineData(ChatChannelKindLite.Society, 0x16u, (uint)TurbineChat.ChatType.Society, "Society")]
[InlineData(ChatChannelKindLite.Olthoi, 0x17u, (uint)TurbineChat.ChatType.Olthoi, "Olthoi")]
public void AllowedResultCarriesRoomIdChatTypeAndDisplayName(
ChatChannelKindLite kind,
uint expectedRoom,
uint expectedChatType,
string expectedName)
{
TurbineChatState turbine = ReceivedRooms(
allegianceRoom: 0x10u,
generalRoom: 0x11u,
tradeRoom: 0x12u,
lfgRoom: 0x13u,
roleplayRoom: 0x14u,
olthoiRoom: 0x17u,
societyRoom: 0x16u);
var options = new RuntimeCharacterOptionsState();
options.Replace(
options.Options1,
options.Options2
| (uint)PlayerDescriptionParser.CharacterOptions2.HearSocietyChat);
TurbineChatGateResult result = TurbineChatMembershipGate.Evaluate(
kind, turbine, options, isOlthoiPlayer: true);
Assert.Equal(TurbineChatGateStatus.Allowed, result.Status);
Assert.Equal(expectedRoom, result.RoomId);
Assert.Equal(expectedChatType, result.ChatType);
Assert.Equal(expectedName, result.DisplayName);
}
[Fact]
public void FreshDefaultOptions_MatchAceMembership_RoleplayAndSocietyAreOff()
{
// The CH3 headline finding (research doc §5.2): ACE's own
// CharacterOptions2.Default (0x00948700, byte-identical to
// RuntimeCharacterOptionsState.DefaultOptions2) OMITS
// HearRoleplayChat/HearSocietyChat — General/Trade/LFG/Allegiance
// are on by default, Roleplay/Society are NOT, and a fresh
// acdream character must reproduce that exact split.
TurbineChatState turbine = ReceivedRooms();
var options = new RuntimeCharacterOptionsState(); // untouched defaults
Assert.Equal(
TurbineChatGateStatus.Allowed,
TurbineChatMembershipGate.Evaluate(
ChatChannelKindLite.Allegiance, turbine, options, false).Status);
Assert.Equal(
TurbineChatGateStatus.Allowed,
TurbineChatMembershipGate.Evaluate(
ChatChannelKindLite.General, turbine, options, false).Status);
Assert.Equal(
TurbineChatGateStatus.Allowed,
TurbineChatMembershipGate.Evaluate(
ChatChannelKindLite.Trade, turbine, options, false).Status);
Assert.Equal(
TurbineChatGateStatus.Allowed,
TurbineChatMembershipGate.Evaluate(
ChatChannelKindLite.Lfg, turbine, options, false).Status);
Assert.Equal(
TurbineChatGateStatus.NotListening,
TurbineChatMembershipGate.Evaluate(
ChatChannelKindLite.Roleplay, turbine, options, false).Status);
Assert.Equal(
TurbineChatGateStatus.NotListening,
TurbineChatMembershipGate.Evaluate(
ChatChannelKindLite.Society, turbine, options, false).Status);
}
private static TurbineChatState ReceivedRooms(
uint allegianceRoom = 0x10u,
uint generalRoom = 0x11u,
uint tradeRoom = 0x12u,
uint lfgRoom = 0x13u,
uint roleplayRoom = 0x14u,
uint olthoiRoom = 0x15u,
uint societyRoom = 0x16u)
{
var state = new TurbineChatState();
state.OnChannelsReceived(
allegianceRoom,
generalRoom,
tradeRoom,
lfgRoom,
roleplayRoom,
olthoiRoom,
societyRoom,
societyCelestialHandRoom: 0u,
societyEldrytchWebRoom: 0u,
societyRadiantBloodRoom: 0u);
return state;
}
}

View file

@ -116,6 +116,17 @@ public sealed class DirectGameRuntimeCommandAdapterTests
new RuntimeChatCommand( new RuntimeChatCommand(
RuntimeChatChannel.General, RuntimeChatChannel.General,
"global")), "global")),
// CH3 (2026-08-09): Roleplay's room id is populated (0x14u
// above) but RuntimeCharacterOptionsState's fresh default omits
// HearRoleplayChat (matches ACE's own CharacterOptions2.Default)
// — the membership gate must refuse LOCALLY (via AddText, "You
// are not listening to the Roleplay channel!") rather than
// silently sending or silently dropping.
adapter.Chat.Execute(
runtime.Generation,
new RuntimeChatCommand(
RuntimeChatChannel.Roleplay,
"should be refused")),
adapter.InventoryState.AddShortcut( adapter.InventoryState.AddShortcut(
runtime.Generation, runtime.Generation,
new RuntimeShortcutCommand(0, 0x70000001u, 0u)), new RuntimeShortcutCommand(0, 0x70000001u, 0u)),
@ -167,9 +178,10 @@ public sealed class DirectGameRuntimeCommandAdapterTests
RuntimeAdvancementKind.TrainSkill, RuntimeAdvancementKind.TrainSkill,
StatId: 4u, StatId: 4u,
Cost: 1u)), Cost: 1u)),
adapter.Character.SetOptions1( adapter.Character.SetSingleOption(
runtime.Generation, runtime.Generation,
options: 0x1234u), optionId: 0x26u,
value: true),
adapter.Social.Execute( adapter.Social.Execute(
runtime.Generation, runtime.Generation,
new RuntimeFriendCommand( new RuntimeFriendCommand(
@ -236,6 +248,11 @@ public sealed class DirectGameRuntimeCommandAdapterTests
staleMovement.Status); staleMovement.Status);
Assert.False(runtime.MovementOwner.HasCommandInput); Assert.False(runtime.MovementOwner.HasCommandInput);
Assert.True(gameActions.Count >= 20); Assert.True(gameActions.Count >= 20);
Assert.Contains(
runtime.CommunicationOwner.Chat.Snapshot(),
entry => entry.Text.Contains(
"not listening to the Roleplay channel",
StringComparison.OrdinalIgnoreCase));
Assert.Contains( Assert.Contains(
trace.Entries, trace.Entries,
entry => entry.Kind == RuntimeTraceKind.Command entry => entry.Kind == RuntimeTraceKind.Command

View file

@ -154,6 +154,90 @@ public sealed class LiveSessionEventRouterTests
router.Dispose(); router.Dispose();
} }
// ── Campaign CH slice CH3: TurbineChat ack HResult surfacing ──
[Fact]
public void TurbineChat_ResponseWithNonZeroHResult_SurfacesAsSystemMessage()
{
using var session = NewSession();
var chat = new ChatLog();
var router = NewRouter(session, new Counters(), chat: chat);
EventDelegate<Action<AcDream.Core.Net.Messages.TurbineChat.Parsed>>(
session, nameof(session.TurbineChatReceived))(
new AcDream.Core.Net.Messages.TurbineChat.Parsed(
AcDream.Core.Net.Messages.TurbineChat.BlobType.ResponseBinary,
AcDream.Core.Net.Messages.TurbineChat.DispatchType.Unknown,
0u, 0u, 0u, 0u, 0u,
new AcDream.Core.Net.Messages.TurbineChat.Payload.Response(
ContextId: 5u,
ResponseId: 2u,
MethodId: 2u,
HResult: -1)));
Assert.Equal(1, chat.Count);
Assert.Contains(
"rejected",
chat.Snapshot()[0].Text,
StringComparison.OrdinalIgnoreCase);
router.Dispose();
}
[Fact]
public void TurbineChat_ResponseWithZeroHResult_StaysSilent()
{
// Retail's ack is silent on ordinary success — only a genuine
// server-side rejection should surface.
using var session = NewSession();
var chat = new ChatLog();
var router = NewRouter(session, new Counters(), chat: chat);
EventDelegate<Action<AcDream.Core.Net.Messages.TurbineChat.Parsed>>(
session, nameof(session.TurbineChatReceived))(
new AcDream.Core.Net.Messages.TurbineChat.Parsed(
AcDream.Core.Net.Messages.TurbineChat.BlobType.ResponseBinary,
AcDream.Core.Net.Messages.TurbineChat.DispatchType.Unknown,
0u, 0u, 0u, 0u, 0u,
new AcDream.Core.Net.Messages.TurbineChat.Payload.Response(
ContextId: 5u,
ResponseId: 2u,
MethodId: 2u,
HResult: 0)));
Assert.Equal(0, chat.Count);
router.Dispose();
}
[Fact]
public void TurbineChat_EventSendToRoom_StillRoutesToChannelBroadcast()
{
using var session = NewSession();
var chat = new ChatLog();
var router = NewRouter(session, new Counters(), chat: chat);
EventDelegate<Action<AcDream.Core.Net.Messages.TurbineChat.Parsed>>(
session, nameof(session.TurbineChatReceived))(
new AcDream.Core.Net.Messages.TurbineChat.Parsed(
AcDream.Core.Net.Messages.TurbineChat.BlobType.EventBinary,
AcDream.Core.Net.Messages.TurbineChat.DispatchType.SendToRoomByName,
0u, 0u, 0u, 0u, 0u,
new AcDream.Core.Net.Messages.TurbineChat.Payload.EventSendToRoom(
RoomId: 2u,
SenderName: "Someone",
Message: "hi",
ExtraDataSize: 0x0Cu,
SenderId: 0x50000001u,
HResult: 0,
ChatType: 2u)));
Assert.Equal(1, chat.Count);
Assert.Equal("hi", chat.Snapshot()[0].Text);
router.Dispose();
}
[Fact] [Fact]
public void NestedRouters_DisposeOlderFirstLeavesOnlyNewerRouter() public void NestedRouters_DisposeOlderFirstLeavesOnlyNewerRouter()
{ {

View file

@ -60,7 +60,11 @@ public sealed class ChannelResolverTests
// and holtburger-core/src/client/commands.rs:50-62. Six legacy channels // and holtburger-core/src/client/commands.rs:50-62. Six legacy channels
// map to known ChatChannel ids; the rest fall through to TurbineChat. // map to known ChatChannel ids; the rest fall through to TurbineChat.
Assert.Equal((0x00000800u, "Fellowship"), AsTuple(ChannelResolver.Resolve(ChatChannelKind.Fellowship))); Assert.Equal((0x00000800u, "Fellowship"), AsTuple(ChannelResolver.Resolve(ChatChannelKind.Fellowship)));
Assert.Equal((0x02000000u, "Allegiance"), AsTuple(ChannelResolver.Resolve(ChatChannelKind.Allegiance))); // CH3 (2026-08-09): 0x02000000 moved from ChatChannelKind.Allegiance
// (now ALWAYS Turbine — retail's @a binds unconditionally to
// DoTurbineChat_Allegiance) to the dedicated AllegianceBroadcast
// kind (retail's @ab verb).
Assert.Equal((0x02000000u, "Allegiance"), AsTuple(ChannelResolver.Resolve(ChatChannelKind.AllegianceBroadcast)));
Assert.Equal((0x00001000u, "Vassals"), AsTuple(ChannelResolver.Resolve(ChatChannelKind.Vassals))); Assert.Equal((0x00001000u, "Vassals"), AsTuple(ChannelResolver.Resolve(ChatChannelKind.Vassals)));
Assert.Equal((0x00002000u, "Patron"), AsTuple(ChannelResolver.Resolve(ChatChannelKind.Patron))); Assert.Equal((0x00002000u, "Patron"), AsTuple(ChannelResolver.Resolve(ChatChannelKind.Patron)));
Assert.Equal((0x00004000u, "Monarch"), AsTuple(ChannelResolver.Resolve(ChatChannelKind.Monarch))); Assert.Equal((0x00004000u, "Monarch"), AsTuple(ChannelResolver.Resolve(ChatChannelKind.Monarch)));
@ -72,10 +76,13 @@ public sealed class ChannelResolverTests
{ {
// Say + Tell are handled separately by the SendChatCmd dispatcher. // Say + Tell are handled separately by the SendChatCmd dispatcher.
// General/Trade/etc. require a TurbineChat channel id we don't yet wire. // General/Trade/etc. require a TurbineChat channel id we don't yet wire.
// Allegiance (CH3, 2026-08-09) is ALWAYS Turbine now — the legacy
// resolver no longer has an entry for it.
Assert.Null(ChannelResolver.Resolve(ChatChannelKind.Say)); Assert.Null(ChannelResolver.Resolve(ChatChannelKind.Say));
Assert.Null(ChannelResolver.Resolve(ChatChannelKind.Tell)); Assert.Null(ChannelResolver.Resolve(ChatChannelKind.Tell));
Assert.Null(ChannelResolver.Resolve(ChatChannelKind.General)); Assert.Null(ChannelResolver.Resolve(ChatChannelKind.General));
Assert.Null(ChannelResolver.Resolve(ChatChannelKind.Trade)); Assert.Null(ChannelResolver.Resolve(ChatChannelKind.Trade));
Assert.Null(ChannelResolver.Resolve(ChatChannelKind.Allegiance));
Assert.Null(ChannelResolver.Resolve(ChatChannelKind.Unknown)); Assert.Null(ChannelResolver.Resolve(ChatChannelKind.Unknown));
} }