Compare commits

..

No commits in common. "main" and "codex/pak-v2-optimization" have entirely different histories.

150 changed files with 1506 additions and 9087 deletions

View file

@ -141,6 +141,7 @@ $env:ACDREAM_TEST_HOST = "127.0.0.1"
$env:ACDREAM_TEST_PORT = "9000"
$env:ACDREAM_TEST_USER = "testaccount"
$env:ACDREAM_TEST_PASS = "testpassword"
$env:ACDREAM_RETAIL_UI = "1"
dotnet run --project src\AcDream.App\AcDream.App.csproj -c Release
```
@ -207,7 +208,7 @@ built-in policies are `idle`, `lifecycle-smoke`, `observer-movement`, and
| `ACDREAM_LIVE=1` | Enable connected mode |
| `ACDREAM_TEST_HOST` / `ACDREAM_TEST_PORT` | ACE endpoint |
| `ACDREAM_TEST_USER` / `ACDREAM_TEST_PASS` | Graphical-client credentials |
| `ACDREAM_RETAIL_UI=0` | Disable the retained retail gameplay UI for diagnostics; it is enabled by default |
| `ACDREAM_RETAIL_UI=1` | Enable the retained retail gameplay UI |
| `ACDREAM_DEVTOOLS=1` | Enable ImGui developer tools |
| `ACDREAM_NO_AUDIO=1` | Suppress OpenAL initialization |
| `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,13 +40,11 @@ Assume a flag has a side effect until its row says otherwise.
- **Everything diagnostic is OFF by default.** Every probe, dump, capture,
and measurement flag in this document is inert until its variable is
explicitly set — an unset environment runs zero diagnostics. Exactly
five flags default ON, and none is a diagnostic: `ACDREAM_RETAIL_CHASE`,
four flags default ON, and none is a diagnostic: `ACDREAM_RETAIL_CHASE`,
`ACDREAM_CAMERA_COLLIDE`, `ACDREAM_CAMERA_ALIGN_SLOPE`, and
`ACDREAM_RETAIL_CLOSE_DEGRADES` are retail *behaviors* wearing an A/B
off-switch (`=0` disables the behavior for a comparison run), while
`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
off-switch (`=0` disables the behavior for a comparison run). That
four-flag set is frozen by `LaunchOptionsDocumentationTests` — a new
default-on flag fails the build.
- `=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
@ -77,13 +75,14 @@ $env:ACDREAM_TEST_HOST = "127.0.0.1"
$env:ACDREAM_TEST_PORT = "9000"
$env:ACDREAM_TEST_USER = "testaccount"
$env:ACDREAM_TEST_PASS = "testpassword"
$env:ACDREAM_RETAIL_UI = "1"
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Release
```
| 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_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_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_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_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` |
@ -116,7 +115,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_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_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_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_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_PORT` | `=<int>` | ACE server port for live-mode connect. | none | `9000` | `RuntimeOptions.LivePort` (`RuntimeOptions.cs:143`) |
@ -185,7 +184,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_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_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_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_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_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,12 +503,6 @@ elapsed seconds that `TexVelocityX/Y` accumulate against with a fixed value.
**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.
**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
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
@ -2597,10 +2591,6 @@ Holtburg pair went from 23,090 differing pixels to 1,211. **The alternative was
`-MaskTopPixels`, which would have permanently blinded the campaign's strictest
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**
(`1f25a609`). The route opened by pressing `AcdreamCycleTimeOfDay` three times.
The mechanism underneath is `WorldTimeService.SetDebugTime`, and

View file

@ -13,12 +13,6 @@
> channel ids) remains accurate and is still the authority. Only the columns
> describing what acdream does are out of date; verify against
> `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

View file

@ -199,27 +199,6 @@ parsed at `CreateObject.cs:611-619`) — reused rather than re-defined.
`RuntimeHouseState` raw-field owner), and file the seven line builders
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)
The handoff claimed "0x0227/0x0228 absent from the enum". Checked

View file

@ -1,68 +0,0 @@
# 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

@ -1,141 +0,0 @@
# 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

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

View file

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

View file

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

View file

@ -383,8 +383,6 @@ internal sealed class LiveSessionCommandRouter : ILiveSessionCommandRouting
ToggleFrameRate: () => InvokeClient(static b => b.ToggleFrameRate()),
ToggleUiLock: () => InvokeClient(static b => b.ToggleUiLock()),
ShowSystemMessage: text => InvokeClient(b => b.ShowSystemMessage(text)),
ShowClientLocalMessage: text =>
InvokeClient(b => b.ShowClientLocalMessage(text)),
ShowWeenieError: error => InvokeClient(b => b.ShowWeenieError(error)),
PlayerPublicWeenieBitfield: () =>
ReadClient(static b => b.PlayerPublicWeenieBitfield(), default(uint?)),
@ -445,91 +443,7 @@ internal sealed class LiveSessionCommandRouter : ILiveSessionCommandRouting
LeaveGmChannel: channelId => InvokeClient(b => b.LeaveGmChannel(channelId)),
RecallAllegianceHometown: () => InvokeClient(static b => b.RecallAllegianceHometown()),
RequestAllegianceInfo: name => InvokeClient(b => b.RequestAllegianceInfo(name)),
AbandonHouse: () => InvokeClient(static b => b.AbandonHouse()),
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)));
AbandonHouse: () => InvokeClient(static b => b.AbandonHouse()));
private bool InvokeClient(Action<ClientCommandController.Bindings> invoke)
{

View file

@ -414,8 +414,7 @@ internal sealed class LiveSessionRuntimeFactory
Allegiance: _domain.Runtime.AllegianceOwner,
Trade: _domain.Runtime.TradeOwner,
House: _domain.Runtime.HouseOwner,
Contracts: _domain.Runtime.ContractsOwner,
PlayerGuid: () => _player.Identity.ServerGuid));
Contracts: _domain.Runtime.ContractsOwner));
return new GraphicalSessionEventRoute(
route,
_domain.Runtime,
@ -617,9 +616,6 @@ internal sealed class LiveSessionRuntimeFactory
// unstarted — CH4/CH5 scope at the earliest, not CH2.
ShowSystemMessage:
text => _domain.Communication.Chat.OnSystemMessage(text, 0x00u),
ShowClientLocalMessage:
text => _domain.Communication.AddText(
text, RetailLogTextType.ClientLocal),
// SHOULD-FIX 3 (docs/research/2026-08-09-ch2-review-findings.md):
// route through the AddText chokepoint instead of the deleted
// ChatLog.OnWeenieError, which hardcoded LogTextType 0x00 —
@ -732,66 +728,7 @@ internal sealed class LiveSessionRuntimeFactory
LeaveGmChannel: session.SendOffChannel,
RecallAllegianceHometown: session.SendRecallAllegianceHometown,
RequestAllegianceInfo: session.SendAllegianceInfoRequest,
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,
})),
AbandonHouse: session.SendAbandonHouse),
_domain.Communication.Chat,
_domain.Communication.TurbineChat,
PlayerGuid: () => _player.Identity.ServerGuid,

View file

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

View file

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

View file

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

View file

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

View file

@ -24,8 +24,6 @@ internal interface IPaperdollFrameView
bool TryGetVisibleSize(out int width, out int height);
void SetTextureHandle(uint textureHandle);
void ClearTextureHandle();
}
internal interface IPaperdollInventoryVisibility
@ -53,9 +51,7 @@ internal interface IPaperdollPoseApplicator
/// presentation edge. The renderer remains a borrowed resource disposed by
/// the existing window shutdown transaction.
/// </summary>
internal sealed class PaperdollFramePresenter :
IPrivateEntityViewportFrame,
IPrivateEntityViewportResourcePreparation
internal sealed class PaperdollFramePresenter : IPrivateEntityViewportFrame
{
private readonly IPaperdollDollRenderer _renderer;
private readonly IPaperdollFrameView _view;
@ -77,30 +73,22 @@ internal sealed class PaperdollFramePresenter :
public void MarkDirty() => _dirty = true;
/// <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()
public void Render()
{
if (_dirty)
{
if (_factory.TryBuild(out WorldEntity? doll))
{
// Redress every accepted live-player refresh, including an
// appearance-equivalent ObjDesc after portal/relogin. The
// private clone belongs to the current presentation
// generation; retaining the old object merely because its
// pixels compare equal can leave it attached to retired
// 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);
_doll = doll;
// Same-generation CreateObject refreshes can repeat the exact
// player ObjDesc at a portal boundary. Retail redresses its
// private inventory object in place; releasing and reacquiring
// an identical synthetic owner briefly blanks the viewport and
// churns its texture composites.
if (!HasEquivalentAppearance(_doll, doll))
{
_renderer.SetDoll(doll);
_doll = doll;
}
_dirty = false;
}
else
@ -108,25 +96,16 @@ internal sealed class PaperdollFramePresenter :
// gmPaperDollUI::RedressCreature @ 0x004A3BC0 leaves its
// private m_pInventoryObject intact when the SmartBox player
// is temporarily unavailable. Keep the successful doll and
// retry this dirty redress on the next frame.
// retry this dirty redress on the next visible frame.
}
}
_renderer.Prepare();
}
public void Render()
{
if (!_view.TryGetVisibleSize(out int width, out int height))
return;
// 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);
_view.SetTextureHandle(_renderer.Render(width, height));
}
/// <summary>
@ -136,10 +115,80 @@ internal sealed class PaperdollFramePresenter :
public void ResetSession()
{
_renderer.SetDoll(null);
_view.ClearTextureHandle();
_doll = null;
_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>
@ -177,9 +226,6 @@ internal sealed class RetailPaperdollFrameView : IPaperdollFrameView
/// </summary>
public void SetTextureHandle(uint textureHandle) =>
_viewport.TextureSlot = UiTextureTableHandle.ToSlot(textureHandle);
public void ClearTextureHandle() =>
_viewport.TextureSlot = GpuTextureSlot.Unassigned;
}
/// <summary>Narrow visibility adapter for the paperdoll's inventory host.</summary>

View file

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

View file

