refactor(net): own live session routing

This commit is contained in:
Erik 2026-07-21 11:17:09 +02:00
parent 707e606e35
commit 961bdd07b7
12 changed files with 1645 additions and 543 deletions

View file

@ -813,16 +813,10 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
private bool DevToolsEnabled => _options.DevTools;
private bool DumpMoveTruthEnabled => _options.DumpMoveTruth;
// Phase I.3 — real ICommandBus for live sessions. Constructed when
// the live session spins up (so SendChatCmd handlers can close over
// _liveSession + Chat). Null when offline; PanelContext then falls
// back to NullCommandBus.Instance.
private AcDream.UI.Abstractions.LiveCommandBus? _commandBus;
// Phase I.7 — bridges CombatState's typed events into ChatLog as
// retail-faithful "You hit ..." / "... evaded your attack." lines.
// Disposable; lives for the duration of the live session.
private AcDream.Core.Chat.CombatChatTranslator? _combatChatTranslator;
// Slice 3: exact session-generation owners. The command router is itself
// an ICommandBus and becomes inert before the displaced socket closes.
private AcDream.App.Net.LiveSessionEventRouter? _liveSessionEvents;
private AcDream.App.Net.LiveSessionCommandRouter? _liveSessionCommands;
// Phase G.1-G.2 world lighting/time state.
public readonly AcDream.Core.World.WorldTimeService WorldTime =
@ -2221,7 +2215,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
Vitals: new AcDream.App.UI.VitalsRuntimeBindings(_vitalsVm),
Chat: new AcDream.App.UI.ChatRuntimeBindings(
retailChatVm,
() => _commandBus ?? (AcDream.UI.Abstractions.ICommandBus)
() => _liveSessionCommands ?? (AcDream.UI.Abstractions.ICommandBus)
AcDream.UI.Abstractions.NullCommandBus.Instance),
Radar: new AcDream.App.UI.RadarRuntimeBindings(
radarSnapshotProvider.BuildSnapshot,
@ -2867,10 +2861,15 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
_liveSession = _liveSessionController.CreateAndWire(_options, WireLiveSessionEvents);
if (_liveSession is null)
{
_commandBus = null;
_combatChatTranslator?.Dispose();
_combatChatTranslator = null;
_liveSessionController = null;
try
{
DisposeLiveSessionRouting();
}
finally
{
_liveSessionController.Dispose();
_liveSessionController = null;
}
return;
}
@ -2890,12 +2889,16 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
out AcDream.Core.Net.Messages.CharacterList.Selection selection))
{
Console.WriteLine("live: no available characters on account; disconnecting");
_commandBus = null;
_combatChatTranslator?.Dispose();
_combatChatTranslator = null;
_liveSessionController.Dispose();
_liveSessionController = null;
_liveSession = null;
try
{
DisposeLiveSessionRouting();
}
finally
{
_liveSessionController.Dispose();
_liveSessionController = null;
_liveSession = null;
}
ClearInboundEntityState();
return;
}
@ -2909,6 +2912,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
Console.WriteLine($"live: entering world as 0x{chosen.Id:X8} {chosen.Name}");
Combat.Clear();
_liveSession.EnterWorld(characterIndex: selection.ActiveIndex);
_liveSessionCommands?.Activate();
_activeToonKey = chosen.Name;
_retailUiRuntime?.RestoreLayout();
@ -2926,12 +2930,16 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
catch (Exception ex)
{
Console.WriteLine($"live: session failed: {ex.Message}");
_commandBus = null;
_combatChatTranslator?.Dispose();
_combatChatTranslator = null;
_liveSessionController?.Dispose();
_liveSessionController = null;
_liveSession = null;
try
{
DisposeLiveSessionRouting();
}
finally
{
_liveSessionController?.Dispose();
_liveSessionController = null;
_liveSession = null;
}
ClearInboundEntityState();
}
}
@ -2995,445 +3003,250 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
}
/// <summary>
/// Step 2 helper: subscribes the live <paramref name="session"/> to all
/// the parsers / handlers / translators that <c>GameWindow</c> needs.
/// Called once by <see cref="LiveSessionController.CreateAndWire"/>
/// immediately after the <see cref="AcDream.Core.Net.WorldSession"/>
/// is constructed and BEFORE any network I/O.
/// Composes the exact inbound and outbound owners for one live session.
/// Registration completes before Connect; outbound commands remain inert
/// until EnterWorld succeeds.
/// </summary>
private void WireLiveSessionEvents(AcDream.Core.Net.WorldSession session)
{
_liveSession = session;
// D.5.4: wire non-lifecycle object quality/property updates. The live
// handlers below apply Create/Delete to both projections only after
// their canonical timestamp decision.
AcDream.Core.Net.ObjectTableWiring.Wire(
session, Objects, () => _playerServerGuid, LocalPlayer);
AcDream.Core.Net.CombatStateWiring.Wire(session, Combat);
_liveSession.EntitySpawned += spawn =>
_inboundEntityEvents.Run(
this,
spawn,
static (window, value) => window.OnLiveEntitySpawned(value));
_liveSession.EntityDeleted += deletion =>
_inboundEntityEvents.Run(
this,
deletion,
static (window, value) => window.OnLiveEntityDeleted(value));
_liveSession.EntityPickedUp += pickup =>
_inboundEntityEvents.Run(
this,
pickup,
static (window, value) => window.OnLiveEntityPickedUp(value));
_liveSession.MotionUpdated += motion =>
_inboundEntityEvents.Run(
this,
motion,
static (window, value) => window.OnLiveMotionUpdated(value));
_liveSession.PositionUpdated += position =>
_inboundEntityEvents.Run(
this,
position,
static (window, value) => window.OnLivePositionUpdated(value));
_liveSession.VectorUpdated += vector =>
_inboundEntityEvents.Run(
this,
vector,
static (window, value) => window.OnLiveVectorUpdated(value));
_liveSession.StateUpdated += state =>
_inboundEntityEvents.Run(
this,
state,
static (window, value) => window.OnLiveStateUpdated(value));
_liveSession.ParentUpdated += parent =>
_inboundEntityEvents.Run(
this,
parent,
static (window, value) => window.OnLiveParentUpdated(value));
_liveSession.TeleportStarted += teleport =>
_inboundEntityEvents.Run(
this,
teleport,
static (window, value) => window.OnTeleportStarted(value));
_liveSession.AppearanceUpdated += appearance =>
_inboundEntityEvents.Run(
this,
appearance,
static (window, value) => window.OnLiveAppearanceUpdated(value));
if (_liveSessionEvents is not null || _liveSessionCommands is not null)
throw new InvalidOperationException("live-session routing is already bound");
// Phase 6c — PlayScript (0xF754) arrives from the server as
// a (guid, scriptId) pair. Resolve the guid's current world
// position and feed the PhysicsScript runner; it schedules
// the script's hooks (particle spawns, sound cues, light
// toggles) at their StartTime offsets. This is the channel
// retail uses for spell casts, combat flinches, emote
// gestures, AND — per Agent #5 research — lightning
// flashes during stormy weather.
_liveSession.PlayPhysicsScriptReceived += script =>
_inboundEntityEvents.Run(
this,
script,
static (window, value) => window.OnPlayScriptReceived(value));
_liveSession.PlayPhysicsScriptTypeReceived += message =>
_inboundEntityEvents.Run(
this,
message,
static (window, value) => window._entityEffects?.HandleTyped(value));
var skillTable = _dats?.Get<DatReaderWriter.DBObjs.SkillTable>(0x0E000004u);
if (_characterSheetProvider is not null && _dats is not null)
{
_characterSheetProvider.SkillTable = skillTable;
_characterSheetProvider.ExperienceTable =
AcDream.App.UI.Layout.CharacterSheetProvider.LoadExperienceTable(
_dats,
Console.WriteLine);
}
// Phase 5d — AdminEnvirons (0xEA60): fog presets + sound
// cues. Fog types (0x00..0x06) set WeatherSystem.Override;
// sound types (0x65..0x7B) play a one-shot audio cue.
// Lightning flashes arrive as a PAIRED PlayScript (the
// visual) + AdminEnvirons ThunderXSound (the audio) — both
// are handled here and in OnPlayScriptReceived respectively.
_liveSession.EnvironChanged += OnEnvironChanged;
// Phase G.1: keep the client's day/night clock in sync with
// server time. Fires once from ConnectRequest (initial seed)
// and repeatedly on TimeSync-flagged packets.
// Phase 3a: also re-roll the active DayGroup if the Dereth-day
// index changed — retail rolls one weather preset per server
// day (r12 §11), deterministic from the day index so retail
// and acdream converge without a wire message.
_liveSession.ServerTimeUpdated += ticks =>
{
WorldTime.SyncFromServer(ticks);
RefreshSkyForCurrentDay();
};
// Phase F.1-H.1: wire every parsed GameEvent into the right
// Core state class (chat, combat, spellbook, items). After
// this one call, server-sent ChannelBroadcast / damage
// notifications / spell learns / wield events all update
// the corresponding client-side state without further glue.
// K-fix13 (2026-04-26): cache portal.dat's SkillTable so the
// skill-formula resolver can apply the AttributeFormula
// contribution. Without this, our wire-derived "skill"
// value was missing the dominant attribute-derived term
// and jumps undershot retail by ~30 % at typical
// attribute levels.
var skillTable = _dats?.Get<DatReaderWriter.DBObjs.SkillTable>(0x0E000004u);
if (_characterSheetProvider is not null && _dats is not null)
{
_characterSheetProvider.SkillTable = skillTable;
_characterSheetProvider.ExperienceTable =
AcDream.App.UI.Layout.CharacterSheetProvider.LoadExperienceTable(
_dats, Console.WriteLine);
}
AcDream.Core.Net.GameEventWiring.WireAll(
_liveSession.GameEvents, Objects, Combat, SpellBook, Chat, LocalPlayer,
TurbineChat,
resolveSkillFormulaBonus: (skillId, attrCurrents) =>
{
// ACE GetFormula (AttributeFormula.cs:55-): when
// formula.X (Attribute1Multiplier) is 0, the formula
// is "no attribute contribution" and the function
// returns 0. Otherwise:
// bonus = (attr1 * Mult1 + attr2 * Mult2) / Divisor + Additive
if (skillTable?.Skills is null) return 0u;
if (!skillTable.Skills.TryGetValue(
(DatReaderWriter.Enums.SkillId)skillId, out var skillBase))
return 0u;
var f = skillBase.Formula;
if (f.Attribute1Multiplier == 0 || f.Divisor == 0) return 0u;
attrCurrents.TryGetValue((uint)f.Attribute1, out uint a1);
attrCurrents.TryGetValue((uint)f.Attribute2, out uint a2);
long num = (long)a1 * f.Attribute1Multiplier
+ (long)a2 * f.Attribute2Multiplier;
long bonus = num / f.Divisor + f.AdditiveBonus;
return bonus < 0 ? 0u : (uint)bonus;
},
onSkillsUpdated: (runSkill, jumpSkill) =>
{
// K-fix7 (2026-04-26): cache the latest server-sent
// Run / Jump skill values so the next
// EnterPlayerModeNow can hand them to the new
// PlayerMovementController. Push immediately too,
// so a PD that arrives WHILE player mode is active
// (re-equip / log-in mid-session) updates the live
// controller. -1 from the wiring means "skill not
// present in this PD" — keep the previous cached
// value rather than overwriting with -1.
if (runSkill >= 0) _lastSeenRunSkill = runSkill;
if (jumpSkill >= 0) _lastSeenJumpSkill = jumpSkill;
if (_playerController is not null
&& _lastSeenRunSkill >= 0 && _lastSeenJumpSkill >= 0)
AcDream.App.Net.LiveSessionEventRouter? events = null;
AcDream.App.Net.LiveSessionCommandRouter? commands = null;
try
{
events = new AcDream.App.Net.LiveSessionEventRouter(
session,
CreateLiveEntitySessionSink(),
new AcDream.App.Net.LiveEnvironmentSessionSink(
OnEnvironChanged,
ticks =>
{
_playerController.SetCharacterSkills(
_lastSeenRunSkill, _lastSeenJumpSkill);
Console.WriteLine($"player: applied server skills run={_lastSeenRunSkill} jump={_lastSeenJumpSkill}");
}
},
onShortcuts: list => Shortcuts = list,
playerGuid: () => _playerServerGuid,
onUseDone: error =>
{
_externalContainers.ApplyUseDone(error);
_itemInteractionController?.CompleteUse(error);
},
itemMana: ItemMana,
onConfirmationRequest: request =>
_retailUiRuntime?.HandleConfirmationRequest(request),
onConfirmationDone: done =>
_retailUiRuntime?.HandleConfirmationDone(done),
friends: Friends,
squelch: Squelch,
onDesiredComponents: components => DesiredComponents = components,
onCharacterOptions: (options1, _) =>
_characterOptions1 =
(AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1)
options1,
clientTime: ClientTimerNow,
externalContainers: _externalContainers);
// Phase I.7: subscribe to CombatState events and emit
// retail-faithful "You hit X for Y damage" chat lines into
// the unified ChatLog. The translator owns the wording
// (templates ported from holtburger chat.rs:221-308); the
// panel renders combat entries via TextColored.
_combatChatTranslator = new AcDream.Core.Chat.CombatChatTranslator(Combat, Chat);
// Phase H.1: feed inbound HearSpeech into the chat log.
_liveSession.SpeechHeard += speech =>
Chat.OnLocalSpeech(
sender: speech.SenderName,
text: speech.Text,
senderGuid: speech.SenderGuid,
isRanged: speech.IsRanged);
// Phase I.6: feed inbound TurbineChat events into the chat log.
// The Response variant is fire-and-forget (server-side ack);
// EventSendToRoom is a real chat message broadcast to a room.
// Phase J: ACE's GameMessageSystemChat (used for the login
// banner "Welcome to Asheron's Call ... type @acehelp" and
// for SystemChat broadcasts) rides opcode 0xF7E0 ServerMessage,
// parsed in I.5 but never wired. Surface it as a System
// chat line so the welcome banner appears + future server
// pushes (announcements, command responses) show.
_liveSession.ServerMessageReceived += sm =>
Chat.OnSystemMessage(sm.Message, sm.ChatType);
// Phase I.5 + J: emotes already had ChatLog adapters; wire
// their session events here so they actually reach chat.
_liveSession.EmoteHeard += emote =>
Chat.OnEmote(emote.SenderName, emote.Text, emote.SenderGuid);
_liveSession.SoulEmoteHeard += emote =>
Chat.OnSoulEmote(emote.SenderName, emote.Text, emote.SenderGuid);
_liveSession.PlayerKilledReceived += pk =>
Chat.OnPlayerKilled(pk.DeathMessage, pk.VictimGuid, pk.KillerGuid);
_liveSession.TurbineChatReceived += parsed =>
{
if (parsed.Body is AcDream.Core.Net.Messages.TurbineChat.Payload.EventSendToRoom ev)
{
// Pass the friendly channel name out-of-band via
// ChatLog.OnChannelBroadcast's channelName param so
// ChatVM.FormatEntry can render the retail-style
// "[Trade] +Acdream says, \"hello\"" without us
// mangling the payload text.
string label = TurbineRoomDisplayName(ev.RoomId, ev.ChatType);
Chat.OnChannelBroadcast(
channelId: ev.RoomId,
sender: ev.SenderName,
text: ev.Message,
channelName: label);
}
// Response (server ack of an outbound RequestSendToRoomById)
// and Unknown payloads are intentionally not surfaced —
// the inbound EventSendToRoom for our own message acts as
// the canonical echo.
};
// Phase I.3: real ICommandBus. Panels publish SendChatCmd here
// and we route it to the right wire opcode (Talk / Tell / ChatChannel)
// plus a local echo into ChatLog so the player sees their own
// message immediately. Closes over _liveSession + Chat so this
// wiring only exists for the lifetime of the live session.
// Step 2: capture the non-null `session` parameter rather than
// the nullable _liveSession field. Semantically identical (the
// field WAS set to `session` at the top of WireLiveSessionEvents)
// but the compiler can prove non-null for the lambda body.
var liveSession = session;
var chat = Chat;
_commandBus = new AcDream.UI.Abstractions.LiveCommandBus();
var turbineChat = TurbineChat;
uint playerGuid = _playerServerGuid;
var clientCommandController = new AcDream.App.UI.ClientCommandController(
new AcDream.App.UI.ClientCommandController.Bindings(
TeleportToLifestone: liveSession.SendTeleportToLifestone,
TeleportToMarketplace: liveSession.SendTeleportToMarketplace,
TeleportToPkArena: liveSession.SendTeleportToPkArena,
TeleportToPkLiteArena: liveSession.SendTeleportToPkLiteArena,
TeleportToHouse: liveSession.SendTeleportToHouse,
TeleportToMansion: liveSession.SendTeleportToMansion,
QueryAge: liveSession.SendQueryAge,
QueryBirth: liveSession.SendQueryBirth,
ToggleFrameRate: ToggleRetailFrameRate,
ToggleUiLock: () => SetRetailUiLocked(!_persistedGameplay.LockUI),
ShowSystemMessage: text => chat.OnSystemMessage(text, 0u),
ShowWeenieError: code => chat.OnWeenieError(code, null),
PlayerPublicWeenieBitfield: () =>
Objects.Get(_playerServerGuid)?.PublicWeenieBitfield,
ClientVersion: () =>
typeof(GameWindow).Assembly.GetName().Version?.ToString(3)
?? "unknown",
CurrentPosition: () => _playerController?.CellPosition,
LastOutsideCorpsePosition: () =>
LocalPlayer.GetPosition(0x0Eu),
ShowConfirmation: (message, completed) =>
_retailUiRuntime?.ShowConfirmation(message, completed),
Suicide: liveSession.SendSuicide,
ClearChat: _ => chat.Clear(),
SaveUi: name => _retailUiRuntime?.SaveNamedLayout(name),
LoadUi: name => _retailUiRuntime?.RestoreNamedLayout(name),
SaveAutoUi: () => _retailUiRuntime?.SaveLayout(),
LoadAutoUi: () => _retailUiRuntime?.RestoreLayout(),
IsAway: () =>
Objects.Get(_playerServerGuid)?.Properties.GetBool(0x6Eu) == true,
SetAway: away => SetRetailAway(liveSession, away),
SetAwayMessage: liveSession.SendSetAfkMessage,
AcceptLootPermits: () => _persistedGameplay.AcceptLootPermits,
SetAcceptLootPermits: SetRetailAcceptLootPermits,
DisplayConsent: liveSession.SendDisplayConsent,
ClearConsent: liveSession.SendClearConsent,
RemoveConsent: liveSession.SendRemoveConsent,
SendEmote: liveSession.SendEmote,
Friends: Friends,
AddFriend: liveSession.SendAddFriend,
RemoveFriend: liveSession.SendRemoveFriend,
ClearFriends: liveSession.SendClearFriends,
RequestLegacyFriends: liveSession.SendLegacyFriendsListRequest,
Squelch: Squelch,
ModifyCharacterSquelch: liveSession.SendModifyCharacterSquelch,
ModifyAccountSquelch: liveSession.SendModifyAccountSquelch,
ModifyGlobalSquelch: liveSession.SendModifyGlobalSquelch,
LastTeller: () => _retailChatVm?.LastIncomingTellSender,
ClearDesiredComponents: () =>
{
liveSession.SendClearDesiredComponents();
DesiredComponents = System.Array.Empty<(uint Id, uint Amount)>();
},
HasOpenVendor: () => false,
FillComponentBuyList: (_, _) => { }));
_commandBus.Register<AcDream.UI.Abstractions.ExecuteClientCommandCmd>(
clientCommandController.Execute);
_commandBus.Register<AcDream.UI.Abstractions.SendServerCommandCmd>(cmd =>
{
if (!string.IsNullOrEmpty(cmd.Text))
liveSession.SendTalk(cmd.Text);
});
_commandBus.Register<AcDream.UI.Abstractions.SendChatCmd>(cmd =>
{
if (string.IsNullOrEmpty(cmd.Text)) return;
switch (cmd.Channel)
{
case AcDream.UI.Abstractions.ChatChannelKind.Say:
// Phase J: drop optimistic /say echo. ACE's
// HandleActionTalk broadcasts a HearSpeech back
// to the sender too, and ChatLog.OnLocalSpeech
// detects own-guid match to render it as
// "You say, ...". Optimistic-echoing here
// doubled the line. ALSO: don't echo "@xxx"
// server-side admin commands — ACE consumes
// them silently and replies via SystemChat.
liveSession.SendTalk(cmd.Text);
break;
case AcDream.UI.Abstractions.ChatChannelKind.Tell:
if (string.IsNullOrEmpty(cmd.TargetName)) return;
liveSession.SendTell(cmd.TargetName, cmd.Text);
chat.OnSelfSent(
AcDream.Core.Chat.ChatKind.Tell, cmd.Text,
targetOrChannel: cmd.TargetName);
break;
default:
// Phase I.6: try TurbineChat first for the global
// community channels (General/Trade/LFG/Roleplay/
// Society/Olthoi) — they ride 0xF7DE TurbineChat.
// Allegiance is double-routed: try TurbineChat first
// (when the player has a Turbine allegiance room) and
// fall back to the legacy 0x0147 ChatChannel.
//
// We do NOT optimistic-echo channels: ACE's
// TurbineChatHandler broadcasts EventSendToRoom back
// to the sender too, so we always get the canonical
// echo from the server. Optimistic-echoing here
// double-prints the message (one as "[Trade] hello"
// from us, one as "[Trade] +Acdream says, \"hello\""
// from the server). Trust the server.
var turbine = ResolveTurbineForKind(cmd.Channel, turbineChat);
if (turbine is not null)
{
uint cookie = turbineChat.NextContextId();
// Use the live player guid if it's been captured;
// otherwise 0 (server treats unknown sender_id
// gracefully — the cookie is what we care about).
uint senderGuid = _playerServerGuid != 0u
? _playerServerGuid
: playerGuid;
Console.WriteLine(
$"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={cmd.Text.Length}");
liveSession.SendTurbineChatTo(
roomId: turbine.Value.RoomId,
chatType: turbine.Value.ChatType,
dispatchType: (uint)AcDream.Core.Net.Messages.TurbineChat.DispatchType.SendToRoomById,
senderGuid: senderGuid,
text: cmd.Text,
cookie: cookie);
// No optimistic echo: server EventSendToRoom
// broadcast comes back with sender="+Acdream"
// and is rendered by ChatVM as
// "[Trade] +Acdream says, \"hello\"".
break;
}
var resolved = AcDream.UI.Abstractions.ChannelResolver.Resolve(cmd.Channel);
if (resolved is null)
{
// Diagnostic: the user picked a channel kind that
// (a) isn't a Turbine channel TurbineChatState
// knows about and (b) has no legacy ChatChannel
// mapping. Most common cause: TurbineChat hasn't
// been enabled yet (server didn't send 0x0295)
// and the kind is General/Trade/LFG/etc.
Console.WriteLine(
$"chat: SendChatCmd kind={cmd.Channel} dropped " +
$"(turbine.Enabled={turbineChat.Enabled} no legacy id)");
return;
}
Console.WriteLine(
$"chat: outbound legacy ChatChannel {resolved.Value.DisplayName} " +
$"id=0x{resolved.Value.ChannelId:X8} len={cmd.Text.Length}");
liveSession.SendChannel(resolved.Value.ChannelId, cmd.Text);
// Legacy channels (Fellowship / Allegiance / Patron /
// Monarch / Vassals / CoVassals) — keep the optimistic
// echo because legacy ChatChannel does NOT always
// broadcast back to the sender. ChannelName is the
// friendly display name so ChatVM renders it as
// "[Fellowship] +Acdream says, \"hello\"".
chat.OnSelfSent(
AcDream.Core.Chat.ChatKind.Channel, cmd.Text,
targetOrChannel: resolved.Value.DisplayName);
break;
}
});
// Issue #5: feed PrivateUpdateVital + PrivateUpdateVitalCurrent
// into LocalPlayer so VitalsPanel can draw Stam / Mana bars.
_liveSession.VitalUpdated += v =>
LocalPlayer.OnVitalUpdate(v.VitalId, v.Ranks, v.Start, v.Xp, v.Current);
_liveSession.VitalCurrentUpdated += v =>
LocalPlayer.OnVitalCurrent(v.VitalId, v.Current);
WorldTime.SyncFromServer(ticks);
RefreshSkyForCurrentDay();
}),
CreateLiveInventorySessionBindings(),
CreateLiveCharacterSessionBindings(skillTable),
CreateLiveSocialSessionBindings());
commands = new AcDream.App.Net.LiveSessionCommandRouter(
CreateLiveSessionCommandBindings(session));
_liveSessionEvents = events;
_liveSessionCommands = commands;
}
catch
{
commands?.Dispose();
events?.Dispose();
throw;
}
}
private AcDream.App.Net.LiveEntitySessionSink CreateLiveEntitySessionSink() => new(
Spawned: spawn => _inboundEntityEvents.Run(
this, spawn, static (window, value) => window.OnLiveEntitySpawned(value)),
Deleted: deletion => _inboundEntityEvents.Run(
this, deletion, static (window, value) => window.OnLiveEntityDeleted(value)),
PickedUp: pickup => _inboundEntityEvents.Run(
this, pickup, static (window, value) => window.OnLiveEntityPickedUp(value)),
MotionUpdated: motion => _inboundEntityEvents.Run(
this, motion, static (window, value) => window.OnLiveMotionUpdated(value)),
PositionUpdated: position => _inboundEntityEvents.Run(
this, position, static (window, value) => window.OnLivePositionUpdated(value)),
VectorUpdated: vector => _inboundEntityEvents.Run(
this, vector, static (window, value) => window.OnLiveVectorUpdated(value)),
StateUpdated: state => _inboundEntityEvents.Run(
this, state, static (window, value) => window.OnLiveStateUpdated(value)),
ParentUpdated: parent => _inboundEntityEvents.Run(
this, parent, static (window, value) => window.OnLiveParentUpdated(value)),
TeleportStarted: teleport => _inboundEntityEvents.Run(
this, teleport, static (window, value) => window.OnTeleportStarted(value)),
AppearanceUpdated: appearance => _inboundEntityEvents.Run(
this, appearance, static (window, value) => window.OnLiveAppearanceUpdated(value)),
PlayPhysicsScript: script => _inboundEntityEvents.Run(
this, script, static (window, value) => window.OnPlayScriptReceived(value)),
PlayPhysicsScriptType: message => _inboundEntityEvents.Run(
this, message, static (window, value) => window._entityEffects?.HandleTyped(value)));
private AcDream.App.Net.LiveInventorySessionBindings
CreateLiveInventorySessionBindings() => new(
Objects,
LocalPlayer,
PlayerGuid: () => _playerServerGuid,
OnShortcuts: list => Shortcuts = list,
OnUseDone: error =>
{
_externalContainers.ApplyUseDone(error);
_itemInteractionController?.CompleteUse(error);
},
ItemMana,
OnDesiredComponents: components => DesiredComponents = components,
ExternalContainers: _externalContainers);
private AcDream.App.Net.LiveCharacterSessionBindings
CreateLiveCharacterSessionBindings(
DatReaderWriter.DBObjs.SkillTable? skillTable) => new(
Combat,
SpellBook,
ResolveSkillFormulaBonus: (skillId, attributeCurrents) =>
{
// ACE AttributeFormula.GetFormula
// (references/ACE/Source/ACE.Entity/Models/AttributeFormula.cs:55-):
// Attribute1Multiplier == 0 means no attribute contribution.
// Otherwise the retail data formula is
// (attr1 * mult1 + attr2 * mult2) / divisor + additive.
// Guard a zero divisor because malformed/custom DAT input must not
// take down the live-session dispatcher.
if (skillTable?.Skills is null)
return 0u;
if (!skillTable.Skills.TryGetValue(
(DatReaderWriter.Enums.SkillId)skillId,
out var skillBase))
return 0u;
var formula = skillBase.Formula;
if (formula.Attribute1Multiplier == 0 || formula.Divisor == 0)
return 0u;
attributeCurrents.TryGetValue((uint)formula.Attribute1, out uint attribute1);
attributeCurrents.TryGetValue((uint)formula.Attribute2, out uint attribute2);
long numerator =
(long)attribute1 * formula.Attribute1Multiplier +
(long)attribute2 * formula.Attribute2Multiplier;
long bonus = numerator / formula.Divisor + formula.AdditiveBonus;
return bonus < 0 ? 0u : (uint)bonus;
},
OnSkillsUpdated: (runSkill, jumpSkill) =>
{
if (runSkill >= 0)
_lastSeenRunSkill = runSkill;
if (jumpSkill >= 0)
_lastSeenJumpSkill = jumpSkill;
if (_playerController is not null
&& _lastSeenRunSkill >= 0
&& _lastSeenJumpSkill >= 0)
{
_playerController.SetCharacterSkills(
_lastSeenRunSkill,
_lastSeenJumpSkill);
Console.WriteLine(
$"player: applied server skills run={_lastSeenRunSkill} " +
$"jump={_lastSeenJumpSkill}");
}
},
OnConfirmationRequest: request =>
_retailUiRuntime?.HandleConfirmationRequest(request),
OnConfirmationDone: done =>
_retailUiRuntime?.HandleConfirmationDone(done),
OnCharacterOptions: (options1, _) =>
_characterOptions1 =
(AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1)
options1,
ClientTime: ClientTimerNow);
private AcDream.App.Net.LiveSocialSessionBindings
CreateLiveSocialSessionBindings() => new(
Chat,
TurbineChat,
Friends,
Squelch);
private AcDream.App.Net.LiveSessionCommandBindings CreateLiveSessionCommandBindings(
AcDream.Core.Net.WorldSession session) => new(
ClientCommands: new AcDream.App.UI.ClientCommandController.Bindings(
TeleportToLifestone: session.SendTeleportToLifestone,
TeleportToMarketplace: session.SendTeleportToMarketplace,
TeleportToPkArena: session.SendTeleportToPkArena,
TeleportToPkLiteArena: session.SendTeleportToPkLiteArena,
TeleportToHouse: session.SendTeleportToHouse,
TeleportToMansion: session.SendTeleportToMansion,
QueryAge: session.SendQueryAge,
QueryBirth: session.SendQueryBirth,
ToggleFrameRate: ToggleRetailFrameRate,
ToggleUiLock: () => SetRetailUiLocked(!_persistedGameplay.LockUI),
ShowSystemMessage: text => Chat.OnSystemMessage(text, 0u),
ShowWeenieError: code => Chat.OnWeenieError(code, null),
PlayerPublicWeenieBitfield: () =>
Objects.Get(_playerServerGuid)?.PublicWeenieBitfield,
ClientVersion: () =>
typeof(GameWindow).Assembly.GetName().Version?.ToString(3)
?? "unknown",
CurrentPosition: () => _playerController?.CellPosition,
LastOutsideCorpsePosition: () => LocalPlayer.GetPosition(0x0Eu),
ShowConfirmation: (message, completed) =>
_retailUiRuntime?.ShowConfirmation(message, completed),
Suicide: session.SendSuicide,
ClearChat: _ => Chat.Clear(),
SaveUi: name => _retailUiRuntime?.SaveNamedLayout(name),
LoadUi: name => _retailUiRuntime?.RestoreNamedLayout(name),
SaveAutoUi: () => _retailUiRuntime?.SaveLayout(),
LoadAutoUi: () => _retailUiRuntime?.RestoreLayout(),
IsAway: () =>
Objects.Get(_playerServerGuid)?.Properties.GetBool(0x6Eu) == true,
SetAway: away => SetRetailAway(session, away),
SetAwayMessage: session.SendSetAfkMessage,
AcceptLootPermits: () => _persistedGameplay.AcceptLootPermits,
SetAcceptLootPermits: SetRetailAcceptLootPermits,
DisplayConsent: session.SendDisplayConsent,
ClearConsent: session.SendClearConsent,
RemoveConsent: session.SendRemoveConsent,
SendEmote: session.SendEmote,
Friends,
AddFriend: session.SendAddFriend,
RemoveFriend: session.SendRemoveFriend,
ClearFriends: session.SendClearFriends,
RequestLegacyFriends: session.SendLegacyFriendsListRequest,
Squelch,
ModifyCharacterSquelch: session.SendModifyCharacterSquelch,
ModifyAccountSquelch: session.SendModifyAccountSquelch,
ModifyGlobalSquelch: session.SendModifyGlobalSquelch,
LastTeller: () => _retailChatVm?.LastIncomingTellSender,
ClearDesiredComponents: () =>
{
session.SendClearDesiredComponents();
DesiredComponents = Array.Empty<(uint Id, uint Amount)>();
},
HasOpenVendor: () => false,
FillComponentBuyList: (_, _) => { }),
Chat,
TurbineChat,
PlayerGuid: () => _playerServerGuid,
SendTalk: session.SendTalk,
SendTell: session.SendTell,
SendChannel: session.SendChannel,
SendTurbineChat: (roomId, chatType, dispatchType, senderGuid, text, cookie) =>
session.SendTurbineChatTo(
roomId, chatType, dispatchType, senderGuid, text, cookie),
Log: Console.WriteLine);
private void DisposeLiveSessionRouting()
{
AcDream.App.Net.LiveSessionCommandRouter? commands = _liveSessionCommands;
AcDream.App.Net.LiveSessionEventRouter? events = _liveSessionEvents;
_liveSessionCommands = null;
_liveSessionEvents = null;
try
{
commands?.Dispose();
}
finally
{
events?.Dispose();
}
}
/// <summary>
/// Convert a Phase 4.7 CreateObject spawn into a WorldEntity with hydrated
/// mesh refs and register it in IGameState. Called from WorldSession events
/// on the main thread (Tick runs in the Silk.NET Update callback).
/// </summary>
private enum LiveProjectionPurpose
{
LogicalRegistration,
@ -11145,7 +10958,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
// is up so panel-emitted SendChatCmd actually flows server-ward.
// Fall back to NullCommandBus for offline / pre-connect renders.
AcDream.UI.Abstractions.ICommandBus bus =
_commandBus ?? (AcDream.UI.Abstractions.ICommandBus)
_liveSessionCommands ?? (AcDream.UI.Abstractions.ICommandBus)
AcDream.UI.Abstractions.NullCommandBus.Instance;
var ctx = new AcDream.UI.Abstractions.PanelContext(
(float)deltaSeconds,
@ -14330,7 +14143,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
_magicCatalog = null;
}),
new("streamer", () => _streamer?.Dispose()),
new("combat chat", () => _combatChatTranslator?.Dispose()),
new("live session routing", DisposeLiveSessionRouting),
new("equipped children", () => _equippedChildRenderer?.Dispose()),
new("live session", () =>
{
@ -14470,76 +14283,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
_window = null;
}
// ── Phase I.6 — TurbineChat outbound helpers ──────────────────
/// <summary>
/// Result of resolving a UI <see cref="AcDream.UI.Abstractions.ChatChannelKind"/>
/// to a runtime Turbine room. Returned by
/// <see cref="ResolveTurbineForKind"/> when the player has access
/// to that Turbine channel; null otherwise.
/// </summary>
private readonly record struct TurbineResolution(uint RoomId, uint ChatType, string DisplayName);
/// <summary>
/// Map a <see cref="AcDream.UI.Abstractions.ChatChannelKind"/> to a
/// runtime Turbine room id + chat-type. Returns null when
/// <paramref name="state"/> isn't <see cref="AcDream.Core.Chat.TurbineChatState.Enabled"/>
/// or the channel has no assigned room (e.g. player not in a society).
/// Mirrors holtburger's <c>resolve_turbine_channel</c>
/// (<c>references/holtburger/.../client/commands.rs</c> lines 64-98).
/// </summary>
private static TurbineResolution? ResolveTurbineForKind(
AcDream.UI.Abstractions.ChatChannelKind kind,
AcDream.Core.Chat.TurbineChatState state)
{
if (!state.Enabled) return null;
var (room, chatType, name) = kind switch
{
AcDream.UI.Abstractions.ChatChannelKind.Allegiance =>
(state.AllegianceRoom, (uint)AcDream.Core.Net.Messages.TurbineChat.ChatType.Allegiance, "Allegiance"),
AcDream.UI.Abstractions.ChatChannelKind.General =>
(state.GeneralRoom, (uint)AcDream.Core.Net.Messages.TurbineChat.ChatType.General, "General"),
AcDream.UI.Abstractions.ChatChannelKind.Trade =>
(state.TradeRoom, (uint)AcDream.Core.Net.Messages.TurbineChat.ChatType.Trade, "Trade"),
AcDream.UI.Abstractions.ChatChannelKind.Lfg =>
(state.LfgRoom, (uint)AcDream.Core.Net.Messages.TurbineChat.ChatType.Lfg, "LFG"),
AcDream.UI.Abstractions.ChatChannelKind.Roleplay =>
(state.RoleplayRoom, (uint)AcDream.Core.Net.Messages.TurbineChat.ChatType.Roleplay, "Roleplay"),
AcDream.UI.Abstractions.ChatChannelKind.Society =>
(state.SocietyRoom, (uint)AcDream.Core.Net.Messages.TurbineChat.ChatType.Society, "Society"),
AcDream.UI.Abstractions.ChatChannelKind.Olthoi =>
(state.OlthoiRoom, (uint)AcDream.Core.Net.Messages.TurbineChat.ChatType.Olthoi, "Olthoi"),
_ => (0u, 0u, string.Empty),
};
if (room == 0u) return null;
return new TurbineResolution(room, chatType, name);
}
/// <summary>
/// Pick a human-readable label for a Turbine room broadcast. Uses
/// the chat-type when known (semantic name), falls back to the
/// numeric room id for unknown rooms.
/// </summary>
private static string TurbineRoomDisplayName(uint roomId, uint chatType)
{
return (AcDream.Core.Net.Messages.TurbineChat.ChatType)chatType switch
{
AcDream.Core.Net.Messages.TurbineChat.ChatType.Allegiance => "Allegiance",
AcDream.Core.Net.Messages.TurbineChat.ChatType.General => "General",
AcDream.Core.Net.Messages.TurbineChat.ChatType.Trade => "Trade",
AcDream.Core.Net.Messages.TurbineChat.ChatType.Lfg => "LFG",
AcDream.Core.Net.Messages.TurbineChat.ChatType.Roleplay => "Roleplay",
AcDream.Core.Net.Messages.TurbineChat.ChatType.Society => "Society",
AcDream.Core.Net.Messages.TurbineChat.ChatType.SocietyCelHan => "Celestial Hand",
AcDream.Core.Net.Messages.TurbineChat.ChatType.SocietyEldWeb => "Eldrytch Web",
AcDream.Core.Net.Messages.TurbineChat.ChatType.SocietyRadBlo => "Radiant Blood",
AcDream.Core.Net.Messages.TurbineChat.ChatType.Olthoi => "Olthoi",
_ => $"Room 0x{roomId:X8}",
};
}
/// <summary>
/// Fallback <see cref="AcDream.Core.Physics.IAnimationLoader"/> for the
/// <see cref="AcDream.App.Rendering.Wb.EntitySpawnAdapter"/> sequencer