Compare commits
7 commits
codex/pak-
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e102fb363e | ||
|
|
8acb544d44 | ||
|
|
cd1cdee0e5 | ||
|
|
fc30285fd7 | ||
|
|
684380d421 | ||
|
|
85530c0b7e | ||
|
|
f7aa8e0eb7 |
150 changed files with 9087 additions and 1506 deletions
|
|
@ -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 |
|
||||
|
|
|
|||
1083
docs/ISSUES.md
1083
docs/ISSUES.md
File diff suppressed because it is too large
Load diff
File diff suppressed because one or more lines are too long
|
|
@ -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`) |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
68
docs/research/2026-08-28-issue178-cell-shell-culling.md
Normal file
68
docs/research/2026-08-28-issue178-cell-shell-culling.md
Normal 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”).
|
||||
141
docs/research/2026-08-28-open-issue-validity-audit.md
Normal file
141
docs/research/2026-08-28-open-issue-validity-audit.md
Normal 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 | CA2–CA4 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.
|
||||
108
docs/research/2026-08-28-owner-closed-issue-validity-audit.md
Normal file
108
docs/research/2026-08-28-owner-closed-issue-validity-audit.md
Normal 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`.
|
||||
|
|
@ -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,
|
||||
|
|
@ -758,7 +762,8 @@ internal sealed class FrameRootCompositionPhase
|
|||
var framePreparation = new RenderFramePreparationController(
|
||||
renderFrameResources,
|
||||
devTools: null,
|
||||
renderWeatherFrame);
|
||||
renderWeatherFrame,
|
||||
live.PaperdollPresenter);
|
||||
IRenderFramePostDiagnosticsPhase postDiagnostics =
|
||||
renderSceneShadowComparison is not null
|
||||
&& lifecycleAutomation is not null
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,17 +2814,11 @@ 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,
|
||||
|
|
@ -2869,7 +2831,6 @@ internal sealed class LiveEntityNetworkUpdateController
|
|||
_origin.CenterX,
|
||||
_origin.CenterY));
|
||||
}
|
||||
}
|
||||
|
||||
// F751 is only a notification gate; the accepted Position may arrive
|
||||
// before or after it. Canonical physics above always consumes the
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ internal interface IPaperdollFrameView
|
|||
bool TryGetVisibleSize(out int width, out int height);
|
||||
|
||||
void SetTextureHandle(uint textureHandle);
|
||||
|
||||
void ClearTextureHandle();
|
||||
}
|
||||
|
||||
internal interface IPaperdollInventoryVisibility
|
||||
|
|
@ -51,7 +53,9 @@ internal interface IPaperdollPoseApplicator
|
|||
/// presentation edge. The renderer remains a borrowed resource disposed by
|
||||
/// the existing window shutdown transaction.
|
||||
/// </summary>
|
||||
internal sealed class PaperdollFramePresenter : IPrivateEntityViewportFrame
|
||||
internal sealed class PaperdollFramePresenter :
|
||||
IPrivateEntityViewportFrame,
|
||||
IPrivateEntityViewportResourcePreparation
|
||||
{
|
||||
private readonly IPaperdollDollRenderer _renderer;
|
||||
private readonly IPaperdollFrameView _view;
|
||||
|
|
@ -73,22 +77,30 @@ internal sealed class PaperdollFramePresenter : IPrivateEntityViewportFrame
|
|||
|
||||
public void MarkDirty() => _dirty = true;
|
||||
|
||||
public void Render()
|
||||
/// <summary>
|
||||
/// Pre-world resource phase: rebuild/redress the private clone and advance
|
||||
/// its mesh/composite readiness BEFORE world draws can consume the
|
||||
/// bounded per-frame composite-upload budget. In a dense scene the late
|
||||
/// presentation phase never wins that budget, which is why the doll was
|
||||
/// visible only while portal space quiesced the world (#443).
|
||||
/// </summary>
|
||||
public void PrepareResources()
|
||||
{
|
||||
if (_dirty)
|
||||
{
|
||||
if (_factory.TryBuild(out WorldEntity? doll))
|
||||
{
|
||||
// Same-generation CreateObject refreshes can repeat the exact
|
||||
// player ObjDesc at a portal boundary. Retail redresses its
|
||||
// private inventory object in place; releasing and reacquiring
|
||||
// an identical synthetic owner briefly blanks the viewport and
|
||||
// churns its texture composites.
|
||||
if (!HasEquivalentAppearance(_doll, doll))
|
||||
{
|
||||
// Redress every accepted live-player refresh, including an
|
||||
// appearance-equivalent ObjDesc after portal/relogin. The
|
||||
// private clone belongs to the current presentation
|
||||
// generation; retaining the old object merely because its
|
||||
// pixels compare equal can leave it attached to retired
|
||||
// mesh/composite readiness that never completes again.
|
||||
// PrivateEntityViewportRenderer promotes replacements in two
|
||||
// phases, so the last completed target remains visible until
|
||||
// this fresh clone is completely drawable.
|
||||
_renderer.SetDoll(doll);
|
||||
_doll = doll;
|
||||
}
|
||||
_dirty = false;
|
||||
}
|
||||
else
|
||||
|
|
@ -96,16 +108,25 @@ internal sealed class PaperdollFramePresenter : IPrivateEntityViewportFrame
|
|||
// gmPaperDollUI::RedressCreature @ 0x004A3BC0 leaves its
|
||||
// private m_pInventoryObject intact when the SmartBox player
|
||||
// is temporarily unavailable. Keep the successful doll and
|
||||
// retry this dirty redress on the next visible frame.
|
||||
// retry this dirty redress on the next frame.
|
||||
}
|
||||
}
|
||||
|
||||
_renderer.Prepare();
|
||||
}
|
||||
|
||||
public void Render()
|
||||
{
|
||||
if (!_view.TryGetVisibleSize(out int width, out int height))
|
||||
return;
|
||||
|
||||
_view.SetTextureHandle(_renderer.Render(width, height));
|
||||
// Zero is a transient not-ready result, not a request to erase a
|
||||
// previously completed paperdoll. Session reset clears explicitly in
|
||||
// ResetSession; ordinary mesh/composite upload latency keeps the last
|
||||
// good image instead of intermittently blanking the viewport.
|
||||
uint textureHandle = _renderer.Render(width, height);
|
||||
if (textureHandle != 0u)
|
||||
_view.SetTextureHandle(textureHandle);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -115,80 +136,10 @@ internal sealed class PaperdollFramePresenter : IPrivateEntityViewportFrame
|
|||
public void ResetSession()
|
||||
{
|
||||
_renderer.SetDoll(null);
|
||||
_view.ClearTextureHandle();
|
||||
_doll = null;
|
||||
_dirty = true;
|
||||
}
|
||||
|
||||
private static bool HasEquivalentAppearance(
|
||||
WorldEntity? current,
|
||||
WorldEntity? candidate)
|
||||
{
|
||||
if (current is null || candidate is null)
|
||||
return ReferenceEquals(current, candidate);
|
||||
if (current.SourceGfxObjOrSetupId != candidate.SourceGfxObjOrSetupId
|
||||
|| current.Scale != candidate.Scale
|
||||
|| current.HiddenPartsMask != candidate.HiddenPartsMask
|
||||
|| current.MeshRefs.Count != candidate.MeshRefs.Count
|
||||
|| current.PartOverrides.Count != candidate.PartOverrides.Count)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
for (int i = 0; i < current.MeshRefs.Count; i++)
|
||||
{
|
||||
MeshRef left = current.MeshRefs[i];
|
||||
MeshRef right = candidate.MeshRefs[i];
|
||||
if (left.GfxObjId != right.GfxObjId
|
||||
|| left.PartTransform != right.PartTransform
|
||||
|| !DictionaryEquals(
|
||||
left.SurfaceOverrides,
|
||||
right.SurfaceOverrides))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
for (int i = 0; i < current.PartOverrides.Count; i++)
|
||||
{
|
||||
if (current.PartOverrides[i] != candidate.PartOverrides[i])
|
||||
return false;
|
||||
}
|
||||
|
||||
PaletteOverride? leftPalette = current.PaletteOverride;
|
||||
PaletteOverride? rightPalette = candidate.PaletteOverride;
|
||||
if (leftPalette is null || rightPalette is null)
|
||||
return leftPalette is null && rightPalette is null;
|
||||
if (leftPalette.BasePaletteId != rightPalette.BasePaletteId
|
||||
|| leftPalette.SubPalettes.Count != rightPalette.SubPalettes.Count)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
for (int i = 0; i < leftPalette.SubPalettes.Count; i++)
|
||||
{
|
||||
if (leftPalette.SubPalettes[i] != rightPalette.SubPalettes[i])
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool DictionaryEquals(
|
||||
IReadOnlyDictionary<uint, uint>? left,
|
||||
IReadOnlyDictionary<uint, uint>? right)
|
||||
{
|
||||
if (left is null || right is null)
|
||||
return left is null && right is null;
|
||||
if (left.Count != right.Count)
|
||||
return false;
|
||||
foreach ((uint key, uint value) in left)
|
||||
{
|
||||
if (!right.TryGetValue(key, out uint rightValue)
|
||||
|| rightValue != value)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Retained-UI visibility and texture publication for the doll view.</summary>
|
||||
|
|
@ -226,6 +177,9 @@ internal sealed class RetailPaperdollFrameView : IPaperdollFrameView
|
|||
/// </summary>
|
||||
public void SetTextureHandle(uint textureHandle) =>
|
||||
_viewport.TextureSlot = UiTextureTableHandle.ToSlot(textureHandle);
|
||||
|
||||
public void ClearTextureHandle() =>
|
||||
_viewport.TextureSlot = GpuTextureSlot.Unassigned;
|
||||
}
|
||||
|
||||
/// <summary>Narrow visibility adapter for the paperdoll's inventory host.</summary>
|
||||
|
|
|
|||
|
|
@ -234,7 +234,8 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
|
|||
IReadOnlySet<uint> attachedOwnerIds,
|
||||
bool includeUnattached = false,
|
||||
IReadOnlySet<uint>? excludedAttachedOwnerIds = null,
|
||||
uint clipSlot = 0)
|
||||
uint clipSlot = 0,
|
||||
UnattachedEmitterCellScope unattachedCellScope = UnattachedEmitterCellScope.Any)
|
||||
{
|
||||
if (camera is null)
|
||||
return;
|
||||
|
|
@ -244,7 +245,8 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
|
|||
attachedOwnerIds,
|
||||
includeUnattached,
|
||||
_scopedEmitterScratch,
|
||||
excludedAttachedOwnerIds);
|
||||
excludedAttachedOwnerIds,
|
||||
unattachedCellScope);
|
||||
Matrix4x4.Invert(camera.View, out Matrix4x4 invView);
|
||||
Vector3 cameraRight = Vector3.Normalize(new Vector3(invView.M11, invView.M12, invView.M13));
|
||||
Vector3 cameraUp = Vector3.Normalize(new Vector3(invView.M21, invView.M22, invView.M23));
|
||||
|
|
|
|||
|
|
@ -20,8 +20,8 @@ internal interface IPrivateEntityViewportCamera : ICamera
|
|||
/// <summary>
|
||||
/// Shared render-to-texture implementation for the private 3-D creature
|
||||
/// viewports used by paperdoll and examination UI. Each instance owns one
|
||||
/// <see cref="IGpuRenderTarget"/>, one synthetic render identity, and one
|
||||
/// balanced texture-owner lease.
|
||||
/// <see cref="IGpuRenderTarget"/> per encountered GPU flight slot, one
|
||||
/// synthetic render identity, and one balanced texture-owner lease.
|
||||
///
|
||||
/// <para><b>Campaign V slice V6k (V4g's first half).</b> The target used to be a
|
||||
/// hand-rolled FBO, colour texture and depth renderbuffer, and the resulting GL
|
||||
|
|
@ -65,7 +65,6 @@ internal sealed class PrivateEntityViewportRenderer :
|
|||
{
|
||||
private const uint PrivateLandblockId = 0u;
|
||||
|
||||
private readonly IGpuDevice _device;
|
||||
private readonly ICurrentGpuFrameSource _frames;
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -94,17 +93,12 @@ internal sealed class PrivateEntityViewportRenderer :
|
|||
/// feature does not exist for them, not just "unused".</summary>
|
||||
private readonly EntitySlot? _backdropSlot;
|
||||
|
||||
// One stable sampled texture-table slot is part of the retained viewport's
|
||||
// presentation contract. Rotating the slot with the Vulkan flight index
|
||||
// made the UI sample a freshly-created/cleared sibling after world reveal.
|
||||
// The frame submission order already protects this target's write -> sample
|
||||
// transition; keep its identity stable until resize or disposal.
|
||||
private IGpuRenderTarget? _target;
|
||||
private IGpuSampler? _sampler;
|
||||
private GpuTextureSlot _slot = GpuTextureSlot.Unassigned;
|
||||
private int _fbW;
|
||||
private int _fbH;
|
||||
private bool _hasRenderedScene;
|
||||
// A target written by frame N cannot also be sampled by an unretired frame
|
||||
// N-1. Vulkan permits those command buffers to overlap, so one shared image
|
||||
// is a cross-frame write/read race. The current frame slot selects one
|
||||
// bounded target + texture-table handle; the retained UI samples that exact
|
||||
// handle later in the same command buffer.
|
||||
private readonly PrivateViewportFlightTargets _flightTargets;
|
||||
|
||||
public PrivateEntityViewportRenderer(
|
||||
IWorldPassScope scope,
|
||||
|
|
@ -127,7 +121,7 @@ internal sealed class PrivateEntityViewportRenderer :
|
|||
_scope = scope ?? throw new ArgumentNullException(
|
||||
nameof(scope),
|
||||
"The viewport must publish a world pass scope to draw into.");
|
||||
_device = device ?? throw new ArgumentNullException(nameof(device));
|
||||
ArgumentNullException.ThrowIfNull(device);
|
||||
_frames = frames ?? throw new ArgumentNullException(nameof(frames));
|
||||
_dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
|
||||
_lightUbo = lightUbo ?? throw new ArgumentNullException(nameof(lightUbo));
|
||||
|
|
@ -137,6 +131,9 @@ internal sealed class PrivateEntityViewportRenderer :
|
|||
_diagnosticName = string.IsNullOrWhiteSpace(diagnosticName)
|
||||
? "creature viewport"
|
||||
: diagnosticName;
|
||||
_flightTargets = new PrivateViewportFlightTargets(
|
||||
device,
|
||||
_diagnosticName);
|
||||
|
||||
IEntityTextureLifetime textureLifetimeChecked = textureLifetime
|
||||
?? throw new ArgumentNullException(nameof(textureLifetime));
|
||||
|
|
@ -173,7 +170,17 @@ internal sealed class PrivateEntityViewportRenderer :
|
|||
/// </summary>
|
||||
public bool TextureIsBottomUp => false;
|
||||
|
||||
public void SetEntity(WorldEntity? entity) => _mainSlot.Set(entity);
|
||||
public void SetEntity(WorldEntity? entity)
|
||||
{
|
||||
_mainSlot.Set(entity);
|
||||
if (entity is null)
|
||||
{
|
||||
// A character-session reset explicitly invalidates the sampled
|
||||
// scenes. Do not let a replacement that is still uploading expose
|
||||
// the previous character through any flight target.
|
||||
_flightTargets.InvalidateCompletedScenes();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Advances the private entity's mesh and texture-composite readiness
|
||||
|
|
@ -226,51 +233,50 @@ internal sealed class PrivateEntityViewportRenderer :
|
|||
/// </summary>
|
||||
public uint Render(int width, int height)
|
||||
{
|
||||
if (width <= 0 || height <= 0)
|
||||
return 0u;
|
||||
|
||||
IGpuFrame frame = _frames.CurrentFrame
|
||||
?? throw new InvalidOperationException(
|
||||
$"The {_diagnosticName} requires an open IGpuFrame (see GpuDeviceFrameLifetime).");
|
||||
int frameSlot = frame.SlotIndex;
|
||||
|
||||
// #443: acquiring a synthetic mesh reference only schedules CPU
|
||||
// preparation/GPU upload; it does not make the mesh drawable. Keep the
|
||||
// last completed private scene intact until every drawable mesh in the
|
||||
// replacement has crossed that upload barrier. On first open there is
|
||||
// no completed scene, so return zero and let the authored panel art
|
||||
// show through instead of publishing a freshly-cleared black target.
|
||||
// current flight slot's last completed private scene intact until every
|
||||
// drawable mesh in the replacement has crossed that upload barrier.
|
||||
// On first open there is no completed scene, so return zero and let the
|
||||
// authored panel art show through instead of publishing a cleared target.
|
||||
bool mainReady = _mainSlot.PrepareForDraw();
|
||||
bool backdropReady = _backdropSlot?.PrepareForDraw() ?? true;
|
||||
if (!mainReady || !backdropReady)
|
||||
{
|
||||
return _mainSlot.Entity is not null
|
||||
&& _hasRenderedScene
|
||||
&& _slot.IsAssigned
|
||||
? UiTextureTableHandle.FromSlot(_slot)
|
||||
? _flightTargets.CompletedHandle(frameSlot)
|
||||
: 0u;
|
||||
}
|
||||
|
||||
WorldEntity? entity = _mainSlot.Entity;
|
||||
if (entity is null || entity.MeshRefs.Count == 0 || width <= 0 || height <= 0)
|
||||
if (entity is null || entity.MeshRefs.Count == 0)
|
||||
return 0u;
|
||||
|
||||
IReadOnlyList<WorldEntity> drawEntities = BuildDrawEntities(
|
||||
_backdropSlot?.Entity,
|
||||
entity);
|
||||
if (!_dispatcher.PreparePrivateEntityResources(drawEntities))
|
||||
{
|
||||
return _hasRenderedScene && _slot.IsAssigned
|
||||
? UiTextureTableHandle.FromSlot(_slot)
|
||||
: 0u;
|
||||
}
|
||||
return _flightTargets.CompletedHandle(frameSlot);
|
||||
|
||||
EnsureRenderTarget(width, height);
|
||||
if (_target is null)
|
||||
PrivateViewportFlightTargets.TargetSlot? targetSlot =
|
||||
_flightTargets.Ensure(frameSlot, width, height);
|
||||
if (targetSlot is null)
|
||||
return 0u;
|
||||
_camera.Aspect = width / (float)height;
|
||||
|
||||
IGpuFrame frame = _frames.CurrentFrame
|
||||
?? throw new InvalidOperationException(
|
||||
$"The {_diagnosticName} requires an open IGpuFrame (see GpuDeviceFrameLifetime).");
|
||||
|
||||
using IGpuPassEncoder encoder = frame.BeginPass(new GpuPassDescription
|
||||
{
|
||||
Name = _diagnosticName,
|
||||
Color = new GpuColorAttachment(
|
||||
Target: _target,
|
||||
Target: targetSlot.Target,
|
||||
Load: GpuLoadOp.Clear,
|
||||
Store: GpuStoreOp.Store,
|
||||
ClearColor: Vector4.Zero),
|
||||
|
|
@ -304,6 +310,12 @@ internal sealed class PrivateEntityViewportRenderer :
|
|||
null),
|
||||
};
|
||||
|
||||
// #443: a private pass must not append its transforms into the shared
|
||||
// world transform frame — the default mesh shaders index parallel
|
||||
// per-instance arrays zero-based, so a non-zero arena base zeroes the
|
||||
// doll's per-instance opacity and the target stays blank whenever a
|
||||
// world frame is active. See NextClassicDrawIsPrivatePass.
|
||||
_dispatcher.NextClassicDrawIsPrivatePass = true;
|
||||
_dispatcher.Draw(
|
||||
_camera,
|
||||
entries,
|
||||
|
|
@ -311,8 +323,8 @@ internal sealed class PrivateEntityViewportRenderer :
|
|||
neverCullLandblockId: PrivateLandblockId,
|
||||
visibleCellIds: null,
|
||||
animatedEntityIds: _animatedIds);
|
||||
_hasRenderedScene = true;
|
||||
return UiTextureTableHandle.FromSlot(_slot);
|
||||
targetSlot.HasRenderedScene = true;
|
||||
return UiTextureTableHandle.FromSlot(targetSlot.TextureSlot);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -363,69 +375,6 @@ internal sealed class PrivateEntityViewportRenderer :
|
|||
});
|
||||
}
|
||||
|
||||
private void EnsureRenderTarget(int width, int height)
|
||||
{
|
||||
if (_target is not null && width == _fbW && height == _fbH)
|
||||
return;
|
||||
ReleaseRenderTarget();
|
||||
|
||||
IGpuRenderTarget target;
|
||||
try
|
||||
{
|
||||
target = _device.CreateRenderTarget(new GpuRenderTargetDescription(
|
||||
_diagnosticName,
|
||||
width,
|
||||
height,
|
||||
GpuTextureFormat.Rgba8UnormRenderTarget,
|
||||
// Depth24Stencil8, as the hand-rolled renderbuffer was: nothing
|
||||
// samples it, and the stencil aspect keeps the attachment shape
|
||||
// the depth/stencil renderers already expect.
|
||||
GpuTextureFormat.Depth24Stencil8,
|
||||
SampleCount: 1));
|
||||
}
|
||||
catch (Exception failure)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[{_diagnosticName}] render target unavailable ({width}x{height}): {failure.Message}");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// The retained UI blits this attachment as an ordinary table entry.
|
||||
// Linear/clamped is the filtering the hand-rolled colour texture set
|
||||
// on itself before the §7.1 seam registered it.
|
||||
_sampler = _device.CreateSampler(GpuSamplerDescription.WorldClamp);
|
||||
_slot = _device.RegisterTexture(target.ColorTexture, _sampler);
|
||||
}
|
||||
catch
|
||||
{
|
||||
target.Dispose();
|
||||
_sampler = null;
|
||||
_slot = GpuTextureSlot.Unassigned;
|
||||
throw;
|
||||
}
|
||||
|
||||
_target = target;
|
||||
_fbW = width;
|
||||
_fbH = height;
|
||||
}
|
||||
|
||||
private void ReleaseRenderTarget()
|
||||
{
|
||||
if (_slot.IsAssigned)
|
||||
{
|
||||
_device.ReleaseTextureSlot(_slot);
|
||||
_slot = GpuTextureSlot.Unassigned;
|
||||
}
|
||||
_sampler = null;
|
||||
_target?.Dispose();
|
||||
_target = null;
|
||||
_fbW = 0;
|
||||
_fbH = 0;
|
||||
_hasRenderedScene = false;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
List<Exception>? failures = null;
|
||||
|
|
@ -447,7 +396,7 @@ internal sealed class PrivateEntityViewportRenderer :
|
|||
}
|
||||
try
|
||||
{
|
||||
ReleaseRenderTarget();
|
||||
_flightTargets.Dispose();
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
|
|
@ -462,6 +411,163 @@ internal sealed class PrivateEntityViewportRenderer :
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bounded render-target ownership keyed by <see cref="IGpuFrame.SlotIndex"/>.
|
||||
/// A frame slot is reopened only after its previous submission retires, so
|
||||
/// the target selected here can be written and sampled within that frame
|
||||
/// without racing a different in-flight command buffer.
|
||||
/// </summary>
|
||||
internal sealed class PrivateViewportFlightTargets : IDisposable
|
||||
{
|
||||
internal sealed class TargetSlot(
|
||||
IGpuRenderTarget target,
|
||||
GpuTextureSlot textureSlot)
|
||||
{
|
||||
internal IGpuRenderTarget Target { get; } = target;
|
||||
internal GpuTextureSlot TextureSlot { get; } = textureSlot;
|
||||
internal bool HasRenderedScene { get; set; }
|
||||
}
|
||||
|
||||
private readonly IGpuDevice _device;
|
||||
private readonly string _diagnosticName;
|
||||
private readonly List<TargetSlot?> _slots = [];
|
||||
private int _width;
|
||||
private int _height;
|
||||
private bool _disposed;
|
||||
|
||||
internal PrivateViewportFlightTargets(
|
||||
IGpuDevice device,
|
||||
string diagnosticName)
|
||||
{
|
||||
_device = device ?? throw new ArgumentNullException(nameof(device));
|
||||
_diagnosticName = string.IsNullOrWhiteSpace(diagnosticName)
|
||||
? "creature viewport"
|
||||
: diagnosticName;
|
||||
}
|
||||
|
||||
internal int AllocatedSlotCount =>
|
||||
_slots.Count(static slot => slot is not null);
|
||||
|
||||
internal TargetSlot? Ensure(int frameSlot, int width, int height)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(frameSlot);
|
||||
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(width);
|
||||
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(height);
|
||||
|
||||
if (_width != 0 && (_width != width || _height != height))
|
||||
ReleaseAll();
|
||||
|
||||
while (_slots.Count <= frameSlot)
|
||||
_slots.Add(null);
|
||||
if (_slots[frameSlot] is { } existing)
|
||||
return existing;
|
||||
|
||||
IGpuRenderTarget target;
|
||||
try
|
||||
{
|
||||
target = _device.CreateRenderTarget(
|
||||
new GpuRenderTargetDescription(
|
||||
$"{_diagnosticName}-flight-{frameSlot}",
|
||||
width,
|
||||
height,
|
||||
GpuTextureFormat.Rgba8UnormRenderTarget,
|
||||
// Depth24Stencil8, as the original private viewport
|
||||
// renderbuffer was. Nothing samples this attachment.
|
||||
GpuTextureFormat.Depth24Stencil8,
|
||||
SampleCount: 1));
|
||||
}
|
||||
catch (Exception failure)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[{_diagnosticName}] render target unavailable "
|
||||
+ $"({width}x{height}, flight {frameSlot}): {failure.Message}");
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// The device de-duplicates immutable samplers. Retained UI
|
||||
// blits this target through its ordinary texture-table entry.
|
||||
IGpuSampler sampler = _device.CreateSampler(
|
||||
GpuSamplerDescription.WorldClamp);
|
||||
GpuTextureSlot textureSlot = _device.RegisterTexture(
|
||||
target.ColorTexture,
|
||||
sampler);
|
||||
var created = new TargetSlot(target, textureSlot);
|
||||
_slots[frameSlot] = created;
|
||||
_width = width;
|
||||
_height = height;
|
||||
return created;
|
||||
}
|
||||
catch
|
||||
{
|
||||
target.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
internal uint CompletedHandle(int frameSlot)
|
||||
{
|
||||
if ((uint)frameSlot >= (uint)_slots.Count
|
||||
|| _slots[frameSlot] is not { HasRenderedScene: true } slot)
|
||||
{
|
||||
return 0u;
|
||||
}
|
||||
|
||||
return UiTextureTableHandle.FromSlot(slot.TextureSlot);
|
||||
}
|
||||
|
||||
internal void InvalidateCompletedScenes()
|
||||
{
|
||||
for (int i = 0; i < _slots.Count; i++)
|
||||
{
|
||||
if (_slots[i] is { } slot)
|
||||
slot.HasRenderedScene = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void ReleaseAll()
|
||||
{
|
||||
List<Exception>? failures = null;
|
||||
for (int i = 0; i < _slots.Count; i++)
|
||||
{
|
||||
TargetSlot? slot = _slots[i];
|
||||
if (slot is null)
|
||||
continue;
|
||||
try
|
||||
{
|
||||
_device.ReleaseTextureSlot(slot.TextureSlot);
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
(failures ??= []).Add(error);
|
||||
}
|
||||
try
|
||||
{
|
||||
slot.Target.Dispose();
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
(failures ??= []).Add(error);
|
||||
}
|
||||
}
|
||||
_slots.Clear();
|
||||
_width = 0;
|
||||
_height = 0;
|
||||
if (failures is { Count: > 0 })
|
||||
throw new AggregateException(failures);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
_disposed = true;
|
||||
ReleaseAll();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One private entity's mesh-reference/texture-owner lifetime, independent
|
||||
/// of every other slot on the renderer. Publication is two-phase: a candidate owns its mesh
|
||||
|
|
|
|||
|
|
@ -16,6 +16,19 @@ internal interface IPrivateEntityViewportFrame
|
|||
void Render();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Optional pre-world resource preparation for a private viewport whose
|
||||
/// visibility must not compete with world composite uploads. The composite
|
||||
/// upload budget opens with the frame's resource phase; a dense world can
|
||||
/// consume all of it every frame, so a viewport that only prepares during
|
||||
/// late presentation can starve indefinitely (#443's paperdoll: visible in
|
||||
/// portal space — where the world is quiesced — and nowhere busy).
|
||||
/// </summary>
|
||||
internal interface IPrivateEntityViewportResourcePreparation
|
||||
{
|
||||
void PrepareResources();
|
||||
}
|
||||
|
||||
internal interface IRetainedGameplayUiFrame
|
||||
{
|
||||
void Render(double deltaSeconds, int width, int height);
|
||||
|
|
|
|||
|
|
@ -29,20 +29,29 @@ internal sealed class RenderFramePreparationController : IRenderFrameResourcePha
|
|||
private readonly IRenderFrameResourcePhase _resources;
|
||||
private readonly IDevToolsFrameLifecycle? _devTools;
|
||||
private readonly IRenderWeatherFramePhase _weather;
|
||||
private readonly IPrivateEntityViewportResourcePreparation? _privateViewports;
|
||||
|
||||
public RenderFramePreparationController(
|
||||
IRenderFrameResourcePhase resources,
|
||||
IDevToolsFrameLifecycle? devTools,
|
||||
IRenderWeatherFramePhase weather)
|
||||
IRenderWeatherFramePhase weather,
|
||||
IPrivateEntityViewportResourcePreparation? privateViewports = null)
|
||||
{
|
||||
_resources = resources ?? throw new ArgumentNullException(nameof(resources));
|
||||
_devTools = devTools;
|
||||
_weather = weather ?? throw new ArgumentNullException(nameof(weather));
|
||||
_privateViewports = privateViewports;
|
||||
}
|
||||
|
||||
public void Prepare(RenderFrameInput input)
|
||||
{
|
||||
_resources.Prepare(input);
|
||||
// The composite upload budget opens with the resource phase. Give the
|
||||
// paperdoll's private object its prewarm slot before the world can
|
||||
// consume the complete per-frame budget (#443 — the doll rendered
|
||||
// only while portal space quiesced the world); presentation samples
|
||||
// the result later, after the world pass has closed.
|
||||
_privateViewports?.PrepareResources();
|
||||
_devTools?.BeginFrame((float)input.DeltaSeconds);
|
||||
_weather.Tick(input.DeltaSeconds);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -239,6 +239,106 @@ internal sealed class RetailAlphaQueue : IWorldSceneAlphaFrame
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drains only the entries at or beyond <paramref name="minViewerDistance"/>
|
||||
/// and keeps every nearer entry queued with the frame open. This is the
|
||||
/// pre/inter-building barrier semantics: retail's far→near land walk means
|
||||
/// <c>DrawBuilding</c>'s <c>FlushAlphaList(0f)</c> @0x0059F2A0 can only
|
||||
/// flush content from cells FARTHER than that building — a nearer emitter
|
||||
/// has not been inserted yet and composites after the building at a later
|
||||
/// flush (the float there is a COUNT threshold, not a depth). The batched
|
||||
/// landscape has no per-cell walk, so the same outcome is restored by
|
||||
/// draining the far prefix of the established far→near order (AP-236).
|
||||
/// Sources are deliberately NOT reset: retained tokens must stay valid
|
||||
/// for the remaining entries' later <see cref="Flush"/>.
|
||||
/// </summary>
|
||||
public void FlushFartherThan(float minViewerDistance)
|
||||
{
|
||||
if (!IsCollecting)
|
||||
throw new InvalidOperationException("Retail alpha flush requires an active frame.");
|
||||
if (_submissions.Count == 0)
|
||||
return;
|
||||
|
||||
float threshold = NormalizeDistance(minViewerDistance);
|
||||
SortRetailOrder();
|
||||
int prefix = 0;
|
||||
while (prefix < _submissions.Count
|
||||
&& _submissions[prefix].ViewerDistance >= threshold)
|
||||
{
|
||||
prefix++;
|
||||
}
|
||||
|
||||
if (prefix == 0)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
EnsureTokenCapacity(prefix);
|
||||
EnsureSourceCapacity(_sources.Count);
|
||||
Array.Clear(_sourceDrawOffsets, 0, _sources.Count);
|
||||
|
||||
for (int sourceIndex = 0; sourceIndex < _sources.Count; sourceIndex++)
|
||||
{
|
||||
IRetailAlphaDrawSource source = _sources[sourceIndex];
|
||||
int sourceCount = 0;
|
||||
for (int i = 0; i < prefix; i++)
|
||||
{
|
||||
RetailAlphaSubmission submission = _submissions[i];
|
||||
if (ReferenceEquals(submission.Source, source))
|
||||
_tokenScratch[sourceCount++] = submission.Token;
|
||||
}
|
||||
|
||||
if (sourceCount > 0)
|
||||
source.PrepareAlphaDraws(_tokenScratch.AsSpan(0, sourceCount));
|
||||
}
|
||||
|
||||
int start = 0;
|
||||
while (start < prefix)
|
||||
{
|
||||
IRetailAlphaDrawSource source = _submissions[start].Source;
|
||||
int end = start + 1;
|
||||
while (end < prefix
|
||||
&& ReferenceEquals(_submissions[end].Source, source))
|
||||
end++;
|
||||
|
||||
int count = end - start;
|
||||
int sourceIndex = FindSourceIndex(source);
|
||||
int firstPreparedDraw = _sourceDrawOffsets[sourceIndex];
|
||||
source.DrawPreparedAlphaBatch(firstPreparedDraw, count);
|
||||
_sourceDrawOffsets[sourceIndex] += count;
|
||||
start = end;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Converge to the full-drain failure shape: the retained suffix
|
||||
// cannot be trusted once a source threw mid-prepare/draw.
|
||||
_submissions.Clear();
|
||||
List<Exception>? resetFailures = null;
|
||||
for (int i = 0; i < _sources.Count; i++)
|
||||
{
|
||||
try
|
||||
{
|
||||
_sources[i].ResetAlphaSubmissions();
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
(resetFailures ??= []).Add(error);
|
||||
}
|
||||
}
|
||||
_sources.Clear();
|
||||
if (resetFailures is { Count: > 0 })
|
||||
{
|
||||
throw new AggregateException(
|
||||
"Retail alpha partial drain failed and its submissions could not be fully reset.",
|
||||
resetFailures);
|
||||
}
|
||||
throw;
|
||||
}
|
||||
|
||||
_submissions.RemoveRange(0, prefix);
|
||||
}
|
||||
|
||||
public void EndFrame()
|
||||
{
|
||||
if (!IsCollecting)
|
||||
|
|
|
|||
|
|
@ -444,20 +444,9 @@ internal sealed class RetailPViewPassExecutor :
|
|||
animatedEntityIds: frame.AnimatedEntityIds);
|
||||
}
|
||||
|
||||
_particleClassifications.ReplaceOutdoor(context.ParticleOwnerIds);
|
||||
|
||||
if (_particleClassifications.Outdoor.Count > 0
|
||||
&& _particles is not null
|
||||
&& _particleRenderer is not null)
|
||||
{
|
||||
_particleRenderer.DrawForOwners(
|
||||
frame.Camera,
|
||||
frame.CameraWorldPosition,
|
||||
ParticleRenderPass.Scene,
|
||||
_particleClassifications.Outdoor,
|
||||
clipSlot: (uint)context.Slice.Slot);
|
||||
}
|
||||
|
||||
// Late-stage particle owners submit ONCE per frame through
|
||||
// DrawLandscapeStaticParticles after the slice loop (retail: one
|
||||
// unclipped alpha-list insertion per emitter), not per slice here.
|
||||
EnableClipDistances();
|
||||
if (frame.RenderSky && frame.RenderWeather)
|
||||
{
|
||||
|
|
@ -492,8 +481,12 @@ internal sealed class RetailPViewPassExecutor :
|
|||
RetailPViewFrameInput frame,
|
||||
RetailPViewLandscapeStaticParticleContext context)
|
||||
{
|
||||
bool scissor = BeginDoorwayScissor(context.Slice.NdcAabb);
|
||||
_surface.BindTerrainClip();
|
||||
// One unclipped submission per owner per frame. Retail never clips a
|
||||
// particle to a portal view — its polys join the one alpha list during
|
||||
// the owner cell's walk turn and the depth test at the flush decides
|
||||
// occlusion (FlushAlphaList @0x0059D2E0). The former per-slice call
|
||||
// with the slice's clip slot both hardware-cut effects at aperture
|
||||
// boundaries and double-submitted owners visible in two slices.
|
||||
DisableClipDistances();
|
||||
|
||||
_particleClassifications.ReplaceOutdoor(context.ParticleOwnerIds);
|
||||
|
|
@ -506,11 +499,9 @@ internal sealed class RetailPViewPassExecutor :
|
|||
frame.CameraWorldPosition,
|
||||
ParticleRenderPass.Scene,
|
||||
_particleClassifications.Outdoor,
|
||||
clipSlot: (uint)context.Slice.Slot);
|
||||
clipSlot: 0);
|
||||
}
|
||||
|
||||
if (scissor)
|
||||
_surface.EndScissor();
|
||||
_entities.ClearClipRouting();
|
||||
DisableClipDistances();
|
||||
}
|
||||
|
|
@ -573,11 +564,19 @@ internal sealed class RetailPViewPassExecutor :
|
|||
|
||||
public void DrawUnattachedSceneParticles(
|
||||
RetailPViewFrameInput frame,
|
||||
ClipViewSlice slice)
|
||||
bool outdoorCells)
|
||||
{
|
||||
if (_particles is null || _particleRenderer is null)
|
||||
return;
|
||||
|
||||
// Retail draws an unattached emitter once, during its owner CELL's
|
||||
// walk turn, with NO portal-view clip (CPhysicsObj::ShouldDrawParticles
|
||||
// @0x0050FE60 gates by cell in-view + distance; occlusion is the depth
|
||||
// test at FlushAlphaList @0x0059D2E0). Outdoor-cell emitters submit in
|
||||
// the landscape stage, interior-cell emitters in the final world stage.
|
||||
// The former once-per-OutsideView-slice submission with that slice's
|
||||
// hardware clip slot made effects vanish by view direction (zero
|
||||
// outside slices in view = zero submissions) — invented behavior.
|
||||
DisableClipDistances();
|
||||
_particleRenderer.DrawForOwners(
|
||||
frame.Camera,
|
||||
|
|
@ -585,11 +584,17 @@ internal sealed class RetailPViewPassExecutor :
|
|||
ParticleRenderPass.Scene,
|
||||
_noSceneParticleEntityIds,
|
||||
includeUnattached: true,
|
||||
clipSlot: (uint)slice.Slot);
|
||||
clipSlot: 0,
|
||||
unattachedCellScope: outdoorCells
|
||||
? UnattachedEmitterCellScope.OutdoorCells
|
||||
: UnattachedEmitterCellScope.InteriorCells);
|
||||
}
|
||||
|
||||
public void FlushLandscapeAlpha() => _alpha.Flush();
|
||||
|
||||
public void FlushLandscapeAlphaFartherThan(float minViewerDistance) =>
|
||||
_alpha.FlushFartherThan(minViewerDistance);
|
||||
|
||||
public void DrawCellParticles(
|
||||
RetailPViewFrameInput frame,
|
||||
RetailPViewCellSliceContext context)
|
||||
|
|
@ -608,12 +613,14 @@ internal sealed class RetailPViewPassExecutor :
|
|||
return;
|
||||
|
||||
DisableClipDistances();
|
||||
// Retail never clips cell particles to a portal view: the owner
|
||||
// cell's walls own occlusion via the depth test at the alpha flush.
|
||||
_particleRenderer.DrawForOwners(
|
||||
frame.Camera,
|
||||
frame.CameraWorldPosition,
|
||||
ParticleRenderPass.Scene,
|
||||
visible,
|
||||
clipSlot: (uint)context.Slice.Slot);
|
||||
clipSlot: 0);
|
||||
DisableClipDistances();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -28,6 +28,19 @@ public sealed class RetailPViewRenderer
|
|||
private static readonly IReadOnlySet<uint> NoParticleOwners =
|
||||
new HashSet<uint>();
|
||||
|
||||
// Frame unions for the once-per-frame particle submissions (retail: one
|
||||
// unclipped alpha-list insertion per emitter; occlusion by depth at the
|
||||
// flush). Per-slice owner culls still run — these accumulate their union.
|
||||
private readonly HashSet<uint> _staticParticleUnionScratch = new();
|
||||
private readonly HashSet<uint> _cellParticleUnionScratch = new();
|
||||
|
||||
// Every cell drawn as a building look-in this frame. Retail marks each
|
||||
// drawn non-player part for the frame (DrawMeshInternal @0x0059F360,
|
||||
// GetDrawnThisFrame), so an object whose cell drew with a look-in cannot
|
||||
// draw again in a later pass; dynamics-last consults this set to honor
|
||||
// the same drawn-once contract.
|
||||
private readonly HashSet<uint> _lookInCellIds = new();
|
||||
|
||||
private readonly HashSet<uint> _oneCell = new(1);
|
||||
// Shell-batch scratch: all of a pass's cells collected for ONE batched
|
||||
// opaque Render call (instead of one heavy Render per cell). Reused across
|
||||
|
|
@ -166,13 +179,19 @@ public sealed class RetailPViewRenderer
|
|||
// contains them). drawableCells itself stays the MAIN flood: it feeds the
|
||||
// seals, the outside-stage predicate, and the frame result.
|
||||
var prepareCells = drawableCells;
|
||||
_lookInCellIds.Clear();
|
||||
if (_lookInFrames.Count > 0)
|
||||
{
|
||||
_lookInPrepareScratch.Clear();
|
||||
_lookInPrepareScratch.UnionWith(drawableCells);
|
||||
foreach (var f in _lookInFrames)
|
||||
{
|
||||
foreach (uint c in f.OrderedVisibleCells)
|
||||
{
|
||||
_lookInPrepareScratch.Add(c);
|
||||
_lookInCellIds.Add(c);
|
||||
}
|
||||
}
|
||||
prepareCells = _lookInPrepareScratch;
|
||||
}
|
||||
|
||||
|
|
@ -239,7 +258,9 @@ public sealed class RetailPViewRenderer
|
|||
_partitionResult,
|
||||
prepareCells,
|
||||
ctx.LandblockEntries,
|
||||
_partitionObserver);
|
||||
_partitionObserver,
|
||||
ctx.Frustum,
|
||||
ctx.PlayerLandblockId ?? 0u);
|
||||
partition = _partitionResult;
|
||||
}
|
||||
|
||||
|
|
@ -249,10 +270,13 @@ public sealed class RetailPViewRenderer
|
|||
RenderProjectionCounts sourceCounts = frameViewBorrowed
|
||||
? frameView.SourceDigest.Counts
|
||||
: LegacySourceCounts(partition!);
|
||||
// prepareCells is exactly "main flood ∪ look-in cells" — the cells
|
||||
// this traversal actually reached, i.e. retail's in-view set.
|
||||
RetailPViewFrameResult result = _frameResultScratch.Reset(
|
||||
pvFrame,
|
||||
clipAssembly,
|
||||
drawableCells,
|
||||
prepareCells,
|
||||
counts,
|
||||
sourceCounts,
|
||||
partition);
|
||||
|
|
@ -294,6 +318,31 @@ public sealed class RetailPViewRenderer
|
|||
frameEntityPasses,
|
||||
in frameView);
|
||||
passes.UseIndoorMembershipOnlyRouting();
|
||||
|
||||
// Retail DrawBuilding @0x0059F2A0 runs FlushAlphaList(0f) BEFORE
|
||||
// its portal-only far-Z pass. In retail's strict far→near walk
|
||||
// everything queued at that instant is FARTHER than the structure
|
||||
// being punched, so no already-drained poly can meet a punched
|
||||
// aperture's falsified depth, and everything drained later is
|
||||
// NEARER than the punched structure and legitimately composites in
|
||||
// front of it. The batched outdoor frame reproduces that invariant
|
||||
// here: drain the far prefix — every entry at or beyond the
|
||||
// nearest cell whose exit-portal mask is about to punch far-Z —
|
||||
// against still-true landscape depth. Without this, an exterior
|
||||
// waterfall beyond the cathedral drains after the punches and
|
||||
// z-passes across every aperture pixel whose true depth the punch
|
||||
// erased (#132 regression found at the 2026-08-29 cathedral gate).
|
||||
// Interior roots keep their pre-clear stage-boundary drain.
|
||||
if (ctx.RootCell.IsOutdoorNode)
|
||||
{
|
||||
passes.FlushLandscapeAlphaFartherThan(
|
||||
ExitPortalMaskBarrierDistance(
|
||||
pvFrame,
|
||||
drawableCells,
|
||||
ctx.Cells,
|
||||
ctx.CameraWorldPosition));
|
||||
}
|
||||
|
||||
DrawExitPortalMasks(ctx, passes, pvFrame, clipAssembly, drawableCells);
|
||||
DrawEnvCellShells(passes, pvFrame);
|
||||
DrawCellObjectLists(
|
||||
|
|
@ -315,6 +364,24 @@ public sealed class RetailPViewRenderer
|
|||
frameEntityPasses,
|
||||
in frameView);
|
||||
|
||||
// OUTDOOR root: the LScape-boundary alpha drain deferred from the
|
||||
// landscape stage runs HERE, after punches, interior shells, cell
|
||||
// objects, and the dynamics pass — the frame's complete opaque
|
||||
// world. Retail's walk draws all of those before its boundary
|
||||
// flush (LScape::draw includes every cell's objects,
|
||||
// DrawSortCell 0x005A17C0), so this is the same one-list far→near
|
||||
// composite over finished depth; draining at the stage end instead
|
||||
// let every later opaque mesh overwrite the flames (#132).
|
||||
if (ctx.RootCell.IsOutdoorNode)
|
||||
passes.FlushLandscapeAlpha();
|
||||
|
||||
// Interior-cell UNATTACHED emitters (spell ground effects and
|
||||
// swirls anchored in EnvCells) draw in this final world scope —
|
||||
// the cells' walls and the seals already own the depth buffer, so
|
||||
// one unclipped submission matches retail's cell-walk insertion.
|
||||
// Outdoor-cell unattached emitters drew in the landscape stage.
|
||||
passes.DrawUnattachedSceneParticles(ctx, outdoorCells: false);
|
||||
|
||||
if (entityFrameOpen)
|
||||
{
|
||||
frameEntityPasses!.CompleteEntityFrame(in frameView);
|
||||
|
|
@ -426,6 +493,71 @@ public sealed class RetailPViewRenderer
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Conservative barrier drain threshold for one look-in frame: the viewer
|
||||
/// distance to the frame's nearest anchor-cell ORIGIN. Cell origins sit
|
||||
/// inside the building, so this over-estimates the building's
|
||||
/// nearest-point distance and under-drains; anything conservatively
|
||||
/// retained still composites correctly at the later depth-tested drains.
|
||||
/// Retail needs no threshold — its far→near walk guarantees only farther
|
||||
/// content is queued when DrawBuilding flushes (@0x0059F2A0). Returns 0
|
||||
/// (full drain, today's behavior) when no cell resolves.
|
||||
/// </summary>
|
||||
/// <summary>
|
||||
/// The pre-punch barrier threshold for <see cref="DrawExitPortalMasks"/>:
|
||||
/// the nearest drawable cell whose exit-portal mask is about to write
|
||||
/// far-Z. Every queued alpha entry at or beyond it must drain first
|
||||
/// (retail DrawBuilding @0x0059F2A0's FlushAlphaList(0f) before the
|
||||
/// portal-only pass), because after the punch those entries would z-pass
|
||||
/// across aperture pixels whose true depth no longer exists. No punched
|
||||
/// cells → MaxValue → the partial drain retains everything.
|
||||
/// </summary>
|
||||
internal static float ExitPortalMaskBarrierDistance(
|
||||
PortalVisibilityFrame frame,
|
||||
HashSet<uint> drawableCells,
|
||||
IRetailPViewCellSource cells,
|
||||
Vector3 viewerPosition)
|
||||
{
|
||||
float best = float.PositiveInfinity;
|
||||
for (int i = 0; i < frame.OrderedVisibleCells.Count; i++)
|
||||
{
|
||||
uint cellId = frame.OrderedVisibleCells[i];
|
||||
if (!drawableCells.Contains(cellId))
|
||||
continue;
|
||||
LoadedCell? cell = cells.Find(cellId);
|
||||
if (cell is null)
|
||||
continue;
|
||||
float distance = Vector3.Distance(
|
||||
cell.WorldTransform.Translation,
|
||||
viewerPosition);
|
||||
if (distance < best)
|
||||
best = distance;
|
||||
}
|
||||
|
||||
return float.IsFinite(best) ? best : float.MaxValue;
|
||||
}
|
||||
|
||||
internal static float LookInBarrierDrainDistance(
|
||||
PortalVisibilityFrame frame,
|
||||
IRetailPViewCellSource cells,
|
||||
Vector3 viewerPosition)
|
||||
{
|
||||
float best = float.PositiveInfinity;
|
||||
for (int i = 0; i < frame.OrderedVisibleCells.Count; i++)
|
||||
{
|
||||
LoadedCell? cell = cells.Find(frame.OrderedVisibleCells[i]);
|
||||
if (cell is null)
|
||||
continue;
|
||||
float distance = Vector3.Distance(
|
||||
cell.WorldTransform.Translation,
|
||||
viewerPosition);
|
||||
if (distance < best)
|
||||
best = distance;
|
||||
}
|
||||
|
||||
return float.IsFinite(best) ? best : 0f;
|
||||
}
|
||||
|
||||
private void RecycleLookInFrames()
|
||||
{
|
||||
for (int i = 0; i < _lookInFrames.Count; i++)
|
||||
|
|
@ -483,12 +615,22 @@ public sealed class RetailPViewRenderer
|
|||
{
|
||||
PortalVisibilityFrame frame = _lookInFrames[frameIndex];
|
||||
|
||||
// Retail enters DrawBuilding once per building and drains every
|
||||
// alpha submission accumulated by the preceding building before
|
||||
// punching the next building's portals. The first building uses
|
||||
// the pre-look-in barrier in DrawLandscapeThroughOutsideView.
|
||||
// Retail enters DrawBuilding once per building and drains the
|
||||
// alpha accumulated by the preceding building before punching the
|
||||
// next building's portals — and because retail's far→near walk
|
||||
// has only inserted FARTHER content by then, that drain can never
|
||||
// composite an emitter nearer than this building
|
||||
// (FlushAlphaList(0f) @0x0059F2A0 under the walk; AP-236).
|
||||
// The first building uses the pre-look-in barrier in
|
||||
// DrawLandscapeThroughOutsideView.
|
||||
if (frameIndex > 0)
|
||||
passes.FlushLandscapeAlpha();
|
||||
{
|
||||
passes.FlushLandscapeAlphaFartherThan(
|
||||
LookInBarrierDrainDistance(
|
||||
frame,
|
||||
ctx.Cells,
|
||||
ctx.CameraWorldPosition));
|
||||
}
|
||||
|
||||
// Pass 1: far-Z punch every aperture of this building.
|
||||
foreach (ExteriorPortalSeed seed in frame.ExteriorSeedPortals)
|
||||
|
|
@ -548,6 +690,8 @@ public sealed class RetailPViewRenderer
|
|||
_cellStaticScratch.Add(e);
|
||||
}
|
||||
|
||||
bool cellDrewObjects = false;
|
||||
_cellParticleUnionScratch.Clear();
|
||||
foreach (ClipViewSlice slice in cellSlices)
|
||||
{
|
||||
int routeIndex = lookInRouteIndex++;
|
||||
|
|
@ -593,12 +737,21 @@ public sealed class RetailPViewRenderer
|
|||
_cellStaticScratch,
|
||||
_oneCell);
|
||||
|
||||
// The nested DrawCells object pass includes emitters and
|
||||
// retains the exact setup_view clip until alpha playback.
|
||||
passes.DrawCellParticles(ctx, new RetailPViewCellSliceContext(
|
||||
cellId, slice, _cellParticleOwnerScratch));
|
||||
cellDrewObjects = true;
|
||||
_cellParticleUnionScratch.UnionWith(
|
||||
_cellParticleOwnerScratch);
|
||||
}
|
||||
}
|
||||
|
||||
// The nested DrawCells object pass includes emitters: ONE
|
||||
// unclipped submission per look-in cell (retail draws a
|
||||
// particle during its cell's walk turn; the cell walls own
|
||||
// occlusion by depth at alpha playback — never a view clip).
|
||||
if (cellDrewObjects)
|
||||
{
|
||||
passes.DrawCellParticles(ctx, new RetailPViewCellSliceContext(
|
||||
cellId, NoClipSlice, _cellParticleUnionScratch));
|
||||
}
|
||||
}
|
||||
|
||||
// The ordinary exterior building shell is clipped by the outer
|
||||
|
|
@ -610,6 +763,7 @@ public sealed class RetailPViewRenderer
|
|||
// anchor EnvCell; never let an unrelated building repaint a
|
||||
// look-in merely because both happen to be nearby.
|
||||
int sliceIndex = 0;
|
||||
_staticParticleUnionScratch.Clear();
|
||||
foreach (ClipViewSlice slice in clipAssembly.OutsideViewSlices)
|
||||
{
|
||||
int shellRouteIndex = LookInBuildingShellRouteIndex(
|
||||
|
|
@ -688,14 +842,20 @@ public sealed class RetailPViewRenderer
|
|||
_lateParticleOwnerScratch,
|
||||
_buildingShellScratch);
|
||||
}
|
||||
passes.DrawLandscapeStaticParticles(
|
||||
ctx,
|
||||
new RetailPViewLandscapeStaticParticleContext(
|
||||
slice,
|
||||
_lateParticleOwnerScratch));
|
||||
_staticParticleUnionScratch.UnionWith(
|
||||
_lateParticleOwnerScratch);
|
||||
}
|
||||
sliceIndex++;
|
||||
}
|
||||
|
||||
// ONE unclipped submission for this look-in frame's shell-route
|
||||
// owners (retail: one alpha-list insertion per emitter,
|
||||
// depth-occluded at the flush — never re-drawn per outside view).
|
||||
passes.DrawLandscapeStaticParticles(
|
||||
ctx,
|
||||
new RetailPViewLandscapeStaticParticleContext(
|
||||
_staticParticleUnionScratch));
|
||||
_staticParticleUnionScratch.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -793,14 +953,19 @@ public sealed class RetailPViewRenderer
|
|||
bool hasBuildingLookIns = _lookInFrames.Count > 0;
|
||||
if (hasBuildingLookIns)
|
||||
{
|
||||
int barrierSliceIndex = 0;
|
||||
foreach (var slice in clipAssembly.OutsideViewSlices)
|
||||
{
|
||||
// Ownerless outdoor emitters cannot ride an entity route. Retail
|
||||
// draws their meshes once for every installed outside_view;
|
||||
// retain that slot through deferred alpha playback.
|
||||
passes.DrawUnattachedSceneParticles(ctx, slice);
|
||||
// Ownerless OUTDOOR-cell emitters cannot ride an entity route.
|
||||
// Retail inserts each one into the single alpha list once, during
|
||||
// its cell's landscape walk turn, with no portal-view clip; the
|
||||
// interior-cell ownerless emitters submit in the final world
|
||||
// scope instead (see DrawDynamicsLast).
|
||||
passes.DrawUnattachedSceneParticles(ctx, outdoorCells: true);
|
||||
|
||||
_staticParticleUnionScratch.Clear();
|
||||
int outsideSliceTotal = clipAssembly.OutsideViewSlices.Length;
|
||||
for (int barrierSliceIndex = 0;
|
||||
barrierSliceIndex < outsideSliceTotal;
|
||||
barrierSliceIndex++)
|
||||
{
|
||||
_lateParticleOwnerScratch.Clear();
|
||||
if (partition is not null)
|
||||
{
|
||||
|
|
@ -833,15 +998,30 @@ public sealed class RetailPViewRenderer
|
|||
barrierSliceIndex,
|
||||
0);
|
||||
}
|
||||
_staticParticleUnionScratch.UnionWith(
|
||||
_lateParticleOwnerScratch);
|
||||
}
|
||||
|
||||
// ONE unclipped submission for the union of every slice's cone
|
||||
// survivors, then retail's pre-building barrier drain. Under
|
||||
// retail's far→near walk, DrawBuilding's FlushAlphaList(0f)
|
||||
// @0x0059F2A0 can only ever flush content from cells FARTHER
|
||||
// than the building it precedes — a nearer emitter (the Holtburg
|
||||
// candle in front of a door) has not been inserted yet and
|
||||
// composites at a later flush, after that building's opaques.
|
||||
// Drain the far prefix only; nearer entries stay queued for the
|
||||
// DrawCells-boundary flush, which runs after the late dynamics
|
||||
// (AP-236 retirement).
|
||||
passes.DrawLandscapeStaticParticles(
|
||||
ctx,
|
||||
new RetailPViewLandscapeStaticParticleContext(
|
||||
slice,
|
||||
_lateParticleOwnerScratch));
|
||||
barrierSliceIndex++;
|
||||
}
|
||||
passes.FlushLandscapeAlpha();
|
||||
_staticParticleUnionScratch));
|
||||
_staticParticleUnionScratch.Clear();
|
||||
passes.FlushLandscapeAlphaFartherThan(
|
||||
LookInBarrierDrainDistance(
|
||||
_lookInFrames[0],
|
||||
ctx.Cells,
|
||||
ctx.CameraWorldPosition));
|
||||
}
|
||||
|
||||
// #124: far-building look-ins draw HERE — still inside the landscape
|
||||
|
|
@ -860,8 +1040,10 @@ public sealed class RetailPViewRenderer
|
|||
// LATE phase (per slice): outside-stage dynamics' meshes (#118 — drawn
|
||||
// pre-clear so the seal protects their aperture pixels; AFTER the
|
||||
// look-ins so a translucent portal mesh blends over a far interior
|
||||
// instead of being overpainted) + the scene-particle owners (statics +
|
||||
// dynamics cone survivors — flames ride here for the same reason).
|
||||
// instead of being overpainted). The scene-particle owners (statics +
|
||||
// dynamics cone survivors) accumulate across the slices and submit
|
||||
// ONCE, unclipped, after the loop.
|
||||
_staticParticleUnionScratch.Clear();
|
||||
probeSliceIndex = 0;
|
||||
foreach (var slice in clipAssembly.OutsideViewSlices)
|
||||
{
|
||||
|
|
@ -889,6 +1071,14 @@ public sealed class RetailPViewRenderer
|
|||
if (viewcone.SphereVisibleInOutsideSlice(probeSliceIndex, c, r))
|
||||
{
|
||||
_outdoorStaticScratch.Add(e);
|
||||
// Particles emit in the stage matching the PARENT CELL:
|
||||
// an INTERIOR dynamic whose sphere merely straddles an
|
||||
// exit-portal plane keeps its mesh in both stages (#118)
|
||||
// but its particles belong to the final pass — draining
|
||||
// them at the pre-clear boundary lets the interior stage
|
||||
// repaint over them except on seal-protected aperture
|
||||
// pixels (the cathedral middle-cell spell-star cut).
|
||||
if (!InteriorEntityPartition.IsIndoorCellId(e.ParentCellId))
|
||||
_lateParticleOwnerScratch.Add(e.Id);
|
||||
}
|
||||
}
|
||||
|
|
@ -929,36 +1119,74 @@ public sealed class RetailPViewRenderer
|
|||
0,
|
||||
ctx.PlayerLandblockId ?? 0);
|
||||
probeSliceIndex++;
|
||||
_staticParticleUnionScratch.UnionWith(_lateParticleOwnerScratch);
|
||||
passes.DrawLandscapeSliceLate(
|
||||
ctx,
|
||||
new RetailPViewLandscapeLateSliceContext(
|
||||
slice,
|
||||
_outdoorStaticScratch,
|
||||
_lateParticleOwnerScratch)
|
||||
_outdoorStaticScratch)
|
||||
{
|
||||
EntityDraw = entityDraw,
|
||||
});
|
||||
}
|
||||
|
||||
// ONE unclipped submission for every late-stage particle owner —
|
||||
// OUTDOOR-parented outside-stage dynamics' emitters plus, without
|
||||
// look-ins, the outdoor statics' emitters (retail: one alpha-list
|
||||
// insertion per emitter during the landscape walk; per-slice
|
||||
// re-submission with clip slots was the direction-dependent
|
||||
// disappearance class). Interior-parented straddlers appear in BOTH
|
||||
// the LandscapeOutsideDynamic and DynamicLast routes; their particles
|
||||
// emit only in the final pass, so remove them here.
|
||||
if (frameEntityPasses is not null)
|
||||
{
|
||||
RenderFrameRouteOwnerSelector.ExceptRoute(
|
||||
_staticParticleUnionScratch,
|
||||
in frameView,
|
||||
RenderFrameCandidateRoute.DynamicLast);
|
||||
}
|
||||
if (_staticParticleUnionScratch.Count > 0)
|
||||
{
|
||||
passes.DrawLandscapeStaticParticles(
|
||||
ctx,
|
||||
new RetailPViewLandscapeStaticParticleContext(
|
||||
_staticParticleUnionScratch));
|
||||
_staticParticleUnionScratch.Clear();
|
||||
}
|
||||
|
||||
// #131: UNATTACHED emitters (AttachedObjectId == 0 — portal swirls,
|
||||
// campfires, ground effects anchored at a position) have no owner id
|
||||
// to ride any of the id-filtered particle passes. Draw once per
|
||||
// installed outside_view for BOTH root kinds, matching retail's
|
||||
// landscape-stage placement and preserving the slot in each deferred
|
||||
// draw. The former outdoor-root post-world tail ran after building
|
||||
// cells and let exterior alpha repaint the cathedral transition.
|
||||
// With no look-ins they drain at the end of the landscape stage; the
|
||||
// look-in path submits them at its pre-building barrier so later opaque
|
||||
// cell floors can cover them.
|
||||
// to ride any of the id-filtered particle passes. OUTDOOR-cell ones
|
||||
// submit ONCE in the landscape stage, unclipped — retail inserts each
|
||||
// particle into the single alpha list during its owner cell's walk
|
||||
// turn (ShouldDrawParticles @0x0050FE60 gates by cell + distance;
|
||||
// FlushAlphaList @0x0059D2E0 depth-tests at composition). The former
|
||||
// once-per-outside-slice submission with that slice's clip slot cut
|
||||
// effects at aperture boundaries and drew NOTHING when no outside
|
||||
// slice was in view. Interior-cell unattached emitters submit in the
|
||||
// final world scope (DrawDynamicsLast) — in the landscape stage the
|
||||
// upcoming depth clear + interior repaint would erase them.
|
||||
if (!hasBuildingLookIns)
|
||||
{
|
||||
foreach (ClipViewSlice slice in clipAssembly.OutsideViewSlices)
|
||||
passes.DrawUnattachedSceneParticles(ctx, slice);
|
||||
}
|
||||
passes.DrawUnattachedSceneParticles(ctx, outdoorCells: true);
|
||||
|
||||
// Retail PView::DrawCells 0x005A4872 drains the landscape alpha list
|
||||
// immediately after LScape::draw and before the optional depth clear.
|
||||
// The queue remains active for the post-clear/final-world scope.
|
||||
//
|
||||
// Only an INTERIOR root drains here: its full depth clear follows, and
|
||||
// a flame drained after that clear would z-pass through every interior
|
||||
// wall. An OUTDOOR root has no depth clear (retail gates it on
|
||||
// portalsDrawnCount, pc:432731), and retail's LScape::draw walk has
|
||||
// already drawn every building interior and every cell object via
|
||||
// DrawSortCell 0x005A17C0 before that boundary — while our outdoor
|
||||
// frame draws punches, interior shells, cell objects, and ALL dynamics
|
||||
// (doors, creatures, NPCs) after this point. Draining here painted the
|
||||
// flames first and let each of those later opaque meshes overwrite
|
||||
// them (#132: "the door draws over the candle"); the outdoor drain
|
||||
// therefore runs after DrawDynamicsLast, where world depth is complete
|
||||
// and the one far-to-near list composites over everything, exactly as
|
||||
// retail's boundary flush does relative to its finished walk.
|
||||
if (!ctx.RootCell.IsOutdoorNode)
|
||||
passes.FlushLandscapeAlpha();
|
||||
|
||||
// T1: retail clears the FULL depth buffer ONCE between the outside
|
||||
|
|
@ -1132,13 +1360,13 @@ public sealed class RetailPViewRenderer
|
|||
Array.Empty<WorldEntity>(),
|
||||
visibleCellIds: null);
|
||||
|
||||
// An owner routed through any pre-clear outside slice already had
|
||||
// its alpha particles drawn there. Meshes may be submitted in both
|
||||
// stages, but particles must be emitted exactly once.
|
||||
RenderFrameRouteOwnerSelector.ExceptRoute(
|
||||
_dynamicParticleOwnerScratch,
|
||||
in frameView,
|
||||
RenderFrameCandidateRoute.LandscapeOutsideDynamic);
|
||||
// Particles emit exactly once, in the stage matching the parent
|
||||
// cell. Pure-outdoor dynamics are absent from the DynamicLast
|
||||
// route (they draw only in the outside stage), and interior
|
||||
// straddlers — present in BOTH routes — emit their particles
|
||||
// HERE so the interior stage cannot repaint over them; the late
|
||||
// landscape submission excludes DynamicLast owners for the same
|
||||
// reason.
|
||||
if (_dynamicParticleOwnerScratch.Count > 0)
|
||||
{
|
||||
passes.DrawDynamicsParticles(
|
||||
|
|
@ -1175,6 +1403,14 @@ public sealed class RetailPViewRenderer
|
|||
$"cell=0x{(e.ParentCellId ?? 0):X8} indoor=False rootOutdoor={rootIsOutdoor} -> CULLED(outside-stage)");
|
||||
continue;
|
||||
}
|
||||
// Drawn-once (retail DrawMeshInternal @0x0059F360 marks every
|
||||
// non-player part for the frame): a dynamic whose cell drew as a
|
||||
// building LOOK-IN already rendered with that cell inside the
|
||||
// landscape stage (#131). Redrawing it here would land AFTER the
|
||||
// boundary alpha drain and overpaint nearer flames — the Holtburg
|
||||
// door repainting the candle in front of it.
|
||||
if (indoor && _lookInCellIds.Contains(e.ParentCellId!.Value))
|
||||
continue;
|
||||
bool visible = indoor
|
||||
? viewcone.SphereVisibleInCell(e.ParentCellId!.Value, c, r)
|
||||
: viewcone.SphereVisibleOutside(c, r);
|
||||
|
|
@ -1221,22 +1457,28 @@ public sealed class RetailPViewRenderer
|
|||
// particles must not double-draw, unlike the depth-idempotent meshes).
|
||||
if (frameEntityPasses is not null)
|
||||
{
|
||||
// Parent-cell stage split: every DynamicLast owner emits its
|
||||
// particles here. Pure-outdoor dynamics are absent from this
|
||||
// route (outside stage only), and interior straddlers — whose
|
||||
// meshes drew in both stages — must emit HERE so the interior
|
||||
// stage cannot repaint over them (matches the production
|
||||
// partition-null path above).
|
||||
RenderFrameRouteOwnerSelector.Replace(
|
||||
_dynamicParticleOwnerScratch,
|
||||
in frameView,
|
||||
RenderFrameCandidateRoute.DynamicLast,
|
||||
0,
|
||||
0);
|
||||
RenderFrameRouteOwnerSelector.ExceptRoute(
|
||||
_dynamicParticleOwnerScratch,
|
||||
in frameView,
|
||||
RenderFrameCandidateRoute.LandscapeOutsideDynamic);
|
||||
}
|
||||
else
|
||||
{
|
||||
_dynamicParticleOwnerScratch.Clear();
|
||||
// Interior-parented dynamics — INCLUDING exit-portal straddlers
|
||||
// whose mesh also drew in the outside stage — emit particles in
|
||||
// this final pass; outdoor-parented ones emitted in the late
|
||||
// landscape submission (parent-cell stage split).
|
||||
foreach (var e in _dynamicsScratch)
|
||||
if (!_outsideStageDynamics.Contains(e))
|
||||
if (InteriorEntityPartition.IsIndoorCellId(e.ParentCellId))
|
||||
_dynamicParticleOwnerScratch.Add(e.Id);
|
||||
}
|
||||
if (_dynamicParticleOwnerScratch.Count > 0)
|
||||
|
|
@ -1628,10 +1870,29 @@ public interface IRetailPViewPassExecutor
|
|||
RetailPViewFrameInput frame,
|
||||
RetailPViewCellSliceContext context,
|
||||
int portalIndex);
|
||||
/// <summary>
|
||||
/// One unclipped submission for every renderable UNATTACHED emitter whose
|
||||
/// owner cell matches the scope: outdoor landcells in the landscape stage,
|
||||
/// interior EnvCells in the final world scope. Retail inserts each such
|
||||
/// particle into the single alpha list during its owner cell's walk turn
|
||||
/// and never clips it to a portal view.
|
||||
/// </summary>
|
||||
void DrawUnattachedSceneParticles(
|
||||
RetailPViewFrameInput frame,
|
||||
ClipViewSlice slice);
|
||||
bool outdoorCells);
|
||||
void FlushLandscapeAlpha();
|
||||
|
||||
/// <summary>
|
||||
/// Pre/inter-building barrier drain: composites only the queued alpha at
|
||||
/// or beyond <paramref name="minViewerDistance"/> and retains nearer
|
||||
/// entries for the later boundary flush — retail's far→near walk outcome
|
||||
/// (DrawBuilding's FlushAlphaList(0f) @0x0059F2A0 can only ever flush
|
||||
/// content from cells farther than that building; AP-236). The default
|
||||
/// falls back to a full flush so non-production executors keep today's
|
||||
/// behavior until they opt in.
|
||||
/// </summary>
|
||||
void FlushLandscapeAlphaFartherThan(float minViewerDistance) =>
|
||||
FlushLandscapeAlpha();
|
||||
void DrawCellParticles(RetailPViewFrameInput frame, RetailPViewCellSliceContext context);
|
||||
void DrawDynamicsParticles(RetailPViewFrameInput frame, IReadOnlySet<uint> ownerIds);
|
||||
void EmitDiagnostics(RetailPViewFrameInput frame, RetailPViewFrameResult result);
|
||||
|
|
@ -1908,6 +2169,23 @@ public sealed class RetailPViewFrameResult
|
|||
public PortalVisibilityFrame PortalFrame { get; private set; } = null!;
|
||||
public ClipFrameAssembly ClipAssembly { get; private set; } = null!;
|
||||
public HashSet<uint> DrawableCells { get; private set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Every cell this completed view actually reached: the main flood
|
||||
/// (<see cref="DrawableCells"/>) plus the building look-in cells. This is
|
||||
/// retail's per-cell <c>in_view</c> answer for effect consumers —
|
||||
/// <c>CPhysicsObj::ShouldDrawParticles</c> @0x0050FE60 gates on
|
||||
/// <c>cell->IsInView()</c>, and a cell entered through a building portal
|
||||
/// (<c>PView::ConstructView</c> @0x005A57B0, installed by
|
||||
/// <c>RenderDeviceD3D::DrawBuilding</c> @0x0059F2A0) is drawn by the same
|
||||
/// <c>PView::DrawCells</c> traversal as a flooded cell, so retail marks it
|
||||
/// in view identically. acdream's look-in adaptation keeps those cells out
|
||||
/// of <see cref="DrawableCells"/> (seals / outside-stage predicate stay
|
||||
/// main-flood scoped, #124); particle and light visibility must consume
|
||||
/// THIS set or look-in rooms render with frozen emitters and dark lights.
|
||||
/// </summary>
|
||||
public HashSet<uint> InViewCells { get; private set; } = null!;
|
||||
|
||||
internal RenderFrameDiagnosticCounts DiagnosticCounts { get; private set; }
|
||||
internal RenderProjectionCounts SourceCounts { get; private set; }
|
||||
internal InteriorEntityPartition.Result? DiagnosticPartition
|
||||
|
|
@ -1917,6 +2195,7 @@ public sealed class RetailPViewFrameResult
|
|||
PortalVisibilityFrame portalFrame,
|
||||
ClipFrameAssembly clipAssembly,
|
||||
HashSet<uint> drawableCells,
|
||||
HashSet<uint> inViewCells,
|
||||
RenderFrameDiagnosticCounts diagnosticCounts,
|
||||
RenderProjectionCounts sourceCounts,
|
||||
InteriorEntityPartition.Result? diagnosticPartition)
|
||||
|
|
@ -1924,6 +2203,7 @@ public sealed class RetailPViewFrameResult
|
|||
PortalFrame = portalFrame;
|
||||
ClipAssembly = clipAssembly;
|
||||
DrawableCells = drawableCells;
|
||||
InViewCells = inViewCells;
|
||||
DiagnosticCounts = diagnosticCounts;
|
||||
SourceCounts = sourceCounts;
|
||||
DiagnosticPartition = diagnosticPartition;
|
||||
|
|
@ -1939,6 +2219,7 @@ public sealed class RetailPViewFrameResult
|
|||
portalFrame,
|
||||
clipAssembly,
|
||||
drawableCells,
|
||||
drawableCells,
|
||||
RetailPViewRenderer.LegacyDiagnosticCounts(
|
||||
diagnosticPartition),
|
||||
RetailPViewRenderer.LegacySourceCounts(
|
||||
|
|
@ -1954,12 +2235,13 @@ public readonly record struct RetailPViewLandscapeSliceContext(
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Outdoor-static emitters submitted at retail's pre-building alpha barrier.
|
||||
/// Mesh alpha for the same owners is already queued by the early landscape
|
||||
/// entity route.
|
||||
/// Scene-particle owners for ONE unclipped landscape-stage submission (the
|
||||
/// union of every outside slice's cone survivors). Mesh alpha for the same
|
||||
/// owners is already queued by the entity routes; retail inserts each
|
||||
/// emitter's polys into the single alpha list once, during its owner cell's
|
||||
/// walk turn, with no portal-view clip.
|
||||
/// </summary>
|
||||
public readonly record struct RetailPViewLandscapeStaticParticleContext(
|
||||
ClipViewSlice Slice,
|
||||
IReadOnlySet<uint> ParticleOwnerIds);
|
||||
|
||||
/// <summary>Retail DrawBuilding's ordinary exterior-shell pass, issued after
|
||||
|
|
@ -1976,8 +2258,7 @@ public readonly record struct RetailPViewLandscapeBuildingShellSliceContext(
|
|||
/// submitted at a pre-building barrier.</summary>
|
||||
public readonly record struct RetailPViewLandscapeLateSliceContext(
|
||||
ClipViewSlice Slice,
|
||||
IReadOnlyList<WorldEntity> Dynamics,
|
||||
IReadOnlySet<uint> ParticleOwnerIds)
|
||||
IReadOnlyList<WorldEntity> Dynamics)
|
||||
{
|
||||
internal RenderFrameEntityDrawRequest? EntityDraw { get; init; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1128,6 +1128,12 @@ internal sealed class RenderScenePViewFrameBuilder
|
|||
private RenderProjectionRecord[] _cell = [];
|
||||
private RenderProjectionRecord[] _dirty = [];
|
||||
private RenderProjectionRecord[] _survivors = [];
|
||||
|
||||
// Cells drawn as building look-ins this frame — the DynamicLast route
|
||||
// honors retail's drawn-once contract (DrawMeshInternal @0x0059F360
|
||||
// marks every non-player part): an object whose cell drew with a look-in
|
||||
// must not enter the final dynamics route again.
|
||||
private readonly HashSet<uint> _lookInCellScratch = new();
|
||||
private RenderProjectionRecord[] _cellRoute = [];
|
||||
private readonly Dictionary<RenderProjectionId, int>
|
||||
_outdoorPositions = [];
|
||||
|
|
@ -1528,6 +1534,16 @@ internal sealed class RenderScenePViewFrameBuilder
|
|||
RenderFrameWriter writer,
|
||||
in RenderScenePViewBuildInput input)
|
||||
{
|
||||
_lookInCellScratch.Clear();
|
||||
for (int frameIndex = 0;
|
||||
frameIndex < input.LookInFrames.Count;
|
||||
frameIndex++)
|
||||
{
|
||||
PortalVisibilityFrame frame = input.LookInFrames[frameIndex];
|
||||
for (int i = 0; i < frame.OrderedVisibleCells.Count; i++)
|
||||
_lookInCellScratch.Add(frame.OrderedVisibleCells[i]);
|
||||
}
|
||||
|
||||
int count = 0;
|
||||
EnsureCapacity(ref _survivors, _dynamicCount);
|
||||
for (int i = 0; i < _dynamicCount; i++)
|
||||
|
|
@ -1539,6 +1555,14 @@ internal sealed class RenderScenePViewFrameBuilder
|
|||
if (!input.RootIsOutdoor && !indoor)
|
||||
continue;
|
||||
|
||||
// Drawn-once (retail DrawMeshInternal @0x0059F360): a dynamic
|
||||
// whose cell drew as a building look-in already rendered with
|
||||
// that cell in the landscape stage; re-entering the final route
|
||||
// would draw it after the boundary alpha drain and overpaint
|
||||
// nearer flames (the Holtburg candle-behind-door class).
|
||||
if (indoor && _lookInCellScratch.Contains(parentCellId!.Value))
|
||||
continue;
|
||||
|
||||
Sphere(in record, out Vector3 center, out float radius);
|
||||
bool visible = indoor
|
||||
? input.Viewcone.SphereVisibleInCell(
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -924,6 +924,24 @@ public sealed unsafe partial class WbDrawDispatcher
|
|||
return new RhiSection(allocation.Buffer, allocation.OffsetBytes, (uint)byteCount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #443 — the next classic draw is a PRIVATE pass (paperdoll, appraisal,
|
||||
/// chargen preview) and must take the plain ring transform path with
|
||||
/// <c>firstInstance = 0</c>, never an append into the shared world
|
||||
/// transform frame. The default mesh shaders index every parallel
|
||||
/// per-instance array (clip slots, light sets, indoor, opacity, selection,
|
||||
/// detail category) zero-based — only the packed world submission's
|
||||
/// shader convention subtracts a shared-arena prefix — so an
|
||||
/// arena-appended classic draw with a non-zero base reads zeroed
|
||||
/// per-instance data (opacity 0 ⇒ an invisible doll whenever a world
|
||||
/// frame is active; portal space worked only because the arena was
|
||||
/// inactive there). The private pass owns its own camera, lighting, and
|
||||
/// target; per the self-contained-render-state rule it must not depend on
|
||||
/// the world frame's pose address space at all. Consumed and cleared by
|
||||
/// the next <see cref="WriteWorldTransformSection"/>.
|
||||
/// </summary>
|
||||
internal bool NextClassicDrawIsPrivatePass;
|
||||
|
||||
private RhiSection WriteWorldTransformSection(
|
||||
IGpuFrame frame,
|
||||
ReadOnlySpan<float> matrixFloats,
|
||||
|
|
@ -939,7 +957,9 @@ public sealed unsafe partial class WbDrawDispatcher
|
|||
ObserveOrdinaryTransformDemand(
|
||||
frame.Serial,
|
||||
checked((uint)(matrixFloats.Length / 16)));
|
||||
if (!_worldTransformFrames.IsActive)
|
||||
bool privatePass = NextClassicDrawIsPrivatePass;
|
||||
NextClassicDrawIsPrivatePass = false;
|
||||
if (privatePass || !_worldTransformFrames.IsActive)
|
||||
{
|
||||
firstInstance = 0;
|
||||
return WriteRingSection(frame, matrixFloats);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -254,8 +254,14 @@ internal sealed class WorldSceneRenderer : IPreparedWorldSceneFramePhase
|
|||
camera.Camera.View,
|
||||
_diagnostics.CameraCellResolution));
|
||||
|
||||
_particleVisibility.MarkVisibleCells(pviewResult.DrawableCells);
|
||||
_frames.ObserveDrawableCells(pviewResult.DrawableCells);
|
||||
// Effect visibility consumes InViewCells (main flood ∪ look-in
|
||||
// cells), not the flood-only DrawableCells: retail's
|
||||
// ShouldDrawParticles @0x0050FE60 asks cell->IsInView(), and a
|
||||
// look-in cell drawn via DrawBuilding @0x0059F2A0 is in view
|
||||
// exactly like a flooded cell. Flood-only scoping froze
|
||||
// emitters and darkened lights in visible adjacent rooms.
|
||||
_particleVisibility.MarkVisibleCells(pviewResult.InViewCells);
|
||||
_frames.ObserveDrawableCells(pviewResult.InViewCells);
|
||||
_diagnostics.EmitPViewInput(
|
||||
pviewResult.PortalFrame,
|
||||
camera.ViewProjection,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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-
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
@ -60,7 +63,8 @@ internal sealed class CharacterManagementUiMountCoordinator : IDisposable
|
|||
resources.TemplateResolver,
|
||||
dialogs,
|
||||
_bindings,
|
||||
resources.Strings);
|
||||
resources.Strings,
|
||||
_openCredits);
|
||||
if (candidate is null)
|
||||
return;
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
433
src/AcDream.App/UI/Layout/CreditsUiController.cs
Normal file
433
src/AcDream.App/UI/Layout/CreditsUiController.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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>();
|
||||
|
|
|
|||
|
|
@ -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)];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,24 +115,16 @@ 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)
|
||||
appliedStateId = UiStateInfo.DirectStateId;
|
||||
Info.States.TryGetValue(appliedStateId, out selectedState);
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (UiElement child in Children)
|
||||
if (child is IUiDatStateful stateful)
|
||||
stateful.TrySetRetailState(UiStateInfo.DirectStateId);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
ActiveState = stateName;
|
||||
}
|
||||
}
|
||||
|
||||
// Per-state Invisible (dat property 0x3B): retail's SetState applies
|
||||
// the committed state's properties through UIElement::OnSetAttribute,
|
||||
|
|
@ -144,21 +137,11 @@ public class UiDatElement : UiElement, IUiDatStateful
|
|||
// Normal_rollover={0x3B:false}, i.e. hidden at rest, shown on
|
||||
// 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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -275,5 +275,6 @@ internal sealed class AckNakScheduler
|
|||
outboundIsaac: null);
|
||||
_send(datagram.Slice(0, datagramLength));
|
||||
_stats.NaksSent++;
|
||||
_stats.NakIdsSent += count;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
124
src/AcDream.Core.Net/Transport/RetailPacketLossAverager.cs
Normal file
124
src/AcDream.Core.Net/Transport/RetailPacketLossAverager.cs
Normal 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<unsigned short,40></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);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 <name>" (0x027B).</summary>
|
||||
public void SendAllegianceInfoRequest(string playerName)
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(ClientCommandRequests.BuildAllegianceInfoRequest(seq, playerName));
|
||||
}
|
||||
|
||||
public void SendBreakAllegianceBoot(string playerName, bool accountBoot)
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(ClientCommandRequests.BuildBreakAllegianceBoot(
|
||||
seq, playerName, accountBoot));
|
||||
}
|
||||
|
||||
public void SendAllegianceChatBoot(string playerName, string reason)
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(ClientCommandRequests.BuildAllegianceChatBoot(
|
||||
seq, playerName, reason));
|
||||
}
|
||||
|
||||
public void SendAllegianceChatGag(string playerName, bool enabled)
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(ClientCommandRequests.BuildAllegianceChatGag(
|
||||
seq, playerName, enabled));
|
||||
}
|
||||
|
||||
public void SendAddAllegianceBan(string playerName)
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(ClientCommandRequests.BuildAddAllegianceBan(seq, playerName));
|
||||
}
|
||||
|
||||
public void SendRemoveAllegianceBan(string playerName)
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(ClientCommandRequests.BuildRemoveAllegianceBan(seq, playerName));
|
||||
}
|
||||
|
||||
public void SendListAllegianceBans()
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(ClientCommandRequests.BuildListAllegianceBans(seq));
|
||||
}
|
||||
|
||||
public void SendSetAllegianceOfficer(string playerName, uint level)
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(ClientCommandRequests.BuildSetAllegianceOfficer(
|
||||
seq, playerName, level));
|
||||
}
|
||||
|
||||
public void SendRemoveAllegianceOfficer(string playerName)
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(ClientCommandRequests.BuildRemoveAllegianceOfficer(
|
||||
seq, playerName));
|
||||
}
|
||||
|
||||
public void SendListAllegianceOfficers()
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(ClientCommandRequests.BuildListAllegianceOfficers(seq));
|
||||
}
|
||||
|
||||
public void SendClearAllegianceOfficers()
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(ClientCommandRequests.BuildClearAllegianceOfficers(seq));
|
||||
}
|
||||
|
||||
public void SendSetAllegianceOfficerTitle(uint level, string title)
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(ClientCommandRequests.BuildSetAllegianceOfficerTitle(
|
||||
seq, level, title));
|
||||
}
|
||||
|
||||
public void SendListAllegianceOfficerTitles()
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(ClientCommandRequests.BuildListAllegianceOfficerTitles(seq));
|
||||
}
|
||||
|
||||
public void SendClearAllegianceOfficerTitles()
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(ClientCommandRequests.BuildClearAllegianceOfficerTitles(seq));
|
||||
}
|
||||
|
||||
public void SendQueryAllegianceName()
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(ClientCommandRequests.BuildQueryAllegianceName(seq));
|
||||
}
|
||||
|
||||
public void SendSetAllegianceName(string name)
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(ClientCommandRequests.BuildSetAllegianceName(seq, name));
|
||||
}
|
||||
|
||||
public void SendClearAllegianceName()
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(ClientCommandRequests.BuildClearAllegianceName(seq));
|
||||
}
|
||||
|
||||
public void SendAllegianceLockAction(uint action)
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(ClientCommandRequests.BuildAllegianceLockAction(seq, action));
|
||||
}
|
||||
|
||||
public void SendSetAllegianceApprovedVassal(string playerName)
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(ClientCommandRequests.BuildSetAllegianceApprovedVassal(
|
||||
seq, playerName));
|
||||
}
|
||||
|
||||
public void SendAllegianceHouseAction(uint action)
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(ClientCommandRequests.BuildAllegianceHouseAction(seq, action));
|
||||
}
|
||||
|
||||
public void SendQueryMotd()
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(ClientCommandRequests.BuildQueryMotd(seq));
|
||||
}
|
||||
|
||||
public void SendSetMotd(string motd)
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(ClientCommandRequests.BuildSetMotd(seq, motd));
|
||||
}
|
||||
|
||||
public void SendClearMotd()
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(ClientCommandRequests.BuildClearMotd(seq));
|
||||
}
|
||||
|
||||
public void SendSetOpenHouseStatus(bool isOpen)
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(ClientCommandRequests.BuildSetOpenHouseStatus(seq, isOpen));
|
||||
}
|
||||
|
||||
public void SendAddPermanentGuest(string playerName)
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(ClientCommandRequests.BuildAddPermanentGuest(seq, playerName));
|
||||
}
|
||||
|
||||
public void SendRemovePermanentGuest(string playerName)
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(ClientCommandRequests.BuildRemovePermanentGuest(seq, playerName));
|
||||
}
|
||||
|
||||
public void SendRemoveAllPermanentGuests()
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(ClientCommandRequests.BuildRemoveAllPermanentGuests(seq));
|
||||
}
|
||||
|
||||
public void SendChangeStoragePermission(string playerName, bool enabled)
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(ClientCommandRequests.BuildChangeStoragePermission(
|
||||
seq, playerName, enabled));
|
||||
}
|
||||
|
||||
public void SendAddAllStoragePermission()
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(ClientCommandRequests.BuildAddAllStoragePermission(seq));
|
||||
}
|
||||
|
||||
public void SendRemoveAllStoragePermission()
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(ClientCommandRequests.BuildRemoveAllStoragePermission(seq));
|
||||
}
|
||||
|
||||
public void SendRequestFullGuestList()
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(ClientCommandRequests.BuildRequestFullGuestList(seq));
|
||||
}
|
||||
|
||||
public void SendBootSpecificHouseGuest(string playerName)
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(ClientCommandRequests.BuildBootSpecificHouseGuest(
|
||||
seq, playerName));
|
||||
}
|
||||
|
||||
public void SendBootEveryone()
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(ClientCommandRequests.BuildBootEveryone(seq));
|
||||
}
|
||||
|
||||
public void SendSetHooksVisibility(bool visible)
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(ClientCommandRequests.BuildSetHooksVisibility(seq, visible));
|
||||
}
|
||||
|
||||
public void SendModifyAllegianceGuestPermission(bool enabled)
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(ClientCommandRequests.BuildModifyAllegianceGuestPermission(
|
||||
seq, enabled));
|
||||
}
|
||||
|
||||
public void SendModifyAllegianceStoragePermission(bool enabled)
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(ClientCommandRequests.BuildModifyAllegianceStoragePermission(
|
||||
seq, enabled));
|
||||
}
|
||||
|
||||
// ── Campaign FA slice FA2 (2026-08-12): fellowship + allegiance
|
||||
// outbound wrappers. SocialActions/AllegianceRequests ship the byte
|
||||
// builders (repaired/added in FA1); this is the missing
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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: "",
|
||||
|
|
|
|||
|
|
@ -271,8 +271,10 @@ public sealed class LightManager
|
|||
/// geometrically closer than the player's own room's torches and win the cap,
|
||||
/// leaving the visible room dark. Scoping candidacy to the frame's actual
|
||||
/// visible cells (the render already computes this — callers pass last frame's
|
||||
/// <c>RetailPViewFrameResult.DrawableCells</c>, one frame of latency, to avoid
|
||||
/// re-threading a mid-render callback) removes those from contention before the
|
||||
/// <c>RetailPViewFrameResult.InViewCells</c>, the main flood PLUS building
|
||||
/// look-in cells, one frame of latency, to avoid re-threading a mid-render
|
||||
/// callback; flood-only scoping darkened look-in rooms' lanterns) removes
|
||||
/// non-visible cells from contention before the
|
||||
/// cap ever applies. The distance-sort anchor stays the PLAYER either way — this
|
||||
/// parameter only narrows candidacy, it does not change the sort (the #176
|
||||
/// correction: CAMERA anchoring, not cell scoping itself, caused the earlier
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"/> <= <paramref name="lowFrame"/>), a
|
||||
/// non-positive <paramref name="framerate"/>, or a non-positive
|
||||
/// <paramref name="elapsedSeconds"/>.
|
||||
/// (<paramref name="highFrame"/> <= <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,
|
||||
|
|
|
|||
|
|
@ -526,7 +526,8 @@ public sealed class ParticleSystem : IParticleSystem
|
|||
IReadOnlySet<uint> attachedOwnerIds,
|
||||
bool includeUnattached,
|
||||
List<ParticleEmitter> destination,
|
||||
IReadOnlySet<uint>? excludedAttachedOwnerIds = null)
|
||||
IReadOnlySet<uint>? excludedAttachedOwnerIds = null,
|
||||
UnattachedEmitterCellScope unattachedCellScope = UnattachedEmitterCellScope.Any)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(attachedOwnerIds);
|
||||
ArgumentNullException.ThrowIfNull(destination);
|
||||
|
|
@ -539,10 +540,13 @@ public sealed class ParticleSystem : IParticleSystem
|
|||
foreach (int handle in _renderableUnattachedHandlesByPass[passIndex])
|
||||
{
|
||||
LastRenderScopeEmitterVisitCount++;
|
||||
if (_byHandle.TryGetValue(handle, out ParticleEmitter? emitter))
|
||||
if (_byHandle.TryGetValue(handle, out ParticleEmitter? emitter)
|
||||
&& MatchesUnattachedCellScope(emitter, unattachedCellScope))
|
||||
{
|
||||
destination.Add(emitter);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Dictionary<uint, OwnerEmitterBucket> owners = _ownerHandlesByPass[passIndex];
|
||||
foreach (uint ownerId in attachedOwnerIds)
|
||||
|
|
@ -566,6 +570,29 @@ public sealed class ParticleSystem : IParticleSystem
|
|||
destination.Sort(static (left, right) => left.Handle.CompareTo(right.Handle));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Splits unattached emitters by their owner cell kind so each draws once
|
||||
/// in its retail stage: an outdoor landcell emitter belongs to the
|
||||
/// landscape stage (before the depth clear), an interior EnvCell emitter
|
||||
/// to the final world stage (after the seals). Retail gets this for free
|
||||
/// because a particle draws during its owner CELL's walk turn
|
||||
/// (CPhysicsObj::ShouldDrawParticles @0x0050FE60 reads the one cell).
|
||||
/// AC cell convention: low word < 0x0100 is an outdoor landcell,
|
||||
/// 0x0100..0xFFFD is an interior EnvCell. Cell 0 matches neither scoped
|
||||
/// mode — such an emitter cannot pass the world in-view gate anyway.
|
||||
/// </summary>
|
||||
private static bool MatchesUnattachedCellScope(
|
||||
ParticleEmitter emitter,
|
||||
UnattachedEmitterCellScope scope)
|
||||
{
|
||||
if (scope == UnattachedEmitterCellScope.Any)
|
||||
return true;
|
||||
uint low = emitter.OwnerCellId & 0xFFFFu;
|
||||
return scope == UnattachedEmitterCellScope.OutdoorCells
|
||||
? low != 0 && low < 0x0100u
|
||||
: low >= 0x0100u;
|
||||
}
|
||||
|
||||
public readonly struct LiveEmitterEnumerable : IEnumerable<ParticleEmitter>
|
||||
{
|
||||
private readonly ParticleSystem _owner;
|
||||
|
|
|
|||
|
|
@ -46,6 +46,20 @@ public enum ParticleRenderPass
|
|||
SkyPostScene = 2,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Which unattached emitters a scoped render copy admits, by owner cell kind.
|
||||
/// Retail draws every particle during its owner CELL's walk turn, so an
|
||||
/// outdoor-cell emitter renders in the landscape stage and an interior-cell
|
||||
/// emitter in the final world stage; acdream draws each group once in the
|
||||
/// matching stage instead of per portal slice.
|
||||
/// </summary>
|
||||
public enum UnattachedEmitterCellScope
|
||||
{
|
||||
Any = 0,
|
||||
OutdoorCells = 1,
|
||||
InteriorCells = 2,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Authority used by retail's particle presentation gate. World-owned
|
||||
/// emitters follow <c>CPhysicsObj::ShouldDrawParticles</c>; examination and
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 <name>" — request allegiance member info.</summary>
|
||||
AllegianceInfo,
|
||||
/// <summary>"@allegiance boot [-account] <name>".</summary>
|
||||
AllegianceBoot,
|
||||
/// <summary>"@allegiance ban ..." administration dispatcher.</summary>
|
||||
AllegianceBan,
|
||||
/// <summary>"@allegiance chat ..." administration dispatcher.</summary>
|
||||
AllegianceChat,
|
||||
/// <summary>"@allegiance broadcast <text>".</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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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 <sub></c> / <c>@hou <sub></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 <sub></c> / <c>@all <sub></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;
|
||||
|
|
|
|||
|
|
@ -10,8 +10,7 @@ namespace AcDream.Runtime.Chat;
|
|||
/// registers these with NO handler; typing them bare reaches the server,
|
||||
/// only <c>@help <verb></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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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->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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,9 +1133,27 @@ 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)
|
||||
{
|
||||
_pendingProjectionRetryScratchByDepth.Add([]);
|
||||
}
|
||||
List<RuntimePlacementProjectionSnapshot> snapshot =
|
||||
_pendingProjectionRetryScratchByDepth[depth];
|
||||
_pendingProjectionRetryDepth = depth + 1;
|
||||
try
|
||||
{
|
||||
snapshot.Clear();
|
||||
// Enumerate the dictionary itself. SortedDictionary.Values exposes
|
||||
// its enumerator through an interface and boxes it (~72 B/call),
|
||||
// which would retain a smaller version of the allocation this
|
||||
// issue removes.
|
||||
foreach (KeyValuePair<ulong, RuntimePlacementProjectionSnapshot>
|
||||
entry in _pendingProjection)
|
||||
{
|
||||
snapshot.Add(entry.Value);
|
||||
}
|
||||
for (int index = 0; index < snapshot.Count; index++)
|
||||
{
|
||||
RuntimePlacementProjectionSnapshot projection = snapshot[index];
|
||||
if (_pendingProjection.TryGetValue(
|
||||
|
|
@ -1131,6 +1165,12 @@ internal sealed class RuntimeSetPositionState : IDisposable
|
|||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
snapshot.Clear();
|
||||
_pendingProjectionRetryDepth = depth;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// C0-1: publishes the initial-create continuation executor's own
|
||||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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: _ => { });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -8,6 +8,14 @@ namespace AcDream.App.Tests.Rendering;
|
|||
|
||||
public sealed class PaperdollFramePresenterTests
|
||||
{
|
||||
/// <summary>One frame in production order: the pre-world resource phase
|
||||
/// (build/redress + prewarm) then the late presentation phase.</summary>
|
||||
private static void Frame(PaperdollFramePresenter presenter)
|
||||
{
|
||||
presenter.PrepareResources();
|
||||
presenter.Render();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HiddenView_BuildsAndPrewarmsWithoutRendering()
|
||||
{
|
||||
|
|
@ -16,7 +24,7 @@ public sealed class PaperdollFramePresenterTests
|
|||
var factory = new RecordingFactory { Doll = CreateDoll() };
|
||||
var presenter = new PaperdollFramePresenter(renderer, view, factory);
|
||||
|
||||
presenter.Render();
|
||||
Frame(presenter);
|
||||
|
||||
Assert.False(presenter.IsDirty);
|
||||
Assert.Equal(1, factory.BuildCount);
|
||||
|
|
@ -34,7 +42,7 @@ public sealed class PaperdollFramePresenterTests
|
|||
var factory = new RecordingFactory { Doll = doll };
|
||||
var presenter = new PaperdollFramePresenter(renderer, view, factory);
|
||||
|
||||
presenter.Render();
|
||||
Frame(presenter);
|
||||
|
||||
Assert.False(presenter.IsDirty);
|
||||
Assert.Equal(1, factory.BuildCount);
|
||||
|
|
@ -54,20 +62,25 @@ public sealed class PaperdollFramePresenterTests
|
|||
};
|
||||
var presenter = new PaperdollFramePresenter(renderer, view, factory);
|
||||
|
||||
presenter.Render();
|
||||
presenter.Render();
|
||||
Frame(presenter);
|
||||
Frame(presenter);
|
||||
presenter.MarkDirty();
|
||||
presenter.Render();
|
||||
Frame(presenter);
|
||||
|
||||
Assert.Equal(2, factory.BuildCount);
|
||||
Assert.Single(renderer.Dolls);
|
||||
Assert.Equal(2, renderer.Dolls.Count);
|
||||
Assert.Equal(3, renderer.RenderCount);
|
||||
Assert.False(presenter.IsDirty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EquivalentPortalRefresh_KeepsPrivateDollAndTextureOwner()
|
||||
public void PortalRefresh_RedressesEvenAnEquivalentAppearance()
|
||||
{
|
||||
// #443: the private clone belongs to the current presentation
|
||||
// generation. Retaining an old clone because its pixels compare equal
|
||||
// can pin retired mesh/composite readiness that never completes
|
||||
// again; the renderer's two-phase promote keeps the last completed
|
||||
// image visible while the fresh clone becomes drawable.
|
||||
WorldEntity first = CreateDoll();
|
||||
WorldEntity repeated = CreateDoll();
|
||||
var renderer = new RecordingRenderer();
|
||||
|
|
@ -75,13 +88,13 @@ public sealed class PaperdollFramePresenterTests
|
|||
var factory = new RecordingFactory { Doll = first };
|
||||
var presenter = new PaperdollFramePresenter(renderer, view, factory);
|
||||
|
||||
presenter.Render();
|
||||
Frame(presenter);
|
||||
factory.Doll = repeated;
|
||||
presenter.MarkDirty();
|
||||
presenter.Render();
|
||||
Frame(presenter);
|
||||
|
||||
Assert.Equal(2, factory.BuildCount);
|
||||
Assert.Equal([first], renderer.Dolls);
|
||||
Assert.Equal([first, repeated], renderer.Dolls);
|
||||
Assert.Equal(2, renderer.RenderCount);
|
||||
Assert.False(presenter.IsDirty);
|
||||
}
|
||||
|
|
@ -96,14 +109,34 @@ public sealed class PaperdollFramePresenterTests
|
|||
var factory = new RecordingFactory { Doll = first };
|
||||
var presenter = new PaperdollFramePresenter(renderer, view, factory);
|
||||
|
||||
presenter.Render();
|
||||
Frame(presenter);
|
||||
factory.Doll = changed;
|
||||
presenter.MarkDirty();
|
||||
presenter.Render();
|
||||
Frame(presenter);
|
||||
|
||||
Assert.Equal([first, changed], renderer.Dolls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TransientZeroRender_NeverErasesThePublishedTexture()
|
||||
{
|
||||
// #443: zero is a not-ready result (mesh/composite upload latency),
|
||||
// not a request to blank a completed paperdoll.
|
||||
var renderer = new RecordingRenderer { TextureHandle = 91u };
|
||||
var view = new RecordingView();
|
||||
var factory = new RecordingFactory { Doll = CreateDoll() };
|
||||
var presenter = new PaperdollFramePresenter(renderer, view, factory);
|
||||
|
||||
Frame(presenter);
|
||||
renderer.TextureHandle = 0u;
|
||||
Frame(presenter);
|
||||
renderer.TextureHandle = 91u;
|
||||
Frame(presenter);
|
||||
|
||||
Assert.Equal([91u, 91u], view.TextureHandles);
|
||||
Assert.Equal(0, view.ClearCount);
|
||||
}
|
||||
|
||||
private static WorldEntity CreateDoll(float scale = 1f) => new()
|
||||
{
|
||||
Id = 42u,
|
||||
|
|
@ -123,11 +156,11 @@ public sealed class PaperdollFramePresenterTests
|
|||
var factory = new RecordingFactory { Doll = firstDoll };
|
||||
var presenter = new PaperdollFramePresenter(renderer, view, factory);
|
||||
|
||||
presenter.Render();
|
||||
Frame(presenter);
|
||||
factory.CanBuild = false;
|
||||
presenter.MarkDirty();
|
||||
presenter.Render();
|
||||
presenter.Render();
|
||||
Frame(presenter);
|
||||
Frame(presenter);
|
||||
|
||||
Assert.True(presenter.IsDirty);
|
||||
Assert.Equal(3, factory.BuildCount);
|
||||
|
|
@ -146,14 +179,17 @@ public sealed class PaperdollFramePresenterTests
|
|||
var factory = new RecordingFactory { Doll = firstDoll };
|
||||
var presenter = new PaperdollFramePresenter(renderer, view, factory);
|
||||
|
||||
presenter.Render();
|
||||
Frame(presenter);
|
||||
presenter.ResetSession();
|
||||
factory.Doll = secondDoll;
|
||||
presenter.Render();
|
||||
Frame(presenter);
|
||||
|
||||
Assert.False(presenter.IsDirty);
|
||||
Assert.Equal(2, factory.BuildCount);
|
||||
Assert.Equal([firstDoll, null, secondDoll], renderer.Dolls);
|
||||
// The session boundary is the ONE explicit viewport clear (#443);
|
||||
// an old character must not linger while the next one uploads.
|
||||
Assert.Equal(1, view.ClearCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -252,7 +288,7 @@ public sealed class PaperdollFramePresenterTests
|
|||
|
||||
private sealed class RecordingRenderer : IPaperdollDollRenderer
|
||||
{
|
||||
public uint TextureHandle { get; init; }
|
||||
public uint TextureHandle { get; set; }
|
||||
public List<WorldEntity?> Dolls { get; } = [];
|
||||
public List<(int Width, int Height)> RenderSizes { get; } = [];
|
||||
public int RenderCount => RenderSizes.Count;
|
||||
|
|
@ -285,6 +321,10 @@ public sealed class PaperdollFramePresenterTests
|
|||
|
||||
public void SetTextureHandle(uint textureHandle) =>
|
||||
TextureHandles.Add(textureHandle);
|
||||
|
||||
public int ClearCount { get; private set; }
|
||||
|
||||
public void ClearTextureHandle() => ClearCount++;
|
||||
}
|
||||
|
||||
private sealed class RecordingFactory : IPaperdollDollFactory
|
||||
|
|
|
|||
|
|
@ -0,0 +1,65 @@
|
|||
using AcDream.App.Rendering;
|
||||
using AcDream.App.Rendering.Gpu;
|
||||
using AcDream.App.Tests.Rendering.Gpu;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering;
|
||||
|
||||
public sealed class PrivateViewportFlightTargetsTests
|
||||
{
|
||||
[Fact]
|
||||
public void FlightSlotsOwnDistinctTargetsAndPublishOnlyCompletedScenes()
|
||||
{
|
||||
using var device = new RecordingGpuDevice();
|
||||
using var targets =
|
||||
new PrivateEntityViewportRenderer.PrivateViewportFlightTargets(
|
||||
device,
|
||||
"paperdoll");
|
||||
|
||||
var first = Assert.IsType<
|
||||
PrivateEntityViewportRenderer.PrivateViewportFlightTargets.TargetSlot>(
|
||||
targets.Ensure(0, 120, 180));
|
||||
var second = Assert.IsType<
|
||||
PrivateEntityViewportRenderer.PrivateViewportFlightTargets.TargetSlot>(
|
||||
targets.Ensure(1, 120, 180));
|
||||
|
||||
Assert.NotSame(first.Target, second.Target);
|
||||
Assert.NotEqual(first.TextureSlot, second.TextureSlot);
|
||||
Assert.Equal(2, targets.AllocatedSlotCount);
|
||||
Assert.Equal(0u, targets.CompletedHandle(0));
|
||||
Assert.Equal(0u, targets.CompletedHandle(1));
|
||||
|
||||
first.HasRenderedScene = true;
|
||||
|
||||
Assert.NotEqual(0u, targets.CompletedHandle(0));
|
||||
Assert.Equal(0u, targets.CompletedHandle(1));
|
||||
Assert.Same(first, targets.Ensure(0, 120, 180));
|
||||
|
||||
targets.InvalidateCompletedScenes();
|
||||
|
||||
Assert.Equal(0u, targets.CompletedHandle(0));
|
||||
Assert.Equal(2, targets.AllocatedSlotCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResizeRetiresEveryFlightTargetBeforeCreatingTheNewExtent()
|
||||
{
|
||||
using var device = new RecordingGpuDevice();
|
||||
using var targets =
|
||||
new PrivateEntityViewportRenderer.PrivateViewportFlightTargets(
|
||||
device,
|
||||
"paperdoll");
|
||||
var first = targets.Ensure(0, 120, 180)!;
|
||||
var second = targets.Ensure(1, 120, 180)!;
|
||||
var firstTarget = Assert.IsType<RecordingGpuRenderTarget>(first.Target);
|
||||
var secondTarget = Assert.IsType<RecordingGpuRenderTarget>(second.Target);
|
||||
|
||||
var resized = targets.Ensure(1, 160, 220)!;
|
||||
|
||||
Assert.True(firstTarget.IsDisposed);
|
||||
Assert.True(secondTarget.IsDisposed);
|
||||
Assert.Equal(1, targets.AllocatedSlotCount);
|
||||
Assert.Equal(160, resized.Target.Description.Width);
|
||||
Assert.Equal(220, resized.Target.Description.Height);
|
||||
Assert.Equal(3, device.CreatedRenderTargets.Count);
|
||||
}
|
||||
}
|
||||
|
|
@ -75,6 +75,80 @@ public sealed class RetailAlphaQueueTests
|
|||
Assert.Equal(2, source.ResetCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FlushFartherThan_DrainsOnlyTheFarPrefixAndRetainsNearerEntries()
|
||||
{
|
||||
// The pre-building barrier: retail's far→near walk means DrawBuilding's
|
||||
// FlushAlphaList(0f) @0x0059F2A0 can only flush content from cells
|
||||
// farther than that building; a nearer candle flame is not inserted
|
||||
// yet and composites after the building at a later flush (AP-236).
|
||||
var log = new List<string>();
|
||||
var objects = new RecordingSource("object", log);
|
||||
var particles = new RecordingSource("particle", log);
|
||||
var queue = new RetailAlphaQueue();
|
||||
|
||||
queue.BeginFrame();
|
||||
queue.Submit(particles, 0, 30f); // far waterfall
|
||||
queue.Submit(objects, 0, 25f); // far translucent part
|
||||
queue.Submit(particles, 1, 10f); // exactly at the building threshold
|
||||
queue.Submit(particles, 2, 5f); // near candle flame — must be kept
|
||||
queue.FlushFartherThan(10f);
|
||||
|
||||
Assert.True(queue.IsCollecting);
|
||||
Assert.Equal(1, queue.PendingCount);
|
||||
Assert.Equal(new[] { "particle:0", "object:0", "particle:1" }, log);
|
||||
Assert.Equal(0, objects.ResetCount);
|
||||
Assert.Equal(0, particles.ResetCount);
|
||||
|
||||
queue.EndFrame();
|
||||
|
||||
Assert.Equal(
|
||||
new[] { "particle:0", "object:0", "particle:1", "particle:2" },
|
||||
log);
|
||||
Assert.Equal(1, objects.ResetCount);
|
||||
Assert.Equal(1, particles.ResetCount);
|
||||
Assert.Equal(2, particles.PrepareCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FlushFartherThan_WithNoFarEntries_LeavesTheQueueUntouched()
|
||||
{
|
||||
var log = new List<string>();
|
||||
var source = new RecordingSource("alpha", log);
|
||||
var queue = new RetailAlphaQueue();
|
||||
|
||||
queue.BeginFrame();
|
||||
queue.Submit(source, 1, 4f);
|
||||
queue.FlushFartherThan(10f);
|
||||
|
||||
Assert.Empty(log);
|
||||
Assert.Equal(1, queue.PendingCount);
|
||||
Assert.Equal(0, source.PrepareCount);
|
||||
Assert.Equal(0, source.ResetCount);
|
||||
queue.EndFrame();
|
||||
Assert.Equal(new[] { "alpha:1" }, log);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FlushFartherThan_DegenerateThreshold_DrainsAllWithoutResettingSources()
|
||||
{
|
||||
var log = new List<string>();
|
||||
var source = new RecordingSource("alpha", log);
|
||||
var queue = new RetailAlphaQueue();
|
||||
|
||||
queue.BeginFrame();
|
||||
queue.Submit(source, 1, 8f);
|
||||
queue.Submit(source, 2, 2f);
|
||||
queue.FlushFartherThan(0f);
|
||||
|
||||
Assert.Equal(new[] { "alpha:1", "alpha:2" }, log);
|
||||
Assert.Equal(0, queue.PendingCount);
|
||||
Assert.Equal(0, source.ResetCount);
|
||||
Assert.True(queue.IsCollecting);
|
||||
queue.EndFrame();
|
||||
Assert.Equal(1, source.ResetCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Flush_BatchesOnlyAdjacentEntriesFromSameRenderer()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -34,13 +34,20 @@ public sealed class RetailPViewPassExecutorTests
|
|||
"terrain-clip",
|
||||
"clear-routing",
|
||||
"landscape-late",
|
||||
"unattached-particles",
|
||||
"landscape-alpha",
|
||||
"unattached-particles-outdoor",
|
||||
// #132: an OUTDOOR root does NOT drain at the stage boundary.
|
||||
// The far prefix drains at the pre-punch barrier (retail
|
||||
// DrawBuilding @0x0059F2A0 flushes before its portal-only
|
||||
// far-Z pass), and the full drain runs after the dynamics
|
||||
// pass, where the frame's opaque world depth is complete.
|
||||
"indoor-routing",
|
||||
"indoor-routing",
|
||||
"landscape-alpha-farther",
|
||||
"exit-mask",
|
||||
"indoor-routing",
|
||||
"opaque-shells",
|
||||
"landscape-alpha",
|
||||
"unattached-particles-interior",
|
||||
],
|
||||
executor.Operations);
|
||||
}
|
||||
|
|
@ -65,7 +72,7 @@ public sealed class RetailPViewPassExecutorTests
|
|||
string.Join('|', executor.Operations),
|
||||
"landscape-early",
|
||||
"landscape-late",
|
||||
"unattached-particles",
|
||||
"unattached-particles-outdoor",
|
||||
"landscape-alpha",
|
||||
"interior-depth-clear",
|
||||
"indoor-routing",
|
||||
|
|
@ -93,6 +100,32 @@ public sealed class RetailPViewPassExecutorTests
|
|||
Assert.DoesNotContain("interior-depth-clear", executor.Operations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DrawInside_interior_without_an_outside_slice_still_draws_interior_unattached_particles()
|
||||
{
|
||||
// Repro (Sanctuary middle cell, looking north): spell ground effects
|
||||
// vanished whenever no exit portal was in view, because unattached
|
||||
// emitters submitted once PER outside slice under that slice's
|
||||
// hardware clip slot — zero slices meant zero submissions. Retail
|
||||
// draws such an emitter during its owner cell's walk turn
|
||||
// (ShouldDrawParticles @0x0050FE60) and never clips it to a view.
|
||||
var renderer = new RetailPViewRenderer();
|
||||
using var executor = new RecordingExecutor();
|
||||
var root = new LoadedCell
|
||||
{
|
||||
CellId = 0xA9B40100u,
|
||||
WorldTransform = Matrix4x4.Identity,
|
||||
InverseWorldTransform = Matrix4x4.Identity,
|
||||
};
|
||||
|
||||
renderer.DrawInside(Frame(root), executor);
|
||||
|
||||
Assert.Contains("unattached-particles-interior", executor.Operations);
|
||||
Assert.DoesNotContain(
|
||||
"unattached-particles-outdoor",
|
||||
executor.Operations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Particle_classifications_reset_before_an_empty_following_frame()
|
||||
{
|
||||
|
|
@ -277,9 +310,9 @@ public sealed class RetailPViewPassExecutorTests
|
|||
AssertAppearsInOrder(
|
||||
string.Join('|', executor.Operations),
|
||||
"landscape-early",
|
||||
"unattached-particles",
|
||||
"unattached-particles-outdoor",
|
||||
"landscape-static-particles",
|
||||
"landscape-alpha",
|
||||
"landscape-alpha-farther",
|
||||
"look-in-punch",
|
||||
"landscape-late",
|
||||
"landscape-alpha",
|
||||
|
|
@ -353,7 +386,7 @@ public sealed class RetailPViewPassExecutorTests
|
|||
"look-in-punch",
|
||||
"landscape-building-shell",
|
||||
"landscape-static-particles",
|
||||
"landscape-alpha",
|
||||
"landscape-alpha-farther",
|
||||
"look-in-punch",
|
||||
"landscape-building-shell");
|
||||
}
|
||||
|
|
@ -763,8 +796,13 @@ public sealed class RetailPViewPassExecutorTests
|
|||
int portalIndex) => Operations.Add("look-in-punch");
|
||||
public void DrawUnattachedSceneParticles(
|
||||
RetailPViewFrameInput frame,
|
||||
ClipViewSlice slice) => Operations.Add("unattached-particles");
|
||||
bool outdoorCells) => Operations.Add(
|
||||
outdoorCells
|
||||
? "unattached-particles-outdoor"
|
||||
: "unattached-particles-interior");
|
||||
public void FlushLandscapeAlpha() => Operations.Add("landscape-alpha");
|
||||
public void FlushLandscapeAlphaFartherThan(float minViewerDistance) =>
|
||||
Operations.Add("landscape-alpha-farther");
|
||||
public void DrawCellParticles(RetailPViewFrameInput frame, RetailPViewCellSliceContext context) => Operations.Add("cell-particles");
|
||||
public void DrawDynamicsParticles(
|
||||
RetailPViewFrameInput frame,
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -251,6 +251,29 @@ public sealed class WorldSceneRendererTests
|
|||
Assert.Equal(4, rig.PView.LastInput.RenderRadius);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PViewWorld_PublishesLookInCellsToParticleAndLightVisibility()
|
||||
{
|
||||
var root = new LoadedCell
|
||||
{
|
||||
CellId = 0x01010001u,
|
||||
IsOutdoorNode = false,
|
||||
};
|
||||
var rig = new Rig(portalVisible: false, waitingForLogin: false, clipRoot: root);
|
||||
|
||||
rig.Renderer.Render(default);
|
||||
|
||||
// Retail gates effects on cell->IsInView() (ShouldDrawParticles
|
||||
// @0x0050FE60), and a cell entered through a building portal
|
||||
// (DrawBuilding @0x0059F2A0 -> PView::ConstructView @0x005A57B0) is
|
||||
// drawn by the same traversal as a flooded cell. The particle gate and
|
||||
// the light-candidate scope must therefore receive InViewCells (flood
|
||||
// plus look-ins), not the flood-only DrawableCells.
|
||||
Assert.Contains(0x01010003u, rig.Visibility.MarkedCells);
|
||||
Assert.NotNull(rig.Frames.ObservedCells);
|
||||
Assert.Contains(0x01010003u, rig.Frames.ObservedCells!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PViewWorld_ReusesOneSynchronousFrameInputAcrossFrames()
|
||||
{
|
||||
|
|
@ -536,7 +559,8 @@ public sealed class WorldSceneRendererTests
|
|||
Frames = new FrameBuilder(Calls, frame);
|
||||
Selection = new SelectionFrame(Calls);
|
||||
var alpha = new AlphaFrame(Calls);
|
||||
var visibility = new ParticleVisibility(Calls);
|
||||
Visibility = new ParticleVisibility(Calls);
|
||||
var visibility = Visibility;
|
||||
PView = new PViewRenderer(Calls);
|
||||
Passes = new PassExecutor(Calls);
|
||||
var diagnostics = new Diagnostics(Calls);
|
||||
|
|
@ -573,6 +597,8 @@ public sealed class WorldSceneRendererTests
|
|||
|
||||
public SelectionFrame Selection { get; }
|
||||
|
||||
public ParticleVisibility Visibility { get; }
|
||||
|
||||
public PViewRenderer PView { get; }
|
||||
|
||||
public PassExecutor Passes { get; }
|
||||
|
|
@ -621,8 +647,13 @@ public sealed class WorldSceneRendererTests
|
|||
return frame;
|
||||
}
|
||||
|
||||
public void ObserveDrawableCells(IReadOnlySet<uint> drawableCells) =>
|
||||
public IReadOnlySet<uint>? ObservedCells { get; private set; }
|
||||
|
||||
public void ObserveDrawableCells(IReadOnlySet<uint> drawableCells)
|
||||
{
|
||||
calls.Add("frame:observe-cells");
|
||||
ObservedCells = new HashSet<uint>(drawableCells);
|
||||
}
|
||||
|
||||
public void ClearDrawableCells() => calls.Add("frame:clear-cells");
|
||||
}
|
||||
|
|
@ -684,8 +715,13 @@ public sealed class WorldSceneRendererTests
|
|||
private sealed class ParticleVisibility(List<string> calls) :
|
||||
IWorldSceneParticleVisibility
|
||||
{
|
||||
public void MarkVisibleCells(HashSet<uint> cellIds) =>
|
||||
public HashSet<uint> MarkedCells { get; } = [];
|
||||
|
||||
public void MarkVisibleCells(HashSet<uint> cellIds)
|
||||
{
|
||||
calls.Add("visibility:mark");
|
||||
MarkedCells.UnionWith(cellIds);
|
||||
}
|
||||
|
||||
public void CompleteFrame() => calls.Add("visibility:complete");
|
||||
|
||||
|
|
@ -701,11 +737,17 @@ public sealed class WorldSceneRendererTests
|
|||
public PViewRenderer(List<string> calls)
|
||||
{
|
||||
_calls = calls;
|
||||
// Distinct flood-only vs in-view sets: 0x01010003 is a look-in
|
||||
// cell that is drawn but never part of the main flood.
|
||||
var interiorPartition = new InteriorEntityPartition.Result();
|
||||
_interiorResult = new RetailPViewFrameResult().Reset(
|
||||
new PortalVisibilityFrame(),
|
||||
new ClipFrameAssembly(),
|
||||
[],
|
||||
new InteriorEntityPartition.Result());
|
||||
[0x01010001u],
|
||||
[0x01010001u, 0x01010003u],
|
||||
RetailPViewRenderer.LegacyDiagnosticCounts(interiorPartition),
|
||||
RetailPViewRenderer.LegacySourceCounts(interiorPartition),
|
||||
interiorPartition);
|
||||
var outdoorPortalFrame = new PortalVisibilityFrame();
|
||||
outdoorPortalFrame.OutsideView.Add(new ViewPolygon(
|
||||
[
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue