Compare commits

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

90 commits

Author SHA1 Message Date
Erik
d3df4cb20a docs: close shipped PAK v2 campaign 2026-08-27 20:50:52 +02:00
Erik
45ba42a3a7 fix(ci): make launcher space guidance culture invariant
All checks were successful
CI / linux-portable (push) Successful in 3m23s
CI / windows-gate (push) Successful in 5m33s
CI / release (push) Successful in 2m8s
2026-08-27 20:40:50 +02:00
Erik
cccae4169a Document PAK v2 resource closeout
Some checks failed
CI / linux-portable (push) Successful in 3m34s
CI / windows-gate (push) Failing after 6m5s
CI / release (push) Has been skipped
2026-08-27 20:29:50 +02:00
Erik
d8c794a478 Ignore local test result artifacts 2026-08-27 20:09:52 +02:00
Erik
0dd966f3a0 Optimize prepared asset package v2 2026-08-27 20:09:09 +02:00
Erik
d123c4b67c test(ci): harden scheduling and allocation gates
All checks were successful
CI / linux-portable (push) Successful in 3m30s
CI / windows-gate (push) Successful in 6m15s
CI / release (push) Successful in 2m11s
2026-08-27 19:28:10 +02:00
Erik
c4608b1127 Merge origin/main into MossTank automation work
Some checks failed
CI / linux-portable (push) Successful in 3m51s
CI / windows-gate (push) Failing after 7m8s
CI / release (push) Has been skipped
# Conflicts:
#	docs/ISSUES.md
2026-08-27 19:01:26 +02:00
Erik
4e6e9bc9d9 feat(mosstank): add VTank-style automation PoC 2026-08-27 18:57:21 +02:00
Erik
1d2f2f738f fix #451: stabilize portal seam rendering
All checks were successful
CI / linux-portable (push) Successful in 3m32s
CI / windows-gate (push) Successful in 6m55s
CI / release (push) Successful in 2m12s
2026-08-27 14:30:21 +02:00
Erik
f6fe0f2a4f fix(client): restore retail interaction parity
All checks were successful
CI / linux-portable (push) Successful in 3m27s
CI / windows-gate (push) Successful in 6m42s
CI / release (push) Successful in 2m12s
Harden keyboard and camera routing, inventory and vendor interactions, chat/emotes, relog portal flow, and paperdoll rendering. Add retail research, connected gate coverage, and release-gate validation.
2026-08-26 20:45:11 +02:00
Erik
0c699240e0 fix(ci): make release gates portable and deterministic
All checks were successful
CI / linux-portable (push) Successful in 3m46s
CI / windows-gate (push) Successful in 6m21s
CI / release (push) Successful in 2m15s
2026-08-25 19:38:10 +02:00
Erik
af9327a17b feat(launcher): stabilize prepared content updates
Some checks failed
CI / linux-portable (push) Failing after 3m12s
CI / windows-gate (push) Failing after 6m35s
CI / release (push) Has been skipped
2026-08-25 19:17:13 +02:00
Erik
f160f3fee1 fix(ui): wait for private viewport mesh residency 2026-08-25 19:16:53 +02:00
Erik
82e4b4cb6d fix(render): harden portal exit handoff
Some checks failed
CI / linux-portable (push) Successful in 3m19s
CI / windows-gate (push) Failing after 6m43s
CI / release (push) Has been skipped
2026-08-25 17:39:44 +02:00
Erik
ddbd7e4096 docs+fix(ui): Campaign AS CLOSED — connected gate PASSED; AS-GF1 probes stripped; #443 narrowed
Some checks failed
CI / linux-portable (push) Failing after 3m15s
CI / windows-gate (push) Failing after 6m54s
CI / release (push) Has been skipped
The owner ran the Campaign AS connected gate live and passed it. The two
gate findings 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/resize reveal
rows — AS-GF1 65f6f584 ruled it not a code defect), and the paperdoll
symptom narrowed from "renders nothing" to an intermittent FIRST-OPEN
DELAY: the probe round proved the private render layer healthy from the
first frames (nonzero handle, 34 MeshRefs, sane bounds/camera) for both
the examination clone and the inventory doll, with mesh residency/upload
latency the leading suspect. #443 stays open with that narrowed shape.

Per the probe-dies-with-its-investigation rule this strips
CreatureAppraisalViewportDiagnostics, its call sites, and the
launch-options row in one commit (recoverable via git show 65f6f584).
App hermetic suite green (6,337/0).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 14:15:26 +02:00
Erik
65f6f5848a fix(ui): Campaign AS gate fixes AS-GF1 — extras-list overflow ruled OUT as a code defect; paperdoll regression not isolated, probe added instead
Two owner-reported defects at the Campaign AS connected gate on the
examination window (player targets): the animated paperdoll no longer
renders at all, and a "reserved black rectangle" appears at the window's
bottom with the character extras list clipped mid-row at default (310x400)
window size.

ROOT CAUSE — extras-list overflow (the "clipped mid-row" half of defect 2):
NOT a code bug. AS3 (armor-level trio) and AS4 (society/allegiance/
configurable extras) grew the extras list past its DAT-authored 87px region
(element 0x10000335) at the window's minimum size — a new hermetic
regression test proves the worst-case combination (every AS3+AS4 addition
at once) reaches 20 rows / 400px of content, a 4.6x overflow. But retail's
own LayoutDesc authors NO scrollbar for this listbox either
(ScrollbarElementId == 0, verified against both the committed fixture and a
fresh tools/LayoutDump read of the live installed DAT — no drift), and the
SAME test proves UiItemList's pre-existing, unmodified wheel-scroll handler
(OnEvent's UiEventType.Scroll branch) already reveals every row on the next
paint. A scrollbar-less, wheel-scrollable list clipped to its authored
region until the user scrolls or resizes IS retail's own already-correctly-
ported mechanism, not a regression — so no fix was made here.

ROOT CAUSE — paperdoll / "black rectangle" (defect 1): NOT ISOLATED despite
exhaustive investigation. Every file the Campaign AS diff touches
(AppraisalUiController.cs, RetailUiRuntime.cs, CreatureAppraisalRows.cs,
AllegianceRankTitleTable.cs, CharacterIdentityText.cs,
CharacterSheetProvider.cs, InteractionRetainedUiComposition.cs, plus two
unrelated mechanical PublicWeenieFlags-literal refactors) was reviewed in
full against the pre-Campaign-AS baseline. The same worst-case regression
test proves Apply/ApplyCreature/RebuildCreatureStats/BuildExtra never throw
and always leave ActiveView == Character, CurrentObjectId != 0, and the
viewport's full ancestor-visibility chain Visible == true — ruling out
RetailCreatureAppraisalFrameView.TryGetVisibleTarget's first three gates.
CreatureAppraisalPresentation.cs and LivePresentationComposition.cs (the
entire render-time viewport pipeline) are byte-for-byte unchanged across
the whole 974fe88a..87e98395 window. UiViewport.OnDraw draws NOTHING (not
black) when its TextureSlot is unassigned, and the creaturePanel's own
full-panel backdrop (0x10000141) is what would show through instead — the
most likely explanation tying both defects to ONE underlying condition, but
its exact trigger (TryGetVisibleTarget's CurrentObjectId check, or
TrySynchronize's live-entity/mesh-availability check) lies in code nothing
in Campaign AS touches, and could not be reproduced hermetically (needs a
live entity + a live examine exchange).

Filed #443 with the full investigation trail. Added a temporary,
state-change-gated diagnostic probe (ACDREAM_PROBE_CREATURE_APPRAISAL_
VIEWPORT=1, CreatureAppraisalViewportDiagnostics) at both
TryGetVisibleTarget and TrySynchronize so the next live repro pinpoints the
exact failing reason instead of another guess. Per CLAUDE.md's "no
workarounds without explicit approval" and the investigation mode's own
escape hatch ("if you cannot root-cause, say what runtime evidence you
need instead of shipping a guess"), no behavioral fix was shipped for
defect 1.

Tests: AcDream.App.Tests hermetic filter 6,337/0; full-solution hermetic
suite 15,612/0 (all 14 projects green, including the known #442 flake,
which did not trip this run).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 12:58:29 +02:00
Erik
87e9839561 docs(AS): AS5 REVIEW-CLOSED; AS6 gate script committed — Campaign AS implementation COMPLETE
AS1-AS5 are all review-closed (AS5: 170/170 title strings verified, 6 of
them PE byte-decoded; zero behavioral findings across the campaign after
AS2). The AS6 connected-gate script is written and waits on the owner:
docs/research/2026-08-25-campaign-as-test-script.md, carrying the two
standing rulings (Society row colors are model-only pending AP-110
FontInfo — do not gate on them; the AD-114 animated paperdoll deviation
is expected) and the R3 unconditional-legend retail side-by-side check.

Also: the re-review's grep-hygiene tail — the last "retires AP-109"
phrasing in AppraisalUiController.cs now reads "closes AP-109's title-bar
residual", and the CT plan's stale "this campaign retires AP-109" intent
line (never executed — CT4 narrowed) carries a dated correction.

Branch remains unpushed per the owner's standing instruction.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 10:33:49 +02:00
Erik
9f3e326374 fix(ui): Campaign AS AS5 fix round — "retires AP-109" corrected to "narrows" at 6 sites
The AS5 review verified the port exhaustively (170/170 strings, 20
dispatch arms, 17 bounds tests, both call sites) and found one docs-class
defect: five code comments plus the plan slice text claimed AP-109 was
RETIRED while the register correctly keeps the row ACTIVE-narrowed (CT4
FormatXp GetNumberFormatA approximation sliver survives). Comment-only
edits; compile-checked; no behavior change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 10:30:53 +02:00
Erik
8f8c0c3a07 feat(ui): Campaign AS AS5 — allegiance rank-title table, exam title bar + character panel name line (G9, narrows AP-109)
Ports the 17-function heritage×gender AllegianceSystem::GetTitle
@0x005B8DD0 dispatch and AllegianceData::GetFullName @0x005B6950 as
AllegianceRankTitleTable (src/AcDream.App/UI/Layout/), then wires both
retail call sites: the examination window's title bar
(AppraisalUiController.BuildCharacterTitleBarName, from ApplyCreature's
character branch) and the character panel's name line
(CharacterSheetProvider.BuildSheet).

Census confirmed directly against the decomp (matches AP-109's
2026-08-25 correction exactly): 11 heritages -> 17 functions. Gearknight
and Tumerok author only a MALE Get*Title function, reused for both
gender dispatch branches; Lugian authors only a FEMALE one, reused for
both. Heritage id 0xA (Penumbraen) aliases to the Shadowbound (5)
functions on both branches. Olthoi/OlthoiAcid (12/13) are excluded by
GetTitle's own unsigned range check (heritage-1) <= 0xa. Every one of
the 17 functions shares an identical unsigned rank bounds test
(rank-1) > 9 -> no title (valid range 1..10; there is no "clamp to
rank-10 title" behavior for an out-of-range rank). All 170 title
strings transcribed verbatim from the decomp, including several
PE-byte-recovered data-literal indirections in the Sho/Gearknight/
Tumerok tables ("Kou", "Ou", "Dux", "Ona", "Rea", "Tah") that match
published AC lore exactly.

GetFullName: title = GetTitle(rank, heritage, gender); when GetTitle
resolves nothing, the output is the plain name; when it resolves, the
output is "title" + a single ASCII space (PE-byte-recovered at
data_794098) + name.

Two call sites independently re-verified against the decomp for the
rank/heritage/gender property ids: CharExamineUI::SetAppraiseInfo's
local AllegianceData struct (proven by its ctor/dtor pair) never shows
an explicit field write for _rank/_hg/_gender in the decompile — a
Binary Ninja struct-flattening artifact, not a missing read — while
gmStatManagementUI::UpdateCharacterInfo shows the same three
CBaseQualities::InqInt(0x71/0xbc/0x1e) calls as plain, unambiguous
locals, confirming Gender=0x71/HeritageGroup=0xBC/AllegianceRank=0x1E
as the three inputs at both sites (ruling R8: read live off the
appraisal/qualities bundle, never RuntimeAllegianceState).

Register: AP-109 is NARROWED, not retired. Its stated risk (a ranked
character's Name line showing plain-name-only) is closed, but the same
CT4 narrowing also flagged FormatXp's non-byte-identical
GetNumberFormatA approximation as "this row's own residual sliver if
one is ever found" — that caveat is untouched by this slice and is now
the row's only surviving open item.

Tests: AllegianceRankTitleTableTests (per-function golden values,
gender-reuse rules, Penumbraen alias, Olthoi exclusion, rank/heritage/
gender bounds, GetFullName composition); AppraisalUiControllerTests
(title-bar prefix, plain-name fallback, monster-path regression pin);
CharacterSheetProviderTests (name-line prefix + plain-name fallback).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 10:20:07 +02:00
Erik
adfce16bf1 docs(AS): AS4 REVIEW-CLOSED; delete the stale duplicate AS4 ledger row
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 10:02:00 +02:00
Erik
bf8f5b70a9 docs(AS): AS4 fix round — oracle corrections (D1), gate carry-note (D2), record fixes (D3); file #442
The AS4 dual-lens review approved the port as exact and required a
docs-only fix round:

- Ground truth §2b row 1: the Society gate is PRESENCE of Int 281
  (AppraisalProfile::InqInt @0x005B3830 returns found/not-found), not
  value!=0; the color rule is same-bit-first (@0x004b49fd/@0x004b4a49/
  @0x004b4a8b) so a multi-bit local player still resolves green on a
  match; the ??? arm precedes the Radiant Blood test.
- Ruling R5 corrected: the row model carries CreatureAppraisalValueStyle
  but ResolveColor is a deliberate no-op until AP-110's FontInfo-list
  residual lands — the Society green/red is model-only and invisible at
  the connected gate; AS6's script must not gate on row colors.
- Ledger: AS4 land 4ade9b04; true full-solution hermetic count is 15,528
  (the AS4 commit body's 15,410 was a mis-report; the review re-ran and
  reconciled 15,483 + 45 new = 15,528).
- #442 filed: pre-existing parallel-load flake in the shadow-caster
  zero-allocation pin, surfaced by the review's full-solution run;
  isolation evidence recorded; unrelated to Campaign AS.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 09:58:47 +02:00
Erik
4ade9b0427 feat(ui): Campaign AS AS4 — society/allegiance/fellowship + configurable extras (G6/G7/G8)
Ports the last three retail extras-list row families from CharExamineUI::
SetAppraiseInfo @0x004B45F0 into CreatureAppraisalRows.BuildExtra, closing
the character-path extras list end to end (AS2 header + AS3 armor-level/
ratings/legend + AS4 here). All rows are CHARACTER-path only; the monster
path (CreatureExamineUI::SetAppraiseInfo @0x004B3FF0) is unaffected and
pinned by a controller-level regression test.

Society row (gap G6, @0x004b49a1-@0x004b4c24): gated on PropertyInt 281
(Faction1Bits) being PRESENT — a literal reading of InqInt's found/not-
found return, not the ground-truth doc's informal "!= 0" value test.
Bit-priority if/else-if chain (Celestial Hand 0x1 -> Eldrytch Web 0x2 ->
"???" when Radiant Blood's 0x4 bit is ALSO clear -> else Radiant Blood)
comes straight off the decompiled branches. Rank-band suffix boundaries
(1-100 Initiate / 101-300 Adept / 301-600 Knight / 601-1000 Lord /
1001-1500 Master, no suffix outside every band) read directly off the
decomp's inclusive range checks @0x004b4ab9-@0x004b4b92 and match the
ground-truth table exactly. Color rule: green when the LOCAL player
shares the target's selected bit (checked first, so extra local bits
don't override a match), red when local has a different bit but not the
target's, normal when local has no society bits; the unrecognized "???"
branch never gets a color (retail's ebx_3 stays at its zero initializer).
The local player's own Faction1Bits comes from a new pure `localFactionBits`
parameter on BuildExtra — the composer never reads state directly.

Monarch/Patron/Followers cascade (gap G7, @0x004b4d97-@0x004b4f54): gated
by the caller on AllegianceRank (Int 30) >= 1, the SAME InqInt read AS2's
header AllegianceName binding already consumes. Four arms in retail's
exact order: MonarchsTitle (Str 21) absent -> "Alleg. Monarch:" + clamped
"%d Follower"/"%d Followers" (Int 35, singular only at exactly 1); present
+ PatronsTitle (Str 35) absent -> "Monarch:" only; both present and
ordinally equal -> one "Monarch/Patron:" row; both present and different
-> "Monarch:" then "Patron:".

Configurable extras (gap G8, @0x004b58be-@0x004b5c4d): Fellowship
(Str 10), Arrived in Dereth (Str 43 DateOfBirth), Time in Dereth (Int 125
Age via the ALREADY-PORTED RetailDurationText.Format — the same
ClientUISystem::DeltaTimeToString @0x00565E10 the decomp calls at
@0x004b59e0, reused rather than re-ported), Chess Rank (Int 181), Fishing
Skill (Int 192), Deaths (Int 43 NumDeaths, <= 0 -> "Has never died" with
the SAME "Deaths:" label, verified in the decomp), Titles Earned
(Int 262). Each row gates independently on its own property being
PRESENT (server already strips these per the target's visibility options
per ground truth §3) — no client-side option or success gating added. No
spacers separate these seven rows, matching the decomp exactly.

Seam: AppraisalUiController takes a new `Func<int> _localFactionBits`
dependency (per-call, never captured once — the secure-trade deferred-Func
lesson), invoked only on the character path. AppraisalRuntimeBindings
gained `LocalFactionBits`, wired in InteractionRetainedUiComposition from
`d.Character.LocalPlayer.Properties.GetInt(281)` — the SAME LocalPlayerState
instance CharacterSheetProvider already reads from, no new state path.

AS3 NIT 11: CharacterResponse_CombatRefreshRetainsArmorLevelRows now
applies a SECOND response with different armor-level values (proving the
refresh re-renders from fresh data) then a THIRD with armorLevels: null
(proving it clears). AS3 NIT 12: BuildExtra's XML doc now documents the
full authored row order with the @0x004b5d7d legend anchor. The test
Parsed() helper now ORs IdentifyResponseFlags.ArmorLevels into Flags
whenever armorLevels is supplied (realism sub-nit), applying uniformly to
every existing armor-level test in the file.

Ground-truth doc imprecisions found while verifying against the decomp
directly: (1) the Society gate is presence-of-property, not value != 0 —
InqInt's return is a found/not-found bool, the summary's "!= 0" phrasing
describes the common case but not the literal branch condition; (2) the
Society color rule's bit-priority (same-bit match checked before the
other-bits check) wasn't spelled out in the summary table, only "same/
different/none" — confirmed exact by reading all three branches
(@0x004b49fd/@0x004b4a49/@0x004b4a8b). No other disagreements found.

Register: docs/architecture/retail-divergence-register.md row AP-110
retires the "exhaustive character detail regions" clause from its
still-lacks list with a dated 2026-08-25 narrowing note (AS2+AS3+AS4
together closed the character-path extras list); the row's other
residuals (item-object preview, effective shield projection, cooldown-
remaining, augmentation-cost StringInfo, creature FontInfo-list
selection) are untouched.

Tests: 51 new rows-level tests in CreatureAppraisalRowsTests.cs (society
gate/bit-priority/band-boundaries/color-vs-local-faction, the full
allegiance cascade incl. follower singular/plural/clamp, each
configurable extra present/absent, "Has never died", monster-path
regression, one complete ordering-pin snapshot) plus 3 new controller-
level tests in AppraisalUiControllerTests.cs through the real LayoutDesc/
template binding (localFactionBits seam wired end to end, its default
fallback, and the strengthened monster-path regression). Hermetic
AcDream.App.Tests: 6253 passed. Full-solution hermetic run: 15,410
passed across all projects, 0 failed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 09:45:57 +02:00
Erik
bc48e3216a docs(AS): AS3 REVIEW-CLOSED (APPROVE, no fix round); R3 legend hedge settled at source
The AS3 dual-lens review verified the armor-level trio, sentinel, legend,
and monster-path exclusion at offset level and disproved the R3
BN-flattening theory structurally (legend sits outside the InqCreature
block, pseudo-C line 189962). The ratings adjudication resolved in the
implementer's favor: the pre-AS3 composer already had retail's per-row
gating and spacer discipline. NITs 11/12 + the AP-110 narrowing ride AS4.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 09:26:15 +02:00
Erik
1616cd3d39 feat(ui): Campaign AS AS3 — per-bodypart armor-level rows (G4)
Plumbs Parsed.ArmorLevels into the extras composer and ports the retail
armor-level trio + unenchantable legend for the player examination window's
extras list (0x10000335), closing gap G4 and the legend half of G8 from
docs/research/2026-08-25-campaign-as-ground-truth.md.

Decomp evidence (docs/research/named-retail/acclient_2013_pseudo_c.txt):
- CharExamineUI::SetAppraiseInfo @0x004B45F0: the armor-level trio
  (@0x004B4FD1-@0x004B5410) gates on ANY of nine base_armor_* fields > 0,
  emits one leading spacer, then three rows "Head/Chest/Groin" (Head,
  Chest, Abdomen), "Bicep/Wrist/Hand" (UpperArm, LowerArm, Hand),
  "Thigh/Shin/Foot" (UpperLeg, LowerLeg, Foot) formatted "AL: %s/%s/%s"
  with each part "%d" below 0x270f (9999) or "*%d" with (value-9999) at/
  above it (data_794344 vs data_7b110c). The trio precedes the ratings
  block and has no trailing spacer of its own.
- The "* = Unenchantable" legend (@0x004B5D7D-@0x004B5DED) is added
  UNCONDITIONALLY after the whole `if (InqCreature)` block closes —
  confirming ruling R3's "unconditional" reading directly from the raw
  decompile, not just the BN flattening theory.
- CreatureExamineUI::SetAppraiseInfo @0x004B3FF0 (monster path): reads the
  same nine ratings properties with the same gating/spacer logic, but
  never touches base_armor_* or the unenchantable literal. Confirmed the
  monster (character:false) path gains neither the trio nor the legend —
  CreatureAppraisalRows.BuildExtra is character-gated for both.
- Ruling R4 (spacer discipline): CharExamineUI's own ratings-block leading-
  spacer flag (ebx_13) is a known BN-decompiler artifact loss (call-
  argument mangling instead of a clean `= 1` assignment). Cross-checked
  against CreatureExamineUI's clean version of the identical algorithm:
  one leading spacer before the FIRST ratings-family row that fires, one
  trailing spacer if ANY fired. The existing BuildExtra ratings logic
  (per-row gates 307|313|314, 308|315|316, 350|351; single leading/
  trailing spacer) already matched this exactly — no functional change to
  the ratings section, only the signature/threading change to make room
  for the trio and legend around it.

Changed:
- CreatureAppraisalRows.BuildExtra now takes (properties, armorLevels,
  character) instead of (properties) alone. Character-gated trio + legend
  wrap the unchanged ratings logic.
- AppraisalUiController.RebuildCreatureStats takes the character flag and
  threads appraisal.ArmorLevels through; ApplyCreature passes its own
  `character` parameter. No caching needed for the combat refresh to keep
  the AL rows: AppraiseInfoParser always parses ArmorLevels into the fresh
  Parsed value Apply receives, so a re-Apply of the refreshed response
  renders the same rows for free.
- Test signature updates only (no behavior pins changed) plus new
  coverage: ArmorLevelTrioUsesRetailGroupingLabelsAndFormatPrecedingRatings,
  ArmorLevelPartRendersUnenchantableSentinelAtOrAbove9999 (theory: 9998/
  9999/10123), ArmorLevelRowMixesStarredAndPlainPartsIndependently,
  AllNineArmorLevelsZeroOrNegativeEmitsNoTrioAndNoSpacer,
  ArmorLevelTrioAbsentWhenArmorLevelsIsNull, EachRatingRowGatesIndependently,
  LegendIsAbsentOnMonsterPathEvenWithRatingsShown,
  LegendIsAlwaysLastOnCharacterPathEvenWithNoOtherExtras (rows-level);
  CharacterResponse_ArmorLevelTrioPopulatesExtraListThroughRealBinding,
  CharacterResponse_CombatRefreshRetainsArmorLevelRows,
  CreatureResponse_NeverGainsArmorLevelTrioOrLegend (controller-level,
  through the real LayoutImporter/FixtureLoader binding seam).

No existing pin was corrected — the pre-AS3 ratings gating/spacer
behavior already matched the decomp; only the call signature changed.

Full hermetic suite: AcDream.App.Tests 6208/0 skips; full-solution
15,483/0 skips. Release build green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 09:16:17 +02:00
Erik
bde5cae031 docs(AS): AS2 REVIEW-CLOSED in the ledger; paperdoll-bullet phrasing polish
Re-review of cc5290af accepted all six findings; the retail port is exact.
The paperdoll ruling bullet now reads cleanly (the retail-colors clause was
dangling off the decomp citation) and points at AD-114. The three remaining
Core-side PK-bit copies are flagged as a follow-up chip outside AS scope.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 09:05:08 +02:00
Erik
cc5290af7d fix(ui): Campaign AS AS2 fix round — review findings F13-F18
Opus's dual-lens review of the AS2 header-identity-block commit
(f8a22589) approved the retail port as exact and ordered six
mechanical fixes:

- F13: correct the plan doc's "static tinted preview" claim about
  retail's examine-window paperdoll — the decomp shows retail's clone
  IS animated (BasicCreatureExamineUI::Init @0x004AB9C0 ->
  CPhysicsObj::makeObject -> MorphToExistingObject +
  play_script_internal, CreatureMode::Render driving update_position
  every frame), just decoupled from the live target. AD-114 already
  records the correct deviation; only the plan doc's rationale text
  was wrong.
- F14: add a regression test pinning the monster path (character:
  false, success:false, no String 5 / Int 0x105 marker) so element
  0x1000053A stays cleared — the exact case that used to emit the
  invented "Assessment incomplete" literal AS2 deleted. This is the
  test coverage the AS2 commit message over-claimed already existed.
- F15: dedup the PK/PKLite PWD bit constants that were copy-pasted
  across three files into named PublicWeenieFlags.PlayerKiller
  (0x20) / PlayerKillerLite (0x02000000) values, routing all three
  consumers (AppraisalUiController, CharacterSheetProvider,
  LocalPlayerTeleportController) through them. Pure mechanical
  substitution; the write side (PlayerKillerStatusBitfield.Apply) is
  untouched.
- F16: file register row AD-115 for a real, previously-unrecorded
  deviation the AS2 commit introduced: BuildCharacterTitleDisplay
  clears element 0x10000151 when neither source resolves, where
  retail (CharExamineUI::Show, BasicCreatureExamineUI::Init) never
  clears it and would show the previous target's stale title
  instead. Marked intentional — ours is deliberately better.
- F17: narrow BuildPlayerKillerDisplay's doc comment — retail's
  `cur_weenobj != 0` guard @0x004b482f skips only the PK line, not
  the whole response; the whole-response drop on a missing local
  object is separate, pre-existing, and out of scope here.
- F18: ledger row for AS2 now reads "review fix round" with the land
  commit and a note that the fix SHA is recorded at review-close.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 08:59:29 +02:00
Erik
f8a2258979 feat(ui): Campaign AS AS2 — examination header identity block (G1/G2/G3/G5/G10)
Retail's CharExamineUI::SetAppraiseInfo (@0x004B45F0, player-target
examine subview) binds four fixed header lines that acdream was feeding
from the wrong appraisal properties:

- 0x10000150 (Heritage) got raw string prop 4 verbatim. Retail composes
  "<Gender> <Heritage>" via InqGenderHeritageDisplay (@0x005B5AE0) from
  Int 113 Gender + Int 188 HeritageGroup, falling back to the assessed
  object's creature-type name only when HeritageGroup == 0. Reuses the
  Campaign CT gender/heritage tables in CharacterIdentityText (new
  GenderHeritageDisplay helper) and the controller's existing
  CreatureDisplayNameResolver for the creature-type fallback arm.
- 0x10000151 (Profession/title) got AllegianceName (string 47) — the
  title line was never shown at all. Now resolves the CURRENT display
  title from Int 261 CharacterTitleId through CharacterTitleResolver
  (CharacterTitleTable::GetCharacterTitleFromID @0x005C6ED0), falling
  back to String 5 Template verbatim when the id is absent or
  unresolvable. RetailUiRuntime.MountAppraisal now wires the SAME
  CharacterTitleResolver instance the D.2b Character panel already
  owns (_bindings.Character.TitleResolver), resolved per call under
  DatLock — never captured once at mount time, per the secure-trade
  deferred-Func lesson.
- 0x10000152 (PlayerKiller) got MonarchsName (string 11) — never shown.
  Ruling R7: retail reads the LOCAL weenie's PWD bits
  (ACCWeenieObject::IsPK/IsPKLite @0x0058C8B0/@0x0058C8A0), never the
  appraisal payload. Now reads the assessed ClientObject's
  PublicWeenieBitfield directly (bit 0x20 -> "Player Killer", bit
  0x02000000 -> "Player Killer Lite", else "Non-Player Killer").
  Apply()'s existing bail-out when the object has left the local table
  already matches retail's "weenie is gone, leave the line cleared".
- 0x1000053A (AllegianceName) invented a literal "Assessment
  incomplete" on failed assess — zero retail provenance, deleted
  outright. Retail clears the element first (ClearCreatureText already
  does this every ApplyCreature call) then sets String 47
  AllegianceName only inside the Int 30 AllegianceRank >= 1 gate.
  Scoped to the character branch since retail's CreatureExamineUI
  (monsters) never binds this element at all.

None of the four lines are success-gated — ACE sends the int/string
tables even on a failed assess, matching retail's own composition.

First-ever AppraisalView.Character controller test coverage (gap G10):
header composition, title/allegiance fallback and gating, all three PK
variants plus the missing-object clear case, failed-assess rendering
with a repo grep confirming the invented literal is gone, and a
regression pin proving the monster (character: false) path is
untouched.

Register: files AD-114 for the examination preview's animated clone
(mirrors the assessed target's live current pose via
CreatureAppraisalFramePresenter) versus retail's independently
animated private CreatureMode clone (BasicCreatureExamineUI::Init
@0x004AB9C0) — owner-ruled intentional deviation, 2026-08-25
("we animate it, and I like it").

Full hermetic suite green: 15,469 tests passed, 0 failed, 0 skipped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 08:36:11 +02:00
Erik
0332466fdc docs: Campaign AS plan + AS1 ground-truth synthesis (assess window, player targets)
Owner report 2026-08-25: the examination window on an assessed PLAYER is
missing retail's identity block (gender/heritage, title, PK), the three
per-bodypart armor-level rows, allegiance/faction lines, and the
target-configurable extras. Three-lens research (our pipeline, ACE wire,
named retail decomp) established: the 0x00C9 parse is already complete
(ArmorLevels 0x4000 parsed and dead), everything arrives from ACE, and
the core defect is an element mis-mapping in the Character subview vs
CharExamineUI @0x004AD3C0 (heritage slot fed a raw string prop, title
slot fed AllegianceName, PK slot fed MonarchsName, allegiance slot fed
an invented "Assessment incomplete" literal). Zero Character-subview
tests existed, which is how it survived the Slice 3 gate.

Slices AS2-AS5 (serial, coupled files) + AS6 gate script; ground truth
doc carries the binding line-composition tables, gap ledger G1-G10, and
rulings R1-R8.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 08:19:38 +02:00
Erik
974fe88ade docs(CT): record the CT7 gate PASS in the ledger
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 07:44:54 +02:00
Erik
9ffc869925 docs: Campaign CT gate PASSED; file #441 (death/lifestone return, intermittent); retire the CT-era hover probe
The ACDREAM_PROBE_UI_HOVER probe dies with its closed investigation
(scrollbar hover, fixed 2d6333f8) per the launch-options rule; #441
stays filed with its probe recipe for the next occurrence.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 07:44:42 +02:00
Erik
752782d0a9 fix(CT-GF1): UiLabel opts out of the self-clip — plugin markup text restored
CT7 gate regression (owner report): all MossTank plugin text vanished
except button captions. A markup <label> authors position only, so
UiLabel's box was degenerate (0x0) and CT-GF1's completed self-clip
(UIRegion::DrawHere @0x0069FA30 shape) cropped its glyphs to nothing;
markup buttons author w/h, which is why their captions survived.

UiLabel now opts out of the self-clip — it is ClickThrough pure text
whose real containment is its ancestors (the plugin panel/window, which
are properly sized), the effective retail behavior for a text region
whose box hugs its glyphs — and keeps a truthful box by measuring its
current text each draw. Mechanism pin: an unsized label's subtree must
render inside its sized parent (probe-child draw-capture test).

Gate note recorded by the owner in the same round: the Titles-page
divider IS visible inside the window in retail while scrolling — a
retail quirk our clipped rendering now reproduces exactly. CT7 gate
PASSED apart from this regression.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 07:28:26 +02:00
Erik
9e85d82325 docs(CT): CT-GF1 review-closed
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 07:17:12 +02:00
Erik
90a0da6820 fix: correct 025108a8 — drop the accidentally-committed owner probe hunk
025108a8's own git-add -p staging for src/AcDream.App/UI/UiRoot.cs was
correct (only the ClipsChildren override + WantsMouse hunks staged,
verified via git diff --cached before committing), but the trailing
`git commit -m ... -- <pathspec>` listed UiRoot.cs by path — and a
pathspec-scoped `git commit` re-reads THOSE paths from the WORKING
TREE rather than honoring the index, silently pulling in the
pre-existing uncommitted ACDREAM_PROBE_UI_HOVER hunk alongside the
two intended ones.

This commit removes exactly that 17-line probe hunk from HEAD via a
direct index/blob edit (git hash-object + update-index), touching
ONLY the git object database — the working tree file is untouched
and still carries the probe as an uncommitted change, exactly as it
was before the CT-GF1 fix round started. Diffed the corrected blob
against HEAD to confirm the removal is byte-for-byte just the probe
block, nothing else.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 07:13:37 +02:00
Erik
025108a8aa fix(CT-GF1): review fix round — literal DrawHere clip shape, empty-clip cull, popup input routing
Applies all 11 items from the Opus dual-lens review of 989f6652 (0
blockers, 7 SHOULD-FIX, 4 NOTE):

- S2: UiElement.DrawSelfAndChildren now pushes the ambient clip right
  after PushAlpha and wraps OnDraw + the children walk +
  OnDrawAfterChildren in ONE block — the literal UIRegion::DrawHere
  @0x0069FA30 shape, which clips an element's OWN DrawSelf too, not
  just its children (UIElement_Text::DrawSelf @0x00467AA0 locks glyph
  blits to its own clipped surface rect; UIRegion::DrawSelf
  @0x0069F1A0 blits per clip rect). Deleted the two now-redundant
  ad-hoc self-clips this supersedes: UiText.DrawText and
  UiField.DrawMultiLine both pushed their own (0,0,Width,Height) —
  exactly what the new ambient clip already provides one level up.
  Kept UiButton.DrawBlockLabel's clip: it clips to LabelBox/ValueBox,
  an authored INNER sub-rect that can be smaller than and offset from
  the button's own full rect — a genuine narrower viewport, not a
  redundant duplicate.
- S3: deleted UiItemList's `ClipsChildren => CellWidth > 0f` override
  — correct under the old opt-in-false default, inverted under the
  new default-true (an unconfigured list would stop clipping instead
  of clipping like everything else).
- S4: pinned the escaped-popup input path end to end. New
  UiAncestorClipTests test mounts a menu inside a short window on a
  real UiRoot, opens it, and proves a click in the escaped popup
  region reaches the menu through UiRoot.PopupHit (a plain top-down
  walk is proven to reject the same point first). UiRoot.WantsMouse
  now also checks PopupHit — it previously only checked Captured/
  HitTestTopDown, so a game action could fire underneath an open
  dropdown's escaped region. OnMouseDown/OnScroll already routed
  through PopupHit first (#374); unchanged.
- S5: strengthened the Titles-divider regression test's positive
  half. The old assertion only checked SOME quad's Y fell in a band —
  vacuously true given other same-band content. Now asserts the
  divider's exact rect (X and Y), then diffs against the same rect
  with the divider hidden (Visible=false) to prove the quad was
  actually attributable to it.
- S1: added UiWindowDrawCaptureSweepTests — Character/Chat/Vendor/
  Options mounted through their real production Bind entry points
  with a non-zero sprite resolver, drawn via RecordingGpuDevice,
  asserting a per-window vertex floor (~40-45% of this session's
  observed baseline: Character 588, Chat 162, Vendor 54, Options 240)
  plus one key sprite id read LIVE off the bound controller/element
  (never hardcoded). Character's key sprite (RetailChromeSprites.
  TopEdge) specifically exercises OnDrawAfterChildren, the exact path
  S2's caution note flagged. Inventory/Paperdoll/social/map-house
  skipped — no single fixture-driven top-level Bind entry point.
- S6: added the CT-GF1 subsection to the campaign plan's ledger
  (989f6652 + this fix round; CT7 re-gate still owed).
- S7: UiRenderContext.PushClipUnbounded now resets to the CANVAS rect
  (0,0,ScreenSize), not null — retail's own popup region is
  SCREEN-clipped (UIElement_Menu::MakePopup spawns a top-level region
  bounded by the screen), not truly unbounded. AD-113 amended.
- N1: UiRoot overrides ClipsChildren => false — the root's own region
  IS the screen (the viewport already scissors it), so this is a
  safety net against a momentarily zero-sized root silently blanking
  the whole UI tree under the new ancestor-clip default.
- N2: added the empty-clip subtree cull (retail's var_24 gate
  @0x0069FB8E) to DrawSelfAndChildren only — DrawOverlays is a wholly
  separate traversal untouched by this change. New test proves a menu
  inside a fully-clipped (zero-width) window still draws its open
  popup via the overlay pass while the main pass draws nothing.
- N3: CT7 script §5 now 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 the re-gate.
- N4: verification below covers both the working tree and the clean
  committed tree.

Decomp anchors: UIRegion::DrawHere @0x0069FA30 (var_24 gate
@0x0069FB8E); UIElement_Text::DrawSelf @0x00467AA0 (self-clip);
UIRegion::DrawSelf @0x0069F1A0; UIElement_Menu::MakePopup (screen-
clipped popup region).

Verification (both runs green, --filter "Lane!=InstalledDat&
Lane!=PreparedPackage&Lane!=Live&Lane!=Manual&Lane!=Timing&
Lane!=Windows&Lane!=Linux&Lane!=SystemFont&Purpose!=Diagnostic&
Status!=KnownFailure"): full Release solution build green; working
tree 14,900+ tests across every project (one LandblockPresentation
PipelineTests flake reproduced ONLY under full-solution parallel
load, passes standalone and on rerun — unrelated to this change,
streaming domain); InstalledDat lane green (ACDREAM_RUN_INSTALLED_DAT
_TESTS=1, Status!=KnownFailure, 205+34+3+172 App/Content/Bake/Core
tests). Clean committed tree (git stash push -u the uncommitted
owner probe + docs files, rerun, stash pop) reported in the session
summary.

src/AcDream.App/UI/UiRoot.cs carries an unrelated, pre-existing
uncommitted owner probe (ACDREAM_PROBE_UI_HOVER) — staged selectively
(git add -p) so only this commit's own two hunks (ClipsChildren
override, WantsMouse) landed; the probe hunk is untouched and stays
uncommitted, same as before this fix round.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 07:11:42 +02:00
Erik
989f665214 feat(CT-GF1): port retail ancestor-clip to the retained UI tree
Fixes the CT7 gate finding: on the Titles tab, the authored divider
0x10000530 escapes the Character window above its top edge at the
CT6-correct 372px mounted default (computed Y ~ -178, matching the
owner's screenshot). Retail clips child rendering to the intersected
ancestor clip-rect chain -- UIRegion::DrawHere @0x0069FA30 takes the
element's screen Box2D plus a SmartArray<Box2D> of inherited clip
rects, intersects them (the min/max clamp loop @0x0069FAA7..0x0069FB82),
and draws EraseSelf/DrawChildren/DrawSelf with the intersected rect
only when non-empty (the var_24 gate @0x0069FB8E). Our UiElement draw
walk rendered children unclipped by default, so any authored element
relying on clipping -- this divider, and the chat input row at small
window sizes (the owner's earlier "text input sticks out on resize"
report) -- became a visible artifact.

Mechanism (element-level, reusing the existing clip-rect-stack
infrastructure in UiRenderContext.PushClip/PopClip):

- UiElement.ClipsChildren now defaults to TRUE for every element
  (was an opt-in used only by UiScrollablePanel/UiItemList). Each
  element's children draw AND hit-test clipped to the intersection
  of its own rect with the inherited ancestor clip; an element
  positioned outside its parent's box silently disappears, matching
  retail's non-empty-intersection gate. HitTest's existing early
  bounds check already implemented this shape for ClipsChildren=true
  elements -- flipping the default aligns hit-testing with the new
  draw-clip default in one property, per the plan's own point 4.

- UiElement.ExpandsClipForPopup (default false) is the one opt-out:
  retail spawns a menu popup as a SEPARATE top-level region
  (UIElement_Menu::MakePopup), clipped only by the screen; acdream
  draws UiMenu's popup inline from the owning button in a second
  traversal (OnDrawOverlay, pre-existing -- its own doc comment
  already says "regardless of this element's position in the tree").
  DrawOverlays now resets the accumulated clip to unbounded
  (UiRenderContext.PushClipUnbounded, sharing the existing clip
  stack) for exactly the OnDrawOverlay call of an opted-in element.
  UiMenu overrides ExpandsClipForPopup=>true, paired with
  ClipsChildren=>false so its own out-of-bounds OnHitTest union (the
  popup occupies ly<0 or ly>=Height depending on open direction)
  stays reachable through the same early-bounds gate that now
  defaults on for every other element.

Opt-out audit (grep for OnDrawOverlay overrides + negative/overflow
OnDraw coordinates across src/AcDream.App/UI): UiMenu's popup is the
ONLY OnDrawOverlay override client-wide, so it is the only element
needing ExpandsClipForPopup. RetailTooltipPresenter's popup and
UiRoot's drag ghost both already escape structurally -- the tooltip
mounts as an ordinary UiRoot CHILD (sibling of every window, clipped
only by the canvas), and the drag ghost is drawn directly by UiRoot
outside the tree entirely -- neither needed a code change, both are
covered by new tests proving the invariant. UiResizeGrip and
UiNineSlicePanel's frame/bevel draw entirely within their own
[0,Width]x[0,Height] (grip flush at the window's own edges; the
window's own Width/Height already represents the OUTER frame
including its 5px bevel, so its ClipsChildren push already covers
the frame's own content children correctly -- no negative insets
found). UiScrollbar draws entirely within its own bounds (confirmed
by reading OnDraw).

Hit-testing: aligned with the new default via the single
ClipsChildren flip (see above); UiMenu's own opt-out override keeps
its popup hit-test union working, verified by the full UiMenuTests
suite staying green.

Divergence register: AD-113 filed for the ExpandsClipForPopup
adaptation (inline popup drawing vs retail's separate top-level
region).

Fixed two pre-existing test-harness gaps the new default surfaced
(both real bugs in the harnesses, not workarounds around the fix):
- ChatLayoutConformanceTests' bottom-right-grip grow test read a
  STALE (pre-shrink) grip screen position because it drove two resize
  gestures back-to-back with no intervening Draw pass -- the only
  place UiElement.ApplyAnchor/LayoutPolicy.Apply run. A real frame
  draws every tick, so production never hits this; the test now
  inserts a real DrawSelfAndChildren pass between the two gestures,
  matching a real frame boundary.
- VendorUiControllerTests' hand-built Items/Buying/Selling page
  containers were left at their bare 0x0 UiElement default (the
  harness never runs a real DAT-driven layout pass) -- harmless
  before ancestor clipping existed, but now hides every child of an
  unsized page. Sized them to the window's own content root, matching
  production's shape (a tab page fills the window body).

Tests (all confirmed as genuine regression pins by temporarily
reverting the relevant default/override and observing the exact
predicted failure, then reverting back):
- CharacterTitlesControllerTests.TitlesPage_Divider_ClipsAwayAtThe
  CT6Default_AndAppearsWhenTheWindowGrowsTaller: the literal gate
  repro against the real character_2100002E.json fixture through
  RetailWindowFrame.Mount at the CT6 372px default -- the divider
  renders nothing (computed Y ~ -173, matching the owner's ~-178);
  growing the window to 600px renders it at its authored spot.
- ChatLayoutConformanceTests.ResizingTheWindowSmall_NoInputRowQuad
  RendersOutsideTheWindowRect: no input-row quad escapes the chat
  window rect at three small sizes (300x100 sanity control,
  120x40/80x30 genuine pre-fix overflow -- verified failing without
  the fix at Y=38/55 past the window edge).
- UiAncestorClipTests (new file): the core mechanism against plain
  synthetic elements (culled-outside / clipped-at-the-edge / hit-test
  parity), UiMenu's popup escaping a tiny owning window (and staying
  clipped while closed), and the tooltip's structural immunity
  (mounts as a UiRoot sibling, unaffected by a tiny ancestor window).

Verification: full solution build green; hermetic suite green
(--filter "Lane!=InstalledDat&Lane!=PreparedPackage&Lane!=Live&
Lane!=Manual&Lane!=Timing&Lane!=Windows&Lane!=Linux&
Lane!=SystemFont&Purpose!=Diagnostic&Status!=KnownFailure",
14,000+ tests across every project); InstalledDat lane green
(ACDREAM_RUN_INSTALLED_DAT_TESTS=1, Status!=KnownFailure,
205+34+3+172 tests). CharacterTitlesControllerTests' existing suite
and the full UiMenuTests/UiScrollbarTests suites are unaffected.

src/AcDream.App/UI/UiRoot.cs carries an unrelated, pre-existing
uncommitted owner probe (ACDREAM_PROBE_UI_HOVER) -- untouched by
this change and deliberately left out of this commit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 06:29:15 +02:00
Erik
6561d08fa8 docs(CT): CT6 review-closed — campaign implementation complete, CT7 gate awaiting owner
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 02:54:56 +02:00
Erik
996cd73675 fix(CT6): fix round — chrome-inclusive host clamp (BLOCKER B1) + 372px mount default
Opus dual-lens review of CT6 (ec50455a) found 1 blocker, 4 should-fix, 5
notes. All applied:

BLOCKER B1 — the shared gmPanelUI host (0x100005FE) IS retail's own
outer window frame, not a content element: its authored 310/372/310/1000
already include the 5px bevel on every side. RetailWindowFrame.Mount was
adding the NineSlice wrapper's OWN 10px chrome inset on top of that
already-chrome-inclusive source, 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 only, value stays
DAT-sourced); MountCharacter sets it true. Mounted clamp is now exactly
the host's four raw values: width fixed 310, height 372..1000. Added a
mount-time invariant (throws if the mounted outer extent falls outside
its own just-computed clamp) that would have caught this at the first
test run.

S4 (campaign-lead ruling) — the window must MOUNT at retail's authored
default, outer 372 (content 362, matching the host's own content parent
0x10000180), not 0x2100002E's own 300x600 content-authoring canvas
(which produced a stale 610px default pre-fix: 600 + 10 chrome inset).
372 is exactly the host's own authored MinHeight — retail opens at its
resize floor and can only be dragged taller. MountCharacter now sets
ContentHeight=362f explicitly. At this default the 9 attribute/vital
rows (180px) overflow the 160px list immediately — retail-correct, not
a regression.

S2 — 0x1000023E and 0x10000533 both author property 0x79
(HideWhenDisabled) TRUE (fixture-verified: BoolValue=true on both). A
fitting list HIDES the scrollbar entirely; it does not draw a full-track
"disabled" thumb. The code was already correct; four wrong descriptions
(plan ledger, CharacterStatController comment, CT7 script, test comment)
are corrected, plus a new IsPresentationVisible assertion pair in the
resize test.

S3 — CharacterTitlesController's `if (listBox.LayoutPolicy is null)`
Anchors fallback was unreachable on both the real DAT and the fixture
(0x10000532/0x10000539 both author HasOriginalParentSize=true, so
LayoutPolicy is always assigned). Deleted; added an InstalledDatFact pin
guarding the deletion against DAT drift.

N4 — renamed NineSlice_ChatShapedConstraints_... to
NineSlice_ContentShapedConstraints_InsetArithmeticClampsProgrammaticResize
(it tested inset arithmetic on a content-shaped source, not chat's real
contract) and added a true chat-contract pin mounting Chrome=Imported
with chat's real 300/100/2000/2000 constraints, asserting no inset
applies.

N5 — corrected the "nothing inferred, no register row" sentences in the
ground-truth doc and plan ledger: they were false pre-fix (the mounted
clamp WAS an inferred double-counted composition); true now that B1
removes the composition.

CT7 script §4 rewritten with exact clamps (no "≈"), the corrected
default-overflow scrollbar behavior, and an absolute starting-height
statement.

Verified: full hermetic solution suite green (15,441 tests, Release,
Lane exclusions per the release gate), InstalledDat lane green across
the whole solution (414 tests, ACDREAM_RUN_INSTALLED_DAT_TESTS=1,
Status!=KnownFailure) including two new pins
(TitlesListAndPage_AuthorHasOriginalParentSize,
Imported_ChatContract_ClampsAtAuthoredBoundsWithNoChromeInset).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 02:53:57 +02:00
Erik
ec50455a63 feat(CT6): character window Y-resize clamped at retail's authored host minimum + shrink-and-scroll list contract
CT6 (Campaign CT slice 6): 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 — live
probe confirmed the host authors MinWidth=MaxWidth=310 (fixed — no
horizontal Resizebar authored), MinHeight=372, MaxHeight=1000, and that
the bottom Resizebar (0x10000660) and top Dragbar (0x1000065C) are
direct children of the host, not the content parent. Decomp chain:
UIElement_Resizebar::StartMouseResizing @0x0046B7E0 calls
UIElement::StartResizing(this->GetParent(), ...), stashing drag state on
that parent; UIElement::MouseResizeElement @0x00461130 then reads
GetAttribute_Int(this, 0x3C..0x3F) off that same element every
mouse-move.

RetailUiRuntime.MountCharacter now imports the host element and passes
it as RetailWindowFrame.Options.DatConstraintSource, matching the
existing MountSideVitals pattern.

CharacterStatController.RebuildActiveList now wraps BOTH the Attributes
and Skills tabs' rows in the same UiScrollablePanel viewport (previously
only Skills got one; Attributes rows had no clipping/scrolling and the
shared scrollbar was force-hidden — owner report item 2). The shared
scrollbar is now always bound + visible; UiScrollbar's own
IsPresentationVisible/IsModelDisabled already draw the correct
full-track "disabled" thumb when content fits. This surfaced and fixed
a real #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 to its mounted height, permanently
capping the viewport short on every later resize. Fixed with an eager
CaptureCurrentAnchorBaseline() call, mirroring UiTemplateListBox
.Viewport's own lazy getter.

CharacterTitlesController.Bind gained the same defensive
Anchors = Left|Top|Bottom fallback for the Titles ListBox that
CharacterStatController already had (a no-op on the real DAT — both the
Titles page and its ListBox already carry a real authored LayoutPolicy
that stretches correctly).

Standardization audit: UiElement.MinWidth/MinHeight/MaxWidth/MaxHeight,
set once at RetailWindowFrame.Mount, are the ONLY clamp fields — read
identically by interactive drag, RetailWindowManager.ResizeTo, and
RetailWindowLayoutPersistence's restore clamp. No gaps found; no
register row (every number is a live-probed authored DAT value or a
structural correctness fix, nothing inferred).

Tests: CharacterStatControllerTests
.CharacterWindow_ResizesYWithinAuthoredHostClamp_AndReflowsListAndScrollbar,
CharacterTitlesControllerTests
.TitlesList_ReflowsWithWindowResize_AndScrollbarOverflowFlips,
RetailWindowFrameTests
.NineSlice_ChatShapedConstraints_ClampProgrammaticResizeAtAuthoredBounds
(shared-mechanism regression pin), CharacterPanelLiveDatTests
.PanelHost_AuthorsFixedWidthAndBottomOnlyResizeContract (InstalledDat
pin). Existing attribute-row tests updated from list.Children to
Descendants(list) for the new nested-viewport shape (the pattern skill
rows already needed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 02:21:56 +02:00
Erik
4cbbdaf4bf docs(CT): CT5 review-closed (fix round 0a37a28e)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 01:48:08 +02:00
Erik
0a37a28e76 fix(ui): Campaign CT5 fix round — Normal-state row media, geometry test, padding, doc corrections
Opus review of CT5 (f532f28c) found 0 blockers, 4 SHOULD-FIX, 6 NOTEs; all
applied here.

SHOULD-FIX 1 (visible retail gap): unselected attribute/skill rows now draw
the row template's Normal-state media (0x06004CC2 — pinned by
CharacterPanelLiveDatTests.AttributeRowTemplate_...) instead of drawing
nothing. Independently decoded against the installed DAT: PFID_A8R8G8B8,
48x48, uniform (0,0,0,175) — a ~69%-opaque black tile the native-size
copy-or-tile blit repeats across the row. Wired at all three sites
(HandleRowClick, ApplySkillSelectionVisuals, AddRow). Selected rows keep
0x06000F93 (RowHighlightSprite) unchanged.

SHOULD-FIX 2: added Bind_AttributeRow_/Bind_VitalRow_/Bind_SkillRow_
geometry tests asserting the authored template pixels against BUILT rows
(not DAT pins) — width, height, icon/name/value column positions. The
skill-row case reproduces the real production scrollbar (X=281, per
CharacterPanelLiveDatTests.StatListBox_AuthorsFiveRowTemplatesInSharedLayout)
to prove the documented 281px clamp (scrollbar.Left - list.Left), one pixel
short of the attribute/vital rows' 282px ceiling.

SHOULD-FIX 3: AddRow's name-column Padding corrected from 1f to 0f — the
authored template carries no margin on 0x1000012A; Padding=1f re-created
the X=26 glyph-start bug this slice existed to fix.

SHOULD-FIX 4: reworded both UiPanel.BackgroundSprite doc comments — the
draw is a native-size copy-or-tile blit (UV-repeat), never a stretch.
Decoded 0x06000F93 as exactly 282x20 (matches the row natively, draws as a
plain copy) vs 0x06004CC2's 48x48 tile. Retail's UIRegion::SetImageByDID
(@0x0069F960) decompiles to a pure BlitMode selector switch — param_2==2 ->
Blit_3Alpha, ==3 -> Blit_4Alpha, else Blit_Normal — with no width/height
touched anywhere in the function, answering CT1's open "draw mode 3"
question: it's an alpha-blend selector, not a resize flag.

NOTEs:
  a. AddRow's nameEl now sets OneLine=true so the authored VJustify=Center
     takes the same single-line vertical-centering path the value column
     already uses.
  b. Tempered the "row width is 282" wording in the SkillContentWidth /
     RowContentWidth doc comments — that's a ceiling attribute/vital rows
     land on, not a fact true for skill rows (281, via SkillViewportWidth's
     scrollbar-gutter measurement).
  c. Reworded the section-header (RowPadX) comment — CT1 verified only the
     four header SPRITES; the caption label's own authored margins were
     never checked. Recorded as an open residual, not a cleared divergence.
  d. AttrRows/VitalRows are now internal (InternalsVisibleTo("AcDream.App.Tests")
     already covers AcDream.App.Tests); CharacterPanelLiveDatTests iterates
     them directly instead of a re-typed duplicate array, and now also
     asserts the vitals 2/4/6 current-enum aliasing claim the doc comment
     made but never enforced.
  e. Deleted the stale pre-CT5 0x06001397 narrative in
     CharacterPanelLiveDatTests; the pin's comment now describes the
     post-CT5 state (a regression guard, not an open bug).
  f. Unified ApplySkillSelectionVisuals' selected-branch SpriteResolve
     wrapper closure with HandleRowClick's direct assignment.

Build green; full hermetic solution suite green (Release,
Lane!=InstalledDat&...&Status!=KnownFailure filter); InstalledDat lane
green (ACDREAM_RUN_INSTALLED_DAT_TESTS=1, Lane=InstalledDat&Status!=KnownFailure).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 01:47:25 +02:00
Erik
657b84c297 docs(CT): CT7 gate script draft — §1-§3/§5 final, §4 pending CT6
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 01:17:35 +02:00
Erik
f532f28c5b feat(ui): Campaign CT slice CT5 — attribute/skill row geometry + selection media
Aligns the hand-built attribute/skill rows in CharacterStatController with
the authored shared row template 0x10000248 (LayoutDesc 0x21000045,
InfoRegion::InfoRegion @0x004F1450 template index 0 — the same template
gmAttributeUI and gmSkillUI both instantiate):

- Row geometry replaced with AUTHORED PIXEL VALUES instead of derived
  fractions: icon flush left 20x20 (was 16x16 at X=4, vertically
  centered), name column X=25 W=150 fixed (was RowPadX+IconSize+IconGap
  offset with a width*0.60 fraction), value column X=175 W=100
  right-justified (its right edge sits 7px short of the row's 282px
  right edge — the authored gutter the owner reported). Row width itself
  now clamps to the authored 282px template width (RowContentWidth)
  rather than the ListBox's raw 300px container width. Attribute-row
  height fixed at 20px (was 22px, no dat basis); SkillRowHeight folded
  into the same RowHeight constant since both row kinds share H=20.

- RowHighlightSprite corrected from 0x06001397 to 0x06000F93 — CT1's
  ground-truth research sealed the verdict that gmAttributeUI::
  UpdateSelection @0x0049DEE0 (SetState(6) -> InfoRegion::SetState
  @0x004F0EE0) swaps the row's Highlight-state media (0x06000F93), a
  full-row background swap. 0x06001397 belongs to a different mechanism
  entirely (the spellbook row's UIElement_UIItem::SetSelectedState
  overlay child) and SpellbookRowStyle.cs is untouched.

- UiClickablePanel.UseSelectionBars/SelectionBarHeight retired outright
  (UiPanel.cs): they existed only to emulate 0x06001397's dark-bars art;
  the correct retail rendering is the full-panel sprite stretch the base
  UiPanel.OnDraw already performs, so the override is dead code once the
  correct sprite is used. No consumer existed outside
  CharacterStatController.

- Per-attribute/per-vital icon DIDs now resolve through the live
  DBObj::GetDIDByEnum chain (RetailDataIdResolver.Resolve, AP-235's
  unification seam) when a resolver is supplied — RetailUiRuntime.
  MountCharacter wires one under the shared DatLock — falling back to
  the hardcoded AttrRows/VitalRows column otherwise (tests, no dat).
  gmAttributeUI::PostInit @0x0049DB70 read verbatim: attributes resolve
  via category 0x10000002 (statId order 1,2,4,3,5,6, matching AttrRows'
  authored display order exactly); vitals via category 0x10000003.
  Live-DAT-verified: every hardcoded fallback value already matched the
  resolved DID byte-exact (new InstalledDat pin
  AttributeAndVitalIconDids_MatchTheRetailEnumMapperChain).

- RetailAppraisalNameResolver.ResolveHeritage's independent
  re-implementation of the 2/5/13 heritage overrides deleted; it now
  delegates straight to CharacterIdentityText.HeritageGroupDisplayName
  (which already bakes in the same overrides) — one owner, byte-identical
  behavior. AP-235's register row updated to reflect the single-owner fix
  (the underlying hardcoded-vs-live-DAT mechanism divergence itself
  stays open — out of CT5's scope).

Hand-built-vs-template ruling: rows stay HAND-BUILT rather than
converting to UiTemplateListBox instantiation. The hand-built path hits
every authored number byte-exact (proven by the CT1 InstalledDat pin
AttributeRowTemplate_IconIsFlushLeftTwentyPixels_NameAndValueAreFixedColumns),
while conversion would touch ~15 call sites (raise-button affordability,
footer State A/B, per-row tooltip, section bucketing, live-refresh,
selection-highlight) for a geometry-only slice — smaller-risk path per
the task's own judgment-call guidance.

Tests: CharacterStatControllerTests' sprite/UseSelectionBars assertions
corrected to the authored geometry; new InstalledDat pin for the icon-DID
chain. Full hermetic solution suite green (App/Core/Runtime/Headless/
Launcher/Content/etc., 0 failures) and the full InstalledDat lane green
(203 App.Tests pins, TowerAscentReplayTests' known Status=KnownFailure
case excluded per the acceptance filter).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 01:15:32 +02:00
Erik
01e44a7018 docs(CT): CT4 review-closed (fix round e7e32409)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 00:56:33 +02:00
Erik
e7e32409c2 fix(ui): Campaign CT4 fix round — luminance text, verbatim title, PK PWD bits
Opus dual-lens review of ed652ed8 found 2 blockers + 5 should-fix. All applied.

BLOCKERS:
- Bind the luminance pair (0x100005C5/0x100005C6): caption "Luminance:"
  (UTF-16, PE-byte-decoded from the gmStatManagementUI vftable-adjacent
  data at @0x007c3dd4) and value "<available> / <maximum>" (narrow
  "%s / %s" @0x007c3dcc) — both literals independently re-derived from the
  raw acclient.exe bytes and confirmed byte-exact against the review's
  claim. Numbers format through a new shared FormatXp helper
  (.ToString("N0", InvariantCulture) — retail's ExperienceSystem::XPToString
  equivalent), also now used by Total XP / XP-to-next-level (previously an
  un-invariant bare "N0"). Hide path switched from Visible=false to
  retail's own UIElement_Text::ClearAllText mechanism
  (@0x004f0e31/@0x004f0e3c — empty LinesProvider, leave layout); each
  LinesProvider re-reads data() on every draw, so no separate refresh call
  is needed.
- CharacterIdentityText.StripLeadingArticle deleted: retail AppendText's
  the resolved title VERBATIM (@0x004f0990); 26 real ACE CharacterTitle
  entries begin with "The" and were being mangled. The dead
  CharacterSheet.Race fallback is deleted alongside it — retail's
  InqGenderHeritageDisplay creature-type argument is a hardcoded literal 0
  (@0x004f08db), no producer exists.

SHOULD-FIX:
- PK line re-sourced: classifies off the live ClientObject.PublicWeenieBitfield
  PWD bits (0x20 IsPK / 0x02000000 IsPKLite — ACCWeenieObject::IsPK/IsPKLite
  @0x0058c8b0/@0x0058c8a0) instead of a bitwise test against raw
  PropertyInt 134, which carries ACE's own PlayerKillerStatus enum bit
  layout, not the PWD layout. PropertyInt 134 already drives the correct
  bits via the existing PlayerKillerStatusBitfield.Apply; this is a
  re-source, not new wiring. Deleted the 0x4|0x8 combined-flag test case,
  which asserted a non-retail answer.
- Register 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 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 an estimated 22 functions/~200
  strings to the actual 17 functions/~170 strings (AllegianceSystem::GetTitle's
  dispatch switch read directly), and downgrades the evidence claim.
  Filed AP-235 for the gender/heritage hardcoded-table-vs-live-EnumMapper
  mechanism divergence, pointing at the ALREADY-EXISTING
  RetailDataIdResolver.Resolve helper as CT5's unification seam.
- CharacterPanelLiveDatTests.HeaderElements_AuthorExpectedFontsAndColors
  extended with the luminance pair's own occurrence-count + font/color
  pins, matching every other header id's pattern.

Also landed: 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 the review
flagged as unverified guesses (10 "Penumbraen", 12 "Olthoi" — both
correct). CharacterSheetProvider.BuildSheet's level read switched from a
GetInt+ContainsKey double lookup to one TryGetValue. Plan ledger's
test-provenance sentence corrected (Bind_HeaderElements_... predates CT4,
extended to cover PkStatusId).

Tests: CharacterStatControllerTests (verbatim title incl. "The Noob",
luminance content/gate, luminance text binding, extended
Bind_HeaderElements_... covering PkStatusId), CharacterSheetProviderTests
(PK status driven through ClientObjectTable.UpdateIntProperty instead of
a raw property write), CharacterPanelLiveDatTests (luminance pin, gender/
heritage EnumMapper pin). Full hermetic solution suite green under Release
(0 failures, 15 projects); InstalledDat pins green (197/197, excluding one
confirmed pre-existing unrelated failure — TowerAscentReplayTests, verified
to fail identically with these changes stashed out).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 00:54:33 +02:00
Erik
ed652ed8ad feat(ui): Campaign CT slice CT4 — header identity block retail-exact
Retires the rest of AP-109's UI half: the character panel's Name/Heritage/
PkStatus/Level header identity block is now live and DAT-faithful on both
Attributes and Skills pages (verified: CharacterStatController.Bind already
scopes Label/LabelAuthoredColor to the ONE physically-visible page container,
so both tabs share the same bound widgets).

- Name/Heritage/PkStatus/Level switch from hand-picked Body/Gold runtime
  colors to the widget's own authored DefaultColor (LabelAuthoredColor) —
  CT1's live-DAT pin (HeaderElements_AuthorExpectedFontsAndColors) confirmed
  all four already carry the correct FontColor (white/white/white/pale-gold
  with Outline); the former "runtime color, dat carries none" comment was
  false.
- PkStatus resolves through StringTable 0x23000001 by key
  (ID_StatManagement_Header_PKStatus_PK/_PKL/_NPK) with a bitwise
  IsPK/IsPKLite test (gmStatManagementUI::UpdatePKStatus @0x004F00A0) instead
  of the prior exact-equality switch, which silently dropped combined-flag
  PlayerKillerStatus values. Live-DAT-verified strings: "Player Killer" /
  "Player Killer Lite" / "Non-Player Killer" (new InstalledDat pin
  PkStatusKeys_ResolveExpectedAuthoredStrings).
- Level shows "%d"-formatted InqInt(0x19) or the PE-recovered literal "???"
  when absent (CharacterSheet.Level is now int?).
- Heritage line appends CT2/CT3's resolved RuntimeCharacterTitleState
  display title through CharacterTitleResolver, refreshing live on both
  TableReplaced (0x0029) and DisplayTitleChanged (0x002B) —
  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 x gender table (verbatim decomp literals, e.g.
  GetAluvianMaleTitle @0x005B7BC0's Yeoman/Baronet/.../High King) judged out
  of reasonable size for this slice. RuntimeAllegianceState already carries
  the local player's own rank; only the string table is missing. Registered,
  not silently omitted.
- Luminance pair (0x100005C5/0x100005C6): CharacterSheet.AvailableLuminance/
  MaximumLuminance (PropertyInt64 6/7) already flow generically through both
  the PlayerDescription snapshot and the live 0x02CF private-update parsers
  (no wiring gap). The retail show/hide gate (Level >= 200 &&
  MaximumLuminance != 0, UpdateExperience @0x004F0A70) is wired and toggles
  Visible on both elements every sheet refresh; the exact caption/value text
  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), so content stays unbound rather
  than guessed.
- AP-109 narrowed accordingly (register row amended in the same commit).

Tests: CharacterStatControllerTests (heritage composition + live title
update, name stays plain, level int/"???" with authored — not constant —
color across 3 cases, PK line shows resolved text in authored color across
3 statuses, luminance visibility across 5 level/luminance combinations) and
CharacterSheetProviderTests (PK key-by-status resolution including a
combined-flag case, no-resolver leaves PkStatus null, Level null-vs-present,
title resolution + live refresh on both title events + unsubscribe-on-
dispose, luminance Int64 read-through). Full hermetic solution suite green
under Release (0 failures across all 14 test projects); InstalledDat pins
green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 00:02:11 +02:00
Erik
aa8106d57a docs(CT): CT3 review-closed (fix round 4cc9448b)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 23:30:27 +02:00
Erik
4cc9448b0a fix(CT): CT3 fix round — port Refresh's unconditional selection clear, drop unresolvable-id rows
Opus dual-lens review of CT3 (03e073b7) found 1 BLOCKER + 2 SHOULD-FIX + notes.

BLOCKER: CharacterTitlesController never ported Refresh @0x0049abc0's own
SetSelectedItem(nullptr, 1) (@0x0049ac5a) — retail clears the current
title selection UNCONDITIONALLY on every Refresh() call, regardless of
whether the previously-selected id is still earned. OnTableReplaced
(0x0029) and OnDisplayTitleChanged (the display half of 0x002B) are
retail's two Refresh() call sites, so both now clear _selectedTitleId
before rebuilding/re-highlighting. OnTitleAdded (0x002B's add half) is a
DIFFERENT retail method — RecvNotice_AddCharacterTitle @0x0049a990 splices
one row without ever touching m_pSelectedItem — so it deliberately still
preserves selection. Net effect: after the user sets a display title and
ACE echoes 0x002B, the previously-highlighted row now goes dark and the
Set-as-Display button re-ghosts, matching retail; earning a new title
while a row is selected still leaves that selection alone.

SHOULD-FIX: ported AddTitleToList @0x0049A840's early-outs
(@0x0049a873/@0x0049a914) — an id of 0, or an id CharacterTitleResolver
fails to resolve, now produces NO row at all. The "Unknown" fallback
literal belongs only to the display-title text (Refresh @0x0049abc0's
other half), never a row — this was previously ported backwards.

SHOULD-FIX: rows and the display text now use their UiText's own authored
DefaultColor instead of a hardcoded Vector4.One, and each LinesProvider
now returns a cached UiText.Line[] built once per text change instead of
allocating a fresh array literal every draw call (pattern:
CharacterCreationSkillsPage.cs:829).

Notes (all ruled in): corrected two CharacterStatController 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 given Visible's draw/click cascade,
kept only for the contentPage-not-found fallback); switched the row sort
from List.Sort to a stable OrderBy/ThenBy (ties broken by title id) so
equal-text rows keep retail's insert-after-equals order; wrapped the
title-resolver delegate in RetailUiRuntime.MountCharacter with the same
DatLock the row-template resolver already takes (DatCollection is
documented not thread-safe); set the list box's authored 24px row height
so wheel/line scroll lands row-aligned; kept the bind-time display-text
refresh with a comment explaining why the pre-notice "Unknown" frame is
unreachable in live play (ACE always sends 0x0029 before this panel can
open).

Tests: inverted TableReplaced_SelectedTitleStillEarned_KeepsSelectionHighlighted
into TableReplaced_ClearsSelection_EvenWhenTheSelectedIdIsStillEarned (cites
@0x0049ac5a), added its DisplayTitleChanged twin, and added
TitleAdded_PreservesSelection (the case most at risk from the blocker fix).
Inverted Rows_UnresolvedTitle_ShowsRetailUnknownLiteral into
Rows_UnresolvedTitle_ProducesNoRow (cites @0x0049a873/@0x0049a914) and added
Rows_TitleIdZero_ProducesNoRow for the other early-out. Extended
ClickingSetDisplay_..._AndMutatesNothingLocally to assert the row set and
selection are untouched by the click. Added
Fixture_PageCaptions_ResolveToNonEmptyText, which rebuilds the committed
character_2100002E.json fixture with a stub string resolver to pin this
class's own claim that the two page captions (0x1000052E/0x10000531) carry
a resolvable authored StringInfo.

Verified pre-existing/unrelated: the full hermetic suite run surfaced 2
failures in AcDream.App.Tests (LiveEntityNetworkBranchRoutingTests IL-shape
assertion, GameWindowRenderLeafCompositionTests IL-shape assertion) that
also fail with these five files stashed back to their pre-fix-round state —
confirmed unrelated to this change.

Build green. CharacterTitlesControllerTests: 24/24 (was 21, +3 net after
one invert-and-split and two new facts). Full hermetic solution suite
(Lane!=InstalledDat/PreparedPackage/Live/Manual/Timing/Windows/Linux/
SystemFont, Purpose!=Diagnostic, Status!=KnownFailure): only the two
pre-existing IL-shape failures above; every other project green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 23:28:28 +02:00
Erik
4ea946257d docs(CT): CT4 literals PE-recovered — separators ' ', level '%d' / '???'
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 23:01:06 +02:00
Erik
03e073b748 feat(ui): Campaign CT slice CT3 — Titles page live via standard GUI classes
Titles tab (AP-109's known-inert gap) now switches to a real page and
CharacterTitlesController binds it entirely through UiTemplateListBox/
UiScrollbar/UiButton — zero bespoke widgets, matching every other
social/options row-list page in this codebase.

Retail anchors: gmCharacterTitleUI::PostInit @0x0049A610; AddTitleToList
@0x0049A840 + FindSortedInsertPosition @0x0049A760 (rows sorted by
resolved display text — this port rebuilds the full sorted set on every
change rather than a positional splice, since UiTemplateListBox has no
insert-at-index primitive and no other consumer needs one either);
InfoRegion::SetState(selected?6:1) (row Highlight/DirectState swap, the
same mechanism CT1's SEALED VERDICT confirmed for the stat rows);
UpdateButtons @0x0049A500 CORRECTED direction (Ghosted unless a row is
selected whose id differs from the current display title — no selection
IS the Ghosted case); Refresh @0x0049abc0 (display-title text, including
the hardcoded "Unknown" fallback, refreshed on both TableReplaced and
DisplayTitleChanged per CT2's review anchor 1); Event_SetDisplayCharacterTitle
@0x006a5720 (wire-only TitleSet 0x002C send, no local mutation).

CharacterStatController.Bind now three-way switches Attributes/Skills/
Titles — Titles is a genuinely separate, non-duplicated page container
(CT1 ground truth §3), unlike Attributes/Skills which share one mounted
page and only rebind content.

The two page captions (0x1000052E/0x10000531) are left untouched:
LayoutImporter.BuildText already resolves every element's authored
StringInfo caption at import time, so no controller-side string lookup
was added.

New IGameRuntimeCommands.SetTitle seam on DeferredGameRuntimeStateCommands
(InteractionUiRuntimeSources.cs) mirrors the existing Advance() shape.
CharacterRuntimeBindings gains Titles/TitleResolver/SendSetTitle;
CharacterTitleResolver (CT2) is constructed once at composition time and
its .Resolve method group is passed to the controller as a delegate
(not the concrete DAT-backed type) so the controller stays hermetically
testable without a live IDatReaderWriter.

Tests (tests/AcDream.App.Tests/UI/Layout/CharacterTitlesControllerTests.cs):
binding-seam coverage against the REAL committed character_2100002E.json
fixture (verified this session to already carry the Titles page subtree,
including the ListBox's own authored TemplateList=[(0x2100005E,
0x10000536)] entry — RowTemplateResolver_ReceivesTheFixturesOwnAuthoredTemplateIds
proves the controller reads that authored pair, not a hardcoded one); a
hand-authored ElementInfo standing in only for the row template itself
(a separate LayoutDesc with no committed fixture yet — CT1 was a live-DAT
probe only); sorted-row order, Unknown fallback, row selection/highlight,
the ghost truth table (no selection / selected==display / selected!=
display), click-sends-exactly-one-SetTitle-and-mutates-nothing,
click-while-ghosted-sends-nothing, TableReplaced rebuild (including
selection survival when the id is still earned), TitleAdded single-row
growth, DisplayTitleChanged text+ghost refresh, and Dispose
unsubscription. CharacterStatControllerTests updated for the Titles tab
no longer being ClickThrough, plus a new tab-switch visibility test.

Register: amends AP-109 (docs/architecture/retail-divergence-register.md)
to record the Titles-page half as LIVE; the header identity block and
luminance fields remain open for CT4.

Suites: full solution 15,405 tests / 0 skips (App 6,130) green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 22:59:40 +02:00
Erik
b5d36f5211 docs(CT): CT2 review-closed (fix round 544f8cb2)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 22:28:01 +02:00
Erik
544f8cb2d7 fix(CT): CT2 fix round — dedupe client-side title add, drop retail-inexact send guard
Opus dual-lens review of CT2 (bcfddc97) found 4 SHOULD-FIX + notes; this
applies the campaign lead's rulings.

F1 (the important one): retail's client-side table add is DEDUPED —
gmCharacterTitleUI::RecvNotice_AddCharacterTitle @0x0049a990 walks
mTitleList and returns without effect when the id is already present,
only inserting on a miss. The server-side SendNotice_AddCharacterTitle
broadcast is unconditional, but RuntimeCharacterTitleState.ApplyUpdateTitle
models the CLIENT receive side, so TitleAdded now fires only on a genuine
new membership. Inverted the pin:
ApplyUpdateTitle_AlreadyEarnedId_DoesNotFireTitleAddedOrBumpRevision.

F3: removed the send-side titleId==0 rejection 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 real
protection is the UI ghost-when-current gate (CT3's job), not a
send-side rejection. No register row: this makes acdream MORE
retail-exact.

A2: ResetSession now publishes TableReplaced unconditionally and
DisplayTitleChanged when the display id was non-zero before the clear,
matching the LocalPlayerState.Clear() precedent (publish every category
even when Clear is repeated, so a failed reset can converge on retry).

A3: RuntimeCharacterState.CaptureOwnership reads the new non-allocating
Titles.Count instead of EarnedTitleIds.Count; EarnedTitleIds now carries
an XML warning that every read allocates.

A4/A5: ReplaceTable/ApplyUpdateTitle now mutate under one _gate hold with
change flags computed inside the lock and events raised after release;
every revision bump is gated on an actual state change (a no-op wire
resend produces zero revision edges), matching the change-gated
RuntimeMovementSkillState precedent. TableReplaced itself still fires
unconditionally per retail's own Refresh() dispatch on 0x0029.

A1/A6/A7/A8: CharacterTitleResolverLiveDatTests honors ACDREAM_DAT_DIR
first (CT1 fix-round pattern); documented the EmitResult
primaryObjectId-as-title-id precedent inline; corrected the "third
consumer" comment (CT1 §5 already records gmAttributeUI::PostInit's
icon-DID lookup — CT5 factors the shared GetDIDByEnum helper); added a
titleId -> resolved-string memo to CharacterTitleResolver, the DAT-static
equivalent of retail's lazy-hash cache on the string buffer.

Appended a "CT3 anchors from the CT2 review" list to the plan doc's CT2
ledger entry for CT3 to consume.

Build green. Runtime (102), Core.Net (12), and App (27 + 3 InstalledDat
pins under ACDREAM_RUN_INSTALLED_DAT_TESTS=1) title-scoped tests pass.
Full hermetic solution suite (Lane exclusions per the release gate) is
green: 0 failures across all 15 test projects.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 22:27:07 +02:00
Erik
bcfddc97e7 feat(CT): CT2 — Runtime character-title ownership + wire
Campaign CT slice CT2: the client now learns the character's earned
titles and current display title from the server, owns that state in
Runtime, and can send a display-title change. No UI (CT3/CT4).

Wire (Core.Net):
- GameEvents.ParseCharacterTitleTable (0x0029 CharacterTitle): retail
  CharacterTitleTable::UnPack @0x005c6e90 skips a leading u32 into no
  field — its own Pack @0x005c6e40 always writes the literal 1 there,
  matching ACE's unconditional Writer.Write(1u) — then reads
  displayTitleId, then a count-prefixed PList<uint> of earned ids.
- GameEvents.ParseUpdateTitle (0x002B UpdateTitle): titleId +
  setAsDisplay, per CM_Social::DispatchUI_AddOrSetCharacterTitle
  @0x006a54c0 -> Handle_Social__AddOrSetCharacterTitle @0x00564260,
  which ALWAYS adds (SendNotice_AddCharacterTitle, unconditional) and
  additionally sets display only when setAsDisplay != 0
  (SendNotice_SetDisplayCharacterTitle, gated).
- SocialActions.BuildTitleSet / WorldSession.SendSetTitle: outbound
  TitleSet (0x002C), u32 titleId, matching ACE's GameActionSetTitle.
- GameEventWiring gains onCharacterTitleTable/onUpdateTitle delegate
  holes (Core.Net cannot reference AcDream.Runtime directly).

Runtime:
- New RuntimeCharacterTitleState (RuntimeCharacterState.Titles): earned
  title id set + display title id, TableReplaced/TitleAdded/
  DisplayTitleChanged events matching retail's unconditional-add /
  gated-display-set contract, clears at generation reset.
  RuntimeCharacterOwnershipSnapshot/CaptureOwnership/IsConverged and
  RuntimeCharacterSnapshot extended (trailing optional fields, no
  existing call site broken).
- IRuntimeCharacterCommands.SetTitle: generation-gated, sends
  TitleSet only — NO optimistic local mutation. 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 (the CA-campaign
  lesson: never re-add an optimistic write). Implemented on both hosts
  (DirectGameRuntimeCommandAdapter direct-send;
  CurrentGameRuntimeCommandAdapter via LiveCommandBus /
  LiveSessionCommandRouter's new SetTitleRuntimeCmd).
- LiveSessionEventRouter wires the two inbound events unconditionally
  (RuntimeCharacterState.Titles is a required child, not an optional
  sibling like Fellowship/Allegiance).

App (non-UI plumbing + resolver):
- CharacterTitleResolver (src/AcDream.App/UI/Layout/): ports
  CharacterTitleTable::GetCharacterTitleFromID @0x005c6ed0 — titleId ->
  EnumMapper(0x22000041) canonical key -> compute_str_hash ->
  StringTable(0x2300000E) localized text. Runtime stays id-only; CT3/
  CT4 consume this for display. DIDs hardcoded per the RetailKeyNames
  precedent (CT1 verified them end-to-end).

Register: no new row. Retail's send path is non-optimistic and so is
ours — no deviation to record for this slice.

Tests: wire conformance (byte-exact + truncation) in
CharacterTitleEventsTests.cs + SocialActionsTests.cs; Runtime owner
unit tests in RuntimeCharacterTitleStateTests.cs plus integration in
RuntimeCharacterStateTests.cs; a no-local-mutation command test in
DirectGameRuntimeCommandAdapterTests.cs; an InstalledDat pin
(CharacterTitleResolverLiveDatTests.cs, ids 0/1/2/3/5/13/14, run green
with ACDREAM_RUN_INSTALLED_DAT_TESTS=1). Full solution build green;
hermetic filtered suite green (15,380 passed / 0 failed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 22:03:22 +02:00
Erik
d38f71cb28 docs(CT): CT1 review-closed (fix round e264d839)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 21:58:56 +02:00
Erik
e264d8392f docs(CT): CT1 fix round — sealed RowHighlightSprite verdict, verified resize mechanism, strengthened pins
Applies the Opus dual-lens review corrections to CT1's DAT ground-truth
research (docs/research/2026-08-24-campaign-ct-dat-ground-truth.md):

- Window constraints (BLOCKER): replaced the "likely a hardcoded
  ResizeTo/SetMinSize" guess with the verified mechanism —
  UIElement::ResizeTo clamps only via element attributes 0x3C-0x3F,
  nothing writes them at runtime, and retail resizes the SHARED
  gmPanelUI host (LayoutDesc 0x2100006E, slot 0x1000018E) rather than
  0x2100002E's own content root. Flags the unresolved 300x600-vs-300x362
  size tension for CT3/CT6 and marks the host elements NOT PROBED by
  CT1.
- RowHighlightSprite upgraded from a flagged hedge to a SEALED VERDICT:
  the stat row's selected-state media is 0x06000F93
  (gmAttributeUI::UpdateSelection -> InfoRegion::SetState on template
  0x10000248), not 0x06001397 (which is legitimately the spellbook
  row's separate selected-overlay mechanism). Falsifies the matching
  comment in CharacterStatController.cs and dated-corrects the older
  2026-06-26 doc at the spot that originated the wrong sprite id.
- Replaced the "18px gutter + 7px = 25px" derived story with the bare
  authored rectangles (the numbers don't compose cleanly: 300-281=19,
  and the 282px row overlaps the 281px scrollbar band by 1px) — CT5
  must implement the authored numbers directly, never a derived
  listWidth-18 formula.
- Plan doc: corrected the UpdateButtons ghost rule (no selection ->
  Ghosted, not "ghosts when selected == current") and added the
  AddTitleToList row-write contract for CT3.
- Pins: CharacterPanelLiveDatTests now honors ACDREAM_DAT_DIR first
  (matching InstalledDatFactAttribute and its sibling live-DAT test
  classes), hoists five vacuous bare-foreach assertions to counted
  .ToList() pins, and adds the stat ListBox + scrollbar rect pins that
  CT5/CT6 depend on.
- Doc hygiene: marked several probe-session observations (header
  geometry "identical" claim, 0x06004CC2 characterization, the
  master-map/category-map dump) as unpinned inference vs. committed
  fact, corrected the 0x1000052D "throwaway container" mislabel, and
  stated the header table's parent-relative coordinate frame.
- Recorded the CT5 gold this round found: InfoRegion::InfoRegion's
  icon-DID lookup (a third GetDIDByEnum consumer, category
  0x10000002) and gmSkillUI::RebuildSkillList's section-header order
  confirmation, plus the RowHeight=22-vs-authored-20 divergence for
  attribute rows.

Verified: ACDREAM_RUN_INSTALLED_DAT_TESTS=1 CharacterPanelLiveDatTests
filter 9/9 green; hermetic App suite filter (CI's Lane exclusion list)
6111/6111 green. No production code changed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 21:58:27 +02:00
Erik
d3877f1c0e docs(CT): CT6 research lead — clamp source is the generic resize path; probe panel-host 0x2100006E slots
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 21:33:58 +02:00
Erik
c73e8c0539 docs(CT): CT1 landed — ledger + three binding corrections
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 21:32:19 +02:00
Erik
ca4100e76a docs #CT1: character-panel DAT ground truth + InstalledDat pins
Campaign CT slice CT1 (docs/plans/2026-08-24-character-panel-parity-campaign.md):
establishes the authored ground truth for LayoutDesc 0x2100002E that CT2-CT6
build against, so those slices port against verified DAT facts instead of
guessing. No production code changed.

Header findings: the PK line is authored pure white (the CT4 bug is in
CharacterStatController's runtime color choice, not a DAT gap); the level
color is a pale-gold (1, 0.949, 0.498) WITH an authored outline, diverging
from the current hardcoded Gold constant. The stat ListBox's row-template
list (LayoutDesc 0x21000045) is unreachable via the whole-layout
ImportInfos overload (the #375 same-layout template-list skip filter) --
the targeted ImportInfos(dats, layoutId, elementId) overload is required,
same as UiTemplateListBox's TemplateResolver already uses. The shared
attribute/skill row template (0x10000248) authors a 20x20 icon flush at
X=0 (current code: 16x16 at X=4), fixed 150px/100px name/value columns at
X=25/X=175 (current code: a width-fraction split), and a 7px gap between
the value's right edge and the row's own edge -- the scrollbar-gutter
margin the owner reported missing. The Titles page roster, row template
(LayoutDesc 0x2100005E), and window constraints are also pinned; the
character window's root authors NO min/max size properties at all (unlike
chat's self-contained window layout), and RetailUiRuntime.MountCharacter
never wires DatConstraintSource -- correcting the plan's "already in-tree"
claim for CT6.

Also derives and pins the full CharacterTitleTable::GetCharacterTitleFromID
chain (title id -> EnumMapper(0x22000041) canonical name -> compute_str_hash
-> StringTable(0x2300000E) localized text), resolved via the two-level
DBObj::GetDIDByEnum master-map indirection (0x25000000 -> category map ->
target DID) and verified end to end against ACE's CharacterTitle.WarMage=13
-> "War Mage". This independently cross-validates RetailKeyNames' existing
0x2300000A/0x2300000B/0x23000007 constants, which turn out to be the same
category-4 map's enum 4/5/3 entries.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 21:31:16 +02:00
Erik
45e276d380 docs(CT): name-line contract closed — AllegianceData::GetFullName @0x005B6950 read verbatim
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 21:08:45 +02:00
Erik
872b227209 docs(CT): CT4 header contract read verbatim — UpdateCharacterInfo @0x004F0770 + UpdatePKStatus @0x004F00A0
Name via AllegianceData::GetFullName, heritage line composed with the
display title, level fallback literal, PK line from StringTable
0x23000001 ID_StatManagement_Header_PKStatus_* keys (the chat-label
mechanism).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 21:08:05 +02:00
Erik
1cd4dfc92a docs: Campaign CT plan — character-panel parity (Titles page, header identity, resize/scrollbar, row alignment)
Owner-reported 2026-08-24 batch, recon-verified against gmCharacterTitleUI
@0x0049A610 / gmStatManagementUI::PostInit @0x004EFD90 / ACE's
CharacterTitle-UpdateTitle-TitleSet wire trio. Retires AP-109 when CT3/CT4
land. Fable plans, Sonnet implements, Opus dual-lens reviews; no push
until the owner directs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 21:05:19 +02:00
Erik
aa94ddebe3 fix(ui): talk-button caption centers per the authored label child
Owner screenshot pair (2026-08-24): retail centers 'Chat' in the whole
46x17 face; we drew it at the synthetic 20px left indent. The caption
child (0x10000015) spans the full button with H=Center/V=Center and no
margins (live-DAT probed) — the controller now derives
ButtonTextCentered from the authored HJustify instead of leaving the
indent default.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 20:47:01 +02:00
Erik
67e963524b fix(ui): talk button keeps authored 46x17; white authored captions + font for talk/Send
Owner report (2026-08-24, post gate-pass): the chat channel button was
bigger than retail and both its caption and the Send caption were warm
gold instead of retail's near-white.

Size: retail never resizes the talk button — HandleSelection
@0x004cd540 only swaps the caption string; the authored 46x17 element
stands, and the authored SHORT captions ('Gen', 'Fell', ...) fit it —
that is why retail abbreviates. Our content-widening reflow (grow the
button to its label, shift the input) was a compensation for the
now-retired invented long captions, measured with the wrong font on
top. Deleted; the authored row layout stands.

Color + font: the button caption child (0x10000015) and the Send
button (0x10000019) both author pure white text with their OWN FontDid
0x40000002 (live-DAT probed) — different from the transcript font,
which is the other half of why 'Chat' fits 46px. UiMenu gains a
ButtonDatFont for the caption (popup rows keep the menu font);
the controller reads both elements' authored FontColor/FontDid instead
of the invented (1,.92,.72) constants.

The old widening pin is rewritten to the retail contract; a new
conformance test pins authored width, white captions, and the authored
font DID being requested for both buttons.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 20:43:46 +02:00
Erik
c12a95b6e8 fix(ui): chat window retail parity — focus rails, authored captions, menu flick, drag-stable text
Four of the five owner-reported chat deltas (2026-08-24), each traced to
its retail mechanism:

1. Missing gold separator left of the input: the chat input authors two
   1px Type-3 rail CHILDREN (0x10000017 at X=0, 0x10000018
   right-anchored) whose only media is Normal_focussed (0x06004D67,
   live-DAT probed). UiField consumes its DAT children, so the rails
   were swallowed and never drawn. The factory now folds them into the
   field, which draws both while focused.

2. Button says "General", retail says "Gen": the talk button's short
   caption comes from per-target ID_Chat_ChatTargetMenu* strings
   (HandleSelection @0x004cd540, StringTable 0x23000001 via
   compute_str_hash — recovered from the raw binary after BN elided the
   ids into name-hash globals). Authored set: Chat/Tell/Fell/Pat/Mon/
   Vas/Alg/Gen/Trade/LFG/RP/Soc/Olt. Menu rows + squelch/tell specials
   resolve from the same table (ID_Chat_TellTo*); production resolves
   through DatStringResolver, fallbacks ARE the authored EoR English.
   ChatStringsLiveDatTests pins the whole set against the installed DAT.

3. Channel button stayed green while the popup was open: retail's
   pressed face is the momentary physical press ("flicks"); the OPEN
   state drives only the arrow-cap child's StateDesc swap
   (UIElement_Menu::UpdateState @0x0046cad0 writes attribute 0xe).
   UiMenu now keys the face on the press, not on IsOpen.

4. Window-title/button text "vibrates" while dragging windows:
   DrawStringDatPass snapped glyphs with MathF.Round — banker's
   rounding. A centered label with a constant .5 fraction alternates
   round-up/round-down across successive integers, double-stepping then
   sticking while the background glides. Half-up Floor(v+0.5) snaps
   every tie one way: uniform 1px steps in lock-step with sprites.

The fifth report (input row sticking out on window resize) did not
reproduce: a controller-bound fixture resize at 220/300/600px keeps the
whole input row inside the window (test added) — awaiting the owner's
exact gesture.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 20:27:04 +02:00
Erik
2d6333f84c fix(ui): disabled scrollbars keep retail hover hot-tracking
Owner report + live [ui-hover] probe (2026-08-24): hovering a
content-fits scrollbar did nothing because IsModelDisabled made the
whole bar hit-TRANSPARENT — every hover over it reported
widget=<none>. Retail's arrows and thumb are real child elements whose
Normal_rollover hot-tracking keeps running while the scrollbar is
disabled (UpdateLayout @0x004710d0 only hides the page-click regions,
children 4-7, and — with attribute 0x79 — the whole bar); scrolling
stays inert through geometry, not an input gate: a full-track thumb has
zero travel and the line/page steps clamp against nothing.

OnHitTest and the input path now gate on presentation visibility only.
A visible disabled bar hover-highlights and consumes clicks without
scrolling; a HideWhenDisabled bar stays inert. New root-level hover
tests drive real UiRoot hit-test dispatch (bare widget + the mounted
production character fixture) so this class of "state machine green,
pointer never arrives" bug fails loudly.

User-verified live 2026-08-24 ("bar works now").

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 19:54:28 +02:00
Erik
8fd3d1a9f0 fix(ui): retail scrollbar parity — button seating, full-track thumb, hover/pressed states
All checks were successful
CI / linux-portable (push) Successful in 3m32s
CI / windows-gate (push) Successful in 6m15s
CI / release (push) Successful in 2m16s
Owner report (2026-08-24): our scrollbar arrows pointed the wrong way,
the thumb vanished when there was nothing to scroll, and neither the
thumb nor the arrow buttons reacted to hover/press.

All three are one retail mechanism we had not ported:

1. Seating: UIElement_Scrollbar::UpdateScrollingArea @0x00470AA0 moves
   the INCREMENT designee (attribute 0x77) to the top/left corner and
   the DECREMENT designee (0x78) to the bottom/right, ignoring authored
   positions. The vertical base skin (0x10000455 in layout 0x2100003E)
   authors the DOWN-arrow decrement at Y=0 and the UP-arrow increment
   at Y=32 (live-DAT probed; sprite art visually verified from decoded
   PNGs), so our authored-Y ordering drew both arrows upside down.
   DatWidgetFactory now seats by designation; the hand-wired sites
   (CharacterStatController, ExternalContainerController, the
   Config/Vendor menu chrome) share the new RetailScrollbarChrome
   catalog instead of local constants.

2. Full-track thumb: UpdateLayout @0x004710d0 sizes the thumb from
   proportion attribute 0x88, which DEFAULTS to 1.0 — a content-fits
   bar shows a thumb filling the whole track; disabled only removes
   input and the page regions. Our draw skipped the thumb entirely on
   !HasOverflow.

3. States: every arrow button and thumb slice authors Normal (red gem /
   dark navy), Normal_rollover (amber gem / bright blue) and
   Normal_pressed (gold highlight / dark) media. The widget now tracks
   thumb hover and selects rollover media on hover and pressed media
   while dragging; the factory extracts the thumb-state media for both
   the 3-slice and single-sprite thumb shapes.

ScrollbarSkinLiveDatTests pins the designations and state media against
the installed DAT so a revision or importer regression fails loudly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 19:20:43 +02:00
Erik
35abbe1d0d fix #430: tooltip wrap and draw honor the text child's authored margins
All checks were successful
CI / linux-portable (push) Successful in 3m29s
CI / windows-gate (push) Successful in 5m44s
CI / release (push) Successful in 2m19s
Third owner-screenshot round: acdream fit one more word per line than
retail and drew glyphs flush against the popup's right border. Retail's
InqSizewMargins @0x00469660 wraps the glyph list at
(bound - m_margL - m_margR) and adds the margins back into the measured
width; the popup skins' shared text child 0x10000396 authors margins
L=2/R=2 (U=2/D=2 on three of the four skins — live-DAT probed). The
presenter now subtracts the horizontal margins from both wrap passes,
re-adds them into the measured width used for root sizing, and counts
the vertical margins in the measured/re-wrapped heights; the widget's
own draw already insets by all four margins (UiText ContentOffsetX +
the top/bottom inset), so the right-side spacing returns for free.

TooltipSkinLiveDatTests pins the authored margins per skin alongside
the P0x3D=256 wrap bound; a new presenter test proves margins shrink
the wrap bound and survive onto the widget.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 18:44:28 +02:00
Erik
d087b50aa3 fix(ui): tooltip wrap bound comes from the popup text child's authored P0x3D, and tooltip text left-aligns
All checks were successful
CI / linux-portable (push) Successful in 3m29s
CI / windows-gate (push) Successful in 6m14s
CI / release (push) Successful in 2m10s
Owner screenshots vs retail at the CA5 re-check caught both:

Wrap width: the popup skins' shared TEXT CHILD (0x10000396) authors
P0x3D=256 on all four skins — live-DAT probed, now pinned by an
installed-DAT test. Retail's InqSizewMargins UITS_MAX_WIDTH reads the
text element's 0x3D BEFORE the display-width fallback, so retail wraps
tooltip text at 256px; our measure pass used the display width because
TS-85's 'zero elements author P0x3D' sweep had only covered hover
TARGETS, never the popup skins. ApplyTooltipText now measures and
re-wraps at the text child's authored bound, falling back to the display
width only when none is authored.

Alignment: tooltip text rendered centered where retail hugs the left
edge. The skin authors no justification; retail's unauthored default is
Left, our importer's ElementInfo default is Center — the same
wrong-default class as #410's VJustify finding, now recorded there as the
horizontal sibling. Point-fixed in the presenter exactly as the chat
transcript already does; the client-wide default flip stays #410's scope.

The two-pass sizing test now models the real skin (max width on the text
child) and asserts left alignment. Full hermetic suite 15,332 passed / 0
failed; the new live-DAT pin passes against the installed DATs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 18:29:03 +02:00
Erik
1e8596a440 fix(ui): retail tooltip rendering — formula-first compose and the two-pass sizing every tooltip was missing
All checks were successful
CI / linux-portable (push) Successful in 3m39s
CI / windows-gate (push) Successful in 6m41s
CI / release (push) Successful in 2m10s
Two corrections from the owner's retail-render oracle at the CA5 re-check,
both against readings the TS-85 register row had recorded as settled:

Compose (skill tooltips): retail is formula + newline + description —
GetTooltip @0x004f1fe0's operator+ has the InqSkillFormula output as the
LEFT operand; the old '"\n" + formula, no separator' reading had the
operand order backwards and produced a leading blank line with the formula
and description glued on one line. A formula-less skill (Salvaging) shows
the bare description, matching the failed-InqSkillFormula branch.

Sizing (ALL tooltips, per the owner's direction): retail sizes a tooltip
in TWO passes (StartTooltip @0x0045DE90) — measure-wrap at the max width,
resize the root through the authored ResizeTo clamps, then
RecalculateGlyphList RE-WRAPS the text at its final clamped width and a
second resize grows the root's HEIGHT for the extra lines. The branch the
register called 'a structural no-op' IS that second pass; without it a
description longer than the clamped popup stayed one clipped line, where
retail shows three. ApplyTooltipText now ports the full chain, so every
tooltip surface (items, options rows, character panel, world hover, map)
wraps and grows exactly as retail.

Pinned by BuildTooltip_FormulaFirstThenNewlineThenDescription,
BuildTooltip_FormulaLessSkillShowsBareDescription, and
LongTooltip_RewrapsAtTheClampedPopupWidth_AndGrowsHeightForTheExtraLines.
TS-85 carries both dated corrections. Owner visual re-check owed: skill
tooltip shows formula on line one, description below, long descriptions
wrapping to three-plus lines inside the parchment. Full hermetic suite
15,332 passed / 0 failed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 18:09:54 +02:00
Erik
51a7c99b94 docs: CA5 ledger — first drive partial results and the fix round
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 16:52:17 +02:00
Erik
bce17b3cfb fix #430 #440: character panel — tooltips can mount, and rows refresh on the authoritative record
Some checks failed
CI / linux-portable (push) Failing after 3m25s
CI / windows-gate (push) Successful in 6m12s
CI / release (push) Has been skipped
Two defects the owner found at the CA5 drive, one shared theme: the data
was right and the presentation seam was dead.

#430 (tooltips): the TS-85 Batch-B port set runtime TooltipText on the
runtime-built attribute/vital/skill rows but never gave them a popup
locator, and RetailTooltipPresenter.OnTooltipShow refuses any widget with
AuthoredTooltipRootElementId == 0 — the tooltip could never mount, on any
row, ever. (The register's 'live-verified on the Character tab' was the
OPTIONS panel's Character tab — authored elements with authored locators;
a different surface.) Rows now carry the shared popup skin
0x10000395/0x21000041 — live-DAT probed as the ONLY locator pair the
character layout references, and the same inference UiItemSlot already
ships for runtime-built widgets. TS-85's row carries the dated correction.

#440 (train row stuck): training a skill debited credits on screen but
left the row in the untrained section until the NEXT click — because the
sheet-changed subscription only refreshed the captured sheet, and row
STRUCTURE rebuilt exclusively in click handlers (the raise 'completed'
callback runs after SEND, before the server answers; the owner's second
click was simply the first rebuild after the record landed, and ACE's
rejection of that second train — 'Failed to train', no credit change —
matches the owner's report exactly). The same gap kept CA4's
awaiting-ghost from visually releasing. CharacterStatController.Bind now
returns the data-changed refresh and MountCharacter invokes it on every
authoritative sheet change, mirroring retail's quality-change broadcast
(InfoRegion::OnQualityChanged @ 0x004F0EB0).

Pinned by DataChangedRefresh_MovesATrainedSkillToItsSection_WithoutAClick
and Rows_CarryTheSharedTooltipPopupLocatorAndDescriptionText. Owner
visual re-check owed next session (hover-dwell a row; train a skill and
watch it move immediately). Full hermetic suite 15,329 passed / 0 failed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 16:51:58 +02:00
Erik
bd943849b1 docs: Campaign CA CA5 connected gate script — awaiting the owner drive
The user-driven verification matrix for the whole advancement chain on a
scratch character: live run-speed change under a Quickness raise, the
Endurance single-record stamina fan-out, the deliberate raise-10 failure
probe that resolves the narrowed AP-73 ghost question, skill raise/train,
gem-driven specialize/lower with the confirmation dialog, and a
regression sweep. CA1-CA4 are committed and pushed; this script is the
campaign's remaining gate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 13:57:51 +02:00
Erik
08b77e20a9 feat(ui) Campaign CA CA4 #431: server-authoritative raises — the optimistic layer is deleted
Some checks failed
CI / linux-portable (push) Successful in 3m30s
CI / windows-gate (push) Failing after 6m32s
CI / release (push) Has been skipped
Retail sends a raise and WAITS: one request in flight, the raise
controls ghost, and displayed state changes only when the authoritative
quality-change record lands (gmStatManagementUI @ 0x004F03F0 family,
pinned in docs/research/2026-07-10-retail-panel-behavior-pseudocode.md
§5, whose own conclusion names ApplyLocalRaise as the thing to remove).
The optimistic layer predates the inbound parsers — it existed so the
panel showed anything at all — and with CA2 delivering server truth it
became strictly harmful: against ACE, a wrong TrainSkill cost fails
SILENTLY, so the optimistic promote-and-debit could show a trained
skill the server refused with nothing to ever correct it.

Deleted: CharacterSheetProvider.ApplyLocalRaise + both spend helpers,
and LocalPlayerState's six optimistic mutators (ApplyAttributeRaise,
ApplyVitalRaise, ApplySkillRaise, ApplySkillTraining, DebitIntProperty,
DebitInt64Property) with their tests. Added: the one-in-flight latch in
HandleRaiseRequest, CharacterSheet.AwaitingRaise ghosting all raise
controls, and gate release on every authoritative quality signal
(attribute/character/player-property events unconditionally; vital
events only release-and-refresh while a raise is in flight, so regen
ticks stay out of the sheet-rebuild path). Panel unmount resets the
gate — retail's awaiting flag lives on the panel instance.

AP-73 NARROWS rather than retires: retail's release on a rejection that
produces NO quality change is statically unverifiable, and ACE sends
chat-only (Raise*) or nothing (RaiseSkill/TrainSkill) on failure; until
the CA5 live check, a silently-rejected request leaves the controls
ghosted until panel reopen — recorded with its observable symptom.

Also verified for CA4: the train button sends the DAT-exact TrainedCost
(ACE's silent exact-match rule), and there is correctly NO panel
specialize send — retail/ACE specialize only via the SkillAlterationDevice
item-use + confirmation round-trip, whose client seams
(SendConfirmationResponse 0x0275, the 0x028B WeenieErrorWithString chat
routing) already exist. Provider tests now pin the retail contract:
send-without-mutation, one-in-flight, release-on-record, release-on-
unmount, and the regen-tick rebuild guard. Full hermetic suite 15,327
passed / 0 failed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 13:56:31 +02:00
Erik
5781895977 feat(runtime) Campaign CA CA3 #431: live derived-stat recompute — a raise is visible without a relog
Some checks are pending
CI / release (push) Blocked by required conditions
CI / windows-gate (push) Has started running
CI / linux-portable (push) Successful in 3m28s
The recompute half of #431, on the CA1 verdict that retail computes
derived values LIVE at inquiry (Set* writes raw; InqSkillBaseLevel
0x00592140 -> SkillFormula::Calculate 0x00591960 re-derive per call;
InqRunRate 0x00592800 runs every motion tick; UI notifications carry no
value and widgets re-pull):

- LocalPlayerState gains the SkillTable formula resolver — the same
  delegate shape (and App-side implementation, RetailSkillFormula over
  the loaded SkillTable) the PlayerDescription path already uses. An
  attribute write re-derives every skill snapshot's cached formula
  contribution; recomputing at the only write that changes the inputs
  yields values identical to retail's compute-on-read at every read. A
  freshly TRAINED skill unseen at login derives its contribution live
  instead of defaulting to zero forever.
- The router pushes movement-skill totals down the SAME seam
  PlayerDescription uses (UpdateMovementSkillBase -> vitae/enchantment
  recompute -> OnSkillsUpdated -> the App stats applier) after an
  attribute update, and after a skill update for Run (24) / Jump (22)
  only. This is what turns a Quickness raise into visible run speed
  mid-session; the server's own movement-packet echo
  (HandleRunRateUpdate -> ApplyServerRunRate) remains the correcting
  authority.
- Vitals maxima needed no new plumbing: GetMaxApprox reads attribute
  currents live and the vitals window binds getter lambdas re-read per
  frame, so CA2's attribute fan-out completes that path. The character
  panel already subscribes to AttributeChanged/CharacterChanged.

Tests: router behavior test drives the real WorldSession events through
the real router and asserts the full chain (state write, live 160/2=80
re-derivation, movement push totals, and that a non-movement skill does
NOT push); the subscription-count contract now includes the two new
events; Core tests cover the fresh-train resolver derivation. Full
hermetic suite 15,335 passed / 0 failed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 13:46:00 +02:00
Erik
65430d4c7c feat(net) Campaign CA CA2 #431: parse the inbound attribute/skill update family
Some checks failed
CI / linux-portable (push) Successful in 3m32s
CI / windows-gate (push) Successful in 6m22s
CI / release (push) Has been cancelled
The server's authoritative answers to a raise were dropped on the floor:
only the vitals pair (0x02E7/0x02E9) had parsers, so after any
RaiseAttribute/RaiseSkill/TrainSkill the client's stat model stayed
frozen at login's PlayerDescription — the root cause of #431's stale
derived skills and run speed. The GUI looked alive only because the
panel applies optimistic local raises.

New parsers with three-source-verified layouts (CA1 research doc §2.5/
§2.8): PrivateUpdateAttribute (0x02E3) and PrivateUpdateSkill (0x02DD —
the wire's ushort ranks + hardcoded adjustPP=1 pair and f64
lastUsedTime preserved exactly). WorldSession dispatches both as typed
events; LiveSessionEventRouter routes them into the J4 character owner's
LocalPlayerState like every other private update. The vestigial
PrivateUpdateSkillLevel (0x02DF) is deliberately unparsed — ACE has no
producer (verified).

OnAttributeUpdate now fans out to the derived-value observers, mirroring
retail's live-at-inquiry model (CACQualities::InqSkill 0x00592660 —
Set* writes raw, Inq* recomputes, notification carries no value): an
Endurance write notifies the Health AND Stamina vital observers (ACE
pushes only a Health record and its own comment says the client must
refresh both), Self notifies Mana, and every attribute write notifies
character-sheet consumers whose formula contributions just changed.
OnSkillWireUpdate preserves the login FormulaBonus — the wire record
carries no attribute contribution; CA3 replaces the cached field with
the live computation.

Also corrected while in the neighborhood: PropertyString.cs's comment
claimed opcode 0x02DD for PrivateUpdatePropertyString; ACE's enum says
0x02D5/0x02D6 (doc-only — nothing dispatched on either).

Conformance tests cover both layouts (including holtburger's golden
skill fixture with adjustPP=1), truncation/wrong-opcode rejection, the
Endurance/Self/Quickness fan-out contract, and FormulaBonus
preservation. Full hermetic suite 15,333 passed / 0 failed (one
load-sensitive transport flake observed on the first run, passed alone
and on the clean re-run — filed as #439 rather than chased).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 13:38:50 +02:00
Erik
1fc64984c9 research: Campaign CA slice CA1 — advancement wire + retail recompute oracle
Two parallel research passes assembled with a hand-verification ledger on
every load-bearing claim.

Wire (ACE + Chorizite + holtburger, field-for-field agreement on all six
inbound layouts): PrivateUpdateAttribute 0x02E3, PrivateUpdateVital 0x02E7
(always Max-family vital ids 1/3/5), PrivateUpdateAttribute2ndLevel 0x02E9
(always current-family ids 2/4/6 — a parser must NOT treat the two as one
id space), PrivateUpdateSkill 0x02DD (ushort ranks + the hardcoded
adjustPP=1 pair, f64 lastUsedTime), PrivateUpdatePropertyInt 0x02CD
(AvailableSkillCredits=24) and Int64 0x02CF (AvailableExperience=2).
Ordered action->response chains for all four raise/train actions,
including the retail quirk that an Endurance raise pushes only a HEALTH
full-vital record and the client is expected to refresh stamina from it
too. Specialize/untrain/reset have NO dedicated opcode — item-Use plus a
confirmation round-trip reusing the same update messages. 0x02DF has no
ACE producer (verified); CA2 skips it.

Recompute (named-retail + live Ghidra): retail computes skills, vitals
maxima and run rate LIVE at inquiry time — Set* are raw-storage writes,
InqSkill re-derives from the attribute formula every call (verified in
the decompile, including the z==0 early-out that IS the attribute-less
Salvaging handling and the +10 augmentation adds), InqRunRate runs every
motion tick, and UI refresh is a value-less observer notification.

Two corrections to our own tree surfaced: PropertyString.cs's comment
claims 0x02DD (it is 0x02D5 — doc-only, nothing dispatches on it), and
SkillSnapshot.FormulaBonus is frozen at PlayerDescription parse — the
stale-cache half of #431 that CA3 replaces with the live computation.
RetailSkillFormula.TryCalculate already ports 0x00591960 exactly, so CA3
reuses it rather than porting anew.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 13:30:33 +02:00
Erik
20721ddada docs: open Campaign CA — character advancement retail parity (promotes #431)
The owner widened #431 into the full advancement family: real-time
refresh of vitals maxima (attribute AND direct vital raises), derived
skills, run speed under Quickness, attribute-less skills like Salvaging,
and the untested train/specialize/respec flows. The promotion survey
pinned the root cause: every outbound raise action (0x0044-0x0047) is
wired — which is why the GUI 'works' — while the inbound private
attribute/skill update family is parsed nowhere (only the vitals pair
0x02E7/0x02E9 is), so the server's post-raise truth never reaches
LocalPlayerState and no recompute ever triggers. Plan doc carries the
oracle targets (message family from ACE/Chorizite/holtburger, retail's
recompute chain in named-retail, specialization/respec semantics) and
five slices ending in a user-driven connected gate. #430 tooltips are
explicitly sequenced after, on the #409 tooltip system.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 13:13:20 +02:00
Erik
373d003f1b docs: file #438 — launcher crash-report bundles (upcoming work); flip #435's stale PARTLY CLOSED header
#438 records the design agreed with the owner: launcher-owned opt-in WER
LocalDumps key (HKCU, minidump, capped count), crash bundle assembled on
the next launch from the dump + log tail + version + capability report,
and an explicit NO-auto-upload line — dumps can hold the plaintext
session password, so sharing stays a user action until there is real
infrastructure and a consent flow. The owner's own machine is already
armed manually for the #422 hunt; this productizes it for alpha users.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 13:06:17 +02:00
Erik
1360b71684 test: tie the soak's move-truth grep to a live emitter — both ends now break together
Some checks failed
CI / linux-portable (push) Successful in 3m35s
CI / windows-gate (push) Failing after 6m22s
CI / release (push) Has been skipped
The r6 soak hard-fails without 'move-truth OUT' lines, yet the only
automated guard was a text assertion that the SCRIPT sets the env var —
it stayed green while #435 part 2 deleted the emitter, and the breakage
would have surfaced as a misleading connected-gate failure. The new
contract test asserts all four links of the chain in one place: the
script greps the pattern, MovementTruthDiagnosticController still emits
it, RuntimeOptions still parses the flag, and GameWindow still wires it
through. Deleting any link fails here, at build time, with the reason.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 13:01:33 +02:00
Erik
35454a9f58 fix #436: combat no-target refusal reaches the SpewBox with retail's exact text
Attacking with no valid target has told the player nothing since Campaign V
slice V11 orphaned the DebugVM toast the message was wired to (#434 found
the drop; this closes it retail-faithfully).

Ground truth from the Ghidra decompile of
ClientCombatSystem::ExecuteAttack (0x0056bb70): retail writes
"You must select a valid combat target before attacking" via
ClientSystem::AddTextToScroll(..., 0x1A, true, 0) — the ClientLocal
SpewBox channel this codebase already routes every other client-local
refusal through. And retail has ONE message, not the two we carried:
attacking outside melee/missile modes is silent (ExecuteAttack is
unreachable there), so the invented "Enter melee or missile combat first"
text is deleted rather than rerouted, and the invented "No monster
target" is replaced by the retail string, which joins ClientTextRefusals
with its decomp citation.

Wiring: CombatFeedbackSlot gains the sibling BindOwned session-lifetime
shape, and SessionPlayerComposition.CompleteSessionPlayer binds it to
RuntimeCommunicationState.AddText(ClientLocal) with session-owned
teardown — a torn-down session's slot returns to its silent unbound
state. A binding-seam test
(CompleteSessionPlayerBindsCombatFeedbackToTheClientLocalSpewBoxRoute)
inspects the compiled composition for the BindOwned call and its
AddText-routing lambda, so the slot can never again pass its unit tests
while production leaves it unbound — the exact failure mode that hid
this defect. The two tests that pinned the invented strings now pin the
retail contract (exact string; silence for the unsupported-mode case).

Full hermetic suite 15,325 passed / 0 failed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 12:53:11 +02:00
Erik
7969a20c8c docs: correct the probe-debt callout — 31 temporary probes remain, all attributed
The part-2 commit message and the callout said 40; the 8 reclassified
rows had left the Temporary table, so the true count is 31 (and the
previous commit's '40 temporary probes remain' line is corrected by this
note).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 12:33:46 +02:00
Erik
c1e6e3da44 fix #435 (part 2, closes it): attribute the unowned probes — delete 7, reclassify 8, restore 1
Part 1 deleted probes whose owning issues were closed. These 14 named no
issue at all, so each was traced to its introducing commit
(git log -S) instead of guessed at. Attribution split them three ways:

DELETED (7, investigations closed): ACDREAM_A8_DUMP_PV and
ACDREAM_DUMP_LIVE_SPAWNS (Phase A8), ACDREAM_DUMP_CLOTHING (#37),
ACDREAM_DUMP_EDGE_SLIDE (#32), ACDREAM_DUMP_STEPUP (L.2.3d-f),
ACDREAM_DUMP_VENDOR (the vendor campaign, 25 call sites across 8 files),
ACDREAM_DUMP_VITALS (#5, four independent read sites). VendorDiagnostics.cs
went entirely.

RECLASSIFIED (8, tools misfiled as probes): the DUMP_CELLS/DUMP_GFXOBJS
fixture-extraction family (replay-harness tooling with a roundtrip test),
PROBE_CELL (standing cell-transit tracer, pair of the permanent
PROBE_RESOLVE), DUMP_SKY and HIDE_PART (generic isolation tools), and
DUMP_STEEP_ROOF — which looked like an L.4 relic but observes LIVE
divergence-register row AD-56; deleting it would have removed the only
runtime lens on an active divergence. All moved to Permanent diagnostics
with their attribution recorded.

RESTORED (1): ACDREAM_DUMP_MOVE_TRUTH was deleted and un-deleted the same
day. It is not a probe — the canonical nine-stop soak
(run-connected-r6-soak.ps1) hard-fails every destination without its
'move-truth OUT' records, with a message that would misdirect the next
operator. Under the no-workarounds rule the gate's mechanism is restored,
not left broken with an IOU (#437, closed). Process lesson recorded on
both issues: a closed owning issue is NOT sufficient to delete a probe —
grep tools/ and the contract tests for consumers first.

Also lands the owner-requested default-off invariant: every diagnostic in
the codebase is inert until its env var is explicitly set. Exactly four
flags default ON and none is a diagnostic — RETAIL_CHASE, CAMERA_COLLIDE,
CAMERA_ALIGN_SLOPE, RETAIL_CLOSE_DEGRADES are retail behaviors wearing an
A/B off-switch. That set is now FROZEN by
LaunchOptionsDocumentationTests.OnlyTheFourRetailBehaviorFlagsDefaultOn;
docs/launch-options.md's Conventions and CLAUDE.md state the rule, and
CLAUDE.md now binds future probes to a documented row in the same commit.

The client reads 137 environment variables (161 at audit start); 40
temporary probes remain, every one attributed. Full hermetic suite 15,322
passed / 0 failed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 12:32:42 +02:00
Erik
0c5057c9ff fix #435 (part 1): delete 17 probes that outlived their closed investigations
Each of these was temporary apparatus added to chase one bug, and each was
supposed to be deleted in the commit that fixed it. Fourteen closed issues
later they were still here: #337's support/wire-mesh trio, #171's sticky
timeline, #119's viewer and entity dumps, #113's phantom probe, and a dozen
more. 3,493 lines removed; the client now reads 144 environment variables
instead of 161, and 47 temporary probes remain instead of 64.

This is not only tidying. Every probe leaves a branch on its hot path when
unset, several re-read the environment per call rather than caching, and
the volume buries the diagnostics that are actually load-bearing. It is
also a headless correctness matter: HeadlessStaticStateAudit reflects over
PhysicsDiagnostics' flags to refuse a multi-session host when any is set,
and cannot see probes that live outside that owner.

Four files went entirely — WalkMissDiagnostic.cs, CollisionMeshWireframe.cs
and two test files whose only subject was a deleted probe.
TransitionTypes.SetContactPlane also sheds its CallerMemberName /
CallerLineNumber parameters, which existed solely for #337's cpSrc=
attribution and carried the instruction to strip them with the probe
family; no call site passed them, so no behavior changes. F2's collision
overlay survives and reverts to its proxy-cylinder form, which is what
removing the ACDREAM_WIRE_MESH upgrade means.

LaunchOptionsDocumentationTests earned its keep here: it refused the
deletion until docs/launch-options.md moved the 17 rows into Retired and
the frozen direct-read counts came down (PhysicsEngine.cs to zero,
TransitionTypes.cs 3 to 2). The documentation could not drift during a
cleanup this wide.

The 14 probes that name no owning issue are deliberately NOT deleted.
Nothing records when they became safe to remove, and guessing is how a
future investigation loses apparatus it needed; #435 stays open for their
attribution.

Full hermetic suite 15,321 passed / 0 failed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 11:41:20 +02:00
Erik
05bfe8d162 fix #434: delete the unreachable DebugPanel/DebugVM surface and the comments that advertised it
DebugPanel and DebugVM have not been constructible since Campaign V slice
V11 removed the ImGui frontend that hosted them: nothing in src/ ever
called their constructors, only a test did. Two consequences, both fixed
here — 35 environment reads inside them were unreachable, and roughly forty
XML doc comments across the diagnostics owners promised a runtime checkbox
that no longer exists. A flag documented as runtime-toggleable when it is
startup-only sends the next investigation down a path that cannot work.

Deleted DebugPanel.cs (340 lines), DebugVM.cs (548) and DebugVMTests.cs
(327). Corrected the surviving claims in PhysicsDiagnostics,
RenderingDiagnostics, CameraDiagnostics, PhysicsEngine and GameWindow to say
what is actually true: these flags are set from the environment at startup
or by direct assignment.

The one real dependant was CombatFeedbackSlot, whose binding target was
DebugVM. It now takes a plain Action<string>, which removes the dependency
without changing behavior — and makes visible that there is no behavior:
nothing binds the slot, so the combat refusals it carries ("No monster
target", "Enter melee or missile combat first") have been discarded all
along. Filed as #436 and pinned by a test, rather than papered over with an
invented chat message; the retail text and channel need the oracle first.

Deliberately untouched: F1's AcdreamToggleDebugPanel binding, which
GameplayInputCommandController consumes as a documented no-op so the key
does not fall through to a lower input scope; and the
DebugVmRenderFactsPublisher / DevToolsRuntimeSources chain, which is still
wired into production composition and deserves its own dead-code pass
instead of being pulled into this one.

Full hermetic suite 15,333 passed / 0 failed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 10:58:59 +02:00
Erik
e77dd7c413 docs: launch-options reference + the test that keeps it honest
The client reads 161 ACDREAM_* environment variables across 79 files. Only
about 25 were written down, and the audit found the documentation drifting
in both directions: CLAUDE.md still advertised ACDREAM_RUN_SKILL /
ACDREAM_JUMP_SKILL (deleted; skills are server-authoritative now, and the
jump fallback is 300, not the documented 200), while flags with real
side effects had no description at all.

docs/launch-options.md documents every one by lifecycle — production,
command line, measurement, automation, permanent diagnostics, temporary
probes, deprecated, retired — with a mandatory side-effects column. That
column is the point: #432 cost three days of taxed measurements because
ACDREAM_AUTOMATION_ARTIFACT_DIR reads like an output path and also builds
a per-frame diagnostics referee, and ACDREAM_STREAM_RADIUS silently
measures a streaming window production never uses. Rows now say so. Other
surprises the audit surfaced and recorded: ACDREAM_DUMP_SCENERY_Z swaps in
a duplicate scenery-placement path rather than only logging,
ACDREAM_PROBE_VIS silently also enables ACDREAM_PROBE_ENVCELL, and
ACDREAM_DUMP_ENTITY's id list doubles as an unrelated probe's watchlist.

LaunchOptionsDocumentationTests enforces it, because a hand-maintained list
of 161 flags is stale within a week: an undocumented flag fails, and so
does a documented row whose read site was deleted. It scans string literals
rather than GetEnvironmentVariable call shapes — the startup path reads
through an injected delegate, so a call-shaped pattern silently missed
ACDREAM_LIVE, ACDREAM_PAK_PATH and every other production flag. A third
test freezes per-file direct-read debt by exact count (20 files outside the
owner classes), so structure rules 4 and 5 can be paid down but not
regressed.

CLAUDE.md's 94-line env-var section becomes a 16-line pointer, and its
stale test-character paragraph is corrected.

Also fixed, all doc-vs-code mismatches the audit proved:
- RenderingDiagnostics.FrameProfEnabled described a GPU-query self-disable
  that Campaign V slice V11 deleted.
- Two comments named ACDREAM_RENDER_BACKEND as a live co-requisite; it died
  with the OpenGL backend.
- EnvCellRenderer.CollectCellAuditLines and its ACDREAM_A8_AUDIT doc: the
  method had no caller anywhere and its documented caller never existed.

Filed rather than fixed, to keep this a documentation change: #434 (the
DebugPanel/DebugVM surface is never constructed, so ~40 "runtime-toggleable"
comments are false and 35 env reads are unreachable) and #435 (17 temporary
probes outlived their closed investigations; 14 more name no owner).

Full hermetic suite 12,202 passed / 0 failed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 10:28:59 +02:00
Erik
92999b0101 fix #432: allocation-free oracle fingerprint sort — the ~6 MB/frame diagnostics tax
Second instance of the #429 (ad695589) boxing-comparer defect class, this
time in the measurement harness rather than production:

ACDREAM_AUTOMATION_ARTIFACT_DIR (with retained-UI screenshots) constructs
CurrentRenderSceneOracle, whose presence as partition observer re-enables
the G5-retired legacy InteriorEntityPartition every frame with per-entity
fingerprinting. Complete() then sorts one fingerprint per RESIDENT entity
(~60k across the streaming window), and the comparer's first key bound
x.ProjectionClass.CompareTo(y.ProjectionClass) to Enum.CompareTo(object),
boxing both operands. The 3-value enum almost always ties, so the boxing
ran on essentially every comparison: a measured ~6.2 MB and ~14 ms per
frame, everywhere — not town-specific and not view-triggered, which is
also why it appeared to "latch" (the resident set drives it, not the view).

Comparing the underlying integral value keeps the identical order. Hermetic
gate: one warmed observed partition of 20,000 entities allocated 15,876,088
bytes before, and passes a <64 KiB bound after
(OracleObservedPartitionAllocationTests).

Ordinary play never constructs the oracle, so no player-visible behavior
changes; what changes is that captures taken with the automation artifact
directory set are no longer taxed. The #429 acceptance data is unaffected
(owner drives and the deciding A/B arms ran with the artifact dir null).

The temporary [pview-alloc] attribution probe that localized this is
retired in the same commit; the gate test now guards the defect.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 10:10:46 +02:00
Erik
b217a292bb docs: close #429 in the issue ledger — owner-accepted both modes, fix SHAs recorded
All checks were successful
CI / linux-portable (push) Successful in 3m29s
CI / windows-gate (push) Successful in 6m56s
CI / release (push) Successful in 3m7s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 09:17:32 +02:00
Erik
ad69558908 fix #429: allocation-free shadow topology rebuild + churn-frame pipelining
The directional-shadow topology rebuilt on every streaming-churn frame
and was the measured body of the run-hitch stalls (701 of 708 baseline
stalls alloc-correlated):

- The draw sort comparer's enum-vs-enum CompareTo bound to
  Enum.CompareTo(object) and boxed BOTH operands on every comparison —
  a constant ~38.9 MB of garbage per topology rebuild (~4M boxes),
  handing the GC a forced gen0 collection mid-frame. The full ~100k-draw
  sort is replaced outright: draws hash-group by exact DrawKey in one
  O(n) pass over retained chained-index arrays, and only the
  few-thousand DISTINCT group keys sort (order-preserving packed
  material|cull|firstIndex|baseVertex + count|slot|layer|foliage keys,
  first-appearance tie-break) — bit-identical emission order to the old
  stable sort, near-zero allocation, and no per-draw comparisons at all.
- The caster frame sorts 4-byte indices keyed on SortKey.Value instead
  of shuffling multi-hundred-byte records through a boxing comparer.
- Owner-approved pipelining: on a frame whose shadow inputs just changed
  (the same frame already paying frame-view/landscape rebuilds), the
  caster-frame and prepared-draws topology rebuilds defer to the next
  quieter frame, capped at two consecutive deferrals — inside the GPU
  fence depth, so retained draws never reference a released arena range.
  First build, generation change, caster BuildSequence change, and
  journal overflow force the immediate path; deferred refreshes skip
  identity-mismatched journal rows.

Owner-accepted in both presentation modes: stall frames 5.8/s -> ~0.45/s
uncapped (0.49/s capped), median stall 20.3 -> 13.7 ms, >25 ms frames
near zero, 275 fps uncapped baseline restored. Allocation gate: a warmed
topology rebuild must allocate <2 KiB (DirectionalShadowPreparedDrawTests).
docs/ISSUES.md carries the full evidence trail; the residual
content-proportional rebuild milliseconds are filed as the
incremental-topology successor, and the pre-existing town-view scaling
latch is filed as #432.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 09:16:58 +02:00
Erik
4873c10673 fix(runtime/camera) #429: presented player and chase camera share the object clock
Two halves of the felt run-hitch (the visible one-frame player lurch):

- The presentation lerp normalized the pending object-clock time by the
  fixed 30 Hz MinQuantum, but retail's object clock simulates
  VARIABLE-length quanta (CPhysicsObj::update_object 0x00515D10: capped
  at MaxQuantum, everything above MinQuantum runs as ONE step). After a
  long frame the view froze for the quantum and then fast-replayed it.
  ComputeRenderPosition now spans the ACTUAL last quantum
  (_lastQuantumSeconds), and PresentedDeltaSeconds accounts continuous
  presented time across quantum boundaries.

- The chase camera damped toward the presented player using wall dt
  while the player presents on the object clock, so a long frame
  stepped the camera far past the under-advanced player — measured up
  to ~1 m of camera/player decoherence in a single frame. Retail ties
  camera update to the physics-update callback
  (SmartBox::PlayerPhysicsUpdatedCallback 0x00452d60), i.e. the same
  clock as the body; both chase cameras now integrate
  PresentedDeltaSeconds. Manual zoom/pitch adjustment stays on wall dt
  (a user-input rate, not target chasing).

Owner gate: camera-vs-player boom-length change fell from ~1 m spikes
to 0.2-1.2 cm median on long frames; teleports settle clean. Two
Runtime tests updated to pin the continuous-rate contract. The
temporary PlayerPresentationProbe apparatus that measured this is
retired with the fix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 09:16:41 +02:00
Erik
0330fcd0d1 perf(render) #429: allocation-exact streamed-mesh completion
UploadGfxObjMeshData built every completed mesh's index data three-plus
times over in LINQ transients (per-batch Indices.ToArray copies plus an
unsized SelectMany growth) on the render thread, up to the per-frame
upload budget. The conversion now fills one exact-size retained
CPUIndices array (the same one the B.4b pick path keeps) and hands the
shared arena (offset, count) segments of it; CPUPositions fills by a
direct pre-sized loop; the Sum/Any/FirstOrDefault transients are gone.
GlobalMeshBuffer.UploadMesh takes the segment form — the staged bytes
per batch are unchanged. Gate: a warmed completion must allocate near
its retained-copy size (MeshPipelineDeviceSeamTests).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 09:14:00 +02:00
Erik
5b9d0260bb docs #429: root cause — 30-77MB/frame LINQ allocation in streamed-mesh completion on the render thread; fix plan + acceptance protocol
Local commit for the implementation handoff; push withheld per owner
direction until the fix session.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 21:46:05 +02:00
584 changed files with 85808 additions and 9091 deletions

1
.gitignore vendored
View file

@ -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
View file

@ -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 A1A6 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 S1S3
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-215AP-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 AK 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, V0V11 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 (~100500 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
View file

@ -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 (~100500 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)

View file

@ -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

File diff suppressed because it is too large Load diff

View file

@ -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

View file

@ -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

View file

@ -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 13; 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 13; 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`.

383
docs/launch-options.md Normal file
View file

@ -0,0 +1,383 @@
# 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
four flags default ON, and none is a diagnostic: `ACDREAM_RETAIL_CHASE`,
`ACDREAM_CAMERA_COLLIDE`, `ACDREAM_CAMERA_ALIGN_SLOPE`, and
`ACDREAM_RETAIL_CLOSE_DEGRADES` are retail *behaviors* wearing an A/B
off-switch (`=0` disables the behavior for a comparison run). That
four-flag set is frozen by `LaunchOptionsDocumentationTests` — a new
default-on flag fails the build.
- `=1` means the code tests for exactly the string `1`. Setting `true`,
`yes`, or `0` does **not** enable such a flag (and `0` does not disable
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"
$env:ACDREAM_RETAIL_UI = "1"
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Release
```
| Flag | Value | What it does | Side effects | Default | Read by |
|---|---|---|---|---|---|
| `ACDREAM_A2C` | `unset/""` keep preset; `"0"/"false"/"False"/"FALSE"` → off; any other non-empty → on | Overrides preset's `AlphaToCoverage` blend flag | Changes MSAA alpha-to-coverage blending mode for foliage/translucent draws — a visual-behavior change, not just perf | preset's `AlphaToCoverage` (High/Ultra=true, Low/Medium=false) | `QualitySettings.WithEnvOverrides` (`QualityPreset.cs:52`) |
| `ACDREAM_AC_DIR` | `=<path>` | Points at a real retail AC install dir; loads `<dir>/controls/controls.ini` to source retail keybind display strings for the retained UI. | Only has any effect when `ACDREAM_RETAIL_UI=1` (retained UI composed). Unset → `ControlsIni.Parse(string.Empty)`, an empty (not error) controls table — silent, no fallback file is searched. | unset (null) → empty controls table | `RuntimeOptions.AcDir``InteractionRetainedUiComposition.cs:610` |
| `ACDREAM_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` | `=1` | Switches on the retained retail UI host tree (`UiHost`/`UiRoot`, D.2b). Without it, no retained UI is composed at all — e.g. no chargen Appearance page, no Summary page. | **Forced to `true` unconditionally** for every `--session-config` / launcher launch (`RuntimeOptions.cs:282`, "a session-config launch IS a product launch — the retail UI is the shipped UI, not a dev option"), regardless of this env var's value — the env var only matters for the bare env-var dev-flow launch path. | `false` for the env-var dev flow; `true` always for `--session-config` launches | `RuntimeOptions.RetailUi``LivePresentationComposition.cs:1108-1131` (gates retained-UI mount via `InteractionRetainedUiComposition`), `GameWindow.cs:455,566` (comments), `RuntimeOptions.cs:277` |
| `ACDREAM_TEST_HOST` | `=<host>` | ACE server hostname for live-mode connect. | none | `"127.0.0.1"` | `RuntimeOptions.LiveHost` (`RuntimeOptions.cs:142`) |
| `ACDREAM_TEST_PASS` | `=<string>` | ACE account password for live-mode connect. | Redacted in `RuntimeOptions.ToString()`/diagnostic printing by design (`PrintMembers` override, `RuntimeOptions.cs:326-342`) — defense-in-depth so it can never leak into a log/exception via the record's default printing. | `null` (empty → `HasLiveCredentials` false) | `RuntimeOptions.LivePass` (`RuntimeOptions.cs:145`) |
| `ACDREAM_TEST_PORT` | `=<int>` | ACE server port for live-mode connect. | none | `9000` | `RuntimeOptions.LivePort` (`RuntimeOptions.cs:143`) |
| `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 wall-clock time, so two launches of a differential/offline gate agree about cloud position. | **Non-obvious dual effect**: this ONE var pins TWO independently-designed clocks that happen to share a name-adjacent purpose — the sky renderer's cloud scroll (`SkyRenderer.AnimationPhaseSecondsOverride`) AND, since Campaign VM slice VM6, the atmospheric post-process graph's foliage-wind clock (`_windClockSecondsOverride`). A gate that only knows about "sky clouds" and sets this to freeze them will *also* freeze foliage-wind evolution — deliberately snapped-to-target on the first advance per an A6 review fix, but still a second surface a naive reader wouldn't expect this var to touch. Distinct from `ACDREAM_DAY_GROUP`/`ACDREAM_WORLD_TIME`, which pin the OTHER sky clock (day group/sun angle) — retail's clouds drift independently of the calendar date by design. | `null` → wall-clock driven (every ordinary run) | `RuntimeOptions.SkyAnimationPhaseSeconds``SkyRenderer.cs:79,85` (cloud UV scroll) **and** `AtmosphericPostProcessGraph.cs:560,586,671` (foliage-wind clock) |
| `ACDREAM_STREAM_WORK_COMPLETIONS` | `=<int>` (`>0`, else default) | Per-frame ceiling on streaming completion admissions on the update thread | Class doc comment states explicitly: this whole `ACDREAM_STREAM_WORK_*` family "exists for A/B measurement only" — not a user/production setting. Directly changes streaming throughput per frame; do not compare a measurement taken with this set against a default run. | 64 | `StreamingWorkBudgetOptions.Parse` (`StreamingWorkBudgetOptions.cs:56-58`) |
| `ACDREAM_STREAM_WORK_CPU_MIB` | `=<int MiB>` (`>0`, else default) | Per-frame ceiling on adopted (newly resident) CPU bytes on the update thread | A/B-measurement-only family; changes per-frame CPU admission budget | 8 MiB | `StreamingWorkBudgetOptions.Parse` (`:59-61`) |
| `ACDREAM_STREAM_WORK_DEST_RESERVE_PERCENT` | `=<float percent>`, exclusive `0 < x < 100`, else default; stored as fraction (`percent/100`) | Fraction of the per-frame work budget reserved for the active reveal destination lane vs. background streaming | A/B-measurement-only family; reallocates frame budget between destination-lane and background streaming work, changing reveal-latency characteristics | 0.75 (75%) | `StreamingWorkBudgetOptions.Parse`/`ParseReservePercent` (`:71-73,154-169`) |
| `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&lt;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 2120 (`=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 |

View file

@ -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.

View file

@ -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).

View 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). |

View 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**.

View 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!").**
AS1AS5 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 G1G10, and
§5 rulings R1R8 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 G1G10; rulings R1R8.
- **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 37, 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 12 and 814: 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 |

View 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.

View file

@ -0,0 +1,483 @@
# MossTank — VTank parity campaign
Date: 2026-08-26
Status: ACTIVE — MT1 USER-PASSED; MTUIMT9 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 User1User5;
- [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.
MT7MT9 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/User1User5 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 | MT2MT9 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.

View 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.

View file

@ -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).

View file

@ -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>

View file

@ -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:

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,142 @@
# Campaign CA — connected gate script (CA5, user-driven)
**Purpose:** live verification of the whole advancement chain against ACE
after CA1CA4 (`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 12 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?).

View 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 CT2CT6 (`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 111113 ("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 **14**
(`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 34 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 CT2CT6 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.

View 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: 1100 Initiate, 101300 Adept, 301600 Knight, 6011000 Lord, 10011500 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 (value9999) 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 46 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.

View file

@ -0,0 +1,147 @@
# Campaign AS — connected gate script (AS6, user-driven)
**Purpose:** live verification of the assess/examination-window parity
campaign (AS2AS5) 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 3721000, 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)?

View file

@ -0,0 +1,179 @@
# Campaign CT — connected gate script (CT7, user-driven)
**Purpose:** live verification of the character-panel parity campaign
(CT1CT6) 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?

View 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 AC and EF 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.

View 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.

View 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, User1User5 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.

View 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 14 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.

View 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 112 |
| 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 1018 |
| 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.

View file

@ -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.

View file

@ -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

View file

@ -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;
}

View file

@ -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(

View file

@ -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;
@ -95,7 +96,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 +390,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 +430,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 +543,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 +582,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 +678,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(
@ -822,7 +865,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 +948,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
@ -1122,11 +1176,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 +1221,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);

View file

@ -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

View file

@ -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",

View file

@ -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(
@ -811,6 +830,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 +1172,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 +1283,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 +1305,7 @@ internal sealed class SessionPlayerCompositionPhase
gameRuntime,
gameRuntime.Selection,
gameRuntime.MovementCommands,
gameRuntime.CharacterCommands,
commands);
GameplayInputActionRouter gameplayActions =
GameplayInputActionRouter.Create(
@ -1308,6 +1335,7 @@ internal sealed class SessionPlayerCompositionPhase
spawnClaimClassifier,
liveSession,
hydration,
deletion,
networkUpdates,
liveness,
sessionEvents,

View file

@ -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)

View file

@ -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

View file

@ -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)

View file

@ -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;

View file

@ -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;

View file

@ -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));
}
}

View file

@ -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;

View file

@ -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))

View file

@ -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();
}
}

View file

@ -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);

View file

@ -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)

View file

@ -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

View 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;
}
}

View 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);
}
}
}

View file

@ -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.

View file

@ -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();

View file

@ -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;
}
}

View 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),
};
}
}

View file

@ -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();

View file

@ -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>(

View file

@ -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
@ -443,6 +455,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,
@ -770,6 +783,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 +799,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() =>

View file

@ -186,12 +186,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+(&amp;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 +211,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;
}
}

View file

@ -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;
}

View file

@ -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)
@ -2128,21 +2101,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);
@ -2685,14 +2643,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
@ -2796,43 +2746,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);
}
}
}

View file

@ -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;
}

View 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

View file

@ -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; }
}

View file

@ -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);
}

View 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;
}
}

View 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);
}
}

View file

@ -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,

View file

@ -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());

View file

@ -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);
}
}

View file

@ -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 -&gt; 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);

View file

@ -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);

View file

@ -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

View file

@ -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)

View file

@ -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 =

View file

@ -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

View file

@ -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

View file

@ -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;
}

View file

@ -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;

View file

@ -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(

View file

@ -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));
}

View file

@ -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);

View file

@ -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,

View file

@ -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)

View file

@ -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=&lt;path&gt;</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.
}
}
}

View file

@ -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>

View file

@ -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

View file

@ -94,11 +94,17 @@ internal sealed class PrivateEntityViewportRenderer :
/// feature does not exist for them, not just "unused".</summary>
private readonly EntitySlot? _backdropSlot;
// One stable sampled texture-table slot is part of the retained viewport's
// presentation contract. Rotating the slot with the Vulkan flight index
// made the UI sample a freshly-created/cleared sibling after world reveal.
// The frame submission order already protects this target's write -> sample
// transition; keep its identity stable until resize or disposal.
private IGpuRenderTarget? _target;
private IGpuSampler? _sampler;
private GpuTextureSlot _slot = GpuTextureSlot.Unassigned;
private int _fbW;
private int _fbH;
private bool _hasRenderedScene;
public PrivateEntityViewportRenderer(
IWorldPassScope scope,
@ -169,6 +175,29 @@ internal sealed class PrivateEntityViewportRenderer :
public void SetEntity(WorldEntity? entity) => _mainSlot.Set(entity);
/// <summary>
/// Advances the private entity's mesh and texture-composite readiness
/// without allocating or clearing a render target. Paperdoll uses this
/// while its tab is hidden so first-open work is already resident.
/// </summary>
public bool Prepare()
{
if (!_mainSlot.PrepareForDraw()
|| !(_backdropSlot?.PrepareForDraw() ?? true))
{
return false;
}
WorldEntity? entity = _mainSlot.Entity;
if (entity is null || entity.MeshRefs.Count == 0)
{
return false;
}
IReadOnlyList<WorldEntity> entities = BuildDrawEntities(
_backdropSlot?.Entity,
entity);
return _dispatcher.PreparePrivateEntityResources(entities);
}
/// <summary>
/// Sets or clears the environment backdrop entity drawn BEHIND the main
/// entity — GF-7/GF-14's fix, retail's <c>gmCG3DView::m_pbgObject</c>. Only
@ -197,10 +226,37 @@ internal sealed class PrivateEntityViewportRenderer :
/// </summary>
public uint Render(int width, int height)
{
// #443: acquiring a synthetic mesh reference only schedules CPU
// preparation/GPU upload; it does not make the mesh drawable. Keep the
// last completed private scene intact until every drawable mesh in the
// replacement has crossed that upload barrier. On first open there is
// no completed scene, so return zero and let the authored panel art
// show through instead of publishing a freshly-cleared black target.
bool mainReady = _mainSlot.PrepareForDraw();
bool backdropReady = _backdropSlot?.PrepareForDraw() ?? true;
if (!mainReady || !backdropReady)
{
return _mainSlot.Entity is not null
&& _hasRenderedScene
&& _slot.IsAssigned
? UiTextureTableHandle.FromSlot(_slot)
: 0u;
}
WorldEntity? entity = _mainSlot.Entity;
if (entity is null || entity.MeshRefs.Count == 0 || width <= 0 || height <= 0)
return 0u;
IReadOnlyList<WorldEntity> drawEntities = BuildDrawEntities(
_backdropSlot?.Entity,
entity);
if (!_dispatcher.PreparePrivateEntityResources(drawEntities))
{
return _hasRenderedScene && _slot.IsAssigned
? UiTextureTableHandle.FromSlot(_slot)
: 0u;
}
EnsureRenderTarget(width, height);
if (_target is null)
return 0u;
@ -236,7 +292,6 @@ internal sealed class PrivateEntityViewportRenderer :
UploadCreatureLight();
IReadOnlyList<WorldEntity> drawEntities = BuildDrawEntities(_backdropSlot?.Entity, entity);
var entries =
new (uint, Vector3, Vector3, IReadOnlyList<WorldEntity>,
IReadOnlyDictionary<uint, WorldEntity>?)[]
@ -256,6 +311,7 @@ internal sealed class PrivateEntityViewportRenderer :
neverCullLandblockId: PrivateLandblockId,
visibleCellIds: null,
animatedEntityIds: _animatedIds);
_hasRenderedScene = true;
return UiTextureTableHandle.FromSlot(_slot);
}
@ -367,6 +423,7 @@ internal sealed class PrivateEntityViewportRenderer :
_target = null;
_fbW = 0;
_fbH = 0;
_hasRenderedScene = false;
}
public void Dispose()
@ -405,31 +462,88 @@ internal sealed class PrivateEntityViewportRenderer :
}
}
private static IEnumerable<ulong> CollectMeshIds(WorldEntity entity)
{
for (int i = 0; i < entity.MeshRefs.Count; i++)
yield return entity.MeshRefs[i].GfxObjId;
for (int i = 0; i < entity.PartOverrides.Count; i++)
yield return entity.PartOverrides[i].GfxObjId;
}
/// <summary>
/// One private entity's own mesh-reference/texture-owner lifetime,
/// independent of any other slot on the same renderer. Factored out at
/// Campaign CC gate round 1 Batch D so the chargen backdrop entity gets
/// the EXACT SAME acquire/replace/retire behavior the main entity already
/// had — a single-owner class shared by both slots rather than a second,
/// hand-duplicated copy of <see cref="PrivateEntityViewportRenderer.SetEntity"/>'s
/// pre-Batch-D body.
/// One private entity's mesh-reference/texture-owner lifetime, independent
/// of every other slot on the renderer. Publication is two-phase: a candidate owns its mesh
/// references while preparation/upload runs, but does not replace the
/// drawable entity until all of its actual <see cref="WorldEntity.MeshRefs"/>
/// are resident. Kept internal so #443's lifetime/readiness behavior can
/// be pinned without constructing a live GPU device.
/// </summary>
private sealed class EntitySlot
internal sealed class EntitySlot
{
private sealed class MeshSnapshot
{
private readonly ulong[] _ownedIds;
private readonly int _drawMeshCount;
private MeshSnapshot(ulong[] ownedIds, int drawMeshCount)
{
_ownedIds = ownedIds;
_drawMeshCount = drawMeshCount;
}
public IReadOnlyList<ulong> OwnedIds => _ownedIds;
public static MeshSnapshot Capture(WorldEntity entity)
{
int drawMeshCount = entity.MeshRefs.Count;
var ids = new ulong[drawMeshCount + entity.PartOverrides.Count];
for (int i = 0; i < drawMeshCount; i++)
ids[i] = entity.MeshRefs[i].GfxObjId;
for (int i = 0; i < entity.PartOverrides.Count; i++)
ids[drawMeshCount + i] = entity.PartOverrides[i].GfxObjId;
return new MeshSnapshot(ids, drawMeshCount);
}
/// <summary>
/// Residency identity deliberately excludes part transforms,
/// palette ranges and surface overrides: changing those does not
/// require another mesh upload. A different entity instance still
/// stages a replacement so its fixed texture owner is refreshed.
/// </summary>
public bool Matches(WorldEntity entity)
{
if (entity.MeshRefs.Count != _drawMeshCount
|| entity.PartOverrides.Count != _ownedIds.Length - _drawMeshCount)
{
return false;
}
for (int i = 0; i < _drawMeshCount; i++)
if (entity.MeshRefs[i].GfxObjId != _ownedIds[i])
return false;
for (int i = 0; i < entity.PartOverrides.Count; i++)
if (entity.PartOverrides[i].GfxObjId != _ownedIds[_drawMeshCount + i])
return false;
return true;
}
public bool AreDrawMeshesReady(IWbMeshAdapter adapter)
{
for (int i = 0; i < _drawMeshCount; i++)
{
ulong id = _ownedIds[i];
if (id == 0u || !adapter.IsRenderDataReady(id))
return false;
}
return true;
}
}
private sealed record PendingEntity(
WorldEntity Entity,
MeshSnapshot Snapshot,
SyntheticEntityMeshReferenceOwner MeshReferences);
private readonly IWbMeshAdapter _meshAdapter;
private readonly FixedEntityTextureOwnerLease _textureOwnerLease;
private readonly string _diagnosticName;
private readonly List<SyntheticEntityMeshReferenceOwner> _retiringMeshReferences = [];
private SyntheticEntityMeshReferenceOwner? _meshReferences;
private MeshSnapshot? _meshSnapshot;
private PendingEntity? _pending;
public EntitySlot(
IWbMeshAdapter meshAdapter,
@ -444,75 +558,181 @@ internal sealed class PrivateEntityViewportRenderer :
public WorldEntity? Entity { get; private set; }
internal bool HasPending => _pending is not null;
public void Set(WorldEntity? entity)
{
ReleaseRetiringMeshReferences();
if (ReferenceEquals(Entity, entity))
if (entity is null)
{
Clear();
return;
}
// The common animated appraisal path reuses one clone object. Let
// PrepareForDraw perform its allocation-free mesh-id comparison;
// if no candidate is pending, reference equality is enough here.
if (ReferenceEquals(Entity, entity) && _pending is null)
return;
SyntheticEntityMeshReferenceOwner? replacement = null;
if (entity is not null)
if (ReferenceEquals(Entity, entity)
&& _meshSnapshot?.Matches(entity) == true)
{
replacement = new SyntheticEntityMeshReferenceOwner(
_meshAdapter,
CollectMeshIds(entity));
replacement.Acquire();
ReleasePending();
return;
}
SyntheticEntityMeshReferenceOwner? previous = _meshReferences;
if (_pending is { } pending
&& ReferenceEquals(pending.Entity, entity)
&& pending.Snapshot.Matches(entity))
{
return;
}
Stage(entity);
}
/// <summary>
/// Refreshes an in-place MeshRefs mutation, re-arms missing uploads,
/// and atomically promotes a fully drawable candidate. False tells the
/// renderer to preserve its last completed render target this frame.
/// </summary>
public bool PrepareForDraw()
{
ReleaseRetiringMeshReferences();
if (_pending is { } pending)
{
if (!pending.Snapshot.Matches(pending.Entity))
Stage(pending.Entity);
}
else if (Entity is { } current
&& _meshSnapshot?.Matches(current) != true)
{
// Chargen animation and appraisal synchronization both mutate
// a retained WorldEntity in place. Detect a changed GfxObj set
// here even when the caller did not issue another Set call.
Stage(current);
}
pending = _pending;
if (pending is null)
return true;
if (!pending.Snapshot.AreDrawMeshesReady(_meshAdapter))
return false;
PromotePending(pending);
return true;
}
private void Stage(WorldEntity entity)
{
MeshSnapshot snapshot = MeshSnapshot.Capture(entity);
var replacement = new SyntheticEntityMeshReferenceOwner(
_meshAdapter,
snapshot.OwnedIds);
try
{
_textureOwnerLease.Replace(entity is not null);
replacement.Acquire();
}
catch (Exception textureFailure)
catch (Exception acquisitionFailure)
{
if (replacement is null)
throw;
try
{
replacement.Dispose();
}
catch (Exception rollbackFailure)
{
_retiringMeshReferences.Add(replacement);
throw new AggregateException(
$"The {_diagnosticName} texture-owner replacement failed "
+ "and the replacement mesh-owner rollback did not converge.",
textureFailure,
$"The {_diagnosticName} candidate mesh acquisition failed "
+ "and its rollback did not converge.",
acquisitionFailure,
rollbackFailure);
}
System.Runtime.ExceptionServices.ExceptionDispatchInfo
.Capture(textureFailure)
.Capture(acquisitionFailure)
.Throw();
throw new InvalidOperationException("Unreachable exception dispatch path.");
}
_meshReferences = replacement;
Entity = entity;
PendingEntity? previous = _pending;
_pending = new PendingEntity(entity, snapshot, replacement);
if (previous is not null)
Retire(previous.MeshReferences);
}
private void PromotePending(PendingEntity pending)
{
// Release the fixed texture owner's prior composites only at the
// same atomic edge that publishes the new mesh set. If release
// fails, the candidate remains pending and can retry intact.
_textureOwnerLease.Replace(hasReplacement: true);
SyntheticEntityMeshReferenceOwner? previous = _meshReferences;
_meshReferences = pending.MeshReferences;
_meshSnapshot = pending.Snapshot;
Entity = pending.Entity;
_pending = null;
if (previous is not null)
Retire(previous);
}
private void Clear()
{
if (Entity is null && _pending is null)
return;
ReleasePending();
_textureOwnerLease.Replace(hasReplacement: false);
SyntheticEntityMeshReferenceOwner? previous = _meshReferences;
_meshReferences = null;
_meshSnapshot = null;
Entity = null;
if (previous is not null)
Retire(previous);
}
private void ReleasePending()
{
PendingEntity? pending = _pending;
if (pending is null)
return;
_pending = null;
Retire(pending.MeshReferences);
}
private void Retire(SyntheticEntityMeshReferenceOwner owner)
{
try
{
try
{
previous.Dispose();
}
catch
{
_retiringMeshReferences.Add(previous);
throw;
}
owner.Dispose();
}
catch
{
_retiringMeshReferences.Add(owner);
throw;
}
}
public void Dispose()
{
Entity = null;
_meshSnapshot = null;
if (_meshReferences is { } current)
{
_meshReferences = null;
_retiringMeshReferences.Add(current);
}
if (_pending is { } pending)
{
_pending = null;
_retiringMeshReferences.Add(pending.MeshReferences);
}
List<Exception>? failures = null;
try

View file

@ -128,9 +128,10 @@ internal readonly record struct ProcessResourceDiagnostics(
int TrackedGpuTextures);
/// <summary>
/// Immutable resource facts captured only when explicit UI-probe dumping is enabled.
/// Grouping keeps the diagnostics controller independent from every canonical renderer,
/// VFX, mesh, texture, and process owner used to produce the values.
/// Immutable resource facts captured only by an explicit low-frequency
/// diagnostic such as UI-probe dumping or reveal timing. Grouping keeps the
/// consumers independent from every canonical renderer, VFX, mesh, texture,
/// and process owner used to produce the values.
/// </summary>
internal readonly record struct RenderFrameResourceDiagnosticsSnapshot(
VfxStreamResourceDiagnostics Vfx,

View file

@ -29,6 +29,12 @@ namespace AcDream.App.Rendering;
/// </summary>
public sealed class RetailChaseCamera : ICamera
{
private const float RetailDefaultBack = 2.5f;
private const float RetailDefaultUp = 0.75f;
private const float RetailLookDownBack = 2f;
private const float RetailMapBack = 450f;
private const float RetailFirstPersonForward = 0.18f;
// ICamera surface.
public Vector3 Position { get; private set; }
@ -75,6 +81,20 @@ public sealed class RetailChaseCamera : ICamera
/// <summary>Height of look-at anchor above the player's feet (m). Retail default 1.5.</summary>
public float PivotHeight { get; set; } = 1.5f;
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;
/// <summary>
/// Optional spring-arm collision probe. When set (and
/// <see cref="CameraDiagnostics.CollideCamera"/> is true), the damped eye
@ -172,8 +192,13 @@ public sealed class RetailChaseCamera : ICamera
// target supplies the frame heading. Without this local rotation, enabling
// Keep in View snaps the camera behind the target and disables RMB orbit.
float viewerYawOffset = trackedHeading.HasValue ? YawOffset : 0f;
(Vector3 targetEye, Vector3 targetForward) = ComputeDesiredPose(
pivotWorld, heading, Distance, Pitch, viewerYawOffset);
(Vector3 targetEye, Vector3 targetForward) = _inHead
? ComputeInHeadPose(pivotWorld, heading)
: _targetDirectionLocal is { } localDirection
? ComputeTargetDirectionPose(
pivotWorld, heading, Distance, Pitch, localDirection)
: ComputeDesiredPose(
pivotWorld, heading, Distance, Pitch, viewerYawOffset);
// 5. Stateful sought position (#180). Retail CameraManager::UpdateCamera
// (0x00456660) interpolates FROM THE CURRENT SWEPT VIEWER toward the
@ -279,16 +304,120 @@ public sealed class RetailChaseCamera : ICamera
/// <see cref="DistanceMin"/>..<see cref="DistanceMax"/>. Mirrors
/// legacy <c>ChaseCamera.AdjustDistance</c>.
/// </summary>
public void AdjustDistance(float delta) =>
public void AdjustDistance(float delta)
{
ExitLookDownForAdjustment();
ExitInHeadForAdjustment();
Distance = Math.Clamp(Distance + delta, DistanceMin, DistanceMax);
}
/// <summary>
/// Adjust the camera pitch by a delta (radians), clamped to
/// <see cref="PitchMin"/>..<see cref="PitchMax"/>. Mirrors legacy
/// <c>ChaseCamera.AdjustPitch</c>.
/// </summary>
public void AdjustPitch(float delta) =>
public void AdjustPitch(float delta)
{
ExitLookDownForAdjustment();
ExitInHeadForAdjustment();
Pitch = Math.Clamp(Pitch + delta, PitchMin, PitchMax);
}
public void SetRetailDefaultView()
{
_lookingDown = false;
_mapMode = false;
_inHead = false;
_targetDirectionLocal = null;
YawOffset = 0f;
PivotHeight = 1.5f;
SetViewerOffset(RetailDefaultBack, RetailDefaultUp);
}
public void SetRetailFirstPersonView()
{
_lookingDown = false;
_mapMode = false;
_inHead = true;
_targetDirectionLocal = null;
YawOffset = 0f;
Distance = RetailFirstPersonForward;
Pitch = 0f;
// Do not spend a transition frame inside the head/neck. Retail's
// SetInHead installs the new viewer offset as one camera preset.
_initialised = false;
}
public void ToggleRetailLookDownView()
{
if (_lookingDown)
{
RestoreLookDownView();
return;
}
SaveLookDownView();
_lookingDown = true;
_mapMode = false;
_inHead = false;
_targetDirectionLocal = new Vector3(0f, 0.5f, -1.8f);
SetViewerOffset(RetailLookDownBack, 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(RetailMapBack, 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);
}
/// <summary>
/// Public entry point for the mouse-input low-pass filter. Calls
@ -436,6 +565,47 @@ public sealed class RetailChaseCamera : ICamera
return (eye, forward);
}
/// <summary>
/// Retail <c>CameraSet::SetInHead @ 0x00458CE0</c>: the target direction
/// is local +Y and the viewer offset is local +Y * 0.18. It is not a
/// negative chase boom looking back toward the player's neck.
/// </summary>
internal static (Vector3 eye, Vector3 forward) ComputeInHeadPose(
Vector3 pivotWorld,
Vector3 heading)
{
Vector3 forward = Vector3.Normalize(heading);
return (pivotWorld + forward * RetailFirstPersonForward, forward);
}
/// <summary>
/// Transform both retail <c>viewer_offset</c> and <c>target_direction</c>
/// through the target frame. LookDown/MapMode do not merely point a
/// horizontally-positioned camera toward the ground: the downward target
/// direction pitches the frame whose -Y/+Z offset places the viewer. For
/// MapMode's (0,-450,0.75) offset this puts the viewer high above and only
/// slightly behind the character, matching CameraSet::SetMapMode.
/// </summary>
internal static (Vector3 eye, Vector3 forward) ComputeTargetDirectionPose(
Vector3 pivotWorld,
Vector3 heading,
float distance,
float pitch,
Vector3 targetDirectionLocal)
{
var (frameForward, frameRight, frameUp) = BuildBasis(heading);
Vector3 targetForward = Vector3.Normalize(
frameForward * targetDirectionLocal.Y
- frameRight * targetDirectionLocal.X
+ frameUp * targetDirectionLocal.Z);
var (_, _, targetUp) = BuildBasis(targetForward);
float back = distance * MathF.Cos(pitch);
float up = distance * MathF.Sin(pitch);
Vector3 eye = pivotWorld - targetForward * back + targetUp * up;
return (eye, targetForward);
}
/// <summary>
/// Build an orthonormal basis with <c>forward = heading</c>. World
/// up is <c>(0, 0, 1)</c>; if <c>heading</c> is near-parallel to it

View file

@ -134,6 +134,8 @@ internal sealed class RetailPViewPassExecutor :
private readonly TerrainDrawDiagnosticsController _terrainDiagnostics;
private readonly RetailPViewParticleClassifications _particleClassifications = new();
private readonly HashSet<uint> _noSceneParticleEntityIds = [];
private readonly Dictionary<uint, int> _singleCellClipRouting = new(1);
private readonly Dictionary<uint, int> _noCellClipRouting = new(0);
/// <summary>
/// Borrowed until the next late landscape pass. The outdoor-root post-world
@ -207,6 +209,8 @@ internal sealed class RetailPViewPassExecutor :
{
List<Exception>? failures = null;
TryAbort(_frameGlState.RestoreFrameDefaults);
TryAbort(() => _envCells.SetClipRouting(null));
TryAbort(_entities.ClearClipRouting);
TryAbort(_particleClassifications.BeginFrame);
TryAbort(_noSceneParticleEntityIds.Clear);
if (failures is { Count: > 0 })
@ -230,6 +234,11 @@ internal sealed class RetailPViewPassExecutor :
ClipFrameAssembly reuseAssembly) =>
ClipFrameAssembler.Assemble(_clipFrame, portalFrame, reuseAssembly);
public void AppendLookInClipFrames(
IReadOnlyList<PortalVisibilityFrame> lookInFrames,
ClipFrameAssembly assembly) =>
ClipFrameAssembler.AppendLookInFrames(_clipFrame, lookInFrames, assembly);
public void PrepareClipFrame(int terrainUploadCount) =>
_surface.PrepareClipFrame(terrainUploadCount);
@ -246,6 +255,24 @@ internal sealed class RetailPViewPassExecutor :
_entities.ClearClipRouting();
}
public void UseCellPortalViewRouting(uint cellId, ClipViewSlice slice)
{
_singleCellClipRouting.Clear();
_singleCellClipRouting.Add(cellId, slice.Slot);
_envCells.SetClipRouting(_singleCellClipRouting);
// Retail DrawMesh only viewcone-checks an object's sphere under the
// installed PortalList and then draws the mesh whole. Hard clipping the
// object here slices a stationary player when the chase camera crosses
// into the opposite cathedral cell while the player remains behind.
_entities.ClearClipRouting();
}
private void UseOutdoorPortalViewRouting(ClipViewSlice slice) =>
_entities.SetClipRouting(
_noCellClipRouting,
outdoorSlot: slice.Slot,
outdoorVisible: true);
public void PrepareCellBatches(
RetailPViewFrameInput frame,
HashSet<uint> visibleCellIds) =>
@ -302,37 +329,6 @@ internal sealed class RetailPViewPassExecutor :
slice,
sliceIndex);
public void EmitOutStageOwner(
WorldEntity entity,
Vector3 sphereCenter,
float sphereRadius,
int sliceIndex,
bool passed) =>
_diagnostics.EmitOutStageOwner(
RenderingDiagnostics.ProbeOutStageEnabled,
RenderingDiagnostics.DumpEntitySourceIds,
entity,
sphereCenter,
sphereRadius,
sliceIndex,
passed);
public void EmitOutStageRouting(
int sliceIndex,
IReadOnlyList<WorldEntity> entities,
ViewconeCuller viewcone) =>
_diagnostics.EmitOutStageRouting(
RenderingDiagnostics.ProbeOutStageEnabled,
sliceIndex,
entities,
viewcone);
public void EmitPhantomObjects(uint cellId, int survivorCount) =>
_diagnostics.EmitPhantomObjects(
RenderingDiagnostics.ProbePhantomEnabled,
cellId,
survivorCount);
public void DrawLandscapeSlice(
RetailPViewFrameInput frame,
RetailPViewLandscapeSliceContext context)
@ -407,6 +403,7 @@ internal sealed class RetailPViewPassExecutor :
if (scissor)
_surface.EndScissor();
_entities.ClearClipRouting();
DisableClipDistances();
}
@ -449,13 +446,7 @@ internal sealed class RetailPViewPassExecutor :
_particleClassifications.ReplaceOutdoor(context.ParticleOwnerIds);
_diagnostics.EmitOutStageParticles(
RenderingDiagnostics.ProbeOutStageEnabled,
_particles,
_particleClassifications.Outdoor);
if (!frame.RootCell.IsOutdoorNode
&& _particleClassifications.Outdoor.Count > 0
if (_particleClassifications.Outdoor.Count > 0
&& _particles is not null
&& _particleRenderer is not null)
{
@ -463,7 +454,8 @@ internal sealed class RetailPViewPassExecutor :
frame.Camera,
frame.CameraWorldPosition,
ParticleRenderPass.Scene,
_particleClassifications.Outdoor);
_particleClassifications.Outdoor,
clipSlot: (uint)context.Slice.Slot);
}
EnableClipDistances();
@ -492,6 +484,77 @@ internal sealed class RetailPViewPassExecutor :
if (scissor)
_surface.EndScissor();
_entities.ClearClipRouting();
DisableClipDistances();
}
public void DrawLandscapeStaticParticles(
RetailPViewFrameInput frame,
RetailPViewLandscapeStaticParticleContext context)
{
bool scissor = BeginDoorwayScissor(context.Slice.NdcAabb);
_surface.BindTerrainClip();
DisableClipDistances();
_particleClassifications.ReplaceOutdoor(context.ParticleOwnerIds);
if (_particleClassifications.Outdoor.Count > 0
&& _particles is not null
&& _particleRenderer is not null)
{
_particleRenderer.DrawForOwners(
frame.Camera,
frame.CameraWorldPosition,
ParticleRenderPass.Scene,
_particleClassifications.Outdoor,
clipSlot: (uint)context.Slice.Slot);
}
if (scissor)
_surface.EndScissor();
_entities.ClearClipRouting();
DisableClipDistances();
}
public void DrawLandscapeBuildingShellSlice(
RetailPViewFrameInput frame,
RetailPViewLandscapeBuildingShellSliceContext context)
{
UseOutdoorPortalViewRouting(context.Slice);
bool scissor = BeginDoorwayScissor(context.Slice.NdcAabb);
_surface.BindTerrainClip();
DisableClipDistances();
if (context.EntityDraw is RenderFrameEntityDrawRequest request)
{
RenderFrameView drawView = request.View;
_entities.DrawPackedProductionRoute(
frame.Camera,
in drawView,
request.Route,
request.RouteIndex,
request.CellId,
request.TupleLandblockId);
}
else if (context.BuildingShells.Count > 0)
{
var buildingEntry = (
frame.PlayerLandblockId ?? 0u,
Vector3.Zero,
Vector3.Zero,
context.BuildingShells,
(IReadOnlyDictionary<uint, WorldEntity>?)null);
_entities.Draw(
frame.Camera,
new[] { buildingEntry },
frame.Frustum,
neverCullLandblockId: frame.PlayerLandblockId,
visibleCellIds: null,
animatedEntityIds: frame.AnimatedEntityIds);
}
if (scissor)
_surface.EndScissor();
_entities.ClearClipRouting();
DisableClipDistances();
}
@ -504,10 +567,13 @@ internal sealed class RetailPViewPassExecutor :
public void DrawLookInPortalPunch(
RetailPViewFrameInput frame,
RetailPViewCellSliceContext context) =>
DrawPortalDepthWrite(context, frame, forceFarZ: true);
RetailPViewCellSliceContext context,
int portalIndex) =>
DrawPortalDepthWrite(context, frame, forceFarZ: true, portalIndex);
public void DrawUnattachedSceneParticles(RetailPViewFrameInput frame)
public void DrawUnattachedSceneParticles(
RetailPViewFrameInput frame,
ClipViewSlice slice)
{
if (_particles is null || _particleRenderer is null)
return;
@ -518,7 +584,8 @@ internal sealed class RetailPViewPassExecutor :
frame.CameraWorldPosition,
ParticleRenderPass.Scene,
_noSceneParticleEntityIds,
includeUnattached: true);
includeUnattached: true,
clipSlot: (uint)slice.Slot);
}
public void FlushLandscapeAlpha() => _alpha.Flush();
@ -545,7 +612,8 @@ internal sealed class RetailPViewPassExecutor :
frame.Camera,
frame.CameraWorldPosition,
ParticleRenderPass.Scene,
visible);
visible,
clipSlot: (uint)context.Slice.Slot);
DisableClipDistances();
}
@ -575,7 +643,6 @@ internal sealed class RetailPViewPassExecutor :
RetailPViewFrameInput frame,
RetailPViewFrameResult result) =>
_diagnostics.EmitRetailPViewDiagnostics(
RenderingDiagnostics.ProbeViewerEnabled,
RenderingDiagnostics.ProbeVisibilityEnabled,
RenderingDiagnostics.ProbeFlapEnabled,
result,
@ -584,13 +651,13 @@ internal sealed class RetailPViewPassExecutor :
frame.PlayerCellId,
frame.CameraWorldPosition,
frame.PlayerViewPosition,
frame.CameraView,
frame.CameraCellResolution);
private void DrawPortalDepthWrite(
RetailPViewCellSliceContext context,
RetailPViewFrameInput frame,
bool forceFarZ)
bool forceFarZ,
int? onlyPortalIndex = null)
{
// Retail D3DPolyRender::DrawPortalPolyInternal @ 0x0059BC90.
// Main interior roots stamp true depth (seal); outdoor and look-in
@ -604,6 +671,8 @@ internal sealed class RetailPViewPassExecutor :
Span<Vector3> world = stackalloc Vector3[32];
for (int index = 0; index < cell.Portals.Count; index++)
{
if (onlyPortalIndex.HasValue && index != onlyPortalIndex.Value)
continue;
if (cell.Portals[index].OtherCellId != 0xFFFF)
continue;
if (index >= cell.PortalPolygons.Count)

View file

@ -50,8 +50,10 @@ public sealed class RetailPViewRenderer
private readonly Stack<PortalVisibilityFrame> _lookInFramePool = new();
private readonly HashSet<uint> _lookInPrepareScratch = new();
// #131/#132: the late landscape phase's scene-particle owner survivors
// (statics + outside-stage dynamics passing the slice cone).
// #131/#132: landscape scene-particle owner survivors. With building
// look-ins, static owners use the pre-building alpha barrier and the late
// phase contains only outside-stage dynamics; otherwise the late phase
// carries both sets.
private readonly HashSet<uint> _lateParticleOwnerScratch = new();
private readonly HashSet<uint> _cellParticleOwnerScratch = new();
private readonly HashSet<uint> _dynamicParticleOwnerScratch = new();
@ -144,6 +146,7 @@ public sealed class RetailPViewRenderer
var clipAssembly = passes.AssembleClipFrame(
pvFrame,
_clipAssemblyScratch);
passes.AppendLookInClipFrames(_lookInFrames, clipAssembly);
int terrainUploadCount = checked(1 + clipAssembly.OutsideViewSlices.Length * 2);
passes.PrepareClipFrame(terrainUploadCount);
@ -413,6 +416,9 @@ public sealed class RetailPViewRenderer
group, ctx.ViewerEyePos, ctx.Cells.Find, ctx.ViewProjection,
OutdoorBuildingSeedDistance, pvFrame.OutsideView.Polygons,
reuseFrame: frameScratch);
LoadedCell sourceCell = group[0];
frame.SourceBuildingKey = sourceCell.BuildingId ?? sourceCell.CellId;
frame.SourceBuildingLandblockId = sourceCell.CellId & 0xFFFF0000u;
if (frame.OrderedVisibleCells.Count > 0)
_lookInFrames.Add(frame);
else
@ -454,65 +460,67 @@ public sealed class RetailPViewRenderer
// then draw the flooded cells' shells + statics far→near (the nested
// DrawCells' DrawEnvCell + DrawObjCellForDummies; its outside_view is
// empty by construction — PView ctor draw_landscape=0 — so no recursive
// landscape/clear/seal). Anything rasterized outside an aperture is
// repainted by the root's own shells after the depth clear, so over-draw
// here is color-safe; statics draw whole (the main viewcone has no entry
// for look-in cells; over-include is the safe direction).
// landscape/clear/seal). Retail CEnvCell::setup_view installs every cell's
// nested portal_view before DrawEnvCell, while DrawMesh iterates that same
// PortalList for cell objects. Preserve that per-slice gate here; drawing a
// nested cell whole lets its floor, details, and emitters escape the authored
// aperture even when the outer depth choreography is otherwise correct.
private void DrawBuildingLookIns(
RetailPViewFrameInput ctx,
IRetailPViewPassExecutor passes,
ClipFrameAssembly clipAssembly,
InteriorEntityPartition.Result? partition,
ViewconeCuller viewcone,
IRenderFrameEntityPassExecutor? frameEntityPasses,
in RenderFrameView frameView)
{
if (_lookInFrames.Count == 0)
return;
foreach (var frame in _lookInFrames)
int outsideSliceCount = clipAssembly.OutsideViewSlices.Length;
int lookInRouteIndex = 0;
for (int frameIndex = 0; frameIndex < _lookInFrames.Count; frameIndex++)
{
PortalVisibilityFrame frame = _lookInFrames[frameIndex];
// Retail enters DrawBuilding once per building and drains every
// alpha submission accumulated by the preceding building before
// punching the next building's portals. The first building uses
// the pre-look-in barrier in DrawLandscapeThroughOutsideView.
if (frameIndex > 0)
passes.FlushLandscapeAlpha();
// Pass 1: far-Z punch every aperture of this building.
foreach (uint cellId in frame.OrderedVisibleCells)
foreach (ExteriorPortalSeed seed in frame.ExteriorSeedPortals)
{
if (!frame.CellViews.TryGetValue(cellId, out var view))
continue;
foreach (var poly in view.Polygons)
foreach (var poly in seed.View.Polygons)
{
var cps = ClipPlaneSet.From(poly);
if (cps.IsNothingVisible)
continue;
passes.DrawLookInPortalPunch(ctx, new RetailPViewCellSliceContext(
cellId,
seed.CellId,
new ClipViewSlice(
0,
new Vector4(poly.MinX, poly.MinY, poly.MaxX, poly.MaxY),
cps.PlaneArray),
NoParticleOwners));
NoParticleOwners),
seed.PortalIndex);
}
}
// Pass 2: shells + statics, far→near.
passes.UseIndoorMembershipOnlyRouting();
// Opaque shells batched per building into ONE Render (this building's
// aperture punches above already ran; z-buffer handles order and
// lighting is per-instance CellId-keyed) — was one heavy per-frame
// Render per cell. Per-cell entity/particle work stays in the loop.
_shellBatch.Clear();
foreach (uint cid in frame.OrderedVisibleCells)
_shellBatch.Add(cid);
if (_shellBatch.Count > 0)
passes.DrawOpaqueCellShells(_shellBatch);
// Pass 2: shells + objects, far→near, once per portal_view slice.
for (int i = frame.OrderedVisibleCells.Count - 1; i >= 0; i--)
{
uint cellId = frame.OrderedVisibleCells[i];
_oneCell.Clear();
_oneCell.Add(cellId);
// Opaque shell batched above. Transparent stays per-cell (far→near)
// for correct compositing; skipped for opaque-only cells.
if (passes.CellHasTransparentShell(cellId))
passes.DrawTransparentCellShells(_oneCell);
var clipKey = new LookInClipCell(frameIndex, cellId);
if (!clipAssembly.LookInCellToViewSlices.TryGetValue(
clipKey,
out ClipViewSlice[]? cellSlices)
|| cellSlices.Length == 0)
{
continue;
}
_cellStaticScratch.Clear();
if (partition is not null
@ -528,8 +536,7 @@ public sealed class RetailPViewRenderer
// post-clear they would z-fail against the root's seal anyway
// (the #118 lesson). Retail draws a look-in cell's objects
// inside the NESTED DrawCells (DrawObjCellForDummies,
// pc:432878+), i.e. right here in the landscape stage. Drawn
// WHOLE like the statics (AP-33's documented over-include).
// pc:432878+), i.e. right here in the landscape stage.
// No double-draw: dynamics-last keeps culling them (their
// cell is absent from the main cone), and their emitters ride
// the DrawCellParticles call below, not DrawDynamicsParticles
@ -541,48 +548,154 @@ public sealed class RetailPViewRenderer
_cellStaticScratch.Add(e);
}
if (frameEntityPasses is not null)
foreach (ClipViewSlice slice in cellSlices)
{
RenderFrameRouteOwnerSelector.Replace(
_cellParticleOwnerScratch,
in frameView,
RenderFrameCandidateRoute.LookInObject,
i,
cellId);
}
else
{
ReplaceOwnerIds(
_cellParticleOwnerScratch,
_cellStaticScratch);
}
int routeIndex = lookInRouteIndex++;
passes.UseCellPortalViewRouting(cellId, slice);
_oneCell.Clear();
_oneCell.Add(cellId);
passes.DrawOpaqueCellShells(_oneCell);
if (passes.CellHasTransparentShell(cellId))
passes.DrawTransparentCellShells(_oneCell);
if (frameEntityPasses is not null
|| _cellStaticScratch.Count > 0)
{
_candidateObserver?.ObservePViewBucket(
CurrentRenderPViewRoute.LookInObject,
i,
cellId,
_cellStaticScratch);
DrawEntityRouteOrLegacy(
ctx,
passes,
frameEntityPasses,
in frameView,
RenderFrameCandidateRoute.LookInObject,
i,
cellId,
_cellStaticScratch,
_oneCell);
if (frameEntityPasses is not null)
{
RenderFrameRouteOwnerSelector.Replace(
_cellParticleOwnerScratch,
in frameView,
RenderFrameCandidateRoute.LookInObject,
routeIndex,
cellId);
}
else
{
ReplaceOwnerIds(
_cellParticleOwnerScratch,
_cellStaticScratch);
}
// The cell-particles pass for look-in cells — retail's
// nested DrawCells draws objects WITH their emitters.
foreach (var slice in GetCellSlicesOrNoClip(clipAssembly, cellId))
if (frameEntityPasses is not null
|| _cellStaticScratch.Count > 0)
{
_candidateObserver?.ObservePViewBucket(
CurrentRenderPViewRoute.LookInObject,
routeIndex,
cellId,
_cellStaticScratch);
DrawEntityRouteOrLegacy(
ctx,
passes,
frameEntityPasses,
in frameView,
RenderFrameCandidateRoute.LookInObject,
routeIndex,
cellId,
_cellStaticScratch,
_oneCell);
// The nested DrawCells object pass includes emitters and
// retains the exact setup_view clip until alpha playback.
passes.DrawCellParticles(ctx, new RetailPViewCellSliceContext(
cellId, slice, _cellParticleOwnerScratch));
}
}
}
// The ordinary exterior building shell is clipped by the outer
// outside_view, not by the nested cell PortalList.
passes.UseIndoorMembershipOnlyRouting();
// Retail's ordinary shell pass immediately follows this same
// building's portal-only pass. Pair by the shell's authored
// anchor EnvCell; never let an unrelated building repaint a
// look-in merely because both happen to be nearby.
int sliceIndex = 0;
foreach (ClipViewSlice slice in clipAssembly.OutsideViewSlices)
{
int shellRouteIndex = LookInBuildingShellRouteIndex(
frameIndex,
outsideSliceCount,
sliceIndex);
_buildingShellScratch.Clear();
if (partition is not null)
{
foreach (WorldEntity entity in partition.OutdoorStatic)
{
if (!entity.IsBuildingShell
|| FindLookInFrameIndex(
entity.BuildingShellAnchorCellId ?? 0,
_lookInFrames,
ctx.Cells) != frameIndex)
{
continue;
}
EntitySphere(entity, out Vector3 center, out float radius);
if (viewcone.SphereVisibleInOutsideSlice(
sliceIndex,
center,
radius))
{
_buildingShellScratch.Add(entity);
}
}
}
_candidateObserver?.ObservePViewBucket(
CurrentRenderPViewRoute.LandscapeBuildingShell,
shellRouteIndex,
0,
_buildingShellScratch);
bool hasPackedShell = frameEntityPasses is not null
&& HasExactRoute(
in frameView,
RenderFrameCandidateRoute.LandscapeBuildingShell,
shellRouteIndex,
0);
if (hasPackedShell || _buildingShellScratch.Count > 0)
{
RenderFrameEntityDrawRequest? shellDraw =
frameEntityPasses is null
? null
: new RenderFrameEntityDrawRequest(
frameView,
RenderFrameCandidateRoute.LandscapeBuildingShell,
shellRouteIndex,
0,
ctx.PlayerLandblockId ?? 0);
passes.DrawLandscapeBuildingShellSlice(
ctx,
new RetailPViewLandscapeBuildingShellSliceContext(
slice,
_buildingShellScratch)
{
EntityDraw = shellDraw,
});
_lateParticleOwnerScratch.Clear();
if (frameEntityPasses is not null)
{
RenderFrameRouteOwnerSelector.Replace(
_lateParticleOwnerScratch,
in frameView,
RenderFrameCandidateRoute.LandscapeBuildingShell,
shellRouteIndex,
0);
}
else
{
ReplaceOwnerIds(
_lateParticleOwnerScratch,
_buildingShellScratch);
}
passes.DrawLandscapeStaticParticles(
ctx,
new RetailPViewLandscapeStaticParticleContext(
slice,
_lateParticleOwnerScratch));
}
sliceIndex++;
}
}
}
@ -598,18 +711,13 @@ public sealed class RetailPViewRenderer
if (clipAssembly.OutsideViewSlices.Length == 0)
return;
// #131/#132 (the FlushAlphaList deferral): retail collects ALL alpha
// draws of the landscape stage and flushes them ONCE after LScape::draw
// (D3DPolyRender::FlushAlphaList, DrawCells pc:432722) — so translucent
// landscape content (portal swirl meshes, flame particles) composites
// AFTER the building look-ins. Our dispatcher draws translucency inside
// each Draw call, so the stage is split in TWO phases instead: EARLY =
// sky + terrain + outdoor STATIC meshes (the look-in punches need their
// depth to mark against, the #117 lesson); then the look-ins; then
// LATE = outside-stage dynamics' meshes + ALL scene particles +
// weather. Content drawn early and overlapped by a look-in aperture
// was otherwise overpainted by the far interior (translucents write no
// depth to protect themselves) — the portal-swirl/candle-flame class.
// #131/#132: retail drains the remaining landscape alpha after
// LScape::draw (DrawCells pc:432720), while each DrawBuilding is also
// an earlier alpha barrier before its portal traversal (pc:427954).
// Our dispatcher batches outdoor content, so the stage is split into:
// EARLY sky/terrain/static meshes; an optional pre-look-in static-alpha
// barrier; building look-ins; then LATE outside-stage dynamics,
// remaining particles, and weather; followed by the outer flush.
int probeSliceIndex = 0;
foreach (var slice in clipAssembly.OutsideViewSlices)
{
@ -628,6 +736,14 @@ public sealed class RetailPViewRenderer
{
foreach (var e in partition.OutdoorStatic)
{
if (e.IsBuildingShell
&& FindLookInFrameIndex(
e.BuildingShellAnchorCellId ?? 0,
_lookInFrames,
ctx.Cells) >= 0)
{
continue;
}
EntitySphere(e, out var c, out float r);
if (viewcone.SphereVisibleInOutsideSlice(
probeSliceIndex,
@ -663,15 +779,81 @@ public sealed class RetailPViewRenderer
});
}
// Retail DrawBuilding flushes every alpha submission accumulated before
// the building immediately before its portal-only traversal
// (RenderDeviceD3D::DrawBuilding pc:427954-427956). That barrier is
// essential at open-air seams: foliage and static emitters encountered
// before the building must not be flushed after the look-in cell floor
// and repaint it. Our outdoor statics are one retained batch rather than
// retail's BSP-by-building walk, so use one barrier before the first
// look-in; DrawBuildingLookIns adds the corresponding barrier between
// each later building pair. Submit the early static owners' particles
// into the same alpha queue first; their mesh alpha was already
// submitted by the EARLY entity route above.
bool hasBuildingLookIns = _lookInFrames.Count > 0;
if (hasBuildingLookIns)
{
int barrierSliceIndex = 0;
foreach (var slice in clipAssembly.OutsideViewSlices)
{
// Ownerless outdoor emitters cannot ride an entity route. Retail
// draws their meshes once for every installed outside_view;
// retain that slot through deferred alpha playback.
passes.DrawUnattachedSceneParticles(ctx, slice);
_lateParticleOwnerScratch.Clear();
if (partition is not null)
{
foreach (var e in partition.OutdoorStatic)
{
if (e.IsBuildingShell
&& FindLookInFrameIndex(
e.BuildingShellAnchorCellId ?? 0,
_lookInFrames,
ctx.Cells) >= 0)
{
continue;
}
EntitySphere(e, out var c, out float r);
if (viewcone.SphereVisibleInOutsideSlice(
barrierSliceIndex,
c,
r))
{
_lateParticleOwnerScratch.Add(e.Id);
}
}
}
if (frameEntityPasses is not null)
{
RenderFrameRouteOwnerSelector.Replace(
_lateParticleOwnerScratch,
in frameView,
RenderFrameCandidateRoute.LandscapeOutdoorStatic,
barrierSliceIndex,
0);
}
passes.DrawLandscapeStaticParticles(
ctx,
new RetailPViewLandscapeStaticParticleContext(
slice,
_lateParticleOwnerScratch));
barrierSliceIndex++;
}
passes.FlushLandscapeAlpha();
}
// #124: far-building look-ins draw HERE — still inside the landscape
// stage (their punches mark against the terrain/exterior depth just
// drawn), strictly BEFORE the depth clear + seals below, matching
// drawn), strictly BEFORE the outer depth clear + seals below, matching
// retail's LScape::draw placement (DrawCells pc:432719 vs 432732/432785).
DrawBuildingLookIns(
ctx,
passes,
clipAssembly,
partition,
viewcone,
frameEntityPasses,
in frameView);
@ -687,8 +869,8 @@ public sealed class RetailPViewRenderer
passes.ClearClipRouting();
_outdoorStaticScratch.Clear(); // late: dynamics survivors
_lateParticleOwnerScratch.Clear(); // late: statics + dynamics survivors
if (partition is not null)
_lateParticleOwnerScratch.Clear(); // late: dynamics, plus statics without look-ins
if (!hasBuildingLookIns && partition is not null)
{
foreach (var e in partition.OutdoorStatic)
{
@ -699,15 +881,6 @@ public sealed class RetailPViewRenderer
r);
if (ownerPass)
_lateParticleOwnerScratch.Add(e.Id);
// #131 owner watchlist (throwaway): ACDREAM_DUMP_ENTITY ids
// double as an ENTITY-id watchlist here — one line per watched
// outdoor-static owner per CHANGE of its cone verdict.
passes.EmitOutStageOwner(
e,
c,
r,
probeSliceIndex,
ownerPass);
}
}
foreach (var e in _outsideStageDynamics)
@ -721,12 +894,19 @@ public sealed class RetailPViewRenderer
}
if (frameEntityPasses is not null)
{
RenderFrameRouteOwnerSelector.Replace(
_lateParticleOwnerScratch,
in frameView,
RenderFrameCandidateRoute.LandscapeOutdoorStatic,
probeSliceIndex,
0);
if (hasBuildingLookIns)
{
_lateParticleOwnerScratch.Clear();
}
else
{
RenderFrameRouteOwnerSelector.Replace(
_lateParticleOwnerScratch,
in frameView,
RenderFrameCandidateRoute.LandscapeOutdoorStatic,
probeSliceIndex,
0);
}
RenderFrameRouteOwnerSelector.Union(
_lateParticleOwnerScratch,
in frameView,
@ -734,10 +914,6 @@ public sealed class RetailPViewRenderer
probeSliceIndex,
0);
}
passes.EmitOutStageRouting(
probeSliceIndex,
_outsideStageDynamics,
viewcone);
_candidateObserver?.ObservePViewBucket(
CurrentRenderPViewRoute.LandscapeOutsideDynamic,
probeSliceIndex,
@ -766,13 +942,19 @@ public sealed class RetailPViewRenderer
// #131: UNATTACHED emitters (AttachedObjectId == 0 — portal swirls,
// campfires, ground effects anchored at a position) have no owner id
// to ride any of the id-filtered particle passes. The outdoor root
// has the dedicated T3 pass for them; an INTERIOR root had NO pass
// at all. Draw them ONCE per frame (not per slice — alpha particles
// must not double-draw, the #121 lesson), at the END of the landscape
// stage: after the clear they would z-fail against the doorway seal.
if (!ctx.RootCell.IsOutdoorNode)
passes.DrawUnattachedSceneParticles(ctx);
// to ride any of the id-filtered particle passes. Draw once per
// installed outside_view for BOTH root kinds, matching retail's
// landscape-stage placement and preserving the slot in each deferred
// draw. The former outdoor-root post-world tail ran after building
// cells and let exterior alpha repaint the cathedral transition.
// With no look-ins they drain at the end of the landscape stage; the
// look-in path submits them at its pre-building barrier so later opaque
// cell floors can cover them.
if (!hasBuildingLookIns)
{
foreach (ClipViewSlice slice in clipAssembly.OutsideViewSlices)
passes.DrawUnattachedSceneParticles(ctx, slice);
}
// Retail PView::DrawCells 0x005A4872 drains the landscape alpha list
// immediately after LScape::draw and before the optional depth clear.
@ -792,6 +974,58 @@ public sealed class RetailPViewRenderer
passes.UseIndoorMembershipOnlyRouting();
}
internal static int LookInBuildingShellRouteIndex(
int frameIndex,
int outsideSliceCount,
int sliceIndex) =>
checked((frameIndex * outsideSliceCount) + sliceIndex);
internal static int FindLookInFrameIndex(
uint buildingShellAnchorCellId,
IReadOnlyList<PortalVisibilityFrame> lookInFrames,
IRetailPViewCellSource cells)
{
if (buildingShellAnchorCellId == 0)
return -1;
LoadedCell? anchorCell = cells.Find(buildingShellAnchorCellId);
if (anchorCell is null)
return -1;
uint buildingKey = anchorCell.BuildingId ?? anchorCell.CellId;
uint landblockId = anchorCell.CellId & 0xFFFF0000u;
for (int frameIndex = 0; frameIndex < lookInFrames.Count; frameIndex++)
{
PortalVisibilityFrame frame = lookInFrames[frameIndex];
if (frame.SourceBuildingKey == buildingKey
&& frame.SourceBuildingLandblockId == landblockId)
{
return frameIndex;
}
}
return -1;
}
private static bool HasExactRoute(
in RenderFrameView view,
RenderFrameCandidateRoute route,
int routeIndex,
uint cellId)
{
foreach (RenderFrameCandidateRange range in view.RouteRanges)
{
if (range.Route == route
&& range.RouteIndex == routeIndex
&& range.CellId == cellId
&& range.Count > 0)
{
return true;
}
}
return false;
}
private void DrawExitPortalMasks(
RetailPViewFrameInput ctx,
IRetailPViewPassExecutor passes,
@ -1093,9 +1327,6 @@ public sealed class RetailPViewRenderer
int survivors = _allCellStatics.Count - survivorsBefore;
if (survivors > 0)
_cellObjCells.Add(cellId);
// BR-2 phantom-site probe (T3-updated): post-viewcone survivors.
passes.EmitPhantomObjects(cellId, survivors);
}
// ONE batched static-object draw for every visible cell (was N per-cell
@ -1196,6 +1427,7 @@ public sealed class RetailPViewRenderer
// T3 scratch lists (render thread only; cleared per use).
private readonly List<WorldEntity> _outdoorStaticScratch = new();
private readonly List<WorldEntity> _buildingShellScratch = new();
private readonly List<WorldEntity> _cellStaticScratch = new();
private readonly List<WorldEntity> _dynamicsScratch = new();
// #118: dynamics assigned to the OUTSIDE stage this frame (interior roots
@ -1210,8 +1442,6 @@ public sealed class RetailPViewRenderer
private bool LegacyPartitionDiagnosticsEnabled =>
_partitionObserver is not null
|| AcDream.Core.Rendering.RenderingDiagnostics.ProbeOutStageEnabled
|| AcDream.Core.Rendering.RenderingDiagnostics.ProbePhantomEnabled
|| AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled
|| AcDream.App.Streaming.EntityVanishProbe.Enabled;
@ -1361,10 +1591,14 @@ public interface IRetailPViewPassExecutor
ClipFrameAssembly AssembleClipFrame(
PortalVisibilityFrame portalFrame,
ClipFrameAssembly reuseAssembly);
void AppendLookInClipFrames(
IReadOnlyList<PortalVisibilityFrame> lookInFrames,
ClipFrameAssembly assembly);
void PrepareClipFrame(int terrainUploadCount);
void SetTerrainClip(ReadOnlySpan<Vector4> planes);
void ClearClipRouting();
void UseIndoorMembershipOnlyRouting();
void UseCellPortalViewRouting(uint cellId, ClipViewSlice slice);
void PrepareCellBatches(
RetailPViewFrameInput frame,
HashSet<uint> visibleCellIds);
@ -1380,23 +1614,23 @@ public interface IRetailPViewPassExecutor
ClipFrameAssembly clipAssembly,
ClipViewSlice slice,
int sliceIndex);
void EmitOutStageOwner(
WorldEntity entity,
Vector3 sphereCenter,
float sphereRadius,
int sliceIndex,
bool passed);
void EmitOutStageRouting(
int sliceIndex,
IReadOnlyList<WorldEntity> entities,
ViewconeCuller viewcone);
void EmitPhantomObjects(uint cellId, int survivorCount);
void DrawLandscapeSlice(RetailPViewFrameInput frame, RetailPViewLandscapeSliceContext context);
void DrawLandscapeStaticParticles(
RetailPViewFrameInput frame,
RetailPViewLandscapeStaticParticleContext context);
void DrawLandscapeBuildingShellSlice(
RetailPViewFrameInput frame,
RetailPViewLandscapeBuildingShellSliceContext context);
void DrawLandscapeSliceLate(RetailPViewFrameInput frame, RetailPViewLandscapeLateSliceContext context);
void ClearInteriorDepth();
void DrawExitPortalMask(RetailPViewFrameInput frame, RetailPViewCellSliceContext context);
void DrawLookInPortalPunch(RetailPViewFrameInput frame, RetailPViewCellSliceContext context);
void DrawUnattachedSceneParticles(RetailPViewFrameInput frame);
void DrawLookInPortalPunch(
RetailPViewFrameInput frame,
RetailPViewCellSliceContext context,
int portalIndex);
void DrawUnattachedSceneParticles(
RetailPViewFrameInput frame,
ClipViewSlice slice);
void FlushLandscapeAlpha();
void DrawCellParticles(RetailPViewFrameInput frame, RetailPViewCellSliceContext context);
void DrawDynamicsParticles(RetailPViewFrameInput frame, IReadOnlySet<uint> ownerIds);
@ -1719,9 +1953,27 @@ public readonly record struct RetailPViewLandscapeSliceContext(
internal RenderFrameEntityDrawRequest? EntityDraw { get; init; }
}
/// <summary>
/// Outdoor-static emitters submitted at retail's pre-building alpha barrier.
/// Mesh alpha for the same owners is already queued by the early landscape
/// entity route.
/// </summary>
public readonly record struct RetailPViewLandscapeStaticParticleContext(
ClipViewSlice Slice,
IReadOnlySet<uint> ParticleOwnerIds);
/// <summary>Retail DrawBuilding's ordinary exterior-shell pass, issued after
/// the same building's portal-only look-in traversal.</summary>
public readonly record struct RetailPViewLandscapeBuildingShellSliceContext(
ClipViewSlice Slice,
IReadOnlyList<WorldEntity> BuildingShells)
{
internal RenderFrameEntityDrawRequest? EntityDraw { get; init; }
}
/// <summary>#131/#132: the late landscape phase's per-slice payload —
/// outside-stage dynamics to mesh-draw, plus the full scene-particle owner
/// set (statics + dynamics cone survivors) the attached-emitter filter keys on.</summary>
/// outside-stage dynamics to mesh-draw, plus the particle owners not already
/// submitted at a pre-building barrier.</summary>
public readonly record struct RetailPViewLandscapeLateSliceContext(
ClipViewSlice Slice,
IReadOnlyList<WorldEntity> Dynamics,

View file

@ -30,6 +30,7 @@ internal readonly record struct CurrentRenderProjectionFingerprint(
internal enum CurrentRenderPViewRoute : byte
{
LandscapeOutdoorStatic,
LandscapeBuildingShell,
LandscapeOutsideDynamic,
LookInObject,
CellStatic,
@ -1023,7 +1024,14 @@ internal sealed class CurrentRenderSceneOracle :
CurrentRenderProjectionFingerprint x,
CurrentRenderProjectionFingerprint y)
{
int value = x.ProjectionClass.CompareTo(y.ProjectionClass);
// enum.CompareTo(enum) binds to Enum.CompareTo(object) and boxes
// BOTH operands per comparison; ProjectionClass has 3 values so
// this first key almost always ties and the boxing executes on
// essentially every comparison of the per-frame fingerprint sort
// (#432: ~6.2 MB/frame across the resident entity set whenever
// the automation-artifact oracle is constructed). Compare the
// underlying integral value instead — same order, no boxing.
int value = ((int)x.ProjectionClass).CompareTo((int)y.ProjectionClass);
if (value != 0) return value;
value = x.LandblockId.CompareTo(y.LandblockId);
if (value != 0) return value;

View file

@ -103,6 +103,9 @@ internal sealed class DirectionalShadowCasterFrame
private RenderProjectionClass[] _casterClasses = [];
private RenderProjectionId[] _denseIdScratch = [];
private RenderProjectionRecord[] _denseRecordScratch = [];
private int[] _sortIndices = [];
private ulong[] _sortKeys = [];
private DirectionalShadowCaster[] _sortScratch = [];
private readonly DirectionalShadowTransformSnapshot[] _transformChangeScratch =
new DirectionalShadowTransformSnapshot[
DirectionalShadowTransformChangeJournal.Capacity];
@ -164,14 +167,34 @@ internal sealed class DirectionalShadowCasterFrame
+ System.Runtime.CompilerServices.Unsafe.SizeOf<
KeyValuePair<RenderProjectionId, int>>()));
public void Build(in RenderSceneQuery query)
/// <summary>
/// #429 owner-approved pipelining: with
/// <paramref name="allowTopologyRebuild"/> false, a topology-stale frame
/// keeps the retained caster product and only refreshes transforms, so the
/// copy+classify cost moves off the streaming-churn frame that triggered
/// it. The deferral is best-effort: the FIRST build, a generation change,
/// and a transform journal that demands a full refresh (dense re-copy by
/// id would dereference removed scene entries) all rebuild immediately
/// regardless. While deferred, journal rows whose caster identity no
/// longer matches the retained topology are skipped instead of throwing —
/// the immediately following rebuild reconciles them.
/// </summary>
public void Build(in RenderSceneQuery query, bool allowTopologyRebuild = true)
{
ulong topologyRevision = query.DirectionalShadowTopologyRevision;
if (BuildSequence != 0
bool current = BuildSequence != 0
&& Generation == query.Generation
&& _topologyRevision == topologyRevision)
&& _topologyRevision == topologyRevision;
bool deferStale = !allowTopologyRebuild
&& !current
&& BuildSequence != 0
&& Generation == query.Generation
&& !RefreshRequiresFullCopy(in query);
if (current || deferStale)
{
int refreshes = RefreshChangedTransforms(in query);
int refreshes = RefreshChangedTransforms(
in query,
tolerateStaleTopology: deferStale);
Stats = Stats with
{
IndexCopies = 0,
@ -230,11 +253,7 @@ internal sealed class DirectionalShadowCasterFrame
for (int i = 0; i < dynamicCount; i++)
Add(_outdoorDynamicScratch[i]);
Array.Sort(
_casters,
0,
_casterCount,
DirectionalShadowCasterComparer.Instance);
SortCasters();
int refreshCasterCount = 0;
for (int casterIndex = 0; casterIndex < _casterCount; casterIndex++)
{
@ -375,7 +394,26 @@ internal sealed class DirectionalShadowCasterFrame
}
}
private int RefreshChangedTransforms(in RenderSceneQuery query)
/// <summary>
/// Pure pre-check for the deferral gate: would refreshing from the journal
/// demand the dense by-id re-copy? The journal copy is a read; the state
/// consuming it (<see cref="_transformRevision"/>) only advances inside
/// <see cref="RefreshChangedTransforms"/>.
/// </summary>
private bool RefreshRequiresFullCopy(in RenderSceneQuery query)
{
if (query.DirectionalShadowTransformRevision == _transformRevision)
return false;
DirectionalShadowTransformChanges changes =
query.CopyDirectionalShadowTransformChanges(
_transformRevision,
_transformChangeScratch);
return changes.RequiresFullRefresh;
}
private int RefreshChangedTransforms(
in RenderSceneQuery query,
bool tolerateStaleTopology = false)
{
_changedCasterPoseCount = 0;
ulong latest = query.DirectionalShadowTransformRevision;
@ -397,6 +435,15 @@ internal sealed class DirectionalShadowCasterFrame
_lastTransformChanges = changes;
if (changes.RequiresFullRefresh)
{
if (tolerateStaleTopology)
{
// Unreachable through Build's deferral gate (it pre-checks via
// RefreshRequiresFullCopy); kept as a hard stop because the
// dense by-id copy below would throw on scene entries the
// stale topology still names.
throw new InvalidOperationException(
"A stale-topology refresh cannot perform the dense full re-copy.");
}
_lastBatchedProjectionCopyCalls = 1;
for (int index = 0; index < _refreshCasterSlotCount; index++)
{
@ -442,6 +489,17 @@ internal sealed class DirectionalShadowCasterFrame
{
continue;
}
if (tolerateStaleTopology
&& (records[index].Id != _casterIds[casterIndex]
|| records[index].ProjectionClass
!= _casterClasses[casterIndex]))
{
// A replaced scene entry (destroy + recreate under a new
// class) can journal against a retained slot while the
// topology rebuild is deferred; the rebuild on the next
// allowed frame reconciles it.
continue;
}
_changedCasterFlags[casterIndex] = true;
ValidateStablePose(in records[index], casterIndex);
_changedCasterPoses[_changedCasterPoseCount++] =
@ -525,6 +583,51 @@ internal sealed class DirectionalShadowCasterFrame
Array.Resize(ref values, capacity);
}
/// <summary>
/// #429 residual-stall fix: same packed-key index sort as
/// <c>DirectionalShadowPreparedDraws.SortSourceDraws</c>. The caster
/// comparer orders by the 64-bit traversal <c>SortKey.Value</c> with the
/// projection id as tie-break, so the key needs no packing at all —
/// almost every pair resolves on one integer compare and the sort swaps
/// 4-byte indices instead of the multi-hundred-byte caster records.
/// Equal keys fall back to the exact comparer plus an index tie-break,
/// preserving the previous total order.
/// </summary>
private void SortCasters()
{
int count = _casterCount;
EnsureCapacity(ref _sortIndices, count);
EnsureCapacity(ref _sortKeys, count);
EnsureCapacity(ref _sortScratch, count);
for (int i = 0; i < count; i++)
{
_sortKeys[i] = _casters[i].Projection.SortKey.Value;
_sortIndices[i] = i;
}
_sortIndices.AsSpan(0, count).Sort(
new CasterIndexComparer(_sortKeys, _casters));
for (int i = 0; i < count; i++)
_sortScratch[i] = _casters[_sortIndices[i]];
(_casters, _sortScratch) = (_sortScratch, _casters);
}
private readonly struct CasterIndexComparer(
ulong[] keys,
DirectionalShadowCaster[] casters) : IComparer<int>
{
public int Compare(int x, int y)
{
ulong left = keys[x];
ulong right = keys[y];
if (left != right)
return left < right ? -1 : 1;
int order = DirectionalShadowCasterComparer.Instance.Compare(
casters[x],
casters[y]);
return order != 0 ? order : x.CompareTo(y);
}
}
private sealed class DirectionalShadowCasterComparer
: IComparer<DirectionalShadowCaster>
{

View file

@ -13,6 +13,7 @@ internal enum RenderFrameBlendClass : byte
internal enum RenderFrameCandidateRoute : byte
{
LandscapeOutdoorStatic,
LandscapeBuildingShell,
LandscapeOutsideDynamic,
LookInObject,
CellStatic,

View file

@ -992,6 +992,8 @@ internal sealed class RenderScenePViewFrameProductController :
{
CurrentRenderPViewRoute.LandscapeOutdoorStatic =>
RenderFrameCandidateRoute.LandscapeOutdoorStatic,
CurrentRenderPViewRoute.LandscapeBuildingShell =>
RenderFrameCandidateRoute.LandscapeBuildingShell,
CurrentRenderPViewRoute.LandscapeOutsideDynamic =>
RenderFrameCandidateRoute.LandscapeOutsideDynamic,
CurrentRenderPViewRoute.LookInObject =>
@ -1157,7 +1159,21 @@ internal sealed class RenderScenePViewFrameBuilder
LoadSceneIndices(input.Scene);
BuildOutdoorRoutes(writer, in input);
BuildLookInRoutes(writer, in input);
int lookInRouteIndex = 0;
for (int frameIndex = 0;
frameIndex < input.LookInFrames.Count;
frameIndex++)
{
BuildLookInRoutes(
writer,
in input,
frameIndex,
ref lookInRouteIndex);
BuildLookInBuildingShellRoutes(
writer,
in input,
frameIndex);
}
BuildOutsideDynamicRoutes(writer, in input);
BuildCellStaticRoute(writer, in input);
BuildDynamicLastRoute(writer, in input);
@ -1279,6 +1295,14 @@ internal sealed class RenderScenePViewFrameBuilder
for (int i = 0; i < _outdoorCount; i++)
{
RenderProjectionRecord record = _outdoor[i];
if (record.EntityPayload.IsBuildingShell
&& RetailPViewRenderer.FindLookInFrameIndex(
record.Source.BuildingShellAnchorCellId,
input.LookInFrames,
input.Cells) >= 0)
{
continue;
}
Sphere(in record, out Vector3 center, out float radius);
if (!input.Viewcone.SphereVisibleInOutsideSlice(
sliceIndex,
@ -1304,22 +1328,84 @@ internal sealed class RenderScenePViewFrameBuilder
}
}
private void BuildLookInBuildingShellRoutes(
RenderFrameWriter writer,
in RenderScenePViewBuildInput input,
int frameIndex)
{
int sliceCount = input.ClipAssembly.OutsideViewSlices.Length;
for (int sliceIndex = 0; sliceIndex < sliceCount; sliceIndex++)
{
int count = 0;
EnsureCapacity(ref _survivors, _outdoorCount);
for (int i = 0; i < _outdoorCount; i++)
{
RenderProjectionRecord record = _outdoor[i];
if (!record.EntityPayload.IsBuildingShell
|| RetailPViewRenderer.FindLookInFrameIndex(
record.Source.BuildingShellAnchorCellId,
input.LookInFrames,
input.Cells) != frameIndex)
{
continue;
}
Sphere(in record, out Vector3 center, out float radius);
if (!input.Viewcone.SphereVisibleInOutsideSlice(
sliceIndex,
in center,
radius))
{
continue;
}
_survivors[count++] = record;
writer.AddOutdoor(in record);
AddProjection(
writer,
in record,
input.AnimatedEntityIds);
}
int routeIndex =
RetailPViewRenderer.LookInBuildingShellRouteIndex(
frameIndex,
sliceCount,
sliceIndex);
writer.AddRouteRange(
RenderFrameCandidateRoute.LandscapeBuildingShell,
routeIndex,
0,
_survivors.AsSpan(0, count));
}
}
private void BuildLookInRoutes(
RenderFrameWriter writer,
in RenderScenePViewBuildInput input)
in RenderScenePViewBuildInput input,
int frameIndex,
ref int routeIndex)
{
for (int frameIndex = 0;
frameIndex < input.LookInFrames.Count;
frameIndex++)
PortalVisibilityFrame frame = input.LookInFrames[frameIndex];
for (int i = frame.OrderedVisibleCells.Count - 1; i >= 0; i--)
{
PortalVisibilityFrame frame = input.LookInFrames[frameIndex];
for (int i = frame.OrderedVisibleCells.Count - 1; i >= 0; i--)
uint cellId = frame.OrderedVisibleCells[i];
var clipKey = new LookInClipCell(frameIndex, cellId);
if (!input.ClipAssembly.LookInCellToViewSlices.TryGetValue(
clipKey,
out ClipViewSlice[]? slices)
|| slices.Length == 0)
{
uint cellId = frame.OrderedVisibleCells[i];
int count = LoadCell(
input.Scene,
cellId,
includeDynamics: true);
continue;
}
int count = LoadCell(
input.Scene,
cellId,
includeDynamics: true);
for (int sliceIndex = 0; sliceIndex < slices.Length; sliceIndex++)
{
int currentRouteIndex = routeIndex++;
if (count == 0)
continue;
@ -1330,7 +1416,7 @@ internal sealed class RenderScenePViewFrameBuilder
input.AnimatedEntityIds);
writer.AddRouteRange(
RenderFrameCandidateRoute.LookInObject,
i,
currentRouteIndex,
cellId,
_cell.AsSpan(0, count));
}

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