acdream/src/AcDream.Core.Net/Messages/GameEvents.cs
Erik 7a0f836af5 fix(physics): AP-129 review fix - port CanMoveInto/IsAllowedIn, stop failing closed
Campaign P Slice P4 Opus review verdict: FIX-FIRST. RestrictionObjPrevalenceInspectionTests
(commit 3b5e0992) found 103,766 of 729,888 installed EnvCells (1,293 landblocks -
the whole housing estate) carry a baked RestrictionObj. The AP-71 gate's
unconditional fail-closed default (CanMoveInto unmodeled) would have locked
every apartment/cottage/villa interior for every player, including its own
owner - a live regression, not the "inert in dev content" the original
register row assumed.

Ports ACCWeenieObject::CanMoveInto (0x0058da40, pc:407982-408056) and
RestrictionDB::IsAllowedIn (0x005ae8f0, pc:444493-444516) verbatim into
ObjectInfo.CheckEntryRestrictions:
- owner_iid == 0 or == mover's own guid -> admit (open/owner)
- no RestrictionDB (retail _db == 0, i.e. never authored or not yet
  received) -> admit
- present RestrictionDB -> IsAllowedIn: open-to-public flag, OR mover
  shares the house's allegiance monarch, OR mover's own guid is a
  guest-table member
- unresolved restriction object -> fails CLOSED, exactly retail's own
  fallback when GetObjectA can't resolve it (pc:704-716)

Wire feed (Core.Net):
- CreateObject.cs: HouseOwner (WeenieHeaderFlag 0x02000000), HouseRestrictions
  (0x04000000), and Monarch (0x40) PWD-tail fields were parsed-and-skipped;
  now captured. Also fixes the HouseRestrictions PHashTable header
  misconception: the wire is ONE packed u32 (low 24 bits = entry count),
  not a separate count(u16)+numBuckets(u16) pair - verified against
  Chorizite's RestrictionDB.generated.cs. The old skip's byte-count
  happened to match for realistic guest-list sizes, but a future
  numBuckets value >255 would have corrupted the parse; now correct
  regardless.
- GameEvents.cs/GameEventWiring.cs: new House_UpdateRestrictions (0x0248)
  parser + wiring - retail's live guest-list refresh, whole-unit replace.
  No-ops if the house object hasn't arrived via CreateObject yet.
- ClientObject/WeenieData/ClientObjectTable: HouseOwnerId, MonarchId,
  Restrictions (new HouseRestrictionRecord) fields + merge-preserving
  Ingest + targeted UpdateHouseRestrictions.

Physics wiring:
- PhysicsEngine gains an Objects (ClientObjectTable?) property, mirroring
  the existing DataCache pattern - acdream's GetObjectA equivalent, used
  ONLY by the entry-restriction gate.
- RuntimeEntityObjectLifetime wires Physics.Engine.Objects = Objects in
  all three constructors, right alongside the table's own construction -
  the same canonical table every other subsystem borrows from, never a
  second one. This is the production fix: without it the gate still fails
  closed on every restricted cell (unresolvable object), so the wiring is
  load-bearing, not cosmetic.

Register: AP-129 narrowed (not retired) to the genuine remaining residual -
House_UpdateRestrictions' Sequence byte isn't used for staleness/reordering
rejection (low-probability, self-correcting), and outdoor CLandCell
restriction (a separate DAT structure) remains unported and unaffected by
this fix.

Tests: 15 new/updated in Ap71EntryRestrictionGateTests.cs (resolved-unowned
admits, owner admits, present-list-excluded blocks, present-list-included
admits, open-to-public admits, shared-allegiance-monarch admits, unresolved
blocks via null and via an empty table, plus two new end-to-end
PhysicsEngine.Objects-wired scenarios); 2 new CreateObject parser tests +
2 new GameEventWiring tests for the wire feed.

