fix: complete retail parity stability pass
This commit is contained in:
parent
d3df4cb20a
commit
f7aa8e0eb7
131 changed files with 7765 additions and 1190 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -239,7 +239,9 @@ public sealed class RetailPViewRenderer
|
|||
_partitionResult,
|
||||
prepareCells,
|
||||
ctx.LandblockEntries,
|
||||
_partitionObserver);
|
||||
_partitionObserver,
|
||||
ctx.Frustum,
|
||||
ctx.PlayerLandblockId ?? 0u);
|
||||
partition = _partitionResult;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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-
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
433
src/AcDream.App/UI/Layout/CreditsUiController.cs
Normal file
433
src/AcDream.App/UI/Layout/CreditsUiController.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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>();
|
||||
|
|
|
|||
|
|
@ -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)];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue