fix(chat): Campaign CH user-gate round 1 — jump-in-air edge, portal cue cadence, wrap/prefix/color fixes

The user tested Campaign CH's CODE-COMPLETE build live and reported ten
defects (docs/plans/2026-08-09-chat-parity-campaign.md, "User gate —
round 1"). Items A-G are fixed here; the remaining three (extra chat
windows on 1/2/3/4, resize working in only one corner, transparency/
artifacts) are out of scope for a fix and filed as slice CH6.

A. Jump-in-air refusal never fired live: the jump block only ever
   evaluated input.Jump inside the grounded-charge or already-charging
   branches. PlayerMovementController now detects the press RISING EDGE
   while airborne and reports WeenieError.NotGrounded once per press,
   leaving the grounded charge/fire path untouched.
B. ChatVM's invented "[System] " prefix is dropped — retail prints
   system text bare. [Popup] is unchanged (AP-175).
C. SpewBoxController's color is now the user-pinned exact value
   (1, 1, 0.247, 1), the same bright yellow as an incoming Tell.
   Register row AP-178 updated: color CLOSES, size/position/font stay
   open per the user's live report that they still differ.
D. Closes #329: PortalTunnelPresentation now emits the portal wait cue
   unconditionally on every rotation-segment boundary, matching
   gmSmartBoxUI::UseTime's decompiled else-arm exactly instead of gating
   on a 5-second hold local transits never reached. PortalWaitNotice
   Controller now renders it in the same pinned yellow as item C.
   Register row AP-150 retired.
E. Closes #362: new ClientCommandResponses.cs parses and renders the
   four previously-unhandled inbound GameEvents (ChannelIndex,
   ChannelList, AvailableHouses, AllegianceInfoResponse), each ported
   line-for-line from the named-retail decomp's inbound handlers.
   Register row TS-70 retired.
F. ChatWindowController.WrapText now splits on embedded '\n'/'\r\n'
   first, then word-wraps each segment independently — server text like
   /help's reply no longer collapses onto one line.
G. The chat input field's right edge no longer holds a fixed absolute
   pixel position across a window resize; Bind now upgrades it to
   retail edge-mode 1 (UiLayoutPolicy) or the AnchorEdges.Right stretch
   fallback so it tracks the window's client width instead of
   overflowing past a narrower resize.

Full Release suite: 12,247 passed / 4 skipped / 0 failed (baseline
12,221/4/0 + 26 new tests across items A, E, F, G).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-09 23:42:32 +02:00
parent d1c1368a5e
commit 47e40900f3
17 changed files with 1428 additions and 81 deletions

View file

