fix(ui): restore radar, retail wield switching, and protection meshes

Late-bind radar snapshots to the canonical LiveEntityRuntime maps so the retained radar survives bootstrap and session replacement instead of capturing empty sentinels.

Route paperdoll drops through the retail AutoWield blocker transaction. Move conflicting held weapons, shields, explicit jewelry destinations, and mismatched ammo to the backpack one authoritative confirmation at a time; preserve compatible arrows when switching to melee.

Render mode-1 and no-degrade particle GfxObjs as their authored modern-pipeline meshes, retain Always2D billboards, interleave both paths back-to-front, balance emitter mesh ownership, and fail safely on corrupt DAT material metadata. This restores the closed apex on Armor Self/protection effects.

Retain Studio fixture controller lifetimes, add installed-DAT and adversarial regression coverage, synchronize retail research/divergence bookkeeping, and pass all three review tracks plus the full Release suite.

Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
Erik 2026-07-14 20:52:45 +02:00
parent 8d63e5c28a
commit b26f84cc69
26 changed files with 1361 additions and 141 deletions

View file

@ -62,6 +62,7 @@ internal sealed class AutoWieldController : IDisposable
private readonly Action<uint, uint>? _sendWield;
private readonly Action<uint, uint, int>? _sendPutItemInContainer;
private readonly Action<string>? _toast;
private readonly Action<string>? _systemMessage;
private PendingSwitch? _pendingSwitch;
private bool _disposed;
@ -73,13 +74,15 @@ internal sealed class AutoWieldController : IDisposable
Func<uint> playerGuid,
Action<uint, uint>? sendWield,
Action<uint, uint, int>? sendPutItemInContainer,
Action<string>? toast)
Action<string>? toast,
Action<string>? systemMessage = null)
{
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
_playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid));
_sendWield = sendWield;
_sendPutItemInContainer = sendPutItemInContainer;
_toast = toast;
_systemMessage = systemMessage;
_objects.ObjectMoved += OnObjectMoved;
_objects.ObjectRemoved += OnObjectRemoved;
@ -94,22 +97,53 @@ internal sealed class AutoWieldController : IDisposable
/// as retail's inventory-ready gate prevents a second concurrent request.
/// </summary>
public bool TryWield(ClientObject item)
=> TryWield(item, EquipMask.None);
/// <summary>
/// Execute AutoWield while retaining the paperdoll's resolved target side.
/// Retail <c>gmPaperDollUI::AcceptDragObject @ 0x004A3B10</c> supplies this
/// location to <c>CPlayerSystem::AutoWield @ 0x00560A60</c> rather than
/// bypassing the transaction with a direct wire request.
/// </summary>
public bool TryWield(ClientObject item, EquipMask requestedMask)
{
ArgumentNullException.ThrowIfNull(item);
if (_pendingSwitch is not null)
return true;
if (item.ValidLocations == EquipMask.None
|| item.CurrentlyEquippedLocation != EquipMask.None)
if (item.ValidLocations == EquipMask.None)
return false;
if (requestedMask != EquipMask.None
&& (requestedMask & item.ValidLocations) != requestedMask)
return false;
EquipMask weaponLocation = item.ValidLocations & WeaponReadyMask;
// gmPaperDollUI::AcceptDragObject can ask AutoWield to move an item
// that is already worn (the classic left-ring -> right-ring drop).
// It is not a blocker transaction: the object itself is the requested
// item, so preserve the paperdoll's explicit destination and let the
// ordinary GetAndWieldItem acknowledgement relocate it.
if (item.CurrentlyEquippedLocation != EquipMask.None)
{
if (requestedMask == EquipMask.None
|| requestedMask == item.CurrentlyEquippedLocation)
return false;
if (GetEquippedObjectAtLocation(
requestedMask, priority: 0, item.ObjectId) is { } blocker)
{
return BeginWeaponReplacement(item.ObjectId, blocker, requestedMask);
}
return SendWield(item, requestedMask);
}
EquipMask weaponLocation = requestedMask == EquipMask.None
? item.ValidLocations & WeaponReadyMask
: requestedMask & WeaponReadyMask;
if (weaponLocation != EquipMask.None)
{
ClientObject? blocker = GetEquippedObjectAtLocation(
WeaponReadyMask, priority: 0, item.ObjectId);
if (blocker is not null)
return BeginWeaponReplacement(item.ObjectId, blocker.ObjectId);
return BeginWeaponReplacement(item.ObjectId, blocker, weaponLocation);
// Retail checks secondary blockers only after the primary weapon
// slot is vacant, then re-enters AutoWield after each confirmed
@ -117,18 +151,20 @@ internal sealed class AutoWieldController : IDisposable
if (BlocksUseOfShield(item)
&& GetEquippedObjectAtLocation(
EquipMask.Shield, priority: 0, item.ObjectId) is { } shield)
return BeginWeaponReplacement(item.ObjectId, shield.ObjectId);
return BeginWeaponReplacement(item.ObjectId, shield, weaponLocation);
if (item.AmmoType is > 0
&& GetEquippedObjectAtLocation(
EquipMask.MissileAmmo, priority: 0, item.ObjectId) is { } ammo
&& ammo.AmmoType != item.AmmoType)
return BeginWeaponReplacement(item.ObjectId, ammo.ObjectId);
return BeginWeaponReplacement(item.ObjectId, ammo, weaponLocation);
return SendWield(item, weaponLocation);
}
EquipMask mask = BestAvailableEquipMask(item);
EquipMask mask = requestedMask != EquipMask.None
? requestedMask
: BestAvailableEquipMask(item);
if (mask == EquipMask.None)
{
_toast?.Invoke("That slot is already in use");
@ -138,7 +174,10 @@ internal sealed class AutoWieldController : IDisposable
return SendWield(item, mask);
}
private bool BeginWeaponReplacement(uint requestedItemId, uint blockingItemId)
private bool BeginWeaponReplacement(
uint requestedItemId,
ClientObject blockingItem,
EquipMask requestedMask)
{
if (_sendPutItemInContainer is null)
{
@ -150,12 +189,17 @@ internal sealed class AutoWieldController : IDisposable
if (player == 0)
return false;
_pendingSwitch = new PendingSwitch(requestedItemId, blockingItemId);
_pendingSwitch = new PendingSwitch(
requestedItemId,
blockingItem.ObjectId,
requestedMask);
// Retail AttemptToPlaceInContainer(blockingID, playerID, 0, 1, 0).
// Do not change the local equip projection yet: the server's move event
// is the transaction boundary and preserves its stance-specific motion.
_sendPutItemInContainer(blockingItemId, player, 0);
_systemMessage?.Invoke(
$"Moving {blockingItem.GetAppropriateName()} to your backpack");
_sendPutItemInContainer(blockingItem.ObjectId, player, 0);
return true;
}
@ -163,7 +207,10 @@ internal sealed class AutoWieldController : IDisposable
{
if (_sendWield is null)
return false;
_pendingSwitch = new PendingSwitch(item.ObjectId, BlockingItemId: 0);
_pendingSwitch = new PendingSwitch(
item.ObjectId,
BlockingItemId: 0,
RequestedMask: mask);
if (!_objects.WieldItemOptimistic(item.ObjectId, _playerGuid(), mask))
{
_pendingSwitch = null;
@ -188,7 +235,7 @@ internal sealed class AutoWieldController : IDisposable
// vacant ready slot and send GetAndWieldItem for the requested weapon.
_pendingSwitch = null;
if (_objects.Get(pending.RequestedItemId) is { } requested)
TryWield(requested);
TryWield(requested, pending.RequestedMask);
}
private void OnWieldConfirmed(ClientObject item)
@ -325,5 +372,6 @@ internal sealed class AutoWieldController : IDisposable
private readonly record struct PendingSwitch(
uint RequestedItemId,
uint BlockingItemId);
uint BlockingItemId,
EquipMask RequestedMask);
}