diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index b1d2689d..d2bf3806 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -53,7 +53,7 @@ accepted-divergence entries (#96, #49, #50). | IA-12 | UI toolkit mirrors retail behavior from research docs, not a byte-port β€” keystone.dll is outside decomp coverage; observed constants embedded (drag 3 px, tooltip 1000 ms). Synthetic wrapper borders/whole-window drag regions use the exact DAT Type-2/Type-9 control cursors. `gmPanelUI` children are independently imported retained frames rather than one physical parent, but `RetailPanelUiController` owns their one canonical geometry and exclusive child lifecycle | `src/AcDream.App/UI/README.md:3`; `src/AcDream.App/UI/CursorFeedbackController.cs`; `src/AcDream.App/UI/Layout/RetailPanelUiController.cs` | keystone.dll has no PDB/decomp; semantics are reconstructed from retail UI deep-dives, named client methods, and production LayoutDesc media. Separate child wrappers preserve each LayoutDesc's content tree while typed move/resize synchronization gives all registered toolbar/detail children the same persistent parent rectangle | Edge-case low-level input semantics can differ silently even though outer geometry, visibility, and restore-previous ownership match | `UIElementManager::CheckCursor` 0x0045ABF0; `UIElement_Resizebar::StartMouseResizing` 0x0046B7E0; `UIElement_Dragbar::StartMouseMoving` 0x0046C760; `gmPanelUI::SetupChildren` 0x004BC9E0; docs/research/retail-ui/04-input-events.md | | IA-13 | GameEventType registry deliberately omits event types retail ignores; unknown events fall through unhandled | `src/AcDream.Core.Net/Messages/GameEventType.cs:11` | Retail also ignores them β€” dropping matches retail by construction | If the "retail ignores X" judgment is wrong for any opcode (or a server mod uses one), the event is silently dropped with no diagnostic pointing at the omission | retail GameEvent dispatch (ignored-event set) | | IA-14 | Rendering + dat-handling base is WorldBuilder's tested port, not a fresh retail-decomp port (Phase N.4/O design stance) | `docs/architecture/worldbuilder-inventory.md` (code at `src/AcDream.{Core,App}/Rendering/Wb/`) | WB visually verified on the AC world, MIT, same stack; known WB↔retail deltas resolved case-by-case β€” terrain split kept retail `FSplitNESW` (**#51**, pinned by `SplitFormulaDivergenceTest`), scenery drift accepted (AP-31) | A WB-upstream divergence not yet caught ships silently as "our" behavior; guard = the inventory doc's 🟒/πŸ”΄ split + per-formula divergence tests | retail decomp per algorithm; `tests/.../SplitFormulaDivergenceTest.cs` | -| IA-15 | D.2b gameplay UI is our own `UiHost`/`UiRoot` retained tree, not a byte-port of Keystone. `RetailUiRuntime` owns the production import/mount graph; `RetailWindowManager`/typed handles centralize registry, raise, focus/capture cleanup, lifecycle events, reverse-order grouped controller teardown, removable Silk input subscriptions, schema-v2 per-character/per-resolution automatic layouts with per-window authored-geometry revisions, and portable named `saveui/loadui` profiles; `RetailWindowFrame` is the single production/Studio mount contract for imported-chrome and shared-wrapper windows. Production LayoutDesc imports include vitals `0x2100006C`, chat `0x2100006F` (Campaign CH slice CH6a corrected this from the previously-imported, unrelated `0x21000006`), toolbar `0x21000016`, character `0x2100002E`, inventory `0x21000023` plus mounted `0x21000024/22/21`, dialog catalog `0x2100003C`, radar `0x21000074`, and external container `0x21000008` (shared bevel plus a user-directed compact 700-pixel initial content width instead of its authored 800-pixel root). The dialog context/queue/callback lifecycle is now a named-client port; only its retained rendering remains under this Keystone adaptation. | `src/AcDream.App/UI/RetailUiRuntime.cs`; `src/AcDream.App/UI/RetailWindowManager.cs`; `src/AcDream.App/UI/RetainedPanelControllerGroup.cs`; `src/AcDream.App/UI/UiHost.cs`; `src/AcDream.App/UI/RetailWindowLayoutPersistence.cs`; `src/AcDream.App/UI/Layout/RetailWindowFrame.cs`; `src/AcDream.App/UI/Layout/RetailDialogFactory.cs`; `src/AcDream.App/UI/Layout/RetailConfirmationDialogView.cs`; `src/AcDream.App/UI/Layout/ExternalContainerController.cs`; `src/AcDream.App/UI/Layout/LayoutImporter.cs`; binding supply in `GameWindow.cs` | Keystone has no matching PDB/decomp, so we preserve its observable ElementDesc/state/input behavior from DAT, named client call sites, and live evidence while using modern retained ownership. Real RenderSurfaces and imported element geometry remain the visual oracle; the external strip's initial width follows the connected visual direction and remains horizontally resizable to its authored extent or the viewport edge. | Persistence and low-level widget rendering are behaviorally reconstructed from retail semantics rather than a Keystone byte-port; lifecycle edge cases remain constrained by conformance tests. The external strip opens 100 pixels narrower than the raw LayoutDesc before user/persistence resizing. | Production LayoutDesc objects; `DialogFactory @ 0x004773C0..0x00478470`; `gmExternalContainerUI @ 0x004CBAD0..0x004CBFE0`; `docs/research/2026-07-13-retail-dialog-factory-pseudocode.md`; Keystone behavior notes in `docs/research/retail-ui/` | +| IA-15 | D.2b gameplay UI is our own `UiHost`/`UiRoot` retained tree, not a byte-port of Keystone. `RetailUiRuntime` owns the production import/mount graph; `RetailWindowManager`/typed handles centralize registry, raise, focus/capture cleanup, lifecycle events, reverse-order grouped controller teardown, removable Silk input subscriptions, schema-v2 per-character/per-resolution automatic layouts with per-window authored-geometry revisions, and portable named `saveui/loadui` profiles; `RetailWindowFrame` is the single production/Studio mount contract for imported-chrome and shared-wrapper windows. Production LayoutDesc imports include vitals `0x2100006C` plus the side-by-side vitals row `0x21000075` (the vitals retail-modes round, 2026-08-17 β€” visibility swapped on the SideBySideVitals option by `VitalsSideBySideController`, retail `@0x004E9DA0`), chat `0x2100006F` (Campaign CH slice CH6a corrected this from the previously-imported, unrelated `0x21000006`), toolbar `0x21000016`, character `0x2100002E`, inventory `0x21000023` plus mounted `0x21000024/22/21`, dialog catalog `0x2100003C`, radar `0x21000074`, and external container `0x21000008` (shared bevel plus a user-directed compact 700-pixel initial content width instead of its authored 800-pixel root). The dialog context/queue/callback lifecycle is now a named-client port; only its retained rendering remains under this Keystone adaptation. | `src/AcDream.App/UI/RetailUiRuntime.cs`; `src/AcDream.App/UI/RetailWindowManager.cs`; `src/AcDream.App/UI/RetainedPanelControllerGroup.cs`; `src/AcDream.App/UI/UiHost.cs`; `src/AcDream.App/UI/RetailWindowLayoutPersistence.cs`; `src/AcDream.App/UI/Layout/RetailWindowFrame.cs`; `src/AcDream.App/UI/Layout/RetailDialogFactory.cs`; `src/AcDream.App/UI/Layout/RetailConfirmationDialogView.cs`; `src/AcDream.App/UI/Layout/ExternalContainerController.cs`; `src/AcDream.App/UI/Layout/LayoutImporter.cs`; binding supply in `GameWindow.cs` | Keystone has no matching PDB/decomp, so we preserve its observable ElementDesc/state/input behavior from DAT, named client call sites, and live evidence while using modern retained ownership. Real RenderSurfaces and imported element geometry remain the visual oracle; the external strip's initial width follows the connected visual direction and remains horizontally resizable to its authored extent or the viewport edge. | Persistence and low-level widget rendering are behaviorally reconstructed from retail semantics rather than a Keystone byte-port; lifecycle edge cases remain constrained by conformance tests. The external strip opens 100 pixels narrower than the raw LayoutDesc before user/persistence resizing. | Production LayoutDesc objects; `DialogFactory @ 0x004773C0..0x00478470`; `gmExternalContainerUI @ 0x004CBAD0..0x004CBFE0`; `docs/research/2026-07-13-retail-dialog-factory-pseudocode.md`; Keystone behavior notes in `docs/research/retail-ui/` | | IA-17 | Toolbar chrome is toolkit-supplied through the central `RetailWindowFrame` mount (`UiCollapsibleFrame` 8-piece bevel) because LayoutDesc `0x21000016` carries no baked frame. It also supports a toolkit-defined collapse-to-one-row (bottom-edge resize snapping between a row-1-only and a two-row height, row-2 visibility tied to the stop) β€” retail's real collapse is keystone.dll (no decomp) and the DAT stacks both rows always. | `src/AcDream.App/UI/Layout/RetailWindowFrame.cs`; `src/AcDream.App/UI/UiCollapsibleFrame.cs`; toolbar policy in `GameWindow.cs`; spec: `docs/superpowers/specs/2026-06-20-d2b-toolbar-collapse-design.md` | The central mount now owns wrapper geometry/registration uniformly; border-over-content prevents the row-2 right cap from poking through | The collapse stops remain a toolkit reconstruction rather than a byte-port of Keystone behavior | gmToolbarUI WM chrome (keystone.dll, no PDB); no bevel ids in LayoutDesc 0x21000016 (toolbar dump) | | IA-18 | Effect overlay tile (enum 0x10000005) is a `ReplaceColor` SURFACE SOURCE β€” pure-white pixels in the composited drag icon are replaced PER-PIXEL with the same (x,y) pixel of the effect tile (the SURFACE overload `SurfaceWindow::ReplaceColor` 0x004415b0), preserving the tile's texture/gradient; the tile itself is NOT blitted as an additional layer. This IS faithful retail behavior. **Anti-regression: do NOT re-implement this as a blit layer NOR as a flat-color replace (it is a per-pixel surface copy).** | `src/AcDream.App/UI/IconComposer.cs` (`ReplaceWhiteFromSurface`) | Faithful port of `IconData::RenderIcons` @407614 β†’ the SURFACE overload `ReplaceColor` 0x004415b0 (`dst[x,y]=src[x,y]` where `dst==white`); confirmed via clean Ghidra decompile + named decomp + visual (the Energy Crystal's blue is a gradient, 2026-06-17). | A blit-layer or flat-color re-implementation would show the wrong effect look (no gradient) β€” the visual-verification regression that retired the mean-color approximation | `IconData::RenderIcons` acclient_2013_pseudo_c.txt:407524; `ReplaceColor` SURFACE overload 0x004415b0:71656; `docs/research/2026-06-17-stateful-icon-RESOLVED.md` | | IA-19 | Automatic combat acquisition is narrowed to attackable non-player monsters. Retail `AutoTarget` falls back to `SelectNext(SELECTION_TYPE_COMPASS_ITEM)`, whose combat filter can also admit attackable enemy players in compatible PK states. | `src/AcDream.Core/Combat/CombatTargetPolicy.cs`; consumers `src/AcDream.App/Interaction/WorldSelectionQuery.cs` (`IsHostileMonster`/`FindClosestHostileMonster`) and `SelectionInteractionController.cs` (`SelectClosestCombatTarget`). This row is auto-acquisition-only: as of #298, explicit-target admission and the combat camera route through the separate, retail-exact `WorldSelectionQuery.IsAttackableTarget` (`ObjectIsAttackable`-backed) instead, so a compatible-PK player is a valid manual attack/camera target β€” do not assume one predicate still serves both concerns. | Explicit product direction: Auto Target must never select NPCs, players, pets, or other objects; manual player-selection commands remain available | In PK play, Auto Target will not acquire an otherwise valid hostile player as retail would; the player must be selected manually | `ClientCombatSystem::AutoTarget @ 0x0056BC80`; `CPlayerSystem::SelectNext @ 0x0055F9A0`; `ClientCombatSystem::ObjectIsAttackable @ 0x0056A600` | @@ -436,7 +436,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | ~~TS-20~~ | **RETIRED AS A FALSE ATTRIBUTION 2026-07-16** β€” `CGfxObj::InitLoad` passes the complete polygon array to `D3DPolyRender::ConstructMesh`; ordinary GfxObj rendering does not filter it through DrawingBSP. Building DrawingBSP traversal discovers and orders portal apertures after `RemoveNonPortalNodes`; it is not a global visible-polygon selector. The alleged building-shell "orphans" are `DrawingBSPNode.Portals`, omitted by the old diagnostic collector; the corrected node-polygons βˆͺ portal-polygons audit finds no true orphans. Applying the proposed filter would repeat the door disappearance regression from `e46d3d9`. | `docs/research/2026-06-11-holistic-map/wf1-gfxobj-draw.md`; `docs/research/2026-06-11-holistic-map/wf1-building-shells.md`; `tests/AcDream.Core.Tests/Rendering/Wb/Issue113DoorVanishDiagnosticTests.cs` | β€” | β€” | `CGfxObj::InitLoad @ 0x005346B0`; `D3DPolyRender::ConstructMesh @ 0x0059DFA0`; `BSPTREE::build_draw_portals_only @ 0x00539860` | | TS-21 | Default run/jump skills 200/300 tuned to feel until the first PlayerDescription lands (the stale "we don't parse yet" comment was FIXED in R4-V5; K-fix7 parses PD β†’ SetCharacterSkills) | `src/AcDream.Runtime/Gameplay/PlayerMovementController.cs:311` | Defaults rule only pre-PD or on PD parse failure; jump bumped 200β†’300 on user complaint (3.01 m max felt too low) | Any window with defaults live predicts run/jump speeds the server disagrees with β€” observer rubber-banding, local snap-backs | retail height = (skill/(skill+1300))Γ—22.2 + 0.05 | | TS-27 | **NARROWED 2026-07-29 (Campaign N Slice N1)** β€” OUTBOUND is ported: sent-packet cache + header-rebuilt resend on server `RequestRetransmit`, `ids[0]` implicit ack, wrap-safe watermark prune (`src/AcDream.Core.Net/Transport/`). Residual: INBOUND loss is still fatal β€” no sequence-aligned inbound ISAAC discipline, no client NAK emission, no `RejectRetransmit` consumption (Campaign N slices N2/N4) | `src/AcDream.Core.Net/WorldSession.cs` (`ProcessDatagram` inbound path); `docs/plans/2026-07-29-network-transport-campaign.md` Β§2.2/Β§2.3 | Campaign N executes the port one direction per slice; the N0 ACE double grades each slice before the next lands | One lost S2C packet still shifts the inbound keystream permanently β€” every later encrypted packet fails checksum and the session goes silently deaf until timeout | `SharedNet::ProcessPacket @ 0x00544790`; `ReceiverData::AddNakked @ 0x00549240`; `SharedNet::EnqueueNaks @ 0x00543BD0` | -| TS-28 | **NARROWED 2026-08-03** β€” F751 teleports resend LoginComplete only after the DAT-authored portal-space viewport and final world fade finish. Initial login no longer acknowledges raw PlayerCreate receipt: graphical and prepared headless hosts send exactly once after canonical local-player first placement; content-less headless sends after its accepted direct Create because it has no placement conductor. Residual: initial login still does not enter the full portal-space presentation. | `src/AcDream.Runtime/Session/RuntimeFirstEntryDriveController.cs`; `src/AcDream.App/Net/GraphicalSessionEventRoute.cs`; `src/AcDream.Headless/Hosting/HeadlessSessionEventRoute.cs`; `src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs`; `src/AcDream.Core.Net/WorldSession.cs` | Initial placement is now the shared readiness contract that releases ACE's intentional Hidden/pink-bubble state without racing presentation. The content-less direct host uses its only truthful admission edge. | The persistent login materialization haze is fixed and server updates no longer unlock before canonical placement. The remaining difference is presentation-only: initial login skips retail's wormhole sequence. | `gmSmartBoxUI::UseTime @ 0x004D6E30`; retail post-EnterWorld flow; holtburger `client/messages.rs:391-422` | +| TS-28 | **NARROWED 2026-08-17 (enter-world round)** β€” the GRAPHICAL host now runs retail's full login wormhole: the login reveal (`RuntimeWorldTransitState.BeginLoginReveal`, shared by direct auto-select, character-select Enter, and enter-after-create) arms the same `TeleportAnimSequencer`/`PortalTunnelPresentation` machine the F751 pump uses (`LocalPlayerTeleportController` login arm), with `Sound_UI_EnterPortal`/`Sound_UI_ExitPortal` at retail's edges and LoginComplete sent at the WorldFadeIn end gated on canonical first placement. Residual: HEADLESS hosts have no presentation β€” prepared headless sends LoginComplete once after canonical local-player first placement; content-less headless sends after its accepted direct Create because it has no placement conductor. | `src/AcDream.App/Streaming/LocalPlayerTeleportController.cs` (login arm); `src/AcDream.App/Net/LiveSessionRuntimeFactory.cs` (first-entry completion latch); `src/AcDream.Headless/Hosting/HeadlessSessionEventRoute.cs`; `src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs` | Headless hosts are bots β€” an animation hold would only delay automation; their placement-edge send remains the truthful admission contract. | A headless bot's LoginComplete reaches ACE seconds earlier than a graphical client's, so its observer-visible materialization is earlier than retail cadence. | `SmartBox::teleport_in_progress @ 0x00451C20`; `gmSmartBoxUI::UseTime @ 0x004D6E30` (login edge @ 0x004D6EAB, LoginComplete @ 0x004D745D); `gmSmartBoxUI::BeginTeleportAnimation @ 0x004D6300` (enter cue @ 0x004D638E); `SmartBox::UseTime @ 0x00455410` (position_update_complete @ 0x00455483); `CPlayerSystem::SendLoginCompleteNotification @ 0x00562E90`; holtburger `client/messages.rs:391-422` | | ~~TS-29~~ | **RETIRED 2026-08-08 (Campaign A slices A5/A6).** Both halves are resolved, in opposite directions. **Ambient:** ported. `AmbientSoundGatherer` walks retail's 3x3 landblock ring x 64 land cells off the region file's `SoundInfo`/`SceneInfo`/`TerrainInfo` chain, `AmbientSoundScheduler` runs the absolute-deadline queue, and continuous beds are re-fired one-shots on `min_rate` rather than looping voices β€” retail never sets the DirectSound loop flag, so the `StartAmbient`/`StopAmbient` handle API this row described modelled a mechanism that does not exist and is deleted. **Music:** there is nothing to port. Retail EoR links a complete winmm MIDI player and never feeds it β€” `midiPlay` has zero callers, the string "music" appears zero times in the 65 MB decomp, `SoundType` has no music member, `InitPrefs` registers no music key, and the retail install ships no music files. What players remember as dungeon music is the AdminEnvirons `UI_*` stinger family (TS-54, landed at A4). | retired | β€” | β€” | `Ambient::UpdatePlayQueue @ 0x551A50`; `Ambient::Play @ 0x5517A0`; `Ambient::UseTime @ 0x551880`; `CLandBlock::add_ambient_sounds @ 0x530310`; `docs/research/2026-08-08-audio-retail-ambient-runtime.md`; `docs/research/2026-08-08-audio-retail-music-absence.md` | | TS-30 | Chat DAT elements `0x10000522`–`0x10000525` render but have no controller semantics; the older claim that they are numbered in-window filter tabs is **unproven** | `src/AcDream.App/UI/Layout/ChatWindowController.cs` | Named retail proves separately filtered main/floaty chat windows, not an in-window numbered-tab model. Wave 5 must live/DAT-confirm these element roles before assigning behavior | The controls may be inert today, but inventing tab switching could be a larger divergence than leaving an unconfirmed role inactive | `gmMainChatUI @ 0x004CCCC0..0x004CE2A0`; correction in `docs/research/2026-07-10-retail-panel-behavior-pseudocode.md` | | TS-31 | **NARROWED 2026-07-13** β€” `/squelch`, `/unsquelch`, `/filter`, `/unfilter`, and `/messagetypes` send the exact modification events and consume the authoritative retail `SquelchDB`; incoming `ChatLog` lines are not yet filtered through that database, and clickable name-tag social actions remain absent | `src/AcDream.Core/Social/SquelchState.cs`; `src/AcDream.Core.Net/Messages/SocialStateMessages.cs`; `src/AcDream.App/UI/ClientCommandController.cs`; `src/AcDream.Core/Chat/ChatLog.cs` | Command/state transport is complete; enforcement belongs at the shared inbound-chat boundary so both backends remain identical | A squelch appears in the list and persists server-side but matching incoming lines can still render; contextual name actions remain unavailable | `SquelchDB::UnPack @ 0x006B1900`; `ChatFilter::IsSquelched`; retail right-click player name β†’ Squelch menu | diff --git a/src/AcDream.App/Input/PlayerModeController.cs b/src/AcDream.App/Input/PlayerModeController.cs index ee8903d3..faa2e1d5 100644 --- a/src/AcDream.App/Input/PlayerModeController.cs +++ b/src/AcDream.App/Input/PlayerModeController.cs @@ -141,13 +141,39 @@ internal sealed class PlayerModeController : if (Controller is null && !TryEnter("teleport")) return false; - if (Controller is not { } controller) + // Enter-world round (2026-08-17): the Runtime first-entry conductor + // can have CONSTRUCTED the controller without publishing it yet + // (CandidatePreparing/Sealed/Dormant). A State write in that window + // throws (EnsurePublishedForRuntimeOperation); CanExecuteLiveMovement + // is the documented skip-instead-of-fault predicate for exactly this + // pre-publication login window. Refuse β€” every caller retries on its + // own tick cadence. + if (Controller is not { CanExecuteLiveMovement: true } controller) return false; controller.State = PlayerState.PortalSpace; return true; } + /// + /// Enter-world round (2026-08-17): the login tunnel's portal-space entry. + /// The login wormhole begins before any player-mode entry has happened + /// (retail's camera set simply follows its always-present player), so + /// this first performs the SAME presentation attach the post-reveal + /// auto-entry used to run ( β€” chase camera, shadow, + /// animation sinks, approach lifetime), then flips into portal space. + /// itself refuses until the Runtime first-entry + /// conductor commits, so pre-publication calls fail closed and retry. + /// + public bool TryEnterPortalSpaceForLogin() + { + _autoEntry?.Cancel(); + if (!_mode.IsPlayerMode && !TryEnter("login")) + return false; + + return TryEnterPortalSpace(); + } + public void EnterWorld() { if (Controller is { } controller) diff --git a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs index cecf9923..9791477e 100644 --- a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs +++ b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs @@ -337,16 +337,25 @@ internal sealed class LiveSessionRuntimeFactory _world.FirstEntryDrive, _ => { - session.SendGameAction(GameActionLoginComplete.Build()); + // Enter-world round (2026-08-17): the graphical host no + // longer sends LoginComplete here. Retail sends 0xA1 at the + // END of the login wormhole (gmSmartBoxUI::UseTime + // @0x004D745D -> CPlayerSystem::SendLoginCompleteNotification + // @0x00562E90), and the login portal-space presentation now + // runs on this host, so the send rides its + // FireLoginComplete edge (LocalPlayerTeleportController's + // login pump) β€” the same edge the F751 pump already uses. + // This notification is the presentation's worldReady latch: + // the canonical local-player first placement committed + // (retail's position_update_complete=1 analogue, + // SmartBox::UseTime @0x00455483). + _world.Teleport.OnLocalPlayerFirstEntryCompleted(); // Night-round review F2: CM_House::Event_QueryHouse @0x006aaa00 // is tail-called, unconditionally, from the end of - // CPlayerSystem::InitializePlayer @0x00563570 β€” the SAME - // once-per-session function AttemptSendLoginCompleteNotification - // lives in (guarded by player_initialized), right after that - // notification. This is the graphical host's direct - // (non-portal) first-entry completion edge β€” the exact - // analogue. Portal-space re-entries (LocalPlayerTeleportController) - // do NOT resend it, matching retail's single-shot guard. + // CPlayerSystem::InitializePlayer @0x00563570 (guarded by + // player_initialized, once per session) β€” an object-arrival + // edge, NOT a tunnel edge, so it stays at first-entry + // completion rather than moving with LoginComplete. session.SendHouseQuery(); }, _world.AcceptedPositionDrive, diff --git a/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs b/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs index ec960380..9b447e78 100644 --- a/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs +++ b/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs @@ -25,6 +25,21 @@ internal interface ILocalPlayerTeleportNetworkSink RuntimeTeleportDestination destination, bool teleportTimestampAdvanced); + /// + /// Enter-world round (2026-08-17): the graphical host's local-player + /// first-entry conductor completed its canonical initial placement. + /// This is the login analogue of retail's + /// SmartBox::UseTime @ 0x00455410 setting + /// position_update_complete = 1 β€” the fact that lets the login + /// portal-space presentation leave its Tunnel hold. LoginComplete + /// (0xA1) itself now rides the presentation's own + /// edge, matching + /// retail's send at the WorldFadeIn end + /// (gmSmartBoxUI::UseTime @ 0x004D745D β†’ + /// CPlayerSystem::SendLoginCompleteNotification @ 0x00562E90). + /// + void OnLocalPlayerFirstEntryCompleted(); + void ResetSession(); void ResetGenerationPresentation(); @@ -65,6 +80,9 @@ internal sealed class DeferredLocalPlayerTeleportNetworkSink bool teleportTimestampAdvanced) => Required().OfferDestination(destination, teleportTimestampAdvanced); + public void OnLocalPlayerFirstEntryCompleted() => + Required().OnLocalPlayerFirstEntryCompleted(); + public void ResetSession() => Required().ResetSession(); public void ResetGenerationPresentation() => @@ -102,6 +120,19 @@ internal interface ILocalPlayerTeleportModeOperations PlayerMovementController? Controller { get; } Matrix4x4 Projection { get; } bool TryEnterPortalSpace(); + + /// + /// Enter-world round (2026-08-17): the LOGIN arm's portal-space entry. + /// Unlike β€” whose F751 callers run when + /// player-mode presentation is already attached (or deliberately absent, + /// e.g. fly mode) β€” the login tunnel begins BEFORE any player-mode entry + /// has happened, so this operation must also perform the same + /// presentation attach the post-reveal auto-entry used to do (chase + /// camera, shadow, animation sinks) before flipping the controller into + /// portal space. Refuses (retryable) until the Runtime first-entry + /// conductor has published the movement controller. + /// + bool TryEnterPortalSpaceForLogin(); void EnterWorld(); } @@ -449,6 +480,37 @@ internal sealed class LocalPlayerTeleportController private long _lifetimeGeneration; private bool _disposed; + /// + /// Enter-world round (2026-08-17): the login reveal generation this + /// controller's presentation currently owns (0 = none). Retail runs the + /// SAME wormhole machine at initial login as at an F751 teleport β€” + /// SmartBox::teleport_in_progress @ 0x00451C20 returns 1 whenever + /// the SmartBox has a player whose position_update_complete is + /// still 0, which is true the moment the login CreatePlayer lands, and + /// gmSmartBoxUI::UseTime @ 0x004D6EAB edge-detects that flag into + /// BeginTeleportAnimation(TAS_TUNNEL) @ 0x004D6EC9 (which plays + /// Sound_UI_EnterPortal @ 0x004D638E) with no F751 involved. + /// acdream's canonical equivalent of that condition is Runtime's login + /// reveal (, + /// begun on the first accepted local-player position) β€” so this arm keys + /// the same presentation off that Runtime-owned lifecycle instead of a + /// teleport start. + /// + private long _loginRevealGeneration; + private bool _loginPresentationActive; + + /// + /// Latched by β€” the + /// first-entry conductor's canonical initial placement committed. The + /// login pump's worldReady requires it, so the sequencer cannot + /// leave its Tunnel hold (and therefore cannot reach + /// ) before the same + /// placement contract that previously gated the immediate LoginComplete + /// send. Session-scoped: cleared only by session-level resets. + /// + private bool _loginPlacementCompleted; + private float _loginHoldSeconds; + public LocalPlayerTeleportController( ILocalPlayerTeleportAuthority authority, ILocalPlayerTeleportInputLifetime input, @@ -476,8 +538,35 @@ internal sealed class LocalPlayerTeleportController public bool IsActive => _transit.IsTeleportActive; public bool IsPortalViewportVisible => _presentation.IsPortalViewportVisible; - public uint ActiveDestinationCell => - _transit.IsTeleportActive ? _pendingCell : 0u; + + /// + /// The destination the portal viewport is currently holding for. The + /// render frame's reveal-preparation arm + /// () + /// keys composite-texture preparation and readiness evaluation off this + /// cell; the login presentation must report its own destination here + /// because entering portal space flips ChaseModeEverEntered, which + /// retires the fallback "waiting for login" cell source that used to keep + /// preparation running pre-entry. + /// + public uint ActiveDestinationCell + { + get + { + if (_transit.IsTeleportActive) + return _pendingCell; + if (_loginPresentationActive) + { + RuntimePortalSnapshot snapshot = _transit.Snapshot; + if (snapshot.Kind == RuntimePortalKind.Login + && snapshot.Generation == _loginRevealGeneration) + { + return snapshot.Readiness.DestinationCell; + } + } + return 0u; + } + } public void OnTeleportStarted(uint sequence) { @@ -515,13 +604,22 @@ internal sealed class LocalPlayerTeleportController TryAimAcceptedDestination(); } + public void OnLocalPlayerFirstEntryCompleted() + { + ThrowIfDisposed(); + _loginPlacementCompleted = true; + } + public void Tick(float deltaSeconds) { ThrowIfDisposed(); TryActivatePendingPresentation(); TryAimAcceptedDestination(); if (!_transit.IsTeleportActive) + { + TickLoginPresentation(deltaSeconds); return; + } long generation = _lifetimeGeneration; ushort sequence = _transit.ActiveTeleportSequence; @@ -902,6 +1000,225 @@ internal sealed class LocalPlayerTeleportController + $"(seq={_transit.ActiveTeleportSequence})"); } + /// + /// Enter-world round (2026-08-17): the login half of retail's ONE + /// wormhole machine. Retail begins the identical TAS_TUNNEL animation for + /// initial login and for F751 teleports from the same + /// gmSmartBoxUI::UseTime @ 0x004D6EAB flag edge β€” + /// SmartBox::teleport_in_progress @ 0x00451C20 goes high the + /// moment the login player object exists with + /// position_update_complete == 0, no F751 required. acdream's + /// canonical login edge is Runtime's login reveal generation + /// (, begun on the + /// first accepted local-player position on every entry route: direct + /// auto-select, character-select Enter, and enter-after-create). + /// + /// + /// Activation retries every Tick until + /// + /// succeeds β€” the same retry shape the F751 arm uses β€” which requires the + /// Runtime first-entry conductor's published movement controller. + /// Entering portal space cancels the player-mode auto-entry (its first + /// statement), so this arm owns the reveal's + /// EnterWorld/LoginComplete/Complete suffix exactly like the teleport + /// pump owns its own; the update-frame order (teleport phase before + /// auto-entry) guarantees this claim happens before auto-entry could + /// fire. + /// + /// + private void TryActivateLoginPresentation() + { + if (_transit.HasPendingTeleportStart || _transit.IsTeleportActive) + return; + + RuntimePortalSnapshot snapshot = _transit.Snapshot; + if (snapshot.Kind != RuntimePortalKind.Login + || snapshot.Generation == 0 + || snapshot.Completed + || snapshot.Cancelled + || _loginRevealGeneration == snapshot.Generation) + { + return; + } + + // Quiet pre-gate: until the first-entry conductor PUBLISHES the + // movement controller, portal-space entry cannot succeed (and the + // full entry path would log a refusal every tick). Retry silently. + if (_mode.Controller is not { CanExecuteLiveMovement: true }) + return; + + long generation = _lifetimeGeneration; + if (!_mode.TryEnterPortalSpaceForLogin() + || _lifetimeGeneration != generation + || _mode.Controller is null) + { + return; + } + + // Re-read after the mode entry: TryEnterPortalSpaceForLogin can run + // arbitrary presentation attach work. + snapshot = _transit.Snapshot; + if (snapshot.Kind != RuntimePortalKind.Login + || snapshot.Generation == 0 + || snapshot.Completed + || snapshot.Cancelled) + { + return; + } + + _loginRevealGeneration = snapshot.Generation; + _loginPresentationActive = true; + _loginHoldSeconds = 0f; + _presentation.Begin(_mode.Projection); + Console.WriteLine( + $"live: login portal-space presentation started " + + $"(gen={snapshot.Generation} " + + $"cell=0x{snapshot.Readiness.DestinationCell:X8})"); + } + + /// + /// Per-frame pump for the login presentation β€” the login mirror of the + /// teleport pump in . Differences, each anchored in + /// retail: there is no Place edge to drive (the first-entry conductor + /// committed the canonical placement before this presentation could + /// begin β€” retail's SmartBox::UseTime @ 0x00455483 likewise only + /// flips position_update_complete, it does not place), and + /// LoginComplete rides + /// at the WorldFadeIn end (gmSmartBoxUI::UseTime @ 0x004D745D β†’ + /// CPlayerSystem::SendLoginCompleteNotification @ 0x00562E90) + /// instead of at raw first placement. + /// + private void TickLoginPresentation(float deltaSeconds) + { + TryActivateLoginPresentation(); + + RuntimePortalSnapshot snapshot = _transit.Snapshot; + bool revealActive = snapshot.Kind == RuntimePortalKind.Login + && snapshot.Generation != 0 + && !snapshot.Completed + && !snapshot.Cancelled; + if (!revealActive || _loginRevealGeneration != snapshot.Generation) + { + if (_loginPresentationActive) + { + // The reveal this presentation was serving ended underneath + // it (cancel, supersession, or session reset that did not + // route through this controller's own reset). Drop the claim + // and retire the visuals; a successor reveal re-activates + // through TryActivateLoginPresentation above. + _loginRevealGeneration = 0; + _loginPresentationActive = false; + _loginHoldSeconds = 0f; + _presentation.Reset(); + } + return; + } + + if (!_loginPresentationActive) + return; + + long generation = _lifetimeGeneration; + long revealGeneration = snapshot.Generation; + uint destinationCell = snapshot.Readiness.DestinationCell; + + bool originReady = !_streaming.IsRecenterPending; + bool worldReady = _loginPlacementCompleted + && originReady + && _worldReveal.Evaluate(destinationCell).IsReady; + if (!IsCurrentLoginLifetime(generation, revealGeneration)) + return; + + if (!worldReady) + _loginHoldSeconds += deltaSeconds; + _presentation.SetWaitCue( + !worldReady + && _worldReveal.ObserveWait( + TimeSpan.FromSeconds(_loginHoldSeconds))); + + var (_, events) = _presentation.Tick(deltaSeconds, worldReady); + if (!IsCurrentLoginLifetime(generation, revealGeneration)) + return; + + foreach (TeleportAnimEvent teleportEvent in events) + { + switch (teleportEvent) + { + case TeleportAnimEvent.PlayEnterSound: + // Sound_UI_EnterPortal as the animation begins β€” + // BeginTeleportAnimation @ 0x004D638E, identical for the + // login entry. Logged (once per login) as the audio-start + // evidence line for connected gates. + Console.WriteLine( + "live: login portal-space enter cue " + + "(Sound_UI_EnterPortal)"); + _presentation.PlayEnterCue(); + if (!IsCurrentLoginLifetime(generation, revealGeneration)) + return; + break; + case TeleportAnimEvent.EnterTunnel: + _presentation.EnterTunnel(); + if (!IsCurrentLoginLifetime(generation, revealGeneration)) + return; + break; + case TeleportAnimEvent.Place: + // No login Place edge: the canonical initial placement is + // the first-entry conductor's, already committed (the + // worldReady latch above requires it). Retail's login + // analogue only flips position_update_complete + // (SmartBox::UseTime @ 0x00455483). + break; + case TeleportAnimEvent.PlayExitSound: + // Release destination cell blocking at the exact + // portal/world viewport swap β€” same edge as the teleport + // pump (gmSmartBoxUI::UseTime @ 0x004D6E30), with + // Sound_UI_ExitPortal @ 0x004D7405. + _worldReveal.RevealWorldViewport(); + if (!IsCurrentLoginLifetime(generation, revealGeneration)) + return; + _presentation.PlayExitCue(); + if (!IsCurrentLoginLifetime(generation, revealGeneration)) + return; + _presentation.ExitTunnel(); + if (!IsCurrentLoginLifetime(generation, revealGeneration)) + return; + break; + case TeleportAnimEvent.FireLoginComplete: + _mode.EnterWorld(); + if (!IsCurrentLoginLifetime(generation, revealGeneration)) + return; + _session.SendLoginComplete(); + if (!IsCurrentLoginLifetime(generation, revealGeneration)) + return; + _worldReveal.Complete(); + _loginRevealGeneration = 0; + _loginPresentationActive = false; + _loginHoldSeconds = 0f; + Console.WriteLine( + "live: login portal-space presentation complete"); + return; + default: + break; + } + } + + _presentation.TickTunnel(deltaSeconds); + } + + /// + /// The login pump's currency check β€” the login mirror of + /// : same controller + /// lifetime, still no active teleport (an F751 supersedes the login + /// presentation), and the transit snapshot still carries the exact + /// claimed login reveal generation. + /// + private bool IsCurrentLoginLifetime( + long lifetimeGeneration, + long revealGeneration) => + _lifetimeGeneration == lifetimeGeneration + && !_transit.IsTeleportActive + && _loginRevealGeneration == revealGeneration + && _transit.Snapshot.Generation == revealGeneration; + private void TryAimAcceptedDestination() { if (!_transit.TryGetAcceptedTeleportDestination( @@ -1030,6 +1347,18 @@ internal sealed class LocalPlayerTeleportController _placementCommitted = false; _awaitingDeferredWake = false; _holdSeconds = 0f; + // Enter-world round (2026-08-17): an F751 arriving mid-login-tunnel + // (clearSession: false) withdraws the login presentation's claim β€” + // the portal pump supersedes it and owns the single LoginComplete, + // exactly as retail's one teleportInProgress flag stays high across + // both and sends once at the eventual WorldFadeIn end. The + // placement-completed latch is a session fact: it survives the + // teleport-scoped reset and clears only with the session. + _loginRevealGeneration = 0; + _loginPresentationActive = false; + _loginHoldSeconds = 0f; + if (clearSession) + _loginPlacementCompleted = false; _streaming.ResetRecenter(clearSession); if (_lifetimeGeneration != generation) diff --git a/src/AcDream.App/UI/Layout/CharacterManagementUiController.cs b/src/AcDream.App/UI/Layout/CharacterManagementUiController.cs index 963d5e23..f42e241a 100644 --- a/src/AcDream.App/UI/Layout/CharacterManagementUiController.cs +++ b/src/AcDream.App/UI/Layout/CharacterManagementUiController.cs @@ -580,6 +580,13 @@ internal sealed class CharacterManagementUiController : IDisposable // remains authoritative and closes it on InWorld/error/reset. EnsureEnterWait(); RuntimeCommandResult result = _bindings.Enter(); + // Enter-world round (2026-08-17): once-per-click outcome line β€” a + // refused Enter was previously indistinguishable from a click that + // never dispatched (both silent), which cost a full connected-gate + // round to tell apart. + Console.WriteLine(result.Accepted + ? "[UI] character enter accepted" + : $"[UI] character enter rejected status={result.Status}"); if (!result.Accepted) CloseContext(ref _enterWaitContext, suppressCallback: true); InvalidateAndTick(); diff --git a/src/AcDream.App/UI/Layout/CharacterOptionsPageController.cs b/src/AcDream.App/UI/Layout/CharacterOptionsPageController.cs index 6e7158f0..d4ee0084 100644 --- a/src/AcDream.App/UI/Layout/CharacterOptionsPageController.cs +++ b/src/AcDream.App/UI/Layout/CharacterOptionsPageController.cs @@ -111,7 +111,7 @@ public static class CharacterOptionsPageController /// steps 13-18, written after the code landed): /// /// - /// NOT dimmed (16 rows, real acdream-side + /// NOT dimmed (17 rows, real acdream-side /// consumer): Group B rows actually bound β€” /// ViewCombatTarget/AutoTarget/AutoRepeatAttack /// (combat: LiveCombatAttackOperations.cs, @@ -124,15 +124,20 @@ public static class CharacterOptionsPageController /// DragItemOnPlayerOpensSecureTrade (TS-48, /// InteractionRetainedUiComposition.cs:326), all six /// ListenTo*Chat ids (TurbineChatMembershipGate.cs:105-136 - /// gates every Turbine room join on the matching bit), and β€” landed at + /// gates every Turbine room join on the matching bit), β€” landed at /// Campaign FA slice FA4, D7, SURVIVING the fix-round correction below /// β€” FellowshipShareXP (the Create-flow click genuinely reads - /// it as the sent shareXP bit, SocialFellowshipPageController.WireButtons). - /// Dimmed (34 rows, store-only): every + /// it as the sent shareXP bit, SocialFellowshipPageController.WireButtons), + /// and β€” the vitals retail-modes round, 2026-08-17 β€” + /// SideBySideVitals (VitalsSideBySideController swaps the + /// stacked 0x2100006C / side-by-side 0x21000075 vitals windows on the + /// bit, retail's gmGamePlayUI::RecvNotice_PlayerOptionChanged + /// @0x004E9DA0). + /// Dimmed (33 rows, store-only): every /// remaining Group A row (wire+store only β€” ACE, not acdream, is the /// consumer) and every remaining Group B row the OP4 gate script's own /// step 16 lists as "no consumer surface" (ShowTooltips, - /// SideBySideVitals, SpellDuration, + /// SpellDuration, /// AdvancedCombatUI, StayInChatMode, /// DisableMostWeatherEffects, PersistentAtDay, /// FilterLanguage, MainPackPreferred), plus every Group D @@ -181,9 +186,9 @@ public static class CharacterOptionsPageController /// /// /// Net: 35 (pre-FA4) β†’ FA4 shipped 31 β†’ fix round reverts three of the - /// four un-dims (Ignore, AutoAccept, ShareLoot) β†’ 34 of 50 dimmed / - /// 16 live, ONE net un-dim from pre-FA4 baseline - /// (FellowshipShareXP only). + /// four un-dims (Ignore, AutoAccept, ShareLoot) β†’ 34 dimmed / 16 live β†’ + /// the vitals retail-modes round (2026-08-17) un-dims + /// SideBySideVitals β†’ 33 of 50 dimmed / 17 live. /// /// Flagged ambiguity, resolved by code evidence /// (see final report, not re-litigated here): the research doc's @@ -226,7 +231,7 @@ public static class CharacterOptionsPageController new(CharacterOptionId.VividTargetingIndicator, "VividTargetingIndicator", Live), // Group C new(CharacterOptionId.ShowTooltips, "ShowTooltips", StoreOnly), // Group B, unbound new(CharacterOptionId.CoordinatesOnRadar, "CoordinatesOnRadar", Live), // Group C - new(CharacterOptionId.SideBySideVitals, "SideBySideVitals", StoreOnly), // Group B, unbound + new(CharacterOptionId.SideBySideVitals, "SideBySideVitals", Live), // vitals retail-modes round (2026-08-17): consumed by VitalsSideBySideController β€” the bit swaps the stacked (0x2100006C) and side-by-side (0x21000075) vitals windows live, retail's gmGamePlayUI::RecvNotice_PlayerOptionChanged @0x004E9DA0 new(CharacterOptionId.SpellDuration, "SpellDuration", StoreOnly), // Group B, unbound new(CharacterOptionId.DisableMostWeatherEffects, "DisableMostWeatherEffects", StoreOnly), // Group B, unbound new(CharacterOptionId.DisableDistanceFog, "DisableDistanceFog", Live), // Group B, bound (GameWindow.cs:657) diff --git a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs index 2a636103..a037ed75 100644 --- a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs +++ b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs @@ -27,10 +27,12 @@ namespace AcDream.App.UI.Layout; /// /// /// -/// The expand-detail overlay present in the front container carries ONLY named -/// states ("HideDetail"/"ShowDetail") β€” no "" DirectState entry β€” so the -/// TryGetValue("") filter in excludes it -/// automatically. +/// The expand-detail icon overlay present in EACH container (back + front) +/// carries ONLY named states ("HideDetail"/"ShowDetail") β€” no "" DirectState +/// entry β€” so the TryGetValue("") filter in +/// excludes it from slice extraction; then absorbs +/// it into for the retail +/// HideDetail/ShowDetail click toggle. /// /// public static class DatWidgetFactory @@ -76,6 +78,15 @@ public static class DatWidgetFactory UiElement e = info.Type switch { UiRadar.RetailClassId => new UiRadar(), // gmRadarUI (Register 0x004D8B80) + // The vitals window roots β€” gmVitalsUI (0x10000009 @0x004BFE10), + // gmFloatyVitalsUI (0x1000004D, stacked 0x2100006C), and + // gmFloatySideVitalsUI (0x10000056, side-by-side 0x21000075). All + // three share the inherited HideDetail/ShowDetail press toggle + // (gmVitalsUI::ListenToElementMessage @0x004BFC00) β€” see UiVitalsRoot. + UiVitalsRoot.GmVitalsClassId + or UiVitalsRoot.GmFloatyVitalsClassId + or UiVitalsRoot.GmFloatySideVitalsClassId + => new UiVitalsRoot(info, resolve), 1 => BuildButton(info, resolve, elementFont, fontResolve, stringResolve), // UIElement_Button 2 => new UiDatElement(info, resolve) // UIElement_Dragbar (Register @ 0x0046C840) { @@ -446,8 +457,11 @@ public static class DatWidgetFactory /// β”‚ β”œβ”€β”€ left-cap image (β†’ front-left sprite) /// β”‚ β”œβ”€β”€ center image (β†’ front-tile sprite) /// β”‚ β”œβ”€β”€ right-cap image (β†’ front-right sprite) - /// β”‚ └── expand overlay (named "ShowDetail"/"HideDetail" only β€” NO DirectState β€” IGNORED) - /// └── text label (Type 0) (IGNORED β€” Fill/Label providers bound by VitalsController) + /// β”‚ └── expand overlay (named "ShowDetail"/"HideDetail" only β€” NO DirectState β€” + /// β”‚ absorbed as the bright fill-clipped detail icon; the back + /// β”‚ container has a matching dim one β€” see ConfigureDetailOverlay) + /// └── text label (Type 0) (built as a real UiText child by LayoutImporter's meter + /// carve-out; Fill/Label providers bound by VitalsController) /// /// /// @@ -508,6 +522,35 @@ public static class DatWidgetFactory m.FrontLeft = fl; m.FrontTile = ft; m.FrontRight = fr; + + // The expand-detail icon overlays (the 0x100004A9 child of EACH + // container β€” dim icon in the back track, bright icon in the + // fill-clipped front layer; health heart 0x06007490/91, stamina + // sword 0x06007492/93, mana scepter 0x06007494/95). They author + // media only for the named ShowDetail state (HideDetail authors + // File=0), which is why SliceIds' DirectState filter already + // excludes them from the slice extraction above. Absorb them into + // the meter alongside the slices so the HideDetail/ShowDetail + // click toggle (gmVitalsUI::ListenToElementMessage @0x004BFC00) + // can draw retail's graphical vitals mode. PassToChildren on the + // meter's own media-less HideDetail/ShowDetail StateDescs gates + // the cascade to the absorbed number label + // (UIElement::SetState @0x00464E70). + ElementInfo? backOverlay = DetailOverlay(containers[0]); + ElementInfo? frontOverlay = DetailOverlay(containers[1]); + if (backOverlay is not null || frontOverlay is not null) + { + bool passToChildren = info.States.Values.Any( + static s => s.Name is "HideDetail" or "ShowDetail" && s.PassToChildren); + m.ConfigureDetailOverlay( + backOverlay is not null ? backOverlay.StateMedia["ShowDetail"].File : 0u, + backOverlay?.X ?? 0f, backOverlay?.Y ?? 0f, + backOverlay?.Width ?? 0f, backOverlay?.Height ?? 0f, + frontOverlay is not null ? frontOverlay.StateMedia["ShowDetail"].File : 0u, + frontOverlay?.X ?? 0f, frontOverlay?.Y ?? 0f, + frontOverlay?.Width ?? 0f, frontOverlay?.Height ?? 0f, + passToChildren); + } } else if (containers.Count == 1 && containers[0].StateMedia.ContainsKey("")) { @@ -617,6 +660,18 @@ public static class DatWidgetFactory => container.Children.Count(c => c.StateMedia.TryGetValue("", out var media) && media.File != 0) >= 3; + /// + /// Finds a container's expand-detail icon overlay: the child that authors a + /// non-zero image for the named ShowDetail state and NO DirectState image + /// (the 0x100004A9 shape in both vitals layouts 0x2100006C / 0x21000075). + /// Returns null when the container has none (non-vitals meters). + /// + private static ElementInfo? DetailOverlay(ElementInfo container) + => container.Children.FirstOrDefault(static c => + !c.StateMedia.ContainsKey("") + && c.StateMedia.TryGetValue("ShowDetail", out var media) + && media.File != 0); + private static bool HasStatefulFill(ElementInfo container) => container.States.Any(pair => pair.Key != UiStateInfo.DirectStateId diff --git a/src/AcDream.App/UI/Layout/LayoutImporter.cs b/src/AcDream.App/UI/Layout/LayoutImporter.cs index 32cbaa16..6bc56c04 100644 --- a/src/AcDream.App/UI/Layout/LayoutImporter.cs +++ b/src/AcDream.App/UI/Layout/LayoutImporter.cs @@ -181,11 +181,15 @@ public static class LayoutImporter // widgets via FindElement and bind LinesProvider without injecting new runtime nodes. // // Type-3 children are SKIPPED here because BuildMeter already consumed them (they - // carry the 3-slice sprite ids, not text content; building them again would - // double-draw the bar art). All other child types are built normally. + // carry the 3-slice sprite ids + the ShowDetail icon overlays, not text content; + // building them again would double-draw the bar art). All other child types are + // built normally. // - // Safe for vitals: the health/stamina/mana meters have ONLY Type-3 slice children - // (no text children). This loop finds nothing for them β†’ no change to vitals. + // For vitals this loop builds exactly the cur/max number label (0x100000EB/ED/EF, + // Type 0 β†’ merged Type 12 β†’ UiText): VitalsController binds its LinesProvider, and + // the HideDetail/ShowDetail cascade from the window root reaches it THROUGH the + // meter (UiMeter.TrySetRetailState forwards to stateful children), flipping its + // per-state 0x3B visibility exactly like retail's PassToChildren SetState walk. foreach (var child in info.Children) { if (child.Type == 3) continue; // slice containers: already consumed by BuildMeter diff --git a/src/AcDream.App/UI/Layout/UiVitalsRoot.cs b/src/AcDream.App/UI/Layout/UiVitalsRoot.cs new file mode 100644 index 00000000..8afb068c --- /dev/null +++ b/src/AcDream.App/UI/Layout/UiVitalsRoot.cs @@ -0,0 +1,95 @@ +using System; + +namespace AcDream.App.UI.Layout; + +/// +/// Behavioral root widget for the vitals windows β€” retail's gmVitalsUI +/// family (gmVitalsUI 0x10000009, gmFloatyVitalsUI 0x1000004D = +/// the stacked window LayoutDesc 0x2100006C, gmFloatySideVitalsUI +/// 0x10000056 = the side-by-side window LayoutDesc 0x21000075; registrations +/// @0x004BFE10 / @0x004CED90 / @0x004D0490). +/// +/// +/// The click toggle (gmVitalsUI::ListenToElementMessage @0x004BFC00, +/// inherited verbatim by both floaty subclasses): on Element_mouse_press +/// (0x1C) with dwParam1 7 (left) or 0xA (right) the root flips +/// SetState(m_state == HideDetail ? ShowDetail : HideDetail). +/// State semantics from the authored data + UIElement::OnSetAttribute +/// case 8 (0x3B = invisible, SetVisible(value == 0) @0x00462DAE): +/// +/// +/// Undef (login default; DefaultState is authored +/// Undef and nothing calls SetState at init): numbers visible, no icons β€” +/// visually identical to HideDetail. +/// HideDetail (0x10000006): the numeric mode β€” the +/// cur/max labels author {0x3B:false} (visible); the icon overlays author +/// File=0 (nothing). +/// ShowDetail (0x10000007): the graphical mode β€” +/// labels author {0x3B:true} (hidden); each bar shows its authored icon pair +/// (dim back + bright fill-clipped front: heart / sword / scepter). +/// +/// +/// +/// The first press from Undef lands on HideDetail (retail's exact expression: +/// ecx = (m_state == 0x10000006); SetState(ecx + 0x10000006) β€” any +/// state that is not HideDetail, including the initial Undef, goes to +/// HideDetail first), so the first click appears to do nothing and the second +/// enters icon mode. NOT persisted anywhere: gmGamePlayUI::SaveScreenLayout +/// @0x004EAD50 writes only window rects, and no PlayerModule option is +/// touched β€” the mode resets to Undef every session, per window. +/// +/// +/// +/// Press routing: retail broadcasts the press to the pressed element +/// and forwards up the parent chain (UIElement::ListenToElementMessage +/// @0x00462340 β†’ ForwardElementMessage), but UIElement_Dragbar +/// (@0x0046C850) and UIElement_Resizebar (@0x0046B930) both return 2 +/// (consumed) unconditionally β€” a press that starts a window move or resize +/// never reaches the vitals root. Mirrored here: presses whose hit target is a +/// move handle or resize grip are ignored. The handler returns false so the +/// event keeps bubbling, matching retail's fall-through to the base handler. +/// +/// +public sealed class UiVitalsRoot : UiDatElement +{ + /// gmVitalsUI registered element class (@0x004BFE1A). + public const uint GmVitalsClassId = 0x10000009u; + /// gmFloatyVitalsUI registered element class (@0x004CED9A) β€” the stacked window root. + public const uint GmFloatyVitalsClassId = 0x1000004Du; + /// gmFloatySideVitalsUI registered element class (@0x004D049A) β€” the side-by-side window root. + public const uint GmFloatySideVitalsClassId = 0x10000056u; + + public UiVitalsRoot(ElementInfo info, Func resolve) + : base(info, resolve) + { + } + + public override bool OnEvent(in UiEvent e) + { + if (e.Type is UiEventType.MouseDown or UiEventType.RightDown + && !PressConsumedByChrome(e.Target)) + { + // gmVitalsUI::ListenToElementMessage @0x004BFC04: + // this->SetState(m_state == HideDetail ? ShowDetail : HideDetail) + // then falls through to the base handler (keep bubbling). + TrySetRetailState( + ActiveRetailStateId == RetailUiStateIds.HideDetail + ? RetailUiStateIds.ShowDetail + : RetailUiStateIds.HideDetail); + } + return base.OnEvent(in e); + } + + /// + /// True when the pressed element is (or sits inside) a window-move handle + /// or resize grip β€” the two retail element classes that consume the press + /// before it can bubble to the vitals root. + /// + private bool PressConsumedByChrome(UiElement? target) + { + for (UiElement? w = target; w is not null && w != this; w = w.Parent) + if (w.WindowMoveHandle || w is UiResizeGrip) + return true; + return false; + } +} diff --git a/src/AcDream.App/UI/Layout/VitalsSideBySideController.cs b/src/AcDream.App/UI/Layout/VitalsSideBySideController.cs new file mode 100644 index 00000000..da74c597 --- /dev/null +++ b/src/AcDream.App/UI/Layout/VitalsSideBySideController.cs @@ -0,0 +1,84 @@ +using System; + +namespace AcDream.App.UI.Layout; + +/// +/// Owns which of the two vitals windows is visible β€” retail's +/// "Side By Side Vitals" character option (Options β†’ Character tab). +/// +/// +/// Retail authors TWO complete vitals windows and swaps their visibility on +/// the option bit; nothing is rearranged in place: +/// +/// +/// gmFloatyVitalsUI (LayoutDesc 0x2100006C, the +/// stacked 160x58 window): UpdateFromPlayerModule @0x004CF140 β€” +/// visible iff PlayerModule::SideBySideVitals() == 0. +/// gmFloatySideVitalsUI (LayoutDesc 0x21000075, the +/// 460x26 single-row window): UpdateFromPlayerModule @0x004D0810 β€” +/// visible iff the bit is set. +/// Live apply: gmGamePlayUI::RecvNotice_PlayerOptionChanged +/// @0x004E9DA0 flips both windows the moment the +/// SideBySideVitals_PlayerOption (0x13) changes; both +/// UpdateFromPlayerModule bodies re-apply on PlayerDescReceived. +/// The bit is PlayerModule::SideBySideVitals @0x005D3070 +/// = (options_ >> 0x15) & 1 β€” CharacterOptions1 0x00200000, +/// option id 0x13, riding the 0x01A1 PlayerModule blob (not a 0x0005 +/// auto-save id) β€” CharacterOptionTable row confirmed against ACE. +/// +/// +/// +/// acdream shape: the option lives in the J4 Runtime owner +/// (RuntimeCharacterState.Options); this controller polls the borrowed +/// bit once per frame from RetailUiRuntime.Tick (the OP campaign's +/// established live-apply seam) and applies BOTH windows' visibility on the +/// edge β€” covering the mount-time default, the PlayerModule blob arriving +/// after mount, and the Character tab's live toggle with one mechanism. +/// Both window names ride stateManagedVisibilityWindows so the saved +/// window layout never restores a visibility this option owns. +/// +/// +public sealed class VitalsSideBySideController +{ + private readonly UiRoot _root; + private readonly Func _sideBySideVitals; + private readonly string _stackedWindow; + private readonly string _sideWindow; + private bool? _applied; + + public VitalsSideBySideController( + UiRoot root, + Func sideBySideVitals, + string stackedWindow, + string sideWindow) + { + _root = root ?? throw new ArgumentNullException(nameof(root)); + _sideBySideVitals = sideBySideVitals + ?? throw new ArgumentNullException(nameof(sideBySideVitals)); + _stackedWindow = stackedWindow; + _sideWindow = sideWindow; + } + + /// The last applied bit (null before the first apply). Exposed for tests. + public bool? Applied => _applied; + + public void Tick() + { + bool sideBySide = _sideBySideVitals(); + if (_applied == sideBySide) return; + _applied = sideBySide; + + // Retail's exact swap (RecvNotice_PlayerOptionChanged @0x004E9DBE): + // option ON β†’ stacked hidden, side shown; OFF β†’ the opposite. + if (sideBySide) + { + _root.HideWindow(_stackedWindow); + _root.ShowWindow(_sideWindow); + } + else + { + _root.ShowWindow(_stackedWindow); + _root.HideWindow(_sideWindow); + } + } +} diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index aca63939..dd52aada 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -495,6 +495,7 @@ public sealed class RetailUiRuntime : IDisposable private UiShortcutDigitGraphics? _shortcutDigitGraphics; private ItemCooldownUiController? _itemCooldownController; private VividTargetIndicatorController? _vividTargetIndicator; + private Layout.VitalsSideBySideController? _vitalsSideBySide; private CharacterManagementUiMountCoordinator? _characterManagementMount; private CharacterCreationUiMountCoordinator? _characterCreationMount; private IDisposable? _characterSheetSubscription; @@ -586,6 +587,14 @@ public sealed class RetailUiRuntime : IDisposable WindowNames.JumpPowerbar, WindowNames.ExternalContainer, WindowNames.Vendor, + // Retail owns which vitals window shows via the + // SideBySideVitals character option (both + // UpdateFromPlayerModule bodies @0x004CF140/@0x004D0810 + // apply it unconditionally at login) β€” the saved layout + // must never restore a visibility the option decides. + // See VitalsSideBySideController. + WindowNames.Vitals, + WindowNames.SideVitals, // Trade gate round 2 (2026-08-14): the trade window is // transient β€” visibility belongs to RuntimeTradeState's // open/closed lifecycle, never to the layout file (a @@ -827,6 +836,7 @@ public sealed class RetailUiRuntime : IDisposable { FpsController?.Tick(); _vividTargetIndicator?.Tick(); + _vitalsSideBySide?.Tick(); SpellbookWindowController?.Tick(); AppraisalController?.Tick(deltaSeconds); SpellcastingUiController?.Tick(); @@ -1312,14 +1322,7 @@ public sealed class RetailUiRuntime : IDisposable return; } - VitalsVM vm = _bindings.Vitals.ViewModel; - VitalsController.Bind(layout, - () => vm.HealthPercent, - () => vm.StaminaPercent ?? 0f, - () => vm.ManaPercent ?? 0f, - () => (vm.HealthCurrent, vm.HealthMax) is (uint c, uint m) ? $"{c}/{m}" : "", - () => (vm.StaminaCurrent, vm.StaminaMax) is (uint c, uint m) ? $"{c}/{m}" : "", - () => (vm.ManaCurrent, vm.ManaMax) is (uint c, uint m) ? $"{c}/{m}" : ""); + BindVitalsLayout(layout); RetailWindowFrame.Mount(Host.Root, layout.Root, _bindings.Assets.ResolveSprite, new RetailWindowFrame.Options { @@ -1333,6 +1336,83 @@ public sealed class RetailUiRuntime : IDisposable ContentClickThrough = false, }); Console.WriteLine("[D.2b] retail UI active β€” vitals window from LayoutDesc importer (0x2100006C)."); + + MountSideVitals(); + } + + /// + /// The side-by-side vitals row β€” retail's SECOND complete vitals window + /// (gmFloatySideVitalsUI 0x10000056, LayoutDesc 0x21000075, 460x26: the + /// same three meters + cur/max labels laid out in one row). Retail swaps + /// it with the stacked window on the SideBySideVitals character option + /// (both UpdateFromPlayerModule bodies @0x004CF140/@0x004D0810 + + /// gmGamePlayUI::RecvNotice_PlayerOptionChanged @0x004E9DA0) β€” see + /// , which owns both + /// windows' visibility from the per-frame option poll. Mounted hidden; + /// the controller's first Tick applies the real bit. + /// + /// Element ids (meters 0x100000E6/EC/EE, labels 0x100000EB/ED/EF) + /// and the detail-icon overlays are authored identically to 0x2100006C, + /// so the same and the inherited + /// click toggle apply as-is. Width constraints + /// ride the root's authored 0x3C..0x3F (min 360 / max 3000, fixed 26 + /// height) through . + /// + private void MountSideVitals() + { + ElementInfo? info; + ImportedLayout? layout; + lock (_bindings.Assets.DatLock) + { + info = LayoutImporter.ImportInfos(_bindings.Assets.Dats, 0x21000075u); + layout = info is null ? null : LayoutImporter.Build( + info, + _bindings.Assets.ResolveSprite, + _bindings.Assets.DefaultFont, + _bindings.Assets.ResolveFont); + } + if (info is null || layout is null) + { + Console.WriteLine("[D.2b] side vitals: LayoutDesc 0x21000075 not found β€” SideBySideVitals unavailable."); + return; + } + + BindVitalsLayout(layout); + RetailWindowFrame.Mount(Host.Root, layout.Root, _bindings.Assets.ResolveSprite, + new RetailWindowFrame.Options + { + WindowName = WindowNames.SideVitals, + Chrome = RetailWindowChrome.Imported, + Left = 10f, + Top = 30f, + ResizeX = true, + ResizeY = false, + DatConstraintSource = info, + Visible = false, + ContentClickThrough = false, + }); + _vitalsSideBySide = new Layout.VitalsSideBySideController( + Host.Root, + () => _bindings.Options.CurrentCharacterOption( + (uint)CharacterOptionId.SideBySideVitals), + WindowNames.Vitals, + WindowNames.SideVitals); + Console.WriteLine("[D.2b] side-by-side vitals window from LayoutDesc importer (0x21000075)."); + } + + /// Binds the shared vitals ViewModel providers to a vitals layout β€” + /// both windows (0x2100006C stacked / 0x21000075 side-by-side) author the + /// same meter/label element ids. + private void BindVitalsLayout(ImportedLayout layout) + { + VitalsVM vm = _bindings.Vitals.ViewModel; + VitalsController.Bind(layout, + () => vm.HealthPercent, + () => vm.StaminaPercent ?? 0f, + () => vm.ManaPercent ?? 0f, + () => (vm.HealthCurrent, vm.HealthMax) is (uint c, uint m) ? $"{c}/{m}" : "", + () => (vm.StaminaCurrent, vm.StaminaMax) is (uint c, uint m) ? $"{c}/{m}" : "", + () => (vm.ManaCurrent, vm.ManaMax) is (uint c, uint m) ? $"{c}/{m}" : ""); } private void MountRadar() diff --git a/src/AcDream.App/UI/Testing/RetailUiAutomationProbe.cs b/src/AcDream.App/UI/Testing/RetailUiAutomationProbe.cs index d3ab64c1..3818ca03 100644 --- a/src/AcDream.App/UI/Testing/RetailUiAutomationProbe.cs +++ b/src/AcDream.App/UI/Testing/RetailUiAutomationProbe.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Globalization; +using System.Numerics; using System.Text; using AcDream.Core.Items; @@ -131,6 +132,22 @@ public sealed class RetailUiAutomationProbe return true; } + /// + /// Raw synthetic click at canvas coordinates (vitals retail-modes round, + /// 2026-08-17): the Options panel's Character-tab rows are template + /// instances sharing ONE dat element id per control + /// (0x10000218/0x10000219 for every toggle row), so + /// element-id addressing cannot reach a SPECIFIC row's checkbox β€” a + /// drive script instead reads the row's rect from + /// and clicks its center. Same synthetic route as + /// β€” never the OS cursor. + /// + public bool ClickAtPoint(int x, int y) + { + ClickAt(x, y); + return true; + } + /// /// 2026-08-17 morning gate: synthetic pointer HOVER (no click) at an /// element's center, for rollover/tooltip verification. Deliberately @@ -147,7 +164,9 @@ public sealed class RetailUiAutomationProbe { var target = FindByDatElementId(datElementId); if (target is null) return Fail($"element 0x{datElementId:X8} not found"); - _root.OnMouseMove((int)target.CenterX, (int)target.CenterY); + // Same canvasβ†’window conversion as ClickAt β€” see CanvasToWindow. + (int x, int y) = CanvasToWindow((int)target.CenterX, (int)target.CenterY); + _root.OnMouseMove(x, y); return true; } @@ -282,8 +301,30 @@ public sealed class RetailUiAutomationProbe } } + /// + /// Enter-world round (2026-08-17): element coordinates from the probe's + /// tree walk are CANVAS coordinates, but / + /// OnMouseDown/OnMouseUp take WINDOW coordinates and map + /// windowβ†’canvas internally (UiRoot.MapWindowToCanvas). The two + /// spaces are identical on every ordinary screen (no + /// ), which is why every prior probe + /// gate passed β€” the character-select/chargen screens are the first + /// STRETCHED canvases this apparatus drove, and there the mismatch sent + /// every synthetic click to canvasΒ·(canvas/window), i.e. nowhere near the + /// target element. Element-derived pointer paths therefore convert + /// canvasβ†’window here before touching the root. + /// + private (int x, int y) CanvasToWindow(int x, int y) + { + Vector2 scale = _root.CanvasScale; + return scale == Vector2.One + ? (x, y) + : ((int)MathF.Round(x * scale.X), (int)MathF.Round(y * scale.Y)); + } + private void ClickAt(int x, int y) { + (x, y) = CanvasToWindow(x, y); Advance(16); _root.OnMouseMove(x, y); _root.OnMouseDown(UiMouseButton.Left, x, y); @@ -294,6 +335,8 @@ public sealed class RetailUiAutomationProbe private void DragAt(int startX, int startY, int endX, int endY) { + (startX, startY) = CanvasToWindow(startX, startY); + (endX, endY) = CanvasToWindow(endX, endY); Advance(16); _root.OnMouseMove(startX, startY); _root.OnMouseDown(UiMouseButton.Left, startX, startY); diff --git a/src/AcDream.App/UI/Testing/RetailUiAutomationScriptRunner.cs b/src/AcDream.App/UI/Testing/RetailUiAutomationScriptRunner.cs index 46d0ec37..7a0c9c7c 100644 --- a/src/AcDream.App/UI/Testing/RetailUiAutomationScriptRunner.cs +++ b/src/AcDream.App/UI/Testing/RetailUiAutomationScriptRunner.cs @@ -219,7 +219,7 @@ public sealed class RetailUiAutomationScriptRunner : IDisposable private bool DoClick(ScriptCommand command) { var p = command.Parts; - if (p.Length < 3) return Stop(command, "usage: click element | click item [source]"); + if (p.Length < 3) return Stop(command, "usage: click element | click item [source] | click at "); string target = p[1].ToLowerInvariant(); if (target == "element") { @@ -231,7 +231,19 @@ public sealed class RetailUiAutomationScriptRunner : IDisposable if (!TryParseUInt(p[2], out uint itemGuid)) return Stop(command, $"bad item guid '{p[2]}'"); return _probe.ClickItem(itemGuid, ParseSource(p, 3)) || Stop(command, "click item failed"); } - return Stop(command, "usage: click element | click item [source]"); + if (target == "at") + { + // `click at ` β€” raw synthetic click in canvas coordinates + // (see RetailUiAutomationProbe.ClickAtPoint: template-instanced + // controls share one dat id, so a specific row is addressed by + // the rect its own `dump` line reports). + if (p.Length < 4 + || !TryParseInt(p[2], out int x) + || !TryParseInt(p[3], out int y)) + return Stop(command, "usage: click at "); + return _probe.ClickAtPoint(x, y) || Stop(command, "click at failed"); + } + return Stop(command, "usage: click element | click item [source] | click at "); } /// 2026-08-17 morning gate: `hover element <datId>` β€” a diff --git a/src/AcDream.App/UI/UiMeter.cs b/src/AcDream.App/UI/UiMeter.cs index db74eaaf..6a90d87e 100644 --- a/src/AcDream.App/UI/UiMeter.cs +++ b/src/AcDream.App/UI/UiMeter.cs @@ -25,6 +25,23 @@ public sealed class UiMeter : UiElement, IUiDatStateful private readonly Dictionary _stateLabels = new(); private (string Text, UiMeterLabelAlign Align)? _activeStateLabel; + // Vitals ShowDetail icon overlays (see ConfigureDetailOverlay). + private bool _detailConfigured; + private bool _detailPassToChildren; + private uint _detailBackSprite; + private uint _detailFrontSprite; + private (float X, float Y, float W, float H) _detailBackRect; + private (float X, float Y, float W, float H) _detailFrontRect; + + /// True when this meter absorbed the vitals detail-icon overlays. Exposed for tests. + internal bool HasDetailOverlay => _detailConfigured; + /// The dim back-container detail icon (ShowDetail media). Exposed for tests. + internal uint DetailBackSprite => _detailBackSprite; + /// The bright fill-clipped front-container detail icon. Exposed for tests. + internal uint DetailFrontSprite => _detailFrontSprite; + /// The back overlay's authored meter-local rect. Exposed for tests. + internal (float X, float Y, float W, float H) DetailBackRect => _detailBackRect; + /// Dat element id, set by the layout importer so duplicated page copies can be scoped. public uint ElementId { get; set; } @@ -114,8 +131,54 @@ public sealed class UiMeter : UiElement, IUiDatStateful _stateLabels[stateId] = (text, align); } + /// + /// Registers the vitals "detail" icon overlays absorbed from the meter's + /// two slice containers (the 0x100004A9 children β€” health heart + /// 0x06007490/91, stamina sword 0x06007492/93, mana scepter 0x06007494/95). + /// Retail authors each icon TWICE: a dim version in the BACK container + /// (drawn unclipped over the empty track) and a bright version in the + /// FRONT container (fill-clipped with the rest of the front layer β€” + /// UIElement_Meter::DrawChildren @0x0046FBD0 clips the whole + /// m_pcChildImage child, element id 2, to the 0x69 fraction), so + /// the icon itself fills up with the vital. Both overlays author media + /// ONLY for ShowDetail (HideDetail authors File=0), so they + /// draw solely in that state. Rects are the overlays' authored X/Y/W/H + /// local to the meter (the containers span the meter at 0,0). + /// + internal void ConfigureDetailOverlay( + uint backSprite, float backX, float backY, float backW, float backH, + uint frontSprite, float frontX, float frontY, float frontW, float frontH, + bool passToChildren) + { + _detailBackSprite = backSprite; + _detailBackRect = (backX, backY, backW, backH); + _detailFrontSprite = frontSprite; + _detailFrontRect = (frontX, frontY, frontW, frontH); + _detailConfigured = backSprite != 0 || frontSprite != 0; + _detailPassToChildren = passToChildren; + } + public bool TrySetRetailState(uint stateId) { + // Vitals detail toggle (gmVitalsUI::ListenToElementMessage @0x004BFC00 + // flips the window root between HideDetail 0x10000006 and ShowDetail + // 0x10000007; the meter receives the state through the authored + // PassToChildren cascade β€” UIElement::SetState @0x00464E70). The + // meter's own HideDetail/ShowDetail StateDescs are media-less and + // exist purely to keep propagating (PassToChildren=true), so forward + // to the absorbed text child (whose per-state 0x3B hides the numbers + // in ShowDetail) and let OnDraw key the icon overlays off the state. + if (_detailConfigured + && stateId is RetailUiStateIds.HideDetail or RetailUiStateIds.ShowDetail) + { + ActiveRetailStateId = stateId; + if (_detailPassToChildren) + foreach (UiElement child in Children) + if (child is IUiDatStateful stateful) + stateful.TrySetRetailState(stateId); + return true; + } + bool hasFill = _stateFillSprites.TryGetValue(stateId, out uint spriteId); bool hasLabel = _stateLabels.TryGetValue(stateId, out var caption); if (!hasFill && !hasLabel) @@ -198,9 +261,23 @@ public sealed class UiMeter : UiElement, IUiDatStateful // drawn at FULL width too but horizontally CLIPPED to the fill fraction. // The front carries its own right-cap (shown at 100%); clipping below 100% // removes it and reveals the back track's right-cap β€” retail's scissor-fill. + // + // ShowDetail icon overlays ride their authored containers: the dim BACK + // icon draws over the full track (its 0x100004A9 child draws AFTER the + // three slice children β€” higher ReadOrder within the back container); + // the bright FRONT icon is clipped with the rest of the front layer to + // the fill fraction (retail clips the whole element-id-2 child). + bool detail = _detailConfigured + && ActiveRetailStateId == RetailUiStateIds.ShowDetail; DrawHBar(ctx, resolve, BackLeft, BackTile, BackRight, Width); + if (detail) + DrawDetailIcon(ctx, resolve, _detailBackSprite, _detailBackRect, Width); if (pct is not null && p > 0f) + { DrawHBar(ctx, resolve, FrontLeft, FrontTile, FrontRight, Width * p); + if (detail) + DrawDetailIcon(ctx, resolve, _detailFrontSprite, _detailFrontRect, Width * p); + } } } else @@ -302,6 +379,30 @@ public sealed class UiMeter : UiElement, IUiDatStateful ctx.DrawSprite(tex, 0f, y, w, visibleH, 0f, v0, 1f, v1, System.Numerics.Vector4.One); } + /// + /// Draws one ShowDetail icon overlay at its authored meter-local rect, + /// horizontally clipped to local px (the back icon + /// passes the full width; the front icon passes Width * fraction, + /// mirroring retail's whole-front-container fill clip). The visible portion + /// is UV-cropped so the icon reveals left-to-right with the fill. Height is + /// clamped to the meter's box β€” retail clips children to the parent rect + /// (the stamina FRONT overlay authors H=28 in a 16px bar; retail shows 16). + /// + private void DrawDetailIcon( + UiRenderContext ctx, Func resolve, + uint spriteId, (float X, float Y, float W, float H) rect, float clipW) + { + if (spriteId == 0 || rect.W <= 0f || rect.H <= 0f) return; + var (tex, _, _) = resolve(spriteId); + if (tex == 0) return; + float visibleW = MathF.Min(rect.W, clipW - rect.X); + if (visibleW <= 0f) return; + float h = MathF.Min(rect.H, Height - rect.Y); + if (h <= 0f) return; + float u1 = visibleW / rect.W; + ctx.DrawSprite(tex, rect.X, rect.Y, visibleW, h, 0f, 0f, u1, 1f, Vector4.One); + } + /// Draw a slice over local [, /// pieceX+], with the texture repeating every /// px (UV-repeat β€” the UI texture is GL_REPEAT-wrapped). diff --git a/src/AcDream.App/UI/UiText.cs b/src/AcDream.App/UI/UiText.cs index 5bd2ab0e..1a9f64e3 100644 --- a/src/AcDream.App/UI/UiText.cs +++ b/src/AcDream.App/UI/UiText.cs @@ -386,6 +386,22 @@ public sealed class UiText : UiElement, IUiDatStateful && TryColor(color, out Vector4 resolvedColor)) DefaultColor = resolvedColor; + // 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 + // (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 + && state.Properties.Values.TryGetValue(0x3Bu, out var invisibleProp) + && invisibleProp.Kind == UiPropertyKind.Bool) + Visible = !invisibleProp.BoolValue; + // Retail's state cascade also swaps the element's AUTHORED string // when the incoming state carries its own 0x17 (the friends row's // status cell: 'Online'/'Offline' with per-state colors). Resolved diff --git a/src/AcDream.App/UI/WindowNames.cs b/src/AcDream.App/UI/WindowNames.cs index 0f741703..1d11d7e5 100644 --- a/src/AcDream.App/UI/WindowNames.cs +++ b/src/AcDream.App/UI/WindowNames.cs @@ -5,6 +5,12 @@ namespace AcDream.App.UI; public static class WindowNames { public const string Vitals = "vitals"; + + /// The side-by-side vitals row (retail gmFloatySideVitalsUI, + /// LayoutDesc 0x21000075) β€” shown instead of when the + /// SideBySideVitals character option is set (see + /// ). + public const string SideVitals = "side-vitals"; public const string Toolbar = "toolbar"; public const string Character = "character"; public const string CharacterInformation = "character-information"; diff --git a/tests/AcDream.App.Tests/Physics/LiveEntityNetworkOnPositionCollapseMatrixTests.cs b/tests/AcDream.App.Tests/Physics/LiveEntityNetworkOnPositionCollapseMatrixTests.cs index cb21a238..8d8d99ff 100644 --- a/tests/AcDream.App.Tests/Physics/LiveEntityNetworkOnPositionCollapseMatrixTests.cs +++ b/tests/AcDream.App.Tests/Physics/LiveEntityNetworkOnPositionCollapseMatrixTests.cs @@ -1825,6 +1825,8 @@ public sealed class LiveEntityNetworkOnPositionCollapseMatrixTests bool teleportTimestampAdvanced) { } + public void OnLocalPlayerFirstEntryCompleted() { } + public void ResetSession() { } public void ResetGenerationPresentation() { } diff --git a/tests/AcDream.App.Tests/Physics/LiveEntityNetworkRemoteTeleportPresentationTests.cs b/tests/AcDream.App.Tests/Physics/LiveEntityNetworkRemoteTeleportPresentationTests.cs index 74f03454..1b5eaa34 100644 --- a/tests/AcDream.App.Tests/Physics/LiveEntityNetworkRemoteTeleportPresentationTests.cs +++ b/tests/AcDream.App.Tests/Physics/LiveEntityNetworkRemoteTeleportPresentationTests.cs @@ -1059,6 +1059,8 @@ public sealed class LiveEntityNetworkRemoteTeleportPresentationTests bool teleportTimestampAdvanced) { } + public void OnLocalPlayerFirstEntryCompleted() { } + public void ResetSession() { } public void ResetGenerationPresentation() { } diff --git a/tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs b/tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs index b1f9ae53..e9174380 100644 --- a/tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs +++ b/tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs @@ -911,12 +911,22 @@ public sealed class LocalPlayerTeleportControllerTests public readonly RuntimeLocalPlayerMovementState Movement; public IPreparedCollisionSource DiagnosticCollisionSource => new UnusedCollisionSource(); + /// + /// Enter-world round (2026-08-17): mutable so a login-arm test can + /// flip destination readiness mid-flight (the login tunnel's hold is + /// exactly "readiness not yet true"). Initialized from the ctor's + /// existing worldReady parameter, so every prior test reads + /// identically. + /// + public bool WorldReady; + public Harness( int centerX = 0x20, int centerY = 0x21, bool worldReady = true, List? order = null) { + WorldReady = worldReady; order ??= new List(); Mode = new FakeMode(order); Streaming = new FakeStreaming(centerX, centerY, order); @@ -927,10 +937,10 @@ public sealed class LocalPlayerTeleportControllerTests Reveal = new WorldRevealCoordinator( Transit, revealWindow: () => RevealWindow, - isRenderNeighborhoodReady: (_, _, _) => worldReady, - isSpawnCellReady: _ => worldReady, - isTerrainNeighborhoodReady: (_, _) => worldReady, - areCompositeTexturesReady: () => worldReady, + isRenderNeighborhoodReady: (_, _, _) => WorldReady, + isSpawnCellReady: _ => WorldReady, + isTerrainNeighborhoodReady: (_, _) => WorldReady, + areCompositeTexturesReady: () => WorldReady, prepareCompositeTextures: (_, _) => { }, invalidateCompositeTextures: () => { }, isSpawnClaimUnhydratable: _ => false, @@ -1330,6 +1340,14 @@ public sealed class LocalPlayerTeleportControllerTests return true; } + public int EnterPortalForLoginCount; + + public bool TryEnterPortalSpaceForLogin() + { + EnterPortalForLoginCount++; + return TryEnterPortalSpace(); + } + public void EnterWorld() { _order.Add("enter-world"); @@ -1439,12 +1457,14 @@ public sealed class LocalPlayerTeleportControllerTests private sealed class FakeNetworkSink : ILocalPlayerTeleportNetworkSink { public List Starts { get; } = []; + public int FirstEntryCompletions; public void OnTeleportStarted(uint sequence) => Starts.Add(sequence); public void OfferDestination( RuntimeTeleportDestination destination, bool teleportTimestampAdvanced) { } + public void OnLocalPlayerFirstEntryCompleted() => FirstEntryCompletions++; public void ResetSession() { } @@ -1482,6 +1502,135 @@ public sealed class LocalPlayerTeleportControllerTests Assert.False(harness.Presentation.IsPortalViewportVisible); } + // ── Enter-world round (2026-08-17): the LOGIN portal-space arm ────── + // + // Retail runs the identical TAS_TUNNEL wormhole on initial login β€” + // SmartBox::teleport_in_progress @ 0x00451C20 goes high the moment the + // login player exists with position_update_complete == 0, and + // gmSmartBoxUI::UseTime @ 0x004D6EAB begins the same animation (and the + // same Sound_UI_EnterPortal @ 0x004D638E) it begins for an F751. These + // tests drive the login reveal (RuntimeWorldTransitState.BeginLoginReveal + // via WorldRevealCoordinator.BeginLogin β€” the shared edge of every entry + // route) through the controller's login pump. + + [Fact] + public void LoginReveal_ArmsThePortalSpacePresentation_WithRetailCues() + { + var order = new List(); + var harness = new Harness(worldReady: false, order: order); + + harness.Reveal.BeginLogin(0x20210001u); + Assert.Equal(RuntimePortalKind.Login, harness.Reveal.Snapshot.Kind); + + // First tick: the arm claims the reveal, enters portal space, and + // begins the presentation. + harness.Controller.Tick(0.016f); + Assert.Equal(1, harness.Mode.EnterPortalCount); + Assert.Equal(Matrix4x4.Identity, harness.Presentation.BeginProjection); + Assert.Equal(0x20210001u, harness.Controller.ActiveDestinationCell); + + // Enter cue at animation begin, tunnel viewport on its own edge β€” + // the same event contract as the F751 pump. + harness.Presentation.Enqueue(TeleportAnimEvent.PlayEnterSound); + harness.Controller.Tick(0.016f); + Assert.Equal(["enter"], harness.Presentation.Cues); + + harness.Presentation.Enqueue(TeleportAnimEvent.EnterTunnel); + harness.Controller.Tick(0.016f); + Assert.True(harness.Presentation.IsPortalViewportVisible); + + // Destination not ready and first placement not yet completed: the + // sequencer must keep seeing worldReady == false (its Tunnel hold). + Assert.All(harness.Presentation.WorldReadyValues, value => Assert.False(value)); + Assert.Equal(0, harness.Session.LoginCompleteCount); + + // Readiness alone is not enough β€” the first-entry conductor's + // canonical placement is half of the latch. + harness.WorldReady = true; + harness.Controller.Tick(0.016f); + Assert.False(harness.Presentation.WorldReadyValues[^1]); + + harness.Controller.OnLocalPlayerFirstEntryCompleted(); + harness.Controller.Tick(0.016f); + Assert.True(harness.Presentation.WorldReadyValues[^1]); + + // The login pump has no Place edge of its own: the conductor already + // placed the player (retail: SmartBox::UseTime @ 0x00455483 only + // flips position_update_complete). + harness.Presentation.Enqueue(TeleportAnimEvent.Place); + harness.Controller.Tick(0.016f); + Assert.False(harness.Placement.Called); + + // Viewport swap: reservation release + exit cue + tunnel retire + // (gmSmartBoxUI::UseTime, Sound_UI_ExitPortal @ 0x004D7405). + harness.Presentation.Enqueue(TeleportAnimEvent.PlayExitSound); + harness.Controller.Tick(0.016f); + Assert.Equal(["enter", "exit"], harness.Presentation.Cues); + Assert.False(harness.Presentation.IsPortalViewportVisible); + Assert.Single(harness.Streaming.ReservationEnds); + + // WorldFadeIn end: EnterWorld + the ONE LoginComplete + reveal + // completion (gmSmartBoxUI::UseTime @ 0x004D745D). + harness.Presentation.Enqueue(TeleportAnimEvent.FireLoginComplete); + harness.Controller.Tick(0.016f); + Assert.Contains("enter-world", order); + Assert.Equal(1, harness.Session.LoginCompleteCount); + Assert.True(harness.Reveal.Snapshot.Completed); + Assert.Equal(0u, harness.Controller.ActiveDestinationCell); + + // The completed reveal stays completed; no re-arm, no second send. + harness.Controller.Tick(0.016f); + Assert.Equal(1, harness.Mode.EnterPortalCount); + Assert.Equal(1, harness.Session.LoginCompleteCount); + } + + [Fact] + public void LoginPump_SendsLoginCompleteOnlyAtThePresentationEnd() + { + var harness = new Harness(worldReady: true); + harness.Reveal.BeginLogin(0x20210001u); + harness.Controller.OnLocalPlayerFirstEntryCompleted(); + + // Ticks before the FireLoginComplete edge never send. + for (int i = 0; i < 5; i++) + harness.Controller.Tick(0.016f); + Assert.Equal(0, harness.Session.LoginCompleteCount); + + harness.Presentation.Enqueue(TeleportAnimEvent.FireLoginComplete); + harness.Controller.Tick(0.016f); + Assert.Equal(1, harness.Session.LoginCompleteCount); + Assert.True(harness.Reveal.Snapshot.Completed); + } + + [Fact] + public void RealTeleportStart_SupersedesTheLoginPresentation() + { + var order = new List(); + var harness = new Harness(worldReady: true, order: order); + harness.Reveal.BeginLogin(0x20210001u); + harness.Controller.OnLocalPlayerFirstEntryCompleted(); + harness.Controller.Tick(0.016f); + harness.Presentation.Enqueue(TeleportAnimEvent.EnterTunnel); + harness.Controller.Tick(0.016f); + Assert.True(harness.Presentation.IsPortalViewportVisible); + + // An F751 mid-login-tunnel (ACE can relocate a broken spawn at + // login): the portal pump takes the presentation over and owns the + // single LoginComplete, mirroring retail's one teleportInProgress + // flag spanning both. + harness.Controller.OnTeleportStarted(3); + Assert.Contains("presentation-reset", order); + Assert.True(harness.Controller.IsActive); + Assert.Equal(0, harness.Session.LoginCompleteCount); + + // The login claim is gone: ticking the portal-active controller + // never re-enters the login pump (no second presentation-begin from + // the login arm beyond the teleport activation's own). + int loginCompletes = harness.Session.LoginCompleteCount; + harness.Controller.Tick(0.016f); + Assert.Equal(loginCompletes, harness.Session.LoginCompleteCount); + } + private sealed class FakePresentation : ILocalPlayerTeleportPresentation { private readonly List _order; diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterOptionsPageControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterOptionsPageControllerTests.cs index 10645d1f..b8a0c79e 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterOptionsPageControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterOptionsPageControllerTests.cs @@ -698,7 +698,7 @@ public sealed class CharacterOptionsPageControllerTests // ── AD-78 caption dimming (user-directed, 2026-08-11, gate 2) ─────────── /// - /// The exact 34 values this slice dims + /// The exact 33 values currently dimmed /// ( == true), transcribed independently /// of CharacterOptionsPageController.Groups from the derivation /// table in that class's own doc comment. Wiring a future consumer @@ -720,15 +720,23 @@ public sealed class CharacterOptionsPageControllerTests /// fellowship Create flow reads it as the sent shareXP bit). /// Net: 35 (pre-FA4) β†’ 34 (post-fix-round), one net un-dim. /// + /// + /// + /// The vitals retail-modes round (2026-08-17) un-dims + /// SideBySideVitals: VitalsSideBySideController genuinely + /// READS the bit every frame and swaps the stacked (0x2100006C) / + /// side-by-side (0x21000075) vitals windows β€” retail's + /// gmGamePlayUI::RecvNotice_PlayerOptionChanged @0x004E9DA0. + /// Net: 34 β†’ 33 dimmed / 17 live. + /// /// private static readonly HashSet ExpectedStoreOnlyIds = [ // Group 1 (UI Behavior) β€” SalvageMultiple (D), MainPackPreferred (B, unbound) CharacterOptionId.SalvageMultiple, CharacterOptionId.MainPackPreferred, - // Group 2 (UI Display) β€” 11 of 15 + // Group 2 (UI Display) β€” 10 of 15 (vitals round: SideBySideVitals is live) CharacterOptionId.ShowTooltips, - CharacterOptionId.SideBySideVitals, CharacterOptionId.SpellDuration, CharacterOptionId.DisableMostWeatherEffects, CharacterOptionId.PersistentAtDay, @@ -774,8 +782,8 @@ public sealed class CharacterOptionsPageControllerTests .ToHashSet(); Assert.Equal(ExpectedStoreOnlyIds, actualStoreOnly); - Assert.Equal(34, actualStoreOnly.Count); - Assert.Equal(16, 50 - actualStoreOnly.Count); // the 16 live rows (fix round: net +1 from pre-FA4 baseline) + Assert.Equal(33, actualStoreOnly.Count); + Assert.Equal(17, 50 - actualStoreOnly.Count); // the 17 live rows (vitals round: SideBySideVitals un-dimmed) } [Fact] diff --git a/tests/AcDream.App.Tests/UI/Layout/VitalsDetailToggleTests.cs b/tests/AcDream.App.Tests/UI/Layout/VitalsDetailToggleTests.cs new file mode 100644 index 00000000..33bc7009 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/VitalsDetailToggleTests.cs @@ -0,0 +1,251 @@ +using AcDream.App.UI; +using AcDream.App.UI.Layout; + +namespace AcDream.App.Tests.UI.Layout; + +/// +/// The retail vitals numeric/graphical click toggle, against the committed +/// vitals fixture (vitals_2100006C.json) β€” no dats, no GL. +/// +/// Retail mechanism (fully derived 2026-08-17): +/// +/// gmVitalsUI::ListenToElementMessage @0x004BFC00 β€” +/// mouse press (msg 0x1C) with dwParam1 7 (left) or 0xA (right) flips +/// SetState(m_state == HideDetail ? ShowDetail : HideDetail). +/// UIElement::SetState @0x00464E70 β€” the state +/// cascades through the authored PassToChildren chain (root and meters +/// author media-less HideDetail/ShowDetail with PassToChildren=true). +/// The cur/max labels author per-state 0x3B (invisible β€” +/// UIElement::OnSetAttribute case 8 @0x00462DAE is +/// SetVisible(value == 0)): HideDetail={0x3B:false} (numbers shown), +/// ShowDetail={0x3B:true} (numbers hidden). +/// The 0x100004A9 overlay children author icon media only +/// for ShowDetail (heart 0x06007490/91, sword 0x06007492/93, scepter +/// 0x06007494/95) β€” dim back icon unclipped, bright front icon fill-clipped. +/// UIElement_Dragbar @0x0046C850 / +/// UIElement_Resizebar @0x0046B930 consume presses (return 2) β€” a +/// window move/resize press never toggles. +/// Not persisted: gmGamePlayUI::SaveScreenLayout +/// @0x004EAD50 writes window rects only; each window resets to the +/// authored Undef default per session. +/// +/// +[Trait("Category", "Conformance")] +public class VitalsDetailToggleTests +{ + private const uint DragBarTop = 0x1000063Cu; // Type 2 β€” WindowMoveHandle + private const uint GripTopLeft = 0x1000063Bu; // Type 9 β€” UiResizeGrip + + // ── Import shape ───────────────────────────────────────────────────────── + + [Fact] + public void VitalsTree_RootIsVitalsRootWidget() + { + var layout = FixtureLoader.LoadVitals(); + Assert.IsType(layout.Root); + } + + [Fact] + public void VitalsTree_MetersAbsorbDetailIconOverlays() + { + var layout = FixtureLoader.LoadVitals(); + + // MeterId β†’ (dim back icon, bright front icon) from the authored + // 0x100004A9 ShowDetail media (format doc Β§11 + installed-DAT probe). + (uint MeterId, uint Back, uint Front)[] cases = + [ + (VitalsController.Health, 0x06007490u, 0x06007491u), // heart + (VitalsController.Stamina, 0x06007492u, 0x06007493u), // sword + (VitalsController.Mana, 0x06007494u, 0x06007495u), // scepter + ]; + + foreach (var (meterId, back, front) in cases) + { + var m = Assert.IsType(layout.FindElement(meterId)); + Assert.True(m.HasDetailOverlay); + Assert.Equal(back, m.DetailBackSprite); + Assert.Equal(front, m.DetailFrontSprite); + } + + // Health's authored overlay rect: 18x16 at x=66 (the heart). + var health = Assert.IsType(layout.FindElement(VitalsController.Health)); + Assert.Equal((66f, 0f, 18f, 16f), health.DetailBackRect); + } + + // ── The press toggle ───────────────────────────────────────────────────── + + [Fact] + public void Press_TogglesUndefThenHideDetailThenShowDetail() + { + var layout = FixtureLoader.LoadVitals(); + var root = Assert.IsType(layout.Root); + var stamina = Assert.IsType(layout.FindElement(VitalsController.Stamina)); + var staminaText = Assert.IsType(layout.FindElement(VitalsController.StaminaText)); + + // Login default: authored DefaultState is Undef and nothing calls + // SetState β€” numbers visible, no icons. + Assert.True(staminaText.Visible); + + // Press 1 (from Undef): retail lands on HideDetail β€” visually identical + // (labels author {0x3B:false} there), never back to Undef afterwards. + Press(root, root); + Assert.Equal(RetailUiStateIds.HideDetail, root.ActiveRetailStateId); + Assert.Equal(RetailUiStateIds.HideDetail, stamina.ActiveRetailStateId); + Assert.True(staminaText.Visible); + + // Press 2: ShowDetail β€” the graphical mode. Numbers hidden, icons on. + Press(root, root); + Assert.Equal(RetailUiStateIds.ShowDetail, root.ActiveRetailStateId); + Assert.Equal(RetailUiStateIds.ShowDetail, stamina.ActiveRetailStateId); + Assert.False(staminaText.Visible); + + // Press 3: back to numeric. + Press(root, root); + Assert.Equal(RetailUiStateIds.HideDetail, root.ActiveRetailStateId); + Assert.True(staminaText.Visible); + } + + [Fact] + public void RightPress_TogglesLikeLeftPress() + { + // Retail accepts dwParam1 7 (left) OR 0xA (right) β€” @0x004BFC19. + var layout = FixtureLoader.LoadVitals(); + var root = Assert.IsType(layout.Root); + + Press(root, root, UiEventType.RightDown); + Assert.Equal(RetailUiStateIds.HideDetail, root.ActiveRetailStateId); + Press(root, root, UiEventType.RightDown); + Assert.Equal(RetailUiStateIds.ShowDetail, root.ActiveRetailStateId); + } + + [Fact] + public void Press_CascadesToAllThreeLabels() + { + var layout = FixtureLoader.LoadVitals(); + var root = Assert.IsType(layout.Root); + + Press(root, root); // Undef β†’ HideDetail + Press(root, root); // HideDetail β†’ ShowDetail + foreach (uint textId in new[] + { + VitalsController.HealthText, + VitalsController.StaminaText, + VitalsController.ManaText, + }) + { + var text = Assert.IsType(layout.FindElement(textId)); + Assert.False(text.Visible); + } + } + + // ── Chrome exclusions ──────────────────────────────────────────────────── + + [Fact] + public void Press_OnDragBar_DoesNotToggle() + { + // Retail UIElement_Dragbar::ListenToElementMessage returns 2 (consumed) + // for every message β€” the press never reaches gmVitalsUI. + var layout = FixtureLoader.LoadVitals(); + var root = Assert.IsType(layout.Root); + var dragBar = layout.FindElement(DragBarTop); + Assert.NotNull(dragBar); + Assert.True(dragBar!.WindowMoveHandle); + + uint before = root.ActiveRetailStateId; + Press(root, dragBar); + Assert.Equal(before, root.ActiveRetailStateId); + } + + [Fact] + public void Press_OnResizeGrip_DoesNotToggle() + { + // Retail UIElement_Resizebar::ListenToElementMessage returns 2 likewise. + var layout = FixtureLoader.LoadVitals(); + var root = Assert.IsType(layout.Root); + var grip = layout.FindElement(GripTopLeft); + Assert.NotNull(grip); + Assert.IsType(grip); + + uint before = root.ActiveRetailStateId; + Press(root, grip!); + Assert.Equal(before, root.ActiveRetailStateId); + } + + // ── Independence: each window keeps its own state ───────────────────────── + + [Fact] + public void TwoWindows_ToggleIndependently() + { + // Retail: gmFloatyVitalsUI and gmFloatySideVitalsUI each carry their + // own m_state; toggling one never touches the other. + var a = FixtureLoader.LoadVitals(); + var b = FixtureLoader.LoadVitals(); + var rootA = Assert.IsType(a.Root); + var rootB = Assert.IsType(b.Root); + + Press(rootA, rootA); + Press(rootA, rootA); + Assert.Equal(RetailUiStateIds.ShowDetail, rootA.ActiveRetailStateId); + Assert.NotEqual(RetailUiStateIds.ShowDetail, rootB.ActiveRetailStateId); + } + + // ── End-to-end: the REAL mouse path (hit test + bubble) ────────────────── + + [Fact] + public void MouseDown_OnWindowBody_TogglesThroughTheRealHitPath() + { + var (root, vitalsRoot) = MountedWindow(); + + // Body press: the stamina meter's center (meters are ClickThrough, so + // the hit lands on the window content and bubbles to the vitals root). + root.OnMouseDown(UiMouseButton.Left, 90, 59); + root.OnMouseUp(UiMouseButton.Left, 90, 59); + Assert.Equal(RetailUiStateIds.HideDetail, vitalsRoot.ActiveRetailStateId); + + // Right press toggles too (retail dwParam1 0xA). + root.OnMouseDown(UiMouseButton.Right, 90, 59); + root.OnMouseUp(UiMouseButton.Right, 90, 59); + Assert.Equal(RetailUiStateIds.ShowDetail, vitalsRoot.ActiveRetailStateId); + } + + [Fact] + public void MouseDown_OnTopDragBar_MovesArmWithoutToggling() + { + var (root, vitalsRoot) = MountedWindow(); + + // The authored top drag bar (0x1000063C) spans content-local + // (5,0)-(155,5) β†’ canvas y just below the window top. Retail's + // Dragbar consumes the press; the toggle must not fire. + root.OnMouseDown(UiMouseButton.Left, 90, 32); + root.OnMouseUp(UiMouseButton.Left, 90, 32); + Assert.NotEqual(RetailUiStateIds.HideDetail, vitalsRoot.ActiveRetailStateId); + Assert.NotEqual(RetailUiStateIds.ShowDetail, vitalsRoot.ActiveRetailStateId); + } + + private static (UiRoot Root, UiVitalsRoot VitalsRoot) MountedWindow() + { + var root = new UiRoot { Width = 800, Height = 600 }; + var layout = FixtureLoader.LoadVitals(); + RetailWindowFrame.Mount(root, layout.Root, static _ => (0u, 0, 0), + new RetailWindowFrame.Options + { + WindowName = WindowNames.Vitals, + Chrome = RetailWindowChrome.Imported, + Left = 10f, + Top = 30f, + ResizeX = true, + ResizeY = false, + MinWidth = 40f, + ContentClickThrough = false, + }); + return (root, Assert.IsType(layout.Root)); + } + + // ── Helpers ────────────────────────────────────────────────────────────── + + /// Delivers a press to as UiRoot's bubble + /// walk would: the hit target rides in . + private static void Press(UiVitalsRoot root, UiElement target, + int type = UiEventType.MouseDown) + => root.OnEvent(new UiEvent(target.EventId, target, type)); +} diff --git a/tests/AcDream.App.Tests/UI/Layout/VitalsSideBySideControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/VitalsSideBySideControllerTests.cs new file mode 100644 index 00000000..5106ac5d --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/VitalsSideBySideControllerTests.cs @@ -0,0 +1,95 @@ +using AcDream.App.UI; +using AcDream.App.UI.Layout; + +namespace AcDream.App.Tests.UI.Layout; + +/// +/// Retail's "Side By Side Vitals" character option swaps which of the TWO +/// complete vitals windows is visible β€” nothing is rearranged in place: +/// gmFloatyVitalsUI::UpdateFromPlayerModule @0x004CF140 (stacked +/// visible iff the bit is CLEAR), gmFloatySideVitalsUI:: +/// UpdateFromPlayerModule @0x004D0810 (side row visible iff SET), and the +/// live flip in gmGamePlayUI::RecvNotice_PlayerOptionChanged @0x004E9DA0. +/// The bit is PlayerModule::SideBySideVitals @0x005D3070 = +/// CharacterOptions1 0x00200000 (option id 0x13) β€” ACE-confirmed. +/// +public sealed class VitalsSideBySideControllerTests +{ + [Fact] + public void FirstTick_AppliesTheCurrentBit_BothDirections() + { + var (root, stacked, side) = Windows(); + bool bit = false; + var controller = new VitalsSideBySideController( + root, () => bit, WindowNames.Vitals, WindowNames.SideVitals); + + controller.Tick(); + Assert.True(stacked.Visible); + Assert.False(side.Visible); + Assert.False(controller.Applied!.Value); + } + + [Fact] + public void FirstTick_WithBitSet_ShowsTheSideRow() + { + var (root, stacked, side) = Windows(); + var controller = new VitalsSideBySideController( + root, () => true, WindowNames.Vitals, WindowNames.SideVitals); + + controller.Tick(); + Assert.False(stacked.Visible); + Assert.True(side.Visible); + } + + [Fact] + public void BitEdge_SwapsLive_BothDirections() + { + // The Character tab's checkbox (or the inbound PlayerModule blob) + // flips the stored bit; the next frame swaps the windows β€” retail's + // RecvNotice_PlayerOptionChanged live apply. + var (root, stacked, side) = Windows(); + bool bit = false; + var controller = new VitalsSideBySideController( + root, () => bit, WindowNames.Vitals, WindowNames.SideVitals); + controller.Tick(); + + bit = true; + controller.Tick(); + Assert.False(stacked.Visible); + Assert.True(side.Visible); + + bit = false; + controller.Tick(); + Assert.True(stacked.Visible); + Assert.False(side.Visible); + } + + [Fact] + public void SteadyBit_DoesNotReassertVisibility() + { + // Edge-detected: a steady bit must not fight other visibility + // machinery every frame (only the option EDGE applies, mirroring + // retail's notice-driven apply rather than a per-frame force). + var (root, stacked, side) = Windows(); + var controller = new VitalsSideBySideController( + root, () => false, WindowNames.Vitals, WindowNames.SideVitals); + controller.Tick(); + + // Simulate an out-of-band hide (e.g. a future toggle surface). + stacked.Visible = false; + controller.Tick(); + Assert.False(stacked.Visible); + } + + private static (UiRoot Root, UiPanel Stacked, UiPanel Side) Windows() + { + var root = new UiRoot { Width = 800, Height = 600 }; + var stacked = new UiPanel { Width = 160, Height = 58 }; + var side = new UiPanel { Width = 460, Height = 26, Visible = false }; + root.AddChild(stacked); + root.AddChild(side); + root.RegisterWindow(WindowNames.Vitals, stacked, stacked, null); + root.RegisterWindow(WindowNames.SideVitals, side, side, null); + return (root, stacked, side); + } +}