Merge campaign-hover-ui-round: vitals retail modes + the login wormhole
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / portable-launcher (ubuntu-latest) (push) Waiting to run
Headless portability / portable-launcher (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / portable-launcher (ubuntu-latest) (push) Waiting to run
Headless portability / portable-launcher (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run
Vitals: click toggles numeric/graphical per gmVitalsUI's exact state machine (HideDetail/ShowDetail cascade, authored icon pairs with clipped fill, first-click no-op retail quirk); Side By Side Vitals as retail's two-window visibility swap on CharacterOptions1 bit 0x00200000, live on option change, blob-persisted. Login portal-space: every world entry (direct, char-select Enter, enter-after-create) now runs retail's wormhole with Sound_UI_EnterPortal via the teleport_in_progress flag edge; LoginComplete moved to retail's fade-in-end timing; TS-28 narrowed; a latent pre-publication portal-space crash fixed; the UI probe's canvas-vs-window coordinate bug fixed. All live-verified on both accounts with screenshots + cue evidence. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
commit
c0dab4e8fa
22 changed files with 1431 additions and 52 deletions
|
|
@ -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-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-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-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-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-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` |
|
| 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-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-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-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-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-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 |
|
| 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 |
|
||||||
|
|
|
||||||
|
|
@ -141,13 +141,39 @@ internal sealed class PlayerModeController :
|
||||||
if (Controller is null && !TryEnter("teleport"))
|
if (Controller is null && !TryEnter("teleport"))
|
||||||
return false;
|
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;
|
return false;
|
||||||
|
|
||||||
controller.State = PlayerState.PortalSpace;
|
controller.State = PlayerState.PortalSpace;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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 (<see cref="TryEnter"/> — chase camera, shadow,
|
||||||
|
/// animation sinks, approach lifetime), then flips into portal space.
|
||||||
|
/// <see cref="TryEnter"/> itself refuses until the Runtime first-entry
|
||||||
|
/// conductor commits, so pre-publication calls fail closed and retry.
|
||||||
|
/// </summary>
|
||||||
|
public bool TryEnterPortalSpaceForLogin()
|
||||||
|
{
|
||||||
|
_autoEntry?.Cancel();
|
||||||
|
if (!_mode.IsPlayerMode && !TryEnter("login"))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
return TryEnterPortalSpace();
|
||||||
|
}
|
||||||
|
|
||||||
public void EnterWorld()
|
public void EnterWorld()
|
||||||
{
|
{
|
||||||
if (Controller is { } controller)
|
if (Controller is { } controller)
|
||||||
|
|
|
||||||
|
|
@ -337,16 +337,25 @@ internal sealed class LiveSessionRuntimeFactory
|
||||||
_world.FirstEntryDrive,
|
_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
|
// Night-round review F2: CM_House::Event_QueryHouse @0x006aaa00
|
||||||
// is tail-called, unconditionally, from the end of
|
// is tail-called, unconditionally, from the end of
|
||||||
// CPlayerSystem::InitializePlayer @0x00563570 — the SAME
|
// CPlayerSystem::InitializePlayer @0x00563570 (guarded by
|
||||||
// once-per-session function AttemptSendLoginCompleteNotification
|
// player_initialized, once per session) — an object-arrival
|
||||||
// lives in (guarded by player_initialized), right after that
|
// edge, NOT a tunnel edge, so it stays at first-entry
|
||||||
// notification. This is the graphical host's direct
|
// completion rather than moving with LoginComplete.
|
||||||
// (non-portal) first-entry completion edge — the exact
|
|
||||||
// analogue. Portal-space re-entries (LocalPlayerTeleportController)
|
|
||||||
// do NOT resend it, matching retail's single-shot guard.
|
|
||||||
session.SendHouseQuery();
|
session.SendHouseQuery();
|
||||||
},
|
},
|
||||||
_world.AcceptedPositionDrive,
|
_world.AcceptedPositionDrive,
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,21 @@ internal interface ILocalPlayerTeleportNetworkSink
|
||||||
RuntimeTeleportDestination destination,
|
RuntimeTeleportDestination destination,
|
||||||
bool teleportTimestampAdvanced);
|
bool teleportTimestampAdvanced);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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
|
||||||
|
/// <c>SmartBox::UseTime @ 0x00455410</c> setting
|
||||||
|
/// <c>position_update_complete = 1</c> — the fact that lets the login
|
||||||
|
/// portal-space presentation leave its Tunnel hold. LoginComplete
|
||||||
|
/// (0xA1) itself now rides the presentation's own
|
||||||
|
/// <see cref="TeleportAnimEvent.FireLoginComplete"/> edge, matching
|
||||||
|
/// retail's send at the WorldFadeIn end
|
||||||
|
/// (<c>gmSmartBoxUI::UseTime @ 0x004D745D</c> →
|
||||||
|
/// <c>CPlayerSystem::SendLoginCompleteNotification @ 0x00562E90</c>).
|
||||||
|
/// </summary>
|
||||||
|
void OnLocalPlayerFirstEntryCompleted();
|
||||||
|
|
||||||
void ResetSession();
|
void ResetSession();
|
||||||
|
|
||||||
void ResetGenerationPresentation();
|
void ResetGenerationPresentation();
|
||||||
|
|
@ -65,6 +80,9 @@ internal sealed class DeferredLocalPlayerTeleportNetworkSink
|
||||||
bool teleportTimestampAdvanced) =>
|
bool teleportTimestampAdvanced) =>
|
||||||
Required().OfferDestination(destination, teleportTimestampAdvanced);
|
Required().OfferDestination(destination, teleportTimestampAdvanced);
|
||||||
|
|
||||||
|
public void OnLocalPlayerFirstEntryCompleted() =>
|
||||||
|
Required().OnLocalPlayerFirstEntryCompleted();
|
||||||
|
|
||||||
public void ResetSession() => Required().ResetSession();
|
public void ResetSession() => Required().ResetSession();
|
||||||
|
|
||||||
public void ResetGenerationPresentation() =>
|
public void ResetGenerationPresentation() =>
|
||||||
|
|
@ -102,6 +120,19 @@ internal interface ILocalPlayerTeleportModeOperations
|
||||||
PlayerMovementController? Controller { get; }
|
PlayerMovementController? Controller { get; }
|
||||||
Matrix4x4 Projection { get; }
|
Matrix4x4 Projection { get; }
|
||||||
bool TryEnterPortalSpace();
|
bool TryEnterPortalSpace();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Enter-world round (2026-08-17): the LOGIN arm's portal-space entry.
|
||||||
|
/// Unlike <see cref="TryEnterPortalSpace"/> — 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.
|
||||||
|
/// </summary>
|
||||||
|
bool TryEnterPortalSpaceForLogin();
|
||||||
void EnterWorld();
|
void EnterWorld();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -449,6 +480,37 @@ internal sealed class LocalPlayerTeleportController
|
||||||
private long _lifetimeGeneration;
|
private long _lifetimeGeneration;
|
||||||
private bool _disposed;
|
private bool _disposed;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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 —
|
||||||
|
/// <c>SmartBox::teleport_in_progress @ 0x00451C20</c> returns 1 whenever
|
||||||
|
/// the SmartBox has a player whose <c>position_update_complete</c> is
|
||||||
|
/// still 0, which is true the moment the login CreatePlayer lands, and
|
||||||
|
/// <c>gmSmartBoxUI::UseTime @ 0x004D6EAB</c> edge-detects that flag into
|
||||||
|
/// <c>BeginTeleportAnimation(TAS_TUNNEL) @ 0x004D6EC9</c> (which plays
|
||||||
|
/// <c>Sound_UI_EnterPortal @ 0x004D638E</c>) with no F751 involved.
|
||||||
|
/// acdream's canonical equivalent of that condition is Runtime's login
|
||||||
|
/// reveal (<see cref="RuntimeWorldTransitState.BeginLoginReveal"/>,
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
private long _loginRevealGeneration;
|
||||||
|
private bool _loginPresentationActive;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Latched by <see cref="OnLocalPlayerFirstEntryCompleted"/> — the
|
||||||
|
/// first-entry conductor's canonical initial placement committed. The
|
||||||
|
/// login pump's <c>worldReady</c> requires it, so the sequencer cannot
|
||||||
|
/// leave its Tunnel hold (and therefore cannot reach
|
||||||
|
/// <see cref="TeleportAnimEvent.FireLoginComplete"/>) before the same
|
||||||
|
/// placement contract that previously gated the immediate LoginComplete
|
||||||
|
/// send. Session-scoped: cleared only by session-level resets.
|
||||||
|
/// </summary>
|
||||||
|
private bool _loginPlacementCompleted;
|
||||||
|
private float _loginHoldSeconds;
|
||||||
|
|
||||||
public LocalPlayerTeleportController(
|
public LocalPlayerTeleportController(
|
||||||
ILocalPlayerTeleportAuthority authority,
|
ILocalPlayerTeleportAuthority authority,
|
||||||
ILocalPlayerTeleportInputLifetime input,
|
ILocalPlayerTeleportInputLifetime input,
|
||||||
|
|
@ -476,8 +538,35 @@ internal sealed class LocalPlayerTeleportController
|
||||||
|
|
||||||
public bool IsActive => _transit.IsTeleportActive;
|
public bool IsActive => _transit.IsTeleportActive;
|
||||||
public bool IsPortalViewportVisible => _presentation.IsPortalViewportVisible;
|
public bool IsPortalViewportVisible => _presentation.IsPortalViewportVisible;
|
||||||
public uint ActiveDestinationCell =>
|
|
||||||
_transit.IsTeleportActive ? _pendingCell : 0u;
|
/// <summary>
|
||||||
|
/// The destination the portal viewport is currently holding for. The
|
||||||
|
/// render frame's reveal-preparation arm
|
||||||
|
/// (<see cref="AcDream.App.Rendering.RuntimeRenderFrameLivePreparation"/>)
|
||||||
|
/// keys composite-texture preparation and readiness evaluation off this
|
||||||
|
/// cell; the login presentation must report its own destination here
|
||||||
|
/// because entering portal space flips <c>ChaseModeEverEntered</c>, which
|
||||||
|
/// retires the fallback "waiting for login" cell source that used to keep
|
||||||
|
/// preparation running pre-entry.
|
||||||
|
/// </summary>
|
||||||
|
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)
|
public void OnTeleportStarted(uint sequence)
|
||||||
{
|
{
|
||||||
|
|
@ -515,13 +604,22 @@ internal sealed class LocalPlayerTeleportController
|
||||||
TryAimAcceptedDestination();
|
TryAimAcceptedDestination();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void OnLocalPlayerFirstEntryCompleted()
|
||||||
|
{
|
||||||
|
ThrowIfDisposed();
|
||||||
|
_loginPlacementCompleted = true;
|
||||||
|
}
|
||||||
|
|
||||||
public void Tick(float deltaSeconds)
|
public void Tick(float deltaSeconds)
|
||||||
{
|
{
|
||||||
ThrowIfDisposed();
|
ThrowIfDisposed();
|
||||||
TryActivatePendingPresentation();
|
TryActivatePendingPresentation();
|
||||||
TryAimAcceptedDestination();
|
TryAimAcceptedDestination();
|
||||||
if (!_transit.IsTeleportActive)
|
if (!_transit.IsTeleportActive)
|
||||||
|
{
|
||||||
|
TickLoginPresentation(deltaSeconds);
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
long generation = _lifetimeGeneration;
|
long generation = _lifetimeGeneration;
|
||||||
ushort sequence = _transit.ActiveTeleportSequence;
|
ushort sequence = _transit.ActiveTeleportSequence;
|
||||||
|
|
@ -902,6 +1000,225 @@ internal sealed class LocalPlayerTeleportController
|
||||||
+ $"(seq={_transit.ActiveTeleportSequence})");
|
+ $"(seq={_transit.ActiveTeleportSequence})");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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
|
||||||
|
/// <c>gmSmartBoxUI::UseTime @ 0x004D6EAB</c> flag edge —
|
||||||
|
/// <c>SmartBox::teleport_in_progress @ 0x00451C20</c> goes high the
|
||||||
|
/// moment the login player object exists with
|
||||||
|
/// <c>position_update_complete == 0</c>, no F751 required. acdream's
|
||||||
|
/// canonical login edge is Runtime's login reveal generation
|
||||||
|
/// (<see cref="RuntimeWorldTransitState.BeginLoginReveal"/>, begun on the
|
||||||
|
/// first accepted local-player position on every entry route: direct
|
||||||
|
/// auto-select, character-select Enter, and enter-after-create).
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Activation retries every Tick until
|
||||||
|
/// <see cref="ILocalPlayerTeleportModeOperations.TryEnterPortalSpace"/>
|
||||||
|
/// 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.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
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})");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Per-frame pump for the login presentation — the login mirror of the
|
||||||
|
/// teleport pump in <see cref="Tick"/>. 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 <c>SmartBox::UseTime @ 0x00455483</c> likewise only
|
||||||
|
/// flips <c>position_update_complete</c>, it does not place), and
|
||||||
|
/// LoginComplete rides <see cref="TeleportAnimEvent.FireLoginComplete"/>
|
||||||
|
/// at the WorldFadeIn end (<c>gmSmartBoxUI::UseTime @ 0x004D745D</c> →
|
||||||
|
/// <c>CPlayerSystem::SendLoginCompleteNotification @ 0x00562E90</c>)
|
||||||
|
/// instead of at raw first placement.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The login pump's currency check — the login mirror of
|
||||||
|
/// <see cref="IsCurrentLifetime(long, ushort)"/>: 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.
|
||||||
|
/// </summary>
|
||||||
|
private bool IsCurrentLoginLifetime(
|
||||||
|
long lifetimeGeneration,
|
||||||
|
long revealGeneration) =>
|
||||||
|
_lifetimeGeneration == lifetimeGeneration
|
||||||
|
&& !_transit.IsTeleportActive
|
||||||
|
&& _loginRevealGeneration == revealGeneration
|
||||||
|
&& _transit.Snapshot.Generation == revealGeneration;
|
||||||
|
|
||||||
private void TryAimAcceptedDestination()
|
private void TryAimAcceptedDestination()
|
||||||
{
|
{
|
||||||
if (!_transit.TryGetAcceptedTeleportDestination(
|
if (!_transit.TryGetAcceptedTeleportDestination(
|
||||||
|
|
@ -1030,6 +1347,18 @@ internal sealed class LocalPlayerTeleportController
|
||||||
_placementCommitted = false;
|
_placementCommitted = false;
|
||||||
_awaitingDeferredWake = false;
|
_awaitingDeferredWake = false;
|
||||||
_holdSeconds = 0f;
|
_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);
|
_streaming.ResetRecenter(clearSession);
|
||||||
if (_lifetimeGeneration != generation)
|
if (_lifetimeGeneration != generation)
|
||||||
|
|
|
||||||
|
|
@ -580,6 +580,13 @@ internal sealed class CharacterManagementUiController : IDisposable
|
||||||
// remains authoritative and closes it on InWorld/error/reset.
|
// remains authoritative and closes it on InWorld/error/reset.
|
||||||
EnsureEnterWait();
|
EnsureEnterWait();
|
||||||
RuntimeCommandResult result = _bindings.Enter();
|
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)
|
if (!result.Accepted)
|
||||||
CloseContext(ref _enterWaitContext, suppressCallback: true);
|
CloseContext(ref _enterWaitContext, suppressCallback: true);
|
||||||
InvalidateAndTick();
|
InvalidateAndTick();
|
||||||
|
|
|
||||||
|
|
@ -111,7 +111,7 @@ public static class CharacterOptionsPageController
|
||||||
/// steps 13-18, written after the code landed):
|
/// steps 13-18, written after the code landed):
|
||||||
///
|
///
|
||||||
/// <list type="bullet">
|
/// <list type="bullet">
|
||||||
/// <item><description><b>NOT dimmed (16 rows, real acdream-side
|
/// <item><description><b>NOT dimmed (17 rows, real acdream-side
|
||||||
/// consumer):</b> Group B rows actually bound —
|
/// consumer):</b> Group B rows actually bound —
|
||||||
/// <c>ViewCombatTarget</c>/<c>AutoTarget</c>/<c>AutoRepeatAttack</c>
|
/// <c>ViewCombatTarget</c>/<c>AutoTarget</c>/<c>AutoRepeatAttack</c>
|
||||||
/// (combat: <c>LiveCombatAttackOperations.cs</c>,
|
/// (combat: <c>LiveCombatAttackOperations.cs</c>,
|
||||||
|
|
@ -124,15 +124,20 @@ public static class CharacterOptionsPageController
|
||||||
/// <c>DragItemOnPlayerOpensSecureTrade</c> (TS-48,
|
/// <c>DragItemOnPlayerOpensSecureTrade</c> (TS-48,
|
||||||
/// <c>InteractionRetainedUiComposition.cs:326</c>), all six
|
/// <c>InteractionRetainedUiComposition.cs:326</c>), all six
|
||||||
/// <c>ListenTo*Chat</c> ids (<c>TurbineChatMembershipGate.cs:105-136</c>
|
/// <c>ListenTo*Chat</c> ids (<c>TurbineChatMembershipGate.cs:105-136</c>
|
||||||
/// 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
|
/// Campaign FA slice FA4, D7, SURVIVING the fix-round correction below
|
||||||
/// — <c>FellowshipShareXP</c> (the Create-flow click genuinely reads
|
/// — <c>FellowshipShareXP</c> (the Create-flow click genuinely reads
|
||||||
/// it as the sent <c>shareXP</c> bit, <c>SocialFellowshipPageController.WireButtons</c>).</description></item>
|
/// it as the sent <c>shareXP</c> bit, <c>SocialFellowshipPageController.WireButtons</c>),
|
||||||
/// <item><description><b>Dimmed (34 rows, store-only):</b> every
|
/// and — the vitals retail-modes round, 2026-08-17 —
|
||||||
|
/// <c>SideBySideVitals</c> (<c>VitalsSideBySideController</c> swaps the
|
||||||
|
/// stacked 0x2100006C / side-by-side 0x21000075 vitals windows on the
|
||||||
|
/// bit, retail's <c>gmGamePlayUI::RecvNotice_PlayerOptionChanged
|
||||||
|
/// @0x004E9DA0</c>).</description></item>
|
||||||
|
/// <item><description><b>Dimmed (33 rows, store-only):</b> every
|
||||||
/// remaining Group A row (wire+store only — ACE, not acdream, is the
|
/// 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
|
/// consumer) and every remaining Group B row the OP4 gate script's own
|
||||||
/// step 16 lists as "no consumer surface" (<c>ShowTooltips</c>,
|
/// step 16 lists as "no consumer surface" (<c>ShowTooltips</c>,
|
||||||
/// <c>SideBySideVitals</c>, <c>SpellDuration</c>,
|
/// <c>SpellDuration</c>,
|
||||||
/// <c>AdvancedCombatUI</c>, <c>StayInChatMode</c>,
|
/// <c>AdvancedCombatUI</c>, <c>StayInChatMode</c>,
|
||||||
/// <c>DisableMostWeatherEffects</c>, <c>PersistentAtDay</c>,
|
/// <c>DisableMostWeatherEffects</c>, <c>PersistentAtDay</c>,
|
||||||
/// <c>FilterLanguage</c>, <c>MainPackPreferred</c>), plus every Group D
|
/// <c>FilterLanguage</c>, <c>MainPackPreferred</c>), plus every Group D
|
||||||
|
|
@ -181,9 +186,9 @@ public static class CharacterOptionsPageController
|
||||||
/// </list>
|
/// </list>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Net: 35 (pre-FA4) → FA4 shipped 31 → fix round reverts three of the
|
/// Net: 35 (pre-FA4) → FA4 shipped 31 → fix round reverts three of the
|
||||||
/// four un-dims (Ignore, AutoAccept, ShareLoot) → <b>34 of 50 dimmed /
|
/// four un-dims (Ignore, AutoAccept, ShareLoot) → 34 dimmed / 16 live →
|
||||||
/// 16 live</b>, ONE net un-dim from pre-FA4 baseline
|
/// the vitals retail-modes round (2026-08-17) un-dims
|
||||||
/// (<c>FellowshipShareXP</c> only).
|
/// <c>SideBySideVitals</c> → <b>33 of 50 dimmed / 17 live</b>.
|
||||||
/// </para></description></item>
|
/// </para></description></item>
|
||||||
/// <item><description><b>Flagged ambiguity, resolved by code evidence
|
/// <item><description><b>Flagged ambiguity, resolved by code evidence
|
||||||
/// (see final report, not re-litigated here):</b> the research doc's
|
/// (see final report, not re-litigated here):</b> the research doc's
|
||||||
|
|
@ -226,7 +231,7 @@ public static class CharacterOptionsPageController
|
||||||
new(CharacterOptionId.VividTargetingIndicator, "VividTargetingIndicator", Live), // Group C
|
new(CharacterOptionId.VividTargetingIndicator, "VividTargetingIndicator", Live), // Group C
|
||||||
new(CharacterOptionId.ShowTooltips, "ShowTooltips", StoreOnly), // Group B, unbound
|
new(CharacterOptionId.ShowTooltips, "ShowTooltips", StoreOnly), // Group B, unbound
|
||||||
new(CharacterOptionId.CoordinatesOnRadar, "CoordinatesOnRadar", Live), // Group C
|
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.SpellDuration, "SpellDuration", StoreOnly), // Group B, unbound
|
||||||
new(CharacterOptionId.DisableMostWeatherEffects, "DisableMostWeatherEffects", StoreOnly), // Group B, unbound
|
new(CharacterOptionId.DisableMostWeatherEffects, "DisableMostWeatherEffects", StoreOnly), // Group B, unbound
|
||||||
new(CharacterOptionId.DisableDistanceFog, "DisableDistanceFog", Live), // Group B, bound (GameWindow.cs:657)
|
new(CharacterOptionId.DisableDistanceFog, "DisableDistanceFog", Live), // Group B, bound (GameWindow.cs:657)
|
||||||
|
|
|
||||||
|
|
@ -27,10 +27,12 @@ namespace AcDream.App.UI.Layout;
|
||||||
/// </para>
|
/// </para>
|
||||||
///
|
///
|
||||||
/// <para>
|
/// <para>
|
||||||
/// The expand-detail overlay present in the front container carries ONLY named
|
/// The expand-detail icon overlay present in EACH container (back + front)
|
||||||
/// states ("HideDetail"/"ShowDetail") — no "" DirectState entry — so the
|
/// carries ONLY named states ("HideDetail"/"ShowDetail") — no "" DirectState
|
||||||
/// <c>TryGetValue("")</c> filter in <see cref="SliceIds"/> excludes it
|
/// entry — so the <c>TryGetValue("")</c> filter in <see cref="SliceIds"/>
|
||||||
/// automatically.
|
/// excludes it from slice extraction; <see cref="DetailOverlay"/> then absorbs
|
||||||
|
/// it into <see cref="UiMeter.ConfigureDetailOverlay"/> for the retail
|
||||||
|
/// HideDetail/ShowDetail click toggle.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class DatWidgetFactory
|
public static class DatWidgetFactory
|
||||||
|
|
@ -76,6 +78,15 @@ public static class DatWidgetFactory
|
||||||
UiElement e = info.Type switch
|
UiElement e = info.Type switch
|
||||||
{
|
{
|
||||||
UiRadar.RetailClassId => new UiRadar(), // gmRadarUI (Register 0x004D8B80)
|
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
|
1 => BuildButton(info, resolve, elementFont, fontResolve, stringResolve), // UIElement_Button
|
||||||
2 => new UiDatElement(info, resolve) // UIElement_Dragbar (Register @ 0x0046C840)
|
2 => new UiDatElement(info, resolve) // UIElement_Dragbar (Register @ 0x0046C840)
|
||||||
{
|
{
|
||||||
|
|
@ -446,8 +457,11 @@ public static class DatWidgetFactory
|
||||||
/// │ ├── left-cap image (→ front-left sprite)
|
/// │ ├── left-cap image (→ front-left sprite)
|
||||||
/// │ ├── center image (→ front-tile sprite)
|
/// │ ├── center image (→ front-tile sprite)
|
||||||
/// │ ├── right-cap image (→ front-right sprite)
|
/// │ ├── right-cap image (→ front-right sprite)
|
||||||
/// │ └── expand overlay (named "ShowDetail"/"HideDetail" only — NO DirectState — IGNORED)
|
/// │ └── expand overlay (named "ShowDetail"/"HideDetail" only — NO DirectState —
|
||||||
/// └── text label (Type 0) (IGNORED — Fill/Label providers bound by VitalsController)
|
/// │ 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)
|
||||||
/// </code>
|
/// </code>
|
||||||
/// </para>
|
/// </para>
|
||||||
///
|
///
|
||||||
|
|
@ -508,6 +522,35 @@ public static class DatWidgetFactory
|
||||||
m.FrontLeft = fl;
|
m.FrontLeft = fl;
|
||||||
m.FrontTile = ft;
|
m.FrontTile = ft;
|
||||||
m.FrontRight = fr;
|
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(""))
|
else if (containers.Count == 1 && containers[0].StateMedia.ContainsKey(""))
|
||||||
{
|
{
|
||||||
|
|
@ -617,6 +660,18 @@ public static class DatWidgetFactory
|
||||||
=> container.Children.Count(c =>
|
=> container.Children.Count(c =>
|
||||||
c.StateMedia.TryGetValue("", out var media) && media.File != 0) >= 3;
|
c.StateMedia.TryGetValue("", out var media) && media.File != 0) >= 3;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
|
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)
|
private static bool HasStatefulFill(ElementInfo container)
|
||||||
=> container.States.Any(pair =>
|
=> container.States.Any(pair =>
|
||||||
pair.Key != UiStateInfo.DirectStateId
|
pair.Key != UiStateInfo.DirectStateId
|
||||||
|
|
|
||||||
|
|
@ -181,11 +181,15 @@ public static class LayoutImporter
|
||||||
// widgets via FindElement and bind LinesProvider without injecting new runtime nodes.
|
// widgets via FindElement and bind LinesProvider without injecting new runtime nodes.
|
||||||
//
|
//
|
||||||
// Type-3 children are SKIPPED here because BuildMeter already consumed them (they
|
// 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
|
// carry the 3-slice sprite ids + the ShowDetail icon overlays, not text content;
|
||||||
// double-draw the bar art). All other child types are built normally.
|
// 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
|
// For vitals this loop builds exactly the cur/max number label (0x100000EB/ED/EF,
|
||||||
// (no text children). This loop finds nothing for them → no change to vitals.
|
// 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)
|
foreach (var child in info.Children)
|
||||||
{
|
{
|
||||||
if (child.Type == 3) continue; // slice containers: already consumed by BuildMeter
|
if (child.Type == 3) continue; // slice containers: already consumed by BuildMeter
|
||||||
|
|
|
||||||
95
src/AcDream.App/UI/Layout/UiVitalsRoot.cs
Normal file
95
src/AcDream.App/UI/Layout/UiVitalsRoot.cs
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
using System;
|
||||||
|
|
||||||
|
namespace AcDream.App.UI.Layout;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Behavioral root widget for the vitals windows — retail's <c>gmVitalsUI</c>
|
||||||
|
/// family (<c>gmVitalsUI</c> 0x10000009, <c>gmFloatyVitalsUI</c> 0x1000004D =
|
||||||
|
/// the stacked window LayoutDesc 0x2100006C, <c>gmFloatySideVitalsUI</c>
|
||||||
|
/// 0x10000056 = the side-by-side window LayoutDesc 0x21000075; registrations
|
||||||
|
/// @0x004BFE10 / @0x004CED90 / @0x004D0490).
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// <b>The click toggle</b> (<c>gmVitalsUI::ListenToElementMessage @0x004BFC00</c>,
|
||||||
|
/// inherited verbatim by both floaty subclasses): on <c>Element_mouse_press</c>
|
||||||
|
/// (0x1C) with dwParam1 7 (left) or 0xA (right) the root flips
|
||||||
|
/// <c>SetState(m_state == HideDetail ? ShowDetail : HideDetail)</c>.
|
||||||
|
/// State semantics from the authored data + <c>UIElement::OnSetAttribute</c>
|
||||||
|
/// case 8 (0x3B = invisible, <c>SetVisible(value == 0)</c> @0x00462DAE):
|
||||||
|
/// </para>
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><description><b>Undef</b> (login default; DefaultState is authored
|
||||||
|
/// Undef and nothing calls SetState at init): numbers visible, no icons —
|
||||||
|
/// visually identical to HideDetail.</description></item>
|
||||||
|
/// <item><description><b>HideDetail</b> (0x10000006): the numeric mode — the
|
||||||
|
/// cur/max labels author {0x3B:false} (visible); the icon overlays author
|
||||||
|
/// File=0 (nothing).</description></item>
|
||||||
|
/// <item><description><b>ShowDetail</b> (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).</description></item>
|
||||||
|
/// </list>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// The first press from Undef lands on HideDetail (retail's exact expression:
|
||||||
|
/// <c>ecx = (m_state == 0x10000006); SetState(ecx + 0x10000006)</c> — 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: <c>gmGamePlayUI::SaveScreenLayout
|
||||||
|
/// @0x004EAD50</c> writes only window rects, and no PlayerModule option is
|
||||||
|
/// touched — the mode resets to Undef every session, per window.
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// <b>Press routing:</b> retail broadcasts the press to the pressed element
|
||||||
|
/// and forwards up the parent chain (<c>UIElement::ListenToElementMessage
|
||||||
|
/// @0x00462340</c> → ForwardElementMessage), but <c>UIElement_Dragbar</c>
|
||||||
|
/// (@0x0046C850) and <c>UIElement_Resizebar</c> (@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.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class UiVitalsRoot : UiDatElement
|
||||||
|
{
|
||||||
|
/// <summary>gmVitalsUI registered element class (@0x004BFE1A).</summary>
|
||||||
|
public const uint GmVitalsClassId = 0x10000009u;
|
||||||
|
/// <summary>gmFloatyVitalsUI registered element class (@0x004CED9A) — the stacked window root.</summary>
|
||||||
|
public const uint GmFloatyVitalsClassId = 0x1000004Du;
|
||||||
|
/// <summary>gmFloatySideVitalsUI registered element class (@0x004D049A) — the side-by-side window root.</summary>
|
||||||
|
public const uint GmFloatySideVitalsClassId = 0x10000056u;
|
||||||
|
|
||||||
|
public UiVitalsRoot(ElementInfo info, Func<uint, (uint tex, int w, int h)> 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
84
src/AcDream.App/UI/Layout/VitalsSideBySideController.cs
Normal file
84
src/AcDream.App/UI/Layout/VitalsSideBySideController.cs
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
using System;
|
||||||
|
|
||||||
|
namespace AcDream.App.UI.Layout;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Owns which of the two vitals windows is visible — retail's
|
||||||
|
/// "Side By Side Vitals" character option (Options → Character tab).
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Retail authors TWO complete vitals windows and swaps their visibility on
|
||||||
|
/// the option bit; nothing is rearranged in place:
|
||||||
|
/// </para>
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><description><c>gmFloatyVitalsUI</c> (LayoutDesc 0x2100006C, the
|
||||||
|
/// stacked 160x58 window): <c>UpdateFromPlayerModule @0x004CF140</c> —
|
||||||
|
/// visible iff <c>PlayerModule::SideBySideVitals() == 0</c>.</description></item>
|
||||||
|
/// <item><description><c>gmFloatySideVitalsUI</c> (LayoutDesc 0x21000075, the
|
||||||
|
/// 460x26 single-row window): <c>UpdateFromPlayerModule @0x004D0810</c> —
|
||||||
|
/// visible iff the bit is set.</description></item>
|
||||||
|
/// <item><description>Live apply: <c>gmGamePlayUI::RecvNotice_PlayerOptionChanged
|
||||||
|
/// @0x004E9DA0</c> flips both windows the moment the
|
||||||
|
/// <c>SideBySideVitals_PlayerOption</c> (0x13) changes; both
|
||||||
|
/// <c>UpdateFromPlayerModule</c> bodies re-apply on PlayerDescReceived.</description></item>
|
||||||
|
/// <item><description>The bit is <c>PlayerModule::SideBySideVitals @0x005D3070</c>
|
||||||
|
/// = <c>(options_ >> 0x15) & 1</c> — CharacterOptions1 0x00200000,
|
||||||
|
/// option id 0x13, riding the 0x01A1 PlayerModule blob (not a 0x0005
|
||||||
|
/// auto-save id) — <c>CharacterOptionTable</c> row confirmed against ACE.</description></item>
|
||||||
|
/// </list>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// acdream shape: the option lives in the J4 Runtime owner
|
||||||
|
/// (<c>RuntimeCharacterState.Options</c>); this controller polls the borrowed
|
||||||
|
/// bit once per frame from <c>RetailUiRuntime.Tick</c> (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 <c>stateManagedVisibilityWindows</c> so the saved
|
||||||
|
/// window layout never restores a visibility this option owns.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class VitalsSideBySideController
|
||||||
|
{
|
||||||
|
private readonly UiRoot _root;
|
||||||
|
private readonly Func<bool> _sideBySideVitals;
|
||||||
|
private readonly string _stackedWindow;
|
||||||
|
private readonly string _sideWindow;
|
||||||
|
private bool? _applied;
|
||||||
|
|
||||||
|
public VitalsSideBySideController(
|
||||||
|
UiRoot root,
|
||||||
|
Func<bool> sideBySideVitals,
|
||||||
|
string stackedWindow,
|
||||||
|
string sideWindow)
|
||||||
|
{
|
||||||
|
_root = root ?? throw new ArgumentNullException(nameof(root));
|
||||||
|
_sideBySideVitals = sideBySideVitals
|
||||||
|
?? throw new ArgumentNullException(nameof(sideBySideVitals));
|
||||||
|
_stackedWindow = stackedWindow;
|
||||||
|
_sideWindow = sideWindow;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The last applied bit (null before the first apply). Exposed for tests.</summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -495,6 +495,7 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
private UiShortcutDigitGraphics? _shortcutDigitGraphics;
|
private UiShortcutDigitGraphics? _shortcutDigitGraphics;
|
||||||
private ItemCooldownUiController? _itemCooldownController;
|
private ItemCooldownUiController? _itemCooldownController;
|
||||||
private VividTargetIndicatorController? _vividTargetIndicator;
|
private VividTargetIndicatorController? _vividTargetIndicator;
|
||||||
|
private Layout.VitalsSideBySideController? _vitalsSideBySide;
|
||||||
private CharacterManagementUiMountCoordinator? _characterManagementMount;
|
private CharacterManagementUiMountCoordinator? _characterManagementMount;
|
||||||
private CharacterCreationUiMountCoordinator? _characterCreationMount;
|
private CharacterCreationUiMountCoordinator? _characterCreationMount;
|
||||||
private IDisposable? _characterSheetSubscription;
|
private IDisposable? _characterSheetSubscription;
|
||||||
|
|
@ -586,6 +587,14 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
WindowNames.JumpPowerbar,
|
WindowNames.JumpPowerbar,
|
||||||
WindowNames.ExternalContainer,
|
WindowNames.ExternalContainer,
|
||||||
WindowNames.Vendor,
|
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
|
// Trade gate round 2 (2026-08-14): the trade window is
|
||||||
// transient — visibility belongs to RuntimeTradeState's
|
// transient — visibility belongs to RuntimeTradeState's
|
||||||
// open/closed lifecycle, never to the layout file (a
|
// open/closed lifecycle, never to the layout file (a
|
||||||
|
|
@ -827,6 +836,7 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
{
|
{
|
||||||
FpsController?.Tick();
|
FpsController?.Tick();
|
||||||
_vividTargetIndicator?.Tick();
|
_vividTargetIndicator?.Tick();
|
||||||
|
_vitalsSideBySide?.Tick();
|
||||||
SpellbookWindowController?.Tick();
|
SpellbookWindowController?.Tick();
|
||||||
AppraisalController?.Tick(deltaSeconds);
|
AppraisalController?.Tick(deltaSeconds);
|
||||||
SpellcastingUiController?.Tick();
|
SpellcastingUiController?.Tick();
|
||||||
|
|
@ -1312,14 +1322,7 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
VitalsVM vm = _bindings.Vitals.ViewModel;
|
BindVitalsLayout(layout);
|
||||||
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}" : "");
|
|
||||||
RetailWindowFrame.Mount(Host.Root, layout.Root, _bindings.Assets.ResolveSprite,
|
RetailWindowFrame.Mount(Host.Root, layout.Root, _bindings.Assets.ResolveSprite,
|
||||||
new RetailWindowFrame.Options
|
new RetailWindowFrame.Options
|
||||||
{
|
{
|
||||||
|
|
@ -1333,6 +1336,83 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
ContentClickThrough = false,
|
ContentClickThrough = false,
|
||||||
});
|
});
|
||||||
Console.WriteLine("[D.2b] retail UI active — vitals window from LayoutDesc importer (0x2100006C).");
|
Console.WriteLine("[D.2b] retail UI active — vitals window from LayoutDesc importer (0x2100006C).");
|
||||||
|
|
||||||
|
MountSideVitals();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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
|
||||||
|
/// <see cref="Layout.VitalsSideBySideController"/>, which owns both
|
||||||
|
/// windows' visibility from the per-frame option poll. Mounted hidden;
|
||||||
|
/// the controller's first Tick applies the real bit.
|
||||||
|
///
|
||||||
|
/// <para>Element ids (meters 0x100000E6/EC/EE, labels 0x100000EB/ED/EF)
|
||||||
|
/// and the detail-icon overlays are authored identically to 0x2100006C,
|
||||||
|
/// so the same <see cref="VitalsController.Bind"/> and the inherited
|
||||||
|
/// <see cref="UiVitalsRoot"/> click toggle apply as-is. Width constraints
|
||||||
|
/// ride the root's authored 0x3C..0x3F (min 360 / max 3000, fixed 26
|
||||||
|
/// height) through <see cref="RetailWindowFrame.Options.DatConstraintSource"/>.</para>
|
||||||
|
/// </summary>
|
||||||
|
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).");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>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.</summary>
|
||||||
|
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()
|
private void MountRadar()
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
|
using System.Numerics;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using AcDream.Core.Items;
|
using AcDream.Core.Items;
|
||||||
|
|
||||||
|
|
@ -131,6 +132,22 @@ public sealed class RetailUiAutomationProbe
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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
|
||||||
|
/// (<c>0x10000218</c>/<c>0x10000219</c> 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 <see cref="DumpText"/>
|
||||||
|
/// and clicks its center. Same synthetic <see cref="UiRoot"/> route as
|
||||||
|
/// <see cref="ClickElement"/> — never the OS cursor.
|
||||||
|
/// </summary>
|
||||||
|
public bool ClickAtPoint(int x, int y)
|
||||||
|
{
|
||||||
|
ClickAt(x, y);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 2026-08-17 morning gate: synthetic pointer HOVER (no click) at an
|
/// 2026-08-17 morning gate: synthetic pointer HOVER (no click) at an
|
||||||
/// element's center, for rollover/tooltip verification. Deliberately
|
/// element's center, for rollover/tooltip verification. Deliberately
|
||||||
|
|
@ -147,7 +164,9 @@ public sealed class RetailUiAutomationProbe
|
||||||
{
|
{
|
||||||
var target = FindByDatElementId(datElementId);
|
var target = FindByDatElementId(datElementId);
|
||||||
if (target is null) return Fail($"element 0x{datElementId:X8} not found");
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -282,8 +301,30 @@ public sealed class RetailUiAutomationProbe
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Enter-world round (2026-08-17): element coordinates from the probe's
|
||||||
|
/// tree walk are CANVAS coordinates, but <see cref="UiRoot.OnMouseMove"/>/
|
||||||
|
/// <c>OnMouseDown</c>/<c>OnMouseUp</c> take WINDOW coordinates and map
|
||||||
|
/// window→canvas internally (<c>UiRoot.MapWindowToCanvas</c>). The two
|
||||||
|
/// spaces are identical on every ordinary screen (no
|
||||||
|
/// <see cref="UiRoot.FixedCanvasSize"/>), 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.
|
||||||
|
/// </summary>
|
||||||
|
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)
|
private void ClickAt(int x, int y)
|
||||||
{
|
{
|
||||||
|
(x, y) = CanvasToWindow(x, y);
|
||||||
Advance(16);
|
Advance(16);
|
||||||
_root.OnMouseMove(x, y);
|
_root.OnMouseMove(x, y);
|
||||||
_root.OnMouseDown(UiMouseButton.Left, 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)
|
private void DragAt(int startX, int startY, int endX, int endY)
|
||||||
{
|
{
|
||||||
|
(startX, startY) = CanvasToWindow(startX, startY);
|
||||||
|
(endX, endY) = CanvasToWindow(endX, endY);
|
||||||
Advance(16);
|
Advance(16);
|
||||||
_root.OnMouseMove(startX, startY);
|
_root.OnMouseMove(startX, startY);
|
||||||
_root.OnMouseDown(UiMouseButton.Left, startX, startY);
|
_root.OnMouseDown(UiMouseButton.Left, startX, startY);
|
||||||
|
|
|
||||||
|
|
@ -219,7 +219,7 @@ public sealed class RetailUiAutomationScriptRunner : IDisposable
|
||||||
private bool DoClick(ScriptCommand command)
|
private bool DoClick(ScriptCommand command)
|
||||||
{
|
{
|
||||||
var p = command.Parts;
|
var p = command.Parts;
|
||||||
if (p.Length < 3) return Stop(command, "usage: click element <datId> | click item <guid> [source]");
|
if (p.Length < 3) return Stop(command, "usage: click element <datId> | click item <guid> [source] | click at <x> <y>");
|
||||||
string target = p[1].ToLowerInvariant();
|
string target = p[1].ToLowerInvariant();
|
||||||
if (target == "element")
|
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]}'");
|
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 _probe.ClickItem(itemGuid, ParseSource(p, 3)) || Stop(command, "click item failed");
|
||||||
}
|
}
|
||||||
return Stop(command, "usage: click element <datId> | click item <guid> [source]");
|
if (target == "at")
|
||||||
|
{
|
||||||
|
// `click at <x> <y>` — 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 <x> <y>");
|
||||||
|
return _probe.ClickAtPoint(x, y) || Stop(command, "click at failed");
|
||||||
|
}
|
||||||
|
return Stop(command, "usage: click element <datId> | click item <guid> [source] | click at <x> <y>");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>2026-08-17 morning gate: `hover element <datId>` — a
|
/// <summary>2026-08-17 morning gate: `hover element <datId>` — a
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,23 @@ public sealed class UiMeter : UiElement, IUiDatStateful
|
||||||
private readonly Dictionary<uint, (string Text, UiMeterLabelAlign Align)> _stateLabels = new();
|
private readonly Dictionary<uint, (string Text, UiMeterLabelAlign Align)> _stateLabels = new();
|
||||||
private (string Text, UiMeterLabelAlign Align)? _activeStateLabel;
|
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;
|
||||||
|
|
||||||
|
/// <summary>True when this meter absorbed the vitals detail-icon overlays. Exposed for tests.</summary>
|
||||||
|
internal bool HasDetailOverlay => _detailConfigured;
|
||||||
|
/// <summary>The dim back-container detail icon (ShowDetail media). Exposed for tests.</summary>
|
||||||
|
internal uint DetailBackSprite => _detailBackSprite;
|
||||||
|
/// <summary>The bright fill-clipped front-container detail icon. Exposed for tests.</summary>
|
||||||
|
internal uint DetailFrontSprite => _detailFrontSprite;
|
||||||
|
/// <summary>The back overlay's authored meter-local rect. Exposed for tests.</summary>
|
||||||
|
internal (float X, float Y, float W, float H) DetailBackRect => _detailBackRect;
|
||||||
|
|
||||||
/// <summary>Dat element id, set by the layout importer so duplicated page copies can be scoped.</summary>
|
/// <summary>Dat element id, set by the layout importer so duplicated page copies can be scoped.</summary>
|
||||||
public uint ElementId { get; set; }
|
public uint ElementId { get; set; }
|
||||||
|
|
||||||
|
|
@ -114,8 +131,54 @@ public sealed class UiMeter : UiElement, IUiDatStateful
|
||||||
_stateLabels[stateId] = (text, align);
|
_stateLabels[stateId] = (text, align);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registers the vitals "detail" icon overlays absorbed from the meter's
|
||||||
|
/// two slice containers (the <c>0x100004A9</c> 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 —
|
||||||
|
/// <c>UIElement_Meter::DrawChildren @0x0046FBD0</c> clips the whole
|
||||||
|
/// <c>m_pcChildImage</c> child, element id 2, to the 0x69 fraction), so
|
||||||
|
/// the icon itself fills up with the vital. Both overlays author media
|
||||||
|
/// ONLY for <c>ShowDetail</c> (<c>HideDetail</c> 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).
|
||||||
|
/// </summary>
|
||||||
|
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)
|
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 hasFill = _stateFillSprites.TryGetValue(stateId, out uint spriteId);
|
||||||
bool hasLabel = _stateLabels.TryGetValue(stateId, out var caption);
|
bool hasLabel = _stateLabels.TryGetValue(stateId, out var caption);
|
||||||
if (!hasFill && !hasLabel)
|
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.
|
// 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%
|
// 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.
|
// 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);
|
DrawHBar(ctx, resolve, BackLeft, BackTile, BackRight, Width);
|
||||||
|
if (detail)
|
||||||
|
DrawDetailIcon(ctx, resolve, _detailBackSprite, _detailBackRect, Width);
|
||||||
if (pct is not null && p > 0f)
|
if (pct is not null && p > 0f)
|
||||||
|
{
|
||||||
DrawHBar(ctx, resolve, FrontLeft, FrontTile, FrontRight, Width * p);
|
DrawHBar(ctx, resolve, FrontLeft, FrontTile, FrontRight, Width * p);
|
||||||
|
if (detail)
|
||||||
|
DrawDetailIcon(ctx, resolve, _detailFrontSprite, _detailFrontRect, Width * p);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
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);
|
ctx.DrawSprite(tex, 0f, y, w, visibleH, 0f, v0, 1f, v1, System.Numerics.Vector4.One);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Draws one ShowDetail icon overlay at its authored meter-local rect,
|
||||||
|
/// horizontally clipped to <paramref name="clipW"/> local px (the back icon
|
||||||
|
/// passes the full width; the front icon passes <c>Width * fraction</c>,
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
|
private void DrawDetailIcon(
|
||||||
|
UiRenderContext ctx, Func<uint, (uint tex, int w, int h)> 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);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Draw a slice over local [<paramref name="pieceX"/>,
|
/// <summary>Draw a slice over local [<paramref name="pieceX"/>,
|
||||||
/// pieceX+<paramref name="pieceW"/>], with the texture repeating every
|
/// pieceX+<paramref name="pieceW"/>], with the texture repeating every
|
||||||
/// <paramref name="nativeW"/> px (UV-repeat — the UI texture is GL_REPEAT-wrapped).
|
/// <paramref name="nativeW"/> px (UV-repeat — the UI texture is GL_REPEAT-wrapped).
|
||||||
|
|
|
||||||
|
|
@ -386,6 +386,22 @@ public sealed class UiText : UiElement, IUiDatStateful
|
||||||
&& TryColor(color, out Vector4 resolvedColor))
|
&& TryColor(color, out Vector4 resolvedColor))
|
||||||
DefaultColor = 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
|
// Retail's state cascade also swaps the element's AUTHORED string
|
||||||
// when the incoming state carries its own 0x17 (the friends row's
|
// when the incoming state carries its own 0x17 (the friends row's
|
||||||
// status cell: 'Online'/'Offline' with per-state colors). Resolved
|
// status cell: 'Online'/'Offline' with per-state colors). Resolved
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,12 @@ namespace AcDream.App.UI;
|
||||||
public static class WindowNames
|
public static class WindowNames
|
||||||
{
|
{
|
||||||
public const string Vitals = "vitals";
|
public const string Vitals = "vitals";
|
||||||
|
|
||||||
|
/// <summary>The side-by-side vitals row (retail gmFloatySideVitalsUI,
|
||||||
|
/// LayoutDesc 0x21000075) — shown instead of <see cref="Vitals"/> when the
|
||||||
|
/// SideBySideVitals character option is set (see
|
||||||
|
/// <see cref="Layout.VitalsSideBySideController"/>).</summary>
|
||||||
|
public const string SideVitals = "side-vitals";
|
||||||
public const string Toolbar = "toolbar";
|
public const string Toolbar = "toolbar";
|
||||||
public const string Character = "character";
|
public const string Character = "character";
|
||||||
public const string CharacterInformation = "character-information";
|
public const string CharacterInformation = "character-information";
|
||||||
|
|
|
||||||
|
|
@ -1825,6 +1825,8 @@ public sealed class LiveEntityNetworkOnPositionCollapseMatrixTests
|
||||||
bool teleportTimestampAdvanced)
|
bool teleportTimestampAdvanced)
|
||||||
{ }
|
{ }
|
||||||
|
|
||||||
|
public void OnLocalPlayerFirstEntryCompleted() { }
|
||||||
|
|
||||||
public void ResetSession() { }
|
public void ResetSession() { }
|
||||||
|
|
||||||
public void ResetGenerationPresentation() { }
|
public void ResetGenerationPresentation() { }
|
||||||
|
|
|
||||||
|
|
@ -1059,6 +1059,8 @@ public sealed class LiveEntityNetworkRemoteTeleportPresentationTests
|
||||||
bool teleportTimestampAdvanced)
|
bool teleportTimestampAdvanced)
|
||||||
{ }
|
{ }
|
||||||
|
|
||||||
|
public void OnLocalPlayerFirstEntryCompleted() { }
|
||||||
|
|
||||||
public void ResetSession() { }
|
public void ResetSession() { }
|
||||||
|
|
||||||
public void ResetGenerationPresentation() { }
|
public void ResetGenerationPresentation() { }
|
||||||
|
|
|
||||||
|
|
@ -911,12 +911,22 @@ public sealed class LocalPlayerTeleportControllerTests
|
||||||
public readonly RuntimeLocalPlayerMovementState Movement;
|
public readonly RuntimeLocalPlayerMovementState Movement;
|
||||||
public IPreparedCollisionSource DiagnosticCollisionSource => new UnusedCollisionSource();
|
public IPreparedCollisionSource DiagnosticCollisionSource => new UnusedCollisionSource();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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 <c>worldReady</c> parameter, so every prior test reads
|
||||||
|
/// identically.
|
||||||
|
/// </summary>
|
||||||
|
public bool WorldReady;
|
||||||
|
|
||||||
public Harness(
|
public Harness(
|
||||||
int centerX = 0x20,
|
int centerX = 0x20,
|
||||||
int centerY = 0x21,
|
int centerY = 0x21,
|
||||||
bool worldReady = true,
|
bool worldReady = true,
|
||||||
List<string>? order = null)
|
List<string>? order = null)
|
||||||
{
|
{
|
||||||
|
WorldReady = worldReady;
|
||||||
order ??= new List<string>();
|
order ??= new List<string>();
|
||||||
Mode = new FakeMode(order);
|
Mode = new FakeMode(order);
|
||||||
Streaming = new FakeStreaming(centerX, centerY, order);
|
Streaming = new FakeStreaming(centerX, centerY, order);
|
||||||
|
|
@ -927,10 +937,10 @@ public sealed class LocalPlayerTeleportControllerTests
|
||||||
Reveal = new WorldRevealCoordinator(
|
Reveal = new WorldRevealCoordinator(
|
||||||
Transit,
|
Transit,
|
||||||
revealWindow: () => RevealWindow,
|
revealWindow: () => RevealWindow,
|
||||||
isRenderNeighborhoodReady: (_, _, _) => worldReady,
|
isRenderNeighborhoodReady: (_, _, _) => WorldReady,
|
||||||
isSpawnCellReady: _ => worldReady,
|
isSpawnCellReady: _ => WorldReady,
|
||||||
isTerrainNeighborhoodReady: (_, _) => worldReady,
|
isTerrainNeighborhoodReady: (_, _) => WorldReady,
|
||||||
areCompositeTexturesReady: () => worldReady,
|
areCompositeTexturesReady: () => WorldReady,
|
||||||
prepareCompositeTextures: (_, _) => { },
|
prepareCompositeTextures: (_, _) => { },
|
||||||
invalidateCompositeTextures: () => { },
|
invalidateCompositeTextures: () => { },
|
||||||
isSpawnClaimUnhydratable: _ => false,
|
isSpawnClaimUnhydratable: _ => false,
|
||||||
|
|
@ -1330,6 +1340,14 @@ public sealed class LocalPlayerTeleportControllerTests
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public int EnterPortalForLoginCount;
|
||||||
|
|
||||||
|
public bool TryEnterPortalSpaceForLogin()
|
||||||
|
{
|
||||||
|
EnterPortalForLoginCount++;
|
||||||
|
return TryEnterPortalSpace();
|
||||||
|
}
|
||||||
|
|
||||||
public void EnterWorld()
|
public void EnterWorld()
|
||||||
{
|
{
|
||||||
_order.Add("enter-world");
|
_order.Add("enter-world");
|
||||||
|
|
@ -1439,12 +1457,14 @@ public sealed class LocalPlayerTeleportControllerTests
|
||||||
private sealed class FakeNetworkSink : ILocalPlayerTeleportNetworkSink
|
private sealed class FakeNetworkSink : ILocalPlayerTeleportNetworkSink
|
||||||
{
|
{
|
||||||
public List<uint> Starts { get; } = [];
|
public List<uint> Starts { get; } = [];
|
||||||
|
public int FirstEntryCompletions;
|
||||||
public void OnTeleportStarted(uint sequence) => Starts.Add(sequence);
|
public void OnTeleportStarted(uint sequence) => Starts.Add(sequence);
|
||||||
public void OfferDestination(
|
public void OfferDestination(
|
||||||
RuntimeTeleportDestination destination,
|
RuntimeTeleportDestination destination,
|
||||||
bool teleportTimestampAdvanced)
|
bool teleportTimestampAdvanced)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
public void OnLocalPlayerFirstEntryCompleted() => FirstEntryCompletions++;
|
||||||
public void ResetSession()
|
public void ResetSession()
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
@ -1482,6 +1502,135 @@ public sealed class LocalPlayerTeleportControllerTests
|
||||||
Assert.False(harness.Presentation.IsPortalViewportVisible);
|
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<string>();
|
||||||
|
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<string>();
|
||||||
|
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 sealed class FakePresentation : ILocalPlayerTeleportPresentation
|
||||||
{
|
{
|
||||||
private readonly List<string> _order;
|
private readonly List<string> _order;
|
||||||
|
|
|
||||||
|
|
@ -698,7 +698,7 @@ public sealed class CharacterOptionsPageControllerTests
|
||||||
// ── AD-78 caption dimming (user-directed, 2026-08-11, gate 2) ───────────
|
// ── AD-78 caption dimming (user-directed, 2026-08-11, gate 2) ───────────
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The exact 34 <see cref="CharacterOptionId"/> values this slice dims
|
/// The exact 33 <see cref="CharacterOptionId"/> values currently dimmed
|
||||||
/// (<see cref="RowSpec.StoreOnly"/> == true), transcribed independently
|
/// (<see cref="RowSpec.StoreOnly"/> == true), transcribed independently
|
||||||
/// of <c>CharacterOptionsPageController.Groups</c> from the derivation
|
/// of <c>CharacterOptionsPageController.Groups</c> from the derivation
|
||||||
/// table in that class's own doc comment. <b>Wiring a future consumer
|
/// table in that class's own doc comment. <b>Wiring a future consumer
|
||||||
|
|
@ -720,15 +720,23 @@ public sealed class CharacterOptionsPageControllerTests
|
||||||
/// fellowship Create flow reads it as the sent <c>shareXP</c> bit).
|
/// fellowship Create flow reads it as the sent <c>shareXP</c> bit).
|
||||||
/// Net: 35 (pre-FA4) → 34 (post-fix-round), one net un-dim.
|
/// Net: 35 (pre-FA4) → 34 (post-fix-round), one net un-dim.
|
||||||
/// </para>
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// The vitals retail-modes round (2026-08-17) un-dims
|
||||||
|
/// <c>SideBySideVitals</c>: <c>VitalsSideBySideController</c> genuinely
|
||||||
|
/// READS the bit every frame and swaps the stacked (0x2100006C) /
|
||||||
|
/// side-by-side (0x21000075) vitals windows — retail's
|
||||||
|
/// <c>gmGamePlayUI::RecvNotice_PlayerOptionChanged @0x004E9DA0</c>.
|
||||||
|
/// Net: 34 → 33 dimmed / 17 live.
|
||||||
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static readonly HashSet<CharacterOptionId> ExpectedStoreOnlyIds =
|
private static readonly HashSet<CharacterOptionId> ExpectedStoreOnlyIds =
|
||||||
[
|
[
|
||||||
// Group 1 (UI Behavior) — SalvageMultiple (D), MainPackPreferred (B, unbound)
|
// Group 1 (UI Behavior) — SalvageMultiple (D), MainPackPreferred (B, unbound)
|
||||||
CharacterOptionId.SalvageMultiple,
|
CharacterOptionId.SalvageMultiple,
|
||||||
CharacterOptionId.MainPackPreferred,
|
CharacterOptionId.MainPackPreferred,
|
||||||
// Group 2 (UI Display) — 11 of 15
|
// Group 2 (UI Display) — 10 of 15 (vitals round: SideBySideVitals is live)
|
||||||
CharacterOptionId.ShowTooltips,
|
CharacterOptionId.ShowTooltips,
|
||||||
CharacterOptionId.SideBySideVitals,
|
|
||||||
CharacterOptionId.SpellDuration,
|
CharacterOptionId.SpellDuration,
|
||||||
CharacterOptionId.DisableMostWeatherEffects,
|
CharacterOptionId.DisableMostWeatherEffects,
|
||||||
CharacterOptionId.PersistentAtDay,
|
CharacterOptionId.PersistentAtDay,
|
||||||
|
|
@ -774,8 +782,8 @@ public sealed class CharacterOptionsPageControllerTests
|
||||||
.ToHashSet();
|
.ToHashSet();
|
||||||
|
|
||||||
Assert.Equal(ExpectedStoreOnlyIds, actualStoreOnly);
|
Assert.Equal(ExpectedStoreOnlyIds, actualStoreOnly);
|
||||||
Assert.Equal(34, actualStoreOnly.Count);
|
Assert.Equal(33, actualStoreOnly.Count);
|
||||||
Assert.Equal(16, 50 - actualStoreOnly.Count); // the 16 live rows (fix round: net +1 from pre-FA4 baseline)
|
Assert.Equal(17, 50 - actualStoreOnly.Count); // the 17 live rows (vitals round: SideBySideVitals un-dimmed)
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
|
||||||
251
tests/AcDream.App.Tests/UI/Layout/VitalsDetailToggleTests.cs
Normal file
251
tests/AcDream.App.Tests/UI/Layout/VitalsDetailToggleTests.cs
Normal file
|
|
@ -0,0 +1,251 @@
|
||||||
|
using AcDream.App.UI;
|
||||||
|
using AcDream.App.UI.Layout;
|
||||||
|
|
||||||
|
namespace AcDream.App.Tests.UI.Layout;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The retail vitals numeric/graphical click toggle, against the committed
|
||||||
|
/// vitals fixture (<c>vitals_2100006C.json</c>) — no dats, no GL.
|
||||||
|
///
|
||||||
|
/// Retail mechanism (fully derived 2026-08-17):
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><description><c>gmVitalsUI::ListenToElementMessage @0x004BFC00</c> —
|
||||||
|
/// mouse press (msg 0x1C) with dwParam1 7 (left) or 0xA (right) flips
|
||||||
|
/// <c>SetState(m_state == HideDetail ? ShowDetail : HideDetail)</c>.</description></item>
|
||||||
|
/// <item><description><c>UIElement::SetState @0x00464E70</c> — the state
|
||||||
|
/// cascades through the authored <c>PassToChildren</c> chain (root and meters
|
||||||
|
/// author media-less HideDetail/ShowDetail with PassToChildren=true).</description></item>
|
||||||
|
/// <item><description>The cur/max labels author per-state 0x3B (invisible —
|
||||||
|
/// <c>UIElement::OnSetAttribute</c> case 8 @0x00462DAE is
|
||||||
|
/// <c>SetVisible(value == 0)</c>): HideDetail={0x3B:false} (numbers shown),
|
||||||
|
/// ShowDetail={0x3B:true} (numbers hidden).</description></item>
|
||||||
|
/// <item><description>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.</description></item>
|
||||||
|
/// <item><description><c>UIElement_Dragbar @0x0046C850</c> /
|
||||||
|
/// <c>UIElement_Resizebar @0x0046B930</c> consume presses (return 2) — a
|
||||||
|
/// window move/resize press never toggles.</description></item>
|
||||||
|
/// <item><description>Not persisted: <c>gmGamePlayUI::SaveScreenLayout
|
||||||
|
/// @0x004EAD50</c> writes window rects only; each window resets to the
|
||||||
|
/// authored Undef default per session.</description></item>
|
||||||
|
/// </list>
|
||||||
|
/// </summary>
|
||||||
|
[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<UiVitalsRoot>(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<UiMeter>(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<UiMeter>(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<UiVitalsRoot>(layout.Root);
|
||||||
|
var stamina = Assert.IsType<UiMeter>(layout.FindElement(VitalsController.Stamina));
|
||||||
|
var staminaText = Assert.IsType<UiText>(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<UiVitalsRoot>(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<UiVitalsRoot>(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<UiText>(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<UiVitalsRoot>(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<UiVitalsRoot>(layout.Root);
|
||||||
|
var grip = layout.FindElement(GripTopLeft);
|
||||||
|
Assert.NotNull(grip);
|
||||||
|
Assert.IsType<UiResizeGrip>(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<UiVitalsRoot>(a.Root);
|
||||||
|
var rootB = Assert.IsType<UiVitalsRoot>(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<UiVitalsRoot>(layout.Root));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>Delivers a press to <paramref name="root"/> as UiRoot's bubble
|
||||||
|
/// walk would: the hit target rides in <see cref="UiEvent.Target"/>.</summary>
|
||||||
|
private static void Press(UiVitalsRoot root, UiElement target,
|
||||||
|
int type = UiEventType.MouseDown)
|
||||||
|
=> root.OnEvent(new UiEvent(target.EventId, target, type));
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,95 @@
|
||||||
|
using AcDream.App.UI;
|
||||||
|
using AcDream.App.UI.Layout;
|
||||||
|
|
||||||
|
namespace AcDream.App.Tests.UI.Layout;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retail's "Side By Side Vitals" character option swaps which of the TWO
|
||||||
|
/// complete vitals windows is visible — nothing is rearranged in place:
|
||||||
|
/// <c>gmFloatyVitalsUI::UpdateFromPlayerModule @0x004CF140</c> (stacked
|
||||||
|
/// visible iff the bit is CLEAR), <c>gmFloatySideVitalsUI::
|
||||||
|
/// UpdateFromPlayerModule @0x004D0810</c> (side row visible iff SET), and the
|
||||||
|
/// live flip in <c>gmGamePlayUI::RecvNotice_PlayerOptionChanged @0x004E9DA0</c>.
|
||||||
|
/// The bit is <c>PlayerModule::SideBySideVitals @0x005D3070</c> =
|
||||||
|
/// CharacterOptions1 0x00200000 (option id 0x13) — ACE-confirmed.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue