Compare commits
91 commits
claude/win
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f7aa8e0eb7 | ||
|
|
d3df4cb20a | ||
|
|
45ba42a3a7 | ||
|
|
cccae4169a | ||
|
|
d8c794a478 | ||
|
|
0dd966f3a0 | ||
|
|
d123c4b67c | ||
|
|
c4608b1127 | ||
|
|
4e6e9bc9d9 | ||
|
|
1d2f2f738f | ||
|
|
f6fe0f2a4f | ||
|
|
0c699240e0 | ||
|
|
af9327a17b | ||
|
|
f160f3fee1 | ||
|
|
82e4b4cb6d | ||
|
|
ddbd7e4096 | ||
|
|
65f6f5848a | ||
|
|
87e9839561 | ||
|
|
9f3e326374 | ||
|
|
8f8c0c3a07 | ||
|
|
adfce16bf1 | ||
|
|
bf8f5b70a9 | ||
|
|
4ade9b0427 | ||
|
|
bc48e3216a | ||
|
|
1616cd3d39 | ||
|
|
bde5cae031 | ||
|
|
cc5290af7d | ||
|
|
f8a2258979 | ||
|
|
0332466fdc | ||
|
|
974fe88ade | ||
|
|
9ffc869925 | ||
|
|
752782d0a9 | ||
|
|
9e85d82325 | ||
|
|
90a0da6820 | ||
|
|
025108a8aa | ||
|
|
989f665214 | ||
|
|
6561d08fa8 | ||
|
|
996cd73675 | ||
|
|
ec50455a63 | ||
|
|
4cbbdaf4bf | ||
|
|
0a37a28e76 | ||
|
|
657b84c297 | ||
|
|
f532f28c5b | ||
|
|
01e44a7018 | ||
|
|
e7e32409c2 | ||
|
|
ed652ed8ad | ||
|
|
aa8106d57a | ||
|
|
4cc9448b0a | ||
|
|
4ea946257d | ||
|
|
03e073b748 | ||
|
|
b5d36f5211 | ||
|
|
544f8cb2d7 | ||
|
|
bcfddc97e7 | ||
|
|
d38f71cb28 | ||
|
|
e264d8392f | ||
|
|
d3877f1c0e | ||
|
|
c73e8c0539 | ||
|
|
ca4100e76a | ||
|
|
45e276d380 | ||
|
|
872b227209 | ||
|
|
1cd4dfc92a | ||
|
|
aa94ddebe3 | ||
|
|
67e963524b | ||
|
|
c12a95b6e8 | ||
|
|
2d6333f84c | ||
|
|
8fd3d1a9f0 | ||
|
|
35abbe1d0d | ||
|
|
d087b50aa3 | ||
|
|
1e8596a440 | ||
|
|
51a7c99b94 | ||
|
|
bce17b3cfb | ||
|
|
bd943849b1 | ||
|
|
08b77e20a9 | ||
|
|
5781895977 | ||
|
|
65430d4c7c | ||
|
|
1fc64984c9 | ||
|
|
20721ddada | ||
|
|
373d003f1b | ||
|
|
1360b71684 | ||
|
|
35454a9f58 | ||
|
|
7969a20c8c | ||
|
|
c1e6e3da44 | ||
|
|
0c5057c9ff | ||
|
|
05bfe8d162 | ||
|
|
e77dd7c413 | ||
|
|
92999b0101 | ||
|
|
b217a292bb | ||
|
|
ad69558908 | ||
|
|
4873c10673 | ||
|
|
0330fcd0d1 | ||
|
|
5b9d0260bb |
675 changed files with 93545 additions and 10253 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -64,6 +64,7 @@ tmp/
|
|||
|
||||
# Disposable dotnet test/build output redirected by local validation runs
|
||||
.test-out/
|
||||
**/TestResults/
|
||||
|
||||
# Connected-gate, benchmark, and visual-capture artifacts are machine-local
|
||||
logs/
|
||||
|
|
|
|||
323
AGENTS.md
323
AGENTS.md
|
|
@ -132,8 +132,194 @@ user-accepted, including exact response flags, independent examination
|
|||
window, inscription transaction, complete creature/item/spell reports,
|
||||
favorite-spell press/right-click behavior, modern scarab/prismatic formula,
|
||||
DAT component icons, foreground stacking, and authored 310 x 400 extent.
|
||||
Resume at Slice 4 equipped-child world picking, then vendor browse and
|
||||
authoritative transactions.
|
||||
Slice 4 equipped-child world picking passed its two-client Coldeve gate and
|
||||
was user-accepted 2026-07-29. **Slices 5 and 6 (the complete vendor
|
||||
experience — browse, staged buying, selling, walk-to-use, the authored
|
||||
panel) closed user-accepted 2026-08-08; the six-slice program is COMPLETE
|
||||
(see the plan's PROGRAM CLOSEOUT). The vendor arc also exposed and fixed
|
||||
two latent client-wide crashers (#348 cursor-handle exhaustion, #350
|
||||
render-ledger overflow).** **Campaign P — physics retail-feel parity
|
||||
(`docs/plans/2026-07-29-physics-parity-campaign.md`) is CLOSED 2026-07-31
|
||||
— final user matrix accepted.** Every physics-scope gap from the
|
||||
2026-07-29 audit landed and user-gated: #266 run speed (retail's ==800
|
||||
sentinel — ACE's >=800 is a misread; never re-import), the #265/#166
|
||||
landing-momentum + bounce family
|
||||
(`docs/research/2026-07-30-landing-bounce-family.md`), the #267 vitae
|
||||
panel, #268 (panel colors + augmentation bonuses), #269 (slope-stop slide
|
||||
— the live-trace contact-plane-restore fix), and TS-8 (0x02C2 StatMod
|
||||
parse). See the plan doc for the retired-row ledger. **Campaign A — audio
|
||||
retail parity (`docs/plans/2026-08-08-audio-parity-campaign.md`) is
|
||||
CODE-COMPLETE 2026-08-08** with slices A1–A6 landed and listening-gate
|
||||
rounds user-driven; open tail: #358 (Ctrl+M mute chord never fires) and
|
||||
the formal plan-status flip. **Campaign CH — chat & interface-text retail
|
||||
parity (`docs/plans/2026-08-09-chat-parity-campaign.md`) is CLOSED
|
||||
USER-ACCEPTED 2026-08-10** after five connected gate rounds: retail
|
||||
colors, the SpewBox with retail's two-plane glyph outlines, working side
|
||||
channels, the 152-verb command registry, the CH6 window shell (floating
|
||||
windows, all-corner resize, opacity), and verbatim /help. Carried tail:
|
||||
#360/#361, #366, #369, AP-177/190/191, and the round-5 review S1–S3
|
||||
polish items. **Campaign OP — the retail four-tab Options panel
|
||||
(`docs/plans/2026-08-10-options-panel-campaign.md`) is CODE-COMPLETE
|
||||
2026-08-11.** Retail's Options panel (Gameplay Options / Character / Chat /
|
||||
Config, LayoutDesc `0x2100002B`) plus the Configure Keyboard screen are
|
||||
acdream's ONE in-client settings surface (design D1): F11/toolbar open the
|
||||
authored tab host; `RuntimeCharacterOptionsState` + the 53-id
|
||||
`CharacterOptionTable` own option storage; retail's wire split ships exactly
|
||||
(21 auto-save ids → `0x0005` immediate, the rest ride the real `0x01A1`
|
||||
PlayerModule blob with Apply/logout/480 s flushes, header always `0x460`);
|
||||
headless bots declare options by name (OP7's live bot-vs-ACE gate PASSED);
|
||||
OP9 retired the dead F11 `SettingsPanel`/`SettingsVM` surface and the
|
||||
`GameplaySettings` record outright. OP1/OP2/OP7/OP9 CLOSED through dual/
|
||||
combined Opus review. **2026-08-14 re-gate round:** the whole gate-4 fix
|
||||
batch (#372 both halves, #374, #375, #378–#382, #385) is USER-PASSED; the
|
||||
OP8 first look filed + same-day-fixed #394/#395/#396 (authored 18px-serif
|
||||
row-caption font; the retail `GetNameFromKey` key-name pipeline — DAT
|
||||
tables `0x2300000A`/`0x2300000B`/`0x23000007` via GetDIDByEnum category 4,
|
||||
OS-localized fallback, register AD-96; the `InitiateBinding` capture-
|
||||
instruction WAIT dialog) plus the WaitDialog-type-0x19 crash (`2a81e813`,
|
||||
live-verified no-crash). **STILL OWED: the full §OP3–§OP6 script sections
|
||||
and §OP8's visual re-check** — script
|
||||
`docs/research/2026-08-11-campaign-op-test-script.md`, launch with
|
||||
`ACDREAM_RETAIL_UI=1`. Tail:
|
||||
#371, #373, AP-198/199/201/202/203. START at
|
||||
`claude-memory/project_settings_options_digest.md`.
|
||||
|
||||
**Campaign FA — the retail social panel (Fellowship & Allegiance)
|
||||
(`docs/plans/2026-08-11-fellowship-allegiance-campaign.md`) is
|
||||
CODE-COMPLETE 2026-08-12.** Retail authors ONE four-tab `gmPanelUI` social
|
||||
panel (Friends / Allegiance / Fellowship / Squelch, host slot
|
||||
`0x1000018F`, id 12; F3 = Allegiance, F4 = Fellowship, keyboard-only —
|
||||
Allegiance is the authored DEFAULT tab), mounted with the OP3 Options-panel
|
||||
recipe. The Fellowship and Allegiance pages are LIVE end-to-end: real wire
|
||||
(FA1 repaired the never-called H.2 builders + parsers — retail's FOUR
|
||||
tree-rejection rules, ELEVEN version gates, the byte-decoded `>=9` size and
|
||||
the truncated XP-share table), two session-scoped Runtime owners
|
||||
(`RuntimeFellowshipState`/`RuntimeAllegianceState`, both clear at
|
||||
generation reset — D2 corrected), and the authored panels through
|
||||
`LayoutImporter`. Friends/Squelch bind read-only to J4.1's owners.
|
||||
**The fellowship two-session flow is PROVEN over the live wire** — FA6's
|
||||
automated bot-vs-ACE gate (`testaccount`/`+Acdream` + `testaccount2`/
|
||||
`+Horan`) passed: the recruited bot's OWN `RuntimeFellowshipState` flips
|
||||
`IsInFellowship`. Six FA slices, each dual-lens Opus reviewed → fix round →
|
||||
narrow re-review; the reviews caught what tests can't (retail's 4th tree
|
||||
rule, the D2 reset-lifetime inversion, the D6 server-side invite filter,
|
||||
a seam-map entry that would have re-introduced a fixed bug). OWED: the
|
||||
user's connected gates (§FA3-§FA6 of
|
||||
`docs/research/2026-08-12-campaign-fa-test-script.md`, several
|
||||
`[TWO-CLIENT]`), and **#384** — the allegiance-swear bot gate is
|
||||
deferred/disabled because ACE returns NOTHING to the `0x001D` swear at
|
||||
0.005 m (no confirmation, no tree update, no error; needs ACE-console
|
||||
disambiguation — the swear CODE is done+reviewed, only its automated
|
||||
two-session proof is unverified; register AD-87). Tail: #383 (installed-
|
||||
DAT vs committed-fixture drift, found at FA3). START at
|
||||
`claude-memory/project_fellowship_allegiance_campaign.md`.
|
||||
|
||||
**2026-08-13/14 gate block — SOCIAL GATES + SECURE TRADE all
|
||||
USER-PASSED.** The social panel's connected gate rounds closed (border-only
|
||||
move cursor, amber row selection, wrapped empty-state text, composed
|
||||
confirmation sentences via the new `DatStringResolver.ResolveTemplate`
|
||||
StringTable-interleave port, the refused-drop SpewBox notice via the
|
||||
`InventoryTransactionState.RequestFailed` seam, live friends
|
||||
Online/Offline through the authored row state machine + the new UiText
|
||||
per-state string swap). Same block: powerbar mode captions
|
||||
(jump 'Height' right-aligned per-STATE justify / 'Power'↔'Accuracy' by
|
||||
combat mode), release-edge airborne jump refusal (supersedes CH round-1's
|
||||
press-edge report), and **SECURE TRADE SHIPPED + two-client user gate
|
||||
PASSED 2026-08-14** — gmSecureTradeUI window (LayoutDesc `0x2100000D`),
|
||||
full `0x1F6`–`0x208` wire, `RuntimeTradeState` as the third sibling
|
||||
J-owner, both retail open paths, staged-item trading marker
|
||||
(`ClientObject.TradeState` now live), cancel text. START at
|
||||
`claude-memory/project_secure_trade.md`; the deferred-Func lesson is
|
||||
`claude-memory/feedback_resolve_deferred_funcs_per_call.md`. Register:
|
||||
AD-93/AD-94 filed, AD-85 narrowed, AD-81 amended, AD-89/AD-95 retired.
|
||||
Filed: #393 (texture-detail options, post-M4).
|
||||
|
||||
**Campaign LA — the alpha launcher (ACTIVE 2026-08-14):** Avalonia
|
||||
launcher/installer/updater (Windows+Linux) + the retail character-
|
||||
management screen, driven autonomously under a user-set goal: Fable
|
||||
plans, Sonnet implements, Opus dual-lens reviews (architectural +
|
||||
retail-faithful). Spec:
|
||||
`docs/superpowers/specs/2026-08-14-launcher-campaign-design.md`; plan +
|
||||
ledger: `docs/plans/2026-08-14-launcher-campaign.md`; START at
|
||||
`claude-memory/project_launcher_direction.md`. Key recon corrections
|
||||
already binding: retail's select screen (`gmCharacterManagementUI`) has
|
||||
NO 3D preview (chargen-only machinery); UI Studio no longer exists
|
||||
(deleted at Campaign V — ignore stale memory/docs claims otherwise);
|
||||
App `Program.cs` has no subcommand dispatch (the `--session-config` flag
|
||||
is additive).
|
||||
LA0 through LA11's automated scope are review-closed. The launcher composer is now
|
||||
compiled into both host test suites, and Launcher.Core runs in the portable
|
||||
Windows/Ubuntu CI closure. The self-contained Avalonia launcher,
|
||||
transactional two-host plugin lifetime, shared login-command route,
|
||||
Runtime-owned retail selection state, authored DAT character screen, and
|
||||
crash-safe verified installer plus atomic cross-platform updater/self-updater
|
||||
are integrated. Windows group-isolated Headless stop, isolated update fixtures,
|
||||
strict status/redaction evidence, and the exact Windows/Ubuntu operator script
|
||||
are landed; the integrated preflight passes 32/32 commands and 14,012 tests /
|
||||
5 skips. Only the connected/visual/real-DAT user gate remains before shipment.
|
||||
|
||||
**Campaign CC — retail character creation (CLOSED USER-ACCEPTED
|
||||
2026-08-16).** All seven slices REVIEW-CLOSED; the connected gate ran as
|
||||
one extended round (findings GF-1..16 + re-tests R2/R3/R4, fix batches
|
||||
A-G + closeout + two re-test rounds, final build `1.0.2-cc.o`) and
|
||||
PASSED. **Milestone: the first live character ever created by acdream
|
||||
against ACE landed mid-round.** The gate round's own harvest hardened
|
||||
shared surfaces well beyond chargen: authored text margins (P0x23-26),
|
||||
the authored Unselected/Selected state pair + per-state label color,
|
||||
un-consumed Type-12 media children (frames/scrollbars client-wide),
|
||||
single-sprite scrollbar thumbs, UiButton/UiDatElement Tint, the
|
||||
dialog-always-on-top re-raise (the invisible-modal input blackhole), a
|
||||
truthful client crash self-report + bounded stderr capture (#405-#407
|
||||
fixed, #406 fixed; #408/#409/#410 filed for their own rounds). The full retail creation flow: Create
|
||||
button (retail's exact `UpdateButtons` roster<slots ghost gate) →
|
||||
`gmCharGenMainUI`'s six-page flow (Heritage / Profession / Skills /
|
||||
Appearance with live 3D preview / Town / Summary with its own zoomed-out
|
||||
viewport) → byte-exact 0xF656 with the 55-slot invariant → complete
|
||||
0xF643 handling (roster append + retail log-straight-in; every rejection
|
||||
dialog, incl. the corrected ground truth that retail shows NameDBDown
|
||||
for Pending/Undef — the plan's original "retail swallows it" was
|
||||
DISPROVEN at CC5's review) → the §LA1 `characterCreated`/`creationFailed`
|
||||
launcher status cycle. `RandomizeCharacter` + sub-primitives are ported
|
||||
(retail's ctor-time open-roll incl. the gender-flip quirk; humans-only
|
||||
random heritage ids 1-4 — a real retail quirk). Plan + ledger:
|
||||
`docs/plans/2026-08-15-character-creation-campaign.md`; connected gate
|
||||
script: `docs/research/2026-08-16-campaign-cc-test-script.md` (launch:
|
||||
launcher flow, or `ACDREAM_RETAIL_UI=1` + `ACDREAM_OPEN_CHARGEN=1`);
|
||||
START at `claude-memory/project_character_creation_campaign_handoff.md`.
|
||||
Register churn: AP-214/AP-225/TS-82/AD-101 retired; AP-211 updated;
|
||||
AP-212 narrowed; AP-215–AP-229 filed (AP-221 one-shot preview binding,
|
||||
AP-222 spin-highlight no-op, AP-229 stacked-screens-vs-retail-teardown
|
||||
are the ones a gate tester will meet). Known-flake set now also names
|
||||
`RuntimeCollisionReportingStateTests.WarmedSteadyContactRefreshDoesNotAllocate`
|
||||
(full-solution parallel load only). Suites at `2176ba76`: full solution
|
||||
14,426 / 4 skips, App 5257/3, Runtime 1735/0, Launcher.Core 324/0.
|
||||
|
||||
**Placement cutover — C4 COMPLETE 2026-08-05, merged to main.** Every
|
||||
placement route now runs through the canonical residence + continuation-
|
||||
executor owner. Routes landed this session: 4b-3 remote teleport/cell-less
|
||||
(`6dc7ba51`), 6 drops (`1b484937`, zero production lines), 5 projectile
|
||||
(`36255af0`), 7 child-cell propagation (`cd3129e9`), 3 portal
|
||||
(`e0f96a55`), plus the `OnPosition` dual-tail collapse (`edc911b0`) that
|
||||
retired the duplication behind three separate defects. Suite 11,027 →
|
||||
**11,090 passed / 4 skipped / 0 failed**. Connected gates: routes 3, 6 and 7
|
||||
user-passed 2026-08-05 with probe evidence; route 7's is THIN (one
|
||||
`cause=propagate`) and 4b-3's `cause=cellless` case remains unrun with an
|
||||
UNESTABLISHED trigger — route 7 invalidated its recorded recipe.
|
||||
**C5 COMPLETE — the placement campaign is FULLY CLOSED (`addb5657`,
|
||||
2026-08-07).** C5a deleted the legacy resolver outright and retired
|
||||
AP-1/AP-145 (closing #318); C5b closed #275 and filed AP-147/AP-148; C5c's
|
||||
closeout passed its 11,196-test automated gate and the owed connected-gate
|
||||
batch USER-PASSED 2026-08-07. #280's portal-prefetch fix and its dual
|
||||
review also landed (AP-149/150/151), and AP-22 retired 2026-08-06. Start
|
||||
any new placement work at `claude-memory/project_placement_cutover_closed.md`
|
||||
(probes deliberately NOT stripped; start at #331).
|
||||
|
||||
**Read `docs/research/2026-08-05-c4-closeout-handoff.md` before any
|
||||
placement work.** Its seven process findings remain binding. The two that
|
||||
cost the most that campaign: a contract asserting a mechanism that does not
|
||||
exist caused three separate defects, and inferring a fact you can observe
|
||||
made one fix strictly worse than the bug it replaced — it removed the
|
||||
invariant failure while leaving the bug.
|
||||
|
||||
**Modern Runtime/performance status:** Slices A–K of
|
||||
`docs/plans/2026-07-24-modern-runtime-architecture.md` are complete. Slice L is
|
||||
|
|
@ -566,9 +752,10 @@ The capped/RDP jump-presentation cadence alias is deferred as issue #235:
|
|||
uncapped Release presentation is smooth, while physics, collision, and wire
|
||||
truth remain correct.
|
||||
See `docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md` and
|
||||
`docs/architecture/code-structure.md`. **Carried:** #153, #116, remaining
|
||||
R6 ownership cleanup, TS-50/TS-51/TS-53, Modern Runtime Slice L, and #225's
|
||||
lifestone/particle alpha visual gate.
|
||||
`docs/architecture/code-structure.md`. **Carried:** #116 (Campaign P P2),
|
||||
remaining R6 ownership cleanup, TS-50/TS-51/TS-53, Modern Runtime Slice L,
|
||||
and #225's lifestone/particle alpha visual gate. #153 closed 2026-07-30
|
||||
(Campaign P P5 ledger evidence chain).
|
||||
|
||||
Start structural work at `memory/project_gamewindow_decomposition.md` and
|
||||
`docs/architecture/code-structure.md`; start
|
||||
|
|
@ -578,6 +765,9 @@ render/streaming work at `claude-memory/project_render_pipeline_digest.md`.
|
|||
Documentation entry point: [`docs/README.md`](docs/README.md).
|
||||
|
||||
For canonical state, read in this order:
|
||||
- [`docs/plans/2026-07-29-network-transport-campaign.md`](docs/plans/2026-07-29-network-transport-campaign.md) — Campaign N, the retail reliable-transport port — **CLOSED 2026-07-29, user-accepted** (#260 closed; a real wire loss recovered live during the acceptance session). Still the SSOT for the transport mechanism, the ACE constraint table, and the landmine list — read it (or `claude-memory/project_network_transport_digest.md`) before touching anything under `src/AcDream.Core.Net/`.
|
||||
- [`docs/plans/2026-07-27-vulkan-campaign.md`](docs/plans/2026-07-27-vulkan-campaign.md) — Campaign V, OpenGL → Vulkan — **CLOSED 2026-07-29**; the completed record of the RHI contract, V0–V11 slices, and the GL deletion. Historical reference for `src/AcDream.App/Rendering/`.
|
||||
- [`docs/ci-and-releases.md`](docs/ci-and-releases.md) — **the Gitea CI/release SSOT (2026-08-19)**: every push to main gates on two self-hosted runners (RARE-win / eriktestLinux) and publishes a Gitea Release the launcher installs from; payloads are release attachments, the `latest` release is the launcher's pointer, old releases are pruned to 5. Load-sensitive tests live in `Lane=Timing` (see `docs/release-gate.md`) — do NOT chase them individually.
|
||||
- [`docs/plans/2026-05-12-milestones.md`](docs/plans/2026-05-12-milestones.md) — milestone targets + freeze list per milestone
|
||||
- [`docs/plans/2026-04-11-roadmap.md`](docs/plans/2026-04-11-roadmap.md) — what's shipped, what's in flight, what's next
|
||||
- [`docs/ISSUES.md`](docs/ISSUES.md) — open + recently closed bugs (tactical)
|
||||
|
|
@ -1367,67 +1557,44 @@ governed by whether the previous shutdown was graceful or forced.
|
|||
### Test character
|
||||
|
||||
`+Acdream` at server guid `0x5000000A`. Starts at or near Holtburg. Has
|
||||
basic stats; `ACDREAM_RUN_SKILL` / `ACDREAM_JUMP_SKILL` env vars (default
|
||||
200) set the *client-side* skill value used by `PlayerWeenie.InqRunRate`
|
||||
for local motion prediction. **These are NOT synced to the server** —
|
||||
ACE's own character data is authoritative for broadcast motion. If you
|
||||
see a speed/anim mismatch between local and observer views, the fix is
|
||||
to sync the runSkill from ACE via `UpdateMotion.ForwardSpeed` echo (wired
|
||||
via `PlayerMovementController.ApplyServerRunRate`) or from
|
||||
`PlayerDescription (0x0013)`.
|
||||
basic stats. Run/jump skills arrive FROM the server and drive local motion
|
||||
prediction (`LiveMovementStatsApplier` → `PlayerMovementController`); the
|
||||
hardcoded fallbacks before the server speaks are 200 run / 300 jump. The
|
||||
former `ACDREAM_RUN_SKILL` / `ACDREAM_JUMP_SKILL` client-side overrides no
|
||||
longer exist — see the Retired section of
|
||||
[`docs/launch-options.md`](docs/launch-options.md). If you see a speed/anim
|
||||
mismatch between local and observer views, check the server sync path
|
||||
(`UpdateMotion.ForwardSpeed` echo via
|
||||
`PlayerMovementController.ApplyServerRunRate`, or
|
||||
`PlayerDescription (0x0013)`).
|
||||
|
||||
### Diagnostic env vars
|
||||
|
||||
- `ACDREAM_DUMP_MOTION=1` — dump every inbound `UpdateMotion` (guid,
|
||||
stance, cmd, speed) + resulting `SetCycle` call. Massive for remote-
|
||||
animation debugging.
|
||||
- `ACDREAM_STREAM_RADIUS=N` — tune landblock visible-window radius
|
||||
(default 2 = 5×5).
|
||||
- `ACDREAM_NO_AUDIO=1` — suppress OpenAL init for headless / driver-
|
||||
broken setups.
|
||||
- `ACDREAM_REMOTE_VEL_DIAG=1` — dump per-tick / per-UM remote motion
|
||||
diagnostics (`[UM_RAW]`, `[SCFAST]`, `[SCFULL]`, `[SETCYCLE]`,
|
||||
`[FWD_WIRE]`, `[OMEGA_DIAG]`, `[SEQSTATE]`, `[PARTSDIAG]`,
|
||||
`[VEL_DIAG]`, `[UPCYCLE]`). Heavy.
|
||||
- `ACDREAM_PROBE_RESOLVE=1` — one `[resolve]` line per
|
||||
`PhysicsEngine.ResolveWithTransition` call: input + target + output
|
||||
position/cell, ok-vs-partial, grounded-in, contact-plane status,
|
||||
wall normal if hit, **responsible entity guid**, env flag, walkable
|
||||
polygon valid. Heavy (~30 Hz × every entity). Runtime-toggleable via
|
||||
the DebugPanel "Diagnostics" section if `ACDREAM_DEVTOOLS=1`.
|
||||
- `ACDREAM_PROBE_CELL=1` — one `[cell-transit]` line per
|
||||
`PlayerMovementController.CellId` change: old → new cell, world
|
||||
position, reason tag (`resolver` / `teleport`). Low volume — only
|
||||
fires on actual cell crossings. Runtime-toggleable via the same
|
||||
DebugPanel section.
|
||||
- `ACDREAM_PROBE_PUSH_BACK=1` — emits three line types per physics
|
||||
tick: `[push-back]` (per `BSPQuery.AdjustSphereToPlane` call),
|
||||
`[push-back-disp]` (per `BSPQuery.FindCollisions` dispatch),
|
||||
`[push-back-cell]` (per `Transition.CheckOtherCells` off-cell hit).
|
||||
Heavy under motion (~100–500 lines/sec). Pair with retail's cdb
|
||||
breakpoint set at `tools/cdb/a6-probe.cdb` for the A6.P1 capture
|
||||
protocol. Runtime-toggleable via the DebugPanel.
|
||||
- `ACDREAM_PROBE_FLAP=1` — capture probe for indoor visibility
|
||||
decisions at frame boundaries. Used to converge the U.4c flap fix
|
||||
(root indoor visibility at player's cell, not eye).
|
||||
- `ACDREAM_PROBE_STICKY=1` — per-guid sticky-melee timeline: `[sticky]`
|
||||
lifecycle lines (STICK/UNSTICK/LEASE-EXPIRE/TARGET-status teardown),
|
||||
per-armed-tick steer lines (signed gap dist, applied delta, heading
|
||||
delta), `[sticky-snap-skip]` at the suppressed NPC UP-snap site.
|
||||
Heavy while a pack is stuck (~60 Hz × stuck count). Converged the
|
||||
#171 residuals (the deep-overlap sign pin AP-82).
|
||||
- `ACDREAM_CAPTURE_RESOLVE=<path>` — live capture of every player-side
|
||||
`PhysicsEngine.ResolveWithTransition` call. Each call appends one
|
||||
JSON Lines record with full inputs, PhysicsBody snapshot before AND
|
||||
after, plus the `ResolveResult`. Filtered to `IsPlayer` mover flag
|
||||
— NPC / remote DR calls don't pollute. Pairs with the trajectory
|
||||
replay harness comparison tests to diff captured vs harness state
|
||||
per field — the first divergence pinpoints missing apparatus state.
|
||||
Capture is OFF when the env var is unset (one null-check cost per
|
||||
call).
|
||||
- `ACDREAM_DUMP_CELLS=<path>` / `ACDREAM_DUMP_GFXOBJS=<path>` — dump
|
||||
resolved cell/GfxObj polygon tables as JSON when ids cache. Used
|
||||
for harness fixture extraction.
|
||||
Every environment variable and command-line argument the client reads —
|
||||
what it does, its exact value shape, and **what else it changes about the
|
||||
run** — is documented in
|
||||
[`docs/launch-options.md`](docs/launch-options.md). That file is the single
|
||||
source of truth for every probe we have and how to turn one on, and it is
|
||||
enforced by `LaunchOptionsDocumentationTests`: a flag without a documented
|
||||
row fails the build, and so does a documented row whose read site was
|
||||
deleted. **Any future probe that stays in the code gets its row there in
|
||||
the same commit — no exceptions.**
|
||||
|
||||
The binding rules:
|
||||
|
||||
- **Every probe and dump is OFF by default.** Nothing that prints, records,
|
||||
or costs performance may activate without its env var explicitly set
|
||||
(`=1`). The only default-on flags are retail *behaviors* wearing an
|
||||
A/B off-switch (`ACDREAM_RETAIL_CHASE`, `ACDREAM_CAMERA_COLLIDE`,
|
||||
`ACDREAM_CAMERA_ALIGN_SLOPE`, `ACDREAM_RETAIL_CLOSE_DEGRADES` — `=0`
|
||||
disables); that set is frozen by `LaunchOptionsDocumentationTests` —
|
||||
never add a default-on diagnostic.
|
||||
- **Read the side-effects column before any measurement.** Flags that look
|
||||
inert are not: `ACDREAM_AUTOMATION_ARTIFACT_DIR` also builds a per-frame
|
||||
diagnostics referee (#432), and `ACDREAM_STREAM_RADIUS` measures a
|
||||
streaming window production never uses.
|
||||
- **A temporary probe dies with its investigation.** Add the row when you
|
||||
add the probe; delete both in the commit that fixes the issue.
|
||||
|
||||
### Outbound motion wire format (acdream → ACE)
|
||||
|
||||
|
|
@ -1478,8 +1645,8 @@ already-running ACE session via the handshake race.
|
|||
|
||||
## Reference repos: cross-check the relevant ones
|
||||
|
||||
The `references/` tree holds **six** vendored projects (ACE, ACViewer,
|
||||
WorldBuilder, Chorizite.ACProtocol, holtburger, AC2D). They overlap in
|
||||
The `references/` tree holds **five** vendored projects (ACE, ACViewer,
|
||||
WorldBuilder, Chorizite.ACProtocol, holtburger). They overlap in
|
||||
some areas and disagree in others. Before committing to an approach,
|
||||
**cross-reference at least two of them** for the domain you're working
|
||||
in — the per-domain hierarchy in the next section tells you which to
|
||||
|
|
@ -1488,7 +1655,7 @@ the relevant references is almost always the truth. The user has
|
|||
repeatedly had to remind me about this when I narrowly searched one ref
|
||||
and missed obvious answers in another.
|
||||
|
||||
The six references:
|
||||
The five references:
|
||||
|
||||
- **`references/ACE/`** — ACEmulator server. Authority on the wire
|
||||
protocol (packet framing, ISAAC, game message opcodes, serialization
|
||||
|
|
@ -1538,15 +1705,15 @@ The six references:
|
|||
the message-builder layer. ACE shows what the server expects;
|
||||
holtburger shows what a real client actually sends.
|
||||
|
||||
- **`references/AC2D/`** — **C++ AC client emulator.** Oldest reference,
|
||||
fixed-function OpenGL, but has the **real AC terrain split formula**
|
||||
(`FSplitNESW` with constants `0x0CCAC033`, `0x421BE3BD`, `0x6C1AC587`,
|
||||
`0x519B8F25`) which differs from WorldBuilder's physics-path formula.
|
||||
Also has the complete `0xF61C` movement packet format with flag bits
|
||||
and the `stMoveInfo` sequence counters. Key lesson from AC2D: it does
|
||||
NOT do client-side terrain Z — it sends movement keys to the server
|
||||
and uses the server's authoritative Z. See
|
||||
`docs/research/2026-04-12-movement-deep-dive.md` for the full analysis.
|
||||
**AC2D is a retired reference (2026-07-29).** It was a C++ AC client demo
|
||||
and the sixth entry in this list; it is no longer vendored under
|
||||
`references/` and must not be re-cloned. Everything we took from it is
|
||||
already written down and still stands: the terrain split formula
|
||||
`FSplitNESW` (constants `0x0CCAC033`, `0x421BE3BD`, `0x6C1AC587`,
|
||||
`0x519B8F25`), the `0xF61C` movement packet layout, and the finding that a
|
||||
client need not compute terrain Z itself. The historical analysis lives in
|
||||
`docs/research/2026-04-12-movement-deep-dive.md`; the UI dat-id work it fed
|
||||
is in `docs/research/retail-ui/`. Cite those, not the repo.
|
||||
|
||||
### Reference hierarchy by domain
|
||||
|
||||
|
|
@ -1571,9 +1738,9 @@ decompiled client code and would have fixed it in minutes.
|
|||
| **EnvCell / dungeon rendering** (cell geometry, portal visibility, collision mesh) | **WorldBuilder `EnvCellRenderManager.cs` + `PortalRenderManager.cs`** | ACME `EnvCellManager.cs` (more complete for collision); ACViewer `Physics/Common/EnvCell.cs` | WB is acdream's geometry base; ACME for collision until ported. |
|
||||
| **Particles / sky** (particle systems, weather, sky particles) | **WorldBuilder `SkyboxRenderManager.cs` + `ParticleEmitterRenderer.cs` + `ParticleBatcher.cs`** | retail decomp | WB is acdream's particle base. |
|
||||
| **Visibility / culling** (frustum, cell visibility) | **WorldBuilder `VisibilityManager.cs` + `Frustum.cs`** | — | WB. |
|
||||
| **Network protocol** (wire format, packet framing, fragment assembly, ISAAC) | **holtburger** `crates/holtburger-session/` | AC2D `cNetwork.cpp` (simpler, good for cross-check) | ACE shows the server side; holtburger + AC2D show the client side. |
|
||||
| **Client behavior** (what to send when, login flow, ack pattern, keepalive) | **holtburger** `crates/holtburger-core/src/client/` | AC2D `cNetwork.cpp` + `cInterface.cpp` | holtburger is the most complete; AC2D is simpler but confirmed working. |
|
||||
| **Movement** (MoveToState format, AutonomousPosition, sequence counters, speed) | **holtburger** `client/movement/` | AC2D `cNetwork.cpp:2592-2664` (0xF61C format) | See `docs/research/2026-04-12-movement-deep-dive.md` for the full cross-reference. |
|
||||
| **Network protocol** (wire format, packet framing, fragment assembly, ISAAC) | **holtburger** `crates/holtburger-session/` | `docs/research/named-retail/` | ACE shows the server side; holtburger shows the client side. AC2D was the second client-side cross-check here — retired reference; historical analysis remains in `docs/research/2026-04-12-movement-deep-dive.md`. |
|
||||
| **Client behavior** (what to send when, login flow, ack pattern, keepalive) | **holtburger** `crates/holtburger-core/src/client/` | `docs/research/named-retail/` | holtburger is the most complete. AC2D was the simpler confirmed-working cross-check — retired reference; historical analysis remains in `docs/research/2026-04-12-movement-deep-dive.md`. |
|
||||
| **Movement** (MoveToState format, AutonomousPosition, sequence counters, speed) | **holtburger** `client/movement/` | `docs/research/named-retail/` | AC2D `cNetwork.cpp:2592-2664` was the `0xF61C` format secondary — retired reference; historical analysis remains in `docs/research/2026-04-12-movement-deep-dive.md`, which carries the packet layout and the terrain-split formula verbatim. |
|
||||
| **Server expectations** (what ACE accepts/rejects, validation thresholds) | **ACE** `Source/ACE.Server/Network/` | — | Only ACE knows what the server actually validates. |
|
||||
| **Silk.NET / .NET 10 idioms** (GL calls, shader setup, VAO patterns) | **WorldBuilder original** | ACME (same stack) | Both use the same backend; original has cleaner isolated examples. |
|
||||
| **Protocol field order** (packed dwords, type prefixes, flag enums) | **Chorizite.ACProtocol** `Types/*.cs` | holtburger (cross-check) | Generated from protocol XML; has accurate field comments. |
|
||||
|
|
|
|||
134
CLAUDE.md
134
CLAUDE.md
|
|
@ -1555,108 +1555,44 @@ governed by whether the previous shutdown was graceful or forced.
|
|||
### Test character
|
||||
|
||||
`+Acdream` at server guid `0x5000000A`. Starts at or near Holtburg. Has
|
||||
basic stats; `ACDREAM_RUN_SKILL` / `ACDREAM_JUMP_SKILL` env vars (default
|
||||
200) set the *client-side* skill value used by `PlayerWeenie.InqRunRate`
|
||||
for local motion prediction. **These are NOT synced to the server** —
|
||||
ACE's own character data is authoritative for broadcast motion. If you
|
||||
see a speed/anim mismatch between local and observer views, the fix is
|
||||
to sync the runSkill from ACE via `UpdateMotion.ForwardSpeed` echo (wired
|
||||
via `PlayerMovementController.ApplyServerRunRate`) or from
|
||||
`PlayerDescription (0x0013)`.
|
||||
basic stats. Run/jump skills arrive FROM the server and drive local motion
|
||||
prediction (`LiveMovementStatsApplier` → `PlayerMovementController`); the
|
||||
hardcoded fallbacks before the server speaks are 200 run / 300 jump. The
|
||||
former `ACDREAM_RUN_SKILL` / `ACDREAM_JUMP_SKILL` client-side overrides no
|
||||
longer exist — see the Retired section of
|
||||
[`docs/launch-options.md`](docs/launch-options.md). If you see a speed/anim
|
||||
mismatch between local and observer views, check the server sync path
|
||||
(`UpdateMotion.ForwardSpeed` echo via
|
||||
`PlayerMovementController.ApplyServerRunRate`, or
|
||||
`PlayerDescription (0x0013)`).
|
||||
|
||||
### Diagnostic env vars
|
||||
|
||||
- `ACDREAM_DUMP_MOTION=1` — dump every inbound `UpdateMotion` (guid,
|
||||
stance, cmd, speed) + resulting `SetCycle` call. Massive for remote-
|
||||
animation debugging.
|
||||
- `ACDREAM_STREAM_RADIUS=N` — **legacy** streaming-radius override
|
||||
(`RuntimeOptions.LegacyStreamRadius`). **Default is UNSET**, not 2: the
|
||||
shipped radii come from the quality preset
|
||||
(`QualityPreset.High` = NearRadius 4 / FarRadius 12, i.e. a 9×9 Near ring
|
||||
inside a 25×25 Far window). When set it FORCES `NearRadius = N` and only
|
||||
ever RAISES `FarRadius` (`SessionPlayerComposition.ComposeCore`), and it is
|
||||
silently discarded by any later Settings `ApplyQuality`
|
||||
(`RuntimeSettingsTargets.ApplyQuality` → `ReconfigureRadii`). **Leave it
|
||||
unset for any measurement or gate run** — with it set you are measuring a
|
||||
different window than production. Per-axis overrides
|
||||
`ACDREAM_NEAR_RADIUS` / `ACDREAM_FAR_RADIUS` (`QualitySettings.WithEnvOverrides`)
|
||||
are the modern spelling.
|
||||
- `ACDREAM_PROBE_REVEAL_RADIUS=N` — #280 A/B measurement probe
|
||||
(`StreamingDiagnostics.RevealRadiusOverride`). Forces the outdoor reveal
|
||||
gate to landblock radius N instead of the derived streaming window, so the
|
||||
same binary can run a route once with the pre-#280 behaviour (`=1`) and once
|
||||
without. Not a user setting; not surfaced in Settings; not persisted.
|
||||
Values below 1 are rejected by the parser: an outdoor acknowledgement with
|
||||
`RequiredRenderRadius == 0` fails Runtime's `invalid-readiness-shape`
|
||||
invariant, so `=0` would hang the route it is meant to measure.
|
||||
- `ACDREAM_NO_AUDIO=1` — suppress OpenAL init for headless / driver-
|
||||
broken setups.
|
||||
- `ACDREAM_REMOTE_VEL_DIAG=1` — dump per-tick / per-UM remote motion
|
||||
diagnostics (`[UM_RAW]`, `[SCFAST]`, `[SCFULL]`, `[SETCYCLE]`,
|
||||
`[FWD_WIRE]`, `[OMEGA_DIAG]`, `[SEQSTATE]`, `[PARTSDIAG]`,
|
||||
`[VEL_DIAG]`, `[UPCYCLE]`). Heavy.
|
||||
- `ACDREAM_PROBE_RESOLVE=1` — one `[resolve]` line per
|
||||
`PhysicsEngine.ResolveWithTransition` call: input + target + output
|
||||
position/cell, ok-vs-partial, grounded-in, contact-plane status,
|
||||
wall normal if hit, **responsible entity guid**, env flag, walkable
|
||||
polygon valid. Heavy (~30 Hz × every entity). Runtime-toggleable via
|
||||
the DebugPanel "Diagnostics" section if `ACDREAM_DEVTOOLS=1`.
|
||||
- `ACDREAM_PROBE_CELL=1` — one `[cell-transit]` line per
|
||||
`PlayerMovementController.CellId` change: old → new cell, world
|
||||
position, reason tag (`resolver` / `teleport`). Low volume — only
|
||||
fires on actual cell crossings. Runtime-toggleable via the same
|
||||
DebugPanel section.
|
||||
- `ACDREAM_PROBE_PUSH_BACK=1` — emits three line types per physics
|
||||
tick: `[push-back]` (per `BSPQuery.AdjustSphereToPlane` call),
|
||||
`[push-back-disp]` (per `BSPQuery.FindCollisions` dispatch),
|
||||
`[push-back-cell]` (per `Transition.CheckOtherCells` off-cell hit).
|
||||
Heavy under motion (~100–500 lines/sec). Pair with retail's cdb
|
||||
breakpoint set at `tools/cdb/a6-probe.cdb` for the A6.P1 capture
|
||||
protocol. Runtime-toggleable via the DebugPanel.
|
||||
- `ACDREAM_PROBE_FLAP=1` — capture probe for indoor visibility
|
||||
decisions at frame boundaries. Used to converge the U.4c flap fix
|
||||
(root indoor visibility at player's cell, not eye).
|
||||
- `ACDREAM_PROBE_STICKY=1` — per-guid sticky-melee timeline: `[sticky]`
|
||||
lifecycle lines (STICK/UNSTICK/LEASE-EXPIRE/TARGET-status teardown),
|
||||
per-armed-tick steer lines (signed gap dist, applied delta, heading
|
||||
delta), `[sticky-snap-skip]` at the suppressed NPC UP-snap site.
|
||||
Heavy while a pack is stuck (~60 Hz × stuck count). Converged the
|
||||
#171 residuals (the deep-overlap sign pin AP-82).
|
||||
- `ACDREAM_PROBE_SUPPORT=1` — **what is holding a body up, and is the
|
||||
collision geometry where the visual geometry is?** (#337, TEMPORARY).
|
||||
`[support]`: one line per resolve **for every body, not just the player**
|
||||
(a corpse falling through geometry is the cheapest control there is on
|
||||
"movement code vs geometry data"). It samples the outdoor terrain
|
||||
independently at the body's own out-XY and prints the contact plane's own
|
||||
height at that same XY, so `support=terrain` / `object` / `none` is a
|
||||
measurement rather than an inference; `cpSrc=` names the site that wrote
|
||||
the plane so provenance cross-checks the classification. Edge-eager,
|
||||
throttled to 4 Hz per body, and emits every 10 cm of vertical movement.
|
||||
`[geom]`: once per GfxObj near the mover — the object's physics-BSP vertex
|
||||
cloud against its visual mesh AABB in the same frame, with a verdict
|
||||
(`coincident` REFUTES "collision isn't where the visual is";
|
||||
`no-physics-bsp` / `empty-physics-bsp` / `displaced` / `extent-mismatch`
|
||||
each name a data defect). `ACDREAM_PROBE_RESOLVE` alone cannot separate
|
||||
those cases — it carries no plane normal, no plane height, no terrain
|
||||
sample and no provenance.
|
||||
- `ACDREAM_WIRE_MESH=1` — upgrades the existing **F2** collision overlay from
|
||||
a broadphase proxy cylinder to the real physics-BSP polygon edges (cyan)
|
||||
beside the same objects' visual mesh boxes (magenta) and the terrain
|
||||
surface (yellow). Settles "visual versus collision" by eye instead of by
|
||||
log. `ACDREAM_WIRE_RADIUS=<metres>` sets the window (default 30).
|
||||
TEMPORARY, with the #337 probe family.
|
||||
- `ACDREAM_CAPTURE_RESOLVE=<path>` — live capture of every player-side
|
||||
`PhysicsEngine.ResolveWithTransition` call. Each call appends one
|
||||
JSON Lines record with full inputs, PhysicsBody snapshot before AND
|
||||
after, plus the `ResolveResult`. Filtered to `IsPlayer` mover flag
|
||||
— NPC / remote DR calls don't pollute. Pairs with the trajectory
|
||||
replay harness comparison tests to diff captured vs harness state
|
||||
per field — the first divergence pinpoints missing apparatus state.
|
||||
Capture is OFF when the env var is unset (one null-check cost per
|
||||
call).
|
||||
- `ACDREAM_DUMP_CELLS=<path>` / `ACDREAM_DUMP_GFXOBJS=<path>` — dump
|
||||
resolved cell/GfxObj polygon tables as JSON when ids cache. Used
|
||||
for harness fixture extraction.
|
||||
Every environment variable and command-line argument the client reads —
|
||||
what it does, its exact value shape, and **what else it changes about the
|
||||
run** — is documented in
|
||||
[`docs/launch-options.md`](docs/launch-options.md). That file is the single
|
||||
source of truth for every probe we have and how to turn one on, and it is
|
||||
enforced by `LaunchOptionsDocumentationTests`: a flag without a documented
|
||||
row fails the build, and so does a documented row whose read site was
|
||||
deleted. **Any future probe that stays in the code gets its row there in
|
||||
the same commit — no exceptions.**
|
||||
|
||||
The binding rules:
|
||||
|
||||
- **Every probe and dump is OFF by default.** Nothing that prints, records,
|
||||
or costs performance may activate without its env var explicitly set
|
||||
(`=1`). The only default-on flags are retail *behaviors* wearing an
|
||||
A/B off-switch (`ACDREAM_RETAIL_CHASE`, `ACDREAM_CAMERA_COLLIDE`,
|
||||
`ACDREAM_CAMERA_ALIGN_SLOPE`, `ACDREAM_RETAIL_CLOSE_DEGRADES` — `=0`
|
||||
disables); that set is frozen by `LaunchOptionsDocumentationTests` —
|
||||
never add a default-on diagnostic.
|
||||
- **Read the side-effects column before any measurement.** Flags that look
|
||||
inert are not: `ACDREAM_AUTOMATION_ARTIFACT_DIR` also builds a per-frame
|
||||
diagnostics referee (#432), and `ACDREAM_STREAM_RADIUS` measures a
|
||||
streaming window production never uses.
|
||||
- **A temporary probe dies with its investigation.** Add the row when you
|
||||
add the probe; delete both in the commit that fixes the issue.
|
||||
|
||||
### Outbound motion wire format (acdream → ACE)
|
||||
|
||||
|
|
|
|||
19
README.md
19
README.md
|
|
@ -97,8 +97,8 @@ missing.
|
|||
- A machine-local `acdream.pak` built from those DATs
|
||||
- A running ACE server for connected play; the examples use
|
||||
`127.0.0.1:9000`
|
||||
- For the graphical client, a driver exposing the mandatory modern OpenGL
|
||||
capabilities
|
||||
- For the graphical client, a driver exposing the mandatory Vulkan
|
||||
capabilities validated at startup
|
||||
|
||||
The project does not distribute Microsoft/Turbine DAT files or derived
|
||||
prepared packages.
|
||||
|
|
@ -111,10 +111,9 @@ dotnet build AcDream.slnx -c Release
|
|||
dotnet test AcDream.slnx -c Release --no-build
|
||||
```
|
||||
|
||||
The current baseline is a successful Release build with **8,826 passing tests
|
||||
and 5 intentional skips**. The build currently reports 17 test-project
|
||||
warnings tracked by [`#228`](docs/ISSUES.md#228--clean-release-build-emits-17-test-project-warnings);
|
||||
production compilation has zero errors.
|
||||
The current CI-filtered Windows baseline is a successful Release build with
|
||||
**16,151 passing tests and zero failures**; opt-in live, installed-DAT,
|
||||
prepared-package, manual, timing, and platform-specific lanes run separately.
|
||||
|
||||
## Prepare content
|
||||
|
||||
|
|
@ -127,8 +126,9 @@ dotnet run --project src\AcDream.Bake\AcDream.Bake.csproj -c Release -- `
|
|||
--out "C:\Games\Asheron's Call\acdream.pak"
|
||||
```
|
||||
|
||||
A complete package is approximately 30 GB. It is machine-local and must not be
|
||||
committed. `ACDREAM_PAK_PATH` overrides the default
|
||||
A complete format-2 package from the standard installed DAT set is about
|
||||
570 MiB (the former format-1 package was about 30 GB). It is machine-local and
|
||||
must not be committed. `ACDREAM_PAK_PATH` overrides the default
|
||||
`<DAT directory>\acdream.pak`.
|
||||
|
||||
## Run the graphical client
|
||||
|
|
@ -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 |
|
||||
|
|
|
|||
2426
docs/ISSUES.md
2426
docs/ISSUES.md
File diff suppressed because it is too large
Load diff
|
|
@ -86,6 +86,12 @@ document in the same change; do not leave both claims standing.
|
|||
pipeline, the self-hosted runners, and how alpha releases are published.
|
||||
Load-sensitive tests live in `Lane=Timing`; see
|
||||
[`release-gate.md`](release-gate.md) before adding to it.
|
||||
- [`launch-options.md`](launch-options.md) is the SSOT for every environment
|
||||
variable and command-line argument the client reads, including what each one
|
||||
changes about the run beyond its obvious effect. Read the side-effects column
|
||||
before trusting any measurement. Enforced by
|
||||
`LaunchOptionsDocumentationTests`: a flag without a row fails the build, and
|
||||
so does a row whose read site was deleted.
|
||||
- [`audit/`](audit/) contains completion and conformance audits.
|
||||
- [`reference/ace-commands.md`](reference/ace-commands.md) preserves the local
|
||||
ACE server's complete in-game command catalog and points to the authoritative
|
||||
|
|
|
|||
|
|
@ -137,6 +137,68 @@ loads none). The headless adapter projects entity snapshots on demand from the
|
|||
canonical Runtime view, subscribes to Runtime's ordered events, and borrows the
|
||||
exact Runtime selection owner; it does not mirror gameplay state.
|
||||
|
||||
Graphical plugin panels are first-class retained windows. A plugin calls
|
||||
`IUiRegistry.AddPanel` with a BCL-only `PluginPanelDescriptor`; Core's scoped
|
||||
host authenticates the owner from the loaded manifest, and App derives the
|
||||
stable identity `plugin:{pluginId}:{windowId}`. The host, not the plugin, owns
|
||||
window geometry, z-order, persisted visibility, minimize/restore chrome, and
|
||||
the shared right-edge plugin shelf. Minimizing only hides the presentation:
|
||||
the plugin session, event subscriptions, automation policy, and binding object
|
||||
remain live. Legacy `AddMarkupPanel` registrations are enriched into the same
|
||||
first-class path, so API-v1 plugins keep working without a second lifecycle.
|
||||
The markup vocabulary includes nested groups plus retained tab, toggle,
|
||||
slider, editable-field, and retail-menu controls. Fields bind live
|
||||
`Action<string>` change/submit callbacks and menus bind an
|
||||
`IEnumerable<string>` plus selection callback, so plugin-owned profile/rule
|
||||
editors stay behind the BCL contract instead of importing App widgets. These
|
||||
are presentation bindings only and never become parallel gameplay owners.
|
||||
|
||||
Durable plugin data uses the BCL-only `IPluginHost.Storage` contract. Core's
|
||||
manifest-authenticated scoped host prefixes every logical key with the loaded
|
||||
plugin id; graphical composition writes atomically beneath the per-user config
|
||||
root (`plugins/{pluginId}/...`). Plugins receive neither another plugin's
|
||||
namespace nor a machine-specific path. Hosts without durable storage expose
|
||||
`NoOpPluginStorage` and report the capability unavailable.
|
||||
The additive `List(prefix)` operation enumerates only keys inside that same
|
||||
authenticated namespace, allowing plugins to discover explicit import/export
|
||||
files without receiving a filesystem path or crossing plugin ownership.
|
||||
|
||||
`IPluginHost.Automation` is the additive gameplay-automation projection. Its
|
||||
character, spell, magic, chat, combat, equipment, item, loot, fellowship,
|
||||
enchantment-observation, and navigation
|
||||
groups contain BCL-only immutable snapshots plus attempt-style commands; the
|
||||
graphical implementation borrows the exact `GameRuntime`
|
||||
character/action/entity/object/vendor/fellowship owners. Item projections also
|
||||
carry Virindi's stable ObjectClass plus ordered ObjDesc subpalette samples;
|
||||
the graphical host resolves each representative RGB directly from portal DAT
|
||||
using VTank's sample-index formula. MossTank owns all
|
||||
macro policy (buff planning, target rules, selection scoring, corpse policy,
|
||||
loot-rule ordering and action timing). In particular, `ICombatAutomation`
|
||||
does not create a plugin combat model: each hostile capture is a detached
|
||||
point-in-time projection of `RuntimeHostileTargetQuery`, and physical commands
|
||||
enter the canonical `RuntimeCombatModeState` / `RuntimeCombatAttackState`
|
||||
press-charge-release state machine. Item and loot commands similarly enter
|
||||
App's one `ItemInteractionController`: appraisal, use/apply, pickup,
|
||||
move/split/merge/drop/give, retail 0x027D salvage, and current-vendor sale
|
||||
reuse the same readiness checks, reservations, wire sends, and authoritative
|
||||
completion/object-table signals as retained retail UI. Plugins never hold an
|
||||
optimistic inventory or vendor shadow. Navigation similarly projects live and
|
||||
server-accepted position, portal/object state, and semantic movement levels;
|
||||
the App host applies those levels through Runtime's one command interpreter.
|
||||
Route sequencing, steering cones, follow breadcrumbs, checkpoint policy,
|
||||
door/lockpick decisions and portal retry behavior remain plugin-owned. The
|
||||
shared enchantment-observation group is deliberately a confirmed-cast timer
|
||||
ledger rather than another authoritative spellbook: the host records successful
|
||||
local duration casts and cooperating plugins can report their own confirmed
|
||||
casts, matching VTank's `LogSpellCast` contract. It resets at session detach;
|
||||
dispel/debuff policy remains plugin-owned. The
|
||||
inert default remains
|
||||
`NoOpAutomationSurface`, preserving one plugin code path on hosts without a
|
||||
live gameplay session.
|
||||
`ICharacterInfo.Name` projects the canonical local `ClientObject.Name` (empty
|
||||
when unavailable) solely for per-character plugin profile scoping; it does not
|
||||
introduce a second identity owner.
|
||||
|
||||
Core `SelectionState` is the sole selected-object owner for world,
|
||||
radar, inventory, paperdoll, toolbar, use/examine consumers, and plugins;
|
||||
`IPluginHost.Selection` exposes that same state and retail-style old/new callback.
|
||||
|
|
@ -332,7 +394,19 @@ src/
|
|||
Installation/ -> portable four-DAT validation, Windows retail
|
||||
path discovery, versioned JSONL bake-process
|
||||
orchestration, and atomic SHA/size/tool-version
|
||||
install-record verification and recovery; one
|
||||
install-record verification and recovery;
|
||||
startup discovery begins only after the desktop
|
||||
window opens, and exceptional whole-pak hashing
|
||||
reports its long-read status in that window;
|
||||
content recipes resolve through one compiled
|
||||
None/Overlay/FullRebuild/Verify migration ledger;
|
||||
bounded changes build one cumulative filtered
|
||||
overlay and publish `pak/content.current.json`,
|
||||
while full rebuilds bake beside the live base and
|
||||
swap only after candidate verification; a tiny
|
||||
`pak/content.client-pending` gate survives a
|
||||
crash/restart until the active client is
|
||||
confirmed compatible; one
|
||||
OS-handle lease serializes recovery/install per
|
||||
DataDirectory; a second OS-held publication
|
||||
lock plus durable per-transaction nonce makes
|
||||
|
|
@ -362,7 +436,9 @@ src/
|
|||
is never persisted, and permits HTTP only for a
|
||||
loopback fixture; production remains pinned HTTPS
|
||||
ViewModels/ -> thin MVVM projection over Launcher.Core,
|
||||
including the first-run DAT/bake wizard and
|
||||
including the first-run DAT/bake wizard, explicit
|
||||
world-data work confirmation (kind, reason,
|
||||
free-space guidance, progress/cancellation), and
|
||||
nonfatal startup/manual update state, actions,
|
||||
progress, cancellation, rollback, and errors
|
||||
-> references Launcher.Core only (Platform transitively); it never owns
|
||||
|
|
@ -372,6 +448,28 @@ src/
|
|||
-> Linux launcher/probe/headless flows remain portable; graphical-client
|
||||
actions are explicitly disabled until Modern Runtime Slice L resumes
|
||||
|
||||
Prepared-content launch contract
|
||||
-> `install.json` remains the strict backward-compatible base-pak authority
|
||||
-> format 2 / recipe 6 is the current full-package contract: globally
|
||||
shared texture payloads, independent adaptive blob compression, and
|
||||
source-native BC1/2/3 for surfaces that require no pixel-local edit
|
||||
-> format changes always use an explicit confirmed full rebuild; the
|
||||
launcher preflights 2 GiB free space, shows long-work progress, validates
|
||||
the candidate beside the active package, and promotes atomically
|
||||
-> the prepared texture LRU is bounded to 64 MiB / 1,024 entries; GPU atlas
|
||||
ownership remains in App and the whole-file mmap is virtual, not a
|
||||
committed-copy cache
|
||||
-> optional `pak/content.current.json` binds one cumulative overlay to the
|
||||
base SHA; there is never an unbounded overlay chain
|
||||
-> `pak/content.client-pending` prevents newly migrated content from
|
||||
becoming launchable before the matching client check/install succeeds,
|
||||
including across launcher restart
|
||||
-> launcher session config carries base + optional overlay paths and both
|
||||
recipe identities only for layered launches
|
||||
-> App and Headless construct one `LayeredPreparedAssetSource`; overlay
|
||||
Missing falls through to base, while overlay Corrupt is authoritative
|
||||
for both render and collision reads
|
||||
|
||||
AcDream.Headless/ Linux/Windows no-window production host
|
||||
Program.cs -> CLI entry only
|
||||
Configuration/ -> strict versioned process/session config
|
||||
|
|
@ -393,6 +491,10 @@ src/
|
|||
IGameState.cs -> done
|
||||
IEvents.cs -> done
|
||||
ISelectionService.cs -> done
|
||||
IPluginStorage.cs -> manifest-scoped durable text profiles
|
||||
Automation.cs -> character/spell/magic/chat automation groups
|
||||
CombatAutomation.cs -> hostile snapshots + retail combat attempts
|
||||
EnchantmentAutomation.cs -> shared confirmed duration-cast timer ledger
|
||||
|
||||
AcDream.App/ Layer 1 + Layer 4 wiring
|
||||
Platform/
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -22,7 +22,7 @@ particles), and uses the same Silk.NET + .NET stack we target.
|
|||
our tree (see CLAUDE.md for the full breakdown):
|
||||
- `src/AcDream.Core/Rendering/Wb/` — pure helpers (no GL): `TerrainUtils`,
|
||||
`TerrainEntry`, `RegionInfo`, `SceneryHelpers`, `TextureHelpers`.
|
||||
- `src/AcDream.App/Rendering/Wb/` — GL infrastructure + mesh pipeline:
|
||||
- `src/AcDream.App/Rendering/Wb/` — Vulkan/RHI infrastructure + mesh pipeline:
|
||||
`ObjectMeshManager`, `WbMeshAdapter`, `WbDrawDispatcher`, texture cache,
|
||||
shader infra, EnvCell/portal/scenery/terrain-blending pipeline classes.
|
||||
|
||||
|
|
@ -42,7 +42,7 @@ non-render content plus explicit bake/equivalence tooling.
|
|||
|
||||
**MP1a (2026-07-05): CPU mesh-extraction half moved to `AcDream.Content`.**
|
||||
The GL-free portion of the former `ObjectMeshManager` — dat read → polygon
|
||||
walk → vertex/index build → inline BCn/palette texture decode →
|
||||
walk → vertex/index build → palette/conditional BCn texture decode →
|
||||
`ObjectMeshData` — is now `MeshExtractor` in a new `src/AcDream.Content/`
|
||||
assembly (no Silk.NET dependency), so the MP1b bake tool can run the exact
|
||||
same extraction code offline without an OpenGL context. This was a
|
||||
|
|
@ -84,7 +84,7 @@ behavior change, no divergence-register row.
|
|||
survive, as they always did). The MP1b bake tool passes its own
|
||||
collector.
|
||||
- **Stays in `src/AcDream.App/Rendering/Wb/`:** `ObjectMeshManager` (the
|
||||
staged-queue/worker-pool/Dispose-quiesce lifecycle and all GL upload;
|
||||
staged-queue/worker-pool/Dispose-quiesce lifecycle and all Vulkan/RHI upload;
|
||||
production workers now consume `IPreparedAssetSource`),
|
||||
`ObjectRenderData`/`ObjectRenderBatch`
|
||||
(hold a GL `TextureAtlasManager` field), `TextureAtlasManager`,
|
||||
|
|
@ -120,9 +120,8 @@ EnvCell, Surface, palette, and texture graphs during portals. The validated
|
|||
machine-local `acdream.pak` is opened through Content's
|
||||
`IPreparedAssetSource`; typed GfxObj and EnvCell requests deserialize immutable
|
||||
`ObjectMeshData` while retaining the existing App worker, staging, render-thread
|
||||
upload, cache, ownership, and shutdown contracts. The original
|
||||
format-1/bake-tool-3 render payload persists exact batch translucency so App
|
||||
does not reconstruct a
|
||||
upload, cache, ownership, and shutdown contracts. The prepared render payload
|
||||
persists exact batch translucency so App does not reconstruct a
|
||||
`GfxObjMesh` for metadata. Setup activation uses the package TOC as an explicit
|
||||
type-presence index before reading valid Setup records through the bounded DAT
|
||||
cache. `DatPreparedAssetSource` and `MeshExtractor` remain explicit
|
||||
|
|
@ -132,8 +131,39 @@ bake/equivalence/UI-Studio tools, not a production fallback. Portal → HighRes
|
|||
installed-DAT gates are recorded in
|
||||
`docs/research/2026-07-24-slice-c-prepared-asset-cutover-report.md`.
|
||||
|
||||
**Slice I3 prepared collision extension (2026-07-25).** The package remains
|
||||
format 1 and retains mesh type values 1–3; bake-tool 4 appends typed GfxObj,
|
||||
**Launcher cumulative-overlay extension (2026-08-25).** Production still has
|
||||
no live-DAT fallback and consumes the same prepared-payload contracts. For a
|
||||
bounded recipe migration, App and Headless may receive one complete base pak
|
||||
plus one cumulative filtered pak through `LayeredPreparedAssetSource`. The
|
||||
overlay is probed first: Missing falls through to the base, while a present but
|
||||
corrupt render or collision payload remains authoritative corruption. Both
|
||||
mapped owners share one composite lifetime and there is never an overlay
|
||||
chain. The launcher binds the overlay to the base digest in the optional
|
||||
`pak/content.current.json` sidecar; format/global extraction migrations retain
|
||||
the explicit full-rebuild path. A tiny `pak/content.client-pending` marker
|
||||
keeps either result non-launchable until the matching client is confirmed,
|
||||
including across a crash/restart. Design and gates:
|
||||
`docs/plans/2026-08-25-launcher-content-stabilization.md`.
|
||||
|
||||
**PAK v2 resource closeout (2026-08-27).** Format 2 / bake recipe 6 retains
|
||||
the fixed header and sorted random-access TOC while moving texture arrays into
|
||||
one globally shared type-8 payload partition and independently applying
|
||||
adaptive Brotli compression with raw fallback. Unedited DXT1/3/5 surfaces
|
||||
retain exact DAT BC blocks through Vulkan upload; clip maps and authored
|
||||
translucency continue through the RGBA edit path. The reader bounds decoded
|
||||
blobs and retains shared texture arrays in a 64 MiB / 1,024-entry LRU. The
|
||||
complete installed package is 597,229,424 bytes versus 29,908,271,024 bytes
|
||||
for format 1; four- and nine-worker bakes have identical SHA-256. The
|
||||
authoritative connected before/after route reduced heavy-route working set by
|
||||
48.0%, private bytes by 26.1%, and prepared-mesh GPU bytes by 25.9% with equal
|
||||
or faster matching reveal/frame percentiles. Format changes remain mandatory
|
||||
launcher-confirmed full rebuilds with beside-active validation and atomic
|
||||
promotion; overlays never cross a format boundary. Design and evidence:
|
||||
`docs/plans/2026-08-27-pak-v2-resource-campaign.md`.
|
||||
|
||||
**Slice I3 prepared collision extension (2026-07-25).** At its introduction,
|
||||
the package remained format 1 and retained mesh type values 1–3; bake-tool 4
|
||||
appended typed GfxObj,
|
||||
Setup, CellStruct, and EnvCell-topology collision payloads. Core owns the
|
||||
immutable flat records and deterministic raw-DAT flattener. Content owns the
|
||||
strict little-endian codec and `IPreparedCollisionSource`.
|
||||
|
|
|
|||
384
docs/launch-options.md
Normal file
384
docs/launch-options.md
Normal file
|
|
@ -0,0 +1,384 @@
|
|||
# acdream launch options — operator reference
|
||||
|
||||
Every environment variable and command-line argument the acdream client
|
||||
reads, what it does, and **what else it changes about the run**.
|
||||
|
||||
**This is an operator's reference, not user documentation.** Players never
|
||||
set these: the launcher owns installation and login, and the in-client
|
||||
Options panel (F11) owns settings. If a flag here looks like something a
|
||||
player would want, that is a signal it belongs in the Options panel, not a
|
||||
signal to document it better.
|
||||
|
||||
## How to use this document
|
||||
|
||||
- **Running the client for yourself?** Read *Production launch* and stop.
|
||||
- **Taking a measurement?** Read *Production launch*, then read the
|
||||
*Side effects* column of every flag you are about to set. A flag that
|
||||
changes what you are measuring is the normal case, not the exception.
|
||||
- **Adding a flag?** Add its row in the same commit. `LaunchOptionsDocumentationTests`
|
||||
fails the build otherwise — in both directions, so deleting a read site
|
||||
without deleting its row fails too.
|
||||
|
||||
### Why the side-effects column exists
|
||||
|
||||
Two flags in this list were believed to be inert and were not:
|
||||
|
||||
- `ACDREAM_AUTOMATION_ARTIFACT_DIR` reads like an output path. It also
|
||||
constructs a per-frame diagnostics referee that re-enabled a retired
|
||||
render pass, costing ~6 MB and ~14 ms **every frame** — three days of
|
||||
performance measurements were silently taxed before anyone noticed
|
||||
([#432](ISSUES.md)).
|
||||
- `ACDREAM_STREAM_RADIUS` reads like a radius knob. It forces the near
|
||||
radius, only ever *raises* the far radius, and is then silently
|
||||
discarded by any later quality apply — so a measurement taken with it
|
||||
set is measuring a window production never uses.
|
||||
|
||||
Assume a flag has a side effect until its row says otherwise.
|
||||
|
||||
## Conventions
|
||||
|
||||
- **Everything diagnostic is OFF by default.** Every probe, dump, capture,
|
||||
and measurement flag in this document is inert until its variable is
|
||||
explicitly set — an unset environment runs zero diagnostics. Exactly
|
||||
five flags default ON, and none is a diagnostic: `ACDREAM_RETAIL_CHASE`,
|
||||
`ACDREAM_CAMERA_COLLIDE`, `ACDREAM_CAMERA_ALIGN_SLOPE`, and
|
||||
`ACDREAM_RETAIL_CLOSE_DEGRADES` are retail *behaviors* wearing an A/B
|
||||
off-switch (`=0` disables the behavior for a comparison run), while
|
||||
`ACDREAM_RETAIL_UI` is the product's only gameplay presentation and uses
|
||||
the same explicit diagnostic opt-out. That five-flag set is frozen by
|
||||
`LaunchOptionsDocumentationTests` — a new
|
||||
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
|
||||
one whose test is "is the variable present").
|
||||
- **Default** is the behavior when the variable is unset.
|
||||
- **Kind** is one of:
|
||||
|
||||
| Kind | Meaning |
|
||||
|---|---|
|
||||
| `production` | Ordinary configuration; safe in a real run. |
|
||||
| `measurement` | Profiling/instrumentation. Read the side effects before trusting numbers taken with it on. |
|
||||
| `automation` | Drives scripted runs; usually implies extra machinery. |
|
||||
| `permanent-probe` | A diagnostic toggle owned by a subsystem's diagnostics class. Expected to persist. |
|
||||
| `temporary-probe` | Tied to an open investigation. Deleted with its issue — never build tooling on one. |
|
||||
| `deprecated` | Superseded. Do not use for new work. |
|
||||
|
||||
---
|
||||
|
||||
## Production launch
|
||||
|
||||
The canonical connected launch against a local ACE server. PowerShell,
|
||||
because the DAT path contains an apostrophe:
|
||||
|
||||
```powershell
|
||||
$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call"
|
||||
$env:ACDREAM_LIVE = "1"
|
||||
$env:ACDREAM_TEST_HOST = "127.0.0.1"
|
||||
$env:ACDREAM_TEST_PORT = "9000"
|
||||
$env:ACDREAM_TEST_USER = "testaccount"
|
||||
$env:ACDREAM_TEST_PASS = "testpassword"
|
||||
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Release
|
||||
```
|
||||
|
||||
| Flag | Value | What it does | Side effects | Default | Read by |
|
||||
|---|---|---|---|---|---|
|
||||
| `ACDREAM_A2C` | `unset/""` keep preset; `"0"/"false"/"False"/"FALSE"` → off; any other non-empty → on | Overrides preset's `AlphaToCoverage` blend flag | Changes MSAA alpha-to-coverage blending mode for foliage/translucent draws — a visual-behavior change, not just perf | preset's `AlphaToCoverage` (High/Ultra=true, Low/Medium=false) | `QualitySettings.WithEnvOverrides` (`QualityPreset.cs:52`) |
|
||||
| `ACDREAM_AC_DIR` | `=<path>` | Points at a real retail AC install dir; loads `<dir>/controls/controls.ini` to source retail keybind display strings for the retained UI. | Only has any effect while the default-on retained UI is composed (`ACDREAM_RETAIL_UI` is not `0`). Unset → `ControlsIni.Parse(string.Empty)`, an empty (not error) controls table — silent, no fallback file is searched. | unset (null) → empty controls table | `RuntimeOptions.AcDir` → `InteractionRetainedUiComposition.cs:610` |
|
||||
| `ACDREAM_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` |
|
||||
| `ACDREAM_CAMERA_COLLIDE` | `=0` disables (anything else/unset = on) | selects whether the chase camera sweeps a 0.3 m collision sphere from head-pivot to eye and stops at the first wall (retail spring-arm) | alters camera position every frame (camera can clip into geometry when disabled); startup-only | true (on) | `CameraDiagnostics.CollideCamera` |
|
||||
| `ACDREAM_CONFIG_DIR` | `=<path>` | Overrides the resolved config-root directory (`settings.json`, `keybinds.json`) | none beyond redirecting config I/O | Windows: `%APPDATA%\acdream`; Linux: `$XDG_CONFIG_HOME/acdream` or `~/.config/acdream` | `ApplicationPathSet.Resolve` (`ApplicationPathSet.cs:79`), via `IApplicationPathEnvironment` seam |
|
||||
| `ACDREAM_DATA_DIR` | `=<path>` | Overrides the resolved data-root directory (logs, screenshots, plugins) | none beyond redirecting data I/O | Windows: `%LOCALAPPDATA%\acdream`; Linux: `$XDG_DATA_HOME/acdream` or `~/.local/share/acdream` | `ApplicationPathSet.Resolve` (`ApplicationPathSet.cs:81`), via `IApplicationPathEnvironment` seam |
|
||||
| `ACDREAM_DAT_DIR` | `=<path>` | Fallback dat-directory when no positional argument is given. App: single read at `Program.cs:58`. Cli: read independently per-subcommand (each subcommand does `args.ElementAtOrDefault(N) ?? Env.GetEnvironmentVariable("ACDREAM_DAT_DIR")`) plus once more for the default (no-subcommand) asset-inventory mode at line 152. | Two of the four `Program.cs` line numbers in the raw grep (91, 135) are **not reads** — they're the literal string `ACDREAM_DAT_DIR` inside `Log.Error` usage-text messages, not `GetEnvironmentVariable` calls. Only line 58 is a real read in `AcDream.App`. | none — hard usage error (exit 2) if unset and no positional arg | `Program.cs:58` (App); `Cli/Program.cs:24,35,47,59,71,84,113,125,137,152` (every Cli subcommand) |
|
||||
| `ACDREAM_DISPLAY_PROTOCOL` | `="auto"` / `"x11"` / `"wayland"` (case-insensitive, trimmed); any other value throws `InvalidOperationException` at startup | Linux-only: forces the GLFW 3.4 platform-init hint (X11 vs Wayland vs auto) before any window is created; ignored entirely on Windows (always `Windows` protocol) | An invalid value is fatal at startup (throws before any window exists), not a silent fallback | unset → auto-detected from `XDG_SESSION_TYPE`/`WAYLAND_DISPLAY`/`DISPLAY`, falling back to GLFW `Automatic` | `GraphicalWindowBackendSelection.Resolve` (`GraphicalWindowBackendSelection.cs:26-58`) |
|
||||
| `ACDREAM_FAR_RADIUS` | `=<int>` | Overrides preset's `FarRadius` (outer streaming/reveal window, landblocks) | Enlarging changes streaming memory budget and what's resident/rendered — CLAUDE.md: leave unset for measurement/gate runs (same family as legacy `ACDREAM_STREAM_RADIUS`) | preset's `FarRadius` (Low=5, Medium=8, High=12, Ultra=15) | `QualitySettings.WithEnvOverrides` (`QualityPreset.cs:47`) |
|
||||
| `ACDREAM_LIVE` | `=1` (exactly the literal string `"1"`) | Core switch: connect to a live ACE server instead of running offline/no-connect. | The 4 non-`RuntimeOptions.cs` line numbers in the raw grep are **all comments or log-message text**, not reads — `SessionStartComposition.cs:39` is inside the string `"live: ACDREAM_LIVE set but TEST_USER/TEST_PASS missing; skipping"`; `Program.cs:126` is inside a `--session-config` override log line; `GameWindow.cs:614,627` are doc comments. The only actual parse is `RuntimeOptions.cs:141`. Requires `ACDREAM_TEST_USER`/`ACDREAM_TEST_PASS` too (`HasLiveCredentials`) or the session silently reports `MissingCredentials` and skips. Forced to effectively-on (LiveMode=true) unconditionally by `--session-config` launches regardless of this var. | `false` | `RuntimeOptions.LiveMode` → `SessionStartComposition.cs` (log text only), `Program.cs:126` (log text only), `GameWindow.cs:614,627` (comments only), consumed for real via `RuntimeOptions.HasLiveCredentials` and `WorldSession`/`GameRuntime` session-start gating |
|
||||
| `ACDREAM_MAX_COMPLETIONS_PER_FRAME` | `=<int>` | Overrides preset's per-frame streaming-completion throughput cap | Directly changes the streaming admission budget measured by perf/completion gates — do not vary during a measurement run | preset's value (Low=2, Medium=3, High=4, Ultra=6) | `QualitySettings.WithEnvOverrides` (`QualityPreset.cs:59`) |
|
||||
| `ACDREAM_MSAA_SAMPLES` | `=<int>` (0/2/4/8) | Overrides preset's MSAA sample count | Changes GPU multisample anti-aliasing (visual + GPU-cost change) | preset's `MsaaSamples` (Low=0, Medium=2, High/Ultra=4) | `QualitySettings.WithEnvOverrides` (`QualityPreset.cs:48`) |
|
||||
| `ACDREAM_NEAR_RADIUS` | `=<int>` | Overrides preset's `NearRadius` (inner streaming ring radius) | Changes the streaming/render window size — CLAUDE.md: leave unset for measurement/gate runs | preset's `NearRadius` (Low=2, Medium=3, High=4, Ultra=5) | `QualitySettings.WithEnvOverrides` (`QualityPreset.cs:46`) |
|
||||
| `ACDREAM_NO_AUDIO` | `=1` | Suppresses OpenAL device init entirely (headless / driver-broken machines). | Fail-open design: even without this flag, a missing/broken OpenAL driver already makes `IsAvailable=false` and all `Play*` calls no-ops — this flag is the explicit, deliberate version of the same fallback. When set, `LiveSessionWorldRuntime.WorldAudio` is `null` and logout-audio reset/resume steps no-op. | `false` | `RuntimeOptions.NoAudio` → `GameWindow.cs:1430` → `ContentEffectsAudioCompositionPhase` → `OpenAlAudioEngine.cs` (fail-open doc), `LiveSessionRuntimeFactory.cs:71` (`WorldAudio` gate) |
|
||||
| `ACDREAM_PAK_PATH` | `=<path>` | Overrides the path to the prepared machine-local asset bake (`acdream.pak`) that production world-mesh streaming reads through `IPreparedAssetSource`. | none beyond the obvious | unset → `<datDir>/acdream.pak` | `RuntimeOptions.PreparedAssetPath` → `ContentEffectsAudioComposition.cs:53,372,379`, `GameWindow.cs:1420` |
|
||||
| `ACDREAM_PLUGIN_TAGS` | comma-separated tags (maximum 128 tags, 128 characters each) | Advertises machine-local role/group tags through the plugin peer-discovery API, for UtilityBelt-compatible expressions such as client selection by tag. Values are trimmed and deduplicated case-insensitively. | Writes the tags into the bounded local peer heartbeat document while a character is in world; no network traffic leaves the machine. | unset → no tags | `RuntimeOptions.PluginTags` → `AppAutomationSurface` / `LocalPluginPeerRegistry` |
|
||||
| `ACDREAM_RESIDENCY_ALPHA_SCRATCH_MIB` | `=<int MiB>` (`>0`, else default; overflow-checked) | Byte ceiling for the retail alpha (translucency) draw queue's scratch buffer | Shrinking below production working set changes translucency-queue eviction/reflow behavior — not comparable to a default-budget perf run | 16 MiB | `ResidencyBudgetOptions.Parse` (`ResidencyBudgetOptions.cs:88-89`), flows through `RuntimeOptions.ResidencyBudgets`→`AlphaScratchBudgetProfile.Create`→`RetailAlphaQueue` ctor (`GameWindow.cs:721-725`) |
|
||||
| `ACDREAM_RESIDENCY_ANIMATION_ENTRIES` | `=<int>` (`>0`, else default) | Entry-count ceiling for the retained animation-data cache | Changes cache eviction cadence for animation data — a perf/memory measurement under a non-default value is not representative | 512 | `ResidencyBudgetOptions.Parse` (`:82-84`) |
|
||||
| `ACDREAM_RESIDENCY_ANIMATION_MIB` | `=<int MiB>` (`>0`, else default) | Byte ceiling for the retained animation-data cache | Same cache-behavior-change caveat as the entries variant | 64 MiB | `ResidencyBudgetOptions.Parse` (`:79-81`) |
|
||||
| `ACDREAM_RESIDENCY_AUDIO_MIB` | `=<int MiB>` (`>0`, else default) | Byte ceiling for the retained audio-buffer cache | Shrinking can force more frequent audio buffer re-decode/eviction | 32 MiB | `ResidencyBudgetOptions.Parse` (`:85-87`), consumed by `ContentEffectsAudioComposition.cs` |
|
||||
| `ACDREAM_RESIDENCY_COMPOSITE_PHYSICAL_MIB` | `=<int MiB>` (`>0`, else default) | Byte ceiling for physically-resident composite (character palette/texture) GPU memory | Changes composite-texture eviction pressure — not representative of production if varied during a measurement run | 128 MiB | `ResidencyBudgetOptions.Parse` (`:67-69`), consumed by `TextureCache.cs` |
|
||||
| `ACDREAM_RESIDENCY_COMPOSITE_UNOWNED_MIB` | `=<int MiB>` (`>0`, else default) | Byte ceiling for unowned/retained (not currently referenced) composite textures kept for reuse | Same eviction-pressure caveat | 64 MiB | `ResidencyBudgetOptions.Parse` (`:70-72`), consumed by `TextureCache.cs` |
|
||||
| `ACDREAM_RESIDENCY_MESH_GPU_MIB` | `=<int MiB>` (`>0`, else default) | Byte ceiling for GPU-resident object mesh data | The single largest residency budget (1024 MiB default) — shrinking it directly forces more mesh re-upload/eviction; do not vary during an FPS/GPU-memory measurement run | 1024 MiB | `ResidencyBudgetOptions.Parse` (`:49-51`), consumed by `ObjectMeshManager.cs`/`WbDrawDispatcher.cs` |
|
||||
| `ACDREAM_RESIDENCY_MESH_STAGING_ENTRIES` | `=<int>` (`>0`, else default) | Entry-count ceiling for the mesh upload staging cache | Changes staging-buffer churn/eviction cadence | 256 | `ResidencyBudgetOptions.Parse` (`:64-66`) |
|
||||
| `ACDREAM_RESIDENCY_MESH_STAGING_MIB` | `=<int MiB>` (`>0`, else default) | Byte ceiling for the mesh upload staging cache | Same staging-churn caveat | 128 MiB | `ResidencyBudgetOptions.Parse` (`:61-63`) |
|
||||
| `ACDREAM_RESIDENCY_MESH_UNOWNED_ENTRIES` | `=<int>` (`>0`, else default) | Entry-count ceiling for unowned (retained-for-reuse) object mesh entries | Changes mesh-cache eviction cadence | 50 | `ResidencyBudgetOptions.Parse` (`:52-54`) |
|
||||
| `ACDREAM_RESIDENCY_PREPARED_MESH_ENTRIES` | `=<int>` (`>0`, else default) | Entry-count ceiling for the CPU-side "prepared mesh" cache (post-classification, pre-upload) | Changes eviction cadence for prepared-mesh CPU memory | 100 | `ResidencyBudgetOptions.Parse` (`:58-60`) |
|
||||
| `ACDREAM_RESIDENCY_PREPARED_MESH_MIB` | `=<int MiB>` (`>0`, else default) | Byte ceiling for the CPU-side prepared-mesh cache | Same eviction-cadence caveat | 128 MiB | `ResidencyBudgetOptions.Parse` (`:55-57`) |
|
||||
| `ACDREAM_RESIDENCY_STANDALONE_UNOWNED_ENTRIES` | `=<int>` (`>0`, else default) | Entry-count ceiling for unowned standalone (non-composite) texture entries | Changes standalone-texture eviction cadence | 256 | `ResidencyBudgetOptions.Parse` (`:76-78`), consumed by `TextureCache.cs` |
|
||||
| `ACDREAM_RESIDENCY_STANDALONE_UNOWNED_MIB` | `=<int MiB>` (`>0`, else default) | Byte ceiling for unowned standalone texture memory | Same eviction-cadence caveat | 32 MiB | `ResidencyBudgetOptions.Parse` (`:73-75`) |
|
||||
| `ACDREAM_RETAIL_CHASE` | `=0` disables (anything else/unset = on) | selects the retail-faithful `RetailChaseCamera` vs. the legacy rigid-follow `ChaseCamera` | swaps the entire active camera implementation — changes camera motion/feel; startup-only in practice (its DebugPanel/DebugVM mirror is unreachable — #434)| true (retail camera on) | `CameraDiagnostics.UseRetailChaseCamera` |
|
||||
| `ACDREAM_RETAIL_CLOSE_DEGRADES` | inverted: `="0"` disables; any other value (incl. unset) enables | Default-**on** real gameplay behavior: applies retail's close-range LOD mesh-part swap (`GfxObjDegradeResolver`) to humanoid setups (issue #47), matching retail's close-detail degrade. | Inverted default (opposite of every other boolean flag in this table — presence of the literal string `"0"` is what disables it, not presence of `"1"` enabling it). Documented explicitly as "set only for before/after diagnostic comparisons" — so although default-on production behavior, its *disable* path exists purely for A/B measurement. | `true` (enabled) unless value is exactly `"0"` | `RuntimeOptions.RetailCloseDegrades` → `DatLiveEntityProjectionMaterializer.cs:275-276,480-498` |
|
||||
| `ACDREAM_RETAIL_UI` | `=0` disables; anything else/unset enables | Controls the retained retail UI host tree (`UiHost`/`UiRoot`, D.2b), the product's only gameplay UI. | Disabling it leaves world rendering with no gameplay interface and is intended only for diagnostics. The same rule applies to bare env and `--session-config`/launcher launches; no path force-overrides it. | `true` | `RuntimeOptions.RetailUi` → `LivePresentationComposition.cs` (gates retained-UI mount via `InteractionRetainedUiComposition`) |
|
||||
| `ACDREAM_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`) |
|
||||
| `ACDREAM_TEST_USER` | `=<string>` | ACE account name for live-mode connect. | none | `null` (empty → `HasLiveCredentials` false) | `RuntimeOptions.LiveUser` (`RuntimeOptions.cs:144`) |
|
||||
| `ACDREAM_VULKAN_DEVICE` | `=<int>` (decimal index) or `=<substring>` (case-insensitive device-name match) | Overrides automatic Vulkan physical-device selection (normally: discrete > integrated > virtual > CPU, tie-broken by device-local heap size) — for multi-GPU machines. | A bare-digits value is matched as an index ONLY (never falls through to substring match) specifically because digits like `"7"` are substrings of real device names ("AMD Radeon RX 9070 XT") — a fallback would silently select the wrong device by coincidence. An override matching nothing falls back to the automatic choice (does not fail startup) and records why in the capability report. | `null` → automatic ranked choice | `RuntimeOptions.VulkanDeviceOverride` → `VulkanPhysicalDeviceSelection.Choose` (`VulkanPhysicalDeviceSelection.cs:54-100`), consumed at `VulkanGraphicsContext.cs:207,322` |
|
||||
|
||||
## Command-line arguments
|
||||
|
||||
### `AcDream.App`
|
||||
|
||||
| Arg | What it does | Side effects |
|
||||
|---|---|---|
|
||||
| `<dat-directory>` (positional) | Dat directory; outranks `ACDREAM_DAT_DIR`. | Not read at all once `--session-config` is present. |
|
||||
| `--session-config <path>` | The launcher's launch path: endpoint, account, credential reference, character selector, status file, plugins, login commands. | **Overrides `ACDREAM_LIVE` and every `ACDREAM_TEST_*`** (logged at startup). Diagnostic flags stay env-controlled. Missing value is a startup error. |
|
||||
|
||||
### `AcDream.Headless`
|
||||
|
||||
Its usage banner matches the parser exactly. `validate` loads and checks a
|
||||
config without connecting; `run` connects.
|
||||
|
||||
| Arg | What it does | Side effects |
|
||||
|---|---|---|
|
||||
| `validate` \| `run` (positional) | Selects the mode; must be the first argument. | Anything else is a parse error. |
|
||||
| `--config <path>` | The versioned headless session-configuration document. Required. | — |
|
||||
| `--config-dir` / `--data-dir` / `--cache-dir` `<path>` | Override each portable path root. | Merged over the config document's own `process.paths`; the command line wins. |
|
||||
| `-user` / `--user`, `-password` / `--password` | Direct single-session credentials, bypassing the config's credential source. | Plaintext in the process command line — prefer the config's credential reference. |
|
||||
| `--help` / `-h` (or no args) | Prints usage, exits 0. | — |
|
||||
|
||||
### `AcDream.Launcher`
|
||||
|
||||
| Arg | What it does | Side effects |
|
||||
|---|---|---|
|
||||
| `--verify-publish` | Packaging smoke probe: parses arguments and exits 0 without opening a display or resolving user paths. | — |
|
||||
| `--config-dir` / `--data-dir` / `--cache-dir` `<absolute path>` | Override each path root. | **All three or none** — supplying a subset is an error. Must be absolute. |
|
||||
| `--update-manifest-uri <uri>` | Points the self-updater at a different release manifest (test-feed seam). | Must be `https://` (or loopback `http://`). Changes where updates come from — do not point a real install at a test feed. |
|
||||
| `--acdream-self-update-helper-v1`, `--acdream-self-update-confirm-v1` | Internal re-exec markers for the self-update handoff. | Not user-facing; never pass these by hand. |
|
||||
|
||||
### `AcDream.Cli`
|
||||
|
||||
A dat-dump and measurement tool dispatched by a positional subcommand
|
||||
(`args[0]`); no `--flag` options. Most subcommands take a dat directory and
|
||||
fall back to `ACDREAM_DAT_DIR`.
|
||||
|
||||
- **Measurement:** `summarize-frame-history <frames.csv> <checkpoints.jsonl> <markers.log> <out.json>`,
|
||||
`compare-screenshots <expected.png> <actual.png> <out.json> [channelTolerance=2] [maxDifferentFraction=0.001] [mask.png]`,
|
||||
`probe <in.png> <x0> <y0> <x1> <y1>`.
|
||||
- **Dat inspection:** no subcommand (asset-type inventory), `dump-vitals-bars`,
|
||||
`dump-vitals-layout [0xLayoutId]`, `list-ui-layouts [0xRootType]`,
|
||||
`dump-sprite-sheet <0xId,...>`, `dump-font-atlas [0xFontId] [sample] [outBase]`,
|
||||
`dump-edges <0xId>`, `export-ui-sprite <0xId> [out.png]`.
|
||||
- **Mockup rendering:** `render-vitals-mockup [out.png]`, `mock-selbar [out.png]`,
|
||||
`crop <in.png> <x> <y> <w> <h> <zoom> <out.png>`.
|
||||
|
||||
## Measurement and profiling
|
||||
|
||||
| Flag | Value | What it does | Side effects | Default | Read by |
|
||||
|---|---|---|---|---|---|
|
||||
| `ACDREAM_CAPTURE_RESOLVE` | `=<path>` | appends one JSON-Lines record (full before/after `PhysicsBody` snapshot) per player-side `ResolveWithTransition` call, filtered to `IsPlayer` movers | real per-tick allocation (snapshot object graph + `System.Text.Json` serialize) and buffered file I/O (`AutoFlush=false`) for the local player only; will skew any perf measurement of local-player physics while active; feeds `CellarUpTrajectoryReplayTests` fixtures | unset (off) | `AcDream.Core.Physics.PhysicsResolveCapture` (`CapturePath`) |
|
||||
| `ACDREAM_COLLISION_SHADOW_DIR` | `=<dir>` | output directory for Slice I5 graph/flat collision-shadow mismatch artifacts | only takes effect when `ACDREAM_COLLISION_SHADOW_EVERY>0`; directory creation + file writes on mismatch | `<CurrentDirectory>/.test-out/collision-shadow` | `PhysicsDiagnostics.CollisionShadowArtifactDirectory` |
|
||||
| `ACDREAM_COLLISION_SHADOW_EVERY` | `=<positive int>` | when >0 and the cache is constructed with `requirePreparedCollision:false`, arms a `CollisionShadowVerifier` that re-runs the graph-vs-flat collision referee every Nth traversal entry (`PhysicsDataCache` ctor) | extra CPU on sampled ticks + mismatch-artifact file I/O; graph path stays authoritative regardless of mismatch (doc-asserted, not independently verified here) — does not change production physics results, but does add work when active | `0` (disabled) | `PhysicsDiagnostics.CollisionShadowSampleEvery` (parsed via `ParsePositiveInt`, non-positive → 0) |
|
||||
| `ACDREAM_DAY_GROUP` | `=<int>` | Forces Dereth's day-group (weather preset) selection instead of the retail hash-based pick, "useful for visually A/B-testing each weather preset against retail" (own doc comment). | **Dead second read**: the `SkyDescLoader.cs:252` raw read only feeds `SelectDayGroupIndex`, which is only called from `ActiveDayGroup(double)` and the `DefaultDayGroup` property — and grepping all of `src/` finds **zero production call sites** for either. That whole path is unreachable; only the typed `RuntimeOptions.ForcedDayGroupIndex` → Runtime path is live. Bounds differ too: the typed path only checks `>= 0` (`TryParseNonNegativeInt`) and Runtime clamps out-of-range to `null`; the dead Core-layer path checks `forced >= 0 && forced < DayGroups.Count` directly. `SkyState.cs:400,403` are doc-comment mentions only, not reads. | unset → normal server/date-driven hash selection | `RuntimeOptions.ForcedDayGroupIndex` (typed) → `GameWindow.cs:718` → `WorldEnvironmentController` → `RuntimeWorldEnvironmentState` (Runtime, live path); **also** raw `Environment.GetEnvironmentVariable` at `SkyDescLoader.cs:252` (Core layer, separate parse) |
|
||||
| `ACDREAM_DISABLE_TIER1_CACHE` | `="1"` (ordinal exact match; anything else = enabled) | A/B diagnostic that forces **every** static (non-animated) entity through the slow per-entity classification path, bypassing the Tier-1 classification cache (`#53`) | Materially changes per-frame CPU cost for entity classification — a perf/FPS measurement taken with this set is NOT representative of production and must not be compared against a normal run | unset (cache enabled) | `WbDrawDispatcher` ctor field `_tier1CacheDisabled` (`WbDrawDispatcher.cs:473-474`) |
|
||||
| `ACDREAM_FRAME_HISTORY` | `=<path>` | opts into a per-frame CSV history capture (frame idx, timestamps, per-stage CPU us, GPU us, alloc bytes) alongside the aggregated 5 s `[frame-prof]` report | allocates a `List<FrameHistoryRecord>` with ~131,072-record (~9 MiB) initial capacity, growing further for longer captures (~72 B/record, ~43 MB/hour at 165 fps) held in memory for the whole run; CSV write happens ONLY at `Dispose`/shutdown (no frame-thread I/O); only takes effect while `ACDREAM_FRAME_PROF` is ALSO on | unset (off) | `RenderingDiagnostics.FrameHistoryPath` / `AcDream.App.Diagnostics.FrameProfiler` |
|
||||
| `ACDREAM_ORBIT_DISTANCE_METERS` | `=<float>`, must be finite and `>0` | Diagnostic-only initial distance for the offline orbit camera, so deterministic renderer acceptance captures land inside a finite shadow reach. | Own doc comment: "used by deterministic renderer acceptance captures." Rejects non-finite/non-positive values silently (parses to `null`, camera default used). | `null` (unset) → normal camera default | `RuntimeOptions.InitialOrbitDistanceMeters` → `GameWindow.cs:1412` → offline orbit-camera composition |
|
||||
| `ACDREAM_ORBIT_PITCH_DEGREES` | `=<float>`, clamped `[-89, 89]` | Diagnostic-only initial orbit camera elevation. | Values outside `[-89,89]` or non-finite are silently rejected (→ `null`, default kept) rather than clamped. | `null` | `RuntimeOptions.InitialOrbitPitchDegrees` → `GameWindow.cs:1414` |
|
||||
| `ACDREAM_ORBIT_YAW_DEGREES` | `=<float>`, must be finite | Diagnostic-only initial orbit camera heading. | Non-finite values silently rejected (→ `null`). | `null` | `RuntimeOptions.InitialOrbitYawDegrees` → `GameWindow.cs:1413` |
|
||||
| `ACDREAM_PROBE_REVEAL_RADIUS` | `=<int>=1` (unparsable or `<1` → override absent; floor is 1, not 0) | #280 A/B measurement probe: forces the OUTDOOR reveal gate to use this landblock radius instead of the derived streaming window (near radius clamped to it), so a route can be measured with the pre-#280 behavior (`=1`, old `OutdoorNeighborhoodRadius`) vs. current | **Changes what gets revealed, not just measured** — genuinely resizes the reveal/visible window used by the live reveal gate. CLAUDE.md: "Leave it unset for any measurement or gate run — with it set you are measuring a different window than production." `=0` is rejected by the parser specifically because it would hang the very A/B route it exists to measure (`RequiredRenderRadius==0` fails `invalid-readiness-shape`). Not a user setting, not in Settings/RuntimeOptions, not persisted. | unset (derivation in charge, no override) | `StreamingDiagnostics.RevealRadiusOverride` (`StreamingDiagnostics.cs:25-27,76-80`), applied by `StreamingDiagnostics.ApplyRevealRadiusOverride` |
|
||||
| `ACDREAM_PROBE_WORLD_FRAME` | `=1` | gates one `[world-frame] agree` line per projected conversion in `DatLiveEntityProjectionMaterializer`, recording the world-frame center both `LiveWorldOriginState` (App) and Runtime's physics-state owner used (issue #283, "measurement only; it never gates placement") | print-only | off | `PhysicsDiagnostics.ProbeWorldFrameEnabled` |
|
||||
| `ACDREAM_SKY_PHASE_SECONDS` | `=<float>` (any finite value; negative accepted, taken mod 1 per axis) | Campaign V slice V7 instrument-determinism pin: freezes the sky's cloud-sheet UV scroll to a fixed elapsed-seconds value instead of monotonic real elapsed time, so two launches of a differential/offline gate agree about cloud position. | **Non-obvious dual effect**: this ONE var pins TWO independently-designed clocks that happen to share a name-adjacent purpose — the sky renderer's cloud scroll (`SkyRenderer.AnimationPhaseSecondsOverride`) AND, since Campaign VM slice VM6, the atmospheric post-process graph's foliage-wind clock (`_windClockSecondsOverride`). A gate that only knows about "sky clouds" and sets this to freeze them will *also* freeze foliage-wind evolution — deliberately snapped-to-target on the first advance per an A6 review fix, but still a second surface a naive reader wouldn't expect this var to touch. Distinct from `ACDREAM_DAY_GROUP`/`ACDREAM_WORLD_TIME`, which pin the OTHER sky clock (day group/sun angle) — retail's clouds drift independently of the calendar date by design. | `null` → monotonic elapsed time (every ordinary run) | `RuntimeOptions.SkyAnimationPhaseSeconds` → `SkyRenderer.AnimationPhaseSecondsOverride` (cloud/rain UV scroll) **and** `AtmosphericPostProcessGraph`'s foliage-wind clock |
|
||||
| `ACDREAM_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`) |
|
||||
| `ACDREAM_STREAM_WORK_ENTITY_OPS` | `=<int>` (`>0`, else default) | Per-frame ceiling on entity-cursor operations (small ops, e.g. one dictionary/index write each) on the update thread | A/B-measurement-only family. Doc comment: elapsed-time ceiling (`ACDREAM_STREAM_WORK_MS`) remains the authoritative CPU guard — this is a secondary cap, deliberately loose (leaves >90% of the time budget unused at default) | 4,096 | `StreamingWorkBudgetOptions.Parse` (`:62-64`) |
|
||||
| `ACDREAM_STREAM_WORK_GL_RETIRE_OPS` | `=<int>` (`>0`, else default) | Per-frame ceiling on GL/GPU resource-retirement operations on the update thread | A/B-measurement-only family; changes retirement cadence, which changes when GPU memory is actually reclaimed | 64 | `StreamingWorkBudgetOptions.Parse` (`:68-70`) |
|
||||
| `ACDREAM_STREAM_WORK_GPU_MIB` | `=<int MiB>` (`>0`, else default) | Per-frame ceiling on GPU upload bytes on the update thread | A/B-measurement-only family; directly changes per-frame upload throughput | 8 MiB | `StreamingWorkBudgetOptions.Parse` (`:65-67`) |
|
||||
| `ACDREAM_STREAM_WORK_HOLD_DEST_MS` | `=<double ms>` (`>0` and finite, else default `8.0`) | Absolute (not quality-scaled) time ceiling for destination-lane work during a portal/login hold; never shrinks a profile whose own ceiling is already ≥ this value | Explicitly documented as "NOT a user setting... exists for A/B measurement only, matching the rest of the `ACDREAM_STREAM_WORK_*` family" — do not set outside a deliberate hold-latency A/B comparison | 8.0 ms | `StreamingWorkBudgetOptions.Parse` (`:74-76`); `HoldDestinationCeilingMilliseconds` widens the frame meter via `StreamingWorkBudget.WidenForDestinationHold` while a destination reservation hides the world behind the authored tunnel (#418) |
|
||||
| `ACDREAM_STREAM_WORK_MS` | `=<double ms>` (`>0` and finite, else default) | Per-frame elapsed-time ceiling for update-thread streaming work — "the authoritative CPU guard" per the entity-ops comment | A/B-measurement-only family; this is the primary per-frame time budget for streaming — changing it changes both perceived streaming latency and measured frame cost | 2.0 ms | `StreamingWorkBudgetOptions.Parse` (`:53-55`) |
|
||||
| `ACDREAM_UNCAPPED_RENDER` | `=1` | Removes the normal VSync/refresh-rate software pacer, so the render loop runs as fast as the GPU/CPU allow. | Own doc comment (`RuntimeOptions.cs:147-150`): "Normal presentation is always bounded by VSync or a refresh-rate software pacer. This explicit diagnostic is the sole way to measure truly uncapped renderer throughput." Not representative of what a real player experiences — exists purely for throughput measurement. | `false` → VSync/pacer-bounded | `RuntimeOptions.UncappedRendering` → `GameWindow.cs:765` → `DisplayFramePacingController`; also `VulkanBringUpHost.cs:75` |
|
||||
| `ACDREAM_WB_DIAG` | `=1` (raw `string.Equals` ordinal compare) | (a) `GameWindow`: gates the `[FRAME-DIAG]` render-thread entity-upload-distribution report; (b) `WbDrawDispatcher`: gates `BeginRhiTimer`/`SampleRhiTimers`, wrapping the opaque/detail/transparent draw passes in extra Vulkan GPU timer-scope queries and periodically logging a `[WB-DIAG]` CPU/GPU median/p95 report | adds extra per-pass GPU timestamp queries every frame while on — genuine measurement overhead; NOT read through `RenderingDiagnostics` or any diagnostics-owner class, unlike every other flag in this set — flag for whitelisting (see Notes #2); the flag's supposed interaction with `ACDREAM_FRAME_PROF`'s GPU query is stale documentation (see Notes #1) | unset (off) | read directly at `WbDrawDispatcher.cs:2061-2064` (every `Draw()`/`BeginEntityDispatch` call, i.e. effectively per frame, NOT cached) and cached once as a readonly field at `GameWindow.cs:153-156` |
|
||||
| `ACDREAM_WORLD_TIME` | `=<float>`, accepted only in `[0, 1)` | Campaign V slice V7 instrument-determinism pin: freezes the Dereth day fraction (and therefore sun direction, sky keyframe, and every lit surface) instead of following the server clock. | Outranks BOTH the server `TimeSync` clock and the `/time` slash command's `SetDebugTime` (which is deliberately transient — the next `TimeSync` clears it); this pin does not clear. Distinct axis from `ACDREAM_DAY_GROUP` (day-group/weather-preset selection) and `ACDREAM_SKY_PHASE_SECONDS` (cloud scroll + foliage wind) — the calendar DATE still advances, only the intra-day fraction freezes. Anything outside `[0,1)` (including negative, unparseable, or unset) leaves the server clock alone entirely — no partial/clamped behavior. | `null` → server clock | `RuntimeOptions.PinnedWorldDayFraction` → `GameWindow.cs:720` → `WorldEnvironmentController` → `Runtime.WorldTime.PinnedDayFraction` |
|
||||
|
||||
## Automation
|
||||
|
||||
A scripted route run adds three things at once — a session config so the
|
||||
client self-selects a character, a route script, and an artifact directory:
|
||||
|
||||
```powershell
|
||||
$env:ACDREAM_UI_PROBE_SCRIPT = "$scratch\route.txt"
|
||||
$env:ACDREAM_AUTOMATION_ARTIFACT_DIR = "$scratch\artifacts"
|
||||
$env:ACDREAM_FRAME_PROF = "1"
|
||||
$env:ACDREAM_FRAME_HISTORY = "$scratch\frames.csv"
|
||||
& $exe --session-config "$scratch\session.json"
|
||||
```
|
||||
|
||||
**Two traps this recipe exists to document:**
|
||||
|
||||
1. **Without `--session-config`, the client stops at character select** and
|
||||
the route never runs. The session JSON supplies the endpoint, account,
|
||||
and a character `index` for auto-selection.
|
||||
2. **`ACDREAM_AUTOMATION_ARTIFACT_DIR` is not free.** It constructs the
|
||||
render-scene oracle, which fingerprints every resident entity every
|
||||
frame. The allocation cost was fixed in
|
||||
[#432](ISSUES.md), but the CPU walk remains — automation-run frame
|
||||
rates are diagnostics-loaded and must only be compared against other
|
||||
automation runs, never against a plain run. Some route verbs
|
||||
(`wait world-*`) additionally do nothing unless this is set.
|
||||
|
||||
| Flag | Value | What it does | Side effects | Default | Read by |
|
||||
|---|---|---|---|---|---|
|
||||
| `ACDREAM_AUTOMATION_ARTIFACT_DIR` | `=<path>` | Output directory for the retail-UI automation probe's checkpoint JSON + screenshot PNG artifacts; gates whether the full `WorldLifecycleAutomationController` (checkpoint/screenshot/render-pack-automation capable) is composed at all vs. the cheaper facts-only `WorldRevealFactsAutomationRuntime` fallback (`wait world-ready/visible` verbs work either way per issue #415's fix; checkpoint/screenshot verbs report "requires ACDREAM_AUTOMATION_ARTIFACT_DIR" without it). | **Known #432 surprise, confirmed still live**: `FrameRootComposition.cs:349-353` — `AutomationArtifactDirectory is not null` (together with `RetainedUi?.Screenshots is not null`) unconditionally constructs a `CurrentRenderSceneOracle` **and** a `RenderSceneShadowComparisonController` — a per-frame diagnostics referee — regardless of whether any checkpoint/screenshot is ever actually requested that session. Merely setting this var for its "just an output path" purpose pays the per-frame comparison cost for the whole run. | unset (null) → facts-only automation runtime, no per-frame referee constructed | `RuntimeOptions.AutomationArtifactDirectory` → `FrameRootComposition.cs:351,543-627`, `WorldLifecycleAutomationController.cs`, `RetailUiAutomationScriptRunner.cs:108` |
|
||||
| `ACDREAM_AUTOMATION_EXACT_FRAMEBUFFER` | `=1` | Forces the graphical host to use the persisted display resolution as the *initial* size of a **borderless** window at creation, so the OS window manager cannot clamp a decorated window to the desktop work area — needed for pixel-exact automated screenshot comparison. | Changes window chrome (borderless) at startup — a visible difference from an ordinary launch, not just an internal measurement knob. | `false` → normal decorated window | `RuntimeOptions.ExactAutomationFramebuffer` → `GameWindow.cs:852` (`CreateStartupWindowOptions`) |
|
||||
| `ACDREAM_BAKE_PUBLISH_NONCE_V1` | `=<32-hex GUID "N" format>` | Launcher-to-bake-child authorization token: when present and valid, the bake child takes a cross-process publish file lock + writes an authorization file before atomic publication (serializes with launcher recovery) | If present but fails `IsValidNonce` (not a 32-char Guid "N"), throws `InvalidOperationException` and aborts the bake. When absent, bake runs unguarded (standalone mode). Never set this manually outside the launcher's own child-process spawn. | unset (standalone unguarded bake) | `BakePublicationGuardPaths.cs:12`, read by `BakePublicationGuard.AcquireIfRequested` (`AcDream.Bake/BakePublicationGuard.cs:18`); set by `BakeProcessRunner.cs:150/162` |
|
||||
| `ACDREAM_NET_DROP_DIR` | `="out"`/`"in"`/anything-else (incl. unset) → `Both` (case-insensitive) | Selects which direction(s) — outbound, inbound, or both — the deterministic loss-injection decorator drops | Only takes effect when `ACDREAM_NET_DROP_PCT>0` (decorator is structurally absent otherwise). Drives real datagram loss on the live connection — the injection point for `tools/run-connected-loss-gate.ps1`. Never set during a normal/measurement run. | `Both` | `NetDiagnostics.NetDropDir` (`NetDiagnostics.cs:88-90,98-104`), consumed by `LossyTransportDecorator.WrapIfConfigured` (`Transport/LossyTransportDecorator.cs:21-22`), also read at `WorldSession.cs:901-907` (comment only) |
|
||||
| `ACDREAM_NET_DROP_PCT` | `=<int 0-100>` (out-of-range or unparsable → 0) | Percent chance (post-handshake-arming, per droppable datagram) that the deterministic `LossyTransportDecorator` drops a packet in the configured direction(s) | **Fault injection.** `>0` wraps the real socket transport in a packet-dropping decorator for the whole session — genuinely breaks/delays delivery to exercise N1-N4 reliable-transport recovery. At 0 the decorator is never constructed (zero structural cost). Must be 0/unset for any normal run or non-loss-gate measurement. | `0` (off, decorator absent) | `NetDiagnostics.NetDropPercent` (`NetDiagnostics.cs:60-69,92-96`), consumed by `LossyTransportDecorator.WrapIfConfigured` (`:21`), wired at `WorldSession.cs:901-907` |
|
||||
| `ACDREAM_NET_DROP_SEED` | `=<int>` (unparsable → `1`) | PRNG seed for the loss decorator (outbound seeded with `seed`, inbound with `~seed`) — same seed reproduces an identical drop pattern | Only matters when `ACDREAM_NET_DROP_PCT>0`; makes fault injection deterministic/reproducible for the connected loss gate | `1` | `NetDiagnostics.NetDropSeed` (`NetDiagnostics.cs:75-82`), consumed by `LossyTransportDecorator` (`:21-22`) |
|
||||
| `ACDREAM_OPEN_CHARGEN` | `=1` | Campaign CC slice CC4 interim env/test-only seam: opens the character-creation screen (`gmCharGenMainUI`) automatically once Runtime's chargen view goes active, bypassing the real retail Create-Character-button transition. Fires once per mount (`_openOnStartConsumed` latch). | Own doc comment explicitly calls this "interim env/test-only" — Campaign CC (closed 2026-08-16, user-accepted) later wired the real Create button with its roster<55-slot ghost gate, so this flag is now a bypass of that gate for automation/testing rather than the only way in. | `false` | `RuntimeOptions.OpenCharacterCreationOnStart` → `CharacterCreationUiController.cs:21,524-530` |
|
||||
| `ACDREAM_UI_PROBE_DUMP` | `=1` | Enables the retail-UI automation probe's diagnostic dump path and feeds `RetailUiProbeBindings`/`RetailUiAutomationScriptRunner`. Also part of `RuntimeOptions.UiProbeEnabled` (`UiProbeDump \ | \ | UiProbeScript is set`). | `RuntimeOptions.UiProbeDump` → `LivePresentationComposition.cs:1465-1495`, `InteractionRetainedUiComposition.cs:1092-1100` |
|
||||
| `ACDREAM_UI_PROBE_SCRIPT` | `=<path>` | Path to a script file the `RetailUiAutomationScriptRunner` executes against the retained UI (pointer/semantic-input command playback) for scripted UI regression testing. | Also flips `RuntimeOptions.UiProbeEnabled` true even without `ACDREAM_UI_PROBE_DUMP=1`. | `null` | `RuntimeOptions.UiProbeScript` → `InteractionRetainedUiComposition.cs:1094` |
|
||||
| `ACDREAM_VULKAN_FORCE_UNSUPPORTED` | `=<feature-name>` (case-insensitive property name, e.g. `MultiDrawIndirect`) | Test knob (Slice V5): clears one named required Vulkan feature from the capability record to synthetically fail the gate, so the `NotSupportedException` → exit-code-4 → report path can be exercised on hardware that actually supports everything. | Deliberately breaks Vulkan startup when set to a matched feature name — this is a "make it fail on purpose" gate-testing flag, never appropriate for a normal or measurement run. | `null` → real capabilities used unmodified | `RuntimeOptions.VulkanForcedUnsupportedFeature` → `VulkanCapabilityRecord.Without` (`VulkanCapabilityRecord.cs:113-119`), consumed at `VulkanGraphicsContext.cs:339` |
|
||||
| `ACDREAM_VULKAN_PROBE` | `=1` | Runs the standalone Vulkan capability-probe/bring-up harness (opens its own window, runs the capability gate, presents synthetic V6c/V6d verification scenes, captures one screenshot) **instead of** the real client composition host, then exits. | This flag ALONE gates entry (`GameWindow.cs:828`); the former `ACDREAM_RENDER_BACKEND=vulkan` co-requisite died with the OpenGL backend (its class doc was corrected 2026-08-24). | `false` → normal composition host | `RuntimeOptions.VulkanCapabilityProbe` → `GameWindow.cs:828` → `VulkanBringUpHost` |
|
||||
| `ACDREAM_VULKAN_PROBE_FRAMES` | `=<int>` (non-negative) | Bounds the bring-up probe harness to N presented frames so it can run unattended in CI, instead of presenting until a human closes the window. | The frame budget never cuts a pending screenshot capture short — the loop stays open until the screenshot has been attempted even past the budget, so an unattended run's whole product (a PNG) is guaranteed. Zero (unset/unparseable/explicit `0`) keeps the interactive wait-for-close behavior. | `0` → interactive (wait for window close) | `RuntimeOptions.VulkanCapabilityProbeFrames` → `VulkanBringUpHost.cs:141-249` |
|
||||
| `ACDREAM_DUMP_MOVE_TRUTH` | `=1` | Emits one `move-truth OUT` line per outbound movement record (MoveToState / AutonomousPosition): local resolved position vs the wire position/cell, ground contact, velocity (`MovementTruthDiagnosticController`). | **Automation apparatus, NOT a spent probe** — the canonical nine-stop soak (`tools/run-connected-r6-soak.ps1`) hard-gates on ≥2 of these lines per destination as its proof that production input produced outbound movement traffic; deleting it fails the soak at every stop (#437, deleted-and-restored 2026-08-24). Print volume follows the outbound send cadence. | off | `RuntimeOptions.DumpMoveTruth` → `GameWindow.cs` → `MovementTruthDiagnosticController` |
|
||||
|
||||
## Permanent diagnostics
|
||||
|
||||
| Flag | Value | What it does | Side effects | Default | Read by |
|
||||
|---|---|---|---|---|---|
|
||||
| `ACDREAM_CAPTURE_PLAYER_QUANTA` | `=<path>` (any non-whitespace path) | Opt-in JSON-Lines trace of every admitted player physics quantum (position/orientation/velocity/contact-plane snapshots at each stage boundary of `CPhysicsObj::UpdateObjectInternal`) | Appends+flushes one JSON line per physics quantum to the file (real file I/O on the physics tick when enabled); disabled path costs one static string null/empty check, no allocation. Read once into a mutable static property (settable via `ResetForTest`) rather than a typed options object. | unset (disabled, zero-alloc) | `PlayerPhysicsQuantumCapture` static class (`AcDream.Runtime/Gameplay/PlayerPhysicsQuantumCapture.cs:22`) |
|
||||
| `ACDREAM_DUMP_MOTION` | `=1` | prints `UM`/`[UM_STALE]`/`[MOTIONDONE]`/`VU.land`/raw-hex wire dump lines tracing inbound `UpdateMotion` handling, remote ground-contact edges, and motion-done callbacks (bug-a/#32 stuck-cast subthread is temporary; core trace is long-lived) | print-only, but the raw-site reads in `LiveEntityNetworkUpdateController.cs` and `UpdateMotion.cs` fire on EVERY inbound motion/UM event (not cached) — `Environment.GetEnvironmentVariable` call per packet even when off; `UpdateMotion.cs`'s branch additionally builds a `StringBuilder` hex dump when on. Rule-5 violation (raw reads outside a diagnostics-owner class) at 5+ call sites | off | THREE independent readers: `PhysicsDiagnostics.DumpMotionEnabled` (owner, appears unconsumed — see Notes), `AnimationPresentationDiagnostics.FromEnvironment()` (App owner record, cached once at startup, consumed by `LiveEntityAnimationPresenter`), and raw `Environment.GetEnvironmentVariable` reads scattered across `LiveEntityNetworkUpdateController.cs` (4 sites) + `Core.Net/Messages/UpdateMotion.cs:163` + `Runtime/Physics/RuntimeRemotePhysicsUpdater.cs:630` |
|
||||
| `ACDREAM_DUMP_PLAYSCRIPT` | `="1"` (ordinal) | Traces PhysicsScript playback: missing/empty script resolution, malformed `StartTime` entries, and other `[pes]`-prefixed hook-dispatch events | print-only (`Console.WriteLine`) at all 4 use sites (`:85-86,136,300,328`) | unset (off) | `PhysicsScriptRunner.DiagEnabled` (`PhysicsScriptRunner.cs:61-62`) — per-instance settable property seeded from the env var, not a shared static diagnostics-owner class |
|
||||
| `ACDREAM_DUMP_SURFACES` | `="1"` (ordinal) | One-shot (per session) surface-format histogram dump for the atlas-opportunity audit — fires once after `_dumpFrameCounter>=600` OnRender ticks AND `_uploadMetadata.Count>=100` uploaded textures; writes to the host diagnostics directory | Doc comment claims "Zero cost when off" but `_uploadMetadata[name]=(w,h,fmt)` (`TextureCache.cs:1042`) is written **unconditionally on every texture upload regardless of the flag** — real (small) always-on dictionary-write cost. `TickSurfaceHistogramDumpIfEnabled` also re-reads `Environment.GetEnvironmentVariable` every OnRender frame (not cached) until the one-shot fires. Dump-write failures are caught and logged to stderr, not fatal. | unset (off) | `TextureCache` (`TextureCache.cs:102-113` fields, gate at `TextureCache.cs:802-812`, dump at `TextureCache.cs:814-829`), Phase N.6 slice 1 |
|
||||
| `ACDREAM_FRAME_PROF` | `=1` | master toggle for the frame profiler: CPU frame time, GPU time samples, per-stage CPU attribution, per-frame alloc/GC, `[frame-prof]` report every ~5 s (doc: "permanent apparatus ... do not strip with session probes") | when on, samples `GC.GetAllocatedBytesForCurrentThread()` and stage-scope timing every frame (cheap, by design); its own XML doc claims a GPU-query self-disable tied to `ACDREAM_WB_DIAG=1` that `FrameProfiler.cs` says no longer exists — see Notes #1; startup-only in practice (its DebugPanel/DebugVM mirror is unreachable — #434)| false (off) | `RenderingDiagnostics.FrameProfEnabled` / `FrameProfiler` |
|
||||
| `ACDREAM_PROBE_ENVCELL` | `=1` | emits one `[envcells]` line per indoor frame: `CellsRendered`/`TrianglesDrawn` + ourBldgs/otherBldgs/filter counts (phase a8 relic; its own render pass was removed but the probe was kept) | print-only; implicitly turned on whenever `ACDREAM_PROBE_VIS` is on (getter is `_probeEnvCellEnabled \ | \ | `RenderingDiagnostics.ProbeEnvCellEnabled` (backing field OR'd with `ProbeVisibilityEnabled`) |
|
||||
| `ACDREAM_PROBE_INDOOR_ALL` | `=1` | master switch that reads as AND / writes as cascade across Walk, Lookup, Upload, Xform, Cull | print-only (every underlying probe is print-only); startup-only in practice (its DebugPanel/DebugVM mirror is unreachable — #434)| false (off) | `RenderingDiagnostics.IndoorAll` (cascades to the 5 flags below) |
|
||||
| `ACDREAM_PROBE_INDOOR_CULL` | `=1` (also set by `ACDREAM_PROBE_INDOOR_ALL=1`) | emits `[indoor-cull]` per culled cell entity with cull reason (visibleCellIds-miss / frustum / landblock) | print-only; startup-only (its DebugPanel/DebugVM mirror is unreachable — #434) | false (off) | `RenderingDiagnostics.ProbeIndoorCullEnabled` |
|
||||
| `ACDREAM_PROBE_INDOOR_LOOKUP` | `=1` (also via `ACDREAM_PROBE_INDOOR_ALL`) | emits `[indoor-lookup]` per visible cell entity/sec: render-data hit/miss, IsSetup, parts-hit/parts-miss tallies | print-only; startup-only (its DebugPanel/DebugVM mirror is unreachable — #434) | false (off) | `RenderingDiagnostics.ProbeIndoorLookupEnabled` |
|
||||
| `ACDREAM_PROBE_INDOOR_UPLOAD` | `=1` (also via `ACDREAM_PROBE_INDOOR_ALL`) | emits `[indoor-upload]` requested/completed lines per EnvCell id at `WbMeshAdapter`'s staged-drain time | print-only; startup-only (its DebugPanel/DebugVM mirror is unreachable — #434) | false (off) | `RenderingDiagnostics.ProbeIndoorUploadEnabled` |
|
||||
| `ACDREAM_PROBE_INDOOR_WALK` | `=1` (also via `ACDREAM_PROBE_INDOOR_ALL`) | emits `[indoor-walk]` per visible cell entity/sec: world position, parent cell, landblock/AABB-visible flags, "drew" flag | print-only; startup-only (its DebugPanel/DebugVM mirror is unreachable — #434) | false (off) | `RenderingDiagnostics.ProbeIndoorWalkEnabled` |
|
||||
| `ACDREAM_PROBE_INDOOR_XFORM` | `=1` (also via `ACDREAM_PROBE_INDOOR_ALL`) | emits `[indoor-xform]` per visible cell entity/sec: cell-geometry SetupPart's composed world-matrix translation | print-only; startup-only (its DebugPanel/DebugVM mirror is unreachable — #434) | false (off) | `RenderingDiagnostics.ProbeIndoorXformEnabled` |
|
||||
| `ACDREAM_PROBE_LOGIN_FRAMES` | `="1"` | Per-completed-frame login/portal-wormhole presentation classification (`world`/`tunnel`/`black`/`void`); logs `[login-frames]` on each classification transition | print-only. "Not a user setting; not in RuntimeOptions; not persisted" (doc comment). | unset (off) | `RenderPresentationDiagnostics.ProbeLoginFrames` (`LoginPresentationFrameProbe.cs:28-29`), consumed by `LoginPresentationFrameProbe.Process` |
|
||||
| `ACDREAM_PROBE_NET` | `="1"` | Emits `[net-out]` (per outbound reliable message), `[net-tick]` (1 Hz WorldSession.Tick summary incl. reliable-transport rates), `[net-final]` (cumulative stats at Dispose), and `[cmd-gate]` (generation-gated command rejections) | print-only. Doc comment: "the counters themselves increment unconditionally in `TransportStats`; only the string work is gated" — i.e. the underlying stats tracking has a small always-on cost independent of this flag, but this flag itself gates only string/console formatting. | unset (off) | `NetDiagnostics.ProbeNet` (`NetDiagnostics.cs:56-57`), issue #260 probe family |
|
||||
| `ACDREAM_PROBE_RESOLVE` | `=1` | gates one structured `[resolve]` line per `PhysicsEngine.ResolveWithTransition` call (in/target/out position+cell, ok-vs-partial, grounded/contact status, wall normal, walkable-polygon validity, responsible entity) (l.2a slice 1, general-purpose resolver probe) | print-only, ~30 Hz per moving entity while on | off | `PhysicsDiagnostics.ProbeResolveEnabled` |
|
||||
| `ACDREAM_PROBE_REVEAL` | `="1"` | While a reveal destination's composite warmup is incomplete, emits one `[composite-warmup]` line/second: pending queue depth, scan state, upload-budget gate, first few unresolved GfxObj ids | print-only | unset (off) | `NetDiagnostics.ProbeReveal` (`NetDiagnostics.cs:115-116`), issue #260 |
|
||||
| `ACDREAM_PROBE_REVEAL_TIMING` | `="1"` | Wall-clock attribution of each login/portal reveal hold: `[reveal-timing]` lines for `begin`/first-true readiness edges (render/composites/collision/gate/materialized), 1 Hz progress, and one `SUMMARY` line at viewport reveal; paired low-frequency `[reveal-resource]` snapshots report mesh staging/uploads/arena state, prepared-asset activity, composite warmup/uploads, managed memory, and tracked GPU residency | print-only; the probe object and render-resource sampler are not constructed when unset. When enabled, canonical resource owners are sampled only at begin, readiness edges, 1 Hz progress, and summary—not every frame. Use with `ACDREAM_FRAME_PROF=1` / `ACDREAM_FRAME_HISTORY` for per-frame CPU/GPU/alloc timing. | unset (off) | `StreamingDiagnostics.ProbeRevealTiming`, `RevealTimingProbe`, `RuntimeRenderFrameResourceDiagnosticsSource`, `PublicationTimingProbe` |
|
||||
| `ACDREAM_PROBE_TUNNEL_FREEZE` | `=1` or `=N` | #419 RenderDoc apparatus: holds the teleport state in stable `Tunnel` after destination readiness and freezes the portal-space animation/roll at frame 72 (`=1`) or an explicit frame 2–120 (`=N`); emits one `[tunnel-freeze]` line with the actual frame and retail Setup/animation ids | **behavior-changing diagnostic:** placement, world viewport reveal, and LoginComplete are intentionally withheld until transition cancellation/process exit. For static visual inspection only; never use in a performance or lifecycle measurement. | unset (off) | `StreamingDiagnostics.TunnelFreezeFrame`; consumed by `LocalPlayerTeleportPresentation` and `PortalTunnelPresentation` |
|
||||
| `ACDREAM_PROBE_SOUND_WIRE` | `="1"` | One line per inbound server Sound event (`0xF750`) and per wire-sound play decision, with the drop reason when nothing plays — used to determine whether missing interior soundscapes are server- or client-side | print-only, consumed at `AudioHookSink.cs:159` and `EntityEffectController.cs:123` | unset (off) | `AudioDiagnostics.ProbeWireSoundsEnabled` (`AudioDiagnostics.cs:20-21`) |
|
||||
| `ACDREAM_PROBE_USEABILITY_FALLBACK` | `=1` | gates a per-call log of `IsUseableTarget` calls that take the null-useability fallback path (creature/door/lifestone passes) (measures a real ace-vs-retail data gap, not a bug investigation) | print-only; measures how often ACE ships entities without `_useability` set | off | `PhysicsDiagnostics.ProbeUseabilityFallbackEnabled` |
|
||||
| `ACDREAM_PROBE_VIS` | `=1` | emits `[vis]` line on root-cell CHANGE: visible cell ids, OutsideView poly/plane counts, per-cell plane counts, scissor-fallback count (phase u.2d repurposed the flag; its DebugPanel mirror is unreachable — #434) | print-only; ALSO implicitly enables the separate `ACDREAM_PROBE_ENVCELL` probe (its getter ORs with this flag — see Notes #3); startup-only in practice (its DebugPanel/DebugVM mirror is unreachable — #434)| false (off) | `RenderingDiagnostics.ProbeVisibilityEnabled` |
|
||||
| `ACDREAM_REMOTE_VEL_DIAG` | `=1` | prints per-UM/per-tick remote-velocity and animation-cycle diagnostic lines; `Runtime/Physics/RemoteMotion.cs` carries diagnostic-only fields (`PrevServerPos`, `PrevServerPosTime`, `MaxRootMotionSpeedSinceLastUP`, `LastOmegaDiagLogTime`) unconditionally on every remote — small fixed per-instance memory regardless of the flag, not gated (long-lived remote-velocity/animation diagnostic, commit a.1) | print-only, but the raw-site reads in `LiveEntityNetworkUpdateController.cs` fire on every UM/tick even when off (rule-5 violation, `Environment.GetEnvironmentVariable` call per event, 6+ call sites) | off | THREE readers: `AnimationPresentationDiagnostics.FromEnvironment()` (App owner record, cached at startup, consumed by `LiveEntityAnimationPresenter` for `[SEQSTATE]`/`[CURRNODE]`/other part-diagnostic lines, throttled to 1/sec/entity) + raw `Environment.GetEnvironmentVariable` reads scattered across `LiveEntityNetworkUpdateController.cs` (6+ sites: `[UM_RAW]`, `[FWD_WIRE]`, `[VEL_DIAG]`, `[UPCYCLE_SRC]`, `[UM_STALE]`) + `RemoteServerControlledVelocityCycle.cs:68` (`[UPCYCLE]`) |
|
||||
| `ACDREAM_DUMP_CELLS` | `=<comma list of hex cell ids>` | one-shot JSON dump of any cached EnvCell whose id matches the list, to `ProbeDumpCellsPath` (issue #98 fixture capture) — Standing fixture-extraction tooling (A6.P3/#98 lineage) for the physics replay harness; roundtrip-tested. Not investigation-scoped. | file I/O once per matching cell id (no-op on repeat); fixture-generation tool, not a perf-neutral no-op when ids are listed | off/unset | `PhysicsDiagnostics.ProbeDumpCellIds` (`ParseHexIdList`) |
|
||||
| `ACDREAM_DUMP_CELLS_DIR` | `=<dir>` | overrides the output directory for `ACDREAM_DUMP_CELLS` — Companion output-directory knob for ACDREAM_DUMP_CELLS. | print/file-path only; no effect unless `ACDREAM_DUMP_CELLS` is also set | off/unset | `PhysicsDiagnostics.ProbeDumpCellsPath` |
|
||||
| `ACDREAM_DUMP_GFXOBJS` | `=<comma list of hex GfxObj ids>` | one-shot JSON dump of any cached GfxObj's polygon table + BSP root metadata matching the list, to `ProbeDumpGfxObjsPath` (issue #98 fixture capture) — Standing fixture-extraction tooling (A6.P3/#98 lineage), pair of DUMP_CELLS. | file I/O once per matching id (no-op on repeat) | off/unset | `PhysicsDiagnostics.ProbeDumpGfxObjIds` (`ParseHexIdList`) |
|
||||
| `ACDREAM_DUMP_GFXOBJS_DIR` | `=<dir>` | overrides the output directory for `ACDREAM_DUMP_GFXOBJS` — Companion output-directory knob for ACDREAM_DUMP_GFXOBJS. | print/file-path only; no effect unless `ACDREAM_DUMP_GFXOBJS` is also set | off/unset | `PhysicsDiagnostics.ProbeDumpGfxObjsPath` |
|
||||
| `ACDREAM_DUMP_SKY` | `=1` | Print-only: dumps decoded `SkyDesc` raw values on region load (`SkyDescLoader.cs`) and per-GfxObj `Surface.Type`/translucency flags on first upload (`SkyRenderer.cs`), plus gates a `TimeSync` console diagnostic in `GameWindow`. Built to resolve specific open questions about retail sky units and GfxObjReplace timing (2026-04-23 research), now answered but the dumps remain wired. — Generic sky-keyframe isolation dump (introduced with the phase-1 tint revert); a tool, not a bug probe. | Three independent reads of the SAME env var, only one of which (`RuntimeOptions.DumpSky`) goes through the typed options object; the other two are raw scattered reads (see Notes). `SkyRenderer.cs:582`'s raw read is in the App layer and has no architectural excuse for bypassing `RuntimeOptions` — `_options.DumpSky` was already available to that composition. `print-only` in all three sites. | off/unset | `RuntimeOptions.DumpSky` (typed) → `GameWindow.cs:704` (`TimeSyncDiagnostic`); **also** two independent raw `Environment.GetEnvironmentVariable` reads at `SkyDescLoader.cs:392` (Core) and `SkyRenderer.cs:582` (App) |
|
||||
| `ACDREAM_DUMP_STEEP_ROOF` | `=1` | gates `[steep-roof] KILL-VELOCITY-APPLIED` in `PhysicsEngine.ResolveWithTransition` when retail's `kill_velocity` zeroes body velocity on steep-slope impact, plus per-frame plane-normal traces in `TransitionTypes`/`PlayerMovementController` — KEEP: observes LIVE divergence-register row AD-56 (the plumb-fall freeze on steep-but-walkable polys, restored 2026-08-07). The only runtime lens on that active divergence; delete only with the AD-56 row itself. | print-only | off/unset | `PhysicsDiagnostics.DumpSteepRoofEnabled` |
|
||||
| `ACDREAM_HIDE_PART` | `=<int>` | Hides one mesh part by index on entities with ≥10 parts (humanoids) — a debugging aid for equipment/clothing part-visibility issues. — Generic model-part isolation tool (issue #37 lineage but general-purpose since); a tool, not a bug probe. | Real (visible) behavior change, not print-only, but scoped to a single diagnostic index and off by default. | off/unset | `RuntimeOptions.HidePartIndex` → `LivePresentationComposition.cs:608` → `LiveEntityAnimationPresenter.cs:21,38,243` |
|
||||
| `ACDREAM_PROBE_CELL` | `=1` | gates one `[cell-transit]` line per `PlayerMovementController.CellId` change (old→new cell, position, reason tag) — Standing cell-transit tracer (L.2a slice 1), pair of the permanent ACDREAM_PROBE_RESOLVE; recurs in every membership investigation. | print-only; low volume (only on actual cell crossings) | off/unset | `PhysicsDiagnostics.ProbeCellEnabled` |
|
||||
|
||||
## Temporary probes
|
||||
|
||||
Each row names the issue that owns it. **A temporary probe is deleted in
|
||||
the same commit as its investigation's fix** — if you find one here whose
|
||||
issue is closed, the strip was missed; delete both.
|
||||
|
||||
> **Probe debt, measured 2026-08-24:** 64 temporary probes existed, citing 21
|
||||
> distinct issues with 14 already closed. [#435](ISSUES.md) part 1 stripped
|
||||
> the 17 rows whose investigation had ended without the strip — see the
|
||||
> Retired section below for their removal record — leaving 47. Part 2
|
||||
> traced each of the (then-)14 unattributed rows to its introducing commit
|
||||
> and stripped the 7 that belonged to closed investigations
|
||||
> (`ACDREAM_A8_DUMP_PV`/Phase A8, `ACDREAM_DUMP_CLOTHING`/#37,
|
||||
> `ACDREAM_DUMP_EDGE_SLIDE`/#32, `ACDREAM_DUMP_LIVE_SPAWNS`/Phase A8,
|
||||
> `ACDREAM_DUMP_STEPUP`/L.2.3d-f, `ACDREAM_DUMP_VENDOR`/the vendor
|
||||
> campaign, `ACDREAM_DUMP_VITALS`/#5). An eighth,
|
||||
> `ACDREAM_DUMP_MOVE_TRUTH`, was deleted and then RESTORED the same day:
|
||||
> it turned out to be automation apparatus, not a probe — the canonical
|
||||
> nine-stop soak hard-gates on its output (see its row under Automation;
|
||||
> #437 is the record). The rest of the attributed rows were reclassified
|
||||
> into Permanent diagnostics as standing tools rather than investigation
|
||||
> probes, leaving **31 temporary probes, every one attributed to an owning
|
||||
> issue or campaign**. Each still costs a branch on its hot path even when
|
||||
> unset, and a handful re-read the environment per frame rather than
|
||||
> caching (see their side-effects column).
|
||||
|
||||
| Flag | Owning investigation | Value | What it does | Side effects | Read by |
|
||||
|---|---|---|---|---|---|
|
||||
| `ACDREAM_CLIP_DEBUG` | #176 | `=1` | forces the EnvCell SHELL pass to map every instance to clip slot 0 (no-clip) instead of its cell's portal-slice region | ALTERS RENDERED OUTPUT: shells draw whole/unclipped instead of trimmed — a visual isolation mode, not a log-only probe; no DebugPanel mirror | `RenderingDiagnostics.ClipDebugNoShellTrim` |
|
||||
| `ACDREAM_DUMP_APPEARANCE` | #5 | `="1"` | Logs every `0xF625` ObjDescEvent + `0xF7DB` UpdateObject with body length, target guid, hex preview — used to debug remote-player appearance asymmetry | print-only (`Console.WriteLine`) | `WorldSession` static field `DumpAppearanceEnabled` (`WorldSession.cs:792-793`), raw scattered read, issue #5 diagnostic |
|
||||
| `ACDREAM_DUMP_OPCODES` | #5 | `="1"` | Logs first occurrence of each genuinely-unhandled inbound opcode (deduped by opcode) | print-only. Must stay the LAST else-if in the dispatch chain per comment (else it would intercept handled opcodes) — currently correct. | `WorldSession` static field `DumpOpcodesEnabled` (`WorldSession.cs:788-789`, consumed `WorldSession.cs:2391-2398`), issue #5 diagnostic. Also mirrored (display-only, non-functional) via `DebugPanel.cs:241`/`DebugVM.cs:227`. |
|
||||
| `ACDREAM_DUMP_SCENERY_Z` | #48 | `=1` | Per-spawn Z-placement diagnostic for procedural scenery (trees/bushes/rocks), added for issue #48 (the "trees-in-sky" bug). | **NOT print-only** — this is a real behavior fork, not just added logging. `LandblockBuildFactory.cs:167-178`: when the flag is on, the streaming worker calls a **separate, duplicate scenery-building method** (`BuildSceneryEntitiesForStreaming`, a full parallel reimplementation of GfxObj/Setup mesh resolution + placement inline in this file) instead of production's `LandblockPhysicsContentBuilder.HydrateProceduralScenery`. Any visual/measurement run taken with this flag set is exercising a different scenery-placement code path than production, which can drift from it silently. | `RuntimeOptions.DumpSceneryZ` → `SessionPlayerComposition.cs:280` → `LandblockBuildFactory.cs:23,42,168,335` |
|
||||
| `ACDREAM_DUMP_TRANSIT_FAIL` | #345 | `=1` | buffers per-tick `[transit-fail-insert]`/`[transit-fail-stepup]`/`[transit-fail-walk]`/`[transit-fail-adjust]` trace lines into a `[ThreadStatic]` list and flushes them to console ONLY when a tick requested nonzero XY movement but delivered zero (self-selecting "stuck tick" predicate) | print-only, zero allocation when off (flag checked before touching any buffer per its own doc); buffer/list allocation only on ticks that are already stuck | `PhysicsDiagnostics.DumpTransitFailEnabled` |
|
||||
| `ACDREAM_LIGHT_DEBUG` | #176 | `=<int>` (`int.TryParse`; unset/invalid → 0) | shader isolation mode uploaded as `uLightDebug` by `EnvCellRenderer` + `WbDrawDispatcher`: 0=off, 1=ambient-only vertex lighting, 2=kill dynamic point lights, 3=raw vLit visualization (texture ignored) | ALTERS RENDERED OUTPUT directly every draw pass (changes fragment-shader lighting/texturing) — not a log probe; no DebugPanel mirror | `RenderingDiagnostics.LightDebugMode` |
|
||||
| `ACDREAM_PROBE_BUILDING` | l.2d slice 1 | `=1` | gates the multi-line `[resolve-bldg]` BSP-shadow-hit trace in `TransitionTypes.FindObjCollisions`, one-time `[entity-source]` registration logs in `GameWindow`, `[door-cycle]` UM dispatch trail, and a one-shot `[setstate-hex]` wire dump of the first `SetState` (0xF74B) packet in `WorldSession` | print-only; also un-gates the `PhysicsDiagnostics.LastBspHitPoly` diagnostic side-channel (a static field write in `BSPQuery`/`FlatBspQuery`, read back by the `[resolve-bldg]` line) — no gameplay effect, but an extra static-field write per BSP hit while on; heavy output (one multi-line entry per BSP hit per physics tick) | `PhysicsDiagnostics.ProbeBuildingEnabled` |
|
||||
| `ACDREAM_PROBE_CELLSET` | a6.p5 | `=1` | gates `PhysicsDiagnostics.LogCellSetBuild`, one `[cellset-build]` line per `BuildCellSetAndPickContaining` call (seed cell, sphere XY, candidate list) from `CellTransit.cs:1468` | print-only; builds a `StringBuilder` of the candidate id list only when the flag is on | `PhysicsDiagnostics.ProbeCellSetEnabled` |
|
||||
| `ACDREAM_PROBE_CELL_CACHE` | indoor walking phase d | `=1` | gates one `[cell-cache]` line per EnvCell first-cached in `PhysicsDataCache.CacheCellStruct` (poly counts, BSP root structure) | print-only; fires at most once per EnvCell (cache is no-op after first population); no DebugPanel mirror | `PhysicsDiagnostics.ProbeCellCacheEnabled` |
|
||||
| `ACDREAM_PROBE_CHILD_CELL` | c4 route 7 | `=1` | gates one `[child-cell]` line per Runtime committed-child canonical-cell write in `RuntimeLiveEntitySessionController`, `RuntimeEntityObjectLifetime`, `RuntimeEntityDirectory` (parent/child guid, old/new cell, cause tag) | print-only | `PhysicsDiagnostics.ProbeChildCellEnabled` |
|
||||
| `ACDREAM_PROBE_CLIPROUTE` | "throwaway apparatus — strip once §4 ships" | `=1` | print-on-change `[clip-route]` / `[clip-route-disp]` / `[clip-route-scis]` lines: outside-slice clip routing, region-SSBO bytes, terrain-UBO head, actual GL/RHI scissor state | print-only | `RenderingDiagnostics.ProbeClipRouteEnabled` |
|
||||
| `ACDREAM_PROBE_CONTACT_PLANE` | spike-only, 2026-05-20 | `=1` | gates one `[cp-write]` line per write to `CollisionInfo.ContactPlane*`/`LastKnownContactPlane*` fields (field, old→new, caller method via stack walk, source line); only logs on actual value changes | print-only, but performs a stack walk to identify the caller method when firing — real CPU cost per write while on (not just a string format); suppresses no-op writes to bound volume | `PhysicsDiagnostics.ProbeContactPlaneEnabled` |
|
||||
| `ACDREAM_PROBE_ENT` | #138 | `="1"` | Traces the persistent player entity across teleport streaming churn: presence in the render draw-set flat view vs. survival of the dynamics cull, to distinguish "missing from draw set" vs "present but culled" | print-only, "Observation-only — emits no behavior change" (doc comment). `LogPlayerDynOnChange` dedupes by transition to avoid per-frame spam. Marked STRIP-once-root-caused (like the dense-town FPS apparatus). | `EntityVanishProbe.Enabled` (`EntityVanishProbe.cs:23-24`), issue #138-B |
|
||||
| `ACDREAM_PROBE_FLAP` | "throwaway apparatus — strip once the flap mechanism is confirmed" | `=1` | EVERY FRAME (unthrottled, not change-gated) while the camera root is indoor: `[flap]` from `PortalVisibilityBuilder.Build` (portal side-test/traverse/cull/projection) + paired `[flap-cam]` from `PhysicsCameraCollisionProbe`/`[flap-sweep]` (FindCameraCell resolution, eye positions) | print-only, but unthrottled per-frame `StringBuilder` allocation + `Console.WriteLine` on multiple call sites while indoor — heavy log volume/allocation under sustained indoor play; does not alter rendered output | `RenderingDiagnostics.ProbeFlapEnabled` |
|
||||
| `ACDREAM_PROBE_GLSTATE` | "throwaway apparatus — strip once §4 ships" | `=1` | print-on-change `[gl-state]` line: depth/blend/cull/scissor/viewport/draw-FBO/color-mask/`glGetError` snapshot | print-only per its docstring; the actual state-snapshot/comparison call site lives outside `RenderingDiagnostics.cs` and was outside this pass's cited read sites | `RenderingDiagnostics.ProbeGlStateEnabled` |
|
||||
| `ACDREAM_PROBE_INDOOR_BSP` | indoor walking phase 1 / cellar-lip wedge | `=1` | gates `[indoor-bsp]` (per `BSPQuery.FindCollisions` indoor call), `[neg-poly]` (near-miss polygon detail in `BSPQuery`), and `[stepdown-decide]` (step-down accept/reject inputs in `TransitionTypes`) trace lines | print-only; also un-gates the `LastBspHitPoly` diagnostic side-channel write (same as `ACDREAM_PROBE_BUILDING`) | `PhysicsDiagnostics.ProbeIndoorBspEnabled` |
|
||||
| `ACDREAM_PROBE_INDOOR_LIGHT` | #176/#177 discriminator, a7.l1 | `=1` | rate-limited (1 Hz) `[indoor-light]` line from `LightManager.BuildPointLightSnapshot`: point-light pool set composition (pool/cellLess/registered/capped/byCell histogram) | print-only, explicitly "inert unless set" per the call-site comment (LightManager.cs:368-370); no DebugPanel mirror | `RenderingDiagnostics.ProbeIndoorLightEnabled` |
|
||||
| `ACDREAM_PROBE_JUMP` | campaign ch round 2 | `=1` | gates the `[jump]` line in `PlayerMovementController.ReportJumpRefusal`, printed UNCONDITIONALLY (even when `OnInterfaceText` is null) to distinguish "branch never fired" from "branch fired, callback dropped it" | print-only; `Headless/Policies/HeadlessBotPolicy.cs`'s `JumpProbeHeadlessBotPolicy` doc comment references this flag as a companion but does not itself read it — it is a headless bot behavior meant to be run alongside `ACDREAM_PROBE_JUMP=1`, not a second consumer | `PhysicsDiagnostics.ProbeJumpEnabled` |
|
||||
| `ACDREAM_PROBE_LOCAL_TELEPORT` | c4 route 3 d-t8 | `=1` | gates one `[local-tp]` line per local-player portal-arrival attempt (committed AND refused) from `LocalPlayerTeleportController` and `RuntimeAcceptedPositionDriveController.LogPortalArrivalAttempt` — the single Runtime chokepoint both graphical and headless hosts share | print-only; dual-host parity evidence (same line shape from both hosts) | `PhysicsDiagnostics.ProbeLocalTeleportEnabled` |
|
||||
| `ACDREAM_PROBE_PARK` | issue #309 | `=1` | gates `[park]`/`[park-restore]` lines when a `RuntimeSetPositionState` placement parks or a cancelled park's withdrawal is rolled back | print-only, low volume (parks are rare); in a MULTI-session headless host, `HeadlessStaticStateAudit.ValidateProcessIsolation` THROWS `HeadlessConfigurationException` at startup if this (or any other process-global `Probe*`/`Dump*` boolean, `CollisionShadowSampleEvery`, or `PhysicsResolveCapture`) is enabled — refusal is waived only when `sessionCount==1` (logs loudly and proceeds instead) | `PhysicsDiagnostics.ProbeParkEnabled` |
|
||||
| `ACDREAM_PROBE_PLACEMENT_FAIL` | issue #98 | `=1` | gates one `[place-fail]` line per Path-1 (Placement/Ethereal) `Collided` return in `BSPQuery.FindCollisions`, plus one per `Transition.DoStepDown` placement-insert rejection | print-only; low volume (fires only on actual rejection) | `PhysicsDiagnostics.ProbePlacementFailEnabled` |
|
||||
| `ACDREAM_PROBE_POLY_DUMP` | a6.p3 slice 4, issue #98 | `=1` | gates one `[poly-dump]` line (full polygon geometry: cell, poly index, sides, plane, all vertices) per `AdjustSphereToPlane` push-back call | print-only; HEAVY output (one full-geometry dump per push-back call) — doc explicitly says "use briefly, then turn off" | `PhysicsDiagnostics.ProbePolyDumpEnabled` |
|
||||
| `ACDREAM_PROBE_PORTAL_CHURN` | "throwaway apparatus — strip once the bound ships" | `=1` | one `[portal-churn]` summary per `PortalVisibilityBuilder.Build` call: per-cell pop/re-pop counts, re-enqueue totals, reciprocal-clip pre→post region growth | print-only | `RenderingDiagnostics.ProbePortalChurnEnabled` |
|
||||
| `ACDREAM_PROBE_PUSH_BACK` | phase a6.p1 | `=1` | gates `[push-back]` (`BSPQuery.AdjustSphereToPlane`), `[push-back-disp]` (`BSPQuery.FindCollisions` 6-path dispatcher), `[push-back-cell]` (`Transition.CheckOtherCells` multi-cell BSP) lines | print-only; the `DebugVM.cs:380` "runtime mirror" is dead code — `DebugVM`/`DebugPanel` (`AcDream.UI.Abstractions/Panels/Debug/`) are never instantiated anywhere in `src/` (the ImGui frontend they required was removed at Campaign V slice V11); only the startup env var takes effect | `PhysicsDiagnostics.ProbePushBackEnabled` |
|
||||
| `ACDREAM_PROBE_PVINPUT` | "throwaway apparatus — strip once the jitter source is pinned" | `=1` | one `[pv-input]` line/frame with 6-dp-precision `PortalVisibilityBuilder.Build` inputs (camera eye, player position, VP elements) + resulting flood-cell count; deliberately runs WITHOUT the heavier `[flap]` probe so the log stays diffable | print-only | `RenderingDiagnostics.ProbePvInputEnabled` |
|
||||
| `ACDREAM_PROBE_REMOTE_SLIDE` | bug b, temporary — strip once two-client roof capture lands | `=1` OR `=<comma-separated hex GUID list>` | gates `[remote-slide-up]`/`[remote-slide-vec]`/`[remote-slide-snap]`/`[remote-slide-enq]` lines across `LiveEntityNetworkUpdateController`, `InterpolationManager`, `RuntimeRemotePhysicsUpdater`, `RuntimeRemoteSteadyStatePosition` tracing two candidate remote-slide "blip" producers | print-only; `BeginRemoteSlideAttribution`/GUID-stamping calls are UNCONDITIONAL at several call sites (self-guard is internal), so a `[ThreadStatic]` field write happens on every remote tick regardless of the flag (cheap, non-allocating); a GUID allow-list narrows output to specific entities for a readable two-client capture | `PhysicsDiagnostics.ProbeRemoteSlideEnabled` + `ProbeRemoteSlideGuids` (raw string parsed via `ParseHexIdList` unless it's the literal `"1"`) |
|
||||
| `ACDREAM_PROBE_REMOTE_TELEPORT` | c4 route 4b-3, temporary | `=1` | gates one `[remote-teleport]` line per routed remote teleport arm in `LiveEntityNetworkUpdateController.ApplyRemoteContactRouting` | print-only; a 2026-08-04 fix moved the enabled-check to the CALL SITE because the probe's internal self-guard did not prevent `teleportStatus.ToString()` from being evaluated/allocated on every teleport regardless of flag state — now properly guarded | `PhysicsDiagnostics.ProbeRemoteTeleportEnabled` |
|
||||
| `ACDREAM_PROBE_SEAMDRAW` | #176, "throwaway apparatus" | `"1"`/`"true"`/blank → default #176 Facility Hub cell set (7 fixed hex ids); otherwise comma-separated hex cell-id list | change-deduped + 2 s-heartbeat `[seam-cell]`/`[seam-snap]`/`[seam-ent]`/`[seam-mask]` lines from `EnvCellRenderer.Render` and `WbDrawDispatcher` describing per-instance transforms and resolved light-set identities at target cells | print-only | `RenderingDiagnostics.ProbeSeamDrawEnabled` / `SeamDrawTargetCells` |
|
||||
| `ACDREAM_PROBE_STEP_WALK` | a6.p3 issue #98 | `=1` | gates `[step-walk]` lines at select points in the transition sub-step loop and step-down probe (requested vs adjusted offset, sphere positions, contact planes, walkable flags) | print-only; no DebugPanel mirror | `PhysicsDiagnostics.ProbeStepWalkEnabled` |
|
||||
| `ACDREAM_PROBE_SWEPT` | phase w stage 0 | `=1` | gates one `[cell-swept]` line per `ResolveWithTransition` call comparing the transition's swept cell vs the legacy static `ResolveCellId` path | print-only | `PhysicsDiagnostics.ProbeSweptEnabled` |
|
||||
| `ACDREAM_PROBE_TELEPORT` | 2026-06-22, "removable diagnostic" | `=1` | gates `[tp-probe]` lines (`LogTeleport`) at AIM/ENQ/BUILD/APPLY/PLACED teleport-pipeline events across `LocalPlayerTeleportController` and `RuntimeAcceptedPositionDriveController`, with cross-thread monotonic timestamps | print-only | `PhysicsDiagnostics.ProbeTeleportEnabled` |
|
||||
## Deprecated
|
||||
|
||||
| Flag | Value | What it does | Side effects | Default | Read by |
|
||||
|---|---|---|---|---|---|
|
||||
| `ACDREAM_DEVTOOLS` | `=1` | logs a one-time "ImGui dev UI removed" notice; the only remaining functional consumer is `VulkanGraphicsContext.cs:184` (`enableOptionalExtensions: _options.DevTools`, selects optional Vulkan validation/debug-utils extensions) | real effect: turns on Vulkan validation/debug-utils extensions (can change perf and can surface validation-layer errors that don't occur when off) — NOT measurement-neutral for a perf gate; `GameWindow.DevToolsEnabled` is a hardcoded `false` const (dead — no ImGui dev UI exists to gate); `DevToolsInputCaptureSource(bool enabled)` explicitly discards its `enabled` ctor arg (`_ = enabled;`) — dead parameter, always reports `WantCaptureKeyboard=false` | off | `RuntimeOptions.DevTools` (typed, `Program.cs`/`RuntimeOptions.Parse`) |
|
||||
| `ACDREAM_STREAM_RADIUS` | `=<int>` (non-negative) | Legacy override for the streaming near/far radii, applied on top of the quality-preset's radii at session-start composition. | **CLAUDE.md explicitly documents this as "legacy" and warns against using it for measurement.** Confirmed in code (`SessionPlayerComposition.cs:256-259`): `nearRadius = legacyRadius; farRadius = Math.Max(legacyRadius, farRadius)` — it FORCES `NearRadius` and only ever RAISES (never lowers) `FarRadius`. It is set once at session-start composition and is **silently discarded** by any later Settings quality change: `RuntimeSettingsController.ApplyQuality` → `RuntimeSettingsTargets.ApplyQuality` → `StreamingController.ReconfigureRadii` recomputes radii straight from the quality preset with no knowledge of this override. A measurement/gate run taken with this set is measuring a different streaming window than production and than any run that later touches Settings. | `null` → quality-preset radii unmodified (production default: High preset, Near 4 / Far 12) | `RuntimeOptions.LegacyStreamRadius` → `SessionPlayerComposition.cs:254-268` |
|
||||
|
||||
---
|
||||
|
||||
<!-- retired -->
|
||||
|
||||
## Retired
|
||||
|
||||
Flags that no longer exist, kept only so a stale script or an old research
|
||||
document does not send someone hunting. Rows below this marker are exempt
|
||||
from the "must still exist" check.
|
||||
|
||||
| Flag | Retired | Replacement |
|
||||
|---|---|---|
|
||||
| `ACDREAM_RUN_SKILL` | Client-side run-skill override for local motion prediction. Skills now arrive from the server (`LiveMovementStatsApplier`); the hardcoded fallback is 200. | none — server-authoritative |
|
||||
| `ACDREAM_JUMP_SKILL` | As above. The fallback is 300, not the 200 that CLAUDE.md advertised. | none — server-authoritative |
|
||||
| `ACDREAM_RENDER_BACKEND` | Selected the GL-vs-Vulkan backend. Campaign V deleted the OpenGL backend; Vulkan is the only one. Two comments still named it as a live co-requisite until 2026-08-24. | none |
|
||||
| `ACDREAM_ANIM_SPEED_SCALE` | Animation-speed multiplier from the pre-retail-sequencer era; died with the 1.248x factor. | none |
|
||||
| `ACDREAM_A8_AUDIT` | Phase A8 EnvCell batch/cull audit dump. Its only caller never existed; `EnvCellRenderer.CollectCellAuditLines` was unreachable and was deleted 2026-08-24. | `ACDREAM_PROBE_ENVCELL` |
|
||||
| `ACDREAM_AIRBORNE_DIAG` | #42 airborne-sweep `[SWEEP]`/`[SWEEP-OBJ]` XY-drift trace. Investigation closed; stripped 2026-08-24 (#435) along with its 16 siblings below. | none |
|
||||
| `ACDREAM_DUMP_ENTITY` | #119 tower-staircase HYDRATE/DRAW/WALK-REJECT entity watchlist. Investigation closed; stripped 2026-08-24 (#435). | none |
|
||||
| `ACDREAM_PROBE_AUTOWALK` | Issue #63 server-initiated auto-walk trace. Investigation closed; stripped 2026-08-24 (#435). | none |
|
||||
| `ACDREAM_PROBE_LIGHT` | #133 A7 dungeon-lighting `[light]`/`[light-detail]` trace. Investigation closed; stripped 2026-08-24 (#435). | none |
|
||||
| `ACDREAM_PROBE_OUTSTAGE` | #131 outside-stage dynamics routing trace (also the `ACDREAM_DUMP_ENTITY` `[outstage-own]` watchlist consumer). Investigation closed; stripped 2026-08-24 (#435). | none |
|
||||
| `ACDREAM_PROBE_PHANTOM` | #113 phantom-shell/phantom-objs draw-mechanism trace. Investigation closed; stripped 2026-08-24 (#435). | none |
|
||||
| `ACDREAM_PROBE_REACH` | #334 broadphase candidate-disposition trace. Investigation closed; stripped 2026-08-24 (#435). | none |
|
||||
| `ACDREAM_PROBE_REMOTE_LANDING` | Bug A / issue #32 remote ground-contact landing trace. Investigation closed; stripped 2026-08-24 (#435). | none |
|
||||
| `ACDREAM_PROBE_SHELL` | #78 cell-shell opaque-pass render trace. Investigation closed; stripped 2026-08-24 (#435). | none |
|
||||
| `ACDREAM_PROBE_STEP_HEIGHTS` | Issue #338 step-up/step-down height provenance trace (including its unconditional once-per-process `AnnounceStepHeightProbeOnce` self-report). Investigation closed; stripped 2026-08-24 (#435). | none |
|
||||
| `ACDREAM_PROBE_STICKY` | R5-V3 issue #171 sticky-melee lifecycle/steer trace. Investigation closed; stripped 2026-08-24 (#435). | none |
|
||||
| `ACDREAM_PROBE_SUPPORT` | Issue #337 `[support]`/`[geom]` collision-vs-visual classifier trace. Investigation closed; stripped 2026-08-24 (#435). | none |
|
||||
| `ACDREAM_PROBE_TEXFLUSH` | #105 white-indoor-textures staged-upload trace. Investigation closed; stripped 2026-08-24 (#435). | none |
|
||||
| `ACDREAM_PROBE_VIEWER` | #119-residual viewer/flood capture (tower-ascent replay). Investigation closed; stripped 2026-08-24 (#435). | none |
|
||||
| `ACDREAM_PROBE_WALK_MISS` | Issue #83 indoor walkable-plane miss trace. Investigation closed; stripped 2026-08-24 (#435). | none |
|
||||
| `ACDREAM_WIRE_MESH` | Issue #337 F2 overlay upgrade to real physics-BSP polygon edges. Investigation closed; stripped 2026-08-24 (#435) — F2 reverted to its proxy-cylinder overlay. | none |
|
||||
| `ACDREAM_WIRE_RADIUS` | Companion radius knob for `ACDREAM_WIRE_MESH`. Stripped alongside it 2026-08-24 (#435). | none |
|
||||
| `ACDREAM_A8_DUMP_PV` | Phase A8.F portal-frame visual-gate triage dump (camera-cell portal census + EXIT-PROJ/EXIT-CLIP/EXIT trace in `PortalVisibilityBuilder.Build`). Phase A8 closed; stripped 2026-08-24 (#435 part 2). | none |
|
||||
| `ACDREAM_DUMP_CLOTHING` | Issue #37 humanoid-coat clothing/part-swap trace. #37 closed 2026-05-11; stripped 2026-08-24 (#435 part 2). | none |
|
||||
| `ACDREAM_DUMP_EDGE_SLIDE` | Issue #32 L.2c edge-slide/cliff-slide branch trace (five `edge-slide:` lines). #32 closed 2026-08-07; stripped 2026-08-24 (#435 part 2). | none |
|
||||
| `ACDREAM_DUMP_LIVE_SPAWNS` | Phase A8 indoor-visibility batch live-spawn/DROP trace. Phase A8 closed; stripped 2026-08-24 (#435 part 2). | none |
|
||||
| `ACDREAM_DUMP_STEPUP` | L.2.3d/e/f step-up `stepup: enter/SUCCESS/FAILED` trace. Investigation closed; stripped 2026-08-24 (#435 part 2) — its content is still covered by the separate `[transit-fail-stepup]` line under `ACDREAM_DUMP_TRANSIT_FAIL`. | `ACDREAM_DUMP_TRANSIT_FAIL` |
|
||||
| `ACDREAM_DUMP_VENDOR` | `[vendor-diag]` trace (~25 call sites) for two vendor-approach/split-stack regressions. Vendor campaign closed 2026-08-08; stripped 2026-08-24 (#435 part 2) along with its owner class `VendorDiagnostics.cs`. | none |
|
||||
| `ACDREAM_DUMP_VITALS` | Issue #5 `PrivateUpdateVital`/`PlayerDescription`/parse-failure trace across 4 sites. #5 closed 2026-04-25; stripped 2026-08-24 (#435 part 2). | none |
|
||||
|
|
@ -184,17 +184,28 @@ panel through `IPanelRenderer`.
|
|||
|
||||
## Plugin UI API
|
||||
|
||||
The shipped plugin-facing gameplay UI contract is
|
||||
`AcDream.Plugin.Abstractions.IUiRegistry.AddMarkupPanel`: a plugin provides
|
||||
KSML-style markup and a binding object; the host builds it into the retained
|
||||
`UiRoot` tree. `IPanel`/`IPanelRenderer` remains a first-party developer-panel
|
||||
contract and is intentionally not referenced by `Plugin.Abstractions`.
|
||||
The shipped plugin-facing gameplay UI contract is the additive BCL-only
|
||||
`AcDream.Plugin.Abstractions.IUiRegistry.AddPanel`: a plugin provides a stable
|
||||
window id/title/icon descriptor, KSML-style markup, and a binding object; the
|
||||
host builds it into the retained `UiRoot` tree. The API-v1
|
||||
`AddMarkupPanel` member remains source/binary compatible and is enriched into
|
||||
the same first-class window route by the scoped host. `IPanel`/
|
||||
`IPanelRenderer` remains a historical first-party developer-panel contract and
|
||||
is intentionally not referenced by `Plugin.Abstractions`.
|
||||
|
||||
This makes plugin gameplay panels independent of ImGui while allowing them to
|
||||
share the retained input, window, and DAT-sprite runtime. Registrations made
|
||||
before the GL host exists are buffered. In builds where retail UI is disabled,
|
||||
they remain registered but have no gameplay surface; the long-term release
|
||||
configuration enables retained gameplay UI.
|
||||
This makes plugin gameplay panels presentation-assembly independent while
|
||||
allowing them to share the retained input, window, and DAT-sprite runtime.
|
||||
Registrations made before the graphical host exists are buffered. The host
|
||||
assigns `plugin:{pluginId}:{windowId}`, registers every panel with the common
|
||||
window manager, persists its geometry/visibility, and exposes it through the
|
||||
shared plugin sidepanel. Hiding/minimizing a panel does not dispose or pause the
|
||||
plugin. No-window hosts retain the plugin session but expose the no-op UI
|
||||
capability.
|
||||
|
||||
The retained markup vocabulary includes panels, nested groups, labels,
|
||||
buttons, meters, tabs, lamp-style toggles, and scalar sliders. Controls bind to
|
||||
BCL-visible properties/actions on the plugin binding object; visible controls
|
||||
must correspond to real behavior, never placeholders that report success.
|
||||
|
||||
The following was the original pre-D.2b proposal and remains historical
|
||||
context, not the shipped plugin contract:
|
||||
|
|
@ -255,7 +266,8 @@ walk around / take damage / regen.
|
|||
### Sprint 3 — Plugin API hardening (superseded shape)
|
||||
|
||||
- Document the `IPanel` contract.
|
||||
- The shipped route is `IUiRegistry.AddMarkupPanel`, not plugin-owned
|
||||
- The shipped route is `IUiRegistry.AddPanel` (with `AddMarkupPanel` as the
|
||||
compatible legacy entry), not plugin-owned
|
||||
`IPanel` implementations.
|
||||
- Confirm plugins can subscribe to game events and expose retained markup
|
||||
bindings without referencing App or ImGui assemblies.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -94,8 +94,9 @@ DAT-authored values.
|
|||
register row for the ACE-sourced 2013-unverifiable mapping.
|
||||
- **D4 — Configure Keyboard is the campaign's rebind screen** (it is the
|
||||
ONLY rebind screen — D1). Port `gmKeyboardUI`'s shape and DAT ActionMap
|
||||
data (lane D Option C) but persist to `keybinds.json`; retail `.keymap`
|
||||
file interchange is a register-row deferral.
|
||||
data (lane D Option C). **Superseded 2026-08-26 by #446:** named retail
|
||||
`.keymap` Load File / Save As/startup/shutdown persistence now ships;
|
||||
`keybinds.json` remains only the host-command compatibility mirror.
|
||||
- **D5 — dead-endpoint buttons short-circuit to their own retail failure
|
||||
strings.** Urgent Assistance / Report Abuse open a defunct
|
||||
`support.turbine.com` URL in retail; acdream skips the browser launch and
|
||||
|
|
@ -360,8 +361,8 @@ modal capture; right-click erases; N-way cross-map conflicts + the
|
|||
non-user-bindable refusal per lane D §5; Save/Cancel; Reset-to-defaults
|
||||
reloads the DAT maps. Persistence: `keybinds.json` (D4).
|
||||
|
||||
**Register rows:** `.keymap` file interchange not implemented (D4); any
|
||||
retail column/behaviour consciously narrowed.
|
||||
**Register rows:** any retail column/behaviour consciously narrowed. The
|
||||
former D4 `.keymap` deferral was retired by #446 on 2026-08-26.
|
||||
|
||||
**Gate:** connected — rebind a movement key, conflict prompt on a taken
|
||||
chord, persistence across relaunch, reset restores retail defaults.
|
||||
|
|
@ -394,7 +395,8 @@ chord, persistence across relaunch, reset restores retail defaults.
|
|||
`2026-08-09-chat-retail-window-shell.md` §6.3's register row.
|
||||
- A pre-world character-select flow (D6 adapts; its register row carries
|
||||
the future work).
|
||||
- Retail `.keymap` file read/write (D4 register row).
|
||||
- None for retail `.keymap` file read/write; #446 implemented it on
|
||||
2026-08-26 and retired AP-202.
|
||||
- The `0x21000017` docked `gmPanelUI` host variant — acdream ships the
|
||||
floating host only (register row in OP3 if the review deems it a
|
||||
divergence; retail exposes both).
|
||||
|
|
|
|||
126
docs/plans/2026-08-24-character-advancement-campaign.md
Normal file
126
docs/plans/2026-08-24-character-advancement-campaign.md
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
# Campaign CA — character advancement retail parity (#431 + the raise/train/specialize family)
|
||||
|
||||
**Status:** ACTIVE 2026-08-24. Owner-directed scope; #431 promoted here.
|
||||
**Milestone:** M4 — Live in the world.
|
||||
**Issue anchors:** #431 (promoted), #430 tooltips (SEQUENCED AFTER, not in
|
||||
this campaign — needs the #409 client-wide tooltip system).
|
||||
|
||||
## Owner's report (2026-08-24, verbatim scope)
|
||||
|
||||
The raise flow "works from the GUI and everything" — the guess is the
|
||||
refresh side "is not wired correctly." Required behavior:
|
||||
|
||||
1. Raising an attribute must update everything derived from it, in real
|
||||
time: raising Endurance/Self/etc. must move the **vitals bar** maxima
|
||||
(health/stamina/mana); raising a **vital directly** (secondary
|
||||
attributes are XP-raisable too) must update the bar the same way.
|
||||
2. **Skills must update in real time** both when their underlying
|
||||
attributes raise and when the skill itself is raised with XP.
|
||||
3. **Run speed must increase when Quickness is raised** — Run is
|
||||
attribute-fed.
|
||||
4. **Skills with no underlying attribute (e.g. Salvaging) need their own
|
||||
handling** — no formula contribution, raise-only progression.
|
||||
5. **Unknown/untested territory:** raising skills with XP, and
|
||||
**specializing** skills; also the retail **respec flow** (quests /
|
||||
item turn-ins that drop learned skills so a character can re-spec /
|
||||
re-specialize). None of this has been exercised against ACE.
|
||||
|
||||
## Current-state survey (2026-08-24, verified in source)
|
||||
|
||||
- **Outbound: complete.** `CharacterActions` builds all four actions —
|
||||
`RaiseVital 0x0044`, `RaiseAttribute 0x0045`, `RaiseSkill 0x0046`,
|
||||
`TrainSkill 0x0047` — and `CharacterSheetProvider` wires the panel's
|
||||
buttons to them through the Runtime command seam. This is why the GUI
|
||||
"works": ACE accepts and applies the raises.
|
||||
- **Inbound: the hole.** The ONLY private stat-update messages parsed
|
||||
anywhere are the vitals pair `PrivateUpdateVital (0x02E7)` /
|
||||
`PrivateUpdateVitalCurrent (0x02E9)`. The attribute and skill update
|
||||
family ACE sends back after a raise is UNHANDLED — no parser, no
|
||||
routing, nothing reaches `LocalPlayerState` (whose own doc says
|
||||
attributes refresh "only at PlayerDescription / future
|
||||
`PrivateUpdateAttribute`"). Post-raise, the client's attribute/skill
|
||||
model is stale until the next full PlayerDescription (i.e. next login).
|
||||
- **Consequences observed by the owner (#431):** derived skills don't
|
||||
move when an attribute raises; run speed doesn't change with
|
||||
Quickness. Both follow directly from the missing inbound family — the
|
||||
recompute never triggers because the trigger never arrives.
|
||||
- **Run-rate seam already exists:**
|
||||
`PlayerMovementController.ApplyServerRunRate` (the #431 filing's own
|
||||
pointer) — the wire echo path updates live run rate; what's missing is
|
||||
driving it (and the formula-side skill totals) from stat updates.
|
||||
- **Character state owner:** `RuntimeCharacterState` (J4.3) owns the
|
||||
spellbook/local-player graph; new stat state routes through it, not
|
||||
through a parallel store.
|
||||
|
||||
## Oracle targets (CA1 — DO FIRST, no guessing)
|
||||
|
||||
Per the mandatory workflow (grep named-retail → cross-reference ≥2 refs →
|
||||
pseudocode → port → conformance):
|
||||
|
||||
1. **The inbound message family.** Pin exact opcodes + layouts from ACE
|
||||
(`GameMessagePrivateUpdateAttribute`, `...Attribute2ndLevel` (vitals),
|
||||
`...Skill`, `...SkillLevel`, `...SkillAC` as ACE names them; their
|
||||
sequence-number semantics) cross-checked against Chorizite.ACProtocol
|
||||
and holtburger. Our `PrivateUpdateVital.cs` already cites ACE's
|
||||
`GameMessagePrivateUpdateAttribute2ndLevel` naming — extend the same
|
||||
treatment to the whole family. Also pin what ACE sends for
|
||||
TrainSkill/specialize responses and for skill-credit changes.
|
||||
2. **Retail's recompute chain.** In named-retail: how the client applies
|
||||
an attribute update — which cached values recompute
|
||||
(`CACQualities::InqSkill` / skill formula with attribute divisors from
|
||||
SkillTable, max-vital formulas, run-rate refresh via the movement
|
||||
system). The SkillTable formula fields we already load for chargen
|
||||
(`ChargenSkillAdvancement`) are the same divisor data — verify the
|
||||
in-world recompute uses identical math.
|
||||
3. **Attribute-less skills.** SkillTable rows with no formula
|
||||
(Salvaging & friends): confirm retail's display/derivation for them
|
||||
(base = trained ranks + augmentation only).
|
||||
4. **Training / specialization semantics.** Credits accounting, what the
|
||||
0x0047 response looks like, how specialization changes the formula
|
||||
multiplier (specialized = ranks count differently), and what messages
|
||||
carry it.
|
||||
5. **Respec / untrain.** Identify the retail mechanism (quest/item-driven
|
||||
skill refund) and what the CLIENT sees — expectation: server-driven
|
||||
property/skill updates using the SAME inbound family, so no bespoke
|
||||
client flow; verify rather than assume. Confirm ACE's implementation
|
||||
surface for a test path.
|
||||
|
||||
## Slices
|
||||
|
||||
- **CA1 — oracle + research doc** (`docs/research/2026-08-24-advancement-wire-and-recompute.md`):
|
||||
everything above, with decomp addresses and ACE file citations. Output:
|
||||
the pinned message table + retail recompute pseudocode.
|
||||
- **CA2 — inbound stat-update family.** Parsers for the
|
||||
attribute/skill/(vital-level) private updates; routed as ordered deltas
|
||||
into the J4 owners (`RuntimeCharacterState` / `LocalPlayerState`)
|
||||
through the existing generation-gated seam. Conformance tests from ACE
|
||||
byte layouts.
|
||||
- **CA3 — derived recompute + real-time presentation.** One recompute
|
||||
path (retail's formula) fed by CA2's deltas driving: character panel
|
||||
skill rows, vitals bar maxima (attribute- and vital-raise both),
|
||||
run-rate into `ApplyServerRunRate`'s seam, attribute-less skills
|
||||
handled per oracle. Binding-seam tests (the #436 lesson: assert the
|
||||
REAL composition binds the refresh, not just that VMs recompute).
|
||||
- **CA4 — train/specialize/respec verification.** Live-vs-ACE for
|
||||
TrainSkill + specialization (panel flow + credits), and the respec
|
||||
path exercised as far as ACE supports; fixes as the oracle demands.
|
||||
- **CA5 — connected gate script** (`docs/research/2026-08-24-campaign-ca-test-script.md`),
|
||||
user-driven: raise Quickness → run speed visibly increases immediately;
|
||||
raise Endurance/Self → vitals maxima move; direct vital raise; skill
|
||||
raise; train; specialize; respec if ACE path exists. PASS = every
|
||||
change visible without relog.
|
||||
|
||||
**Sequenced after this campaign:** #430 skill/attribute tooltips (needs
|
||||
the #409 client-wide tooltip surface; the CA1 oracle should still note
|
||||
where retail sources its tooltip strings while it is in the
|
||||
neighborhood).
|
||||
|
||||
## Ledger
|
||||
|
||||
| Slice | Status | Evidence |
|
||||
|---|---|---|
|
||||
| CA1 | COMPLETE 2026-08-24 | docs/research/2026-08-24-advancement-wire-and-recompute.md — six inbound messages pinned byte-for-byte with 3-source agreement; live-at-inquiry recompute verdict verified by hand in Ghidra; RetailSkillFormula already ports 0x00591960 exactly |
|
||||
| CA2 | COMPLETE 2026-08-24 (`65430d4c`) | 0x02E3/0x02DD parsers + WorldSession events + router routing into LocalPlayerState; conformance tests incl. holtburger golden fixture; 0x02DF deliberately unparsed (no ACE producer) |
|
||||
| CA3 | COMPLETE 2026-08-24 | Live formula recompute (SkillFormulaBonusResolver over RetailSkillFormula) on attribute writes + fresh-train derivation; movement re-applied down the PD seam (PushMovementSkillTotals — Quickness raise → run speed, no relog); vitals bar pull-model verified; router behavior + fresh-train tests |
|
||||
| CA4 | COMPLETE 2026-08-24 | Optimistic ApplyLocalRaise layer DELETED; retail one-in-flight + ghost + server-authoritative flow ported per the pinned §5 pseudocode (AP-73 NARROWED — rejection-release semantics owed to CA5 live); train cost verified DAT-exact; specialize correctly has no panel send (gem + confirmation route, seams already present); provider contract tests rewritten |
|
||||
| CA5 | FIRST DRIVE 2026-08-24 (partial) + fix round `bce17b3c` | §1 PASSED from the log (run total 40→433 live, each raise re-applied; zero exceptions). Drive harvested #440 (trained row stuck until next click — row refresh now driven by the authoritative record) and closed #430 (tooltips could never mount — rows lacked the popup locator). OWED at the next drive: §2 Endurance→stamina fan-out, §3.2 AP-73 ghost probe, §5/§6 re-check (train row moves immediately now), tooltip hover-dwell visual, and the three feel answers (run speed, advancement chat lines, ghost cycle). |
|
||||
602
docs/plans/2026-08-24-character-panel-parity-campaign.md
Normal file
602
docs/plans/2026-08-24-character-panel-parity-campaign.md
Normal file
|
|
@ -0,0 +1,602 @@
|
|||
# Campaign CT — Character-panel retail parity (header identity, Titles page, resize/scrollbar, row alignment)
|
||||
|
||||
**Status:** IMPLEMENTATION COMPLETE 2026-08-25 (+ gate-fix CT-GF1 review-closed: retail ancestor+self clip `989f6652`/`025108a8`/`90a0da68`) — CT1-CT6 all review-closed (per-slice Opus dual-lens review + fix round). CT-GF1 (the CT7 gate's own first finding — the client-wide retained-UI ancestor clip) landed `989f6652` and its fix round is CODE-COMPLETE (see the CT-GF1 subsection below); CT7 connected gate script ready at `docs/research/2026-08-25-campaign-ct-test-script.md`, still awaiting the owner's drive. **CT7 GATE PASSED 2026-08-25** — two rounds: full pass, then one regression (plugin markup text under the CT-GF1 self-clip, fixed `752782d0`) re-gated PASS. Owner retail observation recorded: the Titles divider IS visible inside retail's window while scrolling — a retail quirk the clipped rendering reproduces. NOT pushed to gitea (owner directive).
|
||||
**Execution model:** Fable plans and coordinates; Sonnet implements each
|
||||
slice; Opus runs the dual-lens review (retail-faithful + architectural)
|
||||
per slice, then a fix round. No pushes to gitea until the owner says so.
|
||||
**Register:** [STALE-INTENT CORRECTION 2026-08-25, at Campaign AS's AS5
|
||||
re-review: this retirement never happened — CT3/CT4 NARROWED AP-109 and
|
||||
Campaign AS AS5 later closed its rank-prefix residual; the row remains
|
||||
ACTIVE-narrowed to CT4's FormatXp sliver.] Original intent: retire AP-109
|
||||
(inert Titles page) when CT3+CT4 land; every deviation a slice introduces
|
||||
adds its row in the same commit.
|
||||
|
||||
## Owner report (2026-08-24, screenshots on file)
|
||||
|
||||
1. Attribute/skill row **icons misaligned** vs retail.
|
||||
2. Retail keeps a **margin between the value column and the border** —
|
||||
the gutter reserved for the list scrollbar that appears when the
|
||||
window is resized shorter. We author no such margin and never show
|
||||
the scrollbar on Attributes.
|
||||
3. The character window is **resizable in Y down to an authored
|
||||
minimum** in retail; ours is not.
|
||||
4. Header identity block: retail shows the name, then
|
||||
**"<Gender> <Heritage> <DisplayTitle>"** (e.g. "Female Aluvian War
|
||||
Mage"), then **"Non-Player Killer" / "Player Killer" /
|
||||
"Player Killer Lite"** in **pure white** — on Attributes AND Skills.
|
||||
We show gender+heritage only, no PK line contract, color off.
|
||||
5. **Level number color** slightly off vs retail.
|
||||
6. **Titles tab is inert** (AP-109): retail lists all earned titles
|
||||
(sorted), shows the current display title, and lets the player set
|
||||
one ("Set as Display Title"); scrollbar with many titles; the
|
||||
header identity line updates live when the display title changes.
|
||||
7. **All windows share retail's authored minimum-size behavior** —
|
||||
resize clamps to the authored constraints everywhere.
|
||||
|
||||
## Retail recon (verified 2026-08-24, this session)
|
||||
|
||||
### Titles page — `gmCharacterTitleUI`
|
||||
- `PostInit @0x0049A610` binds: display-title text `0x1000052F`,
|
||||
"Set as Display Title" button `0x10000535`, title ListBox
|
||||
`0x10000532`. Registers notice handlers for the title-table /
|
||||
add-title / set-display-title notices.
|
||||
- Rows carry the title id in attribute `0x1000008E`;
|
||||
`AddTitleToList @0x0049A840` resolves the display string via
|
||||
`CharacterTitleTable::GetCharacterTitleFromID` (DAT title-string
|
||||
table — CT2 locates the DID) and inserts SORTED
|
||||
(`FindSortedInsertPosition @0x0049A760`). It writes the resolved title
|
||||
text into row child `0x10000537` and stamps the row's id via
|
||||
`SetAttribute_Enum(row, 0x1000008E, titleId)`, inserting the row via
|
||||
`AddItemFromTemplateList(listBox, 0, insertPos)` — CT3 mirrors this
|
||||
exact write shape when populating `0x10000532`.
|
||||
- **CORRECTED (CT1 fix round 2026-08-24):** `UpdateButtons @0x0049A500`
|
||||
— the display button is **GHOSTED (state 0xd) UNLESS a row is
|
||||
SELECTED whose title id differs from the current display title; no
|
||||
selection → Ghosted.** (Not "ghosts when selected == current" — that
|
||||
phrasing had the no-selection case backwards.) Verbatim mechanism:
|
||||
a no-match selection falls through to index `0xFFFFFFFF` →
|
||||
`GetItem` returns null → `SetState(0xd)`. Selection change
|
||||
(msg 4/0x43) re-runs it.
|
||||
- Clicking `0x10000535` sends
|
||||
`CM_Social::Event_SetDisplayCharacterTitle(titleId)`
|
||||
(`ListenToElementMessage @0x0049A6D0`).
|
||||
- `gmStatManagementUI::RecvNotice_SetDisplayCharacterTitle @0x004EFD50`
|
||||
→ the stat panel refreshes its header when the display title changes.
|
||||
|
||||
### Wire (ACE cross-checked)
|
||||
- Inbound `CharacterTitle` event `0x0029` (already in our
|
||||
`GameEventType`): `u32 =1, u32 displayTitleId, u32 count,
|
||||
count × u32 titleId` (`GameEventCharacterTitle.cs`).
|
||||
- Inbound `UpdateTitle` event `0x002B`: `u32 titleId,
|
||||
u32 setAsDisplay` (`GameEventUpdateTitle.cs`).
|
||||
- Outbound `TitleSet` GameAction (`GameActionSetTitle.cs`):
|
||||
`u32 titleId`. Retail sender: `CM_Social::Event_SetDisplayCharacterTitle`.
|
||||
|
||||
### Header identity — `gmStatManagementUI::PostInit @0x004EFD90`
|
||||
Binds name `0x10000231`, heritage line `0x10000232`, PK line
|
||||
`0x10000233`, level `0x1000023B`, total XP `0x10000235`, XP-to-level
|
||||
`0x10000238` + meter `0x10000236`, luminance pair `0x100005C5/C6`, list
|
||||
box `0x1000023D`. The refresh (vtable slot, near
|
||||
`UpdateExperience @0x004F0A70`) composes the heritage line WITH the
|
||||
display title; the PK strings are exactly "Player Killer" /
|
||||
"Player Killer Lite" / "Non-Player Killer" (IsPK / IsPKLite —
|
||||
cross-anchor `CharExamineUI::SetAppraiseInfo @0x004B45F0`). CT5 reads
|
||||
the composing function verbatim before writing a line of C#.
|
||||
|
||||
### CT4 contract (read verbatim 2026-08-24, Fable)
|
||||
`gmStatManagementUI::UpdateCharacterInfo @0x004F0770`:
|
||||
- **Name line** (`0x10000231`): `ACCWeenieObject::GetObjectName(player,
|
||||
NAME_SINGULAR)` through `AllegianceData::GetFullName @0x005B6950`
|
||||
(read verbatim): when `AllegianceSystem::GetTitle(rank, heritage,
|
||||
gender)` yields an allegiance rank title, the line is
|
||||
"<RankTitle><sep @data_794098><Name>"; otherwise the plain name.
|
||||
The owner's retail screenshot (plain "Dww") is the rankless case.
|
||||
- **Heritage line** (`0x10000232`):
|
||||
`AppraisalSystem::InqGenderHeritageDisplay(gender=Int 0x71,
|
||||
heritage=Int 0xBC, 0)` ("Female Aluvian"); then, when
|
||||
`CharacterTitleTable::GetCharacterTitleFromID(m_titleID)` resolves,
|
||||
`AppendText(separator @data_794358)` + `AppendText(titleString)` →
|
||||
"Female Aluvian War Mage". PE-read RECOVERED (2026-08-24):
|
||||
the separator `@data_794358` is a single space `" "`; the
|
||||
allegiance-rank separator `@data_794098` is likewise `" "`.
|
||||
- **Level** (`0x1000023B`): `InqInt(0x19)` present →
|
||||
`Formatted(@data_7a0184)` = `"%d"`; absent → literal
|
||||
`@data_7b0f34` = `"???"` (both PE-read recovered 2026-08-24).
|
||||
- **PK line** (`0x10000233`): `UpdatePKStatus @0x004F00A0` — three-way
|
||||
`IsPK` / `IsPKLite` / neither → StringInfo from table enum
|
||||
`0x10000001` (= StringTable `0x23000001`, same compute_str_hash
|
||||
mechanism the chat labels use) with keys
|
||||
`ID_StatManagement_Header_PKStatus_PK` / `_PKL` / `_NPK`.
|
||||
- Related key family for the footer/meter (already-shipped surfaces —
|
||||
audit only if CT1 finds drift): `ID_StatManagement_Footer_*`,
|
||||
`ID_StatManagement_Header_XPToLevelMeterInfinity`.
|
||||
|
||||
### Already in-tree
|
||||
- Tab/page ids wired (`TabTitlesId 0x10000538`, `TitlesPageId
|
||||
0x10000539`); pages currently show retail-authored closed visuals.
|
||||
- Header labels partially bound (`StatHeaderLine` + `PkStatus` seams
|
||||
exist in `CharacterStatController.Bind` — content contract wrong).
|
||||
- `GameEventType.CharacterTitle/UpdateTitle` enum entries exist and now
|
||||
(CT2, landed) have a parser, a `RuntimeCharacterTitleState` owner, and
|
||||
an outbound `TitleSet` builder — see CT2's paragraph below.
|
||||
- The character window registers with `DatConstraintSource` — authored
|
||||
min/max plumbing exists in `RetailWindowFrame`; Y-resize for this
|
||||
window and the list-scrollbar contract do not.
|
||||
|
||||
## Slices
|
||||
|
||||
**CT1 — DAT ground truth + pins. REVIEW-CLOSED (2026-08-24): landed `ca4100e7`, Opus dual-lens review (1 doc-level blocker + 5 should-fix, all applied), fix round `e264d839`.**
|
||||
Research: `docs/research/2026-08-24-campaign-ct-dat-ground-truth.md`;
|
||||
9 InstalledDat pins in `CharacterPanelLiveDatTests`. Three corrections
|
||||
now BINDING on later slices:
|
||||
(a) the character root (`0x10000227`, Type-8 TabControl) authors NO
|
||||
min/max constraints and `MountCharacter` wires no `DatConstraintSource`
|
||||
— CT6 must first find retail's actual minimum mechanism in the decomp
|
||||
(likely class behavior, not a DAT property);
|
||||
(b) row templates are reachable ONLY via the targeted
|
||||
`ImportInfos(dats, layoutId, elementId)` overload (`0x10000248` stat
|
||||
row in `0x21000045`; `0x10000536` title row in `0x2100005E`) — the
|
||||
plain import's #375 prototype-skip hides them;
|
||||
(c) authored row geometry: icon 20x20 at X=0 (code: 16x16 at X=4),
|
||||
name X=25 W=150, value X=175 W=100 right-justified, 7px gutter to the
|
||||
282px row edge; row Highlight media is `0x06000F93` (code uses
|
||||
`0x06001397` — flagged, CT5 verifies).
|
||||
Title chain verified end-to-end: titleId → EnumMapper `0x22000041`
|
||||
(canonical key, e.g. `ID_CharacterTitle_War_Mage`) → compute_str_hash →
|
||||
StringTable `0x2300000E` → text (id 13 = "War Mage").
|
||||
Original scope: Live-DAT probe of layout
|
||||
`0x2100002E`: attribute/skill row templates (icon x/y vs our hand-built
|
||||
rows), the value-column right margin, header element fonts/colors
|
||||
(level `0x1000023B` color — item 5), Titles-page elements
|
||||
(`0x1000052F/32/35` geometry, row template, scrollbar), window
|
||||
min/max constraints. Output: research doc + InstalledDat pins (the
|
||||
tooltip/scrollbar-pin pattern). No production changes.
|
||||
|
||||
**CT2 — Runtime title ownership + wire. REVIEW-CLOSED 2026-08-24: landed `bcfddc97`, Opus review (0 blockers, 4 should-fix), fix round `544f8cb2`.** Parsed
|
||||
`0x0029 CharacterTitle` (retail's `CharacterTitleTable::UnPack
|
||||
@0x005c6e90` — the leading ACE `1u`/retail-Pack-constant field is
|
||||
discarded, matching retail's own read) and `0x002B UpdateTitle`
|
||||
(`CM_Social::DispatchUI_AddOrSetCharacterTitle @0x006a54c0`: title id +
|
||||
setAsDisplay). New sibling owner `RuntimeCharacterTitleState`
|
||||
(`RuntimeCharacterState.Titles`) holds the earned-title set + display
|
||||
title id, clears at generation reset (`CaptureOwnership`/`IsConverged`
|
||||
extended with `TitleCount`/`DisplayTitleIsDefault`), and fires
|
||||
`TableReplaced`/`TitleAdded`/`DisplayTitleChanged`. Outbound
|
||||
`TitleSet (0x002C)` ships behind `IRuntimeCharacterCommands.SetTitle`
|
||||
on both hosts (`DirectGameRuntimeCommandAdapter` direct-send,
|
||||
`CurrentGameRuntimeCommandAdapter` via the `LiveCommandBus`/
|
||||
`LiveSessionCommandRouter` queue) — verified against retail's own
|
||||
`CM_Social::Event_SetDisplayCharacterTitle @0x006a5720`, which sends
|
||||
the wire message and touches no local field; the display title updates
|
||||
only from the server's own echo. No register row: this slice
|
||||
introduces no retail deviation. App-layer `CharacterTitleResolver`
|
||||
(`src/AcDream.App/UI/Layout/CharacterTitleResolver.cs`) ports
|
||||
`GetCharacterTitleFromID`'s EnumMapper(`0x22000041`) → hash →
|
||||
StringTable(`0x2300000E`) chain for CT3/CT4 to consume; Runtime stays
|
||||
id-only. Conformance tests against ACE's writer shapes
|
||||
(`tests/AcDream.Core.Net.Tests/Messages/CharacterTitleEventsTests.cs`),
|
||||
Runtime owner tests (`RuntimeCharacterTitleStateTests.cs` +
|
||||
`RuntimeCharacterStateTests.cs` integration), a wire-send command test
|
||||
(`DirectGameRuntimeCommandAdapterTests.cs`), and an InstalledDat pin
|
||||
(`CharacterTitleResolverLiveDatTests.cs`, ids 0/1/2/3/5/13/14) all pass.
|
||||
|
||||
**CT2 fix round (Opus dual-lens review, 2026-08-24).** Four SHOULD-FIX
|
||||
corrections landed. **F1 (the important one):** the NOTICE broadcast is
|
||||
unconditional (retail's server-side `SendNotice_AddCharacterTitle` fires
|
||||
regardless of prior membership), but the client-side table ADD is
|
||||
DEDUPED — `gmCharacterTitleUI::RecvNotice_AddCharacterTitle @0x0049a990`
|
||||
walks `mTitleList` and returns without effect when the id is already
|
||||
present, only inserting + adding the row on a miss.
|
||||
`RuntimeCharacterTitleState.ApplyUpdateTitle` (which models the CLIENT
|
||||
receive side, not the server send side) now fires `TitleAdded` only on a
|
||||
genuine new membership; the inverted pin is
|
||||
`ApplyUpdateTitle_AlreadyEarnedId_DoesNotFireTitleAddedOrBumpRevision`.
|
||||
**F3:** the send-side `titleId == 0` rejection is REMOVED from both
|
||||
command adapters — retail's own send path
|
||||
(`Event_SetDisplayCharacterTitle @0x006a5720`) packs whatever id it is
|
||||
handed, and ACE accepts id 0 (`CharacterTitle.Invalid` is a defined enum
|
||||
value); retail's actual protection is the UI ghost-when-current gate
|
||||
(CT3's job), not a send-side rejection. No register row: removing the
|
||||
guard makes acdream MORE retail-exact, not less. The fix round also
|
||||
closed four SHOULD-FIX-adjacent items: A2 (`ResetSession` now publishes
|
||||
`TableReplaced` unconditionally and `DisplayTitleChanged` when the
|
||||
display id was non-zero before the clear, matching the
|
||||
`LocalPlayerState.Clear()` precedent), A3 (`RuntimeCharacterState
|
||||
.CaptureOwnership` reads the new non-allocating `Titles.Count` instead of
|
||||
`EarnedTitleIds.Count`), A4 (the whole mutation in `ReplaceTable`/
|
||||
`ApplyUpdateTitle` now happens under one `_gate` hold, with change flags
|
||||
computed inside the lock and events raised after release), and A5 (every
|
||||
revision bump is now gated on an actual state change — a no-op wire
|
||||
resend produces zero revision edges; `TableReplaced` itself still fires
|
||||
unconditionally per retail's own `Refresh()` dispatch). A1
|
||||
(`CharacterTitleResolverLiveDatTests` now honors `ACDREAM_DAT_DIR`
|
||||
first), A6 (documented the `EmitResult` `primaryObjectId`-as-title-id
|
||||
precedent inline), A7 (corrected the "third consumer" comment — CT1 §5
|
||||
already records `gmAttributeUI::PostInit`'s icon-DID lookup as that third
|
||||
consumer; CT5 is where the shared `GetDIDByEnum` helper gets factored),
|
||||
and A8 (`CharacterTitleResolver` now memoizes the final resolved string
|
||||
per title id, the DAT-static equivalent of retail's lazy-hash cache on
|
||||
the string buffer) round out the fix round.
|
||||
|
||||
**CT3 anchors from the CT2 review** (carried forward for CT3 to consume,
|
||||
not yet acted on):
|
||||
1. CT3 must refresh the display-title TEXT from `TableReplaced` as well
|
||||
as `DisplayTitleChanged` — retail's
|
||||
`RecvNotice_UpdateCharacterTitleTable` unconditionally `Refresh()`es
|
||||
on every `0x0029` arrival, not only when the display id differs.
|
||||
2. ACE sends NO echo when re-setting the already-current title — the
|
||||
Set-as-Display button must not wait for a confirmation that never
|
||||
arrives; retail prevents the send in the first place via the UI
|
||||
ghost-when-current gate.
|
||||
3. Retail's fallback display text when a title id doesn't resolve is the
|
||||
hardcoded literal `"Unknown"` (`Refresh @0x0049abc0`), not a
|
||||
StringTable key — `CharacterTitleResolver.Resolve` returning `null`
|
||||
is the correct signal for CT3 to substitute that literal.
|
||||
4. The deduped client-side add contract (F1 above) — CT3's title-list
|
||||
row rendering must not assume every `TitleAdded` firing corresponds
|
||||
to a wire arrival; the reverse still holds (every genuine new row has
|
||||
a `TitleAdded` firing).
|
||||
|
||||
**CT3 — Titles page UI. REVIEW-CLOSED 2026-08-24: landed `03e073b7`, Opus review (1 blocker: selection-clear semantics; 2 should-fix), fix round `4cc9448b`; full hermetic suite re-verified green after a load-flake false alarm.** Bind the authored page through the standard
|
||||
GUI classes (`UiTemplateListBox`/`UiScrollbar`/`UiButton` — zero
|
||||
bespoke widgets): sorted rows via the ported title-table lookup,
|
||||
selection, ghost-when-current logic (state 0xd contract), display-title
|
||||
text, Set-as-Display round trip, scrollbar. Retires half of AP-109.
|
||||
|
||||
**CT3 fix round (Opus dual-lens review, 2026-08-24).** BLOCKER: ported
|
||||
`Refresh @0x0049abc0`'s unconditional `SetSelectedItem(nullptr, 1)`
|
||||
(`@0x0049ac5a`) — selection now clears on BOTH `TableReplaced` and
|
||||
`DisplayTitleChanged`, regardless of whether the previously-selected id
|
||||
is still earned in the new table, but deliberately survives
|
||||
`TitleAdded` (`RecvNotice_AddCharacterTitle @0x0049a990` splices one
|
||||
row without ever touching `m_pSelectedItem` — a genuinely different
|
||||
retail method from `Refresh`). SHOULD-FIX: `AddTitleToList @0x0049A840`'s
|
||||
early-outs (`@0x0049a873`/`@0x0049a914`) ported — an id of 0, or an id
|
||||
`CharacterTitleResolver.Resolve` fails to resolve, now produces NO row
|
||||
at all (the `"Unknown"` fallback literal belongs only to the
|
||||
display-title text, never a row — this was previously ported
|
||||
backwards); rows use the row template's own authored `DefaultColor`
|
||||
instead of a hardcoded white, and each row/display-text `UiText.Line[]`
|
||||
is built once per text change and cached instead of reallocated every
|
||||
draw call. Notes also applied: corrected two comments that falsely
|
||||
claimed the Titles page authors its own copies of the raise buttons
|
||||
(verified against the fixture — it does not; the hide loop that
|
||||
comment guarded is a defensive no-op, kept only for the
|
||||
contentPage-not-found fallback path), switched the row sort from
|
||||
`List.Sort` to a stable `OrderBy`/`ThenBy` (ties broken by title id),
|
||||
wrapped the title-resolver delegate in the same `DatLock` the
|
||||
row-template resolver already takes (`RetailUiRuntime.MountCharacter`),
|
||||
and set the list box's authored 24px row height so wheel/line scroll
|
||||
lands row-aligned.
|
||||
|
||||
**CT4 — Header identity block. REVIEW-CLOSED 2026-08-25: landed `ed652ed8`, Opus review (2 blockers: luminance strings recovered by the reviewer, verbatim-title append; PK re-sourced to PWD bits), fix round `e7e32409`.** Retail composition: name; "<Gender>
|
||||
<Heritage> <DisplayTitle>"; PK status line — authored fonts/colors
|
||||
(pure white per probe), live refresh on display-title change and PK
|
||||
status, identical on Attributes AND Skills pages. Level color from the
|
||||
authored element. Retires the rest of AP-109's UI half.
|
||||
|
||||
**CT4 landing notes (2026-08-24).** Verified the existing `Label(...)` seam
|
||||
already covers both Attributes/Skills page copies — `CharacterStatController`
|
||||
binds the SAME physically-visible container (contentPage = the Attributes
|
||||
page chain) for both tabs; the Skills-page duplicate header subtree is never
|
||||
shown (pinned by `Bind_HeaderElements_UseVisibleAttributesPageWhenIdsAreDuplicated`,
|
||||
a test that PREDATES CT4 — corrected at the CT4 fix round below, since the
|
||||
original wording here implied CT4 wrote it fresh; the pre-existing test did
|
||||
not cover `PkStatusId` until the fix round extended it).
|
||||
All four header identity elements (Name/Heritage/PkStatus/Level) switched
|
||||
from hand-picked `Body`/`Gold` runtime colors to the widget's own authored
|
||||
`DefaultColor` (`LabelAuthoredColor`), matching CT1's live-DAT pin exactly —
|
||||
the former "runtime color, dat carries none" comment was false. PK status now
|
||||
resolves through StringTable `0x23000001` by key with a bitwise IsPK/IsPKLite
|
||||
test (the prior exact-equality switch silently dropped combined-flag
|
||||
values); **live-DAT-verified authored strings**: `ID_StatManagement_Header_PKStatus_PK`
|
||||
→ "Player Killer", `_PKL` → "Player Killer Lite", `_NPK` → "Non-Player Killer"
|
||||
(pinned in `CharacterPanelLiveDatTests.PkStatusKeys_ResolveExpectedAuthoredStrings`).
|
||||
Level shows `"%d"`-formatted `InqInt(0x19)` or the PE-recovered literal
|
||||
`"???"` when absent (`CharacterSheet.Level` is now `int?`). The heritage
|
||||
line's appended title now comes from CT2/CT3's `RuntimeCharacterTitleState.DisplayTitleId`
|
||||
resolved through `CharacterTitleResolver`, refreshing live on both
|
||||
`TableReplaced` and `DisplayTitleChanged` (`CharacterSheetProvider`'s
|
||||
`ChangeBinding` now subscribes to both). **Name-line ruling:** ships the
|
||||
PLAIN-NAME case only — retail's allegiance rank-title prefix
|
||||
(`AllegianceData::GetFullName @0x005b6950` → `AllegianceSystem::GetTitle
|
||||
@0x005b8dd0`) needs a ~200-string, 22-function heritage×gender table
|
||||
(verbatim hardcoded literals in the decomp, not DAT-resolved — e.g.
|
||||
`GetAluvianMaleTitle @0x005b7bc0`'s "Yeoman"/"Baronet"/"Baron"/"Reeve"/
|
||||
"Thane"/"Ealdor"/"Duke"/"Aetheling"/"King"/"High King") judged out of
|
||||
reasonable size for this slice; `RuntimeAllegianceState` already carries the
|
||||
local player's own rank, so only the string table is missing. **Luminance
|
||||
(item 5):** the DATA (`CharacterSheet.AvailableLuminance`/`MaximumLuminance`,
|
||||
PropertyInt64 6/7) already flows generically through both the
|
||||
PlayerDescription snapshot parser and the live `0x02CF` private-update path
|
||||
— no wiring gap existed — and the retail show/hide gate
|
||||
(`Level >= 200 && MaximumLuminance != 0`, `UpdateExperience @0x004F0A70`) is
|
||||
wired and toggles `Visible` on both `0x100005C5`/`0x100005C6`, but the
|
||||
label's caption and the value's composed number format could not be
|
||||
recovered this slice (retail's `SetText` source resolves through a
|
||||
Binary-Ninja-mislabeled data pointer, not a StringTable key; a DAT
|
||||
string-table sweep found no match) — content stays unbound rather than
|
||||
guessed. AP-109 narrowed accordingly (register row updated in the same
|
||||
commit, not deleted — the two open items above remain). Tests:
|
||||
`CharacterStatControllerTests` (heritage composition + live update, name
|
||||
stays plain, level int/"???" with authored — not constant — color, PK line
|
||||
shows resolved text in authored color, luminance visibility across five
|
||||
level/luminance combinations) and `CharacterSheetProviderTests` (PK
|
||||
key-by-status resolution including a combined-flag case, no-resolver ⇒ null,
|
||||
Level null-vs-present, title resolution + live refresh on both title
|
||||
events + unsubscribe-on-dispose, luminance Int64 read-through).
|
||||
|
||||
**CT4 fix round (Opus dual-lens review, 2026-08-25).** 2 BLOCKERS: (1) the
|
||||
luminance caption/value strings were RECOVERED by PE-byte-decoding the raw
|
||||
retail binary (caption UTF-16 `"Luminance:"` at `@0x007c3dd4`, value narrow
|
||||
`"%s / %s"` at `@0x007c3dcc`, both immediately following
|
||||
`gmStatManagementUI::UpdatePKStatus`'s own vftable slots — the CT4 landing's
|
||||
"could not be recovered" claim is FALSIFIED), so the pair now binds real
|
||||
text (each number formatted through a new shared `FormatXp` helper —
|
||||
`.ToString("N0", InvariantCulture)`, also now used by Total XP / XP-to-next-
|
||||
level, replacing their un-invariant `.ToString("N0")`), and the hide path
|
||||
switched from `Visible = false` to retail's own `UIElement_Text::ClearAllText`
|
||||
mechanism (`@0x004f0e31`/`@0x004f0e3c` — empty the LinesProvider, leave
|
||||
layout); (2) `CharacterIdentityText.StripLeadingArticle` is deleted — retail
|
||||
`AppendText`s the resolved title VERBATIM (`@0x004f0990`), and 26 real ACE
|
||||
`CharacterTitle` entries begin with "The", so every one of them was being
|
||||
mangled; the dead `CharacterSheet.Race` fallback (no retail producer — the
|
||||
`InqGenderHeritageDisplay` creature-type argument is a hardcoded literal `0`
|
||||
at `@0x004f08db`) is deleted alongside it. 5 SHOULD-FIX: (3) the PK line now
|
||||
classifies off the live `ClientObject.PublicWeenieBitfield` PWD bits
|
||||
(`0x20`/`0x02000000`, `ACCWeenieObject::IsPK`/`IsPKLite`
|
||||
`@0x0058c8b0`/`@0x0058c8a0`) instead of a bitwise test against raw
|
||||
PropertyInt 134 — PropertyInt 134 carries ACE's own `PlayerKillerStatus`
|
||||
enum bit layout, not the PWD layout, so the deleted `0x4 | 0x8` combined-flag
|
||||
test case asserted a non-retail answer (PropertyInt 134 already drives the
|
||||
correct PWD bits via `PlayerKillerStatusBitfield.Apply`, so this is a
|
||||
re-source, not new wiring); (4) the register's AP-109 row restores CT3's
|
||||
Titles-page narrowing paragraph (CT4's edit had compressed it to a bare
|
||||
pointer phrase), corrects the rank-prefix item's source to PropertyInt
|
||||
`0x1E` (`AllegianceRank`) read live off the qualities bundle — NOT
|
||||
`RuntimeAllegianceState`, which is a DIFFERENT UI's (`SocialAllegiancePageController`)
|
||||
own documented substitute — corrects the title-table size from the
|
||||
originally-estimated 22 functions/~200 strings to the ACTUAL 17
|
||||
functions/~170 strings (`AllegianceSystem::GetTitle`'s dispatch switch read
|
||||
directly: Gearknight/Tumerok author only a male function reused both ways,
|
||||
Lugian only a female one, and Olthoi/OlthoiAcid have none), and downgrades
|
||||
the row's evidence claim to "synthetic-layout binding tests plus a small
|
||||
number of InstalledDat string/DID pins" rather than implying a
|
||||
connected/live gate; (5) `CharacterPanelLiveDatTests.HeaderElements_AuthorExpectedFontsAndColors`
|
||||
gains the luminance pair's own occurrence-count + font/color pins, matching
|
||||
the pattern every other header id already uses. Also landed this round: an
|
||||
InstalledDat pin (`GenderHeritageDisplayNameTables_MatchTheRetailEnumMapperChain`)
|
||||
proving `CharacterIdentityText.GenderDisplayName`/`HeritageGroupDisplayName`
|
||||
match the live retail `EnumMapper` chain (master map category 1 →
|
||||
`ClientEnumToID[0x10000001]`/`[0x10000002]` → EnumMapper DIDs
|
||||
`0x2200000A`/`0x2200000B`) byte-exact, including the two entries (10
|
||||
"Penumbraen", 12 "Olthoi") the review had flagged as unverified guesses —
|
||||
both are correct; the mechanism divergence (hardcoded table vs. live DAT
|
||||
read) is filed as AP-235, pointing CT5 at the ALREADY-EXISTING generic
|
||||
`RetailDataIdResolver.Resolve` helper (not a new "GetDIDByEnum helper" to
|
||||
write) as the unification seam; `RetailAppraisalNameResolver.ResolveHeritage`'s
|
||||
independent re-implementation of the same three overrides is noted there
|
||||
too, for CT5. `CharacterSheetProvider.BuildSheet`'s level read switched from
|
||||
a `GetInt` + `Ints.ContainsKey` double dictionary lookup to one
|
||||
`TryGetValue`.
|
||||
|
||||
**CT5 — Row alignment + value gutter. REVIEW-CLOSED 2026-08-25: landed `f532f28c`, Opus review (0 blockers, 4 should-fix incl. the authored Normal-state row band 0x06004CC2), fix round `0a37a28e`.** Reconcile our hand-built
|
||||
attribute/skill rows with the authored row templates from CT1: icon
|
||||
placement, name/value columns, the authored right margin that reserves
|
||||
the scrollbar gutter.
|
||||
|
||||
**CT6 — Resize + scrollbar contract. REVIEW-CLOSED 2026-08-25: landed `ec50455a`, Opus review (1 blocker: chrome-inclusive host constraints double-counted; S4 default-height ruling = retail's 372px), fix round `996cd736`.** CT6 research lead (Fable,
|
||||
2026-08-24, follows CT1 correction (a)): `gmPanelUI::ResizeTo
|
||||
@0x004BC6E0` is a bare tailcall to `UIElement::ResizeTo` — no clamp
|
||||
there, so the minimum lives in the generic Resizebar drag path reading
|
||||
element attributes, and the authoring to probe is the PANEL-HOST layout
|
||||
`0x2100006E`'s slot elements (Character slot per `RetailPanelCatalog`,
|
||||
sibling of social `0x1000018F`) — NOT the character layout root CT1
|
||||
probed. First CT6 step: probe the host slots' min/max + resize
|
||||
authoring, then read `UIElement_Resizebar::StartMouseResizing
|
||||
@0x0046B7E0`'s clamp source verbatim.
|
||||
Original scope: Character window Y-resizable to
|
||||
the authored minimum; the stat list shows its scrollbar when the
|
||||
resized viewport overflows (the full-track/disabled behavior from the
|
||||
2026-08-24 scrollbar work applies as-is); authored min/max constraint
|
||||
enforcement verified as the STANDARD path for every registered window
|
||||
(one shared mechanism in `RetailWindowFrame`/`RetailWindowManager`,
|
||||
no per-window special cases).
|
||||
|
||||
**CT6 landing notes (2026-08-25, implementation).** Live probe (dumped
|
||||
+ deleted, pattern preserved by the new
|
||||
`CharacterPanelLiveDatTests.PanelHost_AuthorsFixedWidthAndBottomOnlyResizeContract`
|
||||
pin) confirmed the research lead's hypothesis exactly: the shared
|
||||
`gmPanelUI` host `0x100005FE` (LayoutDesc `0x2100006E`) authors
|
||||
MinWidth=MaxWidth=310 (fixed — no horizontal Resizebar), MinHeight=372,
|
||||
MaxHeight=1000; its bottom Resizebar (`0x10000660`) and top Dragbar
|
||||
(`0x1000065C`) are DIRECT CHILDREN of the host, not the content parent
|
||||
— matching `UIElement_Resizebar::StartMouseResizing @0x0046B7E0`'s
|
||||
`GetParent()` call and `UIElement::MouseResizeElement @0x00461130`'s
|
||||
`GetAttribute_Int(this, 0x3C..0x3F)` reads off that same parent. The
|
||||
Character/Skills slot `0x1000018E` itself authors no constraints of its
|
||||
own (confirmed, same pin). `RetailUiRuntime.MountCharacter` now imports
|
||||
that host element and passes it as `DatConstraintSource`. **CORRECTED
|
||||
(CT6 fix round, BLOCKER B1):** this paragraph originally claimed the
|
||||
mounted outer frame clamped at "MinWidth=MaxWidth≈320, MinHeight≈382,
|
||||
MaxHeight≈1010 after the NineSlice chrome inset" — that was WRONG. Host
|
||||
`0x100005FE` is not a content element our wrapper adds chrome to; it IS
|
||||
retail's own outer window frame (5px bevel + 300×362 content parent
|
||||
`0x10000180` + 5px = 310×372), so its authored 0x3C..0x3F values are
|
||||
already chrome-INCLUSIVE. Adding the NineSlice wrapper's own 10px inset
|
||||
on top double-counted the bevel, clamping MinWidth to 320 while the
|
||||
window's actual mounted outer width stayed 310 — silently below its own
|
||||
minimum until `RetailWindowManager.ResizeTo` forcibly widened it despite
|
||||
`ResizeX=false`. Fixed with a new
|
||||
`RetailWindowFrame.Options.DatConstraintSourceIsOuterFrame` opt-out
|
||||
(chrome inset = 0 for constraint resolution when set — the value stays
|
||||
DAT-sourced, only the redundant inset is skipped); `MountCharacter` sets
|
||||
it true. The mounted outer clamps are now EXACTLY what the host authors:
|
||||
width fixed **310**, height **372..1000** — no composed arithmetic. A
|
||||
new mount-time invariant in `RetailWindowFrame.Mount` (throws if the
|
||||
just-mounted outer extent falls outside its own just-computed clamp)
|
||||
would have caught B1 at the very first test run; it is now permanent for
|
||||
every window this path mounts.
|
||||
|
||||
**S4 (2026-08-25, campaign-lead ruling — which number governs the
|
||||
default mount size):** `0x2100002E`'s own root is authored 300×600 (the
|
||||
"Size tension" the earlier ground-truth doc left unresolved — CT3's
|
||||
Titles page alone is 300×575, plus the 25px tab bar). That 600 is a real
|
||||
authored canvas, but it is the CONTENT's own design surface, not the
|
||||
mounted default — retail scroll-clips it into the shared host's much
|
||||
smaller 300×362 content parent (`0x10000180`). Pre-fix, `MountCharacter`
|
||||
left `ContentHeight` unset, so it fell back to the raw 600px canvas,
|
||||
producing a stale 610px mounted default (600 + 10px chrome inset) that
|
||||
was never retail's actual opening size. **372 (the host's own outer
|
||||
frame, 362 content + 10 chrome) is the number that governs the mount
|
||||
default** — it is also exactly the host's own authored MinHeight, so
|
||||
retail's Character/Skills window opens AT its resize floor and can only
|
||||
be dragged taller, never shorter. `MountCharacter` now sets
|
||||
`Options.ContentHeight = 362f` explicitly to realize this. The authored
|
||||
page composition (header 112 + list 160 + divider + footer) IS the
|
||||
362px design; at that default the 9 attribute/vital rows (180px content)
|
||||
OVERFLOW the 160px list, so the stat list's scrollbar is active
|
||||
immediately on open — retail-correct, not a regression (see S2 below for
|
||||
what "active" actually looks like). Persistence still restores a
|
||||
user-chosen size within the 372..1000 clamp on top of this default. Full
|
||||
derivation + decomp anchors:
|
||||
`docs/research/2026-08-24-campaign-ct-dat-ground-truth.md` §CT6.
|
||||
`CharacterStatController.RebuildActiveList` now wraps BOTH the
|
||||
Attributes and Skills tabs' rows in the same `UiScrollablePanel`
|
||||
viewport (previously only Skills got one; Attributes rows were added
|
||||
directly to the ListBox with no clipping/scrolling and the shared
|
||||
scrollbar was force-hidden — the owner's item 2). The shared scrollbar
|
||||
is now always BOUND (`.Model`/`.Visible = true`); no per-tab visibility
|
||||
toggle is needed. **CORRECTED (CT6 fix round, S2):** this paragraph
|
||||
originally claimed `UiScrollbar`'s own `IsPresentationVisible`/
|
||||
`IsModelDisabled` "draw the correct full-track 'disabled' thumb when
|
||||
content fits (`HideWhenDisabled` defaults false)" — that had the
|
||||
authored default BACKWARDS. `0x1000023E` (this scrollbar) and
|
||||
`0x10000533` (the Titles list's own scrollbar) both author property
|
||||
`0x79` (`HideWhenDisabled`) **TRUE**, fixture-verified (`BoolValue: true`
|
||||
on both elements' property 121/0x79 in the committed fixture). A fitting
|
||||
list HIDES the bar entirely; it does not leave a full-track disabled
|
||||
thumb visible. The code was already correct — `.Visible = true` only
|
||||
keeps the bar in the tree, `IsPresentationVisible` does the actual
|
||||
show/hide — only this description was wrong; fixed here, in
|
||||
`CharacterStatController.RebuildActiveList`'s own comment, in the CT7
|
||||
script, and in `CharacterStatControllerTests`' comment, plus a new
|
||||
`IsPresentationVisible` assertion pair added to the resize test (hidden
|
||||
once growing makes the content fit, visible+interactive while
|
||||
overflowing). This surfaced and fixed a real, previously-unexercised `#372`/
|
||||
`#412`-class anchor-baseline bug: the viewport's `Left|Top|Bottom`
|
||||
anchor was capturing its baseline margins lazily on its OWN first
|
||||
`ApplyAnchor` call, which happens AFTER the ListBox has already grown
|
||||
from its raw DAT height (160px) to its mounted height — measuring a
|
||||
bogus non-zero margin that permanently capped the viewport short on
|
||||
every later resize. Fixed with an eager
|
||||
`viewport.CaptureCurrentAnchorBaseline()` call right after
|
||||
`AddChild`, mirroring the identical fix already shipped in
|
||||
`UiTemplateListBox.Viewport`'s own lazy getter. **CORRECTED (CT6 fix
|
||||
round, S3):** `CharacterTitlesController.Bind` originally gained the
|
||||
same defensive `if (listBox.LayoutPolicy is null) Anchors =
|
||||
Left|Top|Bottom` fallback for the Titles ListBox (`0x10000532`) that
|
||||
`CharacterStatController` already had for its own list. Both
|
||||
`0x10000532` and the Titles page container `0x10000539` author
|
||||
`HasOriginalParentSize=true` in the real DAT AND the committed fixture,
|
||||
which makes `LayoutImporter`/`DatWidgetFactory` always assign a real
|
||||
`LayoutPolicy` — the fallback branch was therefore UNREACHABLE, not a
|
||||
harmless no-op "matching the established pattern for synthetic/test
|
||||
layouts" as originally described. Deleted rather than left as dead code;
|
||||
a new `CharacterPanelLiveDatTests` pin asserts `HasOriginalParentSize`
|
||||
on both elements to guard the deletion against future DAT drift.
|
||||
STANDARDIZATION AUDIT (no gaps found, no follow-up filed): `UiElement
|
||||
.MinWidth/MinHeight/MaxWidth/MaxHeight`, set once at
|
||||
`RetailWindowFrame.Mount` from `Options.DatConstraintSource`/explicit
|
||||
overrides, are the ONLY clamp fields — read identically by the
|
||||
interactive drag path (`UiRoot`'s resize handling), the programmatic
|
||||
path (`RetailWindowManager.ResizeTo`, which both `RetailPanelUiController`'s
|
||||
main-panel geometry sync and this slice's tests exercise), and the
|
||||
persisted-geometry restore clamp (`RetailWindowLayoutPersistence.Apply`).
|
||||
`RetailWindowFrame.Mount` remains the single production mount path (no
|
||||
window bypasses it). New regression pin (**renamed, CT6 fix round N4**:
|
||||
the original name `NineSlice_ChatShapedConstraints_
|
||||
ClampProgrammaticResizeAtAuthoredBounds` overclaimed — it exercises
|
||||
NineSlice inset ARITHMETIC on a content-shaped source (490×100,
|
||||
height-only synthetic constraints) and never actually pinned chat's real
|
||||
DAT contract, since no width constraints were even set):
|
||||
`RetailWindowFrameTests.NineSlice_ContentShapedConstraints_InsetArithmeticClampsProgrammaticResize`
|
||||
proves the same mechanism still clamps chat-shaped constraints after
|
||||
Character was wired onto it. A new companion test,
|
||||
`Imported_ChatContract_ClampsAtAuthoredBoundsWithNoChromeInset`, mounts
|
||||
with `Chrome=Imported` and chat's real 300/100/2000/2000 constraints
|
||||
(matching production's actual `MountChat` wiring) and asserts no inset
|
||||
applies — the true chat-contract pin the renamed test's name no longer
|
||||
claims to be. Tests: `CharacterStatControllerTests
|
||||
.CharacterWindow_ResizesYWithinAuthoredHostClamp_AndReflowsListAndScrollbar`
|
||||
(window-level: clamp at authored min/max, list shrink, scrollbar
|
||||
overflow flip, footer stays bottom-docked, grow-back restore) and
|
||||
`CharacterTitlesControllerTests.TitlesList_ReflowsWithWindowResize_AndScrollbarOverflowFlips`
|
||||
(same contract for the Titles list) plus the pre-existing 126+22-test
|
||||
suites, all updated where the new nested-viewport DOM shape required it
|
||||
(`Descendants(list)` instead of `list.Children` — the shape Skills rows
|
||||
already needed). No register row: **CORRECTED (CT6 fix round, N5)** —
|
||||
before the B1 fix this sentence ("every number is either a live-probed
|
||||
authored DAT value ... nothing inferred") was not actually true: the
|
||||
mounted 320/382/1010 clamp WAS an inference (the host's chrome-inclusive
|
||||
values plus a second, redundant chrome inset composed on top). After B1
|
||||
removes that composition, the mounted clamp is now literally the host's
|
||||
own four probed values with zero arithmetic applied — the sentence holds
|
||||
for real. No register row for the S4 content-height default either: 362
|
||||
is the same host content-parent width/height CT6 already probed and
|
||||
cited (`0x10000180`, 300×362), not a new number.
|
||||
|
||||
**CT7 — Connected gate.** Test script
|
||||
(`docs/research/2026-08-25-campaign-ct-test-script.md`), owner drive:
|
||||
titles round trip against ACE (earn/set/display), header lines vs
|
||||
retail side-by-side, resize behavior, row alignment screenshots.
|
||||
|
||||
### CT-GF1 — client-wide retained-UI ancestor clip (gate finding + fix round)
|
||||
|
||||
Landed `989f6652`: ports retail's `UIRegion::DrawHere @0x0069FA30`
|
||||
ancestor-clip intersection as `UiElement.ClipsChildren`'s new client-wide
|
||||
default (true), fixing the CT7 gate's own first finding — the Titles page's
|
||||
authored divider `0x10000530` escaping the Character window above its top
|
||||
edge at the CT6-correct 372px mounted default. One opt-out
|
||||
(`UiElement.ExpandsClipForPopup`, `UiMenu`'s inline-drawn popup) plus new
|
||||
`UiAncestorClipTests` mechanism coverage.
|
||||
|
||||
**Fix round** (Opus dual-lens review, 0 blockers / 7 SHOULD-FIX / 4 NOTE, all
|
||||
applied): moved the ambient clip to wrap `OnDraw` + children +
|
||||
`OnDrawAfterChildren` in one block — the literal `DrawHere` shape, clipping
|
||||
an element's own `DrawSelf` too, not just its children (`UIElement_Text::
|
||||
DrawSelf @0x00467AA0`; `UIRegion::DrawSelf @0x0069F1A0`) — and deleted the
|
||||
two now-redundant ad-hoc self-clips it superseded (`UiText.DrawText`,
|
||||
`UiField.DrawMultiLine`); kept the one that clips to a genuinely smaller
|
||||
authored inner rect (`UiButton.DrawBlockLabel`'s `LabelBox`/`ValueBox`).
|
||||
Deleted `UiItemList`'s `ClipsChildren` override (inverted under the new
|
||||
default). Pinned the escaped-popup input path end to end (`UiRoot.PopupHit`
|
||||
routing, `WantsMouse`) with a new real-`UiRoot` test. Strengthened the
|
||||
Titles-divider regression test's positive half (exact-rect assertion +
|
||||
visible/hidden diff, not a bare Y-band check). Added a draw-capture
|
||||
regression sweep across Character/Chat/Vendor/Options mounted through their
|
||||
real controllers (`UiWindowDrawCaptureSweepTests`). `PushClipUnbounded` now
|
||||
resets to the screen rect, not `null` — retail's own popup region is
|
||||
screen-clipped, not truly unbounded (AD-113 amended). `UiRoot.ClipsChildren`
|
||||
now explicitly overrides false (the root's own region IS the screen — a
|
||||
safety net against a momentarily zero-sized root blanking the whole UI).
|
||||
Added the empty-clip subtree cull (retail's `var_24` gate), scoped to the
|
||||
main draw pass only — the popup's separate `DrawOverlays` traversal is
|
||||
provably unaffected (new coverage: a menu inside a fully-clipped window
|
||||
still draws its popup).
|
||||
|
||||
**Owed:** the CT7 re-gate (script `docs/research/2026-08-25-campaign-ct-test-
|
||||
script.md`) still needs the owner's connected drive — this fix round landed
|
||||
on the automated side only. §5 of that script now also names the
|
||||
collapsed-toolbar check and the four highest-overflow windows (combat/
|
||||
vitals bar, Options bottom-button row, map/house page, floaty chat) as
|
||||
explicit eyeball items for that same re-gate.
|
||||
|
||||
## Review protocol
|
||||
|
||||
Per slice: Sonnet implements → Opus dual-lens review (lens 1
|
||||
retail-faithfulness vs the cited decomp anchors; lens 2 architecture —
|
||||
GUI-class standardization, Runtime ownership boundaries, no
|
||||
controller-side state) → fix round → full hermetic suite green.
|
||||
Commits to the worktree branch as slices land; **no gitea push until
|
||||
the owner directs it**.
|
||||
157
docs/plans/2026-08-25-assess-window-parity-campaign.md
Normal file
157
docs/plans/2026-08-25-assess-window-parity-campaign.md
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
# Campaign AS — assess/examination window retail parity (player targets)
|
||||
|
||||
**Status: CLOSED — CONNECTED GATE PASSED 2026-08-25 ("fixed! gate pass!").**
|
||||
AS1–AS5 review-closed; the gate round harvested two findings, both resolved
|
||||
in-round: the extras-list "black rectangle" is retail's own authored
|
||||
scroll-less clipped listbox (no scrollbar authored on 0x10000335 — verified
|
||||
against the live DAT; wheel-scroll and resize reveal rows; AS-GF1
|
||||
`65f6f584` ruled it not-a-code-defect), and the paperdoll's absence
|
||||
narrowed to an intermittent FIRST-OPEN DELAY (#443, kept open) after the
|
||||
probe round proved the render layer healthy — the render pipeline was
|
||||
never broken by this campaign. Gate probes deleted at close per the
|
||||
probe-dies rule (recoverable via `git show 65f6f584`). Branch NOT pushed —
|
||||
the owner pushes on their word.
|
||||
|
||||
Owner report (2026-08-25, side-by-side screenshots, acdream vs retail, both
|
||||
assessing the player "Dww"): acdream's examination window on a PLAYER target
|
||||
is missing retail's identity block and body/config sections.
|
||||
|
||||
Missing vs retail:
|
||||
|
||||
1. **Identity block** under the title bar: gender + heritage ("Female
|
||||
Aluvian"), current display title ("War Mage"), PK status
|
||||
("Non-Player Killer") — none of it rendered by acdream.
|
||||
2. **Per-bodypart armor levels**: retail shows three grouped rows —
|
||||
"Head/Chest/Groin AL: x/y/z", "Bicep/Wrist/Hand AL: x/y/z",
|
||||
"Thigh/Shin/Foot AL: x/y/z". Absent in acdream.
|
||||
3. **Target-configurable extras**: retail only shows what the assessed
|
||||
player configured to show (date of birth, age, number of deaths, chess
|
||||
rank, fishing skill, damage/crit ratings, …). acdream shows a
|
||||
"Dmg/CritDmg Rating:" line but not the rest of the family.
|
||||
4. **Allegiance/faction lines**: allegiance name/patron/faction shown when
|
||||
the target has them. Absent in acdream.
|
||||
|
||||
Explicit owner rulings:
|
||||
|
||||
- **The animated 3D paperdoll is an INTENTIONAL acdream deviation**
|
||||
(register row AD-114, filed at AS2). Retail's examine preview clone is
|
||||
NOT a static tinted preview — it is INDEPENDENTLY ANIMATED, just
|
||||
decoupled from the live target: `BasicCreatureExamineUI::Init
|
||||
@0x004AB9C0` clones the selected object via `CPhysicsObj::makeObject
|
||||
@0x005144B0` (which runs `MorphToExistingObject` then
|
||||
`play_script_internal(setup->default_script_id)`), sets the clone's
|
||||
heading to 191.367905°, and `CreatureMode::Render @0x004529D0` runs
|
||||
`update_position` on it every frame. acdream's deviation is that our
|
||||
preview mirrors the target's LIVE motion instead of playing its own
|
||||
private, decoupled cycle. Keep ours. (Retail's preview colors are also
|
||||
buggy on the owner's reference setup, so porting the decoupled clone
|
||||
would not even be a faithfulness win.)
|
||||
- Retail comparison is the oracle for text composition; all strings come
|
||||
from DAT StringTables per the decomp — **never hardcoded English
|
||||
literals**.
|
||||
- All UI work goes through the standard GUI classes (UiLabel / UiPanel /
|
||||
UiScrollablePanel / retail chrome) — no bespoke widgets, no quick fixes.
|
||||
|
||||
## Execution model (set by owner)
|
||||
|
||||
- **Fable** plans and coordinates (this doc + slice contracts + synthesis).
|
||||
- **Sonnet** implements each slice against a pinned contract.
|
||||
- **Opus** runs the dual-lens review per slice — retail-faithfulness lens +
|
||||
architecture lens — followed by a fix round; slice is REVIEW-CLOSED only
|
||||
after the re-review accepts the fixes.
|
||||
- Commit to the worktree branch (`claude/windmill-seam-and-solid-polys`) as
|
||||
slices land, full hermetic suite green each time
|
||||
(`Lane!=InstalledDat&Lane!=PreparedPackage&Lane!=Live&Lane!=Manual&Lane!=Timing&Lane!=Windows&Lane!=Linux&Lane!=SystemFont&Purpose!=Diagnostic&Status!=KnownFailure`).
|
||||
- **Do NOT push to gitea until the owner says so.**
|
||||
- Campaign stops when all slices are REVIEW-CLOSED and the connected-gate
|
||||
script is written and waiting on the owner's drive.
|
||||
|
||||
## Ground truth sources
|
||||
|
||||
- `docs/research/named-retail/acclient_2013_pseudo_c.txt` — the retail
|
||||
examination window class, line composition, StringTable keys, 0x00C9
|
||||
client-side parse.
|
||||
- `references/ACE/` — what our live server actually sends per flag, and the
|
||||
target-option gating rules.
|
||||
- `references/Chorizite.ACProtocol/` — field-order cross-check.
|
||||
- Campaign CT sealed verdicts (`claude-memory/project_character_panel_campaign.md`)
|
||||
— the heritage/title composition and PK bitfield rules already ported for
|
||||
the character panel; REUSE, do not re-derive.
|
||||
- Research synthesis doc (AS1 output):
|
||||
`docs/research/2026-08-25-campaign-as-ground-truth.md`.
|
||||
|
||||
## Slices (FINAL — re-cut at AS1: the 0x00C9 parse is already complete and
|
||||
the appraisal profile is session-scoped UI presentation state flowing
|
||||
through the established router seam, so the skeleton's wire and
|
||||
runtime-owner slices are unnecessary; all work is App-side composition)
|
||||
|
||||
**Oracle for every slice:**
|
||||
`docs/research/2026-08-25-campaign-as-ground-truth.md` (AS1 synthesis) —
|
||||
its §2 line-composition tables, §3 wire truth, §4 gap ledger G1–G10, and
|
||||
§5 rulings R1–R8 are BINDING on implementers and reviewers. Deviating from
|
||||
a ruling requires a plan-doc amendment, not an implementer judgment call.
|
||||
|
||||
- **AS1 — ground truth synthesis (DONE, Fable).** Three research lenses
|
||||
merged; gap ledger G1–G10; rulings R1–R8.
|
||||
- **AS2 — header identity block (Sonnet).** Fix the element mis-mapping in
|
||||
`AppraisalUiController.ApplyCreature(character: true)` per ground truth
|
||||
§2a: `0x10000150` ← composed gender+heritage (reuse
|
||||
`CharacterIdentityText`, including retail's heritage-id overrides and the
|
||||
creature fallback when heritage==0); `0x10000151` ← current display title
|
||||
via `CharacterTitleResolver` (Int 261, fallback String 5 Template);
|
||||
`0x10000152` ← PK line from the assessed `ClientObject`'s PWD bits
|
||||
(`PlayerKillerStatusBitfield`, ruling R7); `0x1000053A` ← String 47
|
||||
AllegianceName gated on Int 30 ≥ 1, DELETING the invented
|
||||
"Assessment incomplete" literal. First-ever `AppraisalView.Character`
|
||||
controller tests (G10). Register: file the animated-paperdoll AD row
|
||||
(owner-ruled intentional deviation 2026-08-25) in this commit.
|
||||
- **AS3 — armor-level rows + extras-list plumbing (Sonnet).** Plumb
|
||||
`Parsed.ArmorLevels` into the extras composer (signature change from
|
||||
bare `PropertyBundle`); emit the spacer + three grouped AL rows before
|
||||
the rating rows per ground truth §2b rows 3–7, including the `*%d`
|
||||
≥9999 unenchantable rendering, retail's per-row rating gates
|
||||
(307|313|314 · 308|315|316 · 350|351), spacer discipline per ruling R4,
|
||||
and the `* = Unenchantable` legend per ruling R3 (unconditional,
|
||||
gate-verified). Ordering pinned by tests.
|
||||
- **AS4 — society/allegiance/fellowship + configurable extras (Sonnet).**
|
||||
Ground truth §2b rows 1–2 and 8–14: the Society row with rank bands and
|
||||
the local-vs-target faction color rule; the Monarch/Patron/Followers
|
||||
cascade; Fellowship; Arrived in Dereth; Time in Dereth (locate or port
|
||||
`ClientUISystem::DeltaTimeToString` — grep for an existing port first);
|
||||
Chess Rank; Fishing Skill; Deaths ("Has never died" at ≤0); Titles
|
||||
Earned. Register: narrow AP-110 ("exhaustive character detail regions"
|
||||
clause retires) in this commit.
|
||||
- **AS5 — allegiance rank-title table (Sonnet).** Port the 17-function
|
||||
heritage×gender `AllegianceSystem::GetTitle @0x005B8DD0` table (census
|
||||
per AP-109's corrected 2026-08-25 text: 11 heritages → 17 functions,
|
||||
~170 strings; Gearknight/Tumerok male-only reused, Lugian female-only
|
||||
reused, Penumbraen aliases Shadowbound, Olthoi excluded by the unsigned
|
||||
range check) + `AllegianceData::GetFullName @0x005B6950`; wire the
|
||||
examination title bar (rank from `props.GetInt(0x1E)`, ruling R8) AND
|
||||
the character panel's name line (closing AP-109's rank-prefix residual).
|
||||
Register: NARROW AP-109 in this commit (CORRECTED at the AS5 review —
|
||||
retiring would have deleted a live open item: CT4's FormatXp
|
||||
`GetNumberFormatA` approximation sliver survives as the row's sole
|
||||
remaining item, so the row stays active-narrowed).
|
||||
- **AS6 — connected gate script (Fable).** User-driven script
|
||||
`docs/research/2026-08-25-campaign-as-test-script.md`; two-client where
|
||||
needed (allegiance/fellowship/PK lines, deception-failure rendering);
|
||||
includes the R3 legend retail-side-by-side check and the owner's
|
||||
configurable-extras toggle matrix.
|
||||
|
||||
Slices AS2→AS3→AS4 are SERIAL (all touch `AppraisalUiController` /
|
||||
`CreatureAppraisalRows` — coupled-file rule); AS5 may run after AS4's
|
||||
review closes. Each slice: Sonnet implements → full hermetic suite green →
|
||||
commit → Opus dual-lens review (retail-faithfulness + architecture) → fix
|
||||
round → narrow re-review → REVIEW-CLOSED.
|
||||
|
||||
## Ledger
|
||||
|
||||
| Slice | State | Land / fix commits | Notes |
|
||||
|---|---|---|---|
|
||||
| AS1 | **DONE 2026-08-25** | (docs commit) | 3-agent research; ground-truth doc committed |
|
||||
| AS2 | **REVIEW-CLOSED 2026-08-25** | `f8a22589` / `cc5290af` | port exact per dual-lens review; 6 findings (docs/test/refactor) fixed; +AD-114 (animated paperdoll), +AD-115 (title clear-vs-stale), PK bits promoted to `PublicWeenieFlags`; carried follow-up chip: 3 more Core PK-bit copies |
|
||||
| AS3 | **REVIEW-CLOSED 2026-08-25** | `1616cd3d` (no fix round) | APPROVE first pass — trio/legend/monster-path exact by offset-level decomp verification; R3 flattening theory disproven at source; ratings adjudication: pre-AS3 code already retail-exact. 5 NITs: 12 (legend-order comment), 11 (stronger refresh test) fold into AS4; 14/15 done in the close commit; 13 (geometry-keyed test helper) noted |
|
||||
| AS4 | **REVIEW-CLOSED 2026-08-25** | `4ade9b04` / `bf8f5b70` (docs-only fix) | port exact per dual-lens review (presence-gate adjudicated FOR the implementer at `InqInt @0x005B3830`; Time-in-Dereth = pre-existing `RetailDurationText @0x00565E10` port, correct reuse); fix round was oracle-doc corrections + records only, NO code change. True full-solution hermetic count 15,528 (the commit's 15,410 was a mis-report). **AS6 carry-note: the Society green/red colorIdx is MODEL-ONLY (ResolveColor no-op pending AP-110 FontInfo residual) — the gate script must NOT gate on row colors.** Pre-existing parallel-load flake surfaced (shadow-caster zero-alloc pin) — #442, unrelated to AS4 |
|
||||
| AS5 | **REVIEW-CLOSED 2026-08-25** | `8f8c0c3a` / `9f3e3263` | the campaign's most rigorously verified slice: 170/170 title strings confirmed (164 mechanical diff, 6 PE byte-decoded from the PDB-paired binary), all 20 dispatch arms, all 17 bounds tests, both call sites re-derived; zero behavioral findings. Fix round = "retires AP-109" → "narrows" at 5 comment sites + the plan (the FormatXp sliver keeps the row active); re-review also flagged + this close fixed the last "retires" phrasing (`AppraisalUiController.cs`) and the CT plan's stale retirement intent |
|
||||
| AS6 | **GATE PASSED 2026-08-25** | script `87e98395`; gate round `65f6f584` (AS-GF1) + probe-removal close commit | owner ran the gate live; two findings harvested and resolved in-round (extras clip = retail's authored scroll-less listbox, not a defect; paperdoll = #443 first-open delay, render layer proven healthy by probe); identity block, AL rows, allegiance/extras, and the rank-title title bar all owner-verified; probes stripped at close |
|
||||
230
docs/plans/2026-08-25-launcher-content-stabilization.md
Normal file
230
docs/plans/2026-08-25-launcher-content-stabilization.md
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
# Launcher content stabilization
|
||||
|
||||
**Date:** 2026-08-25
|
||||
**Status:** IMPLEMENTED
|
||||
**Goal:** make prepared-content updates fast, explicit, and safe without
|
||||
turning the launcher into a package manager.
|
||||
|
||||
## User contract
|
||||
|
||||
1. The launcher window appears before network access, full-file hashing,
|
||||
baking, recovery, or any other potentially long operation.
|
||||
2. Ordinary startup reads only small metadata: the install record, pak header,
|
||||
file length/write time, and the verification sidecar when present.
|
||||
3. No long content operation begins silently. The launcher first names the
|
||||
reason, work kind, approximate disk requirement, and whether the existing
|
||||
installed game remains usable.
|
||||
4. A prepared-content change uses a small locally generated overlay whenever
|
||||
the affected DAT IDs/landblocks are bounded. A full rebuild is an explicit,
|
||||
rare fallback for format changes or extraction changes with unbounded
|
||||
impact.
|
||||
5. The launcher never starts a mixed client/content pair. Cancellation or a
|
||||
bake/publication failure preserves the prior pair. Once approved content is
|
||||
ready, an unavailable or failed matching-client update leaves Play disabled
|
||||
and retains the verified content for a cheap retry.
|
||||
|
||||
## What exists already
|
||||
|
||||
- `acdream.pak` has a 64-byte header containing DAT iterations, format version,
|
||||
and `BakeToolVersion` (the current content-recipe identity).
|
||||
- `install.json` records the pak SHA-256, size, DAT path, and recipe identity.
|
||||
- `install.verification.json` avoids the former 24-second startup hash when
|
||||
size/write-time still match. Explicit **Verify files** remains the full-hash
|
||||
path.
|
||||
- `acdream-bake` already accepts `--ids` and `--landblocks`, and a filtered
|
||||
bake produces an ordinary valid pak with only those typed keys.
|
||||
- `IPreparedAssetSource` and `IPreparedCollisionSource` are the existing
|
||||
renderer/physics seams; no consumer needs to know which mapped pak supplied a
|
||||
key.
|
||||
- Launcher and client are published together, and the launcher payload already
|
||||
includes the matching bake executable.
|
||||
|
||||
## Deliberately small model
|
||||
|
||||
There are only four work kinds:
|
||||
|
||||
| Kind | Launcher behavior |
|
||||
|---|---|
|
||||
| `None` | No content prompt. |
|
||||
| `Overlay` | Build one cumulative overlay containing all keys changed since the base recipe. |
|
||||
| `FullRebuild` | Explain the long rebuild and required free space before starting. |
|
||||
| `Verify` | User-requested or exceptional recovery hash; always visible and cancellable. |
|
||||
|
||||
The release-feed schema remains unchanged for the first implementation. Every
|
||||
published build already updates the launcher before the client. The updated
|
||||
launcher carries the matching content requirement and a small compiled
|
||||
migration catalog. This avoids stranding strict schema-1 launchers on a feed
|
||||
shape they cannot parse. A future independently versioned content feed can
|
||||
replace the catalog without changing the runtime content model.
|
||||
|
||||
`BakeToolVersion` is retained on disk for compatibility but is treated as a
|
||||
**content recipe version**, not an executable build number. It changes only
|
||||
when the produced prepared content changes.
|
||||
|
||||
## On-disk content state
|
||||
|
||||
The existing `install.json` remains the base-pak authority and is not extended;
|
||||
older launchers reject unknown fields. New state lives in the optional sidecar
|
||||
`DataDirectory/pak/content.current.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"baseSha256": "<sha256 from install.json>",
|
||||
"effectiveRecipeVersion": 6,
|
||||
"overlay": {
|
||||
"path": "acdream-update-6.pak",
|
||||
"sha256": "<64 lowercase hex>",
|
||||
"size": 123,
|
||||
"recipeVersion": 6
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- The sidecar is valid only when `baseSha256` binds it to the current base
|
||||
record and every path is a safe canonical filename beneath the pak directory.
|
||||
- At most one overlay is active. A later overlay is cumulative and atomically
|
||||
replaces the prior sidecar; there is no unbounded lookup chain.
|
||||
- The base and overlay must name the same installed DAT iterations and pak
|
||||
format. The base may carry an older recipe; the overlay carries the effective
|
||||
recipe.
|
||||
- Missing overlay keys fall through to the base. A present-but-corrupt overlay
|
||||
key is authoritative corruption and never falls through.
|
||||
- Render and collision reads follow the same ordering and share the same two
|
||||
memory mappings.
|
||||
- An absent sidecar means the base pak is the complete active content set.
|
||||
- `content.client-pending` is a separate, tiny crash-safe activation gate. A
|
||||
content migration creates it before touching content and removes it only
|
||||
after client compatibility is confirmed. It deliberately carries no package
|
||||
graph; existence means “do not publish this content to Play yet.”
|
||||
|
||||
## Migration catalog
|
||||
|
||||
One compiled catalog entry describes each recipe transition:
|
||||
|
||||
```text
|
||||
target recipe
|
||||
work kind
|
||||
player-facing reason
|
||||
affected DAT IDs and/or landblocks (overlay only)
|
||||
```
|
||||
|
||||
To update a base from recipe 5 directly to recipe 7, the launcher asks the
|
||||
catalog for the cumulative 5 -> 7 impact and emits one recipe-7 overlay. If any
|
||||
step is `FullRebuild`, the combined migration is a full rebuild. A missing
|
||||
catalog step fails closed with an explanatory error; it never guesses.
|
||||
|
||||
The recent procedural night-sky change is `None` because it changed client
|
||||
shader/code only. A future addition of bounded prepared sky keys can be
|
||||
`Overlay`. A global mesh-extraction correction such as recipe 5's solid-face
|
||||
change is `FullRebuild`.
|
||||
|
||||
## Update transaction and UI
|
||||
|
||||
The launcher keeps the existing one-question update surface. When the candidate
|
||||
client needs newer content, pressing **Update** first opens the content-work
|
||||
confirmation:
|
||||
|
||||
> **World data update required**
|
||||
> This release adds prepared sky assets. acdream will build a small update
|
||||
> from your installed Asheron's Call files. The existing game stays installed
|
||||
> until this finishes.
|
||||
> Estimated work: overlay / approximately N files / M free space required.
|
||||
> **Update now** · **Later**
|
||||
|
||||
After confirmation:
|
||||
|
||||
1. Validate the remembered DAT directory and free-space floor.
|
||||
2. Build to a transaction-owned candidate path while the active content stays
|
||||
untouched.
|
||||
3. Validate pak header/TOC and compute the new artifact's SHA once. Never hash
|
||||
the unchanged base as part of an overlay update.
|
||||
4. Atomically publish the content sidecar.
|
||||
5. Install/activate the compatible client.
|
||||
|
||||
The newly prepared content is not published to the launch orchestrator until
|
||||
the startup check confirms that the active client is compatible or the client
|
||||
update succeeds. Choosing **Not now**, losing the network, or failing the
|
||||
client download therefore cannot launch the old executable against the new
|
||||
pak. The launcher keeps the verified content on disk and resumes at the much
|
||||
smaller client-update step.
|
||||
|
||||
For `FullRebuild`, the same transaction builds a candidate base beside the old
|
||||
base, verifies it, then atomically swaps the base record/file. It never moves
|
||||
the playable base out of place before the long build starts.
|
||||
|
||||
Progress uses the existing strict Bake JSONL protocol and shows phase,
|
||||
percentage, failures, and ETA. Cancellation returns to the launcher without
|
||||
changing active content.
|
||||
|
||||
## Startup ordering
|
||||
|
||||
`App.OnFrameworkInitializationCompleted` must not synchronously wait on
|
||||
`LoadExistingAsync` before constructing `MainWindow`. It constructs the shell
|
||||
with an explicit `Checking` installation state, assigns/shows the window, then
|
||||
starts content discovery on the UI dispatcher. Feed update checking begins only
|
||||
after that cheap discovery completes, preventing two startup modals from
|
||||
racing.
|
||||
|
||||
If an exceptional recovery path really needs a full base hash, the shell is
|
||||
already visible and says exactly what it is doing. Launch stays disabled until
|
||||
the recovery check finishes, but the application never looks frozen.
|
||||
|
||||
## Compatibility and rollback
|
||||
|
||||
- A client session receives the resolved base path plus zero or one overlay
|
||||
path. Old clients continue receiving only the base.
|
||||
- The client validates the effective recipe before constructing world owners.
|
||||
- The updater does not activate a client whose content requirement is
|
||||
unsatisfied.
|
||||
- Choosing **Later** leaves the old client/base pair active.
|
||||
- Client rollback is allowed only when the selected client accepts the active
|
||||
content set; otherwise the launcher explains the required content rollback
|
||||
or rebuild instead of launching an incompatible pair.
|
||||
|
||||
## Verification gates
|
||||
|
||||
- Launcher window construction test proves no installer/hash task is awaited
|
||||
before the main window is assigned.
|
||||
- Quick-discovery tests cover missing sidecar, matching sidecar, missing cache,
|
||||
changed length/time, recipe mismatch, and exceptional visible verification.
|
||||
- Content-state tests cover path containment, base-digest binding, atomic
|
||||
publication, cancellation, and crash residue.
|
||||
- Composite-source tests cover overlay hit, base fallback, authoritative
|
||||
overlay corruption, render/collision parity, stats, and balanced disposal.
|
||||
- Session-config round trips cover base-only and base+overlay on App and
|
||||
Headless.
|
||||
- Update tests prove prepared content cannot become launchable before client
|
||||
compatibility is confirmed; **Not now** and client-download failure remain
|
||||
fail-closed, while bake/candidate failure preserves the prior pair.
|
||||
- Release solution compilation and the affected Launcher, Content, App, and
|
||||
Headless gates remain green.
|
||||
|
||||
## Implementation checkpoint
|
||||
|
||||
Implemented 2026-08-25:
|
||||
|
||||
- The Avalonia window is assigned and opened before content discovery, client
|
||||
recovery, feed access, or exceptional hashing begins.
|
||||
- Ordinary current-install discovery uses metadata/header/cache checks; the
|
||||
explicit verification command owns visible whole-pak hashing.
|
||||
- Recipe migrations are compiled and cumulative. Bounded migrations build one
|
||||
filtered overlay; unbounded/global migrations use the explicit candidate
|
||||
full-rebuild path. The current recipe 4 -> 5 transition is correctly a full
|
||||
rebuild because the solid-face extraction change is global.
|
||||
- Base/overlay reads are unified for render and collision with overlay-first,
|
||||
Missing-only fallback and authoritative corruption.
|
||||
- Content activation is bound to client compatibility in memory and through
|
||||
`content.client-pending`, so **Not now**, failed download, process crash, and
|
||||
launcher restart cannot expose a mixed pair.
|
||||
|
||||
Final Release gates:
|
||||
|
||||
- `dotnet build AcDream.slnx -c Release`: 0 warnings, 0 errors.
|
||||
- Launcher UI/ViewModels, excluding the documented manual desktop lane: 82/82.
|
||||
- Launcher.Core Windows-compatible suite: 360/360.
|
||||
- Hermetic Content suite: 130/130.
|
||||
- Affected App layered/session composition: 35/35.
|
||||
- Affected Headless configuration: 9/9.
|
||||
483
docs/plans/2026-08-26-mosstank-parity-campaign.md
Normal file
483
docs/plans/2026-08-26-mosstank-parity-campaign.md
Normal file
|
|
@ -0,0 +1,483 @@
|
|||
# MossTank — VTank parity campaign
|
||||
|
||||
Date: 2026-08-26
|
||||
Status: ACTIVE — MT1 USER-PASSED; MTUI–MT9 functional/API scope complete; connected shelf/shell, accessibility, reconnect, bidirectional peer-expression and two-member fellowship gates passed; #452 root fixed and 30-minute dual-client activation soak passed; hostile/collision gates remain
|
||||
|
||||
Research baseline:
|
||||
`docs/research/2026-08-26-mosstank-vtank-utilitybelt-research.md`
|
||||
|
||||
## Product definition
|
||||
|
||||
MossTank will provide the complete automation capability associated with
|
||||
Virindi Tank, implemented as a first-class acdream plugin over a stable,
|
||||
BCL-only plugin API. UtilityBelt's typed expression dialect is the scripting
|
||||
baseline. Native file formats may differ; behavior and extensibility may not.
|
||||
The finished surface is a visually verbatim VTank reproduction: every VTank
|
||||
tab and function is present and every enabled control invokes real behavior.
|
||||
|
||||
## Non-negotiable boundaries
|
||||
|
||||
- modern code, behavior matched to documented VTank/retail behavior;
|
||||
- one Runtime owner for every state/action; plugin API is a borrowed projection;
|
||||
- policy engines remain in MossTank, not App or Runtime;
|
||||
- plugin UI only through `IUiRegistry`;
|
||||
- every API addition works in graphical and no-window hosts, with explicit
|
||||
unavailable behavior until the host can genuinely supply it;
|
||||
- no fake success and no silent expression-function omission.
|
||||
|
||||
## Slice ledger
|
||||
|
||||
### MT0 — research and campaign design
|
||||
|
||||
- [x] Reconcile existing VTank audit with current Runtime ownership.
|
||||
- [x] Audit current UtilityBelt grammar and all 260 expression declarations.
|
||||
- [x] Define complete capability ledger and staged architecture.
|
||||
|
||||
### MT1 — autocombat foundation (current stop gate)
|
||||
|
||||
- [x] Add target/combat views and attempt commands to the plugin API.
|
||||
- [x] Project canonical hostile, selection, mode, power and spell state.
|
||||
- [x] Implement target lock and range/angle/hybrid selection.
|
||||
- [x] Implement melee/missile charge-release and direct offensive magic.
|
||||
- [x] Deliver the polished combat dashboard and settings.
|
||||
- [x] Focused, App/Runtime and complete solution gates.
|
||||
- [x] Connected user gate: user confirmed autocombat works in the plugin.
|
||||
|
||||
MT1 intentionally does not pretend later features exist. It is “autocombat
|
||||
ported,” not “all combat policy ported.”
|
||||
|
||||
### MTUI — generic plugin-window and VTank shell foundation
|
||||
|
||||
- [x] Add manifest-authenticated, stable plugin panel descriptors without
|
||||
breaking API-v1 hosts/plugins.
|
||||
- [x] Register plugin panels with the common retained window manager so
|
||||
geometry and visibility persist.
|
||||
- [x] Add the shared right-edge plugin shelf and window minimize/restore;
|
||||
hidden panels leave the plugin session and automation running.
|
||||
- [x] Add reusable nested groups, tabs, lamp toggles and sliders to retained
|
||||
plugin markup.
|
||||
- [x] Replace MossTank's dashboard/settings pair with one VTank-shaped shell
|
||||
using the exact Options, Profiles, Vitals, Monsters, Items, Consumables,
|
||||
Buffs, Route, Meta tab order.
|
||||
- [x] Bind all currently enabled controls to real MT1/buff behavior and leave
|
||||
unimplemented tabs visibly disabled.
|
||||
- [x] Enable the Items/Consumables pages against durable, manifest-scoped
|
||||
exact-name profiles; selection and Add/Add-no-buffs/Add-All-Peas controls
|
||||
all mutate the policy consumed by combat.
|
||||
- [x] Connected visual gate: shelf placement, minimize/restore persistence,
|
||||
and first VTank-shell comparison in the live client.
|
||||
|
||||
### MT2 — complete monster/weapon/debuff combat policy
|
||||
|
||||
- [x] ordered `DEFAULT` + first-match monster rules;
|
||||
- [x] priorities -1..4 and complete action-flag matrix;
|
||||
- [x] damage/weapon/offhand selection, swap state machine and auto power,
|
||||
including the official GameInfoDB exact-name overrides, ordered creature-
|
||||
species preferences, and VTank's final elemental fallback;
|
||||
- [x] debuff groups, skill/level choice, receipt-gated reapply and explicit
|
||||
wand switching policy;
|
||||
- [x] ring/arc/bolt density/range logic, streaks, Void, harm/martyr, grenades,
|
||||
lenses, cast-on-strike and pets;
|
||||
(carried phials are complete; crafting a missing phial belongs to MT4's
|
||||
generalized craft transaction);
|
||||
- [x] blacklist and both ghost-monster detectors, including canonical App
|
||||
entity teardown for a detected client ghost.
|
||||
|
||||
MT2 checkpoint 2026-08-27: the BCL API now projects complete learned-combat
|
||||
spell metadata, server cast and physical-attack receipts, health-update
|
||||
revision/age, canonical equipment snapshots/commands, and exact-incarnation
|
||||
ghost deletion. MossTank owns the complete Monsters expression/action model,
|
||||
debuff tracker, elemental/shape spell catalog, range/density selection,
|
||||
weapon/offhand policy, temporary blacklist, and both VTank ghost algorithms.
|
||||
Focused evidence at this checkpoint: 104 MossTank tests, 20 Runtime action/
|
||||
target tests, and an isolated Release App build all pass with zero failures or
|
||||
warnings. MT2 remains open for automatic physical power and the four item-
|
||||
backed combat families.
|
||||
|
||||
MT2 checkpoint 2 (2026-08-27): the official VTank assembly and live GameInfoDB
|
||||
feed were inspected directly. Item appraisal SpellBooks are now retained;
|
||||
plugins receive ordered combat chat and exact item UseDone receipts; the
|
||||
source planner implements `dz.b.CompareTo` for SpellLevel/Skill preference;
|
||||
the 72 official phials, lenses, cast-on-strike weapons and pets are executable
|
||||
and profile-gated; proc success waits for the actual `You cast ... on ...`
|
||||
line. `hi.cs` automatic attack power, including Recklessness clamping, is
|
||||
ported verbatim. Items/Consumables profiles are atomically persisted through a
|
||||
new per-manifest plugin-storage contract. Focused evidence: 131 MossTank tests,
|
||||
the storage/chat/App tests, and an isolated Release App build pass. MT2 remains
|
||||
open only for target-database `Auto` damage selection and the connected gate;
|
||||
missing-grenade crafting is deliberately MT4 transaction scope.
|
||||
|
||||
MT2 automated closeout (2026-08-27): `Auto` now consumes the official 59-name
|
||||
override and 103-species preference tables. The ordered element decision
|
||||
outranks spell shape/tier, drives profiled physical weapon selection, and feeds
|
||||
automatic attack power and vulnerability policy. Unknown targets preserve
|
||||
VTank's final Pierce→Bludgeon→Slash→Acid→Lightning→Cold→Fire fallback. Focused
|
||||
evidence after the closeout and named-profile foundation: 146 MossTank tests;
|
||||
isolated Release App build 0 warnings / 0 errors. The connected MT2 combat
|
||||
matrix remains part of the later combined user gate.
|
||||
|
||||
### MT3 — buff, heal and resource parity
|
||||
|
||||
- [x] named macro profiles, buff exclusions/item buffs/top-off foundation;
|
||||
- [x] all three vital threshold tiers and canonical fellowship vitals;
|
||||
- [x] profiled kits/consumables and worn-item mana recharge;
|
||||
- [x] VTank ManaStone/ManaTank acquisition and exact-receipt fill behavior;
|
||||
- [x] conversions, self/item/fellow dispel response, and critical/normal/idle
|
||||
component plus six-category consumable upkeep.
|
||||
|
||||
### MT4 — inventory, craft and transactions
|
||||
|
||||
- [x] AutoStack/AutoCram and the official 757-row VTank craft database;
|
||||
- [x] generalized use/apply/give/move/split/stack/drop transaction API with
|
||||
receipts and busy arbitration;
|
||||
- [x] retail 0x027D salvage and authoritative current-vendor sale paths;
|
||||
- [x] same-input authoritative split crafting, all three split priorities and
|
||||
exact VTank door/lockpick policy.
|
||||
|
||||
### MT5 — looting and extensible rule engine
|
||||
|
||||
- [x] corpse lifecycle/ID waits, exact 30-attempt/200-second open blacklist,
|
||||
60-minute cache, 100-second public ownership, fellow Share Loot and rare-only
|
||||
policy;
|
||||
- [x] ordered first-match raw/projected-property expressions plus Keep,
|
||||
KeepUpTo, Read, Salvage, Sell, ManaStone, ManaTank and User1–User5;
|
||||
- [x] canonical appraisal/pickup/salvage/vendor seams, unknown-scroll fallback,
|
||||
and exact VTank salvage workmanship bands with 40-attempt abandonment;
|
||||
- [x] independent By-char and named native loot profile documents;
|
||||
- [x] exact VTClassic `.utl` v0/v1 importer/exporter, every structured
|
||||
requirement, forward-compatible length blocks, and profile-owned salvage
|
||||
ranges/value modes;
|
||||
- [x] external loot-classifier plugin capability.
|
||||
|
||||
MT5 functional closeout (2026-08-27): the graphical host now exposes corpse
|
||||
discovery, raw item properties, canonical appraisal/pickup, learned-spell
|
||||
membership, fellowship Share Loot, retail salvage (0x027D), and current-vendor
|
||||
sale through additive BCL-only interfaces. MossTank owns all policy and waits
|
||||
for authoritative receipts/object removal; no action reports success at
|
||||
dispatch. The official VTank corpse timers, rare/fellow ownership branches,
|
||||
unknown-scroll difficulty check, mana-stone pairing, salvage-bag workmanship
|
||||
bands, and bugged-bag retry ceiling were ported from the official decompiled
|
||||
source. Focused evidence: 184 MossTank tests, 21 inventory-wire/session tests,
|
||||
134 App automation/item/UI tests, and an isolated Release App build with zero
|
||||
warnings/errors. File interoperability remains an MT9 compatibility tail, not
|
||||
a reason to hold Route/Navigation.
|
||||
|
||||
### MT6 — navigation
|
||||
|
||||
- [x] canonical move/follow/turn/charged-jump/checkpoint host primitives;
|
||||
- [x] circular, linear, once and Target/follow routes, including VTank's
|
||||
endpoint reversal, destructive Once traversal and follow-around-corners;
|
||||
- [x] every decoded nav node (0..9), closed-door/lockpick policy, vendor and
|
||||
repeated NPC use, portal re-entry protection and combat/nav priority;
|
||||
- [x] independent By-char and named native route profiles;
|
||||
- [x] exact `uTank2 NAV 1.2` importer/exporter.
|
||||
|
||||
MT6 functional closeout (2026-08-27): the additive navigation API projects
|
||||
VTank coordinates, live and server-accepted player position, object
|
||||
reacquisition, door state, portal state, and typed movement levels through the
|
||||
one Runtime command interpreter. MossTank owns the exact four route modes and
|
||||
ten node types. Steering ports `fd.cs`'s 4° turn threshold, far 45° and near
|
||||
15° forward cones; checkpoints use `gr.cs`'s accepted-position gate and
|
||||
15-second nudge; Target mode ports `gl.cs` breadcrumb pruning; doors port
|
||||
`b7.cs`'s defaults (disabled, 20 m ID, 4 m open, −50 lockpick threshold).
|
||||
Portal2/UseNPC reacquire exact-name objects near the saved point, NPC use waits
|
||||
for tell/give chat, jumps align to their stored heading before charge/release,
|
||||
and Once removes completed rows exactly like VTank. Evidence: 204 MossTank
|
||||
tests, focused App navigation projection tests, and isolated Release App build
|
||||
with zero warnings/errors. Legacy file interop remains an MT9 compatibility
|
||||
tail and does not hold the expression engine.
|
||||
|
||||
### MT7 — expressions
|
||||
|
||||
- [x] immutable AST, typed values, budgets and diagnostics;
|
||||
- [x] UtilityBelt grammar semantics including lists/dicts/slices;
|
||||
- [x] implement/alias/explicitly disposition the 260-function audit ledger;
|
||||
- [x] VTank option/expression command diagnostics;
|
||||
- [x] parser, evaluator, persistence and capability-security gates.
|
||||
|
||||
### MT8 — meta engine and runtime views
|
||||
|
||||
- [x] complete condition/action vocabulary, nested composition, once-per-entry,
|
||||
call/return and watchdog;
|
||||
- [x] chat capture variables and option access;
|
||||
- [x] plugin-authored runtime views over the retained markup contract;
|
||||
- [x] native meta profile;
|
||||
- [x] exact VTank CondAct `.met` importer/exporter, including recursive rules,
|
||||
embedded NAV and the historical CreateView record quirk.
|
||||
|
||||
### MT9 — fellowship, profiles, commands and polish
|
||||
|
||||
- [x] tell-driven recruitment, waiting-list, status/location commands, and
|
||||
two-minute kick/ban/giveleader/setopen voting over canonical fellowship
|
||||
commands;
|
||||
- [x] helper healing, fellowship corpse permissions and shared target views;
|
||||
- [x] macro-profile foundation: true per-character `By char` documents, named
|
||||
create/copy/clear/select, mine-only filtering, hot loading, atomic manifest-
|
||||
scoped storage, and complete current combat/buff/vitals/monster/item state;
|
||||
- [x] independent navigation/loot/meta profile documents remain with MT5/MT6/MT8;
|
||||
- [x] exact 137-name typed VTank option catalog/defaults and durable
|
||||
`/vt opt setinall` across every indexed named/character macro profile;
|
||||
- [x] all documented `/vt` command names are locally registered and handled;
|
||||
- [x] exact `.nav`, `.met`, and `.utl` dumps/import-export;
|
||||
- [x] privileged debug-operation semantics (`clearlocks`, `clearbusy`,
|
||||
`fakeimp`) use canonical owners and authoritative lifetime cleanup;
|
||||
- [x] first-run guidance, native/VTank profile migration and corrupt-profile
|
||||
recovery with append-only raw-data preservation;
|
||||
- [x] accessibility and scaling polish;
|
||||
- [ ] performance soak, reconnect/lifecycle and multi-client gates.
|
||||
|
||||
## MT1 execution order
|
||||
|
||||
1. Add BCL-only combat records/interfaces with inert defaults.
|
||||
2. Extend Runtime hostile query with exact position/heading snapshots.
|
||||
3. Bind App's automation surface to the canonical action/spell owners.
|
||||
4. Implement/test MossTank's deterministic combat controller.
|
||||
5. Replace the small panel with dashboard/settings markup and generic markup
|
||||
affordances needed by the design.
|
||||
6. Run narrow tests, Release build, broad tests; record exact evidence here.
|
||||
|
||||
## Closeout evidence
|
||||
|
||||
MT1 code-complete 2026-08-26 and user-passed 2026-08-27. The additive BCL-only contract is
|
||||
`CombatAutomation.cs`; older API-v1 implementations retain inert default
|
||||
members. `AppAutomationSurface` borrows the canonical Runtime owners and
|
||||
projects hostile captures, combat state, physical press/release attempts,
|
||||
targeted casting and learned direct offensive spells. MossTank's
|
||||
`CombatController` owns priority, target lock, range/angle/both selection,
|
||||
mode entry, power-bar timing and magic choice. The dashboard/settings markup
|
||||
uses the retained plugin registry; generic markup now supports bound child
|
||||
visibility/enabled state and button colors.
|
||||
|
||||
Automated evidence:
|
||||
|
||||
- focused MossTank: 54 passed / 0 failed;
|
||||
- complete Runtime: 1,854 passed / 0 failed;
|
||||
- repository-owned hermetic Release gate: **15,775 passed / 0 skipped /
|
||||
0 failed across 14 assemblies**;
|
||||
- Release build: 0 warnings / 0 errors;
|
||||
- the original MossTank XML documents parsed successfully before the gate.
|
||||
|
||||
MTUI code-complete 2026-08-27. `PluginPanelDescriptor` and authenticated
|
||||
`PluginUiOwner` carry presentation metadata through Core's transactional
|
||||
plugin lifetime; App mounts the stable panel as a `RetailWindowHandle` and the
|
||||
generic `PluginSidePanel` owns only hide/restore UI. The one-window MossTank
|
||||
shell uses real retained tabs/toggles/sliders. Focused evidence: 18 App/plugin
|
||||
tests and 56 MossTank tests passed; isolated Release App build passed with
|
||||
0 warnings / 0 errors. Broader hermetic evidence: Core 4,720/4,720 and Runtime
|
||||
1,854/1,854 passed; App passed 6,441/6,442 with the sole failure in the
|
||||
unrelated pre-existing landblock recenter assertion
|
||||
`OriginRecenter_RetryPreservesLiveIdentityAndDoesNotRescueReusedGuid`. Its
|
||||
connected visual gate remains open.
|
||||
|
||||
MT3/MT4 resource closeout 2026-08-27: crafting now runs through VTank's three
|
||||
ordered tiers: critical component/consumable recovery, normal component and
|
||||
general profile crafting, then no-target idle component and six-category
|
||||
kit/food stock targets. Same-input recipes wait for both the authoritative
|
||||
split receipt and publication of two distinct stacks before applying. The
|
||||
official `IdleCraftCount_*` underscore names, 4/20/20 component defaults, and
|
||||
2/2/2 kit plus 15/15/15 food targets persist in named/By-char profiles.
|
||||
Self-cast and item dispels port `c8.cs`/`cx.cs`; fellowship Awakener selection
|
||||
ports `af.cs`, including exact training, Arcane Lore, 5 m, spell-3179 and
|
||||
summed-vulnerability-quality gates. The additive shared duration-spell ledger
|
||||
matches VTank's confirmed local/external `LogSpellCast` model and clears on
|
||||
session detach. Evidence: 277/277 MossTank tests, 12/12 focused App automation
|
||||
tests, and isolated Release App build with zero warnings/errors.
|
||||
|
||||
MT7–MT9 checkpoint 2026-08-27: MossTank registers all 260 audited
|
||||
UtilityBelt public expression names over the typed evaluator, and the Meta
|
||||
runtime/editor, dynamic views, embedded routes, command execution and durable
|
||||
variable scopes are integrated. The host now provides an unload-safe generic
|
||||
plugin-command registry; `/vt` follows the same local command route from typed
|
||||
chat, launcher login commands and no-window clients. The exact official
|
||||
four-line command catalog and 137-row typed option database are present;
|
||||
`setinall` rewrites every indexed named/character profile. Run Macro is now a
|
||||
master lifecycle distinct from Enable Combat, and command jumps align before
|
||||
charging. The additive fellowship API projects the canonical retail commands;
|
||||
MossTank owns VTank's tell commands, wait list, spam limit, near-player
|
||||
recruitment, leader transition cleanup and two-minute voting. Evidence at this
|
||||
checkpoint: 261/261 MossTank tests, 18/18 runnable focused App/plugin tests,
|
||||
and isolated Release App build with zero warnings/errors. Four additional
|
||||
GraphicalPluginSession tests could not locate the repository when deliberately
|
||||
run from an isolated OutputPath; this is test-harness path behavior, not a
|
||||
product failure. Connected shelf/UI/fellowship and combined automation gates
|
||||
remain open.
|
||||
|
||||
Legacy-profile checkpoint 2026-08-27: native JSON remains MossTank's durable
|
||||
working format, while every save also emits a genuine VTank compatibility
|
||||
file. `uTank2 NAV 1.2` routes and CondAct `.met` files round-trip exactly;
|
||||
the Meta writer was independently accepted and canonicalized byte-identically
|
||||
by the public `metaf` reference compiler. VTClassic `.utl` v0/v1 now retains
|
||||
length-delimited unknown requirements/blocks, executes all 31 published
|
||||
requirement types (including the DAT-resolved ordered-palette color family),
|
||||
and applies per-material salvage ranges/value modes to the real 0x027D combine
|
||||
planner. Native-only text rules export disabled rather than becoming
|
||||
VTClassic's dangerous empty-requirement match-all. Evidence: 290/290 MossTank
|
||||
tests, 13/13 focused App/plugin tests, and isolated Release App build with zero
|
||||
warnings/errors.
|
||||
|
||||
External-loot checkpoint 2026-08-27: the BCL-only host now owns an unload-safe
|
||||
classifier registry. Classifier ids are namespaced to the registering plugin,
|
||||
all registrations are disposed transactionally with that plugin's session,
|
||||
and exceptions are isolated at the registry boundary. MossTank exposes the
|
||||
available engines in Profiles, persists the selection with the macro profile,
|
||||
and runs Keep/KeepUpTo/Read/Salvage/Sell/User1–User5 decisions through its
|
||||
existing authoritative corpse executor. An unavailable engine never silently
|
||||
changes policy by falling back to VTClassic. Evidence: 2 focused Core registry
|
||||
tests, 55 focused MossTank loot/panel/markup tests, and isolated Release App
|
||||
build with zero warnings/errors.
|
||||
|
||||
Options/debug checkpoint 2026-08-27: the VTank Options page now uses the
|
||||
verbatim four-column control arrangement. Normal automatic rebuff, the
|
||||
separate idle top-off window, Attack→Approach distance navigation, and final
|
||||
Idle Peace fallback were ported from `fz.cs`, `cLogic.cs`, `g8.cs`, `eb.cs`
|
||||
and `cm.cs`; Force Buff and Cancel Force Buff remain distinct actions. The
|
||||
Advanced Options button opens the full ordered 137-setting table. `/vt
|
||||
clearbusy` decrements exactly one Runtime-owned inventory busy reference,
|
||||
`clearlocks` clears only MossTank's transient policy locks, and `fakeimp`
|
||||
records VTank's local 3,000-second Gossamer Flesh debug marker without forging
|
||||
a server cast. External classifiers now receive authoritative `OnLooted` and
|
||||
`OnItemRemoved` lifecycle callbacks after inventory publication. Evidence:
|
||||
298/298 MossTank tests and an isolated Release App build with zero warnings
|
||||
and zero errors.
|
||||
|
||||
Final automated API/options checkpoint 2026-08-27: every one of the 137
|
||||
official advanced-option names has an explicit writable live-policy mapping;
|
||||
the full catalog, official defaults, case-insensitive lookup and durable
|
||||
profile propagation are covered. The Monsters page now exposes the three
|
||||
distinct official cycles for Damage type, Ex. Vuln and PetDmg rather than one
|
||||
shared internal enum. Prismatic remains an ammunition policy while preserving
|
||||
automatic magic-element selection; Fists uses Tusker Fists only while its
|
||||
enchantment is active. `DoJiggle` now ports VTank's PreviousSelection followed
|
||||
by alternating NextPlayer/PreviousPlayer at 131 ms and no longer moves the
|
||||
character. `ShowCollisionDebug` publishes bounded projectile samples through
|
||||
the BCL-only API and renders transient red/green markers in the retained UI.
|
||||
`WhoYouGonnaCall` is intentionally stored but inert, matching the official
|
||||
source's explicit `No Function` disposition.
|
||||
|
||||
The plugin API now projects combat, magic, equipment/items, looting,
|
||||
fellowship, enchantments, navigation, world objects/time, login, network peer
|
||||
state, recovery, projectile diagnostics and selection through canonical
|
||||
Runtime/App owners. Startup peer tags are parsed once by `RuntimeOptions`,
|
||||
portable data paths come from `ApplicationPathSet`, and both graphical and
|
||||
headless plugin hosts load fixtures correctly from isolated output graphs.
|
||||
Latest hermetic evidence: App 6,592 passed / 94 environment-dependent skips;
|
||||
Runtime 1,863/1,863; Core 4,911/4,911; Core.Net 1,042/1,042; Headless
|
||||
171/171; UI abstractions 880/880; MossTank 320/320 — **15,779 passed, zero
|
||||
failed** across the selected automated lanes. The Release App build completed
|
||||
with zero warnings and zero errors. Excluded gates are explicit: manual/live
|
||||
lanes, Linux-only tests on this Windows host, the machine-local stale bake-tool
|
||||
4 PAK test, and one registered pre-existing tower-ascent known failure. The
|
||||
generic shelf, VTank shell, minimization-while-running, reconnect, live combat,
|
||||
multi-client peer expressions, and collision-marker appearance remain owed in
|
||||
the combined connected user gate.
|
||||
|
||||
Connected shelf/shell gate 2026-08-27: the first isolated Release launch found
|
||||
that App's plugin-copy target still assumed each plugin's conventional `bin`
|
||||
directory when a custom `OutputPath` was active. That caused the packaged
|
||||
MossTank DLL/markup to be stale even though the root build outputs were current.
|
||||
Build and publish now resolve both first-party plugin targets through MSBuild's
|
||||
`GetTargetPath`; MossTank markup copies directly from its source. The rebuilt
|
||||
package's MossTank DLL and XML matched their build/source SHA-256 hashes and
|
||||
the boundary regression passed 5/5.
|
||||
|
||||
The next live launch exposed a retained-markup contract mismatch: one field
|
||||
reused an `Action` button binding where `onsubmit` requires `Action<string>`,
|
||||
preventing the complete plugin window from mounting. MossTank now has a typed
|
||||
submit action and its markup contract test validates every interactive binding's
|
||||
delegate shape. A later visual pass also caught three unsupported inline label
|
||||
bindings on Meta; all are now whole-value properties, and the contract rejects
|
||||
future inline interpolation. Focused MossTank evidence is 321/321; isolated
|
||||
Release build `app-release22` is zero-warning/zero-error with exact packaged
|
||||
artifact hashes.
|
||||
|
||||
The connected `app-release22` gate then passed: all nine tabs mounted and were
|
||||
visually inspected; Meta rendered `State: Default`, `N: 0`, and `N2: 0`; the
|
||||
right-edge `MT` shelf button was fully reachable; minimize hid only the window;
|
||||
while hidden the live buff pass advanced from 91/97 to 77/97; restore showed
|
||||
`Stop Macro` and the changed live status; the macro stopped normally. Logs show
|
||||
92 server-confirmed `UseDone err=0` casts and no plugin/UI exception. Shift+Esc
|
||||
completed the full logout presentation and returned to character selection.
|
||||
This supersedes the earlier statement that the shelf, shell, minimization, and
|
||||
basic reconnect/lifecycle presentation were wholly unproven. At that checkpoint,
|
||||
still owed were
|
||||
the accessibility/scale closeout, longer performance/reconnect soak, live
|
||||
hostile combat matrix, two-client peer expressions/fellowship, and collision-
|
||||
marker appearance.
|
||||
|
||||
Accessibility/reconnect/peer checkpoint 2026-08-27: textless and terse controls
|
||||
now carry runtime-bound retained tooltips, and the common window owner clamps
|
||||
plugin panels to the current viewport (including the 800x600 oversize case).
|
||||
Focused evidence is 325/325 MossTank tests, 16/16 retained-UI tooltip/geometry
|
||||
tests, and isolated Release `app-release23` with zero warnings/errors. The live
|
||||
client displayed the Monster Range help text, completed a same-character
|
||||
logout/re-entry, restarted the macro, and completed another 92 server-confirmed
|
||||
casts. Working/private memory stayed approximately 1.59/1.84 GiB across the
|
||||
combined soak rather than climbing with casts or reconnect.
|
||||
|
||||
The local peer API also passed real two-process expressions in both directions:
|
||||
the secondary `+Horan` evaluated
|
||||
`dictgetitem[listgetitem[netclients['mosstank-guard-primary'],0],'Name']` and
|
||||
received `+Acdream`, while the earlier reciprocal gate returned `+Horan` to
|
||||
the primary; both heartbeat documents contained the expected names, tags,
|
||||
vitals and positions.
|
||||
|
||||
That broader gate exposed separate client defect #452. First-chance cdb proof
|
||||
located it in GLFW's Win32 event pump: temporary cross-process input-queue
|
||||
attachment let `GetActiveWindow` return the other acdream process's HWND;
|
||||
GLFW's shared `L"GLFW"` property then returned the other process's private
|
||||
`_GLFWwindow*`, which the caller dereferenced. `app-release24` installs the
|
||||
current-process HWND guard at GLFW's own import slot before `glfwInit`; its four
|
||||
focused tests pass. Two rebuilt graphical clients then entered world, survived
|
||||
100 rapid forced activation switches—the exact old trigger—and remained
|
||||
responsive through a 30-minute combined soak with no native error. Issue #452
|
||||
remains in-progress only until both sessions complete a graceful-exit gate.
|
||||
|
||||
The secondary-owned fellowship gate also passed: `+Acdream` created
|
||||
`mosstankgate`, `+Horan` joined, both canonical rosters contained both members,
|
||||
and the secondary evaluated `getfellowshipcount[]` as `2`.
|
||||
|
||||
Still owed here: the hostile combat matrix and collision-marker appearance.
|
||||
|
||||
Final local validation checkpoint 2026-08-27: the complete Release solution
|
||||
build passed with zero warnings and zero errors. Focused MossTank passed
|
||||
325/325 and the App plugin/API/UI/GLFW set passed 35/35. The conservative
|
||||
Windows hermetic filter passed 15,083 non-network tests; Core.Net then passed
|
||||
1,042/1,042 in its isolated lane, for 16,125 passing selected tests. The first
|
||||
max-parallel combined invocation made Core.Net's timing-sensitive two-percent
|
||||
packet-loss soak exhaust its wall-clock headroom; the same case and complete
|
||||
Core.Net lane passed immediately when isolated. No MossTank, plugin API, plugin
|
||||
UI, Runtime-owner, or #452 guard test failed.
|
||||
|
||||
Live hostile discovery checkpoint 2026-08-27: the first surrounded-monster
|
||||
gate exposed two coupled compatibility defects. Retail's classic `* Lure`
|
||||
vulnerability names were absent from the debuff classifier, so an attack-only
|
||||
profile could misclassify Piercing Lure's "piercing damage" description as a
|
||||
direct attack. The classifier now recognizes all seven classic elemental Lure
|
||||
families (while excluding the distinct Lure Blade item spell), and the attack
|
||||
catalog defensively rejects every host-authored debuff. Target evaluation also
|
||||
now ports official `dz::a`'s previous-target tie-break after priority and manual
|
||||
TargetLock: a valid chosen monster remains selected while the character turns,
|
||||
instead of angle rescans alternating between surrounding monsters. The new
|
||||
Lure/attack and target-stability regressions bring the focused MossTank lane to
|
||||
337/337. Connected re-test remains part of the hostile combat gate.
|
||||
|
||||
## Requirement-level completion audit (2026-08-27)
|
||||
|
||||
Completion is deliberately **not** claimed while live evidence remains missing.
|
||||
The authoritative requirement/evidence map is:
|
||||
|
||||
| Objective requirement | Current evidence | Audit result |
|
||||
| --- | --- | --- |
|
||||
| Functionally complete VTank behavior | MT2–MT9 implementation ledger; 337 MossTank behavior/format/expression tests; connected MT1 autocombat acceptance | Proven for implemented policy and formats; the combined hostile physical/magic matrix remains live-unproven |
|
||||
| Visually verbatim nine-tab VTank surface | `mosstank.xml` contains the exact Options, Profiles, Vitals, Monsters, Items, Consumables, Buffs, Route, Meta order; all nine tabs mounted in `app-release22` | Proven for shell/tab presence and first comparison; projectile debug-marker appearance remains live-unproven |
|
||||
| Every visible control has real behavior | 190 interactive controls expose 202 bindings (191 unique); `MossTankMarkupContractTests` resolves every binding, verifies delegate shape, and rejects handlerless controls; 137/137 advanced options have explicit writable mappings | Proven statically and by focused controller tests. `WhoYouGonnaCall` intentionally stores its value but performs no action because the official VTank source labels it `No Function` |
|
||||
| Generic plugin sidepanel; minimizing must not stop plugins | retained `PluginSidePanel`/window-manager tests plus connected hide/restore gate where the hidden buff pass advanced from 91/97 to 77/97 | Proven |
|
||||
| Modern acdream plugin APIs over canonical owners | additive BCL-only combat, magic, equipment, item, loot, fellowship, enchantment, navigation, object, world-time, login, network, recovery, projectile, selection, storage, command and classifier contracts; 35 focused App/API/UI tests and 16,125 selected Release tests | Proven for the graphical live host; older/no-window implementations explicitly report unavailable and never fabricate success |
|
||||
| UtilityBelt-compatible expression superset | immutable evaluator tests; all 260 audited public names registered; host-action, object, fellowship, time, login/network, UI, persistence, collection and meta tests | Proven by catalog and semantic family tests; bidirectional two-client network expressions passed live |
|
||||
| Lifecycle, reconnect, multi-client stability | same-character reconnect and hidden execution passed; peer expressions and two-member fellowship passed; #452 exact trigger survived 100 focus switches and a 30-minute dual-client soak | Proven through soak; #452 cannot close until both current sessions exit gracefully |
|
||||
|
||||
Open completion gates: (1) hostile physical and offensive-magic behavior against
|
||||
a live target at valid configured range; (2) visible green/red projectile
|
||||
collision markers with `ShowCollisionDebug`; (3) graceful exit of both current
|
||||
soak clients with no native or managed failure. These are evidence gaps, not
|
||||
redefined-away acceptance criteria.
|
||||
164
docs/plans/2026-08-27-pak-v2-resource-campaign.md
Normal file
164
docs/plans/2026-08-27-pak-v2-resource-campaign.md
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
# PAK v2 resource campaign
|
||||
|
||||
Status: CLOSED — SHIPPED (2026-08-27)
|
||||
|
||||
## Objective and release gates
|
||||
|
||||
Ship one crash-safe prepared-asset format migration that:
|
||||
|
||||
- reduces the complete installed package from 29,908,271,024 bytes to at
|
||||
most 5 GiB;
|
||||
- preserves decoded geometry, material metadata, texture bytes, deterministic
|
||||
baking, corruption isolation, and random-access loading;
|
||||
- does not regress cold or warm world-reveal latency or frame-time percentiles;
|
||||
- reduces offline bake time and live client CPU/GPU memory where the data
|
||||
permits it, without changing the rendered result; the one-time bake remains
|
||||
bounded for the user-confirmed 16-32 GiB target machines;
|
||||
- gives launcher users a clear one-time update message and progress, while
|
||||
retaining the last verified package until the replacement is validated;
|
||||
- passes two different worker-count bakes with identical SHA-256, the complete
|
||||
installed-DAT bake, content equivalence, performance, solution, Windows CI,
|
||||
and release gates.
|
||||
|
||||
## Measured format-1 baseline
|
||||
|
||||
The installed package was parsed from its actual TOC, not estimated:
|
||||
|
||||
| Partition | Physical blobs | Physical bytes |
|
||||
|---|---:|---:|
|
||||
| GfxObj render meshes | 15,318 | 9,306,115,868 |
|
||||
| Setup render meshes | 5,935 | 4,078,139 |
|
||||
| EnvCell render meshes | 17,117 | 20,232,745,510 |
|
||||
| All collision payloads | 12,938 | 30,392,894 |
|
||||
| EnvCell topology | 729,888 | 255,512,622 |
|
||||
| TOC | 2,232,170 rows | 53,572,080 |
|
||||
|
||||
Total: 29,908,271,024 bytes. Render payloads account for approximately
|
||||
29.54 GB and 99% of physical payload bytes. The collision and index data are
|
||||
not the size problem. Format 1 already aliases duplicate complete EnvCell
|
||||
blobs, but each remaining mesh embeds another copy of every decoded RGBA
|
||||
texture it uses.
|
||||
|
||||
Historical complete-bake baseline: 80.5-107.5 seconds, 4.43-4.89 GB peak
|
||||
working set, and 3.81-4.21 GB peak private bytes.
|
||||
|
||||
## Format 2 contract
|
||||
|
||||
The 64-byte header and 24-byte sorted TOC row remain fixed. Format version is
|
||||
2 and bake recipe is 6.
|
||||
|
||||
1. A new `TexturePayload` key partition (type 8) owns globally shared texture
|
||||
byte arrays. Mesh payloads store the texture payload key while retaining
|
||||
their own exact dimensions, format, upload metadata, surface identity,
|
||||
translucency, culling, and index data.
|
||||
2. Texture payload keys are the first 56 bits of SHA-256 under the type-8
|
||||
namespace. The writer retains the full SHA-256 digest and length for every
|
||||
unique texture, making even a truncated-key collision a loud bake failure
|
||||
rather than silent substitution without pinning another copy of all bytes.
|
||||
3. Every physical blob is independently encoded. The high bit of the TOC
|
||||
length marks compression; the low 31 bits are the stored length. A
|
||||
compressed blob contains a four-byte decoded-length prefix followed by
|
||||
Brotli. Small or insufficiently compressible blobs remain exactly raw.
|
||||
CRC-32 covers stored bytes, then decompression is independently validated.
|
||||
4. Random access remains one binary search plus one mmap copy for raw blobs.
|
||||
Compressed blobs add decompression only when the writer proved a material
|
||||
size win. Texture payloads use a bounded, thread-safe 64 MiB / 1,024-entry
|
||||
LRU; concurrently decoded meshes converge on one shared array instance.
|
||||
5. Whole-file compression is forbidden. It would destroy random access and
|
||||
make a small world reveal depend on unrelated content.
|
||||
6. Unedited DAT DXT1/3/5 surfaces retain their exact BC1/2/3 source blocks
|
||||
through bake, mmap, and Vulkan upload. Clip maps and surfaces with authored
|
||||
translucency still decode to RGBA8 because their per-surface alpha edits
|
||||
require pixels. This is smaller and closer to retail's hardware DXT path
|
||||
than the former unconditional software decode.
|
||||
|
||||
## Determinism and publication
|
||||
|
||||
Asset traversal and mesh serialization remain sorted. A texture is emitted at
|
||||
its first deterministic encounter, so its physical order is independent of
|
||||
worker completion order. Aliases preserve the source row's exact offset,
|
||||
encoded length/flags, and CRC.
|
||||
|
||||
Recipe 5 to 6 is a mandatory full rebuild. The launcher builds
|
||||
`acdream.pak.candidate` beside the active package, validates format, recipe,
|
||||
DAT iterations, TOC counts, size, completion protocol, and SHA-256, then uses
|
||||
the existing atomic promotion/backup transaction. Cancellation or failure
|
||||
keeps the verified format-1 package. No overlay may cross this format change.
|
||||
|
||||
## Complete installed-DAT evidence
|
||||
|
||||
The first installed-DAT mixed sample (four GfxObj, three Setup, three EnvCell,
|
||||
all corresponding collision/topology payloads) produced 58 keys, 29 globally
|
||||
deduplicated texture payloads, and 57 physical blobs. Decoded payload was
|
||||
3.5 MiB and stored payload 1.0 MiB (3.62x); output was 1.0 MiB. Eight-worker
|
||||
and three-worker bakes had the identical SHA-256
|
||||
`78886DFA28A3EDF9368A1E25C9B02A3B69ADC5DFCC01358D64A073B549B5B532`.
|
||||
|
||||
The final complete four-worker and nine-worker bakes are byte-identical:
|
||||
|
||||
- 2,237,866 logical keys and 786,892 physical blobs;
|
||||
- 5,696 globally shared texture payloads;
|
||||
- zero extraction/validation failures;
|
||||
- 597,229,424 bytes (569.6 MiB), down 98.0% from 29,908,271,024 bytes;
|
||||
- SHA-256
|
||||
`37BC0EA1778F899AF9E3B2397937D373F69D615B15D3E32041BF389D93B624BF`;
|
||||
- 28.7 seconds for the final four-worker bake versus 79.1 seconds for the
|
||||
same-machine recipe-5 format-1 baseline (63.7% faster).
|
||||
|
||||
The exact before/after connected lifecycle route used the same machine,
|
||||
server, 1280x720 Vulkan presentation, retail render pack, and matching client
|
||||
code. Both runs passed fresh login, multi-world portal travel, same-location
|
||||
revisit, fresh-process reconnect, and graceful teardown.
|
||||
|
||||
| Matching live measurement | Format 1 | Format 2 | Change |
|
||||
|---|---:|---:|---:|
|
||||
| Heavy-route final working set | 2,621.9 MiB | 1,363.9 MiB | -48.0% |
|
||||
| Heavy-route final private bytes | 2,470.5 MiB | 1,825.6 MiB | -26.1% |
|
||||
| Holtburg prepared-mesh GPU bytes | 229.6 MiB | 170.1 MiB | -25.9% |
|
||||
| Fresh reconnect working set | 974.1 MiB | 869.0 MiB | -10.8% |
|
||||
| Fresh reconnect private bytes | 1,253.8 MiB | 1,166.6 MiB | -7.0% |
|
||||
| Fresh reconnect to checkpoint | 59.3 s | 59.3 s | equal |
|
||||
|
||||
Rynthid and Facility transition times were equal; Holtburg completed 2.9 s
|
||||
faster; Aerlinthe revisit differed by 0.1 s. Matching stable CPU p50/p95 rows
|
||||
were equal or slightly faster and GPU time remained within 0.1 ms. The first
|
||||
raw login samples were excluded from comparison because the server started
|
||||
them in different cells with 6,671 versus 11,799 world entities. Matching
|
||||
screenshots preserve geometry, materials, lighting, transparency, texture
|
||||
detail, and mip behavior.
|
||||
|
||||
Automated gates at this checkpoint: 181/181 installed-DAT Content tests,
|
||||
16,151/16,151 CI-filtered Windows tests, Release build with zero warnings, and
|
||||
the authoritative connected lifecycle/reconnect gate all pass.
|
||||
|
||||
## Release closeout
|
||||
|
||||
Main fast-forwarded cleanly to `45ba42a3`. Gitea Actions run 206 passed the
|
||||
Windows gate, Linux portable gate, and release job. The published alpha is
|
||||
`0.1.0-build.202608271848`; the stable `latest` pointer carries the 45 MiB
|
||||
Windows client, 78 MiB Windows launcher, and update manifest.
|
||||
|
||||
The first remote run exposed one locale-only contract failure: the launcher's
|
||||
disk-space guidance rendered `2,0 GiB` under the runner culture. The shipped
|
||||
fix formats the value invariantly and exercises the failure path under
|
||||
`sv-SE`; the exact follow-up local Release gate again passed 16,151/16,151.
|
||||
The exact shipped App binary also passed the connected lifecycle/reconnect
|
||||
route (`connected-world-gate-20260827-204249`) with only the 25 expected
|
||||
world-edge misses.
|
||||
|
||||
## Work ledger
|
||||
|
||||
- [x] Measure the format-1 package by TOC partition.
|
||||
- [x] Implement and unit-test format-2 external texture references, adaptive
|
||||
independent compression, corruption handling, bounded sharing, and byte
|
||||
determinism.
|
||||
- [x] Integrate format-2 accounting and strict validation into the bake.
|
||||
- [x] Publish the recipe-6 mandatory full-rebuild launcher migration.
|
||||
- [x] Add launcher disk-space preflight and explicit long-work detail.
|
||||
- [x] Complete installed-DAT equivalence and dual-worker full bakes.
|
||||
- [x] Measure/tune package size, bake time, read CPU/allocations, cold
|
||||
and warm reveal latency, and frame-time percentiles.
|
||||
- [x] Evaluate source-native BC texture retention only if it remains visually
|
||||
exact and does not shift mip-generation work into the reveal frame.
|
||||
- [x] Pass complete local tests and authoritative connected gates.
|
||||
- [x] Pass Gitea Windows CI, merge, push, and release gates.
|
||||
|
|
@ -31,6 +31,20 @@ the remaining polish on acdream's `CharacterStatController` (LayoutDesc 0x210000
|
|||
- **Selected row (Strength):** highlighted with a **DARKER background + bars above/below** — retail's
|
||||
selected-row sprite `0x06001397` (Button state 6). (✗ acdream uses a translucent GOLD tint — replace
|
||||
with the dark-bar sprite.)
|
||||
|
||||
> **CORRECTION 2026-08-24 (Campaign CT, CT1 fix round).** The sprite id
|
||||
> above is WRONG for this element. Campaign CT's live-DAT probe
|
||||
> (`docs/research/2026-08-24-campaign-ct-dat-ground-truth.md`, §2
|
||||
> "SEALED VERDICT") found the attribute/skill row's actual Highlight
|
||||
> media is `0x06000F93`, drawn via `gmAttributeUI::UpdateSelection
|
||||
> @0x0049DEE0`'s `SetState(6)` → `InfoRegion::SetState @0x004F0EE0` on
|
||||
> the row template `0x10000248` itself. `0x06001397` is real, but it
|
||||
> belongs to a DIFFERENT mechanism: the spellbook row's separate
|
||||
> selected-overlay child element (`0x10000342` under prototype
|
||||
> `0x10000343`, via `UIElement_UIItem::SetSelectedState @0x004E1240`).
|
||||
> This note's "Button state 6" framing was accidentally right about the
|
||||
> STATE number but wrong about which sprite that state resolves to on
|
||||
> this element. CT5 is the owning slice for the fix.
|
||||
- **Footer flips to:**
|
||||
- Title: **"Strength: 200"** — **WHITE** text (✗ acdream uses the body/gold color).
|
||||
- "Experience To Raise:" + **"Infinity!"** (Strength is maxed → cost is infinite; ✗ acdream shows a
|
||||
|
|
@ -45,7 +59,8 @@ the remaining polish on acdream's `CharacterStatController` (LayoutDesc 0x210000
|
|||
3. [ ] Add "Total Experience (XP):" caption.
|
||||
4. [ ] Add "XP for next level:" caption + value (un-consume from the meter, or render alongside).
|
||||
5. [ ] Row text larger (≈icon height) + rows tighter.
|
||||
6. [ ] Selection highlight → sprite 0x06001397 (dark bars), not gold tint.
|
||||
6. [ ] Selection highlight → sprite 0x06000F93 (dark bars), not gold tint. (Corrected 2026-08-24 —
|
||||
see the CORRECTION note above; the sprite id in this checklist item was originally 0x06001397.)
|
||||
7. [ ] Selected footer title → white.
|
||||
8. [ ] Maxed attribute → "Experience To Raise: Infinity!".
|
||||
9. [ ] Footer title wording = "Select an Attribute to Improve" (Attribute).
|
||||
|
|
|
|||
|
|
@ -337,44 +337,18 @@ nothing wrong" surprise, matching the register's existing framing of the
|
|||
|
||||
### B.2 — Double-click
|
||||
|
||||
**No dedicated double-click-to-buy mechanism was found for vendor shop
|
||||
items.** Evidence, not absence-of-search:
|
||||
**Corrected 2026-08-26:** the original symbol-name search missed the real
|
||||
mechanism. Retail handles this inside the general
|
||||
`gmVendorUI::HandleMousePresses @ 0x004C40D0`; it does not require a separately
|
||||
named `CheckForDoubleClick` function. In the Items-list branch, the retail
|
||||
double-click condition directly calls `gmVendorUI::BuySingleItem` for the
|
||||
clicked row. The same function also owns staged Buying/Selling removal and
|
||||
their `ClientLocal` feedback.
|
||||
|
||||
- `gmVendorUI::ListenToElementMessage` (`pc:204260-204309`, full function
|
||||
read) dispatches on message id 1 (button click →
|
||||
`HandleButtonClicks`), 7 (dropdown selection change), `0x2c` (page
|
||||
change), `0x15` (drop release), and `0x1c` (routes to
|
||||
`HandleMousePresses` only when `m_itemsUI != 0`) — there is no distinct
|
||||
"double-click" message id handled at the panel level.
|
||||
- The base list class `UIElement_ItemList` (every method enumerated via
|
||||
`docs/research/named-retail/symbols.json`, ~50 symbols) has
|
||||
`HandleSingleSelection`, `HandleTargetedUseLeftClick`,
|
||||
`ItemList_SetSelectedItem`, `ItemList_OpenContainer` (for double-clicking
|
||||
a CONTAINER item specifically — opening it, not buying), but **no
|
||||
generic double-click handler** and no vendor-specific one either.
|
||||
- Other retail panels DO have an explicit, separately-named double-click
|
||||
handler when the mechanism exists — e.g. `gmContractsUI::CheckForDoubleClick`
|
||||
(`0x00497A10`), `gmPageListUI::CheckForDoubleClick` (`0x00493140`). No
|
||||
`gmVendorUI::CheckForDoubleClick` or `VendorItemsUI::CheckForDoubleClick`
|
||||
symbol exists in the 18,366-function named table.
|
||||
|
||||
**Conclusion:** retail's confirmed vendor-item interaction model is
|
||||
single-click-to-select (→ drives the global `ACCWeenieObject::selectedID`,
|
||||
B.3 below) plus an explicit Buy/Add button press. There is no evidence
|
||||
retail supports double-click-to-buy on the shop list. The user's
|
||||
expectation likely carries over from inventory-panel muscle memory
|
||||
(double-click = use/equip elsewhere in retail) — but the vendor "Items"
|
||||
list is not that panel. **This is flagged as an open question for the
|
||||
contract, not resolved unilaterally**: per the project's
|
||||
no-invented-mechanisms discipline, do not silently add a double-click-buy
|
||||
shortcut and call it retail-faithful. The retail-faithful, fully-evidenced
|
||||
fix for "double-click does nothing" is: (a) make single-click meaningfully
|
||||
select (today it only sets a private field with no visible effect — see
|
||||
B.3), and (b) make the Buy button actually work. If the user still wants a
|
||||
double-click shortcut after seeing single-click+Buy work, that is a
|
||||
deliberate, flagged acdream UX addition on top of retail, not a retail port
|
||||
— call it out explicitly in the commit/register the way AP-116
|
||||
(Particle Range) or similar user-directed deviations are recorded.
|
||||
**Conclusion:** browse-row double-click-to-buy is verbatim retail behavior.
|
||||
The acdream binding is a port, not an optional modernization. The previous
|
||||
absence-of-symbol inference was false and is superseded by the direct function
|
||||
body.
|
||||
|
||||
### B.3 — The quantity slider
|
||||
|
||||
|
|
@ -735,10 +709,9 @@ concretely unblocked by the one before it; skipping ahead reproduces the
|
|||
polish, not correctness — the server is authoritative either way) and
|
||||
file it as a fast follow-up if the user notices the round-trip lag on a
|
||||
refused purchase.
|
||||
2. **Double-click** — no retail mechanism found (B.2). Ask the user
|
||||
directly whether they want a deliberate acdream-only double-click
|
||||
shortcut once single-click-select + Buy-button-works is verified live,
|
||||
rather than assuming yes and inventing behavior.
|
||||
2. **Double-click — RESOLVED 2026-08-26.** Retail's
|
||||
`gmVendorUI::HandleMousePresses @ 0x004C40D0` directly buys a browse row on
|
||||
double-click. Keep this behavior and its staged-row siblings.
|
||||
3. **Where does the vendor-owned split-exempt-mask predicate live** — C.1's
|
||||
design question: fold into `SelectedObjectController` directly (it
|
||||
already owns the seeding logic, would need a `Func<uint,bool>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -5,6 +5,13 @@ Research lane D of the settings-track campaign
|
|||
questions **Q5** (Configure Keyboard: retail's keymap UI + storage) and
|
||||
**Q6** (what every Gameplay Options tab button does).
|
||||
|
||||
> **2026-08-26 implementation addendum:** the report below describes the
|
||||
> pre-OP8 state and its design choices at that date. acdream has now shipped
|
||||
> Option C end to end: all 306 installed-DAT rows, retail conflicts/capture,
|
||||
> and real named `.keymap` Load File / Save As/startup/shutdown persistence.
|
||||
> See `docs/research/2026-08-26-retail-keyboard-routing-audit.md`; AP-202 is
|
||||
> retired.
|
||||
|
||||
**Report only.** No repo code was changed. Every retail claim below carries
|
||||
a named symbol + address from the Sept 2013 EoR PDB-paired build. The
|
||||
PDB/binary pairing was verified first:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
1256
docs/research/2026-08-24-advancement-wire-and-recompute.md
Normal file
1256
docs/research/2026-08-24-advancement-wire-and-recompute.md
Normal file
File diff suppressed because it is too large
Load diff
142
docs/research/2026-08-24-campaign-ca-test-script.md
Normal file
142
docs/research/2026-08-24-campaign-ca-test-script.md
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
# Campaign CA — connected gate script (CA5, user-driven)
|
||||
|
||||
**Purpose:** live verification of the whole advancement chain against ACE
|
||||
after CA1–CA4 (`1fc64984`, `65430d4c`, `57818959`, `08b77e20`). Everything
|
||||
below should be visible **without a relog** — that is the entire point of
|
||||
the campaign.
|
||||
|
||||
**Character:** use a SCRATCH character (owner decision 2026-08-24 — XP and
|
||||
skill credits will be spent, and the respec checks are destructive).
|
||||
A fresh character is ideal: low attribute costs mean many cheap raises.
|
||||
|
||||
**Launch:** the normal connected launch (`ACDREAM_RETAIL_UI=1`, live ACE at
|
||||
`127.0.0.1:9000`). No diagnostic env vars needed. Useful ACE console
|
||||
helpers: `@ci <wcid>` to spawn gems, `@grantxp`, `@grantskillcredits`
|
||||
(see `claude-memory/../memory/reference_ace_commands.md`).
|
||||
|
||||
Open the Character panel (F9 / toolbar) before starting; keep the vitals
|
||||
bar visible throughout.
|
||||
|
||||
---
|
||||
|
||||
## 1. Attribute raise → derived skills + run speed (the original #431)
|
||||
|
||||
Prep: bank some XP (`@grantxp` if the scratch character is too poor).
|
||||
Note current run speed by running a straight line; note the Run skill's
|
||||
displayed value and one other Quickness-fed skill (e.g. Melee Defense).
|
||||
|
||||
1. Raise **Quickness** by 1.
|
||||
- PASS: the attribute value updates when the server record lands (a
|
||||
round trip, not a relog); **Run and every Quickness-fed skill row
|
||||
update in the same moment**; XP remaining drops by the server's
|
||||
accounting.
|
||||
2. Raise Quickness repeatedly until the formula contribution crosses a
|
||||
point (attribute current +2 → skill formula +1 for /2-divisor skills).
|
||||
- PASS: skill rows tick up as the attribute crosses each threshold.
|
||||
3. **Run before and after.** With `runrate_add_hooks` active on ACE the
|
||||
server also re-broadcasts your movement speed mid-run.
|
||||
- PASS: run speed visibly increases after the raise — at latest on the
|
||||
next movement start. FAIL if speed only changes after relog.
|
||||
4. While the raise is in flight (click and watch closely): the raise
|
||||
buttons ghost momentarily and un-ghost when the record lands.
|
||||
- PASS: brief ghost; no double-send on rapid double-click (the second
|
||||
click does nothing).
|
||||
|
||||
## 2. Endurance/Self raises → vitals maxima (the single-record quirk)
|
||||
|
||||
Note max health / max stamina / max mana from the vitals bar.
|
||||
|
||||
1. Raise **Endurance** by 1–2 points.
|
||||
- PASS: **max health AND max stamina both move** on the bar and the
|
||||
panel (ACE only pushes a Health record; the client-side fan-out must
|
||||
cover Stamina — research doc §4.1).
|
||||
2. Raise **Self**.
|
||||
- PASS: max mana moves.
|
||||
3. Sanity: current values don't jump wrongly (regen keeps ticking
|
||||
normally; watch ~10 s).
|
||||
|
||||
## 3. Direct vital raise + the retail raise-10 client bug (AP-73 CHECK)
|
||||
|
||||
1. Raise **Max Health** directly by 1.
|
||||
- PASS: bar max + panel update on the record; XP debits.
|
||||
2. **The deliberate failure case** — this resolves the narrowed AP-73 row.
|
||||
Arrange XP so you can afford exactly ONE vital raise but not ten
|
||||
(spend down; the cost curve is steep so this is easy). The retail
|
||||
client bug ACE documents: the raise-10 button is enabled anyway.
|
||||
Click **raise ×10**.
|
||||
- EXPECTED from ACE: chat line "Your attempt to raise ... has failed."
|
||||
and NO stat change.
|
||||
- **RECORD: do the raise buttons stay ghosted afterward?**
|
||||
- If they un-ghost on their own → note what un-ghosted them (a regen
|
||||
tick counts as a quality change — that is retail-plausible and
|
||||
AP-73 can then RETIRE with that mechanism recorded).
|
||||
- If they stay ghosted until you close/reopen the panel → AP-73's
|
||||
symptom confirmed; report it and we decide the fix against the
|
||||
retail oracle (cdb on the live retail client if needed).
|
||||
|
||||
## 4. Skill raise
|
||||
|
||||
1. Select a TRAINED skill, raise ×1 and ×10.
|
||||
- PASS: ranks/value update on the record; XP debits; the
|
||||
"Your base <skill> skill is now N!" advancement line appears in the
|
||||
SpewBox with the advancement color.
|
||||
2. Confirm an attribute-less skill (e.g. **Salvaging**, if trained) shows
|
||||
value = ranks-only progression and raises normally.
|
||||
|
||||
## 5. Train a new skill
|
||||
|
||||
Prep: ensure ≥ the DAT cost in skill credits (`@grantskillcredits`).
|
||||
|
||||
1. Select an UNTRAINED skill, click Train.
|
||||
- PASS: the skill flips to Trained on the record; credits drop by the
|
||||
DAT cost; chat: "<skill> trained. You now have N credits available."
|
||||
2. NEGATIVE (silent-failure probe): nothing client-side should allow a
|
||||
wrong cost, so simply confirm no double-send on rapid clicks and that
|
||||
the button ghosts while awaiting.
|
||||
|
||||
## 6. Specialize / lower (SkillAlterationDevice)
|
||||
|
||||
Prep: `@ci` a **Gem of Enlightenment** (specialize) and a
|
||||
**Gem of Forgetfulness** (lower) — wcids per ACE's db (ask the console
|
||||
with `@acecommands` if unsure).
|
||||
|
||||
1. Use the Enlightenment gem on a TRAINED skill.
|
||||
- PASS: retail confirmation dialog appears; on accept, the skill flips
|
||||
to Specialized, credits drop, and the "You have succeeded
|
||||
specializing..." notice lands in chat (the 0x028B
|
||||
WeenieErrorWithString route).
|
||||
- Also confirm DECLINING the dialog changes nothing.
|
||||
2. Use the Forgetfulness gem: Specialized → Trained, then Trained →
|
||||
Untrained.
|
||||
- PASS: each step updates the panel on the record and refunds credits
|
||||
per ACE's accounting; an untrained skill row returns to the
|
||||
untrained section with formula-only value.
|
||||
|
||||
## 7. Respec-adjacent (as far as ACE supports)
|
||||
|
||||
If ACE's Enlightenment/attribute-reset paths are reachable on this server
|
||||
(level requirements may block a scratch character — skip if so), exercise
|
||||
one and confirm the client tracks every pushed record without a relog.
|
||||
Otherwise mark N/A — the message shapes are identical to §6's, so §6
|
||||
passing covers the client-side mechanism.
|
||||
|
||||
## 8. Regression sweep (5 minutes)
|
||||
|
||||
- Vitae/buff display still correct after raises (buff an attribute; panel
|
||||
shows effective + base pair; skill values include the buff through the
|
||||
formula).
|
||||
- Logout/login: everything you raised persists and PlayerDescription
|
||||
agrees with what the live records showed (any mismatch = a parser bug —
|
||||
report exact numbers).
|
||||
- Ordinary play smoke: run, jump, cast, fight one mob — nothing about
|
||||
movement feel changed outside the raises.
|
||||
|
||||
---
|
||||
|
||||
## Report back
|
||||
|
||||
Per section: PASS/FAIL plus anything odd. The three answers that matter
|
||||
most:
|
||||
1. §1.3 — did run speed change live?
|
||||
2. §2.1 — did max STAMINA move on an Endurance raise?
|
||||
3. §3.2 — the AP-73 ghost question (un-ghosted by what / stayed stuck?).
|
||||
797
docs/research/2026-08-24-campaign-ct-dat-ground-truth.md
Normal file
797
docs/research/2026-08-24-campaign-ct-dat-ground-truth.md
Normal file
|
|
@ -0,0 +1,797 @@
|
|||
# Campaign CT slice CT1 — DAT ground truth for the character panel (0x2100002E)
|
||||
|
||||
**Status:** RESEARCH COMPLETE 2026-08-24. No production code changed in this
|
||||
slice. Findings feed CT2–CT6 (`docs/plans/2026-08-24-character-panel-parity-campaign.md`).
|
||||
|
||||
Method: temporary `Assert.Fail` probe tests (deleted before commit; the
|
||||
pattern is preserved in `tests/AcDream.App.Tests/UI/Layout/ScrollbarSkinLiveDatTests.cs`
|
||||
and its sibling `CharacterPanelLiveDatTests.cs` written by this slice) against
|
||||
the installed DAT set (`%USERPROFILE%\Documents\Asheron's Call`), driven
|
||||
through `LayoutImporter.ImportInfos` / `ElementInfo`
|
||||
(`src/AcDream.App/UI/Layout/ElementReader.cs`,
|
||||
`src/AcDream.App/UI/Layout/LayoutImporter.cs`).
|
||||
|
||||
## 1. Header elements (gmStatManagementUI content, sub-layout under 0x2100002E)
|
||||
|
||||
All header elements are DUPLICATED — the imported 0x2100002E tree carries
|
||||
two structurally identical copies of the whole header block, one reached
|
||||
through the Attributes page chain (`0x10000227 > 0x1000022B > 0x10000226 >
|
||||
0x10000230`) and one through the Skills page chain (`... > 0x1000022C >
|
||||
...`). **The two copies are geometrically and stylistically IDENTICAL** —
|
||||
this is the "duplicated stat-management branches" quirk documented on
|
||||
`PrepareSkillScrollbar` in `CharacterStatController.cs`. **Caveat: this
|
||||
"identical" claim rests on the deleted probe tests, not the committed
|
||||
pins.** `CharacterPanelLiveDatTests.HeaderElements_AuthorExpectedFontsAndColors`
|
||||
only asserts font/color/margin equality across the two copies (and the
|
||||
2-count) — it does not assert X/Y/W/H geometric equality between them.
|
||||
Treat "geometrically identical" as probe-session observation, not a
|
||||
pinned fact, until a future slice adds a geometry-equality assertion.
|
||||
|
||||
**Coordinate frame:** every `X,Y` below is **parent-relative**, not
|
||||
page-relative — e.g. Name `0,0` means the top-left of its immediate
|
||||
parent container (`0x10000230`), not the top-left of the Attributes/
|
||||
Skills page. Cross-reference
|
||||
`docs/research/2026-06-25-character-window-faithful-spec.md` for the
|
||||
page-relative numbers if you need the header block's position within
|
||||
the page itself; do not mix the two frames when placing elements.
|
||||
|
||||
| Element | Id | X,Y | W×H | HJustify | VJustify | FontDid | FontColor | Outline | Margins |
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
| Name | 0x10000231 | 0,0 | 230×20 | Center | Center | 0x40000001 | white (1,1,1,1) | false | 0 |
|
||||
| Heritage line | 0x10000232 | 0,20 | 230×15 | Center | Center | 0x40000002 | white (1,1,1,1) | false | L5 R5 |
|
||||
| PK line | 0x10000233 | 0,35 | 230×15 | Center | Center | 0x40000002 | **white (1,1,1,1)** | false | 0 |
|
||||
| Level | 0x1000023B | 235,35 | 65×50 | Center | Center | 0x40000010 | **(1, 0.9490196, 0.49803922, 1)** | **true** | 0 |
|
||||
| Total XP | 0x10000235 | 130,70 | 100×18 | Right | Center | 0x40000000 | white | false | 0 |
|
||||
| XP meter | 0x10000236 (Type 7) | 0,88 | 230×17 | — | — | — | — | — | — |
|
||||
| XP-next label (child of meter) | 0x10000237 | 0,0 | 130×17 | Left | Center | 0x40000000 | white | false | 0 |
|
||||
| XP-to-level value (child of meter) | 0x10000238 | 130,0 | 100×17 | Right | Center | 0x40000000 | white | false | 0 |
|
||||
| Luminance label | 0x100005C5 | 0,52 | 110×18 | Left | Center | 0x40000000 | white | false | 0 |
|
||||
| Luminance value | 0x100005C6 | 110,52 | 120×18 | Right | Center | 0x40000000 | white | false | 0 |
|
||||
|
||||
Header block container (0x10000230) is 300×110 inside the shared prototype
|
||||
layout 0x21000045 — the character content column itself is only 230px wide
|
||||
(name/heritage/PK/XP all live in that 230px column), with a 5px vertical
|
||||
divider (0x10000239, X=230, W=5) separating it from the level box
|
||||
(X=235..300, matching the plan's "Level area (65,50)" spec).
|
||||
|
||||
**Confirmations vs. the owner report / plan:**
|
||||
- Item 4 (PK line pure white): CONFIRMED — `0x10000233` FontColor is
|
||||
exactly white, not the "color off" state the owner reported. The bug is
|
||||
purely in `CharacterStatController.Bind`'s runtime color choice (`Body`
|
||||
= parchment `(0.92,0.90,0.82,1)`), not a DAT-reading gap. CT4 must switch
|
||||
the PK-line color to `Vector4.One`.
|
||||
- Item 5 (level color): CONFIRMED DIVERGENT. DAT-authored level color is
|
||||
`(1, 0.9490196, 0.49803922, 1)` (a pale gold, ~RGB 255/242/127) **with
|
||||
Outline=true**. `CharacterStatController.Gold` is currently
|
||||
`(1, 0.82, 0.36, 1)` (a deeper orange-gold) with no outline applied. CT4
|
||||
should read the DAT FontColor + Outline directly instead of hand-picking
|
||||
a runtime color, matching how `LevelId`'s dat FontDid is already honored.
|
||||
|
||||
## 2. Stat list (0x1000023D) + row templates
|
||||
|
||||
`0x1000023D` (Type 5 ListBox) is duplicated the same harmless way as the
|
||||
header (once per Attributes/Skills page chain, identical geometry both
|
||||
times): `X=0 Y=112 W=300 H=160`, `ScrollbarElementId=0x1000023E`
|
||||
(scrollbar at `X=281 W=16 H=160`, i.e. always reserved, whether or not it's
|
||||
shown). Its authored `TemplateList` (dat property 0x64) names FIVE
|
||||
same-layout-family entries, all in **LayoutDesc 0x21000045**:
|
||||
|
||||
```
|
||||
0x10000248, 0x10000249, 0x1000024A, 0x1000024B, 0x1000024C
|
||||
```
|
||||
|
||||
These are NOT reachable by walking `ImportInfos(dats, 0x21000045u)`'s built
|
||||
tree — `LayoutImporter.ImportInfos` intentionally filters out same-layout
|
||||
template-list targets (the `#375` fix documented in
|
||||
`LayoutImporter.ImportInfos`'s own comment: "retail never instantiates a
|
||||
template-list element as a live widget… building them here parked two live
|
||||
prototype rows… over and outside the framed panel"). The correct read path
|
||||
— and the one CT5 must use — is the **targeted single-root overload**:
|
||||
`LayoutImporter.ImportInfos(dats, 0x21000045u, templateElementId)`, the same
|
||||
seam `UiTemplateListBox`'s `TemplateResolver` already uses for other
|
||||
authored row templates.
|
||||
|
||||
Dumping all five with that overload:
|
||||
|
||||
### 0x10000248 — the ONE shared data-row template (icon + name + value)
|
||||
|
||||
```
|
||||
Id=0x10000248 Type=3 (container) X=0 Y=0 W=282 H=20
|
||||
StateMedia[Normal] File=0x06004CC2 DrawMode=1
|
||||
StateMedia[Highlight] File=0x06000F93 DrawMode=1
|
||||
Id=0x10000129 Type=3 (icon slot) X=0 Y=0 W=20 H=20 (no own media — set per-row at runtime)
|
||||
Id=0x1000012A Type=0xC (name text) X=25 Y=0 W=150 H=20 HJustify=Left FontDid=0x40000001 white
|
||||
Id=0x1000012B Type=0xC (value text)X=175 Y=0 W=100 H=20 HJustify=Right FontDid=0x40000001 white
|
||||
```
|
||||
|
||||
This is the single row template used for BOTH the attribute rows AND the
|
||||
skill rows (retail's `gmAttributeUI`/`gmSkillUI` share it). Ground truth
|
||||
for item 1 (icon alignment) and item 2 (value-column gutter):
|
||||
|
||||
- **Icon: 20×20, flush at the row's left edge (X=0), full row height.**
|
||||
Current code (`CharacterStatController.IconSize = 16f`,
|
||||
`RowPadX = 4f`) draws a 16×16 icon at X=4 — smaller AND offset from
|
||||
retail's flush-left 20×20. This is the "icons misaligned" bug (item 1).
|
||||
- **Name column: X=25, W=150 (fixed pixel widths, not a width fraction).**
|
||||
Current code computes `nameX = RowPadX + IconSize + IconGap` (4+16+6=26,
|
||||
off by one from retail's 25) and `nameW = width * 0.60` (a
|
||||
content-relative fraction retail does not use at all — retail's name
|
||||
column is a FIXED 150px regardless of the 282px row width).
|
||||
- **Value column: X=175, W=100, right-justified.** Value's right edge
|
||||
sits at X=275. **The row template itself is 282px wide, so there is a
|
||||
7px gap between the value's right edge and the row's own right edge**
|
||||
— this is the "authored margin between the value column and the
|
||||
border" the owner reported (item 2), confirmed as exactly 7px at the
|
||||
row-template level.
|
||||
- **The bare authored rectangles** (no derived arithmetic — see the
|
||||
caveat below): ListBox `0x1000023D` is `X=0 Y=112 W=300 H=160`; its
|
||||
scrollbar `0x1000023E` is `X=281 W=16 H=160`; the data row template is
|
||||
`W=282`; the value column inside the row is `X=175 W=100`,
|
||||
right-justified, 7px from the row's own right edge (`282 - (175+100) =
|
||||
7`).
|
||||
- **These numbers do NOT compose into a tidy "gutter" story — do not
|
||||
infer one.** `300 - 281 = 19`, not the row's 282px width's complement
|
||||
(`300 - 282 = 18`); and the 282px row actually OVERLAPS the 281px
|
||||
scrollbar band by 1px (`281 < 282`). An earlier draft of this doc
|
||||
described the row as "inset by 18px to clear an always-reserved
|
||||
scrollbar gutter" and summed 18+7=25 — that decomposition does not
|
||||
close against the authored numbers above and is **inference, not
|
||||
fact**; withdrawn. CT5 must implement the authored numbers directly
|
||||
(row width 282 — matches the existing `SkillContentWidth = 282f`
|
||||
constant already in the code; value column X=175 W=100; scrollbar
|
||||
X=281 W=16), never a derived `listWidth - 18` or similar formula.
|
||||
- Row background: `Normal` state file `0x06004CC2`. **The description
|
||||
"generic panel-chrome fill used elsewhere client-wide" is UNVERIFIED**
|
||||
— no cross-reference sweep for other consumers of `0x06004CC2` was run
|
||||
this slice; only the file id itself, as authored on this specific
|
||||
template, is pinned. `Highlight` state file **`0x06000F93`**.
|
||||
|
||||
### SEALED VERDICT: RowHighlightSprite is wrong, not merely flagged
|
||||
|
||||
Retail's selected attribute/skill row draws the row template's Highlight
|
||||
state media — `0x06000F93`, not `CharacterStatController`'s current
|
||||
`0x06001397u`. This is no longer a hedge; the decomp confirms the
|
||||
mechanism end to end:
|
||||
|
||||
- `gmAttributeUI::UpdateSelection @0x0049DEE0` calls
|
||||
`SetState(selected ? 6 : 1)` on the row.
|
||||
- `InfoRegion::SetState @0x004F0EE0` forwards that state to the row
|
||||
element instantiated from template `0x10000248` — the exact template
|
||||
this slice dumped, whose Highlight-state file is `0x06000F93`.
|
||||
- State `6` IS `UIStateId.Highlight` — so the selected row draws
|
||||
`0x10000248`'s own `Highlight` media, not a separately-chosen sprite.
|
||||
|
||||
`CharacterStatController.cs`'s comment near lines 111–113 ("matches
|
||||
retail... sprite 0x06001397 visual intent") is **falsified for this
|
||||
element**. CT5 must correct `RowHighlightSprite` to `0x06000F93` for the
|
||||
STAT rows and should reconsider `UseSelectionBars`/`HighlightBg`, which
|
||||
currently emulate the wrong sprite's art (a translucent gold tint tuned
|
||||
to look like `0x06001397`'s dark bars, not `0x06000F93`'s actual look).
|
||||
|
||||
**`0x06001397` is not a phantom constant — it is legitimate ELSEWHERE.**
|
||||
The spellbook row prototype `0x10000343` has a separate selected-overlay
|
||||
CHILD element `0x10000342` whose media IS `0x06001397`
|
||||
(`UIElement_UIItem::SetSelectedState @0x004E1240` mechanism — a
|
||||
different code path from `InfoRegion::SetState`, and a different visual
|
||||
composition: an overlay child, not a state-swap on the row itself). CT5
|
||||
must correct the STAT rows ONLY and must NOT touch `SpellbookRowStyle.cs`
|
||||
or its tests — `0x06001397` is correct there.
|
||||
|
||||
### 0x10000249 / 0x1000024A / 0x1000024B / 0x1000024C — skill SECTION HEADER captions
|
||||
|
||||
```
|
||||
Id=0x10000249 Type=0xC X=0 Y=0 W=280 H=20 HJustify=Left Margins L5 R5 FontDid=0x40000001 StateMedia[]=0x06000F90
|
||||
Id=0x1000024A Type=0xC X=0 Y=0 W=280 H=20 HJustify=Left Margins L5 R5 FontDid=0x40000001 StateMedia[]=0x06000F86
|
||||
Id=0x1000024B Type=0xC X=0 Y=0 W=280 H=20 HJustify=Left Margins L5 R5 FontDid=0x40000001 StateMedia[]=0x06000F98
|
||||
Id=0x1000024C Type=0xC X=0 Y=0 W=280 H=20 HJustify=Left Margins L5 R5 FontDid=0x40000001 StateMedia[]=0x06000F89
|
||||
```
|
||||
|
||||
These are single full-width caption bars (no icon/name/value split), 280px
|
||||
wide (2px narrower than the data row — no scrollbar-gutter inset needed
|
||||
since they never scroll independently). Their sprites are an EXACT match
|
||||
for the existing constants already in `CharacterStatController.cs`:
|
||||
|
||||
| Constant | Value | Probe file | Match |
|
||||
|---|---|---|---|
|
||||
| `SkillHeaderSpecializedSprite` | 0x06000F90 | 0x10000249 | YES |
|
||||
| `SkillHeaderTrainedSprite` | 0x06000F86 | 0x1000024A | YES |
|
||||
| `SkillHeaderUntrainedSprite` | 0x06000F98 | 0x1000024B | YES |
|
||||
| `SkillHeaderUnusableSprite` | 0x06000F89 | 0x1000024C | YES |
|
||||
|
||||
These four are correctly ported already; no CT5 work needed here.
|
||||
|
||||
### Row-height divergence (CT5 gold, found this fix round)
|
||||
|
||||
The row template above authors `H=20` (line `Id=0x10000248 Type=3
|
||||
(container) X=0 Y=0 W=282 H=20`, already pinned by
|
||||
`CharacterPanelLiveDatTests.AttributeRowTemplate_...`). Current code
|
||||
matches this for skill rows (`CharacterStatController.SkillRowHeight =
|
||||
20f`) but NOT for attribute rows, which use a separate
|
||||
`CharacterStatController.RowHeight = 22f` constant. CT5 must fix the
|
||||
attribute-row path to 20px; there is no authored basis for 22 anywhere
|
||||
in the row template.
|
||||
|
||||
### Row instantiation + icon-DID anchors (CT5 gold, found this fix round)
|
||||
|
||||
`InfoRegion::InfoRegion @0x004F1450` instantiates each stat row via
|
||||
`AddItemFromTemplateList(listBox, 0, ...)` — template index **0**, i.e.
|
||||
`0x10000248`, the shared data row confirmed above — and binds
|
||||
`0x1000012A` (label), `0x1000012B` (value), `0x10000129` (icon) via
|
||||
`UIRegion::SetImageByDID(icon, did, 3)` (icon draw mode 3). Per-attribute
|
||||
icon DIDs come from `DBObj::GetDIDByEnum(statEnum, category
|
||||
0x10000002)` in `gmAttributeUI::PostInit @0x0049DB70` — a THIRD consumer
|
||||
of the `GetDIDByEnum` master-map mechanism documented in §5 below
|
||||
(alongside the title EnumMapper/StringTable pair and `RetailKeyNames`),
|
||||
which is enough precedent that CT2/CT5 should factor a shared
|
||||
`GetDIDByEnum(enumValue, category)` helper instead of hardcoding a third
|
||||
independent DID pair.
|
||||
|
||||
`gmSkillUI::RebuildSkillList @0x0049C3A0` adds template indices **1–4**
|
||||
(`0x10000249..0x1000024C`) for the section-header captions, confirming
|
||||
the section-header order already pinned by
|
||||
`SkillSectionHeaderTemplates_MatchExistingSpriteConstants` above:
|
||||
Specialized, Trained, Untrained, Unusable.
|
||||
|
||||
## 3. Titles page (0x10000539 subtree, imported as part of 0x2100002E)
|
||||
|
||||
`0x10000539` (Type 0x10000046, the Titles page container) is NOT
|
||||
duplicated like the Attributes/Skills content — it appears once, as a
|
||||
direct child of the tab-control root `0x10000227` (siblings with the
|
||||
Attributes/Skills tab buttons and the Titles tab button `0x10000538`).
|
||||
Geometry: `X=0 Y=25 W=300 H=575` (fills the window below the 25px tab bar).
|
||||
|
||||
| Element | Id | X,Y | W×H | Notes |
|
||||
|---|---|---|---|---|
|
||||
| (unlabeled caption) | 0x1000052E | 8,20 | 270×18 | Left, margin L6, FontDid 0x40000001 white — likely "Current Title:" caption |
|
||||
| **Current display title text** | **0x1000052F** | 8,40 | 270×18 | Center, margins L5 R5, FontDid 0x40000001 white |
|
||||
| divider | 0x10000530 | 0,60 | 300×9 | sprite 0x06001420 |
|
||||
| (unlabeled caption) | 0x10000531 | 8,70 | 270×18 | Left, margin L6 — likely "Titles Earned:" caption |
|
||||
| **Title ListBox** | **0x10000532** | 8,90 | 270×455 | ScrollbarElementId=0x10000533; TemplateList: LayoutDid=0x2100005E, ElementId=0x10000536 |
|
||||
| Title scrollbar | 0x10000533 | 280,90 | 16×455 | shared `RetailScrollbarChrome` media (thumb/up/down ids identical to the base skin pinned by `ScrollbarSkinLiveDatTests`) |
|
||||
| divider | 0x10000534 | 0,550 | 300×9 | sprite 0x06001420 |
|
||||
| **"Set as Display Title" button** | **0x10000535** | 53,560 | 200×32 | MinWidth=65, margins L7 R7, FontDid 0x40000001 white, DefaultState=**Ghosted** (matches the plan's `UpdateButtons` ghost-when-current contract); three-slice chrome children 0x100002CE/CF/D0 with Normal/Normal_rollover/Normal_pressed/**Ghosted** states each |
|
||||
|
||||
### Title row template — LayoutDesc 0x2100005E, element 0x10000536
|
||||
|
||||
```
|
||||
Id=0x10000536 Type=3 (container) X=0 Y=0 W=270 H=24
|
||||
StateMedia[DirectState, key ""] File=0x06004CCA DrawMode=3
|
||||
StateMedia[Highlight] File=0x06001AAF DrawMode=1
|
||||
Id=0x10000537 Type=0xC (text) X=0 Y=0 W=270 H=24 HJustify=Left Margins L6 R6 FontDid=0x40000001 white
|
||||
```
|
||||
|
||||
A single-line text row, no icon column (titles have no per-row icon in
|
||||
retail) — 24px tall vs. the stat rows' 20px. Row width 270 matches the
|
||||
ListBox content width exactly (`0x10000532` is 270 wide, with its
|
||||
scrollbar `0x10000533` living OUTSIDE that width at X=280 — unlike the
|
||||
stat list, the title row template does NOT need its own internal
|
||||
scrollbar-gutter inset because the ListBox width itself already excludes
|
||||
the scrollbar column).
|
||||
|
||||
Same targeted-root import gotcha as the stat templates applies here:
|
||||
`LayoutImporter.ImportInfos(dats, 0x2100005Eu)` (the whole-layout
|
||||
overload) returns the SAME element `0x1000052D`, but does NOT surface
|
||||
`0x10000536` as a reachable child. **`0x1000052D` is not a throwaway
|
||||
container** — per the `BaseElement`/`BaseLayoutId` table in
|
||||
`docs/research/2026-06-25-character-window-faithful-spec.md` (line
|
||||
~19), `0x1000052D` is the authored `BaseElement` that the Titles page
|
||||
root `0x10000539` inherits its content from (`0x10000539`'s
|
||||
`BaseLayoutId` is `0x2100005E`, `BaseElement` is `0x1000052D`) — it is
|
||||
the real authored Titles-page content, just reached by a different path
|
||||
than the live-mounted tree. Use
|
||||
`LayoutImporter.ImportInfos(dats, 0x2100005Eu, 0x10000536u)` (the
|
||||
targeted single-root overload) to reach the row template `0x10000536`
|
||||
underneath it.
|
||||
|
||||
## 4. Window min/max constraints
|
||||
|
||||
### Character window (0x2100002E) root
|
||||
|
||||
`LayoutImporter.ImportInfos(dats, 0x2100002Eu)` returns element
|
||||
`0x10000227` (Type 0x8, TabControl) as the tree root — this IS the
|
||||
top-level element retail's `RetailUiRuntime.MountCharacter()` mounts via
|
||||
`RetailWindowFrame.Mount(..., layout.Root, ...)`. **It authors NO
|
||||
MinWidth/MinHeight/MaxWidth/MaxHeight properties** (dat properties
|
||||
0x3F/0x3E/0x3D/0x3C all absent — probe shows every one of `MinW/MinH/MaxW/MaxH`
|
||||
blank for 0x10000227 and every element under it, including the footer,
|
||||
header, and Titles page).
|
||||
|
||||
### Chat window (0x2100006F) root, for comparison
|
||||
|
||||
`LayoutImporter.ImportInfos(dats, 0x2100006Fu)` returns element
|
||||
`0x10000600` (Type 0x10000050, a self-contained "window" element that
|
||||
directly includes its own dragbar (Type 2), border frame (Type 3), and
|
||||
FOUR resize-grip corners (Type 9) as children — none of which the
|
||||
character layout's root has). It DOES author constraints:
|
||||
**MinWidth=300, MinHeight=100, MaxWidth=2000, MaxHeight=2000.**
|
||||
|
||||
### Correction to the plan
|
||||
|
||||
The plan's "Already in-tree" section states: *"The character window
|
||||
registers with `DatConstraintSource` — authored min/max plumbing exists in
|
||||
`RetailWindowFrame`; Y-resize for this window and the list-scrollbar
|
||||
contract do not."* Read literally this implies the character window's
|
||||
`RetailWindowFrame.Mount` call already sets `DatConstraintSource`. **It
|
||||
does not.** `RetailUiRuntime.MountCharacter()` (`src/AcDream.App/UI/RetailUiRuntime.cs`,
|
||||
~line 4043) constructs `RetailWindowFrame.Options` with `ResizeY = true`,
|
||||
`ResizableEdges = ResizeEdges.Bottom`, `ConstrainResizeToParent = true` —
|
||||
but **no `DatConstraintSource`, `MinHeight`, or `MaxHeight` field at
|
||||
all**, unlike e.g. `MountSideVitals()` (~line 1474) which explicitly sets
|
||||
`DatConstraintSource = info` from its own imported root. This is
|
||||
consistent with what CT1 also found in the DAT itself: 0x2100002E's root
|
||||
authors no size constraints to plumb through in the first place — chat's
|
||||
window-frame elements are its own self-contained LayoutDesc, while
|
||||
0x2100002E is CONTENT ONLY (tab bar + pages), with retail's window chrome
|
||||
supplied by a separate mechanism.
|
||||
|
||||
Treat the "Already in-tree" plan bullet as **inaccurate**: start CT6
|
||||
from the decomp for the window-frame class instead of assuming the
|
||||
wiring is already 90% done. The paragraph below replaces this doc's
|
||||
earlier "likely a hardcoded ResizeTo/SetMinSize call" guess with the
|
||||
verified mechanism.
|
||||
|
||||
### Verified resize mechanism (2026-08-24, supersedes the hypothesis above)
|
||||
|
||||
`UIElement::ResizeTo @0x00463C30` clamps ONLY via element attributes —
|
||||
`0x3C` (clamp-max-height), `0x3E` (clamp-min-height), `0x3D`
|
||||
(clamp-max-width), `0x3F` (clamp-min-width) — read off `this`, the
|
||||
element actually being resized. A decomp-wide grep for writers of those
|
||||
four attributes turns up NOTHING: no runtime code ever sets them at
|
||||
runtime. The clamp source is exclusively **authored DAT properties on
|
||||
whichever element `ResizeTo` is called against, full stop.** There is no
|
||||
hardcoded `SetMinSize` call anywhere in the class hierarchy; the earlier
|
||||
"likely a hardcoded ResizeTo/SetMinSize call" phrasing in this doc was a
|
||||
guess and is WRONG.
|
||||
|
||||
The element `ResizeTo` is called against is not `0x2100002E`'s own root.
|
||||
Per `docs/research/2026-07-17-retail-shared-main-panel-pseudocode.md`
|
||||
(lines ~83-107) and the slot table in
|
||||
`docs/research/2026-08-11-fa-panel-structure.md` (row for `0x1000018E`),
|
||||
retail's Character/Skills tab content is one child slot inside the
|
||||
SHARED `gmPanelUI` host, LayoutDesc `0x2100006E`:
|
||||
|
||||
```text
|
||||
gmPanelUI host 0x100005FE 310 x 372
|
||||
content parent 0x10000180 300 x 362 (anchored all edges)
|
||||
Character/Skills slot 0x1000018E panel id 11, 300 x 362
|
||||
top-center Dragbar 0x1000065C Type 2
|
||||
bottom-center Resizebar 0x10000660 Type 9
|
||||
```
|
||||
|
||||
`gmPanelUI::ResizeTo @0x004BC6E0` is a bare tailcall into
|
||||
`UIElement::ResizeTo` — the HOST is what gets resized (via its
|
||||
Resizebar), not `0x2100002E`'s content root; the content root's own
|
||||
absent MinHeight/MaxHeight (confirmed above) is therefore consistent
|
||||
with retail's actual mechanism, not evidence of a missing DAT property.
|
||||
|
||||
**NOT PROBED by CT1.** The host elements above (`0x100005FE`,
|
||||
`0x10000180`, `0x1000018E`, `0x1000065C`, `0x10000660`) were read from
|
||||
the cited pseudocode doc, not re-probed live against the installed DAT
|
||||
this session. CT6's first step is to probe those host slots directly
|
||||
(min/max + resize authoring) before porting anything, then read
|
||||
`UIElement_Resizebar::StartMouseResizing @0x0046B7E0` verbatim for the
|
||||
drag-time clamp application.
|
||||
|
||||
**Size tension for CT3/CT6 to resolve.** `0x2100002E`'s own root is
|
||||
authored **300×600** (the Titles page alone is 300×575, plus the 25px
|
||||
tab bar = 600 — §3), but retail mounts that content into the host's
|
||||
**300×362** slot (`0x1000018E`). CT3's title ListBox height (`0x10000532`
|
||||
is 270×455 per §3) and CT6's resize contract both assume a taller
|
||||
available area than the host slot's authored 362px. This doc does not
|
||||
resolve which number governs at runtime (scroll-clipped content inside a
|
||||
fixed slot vs. the slot itself growing to accommodate) — CT6 must
|
||||
resolve it from the decomp before implementing the resize contract, not
|
||||
infer it from either number in isolation.
|
||||
|
||||
## 5. The title-string table (DAT ground truth for `CharacterTitleTable::GetCharacterTitleFromID`)
|
||||
|
||||
### Decomp chain (named-retail, `docs/research/named-retail/acclient_2013_pseudo_c.txt`)
|
||||
|
||||
`CharacterTitleTable::GetCharacterTitleFromID @0x005c6ed0` does **not**
|
||||
read a StringTable directly. It goes through TWO independent
|
||||
enum-mapper indirections:
|
||||
|
||||
1. `EnumMapper::GetString(0x10000006, titleId, &rawName)` (the static
|
||||
3-arg overload @`0x0041ac40`) — internally calls
|
||||
`DBObj::GetDIDByEnum(&did, 0x10000006, /*category*/1)` to resolve the
|
||||
**title EnumMapper object's DID**, then dispatches on
|
||||
`MasterDBMap::DivineType` (0x24 = EnumMapper) to call
|
||||
`EnumMapper::GetString(titleId, &rawName)` on it, giving a raw
|
||||
canonical string name (NOT yet localized/hashed).
|
||||
2. `compute_str_hash(rawName)` (ELF-style hash, already ported byte-exact
|
||||
as `DatStringResolver.ComputeHash` — see its own citation of
|
||||
`compute_str_hash @ 0x00413110`).
|
||||
3. `StringInfo::SetStringIDandTableEnum(&info, hash, 0x10000007)`
|
||||
(`@0x0042c760`) — internally calls
|
||||
`DBObj::GetDIDByEnum(&did, 0x10000007, /*category*/4)` to resolve the
|
||||
**title StringTable's DID**.
|
||||
4. `StringInfo::GetString(&info)` (`@0x0042e760` → `InqString` →
|
||||
`StringTableMetaLanguage::UnescapeString`) resolves the final localized
|
||||
text — the same `StringTable.Strings[hash]` lookup
|
||||
`DatStringResolver.Resolve(tableId, stringId)` already performs.
|
||||
|
||||
`DBObj::GetDIDByEnum(enumValue, category)` (`@0x004153a0` →
|
||||
`DBCache::GetDIDFromEnum @0x00413940`) is itself a **two-level indirection**
|
||||
through a master map object (`this->m_MasterMapID`): look up `category`
|
||||
in the master map to get an intermediate category-map DID, then look up
|
||||
`enumValue` in THAT map to get the final DID. This is the exact same
|
||||
mechanism already ported (empirically, not by name) as `RetailKeyNames`'
|
||||
`0x2300000A`/`0x2300000B`/`0x23000007` constants
|
||||
(`src/AcDream.App/UI/Layout/RetailKeyNames.cs`, citing "`DBCache::GetDIDFromEnumStatic`
|
||||
category 4") — this slice confirms those three constants ARE exactly the
|
||||
category-4 (STRINGTABLE) map's enum 4/5/3 entries (see table below), so
|
||||
the existing `RetailKeyNames` port is independently cross-validated by
|
||||
this investigation.
|
||||
|
||||
### Live-DAT resolution (verified end-to-end this session)
|
||||
|
||||
**The master-map / category-map tables and the `RetailKeyNames`
|
||||
cross-validation below are UNPINNED probe output** — they come from the
|
||||
same deleted `Assert.Fail` probe tests as the rest of this doc and are
|
||||
not backed by a committed `InstalledDatFact` assertion (unlike the
|
||||
`TitleStringTable_ResolvesWarMageEndToEnd` pin, which DOES commit the
|
||||
final two DIDs and the end-to-end string resolution). Treat the
|
||||
category-4 dump and the `RetailKeyNames` match column as this session's
|
||||
observation, re-derivable from the DAT but not regression-guarded.
|
||||
|
||||
`DatReaderWriter.DBObjs.EnumIDMap` (ACE's historical name: `DidMapper`,
|
||||
file-type byte `0x25`) is the object type both master and category maps
|
||||
use; `DatReaderWriter.DBObjs.EnumMapper` (file-type byte `0x22` on the
|
||||
installed dat) is the flat id→string table type.
|
||||
|
||||
**Master map, DID `0x25000000`** (`ClientEnumToID`/`ClientEnumToName`,
|
||||
22 entries) — the categories relevant here:
|
||||
|
||||
| category enum | name | category-map DID |
|
||||
|---|---|---|
|
||||
| 1 | EMAPPER | 0x25000001 |
|
||||
| 4 | STRINGTABLE | 0x25000004 |
|
||||
|
||||
**Category 1 (EMAPPER) map, DID `0x25000001`** — relevant entry:
|
||||
|
||||
| enum | name | DID |
|
||||
|---|---|---|
|
||||
| 0x10000006 | CharacterTitle | **0x22000041** |
|
||||
|
||||
**Category 4 (STRINGTABLE) map, DID `0x25000004`** — full dump (12 entries),
|
||||
confirming the `RetailKeyNames` constants along the way:
|
||||
|
||||
| enum | name | DID | cross-check |
|
||||
|---|---|---|---|
|
||||
| 0x00000003 | KeyMap | **0x23000007** | = `RetailKeyNames.DelimiterTableId` ✓ |
|
||||
| 0x00000004 | KeyNameOverride | **0x2300000A** | = `RetailKeyNames.KeyNameTableId` ✓ |
|
||||
| 0x00000005 | MetakeyNameOverride | **0x2300000B** | = `RetailKeyNames.MetaKeyNameTableId` ✓ |
|
||||
| 0x10000007 | CharacterTitle | **0x2300000E** | (this slice's target) |
|
||||
| 0x10000001 | UI | 0x23000001 | |
|
||||
| 0x10000002 | UI_Pregame | 0x23000002 | |
|
||||
| 0x10000003 | Preference | 0x23000003 | |
|
||||
| 0x10000004 | UI_Options | 0x23000004 | |
|
||||
| 0x10000006 | Options | 0x2300000D | |
|
||||
| 0x00000002 | Calendar | 0x23000006 | |
|
||||
| 0x00000006 | CommandSetup | 0x2300000C | |
|
||||
| 0x00000007 | ActionDescription | 0x23000005 | |
|
||||
| 0x00000008 | ServerEngine | 0x23000010 | |
|
||||
|
||||
So: **the title EnumMapper is DID `0x22000041`; the title StringTable is
|
||||
DID `0x2300000E`.**
|
||||
|
||||
### End-to-end verification (ACE's `CharacterTitle` enum, `WarMage = 13`)
|
||||
|
||||
```
|
||||
EnumMapper(0x22000041).IdToStringMap has 873 entries, including:
|
||||
titleId 0 -> ID_CharacterTitle_Invalid
|
||||
titleId 1 -> ID_CharacterTitle_Adventurer
|
||||
titleId 5 -> ID_CharacterTitle_Life_Mage
|
||||
titleId 13 -> ID_CharacterTitle_War_Mage
|
||||
titleId 14 -> ID_CharacterTitle_Wayfarer
|
||||
|
||||
ComputeHash("ID_CharacterTitle_War_Mage") = 0x0543AF05
|
||||
DatStringResolver(dats).Resolve(0x2300000E, 0x0543AF05) = "War Mage"
|
||||
```
|
||||
|
||||
Byte-exact confirmation the chain is understood correctly end to end —
|
||||
titleId 13 round-trips through the EnumMapper canonical-name lookup, the
|
||||
retail hash function, and the StringTable localization lookup to produce
|
||||
exactly "War Mage".
|
||||
|
||||
### What CT2 needs to port
|
||||
|
||||
1. Two `GetDIDByEnum`-shaped lookups (master map `0x25000000` → category
|
||||
map → target DID) — CT2 can either hardcode the two resolved DIDs
|
||||
(`0x22000041` for the EnumMapper, `0x2300000E` for the StringTable, the
|
||||
way `RetailKeyNames` hardcodes its three) or port the two-level
|
||||
indirection generically. **A THIRD consumer has now appeared** (found
|
||||
this fix round): `gmAttributeUI::PostInit @0x0049DB70` resolves
|
||||
per-attribute icon DIDs via `DBObj::GetDIDByEnum(statEnum, category
|
||||
0x10000002)` — see the "Row instantiation + icon-DID anchors" note in
|
||||
§2. With `RetailKeyNames` (category 4) and the title chain (categories
|
||||
1 and 4) already hardcoding resolved DIDs, this third independent
|
||||
category (`0x10000002`) is the point where CT2/CT5 should factor a
|
||||
shared `GetDIDByEnum(enumValue, category)` helper instead of adding a
|
||||
fourth ad-hoc hardcoded pair.
|
||||
2. `EnumMapper.IdToStringMap[titleId]` → raw canonical name (already
|
||||
readable via `dats.Portal.TryGet<DatReaderWriter.DBObjs.EnumMapper>`).
|
||||
3. `DatStringResolver.ComputeHash(rawName)` (already exists, no new code).
|
||||
4. `DatStringResolver.Resolve(0x2300000Eu, hash)` (already exists, no new
|
||||
code) for the final localized display string.
|
||||
|
||||
No new DAT-reading primitives are required — `EnumMapper`/`EnumIDMap` are
|
||||
already exposed by `DatReaderWriter.DBObjs`, and `DatStringResolver`
|
||||
already does steps 3–4 for other consumers.
|
||||
|
||||
## Corrections to the plan (summary)
|
||||
|
||||
1. **Window constraints are NOT already 90% wired — and the clamp
|
||||
mechanism is now VERIFIED, not guessed.** The plan's "Already in-tree"
|
||||
bullet claims `DatConstraintSource` registration for the character
|
||||
window; the actual `MountCharacter()` call sets no such field, and the
|
||||
DAT layout itself authors no MinHeight/MaxHeight on its root to source
|
||||
one from even if it were wired. `UIElement::ResizeTo @0x00463C30`
|
||||
clamps only via element attributes `0x3C`–`0x3F`, which nothing writes
|
||||
at runtime — the clamp source is always authored DAT properties on the
|
||||
resized element, full stop, and the resized element is the SHARED
|
||||
`gmPanelUI` host (`0x2100006E`, slot `0x1000018E`), not `0x2100002E`'s
|
||||
own root. See §4's "Verified resize mechanism" for the full chain and
|
||||
the unresolved 300×600-vs-300×362 size tension CT6 must still resolve.
|
||||
2. **The row-template elements are not walkable via the normal
|
||||
`ImportInfos(dats, layoutId)` overload.** `#375`'s prototype-skip logic
|
||||
deliberately excludes same-layout template-list targets from the built
|
||||
tree. CT5 must use `ImportInfos(dats, layoutId, elementId)` (the
|
||||
targeted single-root overload) to read `0x10000248`
|
||||
(`LayoutDesc 0x21000045`) and `0x10000536` (`LayoutDesc 0x2100005E`) —
|
||||
documented here so CT5 doesn't waste a cycle rediscovering the same
|
||||
"NOT FOUND" dead end this slice hit first.
|
||||
3. **`RowHighlightSprite` IS wrong — SEALED, not merely flagged.** The
|
||||
DAT's row-template Highlight state is `0x06000F93`, reached via
|
||||
`gmAttributeUI::UpdateSelection`'s `SetState(6)` →
|
||||
`InfoRegion::SetState` on the row itself; the current constant in
|
||||
`CharacterStatController.cs` is `0x06001397`, which belongs to a
|
||||
DIFFERENT mechanism (the spellbook row's selected-overlay child,
|
||||
`UIElement_UIItem::SetSelectedState`). CT5 must correct the STAT rows'
|
||||
`RowHighlightSprite` to `0x06000F93` and must NOT touch
|
||||
`SpellbookRowStyle.cs` — see §2's "SEALED VERDICT" note for the full
|
||||
anchor chain.
|
||||
4. Everything else in the plan's "Retail recon" section (the Titles page
|
||||
element roster, the header element ids, the PostInit binding order)
|
||||
checks out exactly against the live DAT — no other corrections.
|
||||
5. **Several findings above are probe-session observations, not
|
||||
committed pins** — flagged this fix round so CT2–CT6 don't cite them
|
||||
as regression-guarded facts: the header block's "two copies
|
||||
geometrically identical" claim (only fonts/colors are pinned, not
|
||||
full geometry — §1), the `0x06004CC2` "generic panel chrome"
|
||||
characterization (§2), and the master-map/category-map dump plus the
|
||||
`RetailKeyNames` cross-validation table (§5). The title chain's final
|
||||
two DIDs and end-to-end string resolution ARE pinned
|
||||
(`TitleStringTable_ResolvesWarMageEndToEnd`).
|
||||
|
||||
## §CT6 — the shared-host resize clamp (2026-08-25 live probe)
|
||||
|
||||
**Verdict: the resize clamp source is the shared `gmPanelUI` host
|
||||
(`0x100005FE` in LayoutDesc `0x2100006E`), not `0x2100002E`'s own root
|
||||
and not the Character/Skills slot `0x1000018E` either.** Probed with a
|
||||
temporary test dumping layout `0x2100006E` via both the whole-layout
|
||||
walk and the targeted single-root overload (deleted before commit; the
|
||||
pattern is preserved by the committed pin
|
||||
`CharacterPanelLiveDatTests.PanelHost_AuthorsFixedWidthAndBottomOnlyResizeContract`).
|
||||
|
||||
### Host element `0x100005FE`
|
||||
|
||||
```
|
||||
Type=0x1000002F (gmPanelUI) X=0 Y=0 W=310 H=372
|
||||
MinWidth=310 MaxWidth=310 (fixed — no horizontal Resizebar authored)
|
||||
MinHeight=372 MaxHeight=1000
|
||||
17 children, including (all DIRECT children of the host, siblings of
|
||||
the content parent, not nested under it):
|
||||
0x10000180 content parent Type=3 X=5 Y=5 W=300 H=362
|
||||
0x1000065C top-center Dragbar Type=2 X=5 Y=0 W=300 H=5
|
||||
0x10000660 bottom-center Resizebar Type=9 X=5 Y=367 W=300 H=5
|
||||
(+ 14 border/corner chrome pieces, 0x10000653-0x10000662)
|
||||
```
|
||||
|
||||
`MinHeight == 372 == the host's own authored default height`: retail's
|
||||
Character/Skills window can only be resized TALLER (up to 1000px), never
|
||||
shorter than its own authored default — this IS the "resizable in Y down
|
||||
to an authored minimum" the owner reported; 372 is that floor, not an
|
||||
arbitrary smaller number.
|
||||
|
||||
The Character/Skills slot `0x1000018E` (the SAME structural role CT1
|
||||
probed for `0x2100002E`'s standalone root `0x10000227`, but reached
|
||||
through the shared host this time) itself authors **no** MinWidth/
|
||||
MinHeight/MaxWidth/MaxHeight — confirming the clamp is exclusively the
|
||||
HOST's, not layered again on the slot:
|
||||
|
||||
```
|
||||
0x1000018E Type=8 (TabControl) X=0 Y=0 W=300 H=362
|
||||
Min=(null,null) Max=(null,null)
|
||||
children: 0x10000228/29 (tab buttons), 0x1000022A (close button),
|
||||
0x10000538 (Titles tab), 0x10000539 (Titles page),
|
||||
0x1000022B/2C (Attributes/Skills pages) — same ids `0x2100002E`
|
||||
imports, reached here via `0x1000018E`'s BaseElement inheritance
|
||||
from `0x10000227` (same mechanism CT1 §3 documents for the Titles
|
||||
row template's `0x1000052D`/`0x10000536` pair).
|
||||
```
|
||||
|
||||
### Decomp chain confirming which element the drag clamp applies to
|
||||
|
||||
- `UIElement_Resizebar::StartMouseResizing @0x0046B7E0`: `eax_1 =
|
||||
this->vtable->GetParent()` then `UIElement::StartResizing(eax_1,
|
||||
border, x, y)` — the drag state (`m_DragStartWidth/Height`,
|
||||
`m_currentBorder`) is stashed on the RESIZEBAR'S PARENT, confirmed
|
||||
live to be the host `0x100005FE` (§ above), not the content parent.
|
||||
- `UIElement::StartResizing @0x0045fca0`: pure state setup
|
||||
(`m_DragStartX/Y/Width/Height`, `m_currentBorder`) on `this` — no
|
||||
clamp read here.
|
||||
- `UIElement::MouseResizeElement @0x00461130`: the actual per-mouse-move
|
||||
resize application. Reads `GetAttribute_Int(this, 0x3F)` (min width),
|
||||
`0x3D` (max width), `0x3E` (min height), `0x3C` (max height) — all off
|
||||
`this`, the SAME element `StartResizing` was called against. Since
|
||||
that element is the host (per the GetParent() call above), the host's
|
||||
own authored 0x3C..0x3F values are what govern every live drag.
|
||||
|
||||
This matches — and completes — the "Verified resize mechanism" section
|
||||
already in this doc (`UIElement::ResizeTo @0x00463C30`'s equivalent
|
||||
clamp for the programmatic path): both the interactive drag
|
||||
(`MouseResizeElement`) and the programmatic call (`ResizeTo`) read
|
||||
0x3C..0x3F off the SAME element, and that element is always whichever
|
||||
one is actually being resized — the host, never the character content
|
||||
root or the slot.
|
||||
|
||||
### Production wiring (`RetailUiRuntime.MountCharacter`)
|
||||
|
||||
`MountCharacter` now imports `ElementInfo? hostConstraint =
|
||||
LayoutImporter.ImportInfos(dats, 0x2100006Eu, 0x100005FEu)` (the same
|
||||
targeted single-root overload CT1 established for row templates) and
|
||||
passes it as `RetailWindowFrame.Options.DatConstraintSource`.
|
||||
|
||||
**CORRECTED (CT6 fix round, BLOCKER B1, 2026-08-25):** this section
|
||||
originally claimed the NineSlice chrome inset (`2 *
|
||||
RetailChromeSprites.Border` = 10px) "is added automatically by
|
||||
`RetailWindowFrame.ResolveConstraint`, giving the mounted outer frame
|
||||
MinWidth=MaxWidth=320, MinHeight=382, MaxHeight=1010." That was WRONG.
|
||||
Host `0x100005FE` is not a bare content element our own NineSlice
|
||||
wrapper adds chrome to — per the geometry dumped above, it IS retail's
|
||||
own complete outer window frame: 5px bevel + the 300×362 content parent
|
||||
(`0x10000180`) + 5px = 310×372 exactly. Its authored 0x3C..0x3F values
|
||||
are therefore already CHROME-INCLUSIVE. Composing the wrapper's own 10px
|
||||
inset on top of an already chrome-inclusive source double-counted the
|
||||
bevel: the mounted window's clamp said MinWidth=320 while its actual
|
||||
mounted outer width was only 310 — the window opened already violating
|
||||
its own minimum, silently "fixed" at runtime only because
|
||||
`RetailWindowManager.ResizeTo`'s main-panel geometry sync forcibly
|
||||
widened it to 320 despite `ResizeX=false`, which would have produced a
|
||||
visible 15px right-bevel seam against the other eight main panels
|
||||
sharing that sync.
|
||||
|
||||
**Fix:** a new opt-out,
|
||||
`RetailWindowFrame.Options.DatConstraintSourceIsOuterFrame` (default
|
||||
`false`, preserving every other window's existing content-plus-chrome
|
||||
behavior), tells `ResolveConstraint` to apply a chrome inset of 0 when
|
||||
the DAT source is itself already the outer frame. `MountCharacter` sets
|
||||
it `true` for `hostConstraint`. The mounted outer clamps are now EXACTLY
|
||||
the host's four raw values, no composed arithmetic: **MinWidth=
|
||||
MaxWidth=310, MinHeight=372, MaxHeight=1000.** A new mount-time
|
||||
invariant in `RetailWindowFrame.Mount` (throws if the just-mounted outer
|
||||
extent falls outside its own just-computed clamp) guards against this
|
||||
class of bug recurring for any window this path mounts.
|
||||
`ResizeX=false`/`ResizableEdges=Bottom` (already correct in the
|
||||
pre-CT6 code) match the fixed-width/bottom-only-Resizebar authoring
|
||||
exactly — no change needed there.
|
||||
|
||||
### Which size governs the mount default (S4, campaign-lead ruling, 2026-08-25)
|
||||
|
||||
The "Size tension" flagged earlier in this doc (§4, "Correction to the
|
||||
plan") — `0x2100002E`'s own root authored 300×600 vs. the host slot's
|
||||
300×362 — is now resolved: **372px outer (362px content) is the number
|
||||
that governs the MOUNTED DEFAULT**, and 600 is the content's own
|
||||
authored design canvas that retail scroll-clips into the much smaller
|
||||
host slot, never the size the window actually opens at. Concretely:
|
||||
`0x2100002E`'s 300×600 root exists because its Titles page alone is
|
||||
authored 300×575 (Y=25 offset + the 25px tab bar — §3 above) — that is
|
||||
real, authored geometry, and the Titles page correctly stretches to fill
|
||||
whatever height the mounted window offers via its own `LayoutPolicy`
|
||||
(confirmed below). But retail never displays that full 600px canvas at
|
||||
once outside of the Titles tab's own internal scroll: the shared
|
||||
`gmPanelUI` host's content parent (`0x10000180`) is fixed at 300×362,
|
||||
and 372 (362 + the 10px chrome bevel) is exactly the host's own
|
||||
authored MinHeight — i.e., retail's Character/Skills window OPENS at
|
||||
its own resize floor and can only be dragged taller, never shorter.
|
||||
Pre-fix, `MountCharacter` left `Options.ContentHeight` unset, so it fell
|
||||
back to `content.Width`/`content.Height` — the raw 600px canvas — giving
|
||||
a stale 610px (600 + 10 chrome) mounted default that was never retail's
|
||||
actual opening size and was 238px taller than the true floor. Fixed by
|
||||
setting `Options.ContentHeight = 362f` explicitly (the same host
|
||||
content-parent height this section already probed and cited, not a new
|
||||
number). At the corrected 372px default, the authored page composition
|
||||
(header 112px + list 160px + divider + footer) is the true 362px design;
|
||||
the 9 attribute/vital rows (9 × 20 = 180px content) OVERFLOW the 160px
|
||||
list immediately, so the stat list's scrollbar is active from the moment
|
||||
the window opens — this is retail-correct (see §S2 below for what
|
||||
"active" means given the corrected `HideWhenDisabled` finding), not a
|
||||
regression introduced by the fix.
|
||||
|
||||
### Titles-page list reflow
|
||||
|
||||
The Titles page's own container (`0x10000539`) and its ListBox
|
||||
(`0x10000532`) both already carry a REAL authored `LayoutPolicy` in the
|
||||
committed/installed layout (live-verified: `LayoutPolicy is not null`
|
||||
for both) that correctly stretches with the mounted content's height —
|
||||
no code was needed to make the PAGE itself reflow.
|
||||
|
||||
**CORRECTED (CT6 fix round, S3, 2026-08-25):** this section originally
|
||||
went on to claim "the only missing piece was the ListBox's own
|
||||
compatibility fallback (`Anchors = Left|Top|Bottom`, engaged only when
|
||||
`LayoutPolicy is null` — a no-op on the real DAT, but needed for
|
||||
synthetic/test layouts...), added in `CharacterTitlesController.Bind`."
|
||||
That framing was wrong: both `0x10000532` and `0x10000539` author
|
||||
`HasOriginalParentSize=true` (the field `DatWidgetFactory` gates
|
||||
`LayoutPolicy` assignment on), confirmed both on the real installed DAT
|
||||
and the committed fixture — `LayoutImporter`/`DatWidgetFactory` ALWAYS
|
||||
assigns a real `LayoutPolicy` to these elements, so the `if
|
||||
(listBox.LayoutPolicy is null)` branch never ran anywhere, not even in
|
||||
the "synthetic/test layouts" case it was written to cover. It was dead
|
||||
code, not a harmless no-op fallback. Deleted from
|
||||
`CharacterTitlesController.Bind`; a new
|
||||
`CharacterPanelLiveDatTests.TitlesListAndPage_AuthorHasOriginalParentSize`
|
||||
pin asserts `HasOriginalParentSize` on both `0x10000532` and `0x10000539`
|
||||
to guard the deletion against future DAT drift.
|
||||
|
||||
### Correction to the scrollbar-visibility finding (S2, 2026-08-25)
|
||||
|
||||
The CT6 landing notes (in the campaign plan ledger) claimed
|
||||
`UiScrollbar`'s own `IsPresentationVisible`/`IsModelDisabled` "already
|
||||
draw the correct full-track 'disabled' thumb when content fits
|
||||
(`HideWhenDisabled` defaults false)." That had the authored default
|
||||
BACKWARDS. Both `0x1000023E` (the shared Attributes/Skills list
|
||||
scrollbar) and `0x10000533` (the Titles list's own scrollbar) author
|
||||
property `0x79` (`HideWhenDisabled`) **TRUE** in the committed fixture —
|
||||
verified directly against `tests/AcDream.App.Tests/UI/Layout/fixtures/character_2100002E.json`,
|
||||
property `"121"` (=0x79) carries `"BoolValue": true` on both elements. A
|
||||
fitting list HIDES the bar entirely (`IsPresentationVisible` false); it
|
||||
does not leave a full-track "disabled" thumb on screen. The CODE in
|
||||
`CharacterStatController.RebuildActiveList` was already correct —
|
||||
`.Visible = true` only keeps the bar bound in the tree, and
|
||||
`IsPresentationVisible` is the actual show/hide computation — only the
|
||||
description was wrong. Given the S4 correction above (mount default is
|
||||
now the compact 372px floor), the practical consequence is: the stat
|
||||
list's scrollbar is VISIBLE and interactive from the moment the window
|
||||
opens (rows overflow at the default), and DISAPPEARS once the window is
|
||||
grown enough that all rows fit — the opposite of what the uncorrected
|
||||
description implied.
|
||||
|
||||
### A latent anchor-baseline bug this slice surfaced and fixed
|
||||
|
||||
`CharacterStatController.RebuildActiveList`'s (and, before CT6, only the
|
||||
Skills tab's) `UiScrollablePanel` viewport is constructed with `Height =
|
||||
statList.Height` **before** the window's first anchor pass ever grows
|
||||
`statList` from its raw DAT-authored height (160px) up to its actual
|
||||
mounted height. Left alone, the viewport's own `Left|Top|Bottom` anchor
|
||||
captures its baseline margins lazily on ITS OWN first `ApplyAnchor` call
|
||||
— which happens AFTER `statList` has already grown — measuring a bogus
|
||||
non-zero bottom margin that then permanently caps the viewport short on
|
||||
every later resize (the exact `#372`/`#412`-class bug `UiTemplateListBox
|
||||
.Viewport`'s own lazy getter already works around). Fixed by calling
|
||||
`viewport.CaptureCurrentAnchorBaseline()` immediately after
|
||||
`statList.AddChild(viewport)`, while `viewport.Height` still exactly
|
||||
equals `statList`'s own current (pre-reflow, zero-margin) height. This
|
||||
was previously unexercised/untested for Skills (no test asserted its
|
||||
viewport's exact height against a real window resize) and is now proven
|
||||
by `CharacterStatControllerTests
|
||||
.CharacterWindow_ResizesYWithinAuthoredHostClamp_AndReflowsListAndScrollbar`.
|
||||
|
||||
### No register row
|
||||
|
||||
Every number in this section is either a live-probed authored DAT value
|
||||
or a structural wiring/anchor-capture-correctness fix — nothing here is
|
||||
inferred or approximated.
|
||||
|
||||
**CORRECTED (CT6 fix round, N5, 2026-08-25):** at initial landing, this
|
||||
sentence was not actually true — the mounted 320/382/1010 clamp WAS an
|
||||
inference (BLOCKER B1: the host's already chrome-inclusive values with a
|
||||
second, redundant chrome inset composed on top). After the B1 fix
|
||||
removes that composition, the mounted clamp is now literally the host's
|
||||
own four probed values (310/310/372/1000) with zero arithmetic applied,
|
||||
so the sentence holds for real. The S4 content-height default (362) is
|
||||
likewise not a new inferred number — it is the same host content-parent
|
||||
width/height (`0x10000180`, 300×362) this section already probed and
|
||||
cited above, applied to `Options.ContentHeight` instead of being left
|
||||
unset.
|
||||
250
docs/research/2026-08-25-campaign-as-ground-truth.md
Normal file
250
docs/research/2026-08-25-campaign-as-ground-truth.md
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
# Campaign AS ground truth — assess/examination window, PLAYER targets
|
||||
|
||||
**AS1 synthesis (2026-08-25).** Three-lens research: our pipeline map, ACE
|
||||
wire truth (`references/ACE`, cross-checked byte-identical against
|
||||
`references/Chorizite.ACProtocol`), and the named 2013 retail decomp
|
||||
(`docs/research/named-retail/acclient_2013_pseudo_c.txt`). This document is
|
||||
the ORACLE for every Campaign AS slice. Addresses are acclient 2013
|
||||
v11.4186; property ids verified against ACE enums.
|
||||
|
||||
## 0. The headline findings
|
||||
|
||||
1. **The 0x00C9 parse is already complete.** `AppraiseInfoParser`
|
||||
(`src/AcDream.Core.Net/Messages/AppraiseInfoParser.cs`) parses every
|
||||
flag retail parses, in retail's exact section order, INCLUDING
|
||||
`ArmorLevels = 0x4000` (nine u32s). `Parsed.ArmorLevels` is a dead
|
||||
field — no production reader. **No wire/parse work is needed.**
|
||||
2. **Everything missing already arrives from ACE** on a successful player
|
||||
assess (see §3/§4). The whole campaign is client-side composition.
|
||||
3. **The core defect is an element MIS-MAPPING** in
|
||||
`AppraisalUiController.ApplyCreature(character: true)`
|
||||
(`src/AcDream.App/UI/Layout/AppraisalUiController.cs:673-708`): retail's
|
||||
`CharExamineUI` ctor `@0x004AD3C0` binds `0x10000150` = Heritage,
|
||||
`0x10000151` = Profession(title), `0x10000152` = PlayerKiller,
|
||||
`0x1000053A` = AllegianceName — we feed them raw-HeritageGroup-string /
|
||||
AllegianceName / MonarchsName / the invented literal
|
||||
`"Assessment incomplete"` (zero retail provenance; the only occurrence
|
||||
in the repo).
|
||||
4. **Zero tests exercise `AppraisalView.Character`** — how the mis-mapping
|
||||
survived the original Slice 3 gate (that gate checked page selection
|
||||
only).
|
||||
5. Retail composes these lines from `u"..."` code literals (not
|
||||
StringTables), same as the item report — matching retail means literals
|
||||
here, with DAT/EnumMapper lookups exactly where retail does them
|
||||
(gender/heritage/title chains, attribute names).
|
||||
|
||||
## 1. Retail class map
|
||||
|
||||
`gmExaminationUI` (element-type 0x1000001C, `Register @0x004AB780`;
|
||||
pinnable `gmFloatyExaminationUI` = 0x1000004C). LayoutDesc `0x2100006B`
|
||||
(DAT-side; already our mount). Sub-UIs implement `ExamineSubUI`
|
||||
(`acclient.h:54747`): `Init` / `SetAppraiseInfo(profile, isNewObject)` /
|
||||
`Show`.
|
||||
|
||||
| Class | Methods that matter |
|
||||
|---|---|
|
||||
| `gmExaminationUI` | ctor `0x004AB2B0`; `PostInit 0x004AD6A0`; `RecvNotice_ExamineObject 0x004AB7B0`; dispatcher `SetAppraiseInfo 0x004ADAE0`; `SetTitleText 0x004AD930`; `UseTime 0x004AB530` (0.75 s combat refresh) |
|
||||
| `BasicCreatureExamineUI` | ctor `0x004ACD30`; `SetAppraiseInfo 0x004B3F70`; `SetLevelValueText 0x004B3E70`; `AddLineToMiscInfo 0x004ABBB0` |
|
||||
| `CreatureExamineUI` (monsters) | `SetAppraiseInfo 0x004B3FF0` |
|
||||
| **`CharExamineUI` (players)** | ctor `0x004AD3C0`; **`SetAppraiseInfo 0x004B45F0`** — the full player line composer |
|
||||
|
||||
Sub-UI dispatch (`0x004ADAE0`): creature profile AND (String 5 Template OR
|
||||
Int 261 CharacterTitleId) → CharExamineUI. Our `SelectView`
|
||||
(`AppraisalUiController.cs:920-928`) already matches.
|
||||
|
||||
Widget ids: title `0x1000012D`; level value `0x1000014C`; attribute list
|
||||
`0x10000149`; extra list `0x10000335`; row template label `0x1000012A` /
|
||||
value `0x1000012B`; header texts `0x10000150/0x10000151/0x10000152/0x1000053A`;
|
||||
viewport `0x10000148`.
|
||||
|
||||
## 2. Player line composition (CharExamineUI::SetAppraiseInfo @0x004B45F0)
|
||||
|
||||
"colorIdx" = 4th arg of `UIElement_Text::SetTextWithFont @0x0046A500` —
|
||||
the authored alternate-font-color index (attr 0x1B/0x1D list): 0 default,
|
||||
**1 = buffed/green, 2 = debuffed/red, 3 = unknown/failed** (semantics
|
||||
pinned by ACE `CreatureProfile.cs:44-45`). Exact authored RGBA lives in
|
||||
LayoutDesc 0x2100006B (DAT), not the binary.
|
||||
|
||||
### 2a. Fixed header elements
|
||||
|
||||
| Element | Source | Composition |
|
||||
|---|---|---|
|
||||
| Title bar `0x1000012D` | `SetTitleText @0x004AD930`: String 52 override else appropriate object name; stack-count prefix | then the char path OVERWRITES with `AllegianceData::GetFullName @0x005B6950` = `AllegianceSystem::GetTitle(rank=Int 30, heritage=Int 188, gender=Int 113) + " " + name` ("Baroness Aluvia"); plain name when no rank title |
|
||||
| Heritage `0x10000150` | Int 113 Gender, Int 188 HeritageGroup (Int 2 CreatureType only if heritage==0) | `AppraisalSystem::InqGenderHeritageDisplay @0x005B5AE0` → `"<Gender> <Heritage>"`. Gender: EnumMapper `0x10000001`; heritage: EnumMapper `0x10000002` with hardcoded overrides `2→"Gharu'ndim"`, `5→"Umbraen"`, `13→"Olthoi"`; creature fallback EnumMapper `0x10000005` (underscore→space) |
|
||||
| Title `0x10000151` | Int 261 CharacterTitleId, fallback String 5 Template | `CharacterTitleTable::GetCharacterTitleFromID @0x005C6ED0`: EnumMapper `0x10000006` → `compute_str_hash` → StringTable behind table-enum `0x10000007`. Current display title only. Same pipeline as `CharacterTitleResolver` (CT campaign). |
|
||||
| PK `0x10000152` | **NOT from the appraisal payload** — local `cur_weenobj` PWD bits: `IsPK() @0x0058C8B0` → `u"Player Killer"`, `IsPKLite() @0x0058C8A0` → `u"Player Killer Lite"`, else `u"Non-Player Killer"`. Skipped (left cleared) if the weenie is gone. Ours: `ClientObject.PublicWeenieBitfield` + `PlayerKillerStatusBitfield` (bits 0x20 / 0x02000000) — the exact port already exists (`ClientObject.cs:439-451`, per AP-109's CT4 correction). |
|
||||
| Allegiance `0x1000053A` | String 47 AllegianceName | plain set, element cleared first; only inside the `Int 30 AllegianceRank >= 1` gate |
|
||||
| Level `0x1000014C` | Int 25 | `SetLevelValueText @0x004B3E70`: >0 → comma-grouped; ≤0/absent → `"???"` |
|
||||
|
||||
### 2b. Extra-info list rows (list `0x10000335`), EXACT ORDER
|
||||
|
||||
Every row = `AddLineToMiscInfo(label, value, colorIdx) @0x004ABBB0`.
|
||||
Literals: `"%d"` @0x794344/0x7A0184; `"*%d"` @0x7B110C; `u"???"` @0x7B0F34;
|
||||
`L""` spacer @0x794320.
|
||||
|
||||
| # | Gate | Label | Value | Color |
|
||||
|---|---|---|---|---|
|
||||
| 1 | **PRESENCE of Int 281** Faction1Bits — `AppraisalProfile::InqInt @0x005B3830` returns found/not-found, NOT value≠0 (AS4-review adjudicated); present-and-zero → `Society: ???` | `Society:` | society name (+` ~ <rank>` band): bit1→"Celestial Hand"+Int 287, bit2→"Eldrytch Web"+Int 288, then the `???` arm, THEN bit4→"Radiant Blood"+Int 289 (retail's odd test order); unrecognized→`???`. Bands: 1–100 Initiate, 101–300 Adept, 301–600 Knight, 601–1000 Lord, 1001–1500 Master; outside bands → name alone | vs LOCAL player's Faction1Bits, SAME-BIT-FIRST (`@0x004b49fd/@0x004b4a49/@0x004b4a8b`): local carries the target's bit → 1 (green) even when local also carries other bits; else local carries any other society bit → 2 (red); else 0 |
|
||||
| 2 | Int 30 ≥ 1 | allegiance rows | Str 21 MonarchsTitle absent → `Alleg. Monarch:` + `%d Follower`/`%d Followers` (Int 35, clamp ≥0). Str 21 present, Str 35 PatronsTitle absent → `Monarch:`+Str21. Both present, equal → one row `Monarch/Patron:`. Different → two rows `Monarch:` / `Patron:` | 0 |
|
||||
| 3 | any of 9 AL > 0 | spacer, then `Head/Chest/Groin`, `Bicep/Wrist/Hand`, `Thigh/Shin/Foot` | per part `"%d"`, or `"*%d"` with (value−9999) when ≥9999 (unenchantable sentinel); value cell = `"AL: %s/%s/%s"`. Nine dwords in order head, chest, groin(=Abdomen), bicep(=UpperArm), wrist(=LowerArm), hand, thigh(=UpperLeg), shin(=LowerLeg), foot | 0 |
|
||||
| 4 | Int 307\|313\|314 > 0 | spacer, then `Dmg/CritDmg` | `Rating: %d/%d` ← (307 DamageRating, 314 CritDamageRating); 313 gates only | 0 |
|
||||
| 5 | Int 308\|315\|316 > 0 | [spacer if row 4 absent] `Dmg/CritDmg` | `Resist: %d/%d` ← (308, 316); 315 gates only | 0 |
|
||||
| 6 | Int 350\|351 > 0 | [spacer if none yet] `DoT/Life:` | `Resist: %d/%d` ← (350, 351) | 0 |
|
||||
| 7 | any of rows 4–6 shown | trailing spacer | | |
|
||||
| 8 | Str 10 Fellowship | `Fellowship:` | verbatim | 0 |
|
||||
| 9 | Str 43 DateOfBirth | `Arrived in Dereth:` | verbatim (server-formatted) | 0 |
|
||||
| 10 | Int 125 Age | `Time in Dereth:` | `ClientUISystem::DeltaTimeToString(seconds)` | 0 |
|
||||
| 11 | Int 181 ChessRank | `Chess Rank:` | `%d` | 0 |
|
||||
| 12 | Int 192 FakeFishingSkill | `Fishing Skill:` | `%d` | 0 |
|
||||
| 13 | Int 43 NumDeaths | `Deaths:` | ≤0 → `Has never died`, else `%d` | 0 |
|
||||
| 14 | Int 262 NumCharacterTitles | `Titles Earned:` | `%d` | 0 |
|
||||
| 15 | unconditional (per decomp — see R3) | `* = Unenchantable` in the LABEL slot, empty value | legend | 0 |
|
||||
|
||||
Then the tail calls `BasicCreatureExamineUI::SetAppraiseInfo @0x004B3F70`
|
||||
(level + attribute token update).
|
||||
|
||||
Note: retail's ratings format strings are `"%Rating: %d/%d"` /
|
||||
`"%Resist: %d/%d"` — the leading `%R` is invalid printf that msvcrt renders
|
||||
as literal `R…`, so the display text is `Rating: x/y` / `Resist: x/y`. Our
|
||||
existing `CreatureAppraisalRows.BuildExtra` output matches the display.
|
||||
|
||||
### 2c. Attribute/vital rows (list `0x10000149`)
|
||||
|
||||
Created once in ctor order **Str, End, Coord, Quick, Focus, Self** then
|
||||
**Health (percent shown), Stamina, Mana**; labels via
|
||||
`SkillSystem::InqAttributeName @0x005C8D90` (StringTable-backed).
|
||||
`AttributeInfoRegion::Update @0x004F1D90`: `"%d"`, 0 → `"???"`; color:
|
||||
success==0 → 3, else enchant HI bit → 1 (green) / LO-without-HI → 2 (red)
|
||||
(`InqAttributeEnchantmentMod @0x005B5EC0`).
|
||||
`Attribute2ndInfoRegion::Update @0x004F1E80`: `%d/%d`, Health
|
||||
`%d/%d (%d %%)`; **success==0: Health shows `%d %%` ONLY; Stamina/Mana show
|
||||
`???`**; same color rule on the MAX-vital bits.
|
||||
|
||||
## 3. ACE wire truth (what the local server sends for a player)
|
||||
|
||||
Serialization: flags dword, Success dword, then sections in retail's parse
|
||||
order (Int 0x1 → Int64 0x2000 → Bool 0x2 → Float 0x4 → String 0x8 → DID
|
||||
0x1000 → SpellBook 0x10 → ArmorProfile 0x80 → CreatureProfile 0x100 →
|
||||
WeaponProfile 0x20 → HookProfile 0x40 → ArmorEnch 0x200 → WeaponEnch 0x800
|
||||
→ ResistEnch 0x400 → ArmorLevels 0x4000). Byte-identical vs Chorizite.
|
||||
Player targets set: Int (always), String (usually), DID (appearance dids),
|
||||
CreatureProfile (always, even failed), ArmorLevels (Success only).
|
||||
|
||||
**ArmorLevels** (`ACE .../Structure/ArmorLevel.cs:85-96`): nine u32
|
||||
Head, Chest, Abdomen, UpperArm, LowerArm, Hand, UpperLeg, LowerLeg, Foot.
|
||||
Sent when `Success && (Player || !Attackable)`. Values are **BUFFED**
|
||||
(base + enchant mod per covering Clothing layer, clamped ≥0); if EVERY
|
||||
covering layer is unenchantable the part gets **+9999** (the `*` sentinel).
|
||||
Chorizite's "BaseArmor*" naming is misleading — trust ACE.
|
||||
|
||||
**Identity ints:** Gender **113**, HeritageGroup **188**,
|
||||
PlayerKillerStatus **134** (ACE enum NPK 0x02 / PK 0x04 / PKLite 0x40 — NOT
|
||||
the PWD bit layout; retail reads the local weenie's PWD bits instead, and so
|
||||
do we), CharacterTitleId **261**, NumCharacterTitles **262**, Level **25**.
|
||||
The display strings never ride the wire — client-composed.
|
||||
|
||||
**Option-gated extras** (removed from the tables when the TARGET's option
|
||||
is off; `AppraiseInfo.cs:352-366`): DateOfBirth str **43**
|
||||
(AllowOthersToSeeYourDateOfBirth), Age int **125**, ChessRank int **181**,
|
||||
FakeFishingSkill int **192**, NumDeaths int **43**, NumCharacterTitles int
|
||||
**262**. Client renders what's present — no client-side option logic.
|
||||
|
||||
**Ratings** (Success only, nonzero only, straight into IntStats):
|
||||
Damage **307** (+5 heritage-weapon bonus applied server-side),
|
||||
DamageResist **308**, Crit **313**, CritDamage **314**, CritResist **315**,
|
||||
CritDamageResist **316**, HealingBoost **323**, NetherResist **331**,
|
||||
DotResist **350**, LifeResist **351**, GearMaxHealth **379**, PKDamage
|
||||
**381**, PKDamageResist **382**. Retail renders only
|
||||
307/314 · 308/316 · 350/351 (313/315 gate; 323 read but never rendered).
|
||||
|
||||
**Allegiance/faction:** AllegianceName str **47**; monarch targets get
|
||||
AllegianceFollowers int **35**; non-monarch get MonarchsTitle str **21**
|
||||
(server-composed "<rank title> <name>") + PatronsTitle str **35**;
|
||||
AllegianceRank int **30**; Faction1Bits int **281** (masked to 0x7);
|
||||
Society ranks ints **287/288/289**; Fellowship str **10**.
|
||||
|
||||
**Failure semantics:** failed assess (target's Deception beats examiner's
|
||||
AssessPerson, target opted into AttemptToDeceiveOtherPlayers) → Success=0
|
||||
but ACE STILL sends full int/string/DID tables; only CreatureProfile
|
||||
attributes (ShowAttributes flag 0x8), ratings, and ArmorLevels are gated.
|
||||
Target sees "X tried and failed to assess you!"; retries inside 5 s
|
||||
auto-fail. Guid-not-found → `Flags=0, Success=0` only.
|
||||
|
||||
## 4. Current acdream state (what exists, what's wrong)
|
||||
|
||||
- Window: `AppraisalUiController` (LayoutDesc 0x2100006B, root 0x100005F2),
|
||||
mounted as independent top-level floaty; subviews Item/Creature/
|
||||
Character/Spell; `UiText` + `UiItemList`/`UiTemplateListSlot` rows from
|
||||
DAT template `0x10000166`; `CreatureAppraisalLayeredList` two-list
|
||||
chrome/foreground split over one `UiScrollable`; animated clone in
|
||||
`UiViewport 0x10000148` (`CreatureAppraisalPresentation`).
|
||||
- Route: `GameEventWiring.cs:1003` → `LiveSessionEventRouter.OnAppraisal` →
|
||||
`RetailUiRuntime.HandleAppraisal` → `Apply(Parsed)`.
|
||||
- Stale-response rejection, 0.75 s visible-combat refresh, busy-cursor
|
||||
balance: present and pinned.
|
||||
- Ratings rows exist (`CreatureAppraisalRows.BuildExtra`,
|
||||
`CreatureAppraisalRows.cs:97-146`) with the retail grouping pinned by
|
||||
`ExtraRatingsFollowRetailGroupingFormattingAndSeparators`. BuildExtra
|
||||
takes only a `PropertyBundle` — it cannot see `Parsed.ArmorLevels`
|
||||
(signature change needed; armor rows precede rating rows).
|
||||
- Attribute rows: nine ordered rows + `???` failure semantics + enchant
|
||||
bit styles pinned in `CreatureAppraisalRowsTests`.
|
||||
- Reusable CT ports: `CharacterIdentityText` (gender/heritage composition,
|
||||
`CharacterIdentityText.cs:81-117`), `CharacterTitleResolver` (EnumMapper
|
||||
0x22000041→hash→StringTable 0x2300000E — the SAME retail pipeline; the
|
||||
decomp's 0x10000006/0x10000007 are element-scope enum ids resolved to
|
||||
those DAT ids), `PlayerKillerStatusBitfield`.
|
||||
|
||||
### Gap ledger
|
||||
|
||||
| # | Gap | Fix slice |
|
||||
|---|---|---|
|
||||
| G1 | Heritage slot fed raw string prop instead of composed gender+heritage | AS2 |
|
||||
| G2 | Title slot fed AllegianceName; title never displayed | AS2 |
|
||||
| G3 | PK slot fed MonarchsName; PK never displayed | AS2 |
|
||||
| G4 | Armor-level trio absent (parsed, never read) | AS3 |
|
||||
| G5 | `0x1000053A` shows invented "Assessment incomplete" instead of AllegianceName | AS2 |
|
||||
| G6 | Society/faction row absent | AS4 |
|
||||
| G7 | Monarch/Patron/Followers rows absent | AS4 |
|
||||
| G8 | Configurable extras absent (Fellowship/DOB/Age/Chess/Fishing/Deaths/Titles + legend) | AS3 (legend) + AS4 |
|
||||
| G9 | Title-bar allegiance rank prefix absent (AP-109's 17-function table) | AS5 |
|
||||
| G10 | Zero Character-subview test coverage | every slice adds |
|
||||
|
||||
## 5. AS1 rulings on the open questions
|
||||
|
||||
- **R1 (String 52 override):** keep current `BuildTitle` behavior — String
|
||||
52 is absent for players; harmless either way.
|
||||
- **R2 (Int 323 HealingBoost):** retail reads it and never renders it; we
|
||||
keep discarding it. Do not add a row.
|
||||
- **R3 (`* = Unenchantable` legend unconditional):** SETTLED AT SOURCE at
|
||||
the AS3 review — the legend at `004b5d7d-004b5dcd` sits OUTSIDE the
|
||||
`if (InqCreature(...))` block (opened `004b4638`, closed at pseudo-C line
|
||||
189962), so the BN-flattening theory is dead: retail adds it
|
||||
unconditionally, into the same `m_extraInfoList` (0x10000335), text in
|
||||
the LABEL slot. Retail's outside-the-gate placement is unobservable in
|
||||
practice (the dispatcher only routes to CharExamineUI when a creature
|
||||
profile exists), so "always, on the character path" is the faithful
|
||||
port. The connected gate keeps its side-by-side check for the VISUAL
|
||||
question only; do not re-investigate the structure.
|
||||
- **R4 (ratings spacer discipline):** the BN output lost the flag
|
||||
assignments in CharExamineUI; use `CreatureExamineUI::SetAppraiseInfo
|
||||
@0x004B3FF0`'s clean version of the SAME logic: one spacer before the
|
||||
first ratings row, one trailing spacer if any rating row was emitted.
|
||||
- **R5 (colorIdx RGBA):** authored in LayoutDesc 0x2100006B text attrs
|
||||
(0x1B/0x1D). CORRECTED at the AS4 review: the row model CARRIES the
|
||||
style (`CreatureAppraisalValueStyle`), but `ResolveColor` is a
|
||||
deliberate no-op — every row renders the authored default color until
|
||||
AP-110's "creature FontInfo-list selection" residual lands. Compute the
|
||||
semantic state, never hardcode RGBA; the Society green/red is therefore
|
||||
MODEL-ONLY today and INVISIBLE at the connected gate (the AS6 script
|
||||
must not gate on it).
|
||||
- **R6 (literals):** the header/extras labels are code literals in retail —
|
||||
match them as literals (consistent with the item report), with DAT
|
||||
lookups only where retail does them (gender/heritage EnumMappers, title
|
||||
chain, attribute names).
|
||||
- **R7 (PK source):** from the assessed `ClientObject`'s PWD bits via
|
||||
`PlayerKillerStatusBitfield`, NOT PropertyInt 134 (AP-109's CT4 lesson);
|
||||
leave the element cleared when the object is no longer in the table.
|
||||
- **R8 (rank source for GetFullName):** `props.GetInt(0x1E)` — the
|
||||
appraisal bundle carries AllegianceRank; never read
|
||||
`RuntimeAllegianceState` for another player's rank.
|
||||
147
docs/research/2026-08-25-campaign-as-test-script.md
Normal file
147
docs/research/2026-08-25-campaign-as-test-script.md
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
# Campaign AS — connected gate script (AS6, user-driven)
|
||||
|
||||
**Purpose:** live verification of the assess/examination-window parity
|
||||
campaign (AS2–AS5) against ACE, retail side-by-side as the oracle.
|
||||
Launch: the normal connected launch (`ACDREAM_RETAIL_UI=1`, live ACE at
|
||||
`127.0.0.1:9000`, `ACDREAM_PAK_PATH=<worktree>\artifacts\owner-gate\acdream-v5.pak`).
|
||||
Sections marked **[TWO-CLIENT]** use the FA-campaign account pair
|
||||
(`testaccount`/`+Acdream` + `testaccount2`/`+Horan`) — assess the OTHER
|
||||
player. Where a second retail client is available, compare the same assess
|
||||
performed from retail.
|
||||
|
||||
**Two standing rulings, read before judging:**
|
||||
- **Row COLORS are out of scope this gate.** The Society green/red (and any
|
||||
buffed/debuffed row tint) is MODEL-ONLY today — `ResolveColor` renders
|
||||
the authored default until AP-110's FontInfo-list residual lands. Judge
|
||||
TEXT content, ordering, and presence only.
|
||||
- **The paperdoll is our registered intentional deviation (AD-114):** ours
|
||||
mirrors the target's live motion; retail's clone plays its own decoupled
|
||||
idle cycle. A mismatch there is EXPECTED and correct.
|
||||
|
||||
---
|
||||
|
||||
## 1. Header identity block (AS2) [TWO-CLIENT]
|
||||
|
||||
Assess the other player. Under the title bar, four lines:
|
||||
|
||||
1. **Gender+heritage**: "<Gender> <Heritage>" composed (e.g. "Female
|
||||
Aluvian") — not a raw property string. PASS: matches retail's line for
|
||||
the same target.
|
||||
2. **Title**: the target's CURRENT display title (e.g. "War Mage"). Have
|
||||
the target change their display title (Character panel → Titles → Set
|
||||
as Display Title), re-assess: the new title shows. No title set →
|
||||
the line is empty (ours deliberately clears where retail can show a
|
||||
stale previous target's title — register AD-115; do not fail us for
|
||||
being cleaner than retail here).
|
||||
3. **PK status**: "Non-Player Killer" for a normal character. (If a PK or
|
||||
PKLite character is available, verify its variant.)
|
||||
4. **Allegiance name**: shown only when the target is in a named
|
||||
allegiance (sworn, rank ≥ 1); otherwise empty.
|
||||
5. **Failed assess**: on the TARGET client enable Deception's
|
||||
"Attempt to Deceive" option (retail Options → Character) if available
|
||||
with a high-Deception target; a failed assess shows "???" attributes
|
||||
BUT the four header lines above still render (identity rides even on
|
||||
failure). Best-effort — needs a target whose Deception beats the
|
||||
examiner's Assess Person.
|
||||
6. **The invented literal is gone**: assess a MONSTER and fail (or any
|
||||
monster assess) — the line that used to read "Assessment incomplete"
|
||||
shows nothing, matching retail.
|
||||
|
||||
## 2. Per-bodypart armor levels (AS3) [TWO-CLIENT]
|
||||
|
||||
1. Target wearing armor: three rows appear —
|
||||
"Head/Chest/Groin AL: a/b/c", "Bicep/Wrist/Hand AL: a/b/c",
|
||||
"Thigh/Shin/Foot AL: a/b/c" — values matching retail's assess of the
|
||||
same target (buffed values: cast an armor buff on the target and
|
||||
re-assess; numbers rise).
|
||||
2. Target naked (bank the armor): the three rows AND their leading blank
|
||||
line disappear entirely.
|
||||
3. Unenchantable coverage (if an unenchantable piece is available): the
|
||||
affected group shows "*N" (N = the level without the sentinel).
|
||||
4. **The R3 legend check (explicit retail side-by-side):** "* =
|
||||
Unenchantable" renders as the LAST line of a player assess. Our port
|
||||
shows it UNCONDITIONALLY (decomp-proven structure). Confirm retail
|
||||
does the same on a player with NO starred values and again with NO
|
||||
armor at all. If retail hides it in either case, report it — that
|
||||
flips ruling R3 and we change ours.
|
||||
|
||||
## 3. Ratings block (regression)
|
||||
|
||||
On a target with ratings (augmented/geared): "Dmg/CritDmg Rating: x/y",
|
||||
"Dmg/CritDmg Resist: x/y", "DoT/Life: Resist: x/y" rows as before,
|
||||
each appearing only when its family is nonzero. Unchanged behavior —
|
||||
spot-check only.
|
||||
|
||||
## 4. Society / allegiance / fellowship rows (AS4) [TWO-CLIENT]
|
||||
|
||||
1. **Fellowship**: form a fellowship (FA campaign flow), assess the other
|
||||
member: "Fellowship: <name>" row appears; disband → row gone on
|
||||
re-assess.
|
||||
2. **Allegiance cascade** (best-effort — ACE's swear-at-close-range quirk
|
||||
#384 may block creating fresh vassals): assess a sworn character —
|
||||
"Monarch:" (and "Patron:" when different, "Monarch/Patron:" when the
|
||||
same person). Assess a MONARCH: "Alleg. Monarch:" + "N Follower(s)".
|
||||
3. **Society** (only if a faction-joined character exists on this ACE):
|
||||
"Society: <name>" with the rank suffix (" ~ Initiate" … " ~ Master")
|
||||
per the target's standing. TEXT only — ignore colors (standing ruling).
|
||||
|
||||
## 5. Target-configurable extras (AS4) [TWO-CLIENT]
|
||||
|
||||
On the TARGET client open Options → the retail Character tab and toggle
|
||||
each "Allow others to see..." option, re-assessing from the other client
|
||||
after each change (each row appears iff the target allows it — the server
|
||||
gates; we render what arrives):
|
||||
|
||||
| Toggle on target | Row | Expected value shape |
|
||||
|---|---|---|
|
||||
| Date of Birth | `Arrived in Dereth:` | server-formatted date, verbatim |
|
||||
| Age | `Time in Dereth:` | retail duration format — bare units, e.g. "3mo 2d 5h 12m 40s"; zero components omitted except seconds |
|
||||
| Chess Rank | `Chess Rank:` | number |
|
||||
| Fishing Skill | `Fishing Skill:` | number |
|
||||
| Number of Deaths | `Deaths:` | number; a deathless character shows "Has never died" as the value (label stays "Deaths:") |
|
||||
| Number of Titles | `Titles Earned:` | number |
|
||||
|
||||
PASS: each row toggles with its option, the labels match retail exactly,
|
||||
and the full extras ORDER matches retail: Society → allegiance rows →
|
||||
[blank] → armor trio → [blank] → ratings → [blank] → Fellowship → Arrived
|
||||
→ Time → Chess → Fishing → Deaths → Titles → "* = Unenchantable".
|
||||
|
||||
## 6. Title-bar allegiance rank prefix (AS5) [TWO-CLIENT, best-effort]
|
||||
|
||||
Assess a sworn character with an allegiance rank: the WINDOW TITLE reads
|
||||
"<RankTitle> <Name>" (e.g. "Yeoman Horan") — heritage- and
|
||||
gender-specific title from retail's table. The character panel (F9) name
|
||||
line of YOUR OWN sworn character shows the same prefix. Unsworn/rank 0 →
|
||||
plain name in both places. (Creating a fresh sworn pair may be blocked by
|
||||
#384 — use any already-sworn character; otherwise mark SKIPPED.)
|
||||
|
||||
## 7. Combat auto-refresh
|
||||
|
||||
Enter combat mode with the exam window open on a player: the window
|
||||
refreshes ~every 0.75 s; the AL trio, extras, and header lines persist
|
||||
and update (buff the target's armor mid-watch: values change without
|
||||
re-assessing manually).
|
||||
|
||||
## 8. Regression sweep (5 minutes)
|
||||
|
||||
- Monster assess: species line + stat rows + ratings as before; NO armor
|
||||
trio, NO legend, nothing where the old invented literal was.
|
||||
- Item assess: the full item report unchanged; inscription flow intact.
|
||||
- Spell assess unchanged.
|
||||
- The animated paperdoll mirrors the target's motion (our AD-114
|
||||
deviation — expected).
|
||||
- Character panel (F9): CT-campaign behaviors intact (titles page, header
|
||||
identity block, resize clamps 372–1000, scrollbar hand-off).
|
||||
|
||||
---
|
||||
|
||||
## Report back
|
||||
|
||||
Per section PASS/FAIL plus anything odd. The three answers that matter
|
||||
most:
|
||||
1. §5 — does every option-gated row toggle correctly with the target's
|
||||
own options, in retail's exact order?
|
||||
2. §2.4 — does retail show the "* = Unenchantable" legend unconditionally
|
||||
(our R3 reading), or does it hide it in some case?
|
||||
3. §1 — is the identity block exact against retail for the same target
|
||||
(composition, title live-update, PK text)?
|
||||
179
docs/research/2026-08-25-campaign-ct-test-script.md
Normal file
179
docs/research/2026-08-25-campaign-ct-test-script.md
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
# Campaign CT — connected gate script (CT7, user-driven)
|
||||
|
||||
**Purpose:** live verification of the character-panel parity campaign
|
||||
(CT1–CT6) against ACE. Launch: the normal connected launch
|
||||
(`ACDREAM_RETAIL_UI=1`, live ACE at `127.0.0.1:9000`,
|
||||
`ACDREAM_PAK_PATH=<worktree>\artifacts\owner-gate\acdream-v5.pak`).
|
||||
Open the character panel (F9 / toolbar). Retail side-by-side comparison
|
||||
is the oracle for every visual item.
|
||||
|
||||
Useful ACE console helpers: title grants come from quests/admin — check
|
||||
`@acecommands` for a title-grant command; `@grantxp` for levels.
|
||||
|
||||
---
|
||||
|
||||
## 1. Header identity block (CT4) — Attributes AND Skills tabs
|
||||
|
||||
1. **Name line**: the plain character name (rankless characters — the
|
||||
allegiance rank prefix is a registered deferral, AP-109).
|
||||
2. **Heritage line**: "<Gender> <Heritage> <DisplayTitle>" — e.g.
|
||||
"Female Aluvian War Mage" when a display title is set; just
|
||||
"Female Aluvian" when none. PASS: matches retail's composition and
|
||||
spacing exactly; a title beginning with "The" shows unmangled.
|
||||
3. **PK line**: "Non-Player Killer" (or "Player Killer" / "Player
|
||||
Killer Lite" on a PK/PKL character) in PURE WHITE. PASS: correct
|
||||
text + color on BOTH the Attributes and Skills tabs.
|
||||
4. **Level number**: pale gold with outline (authored color — compare
|
||||
against retail's level display side-by-side; the owner reported ours
|
||||
was previously off).
|
||||
5. **Luminance pair**: on a sub-200 character, NO luminance caption or
|
||||
value renders. (A level ≥ 200 character with MaximumLuminance shows
|
||||
"Luminance:" and "<available> / <maximum>" — verify only if such a
|
||||
character is available.)
|
||||
6. **Live update**: set a display title (see §2) — the heritage line
|
||||
updates the moment the server confirms, with no relog and no panel
|
||||
re-open.
|
||||
|
||||
## 2. Titles tab (CT3)
|
||||
|
||||
1. Click the **Titles** tab. PASS: the page shows "Current Display
|
||||
Title:" + the current title (or "Unknown" only when the server's
|
||||
title id fails to resolve — normally a real title or the authored
|
||||
empty state), the "All Available Titles:" list, and the
|
||||
"Set as Display Title" button.
|
||||
2. **List content**: every earned title, alphabetically sorted,
|
||||
readable rows. With few titles the scrollbar shows retail's
|
||||
full-track thumb; with many (if available) the thumb sizes
|
||||
proportionally and scrolls.
|
||||
3. **Ghost rule**: with NOTHING selected the button is ghosted. Select
|
||||
the title that IS the current display title — button stays ghosted.
|
||||
Select a DIFFERENT title — button un-ghosts.
|
||||
4. **Set round trip**: click Set as Display Title. PASS: the display
|
||||
title text updates on the server's confirmation, the selection
|
||||
CLEARS (row highlight goes dark — retail behavior), the button
|
||||
re-ghosts, and the §1 heritage line updates live.
|
||||
5. **Row selection visual**: the selected row highlights with retail's
|
||||
row highlight art (full-row background swap), not a synthesized bar.
|
||||
|
||||
## 3. Attribute/skill rows (CT5)
|
||||
|
||||
1. **Icon alignment**: row icons sit flush left (20x20 at the row's
|
||||
left edge), matching retail — the previous inset/smaller icons are
|
||||
gone. Compare a few rows side-by-side against retail.
|
||||
2. **Value gutter**: the numbers column ends with a visible margin
|
||||
before the panel border (the scrollbar band) — retail's 7px gutter.
|
||||
3. **Row height**: rows are retail-height (slightly tighter than
|
||||
before); section headers (Trained/Untrained/Unusable) unchanged.
|
||||
4. **Selection highlight**: clicking a row highlights with the retail
|
||||
full-row art; the spellbook's selection visuals are UNCHANGED
|
||||
(regression check — open the spellbook and select a spell).
|
||||
5. **Raise buttons / tooltips / footer**: regression sweep — raise ×1
|
||||
and ×10 still work with correct ghosting, skill tooltips still show
|
||||
formula + description wrapped correctly, the footer numbers update.
|
||||
|
||||
## 4. Resize + scrollbar (CT6)
|
||||
|
||||
Ground truth (2026-08-25 live probe against layout `0x2100006E`, host
|
||||
`0x100005FE` — `docs/research/2026-08-24-campaign-ct-dat-ground-truth.md`
|
||||
§CT6, corrected by the CT6 fix round's BLOCKER B1): the resize clamp is
|
||||
authored on the SHARED `gmPanelUI` host, not the character content
|
||||
itself, and the host IS retail's own outer window frame — its authored
|
||||
values are chrome-INCLUSIVE, not a content size our own chrome adds on
|
||||
top of. Host authors **MinWidth=MaxWidth=310** (fixed width — no
|
||||
horizontal Resizebar) and **MinHeight=372, MaxHeight=1000**. The
|
||||
MOUNTED window's outer bounds are EXACTLY those same numbers: width
|
||||
fixed **310px**, floor **372px**, ceiling **1000px** — no inset is
|
||||
added on top (`RetailWindowFrame.Options.DatConstraintSourceIsOuterFrame`
|
||||
now tells the mount path this source already includes the bevel).
|
||||
**Starting height:** the window OPENS at retail's authored default,
|
||||
**372px** — its own resize floor. It cannot open any shorter; it can
|
||||
only be dragged taller.
|
||||
|
||||
1. **Grab the bottom edge and drag up (shrink).** PASS: the window
|
||||
stops shrinking at its authored floor (372px outer / the point where
|
||||
further dragging has no visible effect) — since the window already
|
||||
OPENS at that floor, this step should show no shrink at all (there
|
||||
is no room below the default to shrink into). It does NOT collapse
|
||||
arbitrarily small. Retail comparison: drag retail's own Character/
|
||||
Skills window down from its own default; it should likewise refuse
|
||||
to shrink further immediately.
|
||||
2. **Keep dragging down (grow).** PASS: the window keeps growing until
|
||||
its authored ceiling (1000px outer) — same side-by-side comparison
|
||||
against retail's own ceiling.
|
||||
3. **Left/right edges do not resize.** Only the bottom edge (and top
|
||||
Dragbar for moving, not resizing) responds — matches retail's
|
||||
fixed-width authoring (no horizontal Resizebar).
|
||||
4. **Scrollbar hand-off, Attributes tab.** At the default window size
|
||||
(372px) the 9 attribute/vital rows (180px content) OVERFLOW the
|
||||
160px list — the scrollbar is ACTIVE (visible + interactive)
|
||||
IMMEDIATELY on open, not after shrinking. PASS: this is the owner's
|
||||
item 2 fix — previously the scrollbar never appeared on Attributes
|
||||
at all. Grow the window until the rows fit without scrolling: the
|
||||
bar DISAPPEARS entirely (0x1000023E authors 0x79 hide-when-disabled
|
||||
TRUE — a fitting list hides the bar, it does not leave a full-track
|
||||
"disabled" thumb visible). Shrink back down and the bar reappears.
|
||||
5. **Scrollbar hand-off, Skills tab.** Same immediate-overflow-at-
|
||||
default check (a longer skills list only makes the overflow more
|
||||
obvious); grow until it fits and confirm the bar disappears the same
|
||||
way.
|
||||
6. **Scrollbar hand-off, Titles tab.** With several earned titles, the
|
||||
Titles list (authored 455px, inside the 575px page) is scroll-clipped
|
||||
into the same 372px-default window and its own scrollbar
|
||||
(`0x10000533`, also hide-when-disabled — fixture-verified) takes over
|
||||
the same way: active when titles overflow, hidden when the window is
|
||||
grown enough that they all fit.
|
||||
7. **Footer stays bottom-docked.** While shrinking/growing on the
|
||||
Attributes/Skills tabs, the footer (raise buttons / selected-stat
|
||||
info) stays pinned to the bottom edge — it does not float mid-window
|
||||
or get clipped early.
|
||||
8. **Grow back restores.** Drag back down to the original default
|
||||
(372px): the lists return to their default OVERFLOWING state
|
||||
(scrollbar reactivates — this is the default, not "all rows fit")
|
||||
and the window returns to its original proportions.
|
||||
9. Other windows (chat, social) still clamp at their own authored
|
||||
minimums — regression check (chat: min 300×100, max 2000×2000 per
|
||||
`CharacterPanelLiveDatTests.ChatWindowRoot_AuthorsExplicitSizeConstraints`).
|
||||
|
||||
## 5. Regression sweep (5 minutes)
|
||||
|
||||
- Attributes/Skills tab switching unaffected; CA5 behaviors intact
|
||||
(raise round trips, live run-speed update on Quickness).
|
||||
- Logout/login: titles and display title persist; the header matches
|
||||
PlayerDescription's values.
|
||||
- Chat window: the CH-round fixes hold (input rails on focus, "Gen"
|
||||
caption, button flick, no vibrating text while dragging).
|
||||
- **CT-GF1 fix round (client-wide ancestor clip) — eyeball items.** The new
|
||||
default clips every element to its own box by default; these are the
|
||||
windows most likely to show a silent over-clip (content trimmed that
|
||||
should be visible) if the port has an edge case the automated suite
|
||||
didn't catch:
|
||||
- **Collapsed toolbar**: collapse the combat/spell toolbar to its narrow
|
||||
strip and back — confirm nothing inside it (icons, the collapse grip)
|
||||
gets cut off or fails to reappear on expand.
|
||||
- **Combat/vitals bar**: at its default size, confirm the health/
|
||||
stamina/mana bars and their numeric overlays render in full, not
|
||||
trimmed at an edge.
|
||||
- **Options panel bottom-button row** (Gameplay tab): confirm all seven
|
||||
buttons (Exit to Character Selection, Configure Keyboard, In-Game
|
||||
Help, Urgent Assistance, Report Abuse, mouse-turning checkbox, Exit
|
||||
Game) render completely, none clipped at the panel's bottom edge.
|
||||
- **Map/house page**: confirm the map image and player/house icons
|
||||
render in full across the page's own scroll/zoom range, not clipped
|
||||
at the viewport edge.
|
||||
- **Floaty chat** (a detached floating chat window, Alt+1..4): confirm
|
||||
the transcript and input row render in full at both a small and a
|
||||
resized-larger window size — the same class of symptom CT-GF1's own
|
||||
`ChatLayoutConformanceTests` regression-pinned for the main chat
|
||||
window's input row.
|
||||
|
||||
---
|
||||
|
||||
## Report back
|
||||
|
||||
Per section: PASS/FAIL plus anything odd. The three answers that matter
|
||||
most:
|
||||
1. §2.4 — does the set-title round trip clear the selection and update
|
||||
the header live?
|
||||
2. §3.1/§3.2 — do icons and the value gutter now match retail
|
||||
side-by-side?
|
||||
3. §1.3 — is the PK line present, white, and correct on both tabs?
|
||||
131
docs/research/2026-08-26-combined-client-parity-gate.md
Normal file
131
docs/research/2026-08-26-combined-client-parity-gate.md
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
# Combined client parity owner gate
|
||||
|
||||
**Date prepared:** 2026-08-26
|
||||
|
||||
**Run:** OWNER-COMPLETE 2026-08-26 on one exact Release binary.
|
||||
|
||||
**Scope:** #443–#450 plus the complete inventory/vendor interaction audit.
|
||||
|
||||
**Outcome:** Sections A–C and E–F passed, including the mid-drag cursor-icon
|
||||
re-test added during the round. Issues #444–#450 are owner-accepted. Section D
|
||||
failed: the private paperdoll remained missing, so #443 stays open as the only
|
||||
surviving defect. Tested executable SHA-256:
|
||||
`173989F3C85C05C0746D628CDF9C6194F6A5E3EFD4597806BF83417483C37B42`.
|
||||
|
||||
Keep the client log for the whole run and take a screenshot for any visual or
|
||||
text mismatch. For every refused item action, record the cursor color, exact
|
||||
SpewBox line, and whether the item visibly moved before the refusal.
|
||||
|
||||
## A. Keyboard, camera, combat input, and relog — #446/#450
|
||||
|
||||
1. In Configure Keyboard, bind Move Forward to bare Shift while Toggle
|
||||
Walk/Run already owns it. Verify the retail conflict dialog appears and the
|
||||
chosen resolution is honored. Repeat with a known allowed shared chord.
|
||||
2. Apply a changed binding, close/reopen Options, then restart the client.
|
||||
Verify it survives. Test Revert, Defaults, Cancel, and Load/Save `.keymap`.
|
||||
3. Press regular Enter: chat input must focus. Press keypad Enter outside chat:
|
||||
it must perform only its configured camera action and must not focus chat.
|
||||
4. Press bare Escape through the retail ladder: cancel target/focus/selection
|
||||
first, then toggle Gameplay Options. It must never enter a developer orbit
|
||||
or bird's-eye mode. Preserve the approved mouse-wheel zoom range.
|
||||
5. Hold End, Page Down, or Delete in melee mode. The bar must charge while the
|
||||
key is held and attack only on release, using the selected height.
|
||||
6. Shift+Escape to character selection, immediately re-enter, and verify the
|
||||
destination loads and exits portal space. Repeat twice in one process.
|
||||
|
||||
## B. Chat and combat text — #447/#448
|
||||
|
||||
1. Run `@acecommands`. Every non-empty server line must be visible; an
|
||||
oversized response must retain the newest complete lines, not blank the
|
||||
transcript.
|
||||
2. Run `@acehelp acecommands` and send ordinary chat afterward. Verify normal
|
||||
text, filtering, and scrolling remain intact.
|
||||
3. Land ordinary and critical melee hits. Outgoing lines must match retail
|
||||
wording and punctuation and contain no acdream-added percentage.
|
||||
|
||||
## C. Selection, use, containers, movement, and splitting — #445/#449
|
||||
|
||||
Use a normal item, unusable item, wearable, weapon, two mergeable stacks, a
|
||||
Pyreal stack, one side pack, a full main pack, a full side pack, an open
|
||||
external container, and a creature/player target.
|
||||
|
||||
1. Single-click and right-click inventory, side-pack, external-container, and
|
||||
paperdoll items. Verify one global selection, stable highlight, status text,
|
||||
and right-click examination.
|
||||
2. Select an owned Pyreal stack. The toolbar must read
|
||||
`<stack> <appropriate name> (of <total carried Pyreals>)`, with no comma
|
||||
insertion added by acdream.
|
||||
3. Single- and double-click a carried side pack. It must open on press, issue
|
||||
no generic item-use request, and remain stably selected.
|
||||
4. Double-click usable, unusable, wearable, and wieldable items. Verify one
|
||||
action. Any local refusal must appear once in SpewBox, not in normal chat.
|
||||
5. Move a full item between main pack and side pack. Before acknowledgement,
|
||||
the source must remain canonical with retail's waiting/ghost presentation;
|
||||
after success it appears only at the destination. Force one rejection and
|
||||
verify no duplicate, disappearance, or speculative capacity change.
|
||||
While holding the item under the cursor, move across the inventory and wait
|
||||
through several ordinary object updates: the cursor icon must remain visible
|
||||
until release. This re-gates the mid-drag procedural-refresh fix found during
|
||||
the first 2026-08-26 owner pass.
|
||||
6. Fill the main pack, observe a rejected move, drop one loose item, then move
|
||||
an item from a side pack into the freed slot immediately. It must accept
|
||||
without reopening the inventory window (#449).
|
||||
7. From a stack of 10, select 2 and split into an empty main-pack slot, an open
|
||||
side pack, and an external container. Each successful result must be 8+2.
|
||||
8. Merge full and partial stacks. Verify selected quantity, target selection,
|
||||
target-cap clamp, source remainder, and exact refusal text for a full target.
|
||||
9. Drop full and partial stacks to the world, then pick them up. Verify pending
|
||||
visuals, authoritative commit, failure cleanup, and no duplicate object.
|
||||
10. Give a full and partial stack to a creature; drag onto another player and
|
||||
verify secure-trade routing. Hover rejection must stay silent; release
|
||||
rejection must print the exact ClientLocal reason.
|
||||
11. Equip once by double-click and once by paperdoll drag. Test a clothing
|
||||
conflict and weapon replacement. Canonical inventory/paperdoll ownership
|
||||
must not change before the authoritative response.
|
||||
|
||||
## D. Private paperdoll viewport — #443
|
||||
|
||||
1. From a fresh process, open inventory and assess one monster and one player.
|
||||
2. The animated doll must be present on first open, not appear only after a
|
||||
delay. Close/reopen each view several times and change equipment once.
|
||||
3. Record #443 independently if the viewport is late or missing even when the
|
||||
underlying equip/inventory transaction is correct.
|
||||
|
||||
## E. Vendor parity and alternate currency — #444/#445
|
||||
|
||||
1. On the browse list, single-click selects, right-click examines, and
|
||||
double-click buys exactly one/current-slider unit through the normal retail
|
||||
purchase path.
|
||||
2. Add a stack quantity greater than one to Buying. Double-click its staged
|
||||
row: remove exactly one unit and print
|
||||
`Removing <name> from shopping list` once in SpewBox.
|
||||
3. Stage an owned item in Selling. Right-click examines it; double-click
|
||||
removes the whole staged entry and prints the same removal form.
|
||||
4. Drag a staged Selling row: it must unstage. Repeat after selecting only part
|
||||
of its stack: SpewBox must print
|
||||
`You cannot split items from this panel` and the slider must reset to max.
|
||||
5. From an owned stack of 10, select 2 and drag into Selling. Verify the
|
||||
temporary row resolves to the new authoritative stack of 2, Sell All sells
|
||||
exactly 2, and 8 remain (#445).
|
||||
6. At an alternate-currency vendor, note holdings in the Items cost sentence
|
||||
and Buying/Selling purse lines. Buy once: every visible holding must decrease
|
||||
immediately and remain correct after authoritative inventory refresh,
|
||||
tab changes, and vendor reopen (#444).
|
||||
7. Buy All once with enough currency and once without enough. The first uses
|
||||
the refreshed balance; the second prints retail's insufficient-funds notice
|
||||
and sends no purchase.
|
||||
|
||||
## F. Re-entrant and lifecycle stress
|
||||
|
||||
1. Change selection during a pending split, close an external container during
|
||||
a pending move, and retry immediately after a refusal.
|
||||
2. Attempt a second inventory operation while one request is pending. Verify a
|
||||
clean retail refusal/no-op, never duplicated wire action or stuck busy state.
|
||||
3. Log out or portal with a recently completed interaction, re-enter, and
|
||||
verify pending projections and the request ledger converge to zero.
|
||||
|
||||
## Pass rule
|
||||
|
||||
The pass rule was satisfied for #444–#450 on the exact binary recorded above.
|
||||
#443 remains open by itself because the transaction rows passed and the failure
|
||||
was confined to private viewport residency.
|
||||
43
docs/research/2026-08-26-issues-444-445-447-test-script.md
Normal file
43
docs/research/2026-08-26-issues-444-445-447-test-script.md
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
# Issues #444, #445, #447 — consolidated owner gate
|
||||
|
||||
Run these checks together on the next connected test build. They deliberately
|
||||
require no special probes; record the client log and one screenshot per
|
||||
section. If a split fails, also record the exact visible error text.
|
||||
|
||||
## #444 — alternate-currency vendor balance
|
||||
|
||||
1. Open a vendor that accepts an alternate currency and note the amount shown
|
||||
in both the Items cost sentence and the Buying-tab purse line.
|
||||
2. Buy one item.
|
||||
3. Confirm both visible amounts decrease immediately and remain correct after
|
||||
the server refresh. Close/reopen the tab and vendor and confirm the amount
|
||||
does not bounce back to the old snapshot.
|
||||
4. Buy All once with enough currency, then once without enough. Confirm the
|
||||
first uses the refreshed holding and the second shows retail's insufficient-
|
||||
money notice without sending a purchase.
|
||||
|
||||
## #445 — inventory and vendor partial stack splits
|
||||
|
||||
1. In the main pack, select a stack of 10, set the slider to 2, and drag it to
|
||||
an empty main-pack slot while at least one side bag is equipped.
|
||||
2. Confirm the source becomes 8 and a new stack of exactly 2 appears at the
|
||||
chosen loose-item position; no error should appear.
|
||||
3. Repeat into an open side bag and confirm the same 8+2 result.
|
||||
4. Reset to a stack of 10, select 2, and drag it onto the vendor Selling list.
|
||||
Confirm the client prints `Splitting the <name> before selling them`, then
|
||||
the staged row resolves to the new stack of 2 rather than the source stack.
|
||||
5. Press Sell All and confirm exactly 2 are sold and 8 remain.
|
||||
|
||||
## #447 — `@acecommands` multiline response
|
||||
|
||||
1. Run `@acecommands` on the test account.
|
||||
2. Confirm command text is visible, consecutive server lines are readable,
|
||||
and there is no screen of blank chat rows.
|
||||
3. Scroll through the retained result. A response beyond retail's 10,000-
|
||||
character transcript cap should retain the newest complete command lines
|
||||
instead of blanking the entire response.
|
||||
4. Run `@acehelp acecommands` and one ordinary chat command afterward; confirm
|
||||
their text and normal chat presentation remain intact.
|
||||
|
||||
Pass all three sections on one exact binary, then mark #444/#445/#447
|
||||
owner-accepted together.
|
||||
447
docs/research/2026-08-26-mosstank-vtank-utilitybelt-research.md
Normal file
447
docs/research/2026-08-26-mosstank-vtank-utilitybelt-research.md
Normal file
|
|
@ -0,0 +1,447 @@
|
|||
# MossTank research: Virindi Tank parity and UtilityBelt expressions
|
||||
|
||||
Date: 2026-08-26
|
||||
|
||||
This report is the requirements baseline for turning MossTank from the small
|
||||
self-buffing sample into acdream's full automation plugin. The product target is
|
||||
deliberately broad: **all Virindi Tank functionality**, with UtilityBelt's more
|
||||
capable expression dialect as the scripting baseline. File compatibility is a
|
||||
separate decision; behavioral capability is not.
|
||||
|
||||
## 1. Evidence and limits
|
||||
|
||||
The following primary VTank pages were read and cross-checked (the live wiki
|
||||
and its indexed historical revisions were both used where a mirror was
|
||||
temporarily unavailable):
|
||||
|
||||
- `http://virindi.net/wiki/index.php/Virindi_Tank`
|
||||
- `http://virindi.net/wiki/index.php/Virindi_Tank_Standard_Options`
|
||||
- `http://virindi.net/wiki/index.php/Virindi_Tank_Advanced_Options`
|
||||
- `http://virindi.net/wiki/index.php/Virindi_Tank_Commands`
|
||||
- `http://virindi.net/wiki/index.php/Virindi_Tank_Meta_System`
|
||||
- `http://virindi.net/wiki/index.php/Meta_Expressions`
|
||||
- `https://utilitybelt.gitlab.io/docs/expressions/`
|
||||
|
||||
The 2026-08-27 MT2 follow-up also verified the exact documented distinctions
|
||||
that drive the combat scheduler:
|
||||
|
||||
- A+R requires `MinimumRingTargets` inside Ring Range; R without A rings with
|
||||
any configured target inside range and falls back to standard war outside;
|
||||
- `UseArcs` prefers an arc over a bolt only at/above `ArcRange`;
|
||||
- `Void Basic`, `Drain Auto`, and `Harm` are distinct Monsters damage choices;
|
||||
- `GhostMonsterSpellAttemptCount` counts spell attempts which never start,
|
||||
while `BlacklistMonsterAttemptCount` counts successful attacks which miss;
|
||||
- the health-tracker ghost detector is independent and applies to melee,
|
||||
missile, and magic.
|
||||
|
||||
Sources: the official `Virindi_Tank_Standard_Options`,
|
||||
`Virindi_Tank_Advanced_Options`, `Virindi_Tank_FAQ`, `Options_List`, and
|
||||
`Virindi_Tank_Changelog` pages listed above.
|
||||
|
||||
For UtilityBelt, documentation was checked against the primary source rather
|
||||
than relying on the generated web page alone. The inspected repository was
|
||||
`https://gitlab.com/utilitybelt/utilitybelt`, commit
|
||||
`5fe9825a82f38047737768fd92c61dd47d88e467` (2026-03-05). The grammar is
|
||||
`UtilityBelt/Lib/Expressions/MetaExpressions.g4`; every method carrying an
|
||||
`ExpressionMethod` attribute was enumerated. The source is MIT licensed.
|
||||
|
||||
This report extends, rather than replaces,
|
||||
`2026-07-29-vtank-plugin-automation-requirements.md`. That earlier report
|
||||
already decoded VTank's `.met`, `.nav`, and `.utl` structures from primary
|
||||
sources and remains the format reference.
|
||||
|
||||
## 2. Complete VTank capability map
|
||||
|
||||
### 2.1 Combat
|
||||
|
||||
VTank is a priority-driven combat controller, not merely an auto-attack loop.
|
||||
Its supported combat family includes:
|
||||
|
||||
- melee, missile, mage, hybrid, two-handed, Void and Summoning characters;
|
||||
- Life harm/martyr attacks, grenades, lenses, cast-on-strike weapons, streaks;
|
||||
- automatic damage/weapon choice and monster-specific weapon, offhand and pet
|
||||
element overrides;
|
||||
- monster rules with `DEFAULT` plus ordered first-match expressions;
|
||||
- per-rule priority from ignore (`-1`) through `4`, attack/debuff flags,
|
||||
damage type, attack height, ring/streak choices, and void curses;
|
||||
- target selection by distance, angular deviation, or the hybrid method using
|
||||
angle inside a configurable cutoff and distance outside it;
|
||||
- target lock, blacklist/retry behavior, and ghost-target retirement after
|
||||
failed casts or missing health updates;
|
||||
- debuff scheduling by one target, priority group, or all targets before
|
||||
attack; spell-level versus skill-based debuff choice and reapply windows;
|
||||
- automatic ring use by nearby-target density and arc/bolt choice by range;
|
||||
- melee high/middle/low attacks, automatic or explicit power, Recklessness;
|
||||
- pet density, element, refill and test behavior.
|
||||
|
||||
VTank's macro scheduler checks multiple action lists in priority order. Combat
|
||||
therefore cannot be implemented as an isolated timer: healing, buffing,
|
||||
navigation, looting, fellowship assistance and combat all need one arbiter.
|
||||
|
||||
### 2.2 Buffing and vitals
|
||||
|
||||
- automatic trained attribute/skill buffs, protections, banes, auras,
|
||||
regeneration and configured extra buffs;
|
||||
- protection/bane profile sets, exclusions, level/tier selection, signed
|
||||
skill-over-difficulty thresholds, force buff and idle top-off;
|
||||
- time-remaining rebuff and persisted item-buff duration knowledge;
|
||||
- combat, idle and fellowship-helper vital thresholds;
|
||||
- kits, vital transfers, post-switch recharge behavior and special healing
|
||||
items;
|
||||
- self-dispel in response to high-level vulnerabilities.
|
||||
|
||||
MossTank's current buff engine already owns the first useful subset: known
|
||||
self buffs, tier/difficulty choice, in-force enchantment timing, force buff,
|
||||
and stamina/mana upkeep. It remains plugin policy over host primitives.
|
||||
|
||||
### 2.3 Inventory and crafting
|
||||
|
||||
- AutoStack and AutoCram;
|
||||
- pea splitting and priority rules;
|
||||
- crafting of kits, foods, arrowheads and special ammunition;
|
||||
- mana-stone acquisition, filling and application to equipped items;
|
||||
- lockpick selection and use;
|
||||
- component, consumable, tool and ammunition upkeep.
|
||||
|
||||
### 2.4 Looting
|
||||
|
||||
- corpse approach/open/retry/timeout/blacklist;
|
||||
- all/fellow/rare loot modes and priority boosts;
|
||||
- appraisal/ID wait, unknown-scroll reading and salvage combining;
|
||||
- a loot-plugin seam, with VTClassic as the canonical ordered, first-match
|
||||
rule engine over raw and computed item properties;
|
||||
- actions including no-loot, keep, keep-up-to, salvage, sell, read and custom
|
||||
user actions.
|
||||
|
||||
The host must expose object property bags, appraisal completion and
|
||||
transaction primitives. Rule ordering and loot-profile policy belong in
|
||||
MossTank.
|
||||
|
||||
### 2.5 Navigation
|
||||
|
||||
- circular, linear, once/runback and follow routes;
|
||||
- points, portals, recalls, pauses, chat, vendor, repeated NPC talk/use,
|
||||
server-confirmed checkpoints and charged/shift/strafe jumps;
|
||||
- closest-entry, reversal, arrival/off-course ranges, door use and
|
||||
follow-around-corners;
|
||||
- combat/nav priority interaction.
|
||||
|
||||
Route storage belongs to the plugin. The host owes move-to, follow, turn,
|
||||
jump, use and authoritative-arrival primitives.
|
||||
|
||||
Direct inspection of the official assembly on 2026-08-27 pinned the route
|
||||
contract more tightly:
|
||||
|
||||
- `eNavType` is Circular, Linear, Target and Once; Once destructively removes
|
||||
its first completed row and Linear deliberately visits each endpoint once
|
||||
while flipping direction;
|
||||
- `eWaypointType` assigns Point/Portal/Recall/Pause/ChatCommand/OpenVendor/
|
||||
Portal2/UseNPC/Checkpoint/Jump to numeric ids 0..9;
|
||||
- `fd.cs` turns outside 4°, moves while turning only within 45° beyond 3 m or
|
||||
15° inside 3 m, and stops at `NavCloseStopRange` (default 2 m);
|
||||
- `gr.cs` compares the checkpoint against the last server position rather
|
||||
than client prediction and nudges forward after 15 seconds without an
|
||||
acknowledgement;
|
||||
- `gl.cs` records the followed player's path by approximately 9.6 cm and
|
||||
drops old breadcrumbs when the follower comes within 2.4 m of a later path
|
||||
segment, preserving follow-around-corners;
|
||||
- `e9.cs` and `fa.cs` reacquire exact-name/class objects within 2.5 m of the
|
||||
stored position. Portal2 retries when portal exit remains within 15 m of
|
||||
its origin; UseNPC repeats until the named NPC tells or gives to the player;
|
||||
- `b7.cs` is a rule independent of the route node list. `OpenDoors` defaults
|
||||
false; it IDs doors at 20 m, opens at 4 m, and accepts a lock when Lockpick
|
||||
is at least `difficulty - 50` using an owned lockpick.
|
||||
|
||||
These are plugin policies over additive canonical projections, not a second
|
||||
movement model. The host applies semantic movement intent through Runtime's
|
||||
existing command interpreter and supplies the accepted server position needed
|
||||
only by Checkpoint.
|
||||
|
||||
### 2.6 Fellowship and social automation
|
||||
|
||||
- tell-driven recruitment and waiting lists;
|
||||
- fellowship leader/member/state queries and leader replacement voting;
|
||||
- fellowship healing, corpse permissions and coordinated target/debuff policy;
|
||||
- multi-client composition through chat rather than a privileged macro API.
|
||||
|
||||
### External loot-classifier seam
|
||||
|
||||
VTank loads one `LootPluginBase`, asks `DoesPotentialItemNeedID`, and then
|
||||
calls `GetLootDecision(GameItemInfo)`. Its public result vocabulary is
|
||||
NoLoot, Keep, Salvage, Sell, Read, User1–User5 and KeepUpTo with `Data1` as the
|
||||
limit. MossTank modernizes discovery into a host-owned classifier registry:
|
||||
plugins register a namespaced classifier for their own lifetime, while
|
||||
MossTank remains the corpse/appraisal/pickup/action executor. The selected
|
||||
engine is durable policy; if it unloads, MossTank returns no classifier match
|
||||
instead of silently applying the built-in profile.
|
||||
|
||||
Direct inspection of the official `hv.cs` also shows that a custom loot
|
||||
plugin's per-item action is retained only after the item enters owned
|
||||
inventory and is removed when the item leaves. The modern registry therefore
|
||||
has matching `OnLooted` and `OnItemRemoved` callbacks. MossTank invokes them
|
||||
only from authoritative inventory publication/removal, never when pickup is
|
||||
merely dispatched.
|
||||
|
||||
### Options-page scheduler findings
|
||||
|
||||
The official Options controls are not merely presentation aliases:
|
||||
|
||||
- `fz.cs` runs the ordinary `RebuffTimeRemainingSeconds` rule before combat;
|
||||
- `cLogic.cs` runs a second `IdleBuffTopoffTimeSeconds` pass only behind
|
||||
`IdleBuffTopoff`, after attack/loot work has gone idle;
|
||||
- the PRETARGETAPPROACH `g8` rule navigates only between `AttackDistance` and
|
||||
`ApproachDistance`, and requires both combat and navigation to be enabled;
|
||||
- `cm.cs` changes to Peace only as the final no-target/no-work fallback.
|
||||
|
||||
The UI displays AC-distance settings multiplied by 240. MossTank stores metres
|
||||
in its typed controllers and converts only at the VTank option boundary.
|
||||
|
||||
### 2.7 Meta state machine
|
||||
|
||||
- named states beginning at `Default`;
|
||||
- state-local rules, each firing once per state entry;
|
||||
- nested conditions (`All`, `Any`, `Not`) and conditions for chat regex,
|
||||
inventory, timers, nav state, death, vendors, monsters, buffs, coordinates,
|
||||
portals, burden, route distance, expressions and captured chat groups;
|
||||
- actions for state transition, chat, grouped actions, embedded navigation,
|
||||
call/return stack, expression execution, expression-derived chat, watchdogs,
|
||||
option read/write and runtime-created views;
|
||||
- a roughly 293 ms decision cadence plus evaluation when the macro asks for
|
||||
its next action.
|
||||
|
||||
### 2.8 Profiles, commands and companion behavior
|
||||
|
||||
- independent settings, navigation, loot and meta profiles; global and
|
||||
per-character variants; hot loading and automatic persistence;
|
||||
- command parity for macro state, options, buffing, meta, item testing,
|
||||
property dumps, monster/spell diagnostics, route editing, attack power and
|
||||
debug output;
|
||||
- extensibility equivalent to VTClassic, VI2, item tools, follower/status HUD,
|
||||
alerts and cross-character inventory. Some belong as separate acdream
|
||||
plugins, but MossTank's API must permit them without privileged host code.
|
||||
|
||||
## 3. Expression language target
|
||||
|
||||
### 3.1 Why UtilityBelt is the baseline
|
||||
|
||||
VTank expressions are enough to power classic metas, but UtilityBelt preserves
|
||||
the familiar syntax while adding typed lists and dictionaries, slicing,
|
||||
higher-order collection functions, broader object queries and more action
|
||||
primitives. MossTank should implement the UtilityBelt-compatible semantic
|
||||
superset and offer a VTank compatibility mode for old expressions.
|
||||
|
||||
### 3.2 Grammar and evaluation semantics
|
||||
|
||||
The audited UtilityBelt grammar supports:
|
||||
|
||||
- multiple `;`-separated statements, returning the final result;
|
||||
- session (`$`), persistent (`@`) and global (`&`) variables;
|
||||
- decimal and hexadecimal numbers, booleans and two string forms;
|
||||
- function calls using `name[...]`;
|
||||
- typed values: number, string, boolean, list, dictionary, coordinate, world
|
||||
object, stopwatch and UI control;
|
||||
- list/string/dictionary indexing, slices and negative indices;
|
||||
- complement, shifts, bitwise operators, exponentiation, arithmetic, regex
|
||||
match (`#`), comparison, short-circuit `&&` and `||`;
|
||||
- registered function metadata, arity/type validation and documented return
|
||||
types;
|
||||
- collection creation/mutation/copying plus map/filter/reduce/sort/range.
|
||||
|
||||
Implementation requirements follow directly: parse into an immutable AST;
|
||||
compile or interpret without ambient reflection; use explicit value kinds;
|
||||
short-circuit logical nodes; attach cancellation and an instruction budget;
|
||||
make all world/action functions capabilities supplied by the MossTank engine;
|
||||
and serialize only persistent/global variable stores.
|
||||
|
||||
### 3.3 Audited UtilityBelt function catalog (260 declarations)
|
||||
|
||||
The declaration count includes aliases/overloads. Grouped by capability, the
|
||||
public names are:
|
||||
|
||||
- **language/conversion/math:** `abs`, `acos`, `asin`, `atan`, `atan2`,
|
||||
`ceiling`, `chr`, `cnumber`, `cos`, `cosh`, `cstr`, `cstrf`, `floor`,
|
||||
`hexstr`, `iif`, `ifthen`, `isfalse`, `istrue`, `lumavg`, `lumtotal`,
|
||||
`ord`, `randint`, `round`, `sin`, `sinh`, `sqrt`, `strlen`, `tan`, `tanh`,
|
||||
`tostring`, `vitae`;
|
||||
- **variables:** `getvar`, `setvar`, `testvar`, `touchvar`, `clearvar`,
|
||||
`clearallvars` and the corresponding `pvar` and `gvar` families;
|
||||
- **execution/chat:** `exec`, `delayexec`, `clearexec`, `echo`, `chatbox`,
|
||||
`chatboxpaste`;
|
||||
- **lists:** `listcreate`, `listadd`, `listinsert`, `listremove`,
|
||||
`listremoveat`, `listgetitem`, `listcontains`, `listindexof`,
|
||||
`listlastindexof`, `listcopy`, `listreverse`, `listpop`, `listcount`,
|
||||
`listclear`, `listfilter`, `listmap`, `listreduce`, `listsort`,
|
||||
`listfromrange`;
|
||||
- **dictionaries:** `dictcreate`, `dictgetitem`, `dictadditem`, `dicthaskey`,
|
||||
`dictremovekey`, `dictkeys`, `dictvalues`, `dictsize`, `dictclear`,
|
||||
`dictcopy`;
|
||||
- **time/location:** `getdatetimelocal`, `getdatetimeutc`, `getunixtime`,
|
||||
`getworldname`, `getplayercoordinates`, `getplayerlandblock`,
|
||||
`getplayerlandcell`, coordinate parse/get/distance/string functions,
|
||||
stopwatch functions, and the eleven `getgame*`/day/night functions;
|
||||
- **character:** raw typed property reads, base/buffed skills, training level,
|
||||
base/current/buffed-max vitals, base/buffed attributes, burden, free slots,
|
||||
cooldown expiration, account hash and character index;
|
||||
- **spells/components:** `getknownspells`, `getisspellknown`,
|
||||
`getcancastspell_buff`, `getcancastspell_hunt`, `getspellexpiration`,
|
||||
`getspellexpirationbyname`, `spelldata`, `spellname`, `componentdata`,
|
||||
`componentname`;
|
||||
- **world objects:** validity/data/ID-time, raw typed properties, identity,
|
||||
health/vitals, spells, coordinates, selection/player/open-container, door
|
||||
state, nearest monster/door/by class/name/template, and `wobjectfindall*`
|
||||
variants over world, landscape, inventory and containers;
|
||||
- **actions:** select, use, apply, give, equip wand, cast, cast-on-target,
|
||||
move, split and drop;
|
||||
- **combat/movement:** combat state get/set, busy state, equipped weapon type,
|
||||
heading/get-heading-to, motion get/set/clear and portal-state query;
|
||||
- **inventory/loot/salvage:** counts by name/regex/type, give-profile,
|
||||
unopened corpse queries, `ustadd`, `ustopen`, `ustsalvage`;
|
||||
- **fellowship/quest/XP:** thirteen fellowship queries, quest state/progress,
|
||||
seven XP-meter operations;
|
||||
- **UI/options/network/login:** status HUD, view/control get/set/visibility,
|
||||
VT option/meta get/set, macro status, UtilityBelt options, regex capture,
|
||||
network clients and next-login control.
|
||||
|
||||
This catalog is a compatibility test ledger. Each name must eventually be
|
||||
implemented, deliberately aliased, or marked unsupported with a documented
|
||||
reason; silent omission is not acceptable.
|
||||
|
||||
## 4. acdream mapping after the 2026-08 campaigns
|
||||
|
||||
The 2026-07 report's architecture remains correct, but its gap table is stale.
|
||||
The Runtime now owns inventory transactions, selection, combat mode and power
|
||||
state, casting, fellowship, allegiance, vendor and secure-trade state. MossTank
|
||||
already consumes a small BCL-only `IAutomationSurface` for vitals, skills,
|
||||
spells, enchantments, casting and local chat.
|
||||
|
||||
The gaps relevant to the first autocombat milestone are narrower:
|
||||
|
||||
| Need | Canonical owner today | Plugin gap |
|
||||
|---|---|---|
|
||||
| hostile query and live position | `RuntimeEntityDirectory` + `ClientObjectTable` | no target snapshot/query |
|
||||
| health and selected target | `RuntimeActionState` | no combat view |
|
||||
| melee/missile charge/release | `RuntimeCombatAttackState` | no command surface |
|
||||
| combat-mode transition | `RuntimeCombatModeState` | no command surface |
|
||||
| known offensive spells | `Spellbook` | only self buffs are enumerated |
|
||||
| target-specific cast | selection + `RuntimeSpellCastState` | possible only by composing two old services |
|
||||
| polished plugin controls | retained `IUiRegistry` markup | markup lacks bound visibility/enabled/style affordances |
|
||||
|
||||
The first implementation therefore does not need a second runtime bridge or a
|
||||
second object model. It needs a narrow additive projection of those exact
|
||||
owners.
|
||||
|
||||
## 5. Decisions for MossTank
|
||||
|
||||
1. MossTank remains an ordinary plugin. It never references App, Runtime,
|
||||
rendering, networking or DAT assemblies.
|
||||
2. The host API exposes snapshots and attempt-style commands; MossTank owns
|
||||
target scoring, rule ordering, spell/attack choice and timing.
|
||||
3. The first combat milestone supports melee, missile and direct offensive
|
||||
magic, target lock, range/angle/hybrid scoring, priority rules, attack
|
||||
height and power. Navigation, weapon swapping, debuffs, vulnerabilities,
|
||||
pets and monster expressions are later combat slices, not hidden stubs.
|
||||
4. Expressions will use UtilityBelt's richer typed semantics. Compatibility
|
||||
is defined by parser/evaluator tests and the audited function ledger, not by
|
||||
copying UtilityBelt implementation code.
|
||||
5. Native MossTank profiles will be versioned JSON. Importers for VTank files
|
||||
can be added later without constraining the internal model.
|
||||
6. The UI uses acdream's retained plugin UI contract. Missing generic controls
|
||||
should improve that contract/markup rather than making MossTank depend on a
|
||||
presentation implementation.
|
||||
|
||||
### 5.1 Follow-up implementation findings (2026-08-27)
|
||||
|
||||
VTank's official `e0.d(name)` first looks in `MonsterDamageOverrides`, then
|
||||
maps the monster to `SpeciesDamages`; `ga.g(...)` walks that ordered preference
|
||||
list and finally tries the unlisted elements 0..6. acdream already projects
|
||||
retail `CreatureType` as `PluginCombatTarget.SpeciesId`, so MossTank can bypass
|
||||
VTank's name-to-species compatibility table while preserving the same ordered
|
||||
damage result. Exact name overrides still win. The imported official feed has
|
||||
59 overrides and 103 species rows.
|
||||
|
||||
### 5.2 Official inventory and loot findings (2026-08-27)
|
||||
|
||||
The official VTank assembly and its GameInfoDB were inspected rather than
|
||||
inferring behavior from the UI labels:
|
||||
|
||||
- `el.cs`/`cf.cs` supply 757 exact craft rows; prerequisites are recursive and
|
||||
share the canonical item-use transaction;
|
||||
- `fo.cs` identifies every corpse before selection, parses `Killed by ...`,
|
||||
admits the player's own corpse immediately, admits a Share Loot fellow
|
||||
immediately, waits 100 seconds for a non-sharing fellow or unrelated public
|
||||
corpse, and never crosses ownership on another player's rare-generating
|
||||
corpse;
|
||||
- the default corpse-open retry contract is 30 attempts, then a 200-second
|
||||
blacklist; completed corpse records expire after 60 minutes;
|
||||
- `hv.cs` applies the ordered loot rule first, then falls back to readable
|
||||
unknown scrolls and automatic mana-stone/tank acquisition;
|
||||
- `dy.cs` proves that ManaTank is a mana-bearing donor target, not a worn-item
|
||||
recharge consumable. A ManaStone is used on that donor when its mana is at
|
||||
least `ManaTankMinimumMana` (default 1000);
|
||||
- `c7.cs` combines only same-material salvage bags in exact workmanship bands
|
||||
`<7`, `7–<9`, `9–<10`, and exactly `10`; one bugged source is abandoned after
|
||||
40 failed combine attempts;
|
||||
- `gmSalvageUI::Salvage` calls
|
||||
`CM_Inventory::Event_CreateTinkeringTool`: game action `0x027D`, tool id,
|
||||
then `PackableList<unsigned long>` (count plus ordered item ids). This same
|
||||
operation handles ordinary source salvage and salvage-bag combination.
|
||||
|
||||
The native implementation keeps settings, loot, route, and meta documents
|
||||
independent, matching VTank's profile model while using versioned JSON as the
|
||||
working format. It also emits and imports exact compatibility files: `uTank2
|
||||
NAV 1.2`, CondAct `.met`, and VTClassic `UTL 1` (plus legacy UTL v0 reads).
|
||||
The UTL port preserves unknown length-delimited requirement and extra-block
|
||||
payloads, executes the complete 31-type requirement vocabulary, and carries
|
||||
the `SalvageCombine` material ranges/value modes into the live combine planner.
|
||||
VTClassic's color rules use the original ordered ObjDesc subpalettes and the
|
||||
original sample index `length*16 + offset*32 + 8`, resolved from portal DAT
|
||||
palette colors rather than approximated from icon pixels.
|
||||
|
||||
Profiles are implemented over manifest-scoped JSON with exact VTank files as
|
||||
an interchange/export layer: `By char` hashes the canonical character name into a distinct
|
||||
document, named profiles are explicit shared snapshots, and the index records
|
||||
owner plus per-character active selection. Create/copy/clear/select all hot-
|
||||
load the same mutable policy owners already borrowed by the controllers. The
|
||||
generic retained markup contract gained editable fields and retail dropdown
|
||||
menus for this editor; later Monsters, Loot, Route, and Meta editors reuse the
|
||||
same controls.
|
||||
|
||||
## 6. Acceptance boundary for “autocombat ported”
|
||||
|
||||
The milestone is complete when an in-world MossTank panel can enable/disable
|
||||
combat, periodically capture canonical hostile targets, preserve a valid
|
||||
locked target, choose a target by configured range/angle/hybrid policy and
|
||||
priority, enter the equipped default combat mode, drive retail's physical
|
||||
press/charge/release state machine at configured height/power, or cast the
|
||||
best usable learned direct offensive spell in magic mode. It must stop cleanly
|
||||
on session loss, invalid/dead/out-of-range targets, and user disable; it must
|
||||
not duplicate Runtime state or issue overlapping requests.
|
||||
|
||||
Full VTank parity is the campaign target. This acceptance boundary is only the
|
||||
first executable slice requested for this work session.
|
||||
|
||||
## 7. Official binary combat-item findings (2026-08-27)
|
||||
|
||||
The official `vt.tar.gz` update was decompiled for behavior research and the
|
||||
live GameInfoDB v9 feed was read directly. The decisive implementations are
|
||||
`dz.cs` (debuff source selection), `ga.cs` (item classification), `gs.cs`
|
||||
(caster-item confirmation), `bo.cs` (physical/proc confirmation), and `hi.cs`
|
||||
(attack-power policy).
|
||||
|
||||
- `dz.b.CompareTo` ranks spell quality then source skill/spellcraft for
|
||||
`SpellLevel`, reverses those two for `Skill`, and gives a learned spell the
|
||||
final tie. Spell quality is normally spell difficulty.
|
||||
- Caster items activate on the target. Melee/missile proc weapons are equipped
|
||||
and repeatedly attack at power 0/1 respectively. Neither path counts as
|
||||
applied until color-7 combat chat matches `^You cast (.*) on .*$`.
|
||||
- Grenades are missile-class items with CombatUse 0 and `Phial` in the name;
|
||||
the official database contains exactly 72 names across eight material tiers,
|
||||
with Alchemy requirements 75..400 and spellcraft 100..520.
|
||||
- Normal physical attack power is not a smooth heuristic. `hi.cs` emits the
|
||||
exact 0, .2, .49, .5 or 1 values for slash/pierce hybrid arrangements, then
|
||||
clamps to .11..90 when trained Recklessness is enabled.
|
||||
|
||||
These findings require three host facts VTank formerly obtained through
|
||||
Decal: retained per-item appraisal SpellBooks, ordered transcript capture, and
|
||||
an explicit combat-mode command. They are additive BCL plugin contracts;
|
||||
MossTank retains all source-choice and retry policy.
|
||||
745
docs/research/2026-08-26-retail-inventory-interaction-audit.md
Normal file
745
docs/research/2026-08-26-retail-inventory-interaction-audit.md
Normal file
|
|
@ -0,0 +1,745 @@
|
|||
# Retail inventory interaction audit
|
||||
|
||||
**Date:** 2026-08-26
|
||||
|
||||
**Scope:** Selection, status text, single/double/right click, drag/drop,
|
||||
container movement, ground pickup/drop, equipping, stack splitting, vendor
|
||||
staging, failure feedback, and SpewBox routing.
|
||||
|
||||
**Change policy:** Audit followed by implementation in the same worktree.
|
||||
|
||||
**Source snapshot:** `0c699240`, plus the already-present working-tree fixes for
|
||||
#444, #445, #446, #447, and #449. Those fixes are assessed as found; this
|
||||
report does not claim that they have been committed or user-accepted.
|
||||
|
||||
## Implementation closeout — 2026-08-26
|
||||
|
||||
Slices 1–4 below are implemented and automated-test covered. Gameplay
|
||||
refusals now use the `ClientLocal` SpewBox route; move/wield failure kinds are
|
||||
complete; full move/drop/wield are request-first with pending projections;
|
||||
owned-container and vendor-row mouse behavior follows the named retail
|
||||
handlers; hover and release share one side-effect-free legality policy; and
|
||||
local item-policy wording is composed from retail's exact literals.
|
||||
|
||||
The last toolbar uncertainty is also resolved. Raw retail bytes at
|
||||
`gmToolbarUI::HandleSelectionChanged @ 0x004BF4EF` push format literal
|
||||
`0x007B4748`, which decodes to `%d %hs (of %d)`. The owned Pyreal-stack branch
|
||||
now renders that exact stack/name/total shape. The final AutoWield fallback was
|
||||
also corrected: retail does not print the invented “That slot is already in
|
||||
use”; with automatic unblocking enabled it moves the preferred occupied-slot
|
||||
item to the backpack, waits for the authoritative move, then retries the
|
||||
wield. Slice 5 remains deliberately deferred as the single combined connected
|
||||
owner gate.
|
||||
|
||||
## Audited root causes (now fixed)
|
||||
|
||||
The inventory implementation was not missing one isolated rule. Most individual
|
||||
operations existed and used the correct wire messages, but three seams made the
|
||||
whole experience feel intermittent:
|
||||
|
||||
1. **Some retail-local refusal text was routed to a dead production callback.**
|
||||
`ItemInteractionController` and `AutoWieldController` used a `toast` callback
|
||||
for a substantial class of local rejections while `GameWindow` supplied
|
||||
`null`. Retail sends these messages to the `ClientLocal` channel, which is
|
||||
the SpewBox in acdream. The result was a real silent-failure class, not merely
|
||||
different wording.
|
||||
2. **Full moves, world drops, and wield operations mutated canonical inventory
|
||||
state before the server accepted them.** Retail normally leaves the source
|
||||
canonical object in place, adds a waiting/ghost projection at the intended
|
||||
destination, and commits only after the authoritative object update. The
|
||||
old optimistic mutation was reversible, but selection, capacity,
|
||||
paperdoll, vendor, and other observers could see a transient state that never
|
||||
existed on the server. This was the largest structural flakiness risk.
|
||||
3. **Several list-specific mouse behaviors did not match retail.** In
|
||||
particular, staged vendor rows could not be double-clicked or dragged to
|
||||
remove them, staged rows lacked right-click examine, and owned side-pack
|
||||
double-click/open ordering differed from retail.
|
||||
|
||||
The wire builders, global selection/split model, merge-first rule, request gate,
|
||||
most right-click examine paths, normal item double-click use/equip, external
|
||||
container pickup, paperdoll placement validation, and the newly repaired
|
||||
vendor-split/main-pack-capacity paths are broadly aligned with retail.
|
||||
|
||||
The implementation was executed in this order:
|
||||
|
||||
1. Route every local item refusal through `ClientLocal`/SpewBox.
|
||||
2. Replace canonical optimistic movement with retail-style pending projections.
|
||||
3. Close the vendor staged-row and owned-container input differences.
|
||||
4. Deepen hover/drop legality and finish exact status/failure text parity.
|
||||
5. Run one connected interaction matrix across inventory, paperdoll, ground,
|
||||
external containers, and vendors.
|
||||
|
||||
## Method and evidence standard
|
||||
|
||||
This audit used four evidence layers:
|
||||
|
||||
- The September 2013 named retail pseudo-C under
|
||||
`docs/research/named-retail/acclient_2013_pseudo_c.txt`, searched by named
|
||||
class and method before relying on older address-only material.
|
||||
- Existing focused retail notes under `docs/research/`, especially the item,
|
||||
drag, give, world-drop, use/autowear, and vendor investigations.
|
||||
- The current production controllers, Runtime owners, UI input dispatch, wire
|
||||
request builders, and communication routing.
|
||||
- Existing focused tests, used to distinguish implemented intent from behavior
|
||||
that is not currently protected.
|
||||
|
||||
Verdicts in this report mean:
|
||||
|
||||
- **Match:** the important retail behavior and ownership rule are present.
|
||||
- **Partial:** the common path matches, but a retail branch, presentation rule,
|
||||
or failure path is absent.
|
||||
- **Mismatch:** direct retail evidence contradicts the current behavior.
|
||||
- **Risk:** the mechanism differs in a way likely to produce transient or race
|
||||
defects, but this audit does not assert a particular live symptom without a
|
||||
connected reproduction.
|
||||
- **Gate pending:** a code fix exists in the working tree and has automated
|
||||
coverage, but the owner has not yet accepted the live behavior.
|
||||
|
||||
## Retail reference model
|
||||
|
||||
### One selected object and one split quantity
|
||||
|
||||
Retail has a client-global selected object. Clicking an item selects it;
|
||||
right-click first selects it and then examines it; beginning a drag selects it
|
||||
if it was not already selected. The toolbar observes that global selection and
|
||||
shows the name, stack quantity, and split controls.
|
||||
|
||||
The split quantity is also global and applies only when the dragged/requested
|
||||
object is the selected object. An unselected stack always means the full stack.
|
||||
Changing selection resets/reseeds the split amount. Vendor-owned selected
|
||||
stacks use a different initial amount from normal owned stacks.
|
||||
|
||||
Primary anchors:
|
||||
|
||||
- `UIElement_ItemList::ListenToElementMessage` at `0x004E4D50`
|
||||
- `UIElement_ItemList::BeginDrag` at `0x004E32D0`
|
||||
- `gmToolbarUI::HandleSelectionChanged` at `0x004BF380`
|
||||
- `ItemHolder::GetObjectSplitSize` in the named retail pseudo-C
|
||||
|
||||
### Mouse-down establishes intent; click completion performs list action
|
||||
|
||||
For a retail item-list entry, left press first gives target mode a chance to
|
||||
consume the object. Otherwise it selects the object. A container-list entry
|
||||
also opens that child container and updates its open indicator in this same
|
||||
item-list message path.
|
||||
|
||||
Right press selects and examines. Double-click invokes generic `UseObject` for
|
||||
ordinary list items, but the generic double-use path is suppressed for an
|
||||
owned `containerList` entry. The ground/external root is explicitly allowed.
|
||||
|
||||
This distinction matters: a side pack is opened as a container, not opened and
|
||||
then generically used as an ordinary item on the second click.
|
||||
|
||||
### Dragging is a request with pending presentation
|
||||
|
||||
Beginning a physical-item drag produces a source waiting/ghost state. Vendor,
|
||||
salvage, and shortcut lists are special list types and do not use the same
|
||||
physical-source waiting ghost.
|
||||
|
||||
Hover is advisory and silent. Release reruns legality with feedback enabled.
|
||||
For a normal container move, retail retains the canonical source ownership and
|
||||
adds a pending destination projection. The server's authoritative object update
|
||||
commits the move. Rejection removes the pending projection and prints the local
|
||||
failure. This same general principle appears in world placement and split-to-
|
||||
world handling.
|
||||
|
||||
Primary anchors:
|
||||
|
||||
- `UIElement_ItemList::BeginDrag` at `0x004E32D0`
|
||||
- `UIElement_ItemList::DragOver` at `0x004E3400`
|
||||
- `UIElement_ItemList::AcceptDragObject` at `0x004E4250`
|
||||
- `UIElement_ItemList::HandleDropRelease` at `0x004E4790`
|
||||
- `ItemHolder::AttemptToPlaceInContainer_IsItemLegal` at `0x005870C0`
|
||||
- `ItemHolder::AttemptToPlaceInContainer_IsContainerLegal` at `0x005879B0`
|
||||
- `ItemHolder::WillItemFitInContainer` at `0x00587D60`
|
||||
- `ItemHolder::IsDragIntoContainerAttemptLegal` at `0x00587E90`
|
||||
|
||||
### Drop target dispatch is ordered
|
||||
|
||||
Retail's three-dimensional drop/give dispatcher follows this practical order:
|
||||
|
||||
1. Require an owned, movable source that is not currently in trade.
|
||||
2. Dropping on self means the main backpack.
|
||||
3. Target zero means ground placement or split-to-world.
|
||||
4. Try stack merge before treating the target as a container.
|
||||
5. A player target opens/routes through secure trade.
|
||||
6. A creature target uses give-item behavior.
|
||||
7. A container target must be open, unlocked, and legal.
|
||||
8. Vendor lists use their own staging rules.
|
||||
9. Otherwise resolve as a ground placement or refuse it.
|
||||
|
||||
`AttemptMerge` uses the selected split amount, clamps to target capacity, sends
|
||||
the merge request, and selects the target stack. Give-item is request-only; it
|
||||
does not optimistically remove the source from canonical inventory.
|
||||
|
||||
Primary anchors:
|
||||
|
||||
- `ItemHolder::AttemptMerge` at `0x005878F0`
|
||||
- `ItemHolder::AttemptPlaceIn3D` at `0x00588600`
|
||||
- `docs/research/2026-07-13-retail-give-item-pseudocode.md`
|
||||
- `docs/research/2026-07-26-retail-inventory-placement-and-world-drop-pseudocode.md`
|
||||
|
||||
### Use and equip
|
||||
|
||||
Generic double-click use passes through `ItemHolder::DetermineUseResult` and
|
||||
`ItemHolder::UseObject`, with a short use throttle. The item is classified as
|
||||
direct-use, targeted-use, pickup, equip/autowear, trade, salvage, or game use.
|
||||
Retail locally refuses invalid states and prints a `ClientLocal` message.
|
||||
|
||||
Paperdoll 3D clicks and discrete equipment-slot lists share the same global
|
||||
selection/examine model. Dropping on a paperdoll location validates the exact
|
||||
location, then chooses auto-wear or auto-wield behavior. Clothing overlap can
|
||||
be rejected locally; weapon replacement has different rules.
|
||||
|
||||
Primary anchors:
|
||||
|
||||
- `ItemHolder::DetermineUseResult` at `0x00588460`
|
||||
- `ItemHolder::UseObject` at `0x00588A80`
|
||||
- `CPlayerSystem::UsingItem` at `0x00562F70`
|
||||
- `gmPaperDollUI::ListenToElementMessage` at `0x004A5C30`
|
||||
- `gmPaperDollUI::AcceptDragObject` at `0x004A3B10`
|
||||
- `gmPaperDollUI::AcceptPaperDollDragObject` at `0x004A4A70`
|
||||
- `docs/research/2026-07-23-retail-item-use-and-autowear-pseudocode.md`
|
||||
|
||||
### Vendor rows are active item lists
|
||||
|
||||
Direct named-retail evidence establishes these behaviors:
|
||||
|
||||
- Double-clicking a vendor browse row buys one item.
|
||||
- Double-clicking a staged buying row removes it and prints
|
||||
“Removing %s from shopping list” through `ClientLocal`.
|
||||
- Double-clicking a staged selling row removes it, clears its sell state, and
|
||||
prints the same form of message.
|
||||
- Dragging an already-staged selling row removes it from the staged list.
|
||||
- If a partial split is selected while dragging a staged selling row, retail
|
||||
refuses to split that row, prints “You cannot split items from this panel”,
|
||||
and resets the split control to the stack maximum.
|
||||
- A new partial-stack drag into the Selling list sends a split request, creates
|
||||
a temporary staged row, and replaces that row when the new matching object
|
||||
arrives.
|
||||
- Hover rejection is silent; release rejection prints to `ClientLocal`.
|
||||
|
||||
Primary anchors:
|
||||
|
||||
- `gmVendorUI::HandleMousePresses` at `0x004C40D0`
|
||||
- `gmVendorUI::RecvNotice_ItemListBeginDrag` at `0x004C4380`
|
||||
- `VendorSellUI::DragItemAcceptable` at `0x004C20C0`
|
||||
- `VendorSellUI::AcceptDragObject` at `0x004C4F00`
|
||||
- `VendorSellUI::ItemAttributesChanged` at `0x004C3FD0`
|
||||
|
||||
This corrects an older project research conclusion: browse-row double-click
|
||||
buy is retail behavior. It is not an acdream modernization.
|
||||
|
||||
### Feedback destination
|
||||
|
||||
Retail item-policy and request-failure messages are sent on the local client
|
||||
text channel. In acdream, `RuntimeCommunicationState.AddText` maps
|
||||
`ClientLocal` (`0x1A`) to the SpewBox only: it does not add the line to the
|
||||
chat transcript and does not apply a chat timestamp.
|
||||
|
||||
Hover failures are normally silent. Release/action failures are not. Server
|
||||
request failures are composed by `ACCWeenieObject::ServerSaysAttemptFailed`
|
||||
at `0x0058EAE0`, including move and wield failures.
|
||||
|
||||
## Current acdream ownership and routing
|
||||
|
||||
The relevant production flow is:
|
||||
|
||||
```text
|
||||
UiRoot / UiItemSlot
|
||||
-> InventoryController | ExternalContainerController | PaperdollController
|
||||
| VendorUiController | SelectedObjectController
|
||||
-> ItemInteractionController / AutoWieldController
|
||||
-> RuntimeInventoryState + RuntimeActionState transactions
|
||||
-> ClientObjectTable (canonical object ownership)
|
||||
-> outbound request builder
|
||||
-> authoritative object update / request failure
|
||||
-> RuntimeCommunicationState.ClientLocal -> SpewBox
|
||||
```
|
||||
|
||||
Important owners:
|
||||
|
||||
- `SelectionState` is the sole selected-object owner shared by inventory,
|
||||
paperdoll, vendor, world selection, and toolbar status.
|
||||
- `RuntimeInventoryState` owns external-container state, item-use transaction
|
||||
state, shared busy/request state, split/pending placement state, and borrows
|
||||
the canonical `ClientObjectTable`.
|
||||
- `SelectedObjectController` projects selection into the authored toolbar and
|
||||
owns the split-slider presentation.
|
||||
- `ItemInteractionController` classifies use/drop/give/move operations and
|
||||
sends requests.
|
||||
- `InventoryController`, `ExternalContainerController`, `PaperdollController`,
|
||||
and `VendorUiController` own their list-specific input and projections.
|
||||
|
||||
This ownership shape aligns with the architecture document. The central issue
|
||||
is not duplicate state; it is which state is mutated before acknowledgement.
|
||||
|
||||
## Behavior matrix
|
||||
|
||||
| Surface/action | Retail | Current acdream | Verdict |
|
||||
|---|---|---|---|
|
||||
| Inventory left press | Target-mode consume, otherwise select | `PrimaryItemPressed` does the same | Match |
|
||||
| Ordinary item single click | Select; no generic use | Mouse-down selects | Match |
|
||||
| Ordinary item double-click | Generic use/equip | `DoubleClicked = ActivateItem` | Match |
|
||||
| Owned side-pack single press | Select and open in the item-list handler | Selects and opens on mouse-down | Match, implemented |
|
||||
| Owned side-pack double-click | Open behavior; generic item double-use suppressed | Opens once; generic activation is suppressed | Match, implemented |
|
||||
| Inventory right-click | Select, then examine | Select and examine | Match |
|
||||
| Drag lift | Select if needed; source ghost | Selects and ghosts | Match |
|
||||
| Drag hover | Silent, legality-aware green/red | Silent and shares the release legality decision | Match, implemented |
|
||||
| Full internal move | Request plus pending destination projection; canonical source waits for server | Request-first pending projection; authoritative update commits | Match, implemented |
|
||||
| Merge stacks | Merge before container placement; selected split amount; select target | Same broad behavior | Match |
|
||||
| Partial move to container | Split request; wait for authoritative object | Request-only | Match |
|
||||
| Drop to ground | Request/pending presentation; source remains canonical until response | Request-first; canonical source waits for response | Match, implemented |
|
||||
| Split to ground | Global pending split; select arriving matching object; timeout | Request/pending path exists | Broad match |
|
||||
| Pick up from ground | Pending destination projection; authoritative commit | Pending destination path | Match |
|
||||
| Open external container | Root/nested list-specific behavior | Root double-click, nested open behavior | Broad match |
|
||||
| Move to external container | Request-only, open/unlocked legality, server commit | Request-only with shared hover/release legality | Match, implemented |
|
||||
| Give to creature | Request-only; selected split amount | Request-only | Match |
|
||||
| Give/drop to player | Secure-trade routing | Secure-trade routing exists | Broad match |
|
||||
| Paperdoll click/right-click | Global select/examine | Global select/examine | Match |
|
||||
| Paperdoll drag equip | Exact location validation; auto-wear/wield | Same broad split | Broad match |
|
||||
| Full wield | Authoritative request model | Request-first; canonical ownership waits for response | Match, implemented |
|
||||
| Invalid item use/equip | ClientLocal text in SpewBox | Shared `ReportClientLocal` route | Match, implemented |
|
||||
| Selected status | Normal name or `{quantity} name`; owned coin is `%d %hs (of %d)` | Both branches implemented | Match, implemented |
|
||||
| Split applicability | Only selected stack uses global quantity | Same | Match |
|
||||
| Vendor browse single/right | Select; right-click examine | Select and right-click examine | Match |
|
||||
| Vendor browse double | Buy one | Buy one | Match |
|
||||
| Drag inventory to Selling | Stage full or selected partial quantity | Present; partial temp-row replacement present | Match, #445 gate pending |
|
||||
| Vendor hover refusal | Silent | Silent | Match |
|
||||
| Vendor release refusal | ClientLocal/SpewBox | System message/SpewBox path | Match |
|
||||
| Staged Buying double-click | Remove one + SpewBox line | Same | Match, implemented |
|
||||
| Staged Selling double-click | Remove row, clear state + SpewBox line | Same | Match, implemented |
|
||||
| Staged Selling drag | Remove row; partial selection warns and resets split | Same, exact refusal + reset | Match, implemented |
|
||||
| Staged row right-click | Generic select/examine item-list behavior | Select and examine on every vendor list role | Match, implemented |
|
||||
| Main-pack capacity | Items and carried containers counted separately | Separate loose-item count now present | Match, #449 gate pending |
|
||||
| Server move/wield failure text | Exact ClientLocal move/wield compositions | Both request kinds and compositions present | Match, implemented |
|
||||
|
||||
## Findings
|
||||
|
||||
### F1 — local inventory refusals can be completely silent
|
||||
|
||||
**Resolution:** CLOSED IN CODE — one `ReportClientLocal` route now selects
|
||||
interface text, system text, or the test fallback in that order.
|
||||
|
||||
**Priority:** P0
|
||||
|
||||
**Confidence:** Confirmed by production composition
|
||||
|
||||
`ItemInteractionController` uses two different presentation routes:
|
||||
|
||||
- `_systemMessage` / `_interfaceText`, which are wired to
|
||||
`RuntimeCommunicationState.AddText(..., ClientLocal)` and reach SpewBox.
|
||||
- `_toast`, used by many local policy refusals.
|
||||
|
||||
`InteractionRetainedUiComposition` forwards its `toast` dependency, but
|
||||
`GameWindow` currently sets the production composition toast to `null` after
|
||||
the developer-toast surface was removed. Consequently, the local rejection
|
||||
still aborts the action, but the user receives no explanation.
|
||||
|
||||
Affected classes include invalid item use, missing use target, trade/wield
|
||||
requirements, locked or unsuitable targets, invalid move/give/drop states,
|
||||
midair/drop refusal, and paperdoll slot-in-use refusal. Exact membership should
|
||||
be frozen in a focused message-routing test before changing it.
|
||||
|
||||
Retail evidence is unambiguous: these are local client text messages and belong
|
||||
in SpewBox, not a transient developer toast.
|
||||
|
||||
**Future fix:** remove the semantic split for gameplay failure text. Give item
|
||||
controllers one `ClientLocal` sink and reserve any visual toast mechanism for
|
||||
non-retail developer/launcher notifications.
|
||||
|
||||
### F2 — optimistic canonical moves expose impossible intermediate state
|
||||
|
||||
**Resolution:** CLOSED IN CODE — full move, world drop, and wield dispatch
|
||||
requests without mutating canonical ownership; pending source/destination
|
||||
presentation converges on confirmation, failure, and reset.
|
||||
|
||||
**Priority:** P0 architectural correction
|
||||
|
||||
**Confidence:** Confirmed mechanism divergence; symptom linkage requires gates
|
||||
|
||||
The full-stack internal move and world-drop paths use optimistic operations
|
||||
against the canonical object table. Full wield uses the same pattern. Failure
|
||||
rollback exists, but all borrowers can observe the speculative state:
|
||||
|
||||
- selection and toolbar status;
|
||||
- loose-item and carried-container capacity;
|
||||
- paperdoll slots;
|
||||
- vendor sell eligibility/staging;
|
||||
- external-container views;
|
||||
- plugins and Runtime views.
|
||||
|
||||
Retail instead keeps source canonical ownership stable and uses waiting/ghost
|
||||
presentation at the intended destination until the server update arrives.
|
||||
|
||||
This does not prove that every reported intermittent inventory symptom comes
|
||||
from this seam. It does explain why otherwise-correct controllers can disagree
|
||||
briefly and why a rejection/late response/re-entrant action can make the UI feel
|
||||
flaky.
|
||||
|
||||
**Future fix:** model full move/drop/wield like the existing request-only split,
|
||||
give, ground-pickup, and external-container paths. Store a generation-scoped
|
||||
pending placement intent and presentation ghost, send the request, and let the
|
||||
authoritative update commit canonical ownership. On failure/timeout/reset,
|
||||
remove only the pending presentation.
|
||||
|
||||
### F3 — vendor staged-row removal behavior is missing
|
||||
|
||||
**Resolution:** CLOSED IN CODE — staged rows implement the retail
|
||||
double-click, right-click, drag-lift, message, and split-reset branches.
|
||||
|
||||
**Priority:** P1
|
||||
|
||||
**Confidence:** Confirmed by direct named-retail functions
|
||||
|
||||
Current staged Buying and Selling rows only bind selection. They have no
|
||||
double-click removal. Selling rows also disable drag source behavior.
|
||||
|
||||
Retail supports:
|
||||
|
||||
- double-click staged Buying to remove;
|
||||
- double-click staged Selling to remove and clear sell state;
|
||||
- drag staged Selling to remove;
|
||||
- a precise ClientLocal removal line;
|
||||
- a partial-split refusal/reset when dragging from the staged Selling list.
|
||||
|
||||
**Future fix:** add list-role-specific actions rather than routing these rows
|
||||
through generic item activation. Protect each action with unit tests that also
|
||||
assert selection, sell-state cleanup, totals, and exact SpewBox routing.
|
||||
|
||||
### F4 — owned side-pack click/double-click sequencing differs
|
||||
|
||||
**Resolution:** CLOSED IN CODE — carried containers open on press and the
|
||||
generic double-use route is suppressed for that list role.
|
||||
|
||||
**Priority:** P1
|
||||
|
||||
**Confidence:** Confirmed structural mismatch
|
||||
|
||||
Retail opens a carried child container in the item-list press handler and
|
||||
suppresses generic double-click use for a `containerList` item. acdream selects
|
||||
on mouse-down, opens on completed click, and binds generic activation to double
|
||||
click for every inventory cell. `UiRoot` emits the second click before the
|
||||
double-click event, so a double-click can both open and activate the pack.
|
||||
|
||||
This is a plausible source of redundant requests and awkward drag/open
|
||||
interactions. It should be fixed by explicit item-list role, not by a global
|
||||
double-click timing change, because ordinary items and the external-container
|
||||
root intentionally retain double-click use/open behavior.
|
||||
|
||||
### F5 — hover acceptance is less strict than release/server legality
|
||||
|
||||
**Resolution:** CLOSED IN CODE — `InventoryContainerPlacementPolicy` is the
|
||||
shared silent-hover/speaking-release decision for owned and external lists.
|
||||
|
||||
**Priority:** P1/P2
|
||||
|
||||
**Confidence:** Confirmed code difference
|
||||
|
||||
Inventory-grid hover mostly checks list role, basic object class, and capacity.
|
||||
External-container hover is broader still. Retail's predicates incorporate
|
||||
ownership, trade state, source/destination identity, real carrying-container
|
||||
restrictions, open/locked state, destination capacity type, and other legal
|
||||
conditions.
|
||||
|
||||
The practical symptom is a green cursor followed by a refusal or apparent
|
||||
no-op on release. Hover must remain silent, but its boolean should be produced
|
||||
from the same pure legality decision used at release.
|
||||
|
||||
**Future fix:** extract one side-effect-free placement decision that returns a
|
||||
reason code. Hover consumes only allowed/denied; release converts the same
|
||||
reason to exact ClientLocal text.
|
||||
|
||||
### F6 — selected status lacks retail's owned-coin special case
|
||||
|
||||
**Resolution:** CLOSED IN CODE — the PDB-matched retail executable resolves
|
||||
the literal at `0x007B4748` to `%d %hs (of %d)`; the controller now reads the
|
||||
player's `CoinValue` and uses that exact branch for owned WCID 273 stacks.
|
||||
|
||||
**Priority:** P2
|
||||
|
||||
**Confidence:** Byte-resolved from the PDB-matched retail executable
|
||||
|
||||
Normal current text—name for a singleton and `{stackSize} {name}` for a
|
||||
stack—matches the main retail branch. Retail has an additional owned-coinstack
|
||||
formatting branch that derives a total/value-aware display and name. The
|
||||
current controller always uses the generic stack prefix.
|
||||
|
||||
Binary inspection resolves the apparent vtable-symbol artifact: the raw call
|
||||
site pushes `0x007B4748`, `%d %hs (of %d)`, with stack size, appropriate name,
|
||||
and the player's integer `CoinValue` as its three arguments.
|
||||
|
||||
### F7 — request failure coverage omits move and wield kinds
|
||||
|
||||
**Resolution:** CLOSED IN CODE — both request kinds are represented and route
|
||||
through the item-aware retail failure composer.
|
||||
|
||||
**Priority:** P2
|
||||
|
||||
**Confidence:** Confirmed enum/composer gap
|
||||
|
||||
Retail's `ServerSaysAttemptFailed` includes move and wield result families.
|
||||
The current request-failure model and `InventoryFailureMessages` cover merge,
|
||||
split, pickup, put, drop, and give, but do not represent the retail move/wield
|
||||
families. A server-side failure in those operations therefore cannot produce
|
||||
the exact item-aware retail sentence through the common composer.
|
||||
|
||||
### F8 — current retail-divergence documentation is wrong about vendor double-click
|
||||
|
||||
**Resolution:** CLOSED — the older research is corrected and AP-171 retired.
|
||||
|
||||
**Priority:** Documentation correction before implementation
|
||||
|
||||
**Confidence:** Confirmed by direct named-retail evidence
|
||||
|
||||
Older vendor research and AP-171 characterize double-click browse-row purchase
|
||||
as an acdream enhancement. `gmVendorUI::HandleMousePresses` directly calls
|
||||
`BuySingleItem` on the retail Items-list double-click. Current browse behavior
|
||||
is correct; the documentation is not. Leaving this claim in the register risks
|
||||
a future parity cleanup deleting a retail feature.
|
||||
|
||||
### F9 — #445 and #449 need connected acceptance, not more inference
|
||||
|
||||
**Priority:** Gate now
|
||||
|
||||
**Confidence:** Automated fixes present
|
||||
|
||||
- #445 now uses the selected split quantity for vendor selling, creates a
|
||||
temporary staged row, and replaces it when the authoritative split object
|
||||
arrives.
|
||||
- #449 now counts loose items separately from carried container objects when
|
||||
deciding whether the main backpack is full.
|
||||
|
||||
Both have focused tests in the current working tree. Neither should be marked
|
||||
closed until a live server gate covers success, refusal, repeated action, and
|
||||
selection changes.
|
||||
|
||||
### F10 — paperdoll disappearance is a separate rendering/residency defect
|
||||
|
||||
**Priority:** Keep separate from transaction fixes
|
||||
|
||||
**Confidence:** Existing issue #443
|
||||
|
||||
The intermittent missing paperdoll that heals after a delay is tracked as
|
||||
paperdoll first-open/residency behavior. It can make a correct equip transaction
|
||||
look broken, so it belongs in the combined user gate, but it should not be
|
||||
folded into inventory ownership or input logic without evidence.
|
||||
|
||||
## SpewBox contract
|
||||
|
||||
The following should appear in the SpewBox through `ClientLocal` when the user
|
||||
commits the action and it is refused or changed:
|
||||
|
||||
- invalid use/equip/wield state;
|
||||
- “choose a target” or invalid target;
|
||||
- cannot move/drop/give an item;
|
||||
- locked, closed, full, or otherwise illegal destination;
|
||||
- merge/split/pickup/put/drop/give/move/wield request failure;
|
||||
- vendor item cannot be sold or split in that list;
|
||||
- removal from a vendor shopping/selling list;
|
||||
- automatic removal of conflicting wear items where retail reports it;
|
||||
- midair or other locally cancelled placement when retail reports it.
|
||||
|
||||
The following should be silent:
|
||||
|
||||
- merely hovering a rejected drop target;
|
||||
- moving the pointer away without releasing;
|
||||
- ordinary selection changes;
|
||||
- beginning a legal drag.
|
||||
|
||||
These messages should not be duplicated into the normal chat log and should
|
||||
not gain chat timestamps. That is already how `ClientLocal` behaves in the
|
||||
communication owner.
|
||||
|
||||
## Existing automated coverage
|
||||
|
||||
The repository already has strong narrow coverage in:
|
||||
|
||||
- `InventoryControllerTests`: population, selection, open/right-click,
|
||||
drag/ghost, pending pickup, split, merge, capacity, rollback, and #449.
|
||||
- `ExternalContainerControllerTests`: root/nested behavior, selection,
|
||||
right-click, partial split, and pending gates.
|
||||
- `PaperdollControllerTests`: selection, examine, drag, and wield placement.
|
||||
- `SelectedObjectControllerTests`: name, stack status, slider, and vendor split
|
||||
initialization.
|
||||
- `VendorUiControllerTests`: browse, buy quantities, selection/examine,
|
||||
staging, partial vendor split/failure, rejection feedback, and alternate
|
||||
currency.
|
||||
- `ItemInteractionControllerTests`: use/equip, world drop, give, partial-stack
|
||||
behavior, failures, and transaction lifecycle.
|
||||
- Runtime inventory tests: request ownership, reset, and lifecycle behavior.
|
||||
|
||||
The pre-implementation test suite was strongest at proving controller-local
|
||||
intent. The implementation program below adds the missing transaction and
|
||||
cross-controller coverage.
|
||||
|
||||
## Automated gates added by the implementation
|
||||
|
||||
The implementation adds or updates coverage for the following:
|
||||
|
||||
1. A production-composition test proving every local policy rejection reaches
|
||||
`ClientLocal`/SpewBox and no gameplay failure depends on a toast callback.
|
||||
2. Owned side-pack single/double-click tests proving one open action and no
|
||||
generic use request, including the second-click event order.
|
||||
3. Vendor staged Buying and Selling double-click removal tests with exact
|
||||
selection, totals, state cleanup, and message assertions.
|
||||
4. Vendor staged Selling drag-to-remove and selected-partial split-reset tests.
|
||||
5. Staged vendor-row right-click select/examine tests.
|
||||
6. A table-driven pure legality test shared by hover and release for inventory,
|
||||
external container, ground, player, creature, vendor, self, locked container,
|
||||
full item slots, and full container slots.
|
||||
7. Owned coinstack toolbar-status parity using the byte-resolved exact format.
|
||||
8. Move and wield authoritative failure-composition tests.
|
||||
9. Transaction-observer tests proving canonical ownership does not change
|
||||
before acknowledgement while selection, capacity, vendor, and paperdoll
|
||||
borrow the same state.
|
||||
10. Re-entrant sequences: drag while a request is pending, selection change
|
||||
during split, rejection after container close, late response after session
|
||||
reset, and repeated action after rollback.
|
||||
|
||||
## Executed implementation program
|
||||
|
||||
### Slice 1 — feedback integrity — COMPLETE
|
||||
|
||||
- Replace gameplay `toast` refusal calls with the shared ClientLocal sink.
|
||||
- Add the missing move/wield failure kinds and exact item-aware compositions.
|
||||
- Freeze hover-silent versus release-speaks behavior.
|
||||
- Correct the vendor double-click documentation claim.
|
||||
|
||||
This is small, high-confidence, and immediately turns “nothing happened” into
|
||||
an actionable player explanation.
|
||||
|
||||
### Slice 2 — authoritative placement ownership — COMPLETE
|
||||
|
||||
- Introduce one generation-scoped pending placement record for full move,
|
||||
world drop, and wield.
|
||||
- Preserve canonical source ownership until the authoritative object update.
|
||||
- Project source waiting/ghost and destination pending visuals separately.
|
||||
- Converge success, refusal, timeout, disconnect, and late-response cleanup.
|
||||
- Prove all borrowed observers see either pre-commit or committed state, never
|
||||
a speculative canonical move.
|
||||
|
||||
This is the most important solidity work and should receive dual review because
|
||||
it crosses Runtime ownership and retained presentation.
|
||||
|
||||
### Slice 3 — item-list mouse parity — COMPLETE
|
||||
|
||||
- Make carried-container press/open and double-click suppression explicit.
|
||||
- Add staged vendor double-click removal.
|
||||
- Add staged Selling drag-to-remove and split reset/refusal.
|
||||
- Restore right-click select/examine consistently across vendor list roles.
|
||||
|
||||
### Slice 4 — shared legality and exact presentation — COMPLETE
|
||||
|
||||
- Unify hover/release placement decisions with reason codes.
|
||||
- Add the owned-coinstack toolbar branch after capturing exact retail text.
|
||||
- Reconcile hard-coded local item wording with DAT-backed retail strings.
|
||||
|
||||
### Automated verification — COMPLETE
|
||||
|
||||
- Focused inventory/external-container/paperdoll/vendor/selection/item-use
|
||||
matrix: 328 passed, 0 failed.
|
||||
- Cross-controller retained-UI interaction flow: 10 passed, 0 failed.
|
||||
- Complete Release build: 0 warnings, 0 errors.
|
||||
- Repository hermetic lane (the exact release filter, serial execution):
|
||||
15,755 passed, 0 skipped, 0 failed across 14 test assemblies.
|
||||
|
||||
The repository wrapper's project-consistency preflight explicitly excludes the
|
||||
tracked deployment-only ACE comparison mods under `tools/ace-mods/`. They
|
||||
compile against a separately installed ACE server and intentionally remain
|
||||
outside `AcDream.slnx`; the portable product graph still owns every other
|
||||
project under `src/`, `tests/`, and `tools/`.
|
||||
|
||||
### Slice 5 — connected closure — DEFERRED OWNER GATE
|
||||
|
||||
Run the manual matrix below against ACE using an exact built binary and retain
|
||||
logs/screenshots for failures. Close #445 and #449 only after their rows pass.
|
||||
Keep #443 independent unless the evidence links paperdoll rendering to an
|
||||
inventory acknowledgement.
|
||||
|
||||
## Connected manual matrix
|
||||
|
||||
Use one normal item, one wearable item, one wieldable item, two mergeable
|
||||
stacks, one side pack, a full main backpack, a full side pack, an open chest,
|
||||
a locked/closed container if available, a creature/player target, and a vendor
|
||||
with normal and alternate currency.
|
||||
|
||||
1. Single-click each item/list type; verify selection border and exact status.
|
||||
2. Right-click inventory, side-pack, external-container, paperdoll, browse,
|
||||
Buying, and Selling rows; verify selection and examine.
|
||||
3. Double-click ordinary usable, wearable, wieldable, and unusable items;
|
||||
verify one request and correct SpewBox refusal where applicable.
|
||||
4. Single- and double-click a carried side pack; verify one open action, no
|
||||
redundant generic use, and stable selection.
|
||||
5. Drag a full item between main pack and side pack; observe source/destination
|
||||
before response, after success, and after forced rejection.
|
||||
6. Fill a side pack, reject a move, free one slot, and retry immediately.
|
||||
7. Fill the main pack with loose items while carrying side packs; verify item
|
||||
and container capacities independently (#449).
|
||||
8. Merge full and partial stacks; verify selected split amount, target
|
||||
selection, source remainder, and full-target refusal text.
|
||||
9. Split to an inventory container, external container, creature, ground, and
|
||||
vendor; change selection while the request is pending.
|
||||
10. Drop full and partial stacks to ground; verify ghost/pending behavior,
|
||||
selected arriving object, rejection cleanup, and no duplicate item.
|
||||
11. Pick up from ground into a nearly full destination, then retry after
|
||||
freeing capacity.
|
||||
12. Equip by double-click and by paperdoll drag; test clothing conflict and
|
||||
weapon replacement. Verify source/paperdoll state before acknowledgement.
|
||||
13. Drag full and partial stacks to vendor Selling; verify exact quantities,
|
||||
temp-row replacement, totals, and #445 behavior.
|
||||
14. Double-click staged Buying and Selling rows to remove them; verify SpewBox
|
||||
text and state cleanup.
|
||||
15. Drag a staged Selling row to remove it; repeat with a partial split selected
|
||||
and verify refusal plus slider reset.
|
||||
16. Complete/cancel transactions in normal and alternate currency; verify
|
||||
currency balance refresh (#444) and selection/status stability.
|
||||
17. Repeat representative actions while another inventory request is pending,
|
||||
immediately after rejection, and immediately after reopening a container.
|
||||
18. Log out/portal/re-enter with a pending or recently completed interaction;
|
||||
verify the request ledger and pending projections converge to zero.
|
||||
|
||||
For every refused release/action, record whether the cursor was green/red,
|
||||
whether a SpewBox line appeared, the exact line, and whether canonical item
|
||||
ownership changed before the server response.
|
||||
|
||||
## Evidence index
|
||||
|
||||
Retail research already in the tree:
|
||||
|
||||
- `docs/research/deepdives/r06-items-inventory.md`
|
||||
- `docs/research/2026-07-13-retail-item-drag-visuals-pseudocode.md`
|
||||
- `docs/research/2026-07-13-retail-give-item-pseudocode.md`
|
||||
- `docs/research/2026-07-23-retail-item-use-and-autowear-pseudocode.md`
|
||||
- `docs/research/2026-07-26-retail-inventory-placement-and-world-drop-pseudocode.md`
|
||||
- `docs/research/2026-08-08-slice6-vendor-transactions-research.md`
|
||||
- `docs/research/named-retail/acclient_2013_pseudo_c.txt`
|
||||
|
||||
Primary current implementation surfaces:
|
||||
|
||||
- `src/AcDream.App/UI/UiRoot.cs`
|
||||
- `src/AcDream.App/UI/UiItemSlot.cs`
|
||||
- `src/AcDream.App/UI/ItemInteractionController.cs`
|
||||
- `src/AcDream.App/UI/Layout/InventoryController.cs`
|
||||
- `src/AcDream.App/UI/Layout/ExternalContainerController.cs`
|
||||
- `src/AcDream.App/UI/Layout/PaperdollController.cs`
|
||||
- `src/AcDream.App/UI/Layout/SelectedObjectController.cs`
|
||||
- `src/AcDream.App/UI/Layout/VendorUiController.cs`
|
||||
- `src/AcDream.App/UI/AutoWieldController.cs`
|
||||
- `src/AcDream.App/Composition/InteractionRetainedUiComposition.cs`
|
||||
- `src/AcDream.App/Rendering/GameWindow.cs`
|
||||
- `src/AcDream.Core/Items/ItemInteractionPolicy.cs`
|
||||
- `src/AcDream.Runtime/Gameplay/RuntimeInventoryState.cs`
|
||||
- `src/AcDream.Runtime/Gameplay/RuntimeActionState.cs`
|
||||
|
||||
## Closure statement
|
||||
|
||||
The retail-backed work order is implemented through Slice 4. The code now has
|
||||
one ClientLocal feedback route, request-first authoritative placement,
|
||||
list-role-specific retail mouse behavior, shared placement legality, complete
|
||||
move/wield failure composition, exact local-policy literals, and the exact
|
||||
owned-coinstack status format. Occupied-slot AutoWield now also follows retail's
|
||||
move-confirm-retry transaction instead of emitting an invented refusal. The
|
||||
complete hermetic automated lane is green. No connected acceptance is claimed
|
||||
here; the combined owner gate remains the final closure step, and #443 remains
|
||||
an independent private-viewport residency issue.
|
||||
115
docs/research/2026-08-26-retail-keyboard-routing-audit.md
Normal file
115
docs/research/2026-08-26-retail-keyboard-routing-audit.md
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
# Retail keyboard defaults and routing audit — 2026-08-26
|
||||
|
||||
## Verdict
|
||||
|
||||
The code gate for #446 now covers all 306 user-bindable rows in the installed
|
||||
Sept-2013 EoR ActionMap. Each row has a distinct `InputAction`, appears enabled
|
||||
in Configure Keyboard, persists through retail-compatible named `.keymap`
|
||||
profiles, and reaches
|
||||
a concrete subsystem consumer. The exact installed-DAT default chord set has
|
||||
zero exceptions. The remaining gate is a connected visual/behavior pass and a
|
||||
fresh-process persistence check.
|
||||
|
||||
The approved acdream extension is deliberately retained: mouse-wheel chase
|
||||
zoom may pull back to 40 m. It does not change the retail keyboard defaults or
|
||||
the keypad camera actions.
|
||||
|
||||
## Oracles
|
||||
|
||||
- `docs/research/named-retail/retail-default.keymap.txt` and installed
|
||||
`client_portal.dat` ActionMap DID `0x26000000`: the 306 rows, default chords,
|
||||
activation types, input contexts, and `ConflictingMaps` relationships.
|
||||
- Installed MasterInputMaps `0x14000000` and `0x14000002`: non-bindable system
|
||||
and mouse commands.
|
||||
- `ClientUISystem::OnAction @0x00564B90`: Escape priority.
|
||||
- `CInputManager_WIN32::GetNameFromKey_Internal @0x00687800` and
|
||||
`ControlNameMapper::LoadSemantics`: displayed keyboard/mouse names.
|
||||
- `ACCmdInterp::InitializeEmoteInputActionHash @0x0058B510`: all 87 emote
|
||||
action-to-motion mappings.
|
||||
- `CPlayerSystem::SelectNext @0x0055F9A0`: selection-cycle filtering and
|
||||
opened-corpse behavior.
|
||||
|
||||
## Exact ActionMap coverage
|
||||
|
||||
| Retail input map | Rows | Consumer |
|
||||
|---|---:|---|
|
||||
| Movement | 14 | Runtime movement owner, including four postures |
|
||||
| Camera + alternate camera | 22 | Held camera input, presets, alternate-scope modifier, instant mouse look |
|
||||
| Combat + melee + missile + magic | 32 | Runtime combat attack owner and spellcasting controller, including spell slots 1–12 |
|
||||
| Emotes | 87 | Exact retail raw-motion table and Runtime `ExecuteMotion` |
|
||||
| Item selection | 26 | Selection controller/query and canonical inventory interaction state |
|
||||
| UI | 42 | Retained panels, screenshot, help/plugin result, logout, and selection commands |
|
||||
| Chat + chat-entry toggle | 7 | Retained chat entry/reply/command routes |
|
||||
| Quickslots | 28 | Toolbar use/select/create routes, including slots 10–18 |
|
||||
| Character settings | 48 | Exact `CharacterOptionId` bit toggle through Runtime |
|
||||
| **Total** | **306** | **306 distinct live identities** |
|
||||
|
||||
The low MasterInputMap entries such as bare Escape and raw mouse event
|
||||
commands are intentionally not Configure Keyboard rows in retail and are not
|
||||
counted among the 306. Unknown rows from a future DAT can still round-trip in
|
||||
the compatibility sibling store, but the installed EoR DAT has no such row and
|
||||
shows no dimmed/store-only keyboard entry.
|
||||
|
||||
## Behavior completed
|
||||
|
||||
- Defaults are an exact installed-DAT transcription, including bare
|
||||
`LeftShift`; device, modifier, activation, and scope all match.
|
||||
- Primary and alternate camera maps remain distinct rebind targets even where
|
||||
retail reuses an action id. The alternate modifier changes the active camera
|
||||
scope without aliasing saved bindings.
|
||||
- Same physical chord may fire each distinct retail action allowed by the
|
||||
ActionMap. In particular, the authored Alt+1..4 chat/UI and quickslot rows
|
||||
multicast instead of one silently replacing the other.
|
||||
- Rebind conflicts use the DAT `ConflictingMaps` table. The shared melee,
|
||||
missile, and magic key cluster remains legal; true conflicts still prompt.
|
||||
- Capture accepts keyboard keys, modifier-only bindings, and mouse buttons.
|
||||
Physical modifier self-bits are normalized, so binding LeftShift does not
|
||||
accidentally become Shift+LeftShift. Mouse button names use retail's
|
||||
`DIMOFS_BUTTON0..7` semantics table. Unsupported joystick and left/right
|
||||
mouse inputs keep the instruction dialog open and re-arm capture.
|
||||
- Setting the chord already present on the same row is a no-op. New chords use
|
||||
retail's dense two-slot insertion rule, and conflicts use priority dialogs
|
||||
with the exact installed-DAT singular/plural and non-bindable text.
|
||||
- Apply/OK, Revert, Defaults, Cancel, explicit unbinding, schema migration,
|
||||
and startup persistence are covered. Revert is enabled only while dirty;
|
||||
OK avoids rewriting an unchanged file.
|
||||
- Load File and Save As use retail's type-7 menu/type-5 text-entry dialogs,
|
||||
PFile bracket-text grammar, filename normalization, overwrite/read-only
|
||||
handling, `Documents\Asheron's Call\*.keymap` directory, selected-profile
|
||||
preference, startup load, and graceful-shutdown rewrite. The portable JSON
|
||||
file remains only as an acdream-host-command compatibility mirror.
|
||||
- Escape follows retail's priority: finish jump charge, release focused UI,
|
||||
stop movement/repeat attack, cancel target mode, clear selection, then
|
||||
toggle the authored Gameplay Options page. It never exits player mode or
|
||||
exposes the orbit/developer camera. Shift+Escape reaches the normal logout
|
||||
gate.
|
||||
- Selection cycling applies retail's containment, cloaking, radar, attackable,
|
||||
fellow, vendor, environment, combat-mode, and opened-corpse rules.
|
||||
Opened-corpse history lives for the session and retires on object deletion.
|
||||
- Screenshot, help, and plugin actions are consumed. Missing separately
|
||||
shipped retail help/plugin surfaces report an honest chat/system result
|
||||
rather than doing nothing.
|
||||
|
||||
## Automated verification
|
||||
|
||||
- App: 6,413/6,413 passed.
|
||||
- Core: 4,713/4,713 passed.
|
||||
- Runtime: 1,849/1,849 passed.
|
||||
- UI.Abstractions: 879/879 passed.
|
||||
- Installed-DAT identity/default conformance and the authored Configure
|
||||
Keyboard mount pin all 306 rows.
|
||||
|
||||
The `.keymap` codec parses the committed real retail file and round-trips all
|
||||
306 user-bindable identities, including low-bit Shift/Ctrl/Alt/Win modifiers,
|
||||
DirectInput controls, fixed Escape/system/edit/pointer maps, and the 48
|
||||
CharacterOption action names. AP-202 is retired. The connected gate could not
|
||||
be run because no local ACE endpoint was listening on UDP port 9000.
|
||||
|
||||
## Connected acceptance gate
|
||||
|
||||
Use the installed EoR DATs and the normal owner-gate pak. In Configure
|
||||
Keyboard, verify that all rows are enabled and that a key, modifier-only chord,
|
||||
and mouse button can each be rebound. Exercise representative movement,
|
||||
camera, melee/missile/magic, emote, selection, panel, chat, quickslot, and
|
||||
character-option actions. Verify conflict prompt, Cancel, Revert, Defaults,
|
||||
Apply, and OK, then restart the process and confirm the applied bindings remain.
|
||||
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`.
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
# MossTank autocombat design
|
||||
|
||||
Date: 2026-08-26
|
||||
|
||||
## Outcome
|
||||
|
||||
Ship the first VTank-class MossTank milestone: a polished in-client controller
|
||||
that performs safe automatic melee, missile or direct-spell combat while all
|
||||
policy remains in the plugin and all authoritative state/actions remain in
|
||||
Runtime.
|
||||
|
||||
## Architecture
|
||||
|
||||
```text
|
||||
Runtime canonical owners
|
||||
entity directory + object table + selection + combat + spellbook
|
||||
|
|
||||
v
|
||||
AppAutomationSurface (borrowed projection, no ownership)
|
||||
PluginCombatTarget[] + PluginCombatSnapshot + attempt commands
|
||||
|
|
||||
v
|
||||
MossTank CombatController (policy/state machine)
|
||||
scan -> score/lock -> mode -> charge/cast -> wait -> repeat
|
||||
|
|
||||
v
|
||||
retained plugin panel (bindings only)
|
||||
```
|
||||
|
||||
`AcDream.Plugin.Abstractions` stays BCL-only. New interfaces use records,
|
||||
enums, arrays/lists and primitives only. Existing interfaces gain default
|
||||
members where needed so API v1 plugins remain loadable.
|
||||
|
||||
## API additions
|
||||
|
||||
- `PluginCombatTarget`: id, name, weenie class, distance, signed relative
|
||||
angle, health-known and health fraction.
|
||||
- `PluginCombatSnapshot`: selected id, mode, charge/request state, power and
|
||||
server-pending state.
|
||||
- `ICombatAutomation`: immutable hostile snapshot plus explicit mode,
|
||||
begin/release/abort attempts.
|
||||
- `ISpellCatalog.KnownAttackSpells`: learned, direct offensive spells.
|
||||
- `IAutomationSurface.Combat`: the combat group.
|
||||
|
||||
Attempt results distinguish unavailable, invalid target, wrong mode, busy,
|
||||
transition started and sent/started. This avoids `bool` APIs whose `false`
|
||||
cannot tell a plugin whether to wait, retry, reselect or stop.
|
||||
|
||||
## Target snapshots
|
||||
|
||||
`RuntimeHostileTargetQuery` is extended with a snapshot capture method. It
|
||||
borrows the same entity directory and `ClientObjectTable` used by gameplay,
|
||||
filters with the same `CombatTargetPolicy`, and computes distance and relative
|
||||
heading using retail's `MoveToMath` helpers. Hidden, no-draw, dead and
|
||||
cell-less entities are excluded. The App surface refreshes at bounded cadence
|
||||
and publishes one immutable list reference; retained UI reads do not scan the
|
||||
world or allocate.
|
||||
|
||||
## Combat controller
|
||||
|
||||
States:
|
||||
|
||||
1. `Off`: no automation command may be emitted.
|
||||
2. `Acquire`: keep a valid lock or choose the lowest score.
|
||||
3. `Mode`: request the equipped default combat mode and wait for confirmation.
|
||||
4. `PhysicalCharge`: select, set power, press height, then wait until the
|
||||
canonical meter reaches desired power before release.
|
||||
5. `MagicCast`: select and cast the chosen known offensive spell.
|
||||
6. `Wait`: wait while physical server response, repeat state or magic busy is
|
||||
active, then reacquire/repeat.
|
||||
|
||||
Target scoring first applies ordered rules (initial slice supplies a default
|
||||
priority and an ignore-name list), then applies the configured selection
|
||||
method. Target lock keeps the current target while it remains admissible.
|
||||
|
||||
The controller never fabricates success. Health and disappearance retire a
|
||||
target; timeouts return to `Acquire`; session loss transitions to `Off` and
|
||||
aborts an in-progress physical build.
|
||||
|
||||
## UI
|
||||
|
||||
The main window becomes a dashboard rather than a single force-buff button:
|
||||
macro toggle, current target/mode, state, vitals, combat settings, buff action
|
||||
and settings navigation. Generic markup gains bound child visibility/enabled
|
||||
and color/style attributes so active controls read as active without App types
|
||||
leaking into the plugin.
|
||||
|
||||
## Verification
|
||||
|
||||
- pure controller tests for selection policies, lock, mode transition,
|
||||
charge/release, busy suppression, magic choice, disable and session loss;
|
||||
- Runtime query tests for filter, range, distance, relative angle and health;
|
||||
- App projection tests for caching and command mapping where practical;
|
||||
- markup parser tests for new generic bindings;
|
||||
- MossTank, Runtime, App and complete Release solution gates.
|
||||
|
|
@ -112,13 +112,17 @@
|
|||
AfterTargets="Build"
|
||||
Condition="'$(IsCrossTargetingBuild)' != 'true'">
|
||||
<PropertyGroup>
|
||||
<_SmokePluginSourceDir>$(MSBuildProjectDirectory)/../AcDream.Plugins.Smoke/bin/$(Configuration)/$(TargetFramework)</_SmokePluginSourceDir>
|
||||
<_SmokePluginSourceDir Condition="'$(RuntimeIdentifier)' != ''">$(_SmokePluginSourceDir)/$(RuntimeIdentifier)</_SmokePluginSourceDir>
|
||||
<_SmokePluginDestDir>$(OutputPath)plugins/AcDream.Plugins.Smoke</_SmokePluginDestDir>
|
||||
</PropertyGroup>
|
||||
<MSBuild
|
||||
Projects="$(MSBuildProjectDirectory)/../AcDream.Plugins.Smoke/AcDream.Plugins.Smoke.csproj"
|
||||
Targets="GetTargetPath"
|
||||
Properties="Configuration=$(Configuration);TargetFramework=$(TargetFramework);RuntimeIdentifier=$(RuntimeIdentifier);OutputPath=$(OutputPath)">
|
||||
<Output TaskParameter="TargetOutputs" ItemName="_SmokePluginBuildTarget" />
|
||||
</MSBuild>
|
||||
<MakeDir Directories="$(_SmokePluginDestDir)" />
|
||||
<Copy
|
||||
SourceFiles="$(_SmokePluginSourceDir)/AcDream.Plugins.Smoke.dll"
|
||||
SourceFiles="@(_SmokePluginBuildTarget)"
|
||||
DestinationFolder="$(_SmokePluginDestDir)"
|
||||
SkipUnchangedFiles="true" />
|
||||
<WriteLinesToFile
|
||||
|
|
@ -132,13 +136,17 @@
|
|||
AfterTargets="Publish"
|
||||
Condition="'$(IsCrossTargetingBuild)' != 'true'">
|
||||
<PropertyGroup>
|
||||
<_SmokePluginPublishSourceDir>$(MSBuildProjectDirectory)/../AcDream.Plugins.Smoke/bin/$(Configuration)/$(TargetFramework)</_SmokePluginPublishSourceDir>
|
||||
<_SmokePluginPublishSourceDir Condition="'$(RuntimeIdentifier)' != ''">$(_SmokePluginPublishSourceDir)/$(RuntimeIdentifier)</_SmokePluginPublishSourceDir>
|
||||
<_SmokePluginPublishDestDir>$(PublishDir)plugins/AcDream.Plugins.Smoke</_SmokePluginPublishDestDir>
|
||||
</PropertyGroup>
|
||||
<MSBuild
|
||||
Projects="$(MSBuildProjectDirectory)/../AcDream.Plugins.Smoke/AcDream.Plugins.Smoke.csproj"
|
||||
Targets="GetTargetPath"
|
||||
Properties="Configuration=$(Configuration);TargetFramework=$(TargetFramework);RuntimeIdentifier=$(RuntimeIdentifier);OutputPath=$(OutputPath)">
|
||||
<Output TaskParameter="TargetOutputs" ItemName="_SmokePluginPublishTarget" />
|
||||
</MSBuild>
|
||||
<MakeDir Directories="$(_SmokePluginPublishDestDir)" />
|
||||
<Copy
|
||||
SourceFiles="$(_SmokePluginPublishSourceDir)/AcDream.Plugins.Smoke.dll"
|
||||
SourceFiles="@(_SmokePluginPublishTarget)"
|
||||
DestinationFolder="$(_SmokePluginPublishDestDir)"
|
||||
SkipUnchangedFiles="true" />
|
||||
<WriteLinesToFile
|
||||
|
|
@ -161,13 +169,17 @@
|
|||
AfterTargets="Build"
|
||||
Condition="'$(IsCrossTargetingBuild)' != 'true'">
|
||||
<PropertyGroup>
|
||||
<_MossTankSourceDir>$(MSBuildProjectDirectory)/../AcDream.Plugins.MossTank/bin/$(Configuration)/$(TargetFramework)</_MossTankSourceDir>
|
||||
<_MossTankSourceDir Condition="'$(RuntimeIdentifier)' != ''">$(_MossTankSourceDir)/$(RuntimeIdentifier)</_MossTankSourceDir>
|
||||
<_MossTankDestDir>$(OutputPath)plugins/AcDream.Plugins.MossTank</_MossTankDestDir>
|
||||
</PropertyGroup>
|
||||
<MSBuild
|
||||
Projects="$(MSBuildProjectDirectory)/../AcDream.Plugins.MossTank/AcDream.Plugins.MossTank.csproj"
|
||||
Targets="GetTargetPath"
|
||||
Properties="Configuration=$(Configuration);TargetFramework=$(TargetFramework);RuntimeIdentifier=$(RuntimeIdentifier);OutputPath=$(OutputPath)">
|
||||
<Output TaskParameter="TargetOutputs" ItemName="_MossTankPluginBuildTarget" />
|
||||
</MSBuild>
|
||||
<MakeDir Directories="$(_MossTankDestDir)" />
|
||||
<Copy
|
||||
SourceFiles="$(_MossTankSourceDir)/AcDream.Plugins.MossTank.dll;$(_MossTankSourceDir)/mosstank.xml;$(_MossTankSourceDir)/mosstank-settings.xml"
|
||||
SourceFiles="@(_MossTankPluginBuildTarget);$(MSBuildProjectDirectory)/../AcDream.Plugins.MossTank/mosstank.xml"
|
||||
DestinationFolder="$(_MossTankDestDir)"
|
||||
SkipUnchangedFiles="true" />
|
||||
<WriteLinesToFile
|
||||
|
|
@ -181,13 +193,17 @@
|
|||
AfterTargets="Publish"
|
||||
Condition="'$(IsCrossTargetingBuild)' != 'true'">
|
||||
<PropertyGroup>
|
||||
<_MossTankPublishSourceDir>$(MSBuildProjectDirectory)/../AcDream.Plugins.MossTank/bin/$(Configuration)/$(TargetFramework)</_MossTankPublishSourceDir>
|
||||
<_MossTankPublishSourceDir Condition="'$(RuntimeIdentifier)' != ''">$(_MossTankPublishSourceDir)/$(RuntimeIdentifier)</_MossTankPublishSourceDir>
|
||||
<_MossTankPublishDestDir>$(PublishDir)plugins/AcDream.Plugins.MossTank</_MossTankPublishDestDir>
|
||||
</PropertyGroup>
|
||||
<MSBuild
|
||||
Projects="$(MSBuildProjectDirectory)/../AcDream.Plugins.MossTank/AcDream.Plugins.MossTank.csproj"
|
||||
Targets="GetTargetPath"
|
||||
Properties="Configuration=$(Configuration);TargetFramework=$(TargetFramework);RuntimeIdentifier=$(RuntimeIdentifier);OutputPath=$(OutputPath)">
|
||||
<Output TaskParameter="TargetOutputs" ItemName="_MossTankPluginPublishTarget" />
|
||||
</MSBuild>
|
||||
<MakeDir Directories="$(_MossTankPublishDestDir)" />
|
||||
<Copy
|
||||
SourceFiles="$(_MossTankPublishSourceDir)/AcDream.Plugins.MossTank.dll;$(_MossTankPublishSourceDir)/mosstank.xml;$(_MossTankPublishSourceDir)/mosstank-settings.xml"
|
||||
SourceFiles="@(_MossTankPluginPublishTarget);$(MSBuildProjectDirectory)/../AcDream.Plugins.MossTank/mosstank.xml"
|
||||
DestinationFolder="$(_MossTankPublishDestDir)"
|
||||
SkipUnchangedFiles="true" />
|
||||
<WriteLinesToFile
|
||||
|
|
|
|||
|
|
@ -66,27 +66,57 @@ internal interface ICombatFeedbackSink
|
|||
void Show(string message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Routes combat refusal text to whichever surface is bound to show it —
|
||||
/// in production, the SpewBox as <c>RetailLogTextType.ClientLocal</c>
|
||||
/// (retail: <c>ClientCombatSystem::ExecuteAttack</c> 0x0056bb70 →
|
||||
/// <c>ClientSystem::AddTextToScroll(..., 0x1A, ...)</c>).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// #434/#436: the bound target used to be the developer <c>DebugVM</c>,
|
||||
/// which Campaign V slice V11 left unreachable, so these messages were
|
||||
/// silently dropped. The session composition now owns the binding via
|
||||
/// <see cref="BindOwned"/> (same lifetime shape as
|
||||
/// <see cref="CombatAttackOperationsSlot"/>); before a session binds —
|
||||
/// and after it tears down — <see cref="Show"/> is a deliberate no-op.
|
||||
/// </remarks>
|
||||
internal sealed class CombatFeedbackSlot : ICombatFeedbackSink
|
||||
{
|
||||
private AcDream.UI.Abstractions.Panels.Debug.DebugVM? _viewModel;
|
||||
private Action<string>? _target;
|
||||
|
||||
public void Bind(AcDream.UI.Abstractions.Panels.Debug.DebugVM viewModel)
|
||||
public void Bind(Action<string> target)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(viewModel);
|
||||
if (_viewModel is not null && !ReferenceEquals(_viewModel, viewModel))
|
||||
ArgumentNullException.ThrowIfNull(target);
|
||||
if (_target is not null && !ReferenceEquals(_target, target))
|
||||
throw new InvalidOperationException(
|
||||
"Combat feedback is already bound to a developer view model.");
|
||||
_viewModel = viewModel;
|
||||
"Combat feedback is already bound to a presentation target.");
|
||||
_target = target;
|
||||
}
|
||||
|
||||
public void Unbind(AcDream.UI.Abstractions.Panels.Debug.DebugVM viewModel)
|
||||
public IDisposable BindOwned(Action<string> target)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(viewModel);
|
||||
if (ReferenceEquals(_viewModel, viewModel))
|
||||
_viewModel = null;
|
||||
ArgumentNullException.ThrowIfNull(target);
|
||||
if (_target is not null)
|
||||
throw new InvalidOperationException(
|
||||
"Combat feedback is already bound to a presentation target.");
|
||||
_target = target;
|
||||
return new Binding(this, target);
|
||||
}
|
||||
|
||||
public void Show(string message) => _viewModel?.AddToast(message);
|
||||
public void Unbind(Action<string> target)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(target);
|
||||
if (ReferenceEquals(_target, target))
|
||||
_target = null;
|
||||
}
|
||||
|
||||
public void Show(string message) => _target?.Invoke(message);
|
||||
|
||||
private sealed class Binding(CombatFeedbackSlot slot, Action<string> target)
|
||||
: IDisposable
|
||||
{
|
||||
public void Dispose() => slot.Unbind(target);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class CombatAttackOperationsSlot
|
||||
|
|
@ -209,7 +239,10 @@ internal sealed class LiveCombatAttackOperations
|
|||
|
||||
if (!CombatInputPlanner.SupportsTargetedAttack(_combat.CurrentMode))
|
||||
{
|
||||
_feedback.Show("Enter melee or missile combat first");
|
||||
// Retail is SILENT here: ClientCombatSystem::ExecuteAttack
|
||||
// (0x0056bb70) is unreachable outside melee/missile modes, so
|
||||
// no user-facing text exists for this case — only the no-target
|
||||
// branch below speaks (#436).
|
||||
Console.WriteLine(
|
||||
"combat: attack ignored; not in melee/missile combat mode");
|
||||
return false;
|
||||
|
|
@ -217,7 +250,9 @@ internal sealed class LiveCombatAttackOperations
|
|||
|
||||
if (_targets.GetSelectedOrClosestCombatTarget(_settings.AutoTarget) is null)
|
||||
{
|
||||
_feedback.Show("No monster target");
|
||||
// Retail: ExecuteAttack's edi==0 branch (0x0056bc05) →
|
||||
// AddTextToScroll(0x1A) — the ClientLocal SpewBox channel.
|
||||
_feedback.Show(AcDream.Core.Chat.ClientTextRefusals.MustSelectCombatTarget);
|
||||
Console.WriteLine("combat: attack ignored; no creature target found");
|
||||
return false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,6 +51,9 @@ internal sealed record ContentEffectsAudioResult(
|
|||
internal sealed record ContentEffectsAudioDependencies(
|
||||
string DatDirectory,
|
||||
string PreparedAssetPath,
|
||||
string? PreparedAssetOverlayPath,
|
||||
uint? PreparedAssetBaseRecipeVersion,
|
||||
uint? PreparedAssetEffectiveRecipeVersion,
|
||||
ResidencyBudgetOptions ResidencyBudgets,
|
||||
PhysicsDataCache PhysicsDataCache,
|
||||
bool DumpMotionEnabled,
|
||||
|
|
@ -99,6 +102,9 @@ internal interface IContentEffectsAudioCompositionFactory
|
|||
IDatReaderWriter OpenDatCollection(string datDirectory);
|
||||
IPreparedAssetSource OpenPreparedAssetSource(
|
||||
string path,
|
||||
string? overlayPath,
|
||||
uint? baseRecipeVersion,
|
||||
uint? effectiveRecipeVersion,
|
||||
IDatReaderWriter dats,
|
||||
Action<string> diagnostic);
|
||||
MagicCatalog LoadMagicCatalog(IDatReaderWriter dats);
|
||||
|
|
@ -169,9 +175,54 @@ internal sealed class RetailContentEffectsAudioCompositionFactory
|
|||
|
||||
public IPreparedAssetSource OpenPreparedAssetSource(
|
||||
string path,
|
||||
string? overlayPath,
|
||||
uint? baseRecipeVersion,
|
||||
uint? effectiveRecipeVersion,
|
||||
IDatReaderWriter dats,
|
||||
Action<string> diagnostic) =>
|
||||
new PakPreparedAssetSource(path, dats, diagnostic);
|
||||
Action<string> diagnostic)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(overlayPath))
|
||||
{
|
||||
return new PakPreparedAssetSource(path, dats, diagnostic);
|
||||
}
|
||||
|
||||
if (baseRecipeVersion is not > 0
|
||||
|| effectiveRecipeVersion is not > 0)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
"Layered prepared content is missing its recipe identities.");
|
||||
}
|
||||
|
||||
if (effectiveRecipeVersion
|
||||
!= AcDream.Content.Pak.PakFormat.CurrentBakeToolVersion)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"Prepared content recipe {effectiveRecipeVersion} does not "
|
||||
+ $"match client recipe "
|
||||
+ $"{AcDream.Content.Pak.PakFormat.CurrentBakeToolVersion}.");
|
||||
}
|
||||
|
||||
PakPreparedAssetSource? baseSource = null;
|
||||
PakPreparedAssetSource? overlaySource = null;
|
||||
try
|
||||
{
|
||||
baseSource = new PakPreparedAssetSource(
|
||||
path,
|
||||
PreparedAssetCatalogIdentity.From(dats, baseRecipeVersion.Value),
|
||||
diagnostic);
|
||||
overlaySource = new PakPreparedAssetSource(
|
||||
overlayPath,
|
||||
PreparedAssetCatalogIdentity.From(dats, effectiveRecipeVersion.Value),
|
||||
diagnostic);
|
||||
return new LayeredPreparedAssetSource(baseSource, overlaySource);
|
||||
}
|
||||
catch
|
||||
{
|
||||
overlaySource?.Dispose();
|
||||
baseSource?.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public MagicCatalog LoadMagicCatalog(IDatReaderWriter dats) =>
|
||||
MagicCatalog.Load(dats);
|
||||
|
|
@ -370,6 +421,9 @@ internal sealed class ContentEffectsAudioCompositionPhase :
|
|||
"prepared asset source",
|
||||
() => _factory.OpenPreparedAssetSource(
|
||||
_dependencies.PreparedAssetPath,
|
||||
_dependencies.PreparedAssetOverlayPath,
|
||||
_dependencies.PreparedAssetBaseRecipeVersion,
|
||||
_dependencies.PreparedAssetEffectiveRecipeVersion,
|
||||
dats,
|
||||
_dependencies.Error),
|
||||
static value => value.Dispose()).Publish(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ using AcDream.Core.Combat;
|
|||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Core.Player;
|
||||
using AcDream.Core.Properties;
|
||||
using AcDream.Core.Selection;
|
||||
using AcDream.Core.Spells;
|
||||
using AcDream.Runtime;
|
||||
|
|
@ -26,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;
|
||||
|
||||
|
|
@ -95,7 +95,9 @@ internal sealed record InteractionRetainedUiDependencies(
|
|||
Func<AcDream.Core.World.DerethDateTime.Calendar> CurrentCalendar,
|
||||
AcDream.App.Rendering.Packs.RenderPackCatalogSource? RenderPackCatalog = null,
|
||||
Func<AcDream.App.Rendering.Packs.RenderPackDiagnosticsSnapshot>?
|
||||
RenderPackDiagnostics = null)
|
||||
RenderPackDiagnostics = null,
|
||||
string? ScreenshotsDirectory = null,
|
||||
AppAutomationSurface? Automation = null)
|
||||
{
|
||||
public RuntimeActionState Actions => Runtime.ActionOwner;
|
||||
|
||||
|
|
@ -387,9 +389,15 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
|
|||
container,
|
||||
placement,
|
||||
amount),
|
||||
sendStackableMerge: (source, target, amount) =>
|
||||
session.CurrentSession?.SendStackableMerge(source, target, amount),
|
||||
requestExternalContainer: guid =>
|
||||
{
|
||||
d.Inventory.ExternalContainers.RequestOpen(guid);
|
||||
ClientObject? container = d.Inventory.Objects.Get(guid);
|
||||
bool isCorpse = container is not null
|
||||
&& ((PublicWeenieFlags)(container.PublicWeenieBitfield ?? 0u)
|
||||
& PublicWeenieFlags.Corpse) != 0;
|
||||
d.Inventory.ExternalContainers.RequestOpen(guid, isCorpse);
|
||||
},
|
||||
requestUse: selection.RequestUse,
|
||||
// Slice 6.3: ItemInteractionController.TryBuy owns the
|
||||
|
|
@ -421,6 +429,13 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
|
|||
return false;
|
||||
activeSession.SendSell(vendorGuid, items);
|
||||
return true;
|
||||
},
|
||||
sendSalvage: (toolGuid, itemGuids) =>
|
||||
{
|
||||
if (session.CurrentSession is not { } activeSession || !session.IsInWorld)
|
||||
return false;
|
||||
activeSession.SendSalvage(toolGuid, itemGuids);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -527,6 +542,19 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
|
|||
var cursorManager = new RetailCursorManager(d.Dats, d.DatLock);
|
||||
checkpoint(InteractionRetainedUiCompositionPoint.CursorAssetsCreated);
|
||||
|
||||
// Campaign CT slice CT3 (2026-08-24): the Titles page's DAT
|
||||
// id -> display-string chain (CT2). Constructed once — its own
|
||||
// constructor does no DAT I/O (only .Resolve reads touch the
|
||||
// dats), matching the characterCreationStrings precedent below.
|
||||
// CT4 (2026-08-24) also feeds this resolver's display-title text
|
||||
// into the character panel's own heritage line (CharacterSheet.Title).
|
||||
var characterTitleResolver = new CharacterTitleResolver(d.Dats);
|
||||
// CT4: the header identity block's PK-status line (StringTable
|
||||
// 0x23000001, ID_StatManagement_Header_PKStatus_* keys — the same
|
||||
// compute_str_hash mechanism ChatWindowController's chatStrings
|
||||
// delegate already uses). One instance, same DatLock discipline
|
||||
// as characterTitleResolver above.
|
||||
var characterUiStrings = new DatStringResolver(d.Dats);
|
||||
var characterSheet = new CharacterSheetProvider(
|
||||
d.Inventory.Objects,
|
||||
d.Character.LocalPlayer,
|
||||
|
|
@ -553,7 +581,17 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
|
|||
late.GameRuntime.Advance(
|
||||
RuntimeAdvancementKind.TrainSkill,
|
||||
statId,
|
||||
credits));
|
||||
credits),
|
||||
titles: d.Character.Titles,
|
||||
resolveDisplayTitle: titleId =>
|
||||
{
|
||||
lock (d.DatLock) return characterTitleResolver.Resolve(titleId);
|
||||
},
|
||||
resolveUiString: key =>
|
||||
{
|
||||
lock (d.DatLock)
|
||||
return characterUiStrings.Resolve(0x23000001u, DatStringResolver.ComputeHash(key));
|
||||
});
|
||||
checkpoint(InteractionRetainedUiCompositionPoint.CharacterSheetCreated);
|
||||
|
||||
uint MagicSkillLevel(MagicSchool school)
|
||||
|
|
@ -639,16 +677,20 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
|
|||
CharacterKey: () => d.Settings.ActiveToonKey,
|
||||
ScreenSize: () => (d.Window.Size.X, d.Window.Size.Y));
|
||||
void ProbeLog(string message) => d.Log("[UI-PROBE] " + message);
|
||||
FrameScreenshotController? screenshots = null;
|
||||
if (d.Options.UiProbeEnabled
|
||||
&& d.Options.AutomationArtifactDirectory is { } artifactDirectory)
|
||||
{
|
||||
screenshots = new FrameScreenshotController(
|
||||
d.BackbufferReader,
|
||||
Path.Combine(artifactDirectory, "screenshots"),
|
||||
ProbeLog,
|
||||
d.RenderPackDiagnostics);
|
||||
}
|
||||
string screenshotDirectory =
|
||||
d.Options.UiProbeEnabled
|
||||
&& d.Options.AutomationArtifactDirectory is { } artifactDirectory
|
||||
? Path.Combine(artifactDirectory, "screenshots")
|
||||
: !string.IsNullOrWhiteSpace(d.ScreenshotsDirectory)
|
||||
? d.ScreenshotsDirectory
|
||||
: Path.Combine(
|
||||
Path.GetDirectoryName(d.KeyBindingsFilePath)!,
|
||||
"screenshots");
|
||||
var screenshots = new FrameScreenshotController(
|
||||
d.BackbufferReader,
|
||||
screenshotDirectory,
|
||||
ProbeLog,
|
||||
d.RenderPackDiagnostics);
|
||||
checkpoint(InteractionRetainedUiCompositionPoint.UiProbeCreated);
|
||||
|
||||
var assets = new RetailUiAssets(
|
||||
|
|
@ -673,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;
|
||||
|
|
@ -822,7 +857,11 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
|
|||
&& d.Inventory.Objects.Get(guid) is { } vendorCandidate
|
||||
&& vendorCandidate.ContainerId == d.Inventory.Vendor.VendorId
|
||||
&& VendorSplitPolicy.IsSplitExempt(vendorCandidate.Type)),
|
||||
Character: new CharacterRuntimeBindings(characterSheet),
|
||||
Character: new CharacterRuntimeBindings(
|
||||
characterSheet,
|
||||
d.Character.Titles,
|
||||
characterTitleResolver,
|
||||
SendSetTitle: titleId => late.GameRuntime.SetTitle(titleId)),
|
||||
Inventory: new InventoryRuntimeBindings(
|
||||
d.Inventory.Objects,
|
||||
() => d.PlayerIdentity.ServerGuid,
|
||||
|
|
@ -901,7 +940,14 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
|
|||
item,
|
||||
inscription),
|
||||
text =>
|
||||
d.Communication.AddText(text, RetailLogTextType.ClientLocal)),
|
||||
d.Communication.AddText(text, RetailLogTextType.ClientLocal),
|
||||
// AS4 (gap G6): the SAME LocalPlayerState instance
|
||||
// characterSheet was built from above — no new state
|
||||
// path, just another read of its PlayerDescription-
|
||||
// backed property snapshot.
|
||||
LocalFactionBits: () =>
|
||||
d.Character.LocalPlayer.Properties.GetInt(
|
||||
(uint)PropertyInt.Faction1Bits)),
|
||||
Options: new OptionsRuntimeBindings(
|
||||
CommandBus: () => late.Session.Commands,
|
||||
// Tri-state per gmGamePlayUI::UseTime @0x004EA3A0's exact
|
||||
|
|
@ -1068,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
|
||||
|
|
@ -1122,11 +1170,10 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
|
|||
late.GameRuntime.CharacterSelectionConfirmDelete,
|
||||
late.GameRuntime.CharacterSelectionRestore,
|
||||
late.GameRuntime.CharacterSelectionCancel,
|
||||
// Campaign LA gate round 2 finding 1: the SAME
|
||||
// window-close path GameplayInputCommandController's
|
||||
// Escape fallback uses (IGameplayWindowCommands.Close
|
||||
// /GameplayWindowCommands wrap this same d.Window.Close
|
||||
// delegate) — no separate exit path.
|
||||
// Campaign LA gate round 2 finding 1: the character
|
||||
// selection screen's Exit button uses the ordinary host
|
||||
// close path. Gameplay Escape is independent: retail
|
||||
// clears selection or toggles the Gameplay Options page.
|
||||
d.Window.Close),
|
||||
// Campaign CC slice CC4: same late-bound generation-capturing
|
||||
// seam as CharacterSelection above. RequestExit here is a
|
||||
|
|
@ -1168,7 +1215,27 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
|
|||
RandomizeAppearance: late.GameRuntime.CharacterCreationRandomizeAppearance,
|
||||
RandomizeClothing: late.GameRuntime.CharacterCreationRandomizeClothing,
|
||||
GetSkillScore: chargenSkillScoreResolver.Resolve,
|
||||
OpenOnStart: d.Options.OpenCharacterCreationOnStart));
|
||||
OpenOnStart: d.Options.OpenCharacterCreationOnStart),
|
||||
CaptureScreenshot: () =>
|
||||
{
|
||||
if (screenshots.TryRequestRetailScreenshot(
|
||||
out string path,
|
||||
out string error))
|
||||
{
|
||||
d.Communication.AddText(
|
||||
$"Screenshot saved to {path}",
|
||||
RetailLogTextType.ClientLocal);
|
||||
}
|
||||
else
|
||||
{
|
||||
d.Communication.AddText(
|
||||
$"Screenshot failed: {error}",
|
||||
RetailLogTextType.ClientLocal);
|
||||
}
|
||||
},
|
||||
ProjectileDebugSamples: d.Automation is null
|
||||
? null
|
||||
: d.Automation.CaptureProjectileDebugSamples);
|
||||
RetailUiRuntime runtime = lease.Mount(
|
||||
() => RetailUiRuntime.CreateUninitialized(bindings));
|
||||
checkpoint(InteractionRetainedUiCompositionPoint.UiRuntimeMounted);
|
||||
|
|
|
|||
|
|
@ -150,6 +150,15 @@ internal sealed class DeferredGameRuntimeStateCommands
|
|||
generation,
|
||||
new RuntimeAdvancementCommand(kind, statId, cost)));
|
||||
|
||||
/// <summary>Campaign CT slice CT3 (2026-08-24): retail <c>TitleSet
|
||||
/// (0x002C)</c> — same generation-capturing late seam as every other
|
||||
/// method here. <see cref="AcDream.App.UI.Layout.CharacterTitlesController"/>
|
||||
/// is the only caller.</summary>
|
||||
public RuntimeCommandResult SetTitle(uint titleId) =>
|
||||
Invoke((commands, generation) => commands.Character.SetTitle(
|
||||
generation,
|
||||
titleId));
|
||||
|
||||
// Campaign LA slice LA8: the retained character-management screen uses
|
||||
// the same generation-capturing late seam as every gameplay panel. The
|
||||
// screen never receives GameRuntime or WorldSession and cannot retain a
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ internal sealed record LivePresentationDependencies(
|
|||
CellVisibility CellVisibility,
|
||||
LiveWorldOriginState WorldOrigin,
|
||||
LocalPlayerIdentityState PlayerIdentity,
|
||||
ChaseCameraInputState ChaseCameraInput,
|
||||
PointerPositionState PointerPosition,
|
||||
PlayerApproachCompletionState PlayerApproachCompletions,
|
||||
GameRenderResourceLifetime RenderResourceLifetime,
|
||||
|
|
@ -808,7 +809,12 @@ internal sealed class LivePresentationCompositionPhase
|
|||
d.RetailAlphaQueue,
|
||||
alphaScratchBudgets.DispatcherBytes,
|
||||
foundation.TerrainAtlas?.BuildingDetailTexture ?? default,
|
||||
() => d.Settings.DisplayPreview.BuildingDetailTextures),
|
||||
() => d.Settings.DisplayPreview.BuildingDetailTextures,
|
||||
serverGuid => serverGuid != 0u
|
||||
&& serverGuid == d.PlayerIdentity.ServerGuid
|
||||
? d.ChaseCameraInput.Retail?.PlayerTranslucency
|
||||
?? (d.ChaseCameraInput.Legacy?.IsInHead == true ? 1f : 0f)
|
||||
: 0f),
|
||||
static value => value.Dispose());
|
||||
var selectionQuery = new WorldSelectionQuery(
|
||||
liveEntities,
|
||||
|
|
@ -845,7 +851,11 @@ internal sealed class LivePresentationCompositionPhase
|
|||
localEntityId =>
|
||||
d.EffectPoses.TryGetRootPose(localEntityId, out Matrix4x4 childRoot)
|
||||
? childRoot
|
||||
: null);
|
||||
: null,
|
||||
hasOpenedCorpse:
|
||||
d.Runtime.InventoryOwner.ExternalContainers.HasCorpseBeenOpened,
|
||||
combatMode: () => d.Runtime.ActionOwner.Combat.CurrentMode,
|
||||
isFellow: guid => d.Runtime.Fellowship.TryGetMember(guid, out _));
|
||||
var radarSnapshotProvider = new RadarSnapshotProvider(
|
||||
d.EntityObjects.Objects,
|
||||
liveEntities,
|
||||
|
|
@ -876,7 +886,12 @@ internal sealed class LivePresentationCompositionPhase
|
|||
() => d.PlayerController.Controller,
|
||||
d.PlayerApproachCompletions),
|
||||
d.Toast,
|
||||
d.PlayerApproachCompletions);
|
||||
d.PlayerApproachCompletions,
|
||||
splitStack: guid =>
|
||||
interaction.RetainedUi?.Runtime.SelectedObjectController?
|
||||
.FocusSplitStackEntry(guid) ?? false,
|
||||
fellowshipMembers: () =>
|
||||
d.Runtime.Fellowship.GetMembers().Select(static member => member.Guid));
|
||||
selectionInteractionSource.Bind(selectionInteractions);
|
||||
bindings.Adopt(
|
||||
"world selection",
|
||||
|
|
|
|||
|
|
@ -83,6 +83,7 @@ internal sealed record SessionPlayerDependencies(
|
|||
CombatFeedbackSlot CombatFeedback,
|
||||
TransferableResourceSlot<PortalTunnelPresentation> PortalTunnelFallback,
|
||||
Action<string> Log,
|
||||
Func<string, bool>? TryHandlePluginCommand,
|
||||
/// <summary>Campaign LA slice LA1: the shared per-session status-event
|
||||
/// writer, no-op when <see cref="RuntimeOptions.StatusFilePath"/> was
|
||||
/// not configured.</summary>
|
||||
|
|
@ -112,6 +113,7 @@ internal sealed record SessionPlayerResult(
|
|||
DatSpawnClaimHydrationClassifier SpawnClaimHydration,
|
||||
LiveSessionController LiveSession,
|
||||
LiveEntityHydrationController Hydration,
|
||||
LiveEntityDeletionController Deletion,
|
||||
LiveEntityNetworkUpdateController NetworkUpdates,
|
||||
LiveEntityLivenessController Liveness,
|
||||
LiveEntitySessionController SessionEvents,
|
||||
|
|
@ -357,7 +359,8 @@ internal sealed class SessionPlayerCompositionPhase
|
|||
// LiveSessionCommandSurface has no dependencies of its own, so
|
||||
// hoisting its construction is inert; the later site now reuses
|
||||
// this instance instead of constructing a second one.
|
||||
var liveSessionCommands = new LiveSessionCommandSurface();
|
||||
var liveSessionCommands = new LiveSessionCommandSurface(
|
||||
d.TryHandlePluginCommand);
|
||||
var settingsTargets = new RuntimeSettingsTargets(
|
||||
new SilkRuntimeDisplayWindowTarget(d.Window),
|
||||
live.DrawDispatcher,
|
||||
|
|
@ -415,6 +418,23 @@ internal sealed class SessionPlayerCompositionPhase
|
|||
? revealMeshes.SetDestinationRevealUploadPriority
|
||||
: static _ => { },
|
||||
foundation.TextureCache.SetDestinationRevealUploadPriority);
|
||||
IRenderFrameResourceDiagnosticsSource? revealResourceDiagnostics =
|
||||
StreamingDiagnostics.ProbeRevealTiming
|
||||
? new RuntimeRenderFrameResourceDiagnosticsSource(
|
||||
particles: null,
|
||||
particleBindings: null,
|
||||
worldDispatcher: revealDispatcher,
|
||||
environmentCells: null,
|
||||
particleRenderer: null,
|
||||
uiTextRenderer: null,
|
||||
portalDepthMask: null,
|
||||
clipFrame: null,
|
||||
terrain: null,
|
||||
lighting: null,
|
||||
meshes: foundation.MeshAdapter,
|
||||
textures: foundation.TextureCache,
|
||||
preparedAssets: content.PreparedAssets)
|
||||
: null;
|
||||
var worldReveal = new WorldRevealCoordinator(
|
||||
live.WorldTransit,
|
||||
// #280: read the radii LIVE from the streaming controller rather
|
||||
|
|
@ -450,7 +470,8 @@ internal sealed class SessionPlayerCompositionPhase
|
|||
worldQuiescence,
|
||||
streaming,
|
||||
revealRenderResources,
|
||||
() => live.WorldState.LoadedLandblockCount);
|
||||
() => live.WorldState.LoadedLandblockCount,
|
||||
revealResourceDiagnostics);
|
||||
Fault(SessionPlayerCompositionPoint.WorldRevealCreated);
|
||||
|
||||
return CompleteSessionPlayer(
|
||||
|
|
@ -562,8 +583,7 @@ internal sealed class SessionPlayerCompositionPhase
|
|||
d.EntityObjects,
|
||||
teardown,
|
||||
d.PlayerIdentity,
|
||||
dormantLiveEntities,
|
||||
d.Options.DumpLiveSpawns ? d.Log : null);
|
||||
dormantLiveEntities);
|
||||
// 2026-08-08 vendor-approach root cause: the local player's
|
||||
// publication-chain host resolves moveto/sticky targets through
|
||||
// RuntimePhysicsState.ResolveObjectTableHost. Bind the graphical
|
||||
|
|
@ -709,7 +729,6 @@ internal sealed class SessionPlayerCompositionPhase
|
|||
d.PlayerIdentity,
|
||||
deletion,
|
||||
dormantLiveEntities,
|
||||
d.Options.DumpLiveSpawns ? d.Log : null,
|
||||
firstEntryDrive,
|
||||
acceptedPositionDrive);
|
||||
bindings.Adopt(
|
||||
|
|
@ -723,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",
|
||||
|
|
@ -811,6 +831,16 @@ internal sealed class SessionPlayerCompositionPhase
|
|||
liveSessionSource,
|
||||
liveSessionSource,
|
||||
d.CombatFeedback)));
|
||||
// #436: combat refusal text goes to the SpewBox as ClientLocal —
|
||||
// retail's ClientCombatSystem::ExecuteAttack (0x0056bb70) routes
|
||||
// "You must select a valid combat target before attacking" through
|
||||
// AddTextToScroll(0x1A). Session-owned so a torn-down session's
|
||||
// slot goes back to its silent unbound state.
|
||||
bindings.Adopt(
|
||||
"combat feedback",
|
||||
d.CombatFeedback.BindOwned(
|
||||
text => d.Communication.AddText(
|
||||
text, RetailLogTextType.ClientLocal)));
|
||||
Fault(SessionPlayerCompositionPoint.CombatOperationsBound);
|
||||
|
||||
MouseLookController? mouseLook =
|
||||
|
|
@ -1143,6 +1173,7 @@ internal sealed class SessionPlayerCompositionPhase
|
|||
live.SelectionInteractions),
|
||||
new LiveSessionWorldRuntime(
|
||||
content.Dats,
|
||||
d.DatLock,
|
||||
content.Audio?.Engine is { } sessionAudioEngine
|
||||
? new AcDream.App.Audio.WorldAudioSessionGate(
|
||||
sessionAudioEngine,
|
||||
|
|
@ -1253,14 +1284,10 @@ internal sealed class SessionPlayerCompositionPhase
|
|||
new RetainedGameplayWindowCommands(
|
||||
interaction.RetainedUi?.Runtime),
|
||||
runtimeDiagnostics,
|
||||
new PlayerModeGameplayCommands(
|
||||
d.PlayerMode,
|
||||
playerMode),
|
||||
new PlayerModeGameplayCommands(playerMode),
|
||||
new ItemTargetModeCommands(interaction.ItemInteraction),
|
||||
new GameplayCameraModeCommands(host.CameraController),
|
||||
gameRuntime,
|
||||
gameRuntime.Combat,
|
||||
new GameplayWindowCommands(d.Window.Close),
|
||||
toggleAudioMute: content.Audio?.Engine is { } audioEngine
|
||||
? () =>
|
||||
{
|
||||
|
|
@ -1279,6 +1306,7 @@ internal sealed class SessionPlayerCompositionPhase
|
|||
gameRuntime,
|
||||
gameRuntime.Selection,
|
||||
gameRuntime.MovementCommands,
|
||||
gameRuntime.CharacterCommands,
|
||||
commands);
|
||||
GameplayInputActionRouter gameplayActions =
|
||||
GameplayInputActionRouter.Create(
|
||||
|
|
@ -1308,6 +1336,7 @@ internal sealed class SessionPlayerCompositionPhase
|
|||
spawnClaimClassifier,
|
||||
liveSession,
|
||||
hydration,
|
||||
deletion,
|
||||
networkUpdates,
|
||||
liveness,
|
||||
sessionEvents,
|
||||
|
|
|
|||
|
|
@ -18,8 +18,8 @@ namespace AcDream.App.Composition;
|
|||
/// <c>UiHost</c>/<c>UiRoot</c> tree — D1) instead of a new
|
||||
/// <c>IPanelRenderer</c> implementation, and its OP9 closeout retired the
|
||||
/// unrendered ImGui-era SettingsPanel/SettingsVM outright. Keybind remapping
|
||||
/// is Campaign OP slice OP8's Configure Keyboard screen, persisting to
|
||||
/// keybinds.json (not retail's <c>.keymap</c> format — register row AP-202).
|
||||
/// is Campaign OP slice OP8's Configure Keyboard screen, persisting retail
|
||||
/// <c>*.keymap</c> profiles with <c>keybinds.json</c> as the host-command mirror.
|
||||
/// </summary>
|
||||
internal sealed record SettingsDevToolsResult(
|
||||
AcDream.UI.Abstractions.Settings.QualitySettings ResolvedQuality)
|
||||
|
|
|
|||
|
|
@ -71,6 +71,12 @@ internal sealed class SessionContentDescriptor
|
|||
|
||||
[JsonRequired]
|
||||
public string PreparedAssetPath { get; init; } = string.Empty;
|
||||
|
||||
public string? PreparedAssetOverlayPath { get; init; }
|
||||
|
||||
public uint? PreparedAssetBaseRecipeVersion { get; init; }
|
||||
|
||||
public uint? PreparedAssetEffectiveRecipeVersion { get; init; }
|
||||
}
|
||||
|
||||
internal sealed record SessionDescriptor
|
||||
|
|
|
|||
|
|
@ -83,6 +83,18 @@ internal static class SessionConfigurationLoader
|
|||
throw new SessionConfigurationException(
|
||||
"process.content requires non-empty datDirectory and preparedAssetPath.");
|
||||
}
|
||||
|
||||
bool hasOverlay = !string.IsNullOrWhiteSpace(
|
||||
content.PreparedAssetOverlayPath);
|
||||
bool hasBaseRecipe = content.PreparedAssetBaseRecipeVersion is > 0;
|
||||
bool hasEffectiveRecipe =
|
||||
content.PreparedAssetEffectiveRecipeVersion is > 0;
|
||||
if (hasOverlay != hasBaseRecipe || hasOverlay != hasEffectiveRecipe)
|
||||
{
|
||||
throw new SessionConfigurationException(
|
||||
"process.content overlay path, base recipe, and effective recipe "
|
||||
+ "must be supplied together.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateSession(SessionDescriptor session)
|
||||
|
|
|
|||
|
|
@ -63,6 +63,36 @@ internal sealed class FrameScreenshotController
|
|||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Queues the first free retail-style screenshot name. Retail scans
|
||||
/// <c>ScreenShot00000.jpg</c> through <c>ScreenShot99999.jpg</c> beside
|
||||
/// its preferences file; acdream keeps the exact stem/numbering while
|
||||
/// writing lossless PNGs in the portable screenshots directory.
|
||||
/// </summary>
|
||||
public bool TryRequestRetailScreenshot(out string path, out string error)
|
||||
{
|
||||
for (int index = 0; index < 100_000; index++)
|
||||
{
|
||||
string name = $"ScreenShot{index:D5}";
|
||||
string candidate = Path.Combine(_directory, name + ".png");
|
||||
if (File.Exists(candidate) || _status.ContainsKey(name))
|
||||
continue;
|
||||
|
||||
if (TryRequest(name, out error))
|
||||
{
|
||||
path = candidate;
|
||||
return true;
|
||||
}
|
||||
|
||||
path = string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
path = string.Empty;
|
||||
error = "all retail screenshot names ScreenShot00000 through ScreenShot99999 are in use";
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool IsComplete(string name) =>
|
||||
_status.TryGetValue(name, out CaptureStatus? status)
|
||||
&& status.State == CaptureState.Complete;
|
||||
|
|
|
|||
|
|
@ -316,6 +316,88 @@ internal sealed class CameraPointerInputController
|
|||
}
|
||||
}
|
||||
|
||||
public bool HandleCameraAction(
|
||||
InputAction action,
|
||||
ActivationType activation)
|
||||
{
|
||||
if (activation != ActivationType.Press
|
||||
|| !_playerMode.IsPlayerMode
|
||||
|| !_camera.IsChaseMode)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
bool handled = action is
|
||||
InputAction.CameraViewDefault
|
||||
or InputAction.CameraAlternateViewDefault
|
||||
or InputAction.CameraViewFirstPerson
|
||||
or InputAction.CameraAlternateViewFirstPerson
|
||||
or InputAction.CameraViewLookDown
|
||||
or InputAction.CameraAlternateViewLookDown
|
||||
or InputAction.CameraViewMapMode
|
||||
or InputAction.CameraAlternateViewMapMode;
|
||||
if (!handled)
|
||||
return false;
|
||||
|
||||
ApplyCameraPreset(_chase.Retail, action);
|
||||
ApplyCameraPreset(_chase.Legacy, action);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void ApplyCameraPreset(
|
||||
RetailChaseCamera? camera,
|
||||
InputAction action)
|
||||
{
|
||||
if (camera is null)
|
||||
return;
|
||||
switch (action)
|
||||
{
|
||||
case InputAction.CameraViewDefault:
|
||||
case InputAction.CameraAlternateViewDefault:
|
||||
camera.SetRetailDefaultView();
|
||||
break;
|
||||
case InputAction.CameraViewFirstPerson:
|
||||
case InputAction.CameraAlternateViewFirstPerson:
|
||||
camera.SetRetailFirstPersonView();
|
||||
break;
|
||||
case InputAction.CameraViewLookDown:
|
||||
case InputAction.CameraAlternateViewLookDown:
|
||||
camera.ToggleRetailLookDownView();
|
||||
break;
|
||||
case InputAction.CameraViewMapMode:
|
||||
case InputAction.CameraAlternateViewMapMode:
|
||||
camera.ToggleRetailMapModeView();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static void ApplyCameraPreset(
|
||||
ChaseCamera? camera,
|
||||
InputAction action)
|
||||
{
|
||||
if (camera is null)
|
||||
return;
|
||||
switch (action)
|
||||
{
|
||||
case InputAction.CameraViewDefault:
|
||||
case InputAction.CameraAlternateViewDefault:
|
||||
camera.SetRetailDefaultView();
|
||||
break;
|
||||
case InputAction.CameraViewFirstPerson:
|
||||
case InputAction.CameraAlternateViewFirstPerson:
|
||||
camera.SetRetailFirstPersonView();
|
||||
break;
|
||||
case InputAction.CameraViewLookDown:
|
||||
case InputAction.CameraAlternateViewLookDown:
|
||||
camera.ToggleRetailLookDownView();
|
||||
break;
|
||||
case InputAction.CameraViewMapMode:
|
||||
case InputAction.CameraAlternateViewMapMode:
|
||||
camera.ToggleRetailMapModeView();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
public string AdjustSensitivity(float factor)
|
||||
{
|
||||
string mode;
|
||||
|
|
|
|||
|
|
@ -15,7 +15,9 @@ internal readonly record struct ChaseCameraAdjustmentInput(
|
|||
bool ZoomIn,
|
||||
bool ZoomOut,
|
||||
bool Raise,
|
||||
bool Lower);
|
||||
bool Lower,
|
||||
bool RotateLeft,
|
||||
bool RotateRight);
|
||||
|
||||
internal interface ICameraFrameInputSource
|
||||
{
|
||||
|
|
@ -71,9 +73,21 @@ internal sealed class DispatcherCameraInputSource : ICameraFrameInputSource
|
|||
return default;
|
||||
|
||||
return new ChaseCameraAdjustmentInput(
|
||||
dispatcher.IsActionHeld(InputAction.CameraZoomIn),
|
||||
dispatcher.IsActionHeld(InputAction.CameraZoomOut),
|
||||
dispatcher.IsActionHeld(InputAction.CameraRaise),
|
||||
dispatcher.IsActionHeld(InputAction.CameraLower));
|
||||
dispatcher.IsActionHeld(InputAction.CameraZoomIn)
|
||||
|| dispatcher.IsActionHeld(InputAction.CameraMoveToward)
|
||||
|| dispatcher.IsActionHeld(InputAction.CameraAlternateMoveToward),
|
||||
dispatcher.IsActionHeld(InputAction.CameraZoomOut)
|
||||
|| dispatcher.IsActionHeld(InputAction.CameraMoveAway)
|
||||
|| dispatcher.IsActionHeld(InputAction.CameraAlternateMoveAway),
|
||||
dispatcher.IsActionHeld(InputAction.CameraRaise)
|
||||
|| dispatcher.IsActionHeld(InputAction.CameraRotateUp)
|
||||
|| dispatcher.IsActionHeld(InputAction.CameraAlternateRotateUp),
|
||||
dispatcher.IsActionHeld(InputAction.CameraLower)
|
||||
|| dispatcher.IsActionHeld(InputAction.CameraRotateDown)
|
||||
|| dispatcher.IsActionHeld(InputAction.CameraAlternateRotateDown),
|
||||
dispatcher.IsActionHeld(InputAction.CameraRotateLeft)
|
||||
|| dispatcher.IsActionHeld(InputAction.CameraAlternateRotateLeft),
|
||||
dispatcher.IsActionHeld(InputAction.CameraRotateRight)
|
||||
|| dispatcher.IsActionHeld(InputAction.CameraAlternateRotateRight));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ internal sealed class DispatcherMovementInputSource : IMovementInputSource
|
|||
return default;
|
||||
|
||||
if (_movement.HasCommandInput)
|
||||
return _movement.CommandInput;
|
||||
return _movement.CommandInput with { IsPersistentCommand = true };
|
||||
|
||||
if (_dispatcher is not { } dispatcher)
|
||||
return default;
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ using AcDream.App.Rendering;
|
|||
using AcDream.App.UI;
|
||||
using AcDream.Core.Combat;
|
||||
using AcDream.Runtime;
|
||||
using AcDream.Runtime.Gameplay;
|
||||
using AcDream.UI.Abstractions.Input;
|
||||
|
||||
namespace AcDream.App.Input;
|
||||
|
|
@ -14,6 +15,8 @@ internal interface IGameplayInputActionSurface
|
|||
void RemoveFired(Action<InputAction, ActivationType> callback);
|
||||
|
||||
void SetCombatScope(InputScope? scope);
|
||||
|
||||
void SetCameraAlternateScope(bool active);
|
||||
}
|
||||
|
||||
internal sealed class DispatcherGameplayInputActionSurface(InputDispatcher dispatcher)
|
||||
|
|
@ -30,6 +33,9 @@ internal sealed class DispatcherGameplayInputActionSurface(InputDispatcher dispa
|
|||
|
||||
public void SetCombatScope(InputScope? scope) =>
|
||||
_dispatcher.SetCombatScope(scope);
|
||||
|
||||
public void SetCameraAlternateScope(bool active) =>
|
||||
_dispatcher.SetCameraAlternateScope(active);
|
||||
}
|
||||
|
||||
internal interface ICombatModeEventSurface
|
||||
|
|
@ -66,6 +72,8 @@ internal interface IGameplayInputPriorityTargets
|
|||
|
||||
bool HandleRetainedUiAction(InputAction action);
|
||||
|
||||
bool HandleCharacterOptionAction(InputAction action);
|
||||
|
||||
bool HandleSelectionAction(InputAction action);
|
||||
|
||||
bool HandlePressedMovementAction(InputAction action);
|
||||
|
|
@ -87,6 +95,7 @@ internal sealed class RuntimeGameplayInputPriorityTargets
|
|||
private readonly IGameRuntimeView _runtimeView;
|
||||
private readonly IRuntimeSelectionCommands _runtimeSelection;
|
||||
private readonly IRuntimeMovementCommands _runtimeMovement;
|
||||
private readonly IRuntimeCharacterCommands _runtimeCharacter;
|
||||
private readonly IGameplayInputCommandTarget _commands;
|
||||
|
||||
public RuntimeGameplayInputPriorityTargets(
|
||||
|
|
@ -97,6 +106,7 @@ internal sealed class RuntimeGameplayInputPriorityTargets
|
|||
IGameRuntimeView runtimeView,
|
||||
IRuntimeSelectionCommands runtimeSelection,
|
||||
IRuntimeMovementCommands runtimeMovement,
|
||||
IRuntimeCharacterCommands runtimeCharacter,
|
||||
IGameplayInputCommandTarget commands)
|
||||
{
|
||||
_frame = frame ?? throw new ArgumentNullException(nameof(frame));
|
||||
|
|
@ -109,11 +119,14 @@ internal sealed class RuntimeGameplayInputPriorityTargets
|
|||
?? throw new ArgumentNullException(nameof(runtimeSelection));
|
||||
_runtimeMovement = runtimeMovement
|
||||
?? throw new ArgumentNullException(nameof(runtimeMovement));
|
||||
_runtimeCharacter = runtimeCharacter
|
||||
?? throw new ArgumentNullException(nameof(runtimeCharacter));
|
||||
_commands = commands ?? throw new ArgumentNullException(nameof(commands));
|
||||
}
|
||||
|
||||
public bool HandlePointerAction(InputAction action, ActivationType activation) =>
|
||||
_frame.HandlePointerAction(action, activation);
|
||||
_frame.HandlePointerAction(action, activation)
|
||||
|| _pointer.HandleCameraAction(action, activation);
|
||||
|
||||
public void HandleScroll(InputAction action) =>
|
||||
_pointer.HandleScroll(action);
|
||||
|
|
@ -122,10 +135,74 @@ internal sealed class RuntimeGameplayInputPriorityTargets
|
|||
_frame.HandleCombatAction(action, activation);
|
||||
|
||||
public bool HandleRetainedUiAction(InputAction action) =>
|
||||
_retainedUi?.HandleInputAction(action) == true;
|
||||
FinishJumpBeforeUi(action)
|
||||
|| _retainedUi?.HandleInputAction(action) == true;
|
||||
|
||||
public bool HandleCharacterOptionAction(InputAction action)
|
||||
{
|
||||
if (!RetailActionIdentityTable.TryGetCharacterOptionId(
|
||||
action,
|
||||
out uint optionId)
|
||||
|| !CharacterOptionTable.TryGet(
|
||||
optionId,
|
||||
out CharacterOptionTableEntry entry))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
RuntimeCharacterOptionsSnapshot options =
|
||||
_runtimeView.Character.Snapshot.Options;
|
||||
uint word = entry.IsOptions1 ? options.Options1 : options.Options2;
|
||||
bool current = (word & entry.Mask) != 0u;
|
||||
_runtimeCharacter.SetSingleOption(
|
||||
_runtimeView.Generation,
|
||||
optionId,
|
||||
!current);
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool FinishJumpBeforeUi(InputAction action)
|
||||
{
|
||||
RuntimeMovementCommand? command = ResolveEscapeMovementCommand(
|
||||
action,
|
||||
_runtimeView.Movement.IsStandingStill,
|
||||
_runtimeView.Movement.JumpCharge,
|
||||
_runtimeView.Actions.Snapshot.CombatAttack);
|
||||
if (command != RuntimeMovementCommand.FinishJump)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_runtimeMovement.Execute(
|
||||
_runtimeView.Generation,
|
||||
command.Value);
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool HandleSelectionAction(InputAction action)
|
||||
{
|
||||
if (action == InputAction.EscapeKey)
|
||||
{
|
||||
IRuntimeMovementView movement = _runtimeView.Movement;
|
||||
RuntimeCombatAttackSnapshot attack = _runtimeView.Actions.Snapshot
|
||||
.CombatAttack;
|
||||
RuntimeMovementCommand? escapeCommand =
|
||||
ResolveEscapeMovementCommand(
|
||||
action,
|
||||
movement.IsStandingStill,
|
||||
movement.JumpCharge,
|
||||
attack);
|
||||
if (escapeCommand == RuntimeMovementCommand.StopCompletely)
|
||||
{
|
||||
_runtimeMovement.Execute(
|
||||
_runtimeView.Generation,
|
||||
escapeCommand.Value);
|
||||
if (attack.RepeatAttackInProgress)
|
||||
_frame.AbortAutomaticAttack();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
RuntimeSelectionCommand? command = action switch
|
||||
{
|
||||
InputAction.SelectionClosestMonster =>
|
||||
|
|
@ -149,15 +226,32 @@ internal sealed class RuntimeGameplayInputPriorityTargets
|
|||
return _selection?.HandleInputAction(action) == true;
|
||||
}
|
||||
|
||||
internal static RuntimeMovementCommand? ResolveEscapeMovementCommand(
|
||||
InputAction action,
|
||||
bool isStandingStill,
|
||||
in AcDream.Runtime.Gameplay.JumpChargeSnapshot jumpCharge,
|
||||
in RuntimeCombatAttackSnapshot attack)
|
||||
{
|
||||
if (action != InputAction.EscapeKey)
|
||||
return null;
|
||||
if (jumpCharge.IsCharging)
|
||||
return RuntimeMovementCommand.FinishJump;
|
||||
if (!isStandingStill || attack.RepeatAttackInProgress)
|
||||
return RuntimeMovementCommand.StopCompletely;
|
||||
return null;
|
||||
}
|
||||
|
||||
public bool HandlePressedMovementAction(InputAction action)
|
||||
{
|
||||
RuntimeMovementCommand? command = action switch
|
||||
if (RetailEmoteMotionTable.TryGetMotion(action, out uint motion))
|
||||
{
|
||||
InputAction.MovementRunLock =>
|
||||
RuntimeMovementCommand.ToggleRunLock,
|
||||
InputAction.MovementStop => RuntimeMovementCommand.Stop,
|
||||
_ => null,
|
||||
};
|
||||
_runtimeMovement.ExecuteMotion(
|
||||
_runtimeView.Generation,
|
||||
motion);
|
||||
return true;
|
||||
}
|
||||
|
||||
RuntimeMovementCommand? command = ResolvePressedMovementCommand(action);
|
||||
if (command is { } typed)
|
||||
{
|
||||
_runtimeMovement.Execute(_runtimeView.Generation, typed);
|
||||
|
|
@ -167,6 +261,18 @@ internal sealed class RuntimeGameplayInputPriorityTargets
|
|||
return _frame.HandlePressedMovementAction(action);
|
||||
}
|
||||
|
||||
internal static RuntimeMovementCommand? ResolvePressedMovementCommand(
|
||||
InputAction action) => action switch
|
||||
{
|
||||
InputAction.MovementRunLock => RuntimeMovementCommand.ToggleRunLock,
|
||||
InputAction.MovementStop => RuntimeMovementCommand.Stop,
|
||||
InputAction.Ready => RuntimeMovementCommand.Ready,
|
||||
InputAction.Sitting => RuntimeMovementCommand.Sit,
|
||||
InputAction.Crouch => RuntimeMovementCommand.Crouch,
|
||||
InputAction.Sleeping => RuntimeMovementCommand.Sleep,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
public void HandleCommand(InputAction action) =>
|
||||
_commands.Handle(action);
|
||||
}
|
||||
|
|
@ -298,6 +404,14 @@ internal sealed class GameplayInputActionRouter : IDisposable
|
|||
{
|
||||
_log($"[input] {action} {activation}");
|
||||
|
||||
if (action == InputAction.CameraActivateAlternateMode)
|
||||
{
|
||||
if (activation == ActivationType.Press)
|
||||
_actions.SetCameraAlternateScope(true);
|
||||
else if (activation == ActivationType.Release)
|
||||
_actions.SetCameraAlternateScope(false);
|
||||
}
|
||||
|
||||
if (_targets.HandlePointerAction(action, activation))
|
||||
return;
|
||||
|
||||
|
|
@ -320,6 +434,8 @@ internal sealed class GameplayInputActionRouter : IDisposable
|
|||
|
||||
if (_targets.HandleRetainedUiAction(action))
|
||||
return;
|
||||
if (_targets.HandleCharacterOptionAction(action))
|
||||
return;
|
||||
if (_targets.HandleSelectionAction(action))
|
||||
return;
|
||||
if (_targets.HandlePressedMovementAction(action))
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
using AcDream.App.Combat;
|
||||
using AcDream.App.Diagnostics;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.UI;
|
||||
using AcDream.Runtime;
|
||||
using AcDream.UI.Abstractions.Input;
|
||||
|
|
@ -24,6 +23,12 @@ internal interface IRetainedGameplayWindowCommands
|
|||
/// <c>RetailUiRuntime.BindToolbarPanelButtons</c>.
|
||||
/// </summary>
|
||||
void ToggleOptionsPanel();
|
||||
|
||||
void ToggleGameplayOptionsPage();
|
||||
|
||||
void FocusChatEntry();
|
||||
|
||||
void LogOutCharacter();
|
||||
}
|
||||
|
||||
internal sealed class RetainedGameplayWindowCommands(RetailUiRuntime? runtime)
|
||||
|
|
@ -39,35 +44,31 @@ internal sealed class RetainedGameplayWindowCommands(RetailUiRuntime? runtime)
|
|||
|
||||
public void ToggleOptionsPanel() =>
|
||||
_runtime?.ToggleWindow(WindowNames.Options);
|
||||
|
||||
public void ToggleGameplayOptionsPage() =>
|
||||
_runtime?.ToggleGameplayOptionsPage();
|
||||
|
||||
public void FocusChatEntry() => _runtime?.FocusChatEntry();
|
||||
|
||||
public void LogOutCharacter() => _runtime?.LogOutCharacter();
|
||||
}
|
||||
|
||||
internal interface IPlayerModeGameplayCommands
|
||||
{
|
||||
bool IsPlayerMode { get; }
|
||||
|
||||
void ToggleFlyOrChase();
|
||||
|
||||
void TogglePlayerMode();
|
||||
|
||||
void ExitPlayerMode();
|
||||
}
|
||||
|
||||
internal sealed class PlayerModeGameplayCommands(
|
||||
ILocalPlayerModeSource mode,
|
||||
PlayerModeController controller) : IPlayerModeGameplayCommands
|
||||
internal sealed class PlayerModeGameplayCommands(PlayerModeController controller)
|
||||
: IPlayerModeGameplayCommands
|
||||
{
|
||||
private readonly ILocalPlayerModeSource _mode = mode
|
||||
?? throw new ArgumentNullException(nameof(mode));
|
||||
private readonly PlayerModeController _controller = controller
|
||||
?? throw new ArgumentNullException(nameof(controller));
|
||||
|
||||
public bool IsPlayerMode => _mode.IsPlayerMode;
|
||||
|
||||
public void ToggleFlyOrChase() => _controller.ToggleFlyOrChase();
|
||||
|
||||
public void TogglePlayerMode() => _controller.Toggle();
|
||||
|
||||
public void ExitPlayerMode() => _controller.Exit();
|
||||
}
|
||||
|
||||
internal interface IItemTargetModeCommands
|
||||
|
|
@ -88,37 +89,6 @@ internal sealed class ItemTargetModeCommands(ItemInteractionController items)
|
|||
public void CancelTargetMode() => _items.CancelTargetMode();
|
||||
}
|
||||
|
||||
internal interface IGameplayCameraModeCommands
|
||||
{
|
||||
bool IsFlyMode { get; }
|
||||
|
||||
void ExitFlyMode();
|
||||
}
|
||||
|
||||
internal sealed class GameplayCameraModeCommands(CameraController camera)
|
||||
: IGameplayCameraModeCommands
|
||||
{
|
||||
private readonly CameraController _camera = camera
|
||||
?? throw new ArgumentNullException(nameof(camera));
|
||||
|
||||
public bool IsFlyMode => _camera.IsFlyMode;
|
||||
|
||||
public void ExitFlyMode() => _camera.ToggleFly();
|
||||
}
|
||||
|
||||
internal interface IGameplayWindowCommands
|
||||
{
|
||||
void Close();
|
||||
}
|
||||
|
||||
internal sealed class GameplayWindowCommands(Action close) : IGameplayWindowCommands
|
||||
{
|
||||
private readonly Action _close = close
|
||||
?? throw new ArgumentNullException(nameof(close));
|
||||
|
||||
public void Close() => _close();
|
||||
}
|
||||
|
||||
internal interface IGameplayInputCommandTarget
|
||||
{
|
||||
bool Handle(InputAction action);
|
||||
|
|
@ -135,10 +105,8 @@ internal sealed class GameplayInputCommandController : IGameplayInputCommandTarg
|
|||
private readonly IRuntimeDiagnosticCommands _diagnostics;
|
||||
private readonly IPlayerModeGameplayCommands _playerMode;
|
||||
private readonly IItemTargetModeCommands _targetMode;
|
||||
private readonly IGameplayCameraModeCommands _camera;
|
||||
private readonly IGameRuntimeView _runtimeView;
|
||||
private readonly IRuntimeCombatCommands _combat;
|
||||
private readonly IGameplayWindowCommands _window;
|
||||
private readonly Action? _toggleAudioMute;
|
||||
|
||||
public GameplayInputCommandController(
|
||||
|
|
@ -146,21 +114,17 @@ internal sealed class GameplayInputCommandController : IGameplayInputCommandTarg
|
|||
IRuntimeDiagnosticCommands diagnostics,
|
||||
IPlayerModeGameplayCommands playerMode,
|
||||
IItemTargetModeCommands targetMode,
|
||||
IGameplayCameraModeCommands camera,
|
||||
IGameRuntimeView runtimeView,
|
||||
IRuntimeCombatCommands combat,
|
||||
IGameplayWindowCommands window,
|
||||
Action? toggleAudioMute = null)
|
||||
{
|
||||
_retained = retained ?? throw new ArgumentNullException(nameof(retained));
|
||||
_diagnostics = diagnostics ?? throw new ArgumentNullException(nameof(diagnostics));
|
||||
_playerMode = playerMode ?? throw new ArgumentNullException(nameof(playerMode));
|
||||
_targetMode = targetMode ?? throw new ArgumentNullException(nameof(targetMode));
|
||||
_camera = camera ?? throw new ArgumentNullException(nameof(camera));
|
||||
_runtimeView = runtimeView
|
||||
?? throw new ArgumentNullException(nameof(runtimeView));
|
||||
_combat = combat ?? throw new ArgumentNullException(nameof(combat));
|
||||
_window = window ?? throw new ArgumentNullException(nameof(window));
|
||||
_toggleAudioMute = toggleAudioMute;
|
||||
}
|
||||
|
||||
|
|
@ -206,10 +170,12 @@ internal sealed class GameplayInputCommandController : IGameplayInputCommandTarg
|
|||
_playerMode.TogglePlayerMode();
|
||||
return true;
|
||||
case InputAction.ToggleChatEntry:
|
||||
// OP9: IDevToolsGameplayCommands.FocusChatInput() retired —
|
||||
// same shape as AcdreamToggleDebugPanel above (its ImGui
|
||||
// ChatPanel target was already gone). Tab is still consumed
|
||||
// here, matching the prior no-op's "handled" contract.
|
||||
case InputAction.EnterChatMode:
|
||||
// Physical Tab/Enter are normally consumed by UiRoot before
|
||||
// the dispatcher. This semantic route is what makes a rebound
|
||||
// key and headless/UI automation reach that same retained
|
||||
// chat field.
|
||||
_retained.FocusChatEntry();
|
||||
return true;
|
||||
case InputAction.ToggleOptionsPanel:
|
||||
// Campaign OP slice OP3 (D1): F11 opens the RETAIL Options
|
||||
|
|
@ -227,6 +193,9 @@ internal sealed class GameplayInputCommandController : IGameplayInputCommandTarg
|
|||
_runtimeView.Generation,
|
||||
RuntimeCombatCommand.ToggleMode);
|
||||
return true;
|
||||
case InputAction.LOGOUT:
|
||||
_retained.LogOutCharacter();
|
||||
return true;
|
||||
case InputAction.EscapeKey:
|
||||
HandleEscape();
|
||||
return true;
|
||||
|
|
@ -239,9 +208,7 @@ internal sealed class GameplayInputCommandController : IGameplayInputCommandTarg
|
|||
{
|
||||
if (_targetMode.IsAnyTargetModeActive)
|
||||
_targetMode.CancelTargetMode();
|
||||
else if (_playerMode.IsPlayerMode)
|
||||
_playerMode.ExitPlayerMode();
|
||||
else
|
||||
_window.Close();
|
||||
_retained.ToggleGameplayOptionsPage();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ internal interface ICombatInputFrameController
|
|||
{
|
||||
void Tick();
|
||||
void HandleMovementInput(InputAction action, ActivationType activation);
|
||||
void AbortAutomaticAttack();
|
||||
bool HandleInputAction(InputAction action, ActivationType activation);
|
||||
}
|
||||
|
||||
|
|
@ -38,6 +39,11 @@ internal sealed class CombatAttackInputFrameAdapter : ICombatInputFrameControlle
|
|||
RuntimeInputActivation.Press));
|
||||
}
|
||||
|
||||
public void AbortAutomaticAttack() =>
|
||||
_owner.HandleCommand(new RuntimeCombatAttackInput(
|
||||
RuntimeCombatAttackCommand.AbortForMovement,
|
||||
RuntimeInputActivation.Press));
|
||||
|
||||
public bool HandleInputAction(InputAction action, ActivationType activation)
|
||||
{
|
||||
RuntimeCombatAttackCommand? command = action switch
|
||||
|
|
@ -52,16 +58,37 @@ internal sealed class CombatAttackInputFrameAdapter : ICombatInputFrameControlle
|
|||
RuntimeCombatAttackCommand.DecreasePower,
|
||||
InputAction.CombatIncreaseAttackPower =>
|
||||
RuntimeCombatAttackCommand.IncreasePower,
|
||||
InputAction.CombatDecreaseMissileAccuracy =>
|
||||
RuntimeCombatAttackCommand.DecreasePower,
|
||||
InputAction.CombatIncreaseMissileAccuracy =>
|
||||
RuntimeCombatAttackCommand.IncreasePower,
|
||||
InputAction.CombatAimLow =>
|
||||
RuntimeCombatAttackCommand.LowAttack,
|
||||
InputAction.CombatAimMedium =>
|
||||
RuntimeCombatAttackCommand.MediumAttack,
|
||||
InputAction.CombatAimHigh =>
|
||||
RuntimeCombatAttackCommand.HighAttack,
|
||||
_ => null,
|
||||
};
|
||||
if (command is null)
|
||||
return false;
|
||||
|
||||
// A retail Hold binding emits Press once, Hold every input frame, then
|
||||
// Release on key-up. RuntimeCombatAttackState already measures the
|
||||
// Press-to-Release interval; forwarding the repeated Hold pulse as a
|
||||
// Release made Delete/End/PageDown attack on the first frame instead
|
||||
// of charging until the player released the key.
|
||||
if (activation == ActivationType.Hold)
|
||||
return true;
|
||||
|
||||
return _owner.HandleCommand(new RuntimeCombatAttackInput(
|
||||
command.Value,
|
||||
activation == ActivationType.Press
|
||||
? RuntimeInputActivation.Press
|
||||
: RuntimeInputActivation.Release));
|
||||
activation switch
|
||||
{
|
||||
ActivationType.Press => RuntimeInputActivation.Press,
|
||||
ActivationType.Release => RuntimeInputActivation.Release,
|
||||
_ => RuntimeInputActivation.Press,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -114,6 +141,8 @@ internal sealed class GameplayInputFrameController
|
|||
public bool HandlePressedMovementAction(InputAction action) =>
|
||||
_movement.HandlePressedAction(action);
|
||||
|
||||
public void AbortAutomaticAttack() => _combat.AbortAutomaticAttack();
|
||||
|
||||
public void QueueRawMouseDelta(float dx, float dy) =>
|
||||
_mouseLook?.QueueRawDelta(dx, dy);
|
||||
|
||||
|
|
|
|||
|
|
@ -149,7 +149,9 @@ internal sealed class MouseLookController : IMouseLookInputFrameController
|
|||
return true;
|
||||
}
|
||||
|
||||
if (action != InputAction.CameraInstantMouseLook)
|
||||
if (action is not (
|
||||
InputAction.CameraInstantMouseLook
|
||||
or InputAction.CameraActivateAlternateMode))
|
||||
return false;
|
||||
|
||||
if (activation == ActivationType.Press)
|
||||
|
|
|
|||
|
|
@ -332,8 +332,6 @@ internal sealed class PlayerModeController :
|
|||
{
|
||||
moveTo.MoveToComplete = error =>
|
||||
{
|
||||
if (PhysicsDiagnostics.ProbeAutoWalkEnabled)
|
||||
Console.WriteLine($"[autowalk-end] reason=complete err={error}");
|
||||
if (error == WeenieError.None)
|
||||
approachLifetime.PublishNaturalCompletion();
|
||||
else
|
||||
|
|
|
|||
129
src/AcDream.App/Input/RetailEmoteMotionTable.cs
Normal file
129
src/AcDream.App/Input/RetailEmoteMotionTable.cs
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
using AcDream.UI.Abstractions.Input;
|
||||
|
||||
namespace AcDream.App.Input;
|
||||
|
||||
/// <summary>
|
||||
/// Verbatim Sept-2013 <c>ACCmdInterp::InitializeEmoteInputActionHash</c>
|
||||
/// (<c>0x0058B510</c>). <c>ACCmdInterp::OnAction</c>
|
||||
/// (<c>0x0058B370</c>) resolves one of these input actions and submits the
|
||||
/// corresponding raw motion through <c>SetMotion</c> with start=true.
|
||||
/// </summary>
|
||||
internal static class RetailEmoteMotionTable
|
||||
{
|
||||
private const uint EmoteInputMap = 0x10000006u;
|
||||
private const uint FirstEmoteAction = 0x10000098u;
|
||||
|
||||
// Action ids 0x10000098..0x100000EE are contiguous in retail's ActionMap.
|
||||
// Values come from the named Motion_* globals used by the initializer.
|
||||
private static readonly uint[] Motions =
|
||||
[
|
||||
0x43000118u, // AFKState
|
||||
0x13000088u, // Akimbo
|
||||
0x420000F9u, // ATOYOT
|
||||
0x430000F2u, // AkimboState
|
||||
0x43000146u, // AtEaseState
|
||||
0x1300007Au, // Beckon
|
||||
0x1300007Bu, // BeSeeingYou
|
||||
0x1300007Cu, // BlowKiss
|
||||
0x1300007Du, // BowDeep
|
||||
0x430000ECu, // BowDeepState
|
||||
0x1300004Cu, // Cheer
|
||||
0x1300007Eu, // ClapHands
|
||||
0x430000EDu, // ClapHandsState
|
||||
0x13000091u, // Cringe
|
||||
0x430000EEu, // CrossArmsState
|
||||
0x1300007Fu, // Cry
|
||||
0x43000117u, // CurtseyState
|
||||
0x1300014Eu, // DrudgeDance
|
||||
0x43000141u, // DrudgeDanceState
|
||||
0x1300014Fu, // HaveASeat
|
||||
0x43000145u, // HaveASeatState
|
||||
0x13000089u, // HeartyLaugh
|
||||
0x13000132u, // Helper
|
||||
0x13000092u, // Kneel
|
||||
0x430000F7u, // KneelState
|
||||
0x1300014Cu, // Knock
|
||||
0x13000080u, // Laugh
|
||||
0x430000F6u, // LeanState
|
||||
0x43000119u, // MeditateState
|
||||
0x13000082u, // MimeDrink
|
||||
0x13000081u, // MimeEat
|
||||
0x130000CBu, // Mock
|
||||
0x13000083u, // Nod
|
||||
0x13000147u, // NudgeLeft
|
||||
0x13000148u, // NudgeRight
|
||||
0x13000093u, // Plead
|
||||
0x430000F8u, // PleadState
|
||||
0x13000084u, // Point
|
||||
0x430000F0u, // PointState
|
||||
0x1300014Bu, // PointDown
|
||||
0x43000140u, // PointDownState
|
||||
0x13000149u, // PointLeft
|
||||
0x4300013Du, // PointLeftState
|
||||
0x1300014Au, // PointRight
|
||||
0x4300013Eu, // PointRightState
|
||||
0x43000142u, // PossumState
|
||||
0x130000CAu, // Pray
|
||||
0x430000EBu, // PrayState
|
||||
0x43000143u, // ReadState
|
||||
0x1300008Au, // Salute
|
||||
0x430000F3u, // SaluteState
|
||||
0x1300014Du, // ScanHorizon
|
||||
0x1300008Bu, // ScratchHead
|
||||
0x430000F4u, // ScratchHeadState
|
||||
0x13000079u, // ShakeFist
|
||||
0x430000EAu, // ShakeFistState
|
||||
0x13000085u, // ShakeHead
|
||||
0x13000094u, // Shiver
|
||||
0x430000EFu, // ShiverState
|
||||
0x13000095u, // Shoo
|
||||
0x13000086u, // Shrug
|
||||
0x4300013Au, // SitState
|
||||
0x4300013Cu, // SitBackState
|
||||
0x4300013Bu, // SitCrossleggedState
|
||||
0x13000096u, // Slouch
|
||||
0x430000FAu, // SlouchState
|
||||
0x1300008Cu, // SmackHead
|
||||
0x43000115u, // SnowAngelState
|
||||
0x13000097u, // Spit
|
||||
0x13000098u, // Surrender
|
||||
0x430000FBu, // SurrenderState
|
||||
0x4300013Fu, // TalktotheHandState
|
||||
0x1300008Du, // TapFoot
|
||||
0x430000F5u, // TapFootState
|
||||
0x130000CCu, // Teapot
|
||||
0x43000144u, // ThinkerState
|
||||
0x13000116u, // WarmHands
|
||||
0x13000087u, // Wave
|
||||
0x430000F1u, // WaveState
|
||||
0x1300008Fu, // WaveLow
|
||||
0x1300008Eu, // WaveHigh
|
||||
0x1300009Au, // Winded
|
||||
0x430000FDu, // WindedState
|
||||
0x13000099u, // Woah
|
||||
0x430000FCu, // WoahState
|
||||
0x13000090u, // YawnStretch
|
||||
0x1200009Bu, // YMCA
|
||||
];
|
||||
|
||||
public static int Count => Motions.Length;
|
||||
|
||||
public static bool TryGetMotion(InputAction action, out uint motion)
|
||||
{
|
||||
motion = 0u;
|
||||
if (!RetailActionIdentityTable.TryGetRetailIdentity(
|
||||
action,
|
||||
out var identity)
|
||||
|| identity.InputMapId != EmoteInputMap)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
uint index = identity.ActionId - FirstEmoteAction;
|
||||
if (index >= Motions.Length)
|
||||
return false;
|
||||
|
||||
motion = Motions[index];
|
||||
return true;
|
||||
}
|
||||
}
|
||||
601
src/AcDream.App/Input/RetailKeymapFile.cs
Normal file
601
src/AcDream.App/Input/RetailKeymapFile.cs
Normal file
|
|
@ -0,0 +1,601 @@
|
|||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using AcDream.UI.Abstractions.Input;
|
||||
|
||||
namespace AcDream.App.Input;
|
||||
|
||||
/// <summary>
|
||||
/// Parser/writer for retail's editable <c>Documents\Asheron's Call\*.keymap</c>
|
||||
/// PFile text. Only the fourteen user-bindable input maps are replaced when a
|
||||
/// profile is loaded; acdream-only commands and retail's fixed system/edit/
|
||||
/// pointer maps remain owned by the host's base <see cref="KeyBindings"/>.
|
||||
/// </summary>
|
||||
public static class RetailKeymapFile
|
||||
{
|
||||
private static readonly Regex BindingLine = new(
|
||||
"^(?<action>[A-Za-z0-9_]+)\\s*\\[\\s*\"\"\\s*\\[\\s*"
|
||||
+ "(?<device>[0-9]+)\\s+(?<control>[A-Za-z0-9_]+)"
|
||||
+ "(?:\\s+(?<sub>[A-Za-z]+))?\\s*\\]"
|
||||
+ "(?:\\s+(?<modifier>0x[0-9A-Fa-f]+|[0-9]+))?"
|
||||
+ "(?:\\s+(?<activation>[A-Za-z]+))?\\s*\\]$",
|
||||
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||
|
||||
private static readonly (uint Id, string Name)[] GroupOrder =
|
||||
{
|
||||
(0x00000004u, "MovementCommands"),
|
||||
(0x10000007u, "ItemSelectionCommands"),
|
||||
(0x10000009u, "UICommands"),
|
||||
(0x1000000Cu, "QuickslotCommands"),
|
||||
(0x1000000Du, "ToggleChatEntry"),
|
||||
(0x1000000Au, "ChatCommands"),
|
||||
(0x10000002u, "Combat"),
|
||||
(0x10000003u, "MeleeCombat"),
|
||||
(0x10000004u, "MissileCombat"),
|
||||
(0x10000005u, "MagicCombat"),
|
||||
(0x10000006u, "Emotes"),
|
||||
(0x00000005u, "CameraControls"),
|
||||
(0x00000006u, "CameraAlternateControls"),
|
||||
(0x10000008u, "CharacterOptionCommands"),
|
||||
};
|
||||
|
||||
private static readonly IReadOnlyDictionary<string, uint> GroupIds =
|
||||
GroupOrder.ToDictionary(static group => group.Name, static group => group.Id,
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Lazy because the explicit character-option semantic table is declared
|
||||
// later in this type; field initializers otherwise observe it as null.
|
||||
private static readonly Lazy<IReadOnlyDictionary<InputAction, string>> ActionNamesHolder =
|
||||
new(BuildActionNames);
|
||||
private static readonly Lazy<IReadOnlyDictionary<string, InputAction>> ActionsByFileNameHolder =
|
||||
new(BuildActionsByFileName);
|
||||
private static IReadOnlyDictionary<InputAction, string> ActionNames => ActionNamesHolder.Value;
|
||||
private static IReadOnlyDictionary<string, InputAction> ActionsByFileName =>
|
||||
ActionsByFileNameHolder.Value;
|
||||
|
||||
public static KeyBindings Parse(string text, KeyBindings baseBindings)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(text);
|
||||
ArgumentNullException.ThrowIfNull(baseBindings);
|
||||
|
||||
var result = new KeyBindings();
|
||||
foreach (Binding binding in baseBindings.All)
|
||||
{
|
||||
if (!RetailActionIdentityTable.ReverseMap.ContainsKey(binding.Action))
|
||||
result.Add(binding);
|
||||
}
|
||||
|
||||
bool foundBindings = false;
|
||||
bool inBindings = false;
|
||||
uint? currentGroup = null;
|
||||
int lineNumber = 0;
|
||||
foreach (string rawLine in text.Replace("\r\n", "\n", StringComparison.Ordinal).Split('\n'))
|
||||
{
|
||||
lineNumber++;
|
||||
string line = rawLine.Trim();
|
||||
if (line.Length == 0 || line.StartsWith('#'))
|
||||
continue;
|
||||
if (line.Equals("Bindings", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
foundBindings = true;
|
||||
inBindings = true;
|
||||
currentGroup = null;
|
||||
continue;
|
||||
}
|
||||
if (!inBindings)
|
||||
continue;
|
||||
|
||||
// In the PFile grammar every input-map name is a bare identifier on
|
||||
// the line before its opening bracket. An unknown map clears the
|
||||
// user-map context so fixed SystemKeys/EditControls rows are ignored.
|
||||
if (Regex.IsMatch(line, "^[A-Za-z][A-Za-z0-9_]*$",
|
||||
RegexOptions.CultureInvariant))
|
||||
{
|
||||
currentGroup = GroupIds.TryGetValue(line, out uint groupId)
|
||||
? groupId
|
||||
: null;
|
||||
continue;
|
||||
}
|
||||
if (currentGroup is not uint inputMapId || line is "[" or "]")
|
||||
continue;
|
||||
|
||||
Match match = BindingLine.Match(line);
|
||||
if (!match.Success)
|
||||
throw new FormatException(
|
||||
$"Malformed retail key binding at line {lineNumber}: {line}");
|
||||
|
||||
string actionName = match.Groups["action"].Value;
|
||||
if (!ActionsByFileName.TryGetValue(FileIdentity(inputMapId, actionName), out InputAction action))
|
||||
{
|
||||
// Several fixed/non-user-bindable controls live inside an
|
||||
// otherwise editable map (UICommands.EscapeKey/LOGOUT in the
|
||||
// shipped file). They remain in baseBindings just like the
|
||||
// wholly fixed maps below the user maps.
|
||||
continue;
|
||||
}
|
||||
|
||||
string control = match.Groups["control"].Value;
|
||||
if (!RetailScanCodeMap.TryFromFileControl(control, out uint scan, out uint tokenDevice)
|
||||
|| !uint.TryParse(match.Groups["device"].Value,
|
||||
NumberStyles.None, CultureInfo.InvariantCulture, out uint device)
|
||||
|| device != tokenDevice
|
||||
|| RetailScanCodeMap.ToSilkKey(scan, device) is not { } key)
|
||||
{
|
||||
throw new FormatException(
|
||||
$"Unsupported retail control '{control}' at line {lineNumber}.");
|
||||
}
|
||||
|
||||
uint fileModifier = 0u;
|
||||
if (match.Groups["modifier"].Success)
|
||||
{
|
||||
string value = match.Groups["modifier"].Value;
|
||||
NumberStyles style = value.StartsWith("0x", StringComparison.OrdinalIgnoreCase)
|
||||
? NumberStyles.AllowHexSpecifier
|
||||
: NumberStyles.None;
|
||||
string digits = style == NumberStyles.AllowHexSpecifier ? value[2..] : value;
|
||||
if (!uint.TryParse(digits, style, CultureInfo.InvariantCulture, out fileModifier))
|
||||
throw new FormatException($"Invalid modifier at line {lineNumber}.");
|
||||
}
|
||||
|
||||
var chord = new KeyChord(key, (ModifierMask)(fileModifier & 0x0Fu), (byte)device);
|
||||
result.Add(new Binding(
|
||||
chord,
|
||||
action,
|
||||
RetailActionIdentityTable.ActivationFor(inputMapId,
|
||||
RetailActionIdentityTable.ReverseMap[action].ActionId),
|
||||
RetailActionIdentityTable.ScopeForInputMap(inputMapId)));
|
||||
}
|
||||
|
||||
if (!foundBindings)
|
||||
throw new FormatException("The file does not contain a retail Bindings section.");
|
||||
return result;
|
||||
}
|
||||
|
||||
public static string Write(KeyBindings bindings)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(bindings);
|
||||
var output = new StringBuilder(24_000);
|
||||
output.AppendLine("#Asheron's Call: Throne of Destiny Keymap File")
|
||||
.AppendLine("#")
|
||||
.AppendLine("#Generated by acdream's retail Configure Keyboard screen.")
|
||||
.AppendLine("#This file is compatible with the Sept-2013 retail PFile keymap grammar.")
|
||||
.AppendLine("#")
|
||||
.AppendLine("\"User Defined Keymap\" [ 00000000-0000-0000-0000-000000000000 ]")
|
||||
.AppendLine()
|
||||
.AppendLine("Devices")
|
||||
.AppendLine("[")
|
||||
.AppendLine(" Keyboard [ GUID_SysKeyboard ]")
|
||||
.AppendLine(" Mouse [ GUID_SysMouse ]")
|
||||
.AppendLine(" Virtual [ GUID_Virtual ]")
|
||||
.AppendLine("]")
|
||||
.AppendLine()
|
||||
.AppendLine("MetaKeys")
|
||||
.AppendLine("[")
|
||||
.AppendLine(" 1 [ 0 DIK_LSHIFT ]")
|
||||
.AppendLine(" 2 [ 0 DIK_LCONTROL ]")
|
||||
.AppendLine(" 2 [ 0 DIK_RCONTROL ]")
|
||||
.AppendLine(" 3 [ 0 DIK_LMENU ]")
|
||||
.AppendLine(" 3 [ 0 DIK_RALT ]")
|
||||
.AppendLine(" 4 [ 0 DIK_LWIN ]")
|
||||
.AppendLine(" 4 [ 0 DIK_RWIN ]")
|
||||
.AppendLine(" 5 [ 1 DIMOFS_BUTTON3 ]")
|
||||
.AppendLine(" 6 [ 1 DIMOFS_BUTTON4 ]")
|
||||
.AppendLine("]")
|
||||
.AppendLine()
|
||||
.AppendLine("Bindings")
|
||||
.AppendLine("[");
|
||||
|
||||
foreach ((uint inputMapId, string groupName) in GroupOrder)
|
||||
{
|
||||
output.Append(" ").AppendLine(groupName).AppendLine(" [");
|
||||
foreach (((uint InputMapId, uint ActionId) identity, InputAction action) in
|
||||
RetailActionIdentityTable.Map
|
||||
.Where(pair => pair.Key.InputMapId == inputMapId)
|
||||
.OrderBy(static pair => pair.Key.ActionId))
|
||||
{
|
||||
foreach (Binding binding in bindings.ForAction(action))
|
||||
{
|
||||
if (!RetailScanCodeMap.TryToFileControl(binding.Chord, out string control))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"{binding.Chord} cannot be represented by the retail DirectInput keymap.");
|
||||
}
|
||||
|
||||
output.Append(" ").Append(ActionNames[action])
|
||||
.Append(" [ \"\" [ ").Append(binding.Chord.Device)
|
||||
.Append(' ').Append(control).Append(" ]");
|
||||
uint modifier = (uint)binding.Chord.Modifiers & 0x0Fu;
|
||||
if (modifier != 0u)
|
||||
output.Append(" 0x").Append(modifier.ToString("X8", CultureInfo.InvariantCulture));
|
||||
output.AppendLine(" ]");
|
||||
}
|
||||
}
|
||||
// Bare Escape is a fixed MasterInputMap control rather than one of
|
||||
// the user-bindable ActionMap rows (LOGOUT is a normal row and was
|
||||
// emitted above). Keep it in exported files so the Sept-2013 client
|
||||
// retains its priority Escape ladder when opening our profile.
|
||||
if (inputMapId == 0x10000009u)
|
||||
output.AppendLine(" EscapeKey [ \"\" [ 0 DIK_ESCAPE ] ]");
|
||||
output.AppendLine(" ]").AppendLine();
|
||||
}
|
||||
|
||||
// Retail's fixed maps are included so a file can also be opened by the
|
||||
// 2013 client. They are deliberately not imported into the 306-row GUI.
|
||||
output.Append(FixedRetailMaps);
|
||||
output.AppendLine("]");
|
||||
return output.ToString();
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<InputAction, string> BuildActionNames()
|
||||
{
|
||||
var names = new Dictionary<InputAction, string>();
|
||||
foreach (InputAction action in RetailActionIdentityTable.ReverseMap.Keys)
|
||||
names[action] = FileActionName(action);
|
||||
return names;
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, InputAction> BuildActionsByFileName()
|
||||
{
|
||||
var actions = new Dictionary<string, InputAction>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (((uint InputMapId, uint ActionId) identity, InputAction action) in
|
||||
RetailActionIdentityTable.Map)
|
||||
{
|
||||
actions.Add(FileIdentity(identity.InputMapId, ActionNames[action]), action);
|
||||
}
|
||||
return actions;
|
||||
}
|
||||
|
||||
private static string FileIdentity(uint inputMapId, string actionName) =>
|
||||
$"{inputMapId:X8}:{actionName}";
|
||||
|
||||
private static string GroupName(uint inputMapId) =>
|
||||
GroupOrder.First(group => group.Id == inputMapId).Name;
|
||||
|
||||
private static string FileActionName(InputAction action)
|
||||
{
|
||||
if (CharacterOptionNames.TryGetValue(action, out string? characterOption))
|
||||
return characterOption;
|
||||
if (action == InputAction.SelectionPlaceInInventory) return "SelectionPickUp";
|
||||
if (action == InputAction.UseSelected) return "USE";
|
||||
string name = action.ToString();
|
||||
if (name.StartsWith("CameraAlternate", StringComparison.Ordinal))
|
||||
return "Camera" + name["CameraAlternate".Length..];
|
||||
if (!name.StartsWith("Emote", StringComparison.Ordinal))
|
||||
return name;
|
||||
string emote = name["Emote".Length..];
|
||||
return emote switch
|
||||
{
|
||||
"AfkState" => "AFKState",
|
||||
"AToyotState" => "ATOYOT",
|
||||
"MimeDrinking" => "MimeDrink",
|
||||
"MimeEating" => "MimeEat",
|
||||
"TalkToTheHandState" => "TalktotheHandState",
|
||||
"YawnAndStretch" => "YawnStretch",
|
||||
"Ymca" => "YMCA",
|
||||
_ => emote,
|
||||
};
|
||||
}
|
||||
|
||||
private static readonly IReadOnlyDictionary<InputAction, string> CharacterOptionNames =
|
||||
new Dictionary<InputAction, string>
|
||||
{
|
||||
[InputAction.ToggleCharacterOptionAutoRepeatAttack] = "AutoRepeatAttacks",
|
||||
[InputAction.ToggleCharacterOptionIgnoreAllegianceRequests] = "IgnoreAllegianceRequests",
|
||||
[InputAction.ToggleCharacterOptionIgnoreFellowshipRequests] = "IgnoreFellowshipRequests",
|
||||
[InputAction.ToggleCharacterOptionIgnoreTradeRequests] = "IgnoreTradeRequests",
|
||||
[InputAction.ToggleCharacterOptionPersistentAtDay] = "PersistentAtDay",
|
||||
[InputAction.ToggleCharacterOptionAllowGive] = "LetPlayersGiveYouItems",
|
||||
[InputAction.ToggleCharacterOptionViewCombatTarget] = "AutoTrackCombatTargets",
|
||||
[InputAction.ToggleCharacterOptionShowTooltips] = "DisplayTooltips",
|
||||
[InputAction.ToggleCharacterOptionUseDeception] = "AttemptToDeceivePlayers",
|
||||
[InputAction.ToggleCharacterOptionToggleRun] = "RunAsDefaultMovement",
|
||||
[InputAction.ToggleCharacterOptionStayInChatMode] = "StayInChatModeAfterSend",
|
||||
[InputAction.ToggleCharacterOptionAdvancedCombatUi] = "AdvancedCombatInterface",
|
||||
[InputAction.ToggleCharacterOptionAutoTarget] = "AutoTarget",
|
||||
[InputAction.ToggleCharacterOptionVividTargetingIndicator] = "VividTargetIndicator",
|
||||
[InputAction.ToggleCharacterOptionFellowshipShareXp] = "ShareFellowshipXP",
|
||||
[InputAction.ToggleCharacterOptionAcceptLootPermits] = "AcceptCorpseLooting",
|
||||
[InputAction.ToggleCharacterOptionFellowshipShareLoot] = "ShareFellowshipLoot",
|
||||
[InputAction.ToggleCharacterOptionFellowshipAutoAcceptRequests] = "AutomaticallyAcceptFellowshipRequests",
|
||||
[InputAction.ToggleCharacterOptionCoordinatesOnRadar] = "ShowRadarCoordinates",
|
||||
[InputAction.ToggleCharacterOptionSpellDuration] = "ShowSpellDurations",
|
||||
[InputAction.ToggleCharacterOptionDisableHouseRestrictionEffects] = "DisableHouseEffect",
|
||||
[InputAction.ToggleCharacterOptionDragItemOnPlayerOpensSecureTrade] = "DragItemOnPlayerOpensSecureTrade",
|
||||
[InputAction.ToggleCharacterOptionDisplayAllegianceLogonNotifications] = "DisplayAllegianceLogonNotifications",
|
||||
[InputAction.ToggleCharacterOptionUseChargeAttack] = "UseChargeAttack",
|
||||
[InputAction.ToggleCharacterOptionUseCraftSuccessDialog] = "ToggleCraftingChanceOfSuccessDialog",
|
||||
[InputAction.ToggleCharacterOptionListenToAllegianceChat] = "AllegianceChat",
|
||||
[InputAction.ToggleCharacterOptionDisplayDateOfBirth] = "DisplayDateOfBirth",
|
||||
[InputAction.ToggleCharacterOptionDisplayAge] = "DisplayAge",
|
||||
[InputAction.ToggleCharacterOptionDisplayChessRank] = "DisplayChessRank",
|
||||
[InputAction.ToggleCharacterOptionDisplayFishingSkill] = "Fishing",
|
||||
[InputAction.ToggleCharacterOptionDisplayNumberDeaths] = "DisplayNumberDeaths",
|
||||
[InputAction.ToggleCharacterOptionDisplayTimeStamps] = "DisplayTimeStamps",
|
||||
[InputAction.ToggleCharacterOptionSalvageMultiple] = "SalvageMultiple",
|
||||
[InputAction.ToggleCharacterOptionListenToGeneralChat] = "GeneralChat",
|
||||
[InputAction.ToggleCharacterOptionListenToTradeChat] = "TradeChat",
|
||||
[InputAction.ToggleCharacterOptionListenToLfgChat] = "LFGChat",
|
||||
[InputAction.ToggleCharacterOptionListenToRoleplayChat] = "RoleplayChat",
|
||||
[InputAction.ToggleCharacterOptionDisplayNumberCharacterTitles] = "DisplayNumberCharacterTitles",
|
||||
[InputAction.ToggleCharacterOptionMainPackPreferred] = "MainPackPreferred",
|
||||
[InputAction.ToggleCharacterOptionLeadMissileTargets] = "LeadMissileTargets",
|
||||
[InputAction.ToggleCharacterOptionUseFastMissiles] = "UseFastMissiles",
|
||||
[InputAction.ToggleCharacterOptionFilterLanguage] = "FilterLanguage",
|
||||
[InputAction.ToggleCharacterOptionConfirmVolatileRareUse] = "ConfirmVolatileRareUse",
|
||||
[InputAction.ToggleCharacterOptionListenToSocietyChat] = "SocietyChat",
|
||||
[InputAction.ToggleCharacterOptionShowHelm] = "ShowHelm",
|
||||
[InputAction.ToggleCharacterOptionDisableDistanceFog] = "DisableDistanceFog",
|
||||
[InputAction.ToggleCharacterOptionShowCloak] = "ShowCloak",
|
||||
[InputAction.ToggleCharacterOptionSideBySideVitals] = "SideBySideVitals",
|
||||
};
|
||||
|
||||
private const string FixedRetailMaps = """
|
||||
TargetedUsage
|
||||
[
|
||||
SelectLeft [ "" [ 1 DIMOFS_BUTTON0 ] ]
|
||||
SelectRight [ "" [ 1 DIMOFS_BUTTON1 ] ]
|
||||
]
|
||||
|
||||
SystemKeys
|
||||
[
|
||||
AltEnter [ "" [ 0 DIK_RETURN ] 0x00000004 ]
|
||||
AltTab [ "" [ 0 DIK_TAB ] 0x00000004 ]
|
||||
AltF4 [ "" [ 0 DIK_F4 ] 0x00000004 ]
|
||||
CtrlShiftEsc [ "" [ 0 DIK_ESCAPE ] 0x00000003 ]
|
||||
]
|
||||
|
||||
MouseCommands
|
||||
[
|
||||
PointerX [ "" [ 1 DIMOFS_X ] 0x00000000 Analog ]
|
||||
PointerY [ "" [ 1 DIMOFS_Y ] 0x00000000 Analog ]
|
||||
SelectLeft [ "" [ 1 DIMOFS_BUTTON0 ] ]
|
||||
SelectRight [ "" [ 1 DIMOFS_BUTTON1 ] ]
|
||||
SelectMid [ "" [ 1 DIMOFS_BUTTON2 ] ]
|
||||
SelectDblLeft [ "" [ 1 DIMOFS_BUTTON0 ] 0x00000000 MouseDblClick ]
|
||||
SelectDblRight [ "" [ 1 DIMOFS_BUTTON1 ] 0x00000000 MouseDblClick ]
|
||||
SelectDblMid [ "" [ 1 DIMOFS_BUTTON2 ] 0x00000000 MouseDblClick ]
|
||||
]
|
||||
|
||||
ScrollableControls
|
||||
[
|
||||
ScrollUp [ "" [ 1 DIMOFS_Z AxisPositive ] ]
|
||||
ScrollDown [ "" [ 1 DIMOFS_Z AxisNegative ] ]
|
||||
ScrollUp [ "" [ 0 DIK_UPARROW ] 0x00000002 ]
|
||||
ScrollDown [ "" [ 0 DIK_DOWNARROW ] 0x00000002 ]
|
||||
]
|
||||
|
||||
EditControls
|
||||
[
|
||||
CursorCharLeft [ "" [ 0 DIK_LEFT ] ]
|
||||
CursorCharRight [ "" [ 0 DIK_RIGHTARROW ] ]
|
||||
CursorPreviousLine [ "" [ 0 DIK_UPARROW ] ]
|
||||
CursorNextLine [ "" [ 0 DIK_DOWNARROW ] ]
|
||||
CursorPreviousPage [ "" [ 0 DIK_PGUP ] ]
|
||||
CursorNextPage [ "" [ 0 DIK_PGDN ] ]
|
||||
CursorWordLeft [ "" [ 0 DIK_LEFT ] 0x00000002 ]
|
||||
CursorWordRight [ "" [ 0 DIK_RIGHTARROW ] 0x00000002 ]
|
||||
CursorStartOfLine [ "" [ 0 DIK_HOME ] ]
|
||||
CursorStartOfDocument [ "" [ 0 DIK_HOME ] 0x00000002 ]
|
||||
CursorEndOfLine [ "" [ 0 DIK_END ] ]
|
||||
CursorEndOfDocument [ "" [ 0 DIK_END ] 0x00000002 ]
|
||||
EscapeKey [ "" [ 0 DIK_ESCAPE ] ]
|
||||
AcceptInput [ "" [ 0 DIK_RETURN ] ]
|
||||
DeleteKey [ "" [ 0 DIK_DELETE ] ]
|
||||
BackspaceKey [ "" [ 0 DIK_BACK ] ]
|
||||
]
|
||||
|
||||
CopyAndPasteControls
|
||||
[
|
||||
CopyText [ "" [ 0 DIK_C ] 0x00000002 ]
|
||||
CopyText [ "" [ 0 DIK_INSERT ] 0x00000002 ]
|
||||
CutText [ "" [ 0 DIK_X ] 0x00000002 ]
|
||||
CutText [ "" [ 0 DIK_DELETE ] 0x00000001 ]
|
||||
PasteText [ "" [ 0 DIK_V ] 0x00000002 ]
|
||||
PasteText [ "" [ 0 DIK_INSERT ] 0x00000001 ]
|
||||
]
|
||||
|
||||
DialogBoxes
|
||||
[
|
||||
EscapeKey [ "" [ 0 DIK_ESCAPE ] ]
|
||||
AcceptInput [ "" [ 0 DIK_RETURN ] ]
|
||||
]
|
||||
|
||||
""";
|
||||
}
|
||||
|
||||
public enum RetailKeymapSaveStatus
|
||||
{
|
||||
Saved,
|
||||
Exists,
|
||||
ReadOnly,
|
||||
InvalidName,
|
||||
Failed,
|
||||
}
|
||||
|
||||
public readonly record struct RetailKeymapSaveResult(
|
||||
RetailKeymapSaveStatus Status,
|
||||
string FileName,
|
||||
string? Error = null);
|
||||
|
||||
/// <summary>
|
||||
/// Owns retail's active-profile preference and <c>*.keymap</c> directory.
|
||||
/// The profile selector lives beside acdream's portable JSON mirror; profile
|
||||
/// files live in retail's Documents/Asheron's Call folder.
|
||||
/// </summary>
|
||||
public sealed class RetailKeymapProfileStore
|
||||
{
|
||||
public const string DefaultFileName = "acdream.keymap";
|
||||
|
||||
private readonly string _jsonPath;
|
||||
private readonly string _directory;
|
||||
private readonly string _selectorPath;
|
||||
|
||||
public RetailKeymapProfileStore(string jsonPath, string? keymapDirectory = null)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(jsonPath);
|
||||
_jsonPath = Path.GetFullPath(jsonPath);
|
||||
string configDirectory = Path.GetDirectoryName(_jsonPath)
|
||||
?? Directory.GetCurrentDirectory();
|
||||
_selectorPath = Path.Combine(configDirectory, "active-keymap.txt");
|
||||
_directory = keymapDirectory ?? Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments),
|
||||
"Asheron's Call");
|
||||
}
|
||||
|
||||
public string DirectoryPath => _directory;
|
||||
|
||||
public string CurrentFileName
|
||||
{
|
||||
get
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(_selectorPath))
|
||||
{
|
||||
string selected = NormalizeFileName(File.ReadAllText(_selectorPath));
|
||||
if (selected.Length != 0) return selected;
|
||||
}
|
||||
}
|
||||
catch (Exception failure)
|
||||
{
|
||||
Console.WriteLine($"keymap: active-profile preference could not be read: {failure.Message}");
|
||||
}
|
||||
return DefaultFileName;
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<string> ListFiles()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!Directory.Exists(_directory)) return Array.Empty<string>();
|
||||
return Directory.EnumerateFiles(_directory, "*.keymap", SearchOption.TopDirectoryOnly)
|
||||
.Select(Path.GetFileName)
|
||||
.Where(static name => !string.IsNullOrEmpty(name))
|
||||
.Cast<string>()
|
||||
.OrderBy(static name => name, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
catch (Exception failure)
|
||||
{
|
||||
Console.WriteLine($"keymap: profile list failed: {failure.Message}");
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryLoad(
|
||||
string fileName,
|
||||
KeyBindings baseBindings,
|
||||
out KeyBindings bindings,
|
||||
out string? error)
|
||||
{
|
||||
bindings = baseBindings;
|
||||
error = null;
|
||||
string normalized = NormalizeFileName(fileName);
|
||||
if (normalized.Length == 0)
|
||||
{
|
||||
error = "The keymap filename is invalid.";
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
string text = File.ReadAllText(Path.Combine(_directory, normalized));
|
||||
bindings = RetailKeymapFile.Parse(text, baseBindings);
|
||||
WriteSelector(normalized);
|
||||
return true;
|
||||
}
|
||||
catch (Exception failure)
|
||||
{
|
||||
error = failure.Message;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public RetailKeymapSaveResult Save(
|
||||
string fileName,
|
||||
KeyBindings bindings,
|
||||
bool overwrite)
|
||||
{
|
||||
string normalized = NormalizeFileName(fileName);
|
||||
if (normalized.Length == 0)
|
||||
return new(RetailKeymapSaveStatus.InvalidName, string.Empty);
|
||||
|
||||
string path = Path.Combine(_directory, normalized);
|
||||
try
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
if (!overwrite)
|
||||
return new(RetailKeymapSaveStatus.Exists, normalized);
|
||||
if ((File.GetAttributes(path) & FileAttributes.ReadOnly) != 0)
|
||||
return new(RetailKeymapSaveStatus.ReadOnly, normalized);
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(_directory);
|
||||
AtomicWrite(path, RetailKeymapFile.Write(bindings));
|
||||
WriteSelector(normalized);
|
||||
return new(RetailKeymapSaveStatus.Saved, normalized);
|
||||
}
|
||||
catch (UnauthorizedAccessException failure)
|
||||
{
|
||||
return new(RetailKeymapSaveStatus.ReadOnly, normalized, failure.Message);
|
||||
}
|
||||
catch (Exception failure)
|
||||
{
|
||||
return new(RetailKeymapSaveStatus.Failed, normalized, failure.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public RetailKeymapSaveResult SaveActive(KeyBindings bindings) =>
|
||||
Save(CurrentFileName, bindings, overwrite: true);
|
||||
|
||||
public static KeyBindings LoadActiveOrJson(
|
||||
string jsonPath,
|
||||
out string profileName)
|
||||
{
|
||||
KeyBindings fallback = KeyBindings.LoadOrDefault(jsonPath);
|
||||
var store = new RetailKeymapProfileStore(jsonPath);
|
||||
profileName = store.CurrentFileName;
|
||||
string profilePath = Path.Combine(store.DirectoryPath, profileName);
|
||||
if (!File.Exists(profilePath)) return fallback;
|
||||
if (store.TryLoad(profileName, fallback, out KeyBindings loaded, out string? error))
|
||||
return loaded;
|
||||
Console.WriteLine($"keymap: '{profileName}' could not be loaded; using JSON/defaults: {error}");
|
||||
return fallback;
|
||||
}
|
||||
|
||||
public static string NormalizeFileName(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return string.Empty;
|
||||
string trimmed = value.Trim();
|
||||
if (!string.Equals(trimmed, Path.GetFileName(trimmed), StringComparison.Ordinal))
|
||||
return string.Empty;
|
||||
if (trimmed.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0)
|
||||
return string.Empty;
|
||||
return trimmed.EndsWith(".keymap", StringComparison.OrdinalIgnoreCase)
|
||||
? trimmed
|
||||
: trimmed + ".keymap";
|
||||
}
|
||||
|
||||
private void WriteSelector(string fileName)
|
||||
{
|
||||
string? directory = Path.GetDirectoryName(_selectorPath);
|
||||
if (!string.IsNullOrEmpty(directory)) Directory.CreateDirectory(directory);
|
||||
AtomicWrite(_selectorPath, fileName + Environment.NewLine);
|
||||
}
|
||||
|
||||
private static void AtomicWrite(string path, string content)
|
||||
{
|
||||
string temp = path + ".tmp-" + Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture);
|
||||
try
|
||||
{
|
||||
File.WriteAllText(temp, content, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
|
||||
File.Move(temp, path, overwrite: true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(temp)) File.Delete(temp);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -61,14 +61,6 @@ internal sealed class PlayerInteractionMovementSink(
|
|||
Height = approach.TargetHeight,
|
||||
};
|
||||
|
||||
if (VendorDiagnostics.DumpVendorEnabled)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[vendor-diag] BeginApproach guid=0x{approach.Target.ServerGuid:X8} "
|
||||
+ $"movementType={movement.Type} distanceToObject={parameters.DistanceToObject} "
|
||||
+ $"canCharge={parameters.CanCharge} target=0x{movement.ObjectId:X8}");
|
||||
}
|
||||
|
||||
// PerformMovement cancels at its head. Do it explicitly before the
|
||||
// intent is armed so cancellation of the preceding move cannot clear
|
||||
// the new request; the internal second call is then a retail no-op.
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ internal sealed class SelectionInteractionController
|
|||
private readonly IPlayerInteractionMovementSink _movement;
|
||||
private readonly PlayerApproachCompletionState _approachCompletions;
|
||||
private readonly Action<string>? _toast;
|
||||
private readonly Func<uint, bool>? _splitStack;
|
||||
private readonly Func<IEnumerable<uint>> _fellowshipMembers;
|
||||
|
||||
public SelectionInteractionController(
|
||||
SelectionState selection,
|
||||
|
|
@ -32,7 +34,9 @@ internal sealed class SelectionInteractionController
|
|||
IRuntimeInteractionTransport transport,
|
||||
IPlayerInteractionMovementSink movement,
|
||||
Action<string>? toast = null,
|
||||
PlayerApproachCompletionState? approachCompletions = null)
|
||||
PlayerApproachCompletionState? approachCompletions = null,
|
||||
Func<uint, bool>? splitStack = null,
|
||||
Func<IEnumerable<uint>>? fellowshipMembers = null)
|
||||
{
|
||||
_selection = selection ?? throw new ArgumentNullException(nameof(selection));
|
||||
_query = query ?? throw new ArgumentNullException(nameof(query));
|
||||
|
|
@ -43,14 +47,96 @@ internal sealed class SelectionInteractionController
|
|||
_toast = toast;
|
||||
_approachCompletions = approachCompletions
|
||||
?? new PlayerApproachCompletionState();
|
||||
_splitStack = splitStack;
|
||||
_fellowshipMembers = fellowshipMembers ?? (() => Array.Empty<uint>());
|
||||
}
|
||||
|
||||
public bool HandleInputAction(InputAction action)
|
||||
{
|
||||
switch (action)
|
||||
{
|
||||
case InputAction.SelectionSelf:
|
||||
SelectSelf();
|
||||
return true;
|
||||
case InputAction.SelectionPlaceInInventory:
|
||||
PlaceSelectionInBackpack(mainPack: false);
|
||||
return true;
|
||||
case InputAction.SelectionPlaceInMainPack:
|
||||
PlaceSelectionInBackpack(mainPack: true);
|
||||
return true;
|
||||
case InputAction.SelectionSplitStack:
|
||||
if (_selection.SelectedObjectId is { } stack)
|
||||
_splitStack?.Invoke(stack);
|
||||
return true;
|
||||
case InputAction.SelectionClosestCompassItem:
|
||||
SelectRetailTarget(RetailSelectionKind.CompassItem, RetailSelectionDirection.Closest);
|
||||
return true;
|
||||
case InputAction.SelectionPreviousCompassItem:
|
||||
SelectRetailTarget(RetailSelectionKind.CompassItem, RetailSelectionDirection.Previous);
|
||||
return true;
|
||||
case InputAction.SelectionNextCompassItem:
|
||||
SelectRetailTarget(RetailSelectionKind.CompassItem, RetailSelectionDirection.Next);
|
||||
return true;
|
||||
case InputAction.SelectionClosestItem:
|
||||
SelectRetailTarget(
|
||||
RetailSelectionKind.Item,
|
||||
RetailSelectionDirection.Closest,
|
||||
excludeOwnedByPlayer: true);
|
||||
return true;
|
||||
case InputAction.SelectionPreviousItem:
|
||||
SelectRetailTarget(RetailSelectionKind.Item, RetailSelectionDirection.Previous);
|
||||
return true;
|
||||
case InputAction.SelectionNextItem:
|
||||
SelectRetailTarget(RetailSelectionKind.Item, RetailSelectionDirection.Next);
|
||||
return true;
|
||||
case InputAction.SelectionClosestMonster:
|
||||
SelectClosestCombatTarget(showToast: true);
|
||||
SelectRetailTarget(
|
||||
RetailSelectionKind.Monster,
|
||||
RetailSelectionDirection.Closest,
|
||||
showToast: true);
|
||||
return true;
|
||||
case InputAction.SelectionPreviousMonster:
|
||||
SelectRetailTarget(RetailSelectionKind.Monster, RetailSelectionDirection.Previous);
|
||||
return true;
|
||||
case InputAction.SelectionNextMonster:
|
||||
SelectRetailTarget(RetailSelectionKind.Monster, RetailSelectionDirection.Next);
|
||||
return true;
|
||||
case InputAction.SelectionLastAttacker:
|
||||
if (_query.FindLastAttacker() is { } attacker)
|
||||
_selection.Select(attacker, SelectionChangeSource.Keyboard);
|
||||
return true;
|
||||
case InputAction.SelectionClosestPlayer:
|
||||
SelectRetailTarget(RetailSelectionKind.Player, RetailSelectionDirection.Closest);
|
||||
return true;
|
||||
case InputAction.SelectionPreviousPlayer:
|
||||
SelectRetailTarget(RetailSelectionKind.Player, RetailSelectionDirection.Previous);
|
||||
return true;
|
||||
case InputAction.SelectionNextPlayer:
|
||||
SelectRetailTarget(RetailSelectionKind.Player, RetailSelectionDirection.Next);
|
||||
return true;
|
||||
case InputAction.SelectionPreviousFellow:
|
||||
SelectFellow(previous: true);
|
||||
return true;
|
||||
case InputAction.SelectionNextFellow:
|
||||
SelectFellow(previous: false);
|
||||
return true;
|
||||
case InputAction.SelectionClosestUnopenedCorpse:
|
||||
SelectRetailTarget(RetailSelectionKind.UnopenedCorpse, RetailSelectionDirection.Closest);
|
||||
return true;
|
||||
case InputAction.SelectionNextUnopenedCorpse:
|
||||
SelectRetailTarget(RetailSelectionKind.UnopenedCorpse, RetailSelectionDirection.Next);
|
||||
return true;
|
||||
case InputAction.SelectionUseClosestUnopenedCorpse:
|
||||
SelectAndUseCorpse(RetailSelectionDirection.Closest);
|
||||
return true;
|
||||
case InputAction.SelectionUseNextUnopenedCorpse:
|
||||
SelectAndUseCorpse(RetailSelectionDirection.Next);
|
||||
return true;
|
||||
case InputAction.SelectionGiveToTarget:
|
||||
GiveSelectionToPreviousTarget();
|
||||
return true;
|
||||
case InputAction.SelectionDrop:
|
||||
DropSelection();
|
||||
return true;
|
||||
case InputAction.SelectionPreviousSelection:
|
||||
_selection.SelectPrevious();
|
||||
|
|
@ -87,11 +173,109 @@ internal sealed class SelectionInteractionController
|
|||
case InputAction.EscapeKey when _items.IsAnyTargetModeActive:
|
||||
_items.CancelTargetMode();
|
||||
return true;
|
||||
case InputAction.EscapeKey when _selection.SelectedObjectId is not null:
|
||||
// ClientUISystem::OnAction @0x00564C8E: Escape willingly
|
||||
// loses the current target before it reaches the Gameplay
|
||||
// Options fallback at 0x00564CBF.
|
||||
_selection.Clear(SelectionChangeSource.Keyboard);
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void SelectSelf()
|
||||
{
|
||||
uint playerGuid = _query.PlayerGuid;
|
||||
if (playerGuid == 0u)
|
||||
return;
|
||||
if (_items.OfferPrimaryClick(playerGuid) is not ItemPrimaryClickResult.NotActive)
|
||||
return;
|
||||
_selection.Select(playerGuid, SelectionChangeSource.Keyboard);
|
||||
}
|
||||
|
||||
private void PlaceSelectionInBackpack(bool mainPack)
|
||||
{
|
||||
if (_selection.SelectedObjectId is { } selected)
|
||||
_items.PlaceWorldItemInBackpack(selected, mainPack);
|
||||
}
|
||||
|
||||
private void SelectRetailTarget(
|
||||
RetailSelectionKind kind,
|
||||
RetailSelectionDirection direction,
|
||||
bool excludeOwnedByPlayer = false,
|
||||
bool showToast = false)
|
||||
{
|
||||
uint? anchor = _selection.SelectedObjectId ?? _selection.PreviousObjectId;
|
||||
uint? target = _query.FindSelectionTarget(
|
||||
kind,
|
||||
direction,
|
||||
anchor,
|
||||
excludeOwnedByPlayer);
|
||||
if (target is { } guid)
|
||||
{
|
||||
_selection.Select(guid, SelectionChangeSource.Keyboard);
|
||||
if (showToast)
|
||||
_toast?.Invoke(_query.Describe(guid));
|
||||
}
|
||||
}
|
||||
|
||||
private void SelectAndUseCorpse(RetailSelectionDirection direction)
|
||||
{
|
||||
SelectRetailTarget(RetailSelectionKind.UnopenedCorpse, direction);
|
||||
if (_selection.SelectedObjectId is { } corpse)
|
||||
EnqueueIdentityBound(
|
||||
RuntimeQueuedInteractionKind.Use,
|
||||
corpse,
|
||||
requireLiveEntity: false);
|
||||
}
|
||||
|
||||
private void SelectFellow(bool previous)
|
||||
{
|
||||
uint[] fellows = _fellowshipMembers()
|
||||
.Where(static guid => guid != 0u)
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
if (fellows.Length == 0)
|
||||
return;
|
||||
|
||||
int current = _selection.SelectedObjectId is { } selected
|
||||
? Array.IndexOf(fellows, selected)
|
||||
: -1;
|
||||
int next = previous
|
||||
? (current > 0 ? current - 1 : fellows.Length - 1)
|
||||
: (current >= 0 && current + 1 < fellows.Length ? current + 1 : 0);
|
||||
_selection.Select(fellows[next], SelectionChangeSource.Keyboard);
|
||||
}
|
||||
|
||||
private void GiveSelectionToPreviousTarget()
|
||||
{
|
||||
if (_selection.SelectedObjectId is not { } selected
|
||||
|| _selection.PreviousObjectId is not { } target
|
||||
|| selected == target
|
||||
|| !_query.IsCreature(target))
|
||||
{
|
||||
_toast?.Invoke(
|
||||
"You must select a creature or a character to give that to.\n");
|
||||
return;
|
||||
}
|
||||
|
||||
if (_items.PlaceSelectedIn3D(selected, target))
|
||||
_selection.Select(target, SelectionChangeSource.Keyboard);
|
||||
}
|
||||
|
||||
private void DropSelection()
|
||||
{
|
||||
if (_selection.SelectedObjectId is not { } selected)
|
||||
return;
|
||||
if (!_items.IsOwnedByPlayer(selected))
|
||||
{
|
||||
_toast?.Invoke("You must pick that up first");
|
||||
return;
|
||||
}
|
||||
_items.PlaceSelectedIn3D(selected, targetGuid: 0u);
|
||||
}
|
||||
|
||||
public uint? PickAtCursor(bool includeSelf)
|
||||
=> _query.PickAtCursor(includeSelf);
|
||||
|
||||
|
|
@ -181,11 +365,6 @@ internal sealed class SelectionInteractionController
|
|||
// ships with it.
|
||||
if (useImmediately && !_query.IsWieldedByPlayer(guid))
|
||||
{
|
||||
if (VendorDiagnostics.DumpVendorEnabled)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[vendor-diag] route=dblclick-world PickAndStoreSelection guid=0x{guid:X8} enqueue=Activate");
|
||||
}
|
||||
EnqueueIdentityBound(
|
||||
RuntimeQueuedInteractionKind.Activate,
|
||||
guid,
|
||||
|
|
@ -221,11 +400,6 @@ internal sealed class SelectionInteractionController
|
|||
_toast?.Invoke("Nothing selected");
|
||||
return;
|
||||
}
|
||||
if (VendorDiagnostics.DumpVendorEnabled)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[vendor-diag] route=keyboard-use UseCurrentSelection guid=0x{selected:X8} enqueue=Use");
|
||||
}
|
||||
EnqueueIdentityBound(
|
||||
RuntimeQueuedInteractionKind.Use,
|
||||
selected,
|
||||
|
|
@ -292,22 +466,10 @@ internal sealed class SelectionInteractionController
|
|||
bool ownedByPlayer = _items.IsOwnedByPlayer(serverGuid);
|
||||
bool useable = ownedByPlayer || _query.IsUseable(serverGuid);
|
||||
|
||||
if (VendorDiagnostics.DumpVendorEnabled)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[vendor-diag] RequestUse entry guid=0x{serverGuid:X8} ownedByPlayer={ownedByPlayer} useable={useable}");
|
||||
}
|
||||
|
||||
if (useable
|
||||
&& _query.TryGetApproach(serverGuid, out InteractionApproach approach)
|
||||
&& !approach.IsCloseRange)
|
||||
{
|
||||
if (VendorDiagnostics.DumpVendorEnabled)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[vendor-diag] RequestUse guid=0x{serverGuid:X8} branch=approach-armed useRadius={approach.UseRadius} isCloseRange={approach.IsCloseRange}");
|
||||
}
|
||||
|
||||
// Genuinely out of range (a real walk, not just a turn) —
|
||||
// mirror SendPickup's arrival-gated shape: arm the transaction
|
||||
// on the approach token BEFORE the movement starts (so a
|
||||
|
|
@ -327,17 +489,7 @@ internal sealed class SelectionInteractionController
|
|||
token.ControllerLifetime,
|
||||
token.ApproachGeneration),
|
||||
out _);
|
||||
if (VendorDiagnostics.DumpVendorEnabled)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[vendor-diag] TryArmPostArrivalUse guid=0x{serverGuid:X8} armed={armed} approachToken=({token.ControllerLifetime},{token.ApproachGeneration})");
|
||||
}
|
||||
});
|
||||
if (VendorDiagnostics.DumpVendorEnabled)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[vendor-diag] BeginApproach-result guid=0x{serverGuid:X8} started={started} armed={armed} stopDistance={approach.UseRadius} target=0x{approach.Target.ServerGuid:X8}");
|
||||
}
|
||||
if (!started || !armed)
|
||||
{
|
||||
// Release whatever got captured (or the caller's own
|
||||
|
|
@ -360,11 +512,6 @@ internal sealed class SelectionInteractionController
|
|||
// — keep retail's immediate send; ACE's own "already within use
|
||||
// distance" branch (Player_Move.cs:65-87) calls back synchronously,
|
||||
// so there is no arrival gap to race here.
|
||||
if (VendorDiagnostics.DumpVendorEnabled)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[vendor-diag] RequestUse guid=0x{serverGuid:X8} branch=immediate-dispatch ownedByPlayer={ownedByPlayer} useable={useable}");
|
||||
}
|
||||
RuntimeInteractionDispatchResult result =
|
||||
_transactions.TryDispatchUse(
|
||||
serverGuid,
|
||||
|
|
@ -373,11 +520,6 @@ internal sealed class SelectionInteractionController
|
|||
reservation,
|
||||
_transport,
|
||||
out uint sequence);
|
||||
if (VendorDiagnostics.DumpVendorEnabled)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[vendor-diag] RequestUse guid=0x{serverGuid:X8} TryDispatchUse verdict={result} seq={sequence}");
|
||||
}
|
||||
if (result == RuntimeInteractionDispatchResult.NotInWorld)
|
||||
_toast?.Invoke("Not in world");
|
||||
if (result == RuntimeInteractionDispatchResult.Dispatched)
|
||||
|
|
@ -609,22 +751,6 @@ internal sealed class SelectionInteractionController
|
|||
RuntimePendingUse pending,
|
||||
bool accepted)
|
||||
{
|
||||
if (VendorDiagnostics.DumpVendorEnabled)
|
||||
{
|
||||
// Diagnostic-only re-query — TryGetApproach is a pure read with
|
||||
// no side effects, so an extra call here (gated off in
|
||||
// production) cannot change RequestUse's own dispatch outcome.
|
||||
string distanceText = "n/a";
|
||||
if (_query.TryGetApproach(pending.ServerGuid, out InteractionApproach diagApproach))
|
||||
{
|
||||
float dx = diagApproach.Target.Entity.Position.X - diagApproach.Player.Position.X;
|
||||
float dy = diagApproach.Target.Entity.Position.Y - diagApproach.Player.Position.Y;
|
||||
distanceText = MathF.Sqrt(dx * dx + dy * dy).ToString("F2");
|
||||
}
|
||||
Console.WriteLine(
|
||||
$"[vendor-diag] HandleUseApproachCompletion guid=0x{pending.ServerGuid:X8} accepted={accepted} playerToTargetDist={distanceText}");
|
||||
}
|
||||
|
||||
if (!accepted)
|
||||
{
|
||||
pending.Reservation?.CancelBeforeDispatch();
|
||||
|
|
|
|||
|
|
@ -6,7 +6,9 @@ using AcDream.Core.Combat;
|
|||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Net;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Properties;
|
||||
using AcDream.Core.Selection;
|
||||
using AcDream.Core.Ui;
|
||||
using AcDream.Core.World;
|
||||
|
||||
namespace AcDream.App.Interaction;
|
||||
|
|
@ -25,6 +27,22 @@ internal readonly record struct WorldInteractionTarget(
|
|||
|
||||
internal readonly record struct ClosestCombatTarget(uint ServerGuid, float DistanceSquared);
|
||||
|
||||
internal enum RetailSelectionKind
|
||||
{
|
||||
Item,
|
||||
CompassItem,
|
||||
Monster,
|
||||
Player,
|
||||
UnopenedCorpse,
|
||||
}
|
||||
|
||||
internal enum RetailSelectionDirection
|
||||
{
|
||||
Closest,
|
||||
Previous,
|
||||
Next,
|
||||
}
|
||||
|
||||
internal readonly record struct InteractionApproach(
|
||||
WorldInteractionTarget Target,
|
||||
PlayerInteractionPose Player,
|
||||
|
|
@ -36,6 +54,7 @@ internal readonly record struct InteractionApproach(
|
|||
|
||||
internal interface IWorldSelectionQuery
|
||||
{
|
||||
uint PlayerGuid => 0u;
|
||||
uint? PickAtCursor(bool includeSelf);
|
||||
uint? PickAt(float mouseX, float mouseY, bool includeSelf);
|
||||
void BeginLightingPulse(uint serverGuid);
|
||||
|
|
@ -46,6 +65,16 @@ internal interface IWorldSelectionQuery
|
|||
bool IsHostileMonster(uint serverGuid);
|
||||
bool IsAttackableTarget(uint serverGuid);
|
||||
ClosestCombatTarget? FindClosestHostileMonster();
|
||||
uint? FindSelectionTarget(
|
||||
RetailSelectionKind kind,
|
||||
RetailSelectionDirection direction,
|
||||
uint? anchor,
|
||||
bool excludeOwnedByPlayer = false) =>
|
||||
kind == RetailSelectionKind.Monster
|
||||
&& direction == RetailSelectionDirection.Closest
|
||||
? FindClosestHostileMonster()?.ServerGuid
|
||||
: null;
|
||||
uint? FindLastAttacker() => null;
|
||||
bool IsUseable(uint serverGuid);
|
||||
bool IsPickupable(uint serverGuid);
|
||||
bool IsWieldedByPlayer(uint serverGuid);
|
||||
|
|
@ -111,6 +140,9 @@ internal sealed class WorldSelectionQuery
|
|||
private readonly Func<uint, WorldEntity, (float Radius, float Height)> _setupCylinder;
|
||||
private readonly Func<uint, (Vector3 Origin, float Radius)?> _selectionSphere;
|
||||
private readonly Func<uint, Matrix4x4?> _childRootPose;
|
||||
private readonly Func<uint, bool> _hasOpenedCorpse;
|
||||
private readonly Func<CombatMode> _combatMode;
|
||||
private readonly Func<uint, bool> _isFellow;
|
||||
|
||||
public WorldSelectionQuery(
|
||||
LiveEntityRuntime liveEntities,
|
||||
|
|
@ -122,7 +154,10 @@ internal sealed class WorldSelectionQuery
|
|||
Func<PlayerInteractionPose?> playerPose,
|
||||
Func<uint, WorldEntity, (float Radius, float Height)> setupCylinder,
|
||||
Func<uint, (Vector3 Origin, float Radius)?> selectionSphere,
|
||||
Func<uint, Matrix4x4?> childRootPose)
|
||||
Func<uint, Matrix4x4?> childRootPose,
|
||||
Func<uint, bool>? hasOpenedCorpse = null,
|
||||
Func<CombatMode>? combatMode = null,
|
||||
Func<uint, bool>? isFellow = null)
|
||||
{
|
||||
_liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities));
|
||||
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
|
||||
|
|
@ -134,8 +169,13 @@ internal sealed class WorldSelectionQuery
|
|||
_setupCylinder = setupCylinder ?? throw new ArgumentNullException(nameof(setupCylinder));
|
||||
_selectionSphere = selectionSphere ?? throw new ArgumentNullException(nameof(selectionSphere));
|
||||
_childRootPose = childRootPose ?? throw new ArgumentNullException(nameof(childRootPose));
|
||||
_hasOpenedCorpse = hasOpenedCorpse ?? (_ => false);
|
||||
_combatMode = combatMode ?? (() => CombatMode.NonCombat);
|
||||
_isFellow = isFellow ?? (_ => false);
|
||||
}
|
||||
|
||||
public uint PlayerGuid => _playerGuid();
|
||||
|
||||
public uint? PickAtCursor(bool includeSelf)
|
||||
{
|
||||
Vector2 cursor = _cursor();
|
||||
|
|
@ -293,6 +333,183 @@ internal sealed class WorldSelectionQuery
|
|||
return best;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Port of retail <c>CPlayerSystem::SelectNext @ 0x0055F9A0</c>. The
|
||||
/// ordering scalar is the retail player-space horizontal distance plus
|
||||
/// <c>1.2 * abs(z)</c>; the object id breaks exact-distance ties through
|
||||
/// <c>CPlayerSystem::Farther @ 0x0055D830</c>. Previous/next wrap exactly
|
||||
/// as the paired calls in <c>CPlayerSystem::OnAction @ 0x00561890</c>.
|
||||
/// </summary>
|
||||
public uint? FindSelectionTarget(
|
||||
RetailSelectionKind kind,
|
||||
RetailSelectionDirection direction,
|
||||
uint? anchor,
|
||||
bool excludeOwnedByPlayer = false)
|
||||
{
|
||||
uint playerGuid = _playerGuid();
|
||||
if (!_liveEntities.TryGetWorldEntity(playerGuid, out WorldEntity player))
|
||||
return null;
|
||||
|
||||
float radarRadius = IsOutdoorCell(player.VisibilityCellId)
|
||||
? RetailRadar.OutdoorRangeMeters
|
||||
: RetailRadar.IndoorRangeMeters;
|
||||
var candidates = new List<(uint Guid, float Order)>();
|
||||
foreach (LiveEntityRecord record in _liveEntities.VisibleRecords)
|
||||
{
|
||||
uint guid = record.ServerGuid;
|
||||
if (guid == 0u
|
||||
|| guid == playerGuid
|
||||
|| record.WorldEntity is not { } entity
|
||||
|| _objects.Get(guid) is not { } obj
|
||||
|| (excludeOwnedByPlayer
|
||||
&& _objects.IsOwnedByObject(guid, playerGuid)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
float order = SelectionOrder(player, entity);
|
||||
if (order > radarRadius
|
||||
|| !MatchesSelectionKind(kind, guid, obj, record.FinalPhysicsState))
|
||||
continue;
|
||||
candidates.Add((guid, order));
|
||||
}
|
||||
|
||||
if (candidates.Count == 0)
|
||||
return null;
|
||||
candidates.Sort(static (left, right) =>
|
||||
{
|
||||
int distance = left.Order.CompareTo(right.Order);
|
||||
return distance != 0 ? distance : left.Guid.CompareTo(right.Guid);
|
||||
});
|
||||
|
||||
if (direction == RetailSelectionDirection.Closest)
|
||||
return candidates[0].Guid;
|
||||
|
||||
(float Order, uint Guid)? anchorKey = null;
|
||||
if (anchor is { } anchorGuid
|
||||
&& _liveEntities.TryGetWorldEntity(anchorGuid, out WorldEntity anchorEntity))
|
||||
{
|
||||
anchorKey = (SelectionOrder(player, anchorEntity), anchorGuid);
|
||||
}
|
||||
|
||||
if (anchorKey is null)
|
||||
{
|
||||
return direction == RetailSelectionDirection.Previous
|
||||
? candidates[^1].Guid
|
||||
: candidates[0].Guid;
|
||||
}
|
||||
|
||||
if (direction == RetailSelectionDirection.Next)
|
||||
{
|
||||
foreach ((uint guid, float order) in candidates)
|
||||
{
|
||||
if (CompareSelectionKey(order, guid, anchorKey.Value.Order, anchorKey.Value.Guid) > 0)
|
||||
return guid;
|
||||
}
|
||||
return candidates[0].Guid;
|
||||
}
|
||||
|
||||
for (int i = candidates.Count - 1; i >= 0; i--)
|
||||
{
|
||||
(uint guid, float order) = candidates[i];
|
||||
if (CompareSelectionKey(order, guid, anchorKey.Value.Order, anchorKey.Value.Guid) < 0)
|
||||
return guid;
|
||||
}
|
||||
return candidates[^1].Guid;
|
||||
}
|
||||
|
||||
public uint? FindLastAttacker()
|
||||
{
|
||||
uint playerGuid = _playerGuid();
|
||||
uint attacker = 0u;
|
||||
if (_objects.Get(playerGuid) is not { } playerObject
|
||||
|| !playerObject.Properties.InstanceIds.TryGetValue(
|
||||
(uint)PropertyInstanceId.CurrentAttacker,
|
||||
out attacker)
|
||||
|| attacker == 0u
|
||||
|| !_liveEntities.TryGetWorldEntity(playerGuid, out WorldEntity player)
|
||||
|| !_liveEntities.TryGetWorldEntity(attacker, out WorldEntity target))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
float radarRadius = IsOutdoorCell(player.VisibilityCellId)
|
||||
? RetailRadar.OutdoorRangeMeters
|
||||
: RetailRadar.IndoorRangeMeters;
|
||||
return SelectionOrder(player, target) <= radarRadius ? attacker : null;
|
||||
}
|
||||
|
||||
private bool MatchesSelectionKind(
|
||||
RetailSelectionKind kind,
|
||||
uint guid,
|
||||
ClientObject obj,
|
||||
PhysicsStateFlags physicsState)
|
||||
{
|
||||
bool showableOnRadar = obj.RadarBehavior is { } behavior
|
||||
&& RetailRadar.IsShowable((RadarBehavior)behavior, hasPhysicsObject: true);
|
||||
PublicWeenieFlags flags = (PublicWeenieFlags)(obj.PublicWeenieBitfield ?? 0u);
|
||||
bool isFellow = _isFellow(guid);
|
||||
bool isCombatCompass = _combatMode() is CombatMode.Melee or CombatMode.Missile;
|
||||
bool isSpecialCompassObject = (flags
|
||||
& (PublicWeenieFlags.Lifestone
|
||||
| PublicWeenieFlags.Portal
|
||||
| PublicWeenieFlags.Bindstone)) != 0;
|
||||
|
||||
// The common tail of CPlayerSystem::SelectNext rejects every object
|
||||
// currently inside a container, every cloaked physics object, and a
|
||||
// PWD carrying the reserved sign bit, independent of selection kind.
|
||||
if (obj.ContainerId != 0u
|
||||
|| (physicsState & PhysicsStateFlags.Cloaked) != 0
|
||||
|| (((uint)flags & 0x8000_0000u) != 0))
|
||||
return false;
|
||||
|
||||
return kind switch
|
||||
{
|
||||
RetailSelectionKind.Item =>
|
||||
obj.RadarBehavior is null or 0
|
||||
|| isSpecialCompassObject,
|
||||
RetailSelectionKind.CompassItem =>
|
||||
(isSpecialCompassObject || showableOnRadar)
|
||||
&& (!isCombatCompass
|
||||
|| (IsAttackableTarget(guid)
|
||||
&& !isFellow
|
||||
&& (flags & PublicWeenieFlags.Vendor) == 0
|
||||
&& (physicsState & PhysicsStateFlags.ReportAsEnvironment) == 0)),
|
||||
RetailSelectionKind.Monster =>
|
||||
showableOnRadar
|
||||
&& IsAttackableTarget(guid)
|
||||
&& !isFellow
|
||||
&& (flags & PublicWeenieFlags.Vendor) == 0,
|
||||
RetailSelectionKind.Player =>
|
||||
showableOnRadar && (flags & PublicWeenieFlags.Player) != 0,
|
||||
RetailSelectionKind.UnopenedCorpse =>
|
||||
(flags & PublicWeenieFlags.Corpse) != 0
|
||||
&& !_hasOpenedCorpse(guid),
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
private static float SelectionOrder(WorldEntity player, WorldEntity target)
|
||||
{
|
||||
Vector3 delta = target.Position - player.Position;
|
||||
Vector3 local = Vector3.Transform(delta, Quaternion.Inverse(player.Rotation));
|
||||
return MathF.Sqrt(local.X * local.X + local.Y * local.Y)
|
||||
+ MathF.Abs(local.Z) * 1.2f;
|
||||
}
|
||||
|
||||
private static int CompareSelectionKey(
|
||||
float leftOrder,
|
||||
uint leftGuid,
|
||||
float rightOrder,
|
||||
uint rightGuid)
|
||||
{
|
||||
int order = leftOrder.CompareTo(rightOrder);
|
||||
return order != 0 ? order : leftGuid.CompareTo(rightGuid);
|
||||
}
|
||||
|
||||
private static bool IsOutdoorCell(uint? cellId)
|
||||
=> cellId is null || (cellId.Value & 0xFFFFu) < 0x100u;
|
||||
|
||||
/// <summary>
|
||||
/// #298 follow-up: retail <c>ClientCombatSystem::UpdateTargetTracking
|
||||
/// @ 0x0056A950</c> (pc:375691-375696) gates <c>CameraSet::TrackTarget</c>
|
||||
|
|
@ -571,12 +788,6 @@ internal sealed class WorldSelectionQuery
|
|||
bool haveSpawn = _liveEntities.TryGetSnapshot(serverGuid, out var spawn);
|
||||
bool fromWire = haveSpawn && spawn.UseRadius is > 0f;
|
||||
float radius = fromWire ? spawn.UseRadius!.Value : DefaultUseRadius;
|
||||
if (VendorDiagnostics.DumpVendorEnabled)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[vendor-diag] GetUseRadius guid=0x{serverGuid:X8} radius={radius} "
|
||||
+ $"source={(fromWire ? "wire" : "fallback-0.6")}");
|
||||
}
|
||||
return radius;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
81
src/AcDream.App/Net/DatChatPoseCatalog.cs
Normal file
81
src/AcDream.App/Net/DatChatPoseCatalog.cs
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
using AcDream.Runtime.Chat;
|
||||
using AcDream.Content;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DatMotionCommand = DatReaderWriter.Enums.MotionCommand;
|
||||
|
||||
namespace AcDream.App.Net;
|
||||
|
||||
/// <summary>
|
||||
/// Immutable projection of retail's portal-DAT ChatPoseTable (0x0E000007).
|
||||
/// Command lookup is case-insensitive, matching
|
||||
/// <c>ChatPoseTable::InqChatPoseCommand @ 0x00570AD0</c>.
|
||||
/// </summary>
|
||||
internal sealed class DatChatPoseCatalog
|
||||
{
|
||||
private const uint ChatPoseTableId = 0x0E000007u;
|
||||
private readonly IReadOnlyDictionary<string, RetailChatPose> _poses;
|
||||
|
||||
private DatChatPoseCatalog(
|
||||
IReadOnlyDictionary<string, RetailChatPose> poses) =>
|
||||
_poses = poses;
|
||||
|
||||
public static DatChatPoseCatalog Load(IDatReaderWriter dats, object datLock)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(dats);
|
||||
ArgumentNullException.ThrowIfNull(datLock);
|
||||
lock (datLock)
|
||||
{
|
||||
ChatPoseTable? table = dats.Get<ChatPoseTable>(ChatPoseTableId);
|
||||
if (table is null)
|
||||
return new DatChatPoseCatalog(
|
||||
new Dictionary<string, RetailChatPose>(
|
||||
StringComparer.OrdinalIgnoreCase));
|
||||
|
||||
var emotes = new Dictionary<string, (string Self, string Others)>(
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var pair in table.ChatEmotes)
|
||||
{
|
||||
emotes[pair.Key.Value] = (
|
||||
pair.Value.MyEmote.Value,
|
||||
pair.Value.OtherEmote.Value);
|
||||
}
|
||||
|
||||
var poses = new Dictionary<string, RetailChatPose>(
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var pair in table.ChatPoses)
|
||||
{
|
||||
string command = pair.Key.Value;
|
||||
string motionName = pair.Value.Value;
|
||||
if (string.IsNullOrEmpty(command)
|
||||
|| !Enum.TryParse(
|
||||
motionName,
|
||||
ignoreCase: true,
|
||||
out DatMotionCommand motion))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
emotes.TryGetValue(motionName, out var text);
|
||||
poses[command] = new RetailChatPose(
|
||||
(uint)motion,
|
||||
text.Self ?? string.Empty,
|
||||
text.Others ?? string.Empty);
|
||||
}
|
||||
return new DatChatPoseCatalog(poses);
|
||||
}
|
||||
}
|
||||
|
||||
public RetailChatPose? Resolve(string command, bool male)
|
||||
{
|
||||
if (!_poses.TryGetValue(command, out RetailChatPose pose))
|
||||
return null;
|
||||
string possessive = male ? "his" : "her";
|
||||
return pose with
|
||||
{
|
||||
OthersText = pose.OthersText.Replace(
|
||||
"%p",
|
||||
possessive,
|
||||
StringComparison.Ordinal),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -36,11 +36,17 @@ internal sealed class LiveSessionAppSource
|
|||
/// retained UI may keep this surface, while the displaced route itself becomes
|
||||
/// inert before inbound subscriptions detach.
|
||||
/// </summary>
|
||||
internal sealed class LiveSessionCommandSurface : ICommandBus
|
||||
internal sealed class LiveSessionCommandSurface : IPluginCommandBus
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
private readonly Func<string, bool>? _tryHandlePluginCommand;
|
||||
private LiveSessionCommandRouter? _active;
|
||||
|
||||
public LiveSessionCommandSurface(Func<string, bool>? tryHandlePluginCommand = null)
|
||||
{
|
||||
_tryHandlePluginCommand = tryHandlePluginCommand;
|
||||
}
|
||||
|
||||
public ILiveSessionCommandRouting Attach(LiveSessionCommandRouter route)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(route);
|
||||
|
|
@ -65,6 +71,9 @@ internal sealed class LiveSessionCommandSurface : ICommandBus
|
|||
route?.Publish(command);
|
||||
}
|
||||
|
||||
public bool TryHandlePluginCommand(string commandLine) =>
|
||||
_tryHandlePluginCommand?.Invoke(commandLine) == true;
|
||||
|
||||
private void Release(LiveSessionCommandRouter expected)
|
||||
{
|
||||
expected.Dispose();
|
||||
|
|
|
|||
|
|
@ -55,6 +55,10 @@ internal sealed record LiveSessionCommandBindings(
|
|||
// research §2.3-§2.7. No-ops when the batched module is clean, matching
|
||||
// retail's CPlayerModule::SaveToServer(force: 0).
|
||||
Action SaveCharacterOptions,
|
||||
// Campaign CT slice CT2 (2026-08-24): TitleSet (0x002C) — sends only, no
|
||||
// local mutation (RuntimeCharacterState.Titles updates from the
|
||||
// server's own echo).
|
||||
Action<uint> SendSetTitle,
|
||||
// Campaign FA slice FA2 (2026-08-12): fellowship + allegiance send
|
||||
// wrappers, the App-bus twin of DirectGameRuntimeCommandAdapter's
|
||||
// direct session.SendXxx calls.
|
||||
|
|
@ -70,7 +74,10 @@ internal sealed record LiveSessionCommandBindings(
|
|||
Action<uint> SendAllegianceKick,
|
||||
Action<string> SendAllegianceInfoRequest,
|
||||
Action<bool> SendAllegianceUpdateRequest,
|
||||
Action<string>? Log = null);
|
||||
Action<string>? Log = null,
|
||||
Func<string, RetailChatPose?>? ResolvePose = null,
|
||||
Action<uint>? ExecuteMotion = null,
|
||||
Action<string>? SendSoulEmote = null);
|
||||
|
||||
internal readonly record struct AddShortcutRuntimeCmd(ShortcutEntry Entry);
|
||||
internal readonly record struct RemoveShortcutRuntimeCmd(uint Index);
|
||||
|
|
@ -95,6 +102,7 @@ internal readonly record struct SetSingleCharacterOptionRuntimeCmd(
|
|||
uint OptionId,
|
||||
bool Value);
|
||||
internal readonly record struct SaveCharacterOptionsRuntimeCmd;
|
||||
internal readonly record struct SetTitleRuntimeCmd(uint TitleId);
|
||||
internal readonly record struct AddFriendRuntimeCmd(string Name);
|
||||
internal readonly record struct RemoveFriendRuntimeCmd(uint CharacterId);
|
||||
|
||||
|
|
@ -180,7 +188,10 @@ internal sealed class LiveSessionCommandRouter : ILiveSessionCommandRouting
|
|||
bindings.SendTell,
|
||||
bindings.SendChannel,
|
||||
bindings.SendTurbineChat,
|
||||
bindings.Log));
|
||||
bindings.Log,
|
||||
bindings.ResolvePose,
|
||||
bindings.ExecuteMotion,
|
||||
bindings.SendSoulEmote));
|
||||
// Campaign CH slice CH4 (2026-08-09): the 22 unregistered
|
||||
// ChannelSystem::GetChannelID fallback tags — bypasses
|
||||
// ChatChannelKind/ChannelResolver entirely and sends the raw
|
||||
|
|
@ -228,6 +239,8 @@ internal sealed class LiveSessionCommandRouter : ILiveSessionCommandRouting
|
|||
command.Value)));
|
||||
commands.Register<SaveCharacterOptionsRuntimeCmd>(
|
||||
_ => SendIfActive(bindings.SaveCharacterOptions));
|
||||
commands.Register<SetTitleRuntimeCmd>(
|
||||
command => SendIfActive(() => bindings.SendSetTitle(command.TitleId)));
|
||||
commands.Register<AddFriendRuntimeCmd>(
|
||||
command => SendIfActive(() => bindings.AddFriend(command.Name)));
|
||||
commands.Register<OpenTradeNegotiationsRuntimeCmd>(
|
||||
|
|
@ -370,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?)),
|
||||
|
|
@ -430,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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ internal sealed record LiveSessionInteractionRuntime(
|
|||
|
||||
internal sealed record LiveSessionWorldRuntime(
|
||||
IDatReaderWriter Dats,
|
||||
object DatLock,
|
||||
// Logout-audio round (2026-08-17): null only when audio is disabled
|
||||
// (ACDREAM_NO_AUDIO / init failure) — the reset step and entered-world
|
||||
// resume both no-op then.
|
||||
|
|
@ -114,6 +115,7 @@ internal sealed class LiveSessionRuntimeFactory
|
|||
private readonly IReadOnlyList<string> _loginCommands;
|
||||
private readonly TimeSpan _loginCommandDelay;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly DatChatPoseCatalog _chatPoses;
|
||||
|
||||
/// <summary>
|
||||
/// Where a bare <c>@log</c> filename lands. See <see cref="ChatSessionLog"/>
|
||||
|
|
@ -162,6 +164,7 @@ internal sealed class LiveSessionRuntimeFactory
|
|||
_loginCommands = loginCommands is null ? [] : [.. loginCommands];
|
||||
_loginCommandDelay = TimeSpan.FromMilliseconds(loginCommandDelayMs);
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_chatPoses = DatChatPoseCatalog.Load(_world.Dats, _world.DatLock);
|
||||
// C3c-F1: stat recomputes route through the Runtime movement owner's
|
||||
// typed application seam; App keeps zero direct controller mutations.
|
||||
_movementStats = new LiveMovementStatsApplier(
|
||||
|
|
@ -217,6 +220,15 @@ internal sealed class LiveSessionRuntimeFactory
|
|||
RestoreLayout: () =>
|
||||
{
|
||||
_ui.RetailUi?.RestoreLayout();
|
||||
// The retained inventory controller exists before the
|
||||
// character object graph is complete. Rebuild its open
|
||||
// container once EnteredWorld makes that graph
|
||||
// authoritative, otherwise the already-open main pack can
|
||||
// keep the empty construction-time cells until the user
|
||||
// switches packs. Redress the private doll at the same
|
||||
// character-complete edge.
|
||||
_ui.RetailUi?.InventoryPanelController?.Populate();
|
||||
_ui.Paperdoll?.MarkDirty();
|
||||
// MUST-FIX 3 re-fix (FA4 re-review REOPEN): re-declare a
|
||||
// still-open Fellowship page's 0x00A6 now we are in world —
|
||||
// RestoreLayout is the post-world UI-restore moment, and
|
||||
|
|
@ -402,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,
|
||||
|
|
@ -443,6 +456,7 @@ internal sealed class LiveSessionRuntimeFactory
|
|||
OnUseDone: error =>
|
||||
{
|
||||
_domain.Inventory.ExternalContainers.ApplyUseDone(error);
|
||||
_domain.Actions.SpellCast.CompleteUse(error);
|
||||
_domain.Actions.Transactions.CompleteUse(error);
|
||||
},
|
||||
_domain.Inventory.ItemMana,
|
||||
|
|
@ -603,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 —
|
||||
|
|
@ -715,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,
|
||||
|
|
@ -770,6 +846,8 @@ internal sealed class LiveSessionRuntimeFactory
|
|||
CharacterState: _domain.Character,
|
||||
SendSingleCharacterOption: SendSingleCharacterOption,
|
||||
SaveCharacterOptions: SaveCharacterOptionsIfDirty,
|
||||
// Campaign CT slice CT2 (2026-08-24).
|
||||
SendSetTitle: session.SendSetTitle,
|
||||
// Campaign FA slice FA2 (2026-08-12): fellowship + allegiance send
|
||||
// wrappers, matching the WorldSession.SendXxx methods FA2 added.
|
||||
SendFellowshipCreate: session.SendFellowshipCreate,
|
||||
|
|
@ -784,7 +862,14 @@ internal sealed class LiveSessionRuntimeFactory
|
|||
SendAllegianceKick: session.SendAllegianceKick,
|
||||
SendAllegianceInfoRequest: session.SendAllegianceInfoRequest,
|
||||
SendAllegianceUpdateRequest: session.SendAllegianceUpdateRequest,
|
||||
Log: _log);
|
||||
Log: _log,
|
||||
ResolvePose: command => _chatPoses.Resolve(
|
||||
command,
|
||||
male: _domain.EntityObjects.Objects
|
||||
.Get(_player.Identity.ServerGuid)?
|
||||
.Properties.GetInt(0x71u) == 1),
|
||||
ExecuteMotion: motion => _player.Controller.ExecuteMotion(motion),
|
||||
SendSoulEmote: session.SendSoulEmote);
|
||||
}
|
||||
|
||||
private static double ClientTimerNow() =>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -186,12 +238,19 @@ internal static class RetailSkillFormula
|
|||
/// <summary>
|
||||
/// Retail <c>SkillInfoRegion::GetTooltip @ 0x004f1fe0</c>, called once
|
||||
/// from <c>SkillInfoRegion::SkillInfoRegion @ 0x004f2140</c>'s
|
||||
/// <c>UIElement::SetTooltip</c> at 0x004f222f. Composition is exactly
|
||||
/// <c>"\n" + formula + description</c> — retail concatenates the
|
||||
/// description directly onto the formula line with NO separator between
|
||||
/// them (ported verbatim, not "fixed": <c>append_n_chars</c> runs
|
||||
/// immediately after the formula assignment with no intervening
|
||||
/// literal). <c>SkillSystem::InqSkillDescription @ 0x005c8770</c> reads
|
||||
/// <c>UIElement::SetTooltip</c> at 0x004f222f. Composition is
|
||||
/// <c>formula + "\n" + description</c> — CORRECTED 2026-08-24 at the
|
||||
/// CA5 gate: the original Batch-B reading ("\n" + formula, no
|
||||
/// separator) had the <c>operator+</c> operand order backwards
|
||||
/// (<c>PVar3 = operator+(&local_c, ...)</c> — <c>local_c</c>, the
|
||||
/// InqSkillFormula output, is the LEFT operand; the "\n" literal is the
|
||||
/// right), and the owner's retail-client render confirms: formula on
|
||||
/// its own first line, description below (wrapping to further lines
|
||||
/// when the DAT text is long). A formula-less skill (Salvaging) shows
|
||||
/// the bare description with no leading break, matching retail's
|
||||
/// failed-<c>InqSkillFormula</c> branch, which appends the description
|
||||
/// to the still-empty output.
|
||||
/// <c>SkillSystem::InqSkillDescription @ 0x005c8770</c> reads
|
||||
/// <c>SkillBase._description</c> — the same DAT field
|
||||
/// <see cref="DatReaderWriter.Types.SkillBase.Description"/> already
|
||||
/// exposes, so no hand-transcription was needed for the ~30+ skill
|
||||
|
|
@ -204,7 +263,7 @@ internal static class RetailSkillFormula
|
|||
|
||||
string? formula = FormatFormula(skillBase.Formula);
|
||||
string description = skillBase.Description.Value ?? string.Empty;
|
||||
string tooltip = (formula is null ? string.Empty : "\n" + formula) + description;
|
||||
string tooltip = (formula is null ? string.Empty : formula + "\n") + description;
|
||||
return tooltip.Length == 0 ? null : tooltip;
|
||||
}
|
||||
}
|
||||
|
|
@ -250,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
|
||||
|
|
@ -263,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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -229,14 +229,6 @@ internal sealed class LiveEntityMotionRuntimeController
|
|||
// CObjectMaint's object table and must still resolve here.
|
||||
if (liveEntities.IsHidden(id))
|
||||
{
|
||||
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[autowalk-host-miss] object=0x{id:X8} "
|
||||
+ $"materialized={_liveEntities.ContainsWorldEntity(id)} "
|
||||
+ $"registered={liveEntities.TryGetPhysicsHost(id, out _)} "
|
||||
+ $"hidden={liveEntities.IsHidden(id)}");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (liveEntities.TryGetPhysicsHost(id, out var existing))
|
||||
|
|
@ -494,26 +486,6 @@ internal sealed class LiveEntityMotionRuntimeController
|
|||
}
|
||||
}
|
||||
movement.PerformMovement(ms);
|
||||
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
|
||||
{
|
||||
string target = turnPath.TargetGuid is { } targetGuid
|
||||
? $"0x{targetGuid:X8}" : "null";
|
||||
bool targetVisible = turnPath.TargetGuid is { } visibleGuid
|
||||
&& _liveEntities.TryGetInteractionEligibleEntity(
|
||||
visibleGuid,
|
||||
out _);
|
||||
bool targetHost = turnPath.TargetGuid is { } hostGuid
|
||||
&& _liveEntities?.TryGetPhysicsHost(hostGuid, out _) == true;
|
||||
var moveTo = movement.MoveTo;
|
||||
Console.WriteLine(
|
||||
$"[autowalk-turn-route] wire=0x{update.MotionState.MovementType:X2} "
|
||||
+ $"routed={ms.Type} target={target} visible={targetVisible} "
|
||||
+ $"host={targetHost} stop={mp.StopCompletelyFlag} "
|
||||
+ $"initialized={moveTo?.Initialized ?? false} "
|
||||
+ $"nodes={moveTo?.PendingActions.Count() ?? 0} "
|
||||
+ $"command=0x{moveTo?.CurrentCommand ?? 0u:X8} "
|
||||
+ $"pendingMotions={movement.Minterp.MotionsPending()}");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -619,28 +619,6 @@ internal sealed class LiveEntityNetworkUpdateController
|
|||
// the exact swing and carries it in Commands[].
|
||||
if (update.Guid == _playerServerGuid)
|
||||
{
|
||||
// B.6 slice 1 (2026-05-14): trace inbound motion for the
|
||||
// local player. One line per inbound UM, gated on
|
||||
// ACDREAM_PROBE_AUTOWALK=1 (name kept through R4-V5).
|
||||
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
|
||||
{
|
||||
string cmdHex = command.HasValue ? $"0x{command.Value:X4}" : "null";
|
||||
string pathStr = update.MotionState.MoveToPath is { } p
|
||||
? $"path=cell=0x{p.OriginCellId:X8},xyz=({p.OriginX:F2},{p.OriginY:F2},{p.OriginZ:F2}),minDist={p.MinDistance:F2},objDist={p.DistanceToObject:F2}"
|
||||
: "path=null";
|
||||
string spd = update.MotionState.ForwardSpeed is { } fs
|
||||
? $"fwdSpd={fs:F2}"
|
||||
: "fwdSpd=null";
|
||||
string mtsSpd = update.MotionState.MoveToSpeed is { } ms
|
||||
? $"mtSpd={ms:F2}"
|
||||
: "mtSpd=null";
|
||||
string mtsRun = update.MotionState.MoveToRunRate is { } mr
|
||||
? $"mtRun={mr:F2}"
|
||||
: "mtRun=null";
|
||||
Console.WriteLine(System.FormattableString.Invariant(
|
||||
$"[autowalk-mt] stance=0x{stance:X4} cmd={cmdHex} mt=0x{update.MotionState.MovementType:X2} isMoveTo={update.MotionState.IsServerControlledMoveTo} moveTowards={update.MotionState.MoveTowards} {pathStr} {spd} {mtsSpd} {mtsRun}"));
|
||||
}
|
||||
|
||||
// R4-V5: retail unpack_movement dispatch for the local
|
||||
// player — the SAME shape the remote branch uses below.
|
||||
// Head (@300566): interrupt + unstick fire for EVERY
|
||||
|
|
@ -700,11 +678,6 @@ internal sealed class LiveEntityNetworkUpdateController
|
|||
}
|
||||
if (localDispatch.RoutedMoveTo)
|
||||
{
|
||||
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
|
||||
{
|
||||
Console.WriteLine(System.FormattableString.Invariant(
|
||||
$"[autowalk-begin] mt=0x{update.MotionState.MovementType:X2} movingTo={_playerController.Movement.IsMovingTo()} type={_playerController.MoveTo?.MovementTypeState}"));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!localDispatch.AppliedInterpretedState)
|
||||
|
|
@ -1150,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>
|
||||
|
|
@ -1965,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)
|
||||
|
|
@ -2128,21 +2098,6 @@ internal sealed class LiveEntityNetworkUpdateController
|
|||
if (update.Guid == _playerServerGuid)
|
||||
_authorityGate.ObserveAcceptedLocalPosition(update.Position.LandblockId);
|
||||
|
||||
// B.6 slice 1 (2026-05-14): trace inbound UpdatePosition cadence for
|
||||
// the local player. Combined with [autowalk-mt] this answers
|
||||
// whether ACE's broadcast frequency during a server-initiated
|
||||
// auto-walk is dense enough to drive smooth visible motion (the
|
||||
// Option C viability check from the design spec). Gated on
|
||||
// ACDREAM_PROBE_AUTOWALK=1; skips remote entities.
|
||||
if (update.Guid == _playerServerGuid
|
||||
&& AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
|
||||
{
|
||||
string velStr = update.Velocity is { } v
|
||||
? $"vel=({v.X:F2},{v.Y:F2},{v.Z:F2})"
|
||||
: "vel=null";
|
||||
Console.WriteLine(System.FormattableString.Invariant(
|
||||
$"[autowalk-up] cell=0x{p.LandblockId:X8} pos=({p.PositionX:F2},{p.PositionY:F2},{p.PositionZ:F2}) world=({worldPos.X:F2},{worldPos.Y:F2},{worldPos.Z:F2}) {velStr} grounded={update.IsGrounded}"));
|
||||
}
|
||||
var rot = timestampDisposition is AcDream.Core.Physics.PositionTimestampDisposition.ForcePosition
|
||||
? entity.Rotation
|
||||
: new System.Numerics.Quaternion(p.RotationX, p.RotationY, p.RotationZ, p.RotationW);
|
||||
|
|
@ -2432,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
|
||||
|
|
@ -2534,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).
|
||||
|
|
@ -2685,14 +2617,6 @@ internal sealed class LiveEntityNetworkUpdateController
|
|||
// first UP after unstick (bounded by the 1 s sticky lease).
|
||||
bool snapSuppressedByStick = !IsPlayerGuid(update.Guid)
|
||||
&& (rmState.Host?.PositionManager.GetStickyObjectId() ?? 0u) != 0u;
|
||||
if (snapSuppressedByStick
|
||||
&& AcDream.Core.Physics.PhysicsDiagnostics.ProbeStickyEnabled)
|
||||
{
|
||||
float snapDist = System.Numerics.Vector3.Distance(
|
||||
worldPos, rmState.Body.Position);
|
||||
Console.WriteLine(FormattableString.Invariant(
|
||||
$"[sticky-snap-skip] guid=0x{update.Guid:X8} d={snapDist:F3} srv=({worldPos.X:F2},{worldPos.Y:F2}) body=({rmState.Body.Position.X:F2},{rmState.Body.Position.Y:F2})"));
|
||||
}
|
||||
|
||||
// C4 routes 4a + 4b-2 + 4b-3 collapse: the complete near/far/
|
||||
// teleport/leftover decision — including the dissolved LANDING
|
||||
|
|
@ -2739,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
|
||||
|
|
@ -2796,43 +2714,6 @@ internal sealed class LiveEntityNetworkUpdateController
|
|||
_motionRuntime.EnsureRemoteMotionBindings(
|
||||
rmState, aeForLand, update.Guid);
|
||||
}
|
||||
|
||||
// Bug A investigation (2026-08-04, docs/ISSUES.md #32):
|
||||
// the packet-side half of the landing capture, now fired
|
||||
// for both guids (diagnostic-only — TEMPORARY, strip with
|
||||
// the probe family; not behaviour).
|
||||
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeRemoteLandingEnabled)
|
||||
{
|
||||
bool gravitySetForProbe = rmState.Body.HasGravity;
|
||||
AcDream.Core.Physics.PhysicsDiagnostics.LogRemoteLanding(
|
||||
site: "controller",
|
||||
guid: update.Guid,
|
||||
airborneBefore: true,
|
||||
gravitySet: gravitySetForProbe,
|
||||
contact: rmState.Body.InContact,
|
||||
onWalkable: rmState.Body.OnWalkable,
|
||||
hasDefaultSink: rmState.Motion.DefaultSink is not null,
|
||||
resolveIsOnGround: null,
|
||||
sequencerStyle: aeForLand?.Sequencer?.CurrentStyle ?? 0,
|
||||
sequencerMotion: aeForLand?.Sequencer?.CurrentMotion ?? 0);
|
||||
if (!gravitySetForProbe)
|
||||
{
|
||||
AcDream.Core.Physics.PhysicsDiagnostics.LogRemoteLandingGateNoOp(
|
||||
"controller", update.Guid);
|
||||
}
|
||||
// Zero the sink-dispatch latches before reading them
|
||||
// back — nothing at THIS site dispatches (the arming
|
||||
// call lives only next to the per-tick HitGround).
|
||||
AcDream.Core.Physics.PhysicsDiagnostics
|
||||
.BeginRemoteLandingDispatchCapture();
|
||||
AcDream.Core.Physics.PhysicsDiagnostics.LogRemoteLandingAfter(
|
||||
site: "controller",
|
||||
guid: update.Guid,
|
||||
hitGroundInvoked: false,
|
||||
sequencerStyle: aeForLand?.Sequencer?.CurrentStyle ?? 0,
|
||||
sequencerMotion: aeForLand?.Sequencer?.CurrentMotion ?? 0,
|
||||
forwardCommand: rmState.Motion.InterpretedState.ForwardCommand);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2933,29 +2814,22 @@ internal sealed class LiveEntityNetworkUpdateController
|
|||
// rmState.CellId is the server cell adopted above. The root
|
||||
// frame is committed before collision publication, as in retail
|
||||
// SetPositionInternal. The ONE entity-sync + shadow-publish tail
|
||||
// for every guid and every arm — except the #316-preserved
|
||||
// exception: a player-guid AirborneSnap arm still commits the
|
||||
// render entity from the resolved body but does NOT publish the
|
||||
// shadow, matching its pre-collapse behaviour exactly (see the
|
||||
// comment at that arm, above).
|
||||
// now covers every guid and every arm, including #316's formerly
|
||||
// skipped player-guid AirborneSnap landing.
|
||||
entity.SetPosition(rmState.Body.Position);
|
||||
entity.ParentCellId = rmState.CellId;
|
||||
entity.Rotation = rmState.Body.Orientation;
|
||||
if (arm is not RemoteContactArm.AirborneSnap
|
||||
|| !IsPlayerGuid(update.Guid))
|
||||
{
|
||||
AcDream.App.Physics.LiveEntityShadowPublisher.TryPublishRemote(
|
||||
_liveEntities,
|
||||
positionRecord,
|
||||
entity,
|
||||
AcDream.App.Physics.LiveEntityShadowPublisher.TryPublishRemote(
|
||||
_liveEntities,
|
||||
positionRecord,
|
||||
entity,
|
||||
rmState,
|
||||
acceptedPositionAuthorityVersion,
|
||||
() => _remotePhysicsUpdater.SyncRemoteShadowToBody(
|
||||
entity.Id,
|
||||
rmState,
|
||||
acceptedPositionAuthorityVersion,
|
||||
() => _remotePhysicsUpdater.SyncRemoteShadowToBody(
|
||||
entity.Id,
|
||||
rmState,
|
||||
_origin.CenterX,
|
||||
_origin.CenterY));
|
||||
}
|
||||
_origin.CenterX,
|
||||
_origin.CenterY));
|
||||
}
|
||||
|
||||
// F751 is only a notification gate; the accepted Position may arrive
|
||||
|
|
|
|||
|
|
@ -151,6 +151,13 @@ internal static class GraphicalWindowBackendConfigurator
|
|||
_ => throw new ArgumentOutOfRangeException(
|
||||
nameof(requested)),
|
||||
});
|
||||
// #451: InitHint proves the packaged glfw3.dll is loaded but runs
|
||||
// before glfwInit creates any window or begins polling. This is
|
||||
// the one safe point to narrow GLFW's GetActiveWindow import so a
|
||||
// temporarily joined Win32 input queue cannot hand it another
|
||||
// acdream process's private GLFWwindow pointer.
|
||||
if (platform.OperatingSystem == GraphicalHostOperatingSystem.Windows)
|
||||
Win32GlfwActiveWindowGuard.Install();
|
||||
_glfw = glfw;
|
||||
_configuredProtocol = requested;
|
||||
}
|
||||
|
|
|
|||
282
src/AcDream.App/Platform/Win32GlfwActiveWindowGuard.cs
Normal file
282
src/AcDream.App/Platform/Win32GlfwActiveWindowGuard.cs
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace AcDream.App.Platform;
|
||||
|
||||
/// <summary>
|
||||
/// Prevents GLFW's Win32 modifier-key repair pass from accepting a window
|
||||
/// owned by another process.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// GLFW 3.4's <c>_glfwPollEventsWin32</c> calls <c>GetActiveWindow</c>, then
|
||||
/// reads that HWND's process-global <c>GLFW</c> property and dereferences the
|
||||
/// result as a local <c>_GLFWwindow*</c>. Normally <c>GetActiveWindow</c> can
|
||||
/// only return a window from this thread's input queue. Windows automation,
|
||||
/// accessibility software, and some multi-box window managers temporarily
|
||||
/// join input queues, however, allowing it to return another acdream process's
|
||||
/// window. Every GLFW process uses the same property name, so <c>GetPropW</c>
|
||||
/// then succeeds but returns a pointer meaningful only in the other process.
|
||||
/// The next modifier-key read is an access violation (#452).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Patch only GLFW's import-address-table entry for <c>GetActiveWindow</c>.
|
||||
/// The replacement returns the real active HWND when it belongs to this
|
||||
/// process and zero otherwise. Zero is GLFW's existing, intentional
|
||||
/// "nothing to repair" path. No process-global Win32 hook is installed and
|
||||
/// no other module's User32 calls are changed.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static unsafe class Win32GlfwActiveWindowGuard
|
||||
{
|
||||
private const string GlfwModuleName = "glfw3.dll";
|
||||
private const string User32ModuleName = "USER32.dll";
|
||||
private const string GetActiveWindowImport = "GetActiveWindow";
|
||||
private const uint PageReadWrite = 0x04;
|
||||
private const ushort DosSignature = 0x5A4D;
|
||||
private const uint PeSignature = 0x00004550;
|
||||
private const ushort Pe32Magic = 0x010B;
|
||||
private const ushort Pe32PlusMagic = 0x020B;
|
||||
private const int ImportDescriptorSize = 20;
|
||||
|
||||
private static readonly uint CurrentProcessId =
|
||||
checked((uint)Environment.ProcessId);
|
||||
private static int _installState;
|
||||
|
||||
internal static bool IsInstalled => Volatile.Read(ref _installState) == 1;
|
||||
|
||||
internal static void Install()
|
||||
{
|
||||
if (!OperatingSystem.IsWindows()
|
||||
|| Interlocked.CompareExchange(ref _installState, 2, 0) != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
nint module = GetModuleHandleW(GlfwModuleName);
|
||||
if (module == 0
|
||||
|| !TryFindImportSlot(
|
||||
module,
|
||||
User32ModuleName,
|
||||
GetActiveWindowImport,
|
||||
out nint slot))
|
||||
{
|
||||
Volatile.Write(ref _installState, -1);
|
||||
Console.Error.WriteLine(
|
||||
"windowing: could not install the GLFW foreign-active-window guard");
|
||||
return;
|
||||
}
|
||||
|
||||
nint replacement = (nint)(delegate* unmanaged[Stdcall]<nint>)
|
||||
&GetCurrentProcessActiveWindow;
|
||||
if (!VirtualProtect(
|
||||
slot,
|
||||
checked((nuint)IntPtr.Size),
|
||||
PageReadWrite,
|
||||
out uint oldProtection))
|
||||
{
|
||||
Volatile.Write(ref _installState, -1);
|
||||
Console.Error.WriteLine(
|
||||
"windowing: GLFW active-window import was not writable");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
*(nint*)slot = replacement;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_ = VirtualProtect(
|
||||
slot,
|
||||
checked((nuint)IntPtr.Size),
|
||||
oldProtection,
|
||||
out _);
|
||||
}
|
||||
|
||||
Volatile.Write(ref _installState, 1);
|
||||
Console.WriteLine(
|
||||
"windowing: GLFW foreign-active-window guard installed (#452).");
|
||||
}
|
||||
catch (Exception failure)
|
||||
{
|
||||
Volatile.Write(ref _installState, -1);
|
||||
Console.Error.WriteLine(
|
||||
$"windowing: GLFW active-window guard failed: {failure.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
[UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])]
|
||||
private static nint GetCurrentProcessActiveWindow()
|
||||
{
|
||||
nint window = GetActiveWindow();
|
||||
if (window == 0)
|
||||
return 0;
|
||||
|
||||
_ = GetWindowThreadProcessId(window, out uint ownerProcessId);
|
||||
return AcceptWindow(window, ownerProcessId, CurrentProcessId);
|
||||
}
|
||||
|
||||
internal static nint AcceptWindow(
|
||||
nint window,
|
||||
uint ownerProcessId,
|
||||
uint currentProcessId) =>
|
||||
window != 0
|
||||
&& ownerProcessId != 0
|
||||
&& ownerProcessId == currentProcessId
|
||||
? window
|
||||
: 0;
|
||||
|
||||
private static bool TryFindImportSlot(
|
||||
nint module,
|
||||
string importedModule,
|
||||
string importedFunction,
|
||||
out nint slot)
|
||||
{
|
||||
slot = 0;
|
||||
byte* image = (byte*)module;
|
||||
if (*(ushort*)image != DosSignature)
|
||||
return false;
|
||||
|
||||
int peOffset = *(int*)(image + 0x3C);
|
||||
if (peOffset <= 0 || *(uint*)(image + peOffset) != PeSignature)
|
||||
return false;
|
||||
|
||||
byte* optionalHeader = image + peOffset + 24;
|
||||
ushort magic = *(ushort*)optionalHeader;
|
||||
int dataDirectoryOffset;
|
||||
int thunkSize;
|
||||
ulong ordinalFlag;
|
||||
if (magic == Pe32PlusMagic)
|
||||
{
|
||||
dataDirectoryOffset = 112;
|
||||
thunkSize = 8;
|
||||
ordinalFlag = 0x8000000000000000UL;
|
||||
}
|
||||
else if (magic == Pe32Magic)
|
||||
{
|
||||
dataDirectoryOffset = 96;
|
||||
thunkSize = 4;
|
||||
ordinalFlag = 0x80000000UL;
|
||||
}
|
||||
else
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
uint sizeOfImage = *(uint*)(optionalHeader + 56);
|
||||
uint importRva = *(uint*)(optionalHeader + dataDirectoryOffset + 8);
|
||||
uint importSize = *(uint*)(optionalHeader + dataDirectoryOffset + 12);
|
||||
if (!Contains(sizeOfImage, importRva, ImportDescriptorSize))
|
||||
return false;
|
||||
|
||||
int descriptorLimit = importSize >= ImportDescriptorSize
|
||||
? checked((int)(importSize / ImportDescriptorSize))
|
||||
: checked((int)((sizeOfImage - importRva) / ImportDescriptorSize));
|
||||
for (int descriptorIndex = 0;
|
||||
descriptorIndex < descriptorLimit;
|
||||
descriptorIndex++)
|
||||
{
|
||||
byte* descriptor = image
|
||||
+ importRva
|
||||
+ descriptorIndex * ImportDescriptorSize;
|
||||
uint originalFirstThunk = *(uint*)descriptor;
|
||||
uint nameRva = *(uint*)(descriptor + 12);
|
||||
uint firstThunk = *(uint*)(descriptor + 16);
|
||||
if (originalFirstThunk == 0 && nameRva == 0 && firstThunk == 0)
|
||||
break;
|
||||
if (!MatchesAsciiZ(image, sizeOfImage, nameRva, importedModule, true))
|
||||
continue;
|
||||
if (originalFirstThunk == 0
|
||||
|| !Contains(sizeOfImage, originalFirstThunk, thunkSize)
|
||||
|| !Contains(sizeOfImage, firstThunk, thunkSize))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
int thunkLimit = checked((int)Math.Min(
|
||||
(sizeOfImage - originalFirstThunk) / (uint)thunkSize,
|
||||
(sizeOfImage - firstThunk) / (uint)thunkSize));
|
||||
for (int thunkIndex = 0; thunkIndex < thunkLimit; thunkIndex++)
|
||||
{
|
||||
ulong nameThunk = thunkSize == 8
|
||||
? *(ulong*)(image + originalFirstThunk + thunkIndex * thunkSize)
|
||||
: *(uint*)(image + originalFirstThunk + thunkIndex * thunkSize);
|
||||
if (nameThunk == 0)
|
||||
break;
|
||||
if ((nameThunk & ordinalFlag) != 0)
|
||||
continue;
|
||||
|
||||
uint importByNameRva = checked((uint)nameThunk);
|
||||
if (!Contains(sizeOfImage, importByNameRva, 3)
|
||||
|| !MatchesAsciiZ(
|
||||
image,
|
||||
sizeOfImage,
|
||||
importByNameRva + 2,
|
||||
importedFunction,
|
||||
false))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
slot = (nint)(image + firstThunk + thunkIndex * thunkSize);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool Contains(uint imageSize, uint offset, int length) =>
|
||||
length >= 0
|
||||
&& offset < imageSize
|
||||
&& (ulong)offset + (uint)length <= imageSize;
|
||||
|
||||
private static bool MatchesAsciiZ(
|
||||
byte* image,
|
||||
uint imageSize,
|
||||
uint offset,
|
||||
string expected,
|
||||
bool ignoreCase)
|
||||
{
|
||||
if (!Contains(imageSize, offset, expected.Length + 1))
|
||||
return false;
|
||||
|
||||
for (int i = 0; i < expected.Length; i++)
|
||||
{
|
||||
char actual = (char)image[offset + (uint)i];
|
||||
char wanted = expected[i];
|
||||
if (ignoreCase)
|
||||
{
|
||||
actual = char.ToUpperInvariant(actual);
|
||||
wanted = char.ToUpperInvariant(wanted);
|
||||
}
|
||||
if (actual != wanted)
|
||||
return false;
|
||||
}
|
||||
return image[offset + (uint)expected.Length] == 0;
|
||||
}
|
||||
|
||||
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
||||
private static extern nint GetModuleHandleW(string moduleName);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool VirtualProtect(
|
||||
nint address,
|
||||
nuint size,
|
||||
uint newProtection,
|
||||
out uint oldProtection);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern nint GetActiveWindow();
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern uint GetWindowThreadProcessId(
|
||||
nint window,
|
||||
out uint processId);
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -10,7 +10,10 @@ public sealed class AppPluginHost : IPluginHost
|
|||
IEvents events,
|
||||
ISelectionService selection,
|
||||
IUiRegistry ui,
|
||||
IAutomationSurface automation)
|
||||
IAutomationSurface automation,
|
||||
IPluginStorage? storage = null,
|
||||
IPluginCommandRegistry? commands = null,
|
||||
IPluginLootClassifierRegistry? lootClassifiers = null)
|
||||
{
|
||||
Log = log;
|
||||
State = state;
|
||||
|
|
@ -18,6 +21,10 @@ public sealed class AppPluginHost : IPluginHost
|
|||
Selection = selection;
|
||||
Ui = ui;
|
||||
Automation = automation;
|
||||
Storage = storage ?? NoOpPluginStorage.Instance;
|
||||
Commands = commands ?? NoOpPluginCommandRegistry.Instance;
|
||||
LootClassifiers = lootClassifiers
|
||||
?? NoOpPluginLootClassifierRegistry.Instance;
|
||||
}
|
||||
|
||||
public bool HasUi => true;
|
||||
|
|
@ -27,4 +34,7 @@ public sealed class AppPluginHost : IPluginHost
|
|||
public ISelectionService Selection { get; }
|
||||
public IUiRegistry Ui { get; }
|
||||
public IAutomationSurface Automation { get; }
|
||||
public IPluginStorage Storage { get; }
|
||||
public IPluginCommandRegistry Commands { get; }
|
||||
public IPluginLootClassifierRegistry LootClassifiers { get; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,18 +11,36 @@ namespace AcDream.App.Plugins;
|
|||
/// </summary>
|
||||
public sealed class BufferedUiRegistry : IScopedUiRegistry
|
||||
{
|
||||
public readonly record struct Pending(string MarkupPath, object Binding)
|
||||
public readonly record struct Pending(
|
||||
PluginUiOwner Owner,
|
||||
PluginPanelDescriptor Descriptor,
|
||||
string MarkupPath,
|
||||
object Binding)
|
||||
{
|
||||
internal long RegistrationId { get; init; }
|
||||
internal string? MarkupContent { get; init; }
|
||||
|
||||
/// <summary>Stable, manifest-scoped retained-window persistence key.</summary>
|
||||
public string WindowName =>
|
||||
$"plugin:{Owner.Id}:{Descriptor.WindowId}";
|
||||
}
|
||||
|
||||
private sealed class Registration(string markupPath, object binding)
|
||||
private sealed class Registration(
|
||||
PluginUiOwner owner,
|
||||
PluginPanelDescriptor descriptor,
|
||||
string markupPath,
|
||||
object binding,
|
||||
string? markupContent = null)
|
||||
{
|
||||
internal PluginUiOwner Owner { get; } = owner;
|
||||
internal PluginPanelDescriptor Descriptor { get; } = descriptor;
|
||||
internal string MarkupPath { get; } = markupPath;
|
||||
internal object Binding { get; } = binding;
|
||||
internal string? MarkupContent { get; } = markupContent;
|
||||
internal bool Drained { get; set; }
|
||||
internal UiRoot? Root { get; set; }
|
||||
internal UiElement? Element { get; set; }
|
||||
internal Action? WindowCleanup { get; set; }
|
||||
}
|
||||
|
||||
private readonly object _gate = new();
|
||||
|
|
@ -32,15 +50,114 @@ public sealed class BufferedUiRegistry : IScopedUiRegistry
|
|||
public void AddMarkupPanel(string markupPath, object binding)
|
||||
=> _ = RegisterMarkupPanel(markupPath, binding);
|
||||
|
||||
public void AddPanel(
|
||||
PluginPanelDescriptor descriptor,
|
||||
string markupPath,
|
||||
object binding)
|
||||
=> _ = RegisterPanel(
|
||||
new PluginUiOwner("unscoped", descriptor.Title),
|
||||
descriptor,
|
||||
markupPath,
|
||||
binding);
|
||||
|
||||
public IDisposable RegisterPanel(
|
||||
PluginPanelDescriptor descriptor,
|
||||
string markupPath,
|
||||
object binding) => RegisterPanel(
|
||||
new PluginUiOwner("unscoped", descriptor.Title),
|
||||
descriptor,
|
||||
markupPath,
|
||||
binding);
|
||||
|
||||
public IDisposable RegisterPanelContent(
|
||||
PluginPanelDescriptor descriptor,
|
||||
string markupContent,
|
||||
object binding) => RegisterPanelContent(
|
||||
new PluginUiOwner("unscoped", descriptor.Title),
|
||||
descriptor,
|
||||
markupContent,
|
||||
binding);
|
||||
|
||||
public bool ViewExists(string viewName) =>
|
||||
ViewExists(new PluginUiOwner("unscoped", "Plugin"), viewName);
|
||||
|
||||
public bool IsViewVisible(string viewName) =>
|
||||
IsViewVisible(new PluginUiOwner("unscoped", "Plugin"), viewName);
|
||||
|
||||
public bool ControlExists(string viewName, string controlName) =>
|
||||
ControlExists(
|
||||
new PluginUiOwner("unscoped", "Plugin"), viewName, controlName);
|
||||
|
||||
public bool SetControlLabel(
|
||||
string viewName,
|
||||
string controlName,
|
||||
string label) => SetControlLabel(
|
||||
new PluginUiOwner("unscoped", "Plugin"), viewName, controlName, label);
|
||||
|
||||
public bool SetControlVisible(
|
||||
string viewName,
|
||||
string controlName,
|
||||
bool visible) => SetControlVisible(
|
||||
new PluginUiOwner("unscoped", "Plugin"), viewName, controlName, visible);
|
||||
|
||||
public IDisposable RegisterMarkupPanel(string markupPath, object binding)
|
||||
=> RegisterPanel(
|
||||
new PluginUiOwner("legacy", "Plugin"),
|
||||
new PluginPanelDescriptor(
|
||||
Path.GetFileNameWithoutExtension(markupPath),
|
||||
Path.GetFileNameWithoutExtension(markupPath)),
|
||||
markupPath,
|
||||
binding);
|
||||
|
||||
public IDisposable RegisterPanel(
|
||||
PluginUiOwner owner,
|
||||
PluginPanelDescriptor descriptor,
|
||||
string markupPath,
|
||||
object binding)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(owner.Id);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(owner.DisplayName);
|
||||
ArgumentNullException.ThrowIfNull(descriptor);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(descriptor.WindowId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(descriptor.Title);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(markupPath);
|
||||
ArgumentNullException.ThrowIfNull(binding);
|
||||
long id;
|
||||
lock (_gate)
|
||||
{
|
||||
id = checked(++_nextRegistrationId);
|
||||
_registrations.Add(id, new Registration(markupPath, binding));
|
||||
_registrations.Add(
|
||||
id,
|
||||
new Registration(owner, descriptor, markupPath, binding));
|
||||
}
|
||||
return new RegistrationToken(this, id);
|
||||
}
|
||||
|
||||
public IDisposable RegisterPanelContent(
|
||||
PluginUiOwner owner,
|
||||
PluginPanelDescriptor descriptor,
|
||||
string markupContent,
|
||||
object binding)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(owner.Id);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(owner.DisplayName);
|
||||
ArgumentNullException.ThrowIfNull(descriptor);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(descriptor.WindowId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(descriptor.Title);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(markupContent);
|
||||
ArgumentNullException.ThrowIfNull(binding);
|
||||
long id;
|
||||
lock (_gate)
|
||||
{
|
||||
id = checked(++_nextRegistrationId);
|
||||
_registrations.Add(
|
||||
id,
|
||||
new Registration(
|
||||
owner,
|
||||
descriptor,
|
||||
$"<inline:{descriptor.WindowId}>",
|
||||
binding,
|
||||
markupContent));
|
||||
}
|
||||
return new RegistrationToken(this, id);
|
||||
}
|
||||
|
|
@ -57,10 +174,13 @@ public sealed class BufferedUiRegistry : IScopedUiRegistry
|
|||
continue;
|
||||
registration.Drained = true;
|
||||
pending.Add(new Pending(
|
||||
registration.Owner,
|
||||
registration.Descriptor,
|
||||
registration.MarkupPath,
|
||||
registration.Binding)
|
||||
{
|
||||
RegistrationId = id,
|
||||
MarkupContent = registration.MarkupContent,
|
||||
});
|
||||
}
|
||||
return pending;
|
||||
|
|
@ -88,6 +208,27 @@ public sealed class BufferedUiRegistry : IScopedUiRegistry
|
|||
root.RemoveChild(element);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Publishes the window-manager half of a mounted registration. Disposal
|
||||
/// may race between retained-tree mount and window registration, so a late
|
||||
/// publication cleans itself up immediately when ownership is already gone.
|
||||
/// </summary>
|
||||
internal void CompleteWindowMount(Pending pending, Action cleanup)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(cleanup);
|
||||
bool stillRegistered;
|
||||
lock (_gate)
|
||||
{
|
||||
stillRegistered = _registrations.TryGetValue(
|
||||
pending.RegistrationId,
|
||||
out Registration? registration);
|
||||
if (stillRegistered)
|
||||
registration!.WindowCleanup = cleanup;
|
||||
}
|
||||
if (!stillRegistered)
|
||||
cleanup();
|
||||
}
|
||||
|
||||
internal void FailMount(Pending pending) => Remove(pending.RegistrationId);
|
||||
|
||||
internal int RegistrationCount
|
||||
|
|
@ -99,18 +240,114 @@ public sealed class BufferedUiRegistry : IScopedUiRegistry
|
|||
}
|
||||
}
|
||||
|
||||
public bool ViewExists(PluginUiOwner owner, string viewName)
|
||||
{
|
||||
lock (_gate)
|
||||
return FindRegistrationLocked(owner, viewName) is not null;
|
||||
}
|
||||
|
||||
public bool IsViewVisible(PluginUiOwner owner, string viewName)
|
||||
{
|
||||
UiElement? view;
|
||||
lock (_gate)
|
||||
view = FindRegistrationLocked(owner, viewName)?.Element;
|
||||
return view?.Visible == true;
|
||||
}
|
||||
|
||||
public bool ControlExists(
|
||||
PluginUiOwner owner,
|
||||
string viewName,
|
||||
string controlName) =>
|
||||
FindControl(owner, viewName, controlName) is not null;
|
||||
|
||||
public bool SetControlLabel(
|
||||
PluginUiOwner owner,
|
||||
string viewName,
|
||||
string controlName,
|
||||
string label)
|
||||
{
|
||||
UiElement? control = FindControl(owner, viewName, controlName);
|
||||
switch (control)
|
||||
{
|
||||
case UiSimpleButton button:
|
||||
button.TextSource = null;
|
||||
button.Text = label;
|
||||
return true;
|
||||
case UiMarkupToggle toggle:
|
||||
toggle.TextSource = null;
|
||||
toggle.Text = label;
|
||||
return true;
|
||||
case UiLabel text:
|
||||
text.TextSource = null;
|
||||
text.Text = label;
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public bool SetControlVisible(
|
||||
PluginUiOwner owner,
|
||||
string viewName,
|
||||
string controlName,
|
||||
bool visible)
|
||||
{
|
||||
UiElement? control = FindControl(owner, viewName, controlName);
|
||||
if (control is null)
|
||||
return false;
|
||||
control.VisibleSource = null;
|
||||
control.Visible = visible;
|
||||
return true;
|
||||
}
|
||||
|
||||
private UiElement? FindControl(
|
||||
PluginUiOwner owner,
|
||||
string viewName,
|
||||
string controlName)
|
||||
{
|
||||
UiElement? view;
|
||||
lock (_gate)
|
||||
view = FindRegistrationLocked(owner, viewName)?.Element;
|
||||
return view is null ? null : FindByName(view, controlName);
|
||||
}
|
||||
|
||||
private Registration? FindRegistrationLocked(
|
||||
PluginUiOwner owner,
|
||||
string viewName) => _registrations.Values.FirstOrDefault(registration =>
|
||||
registration.Owner == owner
|
||||
&& (registration.Descriptor.WindowId.Equals(
|
||||
viewName, StringComparison.Ordinal)
|
||||
|| registration.Descriptor.Title.Equals(
|
||||
viewName, StringComparison.Ordinal)));
|
||||
|
||||
private static UiElement? FindByName(UiElement root, string name)
|
||||
{
|
||||
if (root.Name?.Equals(name, StringComparison.Ordinal) == true)
|
||||
return root;
|
||||
foreach (UiElement child in root.Children)
|
||||
{
|
||||
UiElement? found = FindByName(child, name);
|
||||
if (found is not null)
|
||||
return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void Remove(long id)
|
||||
{
|
||||
UiRoot? root;
|
||||
UiElement? element;
|
||||
Action? windowCleanup;
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_registrations.Remove(id, out Registration? registration))
|
||||
return;
|
||||
root = registration.Root;
|
||||
element = registration.Element;
|
||||
windowCleanup = registration.WindowCleanup;
|
||||
}
|
||||
|
||||
windowCleanup?.Invoke();
|
||||
if (root is not null && element is not null)
|
||||
root.RemoveChild(element);
|
||||
}
|
||||
|
|
|
|||
86
src/AcDream.App/Plugins/FilePluginStorage.cs
Normal file
86
src/AcDream.App/Plugins/FilePluginStorage.cs
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
using System.Text;
|
||||
using AcDream.Plugin.Abstractions;
|
||||
|
||||
namespace AcDream.App.Plugins;
|
||||
|
||||
/// <summary>Crash-safe filesystem implementation behind scoped plugin keys.</summary>
|
||||
internal sealed class FilePluginStorage : IPluginStorage
|
||||
{
|
||||
private readonly string _root;
|
||||
|
||||
internal FilePluginStorage(string root)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(root);
|
||||
_root = Path.GetFullPath(root);
|
||||
}
|
||||
|
||||
public bool IsAvailable => true;
|
||||
|
||||
public string? ReadText(string key)
|
||||
{
|
||||
string path = Resolve(key);
|
||||
return File.Exists(path)
|
||||
? File.ReadAllText(path, Encoding.UTF8)
|
||||
: null;
|
||||
}
|
||||
|
||||
public IReadOnlyList<string> List(string prefix)
|
||||
{
|
||||
string directory = Resolve(prefix);
|
||||
if (!Directory.Exists(directory))
|
||||
return Array.Empty<string>();
|
||||
return Directory.EnumerateFiles(directory, "*", SearchOption.AllDirectories)
|
||||
.Select(path => Path.GetRelativePath(_root, path)
|
||||
.Replace(Path.DirectorySeparatorChar, '/'))
|
||||
.OrderBy(static key => key, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
public void WriteText(string key, string content)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(content);
|
||||
string path = Resolve(key);
|
||||
string directory = Path.GetDirectoryName(path)!;
|
||||
Directory.CreateDirectory(directory);
|
||||
string temporary = Path.Combine(
|
||||
directory,
|
||||
$".{Path.GetFileName(path)}.{Guid.NewGuid():N}.tmp");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(temporary, content, new UTF8Encoding(false));
|
||||
File.Move(temporary, path, overwrite: true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(temporary))
|
||||
File.Delete(temporary);
|
||||
}
|
||||
}
|
||||
|
||||
public bool Delete(string key)
|
||||
{
|
||||
string path = Resolve(key);
|
||||
if (!File.Exists(path))
|
||||
return false;
|
||||
File.Delete(path);
|
||||
return true;
|
||||
}
|
||||
|
||||
private string Resolve(string key)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(key);
|
||||
if (Path.IsPathRooted(key))
|
||||
throw new ArgumentException("Plugin storage keys must be relative.", nameof(key));
|
||||
string path = Path.GetFullPath(Path.Combine(_root, key));
|
||||
string relative = Path.GetRelativePath(_root, path);
|
||||
if (Path.IsPathRooted(relative)
|
||||
|| relative.Equals("..", StringComparison.Ordinal)
|
||||
|| relative.StartsWith(
|
||||
".." + Path.DirectorySeparatorChar,
|
||||
StringComparison.Ordinal))
|
||||
{
|
||||
throw new ArgumentException("Plugin storage key escapes its root.", nameof(key));
|
||||
}
|
||||
return path;
|
||||
}
|
||||
}
|
||||
225
src/AcDream.App/Plugins/LocalPluginPeerRegistry.cs
Normal file
225
src/AcDream.App/Plugins/LocalPluginPeerRegistry.cs
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
using System.Text.Json;
|
||||
using AcDream.Plugin.Abstractions;
|
||||
|
||||
namespace AcDream.App.Plugins;
|
||||
|
||||
/// <summary>
|
||||
/// Small cross-process peer roster for plugins. UtilityBelt used a local TCP
|
||||
/// relay; acdream uses bounded heartbeat documents in the user's local app
|
||||
/// data, which provides the same machine-local discovery without a privileged
|
||||
/// daemon or a fixed port. The files carry data only—never commands.
|
||||
/// </summary>
|
||||
internal sealed class LocalPluginPeerRegistry : IDisposable
|
||||
{
|
||||
internal static readonly TimeSpan StaleAfter = TimeSpan.FromSeconds(15);
|
||||
private const long MaximumDocumentBytes = 64 * 1024;
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
};
|
||||
|
||||
private readonly string _directory;
|
||||
private readonly string _path;
|
||||
private readonly TimeProvider _time;
|
||||
private readonly Guid _instanceId;
|
||||
private bool _disposed;
|
||||
|
||||
public LocalPluginPeerRegistry(
|
||||
string directory,
|
||||
TimeProvider? timeProvider = null,
|
||||
Guid? instanceId = null)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(directory);
|
||||
_directory = Path.GetFullPath(directory);
|
||||
_time = timeProvider ?? TimeProvider.System;
|
||||
_instanceId = instanceId ?? Guid.NewGuid();
|
||||
_path = Path.Combine(_directory, $"peer-{_instanceId:N}.json");
|
||||
ClientId = BitConverter.ToUInt32(_instanceId.ToByteArray(), 0);
|
||||
if (ClientId == 0u)
|
||||
ClientId = 1u;
|
||||
}
|
||||
|
||||
public uint ClientId { get; private set; }
|
||||
|
||||
public void Publish(in PluginNetworkClient client)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
Directory.CreateDirectory(_directory);
|
||||
var document = PeerDocument.From(
|
||||
client with { ClientId = ClientId },
|
||||
_instanceId,
|
||||
_time.GetUtcNow().ToUnixTimeMilliseconds());
|
||||
string temporary = _path + "." + Guid.NewGuid().ToString("N") + ".tmp";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(temporary, JsonSerializer.Serialize(document, JsonOptions));
|
||||
File.Move(temporary, _path, overwrite: true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(temporary))
|
||||
File.Delete(temporary);
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<PluginNetworkClient> CaptureRemoteClients()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
if (!Directory.Exists(_directory))
|
||||
return Array.Empty<PluginNetworkClient>();
|
||||
long newestAllowed = _time.GetUtcNow().Subtract(StaleAfter)
|
||||
.ToUnixTimeMilliseconds();
|
||||
var result = new List<PluginNetworkClient>();
|
||||
foreach (string file in Directory.EnumerateFiles(
|
||||
_directory,
|
||||
"peer-*.json",
|
||||
SearchOption.TopDirectoryOnly))
|
||||
{
|
||||
if (file.Equals(_path, StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
try
|
||||
{
|
||||
var info = new FileInfo(file);
|
||||
if (info.Length is <= 0 or > MaximumDocumentBytes)
|
||||
continue;
|
||||
PeerDocument? document = JsonSerializer.Deserialize<PeerDocument>(
|
||||
File.ReadAllText(file),
|
||||
JsonOptions);
|
||||
if (document is null
|
||||
|| document.InstanceId == _instanceId
|
||||
|| document.UpdatedUnixMs < newestAllowed
|
||||
|| document.ClientId == 0u
|
||||
|| document.PlayerId == 0u
|
||||
|| string.IsNullOrWhiteSpace(document.Name)
|
||||
|| document.Name.Length > 128
|
||||
|| document.WorldName is null
|
||||
|| document.WorldName.Length > 128
|
||||
|| document.Tags is null
|
||||
|| document.Tags.Length > 128
|
||||
|| !double.IsFinite(document.EastWest)
|
||||
|| !double.IsFinite(document.NorthSouth)
|
||||
|| !double.IsFinite(document.Elevation)
|
||||
|| !float.IsFinite(document.Heading))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
result.Add(document.ToClient());
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// A peer can atomically replace or remove its own heartbeat
|
||||
// between enumeration and read. It will reappear next scan.
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
}
|
||||
}
|
||||
return result
|
||||
.OrderBy(static client => client.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(static client => client.ClientId)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
public void Withdraw()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
try
|
||||
{
|
||||
if (File.Exists(_path))
|
||||
File.Delete(_path);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
Withdraw();
|
||||
_disposed = true;
|
||||
}
|
||||
|
||||
private sealed class PeerDocument
|
||||
{
|
||||
public Guid InstanceId { get; set; }
|
||||
public long UpdatedUnixMs { get; set; }
|
||||
public uint ClientId { get; set; }
|
||||
public uint PlayerId { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string WorldName { get; set; } = string.Empty;
|
||||
public string[] Tags { get; set; } = [];
|
||||
public uint CellId { get; set; }
|
||||
public double EastWest { get; set; }
|
||||
public double NorthSouth { get; set; }
|
||||
public double Elevation { get; set; }
|
||||
public bool IsOutdoor { get; set; }
|
||||
public float Heading { get; set; }
|
||||
public uint CurrentHealth { get; set; }
|
||||
public uint CurrentMana { get; set; }
|
||||
public uint CurrentStamina { get; set; }
|
||||
public uint MaxHealth { get; set; }
|
||||
public uint MaxMana { get; set; }
|
||||
public uint MaxStamina { get; set; }
|
||||
|
||||
public static PeerDocument From(
|
||||
in PluginNetworkClient client,
|
||||
Guid instanceId,
|
||||
long updatedUnixMs) => new()
|
||||
{
|
||||
InstanceId = instanceId,
|
||||
UpdatedUnixMs = updatedUnixMs,
|
||||
ClientId = client.ClientId,
|
||||
PlayerId = client.PlayerId,
|
||||
Name = client.Name,
|
||||
WorldName = client.WorldName,
|
||||
Tags = client.Tags
|
||||
.Where(static tag => !string.IsNullOrWhiteSpace(tag))
|
||||
.Select(static tag => tag.Trim())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.Take(128)
|
||||
.ToArray(),
|
||||
CellId = client.Position.CellId,
|
||||
EastWest = client.Position.EastWest,
|
||||
NorthSouth = client.Position.NorthSouth,
|
||||
Elevation = client.Position.Elevation,
|
||||
IsOutdoor = client.Position.IsOutdoor,
|
||||
Heading = client.Heading,
|
||||
CurrentHealth = client.CurrentHealth,
|
||||
CurrentMana = client.CurrentMana,
|
||||
CurrentStamina = client.CurrentStamina,
|
||||
MaxHealth = client.MaxHealth,
|
||||
MaxMana = client.MaxMana,
|
||||
MaxStamina = client.MaxStamina,
|
||||
};
|
||||
|
||||
public PluginNetworkClient ToClient() => new(
|
||||
ClientId,
|
||||
PlayerId,
|
||||
Name,
|
||||
WorldName,
|
||||
new PluginNavigationPosition(
|
||||
CellId,
|
||||
EastWest,
|
||||
NorthSouth,
|
||||
Elevation,
|
||||
Heading,
|
||||
IsOutdoor),
|
||||
Tags,
|
||||
CurrentHealth,
|
||||
CurrentMana,
|
||||
CurrentStamina,
|
||||
MaxHealth,
|
||||
MaxMana,
|
||||
MaxStamina,
|
||||
Heading);
|
||||
}
|
||||
}
|
||||
|
|
@ -160,7 +160,13 @@ using IDisposable atmosphericPackRegistration = renderPackRegistry.Register(
|
|||
"spv")));
|
||||
// Constructed here and handed to both sides: GameWindow binds it to the live
|
||||
// session's Runtime owners, the plugin host exposes it to plugins.
|
||||
using var automation = new AcDream.App.Plugins.AppAutomationSurface();
|
||||
using var automation = new AcDream.App.Plugins.AppAutomationSurface(
|
||||
worldEvents,
|
||||
new AcDream.App.Plugins.LocalPluginPeerRegistry(Path.Combine(
|
||||
applicationPaths.DataDirectory,
|
||||
"plugin-peers")),
|
||||
runtimeOptions.PluginTags);
|
||||
var lootClassifiers = new AcDream.Core.Plugins.PluginLootClassifierRegistry();
|
||||
using var window = new GameWindow(
|
||||
runtimeOptions,
|
||||
worldGameState,
|
||||
|
|
@ -175,7 +181,11 @@ var host = new AppPluginHost(
|
|||
worldEvents,
|
||||
window.Selection,
|
||||
uiRegistry,
|
||||
automation);
|
||||
automation,
|
||||
new FilePluginStorage(
|
||||
Path.Combine(applicationPaths.ConfigDirectory, "plugins")),
|
||||
automation.PluginCommands,
|
||||
lootClassifiers);
|
||||
GraphicalPluginSession pluginSession = GraphicalPluginSession.Create(
|
||||
applicationPaths,
|
||||
runtimeOptions.Plugins,
|
||||
|
|
|
|||
|
|
@ -85,6 +85,28 @@ internal sealed class CameraFrameController : ICameraFramePhase
|
|||
retail.AdjustPitch(+adjustment * 0.02f);
|
||||
if (input.Lower)
|
||||
retail.AdjustPitch(-adjustment * 0.02f);
|
||||
if (input.RotateLeft)
|
||||
retail.YawOffset += adjustment * 0.02f;
|
||||
if (input.RotateRight)
|
||||
retail.YawOffset -= adjustment * 0.02f;
|
||||
}
|
||||
else
|
||||
{
|
||||
ChaseCameraAdjustmentInput input = _input.CaptureChaseAdjustment();
|
||||
float adjustment = CameraDiagnostics.CameraAdjustmentSpeed
|
||||
* timing.SimulationDeltaSecondsSingle;
|
||||
if (input.ZoomIn)
|
||||
legacy.AdjustDistance(-adjustment);
|
||||
if (input.ZoomOut)
|
||||
legacy.AdjustDistance(+adjustment);
|
||||
if (input.Raise)
|
||||
legacy.AdjustPitch(+adjustment * 0.02f);
|
||||
if (input.Lower)
|
||||
legacy.AdjustPitch(-adjustment * 0.02f);
|
||||
if (input.RotateLeft)
|
||||
legacy.YawOffset += adjustment * 0.02f;
|
||||
if (input.RotateRight)
|
||||
legacy.YawOffset -= adjustment * 0.02f;
|
||||
}
|
||||
|
||||
if (!_localFrame.TryGetPresentationAfterNetwork(out var playerFrame))
|
||||
|
|
@ -94,11 +116,23 @@ internal sealed class CameraFrameController : ICameraFramePhase
|
|||
_spatialReconciler.Reconcile();
|
||||
|
||||
MovementResult result = playerFrame.Movement;
|
||||
// #429 defect 2: the chase camera smooths toward the PRESENTED player
|
||||
// position, which lives on the retail 30 Hz object clock (see
|
||||
// PlayerMovementController.PresentedDeltaSeconds). Integrating the
|
||||
// damping with wall dt made the camera step full wall time on long
|
||||
// frames while the presented player under-advanced against the
|
||||
// quantum — measured ~1 m of camera/player decoherence in one frame,
|
||||
// the felt run-hitch. Retail ties the camera to the physics-update
|
||||
// callback (SmartBox::PlayerPhysicsUpdatedCallback 0x00452d60), i.e.
|
||||
// the same clock as the body; the presented delta restores that.
|
||||
// Manual zoom/pitch adjustment above stays on wall dt — it is a
|
||||
// user-input rate, not target chasing.
|
||||
float cameraDt = controller.PresentedDeltaSeconds;
|
||||
legacy.Update(
|
||||
result.RenderPosition,
|
||||
controller.Yaw,
|
||||
isOnGround: result.IsOnGround,
|
||||
dt: timing.SimulationDeltaSecondsSingle);
|
||||
dt: cameraDt);
|
||||
|
||||
retail?.Update(
|
||||
result.RenderPosition,
|
||||
|
|
@ -106,7 +140,7 @@ internal sealed class CameraFrameController : ICameraFramePhase
|
|||
playerVelocity: controller.BodyVelocity,
|
||||
isOnGround: result.IsOnGround,
|
||||
contactPlaneNormal: controller.ContactPlane.Normal,
|
||||
dt: timing.SimulationDeltaSecondsSingle,
|
||||
dt: cameraDt,
|
||||
cellId: controller.CellId,
|
||||
selfEntityId: controller.LocalEntityId,
|
||||
trackedTargetPoint: _combatTarget.GetTrackedTargetPoint());
|
||||
|
|
|
|||
|
|
@ -10,6 +10,21 @@ namespace AcDream.App.Rendering;
|
|||
/// </summary>
|
||||
public sealed class ChaseCamera : ICamera
|
||||
{
|
||||
private const float RetailDefaultBack = 2.5f;
|
||||
private const float RetailDefaultUp = 0.75f;
|
||||
private bool _lookingDown;
|
||||
private bool _mapMode;
|
||||
private bool _inHead;
|
||||
private bool _savedInHead;
|
||||
private float _savedDistance;
|
||||
private float _savedPitch;
|
||||
private float _savedYawOffset;
|
||||
private Vector3? _targetDirectionLocal;
|
||||
private Vector3? _savedTargetDirectionLocal;
|
||||
|
||||
public bool IsLookingDown => _lookingDown;
|
||||
public bool IsMapMode => _mapMode;
|
||||
public bool IsInHead => _inHead;
|
||||
public Vector3 Position { get; private set; }
|
||||
public float Aspect { get; set; } = 16f / 9f;
|
||||
// #389: smartbox law at the 16:9 default aspect — see RetailFieldOfView.
|
||||
|
|
@ -108,10 +123,35 @@ public sealed class ChaseCamera : ICamera
|
|||
float horizontalDist = Distance * MathF.Cos(Pitch);
|
||||
float verticalDist = Distance * MathF.Sin(Pitch);
|
||||
|
||||
Position = new Vector3(
|
||||
playerPosition.X - forwardX * horizontalDist,
|
||||
playerPosition.Y - forwardY * horizontalDist,
|
||||
_trackedZ + EyeHeight + verticalDist); // ← uses tracked Z (pinned to ground while airborne)
|
||||
if (_inHead)
|
||||
{
|
||||
Vector3 forward = new(MathF.Cos(playerYaw), MathF.Sin(playerYaw), 0f);
|
||||
Position = new Vector3(
|
||||
playerPosition.X,
|
||||
playerPosition.Y,
|
||||
_trackedZ + EyeHeight) + forward * 0.18f;
|
||||
_lookAt = Position + forward;
|
||||
}
|
||||
else if (_targetDirectionLocal is { } localDirection)
|
||||
{
|
||||
Vector3 pivot = new(playerPosition.X, playerPosition.Y, _trackedZ + EyeHeight);
|
||||
var directedPose = RetailChaseCamera.ComputeTargetDirectionPose(
|
||||
pivot,
|
||||
new Vector3(MathF.Cos(playerYaw), MathF.Sin(playerYaw), 0f),
|
||||
Distance,
|
||||
Pitch,
|
||||
localDirection);
|
||||
Position = directedPose.eye;
|
||||
Vector3 direction = directedPose.forward;
|
||||
_lookAt = Position + direction;
|
||||
}
|
||||
else
|
||||
{
|
||||
Position = new Vector3(
|
||||
playerPosition.X - forwardX * horizontalDist,
|
||||
playerPosition.Y - forwardY * horizontalDist,
|
||||
_trackedZ + EyeHeight + verticalDist); // ← uses tracked Z (pinned to ground while airborne)
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -119,6 +159,8 @@ public sealed class ChaseCamera : ICamera
|
|||
/// </summary>
|
||||
public void AdjustPitch(float delta)
|
||||
{
|
||||
ExitLookDownForAdjustment();
|
||||
ExitInHeadForAdjustment();
|
||||
Pitch = Math.Clamp(Pitch + delta, PitchMin, PitchMax);
|
||||
}
|
||||
|
||||
|
|
@ -127,6 +169,101 @@ public sealed class ChaseCamera : ICamera
|
|||
/// </summary>
|
||||
public void AdjustDistance(float delta)
|
||||
{
|
||||
ExitLookDownForAdjustment();
|
||||
ExitInHeadForAdjustment();
|
||||
Distance = Math.Clamp(Distance + delta, DistanceMin, DistanceMax);
|
||||
}
|
||||
|
||||
public void SetRetailDefaultView()
|
||||
{
|
||||
_lookingDown = false;
|
||||
_mapMode = false;
|
||||
_inHead = false;
|
||||
_targetDirectionLocal = null;
|
||||
YawOffset = 0f;
|
||||
EyeHeight = 1.5f;
|
||||
SetViewerOffset(RetailDefaultBack, RetailDefaultUp);
|
||||
}
|
||||
|
||||
public void SetRetailFirstPersonView()
|
||||
{
|
||||
_lookingDown = false;
|
||||
_mapMode = false;
|
||||
_inHead = true;
|
||||
_targetDirectionLocal = null;
|
||||
YawOffset = 0f;
|
||||
Distance = 0.18f;
|
||||
Pitch = 0f;
|
||||
}
|
||||
|
||||
public void ToggleRetailLookDownView()
|
||||
{
|
||||
if (_lookingDown)
|
||||
{
|
||||
RestoreLookDownView();
|
||||
return;
|
||||
}
|
||||
SaveLookDownView();
|
||||
_lookingDown = true;
|
||||
_mapMode = false;
|
||||
_inHead = false;
|
||||
_targetDirectionLocal = new Vector3(0f, 0.5f, -1.8f);
|
||||
SetViewerOffset(2f, RetailDefaultUp);
|
||||
}
|
||||
|
||||
public void ToggleRetailMapModeView()
|
||||
{
|
||||
if (_mapMode)
|
||||
{
|
||||
RestoreLookDownView();
|
||||
return;
|
||||
}
|
||||
if (!_lookingDown)
|
||||
SaveLookDownView();
|
||||
_lookingDown = true;
|
||||
_mapMode = true;
|
||||
_inHead = false;
|
||||
_targetDirectionLocal = new Vector3(0f, 0.5f, -1.8f);
|
||||
SetViewerOffset(450f, RetailDefaultUp);
|
||||
}
|
||||
|
||||
private void SaveLookDownView()
|
||||
{
|
||||
_savedDistance = Distance;
|
||||
_savedPitch = Pitch;
|
||||
_savedYawOffset = YawOffset;
|
||||
_savedTargetDirectionLocal = _targetDirectionLocal;
|
||||
_savedInHead = _inHead;
|
||||
}
|
||||
|
||||
private void RestoreLookDownView()
|
||||
{
|
||||
Distance = _savedDistance;
|
||||
Pitch = _savedPitch;
|
||||
YawOffset = _savedYawOffset;
|
||||
_targetDirectionLocal = _savedTargetDirectionLocal;
|
||||
_inHead = _savedInHead;
|
||||
_lookingDown = false;
|
||||
_mapMode = false;
|
||||
}
|
||||
|
||||
private void ExitLookDownForAdjustment()
|
||||
{
|
||||
if (_lookingDown)
|
||||
RestoreLookDownView();
|
||||
}
|
||||
|
||||
private void ExitInHeadForAdjustment()
|
||||
{
|
||||
if (!_inHead)
|
||||
return;
|
||||
_inHead = false;
|
||||
Distance = DistanceMin;
|
||||
}
|
||||
|
||||
private void SetViewerOffset(float back, float up)
|
||||
{
|
||||
Distance = MathF.Sqrt(back * back + up * up);
|
||||
Pitch = MathF.Atan2(up, back);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,13 @@ public enum TerrainClipMode
|
|||
/// </summary>
|
||||
public readonly record struct ClipViewSlice(int Slot, Vector4 NdcAabb, Vector4[] Planes);
|
||||
|
||||
/// <summary>
|
||||
/// Identifies one cell inside one nested building look-in. The same EnvCell can
|
||||
/// be reached by more than one building PView, so a cell id alone is not a
|
||||
/// sufficient routing key.
|
||||
/// </summary>
|
||||
public readonly record struct LookInClipCell(int FrameIndex, uint CellId);
|
||||
|
||||
/// <summary>
|
||||
/// Result of <see cref="ClipFrameAssembler.Assemble"/>: populated clip buffers
|
||||
/// plus routing data consumed by the render orchestration.
|
||||
|
|
@ -57,6 +64,12 @@ public sealed class ClipFrameAssembly
|
|||
/// <summary>Full retail portal_view slices per visible cell.</summary>
|
||||
public Dictionary<uint, ClipViewSlice[]> CellIdToViewSlices { get; } = new();
|
||||
|
||||
/// <summary>First drawable slice slot per nested look-in cell.</summary>
|
||||
public Dictionary<LookInClipCell, int> LookInCellToSlot { get; } = new();
|
||||
|
||||
/// <summary>All retail portal_view slices per nested look-in cell.</summary>
|
||||
public Dictionary<LookInClipCell, ClipViewSlice[]> LookInCellToViewSlices { get; } = new();
|
||||
|
||||
/// <summary>Full retail outside_view slices.</summary>
|
||||
public ClipViewSlice[] OutsideViewSlices { get; private set; } = System.Array.Empty<ClipViewSlice>();
|
||||
|
||||
|
|
@ -93,6 +106,8 @@ public sealed class ClipFrameAssembly
|
|||
Frame = frame;
|
||||
foreach (ClipViewSlice[] slices in CellIdToViewSlices.Values)
|
||||
ReturnSlices(slices);
|
||||
foreach (ClipViewSlice[] slices in LookInCellToViewSlices.Values)
|
||||
ReturnSlices(slices);
|
||||
foreach (int[] slots in CellIdToViewSlots.Values)
|
||||
ReturnSlots(slots);
|
||||
if (OutsideViewSlices.Length != 0)
|
||||
|
|
@ -101,6 +116,8 @@ public sealed class ClipFrameAssembly
|
|||
CellIdToSlot.Clear();
|
||||
CellIdToViewSlots.Clear();
|
||||
CellIdToViewSlices.Clear();
|
||||
LookInCellToSlot.Clear();
|
||||
LookInCellToViewSlices.Clear();
|
||||
PerCellPlaneCounts.Clear();
|
||||
OutsideViewSlices = System.Array.Empty<ClipViewSlice>();
|
||||
SliceScratch.Clear();
|
||||
|
|
@ -363,6 +380,70 @@ public static class ClipFrameAssembler
|
|||
return assembly;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Appends the cell views used by nested <c>DrawBuilding -> DrawPortal</c>
|
||||
/// PViews to the already assembled frame. Retail installs each nested
|
||||
/// cell's own <c>portal_view</c> before drawing its shell and object list;
|
||||
/// these slots must therefore be published with the main frame before the
|
||||
/// first draw is recorded.
|
||||
/// </summary>
|
||||
public static void AppendLookInFrames(
|
||||
ClipFrame frame,
|
||||
IReadOnlyList<PortalVisibilityFrame> lookInFrames,
|
||||
ClipFrameAssembly assembly)
|
||||
{
|
||||
System.ArgumentNullException.ThrowIfNull(frame);
|
||||
System.ArgumentNullException.ThrowIfNull(lookInFrames);
|
||||
System.ArgumentNullException.ThrowIfNull(assembly);
|
||||
if (!ReferenceEquals(frame, assembly.Frame))
|
||||
throw new System.ArgumentException(
|
||||
"The look-in slots must be appended to the assembly's clip frame.",
|
||||
nameof(frame));
|
||||
|
||||
for (int frameIndex = 0; frameIndex < lookInFrames.Count; frameIndex++)
|
||||
{
|
||||
PortalVisibilityFrame lookIn = lookInFrames[frameIndex];
|
||||
foreach (uint cellId in lookIn.OrderedVisibleCells)
|
||||
{
|
||||
if (!lookIn.CellViews.TryGetValue(cellId, out CellView? view))
|
||||
continue;
|
||||
|
||||
List<ClipViewSlice> slices = assembly.SliceScratch;
|
||||
slices.Clear();
|
||||
foreach (ViewPolygon poly in view.Polygons)
|
||||
{
|
||||
ClipPlaneSet cps = ClipPlaneSet.From(poly);
|
||||
if (cps.IsNothingVisible)
|
||||
continue;
|
||||
|
||||
int slot;
|
||||
Vector4[] planes;
|
||||
if (cps.Count > 0)
|
||||
{
|
||||
planes = cps.PlaneArray;
|
||||
slot = frame.AppendSlot(planes);
|
||||
}
|
||||
else
|
||||
{
|
||||
planes = System.Array.Empty<Vector4>();
|
||||
slot = 0;
|
||||
assembly.ScissorFallbacks++;
|
||||
}
|
||||
|
||||
slices.Add(new ClipViewSlice(slot, AabbOf(poly), planes));
|
||||
}
|
||||
|
||||
if (slices.Count == 0)
|
||||
continue;
|
||||
|
||||
ClipViewSlice[] packed = assembly.CopySlices(slices);
|
||||
var key = new LookInClipCell(frameIndex, cellId);
|
||||
assembly.LookInCellToViewSlices.Add(key, packed);
|
||||
assembly.LookInCellToSlot.Add(key, packed[0].Slot);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static Vector4 AabbOf(ViewPolygon poly) =>
|
||||
new(poly.MinX, poly.MinY, poly.MaxX, poly.MaxY);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,372 +0,0 @@
|
|||
using System.Collections.Immutable;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Rendering;
|
||||
|
||||
namespace AcDream.App.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// #337 collision-mesh wireframe (2026-08-06 — TEMPORARY, strip with the #337
|
||||
/// probe family).
|
||||
///
|
||||
/// <para>
|
||||
/// The F2 collision overlay predating this class drew, for a BSP object, a
|
||||
/// proxy cylinder sized from the object's registered BROADPHASE radius. That
|
||||
/// answers "where does the collision system think this object roughly is" and
|
||||
/// nothing more. The open question in Neftet is a different one — whether an
|
||||
/// object's collision SURFACES are where its visual mesh is drawn — and a
|
||||
/// proxy sphere cannot answer it in either direction.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// This draws the actual geometry instead, in three colours that are meant to
|
||||
/// be read against each other:
|
||||
/// <list type="bullet">
|
||||
/// <item><b>Cyan</b> — the object's real physics-BSP polygon edges, in world
|
||||
/// space. These are the surfaces a body can stand on or be stopped by.
|
||||
/// Where the cyan mesh sits away from the rock you can see, the collision
|
||||
/// is displaced; where a visible rock has no cyan on it at all, it has no
|
||||
/// collision geometry.</item>
|
||||
/// <item><b>Magenta</b> — the same object's VISUAL mesh bounding box, from
|
||||
/// the same prepared assets the renderer draws from. It is the reference
|
||||
/// the cyan is judged against, so the comparison does not depend on the
|
||||
/// eye's guess about where the visual "really" is.</item>
|
||||
/// <item><b>Yellow</b> — the outdoor terrain surface under the player, as a
|
||||
/// grid of the physics engine's own sampled heights. If a body is resting
|
||||
/// on the yellow rather than on cyan, terrain is holding it up and the
|
||||
/// object's collision is not involved at all.</item>
|
||||
/// </list>
|
||||
/// Dim orange keeps the old broadphase proxy visible so nothing the previous
|
||||
/// overlay showed has been taken away.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Geometry is resolved through the SAME prepared collision accessors the
|
||||
/// resolver queries (<c>PhysicsDataCache.GetFlatGfxObj</c> /
|
||||
/// <c>GetVisualBounds</c>) and placed with the SAME world transform the
|
||||
/// collision probes use, so this cannot draw a shape the collision system does
|
||||
/// not actually hold. Reading the geometry by a second route is how AP-156
|
||||
/// managed to report a sphere the registry never emitted.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Pure reads. Nothing here mutates physics, registry, or render state; the
|
||||
/// caller owns the <see cref="DebugLineRenderer"/> frame.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal sealed class CollisionMeshWireframe
|
||||
{
|
||||
// Colours, in the order the class comment lists them.
|
||||
private static readonly Vector3 PhysicsColor = new(0f, 1f, 1f);
|
||||
private static readonly Vector3 VisualColor = new(1f, 0f, 1f);
|
||||
private static readonly Vector3 TerrainColor = new(1f, 1f, 0f);
|
||||
private static readonly Vector3 BroadphaseColor = new(0.45f, 0.22f, 0f);
|
||||
|
||||
/// <summary>
|
||||
/// Per-frame line ceiling. Each line is 48 bytes in the debug renderer's
|
||||
/// ring allocation, so this caps the overlay at ~1.9 MB a frame. Landblock
|
||||
/// 0x8766 carries the largest single collision owner measured in the game
|
||||
/// (an 81-cell footprint), and an uncapped walk of it would be the one
|
||||
/// place this overlay falls over.
|
||||
/// </summary>
|
||||
private const int MaxLines = 40_000;
|
||||
|
||||
/// <summary>Per-object polygon ceiling, so one enormous formation cannot
|
||||
/// consume the whole budget and hide every other object near it.</summary>
|
||||
private const int MaxPolygonsPerObject = 4_000;
|
||||
|
||||
/// <summary>Half-width in metres of the terrain grid drawn under the
|
||||
/// player, and its sample spacing.</summary>
|
||||
private const float TerrainGridHalfWidth = 12f;
|
||||
private const float TerrainGridStep = 2f;
|
||||
|
||||
private readonly PhysicsEngine _physics;
|
||||
|
||||
public CollisionMeshWireframe(PhysicsEngine physics)
|
||||
=> _physics = physics ?? throw new ArgumentNullException(nameof(physics));
|
||||
|
||||
/// <summary>
|
||||
/// Emit the overlay for everything within
|
||||
/// <see cref="RenderingDiagnostics.CollisionMeshWireframeRadius"/> of
|
||||
/// <paramref name="centre"/>. Returns what it drew so the caller can
|
||||
/// report a capped frame rather than silently showing partial geometry.
|
||||
/// </summary>
|
||||
public CollisionMeshWireframeStats Draw(DebugLineRenderer lines, Vector3 centre)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(lines);
|
||||
|
||||
float radius = RenderingDiagnostics.CollisionMeshWireframeRadius;
|
||||
float radiusSquared = radius * radius;
|
||||
var budget = new LineBudget(MaxLines);
|
||||
|
||||
int objects = 0;
|
||||
int polygons = 0;
|
||||
int withoutGeometry = 0;
|
||||
|
||||
PhysicsDataCache? cache = _physics.DataCache;
|
||||
|
||||
foreach (ShadowEntry shadow in _physics.ShadowObjects.AllEntriesForDebug())
|
||||
{
|
||||
// Objects register their part ORIGIN, which for a BSP part is
|
||||
// routinely nowhere near the geometry itself (376 of the 973
|
||||
// installed physics-BSP parts sit further from their own bounding
|
||||
// centre than half their radius). Admitting on origin distance
|
||||
// ALONE would drop exactly the large displaced-centre formations
|
||||
// this overlay exists to look at, so the object's own radius is
|
||||
// added to the window.
|
||||
float reach = radius + shadow.Radius;
|
||||
if (Vector3.DistanceSquared(shadow.Position, centre) > reach * reach)
|
||||
continue;
|
||||
|
||||
objects++;
|
||||
|
||||
if (shadow.CollisionType != ShadowCollisionType.BSP)
|
||||
{
|
||||
DrawBroadphaseProxy(lines, in shadow, budget);
|
||||
continue;
|
||||
}
|
||||
|
||||
FlatGfxObjCollisionAsset? asset = cache?.GetFlatGfxObj(shadow.GfxObjId);
|
||||
int drawn = DrawPhysicsPolygons(lines, in shadow, asset, centre, radiusSquared, budget);
|
||||
polygons += drawn;
|
||||
if (drawn == 0) withoutGeometry++;
|
||||
|
||||
DrawVisualBounds(lines, in shadow, cache?.GetVisualBounds(shadow.GfxObjId), budget);
|
||||
DrawBroadphaseProxy(lines, in shadow, budget);
|
||||
}
|
||||
|
||||
DrawTerrainGrid(lines, centre, budget);
|
||||
|
||||
return new CollisionMeshWireframeStats(
|
||||
ObjectsConsidered: objects,
|
||||
PolygonsDrawn: polygons,
|
||||
ObjectsWithoutPhysicsGeometry: withoutGeometry,
|
||||
LinesDrawn: budget.Used,
|
||||
Capped: budget.Capped);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Walk the object's physics BSP and emit one closed edge loop per polygon
|
||||
/// the tree actually indexes. Polygons the tree does not reference are NOT
|
||||
/// drawn: no query can reach them, so showing them would overstate the
|
||||
/// collision surface. Returns the polygon count emitted.
|
||||
/// </summary>
|
||||
private static int DrawPhysicsPolygons(
|
||||
DebugLineRenderer lines,
|
||||
in ShadowEntry shadow,
|
||||
FlatGfxObjCollisionAsset? asset,
|
||||
Vector3 centre,
|
||||
float radiusSquared,
|
||||
LineBudget budget)
|
||||
{
|
||||
FlatPhysicsBsp? bsp = asset?.PhysicsBsp;
|
||||
if (bsp is not { RootIndex: >= 0 } || bsp.Nodes.Length == 0)
|
||||
return 0;
|
||||
|
||||
FlatPolygonTable table = bsp.PolygonTable;
|
||||
ImmutableArray<Vector3> vertices = table.Vertices;
|
||||
int emitted = 0;
|
||||
|
||||
foreach (FlatPhysicsBspNode node in bsp.Nodes)
|
||||
{
|
||||
FlatIndexRange indices = node.PolygonIndexRange;
|
||||
for (int i = indices.Start; i < indices.EndExclusive; i++)
|
||||
{
|
||||
if (emitted >= MaxPolygonsPerObject || budget.Exhausted)
|
||||
return emitted;
|
||||
|
||||
int polygonIndex = bsp.PolygonIndexStream[i];
|
||||
if ((uint)polygonIndex >= (uint)table.Polygons.Length) continue;
|
||||
|
||||
FlatIndexRange span = table.Polygons[polygonIndex].VertexRange;
|
||||
if (span.Count < 2) continue;
|
||||
|
||||
Vector3 first = ToWorld(vertices[span.Start], in shadow);
|
||||
// Per-polygon distance rejection, AFTER the world transform:
|
||||
// a big object admitted by the object-level window still only
|
||||
// needs the faces near the player drawn.
|
||||
if (Vector3.DistanceSquared(first, centre) > radiusSquared) continue;
|
||||
|
||||
Vector3 previous = first;
|
||||
for (int v = span.Start + 1; v < span.EndExclusive; v++)
|
||||
{
|
||||
Vector3 current = ToWorld(vertices[v], in shadow);
|
||||
if (!budget.TryAdd()) return emitted;
|
||||
lines.AddLine(previous, current, PhysicsColor);
|
||||
previous = current;
|
||||
}
|
||||
|
||||
if (span.Count > 2)
|
||||
{
|
||||
if (!budget.TryAdd()) return emitted;
|
||||
lines.AddLine(previous, first, PhysicsColor);
|
||||
}
|
||||
|
||||
emitted++;
|
||||
}
|
||||
}
|
||||
|
||||
return emitted;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The visual mesh box, placed with the SAME transform as the physics
|
||||
/// polygons above. It is drawn as the object's own rotated box (eight
|
||||
/// transformed corners, twelve edges) rather than as a world-axis-aligned
|
||||
/// box, so a rotated object's magenta lines still bound its actual visual.
|
||||
/// </summary>
|
||||
private static void DrawVisualBounds(
|
||||
DebugLineRenderer lines,
|
||||
in ShadowEntry shadow,
|
||||
GfxObjVisualBounds? visual,
|
||||
LineBudget budget)
|
||||
{
|
||||
if (visual is null || budget.Exhausted) return;
|
||||
|
||||
Vector3 min = visual.Min;
|
||||
Vector3 max = visual.Max;
|
||||
Span<Vector3> corners =
|
||||
[
|
||||
ToWorld(new Vector3(min.X, min.Y, min.Z), in shadow),
|
||||
ToWorld(new Vector3(max.X, min.Y, min.Z), in shadow),
|
||||
ToWorld(new Vector3(max.X, max.Y, min.Z), in shadow),
|
||||
ToWorld(new Vector3(min.X, max.Y, min.Z), in shadow),
|
||||
ToWorld(new Vector3(min.X, min.Y, max.Z), in shadow),
|
||||
ToWorld(new Vector3(max.X, min.Y, max.Z), in shadow),
|
||||
ToWorld(new Vector3(max.X, max.Y, max.Z), in shadow),
|
||||
ToWorld(new Vector3(min.X, max.Y, max.Z), in shadow),
|
||||
];
|
||||
|
||||
ReadOnlySpan<int> edges =
|
||||
[
|
||||
0, 1, 1, 2, 2, 3, 3, 0,
|
||||
4, 5, 5, 6, 6, 7, 7, 4,
|
||||
0, 4, 1, 5, 2, 6, 3, 7,
|
||||
];
|
||||
|
||||
for (int e = 0; e < edges.Length; e += 2)
|
||||
{
|
||||
if (!budget.TryAdd()) return;
|
||||
lines.AddLine(corners[edges[e]], corners[edges[e + 1]], VisualColor);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The registered broadphase shape — what the pre-#337 overlay showed, and
|
||||
/// what the collision system's reach filter measures against. Kept so this
|
||||
/// overlay is a superset of the one it replaces.
|
||||
/// </summary>
|
||||
private static void DrawBroadphaseProxy(
|
||||
DebugLineRenderer lines,
|
||||
in ShadowEntry shadow,
|
||||
LineBudget budget)
|
||||
{
|
||||
// AddCylinder emits a fixed 36 lines. Reserve them together so a
|
||||
// partial ring cannot be drawn.
|
||||
if (!budget.TryAdd(36)) return;
|
||||
|
||||
if (shadow.CollisionType == ShadowCollisionType.Cylinder)
|
||||
{
|
||||
float height = shadow.CylHeight > 0f ? shadow.CylHeight : shadow.Radius * 2f;
|
||||
lines.AddCylinder(shadow.Position, shadow.Radius, height, BroadphaseColor);
|
||||
return;
|
||||
}
|
||||
|
||||
lines.AddCylinder(
|
||||
shadow.Position - new Vector3(0f, 0f, shadow.Radius),
|
||||
shadow.Radius,
|
||||
shadow.Radius * 2f,
|
||||
BroadphaseColor);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The terrain surface under the player, sampled through the physics
|
||||
/// engine's own height resolver — the same numbers the resolver grounds
|
||||
/// against, not a re-derivation. Drawn as a grid rather than as the single
|
||||
/// containing triangle so the slope around the player reads at a glance.
|
||||
/// </summary>
|
||||
private void DrawTerrainGrid(DebugLineRenderer lines, Vector3 centre, LineBudget budget)
|
||||
{
|
||||
int steps = (int)(TerrainGridHalfWidth * 2f / TerrainGridStep);
|
||||
float originX = centre.X - TerrainGridHalfWidth;
|
||||
float originY = centre.Y - TerrainGridHalfWidth;
|
||||
|
||||
for (int ix = 0; ix <= steps; ix++)
|
||||
{
|
||||
for (int iy = 0; iy <= steps; iy++)
|
||||
{
|
||||
float x = originX + ix * TerrainGridStep;
|
||||
float y = originY + iy * TerrainGridStep;
|
||||
float? z = _physics.SampleTerrainZ(x, y);
|
||||
if (z is null) continue;
|
||||
|
||||
var here = new Vector3(x, y, z.Value);
|
||||
|
||||
if (ix < steps)
|
||||
{
|
||||
float nx = x + TerrainGridStep;
|
||||
if (_physics.SampleTerrainZ(nx, y) is { } nz)
|
||||
{
|
||||
if (!budget.TryAdd()) return;
|
||||
lines.AddLine(here, new Vector3(nx, y, nz), TerrainColor);
|
||||
}
|
||||
}
|
||||
|
||||
if (iy < steps)
|
||||
{
|
||||
float ny = y + TerrainGridStep;
|
||||
if (_physics.SampleTerrainZ(x, ny) is { } nz2)
|
||||
{
|
||||
if (!budget.TryAdd()) return;
|
||||
lines.AddLine(here, new Vector3(x, ny, nz2), TerrainColor);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The one placement formula, matching the <c>[resolve-bldg]</c> probe's
|
||||
/// world transform for a shadow part
|
||||
/// (<c>TransitionTypes.FindObjCollisionsInCell</c>): scale in the part's
|
||||
/// own frame, then rotate, then translate to the registered position.
|
||||
/// </summary>
|
||||
private static Vector3 ToWorld(Vector3 local, in ShadowEntry shadow)
|
||||
=> shadow.Position + Vector3.Transform(local * shadow.Scale, shadow.Rotation);
|
||||
|
||||
/// <summary>
|
||||
/// Mutable line counter shared across the draw. A class rather than a
|
||||
/// struct so the per-shape helpers can be static and still share it
|
||||
/// without ref-plumbing through every signature.
|
||||
/// </summary>
|
||||
private sealed class LineBudget(int limit)
|
||||
{
|
||||
public int Used { get; private set; }
|
||||
|
||||
public bool Capped { get; private set; }
|
||||
|
||||
public bool Exhausted => Used >= limit;
|
||||
|
||||
public bool TryAdd(int count = 1)
|
||||
{
|
||||
if (Used + count > limit)
|
||||
{
|
||||
Capped = true;
|
||||
return false;
|
||||
}
|
||||
Used += count;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>What one <see cref="CollisionMeshWireframe.Draw"/> emitted.
|
||||
/// <paramref name="ObjectsWithoutPhysicsGeometry"/> is the interesting one: a
|
||||
/// non-zero count means objects near the player carry no reachable collision
|
||||
/// polygons at all.</summary>
|
||||
internal readonly record struct CollisionMeshWireframeStats(
|
||||
int ObjectsConsidered,
|
||||
int PolygonsDrawn,
|
||||
int ObjectsWithoutPhysicsGeometry,
|
||||
int LinesDrawn,
|
||||
bool Capped);
|
||||
|
|
@ -134,13 +134,16 @@ internal sealed class RetailCreatureAppraisalFrameView :
|
|||
width = 0;
|
||||
height = 0;
|
||||
if (_controller.ActiveView is not (
|
||||
AppraisalView.Creature or AppraisalView.Character)
|
||||
|| !IsEffectivelyVisible(_windowFrame)
|
||||
|| !IsEffectivelyVisible(_viewport)
|
||||
|| _controller.CurrentObjectId == 0u)
|
||||
AppraisalView.Creature or AppraisalView.Character))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (!IsEffectivelyVisible(_windowFrame))
|
||||
return false;
|
||||
if (!IsEffectivelyVisible(_viewport))
|
||||
return false;
|
||||
if (_controller.CurrentObjectId == 0u)
|
||||
return false;
|
||||
|
||||
serverGuid = _controller.CurrentObjectId;
|
||||
width = (int)_viewport.Width;
|
||||
|
|
@ -203,11 +206,10 @@ internal sealed class RetailCreatureAppraisalCloneFactory :
|
|||
synchronizedClone = null;
|
||||
boundsMin = Vector3.Zero;
|
||||
boundsMax = Vector3.Zero;
|
||||
if (!_entities.TryGet(serverGuid, out WorldEntity source)
|
||||
|| source.MeshRefs.Count == 0)
|
||||
{
|
||||
if (!_entities.TryGet(serverGuid, out WorldEntity source))
|
||||
return false;
|
||||
if (source.MeshRefs.Count == 0)
|
||||
return false;
|
||||
}
|
||||
|
||||
WorldEntity clone = currentClone is not null
|
||||
&& currentClone.SourceGfxObjOrSetupId == source.SourceGfxObjOrSetupId
|
||||
|
|
|
|||
|
|
@ -58,17 +58,6 @@ internal sealed class DatLiveEntityProjectionMaterializer
|
|||
/// </summary>
|
||||
private readonly RuntimeWorldTransitState _transit;
|
||||
|
||||
private int _received;
|
||||
private int _hydrated;
|
||||
private int _noPosition;
|
||||
private int _noSetup;
|
||||
private int _missingSetup;
|
||||
private int _noMesh;
|
||||
private int _noCycle;
|
||||
private int _zeroFramerate;
|
||||
private int _singleFrame;
|
||||
private int _missingPartFrames;
|
||||
|
||||
public DatLiveEntityProjectionMaterializer(
|
||||
RuntimeOptions options,
|
||||
IDatReaderWriter dats,
|
||||
|
|
@ -116,16 +105,6 @@ internal sealed class DatLiveEntityProjectionMaterializer
|
|||
|
||||
public void ResetSessionState()
|
||||
{
|
||||
_received = 0;
|
||||
_hydrated = 0;
|
||||
_noPosition = 0;
|
||||
_noSetup = 0;
|
||||
_missingSetup = 0;
|
||||
_noMesh = 0;
|
||||
_noCycle = 0;
|
||||
_zeroFramerate = 0;
|
||||
_singleFrame = 0;
|
||||
_missingPartFrames = 0;
|
||||
}
|
||||
|
||||
public bool TryMaterialize(
|
||||
|
|
@ -157,18 +136,10 @@ internal sealed class DatLiveEntityProjectionMaterializer
|
|||
if (appearanceUpdate is not null && expectedRecord is null)
|
||||
return false;
|
||||
|
||||
_received++;
|
||||
bool dumpLiveSpawns = _options.DumpLiveSpawns;
|
||||
DumpSpawn(canonicalSpawn, dumpLiveSpawns);
|
||||
|
||||
if (!_origin.IsKnown)
|
||||
return false;
|
||||
if (canonicalSpawn.Position is null || canonicalSpawn.SetupTableId is null)
|
||||
{
|
||||
if (canonicalSpawn.Position is null)
|
||||
_noPosition++;
|
||||
else
|
||||
_noSetup++;
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
@ -216,13 +187,6 @@ internal sealed class DatLiveEntityProjectionMaterializer
|
|||
_collisionAssets.CacheSetup(canonicalSpawn.SetupTableId.Value, setup);
|
||||
if (setup is null)
|
||||
{
|
||||
_missingSetup++;
|
||||
if (dumpLiveSpawns)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"live: DROP setup dat 0x{canonicalSpawn.SetupTableId.Value:X8} missing "
|
||||
+ $"(guid=0x{canonicalSpawn.Guid:X8})");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
@ -248,14 +212,6 @@ internal sealed class DatLiveEntityProjectionMaterializer
|
|||
List<MeshRef> flattened = [.. SetupMesh.Flatten(setup, idleFrame)];
|
||||
IReadOnlyList<CreateObject.AnimPartChange> animPartChanges =
|
||||
canonicalSpawn.AnimPartChanges ?? Array.Empty<CreateObject.AnimPartChange>();
|
||||
bool dumpClothing = _options.DumpClothing && setup.Parts.Count >= 10;
|
||||
DumpClothingHeader(
|
||||
canonicalSpawn,
|
||||
setup,
|
||||
flattened,
|
||||
idleFrame,
|
||||
animPartChanges,
|
||||
dumpClothing);
|
||||
|
||||
foreach (CreateObject.AnimPartChange change in animPartChanges)
|
||||
{
|
||||
|
|
@ -273,17 +229,14 @@ internal sealed class DatLiveEntityProjectionMaterializer
|
|||
baseId => ResolveCollisionPart(baseId));
|
||||
|
||||
if (_options.RetailCloseDegrades && IsIssue47HumanoidSetup(setup))
|
||||
ApplyRetailCloseDegrades(flattened, dumpClothing);
|
||||
ApplyRetailCloseDegrades(flattened);
|
||||
|
||||
IReadOnlyList<CreateObject.TextureChange> textureChanges =
|
||||
canonicalSpawn.TextureChanges ?? Array.Empty<CreateObject.TextureChange>();
|
||||
Dictionary<int, Dictionary<uint, uint>>? surfaceOverrides =
|
||||
ResolveSurfaceOverrides(
|
||||
canonicalSpawn,
|
||||
flattened,
|
||||
textureChanges,
|
||||
dumpClothing,
|
||||
dumpLiveSpawns);
|
||||
textureChanges);
|
||||
|
||||
float scale = canonicalSpawn.ObjScale ?? 1f;
|
||||
Matrix4x4 scaleMatrix = Matrix4x4.CreateScale(scale);
|
||||
|
|
@ -294,7 +247,6 @@ internal sealed class DatLiveEntityProjectionMaterializer
|
|||
var indexedPartAvailable = new bool[flattened.Count];
|
||||
var animatedPartTemplate = new LiveAnimationPartTemplate[flattened.Count];
|
||||
var bounds = new LocalBoundsAccumulator();
|
||||
int clothingTriangles = 0;
|
||||
|
||||
for (int partIndex = 0; partIndex < flattened.Count; partIndex++)
|
||||
{
|
||||
|
|
@ -318,23 +270,10 @@ internal sealed class DatLiveEntityProjectionMaterializer
|
|||
drawable);
|
||||
if (gfx is null)
|
||||
{
|
||||
if (dumpClothing)
|
||||
Console.WriteLine($" EMIT part={partIndex:D2} gfx=0x{part.GfxObjId:X8} GFXOBJ_DAT_MISSING -> 0 tris");
|
||||
continue;
|
||||
}
|
||||
|
||||
_collisionAssets.CacheGfxObj(part.GfxObjId, gfx);
|
||||
if (dumpClothing)
|
||||
{
|
||||
var subMeshes = GfxObjMesh.Build(gfx, _dats);
|
||||
int triangles = 0;
|
||||
foreach (var subMesh in subMeshes)
|
||||
triangles += subMesh.Indices.Length / 3;
|
||||
clothingTriangles += triangles;
|
||||
Console.WriteLine(
|
||||
$" EMIT part={partIndex:D2} gfx=0x{part.GfxObjId:X8} "
|
||||
+ $"subMeshes={subMeshes.Count} tris={triangles}");
|
||||
}
|
||||
|
||||
if (GfxObjBounds.Get(gfx) is { } partBounds)
|
||||
bounds.Add(transform, partBounds);
|
||||
|
|
@ -346,21 +285,8 @@ internal sealed class DatLiveEntityProjectionMaterializer
|
|||
|
||||
if (meshRefs.Count == 0)
|
||||
{
|
||||
_noMesh++;
|
||||
if (dumpLiveSpawns)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"live: DROP no mesh refs from setup 0x{canonicalSpawn.SetupTableId.Value:X8} "
|
||||
+ $"(guid=0x{canonicalSpawn.Guid:X8})");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (dumpClothing)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$" TOTAL tris={clothingTriangles} meshRefs={meshRefs.Count} "
|
||||
+ $"(parts.Count={flattened.Count})");
|
||||
}
|
||||
|
||||
PaletteOverride? paletteOverride = CreatePaletteOverride(canonicalSpawn);
|
||||
PartOverride[] partOverrides = CreatePartOverrides(animPartChanges);
|
||||
|
|
@ -456,7 +382,6 @@ internal sealed class DatLiveEntityProjectionMaterializer
|
|||
indexedPartAvailable,
|
||||
animatedPartTemplate,
|
||||
bounds,
|
||||
dumpLiveSpawns,
|
||||
expectedCreateIntegrationVersion,
|
||||
synchronizeAnimation: supersessionRecovery);
|
||||
}
|
||||
|
|
@ -477,9 +402,7 @@ internal sealed class DatLiveEntityProjectionMaterializer
|
|||
return slotZeroId;
|
||||
}
|
||||
|
||||
private void ApplyRetailCloseDegrades(
|
||||
List<MeshRef> parts,
|
||||
bool dumpClothing)
|
||||
private void ApplyRetailCloseDegrades(List<MeshRef> parts)
|
||||
{
|
||||
for (int partIndex = 0; partIndex < parts.Count; partIndex++)
|
||||
{
|
||||
|
|
@ -495,33 +418,13 @@ internal sealed class DatLiveEntityProjectionMaterializer
|
|||
}
|
||||
|
||||
parts[partIndex] = new MeshRef(resolvedId, part.PartTransform);
|
||||
if (dumpClothing)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$" DEGRADE part={partIndex:D2} gfx=0x{part.GfxObjId:X8} "
|
||||
+ $"-> close=0x{resolvedId:X8}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Dictionary<int, Dictionary<uint, uint>>? ResolveSurfaceOverrides(
|
||||
WorldSession.EntitySpawn spawn,
|
||||
IReadOnlyList<MeshRef> parts,
|
||||
IReadOnlyList<CreateObject.TextureChange> textureChanges,
|
||||
bool dumpClothing,
|
||||
bool dumpLiveSpawns)
|
||||
IReadOnlyList<CreateObject.TextureChange> textureChanges)
|
||||
{
|
||||
if (dumpClothing)
|
||||
{
|
||||
Console.WriteLine($" TextureChanges count={textureChanges.Count}");
|
||||
foreach (CreateObject.TextureChange change in textureChanges)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$" TC part={change.PartIndex:D2} oldTex=0x{change.OldTexture:X8} "
|
||||
+ $"-> newTex=0x{change.NewTexture:X8}");
|
||||
}
|
||||
}
|
||||
|
||||
if (textureChanges.Count == 0)
|
||||
return null;
|
||||
|
||||
|
|
@ -536,8 +439,6 @@ internal sealed class DatLiveEntityProjectionMaterializer
|
|||
oldToNew[change.OldTexture] = change.NewTexture;
|
||||
}
|
||||
|
||||
bool statueDiagnostic = dumpLiveSpawns
|
||||
&& spawn.Name?.Contains("Statue", StringComparison.OrdinalIgnoreCase) == true;
|
||||
var result = new Dictionary<int, Dictionary<uint, uint>>();
|
||||
for (int partIndex = 0; partIndex < parts.Count; partIndex++)
|
||||
{
|
||||
|
|
@ -547,12 +448,6 @@ internal sealed class DatLiveEntityProjectionMaterializer
|
|||
GfxObj? gfx = _dats.Get<GfxObj>(parts[partIndex].GfxObjId);
|
||||
if (gfx is null)
|
||||
{
|
||||
if (statueDiagnostic)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"live: [STATUE] resolve part={partIndex} "
|
||||
+ $"GfxObj 0x{parts[partIndex].GfxObjId:X8} missing");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
_collisionAssets.CacheGfxObj(parts[partIndex].GfxObjId, gfx);
|
||||
|
|
@ -729,7 +624,6 @@ internal sealed class DatLiveEntityProjectionMaterializer
|
|||
IReadOnlyList<bool> indexedPartAvailable,
|
||||
IReadOnlyList<LiveAnimationPartTemplate> animatedPartTemplate,
|
||||
LocalBoundsAccumulator bounds,
|
||||
bool dumpLiveSpawns,
|
||||
ulong expectedCreateIntegrationVersion,
|
||||
bool synchronizeAnimation)
|
||||
{
|
||||
|
|
@ -835,7 +729,6 @@ internal sealed class DatLiveEntityProjectionMaterializer
|
|||
_worldEvents.UpsertCurrent(snapshot);
|
||||
if (_runtime.TryMarkWorldSpawnPublished(spawn.Guid))
|
||||
_worldEvents.FireEntitySpawned(snapshot);
|
||||
_hydrated++;
|
||||
|
||||
if (!_runtime.IsCurrentCreateIntegration(
|
||||
expectedRecord,
|
||||
|
|
@ -921,18 +814,6 @@ internal sealed class DatLiveEntityProjectionMaterializer
|
|||
return false;
|
||||
}
|
||||
|
||||
if (dumpLiveSpawns && _received % 20 == 0)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"live: animated={_runtime.AnimationRuntimeCount} "
|
||||
+ $"animReject: noCycle={_noCycle} fr0={_zeroFramerate} "
|
||||
+ $"1frame={_singleFrame} partFrames={_missingPartFrames}");
|
||||
Console.WriteLine(
|
||||
$"live: summary recv={_received} hydrated={_hydrated} "
|
||||
+ $"drops: noPos={_noPosition} noSetup={_noSetup} "
|
||||
+ $"setupMissing={_missingSetup} noMesh={_noMesh}");
|
||||
}
|
||||
|
||||
return _runtime.IsCurrentCreateIntegration(
|
||||
expectedRecord,
|
||||
expectedCreateIntegrationVersion);
|
||||
|
|
@ -961,18 +842,6 @@ internal sealed class DatLiveEntityProjectionMaterializer
|
|||
spawn,
|
||||
idleCycle);
|
||||
}
|
||||
if (!retainedAnimation)
|
||||
{
|
||||
if (idleCycle is null)
|
||||
_noCycle++;
|
||||
else if (idleCycle.Framerate == 0f)
|
||||
_zeroFramerate++;
|
||||
else if (idleCycle.HighFrame <= idleCycle.LowFrame)
|
||||
_singleFrame++;
|
||||
else if (idleCycle.Animation.PartFrames.Count <= 1)
|
||||
_missingPartFrames++;
|
||||
}
|
||||
|
||||
if (!retainedAnimation
|
||||
&& idleCycle is not null
|
||||
&& idleCycle.Framerate != 0f
|
||||
|
|
@ -1159,56 +1028,6 @@ internal sealed class DatLiveEntityProjectionMaterializer
|
|||
: null;
|
||||
}
|
||||
|
||||
private void DumpSpawn(WorldSession.EntitySpawn spawn, bool enabled)
|
||||
{
|
||||
if (!enabled)
|
||||
return;
|
||||
|
||||
string position = spawn.Position is { } p
|
||||
? $"({p.PositionX:F1},{p.PositionY:F1},{p.PositionZ:F1})@0x{p.LandblockId:X8}"
|
||||
: "no-pos";
|
||||
string setup = spawn.SetupTableId is { } setupId
|
||||
? $"0x{setupId:X8}"
|
||||
: "no-setup";
|
||||
string physicsTable = spawn.Physics?.PhysicsScriptTableId is { } tableId
|
||||
? $"0x{tableId:X8}"
|
||||
: "no-petable";
|
||||
string name = spawn.Name is { Length: > 0 } foundName
|
||||
? $"\"{foundName}\""
|
||||
: "no-name";
|
||||
string itemType = spawn.ItemType is { } foundItemType
|
||||
? $"0x{foundItemType:X8}"
|
||||
: "no-itemtype";
|
||||
Console.WriteLine(
|
||||
$"live: spawn guid=0x{spawn.Guid:X8} name={name} setup={setup} pos={position} "
|
||||
+ $"petable={physicsTable} itemType={itemType} "
|
||||
+ $"animParts={spawn.AnimPartChanges?.Count ?? 0} "
|
||||
+ $"texChanges={spawn.TextureChanges?.Count ?? 0} "
|
||||
+ $"subPalettes={spawn.SubPalettes?.Count ?? 0}");
|
||||
}
|
||||
|
||||
private void DumpClothingHeader(
|
||||
WorldSession.EntitySpawn spawn,
|
||||
Setup setup,
|
||||
IReadOnlyList<MeshRef> flattened,
|
||||
AnimationFrame? idleFrame,
|
||||
IReadOnlyList<CreateObject.AnimPartChange> changes,
|
||||
bool enabled)
|
||||
{
|
||||
if (!enabled)
|
||||
return;
|
||||
|
||||
Console.WriteLine(
|
||||
$"\n=== DUMP_CLOTHING: guid=0x{spawn.Guid:X8} name='{spawn.Name}' "
|
||||
+ $"setup=0x{setup.Id:X8} setup.Parts.Count={setup.Parts.Count} "
|
||||
+ $"flatten.Count={flattened.Count} APC={changes.Count} ===");
|
||||
foreach (CreateObject.AnimPartChange change in changes)
|
||||
Console.WriteLine($" APC part={change.PartIndex:D2} -> gfx=0x{change.NewModelId:X8}");
|
||||
Console.WriteLine(
|
||||
$" basePalette=0x{spawn.BasePaletteId ?? 0:X8} "
|
||||
+ $"subPalettes={spawn.SubPalettes?.Count ?? 0}");
|
||||
}
|
||||
|
||||
private static bool IsIssue47HumanoidSetup(Setup setup)
|
||||
{
|
||||
if (setup.Parts.Count != 34)
|
||||
|
|
|
|||
|
|
@ -38,7 +38,12 @@ internal readonly record struct DirectionalSunShadowRenderInput(
|
|||
float ResidentMaximumReachMeters = float.PositiveInfinity,
|
||||
bool MeasureGpuTimers = true,
|
||||
bool MeasureCpuStages = false,
|
||||
AtmosphericFrameBufferBinding AtmosphericFrame = default);
|
||||
AtmosphericFrameBufferBinding AtmosphericFrame = default,
|
||||
// #429 owner-approved pipelining: false keeps the retained caster/draw
|
||||
// topology this frame (transform refresh only) so the rebuild lands on a
|
||||
// quieter frame. The prepare seams below re-validate and rebuild anyway
|
||||
// whenever deferral would be unsafe.
|
||||
bool AllowTopologyRebuild = true);
|
||||
|
||||
internal readonly record struct DirectionalSunShadowCpuStageTicks(
|
||||
long EnvironmentGateTicks,
|
||||
|
|
@ -394,7 +399,9 @@ internal sealed class DirectionalSunShadowRenderer : IDirectionalShadowReceiverS
|
|||
|
||||
long cpuStageStarted = input.MeasureCpuStages ? Stopwatch.GetTimestamp() : 0L;
|
||||
DirectionalShadowPreparedDraws worldDraws =
|
||||
world.PrepareDirectionalShadowDraws(input.Casters);
|
||||
world.PrepareDirectionalShadowDraws(
|
||||
input.Casters,
|
||||
input.AllowTopologyRebuild);
|
||||
DirectionalShadowTerrainPreparedDraws terrainDraws =
|
||||
terrain.PrepareDirectionalShadowDraws();
|
||||
DirectionalShadowMeshGeometry? worldGeometry =
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
using AcDream.Core.Plugins;
|
||||
using AcDream.App.Composition;
|
||||
using AcDream.App.Input;
|
||||
using AcDream.App.Physics;
|
||||
using AcDream.App.Rendering.Gpu;
|
||||
using AcDream.App.Rendering.Scene;
|
||||
|
|
@ -17,6 +18,7 @@ using DatReaderWriter;
|
|||
using Silk.NET.Input;
|
||||
using Silk.NET.Maths;
|
||||
using Silk.NET.Windowing;
|
||||
using AcDream.UI.Abstractions.Input;
|
||||
|
||||
namespace AcDream.App.Rendering;
|
||||
|
||||
|
|
@ -134,8 +136,8 @@ public sealed class GameWindow :
|
|||
private DebugLineRenderer? _debugLines;
|
||||
// K-fix4 (2026-04-26): default OFF. The orange BSP / green cylinder
|
||||
// wireframes are noisy outdoors and confuse first-time users into
|
||||
// thinking they're a rendering bug. Ctrl+F2 toggles, the DebugPanel
|
||||
// → Diagnostics → "Toggle collision wires" button toggles too.
|
||||
// thinking they're a rendering bug. Ctrl+F2 toggles. (The DebugPanel
|
||||
// button that also toggled it is gone — #434.)
|
||||
private readonly AcDream.App.Rendering.WorldSceneDebugState
|
||||
_worldSceneDebugState = new();
|
||||
|
||||
|
|
@ -599,14 +601,18 @@ public sealed class GameWindow :
|
|||
// startup — no other call to RetailDefaults() / AcdreamCurrentDefaults()
|
||||
// should land in the GameWindow construction path.
|
||||
private readonly AcDream.UI.Abstractions.Input.KeyBindings _keyBindings;
|
||||
private bool _keyBindingsPersisted;
|
||||
private readonly GraphicalHostPlatformServices _platformServices;
|
||||
private readonly ApplicationPathSet _applicationPaths;
|
||||
|
||||
private static AcDream.UI.Abstractions.Input.KeyBindings LoadStartupKeyBindings(
|
||||
string path)
|
||||
{
|
||||
var bindings = AcDream.UI.Abstractions.Input.KeyBindings.LoadOrDefault(path);
|
||||
Console.WriteLine($"keybinds: loaded {bindings.All.Count} bindings from {path}");
|
||||
var bindings = AcDream.App.Input.RetailKeymapProfileStore.LoadActiveOrJson(
|
||||
path, out string profileName);
|
||||
Console.WriteLine(
|
||||
$"keybinds: loaded {bindings.All.Count} bindings; active retail profile "
|
||||
+ $"'{profileName}', JSON mirror {path}");
|
||||
return bindings;
|
||||
}
|
||||
|
||||
|
|
@ -709,6 +715,7 @@ public sealed class GameWindow :
|
|||
// reset across generations. Re-binding per session would be re-binding
|
||||
// the same two references.
|
||||
_automation?.Bind(_runtime, _runtime.CharacterOwner, _runtime.ActionOwner.SpellCast);
|
||||
_automation?.BindProjectileCollision(_physicsEngine);
|
||||
_localPlayerIdentity = new AcDream.App.Input.LocalPlayerIdentityState(
|
||||
_runtime.PlayerIdentity);
|
||||
_updateFrameClock = new AcDream.App.Update.UpdateFrameClock(
|
||||
|
|
@ -954,6 +961,10 @@ public sealed class GameWindow :
|
|||
// zero skills with no error to explain it.
|
||||
if (_automation is null)
|
||||
return;
|
||||
_automation.BindSpeciesNameResolver(
|
||||
AcDream.App.UI.Layout.CreatureDisplayNameResolver.Load(value).Resolve);
|
||||
_automation.BindPaletteColorResolver(
|
||||
new AcDream.Content.CharGen.ChargenAppearanceCatalog(value));
|
||||
if (!value.TryGet<DatReaderWriter.DBObjs.SkillTable>(0x0E000004u, out var skillTable)
|
||||
|| skillTable is null)
|
||||
{
|
||||
|
|
@ -977,8 +988,11 @@ public sealed class GameWindow :
|
|||
"prepared asset source");
|
||||
|
||||
void IGameWindowContentEffectsAudioPublication.PublishMagicCatalog(
|
||||
MagicCatalog value) =>
|
||||
MagicCatalog value)
|
||||
{
|
||||
PublishCompositionOwner(ref _magicCatalog, value, "magic catalog");
|
||||
_automation?.BindMagicCatalog(value);
|
||||
}
|
||||
|
||||
void IGameWindowContentEffectsAudioPublication.PublishAnimationLoader(
|
||||
AcDream.Core.Physics.IAnimationLoader value) =>
|
||||
|
|
@ -1138,6 +1152,25 @@ public sealed class GameWindow :
|
|||
_combatAttackController = result.CombatAttack;
|
||||
_externalContainerLifecycle = result.ExternalContainerLifecycle;
|
||||
_itemInteractionController = result.ItemInteraction;
|
||||
_automation?.BindEquipment(
|
||||
(itemId, requestedLocation) =>
|
||||
result.ItemInteraction.TryWieldItem(
|
||||
itemId,
|
||||
(AcDream.Core.Items.EquipMask)requestedLocation),
|
||||
() => result.ItemInteraction.IsAutoWieldBusy);
|
||||
_automation?.BindItems(
|
||||
result.ItemInteraction.TryUseItemForAutomation,
|
||||
result.ItemInteraction.TryApplyItem,
|
||||
result.ItemInteraction.TryMoveItemForAutomation,
|
||||
result.ItemInteraction.TryMergeItemsForAutomation,
|
||||
result.ItemInteraction.TryDropItemForAutomation,
|
||||
result.ItemInteraction.TryGiveItemForAutomation,
|
||||
result.ItemInteraction.PlaceWorldItemInBackpack,
|
||||
result.ItemInteraction.TryAppraiseForAutomation,
|
||||
result.ItemInteraction.TrySalvageItemsForAutomation,
|
||||
(vendorId, itemId, amount) => result.ItemInteraction.TrySell(
|
||||
vendorId,
|
||||
[(amount, itemId)]));
|
||||
_interactionUiLateBindings = result.LateBindings;
|
||||
_magicRuntime = result.Magic;
|
||||
if (result.RetainedUi is { } retained)
|
||||
|
|
@ -1213,6 +1246,17 @@ public sealed class GameWindow :
|
|||
_retailSelectionScene = result.SelectionScene;
|
||||
_worldSelectionQuery = result.SelectionQuery;
|
||||
_selectionInteractions = result.SelectionInteractions;
|
||||
_automation?.BindSelectionActions(action =>
|
||||
result.SelectionInteractions.HandleInputAction(action switch
|
||||
{
|
||||
AcDream.Plugin.Abstractions.PluginSelectionAction.PreviousSelection =>
|
||||
InputAction.SelectionPreviousSelection,
|
||||
AcDream.Plugin.Abstractions.PluginSelectionAction.PreviousPlayer =>
|
||||
InputAction.SelectionPreviousPlayer,
|
||||
AcDream.Plugin.Abstractions.PluginSelectionAction.NextPlayer =>
|
||||
InputAction.SelectionNextPlayer,
|
||||
_ => InputAction.None,
|
||||
}));
|
||||
_retainedUiGameplayBinding = result.RetainedGameplay;
|
||||
_paperdollViewportRenderer = result.PaperdollRenderer;
|
||||
_paperdollFramePresenter = result.PaperdollPresenter;
|
||||
|
|
@ -1266,6 +1310,7 @@ public sealed class GameWindow :
|
|||
_worldReveal = result.WorldReveal;
|
||||
_spawnClaimHydration = result.SpawnClaimHydration;
|
||||
_liveEntityHydration = result.Hydration;
|
||||
_automation?.BindGhostDeletion(result.Deletion.DeleteClientGhost);
|
||||
_liveEntityNetworkUpdates = result.NetworkUpdates;
|
||||
_liveEntityLiveness = result.Liveness;
|
||||
_liveEntitySessionEvents = result.SessionEvents;
|
||||
|
|
@ -1276,6 +1321,7 @@ public sealed class GameWindow :
|
|||
_playerModeAutoEntry = result.PlayerModeAutoEntry;
|
||||
_localPlayerTeleport = result.LocalTeleport;
|
||||
_liveSessionHost = result.SessionHost;
|
||||
_automation?.BindSessionCommands(result.GameRuntime);
|
||||
_gameplayInputActions = result.GameplayActions;
|
||||
_sessionPlayerBindings = result.RuntimeBindings;
|
||||
}
|
||||
|
|
@ -1418,6 +1464,9 @@ public sealed class GameWindow :
|
|||
new ContentEffectsAudioDependencies(
|
||||
_datDir,
|
||||
_options.PreparedAssetPath,
|
||||
_options.PreparedAssetOverlayPath,
|
||||
_options.PreparedAssetBaseRecipeVersion,
|
||||
_options.PreparedAssetEffectiveRecipeVersion,
|
||||
_options.ResidencyBudgets,
|
||||
_physicsDataCache,
|
||||
_animationDiagnostics.DumpMotionEnabled,
|
||||
|
|
@ -1519,7 +1568,9 @@ public sealed class GameWindow :
|
|||
hostInputCamera.GpuFrameLifetime,
|
||||
() => WorldTime.CurrentCalendar,
|
||||
settingsDevTools.RenderPacks,
|
||||
_renderPackDiagnostics.CaptureDiagnostics),
|
||||
_renderPackDiagnostics.CaptureDiagnostics,
|
||||
_applicationPaths.ScreenshotsDirectory,
|
||||
_automation),
|
||||
_retailUiLease,
|
||||
this).Compose(
|
||||
platformResult,
|
||||
|
|
@ -1566,6 +1617,7 @@ public sealed class GameWindow :
|
|||
_cellVisibility,
|
||||
_liveWorldOrigin,
|
||||
_localPlayerIdentity,
|
||||
_chaseCameraInput,
|
||||
_pointerPosition,
|
||||
_playerApproachCompletions,
|
||||
_renderResourceLifetime,
|
||||
|
|
@ -1642,6 +1694,9 @@ public sealed class GameWindow :
|
|||
_combatFeedback,
|
||||
_portalTunnelFallback,
|
||||
Console.WriteLine,
|
||||
_automation is null
|
||||
? null
|
||||
: _automation.TryHandlePluginCommand,
|
||||
_statusWriter),
|
||||
this).Compose(
|
||||
hostInputCamera,
|
||||
|
|
@ -1818,6 +1873,7 @@ public sealed class GameWindow :
|
|||
|
||||
if (!_lifetime.HasShutdownRoots)
|
||||
{
|
||||
PersistKeyBindingsAtShutdown();
|
||||
// Campaign LA slice LA1: capture BEFORE the shutdown roots run —
|
||||
// by the time teardown completes, IsInWorld is always false
|
||||
// regardless of whether a real session was ever connected.
|
||||
|
|
@ -1858,6 +1914,33 @@ public sealed class GameWindow :
|
|||
ReportExited(report);
|
||||
}
|
||||
|
||||
private void PersistKeyBindingsAtShutdown()
|
||||
{
|
||||
// Construction-only tests and failed starts never create the input
|
||||
// dispatcher. They must not materialize a profile in the real user's
|
||||
// Documents folder merely because the half-built window is disposed.
|
||||
if (_keyBindingsPersisted || _inputDispatcher is null) return;
|
||||
_keyBindingsPersisted = true;
|
||||
try
|
||||
{
|
||||
KeyBindings current = _inputDispatcher.Bindings;
|
||||
var profiles = new AcDream.App.Input.RetailKeymapProfileStore(
|
||||
_applicationPaths.KeyBindingsFile);
|
||||
RetailKeymapSaveResult saved = profiles.SaveActive(current);
|
||||
if (saved.Status != RetailKeymapSaveStatus.Saved)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"keymap: shutdown save failed ({saved.Status}): {saved.Error}");
|
||||
return;
|
||||
}
|
||||
current.SaveToFile(_applicationPaths.KeyBindingsFile);
|
||||
}
|
||||
catch (Exception failure)
|
||||
{
|
||||
Console.WriteLine($"keymap: shutdown persistence failed: {failure.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes the ONE terminal "exited" status event for this session
|
||||
/// (fix #406). A resource-shutdown transaction can converge cleanly
|
||||
|
|
|
|||
|
|
@ -7,8 +7,10 @@ using Silk.NET.Windowing;
|
|||
namespace AcDream.App.Rendering.Gpu.Vk;
|
||||
|
||||
/// <summary>
|
||||
/// The Vulkan capability-probe and bring-up harness. Reached only when
|
||||
/// <c>ACDREAM_RENDER_BACKEND=vulkan</c> <b>and</b> <c>ACDREAM_VULKAN_PROBE=1</c>.
|
||||
/// The Vulkan capability-probe and bring-up harness. Reached when
|
||||
/// <c>ACDREAM_VULKAN_PROBE=1</c>. (This previously also required
|
||||
/// <c>ACDREAM_RENDER_BACKEND=vulkan</c>; that variable died with the OpenGL
|
||||
/// backend at Campaign V and is read nowhere — Vulkan is the only backend.)
|
||||
///
|
||||
/// <para><b>What it is for.</b> Answering two questions without starting the
|
||||
/// client: does this machine pass the Vulkan capability gate, and does the RHI
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -99,7 +99,7 @@ internal sealed class AtmosphericFrameInputState : IAtmosphericWorldFrameSink
|
|||
_host.DeltaSeconds,
|
||||
_host.ViewportWidth,
|
||||
_host.ViewportHeight,
|
||||
IsOutdoor: world.Roots.RenderSky && !world.Roots.CameraInsideCell);
|
||||
IsOutdoor: world.Roots.IsAtmosphericallyOutdoor);
|
||||
_published = true;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -197,6 +197,10 @@ internal sealed class AtmosphericPostProcessGraph :
|
|||
private readonly bool _fuseLowPostProcess;
|
||||
private readonly AtmosphericCpuStageProfiler? _cpuStageProfiler;
|
||||
private readonly DirectionalShadowCasterFrame _shadowCasters = new();
|
||||
// #429 owner-approved pipelining state — see RenderDirectionalShadows.
|
||||
private ulong _lastObservedSceneShadowRevision;
|
||||
private long _lastObservedAvailabilityVersion;
|
||||
private int _shadowRebuildDeferrals;
|
||||
private TargetSet? _targets;
|
||||
private AtmosphericFrameInputs _lastInputs;
|
||||
private DirectionalSunShadowDiagnostics _lastShadowDiagnostics;
|
||||
|
|
@ -395,14 +399,39 @@ internal sealed class AtmosphericPostProcessGraph :
|
|||
bool measureCpuStages = _cpuStageProfiler is not null
|
||||
&& AtmosphericCpuStageProfiler.ShouldMeasure(frame.Serial);
|
||||
long stageStarted = measureCpuStages ? Stopwatch.GetTimestamp() : 0L;
|
||||
_shadowCasters.Build(in scene);
|
||||
// #429 owner-approved pipelining: on a frame where the shadow inputs
|
||||
// just CHANGED (streaming publish churn — the same frame already pays
|
||||
// the frame-view and landscape rebuilds), keep the retained shadow
|
||||
// topology and let the rebuild land on the next quieter frame. Capped
|
||||
// at two consecutive deferrals: the GPU frame fence is deeper than
|
||||
// that, so retained prepared draws can never reference an arena range
|
||||
// that was released AND reused while deferred. A caster rebuild the
|
||||
// frame forces anyway (first build, generation change, journal
|
||||
// overflow) re-enables the draws rebuild in the same frame — the
|
||||
// prepared draws must never index a caster frame they were not built
|
||||
// from.
|
||||
ulong sceneShadowRevision = scene.DirectionalShadowTopologyRevision;
|
||||
long availabilityVersion = worldMeshes.DirectionalShadowAvailabilityVersion;
|
||||
bool shadowInputsChanged =
|
||||
sceneShadowRevision != _lastObservedSceneShadowRevision
|
||||
|| availabilityVersion != _lastObservedAvailabilityVersion;
|
||||
_lastObservedSceneShadowRevision = sceneShadowRevision;
|
||||
_lastObservedAvailabilityVersion = availabilityVersion;
|
||||
bool allowTopologyRebuild =
|
||||
!shadowInputsChanged || _shadowRebuildDeferrals >= 2;
|
||||
ulong casterSequenceBefore = _shadowCasters.BuildSequence;
|
||||
_shadowCasters.Build(in scene, allowTopologyRebuild);
|
||||
if (_shadowCasters.BuildSequence != casterSequenceBefore)
|
||||
allowTopologyRebuild = true;
|
||||
_shadowRebuildDeferrals = allowTopologyRebuild
|
||||
? 0
|
||||
: _shadowRebuildDeferrals + 1;
|
||||
long casterBuildFinished = measureCpuStages ? Stopwatch.GetTimestamp() : 0L;
|
||||
AuthoredCelestialShadowSource source = world.CelestialShadowSource;
|
||||
var environment = new DirectionalShadowEnvironmentInput(
|
||||
PackEnabled: true,
|
||||
PortalOrLoginCoverVisible: foundation.PortalViewportVisible,
|
||||
PlayerInsideCell: world.Roots.PlayerInsideCell
|
||||
|| world.Roots.CameraInsideCell,
|
||||
PlayerInsideCell: world.Roots.PlayerOrCameraInsideEnclosedCell,
|
||||
source,
|
||||
foundation.Atmosphere,
|
||||
ActiveDayGroupMultiplier: Math.Clamp(
|
||||
|
|
@ -419,7 +448,7 @@ internal sealed class AtmosphericPostProcessGraph :
|
|||
// or deactivation can never leave a stale exclusion set applied to
|
||||
// the dispatcher (see FoliageWindExclusions's doc comment).
|
||||
worldMeshes.FoliageWindExclusions = _foliageWindExclusions;
|
||||
bool isOutdoor = world.Roots.RenderSky && !world.Roots.CameraInsideCell;
|
||||
bool isOutdoor = world.Roots.IsAtmosphericallyOutdoor;
|
||||
AtmosphericFrameBufferBinding shadowAtmosphericFrame =
|
||||
BuildShadowAtmosphericFrameBinding(frame, foundation.Atmosphere.Kind, isOutdoor);
|
||||
var input = new DirectionalSunShadowRenderInput(
|
||||
|
|
@ -433,7 +462,8 @@ internal sealed class AtmosphericPostProcessGraph :
|
|||
Preset.Semantic,
|
||||
frame.Serial),
|
||||
MeasureCpuStages: measureCpuStages,
|
||||
AtmosphericFrame: shadowAtmosphericFrame);
|
||||
AtmosphericFrame: shadowAtmosphericFrame,
|
||||
AllowTopologyRebuild: allowTopologyRebuild);
|
||||
long environmentFinished = measureCpuStages ? Stopwatch.GetTimestamp() : 0L;
|
||||
_lastShadowCasterCount = _shadowCasters.Stats.Accepted;
|
||||
_lastShadowClassificationCalls = _shadowCasters.Stats.TopologyRebuilt ? 1 : 0;
|
||||
|
|
|
|||
|
|
@ -210,8 +210,7 @@ internal class DeclaredFullscreenRenderPackGraph :
|
|||
var environment = new DirectionalShadowEnvironmentInput(
|
||||
PackEnabled: true,
|
||||
PortalOrLoginCoverVisible: foundation.PortalViewportVisible,
|
||||
PlayerInsideCell: world.Roots.PlayerInsideCell
|
||||
|| world.Roots.CameraInsideCell,
|
||||
PlayerInsideCell: world.Roots.PlayerOrCameraInsideEnclosedCell,
|
||||
source,
|
||||
foundation.Atmosphere,
|
||||
ActiveDayGroupMultiplier: Math.Clamp(
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ internal interface IPaperdollDollRenderer
|
|||
{
|
||||
void SetDoll(WorldEntity? doll);
|
||||
|
||||
void Prepare();
|
||||
|
||||
uint Render(int width, int height);
|
||||
}
|
||||
|
||||
|
|
@ -73,9 +75,6 @@ internal sealed class PaperdollFramePresenter : IPrivateEntityViewportFrame
|
|||
|
||||
public void Render()
|
||||
{
|
||||
if (!_view.TryGetVisibleSize(out int width, out int height))
|
||||
return;
|
||||
|
||||
if (_dirty)
|
||||
{
|
||||
if (_factory.TryBuild(out WorldEntity? doll))
|
||||
|
|
@ -101,6 +100,11 @@ internal sealed class PaperdollFramePresenter : IPrivateEntityViewportFrame
|
|||
}
|
||||
}
|
||||
|
||||
_renderer.Prepare();
|
||||
|
||||
if (!_view.TryGetVisibleSize(out int width, out int height))
|
||||
return;
|
||||
|
||||
_view.SetTextureHandle(_renderer.Render(width, height));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -42,6 +42,8 @@ public sealed class PaperdollViewportRenderer :
|
|||
|
||||
public void SetDoll(WorldEntity? doll) => _renderer.SetEntity(doll);
|
||||
|
||||
public void Prepare() => _renderer.Prepare();
|
||||
|
||||
public uint Render(int width, int height) =>
|
||||
_renderer.Render(width, height);
|
||||
|
||||
|
|
|
|||
|
|
@ -71,10 +71,8 @@ public sealed unsafe partial class ParticleRenderer
|
|||
|
||||
private const uint QuadStrideBytes = 4 * sizeof(float);
|
||||
|
||||
/// <summary>Floats per mesh-particle instance: a <c>mat4</c> plus an RGBA colour.</summary>
|
||||
internal const int MeshInstanceFloats = 20;
|
||||
|
||||
private const uint MeshInstanceStrideBytes = MeshInstanceFloats * sizeof(float);
|
||||
private static readonly uint MeshInstanceStrideBytes =
|
||||
(uint)sizeof(MeshParticleGpuInstance);
|
||||
|
||||
/// <summary>
|
||||
/// The billboard layout: the shared unit quad at vertex rate, and one
|
||||
|
|
@ -103,7 +101,8 @@ public sealed unsafe partial class ParticleRenderer
|
|||
// Location 6 is `in uint aTextureIndex` — an INTEGER shader input,
|
||||
// so R8G8B8A8's normalized cousin would be wrong in kind. It is one
|
||||
// 32-bit unsigned value; Float1 would reinterpret its bits.
|
||||
new GpuVertexAttribute(6, GpuVertexFormat.UInt1, 64, Binding: 1)));
|
||||
new GpuVertexAttribute(6, GpuVertexFormat.UInt1, 64, Binding: 1),
|
||||
new GpuVertexAttribute(7, GpuVertexFormat.UInt1, 68, Binding: 1)));
|
||||
|
||||
/// <summary>
|
||||
/// The mesh-particle layout: the shared world-mesh vertex at vertex rate,
|
||||
|
|
@ -126,7 +125,8 @@ public sealed unsafe partial class ParticleRenderer
|
|||
new GpuVertexAttribute(4, GpuVertexFormat.Float4, 16, Binding: 1),
|
||||
new GpuVertexAttribute(5, GpuVertexFormat.Float4, 32, Binding: 1),
|
||||
new GpuVertexAttribute(6, GpuVertexFormat.Float4, 48, Binding: 1),
|
||||
new GpuVertexAttribute(7, GpuVertexFormat.Float4, 64, Binding: 1)));
|
||||
new GpuVertexAttribute(7, GpuVertexFormat.Float4, 64, Binding: 1),
|
||||
new GpuVertexAttribute(8, GpuVertexFormat.UInt1, 80, Binding: 1)));
|
||||
|
||||
/// <summary>
|
||||
/// The RHI arm's constructor. No GL context, no <c>Shader</c>, no
|
||||
|
|
@ -340,22 +340,22 @@ public sealed unsafe partial class ParticleRenderer
|
|||
while (submission.Kind == ParticleSubmissionKind.Mesh
|
||||
&& _meshDrawListScratch[submission.DrawIndex].Key == meshKey);
|
||||
|
||||
int neededFloats = _meshRunScratch.Count * MeshInstanceFloats;
|
||||
if (_meshInstanceScratch.Length < neededFloats)
|
||||
_meshInstanceScratch = new float[neededFloats + 256 * MeshInstanceFloats];
|
||||
int neededInstances = _meshRunScratch.Count;
|
||||
if (_meshInstanceScratch.Length < neededInstances)
|
||||
_meshInstanceScratch = new MeshParticleGpuInstance[neededInstances + 256];
|
||||
for (int instance = 0; instance < _meshRunScratch.Count; instance++)
|
||||
{
|
||||
WriteMeshGpuInstance(
|
||||
_meshInstanceScratch,
|
||||
instance * MeshInstanceFloats,
|
||||
ref _meshInstanceScratch[instance],
|
||||
_meshRunScratch[instance]);
|
||||
}
|
||||
|
||||
GpuRingAllocation instances = WriteVertexRing<float>(
|
||||
GpuRingAllocation instances = WriteVertexRing<MeshParticleGpuInstance>(
|
||||
frame,
|
||||
_meshInstanceScratch.AsSpan(0, neededFloats));
|
||||
_meshInstanceScratch.AsSpan(0, neededInstances));
|
||||
DrawMeshBatchRhi(
|
||||
encoder,
|
||||
frame,
|
||||
global,
|
||||
batch,
|
||||
viewProjection,
|
||||
|
|
@ -384,7 +384,13 @@ public sealed unsafe partial class ParticleRenderer
|
|||
GpuRingAllocation ring = WriteVertexRing<BillboardGpuInstance>(
|
||||
frame,
|
||||
_instanceScratch.AsSpan(0, instances.Count));
|
||||
BindBillboardPipeline(encoder, viewProjection, additive, ring.Buffer, ring.OffsetBytes);
|
||||
BindBillboardPipeline(
|
||||
encoder,
|
||||
frame,
|
||||
viewProjection,
|
||||
additive,
|
||||
ring.Buffer,
|
||||
ring.OffsetBytes);
|
||||
encoder.DrawIndexed(
|
||||
(uint)QuadIndices.Length,
|
||||
(uint)instances.Count,
|
||||
|
|
@ -401,6 +407,7 @@ public sealed unsafe partial class ParticleRenderer
|
|||
/// </summary>
|
||||
private void BindBillboardPipeline(
|
||||
IGpuPassEncoder encoder,
|
||||
IGpuFrame frame,
|
||||
Matrix4x4 viewProjection,
|
||||
bool additive,
|
||||
IGpuBuffer instanceBuffer,
|
||||
|
|
@ -426,10 +433,15 @@ public sealed unsafe partial class ParticleRenderer
|
|||
encoder.BindVertexBuffer(0, _quadVertexBuffer!, 0);
|
||||
encoder.BindVertexBuffer(1, instanceBuffer, instanceOffsetBytes);
|
||||
encoder.BindIndexBuffer(_quadIndexBuffer!, 0, GpuIndexType.UInt32);
|
||||
WorldFrameSectionBinding.BindClipRegions(
|
||||
encoder,
|
||||
_scope!.Sections,
|
||||
frame);
|
||||
}
|
||||
|
||||
private void DrawMeshBatchRhi(
|
||||
IGpuPassEncoder encoder,
|
||||
IGpuFrame frame,
|
||||
GlobalMeshBuffer global,
|
||||
ObjectRenderBatch batch,
|
||||
Matrix4x4 viewProjection,
|
||||
|
|
@ -472,6 +484,10 @@ public sealed unsafe partial class ParticleRenderer
|
|||
"The shared mesh arena has no index store."),
|
||||
0,
|
||||
GpuIndexType.UInt16);
|
||||
WorldFrameSectionBinding.BindClipRegions(
|
||||
encoder,
|
||||
_scope!.Sections,
|
||||
frame);
|
||||
encoder.DrawIndexed(
|
||||
(uint)batch.IndexCount,
|
||||
instanceCount,
|
||||
|
|
@ -520,9 +536,8 @@ public sealed unsafe partial class ParticleRenderer
|
|||
Array.Resize(ref _preparedInstanceOffsets, count + 256);
|
||||
if (_instanceScratch.Length < count)
|
||||
Array.Resize(ref _instanceScratch, count + 256);
|
||||
int neededMeshFloats = count * MeshInstanceFloats;
|
||||
if (_meshInstanceScratch.Length < neededMeshFloats)
|
||||
_meshInstanceScratch = new float[neededMeshFloats + 256 * MeshInstanceFloats];
|
||||
if (_meshInstanceScratch.Length < count)
|
||||
_meshInstanceScratch = new MeshParticleGpuInstance[count + 256];
|
||||
|
||||
int billboardCount = 0;
|
||||
int meshCount = 0;
|
||||
|
|
@ -541,8 +556,7 @@ public sealed unsafe partial class ParticleRenderer
|
|||
{
|
||||
_preparedInstanceOffsets[i] = (uint)meshCount;
|
||||
WriteMeshGpuInstance(
|
||||
_meshInstanceScratch,
|
||||
meshCount++ * MeshInstanceFloats,
|
||||
ref _meshInstanceScratch[meshCount++],
|
||||
deferred.Mesh.Instance);
|
||||
}
|
||||
}
|
||||
|
|
@ -553,9 +567,9 @@ public sealed unsafe partial class ParticleRenderer
|
|||
_instanceScratch.AsSpan(0, billboardCount)))
|
||||
: default;
|
||||
_preparedMeshInstances = meshCount > 0
|
||||
? SectionOf(WriteVertexRing<float>(
|
||||
? SectionOf(WriteVertexRing<MeshParticleGpuInstance>(
|
||||
frame,
|
||||
_meshInstanceScratch.AsSpan(0, meshCount * MeshInstanceFloats)))
|
||||
_meshInstanceScratch.AsSpan(0, meshCount)))
|
||||
: default;
|
||||
_preparedAlphaCount = count;
|
||||
}
|
||||
|
|
@ -591,6 +605,7 @@ public sealed unsafe partial class ParticleRenderer
|
|||
{
|
||||
BindBillboardPipeline(
|
||||
encoder,
|
||||
RequireRhiFrame(),
|
||||
viewProjection,
|
||||
key.Additive,
|
||||
billboards,
|
||||
|
|
@ -632,6 +647,7 @@ public sealed unsafe partial class ParticleRenderer
|
|||
{
|
||||
DrawMeshBatchRhi(
|
||||
encoder,
|
||||
RequireRhiFrame(),
|
||||
global,
|
||||
batch,
|
||||
meshViewProjection,
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
|
|||
public readonly uint ColorArgb;
|
||||
public readonly AcDream.App.Rendering.Gpu.GpuTextureSlot TextureSlot;
|
||||
public readonly float DistanceSq;
|
||||
public readonly uint ClipSlot;
|
||||
|
||||
public ParticleInstance(
|
||||
Vector3 position,
|
||||
|
|
@ -54,7 +55,8 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
|
|||
Vector3 axisY,
|
||||
uint colorArgb,
|
||||
AcDream.App.Rendering.Gpu.GpuTextureSlot textureSlot,
|
||||
float distanceSq)
|
||||
float distanceSq,
|
||||
uint clipSlot)
|
||||
{
|
||||
Position = position;
|
||||
AxisX = axisX;
|
||||
|
|
@ -62,6 +64,7 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
|
|||
ColorArgb = colorArgb;
|
||||
TextureSlot = textureSlot;
|
||||
DistanceSq = distanceSq;
|
||||
ClipSlot = clipSlot;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -80,6 +83,16 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
|
|||
public Vector4 AxisY;
|
||||
public Vector4 Color;
|
||||
public uint TextureIndex;
|
||||
public uint ClipSlot;
|
||||
}
|
||||
|
||||
/// <summary>Vertex-instance ABI shared with particle_mesh.vert.</summary>
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal struct MeshParticleGpuInstance
|
||||
{
|
||||
public Matrix4x4 Model;
|
||||
public Vector4 Color;
|
||||
public uint ClipSlot;
|
||||
}
|
||||
|
||||
private readonly struct MeshParticleInstance
|
||||
|
|
@ -87,12 +100,18 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
|
|||
public readonly Matrix4x4 Model;
|
||||
public readonly uint ColorArgb;
|
||||
public readonly float DistanceSq;
|
||||
public readonly uint ClipSlot;
|
||||
|
||||
public MeshParticleInstance(Matrix4x4 model, uint colorArgb, float distanceSq)
|
||||
public MeshParticleInstance(
|
||||
Matrix4x4 model,
|
||||
uint colorArgb,
|
||||
float distanceSq,
|
||||
uint clipSlot)
|
||||
{
|
||||
Model = model;
|
||||
ColorArgb = colorArgb;
|
||||
DistanceSq = distanceSq;
|
||||
ClipSlot = clipSlot;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -124,7 +143,7 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
|
|||
internal (int SetCount, long CapacityBytes) DynamicBufferDiagnostics => (0, 0);
|
||||
|
||||
private BillboardGpuInstance[] _instanceScratch = new BillboardGpuInstance[256];
|
||||
private float[] _meshInstanceScratch = new float[256 * 20];
|
||||
private MeshParticleGpuInstance[] _meshInstanceScratch = new MeshParticleGpuInstance[256];
|
||||
|
||||
// MP-Alloc (2026-07-05): Draw() is called up to ~11 times per frame
|
||||
// (sky pre/post, scene, per-visible-cell, dynamics, unattached passes),
|
||||
|
|
@ -203,7 +222,8 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
|
|||
cameraRight,
|
||||
cameraUp,
|
||||
emitterFilter,
|
||||
scopedEmitters: null);
|
||||
scopedEmitters: null,
|
||||
clipSlot: 0);
|
||||
FinishDraw(camera, renderPass);
|
||||
}
|
||||
|
||||
|
|
@ -213,7 +233,8 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
|
|||
ParticleRenderPass renderPass,
|
||||
IReadOnlySet<uint> attachedOwnerIds,
|
||||
bool includeUnattached = false,
|
||||
IReadOnlySet<uint>? excludedAttachedOwnerIds = null)
|
||||
IReadOnlySet<uint>? excludedAttachedOwnerIds = null,
|
||||
uint clipSlot = 0)
|
||||
{
|
||||
if (camera is null)
|
||||
return;
|
||||
|
|
@ -233,7 +254,8 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
|
|||
cameraRight,
|
||||
cameraUp,
|
||||
emitterFilter: null,
|
||||
_scopedEmitterScratch);
|
||||
_scopedEmitterScratch,
|
||||
clipSlot);
|
||||
FinishDraw(camera, renderPass);
|
||||
}
|
||||
|
||||
|
|
@ -332,7 +354,8 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
|
|||
Vector3 cameraRight,
|
||||
Vector3 cameraUp,
|
||||
Func<AcDream.Core.Vfx.ParticleEmitter, bool>? emitterFilter,
|
||||
IReadOnlyList<RuntimeParticleEmitter>? scopedEmitters)
|
||||
IReadOnlyList<RuntimeParticleEmitter>? scopedEmitters,
|
||||
uint clipSlot)
|
||||
{
|
||||
var draws = _drawListScratch;
|
||||
draws.Clear();
|
||||
|
|
@ -348,6 +371,7 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
|
|||
cameraWorldPos,
|
||||
cameraRight,
|
||||
cameraUp,
|
||||
clipSlot,
|
||||
ref sequence);
|
||||
}
|
||||
return;
|
||||
|
|
@ -356,7 +380,13 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
|
|||
foreach (RuntimeParticleEmitter emitter in _particles.EnumerateRenderableEmitters(renderPass))
|
||||
{
|
||||
if (emitterFilter is null || emitterFilter(emitter))
|
||||
AppendEmitterDraws(emitter, cameraWorldPos, cameraRight, cameraUp, ref sequence);
|
||||
AppendEmitterDraws(
|
||||
emitter,
|
||||
cameraWorldPos,
|
||||
cameraRight,
|
||||
cameraUp,
|
||||
clipSlot,
|
||||
ref sequence);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -365,6 +395,7 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
|
|||
Vector3 cameraWorldPos,
|
||||
Vector3 cameraRight,
|
||||
Vector3 cameraUp,
|
||||
uint clipSlot,
|
||||
ref int sequence)
|
||||
{
|
||||
List<ParticleDraw> draws = _drawListScratch;
|
||||
|
|
@ -385,7 +416,13 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
|
|||
uint gfxObjId = em.Desc.HwGfxObjId != 0 ? em.Desc.HwGfxObjId : em.Desc.GfxObjId;
|
||||
if (gfxObjId != 0
|
||||
&& ResolveGeometryKind(gfxObjId) == RetailParticleGeometryKind.FullMesh
|
||||
&& TryAppendMeshDraws(em, p, gfxObjId, cameraWorldPos, ref sequence))
|
||||
&& TryAppendMeshDraws(
|
||||
em,
|
||||
p,
|
||||
gfxObjId,
|
||||
cameraWorldPos,
|
||||
clipSlot,
|
||||
ref sequence))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
|
@ -483,7 +520,8 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
|
|||
axisY,
|
||||
p.ColorArgb,
|
||||
gfxInfo.TextureSlot,
|
||||
distSq)));
|
||||
distSq,
|
||||
clipSlot)));
|
||||
_submissionScratch.Add(new ParticleSubmission(
|
||||
ParticleSubmissionKind.Billboard,
|
||||
drawIndex,
|
||||
|
|
@ -497,6 +535,7 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
|
|||
Particle particle,
|
||||
uint gfxObjId,
|
||||
Vector3 cameraWorldPosition,
|
||||
uint clipSlot,
|
||||
ref int sequence)
|
||||
{
|
||||
if (_meshAdapter is null || !MeshParticlesAvailable)
|
||||
|
|
@ -520,7 +559,11 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
|
|||
model,
|
||||
cameraWorldPosition);
|
||||
float distanceSq = viewerDistance * viewerDistance;
|
||||
var instance = new MeshParticleInstance(model, particle.ColorArgb, distanceSq);
|
||||
var instance = new MeshParticleInstance(
|
||||
model,
|
||||
particle.ColorArgb,
|
||||
distanceSq,
|
||||
clipSlot);
|
||||
|
||||
for (int batchIndex = 0; batchIndex < renderData.Batches.Count; batchIndex++)
|
||||
{
|
||||
|
|
@ -581,35 +624,24 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
|
|||
TextureIndex = particle.TextureSlot.IsAssigned
|
||||
? particle.TextureSlot.Index
|
||||
: NoTextureSlot,
|
||||
ClipSlot = particle.ClipSlot,
|
||||
};
|
||||
}
|
||||
|
||||
private static void WriteMeshGpuInstance(
|
||||
float[] destination,
|
||||
int offset,
|
||||
ref MeshParticleGpuInstance destination,
|
||||
MeshParticleInstance instance)
|
||||
{
|
||||
Matrix4x4 model = instance.Model;
|
||||
destination[offset + 0] = model.M11;
|
||||
destination[offset + 1] = model.M12;
|
||||
destination[offset + 2] = model.M13;
|
||||
destination[offset + 3] = model.M14;
|
||||
destination[offset + 4] = model.M21;
|
||||
destination[offset + 5] = model.M22;
|
||||
destination[offset + 6] = model.M23;
|
||||
destination[offset + 7] = model.M24;
|
||||
destination[offset + 8] = model.M31;
|
||||
destination[offset + 9] = model.M32;
|
||||
destination[offset + 10] = model.M33;
|
||||
destination[offset + 11] = model.M34;
|
||||
destination[offset + 12] = model.M41;
|
||||
destination[offset + 13] = model.M42;
|
||||
destination[offset + 14] = model.M43;
|
||||
destination[offset + 15] = model.M44;
|
||||
destination[offset + 16] = ((instance.ColorArgb >> 16) & 0xFF) / 255f;
|
||||
destination[offset + 17] = ((instance.ColorArgb >> 8) & 0xFF) / 255f;
|
||||
destination[offset + 18] = (instance.ColorArgb & 0xFF) / 255f;
|
||||
destination[offset + 19] = ((instance.ColorArgb >> 24) & 0xFF) / 255f;
|
||||
destination = new MeshParticleGpuInstance
|
||||
{
|
||||
Model = instance.Model,
|
||||
Color = new Vector4(
|
||||
((instance.ColorArgb >> 16) & 0xFF) / 255f,
|
||||
((instance.ColorArgb >> 8) & 0xFF) / 255f,
|
||||
(instance.ColorArgb & 0xFF) / 255f,
|
||||
((instance.ColorArgb >> 24) & 0xFF) / 255f),
|
||||
ClipSlot = instance.ClipSlot,
|
||||
};
|
||||
}
|
||||
|
||||
private TranslucencyKind ResolveMeshBlend(ObjectRenderBatch batch)
|
||||
|
|
|
|||
|
|
@ -1,75 +0,0 @@
|
|||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Numerics;
|
||||
|
||||
namespace AcDream.App.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// TEMPORARY #429 apparatus. One CSV row per rendered frame:
|
||||
/// <c>seconds,camX,camY,camZ,playerX,playerY,playerZ</c>, where seconds is a
|
||||
/// Stopwatch-derived monotonic time and player is the frame's
|
||||
/// <c>PlayerViewPosition</c> — the presented local-player position driving
|
||||
/// lighting/visibility this frame. Off unless
|
||||
/// <c>ACDREAM_PROBE_PLAYER_PRESENT=<path></c> is set (one null check per
|
||||
/// frame). Analysis: a hitch frame shows the player's per-frame delta
|
||||
/// collapsing to ~0 or doubling; whether the SAME frame's dt is smooth
|
||||
/// separates a presentation-phase bug (pack frame graph sampling a stale
|
||||
/// snapshot) from genuine frame-pacing spikes. Strip with the #429 fix.
|
||||
/// </summary>
|
||||
internal sealed class PlayerPresentationProbe : IDisposable
|
||||
{
|
||||
private readonly StreamWriter _writer;
|
||||
private readonly long _startTimestamp = Stopwatch.GetTimestamp();
|
||||
private int _linesSinceFlush;
|
||||
|
||||
private PlayerPresentationProbe(StreamWriter writer)
|
||||
{
|
||||
_writer = writer;
|
||||
_writer.WriteLine("seconds,camX,camY,camZ,playerX,playerY,playerZ");
|
||||
}
|
||||
|
||||
internal static PlayerPresentationProbe? CreateFromEnvironment()
|
||||
{
|
||||
string? path = Environment.GetEnvironmentVariable("ACDREAM_PROBE_PLAYER_PRESENT");
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
return new PlayerPresentationProbe(new StreamWriter(path, append: false));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[player-present] probe file '{path}' could not be opened: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
internal void Observe(in Vector3 cameraPosition, in Vector3 playerViewPosition)
|
||||
{
|
||||
double seconds = (Stopwatch.GetTimestamp() - _startTimestamp)
|
||||
/ (double)Stopwatch.Frequency;
|
||||
_writer.WriteLine(string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"{seconds:F6},{cameraPosition.X:F4},{cameraPosition.Y:F4},{cameraPosition.Z:F4},{playerViewPosition.X:F4},{playerViewPosition.Y:F4},{playerViewPosition.Z:F4}"));
|
||||
if (++_linesSinceFlush >= 240)
|
||||
{
|
||||
_linesSinceFlush = 0;
|
||||
_writer.Flush();
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
_writer.Flush();
|
||||
_writer.Dispose();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// A probe must never turn teardown fallible.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.Rendering.Gpu;
|
||||
using AcDream.App.Rendering.Wb;
|
||||
using AcDream.App.Streaming;
|
||||
using AcDream.App.UI;
|
||||
using AcDream.Content.Vfx;
|
||||
using AcDream.Core.Lighting;
|
||||
|
|
@ -96,6 +97,7 @@ public sealed class PortalTunnelPresentation : IDisposable
|
|||
private float _rotationEndAngle;
|
||||
private float _rotationCurrentAngle;
|
||||
private bool _waitCueVisible;
|
||||
private bool _probeFreezeReached;
|
||||
private bool _disposeRequested;
|
||||
private bool _disposing;
|
||||
private bool _disposed;
|
||||
|
|
@ -256,6 +258,7 @@ public sealed class PortalTunnelPresentation : IDisposable
|
|||
_rotationCurrentAngle = 0f;
|
||||
_camera.DirectionDegrees = 0f;
|
||||
_waitCueVisible = false;
|
||||
_probeFreezeReached = false;
|
||||
_visible = true;
|
||||
RebuildPose();
|
||||
}
|
||||
|
|
@ -267,6 +270,7 @@ public sealed class PortalTunnelPresentation : IDisposable
|
|||
return;
|
||||
_visible = false;
|
||||
_waitCueVisible = false;
|
||||
_probeFreezeReached = false;
|
||||
_animationHooks.Clear();
|
||||
_sequence.ClearAnimations();
|
||||
}
|
||||
|
|
@ -275,11 +279,25 @@ public sealed class PortalTunnelPresentation : IDisposable
|
|||
{
|
||||
if (!_visible || dt < 0f)
|
||||
return;
|
||||
if (_probeFreezeReached)
|
||||
return;
|
||||
|
||||
_sequence.Update(dt, frame: null);
|
||||
RebuildPose();
|
||||
_animationHooks.Drain(Vector3.Zero);
|
||||
TickRotation(dt);
|
||||
|
||||
if (StreamingDiagnostics.TunnelFreezeFrame is not { } freezeFrame
|
||||
|| CurrentAnimationFrame < freezeFrame)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_probeFreezeReached = true;
|
||||
Console.WriteLine(
|
||||
$"[tunnel-freeze] frame={CurrentAnimationFrame} "
|
||||
+ $"target={freezeFrame} setup=0x{_setupDid:X8} "
|
||||
+ $"animation=0x{_animationDid:X8} state=held");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -26,11 +26,21 @@ public sealed class PortalVisibilityFrame
|
|||
private int _processedViewCountsUnderusedFrames;
|
||||
private int _orderedVisibleCellsUnderusedFrames;
|
||||
private int _todoUnderusedFrames;
|
||||
private int _exteriorSeedPortalsUnderusedFrames;
|
||||
|
||||
internal PortalPolygonVertexStore PolygonVertices => _polygonVertices;
|
||||
internal int PolygonVertexAllocationCount => _polygonVertices.AllocationCount;
|
||||
internal int RetainedPolygonVertexArrayCount => _polygonVertices.RetainedArrayCount;
|
||||
|
||||
// Interior-root look-ins are constructed one building at a time. Keep
|
||||
// the source identity on the retained frame so the landscape pass can
|
||||
// pair retail's portal-only traversal with that building's own exterior
|
||||
// shell instead of repainting every nearby shell after every look-in.
|
||||
// Building ids are publication-local, so the landblock is part of the
|
||||
// identity. An unstamped building uses its seed cell id as the key.
|
||||
internal uint SourceBuildingKey { get; set; }
|
||||
internal uint SourceBuildingLandblockId { get; set; }
|
||||
|
||||
/// <summary>Screen region (NDC) where outdoor terrain/scenery may draw — exit portals
|
||||
/// recursively clipped to their portal chain. The cellar-flap fix.</summary>
|
||||
public CellView OutsideView { get; private set; } = new();
|
||||
|
|
@ -49,6 +59,15 @@ public sealed class PortalVisibilityFrame
|
|||
/// neighbour cell id that left the camera building's cell set (wire-in #3 / Step 5).</summary>
|
||||
public Dictionary<uint, CellView> CrossBuildingViews { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Exact outside-facing building portals that successfully seeded this
|
||||
/// exterior construction, together with the clipped view region produced
|
||||
/// by that portal. Retail's DrawBuilding pass punches these CBldPortal
|
||||
/// apertures themselves; it does not infer them later from every exit on a
|
||||
/// cell reached by the resulting flood.
|
||||
/// </summary>
|
||||
public List<ExteriorPortalSeed> ExteriorSeedPortals { get; } = new();
|
||||
|
||||
// Build scratch belongs to the frame so a caller that reuses a frame also reuses the large
|
||||
// hash tables and the 128-entry convergence trace. This is especially important outdoors,
|
||||
// where retail runs one small ConstructView flood per nearby building every frame.
|
||||
|
|
@ -114,6 +133,7 @@ public sealed class PortalVisibilityFrame
|
|||
int processedViewCount = ProcessedViewCountsScratch.Count;
|
||||
int orderedVisibleCellCount = OrderedVisibleCells.Count;
|
||||
int todoCount = TodoScratch.Count;
|
||||
int exteriorSeedPortalCount = ExteriorSeedPortals.Count;
|
||||
|
||||
if (OutsideView.IsRetainable)
|
||||
OutsideView.Reset();
|
||||
|
|
@ -121,9 +141,14 @@ public sealed class PortalVisibilityFrame
|
|||
OutsideView = new CellView();
|
||||
ReturnCellViews(CellViews);
|
||||
ReturnCellViews(CrossBuildingViews);
|
||||
for (int index = 0; index < ExteriorSeedPortals.Count; index++)
|
||||
ReturnCellView(ExteriorSeedPortals[index].View);
|
||||
CellViews.Clear();
|
||||
OrderedVisibleCells.Clear();
|
||||
CrossBuildingViews.Clear();
|
||||
ExteriorSeedPortals.Clear();
|
||||
SourceBuildingKey = 0;
|
||||
SourceBuildingLandblockId = 0;
|
||||
QueuedScratch.Clear();
|
||||
DrawListedScratch.Clear();
|
||||
ProcessedViewCountsScratch.Clear();
|
||||
|
|
@ -152,6 +177,10 @@ public sealed class PortalVisibilityFrame
|
|||
orderedVisibleCellCount,
|
||||
ref _orderedVisibleCellsUnderusedFrames);
|
||||
TrimIfCold(TodoScratch, todoCount, ref _todoUnderusedFrames);
|
||||
TrimIfCold(
|
||||
ExteriorSeedPortals,
|
||||
exteriorSeedPortalCount,
|
||||
ref _exteriorSeedPortalsUnderusedFrames);
|
||||
}
|
||||
|
||||
internal ViewPolygon CopyPolygon(ReadOnlySpan<Vector2> vertices)
|
||||
|
|
@ -178,11 +207,14 @@ public sealed class PortalVisibilityFrame
|
|||
private void ReturnCellViews(Dictionary<uint, CellView> views)
|
||||
{
|
||||
foreach (CellView view in views.Values)
|
||||
{
|
||||
view.Reset();
|
||||
if (view.IsRetainable && _cellViewPool.Count < MaxRetainedCellViews)
|
||||
_cellViewPool.Push(view);
|
||||
}
|
||||
ReturnCellView(view);
|
||||
}
|
||||
|
||||
private void ReturnCellView(CellView view)
|
||||
{
|
||||
view.Reset();
|
||||
if (view.IsRetainable && _cellViewPool.Count < MaxRetainedCellViews)
|
||||
_cellViewPool.Push(view);
|
||||
}
|
||||
|
||||
private static void TrimIfCold<TKey, TValue>(
|
||||
|
|
@ -243,6 +275,15 @@ public sealed class PortalVisibilityFrame
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One outside-facing portal accepted as an exterior building-view seed.
|
||||
/// <paramref name="View"/> is the exact installed-view-clipped aperture.
|
||||
/// </summary>
|
||||
public readonly record struct ExteriorPortalSeed(
|
||||
uint CellId,
|
||||
int PortalIndex,
|
||||
CellView View);
|
||||
|
||||
public static class PortalVisibilityBuilder
|
||||
{
|
||||
// Side-classification epsilon. Retail's is F_EPSILON = 0.000199999995
|
||||
|
|
@ -265,12 +306,6 @@ public static class PortalVisibilityBuilder
|
|||
// stands near a doorway plane).
|
||||
private const float SeedInPlaneEpsilon = 0.0002f;
|
||||
|
||||
// TEMP diagnostic (Phase A8.F visual-gate triage; strip after): ACDREAM_A8_DUMP_PV=1 dumps the
|
||||
// local→NDC→clipped portal geometry for the first 2 Build calls per distinct camera cell.
|
||||
private static readonly bool s_pvDump =
|
||||
Environment.GetEnvironmentVariable("ACDREAM_A8_DUMP_PV") == "1";
|
||||
private static readonly Dictionary<uint, int> s_pvDumpCount = new();
|
||||
|
||||
/// <summary>
|
||||
/// #120 observable: total convergence-tripwire firings across both the
|
||||
/// interior <see cref="Build"/> and the exterior look-in propagation.
|
||||
|
|
@ -386,36 +421,6 @@ public static class PortalVisibilityBuilder
|
|||
int churnReenqueues = 0;
|
||||
var churnReciprocal = churnProbe ? new System.Text.StringBuilder(256) : null;
|
||||
|
||||
bool pvDump = false;
|
||||
if (s_pvDump)
|
||||
{
|
||||
lock (s_pvDumpCount)
|
||||
{
|
||||
s_pvDumpCount.TryGetValue(cameraCell.CellId, out int dc);
|
||||
if (dc < 2) { s_pvDumpCount[cameraCell.CellId] = dc + 1; pvDump = true; }
|
||||
}
|
||||
if (pvDump)
|
||||
{
|
||||
Console.WriteLine($"[pv-dump] camCell=0x{cameraCell.CellId:X8} portals={cameraCell.Portals.Count} polyLists={cameraCell.PortalPolygons.Count} vp[M11={viewProj.M11:F3} M22={viewProj.M22:F3} M33={viewProj.M33:F3} M34={viewProj.M34:F3} M43={viewProj.M43:F3} M44={viewProj.M44:F3}]");
|
||||
// Camera-cell portal census (A8.F triage 2026-05-29): report, for EVERY
|
||||
// portal, the exact inputs the BFS guards read — BEFORE the guards run, so
|
||||
// a portal the loop silently `continue`s past is still visible here. An
|
||||
// empty OUTSIDEVIEW can then be traced to the precise gate: polyLen<3 (empty
|
||||
// polygon from EnvCellLandblockBuildBuilder), interiorSide=false (camera back-facing the
|
||||
// portal — a legitimately-empty result, not a bug), or (if both OK) a
|
||||
// downstream projection/clip failure shown by the EXIT-PROJ/EXIT-CLIP lines.
|
||||
for (int ci = 0; ci < cameraCell.Portals.Count; ci++)
|
||||
{
|
||||
int plen = ci < cameraCell.PortalPolygons.Count
|
||||
? (cameraCell.PortalPolygons[ci]?.Length ?? -1) : -2;
|
||||
bool hasPlane = ci < cameraCell.ClipPlanes.Count;
|
||||
bool interiorSide = !hasPlane || CameraOnInteriorSide(cameraCell, ci, cameraPos);
|
||||
var n = hasPlane ? cameraCell.ClipPlanes[ci].Normal : Vector3.Zero;
|
||||
Console.WriteLine($"[pv-dump] CAMPORTAL[{ci}] other=0x{cameraCell.Portals[ci].OtherCellId:X4} polyLen={plen} hasPlane={hasPlane} interiorSide={interiorSide} planeN=({n.X:F3},{n.Y:F3},{n.Z:F3})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// T2 (BR-4): retail's growth propagation is IN PLACE, never by re-enqueue
|
||||
// — PView::AddViewToPortals (Ghidra 0x005a52d0, pc:433446): first
|
||||
// discovery enqueues via InsCellTodoList; growth into a cell whose
|
||||
|
|
@ -481,8 +486,6 @@ public static class PortalVisibilityBuilder
|
|||
continue;
|
||||
}
|
||||
|
||||
bool dx = pvDump && cell.Portals[i].OtherCellId == 0xFFFF;
|
||||
|
||||
// (R-A2b Phase 1 pin, throwaway) Log the side-test inputs for EVERY portal so a back-portal
|
||||
// traversal (cell=0x..0173 p->0x0171) can be attributed to the side test.
|
||||
// Strip with the rest of the [pv-trace] apparatus.
|
||||
|
|
@ -505,7 +508,6 @@ public static class PortalVisibilityBuilder
|
|||
&& !CameraOnInteriorSide(cell, i, cameraPos))
|
||||
{
|
||||
trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->0x{portal.OtherCellId:X4} skip=side");
|
||||
if (dx) Console.WriteLine($"[pv-dump] EXIT-CULLED(side) cell=0x{cell.CellId:X8} p{i} localN={poly.Length} hasClipPlane={(i < cell.ClipPlanes.Count)}");
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -526,8 +528,6 @@ public static class PortalVisibilityBuilder
|
|||
endCount - processedCount,
|
||||
clippedRegion,
|
||||
out int clipVerts);
|
||||
if (dx) Console.WriteLine($"[pv-dump] EXIT-PROJ cell=0x{cell.CellId:X8} p{i} localN={poly.Length} clipN={clipVerts} local0=({poly[0].X:F2},{poly[0].Y:F2},{poly[0].Z:F2})");
|
||||
if (dx) Console.WriteLine($"[pv-dump] EXIT-CLIP cell=0x{cell.CellId:X8} p{i} currentViewPolys={currentView.Polygons.Count} clipResult={clippedRegion.Count}");
|
||||
|
||||
// Empty clip = no flood through this portal, period — retail's empty-GetClip rule
|
||||
// (polyClipFinish <3 survivors → reject; ClipPortals adds no view). The
|
||||
|
|
@ -544,13 +544,6 @@ public static class PortalVisibilityBuilder
|
|||
|
||||
if (portal.OtherCellId == 0xFFFF)
|
||||
{
|
||||
if (pvDump)
|
||||
{
|
||||
Console.WriteLine($"[pv-dump] EXIT cell=0x{cell.CellId:X8} p{i} localN={poly.Length} clipVerts={clipVerts} clipPolys={clippedRegion.Count}");
|
||||
Console.WriteLine($"[pv-dump] local=[{string.Join(" ", System.Array.ConvertAll(poly, v => $"({v.X:F2},{v.Y:F2},{v.Z:F2})"))}]");
|
||||
foreach (var cp in clippedRegion)
|
||||
Console.WriteLine($"[pv-dump] clipped({cp.Vertices.Length})=[{string.Join(" ", System.Array.ConvertAll((Vector2[])cp.Vertices, v => $"({v.X:F3},{v.Y:F3})"))}]");
|
||||
}
|
||||
// Exit portal -> outdoors visible through this (clipped) opening.
|
||||
// OutsideView gates DRAWN color (terrain/sky/scissor), and the
|
||||
// shell that rasterizes this aperture draws +drawLiftZ above
|
||||
|
|
@ -688,9 +681,6 @@ public static class PortalVisibilityBuilder
|
|||
ProcessCellPortals(cell, 0);
|
||||
}
|
||||
|
||||
if (pvDump)
|
||||
Console.WriteLine($"[pv-dump] OUTSIDEVIEW polys={frame.OutsideView.Polygons.Count} bfsCellViews={frame.CellViews.Count} crossBldg={frame.CrossBuildingViews.Count}");
|
||||
|
||||
// Phase U.4c flap probe (ACDREAM_PROBE_FLAP) — read-only per-frame snapshot of the
|
||||
// root cell's per-portal side-test + projection + the frame's exit/visible counts.
|
||||
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled)
|
||||
|
|
@ -761,10 +751,27 @@ public static class PortalVisibilityBuilder
|
|||
// ever built from a knife-edge aperture.
|
||||
if (i < cell.ClipPlanes.Count)
|
||||
{
|
||||
if (CameraOnInteriorSide(cell, i, cameraPos))
|
||||
continue;
|
||||
if (EyeInPlaneOfPortal(cell, i, cameraPos))
|
||||
continue;
|
||||
|
||||
// Do NOT reuse the ordinary EnvCell traversal tolerance
|
||||
// here. PortalSideEpsilon deliberately admits a 1 cm
|
||||
// stale-root margin, but retail ConstructView(CBldPortal)
|
||||
// classifies the seed with Sidedness's exact F_EPSILON
|
||||
// (0.0002 m). At Sanctuary the 0104 and 0106 exterior
|
||||
// planes coincide; the chase eye can sit ~4 mm outside the
|
||||
// opposite cathedral half while its EnvCell root remains
|
||||
// 0104. Applying the 1 cm margin calls that eye "inside"
|
||||
// 0106 and drops the entire look-in flood, exposing the
|
||||
// landscape/waterfall through half the cathedral.
|
||||
if (CameraOnInteriorSide(
|
||||
cell,
|
||||
i,
|
||||
cameraPos,
|
||||
SeedInPlaneEpsilon))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
float seedDistance = NearestPortalVertexDistance(poly, cell.WorldTransform, cameraPos);
|
||||
|
|
@ -792,6 +799,17 @@ public static class PortalVisibilityBuilder
|
|||
if (clippedRegion.Count == 0)
|
||||
continue;
|
||||
|
||||
// Preserve the exact CBldPortal that produced this view. The
|
||||
// renderer must punch only accepted seed apertures; iterating
|
||||
// every OtherCellId==0xFFFF portal on the reached cell turns a
|
||||
// single accepted opening into unrelated far-depth holes.
|
||||
var seedPortalView = frame.RentCellView();
|
||||
AddRegion(seedPortalView, clippedRegion);
|
||||
frame.ExteriorSeedPortals.Add(new ExteriorPortalSeed(
|
||||
cell.CellId,
|
||||
i,
|
||||
seedPortalView));
|
||||
|
||||
var seedView = GetOrCreate(frame, frame.CellViews, cell.CellId);
|
||||
bool grew = AddRegion(seedView, clippedRegion);
|
||||
|
||||
|
|
@ -1119,13 +1137,17 @@ public static class PortalVisibilityBuilder
|
|||
// InitCell leaves the in-plane case a CANDIDATE for cell portals (Ghidra
|
||||
// 0x005a4b70); building/exterior SEED portals additionally reject in-plane
|
||||
// via EyeInPlaneOfPortal (retail ConstructView(CBldPortal) IN_PLANE → 0).
|
||||
private static bool CameraOnInteriorSide(LoadedCell cell, int portalIndex, Vector3 cameraPos)
|
||||
private static bool CameraOnInteriorSide(
|
||||
LoadedCell cell,
|
||||
int portalIndex,
|
||||
Vector3 cameraPos,
|
||||
float epsilon = PortalSideEpsilon)
|
||||
{
|
||||
var plane = cell.ClipPlanes[portalIndex];
|
||||
if (plane.Normal.LengthSquared() < 1e-8f) return true; // no usable plane → allow
|
||||
var localCam = Vector3.Transform(cameraPos, cell.InverseWorldTransform);
|
||||
float dot = Vector3.Dot(plane.Normal, localCam) + plane.D;
|
||||
return plane.InsideSide == 0 ? dot >= -PortalSideEpsilon : dot <= PortalSideEpsilon;
|
||||
return plane.InsideSide == 0 ? dot >= -epsilon : dot <= epsilon;
|
||||
}
|
||||
|
||||
// T2 (BR-4): retail ConstructView(CBldPortal)'s Sidedness IN_PLANE reject
|
||||
|
|
|
|||
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