authored gmSecureTradeUI window, and both retail open paths
Three-lane research first (docs/research/2026-08-14-trade-lane{A,B,C}):
retail gmSecureTradeUI decode, the byte-exact ACE/decomp/holtburger
three-way wire agreement, and the acdream seam map (which found both
open paths ALREADY classified by the ported policy - OpenSecureTrade on
Use-a-player, StartSecureTrade on drag-item-onto-player with the
DragItemOnPlayerOpensSecureTrade option - dead-ending at a stub toast).
- Core.Net: TradeRequests builders (0x1F6-0x204, retail's CM_Trade
senders byte-checked against ACE's readers; the ACE-discarded
AcceptTrade echo carries zero-count item lists - AD-94), corrected +
completed inbound parsers (0x1FD-0x208; the old AddToTrade parser
missed the SIDE dword, TradeFailure missed the reason), delegate-hole
registrars, six WorldSession sends. 10 golden-byte tests.
- Runtime: RuntimeTradeState, the third sibling J-owner (fellowship/
allegiance shape): session-scoped, clears at generation reset (new
stage Trade=14), staged teardown stage 11 (Identity/EntityObjects
shift 12/13, TeardownStageCount 14 - the FA2-era per-stage-flag test
caught the mapping exactly as designed), combined ownership ledger,
event routing with ACE's wrong-initiator RegisterTrade landmine
honored (partner = whichever guid is not mine). 7 conformance tests.
- App: SecureTradeUiController binds the dedicated authored LayoutDesc
0x2100000D (root 0x1000007A - gmSecureTradeUI::PostInit's exact ids):
partner name/status/count/grid, the authored 'Trade' accept toggle
(accept <-> decline withdraw), 'Clear All' (ACE clears BOTH sides -
surfaced honestly), the X close, drop-on-your-grid staging, per-mode
accept cues (partner icon's authored Highlight state + Trade button
Selected latch). Mounted via the vendor recipe (nine-slice chrome,
hidden until RegisterTrade). ItemInteractionController's two policy
arms now raise SecureTradeRequested instead of the stub toast; the
drag path queues the dragged item until the window registers
(ClientTradeSystem::AttemptToTradeItem @0x0056DF80's shape).
Register: AD-94 (accept-echo zero-count lists), AD-95 (numeric-only
count texts pending template verification).
Suites: App 4,990/3, Core.Net 905, Runtime 1,626 - all green. The
panel itself is user-gate acceptance (two-client connected trade), the
#372-class lesson: fixture-green alone is not acceptance for a mount.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1209 lines
60 KiB
C#
1209 lines
60 KiB
C#
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)
|
||
{
|
||
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());
|
||
}
|
||
|
||
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;
|
||
}
|
||
}
|