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:
parent
d1c1368a5e
commit
47e40900f3
17 changed files with 1428 additions and 81 deletions
406
src/AcDream.Core.Net/Messages/ClientCommandResponses.cs
Normal file
406
src/AcDream.Core.Net/Messages/ClientCommandResponses.cs
Normal 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 >400-locations
|
||||
/// truncation notice (<c>data_7e1d70</c>,
|
||||
/// <c>acclient_2013_pseudo_c.txt:1032459</c>) if <c>TotalAvailable > 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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue