fix(client): restore retail interaction parity
All checks were successful
CI / linux-portable (push) Successful in 3m27s
CI / windows-gate (push) Successful in 6m42s
CI / release (push) Successful in 2m12s

Harden keyboard and camera routing, inventory and vendor interactions, chat/emotes, relog portal flow, and paperdoll rendering. Add retail research, connected gate coverage, and release-gate validation.
This commit is contained in:
Erik 2026-08-26 20:45:11 +02:00
parent 0c699240e0
commit f6fe0f2a4f
151 changed files with 10162 additions and 1211 deletions

View file

@ -0,0 +1,81 @@
using AcDream.Runtime.Chat;
using AcDream.Content;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatMotionCommand = DatReaderWriter.Enums.MotionCommand;
namespace AcDream.App.Net;
/// <summary>
/// Immutable projection of retail's portal-DAT ChatPoseTable (0x0E000007).
/// Command lookup is case-insensitive, matching
/// <c>ChatPoseTable::InqChatPoseCommand @ 0x00570AD0</c>.
/// </summary>
internal sealed class DatChatPoseCatalog
{
private const uint ChatPoseTableId = 0x0E000007u;
private readonly IReadOnlyDictionary<string, RetailChatPose> _poses;
private DatChatPoseCatalog(
IReadOnlyDictionary<string, RetailChatPose> poses) =>
_poses = poses;
public static DatChatPoseCatalog Load(IDatReaderWriter dats, object datLock)
{
ArgumentNullException.ThrowIfNull(dats);
ArgumentNullException.ThrowIfNull(datLock);
lock (datLock)
{
ChatPoseTable? table = dats.Get<ChatPoseTable>(ChatPoseTableId);
if (table is null)
return new DatChatPoseCatalog(
new Dictionary<string, RetailChatPose>(
StringComparer.OrdinalIgnoreCase));
var emotes = new Dictionary<string, (string Self, string Others)>(
StringComparer.OrdinalIgnoreCase);
foreach (var pair in table.ChatEmotes)
{
emotes[pair.Key.Value] = (
pair.Value.MyEmote.Value,
pair.Value.OtherEmote.Value);
}
var poses = new Dictionary<string, RetailChatPose>(
StringComparer.OrdinalIgnoreCase);
foreach (var pair in table.ChatPoses)
{
string command = pair.Key.Value;
string motionName = pair.Value.Value;
if (string.IsNullOrEmpty(command)
|| !Enum.TryParse(
motionName,
ignoreCase: true,
out DatMotionCommand motion))
{
continue;
}
emotes.TryGetValue(motionName, out var text);
poses[command] = new RetailChatPose(
(uint)motion,
text.Self ?? string.Empty,
text.Others ?? string.Empty);
}
return new DatChatPoseCatalog(poses);
}
}
public RetailChatPose? Resolve(string command, bool male)
{
if (!_poses.TryGetValue(command, out RetailChatPose pose))
return null;
string possessive = male ? "his" : "her";
return pose with
{
OthersText = pose.OthersText.Replace(
"%p",
possessive,
StringComparison.Ordinal),
};
}
}

View file

@ -74,7 +74,10 @@ internal sealed record LiveSessionCommandBindings(
Action<uint> SendAllegianceKick,
Action<string> SendAllegianceInfoRequest,
Action<bool> SendAllegianceUpdateRequest,
Action<string>? Log = null);
Action<string>? Log = null,
Func<string, RetailChatPose?>? ResolvePose = null,
Action<uint>? ExecuteMotion = null,
Action<string>? SendSoulEmote = null);
internal readonly record struct AddShortcutRuntimeCmd(ShortcutEntry Entry);
internal readonly record struct RemoveShortcutRuntimeCmd(uint Index);
@ -185,7 +188,10 @@ internal sealed class LiveSessionCommandRouter : ILiveSessionCommandRouting
bindings.SendTell,
bindings.SendChannel,
bindings.SendTurbineChat,
bindings.Log));
bindings.Log,
bindings.ResolvePose,
bindings.ExecuteMotion,
bindings.SendSoulEmote));
// Campaign CH slice CH4 (2026-08-09): the 22 unregistered
// ChannelSystem::GetChannelID fallback tags — bypasses
// ChatChannelKind/ChannelResolver entirely and sends the raw

View file

@ -67,6 +67,7 @@ internal sealed record LiveSessionInteractionRuntime(
internal sealed record LiveSessionWorldRuntime(
IDatReaderWriter Dats,
object DatLock,
// Logout-audio round (2026-08-17): null only when audio is disabled
// (ACDREAM_NO_AUDIO / init failure) — the reset step and entered-world
// resume both no-op then.
@ -114,6 +115,7 @@ internal sealed class LiveSessionRuntimeFactory
private readonly IReadOnlyList<string> _loginCommands;
private readonly TimeSpan _loginCommandDelay;
private readonly TimeProvider _timeProvider;
private readonly DatChatPoseCatalog _chatPoses;
/// <summary>
/// Where a bare <c>@log</c> filename lands. See <see cref="ChatSessionLog"/>
@ -162,6 +164,7 @@ internal sealed class LiveSessionRuntimeFactory
_loginCommands = loginCommands is null ? [] : [.. loginCommands];
_loginCommandDelay = TimeSpan.FromMilliseconds(loginCommandDelayMs);
_timeProvider = timeProvider ?? TimeProvider.System;
_chatPoses = DatChatPoseCatalog.Load(_world.Dats, _world.DatLock);
// C3c-F1: stat recomputes route through the Runtime movement owner's
// typed application seam; App keeps zero direct controller mutations.
_movementStats = new LiveMovementStatsApplier(
@ -217,6 +220,15 @@ internal sealed class LiveSessionRuntimeFactory
RestoreLayout: () =>
{
_ui.RetailUi?.RestoreLayout();
// The retained inventory controller exists before the
// character object graph is complete. Rebuild its open
// container once EnteredWorld makes that graph
// authoritative, otherwise the already-open main pack can
// keep the empty construction-time cells until the user
// switches packs. Redress the private doll at the same
// character-complete edge.
_ui.RetailUi?.InventoryPanelController?.Populate();
_ui.Paperdoll?.MarkDirty();
// MUST-FIX 3 re-fix (FA4 re-review REOPEN): re-declare a
// still-open Fellowship page's 0x00A6 now we are in world —
// RestoreLayout is the post-world UI-restore moment, and
@ -786,7 +798,14 @@ internal sealed class LiveSessionRuntimeFactory
SendAllegianceKick: session.SendAllegianceKick,
SendAllegianceInfoRequest: session.SendAllegianceInfoRequest,
SendAllegianceUpdateRequest: session.SendAllegianceUpdateRequest,
Log: _log);
Log: _log,
ResolvePose: command => _chatPoses.Resolve(
command,
male: _domain.EntityObjects.Objects
.Get(_player.Identity.ServerGuid)?
.Properties.GetInt(0x71u) == 1),
ExecuteMotion: motion => _player.Controller.ExecuteMotion(motion),
SendSoulEmote: session.SendSoulEmote);
}
private static double ClientTimerNow() =>