Compare commits

...
Sign in to create a new pull request.

7 commits

Author SHA1 Message Date
Erik
e102fb363e fix(render) #132: outdoor alpha drains follow retail walk order
Some checks failed
CI / linux-portable (push) Successful in 3m35s
CI / windows-gate (push) Failing after 6m46s
CI / release (push) Has been skipped
Retail draws every cell's objects inside LScape::draw's far-to-near walk
(DrawSortCell @0x005A17C0) and drains the one delayed-alpha list at the
DrawCells boundary AFTER the finished walk (@0x005A4872). Our outdoor
frame drained at the landscape-stage end and then drew punches, interior
shells, cell objects, and ALL dynamics — every one of those opaque
passes overwrote the already-composited flames (the reopened #132
candle class: "the door draws over the candle", creatures at openings).
Depth and barrier A/Bs were no-ops because the eraser is opaque color
painted after the drain.

Two retail-cited ordering corrections, outdoor-node roots only:

1. The stage-boundary drain is skipped and FlushLandscapeAlpha() runs
   after DrawDynamicsLast, where the frame's opaque world depth is
   complete — the one far-to-near list composites over everything,
   exactly like retail's boundary flush relative to its finished walk.
2. Before DrawExitPortalMasks, FlushLandscapeAlphaFartherThan(
   ExitPortalMaskBarrierDistance(...)) drains everything at or beyond
   the nearest cell whose exit-portal mask is about to write far-Z —
   retail DrawBuilding @0x0059F2A0 runs FlushAlphaList(0f) BEFORE its
   portal-only pass, so in the far-to-near walk nothing already drained
   can meet a punched aperture's falsified depth. Without this, the
   first correction let exterior waterfalls z-pass across punched
   apertures whose true landscape depth the punch erased (found live at
   the cathedral gate). Nearer content stays queued and legitimately
   composites in front of punched structures.

Interior roots keep the pre-clear stage-boundary drain unchanged.

User-gated live: Holtburg sign candle whole in front of the sign and
tower door at the aligned pose; cathedral waterfalls contained at every
camera zoom, inside and outside. Register row AP-236 retired (the
walk-order outcome reconstruction is complete; AP-34 remains the
umbrella for the CYpt-sort reconstruction itself). Filed #456 for the
separate occluded-distant-building/creature admission residual this
session diagnosed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 19:03:35 +02:00
Erik
8acb544d44 docs: file #454 barred timered quest-item icon, #455 paperdoll click-dequip
Both owner-reported 2026-08-29 after the #443 fix gate. #454: a boss
quest item with a reuse timer lands in the backpack with the barred
(unusable) icon overlay and stays barred; expected clear immediately,
timer text is chat-only. #455: clicking an equipped item on the
paperdoll does nothing; the retail gesture and gmPaperDollUI click
handling must come from the named decomp before implementation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 17:11:41 +02:00
Erik
cd1cdee0e5 fix(render) #443: private viewports take the ring transform path
The paperdoll was visible only in portal space. Root cause: the classic
WbDrawDispatcher.Draw path appended its transforms into the SHARED world
transform frame (WorldTransformFrameArena.Append) with a non-zero base
instance, but the default mesh shaders index every parallel per-instance
array - clip slots, light sets, indoor, OPACITY, selection lighting,
detail category - zero-based; only the packed world submission's shader
convention subtracts the shared-arena prefix. With a world frame active
the doll drew all instances at per-instance opacity 0 into a cleared
target: counted draws, blank pixels, deterministic. Portal space worked
because no world transform frame is active there, so the same code took
the ring path with base 0. The private viewports are the only production
consumers of the classic path, hiding the defect everywhere else.

Fix: WbDrawDispatcher.NextClassicDrawIsPrivatePass - the private
viewport renderer marks its draw and WriteWorldTransformSection routes
private passes onto the plain ring path unconditionally (self-contained
render state: the private pass owns its own camera, lighting, and
target, and must not depend on the world frame's pose address space).

Also landed, each independently justified:
- Per-GPU-flight-slot private targets (PrivateViewportFlightTargets),
  restoring the pre-f6fe0f2a design: that revert's claim that frame
  submission order protects the single target's write->sample transition
  is not guaranteed across Vulkan command buffers. Per-slot completed
  scenes fix the cleared-sibling-after-reveal wart the old attempt had.
- Paperdoll resource preparation moved to the frame resource phase
  (IPrivateEntityViewportResourcePreparation) before world draws consume
  the bounded composite-upload budget.
- The presenter redresses on every dirty edge (an appearance-equal clone
  can pin retired readiness across generations; the renderer's two-phase
  promote keeps the last completed image visible during replacement),
  publishes only non-zero handles, and clears the viewport exactly once
  at the explicit character-session boundary.

Verified live on the clean build: doll visible in the NORMAL world,
visible through portal space, and still visible after arrival - the
exact reported repro cycle. 26 paperdoll/private-viewport/preparation
tests plus 60 renderer-suite tests pass; owner visual gate pending.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 16:51:32 +02:00
Erik
fc30285fd7 fix(render): drawn-once look-in dynamics + farther-than-building alpha drains
Two retail-parity corrections on top of 684380d4, user-gated together
(no regressions; door/creature-through-opening rendering verified live
at Holtburg; cathedral waterfall and look-north casts pass):

1. Drawn-once look-in dynamics. Retail marks every drawn non-player
part for the frame (DrawMeshInternal @0x0059F360 GetDrawnThisFrame), so
an object draws once, with its cell. acdream drew a look-in cell's
dynamics twice under an outdoor root - once correctly with the look-in,
then again in dynamics-last after the boundary alpha drain, where the
second draw overpainted nearer flames. Both the accepted path
(_lookInCellIds) and the frame product (BuildDynamicLastRoute) now
exclude dynamics whose parent cell drew as a look-in.

2. Pre/inter-building barriers drain only content FARTHER than the
building they precede (FlushLandscapeAlphaFartherThan +
RetailAlphaQueue.FlushFartherThan + conservative anchor-origin
threshold). Retail's far-to-near walk guarantees a building's
FlushAlphaList(0f) @0x0059F2A0 has only farther content queued; a
nearer emitter composites at a later flush. AP-236 documents the
remaining barrier-order divergence.

The #132 candle-before-door overdraw is NOT yet fixed by these steps
and stays open: the current suspect is that houses without a
constructed look-in still draw their interior-parented door in
dynamics-last after the outdoor candle's boundary drain (see the
2026-08-29 ledger for the retail flush-after-objects hypothesis).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 16:06:04 +02:00
Erik
684380d421 fix(render): particles draw unclipped, once, in their retail stage
Retail never clips a particle to a portal view: each emitter's polys
join the ONE alpha list during its owner cell's far-to-near walk turn
(LScape::draw @0x00506330 iterates block_draw_list reversed; DrawBlock
@0x005A17C0 walks cells; ShouldDrawParticles @0x0050FE60 gates by cell
and distance), and occlusion is the depth test at FlushAlphaList
@0x0059D2E0 (its float is a COUNT threshold - 0f = flush all). The
1d2f2f73 architecture instead re-submitted particles once per
OutsideView slice under that slice's hardware clip slot, which cut
effects at aperture boundaries and drew nothing when no outside slice
was in view (the cathedral look-north disappearance).

Now: unattached emitters submit once per frame by owner-cell kind
(outdoor landcells in the landscape stage, interior EnvCells in the
final world scope - new UnattachedEmitterCellScope filter); cell,
shell-route, barrier-static, and late-stage owners submit their
per-slice cone-cull UNION once with clipSlot 0; and particles emit in
the stage matching their PARENT CELL - an interior dynamic whose
sphere straddles an exit-portal plane keeps its mesh in both stages
(#118) but its particles move to the final pass, so the interior
stage can no longer repaint over them (the aperture-band star cut).

Also lands the inert Change-2 primitives for the AP-236 retirement
(candle-behind-door): RetailAlphaQueue.FlushFartherThan drains only
the far prefix without resetting sources, plus the executor
passthrough and the conservative look-in threshold helper - nothing
calls them yet.

User-gated 2026-08-29 round 2 at the Sanctuary Cathedral: spell and
recall stars cover the whole room at every camera direction including
north; waterfall containment holds on retail's depth/seal mechanism;
adjacent-room particles/lights, walls, Holtburg, recall unregressed
(paperdoll remains pre-existing intermittent #443). Register: AP-236
filed for the remaining barrier-order divergence.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 12:35:30 +02:00
Erik
85530c0b7e fix(render): feed look-in cells to particle and light visibility
Look-in rooms (cells reached through building portals) drew their
geometry but never entered the visible-cell set consumed by the
particle gate and the point-light candidacy scope, so their emitters
stayed frozen and their lanterns dark until the player entered or the
camera left the building. Retail has no look-in split:
ShouldDrawParticles @0x0050FE60 gates on cell->IsInView(), and a cell
installed by DrawBuilding @0x0059F2A0 -> PView::ConstructView
@0x005A57B0 is drawn by the same DrawCells traversal as any flooded
cell, so it is in view identically (per-cell in_view stamped by
PView::InitCell @0x005A4B70).

RetailPViewFrameResult now carries InViewCells (main flood + look-in
cells, the existing prepareCells union; the borrowed-scratch contract
is unchanged because RecycleLookInFrames runs at the start of the next
DrawInside). WorldSceneRenderer feeds InViewCells to
ParticleVisibilityController and the point-light scope. DrawableCells
is untouched for seals, the outside-stage predicate, diagnostics, and
the packed-product referee. The legacy 4-arg Reset defaults
InViewCells = DrawableCells for standalone callers.

User-gated 2026-08-29 at the Sanctuary Cathedral: adjacent look-in
rooms show particles and lantern lights from the root cell without
entry or an outside camera; recall/waterfall/Holtburg/paperdoll
unregressed. The pre-existing #132-family candle-behind-opening
report remains open and is unchanged by this fix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 11:44:19 +02:00
Erik
f7aa8e0eb7 fix: complete retail parity stability pass
All checks were successful
CI / linux-portable (push) Successful in 3m41s
CI / windows-gate (push) Successful in 6m49s
CI / release (push) Successful in 3m22s
2026-08-28 20:01:39 +02:00
150 changed files with 9087 additions and 1506 deletions

View file

@ -141,7 +141,6 @@ $env:ACDREAM_TEST_HOST = "127.0.0.1"
$env:ACDREAM_TEST_PORT = "9000" $env:ACDREAM_TEST_PORT = "9000"
$env:ACDREAM_TEST_USER = "testaccount" $env:ACDREAM_TEST_USER = "testaccount"
$env:ACDREAM_TEST_PASS = "testpassword" $env:ACDREAM_TEST_PASS = "testpassword"
$env:ACDREAM_RETAIL_UI = "1"
dotnet run --project src\AcDream.App\AcDream.App.csproj -c Release dotnet run --project src\AcDream.App\AcDream.App.csproj -c Release
``` ```
@ -208,7 +207,7 @@ built-in policies are `idle`, `lifecycle-smoke`, `observer-movement`, and
| `ACDREAM_LIVE=1` | Enable connected mode | | `ACDREAM_LIVE=1` | Enable connected mode |
| `ACDREAM_TEST_HOST` / `ACDREAM_TEST_PORT` | ACE endpoint | | `ACDREAM_TEST_HOST` / `ACDREAM_TEST_PORT` | ACE endpoint |
| `ACDREAM_TEST_USER` / `ACDREAM_TEST_PASS` | Graphical-client credentials | | `ACDREAM_TEST_USER` / `ACDREAM_TEST_PASS` | Graphical-client credentials |
| `ACDREAM_RETAIL_UI=1` | Enable the retained retail gameplay UI | | `ACDREAM_RETAIL_UI=0` | Disable the retained retail gameplay UI for diagnostics; it is enabled by default |
| `ACDREAM_DEVTOOLS=1` | Enable ImGui developer tools | | `ACDREAM_DEVTOOLS=1` | Enable ImGui developer tools |
| `ACDREAM_NO_AUDIO=1` | Suppress OpenAL initialization | | `ACDREAM_NO_AUDIO=1` | Suppress OpenAL initialization |
| `ACDREAM_UNCAPPED_RENDER=1` | Disable normal frame pacing for diagnostics | | `ACDREAM_UNCAPPED_RENDER=1` | Disable normal frame pacing for diagnostics |

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

View file

@ -40,11 +40,13 @@ Assume a flag has a side effect until its row says otherwise.
- **Everything diagnostic is OFF by default.** Every probe, dump, capture, - **Everything diagnostic is OFF by default.** Every probe, dump, capture,
and measurement flag in this document is inert until its variable is and measurement flag in this document is inert until its variable is
explicitly set — an unset environment runs zero diagnostics. Exactly explicitly set — an unset environment runs zero diagnostics. Exactly
four flags default ON, and none is a diagnostic: `ACDREAM_RETAIL_CHASE`, five flags default ON, and none is a diagnostic: `ACDREAM_RETAIL_CHASE`,
`ACDREAM_CAMERA_COLLIDE`, `ACDREAM_CAMERA_ALIGN_SLOPE`, and `ACDREAM_CAMERA_COLLIDE`, `ACDREAM_CAMERA_ALIGN_SLOPE`, and
`ACDREAM_RETAIL_CLOSE_DEGRADES` are retail *behaviors* wearing an A/B `ACDREAM_RETAIL_CLOSE_DEGRADES` are retail *behaviors* wearing an A/B
off-switch (`=0` disables the behavior for a comparison run). That off-switch (`=0` disables the behavior for a comparison run), while
four-flag set is frozen by `LaunchOptionsDocumentationTests` — a new `ACDREAM_RETAIL_UI` is the product's only gameplay presentation and uses
the same explicit diagnostic opt-out. That five-flag set is frozen by
`LaunchOptionsDocumentationTests` — a new
default-on flag fails the build. default-on flag fails the build.
- `=1` means the code tests for exactly the string `1`. Setting `true`, - `=1` means the code tests for exactly the string `1`. Setting `true`,
`yes`, or `0` does **not** enable such a flag (and `0` does not disable `yes`, or `0` does **not** enable such a flag (and `0` does not disable
@ -75,14 +77,13 @@ $env:ACDREAM_TEST_HOST = "127.0.0.1"
$env:ACDREAM_TEST_PORT = "9000" $env:ACDREAM_TEST_PORT = "9000"
$env:ACDREAM_TEST_USER = "testaccount" $env:ACDREAM_TEST_USER = "testaccount"
$env:ACDREAM_TEST_PASS = "testpassword" $env:ACDREAM_TEST_PASS = "testpassword"
$env:ACDREAM_RETAIL_UI = "1"
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Release dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Release
``` ```
| Flag | Value | What it does | Side effects | Default | Read by | | Flag | Value | What it does | Side effects | Default | Read by |
|---|---|---|---|---|---| |---|---|---|---|---|---|
| `ACDREAM_A2C` | `unset/""` keep preset; `"0"/"false"/"False"/"FALSE"` → off; any other non-empty → on | Overrides preset's `AlphaToCoverage` blend flag | Changes MSAA alpha-to-coverage blending mode for foliage/translucent draws — a visual-behavior change, not just perf | preset's `AlphaToCoverage` (High/Ultra=true, Low/Medium=false) | `QualitySettings.WithEnvOverrides` (`QualityPreset.cs:52`) | | `ACDREAM_A2C` | `unset/""` keep preset; `"0"/"false"/"False"/"FALSE"` → off; any other non-empty → on | Overrides preset's `AlphaToCoverage` blend flag | Changes MSAA alpha-to-coverage blending mode for foliage/translucent draws — a visual-behavior change, not just perf | preset's `AlphaToCoverage` (High/Ultra=true, Low/Medium=false) | `QualitySettings.WithEnvOverrides` (`QualityPreset.cs:52`) |
| `ACDREAM_AC_DIR` | `=<path>` | Points at a real retail AC install dir; loads `<dir>/controls/controls.ini` to source retail keybind display strings for the retained UI. | Only has any effect when `ACDREAM_RETAIL_UI=1` (retained UI composed). Unset → `ControlsIni.Parse(string.Empty)`, an empty (not error) controls table — silent, no fallback file is searched. | unset (null) → empty controls table | `RuntimeOptions.AcDir``InteractionRetainedUiComposition.cs:610` | | `ACDREAM_AC_DIR` | `=<path>` | Points at a real retail AC install dir; loads `<dir>/controls/controls.ini` to source retail keybind display strings for the retained UI. | Only has any effect while the default-on retained UI is composed (`ACDREAM_RETAIL_UI` is not `0`). Unset → `ControlsIni.Parse(string.Empty)`, an empty (not error) controls table — silent, no fallback file is searched. | unset (null) → empty controls table | `RuntimeOptions.AcDir``InteractionRetainedUiComposition.cs:610` |
| `ACDREAM_ANISOTROPIC` | `=<int>` (`int.TryParse`, invariant) | Overrides preset's `AnisotropicLevel` texture filtering | Changes GPU texture sampling filter level (visual sharpness), not just perf | preset's `AnisotropicLevel` (Low=4, Medium=8, High/Ultra=16) | `QualitySettings.WithEnvOverrides` (`QualityPreset.cs:49`) | | `ACDREAM_ANISOTROPIC` | `=<int>` (`int.TryParse`, invariant) | Overrides preset's `AnisotropicLevel` texture filtering | Changes GPU texture sampling filter level (visual sharpness), not just perf | preset's `AnisotropicLevel` (Low=4, Medium=8, High/Ultra=16) | `QualitySettings.WithEnvOverrides` (`QualityPreset.cs:49`) |
| `ACDREAM_CACHE_DIR` | `=<path>` | Overrides the resolved cache-root directory (used for `DiagnosticsDirectory`, etc.) | none beyond redirecting cache I/O | Windows: `%LOCALAPPDATA%\acdream\cache`; Linux: `$XDG_CACHE_HOME/acdream` or `~/.cache/acdream` | `ApplicationPathSet.Resolve` (`ApplicationPathSet.cs:83`), via `IApplicationPathEnvironment` seam | | `ACDREAM_CACHE_DIR` | `=<path>` | Overrides the resolved cache-root directory (used for `DiagnosticsDirectory`, etc.) | none beyond redirecting cache I/O | Windows: `%LOCALAPPDATA%\acdream\cache`; Linux: `$XDG_CACHE_HOME/acdream` or `~/.cache/acdream` | `ApplicationPathSet.Resolve` (`ApplicationPathSet.cs:83`), via `IApplicationPathEnvironment` seam |
| `ACDREAM_CAMERA_ALIGN_SLOPE` | `=0` disables (anything else/unset = on) | selects whether the chase camera basis tilts to the player's 5-frame averaged velocity vs staying flat/horizontal on slopes | alters camera orientation / rendered view every frame; startup-only in practice (its DebugPanel/DebugVM mirror is unreachable — #434)| true (on) | `AcDream.Core.Rendering.CameraDiagnostics.AlignToSlope` | | `ACDREAM_CAMERA_ALIGN_SLOPE` | `=0` disables (anything else/unset = on) | selects whether the chase camera basis tilts to the player's 5-frame averaged velocity vs staying flat/horizontal on slopes | alters camera orientation / rendered view every frame; startup-only in practice (its DebugPanel/DebugVM mirror is unreachable — #434)| true (on) | `AcDream.Core.Rendering.CameraDiagnostics.AlignToSlope` |
@ -115,7 +116,7 @@ dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Release
| `ACDREAM_RESIDENCY_STANDALONE_UNOWNED_MIB` | `=<int MiB>` (`>0`, else default) | Byte ceiling for unowned standalone texture memory | Same eviction-cadence caveat | 32 MiB | `ResidencyBudgetOptions.Parse` (`:73-75`) | | `ACDREAM_RESIDENCY_STANDALONE_UNOWNED_MIB` | `=<int MiB>` (`>0`, else default) | Byte ceiling for unowned standalone texture memory | Same eviction-cadence caveat | 32 MiB | `ResidencyBudgetOptions.Parse` (`:73-75`) |
| `ACDREAM_RETAIL_CHASE` | `=0` disables (anything else/unset = on) | selects the retail-faithful `RetailChaseCamera` vs. the legacy rigid-follow `ChaseCamera` | swaps the entire active camera implementation — changes camera motion/feel; startup-only in practice (its DebugPanel/DebugVM mirror is unreachable — #434)| true (retail camera on) | `CameraDiagnostics.UseRetailChaseCamera` | | `ACDREAM_RETAIL_CHASE` | `=0` disables (anything else/unset = on) | selects the retail-faithful `RetailChaseCamera` vs. the legacy rigid-follow `ChaseCamera` | swaps the entire active camera implementation — changes camera motion/feel; startup-only in practice (its DebugPanel/DebugVM mirror is unreachable — #434)| true (retail camera on) | `CameraDiagnostics.UseRetailChaseCamera` |
| `ACDREAM_RETAIL_CLOSE_DEGRADES` | inverted: `="0"` disables; any other value (incl. unset) enables | Default-**on** real gameplay behavior: applies retail's close-range LOD mesh-part swap (`GfxObjDegradeResolver`) to humanoid setups (issue #47), matching retail's close-detail degrade. | Inverted default (opposite of every other boolean flag in this table — presence of the literal string `"0"` is what disables it, not presence of `"1"` enabling it). Documented explicitly as "set only for before/after diagnostic comparisons" — so although default-on production behavior, its *disable* path exists purely for A/B measurement. | `true` (enabled) unless value is exactly `"0"` | `RuntimeOptions.RetailCloseDegrades``DatLiveEntityProjectionMaterializer.cs:275-276,480-498` | | `ACDREAM_RETAIL_CLOSE_DEGRADES` | inverted: `="0"` disables; any other value (incl. unset) enables | Default-**on** real gameplay behavior: applies retail's close-range LOD mesh-part swap (`GfxObjDegradeResolver`) to humanoid setups (issue #47), matching retail's close-detail degrade. | Inverted default (opposite of every other boolean flag in this table — presence of the literal string `"0"` is what disables it, not presence of `"1"` enabling it). Documented explicitly as "set only for before/after diagnostic comparisons" — so although default-on production behavior, its *disable* path exists purely for A/B measurement. | `true` (enabled) unless value is exactly `"0"` | `RuntimeOptions.RetailCloseDegrades``DatLiveEntityProjectionMaterializer.cs:275-276,480-498` |
| `ACDREAM_RETAIL_UI` | `=1` | Switches on the retained retail UI host tree (`UiHost`/`UiRoot`, D.2b). Without it, no retained UI is composed at all — e.g. no chargen Appearance page, no Summary page. | **Forced to `true` unconditionally** for every `--session-config` / launcher launch (`RuntimeOptions.cs:282`, "a session-config launch IS a product launch — the retail UI is the shipped UI, not a dev option"), regardless of this env var's value — the env var only matters for the bare env-var dev-flow launch path. | `false` for the env-var dev flow; `true` always for `--session-config` launches | `RuntimeOptions.RetailUi``LivePresentationComposition.cs:1108-1131` (gates retained-UI mount via `InteractionRetainedUiComposition`), `GameWindow.cs:455,566` (comments), `RuntimeOptions.cs:277` | | `ACDREAM_RETAIL_UI` | `=0` disables; anything else/unset enables | Controls the retained retail UI host tree (`UiHost`/`UiRoot`, D.2b), the product's only gameplay UI. | Disabling it leaves world rendering with no gameplay interface and is intended only for diagnostics. The same rule applies to bare env and `--session-config`/launcher launches; no path force-overrides it. | `true` | `RuntimeOptions.RetailUi``LivePresentationComposition.cs` (gates retained-UI mount via `InteractionRetainedUiComposition`) |
| `ACDREAM_TEST_HOST` | `=<host>` | ACE server hostname for live-mode connect. | none | `"127.0.0.1"` | `RuntimeOptions.LiveHost` (`RuntimeOptions.cs:142`) | | `ACDREAM_TEST_HOST` | `=<host>` | ACE server hostname for live-mode connect. | none | `"127.0.0.1"` | `RuntimeOptions.LiveHost` (`RuntimeOptions.cs:142`) |
| `ACDREAM_TEST_PASS` | `=<string>` | ACE account password for live-mode connect. | Redacted in `RuntimeOptions.ToString()`/diagnostic printing by design (`PrintMembers` override, `RuntimeOptions.cs:326-342`) — defense-in-depth so it can never leak into a log/exception via the record's default printing. | `null` (empty → `HasLiveCredentials` false) | `RuntimeOptions.LivePass` (`RuntimeOptions.cs:145`) | | `ACDREAM_TEST_PASS` | `=<string>` | ACE account password for live-mode connect. | Redacted in `RuntimeOptions.ToString()`/diagnostic printing by design (`PrintMembers` override, `RuntimeOptions.cs:326-342`) — defense-in-depth so it can never leak into a log/exception via the record's default printing. | `null` (empty → `HasLiveCredentials` false) | `RuntimeOptions.LivePass` (`RuntimeOptions.cs:145`) |
| `ACDREAM_TEST_PORT` | `=<int>` | ACE server port for live-mode connect. | none | `9000` | `RuntimeOptions.LivePort` (`RuntimeOptions.cs:143`) | | `ACDREAM_TEST_PORT` | `=<int>` | ACE server port for live-mode connect. | none | `9000` | `RuntimeOptions.LivePort` (`RuntimeOptions.cs:143`) |
@ -184,7 +185,7 @@ fall back to `ACDREAM_DAT_DIR`.
| `ACDREAM_ORBIT_YAW_DEGREES` | `=<float>`, must be finite | Diagnostic-only initial orbit camera heading. | Non-finite values silently rejected (→ `null`). | `null` | `RuntimeOptions.InitialOrbitYawDegrees``GameWindow.cs:1413` | | `ACDREAM_ORBIT_YAW_DEGREES` | `=<float>`, must be finite | Diagnostic-only initial orbit camera heading. | Non-finite values silently rejected (→ `null`). | `null` | `RuntimeOptions.InitialOrbitYawDegrees``GameWindow.cs:1413` |
| `ACDREAM_PROBE_REVEAL_RADIUS` | `=<int>=1` (unparsable or `<1` → override absent; floor is 1, not 0) | #280 A/B measurement probe: forces the OUTDOOR reveal gate to use this landblock radius instead of the derived streaming window (near radius clamped to it), so a route can be measured with the pre-#280 behavior (`=1`, old `OutdoorNeighborhoodRadius`) vs. current | **Changes what gets revealed, not just measured** — genuinely resizes the reveal/visible window used by the live reveal gate. CLAUDE.md: "Leave it unset for any measurement or gate run — with it set you are measuring a different window than production." `=0` is rejected by the parser specifically because it would hang the very A/B route it exists to measure (`RequiredRenderRadius==0` fails `invalid-readiness-shape`). Not a user setting, not in Settings/RuntimeOptions, not persisted. | unset (derivation in charge, no override) | `StreamingDiagnostics.RevealRadiusOverride` (`StreamingDiagnostics.cs:25-27,76-80`), applied by `StreamingDiagnostics.ApplyRevealRadiusOverride` | | `ACDREAM_PROBE_REVEAL_RADIUS` | `=<int>=1` (unparsable or `<1` → override absent; floor is 1, not 0) | #280 A/B measurement probe: forces the OUTDOOR reveal gate to use this landblock radius instead of the derived streaming window (near radius clamped to it), so a route can be measured with the pre-#280 behavior (`=1`, old `OutdoorNeighborhoodRadius`) vs. current | **Changes what gets revealed, not just measured** — genuinely resizes the reveal/visible window used by the live reveal gate. CLAUDE.md: "Leave it unset for any measurement or gate run — with it set you are measuring a different window than production." `=0` is rejected by the parser specifically because it would hang the very A/B route it exists to measure (`RequiredRenderRadius==0` fails `invalid-readiness-shape`). Not a user setting, not in Settings/RuntimeOptions, not persisted. | unset (derivation in charge, no override) | `StreamingDiagnostics.RevealRadiusOverride` (`StreamingDiagnostics.cs:25-27,76-80`), applied by `StreamingDiagnostics.ApplyRevealRadiusOverride` |
| `ACDREAM_PROBE_WORLD_FRAME` | `=1` | gates one `[world-frame] agree` line per projected conversion in `DatLiveEntityProjectionMaterializer`, recording the world-frame center both `LiveWorldOriginState` (App) and Runtime's physics-state owner used (issue #283, "measurement only; it never gates placement") | print-only | off | `PhysicsDiagnostics.ProbeWorldFrameEnabled` | | `ACDREAM_PROBE_WORLD_FRAME` | `=1` | gates one `[world-frame] agree` line per projected conversion in `DatLiveEntityProjectionMaterializer`, recording the world-frame center both `LiveWorldOriginState` (App) and Runtime's physics-state owner used (issue #283, "measurement only; it never gates placement") | print-only | off | `PhysicsDiagnostics.ProbeWorldFrameEnabled` |
| `ACDREAM_SKY_PHASE_SECONDS` | `=<float>` (any finite value; negative accepted, taken mod 1 per axis) | Campaign V slice V7 instrument-determinism pin: freezes the sky's cloud-sheet UV scroll to a fixed elapsed-seconds value instead of wall-clock time, so two launches of a differential/offline gate agree about cloud position. | **Non-obvious dual effect**: this ONE var pins TWO independently-designed clocks that happen to share a name-adjacent purpose — the sky renderer's cloud scroll (`SkyRenderer.AnimationPhaseSecondsOverride`) AND, since Campaign VM slice VM6, the atmospheric post-process graph's foliage-wind clock (`_windClockSecondsOverride`). A gate that only knows about "sky clouds" and sets this to freeze them will *also* freeze foliage-wind evolution — deliberately snapped-to-target on the first advance per an A6 review fix, but still a second surface a naive reader wouldn't expect this var to touch. Distinct from `ACDREAM_DAY_GROUP`/`ACDREAM_WORLD_TIME`, which pin the OTHER sky clock (day group/sun angle) — retail's clouds drift independently of the calendar date by design. | `null`wall-clock driven (every ordinary run) | `RuntimeOptions.SkyAnimationPhaseSeconds``SkyRenderer.cs:79,85` (cloud UV scroll) **and** `AtmosphericPostProcessGraph.cs:560,586,671` (foliage-wind clock) | | `ACDREAM_SKY_PHASE_SECONDS` | `=<float>` (any finite value; negative accepted, taken mod 1 per axis) | Campaign V slice V7 instrument-determinism pin: freezes the sky's cloud-sheet UV scroll to a fixed elapsed-seconds value instead of monotonic real elapsed time, so two launches of a differential/offline gate agree about cloud position. | **Non-obvious dual effect**: this ONE var pins TWO independently-designed clocks that happen to share a name-adjacent purpose — the sky renderer's cloud scroll (`SkyRenderer.AnimationPhaseSecondsOverride`) AND, since Campaign VM slice VM6, the atmospheric post-process graph's foliage-wind clock (`_windClockSecondsOverride`). A gate that only knows about "sky clouds" and sets this to freeze them will *also* freeze foliage-wind evolution — deliberately snapped-to-target on the first advance per an A6 review fix, but still a second surface a naive reader wouldn't expect this var to touch. Distinct from `ACDREAM_DAY_GROUP`/`ACDREAM_WORLD_TIME`, which pin the OTHER sky clock (day group/sun angle) — retail's clouds drift independently of the calendar date by design. | `null`monotonic elapsed time (every ordinary run) | `RuntimeOptions.SkyAnimationPhaseSeconds``SkyRenderer.AnimationPhaseSecondsOverride` (cloud/rain UV scroll) **and** `AtmosphericPostProcessGraph`'s foliage-wind clock |
| `ACDREAM_STREAM_WORK_COMPLETIONS` | `=<int>` (`>0`, else default) | Per-frame ceiling on streaming completion admissions on the update thread | Class doc comment states explicitly: this whole `ACDREAM_STREAM_WORK_*` family "exists for A/B measurement only" — not a user/production setting. Directly changes streaming throughput per frame; do not compare a measurement taken with this set against a default run. | 64 | `StreamingWorkBudgetOptions.Parse` (`StreamingWorkBudgetOptions.cs:56-58`) | | `ACDREAM_STREAM_WORK_COMPLETIONS` | `=<int>` (`>0`, else default) | Per-frame ceiling on streaming completion admissions on the update thread | Class doc comment states explicitly: this whole `ACDREAM_STREAM_WORK_*` family "exists for A/B measurement only" — not a user/production setting. Directly changes streaming throughput per frame; do not compare a measurement taken with this set against a default run. | 64 | `StreamingWorkBudgetOptions.Parse` (`StreamingWorkBudgetOptions.cs:56-58`) |
| `ACDREAM_STREAM_WORK_CPU_MIB` | `=<int MiB>` (`>0`, else default) | Per-frame ceiling on adopted (newly resident) CPU bytes on the update thread | A/B-measurement-only family; changes per-frame CPU admission budget | 8 MiB | `StreamingWorkBudgetOptions.Parse` (`:59-61`) | | `ACDREAM_STREAM_WORK_CPU_MIB` | `=<int MiB>` (`>0`, else default) | Per-frame ceiling on adopted (newly resident) CPU bytes on the update thread | A/B-measurement-only family; changes per-frame CPU admission budget | 8 MiB | `StreamingWorkBudgetOptions.Parse` (`:59-61`) |
| `ACDREAM_STREAM_WORK_DEST_RESERVE_PERCENT` | `=<float percent>`, exclusive `0 < x < 100`, else default; stored as fraction (`percent/100`) | Fraction of the per-frame work budget reserved for the active reveal destination lane vs. background streaming | A/B-measurement-only family; reallocates frame budget between destination-lane and background streaming work, changing reveal-latency characteristics | 0.75 (75%) | `StreamingWorkBudgetOptions.Parse`/`ParseReservePercent` (`:71-73,154-169`) | | `ACDREAM_STREAM_WORK_DEST_RESERVE_PERCENT` | `=<float percent>`, exclusive `0 < x < 100`, else default; stored as fraction (`percent/100`) | Fraction of the per-frame work budget reserved for the active reveal destination lane vs. background streaming | A/B-measurement-only family; reallocates frame budget between destination-lane and background streaming work, changing reveal-latency characteristics | 0.75 (75%) | `StreamingWorkBudgetOptions.Parse`/`ParseReservePercent` (`:71-73,154-169`) |

View file

@ -503,6 +503,12 @@ elapsed seconds that `TexVelocityX/Y` accumulate against with a fixed value.
**Unset — the default, and every ordinary run — keeps the wall clock**, so **Unset — the default, and every ordinary run — keeps the wall clock**, so
nothing the user or the offline gate sees changes unless a gate asks. nothing the user or the offline gate sees changes unless a gate asks.
**2026-08-28 follow-up:** the ordinary sky-animation source is now a monotonic
`Stopwatch` clock, matching retail's accumulated timer deltas and preventing an
OS time correction from jumping rain/cloud UVs. The V7 pin and its gate
semantics are unchanged; the wall-clock wording above describes the original
V7 implementation.
It exists because `ACDREAM_DAY_GROUP` and the `AcdreamCycleTimeOfDay` override It exists because `ACDREAM_DAY_GROUP` and the `AcdreamCycleTimeOfDay` override
pin only the *other* sky clock: the Dereth date, which chooses the day group, pin only the *other* sky clock: the Dereth date, which chooses the day group,
the keyframe and the sun angle. The cloud sheet does not read that clock at all the keyframe and the sun angle. The cloud sheet does not read that clock at all
@ -2591,6 +2597,10 @@ Holtburg pair went from 23,090 differing pixels to 1,211. **The alternative was
`-MaskTopPixels`, which would have permanently blinded the campaign's strictest `-MaskTopPixels`, which would have permanently blinded the campaign's strictest
instrument to the entire sky.** See §5.1. instrument to the entire sky.** See §5.1.
The 2026-08-28 rain-timing parity follow-up replaced that adjustable wall clock
with monotonic `Stopwatch` elapsed time; this historical V7 measurement and the
diagnostic pin remain otherwise unchanged.
**3. The world clock was never pinned at all, and that was most of the number** **3. The world clock was never pinned at all, and that was most of the number**
(`1f25a609`). The route opened by pressing `AcdreamCycleTimeOfDay` three times. (`1f25a609`). The route opened by pressing `AcdreamCycleTimeOfDay` three times.
The mechanism underneath is `WorldTimeService.SetDebugTime`, and The mechanism underneath is `WorldTimeService.SetDebugTime`, and

View file

@ -13,6 +13,12 @@
> channel ids) remains accurate and is still the authority. Only the columns > channel ids) remains accurate and is still the authority. Only the columns
> describing what acdream does are out of date; verify against > describing what acdream does are out of date; verify against
> `ChatInputParser.cs` before trusting them. > `ChatInputParser.cs` before trusting them.
>
> **CORRECTION 2026-08-28 — issue #360 is complete.** The full local
> `@allegiance`/`@all`, `@house`/`@hou`, and standalone `@motd` dispatcher
> families are implemented with the named-retail grammar and exact GameAction
> wire layouts in both graphical and headless hosts. The older MISSING status
> cells in §2.5/§2.5b and the priority list are historical.
Date: 2026-08-09 Date: 2026-08-09

View file

@ -199,6 +199,27 @@ parsed at `CreateObject.cs:611-619`) — reused rather than re-defined.
`RuntimeHouseState` raw-field owner), and file the seven line builders `RuntimeHouseState` raw-field owner), and file the seven line builders
as an ISSUES entry rather than porting them tonight. as an ISSUES entry rather than porting them tonight.
### 2026-08-28 owned-house closeout
Issue #413 completed this deliberately deferred builder chain. Direct byte
disassembly of the PDB-paired `acclient.exe` recovered the literals and the
x87-obscured branches:
- buy and rent rows use `HousePaymentList::ComposeText`/`ComposeText2` with
retail's singular/plural fallback (`s`, or `es` after lowercase `s`/`x`);
- normal houses use 2,592,000-second periods and apartments use 7,776,000;
paid/maintenance-free rent advances the next-due row by two periods;
- location is `Location: %.1f%s, %.1f%s`, Y/S-N first and X/W-E second;
apartments emit no location;
- paid and unpaid warnings select `HousePanelTextColor` indices 1 and 2
through the row template's authored font-color palette;
- 0x0227 installs the new rent time and clears paid counts, while 0x0228
replaces the rent list; both rebuild the whole display just as retail does.
`RuntimeHouseStateTests`, `LiveSessionEventRouterTests`, and
`MapHousePanelControllerTests` cover the full synthetic wire/state/UI path.
The canonical Release gate passes 16,315/16,315 on 2026-08-28.
## Wire — GameEventType already has all four ids (corrects the handoff) ## Wire — GameEventType already has all four ids (corrects the handoff)
The handoff claimed "0x0227/0x0228 absent from the enum". Checked The handoff claimed "0x0227/0x0228 absent from the enum". Checked

View file

@ -0,0 +1,68 @@
# Issue #178 — retail EnvCell shell culling (2026-08-28)
## Outcome
The Phase A8 `CullMode.Landblock -> CullMode.None` workaround is removed
from both production EnvCell draw paths. Constructed cell-shell batches now
use retail's clockwise cull state instead of drawing every ordinary shell
face twice. No PAK rebuild is required: the package already contains the
authored `sides_type` and the correctly expanded index geometry; this change
selects the correct GPU state when that geometry is drawn.
The source and installed-DAT gates are complete. The owner visual gate passed
2026-08-28 (“Ok looks good”) at the requested interior matrix; #178 is closed.
## Retail oracle
The misleading detail is that `DatReaderWriter.Enums.CullMode` is used for
the CellStruct polygon's `sides_type`; its values are not direct GPU cull
states.
- `D3DPolyRender::ConstructMesh @ 0x0059DFA0` expands `sides_type` 0 as the
positive face, type 1 as that face plus a reversed copy, and type 2 as the
positive and negative surfaces. Its polygon fan is `[0, i-1, i]`; the
reversed copy is `[i, i-1, 0]`.
- `D3DPolyRender::RenderMeshSubset @ 0x0059CA10` draws the constructed mesh
with `D3DCULL_CW` on the ordinary path.
- `RenderDeviceD3D::DrawEnvCell @ 0x0059F170` uses that constructed-mesh
route. The immediate-mode exception for a type-1 polygon does not apply to
the EnvCell mesh.
`MeshExtractor.PrepareCellStructMeshData` already reproduces retail's fan and
the required reversed geometry. The render policy therefore must be
clockwise culling for every constructed shell batch; mapping DAT value 0 to
`None` was the obsolete workaround, while mapping it to the generic
`Landblock` render state would cull the opposite side.
## Installed-DAT catalog
`A8CellAudit cell-winding-catalog` scanned the installed DATs:
- 772 environments and 3,168 CellStructs
- 38,189 polygons and 70,091 generated fan triangles
- 37,843 `Landblock(0)` polygons and 346 `None(1)` polygons
- no unknown or unsupported `sides_type` values
- no missing polygon vertices
Vertex-normal orientation was recorded as a diagnostic, not treated as a
contract: CellStruct vertex normals may be smoothed rather than geometric,
and retail submits the identical authored fan.
## Implementation and gates
- `EnvCellRenderer.ResolveRetailCellShellCullMode` documents and enforces the
constructed-mesh policy in both the main and shadow-receiver draw paths.
- Hermetic extraction tests pin the exact type-0 fan and type-1 reversed-face
expansion.
- Renderer tests pin all four source enum values to the retail clockwise
constructed-mesh state.
- Installed-DAT audit: passed.
- Canonical Release gate: 16,321 passed, 0 skipped, 0 failed across 14 test
assemblies; Release build completed with 0 warnings and 0 errors.
## Owner visual acceptance
In Holtburg buildings and the Facility Hub, rotate the camera through walls,
floors, ceilings, ramps, and stairs from their ordinary playable sides.
Nothing should vanish at any camera angle. Acceptance of that matrix closes
#178. The owner accepted this matrix on 2026-08-28 (“Ok looks good”).

View file

@ -0,0 +1,141 @@
# Open-issue validity audit — 2026-08-28
## Scope and verdicts
This is a read-only product audit of every non-final entry in
`docs/ISSUES.md`. It does not fix product code and it does not close or
reprioritize anything. The ledger contains **88** such entries, not 87:
`#341` still says `OPEN` on its status line even though its heading and its
own evidence say it closed with a 10/10 gate.
- **CONFIRMED CURRENT** means the current source, a current missing path, or
repeated recorded evidence supports the material claim. This bucket also
identifies whether the item is a product defect, missing feature,
maintenance/performance debt, or test-infrastructure flake.
- **GHOST / NOT A CURRENT CLIENT ISSUE** means the entry is fixed, stale,
superseded, based on a deleted path, an accepted decision, an external
system/environment condition, or is a research/test-plan note misfiled as a
client issue. “Ghost” does not mean the original report was fabricated.
- **NEEDS VERIFICATION** means there was plausible historical evidence, but
current source inspection cannot prove the user-visible symptom still
exists. These should not be called either fixed or real until a focused
current-binary gate reproduces or clears them.
The audit checked the current source and tests, the evidence/status text in
the ledger, superseding issue/campaign records, and a clean current Release
build. The build completed with **0 warnings and 0 errors**.
## Confirmed current — 35
| ID | Type | Verification basis |
|---:|---|---|
| #178 | Closed after audit (owner-accepted 2026-08-28) | Named-retail confirms CellStruct `sides_type` controls constructed geometry and every constructed EnvCell subset uses clockwise culling. Both double-sided overrides are removed, the installed-DAT catalog found no invalid side types or missing vertices across 38,189 polygons, winding/policy tests plus the 16,321-test Release gate passed, and the owner accepted the Holtburg/Facility-Hub visual matrix. |
| #241 | Fixed after audit (2026-08-28) | The always-on parity partition now rejects off-frustum landblocks before walking entities and preserves the player's current landblock, using the prepared frame frustum already available at the call site. |
| #258 | Owner-voided 2026-08-28 | The former ImGui host is deleted, but the owner classified a replacement developer-panel host as undesired scope and closed the entry. |
| #261 | Telemetry defect | Production `LinkStatusSnapshot` still receives no packet-loss calculation, so `PacketLossPercentage` remains its default. |
| #310 | Fixed after audit (2026-08-28) | Collision-prefix retirement now supersedes an exact authored mover still awaiting first preparation, then completes through the normal withdrawal handshake; the former indefinite-poll regression converges. |
| #311 | Fixed after audit (2026-08-28) | `RetryPendingProjections` now uses retained, depth-safe scratch lists; the warmed fixture drops from 424 B to the event stream's 72 B publication floor and re-entry is regression-tested. |
| #313 | Fixed after audit (2026-08-28) | Successful split-to-world recovery now selects the resulting GUID through the canonical `SelectionState`; unrelated unknown spawns cannot steal selection. |
| #316 | Fixed after audit (2026-08-28) | The player-only `AirborneSnap` shadow skip is gone; every accepted landing publishes the resolved collision pose and the 27-row routing matrix passes. |
| #320 | Fixed after audit (2026-08-28; stale ledger entry) | The Runtime physics cutover already committed every ordinary transition's exact cell and rebucketed from `CellCommitted`; the added local-player test proves source-landblock retirement cannot park a player who walked across the boundary. |
| #322 | Fixed after audit (2026-08-28) | Both production callers now consume one pure disposition/HasAnims pre-placement derivation; the end-to-end application matrix and explicit truth table pass. |
| #324 | Architecture debt | Graphical and no-window hosts still own parallel inbound entity-routing composition. Accurate structural debt; no current symptom is proven. |
| #325 | Fixed after audit (2026-08-28) | Gate A and Runtime authority now accept equal-or-newer teleport stamps without consuming TELEPORT_TS; wrap, stale-pair, velocity, hook, and acknowledgement tests pass. |
| #330 | Headless capability defect | The headless composition still registers no live-entity collision owner, so bots cannot collide with dynamic creatures/objects. |
| #332 | Headless capability gap | Headless composition still lacks the graphical host's remote dead-reckoning path. Whether this is required product scope remains a decision. |
| #340 | Fixed before audit; stale ledger entry | `dfc841b7` injected a deterministic meter clock and the fixture uses a constant timestamp, removing suite load from the policy contract. |
| #346 | Fixed before audit; stale ledger entry | `dfc841b7` added tiered-JIT warmup plus five-batch sampling while retaining a threshold far below the former linear allocation regression. |
| #359 | Fixed after audit (2026-08-28) | The live route now passes the canonical player GUID and `ChatLog.OnPlayerKilled` suppresses victim/killer recipients exactly like retail; regression tests cover victim, killer, and bystander. |
| #360 | Fixed after audit (2026-08-28) | One shared Runtime dispatcher now implements the complete named-retail allegiance, house, and standalone `@motd` grammar for graphical and headless hosts; all GameAction payloads are byte-verified and the 16,309-test Release gate passes. |
| #361 | Fixed after audit (2026-08-28) | `@log` has a reconnect-safe file lifecycle; `@day` now toggles persistent noon landscape lighting; `@render radius/fov` implements the named-retail parsing, bounds, replies, and persisted renderer settings. |
| #370 | Headless movement defect | The released-jump probe reproduced 3/3 after the threading hypothesis was eliminated. No later fix is recorded. |
| #393 | Void/closed after audit (2026-08-28) | High-resolution DAT use is already implemented in both live and pak paths. The alleged separate retail toggle was a research misread; the remaining old texture-level degradation is explicitly unwanted by owner direction. |
| #400 | Fixed after audit (2026-08-28) | The exact authored two-root `gmCreditsUI` flow is mounted: 2,345 localized fragments, seven cyclic pictures, retail timing/shared scroll, Please Wait exit, and return to character management. Installed-DAT and controller gates pass. |
| #401 | Fixed after audit (2026-08-28) | Retained UI is now default-on across all launch paths, literal `ACDREAM_RETAIL_UI=0` opts out, the session-config force is removed, and authoritative launch docs/tests are updated. |
| #402 | Fixed before audit; stale ledger entry | `dfc841b7` moved the contract to a named dedicated thread with bounded start/block/join and captured worker failure, eliminating the fragile scheduling observation. |
| #403 | Fixed after audit (2026-08-28) | The live presenter now delegates advance/wrap and no-sequence interpolation to `RetailAnimationCyclePlayback`; Core and presenter regression suites pass. |
| #404 | Fixed after audit (2026-08-28) | Resolver now consumes `Runtime.CharacterCreation.Options`; the second raw SkillTable read and lock are gone, with focused, synthetic-projection, and installed-DAT gates passing. |
| #408 | Fixed after audit (2026-08-28) | The shared importer now applies DAT `0x3B` client-wide; all stateful retained widget types apply named/DirectState visibility, the scoped chargen path is deleted, and an installed-DAT sweep proves 990/990 built authored-invisible widgets across 38 layouts start hidden. |
| #410 | Fixed after audit (2026-08-28) | Both justification axes now share retail's exact 1/3/5 table, unauthored text defaults Left/Top, merge uses property presence, and automated plus installed-DAT UI suites pass. |
| #413 | Fixed after audit (2026-08-28) | All owned-house builders now match the recovered retail strings/math/colors; 0x0227/0x0228 refresh the shared Runtime owner, synthetic wire/state/UI coverage passes, and the 16,315-test Release gate is green. |
| #421 | Performance debt | The directional-shadow renderer still owns and uploads `DirectionalShadowTransformBufferSet` separately from the main instance SSBO. |
| #422 | Intermittent native crash | Heap corruption at graceful exit has multiple independent sightings, including a connected run. It lacks a stack, not evidence. |
| #423 | Rendering-policy mismatch | The atmospheric pack still declares and evaluates raw `ActiveDayGroupMultiplier` values instead of a `WeatherKind`-keyed policy. |
| #428 | Fixed after audit (2026-08-28) | Installed DATs proved the scripted sky carriers have one identity-transformed part 0; publishing that exact synthetic part lets legitimate particle hooks resolve, while the day-group-flip regression proves teardown prevents later dispatch. |
| #438 | Missing diagnostic feature | Launcher crash bundles are genuinely not implemented. This is an approved enhancement, not an existing launcher malfunction. |
| #442 | Fixed before audit; stale ledger entry | `d123c4b6` moved the warmed dense path to the shared 64-frame zero-allocation measurement probe; the strict zero allocation contract remains. |
## Ghost / not a current client issue — 33
| ID | Why it should not remain framed as a current client defect |
|---:|---|
| #3 | Fixed: periodic `TimeSync` is parsed and routed into the world clock. |
| #73 | A process policy for future string sweeps, not a concrete defect; its own text says no infrastructure work remains. |
| #180 | Both camera-collision fixes shipped and were log-verified; the residual visual was moved to now-closed #181. |
| #194 | Fixed: `WbDrawDispatcher.BeginFrame` prunes old instance groups and has coverage. |
| #195 | Obsolete architecture: the duplicate ChatVM/provider shape described by the issue no longer exists; local commands have dedicated routing. |
| #199 | Fixed by the Campaign CA server-authoritative one-request-in-flight raise flow; optimistic local mutation was removed. |
| #200 | Stale migration list: the old inline mounts/MockupDesktop path named by the issue no longer describes current composition. |
| #212 | Implementation and regression coverage are present; the status remained `IN-PROGRESS` only for an old user gate. |
| #213 | Fixed: client commands are intercepted by `ClientCommandController` instead of being sent to ACE as chat. |
| #228 | Directly disproved by the current Release build: 0 warnings, not 17. |
| #242 | Fixed: static presentation orders/prepares snapshots once and reuses the prepared replacement rather than rebuilding a third dictionary per attempt. |
| #249 | Obsolete: it targets the deleted OpenGL/bindless backend; production is Vulkan-only. |
| #251 | Obsolete: it targets deleted `glClientWaitSync`/OpenGL fence code. |
| #256 | Superseded by #260; its discriminator found no missing-object drift and the transport-loss mechanism was fixed elsewhere. |
| #257 | Superseded/refuted as a leak: its own portal-churn discriminator was flat/negative and later PAK/runtime work replaced the measured architecture. |
| #259 | Explicitly a machine-wide Vulkan/environment failure, not an acdream product defect. |
| #274 | A request for a connected retail comparison, not an observed failure. It belongs in a gate/research checklist. |
| #309 | Explicit owner-accepted divergence recorded in the divergence register, with no planned fix. |
| #318 | A test-plan/composition-coverage residual, not evidence of a product defect. Track as test debt if still desired. |
| #339 | Fixed and live-validated; current mesh-publication guards are present. The status header was never finalized. |
| #341 | Internally contradictory ghost: heading/evidence say closed and 10/10 bit-identical, but the status line still says `OPEN`. |
| #342 | Fixed: the current assertion compares old-model and new-model values rather than the same expression to itself. |
| #343 | Fixed; the issue body already records the guarded native-release lifecycle correction. |
| #344 | Fixed; the issue body already records the teleport-authority discriminator and clean suite. |
| #350 | Fixed in current source: the lifetime render-shadow counters are widened to `long`. |
| #352 | The behavior was live-gate verified; the only remaining request is one extra discriminating unit test. This is test debt, not a vendor bug. |
| #366 | Fixed: the chat controller now owns the unread indicator, unseen-text state, tick/click behavior, and tests. |
| #369 | An unanswered retail research question with no demonstrated mismatch, so it does not belong as a product issue. |
| #383 | Installed-DAT/committed-fixture provenance drift is environment/fixture-maintenance work, not a current client defect. |
| #384 | The client sends the swear action; the missing response is an ACE server behavior/blocker, not an acdream client bug. |
| #396 | Fixed and live-verified crash-free; only a visual re-check of the already-mounted instruction dialog remained. |
| #425 | Fixed; both resolution-scaled pack budgets and activation memo behavior are recorded in the issue itself. |
| #427 | Fixed and owner-reported; the sky/fog seam correction is already recorded in the issue itself. |
## Needs current verification — 20
| ID | What is known and what is still required |
|---:|---|
| #2 | The old lightning presentation mismatch was plausible, but the sky/PES pipeline changed substantially. Reproduce side-by-side on the current binary. |
| #29 | The thin-cloud observation has no current post-renderer visual gate. A present-day retail comparison is required. |
| #55 | The 1.45M `meshMissing` figure belongs to an old streaming diagnostic. Re-measure current PAK-v2 production before treating it as real. |
| #130 | Closed as owner-accepted residual (2026-08-28) | The connected visual re-gate confirmed the thin top-edge strip remains; owner direction is to leave it as-is. |
| #177 | Strong historical dungeon evidence exists, but the renderer/portal pipeline changed afterward. Repeat the named stair routes on the current binary. |
| #183 | Owner-closed 2026-08-28 after the validity audit. |
| #250 | The zero-allocation flakes were real historically, but the stated roughly-one-in-three frequency predates major runtime/test changes. Run a current parallel stress lane. |
| #262 | Owner reports the first-login movement issue solved; closed 2026-08-28. |
| #265 | Closed during follow-through: Campaign P's final user matrix already accepted downhill bounce, flat pop, and uphill landing on 2026-07-31. |
| #267 | Closed during follow-through: Campaign P's final user matrix already accepted live vitae/buff values and immediate skill-row refresh on 2026-07-31. |
| #317 | The call still exists, but “has no retail basis” is a research conclusion, not proof that current behavior is wrong or reachable. |
| #321 | One old full-suite sound-cache failure is not enough to establish a current flake after later suite/runtime changes. Stress the exact class in parallel. |
| #323 | The stale-receipt mechanism is plausible, but production reachability and a user-visible symptom were never established. |
| #377 | The fullscreen crash was once deterministic but is explicitly not reproducible on current display code. A current cold-start matrix decides it. |
| #397 | Closed during follow-through: the real Windows supervisor stopped a connected Release headless host gracefully, and a separately supervised process re-entered the same account after the documented 2.5-second ACE account-release quiescence; both exited code 0. |
| #431 | CA2CA4 implemented the missing inbound/recompute flow and the first CA5 drive passed Quickness/run updates. The original title is no longer proven; remaining CA5 cases need live gating. |
| #433 | Owner-observed stale entities are credible, but the issue is intermittent and has no current captured reproduction. |
| #439 | One full-suite failure followed by an isolated pass and clean rerun is only a flake candidate. Run the established timing lane before confirming it. |
| #441 | Owner-closed 2026-08-28 after two old observations were followed by a healthy probed baseline and no recurrence. |
| #452 | Closed during follow-through: both fixed `app-release24` sessions have retained logs proving graceful logout and orderly teardown after the 100-switch/30-minute stress. |
## Recommended ledger cleanup order
1. Close or archive the 33 ghost entries after owner review.
2. Keep type labels on confirmed items so
enhancements, maintenance debt, and flaky tests are not mistaken for
gameplay defects.
3. Run one focused verification batch for the 20 uncertain entries. Close a
fixed-awaiting-gate item when its current gate passes; close an old symptom
as stale when its documented current reproduction route no longer fails.
4. Prioritize only the confirmed product defects after the ledger is clean;
do not mix them with refactors, test debt, or external ACE/environment work.

View file

@ -0,0 +1,108 @@
# Owner-closed issue validity audit — 2026-08-28
## Scope and verdicts
This is a read-only companion to the
[open-entry validity audit](2026-08-28-open-issue-validity-audit.md). It checks
the exact **57 unique issue IDs** in the owner's 2026-08-28 closure request.
Fifty-six entries were newly marked closed; `#167` was already `DONE` before
that request. This audit changes no issue status and fixes no product code.
- **OWNER-CONFIRMED CLOSED DESPITE REMAINING EVIDENCE** means current source or
still-applicable recorded evidence supports unresolved work, but the owner
explicitly chose on 2026-08-28 to keep the entry closed. This records a
scope/status decision, not technical proof that the symptom is fixed.
- **VERIFY BEFORE KEEPING CLOSED** means the historical report was credible,
but later architectural changes or an implemented fix make a current live
gate necessary before deciding whether the closure is correct.
- **SAFE TO REMAIN CLOSED** means current code/tests and later project history
show the issue fixed, superseded, disproved, owner-accepted, or not a valid
product defect in the first place.
The audit inspected current source and focused tests, each issue's complete
evidence trail, later campaign/user-gate records, and the current architecture.
The supporting focused Release tests completed with **321 passed, 1 skipped,
0 failed**. The companion open-entry audit's clean Release build completed with
**0 warnings and 0 errors**.
## Owner-confirmed closed despite remaining evidence — 3
| ID | Remaining technical evidence |
|---:|---|
| #64 | The local pickup transaction sends/accepts the pickup, but the current local-player animation path still has no pickup one-shot trigger and no admitted server-motion equivalent. The original missing local bend/grab animation remains an unimplemented path. |
| #235 | The live animation presenter still publishes part poses at admitted object-clock frames. Root presentation is smoothed, but no render-time interpolation exists between animated part poses, which is the exact capped/RDP cadence alias described by the issue. |
| #263 | The Drudge Scrying Orb residual was user-confirmed after the general translucency fix and explicitly deferred. No later orb-specific ClipMap/order fix or clearing visual gate exists. |
## Verify before keeping closed — 11
| ID | What is known and what should be gated |
|---:|---|
| #41 | The remote-root-motion correction was implemented, but the entry never received its final two-client sub-decimeter-blip visual gate. Observe another player starting/stopping/turning on the current binary. |
| #94 | The original held-light report became testable only after child parenting landed. Current lighting distinguishes authored point and spot lights, but there is no post-parenting side-by-side reproduction or clearance of the wall spotlight symptom. |
| #115 | The cramped-interior camera feel report was credible, but camera collision, viewer stepping, display pacing, and the renderer changed substantially afterward. Repeat the same pressed-wall turns against retail. |
| #129 | The long-distance door/doorway leak fix shipped, but the entry retained an explicit distant-view visual gate. Revisit the named over-one-landblock views before treating it as conclusively closed. |
| #165 | Remote wall penetration was observed, while the leading physics explanations were later ruled out and the remaining presentation hypothesis was never proven. A current observer/client reproduction decides it. |
| #181 | The Facility Hub artifact survived its original seven-fix ladder, but its early attribution was partly retracted and the camera/render pipeline later changed. Repeat the exact pressed-camera pose without capture overlays. |
| #208 | The peaceful-login combat-bar correction is present, but the issue's final fresh-login visual check was never recorded. Gate a fresh peaceful login and one relog. |
| #225 | The particle/translucent-object ordering implementation and stress tests pass, but the issue explicitly retained its final lifestone/candle visual matrix. Run that matrix; `#263` remains a separate orb-specific residual. |
| #337 | The Neftet rock behavior is covered by the `#333` broadphase fix, but the exact rock-plateau live acceptance was never recorded. Re-test that fixture before relying only on the general mechanism. |
| #409 | The client-wide tooltip system is code-complete and one live path was proven, but the remaining connected hover paths in its acceptance matrix were still owed. Complete that small UI gate. |
| #418 | The original 27 s/32-blocks-per-second limiter was fixed and reduced dramatically, so the title is stale. Cold-login readiness was still above the retail-feel target at the last measured checkpoint, and later PAK/runtime work was not re-measured with the same probe; run one current cold-login timing gate. |
## Safe to remain closed — 43
| ID | Why the closure is safe |
|---:|---|
| #102 | Fixed: `PortalVisibilityBuilder` now uses retail's in-place `update_count` watermark/fixpoint propagation; the old per-cell cap is gone and focused convergence tests pass. |
| #104 | Fixed: VFX instances carry owner-cell identity and `ParticleVisibilityController` gates them against the current PView visible-cell set. |
| #114 | Superseded by the current indoor renderer: the old `gl_ClipDistance` shell-chop mechanism named by the issue was deliberately deleted in favor of whole-shell depth/order handling. |
| #116 | The second divergence was fixed; the remaining first shape was traced to a simplified fixture while the real Path-5/Path-6 behavior is retail-exact. No confirmed engine defect remains. |
| #120 | Fixed: the reciprocal/in-place convergence case is pinned by the current portal-visibility replay tests. |
| #121 | Fixed and superseded by later portal campaigns/gates; world-portal VFX are routed through the current scene-particle path rather than the broken visibility path described here. |
| #139 | Fixed: retained buttons and menus now implement the authored normal, rollover, pressed, highlight, and open-state transitions with regression coverage. |
| #144 | Not an established defect: the empty-slot release is a harmless no-op and the entry never established that retail suppresses the click notification differently. |
| #146 | Non-actionable polish list: the capacity-bar visuals were already accepted and the entry asks for unspecified additional polish rather than describing a remaining defect. |
| #148 | Fixed: the authored backpack button toggles the inventory panel and projects open/closed state back to Highlight/Normal, with tests. |
| #151 | Fixed for the reported defect: the Arwic/city wall collision was user-verified. The residual terrain note was a question, not a demonstrated mismatch. |
| #158 | Not a bug by its own description; it is an old, vague character-window polish list against presentation paths that were subsequently replaced. |
| #166 | Fixed and user-gated as part of the closed Campaign P landing-momentum/bounce family. |
| #167 | Already `DONE` before the cleanup request; the retail leash behavior and constants were ported and covered. |
| #172 | Fixed and covered by the user-accepted Campaign P portal-platform/step-up gate. |
| #173 | Fixed and covered by the user-accepted Campaign P remote ceiling-response gate. |
| #174 | Fixed: door-use animation-link queue handling shipped and the later physics/door gate was accepted. |
| #175 | Fixed: door collision now follows the closed motion-table pose; the later physics/door gate was accepted. |
| #176 | The original purple flashing/lighting defect was fixed and verified. Its distinct pressed-camera residual was split into `#181`, so keeping this parent closed is correct. |
| #182 | Fixed by the retail collision-velocity rebuild and covered by the accepted crowded-movement Campaign P matrix. |
| #191 | Fixed by the root-motion presentation path; current coverage asserts that a brief forward edge cannot glide when the animation contributes no root delta. |
| #193 | Fixed and measurement-verified: the retained-resource/OOM correction survived the extended-play resource gates. |
| #202 | Fixed: the current `WeenieErrorMessages` table and routing replace the old missing lookup and cover interpolation plus `UseDone` behavior. |
| #209 | Fixed and subsequently live-gated: the jump/combat power-bar modes and captions were accepted in the later keyboard/powerbar rounds. |
| #226 | Fixed and connected-visual verified: building/EnvCell detail-texture overlay is present in the modern renderer. |
| #253 | Superseded/disproved by later byte-exact retail-DAT research: the shared row template authors a flush-left 20×20 icon cell, and the current sprite draw preserves authored texture alpha. The earlier guessed centering offset was not retail ground truth. |
| #264 | Research note, not a confirmed client defect: it explicitly records no known `WATER_CONTACT_TS` divergence and asks for xref/swim investigation. Any discovered mismatch should be filed as a concrete new issue. |
| #273 | Fixed: the exact captured Holtburg gap fixture now blocks traversal, requires both real geometry participants, and matches graph/flat collision paths in focused tests. |
| #319 | Fixed and user-gated in placement Campaign C4 route 7: player-parented children receive canonical-cell propagation through the unified owner. |
| #333 | Fixed: the broadphase no longer rejects an off-centre BSP part by measuring reach only from the part origin; regression coverage pins the case. |
| #348 | Fixed: cursor objects are process-lifetime cached instead of being recreated on every alternation; later long connected sessions did not reproduce handle exhaustion. |
| #394 | Fixed: Configure Keyboard row captions use the authored serif DAT font, with current layout tests and later keyboard gates. |
| #395 | Fixed: key captions resolve through retail's DAT/localized key-name pipeline rather than raw enum spellings. |
| #414 | Fixed: logout teardown no longer leaves the cursor in raw-captured fly mode. |
| #415 | Fixed: deferred UI-probe wait verbs are bound independently of the optional artifact-directory setting. |
| #416 | Fixed: roster rollover state clears when the pointer leaves a row. |
| #417 | Fixed: ambience ownership is reset on world logout and does not continue/re-fire at character selection. |
| #419 | Fixed and explicitly owner-accepted: the portal rim/ring artifact disappeared after matching retail's view-plane exit animation. |
| #420 | Fixed: `UiButton` safely handles the character-select media-state case that previously produced the null-key crash. |
| #424 | Fixed: exclusive-fullscreen alt-tab no longer sends a zero-area extent into render-pack activation validation. |
| #426 | Fixed: NO_POS_UVS/solid-colour faces are extracted and rendered instead of being discarded as having no positive face. |
| #432 | Disproved as a production performance bug: the allocation/frame-time spike came from diagnostics being enabled, not the normal client path. |
| #443 | Fixed and owner-passed: paperdoll/private-viewport ownership now hydrates on first open without the intermittent missing/delayed doll. |
## Recommended action
1. Keep `#64`, `#235`, and `#263` closed per the owner's explicit post-audit
decision. If one becomes a priority later, reopen it or file a fresh issue
with a current reproduction.
2. Run one compact current-binary gate for the 11 verification items; keep each
closed if the named route passes, otherwise reopen it with the new evidence.
3. Leave the remaining 43 closed. Do not resurrect their stale titles merely
because their original status text once said `OPEN`.

View file

@ -11,6 +11,7 @@ using AcDream.Runtime;
using AcDream.Runtime.Session; using AcDream.Runtime.Session;
using AcDream.Core.Combat; using AcDream.Core.Combat;
using AcDream.Core.Lighting; using AcDream.Core.Lighting;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics; using AcDream.Core.Physics;
using AcDream.Core.Selection; using AcDream.Core.Selection;
using AcDream.Core.World; using AcDream.Core.World;
@ -393,7 +394,10 @@ internal sealed class FrameRootCompositionPhase
live.EnvCellRenderer!, live.EnvCellRenderer!,
foundation.SceneLighting!, foundation.SceneLighting!,
d.RenderRange, d.RenderRange,
skyPesFrame); skyPesFrame,
persistentDaylight: () =>
d.Runtime.CharacterOwner.Options.GetOptionBit(
CharacterOptionId.PersistentAtDay));
var worldRenderFrameBuilder = new WorldRenderFrameBuilder( var worldRenderFrameBuilder = new WorldRenderFrameBuilder(
new RuntimeWorldFrameCameraSource( new RuntimeWorldFrameCameraSource(
host.CameraController, host.CameraController,
@ -758,7 +762,8 @@ internal sealed class FrameRootCompositionPhase
var framePreparation = new RenderFramePreparationController( var framePreparation = new RenderFramePreparationController(
renderFrameResources, renderFrameResources,
devTools: null, devTools: null,
renderWeatherFrame); renderWeatherFrame,
live.PaperdollPresenter);
IRenderFramePostDiagnosticsPhase postDiagnostics = IRenderFramePostDiagnosticsPhase postDiagnostics =
renderSceneShadowComparison is not null renderSceneShadowComparison is not null
&& lifecycleAutomation is not null && lifecycleAutomation is not null

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -24,6 +24,8 @@ internal interface IPaperdollFrameView
bool TryGetVisibleSize(out int width, out int height); bool TryGetVisibleSize(out int width, out int height);
void SetTextureHandle(uint textureHandle); void SetTextureHandle(uint textureHandle);
void ClearTextureHandle();
} }
internal interface IPaperdollInventoryVisibility internal interface IPaperdollInventoryVisibility
@ -51,7 +53,9 @@ internal interface IPaperdollPoseApplicator
/// presentation edge. The renderer remains a borrowed resource disposed by /// presentation edge. The renderer remains a borrowed resource disposed by
/// the existing window shutdown transaction. /// the existing window shutdown transaction.
/// </summary> /// </summary>
internal sealed class PaperdollFramePresenter : IPrivateEntityViewportFrame internal sealed class PaperdollFramePresenter :
IPrivateEntityViewportFrame,
IPrivateEntityViewportResourcePreparation
{ {
private readonly IPaperdollDollRenderer _renderer; private readonly IPaperdollDollRenderer _renderer;
private readonly IPaperdollFrameView _view; private readonly IPaperdollFrameView _view;
@ -73,22 +77,30 @@ internal sealed class PaperdollFramePresenter : IPrivateEntityViewportFrame
public void MarkDirty() => _dirty = true; public void MarkDirty() => _dirty = true;
public void Render() /// <summary>
/// Pre-world resource phase: rebuild/redress the private clone and advance
/// its mesh/composite readiness BEFORE world draws can consume the
/// bounded per-frame composite-upload budget. In a dense scene the late
/// presentation phase never wins that budget, which is why the doll was
/// visible only while portal space quiesced the world (#443).
/// </summary>
public void PrepareResources()
{ {
if (_dirty) if (_dirty)
{ {
if (_factory.TryBuild(out WorldEntity? doll)) if (_factory.TryBuild(out WorldEntity? doll))
{ {
// Same-generation CreateObject refreshes can repeat the exact // Redress every accepted live-player refresh, including an
// player ObjDesc at a portal boundary. Retail redresses its // appearance-equivalent ObjDesc after portal/relogin. The
// private inventory object in place; releasing and reacquiring // private clone belongs to the current presentation
// an identical synthetic owner briefly blanks the viewport and // generation; retaining the old object merely because its
// churns its texture composites. // pixels compare equal can leave it attached to retired
if (!HasEquivalentAppearance(_doll, doll)) // mesh/composite readiness that never completes again.
{ // PrivateEntityViewportRenderer promotes replacements in two
// phases, so the last completed target remains visible until
// this fresh clone is completely drawable.
_renderer.SetDoll(doll); _renderer.SetDoll(doll);
_doll = doll; _doll = doll;
}
_dirty = false; _dirty = false;
} }
else else
@ -96,16 +108,25 @@ internal sealed class PaperdollFramePresenter : IPrivateEntityViewportFrame
// gmPaperDollUI::RedressCreature @ 0x004A3BC0 leaves its // gmPaperDollUI::RedressCreature @ 0x004A3BC0 leaves its
// private m_pInventoryObject intact when the SmartBox player // private m_pInventoryObject intact when the SmartBox player
// is temporarily unavailable. Keep the successful doll and // is temporarily unavailable. Keep the successful doll and
// retry this dirty redress on the next visible frame. // retry this dirty redress on the next frame.
} }
} }
_renderer.Prepare(); _renderer.Prepare();
}
public void Render()
{
if (!_view.TryGetVisibleSize(out int width, out int height)) if (!_view.TryGetVisibleSize(out int width, out int height))
return; return;
_view.SetTextureHandle(_renderer.Render(width, height)); // Zero is a transient not-ready result, not a request to erase a
// previously completed paperdoll. Session reset clears explicitly in
// ResetSession; ordinary mesh/composite upload latency keeps the last
// good image instead of intermittently blanking the viewport.
uint textureHandle = _renderer.Render(width, height);
if (textureHandle != 0u)
_view.SetTextureHandle(textureHandle);
} }
/// <summary> /// <summary>
@ -115,80 +136,10 @@ internal sealed class PaperdollFramePresenter : IPrivateEntityViewportFrame
public void ResetSession() public void ResetSession()
{ {
_renderer.SetDoll(null); _renderer.SetDoll(null);
_view.ClearTextureHandle();
_doll = null; _doll = null;
_dirty = true; _dirty = true;
} }
private static bool HasEquivalentAppearance(
WorldEntity? current,
WorldEntity? candidate)
{
if (current is null || candidate is null)
return ReferenceEquals(current, candidate);
if (current.SourceGfxObjOrSetupId != candidate.SourceGfxObjOrSetupId
|| current.Scale != candidate.Scale
|| current.HiddenPartsMask != candidate.HiddenPartsMask
|| current.MeshRefs.Count != candidate.MeshRefs.Count
|| current.PartOverrides.Count != candidate.PartOverrides.Count)
{
return false;
}
for (int i = 0; i < current.MeshRefs.Count; i++)
{
MeshRef left = current.MeshRefs[i];
MeshRef right = candidate.MeshRefs[i];
if (left.GfxObjId != right.GfxObjId
|| left.PartTransform != right.PartTransform
|| !DictionaryEquals(
left.SurfaceOverrides,
right.SurfaceOverrides))
{
return false;
}
}
for (int i = 0; i < current.PartOverrides.Count; i++)
{
if (current.PartOverrides[i] != candidate.PartOverrides[i])
return false;
}
PaletteOverride? leftPalette = current.PaletteOverride;
PaletteOverride? rightPalette = candidate.PaletteOverride;
if (leftPalette is null || rightPalette is null)
return leftPalette is null && rightPalette is null;
if (leftPalette.BasePaletteId != rightPalette.BasePaletteId
|| leftPalette.SubPalettes.Count != rightPalette.SubPalettes.Count)
{
return false;
}
for (int i = 0; i < leftPalette.SubPalettes.Count; i++)
{
if (leftPalette.SubPalettes[i] != rightPalette.SubPalettes[i])
return false;
}
return true;
}
private static bool DictionaryEquals(
IReadOnlyDictionary<uint, uint>? left,
IReadOnlyDictionary<uint, uint>? right)
{
if (left is null || right is null)
return left is null && right is null;
if (left.Count != right.Count)
return false;
foreach ((uint key, uint value) in left)
{
if (!right.TryGetValue(key, out uint rightValue)
|| rightValue != value)
{
return false;
}
}
return true;
}
} }
/// <summary>Retained-UI visibility and texture publication for the doll view.</summary> /// <summary>Retained-UI visibility and texture publication for the doll view.</summary>
@ -226,6 +177,9 @@ internal sealed class RetailPaperdollFrameView : IPaperdollFrameView
/// </summary> /// </summary>
public void SetTextureHandle(uint textureHandle) => public void SetTextureHandle(uint textureHandle) =>
_viewport.TextureSlot = UiTextureTableHandle.ToSlot(textureHandle); _viewport.TextureSlot = UiTextureTableHandle.ToSlot(textureHandle);
public void ClearTextureHandle() =>
_viewport.TextureSlot = GpuTextureSlot.Unassigned;
} }
/// <summary>Narrow visibility adapter for the paperdoll's inventory host.</summary> /// <summary>Narrow visibility adapter for the paperdoll's inventory host.</summary>

View file

@ -234,7 +234,8 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
IReadOnlySet<uint> attachedOwnerIds, IReadOnlySet<uint> attachedOwnerIds,
bool includeUnattached = false, bool includeUnattached = false,
IReadOnlySet<uint>? excludedAttachedOwnerIds = null, IReadOnlySet<uint>? excludedAttachedOwnerIds = null,
uint clipSlot = 0) uint clipSlot = 0,
UnattachedEmitterCellScope unattachedCellScope = UnattachedEmitterCellScope.Any)
{ {
if (camera is null) if (camera is null)
return; return;
@ -244,7 +245,8 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
attachedOwnerIds, attachedOwnerIds,
includeUnattached, includeUnattached,
_scopedEmitterScratch, _scopedEmitterScratch,
excludedAttachedOwnerIds); excludedAttachedOwnerIds,
unattachedCellScope);
Matrix4x4.Invert(camera.View, out Matrix4x4 invView); Matrix4x4.Invert(camera.View, out Matrix4x4 invView);
Vector3 cameraRight = Vector3.Normalize(new Vector3(invView.M11, invView.M12, invView.M13)); Vector3 cameraRight = Vector3.Normalize(new Vector3(invView.M11, invView.M12, invView.M13));
Vector3 cameraUp = Vector3.Normalize(new Vector3(invView.M21, invView.M22, invView.M23)); Vector3 cameraUp = Vector3.Normalize(new Vector3(invView.M21, invView.M22, invView.M23));

View file

@ -20,8 +20,8 @@ internal interface IPrivateEntityViewportCamera : ICamera
/// <summary> /// <summary>
/// Shared render-to-texture implementation for the private 3-D creature /// Shared render-to-texture implementation for the private 3-D creature
/// viewports used by paperdoll and examination UI. Each instance owns one /// viewports used by paperdoll and examination UI. Each instance owns one
/// <see cref="IGpuRenderTarget"/>, one synthetic render identity, and one /// <see cref="IGpuRenderTarget"/> per encountered GPU flight slot, one
/// balanced texture-owner lease. /// synthetic render identity, and one balanced texture-owner lease.
/// ///
/// <para><b>Campaign V slice V6k (V4g's first half).</b> The target used to be a /// <para><b>Campaign V slice V6k (V4g's first half).</b> The target used to be a
/// hand-rolled FBO, colour texture and depth renderbuffer, and the resulting GL /// hand-rolled FBO, colour texture and depth renderbuffer, and the resulting GL
@ -65,7 +65,6 @@ internal sealed class PrivateEntityViewportRenderer :
{ {
private const uint PrivateLandblockId = 0u; private const uint PrivateLandblockId = 0u;
private readonly IGpuDevice _device;
private readonly ICurrentGpuFrameSource _frames; private readonly ICurrentGpuFrameSource _frames;
/// <summary> /// <summary>
@ -94,17 +93,12 @@ internal sealed class PrivateEntityViewportRenderer :
/// feature does not exist for them, not just "unused".</summary> /// feature does not exist for them, not just "unused".</summary>
private readonly EntitySlot? _backdropSlot; private readonly EntitySlot? _backdropSlot;
// One stable sampled texture-table slot is part of the retained viewport's // A target written by frame N cannot also be sampled by an unretired frame
// presentation contract. Rotating the slot with the Vulkan flight index // N-1. Vulkan permits those command buffers to overlap, so one shared image
// made the UI sample a freshly-created/cleared sibling after world reveal. // is a cross-frame write/read race. The current frame slot selects one
// The frame submission order already protects this target's write -> sample // bounded target + texture-table handle; the retained UI samples that exact
// transition; keep its identity stable until resize or disposal. // handle later in the same command buffer.
private IGpuRenderTarget? _target; private readonly PrivateViewportFlightTargets _flightTargets;
private IGpuSampler? _sampler;
private GpuTextureSlot _slot = GpuTextureSlot.Unassigned;
private int _fbW;
private int _fbH;
private bool _hasRenderedScene;
public PrivateEntityViewportRenderer( public PrivateEntityViewportRenderer(
IWorldPassScope scope, IWorldPassScope scope,
@ -127,7 +121,7 @@ internal sealed class PrivateEntityViewportRenderer :
_scope = scope ?? throw new ArgumentNullException( _scope = scope ?? throw new ArgumentNullException(
nameof(scope), nameof(scope),
"The viewport must publish a world pass scope to draw into."); "The viewport must publish a world pass scope to draw into.");
_device = device ?? throw new ArgumentNullException(nameof(device)); ArgumentNullException.ThrowIfNull(device);
_frames = frames ?? throw new ArgumentNullException(nameof(frames)); _frames = frames ?? throw new ArgumentNullException(nameof(frames));
_dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
_lightUbo = lightUbo ?? throw new ArgumentNullException(nameof(lightUbo)); _lightUbo = lightUbo ?? throw new ArgumentNullException(nameof(lightUbo));
@ -137,6 +131,9 @@ internal sealed class PrivateEntityViewportRenderer :
_diagnosticName = string.IsNullOrWhiteSpace(diagnosticName) _diagnosticName = string.IsNullOrWhiteSpace(diagnosticName)
? "creature viewport" ? "creature viewport"
: diagnosticName; : diagnosticName;
_flightTargets = new PrivateViewportFlightTargets(
device,
_diagnosticName);
IEntityTextureLifetime textureLifetimeChecked = textureLifetime IEntityTextureLifetime textureLifetimeChecked = textureLifetime
?? throw new ArgumentNullException(nameof(textureLifetime)); ?? throw new ArgumentNullException(nameof(textureLifetime));
@ -173,7 +170,17 @@ internal sealed class PrivateEntityViewportRenderer :
/// </summary> /// </summary>
public bool TextureIsBottomUp => false; public bool TextureIsBottomUp => false;
public void SetEntity(WorldEntity? entity) => _mainSlot.Set(entity); public void SetEntity(WorldEntity? entity)
{
_mainSlot.Set(entity);
if (entity is null)
{
// A character-session reset explicitly invalidates the sampled
// scenes. Do not let a replacement that is still uploading expose
// the previous character through any flight target.
_flightTargets.InvalidateCompletedScenes();
}
}
/// <summary> /// <summary>
/// Advances the private entity's mesh and texture-composite readiness /// Advances the private entity's mesh and texture-composite readiness
@ -226,51 +233,50 @@ internal sealed class PrivateEntityViewportRenderer :
/// </summary> /// </summary>
public uint Render(int width, int height) public uint Render(int width, int height)
{ {
if (width <= 0 || height <= 0)
return 0u;
IGpuFrame frame = _frames.CurrentFrame
?? throw new InvalidOperationException(
$"The {_diagnosticName} requires an open IGpuFrame (see GpuDeviceFrameLifetime).");
int frameSlot = frame.SlotIndex;
// #443: acquiring a synthetic mesh reference only schedules CPU // #443: acquiring a synthetic mesh reference only schedules CPU
// preparation/GPU upload; it does not make the mesh drawable. Keep the // preparation/GPU upload; it does not make the mesh drawable. Keep the
// last completed private scene intact until every drawable mesh in the // current flight slot's last completed private scene intact until every
// replacement has crossed that upload barrier. On first open there is // drawable mesh in the replacement has crossed that upload barrier.
// no completed scene, so return zero and let the authored panel art // On first open there is no completed scene, so return zero and let the
// show through instead of publishing a freshly-cleared black target. // authored panel art show through instead of publishing a cleared target.
bool mainReady = _mainSlot.PrepareForDraw(); bool mainReady = _mainSlot.PrepareForDraw();
bool backdropReady = _backdropSlot?.PrepareForDraw() ?? true; bool backdropReady = _backdropSlot?.PrepareForDraw() ?? true;
if (!mainReady || !backdropReady) if (!mainReady || !backdropReady)
{ {
return _mainSlot.Entity is not null return _mainSlot.Entity is not null
&& _hasRenderedScene ? _flightTargets.CompletedHandle(frameSlot)
&& _slot.IsAssigned
? UiTextureTableHandle.FromSlot(_slot)
: 0u; : 0u;
} }
WorldEntity? entity = _mainSlot.Entity; WorldEntity? entity = _mainSlot.Entity;
if (entity is null || entity.MeshRefs.Count == 0 || width <= 0 || height <= 0) if (entity is null || entity.MeshRefs.Count == 0)
return 0u; return 0u;
IReadOnlyList<WorldEntity> drawEntities = BuildDrawEntities( IReadOnlyList<WorldEntity> drawEntities = BuildDrawEntities(
_backdropSlot?.Entity, _backdropSlot?.Entity,
entity); entity);
if (!_dispatcher.PreparePrivateEntityResources(drawEntities)) if (!_dispatcher.PreparePrivateEntityResources(drawEntities))
{ return _flightTargets.CompletedHandle(frameSlot);
return _hasRenderedScene && _slot.IsAssigned
? UiTextureTableHandle.FromSlot(_slot)
: 0u;
}
EnsureRenderTarget(width, height); PrivateViewportFlightTargets.TargetSlot? targetSlot =
if (_target is null) _flightTargets.Ensure(frameSlot, width, height);
if (targetSlot is null)
return 0u; return 0u;
_camera.Aspect = width / (float)height; _camera.Aspect = width / (float)height;
IGpuFrame frame = _frames.CurrentFrame
?? throw new InvalidOperationException(
$"The {_diagnosticName} requires an open IGpuFrame (see GpuDeviceFrameLifetime).");
using IGpuPassEncoder encoder = frame.BeginPass(new GpuPassDescription using IGpuPassEncoder encoder = frame.BeginPass(new GpuPassDescription
{ {
Name = _diagnosticName, Name = _diagnosticName,
Color = new GpuColorAttachment( Color = new GpuColorAttachment(
Target: _target, Target: targetSlot.Target,
Load: GpuLoadOp.Clear, Load: GpuLoadOp.Clear,
Store: GpuStoreOp.Store, Store: GpuStoreOp.Store,
ClearColor: Vector4.Zero), ClearColor: Vector4.Zero),
@ -304,6 +310,12 @@ internal sealed class PrivateEntityViewportRenderer :
null), null),
}; };
// #443: a private pass must not append its transforms into the shared
// world transform frame — the default mesh shaders index parallel
// per-instance arrays zero-based, so a non-zero arena base zeroes the
// doll's per-instance opacity and the target stays blank whenever a
// world frame is active. See NextClassicDrawIsPrivatePass.
_dispatcher.NextClassicDrawIsPrivatePass = true;
_dispatcher.Draw( _dispatcher.Draw(
_camera, _camera,
entries, entries,
@ -311,8 +323,8 @@ internal sealed class PrivateEntityViewportRenderer :
neverCullLandblockId: PrivateLandblockId, neverCullLandblockId: PrivateLandblockId,
visibleCellIds: null, visibleCellIds: null,
animatedEntityIds: _animatedIds); animatedEntityIds: _animatedIds);
_hasRenderedScene = true; targetSlot.HasRenderedScene = true;
return UiTextureTableHandle.FromSlot(_slot); return UiTextureTableHandle.FromSlot(targetSlot.TextureSlot);
} }
/// <summary> /// <summary>
@ -363,69 +375,6 @@ internal sealed class PrivateEntityViewportRenderer :
}); });
} }
private void EnsureRenderTarget(int width, int height)
{
if (_target is not null && width == _fbW && height == _fbH)
return;
ReleaseRenderTarget();
IGpuRenderTarget target;
try
{
target = _device.CreateRenderTarget(new GpuRenderTargetDescription(
_diagnosticName,
width,
height,
GpuTextureFormat.Rgba8UnormRenderTarget,
// Depth24Stencil8, as the hand-rolled renderbuffer was: nothing
// samples it, and the stencil aspect keeps the attachment shape
// the depth/stencil renderers already expect.
GpuTextureFormat.Depth24Stencil8,
SampleCount: 1));
}
catch (Exception failure)
{
Console.WriteLine(
$"[{_diagnosticName}] render target unavailable ({width}x{height}): {failure.Message}");
return;
}
try
{
// The retained UI blits this attachment as an ordinary table entry.
// Linear/clamped is the filtering the hand-rolled colour texture set
// on itself before the §7.1 seam registered it.
_sampler = _device.CreateSampler(GpuSamplerDescription.WorldClamp);
_slot = _device.RegisterTexture(target.ColorTexture, _sampler);
}
catch
{
target.Dispose();
_sampler = null;
_slot = GpuTextureSlot.Unassigned;
throw;
}
_target = target;
_fbW = width;
_fbH = height;
}
private void ReleaseRenderTarget()
{
if (_slot.IsAssigned)
{
_device.ReleaseTextureSlot(_slot);
_slot = GpuTextureSlot.Unassigned;
}
_sampler = null;
_target?.Dispose();
_target = null;
_fbW = 0;
_fbH = 0;
_hasRenderedScene = false;
}
public void Dispose() public void Dispose()
{ {
List<Exception>? failures = null; List<Exception>? failures = null;
@ -447,7 +396,7 @@ internal sealed class PrivateEntityViewportRenderer :
} }
try try
{ {
ReleaseRenderTarget(); _flightTargets.Dispose();
} }
catch (Exception error) catch (Exception error)
{ {
@ -462,6 +411,163 @@ internal sealed class PrivateEntityViewportRenderer :
} }
} }
/// <summary>
/// Bounded render-target ownership keyed by <see cref="IGpuFrame.SlotIndex"/>.
/// A frame slot is reopened only after its previous submission retires, so
/// the target selected here can be written and sampled within that frame
/// without racing a different in-flight command buffer.
/// </summary>
internal sealed class PrivateViewportFlightTargets : IDisposable
{
internal sealed class TargetSlot(
IGpuRenderTarget target,
GpuTextureSlot textureSlot)
{
internal IGpuRenderTarget Target { get; } = target;
internal GpuTextureSlot TextureSlot { get; } = textureSlot;
internal bool HasRenderedScene { get; set; }
}
private readonly IGpuDevice _device;
private readonly string _diagnosticName;
private readonly List<TargetSlot?> _slots = [];
private int _width;
private int _height;
private bool _disposed;
internal PrivateViewportFlightTargets(
IGpuDevice device,
string diagnosticName)
{
_device = device ?? throw new ArgumentNullException(nameof(device));
_diagnosticName = string.IsNullOrWhiteSpace(diagnosticName)
? "creature viewport"
: diagnosticName;
}
internal int AllocatedSlotCount =>
_slots.Count(static slot => slot is not null);
internal TargetSlot? Ensure(int frameSlot, int width, int height)
{
ObjectDisposedException.ThrowIf(_disposed, this);
ArgumentOutOfRangeException.ThrowIfNegative(frameSlot);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(width);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(height);
if (_width != 0 && (_width != width || _height != height))
ReleaseAll();
while (_slots.Count <= frameSlot)
_slots.Add(null);
if (_slots[frameSlot] is { } existing)
return existing;
IGpuRenderTarget target;
try
{
target = _device.CreateRenderTarget(
new GpuRenderTargetDescription(
$"{_diagnosticName}-flight-{frameSlot}",
width,
height,
GpuTextureFormat.Rgba8UnormRenderTarget,
// Depth24Stencil8, as the original private viewport
// renderbuffer was. Nothing samples this attachment.
GpuTextureFormat.Depth24Stencil8,
SampleCount: 1));
}
catch (Exception failure)
{
Console.WriteLine(
$"[{_diagnosticName}] render target unavailable "
+ $"({width}x{height}, flight {frameSlot}): {failure.Message}");
return null;
}
try
{
// The device de-duplicates immutable samplers. Retained UI
// blits this target through its ordinary texture-table entry.
IGpuSampler sampler = _device.CreateSampler(
GpuSamplerDescription.WorldClamp);
GpuTextureSlot textureSlot = _device.RegisterTexture(
target.ColorTexture,
sampler);
var created = new TargetSlot(target, textureSlot);
_slots[frameSlot] = created;
_width = width;
_height = height;
return created;
}
catch
{
target.Dispose();
throw;
}
}
internal uint CompletedHandle(int frameSlot)
{
if ((uint)frameSlot >= (uint)_slots.Count
|| _slots[frameSlot] is not { HasRenderedScene: true } slot)
{
return 0u;
}
return UiTextureTableHandle.FromSlot(slot.TextureSlot);
}
internal void InvalidateCompletedScenes()
{
for (int i = 0; i < _slots.Count; i++)
{
if (_slots[i] is { } slot)
slot.HasRenderedScene = false;
}
}
private void ReleaseAll()
{
List<Exception>? failures = null;
for (int i = 0; i < _slots.Count; i++)
{
TargetSlot? slot = _slots[i];
if (slot is null)
continue;
try
{
_device.ReleaseTextureSlot(slot.TextureSlot);
}
catch (Exception error)
{
(failures ??= []).Add(error);
}
try
{
slot.Target.Dispose();
}
catch (Exception error)
{
(failures ??= []).Add(error);
}
}
_slots.Clear();
_width = 0;
_height = 0;
if (failures is { Count: > 0 })
throw new AggregateException(failures);
}
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
ReleaseAll();
}
}
/// <summary> /// <summary>
/// One private entity's mesh-reference/texture-owner lifetime, independent /// One private entity's mesh-reference/texture-owner lifetime, independent
/// of every other slot on the renderer. Publication is two-phase: a candidate owns its mesh /// of every other slot on the renderer. Publication is two-phase: a candidate owns its mesh

View file

@ -16,6 +16,19 @@ internal interface IPrivateEntityViewportFrame
void Render(); void Render();
} }
/// <summary>
/// Optional pre-world resource preparation for a private viewport whose
/// visibility must not compete with world composite uploads. The composite
/// upload budget opens with the frame's resource phase; a dense world can
/// consume all of it every frame, so a viewport that only prepares during
/// late presentation can starve indefinitely (#443's paperdoll: visible in
/// portal space — where the world is quiesced — and nowhere busy).
/// </summary>
internal interface IPrivateEntityViewportResourcePreparation
{
void PrepareResources();
}
internal interface IRetainedGameplayUiFrame internal interface IRetainedGameplayUiFrame
{ {
void Render(double deltaSeconds, int width, int height); void Render(double deltaSeconds, int width, int height);

View file

@ -29,20 +29,29 @@ internal sealed class RenderFramePreparationController : IRenderFrameResourcePha
private readonly IRenderFrameResourcePhase _resources; private readonly IRenderFrameResourcePhase _resources;
private readonly IDevToolsFrameLifecycle? _devTools; private readonly IDevToolsFrameLifecycle? _devTools;
private readonly IRenderWeatherFramePhase _weather; private readonly IRenderWeatherFramePhase _weather;
private readonly IPrivateEntityViewportResourcePreparation? _privateViewports;
public RenderFramePreparationController( public RenderFramePreparationController(
IRenderFrameResourcePhase resources, IRenderFrameResourcePhase resources,
IDevToolsFrameLifecycle? devTools, IDevToolsFrameLifecycle? devTools,
IRenderWeatherFramePhase weather) IRenderWeatherFramePhase weather,
IPrivateEntityViewportResourcePreparation? privateViewports = null)
{ {
_resources = resources ?? throw new ArgumentNullException(nameof(resources)); _resources = resources ?? throw new ArgumentNullException(nameof(resources));
_devTools = devTools; _devTools = devTools;
_weather = weather ?? throw new ArgumentNullException(nameof(weather)); _weather = weather ?? throw new ArgumentNullException(nameof(weather));
_privateViewports = privateViewports;
} }
public void Prepare(RenderFrameInput input) public void Prepare(RenderFrameInput input)
{ {
_resources.Prepare(input); _resources.Prepare(input);
// The composite upload budget opens with the resource phase. Give the
// paperdoll's private object its prewarm slot before the world can
// consume the complete per-frame budget (#443 — the doll rendered
// only while portal space quiesced the world); presentation samples
// the result later, after the world pass has closed.
_privateViewports?.PrepareResources();
_devTools?.BeginFrame((float)input.DeltaSeconds); _devTools?.BeginFrame((float)input.DeltaSeconds);
_weather.Tick(input.DeltaSeconds); _weather.Tick(input.DeltaSeconds);
} }

View file

@ -239,6 +239,106 @@ internal sealed class RetailAlphaQueue : IWorldSceneAlphaFrame
} }
} }
/// <summary>
/// Drains only the entries at or beyond <paramref name="minViewerDistance"/>
/// and keeps every nearer entry queued with the frame open. This is the
/// pre/inter-building barrier semantics: retail's far→near land walk means
/// <c>DrawBuilding</c>'s <c>FlushAlphaList(0f)</c> @0x0059F2A0 can only
/// flush content from cells FARTHER than that building — a nearer emitter
/// has not been inserted yet and composites after the building at a later
/// flush (the float there is a COUNT threshold, not a depth). The batched
/// landscape has no per-cell walk, so the same outcome is restored by
/// draining the far prefix of the established far→near order (AP-236).
/// Sources are deliberately NOT reset: retained tokens must stay valid
/// for the remaining entries' later <see cref="Flush"/>.
/// </summary>
public void FlushFartherThan(float minViewerDistance)
{
if (!IsCollecting)
throw new InvalidOperationException("Retail alpha flush requires an active frame.");
if (_submissions.Count == 0)
return;
float threshold = NormalizeDistance(minViewerDistance);
SortRetailOrder();
int prefix = 0;
while (prefix < _submissions.Count
&& _submissions[prefix].ViewerDistance >= threshold)
{
prefix++;
}
if (prefix == 0)
return;
try
{
EnsureTokenCapacity(prefix);
EnsureSourceCapacity(_sources.Count);
Array.Clear(_sourceDrawOffsets, 0, _sources.Count);
for (int sourceIndex = 0; sourceIndex < _sources.Count; sourceIndex++)
{
IRetailAlphaDrawSource source = _sources[sourceIndex];
int sourceCount = 0;
for (int i = 0; i < prefix; i++)
{
RetailAlphaSubmission submission = _submissions[i];
if (ReferenceEquals(submission.Source, source))
_tokenScratch[sourceCount++] = submission.Token;
}
if (sourceCount > 0)
source.PrepareAlphaDraws(_tokenScratch.AsSpan(0, sourceCount));
}
int start = 0;
while (start < prefix)
{
IRetailAlphaDrawSource source = _submissions[start].Source;
int end = start + 1;
while (end < prefix
&& ReferenceEquals(_submissions[end].Source, source))
end++;
int count = end - start;
int sourceIndex = FindSourceIndex(source);
int firstPreparedDraw = _sourceDrawOffsets[sourceIndex];
source.DrawPreparedAlphaBatch(firstPreparedDraw, count);
_sourceDrawOffsets[sourceIndex] += count;
start = end;
}
}
catch
{
// Converge to the full-drain failure shape: the retained suffix
// cannot be trusted once a source threw mid-prepare/draw.
_submissions.Clear();
List<Exception>? resetFailures = null;
for (int i = 0; i < _sources.Count; i++)
{
try
{
_sources[i].ResetAlphaSubmissions();
}
catch (Exception error)
{
(resetFailures ??= []).Add(error);
}
}
_sources.Clear();
if (resetFailures is { Count: > 0 })
{
throw new AggregateException(
"Retail alpha partial drain failed and its submissions could not be fully reset.",
resetFailures);
}
throw;
}
_submissions.RemoveRange(0, prefix);
}
public void EndFrame() public void EndFrame()
{ {
if (!IsCollecting) if (!IsCollecting)

View file

@ -444,20 +444,9 @@ internal sealed class RetailPViewPassExecutor :
animatedEntityIds: frame.AnimatedEntityIds); animatedEntityIds: frame.AnimatedEntityIds);
} }
_particleClassifications.ReplaceOutdoor(context.ParticleOwnerIds); // Late-stage particle owners submit ONCE per frame through
// DrawLandscapeStaticParticles after the slice loop (retail: one
if (_particleClassifications.Outdoor.Count > 0 // unclipped alpha-list insertion per emitter), not per slice here.
&& _particles is not null
&& _particleRenderer is not null)
{
_particleRenderer.DrawForOwners(
frame.Camera,
frame.CameraWorldPosition,
ParticleRenderPass.Scene,
_particleClassifications.Outdoor,
clipSlot: (uint)context.Slice.Slot);
}
EnableClipDistances(); EnableClipDistances();
if (frame.RenderSky && frame.RenderWeather) if (frame.RenderSky && frame.RenderWeather)
{ {
@ -492,8 +481,12 @@ internal sealed class RetailPViewPassExecutor :
RetailPViewFrameInput frame, RetailPViewFrameInput frame,
RetailPViewLandscapeStaticParticleContext context) RetailPViewLandscapeStaticParticleContext context)
{ {
bool scissor = BeginDoorwayScissor(context.Slice.NdcAabb); // One unclipped submission per owner per frame. Retail never clips a
_surface.BindTerrainClip(); // particle to a portal view — its polys join the one alpha list during
// the owner cell's walk turn and the depth test at the flush decides
// occlusion (FlushAlphaList @0x0059D2E0). The former per-slice call
// with the slice's clip slot both hardware-cut effects at aperture
// boundaries and double-submitted owners visible in two slices.
DisableClipDistances(); DisableClipDistances();
_particleClassifications.ReplaceOutdoor(context.ParticleOwnerIds); _particleClassifications.ReplaceOutdoor(context.ParticleOwnerIds);
@ -506,11 +499,9 @@ internal sealed class RetailPViewPassExecutor :
frame.CameraWorldPosition, frame.CameraWorldPosition,
ParticleRenderPass.Scene, ParticleRenderPass.Scene,
_particleClassifications.Outdoor, _particleClassifications.Outdoor,
clipSlot: (uint)context.Slice.Slot); clipSlot: 0);
} }
if (scissor)
_surface.EndScissor();
_entities.ClearClipRouting(); _entities.ClearClipRouting();
DisableClipDistances(); DisableClipDistances();
} }
@ -573,11 +564,19 @@ internal sealed class RetailPViewPassExecutor :
public void DrawUnattachedSceneParticles( public void DrawUnattachedSceneParticles(
RetailPViewFrameInput frame, RetailPViewFrameInput frame,
ClipViewSlice slice) bool outdoorCells)
{ {
if (_particles is null || _particleRenderer is null) if (_particles is null || _particleRenderer is null)
return; return;
// Retail draws an unattached emitter once, during its owner CELL's
// walk turn, with NO portal-view clip (CPhysicsObj::ShouldDrawParticles
// @0x0050FE60 gates by cell in-view + distance; occlusion is the depth
// test at FlushAlphaList @0x0059D2E0). Outdoor-cell emitters submit in
// the landscape stage, interior-cell emitters in the final world stage.
// The former once-per-OutsideView-slice submission with that slice's
// hardware clip slot made effects vanish by view direction (zero
// outside slices in view = zero submissions) — invented behavior.
DisableClipDistances(); DisableClipDistances();
_particleRenderer.DrawForOwners( _particleRenderer.DrawForOwners(
frame.Camera, frame.Camera,
@ -585,11 +584,17 @@ internal sealed class RetailPViewPassExecutor :
ParticleRenderPass.Scene, ParticleRenderPass.Scene,
_noSceneParticleEntityIds, _noSceneParticleEntityIds,
includeUnattached: true, includeUnattached: true,
clipSlot: (uint)slice.Slot); clipSlot: 0,
unattachedCellScope: outdoorCells
? UnattachedEmitterCellScope.OutdoorCells
: UnattachedEmitterCellScope.InteriorCells);
} }
public void FlushLandscapeAlpha() => _alpha.Flush(); public void FlushLandscapeAlpha() => _alpha.Flush();
public void FlushLandscapeAlphaFartherThan(float minViewerDistance) =>
_alpha.FlushFartherThan(minViewerDistance);
public void DrawCellParticles( public void DrawCellParticles(
RetailPViewFrameInput frame, RetailPViewFrameInput frame,
RetailPViewCellSliceContext context) RetailPViewCellSliceContext context)
@ -608,12 +613,14 @@ internal sealed class RetailPViewPassExecutor :
return; return;
DisableClipDistances(); DisableClipDistances();
// Retail never clips cell particles to a portal view: the owner
// cell's walls own occlusion via the depth test at the alpha flush.
_particleRenderer.DrawForOwners( _particleRenderer.DrawForOwners(
frame.Camera, frame.Camera,
frame.CameraWorldPosition, frame.CameraWorldPosition,
ParticleRenderPass.Scene, ParticleRenderPass.Scene,
visible, visible,
clipSlot: (uint)context.Slice.Slot); clipSlot: 0);
DisableClipDistances(); DisableClipDistances();
} }

View file

@ -28,6 +28,19 @@ public sealed class RetailPViewRenderer
private static readonly IReadOnlySet<uint> NoParticleOwners = private static readonly IReadOnlySet<uint> NoParticleOwners =
new HashSet<uint>(); new HashSet<uint>();
// Frame unions for the once-per-frame particle submissions (retail: one
// unclipped alpha-list insertion per emitter; occlusion by depth at the
// flush). Per-slice owner culls still run — these accumulate their union.
private readonly HashSet<uint> _staticParticleUnionScratch = new();
private readonly HashSet<uint> _cellParticleUnionScratch = new();
// Every cell drawn as a building look-in this frame. Retail marks each
// drawn non-player part for the frame (DrawMeshInternal @0x0059F360,
// GetDrawnThisFrame), so an object whose cell drew with a look-in cannot
// draw again in a later pass; dynamics-last consults this set to honor
// the same drawn-once contract.
private readonly HashSet<uint> _lookInCellIds = new();
private readonly HashSet<uint> _oneCell = new(1); private readonly HashSet<uint> _oneCell = new(1);
// Shell-batch scratch: all of a pass's cells collected for ONE batched // Shell-batch scratch: all of a pass's cells collected for ONE batched
// opaque Render call (instead of one heavy Render per cell). Reused across // opaque Render call (instead of one heavy Render per cell). Reused across
@ -166,13 +179,19 @@ public sealed class RetailPViewRenderer
// contains them). drawableCells itself stays the MAIN flood: it feeds the // contains them). drawableCells itself stays the MAIN flood: it feeds the
// seals, the outside-stage predicate, and the frame result. // seals, the outside-stage predicate, and the frame result.
var prepareCells = drawableCells; var prepareCells = drawableCells;
_lookInCellIds.Clear();
if (_lookInFrames.Count > 0) if (_lookInFrames.Count > 0)
{ {
_lookInPrepareScratch.Clear(); _lookInPrepareScratch.Clear();
_lookInPrepareScratch.UnionWith(drawableCells); _lookInPrepareScratch.UnionWith(drawableCells);
foreach (var f in _lookInFrames) foreach (var f in _lookInFrames)
{
foreach (uint c in f.OrderedVisibleCells) foreach (uint c in f.OrderedVisibleCells)
{
_lookInPrepareScratch.Add(c); _lookInPrepareScratch.Add(c);
_lookInCellIds.Add(c);
}
}
prepareCells = _lookInPrepareScratch; prepareCells = _lookInPrepareScratch;
} }
@ -239,7 +258,9 @@ public sealed class RetailPViewRenderer
_partitionResult, _partitionResult,
prepareCells, prepareCells,
ctx.LandblockEntries, ctx.LandblockEntries,
_partitionObserver); _partitionObserver,
ctx.Frustum,
ctx.PlayerLandblockId ?? 0u);
partition = _partitionResult; partition = _partitionResult;
} }
@ -249,10 +270,13 @@ public sealed class RetailPViewRenderer
RenderProjectionCounts sourceCounts = frameViewBorrowed RenderProjectionCounts sourceCounts = frameViewBorrowed
? frameView.SourceDigest.Counts ? frameView.SourceDigest.Counts
: LegacySourceCounts(partition!); : LegacySourceCounts(partition!);
// prepareCells is exactly "main flood look-in cells" — the cells
// this traversal actually reached, i.e. retail's in-view set.
RetailPViewFrameResult result = _frameResultScratch.Reset( RetailPViewFrameResult result = _frameResultScratch.Reset(
pvFrame, pvFrame,
clipAssembly, clipAssembly,
drawableCells, drawableCells,
prepareCells,
counts, counts,
sourceCounts, sourceCounts,
partition); partition);
@ -294,6 +318,31 @@ public sealed class RetailPViewRenderer
frameEntityPasses, frameEntityPasses,
in frameView); in frameView);
passes.UseIndoorMembershipOnlyRouting(); passes.UseIndoorMembershipOnlyRouting();
// Retail DrawBuilding @0x0059F2A0 runs FlushAlphaList(0f) BEFORE
// its portal-only far-Z pass. In retail's strict far→near walk
// everything queued at that instant is FARTHER than the structure
// being punched, so no already-drained poly can meet a punched
// aperture's falsified depth, and everything drained later is
// NEARER than the punched structure and legitimately composites in
// front of it. The batched outdoor frame reproduces that invariant
// here: drain the far prefix — every entry at or beyond the
// nearest cell whose exit-portal mask is about to punch far-Z —
// against still-true landscape depth. Without this, an exterior
// waterfall beyond the cathedral drains after the punches and
// z-passes across every aperture pixel whose true depth the punch
// erased (#132 regression found at the 2026-08-29 cathedral gate).
// Interior roots keep their pre-clear stage-boundary drain.
if (ctx.RootCell.IsOutdoorNode)
{
passes.FlushLandscapeAlphaFartherThan(
ExitPortalMaskBarrierDistance(
pvFrame,
drawableCells,
ctx.Cells,
ctx.CameraWorldPosition));
}
DrawExitPortalMasks(ctx, passes, pvFrame, clipAssembly, drawableCells); DrawExitPortalMasks(ctx, passes, pvFrame, clipAssembly, drawableCells);
DrawEnvCellShells(passes, pvFrame); DrawEnvCellShells(passes, pvFrame);
DrawCellObjectLists( DrawCellObjectLists(
@ -315,6 +364,24 @@ public sealed class RetailPViewRenderer
frameEntityPasses, frameEntityPasses,
in frameView); in frameView);
// OUTDOOR root: the LScape-boundary alpha drain deferred from the
// landscape stage runs HERE, after punches, interior shells, cell
// objects, and the dynamics pass — the frame's complete opaque
// world. Retail's walk draws all of those before its boundary
// flush (LScape::draw includes every cell's objects,
// DrawSortCell 0x005A17C0), so this is the same one-list far→near
// composite over finished depth; draining at the stage end instead
// let every later opaque mesh overwrite the flames (#132).
if (ctx.RootCell.IsOutdoorNode)
passes.FlushLandscapeAlpha();
// Interior-cell UNATTACHED emitters (spell ground effects and
// swirls anchored in EnvCells) draw in this final world scope —
// the cells' walls and the seals already own the depth buffer, so
// one unclipped submission matches retail's cell-walk insertion.
// Outdoor-cell unattached emitters drew in the landscape stage.
passes.DrawUnattachedSceneParticles(ctx, outdoorCells: false);
if (entityFrameOpen) if (entityFrameOpen)
{ {
frameEntityPasses!.CompleteEntityFrame(in frameView); frameEntityPasses!.CompleteEntityFrame(in frameView);
@ -426,6 +493,71 @@ public sealed class RetailPViewRenderer
} }
} }
/// <summary>
/// Conservative barrier drain threshold for one look-in frame: the viewer
/// distance to the frame's nearest anchor-cell ORIGIN. Cell origins sit
/// inside the building, so this over-estimates the building's
/// nearest-point distance and under-drains; anything conservatively
/// retained still composites correctly at the later depth-tested drains.
/// Retail needs no threshold — its far→near walk guarantees only farther
/// content is queued when DrawBuilding flushes (@0x0059F2A0). Returns 0
/// (full drain, today's behavior) when no cell resolves.
/// </summary>
/// <summary>
/// The pre-punch barrier threshold for <see cref="DrawExitPortalMasks"/>:
/// the nearest drawable cell whose exit-portal mask is about to write
/// far-Z. Every queued alpha entry at or beyond it must drain first
/// (retail DrawBuilding @0x0059F2A0's FlushAlphaList(0f) before the
/// portal-only pass), because after the punch those entries would z-pass
/// across aperture pixels whose true depth no longer exists. No punched
/// cells → MaxValue → the partial drain retains everything.
/// </summary>
internal static float ExitPortalMaskBarrierDistance(
PortalVisibilityFrame frame,
HashSet<uint> drawableCells,
IRetailPViewCellSource cells,
Vector3 viewerPosition)
{
float best = float.PositiveInfinity;
for (int i = 0; i < frame.OrderedVisibleCells.Count; i++)
{
uint cellId = frame.OrderedVisibleCells[i];
if (!drawableCells.Contains(cellId))
continue;
LoadedCell? cell = cells.Find(cellId);
if (cell is null)
continue;
float distance = Vector3.Distance(
cell.WorldTransform.Translation,
viewerPosition);
if (distance < best)
best = distance;
}
return float.IsFinite(best) ? best : float.MaxValue;
}
internal static float LookInBarrierDrainDistance(
PortalVisibilityFrame frame,
IRetailPViewCellSource cells,
Vector3 viewerPosition)
{
float best = float.PositiveInfinity;
for (int i = 0; i < frame.OrderedVisibleCells.Count; i++)
{
LoadedCell? cell = cells.Find(frame.OrderedVisibleCells[i]);
if (cell is null)
continue;
float distance = Vector3.Distance(
cell.WorldTransform.Translation,
viewerPosition);
if (distance < best)
best = distance;
}
return float.IsFinite(best) ? best : 0f;
}
private void RecycleLookInFrames() private void RecycleLookInFrames()
{ {
for (int i = 0; i < _lookInFrames.Count; i++) for (int i = 0; i < _lookInFrames.Count; i++)
@ -483,12 +615,22 @@ public sealed class RetailPViewRenderer
{ {
PortalVisibilityFrame frame = _lookInFrames[frameIndex]; PortalVisibilityFrame frame = _lookInFrames[frameIndex];
// Retail enters DrawBuilding once per building and drains every // Retail enters DrawBuilding once per building and drains the
// alpha submission accumulated by the preceding building before // alpha accumulated by the preceding building before punching the
// punching the next building's portals. The first building uses // next building's portals — and because retail's far→near walk
// the pre-look-in barrier in DrawLandscapeThroughOutsideView. // has only inserted FARTHER content by then, that drain can never
// composite an emitter nearer than this building
// (FlushAlphaList(0f) @0x0059F2A0 under the walk; AP-236).
// The first building uses the pre-look-in barrier in
// DrawLandscapeThroughOutsideView.
if (frameIndex > 0) if (frameIndex > 0)
passes.FlushLandscapeAlpha(); {
passes.FlushLandscapeAlphaFartherThan(
LookInBarrierDrainDistance(
frame,
ctx.Cells,
ctx.CameraWorldPosition));
}
// Pass 1: far-Z punch every aperture of this building. // Pass 1: far-Z punch every aperture of this building.
foreach (ExteriorPortalSeed seed in frame.ExteriorSeedPortals) foreach (ExteriorPortalSeed seed in frame.ExteriorSeedPortals)
@ -548,6 +690,8 @@ public sealed class RetailPViewRenderer
_cellStaticScratch.Add(e); _cellStaticScratch.Add(e);
} }
bool cellDrewObjects = false;
_cellParticleUnionScratch.Clear();
foreach (ClipViewSlice slice in cellSlices) foreach (ClipViewSlice slice in cellSlices)
{ {
int routeIndex = lookInRouteIndex++; int routeIndex = lookInRouteIndex++;
@ -593,12 +737,21 @@ public sealed class RetailPViewRenderer
_cellStaticScratch, _cellStaticScratch,
_oneCell); _oneCell);
// The nested DrawCells object pass includes emitters and cellDrewObjects = true;
// retains the exact setup_view clip until alpha playback. _cellParticleUnionScratch.UnionWith(
passes.DrawCellParticles(ctx, new RetailPViewCellSliceContext( _cellParticleOwnerScratch);
cellId, slice, _cellParticleOwnerScratch));
} }
} }
// The nested DrawCells object pass includes emitters: ONE
// unclipped submission per look-in cell (retail draws a
// particle during its cell's walk turn; the cell walls own
// occlusion by depth at alpha playback — never a view clip).
if (cellDrewObjects)
{
passes.DrawCellParticles(ctx, new RetailPViewCellSliceContext(
cellId, NoClipSlice, _cellParticleUnionScratch));
}
} }
// The ordinary exterior building shell is clipped by the outer // The ordinary exterior building shell is clipped by the outer
@ -610,6 +763,7 @@ public sealed class RetailPViewRenderer
// anchor EnvCell; never let an unrelated building repaint a // anchor EnvCell; never let an unrelated building repaint a
// look-in merely because both happen to be nearby. // look-in merely because both happen to be nearby.
int sliceIndex = 0; int sliceIndex = 0;
_staticParticleUnionScratch.Clear();
foreach (ClipViewSlice slice in clipAssembly.OutsideViewSlices) foreach (ClipViewSlice slice in clipAssembly.OutsideViewSlices)
{ {
int shellRouteIndex = LookInBuildingShellRouteIndex( int shellRouteIndex = LookInBuildingShellRouteIndex(
@ -688,14 +842,20 @@ public sealed class RetailPViewRenderer
_lateParticleOwnerScratch, _lateParticleOwnerScratch,
_buildingShellScratch); _buildingShellScratch);
} }
passes.DrawLandscapeStaticParticles( _staticParticleUnionScratch.UnionWith(
ctx, _lateParticleOwnerScratch);
new RetailPViewLandscapeStaticParticleContext(
slice,
_lateParticleOwnerScratch));
} }
sliceIndex++; sliceIndex++;
} }
// ONE unclipped submission for this look-in frame's shell-route
// owners (retail: one alpha-list insertion per emitter,
// depth-occluded at the flush — never re-drawn per outside view).
passes.DrawLandscapeStaticParticles(
ctx,
new RetailPViewLandscapeStaticParticleContext(
_staticParticleUnionScratch));
_staticParticleUnionScratch.Clear();
} }
} }
@ -793,14 +953,19 @@ public sealed class RetailPViewRenderer
bool hasBuildingLookIns = _lookInFrames.Count > 0; bool hasBuildingLookIns = _lookInFrames.Count > 0;
if (hasBuildingLookIns) if (hasBuildingLookIns)
{ {
int barrierSliceIndex = 0; // Ownerless OUTDOOR-cell emitters cannot ride an entity route.
foreach (var slice in clipAssembly.OutsideViewSlices) // Retail inserts each one into the single alpha list once, during
{ // its cell's landscape walk turn, with no portal-view clip; the
// Ownerless outdoor emitters cannot ride an entity route. Retail // interior-cell ownerless emitters submit in the final world
// draws their meshes once for every installed outside_view; // scope instead (see DrawDynamicsLast).
// retain that slot through deferred alpha playback. passes.DrawUnattachedSceneParticles(ctx, outdoorCells: true);
passes.DrawUnattachedSceneParticles(ctx, slice);
_staticParticleUnionScratch.Clear();
int outsideSliceTotal = clipAssembly.OutsideViewSlices.Length;
for (int barrierSliceIndex = 0;
barrierSliceIndex < outsideSliceTotal;
barrierSliceIndex++)
{
_lateParticleOwnerScratch.Clear(); _lateParticleOwnerScratch.Clear();
if (partition is not null) if (partition is not null)
{ {
@ -833,15 +998,30 @@ public sealed class RetailPViewRenderer
barrierSliceIndex, barrierSliceIndex,
0); 0);
} }
_staticParticleUnionScratch.UnionWith(
_lateParticleOwnerScratch);
}
// ONE unclipped submission for the union of every slice's cone
// survivors, then retail's pre-building barrier drain. Under
// retail's far→near walk, DrawBuilding's FlushAlphaList(0f)
// @0x0059F2A0 can only ever flush content from cells FARTHER
// than the building it precedes — a nearer emitter (the Holtburg
// candle in front of a door) has not been inserted yet and
// composites at a later flush, after that building's opaques.
// Drain the far prefix only; nearer entries stay queued for the
// DrawCells-boundary flush, which runs after the late dynamics
// (AP-236 retirement).
passes.DrawLandscapeStaticParticles( passes.DrawLandscapeStaticParticles(
ctx, ctx,
new RetailPViewLandscapeStaticParticleContext( new RetailPViewLandscapeStaticParticleContext(
slice, _staticParticleUnionScratch));
_lateParticleOwnerScratch)); _staticParticleUnionScratch.Clear();
barrierSliceIndex++; passes.FlushLandscapeAlphaFartherThan(
} LookInBarrierDrainDistance(
passes.FlushLandscapeAlpha(); _lookInFrames[0],
ctx.Cells,
ctx.CameraWorldPosition));
} }
// #124: far-building look-ins draw HERE — still inside the landscape // #124: far-building look-ins draw HERE — still inside the landscape
@ -860,8 +1040,10 @@ public sealed class RetailPViewRenderer
// LATE phase (per slice): outside-stage dynamics' meshes (#118 — drawn // LATE phase (per slice): outside-stage dynamics' meshes (#118 — drawn
// pre-clear so the seal protects their aperture pixels; AFTER the // pre-clear so the seal protects their aperture pixels; AFTER the
// look-ins so a translucent portal mesh blends over a far interior // look-ins so a translucent portal mesh blends over a far interior
// instead of being overpainted) + the scene-particle owners (statics + // instead of being overpainted). The scene-particle owners (statics +
// dynamics cone survivors — flames ride here for the same reason). // dynamics cone survivors) accumulate across the slices and submit
// ONCE, unclipped, after the loop.
_staticParticleUnionScratch.Clear();
probeSliceIndex = 0; probeSliceIndex = 0;
foreach (var slice in clipAssembly.OutsideViewSlices) foreach (var slice in clipAssembly.OutsideViewSlices)
{ {
@ -889,6 +1071,14 @@ public sealed class RetailPViewRenderer
if (viewcone.SphereVisibleInOutsideSlice(probeSliceIndex, c, r)) if (viewcone.SphereVisibleInOutsideSlice(probeSliceIndex, c, r))
{ {
_outdoorStaticScratch.Add(e); _outdoorStaticScratch.Add(e);
// Particles emit in the stage matching the PARENT CELL:
// an INTERIOR dynamic whose sphere merely straddles an
// exit-portal plane keeps its mesh in both stages (#118)
// but its particles belong to the final pass — draining
// them at the pre-clear boundary lets the interior stage
// repaint over them except on seal-protected aperture
// pixels (the cathedral middle-cell spell-star cut).
if (!InteriorEntityPartition.IsIndoorCellId(e.ParentCellId))
_lateParticleOwnerScratch.Add(e.Id); _lateParticleOwnerScratch.Add(e.Id);
} }
} }
@ -929,36 +1119,74 @@ public sealed class RetailPViewRenderer
0, 0,
ctx.PlayerLandblockId ?? 0); ctx.PlayerLandblockId ?? 0);
probeSliceIndex++; probeSliceIndex++;
_staticParticleUnionScratch.UnionWith(_lateParticleOwnerScratch);
passes.DrawLandscapeSliceLate( passes.DrawLandscapeSliceLate(
ctx, ctx,
new RetailPViewLandscapeLateSliceContext( new RetailPViewLandscapeLateSliceContext(
slice, slice,
_outdoorStaticScratch, _outdoorStaticScratch)
_lateParticleOwnerScratch)
{ {
EntityDraw = entityDraw, EntityDraw = entityDraw,
}); });
} }
// ONE unclipped submission for every late-stage particle owner —
// OUTDOOR-parented outside-stage dynamics' emitters plus, without
// look-ins, the outdoor statics' emitters (retail: one alpha-list
// insertion per emitter during the landscape walk; per-slice
// re-submission with clip slots was the direction-dependent
// disappearance class). Interior-parented straddlers appear in BOTH
// the LandscapeOutsideDynamic and DynamicLast routes; their particles
// emit only in the final pass, so remove them here.
if (frameEntityPasses is not null)
{
RenderFrameRouteOwnerSelector.ExceptRoute(
_staticParticleUnionScratch,
in frameView,
RenderFrameCandidateRoute.DynamicLast);
}
if (_staticParticleUnionScratch.Count > 0)
{
passes.DrawLandscapeStaticParticles(
ctx,
new RetailPViewLandscapeStaticParticleContext(
_staticParticleUnionScratch));
_staticParticleUnionScratch.Clear();
}
// #131: UNATTACHED emitters (AttachedObjectId == 0 — portal swirls, // #131: UNATTACHED emitters (AttachedObjectId == 0 — portal swirls,
// campfires, ground effects anchored at a position) have no owner id // campfires, ground effects anchored at a position) have no owner id
// to ride any of the id-filtered particle passes. Draw once per // to ride any of the id-filtered particle passes. OUTDOOR-cell ones
// installed outside_view for BOTH root kinds, matching retail's // submit ONCE in the landscape stage, unclipped — retail inserts each
// landscape-stage placement and preserving the slot in each deferred // particle into the single alpha list during its owner cell's walk
// draw. The former outdoor-root post-world tail ran after building // turn (ShouldDrawParticles @0x0050FE60 gates by cell + distance;
// cells and let exterior alpha repaint the cathedral transition. // FlushAlphaList @0x0059D2E0 depth-tests at composition). The former
// With no look-ins they drain at the end of the landscape stage; the // once-per-outside-slice submission with that slice's clip slot cut
// look-in path submits them at its pre-building barrier so later opaque // effects at aperture boundaries and drew NOTHING when no outside
// cell floors can cover them. // slice was in view. Interior-cell unattached emitters submit in the
// final world scope (DrawDynamicsLast) — in the landscape stage the
// upcoming depth clear + interior repaint would erase them.
if (!hasBuildingLookIns) if (!hasBuildingLookIns)
{ passes.DrawUnattachedSceneParticles(ctx, outdoorCells: true);
foreach (ClipViewSlice slice in clipAssembly.OutsideViewSlices)
passes.DrawUnattachedSceneParticles(ctx, slice);
}
// Retail PView::DrawCells 0x005A4872 drains the landscape alpha list // Retail PView::DrawCells 0x005A4872 drains the landscape alpha list
// immediately after LScape::draw and before the optional depth clear. // immediately after LScape::draw and before the optional depth clear.
// The queue remains active for the post-clear/final-world scope. // The queue remains active for the post-clear/final-world scope.
//
// Only an INTERIOR root drains here: its full depth clear follows, and
// a flame drained after that clear would z-pass through every interior
// wall. An OUTDOOR root has no depth clear (retail gates it on
// portalsDrawnCount, pc:432731), and retail's LScape::draw walk has
// already drawn every building interior and every cell object via
// DrawSortCell 0x005A17C0 before that boundary — while our outdoor
// frame draws punches, interior shells, cell objects, and ALL dynamics
// (doors, creatures, NPCs) after this point. Draining here painted the
// flames first and let each of those later opaque meshes overwrite
// them (#132: "the door draws over the candle"); the outdoor drain
// therefore runs after DrawDynamicsLast, where world depth is complete
// and the one far-to-near list composites over everything, exactly as
// retail's boundary flush does relative to its finished walk.
if (!ctx.RootCell.IsOutdoorNode)
passes.FlushLandscapeAlpha(); passes.FlushLandscapeAlpha();
// T1: retail clears the FULL depth buffer ONCE between the outside // T1: retail clears the FULL depth buffer ONCE between the outside
@ -1132,13 +1360,13 @@ public sealed class RetailPViewRenderer
Array.Empty<WorldEntity>(), Array.Empty<WorldEntity>(),
visibleCellIds: null); visibleCellIds: null);
// An owner routed through any pre-clear outside slice already had // Particles emit exactly once, in the stage matching the parent
// its alpha particles drawn there. Meshes may be submitted in both // cell. Pure-outdoor dynamics are absent from the DynamicLast
// stages, but particles must be emitted exactly once. // route (they draw only in the outside stage), and interior
RenderFrameRouteOwnerSelector.ExceptRoute( // straddlers — present in BOTH routes — emit their particles
_dynamicParticleOwnerScratch, // HERE so the interior stage cannot repaint over them; the late
in frameView, // landscape submission excludes DynamicLast owners for the same
RenderFrameCandidateRoute.LandscapeOutsideDynamic); // reason.
if (_dynamicParticleOwnerScratch.Count > 0) if (_dynamicParticleOwnerScratch.Count > 0)
{ {
passes.DrawDynamicsParticles( passes.DrawDynamicsParticles(
@ -1175,6 +1403,14 @@ public sealed class RetailPViewRenderer
$"cell=0x{(e.ParentCellId ?? 0):X8} indoor=False rootOutdoor={rootIsOutdoor} -> CULLED(outside-stage)"); $"cell=0x{(e.ParentCellId ?? 0):X8} indoor=False rootOutdoor={rootIsOutdoor} -> CULLED(outside-stage)");
continue; continue;
} }
// Drawn-once (retail DrawMeshInternal @0x0059F360 marks every
// non-player part for the frame): a dynamic whose cell drew as a
// building LOOK-IN already rendered with that cell inside the
// landscape stage (#131). Redrawing it here would land AFTER the
// boundary alpha drain and overpaint nearer flames — the Holtburg
// door repainting the candle in front of it.
if (indoor && _lookInCellIds.Contains(e.ParentCellId!.Value))
continue;
bool visible = indoor bool visible = indoor
? viewcone.SphereVisibleInCell(e.ParentCellId!.Value, c, r) ? viewcone.SphereVisibleInCell(e.ParentCellId!.Value, c, r)
: viewcone.SphereVisibleOutside(c, r); : viewcone.SphereVisibleOutside(c, r);
@ -1221,22 +1457,28 @@ public sealed class RetailPViewRenderer
// particles must not double-draw, unlike the depth-idempotent meshes). // particles must not double-draw, unlike the depth-idempotent meshes).
if (frameEntityPasses is not null) if (frameEntityPasses is not null)
{ {
// Parent-cell stage split: every DynamicLast owner emits its
// particles here. Pure-outdoor dynamics are absent from this
// route (outside stage only), and interior straddlers — whose
// meshes drew in both stages — must emit HERE so the interior
// stage cannot repaint over them (matches the production
// partition-null path above).
RenderFrameRouteOwnerSelector.Replace( RenderFrameRouteOwnerSelector.Replace(
_dynamicParticleOwnerScratch, _dynamicParticleOwnerScratch,
in frameView, in frameView,
RenderFrameCandidateRoute.DynamicLast, RenderFrameCandidateRoute.DynamicLast,
0, 0,
0); 0);
RenderFrameRouteOwnerSelector.ExceptRoute(
_dynamicParticleOwnerScratch,
in frameView,
RenderFrameCandidateRoute.LandscapeOutsideDynamic);
} }
else else
{ {
_dynamicParticleOwnerScratch.Clear(); _dynamicParticleOwnerScratch.Clear();
// Interior-parented dynamics — INCLUDING exit-portal straddlers
// whose mesh also drew in the outside stage — emit particles in
// this final pass; outdoor-parented ones emitted in the late
// landscape submission (parent-cell stage split).
foreach (var e in _dynamicsScratch) foreach (var e in _dynamicsScratch)
if (!_outsideStageDynamics.Contains(e)) if (InteriorEntityPartition.IsIndoorCellId(e.ParentCellId))
_dynamicParticleOwnerScratch.Add(e.Id); _dynamicParticleOwnerScratch.Add(e.Id);
} }
if (_dynamicParticleOwnerScratch.Count > 0) if (_dynamicParticleOwnerScratch.Count > 0)
@ -1628,10 +1870,29 @@ public interface IRetailPViewPassExecutor
RetailPViewFrameInput frame, RetailPViewFrameInput frame,
RetailPViewCellSliceContext context, RetailPViewCellSliceContext context,
int portalIndex); int portalIndex);
/// <summary>
/// One unclipped submission for every renderable UNATTACHED emitter whose
/// owner cell matches the scope: outdoor landcells in the landscape stage,
/// interior EnvCells in the final world scope. Retail inserts each such
/// particle into the single alpha list during its owner cell's walk turn
/// and never clips it to a portal view.
/// </summary>
void DrawUnattachedSceneParticles( void DrawUnattachedSceneParticles(
RetailPViewFrameInput frame, RetailPViewFrameInput frame,
ClipViewSlice slice); bool outdoorCells);
void FlushLandscapeAlpha(); void FlushLandscapeAlpha();
/// <summary>
/// Pre/inter-building barrier drain: composites only the queued alpha at
/// or beyond <paramref name="minViewerDistance"/> and retains nearer
/// entries for the later boundary flush — retail's far→near walk outcome
/// (DrawBuilding's FlushAlphaList(0f) @0x0059F2A0 can only ever flush
/// content from cells farther than that building; AP-236). The default
/// falls back to a full flush so non-production executors keep today's
/// behavior until they opt in.
/// </summary>
void FlushLandscapeAlphaFartherThan(float minViewerDistance) =>
FlushLandscapeAlpha();
void DrawCellParticles(RetailPViewFrameInput frame, RetailPViewCellSliceContext context); void DrawCellParticles(RetailPViewFrameInput frame, RetailPViewCellSliceContext context);
void DrawDynamicsParticles(RetailPViewFrameInput frame, IReadOnlySet<uint> ownerIds); void DrawDynamicsParticles(RetailPViewFrameInput frame, IReadOnlySet<uint> ownerIds);
void EmitDiagnostics(RetailPViewFrameInput frame, RetailPViewFrameResult result); void EmitDiagnostics(RetailPViewFrameInput frame, RetailPViewFrameResult result);
@ -1908,6 +2169,23 @@ public sealed class RetailPViewFrameResult
public PortalVisibilityFrame PortalFrame { get; private set; } = null!; public PortalVisibilityFrame PortalFrame { get; private set; } = null!;
public ClipFrameAssembly ClipAssembly { get; private set; } = null!; public ClipFrameAssembly ClipAssembly { get; private set; } = null!;
public HashSet<uint> DrawableCells { get; private set; } = null!; public HashSet<uint> DrawableCells { get; private set; } = null!;
/// <summary>
/// Every cell this completed view actually reached: the main flood
/// (<see cref="DrawableCells"/>) plus the building look-in cells. This is
/// retail's per-cell <c>in_view</c> answer for effect consumers —
/// <c>CPhysicsObj::ShouldDrawParticles</c> @0x0050FE60 gates on
/// <c>cell-&gt;IsInView()</c>, and a cell entered through a building portal
/// (<c>PView::ConstructView</c> @0x005A57B0, installed by
/// <c>RenderDeviceD3D::DrawBuilding</c> @0x0059F2A0) is drawn by the same
/// <c>PView::DrawCells</c> traversal as a flooded cell, so retail marks it
/// in view identically. acdream's look-in adaptation keeps those cells out
/// of <see cref="DrawableCells"/> (seals / outside-stage predicate stay
/// main-flood scoped, #124); particle and light visibility must consume
/// THIS set or look-in rooms render with frozen emitters and dark lights.
/// </summary>
public HashSet<uint> InViewCells { get; private set; } = null!;
internal RenderFrameDiagnosticCounts DiagnosticCounts { get; private set; } internal RenderFrameDiagnosticCounts DiagnosticCounts { get; private set; }
internal RenderProjectionCounts SourceCounts { get; private set; } internal RenderProjectionCounts SourceCounts { get; private set; }
internal InteriorEntityPartition.Result? DiagnosticPartition internal InteriorEntityPartition.Result? DiagnosticPartition
@ -1917,6 +2195,7 @@ public sealed class RetailPViewFrameResult
PortalVisibilityFrame portalFrame, PortalVisibilityFrame portalFrame,
ClipFrameAssembly clipAssembly, ClipFrameAssembly clipAssembly,
HashSet<uint> drawableCells, HashSet<uint> drawableCells,
HashSet<uint> inViewCells,
RenderFrameDiagnosticCounts diagnosticCounts, RenderFrameDiagnosticCounts diagnosticCounts,
RenderProjectionCounts sourceCounts, RenderProjectionCounts sourceCounts,
InteriorEntityPartition.Result? diagnosticPartition) InteriorEntityPartition.Result? diagnosticPartition)
@ -1924,6 +2203,7 @@ public sealed class RetailPViewFrameResult
PortalFrame = portalFrame; PortalFrame = portalFrame;
ClipAssembly = clipAssembly; ClipAssembly = clipAssembly;
DrawableCells = drawableCells; DrawableCells = drawableCells;
InViewCells = inViewCells;
DiagnosticCounts = diagnosticCounts; DiagnosticCounts = diagnosticCounts;
SourceCounts = sourceCounts; SourceCounts = sourceCounts;
DiagnosticPartition = diagnosticPartition; DiagnosticPartition = diagnosticPartition;
@ -1939,6 +2219,7 @@ public sealed class RetailPViewFrameResult
portalFrame, portalFrame,
clipAssembly, clipAssembly,
drawableCells, drawableCells,
drawableCells,
RetailPViewRenderer.LegacyDiagnosticCounts( RetailPViewRenderer.LegacyDiagnosticCounts(
diagnosticPartition), diagnosticPartition),
RetailPViewRenderer.LegacySourceCounts( RetailPViewRenderer.LegacySourceCounts(
@ -1954,12 +2235,13 @@ public readonly record struct RetailPViewLandscapeSliceContext(
} }
/// <summary> /// <summary>
/// Outdoor-static emitters submitted at retail's pre-building alpha barrier. /// Scene-particle owners for ONE unclipped landscape-stage submission (the
/// Mesh alpha for the same owners is already queued by the early landscape /// union of every outside slice's cone survivors). Mesh alpha for the same
/// entity route. /// owners is already queued by the entity routes; retail inserts each
/// emitter's polys into the single alpha list once, during its owner cell's
/// walk turn, with no portal-view clip.
/// </summary> /// </summary>
public readonly record struct RetailPViewLandscapeStaticParticleContext( public readonly record struct RetailPViewLandscapeStaticParticleContext(
ClipViewSlice Slice,
IReadOnlySet<uint> ParticleOwnerIds); IReadOnlySet<uint> ParticleOwnerIds);
/// <summary>Retail DrawBuilding's ordinary exterior-shell pass, issued after /// <summary>Retail DrawBuilding's ordinary exterior-shell pass, issued after
@ -1976,8 +2258,7 @@ public readonly record struct RetailPViewLandscapeBuildingShellSliceContext(
/// submitted at a pre-building barrier.</summary> /// submitted at a pre-building barrier.</summary>
public readonly record struct RetailPViewLandscapeLateSliceContext( public readonly record struct RetailPViewLandscapeLateSliceContext(
ClipViewSlice Slice, ClipViewSlice Slice,
IReadOnlyList<WorldEntity> Dynamics, IReadOnlyList<WorldEntity> Dynamics)
IReadOnlySet<uint> ParticleOwnerIds)
{ {
internal RenderFrameEntityDrawRequest? EntityDraw { get; init; } internal RenderFrameEntityDrawRequest? EntityDraw { get; init; }
} }

View file

@ -1128,6 +1128,12 @@ internal sealed class RenderScenePViewFrameBuilder
private RenderProjectionRecord[] _cell = []; private RenderProjectionRecord[] _cell = [];
private RenderProjectionRecord[] _dirty = []; private RenderProjectionRecord[] _dirty = [];
private RenderProjectionRecord[] _survivors = []; private RenderProjectionRecord[] _survivors = [];
// Cells drawn as building look-ins this frame — the DynamicLast route
// honors retail's drawn-once contract (DrawMeshInternal @0x0059F360
// marks every non-player part): an object whose cell drew with a look-in
// must not enter the final dynamics route again.
private readonly HashSet<uint> _lookInCellScratch = new();
private RenderProjectionRecord[] _cellRoute = []; private RenderProjectionRecord[] _cellRoute = [];
private readonly Dictionary<RenderProjectionId, int> private readonly Dictionary<RenderProjectionId, int>
_outdoorPositions = []; _outdoorPositions = [];
@ -1528,6 +1534,16 @@ internal sealed class RenderScenePViewFrameBuilder
RenderFrameWriter writer, RenderFrameWriter writer,
in RenderScenePViewBuildInput input) in RenderScenePViewBuildInput input)
{ {
_lookInCellScratch.Clear();
for (int frameIndex = 0;
frameIndex < input.LookInFrames.Count;
frameIndex++)
{
PortalVisibilityFrame frame = input.LookInFrames[frameIndex];
for (int i = 0; i < frame.OrderedVisibleCells.Count; i++)
_lookInCellScratch.Add(frame.OrderedVisibleCells[i]);
}
int count = 0; int count = 0;
EnsureCapacity(ref _survivors, _dynamicCount); EnsureCapacity(ref _survivors, _dynamicCount);
for (int i = 0; i < _dynamicCount; i++) for (int i = 0; i < _dynamicCount; i++)
@ -1539,6 +1555,14 @@ internal sealed class RenderScenePViewFrameBuilder
if (!input.RootIsOutdoor && !indoor) if (!input.RootIsOutdoor && !indoor)
continue; continue;
// Drawn-once (retail DrawMeshInternal @0x0059F360): a dynamic
// whose cell drew as a building look-in already rendered with
// that cell in the landscape stage; re-entering the final route
// would draw it after the boundary alpha drain and overpaint
// nearer flames (the Holtburg candle-behind-door class).
if (indoor && _lookInCellScratch.Contains(parentCellId!.Value))
continue;
Sphere(in record, out Vector3 center, out float radius); Sphere(in record, out Vector3 center, out float radius);
bool visible = indoor bool visible = indoor
? input.Viewcone.SphereVisibleInCell( ? input.Viewcone.SphereVisibleInCell(

View file

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

View file

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

View file

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

View file

@ -924,6 +924,24 @@ public sealed unsafe partial class WbDrawDispatcher
return new RhiSection(allocation.Buffer, allocation.OffsetBytes, (uint)byteCount); return new RhiSection(allocation.Buffer, allocation.OffsetBytes, (uint)byteCount);
} }
/// <summary>
/// #443 — the next classic draw is a PRIVATE pass (paperdoll, appraisal,
/// chargen preview) and must take the plain ring transform path with
/// <c>firstInstance = 0</c>, never an append into the shared world
/// transform frame. The default mesh shaders index every parallel
/// per-instance array (clip slots, light sets, indoor, opacity, selection,
/// detail category) zero-based — only the packed world submission's
/// shader convention subtracts a shared-arena prefix — so an
/// arena-appended classic draw with a non-zero base reads zeroed
/// per-instance data (opacity 0 ⇒ an invisible doll whenever a world
/// frame is active; portal space worked only because the arena was
/// inactive there). The private pass owns its own camera, lighting, and
/// target; per the self-contained-render-state rule it must not depend on
/// the world frame's pose address space at all. Consumed and cleared by
/// the next <see cref="WriteWorldTransformSection"/>.
/// </summary>
internal bool NextClassicDrawIsPrivatePass;
private RhiSection WriteWorldTransformSection( private RhiSection WriteWorldTransformSection(
IGpuFrame frame, IGpuFrame frame,
ReadOnlySpan<float> matrixFloats, ReadOnlySpan<float> matrixFloats,
@ -939,7 +957,9 @@ public sealed unsafe partial class WbDrawDispatcher
ObserveOrdinaryTransformDemand( ObserveOrdinaryTransformDemand(
frame.Serial, frame.Serial,
checked((uint)(matrixFloats.Length / 16))); checked((uint)(matrixFloats.Length / 16)));
if (!_worldTransformFrames.IsActive) bool privatePass = NextClassicDrawIsPrivatePass;
NextClassicDrawIsPrivatePass = false;
if (privatePass || !_worldTransformFrames.IsActive)
{ {
firstInstance = 0; firstInstance = 0;
return WriteRingSection(frame, matrixFloats); return WriteRingSection(frame, matrixFloats);

View file

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

View file

@ -254,8 +254,14 @@ internal sealed class WorldSceneRenderer : IPreparedWorldSceneFramePhase
camera.Camera.View, camera.Camera.View,
_diagnostics.CameraCellResolution)); _diagnostics.CameraCellResolution));
_particleVisibility.MarkVisibleCells(pviewResult.DrawableCells); // Effect visibility consumes InViewCells (main flood look-in
_frames.ObserveDrawableCells(pviewResult.DrawableCells); // cells), not the flood-only DrawableCells: retail's
// ShouldDrawParticles @0x0050FE60 asks cell->IsInView(), and a
// look-in cell drawn via DrawBuilding @0x0059F2A0 is in view
// exactly like a flooded cell. Flood-only scoping froze
// emitters and darkened lights in visible adjacent rooms.
_particleVisibility.MarkVisibleCells(pviewResult.InViewCells);
_frames.ObserveDrawableCells(pviewResult.InViewCells);
_diagnostics.EmitPViewInput( _diagnostics.EmitPViewInput(
pviewResult.PortalFrame, pviewResult.PortalFrame,
camera.ViewProjection, camera.ViewProjection,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -271,8 +271,10 @@ public sealed class LightManager
/// geometrically closer than the player's own room's torches and win the cap, /// geometrically closer than the player's own room's torches and win the cap,
/// leaving the visible room dark. Scoping candidacy to the frame's actual /// leaving the visible room dark. Scoping candidacy to the frame's actual
/// visible cells (the render already computes this — callers pass last frame's /// visible cells (the render already computes this — callers pass last frame's
/// <c>RetailPViewFrameResult.DrawableCells</c>, one frame of latency, to avoid /// <c>RetailPViewFrameResult.InViewCells</c>, the main flood PLUS building
/// re-threading a mid-render callback) removes those from contention before the /// look-in cells, one frame of latency, to avoid re-threading a mid-render
/// callback; flood-only scoping darkened look-in rooms' lanterns) removes
/// non-visible cells from contention before the
/// cap ever applies. The distance-sort anchor stays the PLAYER either way — this /// cap ever applies. The distance-sort anchor stays the PLAYER either way — this
/// parameter only narrows candidacy, it does not change the sort (the #176 /// parameter only narrows candidacy, it does not change the sort (the #176
/// correction: CAMERA anchoring, not cell scoping itself, caused the earlier /// correction: CAMERA anchoring, not cell scoping itself, caused the earlier

View file

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

View file

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

View file

@ -526,7 +526,8 @@ public sealed class ParticleSystem : IParticleSystem
IReadOnlySet<uint> attachedOwnerIds, IReadOnlySet<uint> attachedOwnerIds,
bool includeUnattached, bool includeUnattached,
List<ParticleEmitter> destination, List<ParticleEmitter> destination,
IReadOnlySet<uint>? excludedAttachedOwnerIds = null) IReadOnlySet<uint>? excludedAttachedOwnerIds = null,
UnattachedEmitterCellScope unattachedCellScope = UnattachedEmitterCellScope.Any)
{ {
ArgumentNullException.ThrowIfNull(attachedOwnerIds); ArgumentNullException.ThrowIfNull(attachedOwnerIds);
ArgumentNullException.ThrowIfNull(destination); ArgumentNullException.ThrowIfNull(destination);
@ -539,10 +540,13 @@ public sealed class ParticleSystem : IParticleSystem
foreach (int handle in _renderableUnattachedHandlesByPass[passIndex]) foreach (int handle in _renderableUnattachedHandlesByPass[passIndex])
{ {
LastRenderScopeEmitterVisitCount++; LastRenderScopeEmitterVisitCount++;
if (_byHandle.TryGetValue(handle, out ParticleEmitter? emitter)) if (_byHandle.TryGetValue(handle, out ParticleEmitter? emitter)
&& MatchesUnattachedCellScope(emitter, unattachedCellScope))
{
destination.Add(emitter); destination.Add(emitter);
} }
} }
}
Dictionary<uint, OwnerEmitterBucket> owners = _ownerHandlesByPass[passIndex]; Dictionary<uint, OwnerEmitterBucket> owners = _ownerHandlesByPass[passIndex];
foreach (uint ownerId in attachedOwnerIds) foreach (uint ownerId in attachedOwnerIds)
@ -566,6 +570,29 @@ public sealed class ParticleSystem : IParticleSystem
destination.Sort(static (left, right) => left.Handle.CompareTo(right.Handle)); destination.Sort(static (left, right) => left.Handle.CompareTo(right.Handle));
} }
/// <summary>
/// Splits unattached emitters by their owner cell kind so each draws once
/// in its retail stage: an outdoor landcell emitter belongs to the
/// landscape stage (before the depth clear), an interior EnvCell emitter
/// to the final world stage (after the seals). Retail gets this for free
/// because a particle draws during its owner CELL's walk turn
/// (CPhysicsObj::ShouldDrawParticles @0x0050FE60 reads the one cell).
/// AC cell convention: low word &lt; 0x0100 is an outdoor landcell,
/// 0x0100..0xFFFD is an interior EnvCell. Cell 0 matches neither scoped
/// mode — such an emitter cannot pass the world in-view gate anyway.
/// </summary>
private static bool MatchesUnattachedCellScope(
ParticleEmitter emitter,
UnattachedEmitterCellScope scope)
{
if (scope == UnattachedEmitterCellScope.Any)
return true;
uint low = emitter.OwnerCellId & 0xFFFFu;
return scope == UnattachedEmitterCellScope.OutdoorCells
? low != 0 && low < 0x0100u
: low >= 0x0100u;
}
public readonly struct LiveEmitterEnumerable : IEnumerable<ParticleEmitter> public readonly struct LiveEmitterEnumerable : IEnumerable<ParticleEmitter>
{ {
private readonly ParticleSystem _owner; private readonly ParticleSystem _owner;

View file

@ -46,6 +46,20 @@ public enum ParticleRenderPass
SkyPostScene = 2, SkyPostScene = 2,
} }
/// <summary>
/// Which unattached emitters a scoped render copy admits, by owner cell kind.
/// Retail draws every particle during its owner CELL's walk turn, so an
/// outdoor-cell emitter renders in the landscape stage and an interior-cell
/// emitter in the final world stage; acdream draws each group once in the
/// matching stage instead of per portal slice.
/// </summary>
public enum UnattachedEmitterCellScope
{
Any = 0,
OutdoorCells = 1,
InteriorCells = 2,
}
/// <summary> /// <summary>
/// Authority used by retail's particle presentation gate. World-owned /// Authority used by retail's particle presentation gate. World-owned
/// emitters follow <c>CPhysicsObj::ShouldDrawParticles</c>; examination and /// emitters follow <c>CPhysicsObj::ShouldDrawParticles</c>; examination and

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -97,6 +97,33 @@ public sealed class RuntimeOptionsSessionConfigTests
Assert.Null(options.StatusFilePath); Assert.Null(options.StatusFilePath);
} }
[Fact]
public void SessionConfigPreservesExplicitRetailUiOptOut()
{
var config = new SessionConfiguration { Version = 1 };
var session = new SessionDescriptor
{
Id = "no-ui",
Endpoint = new SessionEndpointDescriptor { Host = "127.0.0.1", Port = 9000 },
Account = "account",
Credential = new SessionCredentialDescriptor
{
Provider = SessionCredentialProviderKind.Environment,
Reference = "X",
},
};
RuntimeOptions options = RuntimeOptions.FromSessionConfig(
"D:\\dat",
key => key == "ACDREAM_RETAIL_UI" ? "0" : null,
"session.json",
config,
session,
"password");
Assert.False(options.RetailUi);
}
[Fact] [Fact]
public void ProcessContentOverridesDatDirectoryAndPreparedAssetPath() public void ProcessContentOverridesDatDirectoryAndPreparedAssetPath()
{ {

View file

@ -107,7 +107,7 @@ public sealed class LaunchOptionsDocumentationTests
} }
/// <summary> /// <summary>
/// The four flags that default ON. All are retail behaviors wearing an /// The five flags that default ON. All are product/retail behaviors wearing an
/// A/B off-switch (<c>=0</c> disables) — none is a diagnostic. FROZEN: /// A/B off-switch (<c>=0</c> disables) — none is a diagnostic. FROZEN:
/// a diagnostic that activates without its env var set taxes every run /// a diagnostic that activates without its env var set taxes every run
/// and every measurement silently, so growing this set fails. /// and every measurement silently, so growing this set fails.
@ -119,6 +119,7 @@ public sealed class LaunchOptionsDocumentationTests
"ACDREAM_CAMERA_COLLIDE", "ACDREAM_CAMERA_COLLIDE",
"ACDREAM_CAMERA_ALIGN_SLOPE", "ACDREAM_CAMERA_ALIGN_SLOPE",
"ACDREAM_RETAIL_CLOSE_DEGRADES", "ACDREAM_RETAIL_CLOSE_DEGRADES",
"ACDREAM_RETAIL_UI",
}; };
/// <summary> /// <summary>
@ -133,7 +134,7 @@ public sealed class LaunchOptionsDocumentationTests
RegexOptions.Compiled); RegexOptions.Compiled);
[Fact] [Fact]
public void OnlyTheFourRetailBehaviorFlagsDefaultOn() public void OnlyTheFiveProductBehaviorFlagsDefaultOn()
{ {
var defaultOn = new HashSet<string>(StringComparer.Ordinal); var defaultOn = new HashSet<string>(StringComparer.Ordinal);
foreach ((string path, _) in SourceFiles()) foreach ((string path, _) in SourceFiles())

View file

@ -101,6 +101,39 @@ public sealed class LiveSessionCommandRouterTests
calls); calls);
} }
[Fact]
public void AdministrationBindings_UseTheSameActivationAndDisposalGuard()
{
var calls = new List<string>();
ClientCommandController.Bindings client = NewClientBindings() with
{
Administration = NewAdministrationBindings() with
{
SetMotd = text => calls.Add("motd:" + text),
BootSpecificHouseGuest = name => calls.Add("boot:" + name),
},
};
var router = NewRouter(clientBindings: client);
Publish();
router.Activate();
Publish();
router.Dispose();
Publish();
Assert.Equal(["motd:Welcome", "boot:Lord Bob"], calls);
void Publish()
{
router.Publish(new ExecuteClientCommandCmd(
ClientCommandId.AllegianceMotd,
"set Welcome"));
router.Publish(new ExecuteClientCommandCmd(
ClientCommandId.HouseBoot,
"Lord Bob"));
}
}
[Fact] [Fact]
public void InactiveAndDisposedRouter_CannotReachTransport() public void InactiveAndDisposedRouter_CannotReachTransport()
{ {
@ -831,6 +864,7 @@ public sealed class LiveSessionCommandRouterTests
ToggleFrameRate: () => { }, ToggleFrameRate: () => { },
ToggleUiLock: () => { }, ToggleUiLock: () => { },
ShowSystemMessage: _ => { }, ShowSystemMessage: _ => { },
ShowClientLocalMessage: _ => { },
ShowWeenieError: _ => { }, ShowWeenieError: _ => { },
PlayerPublicWeenieBitfield: () => null, PlayerPublicWeenieBitfield: () => null,
ClientVersion: () => "test", ClientVersion: () => "test",
@ -879,5 +913,49 @@ public sealed class LiveSessionCommandRouterTests
LeaveGmChannel: _ => { }, LeaveGmChannel: _ => { },
RecallAllegianceHometown: () => { }, RecallAllegianceHometown: () => { },
RequestAllegianceInfo: _ => { }, RequestAllegianceInfo: _ => { },
AbandonHouse: () => { }); AbandonHouse: () => { },
Administration: NewAdministrationBindings(),
IsPersistentDaylight: () => false,
SetPersistentDaylight: _ => { },
SetLandscapeRadius: _ => { },
SetFieldOfView: _ => { });
private static ClientCommandController.AdministrationBindings
NewAdministrationBindings() => new(
BreakAllegianceBoot: (_, _) => { },
AllegianceChatBoot: (_, _) => { },
AllegianceChatGag: (_, _) => { },
AllegianceBroadcast: _ => { },
ListAllegianceBans: () => { },
AddAllegianceBan: _ => { },
RemoveAllegianceBan: _ => { },
ListAllegianceOfficers: () => { },
ClearAllegianceOfficers: () => { },
SetAllegianceOfficer: (_, _) => { },
RemoveAllegianceOfficer: _ => { },
ListAllegianceOfficerTitles: () => { },
ClearAllegianceOfficerTitles: () => { },
SetAllegianceOfficerTitle: (_, _) => { },
QueryAllegianceName: () => { },
SetAllegianceName: _ => { },
ClearAllegianceName: () => { },
AllegianceLockAction: _ => { },
SetAllegianceApprovedVassal: _ => { },
AllegianceHouseAction: _ => { },
QueryMotd: () => { },
SetMotd: _ => { },
ClearMotd: () => { },
SetOpenHouseStatus: _ => { },
AddPermanentGuest: _ => { },
RemovePermanentGuest: _ => { },
RemoveAllPermanentGuests: () => { },
ChangeStoragePermission: (_, _) => { },
AddAllStoragePermission: () => { },
RemoveAllStoragePermission: () => { },
RequestFullGuestList: () => { },
BootSpecificHouseGuest: _ => { },
BootEveryone: () => { },
SetHooksVisibility: _ => { },
ModifyAllegianceGuestPermission: _ => { },
ModifyAllegianceStoragePermission: _ => { });
} }

View file

@ -1,3 +1,4 @@
using System.Collections.Frozen;
using AcDream.App.Net; using AcDream.App.Net;
using AcDream.Core.CharGen; using AcDream.Core.CharGen;
using DatReaderWriter.DBObjs; using DatReaderWriter.DBObjs;
@ -258,22 +259,26 @@ public sealed class RetailSkillFormulaTests
AttributeId attributeId) AttributeId attributeId)
{ {
const uint skillId = 0x10u; const uint skillId = 0x10u;
var skillTable = new SkillTable(); var options = ChargenOptions.Empty with
skillTable.Skills.Add((SkillId)skillId, new SkillBase
{ {
Formula = new SkillFormula GlobalSkillDetailsBySkillId = new Dictionary<uint, ChargenSkillDetail>
{ {
AdditiveBonus = 0, [skillId] = new ChargenSkillDetail(
Attribute1Multiplier = 1, skillId,
Attribute2Multiplier = 0, MinLevel: 1u,
Divisor = 1, Description: string.Empty,
Attribute1 = attributeId, new ChargenSkillFormula(
// Attribute2 deliberately left at its zero default (Strength) — AdditiveBonus: 0,
// Attribute2Multiplier=0 means whatever it reads contributes Attribute1Multiplier: 1,
// nothing, so it cannot mask a wrong Attribute1 case. Attribute2Multiplier: 0,
}, Divisor: 1,
}); Attribute1: (uint)attributeId,
var resolver = new ChargenSkillScoreResolver(skillTable); // Attribute2Multiplier=0 means the second attribute
// cannot mask a wrong Attribute1 mapping.
Attribute2: 0u)),
}.ToFrozenDictionary(),
};
var resolver = new ChargenSkillScoreResolver(options);
ChargenAttributeValues attributes = AttributeValuesWith(attributeId, 42); ChargenAttributeValues attributes = AttributeValuesWith(attributeId, 42);
uint result = resolver.Resolve(skillId, attributes, ChargenSkillAdvancementClass.Untrained); uint result = resolver.Resolve(skillId, attributes, ChargenSkillAdvancementClass.Untrained);
@ -281,6 +286,19 @@ public sealed class RetailSkillFormulaTests
Assert.Equal(42u, result); Assert.Equal(42u, result);
} }
[Fact]
public void ChargenSkillScoreResolver_MissingProjectedSkill_ReturnsZero()
{
var resolver = new ChargenSkillScoreResolver(ChargenOptions.Empty);
uint result = resolver.Resolve(
0x10u,
new ChargenAttributeValues(10, 20, 30, 40, 50, 60),
ChargenSkillAdvancementClass.Specialized);
Assert.Equal(0u, result);
}
private static ChargenAttributeValues AttributeValuesWith(AttributeId attributeId, int value) => private static ChargenAttributeValues AttributeValuesWith(AttributeId attributeId, int value) =>
attributeId switch attributeId switch
{ {

View file

@ -219,7 +219,7 @@ public sealed class LiveEntityNetworkOnPositionCollapseMatrixTests
// ── Scenario 2: landing packet (the preserved rows 2a/2b asymmetry) ─ // ── Scenario 2: landing packet (the preserved rows 2a/2b asymmetry) ─
[Fact] [Fact]
public void LandingPacket_PlayerGuid_QueueClearedNoShadowPublish_316Preserved() public void LandingPacket_PlayerGuid_QueueClearedAndShadowPublished()
{ {
using var fixture = new Fixture(PlayerGuid); using var fixture = new Fixture(PlayerGuid);
EntityPhysicsHost host = fixture.InstallHost(); EntityPhysicsHost host = fixture.InstallHost();
@ -253,14 +253,13 @@ public sealed class LiveEntityNetworkOnPositionCollapseMatrixTests
// Row 2a, PRESERVED for player guids: the interp queue IS cleared. // Row 2a, PRESERVED for player guids: the interp queue IS cleared.
Assert.False(fixture.Remote.Interp.IsActive); Assert.False(fixture.Remote.Interp.IsActive);
// Row 2b / #316, PRESERVED (NOT fixed): the shadow is NOT // #316: the same resolved landing pose must publish to collision in
// republished for a player-guid landing — it stays at whatever it // this packet, not wait for a later physics tick to self-heal.
// was before this packet.
ShadowEntry shadowEntry = Assert.Single( ShadowEntry shadowEntry = Assert.Single(
fixture.Shadows.AllEntriesForDebug(), fixture.Shadows.AllEntriesForDebug(),
entry => entry.EntityId == fixture.Entity.Id); entry => entry.EntityId == fixture.Entity.Id);
Assert.Equal(spawnShadowPos, shadowEntry.Position); Assert.Equal(landingPos, shadowEntry.Position);
Assert.NotEqual(landingPos, shadowEntry.Position); Assert.NotEqual(spawnShadowPos, shadowEntry.Position);
// AP-135's cell-adopt bookkeeping still ran (via the post-routing // AP-135's cell-adopt bookkeeping still ran (via the post-routing
// wire-cell adopt, not suppressed for AirborneSnap). // wire-cell adopt, not suppressed for AirborneSnap).
@ -574,6 +573,26 @@ public sealed class LiveEntityNetworkOnPositionCollapseMatrixTests
_ = host; _ = host;
} }
[Fact]
public void PositionPackVelocity_DoesNotOverwriteAuthoritativeBodyVelocity()
{
using var fixture = new Fixture(CreatureGuid);
Vector3 authoritativeVectorUpdate = new(4f, 5f, 6f);
fixture.Remote.Body.Velocity = authoritativeVectorUpdate;
// Retail's airborne/non-teleport Position arm performs no placement,
// isolating the PositionPack velocity rule from contact resolution.
fixture.Controller.OnPosition(fixture.Update(
new Vector3(12f, 14f, SpawnHeight + 2f),
SourceCell,
teleportSequence: 1,
guid: CreatureGuid,
isGrounded: false,
velocity: new Vector3(0.7f, 0.2f, -0.1f)));
Assert.Equal(authoritativeVectorUpdate, fixture.Remote.Body.Velocity);
}
// ── Sabotage check (contract §5, one-time, manual) ────────────────── // ── Sabotage check (contract §5, one-time, manual) ──────────────────
// //
// Performed by hand during implementation, not committed as a test (a // Performed by hand during implementation, not committed as a test (a

View file

@ -12,7 +12,7 @@ namespace AcDream.App.Tests.Physics;
public sealed class LiveEntityOrdinaryPhysicsUpdaterTests public sealed class LiveEntityOrdinaryPhysicsUpdaterTests
{ {
private const uint Guid = 0x70000071u; private const uint Guid = 0x50000071u;
private const uint SourceCell = 0xA9B40039u; private const uint SourceCell = 0xA9B40039u;
private const uint DestinationCell = 0xAAB40001u; private const uint DestinationCell = 0xAAB40001u;
@ -37,7 +37,8 @@ public sealed class LiveEntityOrdinaryPhysicsUpdaterTests
Rotation = Quaternion.Identity, Rotation = Quaternion.Identity,
MeshRefs = Array.Empty<MeshRef>(), MeshRefs = Array.Empty<MeshRef>(),
ParentCellId = SourceCell, ParentCellId = SourceCell,
}); },
isLocalPlayer: true);
WorldEntity entity = Assert.IsType<WorldEntity>(record.WorldEntity); WorldEntity entity = Assert.IsType<WorldEntity>(record.WorldEntity);
var remote = new AcDream.Runtime.Physics.RemoteMotion var remote = new AcDream.Runtime.Physics.RemoteMotion
@ -84,6 +85,17 @@ public sealed class LiveEntityOrdinaryPhysicsUpdaterTests
Assert.True(entity.Position.X > 192f); Assert.True(entity.Position.X > 192f);
Assert.Equal(entity.Position, record.PhysicsBody.Position); Assert.Equal(entity.Position, record.PhysicsBody.Position);
Assert.Equal(epoch, record.ObjectClockEpoch); Assert.Equal(epoch, record.ObjectClockEpoch);
// #320: retiring the landblock the player walked out of must not
// sweep the still-live player into a DeferredCell park. Before the
// ordinary-cell commit existed, FullCellId remained SourceCell and
// this exact retirement selected the player as an affected spatial
// root.
live.Physics.SetPosition.ParkCollisionResidents(
SourceCell,
includeOutdoorCells: true);
Assert.Equal(DestinationCell, record.FullCellId);
Assert.True(live.Physics.IsSpatialRoot(record.Canonical));
} }
[Fact] [Fact]

View file

@ -109,4 +109,36 @@ public class InteriorEntityPartitionTests
Assert.Empty(result.Dynamics); Assert.Empty(result.Dynamics);
Assert.Empty(result.OutdoorStatic); Assert.Empty(result.OutdoorStatic);
} }
[Fact]
public void FrustumRejectsWholeLandblockBeforeWalkingItsEntities()
{
const uint culledLandblock = 0xA8B4FFFFu;
const uint cameraLandblock = 0xA9B4FFFFu;
var culled = Ent(10, serverGuid: 0x80000010u, parentCell: OutdoorCell);
var camera = Ent(11, serverGuid: 0x80000011u, parentCell: OutdoorCell);
var entries = new[]
{
(culledLandblock,
new Vector3(10f),
new Vector3(11f),
(IReadOnlyList<WorldEntity>)new[] { culled },
(IReadOnlyDictionary<uint, WorldEntity>?)null),
(cameraLandblock,
new Vector3(10f),
new Vector3(11f),
(IReadOnlyList<WorldEntity>)new[] { camera },
(IReadOnlyDictionary<uint, WorldEntity>?)null),
};
InteriorEntityPartition.Result result =
InteriorEntityPartition.Partition(
new HashSet<uint>(),
entries,
FrustumPlanes.FromViewProjection(Matrix4x4.Identity),
neverCullLandblockId: cameraLandblock);
Assert.DoesNotContain(culled, result.Dynamics);
Assert.Equal(camera, Assert.Single(result.Dynamics));
}
} }

View file

@ -381,6 +381,50 @@ public sealed class LiveEntityAnimationPresenterTests
Assert.Equal(0.5f, fixture.State.CurrFrame); Assert.Equal(0.5f, fixture.State.CurrFrame);
} }
[Fact]
public void LegacyCycle_WrapsAndInterpolatesThroughSharedRetailPlayback()
{
var fixture = Build(partCount: 1);
fixture.State.Sequencer = null;
fixture.State.LowFrame = 0;
fixture.State.HighFrame = 2;
fixture.State.Framerate = 2f;
fixture.State.CurrFrame = 2.5f;
var animation = new Animation();
foreach (float x in new[] { 0f, 10f, 20f })
{
var frame = new AnimationFrame(1);
frame.Frames.Add(new Frame
{
Origin = new Vector3(x, 0f, 0f),
Orientation = Quaternion.Identity,
});
animation.PartFrames.Add(frame);
}
fixture.State.Animation = animation;
var schedule = new LiveEntityAnimationSchedule(
SequenceFrames: null,
LegacyAdvanceSeconds: 0.5f,
ComposeParts: true,
fixture.Record,
fixture.Entity,
fixture.State,
fixture.Record.ObjectClockEpoch,
fixture.Record.ProjectionMutationVersion,
fixture.State.PresentationRevision);
var presenter = Presenter(fixture.Live, new EntityEffectPoseRegistry(), new Context());
presenter.Present(new Dictionary<RuntimeEntityKey, LiveEntityAnimationSchedule>
{
[fixture.Record.ProjectionKey!.Value] = schedule,
});
// Legacy arithmetic: 2.5 + (0.5 * 2) = 3.5, wrapped over the
// inclusive 0..2 span to 0.5, then interpolated halfway 0 -> 10.
Assert.Equal(0.5f, fixture.State.CurrFrame);
Assert.Equal(new Vector3(5f, 0f, 0f), fixture.Entity.MeshRefs[0].PartTransform.Translation);
}
private static LiveEntityAnimationPresenter Presenter( private static LiveEntityAnimationPresenter Presenter(
LiveEntityRuntime live, LiveEntityRuntime live,
EntityEffectPoseRegistry poses, EntityEffectPoseRegistry poses,

View file

@ -8,6 +8,14 @@ namespace AcDream.App.Tests.Rendering;
public sealed class PaperdollFramePresenterTests public sealed class PaperdollFramePresenterTests
{ {
/// <summary>One frame in production order: the pre-world resource phase
/// (build/redress + prewarm) then the late presentation phase.</summary>
private static void Frame(PaperdollFramePresenter presenter)
{
presenter.PrepareResources();
presenter.Render();
}
[Fact] [Fact]
public void HiddenView_BuildsAndPrewarmsWithoutRendering() public void HiddenView_BuildsAndPrewarmsWithoutRendering()
{ {
@ -16,7 +24,7 @@ public sealed class PaperdollFramePresenterTests
var factory = new RecordingFactory { Doll = CreateDoll() }; var factory = new RecordingFactory { Doll = CreateDoll() };
var presenter = new PaperdollFramePresenter(renderer, view, factory); var presenter = new PaperdollFramePresenter(renderer, view, factory);
presenter.Render(); Frame(presenter);
Assert.False(presenter.IsDirty); Assert.False(presenter.IsDirty);
Assert.Equal(1, factory.BuildCount); Assert.Equal(1, factory.BuildCount);
@ -34,7 +42,7 @@ public sealed class PaperdollFramePresenterTests
var factory = new RecordingFactory { Doll = doll }; var factory = new RecordingFactory { Doll = doll };
var presenter = new PaperdollFramePresenter(renderer, view, factory); var presenter = new PaperdollFramePresenter(renderer, view, factory);
presenter.Render(); Frame(presenter);
Assert.False(presenter.IsDirty); Assert.False(presenter.IsDirty);
Assert.Equal(1, factory.BuildCount); Assert.Equal(1, factory.BuildCount);
@ -54,20 +62,25 @@ public sealed class PaperdollFramePresenterTests
}; };
var presenter = new PaperdollFramePresenter(renderer, view, factory); var presenter = new PaperdollFramePresenter(renderer, view, factory);
presenter.Render(); Frame(presenter);
presenter.Render(); Frame(presenter);
presenter.MarkDirty(); presenter.MarkDirty();
presenter.Render(); Frame(presenter);
Assert.Equal(2, factory.BuildCount); Assert.Equal(2, factory.BuildCount);
Assert.Single(renderer.Dolls); Assert.Equal(2, renderer.Dolls.Count);
Assert.Equal(3, renderer.RenderCount); Assert.Equal(3, renderer.RenderCount);
Assert.False(presenter.IsDirty); Assert.False(presenter.IsDirty);
} }
[Fact] [Fact]
public void EquivalentPortalRefresh_KeepsPrivateDollAndTextureOwner() public void PortalRefresh_RedressesEvenAnEquivalentAppearance()
{ {
// #443: the private clone belongs to the current presentation
// generation. Retaining an old clone because its pixels compare equal
// can pin retired mesh/composite readiness that never completes
// again; the renderer's two-phase promote keeps the last completed
// image visible while the fresh clone becomes drawable.
WorldEntity first = CreateDoll(); WorldEntity first = CreateDoll();
WorldEntity repeated = CreateDoll(); WorldEntity repeated = CreateDoll();
var renderer = new RecordingRenderer(); var renderer = new RecordingRenderer();
@ -75,13 +88,13 @@ public sealed class PaperdollFramePresenterTests
var factory = new RecordingFactory { Doll = first }; var factory = new RecordingFactory { Doll = first };
var presenter = new PaperdollFramePresenter(renderer, view, factory); var presenter = new PaperdollFramePresenter(renderer, view, factory);
presenter.Render(); Frame(presenter);
factory.Doll = repeated; factory.Doll = repeated;
presenter.MarkDirty(); presenter.MarkDirty();
presenter.Render(); Frame(presenter);
Assert.Equal(2, factory.BuildCount); Assert.Equal(2, factory.BuildCount);
Assert.Equal([first], renderer.Dolls); Assert.Equal([first, repeated], renderer.Dolls);
Assert.Equal(2, renderer.RenderCount); Assert.Equal(2, renderer.RenderCount);
Assert.False(presenter.IsDirty); Assert.False(presenter.IsDirty);
} }
@ -96,14 +109,34 @@ public sealed class PaperdollFramePresenterTests
var factory = new RecordingFactory { Doll = first }; var factory = new RecordingFactory { Doll = first };
var presenter = new PaperdollFramePresenter(renderer, view, factory); var presenter = new PaperdollFramePresenter(renderer, view, factory);
presenter.Render(); Frame(presenter);
factory.Doll = changed; factory.Doll = changed;
presenter.MarkDirty(); presenter.MarkDirty();
presenter.Render(); Frame(presenter);
Assert.Equal([first, changed], renderer.Dolls); Assert.Equal([first, changed], renderer.Dolls);
} }
[Fact]
public void TransientZeroRender_NeverErasesThePublishedTexture()
{
// #443: zero is a not-ready result (mesh/composite upload latency),
// not a request to blank a completed paperdoll.
var renderer = new RecordingRenderer { TextureHandle = 91u };
var view = new RecordingView();
var factory = new RecordingFactory { Doll = CreateDoll() };
var presenter = new PaperdollFramePresenter(renderer, view, factory);
Frame(presenter);
renderer.TextureHandle = 0u;
Frame(presenter);
renderer.TextureHandle = 91u;
Frame(presenter);
Assert.Equal([91u, 91u], view.TextureHandles);
Assert.Equal(0, view.ClearCount);
}
private static WorldEntity CreateDoll(float scale = 1f) => new() private static WorldEntity CreateDoll(float scale = 1f) => new()
{ {
Id = 42u, Id = 42u,
@ -123,11 +156,11 @@ public sealed class PaperdollFramePresenterTests
var factory = new RecordingFactory { Doll = firstDoll }; var factory = new RecordingFactory { Doll = firstDoll };
var presenter = new PaperdollFramePresenter(renderer, view, factory); var presenter = new PaperdollFramePresenter(renderer, view, factory);
presenter.Render(); Frame(presenter);
factory.CanBuild = false; factory.CanBuild = false;
presenter.MarkDirty(); presenter.MarkDirty();
presenter.Render(); Frame(presenter);
presenter.Render(); Frame(presenter);
Assert.True(presenter.IsDirty); Assert.True(presenter.IsDirty);
Assert.Equal(3, factory.BuildCount); Assert.Equal(3, factory.BuildCount);
@ -146,14 +179,17 @@ public sealed class PaperdollFramePresenterTests
var factory = new RecordingFactory { Doll = firstDoll }; var factory = new RecordingFactory { Doll = firstDoll };
var presenter = new PaperdollFramePresenter(renderer, view, factory); var presenter = new PaperdollFramePresenter(renderer, view, factory);
presenter.Render(); Frame(presenter);
presenter.ResetSession(); presenter.ResetSession();
factory.Doll = secondDoll; factory.Doll = secondDoll;
presenter.Render(); Frame(presenter);
Assert.False(presenter.IsDirty); Assert.False(presenter.IsDirty);
Assert.Equal(2, factory.BuildCount); Assert.Equal(2, factory.BuildCount);
Assert.Equal([firstDoll, null, secondDoll], renderer.Dolls); Assert.Equal([firstDoll, null, secondDoll], renderer.Dolls);
// The session boundary is the ONE explicit viewport clear (#443);
// an old character must not linger while the next one uploads.
Assert.Equal(1, view.ClearCount);
} }
[Fact] [Fact]
@ -252,7 +288,7 @@ public sealed class PaperdollFramePresenterTests
private sealed class RecordingRenderer : IPaperdollDollRenderer private sealed class RecordingRenderer : IPaperdollDollRenderer
{ {
public uint TextureHandle { get; init; } public uint TextureHandle { get; set; }
public List<WorldEntity?> Dolls { get; } = []; public List<WorldEntity?> Dolls { get; } = [];
public List<(int Width, int Height)> RenderSizes { get; } = []; public List<(int Width, int Height)> RenderSizes { get; } = [];
public int RenderCount => RenderSizes.Count; public int RenderCount => RenderSizes.Count;
@ -285,6 +321,10 @@ public sealed class PaperdollFramePresenterTests
public void SetTextureHandle(uint textureHandle) => public void SetTextureHandle(uint textureHandle) =>
TextureHandles.Add(textureHandle); TextureHandles.Add(textureHandle);
public int ClearCount { get; private set; }
public void ClearTextureHandle() => ClearCount++;
} }
private sealed class RecordingFactory : IPaperdollDollFactory private sealed class RecordingFactory : IPaperdollDollFactory

View file

@ -0,0 +1,65 @@
using AcDream.App.Rendering;
using AcDream.App.Rendering.Gpu;
using AcDream.App.Tests.Rendering.Gpu;
namespace AcDream.App.Tests.Rendering;
public sealed class PrivateViewportFlightTargetsTests
{
[Fact]
public void FlightSlotsOwnDistinctTargetsAndPublishOnlyCompletedScenes()
{
using var device = new RecordingGpuDevice();
using var targets =
new PrivateEntityViewportRenderer.PrivateViewportFlightTargets(
device,
"paperdoll");
var first = Assert.IsType<
PrivateEntityViewportRenderer.PrivateViewportFlightTargets.TargetSlot>(
targets.Ensure(0, 120, 180));
var second = Assert.IsType<
PrivateEntityViewportRenderer.PrivateViewportFlightTargets.TargetSlot>(
targets.Ensure(1, 120, 180));
Assert.NotSame(first.Target, second.Target);
Assert.NotEqual(first.TextureSlot, second.TextureSlot);
Assert.Equal(2, targets.AllocatedSlotCount);
Assert.Equal(0u, targets.CompletedHandle(0));
Assert.Equal(0u, targets.CompletedHandle(1));
first.HasRenderedScene = true;
Assert.NotEqual(0u, targets.CompletedHandle(0));
Assert.Equal(0u, targets.CompletedHandle(1));
Assert.Same(first, targets.Ensure(0, 120, 180));
targets.InvalidateCompletedScenes();
Assert.Equal(0u, targets.CompletedHandle(0));
Assert.Equal(2, targets.AllocatedSlotCount);
}
[Fact]
public void ResizeRetiresEveryFlightTargetBeforeCreatingTheNewExtent()
{
using var device = new RecordingGpuDevice();
using var targets =
new PrivateEntityViewportRenderer.PrivateViewportFlightTargets(
device,
"paperdoll");
var first = targets.Ensure(0, 120, 180)!;
var second = targets.Ensure(1, 120, 180)!;
var firstTarget = Assert.IsType<RecordingGpuRenderTarget>(first.Target);
var secondTarget = Assert.IsType<RecordingGpuRenderTarget>(second.Target);
var resized = targets.Ensure(1, 160, 220)!;
Assert.True(firstTarget.IsDisposed);
Assert.True(secondTarget.IsDisposed);
Assert.Equal(1, targets.AllocatedSlotCount);
Assert.Equal(160, resized.Target.Description.Width);
Assert.Equal(220, resized.Target.Description.Height);
Assert.Equal(3, device.CreatedRenderTargets.Count);
}
}

View file

@ -75,6 +75,80 @@ public sealed class RetailAlphaQueueTests
Assert.Equal(2, source.ResetCount); Assert.Equal(2, source.ResetCount);
} }
[Fact]
public void FlushFartherThan_DrainsOnlyTheFarPrefixAndRetainsNearerEntries()
{
// The pre-building barrier: retail's far→near walk means DrawBuilding's
// FlushAlphaList(0f) @0x0059F2A0 can only flush content from cells
// farther than that building; a nearer candle flame is not inserted
// yet and composites after the building at a later flush (AP-236).
var log = new List<string>();
var objects = new RecordingSource("object", log);
var particles = new RecordingSource("particle", log);
var queue = new RetailAlphaQueue();
queue.BeginFrame();
queue.Submit(particles, 0, 30f); // far waterfall
queue.Submit(objects, 0, 25f); // far translucent part
queue.Submit(particles, 1, 10f); // exactly at the building threshold
queue.Submit(particles, 2, 5f); // near candle flame — must be kept
queue.FlushFartherThan(10f);
Assert.True(queue.IsCollecting);
Assert.Equal(1, queue.PendingCount);
Assert.Equal(new[] { "particle:0", "object:0", "particle:1" }, log);
Assert.Equal(0, objects.ResetCount);
Assert.Equal(0, particles.ResetCount);
queue.EndFrame();
Assert.Equal(
new[] { "particle:0", "object:0", "particle:1", "particle:2" },
log);
Assert.Equal(1, objects.ResetCount);
Assert.Equal(1, particles.ResetCount);
Assert.Equal(2, particles.PrepareCount);
}
[Fact]
public void FlushFartherThan_WithNoFarEntries_LeavesTheQueueUntouched()
{
var log = new List<string>();
var source = new RecordingSource("alpha", log);
var queue = new RetailAlphaQueue();
queue.BeginFrame();
queue.Submit(source, 1, 4f);
queue.FlushFartherThan(10f);
Assert.Empty(log);
Assert.Equal(1, queue.PendingCount);
Assert.Equal(0, source.PrepareCount);
Assert.Equal(0, source.ResetCount);
queue.EndFrame();
Assert.Equal(new[] { "alpha:1" }, log);
}
[Fact]
public void FlushFartherThan_DegenerateThreshold_DrainsAllWithoutResettingSources()
{
var log = new List<string>();
var source = new RecordingSource("alpha", log);
var queue = new RetailAlphaQueue();
queue.BeginFrame();
queue.Submit(source, 1, 8f);
queue.Submit(source, 2, 2f);
queue.FlushFartherThan(0f);
Assert.Equal(new[] { "alpha:1", "alpha:2" }, log);
Assert.Equal(0, queue.PendingCount);
Assert.Equal(0, source.ResetCount);
Assert.True(queue.IsCollecting);
queue.EndFrame();
Assert.Equal(1, source.ResetCount);
}
[Fact] [Fact]
public void Flush_BatchesOnlyAdjacentEntriesFromSameRenderer() public void Flush_BatchesOnlyAdjacentEntriesFromSameRenderer()
{ {

View file

@ -34,13 +34,20 @@ public sealed class RetailPViewPassExecutorTests
"terrain-clip", "terrain-clip",
"clear-routing", "clear-routing",
"landscape-late", "landscape-late",
"unattached-particles", "unattached-particles-outdoor",
"landscape-alpha", // #132: an OUTDOOR root does NOT drain at the stage boundary.
// The far prefix drains at the pre-punch barrier (retail
// DrawBuilding @0x0059F2A0 flushes before its portal-only
// far-Z pass), and the full drain runs after the dynamics
// pass, where the frame's opaque world depth is complete.
"indoor-routing", "indoor-routing",
"indoor-routing", "indoor-routing",
"landscape-alpha-farther",
"exit-mask", "exit-mask",
"indoor-routing", "indoor-routing",
"opaque-shells", "opaque-shells",
"landscape-alpha",
"unattached-particles-interior",
], ],
executor.Operations); executor.Operations);
} }
@ -65,7 +72,7 @@ public sealed class RetailPViewPassExecutorTests
string.Join('|', executor.Operations), string.Join('|', executor.Operations),
"landscape-early", "landscape-early",
"landscape-late", "landscape-late",
"unattached-particles", "unattached-particles-outdoor",
"landscape-alpha", "landscape-alpha",
"interior-depth-clear", "interior-depth-clear",
"indoor-routing", "indoor-routing",
@ -93,6 +100,32 @@ public sealed class RetailPViewPassExecutorTests
Assert.DoesNotContain("interior-depth-clear", executor.Operations); Assert.DoesNotContain("interior-depth-clear", executor.Operations);
} }
[Fact]
public void DrawInside_interior_without_an_outside_slice_still_draws_interior_unattached_particles()
{
// Repro (Sanctuary middle cell, looking north): spell ground effects
// vanished whenever no exit portal was in view, because unattached
// emitters submitted once PER outside slice under that slice's
// hardware clip slot — zero slices meant zero submissions. Retail
// draws such an emitter during its owner cell's walk turn
// (ShouldDrawParticles @0x0050FE60) and never clips it to a view.
var renderer = new RetailPViewRenderer();
using var executor = new RecordingExecutor();
var root = new LoadedCell
{
CellId = 0xA9B40100u,
WorldTransform = Matrix4x4.Identity,
InverseWorldTransform = Matrix4x4.Identity,
};
renderer.DrawInside(Frame(root), executor);
Assert.Contains("unattached-particles-interior", executor.Operations);
Assert.DoesNotContain(
"unattached-particles-outdoor",
executor.Operations);
}
[Fact] [Fact]
public void Particle_classifications_reset_before_an_empty_following_frame() public void Particle_classifications_reset_before_an_empty_following_frame()
{ {
@ -277,9 +310,9 @@ public sealed class RetailPViewPassExecutorTests
AssertAppearsInOrder( AssertAppearsInOrder(
string.Join('|', executor.Operations), string.Join('|', executor.Operations),
"landscape-early", "landscape-early",
"unattached-particles", "unattached-particles-outdoor",
"landscape-static-particles", "landscape-static-particles",
"landscape-alpha", "landscape-alpha-farther",
"look-in-punch", "look-in-punch",
"landscape-late", "landscape-late",
"landscape-alpha", "landscape-alpha",
@ -353,7 +386,7 @@ public sealed class RetailPViewPassExecutorTests
"look-in-punch", "look-in-punch",
"landscape-building-shell", "landscape-building-shell",
"landscape-static-particles", "landscape-static-particles",
"landscape-alpha", "landscape-alpha-farther",
"look-in-punch", "look-in-punch",
"landscape-building-shell"); "landscape-building-shell");
} }
@ -763,8 +796,13 @@ public sealed class RetailPViewPassExecutorTests
int portalIndex) => Operations.Add("look-in-punch"); int portalIndex) => Operations.Add("look-in-punch");
public void DrawUnattachedSceneParticles( public void DrawUnattachedSceneParticles(
RetailPViewFrameInput frame, RetailPViewFrameInput frame,
ClipViewSlice slice) => Operations.Add("unattached-particles"); bool outdoorCells) => Operations.Add(
outdoorCells
? "unattached-particles-outdoor"
: "unattached-particles-interior");
public void FlushLandscapeAlpha() => Operations.Add("landscape-alpha"); public void FlushLandscapeAlpha() => Operations.Add("landscape-alpha");
public void FlushLandscapeAlphaFartherThan(float minViewerDistance) =>
Operations.Add("landscape-alpha-farther");
public void DrawCellParticles(RetailPViewFrameInput frame, RetailPViewCellSliceContext context) => Operations.Add("cell-particles"); public void DrawCellParticles(RetailPViewFrameInput frame, RetailPViewCellSliceContext context) => Operations.Add("cell-particles");
public void DrawDynamicsParticles( public void DrawDynamicsParticles(
RetailPViewFrameInput frame, RetailPViewFrameInput frame,

View file

@ -0,0 +1,39 @@
using System;
using System.Diagnostics;
using System.IO;
using AcDream.App.Rendering.Sky;
using Xunit;
namespace AcDream.App.Tests.Rendering.Sky;
public sealed class RainAnimationClockTests
{
[Fact]
public void AnimationPhaseUsesMonotonicStopwatchTicks()
{
const long start = 1234;
Assert.Equal(
1f,
SkyRenderer.ElapsedAnimationSeconds(start, start + Stopwatch.Frequency));
}
[Fact]
public void RendererDoesNotReadTheAdjustableSystemClock()
{
string source = File.ReadAllText(Path.Combine(
RepositoryRoot(), "src", "AcDream.App", "Rendering", "Sky", "SkyRenderer.cs"));
Assert.Contains("Stopwatch.GetTimestamp()", source, StringComparison.Ordinal);
Assert.DoesNotContain("DateTime.UtcNow", source, StringComparison.Ordinal);
}
private static string RepositoryRoot()
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "AcDream.slnx")))
directory = directory.Parent;
return directory?.FullName
?? throw new InvalidOperationException("Could not locate repository root.");
}
}

View file

@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Numerics; using System.Numerics;
using AcDream.App.Rendering; using AcDream.App.Rendering;
using AcDream.App.Rendering.Vfx; using AcDream.App.Rendering.Vfx;
using AcDream.Core.Physics;
using AcDream.Core.Vfx; using AcDream.Core.Vfx;
using AcDream.Core.World; using AcDream.Core.World;
using DatReaderWriter.Types; using DatReaderWriter.Types;
@ -22,20 +23,59 @@ public sealed class SkyPesFrameControllerTests
{ {
private const uint AuroraSetup = 0x02000714u; private const uint AuroraSetup = 0x02000714u;
private const uint AuroraScript = 0x330007DBu; private const uint AuroraScript = 0x330007DBu;
private const uint LightningEmitter = 0x320002C2u;
private sealed class Harness private sealed class Harness
{ {
private sealed class RecordingHookSink : IAnimationHookSink
{
public List<(uint EntityId, Vector3 Position, AnimationHook Hook)> Calls { get; } = [];
public void OnHook(
uint entityId,
Vector3 entityWorldPosition,
AnimationHook hook) =>
Calls.Add((entityId, entityWorldPosition, hook));
}
public readonly List<uint> ResolvedScriptIds = []; public readonly List<uint> ResolvedScriptIds = [];
public readonly List<string> Diagnostics = []; public readonly List<string> Diagnostics = [];
public readonly List<(uint EntityId, Vector3 Position, AnimationHook Hook)> HookCalls;
public readonly PhysicsScriptRunner Runner; public readonly PhysicsScriptRunner Runner;
public readonly SkyPesFrameController Controller; public readonly SkyPesFrameController Controller;
public readonly ParticleSystem Particles;
public Harness() public Harness(AnimationHook? hook = null, double hookTime = 0.0)
{ {
var registry = new EmitterDescRegistry(); var registry = new EmitterDescRegistry();
var system = new ParticleSystem(registry, new Random(42)); registry.Register(new EmitterDesc
{
DatId = LightningEmitter,
Type = ParticleType.Still,
Flags = EmitterFlags.Billboard,
EmitterKind = ParticleEmitterKind.BirthratePerSec,
MaxParticles = 2,
InitialParticles = 1,
LifetimeMin = 0.01f,
LifetimeMax = 0.01f,
Lifespan = 0.01f,
StartSize = 1f,
EndSize = 1f,
StartAlpha = 1f,
EndAlpha = 1f,
Birthrate = 1000f,
});
Particles = new ParticleSystem(registry, new Random(42));
var poses = new EntityEffectPoseRegistry(); var poses = new EntityEffectPoseRegistry();
var sink = new ParticleHookSink(system, poses); var sink = new ParticleHookSink(Particles, poses)
{
DiagnosticSink = Diagnostics.Add,
};
var recording = new RecordingHookSink();
HookCalls = recording.Calls;
var router = new AnimationHookRouter();
router.Register(sink);
router.Register(recording);
Runner = new PhysicsScriptRunner( Runner = new PhysicsScriptRunner(
id => id =>
{ {
@ -43,12 +83,12 @@ public sealed class SkyPesFrameControllerTests
var script = new DatPhysicsScript(); var script = new DatPhysicsScript();
script.ScriptData.Add(new PhysicsScriptData script.ScriptData.Add(new PhysicsScriptData
{ {
StartTime = 0.0, StartTime = hookTime,
Hook = new SoundHook(), Hook = hook ?? new SoundHook(),
}); });
return script; return script;
}, },
sink); router);
Controller = new SkyPesFrameController( Controller = new SkyPesFrameController(
Runner, Runner,
sink, sink,
@ -169,4 +209,52 @@ public sealed class SkyPesFrameControllerTests
h.Controller.Update(0.1f, null, Vector3.Zero); h.Controller.Update(0.1f, null, Vector3.Zero);
Assert.Equal(0, h.Runner.ActiveScriptCount); Assert.Equal(0, h.Runner.ActiveScriptCount);
} }
[Fact]
public void LightningPartZeroUsesTheLiveCarrierPoseAndStopsOnDayGroupFlip()
{
var create = new CreateParticleHook
{
EmitterInfoId = LightningEmitter,
EmitterId = 1u,
PartIndex = 0u,
Offset = new Frame(),
};
var h = new Harness(create);
h.Controller.Update(0.1f, Group(Carrier()), new Vector3(4f, 5f, 6f));
h.Runner.Tick(0.0);
Assert.Equal(1, h.Particles.ActiveEmitterCount);
Assert.Empty(h.Diagnostics);
h.Controller.Update(0.1f, null, Vector3.Zero);
h.Runner.Tick(1.0);
Assert.Equal(0, h.Runner.ActiveScriptCount);
Assert.Empty(h.Diagnostics);
}
[Fact]
public void PersistentWeatherCarrier_RefreshesSoundAnchorToCurrentCamera()
{
var sound = new SoundTweakedHook
{
SoundId = 0x0A00038Bu,
Volume = 0.1f,
Priority = 1f,
};
var h = new Harness(sound, hookTime: 1.0);
var initialCamera = new Vector3(10f, 20f, 30f);
var currentCamera = new Vector3(410f, 520f, 630f);
h.Controller.Update(0.3f, Group(Carrier()), initialCamera);
h.Controller.Update(0.4f, Group(Carrier()), currentCamera);
h.Runner.Tick(1.0);
var call = Assert.Single(h.HookCalls);
Assert.Same(sound, call.Hook);
Assert.Equal(currentCamera, call.Position);
Assert.Single(h.ResolvedScriptIds);
}
} }

View file

@ -30,11 +30,25 @@ using AcDream.App.Tests.Rendering.Gpu;
using AcDream.Content; using AcDream.Content;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using Xunit; using Xunit;
using CullMode = DatReaderWriter.Enums.CullMode;
namespace AcDream.App.Tests.Rendering.Wb; namespace AcDream.App.Tests.Rendering.Wb;
public class EnvCellRendererTests public class EnvCellRendererTests
{ {
[Theory]
[InlineData(CullMode.Landblock)]
[InlineData(CullMode.None)]
[InlineData(CullMode.Clockwise)]
[InlineData(CullMode.CounterClockwise)]
public void CellShellCullPolicy_UsesRetailConstructedMeshClockwiseCull(
CullMode sourceSidesType)
{
Assert.Equal(
CullMode.Clockwise,
EnvCellRenderer.ResolveRetailCellShellCullMode(sourceSidesType));
}
private sealed class NullPreparedAssetSource : IPreparedAssetSource private sealed class NullPreparedAssetSource : IPreparedAssetSource
{ {
public PreparedAssetSourceStats Stats => default; public PreparedAssetSourceStats Stats => default;

View file

@ -300,6 +300,56 @@ public sealed class WorldRenderFrameBuilderTests
Assert.Contains(hiddenLight, lighting.PointSnapshot); Assert.Contains(hiddenLight, lighting.PointSnapshot);
} }
[Fact]
public void PersistentAtDay_UsesNoonLandscapeLightingWithoutChangingTheSkyClock()
{
SkyStateProvider sky = SkyStateProvider.Default();
var clock = new WorldTimeService(sky);
clock.PinnedDayFraction = 0f;
var lighting = new LightManager();
bool persistentDaylight = true;
var environment = new RuntimeWorldFrameEnvironmentPreparation(
RuntimeOptions.Parse("test-dat", _ => null),
clock,
lighting,
dispatcher: null,
environmentCells: null,
lightingUbo: null,
new WorldRenderRangeState(4, 12),
skyPes: null,
persistentDaylight: () => persistentDaylight);
WorldCameraFrame camera = CameraFrame(new FlyCamera());
WorldRootFrame roots = default;
SkyKeyframe midnight = sky.Interpolate(0f);
SkyKeyframe noon = sky.Interpolate(0.5f);
var foundation = new RenderFrameFoundation(
PortalViewportVisible: false,
Sky: midnight,
Atmosphere: default);
environment.Prepare(
in camera,
in roots,
in foundation,
activeDayGroup: null);
Assert.Equal(noon.AmbientColor, lighting.CurrentAmbient.AmbientColor);
Assert.NotNull(lighting.Sun);
Assert.Equal(noon.SunColor, lighting.Sun!.ColorLinear);
Assert.Equal(0d, clock.DayFraction, precision: 5);
persistentDaylight = false;
environment.Prepare(
in camera,
in roots,
in foundation,
activeDayGroup: null);
Assert.Equal(midnight.AmbientColor, lighting.CurrentAmbient.AmbientColor);
Assert.NotNull(lighting.Sun);
Assert.Equal(midnight.SunColor, lighting.Sun!.ColorLinear);
}
[Fact] [Fact]
public void Environment_preparation_keeps_lighting_snapshot_before_ubo_upload() public void Environment_preparation_keeps_lighting_snapshot_before_ubo_upload()
{ {

View file

@ -251,6 +251,29 @@ public sealed class WorldSceneRendererTests
Assert.Equal(4, rig.PView.LastInput.RenderRadius); Assert.Equal(4, rig.PView.LastInput.RenderRadius);
} }
[Fact]
public void PViewWorld_PublishesLookInCellsToParticleAndLightVisibility()
{
var root = new LoadedCell
{
CellId = 0x01010001u,
IsOutdoorNode = false,
};
var rig = new Rig(portalVisible: false, waitingForLogin: false, clipRoot: root);
rig.Renderer.Render(default);
// Retail gates effects on cell->IsInView() (ShouldDrawParticles
// @0x0050FE60), and a cell entered through a building portal
// (DrawBuilding @0x0059F2A0 -> PView::ConstructView @0x005A57B0) is
// drawn by the same traversal as a flooded cell. The particle gate and
// the light-candidate scope must therefore receive InViewCells (flood
// plus look-ins), not the flood-only DrawableCells.
Assert.Contains(0x01010003u, rig.Visibility.MarkedCells);
Assert.NotNull(rig.Frames.ObservedCells);
Assert.Contains(0x01010003u, rig.Frames.ObservedCells!);
}
[Fact] [Fact]
public void PViewWorld_ReusesOneSynchronousFrameInputAcrossFrames() public void PViewWorld_ReusesOneSynchronousFrameInputAcrossFrames()
{ {
@ -536,7 +559,8 @@ public sealed class WorldSceneRendererTests
Frames = new FrameBuilder(Calls, frame); Frames = new FrameBuilder(Calls, frame);
Selection = new SelectionFrame(Calls); Selection = new SelectionFrame(Calls);
var alpha = new AlphaFrame(Calls); var alpha = new AlphaFrame(Calls);
var visibility = new ParticleVisibility(Calls); Visibility = new ParticleVisibility(Calls);
var visibility = Visibility;
PView = new PViewRenderer(Calls); PView = new PViewRenderer(Calls);
Passes = new PassExecutor(Calls); Passes = new PassExecutor(Calls);
var diagnostics = new Diagnostics(Calls); var diagnostics = new Diagnostics(Calls);
@ -573,6 +597,8 @@ public sealed class WorldSceneRendererTests
public SelectionFrame Selection { get; } public SelectionFrame Selection { get; }
public ParticleVisibility Visibility { get; }
public PViewRenderer PView { get; } public PViewRenderer PView { get; }
public PassExecutor Passes { get; } public PassExecutor Passes { get; }
@ -621,8 +647,13 @@ public sealed class WorldSceneRendererTests
return frame; return frame;
} }
public void ObserveDrawableCells(IReadOnlySet<uint> drawableCells) => public IReadOnlySet<uint>? ObservedCells { get; private set; }
public void ObserveDrawableCells(IReadOnlySet<uint> drawableCells)
{
calls.Add("frame:observe-cells"); calls.Add("frame:observe-cells");
ObservedCells = new HashSet<uint>(drawableCells);
}
public void ClearDrawableCells() => calls.Add("frame:clear-cells"); public void ClearDrawableCells() => calls.Add("frame:clear-cells");
} }
@ -684,8 +715,13 @@ public sealed class WorldSceneRendererTests
private sealed class ParticleVisibility(List<string> calls) : private sealed class ParticleVisibility(List<string> calls) :
IWorldSceneParticleVisibility IWorldSceneParticleVisibility
{ {
public void MarkVisibleCells(HashSet<uint> cellIds) => public HashSet<uint> MarkedCells { get; } = [];
public void MarkVisibleCells(HashSet<uint> cellIds)
{
calls.Add("visibility:mark"); calls.Add("visibility:mark");
MarkedCells.UnionWith(cellIds);
}
public void CompleteFrame() => calls.Add("visibility:complete"); public void CompleteFrame() => calls.Add("visibility:complete");
@ -701,11 +737,17 @@ public sealed class WorldSceneRendererTests
public PViewRenderer(List<string> calls) public PViewRenderer(List<string> calls)
{ {
_calls = calls; _calls = calls;
// Distinct flood-only vs in-view sets: 0x01010003 is a look-in
// cell that is drawn but never part of the main flood.
var interiorPartition = new InteriorEntityPartition.Result();
_interiorResult = new RetailPViewFrameResult().Reset( _interiorResult = new RetailPViewFrameResult().Reset(
new PortalVisibilityFrame(), new PortalVisibilityFrame(),
new ClipFrameAssembly(), new ClipFrameAssembly(),
[], [0x01010001u],
new InteriorEntityPartition.Result()); [0x01010001u, 0x01010003u],
RetailPViewRenderer.LegacyDiagnosticCounts(interiorPartition),
RetailPViewRenderer.LegacySourceCounts(interiorPartition),
interiorPartition);
var outdoorPortalFrame = new PortalVisibilityFrame(); var outdoorPortalFrame = new PortalVisibilityFrame();
outdoorPortalFrame.OutsideView.Add(new ViewPolygon( outdoorPortalFrame.OutsideView.Add(new ViewPolygon(
[ [

Some files were not shown because too many files have changed in this diff Show more