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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
"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>
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>
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>
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>
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>
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>
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.
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.
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>
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>
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>
Item 2: retail's portal-space "In Portal Space..." notice is the SpewBox
(ECM_UI::SendNotice_DisplayStringInfo(0x1A,...) -> AddTextToScroll(str,
0x1A, 1, 0), hardcoded to the SpewBox per the decomp), not a dedicated
centered overlay. PortalWaitNoticeController and its lease are deleted;
PortalTunnelPresentation's per-rotation-segment cadence now writes
straight into RuntimeCommunicationState.AddText(ClientLocal) -- the
SpewBox's own dedupe-at-index-0 handles the repetition exactly as
retail's does. Register row AP-184 records the surface fix and the AP-178
scope extension.
Items 4+5: /help text was partially fabricated -- the user caught the
"/help death" meta-message. Generalized
tools/pdb-extract/sweep_weenie_strings.py to decode narrow
PStringBase<char> literals (the ClientCommunicationSystem::Help* family's
shape) alongside its original UTF-16LE support, then swept every
HelpXxxGroup function's exact byte extent against the PDB-paired
acclient.exe. 4 of 7 group topics (death/status/text/allegiances) are now
complete verbatim listings; the other 3 (channels/chatting/commands) keep
an honest UNVERIFIED note citing HelpStupidChannelHack @0x0056f290 (a
genuinely undecodable BN-mislabeled-fragment mechanism) instead of the
old fabricated sentinel. 7 of ~35 channel one-liners are also now
verbatim. ISSUES.md #364 tracks the remainder;
RetailCommandHelpTableTests.cs pins every result byte-exact.
Item 1: jump-in-air refusal still silent live is NOT reproduced and NOT
speculatively fixed. Exhaustive static re-audit found the mechanism
correct by construction (single-writer OnWalkable, exactly-once-per-frame
Update()/Capture(), no interfering edge-history resets). A live headless
repro (new jump-probe bot policy, real ACE connect) was blocked --
probeaccount2 has no character, and the graphical client already owned
testaccount this session so the task's own fallback rule forbade using
it. Two temporary probes are left behind ACDREAM_PROBE_JUMP=1 (blocked
entirely in Headless by the existing multi-session static-state guard --
graphical-only for the next round).
Item 3 confirmed fixed, no regression. Item 6 (resize: no diagonal
cursors, cannot grow Y from bottom-right) folded into CH6a's existing
scope.
Full Release suite: 12,267 passed / 4 skipped / 0 failed (up from
12,221/4/0).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>