authored gmSecureTradeUI window, and both retail open paths
Three-lane research first (docs/research/2026-08-14-trade-lane{A,B,C}):
retail gmSecureTradeUI decode, the byte-exact ACE/decomp/holtburger
three-way wire agreement, and the acdream seam map (which found both
open paths ALREADY classified by the ported policy - OpenSecureTrade on
Use-a-player, StartSecureTrade on drag-item-onto-player with the
DragItemOnPlayerOpensSecureTrade option - dead-ending at a stub toast).
- Core.Net: TradeRequests builders (0x1F6-0x204, retail's CM_Trade
senders byte-checked against ACE's readers; the ACE-discarded
AcceptTrade echo carries zero-count item lists - AD-94), corrected +
completed inbound parsers (0x1FD-0x208; the old AddToTrade parser
missed the SIDE dword, TradeFailure missed the reason), delegate-hole
registrars, six WorldSession sends. 10 golden-byte tests.
- Runtime: RuntimeTradeState, the third sibling J-owner (fellowship/
allegiance shape): session-scoped, clears at generation reset (new
stage Trade=14), staged teardown stage 11 (Identity/EntityObjects
shift 12/13, TeardownStageCount 14 - the FA2-era per-stage-flag test
caught the mapping exactly as designed), combined ownership ledger,
event routing with ACE's wrong-initiator RegisterTrade landmine
honored (partner = whichever guid is not mine). 7 conformance tests.
- App: SecureTradeUiController binds the dedicated authored LayoutDesc
0x2100000D (root 0x1000007A - gmSecureTradeUI::PostInit's exact ids):
partner name/status/count/grid, the authored 'Trade' accept toggle
(accept <-> decline withdraw), 'Clear All' (ACE clears BOTH sides -
surfaced honestly), the X close, drop-on-your-grid staging, per-mode
accept cues (partner icon's authored Highlight state + Trade button
Selected latch). Mounted via the vendor recipe (nine-slice chrome,
hidden until RegisterTrade). ItemInteractionController's two policy
arms now raise SecureTradeRequested instead of the stub toast; the
drag path queues the dragged item until the window registers
(ClientTradeSystem::AttemptToTradeItem @0x0056DF80's shape).
Register: AD-94 (accept-echo zero-count lists), AD-95 (numeric-only
count texts pending template verification).
Suites: App 4,990/3, Core.Net 905, Runtime 1,626 - all green. The
panel itself is user-gate acceptance (two-client connected trade), the
#372-class lesson: fixture-green alone is not acceptance for a mount.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
463 lines
18 KiB
C#
463 lines
18 KiB
C#
using AcDream.Runtime.Entities;
|
|
using AcDream.Runtime.Gameplay;
|
|
using AcDream.Runtime.World;
|
|
|
|
namespace AcDream.Runtime;
|
|
|
|
/// <summary>
|
|
/// The only host-specific edge in a character-generation reset. The host
|
|
/// retires presentation borrowed from an exact Runtime incarnation; Runtime
|
|
/// retains the retirement set, cursor, canonical cleanup, and generation.
|
|
/// </summary>
|
|
public interface IRuntimeGenerationResetHost
|
|
{
|
|
void RetireEntityProjection(RuntimeEntityRecord entity);
|
|
|
|
void DrainEntityProjectionBoundary();
|
|
|
|
void CompleteEntityProjectionRetirement();
|
|
}
|
|
|
|
public enum RuntimeGenerationResetStage
|
|
{
|
|
None = 0,
|
|
Transit = 1,
|
|
CommandTargets = 2,
|
|
ExternalContainer = 3,
|
|
Actions = 4,
|
|
Movement = 5,
|
|
ObjectTable = 6,
|
|
Character = 7,
|
|
ItemMana = 8,
|
|
Friends = 9,
|
|
Squelch = 10,
|
|
NegotiatedChannels = 11,
|
|
/// <summary>
|
|
/// Campaign FA slice FA2 (2026-08-12): the fellowship roster is
|
|
/// session-scoped (a disconnect drops you from the fellowship
|
|
/// server-side) — clear it here, alongside the other social-list
|
|
/// stages.
|
|
/// </summary>
|
|
Fellowship = 12,
|
|
/// <summary>
|
|
/// FA2 fix-round MUST-FIX 1 (2026-08-12,
|
|
/// docs/research/2026-08-12-fa2-review-mechanism.md MF-1): the
|
|
/// allegiance profile is ALSO cleared here — retail's
|
|
/// <c>ClientAllegianceSystem::OnEndCharacterSession @0x00569FA0</c>
|
|
/// tail-calls <c>AllegianceProfile::Clear</c> at exactly this
|
|
/// per-character-session boundary, and the precedent this owner cites
|
|
/// (<see cref="RuntimeCharacterOptionsState.ResetSession"/>) clears AND
|
|
/// re-latches, it does not persist. See
|
|
/// <see cref="RuntimeAllegianceState"/>'s class doc for the full
|
|
/// correction (the original "survives reconnect" design was inverted
|
|
/// from the precedent it named).
|
|
/// </summary>
|
|
Allegiance = 13,
|
|
/// <summary>
|
|
/// Secure trade (2026-08-14): the trade window is session-scoped — a
|
|
/// disconnect closes the trade server-side (ACE tears the negotiation
|
|
/// down with the session), so the third sibling J-owner clears here
|
|
/// beside its fellowship/allegiance precedents.
|
|
/// </summary>
|
|
Trade = 14,
|
|
BeginEntityRetirement = 15,
|
|
RetireEntities = 16,
|
|
DrainHostProjection = 17,
|
|
CompleteCanonicalEntities = 18,
|
|
CompleteHostProjection = 19,
|
|
ChatIdentity = 20,
|
|
PlayerSnapshots = 21,
|
|
PlayerIdentity = 22,
|
|
Complete = 23,
|
|
}
|
|
|
|
public readonly record struct RuntimeGenerationResetSnapshot(
|
|
bool IsActive,
|
|
bool IsExecuting,
|
|
RuntimeGenerationToken RetiringGeneration,
|
|
RuntimeGenerationToken LastCompletedGeneration,
|
|
RuntimeGenerationResetStage Stage,
|
|
int RetirementCount,
|
|
int RetirementCursor,
|
|
bool CurrentProjectionAcknowledged,
|
|
long TransactionId)
|
|
{
|
|
public bool IsConverged =>
|
|
!IsActive
|
|
&& !IsExecuting;
|
|
}
|
|
|
|
public sealed class RuntimeGenerationResetStageException(
|
|
RuntimeGenerationToken retiringGeneration,
|
|
RuntimeGenerationResetStage stage,
|
|
Exception innerException) : Exception(
|
|
$"Runtime generation {retiringGeneration.Value} reset stage "
|
|
+ $"'{stage}' did not converge.",
|
|
innerException)
|
|
{
|
|
public RuntimeGenerationToken RetiringGeneration { get; } =
|
|
retiringGeneration;
|
|
|
|
public RuntimeGenerationResetStage Stage { get; } = stage;
|
|
}
|
|
|
|
/// <summary>
|
|
/// One persisted, retryable reset transaction for every canonical owner in a
|
|
/// reusable <see cref="GameRuntime"/>. A failed call retains the exact suffix:
|
|
/// completed stages and exact-incarnation retirements never replay.
|
|
/// </summary>
|
|
public sealed class RuntimeGenerationReset
|
|
{
|
|
private readonly RuntimeWorldTransitState _transit;
|
|
private readonly RuntimeCommunicationState _communication;
|
|
private readonly RuntimeInventoryState _inventory;
|
|
private readonly RuntimeActionState _actions;
|
|
private readonly RuntimeLocalPlayerMovementState _movement;
|
|
private readonly RuntimeEntityObjectLifetime _entityObjects;
|
|
private readonly RuntimeCharacterState _character;
|
|
private readonly RuntimeLocalPlayerIdentityState _identity;
|
|
private readonly RuntimeFellowshipState _fellowship;
|
|
private readonly RuntimeAllegianceState _allegiance;
|
|
private readonly RuntimeTradeState _trade;
|
|
private ResetState? _state;
|
|
private RuntimeGenerationToken _lastCompletedGeneration;
|
|
private bool _hasCompletedGeneration;
|
|
private bool _executing;
|
|
private long _nextTransactionId;
|
|
|
|
internal RuntimeGenerationReset(
|
|
RuntimeWorldTransitState transit,
|
|
RuntimeCommunicationState communication,
|
|
RuntimeInventoryState inventory,
|
|
RuntimeActionState actions,
|
|
RuntimeLocalPlayerMovementState movement,
|
|
RuntimeEntityObjectLifetime entityObjects,
|
|
RuntimeCharacterState character,
|
|
RuntimeLocalPlayerIdentityState identity,
|
|
RuntimeFellowshipState fellowship,
|
|
RuntimeAllegianceState allegiance,
|
|
RuntimeTradeState trade)
|
|
{
|
|
_transit = transit ?? throw new ArgumentNullException(nameof(transit));
|
|
_communication = communication
|
|
?? throw new ArgumentNullException(nameof(communication));
|
|
_inventory = inventory
|
|
?? throw new ArgumentNullException(nameof(inventory));
|
|
_actions = actions ?? throw new ArgumentNullException(nameof(actions));
|
|
_movement = movement
|
|
?? throw new ArgumentNullException(nameof(movement));
|
|
_entityObjects = entityObjects
|
|
?? throw new ArgumentNullException(nameof(entityObjects));
|
|
_character = character
|
|
?? throw new ArgumentNullException(nameof(character));
|
|
_identity = identity
|
|
?? throw new ArgumentNullException(nameof(identity));
|
|
_fellowship = fellowship
|
|
?? throw new ArgumentNullException(nameof(fellowship));
|
|
_allegiance = allegiance
|
|
?? throw new ArgumentNullException(nameof(allegiance));
|
|
_trade = trade ?? throw new ArgumentNullException(nameof(trade));
|
|
}
|
|
|
|
public RuntimeGenerationToken? ActiveRetiringGeneration =>
|
|
_state?.Generation;
|
|
|
|
public RuntimeGenerationResetSnapshot CaptureSnapshot()
|
|
{
|
|
ResetState? state = _state;
|
|
return state is null
|
|
? new RuntimeGenerationResetSnapshot(
|
|
false,
|
|
_executing,
|
|
default,
|
|
_lastCompletedGeneration,
|
|
_hasCompletedGeneration
|
|
? RuntimeGenerationResetStage.Complete
|
|
: RuntimeGenerationResetStage.None,
|
|
0,
|
|
0,
|
|
false,
|
|
0)
|
|
: new RuntimeGenerationResetSnapshot(
|
|
true,
|
|
_executing,
|
|
state.Generation,
|
|
_lastCompletedGeneration,
|
|
state.Stage,
|
|
state.Retirements?.Length ?? 0,
|
|
state.RetirementCursor,
|
|
state.CurrentProjectionAcknowledged,
|
|
state.TransactionId);
|
|
}
|
|
|
|
public void Reset(
|
|
RuntimeGenerationToken retiringGeneration,
|
|
IRuntimeGenerationResetHost host)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(host);
|
|
if (_executing)
|
|
{
|
|
throw new InvalidOperationException(
|
|
"Runtime generation reset cannot run concurrently or reentrantly.");
|
|
}
|
|
|
|
ResetState state = AcquireState(retiringGeneration, host);
|
|
if (state.Stage is RuntimeGenerationResetStage.Complete)
|
|
return;
|
|
|
|
_executing = true;
|
|
try
|
|
{
|
|
Drain(state);
|
|
}
|
|
catch (Exception error)
|
|
{
|
|
throw new RuntimeGenerationResetStageException(
|
|
state.Generation,
|
|
state.Stage,
|
|
error);
|
|
}
|
|
finally
|
|
{
|
|
_executing = false;
|
|
}
|
|
}
|
|
|
|
internal void DrainPending()
|
|
{
|
|
ResetState? state = _state;
|
|
if (state is null)
|
|
return;
|
|
Reset(state.Generation, state.Host);
|
|
}
|
|
|
|
private ResetState AcquireState(
|
|
RuntimeGenerationToken generation,
|
|
IRuntimeGenerationResetHost host)
|
|
{
|
|
if (_state is { } pending)
|
|
{
|
|
if (pending.Generation != generation)
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"Runtime generation {pending.Generation.Value} reset "
|
|
+ $"must converge before generation {generation.Value} can reset.");
|
|
}
|
|
if (!ReferenceEquals(pending.Host, host))
|
|
{
|
|
throw new InvalidOperationException(
|
|
"An in-progress Runtime generation reset cannot replace "
|
|
+ "its borrowed projection host.");
|
|
}
|
|
return pending;
|
|
}
|
|
|
|
if (_hasCompletedGeneration)
|
|
{
|
|
if (generation == _lastCompletedGeneration)
|
|
return ResetState.Completed(generation, host);
|
|
if (generation.Value < _lastCompletedGeneration.Value)
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"Runtime generation {generation.Value} reset is stale; "
|
|
+ $"generation {_lastCompletedGeneration.Value} already converged.");
|
|
}
|
|
}
|
|
|
|
var created = new ResetState(
|
|
generation,
|
|
host,
|
|
checked(++_nextTransactionId));
|
|
_state = created;
|
|
return created;
|
|
}
|
|
|
|
private void Drain(ResetState state)
|
|
{
|
|
while (state.Stage is not RuntimeGenerationResetStage.Complete)
|
|
{
|
|
switch (state.Stage)
|
|
{
|
|
case RuntimeGenerationResetStage.Transit:
|
|
Advance(state, _transit.ResetSession);
|
|
break;
|
|
case RuntimeGenerationResetStage.CommandTargets:
|
|
Advance(state, _communication.ResetCommandTargets);
|
|
break;
|
|
case RuntimeGenerationResetStage.ExternalContainer:
|
|
// Slice 5.3: the vendor browse session shares the
|
|
// external-container stage rather than claiming a new
|
|
// enum ordinal — both are client-local "open server
|
|
// object" sessions torn down uniformly at session
|
|
// reset/portal-out/logout (research doc §C.2's
|
|
// "generation/lifecycle contract every other J4/J5 child
|
|
// follows").
|
|
Advance(state, () =>
|
|
{
|
|
_inventory.ResetExternalContainer();
|
|
_inventory.ResetVendor();
|
|
});
|
|
break;
|
|
case RuntimeGenerationResetStage.Actions:
|
|
Advance(state, _actions.ResetSession);
|
|
break;
|
|
case RuntimeGenerationResetStage.Movement:
|
|
Advance(state, _movement.ResetSession);
|
|
break;
|
|
case RuntimeGenerationResetStage.ObjectTable:
|
|
Advance(state, _entityObjects.ClearObjects);
|
|
break;
|
|
case RuntimeGenerationResetStage.Character:
|
|
Advance(state, _character.ResetSession);
|
|
break;
|
|
case RuntimeGenerationResetStage.ItemMana:
|
|
Advance(state, _inventory.ResetItemMana);
|
|
break;
|
|
case RuntimeGenerationResetStage.Friends:
|
|
Advance(state, _communication.ResetFriends);
|
|
break;
|
|
case RuntimeGenerationResetStage.Squelch:
|
|
Advance(state, _communication.ResetSquelch);
|
|
break;
|
|
case RuntimeGenerationResetStage.NegotiatedChannels:
|
|
Advance(
|
|
state,
|
|
_communication.ResetNegotiatedChannels);
|
|
break;
|
|
case RuntimeGenerationResetStage.Fellowship:
|
|
Advance(state, _fellowship.ResetSession);
|
|
break;
|
|
case RuntimeGenerationResetStage.Allegiance:
|
|
Advance(state, _allegiance.ResetSession);
|
|
break;
|
|
case RuntimeGenerationResetStage.Trade:
|
|
Advance(state, _trade.Clear);
|
|
break;
|
|
case RuntimeGenerationResetStage.BeginEntityRetirement:
|
|
_ = _entityObjects.BeginSessionClear();
|
|
state.Retirements = _entityObjects
|
|
.CaptureSessionClearRetirements()
|
|
.ToArray();
|
|
state.Stage = RuntimeGenerationResetStage.RetireEntities;
|
|
break;
|
|
case RuntimeGenerationResetStage.RetireEntities:
|
|
RetireCurrentEntity(state);
|
|
break;
|
|
case RuntimeGenerationResetStage.DrainHostProjection:
|
|
state.Host.DrainEntityProjectionBoundary();
|
|
state.Stage =
|
|
RuntimeGenerationResetStage.CompleteCanonicalEntities;
|
|
break;
|
|
case RuntimeGenerationResetStage.CompleteCanonicalEntities:
|
|
if (!_entityObjects.CompleteSessionClearIfConverged())
|
|
{
|
|
throw new InvalidOperationException(
|
|
"Canonical entity/object lifetime still owns an "
|
|
+ "unacknowledged session retirement.");
|
|
}
|
|
state.Stage =
|
|
RuntimeGenerationResetStage.CompleteHostProjection;
|
|
break;
|
|
case RuntimeGenerationResetStage.CompleteHostProjection:
|
|
state.Host.CompleteEntityProjectionRetirement();
|
|
state.Stage = RuntimeGenerationResetStage.ChatIdentity;
|
|
break;
|
|
case RuntimeGenerationResetStage.ChatIdentity:
|
|
// CH2 REJECT-review rework (SHOULD-FIX 1,
|
|
// docs/research/2026-08-09-ch2-review-findings.md):
|
|
// RuntimeCommunicationState.ResetSpewBox was dead code —
|
|
// no caller reset the transient SpewBox queue at
|
|
// generation boundaries even though ResetChatIdentity
|
|
// (the chat transcript's identity/dedup reset) already
|
|
// ran here every generation. They share this stage
|
|
// because they're the same lifetime boundary — a fresh
|
|
// generation must not resurrect a stale refusal line —
|
|
// even though they differ in WHAT they reset:
|
|
// ResetChatIdentity preserves the visible transcript,
|
|
// ResetSpewBox clears it (see RuntimeCommunicationState's
|
|
// own doc comments on each).
|
|
Advance(state, () =>
|
|
{
|
|
_communication.ResetChatIdentity();
|
|
_communication.ResetSpewBox();
|
|
});
|
|
break;
|
|
case RuntimeGenerationResetStage.PlayerSnapshots:
|
|
Advance(state, _inventory.ResetPlayerSnapshots);
|
|
break;
|
|
case RuntimeGenerationResetStage.PlayerIdentity:
|
|
_identity.ResetSession();
|
|
Complete(state);
|
|
break;
|
|
default:
|
|
throw new InvalidOperationException(
|
|
$"Unsupported Runtime generation reset stage {state.Stage}.");
|
|
}
|
|
}
|
|
}
|
|
|
|
private void RetireCurrentEntity(ResetState state)
|
|
{
|
|
RuntimeEntityRecord[] retirements = state.Retirements ?? [];
|
|
if (state.RetirementCursor >= retirements.Length)
|
|
{
|
|
state.Stage = RuntimeGenerationResetStage.DrainHostProjection;
|
|
return;
|
|
}
|
|
|
|
RuntimeEntityRecord current = retirements[state.RetirementCursor];
|
|
if (!state.CurrentProjectionAcknowledged)
|
|
{
|
|
state.Host.RetireEntityProjection(current);
|
|
state.CurrentProjectionAcknowledged = true;
|
|
}
|
|
|
|
_entityObjects.CompleteSessionEntityRetirement(current);
|
|
state.CurrentProjectionAcknowledged = false;
|
|
state.RetirementCursor++;
|
|
}
|
|
|
|
private static void Advance(ResetState state, Action action)
|
|
{
|
|
action();
|
|
state.Stage++;
|
|
}
|
|
|
|
private void Complete(ResetState state)
|
|
{
|
|
state.Stage = RuntimeGenerationResetStage.Complete;
|
|
_lastCompletedGeneration = state.Generation;
|
|
_hasCompletedGeneration = true;
|
|
_state = null;
|
|
}
|
|
|
|
private sealed class ResetState
|
|
{
|
|
public ResetState(
|
|
RuntimeGenerationToken generation,
|
|
IRuntimeGenerationResetHost host,
|
|
long transactionId)
|
|
{
|
|
Generation = generation;
|
|
Host = host;
|
|
TransactionId = transactionId;
|
|
Stage = RuntimeGenerationResetStage.Transit;
|
|
}
|
|
|
|
public RuntimeGenerationToken Generation { get; }
|
|
public IRuntimeGenerationResetHost Host { get; }
|
|
public long TransactionId { get; }
|
|
public RuntimeGenerationResetStage Stage { get; set; }
|
|
public RuntimeEntityRecord[]? Retirements { get; set; }
|
|
public int RetirementCursor { get; set; }
|
|
public bool CurrentProjectionAcknowledged { get; set; }
|
|
|
|
public static ResetState Completed(
|
|
RuntimeGenerationToken generation,
|
|
IRuntimeGenerationResetHost host) =>
|
|
new(generation, host, 0)
|
|
{
|
|
Stage = RuntimeGenerationResetStage.Complete,
|
|
};
|
|
}
|
|
}
|