using System; using System.Linq; 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; using AcDream.Core.Social; 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 returned /// subscription owns exactly these registrations and must be disposed /// before the session is replaced. A later legacy registration still /// overrides the corresponding default without becoming owned here. /// /// /// /// 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 IDisposable WireAll( GameEventDispatcher dispatcher, ClientObjectTable 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, // D.5.1 Task 4: persists Shortcuts from each PlayerDescription so the // toolbar can populate itself at login without keeping a parser reference. // Optional so all existing callers and tests compile unchanged. Action>? onShortcuts = null, // B-Wire: the local player's server guid. When provided, the PD handler upserts // the player's own PropertyBundle (EncumbranceVal etc.) into the player ClientObject. Func? playerGuid = null, Action? onUseDone = null, Action? onAppraisal = null, ItemManaState? itemMana = null, Action? onConfirmationRequest = null, Action? onConfirmationDone = null, FriendsState? friends = null, SquelchState? squelch = null, Action>? onDesiredComponents = null, Action? onCharacterOptions = null, Func? clientTime = null, ExternalContainerState? externalContainers = null, // Slice 5.3: the vendor browse session owner. Matches the existing // itemMana/friends/squelch/externalContainers pattern — optional so // every existing caller compiles unchanged. VendorState? vendor = null, // Campaign CH slice CH2: the retail-faithful text/type router // (RuntimeCommunicationState.AddText — Core.Net cannot reference // AcDream.Runtime directly, so this is a delegate hole exactly like // every other Runtime-owned sink above). When null, WeenieError/ // WeenieErrorWithString/CommunicationTransientString/UseDone fall // back to their pre-CH2 behavior (still text-correct via the full // WeenieErrorMessages table, just without the SpewBox split) so // every existing caller compiles and behaves unchanged. Action? onInterfaceText = null, Func? accepting = null, // Campaign FA slice FA2 (2026-08-12): fellowship + allegiance // delegate holes. RuntimeFellowshipState/RuntimeAllegianceState are // AcDream.Runtime types — Core.Net cannot reference AcDream.Runtime // directly, so these are delegate holes exactly like every other // Runtime-owned sink above (docs/research/2026-08-11-fa-acdream-seams.md // §2.2). All optional/nullable so every existing caller compiles // unchanged. Action? onFellowshipFullUpdate = null, Action? onFellowshipUpdateFellow = null, Action? onFellowshipQuit = null, Action? onFellowshipDismiss = null, Action? onFellowshipDisband = null, Action? onAllegianceUpdate = null, Action? onAllegianceUpdateDone = null, Action? onAllegianceUpdateAborted = null, Action? onAllegianceLoginNotification = null, // Secure trade (2026-08-14): the same Runtime-owned delegate-hole // shape as fellowship above. RuntimeTradeState is the consumer; // docs/research/2026-08-14-trade-laneB-wire.md is the wire SSOT. Action? onTradeRegister = null, Action? onTradeClose = null, Action? onTradeAdd = null, Action? onTradeRemove = null, Action? onTradeAccept = null, Action? onTradeDecline = null, Action? onTradeReset = null, Action? onTradeFailure = null, Action? onTradeClearAcceptance = null, // House panel (Batch C, Map/House toolbar panel, 2026-08-17): the // same Runtime-owned delegate-hole shape as trade above — // RuntimeHouseState (or a lighter equivalent) is the consumer. Action? onHouseData = null, Action? onHouseStatus = null, Action? onHouseUpdateRentTime = null, Action>? onHouseUpdateRentPayment = null) { ArgumentNullException.ThrowIfNull(dispatcher); ArgumentNullException.ThrowIfNull(items); ArgumentNullException.ThrowIfNull(combat); ArgumentNullException.ThrowIfNull(spellbook); ArgumentNullException.ThrowIfNull(chat); clientTime ??= static () => 0d; var registrar = new OwnedGameEventRegistrar(dispatcher, accepting); using var construction = new RegistrationBuildScope(registrar); // ── Chat ────────────────────────────────────────────────── registrar.Register(GameEventType.ChannelBroadcast, e => { var p = GameEvents.ParseChannelBroadcast(e.Payload.Span); // logTextType left unset — ChatLog.OnChannelBroadcast derives it // from ChannelId via LegacyChannelChatType.Resolve(ownSend: false), // the correct branch for this inbound (hear) 0x0147 handler. if (p is not null) chat.OnChannelBroadcast(p.Value.ChannelId, p.Value.SenderName, p.Value.Message); }); registrar.Register(GameEventType.Tell, e => { var p = GameEvents.ParseTell(e.Payload.Span); // p.Value.ChatType is the wire LogTextType (normally 0x03 Tell) — // passed through verbatim, matching HearSpeech's zero-remap rule. if (p is not null) chat.OnTellReceived(p.Value.SenderName, p.Value.Message, p.Value.SenderGuid, p.Value.ChatType); }); registrar.Register(GameEventType.CommunicationTransientString, e => { // 0x02EB carries no chat type on the wire (see ParseTransient) — // retail doesn't need one, because // Handle_Communication__TransientString @0x0057D460 // HARDCODES the destination: // AddTextToScroll(text, 0x1A, 1, 0). Every 0x02EB message is // SpewBox, unconditionally, regardless of who's the recipient // (research doc §1.3/§2.4) — this corrects Campaign CH slice // CH1's routing note, which assumed "server-driven" implied // "not client-local" (it doesn't; retail's routing key is the // TYPE ARGUMENT the handler passes, not who initiated the text). var s = GameEvents.ParseTransient(e.Payload.Span); if (s is null) return; if (onInterfaceText is not null) onInterfaceText(s, RetailLogTextType.ClientLocal); else chat.OnSystemMessage(s, chatType: (uint)RetailLogTextType.ClientLocal); }); registrar.Register(GameEventType.PopupString, e => { var s = GameEvents.ParsePopupString(e.Payload.Span); if (s is not null) chat.OnPopup(s); }); registrar.Register(GameEventType.QueryAgeResponse, e => { var p = GameEvents.ParseQueryAgeResponse(e.Payload.Span); if (p is null) return; string text = string.IsNullOrEmpty(p.Value.Name) ? $"You have played for {p.Value.Age}." : $"{p.Value.Name} has played for {p.Value.Age}."; // Decomp-confirmed 0x00 Default: // CM_Character::DispatchUI_QueryAgeResponse @0x006A2E40 -> // Handle_Character__QueryAgeResponse @0x005711D0 -> // AddTextToScroll(..., 0, 1, 0), pc:382186. chat.OnSystemMessage(text, chatType: 0u); }); // #362 / register row TS-70 (Campaign CH user-gate round 1, item E): // @index/@clist/@hslist/@allegiance info sent byte-correct requests // with no inbound handler — ACE's reply was silently dropped. All // four render LogTextType 0x00 Default lines, matching their retail // handlers exactly (see ClientCommandResponses' per-method doc // comments for the named-retail anchors). registrar.Register(GameEventType.ChannelIndex, e => { var channels = ClientCommandResponses.ParseChannelIndex(e.Payload.Span); if (channels is null) return; foreach (string line in ClientCommandResponses.FormatChannelIndexLines(channels)) chat.OnSystemMessage(line, chatType: 0u); }); registrar.Register(GameEventType.ChannelList, e => { var names = ClientCommandResponses.ParseChannelList(e.Payload.Span); if (names is null) return; foreach (string line in ClientCommandResponses.FormatChannelListLines(names)) chat.OnSystemMessage(line, chatType: 0u); }); registrar.Register(GameEventType.AvailableHouses, e => { var houses = ClientCommandResponses.ParseAvailableHouses(e.Payload.Span); if (houses is null) return; foreach (string line in ClientCommandResponses.FormatAvailableHousesLines(houses.Value)) chat.OnSystemMessage(line, chatType: 0u); }); // Campaign FA slice FA2 (2026-08-12) correction: GameEventDispatcher. // Dispatch invokes ONLY the single most-recently-registered handler // per GameEventType (GameEventDispatcher.cs:95-117) — a second // registrar.Register(GameEventType.AllegianceInfoResponse, ...) // call does NOT chain-invoke the first; it REPLACES it (the // superseded handler only comes back if the newer registration's // token is later disposed). The seam doc's "the dispatcher supports // multiple owned handlers per type — both fire" claim // (docs/research/2026-08-11-fa-acdream-seams.md §2.3, dated addendum // added) does not hold against the actual dispatcher — a second // Register call here would silently kill this already-live // `@allegiance info` chat-text output. // // FA2 fix-round MUST-FIX 2 (2026-08-12, // docs/research/2026-08-12-fa2-review-mechanism.md / // docs/research/2026-08-12-fa2-review-blast.md): this handler // previously ALSO forwarded to a Runtime allegiance-owner callback // (self-gated on TargetGuid == playerGuid()). Retail's own handler // for 0x027C (CM_Allegiance::DispatchUI_AllegianceInfoResponseEvent // @0x006a7470) unpacks into a STACK-LOCAL profile destroyed on // return and is consumed only by // Handle_Allegiance__AllegianceInfoResponseEvent @0x0056a1d0's // AddTextToScroll calls — retail's allegiance panel is fed // EXCLUSIVELY by 0x0020 AllegianceUpdate. Forwarding also fabricated // RuntimeAllegianceSnapshot.Rank (0x027C carries no rank field) on // any client whose first allegiance message was a self // `@allegiance info` query. Restored to text-only, matching retail. registrar.Register(GameEventType.AllegianceInfoResponse, e => { var info = ClientCommandResponses.ParseAllegianceInfoResponse(e.Payload.Span); if (info is null) return; foreach (string line in ClientCommandResponses.FormatAllegianceInfoLines(info.Value)) chat.OnSystemMessage(line, chatType: 0u); }); // ── Fellowship (Campaign FA slice FA2, 2026-08-12) ────────────── if (onFellowshipFullUpdate is not null) { registrar.Register(GameEventType.FellowshipFullUpdate, e => { var update = GameEvents.ParseFellowshipFullUpdate(e.Payload.Span); if (update is not null) onFellowshipFullUpdate(update.Value); }); } if (onFellowshipUpdateFellow is not null) { registrar.Register(GameEventType.FellowshipUpdateFellow, e => { var update = GameEvents.ParseFellowshipUpdateFellow(e.Payload.Span); if (update is not null) onFellowshipUpdateFellow(update.Value); }); } if (onFellowshipQuit is not null) { registrar.Register(GameEventType.FellowshipQuit, e => { var quit = GameEvents.ParseFellowshipQuit(e.Payload.Span); if (quit is not null) onFellowshipQuit(quit.Value.QuitterGuid); }); } if (onFellowshipDismiss is not null) { registrar.Register(GameEventType.FellowshipDismiss, e => { var dismiss = GameEvents.ParseFellowshipDismiss(e.Payload.Span); if (dismiss is not null) onFellowshipDismiss(dismiss.Value.DismissedGuid); }); } if (onFellowshipDisband is not null) { registrar.Register(GameEventType.FellowshipDisband, e => { if (GameEvents.ParseFellowshipDisband(e.Payload.Span)) onFellowshipDisband(); }); } // ── Allegiance (Campaign FA slice FA2, 2026-08-12) ────────────── if (onAllegianceUpdate is not null) { registrar.Register(GameEventType.AllegianceUpdate, e => { var update = ClientCommandResponses.ParseAllegianceUpdate(e.Payload.Span); if (update is not null) onAllegianceUpdate(update.Value); }); } if (onAllegianceUpdateDone is not null) { registrar.Register(GameEventType.AllegianceUpdateDone, e => { var code = GameEvents.ParseAllegianceUpdateDone(e.Payload.Span); if (code is not null) onAllegianceUpdateDone(code.Value); }); } if (onAllegianceUpdateAborted is not null) { registrar.Register(GameEventType.AllegianceUpdateAborted, e => { var code = GameEvents.ParseAllegianceUpdateAborted(e.Payload.Span); if (code is not null) onAllegianceUpdateAborted(code.Value); }); } if (onAllegianceLoginNotification is not null) { registrar.Register(GameEventType.AllegianceLoginNotification, e => { var notice = GameEvents.ParseAllegianceLoginNotification(e.Payload.Span); if (notice is not null) onAllegianceLoginNotification(notice.Value); }); } // ── Secure trade (0x01FD–0x0208) ────────────────────────── if (onTradeRegister is not null) { registrar.Register(GameEventType.RegisterTrade, e => { var p = GameEvents.ParseRegisterTrade(e.Payload.Span); if (p is not null) onTradeRegister(p.Value); }); } if (onTradeClose is not null) { registrar.Register(GameEventType.CloseTrade, e => { var p = GameEvents.ParseCloseTrade(e.Payload.Span); if (p is not null) onTradeClose(p.Value); }); } if (onTradeAdd is not null) { registrar.Register(GameEventType.AddToTrade, e => { var p = GameEvents.ParseAddToTrade(e.Payload.Span); if (p is not null) onTradeAdd(p.Value); }); } if (onTradeRemove is not null) { // ACE never emits 0x0201; registered defensively for the retail // handler's sake (Handle_Trade__Recv_RemoveFromTrade @ 0x0056DC00). registrar.Register(GameEventType.RemoveFromTrade, e => { var p = GameEvents.ParseRemoveFromTrade(e.Payload.Span); if (p is not null) onTradeRemove(p.Value); }); } if (onTradeAccept is not null) { registrar.Register(GameEventType.AcceptTrade, e => { var p = GameEvents.ParseAcceptTrade(e.Payload.Span); if (p is not null) onTradeAccept(p.Value); }); } if (onTradeDecline is not null) { registrar.Register(GameEventType.DeclineTrade, e => { var p = GameEvents.ParseDeclineTrade(e.Payload.Span); if (p is not null) onTradeDecline(p.Value); }); } if (onTradeReset is not null) { registrar.Register(GameEventType.ResetTrade, e => { var p = GameEvents.ParseResetTrade(e.Payload.Span); if (p is not null) onTradeReset(p.Value); }); } if (onTradeFailure is not null) { registrar.Register(GameEventType.TradeFailure, e => { var p = GameEvents.ParseTradeFailure(e.Payload.Span); if (p is not null) onTradeFailure(p.Value); }); } if (onTradeClearAcceptance is not null) { // 0x0208 carries no payload (GameEventClearTradeAcceptance). registrar.Register(GameEventType.ClearTradeAcceptance, _ => onTradeClearAcceptance()); } // ── House panel (0x0225–0x0228) ─────────────────────────── // Batch C (Map/House toolbar panel, 2026-08-17). gmHouseUI:: // PostInit registers all four; consumers are optional so every // existing caller compiles unchanged. if (onHouseData is not null) { registrar.Register(GameEventType.HouseData, e => { var p = GameEvents.ParseHouseData(e.Payload.Span); if (p is not null) onHouseData(p.Value); }); } if (onHouseStatus is not null) { registrar.Register(GameEventType.HouseStatus, e => { var p = GameEvents.ParseHouseStatus(e.Payload.Span); if (p is not null) onHouseStatus(p.Value); }); } if (onHouseUpdateRentTime is not null) { registrar.Register(GameEventType.UpdateRentTime, e => { var p = GameEvents.ParseUpdateRentTime(e.Payload.Span); if (p is not null) onHouseUpdateRentTime(p.Value); }); } if (onHouseUpdateRentPayment is not null) { registrar.Register(GameEventType.UpdateRentPayment, e => { var p = GameEvents.ParseUpdateRentPayment(e.Payload.Span); if (p is not null) onHouseUpdateRentPayment(p); }); } if (onConfirmationRequest is not null) { registrar.Register(GameEventType.CharacterConfirmationRequest, e => { var request = GameEvents.ParseCharacterConfirmationRequest(e.Payload.Span); if (request is not null) onConfirmationRequest(request.Value); }); } if (onConfirmationDone is not null) { registrar.Register(GameEventType.CharacterConfirmationDone, e => { var done = GameEvents.ParseCharacterConfirmationDone(e.Payload.Span); if (done is not null) onConfirmationDone(done.Value); }); } if (friends is not null) { registrar.Register(GameEventType.FriendsListUpdate, e => { FriendsUpdate? update = SocialStateMessages.ParseFriendsUpdate(e.Payload.Span); if (update is not null) friends.Apply(update); }); } if (squelch is not null) { registrar.Register(GameEventType.SetSquelchDB, e => { SquelchDatabase? database = SocialStateMessages.ParseSquelchDatabase(e.Payload.Span); if (database is not null) squelch.Replace(database); }); } // ── 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) { registrar.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". // // Campaign CH slice CH2: retail resolves BOTH the display text and // the AddTextToScroll destination type from the SAME per-id switch // (ClientCommunicationSystem::HandleFailureEvent @0x00571990 — see // WeenieErrorMessages' full 344-row port). When a router is wired // (the production path), the resolved type decides chat vs SpewBox; // otherwise this falls back to a direct chat append so callers that // don't wire the router (older tests) keep a working, if // SpewBox-less, path. // // REJECT-review rework (SHOULD-FIX 3/4, // docs/research/2026-08-09-ch2-review-findings.md): the legacy // fallback no longer routes through the deleted ChatLog.OnWeenieError // (SHOULD-FIX 3 — that chokepoint bypass is retired everywhere, not // just at the ShowWeenieError call site) and an unmapped id resolves // to a null Text — retail's switch has no default case, so it // produces NO text toward the player (SHOULD-FIX 4). Both branches // below skip display for a null Text and log the raw id instead, so // an unmapped code stays visible to US without ever reaching chat. registrar.Register(GameEventType.WeenieError, e => { var code = GameEvents.ParseWeenieError(e.Payload.Span); if (code is null) return; if (WeenieErrorMessages.IsSilentClientControlStatus(code.Value)) return; var (text, type) = WeenieErrorMessages.Resolve(code.Value, null); if (text is null) { Console.WriteLine($"[weenie-error] unmapped code=0x{code.Value:X4}"); return; } if (onInterfaceText is not null) onInterfaceText(text, type); else chat.OnSystemMessage(text, chatType: (uint)type); }); registrar.Register(GameEventType.WeenieErrorWithString, e => { var p = GameEvents.ParseWeenieErrorWithString(e.Payload.Span); if (p is null) return; if (WeenieErrorMessages.IsSilentClientControlStatus(p.Value.ErrorCode)) return; var (text, type) = WeenieErrorMessages.Resolve(p.Value.ErrorCode, p.Value.Interpolation); if (text is null) { Console.WriteLine( $"[weenie-error] unmapped code=0x{p.Value.ErrorCode:X4} param={p.Value.Interpolation}"); return; } if (onInterfaceText is not null) onInterfaceText(text, type); else chat.OnSystemMessage(text, chatType: (uint)type); }); // ── Combat ──────────────────────────────────────────────── registrar.Register(GameEventType.UpdateHealth, e => { var p = GameEvents.ParseUpdateHealth(e.Payload.Span); if (p is not null) combat.OnUpdateHealth(p.Value.TargetGuid, p.Value.HealthPercent); }); if (itemMana is not null) { registrar.Register(GameEventType.QueryItemManaResponse, e => { var p = GameEvents.ParseQueryItemManaResponse(e.Payload.Span); if (p is not null) itemMana.OnQueryItemManaResponse( p.Value.ItemGuid, p.Value.ManaPercent, p.Value.Valid); }); } registrar.Register(GameEventType.VictimNotification, e => { var p = GameEvents.ParseVictimNotification(e.Payload.Span); // VictimNotification (0x01AC) and KillerNotification (0x01AD) // both dispatch through the SAME retail handler, // ClientCombatSystem::HandleKillerNotificationEvent @0x0056C410 // (pc:359548-359559), which calls AddTextToScroll(..., 0, 1, 0) // — LogTextType 0x00 Default, not a combat color. if (p is not null) chat.OnCombatLine(p.Value.DeathMessage, logTextType: 0x00u, kind: CombatLineKind.Error); }); registrar.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); }); registrar.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); }); registrar.Register(GameEventType.EvasionAttackerNotification, e => { var name = GameEvents.ParseEvasionAttackerNotification(e.Payload.Span); if (name is not null) combat.OnEvasionAttackerNotification(name); }); registrar.Register(GameEventType.EvasionDefenderNotification, e => { var name = GameEvents.ParseEvasionDefenderNotification(e.Payload.Span); if (name is not null) combat.OnEvasionDefenderNotification(name); }); registrar.Register(GameEventType.AttackDone, e => { var p = GameEvents.ParseAttackDone(e.Payload.Span); if (p is not null) combat.OnAttackDone(p.Value.AttackSequence, p.Value.WeenieError); }); registrar.Register(GameEventType.CombatCommenceAttack, e => { if (GameEvents.ParseCombatCommenceAttack(e.Payload.Span)) combat.OnCombatCommenceAttack(); }); registrar.Register(GameEventType.KillerNotification, e => { var p = GameEvents.ParseKillerNotification(e.Payload.Span); // Same handler/type as VictimNotification above — 0x00 Default. if (p is not null) chat.OnCombatLine(p.Value.DeathMessage, logTextType: 0x00u, kind: CombatLineKind.Info); }); // ── Spells ──────────────────────────────────────────────── registrar.Register(GameEventType.MagicUpdateSpell, e => { var spellId = GameEvents.ParseMagicUpdateSpell(e.Payload.Span); if (spellId is not null) spellbook.OnSpellLearned(spellId.Value); }); registrar.Register(GameEventType.MagicRemoveSpell, e => { var spellId = GameEvents.ParseMagicRemoveSpell(e.Payload.Span); if (spellId is not null) spellbook.OnSpellForgotten(spellId.Value); }); registrar.Register(GameEventType.MagicUpdateEnchantment, e => { var p = GameEvents.ParseMagicUpdateEnchantment(e.Payload.Span); if (p is not null) spellbook.OnEnchantmentAdded(ToActiveEnchantment(p.Value, clientTime())); }); registrar.Register(GameEventType.MagicUpdateMultipleEnchantments, e => { var entries = GameEvents.ParseMagicUpdateMultipleEnchantments(e.Payload.Span); if (entries is not null) { double receivedAt = clientTime(); spellbook.OnEnchantmentsAdded(entries.Select(entry => ToActiveEnchantment(entry, receivedAt))); } }); registrar.Register(GameEventType.MagicRemoveEnchantment, e => { var p = GameEvents.ParseMagicRemoveEnchantment(e.Payload.Span); if (p is not null) spellbook.OnEnchantmentRemoved(p.Value.Layer, p.Value.SpellId); }); registrar.Register(GameEventType.MagicRemoveMultipleEnchantments, e => { var entries = GameEvents.ParseMagicLayeredSpellList(e.Payload.Span); if (entries is not null) spellbook.OnEnchantmentsRemoved(entries.Select(item => ((uint)item.SpellId, (uint)item.Layer))); }); registrar.Register(GameEventType.MagicDispelEnchantment, e => { var p = GameEvents.ParseMagicDispelEnchantment(e.Payload.Span); if (p is not null) spellbook.OnEnchantmentRemoved(p.Value.Layer, p.Value.SpellId); }); registrar.Register(GameEventType.MagicDispelMultipleEnchantments, e => { var entries = GameEvents.ParseMagicLayeredSpellList(e.Payload.Span); if (entries is not null) spellbook.OnEnchantmentsRemoved(entries.Select(item => ((uint)item.SpellId, (uint)item.Layer))); }); registrar.Register(GameEventType.MagicPurgeEnchantments, _ => spellbook.OnPurgeAll()); registrar.Register(GameEventType.MagicPurgeBadEnchantments, _ => spellbook.OnPurgeBadEnchantments()); // ── Inventory ───────────────────────────────────────────── registrar.Register(GameEventType.WieldObject, e => { var p = GameEvents.ParseWieldObject(e.Payload.Span); if (p is null) return; uint wielderGuid = playerGuid?.Invoke() ?? 0u; items.ApplyConfirmedServerWield( p.Value.ItemGuid, wielderGuid, (AcDream.Core.Items.EquipMask)p.Value.EquipLoc); }); registrar.Register(GameEventType.InventoryPutObjInContainer, e => { var p = GameEvents.ParsePutObjInContainer(e.Payload.Span); if (p is null) return; items.ApplyConfirmedServerMove( p.Value.ItemGuid, p.Value.ContainerGuid, newWielderId: 0u, newSlot: (int)p.Value.Placement, containerTypeHint: p.Value.ContainerType); }); // AP-129 (Campaign P Slice P4 review fix, 2026-07-30): House_UpdateRestrictions // (0x0248) — live refresh of a house object's guest/ban list. Feeds // ObjectInfo.CheckEntryRestrictions' CanMoveInto port. No-ops if the house // object hasn't arrived via CreateObject yet (UpdateHouseRestrictions // returns false), matching every other targeted property update here. registrar.Register(GameEventType.HouseUpdateRestrictions, e => { var p = GameEvents.ParseHouseUpdateRestrictions(e.Payload.Span); if (p is null) return; items.UpdateHouseRestrictions(p.Value.SenderId, p.Value.Restrictions); }); // Slice 5.3: ApproachVendor (0x0062) — the sole wire message that // opens a vendor's shop; it rides the ordinary Use action, there is // no separate "open vendor" opcode (research doc // docs/research/2026-08-08-slice5-vendor-browse-research.md §A.1-A.2). // Every event is a COMPLETE REPLACE (§A.3) — VendorState.Apply is a // single-phase authoritative-replace call, matching that contract. // A malformed payload is dropped silently: every sibling handler in // this section (WieldObject, InventoryPutObjInContainer, // HouseUpdateRestrictions above, ViewContents/CloseGroundContainer // below) uses the same `if (p is null) return;` shape with no // logging — there is no established parse-failure logging // convention in this file to deviate from. registrar.Register(GameEventType.ApproachVendor, e => { var p = VendorApproach.TryParse(e.Payload.Span); if (VendorDiagnostics.DumpVendorEnabled) { Console.WriteLine( $"[vendor-diag] ApproachVendor(0x0062) inbound parsed={p is not null} " + $"vendorGuid={(p is null ? "n/a" : $"0x{p.Value.VendorGuid:X8}")} " + $"itemCount={(p is null ? "n/a" : p.Value.Items.Count.ToString())}"); } if (p is null) return; var profile = new VendorShopProfile( p.Value.Profile.MerchandiseItemTypes, p.Value.Profile.MerchandiseMinValue, p.Value.Profile.MerchandiseMaxValue, p.Value.Profile.DealMagicalItems, p.Value.Profile.BuyPrice, p.Value.Profile.SellPrice, p.Value.Profile.AlternateCurrencyWcid, p.Value.Profile.AlternateCurrencyAmount, p.Value.Profile.AlternateCurrencyPluralName); var shopItems = new VendorShopItem[p.Value.Items.Count]; for (int i = 0; i < shopItems.Length; i++) { VendorApproach.ItemProfile item = p.Value.Items[i]; if (VendorDiagnostics.DumpVendorEnabled && i < 5) { Console.WriteLine( $"[vendor-diag] ApproachVendor wire-item[{i}] guid=0x{item.ItemGuid:X8} " + $"name={item.Desc.Name ?? "null"} " + $"descStackSize={(item.Desc.StackSize is { } ds ? ds.ToString() : "null")} " + $"stackSizeMax={(item.Desc.StackSizeMax is { } sm ? sm.ToString() : "null")}"); } shopItems[i] = new VendorShopItem( item.ItemGuid, item.StackSize, item.Desc.WeenieClassId, item.Desc.Name, item.Desc.ItemType, item.Desc.IconId, item.Desc.Value, // Slice 5.3 review fix 2: the DESC's own StackSize (NOT // item.StackSize above, ItemProfile's separate packed // supply-count field) -- VendorPricing.PerUnitValue's // divisor for turning Value's stack-total wire number // into a per-unit display price. item.Desc.StackSize, // Grand-gate finding R1 (register AP-169 correction): // the item TYPE's authored stack ceiling (retail // PublicWeenieDesc::_maxStackSize) — see // VendorShopItem.MaxStackSize's doc comment. item.Desc.StackSizeMax, // Slice 5.4 review fix F5: forward the icon overlay/ // underlay/effects PublicWeenieDescParser already // captures, so a shop item's icon composites the same // way ExternalContainerController.CreateCell's does. item.Desc.IconUnderlayId, item.Desc.IconOverlayId, item.Desc.UiEffects, // Slice 5.4 review fix F2/F3: forward the plural name so // the browse panel can show "100 Arrows" instead of // fabricating a plural. item.Desc.PluralName); } vendor?.Apply(p.Value.VendorGuid, profile, shopItems); }); // ViewContents (0x0196) — the server's AUTHORITATIVE full contents list for a container you // opened (Use 0x0036). Treat it as a full projection-only REPLACE: update membership without // inventing ContainerSlot values, then publish one ContainerContentsReplaced notification so // every UI consumer repaints from the same snapshot. Retail: ClientUISystem::OnViewContents. registrar.Register(GameEventType.ViewContents, e => { var p = GameEvents.ParseViewContents(e.Payload.Span); if (p is null) return; var entries = new ContainerContentEntry[p.Value.Items.Count]; for (int i = 0; i < entries.Length; i++) entries[i] = new ContainerContentEntry( p.Value.Items[i].Guid, p.Value.Items[i].ContainerType); items.ReplaceContents(p.Value.ContainerGuid, entries); externalContainers?.ApplyViewContents(p.Value.ContainerGuid); }); // B-Wire: InventoryPutObjectIn3D (0x019A) — server confirms an item dropped // to the world. Unparent it from its container (it's now a ground object) so // the inventory grid drops the cell; the object itself survives. registrar.Register(GameEventType.InventoryPutObjectIn3D, e => { var guid = GameEvents.ParsePutObjectIn3D(e.Payload.Span); if (guid is not null) { items.ApplyConfirmedServerMove( guid.Value, newContainerId: 0u, newWielderId: 0u); } }); // B-Drag: InventoryServerSaveFailed (0x00A0) — server rejected an optimistic move. // Snap the item back to its pre-move slot. Log only when there was no pending move // (a server-initiated failure on a non-optimistic path). registrar.Register(GameEventType.InventoryServerSaveFailed, e => { var p = GameEvents.ParseInventoryServerSaveFailed(e.Payload.Span); if (p is null) return; // B-Drag: the server rejected an optimistic move — snap the item back to its pre-move slot. var item = items.Get(p.Value.ItemGuid); string itemInfo = item is null ? "unknown" : $"'{item.Name}' valid=0x{(uint)item.ValidLocations:X8} equip=0x{(uint)item.CurrentlyEquippedLocation:X8} priority=0x{item.Priority:X8} container=0x{item.ContainerId:X8} wielder=0x{item.WielderId:X8}"; bool rolledBack = items.RejectMove(p.Value.ItemGuid, p.Value.WeenieError); Console.WriteLine($"[B-Drag] InventoryServerSaveFailed guid=0x{p.Value.ItemGuid:X8} err=0x{p.Value.WeenieError:X} rolledBack={rolledBack} item={itemInfo}"); }); // UseDone (0x01C7) — the Use/UseWithTarget completion signal. A non-zero // code is a WeenieError refusal ("You are not trained in healing!" etc.). // // Campaign CH slice CH2: folds the former 4-entry // WeenieErrorText.cs (#202 / register AP-74) into the full // WeenieErrorMessages table, which resolves both the text AND the // real per-code retail destination type (most UseDone refusal codes // — 0x1D/0x4EB/0x4FC/0x4FE among them — route to the SpewBox, not // chat, per HandleFailureEvent) instead of the previous hardcoded // chatType 0. registrar.Register(GameEventType.UseDone, e => { uint? err = GameEvents.ParseUseDone(e.Payload.Span); if (VendorDiagnostics.DumpVendorEnabled) { Console.WriteLine( $"[vendor-diag] UseDone(0x01C7) inbound parsed={err is not null} " + $"err={(err is null ? "n/a" : $"0x{err.Value:X4}")}"); } if (err is null) return; // Already the diagnostics-only log line SHOULD-FIX 4 asks for — // it fires unconditionally, so an unmapped code below stays // visible to US even though it produces no player-facing text. Console.WriteLine($"[use-done] err=0x{err.Value:X4}"); onUseDone?.Invoke(err.Value); if (err.Value == 0) return; // NIT 6 (docs/research/2026-08-09-ch2-review-findings.md): // aligned with the WeenieError/WeenieErrorWithString handlers // above, which check this before resolving. Harmless either // way today — 0x3B/0x3C have no HandleFailureEvent case, so // WeenieErrorMessages.Resolve already returns a null Text for // them — but an explicit early-out here is more direct than // relying on that coincidence, and guards against a future // table addition accidentally making one of these two // resolvable when retail's own switch genuinely has no case // for either. if (WeenieErrorMessages.IsSilentClientControlStatus(err.Value)) return; var (text, type) = WeenieErrorMessages.Resolve(err.Value, null); if (text is null) return; if (onInterfaceText is not null) onInterfaceText(text, type); else chat.OnSystemMessage(text, chatType: (uint)type); }); // CloseGroundContainer (0x0052): clear ClientUISystem::groundObject and // retire the root plus nested temporary ViewContents projections. Child // objects remain until authoritative move/delete wire says otherwise. registrar.Register(GameEventType.CloseGroundContainer, e => { var guid = GameEvents.ParseCloseGroundContainer(e.Payload.Span); if (guid is null) return; externalContainers?.ApplyClose(guid.Value); items.StopViewingContentsTree(guid.Value); }); registrar.Register(GameEventType.IdentifyObjectResponse, e => { var p = AppraiseInfoParser.TryParse(e.Payload.Span); if (p is null) return; // Merge parsed properties into the item if we know about it. if (p.Value.Success && items.Get(p.Value.Guid) is not null) items.UpdateProperties(p.Value.Guid, p.Value.Properties); if (p.Value.CreatureProfile is { HealthMax: > 0u } creature) combat.OnUpdateHealth( p.Value.Guid, Math.Clamp( (float)creature.Health / creature.HealthMax, 0f, 1f)); onAppraisal?.Invoke(p.Value); // 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). // The appraised spellbook belongs to that item. The local player's // learned spell manifest arrives only in PlayerDescription. }); // ── 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"; registrar.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; // R3: a trailer-truncated parse carries zero placeholder option // words, not server truth — the consumer must not arm the 0x01A1 // flush gate on them (RuntimeCharacterOptionsState.Replace). onCharacterOptions?.Invoke( p.Value.Options1, p.Value.Options2, p.Value.TrailerTruncated); onDesiredComponents?.Invoke(p.Value.DesiredComps); double receivedAt = clientTime(); ActiveEnchantmentRecord[] enchantments = p.Value.Enchantments .Select(entry => ToActiveEnchantment(entry, receivedAt)) .ToArray(); spellbook.ReplaceManifest( p.Value.Spells, enchantments, p.Value.HotbarSpells, p.Value.DesiredComps, p.Value.SpellbookFilters); // B-Wire: deliver the player's OWN properties to the player ClientObject. // (PD's "membership manifest" rule is about ITEMS, whose data comes from // CreateObject; the player's own stats legitimately come from PD.) Upsert // because PD can arrive before the player's CreateObject. Retires AP-48/AP-49. if (playerGuid is not null) items.UpsertProperties(playerGuid(), p.Value.Properties); // 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) { localPlayer.OnProperties(p.Value.Properties); localPlayer.OnPositions(p.Value.Positions.ToDictionary( static pair => pair.Key, static pair => new AcDream.Core.Physics.Position( pair.Value.LandblockId, new System.Numerics.Vector3( pair.Value.X, pair.Value.Y, pair.Value.Z), new System.Numerics.Quaternion( pair.Value.Qx, pair.Value.Qy, pair.Value.Qz, pair.Value.Qw)))); 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); } } } // 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 (localPlayer is not null || onSkillsUpdated is not null) { int runSkill = -1; int jumpSkill = -1; foreach (var s in p.Value.Skills) { // 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; localPlayer?.OnSkillUpdate( skillId: s.SkillId, ranks: s.Ranks, status: s.Status, xp: s.Xp, init: s.Init, resistance: s.Resistance, lastUsed: s.LastUsed, formulaBonus: formulaBonus); if (s.SkillId != 22u && s.SkillId != 24u) continue; 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?.Invoke(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). // D.5.4: PlayerDescription is a membership MANIFEST, not the data // source. Record existence (+ equip slot); CreateObject fills the // actual weenie data via ObjectTableWiring. (Previously this seeded // stubs with WeenieClassId = ContainerType, a misuse — ContainerType // is a 0/1/2 container-kind discriminator, not a weenie class id.) uint ownerGuid = playerGuid?.Invoke() ?? 0u; if (ownerGuid != 0u) { var entries = new ContainerContentEntry[p.Value.Inventory.Count]; for (int i = 0; i < entries.Length; i++) entries[i] = new ContainerContentEntry( p.Value.Inventory[i].Guid, p.Value.Inventory[i].ContainerType); items.InitializeInventoryManifest(ownerGuid, entries); } else { foreach (var inv in p.Value.Inventory) items.RecordMembership(inv.Guid, containerTypeHint: inv.ContainerType); } if (ownerGuid != 0u) { var equipment = new EquipmentManifestEntry[p.Value.Equipped.Count]; for (int i = 0; i < equipment.Length; i++) { var eq = p.Value.Equipped[i]; equipment[i] = new EquipmentManifestEntry( eq.Guid, (EquipMask)eq.EquipLocation, eq.Priority); } items.InitializeEquipmentManifest(ownerGuid, equipment); } else { foreach (var eq in p.Value.Equipped) { items.RecordMembership( eq.Guid, equip: (EquipMask)eq.EquipLocation, priority: eq.Priority); } } // D.5.1 Task 4: forward shortcut bar entries to the caller so the // toolbar can read them without holding a parser reference. onShortcuts?.Invoke(p.Value.Shortcuts); }); return construction.Complete(); } private sealed class OwnedGameEventRegistrar( GameEventDispatcher dispatcher, Func? accepting) : IDisposable { private readonly SubscriptionSet _subscriptions = new(); public void Register( GameEventType type, GameEventDispatcher.EventHandler handler) { GameEventDispatcher.EventHandler registered = accepting is null ? handler : envelope => { if (accepting()) handler(envelope); }; _subscriptions.Add(dispatcher.RegisterOwned(type, registered)); } public void Dispose() => _subscriptions.Dispose(); } private sealed class RegistrationBuildScope( OwnedGameEventRegistrar registration) : IDisposable { private bool _complete; public IDisposable Complete() { _complete = true; return registration; } public void Dispose() { if (!_complete) registration.Dispose(); } } private static ActiveEnchantmentRecord ToActiveEnchantment( PlayerDescriptionParser.EnchantmentEntry enchantment, double receivedAt) => new( SpellId: enchantment.SpellId, LayerId: enchantment.Layer, Duration: enchantment.Duration, CasterGuid: enchantment.CasterGuid, StatModType: enchantment.StatModType, StatModKey: enchantment.StatModKey, StatModValue: enchantment.StatModValue, Bucket: enchantment.Bucket == 0 ? ClassifyLiveEnchantmentBucket(enchantment.StatModType) : (uint)enchantment.Bucket, // Retail Enchantment::UnPack (0x005CB040) converts both relative // wire timestamps to the monotonic client Timer domain at receipt. StartTime: receivedAt + enchantment.StartTime, SpellCategory: enchantment.SpellCategory, PowerLevel: enchantment.PowerLevel, DegradeModifier: enchantment.DegradeModifier, DegradeLimit: enchantment.DegradeLimit, LastTimeDegraded: receivedAt + enchantment.LastTimeDegraded, SpellSetId: enchantment.SpellSetId); /// /// Live 0x02C2/0x02C4 records do not carry PlayerDescription's outer /// EnchantmentMask bucket. Retail reconstructs the registry list from the /// StatMod type flags; this is the same ordering used by ACE's /// EnchantmentRegistry.BuildCategories. /// private static uint ClassifyLiveEnchantmentBucket(uint statModType) { const uint Multiplicative = 0x00004000u; const uint Additive = 0x00008000u; const uint Vitae = 0x00800000u; const uint Cooldown = 0x01000000u; if ((statModType & Vitae) != 0) return 4u; if ((statModType & Cooldown) != 0) return 8u; if ((statModType & Multiplicative) != 0) return 1u; if ((statModType & Additive) != 0) return 2u; return 0u; } }