fix: complete retail parity stability pass
All checks were successful
CI / linux-portable (push) Successful in 3m41s
CI / windows-gate (push) Successful in 6m49s
CI / release (push) Successful in 3m22s

This commit is contained in:
Erik 2026-08-28 20:01:39 +02:00
parent d3df4cb20a
commit f7aa8e0eb7
131 changed files with 7765 additions and 1190 deletions

View file

@ -141,7 +141,6 @@ $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
```
@ -208,7 +207,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=1` | Enable the retained retail gameplay UI |
| `ACDREAM_RETAIL_UI=0` | Disable the retained retail gameplay UI for diagnostics; it is enabled by default |
| `ACDREAM_DEVTOOLS=1` | Enable ImGui developer tools |
| `ACDREAM_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,11 +40,13 @@ Assume a flag has a side effect until its row says otherwise.
- **Everything diagnostic is OFF by default.** Every probe, dump, capture,
and measurement flag in this document is inert until its variable is
explicitly set — an unset environment runs zero diagnostics. Exactly
four flags default ON, and none is a diagnostic: `ACDREAM_RETAIL_CHASE`,
five flags default ON, and none is a diagnostic: `ACDREAM_RETAIL_CHASE`,
`ACDREAM_CAMERA_COLLIDE`, `ACDREAM_CAMERA_ALIGN_SLOPE`, and
`ACDREAM_RETAIL_CLOSE_DEGRADES` are retail *behaviors* wearing an A/B
off-switch (`=0` disables the behavior for a comparison run). That
four-flag set is frozen by `LaunchOptionsDocumentationTests` — a new
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
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
@ -75,14 +77,13 @@ $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 when `ACDREAM_RETAIL_UI=1` (retained UI composed). Unset → `ControlsIni.Parse(string.Empty)`, an empty (not error) controls table — silent, no fallback file is searched. | unset (null) → empty controls table | `RuntimeOptions.AcDir``InteractionRetainedUiComposition.cs:610` |
| `ACDREAM_AC_DIR` | `=<path>` | Points at a real retail AC install dir; loads `<dir>/controls/controls.ini` to source retail keybind display strings for the retained UI. | Only has any effect while the default-on retained UI is composed (`ACDREAM_RETAIL_UI` is not `0`). Unset → `ControlsIni.Parse(string.Empty)`, an empty (not error) controls table — silent, no fallback file is searched. | unset (null) → empty controls table | `RuntimeOptions.AcDir``InteractionRetainedUiComposition.cs:610` |
| `ACDREAM_ANISOTROPIC` | `=<int>` (`int.TryParse`, invariant) | Overrides preset's `AnisotropicLevel` texture filtering | Changes GPU texture sampling filter level (visual sharpness), not just perf | preset's `AnisotropicLevel` (Low=4, Medium=8, High/Ultra=16) | `QualitySettings.WithEnvOverrides` (`QualityPreset.cs:49`) |
| `ACDREAM_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` |
@ -115,7 +116,7 @@ dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Release
| `ACDREAM_RESIDENCY_STANDALONE_UNOWNED_MIB` | `=<int MiB>` (`>0`, else default) | Byte ceiling for unowned standalone texture memory | Same eviction-cadence caveat | 32 MiB | `ResidencyBudgetOptions.Parse` (`:73-75`) |
| `ACDREAM_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` | `=1` | Switches on the retained retail UI host tree (`UiHost`/`UiRoot`, D.2b). Without it, no retained UI is composed at all — e.g. no chargen Appearance page, no Summary page. | **Forced to `true` unconditionally** for every `--session-config` / launcher launch (`RuntimeOptions.cs:282`, "a session-config launch IS a product launch — the retail UI is the shipped UI, not a dev option"), regardless of this env var's value — the env var only matters for the bare env-var dev-flow launch path. | `false` for the env-var dev flow; `true` always for `--session-config` launches | `RuntimeOptions.RetailUi``LivePresentationComposition.cs:1108-1131` (gates retained-UI mount via `InteractionRetainedUiComposition`), `GameWindow.cs:455,566` (comments), `RuntimeOptions.cs:277` |
| `ACDREAM_RETAIL_UI` | `=0` disables; anything else/unset enables | Controls the retained retail UI host tree (`UiHost`/`UiRoot`, D.2b), the product's only gameplay UI. | Disabling it leaves world rendering with no gameplay interface and is intended only for diagnostics. The same rule applies to bare env and `--session-config`/launcher launches; no path force-overrides it. | `true` | `RuntimeOptions.RetailUi``LivePresentationComposition.cs` (gates retained-UI mount via `InteractionRetainedUiComposition`) |
| `ACDREAM_TEST_HOST` | `=<host>` | ACE server hostname for live-mode connect. | none | `"127.0.0.1"` | `RuntimeOptions.LiveHost` (`RuntimeOptions.cs:142`) |
| `ACDREAM_TEST_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`) |
@ -184,7 +185,7 @@ fall back to `ACDREAM_DAT_DIR`.
| `ACDREAM_ORBIT_YAW_DEGREES` | `=<float>`, must be finite | Diagnostic-only initial orbit camera heading. | Non-finite values silently rejected (→ `null`). | `null` | `RuntimeOptions.InitialOrbitYawDegrees``GameWindow.cs:1413` |
| `ACDREAM_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 wall-clock time, so two launches of a differential/offline gate agree about cloud position. | **Non-obvious dual effect**: this ONE var pins TWO independently-designed clocks that happen to share a name-adjacent purpose — the sky renderer's cloud scroll (`SkyRenderer.AnimationPhaseSecondsOverride`) AND, since Campaign VM slice VM6, the atmospheric post-process graph's foliage-wind clock (`_windClockSecondsOverride`). A gate that only knows about "sky clouds" and sets this to freeze them will *also* freeze foliage-wind evolution — deliberately snapped-to-target on the first advance per an A6 review fix, but still a second surface a naive reader wouldn't expect this var to touch. Distinct from `ACDREAM_DAY_GROUP`/`ACDREAM_WORLD_TIME`, which pin the OTHER sky clock (day group/sun angle) — retail's clouds drift independently of the calendar date by design. | `null`wall-clock driven (every ordinary run) | `RuntimeOptions.SkyAnimationPhaseSeconds``SkyRenderer.cs:79,85` (cloud UV scroll) **and** `AtmosphericPostProcessGraph.cs:560,586,671` (foliage-wind clock) |
| `ACDREAM_SKY_PHASE_SECONDS` | `=<float>` (any finite value; negative accepted, taken mod 1 per axis) | Campaign V slice V7 instrument-determinism pin: freezes the sky's cloud-sheet UV scroll to a fixed elapsed-seconds value instead of monotonic real elapsed time, so two launches of a differential/offline gate agree about cloud position. | **Non-obvious dual effect**: this ONE var pins TWO independently-designed clocks that happen to share a name-adjacent purpose — the sky renderer's cloud scroll (`SkyRenderer.AnimationPhaseSecondsOverride`) AND, since Campaign VM slice VM6, the atmospheric post-process graph's foliage-wind clock (`_windClockSecondsOverride`). A gate that only knows about "sky clouds" and sets this to freeze them will *also* freeze foliage-wind evolution — deliberately snapped-to-target on the first advance per an A6 review fix, but still a second surface a naive reader wouldn't expect this var to touch. Distinct from `ACDREAM_DAY_GROUP`/`ACDREAM_WORLD_TIME`, which pin the OTHER sky clock (day group/sun angle) — retail's clouds drift independently of the calendar date by design. | `null`monotonic elapsed time (every ordinary run) | `RuntimeOptions.SkyAnimationPhaseSeconds``SkyRenderer.AnimationPhaseSecondsOverride` (cloud/rain UV scroll) **and** `AtmosphericPostProcessGraph`'s foliage-wind clock |
| `ACDREAM_STREAM_WORK_COMPLETIONS` | `=<int>` (`>0`, else default) | Per-frame ceiling on streaming completion admissions on the update thread | Class doc comment states explicitly: this whole `ACDREAM_STREAM_WORK_*` family "exists for A/B measurement only" — not a user/production setting. Directly changes streaming throughput per frame; do not compare a measurement taken with this set against a default run. | 64 | `StreamingWorkBudgetOptions.Parse` (`StreamingWorkBudgetOptions.cs:56-58`) |
| `ACDREAM_STREAM_WORK_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,6 +503,12 @@ elapsed seconds that `TexVelocityX/Y` accumulate against with a fixed value.
**Unset — the default, and every ordinary run — keeps the wall clock**, so
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
@ -2591,6 +2597,10 @@ Holtburg pair went from 23,090 differing pixels to 1,211. **The alternative was
`-MaskTopPixels`, which would have permanently blinded the campaign's strictest
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,6 +13,12 @@
> 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,6 +199,27 @@ 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

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

View file

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

View file

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

View file

@ -11,6 +11,7 @@ using AcDream.Runtime;
using AcDream.Runtime.Session;
using AcDream.Core.Combat;
using AcDream.Core.Lighting;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
using AcDream.Core.Selection;
using AcDream.Core.World;
@ -393,7 +394,10 @@ internal sealed class FrameRootCompositionPhase
live.EnvCellRenderer!,
foundation.SceneLighting!,
d.RenderRange,
skyPesFrame);
skyPesFrame,
persistentDaylight: () =>
d.Runtime.CharacterOwner.Options.GetOptionBit(
CharacterOptionId.PersistentAtDay));
var worldRenderFrameBuilder = new WorldRenderFrameBuilder(
new RuntimeWorldFrameCameraSource(
host.CameraController,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -239,7 +239,9 @@ public sealed class RetailPViewRenderer
_partitionResult,
prepareCells,
ctx.LandblockEntries,
_partitionObserver);
_partitionObserver,
ctx.Frustum,
ctx.PlayerLandblockId ?? 0u);
partition = _partitionResult;
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -60,6 +60,41 @@ 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
@ -283,8 +318,9 @@ public static class ClientCommandRequests
public static byte[] BuildRecallAllegianceHometown(uint sequence) =>
BuildParameterless(sequence, RecallAllegianceHometownOpcode);
// "@allegiance info [name]" — GameActionAllegianceInfoRequest.Handle:
// ReadString16L() player name (empty string = self).
// "@allegiance info <name>" — GameActionAllegianceInfoRequest.Handle:
// ReadString16L() player name. Retail refuses an empty argument before
// constructing this request.
public static byte[] BuildAllegianceInfoRequest(uint sequence, string playerName) =>
BuildString(sequence, AllegianceInfoRequestOpcode, playerName);
@ -308,6 +344,133 @@ 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) =>
@ -337,6 +500,39 @@ 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,5 +275,6 @@ internal sealed class AckNakScheduler
outboundIsaac: null);
_send(datagram.Slice(0, datagramLength));
_stats.NaksSent++;
_stats.NakIdsSent += count;
}
}

View file

@ -263,6 +263,23 @@ 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,6 +41,9 @@ 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
@ -51,6 +54,7 @@ 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;
@ -71,13 +75,19 @@ 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,
send,
CountedSend,
pool);
Inbound = new InboundSequenceTracker(inboundIsaac, Stats);
Scheduler = new AckNakScheduler(
@ -87,8 +97,9 @@ internal sealed class ReliableTransport : IDisposable
sessionClientId,
sessionIteration,
Stats,
send);
CountedSend);
Stats.CacheDepthSource = () => Outbound.CacheDepth;
_packetLoss = new RetailPacketLossAverager(Clock, Stats);
}
/// <summary>Last reliable sequence on the wire — the value unsequenced
@ -112,6 +123,12 @@ 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

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

View file

@ -9,6 +9,15 @@ namespace AcDream.Core.Net.Transport;
/// </summary>
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;
@ -57,6 +66,11 @@ 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,7 +652,8 @@ public sealed class WorldSession : IDisposable
Volatile.Read(ref _lastInboundPacketTicks),
Stopwatch.GetTimestamp(),
Stopwatch.Frequency,
PingRoundTripSeconds);
PingRoundTripSeconds,
_transport?.PacketLossPercentage ?? 0d);
internal double? PingRoundTripSeconds
{
@ -1861,6 +1862,8 @@ 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
@ -2814,13 +2817,234 @@ public sealed class WorldSession : IDisposable
SendGameAction(ClientCommandRequests.BuildRecallAllegianceHometown(seq));
}
/// <summary>Send retail "@allegiance info [name]" (0x027B).</summary>
/// <summary>Send retail "@allegiance info &lt;name&gt;" (0x027B).</summary>
public void SendAllegianceInfoRequest(string playerName)
{
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,6 +81,7 @@ 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;
@ -98,11 +99,24 @@ 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>
@ -128,14 +142,37 @@ public sealed class DatSoundCache
return null;
}
Interlocked.Increment(ref _misses);
_afterInitialWaveMiss?.Invoke(waveId);
Lazy<WaveData?> lazy = _inflight.GetOrAdd(
waveId,
static (id, self) => new Lazy<WaveData?>(
() => self.DecodeUncached(id),
LazyThreadSafetyMode.ExecutionAndPublication),
this);
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);
}
try
{
WaveData? decoded = lazy.Value;

View file

@ -179,14 +179,28 @@ public sealed class ChatLog
/// <summary>PlayerKilled (0x019E) — death announcement.</summary>
/// <remarks>
/// Death messages are routed as <see cref="ChatKind.System"/> so they
/// share styling with other server announcements. The
/// 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
/// <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)
public void OnPlayerKilled(
string deathMessage,
uint victimGuid,
uint killerGuid,
uint localPlayerGuid = 0u)
{
if (localPlayerGuid != 0u
&& (localPlayerGuid == victimGuid || localPlayerGuid == killerGuid))
{
return;
}
Append(new ChatEntry(
Kind: ChatKind.System,
Sender: "",

View file

@ -192,11 +192,12 @@ public sealed class PhysicsTimestampGate
_timestamps[ForcePosition] = forcePosition;
// SmartBox::HandleReceivedPosition 0x00453FD0: a fresh local
// 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])
// 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]))
{
_timestamps[Position] = position;
return PositionTimestampDisposition.ForcePosition;

View file

@ -16,26 +16,13 @@ namespace AcDream.Core.Physics;
/// transitional blending.
///
/// <para>
/// 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
/// 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
/// <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 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.
/// entity — character creation hasn't happened yet), so neither consumer can
/// own the primitive. Keeping it here prevents the two paths from drifting.
/// </para>
/// </summary>
public static class RetailAnimationCyclePlayback
@ -43,14 +30,15 @@ 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 <c>LiveEntityAnimationPresenter.Present</c>'s legacy branch uses
/// shape retail playback and the live presenter's legacy branch use
/// (<c>over % (span + 1)</c>, not a plain clamp — a frame position that
/// 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"/>), a
/// non-positive <paramref name="framerate"/>, or a non-positive
/// <paramref name="elapsedSeconds"/>.
/// (<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.
/// </summary>
public static float Advance(
float currFrame,
@ -60,7 +48,7 @@ public static class RetailAnimationCyclePlayback
float elapsedSeconds)
{
int span = highFrame - lowFrame;
if (span <= 0 || framerate <= 0f || elapsedSeconds <= 0f)
if (span <= 0 || elapsedSeconds <= 0f)
return currFrame;
float next = currFrame + elapsedSeconds * framerate;
@ -84,8 +72,7 @@ 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
/// <c>LiveEntityAnimationPresenter.TryResolvePartFrame</c>'s no-
/// sequence-frames branch exactly.
/// the live presenter's no-sequence-frames branch exactly.
/// </summary>
public static bool TryInterpolatePart(
Animation animation,

View file

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

View file

@ -18,6 +18,10 @@ 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,
@ -84,18 +88,52 @@ public enum ClientCommandId
OffChannel,
/// <summary>@alh / @ah / "@allegiance hometown" / "@allegiance ho" — recall to the allegiance bindstone.</summary>
AllegianceHometown,
/// <summary>"@allegiance info [name]" — request allegiance member info.</summary>
/// <summary>"@allegiance info &lt;name&gt;" — 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>
/// 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>.
/// 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.
/// </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

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

View file

@ -111,6 +111,23 @@ public static class RetailClientCommandCatalog
"/framerate",
"/framerate - 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",
@ -444,15 +461,96 @@ 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 name, empty = self.
// GameActionAllegianceInfoRequest.Handle — String16L member name. Retail
// rejects an empty argument locally before it constructs this action.
// Exact retail help: acclient_2013_pseudo_c.txt:1031214 —
// "@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.".
@ -468,8 +566,9 @@ 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, and shows this refusal for every subcommand beyond the
// 2 ported ones (info/hometown/ho — TS-68 tracks the other 10).
// table does. The recognized subcommands below now route to their
// complete retail handlers; only genuinely unknown forms use this
// refusal.
private static readonly Definition AllegianceUnrecognizedSubcommand = new(
ClientCommandId.AllegianceUnrecognizedSubcommand,
Usage: "/allegiance <sub>",
@ -477,6 +576,13 @@ 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)
{
@ -499,6 +605,8 @@ public static class RetailClientCommandCatalog
["age"] = QueryAge,
["birth"] = QueryBirth,
["framerate"] = FrameRate,
["day"] = Day,
["render"] = Render,
["lockui"] = LockUi,
["version"] = Version,
["loc"] = Location,
@ -545,6 +653,7 @@ public static class RetailClientCommandCatalog
["off"] = OffChannel,
["alh"] = AllegianceHometown,
["ah"] = AllegianceHometown,
["motd"] = AllegianceMotd,
}.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
/// <summary>
@ -603,38 +712,69 @@ 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> 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.
/// 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.
/// </summary>
private static bool TryMatchHouse(string arguments, out Match match)
{
match = default;
string subcommand = arguments.ToLowerInvariant();
Definition? definition = subcommand switch
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
{
"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)
return false;
{
match = new Match(
HouseUnrecognizedSubcommand.Command,
arguments,
HouseUnrecognizedSubcommand.Usage,
HasValidArguments: false,
HouseUnrecognizedSubcommand.InvalidArgumentsText);
return true;
}
if (definition == HouseRecall || definition == MansionRecall)
{
match = new Match(
definition.Command,
rest,
definition.Usage,
HasValidArguments: rest.Length == 0,
InvalidArgumentsText: "Please see @help House for more information on how to use this command.");
return true;
}
if (definition == HouseAvailableList)
{
match = new Match(
definition.Command,
rest,
definition.Usage,
definition.ValidateArguments(rest),
definition.InvalidArgumentsText);
return true;
}
string nestedArguments = definition == HouseOpenStatus
? subcommand
: rest;
match = new Match(
definition.Command,
Arguments: string.Empty,
Arguments: nestedArguments,
definition.Usage,
HasValidArguments: true,
InvalidArgumentsText: null);
@ -643,25 +783,15 @@ 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 12
/// subcommands (see the registry doc §2.5) locally; acdream Campaign CH
/// slice CH4 (2026-08-09) ports 2 of them (info, hometown/ho).
/// Retail's real <c>DoAllegiance @ 0x0057D5A0</c> handles all twelve
/// subcommands locally. Unknown forms remain locally owned and print its
/// exact Allegiance refusal.
/// </summary>
/// <remarks>
/// <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.
/// 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.
/// </remarks>
private static bool TryMatchAllegiance(string arguments, out Match match)
{
@ -669,24 +799,29 @@ public static class RetailClientCommandCatalog
string subcommand = separator < 0 ? arguments : arguments[..separator];
string rest = separator < 0 ? string.Empty : arguments[(separator + 1)..].Trim();
if (subcommand.Equals("hometown", StringComparison.OrdinalIgnoreCase)
|| subcommand.Equals("ho", StringComparison.OrdinalIgnoreCase))
Definition? definition = subcommand.ToLowerInvariant() switch
{
match = new Match(
AllegianceHometown.Command,
Arguments: string.Empty,
AllegianceHometown.Usage,
HasValidArguments: true,
InvalidArgumentsText: null);
return true;
}
"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 (subcommand.Equals("info", StringComparison.OrdinalIgnoreCase))
if (definition is not null)
{
match = new Match(
AllegianceInfo.Command,
rest,
AllegianceInfo.Usage,
definition.Command,
definition == AllegianceHometown ? string.Empty : rest,
definition.Usage,
HasValidArguments: true,
InvalidArgumentsText: null);
return true;

View file

@ -10,8 +10,7 @@ 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, even though most subcommands are not yet locally executed —
/// see TS-68). <b>Corrected at the consolidated-review round
/// here too). <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
@ -379,9 +378,8 @@ 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. TS-68's
// implemented-vs-not-yet-implemented tracking lives in ISSUES.md and
// the divergence register now, not in this user-visible string.
// bracket, no alias mention at all) — both removed here. Implementation
// status never belongs in this verbatim 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"
@ -427,8 +425,7 @@ 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. TS-68's implemented-vs-not-yet-implemented tracking lives
// in ISSUES.md and the divergence register now, not in this
// appended. Implementation status never belongs in this verbatim
// user-visible string.
public const string HouseOverview =
HouseOneLiner
@ -955,6 +952,9 @@ 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,6 +1,7 @@
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
using AcDream.Runtime.Physics;
namespace AcDream.Runtime.Entities;
@ -597,8 +598,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 classifying them here needs no route, no player distance and no
/// signature change (see the truth table at the call below). The
/// why deriving them here needs no route, no player distance and no
/// signature change. The
/// near/far/teleport routing decision proper is still downstream of this
/// merge and still belongs to
/// <c>RuntimeAuthoritativePositionRouteClassifier</c>, which the
@ -606,15 +607,9 @@ public sealed class InboundPhysicsStateController
/// which the App-layer OnPosition tail runs post-merge for this caller;
/// contact still comes solely from the retained wire packet's own
/// <c>IsGrounded</c> bit on both. That remaining structural difference
/// (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.
/// 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.
/// </summary>
public bool TryApplyPosition(
WorldSession.EntityPositionUpdate update,
@ -680,14 +675,6 @@ 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.
@ -695,6 +682,10 @@ public sealed class InboundPhysicsStateController
(old.MotionTableId ?? old.Physics?.MotionTableId)
is { } motionTableId
&& motionTableId != 0u;
RuntimeAcceptedPositionPrePlacementFlags prePlacement =
RuntimeAuthoritativePositionRouteClassifier.DerivePrePlacementFlags(
disposition,
hasAnimations);
accepted = ApplyAcceptedPosition(
old,
update,
@ -703,8 +694,8 @@ public sealed class InboundPhysicsStateController
isLocalPlayer,
forcePositionRotation,
currentLocalVelocity,
installPlacementFrame: !force && !hasAnimations,
clearParent: !force);
installPlacementFrame: prePlacement.ApplyPlacementFrameBeforeRouting,
clearParent: prePlacement.UnparentBeforeRouting);
_snapshots[update.Guid] = accepted;
return true;
}
@ -713,10 +704,8 @@ 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; 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>
/// flags; the steady-state <see cref="TryApplyPosition"/> caller gets the
/// same values from the shared pre-placement derivation.</summary>
internal bool ApplyAcceptedPositionSnapshot(
uint guid,
WorldSession.EntityPositionUpdate update,
@ -817,13 +806,9 @@ 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. 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
/// (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
/// retail's HandleReceivedPosition Gate A returns from immediately,
/// BEFORE either call; <paramref name="installPlacementFrame"/> is
/// additionally false whenever HasAnims is true.

View file

@ -1,14 +1,15 @@
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 MINIMAL
/// "houseless-status only" per ISSUES #413's own sizing note: a full
/// two-tab Map/House panel (<c>gmHouseUI</c>). Deliberately lightweight
/// 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
@ -28,8 +29,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 — those remain unported, ISSUES #413
/// item 3.
/// and emit NOTHING when houseless. The owned-house path retains the exact
/// 0x0225 snapshot and ports all seven builders in retail's fixed order.
/// </para>
/// <para>
/// <b>CORRECTED at the 2026-08-17 morning gate round (user finding 2: the
@ -63,7 +64,9 @@ namespace AcDream.Runtime.Gameplay;
/// <c>m_pHouseData == 0</c> (still houseless) and emits the literal at
/// <c>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). So a queried houseless character's House tab shows exactly TWO
/// round). The owned branch now composes the retained buy list through the
/// retail <c>HousePaymentList</c> rules. So a queried houseless character's
/// House tab shows exactly TWO
/// lines, in builder order: "You do not currently own a house." then the
/// purchase-time line — which this class now renders.
/// </para>
@ -101,7 +104,9 @@ 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
@ -123,6 +128,28 @@ 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
@ -131,15 +158,48 @@ public sealed class RuntimeHouseState
}
/// <summary>0x0225 HouseData — <c>RecvNotice_UpdateHouseData</c>
/// (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>
/// (owned-house case). The snapshot is retained defensively because its
/// payment lists are later replaced by 0x0227/0x0228 notices.</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);
}
}
@ -158,6 +218,7 @@ public sealed class RuntimeHouseState
{
_hasReceivedNotice = true;
_ownsHouse = false;
_houseData = null;
Recompute(selfGuid);
}
}
@ -171,29 +232,67 @@ public sealed class RuntimeHouseState
{
_hasReceivedNotice = false;
_ownsHouse = false;
_houseData = null;
_lines = Array.Empty<string>();
_panelLines = Array.Empty<HousePanelLine>();
}
}
/// <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>
/// <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>
private void Recompute(uint selfGuid)
{
var lines = new List<string>(2);
var lines = new List<HousePanelLine>(_ownsHouse ? 8 : 2);
// gmHouseUI::DisplayBuyPayment @0x004a2b30 — NOT houseless-silent
// (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. 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.");
// 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."));
}
// gmHouseUI::DisplayPurchaseTimeText @0x004a3110, both branches.
int timestamp = _objects?.Get(selfGuid)?.Properties
@ -221,18 +320,95 @@ public sealed class RuntimeHouseState
timestamp + PurchaseWaitPeriodSeconds);
DateTime expiryLocal = TimeZoneInfo.ConvertTime(
expiryUtc, _timeProvider.LocalTimeZone).DateTime;
lines.Add(
lines.Add(Normal(
"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(_ownsHouse
lines.Add(Normal(_ownsHouse
? "You may buy another house immediately after you abandon this one."
: "You may buy another house immediately.");
: "You may buy another house immediately."));
}
_lines = lines;
_panelLines = lines;
_lines = lines.Select(static line => line.Text).ToArray();
}
private HousePanelLine Normal(string text) =>
new(text, HousePanelTextColor.Normal);
private static GameEvents.HouseData Copy(GameEvents.HouseData data) =>
data with
{
Buy = (data.Buy ?? Array.Empty<GameEvents.HousePayment>()).ToArray(),
Rent = (data.Rent ?? Array.Empty<GameEvents.HousePayment>()).ToArray(),
};
private static bool IsPaidInFull(IReadOnlyList<GameEvents.HousePayment> payments)
{
for (int i = 0; i < payments.Count; i++)
{
if (payments[i].Paid < payments[i].Num)
return false;
}
return true;
}
private static string ComposePayments(
IReadOnlyList<GameEvents.HousePayment> payments, bool includePaid)
{
if (payments.Count == 0)
return string.Empty;
var parts = new string[payments.Count];
for (int i = 0; i < payments.Count; i++)
{
GameEvents.HousePayment payment = payments[i];
string quantity = includePaid
? $"{payment.Paid.ToString(CultureInfo.InvariantCulture)}/{payment.Num.ToString(CultureInfo.InvariantCulture)}"
: payment.Num.ToString(CultureInfo.InvariantCulture);
parts[i] = quantity + " " + PaymentName(payment);
}
return string.Join(", ", parts);
}
private static string PaymentName(GameEvents.HousePayment payment)
{
if (payment.Num == 1)
return payment.Name;
if (!string.IsNullOrEmpty(payment.PluralName))
return payment.PluralName;
return payment.Name.EndsWith('s') || payment.Name.EndsWith('x')
? payment.Name + "es"
: payment.Name + "s";
}
private static long GetRentPeriodSeconds(uint houseType) =>
houseType == 4u ? 7_776_000L : 2_592_000L;
private string ConvertTime(long epochSeconds)
{
if (epochSeconds == 0L)
return "N/A";
DateTimeOffset utc = DateTimeOffset.FromUnixTimeSeconds(epochSeconds);
DateTime local = TimeZoneInfo.ConvertTime(
utc, _timeProvider.LocalTimeZone).DateTime;
return local.ToString(CultureInfo.CurrentCulture);
}
}
/// <summary>Retail <c>HousePanelTextColor</c>; the numeric value is the
/// authored row template's font-color palette index.</summary>
public enum HousePanelTextColor
{
Normal = 0,
RentPaid = 1,
RentNotPaid = 2,
}
public readonly record struct HousePanelLine(
string Text, HousePanelTextColor Color);

View file

@ -131,6 +131,16 @@ internal readonly record struct RuntimeAcceptedPositionRouteRequest(
bool HasAnimations,
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.
@ -189,6 +199,20 @@ 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)
{
@ -308,6 +332,10 @@ 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)
@ -320,8 +348,8 @@ internal static class RuntimeAuthoritativePositionRouteClassifier
operation,
AuthoritativeTeleportFlags,
request.PlacementFrame ?? 0u,
UnparentBeforeRouting: false,
ApplyPlacementFrameBeforeRouting: false,
prePlacement.UnparentBeforeRouting,
prePlacement.ApplyPlacementFrameBeforeRouting,
LeaveWorld: false,
TeleportHookPhase: RuntimeTeleportHookPhase.None,
StopInterpolating: false,
@ -343,8 +371,8 @@ internal static class RuntimeAuthoritativePositionRouteClassifier
operation,
AuthoritativeTeleportFlags,
placement,
UnparentBeforeRouting: true,
ApplyPlacementFrameBeforeRouting: !request.HasAnimations,
prePlacement.UnparentBeforeRouting,
prePlacement.ApplyPlacementFrameBeforeRouting,
LeaveWorld: false,
TeleportHookPhase: RuntimeTeleportHookPhase.AfterPositionOperation,
StopInterpolating: false,
@ -365,8 +393,8 @@ internal static class RuntimeAuthoritativePositionRouteClassifier
operation,
PhysicsSetPositionFlags.None,
placement,
UnparentBeforeRouting: true,
ApplyPlacementFrameBeforeRouting: !request.HasAnimations,
prePlacement.UnparentBeforeRouting,
prePlacement.ApplyPlacementFrameBeforeRouting,
LeaveWorld: false,
TeleportHookPhase: RuntimeTeleportHookPhase.None,
StopInterpolating: false,
@ -396,8 +424,8 @@ internal static class RuntimeAuthoritativePositionRouteClassifier
operation,
AuthoritativeTeleportFlags,
placement,
UnparentBeforeRouting: true,
ApplyPlacementFrameBeforeRouting: !request.HasAnimations,
prePlacement.UnparentBeforeRouting,
prePlacement.ApplyPlacementFrameBeforeRouting,
LeaveWorld: false,
TeleportHookPhase: RuntimeTeleportHookPhase.BeforePositionOperation,
StopInterpolating: false,
@ -422,8 +450,8 @@ internal static class RuntimeAuthoritativePositionRouteClassifier
operation,
PhysicsSetPositionFlags.None,
placement,
UnparentBeforeRouting: true,
ApplyPlacementFrameBeforeRouting: !request.HasAnimations,
prePlacement.UnparentBeforeRouting,
prePlacement.ApplyPlacementFrameBeforeRouting,
LeaveWorld: false,
TeleportHookPhase: RuntimeTeleportHookPhase.None,
StopInterpolating: false,
@ -452,8 +480,8 @@ internal static class RuntimeAuthoritativePositionRouteClassifier
operation,
nearby ? PhysicsSetPositionFlags.None : AuthoritativeTeleportFlags,
placement,
UnparentBeforeRouting: true,
ApplyPlacementFrameBeforeRouting: !request.HasAnimations,
prePlacement.UnparentBeforeRouting,
prePlacement.ApplyPlacementFrameBeforeRouting,
LeaveWorld: false,
TeleportHookPhase: RuntimeTeleportHookPhase.None,
StopInterpolating: !nearby,
@ -480,9 +508,7 @@ internal static class RuntimeAuthoritativePositionRouteClassifier
{
PositionTimestampDisposition.Apply => true,
PositionTimestampDisposition.ForcePosition =>
kind is RuntimePositionEntityKind.LocalPlayer
&& authority.PreviousTeleportSequence
== authority.AcceptedTeleportSequence,
kind is RuntimePositionEntityKind.LocalPlayer,
_ => false,
};
}

View file

@ -597,6 +597,14 @@ 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 = [];
@ -934,6 +942,14 @@ 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;
@ -1117,18 +1133,42 @@ internal sealed class RuntimeSetPositionState : IDisposable
internal void RetryPendingProjections()
{
EnsureNotDisposed();
RuntimePlacementProjectionSnapshot[] snapshot =
_pendingProjection.Values.ToArray();
for (int index = 0; index < snapshot.Length; index++)
int depth = _pendingProjectionRetryDepth;
if (depth == _pendingProjectionRetryScratchByDepth.Count)
{
RuntimePlacementProjectionSnapshot projection = snapshot[index];
if (_pendingProjection.TryGetValue(
projection.Token.Sequence,
out RuntimePlacementProjectionSnapshot current)
&& current == projection)
_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)
{
PublishPlacement(projection);
snapshot.Add(entry.Value);
}
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;
}
}
@ -4101,6 +4141,50 @@ 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,7 +95,11 @@ 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);
RuntimeContractState? Contracts = null,
// Issue #359: retail suppresses 0x019E PlayerKilled for the victim and
// killer. Optional for compatibility with state-only tests; production
// hosts always supply their canonical identity owner.
Func<uint>? PlayerGuid = null);
/// <summary>
/// Owns every inbound subscription for one exact live session. Domain state
@ -338,6 +342,12 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting
onHouseStatus: social.House is { } houseStatus
? 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
@ -451,7 +461,8 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting
killed => social.Chat.OnPlayerKilled(
killed.DeathMessage,
killed.VictimGuid,
killed.KillerGuid));
killed.KillerGuid,
social.PlayerGuid?.Invoke() ?? 0u));
Subscribe<TurbineChat.Parsed>(
h => session.TurbineChatReceived += h,
h => session.TurbineChatReceived -= h,

View file

@ -180,10 +180,9 @@ public sealed record DisplaySettings(
int LandscapeTextureDetail = 2,
int EnvironmentTextureDetail = 1,
int TextureFiltering = 1,
// 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.
// 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.
int LandscapeDrawDistance = 8,
bool BuildingDetailTextures = true,
bool MultiPassAlpha = false)

View file

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

View file

@ -101,6 +101,39 @@ 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()
{
@ -831,6 +864,7 @@ public sealed class LiveSessionCommandRouterTests
ToggleFrameRate: () => { },
ToggleUiLock: () => { },
ShowSystemMessage: _ => { },
ShowClientLocalMessage: _ => { },
ShowWeenieError: _ => { },
PlayerPublicWeenieBitfield: () => null,
ClientVersion: () => "test",
@ -879,5 +913,49 @@ public sealed class LiveSessionCommandRouterTests
LeaveGmChannel: _ => { },
RecallAllegianceHometown: () => { },
RequestAllegianceInfo: _ => { },
AbandonHouse: () => { });
AbandonHouse: () => { },
Administration: NewAdministrationBindings(),
IsPersistentDaylight: () => false,
SetPersistentDaylight: _ => { },
SetLandscapeRadius: _ => { },
SetFieldOfView: _ => { });
private static ClientCommandController.AdministrationBindings
NewAdministrationBindings() => new(
BreakAllegianceBoot: (_, _) => { },
AllegianceChatBoot: (_, _) => { },
AllegianceChatGag: (_, _) => { },
AllegianceBroadcast: _ => { },
ListAllegianceBans: () => { },
AddAllegianceBan: _ => { },
RemoveAllegianceBan: _ => { },
ListAllegianceOfficers: () => { },
ClearAllegianceOfficers: () => { },
SetAllegianceOfficer: (_, _) => { },
RemoveAllegianceOfficer: _ => { },
ListAllegianceOfficerTitles: () => { },
ClearAllegianceOfficerTitles: () => { },
SetAllegianceOfficerTitle: (_, _) => { },
QueryAllegianceName: () => { },
SetAllegianceName: _ => { },
ClearAllegianceName: () => { },
AllegianceLockAction: _ => { },
SetAllegianceApprovedVassal: _ => { },
AllegianceHouseAction: _ => { },
QueryMotd: () => { },
SetMotd: _ => { },
ClearMotd: () => { },
SetOpenHouseStatus: _ => { },
AddPermanentGuest: _ => { },
RemovePermanentGuest: _ => { },
RemoveAllPermanentGuests: () => { },
ChangeStoragePermission: (_, _) => { },
AddAllStoragePermission: () => { },
RemoveAllStoragePermission: () => { },
RequestFullGuestList: () => { },
BootSpecificHouseGuest: _ => { },
BootEveryone: () => { },
SetHooksVisibility: _ => { },
ModifyAllegianceGuestPermission: _ => { },
ModifyAllegianceStoragePermission: _ => { });
}

View file

@ -1,3 +1,4 @@
using System.Collections.Frozen;
using AcDream.App.Net;
using AcDream.Core.CharGen;
using DatReaderWriter.DBObjs;
@ -258,22 +259,26 @@ public sealed class RetailSkillFormulaTests
AttributeId attributeId)
{
const uint skillId = 0x10u;
var skillTable = new SkillTable();
skillTable.Skills.Add((SkillId)skillId, new SkillBase
var options = ChargenOptions.Empty with
{
Formula = new SkillFormula
GlobalSkillDetailsBySkillId = new Dictionary<uint, ChargenSkillDetail>
{
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);
[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);
ChargenAttributeValues attributes = AttributeValuesWith(attributeId, 42);
uint result = resolver.Resolve(skillId, attributes, ChargenSkillAdvancementClass.Untrained);
@ -281,6 +286,19 @@ 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_QueueClearedNoShadowPublish_316Preserved()
public void LandingPacket_PlayerGuid_QueueClearedAndShadowPublished()
{
using var fixture = new Fixture(PlayerGuid);
EntityPhysicsHost host = fixture.InstallHost();
@ -253,14 +253,13 @@ public sealed class LiveEntityNetworkOnPositionCollapseMatrixTests
// Row 2a, PRESERVED for player guids: the interp queue IS cleared.
Assert.False(fixture.Remote.Interp.IsActive);
// 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.
// #316: the same resolved landing pose must publish to collision in
// this packet, not wait for a later physics tick to self-heal.
ShadowEntry shadowEntry = Assert.Single(
fixture.Shadows.AllEntriesForDebug(),
entry => entry.EntityId == fixture.Entity.Id);
Assert.Equal(spawnShadowPos, shadowEntry.Position);
Assert.NotEqual(landingPos, shadowEntry.Position);
Assert.Equal(landingPos, shadowEntry.Position);
Assert.NotEqual(spawnShadowPos, shadowEntry.Position);
// AP-135's cell-adopt bookkeeping still ran (via the post-routing
// wire-cell adopt, not suppressed for AirborneSnap).
@ -574,6 +573,26 @@ 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 = 0x70000071u;
private const uint Guid = 0x50000071u;
private const uint SourceCell = 0xA9B40039u;
private const uint DestinationCell = 0xAAB40001u;
@ -37,7 +37,8 @@ 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
@ -84,6 +85,17 @@ 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,4 +109,36 @@ 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,6 +381,50 @@ 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

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

View file

@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Numerics;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Vfx;
using AcDream.Core.Physics;
using AcDream.Core.Vfx;
using AcDream.Core.World;
using DatReaderWriter.Types;
@ -22,20 +23,59 @@ 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()
public Harness(AnimationHook? hook = null, double hookTime = 0.0)
{
var registry = new EmitterDescRegistry();
var system = new ParticleSystem(registry, new Random(42));
registry.Register(new EmitterDesc
{
DatId = LightningEmitter,
Type = ParticleType.Still,
Flags = EmitterFlags.Billboard,
EmitterKind = ParticleEmitterKind.BirthratePerSec,
MaxParticles = 2,
InitialParticles = 1,
LifetimeMin = 0.01f,
LifetimeMax = 0.01f,
Lifespan = 0.01f,
StartSize = 1f,
EndSize = 1f,
StartAlpha = 1f,
EndAlpha = 1f,
Birthrate = 1000f,
});
Particles = new ParticleSystem(registry, new Random(42));
var poses = new EntityEffectPoseRegistry();
var sink = new ParticleHookSink(system, poses);
var sink = new ParticleHookSink(Particles, poses)
{
DiagnosticSink = Diagnostics.Add,
};
var recording = new RecordingHookSink();
HookCalls = recording.Calls;
var router = new AnimationHookRouter();
router.Register(sink);
router.Register(recording);
Runner = new PhysicsScriptRunner(
id =>
{
@ -43,12 +83,12 @@ public sealed class SkyPesFrameControllerTests
var script = new DatPhysicsScript();
script.ScriptData.Add(new PhysicsScriptData
{
StartTime = 0.0,
Hook = new SoundHook(),
StartTime = hookTime,
Hook = hook ?? new SoundHook(),
});
return script;
},
sink);
router);
Controller = new SkyPesFrameController(
Runner,
sink,
@ -169,4 +209,52 @@ 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,11 +30,25 @@ 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,6 +300,56 @@ 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

@ -6,11 +6,10 @@ namespace AcDream.App.Tests;
public class RuntimeOptionsRetailUiTests
{
[Fact]
public void Parse_ReadsRetailUiAndAcDir()
public void Parse_RetailUiIsDefaultOnAndReadsAcDir()
{
var env = new Dictionary<string, string?>
{
["ACDREAM_RETAIL_UI"] = "1",
["ACDREAM_AC_DIR"] = @"C:\Turbine\Asheron's Call",
};
var opts = RuntimeOptions.Parse("dats", k => env.GetValueOrDefault(k));
@ -19,13 +18,23 @@ public class RuntimeOptionsRetailUiTests
}
[Fact]
public void Parse_DefaultsRetailUiOffAndAcDirNull()
public void Parse_DefaultsRetailUiOnAndAcDirNull()
{
var opts = RuntimeOptions.Parse("dats", _ => null);
Assert.False(opts.RetailUi);
Assert.True(opts.RetailUi);
Assert.Null(opts.AcDir);
}
[Fact]
public void Parse_LiteralZeroOptsOutOfRetailUi()
{
var opts = RuntimeOptions.Parse(
"dats",
key => key == "ACDREAM_RETAIL_UI" ? "0" : null);
Assert.False(opts.RetailUi);
}
[Fact]
public void Parse_ReadsUiProbeOptions()
{

View file

@ -11,6 +11,40 @@ namespace AcDream.App.Tests.Settings;
public sealed class RuntimeSettingsControllerTests
{
[Theory]
[InlineData(3, 3, 3)]
[InlineData(5, 4, 5)]
[InlineData(8, 4, 8)]
[InlineData(12, 4, 12)]
[InlineData(25, 4, 25)]
public void LandscapeDrawDistance_IsTheRetailFarRadiusValue(
int value,
int expectedNear,
int expectedFar)
{
QualitySettings result = RuntimeSettingsController
.ApplyLandscapeDrawDistance(
QualitySettings.From(QualityPreset.High),
value);
Assert.Equal(expectedNear, result.NearRadius);
Assert.Equal(expectedFar, result.FarRadius);
}
[Theory]
[InlineData(0)]
[InlineData(2)]
[InlineData(26)]
public void InvalidStoredLandscapeDistance_PreservesThePreset(int value)
{
QualitySettings original = QualitySettings.From(QualityPreset.High);
QualitySettings result = RuntimeSettingsController
.ApplyLandscapeDrawDistance(original, value);
Assert.Equal(original, result);
}
[Fact]
public void ConstructionLoadsEachBagOnceAndPublishesOneStartupSnapshot()
{

View file

@ -1,3 +1,4 @@
using System.Globalization;
using AcDream.App.UI;
using AcDream.Core.Chat;
using AcDream.Core.Physics;
@ -552,11 +553,355 @@ public sealed class ClientCommandControllerTests
Assert.Equal("my chat log.txt", match.Arguments);
}
// ── #361: retail's @day / @render ─────────────────────────────────
[Fact]
public void Day_TogglesThePersistentOptionAndPrintsRetailsExactLines()
{
bool persistentDaylight = false;
var values = new List<bool>();
var messages = new List<string>();
ClientCommandController controller = NewController(
messages: messages,
isPersistentDaylight: () => persistentDaylight,
setPersistentDaylight: value =>
{
persistentDaylight = value;
values.Add(value);
});
controller.Execute(new ExecuteClientCommandCmd(
ClientCommandId.TogglePersistentDaylight,
"ignored exactly like retail"));
controller.Execute(new ExecuteClientCommandCmd(
ClientCommandId.TogglePersistentDaylight,
string.Empty));
Assert.Equal([true, false], values);
Assert.Equal(
["Let there be light!", "Normality has been restored."],
messages);
}
[Theory]
[InlineData("radius 5", 5)]
[InlineData("RADIUS 25 extra ignored", 25)]
[InlineData("radius 12suffix", 12)]
public void RenderRadius_AcceptsRetailRangeAndAtoiPrefix(
string arguments,
int expected)
{
var radii = new List<int>();
var messages = new List<string>();
ClientCommandController controller = NewController(
messages: messages,
setLandscapeRadius: radii.Add);
controller.Execute(new ExecuteClientCommandCmd(
ClientCommandId.RenderOption,
arguments));
Assert.Equal([expected], radii);
Assert.Equal(["Landscape radius set"], messages);
}
[Theory]
[InlineData("fov 10", 10f)]
[InlineData("FOV 160 extra", 160f)]
[InlineData("fov 91degrees", 91f)]
public void RenderFov_AcceptsRetailRangeAndAtoiPrefix(
string arguments,
float expected)
{
var values = new List<float>();
var messages = new List<string>();
ClientCommandController controller = NewController(
messages: messages,
setFieldOfView: values.Add);
controller.Execute(new ExecuteClientCommandCmd(
ClientCommandId.RenderOption,
arguments));
Assert.Equal([expected], values);
Assert.Equal(["Field of view set"], messages);
}
[Theory]
[InlineData("radius", "Must specify a radius")]
[InlineData("radius 4", "Radius must be between 5 and 25")]
[InlineData("radius nope", "Radius must be between 5 and 25")]
[InlineData("fov", "Must specify a field of view")]
[InlineData("fov 161", "Field of view must be between 10 and 160")]
public void Render_InvalidValuesPrintRetailsExactReply(
string arguments,
string expected)
{
var calls = new List<string>();
var messages = new List<string>();
ClientCommandController controller = NewController(
calls,
messages: messages);
controller.Execute(new ExecuteClientCommandCmd(
ClientCommandId.RenderOption,
arguments));
Assert.DoesNotContain(calls, call =>
call.StartsWith("radius:", StringComparison.Ordinal)
|| call.StartsWith("fov:", StringComparison.Ordinal));
Assert.Equal([expected], messages);
}
[Fact]
public void Render_UsageAndUnknownOptionMatchRetail()
{
var messages = new List<string>();
ClientCommandController controller = NewController(messages: messages);
controller.Execute(new ExecuteClientCommandCmd(
ClientCommandId.RenderOption,
string.Empty));
controller.Execute(new ExecuteClientCommandCmd(
ClientCommandId.RenderOption,
"usage"));
controller.Execute(new ExecuteClientCommandCmd(
ClientCommandId.RenderOption,
"unknown 1"));
string usage = RetailCommandHelpTable.Render.TrimEnd('\n');
Assert.Equal([usage, usage], messages);
}
[Fact]
public void AllegianceAdministration_ExecutesEveryRetailDispatcherBranch()
{
var calls = new List<string>();
var system = new List<string>();
var clientLocal = new List<string>();
ClientCommandController controller = NewController(
calls: calls,
messages: system,
clientLocalMessages: clientLocal);
Execute(ClientCommandId.AllegianceInfo, "Lord Bob");
Execute(ClientCommandId.AllegianceBoot, "Lord Bob");
Execute(ClientCommandId.AllegianceBoot, "-account Account Bob");
// Retail validates non-empty BEFORE removing -account, so this odd
// form deliberately sends an empty name with accountBoot=true.
Execute(ClientCommandId.AllegianceBoot, "-account");
Execute(ClientCommandId.AllegianceBan, "list ignored");
Execute(ClientCommandId.AllegianceBan, "add Lord Bob");
Execute(ClientCommandId.AllegianceBan, "remove Lord Bob");
Execute(ClientCommandId.AllegianceChat, "on");
Execute(ClientCommandId.AllegianceChat, "off");
Execute(ClientCommandId.AllegianceChat, "kick Bob");
Execute(ClientCommandId.AllegianceChat, "kick Bob, Bad manners");
Execute(ClientCommandId.AllegianceChat, "kick ");
Execute(ClientCommandId.AllegianceChat, "gag Lord Bob");
Execute(ClientCommandId.AllegianceChat, "ungag Lord Bob");
Execute(ClientCommandId.AllegianceBroadcast, "Hear ye");
Execute(ClientCommandId.AllegianceOfficer, "");
Execute(ClientCommandId.AllegianceOfficer, "clear ignored");
Execute(ClientCommandId.AllegianceOfficer, "remove Lord Bob");
Execute(ClientCommandId.AllegianceOfficer, "add 0x2 Lord Bob");
Execute(ClientCommandId.AllegianceOfficer, "set 03 Aunt Alice");
Execute(ClientCommandId.AllegianceOfficerTitle, "");
Execute(ClientCommandId.AllegianceOfficerTitle, "clear ignored");
Execute(ClientCommandId.AllegianceOfficerTitle, "set 0x2 High Regent");
Execute(ClientCommandId.AllegianceOfficerTitle, "set 1");
Execute(ClientCommandId.AllegianceName, "");
Execute(ClientCommandId.AllegianceName, "set The Best Allegiance");
Execute(ClientCommandId.AllegianceName, "set");
Execute(ClientCommandId.AllegianceName, "clear ignored");
Execute(ClientCommandId.AllegianceLock, "");
Execute(ClientCommandId.AllegianceLock, "off");
Execute(ClientCommandId.AllegianceLock, "on");
Execute(ClientCommandId.AllegianceLock, "toggle");
Execute(ClientCommandId.AllegianceLock, "check");
Execute(ClientCommandId.AllegianceLock, "bypass");
Execute(ClientCommandId.AllegianceLock, "bypass clear");
Execute(ClientCommandId.AllegianceLock, "bypass Lord Bob");
Execute(ClientCommandId.AllegianceHouse, "");
Execute(ClientCommandId.AllegianceHouse, "guest open");
Execute(ClientCommandId.AllegianceHouse, "guest close");
Execute(ClientCommandId.AllegianceHouse, "storage open");
Execute(ClientCommandId.AllegianceHouse, "storage close");
Execute(ClientCommandId.AllegianceMotd, "");
Execute(ClientCommandId.AllegianceMotd, "set Welcome everyone");
Execute(ClientCommandId.AllegianceMotd, "set");
Execute(ClientCommandId.AllegianceMotd, "clear ignored");
Assert.Equal(
[
"alleginfo:Lord Bob",
"allegboot:Lord Bob:False",
"allegboot:Account Bob:True",
"allegboot::True",
"allegban:list",
"allegban:add:Lord Bob",
"allegban:remove:Lord Bob",
"charoption:27:True",
"charoption:27:False",
"allegchatboot:Bob:No reason given.",
"allegchatboot:Bob:Bad manners",
"allegchatboot::No reason given.",
"allegchatgag:Lord Bob:True",
"allegchatgag:Lord Bob:False",
"allegbroadcast:Hear ye",
"allegofficer:list",
"allegofficer:clear",
"allegofficer:remove:Lord Bob",
"allegofficer:set:2:Lord Bob",
"allegofficer:set:3:Aunt Alice",
"allegtitle:list",
"allegtitle:clear",
"allegtitle:set:2:High Regent",
"allegtitle:set:1:",
"allegname:query",
"allegname:set:The Best Allegiance",
"allegname:set:",
"allegname:clear",
"alleglock:4",
"alleglock:1",
"alleglock:2",
"alleglock:3",
"alleglock:4",
"alleglock:5",
"alleglock:6",
"alleglock:bypass:Lord Bob",
"alleghouse:1",
"alleghouse:2",
"alleghouse:3",
"alleghouse:4",
"alleghouse:5",
"motd:query",
"motd:set:Welcome everyone",
"motd:set:",
"motd:clear",
],
calls);
Assert.Equal(
[
"Attempting to boot Lord Bob...",
"Attempting to boot Account Bob (Account)...",
"Attempting to boot (Account)...",
],
system);
Assert.Empty(clientLocal);
void Execute(ClientCommandId command, string arguments) =>
controller.Execute(new ExecuteClientCommandCmd(command, arguments));
}
[Fact]
public void HouseAdministration_ExecutesEveryRetailDispatcherBranch()
{
var calls = new List<string>();
ClientCommandController controller = NewController(calls: calls);
Execute(ClientCommandId.HouseOpenStatus, "open");
Execute(ClientCommandId.HouseOpenStatus, "close");
Execute(ClientCommandId.HouseGuests, "add Lord Bob");
Execute(ClientCommandId.HouseGuests, "remove Lord Bob");
Execute(ClientCommandId.HouseGuests, "remove_all ignored");
Execute(ClientCommandId.HouseGuests, "list ignored");
Execute(ClientCommandId.HouseGuests, "show ignored");
Execute(ClientCommandId.HouseGuests, "add_allegiance ignored");
Execute(ClientCommandId.HouseGuests, "remove_allegiance ignored");
Execute(ClientCommandId.HouseStorage, "add Lord Bob");
Execute(ClientCommandId.HouseStorage, "remove Lord Bob");
Execute(ClientCommandId.HouseStorage, "add -all");
Execute(ClientCommandId.HouseStorage, "remove -all");
Execute(ClientCommandId.HouseStorage, "remove_all ignored");
Execute(ClientCommandId.HouseStorage, "list ignored");
Execute(ClientCommandId.HouseStorage, "show ignored");
Execute(ClientCommandId.HouseStorage, "add_allegiance ignored");
Execute(ClientCommandId.HouseStorage, "remove_allegiance ignored");
Execute(ClientCommandId.HouseBoot, "Lord Bob");
Execute(ClientCommandId.HouseBoot, "-all");
Execute(ClientCommandId.HouseBootAll, "ignored");
Execute(ClientCommandId.HouseHooks, "on ignored");
Execute(ClientCommandId.HouseHooks, "off ignored");
Assert.Equal(
[
"houseopen:True",
"houseopen:False",
"houseguest:add:Lord Bob",
"houseguest:remove:Lord Bob",
"houseguest:remove_all",
"houseguest:list",
"houseguest:list",
"houseguest:allegiance:True",
"houseguest:allegiance:False",
"housestorage:True:Lord Bob",
"housestorage:False:Lord Bob",
"housestorage:add_all",
"housestorage:remove_all",
"housestorage:remove_all",
"houseguest:list",
"houseguest:list",
"housestorage:allegiance:True",
"housestorage:allegiance:False",
"houseboot:Lord Bob",
"houseboot:all",
"houseboot:all",
"househooks:True",
"househooks:False",
],
calls);
void Execute(ClientCommandId command, string arguments) =>
controller.Execute(new ExecuteClientCommandCmd(command, arguments));
}
[Theory]
[InlineData(ClientCommandId.AllegianceInfo, "", "Please specify an actual name.")]
[InlineData(ClientCommandId.AllegianceBoot, "", "Please specify an actual name.")]
[InlineData(ClientCommandId.AllegianceBan, "add", "Please specify an actual name.")]
[InlineData(ClientCommandId.AllegianceChat, "gag", "Please specify an actual name.")]
[InlineData(ClientCommandId.AllegianceBroadcast, "", "Please see @help Allegiance for more information on how to use this command.")]
[InlineData(ClientCommandId.AllegianceOfficer, "remove", "Please specify the name of an allegiance member.")]
[InlineData(ClientCommandId.AllegianceOfficer, "add nope Bob", "Please specify a valid officer level as a number between 1 and 3. Check the game help files for more information on officer levels.")]
[InlineData(ClientCommandId.AllegianceOfficer, "add 2", "Please specify the name of an allegiance member.")]
[InlineData(ClientCommandId.AllegianceOfficerTitle, "set 4 Regent", "Please specify a valid officer level as a number between 1 and 3.")]
[InlineData(ClientCommandId.AllegianceName, "nope", "Please see @help Allegiance for more information on how to use this command.")]
[InlineData(ClientCommandId.AllegianceLock, "nope", "Please see @help Allegiance for more information on how to use this command.")]
[InlineData(ClientCommandId.AllegianceHouse, "guest nope", "Please see @help Allegiance for more information on how to use this command.")]
[InlineData(ClientCommandId.AllegianceMotd, "nope", "Please see @help Allegiance for more information on how to use this command.")]
[InlineData(ClientCommandId.AllegianceUnrecognizedSubcommand, "nope", "Please see @help Allegiance for more information on how to use this command.")]
[InlineData(ClientCommandId.HouseGuests, "add", "Please specify the guest's name.")]
[InlineData(ClientCommandId.HouseStorage, "add", "Please specify an actual name.")]
[InlineData(ClientCommandId.HouseBoot, "", "Please see @help House for more information on how to use this command.")]
[InlineData(ClientCommandId.HouseHooks, "maybe", "Please see @help House for more information on how to use this command.")]
[InlineData(ClientCommandId.HouseUnrecognizedSubcommand, "nope", "Please see @help House for more information on how to use this command.")]
public void AdministrationInvalidForms_EmitExactRetailClientLocalText(
ClientCommandId command,
string arguments,
string expected)
{
var calls = new List<string>();
var system = new List<string>();
var clientLocal = new List<string>();
ClientCommandController controller = NewController(
calls: calls,
messages: system,
clientLocalMessages: clientLocal);
controller.Execute(new ExecuteClientCommandCmd(command, arguments));
Assert.Empty(calls);
Assert.Empty(system);
Assert.Equal([expected], clientLocal);
}
private static ClientCommandController NewController(
List<string>? calls = null,
List<uint>? errors = null,
uint? playerBitfield = 0x02000028u,
List<string>? messages = null,
List<string>? clientLocalMessages = null,
bool isAway = false,
bool acceptsLootPermits = true,
FriendsState? friends = null,
@ -569,11 +914,16 @@ public sealed class ClientCommandControllerTests
// Defaults to "always accept" so every pre-existing single-stage
// test (Die, etc.) keeps its original behavior unchanged.
Queue<bool>? confirmationResponses = null,
Func<string, AcDream.Core.Chat.ChatLogResult>? chatLog = null)
Func<string, AcDream.Core.Chat.ChatLogResult>? chatLog = null,
Func<bool>? isPersistentDaylight = null,
Action<bool>? setPersistentDaylight = null,
Action<int>? setLandscapeRadius = null,
Action<float>? setFieldOfView = null)
{
calls ??= [];
errors ??= [];
messages ??= [];
clientLocalMessages ??= messages;
return new ClientCommandController(new ClientCommandController.Bindings(
() => calls.Add("ls"),
() => calls.Add("mp"),
@ -586,6 +936,7 @@ public sealed class ClientCommandControllerTests
() => calls.Add("fps"),
() => calls.Add("lock"),
messages.Add,
clientLocalMessages.Add,
errors.Add,
() => playerBitfield,
() => "1.2.3",
@ -653,6 +1004,61 @@ public sealed class ClientCommandControllerTests
channelId => calls.Add("off:" + channelId),
() => calls.Add("alh"),
name => calls.Add("alleginfo:" + name),
() => calls.Add("houseabandon")));
() => calls.Add("houseabandon"),
NewAdministrationBindings(calls),
isPersistentDaylight ?? (() => false),
setPersistentDaylight ?? (value => calls.Add("day:" + value)),
setLandscapeRadius ?? (value => calls.Add("radius:" + value)),
setFieldOfView ?? (value => calls.Add(
"fov:" + value.ToString(CultureInfo.InvariantCulture)))));
}
private static ClientCommandController.AdministrationBindings
NewAdministrationBindings(List<string> calls) => new(
BreakAllegianceBoot: (name, account) =>
calls.Add($"allegboot:{name}:{account}"),
AllegianceChatBoot: (name, reason) =>
calls.Add($"allegchatboot:{name}:{reason}"),
AllegianceChatGag: (name, enabled) =>
calls.Add($"allegchatgag:{name}:{enabled}"),
AllegianceBroadcast: text => calls.Add("allegbroadcast:" + text),
ListAllegianceBans: () => calls.Add("allegban:list"),
AddAllegianceBan: name => calls.Add("allegban:add:" + name),
RemoveAllegianceBan: name => calls.Add("allegban:remove:" + name),
ListAllegianceOfficers: () => calls.Add("allegofficer:list"),
ClearAllegianceOfficers: () => calls.Add("allegofficer:clear"),
SetAllegianceOfficer: (name, level) =>
calls.Add($"allegofficer:set:{level}:{name}"),
RemoveAllegianceOfficer: name =>
calls.Add("allegofficer:remove:" + name),
ListAllegianceOfficerTitles: () => calls.Add("allegtitle:list"),
ClearAllegianceOfficerTitles: () => calls.Add("allegtitle:clear"),
SetAllegianceOfficerTitle: (level, title) =>
calls.Add($"allegtitle:set:{level}:{title}"),
QueryAllegianceName: () => calls.Add("allegname:query"),
SetAllegianceName: name => calls.Add("allegname:set:" + name),
ClearAllegianceName: () => calls.Add("allegname:clear"),
AllegianceLockAction: action => calls.Add("alleglock:" + action),
SetAllegianceApprovedVassal: name =>
calls.Add("alleglock:bypass:" + name),
AllegianceHouseAction: action => calls.Add("alleghouse:" + action),
QueryMotd: () => calls.Add("motd:query"),
SetMotd: text => calls.Add("motd:set:" + text),
ClearMotd: () => calls.Add("motd:clear"),
SetOpenHouseStatus: open => calls.Add("houseopen:" + open),
AddPermanentGuest: name => calls.Add("houseguest:add:" + name),
RemovePermanentGuest: name => calls.Add("houseguest:remove:" + name),
RemoveAllPermanentGuests: () => calls.Add("houseguest:remove_all"),
ChangeStoragePermission: (name, enabled) =>
calls.Add($"housestorage:{enabled}:{name}"),
AddAllStoragePermission: () => calls.Add("housestorage:add_all"),
RemoveAllStoragePermission: () => calls.Add("housestorage:remove_all"),
RequestFullGuestList: () => calls.Add("houseguest:list"),
BootSpecificHouseGuest: name => calls.Add("houseboot:" + name),
BootEveryone: () => calls.Add("houseboot:all"),
SetHooksVisibility: visible => calls.Add("househooks:" + visible),
ModifyAllegianceGuestPermission: enabled =>
calls.Add("houseguest:allegiance:" + enabled),
ModifyAllegianceStoragePermission: enabled =>
calls.Add("housestorage:allegiance:" + enabled));
}

View file

@ -1059,8 +1059,8 @@ public sealed class CharacterCreationLiveDatTests
/// GF-13: the BEHAVIOR half — after the real controller mounts through
/// <see cref="CharacterCreationUiController.CreateDetached"/> (not a raw
/// <see cref="LayoutImporter.Build"/> call), the two authored-invisible
/// elements are not <see cref="UiElement.Visible"/>. Exercises
/// <c>HideAuthoredInvisibleElements</c>'s real chargen-scoped honor path,
/// elements are not <see cref="UiElement.Visible"/>. Exercises the shared
/// importer-wide #408 behavior through a real chargen controller mount,
/// not just the data plumbing the sibling test above pins.
/// </summary>
[InstalledDatFact]

View file

@ -446,7 +446,7 @@ public sealed class CharacterCreationUiControllerTests
/// Center default.
/// </summary>
[Fact]
public void SkillsPage_InfoBoxPanes_ForceTopVerticalJustify_ToAvoidTitleDescriptionOverlap()
public void SkillsPage_InfoBoxPanes_InheritRetailTopDefault_ToAvoidTitleDescriptionOverlap()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();

View file

@ -168,6 +168,31 @@ public sealed class CharacterManagementUiControllerTests
string.Join(" ", worldText.LinesProvider().Select(static line => line.Text)));
}
[Fact]
public void CreditsButton_QueuesTheCreditsMode_AndPresentationCanBeSuppressedForIt()
{
int openCalls = 0;
using var environment = new EnvironmentHarness(() => openCalls++);
CharacterManagementUiController controller = environment.Controller;
UiButton credits = environment.Button(
CharacterManagementUiController.CreditsElementId);
Assert.True(credits.Visible);
Assert.True(credits.Enabled);
Assert.NotNull(credits.OnClick);
credits.OnClick!();
Assert.Equal(1, openCalls);
controller.SetPresentationSuppressed(true);
Assert.False(controller.Root.Visible);
Assert.Null(environment.Host.FixedCanvasSize);
controller.SetPresentationSuppressed(false);
Assert.True(controller.Root.Visible);
Assert.Equal(new Vector2(800f, 600f), environment.Host.FixedCanvasSize);
Assert.Equal(3, controller.Rows.Count);
}
[Fact]
public void RowHeight_UsesAllowedSlotsAndClampsAtOneTenthForLargeRosters()
{
@ -882,7 +907,7 @@ public sealed class CharacterManagementUiControllerTests
private sealed class EnvironmentHarness : IDisposable
{
public EnvironmentHarness()
public EnvironmentHarness(Action? openCredits = null)
{
Host = new UiRoot { Width = 800f, Height = 600f };
Screen = BuildScreen();
@ -901,7 +926,8 @@ public sealed class CharacterManagementUiControllerTests
static (_, _) => BuildRow(),
Dialogs,
Runtime.Bindings,
TestStrings()));
TestStrings(),
openCredits));
}
public UiRoot Host { get; }

View file

@ -1115,7 +1115,7 @@ public sealed class ConfigOptionsPageControllerTests
2, // 19 Landscape Texture Detail
1, // 20 Environment Texture Detail
1, // 21 Texture Filtering
8, // 22 Landscape Draw Distance (opaque — AP-198 sub-note)
8, // 22 Landscape Draw Distance (retail radius payload)
true, // 23 Building Detail Textures
false, // 24 Multi-Pass Alpha
@ -1418,7 +1418,7 @@ public sealed class ConfigOptionsPageControllerTests
(23, RowKind.Menu, true, "Landscape Texture Detail"), // AP-198
(24, RowKind.Menu, true, "Environment Texture Detail"), // AP-198
(25, RowKind.Menu, true, "Texture Filtering"), // AP-198
(26, RowKind.Menu, true, "Landscape Draw Distance"), // AP-198
(26, RowKind.Menu, false, "Landscape Draw Distance"), // LIVE — #361
(27, RowKind.Toggle, false, "Building Detail Textures"), // LIVE — #226
(28, RowKind.Toggle, true, "Multi-Pass Alpha"), // AP-198
(31, RowKind.Slider, true, "Mouse Look Sensitivity"), // TS-74
@ -1485,6 +1485,27 @@ public sealed class ConfigOptionsPageControllerTests
}
}
[Fact]
public void LandscapeDrawDistance_UsesRetailRadiusPayloads_AndAppliesSelection()
{
(OptionsPanelController controller, FakeBindings bindings, bool bound) = BindReal();
Assert.True(bound);
var configSlot = UiElement.FindDescendant(controller.TabPanel, ConfigPageSlotId)!;
List<UiMenu> menus = CollectMenus(configSlot);
UiMenu drawDistance = menus[5];
Assert.Equal(8, drawDistance.Selected);
Assert.Equal(
[3, 5, 8, 11, 15, 25],
drawDistance.Items.Select(item => Assert.IsType<int>(item.Payload)).ToArray());
drawDistance.OnSelect!(25);
controller.ConfigPage.Apply();
Assert.Equal(25, bindings.Display.LandscapeDrawDistance);
}
// ── #412-class regression: Config tab content escaping the window frame ──
//
// 2026-08-16/17 overnight hover/UI round, Batch A bug 2. The user's

View file

@ -0,0 +1,110 @@
using AcDream.App.UI.Layout;
using AcDream.App.UI;
using AcDream.Content;
using DatReaderWriter;
using DatReaderWriter.Options;
namespace AcDream.App.Tests.UI.Layout;
/// <summary>
/// Installed-retail-DAT oracle for issue #400's <c>gmCreditsUI</c> port.
/// Retail creates two independent enum-table-5 roots: the picture strip
/// and the scrolling text field.
/// </summary>
[Trait("Lane", "InstalledDat")]
public sealed class CreditsLiveDatTests
{
[InstalledDatFact]
public void Category4_ResolvesAuthoredPictureAndTextRoots()
{
string datDirectory = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR")
?? Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
"Documents",
"Asheron's Call");
using var dats = new DatCollection(datDirectory, DatAccessType.Read);
// CreateAndAddRootElement(layoutEnum=0x10000004, rootElementId)
// uses the ordinary table-5 layout map, exactly like character
// management's (0x10000005, 0x1000039A) pair.
uint pictureLayout = RetailDataIdResolver.Resolve(dats, 0x10000004u, 5u);
Assert.Equal(0x21000003u, pictureLayout);
ElementInfo picture = Assert.IsType<ElementInfo>(
LayoutImporter.ImportInfos(dats, pictureLayout, 0x10000413u));
ElementInfo text = Assert.IsType<ElementInfo>(
LayoutImporter.ImportInfos(dats, pictureLayout, 0x10000410u));
Assert.Equal(CreditsUiController.PictureRootElementId, picture.Id);
Assert.Equal(3u, picture.Type);
Assert.Equal((0f, 0f, 400f, 600f),
(picture.X, picture.Y, picture.Width, picture.Height));
Assert.Equal(CreditsUiController.TextRootElementId, text.Id);
Assert.Equal(3u, text.Type);
Assert.Equal((400f, 0f, 400f, 600f),
(text.X, text.Y, text.Width, text.Height));
Assert.True(TryDataId(text, 0x10000002u, out uint textAreaId));
Assert.Equal(CreditsUiController.TextAreaElementId, textAreaId);
Assert.True(TryDataId(text, 0x10000003u, out uint stringTableId));
Assert.Equal(0x23000008u, stringTableId);
Assert.True(text.TryGetEffectiveFloat(0x10000004u, out float seconds));
Assert.Equal(20f, seconds);
ElementInfo textArea = Assert.Single(text.Children);
Assert.Equal(CreditsUiController.TextAreaElementId, textArea.Id);
Assert.Equal(12u, textArea.Type);
Assert.Equal(0x40000000u, textArea.FontDid);
Assert.Equal(HJustify.Center, textArea.HJustify);
Assert.Equal(VJustify.Center, textArea.VJustify);
Assert.True(picture.TryGetEffectiveProperty(
0x10000005u,
out UiPropertyValue pictureArray));
Assert.Equal(UiPropertyKind.Array, pictureArray.Kind);
Assert.Equal(
Enumerable.Range(0, 7).Select(index => 0x06005F14u + (uint)index),
pictureArray.ArrayValue.Select(static value => (uint)value.UnsignedValue));
ImportedLayout builtText = Assert.IsType<ImportedLayout>(
LayoutImporter.Import(
dats,
pictureLayout,
CreditsUiController.TextRootElementId,
static id => (id, 1, 1),
null));
Assert.IsType<UiText>(builtText.FindElement(
CreditsUiController.TextAreaElementId));
var strings = new DatStringResolver(dats);
int creditsCount = 0;
for (int i = 1; i <= 4096; i++)
{
string? value = strings.Resolve(
stringTableId,
DatStringResolver.ComputeHash($"ID_Credits{i}"));
if (value is null)
break;
creditsCount++;
}
Assert.Equal(2345, creditsCount);
Assert.NotNull(strings.Resolve(
0x23000001u,
DatStringResolver.ComputeHash("ID_Wait_PleaseWait")));
}
private static bool TryDataId(
ElementInfo info,
uint propertyId,
out uint value)
{
if (info.TryGetEffectiveProperty(propertyId, out UiPropertyValue property)
&& property.Kind is UiPropertyKind.DataId or UiPropertyKind.Enum)
{
value = checked((uint)property.UnsignedValue);
return true;
}
value = 0u;
return false;
}
}

View file

@ -0,0 +1,166 @@
using System.Numerics;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
namespace AcDream.App.Tests.UI.Layout;
public sealed class CreditsUiControllerTests
{
[Fact]
public void Activate_UsesAuthoredCanvas_TextTiming_AndCyclicPictureStrip()
{
using var environment = new EnvironmentHarness();
CreditsUiController controller = environment.Controller;
controller.Activate();
Assert.True(controller.IsActive);
Assert.Equal(new Vector2(800f, 600f), environment.Host.FixedCanvasSize);
Assert.True(controller.PictureRoot.Visible);
Assert.True(controller.TextRoot.Visible);
Assert.Equal(600f, controller.TextArea.Top);
Assert.Equal(48f, controller.TextArea.Height);
Assert.Single(controller.Pictures);
Assert.Equal(601f, controller.Pictures[0].Top);
Assert.Equal(0x06000001u, controller.Pictures[0].BackgroundSprite);
Assert.Equal(
20d * (600d + 48d) / (600d + 48d / 3d),
controller.DurationSeconds,
precision: 5);
environment.Now += controller.DurationSeconds * 0.5d;
controller.Tick();
Assert.Equal(276f, controller.TextArea.Top);
Assert.True(controller.Pictures[0].Top < 600f);
Assert.Equal(2, controller.Pictures.Count);
Assert.Equal(0x06000002u, controller.Pictures[1].BackgroundSprite);
Assert.Equal(
controller.Pictures[0].Top + controller.Pictures[0].Height + 1f,
controller.Pictures[1].Top);
}
[Fact]
public void AnyKey_ShowsWaitForAFrame_ThenReturnsToCharacterManagement()
{
using var environment = new EnvironmentHarness();
CreditsUiController controller = environment.Controller;
controller.Activate();
environment.Host.OnKeyDown(123);
Assert.Equal(1, environment.Dialogs.ActiveCount);
controller.Tick();
Assert.True(controller.IsActive);
Assert.Equal(0, environment.ReturnCalls);
controller.Tick();
Assert.False(controller.IsActive);
Assert.Equal(1, environment.ReturnCalls);
Assert.Equal(0, environment.Dialogs.ActiveCount);
Assert.Null(environment.Host.FixedCanvasSize);
Assert.False(controller.PictureRoot.Visible);
Assert.False(controller.TextRoot.Visible);
}
[Fact]
public void NaturalCompletion_UsesTheSameWaitAndReturnPath()
{
using var environment = new EnvironmentHarness();
CreditsUiController controller = environment.Controller;
controller.Activate();
environment.Now += controller.DurationSeconds;
controller.Tick();
Assert.Equal(1, environment.Dialogs.ActiveCount);
controller.Tick();
Assert.True(controller.IsActive);
controller.Tick();
Assert.False(controller.IsActive);
Assert.Equal(1, environment.ReturnCalls);
Assert.Equal(0, environment.Dialogs.ActiveCount);
}
private sealed class EnvironmentHarness : IDisposable
{
public EnvironmentHarness()
{
Host = new UiRoot { Width = 1280f, Height = 720f };
Dialogs = new RetailDialogFactory(
Host,
RetailDialogFactoryTests.BuildDialogLayout);
CreditsUiResources resources = BuildResources();
Controller = Assert.IsType<CreditsUiController>(
CreditsUiController.CreateDetached(
Host,
resources,
Dialogs,
() => Now,
ResolveSprite,
() => ReturnCalls++));
}
public UiRoot Host { get; }
public RetailDialogFactory Dialogs { get; }
public CreditsUiController Controller { get; }
public double Now { get; set; } = 100d;
public int ReturnCalls { get; private set; }
public void Dispose()
{
Controller.Dispose();
Dialogs.Dispose();
}
private static (uint tex, int w, int h) ResolveSprite(uint id)
=> (id, 400, 300);
private static CreditsUiResources BuildResources()
{
ImportedLayout picture = LayoutImporter.Build(
new ElementInfo
{
Id = CreditsUiController.PictureRootElementId,
Type = 3u,
X = 0f,
Y = 0f,
Width = 400f,
Height = 600f,
},
ResolveSprite,
null);
var textRoot = new ElementInfo
{
Id = CreditsUiController.TextRootElementId,
Type = 3u,
X = 400f,
Y = 0f,
Width = 400f,
Height = 600f,
};
textRoot.Children.Add(new ElementInfo
{
Id = CreditsUiController.TextAreaElementId,
Type = 12u,
Width = 0f,
Height = 0f,
HJustify = HJustify.Center,
VJustify = VJustify.Center,
});
ImportedLayout text = LayoutImporter.Build(
textRoot,
ResolveSprite,
null);
return new CreditsUiResources(
0x21000003u,
picture,
text,
["One\n", "Two\n"],
[0x06000001u, 0x06000002u],
20f,
"Please Wait");
}
}
}

View file

@ -1111,7 +1111,7 @@ public class DatWidgetFactoryTests
// ── Justification build-time application (new for importer Fix A) ────────
/// <summary>
/// A Type-12 text element with HJustify=Center (the default) must produce a
/// A Type-12 text element with authored HJustify=Center must produce a
/// UiText with Centered=true and RightAligned=false at build time.
/// This proves BuildText applies the dat's HJustify at construction without a
/// controller binding step.
@ -1123,7 +1123,18 @@ public class DatWidgetFactoryTests
var t = Assert.IsType<UiText>(DatWidgetFactory.Create(info, NoTex, null));
Assert.True(t.Centered);
Assert.False(t.RightAligned);
Assert.Equal(VJustify.Center, t.VerticalJustify);
Assert.Equal(VJustify.Top, t.VerticalJustify);
}
[Fact]
public void BuildText_UnauthoredJustification_UsesRetailNearEdges()
{
var info = new ElementInfo { Type = 12, Width = 100, Height = 20 };
var t = Assert.IsType<UiText>(DatWidgetFactory.Create(info, NoTex, null));
Assert.False(t.Centered);
Assert.False(t.RightAligned);
Assert.Equal(VJustify.Top, t.VerticalJustify);
}
/// <summary>

View file

@ -182,40 +182,40 @@ public class ElementReaderTests
// ── HJustify / VJustify — Merge propagation ─────────────────────────────
/// <summary>
/// When the derived element has a non-Center HJustify, the derived value wins
/// (same "non-default wins" rule as FontDid).
/// Authored justification is presence-based: raw 3 on the derived element
/// overrides raw 1 on its base.
/// </summary>
[Fact]
public void Merge_DerivedHJustifyRight_OverridesBaseCenter()
{
var base_ = new ElementInfo { HJustify = HJustify.Center };
var derived = new ElementInfo { HJustify = HJustify.Right };
ElementInfo base_ = WithJustification(0x14u, 1u);
ElementInfo derived = WithJustification(0x14u, 3u);
var merged = ElementReader.Merge(base_, derived);
Assert.Equal(HJustify.Right, merged.HJustify);
}
/// <summary>
/// When the derived element has the default HJustify (Center), the base value
/// is inherited — Center from the derived does NOT override a Left base.
/// Center is a real authored raw value, not an unset sentinel, and must
/// therefore override a Left base.
/// </summary>
[Fact]
public void Merge_DerivedHJustifyCenter_InheritsBaseLeft()
public void Merge_DerivedAuthoredHJustifyCenter_OverridesBaseLeft()
{
var base_ = new ElementInfo { HJustify = HJustify.Left };
var derived = new ElementInfo { HJustify = HJustify.Center }; // default — no explicit dat property
ElementInfo base_ = WithJustification(0x14u, 2u);
ElementInfo derived = WithJustification(0x14u, 1u);
var merged = ElementReader.Merge(base_, derived);
Assert.Equal(HJustify.Left, merged.HJustify);
Assert.Equal(HJustify.Center, merged.HJustify);
}
/// <summary>
/// VJustify=Top from the base propagates when the derived element has no explicit
/// (Center) vertical justification.
/// VJustify=Top from the base propagates when the derived element authors
/// no vertical justification.
/// </summary>
[Fact]
public void Merge_BaseVJustifyTop_InheritedWhenDerivedIsCenter()
public void Merge_BaseVJustifyTop_InheritedWhenDerivedIsUnauthored()
{
var base_ = new ElementInfo { VJustify = VJustify.Top };
var derived = new ElementInfo { VJustify = VJustify.Center }; // default
ElementInfo base_ = WithJustification(0x15u, 4u);
var derived = new ElementInfo();
var merged = ElementReader.Merge(base_, derived);
Assert.Equal(VJustify.Top, merged.VJustify);
}
@ -226,8 +226,8 @@ public class ElementReaderTests
[Fact]
public void Merge_DerivedVJustifyBottom_OverridesBaseCenter()
{
var base_ = new ElementInfo { VJustify = VJustify.Center };
var derived = new ElementInfo { VJustify = VJustify.Bottom };
ElementInfo base_ = WithJustification(0x15u, 1u);
ElementInfo derived = WithJustification(0x15u, 3u);
var merged = ElementReader.Merge(base_, derived);
Assert.Equal(VJustify.Bottom, merged.VJustify);
}
@ -380,6 +380,54 @@ public class ElementReaderTests
return info;
}
private static ElementInfo WithJustification(uint propertyId, uint raw)
{
ElementInfo info = WithDirectProperty(propertyId, EnumProp(raw));
ElementReader.ApplyCanonicalLegacyProjection(info);
return info;
}
[Fact]
public void Justification_UnauthoredDefaultsMatchRetailConstructors()
{
var info = new ElementInfo();
Assert.Equal(HJustify.Left, info.HJustify);
Assert.Equal(VJustify.Top, info.VJustify);
}
[Theory]
[InlineData(0u, HJustify.Left)]
[InlineData(1u, HJustify.Center)]
[InlineData(2u, HJustify.Left)]
[InlineData(3u, HJustify.Right)]
[InlineData(4u, HJustify.Left)]
[InlineData(5u, HJustify.Right)]
public void HorizontalJustification_AllRetailRawValues(
uint raw,
HJustify expected)
{
ElementInfo info = WithJustification(0x14u, raw);
Assert.Equal(expected, info.HJustify);
}
[Theory]
[InlineData(0u, VJustify.Top)]
[InlineData(1u, VJustify.Center)]
[InlineData(2u, VJustify.Top)]
[InlineData(3u, VJustify.Bottom)]
[InlineData(4u, VJustify.Top)]
[InlineData(5u, VJustify.Bottom)]
public void VerticalJustification_AllRetailRawValues(
uint raw,
VJustify expected)
{
ElementInfo info = WithJustification(0x15u, raw);
Assert.Equal(expected, info.VJustify);
}
[Fact]
public void ReadTabTable_DecodesButtonPageDefaultInAuthoredOrder()
{

View file

@ -0,0 +1,106 @@
using System.IO;
using System.Linq;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Content;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Options;
namespace AcDream.App.Tests.UI.Layout;
/// <summary>
/// #408 client-wide blast-radius gate for DAT property 0x3B (Invisible).
/// Retail applies the property in UIElement::OnSetAttribute for every imported
/// element. Enumerate every installed LayoutDesc, then prove every corresponding
/// widget which survives importer child-consumption starts hidden.
/// </summary>
[Trait("Lane", "InstalledDat")]
public sealed class LayoutImporterInvisibleSweepTests
{
private static string DatDirectory =>
System.Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR")
?? Path.Combine(
System.Environment.GetFolderPath(System.Environment.SpecialFolder.UserProfile),
"Documents",
"Asheron's Call");
private readonly record struct Finding(uint LayoutId, uint ElementId);
[InstalledDatFact]
public void EveryAuthoredInvisibleWidget_StartsHiddenAcrossAllLayouts()
{
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
var authored = new List<Finding>();
var built = new List<Finding>();
var incorrectlyVisible = new List<Finding>();
foreach (uint layoutId in dats.GetAllIdsOfType<LayoutDesc>().OrderBy(static id => id))
{
ElementInfo? tree = LayoutImporter.ImportInfos(dats, layoutId);
if (tree is null) continue;
CollectAuthored(layoutId, tree, authored);
ImportedLayout layout = LayoutImporter.Build(
tree, _ => (0u, 0, 0), datFont: null, sourceLayoutDid: layoutId);
CollectBuilt(layoutId, layout.Root, built, incorrectlyVisible);
}
foreach (IGrouping<uint, Finding> group in authored
.GroupBy(static f => f.LayoutId)
.OrderBy(static g => g.Key))
{
Console.WriteLine(
$"[INVISIBLE] layout=0x{group.Key:X8} count={group.Count()} ids=["
+ string.Join(",", group.Select(static f => $"0x{f.ElementId:X8}"))
+ "]");
}
Console.WriteLine(
$"[INVISIBLE] authored={authored.Count} layouts="
+ $"{authored.Select(static f => f.LayoutId).Distinct().Count()} "
+ $"built={built.Count} incorrectlyVisible={incorrectlyVisible.Count}");
// Keep the global threshold resilient to an installed DAT revision while
// pinning landmarks from independent screens in both data and widgets.
Assert.True(authored.Count >= 1_000,
$"Expected the known client-wide 0x3B population, found {authored.Count}.");
Assert.Contains(authored, static f => f.ElementId == 0x10000403u); // chargen GM label
Assert.Contains(authored, static f => f.ElementId == 0x10000494u); // chargen envoy label
Assert.Contains(authored, static f => f.ElementId == 0x100006A4u); // combat root
Assert.Contains(authored, static f => f.ElementId == 0x1000048Cu); // chat new-text indicator
Assert.Contains(built, static f => f.ElementId == 0x100006A4u);
Assert.Contains(built, static f => f.ElementId == 0x1000048Cu);
Assert.Empty(incorrectlyVisible);
}
private static void CollectAuthored(uint layoutId, ElementInfo node, List<Finding> findings)
{
if (node.Invisible)
findings.Add(new Finding(layoutId, node.Id));
foreach (ElementInfo child in node.Children)
CollectAuthored(layoutId, child, findings);
}
private static void CollectBuilt(
uint layoutId,
UiElement node,
List<Finding> built,
List<Finding> incorrectlyVisible)
{
if (node.AuthoredInvisible)
{
var finding = new Finding(layoutId, node.DatElementId);
built.Add(finding);
if (node.Visible)
incorrectlyVisible.Add(finding);
}
foreach (UiElement child in node.Children)
CollectBuilt(layoutId, child, built, incorrectlyVisible);
}
}

View file

@ -363,6 +363,29 @@ public class LayoutImporterTests
Assert.Null(found.AuthoredTooltipDelaySeconds);
}
[Fact]
public void BuildWidget_AuthoredInvisible_HidesInitiallyButDoesNotLatchVisibility()
{
var root = new ElementInfo { Id = 0x1, Type = 3, Width = 100, Height = 40 };
var hidden = new ElementInfo
{
Id = 0x2,
Type = 3,
Width = 20,
Height = 20,
Invisible = true,
};
ImportedLayout tree = LayoutImporter.BuildFromInfos(root, [hidden], NoTex, null);
UiElement found = tree.FindElement(0x2)!;
Assert.True(found.AuthoredInvisible);
Assert.False(found.Visible);
found.Visible = true;
Assert.True(found.Visible);
}
// ── Helpers ───────────────────────────────────────────────────────────────
private static ElementInfo BuildSliceContainer(uint id, uint ReadOrder, uint l, uint t, uint r)

View file

@ -1,8 +1,10 @@
using System.Linq;
using System.Numerics;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Core.Net.Messages;
using AcDream.Core.World;
using AcDream.Runtime.Gameplay;
namespace AcDream.App.Tests.UI.Layout;
@ -124,7 +126,18 @@ public sealed class MapHousePanelControllerTests
/// UiButton hotspots above — a fresh instance per call, matching
/// production's real resolver.</summary>
private static UiElement? FakeHouseRowTemplate(uint layoutId, uint elementId)
=> new UiText { Width = 280f, Height = 28f };
=> new UiText
{
Width = 280f,
Height = 28f,
DefaultColor = new Vector4(0.1f, 0.1f, 0.1f, 1f),
FontColorPalette =
[
new Vector4(1f, 1f, 1f, 1f),
new Vector4(0f, 1f, 0f, 1f),
new Vector4(1f, 0f, 0f, 1f),
],
};
private static MapHousePanelController.Callbacks MakeCallbacks(
List<string>? calls = null,
@ -132,7 +145,8 @@ public sealed class MapHousePanelControllerTests
Func<uint>? playerCellId = null,
Func<CreateObject.ServerPosition?>? housePosition = null,
Func<IReadOnlyList<string>>? houseLines = null,
Func<uint, uint, ElementInfo?>? templateInfoResolver = null)
Func<uint, uint, ElementInfo?>? templateInfoResolver = null,
Func<IReadOnlyList<HousePanelLine>>? housePanelLines = null)
{
calls ??= new List<string>();
return new MapHousePanelController.Callbacks(
@ -147,7 +161,8 @@ public sealed class MapHousePanelControllerTests
House: new HousePageController.Bindings(
Lines: houseLines ?? (static () => Array.Empty<string>()),
OnShown: () => calls.Add("house-shown"),
TemplateResolver: FakeHouseRowTemplate));
TemplateResolver: FakeHouseRowTemplate,
PanelLines: housePanelLines));
}
[Fact]
@ -377,4 +392,35 @@ public sealed class MapHousePanelControllerTests
"You may buy another house immediately.",
Assert.Single(row.LinesProvider()).Text);
}
[Fact]
public void Tick_UsesRetailHousePanelColorAsAuthoredPaletteIndex()
{
ElementInfo rootInfo = FixtureLoader.LoadMapHouseHostInfos();
ImportedLayout layout = FixtureLoader.LoadMapHouseHost();
HousePanelLine[] lines =
[
new("paid", HousePanelTextColor.RentPaid),
new("unpaid", HousePanelTextColor.RentNotPaid),
];
MapHousePanelController? controller = MapHousePanelController.Bind(
rootInfo,
layout,
MakeCallbacks(housePanelLines: () => lines));
Assert.NotNull(controller);
controller!.Tick(0.016);
var listBox = Assert.IsType<UiTemplateListBox>(
UiElement.FindDescendant(controller.Root, HousePageController.TextBoxId));
UiScrollablePanel viewport = Assert.IsType<UiScrollablePanel>(
listBox.ViewportForTest);
Assert.Equal(2, viewport.Children.Count);
Assert.Equal(
new Vector4(0f, 1f, 0f, 1f),
Assert.Single(Assert.IsType<UiText>(viewport.Children[0]).LinesProvider()).Color);
Assert.Equal(
new Vector4(1f, 0f, 0f, 1f),
Assert.Single(Assert.IsType<UiText>(viewport.Children[1]).LinesProvider()).Color);
}
}

View file

@ -313,9 +313,11 @@ public sealed class OptionsPanelLiveMountProbeTests
/// LayoutDesc, and <c>Open @0x0046cc30</c> centers the POPUP over the
/// button when bool attr 3 is authored. This probe measures all of that
/// authored data for the Config option-menu chain (catalog 0x21000043,
/// base 0x10000353) with vendor's dropdown (0x1000034F chain — visibly a
/// FIXED 6-row + scrollbar popup in retail, user-gated during the vendor
/// campaign) as the contrast control.</summary>
/// base 0x10000353) and the vendor dropdown's sibling chain
/// (0x1000034F). The latter was once treated as a fixed-six-row contrast;
/// #386's named-retail trace corrected that reading: it follows the same
/// docked size-to-content message route and authors scrollbar property
/// 0x79 (hide when disabled).</summary>
[Fact]
[Trait("Purpose", "Diagnostic")]
public void ProbeMenuPopupSizingAndTextStyle()
@ -371,7 +373,7 @@ public sealed class OptionsPanelLiveMountProbeTests
Console.WriteLine("[menuprobe3] row template 0x1000035A FAILED to import");
}
Console.WriteLine("[menuprobe3] === CONTROL: vendor chain (fixed 6-row + scrollbar in retail) ===");
Console.WriteLine("[menuprobe3] === CONTROL: vendor chain (content-sized; scrollbar 0x79 hides when disabled) ===");
DumpDockAndSize(dats, 0x21000043u, 0x1000034Fu, "Vendor popup root");
DumpDockAndSize(dats, 0x21000043u, 0x10000350u, "Vendor popup ListBox");
ElementInfo? vendorBase = LayoutImporter.ImportInfos(dats, 0x21000043u, 0x1000034Bu);

View file

@ -18,6 +18,11 @@ public sealed class SpellcastingUiControllerTests
ImportedLayout layout = LayoutImporter.Build(
FixtureLoader.LoadCombatInfos(), NoTex, datFont: null);
RetailCombatLayout.FitFavoriteSlots(layout);
// The authored combat root starts Invisible=true. Production's
// RetailWindowFrame/CombatUiController show it when combat mode opens
// the window; this standalone pointer fixture must model that mount
// edge explicitly now that #408 honors the DAT flag client-wide.
layout.Root.Visible = true;
var spellbook = new Spellbook();
spellbook.OnSpellLearned(42u, 1f);
spellbook.SetFavorite(0, 0, 42u);
@ -199,6 +204,7 @@ public sealed class SpellcastingUiControllerTests
ImportedLayout layout = LayoutImporter.Build(
FixtureLoader.LoadCombatInfos(), NoTex, datFont: null);
RetailCombatLayout.FitFavoriteSlots(layout);
layout.Root.Visible = true;
var spellbook = new Spellbook();
spellbook.OnSpellLearned(1u, 1f);
spellbook.OnSpellLearned(2u, 1f);
@ -468,6 +474,7 @@ public sealed class SpellcastingUiControllerTests
ImportedLayout layout = LayoutImporter.Build(
FixtureLoader.LoadCombatInfos(), NoTex, datFont: null);
RetailCombatLayout.FitFavoriteSlots(layout);
layout.Root.Visible = true;
var spellbook = new Spellbook();
spellbook.OnSpellLearned(1u, 1f);
spellbook.OnSpellLearned(2u, 1f);

View file

@ -247,13 +247,12 @@ public class UiDatElementTests
}
/// <summary>
/// The state-0 fallback must NOT honor a base-DirectState Invisible
/// (dat 0x3B) — that is the #408 construction-time class, gated
/// separately; retail's per-state Invisible honor applies to NAMED
/// authored states only.
/// #408: retail's unauthored-state fallback commits state 0 and applies
/// its properties, including Invisible, through the same OnSetAttribute
/// path as a named state.
/// </summary>
[Fact]
public void TrySetRetailState_UnauthoredStateFallback_DoesNotHonorBaseInvisible()
public void TrySetRetailState_UnauthoredStateFallback_RestoresBaseInvisible()
{
var info = new ElementInfo();
var baseState = new UiStateInfo { Id = UiStateInfo.DirectStateId };
@ -265,12 +264,12 @@ public class UiDatElementTests
info.States[UiStateInfo.DirectStateId] = baseState;
info.StateMedia["Normal_rollover"] = (0x06005EB6u, 1);
var element = new UiDatElement(info, _ => (0u, 0, 0));
Assert.True(element.Visible);
element.Visible = true;
Assert.True(element.TrySetRetailState(UiButtonStateMachine.Normal));
Assert.Equal("", element.ActiveState);
Assert.True(element.Visible);
Assert.False(element.Visible);
}
/// <summary>

View file

@ -1005,6 +1005,10 @@ public sealed class VendorUiControllerTests
Assert.Equal(6, h.TypeMenu.RowsPerColumn);
Assert.Equal(18f, h.TypeMenu.RowHeight);
Assert.Equal(100f, h.TypeMenu.ColumnWidth);
Assert.True(h.TypeMenu.PopupSizeToContent);
Assert.True(h.TypeMenu.PopupScrollbarHideWhenDisabled);
Assert.Equal(2 * h.TypeMenu.RowHeight + 2 * 5f, h.TypeMenu.PopupOuterHeight);
Assert.Equal(h.TypeMenu.ColumnWidth + 2 * 5f, h.TypeMenu.PopupOuterWidth);
// Open via the real widget event path.
Assert.True(h.TypeMenu.OnEvent(new UiEvent(0, h.TypeMenu, UiEventType.MouseDown, 0, 10, 5)));
@ -1054,7 +1058,7 @@ public sealed class VendorUiControllerTests
// where nothing lives; UiMenu treats it as an ordinary button click
// and just re-closes the still-open menu instead of picking a row.
const int border = 5;
float outerH = h.TypeMenu.RowsPerColumn * h.TypeMenu.RowHeight + 2 * border;
float outerH = h.TypeMenu.PopupOuterHeight;
const int targetRow = 1;
float iy = targetRow * h.TypeMenu.RowHeight + h.TypeMenu.RowHeight / 2f;
float oldUpwardLy = iy - outerH + border;

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