@ -260,7 +260,7 @@ public sealed class PortalTunnelPresentation : IDisposable
_rotationEndAngle = 0f;
_rotationCurrentAngle = 0f;
_camera.DirectionDegrees = 0f;
SetWaitCue(false);
ClearWaitCueNotice();
_visible = true;
RebuildPose();
}
@ -271,7 +271,7 @@ public sealed class PortalTunnelPresentation : IDisposable
if (_disposed)
return;
_visible = false;
SetWaitCue(false);
ClearWaitCueNotice();
_animationHooks.Clear();
_sequence.ClearAnimations();
}
@ -287,6 +287,18 @@ public sealed class PortalTunnelPresentation : IDisposable
TickRotation(dt);
}
/// <summary>
/// The hold-delay-gated arm/disarm <c>LocalPlayerTeleportController</c>
/// still drives every frame from <c>RuntimeWorldTransitState.ObserveWait</c>
/// (own telemetry: <c>RuntimePortalSnapshot.WaitCueShown</c>). This is
/// deliberately NOT the retail cue-emission path any more — see
/// <see cref="TickRotation"/>'s unconditional per-segment write (item D,
/// #329). Kept only so the controller's own hold bookkeeping still has
/// somewhere to land; because <c>visible</c> is false for the entire
/// common case (a transit that never crosses the invented 5-second
/// hold), this is a same-value no-op there and never contends with the
/// per-segment write above.
/// </summary>
public void SetWaitCue(bool visible)
{
if (_waitCueVisible == visible)
@ -297,6 +309,21 @@ public sealed class PortalTunnelPresentation : IDisposable
visible ? "In Portal Space - Please Wait..." : null);
}
/// <summary>
/// Unconditionally hides any wait-cue notice text and resets the
/// hold-delay dedup state, independent of <see cref="_waitCueVisible"/>'s
/// current value. <see cref="TickRotation"/> now writes the notice text
/// directly (bypassing <see cref="SetWaitCue"/>'s dedup), so the old
/// `SetWaitCue(false)` calls at Enter/Exit/Dispose could no-op and leave
/// a stale "In Portal Space..." line on screen after the presentation
/// went invisible — this always clears it.
/// </summary>
private void ClearWaitCueNotice()
{
_waitCueVisible = false;
_displayNotice?.Invoke(null);
}
/// <summary>
/// Draw retail portal space into the active viewport. The caller suppresses
/// the normal world viewport while this scene is visible, then draws the
@ -377,8 +404,29 @@ public sealed class PortalTunnelPresentation : IDisposable
_rotationDuration = NextDouble(RotationDurationMin, RotationDurationMax);
_rotationStartAngle = _rotationCurrentAngle;
_rotationEndAngle = (float)NextDouble(0.0, 360.0);
if (_waitCueVisible)
_displayNotice?.Invoke("In Portal Space - Please Wait...");
// Campaign CH user-gate round 1 (item D, #329): retail's
// gmSmartBoxUI::UseTime @0x004D6E30 emits
// ECM_UI::SendNotice_DisplayStringInfo(0x1a, "In Portal Space -
// Please Wait...") in the else arm of the rotation-segment-
// expiry test at 0x004D6FCD UNCONDITIONALLY -- every time a
// segment expires, with no hold/threshold check anywhere in
// that decompiled function. acdream's own RotationDurationMin/
// Max already match retail's RandDouble(0.6, 1.8) segment
// window decoded at 0x004D6FE6; the only bug was gating this
// call on `_waitCueVisible`, which only ever became true after
// RuntimeWorldTransitState.RetailWaitCueDelay's invented 5-
// second hold -- a threshold most local transits never reach,
// so the cue silently never fired. This write is deliberately
// independent of `_waitCueVisible`/SetWaitCue (see that
// method's own doc comment): LocalPlayerTeleportController
// still drives SetWaitCue every frame from its own hold-delay
// bookkeeping, but because that call is a same-value no-op for
// the entire common case (a transit that never crosses the 5s
// hold), it never fights this unconditional per-segment write.
// Enter/Exit/Dispose clear the notice directly (not through
// SetWaitCue's dedup) so a stale line can never survive past
// this presentation going invisible.
_displayNotice?.Invoke("In Portal Space - Please Wait...");
}
else
{
@ -469,7 +517,7 @@ public sealed class PortalTunnelPresentation : IDisposable
try
{
_visible = false;
SetWaitCue(false);
ClearWaitCueNotice();
_animationHooks.Clear();
_sequence.ClearAnimations();
_meshReferences.Dispose();

View file

@ -238,6 +238,38 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
c.Input.SpriteResolve = resolve;
c.Input.OnSubmit = text => ChatCommandRouter.Submit(text, vm, busProvider(), c._activeChannel);
// Campaign CH user-gate round 1 (item G): the imported field's right
// edge otherwise holds a FIXED absolute pixel position across a
// window resize — retail edge-mode 0's "frozen at current" fallback
// (UiLayoutPolicy.ApplyFar), or the AnchorEdges default (Left|Top,
// no stretch) when this field imported without a LayoutPolicy at
// all. ReflowInputRow below only repositions Left/Width at bind
// time and on channel change; nothing re-runs it on a plain window
// RESIZE, so shrinking the window below its authored width left the
// input's right edge frozen past the new, narrower client area —
// the reported overflow. Retail edge-mode 1 on a FAR edge
// ("originalEdge + parentDelta", UiLayoutPolicy.ApplyFar) keeps a
// CONSTANT MARGIN from the parent's right edge instead, so the
// field's right edge now tracks every resize, not just
// bind/channel-change moments; the compatibility AnchorEdges.Right
// stretch is the equivalent programmatic-widget fallback. Only the
// right-edge behavior changes — Left/Top/Bottom stay whatever the
// DAT authored (or the AnchorEdges default).
if (c.Input.LayoutPolicy is { } inputPolicy)
{
c.Input.LayoutPolicy = new UiLayoutPolicy(
inputPolicy.LeftMode,
inputPolicy.TopMode,
rightMode: 1u,
inputPolicy.BottomMode,
inputPolicy.OriginalChild,
inputPolicy.OriginalParent);
}
else
{
c.Input.Anchors |= AnchorEdges.Right;
}
// ── Scrollbar — bind the factory-built Type-11 track element ────────
// The factory now builds the Type-11 track element (0x10000012) as a UiScrollbar
// directly. Find it, bind it in place — no remove/add needed.
@ -508,9 +540,40 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
/// </summary>
public static IEnumerable<string> WrapText(string text, float maxW, Func<string, float> measure)
{
if (string.IsNullOrEmpty(text) || maxW <= 0f || measure(text) <= maxW)
if (string.IsNullOrEmpty(text))
{
yield return text ?? string.Empty;
yield return string.Empty;
yield break;
}
// Campaign CH user-gate round 1 (item F): server text (e.g. /help's
// reply) carries embedded '\n's. This function used to hand the
// WHOLE blob — newlines and all — to the single early-out below,
// rendering multi-line text as one UiText.Line with literal newline
// characters in it instead of one rendered line per segment. Split
// on '\n' FIRST (normalizing "\r\n"/bare "\r" the same way), then
// word-wrap each segment independently; the early-out is now scoped
// to one already-newline-free segment, so it only ever collapses a
// single-segment text to one line, never a multi-line one.
string normalized = text.Replace("\r\n", "\n").Replace('\r', '\n');
foreach (string segment in normalized.Split('\n'))
{
foreach (string frag in WrapSingleLine(segment, maxW, measure))
yield return frag;
}
}
/// <summary>
/// Greedy word-wrap for a single, already newline-free line. Split out of
/// <see cref="WrapText"/> (Campaign CH user-gate round 1, item F) so the
/// multi-segment split there can call this once per '\n'-delimited
/// segment without re-deriving the per-line wrap algorithm.
/// </summary>
private static IEnumerable<string> WrapSingleLine(string text, float maxW, Func<string, float> measure)
{
if (text.Length == 0 || maxW <= 0f || measure(text) <= maxW)
{
yield return text;
yield break;
}

View file

@ -9,6 +9,16 @@ namespace AcDream.App.UI;
/// </summary>
internal sealed class PortalWaitNoticeController : IDisposable
{
/// <summary>
/// Register row AP-150/AP-178: CH user-gate round 1 (2026-08-09) PINNED
/// this — the user confirmed live, side-by-side against retail, that
/// this notice renders in the same bright yellow as an incoming Tell
/// (<c>0x81C4C8</c>, <c>RetailChatColorTable.Yellow</c> =
/// <c>(1, 1, 0.247, 1)</c>), not white. Same exact value as the
/// SpewBox's pinned colour (<see cref="AcDream.App.UI.SpewBoxController"/>).
/// </summary>
private static readonly Vector4 RetailWaitCueColor = new(1f, 1f, 0.247f, 1f);
private readonly UiRoot _root;
private readonly UiText _text;
private UiText.Line[] _lines = [];
@ -32,7 +42,7 @@ internal sealed class PortalWaitNoticeController : IDisposable
OneLine = true,
ClickThrough = true,
ZOrder = int.MaxValue,
DefaultColor = Vector4.One,
DefaultColor = RetailWaitCueColor,
Visible = false,
};
_text.LinesProvider = () => _lines;
@ -49,7 +59,7 @@ internal sealed class PortalWaitNoticeController : IDisposable
return;
}
_lines = [new UiText.Line(message, Vector4.One)];
_lines = [new UiText.Line(message, RetailWaitCueColor)];
_text.Visible = true;
}

View file

@ -106,23 +106,22 @@ internal sealed class SpewBoxController : IDisposable
private const float SpewBoxHeight = 72f;
/// <summary>
/// Register row AP-178 (colour): retail's authored colour for THIS
/// element remains unresolved — the LayoutDesc dump (see class remarks)
/// found only two direct-state properties on the SpewBox element/ListBox
/// (a bool at <c>0x3B</c> and the <c>MaxConcurrentItems</c> integer at
/// <c>0x10000028</c>); no colour property surfaced in that direct-state
/// dump, and the per-<c>UIStateId</c> <c>States</c> dictionary (hover/
/// pressed/etc. variants, which could carry it) was not walked this
/// pass. The chat colour table's <c>0x1A</c> entry
/// (<c>colorBrightRed</c>) is explicitly NOT this — retail's own
/// Register row AP-178 (colour): CH user-gate round 1 (2026-08-09)
/// PINNED this — the user confirmed live, side-by-side against retail,
/// that the on-screen SpewBox text is the same bright yellow as an
/// incoming Tell (<c>0x81C4C8</c>, <c>RetailChatColorTable.Yellow</c> =
/// <c>(1, 1, 0.247, 1)</c>). The chat colour table's <c>0x1A</c> entry
/// (<c>colorBrightRed</c>) is still explicitly NOT this — retail's own
/// <c>BuildChatColorLookupTable</c> writes to <c>ChatInterface::m_chatLog</c>,
/// a completely different element tree the SpewBox never touches
/// (research doc §3.2.3). This warm-yellow placeholder follows the
/// user's own recollection of the retail SpewBox's colour (unconfirmed
/// by any decompiled or DAT-authored source) rather than an arbitrary
/// choice.
/// (research doc §3.2.3); the LayoutDesc dump (see class remarks) also
/// never surfaced a colour property for this element. The exact retail
/// value simply happens to coincide with the Tell colour, per the user's
/// live observation. SIZE/POSITION/FONT remain OPEN — the user reports
/// all three still differ from retail; user gate round 1: differs,
/// iterating.
/// </summary>
private static readonly Vector4 SpewBoxColor = new(1f, 1f, 0.4f, 1f);
private static readonly Vector4 SpewBoxColor = new(1f, 1f, 0.247f, 1f);
private readonly UiRoot _root;
private readonly UiText _text;

View file

@ -161,6 +161,42 @@ public static class GameEventWiring
// 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);
});
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);
});
if (onConfirmationRequest is not null)
{
registrar.Register(GameEventType.CharacterConfirmationRequest, e =>

View file

@ -0,0 +1,406 @@
using System;
using System.Buffers.Binary;
using System.Collections.Generic;
using AcDream.Core.Ui;
namespace AcDream.Core.Net.Messages;
/// <summary>
/// Inbound parsers + retail-shaped text rendering for the four CH4
/// client-command GameEvent responses that had a request builder
/// (<see cref="ClientCommandRequests"/>) but no inbound handler — issue
/// #362 / register row TS-70. ACE's server-side writers are the wire-shape
/// oracle (cited per method); the retail CLIENT's decompiled handlers are
/// the oracle for what text gets printed and in what order — see
/// <c>docs/research/named-retail/acclient_2013_pseudo_c.txt</c> anchors
/// cited per method.
/// </summary>
public static class ClientCommandResponses
{
// ── 0x0149 ChannelIndex / 0x0148 ChannelList ───────────────────────────
// ACE: GameEventChannelIndex.cs / GameEventChannelList.cs — both write
// `Writer.Write(count)` (u32) then `count` WriteString16L entries; no
// other framing. Retail: ClientCommunicationSystem::
// Handle_Communication__ChannelIndex @0x0057d0c0 /
// Handle_Communication__ChannelList @0x0057d230 — both parse a single
// PackableList<PStringBase> the same way.
/// <summary>0x0149 ChannelIndex: the list of GM/staff channel names available to this account.</summary>
public static IReadOnlyList<string>? ParseChannelIndex(ReadOnlySpan<byte> payload) =>
ParseStringList(payload);
/// <summary>0x0148 ChannelList: the list of character names currently listening on the queried channel.</summary>
public static IReadOnlyList<string>? ParseChannelList(ReadOnlySpan<byte> payload) =>
ParseStringList(payload);
private static IReadOnlyList<string>? ParseStringList(ReadOnlySpan<byte> payload)
{
try
{
int pos = 0;
uint count = ReadU32(payload, ref pos);
var list = new List<string>();
for (uint i = 0; i < count; i++)
list.Add(StringReader.ReadString16L(payload, ref pos));
return list;
}
catch (FormatException) { return null; }
}
/// <summary>
/// Retail-shaped lines for a ChannelIndex response — verbatim port of
/// <c>Handle_Communication__ChannelIndex</c>'s header + per-entry loop
/// (all LogTextType 0x00 Default). Header text at
/// <c>docs/research/named-retail/acclient_2013_pseudo_c.txt:1031363</c>
/// (<c>data_7e0b10</c>).
/// </summary>
public static IEnumerable<string> FormatChannelIndexLines(IReadOnlyList<string> channels)
{
yield return "The following channels are available to you:";
foreach (string channel in channels)
yield return channel;
}
/// <summary>
/// Retail-shaped lines for a ChannelList response — verbatim port of
/// <c>Handle_Communication__ChannelList</c>. Header text at
/// <c>acclient_2013_pseudo_c.txt:1031367</c> (<c>data_7e0b40</c>).
/// </summary>
public static IEnumerable<string> FormatChannelListLines(IReadOnlyList<string> names)
{
yield return "The following characters are currently listening on the channel:";
foreach (string name in names)
yield return name;
}
// ── 0x0271 AvailableHouses ──────────────────────────────────────────────
// ACE: GameEventHouseAvailableHouses.cs — Write((uint)type) +
// Write(locations: List<uint>, PackableList.cs:20 — u32 count then N u32
// landblock ids) + Write(totalAvailable: int). Retail:
// ClientHousingSystem::Handle_House__Recv_AvailableHouses @0x00585d50 +
// DisplayListOfCoords @0x00585c20.
public readonly record struct AvailableHousesResponse(
uint HouseType,
IReadOnlyList<uint> Locations,
int TotalAvailable);
public static AvailableHousesResponse? ParseAvailableHouses(ReadOnlySpan<byte> payload)
{
try
{
int pos = 0;
uint houseType = ReadU32(payload, ref pos);
uint count = ReadU32(payload, ref pos);
var locations = new uint[count];
for (uint i = 0; i < count; i++)
locations[i] = ReadU32(payload, ref pos);
int totalAvailable = unchecked((int)ReadU32(payload, ref pos));
return new AvailableHousesResponse(houseType, locations, totalAvailable);
}
catch (FormatException) { return null; }
}
/// <summary>
/// ACE <c>HouseType</c> enum values (Undef=0, Cottage=1, Villa=2,
/// Mansion=3, Apartment=4) — matches the switch at
/// <c>acclient_2013_pseudo_c.txt:400205-400227</c> exactly (case 1..4;
/// out-of-range/0 leaves the type name empty, matching retail's
/// skipped-`if` fallthrough).
/// </summary>
private static string HouseTypeName(uint houseType) => houseType switch
{
1u => "cottages",
2u => "villas",
3u => "mansions",
4u => "apartments",
_ => "",
};
/// <summary>
/// Retail-shaped lines for an AvailableHouses response. Verbatim port of
/// <c>Handle_House__Recv_AvailableHouses</c> @0x00585d50 +
/// <c>DisplayListOfCoords</c> @0x00585c20: the summary line, then one
/// coordinate line per location UNLESS the type is Apartment (retail
/// skips <c>DisplayListOfCoords</c> entirely for <c>arg2 == 4</c> —
/// apartments have no world location), then the &gt;400-locations
/// truncation notice (<c>data_7e1d70</c>,
/// <c>acclient_2013_pseudo_c.txt:1032459</c>) if <c>TotalAvailable &gt; 0x190</c>.
/// All lines are LogTextType 0x00 Default. Coordinate formatting is
/// <see cref="AcDream.Core.Ui.RadarCoordinates"/> — the same
/// <c>(lcoord - 1024) * 0.1 + 0.5</c> port
/// <c>CPlayerSystem::InqPlayerCoords</c> uses — with retail's own
/// 5-space indent and "Y, X" order
/// (<c>" %.1f%s, %.1f%s\n"</c>, args <c>Y, Ysuffix, X</c>[, Xsuffix]).
/// </summary>
public static IEnumerable<string> FormatAvailableHousesLines(AvailableHousesResponse response)
{
yield return string.Create(
System.Globalization.CultureInfo.InvariantCulture,
$"There are {response.TotalAvailable} {HouseTypeName(response.HouseType)} available.");
if (response.HouseType != 4u)
{
foreach (uint landblockId in response.Locations)
{
if (RadarCoordinates.TryFromCell(landblockId, out var coordinates))
yield return $" {coordinates.YText}, {coordinates.XText}";
}
if (response.TotalAvailable > 0x190)
yield return "There were too many houses to display all the locations. Only the first 400 locations are displayed here.";
}
}
// ── 0x027C AllegianceInfoResponse ───────────────────────────────────────
// ACE: GameEventAllegianceInfoResponse.cs -> AllegianceProfileExtensions.
// Write / AllegianceHierarchyExtensions.Write / AllegianceDataExtensions.
// Write. Retail: ClientAllegianceSystem::
// Handle_Allegiance__AllegianceInfoResponseEvent @0x0056a1d0, walking
// AllegianceProfile::GetData/GetPatron/GetFirstVassal/GetNextVassal.
/// <summary>
/// One retail <c>AllegianceData</c> record. <paramref name="ParentGuid"/>
/// is the wire's "treeParent" tag (0 for the monarch, who has none) —
/// retail's own <c>GetPatron</c>/<c>GetFirstVassal</c> walk the flat
/// record list by this tag rather than storing an explicit tree.
/// </summary>
public readonly record struct AllegianceMemberRecord(
uint CharacterId,
uint ParentGuid,
bool IsLoggedIn,
string Name);
/// <summary>
/// <see cref="AllegianceIndex.LoggedIn"/>/<c>HasAllegianceAge</c>/
/// <c>HasPackedLevel</c> bit values — ACE
/// <c>Source/ACE.Server/Network/Enum/AllegianceIndex.cs</c>.
/// </summary>
private const uint LoggedInBit = 0x1u;
private const uint HasAllegianceAgeBit = 0x4u;
private const uint HasPackedLevelBit = 0x8u;
public readonly record struct AllegianceInfoResponse(
uint TargetGuid,
uint TotalMembers,
uint TotalVassals,
ushort RecordCount,
string AllegianceName,
AllegianceMemberRecord? Monarch,
IReadOnlyList<AllegianceMemberRecord> Records)
{
/// <summary>
/// Port of <c>AllegianceProfile::GetData</c>: find the record
/// (monarch or otherwise) whose own <c>characterID</c> matches
/// <paramref name="guid"/>.
/// </summary>
public AllegianceMemberRecord? FindData(uint guid)
{
if (Monarch is { } monarch && monarch.CharacterId == guid)
return monarch;
foreach (AllegianceMemberRecord record in Records)
{
if (record.CharacterId == guid)
return record;
}
return null;
}
/// <summary>
/// Port of <c>AllegianceProfile::GetPatron</c>: the monarch has no
/// patron; anyone else's patron is <see cref="FindData"/> applied to
/// their own record's <see cref="AllegianceMemberRecord.ParentGuid"/>
/// (which is the monarch's own guid when the patron IS the monarch —
/// ACE never emits a separate patron record in that case, see
/// <c>AllegianceHierarchy.Write</c>'s <c>!node.Patron.IsMonarch</c>
/// guard).
/// </summary>
public AllegianceMemberRecord? FindPatron(uint guid)
{
if (Monarch is { } monarch && monarch.CharacterId == guid)
return null;
foreach (AllegianceMemberRecord record in Records)
{
if (record.CharacterId == guid)
return FindData(record.ParentGuid);
}
return null;
}
/// <summary>Port of <c>GetFirstVassal</c>/<c>GetNextVassal</c>: every record whose parent is <paramref name="guid"/>.</summary>
public IEnumerable<AllegianceMemberRecord> FindVassals(uint guid)
{
foreach (AllegianceMemberRecord record in Records)
{
if (record.ParentGuid == guid)
yield return record;
}
}
}
public static AllegianceInfoResponse? ParseAllegianceInfoResponse(ReadOnlySpan<byte> payload)
{
try
{
int pos = 0;
uint targetGuid = ReadU32(payload, ref pos);
uint totalMembers = ReadU32(payload, ref pos);
uint totalVassals = ReadU32(payload, ref pos);
ushort recordCount = ReadU16(payload, ref pos);
_ = ReadU16(payload, ref pos); // oldVersion — not consulted by the renderer
// officers: PackableHashTable<ObjectGuid,AllegianceOfficerLevel>.
// ACE always sends this empty ("always sent as empty in retail?"
// per AllegianceHierarchy.cs) and retail's own chat renderer never
// reads it — skip the entries, keep the cursor faithful.
ushort officerCount = ReadU16(payload, ref pos);
_ = ReadU16(payload, ref pos); // numBuckets
for (int i = 0; i < officerCount; i++)
{
_ = ReadU32(payload, ref pos); // guid
_ = ReadU32(payload, ref pos); // officer level
}
// officerTitles: List<string>.Write — a bare int32 count (NOT the
// PackableHashTable u16/u16 header), then N String16L.
int titleCount = unchecked((int)ReadU32(payload, ref pos));
for (int i = 0; i < titleCount; i++)
_ = StringReader.ReadString16L(payload, ref pos);
_ = ReadU32(payload, ref pos); // monarchBroadcastTime
_ = ReadU32(payload, ref pos); // monarchBroadcastsToday
_ = ReadU32(payload, ref pos); // spokesBroadcastTime
_ = ReadU32(payload, ref pos); // spokesBroadcastsToday
_ = StringReader.ReadString16L(payload, ref pos); // motd
_ = StringReader.ReadString16L(payload, ref pos); // motdSetBy
_ = ReadU32(payload, ref pos); // chatRoomID
// bindPoint Position: cell(u32) + pos(3xfloat) + rotation(4xfloat,
// W/X/Y/Z order) = 32 bytes. Not surfaced by the retail chat
// renderer (only the Allegiance UI panel's bind-point display
// would use it) — skip with bounds checking via ReadU32.
for (int i = 0; i < 8; i++)
_ = ReadU32(payload, ref pos);
string allegianceName = StringReader.ReadString16L(payload, ref pos);
_ = ReadU32(payload, ref pos); // nameLastSetTime
_ = ReadU32(payload, ref pos); // isLocked
_ = ReadU32(payload, ref pos); // approvedVassal
AllegianceMemberRecord? monarch = null;
var records = new List<AllegianceMemberRecord>();
if (recordCount > 0)
{
monarch = ReadAllegianceData(payload, ref pos, parentGuid: 0u);
for (int i = 1; i < recordCount; i++)
{
uint parentGuid = ReadU32(payload, ref pos);
records.Add(ReadAllegianceData(payload, ref pos, parentGuid));
}
}
return new AllegianceInfoResponse(
targetGuid, totalMembers, totalVassals, recordCount,
allegianceName, monarch, records);
}
catch (FormatException) { return null; }
}
private static AllegianceMemberRecord ReadAllegianceData(
ReadOnlySpan<byte> payload, ref int pos, uint parentGuid)
{
uint characterId = ReadU32(payload, ref pos);
_ = ReadU32(payload, ref pos); // cpCached
_ = ReadU32(payload, ref pos); // cpTithed
uint bitfield = ReadU32(payload, ref pos);
_ = ReadByte(payload, ref pos); // gender
_ = ReadByte(payload, ref pos); // heritage group
_ = ReadU16(payload, ref pos); // rank
if ((bitfield & HasPackedLevelBit) != 0u)
_ = ReadU32(payload, ref pos); // level
_ = ReadU16(payload, ref pos); // loyalty
_ = ReadU16(payload, ref pos); // leadership
if ((bitfield & HasAllegianceAgeBit) != 0u)
{
_ = ReadU32(payload, ref pos); // timeOnline
_ = ReadU32(payload, ref pos); // allegianceAge
}
else
{
_ = ReadU32(payload, ref pos); // uTimeOnline low
_ = ReadU32(payload, ref pos); // uTimeOnline high
}
string name = StringReader.ReadString16L(payload, ref pos);
return new AllegianceMemberRecord(
characterId, parentGuid, (bitfield & LoggedInBit) != 0u, name);
}
/// <summary>
/// Retail's " *" online marker (<c>data_7cef28</c>,
/// <c>acclient_2013_pseudo_c.txt:1027329-1027330</c> — UTF-16 bytes
/// <c>20 00 2a 00</c> = space + asterisk) versus the empty string for an
/// offline member (<c>data_794320</c>, the generic empty-PStringBase
/// sentinel).
/// </summary>
private static string OnlineMarker(AllegianceMemberRecord member) =>
member.IsLoggedIn ? " *" : "";
/// <summary>
/// Retail-shaped lines for an AllegianceInfoResponse — verbatim port of
/// <c>Handle_Allegiance__AllegianceInfoResponseEvent</c> @0x0056a1d0.
/// Retail's own <c>AllegianceProfile::GetData</c> failure (the queried
/// player has no record at all — e.g. no allegiance) returns early with
/// NO text printed at all; this yields an empty sequence for that case,
/// matching retail exactly rather than inventing a "no allegiance"
/// message retail never shows.
/// </summary>
public static IEnumerable<string> FormatAllegianceInfoLines(AllegianceInfoResponse response)
{
AllegianceMemberRecord? self = response.FindData(response.TargetGuid);
if (self is not { } selfRecord)
yield break;
yield return "Note: An asterisk (*) indicates that the character is currently online.";
yield return $"Allegiance information for {selfRecord.Name}{OnlineMarker(selfRecord)}:";
if (response.FindPatron(response.TargetGuid) is { } patron)
yield return $" Patron: {patron.Name}{OnlineMarker(patron)}";
bool wroteVassalHeader = false;
foreach (AllegianceMemberRecord vassal in response.FindVassals(response.TargetGuid))
{
if (!wroteVassalHeader)
{
yield return " Vassals: ";
wroteVassalHeader = true;
}
yield return $" {vassal.Name}{OnlineMarker(vassal)}";
}
}
// ── Shared primitive readers (throw on truncation, like StringReader) ──
private static uint ReadU32(ReadOnlySpan<byte> source, ref int pos)
{
if (source.Length - pos < 4) throw new FormatException("truncated u32");
uint value = BinaryPrimitives.ReadUInt32LittleEndian(source.Slice(pos));
pos += 4;
return value;
}
private static ushort ReadU16(ReadOnlySpan<byte> source, ref int pos)
{
if (source.Length - pos < 2) throw new FormatException("truncated u16");
ushort value = BinaryPrimitives.ReadUInt16LittleEndian(source.Slice(pos));
pos += 2;
return value;
}
private static byte ReadByte(ReadOnlySpan<byte> source, ref int pos)
{
if (source.Length - pos < 1) throw new FormatException("truncated byte");
byte value = source[pos];
pos += 1;
return value;
}
}

View file

@ -455,6 +455,13 @@ public sealed class PlayerMovementController
private bool _jumpCharging;
private float _jumpExtent;
// Campaign CH user-gate round 1 (item A, #329 sibling finding): previous
// frame's raw Jump input, so an airborne jump press can be reported on
// its RISING edge only — retail's jump_is_allowed (called from
// ClientCombatSystem::DoJump @0x0056B110) refuses once per press, not
// once per frame the key is held.
private bool _prevJumpHeld;
/// <summary>
/// Current retail jump-powerbar state. Power is always zero when no jump is
/// pending and otherwise lies in [0,1].
@ -2574,6 +2581,21 @@ public sealed class PlayerMovementController
_jumpCharging = false;
_jumpExtent = 0f;
}
else if (input.Jump && !_prevJumpHeld && !_body.OnWalkable)
{
// Campaign CH user-gate round 1, item A: the whole jump block
// above only ever evaluates `input.Jump` inside
// `input.Jump && _body.OnWalkable` (charge) or `_jumpCharging`
// (fire/refuse) — pressing jump while airborne and NOT already
// charging never reached either branch, so retail's 0x24 "You
// can't jump while in the air" (jump_is_allowed via
// ClientCombatSystem::DoJump @0x0056B110) could never fire live.
// Report it exactly like the grounded refusals above, gated to
// the press EDGE only (see _prevJumpHeld) so holding space
// in-air raises exactly one report, not one per frame.
ReportJumpRefusal(WeenieError.NotGrounded);
}
_prevJumpHeld = input.Jump;
// ── 2. Run admitted complete-object quanta ────────────────────────────
// CPhysicsObj::update_object (0x00515D10) retains a remainder at or

View file

@ -237,7 +237,11 @@ public sealed class ChatVM : IDisposable
ChatKind.Tell => entry.SenderGuid != 0
? $"{entry.Sender} tells you, \"{entry.Text}\""
: $"You tell {entry.Sender}, \"{entry.Text}\"",
ChatKind.System => $"[System] {entry.Text}",
// Campaign CH user-gate round 1 (item B): retail prints system text
// bare, with no "[System]" prefix — that prefix was acdream's own
// invention. [Popup] stays (AP-175, a deliberate divergent
// presentation marker for a different kind).
ChatKind.System => entry.Text,
ChatKind.Popup => $"[Popup] {entry.Text}",
// Phase I.5: emote rendering matches retail's leading-asterisk
// convention ("* Caith waves at you"). SoulEmote uses the same