Blocker 1: an unrecognized "@allegiance <sub>" subcommand escaped TryMatchAllegiance (which only claimed "info"/"hometown") and fell through the unregistered-tag channel fallback, broadcasting the raw subcommand text to the Allegiance chat channel (0x02000000). Retail's own DoAllegiance never reaches DoChannelCommand for an unrecognized subcommand — it claims the whole verb and prints its own client-local refusal. TryMatchAllegiance now claims "allegiance"/"all" unconditionally and shows retail's "Please see @help Allegiance..." text; ChatCommandRouter also gained a blanket RetailClientCommandCatalog.KnownVerbs ownership guard in TryDispatchChannelFallback as defense in depth. Blocker 2: "@house abandon" sent 0x021F immediately with no confirmation. Retail runs a real two-stage dialog before Event_AbandonHouse(); ported both verbatim strings and chained two ShowConfirmation calls. Should-fixes: a bare unregistered tag with no text now passes through silently instead of showing a refusal that belongs to a different retail function; @join/@leave update RuntimeCharacterOptionsState locally (new SetOptionBit) before the wire push so the Turbine membership gate stops refusing a just-joined room; @permit accepts multi-word names; @clist/ @on/@off validate shape only and raise WeenieError 0x422 for an unknown tag; @mr/@pr help text is now the verbatim retail strings; corrected issue #360, register row TS-68, the campaign doc's B.7 note, and a stale RetailChannelTagTable comment; filed issue #363 + register row AP-183 for the deferred error-typing debt. Nits: fixed TryMatchHouse's stale doc comment, the AP-182/@title "stores the value" comments (the binding is a no-op), IsUnregisteredFallbackTag's olthoi false-positive, added /g and /rp binding-level conformance pins, made @index ignore extra arguments, and noted the six removed invented verbs in ISSUES.md. Suite: 12,216 passed / 4 skipped / 0 failed (Release), up from CH4's 12,190/4/0 — net +26 tests, no removals. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
885 lines
34 KiB
C#
885 lines
34 KiB
C#
using AcDream.Core.Player;
|
|
using AcDream.Core.Net.Messages;
|
|
using AcDream.Core.Spells;
|
|
using AcDream.Core.Items;
|
|
using AcDream.Core.Properties;
|
|
|
|
namespace AcDream.Runtime.Gameplay;
|
|
|
|
public readonly record struct RuntimeCharacterOwnershipSnapshot(
|
|
bool IsDisposed,
|
|
bool InternalSubscriptionsAttached,
|
|
int LearnedSpellCount,
|
|
int ActiveEnchantmentCount,
|
|
int DesiredComponentCount,
|
|
int FavoriteSpellCount,
|
|
int VitalCount,
|
|
int AttributeCount,
|
|
int SkillCount,
|
|
int PositionCount,
|
|
int PropertyCount,
|
|
bool OptionsAreDefaults,
|
|
bool MovementSkillsAreReset,
|
|
/// <summary>C0-2: <see cref="RuntimeCharacterState.AutonomyLevel"/> is back at retail's default (<see cref="RuntimeCharacterState.FullAutonomyLevel"/>).</summary>
|
|
bool AutonomyIsDefault = true)
|
|
{
|
|
public bool IsConverged =>
|
|
IsDisposed
|
|
&& !InternalSubscriptionsAttached
|
|
&& LearnedSpellCount == 0
|
|
&& ActiveEnchantmentCount == 0
|
|
&& DesiredComponentCount == 0
|
|
&& FavoriteSpellCount == 0
|
|
&& VitalCount == 0
|
|
&& AttributeCount == 0
|
|
&& SkillCount == 0
|
|
&& PositionCount == 0
|
|
&& PropertyCount == 0
|
|
&& OptionsAreDefaults
|
|
&& MovementSkillsAreReset
|
|
&& AutonomyIsDefault;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Canonical presentation-independent owner for the local character's magic
|
|
/// and player-sheet state. The two objects form one lifetime group because
|
|
/// vital maxima read active enchantments from this exact spellbook.
|
|
/// </summary>
|
|
public sealed class RuntimeCharacterState : IDisposable
|
|
{
|
|
/// <summary>ACE Skill enum ordinal for Run (K-fix7 / pseudocode doc §5).</summary>
|
|
public const uint RunSkillId = 24u;
|
|
/// <summary>ACE Skill enum ordinal for Jump (K-fix7 / pseudocode doc §5).</summary>
|
|
public const uint JumpSkillId = 22u;
|
|
/// <summary>
|
|
/// C0-2/F5(b): retail <c>CommandInterpreter</c>'s own default, set at
|
|
/// construction (pseudo-C 699752, <c>this->autonomy_level = 2;</c>, a
|
|
/// direct field write, not a <c>SetAutonomyLevel</c> call) and by the
|
|
/// command-line-only override at admission
|
|
/// (<c>command_line_autonomy_level</c>, pseudo-C 1088429, itself
|
|
/// defaulting to <c>0x2</c>). Exactly ONE retail caller of
|
|
/// <c>CommandInterpreter::SetAutonomyLevel</c> exists in the named
|
|
/// retail decomp - the startup construction path at pseudo-C 94102
|
|
/// (<c>cmdinterp->vtable->SetAutonomyLevel(cmdinterp, command_line_autonomy_level)</c>)
|
|
/// - it is a startup/debug knob, not a per-play-session gameplay
|
|
/// toggle, so acdream's own default matches retail's value exactly and
|
|
/// nothing in ordinary play ever changes it.
|
|
/// </summary>
|
|
public const uint FullAutonomyLevel = 2u;
|
|
|
|
private bool _disposed;
|
|
private long _characterRevision;
|
|
private long _spellbookRevision;
|
|
private bool _internalSubscriptionsAttached;
|
|
private uint _autonomyLevel = FullAutonomyLevel;
|
|
|
|
/// <summary>
|
|
/// Campaign P Slice P1 (2026-07-30): the pre-<c>EnchantSkill</c> base
|
|
/// run/jump skill (formulaBonus+init+ranks, as parsed from
|
|
/// PlayerDescription) — kept so <see cref="RecomputeMovementSkills"/>
|
|
/// can re-derive the vitae/enchantment-adjusted value purely from a
|
|
/// spellbook change, without waiting for a fresh skill push. -1 =
|
|
/// unknown (mirrors <see cref="RuntimeMovementSkillState"/>'s own
|
|
/// sentinel convention).
|
|
/// </summary>
|
|
private int _runSkillBase = -1;
|
|
private int _jumpSkillBase = -1;
|
|
private PlayerSkillMath.AugmentationBonuses _movementSkillAugmentations;
|
|
|
|
public RuntimeCharacterState(SpellTable? spellTable = null)
|
|
{
|
|
Spellbook = new Spellbook(spellTable);
|
|
LocalPlayer = new LocalPlayerState(Spellbook);
|
|
Options = new RuntimeCharacterOptionsState();
|
|
MovementSkills = new RuntimeMovementSkillState();
|
|
View = new CharacterView(this);
|
|
Spellbook.StateChanged += OnSpellbookChanged;
|
|
Spellbook.EnchantmentsChanged += OnEnchantmentsChangedForMovement;
|
|
LocalPlayer.Changed += OnVitalChanged;
|
|
LocalPlayer.AttributeChanged += OnAttributeChanged;
|
|
LocalPlayer.CharacterChanged += OnCharacterChanged;
|
|
_internalSubscriptionsAttached = true;
|
|
}
|
|
|
|
public Spellbook Spellbook { get; }
|
|
public LocalPlayerState LocalPlayer { get; }
|
|
public RuntimeCharacterOptionsState Options { get; }
|
|
public RuntimeMovementSkillState MovementSkills { get; }
|
|
public IRuntimeCharacterView View { get; }
|
|
public bool IsDisposed => _disposed;
|
|
|
|
/// <summary>
|
|
/// Campaign CH slice CH3 (2026-08-09): retail
|
|
/// <c>PlayerModule::IsOlthoi</c> / ACE <c>Player.IsOlthoiPlayer</c>
|
|
/// (<c>Player.cs:169</c>, <c>HeritageGroup == Olthoi ||
|
|
/// HeritageGroup == OlthoiAcid</c>) — the gate
|
|
/// <c>TurbineChatMembershipGate</c> consults for the Olthoi room instead
|
|
/// of a <c>Hear*Chat</c> option (there is no such toggle; only heritage
|
|
/// gates that room). Reads the already-parsed
|
|
/// <c>PropertyInt.HeritageGroup (188)</c> off the local player's own
|
|
/// property bundle — 0 (unparsed/unknown) reads as "not Olthoi", matching
|
|
/// every other heritage-gated check in this codebase.
|
|
/// </summary>
|
|
public bool IsOlthoiPlayer
|
|
{
|
|
get
|
|
{
|
|
int heritage = LocalPlayer.Properties.GetInt((uint)PropertyInt.HeritageGroup, 0);
|
|
return heritage == 12 || heritage == 13; // HeritageGroup.Olthoi / OlthoiAcid
|
|
}
|
|
}
|
|
|
|
/// <summary>Retail <c>CommandInterpreter::GetAutonomyLevel</c>.</summary>
|
|
public uint AutonomyLevel => Volatile.Read(ref _autonomyLevel);
|
|
|
|
/// <summary>
|
|
/// C0-2: retail <c>CommandInterpreter::UsePositionFromServer</c>
|
|
/// (pseudo-C 699506-699512: <c>result = this->autonomy_level != 2;</c>).
|
|
/// This is the source
|
|
/// <see cref="AcDream.Runtime.Entities.RuntimeInitialCreateContinuationExecutor.BindLiveInputs"/>
|
|
/// binds for <c>RuntimeInitialCreateExecutionInputs.UsePositionFromServer</c>
|
|
/// - the local-player-only interpolate gate consumed by
|
|
/// <c>RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition</c>.
|
|
/// </summary>
|
|
public bool UsePositionFromServer => AutonomyLevel != FullAutonomyLevel;
|
|
|
|
/// <summary>
|
|
/// Retail <c>CommandInterpreter::SetAutonomyLevel</c> (pseudo-C
|
|
/// 699542-699552): rejects any value above 2, otherwise commits.
|
|
/// F5(a): retail's own setter ALSO sends <c>SendAutonomyLevelEvent</c>
|
|
/// (pseudo-C 699550) after committing - this Runtime-only port has no
|
|
/// outbound wire concept to carry that event today (autonomy level has
|
|
/// no host caller yet). Any FUTURE host exposure of this setter (e.g. a
|
|
/// debug/admin command) MUST also send the equivalent outbound event -
|
|
/// do not port only the field write.
|
|
/// </summary>
|
|
public bool TrySetAutonomyLevel(uint level)
|
|
{
|
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
|
if (level > FullAutonomyLevel)
|
|
return false;
|
|
Volatile.Write(ref _autonomyLevel, level);
|
|
return true;
|
|
}
|
|
|
|
public RuntimeCharacterOwnershipSnapshot CaptureOwnership()
|
|
{
|
|
int favoriteCount = 0;
|
|
for (int tab = 0; tab < 8; tab++)
|
|
favoriteCount += Spellbook.GetFavorites(tab).Count;
|
|
|
|
int vitalCount = 0;
|
|
foreach (LocalPlayerState.VitalKind kind
|
|
in Enum.GetValues<LocalPlayerState.VitalKind>())
|
|
{
|
|
if (LocalPlayer.Get(kind) is not null)
|
|
vitalCount++;
|
|
}
|
|
|
|
int attributeCount = 0;
|
|
foreach (LocalPlayerState.AttributeKind kind
|
|
in Enum.GetValues<LocalPlayerState.AttributeKind>())
|
|
{
|
|
if (LocalPlayer.GetAttribute(kind) is not null)
|
|
attributeCount++;
|
|
}
|
|
|
|
PropertyBundle properties = LocalPlayer.Properties;
|
|
int propertyCount =
|
|
properties.Bools.Count
|
|
+ properties.Ints.Count
|
|
+ properties.Int64s.Count
|
|
+ properties.Floats.Count
|
|
+ properties.Strings.Count
|
|
+ properties.DataIds.Count
|
|
+ properties.InstanceIds.Count;
|
|
RuntimeCharacterOptionsSnapshot options = Options.Snapshot;
|
|
return new RuntimeCharacterOwnershipSnapshot(
|
|
_disposed,
|
|
_internalSubscriptionsAttached,
|
|
Spellbook.LearnedCount,
|
|
Spellbook.ActiveCount,
|
|
Spellbook.DesiredComponents.Count,
|
|
favoriteCount,
|
|
vitalCount,
|
|
attributeCount,
|
|
LocalPlayer.Skills.Count,
|
|
LocalPlayer.Positions.Count,
|
|
propertyCount,
|
|
options.Options1 == RuntimeCharacterOptionsState.DefaultOptions1
|
|
&& options.Options2
|
|
== RuntimeCharacterOptionsState.DefaultOptions2,
|
|
MovementSkills.RunSkill == -1
|
|
&& MovementSkills.JumpSkill == -1
|
|
&& MovementSkills.Burden == 0f
|
|
&& MovementSkills.CurrentStamina == -1
|
|
&& MovementSkills.OwnPwdBitfield == 0u
|
|
&& MovementSkills.PlayerKillerStatus == -1
|
|
&& MovementSkills.LastPkAttackTimestamp is null
|
|
&& _runSkillBase == -1
|
|
&& _jumpSkillBase == -1
|
|
&& _movementSkillAugmentations == default,
|
|
AutonomyLevel == FullAutonomyLevel);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Campaign P Slice P1 (2026-07-30): stores the pre-<c>EnchantSkill</c>
|
|
/// base run/jump skill (PlayerDescription's formulaBonus+init+ranks)
|
|
/// and pushes the augmentation/vitae/enchantment-adjusted result into
|
|
/// <see cref="MovementSkills"/> — the SAME call shape
|
|
/// <c>LiveSessionEventRouter</c>'s pre-P1 <c>onSkillsUpdated</c> callback
|
|
/// already used (<c>MovementSkills.Update(runSkill, jumpSkill)</c>), now
|
|
/// routed through the retail <c>CEnchantmentRegistry::EnchantSkill</c>
|
|
/// chain. A value < 0 leaves that half's base untouched (matches
|
|
/// <see cref="RuntimeMovementSkillState.Update"/>'s own "don't touch"
|
|
/// convention for a missing half).
|
|
/// </summary>
|
|
public void UpdateMovementSkillBase(int runSkillBase, int jumpSkillBase)
|
|
{
|
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
|
if (runSkillBase >= 0) _runSkillBase = runSkillBase;
|
|
if (jumpSkillBase >= 0) _jumpSkillBase = jumpSkillBase;
|
|
RecomputeMovementSkills();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Installs the player-quality augmentation terms consumed by retail
|
|
/// <c>CACQualities::InqRunRate</c>/<c>InqJumpVelocity</c>. The object
|
|
/// table remains authoritative for live PropertyInt updates; Runtime
|
|
/// retains only this immutable derived snapshot.
|
|
/// </summary>
|
|
public void UpdateMovementSkillAugmentations(
|
|
PlayerSkillMath.AugmentationBonuses augmentations)
|
|
{
|
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
|
if (_movementSkillAugmentations == augmentations)
|
|
return;
|
|
_movementSkillAugmentations = augmentations;
|
|
RecomputeMovementSkills();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Re-derives the adjusted run/jump skill from the stored base plus the
|
|
/// CURRENT spellbook state (vitae + skill enchantments) — matching
|
|
/// retail's <c>CEnchantmentRegistry::EnchantSkill</c> (0x005947b0):
|
|
/// vitae multiplies first, then matching Skill-flagged mult/add
|
|
/// enchantments, floored to 0 below 0.5, truncated to int. Fires on
|
|
/// every base push AND on every <see cref="Spellbook.EnchantmentsChanged"/>
|
|
/// notification (a vitae/buff change alone must move the produced rate
|
|
/// without a fresh PlayerDescription).
|
|
/// </summary>
|
|
private void RecomputeMovementSkills()
|
|
{
|
|
int run = _runSkillBase >= 0
|
|
? CalculateMovementSkill(_runSkillBase, RunSkillId)
|
|
: -1;
|
|
int jump = _jumpSkillBase >= 0
|
|
? CalculateMovementSkill(_jumpSkillBase, JumpSkillId)
|
|
: -1;
|
|
// #266 apparatus (permanent, low-volume — fires only on skill-base or
|
|
// enchantment changes, the [snap] class): the full stat-chain state at
|
|
// each recompute. If vitae is active but runMod prints 1.0, the break
|
|
// is in GetSkillMod's record handling; if runMod is right but the
|
|
// felt speed doesn't change, the break is downstream of MovementSkills.
|
|
EnchantmentMath.VitalMod runMod = Spellbook.GetSkillMod(RunSkillId);
|
|
int activeCount = System.Linq.Enumerable.Count(Spellbook.ActiveEnchantments);
|
|
System.Console.WriteLine(
|
|
System.FormattableString.Invariant(
|
|
$"[stat-chain] base run={_runSkillBase} jump={_jumpSkillBase} runMod={runMod.Multiplier:F4}x+{runMod.Additive:F1} -> eff run={run} jump={jump} (activeEnchantments={activeCount})"));
|
|
MovementSkills.Update(run, jump);
|
|
}
|
|
|
|
private int CalculateMovementSkill(int baseSkill, uint skillId)
|
|
{
|
|
EnchantmentMath.VitalMod mod = Spellbook.GetSkillMod(skillId);
|
|
float vitae = EnchantmentMath.GetVitaeMultiplier(
|
|
Spellbook.ActiveEnchantments);
|
|
uint advancementClass = LocalPlayer.GetSkill(skillId)?.Status ?? 0u;
|
|
return PlayerSkillMath.Calculate(
|
|
baseSkill,
|
|
skillId,
|
|
advancementClass,
|
|
_movementSkillAugmentations,
|
|
mod,
|
|
vitae).EffectiveLevel;
|
|
}
|
|
|
|
private void OnEnchantmentsChangedForMovement() => RecomputeMovementSkills();
|
|
|
|
/// <summary>
|
|
/// Installs immutable DAT metadata without transferring its ownership to
|
|
/// Runtime. The content host may install one table after portal.dat opens.
|
|
/// </summary>
|
|
public void InstallSpellMetadata(SpellTable spellTable)
|
|
{
|
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
|
Spellbook.InstallMetadata(spellTable);
|
|
}
|
|
|
|
public void ResetSpellbook()
|
|
{
|
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
|
Spellbook.Clear();
|
|
}
|
|
|
|
public void ResetLocalPlayer()
|
|
{
|
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
|
LocalPlayer.Clear();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Apply retail's local favorite insertion before sending the matching
|
|
/// character event.
|
|
/// </summary>
|
|
public bool TryAddFavorite(
|
|
int tabIndex,
|
|
int position,
|
|
uint spellId,
|
|
Action publishOutbound)
|
|
{
|
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
|
ArgumentNullException.ThrowIfNull(publishOutbound);
|
|
if ((uint)tabIndex >= 8u || position < 0 || spellId == 0u)
|
|
return false;
|
|
Spellbook.SetFavorite(tabIndex, position, spellId);
|
|
publishOutbound();
|
|
return true;
|
|
}
|
|
|
|
public bool TryRemoveFavorite(
|
|
int tabIndex,
|
|
uint spellId,
|
|
Action publishOutbound)
|
|
{
|
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
|
ArgumentNullException.ThrowIfNull(publishOutbound);
|
|
if ((uint)tabIndex >= 8u || spellId == 0u)
|
|
return false;
|
|
Spellbook.RemoveFavorite(tabIndex, spellId);
|
|
publishOutbound();
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Apply and publish a spellbook filter only when it differs, matching
|
|
/// <c>gmSpellbookUI::UpdateFilter @ 0x0048B5E0</c>.
|
|
/// </summary>
|
|
public void SetSpellbookFilter(
|
|
uint filters,
|
|
Action publishOutbound)
|
|
{
|
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
|
ArgumentNullException.ThrowIfNull(publishOutbound);
|
|
if (Spellbook.SpellbookFilters == filters)
|
|
return;
|
|
Spellbook.SetSpellbookFilters(filters);
|
|
publishOutbound();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Retail publishes the desired-component event before changing its local
|
|
/// PlayerModule table.
|
|
/// </summary>
|
|
public bool TrySetDesiredComponent(
|
|
uint componentId,
|
|
uint amount,
|
|
Action publishOutbound)
|
|
{
|
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
|
ArgumentNullException.ThrowIfNull(publishOutbound);
|
|
if (componentId == 0u || amount > 5000u)
|
|
return false;
|
|
try
|
|
{
|
|
publishOutbound();
|
|
}
|
|
finally
|
|
{
|
|
Spellbook.SetDesiredComponent(componentId, amount);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
public void ClearDesiredComponents(Action publishOutbound)
|
|
{
|
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
|
ArgumentNullException.ThrowIfNull(publishOutbound);
|
|
try
|
|
{
|
|
publishOutbound();
|
|
}
|
|
finally
|
|
{
|
|
Spellbook.ClearDesiredComponents();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Clears both coupled owners while retaining every failed suffix for a
|
|
/// retry. State mutation happens before the existing synchronous
|
|
/// invalidation callbacks, so retrying is safe and convergent.
|
|
/// </summary>
|
|
public void ResetSession()
|
|
{
|
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
|
List<Exception>? failures = null;
|
|
Try(Spellbook.Clear, ref failures);
|
|
Try(LocalPlayer.Clear, ref failures);
|
|
Try(Options.ResetSession, ref failures);
|
|
_runSkillBase = -1;
|
|
_jumpSkillBase = -1;
|
|
_movementSkillAugmentations = default;
|
|
Volatile.Write(ref _autonomyLevel, FullAutonomyLevel);
|
|
Try(MovementSkills.ResetSession, ref failures);
|
|
if (failures is not null)
|
|
{
|
|
throw new AggregateException(
|
|
"Runtime character state did not converge during reset.",
|
|
failures);
|
|
}
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
if (_disposed)
|
|
return;
|
|
List<Exception>? failures = null;
|
|
try
|
|
{
|
|
// Do not call ResetSession as one opaque step here. Terminal
|
|
// disposal must run every suffix even when an external UI observer
|
|
// throws from one Core owner's synchronous clear notification.
|
|
Try(Spellbook.Clear, ref failures);
|
|
Try(LocalPlayer.Clear, ref failures);
|
|
Try(Options.ResetSession, ref failures);
|
|
_runSkillBase = -1;
|
|
_jumpSkillBase = -1;
|
|
_movementSkillAugmentations = default;
|
|
Volatile.Write(ref _autonomyLevel, FullAutonomyLevel);
|
|
Try(MovementSkills.ResetSession, ref failures);
|
|
}
|
|
finally
|
|
{
|
|
Spellbook.StateChanged -= OnSpellbookChanged;
|
|
Spellbook.EnchantmentsChanged -= OnEnchantmentsChangedForMovement;
|
|
LocalPlayer.Changed -= OnVitalChanged;
|
|
LocalPlayer.AttributeChanged -= OnAttributeChanged;
|
|
LocalPlayer.CharacterChanged -= OnCharacterChanged;
|
|
_internalSubscriptionsAttached = false;
|
|
_disposed = true;
|
|
}
|
|
if (failures is not null)
|
|
{
|
|
throw new AggregateException(
|
|
"Runtime character state did not converge during disposal.",
|
|
failures);
|
|
}
|
|
}
|
|
|
|
private void OnSpellbookChanged() =>
|
|
Interlocked.Increment(ref _spellbookRevision);
|
|
|
|
private void OnVitalChanged(LocalPlayerState.VitalKind _) =>
|
|
Interlocked.Increment(ref _characterRevision);
|
|
|
|
private void OnAttributeChanged(LocalPlayerState.AttributeKind _) =>
|
|
Interlocked.Increment(ref _characterRevision);
|
|
|
|
private void OnCharacterChanged() =>
|
|
Interlocked.Increment(ref _characterRevision);
|
|
|
|
private static void Try(Action action, ref List<Exception>? failures)
|
|
{
|
|
try
|
|
{
|
|
action();
|
|
}
|
|
catch (Exception error)
|
|
{
|
|
(failures ??= []).Add(error);
|
|
}
|
|
}
|
|
|
|
private sealed class CharacterView(RuntimeCharacterState owner)
|
|
: IRuntimeCharacterView
|
|
{
|
|
public RuntimeCharacterSnapshot Snapshot => new(
|
|
Interlocked.Read(ref owner._characterRevision),
|
|
Interlocked.Read(ref owner._spellbookRevision),
|
|
owner.Options.Snapshot,
|
|
owner.MovementSkills.Snapshot,
|
|
owner.Spellbook.LearnedSpells.Count,
|
|
owner.Spellbook.ActiveEnchantments.Count(),
|
|
owner.Spellbook.DesiredComponents.Count,
|
|
owner.LocalPlayer.Skills.Count,
|
|
owner.Spellbook.SpellbookFilters);
|
|
|
|
public bool TryGetVital(int kind, out RuntimeVitalSnapshot vital)
|
|
{
|
|
if (!Enum.IsDefined((LocalPlayerState.VitalKind)kind)
|
|
|| owner.LocalPlayer.Get((LocalPlayerState.VitalKind)kind)
|
|
is not LocalPlayerState.VitalSnapshot current)
|
|
{
|
|
vital = default;
|
|
return false;
|
|
}
|
|
|
|
vital = new RuntimeVitalSnapshot(
|
|
kind,
|
|
current.Ranks,
|
|
current.Start,
|
|
current.Xp,
|
|
current.Current,
|
|
owner.LocalPlayer.GetMaxApprox(
|
|
(LocalPlayerState.VitalKind)kind) ?? 0u);
|
|
return true;
|
|
}
|
|
|
|
public bool TryGetAttribute(
|
|
int kind,
|
|
out RuntimeAttributeSnapshot attribute)
|
|
{
|
|
if (!Enum.IsDefined((LocalPlayerState.AttributeKind)kind)
|
|
|| owner.LocalPlayer.GetAttribute(
|
|
(LocalPlayerState.AttributeKind)kind)
|
|
is not LocalPlayerState.AttributeSnapshot current)
|
|
{
|
|
attribute = default;
|
|
return false;
|
|
}
|
|
|
|
attribute = new RuntimeAttributeSnapshot(
|
|
kind,
|
|
current.Ranks,
|
|
current.Start,
|
|
current.Xp,
|
|
current.Current);
|
|
return true;
|
|
}
|
|
|
|
public bool TryGetSkill(uint skillId, out RuntimeSkillSnapshot skill)
|
|
{
|
|
if (owner.LocalPlayer.GetSkill(skillId)
|
|
is not LocalPlayerState.SkillSnapshot current)
|
|
{
|
|
skill = default;
|
|
return false;
|
|
}
|
|
|
|
skill = new RuntimeSkillSnapshot(
|
|
current.SkillId,
|
|
current.Ranks,
|
|
current.Status,
|
|
current.Xp,
|
|
current.Init,
|
|
current.Resistance,
|
|
current.LastUsed,
|
|
current.FormulaBonus,
|
|
current.CurrentLevel);
|
|
return true;
|
|
}
|
|
|
|
public bool KnowsSpell(uint spellId) =>
|
|
owner.Spellbook.Knows(spellId);
|
|
|
|
public bool TryGetFavorite(
|
|
int tabIndex,
|
|
int position,
|
|
out uint spellId)
|
|
{
|
|
IReadOnlyList<uint> favorites =
|
|
owner.Spellbook.GetFavorites(tabIndex);
|
|
if ((uint)position >= (uint)favorites.Count)
|
|
{
|
|
spellId = 0u;
|
|
return false;
|
|
}
|
|
spellId = favorites[position];
|
|
return true;
|
|
}
|
|
|
|
public bool TryGetDesiredComponent(
|
|
uint componentId,
|
|
out uint amount) =>
|
|
owner.Spellbook.DesiredComponents.TryGetValue(
|
|
componentId,
|
|
out amount);
|
|
}
|
|
}
|
|
|
|
public readonly record struct RuntimeCharacterOptionsSnapshot(
|
|
uint Options1,
|
|
uint Options2,
|
|
long Revision)
|
|
{
|
|
public bool DragItemOnPlayerOpensSecureTrade =>
|
|
(Options1
|
|
& (uint)PlayerDescriptionParser.CharacterOptions1
|
|
.DragItemOnPlayerOpensSecureTrade) != 0u;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Canonical session-owned copy of retail's two character-option bitfields.
|
|
/// <c>PlayerModule::PlayerModule @ 0x005D51F0</c> installs the defaults.
|
|
/// Runtime reset restores the equivalent fresh-player-module state because
|
|
/// one Runtime owner survives across graphical and no-window sessions.
|
|
/// </summary>
|
|
public sealed class RuntimeCharacterOptionsState
|
|
{
|
|
public const uint DefaultOptions1 =
|
|
(uint)PlayerDescriptionParser.CharacterOptions1.Default;
|
|
public const uint DefaultOptions2 = 0x00948700u;
|
|
|
|
private uint _options1 = DefaultOptions1;
|
|
private uint _options2 = DefaultOptions2;
|
|
private long _revision;
|
|
|
|
public uint Options1 => Volatile.Read(ref _options1);
|
|
public uint Options2 => Volatile.Read(ref _options2);
|
|
public long Revision => Interlocked.Read(ref _revision);
|
|
public RuntimeCharacterOptionsSnapshot Snapshot =>
|
|
new(_options1, _options2, Revision);
|
|
|
|
public bool DragItemOnPlayerOpensSecureTrade =>
|
|
Snapshot.DragItemOnPlayerOpensSecureTrade;
|
|
|
|
public void Replace(uint options1, uint options2)
|
|
{
|
|
Volatile.Write(ref _options1, options1);
|
|
Volatile.Write(ref _options2, options2);
|
|
Interlocked.Increment(ref _revision);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Set ONE character-option bit locally, by its linear
|
|
/// <c>CharacterOptionId</c> (the same id carried on the wire by
|
|
/// <c>SetSingleCharacterOption (0x0005)</c>). Retail's
|
|
/// <c>PlayerModule::SetHearGeneralChat @0x005D35C0</c> (and its five
|
|
/// <c>SetHear*Chat</c> siblings) write the bit into this LOCAL copy
|
|
/// FIRST, before the client ever notifies the server. CH4
|
|
/// REJECT-review SHOULD-FIX 4 (2026-08-09): acdream's <c>@join</c>/
|
|
/// <c>@leave</c> previously pushed only the wire message and left this
|
|
/// state untouched, so <see cref="AcDream.Runtime.Gameplay.TurbineChatMembershipGate"/>
|
|
/// kept refusing a room the player had just joined until the next
|
|
/// <c>PlayerDescription</c> happened to arrive. Only the six
|
|
/// <c>ListenTo*Chat</c> ids <c>CharacterOptionId</c> models are
|
|
/// recognized here; any other id is a silent no-op — this state only
|
|
/// tracks what the Turbine-chat membership gate needs, not a complete
|
|
/// <c>PlayerModule</c> mirror.
|
|
/// </summary>
|
|
public void SetOptionBit(uint characterOptionId, bool value)
|
|
{
|
|
(bool isOptions1, uint mask) = characterOptionId switch
|
|
{
|
|
(uint)CharacterOptionId.ListenToAllegianceChat =>
|
|
(true, (uint)PlayerDescriptionParser.CharacterOptions1.HearAllegianceChat),
|
|
(uint)CharacterOptionId.ListenToGeneralChat =>
|
|
(false, (uint)PlayerDescriptionParser.CharacterOptions2.HearGeneralChat),
|
|
(uint)CharacterOptionId.ListenToTradeChat =>
|
|
(false, (uint)PlayerDescriptionParser.CharacterOptions2.HearTradeChat),
|
|
(uint)CharacterOptionId.ListenToLFGChat =>
|
|
(false, (uint)PlayerDescriptionParser.CharacterOptions2.HearLFGChat),
|
|
(uint)CharacterOptionId.ListenToRoleplayChat =>
|
|
(false, (uint)PlayerDescriptionParser.CharacterOptions2.HearRoleplayChat),
|
|
(uint)CharacterOptionId.ListenToSocietyChat =>
|
|
(false, (uint)PlayerDescriptionParser.CharacterOptions2.HearSocietyChat),
|
|
_ => (false, 0u),
|
|
};
|
|
if (mask == 0u)
|
|
return;
|
|
|
|
if (isOptions1)
|
|
{
|
|
uint updated = value ? (Options1 | mask) : (Options1 & ~mask);
|
|
Volatile.Write(ref _options1, updated);
|
|
}
|
|
else
|
|
{
|
|
uint updated = value ? (Options2 | mask) : (Options2 & ~mask);
|
|
Volatile.Write(ref _options2, updated);
|
|
}
|
|
|
|
Interlocked.Increment(ref _revision);
|
|
}
|
|
|
|
public void ResetSession()
|
|
{
|
|
Volatile.Write(ref _options1, DefaultOptions1);
|
|
Volatile.Write(ref _options2, DefaultOptions2);
|
|
Interlocked.Increment(ref _revision);
|
|
}
|
|
}
|
|
|
|
public readonly record struct RuntimeMovementSkillSnapshot(
|
|
int RunSkill,
|
|
int JumpSkill,
|
|
long Revision,
|
|
// Campaign P Slice P1 (2026-07-30): retail InqLoad-equivalent burden
|
|
// ratio (0.0 unencumbered) and current stamina (-1 = unknown/don't-gate,
|
|
// matching RunSkill/JumpSkill's own sentinel convention).
|
|
float Burden = 0f,
|
|
int CurrentStamina = -1,
|
|
// TS-23 (Campaign P Slice P3, 2026-07-30): the local player's own
|
|
// PublicWeenieDesc._bitfield (0 = never pushed / no PK-relevant bits —
|
|
// a no-op OR, matching every pre-P3 caller) and the raw
|
|
// PlayerKillerStatus/LastPkAttackTimestamp pair §12b's PK-timer
|
|
// jump-cost bump reads (-1 / null = never pushed).
|
|
uint OwnPwdBitfield = 0u,
|
|
int PlayerKillerStatus = -1,
|
|
float? LastPkAttackTimestamp = null)
|
|
{
|
|
public bool IsComplete => RunSkill >= 0 && JumpSkill >= 0;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Server-authoritative run/jump/burden/stamina/PK-status values retained
|
|
/// independently of any graphical movement controller. App applies this
|
|
/// borrowed state whenever its presentation/physics controller exists or is
|
|
/// rebuilt. Campaign P Slice P1 (2026-07-30) extended this beyond run/jump
|
|
/// skill to the full stat-coupled-movement input set (burden, current
|
|
/// stamina) — see the pseudocode doc §9. Campaign P Slice P3 (2026-07-30)
|
|
/// further added the player's own PWD bitfield (PK/PKLite/Impenetrable
|
|
/// collision-exemption bits) and the PlayerKillerStatus/
|
|
/// LastPkAttackTimestamp pair (the PK-timer jump-cost bump) — the SAME
|
|
/// reactive push seam, since both ride the player's own ClientObject
|
|
/// property-update stream. RunSkill/JumpSkill arrive here ALREADY
|
|
/// vitae/enchantment-adjusted by
|
|
/// <see cref="RuntimeCharacterState.RecomputeMovementSkills"/>.
|
|
/// </summary>
|
|
public sealed class RuntimeMovementSkillState
|
|
{
|
|
private int _runSkill = -1;
|
|
private int _jumpSkill = -1;
|
|
private float _burden;
|
|
private int _currentStamina = -1;
|
|
private uint _ownPwdBitfield;
|
|
private int _playerKillerStatus = -1;
|
|
private float _lastPkAttackTimestamp;
|
|
private bool _hasLastPkAttackTimestamp;
|
|
private long _revision;
|
|
|
|
public int RunSkill => Volatile.Read(ref _runSkill);
|
|
public int JumpSkill => Volatile.Read(ref _jumpSkill);
|
|
public float Burden => Volatile.Read(ref _burden);
|
|
public int CurrentStamina => Volatile.Read(ref _currentStamina);
|
|
public uint OwnPwdBitfield => Volatile.Read(ref _ownPwdBitfield);
|
|
public int PlayerKillerStatus => Volatile.Read(ref _playerKillerStatus);
|
|
public float? LastPkAttackTimestamp =>
|
|
Volatile.Read(ref _hasLastPkAttackTimestamp)
|
|
? Volatile.Read(ref _lastPkAttackTimestamp)
|
|
: null;
|
|
public bool IsComplete => _runSkill >= 0 && _jumpSkill >= 0;
|
|
public long Revision => Interlocked.Read(ref _revision);
|
|
public RuntimeMovementSkillSnapshot Snapshot =>
|
|
new(
|
|
_runSkill,
|
|
_jumpSkill,
|
|
Revision,
|
|
_burden,
|
|
_currentStamina,
|
|
OwnPwdBitfield,
|
|
PlayerKillerStatus,
|
|
LastPkAttackTimestamp);
|
|
|
|
public void Update(int runSkill, int jumpSkill)
|
|
{
|
|
bool changed = false;
|
|
if (runSkill >= 0 && RunSkill != runSkill)
|
|
{
|
|
Volatile.Write(ref _runSkill, runSkill);
|
|
changed = true;
|
|
}
|
|
if (jumpSkill >= 0 && JumpSkill != jumpSkill)
|
|
{
|
|
Volatile.Write(ref _jumpSkill, jumpSkill);
|
|
changed = true;
|
|
}
|
|
if (changed)
|
|
Interlocked.Increment(ref _revision);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Pushes a fresh retail <c>InqLoad</c>-equivalent burden ratio (Strength
|
|
/// + augmentation property 0xE6 + EncumbranceVal property 5 — the same
|
|
/// inputs the burden HUD already assembles). Any value, including 0,
|
|
/// is a real reading.
|
|
/// </summary>
|
|
public void UpdateBurden(float burden)
|
|
{
|
|
if (Burden == burden) return;
|
|
Volatile.Write(ref _burden, burden);
|
|
Interlocked.Increment(ref _revision);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Pushes the current-stamina vital reading. Any non-negative value,
|
|
/// including 0 (exhausted — zeroes the effective run/jump skill), is
|
|
/// real; pass a negative value only to restore the "unknown" sentinel.
|
|
/// </summary>
|
|
public void UpdateStamina(int currentStamina)
|
|
{
|
|
if (CurrentStamina == currentStamina) return;
|
|
Volatile.Write(ref _currentStamina, currentStamina);
|
|
Interlocked.Increment(ref _revision);
|
|
}
|
|
|
|
/// <summary>
|
|
/// TS-23 (Campaign P Slice P3, 2026-07-30): pushes the local player's own
|
|
/// raw <c>PublicWeenieDesc._bitfield</c> — the PWD wire bit-space
|
|
/// <see cref="AcDream.Core.Physics.EntityCollisionFlagsExt.FromPwdBitfield"/>
|
|
/// decodes. Fires on the SAME <c>ClientObject</c> add/update events
|
|
/// <see cref="UpdateBurden"/> already reacts to (the player's own
|
|
/// PublicWeenieBitfield lives on the same row).
|
|
/// </summary>
|
|
public void UpdateOwnPwdBitfield(uint bitfield)
|
|
{
|
|
if (OwnPwdBitfield == bitfield) return;
|
|
Volatile.Write(ref _ownPwdBitfield, bitfield);
|
|
Interlocked.Increment(ref _revision);
|
|
}
|
|
|
|
/// <summary>
|
|
/// TS-23 §12b: pushes the raw <c>PlayerKillerStatus</c>
|
|
/// (<c>PropertyInt</c> 0x86) / <c>LastPkAttackTimestamp</c>
|
|
/// (<c>PropertyFloat</c> 0x91) pair feeding
|
|
/// <c>CACQualities::JumpStaminaCost</c>'s 20-second PK-timer bump. A
|
|
/// negative <paramref name="playerKillerStatus"/> restores "never
|
|
/// pushed" (matches <see cref="UpdateStamina"/>'s own sentinel
|
|
/// convention); <paramref name="lastPkAttackTimestamp"/> is <c>null</c>
|
|
/// when the property is absent.
|
|
/// </summary>
|
|
public void UpdatePlayerKillerStatus(int playerKillerStatus, float? lastPkAttackTimestamp)
|
|
{
|
|
bool changed = false;
|
|
if (PlayerKillerStatus != playerKillerStatus)
|
|
{
|
|
Volatile.Write(ref _playerKillerStatus, playerKillerStatus);
|
|
changed = true;
|
|
}
|
|
bool hasTimestamp = lastPkAttackTimestamp.HasValue;
|
|
float timestamp = lastPkAttackTimestamp ?? 0f;
|
|
if (Volatile.Read(ref _hasLastPkAttackTimestamp) != hasTimestamp
|
|
|| (hasTimestamp && Volatile.Read(ref _lastPkAttackTimestamp) != timestamp))
|
|
{
|
|
Volatile.Write(ref _lastPkAttackTimestamp, timestamp);
|
|
Volatile.Write(ref _hasLastPkAttackTimestamp, hasTimestamp);
|
|
changed = true;
|
|
}
|
|
if (changed)
|
|
Interlocked.Increment(ref _revision);
|
|
}
|
|
|
|
public void ResetSession()
|
|
{
|
|
Volatile.Write(ref _runSkill, -1);
|
|
Volatile.Write(ref _jumpSkill, -1);
|
|
Volatile.Write(ref _burden, 0f);
|
|
Volatile.Write(ref _currentStamina, -1);
|
|
Volatile.Write(ref _ownPwdBitfield, 0u);
|
|
Volatile.Write(ref _playerKillerStatus, -1);
|
|
Volatile.Write(ref _lastPkAttackTimestamp, 0f);
|
|
Volatile.Write(ref _hasLastPkAttackTimestamp, false);
|
|
Interlocked.Increment(ref _revision);
|
|
}
|
|
}
|