@ -20,8 +20,8 @@ internal interface IPrivateEntityViewportCamera : ICamera
/// <summary>
/// Shared render-to-texture implementation for the private 3-D creature
/// viewports used by paperdoll and examination UI. Each instance owns one
/// <see cref="IGpuRenderTarget"/> per encountered GPU flight slot, one
/// synthetic render identity, and one balanced texture-owner lease.
/// <see cref="IGpuRenderTarget"/>, one 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
/// hand-rolled FBO, colour texture and depth renderbuffer, and the resulting GL
@ -65,6 +65,7 @@ internal sealed class PrivateEntityViewportRenderer :
{
private const uint PrivateLandblockId = 0u;
private readonly IGpuDevice _device;
private readonly ICurrentGpuFrameSource _frames;
/// <summary>
@ -93,12 +94,17 @@ internal sealed class PrivateEntityViewportRenderer :
/// feature does not exist for them, not just "unused".</summary>
private readonly EntitySlot? _backdropSlot;
// A target written by frame N cannot also be sampled by an unretired frame
// N-1. Vulkan permits those command buffers to overlap, so one shared image
// is a cross-frame write/read race. The current frame slot selects one
// bounded target + texture-table handle; the retained UI samples that exact
// handle later in the same command buffer.
private readonly PrivateViewportFlightTargets _flightTargets;
// One stable sampled texture-table slot is part of the retained viewport's
// presentation contract. Rotating the slot with the Vulkan flight index
// made the UI sample a freshly-created/cleared sibling after world reveal.
// The frame submission order already protects this target's write -> sample
// transition; keep its identity stable until resize or disposal.
private IGpuRenderTarget? _target;
private IGpuSampler? _sampler;
private GpuTextureSlot _slot = GpuTextureSlot.Unassigned;
private int _fbW;
private int _fbH;
private bool _hasRenderedScene;
public PrivateEntityViewportRenderer(
IWorldPassScope scope,
@ -121,7 +127,7 @@ internal sealed class PrivateEntityViewportRenderer :
_scope = scope ?? throw new ArgumentNullException(
nameof(scope),
"The viewport must publish a world pass scope to draw into.");
ArgumentNullException.ThrowIfNull(device);
_device = device ?? throw new ArgumentNullException(nameof(device));
_frames = frames ?? throw new ArgumentNullException(nameof(frames));
_dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
_lightUbo = lightUbo ?? throw new ArgumentNullException(nameof(lightUbo));
@ -131,9 +137,6 @@ internal sealed class PrivateEntityViewportRenderer :
_diagnosticName = string.IsNullOrWhiteSpace(diagnosticName)
? "creature viewport"
: diagnosticName;
_flightTargets = new PrivateViewportFlightTargets(
device,
_diagnosticName);
IEntityTextureLifetime textureLifetimeChecked = textureLifetime
?? throw new ArgumentNullException(nameof(textureLifetime));
@ -170,17 +173,7 @@ internal sealed class PrivateEntityViewportRenderer :
/// </summary>
public bool TextureIsBottomUp => false;
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();
}
}
public void SetEntity(WorldEntity? entity) => _mainSlot.Set(entity);
/// <summary>
/// Advances the private entity's mesh and texture-composite readiness
@ -233,50 +226,51 @@ internal sealed class PrivateEntityViewportRenderer :
/// </summary>
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
// preparation/GPU upload; it does not make the mesh drawable. Keep the
// current flight slot's last completed private scene intact until every
// drawable mesh in the replacement has crossed that upload barrier.
// On first open there is no completed scene, so return zero and let the
// authored panel art show through instead of publishing a cleared target.
// last completed private scene intact until every drawable mesh in the
// replacement has crossed that upload barrier. On first open there is
// no completed scene, so return zero and let the authored panel art
// show through instead of publishing a freshly-cleared black target.
bool mainReady = _mainSlot.PrepareForDraw();
bool backdropReady = _backdropSlot?.PrepareForDraw() ?? true;
if (!mainReady || !backdropReady)
{
return _mainSlot.Entity is not null
? _flightTargets.CompletedHandle(frameSlot)
&& _hasRenderedScene
&& _slot.IsAssigned
? UiTextureTableHandle.FromSlot(_slot)
: 0u;
}
WorldEntity? entity = _mainSlot.Entity;
if (entity is null || entity.MeshRefs.Count == 0)
if (entity is null || entity.MeshRefs.Count == 0 || width <= 0 || height <= 0)
return 0u;
IReadOnlyList<WorldEntity> drawEntities = BuildDrawEntities(
_backdropSlot?.Entity,
entity);
if (!_dispatcher.PreparePrivateEntityResources(drawEntities))
return _flightTargets.CompletedHandle(frameSlot);
{
return _hasRenderedScene && _slot.IsAssigned
? UiTextureTableHandle.FromSlot(_slot)
: 0u;
}
PrivateViewportFlightTargets.TargetSlot? targetSlot =
_flightTargets.Ensure(frameSlot, width, height);
if (targetSlot is null)
EnsureRenderTarget(width, height);
if (_target is null)
return 0u;
_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
{
Name = _diagnosticName,
Color = new GpuColorAttachment(
Target: targetSlot.Target,
Target: _target,
Load: GpuLoadOp.Clear,
Store: GpuStoreOp.Store,
ClearColor: Vector4.Zero),
@ -310,12 +304,6 @@ internal sealed class PrivateEntityViewportRenderer :
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(
_camera,
entries,
@ -323,8 +311,8 @@ internal sealed class PrivateEntityViewportRenderer :
neverCullLandblockId: PrivateLandblockId,
visibleCellIds: null,
animatedEntityIds: _animatedIds);
targetSlot.HasRenderedScene = true;
return UiTextureTableHandle.FromSlot(targetSlot.TextureSlot);
_hasRenderedScene = true;
return UiTextureTableHandle.FromSlot(_slot);
}
/// <summary>
@ -375,6 +363,69 @@ 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()
{
List<Exception>? failures = null;
@ -396,7 +447,7 @@ internal sealed class PrivateEntityViewportRenderer :
}
try
{
_flightTargets.Dispose();
ReleaseRenderTarget();
}
catch (Exception error)
{
@ -411,163 +462,6 @@ 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>
/// 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

View file

@ -16,19 +16,6 @@ internal interface IPrivateEntityViewportFrame
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
{
void Render(double deltaSeconds, int width, int height);

View file

@ -29,29 +29,20 @@ internal sealed class RenderFramePreparationController : IRenderFrameResourcePha
private readonly IRenderFrameResourcePhase _resources;
private readonly IDevToolsFrameLifecycle? _devTools;
private readonly IRenderWeatherFramePhase _weather;
private readonly IPrivateEntityViewportResourcePreparation? _privateViewports;
public RenderFramePreparationController(
IRenderFrameResourcePhase resources,
IDevToolsFrameLifecycle? devTools,
IRenderWeatherFramePhase weather,
IPrivateEntityViewportResourcePreparation? privateViewports = null)
IRenderWeatherFramePhase weather)
{
_resources = resources ?? throw new ArgumentNullException(nameof(resources));
_devTools = devTools;
_weather = weather ?? throw new ArgumentNullException(nameof(weather));
_privateViewports = privateViewports;
}
public void Prepare(RenderFrameInput 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);
_weather.Tick(input.DeltaSeconds);
}

View file

@ -239,106 +239,6 @@ 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()
{
if (!IsCollecting)

View file

@ -444,9 +444,20 @@ internal sealed class RetailPViewPassExecutor :
animatedEntityIds: frame.AnimatedEntityIds);
}
// Late-stage particle owners submit ONCE per frame through
// DrawLandscapeStaticParticles after the slice loop (retail: one
// unclipped alpha-list insertion per emitter), not per slice here.
_particleClassifications.ReplaceOutdoor(context.ParticleOwnerIds);
if (_particleClassifications.Outdoor.Count > 0
&& _particles is not null
&& _particleRenderer is not null)
{
_particleRenderer.DrawForOwners(
frame.Camera,
frame.CameraWorldPosition,
ParticleRenderPass.Scene,
_particleClassifications.Outdoor,
clipSlot: (uint)context.Slice.Slot);
}
EnableClipDistances();
if (frame.RenderSky && frame.RenderWeather)
{
@ -481,12 +492,8 @@ internal sealed class RetailPViewPassExecutor :
RetailPViewFrameInput frame,
RetailPViewLandscapeStaticParticleContext context)
{
// One unclipped submission per owner per frame. Retail never clips a
// 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.
bool scissor = BeginDoorwayScissor(context.Slice.NdcAabb);
_surface.BindTerrainClip();
DisableClipDistances();
_particleClassifications.ReplaceOutdoor(context.ParticleOwnerIds);
@ -499,9 +506,11 @@ internal sealed class RetailPViewPassExecutor :
frame.CameraWorldPosition,
ParticleRenderPass.Scene,
_particleClassifications.Outdoor,
clipSlot: 0);
clipSlot: (uint)context.Slice.Slot);
}
if (scissor)
_surface.EndScissor();
_entities.ClearClipRouting();
DisableClipDistances();
}
@ -564,19 +573,11 @@ internal sealed class RetailPViewPassExecutor :
public void DrawUnattachedSceneParticles(
RetailPViewFrameInput frame,
bool outdoorCells)
ClipViewSlice slice)
{
if (_particles is null || _particleRenderer is null)
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();
_particleRenderer.DrawForOwners(
frame.Camera,
@ -584,17 +585,11 @@ internal sealed class RetailPViewPassExecutor :
ParticleRenderPass.Scene,
_noSceneParticleEntityIds,
includeUnattached: true,
clipSlot: 0,
unattachedCellScope: outdoorCells
? UnattachedEmitterCellScope.OutdoorCells
: UnattachedEmitterCellScope.InteriorCells);
clipSlot: (uint)slice.Slot);
}
public void FlushLandscapeAlpha() => _alpha.Flush();
public void FlushLandscapeAlphaFartherThan(float minViewerDistance) =>
_alpha.FlushFartherThan(minViewerDistance);
public void DrawCellParticles(
RetailPViewFrameInput frame,
RetailPViewCellSliceContext context)
@ -613,14 +608,12 @@ internal sealed class RetailPViewPassExecutor :
return;
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(
frame.Camera,
frame.CameraWorldPosition,
ParticleRenderPass.Scene,
visible,
clipSlot: 0);
clipSlot: (uint)context.Slice.Slot);
DisableClipDistances();
}

View file

@ -28,19 +28,6 @@ public sealed class RetailPViewRenderer
private static readonly IReadOnlySet<uint> NoParticleOwners =
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);
// 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
@ -179,19 +166,13 @@ public sealed class RetailPViewRenderer
// contains them). drawableCells itself stays the MAIN flood: it feeds the
// seals, the outside-stage predicate, and the frame result.
var prepareCells = drawableCells;
_lookInCellIds.Clear();
if (_lookInFrames.Count > 0)
{
_lookInPrepareScratch.Clear();
_lookInPrepareScratch.UnionWith(drawableCells);
foreach (var f in _lookInFrames)
{
foreach (uint c in f.OrderedVisibleCells)
{
_lookInPrepareScratch.Add(c);
_lookInCellIds.Add(c);
}
}
prepareCells = _lookInPrepareScratch;
}
@ -258,9 +239,7 @@ public sealed class RetailPViewRenderer
_partitionResult,
prepareCells,
ctx.LandblockEntries,
_partitionObserver,
ctx.Frustum,
ctx.PlayerLandblockId ?? 0u);
_partitionObserver);
partition = _partitionResult;
}
@ -270,13 +249,10 @@ public sealed class RetailPViewRenderer
RenderProjectionCounts sourceCounts = frameViewBorrowed
? frameView.SourceDigest.Counts
: 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(
pvFrame,
clipAssembly,
drawableCells,
prepareCells,
counts,
sourceCounts,
partition);
@ -318,31 +294,6 @@ public sealed class RetailPViewRenderer
frameEntityPasses,
in frameView);
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);
DrawEnvCellShells(passes, pvFrame);
DrawCellObjectLists(
@ -364,24 +315,6 @@ public sealed class RetailPViewRenderer
frameEntityPasses,
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)
{
frameEntityPasses!.CompleteEntityFrame(in frameView);
@ -493,71 +426,6 @@ 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()
{
for (int i = 0; i < _lookInFrames.Count; i++)
@ -615,22 +483,12 @@ public sealed class RetailPViewRenderer
{
PortalVisibilityFrame frame = _lookInFrames[frameIndex];
// Retail enters DrawBuilding once per building and drains the
// alpha accumulated by the preceding building before punching the
// next building's portals — and because retail's far→near walk
// 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.
// Retail enters DrawBuilding once per building and drains every
// alpha submission accumulated by the preceding building before
// punching the next building's portals. The first building uses
// the pre-look-in barrier in DrawLandscapeThroughOutsideView.
if (frameIndex > 0)
{
passes.FlushLandscapeAlphaFartherThan(
LookInBarrierDrainDistance(
frame,
ctx.Cells,
ctx.CameraWorldPosition));
}
passes.FlushLandscapeAlpha();
// Pass 1: far-Z punch every aperture of this building.
foreach (ExteriorPortalSeed seed in frame.ExteriorSeedPortals)
@ -690,8 +548,6 @@ public sealed class RetailPViewRenderer
_cellStaticScratch.Add(e);
}
bool cellDrewObjects = false;
_cellParticleUnionScratch.Clear();
foreach (ClipViewSlice slice in cellSlices)
{
int routeIndex = lookInRouteIndex++;
@ -737,21 +593,12 @@ public sealed class RetailPViewRenderer
_cellStaticScratch,
_oneCell);
cellDrewObjects = true;
_cellParticleUnionScratch.UnionWith(
_cellParticleOwnerScratch);
// The nested DrawCells object pass includes emitters and
// retains the exact setup_view clip until alpha playback.
passes.DrawCellParticles(ctx, new RetailPViewCellSliceContext(
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
@ -763,7 +610,6 @@ public sealed class RetailPViewRenderer
// anchor EnvCell; never let an unrelated building repaint a
// look-in merely because both happen to be nearby.
int sliceIndex = 0;
_staticParticleUnionScratch.Clear();
foreach (ClipViewSlice slice in clipAssembly.OutsideViewSlices)
{
int shellRouteIndex = LookInBuildingShellRouteIndex(
@ -842,20 +688,14 @@ public sealed class RetailPViewRenderer
_lateParticleOwnerScratch,
_buildingShellScratch);
}
_staticParticleUnionScratch.UnionWith(
_lateParticleOwnerScratch);
passes.DrawLandscapeStaticParticles(
ctx,
new RetailPViewLandscapeStaticParticleContext(
slice,
_lateParticleOwnerScratch));
}
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();
}
}
@ -953,19 +793,14 @@ public sealed class RetailPViewRenderer
bool hasBuildingLookIns = _lookInFrames.Count > 0;
if (hasBuildingLookIns)
{
// Ownerless OUTDOOR-cell emitters cannot ride an entity route.
// Retail inserts each one into the single alpha list once, during
// its cell's landscape walk turn, with no portal-view clip; the
// interior-cell ownerless emitters submit in the final world
// scope instead (see DrawDynamicsLast).
passes.DrawUnattachedSceneParticles(ctx, outdoorCells: true);
_staticParticleUnionScratch.Clear();
int outsideSliceTotal = clipAssembly.OutsideViewSlices.Length;
for (int barrierSliceIndex = 0;
barrierSliceIndex < outsideSliceTotal;
barrierSliceIndex++)
int barrierSliceIndex = 0;
foreach (var slice in clipAssembly.OutsideViewSlices)
{
// Ownerless outdoor emitters cannot ride an entity route. Retail
// draws their meshes once for every installed outside_view;
// retain that slot through deferred alpha playback.
passes.DrawUnattachedSceneParticles(ctx, slice);
_lateParticleOwnerScratch.Clear();
if (partition is not null)
{
@ -998,30 +833,15 @@ public sealed class RetailPViewRenderer
barrierSliceIndex,
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(
ctx,
new RetailPViewLandscapeStaticParticleContext(
_staticParticleUnionScratch));
_staticParticleUnionScratch.Clear();
passes.FlushLandscapeAlphaFartherThan(
LookInBarrierDrainDistance(
_lookInFrames[0],
ctx.Cells,
ctx.CameraWorldPosition));
passes.DrawLandscapeStaticParticles(
ctx,
new RetailPViewLandscapeStaticParticleContext(
slice,
_lateParticleOwnerScratch));
barrierSliceIndex++;
}
passes.FlushLandscapeAlpha();
}
// #124: far-building look-ins draw HERE — still inside the landscape
@ -1040,10 +860,8 @@ public sealed class RetailPViewRenderer
// LATE phase (per slice): outside-stage dynamics' meshes (#118 — drawn
// pre-clear so the seal protects their aperture pixels; AFTER the
// look-ins so a translucent portal mesh blends over a far interior
// instead of being overpainted). The scene-particle owners (statics +
// dynamics cone survivors) accumulate across the slices and submit
// ONCE, unclipped, after the loop.
_staticParticleUnionScratch.Clear();
// instead of being overpainted) + the scene-particle owners (statics +
// dynamics cone survivors — flames ride here for the same reason).
probeSliceIndex = 0;
foreach (var slice in clipAssembly.OutsideViewSlices)
{
@ -1071,15 +889,7 @@ public sealed class RetailPViewRenderer
if (viewcone.SphereVisibleInOutsideSlice(probeSliceIndex, c, r))
{
_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);
}
}
if (frameEntityPasses is not null)
@ -1119,75 +929,37 @@ public sealed class RetailPViewRenderer
0,
ctx.PlayerLandblockId ?? 0);
probeSliceIndex++;
_staticParticleUnionScratch.UnionWith(_lateParticleOwnerScratch);
passes.DrawLandscapeSliceLate(
ctx,
new RetailPViewLandscapeLateSliceContext(
slice,
_outdoorStaticScratch)
_outdoorStaticScratch,
_lateParticleOwnerScratch)
{
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,
// campfires, ground effects anchored at a position) have no owner id
// to ride any of the id-filtered particle passes. OUTDOOR-cell ones
// submit ONCE in the landscape stage, unclipped — retail inserts each
// particle into the single alpha list during its owner cell's walk
// turn (ShouldDrawParticles @0x0050FE60 gates by cell + distance;
// FlushAlphaList @0x0059D2E0 depth-tests at composition). The former
// once-per-outside-slice submission with that slice's clip slot cut
// effects at aperture boundaries and drew NOTHING when no outside
// 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.
// to ride any of the id-filtered particle passes. Draw once per
// installed outside_view for BOTH root kinds, matching retail's
// landscape-stage placement and preserving the slot in each deferred
// draw. The former outdoor-root post-world tail ran after building
// cells and let exterior alpha repaint the cathedral transition.
// With no look-ins they drain at the end of the landscape stage; the
// look-in path submits them at its pre-building barrier so later opaque
// cell floors can cover them.
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
// immediately after LScape::draw and before the optional depth clear.
// 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
// stage and the interior stage (PView::DrawCells, Ghidra 0x005a4840 —
@ -1360,13 +1132,13 @@ public sealed class RetailPViewRenderer
Array.Empty<WorldEntity>(),
visibleCellIds: null);
// Particles emit exactly once, in the stage matching the parent
// cell. Pure-outdoor dynamics are absent from the DynamicLast
// route (they draw only in the outside stage), and interior
// straddlers — present in BOTH routes — emit their particles
// HERE so the interior stage cannot repaint over them; the late
// landscape submission excludes DynamicLast owners for the same
// reason.
// An owner routed through any pre-clear outside slice already had
// its alpha particles drawn there. Meshes may be submitted in both
// stages, but particles must be emitted exactly once.
RenderFrameRouteOwnerSelector.ExceptRoute(
_dynamicParticleOwnerScratch,
in frameView,
RenderFrameCandidateRoute.LandscapeOutsideDynamic);
if (_dynamicParticleOwnerScratch.Count > 0)
{
passes.DrawDynamicsParticles(
@ -1403,14 +1175,6 @@ public sealed class RetailPViewRenderer
$"cell=0x{(e.ParentCellId ?? 0):X8} indoor=False rootOutdoor={rootIsOutdoor} -> CULLED(outside-stage)");
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
? viewcone.SphereVisibleInCell(e.ParentCellId!.Value, c, r)
: viewcone.SphereVisibleOutside(c, r);
@ -1457,28 +1221,22 @@ public sealed class RetailPViewRenderer
// particles must not double-draw, unlike the depth-idempotent meshes).
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(
_dynamicParticleOwnerScratch,
in frameView,
RenderFrameCandidateRoute.DynamicLast,
0,
0);
RenderFrameRouteOwnerSelector.ExceptRoute(
_dynamicParticleOwnerScratch,
in frameView,
RenderFrameCandidateRoute.LandscapeOutsideDynamic);
}
else
{
_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)
if (InteriorEntityPartition.IsIndoorCellId(e.ParentCellId))
if (!_outsideStageDynamics.Contains(e))
_dynamicParticleOwnerScratch.Add(e.Id);
}
if (_dynamicParticleOwnerScratch.Count > 0)
@ -1870,29 +1628,10 @@ public interface IRetailPViewPassExecutor
RetailPViewFrameInput frame,
RetailPViewCellSliceContext context,
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(
RetailPViewFrameInput frame,
bool outdoorCells);
ClipViewSlice slice);
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 DrawDynamicsParticles(RetailPViewFrameInput frame, IReadOnlySet<uint> ownerIds);
void EmitDiagnostics(RetailPViewFrameInput frame, RetailPViewFrameResult result);
@ -2169,23 +1908,6 @@ public sealed class RetailPViewFrameResult
public PortalVisibilityFrame PortalFrame { get; private set; } = null!;
public ClipFrameAssembly ClipAssembly { 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 RenderProjectionCounts SourceCounts { get; private set; }
internal InteriorEntityPartition.Result? DiagnosticPartition
@ -2195,7 +1917,6 @@ public sealed class RetailPViewFrameResult
PortalVisibilityFrame portalFrame,
ClipFrameAssembly clipAssembly,
HashSet<uint> drawableCells,
HashSet<uint> inViewCells,
RenderFrameDiagnosticCounts diagnosticCounts,
RenderProjectionCounts sourceCounts,
InteriorEntityPartition.Result? diagnosticPartition)
@ -2203,7 +1924,6 @@ public sealed class RetailPViewFrameResult
PortalFrame = portalFrame;
ClipAssembly = clipAssembly;
DrawableCells = drawableCells;
InViewCells = inViewCells;
DiagnosticCounts = diagnosticCounts;
SourceCounts = sourceCounts;
DiagnosticPartition = diagnosticPartition;
@ -2219,7 +1939,6 @@ public sealed class RetailPViewFrameResult
portalFrame,
clipAssembly,
drawableCells,
drawableCells,
RetailPViewRenderer.LegacyDiagnosticCounts(
diagnosticPartition),
RetailPViewRenderer.LegacySourceCounts(
@ -2235,13 +1954,12 @@ public readonly record struct RetailPViewLandscapeSliceContext(
}
/// <summary>
/// Scene-particle owners for ONE unclipped landscape-stage submission (the
/// union of every outside slice's cone survivors). Mesh alpha for the same
/// 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.
/// Outdoor-static emitters submitted at retail's pre-building alpha barrier.
/// Mesh alpha for the same owners is already queued by the early landscape
/// entity route.
/// </summary>
public readonly record struct RetailPViewLandscapeStaticParticleContext(
ClipViewSlice Slice,
IReadOnlySet<uint> ParticleOwnerIds);
/// <summary>Retail DrawBuilding's ordinary exterior-shell pass, issued after
@ -2258,7 +1976,8 @@ public readonly record struct RetailPViewLandscapeBuildingShellSliceContext(
/// submitted at a pre-building barrier.</summary>
public readonly record struct RetailPViewLandscapeLateSliceContext(
ClipViewSlice Slice,
IReadOnlyList<WorldEntity> Dynamics)
IReadOnlyList<WorldEntity> Dynamics,
IReadOnlySet<uint> ParticleOwnerIds)
{
internal RenderFrameEntityDrawRequest? EntityDraw { get; init; }
}

View file

@ -1128,12 +1128,6 @@ internal sealed class RenderScenePViewFrameBuilder
private RenderProjectionRecord[] _cell = [];
private RenderProjectionRecord[] _dirty = [];
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 readonly Dictionary<RenderProjectionId, int>
_outdoorPositions = [];
@ -1534,16 +1528,6 @@ internal sealed class RenderScenePViewFrameBuilder
RenderFrameWriter writer,
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;
EnsureCapacity(ref _survivors, _dynamicCount);
for (int i = 0; i < _dynamicCount; i++)
@ -1555,14 +1539,6 @@ internal sealed class RenderScenePViewFrameBuilder
if (!input.RootIsOutdoor && !indoor)
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);
bool visible = indoor
? input.Viewcone.SphereVisibleInCell(

View file

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

View file

@ -43,9 +43,6 @@ namespace AcDream.App.Rendering;
/// </summary>
internal sealed class SkyPesFrameController
{
private static readonly Matrix4x4[] IdentityPartPose =
[Matrix4x4.Identity];
private readonly record struct SkyPesKey(
int ObjectIndex,
uint GfxObjId,
@ -125,23 +122,12 @@ internal sealed class SkyPesFrameController
? ParticleRenderPass.SkyPostScene
: ParticleRenderPass.SkyPreScene;
_particles.SetEntityRenderPass(ownerId, renderPass);
// The sky cell follows the viewer. Keep the script dispatch
// anchor on that same current-frame pose: SoundTweaked hooks in
// the Rainy carriers are ordinary world sounds, and a stale
// creation-time anchor falls beyond retail's audible radius as
// soon as login/teleport/movement displaces the camera.
_scripts.SetOwnerAnchor(ownerId, cameraWorldPosition);
Quaternion rotation = Rotation(skyObject, dayFraction);
_poses.Publish(
ownerId,
Matrix4x4.CreateFromQuaternion(rotation)
* Matrix4x4.CreateTranslation(cameraWorldPosition),
// 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,
Array.Empty<Matrix4x4>(),
cellId: 0u);
if (_active.Contains(key) || _missing.Contains(key))

View file

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

View file

@ -924,24 +924,6 @@ public sealed unsafe partial class WbDrawDispatcher
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(
IGpuFrame frame,
ReadOnlySpan<float> matrixFloats,
@ -957,9 +939,7 @@ public sealed unsafe partial class WbDrawDispatcher
ObserveOrdinaryTransformDemand(
frame.Serial,
checked((uint)(matrixFloats.Length / 16)));
bool privatePass = NextClassicDrawIsPrivatePass;
NextClassicDrawIsPrivatePass = false;
if (privatePass || !_worldTransformFrames.IsActive)
if (!_worldTransformFrames.IsActive)
{
firstInstance = 0;
return WriteRingSection(frame, matrixFloats);

View file

@ -462,7 +462,6 @@ internal sealed class RuntimeWorldFrameEnvironmentPreparation
private readonly SceneLightingUboBinding? _lightingUbo;
private readonly IWorldRenderRangeSource _ranges;
private readonly SkyPesFrameController? _skyPes;
private readonly Func<bool> _persistentDaylight;
private readonly HashSet<uint> _visibleCells = [];
private bool _visibleCellsValid;
@ -474,8 +473,7 @@ internal sealed class RuntimeWorldFrameEnvironmentPreparation
EnvCellRenderer? environmentCells,
SceneLightingUboBinding? lightingUbo,
IWorldRenderRangeSource ranges,
SkyPesFrameController? skyPes,
Func<bool>? persistentDaylight = null)
SkyPesFrameController? skyPes)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
_worldTime = worldTime ?? throw new ArgumentNullException(nameof(worldTime));
@ -485,7 +483,6 @@ internal sealed class RuntimeWorldFrameEnvironmentPreparation
_lightingUbo = lightingUbo;
_ranges = ranges ?? throw new ArgumentNullException(nameof(ranges));
_skyPes = skyPes;
_persistentDaylight = persistentDaylight ?? (static () => false);
}
public void Prepare(
@ -503,14 +500,7 @@ internal sealed class RuntimeWorldFrameEnvironmentPreparation
activeDayGroup,
camera.Position);
// 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);
UpdateSunFromSky(foundation.Sky, roots.PlayerInsideCell);
_lighting.UpdateViewerLight(roots.PlayerViewPosition);
_lighting.Tick(camera.Position);
_lighting.BuildPointLightSnapshot(

View file

@ -254,14 +254,8 @@ internal sealed class WorldSceneRenderer : IPreparedWorldSceneFramePhase
camera.Camera.View,
_diagnostics.CameraCellResolution));
// Effect visibility consumes InViewCells (main flood look-in
// 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);
_particleVisibility.MarkVisibleCells(pviewResult.DrawableCells);
_frames.ObserveDrawableCells(pviewResult.DrawableCells);
_diagnostics.EmitPViewInput(
pviewResult.PortalFrame,
camera.ViewProjection,

View file

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

View file

@ -161,18 +161,11 @@ internal sealed class RuntimeSettingsController :
Func<uint, bool>? characterOptionValue = null)
{
_storage = storage ?? throw new ArgumentNullException(nameof(storage));
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));
_resolveQuality = resolveQuality ?? ResolveQuality;
_log = log ?? Console.WriteLine;
_characterOptionValue = characterOptionValue;
Display = _storage.LoadDisplay();
Audio = _storage.LoadAudio();
Chat = _storage.LoadChat();
_defaultCharacter = _storage.LoadCharacter(DefaultToonKey);
@ -620,35 +613,6 @@ internal sealed class RuntimeSettingsController :
/// <inheritdoc cref="ServerOptionsSeeded"/>
public void NotifyServerOptionsSeeded() => ServerOptionsSeeded?.Invoke();
private static QualitySettings ResolveQuality(
QualityPreset preset,
int landscapeDrawDistance)
{
QualitySettings quality = ApplyLandscapeDrawDistance(
QualitySettings.From(preset),
landscapeDrawDistance);
return QualitySettings.WithEnvOverrides(quality);
}
internal static QualitySettings ApplyLandscapeDrawDistance(
QualitySettings quality,
int landscapeDrawDistance)
{
// Retail's enum contains 3,5,8,11,15,25 and @render accepts every
// integer in [5,25]. Values outside the union's structural [3,25]
// range can only come from an old/corrupt settings file, so preserve
// the preset rather than constructing an invalid streaming window.
if (landscapeDrawDistance is >= 3 and <= 25)
{
quality = quality with
{
NearRadius = Math.Min(
quality.NearRadius,
landscapeDrawDistance),
FarRadius = landscapeDrawDistance,
};
}
return quality;
}
private static QualitySettings ResolveQuality(QualityPreset preset) =>
QualitySettings.WithEnvOverrides(QualitySettings.From(preset));
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -321,8 +321,7 @@ public sealed record SocialRuntimeBindings(
/// <summary>
/// Batch C (overnight hover/UI round, 2026-08-17): bindings for the
/// two-tab Map/House panel. <see cref="HousePosition"/> defaults to
/// "no house" and <see cref="HouseLines"/>/<see cref="HousePanelLines"/>
/// to empty when the caller doesn't
/// "no house" and <see cref="HouseLines"/> to empty when the caller doesn't
/// wire the House wire groundwork — the panel still mounts and the Map tab
/// still works standalone.
/// </summary>
@ -331,8 +330,7 @@ public sealed record MapHouseRuntimeBindings(
Func<uint> PlayerCellId,
Func<CreateObject.ServerPosition?>? HousePosition = null,
Func<IReadOnlyList<string>>? HouseLines = null,
Action? HouseShown = null,
Func<IReadOnlyList<AcDream.Runtime.Gameplay.HousePanelLine>>? HousePanelLines = null);
Action? HouseShown = null);
/// <summary>
/// Campaign QT slice QT5: what the Journal panel's Contracts page reads —
@ -561,7 +559,6 @@ public sealed class RetailUiRuntime : IDisposable
private ProjectileDebugOverlayController? _projectileDebugOverlay;
private Layout.VitalsSideBySideController? _vitalsSideBySide;
private CharacterManagementUiMountCoordinator? _characterManagementMount;
private CreditsUiController? _creditsController;
private CharacterCreationUiMountCoordinator? _characterCreationMount;
private PluginSidePanel? _pluginSidePanel;
private IDisposable? _characterSheetSubscription;
@ -783,7 +780,6 @@ public sealed class RetailUiRuntime : IDisposable
public MapHousePanelController? MapHousePanelController { get; private set; }
internal CharacterManagementUiController? CharacterManagementController =>
_characterManagementMount?.Controller;
internal CreditsUiController? CreditsController => _creditsController;
internal CharacterCreationUiController? CharacterCreationController =>
_characterCreationMount?.Controller;
@ -968,7 +964,6 @@ public sealed class RetailUiRuntime : IDisposable
_itemCooldownController?.Tick();
_characterManagementMount?.Tick();
CharacterManagementController?.Tick();
_creditsController?.Tick();
_characterCreationMount?.Tick();
CharacterCreationController?.Tick();
DialogFactory?.Tick();
@ -1364,7 +1359,6 @@ public sealed class RetailUiRuntime : IDisposable
{
try
{
_creditsController?.ResetSession();
CharacterManagementController?.ResetSession();
DialogFactory?.Reset();
TooltipPresenter?.HideCurrent();
@ -4179,8 +4173,7 @@ public sealed class RetailUiRuntime : IDisposable
// Same generic template resolver the Map tab's town
// hotspots use — see HousePageController.Bindings.
// TemplateResolver's own doc for why reusing it is correct.
TemplateResolver: ResolveHotspotTemplate,
PanelLines: mh.HousePanelLines));
TemplateResolver: ResolveHotspotTemplate));
Layout.MapHousePanelController? controller;
lock (_bindings.Assets.DatLock)
@ -5106,194 +5099,7 @@ public sealed class RetailUiRuntime : IDisposable
Host.Root,
bindings with { RequestCreate = () => CharacterCreationController?.Open() },
EnsureDialogFactory,
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;
LoadCharacterManagementResources);
}
private RetailDialogFactory? EnsureDialogFactory()
@ -5586,7 +5392,6 @@ public sealed class RetailUiRuntime : IDisposable
() => _itemConfirmationController?.Dispose(),
() =>
{
_creditsController?.Dispose();
_characterManagementMount?.Dispose();
_characterCreationMount?.Dispose();
_gameplayConfirmationController?.Dispose();

View file

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

View file

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

View file

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

View file

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

View file

@ -2,7 +2,6 @@ using AcDream.App.UI;
using AcDream.Core.Items;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Selection;
namespace AcDream.App.World;
@ -27,7 +26,6 @@ internal sealed class InventoryWorldDropProjectionController : IDisposable
private readonly ClientObjectTable _objects;
private readonly LiveEntityRuntime _runtime;
private readonly LiveEntityHydrationController _hydration;
private readonly SelectionState _selection;
private readonly PendingSplitToWorldProjection _pending;
private readonly Func<double> _now;
private bool _disposed;
@ -37,7 +35,6 @@ internal sealed class InventoryWorldDropProjectionController : IDisposable
ClientObjectTable objects,
LiveEntityRuntime runtime,
LiveEntityHydrationController hydration,
SelectionState selection,
Func<double> now)
{
_interaction = interaction
@ -46,7 +43,6 @@ internal sealed class InventoryWorldDropProjectionController : IDisposable
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
_hydration = hydration
?? throw new ArgumentNullException(nameof(hydration));
_selection = selection ?? throw new ArgumentNullException(nameof(selection));
_now = now ?? throw new ArgumentNullException(nameof(now));
_pending = new PendingSplitToWorldProjection();
@ -68,15 +64,7 @@ internal sealed class InventoryWorldDropProjectionController : IDisposable
// Consume before the synchronous CreateObject graph runs. Re-entrant
// callbacks cannot bind the same split intent to a second GUID.
_hydration.OnCreate(spawn);
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;
return _runtime.TryGetSnapshot(update.Guid, out _);
}
public void Dispose()

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -271,10 +271,8 @@ public sealed class LightManager
/// 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
/// visible cells (the render already computes this — callers pass last frame's
/// <c>RetailPViewFrameResult.InViewCells</c>, the main flood PLUS building
/// 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
/// <c>RetailPViewFrameResult.DrawableCells</c>, one frame of latency, to avoid
/// re-threading a mid-render callback) removes those from contention before the
/// 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
/// correction: CAMERA anchoring, not cell scoping itself, caused the earlier

View file

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

View file

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

View file

@ -526,8 +526,7 @@ public sealed class ParticleSystem : IParticleSystem
IReadOnlySet<uint> attachedOwnerIds,
bool includeUnattached,
List<ParticleEmitter> destination,
IReadOnlySet<uint>? excludedAttachedOwnerIds = null,
UnattachedEmitterCellScope unattachedCellScope = UnattachedEmitterCellScope.Any)
IReadOnlySet<uint>? excludedAttachedOwnerIds = null)
{
ArgumentNullException.ThrowIfNull(attachedOwnerIds);
ArgumentNullException.ThrowIfNull(destination);
@ -540,11 +539,8 @@ public sealed class ParticleSystem : IParticleSystem
foreach (int handle in _renderableUnattachedHandlesByPass[passIndex])
{
LastRenderScopeEmitterVisitCount++;
if (_byHandle.TryGetValue(handle, out ParticleEmitter? emitter)
&& MatchesUnattachedCellScope(emitter, unattachedCellScope))
{
if (_byHandle.TryGetValue(handle, out ParticleEmitter? emitter))
destination.Add(emitter);
}
}
}
@ -570,29 +566,6 @@ public sealed class ParticleSystem : IParticleSystem
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>
{
private readonly ParticleSystem _owner;

View file

@ -46,20 +46,6 @@ public enum ParticleRenderPass
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>
/// Authority used by retail's particle presentation gate. World-owned
/// emitters follow <c>CPhysicsObj::ShouldDrawParticles</c>; examination and

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,15 +1,14 @@
using System.Globalization;
using AcDream.Core.Items;
using AcDream.Core.Net.Messages;
using AcDream.Core.Ui;
using AcDream.Core.Properties;
namespace AcDream.Runtime.Gameplay;
/// <summary>
/// Canonical presentation-independent owner for the House tab of retail's
/// two-tab Map/House panel (<c>gmHouseUI</c>). Deliberately lightweight
/// a full
/// two-tab Map/House panel (<c>gmHouseUI</c>). Deliberately MINIMAL
/// "houseless-status only" per ISSUES #413's own sizing note: a full
/// <c>RuntimeTradeState</c>-weight owner (construction-transaction
/// <c>Fault()</c> injection point, disposal ordering, convergence tracking)
/// is disproportionate for what this slice needs, since (unlike Trade) this
@ -29,8 +28,8 @@ namespace AcDream.Runtime.Gameplay;
/// (<c>DisplayRentPayment</c>, <c>DisplayBuyTime</c>,
/// <c>DisplayRentTimes</c>, <c>DisplayLocation</c>,
/// <c>DisplayWarningText</c>) open with <c>if (this-&gt;m_pHouseData != 0)</c>
/// and emit NOTHING when houseless. The owned-house path retains the exact
/// 0x0225 snapshot and ports all seven builders in retail's fixed order.
/// and emit NOTHING when houseless — those remain unported, ISSUES #413
/// item 3.
/// </para>
/// <para>
/// <b>CORRECTED at the 2026-08-17 morning gate round (user finding 2: the
@ -64,9 +63,7 @@ namespace AcDream.Runtime.Gameplay;
/// <c>m_pHouseData == 0</c> (still houseless) and emits the literal at
/// <c>data_7ab7f0</c>: <b>"You may buy another house immediately."</b>
/// (owns-a-house sibling at <c>data_7ab818</c>, byte-re-verified this
/// round). 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
/// round). So a queried houseless character's House tab shows exactly TWO
/// lines, in builder order: "You do not currently own a house." then the
/// purchase-time line — which this class now renders.
/// </para>
@ -104,9 +101,7 @@ public sealed class RuntimeHouseState
private readonly object _gate = new();
private bool _hasReceivedNotice;
private bool _ownsHouse;
private GameEvents.HouseData? _houseData;
private IReadOnlyList<string> _lines = Array.Empty<string>();
private IReadOnlyList<HousePanelLine> _panelLines = Array.Empty<HousePanelLine>();
/// <summary>Borrows the canonical object table (optional for bare
/// fixtures) to read the local player's own
@ -128,28 +123,6 @@ public sealed class RuntimeHouseState
get { lock (_gate) return _lines; }
}
/// <summary>The same rows with retail's <c>HousePanelTextColor</c>
/// index preserved for the authored row template.</summary>
public IReadOnlyList<HousePanelLine> PanelLines
{
get { lock (_gate) return _panelLines; }
}
/// <summary>The owned-house position used by retail's Map-page house
/// marker. Apartments intentionally expose no landscape position.</summary>
public CreateObject.ServerPosition? Position
{
get
{
lock (_gate)
{
return _houseData is { Type: not 4u, Position: { LandblockId: not 0u } } data
? data.Position
: null;
}
}
}
/// <summary>Whether any of the four House notices (0x0225-0x0228) has
/// arrived this session.</summary>
public bool HasReceivedNotice
@ -158,48 +131,15 @@ public sealed class RuntimeHouseState
}
/// <summary>0x0225 HouseData — <c>RecvNotice_UpdateHouseData</c>
/// (owned-house case). The snapshot is retained defensively because its
/// payment lists are later replaced by 0x0227/0x0228 notices.</summary>
/// (owned-house case). Only <see cref="_ownsHouse"/> is consumed today;
/// the owned-house payload itself (buy/rent payments, times, location)
/// feeds ISSUES #413's remaining six builders, not yet ported.</summary>
public void ApplyHouseData(GameEvents.HouseData data, uint selfGuid)
{
lock (_gate)
{
_hasReceivedNotice = true;
_ownsHouse = true;
_houseData = Copy(data);
Recompute(selfGuid);
}
}
/// <summary>0x0227 UpdateRentTime. Retail installs the new period start,
/// clears every paid count, then rebuilds the complete panel.</summary>
public void ApplyRentTime(uint rentTime, uint selfGuid)
{
lock (_gate)
{
if (_houseData is not { } data)
return;
GameEvents.HousePayment[] rent = data.Rent
.Select(static payment => payment with { Paid = 0 })
.ToArray();
_houseData = data with { RentTime = rentTime, Rent = rent };
Recompute(selfGuid);
}
}
/// <summary>0x0228 UpdateRentPayment. Retail replaces the complete rent
/// list, then rebuilds the complete panel.</summary>
public void ApplyRentPayment(
IReadOnlyList<GameEvents.HousePayment> rent, uint selfGuid)
{
ArgumentNullException.ThrowIfNull(rent);
lock (_gate)
{
if (_houseData is not { } data)
return;
_houseData = data with { Rent = rent.ToArray() };
Recompute(selfGuid);
}
}
@ -218,7 +158,6 @@ public sealed class RuntimeHouseState
{
_hasReceivedNotice = true;
_ownsHouse = false;
_houseData = null;
Recompute(selfGuid);
}
}
@ -232,67 +171,29 @@ public sealed class RuntimeHouseState
{
_hasReceivedNotice = false;
_ownsHouse = false;
_houseData = null;
_lines = Array.Empty<string>();
_panelLines = Array.Empty<HousePanelLine>();
}
}
/// <summary>Port of <c>gmHouseUI::DisplayHouseData @0x004a3380</c>'s
/// fixed seven-builder order. <c>DisplayRentTimes</c> emits two rows, so
/// an owned outdoor house produces eight rows total. Must hold
/// <see cref="_gate"/>.</summary>
/// <summary>The ported share of <c>gmHouseUI::Update</c>'s fixed
/// seven-builder order: <c>DisplayBuyPayment @0x004a2b30</c>'s
/// houseless branch (first) and <c>DisplayPurchaseTimeText
/// @0x004a3110</c> (last), both branches. The five houseless-silent
/// builders between them, and DisplayBuyPayment's owned branch, are
/// ISSUES #413 item 3. Must hold <see cref="_gate"/>.</summary>
private void Recompute(uint selfGuid)
{
var lines = new List<HousePanelLine>(_ownsHouse ? 8 : 2);
var lines = new List<string>(2);
// gmHouseUI::DisplayBuyPayment @0x004a2b30 — NOT houseless-silent
// (2026-08-17 morning gate correction; see the class doc): the
// m_pHouseData gate only selects WHICH text, and the emit runs in
// both branches. The owned prefix and every remaining literal were
// recovered from the PDB-paired binary for issue #413.
if (_houseData is { } data)
{
lines.Add(Normal(
"The purchase price for this dwelling is:\n"
+ ComposePayments(data.Buy, includePaid: false)));
lines.Add(Normal(
"Rent:\n" + ComposePayments(data.Rent, includePaid: true)));
lines.Add(Normal("Bought: " + ConvertTime(data.BuyTime)));
long period = GetRentPeriodSeconds(data.Type);
bool paid = data.MaintenanceFree || IsPaidInFull(data.Rent);
lines.Add(Normal(
"This maintenance period ends: "
+ ConvertTime((long)data.RentTime + period)));
lines.Add(Normal(
"Maintenance is next due: "
+ ConvertTime((long)data.RentTime + (paid ? 2L : 1L) * period)));
if (data.Type != 4u
&& RadarCoordinates.TryFromCell(
data.Position.LandblockId, out RadarCoordinates coordinates))
{
lines.Add(Normal(
$"Location: {coordinates.YText}, {coordinates.XText}"));
}
lines.Add(paid
? new HousePanelLine(
"The maintenance has already been paid for this period. "
+ "You may not prepay next period's maintenance.",
HousePanelTextColor.RentPaid)
: new HousePanelLine(
"Warning! You have not paid your maintenance costs for the last "
+ (period / 86_400L).ToString(CultureInfo.InvariantCulture)
+ " day maintenance period. Please pay these costs by this deadline"
+ " or you will lose your house, and all your items within it.",
HousePanelTextColor.RentNotPaid));
}
else
{
lines.Add(Normal("You do not currently own a house."));
}
// both branches. Houseless (@0x004a2b57, byte-decoded data_7ab688):
// this exact literal. Owned (@0x004a2b63, data_7ab65c "The purchase
// price for this dwelling is:\n" + HousePaymentList::ComposeText):
// unported, #413 item 3 — the owned case adds nothing here yet.
if (!_ownsHouse)
lines.Add("You do not currently own a house.");
// gmHouseUI::DisplayPurchaseTimeText @0x004a3110, both branches.
int timestamp = _objects?.Get(selfGuid)?.Properties
@ -320,95 +221,18 @@ public sealed class RuntimeHouseState
timestamp + PurchaseWaitPeriodSeconds);
DateTime expiryLocal = TimeZoneInfo.ConvertTime(
expiryUtc, _timeProvider.LocalTimeZone).DateTime;
lines.Add(Normal(
lines.Add(
"You may buy another landscape house at "
+ expiryLocal.ToString(CultureInfo.CurrentCulture)
+ ". This restriction does not apply to apartments."));
+ ". This restriction does not apply to apartments.");
}
else
{
lines.Add(Normal(_ownsHouse
lines.Add(_ownsHouse
? "You may buy another house immediately after you abandon this one."
: "You may buy another house immediately."));
: "You may buy another house immediately.");
}
_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);
_lines = lines;
}
}
/// <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,16 +131,6 @@ internal readonly record struct RuntimeAcceptedPositionRouteRequest(
bool HasAnimations,
RuntimePositionPlacementFacts PlacementFacts);
/// <summary>
/// Retail's two writes before <c>MoveOrTeleport</c>: the force-position
/// self-echo returns before both; every ordinary accepted Position unparents,
/// and only an object without animations receives the wire placement frame.
/// These facts depend solely on timestamp disposition and HasAnims.
/// </summary>
internal readonly record struct RuntimeAcceptedPositionPrePlacementFlags(
bool UnparentBeforeRouting,
bool ApplyPlacementFrameBeforeRouting);
/// <summary>
/// Immutable action plan for retail HandleReceivedPosition/MoveOrTeleport.
/// It deliberately contains no renderer, world entity, UI, or host callback.
@ -199,20 +189,6 @@ internal static class RuntimeAuthoritativePositionRouteClassifier
internal static bool IsValidCreateWirePosition(
in CreateObject.ServerPosition position) => ValidPosition(position);
internal static RuntimeAcceptedPositionPrePlacementFlags DerivePrePlacementFlags(
PositionTimestampDisposition disposition,
bool hasAnimations) => disposition switch
{
PositionTimestampDisposition.Apply => new(
UnparentBeforeRouting: true,
ApplyPlacementFrameBeforeRouting: !hasAnimations),
PositionTimestampDisposition.ForcePosition => default,
// Rejected packets use the timestamp-only merge and never inspect
// either flag. Returning default keeps that branch explicit.
PositionTimestampDisposition.Rejected => default,
_ => throw new ArgumentOutOfRangeException(nameof(disposition), disposition, null),
};
internal static RuntimeAuthoritativePositionRoute ClassifyCreate(
in RuntimeCreatePositionRouteRequest request)
{
@ -332,10 +308,6 @@ internal static class RuntimeAuthoritativePositionRouteClassifier
if (!ValidPosition(request.AcceptedWirePosition))
return RejectedData(request.Authority, operation, reporting);
RuntimeAcceptedPositionPrePlacementFlags prePlacement =
DerivePrePlacementFlags(
request.Authority.TimestampDisposition,
request.HasAnimations);
bool force = request.Authority.TimestampDisposition
is PositionTimestampDisposition.ForcePosition;
if (force)
@ -348,8 +320,8 @@ internal static class RuntimeAuthoritativePositionRouteClassifier
operation,
AuthoritativeTeleportFlags,
request.PlacementFrame ?? 0u,
prePlacement.UnparentBeforeRouting,
prePlacement.ApplyPlacementFrameBeforeRouting,
UnparentBeforeRouting: false,
ApplyPlacementFrameBeforeRouting: false,
LeaveWorld: false,
TeleportHookPhase: RuntimeTeleportHookPhase.None,
StopInterpolating: false,
@ -371,8 +343,8 @@ internal static class RuntimeAuthoritativePositionRouteClassifier
operation,
AuthoritativeTeleportFlags,
placement,
prePlacement.UnparentBeforeRouting,
prePlacement.ApplyPlacementFrameBeforeRouting,
UnparentBeforeRouting: true,
ApplyPlacementFrameBeforeRouting: !request.HasAnimations,
LeaveWorld: false,
TeleportHookPhase: RuntimeTeleportHookPhase.AfterPositionOperation,
StopInterpolating: false,
@ -393,8 +365,8 @@ internal static class RuntimeAuthoritativePositionRouteClassifier
operation,
PhysicsSetPositionFlags.None,
placement,
prePlacement.UnparentBeforeRouting,
prePlacement.ApplyPlacementFrameBeforeRouting,
UnparentBeforeRouting: true,
ApplyPlacementFrameBeforeRouting: !request.HasAnimations,
LeaveWorld: false,
TeleportHookPhase: RuntimeTeleportHookPhase.None,
StopInterpolating: false,
@ -424,8 +396,8 @@ internal static class RuntimeAuthoritativePositionRouteClassifier
operation,
AuthoritativeTeleportFlags,
placement,
prePlacement.UnparentBeforeRouting,
prePlacement.ApplyPlacementFrameBeforeRouting,
UnparentBeforeRouting: true,
ApplyPlacementFrameBeforeRouting: !request.HasAnimations,
LeaveWorld: false,
TeleportHookPhase: RuntimeTeleportHookPhase.BeforePositionOperation,
StopInterpolating: false,
@ -450,8 +422,8 @@ internal static class RuntimeAuthoritativePositionRouteClassifier
operation,
PhysicsSetPositionFlags.None,
placement,
prePlacement.UnparentBeforeRouting,
prePlacement.ApplyPlacementFrameBeforeRouting,
UnparentBeforeRouting: true,
ApplyPlacementFrameBeforeRouting: !request.HasAnimations,
LeaveWorld: false,
TeleportHookPhase: RuntimeTeleportHookPhase.None,
StopInterpolating: false,
@ -480,8 +452,8 @@ internal static class RuntimeAuthoritativePositionRouteClassifier
operation,
nearby ? PhysicsSetPositionFlags.None : AuthoritativeTeleportFlags,
placement,
prePlacement.UnparentBeforeRouting,
prePlacement.ApplyPlacementFrameBeforeRouting,
UnparentBeforeRouting: true,
ApplyPlacementFrameBeforeRouting: !request.HasAnimations,
LeaveWorld: false,
TeleportHookPhase: RuntimeTeleportHookPhase.None,
StopInterpolating: !nearby,
@ -508,7 +480,9 @@ internal static class RuntimeAuthoritativePositionRouteClassifier
{
PositionTimestampDisposition.Apply => true,
PositionTimestampDisposition.ForcePosition =>
kind is RuntimePositionEntityKind.LocalPlayer,
kind is RuntimePositionEntityKind.LocalPlayer
&& authority.PreviousTeleportSequence
== authority.AcceptedTeleportSequence,
_ => false,
};
}

