fix: complete retail parity stability pass
All checks were successful
CI / linux-portable (push) Successful in 3m41s
CI / windows-gate (push) Successful in 6m49s
CI / release (push) Successful in 3m22s

This commit is contained in:
Erik 2026-08-28 20:01:39 +02:00
parent d3df4cb20a
commit f7aa8e0eb7
131 changed files with 7765 additions and 1190 deletions

View file

@ -11,6 +11,7 @@ using AcDream.Runtime;
using AcDream.Runtime.Session;
using AcDream.Core.Combat;
using AcDream.Core.Lighting;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
using AcDream.Core.Selection;
using AcDream.Core.World;
@ -393,7 +394,10 @@ internal sealed class FrameRootCompositionPhase
live.EnvCellRenderer!,
foundation.SceneLighting!,
d.RenderRange,
skyPesFrame);
skyPesFrame,
persistentDaylight: () =>
d.Runtime.CharacterOwner.Options.GetOptionBit(
CharacterOptionId.PersistentAtDay));
var worldRenderFrameBuilder = new WorldRenderFrameBuilder(
new RuntimeWorldFrameCameraSource(
host.CameraController,

View file

@ -27,7 +27,6 @@ using AcDream.UI.Abstractions.Input;
using AcDream.UI.Abstractions.Panels.Chat;
using AcDream.UI.Abstractions.Panels.Vitals;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using Silk.NET.Input;
using Silk.NET.Windowing;
@ -716,19 +715,12 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
// outside the lock matches this file's existing pattern
// elsewhere (construct once, lock only around Resolve calls).
var characterCreationStrings = new DatStringResolver(d.Dats);
// CC5 review fix round F3 (2026-08-16): read the global
// SkillTable (portal.dat 0x0E000004 — the SAME file
// ChargenOptions.GlobalSkillCostsBySkillId's own doc comment and
// LiveSessionRuntimeFactory.CreateCharacterBindings already read)
// ONCE at composition time, under the DatLock DatCollection's
// thread-safety contract requires — mirrors LiveSkillCreditResolver's
// own constructor-time load. The resolver itself does no further
// DAT access per call (pure SkillFormula arithmetic), so the
// Summary page's GetSkillScore binding below needs no lock.
SkillTable? chargenSkillTable;
lock (d.DatLock)
chargenSkillTable = d.Dats.Get<SkillTable>(0x0E000004u);
var chargenSkillScoreResolver = new ChargenSkillScoreResolver(chargenSkillTable);
// #404: ChargenTableReader already loads the global SkillTable and
// projects its formulas into this immutable options model. Reuse
// that single source of truth; summary score reads are pure and
// need neither another DAT read nor another DatLock acquisition.
var chargenSkillScoreResolver = new ChargenSkillScoreResolver(
d.Runtime.CharacterCreation.Options);
// Campaign QT slice QT5: lazily loaded on first open (the panel
// is hidden at mount), then held for the session.
AcDream.Core.Quests.ContractCatalog? contractCatalog = null;
@ -1122,7 +1114,9 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
MapHouse: new MapHouseRuntimeBindings(
CurrentCalendar: d.CurrentCalendar,
PlayerCellId: () => d.PlayerController.Controller?.CellId ?? 0u,
HouseLines: () => d.Runtime.HouseOwner.Lines),
HousePosition: () => d.Runtime.HouseOwner.Position,
HouseLines: () => d.Runtime.HouseOwner.Lines,
HousePanelLines: () => d.Runtime.HouseOwner.PanelLines),
// Campaign QT slice QT5. The catalog is read from the dats
// ONCE and cached: it is immutable installed content, and the
// panel would otherwise re-read a 322-entry table on every

View file

@ -742,6 +742,7 @@ internal sealed class SessionPlayerCompositionPhase
d.EntityObjects.Objects,
live.LiveEntities,
hydration,
d.Actions.Selection,
() => d.UpdateClock.SimulationTimeSeconds);
bindings.Adopt(
"inventory world-drop projection",

View file

@ -383,6 +383,8 @@ internal sealed class LiveSessionCommandRouter : ILiveSessionCommandRouting
ToggleFrameRate: () => InvokeClient(static b => b.ToggleFrameRate()),
ToggleUiLock: () => InvokeClient(static b => b.ToggleUiLock()),
ShowSystemMessage: text => InvokeClient(b => b.ShowSystemMessage(text)),
ShowClientLocalMessage: text =>
InvokeClient(b => b.ShowClientLocalMessage(text)),
ShowWeenieError: error => InvokeClient(b => b.ShowWeenieError(error)),
PlayerPublicWeenieBitfield: () =>
ReadClient(static b => b.PlayerPublicWeenieBitfield(), default(uint?)),
@ -443,7 +445,91 @@ internal sealed class LiveSessionCommandRouter : ILiveSessionCommandRouting
LeaveGmChannel: channelId => InvokeClient(b => b.LeaveGmChannel(channelId)),
RecallAllegianceHometown: () => InvokeClient(static b => b.RecallAllegianceHometown()),
RequestAllegianceInfo: name => InvokeClient(b => b.RequestAllegianceInfo(name)),
AbandonHouse: () => InvokeClient(static b => b.AbandonHouse()));
AbandonHouse: () => InvokeClient(static b => b.AbandonHouse()),
Administration: BuildGuardedAdministration(),
IsPersistentDaylight: () =>
ReadClient(static b => b.IsPersistentDaylight(), false),
SetPersistentDaylight: value =>
InvokeClient(b => b.SetPersistentDaylight(value)),
SetLandscapeRadius: radius =>
InvokeClient(b => b.SetLandscapeRadius(radius)),
SetFieldOfView: degrees =>
InvokeClient(b => b.SetFieldOfView(degrees)));
private ClientCommandController.AdministrationBindings BuildGuardedAdministration() =>
new(
BreakAllegianceBoot: (name, account) =>
InvokeClient(b => b.Administration.BreakAllegianceBoot(name, account)),
AllegianceChatBoot: (name, reason) =>
InvokeClient(b => b.Administration.AllegianceChatBoot(name, reason)),
AllegianceChatGag: (name, enabled) =>
InvokeClient(b => b.Administration.AllegianceChatGag(name, enabled)),
AllegianceBroadcast: text =>
InvokeClient(b => b.Administration.AllegianceBroadcast(text)),
ListAllegianceBans: () =>
InvokeClient(static b => b.Administration.ListAllegianceBans()),
AddAllegianceBan: name =>
InvokeClient(b => b.Administration.AddAllegianceBan(name)),
RemoveAllegianceBan: name =>
InvokeClient(b => b.Administration.RemoveAllegianceBan(name)),
ListAllegianceOfficers: () =>
InvokeClient(static b => b.Administration.ListAllegianceOfficers()),
ClearAllegianceOfficers: () =>
InvokeClient(static b => b.Administration.ClearAllegianceOfficers()),
SetAllegianceOfficer: (name, level) =>
InvokeClient(b => b.Administration.SetAllegianceOfficer(name, level)),
RemoveAllegianceOfficer: name =>
InvokeClient(b => b.Administration.RemoveAllegianceOfficer(name)),
ListAllegianceOfficerTitles: () =>
InvokeClient(static b => b.Administration.ListAllegianceOfficerTitles()),
ClearAllegianceOfficerTitles: () =>
InvokeClient(static b => b.Administration.ClearAllegianceOfficerTitles()),
SetAllegianceOfficerTitle: (level, title) =>
InvokeClient(b => b.Administration.SetAllegianceOfficerTitle(level, title)),
QueryAllegianceName: () =>
InvokeClient(static b => b.Administration.QueryAllegianceName()),
SetAllegianceName: name =>
InvokeClient(b => b.Administration.SetAllegianceName(name)),
ClearAllegianceName: () =>
InvokeClient(static b => b.Administration.ClearAllegianceName()),
AllegianceLockAction: action =>
InvokeClient(b => b.Administration.AllegianceLockAction(action)),
SetAllegianceApprovedVassal: name =>
InvokeClient(b => b.Administration.SetAllegianceApprovedVassal(name)),
AllegianceHouseAction: action =>
InvokeClient(b => b.Administration.AllegianceHouseAction(action)),
QueryMotd: () =>
InvokeClient(static b => b.Administration.QueryMotd()),
SetMotd: motd =>
InvokeClient(b => b.Administration.SetMotd(motd)),
ClearMotd: () =>
InvokeClient(static b => b.Administration.ClearMotd()),
SetOpenHouseStatus: open =>
InvokeClient(b => b.Administration.SetOpenHouseStatus(open)),
AddPermanentGuest: name =>
InvokeClient(b => b.Administration.AddPermanentGuest(name)),
RemovePermanentGuest: name =>
InvokeClient(b => b.Administration.RemovePermanentGuest(name)),
RemoveAllPermanentGuests: () =>
InvokeClient(static b => b.Administration.RemoveAllPermanentGuests()),
ChangeStoragePermission: (name, enabled) =>
InvokeClient(b => b.Administration.ChangeStoragePermission(name, enabled)),
AddAllStoragePermission: () =>
InvokeClient(static b => b.Administration.AddAllStoragePermission()),
RemoveAllStoragePermission: () =>
InvokeClient(static b => b.Administration.RemoveAllStoragePermission()),
RequestFullGuestList: () =>
InvokeClient(static b => b.Administration.RequestFullGuestList()),
BootSpecificHouseGuest: name =>
InvokeClient(b => b.Administration.BootSpecificHouseGuest(name)),
BootEveryone: () =>
InvokeClient(static b => b.Administration.BootEveryone()),
SetHooksVisibility: visible =>
InvokeClient(b => b.Administration.SetHooksVisibility(visible)),
ModifyAllegianceGuestPermission: enabled =>
InvokeClient(b => b.Administration.ModifyAllegianceGuestPermission(enabled)),
ModifyAllegianceStoragePermission: enabled =>
InvokeClient(b => b.Administration.ModifyAllegianceStoragePermission(enabled)));
private bool InvokeClient(Action<ClientCommandController.Bindings> invoke)
{

View file

@ -414,7 +414,8 @@ internal sealed class LiveSessionRuntimeFactory
Allegiance: _domain.Runtime.AllegianceOwner,
Trade: _domain.Runtime.TradeOwner,
House: _domain.Runtime.HouseOwner,
Contracts: _domain.Runtime.ContractsOwner));
Contracts: _domain.Runtime.ContractsOwner,
PlayerGuid: () => _player.Identity.ServerGuid));
return new GraphicalSessionEventRoute(
route,
_domain.Runtime,
@ -616,6 +617,9 @@ internal sealed class LiveSessionRuntimeFactory
// unstarted — CH4/CH5 scope at the earliest, not CH2.
ShowSystemMessage:
text => _domain.Communication.Chat.OnSystemMessage(text, 0x00u),
ShowClientLocalMessage:
text => _domain.Communication.AddText(
text, RetailLogTextType.ClientLocal),
// SHOULD-FIX 3 (docs/research/2026-08-09-ch2-review-findings.md):
// route through the AddText chokepoint instead of the deleted
// ChatLog.OnWeenieError, which hardcoded LogTextType 0x00 —
@ -728,7 +732,66 @@ internal sealed class LiveSessionRuntimeFactory
LeaveGmChannel: session.SendOffChannel,
RecallAllegianceHometown: session.SendRecallAllegianceHometown,
RequestAllegianceInfo: session.SendAllegianceInfoRequest,
AbandonHouse: session.SendAbandonHouse),
AbandonHouse: session.SendAbandonHouse,
Administration: new ClientCommandController.AdministrationBindings(
BreakAllegianceBoot: session.SendBreakAllegianceBoot,
AllegianceChatBoot: session.SendAllegianceChatBoot,
AllegianceChatGag: session.SendAllegianceChatGag,
AllegianceBroadcast: text =>
session.SendChannel(0x02000000u, text),
ListAllegianceBans: session.SendListAllegianceBans,
AddAllegianceBan: session.SendAddAllegianceBan,
RemoveAllegianceBan: session.SendRemoveAllegianceBan,
ListAllegianceOfficers: session.SendListAllegianceOfficers,
ClearAllegianceOfficers: session.SendClearAllegianceOfficers,
SetAllegianceOfficer: session.SendSetAllegianceOfficer,
RemoveAllegianceOfficer: session.SendRemoveAllegianceOfficer,
ListAllegianceOfficerTitles: session.SendListAllegianceOfficerTitles,
ClearAllegianceOfficerTitles: session.SendClearAllegianceOfficerTitles,
SetAllegianceOfficerTitle: session.SendSetAllegianceOfficerTitle,
QueryAllegianceName: session.SendQueryAllegianceName,
SetAllegianceName: session.SendSetAllegianceName,
ClearAllegianceName: session.SendClearAllegianceName,
AllegianceLockAction: session.SendAllegianceLockAction,
SetAllegianceApprovedVassal: session.SendSetAllegianceApprovedVassal,
AllegianceHouseAction: session.SendAllegianceHouseAction,
QueryMotd: session.SendQueryMotd,
SetMotd: session.SendSetMotd,
ClearMotd: session.SendClearMotd,
SetOpenHouseStatus: session.SendSetOpenHouseStatus,
AddPermanentGuest: session.SendAddPermanentGuest,
RemovePermanentGuest: session.SendRemovePermanentGuest,
RemoveAllPermanentGuests: session.SendRemoveAllPermanentGuests,
ChangeStoragePermission: session.SendChangeStoragePermission,
AddAllStoragePermission: session.SendAddAllStoragePermission,
RemoveAllStoragePermission: session.SendRemoveAllStoragePermission,
RequestFullGuestList: session.SendRequestFullGuestList,
BootSpecificHouseGuest: session.SendBootSpecificHouseGuest,
BootEveryone: session.SendBootEveryone,
SetHooksVisibility: session.SendSetHooksVisibility,
ModifyAllegianceGuestPermission:
session.SendModifyAllegianceGuestPermission,
ModifyAllegianceStoragePermission:
session.SendModifyAllegianceStoragePermission),
IsPersistentDaylight: () =>
_domain.Character.Options.GetOptionBit(
CharacterOptionId.PersistentAtDay),
SetPersistentDaylight: enabled =>
SendSingleCharacterOption(
(uint)CharacterOptionId.PersistentAtDay,
enabled),
SetLandscapeRadius: radius =>
_interaction.Settings.SaveDisplay(
_interaction.Settings.Display with
{
LandscapeDrawDistance = radius,
}),
SetFieldOfView: degrees =>
_interaction.Settings.SaveDisplay(
_interaction.Settings.Display with
{
FieldOfView = degrees,
})),
_domain.Communication.Chat,
_domain.Communication.TurbineChat,
PlayerGuid: () => _player.Identity.ServerGuid,

View file

@ -19,16 +19,35 @@ internal static class RetailSkillFormula
{
ArgumentNullException.ThrowIfNull(formula);
uint divisor = unchecked((uint)formula.Divisor);
return TryCalculate(
formula.AdditiveBonus,
formula.Attribute1Multiplier,
formula.Attribute2Multiplier,
formula.Divisor,
attribute1,
attribute2,
out result);
}
private static bool TryCalculate(
int additiveBonus,
int attribute1Multiplier,
int attribute2Multiplier,
int divisorStorage,
uint attribute1,
uint attribute2,
out uint result)
{
uint divisor = unchecked((uint)divisorStorage);
if (divisor == 0u)
{
result = 0u;
return false;
}
uint x = unchecked((uint)formula.Attribute1Multiplier);
uint y = unchecked((uint)formula.Attribute2Multiplier);
uint w = unchecked((uint)formula.AdditiveBonus);
uint x = unchecked((uint)attribute1Multiplier);
uint y = unchecked((uint)attribute2Multiplier);
uint w = unchecked((uint)additiveBonus);
uint numerator = unchecked(x * attribute1 + y * attribute2 + w);
result = (uint)Math.Floor((double)numerator / divisor + 0.5d);
return true;
@ -86,6 +105,39 @@ internal static class RetailSkillFormula
};
}
/// <summary>
/// Projection-owned sibling used by chargen presentation. Keeping the
/// arithmetic here means the UI consumes the immutable model loaded by
/// <c>ChargenTableReader</c> instead of reading the global SkillTable a
/// second time.
/// </summary>
public static uint CalculateChargenScore(
ChargenSkillDetail skillDetail,
uint attribute1,
uint attribute2,
ChargenSkillAdvancementClass level)
{
ChargenSkillFormula formula = skillDetail.Formula;
if (!TryCalculate(
formula.AdditiveBonus,
formula.Attribute1Multiplier,
formula.Attribute2Multiplier,
formula.Divisor,
attribute1,
attribute2,
out uint result))
{
return 0u;
}
return level switch
{
ChargenSkillAdvancementClass.Trained => result + 5u,
ChargenSkillAdvancementClass.Specialized => result + 10u,
_ => result,
};
}
/// <summary>
/// Retail <c>SkillSystem::InqAttributeName @ 0x005c8d90</c> — the six
/// hardcoded attribute display names (matched exactly against
@ -257,7 +309,7 @@ internal sealed class LiveSkillCreditResolver(SkillTable? skillTable)
/// Campaign CC CC5 review fix round, F3 (2026-08-16). Chargen-side sibling
/// of <see cref="LiveSkillCreditResolver"/>: resolves
/// <see cref="RetailSkillFormula.CalculateChargenScore"/> against the SAME
/// global <c>SkillTable</c> (portal.dat <c>0x0E000004</c>), fed by a
/// global SkillTable projection in <see cref="ChargenOptions"/>, fed by a
/// candidate character's CHARGEN attribute spread (<see cref="ChargenAttributeValues"/>,
/// keyed the same way <c>AcDream.Runtime.Session.ChargenAttributeId</c>
/// already does — verified against DatReaderWriter's own
@ -270,36 +322,33 @@ internal sealed class LiveSkillCreditResolver(SkillTable? skillTable)
/// dependency of its own — same shape as that composition's existing
/// <c>ResolveText</c> binding.
/// </summary>
internal sealed class ChargenSkillScoreResolver(SkillTable? skillTable)
internal sealed class ChargenSkillScoreResolver(ChargenOptions options)
{
public uint Resolve(
uint skillId,
ChargenAttributeValues attributes,
ChargenSkillAdvancementClass level)
{
if (skillTable?.Skills is null
|| !skillTable.Skills.TryGetValue(
(DatReaderWriter.Enums.SkillId)skillId,
out var skillBase))
if (!options.TryGetSkillDetail(skillId, out ChargenSkillDetail skillDetail))
{
return 0u;
}
uint attribute1 = ResolveAttribute(skillBase.Formula.Attribute1, attributes);
uint attribute2 = ResolveAttribute(skillBase.Formula.Attribute2, attributes);
return RetailSkillFormula.CalculateChargenScore(skillBase, attribute1, attribute2, level);
uint attribute1 = ResolveAttribute(skillDetail.Formula.Attribute1, attributes);
uint attribute2 = ResolveAttribute(skillDetail.Formula.Attribute2, attributes);
return RetailSkillFormula.CalculateChargenScore(skillDetail, attribute1, attribute2, level);
}
private static uint ResolveAttribute(
DatReaderWriter.Enums.AttributeId attributeId,
uint attributeId,
ChargenAttributeValues attributes) => attributeId switch
{
DatReaderWriter.Enums.AttributeId.Strength => (uint)Math.Max(0, attributes.Strength),
DatReaderWriter.Enums.AttributeId.Endurance => (uint)Math.Max(0, attributes.Endurance),
DatReaderWriter.Enums.AttributeId.Quickness => (uint)Math.Max(0, attributes.Quickness),
DatReaderWriter.Enums.AttributeId.Coordination => (uint)Math.Max(0, attributes.Coordination),
DatReaderWriter.Enums.AttributeId.Focus => (uint)Math.Max(0, attributes.Focus),
DatReaderWriter.Enums.AttributeId.Self => (uint)Math.Max(0, attributes.Self),
1u => (uint)Math.Max(0, attributes.Strength),
2u => (uint)Math.Max(0, attributes.Endurance),
3u => (uint)Math.Max(0, attributes.Quickness),
4u => (uint)Math.Max(0, attributes.Coordination),
5u => (uint)Math.Max(0, attributes.Focus),
6u => (uint)Math.Max(0, attributes.Self),
_ => 0u,
};
}

View file

@ -1123,9 +1123,8 @@ internal sealed class LiveEntityNetworkUpdateController
/// carve-out is live for both — its <c>AirborneSnap</c> result is exactly
/// the dissolved landing scenario, for every guid (see
/// <c>ToConstraintArm</c>'s A1 mapping and <c>OnPosition</c>'s own
/// <c>arm is AirborneSnap</c> handling for the two guid-preserved
/// extras — #316's shadow-publish skip and the interp-clear — that ride
/// along with it).
/// <c>arm is AirborneSnap</c> handling for the remaining player-guid
/// interp-clear. #316 retired the former shadow-publish skip.)
/// </para>
///
/// <para>
@ -1938,8 +1937,6 @@ internal sealed class LiveEntityNetworkUpdateController
RuntimeEntityRecord acceptedPositionCanonical = accepted.Canonical;
ulong acceptedPositionAuthorityVersion =
accepted.PositionAuthorityVersion;
ulong acceptedPositionVelocityAuthorityVersion =
accepted.VelocityAuthorityVersion;
if (!_liveEntities.TryGetProjection(
acceptedPositionCanonical,
out LiveEntityRecord acceptedPositionRecord)
@ -2390,39 +2387,21 @@ internal sealed class LiveEntityNetworkUpdateController
update.Position.LandblockId);
}
// 4a-family correction (2026-08-04, found and reported while
// pinning C4 route 5's D-P5 no-velocity design): the previous
// comment here claimed "MoveOrTeleport installs that exact vector
// with set_velocity". A byte-level disassembly of the PDB-paired
// binary (0x00516330-0x00516438, every branch) shows
// MoveOrTeleport never reads its velocity argument's stack slot,
// and UnpackPositionEvent performs no set_velocity either — the
// only set_velocity in the whole accepted-Position chain zeroes
// the LOCAL player (@0x004541B4). This call's actual retail
// justification is therefore NOT yet established and needs its
// own audit; what IS still true and unaffected by that finding:
// the canonical seam below wakes the retained ObjectClock and
// body in one operation, and the Position-delta velocity further
// down remains animation diagnostics, never substituted into
// physics.
// #317: a PositionPack carries an optional velocity vector, but
// retail only passes it through HandleReceivedPosition to
// CPhysicsObj::MoveOrTeleport; that function never reads the
// argument (0x00516330-0x00516438). UnpackPositionEvent has no
// set_velocity either. Do not overwrite the physics body's
// velocity here. The wire vector remains available below as the
// remote server-controlled animation/dead-reckoning sample;
// actual authoritative body velocity arrives through the
// separate VectorUpdate handler, which calls set_velocity.
if (!_liveEntities.IsCurrentPositionAuthority(
positionRecord,
acceptedPositionAuthorityVersion))
{
return;
}
if (_liveEntities.IsCurrentVelocityAuthority(
positionRecord,
acceptedPositionVelocityAuthorityVersion)
&& !_liveEntities.TryCommitAuthoritativeVelocity(
positionRecord,
rmState.Body,
acceptedSpawn.Physics?.Velocity
?? System.Numerics.Vector3.Zero,
_physicsScriptGameTime))
{
return;
}
// C4 route 4b-3 / D4: retail's single ConstrainTo arming site
// (@0x00454272) is now entirely post-operation
@ -2492,15 +2471,10 @@ internal sealed class LiveEntityNetworkUpdateController
// survive, both named and justified rather than silently kept:
// • TS-44 sticky suppression (below) — an NPC-only steady-state
// gate; its own register row already describes it that way.
// • The AirborneSnap arm's interp-clear and collision-shadow
// publish (further below) — PRESERVED, not unified, because
// unifying either way would be an unauthorized behaviour
// change: #316 (filed 2026-08-04) is a real, UNMEASURED
// pre-existing player-guid defect (no shadow publish on
// landing) that this behaviour-preserving collapse must not
// fix, and the interp-clear's equivalence could not be proven
// for the steep-non-walkable-landing edge case (see the
// comment at that arm).
// • The AirborneSnap arm's interp-clear (further below) remains
// player-only because its steep-non-walkable equivalence has
// not been proven. #316 later unified collision-shadow
// publication for both guid ranges at the common tail.
// nowSec is captured ONCE, shared by both guid ranges (was two
// independent DateTime.UtcNow reads before this collapse — a
// microsecond-scale skew in acdream-only bookkeeping/diagnostics).
@ -2689,17 +2663,11 @@ internal sealed class LiveEntityNetworkUpdateController
// A1 fix) supplies the same arm value the old landing
// block hard-coded.
//
// #316 (filed 2026-08-04, deliberately NOT fixed here):
// the player-guid copy of this scenario has never
// published the collision shadow (the tail below does,
// for every OTHER arm and for this SAME arm on NPC
// guids) — a real, UNMEASURED pre-existing defect that
// contradicts the file's own #184 Slice 2b design intent
// ("player shadows now follow the resolved body ...
// exactly like NPCs"). Fixing it is a behaviour change
// this collapse may not make; the skip is reproduced
// verbatim at the tail below, keyed on this same `arm`
// value.
// #316 fixed 2026-08-28: the shared tail now publishes
// the resolved collision shadow for player-guid landings
// exactly as it already did for the same NPC arm. The old
// player-only skip left render/body at the landing pose
// while collision remained at its pre-snap position.
//
// The interp-queue clear is preserved alongside it rather
// than unified either way. AdjustOffset's CONTACT_TS gate
@ -2846,29 +2814,22 @@ internal sealed class LiveEntityNetworkUpdateController
// rmState.CellId is the server cell adopted above. The root
// frame is committed before collision publication, as in retail
// SetPositionInternal. The ONE entity-sync + shadow-publish tail
// for every guid and every arm — except the #316-preserved
// exception: a player-guid AirborneSnap arm still commits the
// render entity from the resolved body but does NOT publish the
// shadow, matching its pre-collapse behaviour exactly (see the
// comment at that arm, above).
// now covers every guid and every arm, including #316's formerly
// skipped player-guid AirborneSnap landing.
entity.SetPosition(rmState.Body.Position);
entity.ParentCellId = rmState.CellId;
entity.Rotation = rmState.Body.Orientation;
if (arm is not RemoteContactArm.AirborneSnap
|| !IsPlayerGuid(update.Guid))
{
AcDream.App.Physics.LiveEntityShadowPublisher.TryPublishRemote(
_liveEntities,
positionRecord,
entity,
AcDream.App.Physics.LiveEntityShadowPublisher.TryPublishRemote(
_liveEntities,
positionRecord,
entity,
rmState,
acceptedPositionAuthorityVersion,
() => _remotePhysicsUpdater.SyncRemoteShadowToBody(
entity.Id,
rmState,
acceptedPositionAuthorityVersion,
() => _remotePhysicsUpdater.SyncRemoteShadowToBody(
entity.Id,
rmState,
_origin.CenterX,
_origin.CenterY));
}
_origin.CenterX,
_origin.CenterY));
}
// F751 is only a notification gate; the accepted Position may arrive

View file

@ -109,10 +109,17 @@ public static class InteriorEntityPartition
HashSet<uint> visibleCells,
IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
IReadOnlyList<WorldEntity> Entities,
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> landblockEntries)
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> landblockEntries,
FrustumPlanes? frustum = null,
uint neverCullLandblockId = 0u)
{
var result = new Result();
Partition(result, visibleCells, landblockEntries);
Partition(
result,
visibleCells,
landblockEntries,
frustum,
neverCullLandblockId);
return result;
}
@ -131,11 +138,23 @@ public static class InteriorEntityPartition
HashSet<uint> visibleCells,
IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
IReadOnlyList<WorldEntity> Entities,
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> landblockEntries)
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> landblockEntries,
FrustumPlanes? frustum = null,
uint neverCullLandblockId = 0u)
{
result.ClearForReuse();
foreach (var entry in landblockEntries)
{
if (!IsLandblockVisible(
entry.LandblockId,
entry.AabbMin,
entry.AabbMax,
frustum,
neverCullLandblockId))
{
continue;
}
foreach (var e in entry.Entities)
{
if (e.MeshRefs.Count == 0) continue;
@ -176,11 +195,18 @@ public static class InteriorEntityPartition
IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
IReadOnlyList<WorldEntity> Entities,
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> landblockEntries,
IObserver? observer)
IObserver? observer,
FrustumPlanes? frustum = null,
uint neverCullLandblockId = 0u)
{
if (observer is null)
{
Partition(result, visibleCells, landblockEntries);
Partition(
result,
visibleCells,
landblockEntries,
frustum,
neverCullLandblockId);
return;
}
@ -190,6 +216,16 @@ public static class InteriorEntityPartition
result.ClearForReuse();
foreach (var entry in landblockEntries)
{
if (!IsLandblockVisible(
entry.LandblockId,
entry.AabbMin,
entry.AabbMax,
frustum,
neverCullLandblockId))
{
continue;
}
foreach (var e in entry.Entities)
{
if (e.MeshRefs.Count == 0) continue;
@ -247,4 +283,14 @@ public static class InteriorEntityPartition
/// <inheritdoc cref="IsIndoorCellId(uint)"/>
public static bool IsIndoorCellId(uint? cellId) => cellId is uint c && IsIndoorCellId(c);
private static bool IsLandblockVisible(
uint landblockId,
Vector3 aabbMin,
Vector3 aabbMax,
FrustumPlanes? frustum,
uint neverCullLandblockId) =>
frustum is null
|| landblockId == neverCullLandblockId
|| FrustumCuller.IsAabbVisible(frustum.Value, aabbMin, aabbMax);
}

View file

@ -159,16 +159,12 @@ internal sealed class LiveEntityAnimationPresenter
continue;
if (span > 0 && legacyAdvanceSeconds > 0f)
{
animation.CurrFrame += legacyAdvanceSeconds * animation.Framerate;
if (animation.CurrFrame > animation.HighFrame)
{
float over = animation.CurrFrame - animation.LowFrame;
animation.CurrFrame = animation.LowFrame + (over % (span + 1));
}
else if (animation.CurrFrame < animation.LowFrame)
{
animation.CurrFrame = animation.LowFrame;
}
animation.CurrFrame = RetailAnimationCyclePlayback.Advance(
animation.CurrFrame,
animation.LowFrame,
animation.HighFrame,
animation.Framerate,
legacyAdvanceSeconds);
}
}
@ -279,33 +275,14 @@ internal sealed class LiveEntityAnimationPresenter
return false;
}
int frameIndex = (int)Math.Floor(animation.CurrFrame);
if (frameIndex < animation.LowFrame
|| frameIndex > animation.HighFrame
|| frameIndex >= animation.Animation.PartFrames.Count)
{
frameIndex = animation.LowFrame;
}
int nextIndex = frameIndex + 1;
if (nextIndex > animation.HighFrame
|| nextIndex >= animation.Animation.PartFrames.Count)
{
nextIndex = animation.LowFrame;
}
float t = Math.Clamp(animation.CurrFrame - frameIndex, 0f, 1f);
var frames = animation.Animation.PartFrames[frameIndex].Frames;
var nextFrames = animation.Animation.PartFrames[nextIndex].Frames;
if (partIndex < frames.Count)
{
var first = frames[partIndex];
var next = partIndex < nextFrames.Count ? nextFrames[partIndex] : first;
origin = Vector3.Lerp(first.Origin, next.Origin, t);
orientation = Quaternion.Slerp(first.Orientation, next.Orientation, t);
return true;
}
origin = default;
orientation = default;
return false;
return RetailAnimationCyclePlayback.TryInterpolatePart(
animation.Animation,
animation.CurrFrame,
animation.LowFrame,
animation.HighFrame,
partIndex,
out origin,
out orientation);
}
private static void EnsureRetainedPoses(LiveEntityAnimationState animation)

View file

@ -239,7 +239,9 @@ public sealed class RetailPViewRenderer
_partitionResult,
prepareCells,
ctx.LandblockEntries,
_partitionObserver);
_partitionObserver,
ctx.Frustum,
ctx.PlayerLandblockId ?? 0u);
partition = _partitionResult;
}

View file

@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Numerics;
using AcDream.Core.Meshing;
@ -52,15 +53,17 @@ public sealed partial class SkyRenderer : IDisposable
// Lazily-built GPU resources per sky-GfxObj.
private readonly Dictionary<uint, List<SubMeshGpu>> _gpuByGfxObj = new();
// When did we start running — used to accumulate TexVelocityX/Y over
// real time (independent of the day-fraction clock).
private readonly DateTime _startedAt = DateTime.UtcNow;
// Retail advances animated texture coordinates from Timer::cur_time
// deltas in CPhysics::UseTime. Stopwatch is the matching monotonic clock:
// unlike wall time, OS clock synchronization cannot make rain/cloud UVs
// jump forward or backward.
private readonly long _animationStartedAtTimestamp = Stopwatch.GetTimestamp();
/// <summary>
/// Campaign V slice V7: pins the sky's scroll phase to a fixed number of
/// seconds instead of reading the wall clock, so two launches agree.
/// seconds instead of advancing the live animation clock, so two launches agree.
/// <c>null</c> — the default, and what every ordinary run gets — keeps the
/// wall clock.
/// monotonic real-elapsed-time clock.
///
/// <para><b>Why the sky needs its own pin when the world clock is already
/// pinnable.</b> Two independent clocks drive this renderer. The Dereth clock
@ -239,7 +242,9 @@ public sealed partial class SkyRenderer : IDisposable
var replaces = PickReplaces(group, dayFraction);
float secondsSinceStart = AnimationPhaseSecondsOverride
?? (float)(DateTime.UtcNow - _startedAt).TotalSeconds;
?? ElapsedAnimationSeconds(
_animationStartedAtTimestamp,
Stopwatch.GetTimestamp());
for (int i = 0; i < group.SkyObjects.Count; i++)
{
@ -454,6 +459,9 @@ public sealed partial class SkyRenderer : IDisposable
}
}
internal static float ElapsedAnimationSeconds(long startTimestamp, long currentTimestamp)
=> (float)Stopwatch.GetElapsedTime(startTimestamp, currentTimestamp).TotalSeconds;
/// <summary>
/// Campaign V slice V6e: the table slot for one (texture, wrap-mode) pair,
/// interning a resident bindless handle on first use.

View file

@ -43,6 +43,9 @@ namespace AcDream.App.Rendering;
/// </summary>
internal sealed class SkyPesFrameController
{
private static readonly Matrix4x4[] IdentityPartPose =
[Matrix4x4.Identity];
private readonly record struct SkyPesKey(
int ObjectIndex,
uint GfxObjId,
@ -122,12 +125,23 @@ internal sealed class SkyPesFrameController
? ParticleRenderPass.SkyPostScene
: ParticleRenderPass.SkyPreScene;
_particles.SetEntityRenderPass(ownerId, renderPass);
// The sky cell follows the viewer. Keep the script dispatch
// anchor on that same current-frame pose: SoundTweaked hooks in
// the Rainy carriers are ordinary world sounds, and a stale
// creation-time anchor falls beyond retail's audible radius as
// soon as login/teleport/movement displaces the camera.
_scripts.SetOwnerAnchor(ownerId, cameraWorldPosition);
Quaternion rotation = Rotation(skyObject, dayFraction);
_poses.Publish(
ownerId,
Matrix4x4.CreateFromQuaternion(rotation)
* Matrix4x4.CreateTranslation(cameraWorldPosition),
Array.Empty<Matrix4x4>(),
// Dereth's scripted sky carriers (including lightning Setup
// 0x02000BA6) are one-part dummy anchors whose default part-0
// frame is identity. Their CreateParticle hooks target part
// 0, not the -1 root sentinel, so a root-only synthetic pose
// makes a live carrier look pose-less to ParticleHookSink.
IdentityPartPose,
cellId: 0u);
if (_active.Contains(key) || _missing.Contains(key))

View file

@ -264,11 +264,8 @@ public sealed unsafe partial class EnvCellRenderer
{
MdiDrawRange drawRange = _mdiDrawRanges[drawRangeIndex];
int groupIndex = drawRange.GroupIndex;
var cullMode = (CullMode)(groupIndex % 4);
// Phase A8 visual-gate evidence: cell meshes use CullMode.Landblock
// uniformly, but the room surfaces need to be visible from inside.
// Render cell polys double-sided, exactly as the GL arm does.
if (cullMode == CullMode.Landblock) cullMode = CullMode.None;
CullMode cullMode = ResolveRetailCellShellCullMode(
(CullMode)(groupIndex % 4));
bool isAdditive = groupIndex >= 4;
IGpuPipeline rangeBasePipeline = isAdditive
@ -361,9 +358,8 @@ public sealed unsafe partial class EnvCellRenderer
for (int drawRangeIndex = 0; drawRangeIndex < _mdiDrawRanges.Count; drawRangeIndex++)
{
MdiDrawRange drawRange = _mdiDrawRanges[drawRangeIndex];
var cullMode = (CullMode)(drawRange.GroupIndex % 4);
if (cullMode == CullMode.Landblock)
cullMode = CullMode.None;
CullMode cullMode = ResolveRetailCellShellCullMode(
(CullMode)(drawRange.GroupIndex % 4));
SetCullMode(encoder, cullMode);
pushConstants.DrawIdOffset = drawRange.FirstCommand;
encoder.SetPushConstants(in pushConstants);
@ -417,6 +413,25 @@ public sealed unsafe partial class EnvCellRenderer
}
}
/// <summary>
/// Resolves a CellStruct polygon's DAT <c>sides_type</c> to the render
/// state used by retail's constructed EnvCell mesh. The similarly named
/// <see cref="CullMode"/> values on <c>Polygon.SidesType</c> are not GPU
/// cull states: 0 emits the positive face, 1 emits that face twice with
/// reversed indices, and 2 emits the positive and negative surface.
/// <c>D3DPolyRender::ConstructMesh @ 0x0059DFA0</c> performs that geometry
/// expansion, then every subset is drawn with <c>D3DCULL_CW</c> through
/// <c>RenderMeshSubset @ 0x0059CA10</c>. <see cref="MeshExtractor"/>
/// already performs the identical expansion, so every shell batch must
/// cull clockwise here. Returning <see cref="CullMode.None"/> for DAT 0
/// was #178's Phase-A8 double-sided stopgap.
/// </summary>
internal static CullMode ResolveRetailCellShellCullMode(CullMode sidesType)
{
_ = sidesType;
return CullMode.Clockwise;
}
/// <summary>
/// Reserves this frame's ring, copies into it, and binds the slice. A
/// logically empty section still reserves one element so the bound range is

View file

@ -462,6 +462,7 @@ internal sealed class RuntimeWorldFrameEnvironmentPreparation
private readonly SceneLightingUboBinding? _lightingUbo;
private readonly IWorldRenderRangeSource _ranges;
private readonly SkyPesFrameController? _skyPes;
private readonly Func<bool> _persistentDaylight;
private readonly HashSet<uint> _visibleCells = [];
private bool _visibleCellsValid;
@ -473,7 +474,8 @@ internal sealed class RuntimeWorldFrameEnvironmentPreparation
EnvCellRenderer? environmentCells,
SceneLightingUboBinding? lightingUbo,
IWorldRenderRangeSource ranges,
SkyPesFrameController? skyPes)
SkyPesFrameController? skyPes,
Func<bool>? persistentDaylight = null)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
_worldTime = worldTime ?? throw new ArgumentNullException(nameof(worldTime));
@ -483,6 +485,7 @@ internal sealed class RuntimeWorldFrameEnvironmentPreparation
_lightingUbo = lightingUbo;
_ranges = ranges ?? throw new ArgumentNullException(nameof(ranges));
_skyPes = skyPes;
_persistentDaylight = persistentDaylight ?? (static () => false);
}
public void Prepare(
@ -500,7 +503,14 @@ internal sealed class RuntimeWorldFrameEnvironmentPreparation
activeDayGroup,
camera.Position);
UpdateSunFromSky(foundation.Sky, roots.PlayerInsideCell);
// LScape::set_landscape_lighting @0x005054D0 keeps the live sky/fog
// clock but, when PersistentAtDay is set, asks the active region for
// lighting at exactly 0.5 (noon). Do not pin WorldTime.DayFraction:
// clouds, celestial objects, fog, and scripts must keep advancing.
SkyKeyframe landscapeLighting = _persistentDaylight()
? _worldTime.SkyAtDayFraction(0.5f)
: foundation.Sky;
UpdateSunFromSky(landscapeLighting, roots.PlayerInsideCell);
_lighting.UpdateViewerLight(roots.PlayerViewPosition);
_lighting.Tick(camera.Position);
_lighting.BuildPointLightSnapshot(

View file

@ -173,7 +173,13 @@ public sealed record RuntimeOptions(
// Legacy override for ACDREAM_STREAM_RADIUS. Caller applies it on
// top of the quality preset's radii. Null when unset or invalid.
LegacyStreamRadius: TryParseNonNegativeInt(env("ACDREAM_STREAM_RADIUS")),
RetailUi: IsExactlyOne(env("ACDREAM_RETAIL_UI")),
// The retained retail UI is the product's only presentation
// stack. It is therefore default-on for every launch path;
// literal 0 remains an explicit diagnostic/headless opt-out.
RetailUi: !string.Equals(
env("ACDREAM_RETAIL_UI"),
"0",
StringComparison.Ordinal),
OpenCharacterCreationOnStart:
IsExactlyOne(env("ACDREAM_OPEN_CHARGEN")),
AcDir: NullIfEmpty(env("ACDREAM_AC_DIR")),
@ -293,14 +299,6 @@ public sealed record RuntimeOptions(
PreparedAssetEffectiveRecipeVersion =
content?.PreparedAssetEffectiveRecipeVersion,
LiveMode = true,
// Campaign LA gate round 2: a session-config launch IS a product
// launch — the retail UI is the shipped UI, not a dev option.
// ACDREAM_RETAIL_UI remains the opt-in for env-var dev launches,
// but the launcher strips ACDREAM_* from children (LA11 isolation),
// so inheriting the env default here shipped a client with world
// rendering and NO interface at all — the guiSelect flow's
// character screen included.
RetailUi = true,
LiveHost = session.Endpoint.Host,
LivePort = session.Endpoint.Port,
LiveUser = session.Account,

View file

@ -161,11 +161,18 @@ internal sealed class RuntimeSettingsController :
Func<uint, bool>? characterOptionValue = null)
{
_storage = storage ?? throw new ArgumentNullException(nameof(storage));
_resolveQuality = resolveQuality ?? ResolveQuality;
Display = _storage.LoadDisplay();
// Render.LandscapeDrawDistance is one of retail's actual quality
// dimensions, not a menu index. Apply it to the production far tier
// before environment overrides; ACDREAM_FAR_RADIUS therefore keeps
// its documented highest precedence for diagnostic runs.
_resolveQuality = resolveQuality
?? (preset => ResolveQuality(
preset,
Display.LandscapeDrawDistance));
_log = log ?? Console.WriteLine;
_characterOptionValue = characterOptionValue;
Display = _storage.LoadDisplay();
Audio = _storage.LoadAudio();
Chat = _storage.LoadChat();
_defaultCharacter = _storage.LoadCharacter(DefaultToonKey);
@ -613,6 +620,35 @@ internal sealed class RuntimeSettingsController :
/// <inheritdoc cref="ServerOptionsSeeded"/>
public void NotifyServerOptionsSeeded() => ServerOptionsSeeded?.Invoke();
private static QualitySettings ResolveQuality(QualityPreset preset) =>
QualitySettings.WithEnvOverrides(QualitySettings.From(preset));
private static QualitySettings ResolveQuality(
QualityPreset preset,
int landscapeDrawDistance)
{
QualitySettings quality = ApplyLandscapeDrawDistance(
QualitySettings.From(preset),
landscapeDrawDistance);
return QualitySettings.WithEnvOverrides(quality);
}
internal static QualitySettings ApplyLandscapeDrawDistance(
QualitySettings quality,
int landscapeDrawDistance)
{
// Retail's enum contains 3,5,8,11,15,25 and @render accepts every
// integer in [5,25]. Values outside the union's structural [3,25]
// range can only come from an old/corrupt settings file, so preserve
// the preset rather than constructing an invalid streaming window.
if (landscapeDrawDistance is >= 3 and <= 25)
{
quality = quality with
{
NearRadius = Math.Min(
quality.NearRadius,
landscapeDrawDistance),
FarRadius = landscapeDrawDistance,
};
}
return quality;
}
}

View file

@ -3,6 +3,7 @@ using AcDream.Core.Chat;
using AcDream.Core.Physics;
using AcDream.Core.Ui;
using AcDream.Core.Social;
using AcDream.Runtime.Chat;
using AcDream.UI.Abstractions;
using AcDream.UI.Abstractions.Panels.Chat;
@ -15,6 +16,50 @@ namespace AcDream.App.UI;
/// </summary>
public sealed class ClientCommandController
{
/// <summary>
/// Typed network effects used by retail's allegiance/house management
/// command family. Grouped separately so the main command binding remains
/// readable and every operation keeps a semantic name instead of exposing
/// raw opcodes to the UI layer.
/// </summary>
public sealed record AdministrationBindings(
Action<string, bool> BreakAllegianceBoot,
Action<string, string> AllegianceChatBoot,
Action<string, bool> AllegianceChatGag,
Action<string> AllegianceBroadcast,
Action ListAllegianceBans,
Action<string> AddAllegianceBan,
Action<string> RemoveAllegianceBan,
Action ListAllegianceOfficers,
Action ClearAllegianceOfficers,
Action<string, uint> SetAllegianceOfficer,
Action<string> RemoveAllegianceOfficer,
Action ListAllegianceOfficerTitles,
Action ClearAllegianceOfficerTitles,
Action<uint, string> SetAllegianceOfficerTitle,
Action QueryAllegianceName,
Action<string> SetAllegianceName,
Action ClearAllegianceName,
Action<uint> AllegianceLockAction,
Action<string> SetAllegianceApprovedVassal,
Action<uint> AllegianceHouseAction,
Action QueryMotd,
Action<string> SetMotd,
Action ClearMotd,
Action<bool> SetOpenHouseStatus,
Action<string> AddPermanentGuest,
Action<string> RemovePermanentGuest,
Action RemoveAllPermanentGuests,
Action<string, bool> ChangeStoragePermission,
Action AddAllStoragePermission,
Action RemoveAllStoragePermission,
Action RequestFullGuestList,
Action<string> BootSpecificHouseGuest,
Action BootEveryone,
Action<bool> SetHooksVisibility,
Action<bool> ModifyAllegianceGuestPermission,
Action<bool> ModifyAllegianceStoragePermission);
public sealed record Bindings(
Action TeleportToLifestone,
Action TeleportToMarketplace,
@ -27,6 +72,7 @@ public sealed class ClientCommandController
Action ToggleFrameRate,
Action ToggleUiLock,
Action<string> ShowSystemMessage,
Action<string> ShowClientLocalMessage,
Action<uint> ShowWeenieError,
Func<uint?> PlayerPublicWeenieBitfield,
Func<string> ClientVersion,
@ -76,13 +122,63 @@ public sealed class ClientCommandController
Action<uint> LeaveGmChannel,
Action RecallAllegianceHometown,
Action<string> RequestAllegianceInfo,
Action AbandonHouse);
Action AbandonHouse,
AdministrationBindings Administration,
Func<bool> IsPersistentDaylight,
Action<bool> SetPersistentDaylight,
Action<int> SetLandscapeRadius,
Action<float> SetFieldOfView);
private readonly Bindings _bindings;
private readonly RetailAdministrationCommandDispatcher _administration;
public ClientCommandController(Bindings bindings)
{
_bindings = bindings ?? throw new ArgumentNullException(nameof(bindings));
AdministrationBindings actions = bindings.Administration;
_administration = new RetailAdministrationCommandDispatcher(
new RetailAdministrationCommandDispatcher.FeedbackBindings(
bindings.ShowSystemMessage,
bindings.ShowClientLocalMessage,
bindings.SetSingleCharacterOption,
bindings.RequestAllegianceInfo),
new RetailAdministrationCommandDispatcher.ActionBindings(
actions.BreakAllegianceBoot,
actions.AllegianceChatBoot,
actions.AllegianceChatGag,
actions.AllegianceBroadcast,
actions.ListAllegianceBans,
actions.AddAllegianceBan,
actions.RemoveAllegianceBan,
actions.ListAllegianceOfficers,
actions.ClearAllegianceOfficers,
actions.SetAllegianceOfficer,
actions.RemoveAllegianceOfficer,
actions.ListAllegianceOfficerTitles,
actions.ClearAllegianceOfficerTitles,
actions.SetAllegianceOfficerTitle,
actions.QueryAllegianceName,
actions.SetAllegianceName,
actions.ClearAllegianceName,
actions.AllegianceLockAction,
actions.SetAllegianceApprovedVassal,
actions.AllegianceHouseAction,
actions.QueryMotd,
actions.SetMotd,
actions.ClearMotd,
actions.SetOpenHouseStatus,
actions.AddPermanentGuest,
actions.RemovePermanentGuest,
actions.RemoveAllPermanentGuests,
actions.ChangeStoragePermission,
actions.AddAllStoragePermission,
actions.RemoveAllStoragePermission,
actions.RequestFullGuestList,
actions.BootSpecificHouseGuest,
actions.BootEveryone,
actions.SetHooksVisibility,
actions.ModifyAllegianceGuestPermission,
actions.ModifyAllegianceStoragePermission));
}
public void Execute(ExecuteClientCommandCmd command)
@ -146,6 +242,23 @@ public sealed class ClientCommandController
case ClientCommandId.ToggleFrameRate:
_bindings.ToggleFrameRate();
break;
// ClientCommunicationSystem::DoDay @0x005706F0. Retail toggles
// LScape::m_fAlwaysDaylight, then writes the same value to the
// PersistentAtDay character option and prints one exact line.
case ClientCommandId.TogglePersistentDaylight:
{
bool enabled = !_bindings.IsPersistentDaylight();
_bindings.SetPersistentDaylight(enabled);
_bindings.ShowSystemMessage(enabled
? "Let there be light!"
: "Normality has been restored.");
break;
}
// ClientCommunicationSystem::DoRenderOption @0x0057E120 ->
// GraphicsOptions::HandleRenderOption @0x00455C30.
case ClientCommandId.RenderOption:
ExecuteRenderOption(command.Arguments);
break;
// DoLockUI @ 0x005703B0 toggles PlayerModule::LockUI and
// broadcasts the new state to every UI element.
case ClientCommandId.ToggleUiLock:
@ -341,9 +454,29 @@ public sealed class ClientCommandController
case ClientCommandId.AllegianceHometown:
_bindings.RecallAllegianceHometown();
break;
// GameActionAllegianceInfoRequest — "@allegiance info [name]".
// ClientCommunicationSystem::DoAllegiance/DoHouse management
// family. The shared Runtime dispatcher keeps graphical and
// headless grammar/refusal behavior identical.
case ClientCommandId.AllegianceInfo:
_bindings.RequestAllegianceInfo(command.Arguments.Trim());
case ClientCommandId.AllegianceBoot:
case ClientCommandId.AllegianceBan:
case ClientCommandId.AllegianceChat:
case ClientCommandId.AllegianceBroadcast:
case ClientCommandId.AllegianceOfficer:
case ClientCommandId.AllegianceOfficerTitle:
case ClientCommandId.AllegianceName:
case ClientCommandId.AllegianceLock:
case ClientCommandId.AllegianceHouse:
case ClientCommandId.AllegianceMotd:
case ClientCommandId.AllegianceUnrecognizedSubcommand:
case ClientCommandId.HouseOpenStatus:
case ClientCommandId.HouseStorage:
case ClientCommandId.HouseBoot:
case ClientCommandId.HouseBootAll:
case ClientCommandId.HouseGuests:
case ClientCommandId.HouseHooks:
case ClientCommandId.HouseUnrecognizedSubcommand:
_ = _administration.TryExecute(command.Command, command.Arguments);
break;
// GameActionHouseAbandon — "@house abandon". Retail's abandon
// branch (DoHouse @ 0x00580D58) opens a FIRST confirmation
@ -384,6 +517,7 @@ public sealed class ClientCommandController
}
}
private void ExecuteUiProfile(string arguments, bool save)
{
string[] parts = SplitArguments(arguments);
@ -405,6 +539,94 @@ public sealed class ClientCommandController
else _bindings.LoadUi(name);
}
/// <summary>
/// Exact retail <c>GraphicsOptions::HandleRenderOption @0x00455C30</c>
/// surface. It has only two options, ignores surplus argv entries, uses
/// C <c>atoi</c> semantics for the value, and silently accepts an unknown
/// option name.
/// </summary>
private void ExecuteRenderOption(string arguments)
{
string[] parts = SplitArguments(arguments);
if (parts.Length == 0
|| parts[0].Equals("usage", StringComparison.OrdinalIgnoreCase))
{
_bindings.ShowSystemMessage(RetailCommandHelpTable.Render.TrimEnd('\n'));
return;
}
if (parts[0].Equals("radius", StringComparison.OrdinalIgnoreCase))
{
if (parts.Length < 2)
{
_bindings.ShowSystemMessage("Must specify a radius");
return;
}
int radius = RetailAtoi(parts[1]);
if (radius is < 5 or > 25)
{
_bindings.ShowSystemMessage("Radius must be between 5 and 25");
return;
}
_bindings.SetLandscapeRadius(radius);
_bindings.ShowSystemMessage("Landscape radius set");
return;
}
if (!parts[0].Equals("fov", StringComparison.OrdinalIgnoreCase))
return;
if (parts.Length < 2)
{
_bindings.ShowSystemMessage("Must specify a field of view");
return;
}
int fieldOfView = RetailAtoi(parts[1]);
if (fieldOfView is < 10 or > 160)
{
_bindings.ShowSystemMessage(
"Field of view must be between 10 and 160");
return;
}
_bindings.SetFieldOfView(fieldOfView);
_bindings.ShowSystemMessage("Field of view set");
}
private static int RetailAtoi(string value)
{
if (string.IsNullOrEmpty(value))
return 0;
int index = 0;
int sign = 1;
if (value[0] is '+' or '-')
{
if (value[0] == '-')
sign = -1;
index++;
}
long result = 0;
bool sawDigit = false;
while (index < value.Length && value[index] is >= '0' and <= '9')
{
sawDigit = true;
result = Math.Min(
(long)int.MaxValue + (sign < 0 ? 1L : 0L),
result * 10L + (value[index] - '0'));
index++;
}
if (!sawDigit)
return 0;
long signed = sign < 0 ? -result : result;
return (int)Math.Clamp(signed, int.MinValue, int.MaxValue);
}
private bool RequireNoArguments(string arguments, string usage)
{
if (string.IsNullOrWhiteSpace(arguments)) return true;

View file

@ -306,41 +306,9 @@ internal sealed class CharacterCreationSkillsPage : IDisposable
_infoTitle = UiElement.FindDescendant(pageRoot, 0x100003FBu) as UiText;
_infoText = UiElement.FindDescendant(pageRoot, 0x100003FCu) as UiText;
// R3-3 (Campaign CC gate round 1 re-test 2): the title
// (0x100003fb, Y=435, Height=100) and description (0x100003fc,
// Y=460, Height=100) panes' own AUTHORED boxes overlap by 75px
// (live-DAT-measured) — retail relies on vertical JUSTIFICATION,
// not disjoint rects, to keep the two visually separate. Neither
// element authors dat property 0x15 (live-DAT-probe-confirmed
// absent on both), so both fall to whatever the unauthored default
// resolves to. Byte-traced against retail's own
// UIElement_Text::UIElement_Text ctor @0x004685ff
// (this->m_eVerticalJustification = 4) cross-referenced with
// UIElement_Text::CalcJustification @0x00467260 (the ACTUAL
// enum semantics: ecx_5==1 -> Center, ecx_5==3||5 -> the FAR edge
// (Bottom), any other value including the ctor's own default of 4
// -> edi=0, the NEAR edge, i.e. Top): the correct unauthored
// default is TOP, not Center. This port's shared
// ElementReader/DatWidgetFactory VJustify mapping and field
// default both currently resolve an absent 0x15 to Center — a
// client-wide mismatch with real retail semantics that is NOT
// fixed here (filed as ISSUES.md #410; the blast radius spans
// every already-shipped DAT-imported UiText that relies on the
// CURRENT Center default, so a global remap needs its own
// dedicated investigation + regression sweep, not a bundled
// fix inside this page). Scoped correction: force these two
// specific panes to the value retail's ctor actually resolves
// to. Under Top justification the title (OneLine, ~1 line) sits
// near its box's own top (global Y~435) and the description
// (multi-line, honoring the SAME justification via
// ConfigureDatState's _honorDatVerticalJustification) starts near
// ITS box's own top (global Y~460) — the two boxes' TOP edges are
// 25px apart, so short/typical content no longer collides even
// though the boxes' full 100px extents still overlap on paper.
if (_infoTitle is { } infoTitle)
infoTitle.VerticalJustify = VJustify.Top;
if (_infoText is { } infoText)
infoText.VerticalJustify = VJustify.Top;
// The two panes author no 0x15. The shared importer now applies
// retail's constructor default (raw 4 -> Top), so no page-local
// justification correction is needed.
// R4-3 (Campaign CC gate round 1 re-test 3): the description pane's
// own raw box (0x100003fc, Y=460 H=100 -> bottom Y=560, live-DAT-

View file

@ -309,40 +309,6 @@ internal sealed class CharacterCreationUiController : IDisposable
_townTab.OnClick = () => ApplyProgressState(Page.Town);
_summaryTab.OnClick = () => ApplyProgressState(Page.Summary);
// GF-13 (Campaign CC gate round 1, Batch A): honor the authored
// Invisible flag (dat property 0x3B) chargen-scoped only — see
// HideAuthoredInvisibleElements's own doc comment.
HideAuthoredInvisibleElements(Root);
}
/// <summary>
/// GF-13 (Campaign CC gate round 1, Batch A). The user's live gate
/// reported an acdream-only "-Non-admin or Non-envoy" text leak below the
/// Summary name field. Root cause: elements <c>0x10000403</c> ("Non-
/// Admin") and <c>0x10000494</c> ("Non-Envoy") author dat property
/// <c>0x3B</c> (Invisible) = <see langword="true"/> — retail's
/// <c>UIElement::OnSetAttribute @0x00462d80</c> case 8
/// (<c>GetPropertyName()-0x33 == 8</c>, property id <c>0x3B</c>) hides any
/// element authoring it via <c>SetVisible(value == 0)</c>. acdream's
/// shared <see cref="LayoutImporter"/> never read this property at all
/// (it now does, into <see cref="ElementInfo.Invisible"/> /
/// <see cref="UiElement.AuthoredInvisible"/>, a pure data addition), so
/// every one of the 1,083 elements client-wide that author it rendered
/// regardless. A blanket importer-wide honor is its own separately-gated
/// visual sweep (docs/ISSUES.md #408) — this method is the NARROW,
/// chargen-scoped fix: walk this screen's own mounted subtree once at
/// construction and hide anything the dat itself marked hidden, by the
/// AUTHORED FLAG rather than a hardcoded id list, so any other
/// authored-invisible element under this root (not just the two the user
/// happened to see) is honored the same way. Register AP-230 records the
/// scoped-vs-general split.
/// </summary>
private static void HideAuthoredInvisibleElements(UiElement element)
{
if (element.AuthoredInvisible)
element.Visible = false;
foreach (UiElement child in element.Children)
HideAuthoredInvisibleElements(child);
}
internal UiElement Root => _layout.Root;

View file

@ -23,7 +23,7 @@ internal sealed class CharacterManagementUiController : IDisposable
/// gmCharacterManagementUI::ListenToElementMessage@0x004ed5a0's element-id
/// switch is keyed off <c>idElement - 0x1000039d</c> (the listbox base);
/// offset 6 -> QueueUIMode(0x10000005), the mode gmCreditsUI registers
/// (Register@0x0047a69e) — out of scope this round (finding 1 note).
/// (Register@0x004E7500).
/// </summary>
internal const uint CreditsElementId = 0x100003A3u;
/// <summary>Offset 7 from the listbox base -> MakeConfirmExitDialog@0x004ed250.</summary>
@ -69,6 +69,7 @@ internal sealed class CharacterManagementUiController : IDisposable
private uint _errorDialogContext;
private uint _confirmExitDialogContext;
private bool _active;
private bool _presentationSuppressed;
private bool _restoreCommandInFlight;
private bool _suppressDialogCallbacks;
private bool _disposed;
@ -86,7 +87,8 @@ internal sealed class CharacterManagementUiController : IDisposable
UiButton exit,
RetailDialogFactory dialogs,
CharacterSelectionRuntimeBindings bindings,
DialogStrings strings)
DialogStrings strings,
Action? openCredits)
{
_host = host;
_layout = layout;
@ -139,13 +141,12 @@ internal sealed class CharacterManagementUiController : IDisposable
_delete.OnClick = RequestDelete;
_restore.OnClick = RestoreSelected;
// Credits (retail QueueUIMode(0x10000005) -> gmCreditsUI) is out of
// scope this round (finding 1 note) — same "future campaign, visibly
// ghosted, no invented action" treatment as Create above. Filed as
// issue #400.
// ListenToElementMessage @0x004ED5A0 case 6 queues UI mode
// 0x10000005, registered by gmCreditsUI::Register @0x004E7500.
// The local composition callback performs that same screen swap.
_credits.Visible = true;
_credits.Enabled = false;
_credits.OnClick = null;
_credits.Enabled = openCredits is not null;
_credits.OnClick = openCredits;
_exit.OnClick = RequestExit;
// World name (retail UpdateWorldName@0x004ec120 /
@ -168,6 +169,7 @@ internal sealed class CharacterManagementUiController : IDisposable
{
if (_disposed)
return;
_presentationSuppressed = false;
Deactivate();
_lastRevision = long.MinValue;
}
@ -178,7 +180,8 @@ internal sealed class CharacterManagementUiController : IDisposable
Func<uint, uint, UiElement?> templateResolver,
RetailDialogFactory dialogs,
CharacterSelectionRuntimeBindings bindings,
DialogStrings strings)
DialogStrings strings,
Action? openCredits = null)
{
CharacterManagementUiController? controller = CreateDetached(
host,
@ -186,7 +189,8 @@ internal sealed class CharacterManagementUiController : IDisposable
templateResolver,
dialogs,
bindings,
strings);
strings,
openCredits);
if (controller is null)
return null;
@ -208,7 +212,8 @@ internal sealed class CharacterManagementUiController : IDisposable
Func<uint, uint, UiElement?> templateResolver,
RetailDialogFactory dialogs,
CharacterSelectionRuntimeBindings bindings,
DialogStrings strings)
DialogStrings strings,
Action? openCredits = null)
{
ArgumentNullException.ThrowIfNull(host);
ArgumentNullException.ThrowIfNull(layout);
@ -255,7 +260,8 @@ internal sealed class CharacterManagementUiController : IDisposable
exit,
dialogs,
bindings,
strings);
strings,
openCredits);
}
catch
{
@ -264,6 +270,7 @@ internal sealed class CharacterManagementUiController : IDisposable
enter.OnClick = null;
delete.OnClick = null;
restore.OnClick = null;
credits.OnClick = null;
exit.OnClick = null;
throw;
}
@ -292,6 +299,13 @@ internal sealed class CharacterManagementUiController : IDisposable
if (_disposed)
return;
if (_presentationSuppressed)
{
Deactivate();
_lastRevision = long.MinValue;
return;
}
IRuntimeCharacterSelectionView? view = _bindings.View();
RuntimeCharacterSelectionSnapshot snapshot = view?.Snapshot ?? default;
if (view is null || !snapshot.IsActive)
@ -380,6 +394,7 @@ internal sealed class CharacterManagementUiController : IDisposable
_enter.OnClick = null;
_delete.OnClick = null;
_restore.OnClick = null;
_credits.OnClick = null;
_exit.OnClick = null;
foreach (UiButton row in _rows)
{
@ -394,6 +409,21 @@ internal sealed class CharacterManagementUiController : IDisposable
}
}
/// <summary>
/// Local UI-mode seam used by <see cref="CreditsUiController"/>. Retail
/// destroys/recreates the two frameworks through QueueUIMode; hiding this
/// retained root while preserving Runtime's character-selection owner has
/// the same observable behavior without duplicating state.
/// </summary>
internal void SetPresentationSuppressed(bool suppressed)
{
if (_disposed || _presentationSuppressed == suppressed)
return;
_presentationSuppressed = suppressed;
_lastRevision = long.MinValue;
Tick();
}
private static bool TryCaptureRoster(
IRuntimeCharacterSelectionView view,
RuntimeCharacterSelectionSnapshot expected,

View file

@ -20,13 +20,15 @@ internal sealed class CharacterManagementUiMountCoordinator : IDisposable
private readonly CharacterSelectionRuntimeBindings _bindings;
private readonly Func<RetailDialogFactory?> _ensureDialogs;
private readonly Func<CharacterManagementUiMountResources?> _loadResources;
private readonly Action? _openCredits;
private bool _disposed;
public CharacterManagementUiMountCoordinator(
UiRoot host,
CharacterSelectionRuntimeBindings bindings,
Func<RetailDialogFactory?> ensureDialogs,
Func<CharacterManagementUiMountResources?> loadResources)
Func<CharacterManagementUiMountResources?> loadResources,
Action? openCredits = null)
{
_host = host ?? throw new ArgumentNullException(nameof(host));
_bindings = bindings ?? throw new ArgumentNullException(nameof(bindings));
@ -34,6 +36,7 @@ internal sealed class CharacterManagementUiMountCoordinator : IDisposable
?? throw new ArgumentNullException(nameof(ensureDialogs));
_loadResources = loadResources
?? throw new ArgumentNullException(nameof(loadResources));
_openCredits = openCredits;
}
public CharacterManagementUiController? Controller { get; private set; }
@ -58,9 +61,10 @@ internal sealed class CharacterManagementUiMountCoordinator : IDisposable
_host,
resources.Layout,
resources.TemplateResolver,
dialogs,
_bindings,
resources.Strings);
dialogs,
_bindings,
resources.Strings,
_openCredits);
if (candidate is null)
return;

View file

@ -1272,6 +1272,11 @@ public static class ConfigOptionsPageController
"ID_Graphics_Value_High", "ID_Graphics_Value_VeryHigh", "ID_Graphics_Value_Extreme",
};
private static readonly int[] LandscapeDrawDistanceValues =
{
3, 5, 8, 11, 15, 25,
};
private static void BindRenderingQualitySection(
UiTemplateListBox listBox,
OptionPage page,
@ -1311,17 +1316,18 @@ public static class ConfigOptionsPageController
storeOnly: true, // AP-198
resolveSprite, datFont, debugFont);
// UNRESOLVED (see class doc / register row): retail's own
// SetDefaultValue(8) does not index this 6-entry choice array.
// Reproduced as an opaque int; the menu simply shows no
// highlighted item at the default (no crash, no invented mapping).
// Retail's SetEnumChoices carries the six integer payloads
// {3,5,8,11,15,25}; captions are indices only in presentation.
// SetDefaultValue(8) therefore selects Medium, and @render radius
// writes the same preference value this row reads.
BuildMenuRow(
listBox, "ID_Graphics_LandscapeDrawDistance", LandscapeDrawDistanceChoices, page, resolveString,
read: () => bindings.LoadDisplay().LandscapeDrawDistance,
apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { LandscapeDrawDistance = value }),
defaultValue: 8,
storeOnly: true, // AP-198
resolveSprite, datFont, debugFont);
storeOnly: false,
resolveSprite, datFont, debugFont,
payloadValues: LandscapeDrawDistanceValues);
BuildToggleRow(
listBox, "ID_Graphics_BuildingDetailTextures", defaultValue: true, page, resolveString,
@ -1736,7 +1742,8 @@ public static class ConfigOptionsPageController
bool storeOnly,
Func<uint, (uint tex, int w, int h)>? resolveSprite,
UiDatFont? datFont,
BitmapFont? debugFont)
BitmapFont? debugFont,
IReadOnlyList<int>? payloadValues = null)
{
UiElement? row = listBox.AddItemFromTemplateList(MenuTemplateIndex);
if (row is null)
@ -1768,7 +1775,13 @@ public static class ConfigOptionsPageController
if (tooltip is not null)
menu.TooltipText = tooltip;
if (payloadValues is not null && payloadValues.Count != choiceKeys.Length)
throw new ArgumentException(
"Menu payload count must match the choice count.",
nameof(payloadValues));
string[] choiceLabels = new string[choiceKeys.Length];
int[] choiceValues = new int[choiceKeys.Length];
var items = new UiMenu.MenuItem[choiceKeys.Length];
for (int i = 0; i < choiceKeys.Length; i++)
{
@ -1779,7 +1792,8 @@ public static class ConfigOptionsPageController
$"[D.2b] ConfigOptionsPageController: menu choice '{choiceKeys[i]}' "
+ $"(for '{labelKey}') did not resolve — item renders with no caption "
+ "rather than invented English.");
items[i] = new UiMenu.MenuItem(choiceLabels[i], i);
choiceValues[i] = payloadValues?[i] ?? i;
items[i] = new UiMenu.MenuItem(choiceLabels[i], choiceValues[i]);
}
menu.Items = items;
@ -1788,7 +1802,8 @@ public static class ConfigOptionsPageController
menu.ButtonLabelProvider = () =>
{
int current = menu.Selected is int selected ? selected : initial;
return current >= 0 && current < choiceLabels.Length ? choiceLabels[current] : string.Empty;
int choiceIndex = Array.IndexOf(choiceValues, current);
return choiceIndex >= 0 ? choiceLabels[choiceIndex] : string.Empty;
};
var row_ = new IntOptionRow(

View file

@ -0,0 +1,433 @@
using System.Numerics;
namespace AcDream.App.UI.Layout;
internal sealed record CreditsUiResources(
uint LayoutId,
ImportedLayout PictureLayout,
ImportedLayout TextLayout,
IReadOnlyList<string> TextFragments,
IReadOnlyList<uint> PictureIds,
float SectionSeconds,
string PleaseWait);
/// <summary>
/// Retained-mode port of retail <c>gmCreditsUI @0x004E6E70..0x004E79E0</c>.
/// Retail composes two selected roots from layout enum <c>0x10000004</c>,
/// scrolls the localized <c>ID_Credits1..N</c> glyph block and a cyclic strip
/// of authored pictures by the same pixel delta, then returns to character
/// management on completion or any input action.
/// </summary>
internal sealed class CreditsUiController : IDisposable
{
internal const uint RootEnum = 0x10000004u;
internal const uint PictureRootElementId = 0x10000413u;
internal const uint TextRootElementId = 0x10000410u;
internal const uint TextAreaElementId = 0x10000411u;
internal const uint DynamicPictureElementId = 0x10000415u;
private readonly UiRoot _host;
private readonly ImportedLayout _pictureLayout;
private readonly ImportedLayout _textLayout;
private readonly UiText _textArea;
private readonly IReadOnlyList<string> _textFragments;
private readonly IReadOnlyList<uint> _pictureIds;
private readonly float _sectionSeconds;
private readonly RetailDialogFactory _dialogs;
private readonly string _pleaseWait;
private readonly Func<double> _nowSeconds;
private readonly Func<uint, (uint tex, int w, int h)> _resolveSprite;
private readonly Action _returnToCharacterManagement;
private readonly CreditsActionSurface _actionSurface;
private readonly List<UiPanel> _pictures = [];
private readonly Vector2 _authoredCanvas;
private UiText.Line[] _lines = [];
private float _textHeight;
private double _startTime;
private double _duration;
private float _lastProgress;
private int _nextPicture;
private long _tickSequence;
private long _returnAtTick = long.MaxValue;
private uint _waitContext;
private bool _active;
private bool _returnPending;
private bool _disposed;
private CreditsUiController(
UiRoot host,
ImportedLayout pictureLayout,
ImportedLayout textLayout,
UiText textArea,
IReadOnlyList<string> textFragments,
IReadOnlyList<uint> pictureIds,
float sectionSeconds,
RetailDialogFactory dialogs,
string pleaseWait,
Func<double> nowSeconds,
Func<uint, (uint tex, int w, int h)> resolveSprite,
Action returnToCharacterManagement)
{
_host = host;
_pictureLayout = pictureLayout;
_textLayout = textLayout;
_textArea = textArea;
_textFragments = textFragments;
_pictureIds = pictureIds;
_sectionSeconds = sectionSeconds;
_dialogs = dialogs;
_pleaseWait = pleaseWait;
_nowSeconds = nowSeconds;
_resolveSprite = resolveSprite;
_returnToCharacterManagement = returnToCharacterManagement;
float width = MathF.Max(
PictureRoot.Left + PictureRoot.Width,
TextRoot.Left + TextRoot.Width);
float height = MathF.Max(
PictureRoot.Top + PictureRoot.Height,
TextRoot.Top + TextRoot.Height);
_authoredCanvas = new Vector2(
width > 0f ? width : 800f,
height > 0f ? height : 600f);
PictureRoot.Visible = false;
TextRoot.Visible = false;
_actionSurface = new CreditsActionSurface(BeginReturn)
{
Width = _authoredCanvas.X,
Height = _authoredCanvas.Y,
Visible = false,
ZOrder = int.MaxValue,
};
}
internal UiElement PictureRoot => _pictureLayout.Root;
internal UiElement TextRoot => _textLayout.Root;
internal UiText TextArea => _textArea;
internal IReadOnlyList<UiPanel> Pictures => _pictures;
internal bool IsActive => _active;
internal double DurationSeconds => _duration;
internal static CreditsUiController? CreateDetached(
UiRoot host,
CreditsUiResources resources,
RetailDialogFactory dialogs,
Func<double> nowSeconds,
Func<uint, (uint tex, int w, int h)> resolveSprite,
Action returnToCharacterManagement)
{
ArgumentNullException.ThrowIfNull(host);
ArgumentNullException.ThrowIfNull(resources);
ArgumentNullException.ThrowIfNull(dialogs);
ArgumentNullException.ThrowIfNull(nowSeconds);
ArgumentNullException.ThrowIfNull(resolveSprite);
ArgumentNullException.ThrowIfNull(returnToCharacterManagement);
if (resources.PictureLayout.Root.DatElementId != PictureRootElementId
|| resources.TextLayout.Root.DatElementId != TextRootElementId
|| resources.TextLayout.FindElement(TextAreaElementId) is not UiText textArea
|| resources.TextFragments.Count == 0
|| resources.PictureIds.Count == 0
|| !float.IsFinite(resources.SectionSeconds)
|| resources.SectionSeconds <= 0f)
{
Console.WriteLine(
"[UI] credits: authored root/text/picture contract is incomplete.");
return null;
}
return new CreditsUiController(
host,
resources.PictureLayout,
resources.TextLayout,
textArea,
resources.TextFragments,
resources.PictureIds,
resources.SectionSeconds,
dialogs,
resources.PleaseWait,
nowSeconds,
resolveSprite,
returnToCharacterManagement);
}
internal void Activate()
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (_active)
return;
AttachRoots();
ResetRun();
_active = true;
PictureRoot.Visible = true;
TextRoot.Visible = true;
_actionSurface.Visible = true;
_host.DeclareFixedCanvas(this, _authoredCanvas);
_host.BringToFront(PictureRoot);
_host.BringToFront(TextRoot);
_host.BringToFront(_actionSurface);
_host.SetKeyboardFocus(_actionSurface);
// Update order is retail's: ScrollText first, then ScrollPictures.
// At progress zero that creates the first picture one pixel below
// the picture field, exactly as CreateAndAddPicture @0x004E7592.
Tick();
}
internal void Tick()
{
if (_disposed || !_active)
return;
_tickSequence++;
if (_returnPending)
{
if (_tickSequence >= _returnAtTick)
CompleteReturn();
return;
}
double elapsed = Math.Max(0d, _nowSeconds() - _startTime);
float progress = _duration <= 0d
? 1f
: Math.Clamp((float)(elapsed / _duration), 0f, 1f);
// Timer::compute_time is monotonic in retail. Preserve that invariant
// even when a deterministic test clock is moved backwards.
progress = MathF.Max(progress, _lastProgress);
_lastProgress = progress;
float fieldHeight = TextRoot.Height;
int oldTop = (int)MathF.Round(_textArea.Top);
int travel = (int)MathF.Round(
(fieldHeight + _textHeight) * progress,
MidpointRounding.ToEven);
int newTop = (int)MathF.Round(fieldHeight) - travel;
_textArea.Left = 0f;
_textArea.Top = newTop;
ScrollPictures(oldTop - newTop);
if (progress >= 1f)
BeginReturn();
}
internal void ResetSession()
{
if (_disposed || !_active)
return;
Deactivate();
_returnToCharacterManagement();
}
public void Dispose()
{
if (_disposed)
return;
bool restoreCharacterManagement = _active;
_disposed = true;
Deactivate();
_host.RemoveChild(PictureRoot);
_host.RemoveChild(TextRoot);
_host.RemoveChild(_actionSurface);
if (restoreCharacterManagement)
_returnToCharacterManagement();
}
private void AttachRoots()
{
if (PictureRoot.Parent is null)
_host.AddChild(PictureRoot);
if (TextRoot.Parent is null)
_host.AddChild(TextRoot);
if (_actionSurface.Parent is null)
_host.AddChild(_actionSurface);
}
private void ResetRun()
{
CloseWait();
ClearPictures();
_returnPending = false;
_returnAtTick = long.MaxValue;
_lastProgress = 0f;
_nextPicture = 0;
_textArea.Width = TextRoot.Width;
float maximumWidth = Math.Max(
1f,
_textArea.Width
- (_textArea.Padding + _textArea.MarginLeft)
- (_textArea.Padding + _textArea.MarginRight));
Func<string, float> measure = _textArea.DatFont is { } font
? font.MeasureWidth
: static value => value.Length * 8f;
string allText = string.Concat(_textFragments);
IReadOnlyList<string> wrapped = UiText.WrapWords(
allText,
measure,
maximumWidth);
if (wrapped.Count == 0)
wrapped = [string.Empty];
_lines = [.. wrapped.Select(
line => new UiText.Line(line, _textArea.DefaultColor))];
_textArea.LinesProvider = () => _lines;
float lineHeight = _textArea.DatFont?.LineHeight ?? 16f;
_textHeight = Math.Max(lineHeight, lineHeight * _lines.Length);
_textArea.Left = 0f;
_textArea.Top = TextRoot.Height;
_textArea.Height = _textHeight;
// Initialize @0x004E726C. The loop cursor is N+1 when the first
// invalid ID_Credits key terminates enumeration, so preserve that
// exact denominator rather than substituting the valid count.
float terminatorIndex = _textFragments.Count + 1f;
_duration = _sectionSeconds
* (TextRoot.Height + _textHeight)
/ (TextRoot.Height + _textHeight / terminatorIndex);
_startTime = _nowSeconds();
}
private void ScrollPictures(int deltaPixels)
{
if (deltaPixels != 0)
foreach (UiPanel picture in _pictures)
picture.Top -= deltaPixels;
if (_pictures.Count > 0
&& _pictures[0].Top + _pictures[0].Height < 0f)
{
UiPanel expired = _pictures[0];
_pictures.RemoveAt(0);
PictureRoot.RemoveChild(expired);
}
if (_pictures.Count == 0)
AddPicture();
if (_pictures.Count > 0
&& _pictures[^1].Top < PictureRoot.Height)
{
AddPicture();
}
}
private void AddPicture()
{
if (_pictureIds.Count == 0)
return;
uint pictureId = _pictureIds[_nextPicture];
_nextPicture = (_nextPicture + 1) % _pictureIds.Count;
(uint texture, int width, int height) = _resolveSprite(pictureId);
if (texture == 0u || width <= 0 || height <= 0)
return;
float top = _pictures.Count == 0
? PictureRoot.Height + 1f
: _pictures[^1].Top + _pictures[^1].Height + 1f;
var picture = new UiPanel
{
DatElementId = DynamicPictureElementId,
Left = 0f,
Top = top,
Width = width,
Height = height,
BackgroundColor = Vector4.Zero,
BorderColor = Vector4.Zero,
BorderThickness = 0f,
BackgroundSprite = pictureId,
SpriteResolve = _resolveSprite,
ClickThrough = true,
};
PictureRoot.AddChild(picture);
_pictures.Add(picture);
}
private void BeginReturn()
{
if (_disposed || !_active || _returnPending)
return;
_returnPending = true;
_waitContext = _dialogs.MakeWait(_pleaseWait);
// QueueUIMode is asynchronous in retail. Keep the wait visible for one
// complete presented frame, then perform the local mode swap.
_returnAtTick = _tickSequence + 2;
}
private void CompleteReturn()
{
if (!_active)
return;
Deactivate();
_returnToCharacterManagement();
}
private void Deactivate()
{
_returnPending = false;
_returnAtTick = long.MaxValue;
_active = false;
PictureRoot.Visible = false;
TextRoot.Visible = false;
_actionSurface.Visible = false;
if (ReferenceEquals(_host.KeyboardFocus, _actionSurface))
_host.SetKeyboardFocus(null);
_host.RevokeFixedCanvas(this);
CloseWait();
ClearPictures();
}
private void CloseWait()
{
uint context = _waitContext;
_waitContext = 0u;
if (context != 0u)
_dialogs.CloseDialog(context);
}
private void ClearPictures()
{
foreach (UiPanel picture in _pictures)
PictureRoot.RemoveChild(picture);
_pictures.Clear();
}
private sealed class CreditsActionSurface : UiElement
{
private readonly Action _onAction;
public override bool HandlesClick => true;
public CreditsActionSurface(Action onAction)
{
_onAction = onAction ?? throw new ArgumentNullException(nameof(onAction));
AcceptsFocus = true;
ClickThrough = false;
}
public override bool OnEvent(in UiEvent e)
{
if (!Enabled || !Visible)
return false;
if (e.Type is UiEventType.KeyDown
or UiEventType.MouseDown
or UiEventType.RightDown
or UiEventType.MiddleDown
or UiEventType.Scroll)
{
_onAction();
return true;
}
return e.Type is UiEventType.KeyUp
or UiEventType.MouseUp
or UiEventType.RightUp
or UiEventType.MiddleUp
or UiEventType.Click
or UiEventType.RightClick;
}
}
}

View file

@ -706,10 +706,11 @@ public static class DatWidgetFactory
if (state.Properties.Values.TryGetValue(0x14u, out var justify)
&& justify.Kind == UiPropertyKind.Enum)
{
align = justify.UnsignedValue switch
align = ElementReader.MapHorizontalJustification(
justify.UnsignedValue) switch
{
0u or 2u => UiMeterLabelAlign.Left,
3u or 5u => UiMeterLabelAlign.Right,
HJustify.Left => UiMeterLabelAlign.Left,
HJustify.Right => UiMeterLabelAlign.Right,
_ => UiMeterLabelAlign.Center,
};
}
@ -893,12 +894,7 @@ public static class DatWidgetFactory
// afterward will override these — this is only the dat-driven default.
bool centered = info.HJustify == HJustify.Center;
bool rightAligned = info.HJustify == HJustify.Right;
var vJustify = info.VJustify switch
{
VJustify.Top => VJustify.Top,
VJustify.Bottom => VJustify.Bottom,
_ => VJustify.Center,
};
var vJustify = info.VJustify;
var t = new UiText
{

View file

@ -14,7 +14,8 @@ public enum HJustify : byte { Left = 0, Center = 1, Right = 2 }
/// <summary>
/// Vertical text justification read from dat property 0x15 (UIElement VerticalJustification).
/// Values: 2=Top, 4=Bottom; absent/other = Center.
/// Retail <c>CalcJustification @ 0x00467260</c> treats 1 as Center,
/// 3/5 as Bottom, and every other value (including constructor default 4) as Top.
/// </summary>
public enum VJustify : byte { Top = 0, Center = 1, Bottom = 2 }
@ -109,18 +110,17 @@ public sealed class ElementInfo
/// <summary>
/// Horizontal text justification from dat <c>Properties[0x14]</c>
/// (<c>EnumBaseProperty</c>: 0=Left, 1=Center, 3/5=Right).
/// Default is <see cref="HJustify.Center"/> to preserve existing behavior where
/// controllers set <c>Centered=true</c> and no property was read.
/// (<c>EnumBaseProperty</c>: 1=Center, 3/5=Right, all others=Left).
/// Retail's constructor default is raw 2, which resolves to Left.
/// </summary>
public HJustify HJustify = HJustify.Center;
public HJustify HJustify = HJustify.Left;
/// <summary>
/// Vertical text justification from dat <c>Properties[0x15]</c>
/// (<c>EnumBaseProperty</c>: 2=Top, 4=Bottom; absent/other = Center).
/// Default is <see cref="VJustify.Center"/> to preserve existing behavior.
/// (<c>EnumBaseProperty</c>: 1=Center, 3/5=Bottom, all others=Top).
/// Retail's constructor default is raw 4, which resolves to Top.
/// </summary>
public VJustify VJustify = VJustify.Center;
public VJustify VJustify = VJustify.Top;
/// <summary>
/// Font color from dat <c>Properties[0x1B]</c> (<c>ColorBaseProperty</c>, ARGB bytes).
@ -251,13 +251,8 @@ public sealed class ElementInfo
/// an authored <c>true</c> HIDES the element at construction. Populated the
/// same way as <see cref="TabTable"/>/<see cref="ScrollbarElementId"/>
/// (recomputed fresh from the effective merged state every call), but this
/// is a PURE DATA ADDITION: the shared <see cref="LayoutImporter"/>/
/// <see cref="DatWidgetFactory"/> path does not act on it. 1,083 elements
/// author this flag client-wide (docs/ISSUES.md #408, its own separately-
/// gated general-honor item) — only screens that explicitly walk their own
/// mounted subtree and check this field may hide elements by it (see
/// <c>CharacterCreationUiController</c>'s chargen-scoped honor, register
/// AP-230).
/// feeds the shared <see cref="LayoutImporter"/>, which applies the retail
/// construction-time visibility write uniformly to every built widget.
/// </summary>
public bool Invisible;
@ -583,12 +578,11 @@ public static class ElementReader
ZLevel = derived.ZLevel != 0 ? derived.ZLevel : base_.ZLevel,
DefaultStateId = derived.DefaultStateId != 0 ? derived.DefaultStateId : base_.DefaultStateId,
FontDid = derived.FontDid != 0 ? derived.FontDid : base_.FontDid,
// HJustify/VJustify: derived wins when it carries an explicit non-Center value
// (the dat property was present and read); otherwise inherit the base prototype's value.
// Center is the default (= "not set by this element") so Center-derived never overrides
// a non-Center base — matching the FontDid "non-zero wins" convention.
HJustify = derived.HJustify != HJustify.Center ? derived.HJustify : base_.HJustify,
VJustify = derived.VJustify != VJustify.Center ? derived.VJustify : base_.VJustify,
// Presence, not a semantic enum value, decides inheritance. Center
// is a legitimate authored override; using it as an "unset"
// sentinel silently lost derived raw value 1.
HJustify = HasEffectiveEnum(derived, 0x14u) ? derived.HJustify : base_.HJustify,
VJustify = HasEffectiveEnum(derived, 0x15u) ? derived.VJustify : base_.VJustify,
// FontColor: derived wins when it has an explicit (non-null) color; otherwise inherit the base.
// Null means "dat carried no 0x1B property" — so null-derived does NOT override a non-null base.
FontColor = derived.FontColor ?? base_.FontColor,
@ -650,23 +644,13 @@ public static class ElementReader
if (info.TryGetEffectiveProperty(0x14u, out var horizontal)
&& horizontal.Kind == UiPropertyKind.Enum)
{
info.HJustify = horizontal.UnsignedValue switch
{
0u or 2u => HJustify.Left,
3u or 5u => HJustify.Right,
_ => HJustify.Center,
};
info.HJustify = MapHorizontalJustification(horizontal.UnsignedValue);
}
if (info.TryGetEffectiveProperty(0x15u, out var vertical)
&& vertical.Kind == UiPropertyKind.Enum)
{
info.VJustify = vertical.UnsignedValue switch
{
2u => VJustify.Top,
4u => VJustify.Bottom,
_ => VJustify.Center,
};
info.VJustify = MapVerticalJustification(vertical.UnsignedValue);
}
if (info.TryGetEffectiveProperty(0x1Bu, out var color))
@ -820,6 +804,24 @@ public static class ElementReader
info.MinHeight = minHeight;
}
internal static HJustify MapHorizontalJustification(ulong raw) => raw switch
{
1UL => HJustify.Center,
3UL or 5UL => HJustify.Right,
_ => HJustify.Left,
};
internal static VJustify MapVerticalJustification(ulong raw) => raw switch
{
1UL => VJustify.Center,
3UL or 5UL => VJustify.Bottom,
_ => VJustify.Top,
};
private static bool HasEffectiveEnum(ElementInfo info, uint propertyId) =>
info.TryGetEffectiveProperty(propertyId, out UiPropertyValue value)
&& value.Kind == UiPropertyKind.Enum;
private static List<UiTabTableEntry> ReadTabTable(ElementInfo info)
{
var entries = new List<UiTabTableEntry>();

View file

@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.Linq;
using System.Numerics;
using AcDream.Runtime.Gameplay;
namespace AcDream.App.UI.Layout;
@ -31,15 +32,11 @@ namespace AcDream.App.UI.Layout;
/// <c>ParseUpdateRentTime</c>/<c>ParseUpdateRentPayment</c>,
/// <c>GameEventWiring</c>'s four delegate holes, the outbound HouseQuery
/// action). The House-tab ownership-text closer session (also 2026-08-17)
/// wired <see cref="Bindings.Lines"/> to the minimal <c>RuntimeHouseState</c>
/// owner and ported <c>DisplayPurchaseTimeText @0x004a3110</c>'s expired
/// branch — a fresh houseless character's House tab shows the single
/// decomp-verified line "You may buy another house immediately.",
/// live-connected-gate-verified (screenshot + structural UI-tree dump
/// against the real <c>+Acdream</c> character on a local ACE server). The
/// other six <c>Display*</c> line builders <c>DisplayHouseData</c> calls
/// (owned-house-only content: buy/rent payments and times, location,
/// warning text) remain unported — ISSUES #413's surviving scope. The
/// wired <see cref="Bindings.Lines"/> to <c>RuntimeHouseState</c>; issue
/// #413 later completed all seven <c>Display*</c> builders for both
/// houseless and owned-house snapshots. <see cref="Bindings.PanelLines"/>
/// preserves retail's Normal/RentPaid/RentNotPaid font-palette index while
/// the original string callback remains a compatibility fallback. The
/// night-round review (F2, 2026-08-17) moved the outbound HouseQuery send
/// from a House-tab-open trigger to retail's real login-complete edge (see
/// <see cref="Bindings.OnShown"/>'s own doc), so by the time a player opens
@ -78,11 +75,15 @@ public sealed class HousePageController
// always returns null (no resolver = no row), so Refresh silently
// produced zero rows regardless of Lines — the gap this session
// closes alongside the text composition itself.
Func<uint, uint, UiElement?>? TemplateResolver = null);
Func<uint, uint, UiElement?>? TemplateResolver = null,
// Issue #413: typed rows preserve retail's HousePanelTextColor
// palette index. The string-only callback remains for compatibility
// with standalone fixtures and older embedding callers.
Func<IReadOnlyList<HousePanelLine>>? PanelLines = null);
private readonly UiTemplateListBox _listBox;
private readonly Bindings _bindings;
private IReadOnlyList<string> _lastLines = Array.Empty<string>();
private IReadOnlyList<HousePanelLine> _lastLines = Array.Empty<HousePanelLine>();
private HousePageController(UiTemplateListBox listBox, Bindings bindings)
{
@ -104,7 +105,7 @@ public sealed class HousePageController
listBox.TemplateResolver = bindings.TemplateResolver;
var controller = new HousePageController(listBox, bindings);
controller.Refresh(bindings.Lines());
controller.Refresh(controller.CurrentLines());
return controller;
}
@ -113,22 +114,43 @@ public sealed class HousePageController
/// other social-panel pages' revision-gated rebuild discipline).</summary>
public void Tick()
{
IReadOnlyList<string> lines = _bindings.Lines();
IReadOnlyList<HousePanelLine> lines = CurrentLines();
if (lines.SequenceEqual(_lastLines)) return;
Refresh(lines);
}
public void OnShown() => _bindings.OnShown?.Invoke();
private void Refresh(IReadOnlyList<string> lines)
private IReadOnlyList<HousePanelLine> CurrentLines()
{
if (_bindings.PanelLines is { } styled)
return styled();
IReadOnlyList<string> plain = _bindings.Lines();
if (plain.Count == 0)
return Array.Empty<HousePanelLine>();
var projected = new HousePanelLine[plain.Count];
for (int i = 0; i < plain.Count; i++)
projected[i] = new HousePanelLine(plain[i], HousePanelTextColor.Normal);
return projected;
}
private void Refresh(IReadOnlyList<HousePanelLine> lines)
{
_lastLines = lines;
_listBox.Flush();
foreach (string line in lines)
foreach (HousePanelLine line in lines)
{
UiElement? row = _listBox.AddItemFromTemplateList(0);
if (row is UiText text)
text.LinesProvider = () => [new UiText.Line(line, Vector4.One)];
{
int colorIndex = (int)line.Color;
Vector4 color = colorIndex >= 0 && colorIndex < text.FontColorPalette.Count
? text.FontColorPalette[colorIndex]
: text.DefaultColor;
text.LinesProvider = () => [new UiText.Line(line.Text, color)];
}
}
}
}

