acdream never implemented @pklite. It is a CLIENT command in retail, not a
server one — ACE has no pklite text-command handler — so typing it forwarded as
inert chat text that the server ignored.
Retail: ClientCommunicationSystem::DoPKLite @0x0057A490 rejects with
WeenieError 0x507 when ACCWeenieObject::IsPlayerKiller @0x0058C910 is true
(that returns true when EITHER the PK bit 0x20 OR the PKLite bit 0x2000000 is
set), prints "Please see @help pklite for more..." and sends nothing if given
any argument text, and otherwise calls CM_Character::Event_EnterPKLite
@0x006A13F0 — a bare 12-byte parameterless game action, opcode 0x28F, the same
shape as Event_LoginCompleteNotification beside it. Verb string at 0x007E16B0,
help text at 0x007DF0C8, failure string at 0x007D31E8; one verb, no alias.
HasPlayerFlag is a tri-state (null = the local PublicWeenieDesc has not
arrived). The existing arena gates compare `== false` because they reject on a
known-FALSE flag; retail's DoPKLite gates the other way, rejecting on
known-TRUE. So this case compares `== true` on either bit: an indeterminate
description sends rather than blocks, which matches retail trusting the server
instead of inventing a client-side suppression rule.
Landed as its own commit because it is retail-faithful on its own merits, but
the motivation is C4 route 2: ACE advances SequenceType.ObjectForcePosition in
exactly two places, and the only reachable one is Player.HandleActionEnterPkLite's
entry-collision bump (allow_pkl_bump, default on). Every admin teleport advances
ObjectTeleport instead, so @teleto-style displacement exercises route 3, not
route 2. Without this command route 2 has no connected acceptance gate at all.
Gates: complete Release solution 10,867 passed / 4 skipped / 0 failed
(9966b531 baseline 10,858/4/0; +9 = the 9 tests added). Coverage includes both
known-true rejections, the known-false success case, the tri-state unknown
case, the 12-byte wire envelope, and @pklite resolving as ClientHandled rather
than falling through to the server-text path.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
424 lines
18 KiB
C#
424 lines
18 KiB
C#
using System.Diagnostics;
|
|
using AcDream.App.Combat;
|
|
using AcDream.App.Composition;
|
|
using AcDream.App.Input;
|
|
using AcDream.App.Interaction;
|
|
using AcDream.App.Physics;
|
|
using AcDream.App.Rendering;
|
|
using AcDream.App.Rendering.Scene;
|
|
using AcDream.App.Rendering.Selection;
|
|
using AcDream.App.Rendering.Vfx;
|
|
using AcDream.App.Settings;
|
|
using AcDream.App.Spells;
|
|
using AcDream.App.Streaming;
|
|
using AcDream.App.UI;
|
|
using AcDream.App.UI.Layout;
|
|
using AcDream.App.World;
|
|
using AcDream.Core.Chat;
|
|
using AcDream.Core.Combat;
|
|
using AcDream.Core.Items;
|
|
using AcDream.Core.Net;
|
|
using AcDream.Core.Net.Messages;
|
|
using AcDream.Core.Player;
|
|
using AcDream.Core.Social;
|
|
using AcDream.Core.Spells;
|
|
using AcDream.Core.World;
|
|
using AcDream.Content;
|
|
using AcDream.Runtime;
|
|
using AcDream.Runtime.Entities;
|
|
using AcDream.Runtime.Gameplay;
|
|
using AcDream.Runtime.Session;
|
|
using AcDream.UI.Abstractions.Panels.Chat;
|
|
using AcDream.UI.Abstractions.Panels.Vitals;
|
|
using DatReaderWriter;
|
|
using DatReaderWriter.DBObjs;
|
|
|
|
namespace AcDream.App.Net;
|
|
|
|
internal sealed record LiveSessionPlayerRuntime(
|
|
LocalPlayerIdentityState Identity,
|
|
RuntimeLocalPlayerMovementState Controller,
|
|
LiveWorldOriginState WorldOrigin);
|
|
|
|
internal sealed record LiveSessionDomainRuntime(
|
|
GameRuntime Runtime,
|
|
RuntimeEntityObjectLifetime EntityObjects,
|
|
RuntimeCharacterState Character,
|
|
RuntimeActionState Actions,
|
|
RuntimeInventoryState Inventory,
|
|
RuntimeCommunicationState Communication);
|
|
|
|
internal sealed record LiveSessionUiRuntime(
|
|
RetailUiRuntime? RetailUi,
|
|
VitalsVM? Vitals,
|
|
CharacterSheetProvider? CharacterSheet,
|
|
MagicRuntime? Magic,
|
|
PaperdollFramePresenter? Paperdoll);
|
|
|
|
internal sealed record LiveSessionInteractionRuntime(
|
|
RuntimeSettingsController Settings,
|
|
GameplayInputFrameController GameplayInput,
|
|
PlayerModeController PlayerMode,
|
|
PlayerModeAutoEntry PlayerModeAutoEntry,
|
|
ItemInteractionController ItemInteraction,
|
|
RuntimeCombatAttackState CombatAttack,
|
|
SelectionInteractionController SelectionInteractions);
|
|
|
|
internal sealed record LiveSessionWorldRuntime(
|
|
IDatReaderWriter Dats,
|
|
GpuWorldState WorldState,
|
|
LiveEntityRuntime LiveEntities,
|
|
LiveEntitySessionController EntitySession,
|
|
WorldEnvironmentController Environment,
|
|
DeferredLocalPlayerTeleportNetworkSink Teleport,
|
|
DatSpawnClaimHydrationClassifier SpawnClaims,
|
|
EquippedChildRenderController EquippedChildren,
|
|
RetailSelectionScene SelectionScene,
|
|
ParticleVisibilityController ParticleVisibility,
|
|
RetailInboundEventDispatcher InboundEvents,
|
|
LiveEntityLivenessController Liveness,
|
|
LiveEntityNetworkUpdateController NetworkUpdates,
|
|
LiveEntityHydrationController Hydration,
|
|
RemoteTeleportController RemoteTeleport,
|
|
EntityEffectController EntityEffects,
|
|
AnimationHookFrameQueue AnimationHookFrames,
|
|
LiveEntityPresentationController Presentation,
|
|
RemoteMovementObservationTracker RemoteMovementObservations,
|
|
RenderSceneShadowRuntime? RenderSceneShadow,
|
|
RuntimePlacementPresentationSink PlacementProjection,
|
|
RuntimePlacementProjectionRetrySlot PlacementRetries,
|
|
RuntimeFirstEntryDriveController FirstEntryDrive,
|
|
RuntimeAcceptedPositionDriveController AcceptedPositionDrive);
|
|
|
|
/// <summary>
|
|
/// Builds the exact per-generation route/reset graph for the canonical live
|
|
/// session. It owns no mirrored session state and retains no window callback.
|
|
/// </summary>
|
|
internal sealed class LiveSessionRuntimeFactory
|
|
{
|
|
private readonly LiveSessionPlayerRuntime _player;
|
|
private readonly LiveSessionDomainRuntime _domain;
|
|
private readonly LiveSessionUiRuntime _ui;
|
|
private readonly LiveSessionInteractionRuntime _interaction;
|
|
private readonly LiveSessionWorldRuntime _world;
|
|
private readonly LiveSessionCommandSurface _commands;
|
|
private readonly Action<string> _log;
|
|
private readonly LiveMovementStatsApplier _movementStats;
|
|
|
|
public LiveSessionRuntimeFactory(
|
|
LiveSessionPlayerRuntime player,
|
|
LiveSessionDomainRuntime domain,
|
|
LiveSessionUiRuntime ui,
|
|
LiveSessionInteractionRuntime interaction,
|
|
LiveSessionWorldRuntime world,
|
|
LiveSessionCommandSurface commands,
|
|
Action<string> log)
|
|
{
|
|
_player = player ?? throw new ArgumentNullException(nameof(player));
|
|
_domain = domain ?? throw new ArgumentNullException(nameof(domain));
|
|
_ui = ui ?? throw new ArgumentNullException(nameof(ui));
|
|
_interaction = interaction
|
|
?? throw new ArgumentNullException(nameof(interaction));
|
|
_world = world ?? throw new ArgumentNullException(nameof(world));
|
|
_commands = commands ?? throw new ArgumentNullException(nameof(commands));
|
|
_log = log ?? throw new ArgumentNullException(nameof(log));
|
|
// C3c-F1: stat recomputes route through the Runtime movement owner's
|
|
// typed application seam; App keeps zero direct controller mutations.
|
|
_movementStats = new LiveMovementStatsApplier(
|
|
_player.Controller,
|
|
_domain.Character.MovementSkills,
|
|
_log);
|
|
}
|
|
|
|
public LiveSessionHost Create(
|
|
LiveSessionController controller,
|
|
LiveSessionConnectOptions connectOptions)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(controller);
|
|
ArgumentNullException.ThrowIfNull(connectOptions);
|
|
var resetHost = new GraphicalRuntimeGenerationResetHost(
|
|
_world.LiveEntities,
|
|
() => _world.RenderSceneShadow?.DrainUpdateBoundary());
|
|
LiveSessionResetPlan reset =
|
|
LiveSessionResetManifest.Create(
|
|
CreateResetBindings(resetHost));
|
|
return new LiveSessionHost(controller, new LiveSessionHostBindings(
|
|
Routing: new(
|
|
CreateEventRouter,
|
|
session => _commands.Attach(new LiveSessionCommandRouter(
|
|
CreateCommandBindings(session)))),
|
|
Reset: reset.Execute,
|
|
Selection: new(
|
|
SetPlayerIdentity: id => _player.Identity.ServerGuid = id,
|
|
SetVitalsIdentity: id => _ui.Vitals?.SetLocalPlayerGuid(id),
|
|
SetChatIdentity: _domain.Communication.Chat.SetLocalPlayerGuid,
|
|
MarkPersistent: _world.WorldState.MarkPersistent,
|
|
SetVanishProbeIdentity: id => EntityVanishProbe.PlayerGuid = id,
|
|
ClearCombat: _domain.Actions.Combat.Clear),
|
|
EnteredWorld: new(
|
|
SetActiveCharacter: _interaction.Settings.SetActiveCharacter,
|
|
RestoreLayout: () => _ui.RetailUi?.RestoreLayout(),
|
|
SyncToolbar: () => _ui.RetailUi?.SyncToolbarWindowButtons(),
|
|
LoadCharacterSettings: _interaction.Settings.LoadCharacterContext,
|
|
ArmPlayerModeAutoEntry: _interaction.PlayerModeAutoEntry.Arm),
|
|
Connecting: (host, port, user) =>
|
|
_domain.Communication.Chat.OnSystemMessage(
|
|
$"connecting to {host}:{port} as {user}",
|
|
chatType: 1),
|
|
Connected: () =>
|
|
_domain.Communication.Chat.OnSystemMessage(
|
|
"connected — character list received",
|
|
chatType: 1)),
|
|
connectOptions);
|
|
}
|
|
|
|
private LiveSessionResetBindings CreateResetBindings(
|
|
IRuntimeGenerationResetHost resetHost) => new()
|
|
{
|
|
MouseCapture = _interaction.GameplayInput.ResetSession,
|
|
PlayerPresentation = ResetPlayerPresentation,
|
|
TeleportPresentation =
|
|
_world.Teleport.ResetGenerationPresentation,
|
|
SessionDialogs = () => _ui.RetailUi?.ResetSessionTransientUi(),
|
|
SettingsCharacterContext =
|
|
_interaction.Settings.RestoreDefaultCharacterContext,
|
|
EquippedChildren = _world.EquippedChildren.Clear,
|
|
InteractionPresentation =
|
|
_interaction.SelectionInteractions.ResetGenerationPresentation,
|
|
SelectionPresentation = _world.SelectionScene.Reset,
|
|
ParticleVisibility = _world.ParticleVisibility.Reset,
|
|
InboundEventFifo = _world.InboundEvents.Clear,
|
|
LiveLiveness = _world.Liveness.Clear,
|
|
RuntimeGeneration = generation =>
|
|
_domain.Runtime.ResetGeneration(generation, resetHost),
|
|
SessionIdentityPresentation = ResetIdentityPresentation,
|
|
RemoteTeleport = _world.RemoteTeleport.Clear,
|
|
NetworkEffects = _world.EntityEffects.ClearNetworkState,
|
|
AnimationHookFrames = _world.AnimationHookFrames.Clear,
|
|
LivePresentation = _world.Presentation.Clear,
|
|
RemoteMovementDiagnostics = _world.RemoteMovementObservations.Clear,
|
|
};
|
|
|
|
private void ResetPlayerPresentation()
|
|
{
|
|
_movementStats.Reset();
|
|
_interaction.PlayerMode.ResetSession();
|
|
_world.SpawnClaims.Reset();
|
|
}
|
|
|
|
private void ResetIdentityPresentation(
|
|
RuntimeGenerationToken retiringGeneration)
|
|
{
|
|
RuntimeGenerationResetSnapshot reset =
|
|
_domain.Runtime.GenerationReset.CaptureSnapshot();
|
|
if (reset.IsActive
|
|
|| reset.LastCompletedGeneration != retiringGeneration)
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"Runtime generation {retiringGeneration.Value} has not "
|
|
+ "converged before App identity projection teardown.");
|
|
}
|
|
|
|
// PlayerModule::Clear @ 0x005D48A0 clears shortcuts, desired
|
|
// components, and character option objects. CPlayerSystem::Begin
|
|
// @ 0x0055D410 resets player identity and session fields. The option
|
|
// default comes from PlayerModule::PlayerModule @ 0x005D51F0.
|
|
_ui.Vitals?.SetLocalPlayerGuid(0u);
|
|
EntityVanishProbe.PlayerGuid = 0u;
|
|
_interaction.Settings.ResetActiveCharacterKey();
|
|
_world.NetworkUpdates.ResetSessionState();
|
|
_world.Hydration.ResetSessionState();
|
|
_ui.Paperdoll?.ResetSession();
|
|
|
|
// X/Y are ignored until the next logical player CreateObject claims a
|
|
// new origin. Keeping the last values avoids inventing a synthetic
|
|
// landblock while still forcing first-spawn initialization.
|
|
_player.WorldOrigin.Reset();
|
|
}
|
|
|
|
private ILiveSessionEventRouting CreateEventRouter(WorldSession session)
|
|
{
|
|
SkillTable? skillTable = _world.Dats.Get<SkillTable>(0x0E000004u);
|
|
if (_ui.CharacterSheet is not null)
|
|
{
|
|
_ui.CharacterSheet.SkillTable = skillTable;
|
|
_ui.CharacterSheet.ExperienceTable =
|
|
CharacterSheetProvider.LoadExperienceTable(_world.Dats, _log);
|
|
}
|
|
|
|
var route = new LiveSessionEventRouter(
|
|
session,
|
|
_world.EntitySession.CreateSink(),
|
|
new LiveEnvironmentSessionSink(
|
|
_world.Environment.ApplyAdminEnvirons,
|
|
_world.Environment.SynchronizeFromServer),
|
|
CreateInventoryBindings(),
|
|
CreateCharacterBindings(skillTable),
|
|
new LiveSocialSessionBindings(
|
|
_domain.Communication.Chat,
|
|
_domain.Communication.TurbineChat,
|
|
_domain.Communication.Friends,
|
|
_domain.Communication.Squelch));
|
|
return new GraphicalSessionEventRoute(
|
|
route,
|
|
_domain.Runtime,
|
|
_world.PlacementProjection,
|
|
_world.PlacementRetries,
|
|
_world.FirstEntryDrive,
|
|
_ => session.SendGameAction(GameActionLoginComplete.Build()),
|
|
_world.AcceptedPositionDrive);
|
|
}
|
|
|
|
private LiveInventorySessionBindings CreateInventoryBindings() => new(
|
|
_domain.Inventory.Objects,
|
|
PlayerGuid: () => _player.Identity.ServerGuid,
|
|
OnShortcuts: _domain.Inventory.Shortcuts.Load,
|
|
OnUseDone: error =>
|
|
{
|
|
_domain.Inventory.ExternalContainers.ApplyUseDone(error);
|
|
_domain.Actions.Transactions.CompleteUse(error);
|
|
},
|
|
_domain.Inventory.ItemMana,
|
|
ExternalContainers: _domain.Inventory.ExternalContainers,
|
|
OnAppraisal: appraisal =>
|
|
{
|
|
if (_ui.RetailUi is { } retailUi)
|
|
retailUi.HandleAppraisal(appraisal);
|
|
else
|
|
_interaction.ItemInteraction.AcceptAppraisalResponse(appraisal.Guid);
|
|
});
|
|
|
|
private LiveCharacterSessionBindings CreateCharacterBindings(
|
|
SkillTable? skillTable)
|
|
{
|
|
var skillCreditResolver = new LiveSkillCreditResolver(skillTable);
|
|
return new LiveCharacterSessionBindings(
|
|
_domain.Actions.Combat,
|
|
_domain.Character,
|
|
ResolveSkillFormulaBonus: skillCreditResolver.Resolve,
|
|
OnSkillsUpdated: (runSkill, jumpSkill) =>
|
|
_movementStats.Apply("skills"),
|
|
OnConfirmationRequest: request =>
|
|
_ui.RetailUi?.HandleConfirmationRequest(request),
|
|
OnConfirmationDone: done =>
|
|
_ui.RetailUi?.HandleConfirmationDone(done),
|
|
ClientTime: ClientTimerNow,
|
|
// Campaign P Slice P1 (2026-07-30): burden/stamina/vitae changes
|
|
// reactively re-apply through the SAME seam skills already used
|
|
// (pseudocode doc §8/§9). C3c-F1: that seam is now the Runtime
|
|
// movement owner's typed application entry — see
|
|
// LiveMovementStatsApplier.
|
|
OnMovementStatsUpdated: () => _movementStats.Apply("stats"));
|
|
}
|
|
|
|
private LiveSessionCommandBindings CreateCommandBindings(
|
|
WorldSession session) => new(
|
|
ClientCommands: new ClientCommandController.Bindings(
|
|
TeleportToLifestone: session.SendTeleportToLifestone,
|
|
TeleportToMarketplace: session.SendTeleportToMarketplace,
|
|
TeleportToPkArena: session.SendTeleportToPkArena,
|
|
TeleportToPkLiteArena: session.SendTeleportToPkLiteArena,
|
|
TeleportToHouse: session.SendTeleportToHouse,
|
|
TeleportToMansion: session.SendTeleportToMansion,
|
|
QueryAge: session.SendQueryAge,
|
|
QueryBirth: session.SendQueryBirth,
|
|
ToggleFrameRate: _interaction.Settings.ToggleFrameRate,
|
|
ToggleUiLock: () =>
|
|
_interaction.Settings.SetUiLocked(
|
|
!_interaction.Settings.Gameplay.LockUI),
|
|
ShowSystemMessage:
|
|
text => _domain.Communication.Chat.OnSystemMessage(text, 0u),
|
|
ShowWeenieError:
|
|
code => _domain.Communication.Chat.OnWeenieError(code, null),
|
|
PlayerPublicWeenieBitfield: () =>
|
|
_domain.EntityObjects.Objects.Get(_player.Identity.ServerGuid)?
|
|
.PublicWeenieBitfield,
|
|
ClientVersion: () =>
|
|
typeof(LiveSessionRuntimeFactory).Assembly
|
|
.GetName().Version?.ToString(3)
|
|
?? "unknown",
|
|
CurrentPosition: () => _player.Controller.Controller?.CellPosition,
|
|
LastOutsideCorpsePosition: () =>
|
|
_domain.Character.LocalPlayer.GetPosition(0x0Eu),
|
|
ShowConfirmation: (message, completed) =>
|
|
_ui.RetailUi?.ShowConfirmation(message, completed),
|
|
Suicide: session.SendSuicide,
|
|
ClearChat: _ => _domain.Communication.Chat.Clear(),
|
|
SaveUi: name => _ui.RetailUi?.SaveNamedLayout(name),
|
|
LoadUi: name => _ui.RetailUi?.RestoreNamedLayout(name),
|
|
SaveAutoUi: () => _ui.RetailUi?.SaveLayout(),
|
|
LoadAutoUi: () => _ui.RetailUi?.RestoreLayout(),
|
|
IsAway: () =>
|
|
_domain.EntityObjects.Objects.Get(_player.Identity.ServerGuid)?
|
|
.Properties.GetBool(0x6Eu) == true,
|
|
SetAway: session.SendSetAfkMode,
|
|
SetAwayMessage: session.SendSetAfkMessage,
|
|
AcceptLootPermits: () =>
|
|
_interaction.Settings.Gameplay.AcceptLootPermits,
|
|
SetAcceptLootPermits: _interaction.Settings.SetAcceptLootPermits,
|
|
DisplayConsent: session.SendDisplayConsent,
|
|
ClearConsent: session.SendClearConsent,
|
|
RemoveConsent: session.SendRemoveConsent,
|
|
SendEmote: session.SendEmote,
|
|
_domain.Communication.Friends,
|
|
AddFriend: session.SendAddFriend,
|
|
RemoveFriend: session.SendRemoveFriend,
|
|
ClearFriends: session.SendClearFriends,
|
|
RequestLegacyFriends: session.SendLegacyFriendsListRequest,
|
|
_domain.Communication.Squelch,
|
|
ModifyCharacterSquelch: session.SendModifyCharacterSquelch,
|
|
ModifyAccountSquelch: session.SendModifyAccountSquelch,
|
|
ModifyGlobalSquelch: session.SendModifyGlobalSquelch,
|
|
LastTeller: () =>
|
|
_domain.Communication.CommandTargets.LastIncomingTellSender,
|
|
ClearDesiredComponents: () =>
|
|
{
|
|
session.SendClearDesiredComponents();
|
|
},
|
|
HasOpenVendor: () => false,
|
|
FillComponentBuyList: (_, _) => { },
|
|
EnterPkLite: session.SendEnterPkLite),
|
|
_domain.Communication.Chat,
|
|
_domain.Communication.TurbineChat,
|
|
PlayerGuid: () => _player.Identity.ServerGuid,
|
|
SendTalk: session.SendTalk,
|
|
SendTell: session.SendTell,
|
|
SendChannel: session.SendChannel,
|
|
SendTurbineChat: (
|
|
roomId,
|
|
chatType,
|
|
dispatchType,
|
|
senderGuid,
|
|
text,
|
|
cookie) => session.SendTurbineChatTo(
|
|
roomId,
|
|
chatType,
|
|
dispatchType,
|
|
senderGuid,
|
|
text,
|
|
cookie),
|
|
AddShortcut: session.SendAddShortcut,
|
|
RemoveShortcut: session.SendRemoveShortcut,
|
|
AddFavorite: session.SendAddSpellFavorite,
|
|
RemoveFavorite: session.SendRemoveSpellFavorite,
|
|
SetSpellbookFilter: session.SendSpellbookFilter,
|
|
ForgetSpell: session.SendRemoveSpell,
|
|
SetDesiredComponent: session.SendSetDesiredComponentLevel,
|
|
ClearDesiredComponents: session.SendClearDesiredComponents,
|
|
RaiseAttribute: session.SendRaiseAttribute,
|
|
RaiseVital: session.SendRaiseVital,
|
|
RaiseSkill: session.SendRaiseSkill,
|
|
TrainSkill: session.SendTrainSkill,
|
|
SetCharacterOptions: session.SendSetCharacterOptions,
|
|
AddFriend: session.SendAddFriend,
|
|
RemoveFriend: session.SendRemoveFriend,
|
|
ClearFriends: session.SendClearFriends,
|
|
RequestLegacyFriends: session.SendLegacyFriendsListRequest,
|
|
ModifyCharacterSquelch: session.SendModifyCharacterSquelch,
|
|
ModifyAccountSquelch: session.SendModifyAccountSquelch,
|
|
ModifyGlobalSquelch: session.SendModifyGlobalSquelch,
|
|
Log: _log);
|
|
|
|
private static double ClientTimerNow() =>
|
|
Stopwatch.GetTimestamp() / (double)Stopwatch.Frequency;
|
|
}
|