View file

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

View file

@ -95,11 +95,7 @@ public sealed record LiveSocialSessionBindings(
RuntimeHouseState? House = null,
// Campaign QT (2026-08-21): the fourth sibling J-owner, same
// trailing/optional compatibility convention.
RuntimeContractState? Contracts = null,
// 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);
RuntimeContractState? Contracts = null);
/// <summary>
/// Owns every inbound subscription for one exact live session. Domain state
@ -342,12 +338,6 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting
onHouseStatus: social.House is { } houseStatus
? weenieError => houseStatus.ApplyHouseStatus(weenieError, inventory.PlayerGuid())
: null,
onHouseUpdateRentTime: social.House is { } houseRentTime
? rentTime => houseRentTime.ApplyRentTime(rentTime, inventory.PlayerGuid())
: null,
onHouseUpdateRentPayment: social.House is { } houseRentPayment
? rent => houseRentPayment.ApplyRentPayment(rent, inventory.PlayerGuid())
: null,
// Campaign QT (2026-08-21): same conditional delegate-hole
// discipline as house above.
onContractTable: social.Contracts is { } contractTable
@ -461,8 +451,7 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting
killed => social.Chat.OnPlayerKilled(
killed.DeathMessage,
killed.VictimGuid,
killed.KillerGuid,
social.PlayerGuid?.Invoke() ?? 0u));
killed.KillerGuid));
Subscribe<TurbineChat.Parsed>(
h => session.TurbineChatReceived += h,
h => session.TurbineChatReceived -= h,

View file

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

View file

@ -97,33 +97,6 @@ public sealed class RuntimeOptionsSessionConfigTests
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]
public void ProcessContentOverridesDatDirectoryAndPreparedAssetPath()
{

View file

@ -107,7 +107,7 @@ public sealed class LaunchOptionsDocumentationTests
}
/// <summary>
/// The five flags that default ON. All are product/retail behaviors wearing an
/// The four flags that default ON. All are retail behaviors wearing an
/// 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
/// and every measurement silently, so growing this set fails.
@ -119,7 +119,6 @@ public sealed class LaunchOptionsDocumentationTests
"ACDREAM_CAMERA_COLLIDE",
"ACDREAM_CAMERA_ALIGN_SLOPE",
"ACDREAM_RETAIL_CLOSE_DEGRADES",
"ACDREAM_RETAIL_UI",
};
/// <summary>
@ -134,7 +133,7 @@ public sealed class LaunchOptionsDocumentationTests
RegexOptions.Compiled);
[Fact]
public void OnlyTheFiveProductBehaviorFlagsDefaultOn()
public void OnlyTheFourRetailBehaviorFlagsDefaultOn()
{
var defaultOn = new HashSet<string>(StringComparer.Ordinal);
foreach ((string path, _) in SourceFiles())

View file

@ -101,39 +101,6 @@ public sealed class LiveSessionCommandRouterTests
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]
public void InactiveAndDisposedRouter_CannotReachTransport()
{
@ -864,7 +831,6 @@ public sealed class LiveSessionCommandRouterTests
ToggleFrameRate: () => { },
ToggleUiLock: () => { },
ShowSystemMessage: _ => { },
ShowClientLocalMessage: _ => { },
ShowWeenieError: _ => { },
PlayerPublicWeenieBitfield: () => null,
ClientVersion: () => "test",
@ -913,49 +879,5 @@ public sealed class LiveSessionCommandRouterTests
LeaveGmChannel: _ => { },
RecallAllegianceHometown: () => { },
RequestAllegianceInfo: _ => { },
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: _ => { });
AbandonHouse: () => { });
}

View file

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

View file

@ -219,7 +219,7 @@ public sealed class LiveEntityNetworkOnPositionCollapseMatrixTests
// ── Scenario 2: landing packet (the preserved rows 2a/2b asymmetry) ─
[Fact]
public void LandingPacket_PlayerGuid_QueueClearedAndShadowPublished()
public void LandingPacket_PlayerGuid_QueueClearedNoShadowPublish_316Preserved()
{
using var fixture = new Fixture(PlayerGuid);
EntityPhysicsHost host = fixture.InstallHost();
@ -253,13 +253,14 @@ public sealed class LiveEntityNetworkOnPositionCollapseMatrixTests
// Row 2a, PRESERVED for player guids: the interp queue IS cleared.
Assert.False(fixture.Remote.Interp.IsActive);
// #316: the same resolved landing pose must publish to collision in
// this packet, not wait for a later physics tick to self-heal.
// Row 2b / #316, PRESERVED (NOT fixed): the shadow is NOT
// republished for a player-guid landing — it stays at whatever it
// was before this packet.
ShadowEntry shadowEntry = Assert.Single(
fixture.Shadows.AllEntriesForDebug(),
entry => entry.EntityId == fixture.Entity.Id);
Assert.Equal(landingPos, shadowEntry.Position);
Assert.NotEqual(spawnShadowPos, shadowEntry.Position);
Assert.Equal(spawnShadowPos, shadowEntry.Position);
Assert.NotEqual(landingPos, shadowEntry.Position);
// AP-135's cell-adopt bookkeeping still ran (via the post-routing
// wire-cell adopt, not suppressed for AirborneSnap).
@ -573,26 +574,6 @@ public sealed class LiveEntityNetworkOnPositionCollapseMatrixTests
_ = 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) ──────────────────
//
// 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
{
private const uint Guid = 0x50000071u;
private const uint Guid = 0x70000071u;
private const uint SourceCell = 0xA9B40039u;
private const uint DestinationCell = 0xAAB40001u;
@ -37,8 +37,7 @@ public sealed class LiveEntityOrdinaryPhysicsUpdaterTests
Rotation = Quaternion.Identity,
MeshRefs = Array.Empty<MeshRef>(),
ParentCellId = SourceCell,
},
isLocalPlayer: true);
});
WorldEntity entity = Assert.IsType<WorldEntity>(record.WorldEntity);
var remote = new AcDream.Runtime.Physics.RemoteMotion
@ -85,17 +84,6 @@ public sealed class LiveEntityOrdinaryPhysicsUpdaterTests
Assert.True(entity.Position.X > 192f);
Assert.Equal(entity.Position, record.PhysicsBody.Position);
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]

