fix(client): restore retail interaction parity
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:
parent
0c699240e0
commit
f6fe0f2a4f
151 changed files with 10162 additions and 1211 deletions
81
src/AcDream.App/Net/DatChatPoseCatalog.cs
Normal file
81
src/AcDream.App/Net/DatChatPoseCatalog.cs
Normal 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),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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() =>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue