Compare commits

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

266 commits

Author SHA1 Message Date
Erik
f7aa8e0eb7 fix: complete retail parity stability pass
All checks were successful
CI / linux-portable (push) Successful in 3m41s
CI / windows-gate (push) Successful in 6m49s
CI / release (push) Successful in 3m22s
2026-08-28 20:01:39 +02:00
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
Erik
ca4bae776c docs #429 + probe: frame stalls are pack-independent (~1.7/s both arms); the pack converts them into visible player jumps (59 vs 3); prediction-snap theory dead
All checks were successful
CI / linux-portable (push) Successful in 3m30s
CI / windows-gate (push) Successful in 6m22s
CI / release (push) Successful in 2m15s
Adds the TEMPORARY PlayerPresentationProbe (ACDREAM_PROBE_PLAYER_PRESENT)
per the #429 apparatus plan and records the measured two-arm evidence in
the issue. Probe strips with the fix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 21:21:04 +02:00
Erik
9deb1a28cf docs: file #430 (missing skill/attribute tooltips), #431 (attribute raise does not refresh derived skills/run speed); refine #429 with slow-run and circling observations
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 19:13:11 +02:00
Erik
eb4544a031 docs: file #429 — periodic local-player run hitch; GC/pack exonerated, prediction-vs-ACE speed snap is the lead
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 19:02:17 +02:00
Erik
133be4d1a8 fix(journal): platform-independent file-name sanitisation — CI linux-portable red since 2c2d57b2
All checks were successful
CI / linux-portable (push) Successful in 3m10s
CI / windows-gate (push) Successful in 5m41s
CI / release (push) Successful in 2m57s
Path.GetInvalidFileNameChars() is platform-dependent: on Linux it is
only '/' and NUL, so a backslash in a server or character name survived
sanitisation there and JournalFileTests.TheFileNameFollowsRetailsPattern
failed on the Linux runner while passing on Windows. Sanitise against a
fixed set (Windows' printable invalid chars plus all control chars) so
one name maps to one file name on every platform.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 18:46:09 +02:00
Erik
9cf15e1f13 perf(sky): ~10x cheaper night-sky lattice — one-round cell hash, 2x2x2 block, bit-sliced stars
Some checks failed
CI / linux-portable (push) Failing after 1m37s
CI / windows-gate (push) Successful in 6m35s
CI / release (push) Has been skipped
The owner measured 63% GPU at night vs 28% by day: the starfield
lattice was the whole difference. Three lossless cuts:
- one packed-multiply hash round per cell instead of ~9 chained PCG
  rounds (position/presence bit-sliced from one result, brightness/tint
  from a second);
- the 3x3x3 neighbourhood sweep becomes the 2x2x2 block around the
  sample — a star's visible footprint (a few px) is far smaller than a
  lattice cell on screen, so any star outside that block is over a full
  cell (>=15 px) away and contributes nothing;
- background mottle drops its invisible third octave.

Same densities, sizes, colours, and look; star positions reshuffle
(procedural layout, not authored). sky.frag.spv re-pinned.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 18:31:39 +02:00
Erik
9fcc3f4870 Revert "feat(sky): the night sky wheels with the Dereth clock; uniform time fade with a scoped twilight band"
This reverts commit 0dac024cdb.
2026-08-23 18:26:16 +02:00
Erik
0dac024cdb feat(sky): the night sky wheels with the Dereth clock; uniform time fade with a scoped twilight band
Rotation (user-directed): the procedural starfield rotates once per
Dereth day (~2 real hours - constellations visibly wheel through a
night) about a celestial pole ~41 deg above the northern horizon, plus
dayOfYear/360 of seasonal drift so the 360-day year changes the night
sky. One SkyParams float (272-byte block, layout test re-pinned)
carries dayFraction + dayOfYear/360 from the world clock; sky.frag
applies a Rodrigues rotation to the sample direction so stars and
mottle turn together. Impossible with retail's static stretched layer.

Fade rework (the 2026-08-23 two-screenshot gate finding): the
per-vertex vTint signal carried the sun-facing product and blanked
stars across the entire twilight half of the sky. The fade now reads
the UNIFORM ambient term - identical star visibility in every compass
direction, same dusk-to-dawn schedule - with one deliberate exception:
a thin suppression band hugging the low sky toward the sun's azimuth
while the sun term is strong, so stars still wash out inside the
actual twilight glow.

Guards updated (rotation anchor, uniform-fade anchor, 272-byte layout);
both sky SPIR-V hashes re-pinned.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 18:21:17 +02:00
Erik
91084b9a83 perf(sky): skip the night-sky lattice entirely while the day fade is zero
The enhanced sky multiplied the full 4-tier star lattice by a night
factor of 0.0 all day long. Early-out when the star layer's lighting
product says daytime, so the pack's night sky costs nothing outside
dusk-to-dawn. sky.frag.spv re-pinned.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 18:09:04 +02:00
Erik
b8cfee6d08 fix(sky): seamless night-sky star lattice — no face seams, no flare, round pixel-exact stars
The first night-sky gate (2026-08-23 screenshot) showed three defects:
glowing beams along the cube-face boundaries (fwidth blowup where
adjacent pixels land on different faces lights every neighbourhood star
solid), diffraction-spiked standouts the user rejects ('that is in a
photo only, not in real sky'), and ellipse-stretched stars from scalar
length(fwidth) sizing at oblique view angles.

One rewrite removes all three: stars now live on a seamless 3D lattice
over the unit sphere (no faces, so no seams by construction), each star
resolved through an exact tangent-plane -> screen-pixel 2x2 solve of
the direction derivatives (perfectly round, true pixel sizing at every
view angle, sharper cores), spikes deleted in favour of a soft round
halo on the bright tiers. Guard test updated to pin the new anchors and
forbid both fwidth-face grids and spikes; sky.frag.spv re-pinned.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 18:00:28 +02:00
Erik
508cefdeb1 feat(sky): pack-gated procedural night sky replacing the stretched DAT star layer; register IA-26
User-directed enhancement ('I want the night sky to look very good'):
retail's star layer is one small texture stretched over a 10-poly dome
cap, so stars smear regardless of source-image quality. With the
Atmospheric render pack active, sky.frag now renders the star layer
(GfxObj 0x010015EF, identical in all 20 Dereth day groups) as a fully
procedural sky computed from the view direction: hash-derived stars on
a cube-face grid in three density tiers plus sparse diffraction-spiked
standouts, sized in SCREEN pixels via derivatives so they stay crisp at
any resolution and FOV, over the user-approved 0.4-1.3% cool mottle
(gen_starfield2.py seed 11, approved 2026-08-23). The draw is forced
additive; the day/night fade rides the star layer's existing retail
lighting product so the schedule matches the authored keyframes. Pack
inactive = retail look byte-untouched.

EnhancedNightSkyRuleTests pins the uParamA gate, the exact star-layer
id, the forced-additive draw, and the pack-runtime wiring; sky shader
SPIR-V recompiled and re-pinned. Hermetic App suite green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 17:55:20 +02:00
Erik
6d8f165d38 docs: #28 aurora connected gate USER-PASSED (intensity delta accepted); file #428 sky-slot pose-leak diagnostic
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 17:20:22 +02:00
Erik
18fce7bb5a fix #28: port retail's sky default-script playback (aurora) and the particle facing law
The aurora was never missing data — it was a missing mechanism plus a
misread. New decompile evidence closes the April-2026 contradiction:
retail plays the sky carriers' PES through the Setup's own DefaultScript
(GameSky::MakeObject @0x00506EE0 -> CPhysicsObj::makeObject @0x00513970
sets state|=0x80000; animate_static_object @0x00513DF0 ticks
ScriptManager + ParticleManager). The pes_id column stays dead — that
half of the April finding stands; the ids are byte-equal mirrors.

- SkyPesFrameController is now the production owner (ACDREAM_ENABLE_SKY_PES
  deleted): script ids resolve from the Setup DefaultScript
  (SkyObjectData.DefaultScriptId; the pes_id column is a one-time-logged
  cross-check), slots persist by (index, gfx id, properties) per
  CreateDeletePhysicsObjects @0x005073C0 — a day-group swap keeping the
  carrier no longer restarts its emitters — and stale slots stop before
  replacements claim the slot-derived owner id.
- RetailParticleFacing ports calc_draw_frame @0x0050DFA0: degrade mode 2
  faces the viewer roll-free (set_vector_heading) instead of the camera
  plane; modes 3/4/5 spin the authored frame around one local axis
  (rotate_around_axis_to_vector) — Dereth authors 54 mode-5 emitters that
  previously got no facing at all; 1,583 mode-2 emitters get the exact
  law; authored/mode-1 paths are unchanged.
- The 2026-08-23 'whole-sky tint' was the Rainy-group lightning/thunder
  PES playing at the debug anchor inside their 0.03-0.19 window, not the
  aurora: the aurora is nine faint viewer-facing glows pulsing on
  6.7/15/55-minute rebirth cycles, in every day group, all day.

Research: docs/research/2026-08-23-sky-default-script-port.md.
Register: AD-112 filed (camera-anchored synthetic owners vs sky-cell
physics objects). ISSUES #2 corrected (the playback ban is lifted by the
new evidence); #28 fix landed pending the connected night gate.
Tests: RetailParticleFacingTests (16), SkyPesFrameControllerTests (6);
hermetic suites App 6,076/0, Core 4,905/0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 15:28:59 +02:00
Erik
6f47740af0 docs(ace-mods): SetTime loads via @mod find, no ACE restart needed 2026-08-23 14:34:43 +02:00
Erik
13f685ee94 tools(ace-mods): add @settime ACE mod for same-clock retail/acdream sky comparisons
Sky and weather gates need retail and acdream looking at the SAME Dereth
hour and the SAME authored day group; until now the only lever was
acdream's ACDREAM_WORLD_TIME/ACDREAM_DAY_GROUP pins, which retail cannot
follow, so the user could never judge night-sky differences (aurora,
dome edge) side by side.

@settime <hour|fraction> [Sunny|Rainy|Clear|Cloudy|<index>] jumps ACE's
Timers.PortalYearTicks FORWARD to the next matching instant. Every
TimeSync header carries that value, so both clients re-sync within
seconds. Forward-only because DelayActions compare against the same
clock (a backward jump would stall them). The calendar and day-group
roll duplicate acdream's retail ports (DerethCalendar shift 3600,
SkyDayGroupSelector LCG from SkyDesc::CalcPresentDayGroup @0x00500E10)
and the 20-entry name table was dumped from the installed Region DAT.

Install: copy SetTime.dll + Meta.json to C:\ACE\Mods\SetTime and run
@mod find (or restart ACE). Dev servers only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 14:34:37 +02:00
Erik
cc42edc8e3 fix #427: sky drawn without fog like retail; world fog range is the keyframe's authored MinWorldFog/MaxWorldFog
Two April stand-ins, neither registered, met at the horizon from altitude as
a hard line between the dome's rim and the fog-coloured clear:

1. sky.frag fogged every non-additive sky layer with a 0.2 floor. Retail's
   GameSky::Draw @0x00506FF0 disables fixed-function fog around the whole
   sky draw unless an AdminEnvirons fog override is active (SetFFFogEnable(
   LScape::m_override_enabled ? 1 : 0)); additive layers stay unfogged via
   SetFFFogAlphaDisabled(1) at D3DPolyRender::SetSurface 0x59c882. The sky
   pass now sets ApplyFog only for (override active && !additive), with no
   floor.
2. WorldRenderFrameBuilder overwrote the authored fog range with one derived
   from the streaming window (538..2189 m always). Retail sets FOGSTART/
   FOGEND straight from the keyframe's MinWorldFog/MaxWorldFog
   (SkyDesc::GetWorldFog @0x00500CE0 -> SetFFFogProperties @0x005A2F70) with
   no draw-distance scaling; zfar is a constant 4000 m. The builder now
   leaves SceneLightingUbo.Build's values alone; ACDREAM_FOG_START_MULT /
   _END_MULT are deleted from RuntimeOptions.

Guards: SkyFogRuleTests (source-level, the sky renderer has no hermetic
harness); sky.frag.spv re-pinned in VulkanShaderManifestTests with the
reason. Research note 2026-04-23-sky-fog.md carries a correction banner.
App hermetic 6,070/0, Core 4,707/0 (Release). Owner look gate owed: night
and rain fog are now retail's shorter authored ranges.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 13:42:42 +02:00
Erik
517d17b4b3 fix #426: extract solid-colour (NO_POS_UVS) faces; skip untextured subsets only on building shells and cells like retail
The Holtburg windmill axle (GfxObj 0x010010CE, 8 polygons, all
Stippling.NoPos + SurfaceType.Base1Solid) extracted to a 0-vertex mesh.
NoPos ("NO_POS_UVS", acclient.h:7380-7388) means "this side has no
texture coordinates" — true of every solid-colour polygon, since
nothing samples them — not "there is no positive face". Extraction read
it as the latter and dropped the polygon entirely, client-wide, for
every untextured polygon on every object.

Retail's D3DPolyRender::DrawMesh (@0x0059d4a0, named-retail decomp
~line 426048) draws an untextured subset on an ordinary object exactly
like a textured one; the only retail cases that skip an untextured
subset are a building shell (RenderDeviceD3D::DrawBuilding @0x0059f2a0
sets ObjBuildingOrBuildingPart=1) or an EnvCell interior
(RenderDeviceD3D::DrawEnvCell @0x0059f170, arg4=1). The #119
investigation's "retail's skipNoTexture never draws them either"
conclusion was itself wrong as a general rule.

- MeshExtractor.PrepareGfxObjMeshData / GfxObjMesh.Build: emit the
  positive side whenever PosSurface is a valid index, regardless of
  NoPos; the existing UV-index-0 fallback already produces zero
  texcoords for a NoPos polygon with no UVs on the wire.
- RetailUntexturedSurfacePolicy.IsUntextured(SurfaceType): the one
  place that answers "is this surface textured"
  ((type & (Base1Image|Base1ClipMap)) == 0), replacing the old
  `isSolid = NoPos || Base1Solid` (which also mis-classified a NEG-side
  batch by the POS-side's NoPos flag).
- RetailUntexturedSubsetPolicy.Draws(isBuildingShell, isUntextured):
  the shared draw-time gate wired into WbDrawDispatcher.ClassifyBatches,
  .PackedOracle.ClassifyPackedBatches, and
  .DirectionalShadows.AddDirectionalShadowBatches — one predicate so the
  three walks cannot drift (Campaign VM VM6 lesson).
- CellMesh.cs / MeshExtractor.PrepareCellStructMeshData deliberately
  KEEP their NoPos-gated skip for cell-wall geometry — retail's
  DrawEnvCell really does skip untextured subsets there; register row
  AP-234 documents the NoPos-vs-Surface.Type approximation.
- PakFormat.CurrentBakeToolVersion 4->5 (LauncherInstallRecordStore in
  lockstep): a pak baked by an older tool is missing every untextured
  face. No bake was run as part of this commit.

Also fixed: WorldBuilder's own upstream ObjectMeshManager.cs has the
identical NoPos bug (ObjectMeshManager.cs:959,984) — our port had
faithfully carried it over, and our own conformance test
(Build_NoPosFlag_OnlyEmitsNegSide) asserted the bug as correct WB
conformance. Renamed/reworded to Build_NoPosFlag_EmitsBothPosAndNegSide
with a citation for why retail decomp overrides WB here.

Issue119UpNullGfxObjDumpTests re-run against the installed DAT:
#119's own two objects (0x010002B4 9/9 polys, 0x010008A8 1/1 poly) now
gate DRAWS on every polygon instead of extracting to nothing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 11:20:24 +02:00
Erik
51a5fe99ef fix(anim): hold the last frame at a cyclic seam instead of blending into frame 0 (Holtburg windmill flap-back); register AP-233
The Holtburg windmill (Setup 0x020003E5, cycle 0x0300061B) is a 60-frame
quarter turn that loops by 4-blade symmetry: frame 59 and frame 0 are the
same picture but 88.5 deg apart. BuildBlendedFrame wrapped the cyclic
node's next-frame index to frame 0, so the seam slerped 88.5 deg backwards
inside one 33 ms interval - the blades visibly flapped back every two
seconds.

Retail never blends animation frames at all: CPartArray::UpdateParts
(0x005190F0) applies get_part_frame(floor(frame_number)), holding every
authored frame for its interval and hard-cutting at the wrap. The
render-side blend now holds the boundary frame at BOTH ends of a node's
window - the same rule the #61 link-tail fix already applied to one-shot
nodes - so every seam is retail's cut while interior frames stay smooth
(the owner's choice over dropping the blend, 2026-08-23). Register row
AP-233 records the blend as the deviation it has been since the R1-P5
cutover.

Test: Advance_CyclicSeamHoldsLastFrameInsteadOfBlendingIntoFrame0 (fails
on the previous code at ~45 deg, passes held at 90 deg). Core 4,696/0,
App 6,068/0 hermetic (Release).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 10:36:47 +02:00
Erik
2c2d57b25a docs: Campaign VM shipped — owner accepted #422 as carried and authorised the merge
Some checks failed
CI / linux-portable (push) Failing after 1m43s
CI / windows-gate (push) Failing after 7m6s
CI / release (push) Has been skipped
VM3 and VM6 user-accepted live at 2560x1440 (High); #424 and #425 fixed
during the gate. Roadmap entry flipped to shipped; plan ledger VM7 row
SHIPPED; #422 recorded as carried by owner decision.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 10:09:05 +02:00
Erik
06e64be72e docs(plan): VM3 linear-light gate user-accepted; VM3/VM6 closed (Campaign VM)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 10:04:41 +02:00
Erik
132395e6f7 fix #425: pack resident budget is a 1080p ceiling that scales with pixel count; an explicit Apply retries a failed selection
Live Holtburg at 2560x1440: the Low preset needed 67,368,164 resident bytes
(screen-sized HDR/depth/ray targets are 44 MB of that) against an absolute
64 MiB ceiling that had only been validated at 1080p, so Options -> Apply
fell back to the default path; every later Apply was then refused by the
controller's failure memo, which treated the user's deliberate choice like
automatic re-activation.

RenderPackResidentBudget.Effective scales the declared 1080p figure by the
viewport's pixel-count ratio (never below 1), still capped by the hardware
MaxPackResidentBytes; both pack graphs use it and the performance-matrix
tool judges its resident column by the same rule (contract test updated).
RenderPackController.Request gains explicitUserChoice, which clears the
memo for that selection; RenderPackSelectionBinding passes it on every
display edge (Apply, including resolution changes) and keeps the memo for
the startup request.

Tests: RenderPackResidentBudgetTests (1080p/720p keep the declared
ceiling, 1440p = 16/9x, 4K = 4x, hardware cap wins, zero extent rejected);
controller explicit-retry; the binding test now proves the user's next
Apply activates once the cause is gone. App hermetic lane 6,068/0 (Release).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 10:00:11 +02:00
Erik
38def07edb fix #424: a zero-area viewport skips the frame before any GPU work (alt-tab out of exclusive fullscreen crashed the pack-on client)
GLFW auto-iconifies an exclusive-fullscreen window on focus loss; for one
frame the window size reads 0x0 while the swapchain is still created, so
GameWindow.OnRender's PrepareFrame guard let a zero-area RenderFrameInput
through. The retail path tolerated it silently; the render-pack controller
correctly rejects a zero activation extent, which surfaced the latent frame
as an unhandled ArgumentOutOfRangeException during the owner's VM6/VM3 gate.

RenderFrameOrchestrator.Render now returns RenderFrameOutcome.ZeroArea
before BeginFrame when either dimension is <= 0 (no GPU frame, phase,
measurement, diagnostics or recovery runs) and GameWindow skips
NoteFrameClosed for it. Test: ZeroAreaViewport_SkipsTheFrameBeforeAnyGpuWork.

Verify: App hermetic lane 6,059/0 (Release).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 09:49:56 +02:00
Erik
b017391b98 docs(plan): VM6 wind gate user-accepted at Overcast (Campaign VM)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 09:45:24 +02:00
Erik
ba1c50c9c2 docs(plan): VM7 ledger row — automated rows passed, owner gates and merge owed (Campaign VM)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 05:26:04 +02:00
Erik
94a9f4537a docs(render): VM7 closeout — performance matrix PASS on fe56b6cf; #422 row updated (Campaign VM)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 05:25:47 +02:00
Erik
fe56b6cf32 tools(i422): parameterised debug-heap loop + plain rate loop; #422 re-characterised after it fired on the retail matrix row
The fault is pack-independent (fired on the retail preset, 1080p, 45 s,
first launch after a build), ~1 in 57 offline runs today, never under a
debugger (46 runs), no WER/event trace on this machine. The pre-campaign
binary cannot be measured with this gate (no in-process close verb).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 05:19:18 +02:00
Erik
621a0edfc6 docs(render): VM7 automated closeout note + roadmap entry (Campaign VM)
Release gate 15,283/0/0; connected lifecycle/reconnect route PASS pack-off
and High; VM0 invariance on the final binary (robust 5-10 px, lifestone
band); #422 0/40 instrumented runs. Performance-matrix section to follow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 04:46:04 +02:00
Erik
99d5b2a62f tools(i422): correct the debug-heap loop's cdb script path
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 04:32:57 +02:00
Erik
c69029bf58 tools(gate): -ExeOverride and -DebuggerScript for the offline pixel gate; #422 characterised over 40 instrumented runs (Campaign VM VM7)
-ExeOverride runs another AcDream.App.exe through the SAME isolated
settings/scene/capture path so two binaries compare without mixing tool
versions (VM0's recorded fieldOfView trap). -DebuggerScript launches the
client under cdb so ntdll's debug heap validates every free and a heap
corruption breaks at the corrupting site with a stack.

#422 (1-in-8 STATUS_HEAP_CORRUPTION exit after a pack-on offline capture):
16 runs attached + 24 runs under the debug heap on 1f151242, all clean
(P ~ 0.5 % at the filed rate). Recorded as not reproducible on the current
binary, carried as a watch item for the owner's VM7 matrix with the loop as
the first step if it recurs; no longer a merge blocker. Evidence under
docs/research/evidence/vm7 alongside the final pack-off-vs-base+normals
robust diff (5-10 px, lifestone band only).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 04:32:27 +02:00
Erik
6ac3cc1070 docs(plan): VM6 ledger row names the round-5 nit commit (Campaign VM)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 04:19:33 +02:00
Erik
1f15124271 test(render): foliage-wind receiver fallback guard asserts branch shape; cascade-0 publish covered (Campaign VM VM6 review 5 nits)
Round-5 narrow re-review of 754d59d9: APPROVE (verified to the SPIR-V
disassembly; shadows-on path proven opcode-identical). Four
non-blocking nits.

N1 (test robustness): T3 (ReceiverVertexShadersFallBackToThePlainLightDirectionWhenTheShadowFlagIsClear)
was satisfied by comment prose -- round 5's own explanatory comments
quote the exact plain-pipeline direction substring
("-uLights[i].dirAndRange.xyz, ... matched bit-for-bit"), so the
substring-only assertion passed even with the code mutated. Fixed:
comment lines are now stripped (new StripLineComments helper) before
any assertion, and a new ordered regex per shader asserts the exact
branch SHAPE -- shadowGatedOff ? -uLights[...] : normalize(uShadow...)
-- not just substring presence. Mutation-tested locally against the
rewritten test: (1) swapping the ternary's true/false operands --
FAILED (previously passed); (2) deleting the fallback entirely,
collapsing to the pre-round-5 buggy expression -- FAILED (previously
passed). Original file restored and reverified passing after each
mutation.

N2 (doc accuracy): "numerically the plain pipeline" overstated the
round-5 fix in three places (plan doc, mesh_atmospheric.vert,
terrain_atmospheric.vert). The direction expression is bit-for-bit;
the SUM is not, because the atmospheric shaders' split ambient+point
vs directional accumulation (and terrain's two varyings vs the plain
pipeline's one) reassociates float summation order by ~1 ulp -- which
is exactly the measured mean |Delta| 0.007 the coordinator's own
pixel-proof evidence already recorded (well under the 65 px noise
floor). All three rewritten to say the receiver "matches the plain
pipeline to within float summation-order rounding (measured mean
|Delta| 0.007 on the offline scene)."

N3 (coverage): RenderPrepared's own cascadeCount == 0 exit (the F2
fix) had no direct test even though RenderPrepared already has 8
direct call sites in this file. Added one: ResidentMaximumReachMeters
at/below CameraNearMeters, passed straight to RenderPrepared with an
otherwise-fully-valid environment (so the fitter, not the environment
gate, is what returns zero cascades), asserting IsBindableFor true /
IsValidFor false.

N4 (latent): RenderPrepared's OWN "if (!environment.ShouldRender)"
exit is a fourth bufferless-disabled path -- unreachable via Render
(whose own gate already validates ShouldRender first) but the same
shape, and RenderPrepared is called directly by tests and any future
caller. Took the preferred fix: publishes the disabled binding there
too, via the same helper, so every exit on a frame that draws the
world publishes when the pack's AtmosphericFrame is bound. Also made
EvaluateGateAndPublishDisabledBinding self-contained: it now resets
_currentFrameBinding to Disabled on its own entry instead of relying
on Render having done so first (idempotent with Render's own reset).

Regenerated SPIR-V: mesh_atmospheric.vert and terrain_atmospheric.vert
are comment-only changes (N2), so only the manifest's source hashes
changed -- compiled .spv bytes are unchanged, consistent with round 3's
precedent for comment-only shader edits.

Verify: Release build 0 warnings/0 errors. App hermetic-lane filter
6,055/0 failed. Full hermetic-filtered solution: 15,283/0 failed
across 15 projects.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 04:19:05 +02:00
Erik
9a49c66d47 docs(register): IA-24 names the receiver lighting-direction re-source and its gated-off fallback (Campaign VM VM6)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 04:02:16 +02:00
Erik
26adf75079 docs(render): VM6 code-complete — foliage-wind pixel proof, repeat-floor apparatus, ledger (Campaign VM)
Closes the automated half of slice VM6 at 754d59d9 and records how it was
proven. The first pixel method (clock pin 0 s vs 3 s, wind-off pair as the
control) is recorded as confounded: the treeline silhouette carries a
bimodal 0-or-~280 px rasterisation churn between runs of the same binary,
and two conclusions drawn from it were retracted. The replacement
apparatus (same pin, wind on vs off, two captures per arm, robust mask =
AND of the four cross pairs minus both repeat pairs, shadows off so only
geometry can move) found the round-4 defect (wind welded to the shadow
gate: 1 m wind = 49-65 px vs a 22 px floor while the CPU state was
correct) and, after round 5, gives 14,993 robust wind pixels vs a 65 px
floor, every one on a treeline tree, hillside tree or shoreline bush, with
the wind-off frame matching the pre-round-4 plain pipeline at mean |d|
0.007 (round 4's eec95535: 1.77, the overhead-sun regression the narrow
review caught).

tools/vm6/wind-pixel-proof.py is the analysis command the closeout names;
evidence overlays under docs/research/evidence/vm6. Plan: VM6 ledger row
CODE-COMPLETE 2026-08-23, owner visual gate owed (section VM6 Acceptance).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 04:01:18 +02:00
Erik
754d59d949 fix(render): shadow-gated-off receiver frames light from the authored sun again (Campaign VM VM6 review 5)
Round 4 (eec95535) fixed wind but introduced a new lighting bug: the
receiver VERTEX shaders source the sun direction from the shadow block,
not only the shadow visibility term. mesh_atmospheric.vert's
accumulateLights read uShadowLightDirectionAndSource unconditionally for
every directional light; terrain_atmospheric.vert did the same for its
single sun term. Round 4's PublishDisabledReceiverBinding writes
direction (0,0,1) into that block on every shadow-gated-off frame (user
sun-shadow-strength 0 in daylight, indoor/portal cover, night), so every
such frame was lighting outdoor terrain and objects from straight
overhead instead of the authored sun. Publishing the environment's real
direction would not have restored parity either -- the celestial shadow
source direction (sun/moon disc) is not the authored light direction.

F1 (BLOCKER): fixed in the shaders themselves, exact parity with the
plain pipeline. Both receiver verts now branch on the same flag bit
acdreamDirectionalShadowVisibility already reads
((uShadowTextureAndFlags.w & 1u) == 0u) and, when clear, use the EXACT
plain-pipeline expression instead of the shadow block's direction:
-uLights[i].dirAndRange.xyz in mesh_atmospheric.vert (matching
mesh_modern.vert, hoisted out of the light loop as a uniform branch);
-uLights[0].dirAndRange.xyz in terrain_atmospheric.vert (matching
terrain_modern.vert's sunDir/-sunDir form). The (0,0,1) word in the
disabled block stays as the documented normalize()-cannot-NaN guard; its
comment now says so explicitly since it is no longer read as a light
direction when the flag is clear.

F2: RenderPrepared's cascadeCount == 0 return is a third bufferless-
disabled path reachable from a frame that already passed Render's own
two gates (the cascade fitter can still find zero usable cascades) --
publishes the same disabled binding now, via the same
PublishDisabledReceiverBinding helper (re-signatured to take a bare
AtmosphericFrameBufferBinding so all three call sites -- Render's two
early-outs plus this one -- share it).

F3: removed a stray duplicated " -- Closeout and merge" fragment under
the plan's VM7 heading.

F4: corrected the false "the flag bit makes it numerically the plain
lighting sum" claim in the plan's round-4 paragraph and in
WbDrawDispatcher.DirectionalShadowReceivers.cs -- the flag bit alone
only fixed the shadow VISIBILITY term (already correct before round 4);
it took both that AND round 5's light-DIRECTION fallback to actually
match the plain pipeline.

T1: extracted Render's gate prologue (environment evaluate -> two
early-outs -> PublishDisabledReceiverBinding) into internal
EvaluateGateAndPublishDisabledBinding(frame, in input, out environment,
out environmentGateTicks), behaviour-preserving, called by Render before
it touches world/terrain -- the ArgumentNullException.ThrowIfNull(world)/
ThrowIfNull(terrain) calls keep their exact position relative to the
gate. No test in this suite constructs a real WbDrawDispatcher +
TerrainModernRenderer pair (still true), so this extraction is what
makes the gate itself testable; two new tests drive it directly with
PlayerInsideCell: true and with ResidentMaximumReachMeters <=
CameraNearMeters, asserting TryGetCurrentFrameBinding true / IsValidFor
false for both.

T2: proves the actual composition WbDrawDispatcher.PipelinesFor and
TerrainModernRenderer both use -- TryGetCurrentFrameBinding feeding
ShouldSelectReceiverPipeline -- selects the receiver pipeline for the
atmospheric world pass once a disabled binding is published, and still
refuses a non-atmospheric pass name.

T3: shader-source guard (same style as AtmosphericPostProcessGraphTests'
existing shader-text tests) pinning that both receiver verts contain the
flag-gated fallback and reference the same uLights expression the plain
verts use, so a future edit that drops the fallback fails this test
instead of only showing up in a pixel capture.

T4: the (0,0,1) test's doc comment and an inline assertion comment now
say the value is a NaN guard, not a light direction.

Regenerated SPIR-V: mesh_atmospheric.vert and terrain_atmospheric.vert
recompiled to different bytes this time (a real code change, not a
comment); manifest updated to match.

Verify: Release build 0 warnings/0 errors. App hermetic-lane filter
6,054/0 failed. Core.Tests 4,695/0 failed. RenderPackValidator 30/30.
Full hermetic-filtered solution: 15,282/0 failed across 15 projects.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 03:58:41 +02:00
Erik
eec9553582 fix(render): foliage wind no longer depends on the directional-shadow gate (Campaign VM VM6 review 4)
The reviewer's offline pixel apparatus found a real design defect, not a
test artefact: foliage wind was welded to "directional shadows rendered
this frame." Evidence: offline High preset, sun-shadow-strength=0,
wind-strength 2 + lean/branch 1 m — wind-on vs wind-off at the same
pinned clock differed by only 49-65 px, inside the apparatus's own 22 px
run-to-run noise floor (no measurable motion). A CPU probe independently
confirmed ResolveFoliageWind was correct (first advance snaps to Clear
0.25/0.15, gate 1, one graph) — the correct uniform never reached the
world pass.

Root cause: DirectionalSunShadowRenderer.Render's two early-out paths
(!environment.ShouldRender, ResidentWindowUnavailable) left
_currentFrameBinding at its pure Disabled (no-buffer) default.
WbDrawDispatcher.PipelinesFor and TerrainModernRenderer's matching
selection logic only choose the atmospheric receiver pipeline
(mesh_atmospheric, the only pipeline that #includes foliage_wind.glsl)
when TryGetCurrentFrameBinding returns true; with no buffer it always
returned false, so the world pass silently fell back to the plain
mesh_modern pipeline, which has no wind code at all. Because the shadow
gate is ActiveDayGroupMultiplier = dayGroupPolicy x elevationResponse x
strength, this killed wind every night (elevation response -> 0), at
user sun-shadow-strength 0, and under the portal/login cover.

Fix (decouple, not patch): DirectionalShadowFrameBinding gained
IsBindableFor ("a real current-frame allocation exists") separate from
IsValidFor ("...and it is Enabled with real shadow content" -- kept
exactly as VolumetricShaftRenderer's own gate needs it).
TryGetCurrentFrameBinding now returns IsBindableFor. When the built-in
pack supplies an AtmosphericFrame binding (declared packs never do, so
their receiver shaders -- which never declare set 3 binding 5 -- are
unaffected), Render's two early-out paths call a new
PublishDisabledReceiverBinding: it allocates one real ring slice and
writes a DISABLED DirectionalShadowUniforms block -- every matrix
Identity, every control/bias term zero, TextureAndFlags all zero (bit 0
clear is exactly what directional_shadow_receiver.glsl's
acdreamDirectionalShadowVisibility already reads as "no shadow, full
visibility" via its existing early return 1.0), and a unit light
direction (0,0,1) so a fragment shader's normalize() can never produce
NaN. BindDirectionalShadowReceiver and TerrainModernRenderer's
shadow-buffer bind now check Buffer is not null instead of Enabled, so
the disabled block actually gets bound once it is selected.

PublishDisabledReceiverBinding is internal (not private) specifically so
it is testable without standing up a real WbDrawDispatcher/
TerrainModernRenderer pair -- no test in this suite constructs either.
New tests: (a)/(b) PublishDisabledReceiverBinding is bindable-not-valid
with a bound AtmosphericFrame and a genuine no-op with an unbound one;
(c) BindDirectionalShadowReceiver emits both UniformDirectionalShadow and
UniformAtmosphericFrame binds for a disabled binding; (d)
VolumetricShaftRenderer's gate still reports NoCurrentDirectionalShadow
for a disabled binding. ShouldSelectReceiverPipeline itself is untouched
and its existing tests (parametrized directly on bindingValid) remain
valid; no existing test asserted the old "disabled shadows -> plain
pipeline / no binding" behaviour in a way this fix invalidates -- every
existing caller either bypasses Render (calls RenderPrepared directly)
or uses a stale-serial binding IsBindableFor still correctly rejects.

Verify: Release build 0 warnings/0 errors. App hermetic-lane filter
6,050/0 failed. Core.Tests 4,695/0 failed. Full hermetic-filtered
solution: 15,278/0 failed across 15 projects.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 03:29:57 +02:00
Erik
fccba8390d refactor(render): one group-creation seam, required foliage key field, first-frame wind snap (Campaign VM VM6 review 3)
Narrow re-review of a82959f1: APPROVE, with follow-ups. All items landed.

N2 (structural): (a) extracted the ONE shared InstanceGroup-from-key
construction seam, WbDrawDispatcher.CreateGroupFromKey(key, registration,
frame) — before this there were two near-identical `new InstanceGroup
{ ... }` initializers (GetOrCreateInstanceGroup and GetOrCreatePackedGroup)
that had already drifted once (the round-2 F1 bug). Both routes call it now;
CreateGroupFromKey's own `new()` is the only production InstanceGroup
construction site repo-wide, same precedent as AppendPackedInstance. (b)
GroupKey.FoliageFlags lost its `= 0u` default and moved before CullMode in
the declaration (CullMode keeps its default, C# requires optional params to
trail required ones), so a `new GroupKey(...)` that omits it is a compile
error. Fixed every real construction site the reorder/requirement touched:
the 2 production sites, ToKey (a reconstruction from InstanceGroup the
review didn't count but the reorder broke), and 5 test sites (one more than
the review's "4" — InstanceGroupClearTests had a second, implicit
target-typed `MakeKey` factory the original count missed). Verified by a
full solution build.

N1: added CreateGroupFromKey_CopiesFoliageFlagsFromTheKey
(InstanceGroupClearTests) — a key carrying FoliageFlags 0x2 in, the created
group's FoliageFlags 0x2 out. That test plus N2b's required field are what
actually guard the round-2 F1 blocker; reworded PackedDispatcherOracleTests'
existing test comment to say what IT proves (the classification-to-
BuildIndirectArrays-to-BatchData.flags path), not that it guards the
classifier.

N3: corrected the plan's round-2 paragraph — folding FoliageFlags into the
G2/G3 digest is correct and symmetric, but CompareClassifiedOutput only
runs from RenderScenePViewFrameProductController.BuildAndCompare, which has
no production caller anywhere in src/AcDream.App/, and both of
RenderScenePViewFrameProductTests's own callers construct the controller
without the optional dispatcher argument — so the fold catches nothing
until that oracle is wired to an actual caller.

N4: the plan's F6 note now names both classification caches — the classic
route's EntityClassificationCache.EntityCacheEntry (self-heals per entity
on its own next eviction) and the packed route's
PackedProjectionClassificationEntry/PackedClassifiedBatch.Key
(PackedProjectionClassificationCache.BeginFrame clears its entire cache in
one shot on a RenderSceneGeneration change) — and notes neither mechanism
is keyed to a pack switch specifically.

N5: deleted the now-unused single-generic ComputeEntityHasCutoutSubset<T>
overload; its 4 test call sites now use the two-generic, zero-alloc
overload with an unused int context and a static (_, value) => value
lambda, so there is exactly one ComputeEntityHasCutoutSubset to keep
correct.

A6 (reviewer-filed): ResolveFoliageWind's _windMean/_windGust started at 0
and always eased toward the weather target by clock delta, with no
distinction for a graph's first-ever advance. A pinned clock
(ACDREAM_SKY_PHASE_SECONDS, the offline pixel gate's determinism pin) never
advances between calls, so the wind reached only whatever fraction the
first (1-second-clamped) step produced and sat there forever; live, the
first 10 s after a graph is constructed (pack selection / login) spun up
from dead calm even though the weather already IS what it is. Fixed at the
root: the first advance (_windFrameSerial == -1, the constructor sentinel)
now snaps _windMean/_windGust straight to the target; every later advance
eases over WeatherSystem.TransitionSeconds exactly as before. Added a
SetWindClockSecondsOverrideForTesting seam (_windClockSecondsOverride is no
longer readonly) so a hermetic test can advance the pinned clock by an
exact amount between two resolves without a real-time Thread.Sleep; two new
tests prove the first-advance snap is exact and a second advance still
eases at the normal rate. The three existing indoor/wind-disabled/amplitude
gate tests pass unchanged.

Verify: Release build 0 warnings/0 errors. App hermetic-lane filter
6,046/0 failed. Core.Tests 4,695/0 failed. Full hermetic-filtered solution:
15,274/0 failed across 15 projects.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 03:03:56 +02:00
Erik
a82959f1b7 fix(render): production packed classifier carries the foliage flags; deferred-alpha replay keeps them (Campaign VM VM6 review 2)
Narrow re-review of 43e3abed found every A1-A5/A7/A8 item resolved but
one new blocker in the production packed classifier.

F1 (BLOCKER): RetailPViewPassExecutor.DrawPackedProductionRoute — the
route production world geometry actually draws from — never computed
FoliageFlags at all. WbDrawDispatcher.PackedOracle.cs's
ClassifyPackedBatches built its GroupKey with the field defaulting to
0u, and GetOrCreatePackedGroup never copied it onto the created
InstanceGroup, so production BatchData.flags bits 1/2 were always zero
for every scenery entity: the world geometry never swayed even though
the independently-classified shadow caster did, so shadows visibly
swayed under rigid trees. Both classifier call sites now compute
FoliageFlags via the identical FoliageWindClassification.Classify call
and entity-scoped HasCutoutSubset OR the classic (non-packed) path
uses, and GetOrCreatePackedGroup copies it exactly like
GetOrCreateInstanceGroup always has. The G2/G3 classified-output
digest (AddOpaqueSubmissionGroup/BuildTransparentSubmissionDigest) now
also folds GroupKey.FoliageFlags into its hash — present in the key
since round 1 but never actually read by either digest function, so a
content-level (not just group-count-level) classic-vs-packed
divergence is now caught.

F2 (medium): the delayed-alpha replay path (PrepareDeferredAlphaDraws)
hardcoded Flags = 1, dropping bits 1/2 for any group replayed through
it — a trunk instance promoted into the alpha-blend group mid-fade
(the #188 translucency-promotion case) would stop swaying for the
duration of its fade. Now 1u | key.FoliageFlags.

F3 (nit): ComputeEntityHasCutoutSubset's three call sites (classic,
caster, and the newly-fixed packed classifier) each allocated a
closure over _meshAdapter per Setup entity per frame. A new
context-taking overload passes the mesh adapter as an explicit
argument to a static lambda instead, letting the compiler cache one
delegate for the method's lifetime rather than allocating fresh ones.

A3 test gap: WbDrawDispatcher.BindDirectionalShadowReceiver is now
internal so DirectionalShadowGpuTests can drive it directly with a
bare RecordingGpuDevice pass encoder, proving it emits
UniformAtmosphericFrame with the exact buffer/offset/size a
DirectionalShadowFrameBinding carries — paired with the existing test
proving that binding carries the caster's real bind forward untouched.

F1's missing test: PackedDispatcherOracleTests chains
FoliageWindClassification.Classify (called with the packed
classifier's exact argument shape) for a real 0x8... scenery entity id
through BuildIndirectArrays — the same shared, already-tested
production step both classic and packed group lists feed into BatchData —
proving the resulting flags word carries bit 0x2. Driving
ClassifyPackedBatches/GetOrCreatePackedGroup directly was not a "cheap
test": both are private instance methods reachable only through the
full RetailPViewPassExecutor route, which needs a real IGpuDevice,
world-pass scope, mesh manager, and compiled pipelines to construct —
no test anywhere in the App test project stands one up.

Nits: F4 corrects foliage_wind.glsl's header comment from "bit 31" to
the top-nibble test; F5 documents at the receiver bind site that the
caster's own AtmosphericFrameBufferBinding has its seven ABI v1
members zero/Identity by construction (only the two v2 wind members
are valid) — safe today because mesh_atmospheric.vert reads that
binding solely for wind displacement, flagged as a footgun for a
future v1-reading addition to that shader; F6 notes in the plan
(rather than fixes) that EntityCacheEntry does not proactively
invalidate when FoliageWindExclusions changes on a pack switch —
harmless with the pack off, self-heals on the entry's next natural
eviction.

foliage_wind.glsl's F4 comment-only change updated the SPIR-V
manifest's source hashes for the five includers (mesh_atmospheric.vert
+ four directional_shadow_world_* casters); the compiled .spv bytes
are byte-identical since comments do not affect bytecode.

Verify: Release build 0 warnings/0 errors. App hermetic-lane filter
6,043/0 failed. Core.Tests 4,695/0 failed. RenderPackValidator 30/30.
Full hermetic-filtered solution: 15,271/0 failed across 15 projects.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 02:28:17 +02:00
Erik
43e3abed4d fix(render): correct foliage-wind classification, receiver/caster desync, and frame binding (Campaign VM VM6 review)
Opus dual-lens review of the three VM6 commits (0930c35d, 39e8408c,
6cc5e183) found two blockers and two should-fix issues; all landed here
along with the review's nits and documentation corrections.

Blockers:
- A1: the procedural-scenery classifier tested bit 31 alone instead of
  the full top nibble (0xF000_0000 == 0x8000_0000), so it also matched
  LandblockStaticEntityIdAllocator's 0xC... namespace (fences/gates/
  building shells with a cutout subset), the 0xDA11_D0xx paperdoll id,
  and the 0xFFFF_FF01 portal-tunnel id as procedural scenery — all
  three would have swayed. ProceduralSceneryIdAllocator.IsInNamespace
  now does the exact top-nibble test; FoliageWindClassification
  delegates to it.
- A2: GroupKey (the receiver's instance-batching key) did not carry
  FoliageFlags while the caster's dedup key already did, so a scenery
  instance and a non-scenery instance sharing a mesh subset coalesced
  into one receiver InstanceGroup whose flags were last-writer-wins —
  disagreeing with the correctly-keyed caster. GroupKey now carries
  FoliageFlags, computed before key construction and set exactly once
  at group creation; the imperative re-stamp is gone, and CachedBatch's
  now-redundant FoliageFlags field is removed.

Should-fix:
- A3: the world receiver pass bound UniformAtmosphericFrame only by
  accident (leftover from the caster pass, which runs first each
  frame, since Vulkan binding state isn't reset between passes).
  DirectionalShadowFrameBinding now carries the caster's exact
  AtmosphericFrameBufferBinding and BindDirectionalShadowReceiver binds
  it explicitly.
- A4: a Setup-composed tree's opaque trunk part never got the trunk
  flag because HasCutoutSubset is cached per GfxObj part, not per
  entity. FoliageWindClassification.ComputeEntityHasCutoutSubset now
  ORs HasCutoutSubset across an entity's resolved sibling parts once
  per entity, threaded into ClassifyBatches/AddDirectionalShadowBatches
  via a new optional override parameter.

Nits: A5 hashes the per-vertex flutter seed relative to the instance
origin instead of absolute world XY (fp32 sin() precision loss at far
landblock corners), mirrored in both foliage_wind.glsl and
FoliageWindModel; A7 documents the max(maxHeight, 0.5) divide-guard as
a deliberate pseudocode divergence; A8 switches FoliageWindExclusions'
construction to ToFrozenSet() and softens the "never stale" doc
comment to "no slower than one frame behind."

Tests added: top-nibble classification (0xFFFFFFFFu now correctly
false), GroupKey inequality across entity-driven scenery/landblock-
static classification, a caster-batch test proving the same pairing
never coalesces, ComputeEntityHasCutoutSubset unit + end-to-end
two-part-Setup tests, the caster→receiver AtmosphericFrame binding
carry-through, flutter-hash translation invariance relative to
instance origin, and a Storm-wind mid-height displacement floor
guarding against a "no motion" regression.

Docs: plan VM6 body corrected to the five-row WeatherKind table, "bits
1 and 2", "all four" caster shaders, and top-nibble wording throughout;
the owner gate checklist's Rain/Storm step; the stale v1-only shader-
interface compatibility entry; semantic-bindings-v1.md's v2 members
folded into the main 192-byte block; the IA-25 register row's top-
nibble wording; AtmosphericFrameInputs.cs's ABI size reference.

foliage_wind.glsl's A5 change recompiled exactly the five shaders that
include it (mesh_atmospheric.vert, the four directional_shadow_world_*
casters) plus the manifest; no other .spv changed.

Verify: Release build 0 warnings/0 errors. App hermetic-lane filter
6,041/0 failed (no environment-specific failures this run).
RenderPackValidator 30/30. Full hermetic-filtered solution: 15,269/0
failed across 15 projects.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 01:54:36 +02:00
Erik
27a446f05c docs(issues): #423 - ray/shadow/volumetric day-group policy keys on the raw index, not WeatherKind
Found at the VM6 spot-check; pre-existing from Campaign AR; values were
owner-accepted so it is filed for its own gate rather than changed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 01:08:32 +02:00
Erik
6cc5e183b9 fix(render): foliage wind keys on the DAT-classified WeatherKind, not the day-group index (Campaign VM VM6)
FoliageWindByDayGroup / FoliageWindDayGroupPoint(int ActiveDayGroup, ...)
becomes FoliageWindByWeather / FoliageWindWeatherPoint(string WeatherKind,
...) in AtmospherePolicyDeclaration (Plugin.Abstractions is BCL-only, so
the key is the exact member name of AcDream.Core.World.WeatherKind rather
than the enum itself). The raw activeDayGroup index carries no weather
meaning by itself; WeatherState.cs already classifies each day group's
authored DAT name into one of five real weather kinds, and that fact was
already threaded through AtmosphericFrameInputs.Weather / uAtmosphereWeather.x
— this reuses it instead of guessing an index-to-category mapping.

Built-in table (BuiltInAtmosphericRenderPack.AtmospherePolicy()): Clear
0.25/0.15, Overcast 0.60/0.35, Rain 0.85/0.60, Snow 0.35/0.20, Storm
1.00/0.75 — all five WeatherKind members declared, the invented "Cloudy"
row dropped. RenderPackAtmospherePolicyEvaluation.FoliageWind now takes a
WeatherKind and matches by weather.ToString() (ordinal) against each
declared point's name; a kind absent from the table falls back to the
declared Clear row, then to (0,0) if Clear itself is undeclared. The
delta-seconds EMA interpolation (EaseTowardTarget) is unchanged.
AtmosphericPostProcessGraph.ResolveFoliageWind and its two callers
(RenderPostProcess via inputs.Weather; RenderDirectionalShadows via
foundation.Atmosphere.Kind) now pass WeatherKind instead of the day-group
int.

RenderPackValidation.ValidateAtmosphere (runs for every pack declaring an
AtmospherePolicy, not gated to Tier2/shadow packs) now rejects an unknown
or non-exact-case weather-kind name and a repeated kind, mirroring the
existing ActiveDayGroupMultiplier duplicate-key check.

Tests: RenderPackAtmospherePolicyEvaluationTests rewritten for the
kind-keyed API (all five kinds resolve to their declared row, an unlisted
kind falls back to Clear, ordinal exact-case matching, null-table
handling); RenderPackSpirvValidatorTests gains four descriptor-validation
cases (unknown name, wrong case, duplicate kind, the five-kind table
accepted); AtmosphericPostProcessGraphTests' three foliage-wind cases now
select WeatherKind.Storm via `with` instead of an assumed day-group index.

Spot-check (per the coordinator's ask, not changed here): yes —
ActiveDayGroupMultiplier / EvaluateDayGroupPolicy (pre-existing, Campaign
AR/VM3-era — BuiltInAtmosphericRenderPack.AtmospherePolicy()'s three rows
`new ActiveDayGroupMultiplier(0, 1.0), (1, 0.35), (2, 0.20)`) key the
sun-ray/shadow/volumetric day-group strength multiplier by the same raw
activeDayGroup index with an undocumented assumed meaning (0=brightest ...
2=dimmest), the identical class of issue this commit fixes for foliage
wind. Left unchanged per instruction; flagging for the coordinator to file.

Full solution Debug and Release builds green. App hermetic filter
6024/6026 — the same 2 pre-existing failures as VM6a/VM6b. Both were
re-run in isolation per the verification ask: both still fail alone (not
a load-flake in this environment) — confirmed via git stash earlier this
session that both already fail on the unmodified pre-VM6 baseline, so
they are pre-existing and unrelated to this change. Core.Tests hermetic
4697/4697. RenderPackValidator.Tests 30/30. No shader/spv changes in this
commit (pure C#/docs fix).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 01:07:54 +02:00
Erik
39e8408c7d feat(render): weather-driven foliage wind for procedural scenery, shadows follow (Campaign VM VM6b)
Procedural-scenery foliage (trees/bushes — entity ids in the
ProceduralSceneryIdAllocator's 0x8XXYYIII namespace) sways with weather in
mesh_atmospheric.vert and all four directional_shadow_world_* caster vertex
shaders, both calling the identical new foliage_wind.glsl include so the
shadow moves with the leaf by construction.

Classification (FoliageWindClassification, AcDream.App.Rendering.Wb): two
new BatchData.flags bits, computed once per (entity, subset) from four
inputs — entity id (bit 31 for procedural scenery), the pack's declared
FoliageExclusions membership, the subset's TranslucencyKind, and
ObjectRenderData.HasCutoutSubset (computed once per mesh at build time, not
per frame). Bit 1 marks an alpha-cutout leaf subset; bit 2 marks an opaque
trunk subset (only when its own mesh also owns a cutout subset, so rocks
stay still). WbDrawDispatcher.ClassifyBatches (world receiver) and
AddDirectionalShadowBatches (caster) call this with the same four inputs, so
casters and receivers classify identically without needing to share state.
Retail's mesh_modern/terrain_modern/mesh_detail pipelines never read these
bits, so pack-off output is unaffected.

Motion model (foliage_wind.glsl, mirrored bit-for-bit in the new
FoliageWindModel for hermetic CPU tests): height-squared-scaled slow lean
for every foliage subset, plus branch swing and per-vertex-hash-decorrelated
flutter for cutout subsets only. AtmosphericPostProcessGraph.ResolveFoliageWind
resolves the wind block once per frame.Serial — advanced by whichever of
RenderDirectionalShadows (which runs first) or RenderPostProcess is called
first that frame, with the second reading the already-advanced state, which
is what keeps the caster and receiver reading byte-identical clock/strength
values. The per-day-group mean/gust target (AtmospherePolicyDeclaration.
FoliageWindByDayGroup, keyed by the same day-group index convention
ActiveDayGroupMultipliers already established: Clear/Cloudy/Overcast/Rainy)
eases toward its target over WeatherSystem.TransitionSeconds (10s) so a
weather change never snaps; wind-enabled off or indoor instead gates the
OUTPUT to an exact zero (not an asymptotic approach) so a settings toggle or
cell transition is immediate. The wind clock is a Stopwatch started at graph
construction (monotonic, session-relative magnitude for GPU sin() accuracy),
overridable by the same ACDREAM_SKY_PHASE_SECONDS pin SkyRenderer already
uses, for deterministic offline gates.

New settings: wind-enabled, wind-strength, wind-direction-degrees (225°
default — no authored retail wind direction exists to read),
wind-lean-metres, wind-branch-metres, wind-flutter-metres (0 on Low),
wind-canopy-height-metres.

Register row IA-25 files this as an intentional, strictly opt-in divergence:
retail applies no per-vertex wind displacement to any geometry. Known,
accepted limitation: classification is per mesh-subset (one BatchData.flags
word per indirect-draw batch), not per entity instance, so the rare case of
one mesh subset being reachable from both a procedural-scenery and a
non-scenery placement would classify all of that subset's instances alike.

Tests: FoliageWindClassificationTests (the full classification matrix),
FoliageWindModelTests (identity on non-foliage/calm-wind/base-vertex,
canopy-top displacement bound, z-never-increases, trunk has no flutter
term), RenderPackAtmospherePolicyEvaluationTests (exact day-group lookup,
no interpolation across day-group ids, easing convergence without overshoot
or discontinuity), AtmosphericShaderAbiTests (each of the five shaders calls
acdreamFoliageDisplace exactly once; mesh_modern/terrain/mesh_detail call it
never), and four AtmosphericPostProcessGraphTests additions (indoor/disabled
exact-zero gating, settings-to-UBO wiring, same-frame-Serial idempotency —
the last proxies the caster/receiver agreement invariant without needing
this hermetic harness's WbDrawDispatcher/TerrainModernRenderer dependency
chain to exercise RenderDirectionalShadows directly).

App hermetic filter: 6015/6017 (the same 2 pre-existing failures as VM6a,
confirmed unrelated). Core.Tests hermetic: 4697/4697. RenderPackValidator.Tests:
30/30. Full solution Debug and Release builds green. Shader recompile
touched exactly the 5 edited files' .spv (plus manifest); the retail oracle
set and every other pack shader are byte-identical.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 00:56:34 +02:00
Erik
0930c35d1d feat(render): shader ABI v2 - AtmosphericFrame gains clock/wind blocks; caster pass binds it (Campaign VM VM6a)
AtmosphericFrame (set 3/binding 5) grows additively from 160 to 192 bytes:
two appended vec4 members, uAtmosphereClockWind and uAtmosphereWindAmplitude,
carry the foliage-wind clock/weather and amplitude inputs VM6b's shader
displacement will read. RenderPackShaderAbi renames the old constant to
AtmosphericFrameSizeBytesV1 (160), adds AtmosphericFrameSizeBytesV2 (192),
keeps AtmosphericFrameSizeBytes pointing at the current (v2) size, and adds
ShaderAbiVersion = 2. RenderPackSpirvValidator.ValidateAtmosphericFrame
accepts either the v1 (seven-member, 160-byte) or v2 (nine-member, 192-byte)
shape and rejects anything else naming both — this is why the frozen
external sample packs under samples/*/Shaders/*.spv, whose GLSL sources are
not in this tree, need no rebuild: a v1 shader bound to the 192-byte buffer
still reads correctly, since a bound range only needs to be >= the block's
own declared size.

DirectionalSunShadowRenderer's caster pass now binds AtmosphericFrame too
(both the multiview and per-cascade sites), through a new
AtmosphericFrameBufferBinding the graph owns and supplies via
DirectionalSunShadowRenderInput. AtmosphericPostProcessGraph.RenderDirectionalShadows
builds its own 192-byte ring allocation for this, separate from the world
receiver's frame block, because the caster pass runs before RenderPostProcess
constructs that block within the same frame. The four world caster pipeline
variants (opaque/cutout, base/multiview) are now allowed to declare binding
5 in the validator; terrain casters are untouched.

This commit is plumbing only: the two new members are always written but
never read by any shader yet (zero placeholders), so pack-on and pack-off
output are both pixel-identical to before. VM6b wires the real weather-driven
values and the shader-side displacement.

App hermetic filter: 5972/5974 (2 pre-existing failures unrelated to this
change, confirmed against the unmodified baseline). Core.Tests hermetic:
4697/4697. RenderPackValidator.Tests: 30/30. VulkanShaderManifestTests
(retail oracle set): 7/7, byte-identical.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 00:30:02 +02:00
Erik
a0157693ec docs(vm3): closeout - owner gate brief with the measured curve table; #422 filed; tests pin the shipped defaults
Opus narrow re-review of 51178f7c: APPROVE. Residuals closed: the AR plan
no longer says '<=1 LSB' unqualified (99.99% of pixels; 95 foliage-
silhouette pixels up to 73 LSB, 58 isolated); the campaign doc says the
same; AtmosphericColorPipelineTests now read the SHIPPED exposure/vignette
defaults from BuiltInAtmosphericRenderPack.Descriptor and the graph's named
DefaultVignetteStrengthFallback instead of literals.

Measured for the gate (offline Holtburg hillside, High defaults vs pack
off): mean luminance -17% noon, -44% dusk, p95 unchanged, clip 0.06% both;
neutral High vs pack off: 110,561 px at |d|=1, 95 at >=5 (foliage edges).
Evidence images under docs/research/evidence/vm3/.

#422 filed: one High-default offline capture exited with
STATUS_HEAP_CORRUPTION after a clean managed shutdown; 1 in 8 runs, never
under validation layers. VM7 gate item.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 23:53:26 +02:00
Erik
51178f7c77 fix(render): VM3 review fixes - pin the filmic decodes, re-derive vignette for linear, name the authored sun colour
Fix round from Opus review of 87677f9c (APPROVE WITH FIXES):

1. (A1/A3) Hardened the atmospheric_filmic.frag / atmospheric_bloom_
   downsample.frag shader-source pinning test: asserts the exact decode
   call count (6 - three in lowFusedScene, three in main's non-fused
   branch), that the 2.2 display-gamma exponent and 1/2.2 inverse in
   atmospheric_common.glsl are formatted FROM AtmosphericColorPipeline
   .DisplayGamma (so shader literal and CPU-tested value cannot drift),
   that the 0.18 contrast pivot in atmospheric_filmic.frag is formatted
   from AtmosphericColorPipeline.LinearMidGrey, and pins
   AtmosphericPostProcessGraph.BloomKneeLinear/BloomThresholdLinear as
   exact literals (0.73f / 1f). Also removed a stray trailing "()" from
   an existing comment in atmospheric_filmic.frag that was inflating the
   decode-call count to 7.

2. (B1) Re-derived the "vignette-strength" default for the linear-light
   post stack. The vignette multiply now happens on linear colour before
   the final encode, so a corner factor of (1 - strength) displays as
   (1 - strength)^(1/2.2), not (1 - strength) directly. The accepted
   look was strength 0.12 under the OLD gamma-space pipeline: a 12%
   on-screen corner darkening. Under strength 0.12 in the new linear
   pipeline that same 0.88 corner multiplier would only display as
   0.88^(1/2.2) ~= 0.9435 (5.6% darkening - visibly weaker). Solving
   (1 - strength)^(1/2.2) = 0.88 gives strength = 1 - 0.88^2.2 ~= 0.245,
   which reproduces the accepted 12% corner darkening. Since
   RenderPackSettingValueCodec requires every declared default to be
   step-aligned from the minimum and 0.245 is not a multiple of the old
   0.01 step, the step also moves to 0.005 (a finer slider, not
   coarser) so the exact derived default is a valid grid point -
   verified by running the ExternalTierTwoPackCanRenameEveryOwnedId
   AndShaderAsset validation test, which failed with "invalid default
   value" before this correction. Also updated the matching fallback in
   AtmosphericPostProcessGraph.FromDescriptor (0.12f -> 0.245f) for
   consistency, and added
   AtmosphericColorPipelineTests.VignetteDefaultReproducesTheAccepted
   TwelvePercentCornerDarkening pinning encode(1-0.245) ~= 0.88.

3. (A4) Renamed VolumetricShaftFrameParameters.LinearSunColor ->
   AuthoredSunColor in VolumetricShaftQuality.cs (internal, 2 references,
   both in that file - safe). Left LightSource.ColorLinear unrenamed:
   grep shows 13 files depend on it (GlobalLightPacker, SceneLightingUbo,
   LightBake, LightManager, EnvCellRenderer, RenderingDiagnostics, and
   several Core tests) across the shared retail default-path lighting
   UBO pipeline - renaming it is out of VM3's pack-only scope and would
   touch the mandatory-unchanged default path. Added a pointer comment
   on the field in LightSource.cs (and a one-line note at its
   WorldRenderFrameBuilder.cs call site) documenting the same
   display-space-not-linear fact and explaining why the rename is
   deferred to its own default-path colour-space pass.

4. (B4) Added a citation beside acesFitted in both atmospheric_filmic
   .frag and its C# mirror (AtmosphericColorPipeline.AcesFitted):
   Krzysztof Narkowicz, "ACES Filmic Tone Mapping Curve" (2016). The fit
   takes linear scene light in and returns linear display light in
   [0,1] - it does not itself gamma-encode. Evidence: acesFitted(0.80 *
   decode(0.46)) = 0.2064 un-encoded versus the accepted 0.51 on screen.

5. (B2) Rewrote the VM3 section of docs/plans/2026-08-22-visualmaster-
   campaign.md with the shipped truth in place of the pre-implementation
   guess: exposure stays 0.80 (at exposure 1.0 the linear pipeline maps
   gamma-0.5 to 0.6017, essentially the same 0.6163 the owner called too
   bright), bloom threshold stays 1.0 (a fixed point of both exponents),
   knee moves 0.45 -> 0.73, vignette-strength moves 0.12 -> 0.245. Added
   the old-vs-new curve table at exposure 0.80 across ten gamma inputs.
   Replaced the acceptance criteria's "new automated test on the
   recording RHI" with the CPU mirror + shader-source pins actually
   used, and recorded that the real-frame masked capture WAS run
   (retail vs High-with-every-effect-neutral, artifacts/vm3):
   independently re-verified by re-running the pixel diff against the
   checked-in screenshots - 110,561 px at |delta|=1 and exactly 95
   pixels at |delta|>=5, confined to foliage-canopy silhouette edges
   against sky with nothing on any ground/building/water surface. Noted
   the Stage-1 luminance table re-capture is still owed at the owner
   gate.

6. (B3) Corrected docs/plans/2026-08-21-atmospheric-rendering.md's VM3
   summary sentence: the bloom intermediate is already linear after
   extraction (no separate "bloom read" decode), and the neutral-preset
   claim is now phrased as a measured numerical identity (<=1 LSB on a
   real frame) rather than an unqualified "is" statement.

7. (A5) Corrected toolchain attribution: tools/compile-shaders.ps1 used
   the managed Silk.NET.Shaderc path (shaderc_shared.dll) to compile in
   both this round and the original VM3 commit - a Vulkan SDK glslc was
   detected and its path recorded, but the managed compiler is what
   actually ran. Regenerating this round only changed the atmospheric_
   filmic frag stage's manifest hash (comment-only edits); the compiled
   .spv bytes are unchanged, and every retail-oracle shader
   (mesh_modern, terrain_modern, mesh_detail, etc.) remains untouched.

8. Replaced an invented motive in the atmospheric_filmic.frag contrast-
   pivot comment ("rounded up for a stronger gamma-space contrast
   feel") with the actual reason: the previous 0.5 was simply the [0,1]
   midpoint of the standard contrast formula, not a deliberately chosen
   value; in linear the perceptual mid-grey is 0.18.

Verify: Release build 0 warnings / 0 errors. App hermetic-filter tests:
5970 passed / 0 failed / 0 skipped. VulkanShaderManifestTests: 7/7 pass
(retail-oracle SPIR-V byte-identical; only the atmospheric_filmic frag
manifest hash changed, no .spv bytes changed).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 23:40:48 +02:00
Erik
87677f9c4f feat(render): Atmospheric post stack runs in linear light; neutral preset is numerically the pack-off image (Campaign VM VM3)
Closes review finding F4 (docs/research/2026-08-22-campaign-ar-review.md):
retail's main-world colour, sun rays and volumetric shafts are all
gamma-encoded display-space values (the 2013 client has no linear
lighting pipeline), but bloom thresholding, ACES (Narkowicz fit),
Rec.709 luma saturation, the contrast pivot and the vignette were all
operating directly on those gamma values, then writing the result to
the UNORM swapchain without re-encoding.

- atmospheric_common.glsl gains acdreamDecodeDisplay/acdreamEncodeDisplay
  (pow(c, 2.2) / pow(c, 1/2.2)). 2.2 is the retail-era CRT/early-LCD
  display-gamma assumption, deliberately not the sRGB piecewise curve,
  which would claim a precision retail's authoring pipeline never had.
  uAtmosphereSunColor's comment is corrected from "authored linear rgb"
  to "authored display-space rgb (retail has no linear pipeline)".
- atmospheric_bloom_downsample.frag, atmospheric_filmic.frag (both the
  fused-Low and non-fused paths) decode every world/ray/volumetric read
  before summing/thresholding; atmospheric_bloom_blur.frag is unchanged
  (it already reads the now-linear bloom buffer); atmospheric_sun_rays.frag
  and atmospheric_volumetric.frag are documented as writing display-space
  colour that the consumers decode.
- The contrast pivot moves from 0.5 (a gamma-space midpoint) to 0.18
  (linear mid-grey, the standard 18%-grey-card exposure convention).
  The final filmic output is clamped in linear, then re-encoded before
  the UNORM write.
- Bloom threshold/knee are re-derived for linear light: the pre-VM3
  gamma-space pair was threshold 1.0 / knee 0.45, i.e. a soft range of
  [0.55, 1.0] in gamma. Decoding both ends with the same 2.2 assumption
  gives decode(1.0) = 1.0 (threshold unchanged) and
  decode(0.55) = 0.55^2.2 ~= 0.27, so linear knee = 1.0 - 0.27 ~= 0.73.
  Replaced the inline 0.45f literals with named constants
  BloomThresholdLinear = 1f / BloomKneeLinear = 0.73f on
  AtmosphericPostProcessGraph. bloom-strength's 0.65 default is
  untouched.
- Exposure stays at its accepted 0.80 default: in linear,
  encode(acesFitted(0.80 * decode(0.46))) ~= 0.50, reproducing the same
  accepted midtone the old gamma-space pipeline produced as 0.51 for the
  same 0.46 input (0.46 * 0.80 fed straight into acesFitted, no
  decode/encode). Highlights now retain more (gamma 0.9 input moves from
  ~0.74 to ~0.85 through the full pipeline) and blacks deepen slightly
  (gamma 0.1 moves from ~0.09 to ~0.05) — the owner's visual gate judges.
- Added AtmosphericColorPipeline, a CPU mirror of the GLSL decode/encode/
  ACES/grade/filmic math (line-for-line, with a header comment requiring
  it stay mirrored), and AtmosphericColorPipelineTests: neutral-preset
  identity within half an 8-bit step for a 0..255 grey sweep (proving the
  neutral preset is numerically the pack-off image), decode/encode
  round-trip within 1e-6, monotonic-in-exposure, the pinned midtone/
  highlight/shadow numbers above, and the bloom-knee derivation.
- Added a shader-source pinning test so a future edit cannot silently
  drop the colour-space conversions: atmospheric_filmic.frag must
  contain exactly one acdreamEncodeDisplay( call in main()'s output,
  atmospheric_bloom_downsample.frag must contain at least three
  acdreamDecodeDisplay( calls.
- Regenerated SPIR-V (tools/compile-shaders.ps1, glslc from the
  installed Vulkan SDK). Only atmospheric_bloom_downsample.frag.spv and
  atmospheric_filmic.frag.spv changed in bytes; every other pack shader
  that includes atmospheric_common.glsl recompiled to a byte-identical
  binary (the new decode/encode helpers are unreferenced dead code for
  them). VulkanShaderManifestTests' retail-oracle SHA-256 set
  (mesh_modern, terrain_modern, mesh_detail, etc.) is untouched and
  still passes — the retail default path did not change.
- Docs: noted the linear-light move in the AR plan's Slice 1 section,
  and added a "Colour space" section to the render-pack ABI doc
  (docs/render-packs/semantic-bindings-v1.md) naming which inputs are
  display-space and pointing at atmospheric_common.glsl as the
  reference implementation. No ABI version bump — the binding layout
  is unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 23:10:57 +02:00
Erik
b1e9912535 fix(render): jitter the volumetric march per pixel (IGN); file #421 for the shadow transform buffer (Campaign VM VM5)
Fixed step-count volumetric ray marches (Low/Medium presets, e.g. 40 steps)
sample the same t values at every pixel, so a shadow-cascade edge crossing
lands at the same march step across a whole neighbourhood of pixels and
reads as visible concentric banding around light shafts. Offset each
pixel's march phase with interleaved gradient noise (Jimenez 2014,
fract(52.9829189 * fract(dot(gl_FragCoord.xy, vec2(0.06711056,
0.00583715))))) instead of the fixed 0.5 sub-step center: t = (step + ign)
/ steps. This decorrelates the step boundaries across pixels, turning the
banding into fine per-pixel noise that the existing quarter/half-res
upsample already averages away. Pack-on only — atmospheric_volumetric is
never used on the retail default path; extinction, colour, and early-outs
are unchanged.

Regenerated SPIR-V via tools/compile-shaders.ps1: only
atmospheric_volumetric.frag.spv and shaders.manifest.json's source hash
for that stage changed.

Also files #421 (docs/ISSUES.md) for Campaign AR review finding F7: the
directional-shadow pass's DirectionalShadowTransformBufferSet duplicates
the main pass's N.5 instance SSBO instead of binding it, to be resolved
once the planned GPU-culling step for shadow cascades lands. Updates the
VM5 ledger row and outcome note in
docs/plans/2026-08-22-visualmaster-campaign.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 22:56:03 +02:00
Erik
6e76314b08 docs(vm4): every Campaign AR document says what its evidence shows
Dated 'VM4 correction' notes, not silent rewrites:
- AR plan: NoOpRenderPackProductionIntegrationTests is a 2x2 recording-
  device fixture, not a production pin; the real invariance is VM0's.
  Both '14,928/14,928 zero skips' totals qualified: hermetic lane filter.
- Stage-2 connected report: the dense-town CPU figures ran under the
  automation observer (9.8 MB/frame alloc) and say nothing about the
  owner's hitch on the product; clean numbers are VM0's.
- Track A report + findings doc: TerrainUtils.GetNormal only orients
  scenery; the render normals were already smooth; A2 replaced central
  differences with retail's incident-face average (the reviewer's own
  premise error).
- Findings doc: 'retail brightens' is the fallback path only; real
  hardware runs the single-pass lerp (VM2). LandscapeDetailTextures = 0
  answers the landscape open question. VM0 production table added as the
  baseline.
- Completion audit retail-path row and the review's F1/F2/F3/F5 headers
  annotated with their closures.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 22:51:59 +02:00
Erik
dcdd102824 docs(vm1): closeout - AP-232 for the translucent detail blend weight; sampler test pins the production constant
Opus narrow re-review of ae651312: APPROVE. Closes its three residuals:
- AP-232 filed: retail's single-pass stage-1 OUTPUT alpha
  (MODULATE(TEXTURE, CURRENT) @0x0059c549) is the blend weight for a
  translucent subset; acdream's two-draw model is exact for opaque
  subsets (fog identity pinned) and a bounded weight difference on
  translucent ones. Distinct from AP-34 (queue order). Owed since
  05970306.
- TerrainAtlas.DetailSamplerDescription names the production sampler
  (WRAP/LINEAR x3 per ACRender::SetDetailSurfaceInternal @0x006b6280);
  the test now asserts that constant's properties instead of a
  test-local copy.
- Plan VM1 section: fragment now described as fogged; VM1 marked CLOSED
  with the Holtburg measurement (+2.17/+0.57/+0.16 vs predicted
  +2.2/+0.66/+0.16) and the detail-on cost (+0.3-0.5 ms CPU at Arwic).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 22:50:06 +02:00
Erik
ae6513126e fix(render): detail overlay is fogged after the combine like retail; VM1 review fixes
Opus dual-lens review of 05970306 + 388457a7 (APPROVE WITH FIXES). Four
items, all landed:

1. FOG (behavioural). Retail's D3D fixed-function fog stage runs AFTER the
   texture-stage pipeline, so the detail contribution must be fogged, not
   just the base. mesh_modern.frag already fogs the base colour
   (applyFog(rgb, vWorldPos)) before mesh_detail's replay draws over it;
   mesh_detail.frag previously emitted raw detail.rgb, understating fog by
   f*a*(fog-detail). Fix: mesh_detail.vert now outputs vWorldPos (mirroring
   mesh_modern.vert); mesh_detail.frag declares the identical SceneLighting
   UBO and applyFog function (copied verbatim, same binding/std140/math) and
   fogs detail.rgb before emitting it. This collapses algebraically to
   retail's fog-after-combine order:
     (1-a)*mix(base,fog,f) + a*mix(detail,fog,f) = mix(lerp(base,detail,a),fog,f)
   RetailDetailTextureContract gains ExpectedFogged(base,detail,opacity,fog,
   fogFactor); RetailDetailTextureContractTests pins the identity across 200
   random samples within 1e-6.

2. EnvCellRenderer.Rhi.cs's DrawEnvCell-category comment still said "apply
   the 10-50 m positive-view-depth fade" — a stale claim from before VM1
   removed the fade. Replaced with the mip-chain attenuation statement that
   mesh_detail.vert's header comment already carries.

3. Added the test the VM1 contract required but never had: TerrainAtlas
   .TryCreateDetailTexture uploads a full mip chain (MipLevelCount ==
   RhiWorldTextureArray.MipLevelsFor(w,h), GenerateMipChain called) and
   registers with the repeat/linear world sampler, not single-level or
   clamped. Drives the private method directly (reflection) against a
   synthetic PFID_A8R8G8B8 RenderSurface through a minimal in-memory
   IDatReaderWriter fake, so the lane stays hermetic (no installed DAT).

4. #226 pseudocode note: noted that retail's stage-1 OUTPUT alpha
   (MODULATE(TEXTURE, CURRENT), 0x0059c549) — the framebuffer blend weight a
   delayed-alpha subset composites with — is not modelled; acdream instead
   draws a second pass weighted by detail.a*diffuseAlpha. Identical for
   opaque subsets, a bounded difference on translucent building/EnvCell
   subsets already covered by the existing AP-34 shared-alpha-queue
   divergence row. Also qualified the tmpmaterial.Diffuse.a = 1f (0x0059cb99)
   citation to name its exact branch (burnedInStaticLights < 0 &&
   *(render_device+0x7e4) == 0); the other branch leaves diffuse FromVertex,
   but the opaque->1 / fading->opacity mapping still holds either way.

Nit also folded in: EnvCellRendererTests' new SubmitRhi instance-alpha test
is now a [Theory] over WbRenderPass.Opaque and .Transparent, pinning the
bind-before-first-draw invariant on both passes.

Regenerated mesh_detail's committed SPIR-V and the shader manifest
(tools/compile-shaders.ps1); no other shader pair changed.

Verified: dotnet build AcDream.slnx -c Release (0 warnings, 0 errors);
dotnet test on AcDream.App.Tests (Release, hermetic lanes) green, including
the shader manifest tests explicitly; AcDream.Core.Tests unaffected/green.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 22:39:58 +02:00
Erik
388457a735 fix(render): interior shell and detail passes bind their own instance opacity (Campaign VM VM1)
EnvCellRenderer.Rhi's SubmitRhi bound StorageInstances/StorageBatches/
StorageClipSlots/StorageGlobalLights/StorageInstanceLightSets every frame
but never GpuBindingModel.StorageInstanceAlpha (binding 7) — the SSBO
mesh_modern.vert reads as instanceAlpha[instanceIndex] (vOpacityMultiplier,
#188) and, as of Campaign VM VM1 (05970306), mesh_detail.vert now reads the
same way (vDetailOpacity). Without a bind of its own, both the interior
shell pass and the interior detail replay read whatever section
WbDrawDispatcher's own SubmitRhi last bound in the same pass — an unrelated
object's opacity array, indexed by these EnvCell instance ids.

This predates VM1 (6c79d35c has the same omission on the mesh_modern side);
VM1 must not widen a latent defect by adding a second unconditional reader
of the same unbound slot.

Fix, root cause, no guard: EnvCellRenderer now owns _instanceAlphaData, a
grow-only float[] parallel to _gpuInstanceTransforms (same pattern as
_clipSlotData/_lightSetData), filled with the constant 1.0f every frame —
EnvCell shells have no #188 TransparentPartHook translucency fade (that
mechanism fades object PARTS, never cells) — and bound at
GpuBindingModel.StorageInstanceAlpha alongside the renderer's other
per-frame ring sections, before any draw in the pass.

Test: EnvCellRendererTests.SubmitRhi_BindsConstantOneInstanceAlphaBeforeAnyDrawInThePass
drives SubmitRhi directly (reflection, mirroring the file's existing
private-method test pattern) with N seeded cell instances and one real
draw command, then asserts against RecordingGpuDevice that
StorageInstanceAlpha is bound with exactly N floats all equal to 1.0f, and
that the bind precedes the pass's first MultiDrawIndexedIndirect call.
Verified failing (StorageInstanceAlpha was never bound) with the fix
temporarily reverted, then passing restored.

Verified: dotnet build AcDream.slnx -c Release (0 warnings, 0 errors);
dotnet test on AcDream.App.Tests (Release, hermetic lanes) green,
5960/5960 (5959 baseline + 1 new test).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 22:13:33 +02:00
Erik
059703066f fix(render): #226 detail overlay uses retail's single-pass combine; drop the dead distance fade (Campaign VM VM1)
VM2's live cdb read against the PDB-paired retail client (GUID
9e847e2f-777c-4bd9-886c-22256bb87f32) proved
m_caps.bCanDoSinglePassDetailing = 1 and trysinglepass = 1 on real hardware,
so D3DPolyRender::RenderMeshSubset (0x0059ca10) never falls back to the
two-pass framebuffer blend the earlier #226 port reproduced. Every loaded
CGfxObj sets use_built_mesh = 1 (CGfxObj::InitLoad 0x005346b0), so buildings
and EnvCells always take the single-pass texture-stage combine set up in
D3DPolyRender::SetSurface (0x0059c4d0):

    result = lerp(base * diffuse, detail.rgb, detail.a * diffuse.a)

RenderMeshSubset lights opaque built-mesh subsets with
tmpmaterial.Diffuse.a = 1, so on the live Dereth category texture
0x06006D58 (mean rgb 0.165, mean alpha 0.132) the combine works out to
~0.868 * base + 0.022 — a mild darkening, the opposite sign of the fallback
DstColor blend's brightening.

Also removes the invented 10 m / 50 m distance fade. Retail's
ACRender::get_alpha_for_z (0x006b6230) is only evaluated in
D3DPolyRender::DrawPolyInternal (0x0059d7c0, the immediate-polygon path)
and only when the static noFadeDetail (0x00820e38, initialised to 1) is 0 —
unreachable for built meshes. Attenuation is the sampler's linear mip chain
converging to the texture mean, not a scripted ramp.

Changes:
- mesh_detail.vert/.frag: drop vDetailFade and its distance term; add
  vDetailOpacity mirroring mesh_modern.vert's InstanceAlphaBuf (binding 7)
  read, and output detail.rgb with alpha = detail.a * vDetailOpacity under
  the corrected pipeline blend.
- VulkanViewportMapping.BlendFactorsOf / GpuEnums.GpuBlendMode.RetailDetail:
  SrcAlpha + OneMinusSrcAlpha instead of DstColor + OneMinusSrcAlpha.
- RetailDetailTextureContract: replaced the distance-fade constants and
  FramebufferFactor with Expected(base, detail, opacity) and IsNeutral,
  matching the lerp; contract tests cover zero-alpha/zero-opacity no-ops,
  the measured darkening on the live category texture, and full-alpha
  replacement.
- Regenerated mesh_detail's committed SPIR-V and the shader manifest
  (tools/compile-shaders.ps1); no other shader pair changed.
- Docs: #226's pseudocode note, the docs/ISSUES.md #226 entry, and the
  retired TS-52 divergence-register row corrected from the two-pass
  DESTCOLOR description to the single-pass path and the darkening
  expectation, each citing the VM2 cdb note.

Verified: dotnet build AcDream.slnx -c Release (0 warnings, 0 errors);
dotnet test on AcDream.App.Tests and AcDream.Core.Tests (Release, hermetic
lanes) both green.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 21:58:48 +02:00
Erik
30e72a2af8 research(vm0): Campaign AR's pack-off path IS the pre-campaign renderer - PASS
Exact-pixel and production-perf comparison of 6c79d35c (+ only the A2
normal files, so no terrain mask is needed) against c51b07ef with no pack:
connected as +Acdream, visible window, one isolated config clone per
variant, pinned clocks. Open field: the only differences are idle pose,
mana digits and a passing flyer. Holtburg: same-binary-twice defines the
dynamic mask (9.8%); in the static 90% both self-diffs have ZERO pixels
with |d|>=8 while base+normals vs HEAD-off has 841/729 - all streaks inside
the animated lifestone. Buildings, ground, trees, sky and UI are clean.

Perf (uncapped Release, no automation observer, ACDREAM_FRAME_PROF=1):
Holtburg CPU p50 4.7 -> 4.1 ms, Arwic 6.0 -> 5.2 ms, GPU unchanged,
alloc/frame 574 KB -> 21 KB. No regression; F5b's '27.8 ms retail CPU' was
the observer.

Three false alarms recorded so nobody repeats them: the isolated gate
settings lack fieldOfView (90 vs the real 86.33 -> a 0.952 zoom); the real
%APPDATA% settings still selected acdream.atmospheric/low (pack ON); a
minimized GLFW window is throttled and never settles.

Tools: tools/vm0/capture-visible.ps1 (pre-campaign gate + -Exe/-Live/
-ConfigDir/-CharacterName/-PreCaptureCommand), tools/vm0/perf-run.sh,
and -BuildingDetailTextures on run-offline-pixel-gate.ps1. Baseline
patches under docs/research/evidence/vm0/.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 21:40:40 +02:00
Erik
4a69ecd32e docs(plans): Campaign VM carries the VM2 lesson - a branch that exists is not a branch that runs
Binding rule: when retail gates a mechanism behind a capability, preference,
or trysinglepass-style switch, the port is not done until the gate's live
value is cdb-read on the PDB-paired client and cited with the binary GUID.
Records that the owner's live 'too bright' was the pack-on gamma-space
tonemap (VM3), not the detail overlay (+3% fallback / -10% real path).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 18:26:02 +02:00
Erik
fe085cadaa research(vm2): retail runs SINGLE-PASS detail texturing - the #226 port targets the fallback
Live cdb read on the PDB-paired retail client (owner's AMD GPU):
  m_caps.bCanDoSinglePassDetailing = 1, trysinglepass = 1,
  MaxSimultaneousTextures = 8, bTexOpDotProduct3 = 1,
  LandscapeDetailTextures = 0, EnvironmentDetailTextures = 1,
  landscape/object detail surfaces null, building/environment non-null,
  tiling 4 everywhere.

So retail's detail combine on modern hardware is the texture-stage path in
D3DPolyRender::SetSurface (stage0 alpha PREMODULATE, stage1 colour
BLENDCURRENTALPHA): lerp(base*diffuse, detail.rgb, detail.a*diffuse.a) -
a mild DARKENING (~-10% mid-tones with 0x06006D58), not the
DSTCOLOR+INVSRCALPHA brightening that Campaign AR ported and that the
2026-08-21 findings doc + AR review both analysed. Those described the
fallback for adapters without D3DTEXOPCAPS_PREMODULATE; the reviewer's
guess that consumer drivers rarely advertised it was wrong.

VM1 now carries the re-port (SRCALPHA+INVSRCALPHA, detail.rgb /
detail.a*diffuseAlpha, neutral at a==0) together with the fade removal.
No distance fade exists on either path. Scripts are read-only attaches
with no breakpoints.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 18:23:27 +02:00
Erik
4dbbe8fead docs(plans): Campaign VM — VisualMaster plan + the Campaign AR review it closes
Adds the independent Campaign AR review (docs/research/2026-08-22-campaign-
ar-review.md): architecture, technical correctness, performance, coverage.
Findings F1-F8; F1 (default-path invariance proven only on a 2x2 recording-
device oracle) blocks merge; F2 (10 m/50 m detail fade has no retail anchor);
F3 (the port is retail's two-pass fallback; single-pass stage path
undetermined); F4 (post stack runs in gamma space); F5 (four overclaiming
sentences, one premise error the reviewer owns).

Campaign VM plans the closure: VM0 masked pixel + production perf A/B vs
6c79d35c; VM1 removes the fade (retail attenuates by mips); VM2 one cdb read
of m_caps.bCanDoSinglePassDetailing; VM3 linear-light post stack with a
numerically neutral preset; VM4 truthful docs; VM5 volumetric jitter; VM6
weather-driven foliage wind - procedural-scenery (bit-31 id namespace) cutout
subsets only, three motions (lean/branch/flutter) with gusts and per-tree
phase, shadows share the displacement include; VM7 closeout + merge.

Roadmap gains the Campaign VM pointer beside Campaign AR.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 18:17:08 +02:00
Erik
c51b07ef95 test(gates): sequence the two-client remote-shadow gate with harness signals
Imported from the codex worktree's uncommitted work
(C:/Users/erikn/.codex/worktrees/bd98/acdream on
codex/atmospheric-rendering-campaign), on top of its 8b7b601b.

The remote-player shadow gate ran two clients on fixed sleeps, so the
observer could move before the primary had taken its 'before' screenshot,
or the primary could take its 'after' shot before the observer had moved.
Now the harness publishes named signal files into each client's artifact
directory and the routes block on them:

- IRetailUiAutomationRuntime.TryIsAutomationSignalPublished, implemented
  by WorldLifecycleAutomationController over <artifactDir>/signals/
  <name>.signal (names validated by AutomationArtifactName, so no path
  escape).
- 'wait signal <name> [timeoutMs]' in RetailUiAutomationScriptRunner.
- run-connected-render-pack-remote-player-gate.ps1 publishes
  'primary-before' to the observer after the primary's before-shot
  completes, then 'observer-moved' to the primary after the observer's
  remote-observer-moved checkpoint.
- Both routes teleport with an explicit heading and wait 12 s to settle.

Build green; the three touched App test classes pass 58/58 including the
two new signal tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 17:44:13 +02:00
Erik
8b7b601b32 Fix ambient flyer cadence and rotating shadows 2026-08-22 17:39:38 +02:00
Erik
9cd429dd73 docs(render): record secret-safe closeout gate 2026-08-22 14:22:35 +02:00
Erik
fa3e7978ec fix(gates): require secret-safe observer credentials 2026-08-22 14:20:14 +02:00
Erik
772526d0cd docs(render): audit Campaign AR completion evidence 2026-08-22 14:18:21 +02:00
Erik
d84edf0ceb docs(render): finalize Campaign AR machine-local gate 2026-08-22 14:14:40 +02:00
Erik
d78ce100d6 test(render): fully warm dense shadow allocation gate 2026-08-22 14:10:53 +02:00
Erik
a7b18364cd fix(gates): support a second account for remote shadows 2026-08-22 14:06:57 +02:00
Erik
a861510a37 test(render): add connected remote-player shadow gate 2026-08-22 14:03:50 +02:00
Erik
e56ac57486 docs(render): record Campaign AR Stage 2 closeout 2026-08-22 14:01:07 +02:00
Erik
f92479ac3e test(render): add connected package lifecycle gate 2026-08-22 13:53:29 +02:00
Erik
1f75ce0505 fix(gates): initialize connected soak artifacts before isolation 2026-08-22 13:34:36 +02:00
Erik
0b6c6557a8 fix(gates): select a character in connected graphical runs 2026-08-22 13:24:39 +02:00
Erik
7a5f96ede5 feat(render): implement Campaign AR and terrain fidelity 2026-08-22 13:13:29 +02:00
Erik
99cf26e00c Merge remote-tracking branch 'origin/main' into codex/atmospheric-rendering-campaign 2026-08-22 13:02:43 +02:00
Erik
6c79d35c0b docs(roadmap): H.3's client scope is complete
Some checks failed
CI / linux-portable (push) Failing after 1m35s
CI / windows-gate (push) Successful in 6m19s
CI / release (push) Has been skipped
The roadmap line read "122 EmoteType x 39 Trigger mini-VM", which is the
SERVER's job — the retail client stores no quest flag, evaluates no emote, and
is never told a flag changed. Reading that line as client work is what made
H.3 look far larger than it was; measuring what we already had narrowed the
remaining scope to the contract tracker alone.

That, plus the Journal notebook and its index, shipped user-accepted today. The
two stale "what you cannot do yet" rows are corrected: the quest tracker exists,
and NPC dialogue works — the user confirmed it mid-campaign, which is what
prompted measuring the scope in the first place.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 18:32:12 +02:00
Erik
57084b5372 docs(quest): Campaigns QT and QJ closed user-accepted
The Journal panel is complete: contracts from the server, a per-character
notebook, and its searchable index.

The gate ledger is recorded rather than smoothed over, because the pattern is
the point — four rounds, and every defect was one mistake wearing different
clothes: an element bound as the wrong thing, or a binding never tested. One
reported failure was not a defect at all; the character was indoors, where
retail records nothing either.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 18:31:25 +02:00
Erik
eadedaea90 fix(journal): the timer's unit labels hide with their boxes
Reported as "1d 2h 51sh   m" — the running countdown drawing straight through
the d/h/m labels. The readout is authored across the same strip as the three
number boxes, so the strip has to be one thing or the other; I hid the boxes
and left their labels behind.

Retail's ShowEditableTimer @0x00495770 toggles SIX elements, not three:
m_pDaysEditBox AND m_pDaysStaticText, and the same for hours and minutes, plus
the readout inverse. Reading the swap as "hide the inputs" instead of "hide the
input ROWS" is what produced the overlap.

Also settles the Record question the same round raised. Nothing was broken:
indoors, retail's own gid_to_lcoord fails and nothing is recorded, and
UpdateLocation @0x004958F0 only ever formats coordinates already stored — there
is no "you are indoors" message in that function to port. The silence is
faithful, and it is now commented as such rather than left looking like a gap.

JournalPanelLiveBindTests is new and is the test that should have existed
first: it builds the panel from the real DATs, constructs the controllers, and
asserts every button actually receives an OnClick. Every other test so far
checked either the layout or the logic — none of them proved the controller
finds its elements in the real tree, which is where an id typo or a subtree
assumption produces a panel where nothing responds and nothing fails.

The temporary ACDREAM_PROBE_JOURNAL instrumentation is removed; the question it
was added for is answered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 18:29:58 +02:00
Erik
e35d9386e4 fix(journal): the location readout is a FIELD, refresh at the click, and Abandon works
Three fixes from the first connected round.

The location readout is authored EDITABLE (0x16), so it builds as a UiField —
not the UiText its "00.0S, 00.0W" placeholder suggests. The controller resolved
it as text, got null, and threw every write away in silence: Record reached the
model and reached the FILE, and never reached the screen. That is exactly what
was reported, and it is a whole class of bug, so the sweep that found it is now
a test over every element all three controllers bind.

The handlers mutated the model and left redrawing to the next frame's Tick.
Retail's ListenToElementMessage @0x004968D0 ends every one of them in Update()
instead — at the moment of the click. The deferred version happened to work in
the client and made the behaviour untestable and a frame late; the notes-page
tests I had not written until now fail against it.

Abandon is wired. "Retail's abandon path is a contract-registry command we have
not ported" was wrong — it is game action 0x0316 with a single contract id, and
ACE replies with the 0x0315 delete QT3 already handles. Nothing is removed
locally, so a refusal leaves the quest visibly intact rather than vanishing it
optimistically and having it reappear on the next full table.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 16:19:18 +02:00
Erik
0b27c5d0fe test(ui): pin every Journal-panel button as enabled and reachable
Built from the REAL authored layout, because that is the only place property
0x0D exists — the hermetic UiButton tests construct their own ElementInfo and
so could not have caught this class of bug at all.

Asserts the two things the player actually experiences, separately: the button
builds enabled, and a click at its centre reaches IT rather than falling
through. Enabled alone would not have been enough to call the fix proven.

Verified to have teeth rather than assumed: restoring the previous UiButton
and re-running fails both tests, naming all ten buttons.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 15:59:19 +02:00
Erik
73a04244e7 fix(ui): button property 0x0D was never "disabled", and it killed every Journal button
Reported symptom: Abandon, New, Record, Start, First and Last all unclickable.
That Abandon was in the list is what identified it — Abandon is deliberately
unwired, so if it behaved the same as the others the cause could not be wiring.

UiButton read authored property 0x0D as "starts disabled" (Enabled = !0x0D).
It was the one property read in that file with no citation, and it was wrong.
Every button on the Journal panel authors 0x0D, so every one built disabled:
visible, because drawing never consults Enabled, and unclickable, because
UiElement.HitTest skips disabled elements. Exactly the reported shape.

The evidence is a sweep of every installed layout (LayoutDump gained --ghosted
for it): 85 elements author 0x0D and ALL 85 author it TRUE — not one False
anywhere in the client — and no panel ever clears it, the only four
SetAttribute_Bool(.., 0xd, ..) sites in the binary being chargen appearance,
the keymap option and the barber. A flag that is only ever true, never cleared,
and sits on New, Record, Start, Delete and Reset cannot mean "dead button";
under the old reading 85 elements were permanently dead in a shipping game.

It is not a pure ghosted LOOK either, which is why this ignores it rather than
moving it to appearance: the same 85 mix live buttons with inert column headers
("Contract", "Status", "Title", "Timer", "Label", "#"), and one appearance
cannot be right for both. Registered as QJ-2 with the measurement, so the open
question is recorded rather than quietly decided.

The test that asserted the old behaviour carried no citation either — it
encoded the same assumption. It now asserts the evidenced behaviour, with a
companion test proving the state machine's own Ghosted transition still
suppresses a click: that mechanism is separate and did not change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 15:58:03 +02:00
Erik
c5cc8ae5fc feat(journal): QJ3/QJ4/QJ5 — both remaining tabs are live
The Journal panel now has all three tabs working: contracts from the server,
and a per-character notebook with its searchable index.

Two ported details that a reimplementation would get wrong in a way nobody
notices until they lose work:

Every navigation button commits the current page FIRST. Retail's
ListenToElementMessage @0x004968D0 calls SaveThisPage on the way out of all
five of them, which is why paging away never eats what you just typed. And the
file is written when the notes page is HIDDEN, not only at exit — a crash then
costs at most the page in front of you.

The search is CASE-SENSITIVE across label, title and notes: retail compares
with wcsstr and lowercases neither side. Making it insensitive would be
friendlier and would be a divergence, so it is ported as-is with a test naming
the reason. The double-click window is a full SECOND (m_LastClickTime + 1.0,
@0x00493158) rather than the 500 ms the item-interaction path uses, and firing
it clears the tracker so a third click does not re-open.

Two unlabelled buttons on the notes page turned out to be prev/next: retail
switches on (idElement - 0x10000565), which names them without a caption. The
running-timer readout is authored at the same x as the three day/hour/minute
boxes, so the strip is one or the other — that overlap is the data form of
ShowEditableTimer versus ShowRunningTimer, not a layout bug.

DeltaTimeToString moved out of the contract code into AcDream.Core.Ui. It is
ClientUISystem's, not gmContractsUI's — the journal timer and the contract
repeat countdown both call it, and it only lived under Quests because that was
its first caller. A bridge class to reach it across features would have been
the wrong answer to the same observation.

The journal file lives in the client's data directory rather than beside the
executable, for the same reason the chat log does. Register QJ-1.

Campaign QJ slices 3, 4 and 5 of 5 — code-complete, connected gate owed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 15:42:05 +02:00
Erik
536d17456d feat(journal): QJ1/QJ2 — the journal's pages and their file
The Journal tab is not a quest feature: it is a per-character notebook with no
wire, no server and no dat content. The player writes it, and it persists to a
tagged text file recovered whole from LoadPages/SavePages.

Retail refuses a journal file that does not OPEN with <NEWP>, with its own
message. That strictness is ported rather than softened — accepting such a file
would scatter the first page's text into no page at all. An ABSENT or empty
file is the opposite case and must not error: that is simply a character who
has never written a page.

Three things the format does not say out loud, each with a test:

<PNUM> is written but page order IS file order, so a reader that trusted the
number would reshuffle a hand-edited file. A recorded location of (0, 0) is a
real place, so the location tags are written on a HasLocation flag rather than
on the numbers being non-zero. And the notes box is multi-line while the file
is line-oriented — an embedded newline would read back as a tagless line and
silently truncate the notes, so they are folded to spaces at the write.

The countdown belongs to the page it was started on, and what belongs in the
file is what is LEFT rather than what it started at — saving the start value
would resurrect the full duration on every reload.

Deleting the last remaining page empties the journal instead of leaving a blank
one behind; inventing a replacement would make the journal impossible to empty.
An out-of-range page is refused rather than clamped, because clamping moves the
player somewhere they did not ask to go.

Campaign QJ slices 1 and 2 of 5.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 15:31:47 +02:00
Erik
45c964dbf0 docs(journal): plan Campaign QJ — the two tabs QT deliberately left inert
The Journal tab is not a quest feature at all: it is a per-character notebook,
entirely client-side, with no wire and no dat content. Each page holds a label,
a title, notes, a recorded location and a countdown timer, and the Page List
tab is a searchable index over them. It shares the panel with Contracts and
nothing else — retail's own naming, not a mix-up here.

The file format is recovered whole, including the detail that a journal file
which does not open with <NEWP> is REFUSED with its own error string, and the
authored max lengths (16 label / 32 title / 2048 notes) that the edit boxes
enforce.

One authored fact worth recording before anyone reads the layout as a bug: the
running-timer readout shares x=84 with the three day/hour/minute fields. That
overlap is the data form of ShowEditableTimer versus ShowRunningTimer — the
strip is either three editable numbers or one running readout, never both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 15:26:20 +02:00
Erik
fe1e68e5fe feat(quest): QT6 — plugins can read the contract tracker
The last piece of QT6's own scope: r10-quest-dialogs.md §11.6's contract half.
IGameState.Contracts exposes what the client structurally knows about quests,
which — per that same research — is the tracker and nothing else. The rest of
§11.6 (chat stream, tells, give, use, confirmations) is other features and
stays out of this campaign.

A pull-through source rather than a pushed mirror. Contracts change rarely and
are already owned canonically, so a second copy would only be a thing to keep
in step; reading through means a plugin cannot observe a stale list.

Both hosts implement it. The headless one carries contract id, stage and
progress but no names — a bot has no dat access — because losing the TEXT is
expected while losing the QUEST would leave a bot silently unable to see what
it is on. Same rule covers a contract the installed dat has never heard of: it
still projects, with empty text and a correct status, rather than vanishing.

The interface member is defaulted so a host predating this campaign still
satisfies IGameState.

Two lazy catalog loads exist (the panel's and this one) rather than one shared
instance. That is deliberate: threading a shared ContractCatalog through three
composition records to avoid reading a 322-row immutable table at most twice
per session would be plumbing for no correctness or performance gain, and the
comment at the call site says so.

Campaign QT is complete; the connected user gate is owed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 15:22:22 +02:00
Erik
56beeb720d fix(quest): QT5 — the rows were unclickable, and nothing showed which was selected
Two defects in the panel as committed, both found by checking the code against
the authored data rather than by a test.

The authored row template is a Type-3 generic container, which resolves through
DatWidgetFactory's fallback arm to UiDatElement — whose constructor sets
ClickThrough = true ("generic decoration; behavioral widgets opt back in").
Binding OnClick without clearing that compiles, reads correctly, and produces a
list in which nothing can be selected: every click sails past the row. The
skills page had already met this and left the precedent; I did not follow it.

And there was no selection highlight at all, so even once clicking worked the
player could not tell which row the detail pane was describing. UiTemplateListBox
has no selection mechanism of its own, so the page opts in directly and
re-PAINTS the highlight after a rebuild — a rebuild discards the row objects, so
remembering the selection is not enough to keep it visible.

The tests for both initially passed while the bugs were live, because the
fixture's row root was a UiPanel and its text started white. A UiPanel is not
click-through, so the first test was vacuous; white-on-white made the highlight
unobservable. The fixture now builds the same UiDatElement production does and
authors a non-white colour. This is the third time this campaign a fixture that
did not match the real widget hid a real defect.

Live mount confirmed against the installed dats in this session's client run:
"[UI] retail journal panel from LayoutDesc importer (0x2100006E slot
0x10000559)" with no bind failure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 15:17:14 +02:00
Erik
ec6eeb120d feat(quest): QT5/QT6 — the Journal panel, and the button that was already there
The quest log is on screen. Rows come from the live tracker joined to the
authored catalog, the Status column runs QT4's port of FillProgressString, and
the detail pane shows contact, locations, description and the other timer.

Two things measured rather than assumed, each now pinned by an installed-DAT
test rather than left to the commit message:

The tab pairing is read from the authored 0x2E table, not inferred from
x-order — the FA campaign had to correct exactly that mistake, and Contracts
turns out to be the authored DEFAULT tab (0x32 = True), so opening on the
wrong one would have looked like an empty panel.

The open path needed no keybind at all. Toolbar button 0x1000055A authors
0x10000029 = 0x19 and has been sitting in ToolbarController.PanelButtonIds
since the toolbar was ported — it just had no panel behind it, so clicking it
did nothing. Registering slot 25 finished a wiring that was already
three-quarters present.

The list rebuild is revision-gated while the repeat countdown is not: nothing
on the wire changes as a cooldown runs down, so a rebuild-gated timer would
freeze on screen, and a per-frame rebuild would reset the player's scroll under
them. Both directions have a test.

Deliberately inert: the Abandon button (retail's abandon path is a
contract-registry command this campaign did not port — authored and visible,
but wiring a no-op handler would look responsive and lie), and the Journal
notes and Page List tabs, which are their own feature.

Campaign QT slices 5 and 6 of 6 — code-complete, connected gate owed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 15:12:05 +02:00
Erik
5ca029d3ea docs(research): terrain detail/normals + atmospheric rendering findings
Measured, not theorised. Records for the next session:

- #226 detail overlay is NOT implemented at 255b0aae, and the
  BuildingDetailTextures Options checkbox is wired to NOTHING (settings +
  UI exist; no consumer in Rendering/). The port must consume that
  existing setting.
- Retail authors detail per terrain entry; Dereth has 33 entries but only
  3 distinct detail textures, one 64x64 backing 88% of them.
- The detail pass is a framebuffer blend, not DOT3: environment uses
  DSTCOLOR+INVSRCALPHA under BLENDOP_ADD, landscape uses SRCALPHA. Decoded
  texture means give factors 1.03-1.20, so retail's own pass BRIGHTENS
  rather than roughens - a real design decision for the port.
- EnvironmentDetailTextures gates buildings+interiors, NOT landscape
  (ChangeRegion passes landscape=0).
- Refuted and recorded so nobody re-chases: highres is NOT an override dat
  (20,684/2,294 ids, zero overlap - we already use it); the detail
  textures are NOT normal maps (24%/8%/15% unit-length); but the engine
  DOES have BumpMap/DotProduct3 machinery.
- Terrain normals are flat per-face with no averaging - whether retail
  smooths is the open parity question.
- Atmospheric rendering want captured: opt-in shader packs, tier table,
  and the shadow constraints (alpha-tested casters, animated casters via
  the N.5 SSBO, indoor gating, streaming-bounded cascades, the #129 bias
  trap, and the shadow_objects naming trap).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 15:02:27 +02:00
Erik
fac2dc7248 docs(quest): QT5 is specified — and it is the Journal panel, not a contract one
Measuring the host layout rather than assuming changed what this slice is.
gmContractsUI is not a panel of its own: it is tab 1 of a THREE-tab "Journal"
panel at gmPanelUI slot 25, beside a notes page and a page list. Building it as
a standalone window would have produced something retail does not have, and
the mistake would only have surfaced at a visual gate.

The other two tabs are out of scope, so the expected intermediate state is a
panel with two dead tabs — recorded here so it is not filed as a defect.

Everything else the page needs is now measured out of the dats: every authored
label, the per-row child ids RefreshContractListbox writes, and the list's
scrollbar link. The list is a UiTemplateListBox, the widget OP2 already built,
so the page is binding work rather than new widget work.

LayoutDump --props now resolves StringInfo through DatStringResolver instead of
printing the type name, which is how the labels were read at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 14:56:46 +02:00
Erik
ef6b7310c5 feat(quest): QT2/QT4 — the contract catalog, and retail's progress column
The wire carries an id, a stage and two timers. Every word the player reads
lives in portal.dat's ContractTable, which nothing in the tree had ever
opened — the only reference counted its entries in a CLI diagnostic. Chorizite
does decode it (322 contracts installed), which was a real question given it
declares TabooTable without decoding it.

FillProgressString @0x00498DE0 is the one real algorithm in this panel, and it
is now ported whole. Its x87 compares are the usual fcom/sahf pattern, so the
(status & 0x41) tests decode as "<= 0" rather than "< 0" — the difference
between a cooldown that expires and one that never does.

Three readings recorded as tests because each looks like a mistake:
TimeWhenDone is on the wire and is never read; an EMPTY QuestflagRepeatTime is
the entire difference between "Done" and "Available"; and DescriptionProgress
is a printf format taking stage-4, not a literal — rendering it verbatim shows
the player "%d/20 Tuskers".

DeltaTimeToString @0x00565E10 emits every part with a trailing space and then
overwrites the last one. That truncation is invisible in the decompiler output
(the instruction reads as pointer noise), so it was settled by decoding the
bytes: mov byte ptr [esp+eax+0x1b], cl with cl == 0 and eax == strlen writes
the terminator over buffer[len-1]. Guessing either way was a coin flip that
decides whether every repeat timer reads "Done (1h 30s  to Repeat)".

The single-%d substitution is a MEASUREMENT, not a convenience: 89 of the 322
installed contracts author a progress format and every one uses exactly one
specifier. An installed-DAT test asserts that, so a future dat that ships two
fails there rather than silently rendering a raw specifier.

LayoutDump gained --contracts, which is how all of the above was measured.

Campaign QT slices 2 and 4 of 6.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 14:53:21 +02:00
Erik
f629ce7f3d feat(quest): QT3 — the contract tracker becomes state, and the events get routed
Fourth sibling J-owner, built to the shape the other three established. It
borrows nothing, because the retail client stores no quest state of its own —
everything here is a projection of what the server pushed.

Clearing at generation reset is safe for the same reason: a fresh session opens
with a full 0x0314 replacement, so the reset cannot lose anything the next
login will not immediately restate, while NOT clearing would show a previous
character's quests.

Three readings of the wire that would each lose contracts silently, one test
apiece: a 0x0314 REPLACES rather than merges (merging resurrects contracts the
server dropped); an empty 0x0314 clears rather than being ignored (it is how
the server says "you have none", and ignoring it strands the last quest on
screen); and a delete carries a full tracker struct, so it looks exactly like
an add apart from one flag.

Adding a teardown stage exposed a genuine trap: TeardownStageCount bounds the
drain loop while GameRuntimeTeardownStage.Complete defines what the ledger
demands, and nothing tied them together. Leave the constant behind and the new
owner is never disposed at all, while the ledger goes on waiting for its flag —
the runtime hangs in teardown rather than failing anywhere near the edit. The
stage-ledger test now reads the constant by reflection and asserts it against
the flag list, so the next owner fails at the edit instead.

Campaign QT slice 3 of 6.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 14:46:53 +02:00
Erik
ab3934e21d feat(quest): QT1 — parse the contract-tracker events we have been dropping
Both opcodes have been named in GameEventType since the wire-catalog work with
nothing behind them, so every contract the server has ever sent us arrived and
was discarded.

Three details that a reimplementation from the enum alone would get wrong, and
each has a test:

The two trailing flags on 0x0315 are widened bools, not bytes, and they sit
OUTSIDE the struct writer — ACE's ContractTracker.Write has them commented out
precisely because the event appends them itself. Reading them as bytes decodes
the delete flag from the wrong four bytes and silently drops contracts.

The stage is not a dense enum. Retail encodes N completed steps as
ProgressCounter + N, so a switch over the four named values sees stage 9 as
unknown and shows nothing. Progress/HasProgressCounter do that arithmetic once
here rather than leaving every caller to remember it.

The countdown anchor is not on the wire. FillProgressString @0x00498DE0 counts
down from CContractTracker::_time_of_server_update, which the server never
sends — so arrival has to be stamped at parse time or the repeat timer has
nothing to tick against.

An empty table is a valid answer rather than a decode failure: it is how the
server says "you have no contracts", and confusing the two would leave stale
quests on screen permanently. A truncated one is rejected outright instead of
decoding to its prefix, which would drop quests just as silently.

Campaign QT slice 1 of 6.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 14:38:00 +02:00
Erik
730662f819 docs(quest): plan Campaign QT, and add the layout search that found the panel
H.3's roadmap line ("122 EmoteType x 39 Trigger mini-VM") describes the
SERVER's job. The retail client never stores a quest flag, never evaluates an
emote, and is never told a flag changed — so most of H.3 was never client work
at all. Measuring what we already have narrows the remaining scope to one
thing: the contract tracker, the only structured view of quest state a client
ever gets. The user confirmed NPC dialogue works live.

LayoutDump could only dump a layout you already knew the id of, but the decomp
hands you a CLASS id with no layout attached (UIElement::RegisterElementClass),
so the gap between the two was crossed by guessing. --find closes it, and it
searches the element's TYPE as well as its id because registration keys on
Type — searching only the id finds a real element with the same number and
quietly answers the wrong question, which is exactly what it did on the first
run here.

The plan records the wire layout, the panel's authored children, and
FillProgressString in full, including the three things a reimplementation
would get wrong: TimeWhenDone is never read, the countdown anchor is not on
the wire, and DescriptionProgress is a printf format rather than a string.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 14:36:04 +02:00
Erik
0e0a77c9b1 feat(chat): CT-B4 — @log, and the research block that was a wrong question
CT-B4 was filed as "the plain-text session chat log, path and rotation
UNKNOWN, needs a live check." Both unknowns dissolve once you read the
handler: there is no automatic session log. Retail's @log is a COMMAND.
DoSetOutput @0x0057E4F0 takes a filename, StartCopyOutputToFile @0x0057C8A0
does the fopen(name, "a+"), and running it again with no argument closes it.
Nothing rotates because it appends forever, and nothing has a fixed path
because the player names the file.

The path question that DOES exist — where a bare name lands — was answered
all along by retail's own help text, which CH4 extracted verbatim into our
help table a fortnight ago and nobody read: "a log file named Aclog.txt in
your Asheron's Call directory." A blocked question sat on top of a committed
answer.

We cannot use the install directory: the launcher replaces it atomically on
update, so a log written there is wiped by the next update or blocks it. The
client's own log directory is the equivalent that survives. Rooted paths are
honoured verbatim, as retail's fopen would. Register CT-5.

The verb was registered in the help table but NOT in the command catalog, so
/log printed help and did nothing — and the CH4 conformance registry recorded
it as a "server passthrough" precisely because that shape is indistinguishable
from an unimplemented client command. It never went on the wire at all. Both
are corrected, with the totals moved in the same commit rather than left to
drift.

Moving it into the catalog also moves which help table answers for it, so
retail's real text moved to the catalog-verb table in the same change. Without
that, /help log would have silently started printing acdream's own invented
one-line summary — caught by the coverage test, and now pinned by a test that
names the text.

All five replies are byte-decoded from the PDB-paired binary rather than read
off Binary Ninja's previews, which truncate at ~33 characters and would have
lost the second half of every one of them (including the two spaces retail
puts after "Copying chat to %s.").

The writer attaches on OPEN, not at startup — retail's help is explicit that
only what appears after the command is copied — and detaches from the
transcript it actually attached to, so a session teardown cannot leave a live
handler writing into a file the player believes is closed. What gets written
is the composed display line with the shared timestamp, because retail's
fprintf sits inside AddTextToScroll: downstream of composition, upstream of
glyph layout. Logging the raw entry text would have produced a file of bare
fragments with no speakers.

acdream's logs carry no inline tag markup where retail's do, since tags live
beside the text as spans here rather than inside it. Registered as CT-6 rather
than reconstructed purely to write it to a file.

Register: CT-5, CT-6.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 11:28:20 +02:00
Erik
89db9a794c feat(ui): authored state media animates, so the unseen-text indicator blinks
The blink is not code. It is data, and we were throwing it away.

A retail UI state's media is a small program: images interleaved with timed
pauses, branches, and a terminal hand-off to another state. Our importer kept
the FIRST image per state and dropped the rest, so nothing authored could ever
animate — the indicator was correct in every other respect and simply sat
still.

Measured from the installed dats (LayoutDump --media 0x1000048C), the chat
unseen-text indicator's Normal state authors thirteen steps: two frames
alternating every half second, three times, then `State 13` — Ghosted, whose
authored 0x3B is Invisible.

So retail's indicator is a three-second attention FLASH that hides itself, not
a badge that stays lit until you scroll to the bottom. Nobody would guess that
from the code, because there is no blink code anywhere; the behaviour lives
entirely in the authored sequence. Our shipped version stayed lit, which is
the one thing the data says it must not do.

Sampling is a pure function of (steps, elapsed) rather than a playback object
holding a cursor, so an element only has to remember WHEN its state began and
the whole thing is testable without a clock, a GPU or a frame loop. One shared
UiMediaClock is advanced once per frame by RetailUiRuntime; a UI element has
no tick of its own.

The controller change is the other half: it starts the flash on the rising
edge ONLY. Re-setting Normal every frame would pin the sequence on frame zero
and it would never blink at all — which is the failure mode the second new
test exists to catch, and which no "is it visible?" assertion would notice.
When the sequence reaches its terminal step the controller follows it down
instead of re-lighting it.

Two guesses are refused rather than made, and both are registered: a Pause's
max duration (every sequence measured sets min == max, and what the range MEANS
is not in the decomp) and a sub-1 branch probability (falls through, the
direction where a malformed sequence stops rather than animates forever).
A jump-cycle with no elapsed time is bounded so a bad sequence cannot spin
inside a frame.

Kept `Other` steps in the list rather than filtering them, so a jump's authored
index still lands on the entry it names.

Register: CT-3, CT-4.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 11:14:53 +02:00
Erik
f44f7641b1 tools(LayoutDump): --props and --media, for questions the decomp cannot answer
Two dump modes, both added because a chat bug turned on authored data rather
than code.

--props prints each state's property ids WITH VALUES. It is what settled the
unseen-text indicator regression: element 0x1000048C authors dat property 0x3B
("Invisible") per state —

    state 13 Ghosted  0x3B = True    -> hidden
    state 1  Normal   0x3B = False   -> shown

— which is what actually puts that element on screen. No amount of reading the
decomp would have produced those two booleans.

--media prints the RAW media sequence per state off the LayoutDesc, because
ElementInfo keeps only the FIRST image of a state (LayoutImporter.cs, the
`!imageRead` guard) and an animation is therefore invisible above that level.
It reports what it CANNOT do rather than implying an element has no media: raw
descriptors only carry what an element overrides, and states usually come from
a base element — for the indicator, base 0x10000527, which lives in a different
layout entirely. Following that chain means reimplementing LayoutImporter's
Resolve, so the mode says so instead of printing a misleading empty list.

What the two modes established between them, for whoever picks up the blink:
the media vocabulary includes MediaDescImage, MediaDescPause and MediaDescJump,
so an authored animation is a SEQUENCE with waits and a loop — a small program,
not a frame array. Supporting it means keeping the sequence through import and
running a media player, not just widening StateMedia to a list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 10:41:54 +02:00
Erik
a330d50df9 fix(chat): the unseen-text indicator follows its authored per-state visibility
Regression from cbab79d7, which I introduced: the indicator stopped showing at
all. Switching it from Visible to state-driven was half a correction — right
about retail's mechanism, wrong about what makes this element appear.

Measured, rather than reasoned about (LayoutDump gained --props for it):

    0x1000048C  state 13 Ghosted  0x3B = True    -> hidden
                state 1  Normal   0x3B = False   -> shown
                state 3  pressed  0x3B = False

Dat property 0x3B is "Invisible", authored PER STATE, and it is what puts this
element on screen. UiDatElement applies 0x3B on a state change; UiButton does
not, and this element builds as a button — so driving the state alone left it
hidden forever. The original Visible toggle was, by coincidence, exactly what
the authored data prescribes.

So the property is applied here rather than left unhonoured. That is the
authored data, not a visibility hack layered over the state machinery.

The state is still set, for the media it selects, but only on the way IN:
TrySetRetailState(Ghosted) means Enabled = false, and disabling the button
would also refuse the click that scrolls to the newest text — a second bug
waiting behind the first.

The test now pins VISIBILITY across the transitions instead of ActiveState.
The previous test passed while the feature was broken because the fixture
element carried no 0x3B, so the assertion could never see the property that
actually decides this. It fails now if the state is driven without the
visibility.

Proper fix noted for later: UiButton should honour per-state 0x3B the way
UiDatElement already does. That is a wider change than this regression wants.

Solution builds clean; full hermetic gate green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 10:33:26 +02:00
Erik
7aae5ba939 chore(chat): CT-D1/D2 — delete the dead ChatPanel, reconcile the stale digest
Campaign CT Group D, closing the campaign.

D1. The ImGui-era ChatPanel has not been constructed anywhere in src/ since
Campaign V deleted AcDream.UI.ImGui. Verified that directly rather than on the
audit's word, then removed it with its three panel-only test files. Those tests
passed, which is exactly the problem: they made the real input surface look
better covered than it is.

ChatVMCombatTests was KEPT — three of its four tests are genuine ChatVM
coverage and only one exercised ChatPanel, so just that method went. Deleting
the file would have quietly dropped real coverage along with the dead kind.
Three doc comments referencing the deleted type were rewritten rather than left
as dangling crefs.

D2. docs/ISSUES.md turned out to be ACCURATE already — #358 and #363 are
recorded CLOSED there, contrary to the audit's summary. What was stale was the
chat DIGEST's "Open" section, which still named four closed issues and claimed
Campaign CH's connected gate was owed. Corrected against ISSUES: genuinely open
are #359, #360, #361 and #366.

The digest also gained a Campaign CT section (the tag mechanism, the MEASURED
tag colour, and what shipped) and three DO-NOT-RETRY rows earned this session:

  - Do not model authored state media with one image per state — the unseen
    indicator's Normal state carries SIX frames and that IS retail's blink.
  - Do not read an element's role from a Binary Ninja field NAME — the names in
    ChatInterface's binder are shifted badly enough to assign a UIElement* into
    a float field.
  - Do not assume our side has a gap because retail has a mechanism. That cost
    this campaign twice in one session: the transcript was claimed unbounded
    when ChatLog has always capped at 500 entries, and C1's auto-scroll was
    planned as a port when UiScrollable already did it.

CT-C4 is deferred and marked so: pure test coverage over behaviour the audit
confirmed already works, changing nothing a user can see.

Solution builds clean; full hermetic gate green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 10:27:43 +02:00
Erik
cbab79d70c fix(chat): the unseen-text indicator is state-driven, as retail drives it
User report: "the Unseen indicator shows, but not blinking. I thought it was
blinking in retail." They were right, and CT-C1 had the mechanism wrong.

The dat settles it. Element 0x1000048C authors:

    1:Normal      media=13/6     <- SIX image frames: the flash
    3:Normal_pressed  media=2/1
    13:Ghosted    media=0/0      <- the authored DEFAULT, draws nothing

and retail's own click handler ends in SetState(0xD) — Ghosted. So the
indicator is driven by authored STATE, never by visibility, and the blinking is
a multi-frame media list in the DATA rather than anything in code.

CT-C1 toggled Visible instead. That looks almost right — the thing appears and
disappears at the correct moments — and can never blink, because visibility has
no frames. Now switched to Normal/Ghosted, which is both the retail mechanism
and the thing the animation hangs off.

STILL NOT BLINKING, and honestly so: our importer keeps ONE image per state
(ElementInfo.StateMedia is a single file), so multi-frame media is not modelled
anywhere in the UI layer. That is a capability rather than a tweak — the same
shape as the tagged-runs work in Group A — and the state machinery here is
correct either way, so it gains the animation for free once that lands. Recorded
in the method's own doc rather than left as a mystery.

The test fixture gained the element: it was absent, so the whole binding path
had never been exercised by any test — which is why a visibility-based
implementation passed everything. The test now asserts the state TRANSITIONS
(Ghosted at rest, Normal when a line arrives while scrolled up, Ghosted again on
returning to the bottom), not merely that something was bound.

Two notes on reading the decomp here, since both nearly misled me. Binary
Ninja's field names in this function are demonstrably shifted — it assigns a
UIElement* into m_fCurrentOpacity, a float — so the element's ROLE was
confirmed from its id and its click handler, not from a name. And the blink was
found by measuring the dat, not by reading code, because there is no blink code
to read.

Solution builds clean; full hermetic gate green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 10:23:15 +02:00
Erik
111fbba3bb fix(input): free-fly is unreachable, and Escape no longer answers to it
User report: "ESC is hardwired to Freefly which it should not be ... the
freefly should really be discarded. Should not be in the client."

Two separate things were true.

Escape ran a priority chain — cancel target mode, else EXIT FLY MODE, else
leave player mode, else close a window — so in a session that had reached the
free-fly camera, Escape spent itself on that rung instead of doing what the
player expected. The rung is gone; a session somehow in fly mode now falls
through to the next one.

And free-fly was still bound: Ctrl+Shift+F in RetailDefaults (the table
production actually loads) and plain F in AcdreamCurrentDefaults (dead since
K.1c, removed anyway so it cannot be revived by accident). The comment on the
live binding advertised two other ways in — the ImGui View menu and the Debug
panel's "Toggle Free-Fly Mode" button — but BOTH went away with
AcDream.UI.ImGui at Campaign V, so the shortcut was the last route in. It is
now unbound, and a test pins that across both default tables.

This makes free-fly unreachable rather than deleted. The implementation still
spans 25 files (CameraController, FlyCamera, the dispatcher capture, pointer
controller, composition, and a streaming observer source), and ripping that out
at the end of a long session is how a regression lands in the camera. Scoped as
its own follow-up; unbinding is what fixes the reported behaviour today.

The Escape priority test was updated rather than deleted: its middle row now
asserts the fall-through, so the removed rung is documented by a passing test
instead of by its absence.

Solution builds clean; full hermetic gate green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 09:55:16 +02:00
Erik
550621efb2 feat(chat): CT-C1 — the unseen-text indicator
Campaign CT slice C1, completing Group C.

The authored element was already in the layout and simply never bound:
0x1000048C, a 16x16 button at the transcript's bottom-left. It now lights when
a line arrives while the transcript is scrolled up, and clicking it jumps to
the newest text.

Half of this slice turned out to be done already, and checking rather than
assuming is what kept it that way. The plan called for porting retail's rule
that IsAtVerticalEnd is sampled BEFORE the new line lands, so a player reading
back is not yanked to the bottom. UiScrollable.SetExtents already does exactly
that via preserveEnd, and chat gets it by default — so the scroll behaviour was
untouched and only the indicator was missing. Rewriting it would have been
churn on correct code.

The flag clears on reaching the bottom by ANY means, not only by clicking the
indicator. Clearing only on the click would leave it lit over text the player
had already scrolled down and read, which is worse than not having it.

Detection samples the scroll position before the rebuild, at the one moment we
know new content arrived (the revision advancing). The first build after bind
is deliberately excluded — a fresh window has not "missed" anything.

Solution builds clean; full hermetic gate green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 08:31:48 +02:00
Erik
9f6d79b7e0 feat(chat): CT-C2 Escape leaves the entry; CT-C3 the timestamp is grey
Campaign CT slices C2 and C3.

**C2 — Escape in the chat input did nothing at all.** Not "did the wrong
thing": nothing. Two independent facts had to hold for that. UiField has no
Escape case, AND a focused field reports IsEditControl, which makes UiRoot skip
its own fallback and the input dispatcher withhold game actions — so the player
had no way out of the bar except the mouse.

Retail maps Escape to input action 0x0B, which runs
ChatInterface::DeactivateChatEntry @0x004F2FC0: RelinquishFocus, then
Deactivate. It does NOT clear the field. That is worth stating because the
obvious guess — "Escape clears the input" — is wrong and would have looked
perfectly reasonable; a half-written message survives stepping away from the
bar, and the test pins that rather than just pinning "handled".

**C3 — the timestamp took the message's colour.** Retail appends it as its own
run at a FIXED colour index (0x0C, which BuildChatColorLookupTable @0x004F31C0
fills with colorGrey) rather than the line's, so it stays grey whether the
message is red combat text or white speech.

Most of C3 was already done and stayed untouched: the DisplayTimeStamps option
is polled, and FormatTimestampPrefix already matches retail's "%#H:%M:%S ".
Only the colour was wrong, and it was only fixable now because A1/A4 made a
line able to carry more than one colour.

The stamp is a span ROLE rather than a second tag type: it is not clickable and
carries no payload, so modelling it as a tag would have made it hit-testable
for no reason. Its colour comes from the same runtime table every message
colour comes from, unlike the tagged-name colour, which is authored per element
(0x1D) and deliberately lives elsewhere.

One consequence worth naming: a timestamped line now needs runs even when its
sender is not tagged, because the stamp alone is reason enough. Before this,
only tagged lines got runs.

Also verified and NOT changed, having checked rather than assumed: C1's
auto-scroll half is already retail-faithful — UiScrollable.SetExtents samples
"was at the end" BEFORE applying new extents and only re-sticks if so, which is
exactly retail's IsAtVerticalEnd rule, and chat gets it by default. C1 reduces
to the unread indicator, which does not exist yet.

Solution builds clean; full hermetic gate green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 08:28:45 +02:00
Erik
2f97052e4f docs: CT-B3 dropped by user direction — no chat word filtering
"I do not want any censoring." Recording it as a KNOWING divergence (register
row CT-2) rather than leaving it as an implicit gap, because retail does filter
and someone reading the code later would otherwise read our silence as an
oversight.

The option itself stays: FilterLanguage still stores and ships its bit to the
server exactly as retail does, so anything the SERVER gates on it behaves
normally. What acdream does not do is substitute words in the transcript.

The attempt before the decision is kept in the plan, since it establishes two
things that would otherwise be rediscovered:

  - The taboo table's dat id is not readable from the decomp —
    TabooTableAdaptor::CheckCensorsW @0x00682A30 reaches it through
    DBObj::GetByEnum with the arguments elided by Binary Ninja. Measured the
    portal master enum map (0x25000000) instead: 22 categories, with category 3
    (0x0E010001 / 0x0E010002) and single-entry categories 8 and 11 as the
    plausible candidates.
  - Chorizite.DatReaderWriter declares a TabooTable DBObj type but does not
    decode it: only DBObjType and HeaderFlags. The format would need decoding
    here first.

Also sharpened CT-B4's status to research-blocked — the chat log file's path
and rotation are not in the decomp either.

Group B therefore ships B1 and B2 and is complete as scoped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 08:14:02 +02:00
Erik
e62aaebda0 feat(chat): CT-B2 — "/r " expands to a tell at whoever last told you
Campaign CT slice B2, and the autocomplete the user asked about directly.

Typing "/r " now rewrites the chat entry to "@tell {LastTeller}, " the moment
the space lands, matching ChatInterface::HandleTextReplacements @0x004F50D0 ->
SetReplyTextInChatBox @0x004F4760.

This is display sugar rather than routing: "/r hello" already SENT correctly
through ChatInputParser's reply aliases. What was missing is that the player
could not SEE who they were about to reply to before pressing enter.

The trigger strings came out of the constant pool, not the decompiled listing —
Binary Ninja renders them as bare data_* references with no preview:

    data_7C4C70 = "r "      data_7C4C68 = "rp "      data_7C4C58 = "reply "

Retail stores them WITHOUT the leading prefix and tests the first character
separately against '/' (0x2F) or '@' (0x40), which is why both prefixes work.
The research summary for this area listed the triggers as "/t ", "/tell " and
"reply " — reading the pool corrected that.

Three boundaries, each pinned by test because each is a way to get this subtly
wrong:

  - The trailing space is PART of the trigger. "/r" alone must be left alone —
    the player may still be typing "/roleplay", and expanding early would
    hijack a different command mid-word.
  - Only on space. Running the replacer per keystroke would rewrite text out
    from under someone mid-word; retail keys on 0x20 specifically.
  - Only with the caret at the end. Otherwise the player is editing existing
    text, and expanding would corrupt a sentence they are part way through
    fixing.

With nobody to reply to, nothing is rewritten — retail leaves the text alone
rather than producing a tell addressed to nobody, and the ordinary submit path
still reports "Someone must @tell you first!".

Solution builds clean; full hermetic gate green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 08:10:39 +02:00
Erik
0f1660d6ea feat(chat): CT-B1 — bound the transcript by retail's character budget
Campaign CT slice B1.

CORRECTION TO THE PLAN: this slice was written as "the transcript grows for the
life of the session — a slow leak". That was wrong, and the plan said it
because I read the retail-side finding and inferred our side without checking.
ChatLog has always been bounded (ConcurrentQueue, maxEntries default 500, with
a dequeue loop in Append). There was no leak.

The real gap is the UNIT. Retail bounds the rendered transcript by CHARACTERS —
0x2710, beheaded toward 0x1D4C at a newline boundary — while we bounded the
model by messages. Two different things: a window of 500 messages is far more
scrollback than 10,000 characters, and the message cap is a safety limit on the
log rather than a display rule.

So the budget is applied where retail applies it: on the rendered window, not
the model. ChatLog's entry cap stays as the model-level bound.

Two deliberate simplifications, both registered as CT-1 rather than left
implicit:

  - ONE threshold, not retail's two. The hysteresis exists to stop retail
    re-trimming an accumulating buffer on every append; we rebuild the visible
    list each time, so there is nothing to damp, and a second threshold would
    only make the oldest visible line jump around as messages arrive.
  - Whole-line cutting rather than a newline search near an offset — our unit
    already IS the line, which is what retail's newline preference is for.

Filtered-out lines deliberately do not consume budget: a line this window
filters out is not in retail's buffer at all, so counting it would mean turning
a filter OFF silently shortened the visible history.

Solution builds clean; full hermetic gate green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 08:06:18 +02:00
Erik
78bf62c80e fix(chat): the tell prefill leaves the caret after the prefix, not before it
Clicking a name filled the entry with "@tell Name, " correctly but parked the
caret at column 0, so the player had to click the chat bar to get behind their
own prefix before typing — which defeats most of the point of the affordance.

Self-inflicted in d32ef388. SetText already places the caret at the end, and I
stacked an explicit "move to the end" on top of it. MoveCaret takes a DELTA, so
int.MaxValue overflowed _caret + delta to negative and the clamp landed at
column 0. The redundant call was not merely redundant; it was the bug.

Removing it is the whole fix. The test now pins CaretPos as well as the text,
and reintroducing the call reproduces the reported symptom exactly (expected
11, actual 0).

Solution builds clean; full hermetic gate green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 08:00:43 +02:00
Erik
d32ef388f0 feat(chat): CT-A5 — clicking a speaker's name opens a tell
Campaign CT slice A5, closing Group A. Retail's
gmMainChatUI::RecvNotice_TextTag_IIDStringClick @0x004CCE10 ->
ChatInterface::StartTell @0x004F41F0 writes "@tell {Name}, " into the chat
entry and takes keyboard focus; clicking a green name here now does the same.

The trailing space is deliberate — without it the first character the player
types joins the comma.

Three seams, each narrow on purpose:

  - UiText.OnCharClick is offered the character under a left click before the
    element-wide OnClick, and consuming it suppresses that. Kept separate
    because a tag click is POSITIONAL and an element click is not; folding
    them together would make every text element with an OnClick swallow tag
    clicks.
  - TaggedRangesForFragment returns tagged column ranges relative to the
    FRAGMENT, because that is what a click resolves to — UiText.HitChar gives
    a line index into the WRAPPED list plus a column within it. Line-relative
    ranges would land every click on a wrapped line at the wrong characters.
  - The controller caches those ranges alongside the runs it already caches,
    so the per-click lookup reads the same cache the draw does.

The hit test is half-open: a caret slot sits BETWEEN glyphs, so clicking just
past a name's last letter belongs to the space after it, not the name. Pinned
by theory rather than left to chance, since off-by-one here means clicking a
name sometimes does nothing.

StartTell uses the tag's NAME, not its object id — retail carries the id but
this handler never reads it, so the tell still addresses correctly for someone
who has since moved out of range.

Group A is complete: names are green (A4) and clickable (A5). Ready for the
user's visual gate.

Solution builds clean; full hermetic gate green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 07:34:58 +02:00
Erik
53395e4de4 feat(chat): CT-A4 — speaker names render in retail's tag colour
Campaign CT slice A4, and the first slice of Group A that shows on screen: a
player's name in a chat line now draws green while the rest of the line keeps
its message colour.

The colour is AUTHORED, not computed. Retail keeps two parallel index-selected
colour arrays on the text element and refreshes both from the same caller index
on every append (UIElement_Text::AppendStringInfoWithFont @0x00469DE0):
property 0x1B for ordinary glyphs, 0x1D for glyphs under an open tag. Property
0x1D is read exactly the way 0x1B already was, carried on ElementInfo, and
seeded onto UiText beside DefaultColor. Measured on the chat transcript
(0x2100006F / 0x10000011) as RGB(0,178,0).

It deliberately does NOT go into RetailChatColorTable. That table is the
runtime-built per-LogTextType mapping; the tag colour is per-element authored
data, and filing it there would put it somewhere it would look right in tests
and be wrong in principle.

RunsForFragment is the load-bearing piece and is pure. Wrapping can drop the
space it broke on, so a fragment is NOT simply the next N characters of the
line — BuildLines locates each fragment in the source text to keep the span
offsets honest, and the mapper clips spans to the fragment window. A tag
straddling a wrap break is therefore split across both fragments and stays
green on both, instead of changing colour mid-word.

Two guards worth naming. A fragment containing no tag returns NULL rather than
a single-run list, so the overwhelming majority of lines keep the existing flat
draw path untouched. And an element authoring no 0x1D falls back to the line
colour, so a name never renders in a colour nobody chose.

The run/fragment contract is property-tested across every substring of a tell
line, because CT-A1's RunsMatchLine refuses mismatched runs by silently falling
back to flat text — a mapping bug here would degrade quietly rather than fail.

Solution builds clean; full hermetic gate green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 07:31:20 +02:00
Erik
44fb74f8a6 feat(chat): CT-A3 — a chat line keeps the identity of who said it
Campaign CT slice A3. Still nothing visible; this removes the blocker.

ChatEntry carried Sender and SenderGuid the whole way through ChatLog, and
RecentLinesDetailed dropped both one step before the transcript. That is why a
speaker's name could never be coloured or clicked apart from the sentence
around it — by the time anything could draw it, there was no name left, only
prose.

FormattedLine now carries a Spans sidecar: the line split into stretches, one
of which may hold a retail text tag. Text remains the VISIBLE line with markup
consumed, so wrapping, selection, hit-testing and the caret are untouched, and
Spans is null for the ordinary single-colour line, which is most of them.

FormatEntryTagged shares its format strings with FormatEntry through a sender
decorator rather than duplicating retail's wording. Two copies would be two
things to keep in step, and these two renderings MUST show the same characters:
the transcript selects against the flat text, so any drift would mean clicking
one character and selecting another.

Tagging is gated on AC1's player id range (0x50000001..0x6FFFFFFF), read off
the guard in Handle_Communication__HearSpeech @0x005712A0 — so monsters and
NPCs never become clickable, which a "has a name" test would get wrong.

One real bug came out of a defensive test rather than a report. A sender name
containing '<' re-parsed as a marker and SWALLOWED characters from the visible
line ("Od<d says" rendered as "Od says"). The markup has no escape mechanism,
and retail's has none either because AC name validation makes the case
unreachable there — but sender names are server data. Such a name is now simply
not tagged, so the line renders plain like any other untaggable sender instead
of rendering corrupted.

Solution builds clean; full hermetic gate green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 07:27:14 +02:00
Erik
2621fbf4a7 feat(chat): CT-A2 — parse retail's inline chat tag markup
Campaign CT slice A2. Pure parser, no UI, nothing wired yet.

Retail's client composes chat lines with the markup already embedded —
Handle_Communication__HearSpeech @0x005712A0 sprintf's it — and the text
element recognises it while appending (UIElement_Text::InqGlyphs @0x00468EA0),
calling TextTagFactory::MakeTag @0x00478480 per marker. A tagged speaker name
arrives as:

    <Tell:IIDString:1342177290:Dww>Dww<\Tell> tells you, "hello"

ChatTagMarkup.Parse splits that into spans, consuming the markers: the name
under a tag, the remainder untagged.

The rule that decides where a tag ENDS is the one worth being careful about.
It is the absence of a colon, not the backslash: MakeTag requires a ':' to
succeed, so ANY bracketed text it cannot parse closes the open tag, and the
backslash in retail's own closer (TextTag::BuildEndTag @0x00479190) is
incidental to that. Porting "a closer starts with a backslash" would look
correct on every retail line and then diverge on everything else, so the test
pins all three of <\Tell>, <Tell> and <anything> as closers.

Two details taken from the decomp rather than guessed: only the FIRST colon of
an IIDString payload separates the id from the name, so a name containing a
colon survives intact (ParseStartTag @0x00478910); and an unterminated '<' is
ordinary text, so a player typing "is 3 < 4 really" does not lose the rest of
their sentence.

The parse also upholds the contract CT-A1's draw side enforces — the
concatenated span text always reproduces the visible line, because selection
and hit-testing index into that flat string.

Solution builds clean; full hermetic gate green.

Note for the record: PreparedAssetVerificationCacheTests.BackupRecoveryHashes-
TheBackupEvenWhenTheLiveCacheIsValid failed once during this slice's gate and
then passed isolated, as a class, and on a full-gate rerun. This branch touches
no launcher code, so it is load-sensitive rather than caused here — flagging it
rather than silently re-running, since a test that only fails under parallel
load is worth someone classifying.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 07:23:07 +02:00
Erik
cf41b27c9a feat(ui): CT-A1 — multi-line text elements can carry coloured runs
Campaign CT slice A1. No behaviour change: this is the capability the rest of
Group A needs.

UiText already drew several differently-coloured runs on one line
(TextRun/RunsProvider, used by the character stat panel) but the path was gated
to OneLine == true, and the chat transcript is multi-line — so a chat line
could only ever be one colour. Retail's is not: a tagged glyph run takes the
element's TAG colour (property 0x1D) while the rest of the line takes the
ordinary one (0x1B), per UIElement_Text::InqGlyphs @0x00468EA0.

LineRunsProvider is a SIDECAR keyed by line index rather than a field on Line.
Roughly fifty files construct Line, and widening its shape would put every one
of them in the blast radius of a chat feature; a line with no runs draws
exactly as before.

The runs fold into the existing datLines list as extra entries at advancing
pen-X, so the S1 outline-then-fill batching is untouched — a multi-colour line
still submits its whole outline pass before any fill, and cannot notch the
descender of the line above.

Two things are deliberately load-bearing:

  - RunsMatchLine. Selection, hit-testing and the caret all index into the FLAT
    line text, so a run list that disagrees with it would draw one thing and
    select another. The draw path verifies the runs say exactly the same
    characters and falls back to the flat line if not, rather than trusting the
    caller.
  - LayoutRuns is pure. The pen-advance is the part that silently mis-renders
    if it drifts, so it is testable without a font atlas or a GPU — which also
    keeps its tests in the ordinary gate rather than the SystemFont lane.

Solution builds clean; full hermetic gate green, 0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 07:18:55 +02:00
Erik
ba6c82af93 docs: Campaign CT rescoped to complete chat parity, system + GUI
The user set the goal: complete retail parity for the chat system AND the chat
GUI, not just the green clickable name that started the review. Plan restated
around that bar, with a definition of done — every retail behaviour either
implemented or carrying a divergence-register row, every user-visible surface
covered by a test, and the digest/ISSUES describing reality.

Slices regrouped into A (the tagged-text capability, a strict chain where
nothing is visible until A4), B (system behaviours), C (GUI), D (hygiene), plus
the research still owed before specific slices and what is deliberately out of
scope.

Four items joined the plan that the original six lanes did not own, because
they fell between lanes:
  - the /r, /t, /tell text-replacement macro (the commands work; retail's
    VISIBLE expansion to "@tell {LastTeller}, " does not exist)
  - FilterLanguage, which is a decorative toggle: we store it, ship the bit and
    show it in Options, and never actually filter anything
  - the plain-text session chat log retail writes and we do not
  - the option-gated timestamp prefix

Also corrects the CH3 command-registry research note. Its "acdream status"
columns are from before slice CH4 and list 13 verbs as MISSING that have all
since been added — cg, soc, o, co-vassals, fellows, group, party, vassal, ab,
guild, ct, clfg, crp — and its DIVERGENT row for /g is likewise stale: acdream
maps /g to Fellowship, matching retail, confirmed against the live client
today. The retail side of that document is still the authority; only the
columns describing us were wrong. They misled this session's investigation,
which is exactly why the banner says to verify against ChatInputParser.cs.

Nothing implemented.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 07:13:55 +02:00
Erik
663129c340 docs: Campaign CT — chat text tags, researched and planned
Six parallel research lanes on retail's chat text and window behaviour, plus a
plan. The headline: the green clickable speaker name is not a chat feature and
not a colour, it is a missing capability in the TEXT stack.

Retail's client sprintfs literal tag markup into the chat line, and the text
element parses the brackets while appending, attaching a ref-counted tag PER
GLYPH. A tagged run is emergent: adjacent glyphs whose tag pointers are equal.
A glyph takes the tag colour (property 0x1D) only when a tag is open and its
type is 0x10000001; otherwise the ordinary line colour (0x1B).

The colour itself was the one thing the decomp could not settle — it is
authored, not runtime-built — so it was MEASURED out of the installed dats
rather than assumed from a screenshot: P0x1D = RGB(0,178,0). That also exposed
a trap: the tag colour is per-ELEMENT and authored while the line colour on the
same element comes from the runtime chat table, so filing "tag green" into the
LogTextType table would put it in the wrong place.

Our own audit found the gap is narrower than feared. UiText ALREADY draws
multi-coloured runs (the character stat panel uses it); the path is just gated
to single-line elements. The draw path needs no renderer work, and HitChar
already resolves a click to line+column. The real blocker is that sender
identity is destroyed before it reaches the renderer: ChatEntry carries
Sender/SenderGuid the whole way, and ChatVM.RecentLinesDetailed drops both.

Two findings beyond the original question. Retail BOUNDS its transcript
(10,000 chars, trimmed to ~7,500 at a newline) and splits auto-scroll from an
unread indicator by sampling "was at bottom" before the line lands — a naive
port auto-scrolls forever and leaks for the life of a session. And the chat-UI
audit turned up an untracked bug: Escape in the chat input does nothing at all,
because UiField has no Escape case and a focused field also suppresses the
input dispatcher's fallback.

Every lane was instructed to write "UNKNOWN — needs X" rather than guess, and
they did; the carried unknowns are listed in the plan rather than papered over.

Seven slices proposed, nothing implemented yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 06:52:05 +02:00
Erik
be324c003c tools(LayoutDump): dump authored colour arrays; measure retail's tag green
The chat deep-dive proved the MECHANISM behind retail's green clickable
speaker name — a glyph tag coloured from property 0x1D rather than the line's
own 0x1B — but not the colour itself: BuildChatColorLookupTable @0x004F31C0
builds only the 0x1B array, so the value is authored rather than runtime-built
and the research correctly returned "UNKNOWN" instead of assuming the green in
a screenshot.

--colors prints the 0x1B/0x1D arrays of every element in a layout, which
measures it out of the installed dats:

    chat 0x2100006F, transcript 0x10000011
      P0x1B [0x00] R=204 G=204 B=204
      P0x1D [0x00] R=  0 G=178 B=  0     <- the green

Recorded in the research note, including the trap it exposes: the tag colour is
per-ELEMENT and authored, while the line colour on that same element comes from
the runtime chat table. Filing "tag green" into the LogTextType colour table
would put it in the wrong place entirely.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 06:49:59 +02:00
Erik
3eb28c57f2 fix(chat): the talk-focus button names the focus, not the target
581a61ef made the chat button display the tell TARGET's name once "Tell to X"
was picked. It should read "Tell" — the button names the focus, the same way
it reads "Chat", "General" or "Fellow" for the other focuses. Reported against
retail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 06:33:48 +02:00
Erik
581a61ef0c feat(chat): the talk-focus menu's Tell-to / Squelch entries actually work
Both entries were deliberate no-ops — the code said so — and both showed a
static label where retail shows the selected player's NAME.

Retail builds them in gmMainChatUI::InitTalkFocusMenu @0x004CDC50 and rebuilds
their labels every time the menu opens, substituting the selection through
StringInfo::AddVariable_String (@0x004CD91C / @0x004CD982). So they now read
"Tell to Dww" / "Squelch (ignore) Dww", rebuilt on open from a live selection
provider, and grey out with nothing selected — retail arms the tell slot only
for a talkable target (SetTalkFocusEnabled(2, 1) @0x004CD9B0).

Picking "Tell to X" aims the chat bar at X. That needed one piece of plumbing:
the parser's plain-speech fallthrough returned a null target, so a line typed
under a Tell focus was dropped by the router for having no one to send to.
Parse/Submit now carry an optional default tell target for exactly that case.

"Squelch X" publishes the ALREADY-REGISTERED /squelch verb rather than
reimplementing the request — the ModifyCharacterSquelch wire builder
(CM_Communication::Event_ModifyCharacterSquelch @0x006A42D0) has been there all
along; only the menu path to it was missing.

UiMenu gains an OnOpen seam, because a menu whose Items are fixed at Bind can
only ever say "Tell to Selected". It fires before _open flips so the rebuilt
rows are measured and drawn in the same opening.

Solution builds clean; 14,480 tests pass on the standard hermetic lane filter,
0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 06:23:46 +02:00
Erik
10304f6dc2 fix(chat): announce enchantment expiry; stop double-printing tells
Two of the four reported chat defects.

**Only item spells announced their expiry.** ACE splits the two cases: an
enchantment expiring on an ITEM arrives as ordinary server chat ("The spell X
on Y has expired.") — which is why those were the only ones showing — while
one expiring on the PLAYER arrives as GameEventMagicDispelEnchantment carrying
no text at all, because retail's client writes that line itself.
ClientMagicSystem::NotifyOfEnchantmentRemoval @0x005686C0 is now ported: the
spell's own name plus " has expired.", at LogTextType 7 (Magic), including
retail's guards (ids >= 0x8000 skipped, a spell missing from the table prints
nothing) and its one special case — spell 0x29A gets " penalty" appended so
vitae reads "Vitae penalty has expired."

Retail's trailing "\n" is deliberately dropped: its scroll appends raw text,
AddText is line-based, and keeping it would print a blank line.

**Every tell printed twice.** ACE's GameActionTell replies with a
GameMessageSystemChat carrying the finished "You tell X, ..." line
(ChatMessageType.OutgoingTell), and we ALSO emitted an optimistic local echo.
Retail's own send path, Event_TalkDirectByName @0x00577CF4, has no
AddTextToScroll beside it — it just transmits and lets the server's reply
print. The local echo is removed, which also makes Tell consistent with Say,
which has always relied on the server echo.

CH3 had this half-right: it removed the legacy-channel echo for precisely this
reason, but kept the Tell echo on the stated grounds that "the server never
resends" it. That premise was false. Both test comments asserting it are
corrected rather than deleted, since the wrong claim is what made the bug
survive review.

Solution builds clean; 14,477 tests pass on the standard hermetic lane filter,
0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 06:11:33 +02:00
Erik
80a3a25594 fix(world): rotate the DAT-scenery root too — the flyers actually orbit now
0c552eec wired the SetOmega hook and I called it done. The birds kept flapping
in place, and the user's report — "flapping and moving up and down, not
orbiting" — is what identified the miss: part animation working, root frozen.

BindLiveOwner THROWS on a zero ServerGuid, so owner.Body is only ever assigned
for server-spawned entities. Ambient flyers are DAT scenery with no ServerGuid
and therefore no PhysicsBody at all. The whole

    if (owner.Body is { } body) { ... Frame::grotate ... }

block — and the omega application I added inside it — silently skipped every
object the fix was written for. It applied the mechanism to a branch these
objects never take.

So the omega now lives on the scheduler's own Owner record rather than on the
PhysicsBody, because most of this workset has no body, and the same grotate is
applied to entity.Rotation when there is none. That is not a shortcut around
the physics owner: for a DAT static the WorldEntity IS the only root retail
would be rotating.

Verified rather than assumed this time, both halves:
  - StaticRenderProjectionJournal.SynchronizeActiveAnimatedSources re-projects
    from the live entity every frame through
    RenderTransform.FromRoot(entity.Position, entity.Rotation, entity.Scale),
    so a rotated root reaches the renderer.
  - Compose builds LOCAL part transforms, so the renderer composes root x part
    and the offset mesh is carried around its circle.

Why it shipped broken: no test exercised a root rotation on the ServerGuid==0
branch, so applying omega body-only passed everything. The new test asserts the
rotation on the branch these objects actually take, and fails with the exact
production symptom (rotation stays identity) when the branch is disabled. Its
sibling pins the other direction — scenery without a SetOmega hook must never
acquire a spin.

Solution builds clean; 14,475 tests pass on the standard hermetic lane filter,
0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 05:52:44 +02:00
Erik
0c552eecac fix(world): honour SetOmega — the birds and butterflies fly again
Ambient flyers played their wing animation and stayed put.

A Static object whose Setup declares a DefaultAnimation joins retail's
CPhysics::static_animating_objects workset (CPhysicsObj::InitDefaults
@0x00513A7B) and is driven by animate_static_object @0x00513DF0. That function
has exactly one motion step:

    CPartArray::Update(part_array, dt, nullptr);            // animate
    Frame::grotate(&this->m_position.frame, &this->m_omegaVector);

Note the nullptr: unlike UpdatePositionInternal @0x00512C30, which combines the
animation's accumulated frame into the object's position, the static branch
DISCARDS it. These objects cannot move by animation translation at all. The
omega vector is the whole mechanism, and one thing writes it —
SetOmegaHook::Execute @0x00526F30 -> CPhysicsObj::set_omega @0x0050F6D0.

We decoded that hook and then dropped it on the floor: IAnimationHookSink's own
docs list SetOmegaHook among the unwired ones, and PhysicsBody.Omega was
assigned nowhere outside projectiles. The scheduler's GRotate call was already
correct — it was multiplying by a permanent zero.

The hook is now applied to the owning body at process_hooks time. Retail runs
process_hooks AFTER the grotate in the same pass, so a newly-set omega first
takes effect on the following frame; our Tick/ProcessHooks split already had
that order.

Scoped from the data rather than guessed. tools/AnimHookScan (new) walks the
dat: of 2,066 animations exactly 8 contain SetOmega, and all 8 are the
DefaultAnimation of one of the 8 setups that use it. No creature animation uses
it, so this belongs precisely where body.Omega is read and nowhere else.

The same scan is why the fix is believable as FLIGHT rather than a pirouette.
Every authored omega is pure yaw, and the setups' parts sit 5.6m, 4.2m, 12m and
36.8m from the origin they spin about. Rotating a frame whose mesh hangs 12m
off-axis carries it around a 12m circle — that offset IS the flight radius. An
installed-DAT test pins both properties, because the fix is only correct while
they hold and neither is visible from the code.

Also checked and deliberately NOT conflated: CSequence::set_omega @0x005248A0
writes CSequence::omega, a different field from CPhysicsObj::m_omegaVector,
fed by the motion table for creature turning. Only the latter drives grotate.

Solution builds clean; 14,473 tests pass on the standard hermetic lane filter
plus the new installed-DAT test, 0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 05:42:59 +02:00
Erik
255b0aaeda fix(magic): the foci map never loaded — casts demanded full components a focus should waive
All checks were successful
CI / linux-portable (push) Successful in 3m18s
CI / windows-gate (push) Successful in 5m23s
CI / release (push) Successful in 2m3s
User report: with the correct focus, scarab and tapers in the pack, a level 1
spell would not cast, and the spell examine window listed the full legacy
component recipe where retail shows only scarab and taper. The question asked
was whether the 2013 client data is too old for foci. It is not -- the EoR
dats carry the map, and the whole client-side mechanism (the requirement
service, the scarab-only formula port, the examine-window routing) was already
built and wired. It was fed an empty table.

MagicCatalog resolved the school-to-foci map with
Resolve(enumValue: 0x10000001, enumCategory: 0x28). Retail's
SpellComponentTable::SchoolOfMagic2WCID @ 0x005BC1F0 calls
DBObj::GetByEnum(0x10000001, 4): master map -> category 0x10000001 -> key 4
-> the school->WCID EnumIDMap. The 0x28 on that call is the EnumIDMap DBTYPE
tag, and it had been read as a lookup category. The master map has no
category 0x28, the resolver returned 0, and the foci map loaded EMPTY --
silently, so a carried focus was never detected: HasRequiredComponents
demanded the full account-customized formula (refusing the cast) and
GetExamineComponents displayed it.

Found by measurement rather than re-reading the code: a SpellDump --foci probe
proved category 0x28 absent, then brute-forced the portal enum tree for ACE's
FociWCIDs and found them at 0x27000003 under category 0x10000001 key 4:

    school 1 -> 15271 Foci of Strife       (War)
    school 2 -> 15270 Foci of Verdancy     (Life)
    school 3 -> 15269 Foci of Artifice     (Item)
    school 4 -> 15268 Foci of Enchantment  (Creature)
    school 5 -> 43173 Foci of Shadow       (Void)

The new Lane=InstalledDat test pins exactly that: the loaded catalog must map
every school to ACE's FociWCIDs -- external constants from the server-side
authority, deliberately not derived from the code under test, so an empty or
wrongly-resolved map cannot pass vacuously.

The infusion-augmentation half of the retail gate (properties 0x126-0x129,
0x148) was already correct against the decomp, as were the scarab-only ID set
{1..6, 0x6E, 0x6F, 0x70, 0xC0, 0xC1} and the taper-count table.

Complete Release suite: 14,469 tests pass on the standard hermetic lane
filter, 0 failures; the new installed-DAT test passes against the real dats.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 21:28:22 +02:00
Erik
cd6eefd0ba feat(plugins): enforce apiVersion; launcher plugins default ON with "none" opt-out
Two gaps from the MossTank shipment review.

**apiVersion was declared in every manifest and checked by nothing.** The
loader now refuses an unsupported contract BEFORE loading any code from the
plugin — checking after the fact is not equivalent, because by then the
assembly is in a collectible context and the mismatch surfaces as a type-load
or missing-member failure from inside the plugin, which reads like the plugin
is broken rather than built for a different host. PluginApi (Current /
MinimumSupported) lives in Plugin.Abstractions beside the contract it
versions, and the refusal is a distinct PluginApiVersionException so callers
can tell "update the client or the plugin" from "this plugin is broken". The
tests pin the ordering too: a manifest with a future apiVersion AND a missing
dll must fail on the version, a supported one on the dll.

**A launcher-launched client loaded no plugins until the user typed ids.**
LA5 distinguishes an omitted allow-list (load all) from an explicit empty one
(load none); a fresh character profile's list is empty, so it composed to
load-none. Direct launches pass null and load everything -- which is why the
gap never showed in development: the two launch paths disagreed and the
launcher was the one users get. This REVERSES the LA5 default deliberately:
"nothing configured" now composes to the omitted list, so plugins are on by
default, including ones installed later. The opt-out is kept -- losing it
would be a real regression for stripped sessions -- respelled as the literal
id "none", and the launcher's plugin box says so.

The cross-host shared fixture composes its explicit-load-none case through
the new spelling, keeping the reader-side contract tests (App and Headless
both preserve an explicit empty list) exactly as they were.

Complete Release suite: 14,469 tests pass on the standard hermetic lane
filter, 0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 21:28:04 +02:00
Erik
7e75be23d1 fix(ui): effects list stayed pinned at its authored height in a taller window
Some checks failed
CI / linux-portable (push) Failing after 3m15s
CI / windows-gate (push) Successful in 6m19s
CI / release (push) Has been skipped
The "Beneficial Spells in Effect" window rendered its rows only in the top
249px and painted the rest of the list as empty black background, with a
scrollbar thumb sized for a viewport far smaller than the visible one. It did
not depend on window size, and the last visible row was sliced mid-height --
a clip boundary, not a missing row.

Root cause is the #412 class again. The authored list element (0x10000123) is
a UiTemplateListBox, not a UiItemList, so EffectsUiController creates the item
list itself and attaches it as a child with fill anchors. That baseline is
captured lazily on the child's first ApplyAnchor -- which lands AFTER the host
has already been resized to the restored window height in the same frame. The
capture then measures a bottom margin of (hostH - 249) and ComputeAnchoredRect
preserves it forever: h = hostH - (hostH - 249) = 249, at every subsequent
size. Rows past 249px fail LayoutCells' cull test and never draw.

Capturing the baseline at creation, while the list's extent still exactly
equals the host's, makes the margins (0,0,0,0) so it tracks the host from then
on. Identical fix and reason to UiTemplateListBox's own viewport seed. The
spellbook's component list is built by the same pattern and had the same
latent defect; it is fixed alongside.

Why it shipped: every existing test in EffectsUiControllerTests supplies a
synthetic UiItemList as the list element, so `host is UiItemList` is true and
the controller uses it directly -- the create-and-attach branch that actually
runs against real dat was never exercised. The new test binds the real
fixture, which builds the real UiTemplateListBox. Neutralising the fix makes
it fail with the exact production numbers (expected 547, actual 249).

Measured, not guessed. tools/LayoutDump grew --resize, which reproduces
retail's raw-edge policy (UIElement::UpdateForParentSizeChange @ 0x00462640)
offline, and it ruled out the authored geometry, the import, the layout policy
and the window frame in turn -- all four are faithful. The 4px gap between the
scrollbar and the window's inner edge is likewise authored: the user confirmed
retail shows the same gap, so it is deliberately left alone.

Solution builds clean; 14,465 tests pass on the standard hermetic lane filter,
0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 21:00:16 +02:00
Erik
6db0d69816 tools(LayoutDump): print the BUILT widget tree beside the authored one
"This control is in the wrong place" has exactly two possible causes: the dat
authored it there, or our importer moved it. Printing only the authored tree
answers half the question.

--built runs LayoutImporter.Build over the same ElementInfo and prints the
resulting widget geometry underneath, so the two can be compared directly. On
the effects window they match exactly, which is how the "misaligned scrollbar"
report was ruled out as an import bug rather than assumed to be one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 20:46:34 +02:00
Erik
46ce6f238c feat: regen buffs, wand aura, spellbook assess, indicator press flash
Four reports from one gate round. Three were mine; the fourth I first
mis-explained, and the correction is the useful part.

**The vital regeneration rates were never cast.** Regeneration (health),
Rejuvenation (stamina) and Mana Renewal (mana) all landed in the catch-all
Other bucket, which is off by default. Retail words each of the three
differently and two of the six phrasings do not begin with "Increases the
caster's" at all:

    Increase caster's natural healing rate by 10%.                 <- and note "Increase"
    Increases your Health Regeneration Rate by 50%.                (Empyrean)
    Increases the rate at which the caster regains Stamina by 10%.
    Increases the caster's natural mana rate by 10%.

They are matched per vital, on by default, and ranked at the very tail of the
Life group so they finish the pass. The mana line had to be checked BEFORE the
generic "Increases the caster's X by N" match, which would otherwise read it as
a buff to a stat named "natural mana rate".

**Aura of Hermetic Link was the sixth aura line and the only one missed.**
"a magic casting implement's" is reached by none of the other alternatives, so
the wand's mana-conversion buff was silently in Other too.

**Right-clicking a spell in the spellbook did nothing.** I claimed this had
never worked; the user said it used to, and they were right -- I had checked
one file's history and concluded from it. The regression is 3e31b0ac, which
gave UiCatalogSlot its own RightClick case returning true unconditionally. On
any list that had not wired the examine seam -- the spellbook among them -- the
event was reported handled and UiRoot stopped bubbling. Two fixes: the row now
reports an unwired right-click UNHANDLED so bubbling continues, and the
spellbook wires the seam to the same appraisal window the spell bar uses.

Retail does this generically in the list rather than per window
(UIElement_ItemList::ListenToElementMessage @ 0x004E4F1F -> ExamineSpell
@ 0x00564A70), which is exactly why a per-controller seam could be forgotten
for one window and not another.

**No green flash when pressing an indicator.** Every indicator button authors
a full-size 0x100000F2 child whose DirectState is a draw-nothing File=0 image
and whose only other state, Normal_pressed, carries the green selector sprite
0x06004CE8 -- and the buttons author Normal_pressed with PassToChildren. But
UiButton.ConsumesDatChildren drops dat children at import, so the cascade had
nothing left to reach. The child is re-attached through the same repair the map
hotspot's rollover highlight already uses.

**tools/LayoutDump** is new, and is why the last two are diagnoses rather than
guesses: it prints an authored LayoutDesc tree -- geometry, edge modes, state
sets, PassToChildren, per-state media -- straight from the installed DATs.
"Does this button even have a pressed state?" was being answered by reading our
own importer and inferring; now it is read from the data.

Solution builds clean; 14,464 tests pass on the standard hermetic lane filter,
0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 20:38:28 +02:00
Erik
fe1f124cd8 feat(mosstank): cast buffs in retail's dependency order
MossTank was casting cheapest-first. That is a reasonable rule for surviving
a mana shortfall and a bad one for everything else, because AC's buff order is
not a preference -- each group raises the skill the next group is cast with:

  1. Creature Enchantment, and inside it:
       - the Creature Enchantment skill itself, which every remaining creature
         buff is then cast with;
       - Focus, then Willpower, then Endurance -- Focus and Self are the
         attributes the Item Enchantment and Life Magic skills derive from, so
         raising them raises the skill groups 2 and 3 are cast with;
       - the rest of the creature spells.
  2. Item Enchantment -- the banes and weapon auras.
  3. Life Magic last -- the protections and Armor Self.

Casting out of that order means casting at a lower skill than the character
could have had, which shows up as fizzles.

Grouping is by the spell's school rather than by name or category, so
protections land in group 3 because retail files them under Life Magic, not
because anything here says "protections go last".

Willpower is matched as "Self": retail's spell is named Willpower but its
description reads "Increases the caster's Self by 10 points", and MossTank
classifies from the description, so the name it matches is the one retail
actually writes. Verified against the spell table rather than assumed
(0x05A5 Willpower Self I).

Cheapest-first survives as the tiebreak inside a group, so a mana shortfall
still costs the cheapest of the last group instead of something the rest of
the pass depended on.

The two ordering tests were confirmed to fail when CastRank is neutralised --
the third guards the tiebreak and passes either way, by design.

Solution builds clean; 14,453 tests pass on the standard hermetic lane filter,
0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 20:07:31 +02:00
Erik
81e6a48603 feat(mosstank): banes, protections and weapon auras
Three whole categories of buff were missing, for two different reasons, and
both were my errors.

**Protections and weapon auras were silently dropped by the description
parser.** They are self-targeted and were sitting in the spellbook the whole
time, but retail words them differently and the pattern only accepted
"Increases the caster's X by N":

    Fire Protection Self  -> "Reduces damage the caster takes from Fire by 9%."
    Armor Self            -> "Increases the caster's natural armor by 20 points."
    Aura of Blood Drinker -> "Increases a weapon's damage value by 2 points."

So the weapon and wand buffs do exist as self-cast "Aura of" lines and are now
cast. Each category has its own toggle, matching VTank's separate
BuffProfile_Prots and BuffProfile_Banes.

The underlying flaw mattered more than the two missing patterns: anything
unmatched was DISCARDED. It now falls into an Other bucket (off by default)
instead, so nothing self-targeted is lost without a word. A test caught a
second instance immediately -- regeneration spells say "Restores..." and were
vanishing the same way.

**Banes were excluded because I misread a flag.** I took IsSelfTargeted as
"can be cast on you". It means "needs no selection". Retail's own bane text
says exactly how they work:

    "Increases a shield or piece of armor's resistance to slashing damage by
     10%. Target yourself to cast this spell on all of your equipped armor."

So banes ARE cast on the person, and the catalogue now includes every
beneficial non-untargeted spell rather than only flagged self-casts, leaving
EvaluateGate to decide what a given target accepts. Before casting anything
without the self flag, MossTank selects the player -- and restores whatever
was selected before the pass, so targeting yourself does not quietly steal
the selection.

They are matched on retail's "Target yourself..." sentence rather than on the
word "Bane", so the classification comes from what the spell says it does.

Solution builds clean; 14,450 tests pass on the standard hermetic lane filter,
0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 19:49:51 +02:00
Erik
5ca4a63272 feat(mosstank): the button is Force Buff — always recast everything
Virindi Tank's own term: Force Buff recasts the lot rather than only what has
lapsed. The button now does that, and says so.

BuffPlan gained a force flag that skips the already-in-force check. Forcing
means "ignore what is already up", NOT "ignore the settings" -- the trained-
skill filter, the attribute toggle and the difficulty margin all still apply,
and there is a test pinning that.

The loop had to change shape for this. It used to re-derive the plan every
tick and treat "plan is empty" as done, which works only because the ordinary
plan shrinks as buffs land. A forced plan never shrinks -- that is the point --
so the same loop would have cast forever. A pass now captures a queue at the
start and works through it by index, which is also cheaper: no rebuilding 80-odd
buff lines every frame.

A spell that will not go now advances the queue rather than blocking it. One
missing component used to mean everything behind it waited for the stall
timeout; now the status line names the refusal and the pass carries on.

Solution builds clean; 14,440 tests pass on the standard hermetic lane filter,
0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 19:17:32 +02:00
Erik
54005a864c feat(mosstank): cast back-to-back, and a settings view for the thresholds
Three things from the first working buff pass.

**Pacing.** Casts were three seconds apart because of a fixed interval I added
before there was a real busy signal. There is one now -- the shared busy count,
incremented by the cast and decremented by the server's UseDone -- so the
interval is gone and the server is the only throttle. MossTank casts the moment
the previous action is acknowledged, which is the spam behaviour VTank has. The
stall timeout moved 25s -> 30s so a slow server does not read as a stall.

**Stamina to Mana was surprising.** VTank does convert vitals by default
(Recharge-*-Mana), so the behaviour is right, but a buff pass quietly spending
your stamina is not something to discover by watching. It is now a setting,
with its own thresholds, and can be turned off outright.

**Settings view.** Spell difficulty margin, rebuff time, the three vital
thresholds, and three toggles. Two details worth recording:

* The difficulty margin is SIGNED, per the VTank wiki: "a positive number
  raises the skill necessary to cast spells, a negative number lowers it. To
  attempt higher spells at a low level use a negative number." So the range
  spans -100..+100 rather than starting at zero.
* It is a second registered panel with a complementary visible binding rather
  than a tab control. Two panels and an Action need no new markup vocabulary,
  and only one is ever on screen.

Adjuster buttons rather than typed fields: buttons are proven in plugin markup,
while an editable UiField would need keyboard routing plumbed through to plugin
panels first. Worth doing, but not as a side quest inside this change.

Also fixed: the App copy target names plugin files explicitly, so the new
markup would have been left out of the plugin directory and the settings panel
would have failed to load at runtime with the build perfectly green. Caught by
listing the output rather than trusting the build.

Solution builds clean; 14,438 tests pass on the standard hermetic lane filter,
0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 18:51:15 +02:00
Erik
1a7b0ed3d5 fix(mosstank): read the real busy signal, not the last-requested spell
A buff pass cast exactly one spell and then stalled at zero. The log timeline
made it unambiguous: one "casting Concentration", then every later pass queued
86 buffs and cast none until the 25s stall fired.

IsCasting was bound to RuntimeSpellCastState.LastRequestedSpellId. That
property records the last spell REQUESTED and is cleared only by Reset() at
session teardown -- it is honestly named, and I read a busy flag into it that
was never there. So it latched true on the first successful cast and stayed
true for the rest of the session, and every tick returned early at the busy
check.

It now reads the shared busy count: incremented by the cast path
(FreeHandsAndCastSpell @0x00566EF0) and decremented by the server's UseDone,
which is the actual in-flight signal. EvaluateGate reports PluginCastGate.Busy
as well, so a genuinely wedged counter names itself in the status line instead
of presenting as silence.

Solution builds clean; 14,437 tests pass on the standard hermetic lane filter,
0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 18:35:37 +02:00
Erik
b9674b1f1e fix(mosstank): unclickable button, empty skill list, dev font, and chat output
Four defects from the first in-world look, three of them with a definite root
cause rather than a plausible one.

**The Buff button did nothing.** Not a hit-testing problem -- the pointer found
the button perfectly. UiRoot's press handling asks the pressed widget whether
it owns the pointer; a widget that does not claim the press falls through to
"move the ancestor window", and a window drag returns early on release without
ever emitting a Click. UiButton and UiClickablePanel both override
HandlesClick for exactly this reason; UiSimpleButton never did. Latent since
that class was written, and invisible until it was put inside a draggable
window -- which is precisely what a markup plugin panel is.

Found by reproducing it headlessly through the real UiRoot dispatcher rather
than by reasoning about it: MarkupPanelClickTests drives press-and-release over
the button and asserts the bound action ran, with a separate test asserting the
pointer finds the button at all, so a future failure says which half broke.
My earlier guess -- that a modal at character select was swallowing the click
-- was wrong, and the screenshot of the panel live in world disproved it.

**"0 trained skills".** The skill-name table was read in OnLoad *before*
GameWindowCompositionPipeline.Run, which is what publishes the DAT collection,
so _dats was still null, the whole block was skipped, and the surface reported
an empty skill list with nothing to explain it. Bound in PublishDatCollection
instead -- the moment the data exists -- so it cannot run early again whatever
the phase ordering does, and a genuinely missing SkillTable now says so.

**Plugin text used the development bitmap font.** UiLabel and UiSimpleButton
gained a DatFont, and MarkupDocument now takes the retail interface font from
the host, so plugin panels render through the same glyph path (including
retail's two-plane outline) as authored panels.

**MossTank now writes to chat.** New BCL-only IPluginChat routes to retail's
ClientLocal log type (0x1A) -- the channel the client uses for its own notices,
local to this client, so a plugin cannot speak in the player's name. MossTank
announces the start, the finish with a cast count, and a stall.

Not addressed here: the cursor showing blue rather than amber. Traced but not
fixed -- CursorFeedbackController picks the cursor family from combat mode, and
CombatMode.Magic selects the blue Magic cursor where Default is amber. That is
a combat-mode question, unrelated to this change, and worth its own look rather
than a speculative fix folded in here.

Solution builds clean; 14,437 tests pass on the standard hermetic lane filter,
0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 18:27:28 +02:00
Erik
17ebfc434d feat(mosstank): buff trained skills and attributes, pick tiers by skill, manage mana
Reworks MossTank against user feedback and the Virindi Tank feature docs
(virindi.net is reachable again over https with a self-signed cert; the
research doc's "unreachable" note is stale).

VTank's stated default is the spec: "automatically buffs every Attribute and
Skill you have trained", and "all buff spells are recast when they go below 5
minutes". The previous pass buffed the whole spellbook and refreshed at 60s;
both are corrected.

The hard problem was working out WHICH stat each buff raises. The client's
spell table has no such link -- it arrives from the server with the
enchantment -- and the naming is too irregular to infer: Invulnerability
raises Melee Defense, Impregnability raises Missile Defense, Fealty raises
Loyalty, Sprint raises Run, Arcane Enlightenment raises Arcane Lore, and the
line called Willpower raises the attribute named Self. Any name-matching
scheme dies on that last one.

Retail states it outright in each spell's own description ("Increases the
caster's Life Magic skill by 10 points"), so BuffProfile derives the whole
mapping from shipped data at runtime. It also carries the one alias the data
needs: the spell text says "Assess Monster" where the skill table says "Assess
Creature", and without that the skill silently never matches.

Two data facts that would each have caused a real bug, found by dumping the
spell table rather than assuming:

* Family is NOT a spell-line identity in general. Retail groups the
  instantaneous vital transfers by SOURCE vital, so family 89 holds both
  "Stamina to Health" and "Stamina to Mana". Picking the strongest tier in a
  family would convert into the wrong vital about half the time. Buff lines
  group by family (correct for duration buffs, which is retail's own stacking
  bucket); the conversions are found by name stem instead.
* Instantaneous spells have no duration and must be excluded from buff lines
  entirely, or they are treated as buffs that never appear to land.

Tier selection now follows the character's skill in the casting school against
the spell's difficulty (VTank's SpellDiffExcessThreshold-Buff), which is why
PluginSpellInfo gained School as a SKILL id -- MagicSchool is retail's 1-5
school enum, not something a character trains.

Mana upkeep is the loop asked for: convert stamina to mana when mana is low,
Revitalize when that leaves stamina too low to convert, and refuse to drain
stamina past a floor. Unknown vitals read as zero and are treated as "no
information" rather than "empty", so it will not cast on a healthy character.

Panel no longer shows at character select. IsAvailable is now the runtime's
own lifecycle state rather than a proxy, and markup gained visible="{Binding}"
plus UiElement.VisibleSource -- evaluated before the visible gate, because
TickSelfAndChildren returns early when hidden and an element could otherwise
never un-hide itself.

Also: a generated SpellId enum of all 6,266 spells (tools/SpellDump --enum),
generated from portal.dat rather than copied, so it cannot drift and carries
no third-party licence; skill and spell names now come from the retail tables
for display; and the Buff click logs unconditionally, so "nothing happened"
can be told apart from "the click never arrived".

Solution builds clean; 14,433 tests pass on the standard hermetic lane filter,
0 failures, including 21 covering the buff profile, tier selection and mana
loop.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 18:09:02 +02:00
Erik
9d1117b923 feat(plugins): MossTank — a self-buffing plugin, and the automation surface it needed
First consumer of acdream's plugin automation surface, and the first slice of
the VTank-class plugin milestone
(docs/research/2026-07-29-vtank-plugin-automation-requirements.md).

MossTank shows a panel with a Buff button; clicking it casts every self-buff
the character is missing, skips what is already in force at an equal or higher
tier, and refreshes what is nearly expired.

The host/plugin line is the load-bearing decision here. The host publishes
spell DATA -- family, tier, difficulty, mana, duration -- plus a cast
primitive with a preflight gate. The plugin owns the POLICY. That is the
architectural conclusion the requirements research reached: VTank's engine
lived in plugin-land, built on Decal's primitives, and baking "best buff for
skill X" into the host would start pulling the engine inward one convenience
at a time.

Why the plan is driven off the spellbook rather than off trained skills, which
is the obvious reading of "buff every trained and specialised skill": the
client cannot honestly make that mapping. The link between a spell and the
stat it modifies arrives from the SERVER in the enchantment message and is
absent from the client's own spell table. What the client does know is which
spells the character has learned -- and a character only learns buffs for the
skills they use, so the spellbook reaches the same set without inventing a
mapping the client has no grounds for.

Surface added, all BCL-only so Plugin.Abstractions keeps its zero project
references:

* ICharacterInfo, ISpellCatalog, IMagicCommands, grouped behind one
  IAutomationSurface so IPluginHost grows by one member rather than three.
* IEvents.Tick. Automation is sequences, not single calls -- a buff pass casts
  several spells and must wait between them. Without a host tick a plugin
  would need its own timer thread re-entering the host off its update thread.
* NoOpAutomationSurface for hosts with no live session, so a plugin keeps one
  code path and checks IsAvailable.

Markup gained <button> and <label>; it previously supported only <meter>, with
a comment promising the rest. Buttons bind onclick to an Action property and
FAIL THE PANEL LOAD if it does not resolve -- a silently dead button is worse
than a panel that refuses to load, because the user clicks and there is
nothing to diagnose. Labels bind through a Func so a status line tracks its
binding instead of freezing at build time.

Enchantment reads use EnchantmentsInEffectSnapshot rather than the raw active
set: retail leaves a weaker same-family enchantment in the registry while a
stronger one is in force, and a plugin asking "am I buffed?" means in force.

BuffPlan is a pure function of (known buffs, active enchantments) precisely so
it can be tested without a session; 9 tests cover tier supersede, the
family-0 no-stack bucket that must not be de-duplicated, expiry refresh, and
plan stability across the rebuilds the tick loop performs.

Solution builds clean; 14,421 tests pass on the standard hermetic lane filter,
0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 16:22:28 +02:00
Erik
690f21889e docs: handoff for the VTank-class plugin automation milestone
The requirements research from 2026-07-29 was nearly lost: it lives in a
session titled "graphics" because it was one strand of a session about
something else, and could not be found again by any search. The research
itself was committed (6077ce4d) and survived; this makes it findable and
usable by a session picking the work up cold.

Contents: reading order, the architectural conclusion not to relitigate
(VTank's meta FSM, expressions and loot engine are plugin-land, not
host-land; the K2 headless triad is already the right substrate), the
five dependency-ordered steps, every relevant file path, and the project
rules that bind the work.

Two things the handoff adds beyond relaying the research. First, a
changed-since section: three of the research's "gap" rows have closed —
vendor landed 2026-08-08, fellowship 2026-08-12, secure trade 2026-08-14 —
so step 4's substrate is materially stronger than when the plan was
written, and §3's gap table is the part that has aged. Second, every path
is verified against the live tree as of today rather than copied forward:
WorldEntitySnapshot is still exactly four fields, IPluginHost is unchanged,
there is still no enchantment-enumerating view and no point-goal movement
primitive, and IHeadlessBotPolicy is internal to AcDream.Headless so step 1
mirrors its shape rather than re-exporting the type.

All 30 path references were checked programmatically. Also records that
claude-memory/ is a junction that resolves in the main checkout but not
inside a worktree, which would otherwise read as a broken pointer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 15:50:58 +02:00
Erik
c254fea83d fix(app): apply the window icon from Load, not beside Window.Create
All checks were successful
CI / linux-portable (push) Successful in 3m20s
CI / windows-gate (push) Successful in 5m40s
CI / release (push) Successful in 2m9s
The client shipped with a PE icon Explorer showed and a window that did not:
launched from the launcher it still drew the stock Windows application icon.

Silk's Window.Create only builds the managed object. IWindow.Initialize is
what, in Silk's own words, "creates the window on the underlying platform".
Applying an icon before that throws:

    after Window.Create : IsInitialized = False
    SetWindowIcon BEFORE Initialize : THREW InvalidOperationException:
                                      Window should be initialized.
    after Initialize    : IsInitialized = True
    SetWindowIcon AFTER  Initialize : returned without throwing

What made this quiet rather than obvious is the fallback. GLFW registers its
window class against a resource named GLFW_ICON and, not finding one, uses
IDI_APPLICATION - the generic Windows icon - rather than the executable's own.
So the PE icon kept showing on the file while the live window lost it, which
reads as a packaging problem and is nothing of the kind. The launcher was
unaffected because Avalonia takes a different path entirely, and that
asymmetry was the tell.

Apply now happens in OnLoad, beside the other window-dependent startup work,
and refuses with a message naming the ordering requirement if it is ever
called on an uninitialized window - the previous generic catch reported
"Window should be initialized" to a stderr nobody reads, which said nothing
about icons.

The regression guard reads the compiled call graph, because this is an
ordering edge with no observable return value: OnLoad must call Apply, and no
method that calls Window.Create may. Verified by reintroducing the bug and
watching it fail, then restoring the fix and watching it pass.

Solution builds clean; 14,408 tests pass on the standard hermetic lane filter,
0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 15:13:29 +02:00
Erik
400e7c766f Merge main into acdream-mosswart-icon
All checks were successful
CI / linux-portable (push) Successful in 3m20s
CI / windows-gate (push) Successful in 6m1s
CI / release (push) Successful in 2m7s
2026-08-20 14:46:26 +02:00
Erik
48c44abd4a fix(IconForge): point the texture path at the real dump directory
render.py was lifted from the scratch pipeline with its default TEXDIR still
aimed at tools/IconExtract's build output — a tool that is not in the repo.
forge.py overrides the value, so the icons built correctly and the staleness
was invisible; anyone importing render.py directly would have been sent to a
path that never existed.

Default now matches where tools/MosswartArt actually writes, and a missing
texture prints a warning instead of silently dropping out: a partial texture
set renders some parts flat grey, which reads as a lighting bug rather than a
missing extraction step.

Both icons still reproduce byte-for-byte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 14:43:37 +02:00
Erik
a1ffe77af4 feat: mosswart client icon and Asheron's Call-inspired launcher icon
acdream had no application icon on either executable. Two marks now ship,
built from the game's own material rather than drawn freehand:

* Client - the retail mosswart head. Not an illustration of one: the actual
  creature mesh (Setup 0x02000B4F part 14, skin atlas 0x05001E11,
  ClothingBase 0x10000344) read out of client_portal.dat through acdream's
  own GfxObjMesh/SetupMesh port, then smoothed, lit and graded. Palette
  values are sampled from that texture, including the mustard belly the
  Mosswart lore calls a "foul yellow".

* Launcher - a forged ring enclosing a barbed crescent, rebuilt from
  measurements of the retail wordmark and the acclient.exe icon resource.
  An original construction in the same visual language, not a copy of the
  trademarked logo. Its warm field matches the retail client icon.

Three techniques carry the render quality, all in tools/IconForge:

* PN-triangle tessellation (smooth.py). The retail head is 104 triangles
  and renders faceted. Each triangle becomes a cubic Bezier patch built
  from its own corner positions and normals, so the silhouette genuinely
  rounds rather than merely shading smoothly - and it needs no mesh
  connectivity, which matters because UV seams would otherwise pull apart.
  Normals are welded across coincident positions first, but only within a
  crease angle, so ear fins and tusk edges stay sharp.

* Matcaps (ring.py). A Lambert rasterizer cannot produce chrome, because
  chrome is almost entirely reflection and there is nothing here to
  reflect. Sampling a lit-sphere image by the camera-space normal is the
  standard stand-in for an environment map.

* Distance-transform bevelling (chisel.py). Flat shapes become chiselled
  metal by treating distance-to-edge as height. The height field is
  blurred before differentiating; without that the medial axis of each
  stroke shows through as a hatched ridge.

Two facts worth recording, both discovered the hard way. Creature Setups
define no upright pose in PlacementFrames, so the exporter must be handed
the weenie's MotionTable id or all 17 parts stack on the origin. And a
mosswart's eyes sit on the sides of the skull like a frog's, so a dead-on
frontal turns them edge-on and the face stops reading as a mosswart at all;
the hero angle is az 266 / el 32.

Wiring: <ApplicationIcon> gives each executable its PE icon. The client's
runtime window icon is embedded rather than copied beside the binary - a
window icon has no sensible fallback if the file goes missing, and
embedding survives single-file publish. WindowIconLoaderTests guards the
resource names, which are coupled to LogicalName in the csproj by string
alone and would otherwise fail only as a silently icon-less window.

Both halves of the pipeline are deterministic and reproduce the committed
PNGs byte-for-byte, so an accidental edit shows up as a diff.

Solution builds clean; 14,378 tests pass on the standard hermetic lane
filter, 0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 14:42:10 +02:00
Erik
1bd2b30291 fix(ui): restore retail vitals and window interactions
All checks were successful
CI / linux-portable (push) Successful in 3m16s
CI / windows-gate (push) Successful in 5m41s
CI / release (push) Successful in 2m5s
2026-08-20 13:26:35 +02:00
Erik
4d84456c21 docs: close Campaign LU with its ledger and the four gate traps
Some checks failed
CI / linux-portable (push) Successful in 3m15s
CI / windows-gate (push) Failing after 6m44s
CI / release (push) Has been skipped
Ten slices, six planned and four the gate rounds added, all shipped through CI
and accepted live: the update flow "works, it updates as it should", launcher
self-update "pass", the client's exit back to the character selector "pass".

The plan now records what the gate rounds found that the plan could not, since
every one of the four was invisible to the automated suite:

- headless play and character refresh had never run once — the launcher passed
  the graphical host's argument shape to the headless host, which reads
  arguments[0] as a command;
- refresh was harmful as well as broken, opening a second connection the server
  treats as a new login;
- Stop WAS the ungraceful exit, killing the client five seconds in;
- Play was below the fold behind the settings form.

And three findings worth keeping: the verification cache cannot see a same-size
same-timestamp change (measured — the CI runner's /tmp is ZFS, 141 of 200
same-size rewrites kept an identical mtime), testing the launcher exercises the
INSTALLED client rather than your source, and a locally built launcher stamps
1.0.0 and therefore can never be offered an update.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 08:13:29 +02:00
Erik
7037681a1f fix: state the verification cache's real limit instead of a claim that is false on ZFS
All checks were successful
CI / linux-portable (push) Successful in 2m56s
CI / windows-gate (push) Successful in 4m53s
CI / release (push) Successful in 2m5s
Run 174's Linux job failed on
ASilentlyCorruptedPackageOfTheSameSizeStillFailsAndDropsTheCache. It passed in
isolation on that same machine, and passed under full-suite load there too, so
it looked like a flake. It is not.

Measured on the runner:

    same-mtime collisions: 141 / 200
    fs type: zfs

Its /tmp is ZFS, whose timestamp granularity is coarse enough that a same-size
rewrite usually lands on the SAME last-write time. So the startup fast path —
size plus write time — cannot see that modification, and the test was right to
fail. LU1's commit message claimed "truncating or touching the package still
blocks launch"; on a coarse-timestamp filesystem the second half of that is
false. NTFS's 100 ns resolution is why it never showed on Windows.

Rather than relax the test until it passes, the contract is now stated as two
facts that are true everywhere instead of one that is not:

- A same-size corruption whose write time moves is caught at startup. The test
  moves the timestamp explicitly instead of trusting the clock, so it asserts
  the mechanism rather than the filesystem's resolution.
- A corruption preserving BOTH size and write time is NOT caught at startup and
  IS caught by a forced full verification — which is exactly what the
  launcher's Verify files button runs. New test, so the escape hatch is
  covered rather than merely mentioned.

PreparedAssetVerificationCache now documents the limit with the measurement, so
the next reader does not have to rediscover it from a red pipeline.

Verified on Windows (11 passed) and five consecutive runs on the ZFS runner
itself (11 passed each). Full solution 14,375 passed, 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 22:03:52 +02:00
Erik
d233538f2c fix(tests): the launcher/headless command-line contract needs executable stubs on Linux
Some checks failed
CI / linux-portable (push) Failing after 3m0s
CI / windows-gate (push) Failing after 5m0s
CI / release (push) Has been skipped
Run 173's Linux job failed on the contract test added one commit earlier — my
test, not the product.

LauncherExecutableSet refuses a host that exists but has no execute bit on
Linux (HasUnixExecutePermission), which is a real and useful check: an update
whose extraction lost its permissions would otherwise fail deep inside process
start instead of at the launch gate. The stubs were written with
File.WriteAllText, which is 0644, so on Linux every one of the four tests died
at that gate before reaching the command-line contract they exist to pin.

Windows never sees this — the predicate short-circuits to true off Linux — so
the test passed locally and could only fail on the runner.

Stubs are now created through a helper that chmods them executable on non-
Windows. Verified on Windows (4 passed); the Linux half is what run 174 checks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 21:53:19 +02:00
Erik
6ab5d8ce0f feat(launcher): LU9/LU10 — stop logs out for real, sessions read plainly, logout lands on character select
Some checks failed
CI / linux-portable (push) Failing after 2m23s
CI / windows-gate (push) Successful in 5m30s
CI / release (push) Has been skipped
Five things from the user's gate.

STOP NOW ACTUALLY LOGS OUT. The UI gave the client five seconds and then
killed it. That is not enough for a graphical client to send its logout, wait
for the server to acknowledge, and tear down a mapped 28 GB world — so Stop
routinely ended in a kill, which sends the server nothing, which is exactly
what leaves the account held. Thirty seconds now, with the kill still there as
a genuine last resort, and the status says "Logging out…".

THE SERVER-SIDE HOLD IS MODELLED INSTEAD OF DISCOVERED. A session that ends
without the host running its own teardown may leave the account logged in
server-side for minutes. Launching again inside that window does not queue or
retry — it fails with a bare "CharacterList not received", which reads as a
broken launcher rather than a busy server. The orchestrator now records whether
each session ended gracefully (the host reported its own exit AND exited zero —
a killed or crashed child can satisfy neither) and refuses that account for
three minutes afterwards, saying how many seconds are left. A graceful exit
never starts a hold.

READABLE TERMINAL TEXT. "Exited: connection-error (code 5)" becomes "Could not
reach the server — the server may hold this account for a few minutes";
"Exited: process-exit (code 0)" becomes "Exited gracefully — logged out
cleanly". Live sessions still show the host's own status line, which is the
most informative thing available while one is running.

COLUMN HEADERS on the sessions list — ACCOUNT / CHARACTER / STATUS / DETAIL,
sharing the row template's widths so they stay aligned.

LOGOUT LANDS ON THE CHARACTER SCREEN (LU10). The toolbar X was already wired
correctly: IndicatorBarController's EndCharacterSessionButtonId 0x100000FA runs
retail's EndCharacterSession, and LiveSessionController's logout transaction
already ends by resetting the world generation and calling
CharacterSelectionState.Begin. What was missing is where that lands: the
retained UI built its character-selection and character-creation bindings only
when NO character selector was supplied, so a launcher-started session logged
out into a client with no screen to return to. The selector decides how a
session STARTS; it must not decide whether the select screen EXISTS. Both
binding sets are now unconditional.

The composition test that pinned the old gate is updated to pin the new
contract — the retained UI must not branch on the selector at all — rather than
being deleted.

App 5380 passed, Launcher.Core 336, Launcher 76, Headless 169. Not pushed; the
user is testing locally.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 21:27:26 +02:00
Erik
18bbd37779 feat(launcher): LU8 — logging in IS the character refresh; put Play above the fold
Two things the user hit while gating LU7.

1. "Refresh characters" disconnected the session they were playing. It opened a
   SECOND connection to the same account purely to read the roster, which the
   server treats as a new login — so refreshing while logged in kicked them
   out. It was also redundant the whole time: every ordinary login already
   carries the roster in the host's own status stream, and the orchestrator
   already folds it into the profiles (ApplyRosterLocked runs for play sessions,
   not just probes). Removed, along with "Add cached character", which existed
   to paper over a roster the launcher can now always obtain by itself. The
   account page says what happens instead: characters appear after you log in.

   ProbeAsync stays in Core — headless bots and the CLI use it, and it has its
   own tests. What is gone is offering it to a player as a button whose only
   effect they could observe was being disconnected.

   AnOrdinaryLoginFoldsTheReportedRosterIntoTheStore pins the replacement,
   including that it persists so the tree is still populated after a restart.

2. "There is no headless or gui option" under a selected character. The buttons
   were there — below the fold. The character page led with a plugins/login-
   commands form whose two 96px text boxes pushed the Launch card past the
   bottom of the scroll area, so the primary action was invisible unless you
   scrolled. Launch now comes first and the settings form sits under it. A
   player should never have to scroll to find Play.

Full solution 14,374 passed, 0 failed under the release-gate filter.

Not pushed — the user is testing locally first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 20:48:15 +02:00
Erik
2bff44a9fa fix: launcher-started headless sessions and character refresh never ran at all
The launcher spawned the headless host as

    acdream-headless --config <path>

but HeadlessCommandLine.Parse reads arguments[0] as the COMMAND and accepts
only "validate" or "run". So every launcher-started headless session and every
"Refresh characters" died on its first instruction with

    Invalid command. Run --help for usage.        (exit 64)

The user's own cache shows it six times over two days. It was invisible because
the failure is an exit code in a status file, not something the UI says out
loud — which is how it survived a whole campaign whose gates exercised the
headless host through its CLI directly, never through the launcher's spec.

The graphical host takes a bare "--session-config" and has no command word;
this sibling call was written to match it. Both headless call sites now pass
"run" first. A probe is an ordinary "run" whose session config carries
mode: "probe" — the difference is in the document, not the command line, so
one fix repairs refresh and headless play together.

LauncherHeadlessCommandLineContractTests is the connection that was missing:
it takes the argument vector the launcher will really use and hands it to the
parser the host will really use, for probe and for headless play, and pins that
the graphical arguments are deliberately NOT a headless command line. The two
sides cannot drift again without failing here. Headless.Tests already
referenced both assemblies, so this needed no new coupling.

Also LU7, at the user's direction: a selected character now offers only Play
and Headless. Choosing a character means choosing to play AS that character, so
"Character select" — which deliberately picks no character — belongs to the
account page alone, where it already lives. The per-character GuiSelect command
and its capability are removed rather than left as dead surface.

Full solution 14,374 passed, 0 failed under the release-gate filter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 20:33:51 +02:00
Erik
955c618013 fix: make locale-independence real, not assumed — parsing, casing, comparison
All checks were successful
CI / linux-portable (push) Successful in 3m46s
CI / windows-gate (push) Successful in 5m5s
CI / release (push) Successful in 1m56s
Follow-up to the retail-text fix. "Green under sv-SE" is not the same as "runs
on any locale", so this establishes the latter by running the suite under
cultures chosen to break different things, and fixing what they broke.

ar-SA found a genuine defect the Swedish runner cannot see: the resolution
parser read "1920x-1" through the ambient culture, and ar-SA's negative sign is
not ASCII '-', so the parse failed and the height silently became 0 instead of
-1. Both copies of that parser (App settings targets and the UI settings store)
now parse invariantly.

Audited every remaining culture-sensitive operation in src/ rather than fixing
only what a test happened to catch:

- Numeric Parse/TryParse with no IFormatProvider: 11 sites, all reading
  MACHINE-readable input — env vars (ACDREAM_LIGHT_DEBUG, ACDREAM_NET_DROP_*,
  streaming/quality knobs), CLI arguments, "1920x1080" settings keys, a chat
  command's price argument, and the launcher's bake thread count, which is
  handed straight to a child process command line. All pinned to
  InvariantCulture.
- ToUpper()/ToLower() with no culture: none. The Turkish-I class was already
  clean, and tr-TR confirms it.
- StartsWith/EndsWith/IndexOf(string) with no StringComparison: one —
  ChatInputParser's "@" prefix test, which is a culture-sensitive comparison
  for a single ASCII character. Now the ordinal char overload.

Verified: 13,958 tests pass identically under the machine default, sv-SE,
tr-TR, ar-SA, and de-DE. (The two launcher test assemblies are excluded from
this run only because a running acdream-launcher.exe holds its own binary; the
one launcher change here is the thread-count parse.)

Dates remain on the current culture by intent, unchanged from the previous
commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 20:11:32 +02:00
Erik
6a15dd063c fix: retail text and golden-string tests must not follow the machine's locale
All checks were successful
CI / linux-portable (push) Successful in 3m13s
CI / windows-gate (push) Successful in 5m15s
CI / release (push) Successful in 2m2s
Run 170's Windows gate went red on 37 tests across four assemblies while the
same commit passed 14,370/0 locally. The failures were all one family:

  Expected: "You have 1 500p"     <- built with the machine's culture
  Actual:   "You have 1,500p"     <- production, correctly invariant

The runner is Swedish; this dev box is not. These tests had been passing on CI
only because that machine's registry locale had been pinned by hand — machine
state, which came undone (almost certainly the reboot after today's hang).
Re-pinning it would be a workaround on one machine for a defect in the repo,
so this fixes the repo instead.

Two genuinely different bugs were hiding in that one symptom.

1. TESTS that build an expected string with the ambient culture and compare it
   to invariant production output, and test-side recording sinks whose traces
   are compared against literal golden strings. Those only ever passed on a
   machine that happens to format like the invariant culture. Pinned to
   InvariantCulture: the vendor purse/cost expectations, and the motion-funnel,
   animation-sequencer, framebuffer-resize, resource-slot, and runtime-attack
   trace sinks.

2. PRODUCTION that formats player-visible retail text with the ambient culture.
   This one matters beyond CI: retail is a US client, so it shows "2.50",
   "1,500p" and "(-20)" to everyone. On a Swedish machine acdream was showing
   "2,50", "1 500p" and "(-20)" with U+2212 MINUS SIGN — the audience for this
   alpha is literally Swedish. Converted 76 sites to InvariantCulture across the
   item/creature appraisal formatters, the character stat panel's buff and vitae
   parentheticals, the appraisal and link-status controllers, the chat
   /framerate and /location output, the camera sensitivity toast, the
   time-override toast, the F3 dump, the sky diagnostics, and the world-frame
   invariant-failure message.

   DATES are deliberately left on the current culture (CharacterController's
   birth/login stamp, RuntimeHouseState's purchase expiry). Retail has no answer
   for a non-US player's date format, and forcing "08/19/2026 7:00:00 PM" on
   them is a UX decision, not a retail-fidelity one.

Apparatus, so the next occurrence is reproducible instead of mysterious:
tests/TestCultureInitializer.cs adds an opt-in ACDREAM_TEST_CULTURE knob to
every test assembly, linked in through a new tests/Directory.Build.props.
Unset — what CI and everyone runs — it changes nothing.

  ACDREAM_TEST_CULTURE=sv-SE dotnet test ...

reproduced all 37 CI failures on this machine plus 6 more the runner's own
locale does not surface (the Unicode-minus family), and drove the fix.

Verified both ways on the full solution under the release-gate filter:
default culture 14,370 passed / 0 failed, and ACDREAM_TEST_CULTURE=sv-SE
14,370 passed / 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 19:56:09 +02:00
Erik
6e6e6f8de4 docs: Campaign LU is code-complete; record the gate limitation
Some checks failed
CI / linux-portable (push) Successful in 3m14s
CI / windows-gate (push) Failing after 6m27s
CI / release (push) Has been skipped
All six slices are committed locally with the full solution green at 14,370
tests. Records the one thing a gate tester will hit that is NOT a Campaign LU
defect: the installed client predates the #420 character-select crash fix, so
Play still dies until a release carrying it is published.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 18:55:27 +02:00
Erik
09305be6c6 feat(launcher): LU5/LU6 — one Play button per character, and sessions say who is playing
LU5. The per-character panel offered "GUI — enter world", "GUI — character
select" and "Headless" as three equal-looking buttons, above a "Default launch
mode" combo. It now leads with one primary **Play** that enters the world as
the selected character, with Character select and Headless kept as deliberate
secondary choices.

The combo is gone. It was never consulted by anything: every launch button
passes its own mode and LauncherOrchestrator.LaunchAsync overrides the
profile's stored mode with it (CloneCharacter(character, mode)). A setting that
changes nothing is worse than no setting, and this one made the three buttons
look like they obeyed it. The stored value is untouched.

Worth recording for whoever reads the LU5 acceptance: the launcher-side
plumbing was already correct end to end — orchestrator, selector composition,
and the client's own "skip character select when a selector is present" gate.
What actually made launching a character fail was #420, a client crash on the
character-select screen, fixed separately. Every play session in the user's
cache had no character selector, which is consistent with them only ever
reaching the select-screen paths.

LU6. Rows read `server / account / character`, then the launch mode
(Gui/GuiSelect/Headless/Probe), then the raw LauncherActivityState enum name,
then a status string. The launch mode is launcher bookkeeping — it says how the
process was started, which tells the person watching nothing and is meaningless
once the client is up.

Rows now show the account, the character (or "Character select" while one is
still being chosen, "Character refresh" for a roster probe), and one plain word
derived from the host's own status stream: Starting -> Character select ->
In game -> Stopping -> Stopped / Failed. A Play launch and a character-select
launch both read "In game" once the player is actually in it.

The orchestrator now KEEPS the identity from the host's enteredWorld event
instead of only formatting it into a status sentence, so a character-select
session stops being anonymous the moment someone enters the world.

Tests: LauncherSessionRowViewModelTests (16 — every state's wording, in-game
independent of launch mode, the character-select placeholder and its
replacement, probe labelling, stop gating). Full solution 14,370 passed,
0 failed, 0 skipped under the release-gate filter.

Campaign LU slices LU5 and LU6.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 18:54:37 +02:00
Erik
0a2defb618 feat(launcher): LU4 — first-run setup ends with "Setup complete" and an OK button
Setup used to finish by leaving a full progress bar and a status line on
screen, with the same Validate / Cancel bake / Close / Build and install row
underneath. Nothing said "you are done" and nothing said what to press.

The wizard now swaps its whole form for a plain completion panel: "Setup
complete", one sentence saying the content was built and verified, and a single
OK that closes the dialog and returns to the launcher.

Raised at exactly one point — after _onInstalled publishes the record — so the
launcher behind the dialog is already in its launch-enabled state when OK is
pressed, and the "Client setup required" banner is gone the moment the user
gets back. The cancelled and failed branches deliberately never reach it and
keep their existing status/error reporting.

Tests: FirstRunSetupEndsWithACompletionPanelThatOkReturnsFrom (form hidden,
panel shown, record published before OK, wizard reopens as an ordinary form
afterwards) and AFailedFirstRunSetupNeverShowsTheCompletionPanel.
Launcher 61 passed.

Campaign LU slice LU4.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 18:51:08 +02:00
Erik
a01ff42640 feat(launcher): LU2/LU3 — one update question at startup, and it restarts itself
The update surface was a panel the user had to reason about: Check again,
Rollback client, Stage launcher, Install client, Cancel, Close, plus an
installed/available version table, a minimum-launcher-version sentence, and a
"restart required" banner they had to act on. Reaching it meant knowing to
press "Check for updates" in the header.

Now: the feed is checked once at startup. If nothing is out of date, nothing
appears. If something is, one dialog says what is new and offers Update or
Not now.

Launcher before client, deliberately. A client release can declare a minimum
launcher version, so updating the launcher first is what makes the client
update installable at all — and it means nobody is ever shown "install
launcher X or newer before the client update", which is not a sentence a
player should have to read.

A launcher update now restarts into the new build by itself. That reuses the
existing, proven handoff rather than inventing a second one: LauncherSelfUpdate
Bootstrap.TryApplyStagedUpdateNowAsync starts the staged payload in helper mode
against the CURRENT process, exactly as ordinary startup does, and the launcher
then shuts down. Restarting by spawning a fresh copy of the current launcher and
letting its startup notice the staged plan would look simpler and be wrong: the
helper would wait on the new copy while the old one still held its own
executable mapped, so the file replacement could fail. The staged-helper launch
is extracted into one private method both paths call, so they cannot drift.

Deleted: the header "Check for updates" button, OpenCommand, CheckCommand,
InstallClientCommand, StageLauncherCommand, RollbackCommand, CloseCommand, the
version table, IsLauncherMinimumBlocked/MinimumLauncherStatus, the restart
banner, and LauncherUpdatePhase plumbing through the view model.

NOT deleted — none of the safety changed: manifest validation, bounded verified
download, safe ZIP extraction, versioned install with an atomic current.json
switch, the update session barrier, and rollback all still live in
AcDream.Launcher.Core/Updates. Rollback simply has no button; it remains
reachable as Core API with its own tests. The complexity the user objected to
was the panel, not the machinery underneath it.

An unreachable feed stays silent. A friend with no internet must still reach
their characters, so a failed startup check shows nothing at all rather than an
error to dismiss.

Tests: LauncherUpdateViewModelTests rewritten against the new surface (8 tests
— nothing-to-do stays silent, client update installs, launcher update stages
then restarts without touching the client, no-restart-seam fallback, silent
offline, Not now, refused while a session runs, failed install reports why).
Tests for the deleted commands are removed with them, not skipped.
Launcher 59 passed, Launcher.Core 335 passed.

Campaign LU slices LU2 and LU3, landed together because the new prompt replaces
the old one in the same files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 18:49:19 +02:00
Erik
00d1278228 feat(launcher): LU1 — stop hashing 28 GB before the launcher window appears
Measured on the user's machine: %LOCALAPPDATA%\acdream\pak\acdream.pak is
29,908,271,024 bytes and SHA-256 over it takes 24.1 s at 1.16 GB/s. App
.OnFrameworkInitializationCompleted ran exactly that hash synchronously,
before constructing the window, and the digest came back identical to the one
install.json already recorded. So the launcher took roughly half a minute to
appear in order to re-confirm a fact that had not changed. A friend does not
see it only because they have no package installed yet — verification
short-circuits at "nothing installed" — so it would hit them the moment
first-run setup finished.

Startup now checks the cheap facts (size, last-write time) and skips only the
hash, and only when a previous FULL hash of that same file agreed with the
install record. Everything that should hash still does: install, update, the
crash-recovery backup path, and a new explicit "Verify files" button.

The remembered fact lives in a SIDECAR (install.verification.json), not as a
new field on the install record: LauncherInstallRecordStore reads install.json
with JsonUnmappedMemberHandling.Disallow, so a new property there would make an
older launcher build reject the record outright and demand a fresh ~28 GB bake
after a rollback. An unknown sidecar is simply ignored by builds that predate
it. The cache type never throws — it sits in front of a guarantee, so every
failure mode (missing, corrupt, unknown schema, unwritable) degrades to
"hash it again" rather than to a failed launch.

Two subtleties worth keeping:
- The write time is re-read after the hash and the entry is only written when
  it is unchanged. A writer racing a multi-second hash would otherwise be
  remembered under the OLD timestamp, and the next startup would trust a
  digest that never covered those bytes.
- A hash that disagrees with the record invalidates the entry, so a stale
  "verified" fact cannot outlive the evidence that produced it.

Tests: PreparedAssetVerificationCacheTests (10) counts hash invocations through
the store's injectable hasher and covers second-startup skip, forced full
verification, touched package, same-size silent corruption, resize, a cache
digest that disagrees with the record, three unreadable-cache shapes, and
backup recovery still hashing. Plus two LauncherWindowViewModel tests for the
Verify files command. Launcher.Core 335 passed, Launcher 57 passed.

Note for the first run after this ships: the very first startup still pays one
full hash to learn the digest for the installed file, and every startup after
that is instant.

Campaign LU slice LU1. Plan: docs/plans/2026-08-19-launcher-usability-campaign.md

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 18:40:34 +02:00
Erik
a34e8f2a17 fix #420: seed face-segment media states so character select stops crashing the client
Every launcher-started play session on 2026-08-19 died a few seconds after
login. The user's own session evidence shows it three times in a row:
started -> connected -> characterList -> exited code 1 "crashed", with
client.err.log carrying

  System.ArgumentNullException: Value cannot be null. (Parameter 'key')
     at System.Collections.Generic.Dictionary`2.FindValue(TKey key)
     at AcDream.App.UI.UiButton.OnDraw(UiRenderContext ctx)

UiButton allocated its per-face-segment media-state array as `new string[n]`,
leaving every element null, while the single-face sibling _faceMediaState was
correctly seeded to "" (DirectState). NextMediaState returns `current`
unchanged on three of its four arms — including retail's own "committed state
authored with an empty media array keeps the previous media playing" rule — so
on a multi-segment button whose committed state carries no media the null
survived the first SyncMediaStates and reached
ElementInfo.StateMedia.TryGetValue(null), throwing mid-paint and taking the
process down.

Seed the array with "" at construction. That is what the constructor's
existing comment already claimed the media machine did ("the media machine
begins on the element's BASE media"); only the segment array was left out.

Verified by reverting the one-line fix: the new regression test throws
ArgumentNullException from UiButton.ActiveFile, the same frame as the live
crash. AcDream.App.Tests UiButton filter: 41 passed, 3 skipped.

Found while investigating Campaign LU item 4 ("launching the selected
character doesn't work") — this is why nothing worked. Also lands the Campaign
LU plan doc, whose recon section records the mechanisms the remaining slices
build on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 18:32:28 +02:00
Erik
a3b0455f59 ci: put the downloads in the 'latest' release, not just the pointer
All checks were successful
CI / linux-portable (push) Successful in 3m14s
CI / windows-gate (push) Successful in 4m52s
CI / release (push) Successful in 1m56s
'latest' held only manifest.json, which serves the launcher but not a person:
it sits at the top of the Releases page with nothing to click, so a friend has
to know to scroll past it to a build tagged with a timestamp. That is the exact
friction this feed exists to remove.

It now carries the client and launcher zips as well, and its body says plainly
which file to download and what to do with it. Storage cost is flat, not per
build: 'latest' is deleted and recreated each publish, so it is one extra copy
at any time rather than one per release.
2026-08-19 16:25:48 +02:00
Erik
172bcc2183 docs: note that docs-only pushes skip the pipeline 2026-08-19 16:15:04 +02:00
Erik
988d242ad9 ci: skip the pipeline for docs-only pushes
All checks were successful
CI / linux-portable (push) Successful in 3m11s
CI / windows-gate (push) Successful in 4m49s
CI / release (push) Successful in 1m48s
Half of today's pushes were documentation, and each cost a full ~7-minute
clean build, 14k tests, and a 121 MB release for changes no test can fail on.
Pushes touching only docs/, the memory trees, or markdown no longer trigger
the gate; any code path still runs the full uncached pipeline, and manual
dispatch is always available.

Deliberately NOT build caching instead: the gate's value is proving a
from-nothing build (what a friend's machine does), and stale bin/obj serving
deleted code is a documented past failure class in this repo.
2026-08-19 16:14:48 +02:00
Erik
7428dda715 docs: route CLAUDE.md's canonical-state list to the CI/release SSOT
All checks were successful
CI / linux-portable (push) Successful in 3m13s
CI / windows-gate (push) Successful in 4m51s
CI / release (push) Successful in 1m49s
Sessions start from CLAUDE.md's reading list; the Gitea pipeline, the
self-hosted runners, the release/pointer layout, and the Lane=Timing rule now
have a one-line entry there so the next session finds docs/ci-and-releases.md
before touching CI.
2026-08-19 16:04:25 +02:00
Erik
20905e16fc docs: warn against leaving load on a runner
All checks were successful
CI / linux-portable (push) Successful in 3m10s
CI / windows-gate (push) Successful in 4m52s
CI / release (push) Successful in 1m52s
A stress run left going on the Windows runner kept 17 dotnet processes alive
and competed with CI for the same machine for roughly half an hour, slowing
every job and making the load-sensitive failures it was meant to diagnose more
likely. Records the cleanup commands and the caveat that the runner agent
itself should be left alone.
2026-08-19 15:53:23 +02:00
Erik
3dd1af5ec1 ci: run Core.Net single-threaded on Linux, as its own step
All checks were successful
CI / linux-portable (push) Successful in 3m10s
CI / windows-gate (push) Successful in 4m48s
CI / release (push) Successful in 1m53s
Push 2 went red on a SIXTH member of the load-sensitive family
(PrunedId_RejectAtFreshSequence_ReclaimKeepsTheStreamAligned), with the
assembly taking 59 s on Linux against ~7 s on Windows. Laning members one at a
time was the pattern that already failed to converge, so this addresses the
assembly instead.

The split is measured, not defensive: on this 6-core container Core.Net FAILS
in 40 s with default parallelism and PASSES in 10 s single-threaded. Its
sessions do real socket work on background threads, so contention both breaks
and slows them. Windows keeps default parallelism — 18 cores, ~7 s, and it
REGRESSED when the same assembly was serialized there.

An earlier attempt passed the flag through a bash array inside the shared loop
and never reached dotnet. This gives the project its own explicit invocation so
the flag cannot be swallowed.
2026-08-19 15:43:41 +02:00
Erik
315d4f7aad docs: point the documentation map at the Timing lane
Some checks failed
CI / linux-portable (push) Failing after 2m38s
CI / windows-gate (push) Successful in 4m53s
CI / release (push) Has been skipped
The CI entry now names Lane=Timing and routes to release-gate.md, which carries
the evidence and the bar for adding a test to it. Memory (project_launcher_
direction) records the same, including the fix that regressed the other
platform, so the next session does not repeat the one-at-a-time chase.
2026-08-19 15:37:44 +02:00
Erik
c155db74d1 test: introduce Lane=Timing for load-sensitive tests, and stop chasing them individually
All checks were successful
CI / linux-portable (push) Successful in 3m27s
CI / windows-gate (push) Successful in 5m34s
CI / release (push) Successful in 1m54s
Four separate fixes each surfaced a different member of the same family, and
one of them (serializing Core.Net.Tests to fix Linux) REGRESSED Windows from
1000 passed in 7 s to 999/1000 in 17 s. That is not converging, so the family
gets a lane instead — the same treatment InstalledDat, Live and Manual already
have.

Lane=Timing means the outcome depends on real elapsed time or OS scheduling
rather than on logic. Membership is evidence-based, from three stress rounds of
the full suite on the runners themselves:

  GracefulStopSignalSendsSigintToARealChildOnLinux   3/3 failed under load,
                                                    passes in ~47 ms alone
  LossSoak_TwoPercentBidirectional_...              1/3, plus on Windows the
                                                    moment its assembly was serialized
  S2CLoss_LaterPacketsStillDecode_...               1/3
  PausedSelector_SeededDroppedServerReady_...       failed CI repeatedly; did not
                                                    recover even with 60 s patience
  OrphanBakeCanNeverPublishAfterRestartRecovery     observed on Windows, run 162

Nothing is weakened or deleted: 997 Core.Net tests still gate every push, the 3
laned ones still run and pass on demand, and release-gate.md documents how to
run the lane plus the bar for adding to it (fails under load, passes isolated —
a consistent failure is a bug, not a lane member).

Also removes the ad-hoc Core.Net parallelism special-case from the Linux job,
which this supersedes.
2026-08-19 15:29:10 +02:00
Erik
6923ca02bd ci: prune old releases, keeping the newest five
Some checks failed
CI / linux-portable (push) Failing after 2m3s
CI / windows-gate (push) Failing after 4m52s
CI / release (push) Has been skipped
Releases were retained forever. Each build is ~121 MB of attachments, so the
Gitea server grew by that much on every push to main — five builds had already
reached 606 MB, and nothing would have stopped it.

The release job now deletes versioned releases beyond the newest five, and
their tags with them (a tag survives its release and would otherwise pile up).
Five keeps a previous build available for a friend or a bisect while staying
well under a gigabyte. The 'latest' pointer is explicitly excluded from
pruning: it is the launcher's feed, not a build.
2026-08-19 14:59:28 +02:00
Erik
1a07f3e7f4 ci: run Core.Net.Tests single-threaded on the Linux runner only
All checks were successful
CI / linux-portable (push) Successful in 3m4s
CI / windows-gate (push) Successful in 4m59s
CI / release (push) Successful in 1m51s
PausedSelector_SeededDroppedServerReady_RecoversOnIdleSweep kept failing on the
Linux runner even after its wall-clock patience was widened to 60 s — the
assembly ran 1 m 39 s and recovery still never completed, so this is real
starvation of the session's background threads, not a tight timeout.

Measured on the runner itself:
  default parallelism      FAILED  in 40 s
  MaxParallelThreads=1     PASSED  in 10 s

Serial is both correct and four times faster there, because the contention was
also costing wall-clock. Scoped to this one project on Linux: Windows has 18
cores, passes with default parallelism in ~7 s, and serializing this assembly
for everyone previously REGRESSED it (1000 passed in 7 s -> 999/1000 in 17 s).

Replaces the earlier '-- xUnit.MaxParallelThreads=2' that was applied to every
Linux project: too weak to help and too broad to be safe.
2026-08-19 14:49:28 +02:00
Erik
ff01423f3f ci: put the launcher's update pointer in a release, and delete the dist branch
Some checks failed
CI / linux-portable (push) Failing after 3m22s
CI / windows-gate (push) Successful in 5m27s
CI / release (push) Has been skipped
The dist branch existed to carry ~120 MB payloads that could not go on main.
Once payloads became release attachments it held one 500-byte manifest.json,
so it was a whole branch for a reason that no longer applied.

The pointer is now a release asset too: each publish recreates a one-asset
 release naming the versioned build. Forgejo has no
/releases/latest/download/ route (404), so a pointer is still required — but
keeping it in a release means nothing about distribution lives in git: no
payload branch, no bot commits on main, and no push that could retrigger the
pipeline (which is why writing the manifest to main was not the answer either).

Recreating the tag deletes the old release AND its tag; the tag outlives its
release and would otherwise block recreation.

Versioned releases are retained, so older builds stay downloadable.
tools/publish-dist.ps1 is removed — publishing is CI's job now.
2026-08-19 14:40:41 +02:00
Erik
353231fe6e docs: correct two landmine rows that my own later fixes disproved
All checks were successful
CI / linux-portable (push) Successful in 2m56s
CI / windows-gate (push) Successful in 4m56s
CI / release (push) Successful in 1m51s
The transport row said to cap xUnit.MaxParallelThreads; serializing that
assembly actually REGRESSED Windows (1000 passed in 7 s -> 999/1000 in 17 s)
and the real fix was widening the virtual-clock harness's wall-clock patience.

The Avalonia row said to serialize via xunit.runner.json; that does not fix it
either — proven twice, including with a compiled-in attribute so delivery could
not be blamed. It needs a real desktop and is now Lane=Manual.

Both rows now record what was tried and disproved, which is the part worth
keeping.
2026-08-19 14:23:31 +02:00
Erik
311c8bd3df test: lane the one Avalonia test that needs a real desktop session
All checks were successful
CI / linux-portable (push) Successful in 2m53s
CI / windows-gate (push) Successful in 5m6s
CI / release (push) Successful in 1m52s
MainWindowViewTests.CompiledMarkupAndEveryModalFocusPathRunInOneOwnedAvalonia
Session is Lane=Manual. Measured across five environments on 2026-08-19:

  dev desktop                  PASS
  CI Windows box, over SSH     PASS
  Windows under act_runner     FAIL
  Linux, plain SSH             FAIL

Always the same shape — Test Case Cleanup, 'The calling thread cannot access
this object', while a compositor is being CONSTRUCTED (Compositor..ctor ->
DefaultRenderLoop.Add -> VerifyAccess).

Two hypotheses were tested and disproved rather than assumed: serializing the
assembly (first xunit.runner.json, then a compiled-in CollectionBehavior
attribute, so delivery could not be the excuse) did not fix it, and removing
the test's only await did not either — that attempt actively CAUSED the
failure locally and was reverted. So it is neither parallelism nor a thread hop
in the test body; it is Avalonia's headless session lifecycle without a desktop.

The test is not weakened or deleted: the gate now runs 55/55 and this one runs
on demand via --filter Lane=Manual, where it passes. That matches how the
InstalledDat and Live lanes already work.
2026-08-19 14:15:21 +02:00
Erik
45f88d2d18 test: serialize Launcher.Tests via an assembly attribute, not xunit.runner.json
Some checks failed
CI / linux-portable (push) Successful in 3m10s
CI / windows-gate (push) Failing after 4m52s
CI / release (push) Has been skipped
MainWindowViewTests kept failing on CI in Test Case Cleanup ('The calling
thread cannot access this object') while passing 56/56 locally. The cause was
delivery, not the fix: xunit.runner.json only takes effect if it is copied
beside the test DLL, and under CI's 'dotnet build' + 'dotnet test --no-build'
split it did not arrive, so CI ran with parallel collections while local runs
did not.

[assembly: CollectionBehavior(DisableTestParallelization = true)] is compiled
into the DLL and cannot fail to deploy. It lives beside the existing
AvaloniaTestApplication/AvaloniaTestIsolation attributes, which document the
same thread-affinity hazard. The json and its csproj copy rule are removed so
there is one source of truth.
2026-08-19 14:06:24 +02:00
Erik
daf28bfec5 test: revert Core.Net serialization; widen the virtual-clock harness patience instead
Some checks failed
CI / linux-portable (push) Successful in 3m21s
CI / windows-gate (push) Failing after 4m57s
CI / release (push) Has been skipped
Serializing AcDream.Core.Net.Tests to fix a Linux starvation REGRESSED Windows,
which had been green: Core.Net went from 1000 passed in 7 s (run 154) to
999/1000 in 17 s (run 155), taking down LossSoak_TwoPercentBidirectional_
ZeroMessageLoss_LedgersConverge, a test that had never failed. That trade trans-
ferred the flake between platforms rather than fixing anything, so it is
reverted: no xunit.runner.json, no csproj change.

The actual fragility is narrower than it looked — exactly ONE test uses
real-time waits (PausedSelector_SeededDroppedServerReady_RecoversOnIdleSweep),
and its harness drives a VIRTUAL clock while asserting on 2 s wall-clock
windows. Those windows are patience for background work, not part of the
assertion, and 2 s only ever encoded 'the machine is idle'. They now share a
60 s HarnessPatience constant.

Nothing about what the test verifies changes: recovery must still occur, a
genuine failure to NAK still fails, and a real hang is still bounded. Campaign N
transport code is untouched.

Local: 1000/1000 in 6 s under the gate filter.
2026-08-19 13:59:42 +02:00
Erik
03bcc1a41b test: serialize AcDream.Core.Net.Tests so real-time transport waits are not starved
Some checks failed
CI / linux-portable (push) Successful in 3m22s
CI / windows-gate (push) Failing after 5m15s
CI / release (push) Has been skipped
PausedSelector_SeededDroppedServerReady_RecoversOnIdleSweep failed twice in CI
on the Linux runner, taking 37 s and 42 s, while passing 5/5 in ~350 ms in
isolation on that same machine. The test drives a virtual clock but asserts on
real-time 2 s SpinUntil windows, so full-assembly parallelism on a 6-core
container starves it.

Passing -- xUnit.MaxParallelThreads=2 through dotnet test did not take effect.
A xunit.runner.json is read by xUnit directly and is the convention already
used by AcDream.Core.Tests and AcDream.Launcher.Tests.
2026-08-19 13:51:07 +02:00
Erik
6ef82934dc docs: SSOT for the Gitea CI pipeline and automated alpha releases
Some checks failed
CI / linux-portable (push) Failing after 2m10s
CI / windows-gate (push) Successful in 4m59s
CI / release (push) Has been skipped
docs/ci-and-releases.md documents what happens on a push to main, why Gitea
rather than GitHub (billing-blocked, private repo, and Forgejo ships no hosted
runners), both runners and their prerequisites, the release/manifest layout,
and how to verify a release with the Lane=Live install test.

Its landmine table is the part worth keeping: every row cost a red pipeline —
Node for JS actions, setup-dotnet unmirrored on data.forgejo.org, the
zombie-task timeout caused by run-release-gate.ps1 redirecting child output,
an en-SE runner locale breaking 40 tests on decimal commas, DAT tests missing
their InstalledDat lane tag, parallel-load timing flakes, and the Avalonia
compositor threading failure that must NOT be 'fixed' by de-async-ing the test.

Also records the culture finding: config, parsing and the wire are all
invariant-safe, so US and European installs behave identically; only
diagnostic strings follow the current culture.

Cross-linked from docs/README.md and release-gate.md, which keeps ownership of
the local bounded gate.
2026-08-19 13:44:55 +02:00
Erik
657ac6baca test: prove a launcher installs the client from the live Gitea release
Some checks failed
CI / windows-gate (push) Has been cancelled
CI / linux-portable (push) Has been cancelled
CI / release (push) Has been cancelled
Lane=Live end-to-end verification against the real feed, using the production
updater the GUI button calls: ReleaseManifestClient.ProductionManifestUri, real
network, real SHA-256/size verification, real ZIP extraction, real atomic
activation. Asserts what the launcher actually does next — that
LauncherExecutableSet can resolve BOTH hosts out of the activated directory,
and that current.json names the installed version — rather than merely that
files exist.

Excluded from the bounded gate (Lane=Live needs the public feed reachable);
run deliberately after a release lands. Writes only under an isolated
temporary path set, never the caller's real launcher data directory.
2026-08-19 13:43:12 +02:00
Erik
aac29e359d ci: retire the smoke workflow now that the real pipeline is green
All checks were successful
CI / linux-portable (push) Successful in 3m23s
CI / windows-gate (push) Successful in 5m40s
CI / release (push) Successful in 1m55s
smoke.yml was scaffolding to prove the self-hosted runners could execute
anything at all, back when checkout and the SDK were still unresolved. ci.yml
now builds, gates and releases on both runners, so the smoke jobs only
duplicate its environment checks. Leaving dead workflows around is exactly the
debt that confuses the next reader.

This commit also serves as the pipeline's first real push-triggered run: every
green run so far was a workflow_dispatch.
2026-08-19 13:32:19 +02:00
Erik
c5492984ef ci: tag DAT-dependent tests into the InstalledDat lane; drop invariant workaround
Some checks failed
CI / linux-portable (push) Successful in 3m26s
CI / windows-gate (push) Failing after 5m2s
CI / release (push) Has been skipped
Three tests reached the CI gate needing the installed retail DATs, which no
build machine has, and failed with FileNotFoundException on client_cell_1.dat:
  - Issue127FloodFlipReplayTests (both facts replay via ResolveDatDir)
  - FindCellListConformanceTests.FindCellList_DoorwayThreshold_IndoorPicks_
    MatchRetail, the one untagged method among already-tagged siblings
They now carry [Trait("Lane", "InstalledDat")] like every other DAT test, so
the gate filter excludes them and the local DAT lane still runs them.

Also reverts the DOTNET_SYSTEM_GLOBALIZATION_INVARIANT pin from the previous
commit. It was too blunt: it fixed the 40 decimal-comma failures but broke
ChatLogTests.FormatTimestampPrefix_UsesLiteralColons_RegardlessOfCurrentCulture,
which legitimately constructs a culture and cannot under invariant mode. The
runner's HKCU locale (LocaleName=en-SE, sDecimal=',') was corrected to en-US
instead, which is the actual defect.
2026-08-19 11:19:14 +02:00
Erik
c851ac79e5 ci: pin invariant globalization on the Windows gate
Some checks failed
CI / linux-portable (push) Successful in 3m16s
CI / windows-gate (push) Failing after 5m5s
CI / release (push) Has been skipped
The runner reports en-US interactively, but its scheduled task inherits
en-SE (English/Sweden), whose decimal separator is a comma. That broke 40
tests across App/Core/Runtime/UI.Abstractions on number formatting alone
(expected "update:0.25", actual "update:0,25"). Set-Culture does not reach
a task running without a loaded user profile, and a build gate should not
depend on a machine's regional settings regardless.

Note for follow-up: this pins CI only. The underlying culture sensitivity is
real — a Swedish-locale player would see comma-formatted numbers in these
diagnostic strings.
2026-08-19 11:11:14 +02:00
Erik
3a02fc8369 ci: cap xUnit parallelism on the constrained Linux runner
Some checks failed
CI / linux-portable (push) Successful in 3m16s
CI / windows-gate (push) Failing after 5m7s
CI / release (push) Has been skipped
FakeAceTransportTests.PausedSelector_SeededDroppedServerReady_RecoversOnIdle
Sweep failed in CI after 37 s while passing 5/5 in ~350 ms in isolation on the
same machine: under full-assembly parallel load on a 6-core container its 2 s
real-time waits get starved. The test is timing-sensitive, not broken, so cap
the runner rather than edit Campaign N transport code.
2026-08-19 11:05:16 +02:00
Erik
994d52403f ci: stream tests on the Windows gate instead of the log-redirecting gate script
Some checks failed
CI / linux-portable (push) Failing after 2m15s
CI / windows-gate (push) Failing after 5m23s
CI / release (push) Has been skipped
The windows-gate job was marked failed while the work was still running: 20
dotnet processes were alive on the runner and a complete 8.7 MB App.Tests TRX
was on disk after Forgejo had already recorded a failure.

Cause: tools/run-release-gate.ps1 redirects every bounded child process to its
own log file, so the workflow step emits no output for minutes. Forgejo treats
a task that stops reporting as a zombie and fails it. The Linux job, which
runs dotnet test directly, streamed continuously and produced real results.

The Windows job now builds and then runs each test project directly with the
same trait filter copied from the gate script's default, so output streams the
whole time. run-release-gate.ps1 remains the canonical LOCAL gate, where its
bounded-process/blame-hang machinery is the point.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 10:50:21 +02:00
Erik
b746d3d61b ci: Gitea pipeline — gate on both self-hosted runners, publish alpha releases
Some checks failed
CI / linux-portable (push) Failing after 1s
CI / windows-gate (push) Failing after 6s
CI / release (push) Has been skipped
Every push to main now runs the gate on the self-hosted runners and, when
green, publishes a Gitea Release carrying the client, launcher+bake, and
manifest.

Pipeline (.gitea/workflows/ci.yml):
- windows-gate runs tools/run-release-gate.ps1, the project's own bounded
  gate. A bare `dotnet test AcDream.slnx` is NOT usable as a gate: it fails
  ~36 tests by design, because the InstalledDat/Live/Manual/OS lanes assert
  their own preconditions. The gate script's trait filter is what excludes
  them.
- linux-portable runs the portable closure, where the Linux-lane tests
  actually execute instead of failing on Windows.
- release depends on both, so a red gate cannot publish. It is a job in the
  same workflow rather than a workflow_run trigger, whose Forgejo support is
  unreliable; `needs` is guaranteed.

No actions/setup-dotnet: data.forgejo.org does not mirror it at all (404),
and both runners carry the pinned SDK band already. actions/checkout IS
mirrored and is used normally.

Release payloads become release ATTACHMENTS, outside git history, so ~120 MB
per build never enters a branch. Only the ~500-byte manifest.json is
committed, to the payload-free dist branch, because Forgejo has no
/releases/latest/download/ route (verified 404) for the launcher to poll.
publish-bin.ps1 takes -BaseUrl so the manifest points at the release tag.

Two real gate failures fixed:
- LauncherProjectBoundaryTests asserted four `**` path filters belonging to
  the push triggers that 8be14d39 removed when workflows went manual-only.
  The assertions about what the workflow DOES are untouched.
- MainWindowViewTests failed in Test Case Cleanup with "calling thread cannot
  access this object" while passing in isolation: Avalonia's headless session
  is thread-affine and xUnit ran collections in parallel. Serialized via
  xunit.runner.json, the same settings AcDream.Core.Tests already uses.

Local gate: 12 projects, 14,346 tests, 0 failures.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 10:35:36 +02:00
Erik
5256a39fb3 ci: extend smoke workflow with a real Linux build job 2026-08-19 10:20:27 +02:00
Erik
ba20d4bbf4 ci: add Gitea Actions smoke workflow for the self-hosted Windows runner 2026-08-19 10:11:26 +02:00
Erik
f260260caf fix(launcher-feed): strip debug symbols from distribution payloads (103 MB -> 77 MB)
A stock publish shipped native debug symbols to players: libSkiaSharp.pdb
(80 MB) and libHarfBuzzSharp.pdb (20 MB) from Avalonia's rendering packages
were 100 MB of a 278 MB launcher payload. MSBuild's DebugType switches only
govern our own managed symbols, not native .pdb files arriving as package
runtime assets, so the payload build drops every .pdb before zipping.

launcher-win-x64.zip 103.4 -> 77.4 MB, client 44.5 -> 43.6 MB. The launcher
payload now also fits under GitHub's 100 MB per-file limit, though the feed
stays on the Gitea-only dist branch to keep main's history clean.

Also fixes a StrictMode crash in the lock-file warning: an empty git status
result is null, not an empty array, so .Count threw at the end of a
successful publish.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 19:56:20 +02:00
Erik
600c331ac6 feat(launcher): Gitea-backed alpha update feed replaces the GitHub Releases source
The launcher reported "no client available" because its update source was
pinned to a GitHub Releases manifest in a PRIVATE repo — nothing anonymous
could ever be fetched from it. Switch the feed to the PUBLIC Gitea repo so a
friend needs no account, and add the two commands that publish it.

- ReleaseManifestClient.ProductionManifestUri now points at
  git.snakedesert.se/erik/acdream raw on the `dist` branch. No update
  machinery changed: the existing strict reader already accepts any HTTPS
  manifest, so this is a URL swap plus a build script.

- tools/publish-bin.ps1 publishes the payloads into /bin and writes
  bin/manifest.json (schema v1, SHA-256 + size per artifact):
    client-win-x64.zip    AcDream.App + acdream-headless
    launcher-win-x64.zip  acdream-launcher + co-deployed acdream-bake
  Stamps InformationalVersion ONLY — never -p:Version, which also rewrites
  project-reference versions inside the committed packages.<rid>.lock.json
  files and churned every one of them with a throwaway build stamp.

- tools/publish-dist.ps1 pushes /bin to the Gitea-only `dist` branch from a
  throwaway worktree, leaving the developer's checkout, index, and HEAD
  untouched. It refuses a GitHub remote outright.

Why `dist` and not main: the launcher payload is ~103 MB because the launcher
and its co-deployed bake CLI are each self-contained single files (deliberate,
see AcDream.Launcher.csproj). GitHub hard-rejects files over 100 MB, and all
three refs currently track main, so payloads on main would break every GitHub
push. `dist` is a single-commit orphan branch that each publish REPLACES, so
superseded builds never accumulate. /bin stays gitignored repo-wide and is
force-added only on that branch.

Verified live: manifest and both payloads serve anonymously over HTTPS, and a
downloaded client payload matches its declared SHA-256 and size byte for byte.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 19:49:06 +02:00
Erik
8be14d3992 ci: make GitHub workflows manual only 2026-08-18 17:50:50 +02:00
1271 changed files with 196331 additions and 12797 deletions

227
.gitea/workflows/ci.yml Normal file
View file

@ -0,0 +1,227 @@
# Gitea Actions CI gate for the self-hosted runners.
#
# Deliberately does NOT use actions/setup-dotnet: data.forgejo.org (the mirror
# Gitea resolves actions from) does not host that action at all, and the
# self-hosted runners carry the pinned SDK band from global.json already.
# actions/checkout IS mirrored, so it is used normally.
#
# The suite runs through tools/run-release-gate.ps1 rather than a bare
# `dotnet test`: that script owns the xUnit trait-lane filter which excludes
# the InstalledDat / Live / Manual / OS-specific lanes. A bare `dotnet test`
# fails ~36 tests by design because those lanes assert their own preconditions.
name: CI
on:
push:
branches: [main]
# Docs-only pushes change nothing a test can fail on, and each gate run is
# ~7 minutes of clean build + 14k tests + a 121 MB release. Skip them; a
# code push (or manual dispatch) still runs everything from scratch —
# deliberately uncached, so the gate keeps proving a from-nothing build.
paths-ignore:
- 'docs/**'
- 'claude-memory/**'
- 'memory/**'
- '**.md'
workflow_dispatch:
jobs:
windows-gate:
runs-on: windows-latest
timeout-minutes: 45
steps:
- uses: actions/checkout@v6
- name: Verify the pinned SDK band resolves
shell: pwsh
run: |
dotnet --version
dotnet --list-sdks
# NOT tools/run-release-gate.ps1 here. That script redirects every child
# process to its own log file, so the step emits nothing for minutes at a
# time; Forgejo treats a task that stops reporting as a zombie and fails
# it while the work is still running (observed: job marked failed with 20
# dotnet processes still alive and a complete 8.7 MB TRX on disk). Running
# the projects directly keeps output streaming. The script stays the
# canonical LOCAL gate; the trait filter below is copied from its default.
- name: Build
shell: pwsh
run: dotnet build AcDream.slnx -c Release --nologo
- name: Test (lane-filtered, streaming)
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$filter = 'Lane!=InstalledDat&Lane!=PreparedPackage&Lane!=Live&Lane!=Manual&Lane!=Timing&Lane!=Windows&Lane!=Linux&Lane!=SystemFont&Purpose!=Diagnostic&Status!=KnownFailure'
$failed = @()
foreach ($proj in Get-ChildItem tests -Directory | Sort-Object Name) {
$csproj = Join-Path $proj.FullName "$($proj.Name).csproj"
if (-not (Test-Path $csproj)) { continue }
Write-Host "::group::$($proj.Name)"
dotnet test $csproj -c Release --no-build --nologo --filter $filter
if ($LASTEXITCODE -ne 0) { $failed += $proj.Name }
Write-Host "::endgroup::"
}
if ($failed.Count) { throw "Failed test projects: $($failed -join ', ')" }
linux-portable:
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- uses: actions/checkout@v6
- name: Portable closure (Linux lanes run here, not on Windows)
run: |
set -e
dotnet --version
# Core.Net runs SINGLE-THREADED here, on its own, and the split is
# measured rather than defensive: on this 6-core container the
# assembly FAILS in 40 s with default parallelism and PASSES in 10 s
# with one thread. Its sessions do real socket work on background
# threads, so contention both breaks and slows them. Windows has 18
# cores, passes in ~7 s parallel, and REGRESSED when serialized, so
# this stays scoped to Linux.
echo '::group::AcDream.Core.Net.Tests (single-threaded)'
dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj \
-c Release --nologo \
--filter 'Lane!=InstalledDat&Lane!=PreparedPackage&Lane!=Live&Lane!=Manual&Lane!=Timing&Lane!=Windows&Lane!=SystemFont&Purpose!=Diagnostic&Status!=KnownFailure' \
-- xUnit.MaxParallelThreads=1
echo '::endgroup::'
for p in \
tests/AcDream.Platform.Tests \
tests/AcDream.Core.Tests \
tests/AcDream.Content.Tests \
tests/AcDream.Runtime.Tests \
tests/AcDream.Headless.Tests \
tests/AcDream.Launcher.Core.Tests \
tests/AcDream.UI.Abstractions.Tests ; do
echo "::group::$p"
dotnet test "$p" -c Release --nologo \
--filter 'Lane!=InstalledDat&Lane!=PreparedPackage&Lane!=Live&Lane!=Manual&Lane!=Timing&Lane!=Windows&Lane!=SystemFont&Purpose!=Diagnostic&Status!=KnownFailure'
echo "::endgroup::"
done
release:
# Same workflow rather than a workflow_run trigger: workflow_run is a
# GitHub feature whose Forgejo support is unreliable, while `needs` is
# guaranteed. A red gate therefore cannot publish.
needs: [windows-gate, linux-portable]
runs-on: windows-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v6
- name: Compute release version
id: ver
shell: pwsh
run: |
$v = '0.1.0-build.{0}' -f ([DateTime]::UtcNow.ToString('yyyyMMddHHmm'))
"version=$v" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8
Write-Host "release version: $v"
- name: Build payloads with release-attachment URLs
shell: pwsh
env:
TAG: ${{ steps.ver.outputs.version }}
run: |
./tools/publish-bin.ps1 -Version $env:TAG -BaseUrl "${{ github.server_url }}/${{ github.repository }}/releases/download/$env:TAG"
- name: Create the release and upload payloads
shell: pwsh
env:
TAG: ${{ steps.ver.outputs.version }}
TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
$ErrorActionPreference = 'Stop'
$api = "${{ github.server_url }}/api/v1/repos/${{ github.repository }}"
$headers = @{ Authorization = "token $env:TOKEN" }
$body = @{
tag_name = $env:TAG
name = "acdream alpha $env:TAG"
body = "Automated alpha build from ${{ github.sha }}."
draft = $false
prerelease = $true
target_commitish = 'main'
} | ConvertTo-Json
$release = Invoke-RestMethod -Method Post -Uri "$api/releases" -Headers $headers -ContentType 'application/json' -Body $body
Write-Host "created release id=$($release.id)"
foreach ($f in Get-ChildItem bin -File) {
Write-Host ("uploading {0} ({1:N1} MB)" -f $f.Name, ($f.Length/1MB))
Invoke-RestMethod -Method Post -Headers $headers -Uri "$api/releases/$($release.id)/assets?name=$($f.Name)" -Form @{ attachment = Get-Item $f.FullName } | Out-Null
}
- name: Republish the `latest` pointer release
shell: pwsh
env:
TAG: ${{ steps.ver.outputs.version }}
TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
$ErrorActionPreference = 'Stop'
$api = "${{ github.server_url }}/api/v1/repos/${{ github.repository }}"
$headers = @{ Authorization = "token $env:TOKEN" }
# Forgejo has no /releases/latest/download/ route, so the launcher
# needs a pointer at a URL that never changes. A one-asset release on
# the fixed `latest` tag is that pointer. Keeping it in a release
# rather than in git means no payload branch, no bot commits on main,
# and no push that would retrigger this workflow.
$existing = Invoke-RestMethod -Method Get -Headers $headers `
-Uri "$api/releases/tags/latest" -SkipHttpErrorCheck
if ($existing.id) {
Invoke-RestMethod -Method Delete -Headers $headers -Uri "$api/releases/$($existing.id)" | Out-Null
# The tag outlives its release and would block recreation.
Invoke-RestMethod -Method Delete -Headers $headers -Uri "$api/tags/latest" -SkipHttpErrorCheck | Out-Null
Write-Host "removed the previous latest pointer"
}
$body = @{
tag_name = 'latest'
name = "Update feed -> $env:TAG"
body = "**Download ``launcher-win-x64.zip``**, unzip it, and run ``acdream-launcher.exe``. It installs the game and keeps itself and the client up to date.`n`nThis is build ``$env:TAG``."
draft = $false
prerelease = $false
target_commitish = 'main'
} | ConvertTo-Json
$pointer = Invoke-RestMethod -Method Post -Uri "$api/releases" -Headers $headers `
-ContentType 'application/json' -Body $body
# Upload the payloads here too, not just the manifest. `latest` is the
# top of the Releases page and the first thing a person sees; a
# pointer-only release gives them nothing to click and makes them hunt
# for a build tagged with a timestamp. The launcher only needs
# manifest.json, but a friend needs launcher-win-x64.zip.
foreach ($f in Get-ChildItem bin -File) {
Invoke-RestMethod -Method Post -Headers $headers `
-Uri "$api/releases/$($pointer.id)/assets?name=$($f.Name)" `
-Form @{ attachment = Get-Item $f.FullName } | Out-Null
}
Write-Host "latest now carries $env:TAG and its downloads"
- name: Prune old releases
shell: pwsh
env:
KEEP: '5'
TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
$ErrorActionPreference = 'Stop'
$api = "${{ github.server_url }}/api/v1/repos/${{ github.repository }}"
$headers = @{ Authorization = "token $env:TOKEN" }
$keep = [int]$env:KEEP
# Each build is ~121 MB of attachments, so without this the server
# grows by that much on EVERY push to main. Keep the newest $keep
# versioned releases: enough to grab a previous build or bisect a
# regression, bounded at well under a gigabyte.
$releases = Invoke-RestMethod -Method Get -Headers $headers -Uri "$api/releases?limit=100"
# Never touch the `latest` pointer — it is the launcher's feed, not a build.
$builds = @($releases | Where-Object { $_.tag_name -ne 'latest' } |
Sort-Object -Property created_at -Descending)
Write-Host "$($builds.Count) versioned release(s); keeping $keep"
foreach ($old in ($builds | Select-Object -Skip $keep)) {
Invoke-RestMethod -Method Delete -Headers $headers -Uri "$api/releases/$($old.id)" | Out-Null
# The tag survives its release and would otherwise accumulate.
Invoke-RestMethod -Method Delete -Headers $headers -Uri "$api/tags/$($old.tag_name)" -SkipHttpErrorCheck | Out-Null
Write-Host " pruned $($old.tag_name)"
}

View file

@ -3,9 +3,6 @@ name: "Copilot Setup Steps"
# This workflow configures the environment for GitHub Copilot Agent with gh-aw MCP server
on:
workflow_dispatch:
push:
paths:
- .github/workflows/copilot-setup-steps.yml
jobs:
# The job MUST be called 'copilot-setup-steps' to be recognized by GitHub Copilot Agent

View file

@ -1,66 +1,6 @@
name: Headless portability
on:
pull_request:
paths:
- ".github/workflows/headless-portability.yml"
- "AcDream.slnx"
- "src/AcDream.Platform/**"
- "src/AcDream.Launcher.Core/**"
- "src/AcDream.Launcher/**"
- "src/AcDream.Bake/**"
- "src/AcDream.Core/**"
- "src/AcDream.Core.Net/**"
- "src/AcDream.Content/**"
- "src/AcDream.Plugin.Abstractions/**"
- "src/AcDream.Runtime/**"
- "src/AcDream.Headless/**"
- "src/AcDream.App/**"
- "src/AcDream.UI.Abstractions/**"
- "tests/AcDream.Platform.Tests/**"
- "tests/AcDream.Launcher.Core.Tests/**"
- "tests/AcDream.Launcher.Tests/**"
- "tests/AcDream.Bake.Tests/**"
- "tests/AcDream.Core.Tests/**"
- "tests/AcDream.Core.Net.Tests/**"
- "tests/AcDream.Content.Tests/**"
- "tests/AcDream.Runtime.Tests/**"
- "tests/AcDream.Headless.Tests/**"
- "tests/AcDream.App.Tests/**"
- "tests/AcDream.UI.Abstractions.Tests/**"
- "tests/Fixtures/campaign-la/**"
- "tools/ShaderCompiler/**"
- "tools/compile-shaders.ps1"
push:
paths:
- ".github/workflows/headless-portability.yml"
- "AcDream.slnx"
- "src/AcDream.Platform/**"
- "src/AcDream.Launcher.Core/**"
- "src/AcDream.Launcher/**"
- "src/AcDream.Bake/**"
- "src/AcDream.Core/**"
- "src/AcDream.Core.Net/**"
- "src/AcDream.Content/**"
- "src/AcDream.Plugin.Abstractions/**"
- "src/AcDream.Runtime/**"
- "src/AcDream.Headless/**"
- "src/AcDream.App/**"
- "src/AcDream.UI.Abstractions/**"
- "tests/AcDream.Platform.Tests/**"
- "tests/AcDream.Launcher.Core.Tests/**"
- "tests/AcDream.Launcher.Tests/**"
- "tests/AcDream.Bake.Tests/**"
- "tests/AcDream.Core.Tests/**"
- "tests/AcDream.Core.Net.Tests/**"
- "tests/AcDream.Content.Tests/**"
- "tests/AcDream.Runtime.Tests/**"
- "tests/AcDream.Headless.Tests/**"
- "tests/AcDream.App.Tests/**"
- "tests/AcDream.UI.Abstractions.Tests/**"
- "tests/Fixtures/campaign-la/**"
- "tools/ShaderCompiler/**"
- "tools/compile-shaders.ps1"
workflow_dispatch:
permissions:

View file

@ -49,9 +49,6 @@
name: "acdream Hygiene Assessment"
on:
schedule:
- cron: "54 4 * * *"
# Friendly format: daily (scattered)
workflow_dispatch: {}
permissions: {}
@ -1348,4 +1345,3 @@ jobs:
/tmp/gh-aw/safe-output-items.jsonl
/tmp/gh-aw/temporary-id-map.json
if-no-files-found: ignore

View file

@ -1,9 +1,6 @@
name: Complete Release gate
on:
pull_request:
push:
branches: [main]
workflow_dispatch:
permissions:

10
.gitignore vendored
View file

@ -2,6 +2,11 @@
bin/
obj/
out/
# NOTE: the repo-root /bin folder holds the alpha distribution feed written by
# tools/publish-bin.ps1. It stays IGNORED here on purpose so a stray `git add`
# can never put ~150 MB of payloads on main (GitHub also hard-rejects any file
# over 100 MB). tools/publish-dist.ps1 force-adds it onto the Gitea-only `dist`
# branch instead, which is what the launcher's update feed reads.
# Rider / VS
.idea/
@ -59,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/
@ -109,3 +115,7 @@ studio-shots/
# Campaign V capture/evidence output - session-local, never tracked (423 MB lesson, 2026-07-29)
artifacts/
341-slope-capture.jsonl
# IconForge DAT extraction scratch (geometry + textures dumped from the
# installed client dats; regenerate with tools/MosswartArt, never commit).
tools/IconForge/work/

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

View file

@ -11,20 +11,31 @@
<Project Path="src/AcDream.Launcher.Core/AcDream.Launcher.Core.csproj" />
<Project Path="src/AcDream.Platform/AcDream.Platform.csproj" />
<Project Path="src/AcDream.Plugin.Abstractions/AcDream.Plugin.Abstractions.csproj" />
<Project Path="src/AcDream.Plugins.MossTank/AcDream.Plugins.MossTank.csproj" />
<Project Path="src/AcDream.Plugins.Smoke/AcDream.Plugins.Smoke.csproj" />
<Project Path="src/AcDream.Runtime/AcDream.Runtime.csproj" />
<Project Path="src/AcDream.UI.Abstractions/AcDream.UI.Abstractions.csproj" />
</Folder>
<Folder Name="/samples/">
<Project Path="samples/AcDream.RenderPacks.AtmosphericTier2/AcDream.RenderPacks.AtmosphericTier2.csproj" />
<Project Path="samples/AcDream.RenderPacks.NoOp/AcDream.RenderPacks.NoOp.csproj" />
<Project Path="samples/AcDream.RenderPacks.ShadowsOnlyTier2/AcDream.RenderPacks.ShadowsOnlyTier2.csproj" />
</Folder>
<Folder Name="/tools/">
<Project Path="tools/A8CellAudit/A8CellAudit.csproj" />
<Project Path="tools/AnimHookScan/AnimHookScan.csproj" />
<Project Path="tools/dump-keymap/dump-keymap.csproj" />
<Project Path="tools/LayoutDump/LayoutDump.csproj" />
<Project Path="tools/MosswartArt/MosswartArt.csproj" />
<Project Path="tools/PesChainAudit/PesChainAudit.csproj" />
<Project Path="tools/ProjectileVfxAudit/ProjectileVfxAudit.csproj" />
<Project Path="tools/RainMeshProbe/RainMeshProbe.csproj" />
<Project Path="tools/RenderPackValidator/AcDream.Tools.RenderPackValidator.csproj" />
<Project Path="tools/RetailTimeProbe/RetailTimeProbe.csproj" />
<Project Path="tools/SetupInspect/SetupInspect.csproj" />
<Project Path="tools/ShaderCompiler/ShaderCompiler.csproj" />
<Project Path="tools/SkyObjectInspect/SkyObjectInspect.csproj" />
<Project Path="tools/SpellDump/SpellDump.csproj" />
<Project Path="tools/StarsProbe/StarsProbe.csproj" />
<Project Path="tools/TextureDump/TextureDump.csproj" />
<Project Path="tools/WeatherEnumerator/WeatherEnumerator.csproj" />
@ -45,7 +56,11 @@
<Project Path="tests/AcDream.Launcher.Core.Tests/AcDream.Launcher.Core.Tests.csproj" />
<Project Path="tests/AcDream.Launcher.Tests/AcDream.Launcher.Tests.csproj" />
<Project Path="tests/AcDream.Platform.Tests/AcDream.Platform.Tests.csproj" />
<Project Path="tests/AcDream.Plugins.MossTank.Tests/AcDream.Plugins.MossTank.Tests.csproj" />
<Project Path="tests/AcDream.Plugin.Tests.Fixtures.HostPlugin/AcDream.Plugin.Tests.Fixtures.HostPlugin.csproj" />
<Project Path="tests/AcDream.Plugin.Tests.Fixtures.InvalidRenderPackInternal/AcDream.Plugin.Tests.Fixtures.InvalidRenderPackInternal.csproj" />
<Project Path="tests/AcDream.Plugin.Tests.Fixtures.InvalidRenderPackMultiple/AcDream.Plugin.Tests.Fixtures.InvalidRenderPackMultiple.csproj" />
<Project Path="tests/AcDream.RenderPackValidator.Tests/AcDream.RenderPackValidator.Tests.csproj" />
<Project Path="tests/AcDream.Runtime.Tests/AcDream.Runtime.Tests.csproj" />
<Project Path="tests/AcDream.UI.Abstractions.Tests/AcDream.UI.Abstractions.Tests.csproj" />
</Folder>

135
CLAUDE.md
View file

@ -765,6 +765,7 @@ 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)
@ -1554,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
@ -141,7 +141,6 @@ $env:ACDREAM_TEST_HOST = "127.0.0.1"
$env:ACDREAM_TEST_PORT = "9000"
$env:ACDREAM_TEST_USER = "testaccount"
$env:ACDREAM_TEST_PASS = "testpassword"
$env:ACDREAM_RETAIL_UI = "1"
dotnet run --project src\AcDream.App\AcDream.App.csproj -c Release
```
@ -208,7 +207,7 @@ built-in policies are `idle`, `lifecycle-smoke`, `observer-movement`, and
| `ACDREAM_LIVE=1` | Enable connected mode |
| `ACDREAM_TEST_HOST` / `ACDREAM_TEST_PORT` | ACE endpoint |
| `ACDREAM_TEST_USER` / `ACDREAM_TEST_PASS` | Graphical-client credentials |
| `ACDREAM_RETAIL_UI=1` | Enable the retained retail gameplay UI |
| `ACDREAM_RETAIL_UI=0` | Disable the retained retail gameplay UI for diagnostics; it is enabled by default |
| `ACDREAM_DEVTOOLS=1` | Enable ImGui developer tools |
| `ACDREAM_NO_AUDIO=1` | Suppress OpenAL initialization |
| `ACDREAM_UNCAPPED_RENDER=1` | Disable normal frame pacing for diagnostics |

93
assets/icons/README.md Normal file
View file

@ -0,0 +1,93 @@
# acdream application icons
Two marks, one family.
| Mark | Files | Used by |
|---|---|---|
| **Client** — the mosswart head | `acdream-client-*.png`, `acdream-client.ico` | `AcDream.App` (PE icon + runtime window icon) |
| **Launcher** — the ring and crescent | `acdream-launcher-*.png`, `acdream-launcher.ico` | `AcDream.Launcher` (PE icon + Avalonia `Window.Icon`) |
Each ships PNGs at 16/24/32/48/64/128/256/512/1024 plus a multi-size `.ico`
carrying 16 through 256.
## Where the art comes from
**The client mark is the retail mosswart**, not a drawing of one. It is the
actual creature head — `Setup 0x02000B4F` part 14, skin atlas `0x05001E11`,
`ClothingBase 0x10000344` — pulled from `client_portal.dat`, smoothed, lit and
graded. Palette values throughout both marks are sampled from that texture:
| | |
|---|---|
| `#ACB820` | chartreuse upper skin |
| `#A09800` | mustard belly — the "foul yellow" the lore names |
| `#485010` | deep olive shadow |
| `#F2ECD2` | tusk bone |
| `#AC7438` | ear membrane / hide |
**The launcher mark is inspired by the Asheron's Call sigil** — a forged ring
enclosing a hooked crescent — rebuilt from measurements of the retail wordmark
and the `acclient.exe` icon resource. It is an original construction in the
same visual language, not a copy of the logo. Its warm field matches the retail
client icon's dark-to-gold interior.
> **Note on rights.** "Asheron's Call" and its logo are trademarks of their
> owners, and the client mark is rendered from copyrighted game art. Unlike DAT
> content — which stays on the user's own disk — these icons are compiled into
> the shipped binaries. If acdream is ever distributed broadly, both marks
> should be reviewed, and the client mark is the one most likely to want an
> original redraw using these renders as reference.
## Regenerating
The launcher mark is fully procedural and rebuilds anywhere:
```bash
py tools/IconForge/forge.py launcher
```
That is byte-for-byte deterministic — it reproduces the committed PNGs exactly,
so an accidental edit is visible as a diff.
The client mark renders real game geometry, so it needs the installed DATs.
One command extracts both halves — the posed geometry and the surfaces it
references — into `tools/IconForge/work/`:
```bash
dotnet run --project tools/MosswartArt -- 0x02000B4F 0x10000344 tools/IconForge/work/mosswart_mesh.json 0x09000009
```
The trailing MotionTable id is required. Creatures do not define an upright pose
in `Setup.PlacementFrames`; without it every part stacks on the origin.
Then:
```bash
py tools/IconForge/forge.py client
```
This is deterministic too — given the same DATs it reproduces the committed
PNGs byte-for-byte.
Requires Python with `numpy`, `pillow` and `scipy`.
## How they are wired in
Neither icon is loaded from disk at runtime.
- **PE icon**`<ApplicationIcon>` in each `.csproj`, pointing at the `.ico`
here. This is what Explorer and the taskbar shortcut show.
- **Client window icon**`AcDream.App.Rendering.WindowIconLoader` hands GLFW
four sizes **from the `Load` callback**. That timing is load-bearing: Silk's
`Window.Create` only builds the managed object, and `IWindow.Initialize` is
what creates the native window, so applying an icon any earlier throws
"Window should be initialized". The failure is quiet and misleading — GLFW
falls back to the stock Windows application icon rather than the
executable's, so Explorer shows the mark and the running window does not.
The PNGs are *embedded resources* linked from this directory, so there is one
source of truth for the art and no missing-file case at runtime.
`WindowIconLoaderTests` guards both the resource names, which are otherwise
coupled to `LogicalName` in the csproj by string only, and the call-site
ordering.
- **Launcher window icon**`AvaloniaResource` linked from here, referenced as
`avares://acdream-launcher/Assets/acdream-launcher.png`.

Binary file not shown.

After

Width:  |  Height:  |  Size: 436 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 699 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 397 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 819 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

File diff suppressed because it is too large Load diff

View file

@ -82,6 +82,16 @@ document in the same change; do not leave both claims standing.
- [`superpowers/specs/`](superpowers/specs/) and
[`superpowers/plans/`](superpowers/plans/) are per-slice design and execution
records. Completed plans remain historical.
- [`ci-and-releases.md`](ci-and-releases.md) is the SSOT for the Gitea CI
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`.

150
docs/ci-and-releases.md Normal file
View file

@ -0,0 +1,150 @@
# Continuous integration and alpha releases (Gitea)
Single source of truth for how acdream builds, gates, and ships alpha builds.
Landed 2026-08-19. Companion to [`release-gate.md`](release-gate.md), which
owns the *local* bounded gate.
## What happens on a push to main
```
git push origin main
├─ windows-gate (RARE-win) build + full lane-filtered suite
├─ linux-portable (eriktestLinux) portable closure, Linux lanes
└─ release (needs BOTH green) publish a Gitea Release
+ republish the `latest` pointer
```
Workflow: [`.gitea/workflows/ci.yml`](../.gitea/workflows/ci.yml). Docs-only
pushes (docs/, the memory trees, markdown) skip the pipeline entirely — no
test can fail on them and a run costs ~7 minutes plus a 121 MB release. A red gate
cannot publish: `release` uses `needs:`, not a `workflow_run` trigger, whose
Forgejo support is unreliable.
## Why Gitea and not GitHub
GitHub Actions is **billing-blocked** on this account ("recent account payments
have failed"), and the repo is private, so hosted runners consume paid minutes.
Forgejo ships **no hosted runners at all**, so Actions there requires
self-hosted ones — which are free on both platforms. The same two machines can
serve GitHub later by registering a second agent; only the workflow's
`runs-on` labels change.
## The runners
| | Windows | Linux |
|---|---|---|
| Host | `RARE` (10.6.0.3) | `eriktestLinux` (10.0.0.202) |
| Agent | `act_runner` 0.2.13 | `forgejo-runner` 13.0.0 |
| Persistence | Scheduled task `ForgejoRunner`, at logon of `acbot` | systemd `forgejo-runner`, `Restart=always` |
| Labels | `windows`, `windows-latest`, `windows-x64` | `ubuntu-latest`, `ubuntu`, `linux`, `ubuntu-slim` |
| Execution | host mode (`:host`) — no Docker on either box | host mode |
Both **poll outbound** over HTTPS. Gitea never connects to them, so no inbound
ports, no port forwarding, and no static IP; they work behind NAT. The runner
does not have to live next to the Gitea container (which runs on `bluesnake`,
a host we have no shell on).
`forgejo-runner` publishes **no Windows binary in any release**, which is why
Windows uses Gitea's `act_runner`. Forgejo speaks the same Actions protocol.
### Prerequisites on a runner
- **.NET SDK in the `global.json` band** — currently `10.0.3xx`. `10.0.400` is a
different feature band and `rollForward: latestPatch` rejects it.
- **Node.js**`actions/checkout` and `actions/upload-artifact` are JavaScript
actions. Docker images normally supply Node; in host mode the machine must.
- **Git**, and outbound HTTPS to `git.snakedesert.se`.
- **PowerShell 7** on Windows (`pwsh`); `tools/*.ps1` require it.
## Releases
Everything about distribution lives under **Releases** — nothing in git. A build
is ~120 MB, so payloads are release attachments; and the pointer the launcher
polls is itself a release asset, so there is no payload branch, no bot commit on
`main`, and no push that could retrigger the pipeline.
```
Release 0.1.0-build.<yyyyMMddHHmm> <- the actual build
client-win-x64.zip AcDream.App.exe + acdream-headless.exe
launcher-win-x64.zip acdream-launcher.exe + acdream-bake.exe
manifest.json
Release latest <- pointer, replaced every publish
manifest.json names the version above and its asset URLs
```
The launcher polls the pointer at a URL that never changes
(`ReleaseManifestClient.ProductionManifestUri`):
```
https://git.snakedesert.se/erik/acdream/releases/download/latest/manifest.json
```
A pointer is needed because **Forgejo has no `/releases/latest/download/`
route** (verified: 404) — unlike GitHub, there is no built-in stable URL for
"the newest release". Publishing it recreates the `latest` tag each time, which
means deleting the old release *and* its tag; the tag outlives its release and
would otherwise block recreation.
The newest **5** versioned releases are kept and older ones are pruned with
their tags. Each build is ~121 MB of attachments, so retaining every one grew
the server by that much per push — 5 builds had already reached 606 MB. Five is
enough to grab a previous build or bisect a regression while staying bounded.
The `latest` pointer is never pruned; it is the feed, not a build.
`tools/publish-bin.ps1 -BaseUrl <release asset base>` builds the payloads; CI
passes the tag's asset base. Running it locally is for inspection only —
publishing is CI's job.
### Verifying a release
```powershell
dotnet test tests/AcDream.Launcher.Core.Tests --filter Lane=Live
```
`LiveGiteaReleaseInstallTests` installs the advertised client from the real feed
through the production updater — real SHA-256/size verification, extraction, and
atomic activation — then asserts both hosts resolve out of the activated
directory and `current.json` names the installed version.
## Landmines
Each of these cost a red pipeline; none was a config typo. Two rows record a
fix that was tried and **disproved** — read those before repeating it.
| Symptom | Cause |
|---|---|
| `Cannot find: node in PATH` | JS actions need Node on the host in `:host` mode |
| `actions/setup-dotnet` never resolves | `data.forgejo.org` does not mirror it (404). `checkout` and `upload-artifact` **are** mirrored. Self-hosted runners carry the SDK anyway |
| Job "failed" while dotnet processes still run | `run-release-gate.ps1` redirects children to log files, so the step goes silent; Forgejo fails a non-reporting task as a zombie. CI runs `dotnet test` directly so output streams |
| ~40 tests fail on formatted numbers | Runner's `HKCU` locale was `en-SE` (comma decimal): expected `"update:0.25"`, got `"update:0,25"`. `Set-Culture` does **not** reach a scheduled task without a loaded profile — set the registry directly |
| `DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1` as the locale fix | Too blunt — it breaks tests that legitimately construct a culture. Fix the machine locale instead |
| `FileNotFoundException: client_cell_1.dat` | DAT-dependent tests missing `[Trait("Lane", "InstalledDat")]`. Build machines have no DATs |
| Timing-sensitive test fails only under load | It belongs in `Lane=Timing` (see [`release-gate.md`](release-gate.md)). Do **not** chase these individually: four separate fixes each surfaced a different member of the same family, and serializing `Core.Net` to fix Linux regressed Windows from 1000 passed in 7 s to 999/1000 in 17 s |
| Avalonia "calling thread cannot access this object" in cleanup | `MainWindowViewTests` needs a real desktop session and is `Lane=Manual`. Measured: PASSES on a dev desktop and on the CI Windows box over SSH; FAILS under `act_runner` and on Linux. Serializing the assembly does **not** fix it (tried via `xunit.runner.json` and a compiled-in `CollectionBehavior` attribute), and de-async-ing the test actively causes the failure. The stack shows a compositor being **constructed** during teardown — it is the headless session lifecycle, not parallelism |
## Do not leave load on a runner
A stress/diagnostic run left going on a runner competes with CI for the same
machine and makes every job slower and more likely to trip a load-sensitive
test — the exact failures you would then be trying to diagnose. Kill background
work before trusting a timing result:
```powershell
Get-Process dotnet -ErrorAction SilentlyContinue | Stop-Process -Force # Windows
pkill -9 dotnet # Linux
```
Leave `act_runner` / `forgejo-runner` itself alone; killing those unregisters
nothing but stops the machine picking up jobs until it restarts.
## Culture note
The `en-SE` discovery is worth remembering beyond CI: config files, numeric
parsing, and the wire are all culture-safe (`System.Text.Json` is invariant by
spec, every `float/double.TryParse` passes `CultureInfo.InvariantCulture`, and
the protocol is binary). Only **diagnostic strings** format with the current
culture, so a European player sees `local=(8,00; 191,00)` in an F3 dump. The
client installs and runs correctly in both the US and Europe.

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

@ -0,0 +1,384 @@
# acdream launch options — operator reference
Every environment variable and command-line argument the acdream client
reads, what it does, and **what else it changes about the run**.
**This is an operator's reference, not user documentation.** Players never
set these: the launcher owns installation and login, and the in-client
Options panel (F11) owns settings. If a flag here looks like something a
player would want, that is a signal it belongs in the Options panel, not a
signal to document it better.
## How to use this document
- **Running the client for yourself?** Read *Production launch* and stop.
- **Taking a measurement?** Read *Production launch*, then read the
*Side effects* column of every flag you are about to set. A flag that
changes what you are measuring is the normal case, not the exception.
- **Adding a flag?** Add its row in the same commit. `LaunchOptionsDocumentationTests`
fails the build otherwise — in both directions, so deleting a read site
without deleting its row fails too.
### Why the side-effects column exists
Two flags in this list were believed to be inert and were not:
- `ACDREAM_AUTOMATION_ARTIFACT_DIR` reads like an output path. It also
constructs a per-frame diagnostics referee that re-enabled a retired
render pass, costing ~6 MB and ~14 ms **every frame** — three days of
performance measurements were silently taxed before anyone noticed
([#432](ISSUES.md)).
- `ACDREAM_STREAM_RADIUS` reads like a radius knob. It forces the near
radius, only ever *raises* the far radius, and is then silently
discarded by any later quality apply — so a measurement taken with it
set is measuring a window production never uses.
Assume a flag has a side effect until its row says otherwise.
## Conventions
- **Everything diagnostic is OFF by default.** Every probe, dump, capture,
and measurement flag in this document is inert until its variable is
explicitly set — an unset environment runs zero diagnostics. Exactly
five flags default ON, and none is a diagnostic: `ACDREAM_RETAIL_CHASE`,
`ACDREAM_CAMERA_COLLIDE`, `ACDREAM_CAMERA_ALIGN_SLOPE`, and
`ACDREAM_RETAIL_CLOSE_DEGRADES` are retail *behaviors* wearing an A/B
off-switch (`=0` disables the behavior for a comparison run), while
`ACDREAM_RETAIL_UI` is the product's only gameplay presentation and uses
the same explicit diagnostic opt-out. That five-flag set is frozen by
`LaunchOptionsDocumentationTests` — a new
default-on flag fails the build.
- `=1` means the code tests for exactly the string `1`. Setting `true`,
`yes`, or `0` does **not** enable such a flag (and `0` does not disable
one whose test is "is the variable present").
- **Default** is the behavior when the variable is unset.
- **Kind** is one of:
| Kind | Meaning |
|---|---|
| `production` | Ordinary configuration; safe in a real run. |
| `measurement` | Profiling/instrumentation. Read the side effects before trusting numbers taken with it on. |
| `automation` | Drives scripted runs; usually implies extra machinery. |
| `permanent-probe` | A diagnostic toggle owned by a subsystem's diagnostics class. Expected to persist. |
| `temporary-probe` | Tied to an open investigation. Deleted with its issue — never build tooling on one. |
| `deprecated` | Superseded. Do not use for new work. |
---
## Production launch
The canonical connected launch against a local ACE server. PowerShell,
because the DAT path contains an apostrophe:
```powershell
$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call"
$env:ACDREAM_LIVE = "1"
$env:ACDREAM_TEST_HOST = "127.0.0.1"
$env:ACDREAM_TEST_PORT = "9000"
$env:ACDREAM_TEST_USER = "testaccount"
$env:ACDREAM_TEST_PASS = "testpassword"
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Release
```
| Flag | Value | What it does | Side effects | Default | Read by |
|---|---|---|---|---|---|
| `ACDREAM_A2C` | `unset/""` keep preset; `"0"/"false"/"False"/"FALSE"` → off; any other non-empty → on | Overrides preset's `AlphaToCoverage` blend flag | Changes MSAA alpha-to-coverage blending mode for foliage/translucent draws — a visual-behavior change, not just perf | preset's `AlphaToCoverage` (High/Ultra=true, Low/Medium=false) | `QualitySettings.WithEnvOverrides` (`QualityPreset.cs:52`) |
| `ACDREAM_AC_DIR` | `=<path>` | Points at a real retail AC install dir; loads `<dir>/controls/controls.ini` to source retail keybind display strings for the retained UI. | Only has any effect while the default-on retained UI is composed (`ACDREAM_RETAIL_UI` is not `0`). Unset → `ControlsIni.Parse(string.Empty)`, an empty (not error) controls table — silent, no fallback file is searched. | unset (null) → empty controls table | `RuntimeOptions.AcDir``InteractionRetainedUiComposition.cs:610` |
| `ACDREAM_ANISOTROPIC` | `=<int>` (`int.TryParse`, invariant) | Overrides preset's `AnisotropicLevel` texture filtering | Changes GPU texture sampling filter level (visual sharpness), not just perf | preset's `AnisotropicLevel` (Low=4, Medium=8, High/Ultra=16) | `QualitySettings.WithEnvOverrides` (`QualityPreset.cs:49`) |
| `ACDREAM_CACHE_DIR` | `=<path>` | Overrides the resolved cache-root directory (used for `DiagnosticsDirectory`, etc.) | none beyond redirecting cache I/O | Windows: `%LOCALAPPDATA%\acdream\cache`; Linux: `$XDG_CACHE_HOME/acdream` or `~/.cache/acdream` | `ApplicationPathSet.Resolve` (`ApplicationPathSet.cs:83`), via `IApplicationPathEnvironment` seam |
| `ACDREAM_CAMERA_ALIGN_SLOPE` | `=0` disables (anything else/unset = on) | selects whether the chase camera basis tilts to the player's 5-frame averaged velocity vs staying flat/horizontal on slopes | alters camera orientation / rendered view every frame; startup-only in practice (its DebugPanel/DebugVM mirror is unreachable — #434)| true (on) | `AcDream.Core.Rendering.CameraDiagnostics.AlignToSlope` |
| `ACDREAM_CAMERA_COLLIDE` | `=0` disables (anything else/unset = on) | selects whether the chase camera sweeps a 0.3 m collision sphere from head-pivot to eye and stops at the first wall (retail spring-arm) | alters camera position every frame (camera can clip into geometry when disabled); startup-only | true (on) | `CameraDiagnostics.CollideCamera` |
| `ACDREAM_CONFIG_DIR` | `=<path>` | Overrides the resolved config-root directory (`settings.json`, `keybinds.json`) | none beyond redirecting config I/O | Windows: `%APPDATA%\acdream`; Linux: `$XDG_CONFIG_HOME/acdream` or `~/.config/acdream` | `ApplicationPathSet.Resolve` (`ApplicationPathSet.cs:79`), via `IApplicationPathEnvironment` seam |
| `ACDREAM_DATA_DIR` | `=<path>` | Overrides the resolved data-root directory (logs, screenshots, plugins) | none beyond redirecting data I/O | Windows: `%LOCALAPPDATA%\acdream`; Linux: `$XDG_DATA_HOME/acdream` or `~/.local/share/acdream` | `ApplicationPathSet.Resolve` (`ApplicationPathSet.cs:81`), via `IApplicationPathEnvironment` seam |
| `ACDREAM_DAT_DIR` | `=<path>` | Fallback dat-directory when no positional argument is given. App: single read at `Program.cs:58`. Cli: read independently per-subcommand (each subcommand does `args.ElementAtOrDefault(N) ?? Env.GetEnvironmentVariable("ACDREAM_DAT_DIR")`) plus once more for the default (no-subcommand) asset-inventory mode at line 152. | Two of the four `Program.cs` line numbers in the raw grep (91, 135) are **not reads** — they're the literal string `ACDREAM_DAT_DIR` inside `Log.Error` usage-text messages, not `GetEnvironmentVariable` calls. Only line 58 is a real read in `AcDream.App`. | none — hard usage error (exit 2) if unset and no positional arg | `Program.cs:58` (App); `Cli/Program.cs:24,35,47,59,71,84,113,125,137,152` (every Cli subcommand) |
| `ACDREAM_DISPLAY_PROTOCOL` | `="auto"` / `"x11"` / `"wayland"` (case-insensitive, trimmed); any other value throws `InvalidOperationException` at startup | Linux-only: forces the GLFW 3.4 platform-init hint (X11 vs Wayland vs auto) before any window is created; ignored entirely on Windows (always `Windows` protocol) | An invalid value is fatal at startup (throws before any window exists), not a silent fallback | unset → auto-detected from `XDG_SESSION_TYPE`/`WAYLAND_DISPLAY`/`DISPLAY`, falling back to GLFW `Automatic` | `GraphicalWindowBackendSelection.Resolve` (`GraphicalWindowBackendSelection.cs:26-58`) |
| `ACDREAM_FAR_RADIUS` | `=<int>` | Overrides preset's `FarRadius` (outer streaming/reveal window, landblocks) | Enlarging changes streaming memory budget and what's resident/rendered — CLAUDE.md: leave unset for measurement/gate runs (same family as legacy `ACDREAM_STREAM_RADIUS`) | preset's `FarRadius` (Low=5, Medium=8, High=12, Ultra=15) | `QualitySettings.WithEnvOverrides` (`QualityPreset.cs:47`) |
| `ACDREAM_LIVE` | `=1` (exactly the literal string `"1"`) | Core switch: connect to a live ACE server instead of running offline/no-connect. | The 4 non-`RuntimeOptions.cs` line numbers in the raw grep are **all comments or log-message text**, not reads — `SessionStartComposition.cs:39` is inside the string `"live: ACDREAM_LIVE set but TEST_USER/TEST_PASS missing; skipping"`; `Program.cs:126` is inside a `--session-config` override log line; `GameWindow.cs:614,627` are doc comments. The only actual parse is `RuntimeOptions.cs:141`. Requires `ACDREAM_TEST_USER`/`ACDREAM_TEST_PASS` too (`HasLiveCredentials`) or the session silently reports `MissingCredentials` and skips. Forced to effectively-on (LiveMode=true) unconditionally by `--session-config` launches regardless of this var. | `false` | `RuntimeOptions.LiveMode``SessionStartComposition.cs` (log text only), `Program.cs:126` (log text only), `GameWindow.cs:614,627` (comments only), consumed for real via `RuntimeOptions.HasLiveCredentials` and `WorldSession`/`GameRuntime` session-start gating |
| `ACDREAM_MAX_COMPLETIONS_PER_FRAME` | `=<int>` | Overrides preset's per-frame streaming-completion throughput cap | Directly changes the streaming admission budget measured by perf/completion gates — do not vary during a measurement run | preset's value (Low=2, Medium=3, High=4, Ultra=6) | `QualitySettings.WithEnvOverrides` (`QualityPreset.cs:59`) |
| `ACDREAM_MSAA_SAMPLES` | `=<int>` (0/2/4/8) | Overrides preset's MSAA sample count | Changes GPU multisample anti-aliasing (visual + GPU-cost change) | preset's `MsaaSamples` (Low=0, Medium=2, High/Ultra=4) | `QualitySettings.WithEnvOverrides` (`QualityPreset.cs:48`) |
| `ACDREAM_NEAR_RADIUS` | `=<int>` | Overrides preset's `NearRadius` (inner streaming ring radius) | Changes the streaming/render window size — CLAUDE.md: leave unset for measurement/gate runs | preset's `NearRadius` (Low=2, Medium=3, High=4, Ultra=5) | `QualitySettings.WithEnvOverrides` (`QualityPreset.cs:46`) |
| `ACDREAM_NO_AUDIO` | `=1` | Suppresses OpenAL device init entirely (headless / driver-broken machines). | Fail-open design: even without this flag, a missing/broken OpenAL driver already makes `IsAvailable=false` and all `Play*` calls no-ops — this flag is the explicit, deliberate version of the same fallback. When set, `LiveSessionWorldRuntime.WorldAudio` is `null` and logout-audio reset/resume steps no-op. | `false` | `RuntimeOptions.NoAudio``GameWindow.cs:1430``ContentEffectsAudioCompositionPhase``OpenAlAudioEngine.cs` (fail-open doc), `LiveSessionRuntimeFactory.cs:71` (`WorldAudio` gate) |
| `ACDREAM_PAK_PATH` | `=<path>` | Overrides the path to the prepared machine-local asset bake (`acdream.pak`) that production world-mesh streaming reads through `IPreparedAssetSource`. | none beyond the obvious | unset → `<datDir>/acdream.pak` | `RuntimeOptions.PreparedAssetPath``ContentEffectsAudioComposition.cs:53,372,379`, `GameWindow.cs:1420` |
| `ACDREAM_PLUGIN_TAGS` | comma-separated tags (maximum 128 tags, 128 characters each) | Advertises machine-local role/group tags through the plugin peer-discovery API, for UtilityBelt-compatible expressions such as client selection by tag. Values are trimmed and deduplicated case-insensitively. | Writes the tags into the bounded local peer heartbeat document while a character is in world; no network traffic leaves the machine. | unset → no tags | `RuntimeOptions.PluginTags``AppAutomationSurface` / `LocalPluginPeerRegistry` |
| `ACDREAM_RESIDENCY_ALPHA_SCRATCH_MIB` | `=<int MiB>` (`>0`, else default; overflow-checked) | Byte ceiling for the retail alpha (translucency) draw queue's scratch buffer | Shrinking below production working set changes translucency-queue eviction/reflow behavior — not comparable to a default-budget perf run | 16 MiB | `ResidencyBudgetOptions.Parse` (`ResidencyBudgetOptions.cs:88-89`), flows through `RuntimeOptions.ResidencyBudgets``AlphaScratchBudgetProfile.Create``RetailAlphaQueue` ctor (`GameWindow.cs:721-725`) |
| `ACDREAM_RESIDENCY_ANIMATION_ENTRIES` | `=<int>` (`>0`, else default) | Entry-count ceiling for the retained animation-data cache | Changes cache eviction cadence for animation data — a perf/memory measurement under a non-default value is not representative | 512 | `ResidencyBudgetOptions.Parse` (`:82-84`) |
| `ACDREAM_RESIDENCY_ANIMATION_MIB` | `=<int MiB>` (`>0`, else default) | Byte ceiling for the retained animation-data cache | Same cache-behavior-change caveat as the entries variant | 64 MiB | `ResidencyBudgetOptions.Parse` (`:79-81`) |
| `ACDREAM_RESIDENCY_AUDIO_MIB` | `=<int MiB>` (`>0`, else default) | Byte ceiling for the retained audio-buffer cache | Shrinking can force more frequent audio buffer re-decode/eviction | 32 MiB | `ResidencyBudgetOptions.Parse` (`:85-87`), consumed by `ContentEffectsAudioComposition.cs` |
| `ACDREAM_RESIDENCY_COMPOSITE_PHYSICAL_MIB` | `=<int MiB>` (`>0`, else default) | Byte ceiling for physically-resident composite (character palette/texture) GPU memory | Changes composite-texture eviction pressure — not representative of production if varied during a measurement run | 128 MiB | `ResidencyBudgetOptions.Parse` (`:67-69`), consumed by `TextureCache.cs` |
| `ACDREAM_RESIDENCY_COMPOSITE_UNOWNED_MIB` | `=<int MiB>` (`>0`, else default) | Byte ceiling for unowned/retained (not currently referenced) composite textures kept for reuse | Same eviction-pressure caveat | 64 MiB | `ResidencyBudgetOptions.Parse` (`:70-72`), consumed by `TextureCache.cs` |
| `ACDREAM_RESIDENCY_MESH_GPU_MIB` | `=<int MiB>` (`>0`, else default) | Byte ceiling for GPU-resident object mesh data | The single largest residency budget (1024 MiB default) — shrinking it directly forces more mesh re-upload/eviction; do not vary during an FPS/GPU-memory measurement run | 1024 MiB | `ResidencyBudgetOptions.Parse` (`:49-51`), consumed by `ObjectMeshManager.cs`/`WbDrawDispatcher.cs` |
| `ACDREAM_RESIDENCY_MESH_STAGING_ENTRIES` | `=<int>` (`>0`, else default) | Entry-count ceiling for the mesh upload staging cache | Changes staging-buffer churn/eviction cadence | 256 | `ResidencyBudgetOptions.Parse` (`:64-66`) |
| `ACDREAM_RESIDENCY_MESH_STAGING_MIB` | `=<int MiB>` (`>0`, else default) | Byte ceiling for the mesh upload staging cache | Same staging-churn caveat | 128 MiB | `ResidencyBudgetOptions.Parse` (`:61-63`) |
| `ACDREAM_RESIDENCY_MESH_UNOWNED_ENTRIES` | `=<int>` (`>0`, else default) | Entry-count ceiling for unowned (retained-for-reuse) object mesh entries | Changes mesh-cache eviction cadence | 50 | `ResidencyBudgetOptions.Parse` (`:52-54`) |
| `ACDREAM_RESIDENCY_PREPARED_MESH_ENTRIES` | `=<int>` (`>0`, else default) | Entry-count ceiling for the CPU-side "prepared mesh" cache (post-classification, pre-upload) | Changes eviction cadence for prepared-mesh CPU memory | 100 | `ResidencyBudgetOptions.Parse` (`:58-60`) |
| `ACDREAM_RESIDENCY_PREPARED_MESH_MIB` | `=<int MiB>` (`>0`, else default) | Byte ceiling for the CPU-side prepared-mesh cache | Same eviction-cadence caveat | 128 MiB | `ResidencyBudgetOptions.Parse` (`:55-57`) |
| `ACDREAM_RESIDENCY_STANDALONE_UNOWNED_ENTRIES` | `=<int>` (`>0`, else default) | Entry-count ceiling for unowned standalone (non-composite) texture entries | Changes standalone-texture eviction cadence | 256 | `ResidencyBudgetOptions.Parse` (`:76-78`), consumed by `TextureCache.cs` |
| `ACDREAM_RESIDENCY_STANDALONE_UNOWNED_MIB` | `=<int MiB>` (`>0`, else default) | Byte ceiling for unowned standalone texture memory | Same eviction-cadence caveat | 32 MiB | `ResidencyBudgetOptions.Parse` (`:73-75`) |
| `ACDREAM_RETAIL_CHASE` | `=0` disables (anything else/unset = on) | selects the retail-faithful `RetailChaseCamera` vs. the legacy rigid-follow `ChaseCamera` | swaps the entire active camera implementation — changes camera motion/feel; startup-only in practice (its DebugPanel/DebugVM mirror is unreachable — #434)| true (retail camera on) | `CameraDiagnostics.UseRetailChaseCamera` |
| `ACDREAM_RETAIL_CLOSE_DEGRADES` | inverted: `="0"` disables; any other value (incl. unset) enables | Default-**on** real gameplay behavior: applies retail's close-range LOD mesh-part swap (`GfxObjDegradeResolver`) to humanoid setups (issue #47), matching retail's close-detail degrade. | Inverted default (opposite of every other boolean flag in this table — presence of the literal string `"0"` is what disables it, not presence of `"1"` enabling it). Documented explicitly as "set only for before/after diagnostic comparisons" — so although default-on production behavior, its *disable* path exists purely for A/B measurement. | `true` (enabled) unless value is exactly `"0"` | `RuntimeOptions.RetailCloseDegrades``DatLiveEntityProjectionMaterializer.cs:275-276,480-498` |
| `ACDREAM_RETAIL_UI` | `=0` disables; anything else/unset enables | Controls the retained retail UI host tree (`UiHost`/`UiRoot`, D.2b), the product's only gameplay UI. | Disabling it leaves world rendering with no gameplay interface and is intended only for diagnostics. The same rule applies to bare env and `--session-config`/launcher launches; no path force-overrides it. | `true` | `RuntimeOptions.RetailUi``LivePresentationComposition.cs` (gates retained-UI mount via `InteractionRetainedUiComposition`) |
| `ACDREAM_TEST_HOST` | `=<host>` | ACE server hostname for live-mode connect. | none | `"127.0.0.1"` | `RuntimeOptions.LiveHost` (`RuntimeOptions.cs:142`) |
| `ACDREAM_TEST_PASS` | `=<string>` | ACE account password for live-mode connect. | Redacted in `RuntimeOptions.ToString()`/diagnostic printing by design (`PrintMembers` override, `RuntimeOptions.cs:326-342`) — defense-in-depth so it can never leak into a log/exception via the record's default printing. | `null` (empty → `HasLiveCredentials` false) | `RuntimeOptions.LivePass` (`RuntimeOptions.cs:145`) |
| `ACDREAM_TEST_PORT` | `=<int>` | ACE server port for live-mode connect. | none | `9000` | `RuntimeOptions.LivePort` (`RuntimeOptions.cs:143`) |
| `ACDREAM_TEST_USER` | `=<string>` | ACE account name for live-mode connect. | none | `null` (empty → `HasLiveCredentials` false) | `RuntimeOptions.LiveUser` (`RuntimeOptions.cs:144`) |
| `ACDREAM_VULKAN_DEVICE` | `=<int>` (decimal index) or `=<substring>` (case-insensitive device-name match) | Overrides automatic Vulkan physical-device selection (normally: discrete > integrated > virtual > CPU, tie-broken by device-local heap size) — for multi-GPU machines. | A bare-digits value is matched as an index ONLY (never falls through to substring match) specifically because digits like `"7"` are substrings of real device names ("AMD Radeon RX 9070 XT") — a fallback would silently select the wrong device by coincidence. An override matching nothing falls back to the automatic choice (does not fail startup) and records why in the capability report. | `null` → automatic ranked choice | `RuntimeOptions.VulkanDeviceOverride``VulkanPhysicalDeviceSelection.Choose` (`VulkanPhysicalDeviceSelection.cs:54-100`), consumed at `VulkanGraphicsContext.cs:207,322` |
## Command-line arguments
### `AcDream.App`
| Arg | What it does | Side effects |
|---|---|---|
| `<dat-directory>` (positional) | Dat directory; outranks `ACDREAM_DAT_DIR`. | Not read at all once `--session-config` is present. |
| `--session-config <path>` | The launcher's launch path: endpoint, account, credential reference, character selector, status file, plugins, login commands. | **Overrides `ACDREAM_LIVE` and every `ACDREAM_TEST_*`** (logged at startup). Diagnostic flags stay env-controlled. Missing value is a startup error. |
### `AcDream.Headless`
Its usage banner matches the parser exactly. `validate` loads and checks a
config without connecting; `run` connects.
| Arg | What it does | Side effects |
|---|---|---|
| `validate` \| `run` (positional) | Selects the mode; must be the first argument. | Anything else is a parse error. |
| `--config <path>` | The versioned headless session-configuration document. Required. | — |
| `--config-dir` / `--data-dir` / `--cache-dir` `<path>` | Override each portable path root. | Merged over the config document's own `process.paths`; the command line wins. |
| `-user` / `--user`, `-password` / `--password` | Direct single-session credentials, bypassing the config's credential source. | Plaintext in the process command line — prefer the config's credential reference. |
| `--help` / `-h` (or no args) | Prints usage, exits 0. | — |
### `AcDream.Launcher`
| Arg | What it does | Side effects |
|---|---|---|
| `--verify-publish` | Packaging smoke probe: parses arguments and exits 0 without opening a display or resolving user paths. | — |
| `--config-dir` / `--data-dir` / `--cache-dir` `<absolute path>` | Override each path root. | **All three or none** — supplying a subset is an error. Must be absolute. |
| `--update-manifest-uri <uri>` | Points the self-updater at a different release manifest (test-feed seam). | Must be `https://` (or loopback `http://`). Changes where updates come from — do not point a real install at a test feed. |
| `--acdream-self-update-helper-v1`, `--acdream-self-update-confirm-v1` | Internal re-exec markers for the self-update handoff. | Not user-facing; never pass these by hand. |
### `AcDream.Cli`
A dat-dump and measurement tool dispatched by a positional subcommand
(`args[0]`); no `--flag` options. Most subcommands take a dat directory and
fall back to `ACDREAM_DAT_DIR`.
- **Measurement:** `summarize-frame-history <frames.csv> <checkpoints.jsonl> <markers.log> <out.json>`,
`compare-screenshots <expected.png> <actual.png> <out.json> [channelTolerance=2] [maxDifferentFraction=0.001] [mask.png]`,
`probe <in.png> <x0> <y0> <x1> <y1>`.
- **Dat inspection:** no subcommand (asset-type inventory), `dump-vitals-bars`,
`dump-vitals-layout [0xLayoutId]`, `list-ui-layouts [0xRootType]`,
`dump-sprite-sheet <0xId,...>`, `dump-font-atlas [0xFontId] [sample] [outBase]`,
`dump-edges <0xId>`, `export-ui-sprite <0xId> [out.png]`.
- **Mockup rendering:** `render-vitals-mockup [out.png]`, `mock-selbar [out.png]`,
`crop <in.png> <x> <y> <w> <h> <zoom> <out.png>`.
## Measurement and profiling
| Flag | Value | What it does | Side effects | Default | Read by |
|---|---|---|---|---|---|
| `ACDREAM_CAPTURE_RESOLVE` | `=<path>` | appends one JSON-Lines record (full before/after `PhysicsBody` snapshot) per player-side `ResolveWithTransition` call, filtered to `IsPlayer` movers | real per-tick allocation (snapshot object graph + `System.Text.Json` serialize) and buffered file I/O (`AutoFlush=false`) for the local player only; will skew any perf measurement of local-player physics while active; feeds `CellarUpTrajectoryReplayTests` fixtures | unset (off) | `AcDream.Core.Physics.PhysicsResolveCapture` (`CapturePath`) |
| `ACDREAM_COLLISION_SHADOW_DIR` | `=<dir>` | output directory for Slice I5 graph/flat collision-shadow mismatch artifacts | only takes effect when `ACDREAM_COLLISION_SHADOW_EVERY>0`; directory creation + file writes on mismatch | `<CurrentDirectory>/.test-out/collision-shadow` | `PhysicsDiagnostics.CollisionShadowArtifactDirectory` |
| `ACDREAM_COLLISION_SHADOW_EVERY` | `=<positive int>` | when >0 and the cache is constructed with `requirePreparedCollision:false`, arms a `CollisionShadowVerifier` that re-runs the graph-vs-flat collision referee every Nth traversal entry (`PhysicsDataCache` ctor) | extra CPU on sampled ticks + mismatch-artifact file I/O; graph path stays authoritative regardless of mismatch (doc-asserted, not independently verified here) — does not change production physics results, but does add work when active | `0` (disabled) | `PhysicsDiagnostics.CollisionShadowSampleEvery` (parsed via `ParsePositiveInt`, non-positive → 0) |
| `ACDREAM_DAY_GROUP` | `=<int>` | Forces Dereth's day-group (weather preset) selection instead of the retail hash-based pick, "useful for visually A/B-testing each weather preset against retail" (own doc comment). | **Dead second read**: the `SkyDescLoader.cs:252` raw read only feeds `SelectDayGroupIndex`, which is only called from `ActiveDayGroup(double)` and the `DefaultDayGroup` property — and grepping all of `src/` finds **zero production call sites** for either. That whole path is unreachable; only the typed `RuntimeOptions.ForcedDayGroupIndex` → Runtime path is live. Bounds differ too: the typed path only checks `>= 0` (`TryParseNonNegativeInt`) and Runtime clamps out-of-range to `null`; the dead Core-layer path checks `forced >= 0 && forced < DayGroups.Count` directly. `SkyState.cs:400,403` are doc-comment mentions only, not reads. | unset → normal server/date-driven hash selection | `RuntimeOptions.ForcedDayGroupIndex` (typed) → `GameWindow.cs:718``WorldEnvironmentController``RuntimeWorldEnvironmentState` (Runtime, live path); **also** raw `Environment.GetEnvironmentVariable` at `SkyDescLoader.cs:252` (Core layer, separate parse) |
| `ACDREAM_DISABLE_TIER1_CACHE` | `="1"` (ordinal exact match; anything else = enabled) | A/B diagnostic that forces **every** static (non-animated) entity through the slow per-entity classification path, bypassing the Tier-1 classification cache (`#53`) | Materially changes per-frame CPU cost for entity classification — a perf/FPS measurement taken with this set is NOT representative of production and must not be compared against a normal run | unset (cache enabled) | `WbDrawDispatcher` ctor field `_tier1CacheDisabled` (`WbDrawDispatcher.cs:473-474`) |
| `ACDREAM_FRAME_HISTORY` | `=<path>` | opts into a per-frame CSV history capture (frame idx, timestamps, per-stage CPU us, GPU us, alloc bytes) alongside the aggregated 5 s `[frame-prof]` report | allocates a `List<FrameHistoryRecord>` with ~131,072-record (~9 MiB) initial capacity, growing further for longer captures (~72 B/record, ~43 MB/hour at 165 fps) held in memory for the whole run; CSV write happens ONLY at `Dispose`/shutdown (no frame-thread I/O); only takes effect while `ACDREAM_FRAME_PROF` is ALSO on | unset (off) | `RenderingDiagnostics.FrameHistoryPath` / `AcDream.App.Diagnostics.FrameProfiler` |
| `ACDREAM_ORBIT_DISTANCE_METERS` | `=<float>`, must be finite and `>0` | Diagnostic-only initial distance for the offline orbit camera, so deterministic renderer acceptance captures land inside a finite shadow reach. | Own doc comment: "used by deterministic renderer acceptance captures." Rejects non-finite/non-positive values silently (parses to `null`, camera default used). | `null` (unset) → normal camera default | `RuntimeOptions.InitialOrbitDistanceMeters``GameWindow.cs:1412` → offline orbit-camera composition |
| `ACDREAM_ORBIT_PITCH_DEGREES` | `=<float>`, clamped `[-89, 89]` | Diagnostic-only initial orbit camera elevation. | Values outside `[-89,89]` or non-finite are silently rejected (→ `null`, default kept) rather than clamped. | `null` | `RuntimeOptions.InitialOrbitPitchDegrees``GameWindow.cs:1414` |
| `ACDREAM_ORBIT_YAW_DEGREES` | `=<float>`, must be finite | Diagnostic-only initial orbit camera heading. | Non-finite values silently rejected (→ `null`). | `null` | `RuntimeOptions.InitialOrbitYawDegrees``GameWindow.cs:1413` |
| `ACDREAM_PROBE_REVEAL_RADIUS` | `=<int>=1` (unparsable or `<1` → override absent; floor is 1, not 0) | #280 A/B measurement probe: forces the OUTDOOR reveal gate to use this landblock radius instead of the derived streaming window (near radius clamped to it), so a route can be measured with the pre-#280 behavior (`=1`, old `OutdoorNeighborhoodRadius`) vs. current | **Changes what gets revealed, not just measured** — genuinely resizes the reveal/visible window used by the live reveal gate. CLAUDE.md: "Leave it unset for any measurement or gate run — with it set you are measuring a different window than production." `=0` is rejected by the parser specifically because it would hang the very A/B route it exists to measure (`RequiredRenderRadius==0` fails `invalid-readiness-shape`). Not a user setting, not in Settings/RuntimeOptions, not persisted. | unset (derivation in charge, no override) | `StreamingDiagnostics.RevealRadiusOverride` (`StreamingDiagnostics.cs:25-27,76-80`), applied by `StreamingDiagnostics.ApplyRevealRadiusOverride` |
| `ACDREAM_PROBE_WORLD_FRAME` | `=1` | gates one `[world-frame] agree` line per projected conversion in `DatLiveEntityProjectionMaterializer`, recording the world-frame center both `LiveWorldOriginState` (App) and Runtime's physics-state owner used (issue #283, "measurement only; it never gates placement") | print-only | off | `PhysicsDiagnostics.ProbeWorldFrameEnabled` |
| `ACDREAM_SKY_PHASE_SECONDS` | `=<float>` (any finite value; negative accepted, taken mod 1 per axis) | Campaign V slice V7 instrument-determinism pin: freezes the sky's cloud-sheet UV scroll to a fixed elapsed-seconds value instead of monotonic real elapsed time, so two launches of a differential/offline gate agree about cloud position. | **Non-obvious dual effect**: this ONE var pins TWO independently-designed clocks that happen to share a name-adjacent purpose — the sky renderer's cloud scroll (`SkyRenderer.AnimationPhaseSecondsOverride`) AND, since Campaign VM slice VM6, the atmospheric post-process graph's foliage-wind clock (`_windClockSecondsOverride`). A gate that only knows about "sky clouds" and sets this to freeze them will *also* freeze foliage-wind evolution — deliberately snapped-to-target on the first advance per an A6 review fix, but still a second surface a naive reader wouldn't expect this var to touch. Distinct from `ACDREAM_DAY_GROUP`/`ACDREAM_WORLD_TIME`, which pin the OTHER sky clock (day group/sun angle) — retail's clouds drift independently of the calendar date by design. | `null` → monotonic elapsed time (every ordinary run) | `RuntimeOptions.SkyAnimationPhaseSeconds``SkyRenderer.AnimationPhaseSecondsOverride` (cloud/rain UV scroll) **and** `AtmosphericPostProcessGraph`'s foliage-wind clock |
| `ACDREAM_STREAM_WORK_COMPLETIONS` | `=<int>` (`>0`, else default) | Per-frame ceiling on streaming completion admissions on the update thread | Class doc comment states explicitly: this whole `ACDREAM_STREAM_WORK_*` family "exists for A/B measurement only" — not a user/production setting. Directly changes streaming throughput per frame; do not compare a measurement taken with this set against a default run. | 64 | `StreamingWorkBudgetOptions.Parse` (`StreamingWorkBudgetOptions.cs:56-58`) |
| `ACDREAM_STREAM_WORK_CPU_MIB` | `=<int MiB>` (`>0`, else default) | Per-frame ceiling on adopted (newly resident) CPU bytes on the update thread | A/B-measurement-only family; changes per-frame CPU admission budget | 8 MiB | `StreamingWorkBudgetOptions.Parse` (`:59-61`) |
| `ACDREAM_STREAM_WORK_DEST_RESERVE_PERCENT` | `=<float percent>`, exclusive `0 < x < 100`, else default; stored as fraction (`percent/100`) | Fraction of the per-frame work budget reserved for the active reveal destination lane vs. background streaming | A/B-measurement-only family; reallocates frame budget between destination-lane and background streaming work, changing reveal-latency characteristics | 0.75 (75%) | `StreamingWorkBudgetOptions.Parse`/`ParseReservePercent` (`:71-73,154-169`) |
| `ACDREAM_STREAM_WORK_ENTITY_OPS` | `=<int>` (`>0`, else default) | Per-frame ceiling on entity-cursor operations (small ops, e.g. one dictionary/index write each) on the update thread | A/B-measurement-only family. Doc comment: elapsed-time ceiling (`ACDREAM_STREAM_WORK_MS`) remains the authoritative CPU guard — this is a secondary cap, deliberately loose (leaves >90% of the time budget unused at default) | 4,096 | `StreamingWorkBudgetOptions.Parse` (`:62-64`) |
| `ACDREAM_STREAM_WORK_GL_RETIRE_OPS` | `=<int>` (`>0`, else default) | Per-frame ceiling on GL/GPU resource-retirement operations on the update thread | A/B-measurement-only family; changes retirement cadence, which changes when GPU memory is actually reclaimed | 64 | `StreamingWorkBudgetOptions.Parse` (`:68-70`) |
| `ACDREAM_STREAM_WORK_GPU_MIB` | `=<int MiB>` (`>0`, else default) | Per-frame ceiling on GPU upload bytes on the update thread | A/B-measurement-only family; directly changes per-frame upload throughput | 8 MiB | `StreamingWorkBudgetOptions.Parse` (`:65-67`) |
| `ACDREAM_STREAM_WORK_HOLD_DEST_MS` | `=<double ms>` (`>0` and finite, else default `8.0`) | Absolute (not quality-scaled) time ceiling for destination-lane work during a portal/login hold; never shrinks a profile whose own ceiling is already ≥ this value | Explicitly documented as "NOT a user setting... exists for A/B measurement only, matching the rest of the `ACDREAM_STREAM_WORK_*` family" — do not set outside a deliberate hold-latency A/B comparison | 8.0 ms | `StreamingWorkBudgetOptions.Parse` (`:74-76`); `HoldDestinationCeilingMilliseconds` widens the frame meter via `StreamingWorkBudget.WidenForDestinationHold` while a destination reservation hides the world behind the authored tunnel (#418) |
| `ACDREAM_STREAM_WORK_MS` | `=<double ms>` (`>0` and finite, else default) | Per-frame elapsed-time ceiling for update-thread streaming work — "the authoritative CPU guard" per the entity-ops comment | A/B-measurement-only family; this is the primary per-frame time budget for streaming — changing it changes both perceived streaming latency and measured frame cost | 2.0 ms | `StreamingWorkBudgetOptions.Parse` (`:53-55`) |
| `ACDREAM_UNCAPPED_RENDER` | `=1` | Removes the normal VSync/refresh-rate software pacer, so the render loop runs as fast as the GPU/CPU allow. | Own doc comment (`RuntimeOptions.cs:147-150`): "Normal presentation is always bounded by VSync or a refresh-rate software pacer. This explicit diagnostic is the sole way to measure truly uncapped renderer throughput." Not representative of what a real player experiences — exists purely for throughput measurement. | `false` → VSync/pacer-bounded | `RuntimeOptions.UncappedRendering``GameWindow.cs:765``DisplayFramePacingController`; also `VulkanBringUpHost.cs:75` |
| `ACDREAM_WB_DIAG` | `=1` (raw `string.Equals` ordinal compare) | (a) `GameWindow`: gates the `[FRAME-DIAG]` render-thread entity-upload-distribution report; (b) `WbDrawDispatcher`: gates `BeginRhiTimer`/`SampleRhiTimers`, wrapping the opaque/detail/transparent draw passes in extra Vulkan GPU timer-scope queries and periodically logging a `[WB-DIAG]` CPU/GPU median/p95 report | adds extra per-pass GPU timestamp queries every frame while on — genuine measurement overhead; NOT read through `RenderingDiagnostics` or any diagnostics-owner class, unlike every other flag in this set — flag for whitelisting (see Notes #2); the flag's supposed interaction with `ACDREAM_FRAME_PROF`'s GPU query is stale documentation (see Notes #1) | unset (off) | read directly at `WbDrawDispatcher.cs:2061-2064` (every `Draw()`/`BeginEntityDispatch` call, i.e. effectively per frame, NOT cached) and cached once as a readonly field at `GameWindow.cs:153-156` |
| `ACDREAM_WORLD_TIME` | `=<float>`, accepted only in `[0, 1)` | Campaign V slice V7 instrument-determinism pin: freezes the Dereth day fraction (and therefore sun direction, sky keyframe, and every lit surface) instead of following the server clock. | Outranks BOTH the server `TimeSync` clock and the `/time` slash command's `SetDebugTime` (which is deliberately transient — the next `TimeSync` clears it); this pin does not clear. Distinct axis from `ACDREAM_DAY_GROUP` (day-group/weather-preset selection) and `ACDREAM_SKY_PHASE_SECONDS` (cloud scroll + foliage wind) — the calendar DATE still advances, only the intra-day fraction freezes. Anything outside `[0,1)` (including negative, unparseable, or unset) leaves the server clock alone entirely — no partial/clamped behavior. | `null` → server clock | `RuntimeOptions.PinnedWorldDayFraction``GameWindow.cs:720``WorldEnvironmentController``Runtime.WorldTime.PinnedDayFraction` |
## Automation
A scripted route run adds three things at once — a session config so the
client self-selects a character, a route script, and an artifact directory:
```powershell
$env:ACDREAM_UI_PROBE_SCRIPT = "$scratch\route.txt"
$env:ACDREAM_AUTOMATION_ARTIFACT_DIR = "$scratch\artifacts"
$env:ACDREAM_FRAME_PROF = "1"
$env:ACDREAM_FRAME_HISTORY = "$scratch\frames.csv"
& $exe --session-config "$scratch\session.json"
```
**Two traps this recipe exists to document:**
1. **Without `--session-config`, the client stops at character select** and
the route never runs. The session JSON supplies the endpoint, account,
and a character `index` for auto-selection.
2. **`ACDREAM_AUTOMATION_ARTIFACT_DIR` is not free.** It constructs the
render-scene oracle, which fingerprints every resident entity every
frame. The allocation cost was fixed in
[#432](ISSUES.md), but the CPU walk remains — automation-run frame
rates are diagnostics-loaded and must only be compared against other
automation runs, never against a plain run. Some route verbs
(`wait world-*`) additionally do nothing unless this is set.
| Flag | Value | What it does | Side effects | Default | Read by |
|---|---|---|---|---|---|
| `ACDREAM_AUTOMATION_ARTIFACT_DIR` | `=<path>` | Output directory for the retail-UI automation probe's checkpoint JSON + screenshot PNG artifacts; gates whether the full `WorldLifecycleAutomationController` (checkpoint/screenshot/render-pack-automation capable) is composed at all vs. the cheaper facts-only `WorldRevealFactsAutomationRuntime` fallback (`wait world-ready/visible` verbs work either way per issue #415's fix; checkpoint/screenshot verbs report "requires ACDREAM_AUTOMATION_ARTIFACT_DIR" without it). | **Known #432 surprise, confirmed still live**: `FrameRootComposition.cs:349-353``AutomationArtifactDirectory is not null` (together with `RetainedUi?.Screenshots is not null`) unconditionally constructs a `CurrentRenderSceneOracle` **and** a `RenderSceneShadowComparisonController` — a per-frame diagnostics referee — regardless of whether any checkpoint/screenshot is ever actually requested that session. Merely setting this var for its "just an output path" purpose pays the per-frame comparison cost for the whole run. | unset (null) → facts-only automation runtime, no per-frame referee constructed | `RuntimeOptions.AutomationArtifactDirectory``FrameRootComposition.cs:351,543-627`, `WorldLifecycleAutomationController.cs`, `RetailUiAutomationScriptRunner.cs:108` |
| `ACDREAM_AUTOMATION_EXACT_FRAMEBUFFER` | `=1` | Forces the graphical host to use the persisted display resolution as the *initial* size of a **borderless** window at creation, so the OS window manager cannot clamp a decorated window to the desktop work area — needed for pixel-exact automated screenshot comparison. | Changes window chrome (borderless) at startup — a visible difference from an ordinary launch, not just an internal measurement knob. | `false` → normal decorated window | `RuntimeOptions.ExactAutomationFramebuffer``GameWindow.cs:852` (`CreateStartupWindowOptions`) |
| `ACDREAM_BAKE_PUBLISH_NONCE_V1` | `=<32-hex GUID "N" format>` | Launcher-to-bake-child authorization token: when present and valid, the bake child takes a cross-process publish file lock + writes an authorization file before atomic publication (serializes with launcher recovery) | If present but fails `IsValidNonce` (not a 32-char Guid "N"), throws `InvalidOperationException` and aborts the bake. When absent, bake runs unguarded (standalone mode). Never set this manually outside the launcher's own child-process spawn. | unset (standalone unguarded bake) | `BakePublicationGuardPaths.cs:12`, read by `BakePublicationGuard.AcquireIfRequested` (`AcDream.Bake/BakePublicationGuard.cs:18`); set by `BakeProcessRunner.cs:150/162` |
| `ACDREAM_NET_DROP_DIR` | `="out"`/`"in"`/anything-else (incl. unset) → `Both` (case-insensitive) | Selects which direction(s) — outbound, inbound, or both — the deterministic loss-injection decorator drops | Only takes effect when `ACDREAM_NET_DROP_PCT>0` (decorator is structurally absent otherwise). Drives real datagram loss on the live connection — the injection point for `tools/run-connected-loss-gate.ps1`. Never set during a normal/measurement run. | `Both` | `NetDiagnostics.NetDropDir` (`NetDiagnostics.cs:88-90,98-104`), consumed by `LossyTransportDecorator.WrapIfConfigured` (`Transport/LossyTransportDecorator.cs:21-22`), also read at `WorldSession.cs:901-907` (comment only) |
| `ACDREAM_NET_DROP_PCT` | `=<int 0-100>` (out-of-range or unparsable → 0) | Percent chance (post-handshake-arming, per droppable datagram) that the deterministic `LossyTransportDecorator` drops a packet in the configured direction(s) | **Fault injection.** `>0` wraps the real socket transport in a packet-dropping decorator for the whole session — genuinely breaks/delays delivery to exercise N1-N4 reliable-transport recovery. At 0 the decorator is never constructed (zero structural cost). Must be 0/unset for any normal run or non-loss-gate measurement. | `0` (off, decorator absent) | `NetDiagnostics.NetDropPercent` (`NetDiagnostics.cs:60-69,92-96`), consumed by `LossyTransportDecorator.WrapIfConfigured` (`:21`), wired at `WorldSession.cs:901-907` |
| `ACDREAM_NET_DROP_SEED` | `=<int>` (unparsable → `1`) | PRNG seed for the loss decorator (outbound seeded with `seed`, inbound with `~seed`) — same seed reproduces an identical drop pattern | Only matters when `ACDREAM_NET_DROP_PCT>0`; makes fault injection deterministic/reproducible for the connected loss gate | `1` | `NetDiagnostics.NetDropSeed` (`NetDiagnostics.cs:75-82`), consumed by `LossyTransportDecorator` (`:21-22`) |
| `ACDREAM_OPEN_CHARGEN` | `=1` | Campaign CC slice CC4 interim env/test-only seam: opens the character-creation screen (`gmCharGenMainUI`) automatically once Runtime's chargen view goes active, bypassing the real retail Create-Character-button transition. Fires once per mount (`_openOnStartConsumed` latch). | Own doc comment explicitly calls this "interim env/test-only" — Campaign CC (closed 2026-08-16, user-accepted) later wired the real Create button with its roster&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

@ -979,7 +979,7 @@ Research: R7 + R10 + R11 + UI slice 05.
- **✓ SHIPPED — H.1 — Chat window.** UI panel + all 6 wire opcodes (Channel, Tell, System, HearSpeech, HearRangedSpeech, TurbineChat). Wire layer + panel + outbound input + holtburger inbound parity + combat translator all shipped across I.1-I.7 on 2026-04-25. Targets `AcDream.UI.Abstractions`; will be reskinned when D.2b's custom retail-look toolkit lands.
- **H.2 — Allegiance.** Tree model + XP pass-up math + 5 allegiance chat channels + MOTD. See `r11-allegiance.md`.
- **H.3 — Emote scripts + quests + dialogs.** 122 EmoteType × 39 Trigger mini-VM. Contract tracker UI. NPC dialog rendered via chat with `<Tell:…>` markup. See `r10-quest-dialogs.md`.
- **H.3 — Emote scripts + quests + dialogs.** **Client scope COMPLETE 2026-08-21** (Campaigns QT + QJ, both user-accepted). The "122 EmoteType × 39 Trigger mini-VM" in this line describes the SERVER's job: per `r10-quest-dialogs.md` §1.3 the retail client stores no quest flag, evaluates no emote, and is never told a flag changed. It learns about quests three ways — dialogue strings the server already formatted, generic error toasts, and the contract tracker. The first two shipped earlier; the tracker, plus the Journal notebook and its index, shipped as the three-tab Journal panel (`RetailPanelCatalog.Journal` = 25). Start at `claude-memory/project_quest_journal_panel.md`.
- **H.4 — Character creation.** `0xE000002 CharGen` dat + 13 heritages + templates + appearance picker + preview renderer. See `r07-character-creation.md`.
**Acceptance:** create a character from scratch, talk to an NPC, get + complete a quest, gain XP that passes up to the patron.
@ -2070,6 +2070,10 @@ Native macOS graphical support is not committed by this track. The current
mandatory renderer requires modern OpenGL capabilities beyond Apple's native
OpenGL ceiling; revisit macOS only if a supported graphics backend is chosen.
**Future / unscheduled — Campaign AR:** the opt-in [Atmospheric Rendering / Shader Packs campaign](2026-08-21-atmospheric-rendering.md) makes moving authored sun-and-moon directional shadows from trees, monsters, players, and buildings its Tier-2 headline while preserving acdream's current retail-faithful renderer as the default and leaving physics, collision, gameplay, and network behavior unchanged; the project owner assigned Campaign AR on 2026-08-22 without displacing active M4 gameplay work. The [celestial source contract](../research/2026-08-22-dereth-celestial-shadow-sources.md) selects sun, dominant moon, then secondary moon by rendered direction while retaining AC's single authored directional-energy channel; sun rays and volumetrics remain sun-only. The previously referenced #268 + TS-8 package is complete and retired. Stage 1's automated correctness, performance, lifetime, locked-restore, Release, evidence, documentation, and project-owner live gates completed on 2026-08-22 after the opt-in exposure correction. The [Stage 2 connected and closeout gate](../research/2026-08-22-atmospheric-stage2-connected-gate.md) now closes every available machine-local ACE, dense A/B, long-lifetime, graphical-package, shader, Release, and complete-test row. The implemented two-client gate proved that ACE rejects two concurrent characters on the one available account and is ready for separate observer credentials without recording secrets. A distinct-account remote-player row, unavailable physical GPU classes, and final project-owner pack-off/pack-on acceptance remain external gates; Campaign AR is not yet declared shipped.
**SHIPPED 2026-08-23 — Campaign VM (VisualMaster):** the [VisualMaster campaign](2026-08-22-visualmaster-campaign.md) closed the [Campaign AR review](../research/2026-08-22-campaign-ar-review.md) findings with evidence: VM0 proved the pack-off path is the pre-campaign renderer (pixel identity on static content, CPU 13 %, allocation 25×); VM2 cdb-read retail's live detail path (single-pass texture stages, the z-fade dead for built meshes) and VM1 re-ported #226 to it; VM4 made the documents truthful (real hardware darkens, the fallback brightened); VM5 de-banded the volumetrics (#421 filed); VM3 put the post stack in linear light with a numerically neutral preset; VM6 added weather-driven foliage wind behind the render pack (scenery-only, cutout/trunk motions, casters follow, independent of the shadow gate) through five Opus review rounds and a repeat-floor pixel apparatus ([note](../research/2026-08-23-vm6-foliage-wind-pixel-proof.md)). VM7's automated gates ran 2026-08-23 on `99d5b2a6`: release gate 15,283/0/0, connected lifecycle/reconnect route PASS pack-off and pack-on, final pack-off vs `base+normals` robust diff 510 px, #422 not reproducible in 40 instrumented runs. The owner's VM3 (linear light) and VM6 (wind) live gates passed 2026-08-23 at 2560×1440 with the High pack; two defects found during those gates were fixed the same session (#424 alt-tab zero-area frame crash, #425 resolution-blind resident budget + activation lockout). #422 (rare heap-corruption exit at process close, pack-independent) is carried open by the owner's decision. Merged to main as a fast-forward of 54 commits.
---
## Cross-cutting work tracked in parallel
@ -2115,7 +2119,7 @@ OpenGL ceiling; revisit macOS only if a supported graphics backend is chosen.
| Sliding along buildings / walls feels wrong | **Phase L.2c + L.2d** |
| Roof edge / cliff / precipice blocks or slides wrong | **Phase L.2c** |
| Crossing outdoor cell seams reports the wrong cell | **Phase L.2e** |
| Can't talk to NPCs | Basic select/use/give interaction works; full emote conversation/dialog systems remain **Phase H.3** |
| Can't talk to NPCs | NPC dialogue works (user-confirmed 2026-08-21); the emote VM behind it is the SERVER's, not ours |
| Can't open a door | **FIXED** ✓ — object-use, animation, fading hooks, and collision transitions shipped |
| Portals render as a rotating black disk | **FIXED** ✓ — DAT particles/effects and portal-space presentation shipped |
| Chimneys have no smoke | **Phase E.3 SHIPPED** ✓ |
@ -2135,6 +2139,6 @@ OpenGL ceiling; revisit macOS only if a supported graphics backend is chosen.
| No character creation — must use ACE admin | **Phase H.4** |
| Sky is a flat color | **Phase G.1** (shipped; F7 cycles time, F10 cycles weather) |
| Can't join allegiance | **Phase H.2** |
| No quest tracker | **Phase H.3** |
| ~~No quest tracker~~ | **SHIPPED 2026-08-21** — the Journal panel's Contracts tab (Campaign QT) |
If you see something not on this list, add it here and assign a phase.

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

@ -503,6 +503,12 @@ elapsed seconds that `TexVelocityX/Y` accumulate against with a fixed value.
**Unset — the default, and every ordinary run — keeps the wall clock**, so
nothing the user or the offline gate sees changes unless a gate asks.
**2026-08-28 follow-up:** the ordinary sky-animation source is now a monotonic
`Stopwatch` clock, matching retail's accumulated timer deltas and preventing an
OS time correction from jumping rain/cloud UVs. The V7 pin and its gate
semantics are unchanged; the wall-clock wording above describes the original
V7 implementation.
It exists because `ACDREAM_DAY_GROUP` and the `AcdreamCycleTimeOfDay` override
pin only the *other* sky clock: the Dereth date, which chooses the day group,
the keyframe and the sun angle. The cloud sheet does not read that clock at all
@ -2591,6 +2597,10 @@ Holtburg pair went from 23,090 differing pixels to 1,211. **The alternative was
`-MaskTopPixels`, which would have permanently blinded the campaign's strictest
instrument to the entire sky.** See §5.1.
The 2026-08-28 rain-timing parity follow-up replaced that adjustable wall clock
with monotonic `Stopwatch` elapsed time; this historical V7 measurement and the
diagnostic pin remain otherwise unchanged.
**3. The world clock was never pinned at all, and that was most of the number**
(`1f25a609`). The route opened by pressing `AcdreamCycleTimeOfDay` three times.
The mechanism underneath is `WorldTimeService.SetDebugTime`, and

View file

@ -129,9 +129,9 @@ implementer per slice against a pinned contract (per
dissolved with the `RetailWindowChrome.Imported` mount (0x2100006F's
own border art IS the window chrome — no nine-slice wrapper, no
content crop, frame==content) using the DAT's real
minH=100/maxH=2000/minW=300/maxW=2000. Register row AP-185 files the
one accepted simplification (the `_Locked` cosmetic border-art swap on
`UiLocked` is not ported; the live grip skin shows unconditionally).
minH=100/maxH=2000/minW=300/maxW=2000. The originally deferred AP-185
`_Locked` cosmetic border-art swap was subsequently ported through the
shared registered-window lock presenter on 2026-08-20.
- **CH6b — floating windows 14.** Mount `0x2100005B` ×4 as
always-resident children per `gmGamePlayUI::SetupChildren
@0x004E9EC0` (ids 0x10000505/0x1000050E/0x1000050F/0x10000510);

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,334 @@
# Campaign LU — launcher usability
**Status: CLOSED USER-ACCEPTED 2026-08-19/20.** Ten slices — the six planned
plus four the gate rounds added — shipped through CI and accepted live.
**Gate results, in the user's words:** the update flow "works, it updates as it
should"; the launcher self-update round "pass"; the client's exit back to the
character selector "pass".
| slice | commit | what it fixed |
|---|---|---|
| (blocker) #420 client crash | `a34e8f2a` | character select killed the client mid-paint |
| LU1 instant startup | `00d12782` | 29.9 s → 0.89 s, measured on the real 27.9 GiB pak |
| LU2/LU3 one update question | `a01ff426` | six buttons → Update / Not now, self-restarting |
| LU4 Setup complete | `0a2defb6` | setup ends with a dialog, not a finished progress bar |
| LU5/LU6 Play + sessions | `09305be6` | one Play per character; rows say who is playing |
| (cross-cutting) locale | `6a15dd06`, `955c6180` | retail text stopped following the machine's locale |
| headless CLI + LU7 | `2bff44a9` | headless and character refresh had never run at all |
| LU8 roster + fold | `18bbd377` | logging in IS the refresh; Play above the fold |
| LU9/LU10 stop + logout | `6ab5d8ce` | 30 s graceful stop, ACE hold, logout lands on select |
| verification-cache limit | `7037681a` | the ZFS finding below |
Full solution under the release-gate filter: **14,375 passed, 0 failed,
0 skipped**, and identical under `sv-SE`, `tr-TR`, `ar-SA` and `de-DE`.
---
## What the gate rounds found that the plan did not
Four of the ten slices did not exist when this plan was written. Each came from
the user running the thing, and each was a defect the automated suite could not
have surfaced:
**Headless and character refresh had never worked, once.** The launcher spawned
`acdream-headless --config <path>`; the host reads `arguments[0]` as its command
and accepts only `validate` or `run`. Every launcher-started headless session
and every roster refresh died on its first instruction with "Invalid command"
and exit 64 — visible only as a code in a status file. A whole campaign's gates
missed it because they drove the headless host through its CLI directly, never
through the launcher's spec. `LauncherHeadlessCommandLineContractTests` now
feeds the launcher's real argument vector to the host's real parser.
**Refresh was harmful as well as broken.** It opened a second connection to an
account purely to read the roster, which the server treats as a new login — so
using it while playing disconnected you. It was also redundant: every ordinary
login already carries the roster, and the orchestrator already folds it in.
**Stop was the crash.** The UI gave the client five seconds before killing it,
which is not enough to send a logout, await the acknowledgement, and tear down a
mapped 28 GB world. So Stop routinely produced exactly the ungraceful exit that
leaves the server holding the account.
**Play was below the fold.** The buttons existed; the plugins/login-commands
form pushed them past the bottom of the scroll area. Reported, correctly, as
"there is no headless or gui option".
## Findings worth keeping
**The verification cache cannot see a same-size, same-timestamp change.** Run
174 failed on a test asserting it could. Measured on the runner: `/tmp` is ZFS,
and 141 of 200 same-size rewrites produced an identical mtime. NTFS's 100 ns
resolution is the only reason it never showed on Windows. The contract is now
two true statements — startup catches a corruption whose write time moves, and
a forced full verification catches one that preserves both — instead of one
that is false on some filesystems. Verify files is the forced path.
**Testing the launcher does not test your source.** The launcher runs the
INSTALLED client from the version store, so a client-side fix cannot be gated
until CI publishes it. A void-world screenshot was read as "the fix failed" when
the installed build was 63 minutes older than the fix.
**A locally built launcher cannot test self-update.** Its stamped version is
`1.0.0`, which sorts above every `0.1.0-build.*` the feed publishes, so it is
never offered an update. Publishing one with a deliberately low
`InformationalVersion` is what made that path testable at all.
**Goal**
> The launcher opens without a long wait. On startup it asks whether to
> update the launcher or the client, and restarts itself after a launcher
> update; the old update flow is gone. First-run setup ends with a success
> popup that returns you to the launcher on OK. A selected character
> launches directly. The sessions frame shows account, character (or Char
> Select) and whether they are in game — not the launch mode.
**Why now.** Campaign LA shipped a launcher that is *correct* — atomic
installs, verified artifacts, session barriers, rollback — and *not
usable*. The user's verdict, twice: "way too complex", "too complex for
sending it to my friends". This campaign changes the surface a person
touches. It does not weaken what happens underneath.
**Acceptance for the whole campaign** is the user's own walkthrough:
download `launcher-win-x64.zip` from the `latest` release, unzip, run,
install, play — without being told anything.
---
## LU1 — the launcher opens immediately
**Measured problem.** [App.axaml.cs:57](../../src/AcDream.Launcher/App.axaml.cs)
blocks the UI thread on `installer.LoadExistingAsync().GetAwaiter().GetResult()`
before the window is constructed. That reaches
`LauncherInstallRecordStore.VerifyFileAsync`, which computes a full SHA-256
of the installed package.
Measured on the user's machine 2026-08-19:
| fact | value |
|---|---|
| `%LOCALAPPDATA%\acdream\pak\acdream.pak` | 29,908,271,024 bytes (27.9 GiB) |
| full SHA-256 | **24.1 s** at 1.16 GB/s |
| digest vs `install.json` | identical (`fee8595d…`) |
So the startup cost is 24 s of disk read to re-confirm something that was
already true. A friend does not see it only because they have no package
installed yet — verification short-circuits at "nothing installed". It
will hit them the moment first-run setup finishes.
**Change.** Startup verification becomes size + last-write-time against
the record. The full hash keeps running where it is cheap and meaningful:
at install, after an update installs a new package, and behind an explicit
**Verify files** button (the Steam shape).
The cheap facts live in a **sidecar** (`install.verification.json`), not as a
new field on the install record. `LauncherInstallRecordStore` reads
`install.json` with `JsonUnmappedMemberHandling.Disallow`, so a new field
there would make an *older* launcher build reject the record outright and
demand a 28 GB re-bake after a rollback. An unknown sidecar file is simply
ignored by older builds, so the change is compatible in both directions.
An install with no sidecar yet pays one full hash and then writes it.
**Acceptance**
- Window visible in under 2 s with the 27.9 GiB package installed.
- Truncating or touching the package still blocks launch with a clear reason.
- **Verify files** reproduces the full check and reports pass/fail.
- The install and update paths still hash in full — unchanged.
---
## LU2 — one update question, asked once, at startup
**Change.** On start the launcher checks the feed once. If the launcher or
the client is behind, it shows **one** dialog naming what is out of date and
offering **Update** / **Not now**. Nothing else.
- Launcher first when the feed's `minimumLauncherVersion` demands it, or
when only the launcher is behind: install, then **restart into the new
version** (`LauncherSelfUpdateBootstrap` already owns this handoff).
- Client otherwise: install, close the dialog, back at the launcher.
- Nothing to do: no dialog at all. The launcher just opens.
**Acceptance** — three observed cases: up to date (silent), client behind
(one dialog → play), launcher behind (one dialog → relaunched on the new
version, confirmed by the version it reports).
---
## LU3 — delete the old update surface
The current prompt offers six buttons — Check again, Rollback client,
Stage launcher, Install client, Cancel, Close — plus a version table and a
restart-required banner. That is the flow being removed, along with the
"Check for updates" header button and the `LauncherUpdateViewModel` paths
only it reached.
**What stays:** everything in `AcDream.Launcher.Core/Updates/` that makes
an update safe — manifest validation, bounded verified download, safe ZIP
extraction, versioned install with an atomic `current.json` switch, the
session barrier, and rollback as a *capability*. The complexity the user
objects to is the panel, not the safety beneath it.
**Open — needs one confirmation before code is deleted:** rollback has no
place in the new single-question flow. It can move behind a small
"Advanced" affordance or leave the UI entirely (staying available as Core
API + tests). I will show the exact deletion list and ask before removing
it.
**Acceptance** — exactly one update entry point in the UI; tests covering
deleted view-model behavior are removed with the code, never skipped.
---
## LU4 — "Setup complete" ends first-run setup
**Change.** When the bake publishes and the install record verifies, the
wizard shows a modal: setup succeeded, what was built, **OK**. OK closes
the wizard and returns to the launcher with the "Client setup required"
banner gone and launching enabled.
**Acceptance** — a real first-run bake shows it exactly once on success;
cancellation and failure paths keep their existing error/status reporting
and must **not** show it.
---
## LU5 — pressing Play on a character launches that character
**Reproduce before changing anything.** The plumbing already exists end to
end: `LauncherOrchestrator.LaunchAsync` clones the character with the
*requested* mode (`CloneCharacter(character, mode)`),
`SessionConfigComposer.BuildSelector` emits an id selector (falling back to
name), and `RuntimeOptions.MapCharacterSelector` maps it into the App host.
A defect somewhere in a chain that reads correct is exactly the case this
project has repeatedly lost time to by guessing.
Two candidates to separate by observation, not argument:
1. The launch button is gated off by a capability reason, so the click
never becomes a session.
2. The selector reaches the client but the roster match fails, so character
select stays on screen — which is what "you can just select different
chars" describes.
**Change.** One obvious **Play** per character that enters the world as
that character, plus the deliberate "Character select" path kept separate.
Three near-identical launch buttons is itself part of the complaint.
**Acceptance** — select a character, press Play, arrive in the world as
that character with no character-select screen in between.
---
## LU6 — the sessions frame says who is playing
Today each row reads `server / account / character`, then `Mode`
(Gui/GuiSelect/Headless/Probe), then `State`, then a raw status string.
The launch mode is launcher bookkeeping and means nothing to a player.
**Change.** Each row shows the account, the character — or **Character
select** when no character was chosen — and one plain status word derived
from the host's own status stream:
`Starting``Character select``In game``Stopped` / `Failed`
Errors keep their own line. Stop keeps its button. Character-refresh
(probe) rows stay distinguishable from play sessions.
**Acceptance** — launching a character shows account + name + **In game**
once in world; a character-select launch shows **Character select** until a
character is entered.
---
## Non-goals
- No change to download verification, atomic install, or the session barrier.
- No change to credential handling (plaintext profile remains the user's decision).
- No change to Linux graphical gating (Slice L stays parked).
## Working rules for this campaign
- One slice per commit, `dotnet build` + `dotnet test` green before each.
- Push to main; CI gates on both runners and publishes the release the
launcher itself updates from — so every slice is testable by the user
through the shipped path within a few minutes.
- LU3's deletions and LU5's root cause get shown to the user before they
land.
---
# Implementation notes (recon 2026-08-19, before any code)
These were read out of the tree, not assumed. They exist so each slice
starts from the mechanism that is already there instead of re-deriving it.
## The self-update restart chain already exists end to end (LU2)
`LauncherUpdater.StageLauncherAsync` stages a verified payload and writes a
plan. On the next ordinary startup `LauncherSelfUpdateBootstrap.HandleAsync`
takes the exclusive lease, sees `SelfUpdatePlanState.Staged`, and spawns the
STAGED launcher in helper mode. `RunHelperAsync` waits for the parent PID to
exit, applies the replacement, starts the updated launcher with
`--acdream-self-update-confirm-v1`, and waits for the confirmation receipt.
So "restart after a launcher update" needs no new update machinery. What it
needs is one seam: after staging succeeds, start the staged helper against
the CURRENT process and shut down. Extract the existing staged-plan branch of
`HandleAsync` into a callable entry point and reuse it — do not duplicate it,
and do not restart by launching a second copy of the launcher and hoping the
bootstrap picks the plan up, which races the exclusive lease against the
process that is still shutting down.
## The orchestrator already knows "in game" (LU6)
`LauncherActivityState` has `InWorld`, and the orchestrator already sets it
from `EnteredWorldStatusEvent`, which carries the real `CharacterId` and
`CharacterName` from the host. Today that identity is written into a status
STRING (`"In world as X."`) and thrown away.
LU6 promotes it: the entered-world event updates the activity's character
name so a character-select launch can show who is actually being played, and
the row renders one word derived from `LauncherActivityState` rather than the
raw enum plus the launch mode:
| state | row shows |
|---|---|
| `Starting`, `Running` | Starting |
| `Connected` | Character select |
| `InWorld` | In game |
| `Disconnected`, `Stopping` | Stopping |
| `Exited`, `Cancelled` | Stopped |
| `Failed` | Failed |
`LauncherActivityKind.Probe` rows stay visually distinct (they are a
character refresh, not a play session).
## First-run completion has an exact point (LU4)
`FirstRunInstallerViewModel.StartAsync` succeeds at the line that calls
`_onInstalled(result.Record)` and sets `Phase = LauncherInstallPhase.Completed`.
That is where the success dialog belongs — after the record is published, so
the launcher behind it is already in its launch-enabled state when the user
presses OK. The cancelled and failed branches immediately below it must not
reach it.
## The launcher side of "launch this character" reads correct (LU5)
Confirmed by reading, so the live repro can skip re-checking these:
- `LauncherOrchestrator.LaunchAsync` -> `CloneCharacter(character, mode)`
overrides the profile's saved `LaunchMode` with the mode the button asked
for, so the stored default cannot leak into an explicit launch.
- `SessionConfigComposer.Compose` builds a selector for every mode except
`GuiSelect`, preferring a parsed non-zero id over the name.
- `SessionPlayerComposition` passes the selector into
`LiveSessionConnectOptions` with `AwaitCharacterSelection: selector is null`,
and `InteractionRetainedUiComposition` binds the character-selection UI only
when the selector is null.
The user's stored profiles all carry `launchMode: "guiSelect"` (the default),
and every cached character has a real id. So the defect is NOT a missing id
and NOT the saved default overriding the click. Reproduce live before
changing anything.

View file

@ -0,0 +1,977 @@
# Campaign AR — Atmospheric Rendering / Shader Packs
**Date:** 2026-08-21
**Status:** MACHINE-LOCAL STAGE 2 COMPLETE — the authored sun-and-moon
shadow-source extension, Stage 1 owner gate, connected ACE matrix, dense A/B,
long lifetime, graphical package lifecycle, shader, locked-restore, Release,
and complete-test gates pass. A distinct-account second-client remote-player
row, unavailable physical GPU classes, and final project-owner pack-off/pack-on
acceptance remain external closeout gates; the campaign is not yet declared
shipped.
**Phase id:** **Campaign AR** — assigned by the project owner on 2026-08-22
**Scheduling:** originally held for the post-M7 rendering-polish pass; the
project owner explicitly authorized implementation on 2026-08-21. This
owner-directed campaign is now Campaign AR and does not displace the active M4
gameplay work. The previously referenced #268 + TS-8 stat-chain package is
already complete and retired, so it is no longer a scheduling dependency.
## Goal
Add an opt-in enhanced-graphics system whose headline feature is **real-time
directional shadows cast by trees, monsters, players, and buildings as
Dereth's authored sun and moons move across the sky**. The same system can add
bloom, filmic tonemapping, colour grading, vignette, sun rays, and later
volumetric shafts, with useful quality levels on weak through high-end
hardware. Sun rays and volumetric shafts remain sun-only effects; the approved
moon scope applies to Tier 2 directional shadows.
Campaign AR executes in two stages. Stage 1 fixed the dense-scene transform
ceiling and shadow quality, completed authored sun/dominant-moon/secondary-moon
source selection, and finished every automated gate that did not require the
project owner's physical-display judgment. The owner accepted the subsequent
live visual/performance round on 2026-08-22 after the default Atmospheric
exposure was corrected from 1.0 to 0.80. The machine-local Stage 2 rows are now
complete. Remaining closeout is limited to a second connected remote player on
a distinct ACE account, unavailable physical-hardware rows, and final owner
acceptance; it does not add another renderer feature tier.
Stage 1's dense-scene regression is pinned by the connected failures already
captured on 2026-08-22: Atmospheric fell back at 68,395, 67,581, and even
65,538 combined world matrices against the old 65,536-matrix binding ceiling.
The corrected connected launch must exceed that historical workload without
persisting acdream-default fallback or splitting the authoritative pose data.
The enhancement is a shader pack, not a rewrite of acdream's renderer or AC's
art. **acdream's current retail-faithful renderer** remains the default,
authoritative path. References below to the “default” or “retail-faithful” path
always mean acdream—not the original retail executable.
The evidence and constraints for this design are recorded in the
[terrain and atmospheric rendering findings](../research/2026-08-21-terrain-and-atmospheric-rendering-findings.md),
especially [the measured renderer baseline](../research/2026-08-21-terrain-and-atmospheric-rendering-findings.md#5-renderer-state-relevant-to-atmospheric-work)
and [the requested tier model](../research/2026-08-21-terrain-and-atmospheric-rendering-findings.md#6-wanted-work--atmospheric-rendering-user-stated).
The approved celestial identity, priority, transform, and direction-versus-
energy contract are pinned by the
[Dereth celestial shadow-source research](../research/2026-08-22-dereth-celestial-shadow-sources.md).
## Opt-in contract
1. **acdream's current renderer is the default.** With no pack selected, the current render graph,
shaders, render targets, submissions, lighting, colours, and screenshots
remain authoritative. No enhancement resource or pass is created.
2. **Selection is explicit.** Installing a pack does not enable it. The user
selects one pack and one quality preset in Display settings. `acdream
default (retail-faithful)` is always present and cannot be removed.
3. **One pack owns the enhancement graph.** Packs do not stack. This prevents
ambiguous pass ordering, incompatible HDR conventions, and unbounded GPU
cost.
4. **The renderer owns the RHI.** A pack declares assets, semantic pass hooks,
capabilities, resources, and quality variants. It never receives Vulkan
handles or mutates the authoritative scene, streaming, gameplay, or
physics owners.
5. **Failure returns to acdream's default renderer.** Unsupported capabilities,
malformed assets, shader/pipeline candidate-creation failure, or an invalid
pass graph disables the complete pack and restores acdream's default path
with a visible reason. A half-enabled pack is never rendered. A terminal
`VK_ERROR_DEVICE_LOST` cannot render either path on the lost device; it tears
down that renderer/device lifetime, and retail remains authoritative while a
fresh renderer/device is constructed and the pack is validated again.
6. **Divergence is honest.** Enhanced screenshots are intentionally not retail
parity evidence. The default path remains the comparison oracle and the
enhancement choice is recorded in diagnostics and screenshot metadata.
7. **No scheduling claim.** Rendering phases stay frozen until the M7 polish
pass unless the project owner explicitly reprioritizes this work.
## Capability tiers
The costs below are **planning estimates**, not measurements. They are
incremental GPU p50 targets for a representative discrete GPU at 1920x1080;
every slice must replace them with physical-hardware measurements. Tier-1
pixel effects scale with output resolution, so 4K has roughly four times the
1080p fragment workload. Shadow-map cost depends more on caster count, map
resolution, and cascade count than on output resolution.
| Tier | Contents | Prerequisite | Rough incremental GPU cost at 1080p |
|---|---|---|---:|
| acdream default | Current authoritative retail-faithful rendering | Current mandatory Vulkan/RHI capabilities | 0 ms |
| 0 | **Weather-driven foliage wind** (Campaign VM VM6): procedural-scenery trees/bushes lean, branch-swing, and flutter; their directional-shadow casters apply the identical displacement so the shadow moves with the leaf | Shader ABI v2 (`AtmosphericFrame` gains `uAtmosphereClockWind`/`uAtmosphereWindAmplitude`); the existing `ProceduralSceneryIdAllocator` entity-id namespace for classification | ~0.05 ms (near-zero; ~25 ALU per displaced vertex, zero CPU cost, zero extra draw submissions — an early-out `uint` AND gates every non-foliage vertex) |
| 1 | Bloom, ACES filmic tonemap, colour grade, vignette | Main-world colour intermediate and fullscreen passes | 0.350.80 ms |
| 1 | Screen-space sun rays (crepuscular) | Authored sun screen position plus an occlusion mask; **no shadow maps** | 0.200.50 ms |
| 2 | **Moving authored sun-and-moon cascaded directional shadows from trees, monsters, players, and houses/buildings** | A second scene pass, selected-celestial view/projection matrices, sampled depth maps, caster pipeline variants | 1.503.00 ms |
| 2+ | Sun-only volumetric light shafts | Reuse Tier-2 shadow infrastructure only while the selected source is the authored sun, plus authored weather | 0.150.40 ms |
| Later | SSAO and water reflections | Scene depth plus normal inputs and separate designs | Not budgeted here |
| Out | True PBR | AC lacks authored per-texture normal/roughness/metalness maps | Not planned |
Tier numbers express prerequisites, not a forced bundle. A pack may offer
Tier 1 without shadows. Tier 2 always includes the complete shadow-caster
classes; weak-hardware presets reduce range, cascade count, and resolution
rather than silently dropping monsters, trees, or buildings.
**Foliage wind (Tier 0, Campaign VM VM6).** Deliberately numbered below Tier
1: it needs no fullscreen pass, no colour intermediate, and no shadow
infrastructure of its own — only the shader ABI v2 clock/wind block and the
per-batch classification bits `BatchData.flags` already carries. It composes
with every other tier (Tier 2's shadow casters read the same displacement so
a swaying tree's shadow tracks it) and, uniquely among these tiers, is cheap
enough that a future default-on consideration is plausible; today it ships
opt-in with the rest of the atmospheric pack, gated by `wind-enabled`.
## Tier 2 headline: Dereth's moving authored sun-and-moon shadows
The [measured renderer state](../research/2026-08-21-terrain-and-atmospheric-rendering-findings.md#5-renderer-state-relevant-to-atmospheric-work)
already supplies retail's single directional colour/energy channel from
`SkyStateProvider`. Tier 2 augments it with the visible authored celestial
positions documented in the
[Dereth celestial shadow-source research](../research/2026-08-22-dereth-celestial-shadow-sources.md).
The camera-relative cascaded map selects, in order, the visible above-horizon
sun (`0x01001348`), dominant haloed moon (`0x01001F6A`), or secondary moon
(`0x01001F67`). The selected mesh's exact rendered transform supplies shadow
direction; retail's one interpolated `DirColor * DirBright` channel supplies
colour/energy. Moon texture brightness and mesh luminosity never manufacture a
second world light. As those authored bodies move, tree branches, monsters,
players, houses, and other eligible world geometry cast correspondingly moving
shadows.
This source selection is an explicit opt-in pack enhancement. It is not a
claim that the retail executable rendered real-time moon shadows, and it does
not alter acdream's default retail-faithful scene lighting. Screen-space sun
rays and volumetric shafts continue to use only the authored sun; they do not
switch to either moon.
The required behavior is:
- Terrain and opaque world geometry receive shadows. Terrain, buildings,
statics, procedural scenery, the local player, remote players, and creatures
cast them when resident and visible to the main outdoor world.
- Foliage and other cutout materials use an alpha-sampling shadow fragment
shader. An empty depth fragment shader would turn each tree plane into a
solid rectangular shadow.
- Animated casters reuse the exact per-part transforms already published in
the N.5 SSBO. The shadow pass must not create a second animation pose or
gameplay entity projection.
- Cascades follow the camera and are texel-stabilized. Their reach is clamped
to the resident two-tier streaming window; the pack does not extend world
streaming or issue speculative loads.
- The celestial directional-shadow pass is outdoor-only. Dungeon and EnvCell lighting remains
authored per-cell lighting. Entering an interior retires or idles outdoor
shadow work without leaving stale maps on screen.
- Shadow direction follows the selected visible above-horizon sun, dominant
moon, or secondary moon. The authored directional colour/energy remains
`DirColor * DirBright`; active day/weather pack policy may soften or reduce
it without inventing a second celestial clock, light-energy channel, or
weather system. A time with no eligible above-horizon body has no
directional shadow; night is not itself a disable condition.
- Transparent blend materials do not cast an opaque silhouette by default.
Only existing opaque and cutout classifications participate until a
material-specific transparent-shadow contract is designed.
The pass reuses the retained resident scene. It must not run PView, portal
traversal, or per-object CPU visibility classification once per cascade.
Initially, each cascade draws the bounded resident caster set through the
existing batched/MDI ownership. If that is too expensive, the next permitted
step is GPU culling—not repeated CPU culling or per-object submissions.
## Pack API surface sketch
The public declarations belong in the BCL-only
`AcDream.Plugin.Abstractions` assembly. The graphical App supplies the
implementation and translates the declarations to the Vulkan RHI. Headless
hosts expose no render-pack registry and never load pack assets.
This is an API shape, not code committed by this design:
```csharp
public interface IRenderPackPlugin
{
void Register(IRenderPackRegistry registry);
}
public interface IRenderPackRegistry
{
IDisposable Register(RenderPackDescriptor descriptor, IRenderPackAssets assets);
}
public interface IRenderPackAssets
{
Stream OpenRead(string assetKey);
}
public sealed record RenderPackDescriptor(
string Id,
string DisplayName,
Version PackVersion,
int PackApiVersion,
RenderPackTier HighestTier,
IReadOnlyList<RenderCapability> RequiredCapabilities,
IReadOnlyList<RenderCapability> OptionalCapabilities,
IReadOnlyList<RenderResourceDeclaration> Resources,
IReadOnlyList<RenderPassDeclaration> Passes,
IReadOnlyList<SceneReplayDeclaration> SceneReplays,
IReadOnlyList<PipelineVariantDeclaration> PipelineVariants,
IReadOnlyList<RenderQualityPreset> QualityPresets,
IReadOnlyList<RenderSettingDeclaration> Settings,
AtmospherePolicyDeclaration? AtmospherePolicy);
```
A pack declares:
- a stable ID, display name, pack version, and pack-API version;
- its highest tier and a human-readable feature summary;
- mandatory and optional GPU capabilities and per-preset limits;
- shader assets and fixed renderer semantic inputs, including world colour,
scene depth, optional normals, selected celestial shadow direction/energy,
sun direction/screen position for sun-only effects, active weather, camera
matrices, shadow-caster transforms, and frame time;
- intermediate images/buffers by relative or absolute extent, format class,
usage, lifetime, and estimated bytes;
- passes at renderer-owned hooks such as `ShadowDepthBeforeWorld`,
`AtmosphereBeforeToneMap`, `ToneMap`, and
`AfterToneMapBeforePrivateViewports`;
- renderer-owned scene replays such as `OutdoorDirectionalShadowCasters`, with
requested cascade views and existing caster/material classes
(`Terrain`, `OpaqueWorld`, `AlphaCutoutWorld`, `AnimatedOpaque`, and
`AnimatedAlphaCutout`); the renderer resolves those classes from its
retained scene and records their existing batched draws;
- fixed pipeline variants for shadow-caster depth and main-world shadow
receivers. A variant names its base semantic (`Terrain`, `WorldMesh`, or
`EnvCell`), shader asset, compatible material classes, and declared inputs
such as cascade matrices, directional depth maps, and sampler state; it does
not replace visibility, batching, mesh ownership, or draw submission code;
- quality presets, user-visible settings with bounded ranges, and declared
incremental GPU/VRAM budgets; and
- an atmosphere policy: explicit directional-source/sun-elevation response
curves and a mapping from AC's categorical `activeDayGroup` values to effect
multipliers. These values live in the visible pack declaration, not as hidden
renderer constants; AC remains the owner of celestial position, directional
energy, and weather state.
The renderer—not the pack—defines descriptor layouts, validates SPIR-V and
resource declarations, resolves semantic scene-replay and pipeline-variant
requests, builds pipelines, schedules barriers, owns frame-flight and teardown,
and supplies immutable frame inputs. Packs cannot add arbitrary draw callbacks,
read gameplay owners, submit command buffers, retain borrowed frame views, or
address resources outside their registration. The built-in pack's Tier-2
caster pass and receiver shaders must be expressible entirely through these
same public declarations.
### Selection and fail-safe lifecycle
1. Discover manifests and descriptors without creating GPU objects.
2. Show compatibility and estimated cost in Display settings. Unsupported
packs remain visible with the exact missing capability; they cannot be
selected.
3. On explicit selection, validate the whole descriptor, all assets, resource
ceilings, hooks, and shader interfaces; then build a complete candidate
pipeline set off to the side.
4. Atomically activate the candidate only after every required object exists.
Until then acdream's default path continues rendering.
5. Persist `pack id + pack version + preset`, never a positional index. If the
pack disappears or becomes incompatible, select `acdream default` and
retain the failure notice.
6. On runtime validation or post-recreation candidate failure, withdraw all
pack passes/resources at a frame boundary and resume acdream's default
renderer. Do not repeatedly retry a failing pack during the session.
7. Unload and reconnect use the normal render-generation and GPU-flight
retirement rules. No pack object may retain a world generation, scene
entity, or collectible plugin load context.
In this campaign, **device recreation** means disposing the complete old
renderer, Vulkan context, and device, then constructing a fresh context/device,
re-probing capabilities, and validating selection again with retail active
until the candidate is complete. It does **not** mean live, in-process recovery
from `VK_ERROR_DEVICE_LOST`; device loss remains terminal to that renderer and
device lifetime.
The built-in Atmospheric Rendering pack should be the first consumer of this
same API. It must not receive private renderer shortcuts that third-party packs
cannot express.
The public v1 authoring surface, manifest schema, shader semantic bindings,
failure guidance, validator command, and external no-op sample are indexed by
the [render-pack SDK](../render-packs/README.md).
## Frame-graph placement
With the pack off, the frozen retail graph is unchanged. With a pack selected,
the renderer builds a separate enhancement graph:
1. Update the existing immutable world frame, including authored sky objects,
the retail directional colour/energy channel, and weather.
2. Outdoors, select the visible above-horizon sun/dominant moon/secondary moon
direction and render Tier-2 cascaded shadow depth from the resident caster
set.
3. Render the main world to the pack's world-colour intermediate, using
pack-selected pipeline variants to sample the shadow map where requested.
4. Preserve the established PView, punch/seal depth discipline, shared-alpha
ordering, particle ordering, and world transparency boundaries.
5. Generate screen-space sun occlusion/rays or, only while the shadow source is
the sun, shadow-map volumetrics.
6. Composite rays/shafts **before tonemapping**, so bloom sees them and the
filmic curve rolls them off instead of clipping them.
7. Apply tonemap, colour grade, and vignette to the main world image.
8. Continue with private portal/paperdoll/appraisal viewports and retained UI
on their existing path. They are not accidentally post-processed with the
main world.
## Delivery slices and acceptance
The slice labels below are local to this document. They are not phase IDs.
### Pre-moon checkpoint, Stage 1 acceptance, and Stage 2 start — 2026-08-22
Before the approved moon extension, all seven local slices (06) had production
implementations in the current worktree. A source-identical isolated clean
snapshot closed that sun-only reference-GPU physical matrix, and one physical
integrated-AMD Auto safe-fallback row was also present. Those artifacts remain
valid evidence for the exact binaries and sun-only scope they measured; they
are not moon-alignment, source-transition, current-worktree, or final user-
acceptance evidence.
Stage 1's automated implementation and validation are complete. The authored
sun/dominant-moon/secondary-moon resolver and its direction-versus-energy
handoff are present and covered without inferring physical quality from unit
tests, screenshots, or historical sun-only rows. The subsequent live ACE round
covered the owner-reported shadow visibility/configuration, temporal
pixelation/shimmer, frame-pacing/desktop responsiveness, selection/fullscreen
regressions, and matched indoor/outdoor exposure. After the exposure correction
the owner accepted the live result. The exact evidence and limits are recorded
in the [Stage 1 live-gate report](../research/2026-08-22-atmospheric-stage1-live-gate.md).
Current authored-celestial Stage 1 automated gate (2026-08-22):
- The shader compiler reports **24/24** Vulkan shader pairs ready. Incremental
regeneration expands only pack includes and preserves all 18 pre-campaign
retail SPIR-V artifacts byte-for-byte; the exact SHA-256 oracle and complete
source-manifest checks pass. All selected-celestial binding-6 modules expose
the 336-byte ABI v1 layout, including source kind at offset 320.
- Focused Release validation passes **344/344** App renderer tests, **30/30**
standalone SDK/pack-validator tests, **14/14** Core sky-loader tests, and
**48/48** MossTank tests. Both external Tier-2 samples embed and validate the
current selected-celestial shader ABI without App or Vulkan dependencies.
- The repository's forced locked restore passes. The complete Release solution,
including all source, tests, tools, and SDK samples, builds with **0 warnings
and 0 errors** after that restore.
- The repository-owned fresh-process hermetic gate passes
**14,928/14,928** tests with zero skips or failures across 14 assemblies
**under the repository's hermetic lane filter** (Manual/Timing/Live/
InstalledDat/Diagnostic lanes excluded; a raw `dotnet test AcDream.slnx`
reports those lanes as skips/failures by design — VM4 correction);
`AcDream.App.Tests` contributes **5,823/5,823**. Evidence is under
`artifacts/atmospheric-rendering/stage1-moon-release-gate/`.
- The App total includes the 9,500-caster 256-frame zero-managed-allocation
steady-state fixture, warmed CPU/GPU sampling allocation gates, the complete
12-cycle Low/Medium/High/retail/resize/failure/recovery/frame-flight/
generation convergence fixture, and independent renderer/context/device
recreation. These prove the non-physical performance and lifetime contracts;
they do not claim physical frame pacing or image quality.
- A final path audit finds no source changes under `src/AcDream.Runtime`, no
physics or collision changes, and no changes to the retail GLSL sources or
tracked retail SPIR-V binaries. Pack-off production integration remains the
strict authoritative-path oracle.
The command-level record and evidence boundary are in the
[Stage 1 automated gate report](../research/2026-08-22-atmospheric-stage1-automated-gate.md).
Recorded pre-moon automated checkpoint (not a current moon-scope completion
claim):
- The repository-owned fresh-process Release test stage passes
**14,880/14,880** tests with zero skips or failures across 14 assemblies
(hermetic lane filter, as above).
`AcDream.App.Tests` contributes **5,783/5,783**; campaign-focused App cases
cover descriptor/asset/SPIR-V
validation, pack-off/no-op invariants, atomic asynchronous candidate swaps,
runtime fallback, declared settings, Tier-1/Tier-2/Tier-2+ graph execution,
all headline caster classes, topology caching, exact animated transforms,
Low/Medium/High/Auto policy, diagnostics, and the pack UI.
- Headless plugin-session tests pass **6/6**, including rejection of a
render-pack-only request before its DLL is loaded.
- The SDK validator suite passes **26/26** and builds/validates the external
`AcDream.RenderPacks.NoOp`, `AcDream.RenderPacks.AtmosphericTier2`, and
`AcDream.RenderPacks.ShadowsOnlyTier2` samples without App or Vulkan
references.
- The production catalog is revisioned rather than frozen at startup. The same
composed controller/UI observes external registration, withdrawal, and
corrected re-registration; an active withdrawn pack retires at the next
frame boundary, persists retail fallback, releases its asset/context owners,
and does not retry the removed registration. Runtime package admission now
matches the SDK: exactly one public constructible render-pack entry point and
at least one live registration, with transactional rollback for malformed,
multiple, internal, zero-registration, and partially failing packages.
- The retained 9,500-caster warmed-frame fixture performs no second-frame
scene-index copy, topology rebuild, sort, or classification and allocates
zero managed bytes. Animated-static, live-dynamic, and equipped-child root
and part transforms refresh through cached IDs/slots with exact float bits.
- Render and screenshot diagnostics now publish exact accepted counts for
terrain commands, outdoor statics, buildings, animated statics, local
players, remote players, non-player creatures, other live dynamics, and
equipped children. These labels stop at the authoritative evidence boundary:
static DAT publication does not distinguish a tree from other outdoor
scenery, and create-object render metadata does not distinguish a hostile
monster from a non-hostile NPC creature. Diagnostics therefore report
`OutdoorStatics` and `NonPlayerCreatures`; they never infer tree or monster
identity from a mesh or ID.
- The recording-RHI long-cycle gate repeatedly crosses Low, Medium, High, and
retail selection; resize; injected candidate failure and explicit recovery;
both frame-flight slots; render-generation replacement; and final renderer
disposal. Pack resources, pipeline-format leases, texture slots, retained
transforms, receiver candidates, and registrations converge exactly. A
second fixture proves that device recreation is old-renderer/context/device
teardown followed by an independent fresh device and activation generation.
- Therefore the deterministic lifecycle implementation, recording-RHI
convergence, and fresh-device recreation definition are locally closed. The
executable connected route and its contract assertions are implemented for
select/disable/re-enable, exact resize, authored time/weather changes, and
fresh-process recreation, but a contract-tested route is not connected-world
evidence; its ACE-backed execution and artifacts remain open below.
- The complete Release solution, including all three SDK samples, the
validator, shader compiler/generated manifest, and repository tools, builds
with **0 warnings and 0 errors**. This historical checkpoint could not repeat
locked restore because that managed workspace denied NuGet access to the
user-profile `NuGet.Config`; the later Stage 1 moon gate and final Stage 2
gate supersede that limitation and both report forced locked restore green.
The corresponding durable source/test entry points are:
- public contracts and SDK:
`src/AcDream.Plugin.Abstractions/Rendering/`, `docs/render-packs/`,
`tools/RenderPackValidator/`, `samples/AcDream.RenderPacks.*`, and
`tests/AcDream.RenderPackValidator.Tests/`;
- activation, compatibility, Auto, diagnostics, and built-in graph:
`src/AcDream.App/Rendering/Packs/` and
`tests/AcDream.App.Tests/Rendering/Packs/`;
- moving authored-celestial cascades, casters, receivers, and retained topology:
`src/AcDream.App/Rendering/DirectionalShadow*.cs`,
`src/AcDream.App/Rendering/Packs/AuthoredCelestialShadowSource.cs`,
`src/AcDream.App/Rendering/Scene/DirectionalShadowCasterFrame.cs`,
`src/AcDream.App/Rendering/Wb/WbDrawDispatcher.DirectionalShadows.cs`, and
`tests/AcDream.App.Tests/Rendering/DirectionalShadow*` plus
`tests/AcDream.App.Tests/Rendering/Packs/AuthoredCelestialShadowSourceResolverTests.cs`;
- retained Display UI and headless boundary:
`src/AcDream.App/UI/Layout/ConfigOptionsPageController.cs`,
`tests/AcDream.App.Tests/UI/Layout/ConfigOptionsPageControllerTests.cs`, and
`tests/AcDream.Headless.Tests/HeadlessPluginSessionTests.cs`.
Machine-local offline/physical evidence currently present under
`artifacts/atmospheric-rendering/` is gate evidence, but it is not a substitute
for connected-world or project-owner acceptance:
- `smoke-retail-720p/` records the pack-off `retail/off` path with zero pack
resources, casters, cascades, or classification calls;
- `accept-shadow-morning-200m/`, `accept-shadow-afternoon-200m/`, and
`accept-shadow-morning-close/` contain fixed-camera moving-sun shadow
screenshots plus metadata for 9,498 resident casters and three cascades;
these pre-moon captures do not prove moon alignment or source transitions;
- `volumetric-valid-camera-500m/` contains a High-preset volumetric diagnostic
capture; it is not a performance acceptance row;
- `matrix-clean-snapshot-dense-linear-v20/` is the current complete AMD Radeon
RX 9070 XT physical matrix: **30/30 rows pass** across
retail/Low/Medium/High/Auto, 1080p/1440p/4K, and capped/uncapped pacing. The
actual worktree's 4,397 source files were copied and hash-verified into an
isolated clean snapshot at commit `4876c970`; the Release App product version
names that exact commit, source and binary identities match, and tracked
status is empty. Its 18 active enhanced rows each own exact 2,048-sample
CPU/receiver/GPU windows, 9,498 casters, and zero warmed classification calls.
Six rows are retail and six 4K Low/Medium/Auto rows are accepted
resource-unavailable fail-safe outcomes;
- `matrix-final-v16-exact-auto/` and the two `current-low-1080p-*-v20/`
captures retain the optimization's predecessor/reference trail; the clean
snapshot matrix above supersedes them as current reference-adapter evidence;
- all six retail rows record zero pack resources, passes, casters, cascades,
draws, or dispatches. The six unavailable 4K Low/Medium/Auto rows likewise
record zero pack work and pass their strict paired-default framebuffer
comparisons instead of rendering a half-enabled graph;
- `igpu-auto-safe-fallback-v7-paired/` records physical Auto behavior on the
integrated **AMD Radeon(TM) Graphics** adapter (Vulkan 1.4.315, driver
2.0.353). After 180 stable Low samples, Auto failed safe with the exact reason
`GPU p99 19.308 ms (budget 3.000 ms), CPU p99 0.534 ms (budget 0.500 ms),
resident GPU bytes 41648404 (budget 67108864)`. The published state is
`retail/off`, has zero pack resources or work, and retains that visible
reason. Its comparison against the paired time-matched retail artifact
`igpu-retail-current-v6-time-matched/`, using `sky-mask.png`, differs in only
**56 / 1,536,000 compared pixels**, a **0.003645833% (0.00365%) sky-masked
pixel difference**, below the 0.1% gate. This closes physical weak-adapter
safe fallback, not active Low performance on that adapter or the remaining
GPU matrix;
- `compare-retained-transform.json` records the retained-transform image
comparison used by the 9,500-caster steady-state gate.
Stage 1 project-owner gate:
- **PASS — accepted by the project owner on 2026-08-22.** The acceptance closes
Stage 1's physical-display and desktop-performance stop. It authorizes Stage
2; it is not a substitute for Stage 2's connected scenario, long-lifetime,
package lifecycle, additional physical-GPU, or final pack-off/pack-on rows.
Stage 2 machine-local closeout result (2026-08-22):
The exact commands, commits, metrics, artifacts, and evidence limits are in the
[Stage 2 connected and closeout report](../research/2026-08-22-atmospheric-stage2-connected-gate.md).
1. **PASS, except the external second-client row.** The connected graphical
route covered moving local players, known monster encounters
reported under the authoritative `NonPlayerCreatures` category, and equipped
children; landblock publication/demotion; clear, overcast and rain;
outdoor/interior/dungeon transitions; portal travel and reconnect. Capture
the implemented select/disable/re-enable, resize, authored sun/moon source
transitions and weather, and
fresh-process renderer/context/device-recreation assertions against a real
ACE session, proving exact resource convergence and no stale maps/owners.
The implemented two-client gate proved the observer login and movement, but
ACE rejected the concurrent primary login when both used the available
account. The gate now requires dedicated observer environment credentials,
keeps them out of its artifacts, and permits independent character indexes;
a distinct-account connected run must close the authoritative nonzero
`RemotePlayers` row.
2. **EXTERNAL HARDWARE GATE.** Repeat the current clean-source RX 9070 XT
pack-off/Low/Medium/High/Auto × 1080p/1440p/4K × capped/uncapped matrix on
every other supported physical GPU class. The integrated-AMD Auto-to-retail
artifact proves weak-adapter safe fallback only; it does not prove active Low
or the complete matrix on that adapter. Automated weak-GPU fixtures, one
fallback row, and one high-end reference adapter do not prove the remaining
physical rows.
3. **FINAL OWNER GATE.** Complete the visual matrix for Tier-1 neutral values and private-view/UI
isolation; rays at dawn/noon/dusk, behind-camera and occluded states;
foliage cutouts; moving animated shadows under sun and moon; indoor gating;
source-transition continuity; temporal pixelation/shimmer; bias/cascade seam
review; sun-only volumetric weather/occluder behavior; and pack-off
restoration.
4. **PASS.** External install/select/update/remove/fail/recover flows passed in
six fresh connected graphical processes. The nine-stop Medium lifetime route
passed with graceful shutdown, as did the deterministic 12-cycle and
fresh-device convergence fixtures.
5. **FINAL OWNER GATE.** Obtain explicit project-owner acceptance of the final pack-off and pack-on
visual/performance matrix before changing this document to shipped.
### Slice 0 — Contract, capability probe, and acdream-default no-op
**Implementation:** complete. The BCL-only v1 ABI, live revisioned plugin
discovery/catalog, retained Display selection, compatibility/cost summaries,
strict SDK-equivalent entry admission, asynchronous candidate preparation,
frame-boundary activation/withdrawal/fallback, registration-scoped no-retry,
stable diagnostics, no-op sample, and headless exclusion are present and
automated. The checked-in default-path oracle and RX 9070 XT physical pack-off
rows pass; connected lifetime and graphical package evidence now pass. The
remaining supported physical GPU classes stay open.
Define the versioned BCL-only descriptor/registry, manifest fields, semantic
bindings, pack discovery, Display selection, diagnostics, and atomic
activation/fallback transaction. Implement a no-op conformance pack only.
**Acceptance:** `acdream default (retail-faithful)` remains selected on clean and upgraded
installs; pack discovery allocates no GPU resources; the disabled run has the
same pass list, pipeline set, draw/dispatch counts, deterministic framebuffer
digests, and resource ledger as the pre-campaign baseline; malformed,
unsupported, missing, and shader-invalid fixtures all report one precise
reason and render acdream's default path without partial resources or retry loops; headless
hosts load no render assemblies or pack assets.
### Slice 1 — Tier-1 world-colour and filmic stack
**Implementation:** complete. The pack-owned main-world target, bloom chain,
ACES filmic pass, colour grade, vignette, declared neutral settings, resize
recreation, and private-viewport/UI placement are implemented and automated.
The RX 9070 XT 1080p/1440p/4K physical budget rows pass. Project-owner visual
acceptance and the remaining supported physical GPU classes stay open.
Add the main-world intermediate and implement bloom, ACES filmic tonemapping,
colour grade, and vignette through the pack API. Supply half/quarter-resolution
variants and preserve private viewports/UI.
**Acceptance:** every effect can be independently set to its neutral value;
the preset is deterministic across resize/recreate; UI, paperdoll, portal, and
appraisal surfaces retain their accepted colours; 1080p/1440p/4K captures show
no clipping, haloing at the world/UI edge, stale frame, or resource leak; the
slice meets its preset GPU/VRAM budget.
Campaign VM VM3 (2026-08-22) moved the post stack to linear light: every
world/ray/volumetric read is decoded with the 2.2 display assumption; the
bloom intermediate is already linear (decoded once at extraction, so
downstream blur/composite reads need no further decode). Tonemap/grade/
vignette run in linear and the result is re-encoded; the post stack's
neutral settings are a numerical identity (measured on a real frame at
Campaign VM VM3: ≤1 LSB on 99.99 % of pixels; 95 foliage-silhouette pixels
— 58 isolated — differ by up to 73 LSB from cutout-edge rasterization between
two separate client runs, none on any surface).
### Slice 2 — Tier-1 screen-space sun rays
**Implementation:** complete. Authored sun projection, the screen-space
occlusion mask, declared sun/day-group/weather policy, pre-tonemap ray
composition, and deterministic disabled gates are implemented and automated.
Connected time/weather transitions pass; physical-display acceptance for
dawn/noon/dusk, behind-camera, occlusion, and edge-flicker behavior remains in
the final owner visual matrix.
Project the existing authored sun position, build a screen-space occlusion
mask, and composite weather-driven crepuscular rays before tonemapping. This
slice deliberately has no shadow-map dependency.
**Acceptance:** clear dawn/dusk produces visible raking rays, noon makes them
vanish, overcast/rain mutes them, the sun behind the camera or fully occluded
produces none, and camera edges do not streak or flicker. The pack descriptor's
sun-elevation and `activeDayGroup` policy deterministically produces those
states without a second weather/clock owner. The exact same scene with Tier 1
disabled returns to the Slice-0 digest from acdream's default
retail-faithful renderer.
### Slice 3 — Tier-2 moving authored sun-and-moon dynamic shadows
**Implementation:** Stage 1 automated and project-owner live gates complete.
Camera-relative stabilized cascades,
outdoor gating, opaque and alpha-cutout casters, terrain/world receivers,
headline caster membership, exact current animated transforms, bounded
resident replay, GPU-flight ownership, and cached topology/dynamic-transform
refresh were present at the sun-only checkpoint. The approved authored
sun/dominant-moon/secondary-moon resolver and direction-versus-energy handoff
are now present, and focused plus complete fresh-process automated validation
pass. The project owner's 2026-08-22 live round accepted source alignment,
temporal stability, desktop responsiveness, and the final exposure correction.
Per-class diagnostics cover
terrain commands, outdoor
statics, buildings, animated statics, local/remote players, non-player
creatures, other live dynamics, and equipped children without inventing tree
or hostile-monster identity. Fixed-camera morning/afternoon artifacts are not
moon evidence; the live-gate result and its exact boundary are recorded in the
[Stage 1 live-gate report](../research/2026-08-22-atmospheric-stage1-live-gate.md).
The machine-local Stage 2 connected and closeout matrix passes. The external
second-client, additional-GPU, and final owner rows above remain open.
This is the campaign's headline slice. Add camera-relative cascades, opaque
and alpha-cutout caster variants, animated SSBO transforms, shadow receivers,
texel stabilization, outdoor gating, and weather/authored-directional-energy
control. Select the exact rendered direction of the visible above-horizon sun,
dominant haloed moon, or secondary moon according to the
[celestial source contract](../research/2026-08-22-dereth-celestial-shadow-sources.md),
while retaining retail's one `DirColor * DirBright` energy channel. Trees,
monsters, players, houses/buildings, terrain, and ordinary outdoor statics
participate through existing scene ownership.
**Acceptance:** in fixed-camera and live dawn/noon/dusk/night captures, shadows
align with and change direction/length under Dereth's authored sun and selected
dominant/secondary moon; overlap and no-source transitions are stable and do
not snap to an unrelated body. Walking players and monsters cast and
self-shadow from their current animated poses; foliage casts leaf/branch
cutouts rather than rectangles; houses and procedural trees retain shadows
through landblock publication/demotion without popping outside the chosen
cascade transition tolerance; indoor/dungeon captures have no outdoor
celestial directional shadow; portal/reconnect/device recreation leaves zero
stale maps or owners. Acne, Peter-panning, cascade seams, distant depth-bias
leaks, temporal pixelation/shimmer, and desktop performance pass the live user
gate and subsequent Stage 2 matrix; the dense-Arwic CPU submission and GPU
budgets pass.
### Slice 4 — Quality scaling and automatic compatibility
**Implementation:** complete. Low/Medium/High declarations, capability and
memory admission, preset cost summaries, retained Display controls, the
built-in Automatic checkbox, hysteretic Auto, diagnostics, asynchronous
off-side candidate preparation, and atomic stable-boundary swaps are present
and automated. The source-identical clean-snapshot RX 9070 XT matrix passes all
30 current rows after the Low dense-pose CPU optimization. The integrated-AMD
physical row proves that persistently over-budget Low returns Auto atomically to
retail with a visible reason and a paired-retail framebuffer match. Active Low
on that adapter and additional supported-adapter matrices remain open.
Land the Low/Medium/High presets, memory ceilings, capability-based preset
availability, stable cascade fitting, resize handling, and optional hysteretic
Auto selection. Auto may change resolution/range only at a stable frame
boundary and must expose its current choice.
**Acceptance:** every supported preset retains all headline caster classes;
weak-hardware fixtures select a valid lower preset or fail safely to acdream's
default renderer;
changing preset cannot leak, stall the render thread, invalidate streaming, or
leave mixed-resolution resources; the quality/performance table is populated
with measured physical-hardware results.
### Slice 5 — Tier-2+ volumetric shafts
**Implementation:** complete for the recorded sun-only scope. The declared
volumetric pass reuses directional-shadow depth only when the selected source
is the authored sun, consumes authored sun/weather/indoor inputs, composites
before tonemapping, and has independent quality/step settings and automated
failure gates. The reference-GPU low-sun 2,048-sample enabled/neutral A/B passes
its incremental cost target. Moon selection does not enable moon shafts.
Connected weather/lifecycle transitions pass; physical-display
occluder/weather acceptance in the final owner matrix and additional physical
GPU classes remain open.
Reuse the directional shadow map for world-space light shafts only while its
source is the authored sun. Drive density, strength, and colour from authored
sun/weather inputs and composite before tonemapping. A selected moon produces
directional shadows but no rays or volumetric shafts.
**Acceptance:** shafts respect terrain, trees, houses, and moving creatures;
clear low sun is strongest, overcast and indoor scenes are muted/off; disabling
shafts leaves Tier-2 shadow output unchanged; the incremental cost stays within
the Tier-2+ budget.
### Slice 6 — Pack SDK and campaign closeout
**Implementation:** SDK deliverables complete and the Stage 1 project-owner
gate passed on 2026-08-22. The v1
manifest schema, semantic binding table, compatibility/failure guide,
validator, built-in pack, and three buildable external samples are present and
automated. Connected graphical package lifecycle and long-run convergence pass.
The distinct-account second-client remote-player row, remaining physical GPU
classes, and final project-owner acceptance remain open; the 30-row RX 9070 XT
reference matrix is complete.
Publish the manifest/schema, semantic binding table, sample no-op pack,
Atmospheric pack, compatibility diagnostics, authoring/validation tool, and
failure-handling guidance. Run the full automated, connected, physical-display,
performance, lifetime, portal, and screenshot matrix.
**Acceptance:** a clean external sample builds without App or Vulkan
references; install/select/update/remove/fail/recover flows work; pack-off
evidence from acdream's default retail-faithful renderer remains authoritative
and unchanged; all resource ledgers
converge after long play, reconnect, portal travel, pack disable, and device
recreation; the project owner accepts the visual matrix before the campaign is
declared shipped.
## Performance budget and measurement
The [measured pre-campaign baseline](../research/2026-08-21-terrain-and-atmospheric-rendering-findings.md#performance-baseline-and-the-binding-constraint)
is **519.7 FPS with CPU/GPU p50 of 1.869/1.096 ms**, and dense towns are
CPU-submission-bound. Fullscreen work may occupy currently idle GPU time, but
it is not treated as free. Shadow cascades must protect the CPU submission
path.
| Preset | Incremental GPU p50 / p99 at 1080p | Incremental render-CPU p50 / p99 | Pack-owned resident GPU memory **at 1080p** (scales with pixel count — `RenderPackResidentBudget.Effective`, #425) |
|---|---:|---:|---:|
| Low | ≤ 2.0 / 3.0 ms | ≤ 0.15 / 0.50 ms | ≤ 64 MiB |
| Medium | ≤ 3.25 / 4.50 ms | ≤ 0.25 / 0.75 ms | ≤ 128 MiB |
| High | ≤ 4.50 / 6.00 ms | ≤ 0.35 / 1.00 ms | ≤ 256 MiB |
The current physical reference matrix is
`artifacts/atmospheric-rendering/matrix-clean-snapshot-dense-linear-v20/` on an
AMD Radeon RX 9070 XT (Vulkan 1.4.349, driver 2.0.395). The source-identical
isolated commit and Release binary both identify `4876c970`; tracked source
status is empty. All 30 rows pass. At capped 1080p, the exact 2,048-sample
windows are:
| Selection | Incremental render-CPU p50 / p99 | Inclusive GPU p50 / p99 | Resident pack memory |
|---|---:|---:|---:|
| Low | 0.108 / 0.164 ms | 0.900 / 1.002 ms | 39.719 MiB |
| Medium | 0.116 / 0.144 ms | 1.001 / 1.023 ms | 73.590 MiB |
| High | 0.121 / 0.145 ms | 1.240 / 1.273 ms | 113.556 MiB |
| Auto (settled High) | 0.122 / 0.146 ms | 1.211 / 1.222 ms | 113.556 MiB |
Every uncapped active row also passes. Across capped and uncapped active rows,
the maximum measured p50/p99 and resident memory are: Low 0.108/0.164 ms CPU,
1.070/1.080 ms GPU, 60.868 MiB; Medium 0.117/0.194 ms CPU, 1.152/1.163 ms GPU,
103.583 MiB; High 0.121/0.159 ms CPU, 2.237/2.252 ms GPU, 238.141 MiB; and Auto
0.122/0.166 ms CPU, 1.482/1.494 ms GPU, 145.856 MiB. All six retail rows record
zero pack resources/work. At 4K, Low needs 131,302,400 bytes and Medium/initial
Auto need 198,440,960 bytes, so those six capped/uncapped rows correctly report
`ResourceUnavailable`, create no pack resources/work, and pass their strict
paired-default framebuffer comparisons. This closes the current reference
adapter, not the connected receiver A/B route or other supported/weak physical
GPU classes.
Tier 2+'s separate low-sun physical A/B is recorded in
`artifacts/atmospheric-rendering/volumetric-performance-ab-1080p.json`. Both
runs pin High, 1080p uncapped, clear weather, 16.667° sun elevation, the same
50 m / 180° / 10° camera, 9,498 casters, four cascades, and exact 2,048-sample
windows. Enabling one volumetric draw over the neutral-strength run adds
**0.189 ms GPU p50 / 0.219 ms p99**, **0.009 ms CPU p50 / 0.007 ms p99**, and
4,147,200 resident bytes. The measured GPU p50 passes the Tier-2+ ≤0.40 ms
reference target; connected weather/occluder behavior and other adapters still
require their own rows.
acdream's default path has a stricter gate: zero new enhancement passes,
images, buffers, submissions, or shader variants, with CPU/GPU deltas within
the existing run-to-run noise envelope and deterministic reference captures
unchanged. `NoOpRenderPackProductionIntegrationTests` pins the pass list,
pipeline set, draw/dispatch tuple, framebuffer SHA-256, and resource ledger of a
**2x2 synthetic, one-draw composition fixture on a recording device that does
not rasterize** — it proves the controller arm adds nothing to that shape, not
that the production frame is unchanged. The matrix's six physical retail rows
record zero pack work at all three resolutions and both pacing modes. The
actual pack-off pixel and production-performance invariance against the
pre-campaign build `6c79d35c` was established later by Campaign VM slice VM0
([report](../research/2026-08-22-vm0-default-path-invariance.md), corrected
2026-08-22).
Measurement protocol:
- Use existing asynchronous GPU timestamps and frame diagnostics. Never add a
`glFinish`/device-idle-style measurement fence to the frame loop.
- Runtime Auto compares the declared incremental CPU budget with pack-added
target-preparation, shadow, post, and volumetric recording only. The complete
enhanced main-world receiver recording is retained separately as an absolute
CPU diagnostic; it is not itself an incremental delta. GPU accounting remains
conservatively inclusive of the complete enhanced receiver pass and every
resolved pack pass, exactly once after asynchronous resolution. Identical
pack-off/on runs remain the authority for the final receiver CPU delta and
the complete physical incremental A/B result.
- Run capped and uncapped Release builds; record CPU/GPU p50, p95, and p99,
FPS, draw/dispatch submissions, shadow-caster count, cascade draw count,
transient/retained GPU bytes, and process working/private memory.
- Compare pack off, Low, Medium, and High with identical camera paths, render
resolution, active day group, authored celestial/time keyframe, entity set,
and warmed residency.
- Cover pinned dense Arwic, a foliage-heavy outdoor route, a building cluster,
moving-player/monster combat, dawn/noon/dusk/night plus sun/moon/no-source
transitions, clear/overcast/rain, a dungeon, portal travel, resize, reconnect,
and a long lifetime run.
- Measure 1920x1080, 2560x1440, and 3840x2160 on each supported physical GPU
class. Report—not hide—unavailable presets.
- No cascade may rerun CPU PView/portal traversal or issue per-object draws.
The pass records CPU classification calls and submission counts so this is
an enforced gate, not an architectural hope.
- Pipeline creation and pack validation occur before atomic activation. Normal
play may not hitch on first shadow, weather, caster, or quality use.
## Quality scaling for weak hardware
These are starting envelopes to validate, not asset or world guarantees.
Distances are metres and are always clamped to current resident world data.
| Setting | Bloom/rays | Directional shadows | Volumetric shafts | Approx. depth-map memory at 32-bit depth |
|---|---|---|---|---:|
| Off / acdream default | Off | Off | Off | 0 MiB |
| Low | Quarter resolution | 2 × 768² cascades, about 72 m maximum reach | Off by default | 4.5 MiB |
| Medium | Half resolution | 3 × 1536² cascades, about 144 m maximum reach | Quarter resolution | 27 MiB |
| High | Half/full as measured | 4 × 2048² cascades, about 240 m maximum reach | Half resolution | 64 MiB |
Additional scaling rules:
- Prefer reducing cascade count, shadow resolution, reach, bloom/ray
resolution, and sample count before removing a feature's semantic
correctness.
- Preserve alpha-tested foliage and animated transforms at every shadow
quality. A cheaper preset may look softer or end sooner; it may not turn a
tree into a rectangle or freeze a monster's shadow.
- Clamp resource dimensions and bytes before allocation. A capability probe
that cannot support Low disables the pack and explains why.
- Preset availability uses the selected Vulkan adapter's probed 2-D image and
array-layer limits. Optional pack memory receives at most one eighth of its
device-local heap, capped at 256 MiB resident and 512 MiB transient; Auto
starts at Low when Medium is unavailable and acdream's default remains the fallback if
Low cannot fit.
- Optional Auto quality uses long hysteresis and stable frame-boundary swaps;
it never oscillates cascade layouts frame to frame. If Low stays over its
declared runtime GPU/CPU/resident budgets for 180 stable samples, Auto
retires the complete pack and returns to acdream's default with the measured
and declared limits in the visible failure reason.
- 4K defaults may choose lower post-process resolution because Tier 1 pays
approximately four times the 1080p pixel workload.
## Constraints and traps
This list carries forward every item in the findings' measured
[shadow-specific constraints](../research/2026-08-21-terrain-and-atmospheric-rendering-findings.md#shadow-specific-constraints)
and adds the current renderer's ownership and lifecycle boundaries.
1. **The renderer is the shipped pass-based Vulkan RHI.** Design against
`IGpuDevice` / `IGpuFrame` / `IGpuPassEncoder` and explicit pass/pipeline
descriptions. Do not revive an OpenGL backend or build a parallel renderer.
2. **The current PView graph is authoritative.** Shadow and atmosphere passes
consume its retained scene; they do not introduce a competing visibility
owner or change punch/seal, shared-alpha, particle, or private-viewport
ordering.
3. **CPU submission is the limiting dimension.** Reusing the full bounded
resident caster set is preferable to CPU-reculling it per cascade. GPU
culling is the only planned escalation.
4. **Alpha-tested foliage needs sampling and discard.** Reusing the existing
empty `portal_depth` fragment shader would cast solid tree rectangles.
5. **Animated casters use the existing N.5 SSBO transforms.** A second pose,
animation tick, or entity owner is forbidden.
6. **Indoors has no outdoor celestial directional shadow.** Dungeon/EnvCell
authored ambient and local lighting wins; outdoor sun/moon directional
shadows, sun rays, and sun shafts are gated off.
7. **Cascades are camera-relative and streaming-bounded.** They may not use a
fixed Dereth-wide extent, request landblocks, retain retired generations, or
draw stale portal destinations.
8. **Depth bias is specified in meaningful eye/world units.** A constant NDC
bias spans approximately `b*d²/near` metres of eye depth at distance and can
recreate issue #129's door-shaped holes through hills. Bias, normal offset,
cascade projection, near/far fitting, and reversed-depth conventions must be
tested together at near and far ranges.
9. **“Shadow” is an overloaded project term.** Existing `shadow_objects` and
`CPhysicsObj::add_shadows_to_cells` are collision registration, not light
shadows. New names use `DirectionalShadowMap`, `ShadowCaster`, or
`CelestialDirectionalShadow`; never generic `ShadowObject`.
10. **Authored celestial position, directional energy, and weather are
inputs.** Do not invent another celestial clock, light-energy channel,
weather state, or hard-coded dawn/noon schedule. The directional map uses
the visible above-horizon sun/dominant moon/secondary moon's exact rendered
direction but retail's single interpolated `DirColor * DirBright` colour/
energy channel, per the
[celestial source research](../research/2026-08-22-dereth-celestial-shadow-sources.md).
Rays and shafts remain sun-only and use the pack's declared sun-elevation
curve and categorical `activeDayGroup` mapping. The decomp evidence proves
the category reaches the frame, not an authored numeric ray intensity, so
the enhancement mapping must remain explicit pack policy.
11. **Atmosphere ordering is deliberate.** Rays/shafts composite before
tonemapping; retained UI and private viewports remain outside main-world
post-processing.
12. **Transparency remains ordered.** The pack cannot flatten the retail
world-alpha queue into an unordered shadow/post pass. Truly translucent
surfaces cast no opaque shadow until separately designed.
13. **Generation and GPU-flight lifetimes remain exact.** Pack images,
descriptors, and pipelines retire through existing fences and converge on
disable, resize, portal, reconnect, reset, failure, and device recreation.
14. **4K is a distinct performance row.** Tier-1 effects scale with pixels;
passing at 1080p is not evidence for 4K.
15. **Do not repeat closed investigations.** High-res DAT precedence is not
dropping overrides, AC detail textures are colour/alpha rather than normal
maps, and the engine's historical DOT3 capability does not turn those
assets into PBR inputs; these points are already falsified in the findings.
16. **Caster evidence must not exceed source identity.** Diagnostics separately
count terrain commands, outdoor statics, buildings, animated statics,
local/remote players, non-player creatures, other live dynamics, and
equipped children. Outdoor statics include trees but have no authoritative
tree discriminator; non-player creatures include monsters but have no
render-only hostile-monster-versus-NPC discriminator. Visual/connected
acceptance must name those limits instead of fabricating narrower counts.
## What this does NOT do
- It does **not** change acdream's default retail-faithful rendering path, its
expected output, or its authority in fidelity tests.
- The Atmospheric shader pack does **not** own or depend on #226 detail
texturing or the terrain-normal parity correction. Those remain separate
Track A ports even though the project owner authorized their implementation
in the same worktree.
- It does **not** add PBR or fabricate normal, roughness, metalness, or material
maps that AC's assets do not contain.
- It does **not** change terrain vertices, collision triangles, walkability,
slope response, physics shadow lists, movement, projectiles, or any Runtime
physics/collision owner.
- It does **not** change gameplay rules/state, network messages or ordering, or
any Runtime gameplay/network owner.
- It does **not** extend view distance, streaming radius, landblock residency,
or PView visibility to find more shadow casters.
- It does **not** add an indoor sun or replace authored EnvCell/local lights.
- It does **not** turn moon texture brightness or mesh luminosity into another
world-light energy channel, and it does not produce moon rays or moon shafts.
- It does **not** post-process retained UI or silently restyle private
paperdoll, appraisal, or portal viewports.
- It does **not** promise that every pack or quality preset runs on unsupported
hardware; compatibility failure is explicit and safely returns to acdream's default renderer.
- Campaign AR does **not** outrank active M4 gameplay work. The project owner's
explicit reprioritization authorizes this campaign without changing M4's
milestone priority; #268 + TS-8 are already complete and retired.
## Completion gate
The requirement ledger and the exact remaining external rows are consolidated
in the [Campaign AR completion audit](../research/2026-08-22-atmospheric-campaign-completion-audit.md).
The design, implementation, Stage 1 owner gate, and every available
machine-local Stage 2 connected, performance, lifetime, package, shader, build,
and complete-test gate are complete. The campaign becomes **shipped** only
after the distinct-account second-client remote-player row, remaining physical-
hardware rows, and final visual gates above pass and the project owner accepts
both sides of the final matrix:
- **pack off:** unchanged output, performance, ownership, and lifecycle from
acdream's authoritative default retail-faithful renderer; and
- **pack on:** moving authored sun-and-moon directional shadows from trees,
monsters, players, and buildings; sun-only rays/shafts; scalable atmosphere,
safe compatibility fallback, measured budgets, and clean long-lived resource
convergence.

View file

@ -0,0 +1,199 @@
# Campaign CT — complete chat parity (system + GUI)
**Status:** Groups A, B, C and D COMPLETE 2026-08-21, each user-gated.
CT-B4 landed 2026-08-21 after the research block turned out to rest on a wrong
premise (see the slice). One item deliberately not shipped: CT-B3 (word
filtering — dropped by user direction, register row CT-2).
**Carried forward:** ~~multi-frame state media~~ **DONE 2026-08-21.** The
importer now keeps the whole authored sequence and `UiMediaSequence` plays it.
Measuring the real data (`LayoutDump --media 0x1000048C`) corrected the
behaviour as well as enabling it: the indicator blinks three times over three
seconds and then hands off to `Ghosted`, hiding itself. Retail's is a transient
attention-flash, not a badge that stays lit until you scroll down. Register
rows CT-3, CT-4.
**Goal, set by the user 2026-08-21: complete retail parity for the chat
system AND the chat GUI.** Not "fix the green name" — that was the symptom
that started the review. The bar is that a retail player sitting down in front
of acdream's chat window finds nothing missing and nothing behaving
differently.
Campaign CH (2026-08-09, closed user-accepted) landed colours, side channels,
the 152-verb command registry, the window shell and verbatim `/help`. CT is
the pass that closes what CH did not reach.
Research notes (all 2026-08-21): `chat-texttag-model.md`,
`chat-tagged-name-composition.md`, `chat-tag-click-dispatch.md`,
`retail-chat-window-ui.md`, `acdream-text-stack-audit.md`,
`acdream-chat-ui-audit.md`.
## Definition of done
1. Every retail chat behaviour is either implemented, or has a divergence-
register row saying why not.
2. Every user-visible chat surface has a test that would catch its regression.
3. The chat digest and `docs/ISSUES.md` describe reality (both are stale today).
## What the review established
### The green clickable name is a TEXT-STACK gap, not a chat gap
The client sprintfs literal markup into the line —
`<Tell:IIDString:{iid}:{name}>{name}<\Tell> says, "{text}"`, closing marker a
literal backslash — and `UIElement_Text::InqGlyphs @0x00468EA0` parses the
brackets while appending, calling `TextTagFactory::MakeTag @0x00478480`. Tags
attach **per glyph**; a "run" is emergent (adjacent glyphs with equal tag
pointers). A glyph takes the tag colour (property `0x1D`) only when a tag is
open AND its `m_type == 0x10000001`, else the line colour (`0x1B`). Only
senders with a GUID in `0x50000001..0x6FFFFFFF` are tagged.
Colour **measured** from the installed dats (`LayoutDump --colors`), chat
`0x2100006F` / transcript `0x10000011`: `P0x1B` = RGB(204,204,204),
`P0x1D` = **RGB(0,178,0)**. The tag colour is per-ELEMENT and authored, while
the line colour on that same element comes from the runtime chat table —
filing "tag green" into the LogTextType table would put it in the wrong place.
Click: `UIElement_Text::MouseUp @0x004694F0` → `DeterminePositionFromXY
@0x004688F0` → `GlyphList::InqGlyph @0x00473430` → virtual `HandleClick` at
tag-vtable `+0x14` → `gmMainChatUI::RecvNotice_TextTag_IIDStringClick
@0x004CCE10` → `ChatInterface::StartTell @0x004F41F0`, which writes
`"@tell {Name}, "`, takes focus, and shows the entry bar. Clicking a name
always opens a TELL — fellowship, allegiance, patron/vassal and named-channel
lines all embed the same markup. No hover effect.
### Our side is closer than feared
`UiText` **already** draws multi-coloured runs (`TextRun`/`RunsProvider`, used
by the character stat panel); it is gated to `OneLine == true`. The draw path
needs no renderer work — arbitrary pen X, substring measurement — and
`UiText.HitChar` already resolves a click to (line, column). The blocker is
that `ChatVM.RecentLinesDetailed()` drops `Sender`/`SenderGuid` one step before
the renderer, though `ChatEntry` carries them the whole way.
### The command registry is already at parity
All 13 verbs the CH3 research note lists as MISSING were closed by CH4 and
verified present 2026-08-21 (`cg`, `soc`, `o`, `co-vassals`, `fellows`,
`group`, `party`, `vassal`, `ab`, `guild`, `ct`, `clfg`, `crp`). `/g` correctly
resolves to Fellowship, confirmed against the live retail client. That note's
"acdream status" columns are stale and now carry a correction banner.
## Slices
### Group A — the tagged-text capability (strict chain, A1→A5)
Nothing is user-visible until A4.
- **CT-A1** Multi-line text elements carry coloured runs. Additive; the ~50
files using `Line` are untouched. No behaviour change.
- **CT-A2** Parse the tag markup into runs with a tag payload, including
retail's rule that an unparseable bracket closes the open tag. Pure, unit-
testable, no UI.
- **CT-A3** Stop flattening: carry sender name + guid through `ChatVM` into
spans, and compose retail's markup in the speech handlers behind the
player-GUID-range gate.
- **CT-A4** Apply the authored `0x1D` tag colour when a tag is open and its
type matches. **Names turn green.**
- **CT-A5** Sub-line hit-testing and `StartTell`. **Names become clickable.**
### Group B — chat SYSTEM behaviours
- **CT-B1** Bound the transcript: 10,000 chars, trim to ~7,500 preferring a
newline boundary (`TruncateChatLog @0x004F4290`). Today it grows for the life
of the session — a slow leak, not only a fidelity gap.
- **CT-B2** Text-replacement macros: typing `/r `, `/t `, `/tell ` rewrites the
input to `@tell {LastTeller}, ` on the space keypress
(`HandleTextReplacements @0x004F50D0`). The commands already work; the
visible expansion does not exist.
- **CT-B3** ~~`FilterLanguage` word filtering~~ — **DROPPED by user direction
(2026-08-21): "I do not want any censoring."** acdream keeps the option
itself, which still stores and ships its bit to the server exactly as retail
does, but performs no client-side substitution. Registered as CT-2 rather
than left as an implicit gap, since it IS a knowing departure from retail
(`PlayerModule::FilterLanguage` + `TabooTableAdaptor::CheckCensorsW
@0x00682A30` inside `AddTextToScroll`).
Worth keeping on record, because the attempt established two things that
would otherwise be rediscovered if this is ever revisited:
1. The table's dat id is not readable from the decomp — `CheckCensorsW`
reaches it through `DBObj::GetByEnum` with the arguments elided by Binary
Ninja. The portal master enum map (`0x25000000`) has 22 categories, with
category 3 (`0x0E010001`, `0x0E010002`) and the single-entry categories 8
and 11 the plausible candidates.
2. Chorizite.DatReaderWriter declares a `TabooTable` type but does NOT decode
it — only `DBObjType` and `HeaderFlags`. The format would have to be
decoded here first.
And the matching is an algorithm, not a word list:
`TabooTable::CreateCheckString @0x00681570` normalises a candidate before
`StringMatchesFilter @0x00681600` compares it, which is how retail catches
obfuscated spellings.
- **CT-B4** ~~The plain-text session chat log~~ **DONE — and the premise was
wrong.** There is no automatic session log to have a path for. Retail's
`@log` is a COMMAND: `ClientCommunicationSystem::DoSetOutput @0x0057E4F0`
takes a filename, `StartCopyOutputToFile @0x0057C8A0` does the
`fopen(name, "a+")`, and running it again with no argument closes it. So
"path and rotation UNKNOWN" was asking a question the design does not have:
the player names the file, and there is no rotation because it appends
forever.
The path question that DOES exist — where a bare name lands — is answered by
retail's own help text, which the CH4 help table already carried verbatim
without anyone reading it: "a log file named Aclog.txt **in your Asheron's
Call directory**". acdream cannot use the install directory (the launcher
replaces it atomically on update), so a bare name lands in the client's own
log directory. Rooted paths are honoured verbatim. Register row CT-5.
Landed with it: the verb registered in the catalog (it had a help entry
since CH4 but no catalog entry, so `/log` printed help and did nothing),
retail's `.txt`-for-extensionless rule, all five reply strings byte-decoded
from the paired binary, and the writer attached at OPEN so only text after
the command is copied. The line logged is the composed display line with the
shared timestamp, because retail's `fprintf` sits inside `AddTextToScroll`
— downstream of composition, upstream of glyph layout.
### Group C — chat GUI
- **CT-C1** Auto-scroll vs unread: retail samples "was at bottom" BEFORE the
line lands; if you had scrolled up it leaves you there and lights the unread
indicator (`0x1000048C`), which scrolls to bottom and clears on click.
- **CT-C2** Escape in the chat input is a complete no-op — `UiField` has no
`Escape` case, and a focused field also suppresses the input dispatcher's
fallback, so there is no clear, no defocus and no hotkey passthrough.
- **CT-C3** Option-gated timestamp prefix (`%#H:%M:%S `, colour index `0x0C`,
grey), gated on `PlayerModule::DisplayTimeStamps()`.
- **CT-C4** Input-bar editing parity: clipboard and selection paths
(Ctrl+C/X/V, shift-selection) work but are untested; `ToggleMaximize` and the
floating-window Close button have zero coverage. **DEFERRED** — pure test
coverage over behaviour the audit confirmed already works, so it changes
nothing a user can see. Worth doing; not worth blocking the campaign on.
### Group D — hygiene
- **CT-D1** ~~Delete the dead ImGui-era `ChatPanel`~~ **DONE.** Verified never
constructed in `src/`, then removed with its three panel-only test files.
`ChatVMCombatTests` was KEPT — three of its four tests are real `ChatVM`
coverage; only the one `ChatPanel` render test went.
- **CT-D2** ~~Reconcile the chat digest and `docs/ISSUES.md`~~ **DONE.**
`docs/ISSUES.md` turned out to be ACCURATE already — #358 and #363 are
recorded CLOSED there. Only the chat digest's "Open" section was stale, and
it is corrected: genuinely open are #359, #360, #361, #366. The digest also
gained a Campaign CT section and three new DO-NOT-RETRY rows.
## Research still owed before the affected slices
- The tag-type roster behind `m_type == 0x10000001` — only "Tell" is
confirmed; the full set lives in the DAT `EnumMapper` category `0x18`.
Blocks nothing in Group A, but decides whether other tag shapes exist.
- Whether retail's transcript supports text selection distinctly from the
entry field (blocks CT-C4's scope).
- Whether a chat-specific sound cue exists — a grep came back empty, which is
weak evidence, not proof of absence.
## Deliberately NOT in scope
Item links and the other three tag shapes (`DID`, `IID`, `IIDEnum`). They have
no listener in the retail build we target, so porting them would be inventing
behaviour. CT-A5's dispatch is generic, so they cost nothing to add later.

View file

@ -0,0 +1,200 @@
# Campaign QT — the contract tracker (H.3's client half)
**Status:** CLOSED USER-ACCEPTED 2026-08-21. All six slices landed and the
connected gate passed, together with Campaign QJ's two tabs.
**Why now.** M4's demo scenario is "talk to an NPC, accept a quest, ... complete
the quest." Everything in that sentence works today EXCEPT the player's ability
to see what they have accepted. NPC dialogue, emote text, soul emotes, tells and
the quest-failure strings all render; Campaign CT (closed 2026-08-21) added the
`<Tell:IIDString:…>` markup those dialog lines carry. What is missing is the
only STRUCTURED view of quest state a retail client ever gets.
**What H.3 is not.** The roadmap line reads "122 EmoteType × 39 Trigger
mini-VM", which describes the SERVER's job. Per `r10-quest-dialogs.md` §1.3 the
retail client never stores a quest flag, never evaluates an emote, and is never
told a flag changed. It learns about quests three ways: dialog strings the
server already formatted, generic error toasts, and the contract tracker. Two of
the three ship. So H.3's remaining client scope is this campaign, and the emote
VM is explicitly out of it.
## Measured ground truth
### The panel
`LayoutDump --find 0x1000004B` (the `UIElement::RegisterElementClass` id from
`gmContractsUI::Register @0x00499C80` — registration keys on the element's
**Type**, not its id) finds the class in six layouts. `0x21000069` holds it as a
standalone 300x500 root (`0x100005CD`); the rest embed it at 300x575 inside
window chrome.
Authored children of `0x100005CD`:
| Element | Type | Rect | Reading |
|---|---|---|---|
| `0x100005CE` | 1 | 8,8 80x18 | header button |
| `0x100005D6` | 1 | 160,8 80x18 | header button |
| `0x100005CF` | 5 | 8,30 270x298 | the contract list |
| `0x100005D0` | 11 | 278,30 16x298 | its scrollbar |
| `0x100005D8`/`0x100005DF` | 12 | y=332 | label / value |
| `0x100005D9`/`0x100005E0` | 12 | y=352 | label / value |
| `0x100005DA`/`0x100005E1` | 12 | y=372 | label / value |
| `0x100005DB`/`0x100005E2` | 12 | y=392 | label / value |
| `0x100005DE` | 12 | 8,418 270x52 | description block |
| `0x100005DD`, `0x100005E3`, `0x100005DC` | 12/12/1 | y=468 | button row |
### The wire
Both opcodes are already NAMED in `GameEventType.cs` and nothing parses them —
the bytes arrive and are dropped.
`0x0315 SendClientContractTracker` — one tracker plus two flags:
```
uint32 Version
uint32 ContractId
uint32 Stage
double TimeWhenDone
double TimeWhenRepeats
uint32 DeleteContract (bool widened)
uint32 SetAsDisplayContract (bool widened)
```
`0x0314 SendClientContractTrackerTable` — a full replacement, as a packable
hash table (`PackableHashTable<unsigned long, CContractTracker>` in the decomp
at `0x00497C10`): the familiar `u16 count` / `u16 numBuckets` header, then
`u32 key` + the 28-byte tracker per entry. NO trailing flags on this path.
Source: `ContractTrackerExtensions.Write`, `GameEventSendClientContractTracker`,
`ContractManager.Write` in ACE; cross-checked against the retail decomp's own
`PackableHashTable<unsigned long,CContractTracker>` instantiations.
`ContractStage`: `1` Available, `2` InProgress, `3` DoneOrPendingRepeat,
`4 + n` ProgressCounter with n steps done.
### `gmContractsUI::FillProgressString @0x00498DE0` — the one real algorithm
Recovered whole. The x87 compares are the standard `fcom` + `sahf` pattern;
`(status & 0x41) != 0` tests C0|C3, i.e. **<= 0**.
```
stage 1 -> "Available"
stage 2 -> "In Progress"
stage 3:
if TimeWhenRepeats <= 0
-> QuestflagRepeatTime empty ? "Done" : "Available"
remaining = TimeWhenRepeats - (now - timeOfServerUpdate)
if remaining <= 0 -> "Available"
else -> "Done (" + DeltaTimeToString(remaining) + " to Repeat)"
stage >= 4:
if DescriptionProgress empty -> "In Progress"
else -> sprintf(DescriptionProgress, stage - 4)
```
Three things a reimplementation would get wrong:
1. **`TimeWhenDone` is never read.** Only `TimeWhenRepeats` drives the text.
2. **`timeOfServerUpdate` is not on the wire.** The client stamps arrival and
counts down from its own clock, so the countdown has to be anchored at parse
time, not recomputed from the server value each frame.
3. **`DescriptionProgress` is a printf format** taking one integer, `stage - 4`.
It is not a literal string.
### It is not a "contract panel" — it is tab 1 of the JOURNAL panel
Measured from the installed dats. Host `0x2100006E`, `gmPanelUI` slot
`0x10000559`, whose own authored `0x10000029` is **`0x19` = 25** — the same
slot-key recipe `RetailPanelCatalog` already uses for Options (10), the social
panel (12) and Map/House (16). Three tabs:
| Tab | Caption | Page | Page type |
|---|---|---|---|
| `0x100005D3` | **Contracts** | `0x100005D4` | `0x1000004B` = `gmContractsUI` |
| `0x10000560` | **Journal** | `0x10000563` | `0x10000048` — notes: "Title:", "Notes:", "First" |
| `0x10000561` | **Page List** | — | — |
`0x10000562` (type 1, at 276,0) is the panel's own corner button.
Only the Contracts tab is in scope. The Journal notes page and Page List are
their own feature and are NOT part of Campaign QT — mounting the panel with two
dead tabs is the expected intermediate state, not a defect.
### The contracts page, resolved
Authored text read out of the dats (`LayoutDump --props`, which now resolves
`StringInfo` rather than printing the type name):
| Element | Role |
|---|---|
| `0x100005CE` / `0x100005D6` | list column headers — "Contract" / "Status" |
| `0x100005CF` (type 5) | the list, scrollbar `0x100005D0` via property `0x72` |
| `0x100005D1` / `0x100005D2` | per-ROW children: contract name / progress text |
| `0x100005D8``0x100005DF` | "Status:" → value |
| `0x100005D9``0x100005E0` | "Contact:" → value |
| `0x100005DA``0x100005E1` | "Contact Location:" → value |
| `0x100005DB``0x100005E2` | "Quest Location:" → value |
| `0x100005DE` | description block (270x52, wrapping) |
| `0x100005DD``0x100005E3` | "Timed:" → value |
| `0x100005DC` | "Abandon" button |
`gmContractsUI::RefreshContractListbox @0x00499830` walks the tracker list and,
per row, sets `0x100005D1` from the contract's name and `0x100005D2` from
`FillProgressString`, caching the result back into the row. The list is a
`UiTemplateListBox` here — the same widget OP2 built for the Options panel — so
the page is binding rather than new widget work.
## Slices
- **QT1 — wire.** Typed records + parsers for `0x0314`/`0x0315`, arrival stamp
included. Pure; no UI, no state ownership.
- **QT2 — dat.** Read `ContractTable`/`Contract` (name, description, progress
description, NPC names, the three positions). Nothing reads it today; the
only reference in the tree counts them in a CLI diagnostic.
- **QT3 — state.** `RuntimeContractState` as a session-scoped J4-style owner:
full replace, single add/update, delete, and the display-contract selection.
Clears at generation reset.
- **QT4 — the progress string.** Port `FillProgressString` + the retail
`DeltaTimeToString` it calls. Table-driven tests over every stage arm.
- **QT5 — the panel.** Register slot 25 in `RetailPanelCatalog`, mount the
Journal panel by the OP3/FA recipe, and bind the Contracts page: rows from
`IRuntimeContractView` x `ContractCatalog`, progress from QT4, selection
driving the detail pane. The other two tabs mount empty.
- **QT6 — open/close.** The open path (no toolbar button authors slot 25, so
it is keyboard or menu — to be measured the way FA's F3/F4 was), plus the
plugin-visible read surface from `r10-quest-dialogs.md` §11.6.
### Landed
QT1 `ab3934e2` (wire), QT3 `f629ce7f` (state + routing), QT2/QT4 `ef6b7310`
(catalog + progress string), QT5/QT6 (the panel and its open path).
**The open path needed no new keybind.** Toolbar button `0x1000055A` authors
`0x10000029 = 0x19` and has been in `ToolbarController.PanelButtonIds` since
the toolbar was ported — it simply had no panel registered behind it, so
clicking it did nothing. Registering slot 25 completed a wiring that was
already three-quarters present.
**The plugin surface** (`r10-quest-dialogs.md` §11.6's contract half) ships as
`IGameState.Contracts`, projected through `ContractPluginProjection` — a
pull-through view of the canonical tracker, never a mirror. Both hosts
implement it; the headless one carries the numeric fields without the authored
text, since a bot has no dat access. The rest of §11.6 (chat stream, tells,
give, use, confirmations) is other features and stays out of Campaign QT.
### Owed
- The connected user gate: accept a quest against live ACE, open the Journal
panel, confirm the list, the progress column and a repeat countdown.
- ~~The Abandon button is deliberately unwired~~ **WIRED 2026-08-21.** The
claim that it had no wire message was wrong: it is game action `0x0316`
carrying one contract id, and ACE answers with the `0x0315` delete QT3
already handles. Nothing is removed locally, so a refused abandon leaves the
quest visibly intact.
- The Journal notes page and Page List tabs mount inert, by design.
## Definition of done
1. Accepting a quest against live ACE shows it in the panel; completing it
updates the stage; a repeatable one shows its countdown.
2. Every ported algorithm cites its retail address.
3. Every slice has a test that would catch its regression.

View file

@ -0,0 +1,121 @@
# Campaign QJ — the Journal and Page List tabs
**Status:** CLOSED USER-ACCEPTED 2026-08-21. All five slices landed and the
connected gate passed.
The gate took four rounds, and every defect it found was the same mistake in a
different place — an element bound as the wrong thing, or a binding never
tested:
1. **Button property `0x0D`** read as "starts disabled", which killed every
button on the panel (register QJ-2).
2. **The location readout** is authored EDITABLE, so it is a `UiField`; bound
as `UiText` it silently discarded every write — the value reached the model
and the file and never the screen.
3. **Handlers deferred their redraw** to the next frame's `Tick` where retail
redraws at the click.
4. **The timer's unit labels** stayed visible behind the running readout,
because retail's `ShowEditableTimer` toggles each box AND its label.
Round 3's "Record does nothing" turned out not to be a defect at all: the
character was indoors, where retail's own `gid_to_lcoord` fails and nothing is
recorded. Faithful, and now commented so it does not read as a gap.
The durable outcome is `JournalPanelLiveBindTests` — see
`claude-memory/feedback_test_the_binding_seam.md`. Completes the panel Campaign QT mounted: QT
shipped the Contracts tab and left the other two inert by design.
**Scope:** retail's `gmJournalUI` (element type `0x10000048`, page
`0x10000563`) and `gmPageListUI` (type `0x10000049`, page `0x10000564`).
## What this actually is
A **per-character notebook**, entirely client-side. No wire, no server
involvement, no dat content — the player writes the pages. Each page carries a
label, a title, free-form notes, a recorded LOCATION, and a countdown TIMER.
The Page List tab is a searchable index over those pages.
Nothing about it depends on quests; it shares the panel with Contracts and
nothing else. That it is called "Journal" while the panel is also called
"Journal" is retail's own naming, not a mistake here.
## Measured ground truth
### The file format
`gmJournalUI::SavePages @0x00497270` / `LoadPages @0x00496AC0`. A plain tagged
text file, `fopen` mode `w+`. Both call sites pass the literal prefix
`"Journal"`; the path template is `%s%s-%s-%s.txt`, i.e.
`{dir}Journal-{server}-{character}.txt`.
```
<NEWP> begins a page (a file that does not open with one is refused)
<PNUM> %d page number
<LABE> %s label (authored max length 16)
<TITL> %s title (32)
<NOTE> %s notes (2048)
<DAYS> %d timer days
<HOUR> %d timer hours
<MINU> %d timer minutes
<LOCX> %f recorded location
<LOCY> %f
<TIME> %f running-timer value
```
Retail's own load error, byte-decoded: `"Problem loading journal: Your journal
file does not create a new page!"`
### The Journal page (`0x10000563`)
| Element | Role |
|---|---|
| `0x10000567` | "New" button |
| `0x10000569` | label edit box (`0x1E` = 16) |
| `0x1000056A` / `0x1000056B` | "Title:" / title edit box (32) |
| `0x1000056C` / `0x1000056D` | "Notes:" / notes edit box (2048), scrollbar `0x1000056E` |
| `0x1000056F` / `0x10000570` / `0x10000571` | "First" / "~ 1 ~" / "Last" |
| `0x10000572` / `0x10000573` / `0x10000574` | "Location:" / "00.0S, 00.0W" / "Record" |
| `0x10000575` | "Timer:" |
| `0x10000576` `0x10000577` | days field, "d" |
| `0x10000578` `0x10000579` | hours field, "h" |
| `0x1000057A` `0x1000057B` | minutes field, "m" |
| `0x1000057C` | running-timer text — OVERLAPS the three fields at x=84 |
| `0x1000057D` | "Start" button |
| `0x10000566` | bottom-right button (65x32) |
`0x1000057C` sharing x=84 with the day/hour/minute fields is the authored form
of `ShowEditableTimer @0x00495770` vs `ShowRunningTimer`: the same strip is
either three editable numbers or one running readout, never both.
### The Page List page (`0x10000564`)
| Element | Role |
|---|---|
| `0x1000057F` `0x10000580` `0x10000581` `0x10000582` | headers "#" / "Title" / "Timer" / "Label" |
| `0x10000583` | the list, scrollbar `0x10000584` |
| `0x10000585` | "Delete" |
| `0x10000586` / `0x10000587` / `0x10000588` | "Search:" / search box / "Reset" |
`gmPageListUI::PageContainsString @0x00493B60` is the search predicate;
`CheckForDoubleClick @0x00493140` opens the page
(`gmJournalUI::GotoPage @0x00496430`).
## Slices
- **QJ1 — the page model and its file.** `JournalPage` plus a faithful
reader/writer for the tagged format, including retail's refusal of a file
that does not open with `<NEWP>`. Pure; no UI, no state ownership.
- **QJ2 — the owner.** `RuntimeJournalState`: the page collection, the current
page, new/delete/goto, and the timer. Per-character.
- **QJ3 — the Journal page.** Edit boxes, page navigation, Record, and the
editable/running timer swap.
- **QJ4 — the Page List page.** The list, the search, delete, and
double-click-to-open.
- **QJ5 — persistence.** Load on character enter, save on exit, under the
client's own data directory.
## Definition of done
1. A page written in one session is there in the next.
2. Every ported algorithm cites its retail address.
3. Every slice has a test that would catch its regression.

View file

@ -0,0 +1,791 @@
# Campaign VM — VisualMaster
**Date:** 2026-08-22 · **Status:** PLANNED — awaiting the owner's goal
**Phase id:** **Campaign VM** — named by the project owner on 2026-08-22
**Branch:** `claude/git-sync-status-5fb1d2` (= main + Campaign AR, `c51b07ef`)
**Predecessor:** [Campaign AR](2026-08-21-atmospheric-rendering.md) and its
[independent review](../research/2026-08-22-campaign-ar-review.md)
**Scheduling:** owner-directed, like Campaign AR. Does not displace M4.
## Goal
Make Campaign AR **provably safe to merge** and then **finish the look**:
close every review finding with evidence rather than assertion, put the
Tier-1 post stack in linear light so its controls mean what they say, and add
the one cheap, high-impact enhancement still missing from the discussed
scope — **gentle, believable wind in Dereth's foliage**, driven by AC's own
authored weather, opt-in through the same render-pack contract, with the
default retail-faithful path untouched.
Campaign VM is shipped when both sides of the final matrix pass:
- **pack off:** pixel-identical to the pre-campaign build outside the two
intended parity changes (terrain normals, building detail), with production
CPU/GPU/allocation within the pre-campaign noise envelope; and
- **pack on:** linear-light post stack, registered-or-removed detail fade,
moving foliage that the owner judges "real, not a screensaver", shadows
that move with the leaves, and all of it inside Campaign AR's declared
budgets.
## Operating model
The launcher/AR cadence, unchanged:
- **Fable** plans, verifies claims against source/decomp, writes the
closeouts, and drives the work order. Nothing in a closeout may claim more
than its artifact shows — every "passes" names the command and the lane.
- **Sonnet** implements each slice against the pinned contract below.
- **Opus** reviews every slice with two lenses (architectural / retail-
faithful) before its closeout; findings go through a fix round and a narrow
re-review.
- **The owner** is stopped for exactly three things: the cdb read on the
retail client (VM2), the visual gates (VM3, VM6, VM7), and the merge.
Rules binding on every slice: no workarounds (CLAUDE.md), no guessed
AC-specific constants — a number without a decomp/DAT anchor gets a register
row in the same commit — and the pack-off path is the oracle: any slice that
moves a pack-off pixel outside its declared mask is a bug, not a tuning.
## Lesson carried into every slice — a branch that exists is not a branch that runs
VM2 found that Campaign AR's #226 port, the 2026-08-21 findings doc it was
briefed from, and the AR review's "probably" were all describing the
**fallback** branch of retail's detail pass — the `stage == 0` framebuffer
blend that retail only takes when the adapter cannot advertise
`D3DTEXOPCAPS_PREMODULATE`. Real hardware (the owner's AMD, and anything
modern) takes the single-pass texture-stage branch, which computes a
different formula with the opposite sign of effect (darkens, not brightens).
The decomp was read correctly three times; what nobody did until VM2 was
spend two minutes reading the capability on a live client.
Binding rule for this campaign and after: **when retail gates a mechanism
behind a capability, preference, or `trysinglepass`-style switch, the port
is not done until the gate's live value is recorded** — a cdb read on the
PDB-paired client, cited in the port's note with the binary GUID. "Grep
named → decompile → port" answers what the code *says*; only the live read
answers what it *does* on the hardware the game actually ran on. A guess
about driver caps ("rarely advertised") is not evidence and must not appear
in a review verdict.
Corollary for brightness questions: the owner's live "too bright" (exposure
1.0 → 0.80) was the pack-on gamma-space tonemap (F4, VM3), not this. The
detail overlay's fallback brightening is +3 % on building shells only; the
single-pass path darkens by ~10 %. Neither explains a scene-wide level.
## Slice ledger
| Slice | Closes | Gate |
|---|---|---|
| VM0 | F1 — default-path invariance | **CLOSED 2026-08-22 — PASS**; [report](../research/2026-08-22-vm0-default-path-invariance.md) |
| VM1 | F2/F3 — #226 single-pass re-port + fade removal | **CLOSED 2026-08-22**`05970306`, `388457a7`, `ae651312`, closeout; Opus APPROVE |
| VM2 | F3 — which retail detail path ran | **CLOSED 2026-08-22** — single-pass; see [cdb note](../research/2026-08-22-vm2-retail-detail-path-cdb.md) |
| VM3 | F4 — linear-light post stack | **CLOSED — USER-ACCEPTED 2026-08-23 ("Good gate pass")**`87677f9c`, `51178f7c`, closeout; Opus APPROVE; live Holtburg, High pack, 2560×1440 fullscreen, day group 9 "Rainy", launched from the cloned config under `artifacts/owner-gate` on `132395e6` (after #424/#425 fixed during the gate) |
| VM4 | F5 — overclaiming docs, incl. the reviewer's own | **CLOSED 2026-08-22** |
| VM5 | F8 — volumetric banding; F7 filed | **CLOSED 2026-08-22** (#421) |
| VM6 | Foliage wind | **CODE-COMPLETE 2026-08-23**`0930c35d`, `39e8408c`, `43e3abed`, `a82959f1`, `fccba839`, `eec95535`, `754d59d9`, `1f151242`; five Opus review rounds (round 3 APPROVE; round 4 REJECT caught the overhead-sun regression, fixed in round 5; round 5 APPROVE, its four nits closed in `1f151242`); pixel proof [note](../research/2026-08-23-vm6-foliage-wind-pixel-proof.md) (14,993 robust wind px vs 65 floor, scenery only; wind-off frames match the plain pipeline at mean |Δ| 0.007); **CLOSED — owner visual gate USER-ACCEPTED 2026-08-23 ("Looks good")** — live Holtburg, High pack, day group 9 "Rainy" (Overcast), 1920×1080, launched from the cloned config under `artifacts/owner-gate`; the remaining §VM6 Acceptance steps (30 s single tree, shadow follows, indoors still, pack-off still, Clear vs Storm) are the owner's to tick or waive |
| VM7 | Closeout: full gates, register, roadmap, merge | **SHIPPED 2026-08-23 — owner: "accept 422, commit and merge with main"; #422 carried as an open watch item; merged to main as a fast-forward.** AUTOMATED ROWS PASSED 2026-08-23 — release gate 15,283/0/0, connected route PASS pack-off + High, VM0 invariance on the final binary (robust 510 px), performance matrix PASS, #422 re-characterised (pack-independent, ~2 %, no stack yet); [closeout note](../research/2026-08-23-vm7-automated-closeout.md). **Owner visual gates (VM3, VM6, VM7 matrix) and the merge OWED** |
Order is VM2 (done) → VM0 (done) → VM1 (done) → VM4 (done) → VM5 (done) → VM3 (closed, user-accepted) → VM6 (closed, user-accepted) → VM7 (shipped; #422 carried). VM0 goes first because everything after it is measured
against the baseline it establishes.
---
## VM0 — Default-path invariance (F1)
**Why:** the only "pre-campaign oracle" is a 2×2 synthetic, one-draw
recording-device fixture. `WbDrawDispatcher.Rhi.cs` was refactored underneath
the default path. Nobody has compared real pack-off pixels or production
performance against `6c79d35c`.
**Implementation**
1. Build `6c79d35c` in a throwaway worktree (Release). Capture with
`tools/run-offline-pixel-gate.ps1 -Out artifacts/vm0/base` — the tool's
own documented baseline mode.
2. Capture HEAD pack-off twice with the same tool and camera set:
`BuildingDetailTextures=false` (isolates the A2 normal change) and
`=true` (the shipped default).
3. Extend the tool's compare step with a **terrain mask** (same mechanism as
the existing `sky-mask.png`): pixels whose depth/material belong to
`terrain_modern` are excluded from the identity assertion and reported
separately.
4. Assert: HEAD(detail=false) vs base — **zero** differing pixels outside the
terrain mask; inside the mask, differences are reported as a histogram
(expected: low-amplitude shading deltas, no structural change).
HEAD(detail=true) vs HEAD(detail=false) — differences confined to
building/EnvCell surfaces (second mask from the detail replay's own
command set).
5. Production performance A/B, **no automation observer, no validation
layers, uncapped Release**, at the CLAUDE.md production-profile camera and
at pinned dense Arwic: base vs HEAD pack-off. Record CPU/GPU p50/p95/p99,
FPS, `alloc_kb p50`, GC counts. The pre-campaign references are
519.7 FPS / 1.869 ms CPU p50 (profile) and ~3.0/4.9 ms CPU p50/p95 (dense
Caul, digest §render). Deltas must sit inside run-to-run noise; any
allocation growth on the pack-off path is a regression to fix in this
slice, not to file.
6. Add the masked comparison as a repeatable tool mode
(`-Baseline … -TerrainMask`) so VM3/VM6 can re-run it.
**OUTCOME (2026-08-22): PASS.** Connected, visible-window, isolated-config captures: base+normals vs HEAD-off has zero strong static differences outside the animated lifestone at Holtburg and the open field; production CPU is 13% cheaper and allocation 25x lower on HEAD's pack-off path (no observer, uncapped Release). Three config traps (isolated FOV, the real Roaming settings still selecting the pack, minimized-window throttling) are recorded in the report. Replaced the planned terrain mask with a base+normals baseline (no mask needed).
**Acceptance:** the two pixel assertions in step 4 hold; the perf A/B shows
no default-path regression; the Fable report records the exact commands,
commits and artifact paths. If a regression is found, it is fixed here and
the report names the cause — no "accepted difference" without a diff that
explains it.
## VM1 — Remove the invented detail fade (F2)
**Why:** `RetailDetailTextureContract.FullDetailDistanceMetres = 10` /
`ZeroDetailDistanceMetres = 50` have no retail anchor. Retail's
`DrawBuilding`/`DrawEnvCell`/`RenderMeshSubset`/`SetDetailSurfaceInternal`
carry no distance term; attenuation is the LINEAR mip chain.
**Decision (Fable, retail-faithful rule):** remove the ramp. **Amended by VM2:** VM1 also re-ports the blend to the single-pass math (see the VM2 outcome) — the fade removal and the blend correction land together, with the `RetailDetailTextureContract` helper and tests rewritten around `Expected(base, detail, diffuseAlpha)`. Mip averaging
already converges the live category texture to its 1.033 mean factor.
**Implementation**
- Delete the two constants and `vDetailFade` from `mesh_detail.vert/.frag`
(`mesh_detail.vert:83`); the fragment outputs `applyFog(detail.rgb)` with alpha `detail.a * instanceOpacity` — fogged so the two-draw result collapses to retail's fog-after-combine pixel (VM1 review fix).
- Confirm the detail texture is uploaded with a full mip chain and sampled
LINEAR/LINEAR/LINEAR, WRAP — that *is* retail's attenuation; test it.
- Rename `RetailDetailTextureContract` members so nothing un-anchored is
labelled retail; keep the blend-factor helper and its tests.
- Update the #226 pseudocode note: strike the "10 m / 50 m" lines, add the
decomp citations above, and state that attenuation is mip-driven.
- If the owner later wants a ramp back, it returns as a **pack setting**
(enhancement), never on the default path.
**OUTCOME (2026-08-22): CLOSED.** Four commits: the single-pass re-port + fade removal (`05970306`); the interior shell/detail passes binding their own instance opacity — a pre-campaign stale-slot read (`388457a7`); the Opus fix round — the detail contribution is fogged so the two-draw result collapses to retail's fog-after-combine pixel, mip/sampler test, doc caveats (`ae651312`); and the closeout (AP-232 for the translucent-subset blend weight, the sampler test pinned to the production constant). Measured on Holtburg buildings: predicted +2.2/+0.66/+0.16 levels vs measured +2.17/+0.57/+0.16 (`artifacts/vm1`, grass/sky controls 0.00). Detail-on costs +0.3-0.5 ms CPU / +0.1 ms GPU at dense Arwic, still under the pre-campaign baseline. Opus dual-lens review: APPROVE WITH FIXES -> fix round -> narrow re-review APPROVE.
**Acceptance:** no reference to a distance fade remains in src or the note;
VM0's masked comparison re-run shows the only change is on detail surfaces;
at >50 m a building reads the same as at 60 m (mips), not a hard step.
## VM2 — Which detail path did retail hardware run? (F3, owner-gated)
**Why:** the port is of the two-pass framebuffer fallback. With
`m_caps.bCanDoSinglePassDetailing`, retail used stage-1 texture ops
(`PREMODULATE` + `BLENDCURRENTALPHA` — a lerp). The cap needs
`D3DTEXOPCAPS_PREMODULATE` (0x0059f6c6), which consumer drivers rarely
exposed — probable, not proven.
**Implementation:** one cdb script (`tools/cdb/vm2-detail-caps.cdb`): attach
to the PDB-paired `acclient.exe`, `dt acclient!RenderDevice::render_device`
`m_caps.bCanDoSinglePassDetailing`, `m_caps.bTexOpDotProduct3`, and the raw
`m_D3DCaps.TextureOpCaps`; `qd`. Record the answer in the #226 note and the
review doc.
- `bCanDoSinglePassDetailing == 0` → the port is the path players saw; close.
- `== 1` → file the single-pass stage math as the correct target, register
the current blend as a bounded divergence, and schedule the re-port as its
own slice (it is a fragment-shader change only).
**Acceptance:** the value is recorded with the binary GUID and the date.
**OUTCOME (2026-08-22): `bCanDoSinglePassDetailing = 1`, `trysinglepass = 1`.** Retail on the owner's GPU runs the single-pass stage path: `lerp(base·diffuse, detail.rgb, detail.a·diffuse.a)` — a mild darkening, not the fallback's brightening. The #226 port targets the wrong path and is re-ported inside VM1 (blend `SRCALPHA+INVSRCALPHA`, output `detail.rgb, detail.a·diffuseAlpha`, neutral at `detail.a == 0`). Also read: `LandscapeDetailTextures = 0` is a real separate preference. Evidence and the exact stage math: [2026-08-22-vm2-retail-detail-path-cdb.md](../research/2026-08-22-vm2-retail-detail-path-cdb.md).
## VM3 — Linear-light post stack (F4)
**Why:** bloom threshold, ACES, Rec.709 luma, saturation and the 0.5
contrast pivot all assume linear light; they receive gamma-encoded retail
colours and the output is never re-encoded. `exposure = 0.80` is the
compensation.
**Design**
- The main-world intermediate stays gamma-encoded `Rgba16Float` — the world
pass and its alpha blending are retail's and must not change.
- Every pack read of world colour decodes once: `acdreamDecode(c) = pow(c, 2.2)`
(a named function in `atmospheric_common.glsl`; 2.2 is the retail-era
display assumption, documented as such — not sRGB piecewise, which would
imply a precision the source never had).
- Bloom chain, sun-ray composite and volumetric composite operate in linear.
- `atmospheric_filmic.frag`: exposure → ACES → grade → vignette in linear,
then `acdreamEncode(c) = pow(c, 1/2.2)` to the UNORM swapchain.
- Defaults re-tuned so the **neutral** preset reproduces the pack-off image
within the VM0 tolerance when every effect is at its neutral value
(exposure 1.0, bloom 0, tonemap mix 0, saturation 1, contrast 1,
vignette 0) — the existing "every effect can be set to neutral" acceptance
now actually holds numerically, and is asserted by a test.
- Opinionated defaults (what the owner accepted at 0.80 exposure) are
re-derived in linear and presented for the visual gate. **Shipped truth
(not the pre-implementation guess above):** exposure stays at **0.80**
at exposure 1.0 the linear pipeline maps gamma-0.5 to 0.6017, essentially
the same 0.6163 the owner called too bright under the old gamma-space
pipeline, so 1.0 reproduces the rejected look, not a corrected one. The
bloom **threshold stays 1.0** (a fixed point of both `pow(x,2.2)` and
`pow(x,1/2.2)` — 1.0 decodes and encodes to 1.0), while the **knee moves
0.45 → 0.73** (`AtmosphericPostProcessGraph.BloomKneeLinear`) and
**vignette-strength moves 0.12 → 0.245** (re-derived so the same accepted
12% corner darkening survives the encode step). See
`AtmosphericColorPipelineTests` for the pinned numbers behind each of
these.
Old-vs-new curve at the shipped exposure 0.80 (gamma input →
old-pipeline display value / new-pipeline display value; filmic
strength 1, saturation/contrast 1, no vignette — i.e. `acesFitted`
applied directly to the gamma value versus `encode(acesFitted(0.80 *
decode(g)))`):
| gamma in | old display | new display |
|---:|---:|---:|
| 0.05 | 0.031 | 0.023 |
| 0.10 | 0.091 | 0.052 |
| 0.15 | 0.162 | 0.091 |
| 0.20 | 0.233 | 0.140 |
| 0.30 | 0.360 | 0.265 |
| 0.46 | 0.511 | 0.488 |
| 0.50 | 0.541 | 0.539 |
| 0.70 | 0.652 | 0.735 |
| 0.90 | 0.725 | 0.845 |
| 1.00 | 0.752 | 0.879 |
Shadows deepen slightly (0.050.30 gamma read darker), the crossover sits
near gamma 0.5 (old and new agree almost exactly there — expected, since
that is close to the 0.46 midtone the exposure was tuned against), and
highlights above roughly gamma 0.5 now read brighter instead of
compressing toward ACES's ~0.75 ceiling.
**Acceptance:** neutral preset ≡ pack-off within tolerance — proved by the
CPU mirror `AtmosphericColorPipeline` (`AtmosphericColorPipelineTests`,
half-an-8-bit-step identity over a 0..255 grey sweep) plus shader-source
pins (`AtmosphericPostProcessGraphTests`) guarding the decode/encode call
sites against silent removal. The real-frame masked capture **was run**
(retail vs High-with-every-effect-neutral, `artifacts/vm3`): 110,561 px at
`|Δ|=1` (float/pow round-trip noise, sub-visible) and 95 foliage-silhouette
pixels at `|Δ|≥5` (58 isolated, max 73 — cutout-edge rasterization between two
separate client runs; ambient motion is the alternative explanation), nothing
on any ground/building/water surface. Stage-1's luminance table re-capture is still **owed** at the
owner gate; owner visual gate: "same look as accepted, no clipping,
highlights roll off". Budget unchanged (two `pow` per pixel).
### VM3 owner visual gate — brief (read this, then play)
Launch normally (launcher or `ACDREAM_RETAIL_UI=1` env launch), Options →
Display → render pack **Atmospheric / High**. Every number below is measured,
not predicted; the sliders are live so you can tune while looking.
**What changed on screen, at the shipped defaults (exposure 0.80):**
| input tone (gamma) | before VM3 | after VM3 | Δ |
|---|---|---|---|
| deep shadow 0.10 | 0.091 | 0.052 | 43 % |
| shadow 0.20 | 0.233 | 0.140 | 40 % |
| mid-grey 0.46 | 0.511 | 0.488 | 5 % |
| 0.50 | 0.541 | 0.539 | 0 (crossover) |
| bright 0.70 | 0.652 | 0.735 | +13 % |
| highlight 0.90 | 0.725 | 0.845 | +16 % |
Midtones are where you left them; **shadows are deeper and highlights no
longer crushed** — that is the linear ACES toe/shoulder, not a bug. Offline
Holtburg hillside captures (`docs/research/evidence/vm3/`): mean luminance vs
pack-off **17 % at noon, 44 % at dusk** (codex's accepted look measured
8 % outdoors on a different scene). Vignette was re-derived (0.12 → 0.245) so
the corner darkening on screen is the same 12 % you accepted.
**Checklist (≈10 min):**
1. Noon outdoors, walk a shaded street: do shadows read as "deep" or as
"crushed"? If crushed → raise **Exposure** to 0.91.0 (mid-greys will
brighten ~10 %) or lower **Filmic strength** to 0.70.8 (lifts the toe,
keeps the shoulder).
2. Look at the sky near the sun and a bright roof: highlights should roll off
smoothly with no clipping (clip % measured 0.06, same as pack off).
3. Dusk (`/time` or wait): the 44 % is the effect of long shadows plus the
toe. Judge whether it is atmospheric or merely dark; the same two sliders
apply.
4. Corners: vignette should match the previous feel. If you had a saved
override of 0.12 it is now weaker (the default moved, overrides did not).
5. Switch the pack to **retail/off** and back: with every effect at its
neutral value the image is the pack-off image (measured: ≤1 LSB on 99.99 %
of pixels).
Say which slider values you settle on; they become the shipped defaults in
VM7. Known: #422 (1-in-8 heap-corruption exit after a pack-on run) is open
and is a VM7 gate item, not a VM3 one.
## VM4 — Truthful documents (F5)
**OUTCOME (2026-08-22): CLOSED.** Corrected in place, each with a dated
"VM4 correction" note: the AR plan's 2x2-oracle sentence and both
"zero skips" totals (now "under the hermetic lane filter"); the Stage-2
report's turning-hitch paragraph (observer tax, not product); the Track A
report's and the findings doc's `TerrainUtils.GetNormal` premise; the findings
doc's "retail brightens" section (fallback only; single-pass lerp is what runs),
its landscape-detail open question (`LandscapeDetailTextures = 0`, answered by
VM2), and its open question 5; the completion audit's retail-path row; and the
review's own F1/F2/F3/F5 headers. The VM0 production table is now the baseline
in the findings doc §5.
Correct, in the AR plan and its reports, the four sentences the review
named: the 2×2 oracle described as a production pin; the turning-hitch
conclusion drawn under the automation observer; the "zero skips" headline
(say "hermetic lanes"); and the Track A premise that `TerrainUtils.GetNormal`
produced a faceted look. Also correct the reviewer's own findings doc §4
(`2026-08-21-terrain-and-atmospheric-rendering-findings.md`): that function
orients scenery; the render normals were already smooth; A2 replaced
central-difference with retail's split-aware incident-face average.
Add the VM0 numbers as the new baseline table. Docs only; no code.
## VM5 — Volumetric jitter, and file the shared transform buffer (F8, F7)
- `atmospheric_volumetric.frag`: interleaved-gradient-noise offset per pixel
on the march start (`fract(52.9829189 * fract(dot(gl_FragCoord.xy,
vec2(0.06711056, 0.00583715))))`), plus the existing quarter/half-res
upsample. Automated: a fixed-camera capture's step-banding metric (row
autocorrelation at the step period) drops below the current value;
GPU delta < 0.02 ms.
- File issue: the shadow pass uploads a second transform buffer; a future
GPU-culling step should bind the main pass's instance SSBO. Not built here.
**OUTCOME (2026-08-22):** IGN jitter landed in atmospheric_volumetric.frag
(pack-on only); F7 filed as #421.
## VM6 — Foliage wind (the feature)
### What "real movement" means here
Trees do not wave like flags. Real foliage has **three motions at three
speeds**: the whole tree leans slowly with the mean wind (seconds), branches
swing at their own natural frequency (about one second), and leaves flutter
fast and independently (fractions of a second). Gusts come and go over tens
of seconds. Neighbouring trees are out of step. Trunk bases do not move.
Anything that ignores one of these reads as "screensaver" — which is what
the owner does not want.
### The rule for what sways (no guessing, data-driven)
- **Candidate set:** procedural scenery only — entity ids in the
`0x8XXYYIII` namespace (`ProceduralSceneryIdAllocator`, top nibble 0x8).
Landblock statics (fences, signposts, buildings), weenies, creatures and
players never sway.
- **Foliage subsets:** within a candidate, the **alpha-cutout material
subsets** (leaves, fronds, bushes, grass tufts). Rocks have none and stay
still; a tree's trunk is opaque and gets only the slow lean (below).
- **Opaque subsets of a candidate that also owns cutout subsets** (trunks,
branches) receive the slow lean only, scaled by height, never the flutter.
- A pack may **exclude** object ids (`FoliageExclusions`, a list in the
descriptor) for the rare scenery object that is cutout but not foliage.
There is no include list: the rule is the rule.
This is a render-only classification flag per batch — bits 1 and 2 of
`BatchData.flags` (`mesh_atmospheric.vert` already reads `flags`; bit 0 is
the #226 built-mesh marker). The dispatcher sets it once at classification
time from the entity id namespace and the subset's blend class; the pack-off
`mesh_modern` pipelines never read it.
### The motion (vertex shader, pack variant only)
Applied in `mesh_atmospheric.vert` and in all four
`directional_shadow_world_*` vertex shaders (constraint: **the shadow must
move with the leaf** — the caster and receiver displacement are one shared
include, `foliage_wind.glsl`, so they cannot drift apart).
Inputs: instance world origin `o` (translation column of
`Instances[i].transform`), vertex world position `p`, `time` (from
`uCameraAndTime.w`, already in the vertex stage), and the pack's wind block
(new `uFoliageWind` in `atmospheric_common.glsl`):
```
vec4 uFoliageWindDirection; // xy unit direction, z = mean strength [0..1], w = gust strength
vec4 uFoliageWindParams; // x = lean amplitude m, y = branch amplitude m, z = flutter amplitude m, w = max height m
```
All amplitudes are **metres at the canopy top**; they are pack settings with
bounded ranges, not shader constants.
```
h = clamp((p.z - o.z) / maxHeight, 0, 1) // 0 at the base, 1 at the top
k = h * h // bend grows with height²: bases stay put
ph = dot(o.xy, vec2(0.137, 0.291)) // per-tree phase from world position
g = 0.5 + 0.5 * sin(0.05 * t + ph) // gust envelope, ~20 s
+ 0.25 * sin(0.13 * t + 1.7 * ph)
s = mean + gust * g // instantaneous strength
lean = k * leanAmp * s * (0.8 + 0.2 * sin(0.35 * t + ph)) // slow lean
branch = k * branchAmp * s * sin(1.1 * t + ph + 2.0 * h) // ~1 Hz, phase runs up the tree
flutter = h * flutterAmp * s * sin(6.0 * t + 7.0 * fract(sin(dot(p.xy, vec2(12.9898, 78.233))) * 43758.5453))
d = dir * (lean + branch) + perp(dir) * 0.35 * branch + vec2(flutter) * normalize(vec2(cos, sin) of vertex hash)
p.xy += d
p.z -= 0.5 * dot(d, d) / max(h * maxHeight, 0.5) // bend shortens, it does not stretch
```
Opaque (trunk) subsets use `lean` only. Cutout subsets use all three. The
vertex hash decorrelates leaves on the same tree; the instance phase
decorrelates trees. The `p.z` term is the cheap length-preserving correction
so a bent canopy sinks slightly instead of growing.
**Deliberate divergence from the pseudocode above (review fix round A7):**
both `h`'s divisor and the `p.z` correction's divisor guard `maxHeight`
(`amp.w`/`uAtmosphereWindAmplitude.w`) with `max(maxHeight, 0.5)`, i.e.
`h = clamp((p.z - o.z) / max(maxHeight, 0.5), 0, 1)` and
`p.z -= 0.5 * dot(d, d) / max(h * maxHeight, 0.5)`. `wind-canopy-height-metres`
is an author-facing pack setting with no enforced floor; without the guard a
misconfigured near-zero canopy height would divide by a near-zero value and
either blow `h` up to a huge (then clamped) number with a discontinuous
derivative right at the base, or make the bend-shortening term explode.
Flooring at 0.5 m (half the shortest plausible sapling) keeps both terms
well-behaved for any authored value, including 0, and is invisible for every
realistic canopy height (the built-in default is 8 m). Both the shader
(`foliage_wind.glsl`) and the CPU mirror (`FoliageWindModel.Displace`) apply
the guard identically.
Normals are **not** rotated (Gouraud on a cutout leaf with flipped lighting
would flicker); AC's flat-lit foliage does not need it.
### Weather drives it (AC owns the weather)
The pack's `AtmospherePolicyDeclaration` gains a `FoliageWindByWeather` table
keyed by the DAT-classified `AcDream.Core.World.WeatherKind` — not the raw
`activeDayGroup` index, which carries no weather meaning by itself.
`WeatherState.cs` already classifies each day group's authored DAT name into
one of these five real kinds, and `AtmosphericFrameInputs.Weather` /
`uAtmosphereWeather.x` already threads that classification through the
frame — this table reuses it instead of re-guessing from the index:
| WeatherKind | mean | gust |
|---|---|---|
| Clear | 0.25 | 0.15 |
| Overcast | 0.60 | 0.35 |
| Rain | 0.85 | 0.60 |
| Snow | 0.35 | 0.20 |
| Storm | 1.00 | 0.75 |
plus a global **Wind** slider (02×) and an **Off** setting. Transitions
between day groups interpolate over the existing weather delta seconds
(`uAtmosphereWeather.z`), so a weather change never snaps. Wind direction is
a pack setting (default NE→SW, 225°); there is no authored retail wind
direction to read, and the register row says so.
### Indoor, shadows, physics, picking
- Indoors (`!IsOutdoor`) wind is zero — EnvCells have no scenery anyway.
- Shadow casters apply the identical displacement (shared include).
- Physics is untouched by construction: the collision BSP is the trunk and
nothing here touches Runtime or Core physics. The acceptance test asserts
the Runtime/physics diff is empty.
- World picking (`WorldPicker`) picks against the undisplaced mesh; a leaf
may be up to `leanAmp + branchAmp` metres from its pick volume. That is
accepted and registered (one row, "render-only foliage displacement");
nobody picks leaves.
### Budget and quality scaling
- Cost: ~25 ALU per foliage vertex, zero CPU, zero submissions. Target:
< 0.05 ms GPU at 1080p dense foliage route; measured with the AR matrix
tooling (`tools/run-atmospheric-performance-matrix.ps1`).
- Low preset: lean + branch only (no flutter). Medium/High: all three.
### Acceptance
- Automated: the classification flag is set only for top-nibble-0x8 entities'
cutout subsets (and lean-only for their opaque subsets); pack-off pipelines never
read the flag and VM0's masked comparison is unchanged; the shared include
is byte-identical between receiver and caster variants (test reads both
SPIR-V inputs' source); weather table interpolates without discontinuity;
budget row passes.
- **Owner visual gate**, in this order: (1) Holtburg outskirts, Clear, noon —
"barely moving, alive"; (2) same place, Rain or Storm (the WeatherKind
comes from WeatherState's classification of the active day group's DAT
name — `/time`-cycle day groups or wait for weather) — "clearly windy,
still not a flag"; (3) watch one tree for 30 s — gusts arrive and leave, neighbours
out of step; (4) the tree's shadow on the ground moves with it; (5) walk
indoors — nothing moves; (6) pack off — nothing moves. The owner's words
decide; the numbers above are starting points to tune live.
### VM6 outcome (implementation landed 2026-08-22; code-complete 2026-08-23 at `1f151242`; owner visual gate outstanding)
Two commits (VM6a shader ABI v2 plumbing, VM6b the feature) shipped the
design above; a same-day fix-round commit corrected the weather-table key.
Settings (`BuiltInAtmosphericRenderPack.Settings()`): `wind-enabled` (bool,
default on), `wind-strength` (02×, default 1.0), `wind-direction-degrees`
(0360°, default 225 — no authored retail wind direction exists to read),
`wind-lean-metres` (default 0.25), `wind-branch-metres` (default 0.15),
`wind-flutter-metres` (default 0.05, forced to 0 on the Low preset),
`wind-canopy-height-metres` (default 8). The mean/gust rows live in
`AtmospherePolicyDeclaration.FoliageWindByWeather`, keyed by NAME
(`FoliageWindWeatherPoint.WeatherKind`, an exact ordinal match against
`AcDream.Core.World.WeatherKind`'s member names — `Clear` 0.25/0.15,
`Overcast` 0.60/0.35, `Rain` 0.85/0.60, `Snow` 0.35/0.20, `Storm` 1.00/0.75),
not the raw `activeDayGroup` index the original design used: the index
carries no weather meaning by itself, and `WeatherState.cs` already
classifies each day group's authored DAT name into one of these five real
kinds — the same fact `AtmosphericFrameInputs.Weather` /
`uAtmosphereWeather.x` already threads through the frame, reused here
instead of re-guessed. `RenderPackAtmospherePolicyEvaluation.FoliageWind`
matches by `weather.ToString()` and falls back to the declared Clear row for
an unlisted kind. Classification bits live in
`FoliageWindClassification` (`AcDream.App.Rendering.Wb`): bit 1 (`0x2`)
cutout foliage, bit 2 (`0x4`) trunk, computed once per (entity, subset) in
`WbDrawDispatcher.ClassifyBatches` (world receiver) and
`AddDirectionalShadowBatches` (caster) from the identical four inputs
(entity id, `AtmospherePolicyDeclaration.FoliageExclusions` membership,
subset `TranslucencyKind`, and `ObjectRenderData.HasCutoutSubset` — computed
once per mesh, not per frame or instance). The shared displacement lives in
`foliage_wind.glsl`, called once each from `mesh_atmospheric.vert` and all
four `directional_shadow_world_*` (opaque/cutout, base/multiview) caster
vertex shaders, reading the ABI v2 `uAtmosphereClockWind`/
`uAtmosphereWindAmplitude` members `AtmosphericPostProcessGraph.ResolveFoliageWind`
resolves once per `frame.Serial` (so the caster, which runs first each
frame, and the receiver read byte-identical values). `FoliageWindModel` is
the CPU mirror pinned by hermetic tests. Register row IA-25. Owed: the
six-step owner visual gate above (implementation is otherwise code-complete
and the automated acceptance criteria pass).
**Review fix round (2026-08-22/23):** an Opus dual-lens review of the three
implementation commits found two blockers, both fixed. (A1) The procedural-
scenery classifier tested bit 31 alone instead of the full top nibble
`0xF000_0000 == 0x8000_0000`, which incorrectly matched `LandblockStatic-
EntityIdAllocator`'s `0xC...` namespace (fences/gates/building shells with a
cutout subset), the `0xDA11_D0xx` paperdoll id, and the `0xFFFF_FF01` portal-
tunnel id as procedural scenery — all three would have swayed.
`ProceduralSceneryIdAllocator.IsInNamespace` now does the exact top-nibble
test and `FoliageWindClassification.IsProceduralScenery` delegates to it.
(A2) `GroupKey` (the world-receiver instance-batching key) did not include
`FoliageFlags` while the caster's dedup key already did, so a scenery
instance and a non-scenery instance sharing the same mesh subset coalesced
into one receiver `InstanceGroup` whose flags were whichever entity
classified it last — the "known limitation" paragraph this outcome section
used to carry. `GroupKey` now carries `FoliageFlags`, set exactly once at
group creation from the key, never re-stamped; the two placements now land
in two distinct groups and the receiver agrees with the caster by
construction. Two should-fix items also landed: (A3) the world receiver
pass now explicitly binds `UniformAtmosphericFrame` from the caster's own
per-frame binding (carried on `DirectionalShadowFrameBinding`) instead of
relying on Vulkan not resetting the caster pass's leftover binding; (A4) a
Setup-composed tree's opaque trunk part now gets the trunk flag by OR-ing
`HasCutoutSubset` across all of the entity's currently-resolved sibling
parts (`FoliageWindClassification.ComputeEntityHasCutoutSubset`) instead of
consulting only the trunk part's own (cutout-free) mesh data. Nits A5
(flutter hash relative to instance origin, not absolute world XY — fp32
precision at far landblock corners) and A8 (`FoliageWindExclusions` as a
`FrozenSet`) also landed; A7 is the divergence note on the `max(maxHeight,
0.5)` guard above.
**Review fix round 2 (2026-08-23):** a narrow re-review found one more
blocker. (F1) The PRODUCTION packed classifier (`WbDrawDispatcher
.PackedOracle.cs`'s `ClassifyPackedBatches`/`GetOrCreatePackedGroup` — the
route `RetailPViewPassExecutor.DrawPackedProductionRoute` actually draws
from) never computed `FoliageFlags` at all: it built its `GroupKey` with the
field defaulting to `0u` and never copied it onto the created
`InstanceGroup`, so production `BatchData.flags` bits 1/2 were always zero
for every scenery entity — the world geometry never swayed even though the
independently-classified shadow caster did, so shadows visibly swayed under
rigid trees. Both classifier call sites now compute `FoliageFlags` via the
identical `FoliageWindClassification.Classify` call and entity-scoped
`HasCutoutSubset` OR the classic path uses, and `GetOrCreatePackedGroup`
copies it exactly like `GetOrCreateInstanceGroup` always has. The G2/G3
classified-output digest (`AddOpaqueSubmissionGroup`/
`BuildTransparentSubmissionDigest`) now also folds `GroupKey.FoliageFlags`
into its hash — previously present in the key but never actually read by
either digest function, so the fold is correct and symmetric between the
two functions. **Review fix round 3 (N3) correction:** this does NOT mean a
classic-vs-packed divergence is caught today. `CompareClassifiedOutput` (the
method that reads this digest) only runs from
`RenderScenePViewFrameProductController.BuildAndCompare`, which has no
production caller anywhere in `src/AcDream.App/``FrameRootComposition.cs`
constructs the controller with a real dispatcher, but nothing ever calls
`BuildAndCompare` on it — and both of `RenderScenePViewFrameProductTests`'s
own callers construct the controller without the optional `dispatcher`
argument, so `_dispatcher` is `null` and `CompareClassifiedOutput` short-
circuits before reaching the digest at all. The fold is correct and ready
for the day this oracle is wired to a caller; it catches nothing until then.
(F2, medium) The delayed-alpha replay
path (`PrepareDeferredAlphaDraws`) hardcoded `Flags = 1`, dropping bits 1/2
for any group replayed through it — a trunk instance promoted into the
alpha-blend group mid-fade (the `#188` translucency-promotion case) would
stop swaying for the duration of its fade; now `1u | key.FoliageFlags`. (F3)
`ComputeEntityHasCutoutSubset`'s three call sites (classic, caster, and the
newly-fixed packed classifier) each allocated a closure over `_meshAdapter`
per Setup entity per frame; a new context-taking overload passes the mesh
adapter as an explicit argument to a `static` lambda instead, letting the
compiler cache one delegate for the method's lifetime. A3's test gap also
closed: `DirectionalShadowGpuTests` now has a companion test proving
`WbDrawDispatcher.BindDirectionalShadowReceiver` (made `internal` for this)
actually emits the `UniformAtmosphericFrame` bind with the exact
buffer/offset/size a `DirectionalShadowFrameBinding` carries, pairing with
the existing test that proves that binding carries the caster's real bind
forward untouched. F4 (the `foliage_wind.glsl` header comment) and F5 (a
comment at the receiver bind site: the caster's own `AtmosphericFrameBuffer-
Binding` has its seven ABI v1 members zero/Identity by construction — only
the two v2 wind members are valid — safe today because `mesh_atmospheric
.vert` reads this binding solely for wind displacement, a footgun for a
future v1-reading addition to that shader) also landed. F6, noted rather
than fixed, applies to BOTH of the classifier's caches: the classic route's
`EntityClassificationCache`'s `EntityCacheEntry`, and the packed
production route's `PackedProjectionClassificationEntry`/
`PackedClassifiedBatch.Key` (`PackedProjectionClassificationCache`). Both
bake `GroupKey.FoliageFlags` (hence exclusion membership) in at
classification time and neither is proactively invalidated when
`FoliageWindExclusions` changes — a newly-excluded or newly-included object
can show its previous classification until its cache entry is next evicted
rather than immediately on a pack switch. The two caches differ in HOW they
eventually recover: `EntityCacheEntry` self-heals per entity, on that
entity's own next eviction (e.g. a landblock demote/reload);
`PackedProjectionClassificationCache.BeginFrame`
(`PackedProjectionClassificationCache.cs:133-137`) instead clears its
ENTIRE cache in one shot whenever `RenderSceneGeneration` changes. Neither
mechanism is keyed to a pack switch specifically, so both are staleness
windows of unknown-but-bounded length, not an immediate reclassification.
Harmless with the pack off (exclusions are pack-scoped); not worth a
proactive invalidation sweep for a rarely-changing, pack-scoped list.
**Review nit A6 (reviewer-filed, landed round 3):** `ResolveFoliageWind`'s
`_windMean`/`_windGust` started at 0 and ALWAYS eased toward the weather
target by clock delta, with no distinction for a graph's first-ever
advance. Two consequences: a pinned clock (`ACDREAM_SKY_PHASE_SECONDS`,
the offline pixel gate's determinism pin) has delta 0 on every advance
after the first, so the wind reached only whatever fraction the first
(clamped-to-1-second) step produced and sat there forever — every offline
capture under-represented the motion; live, the first 10 s after a graph
is constructed (pack selection / login) spun up from dead calm even though
the weather already IS what it is, because there was no previous frame to
ease from. Fixed at the root: the first advance
(`_windFrameSerial == -1`, the constructor sentinel) now SNAPS `_windMean`/
`_windGust` straight to the target; every later advance eases over
`WeatherSystem.TransitionSeconds` exactly as before. A new
`SetWindClockSecondsOverrideForTesting` test-only seam
(`AtmosphericPostProcessGraph`, `_windClockSecondsOverride` no longer
`readonly`) lets a hermetic test advance the pinned clock by an exact,
deterministic amount between two resolves — proving both the first-advance
snap and that a SECOND advance still eases normally — without a real-time
`Thread.Sleep`.
**Review fix round 4 (2026-08-23): wind decoupled from the shadow gate.**
The reviewer's offline pixel apparatus caught a real design defect the
first three rounds' CPU-side reasoning could not see: foliage wind was
welded to "directional shadows rendered this frame." Evidence: at the
High preset with `sun-shadow-strength=0` and wind-strength 2 + 1 m
lean/branch amplitude, wind-on vs wind-off at the same pinned clock
differed by only 4965 px — inside the apparatus's own 22 px run-to-run
noise floor, i.e. no measurable motion at all. A CPU probe independently
confirmed `ResolveFoliageWind` itself was correct (first advance snaps
exactly to Clear's 0.25/0.15, the gate is 1, one graph instance) — the
correct uniform was computed but never reached the world pass. Root
cause: `DirectionalSunShadowRenderer.Render` left `_currentFrameBinding`
at its pure `Disabled` (no-buffer) default on both early-out paths
(`!environment.ShouldRender`, `ResidentWindowUnavailable`);
`WbDrawDispatcher.PipelinesFor`/`TerrainModernRenderer`'s matching
selection logic only chose the atmospheric receiver pipeline
(`mesh_atmospheric`, which alone `#include`s `foliage_wind.glsl`) when
`TryGetCurrentFrameBinding` returned true; with no buffer it always
returned false, so the world pass silently ran the plain `mesh_modern`
pipeline instead — which has no wind code at all. Because the shadow
gate is `ActiveDayGroupMultiplier = dayGroupPolicy × elevationResponse ×
strength`, this killed wind every NIGHT (elevation response → 0), at
user `sun-shadow-strength` 0, and under the portal/login cover — not
just in the artificial `strength=0` repro.
Fix (decouple, don't patch): `DirectionalShadowFrameBinding` gained
`IsBindableFor` ("a real current-frame allocation exists") separate from
`IsValidFor` ("...and it is Enabled with real shadow content" — the
volumetric pass still gates on this, unchanged);
`TryGetCurrentFrameBinding` now returns `IsBindableFor`. When the
built-in pack supplies an `AtmosphericFrame` binding (declared packs
never do, so they are unaffected), `Render`'s two early-out paths call a
new `PublishDisabledReceiverBinding`: it allocates one real ring slice
and writes a DISABLED `DirectionalShadowUniforms` block — every matrix
Identity, every control/bias term zero, `TextureAndFlags` all zero (bit
0 clear is exactly what `directional_shadow_receiver.glsl`'s
`acdreamDirectionalShadowVisibility` already reads as "no shadow, full
visibility" via its existing early `return 1.0`), and a UNIT light
direction `(0,0,1)` so a fragment shader's `normalize()` can never
produce NaN. `BindDirectionalShadowReceiver` and
`TerrainModernRenderer`'s shadow-buffer bind now check `Buffer is not
null` instead of `Enabled`, so this disabled block actually gets bound
once it is selected.
**Review fix round 5 (2026-08-23) correction to the paragraph above:** the
claim "the flag bit makes it numerically the plain lighting sum" was
false as written — F1 BLOCKER, found by the reviewer's own offline pixel
apparatus reading the shader source, not by a repro capture (the disabled
block's `(0,0,1)` direction differs too little from straight-overhead to
show up above the apparatus's noise floor at these amplitudes, so the
mislighting was invisible to that specific test even though it is real).
Both receiver VERTEX shaders (`mesh_atmospheric.vert`,
`terrain_atmospheric.vert`) sourced the sun direction used to compute
`directionalLit`/`vDirectionalLit` from the shadow block's own
`uShadowLightDirectionAndSource`, unconditionally — not only the
*visibility* term, which was already correctly flag-gated. Every shadow-
gated-off frame was lighting outdoor terrain and objects from the
disabled block's `(0,0,1)` placeholder — straight overhead — regardless
of the authored sun's actual position. The celestial shadow source
direction is not the authored light direction either, so publishing it
instead would not have restored parity. Fixed in the shaders themselves:
both receiver verts now branch on the SAME flag bit
(`(uShadowTextureAndFlags.w & 1u) == 0u`) and, when clear, use the EXACT
plain-pipeline expression (`-uLights[i].dirAndRange.xyz` in
`mesh_atmospheric.vert`, matching `mesh_modern.vert`;
`-uLights[0].dirAndRange.xyz` in `terrain_atmospheric.vert`, matching
`terrain_modern.vert`) instead of the shadow block's direction. Corrected
statement: with the flag bit clear the receiver shaders take visibility
1.0 AND fall back to the authored `uLights` direction (round 5), so a
gated-off frame matches the plain pipeline to within float summation-
order rounding (review fix round 6, N2: "numerically the plain pipeline"
overstated it — the direction expression is bit-for-bit, but the SUM is
not, because the atmospheric fragment's split ambient+point vs
directional accumulation reassociates the terms relative to the plain
pipeline's single varying, and terrain's two separate varyings vs the
plain pipeline's one do the same; measured mean |Δ| 0.007 on the offline
scene, well under the apparatus's 65 px floor). Wind is unaffected —
`foliage_wind.glsl` reads only the AtmosphericFrame half of set 3, never
the shadow block.
**Pixel proof after rounds 45 (2026-08-23, `eec95535``754d59d9`):** the same
apparatus that found the defect — High preset, `sun-shadow-strength=0` so
no shadow term can move, same pinned clock, wind on vs off, two captures per
arm so the repeat pairs measure the run-to-run floor — gives a robust wind
mask of **13,854 px** on `eec95535` and **14,993 px** on `754d59d9`
(amplified: strength 2, lean/branch 1 m, flutter 0.5 m) against a 1965 px
floor, and **1,402 px** at the default Clear strength; the wind-off frame on
`754d59d9` matches the pre-round-4 plain pipeline at mean |Δ| 0.007 (round
4's `eec95535` was 1.77 — the overhead-sun regression); every marked pixel is a treeline tree, hillside tree or shoreline
bush, and no house, fence, road, lifestone, ground, water or UI pixel moves.
The first method tried (clock pin 0 s vs 3 s, wind-off pair as the control)
is recorded as confounded in the note and must not be reused: the treeline
silhouette carries a bimodal 0-or-~280 px rasterisation churn between runs
that lands on either side of the subtraction by luck. Tool:
`tools/vm6/wind-pixel-proof.py`; note:
`docs/research/2026-08-23-vm6-foliage-wind-pixel-proof.md`.
## VM7 — Closeout and merge
- Full gates: `tools/run-release-gate.ps1` (hermetic lanes), the AR
reference matrix re-run for the changed presets, VM0's masked comparison
on the final binary, the connected lifecycle/reconnect route.
- Register: rows for the wind direction constant, the render-only
displacement vs picking, and whatever VM2 decides; TS-52 stays retired.
- Roadmap: move Campaign VM to shipped; AR's "final owner gate" rows that
VM0/VM3/VM6 satisfy are marked so in the AR plan.
- Memory: a `project_visualmaster_campaign.md` digest entry (the wind rule,
the linear-light decision, the VM0 oracle recipe).
- Owner: final pack-off / pack-on visual matrix, then merge to main.
## What this campaign does NOT do
- It does not touch the retail-faithful default path except through VM0's
evidence and VM1's removal of an un-anchored constant.
- It does not add normal maps, PBR, SSAO, water, or a second weather system.
- It does not move trunks' collision, walkability, or anything in Runtime.
- It does not animate landblock statics, weenies, grass decals that are not
scenery, or creatures.
- It does not rebuild the shadow transform buffer (filed, VM5).
- It does not re-port #226 to the single-pass path unless VM2 proves retail
ran it.
## Proposed owner goal
> **Campaign VM — VisualMaster.** Drive
> `docs/plans/2026-08-22-visualmaster-campaign.md` to shipped on branch
> `claude/git-sync-status-5fb1d2`: VM0 proves the pack-off path is
> pixel-identical to `6c79d35c` outside the terrain/detail masks with no
> production perf or allocation regression; VM1 removes the un-anchored
> detail fade; VM3 puts the post stack in linear light with a numerically
> neutral preset; VM4 makes every document truthful; VM5 de-bands the
> volumetrics; VM6 adds weather-driven foliage wind (scenery-only, cutout
> subsets, three motions, shadows follow) behind the render pack. Fable plans
> and verifies, Sonnet implements, Opus dual-lens reviews every slice. Stop
> me only for: the retail cdb read (VM2), the three visual gates (VM3, VM6,
> VM7), and the merge. Every closeout claim must name its command, lane and
> artifact.

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

@ -12,11 +12,11 @@ The command verifies that `AcDream.slnx` contains every `.csproj` under `src/`,
then discovers and runs every hermetic test in every default test assembly once
in a fresh Release process. It does not retry failures. Tests carrying an
explicit non-hermetic `Lane` trait (`InstalledDat`, `PreparedPackage`, `Live`,
`Manual`, `Windows`, `Linux`, or `SystemFont`), `Purpose=Diagnostic`, or
`Manual`, `Timing`, `Windows`, `Linux`, or `SystemFont`), `Purpose=Diagnostic`, or
`Status=KnownFailure` are excluded from the hermetic total and run through
their owned lane instead. The graph currently contains 44 projects,
including all 13 maintained .NET tools; data-dependent tools are built but are
not executed as tests.
their owned lane instead. The graph currently contains 54 projects,
including all 17 maintained .NET tools and three render-pack SDK samples;
data-dependent tools and SDK samples are built but are not executed as tests.
Build and dependency policy is repository-owned:
@ -72,6 +72,40 @@ The JSON summary records the exact test filter. Environment-dependent,
diagnostic, manual, and known-failure results must be published as their own
lane and must never be added to the hermetic pass headline.
## The Timing lane
`Lane=Timing` marks tests whose outcome depends on **real elapsed time or OS
scheduling** rather than on logic: simulated packet-loss soaks, a virtual-clock
transport session that still waits on wall-clock windows, signalling a real
child process, orphaned-process restart recovery. They pass on an idle machine
and fail intermittently under full-assembly load, so they cannot gate a push
without making the gate untrustworthy.
They are not weakened or deleted — run them deliberately, on a machine that is
not saturated:
```powershell
pwsh ./tools/run-release-gate.ps1 -SkipRestore -SkipBuild `
-TestFilter 'Lane=Timing&Status!=KnownFailure&Purpose!=Diagnostic'
```
Measured before laning: on the 6-core Linux runner, three stress rounds of the
full suite failed `GracefulStopSignalSendsSigintToARealChildOnLinux` 3/3 (it
passes in ~47 ms alone) and two loss-simulation tests 1/3 each. Chasing them one
at a time did not converge — four separate fixes, each surfacing a different
member of the same family, and one of those fixes regressed the other platform.
Add to this lane only with evidence that a test fails under load and passes in
isolation. A test that fails consistently is a bug, not a timing lane member.
## Continuous integration
This document owns the LOCAL gate. Pushes to `main` are gated on self-hosted
runners and publish alpha releases — see
[`ci-and-releases.md`](ci-and-releases.md). Note that CI deliberately does NOT
invoke `run-release-gate.ps1`: that script redirects child output to log files,
and Forgejo fails a task that stops reporting as a zombie.
## Non-hermetic test lanes
Installed-DAT tests require an explicit opt-in and a retail DAT directory:

236
docs/render-packs/README.md Normal file
View file

@ -0,0 +1,236 @@
# Render-pack SDK v1
**Campaign:** Atmospheric Rendering / Shader Packs
**Phase id:** **Campaign AR**
**Contract version:** `RenderPackApi.Current == 1`
Render packs are opt-in, declarative graphics extensions. acdream's current
retail-faithful renderer is always installed, remains the default and
authoritative comparison path, and is restored as one complete transaction
when a selected pack cannot run. A
pack cannot access Vulkan, renderer internals, gameplay state, world streaming,
or physics.
The public dependency is only
`AcDream.Plugin.Abstractions`. Do not reference `AcDream.App`, Silk.NET, or a
Vulkan binding. Three buildable external samples cover the API:
- [`AcDream.RenderPacks.NoOp`](../../samples/AcDream.RenderPacks.NoOp/) is the
smallest discovery and activation conformance pack.
- [`AcDream.RenderPacks.AtmosphericTier2`](../../samples/AcDream.RenderPacks.AtmosphericTier2/)
declares the complete semantic atmospheric executor with deliberately
renamed pack-owned IDs, embeds all referenced SPIR-V, and demonstrates
moving authored sun-and-moon shadows for terrain, trees, buildings, players, and monsters.
- [`AcDream.RenderPacks.ShadowsOnlyTier2`](../../samples/AcDream.RenderPacks.ShadowsOnlyTier2/)
demonstrates that Tier 2 is composable: it requests the same selected-celestial
caster/receiver semantics without Tier-1 post-processing or volumetric
shafts.
## Quick start
1. Target `.NET 10` and reference `AcDream.Plugin.Abstractions` with runtime
copy disabled. The acdream host supplies that assembly.
2. Add [`plugin.json`](plugin-manifest-v1.schema.json), include
`"kinds": ["renderPack"]`, and copy it beside the built entry DLL.
3. Expose exactly one public, parameterless `IRenderPackPlugin` entry point.
4. Construct immutable `RenderPackDescriptor` values and register them from
`Register`. Registration must only publish declarations; do not open assets,
compile shaders, start threads, or allocate native/GPU resources.
5. Supply shader bytes lazily through `IRenderPackAssets.OpenRead`. Asset keys
are forward-slash relative logical paths: never rooted, backslash-based, or
`.`/`..` traversals.
6. Build and run the SDK validator:
```powershell
dotnet build samples/AcDream.RenderPacks.NoOp/AcDream.RenderPacks.NoOp.csproj -c Release
dotnet run --project tools/RenderPackValidator/AcDream.Tools.RenderPackValidator.csproj -c Release -- samples/AcDream.RenderPacks.NoOp/bin/Release/net10.0
```
Substitute `AcDream.RenderPacks.AtmosphericTier2` in both paths to validate
the complete Tier 2/Tier 2+ example and its embedded shader interfaces.
The validator executes the managed registration entry point. Use it only on a
pack you trust. It loads no App, RHI, or Vulkan assembly and creates no GPU
objects. It validates the manifest, v1 declarations, referenced asset keys,
SPIR-V stage/entry point and complete v1 binary interface, managed
registration, and duplicate pack IDs. Hardware and driver compatibility remain
client-side activation checks.
To launch a visible offline preview of acdream with the built-in Atmospheric
pack and a disposable settings profile:
```powershell
.\tools\launch-atmospheric-preview.ps1 -Preset High
```
The preview starts `AcDream.App` with audio disabled, clears inherited
`ACDREAM_*` live/automation/diagnostic state for that child, and leaves the
user's normal acdream settings untouched. It records the binary identity,
selected audio mode, and separate stdout/stderr logs beside the disposable
profile. To exercise OpenAL explicitly, add `-EnableAudio`.
Vertex and fragment asset keys are independent opaque keys; they do not need
matching basenames or a host shader-directory stem. On explicit selection the
client opens each declared stream, validates it, copies the bytes into the
isolated candidate, and creates shader modules from those immutable blobs.
The pipeline retains neither the stream nor a path into the plugin directory.
Each stage must be little-endian, word-aligned SPIR-V no larger than 16 MiB.
Shader-visible pack settings are deliberately capped at 64 declarations. The
public `PackSettings` binding and value encoding are documented in the
[`semantic binding table`](semantic-bindings-v1.md#packsettings-set-3-binding-8-256-bytes).
A persisted user override wins the selected preset override, which wins the
declaration default. Overrides are stored by stable pack ID plus setting ID;
the host validates the selected descriptor's kind, invariant numeric grammar,
range, step, and choice list before supplying the resolved scalars. No renderer
object is exposed to managed code.
When a pack is selected, the retained Config page appends its declared
Boolean, bounded Float/Integer, and Choice controls under **Graphics
Enhancements**. Changing packs replaces only that optional tail; retail's 39
authored Config rows remain unchanged. Numeric controls snap to declared bounds
and steps, preset changes retain explicit user overrides, and a pack change
starts with an empty valid override map for the new stable pack identity.
## Manifest
The authoritative machine-readable schema is
[`plugin-manifest-v1.schema.json`](plugin-manifest-v1.schema.json).
| Field | Meaning |
|---|---|
| `id` | Stable lowercase logical plugin ID. It is persisted and must not be localized or reused. |
| `displayName` | User-visible plugin name. |
| `version` | Dotted `System.Version`-compatible package version. |
| `entryDll` | Safe path beneath the plugin directory to the managed entry DLL. |
| `apiVersion` | General `PluginApi` version. v1 is `1`; this is distinct from `RenderPackApi`. |
| `dependencies` | Optional plugin IDs that must load first. |
| `kinds` | Entry-point kinds. Include `renderPack`; omission means legacy `gameplay` only. A hybrid lists both. |
Install one plugin directory containing this manifest, the entry DLL, its
private managed dependencies, and declared shader assets. Do not redistribute
`AcDream.Plugin.Abstractions.dll` in that directory: type identity is shared
from the host.
## Declaration schema
The C# records in `AcDream.Plugin.Abstractions.Rendering` are the public v1
declaration schema. `RenderPackShaderAbi` publishes the corresponding numeric
SPIR-V set, binding, block-size, and capacity constants. They are intentionally
BCL-only and expose no Vulkan handle.
| Declaration | What the pack supplies | What the host owns |
|---|---|---|
| `RenderPackDescriptor` | Identity/version, highest tier, capabilities, resources, passes, replays, variants, presets, settings, atmosphere policy | Validation, candidate creation, activation and fallback |
| `RenderResourceDeclaration` | Logical ID, portable format, extent, usage, lifetime, estimated bytes | Images/buffers, allocation, barriers, frame-flight retirement |
| `RenderPassDeclaration` | Fixed hook, shader asset keys, semantic inputs, logical resource reads/writes | Render graph order, descriptor layout, pipeline, command recording |
| `SceneReplayDeclaration` | One supported replay semantic, caster flags, 14 views | Resident caster selection and existing batched submissions |
| `PipelineVariantDeclaration` | Base pipeline semantic, shader assets, compatible material flags, inputs | Visibility, mesh/material ownership, fixed renderer state |
| `RenderQualityPreset` | Capability requirements, resource/setting overrides, optional execution hints, and p50/p99 CPU/GPU/VRAM ceilings | Availability, explicit selection and stable-boundary swaps |
| `RenderSettingDeclaration` | Stable ID, kind, default, bounds/choices | Persistence and conditional Display UI |
| `AtmospherePolicyDeclaration` | Ordered sun and selected-light elevation curves plus explicit `activeDayGroup` multipliers | Authored Dereth clock, celestial source, day group, weather and indoor state |
IDs use `^[a-z][a-z0-9._-]*$`, are case-insensitively unique within each
declaration kind, and remain stable across updates. A pack must declare at
least one quality preset and at most 64 settings. The SDK ceiling is 256 MiB pack-owned resident GPU
memory, 16 MiB per SPIR-V asset, 16,384 pixels per absolute image dimension,
256 image layers, and four scene-replay views. A physical device may expose a
lower ceiling or reject a preset whose mandatory capabilities are absent.
For API v1 the host admits optional pack memory from one eighth of the selected
adapter's probed device-local heaps, capped at 256 MiB resident and 512 MiB
transient multisample storage. Presets remain listed with exact limit reasons.
Auto requires asynchronous timestamps and uses Low when Medium cannot fit. At
runtime, Auto alone watches the selected preset's declared inclusive-GPU p99,
pack-added CPU p99, and resident-GPU budgets. If Low remains over any of those
budgets for 180 stable samples, the whole pack fails safely to acdream's
default renderer with the measured and declared limits in the failure reason.
Explicit Low remains selectable and is not silently disabled by the Auto
performance policy.
A Tier-2 directional-shadow elevation curve must resolve to exactly zero at
and below the authored 0-degree horizon. Every declared non-positive point
must therefore have multiplier `0`; if the curve omits an exact 0-degree
point, its first positive point must also be `0` so endpoint clamping or
interpolation cannot manufacture a below-horizon directional shadow. The host still
owns the independent no-selected-light-energy and indoor gates.
The built-in Atmospheric Low preset preserves the complete directional-shadow
caster set (terrain, opaque and alpha-cutout world geometry, and both animated
classes). It reduces cost with two 768 x 768 shadow maps and the ordinary
six-pass, quarter-resolution separable post chain: sun occlusion, sun rays,
bloom downsample, horizontal blur, vertical blur, and filmic composition. It
does not remove a caster class or use the fused post-process hint.
`FusedAtmosphericPostProcess` is an optional external-pack Low-preset execution
hint for the standard atmospheric graph; it is not built-in Low behavior. An
opting-in shader pack implements the PackPass ABI below: the host feeds scene
depth directly to sun rays and asks filmic to evaluate the declared bloom
extraction and separable filter while composing the final image. This reduces
command recording without disabling rays, bloom, or filmic composition. The
host never infers the hint from pack identity; unknown hints, non-Low use, and
incomplete standard graphs fail validation.
`MultiviewDirectionalShadowCascades` is a separate explicit Low-preset
execution hint. The opting-in pack must implement three multiview caster
variants. The host records one layered directional-depth
pass with view mask `0b11`; `gl_ViewIndex` selects the exact two declared Low
cascade matrices. Terrain, opaque, and alpha-cutout commands retain their
ordinary pipeline, transform, cull, and cutout semantics. The preset must require
`MultiviewDirectionalShadowCascades`; unsupported hardware makes that Low preset
unavailable before allocation. A zero hint retains ordinary per-cascade passes.
Resources are declared in execution order: a pass cannot read a pack resource
before an earlier pass writes it, and one pass cannot read and write the same
resource. `WorldColor`, `SceneDepth`, and other renderer semantics are not pack
resources and are named in `SemanticInputs` instead. API v1 exposes four
sampled pass-input slots; buffers, `StructuredData`, and storage resources are
reserved enum values and are rejected until a public binding contract exists.
Colour image arrays are likewise reserved; v1 arrays are directional-depth
maps. One declared pass writes at most one attachment. Only `ToneMap` and
`AfterToneMapBeforePrivateViewports` may write directly to the host surface
without naming a pack resource.
Every semantic input implies its capability and the descriptor must list that
capability as required: world colour, scene depth/normals, authored sun/selected-
celestial/day/weather facts, animation transforms, and directional maps cannot
be treated as
optional after a pass unconditionally declares them.
## Lifecycle and versioning
- Discovery calls `Register` but does not open assets or allocate GPU objects.
- Installing a pack never selects it. The user selects a pack ID, version, and
preset; `acdream default (retail-faithful)` is always available.
- The client validates every declaration and selected asset, builds the full
candidate beside the active retail graph, then swaps at a frame boundary.
- Dispose the registration handle to withdraw the descriptor. The host also
withdraws every handle before unloading its collectible plugin context.
- `PluginApi` versions the general managed plugin ABI. `RenderPackApi` versions
these graphics declarations. Additive enum/record support stays compatible;
a breaking contract requires a new render-pack API version and explicit
compatibility path.
- `RenderPackShaderAbi.ShaderAbiVersion` separately versions the numeric
SPIR-V interface (set/binding numbers and std140 block layouts) declarations
are validated against — distinct from `RenderPackApi`/`PluginApi`. Campaign
VM VM6 shipped v2: `AtmosphericFrame` (set 3, binding 5) grew additively
from 160 to 192 bytes (see `docs/render-packs/semantic-bindings-v1.md`'s
"ABI v2 (additive)" section). `RenderPackSpirvValidator` accepts both the
v1 and v2 shapes, so shader assets compiled before a version bump — the
external sample packs among them — never need a rebuild for an additive
change.
- Persisted identity is pack ID + pack version + preset ID, never list index.
User-authored setting strings are keyed by the same stable pack identity and
stable setting ID, never declaration or menu index.
"Device recreation" in the v1 SDK means full teardown of the old renderer,
graphics context, and device, followed by construction and capability probing
of a fresh context/device. Retail is authoritative until a fresh pack candidate
validates and activates. The SDK does not promise live recovery of a pack or
renderer after `VK_ERROR_DEVICE_LOST`; that error is terminal to the old device
lifetime.
The complete campaign contract, budgets, and non-goals remain in
[`2026-08-21-atmospheric-rendering.md`](../plans/2026-08-21-atmospheric-rendering.md).
The public shader-facing contracts are the
[`semantic binding table`](semantic-bindings-v1.md) and
[`compatibility/failure guide`](compatibility-and-failure-v1.md).

View file

@ -0,0 +1,134 @@
# Render-pack compatibility and failure handling v1
**Campaign phase id:** **TBD**
Compatibility is a declaration and activation result, not a promise inferred
from a GPU brand. The client keeps unsupported packs visible with one exact
reason, refuses to select an unavailable preset, and continues rendering the
authoritative acdream default (retail-faithful) path.
## Author responsibilities
- Declare every mandatory facility in `RequiredCapabilities`. Use
`OptionalCapabilities` only when the pack has a deterministic path that does
not need it.
- Gate each preset independently. Low must remain semantically correct; lower
shadow resolution or reach rather than silently removing trees, monsters,
players, buildings, alpha cutouts, or animated transforms.
- Keep resource estimates conservative and below the preset and 256 MiB SDK
ceilings. The host clamps dimensions and bytes before allocation. Its
optional-pack memory policy admits at most one eighth of the selected
adapter's probed device-local heap, capped at 256 MiB resident and 512 MiB
transient multisample storage; the lower value wins and is printed in an
unavailable-preset reason.
- Use only declared hooks, semantic inputs, resources, scene replays and base
pipeline variants. Pack code receives no arbitrary per-frame callback,
command buffer, gameplay owner, RHI object, or Vulkan handle.
- Treat registration as pure declaration publication. `OpenRead` must return a
new readable stream for the exact requested key and must not retain a world
generation or borrowed frame state.
- Ship SPIR-V words little-endian, four-byte aligned, no larger than 16 MiB per
asset, and compatible with the published v1 semantic binding ABI. Both the
SDK and client validate the binary stage, `main` entry point, descriptor
allowlist, exact uniform/push layouts, and read-only storage contract before
pipeline creation. Vertex and fragment keys are independent logical keys;
the selected candidate copies their blobs and never resolves them through
the host shader directory.
- Declare no more than 64 settings and keep their descriptor order stable. The
set-3/binding-8 shader mapping is positional: a persisted user override wins
the selected-preset override, which wins the declaration default. Boolean
becomes 0/1, Choice becomes its zero-based choice index, numeric strings use
invariant culture, and unused or defensively invalid slots are zero. The
selected descriptor validates every user string against kind, range, step,
and choices before activation.
## Client transaction
1. Discover the manifest and descriptor without opening assets or constructing
GPU objects.
2. Compare required capabilities and preset ceilings with the active physical
device's probed `maxImageDimension2D`, `maxImageArrayLayers`, device-local
heap bytes, and format/timestamp support. An unsupported pack remains
installed and its individual presets remain visible with exact
needed-versus-provided reasons.
3. After explicit selection, validate every referenced asset and shader
interface, then build every resource and pipeline in an isolated candidate.
4. Activate the complete candidate at a stable frame boundary. Until that
point retail keeps rendering.
5. If any step fails, retire the candidate through normal GPU-flight fences,
record one stable diagnostic, select `acdream default`, and do not retry
that pack again during the session.
No half-enabled graph is valid. A missing bloom shader does not leave shadows
active; a failed shadow pipeline does not leave a world-colour intermediate or
stale descriptor alive.
Auto is a logical selector rather than an allocated preset. It requires
asynchronous GPU timestamps, starts at Medium when Medium fits, otherwise
starts and stays at Low, and never promotes beyond the highest contiguous
compatible preset. If Low itself cannot fit, Auto fails safely to Retail and
reports the Low limit that failed.
Runtime Auto decisions use the active preset's declared inclusive-GPU p99,
pack-added CPU p99, and resident-GPU budgets. An over-budget Medium selection
can step down to Low; if Low then remains over any declared limit for 180
stable samples, the host atomically deactivates the complete pack, reports the
measured and budget values, and enters `FailedToRetail` without a retry loop.
This performance fallback is Auto-only. Explicit Low remains selectable when
only timestamp support is missing and is never silently reduced by removing
terrain, trees, buildings, monsters, players, alpha cutouts, or animated
casters. The built-in Low preset instead uses two 768 x 768 shadow maps and an
unfused six-pass, quarter-resolution separable post chain. An ordinary explicit
Low validation, candidate-build, or runtime failure still follows the complete
transactional fallback rules above.
## Diagnostic categories
| Category | Example user-facing reason | Recovery |
|---|---|---|
| Manifest | `plugin.json does not declare the renderPack kind` | Correct/reinstall the package |
| Managed ABI | `apiVersion 2 is unsupported; this SDK supports 1..1` | Use a compatible client or rebuild the pack |
| Pack ABI | `requires render-pack API 2; this client supports 1..1` | Same as above |
| Capability | `requires unsupported capability DirectionalShadowMaps` | Select a supported preset/device or retail |
| Declaration | `Pass 'blur' reads resource 'bloom-a' before it is written` | Correct the descriptor |
| User setting | `user override 'exposure' has invalid Float value '1,5'` | Correct/remove that stable setting-ID override; retail remains active |
| Asset | `asset 'bloom.frag.spv' is not valid SPIR-V` | Rebuild/reinstall the pack |
| Shader interface | `AtmosphericFrame must match ABI v1 (seven members, 160 bytes) or ABI v2 (nine members, 192 bytes)` | Recompile against the v1 or v2 binding table — v2 is additive over v1, so existing v1 modules remain valid and need no rebuild; this fires only when a module's `AtmosphericFrame` block matches neither accepted layout |
| Resource ceiling | `preset 'high' exceeds the pack memory ceiling` | Reduce the preset declaration |
| Auto performance | `Low remained over its declared performance budget for 180 stable samples` | Complete pack falls back to Retail; select explicit Low only after reviewing the measured limits |
| Candidate build | `pipeline creation failed for 'directional-shadow-world-cutout'` | Driver/asset diagnosis; retail for this session |
| Runtime/device | `selected pack failed validation on the fresh device` | Retail on the fresh renderer for this session; no retry loop |
| Removal/update | `selected pack is no longer installed` | Retail, while retaining the notice |
Diagnostics and screenshot metadata record pack ID, pack version, preset ID,
compatibility result and fallback reason. Enhanced screenshots are not retail
parity evidence.
## Update and removal
Pack IDs remain stable across compatible updates; increment `PackVersion` and
manifest `version` together. A preset or setting ID that persists must keep its
meaning. User values are persisted as invariant strings under the selected
pack ID and setting ID, so declaration reordering cannot retarget a value. If
an update removes or changes a persisted setting incompatibly, selection fails
atomically to retail with the unknown/invalid override reason instead of
silently applying it elsewhere. If an update removes the selected preset, the client falls back to a
compatible declared preset only after explicit policy permits it; otherwise it
selects retail. Removing or unloading a pack first withdraws registrations,
then retires GPU-flight resources, then releases the collectible load context.
The built-in atmospheric pack's `sun-shadow-*` setting IDs predate the
selected-celestial source contract. They remain stable persisted identifiers;
their current labels and semantics apply to directional shadows from whichever
authored celestial source the renderer selects.
Reconnect, portal travel, resize and world-generation replacement do not
re-register managed packs. Renderer-owned resources are recreated or retired
within the same generation/fence rules; pack assets never own gameplay,
streaming, collision, or physics lifetime.
For v1, device recreation is not an in-place `VK_ERROR_DEVICE_LOST` recovery
path. The host tears down the complete old renderer, context, and device, then
constructs and probes a new context/device. The default retail renderer remains
authoritative while the selected pack is validated as a fresh candidate; a
failed candidate stays on retail without an automatic retry loop.

View file

@ -0,0 +1,68 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "urn:acdream:render-pack:plugin-manifest:v1",
"title": "acdream plugin manifest v1",
"description": "Manifest shared by gameplay plugins and declarative render packs. A render pack includes renderPack in kinds.",
"type": "object",
"required": [
"id",
"displayName",
"version",
"entryDll",
"apiVersion"
],
"properties": {
"$schema": {
"type": "string"
},
"id": {
"type": "string",
"minLength": 1,
"maxLength": 128,
"pattern": "^[a-z][a-z0-9._-]*$",
"description": "Stable plugin identity. It is persisted; do not reuse or localize it."
},
"displayName": {
"type": "string",
"minLength": 1
},
"version": {
"type": "string",
"pattern": "^[0-9]+(?:\\.[0-9]+){1,3}$",
"description": "Dotted System.Version-compatible package version."
},
"entryDll": {
"type": "string",
"minLength": 5,
"maxLength": 512,
"pattern": "^(?![A-Za-z]:)(?!/)(?!.*(?:^|/)\\.\\.(?:/|$))[^\\\\]+\\.[dD][lL][lL]$",
"description": "Safe forward-slash relative path to the managed entry assembly."
},
"apiVersion": {
"type": "integer",
"const": 1,
"description": "AcDream.Plugin.Abstractions PluginApi version, not RenderPackApi."
},
"dependencies": {
"type": "array",
"uniqueItems": true,
"items": {
"type": "string",
"pattern": "^[a-z][a-z0-9._-]*$"
},
"default": []
},
"kinds": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": {
"type": "string",
"enum": ["gameplay", "renderPack"]
},
"default": ["gameplay"],
"description": "Omitting kinds preserves legacy gameplay-plugin behavior. A render pack must explicitly include renderPack."
}
},
"additionalProperties": true
}

View file

@ -0,0 +1,424 @@
# Render-pack shader ABI and semantic bindings v1
**Campaign phase id:** **Campaign AR**
**Render-pack API:** `1`
This is a SPIR-V binary contract over renderer-owned descriptors. It does not
expose Vulkan descriptor sets, descriptor handles, images, buffers, samplers,
command buffers, devices, queues, or fences to managed pack code. A pack only
declares semantic inputs and supplies SPIR-V; the renderer validates the
interface and binds immutable frame data.
## Semantic execution and stable identity
`RenderResourceSemantic`, `RenderPassSemantic`,
`RenderPipelineVariantSemantic`, `RenderQualitySemantic`, and
`RenderSettingSemantic` select renderer-owned execution roles. Pack-owned IDs
remain stable persistence, UI, graph-edge, and diagnostic keys; the executor
never recognizes a role by comparing an ID or asset-name string. Custom
fullscreen declarations retain `Custom` semantics and are executed from their
declared hooks and edges.
Every non-custom semantic is unique within its declaration kind. The complete
Tier-2+ atmospheric executor requires its exact v1 pass/resource/pipeline-
variant roles, hook order, scene replay, and graph edges. A Tier-2 pack may
instead declare the directional-shadow component plus the technical custom
`WorldColor` tone-map copy needed to present the HDR world. That profile still
requires the exact shadow depth resource/pass, five caster/receiver variants,
headline-caster replay, capabilities, settings, and elevation policy; it does
not require bloom, rays, grading, vignette, or volumetric shafts. Any other
partial or malformed semantic graph fails validation even when all pack-owned
IDs remain syntactically valid.
Directional-shadow declarations use
`SelectedCelestialDirectionalLight` together with the required
`AuthoredCelestialDirectionalLight` capability and the descriptor's
`DirectionalShadowLightElevationResponse`. `SunDirection`,
`SunElevationResponse`, and `VolumetricShaftSunElevationResponse` remain the
separate sun-specific atmosphere contract for rays and shafts.
The public v1 point record remains named `SunElevationResponsePoint` for ABI
compatibility; points stored in `DirectionalShadowLightElevationResponse` are
interpreted against the selected celestial light's elevation.
## Fixed descriptor ownership
| Set | Binding | Shader declaration | Owner and use |
|---:|---:|---|---|
| 3 | 5 | `AtmosphericFrame` std140 uniform block | Renderer-owned camera/reconstruction, authored sun/day/weather and frame facts |
| 3 | 6 | `DirectionalShadow` std140 uniform block | Renderer-owned cascade matrices, splits, shadow texture slot, shadow policy, and selected authored celestial direction |
| 3 | 7 | `PackPass` std140 uniform block | Renderer-resolved pass resource slots, output facts and pass-local parameters |
| 3 | 8 | `PackSettings` std140 uniform block | Renderer-resolved declaration-order scalar values for the selected preset |
| 2 | 0 | `sampler2DArray uTextures[]` combined-image-sampler array | Existing global sampled-texture table; index with host-supplied slot IDs and `nonuniformEXT` |
Set 0 remains the renderer's existing storage-buffer set. A declared base
pipeline variant inherits the exact renderer pipeline ABI it specializes; it
does not gain arbitrary set-0 storage access. In particular,
`ShadowCasterTransforms` reuses the renderer's existing per-instance transform
publication rather than publishing a second animation pose.
Set 1 remains the current retail uniform layout at bindings 14. Pack shaders
must not redeclare or alias it. Set 3 is strictly opt-in: retail pipeline
layouts contain only sets 02, and the host creates no set-3 Vulkan object
until a validated pack pipeline is activated. Bindings other than those in the
table are reserved and validation rejects them.
## SPIR-V interface validation
Candidate activation and the standalone authoring validator inspect the
actual SPIR-V binary before any shader module or pipeline is created. Each
asset must expose exactly the declared vertex or fragment stage with entry
point `main`. A fullscreen pass may declare only the sampled table at set 2,
binding 0 when its declaration supplies a sampled semantic/resource input,
plus the role-appropriate set-3 blocks. It may not access renderer-private set
0 or retail set 1. A retained-scene pipeline variant may use only the base
set-0/set-1 bindings documented for that exact semantic role, plus its allowed
set-2/set-3 bindings.
Validation checks descriptor type and count, all set-3 uniform-block member
types, offsets, strides, and total shapes, and any declared push block against
the exact 96-byte retail layout below. Renderer storage buffers inherited by a
variant must be read-only; storage images, arbitrary storage descriptors, and
`OpImageWrite` are forbidden. An absent, malformed, aliased, writable, or
undeclared interface rejects the whole candidate atomically to retail with a
specific reason. Validation never exposes or accepts a Vulkan handle.
The binary member layout is validated against matching host structs. The
checked-in shared render-pack GLSL includes are the byte-offset SSOT; authors
include those definitions rather than maintaining a private copy. The tables
below state the same values for review and tool diagnostics.
### `AtmosphericFrame` — set 3, binding 5, 192 bytes (ABI v2; see below)
```glsl
layout(std140, set = 3, binding = 5) uniform AtmosphericFrame {
vec4 uAtmosphereSunScreen; // @0: uv.xy, resolved ray strength, elevation degrees
vec4 uAtmosphereSunColor; // @16: authored display-space rgb (retail has no linear pipeline), combined ray-policy multiplier
vec4 uAtmosphereViewport; // @32: width, height, 1/width, 1/height
vec4 uAtmosphereWeather; // @48: WeatherKind numeric, intensity, delta seconds, outdoor 0/1
vec4 uAtmosphereSunDirection; // @64: surface-to-sun xyz, authored direction brightness
vec4 uAtmospherePolicy; // @80: day group, group factor, shadow factor, shaft factor
mat4 uAtmosphereInverseViewProjection; // @96
// ABI v2 (Campaign VM VM6) — additive, see below:
vec4 uAtmosphereClockWind; // @160: elapsed seconds, wind mean [0..1], wind gust [0..1], wind direction radians
vec4 uAtmosphereWindAmplitude; // @176: lean amplitude m, branch amplitude m, flutter amplitude m, max canopy height m
};
```
`uAtmosphereSunScreen.xy` uses normalized main-world viewport coordinates. The two
strength fields are host-evaluated authored/policy facts; they do not create a
second sky or weather owner. `uAtmosphereWeather.x` is numerically integral and must be
interpreted with this v1 table, not guessed from colour or time:
| Numeric value | Weather kind |
|---:|---|
| 0 | Clear |
| 1 | Overcast |
| 2 | Rain |
| 3 | Snow |
| 4 | Storm |
All other values are reserved. `uAtmosphereWeather.y` is the transition
intensity in the inclusive range 01.
`uAtmosphereSunDirection.xyz` is normalized and points from a lit surface
toward the authored sun. `uAtmosphereSunScreen.z` is the resolved visible ray
strength; `uAtmosphereSunColor.w` is the combined ray elevation/day-group/
weather policy multiplier before a pass's own declared setting. In
`uAtmospherePolicy`, `.x` is the numerically integral active day group, `.y` is
that group's declared multiplier, `.z` is the declared directional-shadow
elevation factor, and `.w` is the declared volumetric-shaft elevation factor.
Shadow curves interpolate in sine-of-elevation space; shaft curves use
smoothstep interpolation in elevation-degree space. These are exact values
from the selected pack's `AtmospherePolicyDeclaration`, not built-in fallback
curves. An accepted directional-shadow curve resolves to exactly zero at and
below the authored 0-degree horizon; non-positive points must be zero, and a
curve without an exact 0-degree point must make its first positive point zero.
`uAtmosphereInverseViewProjection` reconstructs main-world positions
from scene depth and the normalized viewport coordinates. Matrix convention
and depth range match the shared push-block `viewProjection`.
### ABI v2 (additive) — Campaign VM VM6
`AtmosphericFrame` grew from 160 to 192 bytes by appending the two members
shown above after `uAtmosphereInverseViewProjection`. Nothing before offset
160 moved or changed meaning.
`uAtmosphereClockWind`/`uAtmosphereWindAmplitude` feed the shared
`foliage_wind.glsl` include, which `mesh_atmospheric.vert` and the four
`directional_shadow_world_*` (opaque/cutout, base and multiview) caster
vertex shaders call identically so a displaced leaf's shadow moves with it.
No other pass reads these members.
**Compatibility rule:** the host always allocates and binds the full 192-byte
v2 block (`RenderPackShaderAbi.AtmosphericFrameSizeBytes`), but a v1 shader —
one compiled before this campaign, declaring only the original seven members
— binds and reads correctly against it: a bound range only needs to be at
least as large as the block's declared size, so the shader simply never sees
the appended bytes. `RenderPackSpirvValidator.ValidateAtmosphericFrame`
accepts either the v1 shape (seven members, 160 bytes) or the v2 shape (nine
members, 192 bytes); any other member count is rejected naming both. This is
why the external sample packs under `samples/*/Shaders/*.spv` — whose GLSL
sources are not in this tree and are never recompiled — needed no rebuild for
this change.
### Colour space
The main-world colour target, `uAtmosphereSunColor` (sun-ray input), and any
pack-written sun-ray or volumetric-shaft colour are all **display-space**
(retail's 2013 client has no linear lighting pipeline — its fixed-function
output is gamma-encoded for direct display). A pack that does linear-space
math — bloom thresholding, ACES or another filmic tonemap, luma-weighted
saturation, a contrast pivot — must decode each such input before that math
and encode its final output before writing to the UNORM swapchain, or the
math is operating on the wrong numbers (Campaign VM VM3, closing finding F4 of
the Campaign AR review). The built-in Atmospheric pack's
`acdreamDecodeDisplay`/`acdreamEncodeDisplay` helpers in
`atmospheric_common.glsl` (`pow(c, 2.2)` / `pow(c, 1/2.2)`) are the reference
implementation; 2.2 is the retail-era display-gamma assumption, deliberately
not the sRGB piecewise curve.
### `PackPass` — set 3, binding 7, 64 bytes
```glsl
layout(std140, set = 3, binding = 7) uniform PackPass {
vec4 uPackParams0; // @0
vec4 uPackParams1; // @16
vec4 uPackParams2; // @32
vec4 uPackParams3; // @48
};
```
The active semantic pass defines these sixteen scalar meanings. Unused values
are zero. A pass cannot reinterpret values owned by a different pass. Sampled
pass inputs use logical `textureIndexA` through `textureIndexD` in the shared push block;
binding 7 carries scalar/vector policy and filter parameters, not descriptors.
The optional external-pack Low-preset `FusedAtmosphericPostProcess` execution
hint uses these fixed values. The built-in Low preset does not declare it:
| Semantic pass | Values |
|---|---|
| `SunRays` | `uPackParams1 = (1, logicalMaskWidth, logicalMaskHeight, 0)`; input A is scene depth and the shader reconstructs the declared RGBA8 sun mask before radial integration |
| `BloomDownsample` / both `BloomBlur` passes | Declared for the standard graph but not recorded for this preset; their threshold, knee, strength, offsets, and weights remain authoritative inputs to filmic |
| `FilmicComposite` | `uPackParams1.z = 1`; `uPackParams2 = (bloomStrength, threshold, knee, hasVolumetric)`, `uPackParams3.xy = logicalBloomTexelStep`; inputs A/B/C are world color, sun rays, and optional volumetric shafts, and filmic evaluates the full separable bloom kernel before composition |
Zero flags retain the ordinary six-pass atmospheric graph. The built-in Low
preset uses that zero-flag path; Medium, High, and external packs that do not
opt in never use this fused ABI.
### Multiview directional-shadow cascades
The optional Low-preset `MultiviewDirectionalShadowCascades` execution
hint requires the three `*MultiviewDirectionalShadowCaster` variants and the
matching capability in the Low preset. The host begins one layered depth pass
with `viewMask = 0b11`; each vertex shader indexes `uShadowWorldToClip` with
`gl_ViewIndex`. Commands retain exact order, `BaseInstance`, the shared N.5
world-transform arena, texture index/layer, fixed-function culling, alpha cutoff
`0.05`, and both fitted cascade matrices. With no hint the host records the
ordinary one-pass-per-cascade path. Unsupported hardware makes the hinted preset
unavailable rather than silently selecting an over-budget execution form.
### `DirectionalShadow` — set 3, binding 6, 336 bytes
```glsl
layout(std140, set = 3, binding = 6) uniform DirectionalShadow {
mat4 uShadowWorldToClip[4]; // @0, @64, @128, @192
vec4 uShadowSplitFarMeters; // @256
vec4 uShadowControl; // @272
vec4 uShadowBiasMeters; // @288
uvec4 uShadowTextureAndFlags; // @304
vec4 uShadowLightDirectionAndSource; // @320
} directionalShadow;
```
Field meanings are fixed:
| Field/component | Meaning |
|---|---|
| `uShadowWorldToClip[0..3]` | Texel-stabilized world-to-shadow-clip matrices; only the first `cascadeCount` entries are active |
| `uShadowSplitFarMeters` | Far distance of cascades 03 in camera-eye metres |
| `uShadowControl.x` | Directional shadow strength |
| `uShadowControl.y` | Filter softness |
| `uShadowControl.z` | Maximum shadow reach in metres, clamped to resident data |
| `uShadowControl.w` | Cascade blend width in metres |
| `uShadowBiasMeters.x` | Constant receiver/caster bias in world metres |
| `uShadowBiasMeters.y` | Slope-scaled bias in world metres |
| `uShadowBiasMeters.z` | Normal offset in world metres |
| `uShadowBiasMeters.w` | Caster depth padding in world metres |
| `uShadowTextureAndFlags.x` | Directional-depth array slot in set 2 |
| `uShadowTextureAndFlags.y` | Active cascade count, 14 |
| `uShadowTextureAndFlags.z` | Square shadow-map resolution in pixels |
| `uShadowTextureAndFlags.w` | Flags; bit 0 means directional shadows are valid/enabled; bits 811 carry the fixed receiver PCF radius; remaining v1 bits are reserved and zero |
| `uShadowLightDirectionAndSource.xyz` | Normalized direction from a lit surface toward the one authored celestial body selected for this shadow frame |
| `uShadowLightDirectionAndSource.w` | Numerically integral selected-source kind from the table below |
Selected-source kinds are stable ABI values:
| Numeric value | Selected celestial source |
|---:|---|
| 0 | None / unavailable; the enabled flag must be clear |
| 1 | Authored sun |
| 2 | Dominant authored Dereth moon |
| 3 | Secondary authored Dereth moon |
All other values are reserved. The selected source is a renderer-owned fact
resolved from the current immutable Dereth sky frame. A pack does not identify
sky objects by private index or create a second celestial clock. The host still
publishes only one directional-depth array: sun and moons are alternative
sources for the same bounded cascade work, not simultaneous shadow maps.
When producing a cascade, its zero-based cascade index uses the existing
`uRenderPass` push-constant member. Consumer shaders choose a cascade from the
eye-space distance and split values. No available selected celestial source, a
selected source at or below its accepted horizon, no authored directional
energy, indoors, and portal/login cover clear the enabled bit; shaders must not
sample stale maps when it is zero. Binding 5 remains sun-specific for sun rays
and volumetric shafts. Such passes must not substitute the selected moon
direction for `uAtmosphereSunDirection`; when binding 6 selects a moon they
treat its shadow map as unrelated to sun-shaft occlusion.
## Shared push constants
Every pipeline retains retail's exact shared 96-byte push-constant range. API
v1 does not enlarge it:
```glsl
layout(push_constant) uniform AcdreamPushBlock {
mat4 viewProjection; // byte 0
int drawIdOffset; // byte 64
int lightingMode; // byte 68
int renderPass; // byte 72
int lightDebug; // byte 76
uint textureIndexA; // byte 80
uint textureIndexB; // byte 84
float paramA; // byte 88
float paramB; // byte 92
} acdreamPush;
```
Pack shader source may use the logical aliases `uTextureIndexC` and
`uTextureIndexD`. The host stores their uint slot bits in the existing
`paramA` and `paramB` words, and the shared Vulkan preamble exposes them as
`floatBitsToUint(acdreamPush.paramA)` and
`floatBitsToUint(acdreamPush.paramB)`. This is an exact bit reinterpretation,
not numeric float conversion. Pack pass scalar/vector parameters belong in
binding 7, so the two spare retail words are available for these input slots.
Do not reshape existing fields. A future additive growth requires matching
host/shader layout tests and must remain inside the 128-byte Vulkan guarantee.
A pack pass receives scalar/vector values through binding 7. Base pipeline
variants use `viewProjection`, draw offset, texture slots and existing mode
fields according to that base pipeline's contract.
## PackSettings (set 3, binding 8, 256 bytes)
`RenderSettingDeclaration` values use one fixed declaration-order block:
```glsl
layout(std140, set = 3, binding = 8) uniform PackSettings {
vec4 uPackSettings[16];
};
```
The descriptor may declare at most 64 settings, which the authoring validator
and graphical host both enforce. Setting index `i` is its zero-based position in
`RenderPackDescriptor.Settings`; it maps to
`uPackSettings[i / 4][i % 4]`. Declaration order is therefore shader ABI and
must remain stable within a compatible pack version. The resolved value is one
IEEE-754 float: Boolean is `0.0` or `1.0`, Choice is its zero-based index in
`Choices`, and Integer/Float parse with invariant culture before float
conversion. Integer values are limited to the exactly representable inclusive
range -16,777,216..16,777,216; Float values must remain finite in float32. A
matching persisted user override wins a selected-preset `SettingOverride`,
which wins the declaration default. User values remain invariant strings keyed
by stable pack ID plus setting ID; before candidate activation the host rejects
unknown IDs and values that fail kind, range, step, or choice validation. That
failure retires the complete candidate to retail with an exact reason. The host
zero-initializes the complete block, so unused slots and any value that fails
defensive parsing are `0.0`; ordinary descriptor/selection validation prevents
invalid values from reaching the bind.
Binding 7 remains pass-local host dynamics and filter parameters. It must not
be overloaded with pack settings: doing so would make the same setting occupy
different components in different passes and would prevent one stable public
mapping. This fixed block is the complete v1 contract because it adds no
descriptor handles, storage buffers, per-frame managed callbacks, or
pass-specific setting schemas.
## Logical semantic table
The descriptor must list every semantic the shader reads. Listing a semantic
does not guarantee device support; the corresponding `RenderCapability` must
also be required when the table says so.
| `RenderSemanticInput` | Logical shader value | Source / lifetime | Capability prerequisite |
|---|---|---|---|
| `WorldColor` | Sampled main-world colour slot, excluding retained UI and private viewports | `textureIndexA-D` into set 2; current main-world frame | `MainWorldColorIntermediate` |
| `SceneDepth` | Sampled main-world depth slot plus reconstruction matrix | `textureIndexA-D` and binding 5; current main-world frame | `SceneDepthSampling` |
| `SceneNormals` | Sampled main-world normal slot | `textureIndexA-D` into set 2; current main-world frame | `SceneNormalSampling` |
| `SunDirection` | Normalized authored surface-to-sun direction; no second clock | Binding 5 `uAtmosphereSunDirection`; current immutable world frame | `AuthoredSunDirection` |
| `SelectedCelestialDirectionalLight` | Normalized direction and stable source kind for the one authored sun/moon selected to cast this frame's directional shadows | Binding 6 `uShadowLightDirectionAndSource`; current immutable world/sky frame | `AuthoredCelestialDirectionalLight` |
| `SunScreenPosition` | Authored sun projected for the main-world viewport, plus valid/in-front state | Binding 5; current camera/world frame | `AuthoredSunScreenPosition` |
| `ActiveDayGroup` | AC's categorical group plus descriptor-declared group/elevation multipliers | Binding 5 `uAtmospherePolicy`; current Runtime environment frame | `AuthoredWeather` |
| `Weather` | Numeric `WeatherKind`, intensity and outdoor state | Binding 5 `uAtmosphereWeather`; current Runtime environment frame | `AuthoredWeather` |
| `CameraMatrices` | Main-world view-projection and inverse, or directional cascade transforms required by the hook | Push `viewProjection` + binding 5 inverse; binding 6 for cascades | None beyond the hook's feature capability |
| `ShadowCasterTransforms` | Exact existing per-instance/per-part transforms for retained eligible casters | Inherited base-pipeline set-0 ABI; current retained scene | `AnimatedCasterTransforms` |
| `DirectionalShadowMaps` | Directional-depth table slot, active cascade count, matrices, splits and valid state | Binding 6 plus set 2; current outdoor shadow frame | `DirectionalShadowMaps` |
| `FrameTime` | Monotonic frame delta in seconds; never a gameplay clock | Binding 5 `uAtmosphereWeather.z`; current frame | None |
Pack-declared `ResourceReads` are resolved deterministically to the pass input
slots supplied by the host; v1 exposes up to four sampled inputs through
`textureIndexA-D`. `DirectionalShadowMaps` uses the binding-6 texture slot and
does not consume A-D. Slot assignment first walks sampled-image entries in
`SemanticInputs` declaration order (`WorldColor`, `SceneDepth`, and
`SceneNormals` when present), then sampled `ResourceReads` declaration order.
The first input receives A, then B, C, and D. Duplicate inputs are invalid. The
pack never chooses a global texture-table index. Resource IDs describe graph
edges, not binding numbers. `ResourceWrites` are render targets chosen by the
host and are not simultaneously sampled by the same pass.
`RenderResourceKind.Buffer`, `RenderFormatClass.StructuredData`, and
`RenderResourceUsage.Storage` are reserved for an additive future contract.
They have no public v1 descriptor binding and the v1 authoring validator
rejects them instead of accepting an unbindable graph. V1 image arrays are
reserved for `DirectionalDepth`; ordinary colour intermediates are `Image2D`.
Each pass writes at most one declared attachment. A zero-write pass is valid
only at `ToneMap` or `AfterToneMapBeforePrivateViewports`, where the host-owned
main-world target is implicit.
## Texture-table sampling
Vulkan pack SPIR-V targets the same global table as retail shaders:
```glsl
#extension GL_EXT_nonuniform_qualifier : require
layout(set = 2, binding = 0) uniform sampler2DArray uTextures[];
vec4 sample2D(uint slot, vec2 uv) {
return texture(uTextures[nonuniformEXT(slot)], vec3(uv, 0.0));
}
```
An ordinary 2-D texture is a one-layer array at layer zero. Array resources use
their declared layer. Directional depth may be sampled as ordinary depth and
compared/filtered in shader according to the declared shadow policy; the pack
does not create a private sampler or descriptor. `0xFFFFFFFFu` is the
unassigned texture-slot sentinel and must be checked before sampling an
optional input.
## Hooks and availability
| `RenderPassHook` | Inputs valid at the hook | Output boundary |
|---|---|---|
| `ShadowDepthBeforeWorld` | Camera/selected celestial source/environment, cascade block, retained caster transforms | Declared directional-depth resources only; outdoor gating applies |
| `AtmosphereBeforeToneMap` | HDR world colour when required, depth/normals when required, authored atmosphere and earlier declared resources | HDR pack intermediates; rays/shafts composite here |
| `ToneMap` | HDR world colour and earlier atmosphere resources | Main-world display colour |
| `AfterToneMapBeforePrivateViewports` | Tonemapped main-world colour and declared resources | Main world only; private viewports and retained UI remain outside |
Pass order is the descriptor order within a hook and never moves backward
through this table. Discovery does not bind any of these blocks. Bindings exist
only in the fully validated candidate and retire through normal frame-flight
fences on fallback, resize, portal, reconnect, unload, or the teardown phase of
device recreation. Recreation means a complete renderer/context/device
teardown followed by a fresh context/device; it is not live recovery from
`VK_ERROR_DEVICE_LOST`.

View file

@ -1,3 +1,13 @@
> **CORRECTION 2026-08-23 (#427).** This note assumed retail fogs its sky
> meshes ("the sky dome mesh is at a distance where fog contribution
> dominates"). It does not: `GameSky::Draw @0x00506FF0` disables fixed-
> function fog around the entire sky draw unless an AdminEnvirons fog
> override is active. The dome's horizon colour is the authored texture plus
> the keyframe tint. The `SKY_FOG_FLOOR = 0.2` clamp that this assumption
> produced was removed the same day. Q3's terrain-fog conclusions (authored
> `MinWorldFog/MaxWorldFog` applied directly, no scaling) stand and are now
> what the client does.
# Sky Fog — How Retail Applies Fog to Sky Meshes (Decompile Trace)
**Date:** 2026-04-23

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

@ -1,5 +1,26 @@
# Retail client slash-command registry — complete enumeration + acdream audit
> **CORRECTION 2026-08-21 — the "acdream status" columns below are STALE.**
> They record the state on 2026-08-09, BEFORE slice CH4 landed. Every verb this
> document calls MISSING has since been added and verified present in
> `ChatInputParser.ChannelVerbs`: `cg`, `soc`, `o`, `co-vassals`, `covassal`,
> `c`, `fellows`, `group`, `party`, `vassal`, `ab`, `guild`, `gu`, `ct`,
> `clfg`, `crp`. The DIVERGENT row for `g` was also corrected — acdream now
> maps `/g` to Fellowship, matching retail, confirmed against the live retail
> client on 2026-08-21.
>
> The RETAIL side of this document (the registered verbs, handler addresses and
> channel ids) remains accurate and is still the authority. Only the columns
> describing what acdream does are out of date; verify against
> `ChatInputParser.cs` before trusting them.
>
> **CORRECTION 2026-08-28 — issue #360 is complete.** The full local
> `@allegiance`/`@all`, `@house`/`@hou`, and standalone `@motd` dispatcher
> families are implemented with the named-retail grammar and exact GameAction
> wire layouts in both graphical and headless hosts. The older MISSING status
> cells in §2.5/§2.5b and the priority list are historical.
Date: 2026-08-09
Status: RESEARCH ONLY. No production code was changed.

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:

View file

@ -199,6 +199,27 @@ parsed at `CreateObject.cs:611-619`) — reused rather than re-defined.
`RuntimeHouseState` raw-field owner), and file the seven line builders
as an ISSUES entry rather than porting them tonight.
### 2026-08-28 owned-house closeout
Issue #413 completed this deliberately deferred builder chain. Direct byte
disassembly of the PDB-paired `acclient.exe` recovered the literals and the
x87-obscured branches:
- buy and rent rows use `HousePaymentList::ComposeText`/`ComposeText2` with
retail's singular/plural fallback (`s`, or `es` after lowercase `s`/`x`);
- normal houses use 2,592,000-second periods and apartments use 7,776,000;
paid/maintenance-free rent advances the next-due row by two periods;
- location is `Location: %.1f%s, %.1f%s`, Y/S-N first and X/W-E second;
apartments emit no location;
- paid and unpaid warnings select `HousePanelTextColor` indices 1 and 2
through the row template's authored font-color palette;
- 0x0227 installs the new rent time and clears paid counts, while 0x0228
replaces the rent list; both rebuild the whole display just as retail does.
`RuntimeHouseStateTests`, `LiveSessionEventRouterTests`, and
`MapHousePanelControllerTests` cover the full synthetic wire/state/UI path.
The canonical Release gate passes 16,315/16,315 on 2026-08-28.
## Wire — GameEventType already has all four ids (corrects the handoff)
The handoff claimed "0x0227/0x0228 absent from the enum". Checked

View file

@ -0,0 +1,211 @@
# Handoff — VTank-class plugin automation milestone
**Written 2026-08-20 for a fresh session picking this up cold.**
You are inheriting a completed requirements-research phase and an unstarted
implementation. Nothing has been built yet. The research is thorough and was
done at `xhigh` effort against primary sources — **read it before designing
anything, and do not re-derive it.** This document tells you where everything
is, what has changed since the research was written, and what to do first.
---
## 1. Start here, in this order
| # | Path | Why |
|---|---|---|
| 1 | [`docs/research/2026-07-29-vtank-plugin-automation-requirements.md`](2026-07-29-vtank-plugin-automation-requirements.md) | **The research.** 384 lines. Full VTank capability inventory, the implied host-API surface, mapping to acdream, and the 5-step milestone. |
| 2 | [`docs/plans/2026-07-29-post-vulkan-work-intake.md`](../plans/2026-07-29-post-vulkan-work-intake.md) | Where the milestone is filed (C-bucket candidate), line 35. |
| 3 | `claude-memory/project_plugin_requirement.md` (see note below) | Why plugins are a day-1 architectural constraint, not a nice-to-have. |
| 4 | `CLAUDE.md` § "Code Structure Rules" rule 3, and § "UI strategy" | The binding constraints on this work (see §6 below). |
Rows 12 are committed to the repo. Paths verified 2026-08-20.
> **`claude-memory/` is a junction, not tracked content.** It resolves in the
> main checkout (`C:\Users\erikn\source\repos\acdream`) but **not inside a git
> worktree**, and `git ls-files claude-memory/` returns nothing. The files
> really live under
> `C:\Users\erikn\.claude\projects\C--Users-erikn-source-repos-acdream\memory\`.
> If you are working in a worktree and a `claude-memory/...` path does not
> resolve, that is why — read it from the main checkout or the real path.
---
## 2. The architectural conclusion — do not relitigate
VTank's meta state machine, expression language and loot-rule engine are
**plugin-land, not host-land.** VTank itself was built on Decal's primitives;
acdream ships the equivalent primitive layer and a VTank-like engine then
becomes an acdream plugin — potentially file-compatible with `.met` / `.nav` /
`.utl`, whose encodings the research already decodes.
The second conclusion: **the K2 headless-bot triad is already the right
substrate.** Synchronous borrowed reads, generation-gated typed commands with
attempt semantics, and an ordered delta stream are exactly what a VTank-class
engine needs, and VTank's 293 ms meta tick maps cleanly onto `Tick`. So this
milestone is largely a bridging and query-surface exercise, not new
architecture.
---
## 3. The five steps (dependency-ordered)
1. **Plugin↔Runtime bridge — the enabler, gates everything else.** Mirror the
K2 triad into BCL-only `Plugin.Abstractions`: per-plugin
`Tick(view, commands)`, generation-gated command groups, ordered event
observer. This upgrades plugins to headless-bot parity.
2. **Entity/property query surface.** Names, classification, weenie type,
physics position on entity snapshots; query verbs; per-object property bags
in the retail key space; ID-request command + ID-arrived event.
3. **Spell/enchantment surface.** Enumerable active enchantments (spell id,
layer, seconds remaining), can-cast prediction, explicit cast-on-target,
cast-outcome events.
4. **Interaction/transaction commands.** use-by-id, apply-on, give, container
take-specific, salvage, vendor buy/sell — over the existing
one-transaction gate, with receipts as events.
5. **Nav/move-to layer.** Point-goal movement with arrival/stuck/off-course
events plus a server-confirmed-arrival variant, follow-entity, jump.
(1) gates all. (2) and (3) are independent once (1) lands. (5) is last and is
**the only genuinely new machinery** — everything else is exposure of state
that already exists Runtime-side.
---
## 4. What changed since the research was written
The research is dated 2026-07-29. Three weeks and several campaigns have
landed since, and **three of its "gap" rows have closed**. Verified today:
| Research said | Reality on 2026-08-20 |
|---|---|
| "vendor = M4 Slices 56 in flight" | **Landed**, user-accepted 2026-08-08. `RuntimeVendorRangeQuery`, `VendorShopItemMaterializer`. The six-slice world-interaction program is COMPLETE. |
| "Fellowship state: not in Runtime views yet" | **Landed.** Campaign FA code-complete 2026-08-12: `RuntimeFellowshipState`, `RuntimeAllegianceState`. |
| (not mentioned — postdates it) | Secure trade shipped 2026-08-14: `RuntimeTradeState`. |
**Net effect: step 4's substrate is materially stronger than when the plan was
written.** Re-read §3 of the research against the live tree before scoping —
its gap table is the one part that has aged.
Still true, verified today:
- `WorldEntitySnapshot` is still exactly four fields —
`(uint Id, uint SourceId, Vector3 Position, Quaternion Rotation)`. The
step-2 gap is real and unchanged.
- `IPluginHost` still exposes only `{ HasUi, Log, State, Events, Selection, Ui }`.
- There is **no** enchantment-enumerating view; the state lives in
`RuntimeCharacterState` / `GameRuntimeGameplayViews`.
- There is **no** point-goal movement primitive. Movement is WASD-intent-shaped.
---
## 5. Every file you will need, verified 2026-08-20
**The plugin API you are extending** (BCL-only — see §6):
```
src/AcDream.Plugin.Abstractions/IPluginHost.cs <- the surface to grow
src/AcDream.Plugin.Abstractions/IGameState.cs
src/AcDream.Plugin.Abstractions/IEvents.cs
src/AcDream.Plugin.Abstractions/ISelectionService.cs
src/AcDream.Plugin.Abstractions/IUiRegistry.cs <- AddMarkupPanel; already a better-typed "Meta Views"
src/AcDream.Plugin.Abstractions/IAcDreamPlugin.cs
src/AcDream.Plugin.Abstractions/IPluginLogger.cs
src/AcDream.Plugin.Abstractions/WorldEntitySnapshot.cs <- the 4-field snapshot to enrich
```
**The Runtime surface you are mirroring** (all three interfaces are `public`):
```
src/AcDream.Runtime/GameRuntimeViews.cs :259 public interface IGameRuntimeView
src/AcDream.Runtime/GameRuntimeCommands.cs :489 public interface IGameRuntimeCommands
src/AcDream.Runtime/GameRuntimeEvents.cs :100 public interface IRuntimeEventObserver
src/AcDream.Runtime/GameRuntimeGameplayViews.cs gameplay projections incl. enchantments
```
**The pattern to copy** — note `IHeadlessBotPolicy` is `internal` to
`AcDream.Headless`, so you are mirroring the *shape*, not re-exporting the type:
```
src/AcDream.Headless/Policies/HeadlessBotPolicy.cs :8 internal interface IHeadlessBotPolicy
:12 void Tick(IGameRuntimeView, IGameRuntimeCommands)
```
**Runtime gameplay state owners** (what step 24 will expose):
```
src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs vitals/skills/spellbook/enchantments
src/AcDream.Runtime/Gameplay/RuntimeInventoryState.cs
src/AcDream.Runtime/Gameplay/RuntimeActionState.cs selection/combat/cast intent, transaction gate
src/AcDream.Runtime/Gameplay/RuntimeFellowshipState.cs (new since research)
src/AcDream.Runtime/Gameplay/RuntimeAllegianceState.cs (new since research)
src/AcDream.Runtime/Gameplay/RuntimeTradeState.cs (new since research)
src/AcDream.Runtime/Gameplay/RuntimeVendorRangeQuery.cs
src/AcDream.Runtime/Gameplay/VendorShopItemMaterializer.cs
```
**Plugin loading/hosting**:
```
src/AcDream.Core/Plugins/PluginLoader.cs
src/AcDream.Core/Plugins/LoadedPlugin.cs
src/AcDream.Core/Plugins/PluginAssemblyLoadContext.cs
src/AcDream.Plugins.Smoke/SmokePlugin.cs <- the worked example
```
**Domain background** (read on demand — junction, see the note in §1):
```
claude-memory/project_modern_runtime_architecture.md Slice-J ownership truth
claude-memory/project_linux_headless_bots.md Slice K; the BINDING #368 contract
claude-memory/project_interaction_pipeline.md use / pickup / appraisal flow
```
---
## 6. Constraints that bind this work
1. **`AcDream.Plugin.Abstractions` is BCL-only.** No App namespaces, no
Runtime types leaking through, no third-party packages. Plugin gameplay UI
goes through `IUiRegistry.AddMarkupPanel` and nothing else. This is what
keeps plugins insulated from presentation churn — see CLAUDE.md rule 3.
2. **Hosts give Runtime ONE of everything** — the BINDING #368 contract in
`claude-memory/project_linux_headless_bots.md`. Read it before adding a
second anything.
3. **Every C-bucket item gets a brainstormed spec before code** — the intake
doc's own sequencing note, and the roadmap rules in CLAUDE.md. Use
`superpowers:brainstorming`, then `superpowers:writing-plans`.
4. **This is not the active milestone.** M4 "Live in the world" is current.
This is filed as a post-M4 C-bucket candidate. Confirm with the user that
they want it started before doing anything beyond the spec — CLAUDE.md's
one-active-milestone rule exists specifically to prevent this kind of drift.
5. **No workarounds without explicit approval**, and any retail deviation
introduced gets its row in
`docs/architecture/retail-divergence-register.md` in the same commit.
---
## 7. First action
Read the research end to end, then re-verify its §3 gap table against the live
tree (three rows are already stale — see §4). Then bring the user a
brainstormed spec for **step 1 only**. Step 1 gates everything else, it is the
smallest coherent slice, and getting the bridge shape wrong is the one mistake
that would be expensive to undo.
Do not start with the nav layer, however tempting — it is step 5 for a reason.
Route automation needs combat and loot reads to decide when to move.
---
## 8. Provenance
- Research commit: `6077ce4d` — *"docs: VTank requirements research — the
plugin-automation milestone model"*, 2026-07-29, authored Fable 5, `xhigh`.
- Originating session: `local_dce33067-349e-45dd-916f-479bfae6cbc6`, titled
**"graphics"**, branch `claude/git-sync-status-5fb1d2`, worktree
`peaceful-blackburn-5333f0`. The misleading title is why it could not be
found by search — the VTank research was one strand of a session about
something else entirely.
- Related sessions, if the VTank *content* is ever needed rather than the
acdream plan: `local_79a15007` (`repos/metas` — authoring VTank `.af` metaf
scripts) and `local_19297181` (`dereth-workspace` — code consuming
`ILootRuleProcessor` / VirindiTank loot rules).
- The research's own source list — Wayback wiki snapshots plus `metaf`,
`virindi-public-clone`, `ACE.BaseMod`, `vtank-routes`, `vtank-loot-profiles`
— is at the end of the research doc. The live virindi.net wiki is
**unreachable** (self-signed TLS; the HTTP wiki returns a database error),
so use the Wayback snapshots cited there rather than trying the live site.

View file

@ -0,0 +1,188 @@
# acdream chat UI audit — window controllers, input bar, menus, filters, window management
Scope: acdream's own chat UI implementation (window controllers, view models,
input bar, menus, filters, window management). Explicitly OUT of scope per
task boundary: retail's glyph tag system, tag click dispatch, and the text
rendering stack (owned by a parallel audit) — not covered here beyond
incidental mentions needed to explain routing.
All claims below are cited `file.cs:line` against the current worktree
(`C:\Users\erikn\source\repos\acdream\.claude\worktrees\objective-leavitt-0cbd10`).
This document supersedes nothing in `claude-memory/project_chat_digest.md`;
it verifies and extends it against the current code as of 2026-08-21.
---
## 1. Inventory — what exists and what each surface owns
| Surface | File | Owns |
|---|---|---|
| Main chat window | `src/AcDream.App/UI/Layout/ChatWindowController.cs` | Binds LayoutDesc `0x2100006F` (retail `gmMainChatUI`/`ChatInterface`, `m_eWindowID==8`). Transcript (`UiText`), input (`UiField`), scrollbar, talk-focus channel menu (`UiMenu`), Send button, max/min toggle, the four floating-window indicator LEDs (mirror + click), the 8 resize-grip locked/live cosmetic swap seed. |
| Floating chat windows ×4 | `src/AcDream.App/UI/Layout/FloatingChatWindowController.cs` | Binds LayoutDesc `0x2100005B` (retail `gmFloatyChatUI`, `m_eWindowID` 2-5) four times, one `FloatingChatWindowController` instance per `WindowId` 1-4. Transcript, input (channel hardcoded to Say), scrollbar, Send button, hardcoded `"Chat {windowId}"` title, Close button. |
| Chat view-model | `src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs` | Formats `ChatLog` entries to display lines (`RecentLines`/`RecentLinesDetailed`), owns `/framerate`/`/loc` client-side output, the `ShowSystemMessage`/`ShowInterfaceText` (0x1A→SpewBox) split, and `ChatCommandTargetState` (last-tell-sender/target) via `_commandTargets`. |
| Chat submit pipeline | `src/AcDream.Runtime/Chat/ChatCommandRouter.cs` | The one `Submit` chokepoint both `ChatWindowController.Bind`'s `Input.OnSubmit` (`ChatWindowController.cs:339-340`) and `FloatingChatWindowController.Bind`'s `Input.OnSubmit` (`FloatingChatWindowController.cs:157`) call. |
| Chat parsing / catalog | `src/AcDream.Runtime/Chat/ChatInputParser.cs`, `RetailClientCommandCatalog.cs`, `RetailCommandHelpTable.cs`, `RetailChannelTagTable.cs` | Verb resolution, 152-verb registry, `/help` text. |
| Per-window filter/open state | `src/AcDream.Core/Chat/ChatWindowState.cs` | The ONE canonical `ChatWindowState` (5 windows: id 0 main + 1-4 floaty) both controllers read live — filters, open/closed, `ShouldDisplay`/`TypeIsActive` (retail's `ChatInterface::TypeIsActive`/`RecvNotice_DisplayFinalStringInfo`). |
| Chat colors | `src/AcDream.UI.Abstractions/Panels/Chat/RetailChatColorTable.cs` | 34-value `RetailLogTextType`→RGBA table, hard-coded (matches retail; no user config — confirmed still true, see §2). |
| Input widget | `src/AcDream.App/UI/UiField.cs` | Generic editable-field widget; the chat entry is one instance of this, built by `DatWidgetFactory.BuildText` for the DAT's Type-12 Editable element. |
| Talk-focus / dropdown menu | `src/AcDream.App/UI/UiMenu.cs` | Generic dropdown popup widget; the chat channel selector is one instance. |
| Chat-tab Settings surface | `src/AcDream.App/UI/Layout/ChatOptionsPageController.cs` (+ `ChatOptionsDatDefaults.cs`) | Options panel → Chat tab: two opacity sliders + 5 per-window 13-row text-type filter blocks, all live-writing `ChatWindowState`/`RetailWindowOpacityController`. |
| Persistence | `src/AcDream.App/UI/RetailUiRuntime.cs` (`SaveChatWindowFilters`, `SaveChatOpacity`, load path ~`RetailUiRuntime.cs:1516-1518`) + generic `RetailWindowLayoutPersistence` | Filters (all 5 windows) + opacity persist to local `settings.json`; window geometry/open-state persist "for free" once registered under `WindowNames.Chat`/`ChatWindow1..4` (`src/AcDream.App/UI/WindowNames.cs:19-23`). |
| Dead/legacy surface | `src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs` | An `IPanel` (ImGui-era D.2a stack) chat panel. **Never instantiated in `src/`** — see §6. |
**Not in this lane** (owned elsewhere per the task boundary): glyph tag
colouring/click dispatch, `UiText` rendering internals, SpewBox glyph
drawing. Where the input bar or router hands text to the SpewBox
(`ChatVM.ShowInterfaceText`, `ChatVM.cs:177-183`) that routing decision is
in-lane; the SpewBox's own rendering is not.
---
## 2. Window management
**Multiple windows.** Five windows total: 1 main (always open, `ChatWindowState.MainWindowId=0`, `ChatWindowState.cs:68,169-175`) + 4 floating (independently open/closed, `ChatWindowState.cs:69-70,178-206`). Confirmed working end-to-end: `ChatWindowController.SetIndicatorOpen`/`BindIndicatorClicks` (`ChatWindowController.cs:671-700`) mirror + drive floating-window visibility from the main window's 4 indicator LEDs, and `RetailUiRuntime.ToggleFloatingChatWindow` (`RetailUiRuntime.cs:1142-1143`) is the Alt+1..4 keybind's landing point (`KeyBindings.cs:218-221`).
**Filters.** Per-window 64-bit `LogTextType` bitmask filter (`ChatWindowState.GetFilter`/`SetFilter`/`ShouldDisplay`, `ChatWindowState.cs:145-167,230-235`), fully live: both controllers read it every transcript rebuild (`ChatWindowController.cs:742,776-777`; `FloatingChatWindowController.cs:261,278-279`), and it's user-editable through the Options→Chat tab (`ChatOptionsPageController.cs:181-193` five `FilterBlockSpec` rows, `:552-598` `BuildFilterBlock`). This is MORE complete than the chat digest implied — the digest's "Main's filter IS user-settable" note (digest line 118-119) is confirmed and the floaty filters are settable through the same UI, not just the main window.
**Move/resize.** The 8 authored resize grips (`ChatWindowController.cs:38-43` doc) and the drag/move title-strip import generically via `DatWidgetFactory`/`UiResizeGrip` — no per-controller binding code needed (confirmed by the class doc; no resize-specific code exists in either controller beyond `AttachWindow`/`ToggleMaximize`).
**Maximize/restore (main window only).** `ChatWindowController.ToggleMaximize` (`ChatWindowController.cs:537-580`) is a faithful port of `gmMainChatUI::HandleMaximizeButton @0x004CCE50` (save/restore Y+height, half-parent expansion, up/down growth choice, DAT-constraint clamping) plus `CaptureWindowState`/`RestoreWindowState` (`ChatWindowController.cs:702-715`) for session persistence. **Floating windows have no maximize** — matches retail (no max/min button authored on `0x2100005B`; `FloatingChatWindowController.cs` has no `MaxMinId`/`ToggleMaximize` equivalent, and this is correct, not a gap).
**Close (floating windows only).** `FloatingChatWindowController.Bind`'s Close-button wiring (`FloatingChatWindowController.cs:211-214`) calls `c.WindowHandle?.Hide()` — a straight port of `gmFloatyChatUI::ListenToElementMessage @0x004CE330`. **Main window has no close button** (matches retail; `ChatWindowState.SetOpen`/`Toggle` are explicit no-ops for window 0, `ChatWindowState.cs:177-188,195-206`).
**Opacity.** Two linked sliders (Default/Active), ported with retail's `DualHash` drag-the-other-value link (`ChatOptionsPageController.cs:344-372` doc, `:412-451`), scoped to exactly the 5 chat windows per issue #379 (see §5 — DONE). Batched persistence (`RetailUiRuntime.SaveChatOpacity`, `RetailUiRuntime.cs:1201-1210`) flushes once per discrete edit, not per drag tick.
**Persistence.** Filters for all 5 windows + both opacity values write to local `settings.json` on every live edit (`RetailUiRuntime.cs:1169-1210`) and reload at startup (`RetailUiRuntime.cs:1516-1518` for the main window's filter; the floaty load leg is the analogous call in `MountFloatingChatWindows`, cited by that same comment). Window geometry/open-state ride the generic `RetailWindowLayoutPersistence` path since all 5 windows are registered under distinct `WindowNames` entries (`WindowNames.cs:19-23`). **No gap found here** — window management persistence is comprehensive.
**Known, already-registered divergences (not new findings, listed for completeness):**
- AP-187/AP-189 (`docs/architecture/retail-divergence-register.md`): floaty filters are local-`settings.json`-only, no `0x1000008C` server-side wire sync between installs; and the chat log's shared `500`-entry ring buffer / `200`-entry display tail (`ChatLog.cs:21-22,447-448`; `InteractionRetainedUiComposition.cs:465`) gives every window a shallower **effective per-window** scrollback than retail's own **per-window** 10,000-line log — a low-traffic window's messages can be evicted from the shared tail by unrelated high-traffic windows' spam before that window's own filter ever sees them. Behaviorally the accumulate-while-closed and independent-per-window-scroll-position mechanics are correctly reproduced; only the depth differs.
- AP-188/#369 (OPEN): floating windows hardcode Send-channel to Say (`FloatingChatWindowController.cs:157`) because the floaty LayoutDesc authors no talk-focus menu; whether retail's floaties actually share the main window's last-picked channel is UNRESEARCHED (see §5).
- AP-190/#379 (#379 DONE, AP-190 partially retired): opacity scope-to-chat-only is fixed; the digest's "we snap, retail eases 5%-of-range per tick" easing-curve residual is unverified as still true today — UNKNOWN, needs re-check against `RetailWindowOpacityController` if picked up.
---
## 3. Input bar — exactly what `UiField` supports
Source: `src/AcDream.App/UI/UiField.cs`, wired per-window at `ChatWindowController.cs:329-372` (main) and `FloatingChatWindowController.cs:151-175` (floaty). Both controllers configure the SAME widget class with near-identical wiring (the floaty path lacks the talk-focus channel, per §2/#369).
**Supported:**
- **Typing / editing:** `InsertChar`, `Backspace`, `DeleteForward`, held-key auto-repeat for Backspace/Delete/Left/Right (`UiField.cs:159-189,683-700`, 0.40s delay / 25/s repeat).
- **Caret movement:** Left/Right (`MoveCaret`), Home/End (`MoveCaretTo`), all Shift-extendable when `Selectable` (`UiField.cs:791-811`). No Ctrl+Left/Right word-jump, no Ctrl+Backspace delete-word.
- **Selection:** mouse click+drag (`MouseDown`/`MouseMove`, `UiField.cs:749-763`), Shift+arrow, Ctrl+A select-all — **all three gated behind `Selectable`** (`UiField.cs:781,789`), which is DAT-authored property `0x27` on element `0x10000016`. Confirmed live-DAT-true for the chat input specifically: `ChatLayoutConformanceTests.ChatFixture_BuildsSelectableTranscriptAndEditableInputInPlace` (`tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs:220-228`) asserts `input.Selectable == true` after `ChatWindowController.Bind`, and neither controller sets it explicitly (`ChatWindowController.cs`/`FloatingChatWindowController.cs` grep clean for `Input.Selectable`) — so this is the real DAT default, not an accidental pass. **Not a gap.**
- **Clipboard:** Ctrl+C/Ctrl+X gated behind `Selectable` (same as above); Ctrl+V (paste) is **not** gated and always works when `Editable` (`UiField.cs:265-303,784`). Paste strips control characters and normalizes CR/LF for multi-line fields; the chat input is one-line so paste collapses to a single stripped line.
- **History:** 100-entry cap (`UiField.cs:326`, sentinel via `_historyIndex=-1`), Up/Down browse (`HistoryPrev`/`HistoryNext`, `UiField.cs:330-350,809-810`) — a faithful port per the class doc's citation of `ChatInterface::ProcessCommand @0x4f5100`.
- **Submit:** Enter/KeypadEnter (`UiField.cs:792-802`) calls `Submit()``OnSubmit` → clears (`ClearOnSubmit`, default true) → pushes history (`RecordHistory`, default true) → releases keyboard focus (`FindRoot()?.SetKeyboardFocus(null)`) — "exit write mode after sending," matching retail's read-mode/write-mode chat behavior.
- **Max length:** DAT-authored via property `0x1E``UiField.MaxCharacters` (`DatWidgetFactory.cs:788-789`); default `0xFFFF` if the DAT doesn't author one. Not hardcoded in the controller — correctly deferred to the imported layout.
- **Focus entry (keyboard):** `UiRoot.OnKeyDown` has a special case — when nothing is focused, Tab or Enter/KeypadEnter focuses `DefaultTextInput` (`UiRoot.cs:1059-1071`), which `RetailUiRuntime.cs:1587` sets to the bound chat `Input`. This is the actual, working mechanism for "press Enter/Tab to start typing" — it runs entirely inside `UiRoot`, independent of the `InputDispatcher`/`InputAction` system.
**Missing / gaps found:**
1. **Escape does nothing while the chat input is focused.** `UiField.OnEvent`'s `KeyDown` switch (`UiField.cs:790-811`) has no `Key.Escape` case, so it falls through to `return false;` (implicit end-of-block after the switch, `UiField.cs:812`). Because `KeyboardFocus.IsEditControl` is true for a focused `UiField` (`UiField.cs:149`), `UiRoot.OnKeyDown`'s modal/root fallback branch (`UiRoot.cs:1083-1089`) is skipped entirely, and the event falls to `WorldKeyFallThrough` (`UiRoot.cs:1091`) — **an event nobody subscribes to in production** (grep for `WorldKeyFallThrough +=` across `src/` finds only the class's own declaration and a `README.md` code sample, `src/AcDream.App/UI/UiHost.cs:19`, `src/AcDream.App/UI/UiRoot.cs:408`). Separately, `InputDispatcher`'s own action-routing is gated off entirely whenever a widget holds keyboard focus (`_mouse.WantCaptureKeyboard``SilkMouseSource.cs:193``Root.WantsKeyboard``KeyboardFocus is not null`, `UiRoot.cs:196`), so `GameplayInputCommandController.HandleEscape` (`GameplayInputCommandController.cs:238-248`, cancel target mode / exit fly mode / close window) never fires either. **Net effect: pressing Escape while the chat box has focus is a complete no-op in acdream today** — no clear, no defocus, no fallback to a game hotkey. This is not tracked in `docs/ISSUES.md` under any existing chat issue.
2. **No autocomplete / tab-completion** of player names, channel tags, or command verbs. Confirmed by exhaustive grep (`autocomplete|tabcomplete|namecomplet` across `src/`) — zero hits. `ChatCommandRouter.Submit` (`ChatCommandRouter.cs:30-179`) is pure parse-and-dispatch with no partial-match suggestion path. Whether retail AC's chat box ever had tab-completion is UNKNOWN — not established either way in this pass; flagging the absence, not asserting it's a regression.
3. **`@title` is a documented no-op** (see §4) — the floating window's title bar (`FloatingChatWindowController.cs:194-207`) is permanently `"Chat {windowId}"`, unaffected by the command that's supposed to set it.
4. **No Ctrl+Left/Right word-jump or Ctrl+Backspace delete-word** — minor editing convenience absent from `UiField`'s `KeyDown` switch entirely (not chat-specific, but the chat input is the surface a user would notice it on most).
---
## 4. Known no-ops and stubs (grepped, cited)
| Site | What's disabled |
|---|---|
| `src/AcDream.Runtime/Chat/ClientCommandId.cs:50-58` (`SetChatTitle`) + `RetailClientCommandCatalog.cs:258-269` (`SetTitle` definition) | `@title <text>` — retail sets the popup chat window's title bar (`ClientCommunicationSystem::DoTitle @0x0057A640`); acdream's binding "is a pure no-op (the value is neither stored nor consumed — no title-bar chrome exists to render it yet, AP-182)". Confirmed live: `FloatingChatWindowController.cs:204` hardcodes `$"Chat {windowId}"` with no seam for an external override. |
| `src/AcDream.App/Input/GameplayInputCommandController.cs:208-213` | `InputAction.ToggleChatEntry` (Tab, bound at `KeyBindings.cs:251`) — the switch case's own comment says "IDevToolsGameplayCommands.FocusChatInput() retired... Tab is still consumed here, matching the prior no-op's 'handled' contract." **Harmless**: `UiRoot.OnKeyDown` (§3) independently handles Tab-to-focus-chat before/alongside this path, so functionally nothing is lost — but the `InputAction`/keybind plumbing for it is dead weight that could mislead a future reader into thinking this is the live mechanism. |
| `src/AcDream.App/Input/GameplayInputCommandController.cs` (whole file) | `InputAction.EnterChatMode` (Enter, bound at `KeyBindings.cs:252`) has **no case at all** in `Handle`'s switch (`GameplayInputCommandController.cs:172-235`) — falls to `default: return false;`. Same "harmless because `UiRoot` does it independently" caveat as `ToggleChatEntry` above. |
| `src/AcDream.Runtime/Chat/RetailClientCommandCatalog.cs:634-696` (`TryMatchAllegiance`) | 9 of 12 `@allegiance` subcommands (boot/ban/officer/title/motd/name/lock/house/chat/broadcast) unported — issue #360, still OPEN (see §5). |
| `src/AcDream.Runtime/Chat/RetailCommandHelpTable.cs:319-349` | `@day`/`@log`/`@render` recognized only by `/help <verb>`; execution falls through to server passthrough — issue #361, still OPEN (see §5). |
| `src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs` | Entire `IPanel`-based class — see §6, dead code, not reachable from any production construction site. |
No other `deferred`/`TODO`/`stub`/`placeholder` hits inside `ChatWindowController.cs`, `FloatingChatWindowController.cs`, or the `Panels/Chat/` directory resolve to a genuine behavioral gap beyond what's listed above and in §5 — the remaining grep hits in those files are either doc-comment cross-references to *other* code's no-ops (e.g. `ChatWindowController.cs:230` explaining that the main filter is "not an inert no-op" — i.e. describing something that was FIXED) or historical narration.
---
## 5. Open issues — current code status
| Issue | One-line verdict | Evidence |
|---|---|---|
| **#358** Ctrl+M mute chord never fires | **STALE — DONE.** `KeyBindings.RetailDefaults()` now binds Ctrl+M (`KeyBindings.cs`, per the fix note); root cause (binding added to the dead `AcdreamCurrentDefaults()` table) is fixed. Live-client verification of the actual mute effect was still owed at closure time — UNKNOWN whether that connected check ever ran. |
| **#359** `0x019E` PlayerKilled prints to participants | **STILL OPEN.** `ChatLog.OnPlayerKilled` (`ChatLog.cs:188-206`) appends the death message unconditionally for every recipient — no `player_id == victim \|\| player_id == killer` guard exists anywhere in the method or its call site. |
| **#360** `@allegiance`/`@house` only port simple subcommands | **STILL OPEN.** `RetailClientCommandCatalog.TryMatchAllegiance` (`RetailClientCommandCatalog.cs:656-696`) only recognizes `hometown`/`ho` and `info`; every other subcommand falls to `AllegianceUnrecognizedSubcommand`'s refusal text (`:689-695`). House subcommands correctly passthrough to ACE per the same file's `TryMatchHouse` comments (`:643`), but neither dispatcher executes the ~22 unported subcommands locally. |
| **#361** `@day`/`@log`/`@render` recognized in help only | **STILL OPEN.** `RetailCommandHelpTable.cs:319-349` still carries the "NOT YET IMPLEMENTED in acdream" meta-tail for all three; `RetailClientCommandCatalog` has no `Day`/`Log`/`Render` client-command definitions with real handlers (only chat verbs actually wired execute; these three fall through to server passthrough, which is a silent no-op against ACE). |
| **#362** Four CH4 outbound requests had no inbound handler | **STALE — DONE**, closed 2026-08-09 (`ClientCommandResponses.cs` parses `ChannelIndex`/`ChannelList`/`AvailableHouses`/`AllegianceInfoResponse`). |
| **#363** Refusal sites typed `0x00` where retail types `0x1A` | **STALE — CLOSED 2026-08-10.** `ChatVM.ShowInterfaceText`/`OnInterfaceText` seam (`ChatVM.cs:159-183`) exists and is wired at `InteractionRetainedUiComposition.cs:472-473`; `ChatCommandRouter` routes every named site through it (confirmed at `ChatCommandRouter.cs:85-98,119-121,154,162`). |
| **#366** New-unseen-text indicator (`0x1000048C`) imports but unwired | **STILL OPEN** (narrowed 2026-08-16 — the build/import half is fixed, the behavior half — what triggers it, what a click does — remains un-researched). No controller code references `0x1000048C` in `ChatWindowController.cs`. |
| **#367** Local-presentation fallbacks land in chat scroll, not SpewBox | **STALE — CLOSED 2026-08-10**, closed as a side effect of #363 (same seam). |
| **#369** Unconfirmed whether floaty windows share the main window's talk-focus channel | **STILL OPEN, unresearched.** `FloatingChatWindowController.cs:157` hardcodes Say; whether that's retail-correct is not established either way — filed as a research task, not yet picked up. |
| **#372** Options panel Character/Chat/Config tabs render blank | **STALE — DONE** (blank-tabs half fixed at the `UiTemplateListBox` viewport-anchor level, not chat-specific; the tangential "13 Chat-tab filter labels resolve blank" sub-note was fixed by the `FilterStringTableId = 0x2300000D` correction visible at `ChatOptionsPageController.cs:96-105`). |
| **#379** Chat opacity applied to all windows, not just chat | **STALE — DONE**, `RetailWindowOpacityController` now scoped to the 5 chat windows only. |
| **#380** Chat tab opacity sliders missing row captions | **STALE — DONE**, `ChatOptionsPageController.cs:399-407,473-503` wires `SetOpacityCaption` from `ChatOptionsDatCaptions`. |
| **#382** Floating-window indicator buttons invisible until hovered | **STALE — DONE**, `UiButton.TrySetRetailState` fix (unrelated file, general `UiButton` bug that happened to be discovered via the chat indicators). |
**Net: of the 12 chat-tagged issues checked, 4 remain genuinely open in code (#359, #360, #361, #366) plus one unresearched design question (#369).** The rest closed since the digest's 2026-08-09/10 snapshot but the digest's own "Open" section (still listing #358/#359/#360/#361/#362/#363) is now stale for #358/#362/#363 — worth a digest refresh independent of this audit.
---
## 6. Test coverage
**Well covered** (direct, behavior-level tests exist):
- `ChatWindowController`: `tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs` — bind success/failure, talk-focus specials (Squelch/Tell-to-selected), transcript parent/mode, transcript layout caching + out-of-range LogTextType fallback, input submit → `SendChatCmd`, channel-change updates submit channel, input-field resize/reflow (both with and without an imported `LayoutPolicy`), indicator open/closed/cross-window-isolation/out-of-range.
- `FloatingChatWindowController`: `tests/AcDream.App.Tests/UI/Layout/FloatingChatWindowControllerTests.cs` — bind success/failure/invalid-window-id, transcript parent, input parent (floaty row vs main bar), input always-Say, per-window filter subset + filter-change reflection + cache reuse.
- `ChatWindowState`: has its own filter/open/`ShouldDisplay` logic covered by construction (not independently verified in this pass, but the class is simple enough that the controller tests above exercise it transitively).
- `UiField`: `tests/AcDream.App.Tests/UI/UiFieldTests.cs` — insert/caret, backspace, submit/clear/history-push, empty-submit no-op, history up/down, history 100-cap, two multi-line-after-shrink regression tests (the 2026-07-29 crash class), character filter, select-all-on-focus, read-only field, multi-line Enter-inserts-newline.
- Command routing: `ChatCommandRouterTests.cs`, `ChatInputParserTests.cs`, `ChatInputParserAtPrefixTests.cs`, `RetailClientCommandCatalogTests.cs`, `RetailCommandHelpTableTests.cs`, `RetailCommandRegistryConformanceTests.cs` (bidirectional ownership-rule enforcement across the whole 152-verb registry).
- Colors: `RetailChatColorTableTests.cs`.
**Gaps found — user-visible behaviors with zero automated coverage:**
1. **`ChatWindowController.ToggleMaximize`** — no test anywhere calls it or exercises `CaptureWindowState`/`RestoreWindowState`. Grep for `ToggleMaximize`/`Maximiz` across `tests/` returns nothing. The growUp/clamp/DAT-constraint logic (`ChatWindowController.cs:537-580`, a direct port of `gmMainChatUI::HandleMaximizeButton`) is entirely unverified by automation — a regression here would only be caught by a human clicking the max/min button.
2. **Floating window's Close button** — no test exercises `FloatingChatWindowController.cs:211-214`'s `OnClick` wiring (`WindowHandle?.Hide()`). Grep for `CloseButton` in `FloatingChatWindowControllerTests.cs` returns nothing.
3. **`UiField` Escape handling** (or lack thereof — see §3 finding 1) — no test exists for Escape at all in `UiFieldTests.cs`; the absence of behavior is untested, meaning it could silently "start working" or silently regress further with no signal either way.
4. **`UiField` clipboard (Ctrl+C/X/V) and Shift-selection** — none of `UiFieldTests.cs`'s 13 tests exercise `CopySelection`/`CutSelection`/`Paste`/Shift+arrow extension. `Selectable`-gating (§3) is only indirectly confirmed via the DAT-fixture conformance test (`ChatLayoutConformanceTests.cs:220-228`), which checks the *property resolves true*, not that copy/cut/select-all *actually work* once it's true.
5. **`ChatPanel.cs` and its whole test suite are exercising dead code.** `ChatPanel` (`src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs`) implements the old ImGui-era `IPanel` contract from the D.2a stack. `AcDream.UI.ImGui` no longer exists as a project (deleted at Campaign V slice V11 per `CLAUDE.md`), and a repo-wide grep for `new ChatPanel(` finds only the class's own constructor declaration — **no production code anywhere constructs a `ChatPanel`.** Its five test files (`ChatPanelFocusTests.cs`, `ChatPanelInputTests.cs`, `ChatPanelLayoutTests.cs`, plus the shared `ChatVMCombatTests.cs`/`ChatVMLastTellSenderTests.cs`/`ChatVMRetellAndProvidersTests.cs` that exercise `ChatVM` directly and remain legitimately live) still compile and pass, which gives a false impression of "chat input is covered" in a naive test-count read — the REAL live input surface is `UiField` + `ChatWindowController`, covered separately (and less deeply, per findings 1-4 above). This is worth flagging to whoever next touches chat tests: `ChatPanel.cs` and its three panel-specific test files are candidates for deletion (dead code, not a functioning fallback), not maintenance targets.
---
## 7. Prioritized gap list (most user-visible first)
This is the ordering to plan slices from — judgment calls, not a flat dump.
1. **Escape does nothing in the chat input (§3 finding 1).** Every retail player's muscle memory includes "Escape backs out of whatever I'm doing," and chat is the single most-used text-entry surface in the client. Right now it's a dead key while typing — worse than doing nothing wrong, because it silently swallows an action a user expects to work (defocus/clear), and if `WorldKeyFallThrough` were ever wired for something else, it would ALSO be swallowed by the exact-focus branch that already fails to handle it. This is a real, previously-untracked bug (no ISSUES.md entry), high frequency of exposure, small fix surface (add an Escape case to `UiField.OnEvent`'s `KeyDown` switch, decide clear-vs-defocus-vs-both against retail).
2. **#360`@allegiance`/`@house` management subcommands (22 of them unported).** Highest-traffic gap by command surface area; already tracked, already scoped ("largest single item; deserves its own slice" per the issue's own text), needs byte-level wire verification before implementation (target-name/guid resolution, confirmation dialogs, multi-field payloads) rather than guessing.
3. **#359 — PlayerKilled line double-prints for the victim/killer.** Small, well-scoped, single-method fix (`ChatLog.OnPlayerKilled` needs the local-player-guid participant check) with a clear retail citation already in the issue. Low effort, directly visible to anyone who dies or gets a kill in acdream.
4. **#369 — floaty-window channel-sharing research.** Currently a design assumption (Say-always) shipped without verification. Low implementation cost either way once researched, but the research itself (`gmCCommunicationSystem`'s floaty send path) hasn't started. Worth resolving before more chat work builds on the current assumption.
5. **`ToggleMaximize`/Close-button test coverage gap (§6.1/6.2).** Not a behavior bug — both features work per the code reading — but zero automated coverage on two interactive, DAT-geometry-dependent code paths (max/min clamping, close-then-reopen) is a latent regression risk given how much chat-adjacent layout churn this codebase has had (8+ chat-parity review rounds in the last two weeks alone).
6. **#366 — new-unseen-text indicator inert.** Cosmetic/discoverability only; retail's exact trigger condition is still unresearched, so this can't be fixed correctly without that research first, and its absence doesn't block any other chat behavior.
7. **#361`@day`/`@log`/`@render`.** Genuinely low-value: `@day` needs a renderer hook that doesn't exist yet (bigger than a chat fix), `@log` was deliberately deferred (file-handle lifecycle risk across reconnects), `@render` has no acdream render-option surface to bind to. Correctly the lowest priority of the open command-registry gaps.
8. **`@title` no-op + hardcoded floaty titles (§4).** Cosmetic, single command, no other feature depends on it. Fine to bundle with a future title-bar-chrome pass rather than a standalone fix.
9. **`ChatPanel.cs` dead-code cleanup (§6.5).** Not a behavior gap at all — it's hygiene. Flagging here rather than fixing inline per this audit's report-only scope; worth a small follow-up to delete the class and its now-misleading test files so future coverage audits don't need to re-discover this.
10. **Missing autocomplete / word-jump editing conveniences (§3 findings 2, 4).** Lowest priority: unconfirmed whether retail even had these, and even if it did, they're minor efficiency features, not correctness or discoverability gaps.
---
## Appendix: files read for this audit
- `src/AcDream.App/UI/Layout/ChatWindowController.cs`
- `src/AcDream.App/UI/Layout/FloatingChatWindowController.cs`
- `src/AcDream.Core/Chat/ChatWindowState.cs`
- `src/AcDream.Core/Chat/ChatLog.cs`
- `src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs`
- `src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs`
- `src/AcDream.Runtime/Chat/ChatCommandRouter.cs`
- `src/AcDream.Runtime/Chat/ClientCommandId.cs`
- `src/AcDream.Runtime/Chat/RetailClientCommandCatalog.cs` (partial)
- `src/AcDream.Runtime/Chat/RetailCommandHelpTable.cs` (partial, grep-targeted)
- `src/AcDream.App/UI/UiField.cs`
- `src/AcDream.App/UI/UiMenu.cs` (partial)
- `src/AcDream.App/UI/UiRoot.cs` (partial — key dispatch + focus)
- `src/AcDream.UI.Abstractions/Input/InputDispatcher.cs` (partial)
- `src/AcDream.App/Input/GameplayInputCommandController.cs` (partial)
- `src/AcDream.App/Input/InputCaptureSources.cs` (partial)
- `src/AcDream.App/UI/Layout/ChatOptionsPageController.cs`
- `src/AcDream.App/UI/RetailUiRuntime.cs` (partial — persistence + mount)
- `src/AcDream.App/UI/WindowNames.cs`
- `src/AcDream.App/Composition/InteractionRetainedUiComposition.cs` (partial)
- `src/AcDream.App/UI/Layout/DatWidgetFactory.cs` (partial — Type-12 field build)
- `tests/AcDream.App.Tests/UI/UiFieldTests.cs`
- `tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs`
- `tests/AcDream.App.Tests/UI/Layout/FloatingChatWindowControllerTests.cs`
- `tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs` (partial)
- `docs/ISSUES.md` (targeted sections: #358-#382 chat-tagged range)
- `docs/architecture/retail-divergence-register.md` (targeted: AP-185 through AP-191)
- `C:\Users\erikn\.claude\projects\C--Users-erikn-source-repos-acdream\memory\project_chat_digest.md`

View file

@ -0,0 +1,376 @@
# acdream text-stack audit: seam for retail glyph-level tagged text
**Scope.** Research only — OUR codebase (`src/AcDream.App/UI/**`,
`src/AcDream.UI.Abstractions/Panels/Chat/**`, `src/AcDream.Core/Chat/**`).
Goal: determine what it takes to support retail's glyph-level tagged
text (a differently-colored, clickable name inside an otherwise
uniformly-colored chat line). No code changes made.
## 1. Current model — what is a rendered text line?
`UiText` (`src/AcDream.App/UI/UiText.cs`) is the one retained-UI text
widget (`RegisterElementClass(0xc)`, class doc at `UiText.cs:10-22`).
It has **two** display-line shapes, both single-color:
- **`Line`** — `UiText.cs:50`:
`public readonly record struct Line(string Text, Vector4 Color);`
One string, one `Vector4` color for the WHOLE string. This is what
`LinesProvider` (`UiText.cs:62`, `Func<IReadOnlyList<Line>>`) returns
and what the scrollable multi-line transcript path renders
(`DrawClippedText`, `UiText.cs:636-691`). Color is **per-line**, not
per-run: `lines[i].Color` (`UiText.cs:673`/`677`) is one value passed
whole to `ctx.DrawStringDatPass`/`ctx.DrawString`.
- **`TextRun`** — `UiText.cs:55`:
`public readonly record struct TextRun(string Text, Vector4 Color);`
Multiple colored fragments concatenated onto **one** authored line,
fed by `RunsProvider` (`UiText.cs:69`,
`Func<IReadOnlyList<TextRun>>?`) and drawn by `DrawSingleLineRuns`
(`UiText.cs:693-754`). This is real per-run coloring — each run gets
its own `ctx.DrawStringDatPass` call at its own pen X
(`UiText.cs:725-743`) — but it is **only reachable when
`OneLine == true`** (`UiText.cs:506-510`: `if (OneLine &&
RunsProvider is { } runsProvider)`), i.e. the static single-line
label path. The chat transcript is NOT `OneLine` (`ChatWindowController.cs:320`:
`c.Transcript.OneLine = false;`), so it can never reach
`DrawSingleLineRuns` — the scrollable multi-line path only ever
reads `LinesProvider`/`Line`.
**Answer to Q1:** color today is per-`Line` in the transcript
(scrollable, multi-line, bottom-pinned/word-wrapped) path, and
per-`TextRun` only in the unrelated static single-line label path
(currently used by exactly one controller — see §5/§6). Chat uses the
former exclusively.
## 2. Where the flattening happens (the key finding)
`ChatEntry` (`src/AcDream.Core/Chat/ChatLog.cs:499-536`) is a
structured record: `Sender` (string), `SenderGuid` (uint), `Text`,
`ChannelId`/`ChannelName`, `Kind`, `LogTextType`. The sender's identity
survives as a distinct field all the way through `ChatLog`.
It is destroyed in **two** steps inside `ChatVM`
(`src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs`), and the second
step is the point of no return:
**Step A — string composition.** `ChatVM.FormatEntry`
(`ChatVM.cs:262-313`) string-interpolates `entry.Sender` directly into
the message prose, e.g. for `ChatKind.LocalSpeech`
(`ChatVM.cs:269-271`):
```
ChatKind.LocalSpeech => IsOwnSpeaker(entry.Sender)
? $"You say, \"{entry.Text}\""
: $"{entry.Sender} says, \"{entry.Text}\"",
```
After this call the sender name is prose inside one `string`; there is
no longer a machine-readable boundary marking where "Name" ends and
"says, ..." begins.
**Step B — metadata drop (the actual point of no return).**
`ChatVM.RecentLinesDetailed()` (`ChatVM.cs:345-371`) builds the
`FormattedLine` record (`ChatVM.cs:384-388`):
```
public readonly record struct FormattedLine(
string Text,
ChatKind Kind,
CombatLineKind? CombatKind,
uint LogTextType);
```
`FormattedLine` does **not** carry `Sender` or `SenderGuid` at all —
only the composed `Text`, `Kind`, `CombatKind`, and the retail color
key `LogTextType`. Every downstream consumer
(`ChatWindowController.GetTranscriptLines`, `ChatWindowController.cs:736-781`,
which calls `vm.RecentLinesDetailed()` at `ChatWindowController.cs:753`,
then `ChatTranscriptRenderer.BuildLines`,
`src/AcDream.App/UI/Layout/ChatTranscriptRenderer.cs:67-95`) only ever
sees the flat `Text` string plus one `LogTextType` per entry.
`ChatTranscriptRenderer.BuildLines` then assigns exactly **one**
`Vector4 currentColor` per entry (resolved once from `LogTextType` at
`ChatTranscriptRenderer.cs:89`) and stamps every word-wrapped fragment
of that entry with that single color (`ChatTranscriptRenderer.cs:90-93`):
```
if (RetailChatColorTable.TryGetColor(d.LogTextType, out Vector4 resolved))
currentColor = resolved;
foreach (string frag in WrapText(d.Text, maxW, measure))
result.Add(new UiText.Line(frag, currentColor));
```
**So: the sender's identity (name + guid) is available up through
`ChatLog`/`ChatEntry`, is baked into prose by `ChatVM.FormatEntry`, and
is then dropped entirely — not merely flattened, but discarded — by
`ChatVM.RecentLinesDetailed`'s `FormattedLine` shape.** By the time a
`UiText.Line` exists, there is no span boundary, no guid, and (because
word-wrap has already run) not even a guarantee that "the sender name"
is wholly contained within a single rendered `Line` if it happened to
sit at a wrap boundary. Any attempt to recover "where is the name"
downstream of this point would have to regex/string-match the composed
prose back apart — fragile (a message body containing the speaker's
own name, or a name that is a prefix of a common word, breaks it) and
still has no guid to attach for the click action.
## 3. Draw path
**One call per line/run, never per-glyph-color-batch beyond that.**
The scrollable path (`DrawClippedText`, `UiText.cs:636-691`) issues one
`ctx.DrawStringDatPass(datFont, text, x, y, color, isOutlinePass)`
(`UiText.cs:687-689`) per visible `Line` (whole wrapped fragment, one
color). The DAT-font backend (`UiRenderContext.DrawStringDatPass`,
`src/AcDream.App/UI/UiRenderContext.cs:320-367`) walks every glyph in
that ONE string with ONE `tint` (`UiRenderContext.cs:321`, `tint`
passed once, applied per-glyph at `UiRenderContext.cs:359-362`) — there
is no per-glyph or per-substring color inside a single
`DrawStringDatPass` call.
**Drawing N differently-colored runs on one visual line requires N
separate `DrawStringDatPass` calls, each starting at its own pen X** —
this is exactly the mechanism `DrawSingleLineRuns`
(`UiText.cs:693-754`) already uses: it measures each run's width
(`datFont.MeasureWidth(run.Text)`, `UiText.cs:709`/`736`), accumulates
a `penX` (`UiText.cs:731-737`), and issues one
`ctx.DrawStringDatPass(datFont, run.Text, run.X, y, run.Color,
isOutlinePass: false)` per run (`UiText.cs:742-743`). The outline pass
is batched block-wide first (all runs' outlines, then all runs' fills —
`UiText.cs:739-743`, same reasoning as the multi-line block-batching
documented at `UiRenderContext.cs:306-318`) so a per-run outline
doesn't notch an adjacent run's descender.
**Yes, the DAT font path supports starting a draw at an arbitrary X
offset and measuring a substring's width.** `UiDatFont.MeasureWidth(string
text)` (`src/AcDream.App/UI/UiDatFont.cs:160-172`) sums per-glyph
advances for any string/substring — already used for substring
measurement in the selection-highlight code
(`UiText.cs:656-657`/`661-662`, `datFont.MeasureWidth(text.Substring(0,
c0))`). `DrawStringDatPass`/`DrawStringDat` take an arbitrary `float x`
(`UiRenderContext.cs:277-278`, `320-321`) with no assumption it starts
at the element's left edge. The bitmap-font fallback (`BitmapFont.cs`,
`MeasureWidth` at `BitmapFont.cs:167`) and `UiRenderContext.DrawString`
(`UiRenderContext.cs:188-203`, also takes an arbitrary `float x`) mirror
the same capability. **Conclusion: the low-level draw primitives
already support everything a run-based transcript line needs — no
renderer/font work is required, only a widget-level model change to
call them N times instead of once.**
## 4. Hit-testing
**No sub-line hit-testing exists today; click/hover route to whole
elements, never to a text span.** `UiRoot.HitTestTopDown`
(`src/AcDream.App/UI/UiRoot.cs:1410-1430`) walks the retained tree via
`UiElement.HitTest` (`src/AcDream.App/UI/UiElement.cs:705-731`), which
recurses into children and, failing that, calls the virtual
`OnHitTest(localX, localY)` (`UiElement.cs:563-564`, default is a
rectangle containment check) — the granularity is always "some
`UiElement`", never "some substring of a `UiElement`'s text." A
resolved hit becomes a `Click` `UiEvent` at `UiRoot.OnMouseUp`
(around `UiRoot.cs:994-997`) and bubbles via
`UiRoot.BubbleEvent`/`UiElement.OnEvent` (`UiRoot.cs:1516-1525`,
`UiElement.cs:571`). `UiText.OnEvent`'s `Click` case
(`UiText.cs:809-813`) fires the single `OnClick` delegate for the
WHOLE element — there is no notion of "which run was clicked."
**The pieces needed already exist, just not wired to `Click`.**
`UiText.HitChar(float localX, float localY)` (`UiText.cs:1031-1050`)
already converts a local point into a `Pos(line, col)` caret position
using the cached draw geometry (`_lastLines`/`_lastBaseY`/
`_lastLineHeight`, `UiText.cs:268-273`) and a per-character advance
lookup (`UiText.cs:1042-1048`, works for both `UiDatFont` and
`BitmapFont`) — but it is currently invoked only from the
selection-drag path (`MouseDown`/`MouseMove` cases, `UiText.cs:826-847`),
gated behind `Selectable` (`UiText.cs:828`, `839`). The chat transcript
IS `Selectable = true` (`ChatWindowController.cs:321`), so `HitChar`
already runs on every mouse-down inside the transcript — it is simply
never asked "which run (if any) covers this `(line, col)`", because
`Line` carries no runs to check against.
**What mapping a click to a run would need:**
1. A per-line list of run boundaries (start col, end col, and a
payload — e.g. sender guid) reaching `UiText` alongside the text,
which does not exist today (`Line` has no such field, see §1/§2).
2. `HitChar`'s existing `(line, col)` result checked against that
list — this is a small, local addition to `UiText`, not a new
hit-test mechanism.
3. A dispatch from "run payload resolved" to an actual action (e.g.
pre-filling a `/tell <name>` in the chat input) — analogous to the
existing `OnClick` delegate, but keyed by run rather than by
element.
No retail-side click semantics (what a name-click does) were
researched here — that is a different agent's lane per the task brief.
## 5. Seam proposal
**Given Code Structure Rules (CLAUDE.md "Code Structure Rules" §1-3):**
- `AcDream.Core` must not depend on window/GL/backend projects (rule 2)
`ChatEntry`/`ChatLog` (Core) can carry the STRUCTURED data a tagged
run needs (sender name + guid + explicit text-span boundaries) but
must not know about `Vector4`/GL/rendering.
- UI panels target `AcDream.UI.Abstractions` only (rule 3) — the
composition of "structured entry -> ordered list of colored,
optionally-actionable spans" is exactly the kind of pure formatting
logic `ChatVM` already owns (`ChatVM.FormatEntry`/
`RecentLinesDetailed`, `ChatVM.cs:262-371`) and should keep owning;
it must not reach into `AcDream.App` (GL/rendering) types.
- The retained-widget rendering (`AcDream.App/UI/UiText.cs`) is where
GL-adjacent draw calls (`DrawStringDatPass`) and Silk.NET-adjacent
hit-testing (`HitChar`, `UiRoot`) live, and must stay there.
**Proposed layering (three seams, one per project boundary):**
1. **`AcDream.UI.Abstractions` (data shape)** — introduce a
run-carrying line shape parallel to (not replacing) `FormattedLine`.
Sketch: `FormattedLine` gains an optional ordered list of spans, or
a new `RichFormattedLine(IReadOnlyList<FormattedRun> Runs, ...)` is
added, where `FormattedRun` is something like
`(string Text, uint? ActorGuid, bool IsSpeakerName)` — deliberately
NOT carrying a color yet (`AcDream.UI.Abstractions` has no
`System.Numerics`/GL dependency requirement today, but keeping
color resolution in `AcDream.App` mirrors the existing
`RetailChatColorTable`/`ChatTranscriptRenderer` split, where
`ChatVM` supplies `LogTextType`/structured data and
`ChatTranscriptRenderer` in `AcDream.App` resolves it to `Vector4`).
`ChatVM.FormatEntry` (`ChatVM.cs:262-313`) would need a sibling that
returns spans instead of one interpolated string — e.g. split each
`case` into "prefix run" / "sender run" / "suffix run" instead of a
single `$"..."` — and `RecentLinesDetailed` would carry
`entry.Sender`/`entry.SenderGuid` through instead of discarding them
(the §2 fix).
2. **`AcDream.App/UI/Layout` (composition)** — `ChatTranscriptRenderer.BuildLines`
(`ChatTranscriptRenderer.cs:67-95`) is the existing per-controller-
shared seam that already resolves `LogTextType -> Vector4` and
word-wraps. It would gain a variant that word-wraps a RUN LIST
instead of a flat string per entry, producing a new "rendered line
with runs" shape (see below) instead of `UiText.Line`. Both
`ChatWindowController` (`ChatWindowController.cs:778`) and
`FloatingChatWindowController` (same shared function, per the class
doc at `ChatTranscriptRenderer.cs:9-15`) would switch to the new
builder — this is the one place both chat surfaces already share,
so it is the natural single point of change for chat specifically.
3. **`AcDream.App/UI/UiText.cs` (widget)** — this is where the actual
gap is. `Line` needs an additive `Runs` concept for the
MULTI-LINE (`OneLine == false`) path, not just the existing
`OneLine`+`TextRun` path (§1). Minimal shape: extend `Line` (or add
a parallel `RichLine`) to carry `IReadOnlyList<TextRun>` alongside
or instead of a flat `string Text` + single `Vector4 Color`; change
`DrawClippedText`'s multi-line loop (`UiText.cs:636-691`) to, for a
line with runs, do what `DrawSingleLineRuns` already does per-run
(walk runs, accumulate `penX`, call `DrawStringDatPass` per run,
batch all outlines-then-all-fills at the BLOCK level exactly as
`UiText.cs:681-690` already batches across LINES today — extending
that batching one level deeper, across runs within lines, is
mechanical). `HitChar` (`UiText.cs:1031-1050`) needs to additionally
resolve which run (if any) contains the hit `col`, and `OnEvent`'s
`Click` case (`UiText.cs:809-813`) needs a second dispatch path
(run-click, distinct from whole-element `OnClick`) that a controller
(e.g. `ChatWindowController`) can bind to "prefill a tell to this
guid," mirroring how `OnClick` is bound today.
**Existing abstraction that already almost does this:**
`TextRun`/`RunsProvider`/`DrawSingleLineRuns` (§1, `UiText.cs:55,69,693-754`)
is the closest precedent — it proves the draw-side mechanics (measure
run, accumulate pen, per-run `DrawStringDatPass`, block-batched
outline) already work and are exercised in production by
`CharacterStatController.BuildSelectedTitleRuns`
(`src/AcDream.App/UI/Layout/CharacterStatController.cs:1425-1454`,
wired at `CharacterStatController.cs:1680`) for a skill/attribute title
with a colored numeric delta suffix. It is currently scoped to
`OneLine` only and carries no click/actor payload — extending it to
the multi-line/wrapped path and adding a payload field is smaller than
building a new mechanism from scratch. `DatRichText`
(`src/AcDream.App/UI/Layout/DatRichText.cs`, `Segment(string? Text,
Vector4 Color)` at `DatRichText.cs:40`, `Compose` at
`DatRichText.cs:52-88`) is a second, partially-overlapping precedent:
it already composes multiple colored segments for a multi-line box,
but it word-wraps EACH segment independently and concatenates the
results as separate `Line`s (`DatRichText.cs:83-84`) — so two segments
that would visually share one wrapped row are NOT joined onto that row
today; it solves "multiple colors across a paragraph's several lines,"
not "multiple colors sharing one rendered row." A tagged-name-in-chat
feature needs the latter (the name and the rest of the sentence share
row 0 of a possibly-multi-row wrapped message), so neither existing
mechanism is a drop-in — both inform the shape of the fix.
## 6. Blast radius
`UiText.Line`/`LinesProvider` is used extremely broadly — 52 files
reference `LinesProvider = ` and 53 reference `UiText.Line(`/`new
UiText()` (full grep list retained below). **If the change is additive**
(new optional `Runs` field/type alongside the existing `Line`, default
behavior unchanged for every caller that keeps returning plain
`Line`s), the blast radius for BEHAVIOR is limited to whichever
controllers opt in (initially: chat only). The blast radius for
BUILD/COMPILE risk (anything that touches `UiText.cs`, `UiElement.cs`
recompiles the whole `AcDream.App` UI layer) and for REVIEW is the full
list below, grouped by category — every one of these should be
smoke-tested after a `UiText`-internal change even if it doesn't touch
their own code:
- **Chat (the actual feature target):**
`ChatWindowController.cs`, `FloatingChatWindowController.cs`,
`ChatTranscriptRenderer.cs`, `SpewBoxController.cs`
(`src/AcDream.App/UI/SpewBoxController.cs` — retail's other
colored-text-scroll surface, `RetailLogTextType.ClientLocal` per
`ChatVM.cs:159-183`; likely wants the SAME run model eventually since
it renders `LogTextType`-colored lines too).
- **Tooltips:** `RetailTooltipPresenter.cs` — world/UI hover tooltips;
currently plain `Line`s.
- **Appraisal / item & creature reports:** `AppraisalUiController.cs`,
`CreatureAppraisalRows.cs`, `ItemAppraisalReport.cs` — these already
render multi-colored informational text (spell names, damage types)
as SEPARATE `Line`s per colored fragment (one color per whole line,
not per run) — a run model could simplify these, or they could stay
as-is if row-granularity coloring already meets retail fidelity
there (not assessed here — out of this audit's scope).
- **Social panels:** `SocialSquelchPageController.cs`,
`SocialAllegiancePageController.cs`,
`SocialFellowshipPageController.cs`, `SocialFriendsPageController.cs`
— friends/allegiance/fellowship rows; per CLAUDE.md's Campaign FA
notes these already do per-state color swaps on `UiText`, a
different (not run-based) mechanism.
- **Vendor / trade:** `VendorUiController.cs`,
`SecureTradeUiController.cs`.
- **Combat / spellcasting:** `CombatUiController.cs`,
`SpellcastingUiController.cs`, `EffectsUiController.cs`.
- **Dialogs:** `RetailWaitDialogView.cs`, `RetailMessageDialogView.cs`,
`RetailConfirmationDialogView.cs`,
`RetailConfirmationTextInputDialogView.cs`.
- **Character sheet / creation:** `CharacterStatController.cs` (the
existing `TextRun` consumer, §5), `CharacterCreationSkillsPage.cs`,
`CharacterCreationSummaryPage.cs`, `CharacterCreationTownPage.cs`,
`CharacterCreationProfessionPage.cs`,
`CharacterCreationHeritagePage.cs`,
`CharacterManagementUiController.cs`.
- **Options / config:** `ConfigOptionsPageController.cs`,
`ChatOptionsPageController.cs`, `CharacterOptionsPageController.cs`,
`KeyboardConfigController.cs`.
- **Misc panels:** `InventoryController.cs`, `RadarController.cs`,
`MapPageController.cs`, `HousePageController.cs`,
`LinkStatusUiController.cs`, `VitaeUiController.cs`,
`VitalsController.cs`, `RetailFpsController.cs`,
`SelectedObjectController.cs`, `IndicatorDetailText.cs`,
`ComponentBookTemplateFactory.cs`, `EffectRowTemplateFactory.cs`,
`CharacterController.cs`, `DatWidgetFactory.cs` (the factory that
builds every `UiText` from LayoutDesc — touches all of the above by
construction).
- **Tests:** `UiTextTests.cs`,
`SocialFellowshipPageControllerTests.cs`,
`SocialPanelControllerTests.cs`, `RowTemplateResolverTests.cs`,
`DatWidgetFactoryTests.cs`, `CharacterStatControllerTests.cs`,
`VitalsBindingTests.cs`, `AppraisalUiControllerTests.cs` — any of
these that assert on `UiText.Line` shape/count would need review if
`Line`'s shape changes (not if a new type is added additively).
**Net:** an ADDITIVE seam (new run-carrying line type, existing `Line`
untouched) keeps the functional blast radius to chat (and optionally
SpewBox) while still requiring the whole `AcDream.App/UI` tree to
rebuild/retest since it all depends on `UiText.cs`/`UiElement.cs`. A
seam that changes `Line`'s existing shape would force a review pass
across every file in the list above.
## What this audit did NOT do
- Did not research retail's own tagged-glyph-run mechanism (separate
agent's lane per the task brief).
- Did not propose or write any code change — `Line`/`TextRun`/`Segment`
shapes above are illustrative sketches for sizing, not a spec.
- Did not assess whether `AppraisalUiController`/`CreatureAppraisalRows`'s
existing one-color-per-`Line` approach is already retail-faithful for
their own content (out of scope; flagged only as a blast-radius
member).

View file

@ -0,0 +1,543 @@
# Chat log click-to-tag dispatch (retail, Sept 2013 EoR build)
Research-only. Source: `docs/research/named-retail/acclient_2013_pseudo_c.txt`
(PDB-named pseudo-C) and `docs/research/named-retail/acclient.h` (verbatim
retail struct layouts). Every claim below is cited `symbol @ 0xADDRESS`.
Binary Ninja's rendering caveats (misleading compare idioms, ~33-char
truncated inline strings) are called out inline wherever they bit this
investigation.
## TL;DR
The suspected anchor `ChatInterface::SetReplyTextInChatBox @ 0x004F4760` is
**not** the click handler. It is a keyboard text-replacement macro
(`/t `, `/tell `, `reply ` + space → `@tell <LastTeller>,`) wired through
`ChatInterface::HandleTextReplacements @ 0x004F50D0`, itself fired from a
"character typed" UI broadcast, not a mouse event. It happens to share the
`"@tell %s, "` idea with the real click path but is a separate code path
with separate (looser) text.
The real click path is a generic, polymorphic **tag** system:
```
mouse button up over a UIElement_Text
→ UIElement_Text::MouseUp @ 0x004694F0
→ UIElement_Text::DeterminePositionFromXY @ 0x004688F0 (screen xy → glyph index)
→ GlyphList::InqGlyph @ 0x00473430 (glyph index → Glyph, incl. m_tag)
→ TextTag::HandleClick (virtual, vtable+0x14) (dispatch by tag TYPE)
TextTag_IIDString::HandleClick @ 0x00478840
→ ECM_UI::SendNotice_TextTag_IIDStringClick @ 0x006927C0
→ gmMainChatUI::RecvNotice_TextTag_IIDStringClick @ 0x004CCE10
(gate: tag m_type == 0x10000001, chat entry not already focused)
→ ChatInterface::StartTell @ 0x004F41F0
(writes "@tell <Name>, " into the entry, focuses it)
```
Player/speaker names in chat are wrapped by the server/client text
formatters in a `<Tell:IIDString:<iid>:<name>>displayText<\Tell>` markup
span (note: closing marker is a **backslash**, `<\Tell>`, not a
forward-slash — confirmed from the raw literal at
`data_7d0bfc @ 0x007D0BFC` etc.). This markup is used for direct tells,
channel "says" lines, `[Fellowship]`, `[Co-Vassals]`, patron/vassal lines,
and (per the generic `[%ws] <Tell:IIDString:0:%ws>...` format at
`data_7e83e8 @ 0x007E83E8`) ordinary named-channel chat too — i.e. **every**
chat line that shows a speaker name embeds the same tag, not just tells.
Clicking any of them always opens a **tell**, regardless of which channel
the line came from.
---
## 1. Click → glyph → tag resolution
### 1a. Entry point: `UIElement_Text::MouseUp`
`UIElement_Text::MouseUp @ 0x004694F0` is registered directly in
`UIElement_Text`'s vtable slot for `MouseUp` (confirmed at the vtable dump,
e.g. `0079C1A4: MouseUp = UIElement_Text::MouseUp`). The relevant tail,
reached only when the mouse-up's button id was previously recorded as
mouse-down over this same element (`cond:0`, looked up in
`this->m_mouseDownTable` keyed by the button id `arg4`):
```c
// UIElement_Text::MouseUp @ 0x004694F0, tail (0x0046959C-0x004695DD)
if (eax_4 != 0) // cond:0 — this button's mouse-down WAS on this element
{
uint32_t eax_6 = UIElement_Text::DeterminePositionFromXY(this, ebp_2, edi_2);
Glyph var_24;
if (GlyphList::InqGlyph(&this->m_glyphList, eax_6, &var_24) != 0 && var_4 != 0)
*(uint32_t*)(*(uint32_t*)var_4 + 0x14)(arg4); // var_4->HandleClick(arg4)
Glyph::~Glyph(&var_24);
}
```
`ebp_2`/`edi_2` are the mouse position converted to element-local,
margin-adjusted coordinates a few lines earlier in the same function:
```c
int32_t ebp_2 = ((arg2 - this->m_margL) - UIRegion::GetScreenX0(this));
int32_t edi_2 = ((arg3 - this->m_margU) - UIRegion::GetScreenY0(this));
```
`arg2`/`arg3` are the raw screen-space mouse coordinates passed down from
the UI event system; `m_margL`/`m_margU` are the element's left/top text
margins (`UIElement_Text` struct, `acclient.h:53412-53415`).
**BN caveat**: `var_4` (the pointer used for the `var_4 != 0` check and the
virtual call) is never shown being assigned in the decompiled output — it
is almost certainly `var_24.m_tag` after `GlyphList::InqGlyph` copies the
found `Glyph` into `var_24` via `Glyph::operator=`, but the copy-into-field
step is not visible in this rendering. This is exactly the kind of
"misleading compare idiom" the project's BN caveat warns about — flagged
rather than silently assumed. The surrounding evidence (struct layout,
vtable offset match below) makes this the only coherent reading, but it is
not a directly-visible assignment.
### 1b. Screen XY → glyph index: `UIElement_Text::DeterminePositionFromXY`
`UIElement_Text::DeterminePositionFromXY @ 0x004688F0`:
```c
int80_t UIElement_Text::DeterminePositionFromXY(this, arg2 /*local x*/, arg3 /*local y*/)
{
UIElement_Text::RecalculateGlyphList(this); // ensure wrapped-line layout is current
int32_t scrolledY = this->m_iScrollableY + arg3; // undo vertical scroll offset
uint32_t line = 0;
GlyphList::FindLineFromY(&this->m_glyphList, scrolledY, &line); // which wrapped line
uint32_t lineWidthPx = 0;
GlyphList::GetGlyphLineWidth(&this->m_glyphList, line, &lineWidthPx); // that line's pixel width
int32_t lineLocalX = (this->m_iScrollableX + arg2)
- UIElement_Text::CalcJustification(this, lineWidthPx, 1); // undo h-scroll + justification
uint32_t glyphIndex = 0;
GlyphList::FindPosFromLineAndPixels(&this->m_glyphList, line, lineLocalX, 1, &glyphIndex);
// clamp to end-of-text
return min(glyphIndex, this->m_glyphList.m_glyphList._num_elements);
}
```
Plain-language: convert the click's local (x, y) into a *scrolled* position
by adding back however far the text view has been scrolled; use the
scrolled Y to find which **wrapped display line** was clicked
(`GlyphList::FindLineFromY @ 0x00472770`); measure that line's pixel width
(`GlyphList::GetGlyphLineWidth @ 0x00472930`) so the justification offset
(left/center/right alignment, `UIElement_Text::CalcJustification @
0x00467260`) can be subtracted back out of the X; then walk that line's
glyph advances to find which **character index** the X pixel falls on
(`GlyphList::FindPosFromLineAndPixels @ 0x004732D0`). The result is a
single integer: "the click landed on/before character N of the full
(unwrapped) text buffer."
### 1c. Glyph index → Glyph (and its tag): `GlyphList::InqGlyph`
`GlyphList::InqGlyph @ 0x00473430`:
```c
uint8_t GlyphList::InqGlyph(this, arg2 /*index*/, arg3 /*out Glyph*/)
{
ListNode<Glyph>* node = this->m_glyphList._head;
if (node == 0 || arg2 >= this->m_glyphList._num_elements) return 0;
for (int i = 0; i != arg2; i++) { node = node->next; if (node == 0) return 0; }
Glyph::operator=(arg3, node); // copy the whole Glyph struct, incl. m_tag
return 1;
}
```
A straight O(n) linked-list walk (the glyph list is a `List<Glyph>`, not an
array) to the Nth glyph, then a struct copy. The `Glyph` layout
(`acclient.h:45330-45338`):
```c
struct __cppobj Glyph
{
unsigned __int16 m_data; // the character code
int m_width;
int m_height;
RGBAColor m_color; // per-glyph color, baked in at append time (see §4)
Font *m_font;
TextTag *m_tag; // non-null only for glyphs inside a <...> tag span
};
```
`m_tag` is set by `Glyph::SetTag @ 0x00474920` while the glyph list is
built from raw text (see §1d), and cleared in bulk by
`GlyphList::RemoveTextTag @ 0x00472BB0` (walks every glyph, clears any
whose `m_tag` matches the tag being removed — used when a tagged span is
truncated/deleted from the log).
### 1d. Building tags from markup: `TextTagFactory::MakeTag`
Text is appended to a `UIElement_Text` glyph list via
`UIElement_Text::InqGlyphs @ 0x00468EA0`. Its char-scan loop treats `<`
(0x3C) as the start of a tag span: it accumulates characters up to the
matching `>` (0x3E) into a string, then calls
`TextTagFactory::MakeTag @ 0x00478480` on that whole inner string (e.g.
`"Tell:IIDString:1234:PlayerName"`):
```c
TextTag* TextTagFactory::MakeTag(PStringBase<unsigned short> const* tagBody)
{
// tagBody looks like "TypeName:ShapeName:payload..."
if (!FindChar(tagBody, ':')) return 0;
typeNameStr = substring-before-first-colon;
if (EnumMapper::InqEnum(0x18 /*category*/, typeNameStr, &tagType) == 0) return 0; // e.g. "Tell" -> 0x10000001
if (!FindChar(rest, ':')) return 0;
shapeNameStr = substring-before-second-colon; // e.g. "IIDString"
if (EnumMapper::InqEnum(0x18, shapeNameStr, &shapeId) == 0) return 0; // 1..4
switch (shapeId) {
case 1: result = new TextTag_DID(); break;
case 2: result = new TextTag_IID(); break;
case 3: result = new TextTag_IIDEnum(); break;
case 4: result = new TextTag_IIDString(); break;
}
result->m_type = tagType; // from the FIRST lookup ("Tell" -> 0x10000001)
result->m_format = shapeId;
result->ParseStartTag(remaining payload); // shape-specific: fills TextTag_IIDString::m_IID/m_string, etc.
return result;
}
```
(Full disassembly at `docs/research/named-retail/acclient_2013_pseudo_c.txt:132871-133065`;
the two-colon split and the two `EnumMapper::InqEnum(..., 0x18, ...)` calls
are visible at `0x004784E6-0x00478545` and `0x00478577-0x004785DA`.) The
resulting `TextTag*` is stashed on every `Glyph` inside the span via
`Glyph::SetTag @ 0x00474920` as `InqGlyphs` walks the display characters
between the tag's `>` and its closing `<\...>`.
**UNKNOWN — needs a DAT/cdb dump**: `EnumMapper::InqEnum`'s category `0x18`
is a data-driven (DAT-resident, likely `client_portal.dat` StringTable/
EnumMapper resource) name↔id table — the code only proves the *mechanism*,
not the full roster of type-name strings it accepts. We confirmed "Tell"
(→ `m_type == 0x10000001`) and "IIDString" (→ shape id 4, inferred — see
§ "Other tag types" below) from literal format strings elsewhere in the
binary, but did not independently dump the table itself.
### 1e. Vtable-offset proof that `var_4` is the tag and `+0x14` is `HandleClick`
The `TextTag` vtable layout, read straight from the four subclass vtable
dumps (`docs/research/named-retail/acclient_2013_pseudo_c.txt:959321-959368`):
| offset | slot | `TextTag_DID` | `TextTag_IIDString` | `TextTag_IIDEnum` | `TextTag_IID` |
|---|---|---|---|---|---|
| 0x00 | `__vecDelDtor` | ✓ | ✓ | ✓ | ✓ |
| 0x04 | `ParseEndTag` | `TextTag::ParseEndTag` (shared) | shared | shared | shared |
| 0x08 | `ParseStartTag` | own | own | own | own |
| 0x0C | `BuildEndTag` | `TextTag::BuildEndTag` (shared) | shared | shared | shared |
| 0x10 | `BuildStartTag` | `TextTag::BuildStartTag` (shared) | shared | shared | shared |
| **0x14** | **`HandleClick`** | **own** | **own** | **own** | **own** |
| 0x18 | `BuildStartTagData` | own | own | own | own |
`+0x14` is exactly `HandleClick`, confirming `MouseUp`'s
`*(uint32_t*)(*(uint32_t*)var_4 + 0x14)(arg4)` is `var_4->HandleClick(arg4)`
— a virtual call, i.e. this is dispatched **per concrete tag subclass**,
not a single hardcoded action.
---
## 2. What fires on click: generic dispatch, not hardcoded to tells
All four subclasses' `HandleClick` do the same shape of thing — forward to
a global "notice" (AC's internal pub/sub event system) carrying the tag's
type + payload, nothing else:
```c
// TextTag_DID::HandleClick @ 0x00478740
ECM_UI::SendNotice_TextTag_DIDClick(this->m_type, this->m_DID.id); // @ 0x006926D0
// TextTag_IID::HandleClick @ 0x00478E80
ECM_UI::SendNotice_TextTag_IIDClick(this->m_type, this->m_IID); // @ 0x00692720
// TextTag_IIDEnum::HandleClick @ 0x00478B40
ECM_UI::SendNotice_TextTag_IIDEnumClick(this->m_type, this->m_IID, this->m_enum); // @ 0x00692770
// TextTag_IIDString::HandleClick @ 0x00478840
ECM_UI::SendNotice_TextTag_IIDStringClick(this->m_type, this->m_IID, &this->m_string); // @ 0x006927C0
```
So the dispatch **is** generic — any listener can register for any of the
four notices and react to any `m_type`. In this build, though, only ONE of
the four notices has an actual (non-stub) listener anywhere in the client:
- `gmMainChatUI::RecvNotice_TextTag_IIDStringClick @ 0x004CCE10` — real,
wired to chat's click-to-tell (see §3).
- `NoticeHandler::RecvNotice_TextTag_IIDEnumClick @ 0x006A0240` is declared
`__pure` — a pure-virtual stub, no base behavior, and no override for it
was found anywhere in this pass.
- No function definition for a real `RecvNotice_TextTag_DIDClick` or
`RecvNotice_TextTag_IIDClick` override exists anywhere in the pseudo-C
file either — every other hit for those names is vtable-slot noise (the
decompiler filling unresolved thunk slots with neighboring symbol names;
cross-checked, none are real function bodies with those signatures).
**Conclusion**: the mechanism is generic (4 tag shapes × arbitrary
`m_type` values × arbitrary listeners), but in this Sept 2013 build only
the chat-log "clickable speaker name → start a tell" feature is actually
wired up end-to-end. `TextTag_DID`/`TextTag_IID`/`TextTag_IIDEnum` exist,
parse, and would dispatch correctly if clicked, but nothing in the client
reacts to their click notices — **UNKNOWN whether item links / URLs /
coordinates use these shapes in later builds or via server-composed text
we didn't grep for**; no evidence of them was found in this pass.
### Other tag types found (full roster)
| Class | Shape id (inferred) | Ctor | `HandleClick` | Real listener found? |
|---|---|---|---|---|
| `TextTag_DID` | 1 | `0x00478760` | `0x00478740` | No |
| `TextTag_IID` | 2 | `0x00478E60` | `0x00478E80` | No |
| `TextTag_IIDEnum` | 3 | `0x00478B20` | `0x00478B40` | No |
| `TextTag_IIDString` | 4 | `0x00478860` | `0x00478840` | **Yes**`gmMainChatUI` |
Shape-id-to-class mapping is inferred from the `switch(shapeId){case
1..4}` construction order in `MakeTag` (`0x00478632`/`0x004785E1`/
`0x004785FC`/`0x00478617`) plus the fact that the only shape name we can
directly read from format strings ("IIDString") is used everywhere the
"Tell" markup appears, which always constructs `TextTag_IIDString`. This
is strong circumstantial evidence, not a direct read of the DAT enum
table — flagged per the "no guessing" rule.
---
## 3. The prefill itself: `ChatInterface::StartTell`
`gmMainChatUI::RecvNotice_TextTag_IIDStringClick @ 0x004CCE10` is the
registered listener for `SendNotice_TextTag_IIDStringClick`:
```c
void gmMainChatUI::RecvNotice_TextTag_IIDStringClick(this, uint32_t type, uint32_t iid, PStringBase<unsigned short> const* name)
{
if (type == 0x10000001 && ChatInterface::IsTextEntryFocused(this) == 0)
ChatInterface::StartTell(this, name);
}
```
Two gates: (a) the tag's semantic type must be `0x10000001` — i.e. only
"Tell"-markup spans do anything on click, other `m_type` values on an
`IIDString` tag (if any exist) are silently ignored here; (b) the chat
**entry box must not already have keyboard focus** — if the player is
mid-sentence typing something else, clicking a name in the log does
nothing (`ChatInterface::IsTextEntryFocused @ 0x004F30A0`, which checks
`UIElementManager`'s active/focused element against `this->m_chatEntry`).
Note the tag's own `iid` payload (`arg3`/`m_IID`) is read into the
parameter list but **never used** by this handler — only the embedded name
string matters.
`ChatInterface::StartTell @ 0x004F41F0`:
```c
void ChatInterface::StartTell(this, PStringBase<unsigned short> const* name)
{
PStringBase<unsigned short> text = Formatted(u"@tell %s, ", name); // note: trailing space after comma
this->m_chatEntry->vtable->Activate();
this->m_chatEntry->vtable->TakeFocus(); // <-- keyboard focus moves to the chat entry
CM_UI::SendNotice_ToggleChatEntry(1); // <-- ensures the chat entry bar is shown
UIElement_Text::SetText(this->m_chatEntry, &text);
UIElement_Text::MoveCursorToPosition(this->m_chatEntry, /* length of `text` */);
UIElement_Text::ClearSelection(this->m_chatEntry);
}
```
So, precisely:
- **Text placed**: `"@tell <PlayerName>, "` — literal `@tell`, a space,
the name, a comma, and a **trailing space** (ready to type the message
body immediately).
- **Focus**: YES, explicitly changed. `Activate()` + `TakeFocus()` move
keyboard focus into the chat entry field, and
`CM_UI::SendNotice_ToggleChatEntry(1) @ 0x0047A200` broadcasts a notice
whose real handler, `ClientUISystem::RecvNotice_ToggleChatEntry @
0x00564200`, is what shows/expands the chat entry bar if it was
currently hidden (confirmed as the one non-stub override among many
vtable-slot look-alikes for that notice name).
- **Cursor**: placed at the end of the inserted text (right after the
trailing space), any prior selection cleared.
- Whatever the player had already typed into the box (if it wasn't
focused — see the focus gate above) is **replaced outright**, not
merged or prepended to.
---
## 4. Hover behavior: color is static per-tag-type, not a hover effect
`UIElement_Text::MouseMove @ 0x004695F0` was checked directly — it does
**no** glyph/tag lookup at all. It only handles active text-selection
dragging (`this->m_bitField & 0x40`) or falls back to the base
`UIElement::MouseMove`. `UIElement_Text::GetShouldBeMouseVisible @
0x00467460` likewise only inspects `this->m_bitField & 5` (an
editable/selectable flag), not glyph tags. **No hover-triggered highlight,
brightening, or cursor-icon change tied to `Glyph::m_tag` was found
anywhere in this pass.**
What *is* real, and is presumably what reads as "the name is green," is a
**static per-glyph color chosen at glyph-construction time**, based on
whether the glyph belongs to an active `0x10000001`-typed tag span. Inside
`UIElement_Text::InqGlyphs @ 0x00468EA0`, right after a tag span is
opened/continues:
```c
// 0x00469084-0x0046908A, ebx_1 = the currently-active TextTag* for this glyph (0 if none)
if (ebx_1 == 0 || ebx_1->m_type != 0x10000001)
color = &this->m_curFontColor; // offset 0x6A4 on UIElement_Text
else
color = &this->m_curTagFontColor; // offset 0x6B8 on UIElement_Text
// ... color is then baked into the new Glyph's m_color field
```
`UIElement_Text`'s struct (`acclient.h:53392-53420`) confirms two distinct
color fields exist: `RGBAColor m_curFontColor;` and
`RGBAColor m_curTagFontColor;`, set via
`UIElement_Text::SetFontColorHelper(this, attrId, &field, colorIndex)`
with **different authored attribute ids**`0x1B` for `m_curFontColor`,
`0x1D` for `m_curTagFontColor` (seen consistently at
`UIElement_Text::AppendTextWithFont @ 0x00469D70` and
`AppendStringInfoWithFont @ 0x00469DE0`). `SetFontColorHelper @
0x00466AC0` treats its 4th argument as an **index into an authored color
array** (an `InqProperty`-backed attribute, not a raw RGBA value) — so
`FontColor` and `TagFontColor` are two independently-authored per-window
color tables (almost certainly LayoutDesc-driven, consistent with this
project's existing DAT-driven-UI findings), and can hold different colors
at the same index. That is the entire "green name" mechanism: **it's
baked into the glyph once, from data, when the text is appended — not
computed or changed on mouse hover.**
`m_curTagFontColor` defaults to `RGBAColor_White` (`0x00468641`) unless a
window's layout overrides attribute `0x1D` — chat windows presumably do.
**UNKNOWN — needs DAT/runtime inspection**: the actual authored RGBA
values (attribute `0x1D`'s color table) live in a LayoutDesc DAT resource,
not in code; not dumped in this pass. Cross-reference
`claude-memory/reference_retail_chat_colors.md` for retail chat-color
ground truth already captured via cdb.
---
## 5. Is `SetReplyTextInChatBox` the click handler? No — keyboard macro only
`ChatInterface::SetReplyTextInChatBox @ 0x004F4760` has exactly one
caller in the whole binary: `ChatInterface::HandleTextReplacements @
0x004F50D0`, which tries three "quick reply" expanders in order:
```c
void ChatInterface::HandleTextReplacements(this)
{
if (this->m_chatEntry == 0) return;
if (!ChatInterface::SetReplyTextInChatBox(this)) // @ 0x004F4760 — replies to LAST TELLER
if (!ChatInterface::SetMonarchReplyTextInChatBox(this)) // @ 0x004F4B70 — replies to monarch
ChatInterface::SetPatronReplyTextInChatBox(this); // @ 0x004F4EA0 — replies to patron
}
```
`HandleTextReplacements` is itself called from exactly one place:
`ChatInterface::ListenToElementMessage @ 0x004F51C0`, `case 0x11` (i.e.
`idMessage == 0x12`), gated on `arg2->dwParam1 == 0x20` (ASCII space) and
the message originating from the chat entry element:
```c
case 0x11: // idMessage == 0x12
if (arg2->pElement == this->m_chatEntry /* decompiled as m_fCurrentOpacity, mis-attributed field */
&& arg2->dwParam1 == 0x20)
ChatInterface::HandleTextReplacements(this);
break;
```
Message id `0x12` is confirmed elsewhere as the "character typed"
broadcast: `UIElement_Text::CharacterHandler @ 0x00469B90` ends its
non-control-character path with
`UIElement::BroadcastElementMessage(this, 0x12, typedChar, 0) @
0x00469CAF` — `dwParam1` carries the raw character code. So this whole
path only fires **while the player is typing into the chat entry box and
presses the SPACE bar**, and only after typing one of a small set of
recognized prefixes.
`SetReplyTextInChatBox` itself: reads the entry's current (trimmed) text,
checks whether it starts with `/` or `@`, and if so, matches the
first-word substring against known shortcut prefixes (`"t"`/`"te"`-style
12 char abbreviations at `data_7c4c70`/`data_7c4c68`, and the literal
`u"reply "` at `0x004F4A04`). If matched, it replaces that recognized
prefix (leaving anything typed after it in place) with:
```c
gmCCommunicationSystem::GetLastTellerName(...) @ 0x00589550 // whoever LAST sent YOU a tell
Formatted(u"@tell %hs,", lastTellerName) // note: NO trailing space, %hs = narrow string
UIElement_Text::SetText / MoveCursorToPosition / ClearSelection // @ 0x004F4ADC-0x004F4AFA
```
This is a **different string** from the click path's `"@tell %s, "` (no
trailing space here; `%hs` explicitly narrow-string-formats
`GetLastTellerName`'s `PStringBase<char>*` return, vs. the click path's
already-wide `PStringBase<unsigned short>` name) — a small but real
divergence between the two "start a tell" paths worth preserving if both
get ported. `SetMonarchReplyTextInChatBox @ 0x004F4B70` and
`SetPatronReplyTextInChatBox @ 0x004F4EA0` are structurally identical,
sourcing the name from `GetLastAtMonarchUserName`/`GetLastAtPatronUserName`
instead.
**Verdict**: `SetReplyTextInChatBox` is a **keyboard-shortcut/text-macro
handler only** — triggered by typing a recognized prefix then a space in
the chat entry. It shares the "write `@tell Name,` into the entry" idea
with the click path but is a wholly separate call chain, keyed off "last
person who told me something" global state
(`gmCCommunicationSystem::SetLastTeller @ 0x005891A0`,
`SetLastTellerName @ 0x00589500`) rather than the specific name embedded
in the clicked chat line's tag. It is **not** invoked by, and does not
invoke, any part of the click-to-tag chain in §1§3.
A consequence worth flagging for the port: because the click path reads
the name baked into that *specific* chat line's tag, clicking an **old**
"X tells you" line further up the scrollback still starts a tell to X,
even if X is no longer whoever last told you something — whereas the
`/t `+space keyboard shortcut always resolves to the single global
"last teller," which could be a different person by then.
---
## 6. Where the clickable markup comes from (bonus — answers "why does this reproduce on so many message types")
The `<Tell:IIDString:<iid>:<name>>displayText<\Tell>` span is built by
`sprintf`-style formatting at multiple sites, not just for direct tells.
Representative literals (some inline strings are BN-truncated at ~33
chars, marked `…`):
| Format string (verbatim where fully visible) | Address | Used for |
|---|---|---|
| `"<Tell:IIDString:%d:%s>%s<\Tell> tells you, \"%s\"\n"` | `data_7d0ec0 @ 0x007D0EC0` | direct tell received |
| `"<Tell:IIDString:%d:%s>%s<\Tell> says, \"%s\"\n"` | `data_7d0e60 @ 0x007D0E60` | local/say-range speech |
| `"[Fellowship] <Tell:IIDString:0:%s>%s<\\Tell> says, \""` | `data_7d0cdc @ 0x007D0CDC` | fellowship chat |
| `"[Co-Vassals] <Tell:IIDString:0:%s>%s<\\Tell> says, \""` | `data_7d0bfc @ 0x007D0BFC` | co-vassal chat |
| `"[Allegiance Broadcast] <Tell:IIDString:0:%s>%s<\\Tell> says, \""` | `data_7d0c30 @ 0x007D0C30` | allegiance broadcast |
| `"Your patron <Tell:IIDString:0:%s>%s<\\Tell> says to you, \""` | `data_7d0d10 @ 0x007D0D10` | patron chat |
| `"Your vassal <Tell:IIDString:0:%s>%s<\\Tell> says to you, \""` | `data_7d0d4c @ 0x007D0D4C` | vassal chat |
| `"Your follower <Tell:IIDString:0:%s>%s<\\Tell> says to you, \""` | `data_7d0ca0 @ 0x007D0CA0` | follower chat |
| `"[%ws] <Tell:IIDString:0:%ws>%ws<\Tell> says, \"%ws\""` | `data_7e83e8 @ 0x007E83E8` | generic named channel say (channel name in `[%ws]`) |
Confirms the closing marker is a literal backslash `<\Tell>` in the raw
string data (not the HTML-style `</Tell>` one might assume), and that the
non-direct-tell variants hardcode the `iid` field to `0` (the click
handler ignores `iid` anyway, so this has no functional effect — but it
means the tag's `m_IID` is meaningless/decorative for anything except
direct tells). The gating logic for direct tells
(`docs/research/named-retail/acclient_2013_pseudo_c.txt:382537-382553`,
`gmCCommunicationSystem`-adjacent code around `0x00571880`) only emits the
tagged, clickable form when the sender's id falls in the player-character
GUID range (`0x50000001`-`0x6FFFFFFF`); tells attributed to ids outside
that range fall back to the plain, non-clickable
`"%s tells you, \"%s\"\n"` format (`0x00571880` false branch) — so
non-player "tells" (system/GM broadcast-as-tell, etc.) are never
clickable.
---
## Open gaps (explicitly not resolved here)
- **UNKNOWN**: the full roster of `EnumMapper` category `0x18` type-name
strings (only "Tell" and, by strong inference, "IIDString" are
confirmed). A DAT dump or a cdb breakpoint on `EnumMapper::GetString`
with category `0x18` would enumerate the rest and settle whether item
links/URLs/coordinates exist as other `m_type` values on the same
`IIDString` shape, or as `DID`/`IID`/`IIDEnum` shapes instead.
- **UNKNOWN**: authored RGBA values behind `m_curFontColor`/
`m_curTagFontColor` attribute ids `0x1B`/`0x1D` for the chat log window
specifically — lives in a LayoutDesc DAT resource, not code.
- **Confirmed absence, not a gap**: no hover-only visual/cursor change was
found for tagged glyphs in this build (`MouseMove`,
`GetShouldBeMouseVisible` both checked directly and neither look at
`Glyph::m_tag`).

View file

@ -0,0 +1,469 @@
# Retail chat: how a tagged, coloured player-name run gets composed
Research-only. No source files modified. All claims cite `symbol @ 0xADDRESS`
in `docs/research/named-retail/acclient_2013_pseudo_c.txt` (pseudo-C) or
`docs/research/named-retail/acclient.h` (verbatim retail struct defs) unless
otherwise noted. Addresses without an explicit file are in the pseudo-C dump.
## TL;DR — the mechanism in one paragraph
Retail does **not** use `StringInfo`'s two-colour-argument mechanism
(`RecvNotice_DisplayFinalStringInfo`'s `arg3`/`arg4`) to colour the player
name differently from the rest of the sentence. `StringInfo` is only a
**localization/variable-substitution template** (string-table id + named
variables, or a literal override) — it has no colour or tag fields at all.
Instead, the sender's name is delivered as a **literal inline markup tag**,
`<Tell:IIDString:<GUID>:<Name>>Name<\Tell>`, baked directly into the plain
wide-char sentence *before* it is ever handed to the UI. When that sentence
is appended to the chat log, `UIElement_Text`'s glyph-list builder
(`UIElement_Text::InqGlyphs @ 0x00468ea0`) recognizes the `<...>` markup,
asks `TextTagFactory::MakeTag @ 0x00478480` to parse it into a
`TextTag_IIDString` object (GUID + name), and colours **every individual
`Glyph`** inside the tagged span from `m_curTagFontColor` (font-color
property `0x1D`) instead of the line's `m_curFontColor` (property `0x1B`) —
but **only if the tag's type is the "Tell" enum value `0x10000001`**. Each
`Glyph` also carries a `TextTag*` pointer, which is what makes the run
clickable and lets a click resolve back to the right player.
## 1. Where the sender name becomes a tagged run
### 1a. The literal tag text is baked in at message-composition time
Two adjacent handlers on `ClientCommunicationSystem` build the chat line
for incoming speech, and both embed the markup directly via `sprintf`,
*before* any StringInfo/UI code runs:
- **Local/overheard speech**`ClientCommunicationSystem::Handle_Communication__HearSpeech @ 0x005712a0`:
```
005714f5 if ((arg4 < 0x50000001 || arg4 > 0x6fffffff))
005714f5 PStringBase<char>::sprintf(&s_NullBuffer_2, "%s says, \"%s\"\n"); // no tag
005714f5 else
00571511 PStringBase<char>::sprintf(&s_NullBuffer_2, "<Tell:IIDString:%d:%s>%s<\Tell> says, \"%s\"\n");
```
(full literal recovered from the constant pool: `data_7d0e60 @ 0x007d0e60`
= `"<Tell:IIDString:%d:%s>%s<\\Tell> says, \"%s\"\n"`, since Binary
Ninja's inline preview truncates at ~33 chars).
- **Direct tell**`ClientCommunicationSystem::Handle_Communication__HearDirectSpeech @ 0x005715a0`, same shape:
```
00571880 if ((arg4 < 0x50000001 || arg4 > 0x6fffffff))
00571880 PStringBase<char>::sprintf(&arg5, "%s tells you, \"%s\"\n"); // no tag
00571880 else
0057189c PStringBase<char>::sprintf(&arg5, "<Tell:IIDString:%d:%s>%s<\Tell> tells you, \"%s\"\n");
```
(full literal: `data_7d0ec0 @ 0x007d0ec0` =
`"<Tell:IIDString:%d:%s>%s<\\Tell> tells you, \"%s\"\n"`).
`%d` = `arg4`, the speaker's actual object GUID from the wire message.
`%s` (first) = the speaker's display name (repeated once inside the tag
payload, once again as the visible glyph text after the `>`).
**The `0x50000001..0x6FFFFFFF` GUID-range gate is load-bearing**: only
senders whose object id falls in that range get the clickable/coloured
treatment at all. This is AC1's dynamic-object id range (players and other
non-static weenies); ids outside it (system/NPC broadcast cases handled
elsewhere) fall through to the plain, untagged `"%s says/tells...` format
with no markup and no special colour.
Group/channel broadcasts go through a **separate** builder,
`ChatRoomTracker::GetChatFormat @ 0x005cd7c0` (called from
`gmCCommunicationSystem::uiChatInterfaceProvider::OnSendToRoom @ 0x0058a590`,
the TurbineChat room-message handler), which always uses `IID:0` in the tag
(no real object id is available/needed there) and prepends the channel
name, e.g. for General chat:
```
005cd93a ebx = 0x1b; // LogTextType = General
005cd93f var_1c_14 = &ChannelSystem::General_GlobalChannelName;
005cd85c sprintf(&s_NullBuffer_2, "[%ws] <Tell:IIDString:0:%ws>%ws<\\Tell> says, \"%ws\"");
```
(full literal: `data_7e83e8 @ 0x007e83e8` =
`"[%ws] <Tell:IIDString:0:%ws>%ws<\\Tell> says, \"%ws\""`). The function
returns a `ChatDisplayInfo{ m_ltt (LogTextType), m_display (the whole
sprintf'd string), m_doDisplayText }` and the caller passes `m_display` and
`m_ltt` straight into `ClientSystem::AddTextToScroll`. Similar hard-coded
`IID:0` tag formats exist for Fellowship broadcast (`"[Fellowship] <Tell:IIDString:0:%s>%s<\\Tell> says, \""`,
`data_7d0cdc @ 0x007d0cdc`), Co-Vassals (`data_7d0bfc @ 0x007d0bfc`),
Allegiance Broadcast (`data_7d0c30 @ 0x007d0c30`), patron/vassal/follower
tells (`data_7d0d10`, `data_7d0d4c`, `data_7d0ca0`).
**BN-truncation note**: every one of the `sprintf(..., "<Tell:IIDString:...")`
call sites above showed only a `…`-truncated ~33-char preview in the raw
decompiler output. The full strings quoted here were recovered from the
named constant-pool entries (`data_7d0bfc`, `data_7d0c30`, `data_7d0c88`,
`data_7d0ca0`, `data_7d0cdc`, `data_7d0d10`, `data_7d0d4c`, `data_7d0e60`,
`data_7d0ec0`, `data_7e83e8`), which the pseudo-C dump prints verbatim
elsewhere in the file as global `char const data_XXXXXXXX[N] = "...", 0`
declarations — not guessed.
### 1b. The plain sprintf'd string reaches the chat window unmodified
`ClientSystem::AddTextToScroll @ 0x00487fc0` (char* overload) widens to
UTF-16 and tail-calls the wide overload `@ 0x00563c50`, which wraps the
**already-composed** text (markup and all) in a **literal-override**
`StringInfo` and forwards it to the notification bus:
```
00563ee9 StringInfo::SetLiteralValue(&var_890, &m_charbuffer_3 /* full "<Tell:...>...\"" text */, 1);
00563f05 StringInfo::SetLiteralValue(&var_920, &s_NullBuffer_4 /* timestamp string, or empty */, 1);
00563f2b ECM_UI::SendNotice_DisplayFinalStringInfo(arg3 /* LogTextType/colour index */, &var_890, &var_920, arg5 /* window id */);
```
This confirms `StringInfo` here is used purely as a **transport wrapper**
around an already-fully-formed literal string — `StringInfo::SetLiteralValue`
sets `m_Override = 1` (literal) so `StringInfo::GetString` later just
returns the wrapped text verbatim; no template/variable substitution
happens for chat lines built this way. (See §4 for why `StringInfo` cannot
itself be the tag carrier.)
`ChatInterface::RecvNotice_DisplayFinalStringInfo @ 0x004f4640` (the
override registered on `ChatInterface`) receives this notice:
```
004f46dc if (StringInfo::IsValid(arg4, 1) != 0)
004f46e9 UIElement_Text::AppendStringInfoWithFont(this->m_chatLog, arg4, 0, 0xc); // arg4 = timestamp StringInfo, colour index 0x0C
004f46fc UIElement_Text::AppendStringInfoWithFont(this->m_chatLog, arg3, 0, arg2); // arg3 = the message StringInfo, colour index arg2 (LogTextType)
```
`arg4` (the *second* StringInfo, appended first, at the fixed colour index
`0x0C`) is the **timestamp prefix** (`"HH:MM:SS "`, built a few lines
earlier in `AddTextToScroll` from `PlayerModule::DisplayTimeStamps` +
`wcsftime`), not a channel-name prefix. `arg3` (colour index `arg2` = the
caller-supplied LogTextType) is the **entire rest of the line**, channel
prefix and all — see §3 for why that resolves the "0x0C is grey" question.
### 1c. `AppendStringInfoWithFont` resolves the literal text and hands it to the glyph parser
`UIElement_Text::AppendStringInfoWithFont @ 0x00469de0`:
```
00469df4 UIElement_Text::SetFontDIDHelper(this, 0x1a, &this->m_curFontObj, arg3);
00469e09 UIElement_Text::SetFontColorHelper(this, 0x1b, &this->m_curFontColor, arg4); // line colour, keyed by LogTextType index arg4
00469e1a UIElement_Text::SetFontColorHelper(this, 0x1d, &this->m_curTagFontColor, arg4); // tag colour, same index
00469e2a eax_1 = StringInfo::GetString(arg2, &arg3, 0); // resolves literal-override text verbatim
00469e44 UIElement_Text::AddText_Internal(this, m_charbuffer, 3);
```
`m_curFontColor` and `m_curTagFontColor` are named fields on
`UIElement_Text` (`docs/research/named-retail/acclient.h:53408,53410`):
```
struct __cppobj __declspec(align(8)) UIElement_Text : UIElement_Scrollable, CInputHandler
{
...
RGBAColor m_curFontColor;
Font *m_curFontObj;
RGBAColor m_curTagFontColor;
...
};
```
So **before any markup parsing happens**, the widget primes two colours
for the whole append call — the base line colour and a *separate* tag
colour — both looked up via the exact same LogTextType-indexed mechanism
(`UIElement_Text::SetFontColorHelper @ 0x00466ac0`, which does
`InqProperty(propId) → indexed-array element at [arg4]`).
### 1d. The glyph-list builder recognizes `<...>` and creates the `TextTag`
`UIElement_Text::InqGlyphs @ 0x00468ea0` (the routine `AddText_Internal`
uses to turn the resolved wide string into `Glyph` records) scans
char-by-char; on finding `<` it captures through the matching `>` and
calls the tag factory:
```
00469021 int32_t eax_16 = TextTagFactory::MakeTag(); // parses the whole "<Tell:IIDString:GUID:Name>" span
00469084 if (ebx_1 == 0 || tag->m_type != 0x10000001)
00469084 edx_15 = <UIElement_Text + 0x6a4>; // this->m_curFontColor (RGBAColor field order matches struct above)
00469084 else
0046908a edx_15 = <UIElement_Text + 0x6b8>; // this->m_curTagFontColor
004690c5 Glyph::Glyph(&esp_1[9]); // constructs the glyph with the chosen colour + tag pointer
```
(Note: this function is heavily register/stack-mangled in Binary Ninja's
output — the raw operand forms above are paraphrased from the literal
`esp`/`ecx` chains in the dump, not verbatim BN text, because the BN
pseudo-C here reads as raw stack-slot arithmetic rather than named field
accesses. The two struct-offset destinations (`+0x6a4`, `+0x6b8`) are
`0x14` bytes apart, matching an `RGBAColor` (16 bytes) + `Font*` (4 bytes)
gap between `m_curFontColor` and `m_curTagFontColor` in the struct dump
above — consistent with, but not a byte-for-byte confirmed alias of, those
two named fields.)
`Glyph` itself carries **per-character** colour and tag
(`docs/research/named-retail/acclient.h:45330`):
```
struct __cppobj Glyph
{
unsigned __int16 m_data; // character code
int m_width;
int m_height;
RGBAColor m_color; // per-glyph colour — this is what makes the name a different colour from the rest of the line
Font *m_font;
TextTag *m_tag; // non-null only for glyphs inside a <Tag:...>...<\Tag> span — this is what makes it clickable
};
```
**This is the answer to "who creates the tagged run": `TextTagFactory::MakeTag @ 0x00478480`**,
called from `UIElement_Text::InqGlyphs @ 0x00468ea0` while it walks the
resolved plain-text string looking for `<...>` markers. It is a **markup
parser operating on plain text**, not a StringInfo/variable mechanism.
`TextTagFactory::MakeTag @ 0x00478480` itself:
1. Confirms the captured span starts with `<` and ends with `>`.
2. Splits on the first `:` — the text before it (e.g. `"Tell"`) is looked
up via `EnumMapper::InqEnum(name, 0x18, &m_type)` (a DAT-driven
string→enum table, category `0x18`) to get the numeric tag **type**
(`this->m_type`, e.g. `0x10000001` for `"Tell"`).
3. Splits again on the next `:` — the text between them (e.g.
`"IIDString"`) is looked up the same way to get a small **class**
selector (`var_18`, 14), which a `switch` uses to instantiate the
right `TextTag` subclass:
- `1``TextTag_DID`
- `2``TextTag_IID`
- `3``TextTag_IIDEnum`
- `4``TextTag_IIDString` (jump table `jump_table_478728 @ 0x00478728`, case `4 @ 0x00478617`)
4. Calls the new tag's virtual `ParseStartTag` on the remaining payload
text (everything after the second `:`, i.e. `"<GUID>:<Name>"`).
## 2. Tag payload — what a click needs to address the right player
`struct TextTag_IIDString : TextTag { unsigned int m_IID; PStringBase<unsigned short> m_string; }`
(`docs/research/named-retail/acclient.h:53960`), with the base class
`struct TextTag : ReferenceCountTemplate<1048576,0> { unsigned int m_type; unsigned int m_format; }`
(`docs/research/named-retail/acclient.h:45358`).
`TextTag_IIDString::ParseStartTag @ 0x00478910` fills it in:
```
00478946 if (PStringBaseIter_Common<unsigned short>::FindChar(&iter, ':', 0) != 0) // find the FIRST ':' in "GUID:Name"
00478a02 if (PSUtils::is_uint32(leftPart) != 0) // left of ':' must parse as a uint32
004788ad this->m_IID = PStringBase<unsigned short>::to_uint32(&leftPart); // -> numeric object GUID
00478ae2 PStringBase<unsigned short>::operator=(&this->m_string, &rightPart); // -> display name text
```
So the payload is **both** the numeric object id **and** the display name
string, not just one or the other. `TextTag_IIDString::BuildStartTagData @ 0x004788e0`
is the inverse (serializes back to `"0x%08X:%ls"`), confirming the same
two-field shape round-trips.
**Click resolution** (`TextTag_IIDString::HandleClick @ 0x00478840`):
```
0047884c ECM_UI::SendNotice_TextTag_IIDStringClick(this->m_type, this->m_IID, &this->m_string);
```
which is picked up by `gmMainChatUI::RecvNotice_TextTag_IIDStringClick @ 0x004cce10`:
```
004cce1b if (arg2 == 0x10000001 && ChatInterface::IsTextEntryFocused(this) == 0) // arg2 = tag->m_type ("Tell")
004cce2d ChatInterface::StartTell(this, arg4); // arg4 = tag->m_string — starts a /tell using the NAME, not the GUID
```
So in the one client-side consumer we traced, the actual action
(`StartTell`) only uses the **name string**, even though the tag also
carries the numeric GUID. The GUID is transmitted through the notice
(`arg3`) but this handler doesn't consume it — it may be used by other,
untraced `RecvNotice_TextTag_IIDStringClick` overrides (several other UI
classes register the same override — see the vtable-slot list in the
pseudo-C dump around `0x0079e580` onward — only `gmMainChatUI`'s and the
base `NoticeHandler::RecvNotice_TextTag_IIDEnumClick` fallback were read in
this pass). **UNKNOWN — needs a scan of every other `RecvNotice_TextTag_IIDStringClick`
override** if a consumer that actually resolves by GUID matters for
acdream's design (e.g. distinguishing two players who changed names, or a
"select in world" action).
## 3. The `[General]` channel prefix — colour, and resolving the 0x0C puzzle
**Resolved: the apparent "0x0C is grey" contradiction was a mis-identification
on my part before tracing the code, not a real contradiction.** Index `0x0C`
is not the channel-prefix colour — it's hard-coded in
`ChatInterface::RecvNotice_DisplayFinalStringInfo @ 0x004f4640` (§1b) as the
colour for the **timestamp** StringInfo (`arg4`), which is entirely
separate from the channel-prefixed message text (`arg3`). Confirmed against
`ChatInterface::BuildChatColorLookupTable @ 0x004f31c0` (see below): index
`0x0C`'s colour **is** `colorGrey` — exactly matching the existing project
note. It's grey because it's the timestamp, not because it's "[General]".
`"[General]"` and the rest of the line (`says, "..."`, including the
embedded name tag's line-colour-before-override) share **one** LogTextType
value for the whole assembled string — set in
`ChatRoomTracker::GetChatFormat @ 0x005cd7c0` (§1a):
```
005cd93a ebx = 0x1b; // General
005cd949 ebx = 0x1c; // Trade
005cd95c ebx = 0x1d; // LFG
005cd96d ebx = 0x1e; // Roleplay
005cd97e ebx = 0x12; // Olthoi
005cd9b8 ebx = 0x20; // Society (all variants)
```
`ChatInterface::BuildChatColorLookupTable @ 0x004f31c0` builds **one**
LogTextType-indexed colour array (`BaseProperty` at property id `0x1B`,
34 entries, indices `1..0x22`) applied to `this->m_chatLog`. It defaults
every index to `colorGreen @ 0x81c578` and then overrides ~27 of them.
Reading the override sequence address-by-address against the named
`RGBAColor` globals in the constant pool (`0x81c4a8`..`0x81c598`, each
printed with a name, e.g. `class RGBAColor colorGrey = { r=0.824 g=0.824
b=0.784 a=1 }`) gives this full index→colour table:
| LogTextType index | Colour name | RGBA |
|---|---|---|
| 2 | colorWhite | (1, 1, 1, 1) |
| 0x0C | colorGrey | (0.824, 0.824, 0.784, 1) — **timestamp**, not General |
| 3, 0xA, 0x13, 0x1F | colorYellow | (1, 1, 0.247, 1) |
| 4, 0xB | colorTan | (0.824, 0.824, 0.392, 1) |
| 5 | colorBrightPurple | (1, 0.498, 1, 1) |
| 6, 0xF, 0x15 | colorDarkRed | (1, 0.247, 0.247, 1) |
| 7, 0x11 | colorLightBlue | (0.247, 0.749, 1, 1) |
| 8, 9 | colorPink | (1, 0.588, 0.588, 1) |
| 0xD | colorCyan | (0.247, 0.863, 0.863, 1) |
| **0xE, 0x1B (General), 0x1C (Trade), 0x1D (LFG), 0x1E (Roleplay), 0x20 (Society)** | **colorBlueGrey** | **(0.706, 0.863, 0.941, 1)** |
| 0x16 | colorLightRed | (0.96, 0.459, 0.447, 1) |
| 0x12 (Olthoi) , 0x21 | colorOrange | (0.933, 0.573, 0.118, 1) |
| 0x1A | colorBrightRed | (1, 0, 0, 1) |
| everything else (1, 0x10, 0x14, 0x17, 0x18, 0x19, 0x22) | colorGreen (default, unoverridden) | (0.5, 1, 0.498, 1) |
So **General/Trade/LFG/Roleplay/Society chat all render in the same pale
blue-grey** (`colorBlueGrey`) as their base line colour — `"[General]"`
and `says, "..."` are the same colour. This cross-checks cleanly against
the project's existing `claude-memory/reference_retail_chat_colors.md`
(same named constants, same addresses, independently dumped live via cdb
on 2026-06-16): its `colorWhite`→LocalSpeech, `colorBrightPurple`(index
5)→Tell, `colorLightRed`(index 0x16)→Combat, and `colorGrey`(index
0x0C)→"Emote/SoulEmote/fallback" mappings all match the indices found here
exactly. That memory doc's "Channel"→`colorLightBlue` guess (its own text
flags this mapping as an *unverified* nearest-match, "the rare kinds map
to the nearest named color... wasn't traced") is superseded by the exact
trace above: the built-in text channels are `colorBlueGrey`, not
`colorLightBlue` (`colorLightBlue` is indices 7 and 0x11, whose LogTextType
names weren't identified in this pass — **UNKNOWN**, would need the
DAT-driven `LogTextTypeEnumMapper` string table to name every index; see §5).
**Net effect for the screenshot in the prompt**: `"[General] <name> says, ..."` is
**two** colours, not three — the whole line (brackets, "says,", the
quoted message) in `colorBlueGrey`, and the name span in whatever
`m_curTagFontColor` resolves to (see §5) wherever the tag's type is
`"Tell"` (`0x10000001`). If the user's read genuinely showed three
distinguishable hues, the third one is not explained by anything traced in
this pass — flag as **UNKNOWN, possibly a rendering/outline-colour effect
(`m_curOutlineColor`, also a field on `UIElement_Text`, untraced here) or
a visual misread of anti-aliasing against the grey timestamp prefix.**
## 4. Is `StringInfo` the tag carrier?
**No.** Traced its full field set from the constructor/accessor bodies
(`StringInfo::StringInfo @ 0x0042da60`, `::Reset @ 0x0042daf0`,
`::IsValid @ 0x0042cbe0`, `::AddVariable_Int/UInt/Float/String/StringInfo
@ 0x0042dde0-0x0042e7d0`): `m_Override` (0=table-driven / 1=literal /
2=?), `m_stringID`, `m_tableID`, `m_strToken`, `m_LiteralValue`,
`m_strEnglish`, `m_strComment`, and `m_variables` (an
`IntrusiveHashTable<name, StringInfoData*>` for named-variable
substitution into a localized template). None of these are colour, tag,
or link fields — `StringInfo` is purely a **localization envelope**
(string-table id + substitution variables, or a raw literal override via
`SetLiteralValue`). Tagging is applied **after** the envelope is unwrapped:
`StringInfo::GetString` (called inside `AppendStringInfoWithFont @
0x00469de0`, §1c) resolves the final plain wide string, and *that* plain
string is what `UIElement_Text::AddText_Internal`/`InqGlyphs` scans for
`<...>` markup. So the two systems are cleanly separated: StringInfo
answers "what text, and in what language", the glyph-list builder answers
"does any of this text contain clickable/differently-coloured spans".
## 5. Which colour does the tagged name actually use?
**Structurally proven, exact value UNKNOWN.** §1c/1d prove the mechanism:
`UIElement_Text::SetFontColorHelper(this, 0x1D, &m_curTagFontColor, arg4) @
0x00469e1a` looks up property `0x1D` through the *identical*
indexed-array-by-LogTextType path as property `0x1B` (line colour) — see
`UIElement_Text::SetFontColorHelper @ 0x00466ac0`, which does
`InqProperty(propId) → array bounds check (arg4 < count) indexed element
copy`. And `UIElement_Text::InqGlyphs @ 0x00468ea0` proves the *use*: a
glyph gets `m_curTagFontColor` instead of `m_curFontColor` specifically
when its enclosing tag's `m_type == 0x10000001` (the "Tell" tag-name enum
value, resolved via the DAT-driven `EnumMapper` category `0x18` — see §1d
step 2). Tags of any *other* type (e.g. `IIDEnum`-based links used
elsewhere in the client) are still clickable (non-null `Glyph.m_tag`) but
render in the ordinary line colour — the green/special-colour behaviour is
specific to Tell-type name links, not "any markup tag."
What I could **not** find: a second `BuildXxxColorLookupTable`-style
function that populates property `0x1D`'s array the way
`ChatInterface::BuildChatColorLookupTable @ 0x004f31c0` populates `0x1B`
(that function only ever calls `SetPropertyName(&var_18, 0x1b)` once, and
the whole function body — read start to end — only ever writes to that one
array before the final `this->m_chatLog->vtable->SetProperty(&var_18)`).
Two explanations are consistent with what's traced and neither is
confirmed:
- Property `0x1D` is authored directly on the chat-log `UIElement_Text`
widget via its `LayoutDesc` (a per-widget default, not something
`ChatInterface` code builds at runtime) — plausible since
`SetFontColorHelper`'s `InqProperty` call would find *any* property the
widget inherits, not just ones `BuildChatColorLookupTable` wrote.
- A second, unlocated runtime builder populates it elsewhere.
**UNKNOWN — needs either**: (a) a `LayoutDesc`/DAT dump of the chat-log
window's property `0x1D` (or its default RGBAColor), or (b) a live cdb
breakpoint on `UIElement_Text::SetFontColorHelper` with `arg2==0x1D` while
a real Tell-tagged line renders, reading `this->m_curTagFontColor` after
the call returns (same toolchain as `claude-memory/reference_retail_chat_colors.md`'s
`x acclient!color*` / `dd` recipe). The user's screenshot reads it as
green, and AC's clickable-name convention is widely remembered as green,
but that is **not** something this pass proved from decomp — flagging it
as inferred-from-screenshot/prior-knowledge, not decomp-verified.
## Open items / follow-ups
- §2: only one `RecvNotice_TextTag_IIDStringClick` override
(`gmMainChatUI`) was traced for click behaviour; others exist (vtable
slots reference `NoticeHandler::RecvNotice_TextTag_IIDEnumClick` and
`UIElement::MouseHover` as generic fallbacks — worth a second pass if
acdream needs to replicate hover/tooltip behaviour, not just click).
- §3: LogTextType names for indices 7, 0x11 (colorLightBlue), 0xE
(shares colorBlueGrey with the built-in channels), and the seven
unoverridden default-green indices (1, 0x10, 0x14, 0x17, 0x18, 0x19,
0x22) are unidentified — the DAT-driven `LogTextTypeEnumMapper` string
table (`struct __cppobj LogTextTypeEnumMapper`, `acclient.h:57333`)
would name them; not pulled in this pass.
- §5: the exact `m_curTagFontColor` RGBA value is unproven from static
decomp alone — see the two follow-up options listed there.
- The `m_format` field on `TextTag` (set from the tag's second colon-split
segment, e.g. `"IIDString"` → the class-selector `1..4`) was read as a
class-shape selector, consistent with the `TextTagFactory::MakeTag`
switch, but its retail name/purpose beyond "which TextTag subclass" was
not otherwise probed.
## RESOLVED: the tag colour is authored, and it is green
The research pass above could only prove the *mechanism* for the tag colour
(property `0x1D`, applied per-glyph when a tag is open and its `m_type` is
`0x10000001`), not its value — `ChatInterface::BuildChatColorLookupTable
@0x004F31C0` builds only the ordinary `0x1B` array, so it correctly flagged the
RGBA as UNKNOWN rather than assuming the green seen in a screenshot.
It is authored in the LayoutDesc, and it measures out of the installed DATs as:
chat window 0x2100006F, transcript element 0x10000011
P0x1B (line colour) [0x00] R=204 G=204 B=204 A=255
P0x1D (tag colour) [0x00] R= 0 G=178 B= 0 A=255 <- the green
Reproduce with:
dotnet run --project tools/LayoutDump -c Release -- 0x2100006F --colors
Two things worth carrying into the port:
- **The tag colour is per-ELEMENT, not per-LogTextType.** `0x1B` here is a
one-entry array too, so on this element the ordinary colour comes from the
runtime-built chat table while the tag colour comes from the authored
property. A port that files "tag green" into the LogTextType colour table
would be putting it in the wrong place.
- The same `0x1D` green appears on more than one element in this layout, so it
is not unique to the transcript.
`tools/LayoutDump --colors` was added for this measurement and prints the
`0x1B`/`0x1D` arrays of every element in a layout.

View file

@ -0,0 +1,849 @@
# Retail chat TextTag / glyph-tag model — data model, lifetime, colour rule
Research-only. No source was modified for this document. All addresses are
from the Sept 2013 EoR build (`refs/acclient.pdb` / `acclient.exe` v11.4186,
CodeView GUID `9e847e2f-777c-4bd9-886c-22256bb87f32`), as decompiled in
`docs/research/named-retail/acclient_2013_pseudo_c.txt` (pseudo-C) and
`docs/research/named-retail/acclient.h` (verbatim retail struct headers).
Every claim below cites `symbol @ 0xADDRESS`; anything not directly
observed in the decompile is marked **UNKNOWN**.
This document explains the mechanism behind the retail behaviour: a
speaker's name inside a chat line renders in a different colour and is
clickable (click → prefill a tell to that person). The mechanism is a
**glyph-level tag model**, not per-line colouring. `UIElement_Text`
(the class backing chat log / most retail text widgets) keeps a list of
`Glyph` structs, one per character, and each `Glyph` optionally points at
a shared, reference-counted `TextTag` object. A **contiguous run of
glyphs sharing the same `TextTag*` pointer** is what gets the special
colour and the click behaviour — there is no separate "run" or "span"
object; identity is pointer equality on `Glyph::m_tag`, discovered by
linear walk every time it matters.
---
## 1. The `TextTag` type family
### 1.1 Struct layout
```
acclient.h:45358
struct __cppobj TextTag : ReferenceCountTemplate<1048576,0>
{
unsigned int m_type;
unsigned int m_format;
};
```
`ReferenceCountTemplate<1048576,0>` (`acclient.h:7974`) is:
```
struct __cppobj ReferenceCountTemplate<1048576,0>
{
ReferenceCountTemplate<1048576,0>Vtbl *vfptr; // +0x0
unsigned int m_cRef; // +0x4
};
```
So on a live `TextTag`, `vfptr` is at `+0x0`, `m_cRef` at `+0x4`,
`m_type` at `+0x8`, `m_format` at `+0xc`. Those exact offsets are used
directly by the pseudo-C at several sites cited below (e.g.
`*(uint32_t*)((char*)result + 8) = var_14` for `m_type`,
`*(uint32_t*)((char*)ebx_1 + 8) != 0x10000001` for a runtime `m_type`
comparison), which cross-checks the struct layout against the header.
`TextTagType` (the type of `m_type`/`m_format`) is only ever typedef'd:
```
acclient.h:62585
typedef unsigned int TextTagType;
```
No named enum for it survived in the PDB (`grep`'d `TextTagType|TAG_TYPE|
eTextTag` across `acclient.h` returns only that one typedef line). See
§7 for what this means for the `0x10000001` sentinel.
### 1.2 Four concrete subclasses — what identifies/distinguishes a tag
```
acclient.h:53947 struct __cppobj TextTag_IID : TextTag { unsigned int m_IID; };
acclient.h:53953 struct __cppobj TextTag_IIDEnum : TextTag { unsigned int m_IID; unsigned int m_enum; };
acclient.h:53960 struct __cppobj TextTag_IIDString : TextTag { unsigned int m_IID; PStringBase<unsigned short> m_string; };
acclient.h:53967 struct __cppobj TextTag_DID : TextTag { IDClass<_tagDataID,32,0> m_DID; };
```
So a `TextTag` is a small, polymorphic, ref-counted "click payload"
object. Its identity as far as glyphs/runs are concerned is just its
**pointer value** (see §2). Its semantic identity — what clicking it
actually means — is carried by the concrete subclass's extra field(s):
- `TextTag_DID` — wraps a `DataID` (a DAT-file object reference).
- `TextTag_IID` — wraps an `IID` (an in-world Instance ID, i.e. a live
object/creature/player's server-assigned id).
- `TextTag_IIDEnum` — an `IID` plus an `enum` (a secondary
small-integer qualifier).
- `TextTag_IIDString` — an `IID` plus a `PStringBase<unsigned short>`
(a wide string) — **this is the shape used for a clickable player
name**: `m_IID` is the speaker's object id, `m_string` most plausibly
carries their display name for building the tell command (see §6).
Each subclass overrides a fixed 7-slot vtable (`__vecDelDtor`,
`ParseEndTag`, `ParseStartTag`, `BuildEndTag`, `BuildStartTag`,
`HandleClick`, `BuildStartTagData` — confirmed layout dumped verbatim at
`acclient_2013_pseudo_c.txt:959321-959375`, e.g. `TextTag_DID::`vftable''
@ `0x0079e09c`).
**Decompiler artifact to flag**: in the vtable dump, `TextTag_IID`'s
`ParseStartTag`/`BuildStartTagData` slots point at
`TextTag_DID::ParseStartTag` / `TextTag_DID::BuildStartTagData`
(`acclient_2013_pseudo_c.txt:959363,959367`), not at distinct
`TextTag_IID::*` functions. This is almost certainly MSVC identical-code
folding (COMDAT folding) — `TextTag_IID`'s parse/build logic for a bare
32-bit `m_IID` is byte-identical to `TextTag_DID`'s for a bare 32-bit
`m_DID.id`, so the linker merged them and the PDB can only attribute the
merged function to one of the two symbols. Treat this as "same code,
shared by both classes," not "IID delegates to DID."
### 1.3 Factory / parsing — `TextTagFactory::MakeTag`
```
acclient_2013_pseudo_c.txt:132871
00478480 class TextTag* TextTagFactory::MakeTag(class PStringBase<unsigned short> const* arg1)
```
Given the text between `<` and `>` (the delimiters are stripped by the
caller — see §3), `MakeTag`:
1. Finds the first `:` in the substring (`FindChar(':')`,
`acclient_2013_pseudo_c.txt:132904`). If none is found, parsing fails
and `MakeTag` returns `0` (`return 0;` @ `0x478700`,
`acclient_2013_pseudo_c.txt:133064`). **This is the mechanism an "end
tag" uses to close a run — see §3.2.**
2. Resolves the substring before the first `:` to an enum value via
`EnumMapper::InqEnum` (`acclient_2013_pseudo_c.txt:132921`).
3. Finds the *second* `:` and resolves that substring to a second enum
value, also via `EnumMapper::InqEnum`
(`acclient_2013_pseudo_c.txt:132954`), then `switch`es on it
(`acclient_2013_pseudo_c.txt:132955`) to allocate one of the four
concrete subclasses:
```
acclient_2013_pseudo_c.txt:132955-133051 (paraphrased switch table)
case 1: result = TextTag_DID::TextTag_DID(...)
case 2: result = TextTag_IID::TextTag_IID(...)
case 3: result = TextTag_IIDEnum::TextTag_IIDEnum(...)
case 4: result = TextTag_IIDString::TextTag_IIDString(...)
```
4. Stores the two resolved enum values into the new object:
```
acclient_2013_pseudo_c.txt:132981-132982
*(int32_t*)((char*)result + 8) = var_14; // m_type = first EnumMapper::InqEnum result
*(int32_t*)((char*)result + 0xc) = var_18; // m_format = second EnumMapper::InqEnum result (== the switch discriminant, 1-4)
```
5. Delegates the remainder of the string (after the second `:`) to the
new object's own `ParseStartTag` virtual (via the vtable, at
`acclient_2013_pseudo_c.txt:133008`,
`*(int32_t*)((char*)vtable + 8)(__return)` — vtable slot `+0x8` =
`ParseStartTag` per the layout in §1.1) to consume the
type-specific payload (the `IID`/`DID`/`enum`/`string` fields).
If that fails, the freshly-allocated tag is released
(`ReferenceCountTemplate<1048576,0>::Release(result)` @
`acclient_2013_pseudo_c.txt:133016`) and no tag is produced for this
span.
So the overall wire format `MakeTag` parses is
**`TYPE_NAME:FORMAT_NAME:PAYLOAD`**, where `TYPE_NAME` selects `m_type`
(a semantic category — see §7) and `FORMAT_NAME` selects which concrete
subclass parses `PAYLOAD` (`m_format` doubles as "which of the four
built-in payload shapes this is").
Round-trip confirmation comes from `TextTag::BuildStartTag` (the
inverse operation, used when a tagged region is serialized back to
text — see §2.3):
```
acclient_2013_pseudo_c.txt:133619-133669 (TextTag::BuildStartTag @ 0x478fe0)
eax_1 = EnumMapper::InqString(0x18, this->m_type, &...); // name for m_type
eax_6 = EnumMapper::InqString(0x18, this->m_format, &...); // name for m_format
this->vtable->BuildStartTagData(&...); // subclass-specific payload text
PStringBase<unsigned short>::sprintf(arg2, u"<%ls:%ls%:%ls>");
```
(`u"<%ls:%ls%:%ls>"` — the stray `%` immediately after the second `%ls`
is very likely a Binary Ninja string-literal rendering artifact, not a
real extra `%` in the format string; the surrounding logic only ever
supplies three substitutions. **Flagged as uncertain** — resolving it
precisely would need a raw byte dump of the `.rdata` string at its
address rather than BN's decompiled string preview.)
`EnumMapper::InqString`/`InqEnum` both route through a *table id*
argument of `0x18` (`acclient_2013_pseudo_c.txt:133627,133636,133725,
133364`, and `EnumMapper::InqEnum`'s call site in `MakeTag` at
`acclient_2013_pseudo_c.txt:132918-132921`). This table id is what
selects which named-enum table (`m_type`'s table vs. individual
subclass fields' tables) to search — see §7 for why we can't yet name
what string maps to `m_type == 0x10000001`.
---
## 2. Attachment model — per-glyph, not per-run, not per-line
### 2.1 `Glyph` struct
```
acclient.h:45330
struct __cppobj Glyph
{
unsigned __int16 m_data; // the character
int m_width;
int m_height;
RGBAColor m_color; // acclient.h:8100 — 4 floats (r,g,b,a), resolved per-glyph at append time
Font *m_font;
TextTag *m_tag; // nullable, shared, ref-counted
};
```
`GlyphList` (`acclient.h:45305`) is a doubly-linked `List<Glyph>` plus a
cached `SmartArray<GlyphLine,1>` (line-break layout cache) and some
bookkeeping (`m_nMaxCharacters`, `m_nFirstInvalidPosition`, etc.).
`UIElement_Text` (`acclient.h:53392`) owns exactly one `GlyphList
m_glyphList` (the live/editable text) and a second `GlyphList
m_glTruncate` (used by the truncation machinery — not investigated
further here).
**There is no `TextTag*` field, run/span object, or index range
anywhere on `GlyphList`, `GlyphLine`, or `UIElement_Text`.** The *only*
place a tag pointer lives is `Glyph::m_tag`, one per character. A
"tagged run" is purely an emergent property: a maximal sequence of
adjacent glyph list nodes whose `m_tag` fields are pointer-equal.
### 2.2 How code discovers a run boundary
Every place in the decompile that needs to know "does this edit split a
tagged run" or "did the tag change here" does the same thing: walk
adjacent glyphs and compare `data.m_tag` by pointer. Three load-bearing
examples:
**On insert**, if the two glyphs immediately either side of the
insertion point shared a tag, the whole tag is stripped (see §3.1 for
why it's the *whole* tag, not just the boundary):
```
acclient_2013_pseudo_c.txt:127079-127090 (GlyphList::Insert @ 0x472e70)
class ListNode<Glyph>* prev = _current->prev;
if (prev != 0)
{
class TextTag* m_tag = prev->data.m_tag;
if (m_tag == _current->data.m_tag)
GlyphList::RemoveTextTag(this, m_tag);
}
```
**On delete**, the boundary glyph of the doomed range is checked the
same way:
```
acclient_2013_pseudo_c.txt:127294-127302 (inside GlyphList::Delete @ 0x4730a0)
class TextTag* m_tag = edi->data.m_tag;
if (m_tag != 0)
{
for (class ListNode<Glyph>* i = this_1->m_glyphList._head; i != 0; i = i->next)
{
if (i->data.m_tag == m_tag)
Glyph::SetTag(i, nullptr);
}
}
```
**On bulk append** (`GlyphList::AddText`), the glyph immediately before
the insertion point is compared to the first glyph being spliced in:
```
acclient_2013_pseudo_c.txt:127354-127362 (inside GlyphList::AddText @ 0x473190)
int32_t ebx = *(int32_t*)((char*)prev + 0x20); // prev->data.m_tag (offset +0x20 into Glyph)
if ((ebx == var_8->data.m_tag && ebx != 0))
{
for (class ListNode<Glyph>* i = this_2->m_glyphList._head; i != 0; i = i->next)
if (i->data.m_tag == ebx)
Glyph::SetTag(i, nullptr);
}
```
**On text serialization** (`GlyphList::InqText`, used to reconstruct
markup text — e.g. what `ChatInterface::TruncateChatLog` reads before
truncating, see §3.3), the same pointer-compare drives when to close
the previous tag's markup and open the new one:
```
acclient_2013_pseudo_c.txt:127671-127700 (inside GlyphList::InqText @ 0x473560)
class TextTag* m_tag = _head->data.m_tag;
if ((eax_4 != 0 && m_tag != m_tag_1)) // tag changed since previous glyph
{
if (m_tag_1 != 0)
m_tag_1->vtable->BuildEndTag(&arg5); // close previous run's markup
if (m_tag != 0)
m_tag->vtable->BuildStartTag(&arg5); // open new run's markup
}
...
m_tag_1 = m_tag; // carried into next iteration
```
So: **a contiguous run is identified only by walking neighbours and
comparing `Glyph::m_tag` pointers; there is no cached run table.**
Every operation that could break a run's contiguity re-derives the
answer by walking.
### 2.3 How a tag attaches during append — `UIElement_Text::InqGlyphs`
```
acclient_2013_pseudo_c.txt:115983
00468ea0 uint8_t __stdcall UIElement_Text::InqGlyphs(class UIElement_Text* this @ ecx, class PStringBase<unsigned short> const* arg2, class SmartArray<Glyph,1>* arg3)
```
This converts a raw wide string (which may contain embedded
`<TYPE:FORMAT:DATA>` markup) into a flat array of `Glyph`s, one
character at a time. It is called from `UIElement_Text::AddText_Internal`
(`acclient_2013_pseudo_c.txt:116791`), which is itself the single choke
point every text-append path funnels through (`AppendText`,
`AppendStringInfo`, `AppendStringInfoWithFont`, `CharacterHandler`, the
paste handler, etc. — confirmed by grepping every
`AddText_Internal(` call site, `acclient_2013_pseudo_c.txt:116886,116916,
116994,117008,117026,117043,117106`).
The character loop keeps one local, `ebx_1` (`class TextTag*`), which is
the **currently-open tag while walking characters** — this is NOT a
field on `UIElement_Text`; it's a local in this one function's loop:
- On seeing `<` (`0x3c`), it scans to the matching `>` (`0x3e`),
extracts the substring, and calls
`TextTagFactory::MakeTag(...)` (`acclient_2013_pseudo_c.txt:116117`).
The `<...>` delimiter text itself is **not emitted as glyphs** — the
character cursor (`edi_1`) is advanced past the closing `>` before
glyph emission resumes (`acclient_2013_pseudo_c.txt:116124-116131`).
The result — a new tag pointer, or `0` if `MakeTag` failed to parse —
replaces `ebx_1` for subsequent characters.
- For every ordinary character, the glyph's colour is chosen from `ebx_1`
(see §5 for the exact rule) and a `Glyph` is constructed carrying
`ebx_1` as its `m_tag` (§4 covers the ref-count mechanics of that
construction).
**Because `ebx_1` is never explicitly reset to `0` on a "closing
bracket," the only way a tag run ends is for a later `<...>` span to
fail to parse into a valid tag** (no `:` found → `MakeTag` returns `0`,
§1.3 step 1). That is exactly what `TextTag::BuildEndTag` emits:
```
acclient_2013_pseudo_c.txt:133714-133748 (TextTag::BuildEndTag @ 0x479190)
if (this->m_type != 0)
{
EnumMapper::InqString(0x18, this->m_type, &var_4); // just the type's name, no ':'
PStringBase<unsigned short>::sprintf(arg2, u"<\%ls>");
return 1;
}
return 0;
```
i.e. the end-tag markup is a bracketed **type name with no colon**
(`u"<\%ls>"` — the `\` immediately before `%` is almost certainly a
Binary Ninja rendering artifact for a literal `/`, i.e. the real string
is most plausibly `"</%ls>"`; **flagged as uncertain**, same caveat as
§1.3 — BN's string preview/escaping for embedded control characters is
not reliably faithful and this should be confirmed with a raw
`.rdata` byte dump before being relied on verbatim). Since that
substring has no `:`, `TextTagFactory::MakeTag`'s `FindChar(':')` check
fails and it returns `0``ebx_1` becomes `0` → every glyph after that
point is untagged, until the next successfully-parsed `<TYPE:FORMAT:
DATA>` start tag. **Start/end tags are symmetric in markup shape but
asymmetric in mechanism**: a start tag is a successful `MakeTag` parse;
an end tag is nothing more than *any* bracketed text that fails to
parse as one.
---
## 3. Lifetime
### 3.1 Creation, retention, destruction — reference counting
`TextTag` inherits `ReferenceCountTemplate<1048576,0>` (`m_cRef` at
`+0x4`, `vfptr` at `+0x0`). `TextTagFactory::MakeTag` hands back an
object with `m_cRef == 1` (set in the base ctor,
`acclient_2013_pseudo_c.txt:133587-133594`,
`TextTag::TextTag @ 0x478f80`: `this->m_cRef = 1;`). From there,
ownership is **fully distributed across every `Glyph` that points at
it** — there is no separate owning list or registry.
**Adopting a tag reference increments the count.** The parameterized
`Glyph` constructor used by `InqGlyphs` when building a new glyph does
this explicitly:
```
acclient_2013_pseudo_c.txt:129088-129106 (Glyph::Glyph(this, char, color*, font*, tag*) @ 0x474a90)
*(uint32_t*)((char*)this_1 + 0x20) = arg5; // this->m_tag = tag
Glyph::SetFont(this_1, arg4);
int32_t eax_5 = *(uint32_t*)((char*)this_1 + 0x20);
if (eax_5 != 0)
InterlockedIncrement((eax_5 + 4)); // tag->m_cRef++
```
The copy assignment operator (used whenever a glyph is copied — e.g.
splicing the freshly-built `SmartArray<Glyph,1>` from `InqGlyphs` into
the live `List<Glyph>`, or `List<Glyph>::flush`'s per-node teardown)
does the matching release-then-acquire:
```
acclient_2013_pseudo_c.txt:128949-128969 (Glyph::operator= @ 0x474870)
class TextTag* m_tag_1 = this->m_tag;
if (m_tag_1 != 0)
{
if (InterlockedDecrement(&m_tag_1->m_cRef) == 0 && m_tag_1 != 0)
m_tag_1->vtable->__vecDelDtor(1); // release old tag, free at zero
this->m_tag = nullptr;
}
class Font* m_font = arg2->m_font;
this->m_font = m_font;
this->m_tag = arg2->m_tag;
if (this->m_tag != 0)
InterlockedIncrement(&this->m_tag->m_cRef); // acquire new tag
```
**Releasing decrements the count and self-deletes at zero, via the
destructor**:
```
acclient_2013_pseudo_c.txt:128905-128925 (Glyph::~Glyph @ 0x474820)
class TextTag* m_tag = this->m_tag;
if (m_tag != 0)
{
if (InterlockedDecrement(&m_tag->m_cRef) == 0 && m_tag != 0)
m_tag->vtable->__vecDelDtor(1);
this->m_tag = nullptr;
}
```
**`Glyph::SetTag` is the exception to note carefully** — it releases the
*old* tag (decrement, free at zero) but does **not** increment the
refcount of the incoming tag:
```
acclient_2013_pseudo_c.txt:128977-128993 (Glyph::SetTag @ 0x474920)
void Glyph::SetTag(class Glyph* this, class TextTag* arg2)
{
class TextTag* m_tag = this->m_tag;
if (m_tag == 0)
{
this->m_tag = arg2;
return;
}
if (InterlockedDecrement(&m_tag->m_cRef) == 0 && m_tag != 0)
m_tag->vtable->__vecDelDtor(1);
this->m_tag = nullptr;
this->m_tag = arg2;
}
```
Every call site of `Glyph::SetTag` actually observed in this decompile
passes `nullptr` for `arg2` (§2.2's three excerpts, plus the identical
pattern at `acclient_2013_pseudo_c.txt:126830`,
`GlyphList::RemoveTextTag`). In practice `SetTag` is only ever used as
"sever this glyph's reference to whatever tag it has" — a porting
engineer must not assume the general two-argument form is
refcount-safe for a non-null argument; if a call site with a non-null
tag is ever found, it must AddRef beforehand, mirroring the
constructor/`operator=` pattern above.
### 3.2 Whole-tag invalidation on any edit that could split a run
This is the single most important porting gotcha in this document.
None of the three "does this edit touch a tag boundary" checks in §2.2
try to *split* a run in two. All of them, on detecting that an edit
would break contiguity, call `Glyph::SetTag(i, nullptr)` on **every
glyph in the entire `GlyphList` that shares that tag pointer** — not
just the glyphs adjacent to the edit. See `GlyphList::RemoveTextTag`:
```
acclient_2013_pseudo_c.txt:126822-126833
void __thiscall GlyphList::RemoveTextTag(class GlyphList* this, class TextTag* arg2)
{
if (arg2 != 0)
{
for (class ListNode<Glyph>* i = this->m_glyphList._head; i != 0; i = i->next)
{
if (i->data.m_tag == arg2)
Glyph::SetTag(i, nullptr);
}
}
}
```
`GlyphList::Insert`'s boundary check (§2.2) calls exactly this function
when it detects a would-be-split. `GlyphList::Delete` and
`GlyphList::AddText` inline the identical "walk the whole list, clear
every glyph sharing this tag" loop rather than calling
`RemoveTextTag` directly, but the effect is the same. **The retail
behaviour is: any edit that would leave a discontiguous run under one
tag pointer instead destroys the tag for the ENTIRE list, not just the
disturbed portion.** A tagged player name that gets partially edited or
partially deleted loses its colour/clickability everywhere it appears
in that `GlyphList`, not just at the edit site.
### 3.3 Scroll-off / truncation — `ChatInterface::TruncateChatLog`
```
acclient_2013_pseudo_c.txt:247098
004f4290 void __fastcall ChatInterface::TruncateChatLog(class ChatInterface* this, uint32_t arg2)
```
This reads the chat log's current text length (via
`UIElement_Text::GetText`, which is backed by `GlyphList::InqText`, §2.2)
and, if it exceeds the cap (`arg2`), calls:
```
acclient_2013_pseudo_c.txt:247148,247178
UIElement_Text::BeheadText(this->m_chatLog, N, 1);
```
`BeheadText` is a thin wrapper:
```
acclient_2013_pseudo_c.txt:116727-116731 (UIElement_Text::BeheadText @ 0x469970)
void __thiscall UIElement_Text::BeheadText(class UIElement_Text* this, uint32_t arg2, uint8_t arg3)
{
UIElement_Text::DeleteSection(this, 0, arg2, arg3);
}
```
which in turn calls `GlyphList::Delete` (`acclient_2013_pseudo_c.txt:
116680`, inside `UIElement_Text::DeleteSection @ 0x469800`) — **the
exact same generic deletion path used for any other text edit** (typed
backspace, cut, selection delete). There is no special-cased "truncate
the chat log" tag handling. Consequences, following directly from §3.1
and §3.2:
- A `TextTag` whose glyphs are entirely scrolled off is destroyed the
ordinary way: `GlyphList::Delete` walks the doomed range, finds the
shared tag, `Glyph::SetTag(..., nullptr)`s every glyph that shares it
(§3.2), decrementing to zero and freeing it.
- A `TextTag` whose glyphs are only **partially** scrolled off (the
truncation boundary falls inside a tagged name) has the tag stripped
from **all** its glyphs, including the ones that remain visible — per
§3.2's whole-list behaviour. The surviving remnant of a truncated
tagged name renders and behaves as plain untagged text. This is a
concrete, verified retail behaviour, not a hypothesis — it falls
directly out of `GlyphList::Delete`'s implementation, which
`BeheadText`/`TruncateChatLog` invoke with no special-casing.
---
## 4. Colour rule — property `0x1b` (font colour) vs. `0x1d` (tag font colour)
### 4.1 `UIElement_Text`'s "current" state fields
```
acclient.h:53392-53420 (struct UIElement_Text, relevant fields with confirmed field order)
RGBAColor m_curFontColor; // used for the property "0x1b" value
Font *m_curFontObj; // used for the property "0x1a" value
RGBAColor m_curTagFontColor; // used for the property "0x1d" value
unsigned int m_curOutlineColor;
```
The field ORDER in the header (`m_curFontColor`, `m_curFontObj`,
`m_curTagFontColor` back to back) matches the raw offset arithmetic seen
at the glyph-construction site in `InqGlyphs`
(`acclient_2013_pseudo_c.txt:116153-116162`, this-relative offsets
`0x6a4` for `m_curFontColor` and `0x6b8` for `m_curTagFontColor`, a
`0x14`-byte gap = 16 bytes of `RGBAColor` + 4 bytes of the `Font*`
pointer in between) — this cross-check confirms the struct layout
against the pseudo-C's raw pointer math.
### 4.2 Setting them per append — `UIElement_Text::AppendStringInfoWithFont`
```
acclient_2013_pseudo_c.txt:117031-117048
00469de0 void __thiscall UIElement_Text::AppendStringInfoWithFont(class UIElement_Text* this, class StringInfo const* arg2, int32_t arg3, int32_t arg4)
{
UIElement_Text::SetFontDIDHelper(this, 0x1a, &this->m_curFontObj, arg3);
UIElement_Text::SetFontColorHelper(this, 0x1b, &this->m_curFontColor, arg4);
UIElement_Text::SetFontColorHelper(this, 0x1d, &this->m_curTagFontColor, arg4);
...
UIElement_Text::AddText_Internal(this, m_charbuffer, 3);
...
}
```
Both colour properties are refreshed from the **same caller-supplied
index**, `arg4`, immediately before the string is appended
(`AddText_Internal``InqGlyphs`, §2.3). The same three-call pattern
(`0x1a`/`0x1b`/`0x1d`, same index argument) recurs at every other append
entry point that carries a colour index:
`UIElement_Text::AppendText`/`AppendStringInfo`'s shared helper at
`acclient_2013_pseudo_c.txt:115213-115222`, the string-download
completion handler at `acclient_2013_pseudo_c.txt:117098-117104` (index
taken from the queued download's own stored index,
`ebx_2[0x25]`/`ebx_2[0x26]`), and the reset-to-index-0 call at
`acclient_2013_pseudo_c.txt:117546-117548`.
### 4.3 What `SetFontColorHelper` actually does — an indexed property array
```
acclient_2013_pseudo_c.txt:113699 (UIElement_Text::SetFontColorHelper @ 0x466ac0)
void __thiscall UIElement_Text::SetFontColorHelper(class UIElement_Text* this, uint32_t arg2 /*propId*/, class RGBAColor* arg3 /*out*/, uint32_t arg4 /*index*/)
```
Simplified control flow (BN's exact vtable-offset dispatch on `0xf0`,
`0xf4`, `0x98` is not named by the PDB — see caveat below):
1. `this->vtable->InqProperty(propId, &var_10)` — looks up the
UIElement's own authored property (`0x1b` or `0x1d`) via its normal
`UIElement` property mechanism, i.e. this is a
**LayoutDesc/DAT-authored per-element property**, not global engine
state.
2. If found, the returned property object is treated as an *indexed
collection*: a virtual call through vtable offset `+0xf0`
(`acclient_2013_pseudo_c.txt:113743`) that plausibly returns the
collection's element count into `arg2`; a bounds check
`if (arg4 < arg2)`; then a virtual call through `+0xf4`
(`acclient_2013_pseudo_c.txt:113758`) that plausibly fetches the
sub-property at index `arg4`; then a virtual call through `+0x98`
(`acclient_2013_pseudo_c.txt:113761`) that plausibly extracts an
`RGBAColor` from that sub-property into the caller's `arg3` output.
3. If any step fails (property absent, index out of range), `arg3` (the
caller's `m_curFontColor`/`m_curTagFontColor`) is left untouched —
i.e. it retains whatever colour it already held from a previous
append.
**Caveat**: BN does not resolve names for the `+0xf0`/`+0xf4`/`+0x98`
virtual calls (they're dispatched through a generic `BaseProperty`-family
vtable, and the pseudo-C prints them as raw `(*(uint32_t*)(vtable +
offset))(...)` calls). The functional interpretation above
("count / get-at-index / get-color") is inferred from the argument
shapes and control flow (an `arg4 < count` bounds check immediately
followed by an index-parameterized fetch), not from a symbol. Treat the
step-by-step mechanics as **probable, not certain** — the porting-load-
bearing fact that IS certain is the *outcome*: property `0x1b` and
property `0x1d` are each an array of colours on the `UIElement_Text`,
indexed by the same `arg4` the caller supplies, and `SetFontColorHelper`
resolves one colour from each array into `m_curFontColor` /
`m_curTagFontColor` respectively before the string is walked into
glyphs. This lines up with `claude-memory/project_chat_digest.md`'s
`LogTextType colors` note — `arg4` is almost certainly the `LogTextType`
of the message being appended (a per-message-category colour index),
and property `0x1d` is a **parallel, per-category array of "tag"
colours** — i.e. retail authors one link/tag colour per chat category,
not one global link colour.
### 4.4 Which colour a glyph actually gets — the `m_type == 0x10000001` gate
Back in `UIElement_Text::InqGlyphs`, per character, the code picks
between the two "current" colours based on whether a tag is open **and**
that tag's `m_type` equals a specific sentinel:
```
acclient_2013_pseudo_c.txt:116150-116162
void* edx_15;
void* esi_6;
if ((ebx_1 == 0 || *(uint32_t*)((char*)ebx_1 + 8) != 0x10000001))
{
esi_6 = esp_1[6]; // this (UIElement_Text*)
edx_15 = ((char*)esi_6 + 0x6a4); // &this->m_curFontColor
}
else
{
esi_6 = esp_1[6];
edx_15 = ((char*)esi_6 + 0x6b8); // &this->m_curTagFontColor
}
```
(`ebx_1 + 8` is `TextTag::m_type`, per the struct layout in §1.1.)
`edx_15` is then passed straight into the parameterized `Glyph`
constructor as the colour source (`acclient_2013_pseudo_c.txt:116173-
116180`). So, precisely:
- No open tag (`ebx_1 == 0`) → glyph gets `m_curFontColor` (property
`0x1b`'s indexed value).
- Open tag, but its `m_type != 0x10000001` → **still**
`m_curFontColor`. Not every tag type gets the special colour.
- Open tag with `m_type == 0x10000001` → glyph gets `m_curTagFontColor`
(property `0x1d`'s indexed value).
**This is the exact rule the task asked for**: property `0x1b` is the
default/base colour used for all untagged text and for any tag whose
type isn't the specially-recognized one; property `0x1d` is used only
for glyphs inside a tag of that one recognized type, and both are
selected from the same caller-supplied colour-category index. See §7
for what is and is not known about what `0x10000001` names.
### 4.5 "Currently open tag" state
There is **no persistent "currently open tag" field on `UIElement_Text`**
`m_curFontColor`/`m_curFontObj`/`m_curTagFontColor` are the *colour
palette currently in effect for this append call* (refreshed once per
`AppendStringInfoWithFont`/`AppendText` call from the indexed DAT
properties), not per-tag state. The actual "is a tag open right now,
and which one" state during the character walk is the **local variable
`ebx_1` inside `UIElement_Text::InqGlyphs`'s loop** (§2.3) — it does not
outlive one call to `InqGlyphs`/`AddText_Internal`. Each append call
starts fresh with no tag open, and the markup embedded in that call's
own string is what opens/closes tags within it.
---
## 5. Click dispatch
Each concrete subclass's `HandleClick` (vtable slot `+0x14`, per §1.2)
forwards to a `ECM_UI::SendNotice_TextTag_*Click` free function, which —
per the `NoticeHandler` vtable declared in `acclient.h:30237-30240` — is
a broadcast notice any registered `NoticeHandler` (e.g. the chat/social
UI) can receive via a matching `RecvNotice_TextTag_*Click` virtual:
```
acclient_2013_pseudo_c.txt:133078-133085 (TextTag_DID::HandleClick @ 0x478740)
ECM_UI::SendNotice_TextTag_DIDClick(this->m_type, this->m_DID.id);
acclient_2013_pseudo_c.txt:133521-133528 (TextTag_IID::HandleClick @ 0x478e80)
ECM_UI::SendNotice_TextTag_IIDClick(this->m_type, this->m_IID);
acclient_2013_pseudo_c.txt:133328-133335 (TextTag_IIDEnum::HandleClick @ 0x478b40)
ECM_UI::SendNotice_TextTag_IIDEnumClick(this->m_type, this->m_IID, this->m_enum);
acclient_2013_pseudo_c.txt:133150-133157 (TextTag_IIDString::HandleClick @ 0x478840)
ECM_UI::SendNotice_TextTag_IIDStringClick(this->m_type, this->m_IID, &this->m_string);
```
matching the `NoticeHandler` vtable slots:
```
acclient.h:30237-30240
void (__thiscall *RecvNotice_TextTag_DIDClick)(NoticeHandler *this, unsigned int, IDClass<_tagDataID,32,0>);
void (__thiscall *RecvNotice_TextTag_IIDClick)(NoticeHandler *this, unsigned int, unsigned int);
void (__thiscall *RecvNotice_TextTag_IIDEnumClick)(NoticeHandler *this, unsigned int, unsigned int, unsigned int);
void (__thiscall *RecvNotice_TextTag_IIDStringClick)(NoticeHandler *this, unsigned int, unsigned int, PStringBase<unsigned short> *);
```
This strongly supports the observed behaviour ("clicking a speaker's
name in chat prefills a tell to them"): a chat name is plausibly tagged
`TextTag_IIDString`, carrying the speaker's `IID` (their in-world
object id — the correct addressee for a `/t` tell) and `m_string`
(plausibly the speaker's display name — needed because the retail tell
command syntax is name-based, not id-based). Clicking dispatches
`ECM_UI::SendNotice_TextTag_IIDStringClick(type, IID, &name)`, and
whichever `NoticeHandler` owns the chat input box (this document did
not trace that far — **UNKNOWN, needs a search of `RecvNotice_TextTag_
IIDStringClick` overrides across the UI classes to find which panel
consumes it and confirm it prefills `/t "name" `**) reacts by loading
that into the input field.
**Not traced from HandleClick backward to the mouse-hit-test that finds
"which glyph, hence which tag, is under the cursor."** No literal
`->vtable->HandleClick(...)` call site was found via text grep (it's an
indirect vtable call, invisible to a literal-string search); locating
the exact hit-test function that resolves a click's screen position to
a glyph index and reads that glyph's `m_tag` was out of scope for this
pass. **UNKNOWN — needs a targeted search for the `UIElement_Text`
mouse-down handler** (candidates: something built on
`GlyphList::FindPosFromLineAndPixels` @
`acclient_2013_pseudo_c.txt:127424`, which is already known to resolve
screen pixels to a glyph index and is a very likely component of that
path, but the actual click-to-`HandleClick` wiring was not confirmed).
---
## 6. Summary — the model to port
1. **Data model**: `TextTag` is a small polymorphic ref-counted object
(`m_type`, `m_format`, plus subclass payload — `IID`/`DID`/`enum`/
`string` in various combinations). `Glyph` carries an OPTIONAL
`TextTag*`. `GlyphList` is a flat list of `Glyph`; nothing above the
glyph level stores tag/run information.
2. **Attachment**: identity of a "run" is pointer equality on
consecutive glyphs' `m_tag`. No cached run table exists; every
consumer (insert-boundary check, delete-boundary check, append-
boundary check, text-serialization) re-derives it by walking
neighbours.
3. **Markup**: `<TYPE:FORMAT:DATA>` opens a tag (parsed by
`TextTagFactory::MakeTag`, dispatching on the `FORMAT` value to one
of 4 concrete classes); any bracketed text that fails to parse (in
particular the literal `<TYPE>`-shaped close marker emitted by
`TextTag::BuildEndTag`) closes the currently-open tag. The delimiter
text itself is never rendered as glyphs.
4. **Lifetime**: pure intrusive refcounting. Adopting a tag reference
(construction, copy-assignment) increments; releasing (destruction,
explicit clear, reassignment) decrements and self-deletes at zero.
**Any edit that would split a tagged run instead strips the tag from
every glyph in the WHOLE `GlyphList` that shares it** — there is no
run-splitting. Chat-log truncation (`TruncateChatLog`
`BeheadText``DeleteSection``GlyphList::Delete`) is not
special-cased; it goes through this exact same path, so a tagged
name straddling the truncation boundary loses its tag entirely, even
on the surviving portion.
5. **Colour**: two parallel, per-`UIElement_Text`, DAT-authored,
index-selected colour arrays — property `0x1b` (base/default) and
property `0x1d` (tag colour) — refreshed from the same caller
colour-category index at the top of every append call. A glyph gets
the `0x1d` colour only if a tag is open AND that tag's `m_type`
equals the sentinel `0x10000001`; otherwise it gets the `0x1b`
colour regardless of whether some *other* kind of tag is open.
6. **Click**: each concrete `TextTag` subclass's `HandleClick`
broadcasts a `NoticeHandler`-family notice
(`ECM_UI::SendNotice_TextTag_*Click`) carrying its payload; some
listener elsewhere (not traced in this pass) reacts to populate chat
input, open a character sheet, etc., depending on subclass/`m_type`.
---
## 7. Open questions / explicitly unresolved
- **What symbolic name does `TextTag::m_type == 0x10000001` correspond
to?** `TextTagType` has no recovered named enum (`acclient.h:62585` is
a bare typedef). The literal `0x10000001` recurs pervasively
elsewhere in the pseudo-C for apparently unrelated purposes (dialog
IDs, `StringInfo::SetStringIDandTableEnum` table-enum arguments,
keymap IDs — see the broad grep hits at
`acclient_2013_pseudo_c.txt:2334,135182,149121,150950,154810,...`),
which suggests it's a low, sequential "category 1" id reused across
several small per-subsystem enums rather than one global constant
with a single meaning — i.e. seeing the same literal elsewhere is
**not** evidence about what it means for `TextTag`. Both
`EnumMapper::InqEnum`/`InqString` route through a table id of `0x18`
(§1.3), which is very likely a DAT-resident enum/string table (the
lookup falls through `MasterDBMap::DivineType`-style DBObj resolution
seen in `EnumMapper::GetEnumByDID`,
`acclient_2013_pseudo_c.txt:29890-29937`, for other DID categories),
meaning the actual keyword strings (e.g. whatever text maps to
`m_type == 1`) live in a DAT string/enum table, not as a compiled
string literal — grepping for literal tag keywords like `"IID"`,
`"DID"` in the pseudo-C found nothing. **Needs**: pulling DAT category
`0x18`'s EnumMapper table contents (likely in
`client_local_English.dat` or `client_portal.dat`) to find the actual
keyword-to-`m_type` mapping, the same way
`claude-memory/project_settings_options_digest.md`'s `GetNameFromKey`
work pulled DAT tables `0x2300000A`/`0x2300000B`/`0x23000007`.
- **Exact wording of the two `sprintf` format strings** at
`acclient_2013_pseudo_c.txt:133651` (`u"<%ls:%ls%:%ls>"`) and
`acclient_2013_pseudo_c.txt:133728` (`u"<\%ls>"`). Binary Ninja's
string-literal rendering is known to mis-escape embedded
slashes/percents in this codebase; both are flagged inline in §1.3/§2.3
as probable artifacts (a stray `%` in the first, a `\` that's likely a
literal `/` in the second). **Needs**: a raw byte dump of the two
`.rdata` string constants at their addresses (not BN's decompiled
preview) to confirm exact bytes before porting the exact markup
syntax.
- **The exact semantics of `SetFontColorHelper`'s three virtual calls**
(vtable offsets `+0xf0`, `+0xf4`, `+0x98`, §4.3) are inferred from
control flow, not named by the PDB. The functional summary (indexed
colour array) is believed correct, but the precise interface
(`BaseProperty`'s exact virtual table) was not independently
confirmed against `acclient.h`'s `BaseProperty`-family struct
definitions in this pass.
- **The mouse-hit-test → `HandleClick` wiring** (§5) — which function
resolves a click position to a glyph, reads its `m_tag`, and invokes
`HandleClick` through the vtable — was not located in this pass (no
literal-text call site exists to grep for an indirect vtable call).
- **Which `NoticeHandler` override actually consumes
`RecvNotice_TextTag_IIDStringClick` and prefills the chat input** —
not traced. This is the last link needed to fully confirm "clicking a
chat name populates a `/t` tell," though the `IID` + name payload
shape on `TextTag_IIDString` makes it the overwhelmingly likely
candidate tag type for that UI behaviour.

View file

@ -0,0 +1,267 @@
# Retail building and environment detail texturing — #226 port note
**Date:** 2026-08-21 · **Amended:** 2026-08-22 (Campaign VM, VM1)
**Status:** IMPLEMENTED + CONNECTED-VISUAL-VERIFIED; re-ported to retail's
single-pass detail combine by VM1 after
[VM2's live cdb read](2026-08-22-vm2-retail-detail-path-cdb.md) proved real
hardware never takes the two-pass framebuffer fallback this note originally
described. The "Exact two-pass pseudocode" and "Brightening decision"
sections below are corrected in place; everything about the reachable
setting/caller chain, the authored source data, and material coverage is
unchanged and still applies.
This note is the implementation handoff requested by #226. The measurements
below come from the already-completed
[`2026-08-21 terrain and atmospheric rendering findings`](2026-08-21-terrain-and-atmospheric-rendering-findings.md),
especially §§12. They are cited here rather than re-derived. The reachable
preference/caller chain is also recorded in
[`2026-07-10 detail texturing`](2026-07-10-detail-texturing.md).
The A2 terrain-normal verdict and A3 subdivision disposition are recorded in
the companion [`Terrain fidelity Track A report`](2026-08-21-terrain-fidelity-track-a-report.md).
## User-visible target and reachable caller trace
The issue title used to say “landscape,” but the Sept-2013 retail client does
not expose live landscape detail through this option:
1. The Options checkbox writes `RenderPrefs.EnvironmentDetailTextures`.
2. `Render::UpdateFromPreferences` (`0x0054d850`) explicitly changes
`Current_Render_LandscapeDetailTextures` to `0` and calls
`SmartBox::SetDetailTexturing(smartbox, 0, environmentEnabled)` at
`0x0054d9f3`.
3. `SmartBox::SetDetailTexturing` (`0x00451df0`) forwards
`LScape::SetDetailTexturing(lscape, landscape, enabled, enabled, 0)`.
4. `LScape::ChangeRegion` (`0x00506cb0`) independently installs the same
category state: `(0, EnvDetail, EnvDetail, 0)`.
The four positions are landscape (0), building (1), environment/EnvCell (2),
and ordinary object (3). The only reachable named-retail preference caller
forces categories 0 and 3 off. `DrawPartCell` also clears ordinary-object
detail. Therefore #226's scene target is **building shells and interior
EnvCell geometry**, not outdoor terrain, scenery, creatures, or players. This
also explains why acdream's existing checkbox is labelled “Building Detail
Textures.”
## Authored source, size, and sampling
Detail data is reached through
`Region(0x13000000).TerrainInfo.LandSurfaces.TexMerge.TerrainDesc[category]`:
```text
SurfaceTextureId = TerrainDesc[category].TerrainTex.DetailTextureId
tiling = TerrainDesc[category].TerrainTex.DetailTexTiling
RenderSurfaceId = SurfaceTexture(SurfaceTextureId).Textures[0]
rgba = decode(RenderSurface(RenderSurfaceId), level 0)
```
For Dereth, enabled categories 1 and 2 both resolve
`0x05001787 -> 0x06006D58`, a **256 x 256 A8R8G8B8** texture, with tiling
**4**. The complete measured Dereth population is three textures across 33
entries: `0x050012AF -> 0x060037D2` (64 x 64, 29 entries),
`0x05001786 -> 0x06006D57` (256 x 256, two), and the enabled-category texture
above (256 x 256, two). See the findings §2 table.
Retail uses wrap addressing in U and V and linear minification,
magnification, and mip filtering. Detail UV is `baseUv * tiling`. The port
therefore uploads each live category as a one-layer RGBA8 texture array with a
full mip chain and the existing repeat/linear world sampler.
## Exact single-pass pseudocode
Retail has both a single-pass multitexture route and a two-pass framebuffer
fallback for adapters that cannot advertise `D3DTEXOPCAPS_PREMODULATE`.
[VM2's live cdb read](2026-08-22-vm2-retail-detail-path-cdb.md) on the
PDB-paired retail client found `m_caps.bCanDoSinglePassDetailing = 1` and the
file-static `trysinglepass = 1` on real (AMD) hardware, so
`D3DPolyRender::RenderMeshSubset` (`0x0059ca10`) never falls back for built
meshes — every loaded `CGfxObj` sets `use_built_mesh = 1`
(`CGfxObj::InitLoad` `0x005346b0`). The port below matches the path players
actually saw.
```text
enabled = DisplaySettings.BuildingDetailTextures // existing setting; no new option
buildingDetail = load_category(TerrainDesc[1])
environmentDetail = load_category(TerrainDesc[2])
for each retail built-mesh material subset:
draw_existing_base_subset_unchanged()
if enabled and subset belongs to a building or EnvCell:
draw the same subset with its category detail texture
// transparent/additive/inverse-alpha: detail follows its base
// immediately, before the next delayed-alpha subset
for each replayed fragment:
reject ordinary objects / landscape / scenery
accept opaque, ClipMap, alpha, additive and inverse-alpha subsets
// No distance term. ACRender::get_alpha_for_z (0x006b6230) is only
// evaluated in D3DPolyRender::DrawPolyInternal (0x0059d7c0, the
// immediate-polygon path) and only when the static noFadeDetail
// (0x00820e38, initialised to 1) is 0 — unreachable for built meshes.
// Attenuation is the LINEAR mip chain converging to the texture mean.
detail = sample(categoryTexture, baseUv * categoryTiling)
diffuseAlpha = base_subset_diffuse_alpha // 1 for opaque; the
// translucency-fade multiplier
// for a fading subset.
// tmpmaterial.Diffuse.a = 1f
// (0x0059cb99) is the
// burnedInStaticLights < 0 &&
// *(render_device+0x7e4) == 0
// branch in RenderMeshSubset;
// the other branch leaves
// diffuse FromVertex. Either
// way the opaque->1 /
// fading->opacity mapping
// still holds.
// D3DPolyRender::SetSurface (0x0059c4d0) texture-stage setup:
// stage 0 colour = MODULATE(TEXTURE, DIFFUSE) = base.rgb * diffuse.rgb
// stage 0 alpha = PREMODULATE(DIFFUSE, DIFFUSE) = diffuseAlpha * detail.a
// stage 1 colour = BLENDCURRENTALPHA(TEXTURE, CURRENT) = lerp(current.rgb, detail.rgb, stage0.a)
src.rgb = detail.rgb
src.a = detail.a * diffuseAlpha
depth test = EQUAL opaque; LESS_OR_EQUAL transparent
depth write = preserve base class // ON opaque; OFF transparent
alpha-to-coverage = OFF // detail alpha is blend input
blend op = ADD
source = SRC_ALPHA
destination = ONE_MINUS_SRC_ALPHA
```
The pixel this produces is `lerp(base * diffuse, detail.rgb, detail.a *
diffuseAlpha)` — a blend **toward** the detail colour by
`detail.a * diffuseAlpha`:
```text
result = base * (1 - detail.a * diffuseAlpha) + detail.rgb * (detail.a * diffuseAlpha)
```
`detail.a * diffuseAlpha == 0` is an exact no-op (fully-transparent detail
texel, or a translucency fade that has reached zero). At `detail.a *
diffuseAlpha == 1` the result is exactly the detail colour. There is no
"neutral gray" point — this is a lerp, not the fallback's multiplicative
`1 + fade * (detail.rgb - detail.a)` factor.
### Built-mesh material coverage and order
The land-polygon `SurfaceType & 4` exclusion does **not** narrow this built-mesh
port. Named-retail `RenderDeviceD3D::DrawEnvCell` (`0x0059f170`) and
`DrawBuilding` (`0x0059f2a0`) install `curr_detail_surface` before calling
`D3DPolyRender::DrawMesh`. `DrawMesh` (`0x0059d4a0`) bypasses delayed-alpha
queuing while that surface is installed and passes detail enabled to
`RenderMeshSubset` (`0x0059ca10`) for each material subset. The fallback then
redraws that exact subset with the detail surface before proceeding. Therefore
ClipMap, straight-alpha, additive, and inverse-alpha built-mesh subsets are
included alongside plain opaque ones.
The Vulkan port first filters the opaque object command stream to coalesced
runs containing at least one category-1 building instance; nonbuilding-only
commands never reach the detail pipeline. A mixed instanced command remains in
the replay and `mesh_detail` rejects its ordinary instances individually. The
accepted opaque path stays batched, while transparent subsets preserve
immediate base/detail adjacency. Their separate detail pipeline
keeps depth writes disabled, matching the base subset's accepted depth
contract. This prevents another shell/object contribution from being
composited between the base and its detail contribution.
Opaque detail uses depth compare **EQUAL** against the exact geometry just
written by the base pass. Vulkan depth is per sample, so on MSAA ClipMap edges
the detail affects only samples whose base alpha-to-coverage mask wrote depth.
The detail pipeline itself deliberately keeps alpha-to-coverage off: detail
alpha controls `ONE_MINUS_SRC_ALPHA` in the retail blend and is not the base
coverage mask. Transparent bases do not write depth, so their adjacent detail
uses `LESS_OR_EQUAL` with depth writes still off.
One bounded ordering seam is explicit: retail bypasses its delayed-alpha queue
while `curr_detail_surface` is installed, whereas acdream retains its already-
authoritative shared alpha-queue order and inserts the detail draw immediately
after the corresponding base draw. This does not narrow material coverage or
change base coverage/blend/depth behavior; it avoids making the checkbox
reorder the default transparent scene. The connected acceptance matrix must
still exercise overlapping transparent building/EnvCell surfaces.
Also unmodelled: retail's stage-1 OUTPUT alpha — `MODULATE(TEXTURE, CURRENT)`
(`0x0059c549`) — which for a delayed-alpha subset becomes the framebuffer
blend weight the alpha queue composites that subset with. acdream instead
draws the base subset with its own alpha and a second, separately blended
draw weighted by `detail.a * diffuseAlpha` (the pipeline in
`VulkanViewportMapping.BlendFactorsOf(GpuBlendMode.RetailDetail)`). For
opaque subsets this is identical (both reduce to the base's own alpha
gating nothing else downstream); on translucent building/EnvCell subsets it
is a bounded difference in how much the SUBSEQUENT alpha-queue compositing
sees, registered as its own row AP-232 (blend WEIGHT on translucent subsets), distinct from AP-34 (queue ORDER)
row rather than a new one.
## Darkening, not brightening
The earlier version of this note read the two-pass fallback's `DEST_COLOR +
ONE_MINUS_SRC_ALPHA` as retail's blend and reported the findings doc's
**1.177**, **1.204**, and **1.033** framebuffer-multiplier measurements for
the three Dereth textures as intentional retail brightening. VM2 showed that
factor formula belongs to the fallback only, which real hardware does not
run. The single-pass lerp above has the opposite sign: with the live
Dereth building/environment category texture (mean rgb 0.165, mean alpha
0.132) and opaque diffuse (`diffuseAlpha = 1`), the combine is
`≈ 0.868 × base + 0.022` — a mild **darkening** of roughly 10% on mid-tones,
not a brightening. This is retail's actual on-screen behavior on the
hardware the game shipped on; it is not a visual correction and carries no
opt-out. See
[`RetailDetailTextureContract`](../../src/AcDream.App/Rendering/RetailDetailTextureContract.cs)
and the VM2 note for the exact numbers.
## What the reverted experiment got wrong
The experiment described by `c25d6186` was never committed as renderer code;
it was reverted from the worktree with `git checkout`. Its useful failure
record remains in that issue commit. It differed from the verified contract in
five material ways:
- It targeted outdoor landscape, while the live setting enables building and
environment categories and forces landscape off.
- It built a per-terrain-type texture array, while retail selects one
category-scoped surface and scalar tiling for each draw path.
- It used `base * detail * 2` (`MODULATE2X`) instead of retail's framebuffer
blend.
- It assumed 128 gray was neutral; retail neutral is RGB equal to alpha.
- Its acceptance prohibited an overall brightness change, although retail's
measured blend intentionally brightens these textures.
The old OpenGL-specific array/bindless wiring is also not reusable in the
current Vulkan-only RHI.
## Corrected acceptance
- With `BuildingDetailTextures=false`, no detail replay is submitted and the
current base rendering remains unchanged.
- With it `true`, toggling the existing Options checkbox **visibly changes
buildings and interior/EnvCell surfaces** without a restart. The connected
2026-08-21 Facility Hub A/B/A gate applied the real Config checkbox on ->
off -> restored-on and captured the same nearby walls/floor after each
transition. Static right-wall mean absolute RGB error was 2.132 for on/off
versus 0.007 for original-on/restored-on; the floor row was 3.385 versus
0.013. The persisted setting was observed false during B, restored true,
and the session ended with ACE-confirmed graceful logout.
- Outdoor terrain, ordinary scenery/objects, creatures, and players do not
gain this overlay.
- Every built building/EnvCell material subset is eligible: opaque, ClipMap,
straight alpha, additive, and inverse alpha. Transparent base/detail draws
remain adjacent in acdream's authoritative shared alpha order.
- Opaque object replay submits only command runs containing a building; mixed
commands are filtered per instance. Depth equality inherits the base pass's
per-sample ClipMap coverage without applying A2C to detail alpha.
- There is no distance fade (VM1, VM2): `noFadeDetail` gates
`get_alpha_for_z` to the immediate-polygon path only, which built meshes
never reach. Attenuation is the sampler's linear mip chain converging to
the texture mean; a building reads the same well past 50 m as it does at
10 m, not a hard step.
- Category source, 256 x 256 size, tiling 4, repeat addressing, and linear mip
sampling match the measured Dereth data.
- The retail single-pass combine darkens the live category texture by
roughly 10% on mid-tones (VM2); this is expected. There is no
`dst=ZERO` correction mode and no brightening two-pass fallback hidden
behind the retail checkbox.
- Physics, collision, walkability, and geometry are untouched.

View file

@ -0,0 +1,650 @@
# Retail chat WINDOW shell — window model, filters, scrollback, chrome
**Date:** 2026-08-21
**Status:** RESEARCH ONLY. No source files touched.
**Scope:** retail's chat window SHELL and DISPLAY behavior — window
management, filtering, scrollback, chrome/interaction, multi-window,
line-composition structure, and other user-visible window mechanics.
**Explicitly out of scope** (covered by sibling research this session):
glyph text-tag coloring, clickable/colored names, tag click dispatch, and
acdream's own current UI code. This document does not re-derive anything
already answered there.
**Primary sources**
- `docs/research/named-retail/acclient_2013_pseudo_c.txt` (Sept 2013 EoR
build, Binary Ninja pseudo-C, PDB-named)
- `docs/research/named-retail/acclient.h` (verbatim retail struct/enum defs)
- `docs/research/named-retail/symbols.json`
**Notes read first so this extends rather than repeats:**
- `docs/research/2026-08-09-chat-retail-window-shell.md` (CH6 shell research
— window lifecycle/identity, LayoutDesc geometry, resize model, opacity,
persistence, multi-window/floaty mechanics). **This document is the
authority for §1 window-lifecycle mechanics, §4 chrome/resize/opacity, and
§5 tabs/multi-window — I only summarize its findings below with pointers,
and add what it doesn't cover:** scrollback/truncation, the exact
window-ID routing predicate as a single decompiled function, structural
line-composition order, and the unseen-text/auto-scroll interaction.
- `docs/research/2026-08-09-chat-retail-color-table.md` §4 (filter storage,
`m_llTextTypeFilter`, `PostInit` seeded defaults) — I summarize and do not
re-derive; I use its findings to cross-check the routing function decoded
fresh below.
- `docs/plans/2026-08-09-chat-parity-campaign.md` — Campaign CH plan/ledger.
**Binary-Ninja caveats (apply throughout, per
`claude-memory/feedback_bn_decomp_field_names.md`):** BN's struct-field
attribution in `ChatInterface::PostInit`/`gmMainChatUI::PostInit` is shifted
by one slot relative to the true member order — the window-shell doc already
documented this for the main window's border elements. I hit the same
artifact in `ChatInterface::PostInit`'s `GetChildRecursive` binding sequence
(§1) and resolve it the same way: against the verbatim struct order in
`acclient.h:54898-54912`, which is authoritative and does not shift.
---
## 1. Window model
### 1.1 How many windows, and how they're identified
Confirmed against `acclient.h:54898-54912` (verbatim `ChatInterface`
struct):
```cpp
/* 6041 */
struct __cppobj ChatInterface : gmNoticeHandler, UIElement_Field
{
unsigned int m_eWindowID;
float m_fDefaultOpacity;
float m_fActiveOpacity;
float m_fCurrentOpacity;
UIElement_Text *m_chatEntry;
UIElement_Text *m_chatLog;
UIElement *m_chatNewNonVisibleTextIndicator;
unsigned __int64 m_llTextTypeFilter;
UIElement_Text *m_pChatTargetButtonText;
PStringBaseArray<unsigned short> m_InputHistory;
unsigned int m_LastInputHistoryPos;
ClientCommunicationSystem *m_pCCS;
};
```
Per the window-shell doc §1.2/§4.1 (not re-derived here): **five** live
chat windows exist — the main window (`m_eWindowID == 8`) and four floating
windows (`m_eWindowID == 2..5`). `m_eWindowID == 0` is the **UNAUTHORED
constructor default** (`ChatInterface::ChatInterface @0x004F4550` sets
`this->m_eWindowID = 0;` before `PostInit` reads the real value off the
LayoutDesc attribute `0x1000007E`). All five windows are authored,
always-resident children of the gameplay-UI root — there is no
runtime-allocated window registry (window-shell doc §1.1).
The SpewBox (`gmSpewBoxUI`) is a **separate, unrelated class** — not a
`ChatInterface` subclass, not part of this window-id space (per
`claude-memory/project_chat_digest.md`).
### 1.2 The wire-to-window routing predicate — one function, load-bearing
`ChatInterface::RecvNotice_DisplayFinalStringInfo @0x004F4640` is the single
function every displayed chat line passes through. Its **head** (the routing
decision, before any text is appended) is:
```
004f4640 void __thiscall ChatInterface::RecvNotice_DisplayFinalStringInfo(
class ChatInterface* this, uint32_t arg2 /*type*/,
class StringInfo const* arg3 /*body*/,
class StringInfo const* arg4 /*prefix*/, uint32_t arg5 /*windowId*/)
004f4640 {
004f4640 uint32_t eax_7 = arg5;
004f4652 if (eax_7 == this->m_eWindowID)
004f4652 {
004f467c label_4f467c:
… (appends — see §3/§6) …
004f4652 }
004f4652 else if ((eax_7 == 0 && ChatInterface::TypeIsActive(this, arg2) != 0))
004f4666 goto label_4f467c;
004f4640 }
```
**The predicate is exactly: `windowId == m_eWindowID` OR (`windowId == 0`
AND `TypeIsActive(type)`).** This is an ADDRESS-vs-BROADCAST model, not a
"windows subscribe to a channel" model:
- A line sent with a **specific windowId** (matching an already-open target
window, e.g. a command whose output is explicitly directed at the window
that issued it — `m_idCurrentCommandSource` per the color-table doc §4)
is shown **only** in that one window, unconditionally — the destination
window's own filter is never consulted for an address-targeted line.
- A line sent with **windowId == 0** ("broadcast") is shown in **every**
window whose own `TypeIsActive(type)` (i.e. its 64-bit
`m_llTextTypeFilter`, decoded in the color-table doc §4) says yes. This is
how the same "Sio says, ..." line can land in the main window and in a
floating window simultaneously if both have `Speech` enabled.
- **Window id 0 is therefore never itself a *window* — it is the broadcast
sentinel value on the wire/call parameter, exactly as the window-shell
doc's goal-window addendum states.** No live `ChatInterface` instance ever
keeps `m_eWindowID == 0` after `PostInit` runs.
`ChatInterface::TypeIsActive @0x004F2F10` (cited, not re-derived, per the
color-table doc §4) is `(1ULL << type) & m_llTextTypeFilter`.
---
## 2. Filters
**Fully decoded already in `2026-08-09-chat-retail-color-table.md` §4 — not
re-derived here.** Summary for completeness of this document's structure:
- Storage: 64-bit `ChatInterface::m_llTextTypeFilter` (`acclient.h:54907`),
read from `PlayerModule::InqChatWindowOption(windowId, 0x1000007F, …)`
(`ChatInterface::UpdateFromPlayerModule @0x004F3920`) and live-updated via
`RecvNotice_GameplayOptionChanged @0x004F30E0`.
- Test: `ChatInterface::TypeIsActive @0x004F2F10` — `(1ULL << type) &
m_llTextTypeFilter`, used both for the broadcast-routing predicate (§1.2)
and, per the color-table doc, nowhere else.
- **`PostInit`'s per-window seeded default** switches on `m_oldState`
(`ChatInterface::PostInit @0x004F3DD0`, `0x004f3df9`): 1 and 8 (the main
window) get `0xFBFFFFFF` low-dword (everything except client-local `0x1A`);
2 (floaty 1) gets Speech/Tell/Speech_Direct_Send/Emote; 3 (floaty 2) gets
Social/Social_Send/Allegiance; 4 (floaty 3) gets Fellowship; 5 (floaty 4)
gets the four Turbine rooms General/Trade/LFG/Roleplay. Every default's
HIGH dword is 0 — Society (`0x20`) and the reserved `0x21` slot start
disabled in **every** window and must be opted into by the user.
- User edit path: `gmChatOptionsUI::InitOptions @0x0049FC60` /
`AddCheckboxBitfield64Option @0x0049EDA0` build one checkbox-grid `SetUserData`
block per window id (main = id 8, with its own dedicated Society checkbox
child at `0x0049FEFB`).
- Squelching is a **separate axis** from filtering:
`LogTextTypeEnumMapper::IsLegalChannel @0x006AFF40` whitelists a 14-value
subset of `LogTextType` as squelchable at all; it has no interaction with
`m_llTextTypeFilter`.
Nothing new to add here beyond what the color-table doc already covers —
the routing predicate decoded fresh in §1.2 above is a second, independent
confirmation of the same "`windowId==0` → filter-gated broadcast" model that
doc's §4 described from `RecvNotice_DisplayFinalStringInfo`'s citation
alone; this document supplies the full decompiled function body.
---
## 3. Scrollback
### 3.1 The cap, the trigger, and the trim target
Still inside `RecvNotice_DisplayFinalStringInfo @0x004F4640`, immediately
after the body append (full excerpt with the append order in §6):
```
004f4701 int32_t m_chatLog_1 = this->m_chatLog;
004f4711 if (*(uint32_t*)(m_chatLog_1 + 0x61c) > 0x2710)
004f4711 {
004f4713 int32_t var_14_4 = 0x1d4c;
004f471a m_chatLog_1 = ChatInterface::TruncateChatLog(this, m_chatLog_1);
004f4711 }
```
`0x2710` = **10,000**, `0x1d4c` = **7,500**. The field read at transcript
offset `+0x61C` tracks the transcript's total **character** count (not a
line count) — retail's scrollback limit is a character budget, not a
fixed number of retained lines. **Trigger: transcript exceeds 10,000
characters. Target: trim back down toward ~7,500.** This runs on every
appended line once the log is over budget — it is not a periodic/timed
sweep, it is inline in the same call that just displayed the line.
### 3.2 The truncation rule — trims at a newline boundary, not mid-line
`ChatInterface::TruncateChatLog @0x004F4290` (arg2 = target length, 7500 at
the only call site found):
```
004f4290 void __fastcall ChatInterface::TruncateChatLog(class ChatInterface* this, uint32_t arg2)
004f4290 {
text = GetText(m_chatLog); // live PStringBase
currentLen = text.length; // *(len_ptr - 4)
004f42c2 if (currentLen <= arg2)
return; // under budget — no-op
004f42c2 else
{
004f42c8 excess = currentLen - arg2; // chars over target
… PStringBaseIter_Common<unsigned short>::FindChar(iter, "\n", 1) …
// search FORWARD from the excess offset for the next '\n'
004f4350 if (found && (excess - foundPos) < (currentLen / 10))
004f4360 BeheadText(m_chatLog, foundPos + 1, 1); // cut at that newline
else {
… FindChar(iter, "\n", 0) … // search again, other direction arg
004f43f0 if (found2 && (foundPos2 - excess) < (currentLen / 10))
goto (the same BeheadText-at-newline path)
004f4403 else
BeheadText(m_chatLog, excess, 1); // fallback: cut at the raw excess offset
}
004f4290 }
```
Reading this at the BN pseudo-C level is genuinely uncertain past the
overall shape — **flagging per the assignment's constraint rather than
guessing**: the `0xCCCCCCCD` multiply + `HIGHD(...) >> 3` pair is the
standard MSVC constant-division-by-10 idiom (`length / 10`), and the two
`FindChar` calls with a `PStringBase(&data_79c288)` needle (confirmed below,
§3.3, to be a single `\n` character) plus `UIElement_Text::BeheadText` are
unambiguous. **UNKNOWN — needs a live cdb capture with real transcript
content to nail down exactly:** whether the two `FindChar` calls search in
opposite directions from the excess offset (my reading above) or whether
one is a fallback re-search after the first's 10%-tolerance check fails for
a different reason; the two `arg3` values passed to `FindChar` (`1` then
`0`) are almost certainly a direction or "case-sensitive/whole-word" flag,
but the pseudo-C never names the parameter. **What is certain and
sufficient to port:** truncation removes text from the FRONT of the
transcript (`BeheadText`), it PREFERS a boundary within the char that
begins the next `\n`-terminated line rather than a raw char-offset cut
(there's a ~10%-of-current-length tolerance band around the target for
preferring the newline-aligned cut), and it falls back to an exact
char-offset behead only if no acceptable newline is found nearby.
### 3.3 The separator character — confirms `\n`, not `\r\n`
```
0079c280 data_79c280: 0d 00 0a 00 00 00 00 00 // L"\r\n" — used elsewhere, NOT here
0079c288 data_79c288: 0a 00 00 00 00 00 00 00 // L"\n" — the separator + the TruncateChatLog needle
```
`data_79c288` is passed both as the inter-line separator string appended in
`RecvNotice_DisplayFinalStringInfo` (§6) and as the `FindChar` needle in
`TruncateChatLog` above — confirming truncation genuinely searches for line
breaks, i.e. it is line-boundary-aware even though the budget itself is
counted in characters.
### 3.4 Auto-scroll / "stick to bottom" — `IsAtVerticalEnd` + `ScrollToPosition`
`UIElement_Text::IsAtVerticalEnd @0x00469350`:
```
00469350 uint8_t __fastcall UIElement_Text::IsAtVerticalEnd(class UIElement_Text* this)
00469350 {
lineCount = this->m_glyphList.m_glyphList._num_elements;
00469359 if (lineCount == 0)
return 1; // empty log counts as "at end"
00469360 lastLineIndex = lineCount - 1;
00469369 return UIElement_Text::IsPositionInView(this, &lastLineIndex);
00469350 }
```
**This is not a scroll-offset comparison — it is "is the last line
currently visible inside the viewport right now."** `IsPositionInView` is
the same hit-test the widget uses for click-to-position, applied to the
transcript's own final line.
`RecvNotice_DisplayFinalStringInfo` captures this **before** appending the
new line, then decides what to do with it **after** appending and
truncating:
```
004f46d3 ebx = UIElement_Text::IsAtVerticalEnd(this->m_chatLog); // BEFORE the new line lands
… append prefix, append body, truncate if over budget (§6, §3.1) …
004f4723 if (ebx != 0)
004f4723 {
004f4732 UIElement_Text::ScrollToPosition(m_chatLog, currentLineCount); // re-stick to the new bottom
004f4739 return;
004f4723 }
004f4723 else
004f473c this->m_chatNewNonVisibleTextIndicator->vtable->SetState(1); // flag "unseen text" instead
```
**So: if the user was already looking at the bottom of the log, retail
scrolls the new line into view (sticky-bottom). If the user had scrolled up
into history, retail does NOT move their scroll position at all — it
instead lights the "new unseen text" indicator.** There is no separate
manual "scroll lock" toggle; this automatic per-line check IS retail's
scroll-lock mechanism. `m_chatNewNonVisibleTextIndicator` is a real
`UIElement*` field (`acclient.h:54906`), bound in `PostInit` from element
id `0x1000048C` — the 16×16 button the window-shell doc's layout dump
already placed at (21,62) in the main window and (5,169) in the floaties,
labeled there "new-unseen-text indicator (Button)" from the authored rect
alone; this document supplies the code that drives it.
### 3.5 Clearing the unseen-text flag
`ChatInterface::ListenToElementMessage @0x004F51C0`, click-message case,
`idElement == 0x1000048c`:
```
004f51f1 if (idElement == 0x1000048c)
004f51f1 {
if (m_chatEntry_or_chatLog != 0) // see field-shift caveat below
004f5208 UIElement_Text::ScrollToPosition(transcript, transcript->lineCount);
004f520d indicator->vtable->SetState(0xd);
}
```
**Field-attribution caveat:** this function's local variable is BN-named
`m_chatEntry` at the point it calls `ScrollToPosition`, but the object it
scrolls is described by `_num_elements` of its own `m_glyphList` — the
transcript's own line count, not the chat-entry input field's. Combined
with the ctor/struct order (§1.1) and the same-class shift already
documented in the window-shell doc for `PostInit`, the operation this
really performs is: **clicking the unseen-text indicator scrolls the
transcript to its own bottom and resets the indicator's own visual state**
(`SetState(0xd)`, a different state than the "flagged" `SetState(1)` set
when new text arrives while scrolled up) — i.e. clicking it is the user's
manual "catch up" action, and it un-flags itself. **Not independently
re-verified via cdb; treat the exact numeric visual STATE values (1 vs
0xd) as confirmed, but the specific field bound to "which object gets
scrolled to bottom" as inferred from the semantics of `IsAtVerticalEnd`
elsewhere, not a literal read of this function's own variable names.**
---
## 4. Window chrome & interaction
**Fully covered by the window-shell doc §1 and §2§4 — not re-derived
here.** Summary pointers:
- **Move/resize:** eight authored `UIElement_Resizebar` (type 9) grips per
window with per-grip bool properties `0x2A`/`0x2B`/`0x2C`/`0x2D`
(bottom/left/right/top); the main window's plain top edge strip is a
`UIElement_Dragbar` (type 2, move-only) rather than a ninth resize grip —
window-shell doc §2.1/§2.3, `UIElement_Resizebar::StartMouseResizing
@0x0046B7E0`.
- **Docking/anchoring:** none found — windows are free-floating, clamped
to stay on-screen only at restore time (`gmFloatyMainChatUI::MoveTo
@0x004D2D10:004d2d53-004d2dbb`).
- **Opacity:** two GLOBAL floats (`Option_DefaultOpacity_Property
0x10000080` unfocused, `Option_ActiveOpacity_Property 0x10000081`
focused), applied to the WHOLE composited window surface including text
via one `SetOpacity` call — window-shell doc §3, `ChatInterface::SetOpacity
@0x004F3120`. Per-class constructed starting values differ (main window
1.0/1.0 always-opaque, floaties 0.5/1.0) until a saved option overrides
them. Retail eases toward the target at 5%-of-delta per tick
(`ChatInterface::ListenToGlobalMessage @0x004F3840`); acdream currently
snaps (AP-190, window-shell doc §3.1).
- **Show/hide:** authored elements toggled via `SetVisible`, driven by
either a keybind (`Alt+1..4` for the floaties) or a generic
registered-action click dispatch — window-shell doc §1.3/§1.4.
- **Persistence:** two independent paths — the per-window `GameplayOptions`
blob (position/size/visible/title, gated on `m_eWindowID != 0`, i.e. the
main window's geometry is NEVER saved this way) and a separate local
screen-layout text file that IS the only path persisting the main
window's geometry — window-shell doc §4.
**One piece of chrome not covered by the window-shell doc — the talk-focus
menu (main window only):**
`gmMainChatUI::InitTalkFocusMenu @0x004CDC50` builds a dropdown menu (from
the button/group pair at elements `0x10000014`/`0x10000015`, window-shell
doc §2.1) with a squelch-toggle entry plus 13 target items, each carrying
an `Enum` attribute `0x1000000B` set to a distinct small integer (`1`
through `0xD`) that records which "talk focus" (broadcast target category)
that menu row represents:
```
004cdcd3 this->m_pSquelchToggleButton = UIElement_Menu::AddTextItem(eax_1, &var_90);
… (13x) …
004cdcfb UIElement::SetAttribute_Enum(eax_3, 0x1000000b, 5);
004cdd07 SmartArray<UIElement_Text *,1>::push_back(&this->m_aTalkFocusButtons, &var_94);
```
`gmMainChatUI::EnableSelection @0x004CE0A0` toggles individual rows'
enabled/greyed state (`SetState(0xd)` when Olthoi-locked); a companion
`RecvNotice_SelectionChanged @0x004CE050` re-syncs the menu's currently
highlighted target whenever the player's WORLD selection changes (via
`ACCWeenieObject::selectedID` and `PublicWeenieDesc::IsTalkable`) — this is
a **world-object selection** feed (F1-click on an NPC), not a transcript
text-tag click, and is out of this document's lane beyond noting that the
main window's talk-focus button exists and is driven from it. Only the
main window has this menu; floaty windows (window-shell doc §2.2) have
neither a talk-focus menu nor a max/min button, only a title bar and close
button.
---
## 5. Tabs / multiple windows
**Fully covered by the window-shell doc §1.1§1.4, §2, §4.1 — not
re-derived here.** Summary:
- There is no "tab" widget. The five windows (§1.1) are five separate,
independently positioned/sized/opaque floating panels, not tabs of one
container.
- **Creation:** none — all five exist from gameplay-UI construction; users
cannot create additional windows. **Naming:** each floaty window has an
editable title (`gmFloatyChatUI::SetWindowTitle @0x004CEAA0`, persisted
option `0x1000008D`) but the SET of windows is fixed at five; there is no
"new chat tab" affordance analogous to modern MMO UIs. **Closing:**
floaty windows close via their own title-bar close button
(`gmFloatyChatUI::ListenToElementMessage @0x004CE330`, element
`0x1000052A`) or the `Alt+N` toggle; the main window cannot be closed at
all (no close button is authored on it — window-shell doc §2.1's element
table has none). **Switching:** there is no focus-cycling shortcut found;
each window is an independent, simultaneously-visible panel, and
"switching" only means moving keyboard focus into a different window's
entry field by clicking it (which is what drives the opacity fade,
§4/window-shell doc §3).
- **Per-window state:** `m_eWindowID`, `m_llTextTypeFilter` (§2),
`DefaultOpacity`/`ActiveOpacity` (global, not per-window — window-shell
doc §3 correction), position/size/visible/title (§4), and the transcript
itself (`m_chatLog`, independently truncated per §3 — each window keeps
its own scrollback, so a floaty showing only Tells has its own 10k/7.5k
character budget separate from the main window's).
- The main window's four indicator buttons (`0x10000522`-`0x10000525`)
mirror the four floaties' visibility as one-directional state indicators,
not a tab strip — window-shell doc §1.4.
---
## 6. Timestamps, prefixes, and line composition order
### 6.1 The two-part composition model — confirmed structurally
`ClientSystem::AddTextToScroll @0x00563C50` is where a body string
(`arg2`), a `LogTextType` (`arg3`), a plugin-hook flag (`arg4`) and a
windowId (`arg5`) become the two `StringInfo` arguments
`RecvNotice_DisplayFinalStringInfo` receives. Its structurally relevant
branch (client-local `0x1A` short-circuits both the timestamp AND the local
log file):
```
00563de6 if (arg3 == 0x1a)
00563de6 {
// build body-only StringInfo, EMPTY prefix StringInfo
00563f2b ECM_UI::SendNotice_DisplayFinalStringInfo(arg3, &bodyOnly, &emptyPrefix, windowId);
00563de6 }
00563de6 else
00563de6 {
00563dfb if (PlayerModule::DisplayTimeStamps(&playerModule) != 0)
00563dfb {
00563e24 wcsftime(&buf, 0x400, u"%#H:%M:%S ", localtime(&now)); // "H:MM:SS " — no date, trailing space
00563e39 PStringBase<unsigned short>::set(&prefixBuffer, &buf);
00563dfb }
… if (s_pLogFile) fprintf(s_pLogFile, "%ls%ls\n", prefixBuffer, bodyBuffer); // §7.4
}
```
**There is exactly ONE structural prefix element: the timestamp, and it is
entirely optional (gated on `PlayerModule::DisplayTimeStamps()`, a
character option toggle backed by `PlayerModule::options2_` bit 6 —
`PlayerModule::DisplayTimeStamps @0x005D39B0`: `return (options2_ >> 6) &
1`).** There is **no separate structural "channel name" prefix element**
(`[Fellowship]`, `[<name>]`, etc.) anywhere in this function or in
`RecvNotice_DisplayFinalStringInfo`. Channel-name brackets that DO appear
in retail's transcript (documented already, by content not structure, in
the color-table doc §3.3's channel-bit table) are baked directly into the
`arg2` body string by the SENDING handler (e.g.
`Handle_Communication__ChannelBroadcast`) before it ever reaches
`AddTextToScroll` — from this function's point of view there are only ever
two composed parts: prefix (timestamp-or-empty) and body.
### 6.2 The append order — separator, then prefix, then body
Back in `RecvNotice_DisplayFinalStringInfo @0x004F4640` (full body, per the
excerpts in §1.2/§3.1/§3.4 stitched together in call order):
```
004f467c if (this->m_chatLog->m_glyphList.m_glyphList._num_elements > 0)
004f4687 {
004f469a UIElement_Text::AppendTextWithFont(this->m_chatLog, L"\n", 0, arg2 /*type*/);
004f467c } // 1. separator (skipped on the very first line)
004f46d3 ebx = UIElement_Text::IsAtVerticalEnd(this->m_chatLog); // captured BEFORE any of the below
004f46dc if (StringInfo::IsValid(arg4, 1) != 0)
004f46e9 UIElement_Text::AppendStringInfoWithFont(this->m_chatLog, arg4 /*prefix*/, 0, 0xc);
// 2. timestamp prefix — ALWAYS color idx 0x0C (grey), only if valid/non-empty
004f46fc UIElement_Text::AppendStringInfoWithFont(this->m_chatLog, arg3 /*body*/, 0, arg2 /*type*/);
// 3. body — colored by the wire LogTextType
```
**Fixed structural order: `[\n if not first line] → [timestamp, if
enabled] → [body]`.** The leading separator is a property of the LOG
(inserted once per new entry, before the entry, so the transcript never
starts with a blank line), not a property of the entry itself — a port that
appends `body + "\n"` per-line instead of `"\n" + body` will still LOOK
identical on screen but will behave differently under `TruncateChatLog`'s
newline-boundary search (§3.2) and under `IsAtVerticalEnd` line-counting
(§3.4) if the two approaches disagree at the very first/last line. The
color assignment itself is the color-table doc's territory (not re-derived
here) — the load-bearing NEW fact this document adds is the *order* and
that the timestamp is unconditionally color index `0x0C` regardless of the
body's own type, which the color-table doc §3.2 already states from the
same address; this document supplies the surrounding append sequence and
confirms the timestamp's StringInfo is `arg4`, always appended strictly
BEFORE the body `arg3`, never interleaved or after.
### 6.3 Timestamp format, verbatim
`u"%#H:%M:%S "` fed to `wcsftime` — hour without a leading zero, minute,
second, **no date**, one trailing space baked into the format string
(explaining why no separate space-insertion code exists between prefix and
body — the prefix string itself carries its own trailing separator).
---
## 7. Other user-visible window behaviors
### 7.1 Local session log file — a port would miss this
`ClientSystem::s_pLogFile` — a plain-text file retail writes chat lines to
during the session, independent of the on-screen transcript's 10k/7.5k
character budget (§3.1) or any window's filter (§2). Written from the same
`AddTextToScroll` branch that builds the on-screen timestamp (§6.1):
```
00563e5b fprintf(ClientSystem::s_pLogFile, "%ls%ls\n", prefixBuffer, bodyBuffer);
```
Client-local type `0x1A` text (§6.1's short-circuit branch) explicitly
bypasses this — client-local errors/refusals never reach the log file,
only the on-screen transcript. **UNKNOWN — needs further grep:** the log
file's path/naming convention and whether it rotates per-session or
per-character; not chased further as it's a filesystem-artifact question
more than a window-UI one, but flagged because "retail also writes a
plain-text chat log to disk" is exactly the kind of behavior a UI-only port
would miss entirely.
### 7.2 Unread/unseen marker — confirmed, see §3.4/§3.5
The `0x1000048C` "new unseen text" indicator button IS retail's unread
marker. It is per-window (each `ChatInterface` owns its own
`m_chatNewNonVisibleTextIndicator`), lights when a broadcast/addressed line
arrives while the user has scrolled away from the bottom, and clears when
the user clicks it (which also snaps the transcript back to its bottom).
There is no separate "flash the window" or "flash the taskbar/app icon" —
`FlashWindow`/`FlashWindowEx` do not appear anywhere in the pseudo-C dump
(checked via a whole-file grep; zero hits).
### 7.3 Sound cues on incoming chat — UNKNOWN, likely none dedicated
A targeted grep for `PlaySound`/`SoundManager::Play*` near the
`Handle_Communication__HearDirectSpeech @0x005715A0` (incoming tell) handler
body found no sound-manager call inside it, and no `Sound_*`-named constant
resembling "tell received" or "chat" turned up in the identifiers swept.
The one chat-adjacent audio-related symbol found is a **global** preference
`Sound_PlaySoundOnlyWhenActive` / `ID_Sound_NoFocusNoSound`
(`UIPreferences::AttachPreference @0x004037E4`,
`SoundManager::PlaySoundInternal @0x0054FEC0` checks
`SoundManager::s_bPlaySoundOnlyWhenActive` against `Device::m_bIsActiveApp`)
— which mutes ALL UI sounds (not specifically chat) when the game window
isn't the active app. **UNKNOWN — needs a deeper sweep or a live cdb
capture on an incoming tell**: this document did not find a chat-specific
sound cue, but a negative grep result over a 66 MB pseudo-C dump is weak
evidence of absence given how many code paths route through indirect
vtable calls the text search can't follow. Flagging rather than asserting
"retail has no tell sound."
### 7.4 Copy/paste and text selection — a base `UIElement_Text` capability
`UIElement_Text::GetSelection @0x00466F20` and `UIElement_Text::SelectAll
@0x004678D0` exist as capabilities of the general text-widget class that
BOTH the chat entry field (`m_chatEntry`) and the read-only transcript
(`m_chatLog`) are instances of (`acclient.h:54904-54905`, both typed
`UIElement_Text*`). `SelectAll`'s call sites found are mostly OTHER
text-entry fields (a character-name box, a stack-size entry box) triggered
by a "select-all-on-first-click" attribute (`UIElement::GetAttribute_Bool(this,
0xd1, ...)` inside `UIElement_Text::MouseDown @0x00469370`), not anything
chat-specific. **UNKNOWN — not independently confirmed for the read-only
transcript specifically:** whether the transcript panel exposes the SAME
click-drag-select-then-copy affordance as the entry field, or whether it is
flagged read-only in a way that suppresses selection; the class-level
capability clearly exists on the type, but no chat-transcript-specific
selection code path was located distinct from the generic `UIElement_Text`
mouse-down handler already cited. Worth a live-client check (select text in
the retail transcript, see if a selection highlight appears) rather than
further static digging.
### 7.5 What's genuinely absent
- No `FlashWindow` anywhere in the binary (§7.2).
- No docking/snapping between chat windows or to screen edges — the
window-shell doc's resize/move research found only free-floating
clamped-on-restore positioning (§4).
- No tab strip / tabbed-window container (§5) — five independent panels,
not a tab model.
- No manual "scroll lock" toggle — the auto-scroll behavior in §3.4 IS the
scroll-lock mechanism, driven automatically by `IsAtVerticalEnd`, with no
user-facing on/off switch found.
---
## Behaviours acdream is most likely missing
Ordered by how load-bearing each gap looks against `RuntimeCommunicationState`
(`docs/research/2026-07-26-slice-j4-1-communication-state.md`) and
`ChatWindowController` as of this session:
1. **Scrollback truncation entirely.** No 10,000-char trigger / ~7,500-char
target / newline-boundary-preferring trim (§3.1§3.3) appears to exist in
acdream today — grep `TruncateChatLog`-equivalent behavior in
`ChatLog`/`ChatWindowController` before assuming an unbounded transcript
is fine; it will diverge from retail under long play sessions (memory
growth) and, more subtly, under the exact wrap point if a port ever needs
pixel/line parity with a retail screenshot at high message volume.
2. **The auto-scroll / stick-to-bottom vs. flag-unseen-instead split
(§3.4§3.5).** This is a genuine behavioral fork, not a cosmetic one: a
naive port that ALWAYS scrolls to bottom on new text will yank the user's
scroll position out from under them mid-read whenever a broadcast line
arrives — exactly the annoyance retail's `IsAtVerticalEnd` check exists to
prevent. Confirm `ChatWindowController` checks "was I at the bottom
before this line landed" before auto-scrolling, and confirm the
`0x1000048C` unseen-indicator element (window-shell doc's layout dump
already has its rect for both window layouts) is wired to light up +
clear via click exactly as §3.4/§3.5 describe.
3. **The window-ID routing predicate as ONE explicit rule (§1.2).** The
color-table doc already flags the routing behavior; this document adds
the exact decompiled shape. Verify `RuntimeCommunicationState`'s chat
windows model (per the CH6c plan in the window-shell doc §6.1) implements
precisely `windowId == m_eWindowID || (windowId == 0 && TypeIsActive)`
not, e.g., "every window with the type enabled shows every line
regardless of address," which would make addressed command-output lines
leak into windows they were never meant for.
4. **Structural composition order (§6.2)** — separator-before-entry (not
after), timestamp-before-body, timestamp always present-or-absent as a
single unit gated on one option bit. A port that concatenates
`timestamp + " " + body` as one string loses retail's separately-colored,
separately-truncatable prefix run and the option-driven all-or-nothing
presence.
5. **The local session chat-log file (§7.1).** Small, but "retail writes a
plain-text transcript to disk every session" is the kind of feature users
notice is missing only when they go looking for it after the fact.
6. **Per-window independent scrollback.** Once §1 is implemented, confirm
each of the five windows truncates its OWN transcript independently
(§5) rather than sharing one global buffer — a floaty window filtered
down to just Tells should never truncate early just because the main
window's transcript is huge.
7. **Sound cues and transcript text-selection are open questions, not
confirmed gaps** (§7.3, §7.4) — do not build negative-result "retail has
none of this" code around them; re-check live if/when they become
relevant.

View file

@ -0,0 +1,349 @@
# Terrain detail, terrain normals, and atmospheric rendering — verified findings
**Date:** 2026-08-21 · **Status:** RESEARCH / HANDOFF — no code written
**Tree:** `main` @ `255b0aae` (branch `claude/git-sync-status-5fb1d2`, level with main)
Everything below was **measured** against the installed DATs, the current
source tree, or the named-retail decomp. It exists so the next session does not
re-derive it. Where a claim was tested and **refuted**, that is recorded too —
those are the expensive ones to rediscover.
---
## 1. The detail-texture overlay is NOT implemented (and the setting lies)
**Renderer:** `terrain_modern.frag` / `.vert` contain **zero** detail
references — no second sampler, no detail UV, no distance-fade term. Tree-wide
there is no `DetailTex` / detail-surface concept in
`src/AcDream.App/Rendering/`. Verified at `255b0aae`, not only at the older
`bb1640f7`.
**But the SETTING exists and is DEAD:**
| Piece | Location |
|---|---|
| `bool BuildingDetailTextures = true` | `AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs:77` |
| persisted as `"buildingDetailTextures"` | `SettingsStore.cs:99` (read), `:677` (write) |
| Options checkbox, retail string `ID_Graphics_BuildingDetailTextures` | `ConfigOptionsPageController.cs:772-774` |
| **consumers in `Rendering/`** | **none** |
The Options window shows "Building Detail Textures", checked by default,
persisting across sessions — wired to nothing. This is worse than not having
the setting: the UI implies a feature that does not exist. **#226's port must
consume this existing setting, not invent a new one**, and its acceptance test
must include "toggling it visibly changes the scene".
Already shipped and NOT to be confused with this: **#155 base tiling**
(`bb5acab9`, ported `TexMerge::CopyAndTile`/`Merge`) — that fixed the
*stretched* look. #155 and #226 were conflated once already.
---
## 2. How retail's detail pass actually works
### Authored data (measured from the installed DATs)
Detail textures are authored **per terrain entry**:
`TerrainTex.DetailTextureId` + `TerrainTex.DetailTexTiling`, reached via
`LandSurf::GetDetailTex` / `GetDetailTiling`.
Region `0x13000000` "Dereth": **33 terrain entries, only 3 distinct detail
textures.**
| Detail texture | RenderSurface | Size | Used by |
|---|---|---|---|
| `0x050012AF` | `0x060037D2` | **64x64** A8R8G8B8 | **29 of 33 (88%)** |
| `0x05001786` | `0x06006D57` | 256x256 A8R8G8B8 | 2 — BarrenRock, LushGrass |
| `0x05001787` | `0x06006D58` | 256x256 A8R8G8B8 | 2 — Grassland, Ice |
`DetailTexTiling` is mostly **1** (4 for grass/rock types, 8 for
FauxWaterRunning, 2 for RoadType) against a base `TexTiling` of 2.
This confirms the community observation that the client "uses the same noise
texture for everything" — one 64x64 texture backs 88% of Dereth's terrain.
### Four categories, and which the setting actually gates
`LScape::SetDetailTexturing` (0x00506b40) manages **four** independent detail
surfaces + tilings: `0 = landscape`, `1 = building`, `2 = environment`,
`3 = object`. Also `GenerateDetailSurface` (0x00506230),
`CleanupDetailSurfaces` (0x00504ae0).
`LScape::ChangeRegion` (0x00506cb0) calls:
```c
SetDetailTexturing(this, 0, EnvDetail, EnvDetail, 0);
// ^landscape=OFF ^building ^environment ^object=OFF
```
gated on the single `Render::m_RenderPrefs.EnvironmentDetailTextures`. **So
retail's setting is a BUILDINGS-AND-INTERIORS setting, not a terrain
setting** — which explains why its UI label is "Building Detail Textures".
`SmartBox::SetDetailTexturing` (0x00451df0) *can* pass landscape through.
**ANSWERED 2026-08-22 (VM2 cdb read):** `Render::m_RenderPrefs.LandscapeDetailTextures`
is a real, separate preference and reads **0** on the live client
(`EnvironmentDetailTextures = 1`); `landscape_detail_surface` is null while
`building_detail_surface`/`environment_detail_surface` are set. Terrain detail
is off by preference, and there is no Options row for it.
### The blend — the FALLBACK path (see the correction below)
`ACRender::SetDetailSurfaceInternal` (0x006b6280):
```c
SetStageTexture(stage, detailTex);
SetSamplerAddressMode(stage, TEXADDRESS_WRAP, TEXADDRESS_WRAP);
SetSamplerFilterMode(stage, TEXFILTER_LINEAR x3);
if (stage == 0) {
SetBlendFunction(curr_detail_src_blend, curr_detail_dst_blend, BLENDOP_ADD);
SetAlphaBlendEnable(1);
SetDepthBufferMode(DEPTHTEST_LESSEQUAL, 1);
}
```
It is a **framebuffer blend, not a DOT3/normal-map path.** Factors differ per
category:
| Path | site | src | dst |
|---|---|---|---|
| environment / interiors | `0059f1c2` | `9` = `BLEND_DSTCOLOR` | `6` = `BLEND_INVSRCALPHA` |
| landscape | `005a19a1` | `5` = `BLEND_SRCALPHA` | `6` = `BLEND_INVSRCALPHA` |
| static default (`.data`) | `0081eca0` | 5 | 6 |
`DSTCOLOR + INVSRCALPHA` under `BLENDOP_ADD` evaluates to
`dest x (detailLum + 1 - alpha)`. Decoded means of the three real textures:
| texture | lum | alpha | factor | effect |
|---|---|---|---|---|
| `0x060037D2` | 0.459 | 0.282 | **1.177** | **+18% brighter** |
| `0x06006D57` | 0.414 | 0.210 | **1.204** | **+20% brighter** |
| `0x06006D58` | 0.165 | 0.132 | **1.033** | **+3% brighter** |
**The FALLBACK detail pass brightens rather than roughens** — and, **CORRECTED
2026-08-22 (VM2/VM4)**, the fallback is not what real hardware runs. The
framebuffer blend above is taken only when `stage == 0`, i.e. when the adapter
cannot advertise `D3DTEXOPCAPS_PREMODULATE`. A live cdb read on the owner's AMD
GPU gave `m_caps.bCanDoSinglePassDetailing = 1` and `trysinglepass = 1`, so
retail uses the single-pass texture-stage path in `D3DPolyRender::SetSurface`
(0x0059c4d0): `lerp(base * diffuse, detail.rgb, detail.a * diffuse.a)` — a mild
blend toward the detail colour (about 10 % on mid-tones with the live category
texture). The community's "reflect more instead of being rougher" describes
the fallback only. Full evidence:
[`2026-08-22-vm2-retail-detail-path-cdb.md`](2026-08-22-vm2-retail-detail-path-cdb.md).
The #226 port was re-done to the single-pass math at Campaign VM VM1.
### Terrain draw path
`ACRender::landPolyDraw`**two overloads**, `0x006b6320` (single-poly) and
`0x006b6760` (two-poly). Detail is gated on
`trysinglepass && m_caps.bCanDoSinglePassDetailing && curr_detail_surface != 0`,
then `SetDetailSurfaceInternal(1)`. Single-pass multitexture, with a
non-single-pass fallback that is **untraced**. Vertex lighting comes from
`ACRender::curLandBlockVertexLighting`.
---
## 3. REFUTED claims — do not re-chase these
- **"client_highres.dat is an override dat and we pick the low-res copy."**
FALSE. Measured twice, including at `255b0aae`: portal **20,684**
RenderSurfaces, highres **2,294**, **overlap 0** — fully disjoint IDs.
`TextureCache` (`Portal.TryGet || HighRes.TryGet`) and
`DatCollectionAdapter.TryResolvePreferred` (Portal then HighRes) both reach
them, so every high-res surface resolves. **We already use them.** The
"Portal precedence is load-bearing" comment in `DatCollectionAdapter` is
vestigial given zero overlap. QUESTION CLOSED.
- **"The detail textures are normal maps."** FALSE. The 64x64 one is
blue-dominant (RGB 0.303/0.469/0.824), which *looks* like a tangent-space
normal map, but unpacking RGB as a vector and measuring length gives
**24.3% / 7.7% / 14.9%** unit-length pixels (a real normal map is ~100%).
They are colour+alpha detail textures.
- **"AC has no normal-map capability at all."** ALSO FALSE — the engine has a
`BumpMap` parameter, the `DotProduct3` texture op, and a hardware capability
probe `m_caps.bTexOpDotProduct3` (read at `0x0059f5c7`). The machinery
exists; the detail textures simply are not normals. What *uses* the bump path
is untraced.
---
## 4. Terrain geometry and normals
- Landblock = **192 m**; 8x8 cells @ **24 m**; 9x9 = **81 height samples**;
2 triangles per cell => **128 triangles per landblock**.
- Heights are **quantised**: each sample indexes a **256-entry**
`region.LandDefs.LandHeightTable`. This caps what smoothing or subdivision
can achieve — they smooth quantisation, they do not recover unsampled detail.
- Diagonal split direction comes from the `FSplitNESW` hash of world cell
coords (constants `0x0CCAC033`, `0x421BE3BD`, `0x6C1AC587`, `0x519B8F25`).
- `src/AcDream.Core/Rendering/Wb/TerrainUtils.cs` `GetNormal` returns the
**flat per-face normal**. **CORRECTED 2026-08-22 (VM4):** that function only
orients procedural scenery (`SceneryGenerator.cs:166`); it never fed the
render normal, and the claim that it produced a faceted look was wrong. The
rendered mesh already used smooth central-difference normals (Phase 3b,
`LandblockMesh`). Retail (`CLandBlockStruct::calc_lighting` 0x00531700)
averages the unit plane normals of each vertex's incident polygons; Campaign
AR's A2 ported that, replacing central differences — a real parity port with
a subtle visual delta (VM0: 1114 % of pixels at mean |Δ| ≈ 11.5).
**Open question, must be answered from the decomp, no guessing:** does retail
smooth terrain vertex normals? Entry points: `ACRender::landPolyDraw` (both
overloads, note `ACRender::curLandBlockVertexLighting`),
`LScape::calc_object_light` (0x00455730), the LScape sunlight/ambient block
~`0x00455b51`, and how a terrain `CPolygon`'s vertex normals/colours are built.
`TerrainUtils` is WorldBuilder-derived, so WB may have simplified — **check
retail, not WB.**
- retail smooths => **parity gap**, schedule like #226.
- retail is faceted => opt-in enhancement, belongs with the shader-pack work.
Smoothing costs **zero geometry change** and therefore **zero physics risk**:
collision, walkability, slope tests and the 4M-cell conformance sweep all see
identical triangles.
**Subdivision is NOT recommended** and the next session should try to refute
rather than confirm that: perceived softness is texture-frequency (§1-2) plus
flat shading (this section), not silhouette resolution; the 256-step
quantisation caps the gain; and it is the only one of the three that touches
the physics contract — those triangles ARE the collision surface (FloorZ /
ValidateWalkable, walkable-polygon tracking, precipice/cliff slide, the 4M-cell
conformance sweep, and the triangle-boundary Z bug that cost five failed fix
attempts). If ever wanted, the only defensible forms are coplanar
surface-preserving subdivision (identical surface, better Gouraud gradients) or
an interpolating spline through the original 81 samples, render-only, with a
registered bounded divergence.
---
## 5. Renderer state relevant to atmospheric work
- **9 shader pairs** (`debug_line`, `mesh_modern`, `particle`, `particle_mesh`,
`portal_depth`, `sky`, `terrain_modern`, `ui_text`, `vk_probe`) plus a
compiled `spv/` directory.
- `mesh_modern` uses **per-vertex Gouraud** lighting deliberately — the A7
comment records that a per-pixel evaluation produced a hard "spotlight pool"
unlike retail's fixed-function T&L. 8-light `SceneLighting` UBO carrying
`uFogParams` / `uFogColor` / `uCameraAndTime`. Two-pass alpha (opaque discard
`<0.95`, translucent discard `>=0.95` and `<0.05`).
- **Sun direction already exists and already tracks the day/night cycle:**
`WorldRenderFrameBuilder.cs:526`
`-SkyStateProvider.SunDirectionFromKeyframe(keyframe)`, supplied as
`LightKind.Directional` (`:531`, `:546`). The sun is a **real drawn object**
(`dayGroup.SkyObjects` via `SkyPesFrameController`).
- **A depth-only pipeline shape already exists:** `portal_depth.vert/.frag`
with `GpuPipelineDescription.ColorWrite = false`
(`Gpu/GpuPipelineDescription.cs:276`, honoured in
`Gpu/Vk/VulkanGpuPipeline.cs:205`) and an empty fragment `main()`. **A shadow
map is that pipeline aimed at the sun.**
- `activeDayGroup` (weather: Clear / Cloudy / Overcast / Rainy) reaches the
frame builder.
- Campaign V (OpenGL -> Vulkan) closed 2026-07-29: pass-based RHI
(`IGpuDevice` / `IGpuFrame` / `IGpuPassEncoder`, explicit
`GpuPassDescription` + `GpuPipelineDescription`) over bindless + MDI.
### Performance baseline and the binding constraint
**519.7 FPS; CPU/GPU p50 1.869 / 1.096 ms** (CLAUDE.md), and the dense-town
profile is **CPU-SUBMISSION-BOUND** (memory:
`feedback_render_perf_measurement`).
**VM0 baseline (2026-08-22, connected, uncapped Release, no automation
observer, `ACDREAM_FRAME_PROF=1`; `2026-08-22-vm0-default-path-invariance.md`):**
| Spot | Binary | CPU p50 / p95 | GPU p50 | alloc KB/frame |
|---|---|---|---|---|
| Holtburg | `6c79d35c` | 4.7 / 5.1 ms | 0.4 | 574 |
| Holtburg | Campaign AR, pack off | 4.1 / 4.4 ms | 0.4 | 21 |
| Arwic (dense) | `6c79d35c` | 6.0 / 6.5 ms | 0.7 | 582 |
| Arwic (dense) | Campaign AR, pack off | 5.2 / 5.6 ms | 0.7 | 29 |
Any figure captured under `ACDREAM_AUTOMATION_ARTIFACT_DIR` carries the
render-scene observer's allocation and is NOT comparable with this table.
Consequences:
- GPU-side **fullscreen** work is nearly free in observed FPS — it fills GPU
idle time. Tier-1 post-processing costs little.
- Anything that adds **CPU submissions** is expensive. **Shadow cascades must
not re-run visibility culling per cascade on the CPU** — draw the full
resident set per cascade, or move culling to a GPU compute pass.
- `glFinish`-style profilers **inflate** GPU timings. Measure with the existing
gates (capped/uncapped, p50/p99 CPU+GPU, pinned dense Arwic).
---
## 6. Wanted work — atmospheric rendering (user-stated)
Opt-in **shader packs**, modelled on Minecraft's Iris/OptiFine: the
retail-faithful path stays the **default and authoritative**; enhanced
rendering is a toggle, exposed through the **plugin API**. This framing is what
resolves the parity tension — the divergence register is untouched because the
faithful path still exists and is still what we test.
| Tier | Contents | Prerequisite |
|---|---|---|
| 1 | bloom, ACES filmic tonemap, colour grade, vignette | none — fullscreen, scales with pixels |
| 1 | screen-space sun rays (crepuscular) | sun screen-pos + occlusion mask; **no shadow maps** |
| 2 | **cascaded directional shadows** — trees, monsters, houses | second scene pass |
| 2+ | volumetric light shafts | reuses the shadow map — nearly free after shadows |
| later | SSAO, water reflections | depth + normals |
| out | true PBR | AC has no per-texture normal/roughness maps |
**Dynamic shadows are an explicit user want** — trees, monsters and buildings
casting real sun shadows — not merely a design exercise.
Design points the user asked for:
- Ray/shaft intensity driven by **AC's own authored weather**
(`activeDayGroup`) rather than invented constants — strong shafts at clear
dawn, muted under Overcast. Combined with the already-authored moving sun
this yields rays that rake low at dawn, vanish at noon, return at dusk.
Retail never had this.
- Composite sun rays **before** tonemapping so bloom picks them up and they
roll off the filmic curve instead of clipping.
### Shadow-specific constraints
1. **Alpha-tested casters.** Foliage is cutout geometry (two-pass alpha), so
the shadow pass **cannot** use `portal_depth`'s empty fragment shader — it
must sample and discard, or every tree casts a solid rectangle.
2. **Animated casters.** Monsters need the same per-part transforms as the main
pass; those already live in the N.5 SSBO, so the shadow vertex shader can
read the same buffer.
3. **Indoors has no sun.** Gate to outdoor cells; dungeon cells use authored
per-cell ambient and must not be fought.
4. **Cascades must be camera-relative**, bounded by the two-tier streaming
window (memory: `reference_two_tier_streaming`), not a fixed world extent.
5. **Depth bias will hit issue #129's bug class** — an NDC-space bias constant
spans `~ b*d^2/near` **metres** of eye depth at distance; #129 leaked
door-shaped holes through hills. Memory:
`feedback_ndc_constants_eye_space_meaning`.
6. **NAMING TRAP:** in AC's codebase "shadow" means the per-cell **physics**
registration list (`CPhysicsObj::add_shadows_to_cells`, `shadow_objects`) —
nothing to do with lighting. Grepping "shadow" drowns in collision hits.
7. Quality scaling for weak hardware: half/quarter-res bloom and rays, fewer
cascades, lower shadow resolution. 4K pays ~4x the tier-1 pixel cost.
---
## 7. Open questions carried forward
1. Does retail smooth terrain vertex normals? (§4) — decides parity-gap vs
enhancement.
2. Who calls `SmartBox::SetDetailTexturing`, and is landscape detail ever
enabled in practice? (§2)
3. What is the non-single-pass detail fallback in `landPolyDraw`? (§2)
4. What uses the `BumpMap` / `DotProduct3` path, if not the detail textures?
(§3)
5. ~~For #226: port retail's brightening blend verbatim, fix it, or expose
both?~~ **ANSWERED 2026-08-22:** neither — the brightening blend was the
fallback; VM1 ported the single-pass lerp that real hardware runs (§2).
## 8. Where this sits
M4 is the active milestone; the next planned implementation work is the
**#268 + TS-8 stat-chain package**, and none of the above should displace it
without the user's say-so. #226 and the terrain-normals question are **parity
gaps** (schedulable now). The shader-pack tiers are **post-M7**, since
rendering phases are frozen until the polish pass — but they are wanted, so the
design should be captured rather than rediscovered.

View file

@ -0,0 +1,81 @@
# Terrain fidelity Track A report
**Date:** 2026-08-21
**Status:** REPORT ACCEPTED BY OWNER DIRECTION; A1/A2 IMPLEMENTED; A3 REJECTED
This report answers Track A from the measured
[`terrain and atmospheric rendering findings`](2026-08-21-terrain-and-atmospheric-rendering-findings.md).
It cites that evidence rather than repeating its measurements, and it does not
reopen the findings' three refuted claims. The project owner subsequently
authorized implementation. No physics or collision behavior changed.
## A1 — #226 detail-texture overlay
The complete source/size/tiling, blend, neutral point, distance units, setting
gate, material-ordering contract, reverted-experiment analysis, and connected
A/B/A evidence are in the
[`#226 retail building/EnvCell detail-texturing port note`](2026-08-21-retail-building-detail-texturing-pseudocode.md).
The report conclusions are:
- The reachable user-visible target is **building shells and interior EnvCell
geometry**, not outdoor terrain. The Options preference caller and
`LScape::ChangeRegion` both install category state `(landscape=0,
building=enabled, environment=enabled, ordinary=0)` through
`SmartBox::SetDetailTexturing`.
- The existing **Building Detail Textures** checkbox is the sole setting gate.
No second option was added. Toggling it now visibly changes the connected
Facility Hub scene without a restart.
- The port keeps retail's `DEST_COLOR + ONE_MINUS_SRC_ALPHA` blend verbatim,
including the measured slight brightening. `dst=ZERO` would be an opt-in
visual correction, not parity; exposing both meanings behind the one retail
checkbox would make that preference ambiguous.
- The reverted experiment targeted landscape, built the wrong texture-array
shape, used `base * detail * 2`, assumed 128 gray was neutral, and rejected
the brightness change that the measured retail blend actually produces.
## A2 — terrain vertex normals
**Verdict: parity gap. Retail smooths shared terrain vertices.**
The decisive named-retail function is
`CLandBlockStruct::calc_lighting` at `0x00531700` in
[`acclient_2013_pseudo_c.txt`](named-retail/acclient_2013_pseudo_c.txt):
1. It zeroes one three-float accumulator for every shared landblock vertex.
2. From `0x00531774` through `0x005317F6`, it walks every terrain polygon and
adds that polygon's plane normal (`CPolygon + 0x20..0x28`) to the
accumulator of each of its three vertex IDs.
3. From `0x00531817` through `0x00531886`, it normalizes every accumulated
vector, falling back to `(0, 0, 1)` only for a degenerate sum.
4. The following sunlight/ambient loop dots those normalized shared-vertex
normals with `LScape::sunlight` and writes per-vertex lighting.
That is incident-face normal averaging. **VM4 correction (2026-08-22):** the
findings doc's premise that `TerrainUtils.GetNormal` produced a faceted render
was wrong — that function only orients procedural scenery
(`SceneryGenerator.cs:166`) and never fed the render normal. The rendered
terrain mesh already had smooth central-difference normals (Phase 3b,
`LandblockMesh`). The real change A2 made is central-difference smoothing →
retail's split-aware incident-face average: a genuine, decomp-verified parity
port, but a subtler visual change than either document implied (VM0 measured
1114 % of pixels at mean |Δ| ≈ 11.5 on the same view).
The approved port is in `LandblockMesh.BuildRetailVertexNormals`. It uses the
same split hash and exact emitted triangle topology, accumulates each
normalized incident face normal at the shared 9 x 9 height-sample vertex, and
normalizes the sum. Tests independently reconstruct the average from emitted
positions/indices and prove every position and index is unchanged.
This is lighting-only parity: the 81 height samples, 128 triangles, split
directions, terrain surface, collision triangles, walkability, and physics
owners are byte-for-byte/topology-equivalent to the prior path.
## A3 — subdivision
**Agree: the standing “not worth doing” recommendation survives.** The
findings §4 already establishes that the 9 x 9 samples are height-table
quantized, so subdivision cannot recover missing terrain detail; changing the
surface would create physics divergence, while coplanar subdivision would only
interpolate a surface whose retail-correct shared-vertex smoothing is now
already present. No subdivision work is scheduled.

View file

@ -0,0 +1,75 @@
# Campaign AR completion audit
**Date:** 2026-08-22
**Branch:** `codex/atmospheric-rendering-campaign`
**Status:** all implementation and machine-local gates complete; not shipped
## Audit boundary
This is the requirement-by-requirement closeout audit for
[Campaign AR](../plans/2026-08-21-atmospheric-rendering.md). It uses the
measured evidence and constraints in the
[terrain and atmospheric findings](2026-08-21-terrain-and-atmospheric-rendering-findings.md)
without re-deriving them or revisiting that report's three refuted claims. The
detailed connected results remain in the
[Stage 2 report](2026-08-22-atmospheric-stage2-connected-gate.md).
The last remote fetch reported `origin/main` at `6c79d35c`; merge `99cf26e0`
is in this branch and `git rev-list --left-right --count HEAD...origin/main`
reported zero missing main commits. Campaign commits remain local and have not
been pushed.
## Requirement ledger
| Requirement | Verdict | Authoritative evidence |
|---|---|---|
| Stage 1 project-owner stop | Pass | The owner's 2026-08-22 “Looks good!” acceptance, after exposure 1.0 → 0.80, is recorded in the [live-gate report](2026-08-22-atmospheric-stage1-live-gate.md). |
| Retail-faithful path stays default and authoritative | Pass (evidence strengthened by VM0, 2026-08-22: exact pack-off pixel identity vs `6c79d35c` on static content and a clean production perf A/B — see `2026-08-22-vm0-default-path-invariance.md`; the no-op oracle named here is a 2x2 recording-device fixture) | The checked-in production no-op oracle, six physical pack-off rows, connected disable/restore transitions, and package-failure rows all pass. A branch diff contains only newly named atmospheric/detail shader sources plus the generated shader manifest; no pre-campaign retail shader source is modified. |
| Physics and collision unchanged | Pass | `git diff origin/main...HEAD -- src/AcDream.Runtime src/AcDream.App/Physics src/AcDream.Core/Physics` is empty. The connected routes use the existing gameplay simulation without changing it. |
| Tier 1 bloom, filmic tone mapping, colour grade, vignette, neutral settings, resize, and retained-UI/private-view isolation | Pass for implementation and available automation/physical rows | Slice 1 tests and the RX 9070 XT 30-row matrix pass; final project-owner image judgment remains below. |
| Tier 1 authored-sun rays | Pass for implementation and connected state transitions | Authored sun/day-group/weather, occlusion-mask, behind-camera/off gates, and pre-tonemap composition are automated; connected time/weather transitions pass. Final physical-display dawn/noon/dusk, occlusion, and edge-flicker judgment remains below. |
| Tier 2 moving authored sun/moon shadows are the headline | Pass for implementation, Stage 1 owner gate, and available connected rows | The selected-source contract, stabilized cascades, opaque/cutout casters, animated transforms, receivers, outdoor/indoor gating, and weather energy pass. Connected metadata proves terrain, outdoor statics, buildings, animated statics, local player, non-player creatures, other dynamics, and equipped children. The distinct-account remote-player row remains below. |
| Headline caster membership without fabricated identity | Pass | Diagnostics report source-owned `OutdoorStatics` and `NonPlayerCreatures` rather than guessing tree or hostile-monster identity. The transition capture published 6,624 casters and the capped route reached 9,533. |
| Low/Medium/High/Auto scaling and weak-hardware fail-safe | Pass on available hardware | The exact RX 9070 XT 30-row matrix passes. The integrated AMD Auto run retires atomically to retail with a visible reason and a 0.00365% paired sky-masked difference. Other physical adapters remain below. |
| Tier 2+ sun-only volumetric shafts | Pass for implementation and available performance/lifecycle rows | Sun-only selection, weather/indoor gates, independent quality, depth reuse, neutral A/B, and the 2,048-sample reference-GPU cost row pass. Final owner occluder/weather judgment remains below. |
| Connected select/disable/re-enable, resize, weather, interior/dungeon, portal, reconnect, and fresh renderer/device recreation | Pass | [`logs/connected-world-gate-20260822-132443/report.json`](../../logs/connected-world-gate-20260822-132443/report.json): three clean graphical processes, graceful exits, 77/77 terminal render-scene shadow parity, zero mismatch, and zero pending deltas. |
| Dense turning/frame-pacing and desktop-performance investigation | Pass | Matched Medium/retail dense-Arwic reports show the reported turning hitch on both paths. Medium does not worsen CPU p95 (32.2 vs 32.9 ms) and adds about 0.70.9 ms GPU work. Pack-owned CPU/GPU/memory measurements stay within Medium budgets. |
| Long-run resource convergence | Pass | [`logs/connected-r6-soak-20260822-134004.report.json`](../../logs/connected-r6-soak-20260822-134004.report.json) completes the 516.064-second nine-stop route with movement/jump/combat, revisits, convergence, and graceful shutdown. Deterministic 12-cycle and fresh-device fixtures also pass. |
| External pack install/select/update/remove/fail/recover | Pass | [`artifacts/atmospheric-rendering/connected-package-lifecycle-20260822-135333/report.json`](../../artifacts/atmospheric-rendering/connected-package-lifecycle-20260822-135333/report.json): all six fresh connected graphical processes pass; failures create no half-active pack and corrected recovery is explicit. |
| Public pack API and SDK | Pass | BCL-only v1 contracts, manifest schema, semantic bindings, compatibility guide, validator, built-in pack, no-op sample, and two Tier-2 samples are present. External projects reference only `AcDream.Plugin.Abstractions`, not App or Vulkan. |
| Shaders and generated artifacts | Pass | Managed regeneration compiles 24/24 Vulkan pairs. The final release gate reports no tracked SPIR-V difference after regeneration. |
| Locked restore, Release build, and complete hermetic tests | Pass | [`artifacts/atmospheric-rendering/stage2-closeout-release-gate-fa3e7978/release-gate-summary.json`](../../artifacts/atmospheric-rendering/stage2-closeout-release-gate-fa3e7978/release-gate-summary.json): clean exact code commit `fa3e7978`, locked restore pass, zero-warning/error Release build, and 15,179/15,179 tests passing with zero skips/failures across 14 assemblies. |
| Campaign documentation and divergence registration | Pass | Campaign plan, SDK docs, Stage 1/2 reports, roadmap pointer, release-gate graph update, IA-24, and this audit are present. Phase identifier remains **Campaign AR**. |
| M4 priority is not displaced | Pass | The roadmap and campaign plan retain M4 as the active gameplay milestone and describe Campaign AR as owner-authorized parallel work. No roadmap milestone is reassigned. |
## Exact remaining external gates
No further renderer, SDK, tool, test, or machine-local documentation work is
known from this audit. Campaign AR remains deliberately **not shipped** until
all three evidence classes below pass:
1. **Distinct-account remote player.** Run
`tools/run-connected-render-pack-remote-player-gate.ps1` with the primary
`ACDREAM_TEST_USER`/`ACDREAM_TEST_PASS` and distinct observer
`ACDREAM_TEST_OBSERVER_USER`/`ACDREAM_TEST_OBSERVER_PASS` environment
variables. Require a nonzero authoritative `RemotePlayers` caster count plus
a visible moving remote-player shadow.
The same-account attempt is recorded in
[`artifacts/atmospheric-rendering/connected-remote-player-20260822-140355/report.json`](../../artifacts/atmospheric-rendering/connected-remote-player-20260822-140355/report.json): the observer entered and moved, then ACE rejected the concurrent primary session. The corrected gate does not serialize credentials.
2. **Unavailable physical adapters.** On every other supported physical Vulkan
adapter, run the source-identical matrix for pack-off, Low, Medium, High,
and Auto at 1920×1080, 2560×1440, and 3840×2160, each capped and uncapped.
That is 30 rows per adapter. The installed RX 9070 XT already passes all 30;
the installed integrated AMD row proves Auto-to-retail fallback only, so
active Low and the other 29 rows on that adapter also remain external.
3. **Final project-owner visual acceptance.** Compare pack off and pack on for
Tier-1 neutral output and private views/UI; sun rays at dawn/noon/dusk,
behind-camera and occluded states; foliage cutouts; moving local, remote,
creature, tree, and building shadows under sun and moon; indoor gating;
source transitions; temporal shimmer/pixelation and cascade/bias artifacts;
sun-only volumetric weather/occluder behavior; and exact pack-off
restoration. The owner must explicitly accept both sides before the plan or
roadmap says shipped.
These are evidence gates, not open implementation slices. Any failure reopens
only the behavior it contradicts; passing them closes the campaign.

View file

@ -0,0 +1,95 @@
# Campaign AR Stage 1 automated gate
**Date:** 2026-08-22
**Verdict:** PASS — every Stage 1 gate that does not require physical visual or
desktop-performance judgment is complete. Those judgments were deliberately
outside this automated report and subsequently passed in the
[Stage 1 live-gate report](2026-08-22-atmospheric-stage1-live-gate.md).
## Scope
This report covers the current authored-celestial implementation: the visible
above-horizon sun, dominant haloed moon, secondary moon, and no-source states;
the selected source's direction-versus-energy handoff; the 336-byte render-pack
shadow ABI; the unchanged authoritative retail path; and local deterministic
performance and lifetime contracts.
It does not itself claim that a physical display proves shadow alignment,
source-transition continuity, temporal pixelation/shimmer quality, or desktop
frame pacing. It did not itself begin Campaign AR Stage 2; the subsequent
project-owner live approval did.
## Results
| Gate | Result |
|---|---|
| Shader compilation | 24/24 Vulkan shader pairs ready |
| Retail shader preservation | all 18 pre-campaign SPIR-V SHA-256 oracles exact; no tracked retail SPIR-V change |
| Focused App renderer validation | 344/344 passed |
| Core sky loader | 14/14 passed |
| SDK and standalone pack validator | 30/30 passed |
| MossTank plugin regression | 48/48 passed |
| Forced locked restore | passed for the complete solution graph |
| Complete Release build after locked restore | passed, 0 warnings, 0 errors |
| Fresh-process hermetic Release gate | 14,928/14,928 passed, 0 skipped, 0 failed, 14 assemblies |
| App assembly inside the complete gate | 5,823/5,823 passed |
The release evidence bundle is
[`artifacts/atmospheric-rendering/stage1-moon-release-gate/`](../../artifacts/atmospheric-rendering/stage1-moon-release-gate/).
Its `release-gate-summary.json`, TRX files, logs, environment inventory, and
`SHA256SUMS.txt` are the machine-readable authority for the fresh-process total.
## Performance and lifetime coverage
The complete App gate includes these deterministic contracts:
- `DirectionalShadowCasterFrameTests.WarmStableFrame_AllocatesZero` builds a
9,500-static-caster scene, warms it, then performs 256 stable frames with
zero managed bytes, no additional scene-index copy, no classification, and
no topology rebuild.
- `DirectionalShadowCasterFrameTests.WarmDenseChangedFrames_AllocateZeroAndReadNoSceneRecords`
proves dense animated-transform refresh stays allocation-free and does not
reread scene records.
- `AtmosphericCpuStageProfilerTests.WarmedObservationAllocatesNothing` and
`AtmosphericGpuTimerSamplingTests.WarmSamplingDecisionsAllocateZero` keep the
measurement path allocation-free after warmup.
- `RenderPackLongCycleConvergenceTests.RepeatedPackResizeFailureGenerationAndFlightCyclesConvergeExactly`
repeatedly crosses Low, Medium, High, retail selection, resize, injected
failure/recovery, both frame-flight slots, render-generation replacement,
and terminal disposal for 12 cycles. Every pack resource, registration,
receiver candidate, transform owner, texture slot, and pipeline-format lease
returns to its exact baseline.
- `RenderPackLongCycleConvergenceTests.DeviceRecreationIsFullRendererTeardownThenANewContextAndDevice`
proves recreation is complete old-renderer/context/device teardown followed
by an independent activation generation on a fresh device.
These are CPU-side and recording-RHI gates. The historical physical AMD rows
remain valid for their exact pre-moon binaries and stated scope, but they are
not reused as current sun-and-moon image-quality or desktop-performance proof.
## Authoritative-path and scope audit
- Shader regeneration expands includes and injects pack-only definitions only
for pack shaders. Unchanged retail sources retain their existing committed
binaries; the source manifest still forces a recompile after a real source
edit.
- The exact pre-campaign retail SPIR-V oracle passes after ordinary shader
regeneration.
- No source file under `src/AcDream.Runtime` changed for this campaign gate.
- No physics or collision source changed.
- No retail GLSL source changed.
- Pack-off production integration continues to require zero enhancement passes,
resources, casters, cascades, draws, or dispatches and its pinned framebuffer
and resource ledger remain exact.
## Pending project-owner gate
When the desktop is healthy, launch the corrected Release client against ACE
and stop for the project owner to judge:
- sun, dominant-moon, and secondary-moon shadow alignment;
- sun-to-moon, moon-to-moon, and no-source transitions;
- temporal pixelation/shimmer during camera and celestial motion; and
- desktop smoothness, frame pacing, and FPS behavior.
Stage 2 and campaign closeout remain gated on that explicit approval.

View file

@ -0,0 +1,46 @@
# Campaign AR Stage 1 live gate
**Date:** 2026-08-22
**Verdict:** PASS — project-owner accepted; Stage 2 authorized
## Scope
This is the physical-display and desktop-performance stop that followed the
[Stage 1 automated gate](2026-08-22-atmospheric-stage1-automated-gate.md). It
records the project owner's live acceptance of the opt-in Atmospheric pack; it
is not final Campaign AR acceptance.
The owner exercised the Vulkan client against the local ACE server through the
Stage 1 correction rounds: visible authored sun and moon shadows, selection and
configuration persistence, temporal texture/shadow shimmer, frame pacing and
desktop responsiveness, world selection, fullscreen, and final exposure. After
the exposure correction the owner reported **“Looks good!”** and directed the
campaign to synchronize with main and proceed autonomously through Stage 2.
The opt-in Atmospheric exposure changed from `1.00` to `0.80`. The retail
renderer remains the default and authoritative path. Physics, collision,
gameplay, and network behavior are unchanged.
## Matched exposure evidence
The final comparison pinned time, day group, sky, weather, MSAA, route, and
camera framing. Its five screenshots and machine-readable metadata are under
[`artifacts/atmospheric-rendering/live-exposure-comparison-exposure080-20260822-125033/`](../../artifacts/atmospheric-rendering/live-exposure-comparison-exposure080-20260822-125033/).
| Scene/preset | Retail mean luminance | Atmospheric mean luminance | Delta | Atmospheric p95 delta | Saturation delta | Clipped pixels |
|---|---:|---:|---:|---:|---:|---:|
| Outdoor / High | 0.1090 | 0.1003 | -8.0% | +6.8% | +29.8% | 0% |
| Interior / High | 0.2711 | 0.2813 | +3.8% | -3.5% | +5.2% | 0% |
| Outdoor / Low | 0.1090 | 0.1011 | -7.2% | +8.4% | +30.1% | 0% |
Before the correction, Atmospheric High measured +19.6% outdoors and +24.5%
indoors. The `0.80` correction removes that overexposure without clipping.
The final outdoor Atmospheric High capture reports 6,613 shadow casters, four
cascades, 95,260,912 resident GPU bytes, and 285 performance samples.
## Acceptance boundary
This gate does not fabricate evidence for a second connected remote player,
portal/reconnect or device-recreation lifecycle, external package flows,
long-run convergence, or unavailable physical GPU classes. Those remain Stage
2/closeout rows, followed by the final project-owner acceptance gate.

View file

@ -0,0 +1,192 @@
# Campaign AR Stage 2 connected and closeout gate
**Date:** 2026-08-22
**Verdict:** PASS for every machine-local Stage 2 row that the available
credentials and hardware can exercise; a distinct-account remote-player row,
external hardware, and final project-owner acceptance remain explicit gates
## Scope and evidence boundary
This report records the executable ACE-connected, dense-scene A/B, long-life,
external-package, shader, build, and complete-test work that followed the
[Stage 1 live approval](2026-08-22-atmospheric-stage1-live-gate.md). It applies
the measured constraints and budgets already established in the
[terrain and atmospheric findings](2026-08-21-terrain-and-atmospheric-rendering-findings.md);
it does not re-derive them or revisit that report's refuted claims.
The measured binaries were clean-source Release products. The retail renderer
remained the default and authoritative path. No physics or collision behavior
was changed.
## Connected scenario matrix
The three-process connected lifecycle gate passed on exact commit `0b6c6557`.
Its report is
[`logs/connected-world-gate-20260822-132443/report.json`](../../logs/connected-world-gate-20260822-132443/report.json).
The capped six-stop process ran for 249.114 seconds, the fresh uncapped reconnect
for 67.917 seconds, and the pack-transition process for 51.277 seconds. All
three exited gracefully with no hard failures. Full graphical-process teardown
and the subsequent fresh Vulkan context/device construction passed.
The route proved:
- capped and uncapped operation, portal travel, reconnect, fresh process, and
destination publication/retirement;
- outdoor, Facility Hub interior/dungeon, and outdoor-after-dungeon states;
- atomic High select, retail disable, High re-enable, 1024 x 768 resize, clear,
overcast, and rain transitions;
- resource-free retail restoration and active enhanced metadata at every named
screenshot; and
- terminal render-scene shadow parity with zero mismatches and zero pending
deltas.
The connected metadata makes the caster boundary concrete. The transition row
published 6,624 casters: 374 terrain commands, 6,571 outdoor statics, 26
buildings, 10 animated statics, one local player, 14 non-player creatures, one
other live dynamic, and one equipped child. The capped login row reached 9,533
casters, including 922 animated statics; Holtburg after the dungeon reached
9,539. Facility Hub correctly published `Outdoor=false`, zero directional
strength, and zero casters. Outdoor DAT publication still cannot distinguish a
tree from other outdoor scenery, and create-object metadata still cannot
distinguish a hostile monster from another non-player creature, so the report
keeps the authoritative `OutdoorStatics` and `NonPlayerCreatures` labels.
One diagnostic distinction is retained rather than hidden: streaming frames
recorded transient render-frame candidate-order differences before the final
candidate sets and digests converged. The authoritative render-scene shadow
comparison finished 77/77 with zero mismatch in the transition process; no
pack or retail-output divergence remained at the terminal checkpoints.
## Dense turning A/B and frame pacing
Matched dense-Arwic runs used the same route, commit `1f75ce05`, resolution,
camera turn, server, character, entity population (15,522), and warmed scene:
- Atmospheric Medium:
[`logs/connected-dense-town-20260822-133441.report.json`](../../logs/connected-dense-town-20260822-133441.report.json)
- retail/off:
[`logs/connected-dense-town-20260822-133727.report.json`](../../logs/connected-dense-town-20260822-133727.report.json)
| Phase | Retail CPU p50 / p95 / p99 | Medium CPU p50 / p95 / p99 | Retail GPU p50 / p95 | Medium GPU p50 / p95 |
|---|---:|---:|---:|---:|
| Six-second turn | 27.8 / 32.9 / 36.7 ms | 28.6 / 32.2 / 40.2 ms | 0.6 / 1.2 ms | 1.5 / 1.9 ms |
| Stationary | 28.6 / 30.2 / 31.1 ms | 29.1 / 30.9 / 32.6 ms | 0.9 / 0.9 ms | 1.7 / 1.8 ms |
**VM4 correction (2026-08-22):** both runs below executed under the connected
automation observer (`ACDREAM_AUTOMATION_ARTIFACT_DIR` render-scene oracle),
which the Slice H closeout documents as allocating 3.910.5 MiB/frame
(`alloc_kb p50 = 9,830` here, 31 Gen0 collections in 216 frames). The absolute
CPU figures are therefore observer cost, not product cost, and this A/B says
nothing about the owner's turning hitch on the product. Clean production
numbers (no observer, uncapped Release) are in the
[VM0 report](2026-08-22-vm0-default-path-invariance.md): dense Arwic CPU p50
6.0 ms pre-campaign vs 5.2 ms pack-off. The paragraph below is kept as the
historical record of the Medium-minus-retail increment, which the observer does
not distort.
With that caveat: Medium did not worsen turn CPU p95 under the observer (32.2
versus 32.9 ms). It adds the expected GPU work, about
0.70.9 ms at p95/p50 in this run. The Atmospheric screenshot's pack-owned
window reports incremental CPU p50/p95/p99 of 0.209/0.248/0.302 ms, inclusive
GPU p50/p95/p99 of 1.374/1.818/1.869 ms, 14,890 casters, three cascades,
61,655,840 resident bytes, and 44,236,800 transient bytes. Medium therefore
stays inside its declared CPU, GPU, and 128 MiB resident budgets.
Process working/private memory was also recorded, as required: the stationary
Medium process was 1,669.9/2,084.4 MiB versus retail's 1,354.8/1,676.8 MiB.
Those process totals include driver and shared host allocations and are not
substituted for the exact pack-owned GPU ledger. Retirement and terminal
convergence are covered by the long route and deterministic lifecycle gates.
## Long lifetime and package lifecycle
The nine-stop Medium route passed on exact commit `1f75ce05`:
[`logs/connected-r6-soak-20260822-134004.report.json`](../../logs/connected-r6-soak-20260822-134004.report.json).
It ran for 516.064 seconds through Caul, Sawato, Rynthid, Aerlinthe, Sawato
revisit, Holtburg, Caul return, Sawato plateau, and Caul plateau. Every
materialization, movement/jump/combat exercise, return/convergence check, and
graceful close passed. The warnings are expected world/DAT population noise;
there are no renderer, pack-budget, or lifecycle failures.
The connected graphical external-package gate passed all six fresh processes
on exact commit `f92479ac`. Its machine-readable report is
[`artifacts/atmospheric-rendering/connected-package-lifecycle-20260822-135333/report.json`](../../artifacts/atmospheric-rendering/connected-package-lifecycle-20260822-135333/report.json).
| Scenario | Plugin outcome | Renderer outcome | Persisted selection |
|---|---|---|---|
| Install/select v1 Low | loaded | external v1 active | external v1 |
| Replace by v2 with stale v1 selection | loaded | safe retail fallback with exact version diagnostic | retail |
| Explicitly select v2 High | loaded | external v2 active | external v2 |
| Remove package | failed-not-found | safe retail fallback | retail |
| Inject failure after registration | failed and transaction rolled back | safe retail fallback | retail |
| Correct package and reselect | loaded | external v2 active | external v2 |
Every process connected, entered world, captured its render-pack metadata,
disconnected, and exited with code zero. Failure rows created no half-active
pack and recovery required an explicit corrected selection.
## Remote-player gate availability
The connected remote-player gate was exercised on exact commit `a861510a`:
[`artifacts/atmospheric-rendering/connected-remote-player-20260822-140355/report.json`](../../artifacts/atmospheric-rendering/connected-remote-player-20260822-140355/report.json).
The observer entered the world and moved at the target location. The concurrent
primary client then timed out waiting for `CharacterList`: ACE did not permit a
second simultaneous character from the same account. Both clients were closed
cleanly, and the failed row is not represented as renderer evidence.
The gate now reads separate observer credentials from
`ACDREAM_TEST_OBSERVER_USER`/`ACDREAM_TEST_OBSERVER_PASS`, rejects same-account
configuration before launch, keeps credentials out of its report, and fails
fast on session failure. Character indexes are independently selectable because
index zero on two accounts still names distinct characters; the entered-world
identity assertion is authoritative. The nonzero `RemotePlayers` caster row and
visible moving remote-player shadow therefore require only access to a second
account; no missing renderer or test-harness implementation remains.
## Final automated gate
On clean exact code commit `fa3e7978`:
- managed shader regeneration compiled **24/24** Vulkan pairs and produced no
tracked SPIR-V difference;
- forced locked restore passed;
- the complete Release solution built with **0 warnings and 0 errors**; and
- the repository-owned fresh-process gate passed **15,179/15,179** tests with
zero skips or failures across 14 assemblies. App contributed **5,951/5,951**.
The complete logs, TRX files, environment inventory, hashes, and aggregate
summary are under
[`artifacts/atmospheric-rendering/stage2-closeout-release-gate-fa3e7978/`](../../artifacts/atmospheric-rendering/stage2-closeout-release-gate-fa3e7978/).
An earlier rerun exposed that the dense-shadow allocation assertion had only a
single warmup frame and could include tiered-JIT work under full-suite parallel
load. Commit `d78ce100` changes that test fixture only: it performs a complete
64-frame dense warm window before measurement. The focused test passed 12
fresh-process repeats, the complete App assembly passed, and the final gate
above is the authority; production renderer behavior was not changed by this
hardening.
## Remaining external gates
Campaign AR is not declared shipped by this report. Three evidence classes are
not available to this machine-local run:
1. A second connected client using a distinct account must produce a nonzero
authoritative `RemotePlayers` caster row and a visible moving remote-player
shadow. The connected gate is implemented; the available single account was
rejected by ACE for concurrent play as recorded above.
2. Supported physical GPU classes other than the installed Radeon RX 9070 XT
and integrated AMD adapter must repeat the required pack-off/Low/Medium/
High/Auto resolution and pacing rows. The integrated adapter currently
proves safe Auto-to-retail fallback, not active Low or the complete matrix.
3. The project owner must later perform and explicitly accept the final
pack-off/pack-on visual matrix, including moving sun and moon shadows,
foliage cutouts, temporal shimmer/cascade seams, indoor gating, rays and
sun-only volumetrics, and retail restoration.
These are evidence gates, not authorization for more renderer features. The
current code, SDK, connected lifecycle, package lifecycle, long lifetime, and
machine-local automation are complete.
The campaign-wide requirement ledger and exact external-row definition are in
the [Campaign AR completion audit](2026-08-22-atmospheric-campaign-completion-audit.md).

View file

@ -0,0 +1,275 @@
# Campaign AR — independent deep review
**Date:** 2026-08-22 · **Reviewer:** Claude (Fable 5), report-only · **Scope:**
`main (6c79d35c) .. c51b07ef` — 378 files, +52,639 / 954 — codex's atmospheric
rendering + terrain fidelity campaign, plus the imported signal-sequencing
commit. Axes: architecture, technical correctness, performance, coverage of
the discussed scope.
Every claim below was checked against the source, the decomp, or a test run
made during this review. Where codex's docs and the evidence disagree, the
evidence is cited.
---
## Verdict
A substantial, technically competent body of work that delivers almost
everything we discussed. The shadow implementation in particular is the
textbook recipe done properly. The architecture is sound and honours the
project's boundaries (Runtime/physics diff is empty, Core changes are additive
metadata, the built-in pack uses the same registry as external packs).
The weaknesses are mostly in **evidence and provenance**, not code:
1. default-path invariance is asserted on a toy oracle, never measured
against the pre-campaign build;
2. the #226 detail-fade constants are invented and presented as retail;
3. the Tier-1 post stack runs in gamma space;
4. several closeout sentences claim more than their artifacts prove.
**Recommendation:** do not merge to main until the two F1 checks below have
run; decide F2; schedule F3/F4 as follow-ups; then the owner visual gate.
---
## Coverage against the discussed scope
| Discussed | Status | Notes |
|---|---|---|
| Dynamic shadows from trees, monsters, houses | **Delivered** | Camera-relative stable CSM, 24 cascades, opaque + alpha-cutout casters, animated casters, terrain + world receivers. Extended to moon shadows (doc records owner approval 2026-08-22). |
| Bloom, ACES tonemap, colour grade, vignette | **Delivered** | Gamma-space caveat (F4). |
| Screen-space sun rays | **Delivered** | Sky-depth occlusion mask, 48-tap radial; sun-only. |
| Volumetric shafts | **Delivered** | Reuses shadow depth; sun-only; no jitter (F8). |
| Rays composited before tonemap | **Delivered** | |
| Ray/shaft intensity from authored weather | **Delivered** | `AtmospherePolicyDeclaration` maps `activeDayGroup` + sun elevation in the pack descriptor. |
| Opt-in, retail default authoritative, via plugin API | **Delivered** | BCL-only `AcDream.Plugin.Abstractions.Rendering`, SDK validator, three samples. |
| #226 detail overlay | **Delivered, with caveats** | Buildings + EnvCells (correct per decomp), through the previously dead checkbox. See F2/F3. |
| Terrain vertex normals (A2) | **Delivered, verified** | Retail `calc_lighting` 0x00531700 port. See F5d for the premise correction. |
| Subdivision (A3) | Rejected | Agree. |
| Tree wind / sway | **Not present** | Raised after codex's brief; not a gap in their work. Still the cheapest item on the list. |
---
## Findings, by severity
### F1 — HIGH (evidence): default-path invariance is unproven
> **CLOSED (VM0, 2026-08-22): PASS.** Exact pixel identity on static content
> vs `6c79d35c` (Holtburg + open field) and a clean production perf A/B with no
> regression — `2026-08-22-vm0-default-path-invariance.md`.
The plan says `NoOpRenderPackProductionIntegrationTests` "pins the
pre-campaign pass list, pipeline set, draw/dispatch tuple, framebuffer
SHA-256". It does — for a **2×2 synthetic framebuffer, one draw call, one
pipeline, on a `RecordingGpuDevice` that does not rasterize**
(`tests/AcDream.App.Tests/Rendering/Packs/NoOpRenderPackProductionIntegrationTests.cs`).
It proves the controller arm adds nothing to a toy composition. It does not
prove the real default frame is unchanged.
Meanwhile the hot default draw path was **refactored, not just extended**:
`WbDrawDispatcher.Rhi.cs` +512/44 replaces the `MeshPipelineSet`,
`BindRingSection`/`WriteRingSection` instance and indirect-command handling
with variant-aware equivalents. `WbDrawDispatcher.cs` +120, `WorldSceneRenderer`
+127, `TerrainAtlas` +187. And the default path legitimately changed in two
places (A2 normals; the detail overlay, which is **on by default**).
`tools/run-offline-pixel-gate.ps1` has a `-Baseline` mode designed for exactly
this ("capture a baseline at the parent commit, then gate the slice"). No
record shows it was run against a `6c79d35c` capture. The six physical
"retail rows" record zero pack *work* and performance, not pixel identity.
**Ask:**
1. Capture `6c79d35c` pack-off with the pixel gate; capture HEAD pack-off with
`BuildingDetailTextures=false`; diff. Expected: differences confined to
terrain shading (A2). Anything outside a terrain mask is a regression.
2. A clean uncapped Release production A/B (no automation observer, no
validation layers) at the CLAUDE.md profile camera: CPU/GPU p50/p99 and
`alloc_kb` pre vs post. The connected dense-town numbers cannot serve (see
F5b).
### F2 — HIGH (process): the #226 fade constants are invented and unregistered
> **Post-review correction (VM1, 2026-08-22):** "invented" was too strong. The
> 10 m → 50 m ramp is a real retail function, `ACRender::get_alpha_for_z`
> (0x006b6230). It is, however, dead for the surfaces #226 targets: retail
> evaluates it only in `D3DPolyRender::DrawPolyInternal` (immediate polygons)
> and only when the static `noFadeDetail` (0x00820e38, initialised 1) is 0;
> built meshes light with `Diffuse.a = 1`. The finding's conclusion (remove
> it; no register row) stands; the provenance claim is corrected.
`RetailDetailTextureContract.FullDetailDistanceMetres = 10f` /
`ZeroDetailDistanceMetres = 50f` drive `mesh_detail.vert:83`
(`clamp((50 z) / 40, 0, 1)`). The pseudocode note presents "full through
10 m; linear 1050 m" as part of the retail contract. **Nothing in retail has
a distance gate on detail:** `RenderDeviceD3D::DrawBuilding` (0x0059f2a0),
`DrawEnvCell` (0x0059f170), `D3DPolyRender::RenderMeshSubset` (0x0059ca10) and
`ACRender::SetDetailSurfaceInternal` (0x006b6280) install surface, tiling,
WRAP, LINEAR×3 and the blend — no viewer-distance term. Retail's distance
attenuation is mip averaging (LINEAR mip filter), which for the live category
texture (mean factor 1.033) converges to near-neutral on its own.
This is a guessed AC-specific constant in a class named `Retail…Contract`,
with TS-52 retired and no replacement register row — a double breach of the
workflow rules. **Ask:** either drop the ramp and rely on mips (retail), or
keep it and file the register row naming it an acdream adaptation.
### F3 — MEDIUM (premise): #226 ports retail's two-pass fallback; which path real hardware ran is undetermined
> **ANSWERED (VM2, 2026-08-22):** `m_caps.bCanDoSinglePassDetailing = 1`,
> `trysinglepass = 1` on the owner's GPU — retail runs the single-pass path.
> The reviewer's "probably the fallback" was wrong. See
> `2026-08-22-vm2-retail-detail-path-cdb.md`; re-ported at VM1.
`SetDetailSurfaceInternal` sets the `DSTCOLOR + INVSRCALPHA` framebuffer blend
**only when `stage == 0`** (the two-pass fallback). With
`m_caps.bCanDoSinglePassDetailing`, `RenderMeshSubset` and `landPolyDraw`
call it with stage 1 and the combine is the texture-stage setup in
`D3DPolyRender::SetSurface` (0x0059c4d0): stage 0 alpha `PREMODULATE`, stage 1
colour `BLENDCURRENTALPHA(texture, current)` — a **lerp toward the detail
colour by alpha**, not a multiply. That capability requires
`D3DTEXOPCAPS_BLENDCURRENTALPHA` and `D3DTEXOPCAPS_PREMODULATE` (0x0059f6c6).
PREMODULATE was rarely advertised by consumer drivers, so the fallback is
*probably* what players saw — but that is a recollection of driver caps, not
evidence. One cdb read settles it:
`dt acclient!RenderDevice::render_device->m_caps` on the PDB-paired binary.
Note this caveat also applies to the reviewer's own earlier "retail's detail
pass brightens" finding, which was derived from the same fallback path.
### F4 — MEDIUM (technical): the Tier-1 post stack runs in gamma space
The main world renders retail's fixed-function, gamma-encoded colours into
`Rgba16Float`. Nothing decodes them: bloom thresholds Rec.709 luma of
gamma values, `acesFitted` (Narkowicz) is applied to gamma values, saturation
and the 0.5 contrast pivot assume linear, and the result is written to the
UNORM swapchain without re-encoding (`atmospheric_filmic.frag`,
`atmospheric_bloom_downsample.frag`; no `pow`/sRGB anywhere in the
atmospheric shaders). The default `exposure = 0.80` is the compensation — ACES
maps gamma-0.5 to 0.62 at exposure 1.0, which is the "too bright" the owner
corrected live.
It is an opt-in look and the owner accepted it, so this is not a blocker.
But the fix is cheap (decode `pow(c, 2.2)` where the world colour is read,
encode at the end) and would make threshold/exposure/contrast behave like
every other tool's controls. Schedule as a slice.
### F5 — MEDIUM (docs): closeout claims exceed their evidence
> **CLOSED (VM4, 2026-08-22):** (a)(d) corrected in place in the AR plan, the
> Stage-2 connected report, the Track A report and the findings doc, each with a
> dated "VM4 correction" note rather than a silent rewrite.
a. "Pins the pre-campaign … framebuffer SHA-256" — see F1.
b. "The user's turning hitch reproduces on the retail path" is drawn from
`connected-dense-town-20260822-133727` (retail/off: CPU p50 **27.8 ms**,
`alloc_kb p50 = 9,830`, 31 Gen0 GCs in 216 frames). The Slice H closeout
already documents that the connected automation observer "intentionally
allocates in proportion to scene size; 3.910.5 MiB/frame outdoor readings
are automation observer cost". Pre-campaign production dense Caul measured
3.0/4.9 ms CPU p50/p95. The A/B is valid for the *Medium-minus-retail
increment* (~0.70.9 ms GPU); it says nothing about the owner's hitch or
about production default-path cost.
c. "14,928/14,928, zero skips" holds only under the hermetic lane filter. A
raw `dotnet test AcDream.slnx` shows 74 skips and 36 failures across five
assemblies; re-run in isolation with the repo filter, all five pass
(7,560 / 0). Fine — but the headline should say "hermetic lanes".
d. The Track A report repeats the findings doc's wrong premise that
`TerrainUtils.GetNormal` produced "the faceted look". That function only
orients scenery (`SceneryGenerator.cs:166`). The rendered mesh already had
smooth central-difference normals (Phase 3b). The real change —
central-difference → retail's split-aware incident-face average — is a
genuine, decomp-verified parity port (0x00531700: sum unit plane normals
per vertex id, normalise, `(0,0,1)` fallback), but a subtler visual change
than either document implies. The reviewer owns the original error.
### F6 — LOW (architecture): the pack API is heavier than what it can express
42 public types in `AcDream.Plugin.Abstractions.Rendering`.
`RenderPassSemantic` is the built-in pack's pass list (BloomDownsample,
BloomBlur×2, SunOcclusion, SunRays, VolumetricShafts, FilmicComposite) plus
`CustomFullscreen`. A third-party pack can swap shaders within the built-in
pipeline and add fullscreen passes; it cannot introduce a new pass kind. That
is a sensible v1 (Iris/OptiFine are fixed programs with named slots) — the
docs should describe it that way rather than as a general framework. The
SPIR-V validator living in the BCL-only abstractions assembly is unusual but
lets the SDK validator run without App/Vulkan references. The built-in pack
registers through `BufferedRenderPackRegistry.Register(descriptor, assets)`
exactly like externals — good.
### F7 — LOW (perf/architecture): the shadow pass keeps its own transform buffer
`DirectionalShadowTransformBufferSet` uploads a second matrix buffer. The
matrices are recomposed from the same `MeshRef.PartTransform × LocalToWorld`
through the same `WbDrawDispatcher.ComposePartWorldMatrix`, so there is no
second pose (constraint 5 honoured in spirit), but there is a second
composition and upload per animated part per frame, and this buffer is where
the 65,536-matrix ceiling bug lived. Measured cost is small (0.110.21 ms CPU
incremental). A future GPU-culling step will want one shared buffer.
### F8 — LOW (quality)
- `atmospheric_volumetric.frag` marches up to 64 steps with no per-pixel
jitter → visible banding at Low/Medium step counts.
- The sun-occlusion mask is "depth ≈ far plane" only; no disc/sky-radiance
weighting, so rays are uniform-coloured.
- `SkyDescLoader.ResolveSortCenter` swallows exceptions to `Vector3.Zero`;
acceptable for enhancement metadata, but note it against the silent-catch
rule. The other 26 added `catch` sites are cleanup-then-rethrow or
convert-to-failure-outcome, consistent with the fail-safe contract.
---
## What is good (specifically)
- **Receiver shader** (`directional_shadow_receiver.glsl`): bias in world
metres (constant + slope-scaled + normal offset), scaled per cascade by
texel density — the #129 NDC-bias trap is handled by construction; true
compare-then-interpolate PCF via `textureGather`; cascade blend band and
terminal reach fade in metres so FOV cannot move seams.
- **Cascade fitter**: bounding-sphere per split, radius quantised to 1/16 m,
centre snapped to the light-space texel grid — stable CSM done right.
- **Caster pass**: per cascade, one terrain MDI plus the opaque/cutout MDI
runs over the **full resident set**; no per-cascade CPU reculling, with
classification counters so the gate is enforced rather than hoped.
Alpha-cutout casters sample and discard. Low uses multiview.
- **Indoor gating**: `IsOutdoor = RenderSky && !CameraInsideCell`.
- **Lifecycle**: off-side candidate preparation, atomic activation, withdrawal
at a frame boundary, no-retry per registration, Auto with 180-sample
hysteresis and a visible reason string. Device loss is correctly treated as
terminal to the device lifetime.
- **Boundaries**: `git diff main..HEAD -- src/AcDream.Runtime src/*/Physics`
is empty; Core edits are additive (`TranslucencyFadeManager.Revision`,
`SkyObjectData.AuthoredSortCenter`); Headless references no pack types.
- **#226 blend**: `src = (d.rgb·f, d.a·f)` under `DSTCOLOR + INVSRCALPHA` gives
`dest × (1 + f·(d.rgb d.a))` — exactly neutral at f=0, retail's measured
factor at f=1. The dead `BuildingDetailTextures` checkbox is now live
without a second option.
- **A2**: the decomp claim is correct and the port matches it (unit face
normals summed per shared vertex, per-landblock like retail).
- **Tests**: green under the repo's hermetic filter — 7,560/0 in the five
assemblies re-run in isolation, plus Runtime 1,818, Core 4,877,
UI.Abstractions 884, Content 160, MossTank 48, RenderPackValidator 30.
---
## Performance summary
Reference adapter (RX 9070 XT, 1080p capped, fixed dense 9,498-caster scene):
Low 0.108 ms CPU incr / 0.90 ms GPU incl / 40 MiB; Medium 0.116 / 1.00 / 74;
High 0.121 / 1.24 / 114; volumetric +0.19 ms GPU. Credible and inside the
declared budgets. Caveats: one high-end adapter; the integrated-AMD row proves
only safe fallback; the connected dense-town figures carry the observer tax
(F5b); the default path's own pre/post cost is unmeasured (F1).
---
## Recommended order
1. F1 — run both checks (pixel gate vs `6c79d35c`; clean production A/B).
Blocking for merge.
2. F2 — decide: drop the ramp (retail) or register it. Small.
3. F3 — one cdb read on the retail client. Small; also settles the
reviewer's earlier finding.
4. F4 — linear-light post stack as a follow-up slice.
5. F5 — correct the four sentences in the plan/reports.
6. Owner visual gate per the plan's own final-gate list; then ship.

View file

@ -0,0 +1,200 @@
# Dereth celestial shadow sources
**Date:** 2026-08-22
**Status:** measured retail-DAT and named-retail finding; implementation input
for Campaign AR
**Scope:** identify the Dereth sun/moons and define the opt-in pack's dominant
directional-shadow source. This note does not change the retail rendering path.
## Conclusion
Dereth's Region `0x13000000` consistently authors three moving celestial
meshes across all 20 day groups:
1. `0x01001348` is the sun disk.
2. `0x01001F6A` is the large, haloed moon and is the dominant lunar source.
3. `0x01001F67` is the smaller secondary moon.
Retail does **not** provide a separate lighting colour or intensity for each
mesh. `SkyDesc::GetLighting` produces one interpolated directional vector,
colour, and brightness from `SkyTimeOfDay.DirHeading`, `DirPitch`, `DirColor`,
and `DirBright`. The opt-in atmospheric pack therefore uses the selected
visible celestial mesh only for shadow **direction**. Colour and energy remain
the single AC-authored directional-light values.
The deterministic priority is:
1. visible sun whose transformed centre is above the horizon;
2. visible large/haloed moon whose transformed centre is above the horizon;
3. visible secondary moon whose transformed centre is above the horizon;
4. no directional shadow source.
This is a pack enhancement, not a claim that retail cast real-time moon
shadows.
## Evidence and provenance
The investigation followed the project rendering inventory and used the
already-loaded retail structures rather than inventing another sky model.
Evidence came from:
- `artifacts/atmospheric-rendering/sky-heading-dump/client.log`, especially
lines 40-88 for Sunny day group 0 and the corresponding repeated entries for
all later day groups. The dump records the three IDs, visibility windows,
angular sweeps, keyframe directional lighting, and the sun surface.
- A read-only `DatCollection.Get<GfxObj>`/`Get<Surface>` probe against the
installed Asheron's Call DATs, using the same inspection path implemented by
`tools/SkyObjectInspect/Program.cs`, for all three `GfxObj` sort centres,
polygon geometry, surfaces, and texture chains.
- `tools/RainMeshProbe/Program.cs` lines 37-49, which names and audits the
celestial surface set independently of the shadow implementation.
- `docs/research/named-retail/acclient_2013_pseudo_c.txt`:
`SkyDesc::GetLighting` at `0x00500a80` (around line 261291),
`SkyDesc::GetSky` at `0x00501ec0` (around line 262761),
`GameSky::CalcFrame` at `0x00506f80` (around line 268650), and
`GameSky::UseTime` at `0x005075b0` (around line 269090).
- `docs/research/2026-04-23-sky-retail-verbatim.md`, especially its recorded
directional-light interpolation and `GameSky::UseTime` material updates.
No fresh decompilation was required. The named-retail corpus already answered
the only question the current code and DAT dump could not answer on their own:
whether a moon mesh contributes a second retail world light. It does not.
## Installed-DAT characterization
The following values were read from the installed Dereth Region and the three
referenced `GfxObj`/surface/texture chains. The same three object IDs, windows,
and sweeps occur in every one of the 20 day groups; only their object index
changes between seven-object and weather-heavy groups.
| Role | GfxObj | Day window | Angular sweep | Authored `SortCenter` |
|---|---:|---:|---:|---:|
| Sun disk | `0x01001348` | `0.1600..0.9400` | `-23 deg..203 deg` | `(1050, 0, 0)` |
| Secondary moon | `0x01001F67` | `0.0400..0.2100` | `-20 deg..190 deg` | `(1909.46, 1874.78, -0.0000157485)` |
| Dominant moon + halo | `0x01001F6A` | `0.0000..0.2300` | `-20 deg..190 deg` | `(2066.82, 552.99, 0)` |
The asset chain establishes the visual identities and the dominant-moon
choice:
| GfxObj | Surface | Surface flags | SurfaceTexture | RenderSurface | Image |
|---:|---:|---|---:|---:|---|
| `0x01001348` | `0x080000D1` | Base1Image, Alpha, Additive | `0x050014CD` | `0x0600388D` | 128x128 `PFID_R8G8B8` sun disk |
| `0x01001F67` | `0x080000D2` | Base1ClipMap | `0x05001A6C` | `0x06003894` | 256x256 `PFID_INDEX16`, palette `0x0400103F` |
| `0x01001F6A` | `0x080000D6` | Base1ClipMap | `0x05001A6D` | `0x06003898` | 256x256 `PFID_INDEX16`, palette `0x0400103F` |
| `0x01001F6A` | `0x080000D7` | Base1Image, Alpha, Additive | `0x05001A6E` | `0x06003899` | 128x128 `PFID_R8G8B8` halo |
Every listed surface has authored `Luminosity=1`, `Diffuse=1`, and
`Translucency=0`. The large moon's primary quad has roughly 2.3 times the
polygon area of the secondary moon before its still larger additive halo is
counted. That makes `0x01001F6A` the unambiguous dominant lunar visual when
both moons are above the horizon.
These installed-DAT facts are characterization evidence, not an ordinary test
dependency. Unit tests use hand-built `DayGroupData` so clean CI and machines
without retail DATs remain deterministic.
## Direction and visibility contract
`SkyObjectData.IsVisible(dayFraction)` owns the normal, always-visible, and
midnight-wrapping window cases. `CurrentAngle(dayFraction)` owns the authored
arc interpolation, including progress through a wrapping window.
The selected direction must match the sky renderer exactly:
```text
heading = active SkyObjectReplace.Rotate
arc = SkyObjectData.CurrentAngle(dayFraction)
model = RotationZ(-heading) * RotationY(-arc)
anchor = effective GfxObj.SortCenter
direction = normalize(TransformNormal(anchor, model))
```
“Effective” means that an active non-zero replacement `GfxObjId` also supplies
its own `SortCenter`. A replacement with `Transparent >= 1` makes the object
ineligible. The replacement lookup follows the renderer's discrete active
keyframe rule; it does not interpolate replacement fields. A zero, non-finite,
or below/on-horizon transformed direction is ineligible.
This deliberately does not substitute `SkyTimeOfDay.DirHeading/DirPitch` for
moon direction. Those values are the one retail world-light direction. The
moon meshes have separate authored arcs, and the enhancement is specifically
intended to align moon shadows with the moon the player can see.
## Authored light contribution
Named retail `SkyDesc::GetLighting` interpolates the two surrounding
`SkyTimeOfDay` records and produces:
```text
sunVector = DirBright * (
cos(DirPitch) * sin(DirHeading),
cos(DirPitch) * cos(DirHeading),
sin(DirPitch))
directionalColor = DirColor * length(sunVector)
```
`length(sunVector)` is `DirBright`. acdream exposes the resulting colour as
`SkyKeyframe.SunColor`. The pack's scalar authored energy is therefore
`clamp(max(SunColor.r, SunColor.g, SunColor.b), 0, 1)`.
By contrast, named retail `GameSky::UseTime` sends a celestial replacement's
`Luminosity`, `MaxBright`, and `Transparent` to the mesh material through
`SetLuminosity`, `SetDiffusion`, and `SetTranslucency`. It does not install a
second directional light. Texture brightness and moon surface luminosity must
not manufacture extra world-light energy.
Weather/day-group reductions, softness, and elevation ramps remain explicit
render-pack policy. They are not mislabelled as measured retail intensities.
## Parity and safety registration
### Retail behavior
- One interpolated directional world-light channel comes from
`SkyTimeOfDay.Dir*`.
- Celestial meshes follow their own visibility windows and transformed arcs.
- Replacement luminosity/diffusion/transparency changes mesh material state,
not the number of world-directional lights.
- Retail does not render the Campaign AR cascaded real-time object shadows.
### Opt-in pack enhancement
- The pack chooses the visible sun or dominant visible moon direction for its
directional shadow map.
- Moon direction follows the rendered moon; energy remains the single
AC-authored directional channel.
- Sun wins any overlap when its transformed centre is above the horizon;
otherwise the haloed moon wins before the secondary moon.
- This deviation belongs in the atmospheric render-pack entry of
`docs/architecture/retail-divergence-register.md`.
### Unchanged boundaries
- The retail rendering path remains the default and authoritative output.
- Pack-off frames do not resolve or render celestial shadow work.
- Existing retail scene lighting remains driven by `SkyStateProvider`; this
policy does not replace it.
- Physics, collision, containment, selection, movement, and DAT geometry are
untouched. The selected source is an immutable one-frame rendering fact.
## Deterministic acceptance coverage
`tests/AcDream.App.Tests/Rendering/Packs/AuthoredCelestialShadowSourceResolverTests.cs`
locks:
- the three verified IDs and priority independent of object-list order;
- sun overlap, dominant-moon fallback, and secondary-moon fallback;
- fully transparent and effective replacement behavior;
- replacement rotation and the exact renderer transform direction;
- no-visible/no-above-horizon suppression;
- midnight-wrapping visibility and angle progress; and
- directional colour-times-brightness energy, including preservation when no
celestial source is available.
The test fixture is entirely hand-built. It neither requires nor silently
substitutes installed retail DAT content.
The complete non-physical verification result, including shader ABI, exact
retail-binary preservation, performance/lifetime fixtures, locked restore,
Release build, and fresh-process totals, is recorded in the
[Campaign AR Stage 1 automated gate report](2026-08-22-atmospheric-stage1-automated-gate.md).

View file

@ -0,0 +1,119 @@
# VM0 — is Campaign AR's pack-off path the pre-campaign renderer?
**Date:** 2026-08-22 · **Campaign:** VM slice VM0 · **Status:** CLOSED — PASS
**Question:** with no render pack selected, does `c51b07ef` (Campaign AR) draw
the same pixels and cost the same as `6c79d35c` (pre-campaign main), outside
the two intended parity changes (A2 terrain normals, #226 building detail)?
**Answer:** yes, to the measured noise floor — and the pack-off path is
faster and allocates less.
## Binaries
| Name | Source | Notes |
|---|---|---|
| `base` | `6c79d35c` | + `evidence/vm0/baseline-configdir.patch` (HEAD's 13-line `ACDREAM_CONFIG_DIR/DATA_DIR/CACHE_DIR` hunk in `ApplicationPathSet`, path resolution only) so it can run from an isolated config |
| `base+normals` | `6c79d35c` + `evidence/vm0/baseline-normals.patch` (only `LandblockMesh.cs` + `TerrainVertex.cs` from the campaign) + the config-dir hunk | isolates A2 so the key comparison needs no terrain mask |
| `HEAD` | `c51b07ef` | `BuildingDetailTextures` off and on |
All Release. Worktrees `.claude/worktrees/vm0-base` and `vm0-base-normals`
were throwaway; the two patches reproduce them.
## Method that finally worked (and the three that did not)
Every run: connected to the local ACE as `+Acdream` (name-selected via
`--session-config`; ACE reorders the roster by last login, so an index is not
stable), **visible normal window** (the product condition), one **isolated
config clone** per variant under `artifacts/vm0/cfg-*` with
`renderPack = retail/off`, pinned `ACDREAM_DAY_GROUP=0`,
`ACDREAM_WORLD_TIME=0.5`, `ACDREAM_SKY_PHASE_SECONDS=0`, `ACDREAM_MSAA_SAMPLES=0`,
1280×720, 12 s settle, one screenshot. Helper:
`tools/vm0/capture-visible.ps1` (the `6c79d35c` pixel-gate script with `-Exe`,
`-Live`, `-ConfigDir`, `-CharacterName`, `-PreCaptureCommand`).
Three earlier attempts produced 8891 % pixel differences that were **all
configuration, none renderer** — recorded so nobody repeats them:
1. **Hidden-window captures vs old-tool captures differed by a 0.952 zoom.**
HEAD's gate writes an isolated `settings.json` with no `fieldOfView`, so
HEAD rendered at the default 90° gameFOV while the old binary read the real
file's `86.33°`. Not a renderer change. Consequence: captures from the new
`run-offline-pixel-gate.ps1` and from the pre-campaign tool are **not
comparable** to each other; each tool is self-consistent.
2. **Connected HEAD frames were brighter with tree shadows.** The real Roaming
`settings.json` still held `renderPack = acdream.atmospheric/low` from the
owner's live gate — the pack was ON. (The reviewer had been reading and
editing a stale `%LOCALAPPDATA%\acdream\settings.json`; the client's
Windows config root is `%APPDATA%\acdream`.) Every HEAD run must pin the
selection explicitly.
3. **A minimized HEAD window rendered brighter than hidden/visible.** Windows
throttles an iconified GLFW surface; the 12 s settle had not completed.
Minimized is not a product condition and is not used.
## Pixel results
Exact per-pixel comparison (`max |ΔRGB|`), no tolerance.
**Open field (logout spot, 60.7S 89.1W, few dynamics):**
| Pair | px differing | notes |
|---|---|---|
| base vs base+normals (A2 only) | 12.7 %, mean Δ 1.5 | terrain shading, low amplitude |
| **base+normals vs HEAD-off (KEY)** | **4.3 %, mean Δ 9** | all of it: idle-pose outline, regenerating mana digits, one ambient flyer crossing the camera — ground, trees and UI clean (`diff-I: HEAD-off vs base+normals (KEY).png`) |
| HEAD-off vs HEAD-on (detail) | 3.3 % | no buildings in view; same noise class |
**Holtburg (`/telepoi Holtburg`, buildings, NPCs, animated lifestone):**
the same binary twice differs in 2535 % of pixels by ±17 (sun/time
sub-steps), so a raw count is meaningless. Pixels with |Δ| ≥ 8 between two
logins of the *same* binary define the dynamic mask (lifestone, NPCs, particle
emitter, radar blips, digits; 9.8 % of the frame after a 6 px dilation). In
the remaining 90.2 %:
| Pair | |Δ| ≥ 8 px in static region | max |
|---|---|---|
| base+normals self (two logins) | **0** | 7 |
| HEAD-off self (two logins) | **0** | 7 |
| **base+normals vs HEAD-off (KEY, run 1)** | **841 (0.10 %)** | 128 |
| **base+normals vs HEAD-off (KEY, run 2)** | **729 (0.09 %)** | 128 |
| base vs base+normals (A2 only) | 7,884 (0.95 %) | 212 |
| HEAD-off vs HEAD-on (detail) | 513 (0.06 %) | 255 |
The 841/729 KEY pixels are streaks inside the animated lifestone crystal that
escaped the mask dilation (`evidence/vm0/holtburg-KEY-static-strong-diff.png`);
buildings, roofs, ground, trees, sky and every UI element are clean. The A2
row is the expected shading change on terrain. The detail row is below the
threshold on building shells because the fallback blend's mean factor is
1.033 (+3 %); VM1's single-pass re-port (10 %) will make it measurable.
Evidence: `docs/research/evidence/vm0/*.png`, full captures under
`artifacts/vm0/` (not committed).
## Production performance (no observer)
`tools/vm0/perf-run.sh`: connected, **uncapped Release**, no
`ACDREAM_AUTOMATION_ARTIFACT_DIR` (so no render-scene observer),
`ACDREAM_FRAME_PROF=1`, same isolated config, 75 s at each spot; last six
5-second `[frame-prof]` windows (`evidence/vm0/perf-frame-prof.txt`):
| Spot | Binary | CPU p50 / p95 / p99 | GPU p50 / p95 | alloc KB/frame | Gen0 per 5 s |
|---|---|---|---|---|---|
| Holtburg | base | 4.7 / 5.1 / 5.5 ms | 0.4 / 0.5 | 574 | 1213 |
| Holtburg | **HEAD off** | **4.1 / 4.4 / 4.8 ms** | 0.4 / 0.5 | **21** | 01 |
| Arwic (dense) | base | 6.0 / 6.5 / 7.0 ms | 0.7 / 0.8 | 582 | 10 |
| Arwic (dense) | **HEAD off** | **5.2 / 5.6 / 6.1 ms** | 0.7 / 0.9 | **29** | 01 |
No regression. The pack-off path is ~13 % cheaper on CPU with ~25× less
managed allocation per frame; GPU unchanged. This also settles review F5b:
the "27.8 ms retail-path CPU" in the Stage-2 connected report was the
automation observer, not the product.
## Verdict
VM0 PASSES. Campaign AR's default path is the pre-campaign renderer plus the
two declared parity changes. The F1 blocker is cleared.
## Carried forward
- The new pixel gate's isolated settings should carry `fieldOfView` (and
`gamma`) so its captures mean the same as the product's — filed for VM7's
tool tidy; not a renderer issue.
- `run-offline-pixel-gate.ps1` gained `-BuildingDetailTextures` (VM0).

View file

@ -0,0 +1,136 @@
# VM2 — which detail-texturing path does retail actually run? (cdb, live)
**Date:** 2026-08-22 · **Campaign:** VM slice VM2 · **Status:** CLOSED — answered
**Method:** read-only cdb attach to the live PDB-paired retail client
(`C:\Turbine\Asheron's Call\acclient.exe`, v11.4186, PDB GUID
`9e847e2f-777c-4bd9-886c-22256bb87f32`, verified `MATCH` by
`tools/pdb-extract/check_exe_pdb.py`), in-world on the owner's AMD GPU.
Scripts: `tools/cdb/vm2-detail-caps.cdb` (+ runner) and
`tools/cdb/vm2-trysinglepass.cdb`. No breakpoints; `qd` at top level.
## The question
Campaign AR's #226 port (`mesh_detail.frag`, pipeline blend
`DSTCOLOR + INVSRCALPHA`) reproduces the path retail takes when
`ACRender::SetDetailSurfaceInternal(0)` is called — the **two-pass
framebuffer fallback**. `RenderMeshSubset` (0x0059ca10) and `landPolyDraw`
(0x006b6320) only take that path when
`trysinglepass == 0 || !m_caps.bCanDoSinglePassDetailing`. Otherwise they
call `SetDetailSurfaceInternal(1)` and the combine is the texture-stage
setup in `D3DPolyRender::SetSurface` (0x0059c4d0). The review (F3) said the
fallback was "probably" what players saw. That was a guess. This settles it.
## The readings (verbatim from the logs)
```
acclient!RenderDevice::render_device->m_caps
+0x008 MaxSimultaneousTextures : 8
+0x00c MaxTextureBlendStages : 8
+0x01c bCanDoSinglePassDetailing : 1 <-- THE ANSWER
+0x01d bTexOpDotProduct3 : 1
+0x01e bTexOpBumpEnvMap : 1
trysinglepass (file-static, decomp 0x00820e40 / 0x00835c04 / 0x00835c10)
00820e40 00000001
00835c04 00000001
00835c10 00000001
acclient!Render::m_RenderPrefs
+0x004 LandscapeDetailTextures : 0
+0x005 EnvironmentDetailTextures : 1
+0x006 MultiPassAlpha : 1
acclient!Render::landscape_detail_surface = 0x00000000 (OFF)
acclient!Render::building_detail_surface = 0x0332e6a8 (ON)
acclient!Render::environment_detail_surface = 0x03416de0 (ON)
acclient!Render::object_detail_surface = 0x00000000 (OFF)
acclient!Render::landscape_detail_tiling = 4
acclient!Render::building_detail_tiling = 4
acclient!Render::environment_detail_tiling = 4
acclient!Render::object_detail_tiling = 4
acclient!Render::curr_detail_src_blend = 5 (static default; unused on this path)
acclient!Render::curr_detail_dst_blend = 6
```
## What this means
**Retail on this hardware uses the single-pass texture-stage path.** The
framebuffer blend that Campaign AR ported — and that the 2026-08-21 findings
doc and the AR review both analysed as "retail's detail pass brightens" —
is the fallback for adapters that cannot advertise
`D3DTEXOPCAPS_PREMODULATE`. A modern AMD driver advertises it. The reviewer's
recollection that consumer drivers rarely did was **wrong**; recorded here so
nobody repeats it.
The single-pass combine, from `D3DPolyRender::SetSurface` with the detail
flag set (args are `(stage, op, arg1, arg2)`; `0 = DIFFUSE`, `1 = CURRENT`,
`2 = TEXTURE`):
```
stage 0 colour = MODULATE(TEXTURE, DIFFUSE) = base.rgb * diffuse.rgb
stage 0 alpha = PREMODULATE(DIFFUSE, DIFFUSE) = diffuse.a * detail.a (premodulate = multiply by the NEXT stage's texture)
stage 1 colour = BLENDCURRENTALPHA(TEXTURE, CURRENT) = detail.rgb * f + current.rgb * (1 - f), f = stage-0 alpha
stage 1 alpha = MODULATE(TEXTURE, CURRENT) = detail.a * current.a
stage 2 DISABLE
```
So the pixel retail actually draws is
```
lerp(base * diffuse, detail.rgb, detail.a * diffuse.a)
```
a **blend toward the detail colour by the detail alpha** — not
`dest × (detail + 1 α)`. With the live category texture `0x06006D58`
(mean rgb 0.165, mean α 0.132) and opaque diffuse (α = 1), the average
effect is `≈ 0.868 × base + 0.022`: a mild **darkening** noise of roughly
10 % on mid-tones. Rougher and darker — the intended look — not brighter.
Consequences:
1. The community remark "they did it backwards, looks like it reflects
more" describes the **fallback** path. On hardware like the owner's it
does not apply.
2. Campaign AR's #226 port reproduces the wrong path for every modern GPU.
It must be re-ported (below). The existing `RetailDetailTextureContract`
"brightening is expected" statement and the findings doc §2 table are
true only of the fallback and must say so (VM4).
3. There is **no distance fade on either path** (VM1 stands): the stage-1
sampler is LINEAR/LINEAR/LINEAR with WRAP; attenuation is the mip chain
converging to the texture mean.
4. `LandscapeDetailTextures` is a real, separate preference, **0** here.
Landscape detail is off by preference, not only by the `ChangeRegion`
literal; there is no Options row for it in the 2013 client. Terrain
detail remains out of #226's scope.
5. `bTexOpDotProduct3 = 1`, `bTexOpBumpEnvMap = 1`: the DOT3 machinery is
available to retail on this hardware, which makes "what uses the BumpMap
path" (findings §7 q4) a live question, still untraced.
## The re-port (folds into VM1; it is small)
Because the lerp is expressible as a framebuffer blend, the existing
separate detail replay pass stays; only its pixel contract changes:
- pipeline blend: `SRCALPHA + INVSRCALPHA`, `ADD` (the pair retail stores
as 5/6) instead of `DSTCOLOR + INVSRCALPHA`;
- `mesh_detail.frag` outputs `vec4(detail.rgb, detail.a * diffuseAlpha)`,
where `diffuseAlpha` is the base subset's vertex/material alpha (1 for
opaque subsets; the translucent subsets already carry it);
- neutral point: `detail.a == 0` (no blend), not `detail.rgb == detail.a`;
- `RetailDetailTextureContract.FramebufferFactor` becomes
`lerp(1, detail.rgb / base, detail.a)` semantics — rewrite the helper and
its tests around `Expected(base, detail, diffuseAlpha)`;
- the 10 m / 50 m fade is deleted as planned (VM1).
A register row is **not** needed for the single-pass port — it is the
retail path. A row IS needed if the fallback is ever exposed (it should
not be).
## Closed / corrected by this note
- Review F3: answered — single-pass. The review's "probably fallback" was
wrong.
- Findings doc `2026-08-21-terrain-and-atmospheric-rendering-findings.md` §2
"retail's own detail pass BRIGHTENS": true for the fallback only; the
hardware path darkens. VM4 amends the doc.
- AR plan + #226 pseudocode note: same amendment.

View file

@ -0,0 +1,119 @@
# Sky default-script (aurora/lightning/thunder) — the retail mechanism, proven
**Date:** 2026-08-23 · **Issues:** #28 (aurora), #2 (lightning, partial), #29 (clouds — not addressed here) · **Phase:** C.1.5c
## The April contradiction, resolved
Two prior research passes (`2026-04-23-sky-pes-wiring.md`, `2026-04-28-pes-pseudocode.md`)
correctly proved `GameSky` never **reads** `CelestialPosition.pes_id`
(`SkyDesc::GetSky @0x00501EC0` writes it at 0x00501FC9; no reader exists), and
issue #2 therefore banned per-SkyObject PES playback "without new decompile
evidence". This document is that evidence. Both were right and both missed the
actual route:
**The sky PES ids ride the sky Setups' own `DefaultScript`, and retail plays
them through the ordinary object default-script machinery — the `pes_id`
column is a dead mirror of the same ids.**
Verified in the installed Dereth DAT: Setup `0x02000714` (aurora carrier,
parts `0x010001EC`) has `DefaultScript = 0x330007DB` — byte-equal to its
SkyObject's `PesObjectId`. Same holds for `0x02000589→0x3300042C` (thunder
ping-pong), `0x02000588→0x33000428` (thunder variant), `0x02000BA6→0x33000453`
(lightning flash).
## The retail chain (named decomp, every link read this session)
1. `GameSky::UseTime @0x005075B0` (30 Hz) → `CRegionDesc::GetSky`
`SkyDesc::GetSky @0x00501EC0` rebuilds the `CelestialPosition` list.
Per-object visibility: **if `begin_time == end_time` the object is always
included**; otherwise `begin <= t <= end` gates the gfx id (out-of-window →
`INVALID_DID`). The aurora object has `begin=end=0.00` in **all 20 day
groups** → always present, all day.
2. `GameSky::CreateDeletePhysicsObjects @0x005073C0`: an existing sky object
is **kept** when its current DataID equals the wanted gfx id AND the
properties word is unchanged AND (`props & 4``LScape::weather_enabled`
did not flip). Only a mismatch destroys/recreates. ⇒ the aurora object —
same id in every group — **persists across day-group changes**, and its
emitters keep their particle population.
3. `GameSky::MakeObject @0x00506EE0`: `props & 4` objects are only created
when `LScape::weather_enabled != 0`; `props & 1` selects `after_sky_cell`
(post-scene) vs `before_sky_cell`. Creation is
`CPhysicsObj::makeObject(gfx_id, 0, 0)`.
4. `CPhysicsObj::makeObject @0x00513970``InitPartArrayObject`: a Setup
with `default_script_id != 0` sets `state |= 0x80000` and registers via
`CPhysics::AddStaticAnimatingObject @0x00509AF0`.
5. `CPhysicsObj::animate_static_object @0x00513DF0` (per tick): state 0x80000
`ScriptManager::UpdateScripts` (starts/advances the default script — the
PES) and updates the object's `ParticleManager`. This is the ~150/min
`CallPES` churn the 2026-04-30 live trace counted (the thunder PES
`0x3300042C` ping-pongs via `CallPES` chains).
## The particle laws confirmed against our port
- `ParticleEmitterInfo::GetRandomOffset @0x005174A0`: random vector, **minus
its projection onto `offset_dir`**, normalized, × rand[min,max] — a disk
PERPENDICULAR to the dir (a shell when dir is zero). Our
`ParticleSystem.RandomOffset` is byte-faithful. The aurora's 450700 m
"Z-dir" offsets are therefore a horizontal RING around the sky-object
origin, not a column above it.
- `Particle::Update @0x0051C290` writes **only the part origin** per type
formula (our `ComputePosition` matches, incl. Swarm cos/sin); only the
GR/LR parabolic variants rotate.
- `ParticleEmitter::SetInfo @0x0051CE90`: parts are ordinary
`CPhysicsPart::makePhysicsPart(hw_gfxobj_id)`; there is no special "2D
particle" draw. `CPhysicsPart::Draw @0x0050D7A0` always feeds
`DrawMesh(gfxobj[deg_level], &draw_pos)`.
- **The facing law**`CPhysicsPart::calc_draw_frame @0x0050DFA0`, driven by
the FIRST degrade entry's mode (`GfxObjDegradeInfo::get_degrade
@0x0051E4B0`; `viewer_heading` = normalized part→viewer from
`UpdateViewerDistance @0x0050E030`):
```
draw = pos
switch deg_mode:
2: Frame::set_vector_heading(draw, viewer_heading) // face viewer, roll-free
3/4/5: Frame::rotate_around_axis_to_vector(draw, X/Y/Z, vh) // cylindrical, one free axis
else: authored orientation (mode 1, mode 0, out of range)
```
Our renderer camera-plane-aligned every "billboard" particle
(`cameraRight`/`cameraUp`); retail faces each part toward the viewer
per-part and honors constrained modes. `CPhysicsPart::Always2D @0x0050D8A0`
(mode != 1) is only consulted for **cell membership**
(`CLandCell::add_all_outside_cells @0x00533360`), not drawing.
- The aurora emitters (`0x32000455/56/57`): BirthratePerSec 10, max 3,
initial 3, lifespans 3300/900/400 s, StartTrans 0.8 → FinalTrans 1.0
(≤20 % opacity fading to nothing), StartScale 78, sprites
`0x01001A61..63` = single ±137.5 m quads whose 64×64 additive textures are
soft glow blobs (row/col profiled — no banding; peak RGB 3671/255). First
degrade mode = **2** on all three. The visible aurora is therefore nine
huge, faint, viewer-facing glows in a slow Swarm drift — a pulse that
re-brightens when a cohort is reborn (~6.7/15/55 min cycles), not a steady
fixture.
## Why the 2026-08-23 experiment looked like "whole-sky tint"
The debug controller (`ACDREAM_ENABLE_SKY_PES=1`) ran under day group 16
(Rainy) at t=0.125 — inside the lightning window (0.030.19) — so the
**lightning-flash and thunder PES** played at the camera anchor alongside the
aurora. A flash sprite at the anchor IS a full-screen additive wash. The
aurora itself was drowned under a faithful-but-wrong-conditions storm.
## Port deltas (this change)
1. Sky default-script playback becomes **production** (no env flag): every
visible sky object whose Setup carries a `DefaultScript` plays it through
`PhysicsScriptRunner`, anchored at the camera (retail sky-cell space is
viewer-centered), pass-routed by `props & 1`.
2. **Persistence contract**: script/emitter state is keyed by
(gfx id, properties) per `CreateDeletePhysicsObjects` — a day-group flip
that keeps the same carrier Setup must NOT restart its emitters.
3. Weather gating: `props & 4` objects follow the weather-enabled state
(acdream's weather system), matching `MakeObject`'s guard.
4. `calc_draw_frame` facing law in the particle renderer: per-sprite degrade
mode picks face-viewer (2), axis-constrained (3/4/5), or authored (else)
orientation — replacing the blanket camera-plane alignment.
Script ids resolve from the **Setup's DefaultScript** (the retail source);
the `PesObjectId` column is only a cross-check.

View file

@ -0,0 +1,145 @@
# VM6 — does the wind move the trees, and only the trees?
**Date:** 2026-08-23 · **Campaign:** VM slice VM6 · **Status:** see the
verdict at the end; the owner's visual gate (plan §VM6 Acceptance) is still
owed regardless.
**Question.** With the built-in `acdream.atmospheric` pack, does the
foliage-wind displacement reach the **production** world geometry, and is it
confined to procedural scenery foliage?
This note records the apparatus that finally answered it, including the
first method that did not — two wrong conclusions were drawn from it before
the confound was found, and both are recorded here so nobody re-derives them.
## Apparatus
Offline pixel gate (`tools/run-offline-pixel-gate.ps1`, Release, `-SkipBuild`,
`-Uncapped`, 1280×720, day group 0 "Sunny" → `WeatherKind.Clear`, day
fraction 0.5, MSAA 0), isolated settings, the fixed offline scene the VM0/VM3
captures use, drawn through `RetailPViewPassExecutor.DrawPackedProductionRoute`
(the production entity route). `-RenderPackPreset high`; `ACDREAM_SKY_PHASE_SECONDS`
pins the pack's per-graph clock so two captures at the same pin see the same
wind phase. Pixel metric: max-channel |Δ| ≥ 8
(`tools/vm6/wind-pixel-proof.py`, numpy).
### Method 1 (confounded — do not reuse): "t0→t3 with wind on, minus t0→t3 with wind off"
The idea was that the clock pin at 0 s vs 3 s moves only the wind, so the
wind-off pair is a control for everything else the clock drives. It is not:
the same binary, same settings, same pin differs run-to-run by **0 or ~280
px**, bimodally, and those pixels are isolated single-pixel flips along the
treeline's leaf edges (`artifacts/vm6amp/churn-crop.png`) — rasterisation
edge flips, not content. Whether the churn lands in the wind pair or the
control pair is luck, so "wind-only" counts of 23 / 314 / 568 / 703 px from
this method on `43e3abed` / `a82959f1` were all within that churn. The
overlay images looked convincing precisely because the churn sits on tree
silhouettes. Two conclusions drawn from it — "the F1 packed-classifier fix
is confirmed by pixels" and "wind reaches the geometry" — were unsupported.
(The F1 fix itself is correct; the round-3 Opus review traced it to the GPU
word by reading, and the round-4 apparatus below confirms geometry motion.)
### Method 2: same pin, wind on vs off, signal amplified, repeats as the floor
Settings `wind-strength = 2`, `wind-lean-metres = 1`, `wind-branch-metres = 1`,
`wind-flutter-metres = 0.5` make the canopy displacement ~1 m — several
pixels even at this scene's treeline distance, where the default Clear wind
(≤ 11 cm lean) is sub-pixel. Two captures per arm; the repeat pairs measure
the floor; the four cross pairs measure the effect.
## Results
### `fccba839` (round 3), `sun-shadow-strength = 0`
| Pair | px ≥ 8 |
|---|---|
| wind-off A vs wind-off B (floor) | 22 |
| amplified-wind A vs amplified-wind B (floor + churn) | 317 |
| amplified A vs wind-off A / B | 65 / 49 |
| amplified B vs wind-off A / B | 284 / 273 |
| robust mask (all four cross pairs, neither repeat pair) | **1** |
A 1 m wind produced nothing. A CPU probe in `ResolveFoliageWind` (removed
after the run) showed the state was right — first advance at serial 2 snaps
to Clear `(0.25, 0.15)`, strength 1, gate on, one graph instance — so the
uniform was correct and the world pass never used it.
**Cause (by reading, round-4 finding):** the wind was welded to "directional
shadows rendered this frame". `DirectionalSunShadowRenderer.Render` left the
frame binding `Disabled` whenever the environment gate said no;
`WbDrawDispatcher.PipelinesFor` then fell back to the plain `mesh_modern`
pipeline, which has no `foliage_wind.glsl` at all; and
`BindDirectionalShadowReceiver` returned early on `!Enabled`. The gate is
`dayGroupPolicy × sunElevationResponse × strength`, so the wind also stopped
every night, at user strength 0 and under the portal cover. Fixed in round 4
by publishing a valid-but-disabled binding (zeroed shadow block, flags 0 —
the receiver shader already returns visibility 1.0 on that bit — plus the
wind frame slice) so the built-in pack's world pass always runs the receiver
pipeline.
### `eec95535` (round 4), `sun-shadow-strength = 0`
| Pair | px ≥ 8 |
|---|---|
| wind-off A vs wind-off B (floor) | 19 |
| amplified-wind A vs amplified-wind B (repeat) | 51 |
| amplified A/B vs wind-off A/B (four cross pairs) | 13,876 / 13,887 / 13,893 / 13,903 |
| **robust mask (all four cross pairs, neither repeat pair)** | **13,854** |
| **default Clear strength vs both wind-off, floor-excluded** | **1,402** (2,655 at |Δ| ≥ 3) |
Per-60-row band of the robust mask: `[8580, 4101, 800, 61, 0, 110, 14, 188, 0, 0, 0, 0]`
the treeline (rows 0180), the hillside trees (rows 300360) and the
shoreline bushes (rows 420480). Overlays:
`evidence/vm6/eec95535-amplified-wind-vs-off-shadows-off.png` (every tree
and bush red; no house, fence, road, lifestone, ground, water or UI pixel),
`evidence/vm6/eec95535-default-clear-wind-vs-off-shadows-off.png`, the
amplified frame itself `evidence/vm6/eec95535-amplified-wind-frame.png`, and
the Method-1 churn crop `evidence/vm6/method1-run-to-run-churn-crop.png`.
### `754d59d9` (round 5 — gated-off frames light from the authored sun again)
The round-4 narrow review found that round 4's decoupling lit every
shadow-gated-off frame from straight overhead: both receiver vertex shaders
take the sun *direction* from the shadow block, and the disabled block's
`(0,0,1)` guard became the sun. Round 5 makes the receiver verts fall back
to the plain pipeline's `uLights` expression when the flag bit is clear.
Same apparatus, wind-off arm compared across binaries (shadows off, same
pin):
| Pair | px ≥ 8 | mean |Δ| |
|---|---|---|
| `754d59d9` wind-off A vs B (floor) | 65 | 0.002 |
| **`754d59d9` vs `fccba839` (plain pipeline, pre-round-4)** | **277** | **0.007** |
| `eec95535` vs `fccba839` (the round-4 regression) | 32,538 | 1.77 |
Wind on `754d59d9`: robust mask **14,993 px** (amplified), same bands as
round 4.
## Verdict
On `754d59d9` the foliage wind reaches the production world geometry with
the shadow term removed (so it is geometry, not shadow), is 700× the
run-to-run floor when amplified and 70× at the default Clear strength, and
is confined to procedural scenery foliage in this scene. Earlier binaries:
`43e3abed` had no geometry wind at all on the production route (round-2
review F1, confirmed by reading); `a82959f1`/`fccba839` had it only while the
shadow gate was open (round-4 defect); `eec95535` had the wind but lit
shadow-gated-off frames from overhead (round-5 defect).
Commands: `tools/run-offline-pixel-gate.ps1 -Out <dir> -SkipBuild
-RenderPackPreset high -SkyPhaseSeconds 3 -Uncapped
-RenderPackSettingOverrides @{ 'wind-enabled'='true'|'false';
'sun-shadow-strength'='0'; [amplified: 'wind-strength'='2';
'wind-lean-metres'='1'; 'wind-branch-metres'='1'; 'wind-flutter-metres'='0.5'] }`
(Release, two captures per arm as above); analysis
`py tools/vm6/wind-pixel-proof.py <root>` (robust mask = AND of the four
cross-pair masks minus both repeat-pair masks; writes `diff-robust-wind.png`).
## What this does and does not prove
- Method 2 with shadows off isolates geometry motion (no shadow term can
change), and the robust mask confines it to where the wind model says it
should be.
- It does not prove the motion *looks* right — amplitude, rhythm and the
Clear/Rain/Storm table are the owner's call at the visual gate — nor does
the offline scene exercise weather other than Clear.

View file

@ -0,0 +1,59 @@
# Campaign VM — VM7 automated closeout
**Date:** 2026-08-23 · **Binary:** `99d5b2a6` (source = binary, gate-verified) ·
**Status:** every automated VM7 row PASSED (the matrix on its second attempt — see #422); the owner's visual gates (VM3,
VM6, VM7 matrix) and the merge to main are OWED and were not attempted.
Every row names its command, lane and artifact (the campaign's closeout rule).
| Row | Command | Lane | Result | Artifact |
|---|---|---|---|---|
| Complete Release gate | `pwsh ./tools/run-release-gate.ps1` | hermetic (54-project graph, locked restore) | **15,283 passed / 0 skipped / 0 failed** across 14 assemblies | `artifacts/release-gate/release-gate-summary.json` |
| Connected lifecycle/reconnect route, pack off | `tools/run-connected-world-lifecycle-gate.ps1 -SkipBuild` | Live (local ACE, `+Acdream`) | **PASS**; warning: 25 expected world-edge landblock misses (capped) | `logs/connected-world-gate-20260823-043414/report.json` |
| Connected lifecycle/reconnect route, High pack | `… -RenderPackPreset high` | Live | **PASS**; same expected warning | `logs/connected-world-gate-20260823-043951/report.json` |
| VM0 invariance on the final binary | `tools/run-offline-pixel-gate.ps1 -RenderPackPreset retail -BuildingDetailTextures $false -Uncapped` for HEAD and `-ExeOverride <base+normals exe>`, two captures each | offline, isolated settings, one tool | **robust diff 510 px** (all in the lifestone band, VM0's known dynamic); whole-frame mean \|Δ\| 0.0075 = the repeat floor | `docs/research/evidence/vm7/1f151242-pack-off-vs-base-normals-robust.png` |
| #422 characterisation | `tools/i422/loop-debugheap.ps1` (24 High + 6 retail-1080p runs under the debug heap), 16 attached runs, `tools/i422/loop-plain.ps1` (10 plain retail-1080p runs, rebuild first) | offline, Release | **1 fault in ~57 runs** — the one hit was the matrix's retail row (pack OFF, first launch after a build); 0/46 under a debugger, 0/10 plain; no WER/event trace → no stack yet. Pack-independent exit-time fault, ~2 %; owner's call whether it blocks the merge | `docs/research/evidence/vm7/i422-*.txt`; `docs/ISSUES.md` #422 |
| AR performance matrix, changed presets | `tools/run-atmospheric-performance-matrix.ps1 -PresetSet retail,low,medium,high -ResolutionSet 1920x1080 -FramePacing uncapped -SkipBuild` | offline, 45 s warm-up | **PASS** (second attempt; see below) | `artifacts/vm7-matrix-2/atmospheric-performance-matrix.md`, copy under `evidence/vm7/` |
The `base+normals` reference is main `6c79d35c` + the two patches recorded at
`docs/research/evidence/vm0/` (config-dir isolation; A2 terrain normals),
built in a throwaway worktree and run through HEAD's gate script — never
the old tool (VM0 §"three methods that did not work", method 1).
## Performance matrix
`tools/run-atmospheric-performance-matrix.ps1 -Out artifacts/vm7-matrix-2 -PresetSet retail,low,medium,high -ResolutionSet 1920x1080 -FramePacing uncapped -SkipBuild` on `fe56b6cf`, RX 9070 XT, 45 s warm-up, 2048-sample windows — **RESULT=PASS**; every pack preset inside its declared ceiling (CPU p50 0.15/0.25/0.35 ms, GPU p50 2/3.25/4.5 ms, resident 64/128/256 MiB). Copy: `evidence/vm7/atmospheric-performance-matrix-fe56b6cf.md`.
| Preset | CPU p50 / p95 / p99 ms | Receiver p50 / p95 / p99 ms | GPU p50 / p95 / p99 ms | Resident MiB | Result |
|---|---:|---:|---:|---:|---|
| retail | 0 / 0 / 0 | 0 / 0 / 0 | 0 / 0 / 0 | 0 | PASS |
| low | 0.125 / 0.14 / 0.163 | 2.577 / 2.664 / 2.92 | 0.921 / 1.025 / 1.029 | 40.219 | PASS |
| medium | 0.134 / 0.154 / 0.189 | 2.557 / 2.661 / 2.964 | 1.033 / 1.06 / 1.17 | 74.09 | PASS |
| high | 0.135 / 0.147 / 0.174 | 2.413 / 2.469 / 2.835 | 1.235 / 1.277 / 1.295 | 114.056 | PASS |
The first attempt (`artifacts/vm7-matrix`, `621a0edf`) FAILED on its retail row: the client exited `-1073740940` (#422) on the first launch after a build — the pack presets of that attempt passed with the same numbers. That occurrence is what re-characterised #422 as pack-independent (see the issue).
## Register, roadmap, memory
- Register: IA-24 amended (the receiver lighting direction follows the
celestial shadow source while shadows render; authored-light fallback when
the gate is closed), IA-25 (foliage wind) and AP-232 (detail blend weight)
stand from their slices; no row retired at VM7.
- Roadmap: Campaign VM entry → "in flight, automated closeout complete;
owner gates and merge owed".
- Memory: `project_visualmaster_campaign.md` (digest + DO-NOT-RETRY) and
`feedback_pixel_diff_needs_repeat_floor.md`.
## What the owner gets to decide
1. **VM3** — the linear-light post stack (brief: plan §VM3 gate brief; the
neutral High preset is pixel-identical to pack-off on static content, the
non-neutral presets are darker and differently toned than before — the
curve table in the brief says by how much).
2. **VM6** — the wind (plan §VM6 Acceptance, six steps; Clear should read
"barely moving, alive", Rain/Storm "clearly windy, still not a flag";
the shadows follow; nothing moves indoors or pack-off).
3. **VM7** — the final pack-off / pack-on matrix, then the merge.
If any pack-on run ends with exit code `-1073740940`, #422 is back: run
`tools/i422/loop-debugheap.ps1` 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,68 @@
# Issue #178 — retail EnvCell shell culling (2026-08-28)
## Outcome
The Phase A8 `CullMode.Landblock -> CullMode.None` workaround is removed
from both production EnvCell draw paths. Constructed cell-shell batches now
use retail's clockwise cull state instead of drawing every ordinary shell
face twice. No PAK rebuild is required: the package already contains the
authored `sides_type` and the correctly expanded index geometry; this change
selects the correct GPU state when that geometry is drawn.
The source and installed-DAT gates are complete. The owner visual gate passed
2026-08-28 (“Ok looks good”) at the requested interior matrix; #178 is closed.
## Retail oracle
The misleading detail is that `DatReaderWriter.Enums.CullMode` is used for
the CellStruct polygon's `sides_type`; its values are not direct GPU cull
states.
- `D3DPolyRender::ConstructMesh @ 0x0059DFA0` expands `sides_type` 0 as the
positive face, type 1 as that face plus a reversed copy, and type 2 as the
positive and negative surfaces. Its polygon fan is `[0, i-1, i]`; the
reversed copy is `[i, i-1, 0]`.
- `D3DPolyRender::RenderMeshSubset @ 0x0059CA10` draws the constructed mesh
with `D3DCULL_CW` on the ordinary path.
- `RenderDeviceD3D::DrawEnvCell @ 0x0059F170` uses that constructed-mesh
route. The immediate-mode exception for a type-1 polygon does not apply to
the EnvCell mesh.
`MeshExtractor.PrepareCellStructMeshData` already reproduces retail's fan and
the required reversed geometry. The render policy therefore must be
clockwise culling for every constructed shell batch; mapping DAT value 0 to
`None` was the obsolete workaround, while mapping it to the generic
`Landblock` render state would cull the opposite side.
## Installed-DAT catalog
`A8CellAudit cell-winding-catalog` scanned the installed DATs:
- 772 environments and 3,168 CellStructs
- 38,189 polygons and 70,091 generated fan triangles
- 37,843 `Landblock(0)` polygons and 346 `None(1)` polygons
- no unknown or unsupported `sides_type` values
- no missing polygon vertices
Vertex-normal orientation was recorded as a diagnostic, not treated as a
contract: CellStruct vertex normals may be smoothed rather than geometric,
and retail submits the identical authored fan.
## Implementation and gates
- `EnvCellRenderer.ResolveRetailCellShellCullMode` documents and enforces the
constructed-mesh policy in both the main and shadow-receiver draw paths.
- Hermetic extraction tests pin the exact type-0 fan and type-1 reversed-face
expansion.
- Renderer tests pin all four source enum values to the retail clockwise
constructed-mesh state.
- Installed-DAT audit: passed.
- Canonical Release gate: 16,321 passed, 0 skipped, 0 failed across 14 test
assemblies; Release build completed with 0 warnings and 0 errors.
## Owner visual acceptance
In Holtburg buildings and the Facility Hub, rotate the camera through walls,
floors, ceilings, ramps, and stairs from their ordinary playable sides.
Nothing should vanish at any camera angle. Acceptance of that matrix closes
#178. The owner accepted this matrix on 2026-08-28 (“Ok looks good”).

View file

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

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