fix(runtime/core): Slice 5.3 review corrections — retirement/transit close, per-unit pricing, guarded auto-close dispatch
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run

The adversarial review's three blocking findings, each fixed at root:

1. A vendor session now CLOSES when its entity retires (despawn,
   death, ObjectDelete) and at teleport BEGIN
   (HasPendingTeleportStart || IsTeleportActive at the existing
   per-frame seam — both hosts funnel through
   RuntimeWorldTransitState.TryQueueTeleportStart, which flips the
   pending flag strictly before activation). The previous permissive
   early-return stranded the session forever: panel pinned to a stale
   guid, ActiveVendorId swallowing Use for the rest of the session.
2. VendorShopItem carries the desc's stack size, and
   VendorPricing.PerUnitValue ports retail's stack-total division
   (VendorProfile::VendorSellPrice 0x005D1B00: <= 0 guard, integer
   division) — a stack of 50 arrows now prices per arrow, not at 50x.
3. VendorState.Close() guards its observer fanout with the
   dispatcher's catch-and-log semantics — a throwing panel listener
   can no longer propagate into the unprotected per-frame path.

Register honesty rides along: the 0.6 m UseRadius fallback was
acdream's invention (ACE's CheckClose has no fallback; retail passes
the raw authored radius) — removed, the watcher now uses the raw
radius and AP-160's citations are corrected and extended with the
accepted-position-snapshot cadence; AD-72 files VendorPricing's
double-vs-x87-extended narrowing (AD-33's class, bounded by the
±0.1 margin).

Nine tests added. Clean-room complete solution: 11,311 passed /
4 skipped / 0 failed.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-07 16:15:57 +02:00
parent 9796d71522
commit 609a2dfda0
9 changed files with 369 additions and 23 deletions

View file

@ -418,7 +418,13 @@ public static class GameEventWiring
item.Desc.Name,
item.Desc.ItemType,
item.Desc.IconId,
item.Desc.Value);
item.Desc.Value,
// Slice 5.3 review fix 2: the DESC's own StackSize (NOT
// item.StackSize above, ItemProfile's separate packed
// supply-count field) -- VendorPricing.PerUnitValue's
// divisor for turning Value's stack-total wire number
// into a per-unit display price.
item.Desc.StackSize);
}
vendor?.Apply(p.Value.VendorGuid, profile, shopItems);

View file

@ -49,6 +49,45 @@ namespace AcDream.Core.Items;
/// </summary>
public static class VendorPricing
{
/// <summary>
/// <c>VendorProfile::VendorSellPrice</c>/<c>VendorBuyPrice</c>'s shared
/// per-unit division (<c>0x005D1B00</c>/<c>0x005D1B70</c>, both bodies
/// identical apart from which rate/branch they go on to feed
/// <see cref="SellPrice"/>/<see cref="BuyPrice"/> — read directly from
/// the decompiled body, <c>docs/research/named-retail/acclient_2013_pseudo_c.txt:484801-484813</c>).
/// The wire item's <c>Value</c> field is the STACK's TOTAL value, not a
/// per-unit price — retail divides it by the item's own authored
/// <c>PublicWeenieDesc::_stackSize</c> (<paramref name="descStackSize"/>
/// — NOT <see cref="VendorShopItem.StackSize"/>, which is
/// <c>ItemProfile</c>'s separately-packed SUPPLY count, a different wire
/// field entirely) before either price formula ever sees it. Slice 5.3
/// review fix 2.
/// </summary>
/// <param name="stackTotalValue">
/// The item's raw wire <c>Value</c> (retail <c>_value</c> — the whole
/// stack's total value, not one unit's).
/// </param>
/// <param name="descStackSize">
/// The item's own <c>PublicWeenieDesc::_stackSize</c> (retail
/// <c>_stackSize</c>). <see langword="null"/> (the field absent on the
/// wire) is treated exactly like retail's zeroed-struct default when the
/// field was never sent — same as an explicit 0 or negative: no
/// division.
/// </param>
/// <returns>
/// <paramref name="stackTotalValue"/> unchanged when
/// <paramref name="descStackSize"/> is absent or <c>&lt;= 0</c> (retail
/// <c>pc:484808-484810</c>: <c>if (_stackSize &lt;= 0) return
/// SellPrice(_value, ...)</c>); otherwise the INTEGER-divided per-unit
/// value (retail <c>pc:484812</c>: <c>COMBINE(0, _value) / _stackSize</c>
/// — a plain non-negative integer divide, .NET's <c>int</c> division
/// truncates toward zero the same way).
/// </returns>
public static int PerUnitValue(int stackTotalValue, int? descStackSize) =>
descStackSize is { } size && size > 0
? stackTotalValue / size
: stackTotalValue;
/// <summary>
/// <c>ShopSystem::BuyPrice</c> (<c>0x006B6120</c>): the price the vendor
/// PAYS the player for <paramref name="quantity"/> units of an item

View file

@ -44,7 +44,20 @@ public readonly record struct VendorShopItem(
string? Name,
uint? ItemType,
uint IconId,
int? Value);
int? Value,
// Slice 5.3 review fix 2: the ITEM'S OWN authored stack depth (retail
// PublicWeenieDesc::_stackSize, wire AcDream.Core.Net.Messages.
// PublicWeenieDescBody.StackSize) -- the divisor VendorPricing.PerUnitValue
// needs to turn Value's STACK-TOTAL wire number into a per-unit display
// price (VendorProfile::VendorSellPrice/VendorBuyPrice, 0x005D1B00/
// 0x005D1B70). This is a DIFFERENT wire field from StackSize above:
// that one is ItemProfile's packed SUPPLY count (how many the vendor has
// in stock), this one is how many units make up one priced stack (e.g.
// 50 for a stack of arrows). Nullable because the wire field is
// conditionally present (weenieFlags-gated) -- absent maps to null here,
// matching retail's own zeroed-struct default of 0 for the same case
// (see VendorPricing.PerUnitValue's <= 0 guard).
int? DescStackSize = null);
public enum VendorStateTransitionKind
{
@ -134,6 +147,31 @@ public sealed class VendorState
/// different-vendor open superseding this one; see research doc §A.3).
/// Returns <c>false</c> if no vendor was open.
/// </summary>
/// <remarks>
/// <b>Slice 5.3 review fix 3.</b> Unlike <see cref="Reset"/>, a failing
/// <see cref="Changed"/> observer here is never rethrown. <c>Close()</c>'s
/// production caller (<c>RuntimeVendorRangeQuery.EnforceRange</c>) runs
/// inside the per-frame post-network-command-phase callback
/// (<c>GameRuntime.CreateLocalPlayerFrameController</c>'s post-network
/// phase) with no try/catch anywhere up the frame-loop chain — an
/// <see cref="AggregateException"/> propagating out of here, <see cref="Reset"/>'s
/// shape, would kill the frame. <see cref="Reset"/> keeps that
/// collect-and-rethrow shape because ITS callers (session
/// reset/portal-out/logout — a rare, explicit teardown boundary) already
/// tolerate/handle it (e.g. <c>RuntimeInventoryState.Dispose</c>'s own
/// <c>Try(...)</c> wrapper collects <see cref="Reset"/>'s failures
/// alongside every other child's). This still fans out to every listener
/// via <c>GetInvocationList()</c> (one broken observer must not starve
/// another — same resilience as <see cref="Reset"/>), but LOGS each
/// failure instead of collecting it into an exception, matching
/// <c>GameEventDispatcher.Dispatch</c>'s own boundary contract
/// (<c>src/AcDream.Core.Net/Messages/GameEventDispatcher.cs:95-117</c> —
/// catch, <c>Console.Error.WriteLine</c>, never rethrow, "the decode
/// thread must survive handler failures"): a per-frame boundary must
/// survive its own observers' failures the same way. Not silent
/// swallowing — the failure surfaces on <see cref="Console.Error"/>
/// exactly the way the dispatcher's do.
/// </remarks>
public bool Close()
{
if (VendorId == 0u) return false;
@ -141,7 +179,20 @@ public sealed class VendorState
uint previous = VendorId;
ClearFields();
Changed?.Invoke(new VendorTransition(VendorStateTransitionKind.Closed, previous, 0u));
var transition = new VendorTransition(VendorStateTransitionKind.Closed, previous, 0u);
Action<VendorTransition>? listeners = Changed;
if (listeners is not null)
{
foreach (Action<VendorTransition> listener in listeners.GetInvocationList())
{
try { listener(transition); }
catch (Exception error)
{
Console.Error.WriteLine(
$"[VendorState] Close() observer threw: {error.Message}");
}
}
}
return true;
}

View file

@ -3,6 +3,7 @@ using AcDream.Core.Items;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
using AcDream.Runtime.Entities;
using AcDream.Runtime.World;
namespace AcDream.Runtime.Gameplay;
@ -18,22 +19,72 @@ namespace AcDream.Runtime.Gameplay;
/// ACE's server-side belt-and-suspenders equivalent, <c>Vendor.CheckClose</c>
/// (<c>references/ACE/Source/ACE.Server/WorldObjects/Vendor.cs:322-367</c>),
/// polls every 1.5 s and closes when <c>GetCylinderDistance(lastPlayer) &gt;
/// UseRadius</c>, falling back to <c>wo.UseRadius ?? 0.6f</c> when the vendor
/// carries no explicit radius (<c>WorldObject_Use.cs:50,57</c>).
/// UseRadius</c> — a plain nullable-float comparison with NO fallback
/// default of its own (<c>UseRadius</c> is <c>float?</c>,
/// <c>WorldObject_Properties.cs:1242-1246</c>; a nullable comparison against
/// a null right operand is always <c>false</c>, so <c>CheckClose</c> never
/// closes when the vendor carries no explicit radius). The <c>?? 0.6f</c>
/// fallback this class's earlier revision mis-attributed to
/// <c>Vendor.CheckClose</c> actually lives in a DIFFERENT method,
/// <c>WorldObject.IsWithinUseRadiusOf</c> (<c>WorldObject_Use.cs:50,57</c>) —
/// the APPROACH check ("how close you need to be to open the shop"), never
/// the close/distance-watch path. Slice 5.3 review fix 4.
/// </summary>
public static class RuntimeVendorRangeQuery
{
/// <summary>ACE <c>WorldObject_Use.cs:50</c>: <c>wo.UseRadius ?? 0.6f</c>.</summary>
private const float DefaultUseRadius = 0.6f;
/// <summary>
/// Close the open vendor session (if any) once the local player has
/// moved beyond the vendor's own UseRadius. No-op when no vendor is
/// open, or when either side's live position cannot be resolved this
/// tick (matches the existing App-layer convention at
/// moved beyond the vendor's own UseRadius, once the vendor entity
/// itself is no longer resolvable, or once an in-session portal/
/// teleport has begun. No-op only when no vendor is open, or when the
/// PLAYER's own live position cannot be resolved this tick (matches the
/// existing App-layer convention at
/// <c>WorldSelectionQuery.IsWithinExternalContainerUseRange</c>: "the
/// server remains authoritative while render projection is absent" —
/// never force-close on missing data).
/// never force-close on missing PLAYER data). A missing/retired VENDOR
/// is a different case — see fix 1a below.
///
/// <para>
/// <b>Fix 1b — in-session transit closes at BEGIN, not arrival (Slice
/// 5.3 review).</b> <see cref="RuntimeWorldTransitState"/> is the
/// canonical Runtime owner of the F751/teleport lifecycle (J6.2/J6.3).
/// <c>HasPendingTeleportStart</c> flips true the instant
/// <c>TryQueueTeleportStart</c> succeeds — for a GRAPHICAL host this is
/// strictly BEFORE <c>ActivateQueuedTeleport</c> (and therefore before
/// <c>IsTeleportActive</c>), because
/// <c>LocalPlayerTeleportController.TryActivatePendingPresentation</c>
/// defers activation until the host can enter portal space; for a
/// HEADLESS host (<c>RuntimeLiveEntitySessionController.OnTeleportStarted</c>)
/// both flip in the same synchronous call. Checking BOTH flags here
/// observes "transit begin" through "transit still resolving" for
/// either host without a second call site: both hosts
/// (<c>LocalPlayerTeleportController.OnTeleportStarted</c> for
/// graphical, <c>RuntimeLiveEntitySessionController.OnTeleportStarted</c>
/// for headless) reach <c>TryQueueTeleportStart</c> as their sole entry
/// point, and this method already runs once per advanced frame for BOTH
/// hosts via the SAME post-network-command-phase callback
/// (<see cref="GameRuntime.CreateLocalPlayerFrameController"/>) that
/// already exists for the range check below — so this reuses that
/// existing per-frame seam instead of adding a new polling loop or event
/// channel. Unconditional: while a transit is in flight, distance is not
/// even evaluated, matching how a portal/teleport makes the player's and
/// vendor's positions momentarily incomparable (different landblock
/// frames — see register AP-160's extension).
/// </para>
///
/// <para>
/// <b>Fix 1a — a retired vendor entity closes the session (Slice 5.3
/// review).</b> Retail's own range watcher
/// (<c>CPlayerSystem::RegisterObjectRangeHandler</c>) dies with its
/// target — it has nothing left to watch once the vendor NPC is
/// retired (despawn, death, ObjectDelete during a recall). The prior
/// revision's early-return here on a failed <c>TryGetActive</c> was a
/// permissive default that stranded the session forever once the
/// vendor left the active entity set. A missing POSITION on a still-
/// active record gets the same treatment: a positionless vendor cannot
/// be range-checked, and retail's watcher likewise has nothing to
/// watch.
/// </para>
///
/// <para>
/// <b>Distance metric divergence (register AP-160):</b> retail/ACE close
@ -59,6 +110,16 @@ public static class RuntimeVendorRangeQuery
if (vendorId == 0u)
return;
// Fix 1b: close unconditionally the instant an in-session transit
// has begun — see the class doc above for why HasPendingTeleportStart
// is the earliest observable "transit begin" edge for both hosts.
RuntimeWorldTransitState transit = runtime.TransitOwner;
if (transit.HasPendingTeleportStart || transit.IsTeleportActive)
{
vendor.Close();
return;
}
uint playerGuid = runtime.PlayerIdentity.ServerGuid;
if (playerGuid == 0u
|| !runtime.EntityObjects.Entities.TryGetActive(
@ -69,15 +130,24 @@ public static class RuntimeVendorRangeQuery
return;
}
// Fix 1a: a retired vendor entity, or one left with no resolvable
// position, closes the session instead of stranding it open forever.
if (!runtime.EntityObjects.Entities.TryGetActive(
vendorId,
out RuntimeEntityRecord vendorRecord)
|| vendorRecord.Snapshot.Position is not { } vendorPosition)
{
vendor.Close();
return;
}
float useRadius = vendorRecord.Snapshot.UseRadius ?? DefaultUseRadius;
// Fix 4: retail passes the vendor's raw authored UseRadius with NO
// client-side fallback (see the class doc's ACE citation correction)
// — an absent/zero radius closes on the very first nonzero-distance
// check, since ObjectRangeMath.ObjectsInRange with range=0 requires
// an EXACT position match. That is retail's own behavior for a
// radius-0 (or unauthored) handler, not a bug to paper over.
float useRadius = vendorRecord.Snapshot.UseRadius ?? 0f;
bool inRange = ObjectRangeMath.ObjectsInRange(
AbsolutePosition(playerPosition),
0f,