View file

@ -109,36 +109,4 @@ public class InteriorEntityPartitionTests
Assert.Empty(result.Dynamics);
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,50 +381,6 @@ public sealed class LiveEntityAnimationPresenterTests
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(
LiveEntityRuntime live,
EntityEffectPoseRegistry poses,

View file

@ -8,14 +8,6 @@ namespace AcDream.App.Tests.Rendering;
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]
public void HiddenView_BuildsAndPrewarmsWithoutRendering()
{
@ -24,7 +16,7 @@ public sealed class PaperdollFramePresenterTests
var factory = new RecordingFactory { Doll = CreateDoll() };
var presenter = new PaperdollFramePresenter(renderer, view, factory);
Frame(presenter);
presenter.Render();
Assert.False(presenter.IsDirty);
Assert.Equal(1, factory.BuildCount);
@ -42,7 +34,7 @@ public sealed class PaperdollFramePresenterTests
var factory = new RecordingFactory { Doll = doll };
var presenter = new PaperdollFramePresenter(renderer, view, factory);
Frame(presenter);
presenter.Render();
Assert.False(presenter.IsDirty);
Assert.Equal(1, factory.BuildCount);
@ -62,25 +54,20 @@ public sealed class PaperdollFramePresenterTests
};
var presenter = new PaperdollFramePresenter(renderer, view, factory);
Frame(presenter);
Frame(presenter);
presenter.Render();
presenter.Render();
presenter.MarkDirty();
Frame(presenter);
presenter.Render();
Assert.Equal(2, factory.BuildCount);
Assert.Equal(2, renderer.Dolls.Count);
Assert.Single(renderer.Dolls);
Assert.Equal(3, renderer.RenderCount);
Assert.False(presenter.IsDirty);
}
[Fact]
public void PortalRefresh_RedressesEvenAnEquivalentAppearance()
public void EquivalentPortalRefresh_KeepsPrivateDollAndTextureOwner()
{
// #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 repeated = CreateDoll();
var renderer = new RecordingRenderer();
@ -88,13 +75,13 @@ public sealed class PaperdollFramePresenterTests
var factory = new RecordingFactory { Doll = first };
var presenter = new PaperdollFramePresenter(renderer, view, factory);
Frame(presenter);
presenter.Render();
factory.Doll = repeated;
presenter.MarkDirty();
Frame(presenter);
presenter.Render();
Assert.Equal(2, factory.BuildCount);
Assert.Equal([first, repeated], renderer.Dolls);
Assert.Equal([first], renderer.Dolls);
Assert.Equal(2, renderer.RenderCount);
Assert.False(presenter.IsDirty);
}
@ -109,34 +96,14 @@ public sealed class PaperdollFramePresenterTests
var factory = new RecordingFactory { Doll = first };
var presenter = new PaperdollFramePresenter(renderer, view, factory);
Frame(presenter);
presenter.Render();
factory.Doll = changed;
presenter.MarkDirty();
Frame(presenter);
presenter.Render();
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()
{
Id = 42u,
@ -156,11 +123,11 @@ public sealed class PaperdollFramePresenterTests
var factory = new RecordingFactory { Doll = firstDoll };
var presenter = new PaperdollFramePresenter(renderer, view, factory);
Frame(presenter);
presenter.Render();
factory.CanBuild = false;
presenter.MarkDirty();
Frame(presenter);
Frame(presenter);
presenter.Render();
presenter.Render();
Assert.True(presenter.IsDirty);
Assert.Equal(3, factory.BuildCount);
@ -179,17 +146,14 @@ public sealed class PaperdollFramePresenterTests
var factory = new RecordingFactory { Doll = firstDoll };
var presenter = new PaperdollFramePresenter(renderer, view, factory);
Frame(presenter);
presenter.Render();
presenter.ResetSession();
factory.Doll = secondDoll;
Frame(presenter);
presenter.Render();
Assert.False(presenter.IsDirty);
Assert.Equal(2, factory.BuildCount);
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]
@ -288,7 +252,7 @@ public sealed class PaperdollFramePresenterTests
private sealed class RecordingRenderer : IPaperdollDollRenderer
{
public uint TextureHandle { get; set; }
public uint TextureHandle { get; init; }
public List<WorldEntity?> Dolls { get; } = [];
public List<(int Width, int Height)> RenderSizes { get; } = [];
public int RenderCount => RenderSizes.Count;
@ -321,10 +285,6 @@ public sealed class PaperdollFramePresenterTests
public void SetTextureHandle(uint textureHandle) =>
TextureHandles.Add(textureHandle);
public int ClearCount { get; private set; }
public void ClearTextureHandle() => ClearCount++;
}
private sealed class RecordingFactory : IPaperdollDollFactory