View file

@ -128,9 +128,13 @@ public static class LayoutImporter
// #409: see the Build overload's own sourceLayoutDid doc comment.
w.SourceLayoutDid = sourceLayoutDid;
// GF-13: pure data passthrough — see UiElement.AuthoredInvisible's own
// doc comment for why this does NOT set Visible here.
// #408: retail applies P0x3B through UIElement::OnSetAttribute case 8
// for every element: Invisible=true means SetVisible(false). Keep the
// authored bit for diagnostics while making its initial behavior a
// property of the shared importer, not of individual screens.
w.AuthoredInvisible = info.Invisible;
if (info.Invisible)
w.Visible = false;
// #409: the six per-element tooltip properties, same pure-data-
// passthrough shape as AuthoredInvisible above. TooltipText is the
@ -223,19 +227,6 @@ public static class LayoutImporter
if (child.StateMedia.Count == 0) continue;
var cw = BuildWidget(child, resolve, datFont, fontResolve, stringResolve, byId, sourceLayoutDid);
if (cw is null) continue;
// F5/F6 (Campaign CC gate round 1 closeout): a NARROW honor
// of AuthoredInvisible, scoped to children reached through
// THIS carve-out only — e.g. the chat new-text indicator
// (0x1000048C, live-DAT-confirmed Invisible=true on every
// layout it appears in) would otherwise render as a phantom
// element retail never shows, now that this carve-out
// builds it as a real widget instead of silently dropping
// it. This is NOT the general client-wide honor (#408,
// 1,083 elements) — every OTHER AuthoredInvisible consumer
// stays data-only, acted on nowhere but chargen's own
// HideAuthoredInvisibleElements walk (register AP-230).
if (cw.AuthoredInvisible)
cw.Visible = false;
w.AddChild(cw);
}
}
@ -395,9 +386,9 @@ public static class LayoutImporter
/// (the character footer's three state-groups; the tab-page content areas) manage
/// visibility purely at runtime via C++ controller code. Retail uses
/// <c>UIElement::SetState(stateId)</c> on the parent to propagate state, then
/// C++ getters access the right sub-group by element id. All groups are shipped
/// as visible in the imported widget tree; the relevant controllers
/// (<see cref="CharacterStatController"/>) perform the initial show/hide.
/// C++ getters access the right sub-group by element id. Sibling groups which
/// do not author property 0x3B start visible; the relevant controllers
/// (<see cref="CharacterStatController"/>) perform their runtime show/hide.
/// </para>
/// </summary>
/// <param name="fontResolve">Optional per-element font resolver (see
@ -677,38 +668,6 @@ public static class LayoutImporter
if (sd.Properties is not null)
{
// HorizontalJustification (0x14): EnumBaseProperty.
// Retail CalcJustification @ 0x00467260: 1=Center, 3/5=Right,
// every other value (including constructor default 2)=Left.
// Only update if still at the default (Center); derived-wins handled in Merge.
if (info.HJustify == HJustify.Center
&& sd.Properties.TryGetValue(0x14u, out var hRaw)
&& hRaw is EnumBaseProperty hEnum)
{
info.HJustify = hEnum.Value switch
{
0u or 2u => HJustify.Left,
1u => HJustify.Center,
3u => HJustify.Right,
5u => HJustify.Right,
_ => HJustify.Left,
};
}
// VerticalJustification (0x15): EnumBaseProperty.
// Retail values: 2=Top, 4=Bottom; absent/other = Center.
if (info.VJustify == VJustify.Center
&& sd.Properties.TryGetValue(0x15u, out var vRaw)
&& vRaw is EnumBaseProperty vEnum)
{
info.VJustify = vEnum.Value switch
{
2u => VJustify.Top,
4u => VJustify.Bottom,
_ => VJustify.Center,
};
}
// ColorBaseProperty (0x1B): ARGB bytes → normalized [0,1] Vector4 (R,G,B,A).
// Only read when not already set (first dat state wins; Merge propagates from base).
if (info.FontColor is null

View file

@ -652,13 +652,8 @@ public sealed class RetailTooltipPresenter : IDisposable
// elements author P0x3D" sweep only covered hover TARGETS, never
// the popup skins' text children.)
// (b) Tooltip text is LEFT-aligned: the text child authors no
// justification and retail's unauthored default is Left, while
// our importer's ElementInfo default is Center — the same
// wrong-default class as #410's VJustify finding. Point-fixed
// here (the chat transcript does the same); the client-wide
// default remains #410's scope.
text.Centered = false;
text.RightAligned = false;
// justification, so the shared importer supplies retail's raw-2
// constructor default (Left).
// (c) The text child's authored margins (P0x23-0x26 — L2/R2/U2/D2 on
// the popup skins) participate exactly as InqSizewMargins does:

View file

@ -85,6 +85,7 @@ public class UiDatElement : UiElement, IUiDatStateful
public bool TrySetRetailState(uint stateId)
{
uint appliedStateId = stateId;
UiStateInfo? selectedState = null;
if (stateId == UiStateInfo.DirectStateId)
{
@ -114,23 +115,15 @@ public class UiDatElement : UiElement, IUiDatStateful
// Normal_rollover/Highlight media but NO 'Normal' state at
// all, so the row's PassToChildren 'Normal' hover-leave
// cascade landed here and the bars never cleared. Retail's
// state-0 arm cascades state 0 to children off the BASE
// descriptor's own PassToChildren (m_desc.m_bPassToChildren,
// @0x00464eca), and the per-state Invisible honor below stays
// scoped to NAMED authored states exactly as before (the #408
// gate) — selectedState remains null on this path.
// state-0 arm applies and cascades the BASE descriptor.
ActiveState = "";
if (Info.States.TryGetValue(
UiStateInfo.DirectStateId, out UiStateInfo? baseState)
&& baseState.PassToChildren)
{
foreach (UiElement child in Children)
if (child is IUiDatStateful stateful)
stateful.TrySetRetailState(UiStateInfo.DirectStateId);
}
return true;
appliedStateId = UiStateInfo.DirectStateId;
Info.States.TryGetValue(appliedStateId, out selectedState);
}
else
{
ActiveState = stateName;
}
ActiveState = stateName;
}
// Per-state Invisible (dat property 0x3B): retail's SetState applies
@ -144,21 +137,11 @@ public class UiDatElement : UiElement, IUiDatStateful
// Normal_rollover={0x3B:false}, i.e. hidden at rest, shown on
// rollover.
//
// SCOPED TO NAMED STATES ONLY: a 0x3B authored in the unnamed
// DirectState is the CONSTRUCTION-time "authored invisible" class
// (1,083 elements client-wide — docs/ISSUES.md #408, its own
// separately-gated general-honor item; ElementReader.Invisible/GF-13
// captures it and only chargen's scoped walk acts on it, register
// AP-230). Honoring it here would un-gate #408 through the back
// door: LayoutImporter.BuildWidget's post-children state reapply
// calls TrySetRetailState(DirectStateId) on every built widget, so
// a DirectState honor would hide all 1,083 at import (measured
// same-round: 10 combat-layout elements incl. 0x10000454 went
// un-hit-testable, breaking the spell-favorite drag tests). The
// NAMED-state flip below is a live visibility state machine that
// cannot work at all without the honor — that is this port's line.
if (stateId != UiStateInfo.DirectStateId
&& selectedState is not null
// #408: DirectState is not a special exception. It reaches the same
// OnSetAttribute switch during construction and whenever retail falls
// back to state 0, so it must be able to restore authored visibility
// after a named state changed it.
if (selectedState is not null
&& selectedState.Properties.TryGetValue(0x3Bu, out var invisibleProp)
&& invisibleProp.Kind == UiPropertyKind.Bool)
Visible = !invisibleProp.BoolValue;
@ -167,7 +150,7 @@ public class UiDatElement : UiElement, IUiDatStateful
{
foreach (UiElement child in Children)
if (child is IUiDatStateful stateful)
stateful.TrySetRetailState(stateId);
stateful.TrySetRetailState(appliedStateId);
}
return true;
}

View file

@ -169,8 +169,8 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
/// the absent authored sprite exactly rather than inventing one.
/// </para>
/// <para>
/// <b>G5 correction (vendor gate finding): it is a SCROLLABLE single
/// column, not a 3-column grid.</b> The F1 review's "column-major grid"
/// <b>G5 correction (vendor gate finding): it is a single scrollable
/// ListBox, not a 3-column grid.</b> The F1 review's "column-major grid"
/// framing was wrong — a live-dat scan (<c>tools/VendorLayoutScan</c>,
/// <c>dump</c>/<c>resolved 0x21000043 0x1000034F</c>) shows
/// <c>0x1000034F</c> has TWO children, not one: the ListBox
@ -183,15 +183,17 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
/// <c>0x06004C60</c>/<c>63</c>/<c>66</c>, up button (element
/// <c>0x10000072</c>, retail-seated on top) <c>0x06004C6C</c>/<c>6D</c>/<c>6E</c>, down button
/// (element <c>0x10000071</c>, retail-seated on the bottom) <c>0x06004C69</c>/<c>6A</c>/<c>6B</c>,
/// track <c>0x06004C5F</c>). With 18 authored categories and only 6
/// visible rows, retail's actual rendering is a single scrolling column
/// (matching the user's reference screenshot: ~visible rows + scrollbar +
/// highlight — not our earlier 3-column x 6-row grid showing all 18 at
/// once). <see cref="UiMenu.Scrollable"/> switches the popup to this
/// shape; <see cref="UiMenu.RowsPerColumn"/> keeps its existing meaning
/// as the authored visible-row count (still 6 — 108px ListBox height /
/// 18px row height, now interpreted as "rows before scrolling" instead
/// of "rows before wrapping to a new column"). Chat's own popup
/// track <c>0x06004C5F</c>). The later named-retail trace resolves the
/// apparent fixed-six-row ambiguity: <c>OpenVendor</c> inserts only present
/// categories, <c>UIElement_ListBox::UpdateLayout @0x0046e460</c> sums their
/// row heights, <c>ResizeScrollableArea</c> broadcasts message <c>0x32</c>,
/// and <c>UIElement_Menu::RecalculatePopupSize @0x0046caf0</c> resizes this
/// four-edge-docked popup to that content, uncapped. The sibling scrollbar's
/// installed-DAT property <c>0x79=true</c> hides it when the resized content
/// fits. <see cref="UiMenu.Scrollable"/> keeps the authored single-column
/// structure while <see cref="UiMenu.PopupSizeToContent"/> and
/// <see cref="UiMenu.PopupScrollbarHideWhenDisabled"/> port those two retail
/// behaviors. Chat's own popup
/// (LayoutDesc <c>0x21000006</c>, element <c>0x1000001C</c>) has NO
/// sibling scrollbar element and is unaffected —
/// <see cref="ChatWindowController"/> never sets <c>Scrollable</c>, so
@ -572,6 +574,8 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
// scrollbar, not a column-major grid — see the class doc's "G5
// correction" paragraph above.
_typeMenu.Scrollable = true;
_typeMenu.PopupSizeToContent = true;
_typeMenu.PopupScrollbarHideWhenDisabled = true;
_typeMenu.ScrollbarWidth = TypeMenuScrollbarWidth;
_typeMenu.ScrollButtonExtent = TypeMenuScrollButtonExtent;
_typeMenu.ScrollTrackSprite = TypeMenuScrollTrackSprite;

View file

@ -321,7 +321,8 @@ public sealed record SocialRuntimeBindings(
/// <summary>
/// Batch C (overnight hover/UI round, 2026-08-17): bindings for the
/// two-tab Map/House panel. <see cref="HousePosition"/> defaults to
/// "no house" and <see cref="HouseLines"/> to empty when the caller doesn't
/// "no house" and <see cref="HouseLines"/>/<see cref="HousePanelLines"/>
/// to empty when the caller doesn't
/// wire the House wire groundwork — the panel still mounts and the Map tab
/// still works standalone.
/// </summary>
@ -330,7 +331,8 @@ public sealed record MapHouseRuntimeBindings(
Func<uint> PlayerCellId,
Func<CreateObject.ServerPosition?>? HousePosition = null,
Func<IReadOnlyList<string>>? HouseLines = null,
Action? HouseShown = null);
Action? HouseShown = null,
Func<IReadOnlyList<AcDream.Runtime.Gameplay.HousePanelLine>>? HousePanelLines = null);
/// <summary>
/// Campaign QT slice QT5: what the Journal panel's Contracts page reads —
@ -559,6 +561,7 @@ public sealed class RetailUiRuntime : IDisposable
private ProjectileDebugOverlayController? _projectileDebugOverlay;
private Layout.VitalsSideBySideController? _vitalsSideBySide;
private CharacterManagementUiMountCoordinator? _characterManagementMount;
private CreditsUiController? _creditsController;
private CharacterCreationUiMountCoordinator? _characterCreationMount;
private PluginSidePanel? _pluginSidePanel;
private IDisposable? _characterSheetSubscription;
@ -780,6 +783,7 @@ public sealed class RetailUiRuntime : IDisposable
public MapHousePanelController? MapHousePanelController { get; private set; }
internal CharacterManagementUiController? CharacterManagementController =>
_characterManagementMount?.Controller;
internal CreditsUiController? CreditsController => _creditsController;
internal CharacterCreationUiController? CharacterCreationController =>
_characterCreationMount?.Controller;
@ -964,6 +968,7 @@ public sealed class RetailUiRuntime : IDisposable
_itemCooldownController?.Tick();
_characterManagementMount?.Tick();
CharacterManagementController?.Tick();
_creditsController?.Tick();
_characterCreationMount?.Tick();
CharacterCreationController?.Tick();
DialogFactory?.Tick();
@ -1359,6 +1364,7 @@ public sealed class RetailUiRuntime : IDisposable
{
try
{
_creditsController?.ResetSession();
CharacterManagementController?.ResetSession();
DialogFactory?.Reset();
TooltipPresenter?.HideCurrent();
@ -4173,7 +4179,8 @@ public sealed class RetailUiRuntime : IDisposable
// Same generic template resolver the Map tab's town
// hotspots use — see HousePageController.Bindings.
// TemplateResolver's own doc for why reusing it is correct.
TemplateResolver: ResolveHotspotTemplate));
TemplateResolver: ResolveHotspotTemplate,
PanelLines: mh.HousePanelLines));
Layout.MapHousePanelController? controller;
lock (_bindings.Assets.DatLock)
@ -5099,7 +5106,194 @@ public sealed class RetailUiRuntime : IDisposable
Host.Root,
bindings with { RequestCreate = () => CharacterCreationController?.Open() },
EnsureDialogFactory,
LoadCharacterManagementResources);
LoadCharacterManagementResources,
OpenCredits);
}
private void OpenCredits()
{
if (_disposed)
return;
CharacterManagementUiController? characters =
CharacterManagementController;
if (characters is null)
return;
if (_creditsController is null)
{
RetailDialogFactory? dialogs = EnsureDialogFactory();
CreditsUiResources? resources = LoadCreditsResources();
if (dialogs is null || resources is null)
return;
_creditsController = CreditsUiController.CreateDetached(
Host.Root,
resources,
dialogs,
static () => System.Diagnostics.Stopwatch.GetTimestamp()
/ (double)System.Diagnostics.Stopwatch.Frequency,
_bindings.Assets.ResolveSprite,
ReturnFromCredits);
if (_creditsController is null)
return;
}
characters.SetPresentationSuppressed(true);
try
{
_creditsController.Activate();
}
catch (Exception error)
{
characters.SetPresentationSuppressed(false);
Console.WriteLine(
$"[UI] credits activation failed: {error.Message}");
}
}
private void ReturnFromCredits()
=> CharacterManagementController?.SetPresentationSuppressed(false);
private CreditsUiResources? LoadCreditsResources()
{
uint layoutId;
ElementInfo? pictureInfo;
ElementInfo? textInfo;
ImportedLayout? pictureLayout;
ImportedLayout? textLayout;
var strings = new DatStringResolver(_bindings.Assets.Dats);
lock (_bindings.Assets.DatLock)
{
layoutId = RetailDataIdResolver.Resolve(
_bindings.Assets.Dats,
CreditsUiController.RootEnum,
5u);
pictureInfo = layoutId == 0u
? null
: LayoutImporter.ImportInfos(
_bindings.Assets.Dats,
layoutId,
CreditsUiController.PictureRootElementId);
textInfo = layoutId == 0u
? null
: LayoutImporter.ImportInfos(
_bindings.Assets.Dats,
layoutId,
CreditsUiController.TextRootElementId);
pictureLayout = layoutId == 0u
? null
: LayoutImporter.Import(
_bindings.Assets.Dats,
layoutId,
CreditsUiController.PictureRootElementId,
_bindings.Assets.ResolveSprite,
_bindings.Assets.DefaultFont,
_bindings.Assets.ResolveFont);
textLayout = layoutId == 0u
? null
: LayoutImporter.Import(
_bindings.Assets.Dats,
layoutId,
CreditsUiController.TextRootElementId,
_bindings.Assets.ResolveSprite,
_bindings.Assets.DefaultFont,
_bindings.Assets.ResolveFont);
}
if (pictureInfo is null
|| textInfo is null
|| pictureLayout is null
|| textLayout is null
|| !TryGetCreditsDataId(
textInfo,
0x10000002u,
out uint textAreaId)
|| textAreaId != CreditsUiController.TextAreaElementId
|| !TryGetCreditsDataId(
textInfo,
0x10000003u,
out uint stringTableId)
|| !textInfo.TryGetEffectiveFloat(
0x10000004u,
out float sectionSeconds)
|| !pictureInfo.TryGetEffectiveProperty(
0x10000005u,
out UiPropertyValue pictureProperty)
|| pictureProperty.Kind != UiPropertyKind.Array)
{
Console.WriteLine(
"[UI] credits: enum-table-5 properties could not be imported.");
return null;
}
uint[] pictureIds =
[
.. pictureProperty.ArrayValue
.Where(static value => value.Kind is
UiPropertyKind.DataId or UiPropertyKind.Enum)
.Select(static value => checked((uint)value.UnsignedValue))
.Where(static value => value != 0u),
];
if (pictureIds.Length == 0)
return null;
var textFragments = new List<string>();
string? pleaseWait;
lock (_bindings.Assets.DatLock)
{
for (int index = 1; index <= 4096; index++)
{
string? fragment = strings.Resolve(
stringTableId,
DatStringResolver.ComputeHash($"ID_Credits{index}"));
if (fragment is null)
break;
textFragments.Add(fragment);
}
// MakePleaseWaitDialog @0x004E76F0 resolves table enum
// 0x10000001, the installed EoR table DID 0x23000001.
pleaseWait = strings.Resolve(
0x23000001u,
DatStringResolver.ComputeHash("ID_Wait_PleaseWait"));
}
if (textFragments.Count == 0 || pleaseWait is null)
{
Console.WriteLine(
"[UI] credits: localized credit/wait strings are unavailable.");
return null;
}
Console.WriteLine(
$"[UI] retail credits ready (layout 0x{layoutId:X8}, "
+ $"{textFragments.Count} text fragments, {pictureIds.Length} pictures). ");
return new CreditsUiResources(
layoutId,
pictureLayout,
textLayout,
textFragments,
pictureIds,
sectionSeconds,
pleaseWait);
}
private static bool TryGetCreditsDataId(
ElementInfo info,
uint propertyId,
out uint value)
{
if (info.TryGetEffectiveProperty(propertyId, out UiPropertyValue property)
&& property.Kind is UiPropertyKind.DataId or UiPropertyKind.Enum
&& property.UnsignedValue <= uint.MaxValue)
{
value = (uint)property.UnsignedValue;
return true;
}
value = 0u;
return false;
}
private RetailDialogFactory? EnsureDialogFactory()
@ -5392,6 +5586,7 @@ public sealed class RetailUiRuntime : IDisposable
() => _itemConfirmationController?.Dispose(),
() =>
{
_creditsController?.Dispose();
_characterManagementMount?.Dispose();
_characterCreationMount?.Dispose();
_gameplayConfirmationController?.Dispose();

View file

@ -385,11 +385,13 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
if (ToggleBehavior && stateId is UiButtonStateMachine.Normal or UiButtonStateMachine.Highlight)
{
Selected = stateId == UiButtonStateMachine.Highlight;
ApplyStateVisibility(stateId);
return true;
}
if (stateId == UiButtonStateMachine.Ghosted)
{
Enabled = false;
ApplyStateVisibility(stateId);
return true;
}
if (!Enabled && stateId != UiButtonStateMachine.Ghosted)
@ -424,12 +426,14 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
if (!HasStateMedia(""))
return false;
ActiveState = "";
ApplyStateVisibility(stateId);
CascadeStateToChildren(stateId);
return true;
}
if (TryFindState(stateId, out var state))
{
ActiveState = state.Name;
ApplyStateVisibility(stateId);
CascadeStateToChildren(stateId);
return true;
}
@ -439,12 +443,28 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
if (!string.IsNullOrEmpty(stateName) && HasStateMedia(stateName))
{
ActiveState = stateName;
ApplyStateVisibility(stateId);
CascadeStateToChildren(stateId);
return true;
}
return false;
}
/// <summary>
/// #408: UIElement::SetState applies the committed descriptor's properties
/// through UIElement::OnSetAttribute for buttons too. Property 0x3B is the
/// shared Invisible switch; it is not a generic-container-only behavior.
/// </summary>
private void ApplyStateVisibility(uint stateId)
{
if (_info.States.TryGetValue(stateId, out var state)
&& state.Properties.Values.TryGetValue(0x3Bu, out var invisible)
&& invisible.Kind == UiPropertyKind.Bool)
{
Visible = !invisible.BoolValue;
}
}
/// <param name="info">Merged <see cref="ElementInfo"/> for this element.</param>
/// <param name="resolve">Dat file-id → (GL texture handle, native px width, native px height).
/// Returns (0,0,0) when the texture is not yet uploaded.</param>

View file

@ -59,14 +59,11 @@ public abstract class UiElement
/// <summary>
/// GF-13 (Campaign CC gate round 1, Batch A): mirrors
/// <c>ElementInfo.Invisible</c> (dat property <c>0x3B</c>) — a PURE DATA
/// PASSTHROUGH set by <c>LayoutImporter.BuildWidget</c> at construction.
/// The shared importer does NOT act on this flag (1,083 elements author
/// it client-wide, docs/ISSUES.md #408); it exists only so a screen that
/// owns its own mounted subtree can honor it explicitly, the way
/// <c>CharacterCreationUiController</c> does for the chargen screen
/// (register AP-230). Reading this never changes <see cref="Visible"/> by
/// itself.
/// <c>ElementInfo.Invisible</c> (dat property <c>0x3B</c>), retained for
/// diagnostics after <c>LayoutImporter.BuildWidget</c> applies retail's
/// construction-time <c>SetVisible(value == 0)</c> behavior client-wide.
/// Runtime controllers may subsequently call <see cref="Visible"/> just
/// as retail may issue a later <c>SetVisible</c>.
/// </summary>
public bool AuthoredInvisible { get; internal set; }

View file

@ -85,21 +85,20 @@ public sealed class UiMenu : UiElement
public float ColumnWidth { get; set; } = 191f; // dat item template W=191
/// <summary>
/// G5 (vendor gate finding): retail's authored vendor category popup
/// Retail's authored vendor category popup
/// (LayoutDesc <c>0x21000043</c>, root <c>0x1000034F</c>) pairs its
/// ListBox (element <c>0x10000350</c>, type <c>0x5</c>) with a SIBLING
/// <c>UIElement_Scrollbar</c> (element <c>0x10000351</c>, type <c>0xB</c>,
/// 16px wide, docked immediately right of the list at x=100) — verified
/// via a live-dat scan (<c>tools/VendorLayoutScan</c>) against
/// <c>client_local_English.dat</c>: the ListBox reads a single-column
/// shape (attributes resolving to <c>m_nCols=1</c>/<c>m_nRows=6</c>) and
/// the row template (<c>0x10000352</c>) is 100×18 — a SCROLLABLE single
/// column with 6 visible rows, not our earlier column-major grid
/// approximation (which showed all 18 categories at once across 3
/// columns, never matching the retail screenshot's ~one-column-with-
/// scrollbar look). <see cref="RowsPerColumn"/> becomes the VISIBLE ROW
/// COUNT in this mode (still authored-driven — 108px ListBox height / 18px
/// row height = 6). Chat's own popup (LayoutDesc <c>0x21000006</c>) has
/// via installed-DAT inspection against <c>client_local_English.dat</c>.
/// The authored 100x108 ListBox starts with a six-row viewport, but retail
/// <c>UIElement_ListBox::UpdateLayout @0x0046e460</c> resizes its scrollable
/// content to the number of inserted categories. Because all four edges are
/// docked, message <c>0x32</c> reaches
/// <c>UIElement_Menu::RecalculatePopupSize @0x0046caf0</c> and grows or
/// shrinks the popup to that content. The sibling scrollbar authors property
/// <c>0x79=true</c>, so it disappears once the resized viewport fits the
/// content. Chat's own popup (LayoutDesc <c>0x21000006</c>) has
/// NO sibling scrollbar element and keeps the class default false — the
/// legacy column-major grid path below is untouched for it.
/// </summary>
@ -140,6 +139,24 @@ public sealed class UiMenu : UiElement
public uint ScrollUpSprite { get; set; }
public uint ScrollDownSprite { get; set; }
/// <summary>
/// Retail scrollbar property <c>0x79</c> for the popup's authored sibling
/// scrollbar. When the content fits and the scrollbar is therefore disabled,
/// hide the sibling completely: no chrome, pointer target, or reserved width.
/// Vendor popup element <c>0x10000351</c> authors this true. Retail owns a real
/// sibling widget and calls <c>SetVisible(false)</c> from
/// <c>UIElement_Scrollbar::UpdateLayout @0x004710d0</c>; this procedural popup
/// must also remove the sibling's width from its flattened geometry to produce
/// the same visible result.
/// </summary>
public bool PopupScrollbarHideWhenDisabled { get; set; }
/// <summary>Presentation projection shared by drawing and pointer dispatch.
/// Internal for the same focused-test purpose as
/// <see cref="UiScrollbar.IsPresentationVisible"/>.</summary>
internal bool IsPopupScrollbarPresentationVisible
=> !PopupScrollbarHideWhenDisabled || PopupContentOverflows;
private bool _draggingPopupThumb;
private float _popupThumbDragOffset;
@ -252,9 +269,8 @@ public sealed class UiMenu : UiElement
/// popup ListBox (0x21000043/0x10000358) reads edges L=T=R=B=1
/// (menuprobe3, <c>OptionsPanelLiveMountProbeTests</c>), so
/// <see cref="AcDream.App.UI.Layout.ConfigOptionsPageController"/> sets
/// this true; chat's grid popup and vendor's shipped 6-row window keep
/// the class default false (vendor's authored ListBox is ALSO docked —
/// tracked as its own issue, not silently reworked here).
/// this true. Vendor's ListBox has the same four docked edges and therefore
/// enables it too; chat's grid popup keeps the class default false.
/// When set, <see cref="RowsPerColumn"/> stops being the visible-window
/// height and the popup shows every item with no scroll overflow.
/// </summary>
@ -333,12 +349,14 @@ public sealed class UiMenu : UiElement
// Interior = the row content; Outer = interior + the 8-piece bevel ring.
// Scrollable: always exactly one column (RowsPerColumn is the VISIBLE window,
// not a wrap threshold), widened by the docked scrollbar's own authored width.
// not a wrap threshold), widened by the docked scrollbar only while that
// sibling is presentation-visible. Retail property 0x79 removes a disabled
// scrollbar completely; keeping its width caused #386's empty placeholder.
private int ColumnCount => Scrollable
? 1
: (Items.Count + RowsPerColumn - 1) / System.Math.Max(1, RowsPerColumn);
private float InteriorW => Scrollable
? ColumnWidth + ScrollbarWidth
? ColumnWidth + EffectiveScrollbarWidth
: ColumnCount * ColumnWidth;
/// <summary>The popup's visible row count. Size-to-content (retail's
@ -352,6 +370,15 @@ public sealed class UiMenu : UiElement
? System.Math.Max(1, Items.Count)
: RowsPerColumn;
/// <summary>UiMenu rows have a fixed authored height, so this is the same
/// overflow decision <see cref="ConfigurePopupScroll"/> publishes to
/// <see cref="PopupScroll"/>, but is stable before the first draw/event has
/// configured that model.</summary>
private bool PopupContentOverflows => Items.Count > EffectiveVisibleRows;
private float EffectiveScrollbarWidth
=> IsPopupScrollbarPresentationVisible ? ScrollbarWidth : 0f;
private float InteriorH => EffectiveVisibleRows * RowHeight;
private float OuterW => InteriorW + 2 * Border;
private float OuterH => InteriorH + 2 * Border;
@ -362,6 +389,11 @@ public sealed class UiMenu : UiElement
/// a full render pass.</summary>
public float PopupOuterHeight => OuterH;
/// <summary>The popup's outer (bevel-inclusive) width. Exposed alongside
/// <see cref="PopupOuterHeight"/> so the retail hide-disabled scrollbar rule
/// can be pinned without a GPU render harness.</summary>
public float PopupOuterWidth => OuterW;
/// <summary>
/// G7 (vendor gate finding, item 2 — popup direction): port of retail
/// <c>UIElement_Menu::Open</c> (pc:120210-120252, <c>0x0046cc30</c>)'s Y placement:
@ -558,10 +590,11 @@ public sealed class UiMenu : UiElement
/// <summary>
/// G5: single-column popup with a docked scrollbar — port of the vendor category
/// dropdown's authored shape (LayoutDesc <c>0x21000043</c>, see <see cref="Scrollable"/>'s
/// doc comment). Draws exactly <see cref="RowsPerColumn"/> rows (the authored visible
/// window), sliced from <see cref="Items"/> starting at <see cref="VisibleTopRow"/>, plus
/// the scrollbar chrome using the SAME thumb geometry <see cref="UiScrollbar"/> itself
/// uses (<see cref="UiScrollbar.ThumbRect"/>).
/// doc comment). Draws <see cref="EffectiveVisibleRows"/> rows, sliced from
/// <see cref="Items"/> starting at <see cref="VisibleTopRow"/>, plus the scrollbar
/// chrome when its authored disabled-presentation rule allows it. Thumb geometry
/// is the same helper <see cref="UiScrollbar"/> itself uses
/// (<see cref="UiScrollbar.ThumbRect"/>).
/// </summary>
private void DrawScrollablePopup(UiRenderContext ctx, Func<uint, (uint tex, int w, int h)> resolve)
{
@ -602,9 +635,9 @@ public sealed class UiMenu : UiElement
{
int lineHeight = System.Math.Max(1, (int)MathF.Round(RowHeight));
PopupScroll.LineHeight = lineHeight;
// Size-to-content: view == content, so HasOverflow is false and the
// scrollbar draws its chrome with no thumb (retail's authored
// scrollbar sibling stretches with the docked popup the same way).
// Size-to-content: view == content, so HasOverflow is false. Whether
// the disabled scrollbar remains visible is its authored 0x79 property,
// projected by IsPopupScrollbarPresentationVisible.
PopupScroll.SetExtents(Items.Count * lineHeight, EffectiveVisibleRows * lineHeight);
}
@ -625,6 +658,8 @@ public sealed class UiMenu : UiElement
private void DrawPopupScrollbar(
UiRenderContext ctx, Func<uint, (uint tex, int w, int h)> resolve, float x, float y)
{
if (!IsPopupScrollbarPresentationVisible) return;
DrawSprite(ctx, resolve, ScrollTrackSprite, x, y, ScrollbarWidth, InteriorH);
float decExtent = System.Math.Clamp(ScrollButtonExtent, 0f, InteriorH);
@ -801,7 +836,9 @@ public sealed class UiMenu : UiElement
}
float scrollbarX = ColumnWidth;
if (ix >= scrollbarX && ix < scrollbarX + ScrollbarWidth && iy >= 0 && iy < InteriorH)
if (IsPopupScrollbarPresentationVisible
&& ix >= scrollbarX && ix < scrollbarX + ScrollbarWidth
&& iy >= 0 && iy < InteriorH)
{
ConfigurePopupScroll();
float decExtent = System.Math.Clamp(ScrollButtonExtent, 0f, InteriorH);

View file

@ -485,15 +485,13 @@ public sealed class UiText : UiElement, IUiDatStateful
// Per-state Invisible (dat property 0x3B): retail's SetState applies the
// committed state's properties through UIElement::OnSetAttribute, whose
// case 8 (@0x00462DAE, property id 0x33 + 8 = 0x3B) is
// `SetVisible(value == 0)`. Same NAMED-states-only scoping as
// UiDatElement.TrySetRetailState (a DirectState 0x3B is the
// construction-time "authored invisible" class — #408, separately
// gated). First consumer here: the vitals cur/max number labels
// `SetVisible(value == 0)`. #408 includes DirectState: returning to
// state 0 must restore its authored visibility after a named state
// changed it. First consumer here: the vitals cur/max number labels
// (0x100000EB/ED/EF) author HideDetail={0x3B:false} /
// ShowDetail={0x3B:true} — the numbers hide when the click toggle
// switches the window to the graphical icon mode.
if (stateId != UiStateInfo.DirectStateId
&& state is not null
if (state is not null
&& state.Properties.Values.TryGetValue(0x3Bu, out var invisibleProp)
&& invisibleProp.Kind == UiPropertyKind.Bool)
Visible = !invisibleProp.BoolValue;

View file

@ -2,6 +2,7 @@ using AcDream.App.UI;
using AcDream.Core.Items;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Selection;
namespace AcDream.App.World;
@ -26,6 +27,7 @@ internal sealed class InventoryWorldDropProjectionController : IDisposable
private readonly ClientObjectTable _objects;
private readonly LiveEntityRuntime _runtime;
private readonly LiveEntityHydrationController _hydration;
private readonly SelectionState _selection;
private readonly PendingSplitToWorldProjection _pending;
private readonly Func<double> _now;
private bool _disposed;
@ -35,6 +37,7 @@ internal sealed class InventoryWorldDropProjectionController : IDisposable
ClientObjectTable objects,
LiveEntityRuntime runtime,
LiveEntityHydrationController hydration,
SelectionState selection,
Func<double> now)
{
_interaction = interaction
@ -43,6 +46,7 @@ internal sealed class InventoryWorldDropProjectionController : IDisposable
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
_hydration = hydration
?? throw new ArgumentNullException(nameof(hydration));
_selection = selection ?? throw new ArgumentNullException(nameof(selection));
_now = now ?? throw new ArgumentNullException(nameof(now));
_pending = new PendingSplitToWorldProjection();
@ -64,7 +68,15 @@ internal sealed class InventoryWorldDropProjectionController : IDisposable
// Consume before the synchronous CreateObject graph runs. Re-entrant
// callbacks cannot bind the same split intent to a second GUID.
_hydration.OnCreate(spawn);
return _runtime.TryGetSnapshot(update.Guid, out _);
bool recovered = _runtime.TryGetSnapshot(update.Guid, out _);
if (recovered)
{
// ACCWeenieObject::DeclareValid @0x0058E481 transfers the one
// global selection to the recognized split result. This is an
// automatic system transition, not a synthetic inventory click.
_selection.Select(update.Guid, SelectionChangeSource.System);
}
return recovered;
}
public void Dispose()

View file

@ -1222,7 +1222,10 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
}
if (record.FullCellId != token.ExactCellId
|| record.Canonical.PlacementCommitVersion
!= token.PlacementCommitVersion)
!= token.PlacementCommitVersion
|| record.Canonical.PhysicsBody is not { } body
|| body.Position != projection.WorldPosition
|| body.Orientation != projection.Orientation)
{
// A newer move superseded this receipt's facts after the drain.
//
@ -1264,13 +1267,13 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
// That is a behaviour change in the wrong direction, not a
// restoration.
//
// The ONE supersession neither term covers is
// RuntimeRemotePlacementDriveController.StoreAcceptedDestinationPose:
// the far-snap Refused/Contention arm writes `body.Position` and
// `body.Orientation` with no placement commit and no cell move, so
// it can stale this receipt's pose silently. That is pre-existing
// (it predates C5b, which changed nothing about that arm) and is
// filed as docs/ISSUES.md #323 rather than papered over here.
// #323: compare the retained body's exact pose too. The receipt
// copied these floats directly from that body, so exact equality
// is the correct proof that its pose is still current. This
// catches StoreAcceptedDestinationPose's legitimate pose-only
// fallback without abusing PositionAuthorityVersion (which can
// advance while the receipt facts remain unchanged) or redefining
// PlacementCommitVersion for a store that committed no cell.
return true;
}

View file

@ -60,6 +60,41 @@ public static class ClientCommandRequests
public const uint AddPlayerPermissionOpcode = 0x0219u;
public const uint RemovePlayerPermissionOpcode = 0x021Au;
public const uint AbandonHouseOpcode = 0x021Fu;
public const uint QueryAllegianceNameOpcode = 0x0030u;
public const uint ClearAllegianceNameOpcode = 0x0031u;
public const uint SetAllegianceNameOpcode = 0x0033u;
public const uint SetAllegianceOfficerOpcode = 0x003Bu;
public const uint SetAllegianceOfficerTitleOpcode = 0x003Cu;
public const uint ListAllegianceOfficerTitlesOpcode = 0x003Du;
public const uint ClearAllegianceOfficerTitlesOpcode = 0x003Eu;
public const uint DoAllegianceLockActionOpcode = 0x003Fu;
public const uint SetAllegianceApprovedVassalOpcode = 0x0040u;
public const uint AllegianceChatGagOpcode = 0x0041u;
public const uint DoAllegianceHouseActionOpcode = 0x0042u;
public const uint AddPermanentGuestOpcode = 0x0245u;
public const uint RemovePermanentGuestOpcode = 0x0246u;
public const uint SetOpenHouseStatusOpcode = 0x0247u;
public const uint ChangeStoragePermissionOpcode = 0x0249u;
public const uint BootSpecificHouseGuestOpcode = 0x024Au;
public const uint RemoveAllStoragePermissionOpcode = 0x024Cu;
public const uint RequestFullGuestListOpcode = 0x024Du;
public const uint SetMotdOpcode = 0x0254u;
public const uint QueryMotdOpcode = 0x0255u;
public const uint ClearMotdOpcode = 0x0256u;
public const uint AddAllStoragePermissionOpcode = 0x025Cu;
public const uint RemoveAllPermanentGuestsOpcode = 0x025Eu;
public const uint BootEveryoneOpcode = 0x025Fu;
public const uint SetHooksVisibilityOpcode = 0x0266u;
public const uint ModifyAllegianceGuestPermissionOpcode = 0x0267u;
public const uint ModifyAllegianceStoragePermissionOpcode = 0x0268u;
public const uint BreakAllegianceBootOpcode = 0x0277u;
public const uint AllegianceChatBootOpcode = 0x02A0u;
public const uint AddAllegianceBanOpcode = 0x02A1u;
public const uint RemoveAllegianceBanOpcode = 0x02A2u;
public const uint ListAllegianceBansOpcode = 0x02A3u;
public const uint RemoveAllegianceOfficerOpcode = 0x02A5u;
public const uint ListAllegianceOfficersOpcode = 0x02A6u;
public const uint ClearAllegianceOfficersOpcode = 0x02A7u;
// Batch C (Map/House toolbar panel, 2026-08-17): the query the House
// tab needs to populate. ACE GameActionHouseQuery.cs: [GameAction(
// GameActionType.HouseQuery)] (0x021E), Handle reads no payload and
@ -283,8 +318,9 @@ public static class ClientCommandRequests
public static byte[] BuildRecallAllegianceHometown(uint sequence) =>
BuildParameterless(sequence, RecallAllegianceHometownOpcode);
// "@allegiance info [name]" — GameActionAllegianceInfoRequest.Handle:
// ReadString16L() player name (empty string = self).
// "@allegiance info <name>" — GameActionAllegianceInfoRequest.Handle:
// ReadString16L() player name. Retail refuses an empty argument before
// constructing this request.
public static byte[] BuildAllegianceInfoRequest(uint sequence, string playerName) =>
BuildString(sequence, AllegianceInfoRequestOpcode, playerName);
@ -308,6 +344,133 @@ public static class ClientCommandRequests
public static byte[] BuildAbandonHouse(uint sequence) =>
BuildParameterless(sequence, AbandonHouseOpcode);
// Complete named-retail @allegiance/@house administration family.
// Opcodes and field order are cross-checked against ACE's individual
// GameAction readers; the client-side grammar is ported separately by
// ClientCommandController from DoAllegiance/DoHouse and their helpers.
public static byte[] BuildBreakAllegianceBoot(
uint sequence, string playerName, bool accountBoot) =>
BuildStringUInt32(
sequence, BreakAllegianceBootOpcode, playerName, accountBoot ? 1u : 0u);
public static byte[] BuildAllegianceChatBoot(
uint sequence, string playerName, string reason) =>
BuildTwoStrings(sequence, AllegianceChatBootOpcode, playerName, reason);
public static byte[] BuildAllegianceChatGag(
uint sequence, string playerName, bool enabled) =>
BuildStringUInt32(
sequence, AllegianceChatGagOpcode, playerName, enabled ? 1u : 0u);
public static byte[] BuildAddAllegianceBan(uint sequence, string playerName) =>
BuildString(sequence, AddAllegianceBanOpcode, playerName);
public static byte[] BuildRemoveAllegianceBan(uint sequence, string playerName) =>
BuildString(sequence, RemoveAllegianceBanOpcode, playerName);
public static byte[] BuildListAllegianceBans(uint sequence) =>
BuildParameterless(sequence, ListAllegianceBansOpcode);
public static byte[] BuildSetAllegianceOfficer(
uint sequence, string playerName, uint officerLevel) =>
BuildStringUInt32(
sequence, SetAllegianceOfficerOpcode, playerName, officerLevel);
public static byte[] BuildRemoveAllegianceOfficer(
uint sequence, string playerName) =>
BuildString(sequence, RemoveAllegianceOfficerOpcode, playerName);
public static byte[] BuildListAllegianceOfficers(uint sequence) =>
BuildParameterless(sequence, ListAllegianceOfficersOpcode);
public static byte[] BuildClearAllegianceOfficers(uint sequence) =>
BuildParameterless(sequence, ClearAllegianceOfficersOpcode);
public static byte[] BuildSetAllegianceOfficerTitle(
uint sequence, uint officerLevel, string title) =>
BuildUInt32String(
sequence, SetAllegianceOfficerTitleOpcode, officerLevel, title);
public static byte[] BuildListAllegianceOfficerTitles(uint sequence) =>
BuildParameterless(sequence, ListAllegianceOfficerTitlesOpcode);
public static byte[] BuildClearAllegianceOfficerTitles(uint sequence) =>
BuildParameterless(sequence, ClearAllegianceOfficerTitlesOpcode);
public static byte[] BuildQueryAllegianceName(uint sequence) =>
BuildParameterless(sequence, QueryAllegianceNameOpcode);
public static byte[] BuildSetAllegianceName(uint sequence, string name) =>
BuildString(sequence, SetAllegianceNameOpcode, name);
public static byte[] BuildClearAllegianceName(uint sequence) =>
BuildParameterless(sequence, ClearAllegianceNameOpcode);
public static byte[] BuildAllegianceLockAction(uint sequence, uint action) =>
BuildUInt32(sequence, DoAllegianceLockActionOpcode, action);
public static byte[] BuildSetAllegianceApprovedVassal(
uint sequence, string playerName) =>
BuildString(sequence, SetAllegianceApprovedVassalOpcode, playerName);
public static byte[] BuildAllegianceHouseAction(uint sequence, uint action) =>
BuildUInt32(sequence, DoAllegianceHouseActionOpcode, action);
public static byte[] BuildQueryMotd(uint sequence) =>
BuildParameterless(sequence, QueryMotdOpcode);
public static byte[] BuildSetMotd(uint sequence, string motd) =>
BuildString(sequence, SetMotdOpcode, motd);
public static byte[] BuildClearMotd(uint sequence) =>
BuildParameterless(sequence, ClearMotdOpcode);
public static byte[] BuildSetOpenHouseStatus(uint sequence, bool isOpen) =>
BuildUInt32(sequence, SetOpenHouseStatusOpcode, isOpen ? 1u : 0u);
public static byte[] BuildAddPermanentGuest(uint sequence, string playerName) =>
BuildString(sequence, AddPermanentGuestOpcode, playerName);
public static byte[] BuildRemovePermanentGuest(uint sequence, string playerName) =>
BuildString(sequence, RemovePermanentGuestOpcode, playerName);
public static byte[] BuildRemoveAllPermanentGuests(uint sequence) =>
BuildParameterless(sequence, RemoveAllPermanentGuestsOpcode);
public static byte[] BuildChangeStoragePermission(
uint sequence, string playerName, bool enabled) =>
BuildStringUInt32(
sequence, ChangeStoragePermissionOpcode, playerName, enabled ? 1u : 0u);
public static byte[] BuildAddAllStoragePermission(uint sequence) =>
BuildParameterless(sequence, AddAllStoragePermissionOpcode);
public static byte[] BuildRemoveAllStoragePermission(uint sequence) =>
BuildParameterless(sequence, RemoveAllStoragePermissionOpcode);
public static byte[] BuildRequestFullGuestList(uint sequence) =>
BuildParameterless(sequence, RequestFullGuestListOpcode);
public static byte[] BuildBootSpecificHouseGuest(
uint sequence, string playerName) =>
BuildString(sequence, BootSpecificHouseGuestOpcode, playerName);
public static byte[] BuildBootEveryone(uint sequence) =>
BuildParameterless(sequence, BootEveryoneOpcode);
public static byte[] BuildSetHooksVisibility(uint sequence, bool visible) =>
BuildUInt32(sequence, SetHooksVisibilityOpcode, visible ? 1u : 0u);
public static byte[] BuildModifyAllegianceGuestPermission(
uint sequence, bool enabled) =>
BuildUInt32(
sequence, ModifyAllegianceGuestPermissionOpcode, enabled ? 1u : 0u);
public static byte[] BuildModifyAllegianceStoragePermission(
uint sequence, bool enabled) =>
BuildUInt32(
sequence, ModifyAllegianceStoragePermissionOpcode, enabled ? 1u : 0u);
// Queries the local player's house info (owned house data, or a
// no-house status) — GameActionHouseQuery.Handle: no payload read.
public static byte[] BuildHouseQuery(uint sequence) =>
@ -337,6 +500,39 @@ public static class ClientCommandRequests
return body;
}
private static byte[] BuildStringUInt32(
uint sequence, uint opcode, string value, uint number)
{
byte[] packed = PackString16L(value);
byte[] body = CreateBody(sequence, opcode, packed.Length + 4);
packed.CopyTo(body, 12);
BinaryPrimitives.WriteUInt32LittleEndian(
body.AsSpan(12 + packed.Length), number);
return body;
}
private static byte[] BuildUInt32String(
uint sequence, uint opcode, uint number, string value)
{
byte[] packed = PackString16L(value);
byte[] body = CreateBody(sequence, opcode, 4 + packed.Length);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), number);
packed.CopyTo(body, 16);
return body;
}
private static byte[] BuildTwoStrings(
uint sequence, uint opcode, string first, string second)
{
byte[] packedFirst = PackString16L(first);
byte[] packedSecond = PackString16L(second);
byte[] body = CreateBody(
sequence, opcode, packedFirst.Length + packedSecond.Length);
packedFirst.CopyTo(body, 12);
packedSecond.CopyTo(body, 12 + packedFirst.Length);
return body;
}
private static byte[] CreateBody(uint sequence, uint opcode, int payloadLength)
{
byte[] body = new byte[12 + payloadLength];

View file

@ -275,5 +275,6 @@ internal sealed class AckNakScheduler
outboundIsaac: null);
_send(datagram.Slice(0, datagramLength));
_stats.NaksSent++;
_stats.NakIdsSent += count;
}
}

View file

@ -263,6 +263,23 @@ internal sealed class OutboundFlowQueue : IDisposable
{
for (int i = 0; i < _pendingResends.Count; i++)
{
// A later cumulative ACK can overtake an earlier NAK in the
// receive-owner queue before this frame reaches its sweep.
// Such a request is stale: the server has already advanced
// its ordered watermark past the requested packet. Replaying
// the old encrypted key is actively harmful against ACE,
// whose CRC/key search runs before duplicate-sequence
// rejection and can burn the remaining 256-word window.
// The equal case is NOT stale: ids[0] of an ordinary NAK is
// also its implicit ACK and still needs retransmission.
if (AckWatermark != 0
&& SequenceMath.IsNewer(
AckWatermark,
_pendingResends[i]))
{
continue;
}
// A pending id can leave the cache between NAK arrival and
// this sweep only if a newer ack already covered it — the
// server has it; serving nothing is correct.

View file

@ -41,6 +41,9 @@ internal sealed class ReliableTransport : IDisposable
public TransportStats Stats { get; }
/// <summary>Retail's 40-heartbeat packet-loss percentage.</summary>
public double PacketLossPercentage => _packetLoss.Percentage;
/// <summary>
/// N6: retail's ephemeral-info flush cadence
/// (<c>Indicator::FlushTimedOutEphInfo @ 0x0054A3D0</c>, the x87 compare
@ -51,6 +54,7 @@ internal sealed class ReliableTransport : IDisposable
public const double AssemblerSweepSeconds = 5.0;
private readonly FragmentAssembler? _assembler;
private readonly RetailPacketLossAverager _packetLoss;
private readonly long _assemblerSweepTicks;
private long _assemblerSweepTimestamp;
@ -71,13 +75,19 @@ internal sealed class ReliableTransport : IDisposable
_assemblerSweepTicks =
(long)Math.Round(AssemblerSweepSeconds * Clock.Frequency);
_assemblerSweepTimestamp = Clock.GetTimestamp();
void CountedSend(ReadOnlySpan<byte> datagram)
{
send(datagram);
Stats.PacketsSent++;
}
Outbound = new OutboundFlowQueue(
outboundIsaac,
sessionClientId,
sessionIteration,
Clock,
Stats,
send,
CountedSend,
pool);
Inbound = new InboundSequenceTracker(inboundIsaac, Stats);
Scheduler = new AckNakScheduler(
@ -87,8 +97,9 @@ internal sealed class ReliableTransport : IDisposable
sessionClientId,
sessionIteration,
Stats,
send);
CountedSend);
Stats.CacheDepthSource = () => Outbound.CacheDepth;
_packetLoss = new RetailPacketLossAverager(Clock, Stats);
}
/// <summary>Last reliable sequence on the wire — the value unsequenced
@ -112,6 +123,12 @@ internal sealed class ReliableTransport : IDisposable
{
Clock.Update();
long now = Clock.GetTimestamp();
// Retail snapshots the preceding two-second interval inside
// ClientNet::ProcessConnection before this heartbeat emits its
// ACK/NAK control traffic. Keep boundary control packets in the new
// sample rather than attributing them to the interval that just
// ended.
_packetLoss.Sweep(now, Stats);
Scheduler.Sweep(now);
Outbound.TransmitPendingResends();

View file

@ -0,0 +1,124 @@
namespace AcDream.Core.Net.Transport;
/// <summary>
/// Retail's packet-loss telemetry window from
/// <c>CLinkStatusAverages::GetAveragePacketLoss @ 0x00546610</c> and
/// <c>CLinkStatusAverages::AddSnapshot @ 0x00546650</c>.
/// </summary>
/// <remarks>
/// <para>
/// <c>ClientNet::ProcessConnection @ 0x00545450</c> snapshots the four
/// packet counters on its 2.0-second heartbeat. Each counter is a
/// <c>CAverager&lt;unsigned short,40&gt;</c>. The loss function divides the
/// windowed NAK + retransmit totals by received + sent totals and reports a
/// percentage. A zero denominator reports zero.
/// </para>
/// <para>
/// The input values here are cumulative acdream counters; each sample stores
/// their delta since the preceding heartbeat. The unchecked ushort conversion
/// preserves retail's snapshot-field width.
/// </para>
/// </remarks>
internal sealed class RetailPacketLossAverager
{
public const int WindowSize = 40;
public const double SnapshotSeconds = 2.0;
private readonly Sample[] _samples = new Sample[WindowSize];
private readonly long _snapshotTicks;
private long _snapshotTimestamp;
private long _lastPacketsSent;
private long _lastRetransmitsSent;
private long _lastPacketsReceived;
private long _lastNakIdsSent;
private long _sentTotal;
private long _retransmitTotal;
private long _receivedTotal;
private long _nakTotal;
private int _next;
private int _count;
public RetailPacketLossAverager(TransportClock clock, TransportStats stats)
{
ArgumentNullException.ThrowIfNull(clock);
ArgumentNullException.ThrowIfNull(stats);
_snapshotTicks = (long)Math.Round(SnapshotSeconds * clock.Frequency);
_snapshotTimestamp = clock.GetTimestamp();
CaptureBaselines(stats);
}
public double Percentage
{
get
{
long denominator = _receivedTotal + _sentTotal;
return denominator <= 0
? 0d
: 100d * (_nakTotal + _retransmitTotal) / denominator;
}
}
public void Sweep(long now, TransportStats stats)
{
ArgumentNullException.ThrowIfNull(stats);
if (now - _snapshotTimestamp < _snapshotTicks)
return;
// ProcessConnection records one snapshot when the heartbeat branch
// runs; it does not synthesize empty samples for skipped frames.
_snapshotTimestamp = now;
var sample = new Sample(
DeltaAsRetailUShort(stats.PacketsSent, ref _lastPacketsSent),
DeltaAsRetailUShort(stats.ResendsSent, ref _lastRetransmitsSent),
DeltaAsRetailUShort(stats.PacketsReceived, ref _lastPacketsReceived),
DeltaAsRetailUShort(stats.NakIdsSent, ref _lastNakIdsSent));
if (_count == WindowSize)
Remove(_samples[_next]);
else
_count++;
_samples[_next] = sample;
_next = (_next + 1) % WindowSize;
Add(sample);
}
private void CaptureBaselines(TransportStats stats)
{
_lastPacketsSent = stats.PacketsSent;
_lastRetransmitsSent = stats.ResendsSent;
_lastPacketsReceived = stats.PacketsReceived;
_lastNakIdsSent = stats.NakIdsSent;
}
private static ushort DeltaAsRetailUShort(long current, ref long previous)
{
long delta = current - previous;
previous = current;
return unchecked((ushort)Math.Max(0L, delta));
}
private void Add(Sample sample)
{
_sentTotal += sample.Sent;
_retransmitTotal += sample.Retransmitted;
_receivedTotal += sample.Received;
_nakTotal += sample.Naked;
}
private void Remove(Sample sample)
{
_sentTotal -= sample.Sent;
_retransmitTotal -= sample.Retransmitted;
_receivedTotal -= sample.Received;
_nakTotal -= sample.Naked;
}
private readonly record struct Sample(
ushort Sent,
ushort Retransmitted,
ushort Received,
ushort Naked);
}

View file

@ -9,6 +9,15 @@ namespace AcDream.Core.Net.Transport;
/// </summary>
internal sealed class TransportStats
{
/// <summary>Checksum-valid post-negotiation datagrams received. Used by
/// retail's 2-second/40-sample link-status loss window.</summary>
public long PacketsReceived;
/// <summary>Datagrams successfully emitted after transport negotiation.
/// Includes reliable traffic, retransmits, cumulative ACKs, and NAKs,
/// matching retail's packet-level denominator.</summary>
public long PacketsSent;
/// <summary>Datagrams re-emitted in response to a server NAK.</summary>
public long ResendsSent;
@ -57,6 +66,11 @@ internal sealed class TransportStats
/// <c>ReceiverData::GetNaks @ 0x005490C0</c>, ≤114 ids each).</summary>
public long NaksSent;
/// <summary>Total missing sequence ids carried by emitted NAKs. Retail's
/// <c>CLinkStatusSnapshot::nPktsNAKed</c> counts missing packets, not the
/// number of control datagrams used to request them.</summary>
public long NakIdsSent;
/// <summary>N4: mis-parked keystream words reclaimed from validated
/// cleartext <c>RejectRetransmit</c> sequences (the AD-51 ACE
/// adaptation; always zero against a retail server).</summary>

View file

@ -652,7 +652,8 @@ public sealed class WorldSession : IDisposable
Volatile.Read(ref _lastInboundPacketTicks),
Stopwatch.GetTimestamp(),
Stopwatch.Frequency,
PingRoundTripSeconds);
PingRoundTripSeconds,
_transport?.PacketLossPercentage ?? 0d);
internal double? PingRoundTripSeconds
{
@ -1861,6 +1862,8 @@ public sealed class WorldSession : IDisposable
// last-heard clock only for valid server traffic. Record at checksum
// acceptance, before any heavy render-thread message handling.
Volatile.Write(ref _lastInboundPacketTicks, Stopwatch.GetTimestamp());
if (_transport is { } acceptedTransport)
acceptedTransport.Stats.PacketsReceived++;
// N6: the first checksum-valid post-negotiation packet confirms the
// server accepted our ConnectResponse and stops the handshake
@ -2814,13 +2817,234 @@ public sealed class WorldSession : IDisposable
SendGameAction(ClientCommandRequests.BuildRecallAllegianceHometown(seq));
}
/// <summary>Send retail "@allegiance info [name]" (0x027B).</summary>
/// <summary>Send retail "@allegiance info &lt;name&gt;" (0x027B).</summary>
public void SendAllegianceInfoRequest(string playerName)
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildAllegianceInfoRequest(seq, playerName));
}
public void SendBreakAllegianceBoot(string playerName, bool accountBoot)
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildBreakAllegianceBoot(
seq, playerName, accountBoot));
}
public void SendAllegianceChatBoot(string playerName, string reason)
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildAllegianceChatBoot(
seq, playerName, reason));
}
public void SendAllegianceChatGag(string playerName, bool enabled)
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildAllegianceChatGag(
seq, playerName, enabled));
}
public void SendAddAllegianceBan(string playerName)
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildAddAllegianceBan(seq, playerName));
}
public void SendRemoveAllegianceBan(string playerName)
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildRemoveAllegianceBan(seq, playerName));
}
public void SendListAllegianceBans()
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildListAllegianceBans(seq));
}
public void SendSetAllegianceOfficer(string playerName, uint level)
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildSetAllegianceOfficer(
seq, playerName, level));
}
public void SendRemoveAllegianceOfficer(string playerName)
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildRemoveAllegianceOfficer(
seq, playerName));
}
public void SendListAllegianceOfficers()
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildListAllegianceOfficers(seq));
}
public void SendClearAllegianceOfficers()
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildClearAllegianceOfficers(seq));
}
public void SendSetAllegianceOfficerTitle(uint level, string title)
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildSetAllegianceOfficerTitle(
seq, level, title));
}
public void SendListAllegianceOfficerTitles()
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildListAllegianceOfficerTitles(seq));
}
public void SendClearAllegianceOfficerTitles()
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildClearAllegianceOfficerTitles(seq));
}
public void SendQueryAllegianceName()
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildQueryAllegianceName(seq));
}
public void SendSetAllegianceName(string name)
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildSetAllegianceName(seq, name));
}
public void SendClearAllegianceName()
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildClearAllegianceName(seq));
}
public void SendAllegianceLockAction(uint action)
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildAllegianceLockAction(seq, action));
}
public void SendSetAllegianceApprovedVassal(string playerName)
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildSetAllegianceApprovedVassal(
seq, playerName));
}
public void SendAllegianceHouseAction(uint action)
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildAllegianceHouseAction(seq, action));
}
public void SendQueryMotd()
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildQueryMotd(seq));
}
public void SendSetMotd(string motd)
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildSetMotd(seq, motd));
}
public void SendClearMotd()
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildClearMotd(seq));
}
public void SendSetOpenHouseStatus(bool isOpen)
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildSetOpenHouseStatus(seq, isOpen));
}
public void SendAddPermanentGuest(string playerName)
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildAddPermanentGuest(seq, playerName));
}
public void SendRemovePermanentGuest(string playerName)
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildRemovePermanentGuest(seq, playerName));
}
public void SendRemoveAllPermanentGuests()
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildRemoveAllPermanentGuests(seq));
}
public void SendChangeStoragePermission(string playerName, bool enabled)
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildChangeStoragePermission(
seq, playerName, enabled));
}
public void SendAddAllStoragePermission()
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildAddAllStoragePermission(seq));
}
public void SendRemoveAllStoragePermission()
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildRemoveAllStoragePermission(seq));
}
public void SendRequestFullGuestList()
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildRequestFullGuestList(seq));
}
public void SendBootSpecificHouseGuest(string playerName)
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildBootSpecificHouseGuest(
seq, playerName));
}
public void SendBootEveryone()
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildBootEveryone(seq));
}
public void SendSetHooksVisibility(bool visible)
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildSetHooksVisibility(seq, visible));
}
public void SendModifyAllegianceGuestPermission(bool enabled)
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildModifyAllegianceGuestPermission(
seq, enabled));
}
public void SendModifyAllegianceStoragePermission(bool enabled)
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildModifyAllegianceStoragePermission(
seq, enabled));
}
// ── Campaign FA slice FA2 (2026-08-12): fellowship + allegiance
// outbound wrappers. SocialActions/AllegianceRequests ship the byte
// builders (repaired/added in FA1); this is the missing

View file

@ -81,6 +81,7 @@ public sealed class DatSoundCache
private readonly Dictionary<uint, LinkedListNode<WaveEntry>> _waveEntries = new();
private readonly LinkedList<WaveEntry> _waveLru = new();
private readonly long _maxWaveBytes;
private readonly Action<uint>? _afterInitialWaveMiss;
private long _residentWaveBytes;
private long _hits;
private long _misses;
@ -98,11 +99,24 @@ public sealed class DatSoundCache
/// exercised deterministically without allocating tens of megabytes.
/// </summary>
public DatSoundCache(IDatObjectSource dats, long maxWaveBytes)
: this(dats, maxWaveBytes, afterInitialWaveMiss: null)
{
}
/// <summary>
/// Deterministic concurrency seam used to park a caller after its fast
/// resident lookup but before the authoritative locked recheck.
/// </summary>
internal DatSoundCache(
IDatObjectSource dats,
long maxWaveBytes,
Action<uint>? afterInitialWaveMiss)
{
ArgumentNullException.ThrowIfNull(dats);
ArgumentOutOfRangeException.ThrowIfLessThan(maxWaveBytes, 1);
_dats = dats;
_maxWaveBytes = maxWaveBytes;
_afterInitialWaveMiss = afterInitialWaveMiss;
}
/// <summary>
@ -128,14 +142,37 @@ public sealed class DatSoundCache
return null;
}
Interlocked.Increment(ref _misses);
_afterInitialWaveMiss?.Invoke(waveId);
Lazy<WaveData?> lazy = _inflight.GetOrAdd(
waveId,
static (id, self) => new Lazy<WaveData?>(
() => self.DecodeUncached(id),
LazyThreadSafetyMode.ExecutionAndPublication),
this);
Lazy<WaveData?> lazy;
lock (_gate)
{
// A caller can pause after the fast miss while another caller
// decodes, admits, and removes the in-flight Lazy. Recheck the
// authoritative caches and acquire/create the Lazy under the same
// gate used by admission so that stale caller cannot start a
// second decode after publication.
if (_waveEntries.TryGetValue(waveId, out var node))
{
Interlocked.Increment(ref _hits);
Touch(node);
return node.Value.Wave;
}
if (_negativeWaveIds.ContainsKey(waveId))
{
Interlocked.Increment(ref _hits);
return null;
}
Interlocked.Increment(ref _misses);
lazy = _inflight.GetOrAdd(
waveId,
static (id, self) => new Lazy<WaveData?>(
() => self.DecodeUncached(id),
LazyThreadSafetyMode.ExecutionAndPublication),
this);
}
try
{
WaveData? decoded = lazy.Value;

View file

@ -179,14 +179,28 @@ public sealed class ChatLog
/// <summary>PlayerKilled (0x019E) — death announcement.</summary>
/// <remarks>
/// Death messages are routed as <see cref="ChatKind.System"/> so they
/// share styling with other server announcements. The
/// Retail <c>ClientCombatSystem::HandlePlayerDeathEvent @0x0056C320</c>
/// suppresses this bystander-facing line when the local player is either
/// the victim or killer; those participants receive their dedicated
/// 0x01AC/0x01AD notification instead. Death messages that survive that
/// gate are routed as <see cref="ChatKind.System"/> so they share styling
/// with other server announcements. The
/// <c>SenderGuid</c> field carries the victim guid; the
/// <c>ChannelId</c> field carries the killer guid (a small misuse
/// of the field but avoids a schema change).
/// </remarks>
public void OnPlayerKilled(string deathMessage, uint victimGuid, uint killerGuid)
public void OnPlayerKilled(
string deathMessage,
uint victimGuid,
uint killerGuid,
uint localPlayerGuid = 0u)
{
if (localPlayerGuid != 0u
&& (localPlayerGuid == victimGuid || localPlayerGuid == killerGuid))
{
return;
}
Append(new ChatEntry(
Kind: ChatKind.System,
Sender: "",

View file

@ -192,11 +192,12 @@ public sealed class PhysicsTimestampGate
_timestamps[ForcePosition] = forcePosition;
// SmartBox::HandleReceivedPosition 0x00453FD0: a fresh local
// FORCE_POSITION whose teleport is exactly equal blips immediately,
// assigns POSITION_TS directly (even equal/older), preserves the
// current heading, sends a position event, and returns WITHOUT
// advancing TELEPORT_TS.
if (teleport == _timestamps[Teleport])
// FORCE_POSITION whose teleport stamp is not older (equal OR
// newer) blips immediately, assigns POSITION_TS directly (even
// equal/older), preserves the current heading, sends a position
// event, and returns WITHOUT advancing TELEPORT_TS. The same
// wrap-safe predicate is used by IsFreshTeleportStart above.
if (!IsNewer(teleport, _timestamps[Teleport]))
{
_timestamps[Position] = position;
return PositionTimestampDisposition.ForcePosition;

View file

@ -16,26 +16,13 @@ namespace AcDream.Core.Physics;
/// transitional blending.
///
/// <para>
/// This exact advance-with-wrap-then-lerp/slerp algorithm already exists as
/// an inline, App-layer-only implementation for the "legacy" (no
/// <see cref="AnimationSequencer"/>) NPC idle-cycle path —
/// <c>LiveEntityAnimationPresenter.Present</c>'s non-sequencer branch
/// (<c>CurrFrame += legacyAdvanceSeconds * Framerate</c> with the same
/// modulo wrap) and its private <c>TryResolvePartFrame</c> helper (the same
/// frame-bracket lerp/slerp). That call site has a live entity, a
/// This is the shared implementation used by both the "legacy" (no
/// <see cref="AnimationSequencer"/>) NPC idle-cycle path and the chargen
/// preview. The live presenter has a live entity, a
/// <c>LiveEntityRuntime</c> membership, and per-tick elapsed time supplied by
/// the render loop; the chargen preview has none of that (there is no live
/// entity — character creation hasn't happened yet), so it cannot reuse that
/// class directly. Rather than re-typing the same formula a second time,
/// this Core, pure, unit-testable class is the shared primitive: the
/// chargen preview (<c>AcDream.App.Rendering.ChargenPreviewAnimator</c>)
/// consumes it directly, and it is safe for a future pass to redirect
/// <c>LiveEntityAnimationPresenter</c>'s inline copy through it as a
/// behavior-preserving mechanical follow-up (not done here — that file is
/// live, heavily tested production entity-rendering code with zero relation
/// to this preview-only feature, so touching it is out of this slice's
/// blast radius by design, not oversight). Tracked as
/// <c>docs/ISSUES.md</c> #403 so the follow-up has an owner.
/// entity — character creation hasn't happened yet), so neither consumer can
/// own the primitive. Keeping it here prevents the two paths from drifting.
/// </para>
/// </summary>
public static class RetailAnimationCyclePlayback
@ -43,14 +30,15 @@ public static class RetailAnimationCyclePlayback
/// <summary>
/// Advances <paramref name="currFrame"/> by <c>elapsedSeconds * framerate</c>
/// and wraps it back into <c>[lowFrame, highFrame]</c> with the SAME modulo
/// shape <c>LiveEntityAnimationPresenter.Present</c>'s legacy branch uses
/// shape retail playback and the live presenter's legacy branch use
/// (<c>over % (span + 1)</c>, not a plain clamp — a frame position that
/// overshoots the end by more than one span wraps around more than once
/// rather than sticking at the boundary, matching a long stall/resume).
/// Returns <paramref name="currFrame"/> unchanged for a degenerate cycle
/// (<paramref name="highFrame"/> &lt;= <paramref name="lowFrame"/>), a
/// non-positive <paramref name="framerate"/>, or a non-positive
/// <paramref name="elapsedSeconds"/>.
/// (<paramref name="highFrame"/> &lt;= <paramref name="lowFrame"/>) or a
/// non-positive <paramref name="elapsedSeconds"/>. A negative framerate
/// advances backward and clamps at the low frame, matching the former
/// live-presenter implementation exactly.
/// </summary>
public static float Advance(
float currFrame,
@ -60,7 +48,7 @@ public static class RetailAnimationCyclePlayback
float elapsedSeconds)
{
int span = highFrame - lowFrame;
if (span <= 0 || framerate <= 0f || elapsedSeconds <= 0f)
if (span <= 0 || elapsedSeconds <= 0f)
return currFrame;
float next = currFrame + elapsedSeconds * framerate;
@ -84,8 +72,7 @@ public static class RetailAnimationCyclePlayback
/// back to <paramref name="lowFrame"/>). Returns <c>false</c> — with
/// <c>default</c> outputs — when <paramref name="partIndex"/> is outside
/// the bracketing frame's part list, matching
/// <c>LiveEntityAnimationPresenter.TryResolvePartFrame</c>'s no-
/// sequence-frames branch exactly.
/// the live presenter's no-sequence-frames branch exactly.
/// </summary>
public static bool TryInterpolatePart(
Animation animation,

View file

@ -505,6 +505,15 @@ public sealed class WorldTimeService
/// <summary>Current sky lighting state.</summary>
public SkyKeyframe CurrentSky => _sky.Interpolate((float)DayFraction);
/// <summary>
/// Interpolate this clock's active day-group provider at an explicit day
/// fraction without changing the clock. Retail <c>LScape::SetDay</c>
/// uses this distinction: the sky continues advancing normally while
/// landscape lighting is sampled at noon.
/// </summary>
public SkyKeyframe SkyAtDayFraction(float dayFraction) =>
_sky.Interpolate(dayFraction);
/// <summary>Convenience: current sun direction from derived sky state.</summary>
public Vector3 CurrentSunDirection =>
SkyStateProvider.SunDirectionFromKeyframe(CurrentSky);

View file

@ -4,6 +4,7 @@ using AcDream.Headless.Diagnostics;
using AcDream.Headless.Plugins;
using AcDream.Headless.Policies;
using AcDream.Content.CharGen;
using AcDream.Core.Chat;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
using AcDream.Runtime;
@ -957,6 +958,16 @@ internal sealed class HeadlessSessionHost : IDisposable
case ClientCommandId.QueryBirth:
session.SendQueryBirth();
return;
case ClientCommandId.TogglePersistentDaylight:
{
bool enabled = !runtime.CharacterOwner.Options.GetOptionBit(
CharacterOptionId.PersistentAtDay);
_ = runtime.CharacterOwner.Options.TrySetOption(
(uint)CharacterOptionId.PersistentAtDay,
enabled,
session.SendSetSingleCharacterOption);
return;
}
case ClientCommandId.Emote
when !string.IsNullOrWhiteSpace(command.Arguments):
session.SendEmote(command.Arguments.Trim());
@ -1004,7 +1015,27 @@ internal sealed class HeadlessSessionHost : IDisposable
session.SendRecallAllegianceHometown();
return;
case ClientCommandId.AllegianceInfo:
session.SendAllegianceInfoRequest(command.Arguments.Trim());
case ClientCommandId.AllegianceBoot:
case ClientCommandId.AllegianceBan:
case ClientCommandId.AllegianceChat:
case ClientCommandId.AllegianceBroadcast:
case ClientCommandId.AllegianceOfficer:
case ClientCommandId.AllegianceOfficerTitle:
case ClientCommandId.AllegianceName:
case ClientCommandId.AllegianceLock:
case ClientCommandId.AllegianceHouse:
case ClientCommandId.AllegianceMotd:
case ClientCommandId.AllegianceUnrecognizedSubcommand:
case ClientCommandId.HouseOpenStatus:
case ClientCommandId.HouseStorage:
case ClientCommandId.HouseBoot:
case ClientCommandId.HouseBootAll:
case ClientCommandId.HouseGuests:
case ClientCommandId.HouseHooks:
case ClientCommandId.HouseUnrecognizedSubcommand:
_ = CreateAdministrationDispatcher(session, runtime).TryExecute(
command.Command,
command.Arguments);
return;
case ClientCommandId.Permit:
ExecutePermit(session, command.Arguments);
@ -1077,6 +1108,61 @@ internal sealed class HeadlessSessionHost : IDisposable
}
}
private static RetailAdministrationCommandDispatcher
CreateAdministrationDispatcher(
AcDream.Core.Net.WorldSession session,
GameRuntime runtime) => new(
new RetailAdministrationCommandDispatcher.FeedbackBindings(
ShowSystemMessage: text => runtime.CommunicationOwner.AddText(
text, RetailLogTextType.Default),
ShowClientLocalMessage: text => runtime.CommunicationOwner.AddText(
text, RetailLogTextType.ClientLocal),
SetSingleCharacterOption: (optionId, enabled) =>
_ = runtime.CharacterOwner.Options.TrySetOption(
optionId,
enabled,
session.SendSetSingleCharacterOption),
RequestAllegianceInfo: session.SendAllegianceInfoRequest),
new RetailAdministrationCommandDispatcher.ActionBindings(
BreakAllegianceBoot: session.SendBreakAllegianceBoot,
AllegianceChatBoot: session.SendAllegianceChatBoot,
AllegianceChatGag: session.SendAllegianceChatGag,
AllegianceBroadcast: text => session.SendChannel(0x02000000u, text),
ListAllegianceBans: session.SendListAllegianceBans,
AddAllegianceBan: session.SendAddAllegianceBan,
RemoveAllegianceBan: session.SendRemoveAllegianceBan,
ListAllegianceOfficers: session.SendListAllegianceOfficers,
ClearAllegianceOfficers: session.SendClearAllegianceOfficers,
SetAllegianceOfficer: session.SendSetAllegianceOfficer,
RemoveAllegianceOfficer: session.SendRemoveAllegianceOfficer,
ListAllegianceOfficerTitles: session.SendListAllegianceOfficerTitles,
ClearAllegianceOfficerTitles: session.SendClearAllegianceOfficerTitles,
SetAllegianceOfficerTitle: session.SendSetAllegianceOfficerTitle,
QueryAllegianceName: session.SendQueryAllegianceName,
SetAllegianceName: session.SendSetAllegianceName,
ClearAllegianceName: session.SendClearAllegianceName,
AllegianceLockAction: session.SendAllegianceLockAction,
SetAllegianceApprovedVassal: session.SendSetAllegianceApprovedVassal,
AllegianceHouseAction: session.SendAllegianceHouseAction,
QueryMotd: session.SendQueryMotd,
SetMotd: session.SendSetMotd,
ClearMotd: session.SendClearMotd,
SetOpenHouseStatus: session.SendSetOpenHouseStatus,
AddPermanentGuest: session.SendAddPermanentGuest,
RemovePermanentGuest: session.SendRemovePermanentGuest,
RemoveAllPermanentGuests: session.SendRemoveAllPermanentGuests,
ChangeStoragePermission: session.SendChangeStoragePermission,
AddAllStoragePermission: session.SendAddAllStoragePermission,
RemoveAllStoragePermission: session.SendRemoveAllStoragePermission,
RequestFullGuestList: session.SendRequestFullGuestList,
BootSpecificHouseGuest: session.SendBootSpecificHouseGuest,
BootEveryone: session.SendBootEveryone,
SetHooksVisibility: session.SendSetHooksVisibility,
ModifyAllegianceGuestPermission:
session.SendModifyAllegianceGuestPermission,
ModifyAllegianceStoragePermission:
session.SendModifyAllegianceStoragePermission));
private ILiveSessionEventRouting CreateEventRoute(
AcDream.Core.Net.WorldSession session)
{
@ -1259,7 +1345,9 @@ internal sealed class HeadlessSessionHost : IDisposable
(text, type) => Runtime.CommunicationOwner.AddText(text, type),
Fellowship: Runtime.FellowshipOwner,
Allegiance: Runtime.AllegianceOwner,
Contracts: Runtime.ContractsOwner));
House: Runtime.HouseOwner,
Contracts: Runtime.ContractsOwner,
PlayerGuid: () => Runtime.PlayerIdentity.ServerGuid));
var eventRoute = new HeadlessSessionEventRoute(
route,
Runtime,

View file

@ -18,6 +18,10 @@ public enum ClientCommandId
QueryAge,
QueryBirth,
ToggleFrameRate,
/// <summary>@day — toggle retail's persistent noon landscape lighting.</summary>
TogglePersistentDaylight,
/// <summary>@render — modify retail's landscape radius or field of view.</summary>
RenderOption,
ToggleUiLock,
ShowVersion,
ShowLocation,
@ -84,18 +88,52 @@ public enum ClientCommandId
OffChannel,
/// <summary>@alh / @ah / "@allegiance hometown" / "@allegiance ho" — recall to the allegiance bindstone.</summary>
AllegianceHometown,
/// <summary>"@allegiance info [name]" — request allegiance member info.</summary>
/// <summary>"@allegiance info &lt;name&gt;" — request allegiance member info.</summary>
AllegianceInfo,
/// <summary>"@allegiance boot [-account] &lt;name&gt;".</summary>
AllegianceBoot,
/// <summary>"@allegiance ban ..." administration dispatcher.</summary>
AllegianceBan,
/// <summary>"@allegiance chat ..." administration dispatcher.</summary>
AllegianceChat,
/// <summary>"@allegiance broadcast &lt;text&gt;".</summary>
AllegianceBroadcast,
/// <summary>"@allegiance officer ..." administration dispatcher.</summary>
AllegianceOfficer,
/// <summary>"@allegiance title ..." officer-title dispatcher.</summary>
AllegianceOfficerTitle,
/// <summary>"@allegiance name ..." dispatcher.</summary>
AllegianceName,
/// <summary>"@allegiance lock ..." dispatcher.</summary>
AllegianceLock,
/// <summary>"@allegiance house ..." allegiance-house dispatcher.</summary>
AllegianceHouse,
/// <summary>"@allegiance motd ..." and standalone "@motd ...".</summary>
AllegianceMotd,
/// <summary>"@house abandon" — abandon the character's house.</summary>
HouseAbandon,
/// <summary>"@house open|close".</summary>
HouseOpenStatus,
/// <summary>"@house storage ...".</summary>
HouseStorage,
/// <summary>"@house remove|boot ...".</summary>
HouseBoot,
/// <summary>"@house boot_all|remove_all".</summary>
HouseBootAll,
/// <summary>"@house guest ...".</summary>
HouseGuests,
/// <summary>"@house hooks on|off".</summary>
HouseHooks,
/// <summary>
/// CH4 REJECT-review Blocker 1 (2026-08-09): "@allegiance"/"@all" with
/// any subcommand beyond the 2 ported ones (info, hometown/ho). Never
/// dispatched — <see cref="RetailClientCommandCatalog.Match.HasValidArguments"/>
/// is always
/// false for this id, so <c>ChatCommandRouter</c> shows retail's own
/// "Please see @help Allegiance..." refusal and never publishes an
/// <c>ExecuteClientCommandCmd</c>.
/// A genuinely unrecognized or incomplete <c>@allegiance</c> subcommand.
/// The catalog claims it locally and emits retail's Allegiance help
/// refusal rather than allowing channel fallback or server passthrough.
/// </summary>
AllegianceUnrecognizedSubcommand,
/// <summary>
/// An unrecognized or incomplete <c>@house</c> subcommand. Like retail,
/// this is claimed locally and prints the House help refusal.
/// </summary>
HouseUnrecognizedSubcommand,
}

View file

@ -0,0 +1,597 @@
using AcDream.Core.Net.Messages;
namespace AcDream.Runtime.Chat;
/// <summary>
/// Presentation-independent port of retail's nested allegiance and house
/// command handlers. Both graphical and headless hosts use this one parser so
/// their argument grammar, local refusals, and wire effects cannot drift.
/// </summary>
/// <remarks>
/// Ported from <c>ClientCommunicationSystem::DoAllegiance @ 0x0057D5A0</c>,
/// <c>DoHouse @ 0x00580860</c>, and their named helper functions in the
/// September 2013 retail decompile. The action bindings name ACE's matching
/// GameAction readers; no raw opcode is exposed at this layer.
/// </remarks>
public sealed class RetailAdministrationCommandDispatcher
{
public sealed record FeedbackBindings(
Action<string> ShowSystemMessage,
Action<string> ShowClientLocalMessage,
Action<uint, bool> SetSingleCharacterOption,
Action<string> RequestAllegianceInfo);
public sealed record ActionBindings(
Action<string, bool> BreakAllegianceBoot,
Action<string, string> AllegianceChatBoot,
Action<string, bool> AllegianceChatGag,
Action<string> AllegianceBroadcast,
Action ListAllegianceBans,
Action<string> AddAllegianceBan,
Action<string> RemoveAllegianceBan,
Action ListAllegianceOfficers,
Action ClearAllegianceOfficers,
Action<string, uint> SetAllegianceOfficer,
Action<string> RemoveAllegianceOfficer,
Action ListAllegianceOfficerTitles,
Action ClearAllegianceOfficerTitles,
Action<uint, string> SetAllegianceOfficerTitle,
Action QueryAllegianceName,
Action<string> SetAllegianceName,
Action ClearAllegianceName,
Action<uint> AllegianceLockAction,
Action<string> SetAllegianceApprovedVassal,
Action<uint> AllegianceHouseAction,
Action QueryMotd,
Action<string> SetMotd,
Action ClearMotd,
Action<bool> SetOpenHouseStatus,
Action<string> AddPermanentGuest,
Action<string> RemovePermanentGuest,
Action RemoveAllPermanentGuests,
Action<string, bool> ChangeStoragePermission,
Action AddAllStoragePermission,
Action RemoveAllStoragePermission,
Action RequestFullGuestList,
Action<string> BootSpecificHouseGuest,
Action BootEveryone,
Action<bool> SetHooksVisibility,
Action<bool> ModifyAllegianceGuestPermission,
Action<bool> ModifyAllegianceStoragePermission);
private readonly FeedbackBindings _feedback;
private readonly ActionBindings _actions;
public RetailAdministrationCommandDispatcher(
FeedbackBindings feedback,
ActionBindings actions)
{
_feedback = feedback ?? throw new ArgumentNullException(nameof(feedback));
_actions = actions ?? throw new ArgumentNullException(nameof(actions));
}
/// <summary>
/// Executes one typed management command. Returns false only when the id
/// is outside this dispatcher's family.
/// </summary>
public bool TryExecute(ClientCommandId command, string arguments)
{
ArgumentNullException.ThrowIfNull(arguments);
switch (command)
{
case ClientCommandId.AllegianceInfo:
ExecuteAllegianceInfo(arguments);
return true;
case ClientCommandId.AllegianceBoot:
ExecuteAllegianceBoot(arguments);
return true;
case ClientCommandId.AllegianceBan:
ExecuteAllegianceBan(arguments);
return true;
case ClientCommandId.AllegianceChat:
ExecuteAllegianceChat(arguments);
return true;
case ClientCommandId.AllegianceBroadcast:
ExecuteAllegianceBroadcast(arguments);
return true;
case ClientCommandId.AllegianceOfficer:
ExecuteAllegianceOfficer(arguments);
return true;
case ClientCommandId.AllegianceOfficerTitle:
ExecuteAllegianceOfficerTitle(arguments);
return true;
case ClientCommandId.AllegianceName:
ExecuteAllegianceName(arguments);
return true;
case ClientCommandId.AllegianceLock:
ExecuteAllegianceLock(arguments);
return true;
case ClientCommandId.AllegianceHouse:
ExecuteAllegianceHouse(arguments);
return true;
case ClientCommandId.AllegianceMotd:
ExecuteAllegianceMotd(arguments);
return true;
case ClientCommandId.AllegianceUnrecognizedSubcommand:
ShowAllegianceHelpRefusal();
return true;
case ClientCommandId.HouseOpenStatus:
_actions.SetOpenHouseStatus(
arguments.Equals("open", StringComparison.OrdinalIgnoreCase));
return true;
case ClientCommandId.HouseStorage:
ExecuteHouseStorage(arguments);
return true;
case ClientCommandId.HouseBoot:
ExecuteHouseBoot(arguments);
return true;
case ClientCommandId.HouseBootAll:
_actions.BootEveryone();
return true;
case ClientCommandId.HouseGuests:
ExecuteHouseGuests(arguments);
return true;
case ClientCommandId.HouseHooks:
ExecuteHouseHooks(arguments);
return true;
case ClientCommandId.HouseUnrecognizedSubcommand:
ShowHouseHelpRefusal();
return true;
default:
return false;
}
}
private void ExecuteAllegianceInfo(string arguments)
{
string name = arguments.Trim();
if (name.Length == 0)
ShowClientLocal("Please specify an actual name.");
else
_feedback.RequestAllegianceInfo(name);
}
private void ExecuteAllegianceBoot(string arguments)
{
string name = arguments.Trim();
if (name.Length == 0)
{
ShowClientLocal("Please specify an actual name.");
return;
}
int accountFlag = name.IndexOf("-account", StringComparison.OrdinalIgnoreCase);
bool accountBoot = accountFlag >= 0;
if (accountBoot)
name = name.Remove(accountFlag, "-account".Length).Trim();
_feedback.ShowSystemMessage(
$"Attempting to boot {name}{(accountBoot ? " (Account)" : string.Empty)}...");
_actions.BreakAllegianceBoot(name, accountBoot);
}
private void ExecuteAllegianceBan(string arguments)
{
string operation = FirstArgument(arguments);
if (operation.Equals("list", StringComparison.OrdinalIgnoreCase))
{
_actions.ListAllegianceBans();
return;
}
string name = RemainderAfterFirstArgument(arguments).Trim();
if (name.Length == 0)
{
ShowClientLocal("Please specify an actual name.");
return;
}
if (operation.Equals("add", StringComparison.OrdinalIgnoreCase))
_actions.AddAllegianceBan(name);
else if (operation.Equals("remove", StringComparison.OrdinalIgnoreCase))
_actions.RemoveAllegianceBan(name);
else
ShowAllegianceHelpRefusal();
}
private void ExecuteAllegianceChat(string arguments)
{
string operation = FirstArgument(arguments);
if (operation.Equals("on", StringComparison.OrdinalIgnoreCase)
|| operation.Equals("off", StringComparison.OrdinalIgnoreCase))
{
_feedback.SetSingleCharacterOption(
(uint)CharacterOptionId.ListenToAllegianceChat,
operation.Equals("on", StringComparison.OrdinalIgnoreCase));
return;
}
string remainder = RemainderAfterFirstArgument(arguments).Trim();
if (operation.Equals("kick", StringComparison.OrdinalIgnoreCase))
{
int comma = remainder.IndexOf(',');
string name = comma < 0 ? remainder : remainder[..comma].Trim();
string reason = comma < 0
? "No reason given."
: remainder[(comma + 1)..].Trim();
_actions.AllegianceChatBoot(name, reason);
return;
}
bool gag = operation.Equals("gag", StringComparison.OrdinalIgnoreCase);
bool ungag = operation.Equals("ungag", StringComparison.OrdinalIgnoreCase);
if (!gag && !ungag)
{
ShowAllegianceHelpRefusal();
return;
}
if (remainder.Length == 0)
{
ShowClientLocal("Please specify an actual name.");
return;
}
_actions.AllegianceChatGag(remainder, gag);
}
private void ExecuteAllegianceBroadcast(string arguments)
{
string message = arguments.Trim();
if (message.Length == 0)
ShowAllegianceHelpRefusal();
else
_actions.AllegianceBroadcast(message);
}
private void ExecuteAllegianceOfficer(string arguments)
{
string operation = FirstArgument(arguments);
if (operation.Length == 0
|| operation.Equals("list", StringComparison.OrdinalIgnoreCase))
{
_actions.ListAllegianceOfficers();
return;
}
if (operation.Equals("clear", StringComparison.OrdinalIgnoreCase))
{
_actions.ClearAllegianceOfficers();
return;
}
string remainder = RemainderAfterFirstArgument(arguments);
if (operation.Equals("remove", StringComparison.OrdinalIgnoreCase))
{
string name = remainder.Trim();
if (name.Length == 0)
ShowClientLocal("Please specify the name of an allegiance member.");
else
_actions.RemoveAllegianceOfficer(name);
return;
}
if (!operation.Equals("add", StringComparison.OrdinalIgnoreCase)
&& !operation.Equals("set", StringComparison.OrdinalIgnoreCase))
{
ShowAllegianceHelpRefusal();
return;
}
string levelText = FirstArgument(remainder);
int level = RetailStrtolBaseZero(levelText);
if (level is < 1 or > 3)
{
ShowClientLocal(
"Please specify a valid officer level as a number between 1 and 3. "
+ "Check the game help files for more information on officer levels.");
return;
}
string officerName = RemainderAfterFirstArgument(remainder).Trim();
if (officerName.Length == 0)
{
ShowClientLocal("Please specify the name of an allegiance member.");
return;
}
_actions.SetAllegianceOfficer(officerName, (uint)level);
}
private void ExecuteAllegianceOfficerTitle(string arguments)
{
string operation = FirstArgument(arguments);
if (operation.Length == 0
|| operation.Equals("list", StringComparison.OrdinalIgnoreCase))
{
_actions.ListAllegianceOfficerTitles();
return;
}
if (operation.Equals("clear", StringComparison.OrdinalIgnoreCase))
{
_actions.ClearAllegianceOfficerTitles();
return;
}
if (!operation.Equals("set", StringComparison.OrdinalIgnoreCase))
{
ShowAllegianceHelpRefusal();
return;
}
string remainder = RemainderAfterFirstArgument(arguments);
string levelText = FirstArgument(remainder);
int level = RetailStrtolBaseZero(levelText);
if (level is < 1 or > 3)
{
ShowClientLocal(
"Please specify a valid officer level as a number between 1 and 3.");
return;
}
string title = RemainderAfterFirstArgument(remainder).Trim();
_actions.SetAllegianceOfficerTitle((uint)level, title);
}
private void ExecuteAllegianceName(string arguments)
{
string operation = FirstArgument(arguments);
if (operation.Length == 0)
_actions.QueryAllegianceName();
else if (operation.Equals("set", StringComparison.OrdinalIgnoreCase))
_actions.SetAllegianceName(RemainderAfterFirstArgument(arguments).Trim());
else if (operation.Equals("clear", StringComparison.OrdinalIgnoreCase))
_actions.ClearAllegianceName();
else
ShowAllegianceHelpRefusal();
}
private void ExecuteAllegianceLock(string arguments)
{
string operation = FirstArgument(arguments);
uint? action = operation.ToLowerInvariant() switch
{
"" or "check" => 4u,
"off" => 1u,
"on" => 2u,
"toggle" => 3u,
_ => null,
};
if (action is not null)
{
_actions.AllegianceLockAction(action.Value);
return;
}
if (!operation.Equals("bypass", StringComparison.OrdinalIgnoreCase))
{
ShowAllegianceHelpRefusal();
return;
}
string approved = RemainderAfterFirstArgument(arguments).Trim();
if (approved.Length == 0)
_actions.AllegianceLockAction(5u);
else if (approved.Equals("clear", StringComparison.OrdinalIgnoreCase))
_actions.AllegianceLockAction(6u);
else
_actions.SetAllegianceApprovedVassal(approved);
}
private void ExecuteAllegianceHouse(string arguments)
{
string category = FirstArgument(arguments);
if (category.Length == 0)
{
_actions.AllegianceHouseAction(1u);
return;
}
string state = FirstArgument(RemainderAfterFirstArgument(arguments));
uint action = (category.ToLowerInvariant(), state.ToLowerInvariant()) switch
{
("guest", "open") => 2u,
("guest", "close") => 3u,
("storage", "open") => 4u,
("storage", "close") => 5u,
_ => 0u,
};
if (action == 0u)
ShowAllegianceHelpRefusal();
else
_actions.AllegianceHouseAction(action);
}
private void ExecuteAllegianceMotd(string arguments)
{
string operation = FirstArgument(arguments);
if (operation.Length == 0)
_actions.QueryMotd();
else if (operation.Equals("set", StringComparison.OrdinalIgnoreCase))
_actions.SetMotd(RemainderAfterFirstArgument(arguments).Trim());
else if (operation.Equals("clear", StringComparison.OrdinalIgnoreCase))
_actions.ClearMotd();
else
ShowAllegianceHelpRefusal();
}
private void ExecuteHouseGuests(string arguments)
{
string operation = FirstArgument(arguments);
string name = RemainderAfterFirstArgument(arguments).Trim();
if (operation.Equals("add", StringComparison.OrdinalIgnoreCase)
|| operation.Equals("remove", StringComparison.OrdinalIgnoreCase))
{
if (name.Length == 0)
{
ShowClientLocal("Please specify the guest's name.");
return;
}
if (operation.Equals("add", StringComparison.OrdinalIgnoreCase))
_actions.AddPermanentGuest(name);
else
_actions.RemovePermanentGuest(name);
return;
}
if (operation.Equals("remove_all", StringComparison.OrdinalIgnoreCase))
_actions.RemoveAllPermanentGuests();
else if (operation.Equals("list", StringComparison.OrdinalIgnoreCase)
|| operation.Equals("show", StringComparison.OrdinalIgnoreCase))
_actions.RequestFullGuestList();
else if (operation.Equals("add_allegiance", StringComparison.OrdinalIgnoreCase))
_actions.ModifyAllegianceGuestPermission(true);
else if (operation.Equals("remove_allegiance", StringComparison.OrdinalIgnoreCase))
_actions.ModifyAllegianceGuestPermission(false);
else
ShowHouseHelpRefusal();
}
private void ExecuteHouseStorage(string arguments)
{
string operation = FirstArgument(arguments);
string name = RemainderAfterFirstArgument(arguments).Trim();
if (operation.Equals("add", StringComparison.OrdinalIgnoreCase)
|| operation.Equals("remove", StringComparison.OrdinalIgnoreCase))
{
if (name.Length == 0)
{
ShowClientLocal("Please specify an actual name.");
return;
}
bool enabled = operation.Equals("add", StringComparison.OrdinalIgnoreCase);
if (name.Equals("-all", StringComparison.OrdinalIgnoreCase))
{
if (enabled)
_actions.AddAllStoragePermission();
else
_actions.RemoveAllStoragePermission();
}
else
{
_actions.ChangeStoragePermission(name, enabled);
}
return;
}
if (operation.Equals("remove_all", StringComparison.OrdinalIgnoreCase))
_actions.RemoveAllStoragePermission();
else if (operation.Equals("list", StringComparison.OrdinalIgnoreCase)
|| operation.Equals("show", StringComparison.OrdinalIgnoreCase))
_actions.RequestFullGuestList();
else if (operation.Equals("add_allegiance", StringComparison.OrdinalIgnoreCase))
_actions.ModifyAllegianceStoragePermission(true);
else if (operation.Equals("remove_allegiance", StringComparison.OrdinalIgnoreCase))
_actions.ModifyAllegianceStoragePermission(false);
else
ShowHouseHelpRefusal();
}
private void ExecuteHouseBoot(string arguments)
{
string name = arguments.Trim();
if (name.Length == 0)
{
ShowHouseHelpRefusal();
return;
}
if (name.Equals("-all", StringComparison.OrdinalIgnoreCase))
_actions.BootEveryone();
else
_actions.BootSpecificHouseGuest(name);
}
private void ExecuteHouseHooks(string arguments)
{
string state = FirstArgument(arguments);
if (state.Equals("on", StringComparison.OrdinalIgnoreCase))
_actions.SetHooksVisibility(true);
else if (state.Equals("off", StringComparison.OrdinalIgnoreCase))
_actions.SetHooksVisibility(false);
else
ShowHouseHelpRefusal();
}
private void ShowAllegianceHelpRefusal() => ShowClientLocal(
"Please see @help Allegiance for more information on how to use this command.");
private void ShowHouseHelpRefusal() => ShowClientLocal(
"Please see @help House for more information on how to use this command.");
private void ShowClientLocal(string text) =>
_feedback.ShowClientLocalMessage(text);
private static int RetailStrtolBaseZero(string value)
{
if (string.IsNullOrEmpty(value))
return 0;
int index = 0;
int sign = 1;
if (value[index] is '+' or '-')
{
if (value[index] == '-')
sign = -1;
if (++index == value.Length)
return 0;
}
int numberBase = 10;
if (value[index] == '0')
{
numberBase = 8;
if (index + 2 < value.Length
&& value[index + 1] is 'x' or 'X'
&& HexDigit(value[index + 2]) >= 0)
{
numberBase = 16;
index += 2;
}
}
long result = 0;
bool sawDigit = false;
while (index < value.Length)
{
int digit = HexDigit(value[index]);
if (digit < 0 || digit >= numberBase)
break;
sawDigit = true;
result = Math.Min(
(long)int.MaxValue + (sign < 0 ? 1L : 0L),
result * numberBase + digit);
index++;
}
if (!sawDigit)
return 0;
long signed = sign < 0 ? -result : result;
return (int)Math.Clamp(signed, int.MinValue, int.MaxValue);
static int HexDigit(char c) => c switch
{
>= '0' and <= '9' => c - '0',
>= 'a' and <= 'f' => c - 'a' + 10,
>= 'A' and <= 'F' => c - 'A' + 10,
_ => -1,
};
}
private static string FirstArgument(string arguments)
{
string trimmed = arguments.Trim();
int separator = trimmed.IndexOfAny([' ', '\t', '\r', '\n']);
return separator < 0 ? trimmed : trimmed[..separator];
}
private static string RemainderAfterFirstArgument(string arguments)
{
string trimmed = arguments.Trim();
int separator = trimmed.IndexOfAny([' ', '\t', '\r', '\n']);
return separator < 0 ? string.Empty : trimmed[(separator + 1)..].TrimStart();
}
}

View file

@ -111,6 +111,23 @@ public static class RetailClientCommandCatalog
"/framerate",
"/framerate - Toggles the framerate display.");
// ClientCommunicationSystem::DoDay @0x005706F0 ignores argc and toggles
// LScape::m_fAlwaysDaylight plus PlayerModule::PersistentAtDay.
private static readonly Definition Day = AnyArguments(
ClientCommandId.TogglePersistentDaylight,
"/day",
RetailCommandHelpTable.Day);
// ClientCommunicationSystem::DoRenderOption @0x0057E120 forwards the
// complete argv to GraphicsOptions::HandleRenderOption @0x00455C30.
// That handler owns its usage/error reporting, so every argument shape
// must reach the application executor rather than the generic catalog
// validation refusal.
private static readonly Definition Render = AnyArguments(
ClientCommandId.RenderOption,
"/render <option> <value>",
RetailCommandHelpTable.Render);
private static readonly Definition LockUi = NoArguments(
ClientCommandId.ToggleUiLock,
"/lockui",
@ -444,15 +461,96 @@ public static class RetailClientCommandCatalog
"/alh",
"@allegiance hometown (@alh, @ah) - Recalls you to your allegiance bindstone, if your allegiance has tied to one.");
// GameActionAllegianceInfoRequest.Handle — String16L name, empty = self.
// GameActionAllegianceInfoRequest.Handle — String16L member name. Retail
// rejects an empty argument locally before it constructs this action.
// Exact retail help: acclient_2013_pseudo_c.txt:1031214 —
// "@allegiance info <name> - Requests information on a member of your
// allegiance.\n"
private static readonly Definition AllegianceInfo = AnyArguments(
ClientCommandId.AllegianceInfo,
"/allegiance info [name]",
"/allegiance info <name>",
"@allegiance info <name> - Requests information on a member of your allegiance.");
private static readonly Definition AllegianceBoot = AnyArguments(
ClientCommandId.AllegianceBoot,
"/allegiance boot [-account] <name>",
RetailCommandHelpTable.AllegianceOverview);
private static readonly Definition AllegianceBan = AnyArguments(
ClientCommandId.AllegianceBan,
"/allegiance ban <add|remove|list> [name]",
RetailCommandHelpTable.AllegianceOverview);
private static readonly Definition AllegianceChat = AnyArguments(
ClientCommandId.AllegianceChat,
"/allegiance chat <on|off|kick|gag|ungag> ...",
RetailCommandHelpTable.AllegianceOverview);
private static readonly Definition AllegianceBroadcast = AnyArguments(
ClientCommandId.AllegianceBroadcast,
"/allegiance broadcast <message>",
RetailCommandHelpTable.AllegianceOverview);
private static readonly Definition AllegianceOfficer = AnyArguments(
ClientCommandId.AllegianceOfficer,
"/allegiance officer [add|set|remove|clear|list] ...",
RetailCommandHelpTable.AllegianceOverview);
private static readonly Definition AllegianceOfficerTitle = AnyArguments(
ClientCommandId.AllegianceOfficerTitle,
"/allegiance title [set|clear|list] ...",
RetailCommandHelpTable.AllegianceOverview);
private static readonly Definition AllegianceName = AnyArguments(
ClientCommandId.AllegianceName,
"/allegiance name [set|clear] ...",
RetailCommandHelpTable.AllegianceOverview);
private static readonly Definition AllegianceLock = AnyArguments(
ClientCommandId.AllegianceLock,
"/allegiance lock [on|off|toggle|check|bypass] ...",
RetailCommandHelpTable.AllegianceOverview);
private static readonly Definition AllegianceHouse = AnyArguments(
ClientCommandId.AllegianceHouse,
"/allegiance house [guest|storage] [open|close]",
RetailCommandHelpTable.AllegianceOverview);
private static readonly Definition AllegianceMotd = AnyArguments(
ClientCommandId.AllegianceMotd,
"/motd [set <text>|clear]",
RetailCommandHelpTable.Motd);
private static readonly Definition HouseOpenStatus = AnyArguments(
ClientCommandId.HouseOpenStatus,
"/house <open|close>",
RetailCommandHelpTable.HouseOverview);
private static readonly Definition HouseStorage = AnyArguments(
ClientCommandId.HouseStorage,
"/house storage <subcommand>",
RetailCommandHelpTable.HouseOverview);
private static readonly Definition HouseBoot = AnyArguments(
ClientCommandId.HouseBoot,
"/house boot <name|-all>",
RetailCommandHelpTable.HouseOverview);
private static readonly Definition HouseBootAll = AnyArguments(
ClientCommandId.HouseBootAll,
"/house boot_all",
RetailCommandHelpTable.HouseOverview);
private static readonly Definition HouseGuests = AnyArguments(
ClientCommandId.HouseGuests,
"/house guest <subcommand>",
RetailCommandHelpTable.HouseOverview);
private static readonly Definition HouseHooks = AnyArguments(
ClientCommandId.HouseHooks,
"/house hooks <on|off>",
RetailCommandHelpTable.HouseOverview);
// ClientCommunicationSystem::DoAllegiance @ 0x0057D5A0. Exact retail
// text: acclient_2013_pseudo_c.txt:1031375 (data_7e0bd0) — "Please see
// @help Allegiance for more information on how to use this command.".
@ -468,8 +566,9 @@ public static class RetailClientCommandCatalog
// to the legacy Allegiance channel (0x02000000) — a real chat-visible
// bug. TryMatchAllegiance below now claims ownership of "allegiance"/
// "all" UNCONDITIONALLY, exactly like retail's registered-command hash
// table does, and shows this refusal for every subcommand beyond the
// 2 ported ones (info/hometown/ho — TS-68 tracks the other 10).
// table does. The recognized subcommands below now route to their
// complete retail handlers; only genuinely unknown forms use this
// refusal.
private static readonly Definition AllegianceUnrecognizedSubcommand = new(
ClientCommandId.AllegianceUnrecognizedSubcommand,
Usage: "/allegiance <sub>",
@ -477,6 +576,13 @@ public static class RetailClientCommandCatalog
ValidateArguments: static _ => false,
InvalidArgumentsText: "Please see @help Allegiance for more information on how to use this command.");
private static readonly Definition HouseUnrecognizedSubcommand = new(
ClientCommandId.HouseUnrecognizedSubcommand,
Usage: "/house <sub>",
HelpText: "Please see @help House for more information on how to use this command.",
ValidateArguments: static _ => false,
InvalidArgumentsText: "Please see @help House for more information on how to use this command.");
private static readonly FrozenDictionary<string, Definition> ByVerb =
new Dictionary<string, Definition>(StringComparer.OrdinalIgnoreCase)
{
@ -499,6 +605,8 @@ public static class RetailClientCommandCatalog
["age"] = QueryAge,
["birth"] = QueryBirth,
["framerate"] = FrameRate,
["day"] = Day,
["render"] = Render,
["lockui"] = LockUi,
["version"] = Version,
["loc"] = Location,
@ -545,6 +653,7 @@ public static class RetailClientCommandCatalog
["off"] = OffChannel,
["alh"] = AllegianceHometown,
["ah"] = AllegianceHometown,
["motd"] = AllegianceMotd,
}.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
/// <summary>
@ -603,38 +712,69 @@ public static class RetailClientCommandCatalog
/// <summary>
/// <c>@house &lt;sub&gt;</c> / <c>@hou &lt;sub&gt;</c> dispatcher.
/// Retail's real <c>DoHouse @ 0x00580860</c> handles 15 subcommands
/// (see the registry doc §2.5b) locally; acdream Campaign CH slice CH4
/// (2026-08-09) ports 4 of them (recall/re, mansion_recall/alleg_recall/
/// ma, abandon). Every OTHER subcommand — open, close, storage, remove,
/// boot, boot_all, remove_all, guest, available, hooks, on, off, and
/// any misspelling of the 4 ported ones — returns <c>false</c>
/// uniformly (there is no separate local-swallow branch; CH4
/// REJECT-review nit 10, 2026-08-09, corrected this comment, which
/// previously described a swallow path that does not exist in the code
/// below), letting <see cref="ChatCommandRouter"/> fall through to
/// server passthrough (ACE replies "Unknown command") rather than
/// being swallowed locally with a wrong usage message — the Tier-1 #4
/// fix from the command-registry doc. The 12 unported subcommands are
/// tracked by TS-68.
/// Retail's real <c>DoHouse @ 0x00580860</c> owns the complete verb and
/// dispatches its nested commands locally. Unknown/incomplete forms print
/// the exact House refusal rather than escaping to server chat.
/// </summary>
private static bool TryMatchHouse(string arguments, out Match match)
{
match = default;
string subcommand = arguments.ToLowerInvariant();
Definition? definition = subcommand switch
int separator = IndexOfWhitespace(arguments);
string subcommand = separator < 0 ? arguments : arguments[..separator];
string rest = separator < 0 ? string.Empty : arguments[(separator + 1)..].Trim();
Definition? definition = subcommand.ToLowerInvariant() switch
{
"recall" or "re" => HouseRecall,
"mansion_recall" or "alleg_recall" or "ma" => MansionRecall,
"abandon" => HouseAbandon,
"open" or "close" => HouseOpenStatus,
"storage" => HouseStorage,
"remove" or "boot" => HouseBoot,
"boot_all" or "remove_all" => HouseBootAll,
"guest" => HouseGuests,
"available" => HouseAvailableList,
"hooks" => HouseHooks,
_ => null,
};
if (definition is null)
return false;
{
match = new Match(
HouseUnrecognizedSubcommand.Command,
arguments,
HouseUnrecognizedSubcommand.Usage,
HasValidArguments: false,
HouseUnrecognizedSubcommand.InvalidArgumentsText);
return true;
}
if (definition == HouseRecall || definition == MansionRecall)
{
match = new Match(
definition.Command,
rest,
definition.Usage,
HasValidArguments: rest.Length == 0,
InvalidArgumentsText: "Please see @help House for more information on how to use this command.");
return true;
}
if (definition == HouseAvailableList)
{
match = new Match(
definition.Command,
rest,
definition.Usage,
definition.ValidateArguments(rest),
definition.InvalidArgumentsText);
return true;
}
string nestedArguments = definition == HouseOpenStatus
? subcommand
: rest;
match = new Match(
definition.Command,
Arguments: string.Empty,
Arguments: nestedArguments,
definition.Usage,
HasValidArguments: true,
InvalidArgumentsText: null);
@ -643,25 +783,15 @@ public static class RetailClientCommandCatalog
/// <summary>
/// <c>@allegiance &lt;sub&gt;</c> / <c>@all &lt;sub&gt;</c> dispatcher.
/// Retail's real <c>DoAllegiance @ 0x0057D5A0</c> handles 12
/// subcommands (see the registry doc §2.5) locally; acdream Campaign CH
/// slice CH4 (2026-08-09) ports 2 of them (info, hometown/ho).
/// Retail's real <c>DoAllegiance @ 0x0057D5A0</c> handles all twelve
/// subcommands locally. Unknown forms remain locally owned and print its
/// exact Allegiance refusal.
/// </summary>
/// <remarks>
/// <b>CH4 REJECT-review Blocker 1 correction (2026-08-09):</b> every
/// OTHER subcommand — boot, ban, officer, title, name, lock, house,
/// motd, chat, broadcast, or garbage — is NOT yet ported (TS-68), but
/// unlike <see cref="TryMatchHouse"/> this method NEVER returns
/// <c>false</c> for the "allegiance"/"all" verb: retail's own
/// <c>DoAllegiance</c> claims the ENTIRE verb unconditionally and
/// prints its own client-local refusal
/// (<see cref="AllegianceUnrecognizedSubcommand"/>) for an unrecognized
/// subcommand — it never falls through to <c>DoChannelCommand</c> or
/// the server. The original CH4 implementation returned <c>false</c>
/// here (matching <see cref="TryMatchHouse"/>'s reasoning), which let
/// an unmatched subcommand escape all the way to the unregistered-tag
/// channel-fallback and broadcast the raw text to the Allegiance
/// channel — a real bug, not merely an incomplete port.
/// Retail claims the entire "allegiance"/"all" verb. Recognized forms
/// execute locally; unknown forms produce
/// <see cref="AllegianceUnrecognizedSubcommand"/> and never escape to
/// channel fallback or server passthrough.
/// </remarks>
private static bool TryMatchAllegiance(string arguments, out Match match)
{
@ -669,24 +799,29 @@ public static class RetailClientCommandCatalog
string subcommand = separator < 0 ? arguments : arguments[..separator];
string rest = separator < 0 ? string.Empty : arguments[(separator + 1)..].Trim();
if (subcommand.Equals("hometown", StringComparison.OrdinalIgnoreCase)
|| subcommand.Equals("ho", StringComparison.OrdinalIgnoreCase))
Definition? definition = subcommand.ToLowerInvariant() switch
{
match = new Match(
AllegianceHometown.Command,
Arguments: string.Empty,
AllegianceHometown.Usage,
HasValidArguments: true,
InvalidArgumentsText: null);
return true;
}
"boot" => AllegianceBoot,
"info" => AllegianceInfo,
"chat" or "ch" => AllegianceChat,
"broadcast" or "br" => AllegianceBroadcast,
"ban" => AllegianceBan,
"officer" => AllegianceOfficer,
"title" => AllegianceOfficerTitle,
"hometown" or "ho" => AllegianceHometown,
"motd" => AllegianceMotd,
"name" => AllegianceName,
"lock" => AllegianceLock,
"house" => AllegianceHouse,
_ => null,
};
if (subcommand.Equals("info", StringComparison.OrdinalIgnoreCase))
if (definition is not null)
{
match = new Match(
AllegianceInfo.Command,
rest,
AllegianceInfo.Usage,
definition.Command,
definition == AllegianceHometown ? string.Empty : rest,
definition.Usage,
HasValidArguments: true,
InvalidArgumentsText: null);
return true;

View file

@ -10,8 +10,7 @@ namespace AcDream.Runtime.Chat;
/// registers these with NO handler; typing them bare reaches the server,
/// only <c>@help &lt;verb&gt;</c> shows anything locally), and the
/// allegiance/house command overviews (the per-subcommand detail lives
/// here too, even though most subcommands are not yet locally executed —
/// see TS-68). <b>Corrected at the consolidated-review round
/// here too). <b>Corrected at the consolidated-review round
/// (2026-08-10), SHOULD-FIX 1:</b> the sentence above previously claimed
/// this table covers only verbs <see cref="RetailClientCommandCatalog"/>
/// "doesn't dispatch directly" — that framing is now FALSE and was itself
@ -379,9 +378,8 @@ public static class RetailCommandHelpTable
// BN already fully decodes with no vtable-slot artifact to work around.
// The pristine dump's "broadcast" line ends "...Also: @ab\n" (no
// bracket) and the "hometown" line ends "...tied to one.\n" (no
// bracket, no alias mention at all) — both removed here. TS-68's
// implemented-vs-not-yet-implemented tracking lives in ISSUES.md and
// the divergence register now, not in this user-visible string.
// bracket, no alias mention at all) — both removed here. Implementation
// status never belongs in this verbatim user-visible string.
public const string AllegianceOverview =
"@allegiance - Commands to help manage your allegiance.\n"
+ "@allegiance boot [-account] <name> - Removes a character from your allegiance.\n"
@ -427,8 +425,7 @@ public static class RetailCommandHelpTable
// counterpart. The "@house available" line's retail literal is exactly
// "@house available - See @hslist\n" — no trailing period, and none of
// the bracketed "[see @hslist, IMPLEMENTED]" text the old version
// appended. TS-68's implemented-vs-not-yet-implemented tracking lives
// in ISSUES.md and the divergence register now, not in this
// appended. Implementation status never belongs in this verbatim
// user-visible string.
public const string HouseOverview =
HouseOneLiner
@ -955,6 +952,9 @@ public static class RetailCommandHelpTable
// that in place of retail's text is exactly what this table exists
// to prevent.
["log"] = Log,
["day"] = Day,
["render"] = Render,
["motd"] = Motd,
["lifestone"] = LifestoneDetail,
["lif"] = LifestoneDetail,
["ls"] = LifestoneDetail,

View file

@ -1,6 +1,7 @@
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
using AcDream.Runtime.Physics;
namespace AcDream.Runtime.Entities;
@ -597,8 +598,8 @@ public sealed class InboundPhysicsStateController
/// PRE-PLACEMENT writes unconditionally. Retail runs both of them BEFORE
/// <c>MoveOrTeleport</c> is consulted, so their gates are a pure function
/// of the timestamp disposition and the static HasAnims proxy - which is
/// why classifying them here needs no route, no player distance and no
/// signature change (see the truth table at the call below). The
/// why deriving them here needs no route, no player distance and no
/// signature change. The
/// near/far/teleport routing decision proper is still downstream of this
/// merge and still belongs to
/// <c>RuntimeAuthoritativePositionRouteClassifier</c>, which the
@ -606,15 +607,9 @@ public sealed class InboundPhysicsStateController
/// which the App-layer OnPosition tail runs post-merge for this caller;
/// contact still comes solely from the retained wire packet's own
/// <c>IsGrounded</c> bit on both. That remaining structural difference
/// (two callers computing the same two flags from the same two inputs
/// rather than sharing one code path) is internal refactor debt - it is
/// NOT a retail divergence and does not belong in
/// docs/architecture/retail-divergence-register.md. It is tracked as
/// docs/ISSUES.md issue <b>#322</b>, filed 2026-08-05 at the C5b review
/// (finding S1): #275 is CLOSED and this comment's former "tracked for
/// the eventual cutover unification / see docs/ISSUES.md" wording pointed
/// at nothing once it was. #322 also records why widening this method's
/// signature to take a whole route would be the wrong unification.
/// shared <c>DerivePrePlacementFlags</c> function now owns the truth table
/// for both this merge and the downstream route classifier (#322); the
/// full route remains downstream because these writes do not depend on it.
/// </summary>
public bool TryApplyPosition(
WorldSession.EntityPositionUpdate update,
@ -680,14 +675,6 @@ public sealed class InboundPhysicsStateController
// ForcePosition | false | false
// Apply | !hasAnimations | true
//
// That is exactly RuntimeAuthoritativePositionRouteClassifier's own
// ApplyPlacementFrameBeforeRouting/UnparentBeforeRouting rows
// (ClassifyAcceptedPosition: false/false on the force row, and
// `!request.HasAnimations`/true on EVERY accepted non-force route).
// The classifier stays the oracle; the equality of the two small pure
// computations is pinned by test, not by a shared code path, so each
// remains separately sabotage-verifiable.
bool force = disposition is PositionTimestampDisposition.ForcePosition;
// AP-130's static proxy, computed from the PRE-merge snapshot `old`
// with the identical expression RuntimeAcceptedPositionRouteRequests
// uses. Deliberately NOT a live animation-queue read.
@ -695,6 +682,10 @@ public sealed class InboundPhysicsStateController
(old.MotionTableId ?? old.Physics?.MotionTableId)
is { } motionTableId
&& motionTableId != 0u;
RuntimeAcceptedPositionPrePlacementFlags prePlacement =
RuntimeAuthoritativePositionRouteClassifier.DerivePrePlacementFlags(
disposition,
hasAnimations);
accepted = ApplyAcceptedPosition(
old,
update,
@ -703,8 +694,8 @@ public sealed class InboundPhysicsStateController
isLocalPlayer,
forcePositionRotation,
currentLocalVelocity,
installPlacementFrame: !force && !hasAnimations,
clearParent: !force);
installPlacementFrame: prePlacement.ApplyPlacementFrameBeforeRouting,
clearParent: prePlacement.UnparentBeforeRouting);
_snapshots[update.Guid] = accepted;
return true;
}
@ -713,10 +704,8 @@ public sealed class InboundPhysicsStateController
/// the remarks on <see cref="ApplyAcceptedObjDescSnapshot"/>. The
/// executor passes its classified route's own
/// <c>ApplyPlacementFrameBeforeRouting</c>/<c>UnparentBeforeRouting</c>
/// flags; since C5b (#275) the steady-state
/// <see cref="TryApplyPosition"/> caller passes the same two values,
/// derived pre-merge from (disposition, hasAnimations) rather than read
/// off a route.</summary>
/// flags; the steady-state <see cref="TryApplyPosition"/> caller gets the
/// same values from the shared pre-placement derivation.</summary>
internal bool ApplyAcceptedPositionSnapshot(
uint guid,
WorldSession.EntityPositionUpdate update,
@ -817,13 +806,9 @@ public sealed class InboundPhysicsStateController
/// <see cref="AcceptedPhysicsTimestamps"/> captured at admission time.
///
/// <paramref name="installPlacementFrame"/>/<paramref name="clearParent"/>
/// (Round 3 B6) carry retail's two PRE-PLACEMENT gates. Since C5b (#275)
/// BOTH callers supply the same classified values, from the same two
/// inputs: the continuation executor reads its classified route's
/// <c>ApplyPlacementFrameBeforeRouting</c>/<c>UnparentBeforeRouting</c>,
/// and the steady-state <see cref="TryApplyPosition"/> merge derives them
/// pre-merge from (disposition, hasAnimations) - see the truth table
/// there. Both are false only for the FORCE_POSITION branch, which
/// (Round 3 B6) carry retail's two PRE-PLACEMENT gates. Both callers now
/// consume the single <c>DerivePrePlacementFlags</c> truth table. Both
/// flags are false only for the FORCE_POSITION branch, which
/// retail's HandleReceivedPosition Gate A returns from immediately,
/// BEFORE either call; <paramref name="installPlacementFrame"/> is
/// additionally false whenever HasAnims is true.

View file

@ -1,14 +1,15 @@
using System.Globalization;
using AcDream.Core.Items;
using AcDream.Core.Net.Messages;
using AcDream.Core.Ui;
using AcDream.Core.Properties;
namespace AcDream.Runtime.Gameplay;
/// <summary>
/// Canonical presentation-independent owner for the House tab of retail's
/// two-tab Map/House panel (<c>gmHouseUI</c>). Deliberately MINIMAL
/// "houseless-status only" per ISSUES #413's own sizing note: a full
/// two-tab Map/House panel (<c>gmHouseUI</c>). Deliberately lightweight
/// a full
/// <c>RuntimeTradeState</c>-weight owner (construction-transaction
/// <c>Fault()</c> injection point, disposal ordering, convergence tracking)
/// is disproportionate for what this slice needs, since (unlike Trade) this
@ -28,8 +29,8 @@ namespace AcDream.Runtime.Gameplay;
/// (<c>DisplayRentPayment</c>, <c>DisplayBuyTime</c>,
/// <c>DisplayRentTimes</c>, <c>DisplayLocation</c>,
/// <c>DisplayWarningText</c>) open with <c>if (this-&gt;m_pHouseData != 0)</c>
/// and emit NOTHING when houseless — those remain unported, ISSUES #413
/// item 3.
/// and emit NOTHING when houseless. The owned-house path retains the exact
/// 0x0225 snapshot and ports all seven builders in retail's fixed order.
/// </para>
/// <para>
/// <b>CORRECTED at the 2026-08-17 morning gate round (user finding 2: the
@ -63,7 +64,9 @@ namespace AcDream.Runtime.Gameplay;
/// <c>m_pHouseData == 0</c> (still houseless) and emits the literal at
/// <c>data_7ab7f0</c>: <b>"You may buy another house immediately."</b>
/// (owns-a-house sibling at <c>data_7ab818</c>, byte-re-verified this
/// round). So a queried houseless character's House tab shows exactly TWO
/// round). The owned branch now composes the retained buy list through the
/// retail <c>HousePaymentList</c> rules. So a queried houseless character's
/// House tab shows exactly TWO
/// lines, in builder order: "You do not currently own a house." then the
/// purchase-time line — which this class now renders.
/// </para>
@ -101,7 +104,9 @@ public sealed class RuntimeHouseState
private readonly object _gate = new();
private bool _hasReceivedNotice;
private bool _ownsHouse;
private GameEvents.HouseData? _houseData;
private IReadOnlyList<string> _lines = Array.Empty<string>();
private IReadOnlyList<HousePanelLine> _panelLines = Array.Empty<HousePanelLine>();
/// <summary>Borrows the canonical object table (optional for bare
/// fixtures) to read the local player's own
@ -123,6 +128,28 @@ public sealed class RuntimeHouseState
get { lock (_gate) return _lines; }
}
/// <summary>The same rows with retail's <c>HousePanelTextColor</c>
/// index preserved for the authored row template.</summary>
public IReadOnlyList<HousePanelLine> PanelLines
{
get { lock (_gate) return _panelLines; }
}
/// <summary>The owned-house position used by retail's Map-page house
/// marker. Apartments intentionally expose no landscape position.</summary>
public CreateObject.ServerPosition? Position
{
get
{
lock (_gate)
{
return _houseData is { Type: not 4u, Position: { LandblockId: not 0u } } data
? data.Position
: null;
}
}
}
/// <summary>Whether any of the four House notices (0x0225-0x0228) has
/// arrived this session.</summary>
public bool HasReceivedNotice
@ -131,15 +158,48 @@ public sealed class RuntimeHouseState
}
/// <summary>0x0225 HouseData — <c>RecvNotice_UpdateHouseData</c>
/// (owned-house case). Only <see cref="_ownsHouse"/> is consumed today;
/// the owned-house payload itself (buy/rent payments, times, location)
/// feeds ISSUES #413's remaining six builders, not yet ported.</summary>
/// (owned-house case). The snapshot is retained defensively because its
/// payment lists are later replaced by 0x0227/0x0228 notices.</summary>
public void ApplyHouseData(GameEvents.HouseData data, uint selfGuid)
{
lock (_gate)
{
_hasReceivedNotice = true;
_ownsHouse = true;
_houseData = Copy(data);
Recompute(selfGuid);
}
}
/// <summary>0x0227 UpdateRentTime. Retail installs the new period start,
/// clears every paid count, then rebuilds the complete panel.</summary>
public void ApplyRentTime(uint rentTime, uint selfGuid)
{
lock (_gate)
{
if (_houseData is not { } data)
return;
GameEvents.HousePayment[] rent = data.Rent
.Select(static payment => payment with { Paid = 0 })
.ToArray();
_houseData = data with { RentTime = rentTime, Rent = rent };
Recompute(selfGuid);
}
}
/// <summary>0x0228 UpdateRentPayment. Retail replaces the complete rent
/// list, then rebuilds the complete panel.</summary>
public void ApplyRentPayment(
IReadOnlyList<GameEvents.HousePayment> rent, uint selfGuid)
{
ArgumentNullException.ThrowIfNull(rent);
lock (_gate)
{
if (_houseData is not { } data)
return;
_houseData = data with { Rent = rent.ToArray() };
Recompute(selfGuid);
}
}
@ -158,6 +218,7 @@ public sealed class RuntimeHouseState
{
_hasReceivedNotice = true;
_ownsHouse = false;
_houseData = null;
Recompute(selfGuid);
}
}
@ -171,29 +232,67 @@ public sealed class RuntimeHouseState
{
_hasReceivedNotice = false;
_ownsHouse = false;
_houseData = null;
_lines = Array.Empty<string>();
_panelLines = Array.Empty<HousePanelLine>();
}
}
/// <summary>The ported share of <c>gmHouseUI::Update</c>'s fixed
/// seven-builder order: <c>DisplayBuyPayment @0x004a2b30</c>'s
/// houseless branch (first) and <c>DisplayPurchaseTimeText
/// @0x004a3110</c> (last), both branches. The five houseless-silent
/// builders between them, and DisplayBuyPayment's owned branch, are
/// ISSUES #413 item 3. Must hold <see cref="_gate"/>.</summary>
/// <summary>Port of <c>gmHouseUI::DisplayHouseData @0x004a3380</c>'s
/// fixed seven-builder order. <c>DisplayRentTimes</c> emits two rows, so
/// an owned outdoor house produces eight rows total. Must hold
/// <see cref="_gate"/>.</summary>
private void Recompute(uint selfGuid)
{
var lines = new List<string>(2);
var lines = new List<HousePanelLine>(_ownsHouse ? 8 : 2);
// gmHouseUI::DisplayBuyPayment @0x004a2b30 — NOT houseless-silent
// (2026-08-17 morning gate correction; see the class doc): the
// m_pHouseData gate only selects WHICH text, and the emit runs in
// both branches. Houseless (@0x004a2b57, byte-decoded data_7ab688):
// this exact literal. Owned (@0x004a2b63, data_7ab65c "The purchase
// price for this dwelling is:\n" + HousePaymentList::ComposeText):
// unported, #413 item 3 — the owned case adds nothing here yet.
if (!_ownsHouse)
lines.Add("You do not currently own a house.");
// both branches. The owned prefix and every remaining literal were
// recovered from the PDB-paired binary for issue #413.
if (_houseData is { } data)
{
lines.Add(Normal(
"The purchase price for this dwelling is:\n"
+ ComposePayments(data.Buy, includePaid: false)));
lines.Add(Normal(
"Rent:\n" + ComposePayments(data.Rent, includePaid: true)));
lines.Add(Normal("Bought: " + ConvertTime(data.BuyTime)));
long period = GetRentPeriodSeconds(data.Type);
bool paid = data.MaintenanceFree || IsPaidInFull(data.Rent);
lines.Add(Normal(
"This maintenance period ends: "
+ ConvertTime((long)data.RentTime + period)));
lines.Add(Normal(
"Maintenance is next due: "
+ ConvertTime((long)data.RentTime + (paid ? 2L : 1L) * period)));
if (data.Type != 4u
&& RadarCoordinates.TryFromCell(
data.Position.LandblockId, out RadarCoordinates coordinates))
{
lines.Add(Normal(
$"Location: {coordinates.YText}, {coordinates.XText}"));
}
lines.Add(paid
? new HousePanelLine(
"The maintenance has already been paid for this period. "
+ "You may not prepay next period's maintenance.",
HousePanelTextColor.RentPaid)
: new HousePanelLine(
"Warning! You have not paid your maintenance costs for the last "
+ (period / 86_400L).ToString(CultureInfo.InvariantCulture)
+ " day maintenance period. Please pay these costs by this deadline"
+ " or you will lose your house, and all your items within it.",
HousePanelTextColor.RentNotPaid));
}
else
{
lines.Add(Normal("You do not currently own a house."));
}
// gmHouseUI::DisplayPurchaseTimeText @0x004a3110, both branches.
int timestamp = _objects?.Get(selfGuid)?.Properties
@ -221,18 +320,95 @@ public sealed class RuntimeHouseState
timestamp + PurchaseWaitPeriodSeconds);
DateTime expiryLocal = TimeZoneInfo.ConvertTime(
expiryUtc, _timeProvider.LocalTimeZone).DateTime;
lines.Add(
lines.Add(Normal(
"You may buy another landscape house at "
+ expiryLocal.ToString(CultureInfo.CurrentCulture)
+ ". This restriction does not apply to apartments.");
+ ". This restriction does not apply to apartments."));
}
else
{
lines.Add(_ownsHouse
lines.Add(Normal(_ownsHouse
? "You may buy another house immediately after you abandon this one."
: "You may buy another house immediately.");
: "You may buy another house immediately."));
}
_lines = lines;
_panelLines = lines;
_lines = lines.Select(static line => line.Text).ToArray();
}
private HousePanelLine Normal(string text) =>
new(text, HousePanelTextColor.Normal);
private static GameEvents.HouseData Copy(GameEvents.HouseData data) =>
data with
{
Buy = (data.Buy ?? Array.Empty<GameEvents.HousePayment>()).ToArray(),
Rent = (data.Rent ?? Array.Empty<GameEvents.HousePayment>()).ToArray(),
};
private static bool IsPaidInFull(IReadOnlyList<GameEvents.HousePayment> payments)
{
for (int i = 0; i < payments.Count; i++)
{
if (payments[i].Paid < payments[i].Num)
return false;
}
return true;
}
private static string ComposePayments(
IReadOnlyList<GameEvents.HousePayment> payments, bool includePaid)
{
if (payments.Count == 0)
return string.Empty;
var parts = new string[payments.Count];
for (int i = 0; i < payments.Count; i++)
{
GameEvents.HousePayment payment = payments[i];
string quantity = includePaid
? $"{payment.Paid.ToString(CultureInfo.InvariantCulture)}/{payment.Num.ToString(CultureInfo.InvariantCulture)}"
: payment.Num.ToString(CultureInfo.InvariantCulture);
parts[i] = quantity + " " + PaymentName(payment);
}
return string.Join(", ", parts);
}
private static string PaymentName(GameEvents.HousePayment payment)
{
if (payment.Num == 1)
return payment.Name;
if (!string.IsNullOrEmpty(payment.PluralName))
return payment.PluralName;
return payment.Name.EndsWith('s') || payment.Name.EndsWith('x')
? payment.Name + "es"
: payment.Name + "s";
}
private static long GetRentPeriodSeconds(uint houseType) =>
houseType == 4u ? 7_776_000L : 2_592_000L;
private string ConvertTime(long epochSeconds)
{
if (epochSeconds == 0L)
return "N/A";
DateTimeOffset utc = DateTimeOffset.FromUnixTimeSeconds(epochSeconds);
DateTime local = TimeZoneInfo.ConvertTime(
utc, _timeProvider.LocalTimeZone).DateTime;
return local.ToString(CultureInfo.CurrentCulture);
}
}
/// <summary>Retail <c>HousePanelTextColor</c>; the numeric value is the
/// authored row template's font-color palette index.</summary>
public enum HousePanelTextColor
{
Normal = 0,
RentPaid = 1,
RentNotPaid = 2,
}
public readonly record struct HousePanelLine(
string Text, HousePanelTextColor Color);

View file

@ -131,6 +131,16 @@ internal readonly record struct RuntimeAcceptedPositionRouteRequest(
bool HasAnimations,
RuntimePositionPlacementFacts PlacementFacts);
/// <summary>
/// Retail's two writes before <c>MoveOrTeleport</c>: the force-position
/// self-echo returns before both; every ordinary accepted Position unparents,
/// and only an object without animations receives the wire placement frame.
/// These facts depend solely on timestamp disposition and HasAnims.
/// </summary>
internal readonly record struct RuntimeAcceptedPositionPrePlacementFlags(
bool UnparentBeforeRouting,
bool ApplyPlacementFrameBeforeRouting);
/// <summary>
/// Immutable action plan for retail HandleReceivedPosition/MoveOrTeleport.
/// It deliberately contains no renderer, world entity, UI, or host callback.
@ -189,6 +199,20 @@ internal static class RuntimeAuthoritativePositionRouteClassifier
internal static bool IsValidCreateWirePosition(
in CreateObject.ServerPosition position) => ValidPosition(position);
internal static RuntimeAcceptedPositionPrePlacementFlags DerivePrePlacementFlags(
PositionTimestampDisposition disposition,
bool hasAnimations) => disposition switch
{
PositionTimestampDisposition.Apply => new(
UnparentBeforeRouting: true,
ApplyPlacementFrameBeforeRouting: !hasAnimations),
PositionTimestampDisposition.ForcePosition => default,
// Rejected packets use the timestamp-only merge and never inspect
// either flag. Returning default keeps that branch explicit.
PositionTimestampDisposition.Rejected => default,
_ => throw new ArgumentOutOfRangeException(nameof(disposition), disposition, null),
};
internal static RuntimeAuthoritativePositionRoute ClassifyCreate(
in RuntimeCreatePositionRouteRequest request)
{
@ -308,6 +332,10 @@ internal static class RuntimeAuthoritativePositionRouteClassifier
if (!ValidPosition(request.AcceptedWirePosition))
return RejectedData(request.Authority, operation, reporting);
RuntimeAcceptedPositionPrePlacementFlags prePlacement =
DerivePrePlacementFlags(
request.Authority.TimestampDisposition,
request.HasAnimations);
bool force = request.Authority.TimestampDisposition
is PositionTimestampDisposition.ForcePosition;
if (force)
@ -320,8 +348,8 @@ internal static class RuntimeAuthoritativePositionRouteClassifier
operation,
AuthoritativeTeleportFlags,
request.PlacementFrame ?? 0u,
UnparentBeforeRouting: false,
ApplyPlacementFrameBeforeRouting: false,
prePlacement.UnparentBeforeRouting,
prePlacement.ApplyPlacementFrameBeforeRouting,
LeaveWorld: false,
TeleportHookPhase: RuntimeTeleportHookPhase.None,
StopInterpolating: false,
@ -343,8 +371,8 @@ internal static class RuntimeAuthoritativePositionRouteClassifier
operation,
AuthoritativeTeleportFlags,
placement,
UnparentBeforeRouting: true,
ApplyPlacementFrameBeforeRouting: !request.HasAnimations,
prePlacement.UnparentBeforeRouting,
prePlacement.ApplyPlacementFrameBeforeRouting,
LeaveWorld: false,
TeleportHookPhase: RuntimeTeleportHookPhase.AfterPositionOperation,
StopInterpolating: false,
@ -365,8 +393,8 @@ internal static class RuntimeAuthoritativePositionRouteClassifier
operation,
PhysicsSetPositionFlags.None,
placement,
UnparentBeforeRouting: true,
ApplyPlacementFrameBeforeRouting: !request.HasAnimations,
prePlacement.UnparentBeforeRouting,
prePlacement.ApplyPlacementFrameBeforeRouting,
LeaveWorld: false,
TeleportHookPhase: RuntimeTeleportHookPhase.None,
StopInterpolating: false,
@ -396,8 +424,8 @@ internal static class RuntimeAuthoritativePositionRouteClassifier
operation,
AuthoritativeTeleportFlags,
placement,
UnparentBeforeRouting: true,
ApplyPlacementFrameBeforeRouting: !request.HasAnimations,
prePlacement.UnparentBeforeRouting,
prePlacement.ApplyPlacementFrameBeforeRouting,
LeaveWorld: false,
TeleportHookPhase: RuntimeTeleportHookPhase.BeforePositionOperation,
StopInterpolating: false,
@ -422,8 +450,8 @@ internal static class RuntimeAuthoritativePositionRouteClassifier
operation,
PhysicsSetPositionFlags.None,
placement,
UnparentBeforeRouting: true,
ApplyPlacementFrameBeforeRouting: !request.HasAnimations,
prePlacement.UnparentBeforeRouting,
prePlacement.ApplyPlacementFrameBeforeRouting,
LeaveWorld: false,
TeleportHookPhase: RuntimeTeleportHookPhase.None,
StopInterpolating: false,
@ -452,8 +480,8 @@ internal static class RuntimeAuthoritativePositionRouteClassifier
operation,
nearby ? PhysicsSetPositionFlags.None : AuthoritativeTeleportFlags,
placement,
UnparentBeforeRouting: true,
ApplyPlacementFrameBeforeRouting: !request.HasAnimations,
prePlacement.UnparentBeforeRouting,
prePlacement.ApplyPlacementFrameBeforeRouting,
LeaveWorld: false,
TeleportHookPhase: RuntimeTeleportHookPhase.None,
StopInterpolating: !nearby,
@ -480,9 +508,7 @@ internal static class RuntimeAuthoritativePositionRouteClassifier
{
PositionTimestampDisposition.Apply => true,
PositionTimestampDisposition.ForcePosition =>
kind is RuntimePositionEntityKind.LocalPlayer
&& authority.PreviousTeleportSequence
== authority.AcceptedTeleportSequence,
kind is RuntimePositionEntityKind.LocalPlayer,
_ => false,
};
}