AcDream.Core.Tests: 4049 passed, 2 skipped, 0 failed.
AcDream.Core.Net.Tests: 761 passed, 0 skipped, 0 failed.
Complete solution suite: 9,961 total, 9,956 passed, 5 skipped, 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 11:36:11 +02:00

609 lines
26 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.Buffers.Binary;
using System.Collections.Generic;
using System.Text;
using AcDream.Core.Items;
namespace AcDream.Core.Net.Messages;
/// <summary>
/// Parser + record types for the most-used <see cref="GameEventType"/>
/// sub-opcodes inside the <c>0xF7B0</c> envelope. Each parser takes the
/// <see cref="GameEventEnvelope.Payload"/> slice (header stripped) and
/// returns a typed record or null on malformed payload.
///
/// <para>
/// References: r08 protocol atlas §4 (wire specs) + ACE
/// <c>GameEventChat.cs</c>, <c>GameEventTell.cs</c>,
/// <c>GameEventUpdateHealth.cs</c>, <c>GameEventWeenieError.cs</c>,
/// <c>GameEventCommunicationTransientString.cs</c>.
/// </para>
/// </summary>
public static class GameEvents
{
// ── Chat / communication ─────────────────────────────────────────────────
/// <summary>0x0147 ChannelBroadcast payload.</summary>
public readonly record struct ChannelBroadcast(
uint ChannelId,
string SenderName,
string Message);
public static ChannelBroadcast? ParseChannelBroadcast(ReadOnlySpan<byte> payload)
{
int pos = 0;
if (payload.Length < 4) return null;
uint channelId = BinaryPrimitives.ReadUInt32LittleEndian(payload);
pos += 4;
try
{
string sender = ReadString16L(payload, ref pos);
string message = ReadString16L(payload, ref pos);
return new ChannelBroadcast(channelId, sender, message);
}
catch { return null; }
}
/// <summary>0x02BD Tell payload.</summary>
public readonly record struct Tell(
string Message,
string SenderName,
uint SenderGuid,
uint TargetGuid,
uint ChatType);
public static Tell? ParseTell(ReadOnlySpan<byte> payload)
{
int pos = 0;
try
{
string message = ReadString16L(payload, ref pos);
string sender = ReadString16L(payload, ref pos);
if (payload.Length - pos < 12) return null;
uint senderGuid = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
uint targetGuid = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
uint chatType = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
return new Tell(message, sender, senderGuid, targetGuid, chatType);
}
catch { return null; }
}
/// <summary>
/// 0x02EB CommunicationTransientString payload: a bare string, and
/// nothing else.
///
/// <para>Three oracles agree there is no chat type on this wire. ACE's
/// <c>GameEvent/Events/GameEventCommunicationTransientString.cs</c> writes
/// exactly one <c>WriteString16L(message)</c>. Retail's handler
/// <c>ClientCommunicationSystem::Handle_Communication__TransientString</c>
/// (0x0057d460) takes a single
/// <c>AC1Legacy::PStringBase&lt;char&gt; const*</c> argument. holtburger
/// carries no type field for it either.</para>
///
/// <para>This parser previously demanded a trailing <c>u32 chatType</c>.
/// Because the string is padded to a 4-byte boundary, the remaining length
/// was always 0, so the guard tripped and every single transient string
/// was dropped.</para>
/// </summary>
public static string? ParseTransient(ReadOnlySpan<byte> payload)
{
int pos = 0;
try { return ReadString16L(payload, ref pos); }
catch { return null; }
}
/// <summary>0x0004 PopupString — modal dialog text.</summary>
public static string? ParsePopupString(ReadOnlySpan<byte> payload)
{
int pos = 0;
try { return ReadString16L(payload, ref pos); } catch { return null; }
}
/// <summary>
/// 0x01C3 QueryAgeResponse: target name (empty for self), then the
/// server-formatted played duration. Retail source:
/// <c>CM_Character::DispatchUI_QueryAgeResponse @ 0x006A2E40</c>.
/// </summary>
public readonly record struct QueryAgeResponse(string Name, string Age);
public static QueryAgeResponse? ParseQueryAgeResponse(ReadOnlySpan<byte> payload)
{
int pos = 0;
try
{
string name = ReadString16L(payload, ref pos);
string age = ReadString16L(payload, ref pos);
return new QueryAgeResponse(name, age);
}
catch { return null; }
}
// ── Errors ──────────────────────────────────────────────────────────────
/// <summary>0x028A WeenieError: generic game-logic failure code.</summary>
public static uint? ParseWeenieError(ReadOnlySpan<byte> payload)
{
if (payload.Length < 4) return null;
return BinaryPrimitives.ReadUInt32LittleEndian(payload);
}
/// <summary>0x028B WeenieErrorWithString.</summary>
public readonly record struct WeenieErrorWithString(uint ErrorCode, string Interpolation);
public static WeenieErrorWithString? ParseWeenieErrorWithString(ReadOnlySpan<byte> payload)
{
if (payload.Length < 4) return null;
uint code = BinaryPrimitives.ReadUInt32LittleEndian(payload);
int pos = 4;
try
{
string interp = ReadString16L(payload, ref pos);
return new WeenieErrorWithString(code, interp);
}
catch { return null; }
}
// ── Vitals / combat ─────────────────────────────────────────────────────
/// <summary>0x01C0 UpdateHealth: (guid, healthPercent 0..1).</summary>
public readonly record struct UpdateHealth(uint TargetGuid, float HealthPercent);
public static UpdateHealth? ParseUpdateHealth(ReadOnlySpan<byte> payload)
{
if (payload.Length < 8) return null;
uint guid = BinaryPrimitives.ReadUInt32LittleEndian(payload);
float pct = BinaryPrimitives.ReadSingleLittleEndian(payload.Slice(4));
return new UpdateHealth(guid, pct);
}
// ── Pings / misc ────────────────────────────────────────────────────────
/// <summary>0x01EA PingResponse has no payload; receipt is the acknowledgement.</summary>
public static bool ParsePingResponse(ReadOnlySpan<byte> payload)
=> payload.IsEmpty;
// ── Spells / magic ──────────────────────────────────────────────────────
/// <summary>0x02C1 MagicUpdateSpell: spell id added to spellbook.</summary>
public static uint? ParseMagicUpdateSpell(ReadOnlySpan<byte> payload)
{
if (payload.Length < 4) return null;
return BinaryPrimitives.ReadUInt32LittleEndian(payload);
}
// ── Combat notifications ────────────────────────────────────────────────
/// <summary>0x01AC VictimNotification - death message for the victim.</summary>
public readonly record struct VictimNotification(string DeathMessage);
public static VictimNotification? ParseVictimNotification(ReadOnlySpan<byte> payload)
{
int pos = 0;
try { return new VictimNotification(ReadString16L(payload, ref pos)); }
catch { return null; }
}
/// <summary>0x01AD KillerNotification - death message for the killer.</summary>
public readonly record struct KillerNotification(string DeathMessage);
public static KillerNotification? ParseKillerNotification(ReadOnlySpan<byte> payload)
{
int pos = 0;
try { return new KillerNotification(ReadString16L(payload, ref pos)); }
catch { return null; }
}
/// <summary>0x01B1 AttackerNotification - "you hit X".</summary>
public readonly record struct AttackerNotification(
string DefenderName,
uint DamageType,
double HealthPercent,
uint Damage,
uint Critical,
ulong AttackConditions);
public static AttackerNotification? ParseAttackerNotification(ReadOnlySpan<byte> payload)
{
int pos = 0;
try
{
string name = ReadString16L(payload, ref pos);
if (payload.Length - pos < 28) return null;
uint damageType = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
double pct = BinaryPrimitives.ReadDoubleLittleEndian(payload.Slice(pos)); pos += 8;
uint damage = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
uint crit = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
ulong cond = BinaryPrimitives.ReadUInt64LittleEndian(payload.Slice(pos)); pos += 8;
return new AttackerNotification(name, damageType, pct, damage, crit, cond);
}
catch { return null; }
}
/// <summary>0x01B2 DefenderNotification - "X hit you".</summary>
public readonly record struct DefenderNotification(
string AttackerName,
uint DamageType,
double HealthPercent,
uint Damage,
uint HitQuadrant,
uint Critical,
ulong AttackConditions);
public static DefenderNotification? ParseDefenderNotification(ReadOnlySpan<byte> payload)
{
int pos = 0;
try
{
string name = ReadString16L(payload, ref pos);
if (payload.Length - pos < 32) return null;
uint dtype = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
double pct = BinaryPrimitives.ReadDoubleLittleEndian(payload.Slice(pos)); pos += 8;
uint dmg = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
uint quad = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
uint crit = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
ulong cond = BinaryPrimitives.ReadUInt64LittleEndian(payload.Slice(pos)); pos += 8;
return new DefenderNotification(name, dtype, pct, dmg, quad, crit, cond);
}
catch { return null; }
}
/// <summary>0x01B3 EvasionAttackerNotification - "X evaded".</summary>
public static string? ParseEvasionAttackerNotification(ReadOnlySpan<byte> payload)
{
int pos = 0;
try { return ReadString16L(payload, ref pos); } catch { return null; }
}
/// <summary>0x01B4 EvasionDefenderNotification - "you evaded X".</summary>
public static string? ParseEvasionDefenderNotification(ReadOnlySpan<byte> payload)
{
int pos = 0;
try { return ReadString16L(payload, ref pos); } catch { return null; }
}
/// <summary>0x01B8 CombatCommenceAttack - empty payload.</summary>
public static bool ParseCombatCommenceAttack(ReadOnlySpan<byte> payload) => payload.Length == 0;
/// <summary>0x01A7 AttackDone - single WeenieError value.</summary>
public readonly record struct AttackDone(uint AttackSequence, uint WeenieError);
public static AttackDone? ParseAttackDone(ReadOnlySpan<byte> payload)
{
if (payload.Length < 4) return null;
return new AttackDone(0u, BinaryPrimitives.ReadUInt32LittleEndian(payload));
}
// ── Spell enchantments ──────────────────────────────────────────────────
/// <summary>
/// 0x02C3 MagicRemoveEnchantment — (layerId, spellId).
/// </summary>
public readonly record struct LayeredSpellId(ushort SpellId, ushort Layer)
{
public uint Packed => SpellId | ((uint)Layer << 16);
}
public readonly record struct MagicRemoveEnchantment(ushort SpellId, ushort Layer);
public static MagicRemoveEnchantment? ParseMagicRemoveEnchantment(ReadOnlySpan<byte> payload)
{
if (payload.Length < 4) return null;
return new MagicRemoveEnchantment(
BinaryPrimitives.ReadUInt16LittleEndian(payload),
BinaryPrimitives.ReadUInt16LittleEndian(payload.Slice(2)));
}
/// <summary>0x01A8 MagicRemoveSpell — spell id removed from spellbook.</summary>
public static uint? ParseMagicRemoveSpell(ReadOnlySpan<byte> payload)
{
if (payload.Length < 4) return null;
return BinaryPrimitives.ReadUInt32LittleEndian(payload);
}
/// <summary>
/// 0x02C2 MagicUpdateEnchantment — the Enchantment blob. Full layout
/// (ACE <c>Enchantment.Pack</c>) is ~80+ bytes of spell metadata +
/// stat mods. We expose the first few fields that drive the enchant
/// bar UI; the rest is available via the raw payload view.
/// </summary>
public static PlayerDescriptionParser.EnchantmentEntry? ParseMagicUpdateEnchantment(
ReadOnlySpan<byte> payload)
{
int position = 0;
try { return EnchantmentWireReader.Read(payload, ref position); }
catch (FormatException) { return null; }
}
public static IReadOnlyList<PlayerDescriptionParser.EnchantmentEntry>?
ParseMagicUpdateMultipleEnchantments(ReadOnlySpan<byte> payload)
{
int position = 0;
try { return EnchantmentWireReader.ReadList(payload, ref position); }
catch (FormatException) { return null; }
}
/// <summary>
/// 0x02C7 MagicDispelEnchantment — (layerId, spellId).
/// Structure matches MagicRemoveEnchantment.
/// </summary>
public static MagicRemoveEnchantment? ParseMagicDispelEnchantment(ReadOnlySpan<byte> payload)
=> ParseMagicRemoveEnchantment(payload);
public static IReadOnlyList<LayeredSpellId>? ParseMagicLayeredSpellList(
ReadOnlySpan<byte> payload)
{
if (payload.Length < 4) return null;
uint count = BinaryPrimitives.ReadUInt32LittleEndian(payload);
if (count > 0x4000 || payload.Length - 4 < checked((int)count * 4)) return null;
var result = new LayeredSpellId[count];
for (int i = 0; i < result.Length; i++)
{
int offset = 4 + i * 4;
result[i] = new LayeredSpellId(
BinaryPrimitives.ReadUInt16LittleEndian(payload.Slice(offset, 2)),
BinaryPrimitives.ReadUInt16LittleEndian(payload.Slice(offset + 2, 2)));
}
return result;
}
// ── Appraise / identify ─────────────────────────────────────────────────
/// <summary>0x00C9 IdentifyObjectResponse header.</summary>
public readonly record struct IdentifyResponseHeader(
uint Guid,
uint AppraiseFlags,
bool Success);
/// <summary>
/// Parse the header of an <c>IdentifyObjectResponse (0x00C9)</c>.
/// Full property-bundle deserialization (int / bool / float / string
/// tables per the AppraiseFlags bitfield) is a future pass; this
/// header alone is enough for the UI to display "Appraise complete
/// on target X" and to route into the repository.
/// </summary>
public static IdentifyResponseHeader? ParseIdentifyResponseHeader(ReadOnlySpan<byte> payload)
{
if (payload.Length < 12) return null;
uint guid = BinaryPrimitives.ReadUInt32LittleEndian(payload);
uint flags = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4));
uint success = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(8));
return new IdentifyResponseHeader(guid, flags, success != 0);
}
/// <summary>0x0023 WieldObject: server-driven equip.</summary>
public readonly record struct WieldObject(
uint ItemGuid,
uint EquipLoc);
public static WieldObject? ParseWieldObject(ReadOnlySpan<byte> payload)
{
if (payload.Length < 8) return null;
return new WieldObject(
BinaryPrimitives.ReadUInt32LittleEndian(payload),
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)));
}
/// <summary>0x0022 InventoryPutObjInContainer: server puts item into container slot.
/// 4 fields (ACE GameEventItemServerSaysContainId.cs): itemGuid, containerGuid,
/// placement, containerType. ContainerType (0=item,1=container,2=foci) confirmed
/// vs holtburger events.rs fixture (slot=3 type=1).</summary>
public readonly record struct InventoryPutObjInContainer(
uint ItemGuid,
uint ContainerGuid,
uint Placement,
uint ContainerType);
public static InventoryPutObjInContainer? ParsePutObjInContainer(ReadOnlySpan<byte> payload)
{
if (payload.Length < 16) return null;
return new InventoryPutObjInContainer(
BinaryPrimitives.ReadUInt32LittleEndian(payload),
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)),
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(8)),
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(12)));
}
/// <summary>0x0196 ViewContents: full contents list of a container you opened.
/// Layout (ACE GameEventViewContents.cs): containerGuid, count, [guid, containerType]×count.
/// Client consumer: ClientUISystem::OnViewContents (PackableList&lt;ContentProfile&gt;).</summary>
public readonly record struct ViewContentsEntry(uint Guid, uint ContainerType);
public readonly record struct ViewContents(uint ContainerGuid, System.Collections.Generic.IReadOnlyList<ViewContentsEntry> Items);
public static ViewContents? ParseViewContents(ReadOnlySpan<byte> payload)
{
if (payload.Length < 8) return null;
uint containerGuid = BinaryPrimitives.ReadUInt32LittleEndian(payload);
uint count = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4));
int pos = 8;
if ((long)payload.Length - pos < (long)count * 8) return null;
var items = new ViewContentsEntry[count];
for (int i = 0; i < count; i++)
{
uint guid = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
uint type = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
items[i] = new ViewContentsEntry(guid, type);
}
return new ViewContents(containerGuid, items);
}
// ── Other small-payload events ──────────────────────────────────────────
/// <summary>0x01C7 UseDone: the Use/UseWithTarget completion signal (WeenieError code).</summary>
public static uint? ParseUseDone(ReadOnlySpan<byte> payload)
{
if (payload.Length < 4) return null;
return BinaryPrimitives.ReadUInt32LittleEndian(payload);
}
/// <summary>0x019A InventoryPutObjectIn3D: server dropped item to ground.</summary>
public static uint? ParsePutObjectIn3D(ReadOnlySpan<byte> payload)
{
if (payload.Length < 4) return null;
return BinaryPrimitives.ReadUInt32LittleEndian(payload);
}
/// <summary>0x00A0 InventoryServerSaveFailed: revert a speculative local inventory op.
/// (itemGuid, weenieError) — ACE GameEventInventoryServerSaveFailed.cs; holtburger
/// events.rs:147 reads both fields.</summary>
public readonly record struct InventoryServerSaveFailed(uint ItemGuid, uint WeenieError);
public static InventoryServerSaveFailed? ParseInventoryServerSaveFailed(ReadOnlySpan<byte> payload)
{
if (payload.Length < 8) return null;
return new InventoryServerSaveFailed(
BinaryPrimitives.ReadUInt32LittleEndian(payload),
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)));
}
/// <summary>0x0052 CloseGroundContainer: server closed a ground container view.</summary>
public static uint? ParseCloseGroundContainer(ReadOnlySpan<byte> payload)
{
if (payload.Length < 4) return null;
return BinaryPrimitives.ReadUInt32LittleEndian(payload);
}
/// <summary>0x0207 TradeFailure: server trade error code.</summary>
public static uint? ParseTradeFailure(ReadOnlySpan<byte> payload)
{
if (payload.Length < 4) return null;
return BinaryPrimitives.ReadUInt32LittleEndian(payload);
}
/// <summary>0x0200 AddToTrade: (itemGuid, slotIndex).</summary>
public readonly record struct AddToTrade(uint ItemGuid, uint SlotIndex);
public static AddToTrade? ParseAddToTrade(ReadOnlySpan<byte> payload)
{
if (payload.Length < 8) return null;
return new AddToTrade(
BinaryPrimitives.ReadUInt32LittleEndian(payload),
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)));
}
/// <summary>0x0202 AcceptTrade: initiator guid.</summary>
public static uint? ParseAcceptTrade(ReadOnlySpan<byte> payload)
{
if (payload.Length < 4) return null;
return BinaryPrimitives.ReadUInt32LittleEndian(payload);
}
/// <summary>
/// 0x0264 QueryItemManaResponse: (itemGuid, manaPercent, valid).
/// Retail anchor: <c>CM_Item::DispatchUI_QueryItemManaResponse @ 0x006A84D0</c>
/// reads the trailing 32-bit validity flag at message offset 0x0C.
/// </summary>
public readonly record struct QueryItemManaResponse(uint ItemGuid, float ManaPercent, bool Valid);
public static QueryItemManaResponse? ParseQueryItemManaResponse(ReadOnlySpan<byte> payload)
{
if (payload.Length < 12) return null;
return new QueryItemManaResponse(
BinaryPrimitives.ReadUInt32LittleEndian(payload),
BinaryPrimitives.ReadSingleLittleEndian(payload.Slice(4)),
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(8)) != 0);
}
/// <summary>0x0274 CharacterConfirmationRequest — server-driven modal confirm.</summary>
public readonly record struct CharacterConfirmationRequest(
uint Type,
uint ContextId,
string Message);
public static CharacterConfirmationRequest? ParseCharacterConfirmationRequest(ReadOnlySpan<byte> payload)
{
if (payload.Length < 8) return null;
int pos = 0;
uint type = BinaryPrimitives.ReadUInt32LittleEndian(payload); pos += 4;
uint contextId = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
try
{
string msg = ReadString16L(payload, ref pos);
return new CharacterConfirmationRequest(type, contextId, msg);
}
catch { return null; }
}
/// <summary>
/// 0x0276 CharacterConfirmationDone — server cancellation/completion of the
/// outstanding confirmation tuple. Retail dispatches the same type/context
/// pair to <c>RecvNotice_AbortConfirmationRequest</c>.
/// </summary>
public readonly record struct CharacterConfirmationDone(uint Type, uint ContextId);
public static CharacterConfirmationDone? ParseCharacterConfirmationDone(
ReadOnlySpan<byte> payload)
{
if (payload.Length < 8) return null;
return new CharacterConfirmationDone(
BinaryPrimitives.ReadUInt32LittleEndian(payload),
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)));
}
// ── House ────────────────────────────────────────────────────────────────
/// <summary>
/// 0x0248 House_UpdateRestrictions: retail's live refresh of a house
/// object's guest/ban list (whole-unit replace, not a delta). Wire shape
/// confirmed verbatim against <c>references/Chorizite.ACProtocol
/// /Chorizite.ACProtocol/Messages/S2C/Events/House_UpdateRestrictions
/// .generated.cs</c>: <c>byte Sequence, uint SenderId, RestrictionDB
/// Restrictions</c> — Sequence is a single unpadded byte, immediately
/// followed by the 4-byte SenderId (the house object whose restrictions
/// changed).
/// </summary>
public readonly record struct HouseUpdateRestrictions(
byte Sequence,
uint SenderId,
HouseRestrictionRecord Restrictions);
public static HouseUpdateRestrictions? ParseHouseUpdateRestrictions(ReadOnlySpan<byte> payload)
{
// Sequence(1) + SenderId(4) + RestrictionDB{Version(4)+Flags(4)+MonarchId(4)+PHashTable-header(4)} = 21
if (payload.Length < 21) return null;
int pos = 0;
byte sequence = payload[pos]; pos += 1;
uint senderId = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
pos += 4; // Version — not consulted
uint flags = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
uint allegianceMonarchId = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
uint packedSize = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
uint entryCount = packedSize & 0xFFFFFFu;
long entryBytes = (long)entryCount * 8;
if (payload.Length - pos < entryBytes) return null;
var guests = new Dictionary<uint, uint>((int)entryCount);
for (uint i = 0; i < entryCount; i++)
{
uint guestId = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
uint permission = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
guests[guestId] = permission;
}
return new HouseUpdateRestrictions(
sequence,
senderId,
new HouseRestrictionRecord(
OpenToPublic: flags != 0,
AllegianceMonarchId: allegianceMonarchId,
Guests: guests));
}
// ── Shared string reader (matches LoginRequest.ReadString16L) ───────────
private static string ReadString16L(ReadOnlySpan<byte> source, ref int pos)
{
if (source.Length - pos < 2) throw new FormatException("truncated String16L length");
ushort length = BinaryPrimitives.ReadUInt16LittleEndian(source.Slice(pos));
pos += 2;
if (source.Length - pos < length) throw new FormatException("truncated String16L body");
// Windows-1252 matches retail (and holtburger's encoding_rs::WINDOWS_1252).
string result = Encoding.GetEncoding(1252).GetString(source.Slice(pos, length));
pos += length;
int recordSize = 2 + length;
int padding = (4 - (recordSize & 3)) & 3;
pos += padding;
return result;
}
}