using System; using AcDream.Core.Chat; using AcDream.Core.Combat; using AcDream.Core.Items; using AcDream.Core.Net.Messages; using AcDream.Core.Player; using AcDream.Core.Spells; namespace AcDream.Core.Net; /// /// Central registration point that wires every parsed GameEvent from /// into the appropriate Core state /// class (, , /// , ). /// /// /// Call once at startup (or on reconnect) passing the session's /// dispatcher + the state instances you want to feed. The wiring is /// additive — if you want to add a custom handler for a specific /// event, register it AFTER this helper so it overrides the default. /// /// /// /// This is the piece that makes Phase F.1's dispatcher go from "a /// thing that routes opcodes" to "a thing that actually populates /// client state so the UI can redraw". Without this glue every /// dispatcher handler had to be written by hand at each call site. /// /// public static class GameEventWiring { public static void WireAll( GameEventDispatcher dispatcher, ItemRepository items, CombatState combat, Spellbook spellbook, ChatLog chat, LocalPlayerState? localPlayer = null, TurbineChatState? turbineChat = null, // K-fix7 (2026-04-26): server-sent skill update callback. Fires // whenever PlayerDescription's skill table arrives with Run (24) // or Jump (22) entries — we only care about those two for // movement physics. Caller (GameWindow) plumbs this into the // active PlayerMovementController + caches it for the next // EnterPlayerModeNow construction. // // K-fix13 (2026-04-26): the wire's `init` field is ONLY // InitLevel (training tier from chargen, per ACE // GameEventPlayerDescription.cs:317 "init_level, for // training/specialized bonus"). The AttributeFormula // contribution (Strength/Quickness/etc-derived) is computed // by ACE at runtime via portal.dat's SkillTable + attribute // currents. Without it our totals undershoot the real // Current skill by 50-100 points for movement skills, which // is why jumps looked too short. The optional // resolveSkillFormulaBonus callback lets the caller // (GameWindow) plug in the AttributeFormula contribution // using its cached SkillTable + attribute currents — when // present, total skill = formulaBonus + init + ranks // (matching ACE's CreatureSkill.Current minus // augs/multipliers/vitae which we still don't model). Action? onSkillsUpdated = null, Func /*attrCurrents*/, uint /*formulaBonus*/>? resolveSkillFormulaBonus = null) { ArgumentNullException.ThrowIfNull(dispatcher); ArgumentNullException.ThrowIfNull(items); ArgumentNullException.ThrowIfNull(combat); ArgumentNullException.ThrowIfNull(spellbook); ArgumentNullException.ThrowIfNull(chat); // ── Chat ────────────────────────────────────────────────── dispatcher.Register(GameEventType.ChannelBroadcast, e => { var p = GameEvents.ParseChannelBroadcast(e.Payload.Span); if (p is not null) chat.OnChannelBroadcast(p.Value.ChannelId, p.Value.SenderName, p.Value.Message); }); dispatcher.Register(GameEventType.Tell, e => { var p = GameEvents.ParseTell(e.Payload.Span); if (p is not null) chat.OnTellReceived(p.Value.SenderName, p.Value.Message, p.Value.SenderGuid); }); dispatcher.Register(GameEventType.CommunicationTransientString, e => { var p = GameEvents.ParseTransient(e.Payload.Span); if (p is not null) chat.OnSystemMessage(p.Value.Message, p.Value.ChatType); }); dispatcher.Register(GameEventType.PopupString, e => { var s = GameEvents.ParsePopupString(e.Payload.Span); if (s is not null) chat.OnPopup(s); }); // ── TurbineChat channel list (0x0295 SetTurbineChatChannels) ───── // Phase I.6: arrives once at login (and after chat-server reconnect) // listing the per-session room ids assigned to General / Trade / // LFG / Roleplay / Society / Olthoi (and the optional Allegiance // Turbine room). Without this the TurbineChat outbound path stays // disabled and the chat panel falls back to the legacy ChatChannel // GameAction. See holtburger client/messages.rs:220-223 for the // server-message handling pattern. if (turbineChat is not null) { dispatcher.Register(GameEventType.SetTurbineChatChannels, e => { var p = SetTurbineChatChannels.TryParse(e.Payload.Span); if (p is null) return; turbineChat.OnChannelsReceived( allegianceRoom: p.Value.AllegianceRoom, generalRoom: p.Value.GeneralRoom, tradeRoom: p.Value.TradeRoom, lfgRoom: p.Value.LfgRoom, roleplayRoom: p.Value.RoleplayRoom, olthoiRoom: p.Value.OlthoiRoom, societyRoom: p.Value.SocietyRoom, societyCelestialHandRoom: p.Value.SocietyCelestialHandRoom, societyEldrytchWebRoom: p.Value.SocietyEldrytchWebRoom, societyRadiantBloodRoom: p.Value.SocietyRadiantBloodRoom); // Diagnostic: confirm the channel ids landed. Without // this print there's no easy way to tell from the live // log whether ACE actually sent 0x0295 or whether // outbound /g /trade /lfg are silently falling back to // the (broken) legacy ChatChannel path. Console.WriteLine( $"chat: SetTurbineChatChannels parsed enabled={turbineChat.Enabled} " + $"general=0x{p.Value.GeneralRoom:X8} trade=0x{p.Value.TradeRoom:X8} " + $"lfg=0x{p.Value.LfgRoom:X8} roleplay=0x{p.Value.RoleplayRoom:X8} " + $"society=0x{p.Value.SocietyRoom:X8} olthoi=0x{p.Value.OlthoiRoom:X8} " + $"allegiance=0x{p.Value.AllegianceRoom:X8}"); }); } // ── Errors ─────────────────────────────────────────────── // Phase I.5: WeenieError + WeenieErrorWithString parsers existed // (GameEvents.ParseWeenieError(WithString)) but were never registered. // The server fires these for game-logic failures: "not enough mana", // "can't pick that up", "your spell fizzled". Routed to chat. dispatcher.Register(GameEventType.WeenieError, e => { var code = GameEvents.ParseWeenieError(e.Payload.Span); if (code is not null) chat.OnWeenieError(code.Value, param: null); }); dispatcher.Register(GameEventType.WeenieErrorWithString, e => { var p = GameEvents.ParseWeenieErrorWithString(e.Payload.Span); if (p is not null) chat.OnWeenieError(p.Value.ErrorCode, p.Value.Interpolation); }); // ── Combat ──────────────────────────────────────────────── dispatcher.Register(GameEventType.UpdateHealth, e => { var p = GameEvents.ParseUpdateHealth(e.Payload.Span); if (p is not null) combat.OnUpdateHealth(p.Value.TargetGuid, p.Value.HealthPercent); }); dispatcher.Register(GameEventType.VictimNotification, e => { var p = GameEvents.ParseVictimNotification(e.Payload.Span); if (p is not null) chat.OnCombatLine(p.Value.DeathMessage, CombatLineKind.Error); }); dispatcher.Register(GameEventType.DefenderNotification, e => { var p = GameEvents.ParseDefenderNotification(e.Payload.Span); if (p is not null) combat.OnDefenderNotification( p.Value.AttackerName, 0u, p.Value.DamageType, p.Value.Damage, p.Value.HitQuadrant, p.Value.Critical); }); dispatcher.Register(GameEventType.AttackerNotification, e => { var p = GameEvents.ParseAttackerNotification(e.Payload.Span); if (p is not null) combat.OnAttackerNotification( p.Value.DefenderName, p.Value.DamageType, p.Value.Damage, (float)p.Value.HealthPercent); }); dispatcher.Register(GameEventType.EvasionAttackerNotification, e => { var name = GameEvents.ParseEvasionAttackerNotification(e.Payload.Span); if (name is not null) combat.OnEvasionAttackerNotification(name); }); dispatcher.Register(GameEventType.EvasionDefenderNotification, e => { var name = GameEvents.ParseEvasionDefenderNotification(e.Payload.Span); if (name is not null) combat.OnEvasionDefenderNotification(name); }); dispatcher.Register(GameEventType.AttackDone, e => { var p = GameEvents.ParseAttackDone(e.Payload.Span); if (p is not null) combat.OnAttackDone(p.Value.AttackSequence, p.Value.WeenieError); }); dispatcher.Register(GameEventType.CombatCommenceAttack, e => { if (GameEvents.ParseCombatCommenceAttack(e.Payload.Span)) combat.OnCombatCommenceAttack(); }); dispatcher.Register(GameEventType.KillerNotification, e => { var p = GameEvents.ParseKillerNotification(e.Payload.Span); if (p is not null) chat.OnCombatLine(p.Value.DeathMessage, CombatLineKind.Info); }); // ── Spells ──────────────────────────────────────────────── dispatcher.Register(GameEventType.MagicUpdateSpell, e => { var spellId = GameEvents.ParseMagicUpdateSpell(e.Payload.Span); if (spellId is not null) spellbook.OnSpellLearned(spellId.Value); }); dispatcher.Register(GameEventType.MagicRemoveSpell, e => { var spellId = GameEvents.ParseMagicRemoveSpell(e.Payload.Span); if (spellId is not null) spellbook.OnSpellForgotten(spellId.Value); }); dispatcher.Register(GameEventType.MagicUpdateEnchantment, e => { var p = GameEvents.ParseMagicUpdateEnchantment(e.Payload.Span); if (p is not null) spellbook.OnEnchantmentAdded( p.Value.SpellId, p.Value.LayerId, p.Value.Duration, p.Value.CasterGuid); }); dispatcher.Register(GameEventType.MagicRemoveEnchantment, e => { var p = GameEvents.ParseMagicRemoveEnchantment(e.Payload.Span); if (p is not null) spellbook.OnEnchantmentRemoved(p.Value.LayerId, p.Value.SpellId); }); dispatcher.Register(GameEventType.MagicDispelEnchantment, e => { var p = GameEvents.ParseMagicDispelEnchantment(e.Payload.Span); if (p is not null) spellbook.OnEnchantmentRemoved(p.Value.LayerId, p.Value.SpellId); }); dispatcher.Register(GameEventType.MagicPurgeEnchantments, _ => spellbook.OnPurgeAll()); // ── Inventory ───────────────────────────────────────────── dispatcher.Register(GameEventType.WieldObject, e => { var p = GameEvents.ParseWieldObject(e.Payload.Span); if (p is not null) items.MoveItem( p.Value.ItemGuid, newContainerId: p.Value.WielderGuid, newEquipLocation: (AcDream.Core.Items.EquipMask)p.Value.EquipLoc); }); dispatcher.Register(GameEventType.InventoryPutObjInContainer, e => { var p = GameEvents.ParsePutObjInContainer(e.Payload.Span); if (p is not null) items.MoveItem(p.Value.ItemGuid, p.Value.ContainerGuid, newSlot: (int)p.Value.Placement); }); dispatcher.Register(GameEventType.IdentifyObjectResponse, e => { var p = AppraiseInfoParser.TryParse(e.Payload.Span); if (p is null || !p.Value.Success) return; // Merge parsed properties into the item if we know about it. if (items.GetItem(p.Value.Guid) is not null) items.UpdateProperties(p.Value.Guid, p.Value.Properties); // Spellbook from appraise: for caster items / scrolls this is // the cast-on-use list. The local player's full learned // spellbook arrives via PlayerDescription (0x0013), which uses // a different wire format (see WorldSession + LocalPlayerState // — feeds vitals from PrivateUpdateVital instead). foreach (uint sid in p.Value.SpellBook) spellbook.OnSpellLearned(sid); }); // ── Player ──────────────────────────────────────────────── // PlayerDescription (0x0013) — full local-player snapshot at // login. Distinct wire format from IdentifyObjectResponse // (0x00C9): hand-written body with property hashtables, // vector-flag-gated blocks, attribute block (where vitals 7/8/9 // carry their absolute current values), skills, spells, and a // long trailer of options + inventory. See // PlayerDescriptionParser for the full layout reference (mirrors // holtburger events.rs:220-625). // // Two outputs from each parsed PlayerDescription: // 1. LocalPlayerState absorbs vital ids 7/8/9 (Health/Stam/Mana). // This is the ONLY way these arrive at login; PrivateUpdateVital // delta opcodes only fire on rank-up / Enlightenment / admin // changes — not initial sync. // 2. Spellbook absorbs the learned spell list — for the local // player this is the authoritative source (the per-item // SpellBook flag in IdentifyObjectResponse is for caster // items / scrolls only). bool dumpPd = Environment.GetEnvironmentVariable("ACDREAM_DUMP_VITALS") == "1"; dispatcher.Register(GameEventType.PlayerDescription, e => { var p = PlayerDescriptionParser.TryParse(e.Payload.Span); if (dumpPd) Console.WriteLine($"vitals: PlayerDescription body.len={e.Payload.Length} parsed={(p is null ? "NULL" : $"vec={p.Value.VectorFlags} attrs={p.Value.Attributes.Count} spells={p.Value.Spells.Count}")}"); if (p is null) return; // K-fix13 (2026-04-26): build attrId → current map while // iterating attributes so the skill-formula resolver below // can apply (attr1.current * mult1 + attr2.current * mult2) // / divisor + additive. "current" here = ranks + start // (the formula-relevant attribute level pre-augs / pre-buffs). // Built unconditionally (not inside the localPlayer guard) // because the skill-formula resolver needs it even if no // LocalPlayerState is wired. var attrCurrents = new Dictionary(); foreach (var attr in p.Value.Attributes) { // PD-attr ids 1-6 are primary attributes (Str / End // / Coord / Quick / Focus / Self). 7/8/9 are vitals. if (attr.AtType >= 1 && attr.AtType <= 6) attrCurrents[attr.AtType] = attr.Ranks + attr.Start; } if (localPlayer is not null) { foreach (var attr in p.Value.Attributes) { if (attr.Current is uint cur) { // Vital entry (id 7/8/9) — has absolute current. if (dumpPd) Console.WriteLine($"vitals: PD-vital id={attr.AtType} ranks={attr.Ranks} start={attr.Start} cur={cur}"); localPlayer.OnVitalUpdate( vitalId: attr.AtType, ranks: attr.Ranks, start: attr.Start, xp: attr.Xp, current: cur); } else { // Primary attribute (id 1..6) — Endurance+Self feed // the vital max formula (Endurance/2 for Health, // Endurance for Stamina, Self for Mana). if (dumpPd) Console.WriteLine($"vitals: PD-attr id={attr.AtType} ranks={attr.Ranks} start={attr.Start}"); localPlayer.OnAttributeUpdate( atType: attr.AtType, ranks: attr.Ranks, start: attr.Start, xp: attr.Xp); } } } foreach (uint sid in p.Value.Spells.Keys) spellbook.OnSpellLearned(sid); // K-fix7 (2026-04-26): push Run + Jump skill values to the // PlayerMovementController so the runRate / jump-arc formulas // use the SERVER's authoritative skill instead of our // hardcoded ACDREAM_*_SKILL defaults. ACE Skill enum // ordinals (Skill.cs:11-37): Jump = 22, Run = 24. The // SkillEntry.Init field is the attribute-derived initial // component; .Ranks is XP-bought additions. Their sum is // the closest we get to ACE's CreatureSkill.Current short // of porting the full Aug/Multiplier/Vitae chain. if (onSkillsUpdated is not null) { int runSkill = -1; int jumpSkill = -1; foreach (var s in p.Value.Skills) { if (s.SkillId != 22u && s.SkillId != 24u) continue; // K-fix13: total = AttributeFormula(skill, attrs) // + InitLevel (s.Init from wire) // + Ranks (s.Ranks from wire) // matches ACE CreatureSkill.Current minus // augs/multipliers/vitae. The attribute-formula // contribution is the dominant term for movement // skills (typically 50-100 points) and was being // dropped pre-fix13 — that's the root cause of // jumps being too short relative to retail. uint formulaBonus = resolveSkillFormulaBonus is not null ? resolveSkillFormulaBonus(s.SkillId, attrCurrents) : 0u; int total = (int)(formulaBonus + s.Init + s.Ranks); if (s.SkillId == 24u) runSkill = total; else if (s.SkillId == 22u) jumpSkill = total; if (dumpPd) Console.WriteLine( $"vitals: PD-skill id={s.SkillId} init={s.Init} ranks={s.Ranks} formulaBonus={formulaBonus} total={total}"); } if (runSkill >= 0 || jumpSkill >= 0) onSkillsUpdated(runSkill, jumpSkill); } // Issue #7 — enchantment block: feed each entry into the // Spellbook with full StatMod data so EnchantmentMath can // aggregate buffs in vital-max calc (issue #6 lights up). foreach (var ench in p.Value.Enchantments) { spellbook.OnEnchantmentAdded(new AcDream.Core.Spells.ActiveEnchantmentRecord( SpellId: ench.SpellId, LayerId: ench.Layer, Duration: (float)ench.Duration, CasterGuid: ench.CasterGuid, StatModType: ench.StatModType, StatModKey: ench.StatModKey, StatModValue: ench.StatModValue, Bucket: (uint)ench.Bucket)); if (dumpPd) Console.WriteLine($"vitals: PD-ench spell={ench.SpellId} layer={ench.Layer} bucket={ench.Bucket} key={ench.StatModKey} val={ench.StatModValue}"); } // Issue #13 — register inventory entries with ItemRepository so // panels (inventory, paperdoll, hotbars) light up after login. // Equipped entries share the same ObjectId as inventory entries // (an equipped item is also in inventory) — register both, but // the equipped record carries the slot mask which we surface via // MoveItem so paperdoll can render. foreach (var inv in p.Value.Inventory) { if (items.GetItem(inv.Guid) is null) { items.AddOrUpdate(new ItemInstance { ObjectId = inv.Guid, WeenieClassId = inv.ContainerType, }); } } foreach (var eq in p.Value.Equipped) { if (items.GetItem(eq.Guid) is null) { items.AddOrUpdate(new ItemInstance { ObjectId = eq.Guid, WeenieClassId = 0, }); } // Reflect the equip slot — paperdoll uses CurrentlyEquippedLocation. items.MoveItem( itemId: eq.Guid, newContainerId: 0, newSlot: -1, newEquipLocation: (EquipMask)eq.EquipLocation); } }); } }