acdream/src/AcDream.Core.Net/GameEventWiring.cs
Erik dd6c7e09f0 feat(net): Map/House panel — slice 4a, House wire parsing groundwork
Adds the outbound HouseQuery action (0x021E, ClientCommandRequests.
BuildHouseQuery / WorldSession.SendHouseQuery — ACE GameActionHouseQuery.
Handle reads no payload) and inbound parsers for all four House wire
opcodes GameEventType already defined (0x0225-0x0228, gmHouseUI::PostInit's
registered notice handlers): GameEvents.ParseHouseData (BuyTime/RentTime/
Type/MaintenanceFree/Buy list/Rent list/Position — the Position field
reuses CreateObject.ServerPosition's existing 32-byte Cell+Pos.XYZ+
Rotation.WXYZ shape rather than a new type), ParseHouseStatus (WeenieError
u32), ParseUpdateRentTime, ParseUpdateRentPayment. Wire shapes verified
against ACE's HouseDataExtensions/HousePaymentExtensions (references/ACE/
Source/ACE.Server/Network/Structure/HouseData.cs, HousePayment.cs) — noted
that ACE's own UpdateRentTime/UpdateRentPayment writers are stubs (always
0u / always an empty list), captured as such rather than assumed live.

GameEventWiring.WireAll gets four new optional delegate holes
(onHouseData/onHouseStatus/onHouseUpdateRentTime/onHouseUpdateRentPayment)
following the exact trade-family precedent — registered only when non-null,
every existing caller compiles unchanged.

This is the "enum/parser groundwork" half of Slice 4's pre-authorized
fallback. NOT included (filed as an ISSUES entry): a RuntimeHouseState
GameRuntime owner (construction-transaction ceremony, fault-injection
points, disposal/convergence tracking — the same weight as
RuntimeTradeState's integration, judged disproportionate for tonight
alongside the completed Map tab), HousePageController's real Lines/
OnShown wiring, the DisplayPurchaseTimeText port, and the six other
Display* line builders. The House tab currently mounts with genuinely
empty content, matching retail's own PostInit (verified via
MapHousePanelSlotProbeTests' live-DAT probe, not assumed).

9 new HouseEventsTests (parser round-trips + truncation), 1 new
GameEventWiringTests case (all four opcodes reach their callbacks).
Core.Net.Tests: 1004/1004 passed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 02:09:34 +02:00

1253 lines
62 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
/// <summary>
/// Central registration point that wires every parsed GameEvent from
/// <see cref="GameEventDispatcher"/> into the appropriate Core state
/// class (<see cref="ClientObjectTable"/>, <see cref="CombatState"/>,
/// <see cref="Spellbook"/>, <see cref="ChatLog"/>).
///
/// <para>
/// 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.
/// </para>
///
/// <para>
/// 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.
/// </para>
/// </summary>
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
// <c>resolveSkillFormulaBonus</c> 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<int /*runSkill*/, int /*jumpSkill*/>? onSkillsUpdated = null,
Func<uint /*skillId*/, IReadOnlyDictionary<uint, uint> /*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<IReadOnlyList<ShortcutEntry>>? 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<uint>? playerGuid = null,
Action<uint /*weenieError*/>? onUseDone = null,
Action<AppraiseInfoParser.Parsed>? onAppraisal = null,
ItemManaState? itemMana = null,
Action<GameEvents.CharacterConfirmationRequest>? onConfirmationRequest = null,
Action<GameEvents.CharacterConfirmationDone>? onConfirmationDone = null,
FriendsState? friends = null,
SquelchState? squelch = null,
Action<IReadOnlyList<(uint Id, uint Amount)>>? onDesiredComponents = null,
Action<uint /*options1*/, uint /*options2*/, bool /*trailerTruncated*/>? onCharacterOptions = null,
Func<double>? 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<string, RetailLogTextType>? onInterfaceText = null,
Func<bool>? 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<GameEvents.FellowshipFullUpdate>? onFellowshipFullUpdate = null,
Action<GameEvents.FellowshipUpdateFellow>? onFellowshipUpdateFellow = null,
Action<uint /*quitterGuid*/>? onFellowshipQuit = null,
Action<uint /*dismissedGuid*/>? onFellowshipDismiss = null,
Action? onFellowshipDisband = null,
Action<ClientCommandResponses.AllegianceUpdate>? onAllegianceUpdate = null,
Action<uint /*weenieError*/>? onAllegianceUpdateDone = null,
Action<uint /*weenieError*/>? onAllegianceUpdateAborted = null,
Action<GameEvents.AllegianceLoginNotification>? 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<GameEvents.RegisterTrade>? onTradeRegister = null,
Action<uint /*endReason*/>? onTradeClose = null,
Action<GameEvents.AddToTrade>? onTradeAdd = null,
Action<GameEvents.RemoveFromTrade>? onTradeRemove = null,
Action<uint /*whoAccepted*/>? onTradeAccept = null,
Action<uint /*whoDeclined*/>? onTradeDecline = null,
Action<uint /*whoReset*/>? onTradeReset = null,
Action<GameEvents.TradeFailure>? 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<GameEvents.HouseData>? onHouseData = null,
Action<uint /*weenieError*/>? onHouseStatus = null,
Action<uint /*rentTime*/>? onHouseUpdateRentTime = null,
Action<IReadOnlyList<GameEvents.HousePayment>>? 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 (0x01FD0x0208) ──────────────────────────
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 (0x02250x0228) ───────────────────────────
// 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<uint, uint>();
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<bool>? 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);
/// <summary>
/// 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.
/// </summary>
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;
}
}