View file

@ -1,65 +0,0 @@
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,80 +75,6 @@ public sealed class RetailAlphaQueueTests
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]
public void Flush_BatchesOnlyAdjacentEntriesFromSameRenderer()
{

View file

@ -34,20 +34,13 @@ public sealed class RetailPViewPassExecutorTests
"terrain-clip",
"clear-routing",
"landscape-late",
"unattached-particles-outdoor",
// #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.
"unattached-particles",
"landscape-alpha",
"indoor-routing",
"indoor-routing",
"landscape-alpha-farther",
"exit-mask",
"indoor-routing",
"opaque-shells",
"landscape-alpha",
"unattached-particles-interior",
],
executor.Operations);
}
@ -72,7 +65,7 @@ public sealed class RetailPViewPassExecutorTests
string.Join('|', executor.Operations),
"landscape-early",
"landscape-late",
"unattached-particles-outdoor",
"unattached-particles",
"landscape-alpha",
"interior-depth-clear",
"indoor-routing",
@ -100,32 +93,6 @@ public sealed class RetailPViewPassExecutorTests
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]
public void Particle_classifications_reset_before_an_empty_following_frame()
{
@ -310,9 +277,9 @@ public sealed class RetailPViewPassExecutorTests
AssertAppearsInOrder(
string.Join('|', executor.Operations),
"landscape-early",
"unattached-particles-outdoor",
"unattached-particles",
"landscape-static-particles",
"landscape-alpha-farther",
"landscape-alpha",
"look-in-punch",
"landscape-late",
"landscape-alpha",
@ -386,7 +353,7 @@ public sealed class RetailPViewPassExecutorTests
"look-in-punch",
"landscape-building-shell",
"landscape-static-particles",
"landscape-alpha-farther",
"landscape-alpha",
"look-in-punch",
"landscape-building-shell");
}
@ -796,13 +763,8 @@ public sealed class RetailPViewPassExecutorTests
int portalIndex) => Operations.Add("look-in-punch");
public void DrawUnattachedSceneParticles(
RetailPViewFrameInput frame,
bool outdoorCells) => Operations.Add(
outdoorCells
? "unattached-particles-outdoor"
: "unattached-particles-interior");
ClipViewSlice slice) => Operations.Add("unattached-particles");
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 DrawDynamicsParticles(
RetailPViewFrameInput frame,

View file

@ -1,39 +0,0 @@
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,7 +3,6 @@ using System.Collections.Generic;
using System.Numerics;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Vfx;
using AcDream.Core.Physics;
using AcDream.Core.Vfx;
using AcDream.Core.World;
using DatReaderWriter.Types;
@ -23,59 +22,20 @@ public sealed class SkyPesFrameControllerTests
{
private const uint AuroraSetup = 0x02000714u;
private const uint AuroraScript = 0x330007DBu;
private const uint LightningEmitter = 0x320002C2u;
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<string> Diagnostics = [];
public readonly List<(uint EntityId, Vector3 Position, AnimationHook Hook)> HookCalls;
public readonly PhysicsScriptRunner Runner;
public readonly SkyPesFrameController Controller;
public readonly ParticleSystem Particles;
public Harness(AnimationHook? hook = null, double hookTime = 0.0)
public Harness()
{
var registry = new EmitterDescRegistry();
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 system = new ParticleSystem(registry, new Random(42));
var poses = new EntityEffectPoseRegistry();
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);
var sink = new ParticleHookSink(system, poses);
Runner = new PhysicsScriptRunner(
id =>
{
@ -83,12 +43,12 @@ public sealed class SkyPesFrameControllerTests
var script = new DatPhysicsScript();
script.ScriptData.Add(new PhysicsScriptData
{
StartTime = hookTime,
Hook = hook ?? new SoundHook(),
StartTime = 0.0,
Hook = new SoundHook(),
});
return script;
},
router);
sink);
Controller = new SkyPesFrameController(
Runner,
sink,
@ -209,52 +169,4 @@ public sealed class SkyPesFrameControllerTests
h.Controller.Update(0.1f, null, Vector3.Zero);
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,25 +30,11 @@ using AcDream.App.Tests.Rendering.Gpu;
using AcDream.Content;
using Microsoft.Extensions.Logging.Abstractions;
using Xunit;
using CullMode = DatReaderWriter.Enums.CullMode;
namespace AcDream.App.Tests.Rendering.Wb;
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
{
public PreparedAssetSourceStats Stats => default;

View file

@ -300,56 +300,6 @@ public sealed class WorldRenderFrameBuilderTests
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]
public void Environment_preparation_keeps_lighting_snapshot_before_ubo_upload()
{

View file

@ -251,29 +251,6 @@ public sealed class WorldSceneRendererTests
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]
public void PViewWorld_ReusesOneSynchronousFrameInputAcrossFrames()
{
@ -559,8 +536,7 @@ public sealed class WorldSceneRendererTests
Frames = new FrameBuilder(Calls, frame);
Selection = new SelectionFrame(Calls);
var alpha = new AlphaFrame(Calls);
Visibility = new ParticleVisibility(Calls);
var visibility = Visibility;
var visibility = new ParticleVisibility(Calls);
PView = new PViewRenderer(Calls);
Passes = new PassExecutor(Calls);
var diagnostics = new Diagnostics(Calls);
@ -597,8 +573,6 @@ public sealed class WorldSceneRendererTests
public SelectionFrame Selection { get; }
public ParticleVisibility Visibility { get; }
public PViewRenderer PView { get; }
public PassExecutor Passes { get; }
@ -647,13 +621,8 @@ public sealed class WorldSceneRendererTests
return frame;
}
public IReadOnlySet<uint>? ObservedCells { get; private set; }
public void ObserveDrawableCells(IReadOnlySet<uint> drawableCells)
{
public void ObserveDrawableCells(IReadOnlySet<uint> drawableCells) =>
calls.Add("frame:observe-cells");
ObservedCells = new HashSet<uint>(drawableCells);
}
public void ClearDrawableCells() => calls.Add("frame:clear-cells");
}
@ -715,13 +684,8 @@ public sealed class WorldSceneRendererTests
private sealed class ParticleVisibility(List<string> calls) :
IWorldSceneParticleVisibility
{
public HashSet<uint> MarkedCells { get; } = [];
public void MarkVisibleCells(HashSet<uint> cellIds)
{
public void MarkVisibleCells(HashSet<uint> cellIds) =>
calls.Add("visibility:mark");
MarkedCells.UnionWith(cellIds);
}
public void CompleteFrame() => calls.Add("visibility:complete");
@ -737,17 +701,11 @@ public sealed class WorldSceneRendererTests
public PViewRenderer(List<string> 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(
new PortalVisibilityFrame(),
new ClipFrameAssembly(),
[0x01010001u],
[0x01010001u, 0x01010003u],
RetailPViewRenderer.LegacyDiagnosticCounts(interiorPartition),
RetailPViewRenderer.LegacySourceCounts(interiorPartition),
interiorPartition);
[],
new InteriorEntityPartition.Result());
var outdoorPortalFrame = new PortalVisibilityFrame();
outdoorPortalFrame.OutsideView.Add(new ViewPolygon(
[

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