View file

@ -597,6 +597,14 @@ internal sealed class RuntimeSetPositionState : IDisposable
_deferredByCellGeneration = [];
private SortedDictionary<ulong, RuntimePlacementProjectionSnapshot>
_pendingProjection = [];
// Issue #311: retry is a per-tick pump while a host receipt remains
// unacknowledged. Retain one snapshot list per synchronous call depth
// instead of allocating Values.ToArray() every tick. PublishPlacement can
// invoke arbitrary observers, so the depth-indexed shape preserves the
// old snapshot semantics even if an observer re-enters this method.
private readonly List<List<RuntimePlacementProjectionSnapshot>>
_pendingProjectionRetryScratchByDepth = [new()];
private int _pendingProjectionRetryDepth;
private readonly List<CellGenerationKey> _deferredBucketOrder = [];
private readonly Dictionary<UnboundCellKey, List<RuntimeEntityKey>>
_unboundDeferredByCell = [];
@ -934,6 +942,14 @@ internal sealed class RuntimeSetPositionState : IDisposable
if (HasPendingProjectionThrough(current.ProjectionBarrierSequence))
return false;
// #310: collision retirement is the stronger authority for an
// authored mover that is still waiting for its first preparation.
// Such an operation has no canonical placement result or projection
// to preserve; waiting for an asset that may never resolve used to
// pin this prefix forever. Cancel the exact unprepared operation
// before evaluating ordinary placement debt, then let the resident
// enter the retirement park below.
CancelUnpreparedPrefixPlacementDebt(current);
if (HasOldPrefixPlacementDebt(current))
return false;
@ -1117,18 +1133,42 @@ internal sealed class RuntimeSetPositionState : IDisposable
internal void RetryPendingProjections()
{
EnsureNotDisposed();
RuntimePlacementProjectionSnapshot[] snapshot =
_pendingProjection.Values.ToArray();
for (int index = 0; index < snapshot.Length; index++)
int depth = _pendingProjectionRetryDepth;
if (depth == _pendingProjectionRetryScratchByDepth.Count)
{
RuntimePlacementProjectionSnapshot projection = snapshot[index];
if (_pendingProjection.TryGetValue(
projection.Token.Sequence,
out RuntimePlacementProjectionSnapshot current)
&& current == projection)
_pendingProjectionRetryScratchByDepth.Add([]);
}
List<RuntimePlacementProjectionSnapshot> snapshot =
_pendingProjectionRetryScratchByDepth[depth];
_pendingProjectionRetryDepth = depth + 1;
try
{
snapshot.Clear();
// Enumerate the dictionary itself. SortedDictionary.Values exposes
// its enumerator through an interface and boxes it (~72 B/call),
// which would retain a smaller version of the allocation this
// issue removes.
foreach (KeyValuePair<ulong, RuntimePlacementProjectionSnapshot>
entry in _pendingProjection)
{
PublishPlacement(projection);
snapshot.Add(entry.Value);
}
for (int index = 0; index < snapshot.Count; index++)
{
RuntimePlacementProjectionSnapshot projection = snapshot[index];
if (_pendingProjection.TryGetValue(
projection.Token.Sequence,
out RuntimePlacementProjectionSnapshot current)
&& current == projection)
{
PublishPlacement(projection);
}
}
}
finally
{
snapshot.Clear();
_pendingProjectionRetryDepth = depth;
}
}
@ -4101,6 +4141,50 @@ internal sealed class RuntimeSetPositionState : IDisposable
return false;
}
private void CancelUnpreparedPrefixPlacementDebt(
CollisionPrefixQuiescence state)
{
uint prefix = state.Token.LandblockPrefix;
List<RuntimeEntityKey>? cancelled = null;
foreach (Operation operation in _operations.Values)
{
if (operation.WakeableLostCell
|| operation.DormantLocalActivation
|| operation.Stage is not RuntimeEntityPlacementStage
.AwaitingPreparation
|| !_moverPreparationAuthorities.TryGetValue(
operation.Key,
out MoverPreparationAuthority preparation)
|| preparation.OperationId != operation.Token.OperationId
|| preparation.Prepared
|| !((preparation.AcceptedPosition.LandblockId
& 0xFFFF0000u) == prefix
|| IsAffectedCollisionResident(
operation.Record,
prefix,
state.IncludeOutdoorCells)))
{
continue;
}
(cancelled ??= []).Add(operation.Key);
}
if (cancelled is null)
return;
for (int index = 0; index < cancelled.Count; index++)
{
_ = CancelCoreDeferred(
cancelled[index],
cancelLostFamily: false,
preserveLostFamily: false,
out RuntimePlacementProjectionSnapshot? discard);
if (discard is { } projection)
PublishPlacement(projection);
}
}
private static bool PlacementTouchesPrefix(
in PhysicsSetPositionRequest request,
uint prefix) =>

View file

@ -95,7 +95,11 @@ public sealed record LiveSocialSessionBindings(
RuntimeHouseState? House = null,
// Campaign QT (2026-08-21): the fourth sibling J-owner, same
// trailing/optional compatibility convention.
RuntimeContractState? Contracts = null);
RuntimeContractState? Contracts = null,
// Issue #359: retail suppresses 0x019E PlayerKilled for the victim and
// killer. Optional for compatibility with state-only tests; production
// hosts always supply their canonical identity owner.
Func<uint>? PlayerGuid = null);
/// <summary>
/// Owns every inbound subscription for one exact live session. Domain state
@ -338,6 +342,12 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting
onHouseStatus: social.House is { } houseStatus
? weenieError => houseStatus.ApplyHouseStatus(weenieError, inventory.PlayerGuid())
: null,
onHouseUpdateRentTime: social.House is { } houseRentTime
? rentTime => houseRentTime.ApplyRentTime(rentTime, inventory.PlayerGuid())
: null,
onHouseUpdateRentPayment: social.House is { } houseRentPayment
? rent => houseRentPayment.ApplyRentPayment(rent, inventory.PlayerGuid())
: null,
// Campaign QT (2026-08-21): same conditional delegate-hole
// discipline as house above.
onContractTable: social.Contracts is { } contractTable
@ -451,7 +461,8 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting
killed => social.Chat.OnPlayerKilled(
killed.DeathMessage,
killed.VictimGuid,
killed.KillerGuid));
killed.KillerGuid,
social.PlayerGuid?.Invoke() ?? 0u));
Subscribe<TurbineChat.Parsed>(
h => session.TurbineChatReceived += h,
h => session.TurbineChatReceived -= h,

View file

@ -180,10 +180,9 @@ public sealed record DisplaySettings(
int LandscapeTextureDetail = 2,
int EnvironmentTextureDetail = 1,
int TextureFiltering = 1,
// UNRESOLVED (OP6, cite in register row): retail's own
// SetDefaultValue(8) does not index its 6-entry SetEnumChoices array
// (VeryLow..Extreme) — reproduced faithfully as an opaque int, not
// guessed into a clamped index.
// Retail stores the VALUE, not the caption index. Its six enum payloads
// are {3,5,8,11,15,25}; SetDefaultValue(8) therefore selects Medium.
// #361 corrected the former index-shaped Config binding.
int LandscapeDrawDistance = 8,
bool BuildingDetailTextures = true,
bool MultiPassAlpha = false)